feat(gmail): add drafts reply/reply-all/forward - #977
Conversation
Split the message-build half of gmail reply/reply-all/forward into pure builders (buildReplyComposeMessage/buildForwardComposeMessage) that take an already-acquired *gmail.Service, plus service-free resolveReplyInputs/resolveForwardInputs that resolve body/note exactly once and validate. The Run methods now orchestrate resolve -> dry-run -> acquire -> build -> finalize, so the acquire gate and finalize step are no longer baked into the builders. Extract composeSignatureOptions (the shared signature flags + signatureRequested/validateSignatureOptions/resolveComposeSignature methods) and embed it into GmailSendCmd and GmailReplyOptions, removing the throwaway GmailSendCmd the reply path constructed just to borrow those methods. Extract a GmailForwardOptions embed from GmailForwardCmd. No behavior change: CLI flags are byte-identical, and validation order, dry-run output, error wrapping, and finalize are unchanged; the existing reply/reply-all/forward/send/signature tests pass with assertions unchanged. This sets up reuse by upcoming drafts reply/reply-all/forward commands, which will share resolve+build and finalize with Drafts.Create under the non-send service gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add `gmail drafts reply`, `drafts reply-all`, and `drafts forward`, giving the drafts surface full flag/ergonomic parity with the send-side reply/reply-all/forward commands. They embed the same options structs and reuse the shared resolve/build helpers, differing only at finalize: they save a draft (Drafts.Create) instead of sending. Drafts use the non-send service gate, so they work under --gmail-no-send, matching gmail drafts create. `drafts forward` allows an addressless draft (no --to), matching drafts create and Gmail's UI. The recipient requirement is resolved explicitly per call, which also aligns gmail forward's required---to handling with send/reply (runtime check + explanatory help) while keeping the MIME missing-To backstop on the send path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
gmail send, forward, and drafts create/update parsed --to/--cc/--bcc with naive comma-splitting, while reply already parsed them as addresses. The splitting mangled quoted display names ("Smith, John" <john@example.com>) wherever the fragment list was consumed directly: --track counted such a recipient as two and refused to send, --track-split built one message per fragment, and dry-runs reported the broken fragments. Unparseable input was not rejected: send transmitted messages with garbage or empty To headers, and RFC 5322 group syntax ("undisclosed-recipients:;", which parses to zero addresses without an error) was silently dropped or silently saved.
Route every compose command through shared address-aware parsing (parseComposeRecipients -> mail.ParseAddressList), with reply's within-flag case-insensitive dedup applying everywhere. Recipients are parsed service-free before the dry-run, so the dry-run reports the same lists the built message carries; malformed input and non-empty flags that parse to zero recipients now fail fast with a flag-named error and no API call, on every compose command. A draft update without --to keeps the existing draft's To header leniently (verbatim if it does not parse), so legacy drafts stay editable. Nameless addresses whose bare form is not valid on the wire (quoted local parts) are re-quoted instead of silently emitted broken. The tracking pixel identity is now always the bare email address, independent of how the recipient was typed. Includes final-review test and comment polish across the compose paths.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
🦞👀 Pull request received. I will update this pull request when review starts. |
|
Codex review: needs maintainer review before merge. Reviewed August 10, 2026, 9:44 PM ET / August 11, 2026, 01:44 UTC. ClawSweeper reviewWhat this changesAdds Gmail draft reply, reply-all, and forward commands, refactors shared compose code, and makes recipient parsing address-aware across compose commands. Merge readinessKeep open for maintainer sponsorship: current main lacks these draft commands, while the PR has credible live Gmail proof but combines a new command surface with a broad compose refactor that VISION.md says to discuss first. Priority: P2 Review scores
Verification
How this fits togetherGmail compose commands resolve source messages and recipient inputs, build an RFC 822 message, then either send it or save it as a draft through the Gmail API. This PR reuses the reply and forward composition paths while changing finalization to draft creation under no-send guardrails. flowchart LR
A[CLI compose command] --> B[Resolve message and recipients]
B --> C[Build MIME message]
C --> D{Final action}
D -->|Send| E[Gmail send API]
D -->|Save draft| F[Gmail drafts API]
E --> G[Message result]
F --> G
Decision needed
Why: VISION.md requires maintainer discussion for new API surfaces and broad refactors; source review cannot make that product-scope choice. Before merge
Agent review detailsSecurityNone. Review metrics
Merge-risk optionsMaintainer options:
Technical reviewBest possible solution: Sponsor the three draft-only Gmail verbs, then merge the shared implementation only after accepting the stricter recipient contract and confirming existing send/reply/forward compatibility. Do we have a high-confidence way to reproduce the issue? Not applicable: this PR adds new commands rather than reporting an existing failure. Its body supplies a redacted live Gmail run with 23 passing checks for the proposed behavior. Is this the best way to solve the issue? Unclear: builder reuse is a maintainable implementation approach, but the repository policy requires maintainer agreement on this new command surface and broad refactor. AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against 71c6c1e63787. LabelsLabel changes:
Label justifications:
EvidenceWhat I checked:
Likely related people:
Rating scale
Overall follows the weaker of proof and patch quality. Workflow
|
…nitized get (#272) Raises `MIN_GOG_VERSION` **0.35.0 → 0.37.0** (and `fly-gog-runner/Dockerfile`'s pin with it) and takes up what 0.36.0 / 0.37.0 added. ## New tools **Gmail — draft-side reply/forward** (gog 0.36.0, openclaw/gogcli#977) | Tool | | |---|---| | `gog_gmail_drafts_reply` | Save a reply as a draft — inherited recipients, subject and quote; never sends | | `gog_gmail_drafts_reply_all` | Same, to every participant | | `gog_gmail_drafts_forward` | Save a forward as a draft; `to` is optional, so it can be staged with no recipients at all | They take the same flag set as the send commands and share gog's composition path, so `replySchema` / `appendReplyFlags` are reused verbatim rather than re-declared. Staging a reply previously meant `gog_gmail_drafts_create` + `replyToThreadId`, which threads the draft but inherits neither the original's recipients nor its quoted body — both had to be rebuilt by hand, and a missed Cc is invisible until the draft goes out. **Sheets — Connected Sheets reads** (gog 0.37.0, openclaw/gogcli#938) `gog_sheets_datasource_list` / `_describe`, and `gog_sheets_datasource_table_list` / `_describe` / `_read`. Read-only by construction (gog exposes no create/update/refresh/delete here). A data-source table has no id of its own in the Sheets API — its definition lives on its top-left cell — so extracts are addressed by a sheet-qualified A1 anchor like `Extracts!B3`. ## Behaviour these depend on - **`gog_gmail_get` gains `sanitizeContent`.** The flag predates 0.37.0 but emitted the headers and body **twice** in JSON (openclaw/gogcli#992), so the flag meant to shrink the payload enlarged it. Verified against a live 0.37.0 build: the sanitized message now arrives under a single `message` key. - **`gog_calendar_events` gains `days`**, and it and `gog_calendar_search` now describe the window rules gog 0.36.0 enforces (openclaw/gogcli#981). `--days` is a window *length* anchored at `--from`; it used to discard `--from` silently and answer for today, at exit 0, in a well-formed table. Fixed presets no longer combine with `from`/`to`/`days`, and `days` no longer combines with `to`. The old descriptions invited exactly the combinations that now fail, and the base calendar test asserted an arg array gog refuses to run. - **The auth tools gain `extraScopes`** (plus `--force-consent` on the interactive one, since Google re-prompts for a *new* scope only when consent is forced). Nothing else can request `bigquery.readonly`, which Google demands whenever a Sheets response *contains* BigQuery Connected Sheets data — without it the new sheets tools cannot be authorized through the wrapper at all. Documented in `docs/auth-scopes.md`. ## Not adopted: `gmail search --count` gog 0.36.0 (openclaw/gogcli#985) upstreamed this wrapper's match-count probe, down to the page size and the exact/lower-bound split. The local probe stays, and the stale comment claiming gog *cannot* supply the count is corrected to say why: - it is spent only on a result set already known to be truncated, where `--count` is decided before the search runs and would cost every search an extra Gmail request; - it is best-effort, where gog returns the probe's error from the whole command — a failed count would turn a search that *did* succeed into an error. ## Verification Beyond the unit tests (the mocked suites only assert arg arrays), every new command was run against the real gog v0.37.0 binary with fake ids, which parses flags before any API call: - `gmail drafts reply` / `reply-all` / `forward` — full flag sets parse; failures are the API's 400 on the fake id (and a send-as validation for `--from`), not unknown flags. - `sheets datasource list` / `describe` / `table list` / `table describe` / `table read` — all reach the API and 404 on the fake spreadsheet. - `calendar events --from 2026-09-25 --days 5` returns the anchored window; `--today --from …` and `--days … --to …` are both rejected with the messages the tool descriptions now quote. - `gmail get --sanitize-content --json` on a real message: top-level keys are exactly `["message"]`. `npm test` (all workspaces + fly-gog-runner), `npm run typecheck` and `npm run build` are green; the 100% coverage gate holds. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01EjH6C4jVKavpHiFwBW574N Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
What
Adds the drafts-side mirror of the send-side compose verbs:
gog gmail drafts reply <messageId>— save a reply as a draftgog gmail drafts reply-all <messageId>— save a reply-all as a draftgog gmail drafts forward <messageId>— save a forward as a draftEach mirrors its send-side counterpart exactly — same positional argument, same flags (they embed the same options structs), same dry-run dictionary (only the action name differs) — and differs only at finalize:
Drafts.Createinstead ofMessages.Send, under the non-send service gate.Why
This completes the human-in-the-loop story from #239 (“I always want a human in the loop”, i.e. draft-only Gmail). The no-send guardrails closed that issue for new mail —
drafts createworks under--gmail-no-sendand in the agent-safe build — but replying and forwarding remained send-only, so an agent prepared to draft a reply for human review simply couldn't. #804 (draft replies didn't set recipients) got its minimal fix at thedrafts create --reply-to-message-idlevel; real reply verbs with recipient auto-population are the ergonomic completion.The layering mirrors how
reply/reply-all/forwardwere themselves layered ontosend: the drafts verbs reuse the existing resolve/build helpers verbatim and add only a different finalize step.Commits
Three commits, each building/vetting/testing green standalone:
Runmethods into service-free resolve+validate, a pure message builder taking an already-acquired service, and an orchestratingRun(resolve → dry-run → acquire → build → finalize). Extracts the shared signature options into an embedded struct, removing the throwawayGmailSendCmdthe reply path constructed to borrow those methods. CLI schema verified byte-identical before/after (fullgog schema --jsondiff).send,forward, anddrafts create/updateparsed--to/--cc/--bccby naive comma-splitting whilereplyalready parsed addresses. The new drafts verbs would have shipped those bugs; instead every compose command now shares one address-aware parse.Behavior changes (complete ledger)
New behavior:
gmail drafts reply|reply-all|forwardexist (aliasesreplyall,fwd), work under--gmail-no-send/config no-send, and are allowed in the agent-safe profile (blocked in readonly).drafts forwardpermits an addressless draft (no--to), likedrafts createand Gmail's UI. The send-sidegmail forwardstill requires--to; that check moved from a kongrequired:""tag to a runtime check (same error text, and the--tohelp now says “required when sending, optional when saving a draft”).inReplyTo/references/replyContextSourcein their result JSON (same shapedrafts createreports after the reply-context work); forward drafts report the Gmail-assigned thread id.--auto-from-addressed-alias(feat(gmail): env-configure --auto-from-addressed-alias via GOG_GMAIL_AUTO_FROM_ADDRESSED_ALIAS #964) and the signature flags work on the drafts verbs identically to their send counterparts.Fixes (commit 3), applying to
send,forward, anddrafts create/update:"Smith, John" <john@example.com>) is one recipient everywhere. Previously--trackcounted it as two and refused,--track-splitbuilt one message per fragment, and dry-runs reported broken fragments.sendcould transmit garbage/empty To headers).undisclosed-recipients:;— parses to zero addresses without an error) is rejected up front instead of silently dropped or saved."john smith"@example.com) is re-quoted instead of emitted broken.drafts updatewithout--tokeeps the existing draft's To header leniently: strict parse when it parses, verbatim fragments when it doesn't, so a body-only edit of a legacy-malformed header (e.g. Outlook-styleTo: a@x.com; b@y.com, which Gmail accepts and Go's parser rejects) cannot fail. The MIME writer renders kept fragments exactly as before this change.Not changed: reply's cross-field overlap rejection stays reply-only by design (reply's
--to/--cc/--bccare add-or-move operations on an existing recipient set, where one address in two flags is contradictory; compose flags assign whole fields, where overlap is well-defined).Proof
Two self-contained TAP scripts (Python stdlib only).
proof-live.pyruns against a real Gmail account: it inserts one synthetic source message viamessages.insert(an IMAP-append — nothing is ever sent, all counterparties areexample.com), asserts each behavior above, and deletes every draft it created — the final check asserts the account's full draft set is byte-identical to the pre-run baseline. The account address is structurally redacted (never printed, even in failure diagnostics).proof-profiles.pyneeds no account: it bakesagent-safe.yamlandreadonly.yamlwith the realbake-safety-profiletool, builds real-tags safety_profilebinaries, and probes the allow/block matrix.Re-run them yourself from the branch:
Scope notes: this is targeted behavior evidence, not a substitute for the CI gate. The inherited flag matrix (quote/HTML/attachments/send-as/signature variants) is covered by the unit suite, including byte-parity tests between the send and draft builders. Tracking identity/split behavior is unit-tested (
TestResolveTrackingConfigand the tracking batch tests) — proving it live would require real sends. Config-based no-send variants are unit-tested; the live script exercises the flag form.proof-live.py output (23/23, real Gmail account, redacted by construction):
proof-live.py (369 lines, stdlib only)
proof-profiles.py output (9/9, no account needed):
proof-profiles.py (144 lines, stdlib only)
Docs
docs/spec.mdsynopsis hand-synced; generated command pages included (make docs-commands, 712 pages); the agent workflow section ofdocs/gmail-workflows.mdupdated to cover drafting replies under no-send. NoCHANGELOG.mdentry per release convention.Review notes
gmail forwardrequired---tomove (kong tag → runtime check) is the one send-side mechanical change commit 2 makes; error text is identical, and the schema records the same requiredness in help. Happy to rework if you prefer a different shape.🤖 Generated with Claude Code