gogcli/internal/cmd/gmail_reply.go
2026-04-27 11:24:52 +01:00

342 lines
9.1 KiB
Go

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
Subject 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"),
Subject: headerValue(msg.Payload, "Subject"),
}
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", "<br>\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(`<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)
}