Summary
Extend the `gro mail draft` command (added in #112 / #113) so it can compose a reply that Gmail (and any standards-compliant MUA) renders as a real reply: grouped under the original thread, with `In-Reply-To` and `References` headers pointing at the parent message.
The non-destructive philosophy carries over verbatim — the CLI still never calls `drafts.send`. The draft sits in the user's Drafts folder for explicit human review.
Reuse, don't rebuild
This is strictly additive on top of the #112 / #113 work. Specifically reuse:
- `internal/gmail/drafts.go` → `DraftMessage`, `DraftAttachment`, `DraftResult`, `buildMIME`, `CreateDraft`. Add fields to `DraftMessage`; do not introduce a parallel `ReplyMessage` type.
- The full MIME assembly pipeline (`MIME-Version: 1.0`, CRLF, base64 wrap, RFC 2047 subject encode, RFC 2231 filenames, multipart/mixed for attachments). Reply mode adds two more headers to the existing builder, nothing else.
- The header-injection guard in `guardHeaderInjection` — extend it to cover the new `InReplyTo` and `References` fields rather than writing a new guard.
- `internal/cmd/mail/draft.go` → `newDraftCommand`. Add two new flags to the existing command; do not create a separate `gro mail reply` command.
- The `MailClient` interface and `MockGmailClient` in `internal/cmd/mail/output.go` / `mock_test.go` — `CreateDraft` already handles arbitrary `DraftMessage` shapes, so no interface change is needed for the API call itself. Only add a new mock entry if we need it for the source-message lookup (which already exists as `GetMessage`, also already on the interface).
- The markdown rendering, plain/HTML mode flags, attachment handling, comma-separated address parsing, `--from` normalisation, and `cobra.NoArgs` — none of those need to change.
- The integration-tests.md draft section — append T21+ scenarios alongside the existing T1-T20.
If you find yourself duplicating any of these, stop and refactor to share instead.
Background
Today `gro mail draft` always creates a new standalone thread because:
- `DraftMessage` has no `ThreadID`, `InReplyTo`, or `References` fields.
- The Gmail API call doesn't set `Draft.Message.ThreadId`, so the server assigns a fresh thread.
- No `In-Reply-To`/`References` MIME headers are emitted, so even when forced into the right thread, downstream MUAs may not show the reply indicator.
Proposed usage
# Simplest form — let gro look up the source message
gro mail draft --reply-to <message-id> --body \"thanks, will review\"
# Overrides still work — recipients, subject, body source, attachments, --from
gro mail draft --reply-to <message-id> --cc colleague@example.com --body \"...\"
gro mail draft --reply-to <message-id> --subject \"Re: customised\" --body \"...\"
# Reply-all behaviour
gro mail draft --reply-to <message-id> --reply-all --body \"...\"
Without `--reply-to`, behaviour is unchanged from #113. With `--reply-to` and no overrides:
- To = original `From`
- Cc = (empty)
- Subject = `Re: ` if not already prefixed
- `In-Reply-To` = original `Message-Id`
- `References` = original `References` (if any) + original `Message-Id`
- `Draft.Message.ThreadId` = original thread ID
With `--reply-all`:
- To = original `From`
- Cc = original `To` + original `Cc`, minus the authenticated user
Explicit `--to`/`--cc`/`--bcc`/`--subject` override the derived values (use `cmd.Flags().Changed()`, matching the existing pattern in #113).
API surface (no scope change)
- `gmail.GmailModifyScope` already covers reading message headers and creating drafts. No `auth.AllScopes` change.
- Reuses existing `(*gmail.Client).GetMessage(ctx, id, includeBody=false)` to fetch headers — already on the `MailClient` interface.
Code changes (additive)
`internal/gmail/drafts.go`
Add three fields to the existing `DraftMessage`:
```go
type DraftMessage struct {
// ... existing fields from #113 ...
ThreadID string // empty = new thread; set = reply
InReplyTo string // original Message-Id (with angle brackets)
References []string // accumulated chain
}
```
Extend the existing `buildMIME` to emit `In-Reply-To` and `References` headers when set. Extend `guardHeaderInjection` to cover these two fields. Extend the existing `CreateDraft` body to set `Draft.Message.ThreadId = msg.ThreadID` before the API call.
`internal/cmd/mail/draft.go`
Add two flags to the existing `newDraftCommand`:
- `--reply-to ` (string)
- `--reply-all` (bool, only meaningful with `--reply-to`)
`RunE` gains a resolution step before existing validation:
- If `--reply-to` is set, call `client.GetMessage(ctx, replyToID, false)` to read headers.
- Derive defaults from the source message.
- Explicit flags override derived values (cobra's `Changed()` — same pattern used for `--subject` and `--file` today).
- Subject auto-prefix with `Re: ` if not already present (case-insensitive check).
Tests
- gmail layer: extend the existing tables in `drafts_test.go` — `In-Reply-To`/`References` emitted in MIME, parsed back via `net/mail`; injection guard rejects CR/LF in both; `ThreadId` propagates to the API request payload (extend the existing httptest wiring test).
- command layer: extend `draft_test.go` — `--reply-to` resolves source message → derived To/Subject/headers; `--reply-all` adds Cc; explicit `--to`/`--subject`/`--cc` override derived; `Re: Re: foo` doesn't double-prefix; missing source message fails before the create call.
- Integration tests: append T21+ scenarios to the existing draft section in `integration-tests.md` — reply lands inside the original thread in Gmail UI.
Out of scope
- No `forward` mode (different semantic — separate ticket).
- No quoting of the original message body (let the user write what they want; Gmail's web UI also doesn't auto-quote on reply unless you click the quote button).
- No `Reply-To:` header support (different from `--reply-to` flag despite the name overlap; defer until requested).
Summary
Extend the `gro mail draft` command (added in #112 / #113) so it can compose a reply that Gmail (and any standards-compliant MUA) renders as a real reply: grouped under the original thread, with `In-Reply-To` and `References` headers pointing at the parent message.
The non-destructive philosophy carries over verbatim — the CLI still never calls `drafts.send`. The draft sits in the user's Drafts folder for explicit human review.
Reuse, don't rebuild
This is strictly additive on top of the #112 / #113 work. Specifically reuse:
If you find yourself duplicating any of these, stop and refactor to share instead.
Background
Today `gro mail draft` always creates a new standalone thread because:
Proposed usage
Without `--reply-to`, behaviour is unchanged from #113. With `--reply-to` and no overrides:
With `--reply-all`:
Explicit `--to`/`--cc`/`--bcc`/`--subject` override the derived values (use `cmd.Flags().Changed()`, matching the existing pattern in #113).
API surface (no scope change)
Code changes (additive)
`internal/gmail/drafts.go`
Add three fields to the existing `DraftMessage`:
```go
type DraftMessage struct {
// ... existing fields from #113 ...
ThreadID string // empty = new thread; set = reply
InReplyTo string // original Message-Id (with angle brackets)
References []string // accumulated chain
}
```
Extend the existing `buildMIME` to emit `In-Reply-To` and `References` headers when set. Extend `guardHeaderInjection` to cover these two fields. Extend the existing `CreateDraft` body to set `Draft.Message.ThreadId = msg.ThreadID` before the API call.
`internal/cmd/mail/draft.go`
Add two flags to the existing `newDraftCommand`:
`RunE` gains a resolution step before existing validation:
Tests
Out of scope