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 / ) 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, ""); i != -1 { return htmlBody[:i] + pixelHTML + htmlBody[i:] } if i := strings.LastIndex(lower, ""); 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 " 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 }