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