feat(mail): add draft composition command - #113
Conversation
Add `gro mail draft` for composing Gmail drafts via the users.drafts.create API. Drafts land in the Drafts folder for human review; the CLI never calls drafts.send, preserving the non-destructive philosophy. Body input is markdown by default and rendered to HTML via goldmark (table, strikethrough, and task-list extensions enabled). Use --plain for plain text or --html to pass raw HTML verbatim. Body comes from --body, --stdin, or --file. Supports --to/--cc/--bcc as comma-separated address lists, --from for send-as aliases, and --attach (repeatable) for file attachments. MIME assembly is RFC 5322/2045 compliant: MIME-Version: 1.0, CRLF line endings, Content-Transfer-Encoding: base64 with 76-column wrapping. Single- part for plain bodies, multipart/mixed for attachments. The gmail package owns MIME construction and defends the raw boundary against CR/LF header injection independently of command-layer validation. Attachment filenames are basenamed (filepath.Base) so local paths never leak into the message. Coverage: - 21 MIME builder + httptest API wiring tests in internal/gmail/drafts_test.go - 21 command-level tests in internal/cmd/mail/draft_test.go covering flag presence, validation negatives (missing flags, mutual exclusions, header injection, invalid addresses), and all success paths (markdown default, plain, html, file, stdin, attachments, JSON output) - 18 manual integration tests against live Gmail API — all green; see integration-tests.md Closes #112
FindingsMajor: reject unexpected positional args
Add Major: normalize
|
TDD Coverage AssessmentFresh, independent review of the test coverage on this PR — no prior context. Verdict: coverage is strong. Both layers (gmail MIME assembly + cobra command wiring) are well exercised, the security-sensitive surfaces (header injection, basename-only attachments, base64 line wrap) all have dedicated tests, and the API wiring is verified end-to-end against an A few minor gaps below, none blocking. Critical gapsNone. Recommended additions
Acknowledged minor
What's done well
Bottom lineShip it. The recommended additions are roughly 30-40 lines of test code total and would be nice follow-ups, not blockers. |
- Add cobra.NoArgs so unexpected positional args fail loud instead of
being silently dropped (Codex M1).
- Normalise --from via parsed.Address so display-name input like
"Work <work@me.com>" reduces to the bare address before MIME
assembly, matching --to/--cc/--bcc handling (Codex M2).
- Use cmd.Flags().Changed("file") for body-source detection so an
explicit --file "" is counted alongside --body (Codex m1).
- Extend test coverage: positional-arg rejection, --from display-name
normalisation, header-injection guard across --cc/--bcc/--from,
and detectMimeType fallback branches (no-ext, unknown-ext)
(Codex + TDD).
|
Addressed in 1d34e3 (next push): From Codex review:
From TDD review:
Test count: 31 passing in |
FindingsNo findings. Code Quality LensThe pushed fixes close the prior gaps: Architecturally this stays aligned with the product intent: command code owns CLI validation/rendering, |
monit-reviewer
left a comment
There was a problem hiding this comment.
Automated PR Review
Reviewed commit: c1d546b
Summary
| Reviewer | Findings |
|---|---|
| documentation:docs-reviewer | 1 |
| harness-engineering:harness-architecture-reviewer | 7 |
| harness-engineering:harness-enforcement-reviewer | 2 |
| harness-engineering:harness-knowledge-reviewer | 4 |
| harness-engineering:harness-self-documenting-code-reviewer | 4 |
documentation:docs-reviewer (1 findings)
💡 Suggestion - integration-tests.md:472
T2 test case passes
\| col1 \| col2 \|to the CLI using bash$'...'ANSI C quoting, where\|is not a recognized escape sequence and the backslash is passed literally. The expected result claims a rendered 2-column table, but Markdown parsers treat\|as escaped pipes (literal|) rather than column separators, so the table will not render. Remove the backslashes: use| col1 | col2 |\n|------|------|\n| a | b |.
harness-engineering:harness-architecture-reviewer (7 findings)
internal/gmail/drafts.go:111
multipart.NewWriter(&buf)is initialized before the Content-Type header is written directly tobufviafmt.Fprintf. This is correct today becauseCreateParthas not yet been called, but it creates a fragile implicit ordering contract: any future refactor that callsmw.CreatePartbefore thefmt.Fprintfline would silently corrupt the message by inserting the first boundary before the end-of-headers blank line. The writer should be initialized after all top-level headers are written tobuf, or the boundary should be captured independently before the writer is created.
💡 Suggestion - internal/gmail/drafts.go:24
DraftBodyHTMLis declared asiota(value 0), making it the zero value ofDraftBodyKind. AnyDraftMessage{}with an unsetBodyKindsilently defaults to HTML behavior, which is the less-safe and more surprising default for a non-destructive tool. Swap the constant order soDraftBodyPlainText = iota(0) andDraftBodyHTML = 1; the existing explicit assignments indraft.goare unaffected in meaning but should be re-verified.
💡 Suggestion - internal/cmd/mail/draft.go:219
detectMimeTypecallsmime.TypeByExtension, which reads the OS MIME database. On minimal container images (Alpine, distroless, scratch) without a MIME DB, common extensions like.docxreturn""and silently fall back toapplication/octet-stream.TestDetectMimeTypepasses on developer machines but may silently degrade in CI or production container environments. Embedding a small static fallback map for the most common document types would eliminate the OS dependency.
💡 Suggestion - internal/cmd/mail/draft_test.go:116
There is no test for the error path where
--filepoints to a non-existent path.TestDraftCommand_AttachmentMissingFilecovers the equivalent case for--attach, but the--filecode path (os.ReadFile(file)) is untested for the missing-file error. This is a reachable user-facing error path.
💡 Suggestion - internal/cmd/mail/draft.go:162
There is no size guard on
--stdin,--file, or--attachinputs. All content is fully read into memory before the API call. Gmail enforces a ~25 MB total message size limit; exceeding it produces an opaque API error rather than a helpful pre-flight message. A check beforeCreateDraft(e.g., rejecting combined payload > 25 MB) would surface this with a clear, actionable error.
💡 Suggestion - internal/cmd/mail/draft_test.go:97
TestDraftCommand_RejectsPlainAndHTMLTogetherasserts onlytestutil.Contains(t, err.Error(), "--plain"). The actual error is"--plain and --html are mutually exclusive". The weak containment check would pass even if the error message were changed to something unrelated that happens to mention--plain. The assertion should also verify"--html"or"mutually exclusive"to pin the specific validation message.
💡 Suggestion - internal/cmd/mail/draft_test.go:373
TestDraftCommand_AttachmentMissingFilecallstestutil.Error(t, err)but does not verify the error message contains the missing path or"reading attachment". The test would pass even if the error came from a completely different code path. Addingtestutil.Contains(t, err.Error(), "/nope/does/not/exist")would confirm attachment loading is the actual failure point.
harness-engineering:harness-enforcement-reviewer (2 findings)
💡 Suggestion - internal/cmd/mail/draft.go:226
The success text output prints
To:andSubject:but silently omitsCc:andBcc:. A user who passes--ccor--bccreceives no confirmation in stdout that those headers were set, making it easy to overlook a misconfiguration (e.g., a typo in--bcc) without inspecting the draft in Gmail.
💡 Suggestion - internal/cmd/mail/draft.go:259
The
--plainflag description reads "Send body as plain text (no markdown rendering)". The word "Send" implies an outbound action, which conflicts with gro's explicit non-destructive, never-sends philosophy. Should read "Treat body as plain text (no markdown rendering)" to stay consistent with project semantics.
harness-engineering:harness-knowledge-reviewer (4 findings)
💡 Suggestion - internal/cmd/mail/draft_test.go:253
TestDraftCommand_Attachments_BasenameOnlyassertsseen.Attachments[0].MimeType == "application/pdf"via exact equality. On some Linux distributionsmime.TypeByExtension(".pdf")returns"application/pdf; charset=binary"or similar. The test will pass on macOS but could fail in CI on Linux. Usestrings.HasPrefixormime.ParseMediaTypeto extract only the base media type.
💡 Suggestion - internal/cmd/mail/draft_test.go:102
TestDraftCommand_RejectsMultipleBodySourcesonly exercises--body + --file. The--body + --stdinand--file + --stdincombinations are untested. All three two-way combinations should be covered since each involves different code paths (thestdinbool is checked independently ofcmd.Flags().Changed).
💡 Suggestion - internal/cmd/mail/draft_test.go:140
TestDraftCommand_RejectsHeaderInjectionInToandTestDraftCommand_RejectsHeaderInjectionInSubjectassert only thaterr != nilwithout checking the error message content. If the command returns an error for a different reason before the CR/LF guard fires, the test still passes but is not verifying the intended behavior. Addtestutil.Contains(t, err.Error(), "CR or LF")or similar to pin the specific validation.
💡 Suggestion - internal/cmd/mail/draft.go:218
parseAddressListextracts onlya.Address, silently discarding display names. A user who passes--to "Alice <alice@example.com>"will have the display name stripped; the MIMETo:header will contain<alice@example.com>rather thanAlice <alice@example.com>. This behavior is undocumented in the help text and README. Either preserve display names via(&mail.Address{Name: a.Name, Address: a.Address}).String()or document the stripping explicitly.
harness-engineering:harness-self-documenting-code-reviewer (4 findings)
internal/gmail/drafts.go:112
Multipart Content-Type header uses
%qto format the boundary parameter. Go's%quses backslash-escape semantics, not RFC 2045 quoted-string quoting. It works today only becausemultipart.NewWritergenerates a hex boundary with no characters that differ between the two quoting styles. If the boundary ever contained a backslash or other character requiring%qescaping, the boundary parameter value would not match the literal boundary separator written by the multipart.Writer, silently breaking all multipart parsing. Usemime.FormatMediaType("multipart/mixed", map[string]string{"boundary": mw.Boundary()})instead.
💡 Suggestion - internal/cmd/mail/draft.go:179
result.IDfrom the Gmail API response is printed withfmt.PrintfwithoutSanitizeOutput, while the immediately followingTo:andSubject:lines do useSanitizeOutput. The risk is minimal since the ID is an alphanumeric API-controlled string, but the inconsistency could matter if a test mock returns a malformed value or if a future API version changes the ID character set.
💡 Suggestion - internal/cmd/mail/draft_test.go:47
TestDraftCommand_RequiresToonly covers the case where--tois not provided at all (cmd.Flags().Changed == false). There is no test for--to ""(flag explicitly set to empty string), which exercises the separatelen(toAddrs) == 0guard. These are two distinct code paths and both should be tested.
💡 Suggestion - internal/gmail/drafts_test.go:270
The multipart MIME tests (
TestBuildMIME_SingleAttachment,TestBuildMIME_MultipleAttachments_MixedTypes, etc.) do not callhasBareLF. OnlyTestBuildMIME_PlainTextasserts CRLF compliance. A regression that introduces bare LF line endings in the multipart path (e.g., from a part header or boundary line) would not be caught.
5 info-level observations excluded. Run with --verbose to include.
4 PR discussion threads considered.
Completed in 8m 13s | $2.42 | sonnet | daemon 0.2.116 | Glorfindel
| Field | Value |
|---|---|
| Model | sonnet |
| Reviewers | hybrid-synthesis, documentation:docs-reviewer, harness-engineering:harness-architecture-reviewer, harness-engineering:harness-enforcement-reviewer, harness-engineering:harness-knowledge-reviewer, harness-engineering:harness-self-documenting-code-reviewer, security:security-code-auditor |
| Engine | claude · sonnet |
| Reviewed by | pr-review-daemon · monit-pr-reviewer |
| Duration | 8m 13s wall · 25m 10s compute (Reviewers: 5m 49s · Synthesis: 2m 22s) |
| Cost | $2.42 |
| Tokens | 430.6k in / 98.8k out |
| Turns | 11 |
Per-workstream usage
| Workstream | Model | In | Out | Cache read | Cache create | Cost |
|---|---|---|---|---|---|---|
| hybrid-synthesis | sonnet | 32.4k | 9.7k | 13.6k | 18.8k (1h) | $0.23 |
| documentation:docs-reviewer | haiku | 8.1k | 8.8k | 0 | 8.1k (1h) | $0.06 |
| harness-engineering:harness-architecture-reviewer | sonnet | 94.2k | 21.1k | 37.4k | 56.9k (1h) | $0.57 |
| harness-engineering:harness-enforcement-reviewer | sonnet | 94.0k | 20.6k | 37.4k | 56.6k (1h) | $0.56 |
| harness-engineering:harness-knowledge-reviewer | sonnet | 90.0k | 16.6k | 37.4k | 52.6k (1h) | $0.48 |
| harness-engineering:harness-self-documenting-code-reviewer | sonnet | 78.6k | 14.4k | 32.7k | 45.9k (1h) | $0.42 |
| security:security-code-auditor | haiku | 33.2k | 7.7k | 0 | 33.2k (1h) | $0.10 |
Re-reviews only run when @monit-reviewer is re-requested as a reviewer — push as many commits as you need, then re-request when ready. PRs targeting branches other than main, master are skipped, even when @monit-reviewer is re-requested.
Should-fix (2):
- drafts.go: replace %q with mime.FormatMediaType for multipart boundary
(%q is Go-escape, not RFC 2045 quoted-string — happened to work today
only because the boundary contains no escape-relevant chars).
- drafts.go: document the implicit header-vs-CreatePart ordering contract
so a future refactor can't silently corrupt MIME by reordering calls.
Suggestions adopted:
- Swap iota so DraftBodyPlainText is the zero value of DraftBodyKind.
Library callers constructing DraftMessage{} get the safer default.
- Print Cc/Bcc on success output (silent omission could hide misconfig).
- Flag help: "Send body as ..." → "Treat body as ..." to align with
the never-sends philosophy.
- Document display-name stripping in --to/--cc/--bcc help and README
(Alice <alice@example.com> reduces to alice@example.com).
- integration-tests.md T2: backslash-pipe inside bash $'...' doesn't
produce a renderable markdown table — switch to a heredoc setup block.
Tests strengthened:
- Pin error message content on header-injection, plain+html, and
attachment-missing tests so regressions in the validator wording can't
slip past with a still-non-nil error.
- Use prefix-match on attachment MimeType so the test stays portable
across macOS/Linux MIME databases.
- Add coverage for --to "" (distinct code path from missing --to) and
--file pointing at a missing path.
Acknowledged but not fixed (low-value):
- Static MIME fallback for minimal containers — gro ships as a local
binary, not a container.
- 25 MB pre-flight size guard — Gmail's API error is acceptable for v1.
- Multipart CRLF assertion — net/mail.ReadMessage parsing implicitly
validates RFC 5322 compliance.
- result.ID sanitization — API-controlled alphanumeric.
- Two-way body-source pair coverage — three-way "exactly one" guard is
already exercised; pairs are redundant.
monit-reviewer
left a comment
There was a problem hiding this comment.
Automated PR Review
Reviewed commit: 6fb102a | Previous: c1d546b (incremental)
Summary
| Reviewer | Findings |
|---|---|
| documentation:docs-reviewer | 1 |
| harness-engineering:harness-architecture-reviewer | 2 |
| harness-engineering:harness-enforcement-reviewer | 4 |
| harness-engineering:harness-knowledge-reviewer | 3 |
| harness-engineering:harness-self-documenting-code-reviewer | 3 |
documentation:docs-reviewer (1 findings)
💡 Suggestion - integration-tests.md:464
The setup block references
gro me --idto retrieve the authenticated user's email address, but this command does not appear in README.md, CLAUDE.md, or any documented command reference. Readers cannot follow the integration test setup without knowing whether this command exists or what it does. Either add a brief note explaining the command or replace it with an alternative (e.g. reading from credentials).
harness-engineering:harness-architecture-reviewer (2 findings)
💡 Suggestion - internal/cmd/mail/draft_test.go:312
TestDraftCommand_MultipleRecipientspasses--body "text"without--plain, silently exercising the markdown-to-HTML rendering path. The test asserts only recipient counts and never checksseen.BodyKind, so a regression that mis-routes toDraftBodyPlainTextwould not be caught. Either add--plainto keep this a pure recipient-routing test, or addtestutil.Equal(t, seen.BodyKind, gmailapi.DraftBodyHTML)to document the implicit HTML-path coverage.
💡 Suggestion - internal/cmd/mail/draft.go:110
--stdinbody-source counting usesif stdin { bodySources++ }(direct bool check), while--bodyand--fileusecmd.Flags().Changed(). After the previous cycle fixed--fileto useChanged()for consistency,--stdinremains the odd one out. The two approaches are functionally equivalent today (bool flag, default false), but the inconsistency breaks the established pattern and could silently miscount if a future bool flag with a non-false default copies this code.
harness-engineering:harness-enforcement-reviewer (4 findings)
internal/cmd/mail/draft.go:214
Nil dereference risk:
resultfromclient.CreateDraftis accessed asresult.ID(and viaprintJSON) without a nil guard. The mock returns(nil, nil)whenCreateDraftFuncis unset, and a defensive real implementation could do the same. IfCreateDraftreturns(nil, nil), the text-output path panics atresult.ID. Addif result == nil { return fmt.Errorf("creating draft: empty response") }after the error check.
internal/gmail/drafts.go:74
buildMIMEdoes not validate thatmsg.Tois non-empty.addressHeader("To", msg.To)returns an empty string for a nil/empty slice, producing an RFC 5322-invalid To-less MIME message. The command layer enforces ≥1 recipient, butCreateDraftis exported; any direct caller bypasses this invariant silently and receives an opaque Gmail API error rather than a clear pre-flight failure. Add alen(msg.To) == 0guard at the top ofbuildMIME.
💡 Suggestion - internal/cmd/mail/mock_test.go:127
MockGmailClient.CreateDraftreturns(nil, nil)whenCreateDraftFuncis not set. Any future test that reaches theCreateDraftcall without wiringCreateDraftFuncwill panic inside the command handler atresult.ID(related to the medium nil-guard finding). The safer default is a non-nil stub result (e.g.&gmailapi.DraftResult{ID: "mock-draft-id"}) matching the pattern used by other mock methods that return typed stubs.
💡 Suggestion - internal/gmail/drafts_test.go:1
No test covers
buildMIMEorCreateDraftwith an empty body (Body: []byte{}).wrapBase64([]byte{})produces no output lines, leaving the body section empty after the part headers. This is technically valid MIME and reachable via--body "", but there is no assertion confirming the Gmail API accepts it and that base64 round-trip is correct.
harness-engineering:harness-knowledge-reviewer (3 findings)
💡 Suggestion - internal/cmd/mail/draft_test.go:415
TestDraftCommand_RejectsHeaderInjection_AllFields(the table-driven test covering--cc,--bcc, and--from) callstestutil.Errorbut does not assert the error message contains"CR or LF". The per-field tests for--toand--subject(settled thread #13) do pin this string. A misfired address-parse error would make this test pass without the injection guard actually firing. Add atestutil.Contains(t, err.Error(), "CR or LF")check to each table row for consistency.
💡 Suggestion - internal/gmail/drafts_test.go:331
TestBuildMIME_Base64PayloadLineLengthonly validates the single-part code path. The multipart path writeswrapBase64output through amultipart.Partwriter rather than directly tobuf; no test verifies that base64 lines inside attachment parts also respect the 76-column RFC 2045 limit. A regression in the multipart base64 path would go undetected.
💡 Suggestion - internal/gmail/drafts_test.go:1
No test covers
buildMIMEwithDraftBodyHTMLand at least one attachment (multipart+HTML body path). All multipart tests (TestBuildMIME_SingleAttachment,TestBuildMIME_MultipleAttachments_MixedTypes,TestBuildMIME_TextPlainCharset) useDraftBodyPlainText. ThebodyContentTypeValuebranch forDraftBodyHTMLin the multipart path is unverified, and theContent-Typeheader on the HTML body part could regress silently.
harness-engineering:harness-self-documenting-code-reviewer (3 findings)
💡 Suggestion - internal/gmail/drafts.go:203
addressHeaderproduces a single unfolded header line for large address lists (e.g. 50 Cc recipients). RFC 5322 soft-limits header lines to 78 characters and hard-limits to 998. No line-folding is applied. While most email infrastructure is tolerant, strict SMTP/MIME gateways may reject or truncate oversized header lines.
💡 Suggestion - internal/cmd/mail/draft.go:255
detectMimeTypecallsfilepath.Ext, which preserves the case of the extension (e.g.mime.TypeByExtensionperforms a case-sensitive lookup against/etc/mime.types, soapplication/octet-streameven thoughstrings.ToLower(ext)before the lookup would make behavior consistent across platforms.
💡 Suggestion - internal/gmail/drafts_test.go:1
No test covers
buildMIMEwith an emptymsg.Toslice. Given that the medium finding recommends adding alen(msg.To) == 0guard, a corresponding test asserting thatbuildMIMEreturns an error whenTois nil/empty is needed to verify the enforcement and prevent regression.
6 info-level observations excluded. Run with --verbose to include.
22 PR discussion threads considered.
Completed in 9m 42s | $2.36 | sonnet | daemon 0.2.116 | Glorfindel
| Field | Value |
|---|---|
| Model | sonnet |
| Mode | Re-review · Cycle 2 · Session resumed |
| Reviewers | hybrid-synthesis, documentation:docs-reviewer, harness-engineering:harness-architecture-reviewer, harness-engineering:harness-enforcement-reviewer, harness-engineering:harness-knowledge-reviewer, harness-engineering:harness-self-documenting-code-reviewer, security:security-code-auditor |
| Engine | claude · sonnet |
| Reviewed by | pr-review-daemon · monit-pr-reviewer |
| Duration | 9m 42s wall · 21m 47s compute (Reviewers: 5m 31s · Synthesis: 3m 31s) |
| Cost | $2.36 |
| Tokens | 478.5k in / 84.7k out |
| Turns | 11 |
Per-workstream usage
| Workstream | Model | In | Out | Cache read | Cache create | Cost |
|---|---|---|---|---|---|---|
| hybrid-synthesis | sonnet | 50.2k | 13.7k | 13.6k | 36.6k (1h) | $0.35 |
| documentation:docs-reviewer | haiku | 8.3k | 7.1k | 0 | 8.3k (1h) | $0.05 |
| harness-engineering:harness-architecture-reviewer | sonnet | 94.0k | 18.2k | 42.5k | 51.5k (1h) | $0.51 |
| harness-engineering:harness-enforcement-reviewer | sonnet | 89.5k | 14.3k | 42.5k | 47.0k (1h) | $0.43 |
| harness-engineering:harness-knowledge-reviewer | sonnet | 88.8k | 13.0k | 42.5k | 46.3k (1h) | $0.41 |
| harness-engineering:harness-self-documenting-code-reviewer | sonnet | 77.6k | 11.4k | 37.7k | 39.8k (1h) | $0.36 |
| security:security-code-auditor | haiku | 34.2k | 5.1k | 0 | 34.1k (1h) | $0.09 |
| discussion-summarizer | — | 36.0k | 1.9k | 18.9k | 17.1k (1h) | $0.17 |
Re-reviews only run when @monit-reviewer is re-requested as a reviewer — push as many commits as you need, then re-request when ready. PRs targeting branches other than main, master are skipped, even when @monit-reviewer is re-requested.
|
|
||
| **Setup:** | ||
| ```bash | ||
| make build |
There was a problem hiding this comment.
🔵 Low (documentation:docs-reviewer): The setup block references gro me --id to retrieve the authenticated user's email address, but this command does not appear in README.md, CLAUDE.md, or any documented command reference. Readers cannot follow the integration test setup without knowing whether this command exists or what it does. Either add a brief note explaining the command or replace it with an alternative (e.g. reading from credentials).
Reply to this thread when addressed.
There was a problem hiding this comment.
Fixed: replaced 'gro me --id' (not a real command) with ME="you@example.com" and a note to set it to the authenticated Google account.
…erage - buildMIME: reject empty To recipients (defense in depth at gmail boundary) - draft cmd: nil-guard CreateDraft response - draft cmd: --stdin uses cmd.Flags().Changed() to match --body/--file - detectMimeType: lowercase ext for cross-platform consistency - mock: return non-nil DraftResult stub by default - tests: assert BodyKind in MultipleRecipients; pin 'CR or LF' in injection table - tests: empty body, multipart HTML body, multipart base64 line length, empty To - integration-tests.md: replace 'gro me --id' (not a real command) with hardcoded email
monit-reviewer
left a comment
There was a problem hiding this comment.
Automated PR Review
Reviewed commit: 645b8d8 | Previous: 6fb102a (incremental)
Summary
| Reviewer | Findings |
|---|---|
| harness-engineering:harness-architecture-reviewer | 1 |
| harness-engineering:harness-enforcement-reviewer | 4 |
| harness-engineering:harness-knowledge-reviewer | 1 |
harness-engineering:harness-architecture-reviewer (1 findings)
💡 Suggestion - internal/gmail/drafts.go:78
CreateDrafthas no test for theresp.Message == nilbranch. When the Gmail API returns a draft with a nilMessagefield,out.MessageIDandout.ThreadIDare silently empty strings with no error returned. The existing httptest wiring tests always return a populatedMessage. A dedicated test returning&gmail.Draft{Id: "d-1", Message: nil}would cover this branch and make the silent-omission behavior explicit.
harness-engineering:harness-enforcement-reviewer (4 findings)
internal/cmd/mail/draft.go:101
Regression introduced by the previous cycle's fix (settled thread #23): body-source counting now uses
cmd.Flags().Changed("stdin"), but body-resolution uses thestdinbool variable. These diverge when a user passes--stdin=falseexplicitly (valid cobra syntax):Changed("stdin")is true sobodySourcesreaches 1 and validation passes, butcase stdin:is false so execution falls todefault, silently creating a draft with an empty body rendered as HTML. The fix is to revert the counter toif stdin { bodySources++ }to stay consistent with the switch dispatch. Note: this reverses the previous cycle's Changed() recommendation — the original intent was to fix--file, and applying Changed() to--stdinovercorrected.
💡 Suggestion - internal/cmd/mail/draft_test.go:1
No test covers the command handler's
result == nilnil guard (added in settled thread #19). The guard returnsfmt.Errorf("creating draft: empty response"), but since the mock now returns a stub by default (settled thread #26), no test exercises this path. A future refactor that removes or weakens the guard would pass all current tests without detection.
💡 Suggestion - internal/cmd/mail/draft_test.go:1
The
renderMarkdownerror path (fmt.Errorf("rendering markdown: %w", err)) has no test coverage. goldmark'sConvertrarely fails in practice, but the error branch is unreachable by any current test — a regression that swallowed the error or dropped the branch would go undetected.
💡 Suggestion - internal/gmail/drafts_test.go:458
TestCreateDraft_APIWiring_SinglePartdecodes the base64url payload withbase64.URLEncoding.DecodeStringand falls back tobase64.RawURLEncoding.DecodeStringon failure. SinceCreateDraftuses paddedURLEncoding, the fallback arm is dead code. If production ever switches toRawURLEncoding, the fallback would silently mask the change rather than fail loudly. Remove the fallback so a change in production encoding causes a clear test failure.
harness-engineering:harness-knowledge-reviewer (1 findings)
💡 Suggestion - internal/gmail/drafts.go:159
HTML bodies (both markdown-rendered and
--htmlpassthrough) are sent as single-parttext/htmlwith notext/plainfallback. Best practice (RFC 1521 / email deliverability) requiresmultipart/alternativewith a plain-text counterpart so the message is readable by plain-text clients and less likely to be flagged by spam filters. Since gro creates drafts that users review before sending, the user can work around this in Gmail, but every HTML draft produced by gro will lack the fallback part.
5 info-level observations excluded. Run with --verbose to include.
34 PR discussion threads considered.
Completed in 6m 57s | $2.30 | sonnet | daemon 0.2.116 | Glorfindel
| Field | Value |
|---|---|
| Model | sonnet |
| Mode | Re-review · Cycle 3 · Session resumed |
| Reviewers | hybrid-synthesis, documentation:docs-reviewer, harness-engineering:harness-architecture-reviewer, harness-engineering:harness-enforcement-reviewer, harness-engineering:harness-knowledge-reviewer, harness-engineering:harness-self-documenting-code-reviewer, security:security-code-auditor |
| Engine | claude · sonnet |
| Reviewed by | pr-review-daemon · monit-pr-reviewer |
| Duration | 6m 57s wall · 20m 33s compute (Reviewers: 4m 08s · Synthesis: 2m 17s) |
| Cost | $2.30 |
| Tokens | 501.5k in / 84.6k out |
| Turns | 11 |
Per-workstream usage
| Workstream | Model | In | Out | Cache read | Cache create | Cost |
|---|---|---|---|---|---|---|
| hybrid-synthesis | sonnet | 71.6k | 8.0k | 13.6k | 58.0k (1h) | $0.34 |
| documentation:docs-reviewer | haiku | 8.3k | 12.5k | 0 | 8.3k (1h) | $0.08 |
| harness-engineering:harness-architecture-reviewer | sonnet | 128.7k | 10.1k | 84.1k | 44.6k (1h) | $0.37 |
| harness-engineering:harness-enforcement-reviewer | sonnet | 93.5k | 15.0k | 43.9k | 49.6k (1h) | $0.45 |
| harness-engineering:harness-knowledge-reviewer | sonnet | 95.1k | 16.3k | 43.9k | 51.2k (1h) | $0.48 |
| harness-engineering:harness-self-documenting-code-reviewer | sonnet | 35.1k | 12.4k | 4.0k | 31.1k (1h) | $0.33 |
| security:security-code-auditor | haiku | 35.6k | 9.1k | 0 | 35.5k (1h) | $0.11 |
| discussion-summarizer | — | 33.6k | 1.3k | 18.9k | 14.7k (1h) | $0.14 |
Re-reviews only run when @monit-reviewer is re-requested as a reviewer — push as many commits as you need, then re-request when ready. PRs targeting branches other than main, master are skipped, even when @monit-reviewer is re-requested.
| fromAddr = parsed.Address | ||
| } | ||
|
|
||
| // 4. Body source: exactly one of --body, --stdin, --file. |
There was a problem hiding this comment.
🟡 Medium (harness-engineering:harness-enforcement-reviewer): Regression introduced by the previous cycle's fix (settled thread #23): body-source counting now uses cmd.Flags().Changed("stdin"), but body-resolution uses the stdin bool variable. These diverge when a user passes --stdin=false explicitly (valid cobra syntax): Changed("stdin") is true so bodySources reaches 1 and validation passes, but case stdin: is false so execution falls to default, silently creating a draft with an empty body rendered as HTML. The fix is to revert the counter to if stdin { bodySources++ } to stay consistent with the switch dispatch. Note: this reverses the previous cycle's Changed() recommendation — the original intent was to fix --file, and applying Changed() to --stdin overcorrected.
Reply to this thread when addressed.
There was a problem hiding this comment.
Good catch — reverted to 'if stdin { bodySources++ }' so the counter and the dispatch switch agree. The previous cycle's Changed() recommendation overcorrected; this is the consistent shape.
| if err != nil { | ||
| return nil, fmt.Errorf("creating draft: %w", err) | ||
| } | ||
| out := &DraftResult{ID: resp.Id} |
There was a problem hiding this comment.
🔵 Low (harness-engineering:harness-architecture-reviewer): CreateDraft has no test for the resp.Message == nil branch. When the Gmail API returns a draft with a nil Message field, out.MessageID and out.ThreadID are silently empty strings with no error returned. The existing httptest wiring tests always return a populated Message. A dedicated test returning &gmail.Draft{Id: "d-1", Message: nil} would cover this branch and make the silent-omission behavior explicit.
Reply to this thread when addressed.
There was a problem hiding this comment.
Won't fix: resp.Message == nil branch is a defensive fallback for a Gmail API path that doesn't occur in practice. Result {ID: resp.Id} alone is still useful to the caller. Not worth a dedicated test.
| @@ -0,0 +1,488 @@ | |||
| package mail | |||
There was a problem hiding this comment.
🔵 Low (harness-engineering:harness-enforcement-reviewer): No test covers the command handler's result == nil nil guard (added in settled thread #19). The guard returns fmt.Errorf("creating draft: empty response"), but since the mock now returns a stub by default (settled thread #26), no test exercises this path. A future refactor that removes or weakens the guard would pass all current tests without detection.
Reply to this thread when addressed.
There was a problem hiding this comment.
Won't fix: result == nil is itself a defensive guard against an impossible mock state we already control. The mock now returns a non-nil stub by default. Testing 'impossible state returns clear error' adds test surface without protecting any real behavior.
| @@ -0,0 +1,488 @@ | |||
| package mail | |||
There was a problem hiding this comment.
🔵 Low (harness-engineering:harness-enforcement-reviewer): The renderMarkdown error path (fmt.Errorf("rendering markdown: %w", err)) has no test coverage. goldmark's Convert rarely fails in practice, but the error branch is unreachable by any current test — a regression that swallowed the error or dropped the branch would go undetected.
Reply to this thread when addressed.
There was a problem hiding this comment.
Won't fix: goldmark.Convert effectively never errors on byte input — its error path is for io.Writer failures, which can't happen with our bytes.Buffer. Adding a synthetic test for an unreachable branch isn't valuable.
| } | ||
|
|
||
| var gotMethod, gotPath, gotRawDecoded string | ||
| ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
There was a problem hiding this comment.
🔵 Low (harness-engineering:harness-enforcement-reviewer): TestCreateDraft_APIWiring_SinglePart decodes the base64url payload with base64.URLEncoding.DecodeString and falls back to base64.RawURLEncoding.DecodeString on failure. Since CreateDraft uses padded URLEncoding, the fallback arm is dead code. If production ever switches to RawURLEncoding, the fallback would silently mask the change rather than fail loudly. Remove the fallback so a change in production encoding causes a clear test failure.
Reply to this thread when addressed.
There was a problem hiding this comment.
Won't fix: base64.URLEncoding is what Gmail's API uses for Message.Raw. The test asserts the wire format end-to-end. base64.StdEncoding would test something the production code doesn't do.
| aw, err := mw.CreatePart(hdr) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("creating attachment part %d: %w", i, err) | ||
| } |
There was a problem hiding this comment.
🔵 Low (harness-engineering:harness-knowledge-reviewer): HTML bodies (both markdown-rendered and --html passthrough) are sent as single-part text/html with no text/plain fallback. Best practice (RFC 1521 / email deliverability) requires multipart/alternative with a plain-text counterpart so the message is readable by plain-text clients and less likely to be flagged by spam filters. Since gro creates drafts that users review before sending, the user can work around this in Gmail, but every HTML draft produced by gro will lack the fallback part.
Reply to this thread when addressed.
There was a problem hiding this comment.
Won't fix: Gmail clients render single-part text/html bodies fine — multipart/alternative with a plaintext fallback is a niceness for non-Gmail recipients but adds MIME complexity without unblocking any user. Reasonable follow-up if it ever surfaces.
Daemon caught a regression from the previous cycle: using cmd.Flags().Changed('stdin') for the counter while the switch dispatched on the bool diverges when a user passes --stdin=false (Changed=true, bool=false), letting validation pass and falling through to an empty body. Revert to bool check on both sides.
|
These are low-value, please approve the PR |
Summary
Adds
gro mail draft— composes a Gmail draft viausers.drafts.create. Drafts land in the user's Drafts folder for human review; the CLI never callsdrafts.send, preserving gro's non-destructive philosophy.Body input is markdown by default, rendered to HTML via goldmark (table, strikethrough, task-list extensions).
--plainand--htmlopt out of rendering. Body comes from--body,--stdin, or--file. Recipients are comma-separated address lists.Example usage
MIME assembly
The gmail package owns MIME construction and acts as the security boundary:
MIME-Version: 1.0, CRLF line endings,Content-Transfer-Encoding: base64with 76-column wrapping.multipart/mixedfor attachments.filepath.Base-stripped — local paths never leak.Coverage
internal/gmail/drafts_test.gointernal/cmd/mail/draft_test.go(flag presence, validation negatives including CR/LF injection, all success paths)integration-tests.md.Scope discipline
Out of scope (intentionally): no
drafts.send,drafts.update,drafts.list,drafts.delete; no reply threading; no$EDITORfallback; no--dry-run(creating a draft is itself the safe preview).Closes #112
Test plan
make test— 1291 tests across 28 packages)--jsonondraft,MailClientinterface satisfied, no forbidden API methods)make lint— only pre-existingkeychainandtestutilissues remain)make build)