Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/cmd/gmail_drafts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
145 changes: 127 additions & 18 deletions internal/cmd/gmail_send.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"encoding/base64"
"fmt"
"html"
"net/mail"
"os"
"strings"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)")
Expand Down Expand Up @@ -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", "<br>\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", "<br>\n")
quoteHTML = userBodyHTML + quoteHTML
}
}

// Determine recipients
var toRecipients, ccRecipients []string
if c.ReplyAll {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand All @@ -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{}
}
Expand All @@ -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.
Expand Down Expand Up @@ -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(`<br><br><div class="gmail_quote"><div class="gmail_attr">On %s, %s wrote:</div><blockquote class="gmail_quote" style="margin:0 0 0 .8ex;border-left:1px #ccc solid;padding-left:1ex">%s</blockquote></div>`,
html.EscapeString(dateStr),
html.EscapeString(senderName),
htmlContent)
}
2 changes: 1 addition & 1 deletion internal/cmd/gmail_send_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
4 changes: 2 additions & 2 deletions internal/cmd/gmail_send_reply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func TestReplyInfoFromMessage_More(t *testing.T) {
},
},
}
info := replyInfoFromMessage(msg)
info := replyInfoFromMessage(msg, false)
if info.InReplyTo != "<m1>" {
t.Fatalf("unexpected InReplyTo: %q", info.InReplyTo)
}
Expand Down Expand Up @@ -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)
}
Expand Down
10 changes: 5 additions & 5 deletions internal/cmd/gmail_send_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand Down