Skip to content

feat(mail): reply-to-thread drafts #114

Description

@rianjs

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:

  1. If `--reply-to` is set, call `client.GetMessage(ctx, replyToID, false)` to read headers.
  2. Derive defaults from the source message.
  3. Explicit flags override derived values (cobra's `Changed()` — same pattern used for `--subject` and `--file` today).
  4. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions