Skip to content

Commit c89a054

Browse files
fix(gmail): add reply-all recipients for draft replies
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
1 parent 23b847c commit c89a054

6 files changed

Lines changed: 310 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Fixed
66

77
- Calendar: accept relative and date-only `freebusy --from`/`--to` values using the same timezone-aware range parsing as events. (#806, #811) — thanks @privatenumber.
8+
- Gmail: add `--reply-all` to draft create and update so reply drafts can infer original recipients while preserving explicit recipient overrides. (#804, #805) — thanks @privatenumber.
89

910
## 0.27.0 - 2026-06-14
1011

docs/commands/gog-gmail-drafts-create.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ gog gmail (mail,email) drafts (draft) create (add,new) [flags]
4242
| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) |
4343
| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) |
4444
| `--quote` | `bool` | | Include quoted original message in reply (requires --reply-to-message-id or --thread-id) |
45+
| `--reply-all` | `bool` | | Auto-populate recipients from original message (requires --reply-to-message-id or --thread-id) |
4546
| `--reply-to` | `string` | | Reply-To header address |
4647
| `--reply-to-message-id` | `string` | | Reply to Gmail message ID (sets In-Reply-To/References and thread) |
4748
| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) |

docs/commands/gog-gmail-drafts-update.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ gog gmail (mail,email) drafts (draft) update (edit,set) <draftId> [flags]
4343
| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) |
4444
| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) |
4545
| `--quote` | `bool` | | Include quoted original message in reply |
46+
| `--reply-all` | `bool` | | Auto-populate recipients from original message (requires --reply-to-message-id or --thread-id) |
4647
| `--reply-to` | `string` | | Reply-To header address |
4748
| `--reply-to-message-id` | `string` | | Reply to Gmail message ID (sets In-Reply-To/References and thread) |
4849
| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) |

docs/spec.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -390,8 +390,8 @@ after the bounded retry window, the command exits with retryable code `8`.
390390
- `gog gmail send --to [email protected] [--subject S] [--body B|--body-file PATH] [--body-html H|--body-html-file PATH] [--cc ...] [--bcc ...] [--reply-to-message-id <messageId>] [--reply-to addr] [--from addr] [--signature|--signature-from addr|--signature-file path] [--attach <file>...]`
391391
- `gog gmail drafts list [--max N] [--page TOKEN]`
392392
- `gog gmail drafts get <draftId> [--download]`
393-
- `gog gmail drafts create --subject S [--to [email protected]] [--body B] [--body-html H] [--cc ...] [--bcc ...] [--reply-to-message-id <messageId>] [--reply-to addr] [--attach <file>...]`
394-
- `gog gmail drafts update <draftId> --subject S [--to [email protected]] [--body B] [--body-html H] [--cc ...] [--bcc ...] [--reply-to-message-id <messageId>] [--reply-to addr] [--attach <file>...]`
393+
- `gog gmail drafts create [--subject S] [--to [email protected]] [--body B] [--body-html H] [--cc ...] [--bcc ...] [--reply-to-message-id <messageId>|--thread-id <threadId>] [--reply-all] [--reply-to addr] [--attach <file>...]`
394+
- `gog gmail drafts update <draftId> [--subject S] [--to [email protected]] [--body B] [--body-html H] [--cc ...] [--bcc ...] [--reply-to-message-id <messageId>|--thread-id <threadId>] [--reply-all] [--reply-to addr] [--attach <file>...]`
395395
- `gog gmail drafts send <draftId>`
396396
- `gog gmail drafts delete <draftId>`
397397
- `gog gmail watch start|status|renew|stop|serve`

internal/cmd/gmail_drafts.go

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,7 @@ type GmailDraftsCreateCmd struct {
262262
BodyHTMLFile string `name:"body-html-file" help:"HTML body file path ('-' for stdin)"`
263263
ReplyToMessageID string `name:"reply-to-message-id" help:"Reply to Gmail message ID (sets In-Reply-To/References and thread)"`
264264
ThreadID string `name:"thread-id" help:"Reply within a Gmail thread (uses latest message for headers)"`
265+
ReplyAll bool `name:"reply-all" help:"Auto-populate recipients from original message (requires --reply-to-message-id or --thread-id)"`
265266
ReplyTo string `name:"reply-to" help:"Reply-To header address"`
266267
Quote bool `name:"quote" help:"Include quoted original message in reply (requires --reply-to-message-id or --thread-id)"`
267268
Attach []string `name:"attach" help:"Attachment file path (repeatable)"`
@@ -277,6 +278,7 @@ type draftComposeInput struct {
277278
BodyHTML string
278279
ReplyToMessageID string
279280
ReplyToThreadID string
281+
ReplyAll bool
280282
ReplyTo string
281283
Quote bool
282284
Attach []string
@@ -287,6 +289,11 @@ type draftComposeInput struct {
287289
}
288290

289291
func (c draftComposeInput) validate() error {
292+
if c.ReplyAll &&
293+
strings.TrimSpace(c.ReplyToMessageID) == "" &&
294+
strings.TrimSpace(c.ReplyToThreadID) == "" {
295+
return usage("--reply-all requires --reply-to-message-id or --thread-id")
296+
}
290297
if strings.TrimSpace(c.Subject) == "" &&
291298
strings.TrimSpace(c.ReplyToMessageID) == "" &&
292299
strings.TrimSpace(c.ReplyToThreadID) == "" {
@@ -299,7 +306,8 @@ func (c draftComposeInput) validate() error {
299306
}
300307

301308
func buildDraftMessage(ctx context.Context, svc *gmail.Service, account string, input draftComposeInput) (*gmail.Message, string, []mailmime.AttachmentMetadata, error) {
302-
from, err := resolveComposeSender(ctx, svc, account, input.From)
309+
sendAs, sendAsErr := listSendAs(ctx, svc)
310+
from, err := resolveComposeFrom(ctx, svc, account, input.From, sendAs, sendAsErr)
303311
if err != nil {
304312
return nil, "", nil, err
305313
}
@@ -321,6 +329,36 @@ func buildDraftMessage(ctx context.Context, svc *gmail.Service, account string,
321329
subject = autoReplySubject("", info.Subject)
322330
}
323331

332+
toRecipients := splitCSV(input.To)
333+
ccRecipients := splitCSV(input.Cc)
334+
bccRecipients := splitCSV(input.Bcc)
335+
if input.ReplyAll {
336+
recipients, recipientErr := buildReplyRecipients(
337+
info,
338+
selfEmailsForReply(account, from.sendingEmail, sendAs),
339+
true,
340+
nil,
341+
nil,
342+
nil,
343+
nil,
344+
)
345+
if recipientErr != nil {
346+
return nil, "", nil, recipientErr
347+
}
348+
toRecipients = formatMailboxes(recipients.To)
349+
ccRecipients = formatMailboxes(recipients.Cc)
350+
bccRecipients = formatMailboxes(recipients.Bcc)
351+
if strings.TrimSpace(input.To) != "" {
352+
toRecipients = splitCSV(input.To)
353+
}
354+
if strings.TrimSpace(input.Cc) != "" {
355+
ccRecipients = splitCSV(input.Cc)
356+
}
357+
if strings.TrimSpace(input.Bcc) != "" {
358+
bccRecipients = splitCSV(input.Bcc)
359+
}
360+
}
361+
324362
msg, err := buildGmailMessage(ctx, sendMessageOptions{
325363
FromAddr: from.header,
326364
ReplyTo: input.ReplyTo,
@@ -330,9 +368,9 @@ func buildDraftMessage(ctx context.Context, svc *gmail.Service, account string,
330368
ReplyInfo: info,
331369
Attachments: atts,
332370
}, sendBatch{
333-
To: splitCSV(input.To),
334-
Cc: splitCSV(input.Cc),
335-
Bcc: splitCSV(input.Bcc),
371+
To: toRecipients,
372+
Cc: ccRecipients,
373+
Bcc: bccRecipients,
336374
}, true)
337375
if err != nil {
338376
return nil, "", nil, err
@@ -557,6 +595,7 @@ func (c *GmailDraftsCreateCmd) Run(ctx context.Context, flags *RootFlags) error
557595
BodyHTML: htmlBody,
558596
ReplyToMessageID: replyToMessageID,
559597
ReplyToThreadID: threadID,
598+
ReplyAll: c.ReplyAll,
560599
ReplyTo: c.ReplyTo,
561600
Quote: c.Quote,
562601
Attach: attachPaths,
@@ -578,6 +617,7 @@ func (c *GmailDraftsCreateCmd) Run(ctx context.Context, flags *RootFlags) error
578617
"body_html_len": len(strings.TrimSpace(input.BodyHTML)),
579618
"reply_to_message_id": strings.TrimSpace(input.ReplyToMessageID),
580619
"thread_id": strings.TrimSpace(input.ReplyToThreadID),
620+
"reply_all": input.ReplyAll,
581621
"reply_to": strings.TrimSpace(input.ReplyTo),
582622
"quote": input.Quote,
583623
"from": strings.TrimSpace(input.From),
@@ -615,6 +655,7 @@ type GmailDraftsUpdateCmd struct {
615655
BodyHTMLFile string `name:"body-html-file" help:"HTML body file path ('-' for stdin)"`
616656
ReplyToMessageID string `name:"reply-to-message-id" help:"Reply to Gmail message ID (sets In-Reply-To/References and thread)"`
617657
ThreadID string `name:"thread-id" help:"Reply within a Gmail thread (uses latest message for headers); overrides the draft's existing thread"`
658+
ReplyAll bool `name:"reply-all" help:"Auto-populate recipients from original message (requires --reply-to-message-id or --thread-id)"`
618659
ReplyTo string `name:"reply-to" help:"Reply-To header address"`
619660
Quote bool `name:"quote" help:"Include quoted original message in reply"`
620661
Attach []string `name:"attach" help:"Attachment file path (repeatable). Replaces existing attachments; omit to preserve them, or use --clear-attachments to remove all."`
@@ -667,6 +708,7 @@ func (c *GmailDraftsUpdateCmd) Run(ctx context.Context, flags *RootFlags) error
667708
BodyHTML: htmlBody,
668709
ReplyToMessageID: replyToMessageID,
669710
ReplyToThreadID: threadID,
711+
ReplyAll: c.ReplyAll,
670712
ReplyTo: c.ReplyTo,
671713
Quote: c.Quote,
672714
Attach: attachPaths,
@@ -681,14 +723,15 @@ func (c *GmailDraftsUpdateCmd) Run(ctx context.Context, flags *RootFlags) error
681723

682724
if dryRunErr := dryRunExit(ctx, flags, "gmail.drafts.update", map[string]any{
683725
"draft_id": draftID,
684-
"to_keep_existing": !toWasSet,
726+
"to_keep_existing": !toWasSet && !input.ReplyAll,
685727
"to": splitCSV(input.To),
686728
"cc": splitCSV(input.Cc),
687729
"bcc": splitCSV(input.Bcc),
688730
"subject": strings.TrimSpace(input.Subject),
689731
"body_len": len(strings.TrimSpace(input.Body)),
690732
"body_html_len": len(strings.TrimSpace(input.BodyHTML)),
691733
"reply_to_message_id": strings.TrimSpace(input.ReplyToMessageID),
734+
"reply_all": input.ReplyAll,
692735
"reply_to": strings.TrimSpace(input.ReplyTo),
693736
"quote": input.Quote,
694737
"from": strings.TrimSpace(input.From),
@@ -709,7 +752,7 @@ func (c *GmailDraftsUpdateCmd) Run(ctx context.Context, flags *RootFlags) error
709752
existingMessageID := ""
710753
existingTo := ""
711754
var existingPayload *gmail.MessagePart
712-
if !toWasSet || strings.TrimSpace(replyToMessageID) == "" || preserveAttachments {
755+
if (!toWasSet && !c.ReplyAll) || strings.TrimSpace(replyToMessageID) == "" || preserveAttachments {
713756
existing, fetchErr := svc.Users.Drafts.Get("me", draftID).Format("full").Do()
714757
if fetchErr != nil {
715758
return fetchErr
@@ -718,12 +761,12 @@ func (c *GmailDraftsUpdateCmd) Run(ctx context.Context, flags *RootFlags) error
718761
existingThreadID = strings.TrimSpace(existing.Message.ThreadId)
719762
existingMessageID = strings.TrimSpace(existing.Message.Id)
720763
existingPayload = existing.Message.Payload
721-
if !toWasSet {
764+
if !toWasSet && !c.ReplyAll {
722765
existingTo = strings.TrimSpace(headerValue(existing.Message.Payload, "To"))
723766
}
724767
}
725768
}
726-
if !toWasSet {
769+
if !toWasSet && !c.ReplyAll {
727770
to = existingTo
728771
}
729772

0 commit comments

Comments
 (0)