diff --git a/CHANGELOG.md b/CHANGELOG.md index d9519504a..bfb8533e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 0.43.1 - Unreleased +- Gmail: add `--signature`, `--signature-from`, and `--signature-file` to `gmail drafts create`, `gmail drafts update`, `gmail forward`, and `gmail drafts forward`; the signature follows the sending alias and sits above the quote or between the note and the forwarded message. (#1193) — thanks @adameq. - Docker: pre-create `/persist/gogcli` owned by the non-root `gog` user so the documented named-volume `GOG_HOME` is writable on first run, and document the one-time ownership step for bind mounts and pre-existing volumes. (#1197) — thanks @wstock. - Dependencies: refresh Google API and telemetry modules, tracking-worker packages, pnpm 11, and x/tools while retaining the Go 1.26 floor and worker release cooldown. - Gmail: include recipient headers and optionally sanitize message-search content through CLI and MCP; preserve untrusted sender/recipient wrapping across JSON projection. (#1188) — thanks @kendrickkester. diff --git a/docs/commands/gog-gmail-drafts-create.md b/docs/commands/gog-gmail-drafts-create.md index 0c701f5ed..34d9b0fad 100644 --- a/docs/commands/gog-gmail-drafts-create.md +++ b/docs/commands/gog-gmail-drafts-create.md @@ -51,6 +51,9 @@ gog gmail (mail,email) drafts (draft) create (add,new) [flags] | `--reply-to-message-id` | `string` | | Reply to Gmail message ID (sets In-Reply-To/References and thread) | | `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | | `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `--signature` | `bool` | | Append the Gmail signature from the active send-as address | +| `--signature-file` | `string` | | Append a local signature file (plain text or HTML) | +| `--signature-from` | `string` | | Append the Gmail signature from this send-as email address | | `--subject` | `string` | | Subject (required) | | `--thread-id` | `string` | | Reply within a Gmail thread (uses latest message for headers; raw mode sets only the thread ID) | | `--to` | `string` | | Recipients (comma-separated) | diff --git a/docs/commands/gog-gmail-drafts-forward.md b/docs/commands/gog-gmail-drafts-forward.md index 673061b1b..280a120c5 100644 --- a/docs/commands/gog-gmail-drafts-forward.md +++ b/docs/commands/gog-gmail-drafts-forward.md @@ -42,6 +42,9 @@ gog gmail (mail,email) drafts (draft) forward (fwd) [flags] | `--readonly` | `bool` | false | Block mutating API requests at runtime; auth add also requests read-only OAuth scopes | | `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | | `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `--signature` | `bool` | | Append the Gmail signature from the active send-as address | +| `--signature-file` | `string` | | Append a local signature file (plain text or HTML) | +| `--signature-from` | `string` | | Append the Gmail signature from this send-as email address | | `--skip-attachments` | `bool` | | Do not include original attachments | | `--to` | `string` | | Recipients (comma-separated; required when sending, optional when saving a draft) | | `-v`
`--verbose` | `bool` | | Enable verbose logging | diff --git a/docs/commands/gog-gmail-drafts-update.md b/docs/commands/gog-gmail-drafts-update.md index 4a8091a72..031ca2e9d 100644 --- a/docs/commands/gog-gmail-drafts-update.md +++ b/docs/commands/gog-gmail-drafts-update.md @@ -53,6 +53,9 @@ gog gmail (mail,email) drafts (draft) update (edit,set) [flags] | `--reply-to-message-id` | `string` | | Reply to Gmail message ID (sets In-Reply-To/References and thread) | | `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | | `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `--signature` | `bool` | | Append the Gmail signature from the active send-as address | +| `--signature-file` | `string` | | Append a local signature file (plain text or HTML) | +| `--signature-from` | `string` | | Append the Gmail signature from this send-as email address | | `--subject` | `string` | | Subject (required) | | `--thread-id` | `string` | | Reply within a Gmail thread (raw mode sets only the thread ID); overrides the draft's existing thread | | `--to` | `*string` | | Recipients (comma-separated; omit to keep existing) | diff --git a/docs/commands/gog-gmail-forward.md b/docs/commands/gog-gmail-forward.md index 79ecc6329..9bea9ff86 100644 --- a/docs/commands/gog-gmail-forward.md +++ b/docs/commands/gog-gmail-forward.md @@ -42,6 +42,9 @@ gog gmail (mail,email) forward (fwd) [flags] | `--readonly` | `bool` | false | Block mutating API requests at runtime; auth add also requests read-only OAuth scopes | | `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | | `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | +| `--signature` | `bool` | | Append the Gmail signature from the active send-as address | +| `--signature-file` | `string` | | Append a local signature file (plain text or HTML) | +| `--signature-from` | `string` | | Append the Gmail signature from this send-as email address | | `--skip-attachments` | `bool` | | Do not include original attachments | | `--to` | `string` | | Recipients (comma-separated; required when sending, optional when saving a draft) | | `-v`
`--verbose` | `bool` | | Enable verbose logging | diff --git a/docs/gmail-workflows.md b/docs/gmail-workflows.md index bae3dba8e..06649162d 100644 --- a/docs/gmail-workflows.md +++ b/docs/gmail-workflows.md @@ -166,6 +166,18 @@ This creates an HTML draft; it does not change plain-text MIME behavior. Check the draft and recipient-client rendering before relying on the workaround for a particular workflow. +## Signatures on drafts + +`gmail drafts create` and `gmail drafts update` accept the same `--signature`, +`--signature-from`, and `--signature-file` flags as `gmail send`. Gmail does not +add a signature to drafts created through the API, so these flags append it, +above any `--quote`. When `--auto-from-addressed-alias` picks the sender, +`--signature` uses that alias. `drafts update` appends the signature to the body +given in that call. + +An HTML signature adds an HTML alternative when the supplied body is plain text, +preserving image-only signatures and escaping the original body text. + ## Stage an exact RFC822 message as a draft Use the same prebuilt MIME file as `gmail send --raw-file` to stage a draft @@ -192,7 +204,7 @@ Rules: - Recipients may be omitted for a draft; any supplied recipient addresses must be valid. Direct access tokens and ADC require an explicit `--account`. - Compose flags cannot be combined with raw input, including body, recipient, - attachment, sender, reply, quote, and update's clear flags. If + attachment, sender, reply, quote, signature, and update's clear flags. If `GOG_GMAIL_AUTO_FROM_ADDRESSED_ALIAS` is enabled, disable it for raw mode with `--auto-from-addressed-alias=false`. - Optional `--thread-id` sets only `message.threadId`; it does not fetch a reply @@ -409,6 +421,14 @@ gog gmail drafts forward --note "FYI" Remote HTTP images remain remote references. Only MIME parts referenced with `cid:` are copied into the outgoing message. +`gmail forward` and `gmail drafts forward` accept the same `--signature`, +`--signature-from`, and `--signature-file` flags as `gmail send`. Gmail does not +add a signature to messages created through the API, so these flags append it +between the `--note` and the forwarded message. When the original has only a +plain-text body, or no body text at all, and the signature has HTML, the forward +also gets an HTML part with the escaped original text, so an image-only +signature is not lost. + `gmail send --reply-to-message-id` remains available as lower-level composition. It now inherits an omitted subject, but its explicit `--to` and `--cc` values retain replacement semantics and quoting remains opt-in. Prefer diff --git a/internal/cmd/execute_gmail_forward_test.go b/internal/cmd/execute_gmail_forward_test.go index 4304a648b..bfdf768b3 100644 --- a/internal/cmd/execute_gmail_forward_test.go +++ b/internal/cmd/execute_gmail_forward_test.go @@ -316,6 +316,7 @@ func TestStripForwardPrefix(t *testing.T) { func TestFormatForwardedMessage(t *testing.T) { result := formatForwardedMessage( "See below", + composeSignature{}, "Alice ", "Mon, 10 Mar 2026 09:00:00 -0400", "Test Subject", @@ -344,7 +345,7 @@ func TestFormatForwardedMessage(t *testing.T) { } func TestFormatForwardedMessage_NoNote(t *testing.T) { - result := formatForwardedMessage("", "from@x.com", "", "Subj", "to@x.com", "", "Body.", time.UTC) + result := formatForwardedMessage("", composeSignature{}, "from@x.com", "", "Subj", "to@x.com", "", "Body.", time.UTC) if strings.HasPrefix(result, "\n\n------") { // Should not have leading blank lines when note is empty. t.Errorf("expected no leading blank lines when note is empty") @@ -357,6 +358,7 @@ func TestFormatForwardedMessage_NoNote(t *testing.T) { func TestFormatForwardedMessageHTML(t *testing.T) { result := formatForwardedMessageHTML( "Check this out", + composeSignature{}, "Alice ", "Mon, 10 Mar 2026 09:00:00 -0400", "Test", diff --git a/internal/cmd/gmail_drafts.go b/internal/cmd/gmail_drafts.go index 5d54ff7dd..8c87138a1 100644 --- a/internal/cmd/gmail_drafts.go +++ b/internal/cmd/gmail_drafts.go @@ -295,23 +295,24 @@ func (c *GmailDraftsSendCmd) Run(ctx context.Context, flags *RootFlags) error { } type GmailDraftsCreateCmd struct { - RawFile string `name:"raw-file" help:"Create a draft from an exact RFC822 message file, or '-' for stdin (cannot be combined with compose flags)"` - To string `name:"to" help:"Recipients (comma-separated)"` - 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)"` - BodyHTMLFile string `name:"body-html-file" help:"HTML body file path ('-' for stdin)"` - ReplyToMessageID string `name:"reply-to-message-id" 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; raw mode sets only the thread ID)"` - 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"` - Quote bool `name:"quote" help:"Include quoted original message in reply (requires --reply-to-message-id or --thread-id)"` - 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)"` - AutoFromAddressedAlias bool `name:"auto-from-addressed-alias" help:"When --from is omitted, reply from the verified send-as alias addressed by the original message" env:"GOG_GMAIL_AUTO_FROM_ADDRESSED_ALIAS"` + RawFile string `name:"raw-file" help:"Create a draft from an exact RFC822 message file, or '-' for stdin (cannot be combined with compose flags)"` + To string `name:"to" help:"Recipients (comma-separated)"` + 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)"` + BodyHTMLFile string `name:"body-html-file" help:"HTML body file path ('-' for stdin)"` + ReplyToMessageID string `name:"reply-to-message-id" 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; raw mode sets only the thread ID)"` + 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"` + Quote bool `name:"quote" help:"Include quoted original message in reply (requires --reply-to-message-id or --thread-id)"` + 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)"` + AutoFromAddressedAlias bool `name:"auto-from-addressed-alias" help:"When --from is omitted, reply from the verified send-as alias addressed by the original message" env:"GOG_GMAIL_AUTO_FROM_ADDRESSED_ALIAS"` + composeSignatureOptions `embed:""` } type draftComposeInput struct { @@ -347,6 +348,7 @@ type draftComposeInput struct { KeptToRecipients []string From string AutoFromAddressedAlias bool + composeSignatureOptions } func (c draftComposeInput) validate() error { @@ -363,7 +365,7 @@ func (c draftComposeInput) validate() error { if strings.TrimSpace(c.Body) == "" && strings.TrimSpace(c.BodyHTML) == "" { return usage("required: --body, --body-file, --body-html, or --body-html-file") } - return nil + return c.validateSignatureOptions() } // keptDraftRecipients converts an existing draft's To header into the @@ -389,10 +391,11 @@ func buildDraftMessage(ctx context.Context, svc *gmail.Service, account string, return nil, draftThreading{}, nil, err } - info, body, htmlBody, err := prepareComposeReply(ctx, svc, input.ReplyToMessageID, input.ReplyToThreadID, input.Quote, input.Body, input.BodyHTML) + info, err := fetchReplyInfo(ctx, svc, input.ReplyToMessageID, input.ReplyToThreadID, input.Quote, "") if err != nil { return nil, draftThreading{}, nil, err } + body, htmlBody := input.Body, input.BodyHTML replyContextSource := "" if strings.TrimSpace(info.InReplyTo) != "" { replyContextSource = replyContextCaller @@ -427,6 +430,20 @@ func buildDraftMessage(ctx context.Context, svc *gmail.Service, account string, } } } + // The signature belongs to the identity that actually sends, so resolve it + // after the alias is picked, and add it before the quote so it stays above it. + signature, err := input.requestedSignature(ctx, svc, from.sendingEmail) + if err != nil { + return nil, draftThreading{}, nil, err + } + if htmlBody == "" && signature.htmlBlock() != "" { + htmlBody = escapeTextToHTML(body) + } + body, htmlBody = appendComposeSignature(body, htmlBody, signature) + body, htmlBody, err = applyReplyQuote(ctx, input.Quote, info, body, htmlBody) + if err != nil { + return nil, draftThreading{}, nil, err + } atts := attachmentsFromPaths(input.Attach) atts = append(atts, input.PrebuiltAttachments...) atts = append(atts, info.InlineResources...) @@ -783,20 +800,21 @@ func (c *GmailDraftsCreateCmd) Run(ctx context.Context, flags *RootFlags) error } input := draftComposeInput{ - To: c.To, - Cc: c.Cc, - Bcc: c.Bcc, - Subject: c.Subject, - Body: body, - BodyHTML: htmlBody, - ReplyToMessageID: replyToMessageID, - ReplyToThreadID: threadID, - ReplyAll: c.ReplyAll, - ReplyTo: c.ReplyTo, - Quote: c.Quote, - Attach: attachPaths, - From: c.From, - AutoFromAddressedAlias: c.AutoFromAddressedAlias, + To: c.To, + Cc: c.Cc, + Bcc: c.Bcc, + Subject: c.Subject, + Body: body, + BodyHTML: htmlBody, + ReplyToMessageID: replyToMessageID, + ReplyToThreadID: threadID, + ReplyAll: c.ReplyAll, + ReplyTo: c.ReplyTo, + Quote: c.Quote, + Attach: attachPaths, + From: c.From, + AutoFromAddressedAlias: c.AutoFromAddressedAlias, + composeSignatureOptions: c.composeSignatureOptions, } if validateErr := input.validate(); validateErr != nil { return validateErr @@ -826,6 +844,9 @@ func (c *GmailDraftsCreateCmd) Run(ctx context.Context, flags *RootFlags) error "from": strings.TrimSpace(input.From), "auto_from_addressed_alias": input.AutoFromAddressedAlias, "attachments": attachPaths, + "signature": input.Signature, + "signature_from": strings.TrimSpace(input.SignatureFrom), + "signature_file": strings.TrimSpace(input.SignatureFile), }); dryRunErr != nil { return dryRunErr } @@ -866,9 +887,10 @@ type GmailDraftsUpdateCmd struct { Attach []string `name:"attach" help:"Attachment file path (repeatable). Replaces existing attachments; omit to preserve them, or use --clear-attachments to remove all."` ClearAttachments bool `name:"clear-attachments" help:"Remove all attachments from the draft. By default, omitting --attach preserves the draft's existing attachments."` //nolint:lll // flag help text - ClearReplyContext bool `name:"clear-reply-context" help:"Strip In-Reply-To/References from the draft, making it a standalone message. By default an update preserves the draft's existing reply headers."` - From string `name:"from" help:"Send from this email address (must be a verified send-as alias)"` - AutoFromAddressedAlias bool `name:"auto-from-addressed-alias" help:"When --from is omitted, reply from the verified send-as alias addressed by the original message" env:"GOG_GMAIL_AUTO_FROM_ADDRESSED_ALIAS"` + ClearReplyContext bool `name:"clear-reply-context" help:"Strip In-Reply-To/References from the draft, making it a standalone message. By default an update preserves the draft's existing reply headers."` + From string `name:"from" help:"Send from this email address (must be a verified send-as alias)"` + AutoFromAddressedAlias bool `name:"auto-from-addressed-alias" help:"When --from is omitted, reply from the verified send-as alias addressed by the original message" env:"GOG_GMAIL_AUTO_FROM_ADDRESSED_ALIAS"` + composeSignatureOptions `embed:""` } func (c *GmailDraftsUpdateCmd) Run(ctx context.Context, flags *RootFlags) error { @@ -917,20 +939,21 @@ func (c *GmailDraftsUpdateCmd) runCompose(ctx context.Context, flags *RootFlags) preserveAttachments := len(attachPaths) == 0 && !c.ClearAttachments input := draftComposeInput{ - To: to, - Cc: c.Cc, - Bcc: c.Bcc, - Subject: c.Subject, - Body: body, - BodyHTML: htmlBody, - ReplyToMessageID: replyToMessageID, - ReplyToThreadID: threadID, - ReplyAll: c.ReplyAll, - ReplyTo: c.ReplyTo, - Quote: c.Quote, - Attach: attachPaths, - From: c.From, - AutoFromAddressedAlias: c.AutoFromAddressedAlias, + To: to, + Cc: c.Cc, + Bcc: c.Bcc, + Subject: c.Subject, + Body: body, + BodyHTML: htmlBody, + ReplyToMessageID: replyToMessageID, + ReplyToThreadID: threadID, + ReplyAll: c.ReplyAll, + ReplyTo: c.ReplyTo, + Quote: c.Quote, + Attach: attachPaths, + From: c.From, + AutoFromAddressedAlias: c.AutoFromAddressedAlias, + composeSignatureOptions: c.composeSignatureOptions, } if validateErr := input.validate(); validateErr != nil { return validateErr @@ -967,6 +990,9 @@ func (c *GmailDraftsUpdateCmd) runCompose(ctx context.Context, flags *RootFlags) "preserve_attachments": preserveAttachments, "thread_id": strings.TrimSpace(threadID), "clear_reply_context": c.ClearReplyContext, + "signature": input.Signature, + "signature_from": strings.TrimSpace(input.SignatureFrom), + "signature_file": strings.TrimSpace(input.SignatureFile), }); dryRunErr != nil { return dryRunErr } diff --git a/internal/cmd/gmail_drafts_raw.go b/internal/cmd/gmail_drafts_raw.go index 539dbfb62..696e0be46 100644 --- a/internal/cmd/gmail_drafts_raw.go +++ b/internal/cmd/gmail_drafts_raw.go @@ -32,7 +32,7 @@ func validateRawDraftFlagPresence(kctx *kong.Context, source string) error { } for _, flag := range []string{ "to", "cc", "bcc", "subject", "body", "body-file", "body-html", "body-html-file", - "attach", "from", "reply-to", "reply-to-message-id", + "attach", "from", "reply-to", "reply-to-message-id", "signature-from", "signature-file", } { if flagProvided(kctx, flag) { return usagef("--raw-file cannot be combined with --%s", flag) @@ -50,6 +50,7 @@ func (c *GmailDraftsCreateCmd) rawModeConflict() string { Body: c.Body, BodyFile: c.BodyFile, BodyHTML: c.BodyHTML, BodyHTMLFile: c.BodyHTMLFile, ReplyToMessageID: c.ReplyToMessageID, ReplyAll: c.ReplyAll, ReplyTo: c.ReplyTo, Attach: c.Attach, From: c.From, Quote: c.Quote, + composeSignatureOptions: c.composeSignatureOptions, } return compose.rawModeConflict() } @@ -69,6 +70,7 @@ func (c *GmailDraftsUpdateCmd) rawModeConflict() string { Body: c.Body, BodyFile: c.BodyFile, BodyHTML: c.BodyHTML, BodyHTMLFile: c.BodyHTMLFile, ReplyToMessageID: c.ReplyToMessageID, ReplyAll: c.ReplyAll, ReplyTo: c.ReplyTo, Attach: c.Attach, From: c.From, Quote: c.Quote, AutoFromAddressedAlias: c.AutoFromAddressedAlias, + composeSignatureOptions: c.composeSignatureOptions, } return compose.rawModeConflict() } diff --git a/internal/cmd/gmail_drafts_signature_test.go b/internal/cmd/gmail_drafts_signature_test.go new file mode 100644 index 000000000..6481411eb --- /dev/null +++ b/internal/cmd/gmail_drafts_signature_test.go @@ -0,0 +1,538 @@ +package cmd + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "google.golang.org/api/gmail/v1" + + "github.com/openclaw/gogcli/internal/app" +) + +type signatureCapture struct { + raw string + sigFetchedFor []string +} + +// newSignatureComposeService serves send-as (primary me@example.com plus +// alias@example.com), per-address signatures (an empty value in signatures +// means "configured but empty"), the reply source message, an existing draft d1, +// and captures the Raw of whatever draft is created (POST) or updated (PUT) +// or message is sent (messages/send). +func newSignatureComposeService(t *testing.T, signatures map[string]string, source map[string]any) (*gmail.Service, *signatureCapture, func()) { + t.Helper() + captured := &signatureCapture{} + svc, cleanup := newGmailServiceForTest(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + const sendAsPrefix = "/gmail/v1/users/me/settings/sendAs/" + switch { + case r.Method == http.MethodGet && r.URL.Path == "/gmail/v1/users/me/settings/sendAs": + sendAsListHandler(w) + case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, sendAsPrefix): + email := strings.TrimPrefix(r.URL.Path, sendAsPrefix) + captured.sigFetchedFor = append(captured.sigFetchedFor, email) + _ = json.NewEncoder(w).Encode(map[string]any{"sendAsEmail": email, "signature": signatures[email]}) + case r.Method == http.MethodGet && r.URL.Path == "/gmail/v1/users/me/messages/msg-1": + _ = json.NewEncoder(w).Encode(source) + case r.Method == http.MethodGet && r.URL.Path == "/gmail/v1/users/me/messages/orig-msg-1": + _ = json.NewEncoder(w).Encode(mockOriginalMessage(false)) + case r.Method == http.MethodGet && r.URL.Path == "/gmail/v1/users/me/messages/plain-msg-1": + _ = json.NewEncoder(w).Encode(mockPlainOnlyForwardSource()) + case r.Method == http.MethodGet && r.URL.Path == "/gmail/v1/users/me/messages/bodyless-msg-1": + _ = json.NewEncoder(w).Encode(mockBodylessForwardSource()) + case r.Method == http.MethodGet && r.URL.Path == "/gmail/v1/users/me/drafts/d1": + _ = json.NewEncoder(w).Encode(map[string]any{"id": "d1", "message": map[string]any{"id": "m1"}}) + case r.Method == http.MethodPost && r.URL.Path == "/gmail/v1/users/me/messages/send": + writeGmailSendResponse(t, w, r, &captured.raw) + case (r.Method == http.MethodPost && r.URL.Path == "/gmail/v1/users/me/drafts") || + (r.Method == http.MethodPut && r.URL.Path == "/gmail/v1/users/me/drafts/d1"): + var draft gmail.Draft + if err := json.NewDecoder(r.Body).Decode(&draft); err != nil { + t.Fatalf("decode draft: %v", err) + } + raw, err := base64.RawURLEncoding.DecodeString(draft.Message.Raw) + if err != nil { + t.Fatalf("decode raw: %v", err) + } + captured.raw = string(raw) + writeDraftCreatedResponse(w) + default: + http.NotFound(w, r) + } + }) + return svc, captured, cleanup +} + +func primarySignature() map[string]string { + return map[string]string{"me@example.com": `
Kind regards
Me Person
`} +} + +func TestGmailDrafts_PlainBodyKeepsImageOnlySignature(t *testing.T) { + const signature = `` + for _, cmd := range [][]string{{"create"}, {"update", "d1"}} { + t.Run(cmd[0], func(t *testing.T) { + svc, got, cleanup := newSignatureComposeService(t, map[string]string{"me@example.com": signature}, nil) + defer cleanup() + args := append([]string{"--account", "me@example.com", "gmail", "drafts"}, cmd...) + args = append(args, "--to", "a@example.com", "--subject", "Hi", "--body", "A < B & C", "--signature") + if result := executeWithGmailTestService(t, args, svc); result.err != nil { + t.Fatal(result.err) + } + for _, want := range []string{"Content-Type: text/html", "A < B & C", signature} { + if !strings.Contains(got.raw, want) { + t.Errorf("missing %q in draft MIME:\n%s", want, got.raw) + } + } + }) + } +} + +func TestGmailDraftsCreate_AppendsSendAsSignatureToPlainAndHTML(t *testing.T) { + svc, got, cleanup := newSignatureComposeService(t, primarySignature(), nil) + defer cleanup() + + result := executeWithGmailTestService(t, []string{ + "--account", "me@example.com", "gmail", "drafts", "create", + "--to", "a@example.com", "--subject", "Hi", "--body", "Body", "--body-html", "

Body

", "--signature", + }, svc) + if result.err != nil { + t.Fatalf("drafts create: %v", result.err) + } + if !strings.Contains(got.raw, "Body\r\n\r\n--\r\nKind regards\r\nMe Person") { + t.Fatalf("plain signature missing:\n%s", got.raw) + } + if !strings.Contains(got.raw, `

Body

`+"\r\n\r\n"+`
Kind regards
Me Person
`) { + t.Fatalf("html signature missing:\n%s", got.raw) + } +} + +func TestGmailDraftsCreate_SignatureStaysAboveQuote(t *testing.T) { + svc, got, cleanup := newSignatureComposeService(t, primarySignature(), mockReplySourceMessage()) + defer cleanup() + + result := executeWithGmailTestService(t, []string{ + "--account", "me@example.com", "gmail", "drafts", "create", + "--reply-to-message-id", "msg-1", "--quote", "--body", "Reply", "--signature", + }, svc) + if result.err != nil { + t.Fatalf("drafts create: %v", result.err) + } + sig := strings.Index(got.raw, "Kind regards") + quote := strings.Index(got.raw, "Original plain body.") + if sig < 0 || quote < 0 || sig > quote { + t.Fatalf("signature (at %d) must precede quote (at %d):\n%s", sig, quote, got.raw) + } +} + +func TestGmailDraftsCreate_AutoFromAliasUsesAliasSignature(t *testing.T) { + source := map[string]any{ + "id": "msg-1", "threadId": "thread-1", + "payload": map[string]any{"headers": []map[string]any{ + {"name": "Message-ID", "value": ""}, + {"name": "From", "value": "alice@example.com"}, + {"name": "To", "value": "alias@example.com"}, + {"name": "Subject", "value": "Hi"}, + }}, + } + svc, got, cleanup := newSignatureComposeService(t, map[string]string{ + "me@example.com": "
primary sig
", + "alias@example.com": "
alias sig
", + }, source) + defer cleanup() + + result := executeWithGmailTestService(t, []string{ + "--account", "me@example.com", "gmail", "drafts", "create", + "--reply-to-message-id", "msg-1", "--body", "Reply", "--signature", "--auto-from-addressed-alias", + }, svc) + if result.err != nil { + t.Fatalf("drafts create: %v", result.err) + } + if strings.Join(got.sigFetchedFor, ",") != "alias@example.com" { + t.Fatalf("signature fetched for %v, want only alias@example.com", got.sigFetchedFor) + } + if !strings.Contains(got.raw, "Reply\r\n\r\n--\r\nalias sig") { + t.Fatalf("alias signature missing:\n%s", got.raw) + } +} + +func TestGmailDraftsCreate_SignatureFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "signature.txt") + if err := os.WriteFile(path, []byte("Local Sig\nhttps://example.com"), 0o600); err != nil { + t.Fatalf("write signature file: %v", err) + } + svc, got, cleanup := newSignatureComposeService(t, nil, nil) + defer cleanup() + + result := executeWithGmailTestService(t, []string{ + "--account", "me@example.com", "gmail", "drafts", "create", + "--to", "a@example.com", "--subject", "Hi", "--body", "Body", "--signature-file", path, + }, svc) + if result.err != nil { + t.Fatalf("drafts create: %v", result.err) + } + if !strings.Contains(got.raw, "Body\r\n\r\n--\r\nLocal Sig\r\nhttps://example.com") { + t.Fatalf("file signature missing:\n%s", got.raw) + } +} + +func TestGmailDraftsCreate_EmptySignatureWarnsAndCreatesDraft(t *testing.T) { + svc, got, cleanup := newSignatureComposeService(t, map[string]string{"me@example.com": ""}, nil) + defer cleanup() + + result := executeWithGmailTestService(t, []string{ + "--account", "me@example.com", "gmail", "drafts", "create", + "--to", "a@example.com", "--subject", "Hi", "--body", "Body", "--signature", + }, svc) + if result.err != nil { + t.Fatalf("drafts create: %v", result.err) + } + if !strings.Contains(result.stderr, "Warning: no signature configured for me@example.com") { + t.Fatalf("expected warning on stderr, got %q", result.stderr) + } + if strings.Contains(result.stdout, "Warning") || got.raw == "" { + t.Fatalf("stdout must stay clean and draft must be created; stdout=%q raw=%q", result.stdout, got.raw) + } +} + +func TestGmailDrafts_SignatureOptionsAreValidated(t *testing.T) { + cases := []struct { + name string + args []string + want string + }{ + {"create conflict", []string{"drafts", "create", "--to", "a@example.com", "--subject", "Hi", "--body", "B", "--signature", "--signature-file", "sig.txt"}, "use only one of --signature/--signature-from or --signature-file"}, + {"update conflict", []string{"drafts", "update", "d1", "--to", "a@example.com", "--subject", "Hi", "--body", "B", "--signature-from", "alias@example.com", "--signature-file", "sig.txt"}, "use only one of --signature/--signature-from or --signature-file"}, + {"forward conflict", []string{"drafts", "forward", "orig-msg-1", "--to", "a@example.com", "--signature", "--signature-file", "sig.txt"}, "use only one of --signature/--signature-from or --signature-file"}, + {"create raw-file", []string{"drafts", "create", "--raw-file", "-", "--signature"}, "--raw-file cannot be combined with --signature"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + svc, _, cleanup := newSignatureComposeService(t, nil, nil) + defer cleanup() + result := executeWithGmailTestService(t, append([]string{"--account", "me@example.com", "gmail"}, tc.args...), svc) + if result.err == nil || !strings.Contains(result.err.Error(), tc.want) { + t.Fatalf("error = %v, want containing %q", result.err, tc.want) + } + }) + } +} + +func TestGmailDrafts_DryRunReportsSignatureFlags(t *testing.T) { + cases := map[string][]string{ + "create": {"drafts", "create", "--to", "a@example.com", "--subject", "Hi", "--body", "B"}, + "update": {"drafts", "update", "d1", "--to", "a@example.com", "--subject", "Hi", "--body", "B"}, + "forward": {"drafts", "forward", "orig-msg-1", "--to", "a@example.com"}, + "send forward": {"forward", "orig-msg-1", "--to", "a@example.com"}, + } + for name, args := range cases { + t.Run(name, func(t *testing.T) { + full := append([]string{"--json", "--dry-run", "--account", "me@example.com", "gmail"}, args...) + full = append(full, "--signature", "--signature-from", "alias@example.com") + result := executeWithTestRuntime(t, full, &app.Runtime{Services: app.Services{ + Gmail: func(context.Context, string) (*gmail.Service, error) { + return nil, errors.New("service must not be acquired during dry-run") + }, + }}) + if result.err != nil { + t.Fatalf("dry-run: %v", result.err) + } + var payload struct { + Request map[string]any `json:"request"` + } + if err := json.Unmarshal([]byte(result.stdout), &payload); err != nil { + t.Fatalf("decode dry-run output: %v\n%s", err, result.stdout) + } + want := map[string]any{"signature": true, "signature_from": "alias@example.com", "signature_file": ""} + for key, value := range want { + if payload.Request[key] != value { + t.Fatalf("request[%q] = %#v, want %#v\n%s", key, payload.Request[key], value, result.stdout) + } + } + }) + } +} + +func TestGmailDraftsCreate_SignatureFromAlias(t *testing.T) { + svc, got, cleanup := newSignatureComposeService(t, map[string]string{ + "me@example.com": "
primary sig
", + "alias@example.com": "
alias sig
", + }, nil) + defer cleanup() + + result := executeWithGmailTestService(t, []string{ + "--account", "me@example.com", "gmail", "drafts", "create", + "--to", "a@example.com", "--subject", "Hi", "--body", "Body", "--signature-from", "alias@example.com", + }, svc) + if result.err != nil { + t.Fatalf("drafts create: %v", result.err) + } + if !strings.Contains(got.raw, "Body\r\n\r\n--\r\nalias sig") || strings.Contains(got.raw, "primary sig") { + t.Fatalf("expected only the alias signature:\n%s", got.raw) + } +} + +func TestGmailDraftsForward_SignatureFromAlias(t *testing.T) { + t.Setenv("GOG_TIMEZONE", "UTC") + svc, got, cleanup := newSignatureComposeService(t, map[string]string{ + "me@example.com": "
primary sig
", + "alias@example.com": "
alias sig
", + }, nil) + defer cleanup() + + result := executeWithGmailTestService(t, []string{ + "--account", "me@example.com", "gmail", "drafts", "forward", "orig-msg-1", + "--to", "a@example.com", "--note", "FYI", "--signature-from", "alias@example.com", + }, svc) + if result.err != nil { + t.Fatalf("drafts forward: %v", result.err) + } + if !strings.Contains(got.raw, "FYI\r\n\r\n--\r\nalias sig\r\n\r\n---------- Forwarded message") || strings.Contains(got.raw, "primary sig") { + t.Fatalf("expected only the alias signature:\n%s", got.raw) + } +} + +func TestGmailDraftsForward_ImageOnlySignatureLeavesNoLoneSeparator(t *testing.T) { + t.Setenv("GOG_TIMEZONE", "UTC") + svc, got, cleanup := newSignatureComposeService(t, map[string]string{ + "me@example.com": ``, + }, nil) + defer cleanup() + + result := executeWithGmailTestService(t, []string{ + "--account", "me@example.com", "gmail", "drafts", "forward", "orig-msg-1", + "--to", "a@example.com", "--signature", + }, svc) + if result.err != nil { + t.Fatalf("drafts forward: %v", result.err) + } + if strings.Contains(got.raw, "--\r\n\r\n---------- Forwarded message") || strings.Contains(got.raw, "\r\n--\r\n") { + t.Fatalf("plain part has a lone signature separator:\n%s", got.raw) + } + if !strings.Contains(got.raw, `
`) { + t.Fatalf("html signature block missing:\n%s", got.raw) + } +} + +func TestGmailDraftsUpdate_AppendsSendAsSignatureToNewBody(t *testing.T) { + svc, got, cleanup := newSignatureComposeService(t, primarySignature(), nil) + defer cleanup() + + result := executeWithGmailTestService(t, []string{ + "--account", "me@example.com", "gmail", "drafts", "update", "d1", + "--to", "a@example.com", "--subject", "Hi", "--body", "New body", "--body-html", "

New body

", "--signature", + }, svc) + if result.err != nil { + t.Fatalf("drafts update: %v", result.err) + } + if !strings.Contains(got.raw, "New body\r\n\r\n--\r\nKind regards\r\nMe Person") { + t.Fatalf("plain signature missing:\n%s", got.raw) + } + if !strings.Contains(got.raw, `
Kind regards
Me Person
`) { + t.Fatalf("html signature missing:\n%s", got.raw) + } +} + +func TestGmailDraftsForward_SignatureBetweenNoteAndForwardedMessage(t *testing.T) { + t.Setenv("GOG_TIMEZONE", "UTC") + svc, got, cleanup := newSignatureComposeService(t, primarySignature(), nil) + defer cleanup() + + result := executeWithGmailTestService(t, []string{ + "--account", "me@example.com", "gmail", "drafts", "forward", "orig-msg-1", + "--to", "a@example.com", "--note", "FYI", "--signature", + }, svc) + if result.err != nil { + t.Fatalf("drafts forward: %v", result.err) + } + if !strings.Contains(got.raw, "FYI\r\n\r\n--\r\nKind regards\r\nMe Person\r\n\r\n---------- Forwarded message ---------") { + t.Fatalf("plain signature not between note and forwarded message:\n%s", got.raw) + } + if !strings.Contains(got.raw, `
FYI

Kind regards
Me Person

`) { + t.Fatalf("html signature not between note and forwarded message:\n%s", got.raw) + } +} + +func TestGmailForward_SignatureBetweenNoteAndForwardedMessage(t *testing.T) { + t.Setenv("GOG_TIMEZONE", "UTC") + svc, got, cleanup := newSignatureComposeService(t, primarySignature(), nil) + defer cleanup() + + result := executeWithGmailTestService(t, []string{ + "--account", "me@example.com", "gmail", "forward", "orig-msg-1", + "--to", "a@example.com", "--note", "FYI", "--signature", + }, svc) + if result.err != nil { + t.Fatalf("forward: %v", result.err) + } + if !strings.Contains(got.raw, "FYI\r\n\r\n--\r\nKind regards\r\nMe Person\r\n\r\n---------- Forwarded message ---------") { + t.Fatalf("plain signature not between note and forwarded message:\n%s", got.raw) + } + if !strings.Contains(got.raw, `
FYI

Kind regards
Me Person

`) { + t.Fatalf("html signature not between note and forwarded message:\n%s", got.raw) + } +} + +func TestGmailDraftsForward_SignatureWithoutNote(t *testing.T) { + t.Setenv("GOG_TIMEZONE", "UTC") + svc, got, cleanup := newSignatureComposeService(t, primarySignature(), nil) + defer cleanup() + + result := executeWithGmailTestService(t, []string{ + "--account", "me@example.com", "gmail", "drafts", "forward", "orig-msg-1", + "--to", "a@example.com", "--signature", + }, svc) + if result.err != nil { + t.Fatalf("drafts forward: %v", result.err) + } + if !strings.Contains(got.raw, "--\r\nKind regards\r\nMe Person\r\n\r\n---------- Forwarded message ---------") { + t.Fatalf("plain signature missing above forwarded message:\n%s", got.raw) + } + if !strings.Contains(got.raw, `
Kind regards
Me Person

`) { + t.Fatalf("html signature missing above forwarded message:\n%s", got.raw) + } +} + +func TestGmailDraftsForward_EmptySignatureWarns(t *testing.T) { + t.Setenv("GOG_TIMEZONE", "UTC") + svc, got, cleanup := newSignatureComposeService(t, map[string]string{"me@example.com": ""}, nil) + defer cleanup() + + result := executeWithGmailTestService(t, []string{ + "--account", "me@example.com", "gmail", "drafts", "forward", "orig-msg-1", + "--to", "a@example.com", "--note", "FYI", "--signature", + }, svc) + if result.err != nil { + t.Fatalf("drafts forward: %v", result.err) + } + if !strings.Contains(result.stderr, "Warning: no signature configured for me@example.com") || got.raw == "" { + t.Fatalf("expected warning and draft; stderr=%q raw=%q", result.stderr, got.raw) + } +} + +// mockPlainOnlyForwardSource is a forward source with only a text/plain body. +func mockPlainOnlyForwardSource() map[string]any { + plain := base64.RawURLEncoding.EncodeToString([]byte("1 & 2\nsecond line")) + return map[string]any{ + "id": "plain-msg-1", + "threadId": "thread-1", + "payload": map[string]any{ + "mimeType": "text/plain", + "headers": []map[string]any{ + {"name": "From", "value": "Alice "}, + {"name": "Date", "value": "Mon, 10 Mar 2026 09:00:00 -0400"}, + {"name": "Subject", "value": "Plain original"}, + }, + "body": map[string]any{"data": plain, "size": len(plain)}, + }, + } +} + +func TestGmailForward_PlainOnlyOriginalKeepsHTMLSignature(t *testing.T) { + t.Setenv("GOG_TIMEZONE", "UTC") + const ( + imageSig = `` + textSig = `
Kind regards
Me Person
` + ) + cases := []struct { + name string + signature string + cmd []string + note string + wantSig string + }{ + {"drafts image no note", imageSig, []string{"drafts", "forward"}, "", `
` + imageSig + `

`}, + {"drafts image with note", imageSig, []string{"drafts", "forward"}, "FYI", `
FYI

` + imageSig + `

`}, + {"send image no note", imageSig, []string{"forward"}, "", `
` + imageSig + `

`}, + {"send image with note", imageSig, []string{"forward"}, "FYI", `
FYI

` + imageSig + `

`}, + {"drafts text signature", textSig, []string{"drafts", "forward"}, "", `
` + textSig + `

`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + svc, got, cleanup := newSignatureComposeService(t, map[string]string{"me@example.com": tc.signature}, nil) + defer cleanup() + args := append([]string{"--account", "me@example.com", "gmail"}, tc.cmd...) + args = append(args, "plain-msg-1", "--to", "a@example.com", "--signature") + if tc.note != "" { + args = append(args, "--note", tc.note) + } + if result := executeWithGmailTestService(t, args, svc); result.err != nil { + t.Fatalf("forward: %v", result.err) + } + if !strings.Contains(got.raw, "Content-Type: text/html") { + t.Fatalf("missing text/html part:\n%s", got.raw) + } + if !strings.Contains(got.raw, tc.wantSig) { + t.Fatalf("signature not above forwarded block:\n%s", got.raw) + } + if !strings.Contains(got.raw, "1 <b> & 2
\r\nsecond line") { + t.Fatalf("plain original not escaped into HTML:\n%s", got.raw) + } + }) + } +} + +// mockBodylessForwardSource is a forward source with an attachment and no body text. +func mockBodylessForwardSource() map[string]any { + return map[string]any{ + "id": "bodyless-msg-1", + "threadId": "thread-1", + "payload": map[string]any{ + "mimeType": "multipart/mixed", + "headers": []map[string]any{ + {"name": "From", "value": "Alice "}, + {"name": "Date", "value": "Mon, 10 Mar 2026 09:00:00 -0400"}, + {"name": "Subject", "value": "Attachment only"}, + }, + "parts": []map[string]any{ + { + "mimeType": "application/pdf", + "filename": "scan.pdf", + "body": map[string]any{"attachmentId": "att-1", "size": 10}, + }, + }, + }, + } +} + +func TestGmailForward_BodylessOriginalKeepsHTMLSignature(t *testing.T) { + t.Setenv("GOG_TIMEZONE", "UTC") + const imageSig = `` + for _, cmd := range [][]string{{"drafts", "forward"}, {"forward"}} { + t.Run(strings.Join(cmd, " "), func(t *testing.T) { + svc, got, cleanup := newSignatureComposeService(t, map[string]string{"me@example.com": imageSig}, nil) + defer cleanup() + args := append([]string{"--account", "me@example.com", "gmail"}, cmd...) + args = append(args, "bodyless-msg-1", "--to", "a@example.com", "--skip-attachments", "--signature") + if result := executeWithGmailTestService(t, args, svc); result.err != nil { + t.Fatalf("forward: %v", result.err) + } + if !strings.Contains(got.raw, `
`+imageSig+`

`) { + t.Fatalf("image signature lost for a bodyless original:\n%s", got.raw) + } + }) + } +} + +func TestGmailDraftsForward_PlainOnlyOriginalWithoutSignatureHasNoHTMLPart(t *testing.T) { + t.Setenv("GOG_TIMEZONE", "UTC") + svc, got, cleanup := newSignatureComposeService(t, primarySignature(), nil) + defer cleanup() + + result := executeWithGmailTestService(t, []string{ + "--account", "me@example.com", "gmail", "drafts", "forward", "plain-msg-1", + "--to", "a@example.com", "--note", "FYI", + }, svc) + if result.err != nil { + t.Fatalf("drafts forward: %v", result.err) + } + if strings.Contains(got.raw, "text/html") { + t.Fatalf("plain-only forward without a signature must stay plain:\n%s", got.raw) + } +} diff --git a/internal/cmd/gmail_forward.go b/internal/cmd/gmail_forward.go index e14f565f2..bb592ddbe 100644 --- a/internal/cmd/gmail_forward.go +++ b/internal/cmd/gmail_forward.go @@ -20,13 +20,14 @@ type GmailForwardCmd struct { } type GmailForwardOptions struct { - To string `name:"to" help:"Recipients (comma-separated; required when sending, optional when saving a draft)"` - Cc string `name:"cc" help:"CC recipients (comma-separated)"` - Bcc string `name:"bcc" help:"BCC recipients (comma-separated)"` - Note string `name:"note" aliases:"intro" help:"Introductory text above the forwarded message"` - NoteFile string `name:"note-file" help:"Note file path (plain text; '-' for stdin)"` - From string `name:"from" help:"Send from this email address (must be a verified send-as alias)"` - SkipAttachments bool `name:"skip-attachments" help:"Do not include original attachments"` + To string `name:"to" help:"Recipients (comma-separated; required when sending, optional when saving a draft)"` + Cc string `name:"cc" help:"CC recipients (comma-separated)"` + Bcc string `name:"bcc" help:"BCC recipients (comma-separated)"` + Note string `name:"note" aliases:"intro" help:"Introductory text above the forwarded message"` + NoteFile string `name:"note-file" help:"Note file path (plain text; '-' for stdin)"` + From string `name:"from" help:"Send from this email address (must be a verified send-as alias)"` + SkipAttachments bool `name:"skip-attachments" help:"Do not include original attachments"` + composeSignatureOptions `embed:""` } // recipientRequirement records whether a compose path must have recipients. The @@ -106,6 +107,9 @@ func (c *GmailForwardOptions) dryRunFields(inputs forwardComposeInputs) map[stri "from": strings.TrimSpace(c.From), "note_len": len(inputs.note), "skip_attachments": c.SkipAttachments, + "signature": c.Signature, + "signature_from": strings.TrimSpace(c.SignatureFrom), + "signature_file": strings.TrimSpace(c.SignatureFile), } } @@ -120,6 +124,10 @@ func (c *GmailForwardOptions) resolveForwardInputs(ctx context.Context, messageI return forwardComposeInputs{}, usage("required: messageId") } + if signatureErr := c.validateSignatureOptions(); signatureErr != nil { + return forwardComposeInputs{}, signatureErr + } + // Parsed before the dry-run so it reports the lists the build will use. toRecipients, ccRecipients, bccRecipients, err := parseComposeRecipients(c.To, c.Cc, c.Bcc) if err != nil { @@ -156,6 +164,11 @@ func (c *GmailForwardOptions) buildForwardComposeMessage(ctx context.Context, sv return forwardComposeMessage{}, err } + signature, err := c.requestedSignature(ctx, svc, from.sendingEmail) + if err != nil { + return forwardComposeMessage{}, err + } + // Fetch the original message in full format (headers + body + attachment metadata). origMsg, err := svc.Users.Messages.Get("me", inputs.messageID).Format(gmailFormatFull).Context(ctx).Do() if err != nil { @@ -184,12 +197,18 @@ func (c *GmailForwardOptions) buildForwardComposeMessage(ctx context.Context, sv } // Build forwarded body (plain text). - fwdPlain := formatForwardedMessage(inputs.note, origFrom, origDate, origSubject, origTo, origCc, origPlain, loc) + fwdPlain := formatForwardedMessage(inputs.note, signature, origFrom, origDate, origSubject, origTo, origCc, origPlain, loc) - // Build forwarded body (HTML) if original had HTML. + // Build forwarded body (HTML) if the original had HTML. An original with + // only plain text, or no body text at all, still gets one when the + // signature has HTML, so the signature is not lost. var fwdHTML string - if origHTML != "" { - fwdHTML = formatForwardedMessageHTML(inputs.note, origFrom, origDate, origSubject, origTo, origCc, origHTML, loc) + if origHTML != "" || signature.htmlBlock() != "" { + htmlContent := origHTML + if htmlContent == "" { + htmlContent = escapeTextToHTML(origPlain) + } + fwdHTML = formatForwardedMessageHTML(inputs.note, signature, origFrom, origDate, origSubject, origTo, origCc, htmlContent, loc) } // Preserve CID-backed inline resources required by the forwarded HTML and, @@ -268,13 +287,17 @@ func stripForwardPrefix(subject string) string { } // formatForwardedMessage builds the plain-text forwarded body. -func formatForwardedMessage(note, from, date, subject, to, cc, body string, loc *time.Location) string { +func formatForwardedMessage(note string, signature composeSignature, from, date, subject, to, cc, body string, loc *time.Location) string { var sb strings.Builder if strings.TrimSpace(note) != "" { sb.WriteString(strings.TrimSpace(note)) sb.WriteString("\n\n") } + if block := signature.plainBlock(); block != "" { + sb.WriteString(block) + sb.WriteString("\n\n") + } sb.WriteString("---------- Forwarded message ---------\n") for _, h := range forwardedMessageHeaders(from, date, subject, to, cc, loc) { @@ -295,7 +318,7 @@ func formatForwardedMessage(note, from, date, subject, to, cc, body string, loc } // formatForwardedMessageHTML builds the HTML forwarded body. -func formatForwardedMessageHTML(note, from, date, subject, to, cc, htmlContent string, loc *time.Location) string { +func formatForwardedMessageHTML(note string, signature composeSignature, from, date, subject, to, cc, htmlContent string, loc *time.Location) string { var sb strings.Builder if strings.TrimSpace(note) != "" { @@ -303,6 +326,10 @@ func formatForwardedMessageHTML(note, from, date, subject, to, cc, htmlContent s sb.WriteString(html.EscapeString(strings.TrimSpace(note))) sb.WriteString("

") } + if block := signature.htmlBlock(); block != "" { + sb.WriteString(block) + sb.WriteString("
") + } sb.WriteString(`
`) sb.WriteString(`
---------- Forwarded message ---------
`) diff --git a/internal/cmd/gmail_reply_commands.go b/internal/cmd/gmail_reply_commands.go index 2ec3bcccf..ec223bb31 100644 --- a/internal/cmd/gmail_reply_commands.go +++ b/internal/cmd/gmail_reply_commands.go @@ -184,7 +184,6 @@ func (c *GmailReplyOptions) resolveReplyInputs(ctx context.Context, messageID st // builds the reply recipients and body, and returns the message without sending // so the caller controls how it is dispatched. func (c *GmailReplyOptions) buildReplyComposeMessage(ctx context.Context, svc *gmail.Service, account string, inputs replyComposeInputs, replyAll bool) (replyComposeMessage, error) { - u := ui.FromContext(ctx) body, htmlBody := inputs.body, inputs.htmlBody sendAs, sendAsErr := listSendAs(ctx, svc) @@ -205,17 +204,11 @@ func (c *GmailReplyOptions) buildReplyComposeMessage(ctx context.Context, svc *g } } } - if c.signatureRequested() { - signature, source, sigErr := c.resolveComposeSignature(ctx, svc, from.sendingEmail) - if sigErr != nil { - return replyComposeMessage{}, sigErr - } - if signature.empty() { - u.Err().Linef("Warning: no signature configured for %s", source) - } else { - body, htmlBody = appendComposeSignature(body, htmlBody, signature) - } + signature, err := c.requestedSignature(ctx, svc, from.sendingEmail) + if err != nil { + return replyComposeMessage{}, err } + body, htmlBody = appendComposeSignature(body, htmlBody, signature) body, htmlBody, err = applyReplyQuote(ctx, !c.NoQuote, info, body, htmlBody) if err != nil { return replyComposeMessage{}, err diff --git a/internal/cmd/gmail_send.go b/internal/cmd/gmail_send.go index ba5cb462f..61f5d75ac 100644 --- a/internal/cmd/gmail_send.go +++ b/internal/cmd/gmail_send.go @@ -167,17 +167,11 @@ func (c *GmailSendCmd) Run(ctx context.Context, flags *RootFlags) error { if err != nil { return err } - if c.signatureRequested() { - signature, source, sigErr := c.resolveComposeSignature(ctx, svc, from.sendingEmail) - if sigErr != nil { - return sigErr - } - if signature.empty() { - u.Err().Linef("Warning: no signature configured for %s", source) - } else { - body, htmlBodyInput = appendComposeSignature(body, htmlBodyInput, signature) - } + signature, err := c.requestedSignature(ctx, svc, from.sendingEmail) + if err != nil { + return err } + 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 index bef4e053f..cba7eb531 100644 --- a/internal/cmd/gmail_send_signature.go +++ b/internal/cmd/gmail_send_signature.go @@ -12,6 +12,7 @@ import ( "github.com/openclaw/gogcli/internal/config" "github.com/openclaw/gogcli/internal/gmailcontent" + "github.com/openclaw/gogcli/internal/ui" ) const maxComposeSignatureFileBytes = 1 << 20 @@ -44,6 +45,41 @@ func (c *composeSignatureOptions) validateSignatureOptions() error { return nil } +// plainBlock is the signature as appended to a plain-text body, or "" when the +// signature has no plain text (for example an image-only signature). +func (s composeSignature) plainBlock() string { + if strings.TrimSpace(s.Plain) == "" { + return "" + } + return "--\n" + strings.TrimSpace(s.Plain) +} + +// htmlBlock is the signature as appended to an HTML body, or "" when empty. +func (s composeSignature) htmlBlock() string { + if strings.TrimSpace(s.HTML) == "" { + return "" + } + return `
` + strings.TrimSpace(s.HTML) + `
` +} + +// requestedSignature resolves the signature the signature flags ask for. It +// returns the zero value when none was requested or the resolved one is empty; +// an empty one also warns on stderr, so callers can append the result as-is. +func (c *composeSignatureOptions) requestedSignature(ctx context.Context, svc *gmail.Service, sendingEmail string) (composeSignature, error) { + if !c.signatureRequested() { + return composeSignature{}, nil + } + signature, source, err := c.resolveComposeSignature(ctx, svc, sendingEmail) + if err != nil { + return composeSignature{}, err + } + if signature.empty() { + ui.FromContext(ctx).Err().Linef("Warning: no signature configured for %s", source) + return composeSignature{}, nil + } + return signature, nil +} + func (c *composeSignatureOptions) resolveComposeSignature(ctx context.Context, svc *gmail.Service, sendingEmail string) (composeSignature, string, error) { if path := strings.TrimSpace(c.SignatureFile); path != "" { signature, err := readComposeSignatureFile(path) @@ -104,11 +140,11 @@ func readComposeSignatureFile(path string) (composeSignature, error) { } 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 block := signature.plainBlock(); block != "" && strings.TrimSpace(plainBody) != "" { + plainBody = appendBodyBlock(plainBody, block) } - if strings.TrimSpace(signature.HTML) != "" && strings.TrimSpace(htmlBody) != "" { - htmlBody = appendBodyBlock(htmlBody, `
`+strings.TrimSpace(signature.HTML)+`
`) + if block := signature.htmlBlock(); block != "" && strings.TrimSpace(htmlBody) != "" { + htmlBody = appendBodyBlock(htmlBody, block) } return plainBody, htmlBody }