Skip to content

Commit 22fbc35

Browse files
chrischallsteipete
andauthored
feat(gmail): add thread IDs to draft replies
* feat(gmail): add --thread-id to drafts create/update for parity with send `gmail send` accepts --thread-id ("uses latest message for headers") but `gmail drafts create`/`update` only accepted --reply-to-message-id, so there was no thread-aware reply path when composing a draft. Callers who knew the thread (not the latest message id) had nowhere to put it; passing a thread id into --reply-to-message-id silently mis-threads — the draft lands in the thread via subject fallback but In-Reply-To/References never anchor to the parent's RFC822 Message-Id, and there's no error. Wire --thread-id into both draft commands, reusing the existing prepareComposeReply/fetchReplyInfo thread path (selects the latest thread message for reply headers): - Mutually exclusive with --reply-to-message-id (matches `send`). - --quote now accepts a thread target too. - On update, a caller-provided --thread-id overrides the draft's existing thread; the existing-draft thread fallback is preserved when omitted. - dry-run/JSON gains a thread_id field. Tests: create resolves to the thread's latest message and stamps threadId + In-Reply-To/References; mutual-exclusion guard; update override. Regenerated command docs. Live-verified (redacted): `gog gmail drafts create --thread-id <T>` produced a draft whose In-Reply-To/References equal the thread's latest message Message-Id and whose threadId == <T> (scratch draft created, verified, deleted). Closes #673. * docs: add changelog for Gmail draft thread IDs --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
1 parent 096323d commit 22fbc35

5 files changed

Lines changed: 216 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
### Added
66

7+
- Gmail: add `--thread-id` to `gmail drafts create` and `gmail drafts update` so drafts can reply within a thread using the latest message headers. (#673, #674) — thanks @chrischall.
8+
79
### Fixed
810

911
## 0.21.0 - 2026-06-01

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,13 @@ gog gmail (mail,email) drafts (draft) create (add,new) [flags]
4040
| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) |
4141
| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) |
4242
| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) |
43-
| `--quote` | `bool` | | Include quoted original message in reply (requires --reply-to-message-id) |
43+
| `--quote` | `bool` | | Include quoted original message in reply (requires --reply-to-message-id or --thread-id) |
4444
| `--reply-to` | `string` | | Reply-To header address |
4545
| `--reply-to-message-id` | `string` | | Reply to Gmail message ID (sets In-Reply-To/References and thread) |
4646
| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) |
4747
| `--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. |
4848
| `--subject` | `string` | | Subject (required) |
49+
| `--thread-id` | `string` | | Reply within a Gmail thread (uses latest message for headers) |
4950
| `--to` | `string` | | Recipients (comma-separated) |
5051
| `-v`<br>`--verbose` | `bool` | | Enable verbose logging |
5152
| `--version` | `kong.VersionFlag` | | Print version and exit |

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ gog gmail (mail,email) drafts (draft) update (edit,set) <draftId> [flags]
4646
| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) |
4747
| `--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. |
4848
| `--subject` | `string` | | Subject (required) |
49+
| `--thread-id` | `string` | | Reply within a Gmail thread (uses latest message for headers); overrides the draft's existing thread |
4950
| `--to` | `*string` | | Recipients (comma-separated; omit to keep existing) |
5051
| `-v`<br>`--verbose` | `bool` | | Enable verbose logging |
5152
| `--version` | `kong.VersionFlag` | | Print version and exit |

internal/cmd/gmail_drafts.go

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -256,8 +256,9 @@ type GmailDraftsCreateCmd struct {
256256
BodyFile string `name:"body-file" help:"Body file path (plain text; '-' for stdin)"`
257257
BodyHTML string `name:"body-html" help:"Body (HTML; optional)"`
258258
ReplyToMessageID string `name:"reply-to-message-id" help:"Reply to Gmail message ID (sets In-Reply-To/References and thread)"`
259+
ThreadID string `name:"thread-id" help:"Reply within a Gmail thread (uses latest message for headers)"`
259260
ReplyTo string `name:"reply-to" help:"Reply-To header address"`
260-
Quote bool `name:"quote" help:"Include quoted original message in reply (requires --reply-to-message-id)"`
261+
Quote bool `name:"quote" help:"Include quoted original message in reply (requires --reply-to-message-id or --thread-id)"`
261262
Attach []string `name:"attach" help:"Attachment file path (repeatable)"`
262263
From string `name:"from" help:"Send from this email address (must be a verified send-as alias)"`
263264
}
@@ -442,8 +443,12 @@ func (c *GmailDraftsCreateCmd) Run(ctx context.Context, flags *RootFlags) error
442443
return err
443444
}
444445
replyToMessageID := normalizeGmailMessageID(c.ReplyToMessageID)
445-
if c.Quote && replyToMessageID == "" {
446-
return usage("--quote requires --reply-to-message-id")
446+
threadID := normalizeGmailThreadID(c.ThreadID)
447+
if replyToMessageID != "" && threadID != "" {
448+
return usage("use only one of --reply-to-message-id or --thread-id")
449+
}
450+
if c.Quote && replyToMessageID == "" && threadID == "" {
451+
return usage("--quote requires --reply-to-message-id or --thread-id")
447452
}
448453

449454
attachPaths, err := expandComposeAttachmentPaths(c.Attach)
@@ -459,7 +464,7 @@ func (c *GmailDraftsCreateCmd) Run(ctx context.Context, flags *RootFlags) error
459464
Body: body,
460465
BodyHTML: c.BodyHTML,
461466
ReplyToMessageID: replyToMessageID,
462-
ReplyToThreadID: "",
467+
ReplyToThreadID: threadID,
463468
ReplyTo: c.ReplyTo,
464469
Quote: c.Quote,
465470
Attach: attachPaths,
@@ -480,6 +485,7 @@ func (c *GmailDraftsCreateCmd) Run(ctx context.Context, flags *RootFlags) error
480485
"body_len": len(strings.TrimSpace(input.Body)),
481486
"body_html_len": len(strings.TrimSpace(input.BodyHTML)),
482487
"reply_to_message_id": strings.TrimSpace(input.ReplyToMessageID),
488+
"thread_id": strings.TrimSpace(input.ReplyToThreadID),
483489
"reply_to": strings.TrimSpace(input.ReplyTo),
484490
"quote": input.Quote,
485491
"from": strings.TrimSpace(input.From),
@@ -515,6 +521,7 @@ type GmailDraftsUpdateCmd struct {
515521
BodyFile string `name:"body-file" help:"Body file path (plain text; '-' for stdin)"`
516522
BodyHTML string `name:"body-html" help:"Body (HTML; optional)"`
517523
ReplyToMessageID string `name:"reply-to-message-id" help:"Reply to Gmail message ID (sets In-Reply-To/References and thread)"`
524+
ThreadID string `name:"thread-id" help:"Reply within a Gmail thread (uses latest message for headers); overrides the draft's existing thread"`
518525
ReplyTo string `name:"reply-to" help:"Reply-To header address"`
519526
Quote bool `name:"quote" help:"Include quoted original message in reply"`
520527
Attach []string `name:"attach" help:"Attachment file path (repeatable)"`
@@ -540,6 +547,10 @@ func (c *GmailDraftsUpdateCmd) Run(ctx context.Context, flags *RootFlags) error
540547
return err
541548
}
542549
replyToMessageID := normalizeGmailMessageID(c.ReplyToMessageID)
550+
threadID := normalizeGmailThreadID(c.ThreadID)
551+
if replyToMessageID != "" && threadID != "" {
552+
return usage("use only one of --reply-to-message-id or --thread-id")
553+
}
543554

544555
attachPaths, err := expandComposeAttachmentPaths(c.Attach)
545556
if err != nil {
@@ -577,6 +588,7 @@ func (c *GmailDraftsUpdateCmd) Run(ctx context.Context, flags *RootFlags) error
577588
"body_len": len(strings.TrimSpace(input.Body)),
578589
"body_html_len": len(strings.TrimSpace(input.BodyHTML)),
579590
"reply_to_message_id": strings.TrimSpace(input.ReplyToMessageID),
591+
"thread_id": strings.TrimSpace(threadID),
580592
"reply_to": strings.TrimSpace(input.ReplyTo),
581593
"quote": input.Quote,
582594
"from": strings.TrimSpace(input.From),
@@ -610,19 +622,27 @@ func (c *GmailDraftsUpdateCmd) Run(ctx context.Context, flags *RootFlags) error
610622
to = existingTo
611623
}
612624

625+
// A caller-provided --thread-id targets that thread for reply headers,
626+
// overriding the draft's own existing thread; otherwise fall back to the
627+
// existing draft thread as before.
628+
targetThreadID := existingThreadID
629+
if threadID != "" {
630+
targetThreadID = threadID
631+
}
632+
613633
replyToThreadID := ""
614634
if c.Quote && strings.TrimSpace(replyToMessageID) == "" {
615-
resolvedMessageID, resolveErr := resolveQuoteReplyTargetMessageID(ctx, svc, existingThreadID, account, existingMessageID)
635+
resolvedMessageID, resolveErr := resolveQuoteReplyTargetMessageID(ctx, svc, targetThreadID, account, existingMessageID)
616636
if resolveErr != nil {
617637
return resolveErr
618638
}
619639
replyToMessageID = resolvedMessageID
620640
}
621641
if strings.TrimSpace(replyToMessageID) == "" {
622-
replyToThreadID = existingThreadID
642+
replyToThreadID = targetThreadID
623643
}
624644
if c.Quote && strings.TrimSpace(replyToMessageID) == "" && strings.TrimSpace(replyToThreadID) == "" {
625-
return usage("--quote requires --reply-to-message-id or existing draft thread")
645+
return usage("--quote requires --reply-to-message-id or --thread-id or existing draft thread")
626646
}
627647

628648
input.To = to

internal/cmd/gmail_drafts_cmd_test.go

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1402,3 +1402,187 @@ func TestGmailDraftsUpdateCmd_QuoteRequiresReplyContext(t *testing.T) {
14021402
t.Fatalf("expected quote/reply context validation error, got %v", err)
14031403
}
14041404
}
1405+
1406+
// --thread-id on drafts create anchors In-Reply-To/References to the thread's
1407+
// latest message and stamps the draft's threadId (parity with `gmail send`).
1408+
func TestGmailDraftsCreateCmd_WithThreadID(t *testing.T) {
1409+
origNew := newGmailService
1410+
t.Cleanup(func() { newGmailService = origNew })
1411+
1412+
var posted gmail.Draft
1413+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1414+
switch {
1415+
case strings.Contains(r.URL.Path, "/gmail/v1/users/me/threads/t1") && r.Method == http.MethodGet:
1416+
w.Header().Set("Content-Type", "application/json")
1417+
_ = json.NewEncoder(w).Encode(map[string]any{
1418+
"id": "t1",
1419+
"messages": []map[string]any{
1420+
{
1421+
"id": "old", "threadId": "t1", "internalDate": "1000",
1422+
"payload": map[string]any{"headers": []map[string]any{
1423+
{"name": "Message-ID", "value": "<old@id>"},
1424+
}},
1425+
},
1426+
{
1427+
"id": "latest", "threadId": "t1", "internalDate": "2000",
1428+
"payload": map[string]any{"headers": []map[string]any{
1429+
{"name": "Message-ID", "value": "<latest@id>"},
1430+
{"name": "References", "value": "<r1@id>"},
1431+
}},
1432+
},
1433+
},
1434+
})
1435+
return
1436+
case strings.Contains(r.URL.Path, "/gmail/v1/users/me/drafts") && r.Method == http.MethodPost:
1437+
body, _ := io.ReadAll(r.Body)
1438+
if unmarshalErr := json.Unmarshal(body, &posted); unmarshalErr != nil {
1439+
t.Fatalf("unmarshal: %v", unmarshalErr)
1440+
}
1441+
w.Header().Set("Content-Type", "application/json")
1442+
_ = json.NewEncoder(w).Encode(map[string]any{"id": "d1", "message": map[string]any{"id": "m2", "threadId": "t1"}})
1443+
return
1444+
default:
1445+
http.NotFound(w, r)
1446+
return
1447+
}
1448+
}))
1449+
defer srv.Close()
1450+
1451+
svc, err := gmail.NewService(context.Background(),
1452+
option.WithoutAuthentication(), option.WithHTTPClient(srv.Client()), option.WithEndpoint(srv.URL+"/"))
1453+
if err != nil {
1454+
t.Fatalf("NewService: %v", err)
1455+
}
1456+
newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil }
1457+
1458+
flags := &RootFlags{Account: "a@b.com"}
1459+
u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
1460+
if uiErr != nil {
1461+
t.Fatalf("ui.New: %v", uiErr)
1462+
}
1463+
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
1464+
1465+
_ = captureStdout(t, func() {
1466+
if runErr := runKong(t, &GmailDraftsCreateCmd{}, []string{
1467+
"--to", "a@example.com", "--subject", "Re: hi", "--body", "Hello", "--thread-id", "t1",
1468+
}, ctx, flags); runErr != nil {
1469+
t.Fatalf("execute: %v", runErr)
1470+
}
1471+
})
1472+
1473+
if posted.Message == nil {
1474+
t.Fatal("no draft posted")
1475+
}
1476+
if posted.Message.ThreadId != "t1" {
1477+
t.Fatalf("expected draft threadId t1, got %q", posted.Message.ThreadId)
1478+
}
1479+
raw, decErr := base64.RawURLEncoding.DecodeString(posted.Message.Raw)
1480+
if decErr != nil {
1481+
t.Fatalf("decode raw: %v", decErr)
1482+
}
1483+
s := string(raw)
1484+
if !strings.Contains(s, "In-Reply-To: <latest@id>") {
1485+
t.Fatalf("In-Reply-To not anchored to latest thread message:\n%s", s)
1486+
}
1487+
if !strings.Contains(s, "References:") || !strings.Contains(s, "<latest@id>") {
1488+
t.Fatalf("References not built from latest thread message:\n%s", s)
1489+
}
1490+
}
1491+
1492+
// --thread-id and --reply-to-message-id are mutually exclusive on drafts create.
1493+
func TestGmailDraftsCreateCmd_ThreadIDAndMessageIDMutuallyExclusive(t *testing.T) {
1494+
flags := &RootFlags{Account: "a@b.com"}
1495+
u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
1496+
if uiErr != nil {
1497+
t.Fatalf("ui.New: %v", uiErr)
1498+
}
1499+
ctx := ui.WithUI(context.Background(), u)
1500+
err := runKong(t, &GmailDraftsCreateCmd{}, []string{
1501+
"--subject", "S", "--body", "B", "--reply-to-message-id", "m1", "--thread-id", "t1",
1502+
}, ctx, flags)
1503+
if err == nil || !strings.Contains(err.Error(), "use only one of --reply-to-message-id or --thread-id") {
1504+
t.Fatalf("expected mutual-exclusion error, got %v", err)
1505+
}
1506+
}
1507+
1508+
// A caller-provided --thread-id on drafts update overrides the draft's own
1509+
// existing thread when anchoring reply headers.
1510+
func TestGmailDraftsUpdateCmd_WithThreadIDOverridesExisting(t *testing.T) {
1511+
origNew := newGmailService
1512+
t.Cleanup(func() { newGmailService = origNew })
1513+
1514+
var posted gmail.Draft
1515+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1516+
switch {
1517+
case strings.Contains(r.URL.Path, "/gmail/v1/users/me/drafts/d1") && r.Method == http.MethodGet:
1518+
w.Header().Set("Content-Type", "application/json")
1519+
_ = json.NewEncoder(w).Encode(map[string]any{
1520+
"id": "d1",
1521+
"message": map[string]any{"id": "m1", "threadId": "te"},
1522+
})
1523+
return
1524+
case strings.Contains(r.URL.Path, "/gmail/v1/users/me/threads/t1") && r.Method == http.MethodGet:
1525+
w.Header().Set("Content-Type", "application/json")
1526+
_ = json.NewEncoder(w).Encode(map[string]any{
1527+
"id": "t1",
1528+
"messages": []map[string]any{
1529+
{
1530+
"id": "latest", "threadId": "t1", "internalDate": "2000",
1531+
"payload": map[string]any{"headers": []map[string]any{
1532+
{"name": "Message-ID", "value": "<latest@id>"},
1533+
}},
1534+
},
1535+
},
1536+
})
1537+
return
1538+
case strings.Contains(r.URL.Path, "/gmail/v1/users/me/drafts/d1") && r.Method == http.MethodPut:
1539+
body, _ := io.ReadAll(r.Body)
1540+
if unmarshalErr := json.Unmarshal(body, &posted); unmarshalErr != nil {
1541+
t.Fatalf("unmarshal: %v", unmarshalErr)
1542+
}
1543+
w.Header().Set("Content-Type", "application/json")
1544+
_ = json.NewEncoder(w).Encode(map[string]any{"id": "d1", "message": map[string]any{"id": "m2", "threadId": "t1"}})
1545+
return
1546+
default:
1547+
http.NotFound(w, r)
1548+
return
1549+
}
1550+
}))
1551+
defer srv.Close()
1552+
1553+
svc, err := gmail.NewService(context.Background(),
1554+
option.WithoutAuthentication(), option.WithHTTPClient(srv.Client()), option.WithEndpoint(srv.URL+"/"))
1555+
if err != nil {
1556+
t.Fatalf("NewService: %v", err)
1557+
}
1558+
newGmailService = func(context.Context, string) (*gmail.Service, error) { return svc, nil }
1559+
1560+
flags := &RootFlags{Account: "a@b.com"}
1561+
u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
1562+
if uiErr != nil {
1563+
t.Fatalf("ui.New: %v", uiErr)
1564+
}
1565+
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
1566+
1567+
_ = captureStdout(t, func() {
1568+
if runErr := runKong(t, &GmailDraftsUpdateCmd{}, []string{
1569+
"d1", "--to", "a@example.com", "--subject", "Re: hi", "--body", "Hello", "--thread-id", "t1",
1570+
}, ctx, flags); runErr != nil {
1571+
t.Fatalf("execute: %v", runErr)
1572+
}
1573+
})
1574+
1575+
if posted.Message == nil {
1576+
t.Fatal("no draft posted")
1577+
}
1578+
if posted.Message.ThreadId != "t1" {
1579+
t.Fatalf("expected caller thread t1 to override existing te, got %q", posted.Message.ThreadId)
1580+
}
1581+
raw, decErr := base64.RawURLEncoding.DecodeString(posted.Message.Raw)
1582+
if decErr != nil {
1583+
t.Fatalf("decode raw: %v", decErr)
1584+
}
1585+
if !strings.Contains(string(raw), "In-Reply-To: <latest@id>") {
1586+
t.Fatalf("In-Reply-To not anchored to caller thread's latest message:\n%s", string(raw))
1587+
}
1588+
}

0 commit comments

Comments
 (0)