Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions docs/commands/gog-gmail-drafts-create.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`<br>`--pick`<br>`--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) |
Expand Down
3 changes: 3 additions & 0 deletions docs/commands/gog-gmail-drafts-forward.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ gog gmail (mail,email) drafts (draft) forward (fwd) <messageId> [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`<br>`--pick`<br>`--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`<br>`--verbose` | `bool` | | Enable verbose logging |
Expand Down
3 changes: 3 additions & 0 deletions docs/commands/gog-gmail-drafts-update.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ gog gmail (mail,email) drafts (draft) update (edit,set) <draftId> [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`<br>`--pick`<br>`--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) |
Expand Down
3 changes: 3 additions & 0 deletions docs/commands/gog-gmail-forward.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ gog gmail (mail,email) forward (fwd) <messageId> [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`<br>`--pick`<br>`--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`<br>`--verbose` | `bool` | | Enable verbose logging |
Expand Down
22 changes: 21 additions & 1 deletion docs/gmail-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -409,6 +421,14 @@ gog gmail drafts forward <messageId> --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
Expand Down
4 changes: 3 additions & 1 deletion internal/cmd/execute_gmail_forward_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,7 @@ func TestStripForwardPrefix(t *testing.T) {
func TestFormatForwardedMessage(t *testing.T) {
result := formatForwardedMessage(
"See below",
composeSignature{},
"Alice <alice@example.com>",
"Mon, 10 Mar 2026 09:00:00 -0400",
"Test Subject",
Expand Down Expand Up @@ -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")
Expand All @@ -357,6 +358,7 @@ func TestFormatForwardedMessage_NoNote(t *testing.T) {
func TestFormatForwardedMessageHTML(t *testing.T) {
result := formatForwardedMessageHTML(
"Check this out",
composeSignature{},
"Alice <alice@example.com>",
"Mon, 10 Mar 2026 09:00:00 -0400",
"Test",
Expand Down
126 changes: 76 additions & 50 deletions internal/cmd/gmail_drafts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -347,6 +348,7 @@ type draftComposeInput struct {
KeptToRecipients []string
From string
AutoFromAddressedAlias bool
composeSignatureOptions
}

func (c draftComposeInput) validate() error {
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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...)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
Loading
Loading