gogcli/internal/cmd/gmail_send.go
2026-01-15 09:51:19 +00:00

621 lines
18 KiB
Go

package cmd
import (
"context"
"encoding/base64"
"fmt"
"net/mail"
"os"
"strings"
"google.golang.org/api/gmail/v1"
"github.com/steipete/gogcli/internal/config"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/tracking"
"github.com/steipete/gogcli/internal/ui"
)
type GmailSendCmd struct {
To string `name:"to" help:"Recipients (comma-separated; required unless --reply-all is used)"`
Cc string `name:"cc" help:"CC recipients (comma-separated)"`
Bcc string `name:"bcc" help:"BCC recipients (comma-separated)"`
Subject string `name:"subject" help:"Subject (required)"`
Body string `name:"body" help:"Body (plain text; required unless --body-html is set)"`
BodyFile string `name:"body-file" help:"Body file path (plain text; '-' for stdin)"`
BodyHTML string `name:"body-html" help:"Body (HTML; optional)"`
ReplyToMessageID string `name:"reply-to-message-id" aliases:"in-reply-to" help:"Reply to Gmail message ID (sets In-Reply-To/References and thread)"`
ThreadID string `name:"thread-id" help:"Reply within a Gmail thread (uses latest message for headers)"`
ReplyAll bool `name:"reply-all" help:"Auto-populate recipients from original message (requires --reply-to-message-id or --thread-id)"`
ReplyTo string `name:"reply-to" help:"Reply-To header address"`
Attach []string `name:"attach" help:"Attachment file path (repeatable)"`
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"`
}
type sendBatch struct {
To []string
Cc []string
Bcc []string
TrackingRecipient string
}
type sendResult struct {
To string
MessageID string
ThreadID string
TrackingID string
}
type sendMessageOptions struct {
FromAddr string
ReplyTo string
Subject string
Body string
BodyHTML string
ReplyInfo *replyInfo
Attachments []mailAttachment
Track bool
TrackingCfg *tracking.Config
}
func (c *GmailSendCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
replyToMessageID := strings.TrimSpace(c.ReplyToMessageID)
threadID := strings.TrimSpace(c.ThreadID)
body, err := resolveBodyInput(c.Body, c.BodyFile)
if err != nil {
return err
}
if replyToMessageID != "" && threadID != "" {
return usage("use only one of --reply-to-message-id or --thread-id")
}
// Validate --reply-all requires a reply target
if c.ReplyAll && replyToMessageID == "" && threadID == "" {
return usage("--reply-all 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)")
}
if strings.TrimSpace(c.Subject) == "" {
return usage("required: --subject")
}
if strings.TrimSpace(body) == "" && strings.TrimSpace(c.BodyHTML) == "" {
return usage("required: --body, --body-file, or --body-html")
}
if c.TrackSplit && !c.Track {
return usage("--track-split requires --track")
}
svc, err := newGmailService(ctx, account)
if err != nil {
return err
}
// Determine the From address
fromAddr := account
sendingEmail := account // The email we're sending from (without display name)
if strings.TrimSpace(c.From) != "" {
// Validate that this is a configured send-as alias
var sa *gmail.SendAs
sa, err = svc.Users.Settings.SendAs.Get("me", c.From).Context(ctx).Do()
if err != nil {
return fmt.Errorf("invalid --from address %q: %w", c.From, err)
}
if sa.VerificationStatus != gmailVerificationAccepted {
return fmt.Errorf("--from address %q is not verified (status: %s)", c.From, sa.VerificationStatus)
}
sendingEmail = c.From
fromAddr = c.From
// Include display name if set
if sa.DisplayName != "" {
fromAddr = sa.DisplayName + " <" + c.From + ">"
}
}
// Fetch reply info (includes recipient headers for reply-all)
replyInfo, err := fetchReplyInfo(ctx, svc, replyToMessageID, threadID)
if err != nil {
return err
}
// Determine recipients
var toRecipients, ccRecipients []string
if c.ReplyAll {
// Auto-populate recipients from original message
toRecipients, ccRecipients = buildReplyAllRecipients(replyInfo, sendingEmail)
}
// Explicit --to and --cc override (not merge with) auto-populated recipients
if strings.TrimSpace(c.To) != "" {
toRecipients = splitCSV(c.To)
}
if strings.TrimSpace(c.Cc) != "" {
ccRecipients = splitCSV(c.Cc)
}
// Final validation: we must have at least one recipient
if len(toRecipients) == 0 {
return usage("no recipients: specify --to or use --reply-all with a message that has recipients")
}
bccRecipients := splitCSV(c.Bcc)
atts := make([]mailAttachment, 0, len(c.Attach))
for _, p := range c.Attach {
expanded, expandErr := config.ExpandPath(p)
if expandErr != nil {
return expandErr
}
atts = append(atts, mailAttachment{Path: expanded})
}
var trackingCfg *tracking.Config
if c.Track {
trackingCfg, err = c.resolveTrackingConfig(account, toRecipients, ccRecipients, bccRecipients)
if err != nil {
return err
}
}
batches := buildSendBatches(toRecipients, ccRecipients, bccRecipients, c.Track, c.TrackSplit)
results, err := sendGmailBatches(ctx, svc, sendMessageOptions{
FromAddr: fromAddr,
ReplyTo: c.ReplyTo,
Subject: c.Subject,
Body: body,
BodyHTML: c.BodyHTML,
ReplyInfo: replyInfo,
Attachments: atts,
Track: c.Track,
TrackingCfg: trackingCfg,
}, batches)
if err != nil {
return err
}
return writeSendResults(ctx, u, fromAddr, results)
}
func (c *GmailSendCmd) resolveTrackingConfig(account string, toRecipients, ccRecipients, bccRecipients []string) (*tracking.Config, error) {
totalRecipients := len(toRecipients) + len(ccRecipients) + len(bccRecipients)
if totalRecipients != 1 && !c.TrackSplit {
return nil, usage("--track requires exactly 1 recipient (no cc/bcc); use --track-split for per-recipient sends")
}
if strings.TrimSpace(c.BodyHTML) == "" {
return nil, fmt.Errorf("--track requires --body-html (pixel must be in HTML)")
}
trackingCfg, err := tracking.LoadConfig(account)
if err != nil {
return nil, fmt.Errorf("load tracking config: %w", err)
}
if !trackingCfg.IsConfigured() {
return nil, fmt.Errorf("tracking not configured; run 'gog gmail track setup' first")
}
return trackingCfg, nil
}
func buildSendBatches(toRecipients, ccRecipients, bccRecipients []string, track, trackSplit bool) []sendBatch {
totalRecipients := len(toRecipients) + len(ccRecipients) + len(bccRecipients)
if track && trackSplit && totalRecipients > 1 {
recipients := append(append(append([]string{}, toRecipients...), ccRecipients...), bccRecipients...)
recipients = deduplicateAddresses(recipients)
batches := make([]sendBatch, 0, len(recipients))
for _, recipient := range recipients {
batches = append(batches, sendBatch{
To: []string{recipient},
TrackingRecipient: recipient,
})
}
return batches
}
trackingRecipient := firstRecipient(toRecipients, ccRecipients, bccRecipients)
return []sendBatch{{
To: toRecipients,
Cc: ccRecipients,
Bcc: bccRecipients,
TrackingRecipient: trackingRecipient,
}}
}
func sendGmailBatches(ctx context.Context, svc *gmail.Service, opts sendMessageOptions, batches []sendBatch) ([]sendResult, error) {
reply := replyInfo{}
if opts.ReplyInfo != nil {
reply = *opts.ReplyInfo
}
results := make([]sendResult, 0, len(batches))
for _, batch := range batches {
htmlBody := opts.BodyHTML
trackingID := ""
if opts.Track {
recipient := strings.TrimSpace(batch.TrackingRecipient)
if recipient == "" {
recipient = strings.TrimSpace(firstRecipient(batch.To, batch.Cc, batch.Bcc))
}
pixelURL, blob, pixelErr := tracking.GeneratePixelURL(opts.TrackingCfg, recipient, opts.Subject)
if pixelErr != nil {
return nil, fmt.Errorf("generate tracking pixel: %w", pixelErr)
}
trackingID = blob
// Inject pixel into HTML body (prefer before </body> / </html>)
pixelHTML := tracking.GeneratePixelHTML(pixelURL)
htmlBody = injectTrackingPixelHTML(htmlBody, pixelHTML)
}
raw, err := buildRFC822(mailOptions{
From: opts.FromAddr,
To: batch.To,
Cc: batch.Cc,
Bcc: batch.Bcc,
ReplyTo: opts.ReplyTo,
Subject: opts.Subject,
Body: opts.Body,
BodyHTML: htmlBody,
InReplyTo: reply.InReplyTo,
References: reply.References,
Attachments: opts.Attachments,
}, nil)
if err != nil {
return nil, err
}
msg := &gmail.Message{
Raw: base64.RawURLEncoding.EncodeToString(raw),
}
if reply.ThreadID != "" {
msg.ThreadId = reply.ThreadID
}
sent, err := svc.Users.Messages.Send("me", msg).Context(ctx).Do()
if err != nil {
return nil, err
}
resultRecipient := strings.TrimSpace(batch.TrackingRecipient)
if resultRecipient == "" {
resultRecipient = strings.TrimSpace(firstRecipient(batch.To, batch.Cc, batch.Bcc))
}
results = append(results, sendResult{
To: resultRecipient,
MessageID: sent.Id,
ThreadID: sent.ThreadId,
TrackingID: trackingID,
})
}
return results, nil
}
func writeSendResults(ctx context.Context, u *ui.UI, fromAddr string, results []sendResult) error {
if outfmt.IsJSON(ctx) {
if len(results) == 1 {
resp := map[string]any{
"messageId": results[0].MessageID,
"threadId": results[0].ThreadID,
"from": fromAddr,
}
if results[0].TrackingID != "" {
resp["tracking_id"] = results[0].TrackingID
}
return outfmt.WriteJSON(os.Stdout, resp)
}
items := make([]map[string]any, 0, len(results))
for _, r := range results {
item := map[string]any{
"messageId": r.MessageID,
"threadId": r.ThreadID,
"from": fromAddr,
}
if r.To != "" {
item["to"] = r.To
}
if r.TrackingID != "" {
item["tracking_id"] = r.TrackingID
}
items = append(items, item)
}
return outfmt.WriteJSON(os.Stdout, map[string]any{"messages": items})
}
if len(results) == 1 {
u.Out().Printf("message_id\t%s", results[0].MessageID)
if results[0].ThreadID != "" {
u.Out().Printf("thread_id\t%s", results[0].ThreadID)
}
if results[0].TrackingID != "" {
u.Out().Printf("tracking_id\t%s", results[0].TrackingID)
}
return nil
}
for i, r := range results {
if i > 0 {
u.Out().Println("")
}
if r.To != "" {
u.Out().Printf("to\t%s", r.To)
}
u.Out().Printf("message_id\t%s", r.MessageID)
if r.ThreadID != "" {
u.Out().Printf("thread_id\t%s", r.ThreadID)
}
if r.TrackingID != "" {
u.Out().Printf("tracking_id\t%s", r.TrackingID)
}
}
return nil
}
func firstRecipient(toRecipients, ccRecipients, bccRecipients []string) string {
if len(toRecipients) > 0 {
return toRecipients[0]
}
if len(ccRecipients) > 0 {
return ccRecipients[0]
}
if len(bccRecipients) > 0 {
return bccRecipients[0]
}
return ""
}
func injectTrackingPixelHTML(htmlBody, pixelHTML string) string {
lower := strings.ToLower(htmlBody)
if i := strings.LastIndex(lower, "</body>"); i != -1 {
return htmlBody[:i] + pixelHTML + htmlBody[i:]
}
if i := strings.LastIndex(lower, "</html>"); i != -1 {
return htmlBody[:i] + pixelHTML + htmlBody[i:]
}
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
}
func replyHeaders(ctx context.Context, svc *gmail.Service, replyToMessageID string) (inReplyTo string, references string, threadID string, err error) {
info, err := fetchReplyInfo(ctx, svc, replyToMessageID, "")
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) {
replyToMessageID = strings.TrimSpace(replyToMessageID)
threadID = strings.TrimSpace(threadID)
if replyToMessageID == "" && threadID == "" {
return &replyInfo{}, nil
}
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()
if err != nil {
return nil, err
}
return replyInfoFromMessage(msg), 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()
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)
}
info := replyInfoFromMessage(msg)
if info.ThreadID == "" {
info.ThreadID = thread.Id
}
return info, nil
}
func replyInfoFromMessage(msg *gmail.Message) *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")),
}
// 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
}