diff --git a/internal/cmd/gmail_autoreply.go b/internal/cmd/gmail_autoreply.go index 2645494..cd1c795 100644 --- a/internal/cmd/gmail_autoreply.go +++ b/internal/cmd/gmail_autoreply.go @@ -12,12 +12,6 @@ import ( "github.com/steipete/gogcli/internal/ui" ) -var autoReplyMetadataHeaders = []string{ - "Message-ID", "Message-Id", "References", "In-Reply-To", - "From", "Reply-To", "To", "Cc", "Date", "Subject", - "Auto-Submitted", "Precedence", "List-Id", "List-Unsubscribe", -} - const autoReplyActionSkipped = "skipped" type GmailAutoReplyCmd struct { @@ -277,7 +271,7 @@ func runGmailAutoReply(ctx context.Context, svc *gmail.Service, account string, func fetchMessageForAutoReply(ctx context.Context, svc *gmail.Service, messageID string) (*gmail.Message, error) { return svc.Users.Messages.Get("me", messageID). Format(gmailFormatMetadata). - MetadataHeaders(autoReplyMetadataHeaders...). + MetadataHeaders(gmailAutoReplyMetadataHeaders...). Context(ctx). Do() } diff --git a/internal/cmd/gmail_get.go b/internal/cmd/gmail_get.go index ebd89dc..531904e 100644 --- a/internal/cmd/gmail_get.go +++ b/internal/cmd/gmail_get.go @@ -54,12 +54,8 @@ func (c *GmailGetCmd) Run(ctx context.Context, flags *RootFlags) error { if format == gmailFormatMetadata { headerList := splitCSV(c.Headers) if len(headerList) == 0 { - headerList = []string{ - "From", "To", "Cc", "Bcc", "Subject", "Date", - "Message-ID", "In-Reply-To", "References", - } - } - if !hasHeaderName(headerList, "List-Unsubscribe") { + headerList = defaultGmailGetMetadataHeaders() + } else if !hasHeaderName(headerList, "List-Unsubscribe") { headerList = append(headerList, "List-Unsubscribe") } call = call.MetadataHeaders(headerList...) diff --git a/internal/cmd/gmail_messages.go b/internal/cmd/gmail_messages.go index 2d59d83..18c16cc 100644 --- a/internal/cmd/gmail_messages.go +++ b/internal/cmd/gmail_messages.go @@ -264,7 +264,7 @@ func fetchMessageDetails(ctx context.Context, svc *gmail.Service, messages []*gm call = call.Format("full") } else { call = call.Format("metadata"). - MetadataHeaders("From", "Subject", "Date"). + MetadataHeaders(gmailMessageSummaryMetadataHeaders...). Fields("id,threadId,labelIds,payload(headers)") } msg, err := call.Context(ctx).Do() diff --git a/internal/cmd/gmail_metadata_headers.go b/internal/cmd/gmail_metadata_headers.go new file mode 100644 index 0000000..f91cfbb --- /dev/null +++ b/internal/cmd/gmail_metadata_headers.go @@ -0,0 +1,20 @@ +package cmd + +var ( + gmailBasicMetadataHeaders = []string{"From", "To", "Cc", "Bcc", "Subject", "Date"} + gmailReplyMetadataHeaders = []string{"Message-ID", "Message-Id", "References", "In-Reply-To", "From", "Reply-To", "To", "Cc", "Date"} + + gmailAutoReplyMetadataHeaders = []string{ + "Message-ID", "Message-Id", "References", "In-Reply-To", + "From", "Reply-To", "To", "Cc", "Date", "Subject", + "Auto-Submitted", "Precedence", "List-Id", "List-Unsubscribe", + } + + gmailMessageSummaryMetadataHeaders = []string{"From", "Subject", "Date"} +) + +func defaultGmailGetMetadataHeaders() []string { + headers := append([]string{}, gmailBasicMetadataHeaders...) + headers = append(headers, "Message-ID", "In-Reply-To", "References", "List-Unsubscribe") + return headers +} diff --git a/internal/cmd/gmail_reply.go b/internal/cmd/gmail_reply.go new file mode 100644 index 0000000..9507d2a --- /dev/null +++ b/internal/cmd/gmail_reply.go @@ -0,0 +1,339 @@ +package cmd + +import ( + "context" + "fmt" + "html" + "net/mail" + "strings" + + "google.golang.org/api/gmail/v1" +) + +// buildReplyAllRecipients constructs To and Cc lists for a reply-all. +// Per RFC 5322: if Reply-To header is present, use it instead of From. +func buildReplyAllRecipients(info *replyInfo, selfEmail string) (to, cc []string) { + toAddrs := make([]string, 0, 1+len(info.ToAddrs)) + + replyAddress := info.ReplyToAddr + if replyAddress == "" { + replyAddress = info.FromAddr + } + if replyAddrs := parseEmailAddresses(replyAddress); len(replyAddrs) > 0 { + toAddrs = append(toAddrs, replyAddrs...) + } + toAddrs = append(toAddrs, info.ToAddrs...) + + toAddrs = filterOutSelf(toAddrs, selfEmail) + toAddrs = deduplicateAddresses(toAddrs) + + ccAddrs := filterOutSelf(info.CcAddrs, selfEmail) + ccAddrs = deduplicateAddresses(ccAddrs) + + toSet := make(map[string]bool) + for _, addr := range toAddrs { + toSet[strings.ToLower(addr)] = true + } + filteredCc := make([]string, 0, len(ccAddrs)) + for _, addr := range ccAddrs { + if !toSet[strings.ToLower(addr)] { + filteredCc = append(filteredCc, addr) + } + } + + return toAddrs, filteredCc +} + +// replyInfo contains all information extracted from the original message for replying. +type replyInfo struct { + InReplyTo string + References string + ThreadID string + FromAddr string + ReplyToAddr string + ToAddrs []string + CcAddrs []string + Date string + Body string + BodyHTML string +} + +func replyHeaders(ctx context.Context, svc *gmail.Service, replyToMessageID string) (inReplyTo string, references string, threadID string, err error) { + info, err := fetchReplyInfo(ctx, svc, replyToMessageID, "", false) + if err != nil { + return "", "", "", err + } + return info.InReplyTo, info.References, info.ThreadID, nil +} + +func fetchReplyInfo(ctx context.Context, svc *gmail.Service, replyToMessageID string, threadID string, includeQuoteBodies bool) (*replyInfo, error) { + replyToMessageID = strings.TrimSpace(replyToMessageID) + threadID = strings.TrimSpace(threadID) + if replyToMessageID == "" && threadID == "" { + return &replyInfo{}, nil + } + + if replyToMessageID != "" { + msg, err := fetchMessageForReplyInfo(ctx, svc, replyToMessageID, includeQuoteBodies) + if err != nil { + return nil, err + } + info := replyInfoFromMessage(msg, includeQuoteBodies) + if info.InReplyTo == "" { + return nil, fmt.Errorf("reply target message %s has no Message-ID header; cannot set In-Reply-To/References", replyToMessageID) + } + return info, nil + } + + thread, err := fetchThreadForReplyInfo(ctx, svc, threadID) + if err != nil { + return nil, err + } + if thread == nil || len(thread.Messages) == 0 { + return nil, fmt.Errorf("thread %s has no messages", threadID) + } + + msg := selectLatestThreadMessage(thread.Messages) + if msg == nil { + return nil, fmt.Errorf("thread %s has no messages", threadID) + } + if includeQuoteBodies && msg.Id != "" { + fullMsg, fullErr := fetchMessageForReplyInfo(ctx, svc, msg.Id, true) + if fullErr == nil && fullMsg != nil { + msg = fullMsg + } + } + + info := replyInfoFromMessage(msg, includeQuoteBodies) + if info.ThreadID == "" { + info.ThreadID = thread.Id + } + return info, nil +} + +func fetchMessageForReplyInfo(ctx context.Context, svc *gmail.Service, messageID string, includeQuoteBodies bool) (*gmail.Message, error) { + call := svc.Users.Messages.Get("me", messageID).Context(ctx) + if includeQuoteBodies { + call = call.Format(gmailFormatFull) + } else { + call = call.Format(gmailFormatMetadata).MetadataHeaders(gmailReplyMetadataHeaders...) + } + return call.Do() +} + +func fetchThreadForReplyInfo(ctx context.Context, svc *gmail.Service, threadID string) (*gmail.Thread, error) { + return svc.Users.Threads.Get("me", threadID). + Format(gmailFormatMetadata). + MetadataHeaders(gmailReplyMetadataHeaders...). + Context(ctx). + Do() +} + +func replyInfoFromMessage(msg *gmail.Message, includeQuoteBodies bool) *replyInfo { + if msg == nil { + return &replyInfo{} + } + info := &replyInfo{ + ThreadID: msg.ThreadId, + FromAddr: headerValue(msg.Payload, "From"), + ReplyToAddr: headerValue(msg.Payload, "Reply-To"), + ToAddrs: parseEmailAddresses(headerValue(msg.Payload, "To")), + CcAddrs: parseEmailAddresses(headerValue(msg.Payload, "Cc")), + Date: headerValue(msg.Payload, "Date"), + } + + if includeQuoteBodies { + plain := findPartBody(msg.Payload, "text/plain") + // Some messages put HTML into text/plain; never dump raw HTML into the plain quote. + if plain != "" && !looksLikeHTML(plain) { + info.Body = plain + } + info.BodyHTML = findPartBody(msg.Payload, "text/html") + } + + messageID := headerValue(msg.Payload, "Message-ID") + if messageID == "" { + messageID = headerValue(msg.Payload, "Message-Id") + } + info.InReplyTo = messageID + info.References = strings.TrimSpace(headerValue(msg.Payload, "References")) + if info.References == "" { + info.References = messageID + } else if messageID != "" && !strings.Contains(info.References, messageID) { + info.References = info.References + " " + messageID + } + return info +} + +func selectLatestThreadMessage(messages []*gmail.Message) *gmail.Message { + var selected *gmail.Message + var selectedDate int64 + hasDate := false + for _, msg := range messages { + if msg == nil { + continue + } + if msg.InternalDate <= 0 { + if selected == nil && !hasDate { + selected = msg + } + continue + } + if !hasDate || msg.InternalDate > selectedDate { + selectedDate = msg.InternalDate + selected = msg + hasDate = true + } + } + return selected +} + +func parseEmailAddresses(header string) []string { + header = strings.TrimSpace(header) + if header == "" { + return nil + } + addrs, err := mail.ParseAddressList(header) + if err != nil { + return parseEmailAddressesFallback(header) + } + result := make([]string, 0, len(addrs)) + for _, addr := range addrs { + if addr.Address != "" { + result = append(result, strings.ToLower(addr.Address)) + } + } + return result +} + +func parseEmailAddressesFallback(header string) []string { + parts := strings.Split(header, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + if start := strings.LastIndex(p, "<"); start != -1 { + if end := strings.LastIndex(p, ">"); end > start { + email := strings.TrimSpace(p[start+1 : end]) + if email != "" { + result = append(result, strings.ToLower(email)) + } + continue + } + } + if strings.Contains(p, "@") { + result = append(result, strings.ToLower(p)) + } + } + return result +} + +func filterOutSelf(addresses []string, selfEmail string) []string { + selfLower := strings.ToLower(selfEmail) + result := make([]string, 0, len(addresses)) + for _, addr := range addresses { + if strings.ToLower(addr) != selfLower { + result = append(result, addr) + } + } + return result +} + +func deduplicateAddresses(addresses []string) []string { + seen := make(map[string]bool) + result := make([]string, 0, len(addresses)) + for _, addr := range addresses { + lower := strings.ToLower(addr) + if !seen[lower] { + seen[lower] = true + result = append(result, addr) + } + } + return result +} + +func escapeTextToHTML(value string) string { + value = html.EscapeString(value) + return strings.ReplaceAll(value, "\n", "
\n") +} + +func applyQuoteToBodies(plainBody string, htmlBody string, quote bool, info *replyInfo) (string, string) { + if !quote || info == nil { + return plainBody, htmlBody + } + if info.Body == "" && info.BodyHTML == "" { + return plainBody, htmlBody + } + + userPlain := plainBody + outPlain := plainBody + if info.Body != "" { + outPlain += formatQuotedMessage(info.FromAddr, info.Date, info.Body) + } + + quoteContent := info.BodyHTML + if quoteContent == "" && info.Body != "" { + quoteContent = escapeTextToHTML(info.Body) + } + if quoteContent == "" { + return outPlain, htmlBody + } + + quoteHTML := formatQuotedMessageHTMLWithContent(info.FromAddr, info.Date, quoteContent) + + outHTML := htmlBody + if strings.TrimSpace(outHTML) == "" { + outHTML = escapeTextToHTML(strings.TrimSpace(userPlain)) + quoteHTML + } else { + outHTML += quoteHTML + } + + return outPlain, outHTML +} + +// formatQuotedMessage formats the original message as a quoted reply. +func formatQuotedMessage(from, date, body string) string { + if body == "" { + return "" + } + + var sb strings.Builder + sb.WriteString("\n\n") + + switch { + case date != "" && from != "": + fmt.Fprintf(&sb, "On %s, %s wrote:\n", date, from) + case from != "": + fmt.Fprintf(&sb, "%s wrote:\n", from) + default: + sb.WriteString("Original message:\n") + } + + lines := strings.Split(body, "\n") + for _, line := range lines { + sb.WriteString("> ") + sb.WriteString(line) + sb.WriteString("\n") + } + + return sb.String() +} + +func formatQuotedMessageHTMLWithContent(from, date, htmlContent string) string { + senderName := from + if addr, err := mail.ParseAddress(from); err == nil && addr.Name != "" { + senderName = addr.Name + } + + dateStr := date + if date == "" { + dateStr = "an earlier date" + } + + return fmt.Sprintf(`

On %s, %s wrote:
%s
`, + html.EscapeString(dateStr), + html.EscapeString(senderName), + htmlContent) +} diff --git a/internal/cmd/gmail_send.go b/internal/cmd/gmail_send.go index c05afda..c0822b6 100644 --- a/internal/cmd/gmail_send.go +++ b/internal/cmd/gmail_send.go @@ -3,8 +3,6 @@ package cmd import ( "context" "fmt" - "html" - "net/mail" "strings" "google.golang.org/api/gmail/v1" @@ -373,361 +371,3 @@ func injectTrackingPixelHTML(htmlBody, pixelHTML string) string { } return htmlBody + pixelHTML } - -// buildReplyAllRecipients constructs To and Cc lists for a reply-all. -// Per RFC 5322: if Reply-To header is present, use it instead of From. -// Reply-To (or From if no Reply-To) -> To -// Original To recipients -> To -// Original Cc recipients -> Cc -// Filters out self and deduplicates. -func buildReplyAllRecipients(info *replyInfo, selfEmail string) (to, cc []string) { - // Collect To recipients: reply address (Reply-To if present, else From) + original To recipients - toAddrs := make([]string, 0, 1+len(info.ToAddrs)) - - // Per RFC 5322, Reply-To takes precedence over From for replies - replyAddress := info.ReplyToAddr - if replyAddress == "" { - replyAddress = info.FromAddr - } - if replyAddrs := parseEmailAddresses(replyAddress); len(replyAddrs) > 0 { - toAddrs = append(toAddrs, replyAddrs...) - } - toAddrs = append(toAddrs, info.ToAddrs...) - - // Filter out self and deduplicate - toAddrs = filterOutSelf(toAddrs, selfEmail) - toAddrs = deduplicateAddresses(toAddrs) - - // Cc recipients: original Cc, filtered - ccAddrs := filterOutSelf(info.CcAddrs, selfEmail) - ccAddrs = deduplicateAddresses(ccAddrs) - - // Remove any Cc addresses that are already in To - toSet := make(map[string]bool) - for _, addr := range toAddrs { - toSet[strings.ToLower(addr)] = true - } - filteredCc := make([]string, 0, len(ccAddrs)) - for _, addr := range ccAddrs { - if !toSet[strings.ToLower(addr)] { - filteredCc = append(filteredCc, addr) - } - } - - return toAddrs, filteredCc -} - -// replyInfo contains all information extracted from the original message for replying -type replyInfo struct { - InReplyTo string - References string - ThreadID string - FromAddr string // Original sender - ReplyToAddr string // Original Reply-To header (per RFC 5322, use this instead of From if present) - ToAddrs []string // Original To recipients - CcAddrs []string // Original Cc recipients - Date string // Original message date (for quoting) - Body string // Original message plain text body (for quoting) - BodyHTML string // Original message HTML body (for quoting with formatting) -} - -func replyHeaders(ctx context.Context, svc *gmail.Service, replyToMessageID string) (inReplyTo string, references string, threadID string, err error) { - info, err := fetchReplyInfo(ctx, svc, replyToMessageID, "", false) - if err != nil { - return "", "", "", err - } - return info.InReplyTo, info.References, info.ThreadID, nil -} - -func fetchReplyInfo(ctx context.Context, svc *gmail.Service, replyToMessageID string, threadID string, includeQuoteBodies bool) (*replyInfo, error) { - replyToMessageID = strings.TrimSpace(replyToMessageID) - threadID = strings.TrimSpace(threadID) - if replyToMessageID == "" && threadID == "" { - return &replyInfo{}, nil - } - - if replyToMessageID != "" { - msg, err := fetchMessageForReplyInfo(ctx, svc, replyToMessageID, includeQuoteBodies) - if err != nil { - return nil, err - } - info := replyInfoFromMessage(msg, includeQuoteBodies) - if info.InReplyTo == "" { - return nil, fmt.Errorf("reply target message %s has no Message-ID header; cannot set In-Reply-To/References", replyToMessageID) - } - return info, nil - } - - // For thread replies, we always need just headers to select the latest message. - // If includeQuoteBodies is true (quoting), fetch that single message in "full" format afterwards - // to avoid pulling entire thread bodies. - thread, err := fetchThreadForReplyInfo(ctx, svc, threadID) - if err != nil { - return nil, err - } - if thread == nil || len(thread.Messages) == 0 { - return nil, fmt.Errorf("thread %s has no messages", threadID) - } - - msg := selectLatestThreadMessage(thread.Messages) - if msg == nil { - return nil, fmt.Errorf("thread %s has no messages", threadID) - } - // If quoting, refetch the selected message in full format to get body parts. - if includeQuoteBodies && msg.Id != "" { - fullMsg, fullErr := fetchMessageForReplyInfo(ctx, svc, msg.Id, true) - if fullErr == nil && fullMsg != nil { - msg = fullMsg - } - } - - info := replyInfoFromMessage(msg, includeQuoteBodies) - if info.ThreadID == "" { - info.ThreadID = thread.Id - } - return info, nil -} - -var replyInfoMetadataHeaders = []string{"Message-ID", "Message-Id", "References", "In-Reply-To", "From", "Reply-To", "To", "Cc", "Date"} - -func fetchMessageForReplyInfo(ctx context.Context, svc *gmail.Service, messageID string, includeQuoteBodies bool) (*gmail.Message, error) { - call := svc.Users.Messages.Get("me", messageID).Context(ctx) - if includeQuoteBodies { - call = call.Format(gmailFormatFull) - } else { - call = call.Format(gmailFormatMetadata).MetadataHeaders(replyInfoMetadataHeaders...) - } - return call.Do() -} - -func fetchThreadForReplyInfo(ctx context.Context, svc *gmail.Service, threadID string) (*gmail.Thread, error) { - return svc.Users.Threads.Get("me", threadID). - Format(gmailFormatMetadata). - MetadataHeaders(replyInfoMetadataHeaders...). - Context(ctx). - Do() -} - -func replyInfoFromMessage(msg *gmail.Message, includeQuoteBodies bool) *replyInfo { - if msg == nil { - return &replyInfo{} - } - info := &replyInfo{ - ThreadID: msg.ThreadId, - FromAddr: headerValue(msg.Payload, "From"), - ReplyToAddr: headerValue(msg.Payload, "Reply-To"), - ToAddrs: parseEmailAddresses(headerValue(msg.Payload, "To")), - CcAddrs: parseEmailAddresses(headerValue(msg.Payload, "Cc")), - Date: headerValue(msg.Payload, "Date"), - } - - // Include body if requested (for quoting) - if includeQuoteBodies { - plain := findPartBody(msg.Payload, "text/plain") - // Some messages (or broken clients) put HTML into text/plain; never dump raw HTML into the plain quote. - if plain != "" && !looksLikeHTML(plain) { - info.Body = plain - } - info.BodyHTML = findPartBody(msg.Payload, "text/html") - } - - // Prefer Message-ID and References from the original message. - messageID := headerValue(msg.Payload, "Message-ID") - if messageID == "" { - messageID = headerValue(msg.Payload, "Message-Id") - } - info.InReplyTo = messageID - info.References = strings.TrimSpace(headerValue(msg.Payload, "References")) - if info.References == "" { - info.References = messageID - } else if messageID != "" && !strings.Contains(info.References, messageID) { - info.References = info.References + " " + messageID - } - return info -} - -func selectLatestThreadMessage(messages []*gmail.Message) *gmail.Message { - var selected *gmail.Message - var selectedDate int64 - hasDate := false - for _, msg := range messages { - if msg == nil { - continue - } - if msg.InternalDate <= 0 { - if selected == nil && !hasDate { - selected = msg - } - continue - } - if !hasDate || msg.InternalDate > selectedDate { - selectedDate = msg.InternalDate - selected = msg - hasDate = true - } - } - return selected -} - -// parseEmailAddresses parses RFC 5322 email addresses from a header value. -// Returns just the email parts (lowercased for comparison). -func parseEmailAddresses(header string) []string { - header = strings.TrimSpace(header) - if header == "" { - return nil - } - addrs, err := mail.ParseAddressList(header) - if err != nil { - // Fallback: try splitting on comma and extracting addresses manually - return parseEmailAddressesFallback(header) - } - result := make([]string, 0, len(addrs)) - for _, addr := range addrs { - if addr.Address != "" { - result = append(result, strings.ToLower(addr.Address)) - } - } - return result -} - -// parseEmailAddressesFallback handles cases where mail.ParseAddressList fails -func parseEmailAddressesFallback(header string) []string { - parts := strings.Split(header, ",") - result := make([]string, 0, len(parts)) - for _, p := range parts { - p = strings.TrimSpace(p) - if p == "" { - continue - } - // Try to extract email from "Name " format - if start := strings.LastIndex(p, "<"); start != -1 { - if end := strings.LastIndex(p, ">"); end > start { - email := strings.TrimSpace(p[start+1 : end]) - if email != "" { - result = append(result, strings.ToLower(email)) - } - continue - } - } - // Assume it's just an email address - if strings.Contains(p, "@") { - result = append(result, strings.ToLower(p)) - } - } - return result -} - -// filterOutSelf removes the sending account from the address list -func filterOutSelf(addresses []string, selfEmail string) []string { - selfLower := strings.ToLower(selfEmail) - result := make([]string, 0, len(addresses)) - for _, addr := range addresses { - if strings.ToLower(addr) != selfLower { - result = append(result, addr) - } - } - return result -} - -// deduplicateAddresses removes duplicate email addresses (case-insensitive) -func deduplicateAddresses(addresses []string) []string { - seen := make(map[string]bool) - result := make([]string, 0, len(addresses)) - for _, addr := range addresses { - lower := strings.ToLower(addr) - if !seen[lower] { - seen[lower] = true - result = append(result, addr) - } - } - return result -} - -func escapeTextToHTML(value string) string { - value = html.EscapeString(value) - return strings.ReplaceAll(value, "\n", "
\n") -} - -func applyQuoteToBodies(plainBody string, htmlBody string, quote bool, info *replyInfo) (string, string) { - if !quote || info == nil { - return plainBody, htmlBody - } - if info.Body == "" && info.BodyHTML == "" { - return plainBody, htmlBody - } - - userPlain := plainBody - outPlain := plainBody - if info.Body != "" { - outPlain += formatQuotedMessage(info.FromAddr, info.Date, info.Body) - } - - quoteContent := info.BodyHTML - if quoteContent == "" && info.Body != "" { - quoteContent = escapeTextToHTML(info.Body) - } - if quoteContent == "" { - return outPlain, htmlBody - } - - quoteHTML := formatQuotedMessageHTMLWithContent(info.FromAddr, info.Date, quoteContent) - - outHTML := htmlBody - if strings.TrimSpace(outHTML) == "" { - outHTML = escapeTextToHTML(strings.TrimSpace(userPlain)) + quoteHTML - } else { - outHTML += quoteHTML - } - - return outPlain, outHTML -} - -// formatQuotedMessage formats the original message as a quoted reply. -// It adds an attribution line and prefixes each line with "> ". -func formatQuotedMessage(from, date, body string) string { - if body == "" { - return "" - } - - var sb strings.Builder - sb.WriteString("\n\n") - - // Attribution line - switch { - case date != "" && from != "": - fmt.Fprintf(&sb, "On %s, %s wrote:\n", date, from) - case from != "": - fmt.Fprintf(&sb, "%s wrote:\n", from) - default: - sb.WriteString("Original message:\n") - } - - // Quote each line with "> " prefix - lines := strings.Split(body, "\n") - for _, line := range lines { - sb.WriteString("> ") - sb.WriteString(line) - sb.WriteString("\n") - } - - return sb.String() -} - -// formatQuotedMessageHTMLWithContent wraps pre-formatted HTML content in a blockquote. -// Use this when the content is already HTML (preserves original formatting). -func formatQuotedMessageHTMLWithContent(from, date, htmlContent string) string { - senderName := from - if addr, err := mail.ParseAddress(from); err == nil && addr.Name != "" { - senderName = addr.Name - } - - dateStr := date - if date == "" { - dateStr = "an earlier date" - } - - return fmt.Sprintf(`

On %s, %s wrote:
%s
`, - html.EscapeString(dateStr), - html.EscapeString(senderName), - htmlContent) -} diff --git a/internal/cmd/gmail_thread_search_helpers.go b/internal/cmd/gmail_thread_search_helpers.go index 2975f7f..fa8f1dd 100644 --- a/internal/cmd/gmail_thread_search_helpers.go +++ b/internal/cmd/gmail_thread_search_helpers.go @@ -240,7 +240,7 @@ func fetchThreadDetails(ctx context.Context, svc *gmail.Service, threads []*gmai fullThread, err := svc.Users.Threads.Get("me", threadID). Format("metadata"). - MetadataHeaders("From", "Subject", "Date"). + MetadataHeaders(gmailMessageSummaryMetadataHeaders...). Context(ctx). Do() if err != nil { @@ -299,7 +299,7 @@ func fetchThreadDetails(ctx context.Context, svc *gmail.Service, threads []*gmai } _, err := svc.Users.Threads.Get("me", thread.Id). Format("metadata"). - MetadataHeaders("From", "Subject", "Date"). + MetadataHeaders(gmailMessageSummaryMetadataHeaders...). Context(ctx). Do() if err != nil { diff --git a/internal/cmd/gmail_watch_server.go b/internal/cmd/gmail_watch_server.go index 6e8eed5..2d1eb34 100644 --- a/internal/cmd/gmail_watch_server.go +++ b/internal/cmd/gmail_watch_server.go @@ -302,7 +302,7 @@ func (s *gmailWatchServer) fetchMessages(ctx context.Context, svc *gmail.Service } msg, err := svc.Users.Messages.Get("me", id). Format(format). - MetadataHeaders("From", "To", "Subject", "Date"). + MetadataHeaders(gmailBasicMetadataHeaders...). Context(ctx). Do() if err != nil {