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(`
%s