From e04c7185123b8a2469d93e45eec588a7c9e2828f Mon Sep 17 00:00:00 2001 From: OpenCode Date: Tue, 3 Feb 2026 16:16:23 +0800 Subject: [PATCH 1/7] feat: add --quote flag to include original message in replies Adds a --quote flag to gog gmail send that automatically quotes the original message when replying. Changes: - Added --quote flag to GmailSendCmd struct - Modified fetchReplyInfo to optionally fetch message body - Added formatQuotedMessage function for attribution and quoting - Updated validation to require reply target when --quote is used --- internal/cmd/gmail_drafts.go | 2 +- internal/cmd/gmail_send.go | 95 ++++++++++++++++++++++----- internal/cmd/gmail_send_reply_test.go | 2 +- internal/cmd/gmail_send_test.go | 10 +-- 4 files changed, 85 insertions(+), 24 deletions(-) diff --git a/internal/cmd/gmail_drafts.go b/internal/cmd/gmail_drafts.go index 0dba84de..99413b32 100644 --- a/internal/cmd/gmail_drafts.go +++ b/internal/cmd/gmail_drafts.go @@ -299,7 +299,7 @@ func buildDraftMessage(ctx context.Context, svc *gmail.Service, account string, } } - info, err := fetchReplyInfo(ctx, svc, input.ReplyToMessageID, input.ReplyToThreadID) + info, err := fetchReplyInfo(ctx, svc, input.ReplyToMessageID, input.ReplyToThreadID, false) if err != nil { return nil, "", err } diff --git a/internal/cmd/gmail_send.go b/internal/cmd/gmail_send.go index 053ba1af..433419be 100644 --- a/internal/cmd/gmail_send.go +++ b/internal/cmd/gmail_send.go @@ -32,6 +32,7 @@ type GmailSendCmd struct { From string `name:"from" help:"Send from this email address (must be a verified send-as alias)"` Track bool `name:"track" help:"Enable open tracking (requires tracking setup)"` TrackSplit bool `name:"track-split" help:"Send tracked messages separately per recipient"` + Quote bool `name:"quote" help:"Include quoted original message in reply (requires --reply-to-message-id or --thread-id)"` } type sendBatch struct { @@ -84,6 +85,11 @@ func (c *GmailSendCmd) Run(ctx context.Context, flags *RootFlags) error { return usage("--reply-all requires --reply-to-message-id or --thread-id") } + // Validate --quote requires a reply target + if c.Quote && replyToMessageID == "" && threadID == "" { + return usage("--quote requires --reply-to-message-id or --thread-id") + } + // --to is required unless --reply-all is used if strings.TrimSpace(c.To) == "" && !c.ReplyAll { return usage("required: --to (or use --reply-all with --reply-to-message-id or --thread-id)") @@ -132,12 +138,17 @@ func (c *GmailSendCmd) Run(ctx context.Context, flags *RootFlags) error { // If lookup fails, we just use the plain email address (no error) } - // Fetch reply info (includes recipient headers for reply-all) - replyInfo, err := fetchReplyInfo(ctx, svc, replyToMessageID, threadID) + // Fetch reply info (includes recipient headers for reply-all, and body for quoting) + replyInfo, err := fetchReplyInfo(ctx, svc, replyToMessageID, threadID, c.Quote) if err != nil { return err } + // If quoting, append the quoted original message to the body + if c.Quote && replyInfo.Body != "" { + body = body + formatQuotedMessage(replyInfo.FromAddr, replyInfo.Date, replyInfo.Body) + } + // Determine recipients var toRecipients, ccRecipients []string if c.ReplyAll { @@ -451,40 +462,54 @@ type replyInfo struct { 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 body (for quoting) } func replyHeaders(ctx context.Context, svc *gmail.Service, replyToMessageID string) (inReplyTo string, references string, threadID string, err error) { - info, err := fetchReplyInfo(ctx, svc, replyToMessageID, "") + 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) (*replyInfo, error) { +func fetchReplyInfo(ctx context.Context, svc *gmail.Service, replyToMessageID string, threadID string, includeBody bool) (*replyInfo, error) { replyToMessageID = strings.TrimSpace(replyToMessageID) threadID = strings.TrimSpace(threadID) if replyToMessageID == "" && threadID == "" { return &replyInfo{}, nil } + // Use "full" format when we need the body for quoting, otherwise "metadata" + format := "metadata" + if includeBody { + format = "full" + } + if replyToMessageID != "" { - msg, err := svc.Users.Messages.Get("me", replyToMessageID). - Format("metadata"). - MetadataHeaders("Message-ID", "Message-Id", "References", "In-Reply-To", "From", "Reply-To", "To", "Cc"). - Context(ctx). - Do() + call := svc.Users.Messages.Get("me", replyToMessageID).Context(ctx) + if format == "metadata" { + call = call.Format("metadata"). + MetadataHeaders("Message-ID", "Message-Id", "References", "In-Reply-To", "From", "Reply-To", "To", "Cc", "Date") + } else { + call = call.Format("full") + } + msg, err := call.Do() if err != nil { return nil, err } - return replyInfoFromMessage(msg), nil + return replyInfoFromMessage(msg, includeBody), nil } - thread, err := svc.Users.Threads.Get("me", threadID). - Format("metadata"). - MetadataHeaders("Message-ID", "Message-Id", "References", "In-Reply-To", "From", "Reply-To", "To", "Cc"). - Context(ctx). - Do() + threadCall := svc.Users.Threads.Get("me", threadID).Context(ctx) + if format == "metadata" { + threadCall = threadCall.Format("metadata"). + MetadataHeaders("Message-ID", "Message-Id", "References", "In-Reply-To", "From", "Reply-To", "To", "Cc", "Date") + } else { + threadCall = threadCall.Format("full") + } + thread, err := threadCall.Do() if err != nil { return nil, err } @@ -496,14 +521,14 @@ func fetchReplyInfo(ctx context.Context, svc *gmail.Service, replyToMessageID st if msg == nil { return nil, fmt.Errorf("thread %s has no messages", threadID) } - info := replyInfoFromMessage(msg) + info := replyInfoFromMessage(msg, includeBody) if info.ThreadID == "" { info.ThreadID = thread.Id } return info, nil } -func replyInfoFromMessage(msg *gmail.Message) *replyInfo { +func replyInfoFromMessage(msg *gmail.Message, includeBody bool) *replyInfo { if msg == nil { return &replyInfo{} } @@ -513,6 +538,12 @@ func replyInfoFromMessage(msg *gmail.Message) *replyInfo { 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 includeBody { + info.Body = bestBodyText(msg.Payload) } // Prefer Message-ID and References from the original message. @@ -626,3 +657,33 @@ func deduplicateAddresses(addresses []string) []string { } return result } + +// 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 + if date != "" && from != "" { + sb.WriteString(fmt.Sprintf("On %s, %s wrote:\n", date, from)) + } else if from != "" { + sb.WriteString(fmt.Sprintf("%s wrote:\n", from)) + } else { + 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() +} diff --git a/internal/cmd/gmail_send_reply_test.go b/internal/cmd/gmail_send_reply_test.go index 65fefdee..eb1a7afa 100644 --- a/internal/cmd/gmail_send_reply_test.go +++ b/internal/cmd/gmail_send_reply_test.go @@ -100,7 +100,7 @@ func TestFetchReplyInfoFromThread(t *testing.T) { } newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil } - info, err := fetchReplyInfo(context.Background(), svc, "", "t1") + info, err := fetchReplyInfo(context.Background(), svc, "", "t1", false) if err != nil { t.Fatalf("fetchReplyInfo: %v", err) } diff --git a/internal/cmd/gmail_send_test.go b/internal/cmd/gmail_send_test.go index 2f18ce98..0a8f5def 100644 --- a/internal/cmd/gmail_send_test.go +++ b/internal/cmd/gmail_send_test.go @@ -179,7 +179,7 @@ func TestFetchReplyInfo_ThreadID(t *testing.T) { t.Fatalf("NewService: %v", err) } - info, err := fetchReplyInfo(context.Background(), svc, "", "t1") + info, err := fetchReplyInfo(context.Background(), svc, "", "t1", false) if err != nil { t.Fatalf("fetchReplyInfo: %v", err) } @@ -910,7 +910,7 @@ func TestFetchReplyInfo(t *testing.T) { ctx := context.Background() // Test m1: multiple recipients - info, err := fetchReplyInfo(ctx, svc, "m1", "") + info, err := fetchReplyInfo(ctx, svc, "m1", "", false) if err != nil { t.Fatalf("fetchReplyInfo(m1): %v", err) } @@ -930,7 +930,7 @@ func TestFetchReplyInfo(t *testing.T) { } // Test m2: sender with display name - info, err = fetchReplyInfo(ctx, svc, "m2", "") + info, err = fetchReplyInfo(ctx, svc, "m2", "", false) if err != nil { t.Fatalf("fetchReplyInfo(m2): %v", err) } @@ -939,7 +939,7 @@ func TestFetchReplyInfo(t *testing.T) { } // Test empty message ID - info, err = fetchReplyInfo(ctx, svc, "", "") + info, err = fetchReplyInfo(ctx, svc, "", "", false) if err != nil { t.Fatalf("fetchReplyInfo(''): %v", err) } @@ -948,7 +948,7 @@ func TestFetchReplyInfo(t *testing.T) { } // Test m3: message with Reply-To header - info, err = fetchReplyInfo(ctx, svc, "m3", "") + info, err = fetchReplyInfo(ctx, svc, "m3", "", false) if err != nil { t.Fatalf("fetchReplyInfo(m3): %v", err) } From cde782738a44a95643b2ebaf5a13ca036b15b7e5 Mon Sep 17 00:00:00 2001 From: OpenCode Date: Tue, 3 Feb 2026 16:21:27 +0800 Subject: [PATCH 2/7] feat(gmail): add --quote flag to gmail send command Add automatic message quoting when replying to emails. The new --quote flag includes the original message body with proper attribution line and quote prefixes when using --reply-to-message-id or --thread-id. Changes: - Add Quote bool flag to GmailSendCmd struct - Validate --quote requires reply target (--reply-to-message-id or --thread-id) - Modify fetchReplyInfo to support fetching full message format for quoting - Add Date and Body fields to replyInfo struct - Add formatQuotedMessage function to format original message as quote - Append quoted message to user's body when --quote is set - Update test calls to replyInfoFromMessage with new includeBody parameter - Use gmailFormatFull/gmailFormatMetadata constants instead of string literals - Use body += for appending instead of body = body + --- internal/cmd/gmail_send.go | 25 +++++++++++++------------ internal/cmd/gmail_send_helpers_test.go | 2 +- internal/cmd/gmail_send_reply_test.go | 2 +- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/internal/cmd/gmail_send.go b/internal/cmd/gmail_send.go index 433419be..d7b249f2 100644 --- a/internal/cmd/gmail_send.go +++ b/internal/cmd/gmail_send.go @@ -146,7 +146,7 @@ func (c *GmailSendCmd) Run(ctx context.Context, flags *RootFlags) error { // If quoting, append the quoted original message to the body if c.Quote && replyInfo.Body != "" { - body = body + formatQuotedMessage(replyInfo.FromAddr, replyInfo.Date, replyInfo.Body) + body += formatQuotedMessage(replyInfo.FromAddr, replyInfo.Date, replyInfo.Body) } // Determine recipients @@ -482,18 +482,18 @@ func fetchReplyInfo(ctx context.Context, svc *gmail.Service, replyToMessageID st } // Use "full" format when we need the body for quoting, otherwise "metadata" - format := "metadata" + format := gmailFormatMetadata if includeBody { - format = "full" + format = gmailFormatFull } if replyToMessageID != "" { call := svc.Users.Messages.Get("me", replyToMessageID).Context(ctx) - if format == "metadata" { - call = call.Format("metadata"). + if format == gmailFormatMetadata { + call = call.Format(gmailFormatMetadata). MetadataHeaders("Message-ID", "Message-Id", "References", "In-Reply-To", "From", "Reply-To", "To", "Cc", "Date") } else { - call = call.Format("full") + call = call.Format(gmailFormatFull) } msg, err := call.Do() if err != nil { @@ -503,11 +503,11 @@ func fetchReplyInfo(ctx context.Context, svc *gmail.Service, replyToMessageID st } threadCall := svc.Users.Threads.Get("me", threadID).Context(ctx) - if format == "metadata" { - threadCall = threadCall.Format("metadata"). + if format == gmailFormatMetadata { + threadCall = threadCall.Format(gmailFormatMetadata). MetadataHeaders("Message-ID", "Message-Id", "References", "In-Reply-To", "From", "Reply-To", "To", "Cc", "Date") } else { - threadCall = threadCall.Format("full") + threadCall = threadCall.Format(gmailFormatFull) } thread, err := threadCall.Do() if err != nil { @@ -669,11 +669,12 @@ func formatQuotedMessage(from, date, body string) string { sb.WriteString("\n\n") // Attribution line - if date != "" && from != "" { + switch { + case date != "" && from != "": sb.WriteString(fmt.Sprintf("On %s, %s wrote:\n", date, from)) - } else if from != "" { + case from != "": sb.WriteString(fmt.Sprintf("%s wrote:\n", from)) - } else { + default: sb.WriteString("Original message:\n") } diff --git a/internal/cmd/gmail_send_helpers_test.go b/internal/cmd/gmail_send_helpers_test.go index 347eeb65..4b4782f2 100644 --- a/internal/cmd/gmail_send_helpers_test.go +++ b/internal/cmd/gmail_send_helpers_test.go @@ -119,7 +119,7 @@ func TestReplyInfoFromMessage(t *testing.T) { }, }, } - info := replyInfoFromMessage(msg) + info := replyInfoFromMessage(msg, false) if info.ThreadID != "t1" { t.Fatalf("unexpected thread id: %q", info.ThreadID) } diff --git a/internal/cmd/gmail_send_reply_test.go b/internal/cmd/gmail_send_reply_test.go index eb1a7afa..2e4bfb59 100644 --- a/internal/cmd/gmail_send_reply_test.go +++ b/internal/cmd/gmail_send_reply_test.go @@ -30,7 +30,7 @@ func TestReplyInfoFromMessage_More(t *testing.T) { }, }, } - info := replyInfoFromMessage(msg) + info := replyInfoFromMessage(msg, false) if info.InReplyTo != "" { t.Fatalf("unexpected InReplyTo: %q", info.InReplyTo) } From fb212e087528d14d5f24fd3dba60f76ebaa7149c Mon Sep 17 00:00:00 2001 From: OpenCode Date: Tue, 3 Feb 2026 16:23:49 +0800 Subject: [PATCH 3/7] feat(gmail): add HTML blockquote support for --quote feature --- internal/cmd/gmail_send.go | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/internal/cmd/gmail_send.go b/internal/cmd/gmail_send.go index d7b249f2..858002b7 100644 --- a/internal/cmd/gmail_send.go +++ b/internal/cmd/gmail_send.go @@ -4,6 +4,7 @@ import ( "context" "encoding/base64" "fmt" + "html" "net/mail" "os" "strings" @@ -145,8 +146,10 @@ func (c *GmailSendCmd) Run(ctx context.Context, flags *RootFlags) error { } // If quoting, append the quoted original message to the body + var bodyHTML string if c.Quote && replyInfo.Body != "" { body += formatQuotedMessage(replyInfo.FromAddr, replyInfo.Date, replyInfo.Body) + bodyHTML = formatQuotedMessageHTML(replyInfo.FromAddr, replyInfo.Date, replyInfo.Body) } // Determine recipients @@ -189,12 +192,16 @@ func (c *GmailSendCmd) Run(ctx context.Context, flags *RootFlags) error { } batches := buildSendBatches(toRecipients, ccRecipients, bccRecipients, c.Track, c.TrackSplit) + htmlBody := c.BodyHTML + if bodyHTML != "" { + htmlBody += bodyHTML + } results, err := sendGmailBatches(ctx, svc, sendMessageOptions{ FromAddr: fromAddr, ReplyTo: c.ReplyTo, Subject: c.Subject, Body: body, - BodyHTML: c.BodyHTML, + BodyHTML: htmlBody, ReplyInfo: replyInfo, Attachments: atts, Track: c.Track, @@ -688,3 +695,23 @@ func formatQuotedMessage(from, date, body string) string { return sb.String() } + +func formatQuotedMessageHTML(from, date, body string) string { + senderName := from + if addr, err := mail.ParseAddress(from); err == nil && addr.Name != "" { + senderName = addr.Name + } + + escapedBody := html.EscapeString(body) + escapedBody = strings.ReplaceAll(escapedBody, "\n", "
\n") + + dateStr := date + if date == "" { + dateStr = "an earlier date" + } + + return fmt.Sprintf(`

On %s, %s wrote:
%s
`, + html.EscapeString(dateStr), + html.EscapeString(senderName), + escapedBody) +} From 646a8778698511fbd845469cbed2c14ec2361430 Mon Sep 17 00:00:00 2001 From: OpenCode Date: Tue, 3 Feb 2026 16:26:13 +0800 Subject: [PATCH 4/7] fix(gmail): include user body in HTML when using --quote --- internal/cmd/gmail_send.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/cmd/gmail_send.go b/internal/cmd/gmail_send.go index 858002b7..cf9a1739 100644 --- a/internal/cmd/gmail_send.go +++ b/internal/cmd/gmail_send.go @@ -149,7 +149,10 @@ func (c *GmailSendCmd) Run(ctx context.Context, flags *RootFlags) error { var bodyHTML string if c.Quote && replyInfo.Body != "" { body += formatQuotedMessage(replyInfo.FromAddr, replyInfo.Date, replyInfo.Body) - bodyHTML = formatQuotedMessageHTML(replyInfo.FromAddr, replyInfo.Date, replyInfo.Body) + // For HTML: include user's body text (escaped) + the HTML quote + userBodyHTML := html.EscapeString(strings.TrimSpace(c.Body)) + userBodyHTML = strings.ReplaceAll(userBodyHTML, "\n", "
\n") + bodyHTML = userBodyHTML + formatQuotedMessageHTML(replyInfo.FromAddr, replyInfo.Date, replyInfo.Body) } // Determine recipients From 917afae037a64e2ade766b7c2cbf3666704a27c1 Mon Sep 17 00:00:00 2001 From: OpenCode Date: Tue, 3 Feb 2026 16:29:08 +0800 Subject: [PATCH 5/7] feat(gmail): preserve original HTML formatting when quoting --- internal/cmd/gmail_send.go | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/internal/cmd/gmail_send.go b/internal/cmd/gmail_send.go index cf9a1739..06e27101 100644 --- a/internal/cmd/gmail_send.go +++ b/internal/cmd/gmail_send.go @@ -152,7 +152,14 @@ func (c *GmailSendCmd) Run(ctx context.Context, flags *RootFlags) error { // For HTML: include user's body text (escaped) + the HTML quote userBodyHTML := html.EscapeString(strings.TrimSpace(c.Body)) userBodyHTML = strings.ReplaceAll(userBodyHTML, "\n", "
\n") - bodyHTML = userBodyHTML + formatQuotedMessageHTML(replyInfo.FromAddr, replyInfo.Date, replyInfo.Body) + // Use original HTML body if available to preserve formatting, otherwise convert plain text + quoteContent := replyInfo.BodyHTML + if quoteContent == "" { + // Fallback to plain text converted to HTML + quoteContent = html.EscapeString(replyInfo.Body) + quoteContent = strings.ReplaceAll(quoteContent, "\n", "
\n") + } + bodyHTML = userBodyHTML + formatQuotedMessageHTMLWithContent(replyInfo.FromAddr, replyInfo.Date, quoteContent) } // Determine recipients @@ -473,7 +480,8 @@ type replyInfo struct { ToAddrs []string // Original To recipients CcAddrs []string // Original Cc recipients Date string // Original message date (for quoting) - Body string // Original message body (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) { @@ -554,6 +562,7 @@ func replyInfoFromMessage(msg *gmail.Message, includeBody bool) *replyInfo { // Include body if requested (for quoting) if includeBody { info.Body = bestBodyText(msg.Payload) + info.BodyHTML = findPartBody(msg.Payload, "text/html") } // Prefer Message-ID and References from the original message. @@ -700,14 +709,19 @@ func formatQuotedMessage(from, date, body string) string { } func formatQuotedMessageHTML(from, date, body string) string { + escapedBody := html.EscapeString(body) + escapedBody = strings.ReplaceAll(escapedBody, "\n", "
\n") + return formatQuotedMessageHTMLWithContent(from, date, escapedBody) +} + +// 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 } - escapedBody := html.EscapeString(body) - escapedBody = strings.ReplaceAll(escapedBody, "\n", "
\n") - dateStr := date if date == "" { dateStr = "an earlier date" @@ -716,5 +730,5 @@ func formatQuotedMessageHTML(from, date, body string) string { return fmt.Sprintf(`

On %s, %s wrote:
%s
`, html.EscapeString(dateStr), html.EscapeString(senderName), - escapedBody) + htmlContent) } From 1a1341fd3a56d25fa57f0a092deef5612453692d Mon Sep 17 00:00:00 2001 From: OpenCode Date: Tue, 3 Feb 2026 16:34:43 +0800 Subject: [PATCH 6/7] fix(gmail): handle --body-file and --body-html edge cases with --quote --- internal/cmd/gmail_send.go | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/internal/cmd/gmail_send.go b/internal/cmd/gmail_send.go index 06e27101..c20e41c7 100644 --- a/internal/cmd/gmail_send.go +++ b/internal/cmd/gmail_send.go @@ -146,12 +146,12 @@ func (c *GmailSendCmd) Run(ctx context.Context, flags *RootFlags) error { } // If quoting, append the quoted original message to the body - var bodyHTML string + var quoteHTML string if c.Quote && replyInfo.Body != "" { + // Save original user body before appending quote (for HTML generation) + userBody := body body += formatQuotedMessage(replyInfo.FromAddr, replyInfo.Date, replyInfo.Body) - // For HTML: include user's body text (escaped) + the HTML quote - userBodyHTML := html.EscapeString(strings.TrimSpace(c.Body)) - userBodyHTML = strings.ReplaceAll(userBodyHTML, "\n", "
\n") + // Use original HTML body if available to preserve formatting, otherwise convert plain text quoteContent := replyInfo.BodyHTML if quoteContent == "" { @@ -159,7 +159,14 @@ func (c *GmailSendCmd) Run(ctx context.Context, flags *RootFlags) error { quoteContent = html.EscapeString(replyInfo.Body) quoteContent = strings.ReplaceAll(quoteContent, "\n", "
\n") } - bodyHTML = userBodyHTML + formatQuotedMessageHTMLWithContent(replyInfo.FromAddr, replyInfo.Date, quoteContent) + quoteHTML = formatQuotedMessageHTMLWithContent(replyInfo.FromAddr, replyInfo.Date, quoteContent) + + // If user didn't provide --body-html, generate HTML from plain text body + quote + if strings.TrimSpace(c.BodyHTML) == "" { + userBodyHTML := html.EscapeString(strings.TrimSpace(userBody)) + userBodyHTML = strings.ReplaceAll(userBodyHTML, "\n", "
\n") + quoteHTML = userBodyHTML + quoteHTML + } } // Determine recipients @@ -203,8 +210,8 @@ func (c *GmailSendCmd) Run(ctx context.Context, flags *RootFlags) error { batches := buildSendBatches(toRecipients, ccRecipients, bccRecipients, c.Track, c.TrackSplit) htmlBody := c.BodyHTML - if bodyHTML != "" { - htmlBody += bodyHTML + if quoteHTML != "" { + htmlBody += quoteHTML } results, err := sendGmailBatches(ctx, svc, sendMessageOptions{ FromAddr: fromAddr, From c7e2c53ee33d0c9e5d96e2b27e065de1f9bb7dcc Mon Sep 17 00:00:00 2001 From: OpenCode Date: Tue, 3 Feb 2026 16:37:44 +0800 Subject: [PATCH 7/7] fix(gmail): handle HTML-only messages when using --quote - Quote now works when original message has only HTML content (no plain text body) - Removed unused formatQuotedMessageHTML function Fixes edge cases where --quote would skip messages with HTML-only bodies. --- internal/cmd/gmail_send.go | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/internal/cmd/gmail_send.go b/internal/cmd/gmail_send.go index c20e41c7..915a8bb0 100644 --- a/internal/cmd/gmail_send.go +++ b/internal/cmd/gmail_send.go @@ -147,10 +147,12 @@ func (c *GmailSendCmd) Run(ctx context.Context, flags *RootFlags) error { // If quoting, append the quoted original message to the body var quoteHTML string - if c.Quote && replyInfo.Body != "" { + if c.Quote && (replyInfo.Body != "" || replyInfo.BodyHTML != "") { // Save original user body before appending quote (for HTML generation) userBody := body - body += formatQuotedMessage(replyInfo.FromAddr, replyInfo.Date, replyInfo.Body) + if replyInfo.Body != "" { + body += formatQuotedMessage(replyInfo.FromAddr, replyInfo.Date, replyInfo.Body) + } // Use original HTML body if available to preserve formatting, otherwise convert plain text quoteContent := replyInfo.BodyHTML @@ -715,12 +717,6 @@ func formatQuotedMessage(from, date, body string) string { return sb.String() } -func formatQuotedMessageHTML(from, date, body string) string { - escapedBody := html.EscapeString(body) - escapedBody = strings.ReplaceAll(escapedBody, "\n", "
\n") - return formatQuotedMessageHTMLWithContent(from, date, escapedBody) -} - // 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 {