refactor(gmail): split reply metadata helpers
This commit is contained in:
parent
9f2fae2913
commit
896cccc3dc
@ -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()
|
||||
}
|
||||
|
||||
@ -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...)
|
||||
|
||||
@ -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()
|
||||
|
||||
20
internal/cmd/gmail_metadata_headers.go
Normal file
20
internal/cmd/gmail_metadata_headers.go
Normal file
@ -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
|
||||
}
|
||||
339
internal/cmd/gmail_reply.go
Normal file
339
internal/cmd/gmail_reply.go
Normal file
@ -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", "<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)
|
||||
}
|
||||
@ -3,8 +3,6 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/mail"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/api/gmail/v1"
|
||||
@ -373,361 +371,3 @@ func injectTrackingPixelHTML(htmlBody, pixelHTML string) string {
|
||||
}
|
||||
return htmlBody + pixelHTML
|
||||
}
|
||||
|
||||
// buildReplyAllRecipients constructs To and Cc lists for a reply-all.
|
||||
// Per RFC 5322: if Reply-To header is present, use it instead of From.
|
||||
// Reply-To (or From if no Reply-To) -> To
|
||||
// Original To recipients -> To
|
||||
// Original Cc recipients -> Cc
|
||||
// Filters out self and deduplicates.
|
||||
func buildReplyAllRecipients(info *replyInfo, selfEmail string) (to, cc []string) {
|
||||
// Collect To recipients: reply address (Reply-To if present, else From) + original To recipients
|
||||
toAddrs := make([]string, 0, 1+len(info.ToAddrs))
|
||||
|
||||
// Per RFC 5322, Reply-To takes precedence over From for replies
|
||||
replyAddress := info.ReplyToAddr
|
||||
if replyAddress == "" {
|
||||
replyAddress = info.FromAddr
|
||||
}
|
||||
if replyAddrs := parseEmailAddresses(replyAddress); len(replyAddrs) > 0 {
|
||||
toAddrs = append(toAddrs, replyAddrs...)
|
||||
}
|
||||
toAddrs = append(toAddrs, info.ToAddrs...)
|
||||
|
||||
// Filter out self and deduplicate
|
||||
toAddrs = filterOutSelf(toAddrs, selfEmail)
|
||||
toAddrs = deduplicateAddresses(toAddrs)
|
||||
|
||||
// Cc recipients: original Cc, filtered
|
||||
ccAddrs := filterOutSelf(info.CcAddrs, selfEmail)
|
||||
ccAddrs = deduplicateAddresses(ccAddrs)
|
||||
|
||||
// Remove any Cc addresses that are already in To
|
||||
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 // Original sender
|
||||
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, "", 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
|
||||
}
|
||||
|
||||
// For thread replies, we always need just headers to select the latest message.
|
||||
// If includeQuoteBodies is true (quoting), fetch that single message in "full" format afterwards
|
||||
// to avoid pulling entire thread bodies.
|
||||
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 quoting, refetch the selected message in full format to get body parts.
|
||||
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
|
||||
}
|
||||
|
||||
var replyInfoMetadataHeaders = []string{"Message-ID", "Message-Id", "References", "In-Reply-To", "From", "Reply-To", "To", "Cc", "Date"}
|
||||
|
||||
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(replyInfoMetadataHeaders...)
|
||||
}
|
||||
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(replyInfoMetadataHeaders...).
|
||||
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"),
|
||||
}
|
||||
|
||||
// Include body if requested (for quoting)
|
||||
if includeQuoteBodies {
|
||||
plain := findPartBody(msg.Payload, "text/plain")
|
||||
// Some messages (or broken clients) 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")
|
||||
}
|
||||
|
||||
// Prefer Message-ID and References from the original message.
|
||||
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
|
||||
}
|
||||
|
||||
// parseEmailAddresses parses RFC 5322 email addresses from a header value.
|
||||
// Returns just the email parts (lowercased for comparison).
|
||||
func parseEmailAddresses(header string) []string {
|
||||
header = strings.TrimSpace(header)
|
||||
if header == "" {
|
||||
return nil
|
||||
}
|
||||
addrs, err := mail.ParseAddressList(header)
|
||||
if err != nil {
|
||||
// Fallback: try splitting on comma and extracting addresses manually
|
||||
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
|
||||
}
|
||||
|
||||
// parseEmailAddressesFallback handles cases where mail.ParseAddressList fails
|
||||
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
|
||||
}
|
||||
// Try to extract email from "Name <email>" format
|
||||
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
|
||||
}
|
||||
}
|
||||
// Assume it's just an email address
|
||||
if strings.Contains(p, "@") {
|
||||
result = append(result, strings.ToLower(p))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// filterOutSelf removes the sending account from the address list
|
||||
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
|
||||
}
|
||||
|
||||
// deduplicateAddresses removes duplicate email addresses (case-insensitive)
|
||||
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.
|
||||
// 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 != "":
|
||||
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")
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
@ -240,7 +240,7 @@ func fetchThreadDetails(ctx context.Context, svc *gmail.Service, threads []*gmai
|
||||
|
||||
fullThread, err := svc.Users.Threads.Get("me", threadID).
|
||||
Format("metadata").
|
||||
MetadataHeaders("From", "Subject", "Date").
|
||||
MetadataHeaders(gmailMessageSummaryMetadataHeaders...).
|
||||
Context(ctx).
|
||||
Do()
|
||||
if err != nil {
|
||||
@ -299,7 +299,7 @@ func fetchThreadDetails(ctx context.Context, svc *gmail.Service, threads []*gmai
|
||||
}
|
||||
_, err := svc.Users.Threads.Get("me", thread.Id).
|
||||
Format("metadata").
|
||||
MetadataHeaders("From", "Subject", "Date").
|
||||
MetadataHeaders(gmailMessageSummaryMetadataHeaders...).
|
||||
Context(ctx).
|
||||
Do()
|
||||
if err != nil {
|
||||
|
||||
@ -302,7 +302,7 @@ func (s *gmailWatchServer) fetchMessages(ctx context.Context, svc *gmail.Service
|
||||
}
|
||||
msg, err := svc.Users.Messages.Get("me", id).
|
||||
Format(format).
|
||||
MetadataHeaders("From", "To", "Subject", "Date").
|
||||
MetadataHeaders(gmailBasicMetadataHeaders...).
|
||||
Context(ctx).
|
||||
Do()
|
||||
if err != nil {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user