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..915a8bb0 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" @@ -32,6 +33,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 +86,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 +139,38 @@ 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 + var quoteHTML string + if c.Quote && (replyInfo.Body != "" || replyInfo.BodyHTML != "") { + // Save original user body before appending quote (for HTML generation) + userBody := 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 + if quoteContent == "" { + // Fallback to plain text converted to HTML + quoteContent = html.EscapeString(replyInfo.Body) + quoteContent = strings.ReplaceAll(quoteContent, "\n", "
\n") + } + 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 var toRecipients, ccRecipients []string if c.ReplyAll { @@ -178,12 +211,16 @@ func (c *GmailSendCmd) Run(ctx context.Context, flags *RootFlags) error { } batches := buildSendBatches(toRecipients, ccRecipients, bccRecipients, c.Track, c.TrackSplit) + htmlBody := c.BodyHTML + if quoteHTML != "" { + htmlBody += quoteHTML + } 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, @@ -451,40 +488,55 @@ 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 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, "") + 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 := gmailFormatMetadata + if includeBody { + format = gmailFormatFull + } + 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 == gmailFormatMetadata { + call = call.Format(gmailFormatMetadata). + MetadataHeaders("Message-ID", "Message-Id", "References", "In-Reply-To", "From", "Reply-To", "To", "Cc", "Date") + } else { + call = call.Format(gmailFormatFull) + } + 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 == gmailFormatMetadata { + threadCall = threadCall.Format(gmailFormatMetadata). + MetadataHeaders("Message-ID", "Message-Id", "References", "In-Reply-To", "From", "Reply-To", "To", "Cc", "Date") + } else { + threadCall = threadCall.Format(gmailFormatFull) + } + thread, err := threadCall.Do() if err != nil { return nil, err } @@ -496,14 +548,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 +565,13 @@ 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) + info.BodyHTML = findPartBody(msg.Payload, "text/html") } // Prefer Message-ID and References from the original message. @@ -626,3 +685,53 @@ 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 + switch { + case date != "" && from != "": + sb.WriteString(fmt.Sprintf("On %s, %s wrote:\n", date, from)) + case from != "": + sb.WriteString(fmt.Sprintf("%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_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 65fefdee..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) } @@ -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) }