gogcli/internal/cmd/gmail_send.go
2025-12-26 15:35:15 +01:00

166 lines
4.9 KiB
Go

package cmd
import (
"encoding/base64"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
"google.golang.org/api/gmail/v1"
)
func newGmailSendCmd(flags *rootFlags) *cobra.Command {
var to string
var cc string
var bcc string
var subject string
var body string
var bodyHTML string
var replyToMessageID string
var replyTo string
var attach []string
var from string
cmd := &cobra.Command{
Use: "send",
Short: "Send an email",
Long: `Send an email. Use --from to send from a configured send-as alias.
To see available send-as aliases: gog gmail sendas list`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
u := ui.FromContext(cmd.Context())
account, err := requireAccount(flags)
if err != nil {
return err
}
if strings.TrimSpace(to) == "" || strings.TrimSpace(subject) == "" {
return usage("required: --to, --subject")
}
if strings.TrimSpace(body) == "" && strings.TrimSpace(bodyHTML) == "" {
return usage("required: --body or --body-html")
}
svc, err := newGmailService(cmd.Context(), account)
if err != nil {
return err
}
// Determine the From address
fromAddr := account
if strings.TrimSpace(from) != "" {
// Validate that this is a configured send-as alias
var sa *gmail.SendAs
sa, err = svc.Users.Settings.SendAs.Get("me", from).Context(cmd.Context()).Do()
if err != nil {
return fmt.Errorf("invalid --from address %q: %w", from, err)
}
if sa.VerificationStatus != "accepted" {
return fmt.Errorf("--from address %q is not verified (status: %s)", from, sa.VerificationStatus)
}
fromAddr = from
// Include display name if set
if sa.DisplayName != "" {
fromAddr = sa.DisplayName + " <" + from + ">"
}
}
inReplyTo, references, threadID, err := replyHeaders(cmd, svc, replyToMessageID)
if err != nil {
return err
}
atts := make([]mailAttachment, 0, len(attach))
for _, p := range attach {
atts = append(atts, mailAttachment{Path: p})
}
raw, err := buildRFC822(mailOptions{
From: fromAddr,
To: splitCSV(to),
Cc: splitCSV(cc),
Bcc: splitCSV(bcc),
ReplyTo: replyTo,
Subject: subject,
Body: body,
BodyHTML: bodyHTML,
InReplyTo: inReplyTo,
References: references,
Attachments: atts,
})
if err != nil {
return err
}
msg := &gmail.Message{
Raw: base64.RawURLEncoding.EncodeToString(raw),
}
if threadID != "" {
msg.ThreadId = threadID
}
sent, err := svc.Users.Messages.Send("me", msg).Context(cmd.Context()).Do()
if err != nil {
return err
}
if outfmt.IsJSON(cmd.Context()) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"messageId": sent.Id,
"threadId": sent.ThreadId,
"from": fromAddr,
})
}
u.Out().Printf("message_id\t%s", sent.Id)
if sent.ThreadId != "" {
u.Out().Printf("thread_id\t%s", sent.ThreadId)
}
return nil
},
}
cmd.Flags().StringVar(&to, "to", "", "Recipients (comma-separated, required)")
cmd.Flags().StringVar(&cc, "cc", "", "CC recipients (comma-separated)")
cmd.Flags().StringVar(&bcc, "bcc", "", "BCC recipients (comma-separated)")
cmd.Flags().StringVar(&subject, "subject", "", "Subject (required)")
cmd.Flags().StringVar(&body, "body", "", "Body (plain text; required unless --body-html is set)")
cmd.Flags().StringVar(&bodyHTML, "body-html", "", "Body (HTML; optional)")
cmd.Flags().StringVar(&replyToMessageID, "reply-to-message-id", "", "Reply to Gmail message ID (sets In-Reply-To/References and thread)")
cmd.Flags().StringVar(&replyTo, "reply-to", "", "Reply-To header address")
cmd.Flags().StringSliceVar(&attach, "attach", nil, "Attachment file path (repeatable)")
cmd.Flags().StringVar(&from, "from", "", "Send from this email address (must be a verified send-as alias)")
return cmd
}
func replyHeaders(cmd *cobra.Command, svc *gmail.Service, replyToMessageID string) (inReplyTo string, references string, threadID string, err error) {
replyToMessageID = strings.TrimSpace(replyToMessageID)
if replyToMessageID == "" {
return "", "", "", nil
}
msg, err := svc.Users.Messages.Get("me", replyToMessageID).
Format("metadata").
MetadataHeaders("Message-ID", "Message-Id", "References", "In-Reply-To").
Context(cmd.Context()).
Do()
if err != nil {
return "", "", "", err
}
threadID = msg.ThreadId
// Prefer Message-ID and References from the original message.
messageID := headerValue(msg.Payload, "Message-ID")
if messageID == "" {
messageID = headerValue(msg.Payload, "Message-Id")
}
inReplyTo = messageID
references = strings.TrimSpace(headerValue(msg.Payload, "References"))
if references == "" {
references = messageID
} else if messageID != "" && !strings.Contains(references, messageID) {
references = references + " " + messageID
}
return inReplyTo, references, threadID, nil
}