From 77a16d10efde42aeb4e577aef03f319903974381 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 08:40:43 +0100 Subject: [PATCH] feat(gmail): append send-as signatures --- CHANGELOG.md | 1 + README.md | 4 + docs/spec.md | 2 +- internal/cmd/gmail_send.go | 23 ++- internal/cmd/gmail_send_signature.go | 168 ++++++++++++++++ internal/cmd/gmail_send_signature_test.go | 224 ++++++++++++++++++++++ 6 files changed, 420 insertions(+), 2 deletions(-) create mode 100644 internal/cmd/gmail_send_signature.go create mode 100644 internal/cmd/gmail_send_signature_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 2163d60..cb4c761 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -128,6 +128,7 @@ - Calendar: add `calendar alias list|set|unset`, and let calendar commands resolve configured aliases before API/name lookup. (#393) — thanks @salmonumbrella. - Calendar: let `calendar freebusy` / `calendar conflicts` accept `--cal`, names, indices, and `--all` like `calendar events`. (#319) — thanks @salmonumbrella. - Calendar: add `calendar subscribe` (aliases `sub`, `add-calendar`) to add a shared calendar to the current account’s calendar list. (#327) — thanks @cdthompson. +- Gmail: add `gmail send --signature`, `--signature-from`, and `--signature-file` to append Gmail send-as or local signatures before sending. (#180, #183) — thanks @kesslerio and @salmonumbrella. - Gmail: add `watch serve --history-types` filtering (`messageAdded|messageDeleted|labelAdded|labelRemoved`) and include `deletedMessageIds` in webhook payloads. (#168) — thanks @salmonumbrella. - Gmail: add `gmail labels rename` to rename user labels by ID or exact name, with system-label guards and wrong-case ID safety. (#391) — thanks @adam-zethraeus. - Gmail: add `gmail messages modify` for single-message label changes, complementing thread- and batch-level modify flows. (#281) — thanks @zerone0x. diff --git a/README.md b/README.md index c81c6c8..f647ddf 100644 --- a/README.md +++ b/README.md @@ -692,6 +692,10 @@ gog gmail send --to a@b.com --subject "Hi" --body "Plain fallback" gog gmail send --to a@b.com --subject "Hi" --body-file ./message.txt gog gmail send --to a@b.com --subject "Hi" --body-file - # Read body from stdin gog gmail send --to a@b.com --subject "Hi" --body "Plain fallback" --body-html "

Hello

" +gog gmail send --to a@b.com --subject "Hi" --body "Hello" --signature +gog gmail send --to a@b.com --subject "Hi" --body "Hello" --from alias@example.com --signature +gog gmail send --to a@b.com --subject "Hi" --body "Hello" --signature-from alias@example.com +gog gmail send --to a@b.com --subject "Hi" --body "Hello" --signature-file ./signature.html gog gmail forward --to a@b.com --note "FYI" gog gmail forward --to a@b.com --skip-attachments # Reply + include quoted original message (auto-generates HTML quote unless you pass --body-html) diff --git a/docs/spec.md b/docs/spec.md index 1594431..0bcff55 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -291,7 +291,7 @@ Flag aliases: - `gog gmail labels create ` - `gog gmail labels rename ` - `gog gmail labels modify [--add ...] [--remove ...]` -- `gog gmail send --to a@b.com --subject S [--body B] [--body-html H] [--cc ...] [--bcc ...] [--reply-to-message-id ] [--reply-to addr] [--attach ...]` +- `gog gmail send --to a@b.com --subject S [--body B] [--body-html H] [--cc ...] [--bcc ...] [--reply-to-message-id ] [--reply-to addr] [--from addr] [--signature|--signature-from addr|--signature-file path] [--attach ...]` - `gog gmail drafts list [--max N] [--page TOKEN]` - `gog gmail drafts get [--download]` - `gog gmail drafts create --subject S [--to a@b.com] [--body B] [--body-html H] [--cc ...] [--bcc ...] [--reply-to-message-id ] [--reply-to addr] [--attach ...]` diff --git a/internal/cmd/gmail_send.go b/internal/cmd/gmail_send.go index c0822b6..e868798 100644 --- a/internal/cmd/gmail_send.go +++ b/internal/cmd/gmail_send.go @@ -25,6 +25,9 @@ type GmailSendCmd struct { 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)"` + Signature bool `name:"signature" help:"Append the Gmail signature from the active send-as address"` + SignatureFrom string `name:"signature-from" help:"Append the Gmail signature from this send-as email address"` + SignatureFile string `name:"signature-file" help:"Append a local signature file (plain text or HTML)"` Track bool `name:"track" help:"Enable open tracking (requires tracking setup)"` TrackSplit bool `name:"track-split" help:"Send tracked messages separately per recipient"` Quote bool `name:"quote" help:"Include quoted original message in reply (requires --reply-to-message-id or --thread-id)"` @@ -98,6 +101,9 @@ func (c *GmailSendCmd) Run(ctx context.Context, flags *RootFlags) error { if c.Track && strings.TrimSpace(c.BodyHTML) == "" { return fmt.Errorf("--track requires --body-html (pixel must be in HTML)") } + if sigErr := c.validateSignatureOptions(); sigErr != nil { + return sigErr + } attachPaths, err := expandComposeAttachmentPaths(c.Attach) if err != nil { @@ -117,6 +123,9 @@ func (c *GmailSendCmd) Run(ctx context.Context, flags *RootFlags) error { "body_len": len(strings.TrimSpace(body)), "body_html_len": len(strings.TrimSpace(c.BodyHTML)), "attachments": attachPaths, + "signature": c.Signature, + "signature_from": strings.TrimSpace(c.SignatureFrom), + "signature_file": strings.TrimSpace(c.SignatureFile), "track": c.Track, "track_split": c.TrackSplit, }); dryRunErr != nil { @@ -132,7 +141,19 @@ func (c *GmailSendCmd) Run(ctx context.Context, flags *RootFlags) error { if err != nil { return err } - replyInfo, body, htmlBody, err := prepareComposeReply(ctx, svc, replyToMessageID, threadID, c.Quote, body, c.BodyHTML) + htmlBodyInput := c.BodyHTML + if c.signatureRequested() { + signature, source, sigErr := c.resolveComposeSignature(ctx, svc, from.sendingEmail) + if sigErr != nil { + return sigErr + } + if signature.empty() { + u.Err().Printf("Warning: no signature configured for %s", source) + } else { + body, htmlBodyInput = appendComposeSignature(body, htmlBodyInput, signature) + } + } + replyInfo, body, htmlBody, err := prepareComposeReply(ctx, svc, replyToMessageID, threadID, c.Quote, body, htmlBodyInput) if err != nil { return err } diff --git a/internal/cmd/gmail_send_signature.go b/internal/cmd/gmail_send_signature.go new file mode 100644 index 0000000..677d4b3 --- /dev/null +++ b/internal/cmd/gmail_send_signature.go @@ -0,0 +1,168 @@ +package cmd + +import ( + "context" + "fmt" + stdhtml "html" + "os" + "strings" + + nethtml "golang.org/x/net/html" + "google.golang.org/api/gmail/v1" + + "github.com/steipete/gogcli/internal/config" +) + +const maxComposeSignatureFileBytes = 1 << 20 + +type composeSignature struct { + Plain string + HTML string +} + +func (s composeSignature) empty() bool { + return strings.TrimSpace(s.Plain) == "" && strings.TrimSpace(s.HTML) == "" +} + +func (c *GmailSendCmd) signatureRequested() bool { + return c.Signature || strings.TrimSpace(c.SignatureFrom) != "" || strings.TrimSpace(c.SignatureFile) != "" +} + +func (c *GmailSendCmd) validateSignatureOptions() error { + if strings.TrimSpace(c.SignatureFile) != "" && (c.Signature || strings.TrimSpace(c.SignatureFrom) != "") { + return usage("use only one of --signature/--signature-from or --signature-file") + } + return nil +} + +func (c *GmailSendCmd) resolveComposeSignature(ctx context.Context, svc *gmail.Service, sendingEmail string) (composeSignature, string, error) { + if path := strings.TrimSpace(c.SignatureFile); path != "" { + signature, err := readComposeSignatureFile(path) + return signature, path, err + } + + email := strings.TrimSpace(c.SignatureFrom) + if email == "" { + email = strings.TrimSpace(sendingEmail) + } + if email == "" { + return composeSignature{}, "", usage("missing send-as email for --signature") + } + + sendAs, err := svc.Users.Settings.SendAs.Get("me", email).Context(ctx).Do() + if err != nil { + return composeSignature{}, email, fmt.Errorf("fetch signature for %s: %w", email, err) + } + htmlSignature := strings.TrimSpace(sendAs.Signature) + return composeSignature{ + Plain: signatureHTMLToText(htmlSignature), + HTML: htmlSignature, + }, email, nil +} + +func readComposeSignatureFile(path string) (composeSignature, error) { + resolved, err := config.ExpandPath(path) + if err != nil { + return composeSignature{}, err + } + info, err := os.Stat(resolved) + if err != nil { + return composeSignature{}, fmt.Errorf("read signature file: %w", err) + } + if info.Size() > maxComposeSignatureFileBytes { + return composeSignature{}, fmt.Errorf("signature file too large: %s", resolved) + } + // #nosec G304 -- --signature-file is an explicit user-provided path. + data, err := os.ReadFile(resolved) + if err != nil { + return composeSignature{}, fmt.Errorf("read signature file: %w", err) + } + + value := strings.TrimSpace(string(data)) + if value == "" { + return composeSignature{}, nil + } + if looksLikeHTML(value) { + return composeSignature{ + Plain: signatureHTMLToText(value), + HTML: value, + }, nil + } + return composeSignature{ + Plain: value, + HTML: escapeTextToHTML(value), + }, nil +} + +func appendComposeSignature(plainBody, htmlBody string, signature composeSignature) (string, string) { + if strings.TrimSpace(signature.Plain) != "" && strings.TrimSpace(plainBody) != "" { + plainBody = appendBodyBlock(plainBody, "--\n"+strings.TrimSpace(signature.Plain)) + } + if strings.TrimSpace(signature.HTML) != "" && strings.TrimSpace(htmlBody) != "" { + htmlBody = appendBodyBlock(htmlBody, `
`+strings.TrimSpace(signature.HTML)+`
`) + } + return plainBody, htmlBody +} + +func appendBodyBlock(body, block string) string { + body = strings.TrimRight(body, "\r\n") + return body + "\n\n" + block +} + +func signatureHTMLToText(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + doc, err := nethtml.Parse(strings.NewReader(value)) + if err != nil { + return stripHTMLTags(value) + } + + var out strings.Builder + var walk func(*nethtml.Node) + walk = func(n *nethtml.Node) { + if n == nil { + return + } + switch n.Type { + case nethtml.TextNode: + out.WriteString(n.Data) + case nethtml.ElementNode: + switch strings.ToLower(n.Data) { + case "br": + writeSignatureNewline(&out) + return + case "div", "p", "li": + writeSignatureNewline(&out) + for child := n.FirstChild; child != nil; child = child.NextSibling { + walk(child) + } + writeSignatureNewline(&out) + return + } + } + for child := n.FirstChild; child != nil; child = child.NextSibling { + walk(child) + } + } + walk(doc) + + lines := strings.Split(stdhtml.UnescapeString(out.String()), "\n") + kept := make([]string, 0, len(lines)) + for _, line := range lines { + if trimmed := strings.TrimSpace(line); trimmed != "" { + kept = append(kept, trimmed) + } + } + return strings.Join(kept, "\n") +} + +func writeSignatureNewline(out *strings.Builder) { + if out.Len() == 0 { + return + } + if !strings.HasSuffix(out.String(), "\n") { + out.WriteByte('\n') + } +} diff --git a/internal/cmd/gmail_send_signature_test.go b/internal/cmd/gmail_send_signature_test.go new file mode 100644 index 0000000..c527729 --- /dev/null +++ b/internal/cmd/gmail_send_signature_test.go @@ -0,0 +1,224 @@ +package cmd + +import ( + "context" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "google.golang.org/api/gmail/v1" + + "github.com/steipete/gogcli/internal/ui" +) + +func TestGmailSendCmd_Run_WithSendAsSignature(t *testing.T) { + raw := runGmailSendWithSignatureServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/gmail/v1/users/me/settings/sendAs": + writeSendAsList(t, w, "a@b.com", "Primary User") + case r.Method == http.MethodGet && r.URL.Path == "/gmail/v1/users/me/settings/sendAs/a@b.com": + writeSendAsGet(t, w, "a@b.com", `
Kind regards
Primary User
`) + default: + http.NotFound(w, r) + } + }, &GmailSendCmd{ + To: "recipient@example.com", + Subject: "Hello", + Body: "Body", + BodyHTML: "

Body

", + Signature: true, + }) + + if !strings.Contains(raw, "Body\r\n\r\n--\r\nKind regards\r\nPrimary User") { + t.Fatalf("plain signature missing from raw message:\n%s", raw) + } + if !strings.Contains(raw, `
Kind regards
Primary User
`) { + t.Fatalf("html signature missing from raw message:\n%s", raw) + } +} + +func TestGmailSendCmd_Run_SignatureFromAlias(t *testing.T) { + raw := runGmailSendWithSignatureServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/gmail/v1/users/me/settings/sendAs": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "sendAs": []map[string]any{ + {"sendAsEmail": "a@b.com", "displayName": "Primary User", "verificationStatus": "accepted", "isPrimary": true}, + {"sendAsEmail": "alias@example.com", "displayName": "Alias", "verificationStatus": "accepted"}, + }, + }) + case r.Method == http.MethodGet && r.URL.Path == "/gmail/v1/users/me/settings/sendAs/alias@example.com": + writeSendAsGet(t, w, "alias@example.com", "

Alias signature

") + default: + http.NotFound(w, r) + } + }, &GmailSendCmd{ + To: "recipient@example.com", + Subject: "Hello", + Body: "Body", + From: "alias@example.com", + SignatureFrom: "alias@example.com", + }) + + if !strings.Contains(raw, `From: "Alias" `) { + t.Fatalf("alias From header missing from raw message:\n%s", raw) + } + if !strings.Contains(raw, "Body\r\n\r\n--\r\nAlias signature") { + t.Fatalf("alias signature missing from raw message:\n%s", raw) + } +} + +func TestGmailSendCmd_Run_WithSignatureFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "signature.txt") + if err := os.WriteFile(path, []byte("Local Sig\nhttps://example.com"), 0o600); err != nil { + t.Fatalf("write signature file: %v", err) + } + + raw := runGmailSendWithSignatureServer(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/gmail/v1/users/me/settings/sendAs": + writeSendAsList(t, w, "a@b.com", "Primary User") + default: + http.NotFound(w, r) + } + }, &GmailSendCmd{ + To: "recipient@example.com", + Subject: "Hello", + Body: "Body", + BodyHTML: "

Body

", + SignatureFile: path, + }) + + if !strings.Contains(raw, "Body\r\n\r\n--\r\nLocal Sig\r\nhttps://example.com") { + t.Fatalf("plain file signature missing from raw message:\n%s", raw) + } + if !strings.Contains(raw, "Local Sig
\r\nhttps://example.com") { + t.Fatalf("html file signature missing from raw message:\n%s", raw) + } +} + +func TestGmailSendCmd_Run_EmptySignatureWarnsAndSends(t *testing.T) { + var raw string + svc, cleanup := newGmailServiceForTest(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/gmail/v1/users/me/settings/sendAs": + writeSendAsList(t, w, "a@b.com", "Primary User") + case r.Method == http.MethodGet && r.URL.Path == "/gmail/v1/users/me/settings/sendAs/a@b.com": + writeSendAsGet(t, w, "a@b.com", "") + case r.Method == http.MethodPost && r.URL.Path == "/gmail/v1/users/me/messages/send": + writeGmailSendResponse(t, w, r, &raw) + default: + http.NotFound(w, r) + } + }) + defer cleanup() + stubGmailServiceForTest(t, svc) + + var stderr strings.Builder + ctx := newGmailSendSignatureTestContext(t, io.Discard, &stderr) + err := (&GmailSendCmd{ + To: "recipient@example.com", + Subject: "Hello", + Body: "Body", + Signature: true, + }).Run(ctx, &RootFlags{Account: "a@b.com"}) + if err != nil { + t.Fatalf("Run: %v", err) + } + + if !strings.Contains(stderr.String(), "Warning: no signature configured for a@b.com") { + t.Fatalf("expected warning, got %q", stderr.String()) + } + if raw == "" { + t.Fatal("expected message send to continue") + } +} + +func TestGmailSendCmd_Run_SignatureOptionConflict(t *testing.T) { + err := (&GmailSendCmd{ + To: "recipient@example.com", + Subject: "Hello", + Body: "Body", + Signature: true, + SignatureFile: "sig.txt", + }).Run(context.Background(), &RootFlags{Account: "a@b.com"}) + if err == nil || !strings.Contains(err.Error(), "use only one of") { + t.Fatalf("expected signature option conflict, got %v", err) + } +} + +func runGmailSendWithSignatureServer(t *testing.T, handler http.HandlerFunc, cmd *GmailSendCmd) string { + t.Helper() + + var raw string + svc, cleanup := newGmailServiceForTest(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && r.URL.Path == "/gmail/v1/users/me/messages/send" { + writeGmailSendResponse(t, w, r, &raw) + return + } + handler(w, r) + }) + defer cleanup() + stubGmailServiceForTest(t, svc) + + if err := cmd.Run(newGmailSendSignatureTestContext(t, io.Discard, io.Discard), &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("Run: %v", err) + } + return raw +} + +func newGmailSendSignatureTestContext(t *testing.T, stdout, stderr io.Writer) context.Context { + t.Helper() + u, err := ui.New(ui.Options{Stdout: stdout, Stderr: stderr, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + return ui.WithUI(context.Background(), u) +} + +func writeSendAsList(t *testing.T, w http.ResponseWriter, email, displayName string) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "sendAs": []map[string]any{ + {"sendAsEmail": email, "displayName": displayName, "verificationStatus": "accepted", "isPrimary": true}, + }, + }) +} + +func writeSendAsGet(t *testing.T, w http.ResponseWriter, email, signature string) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "sendAsEmail": email, + "verificationStatus": "accepted", + "signature": signature, + }) +} + +func writeGmailSendResponse(t *testing.T, w http.ResponseWriter, r *http.Request, rawOut *string) { + t.Helper() + + var msg gmail.Message + if err := json.NewDecoder(r.Body).Decode(&msg); err != nil { + t.Fatalf("decode sent message: %v", err) + } + raw, err := base64.RawURLEncoding.DecodeString(msg.Raw) + if err != nil { + t.Fatalf("decode raw message: %v", err) + } + *rawOut = string(raw) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "m1", + "threadId": "t1", + }) +}