feat(mail): quote source message in reply drafts - #126
Conversation
Reply drafts (--reply-to) now quote the source message below the authored body, matching Gmail's web UI default, instead of producing a blank body. The source is fetched with the full body only when quoting; --no-quote keeps the cheaper metadata-only fetch and reproduces the prior blank draft. - Attribution is a fixed en-US "On <date> <from> wrote:" line with the date rendered in the source message's own timezone (never UTC/local); deliberately not localized. Falls back to the raw Date on parse failure. - Plain replies use "> " line prefixes (empty lines become ">"); HTML and markdown replies wrap the quote in Gmail's gmail_quote div/blockquote markup so the Gmail UI collapses it natively. Attribution and source are HTML-escaped to prevent markup injection via display name or address. - Reply mode now allows zero body sources: a bare --reply-to yields a quote-only draft; the leading separator is omitted when there is no authored body. Non-reply mode still requires exactly one body source. - A source with only an HTML part (no plain-text alternative) is shown as escaped tags, by design, to avoid an HTML-to-text dependency. Adds the "Gmail browser parity" design principle to CLAUDE.md, the rule this behavior embodies. Closes #125
|
Major
Minor
Confirmed
|
quotePlain strips a terminal CR per line before classifying, so a CRLF blank line quotes as ">" (not "> \r") and content lines carry no stray CR; output is normalized to LF. Real email text/plain parts are CRLF. Adds a CRLF-source test and a default-mode (markdown/HTML) bare-reply test asserting no leading newline and a leading gmail_quote block.
TDD coverage assessmentOverall the test suite is thorough and well-structured. All major happy-paths and guard-clauses have tests with real assertions. A few gaps are worth noting. MajorNone. MinorM1 — HTML body + quote: ordering and separator not asserted M2 — CRLF in HTML source body is not tested M3 — Raw NitN1 — Doc comment / code mismatch in N2 — STATUS: tdd_majors=0 tdd_minors=3 |
quoteHTML also leaked CRLF (a "\r<br>" before each break) — extract a shared normalizeLF used by both quotePlain and quoteHTML so neither emits stray CRs. Real text/html and text/plain parts decode as CRLF. Tests: add HTML-CRLF coverage, a raw --html reply-quoting case, and an ordering assertion (quote follows the authored body, preceded by the "\n" separator). Fix the replyAttribution doc comment to match the comma-free format the code actually emits.
TDD assessment — resolutionAll three minors + the nit addressed in
Thanks — M2 caught a genuine correctness bug for real CRLF email bodies. |
An HTML-only source message (no text/plain alternative — common for alert/marketing/SaaS senders) was HTML-escaped into a wall of visible tags in the quote. Gmail itself nests the original message's HTML inside the gmail_quote blockquote on reply; mirror that. - gmail: extractBodyWithKind reports whether Body came from text/html; Message gains BodyIsHTML. extractBody kept as a thin wrapper so the existing callers/tests are untouched. - mail: quoteHTML nests an HTML source verbatim and only escapes a plain-text source. The attribution line is always escaped regardless (it carries the raw From header / display name). Reverses the earlier "escape everything / documented limitation" decision: the limitation hit a common real-world sender and produced exactly the technically-valid-but-foreign output the Gmail-parity principle rejects.
|
Major
Minor
Confirmed
|
Nesting raw source HTML risked container breakout: a source body with "</blockquote></div>" could escape the gmail_quote wrapper before Gmail renders it (Gmail sanitizes active content but does not re-balance our quote containment). Parse the source with x/net/html and re-serialize the <body> children, which (1) strips the DOCTYPE/<html>/<head> so we no longer nest a full document inside a blockquote, and (2) makes breakout structurally impossible — stray closers are dropped by the parser since we emit a re-parsed tree, not raw bytes. Parse failure degrades safely to escaped (plain-source) treatment. Attribution stays escaped. x/net was already in the module graph (now direct). Also: BodyIsHTML is json:"-" (internal routing bit, not a public --json surface), and a parseMessage(includeBody=true) test proves an HTML-only payload sets both Body and BodyIsHTML. Breakout regression added.
|
Findings Assessment
|
Summary
gro mail draft --reply-toproduced a blank body — it fetched the source with metadata only, so the prior message was never quoted (it behaved as if you hit Reply in Gmail and deleted everything). This makes reply drafts quote the source like Gmail's web UI, by default.--no-quoteopts out (fully blank, byte-identical to today; keeps the cheaper metadata-only fetch).On Mon, Jan 2, 2006 at 3:04 PM <From> wrote:, date in the source message's own timezone (never UTC/local). Deliberately not localized — Gmail's collapse is driven bygmail_quotemarkup, not this text, and the CLI has no reliable recipient locale. Raw-Date fallback on parse failure.>line prefixes (empty line →>). HTML/markdown replies: wrapped in Gmail'sgmail_quotediv/blockquoteso the Gmail UI collapses it behind its “…” affordance. Attribution and source are HTML-escaped (no markup injection via display name /<addr>).gro mail draft --reply-to <id>with no body source = quote-only draft; leading separator omitted when there's no authored text. Non-reply mode still requires exactly one body source.text/plain, so this is rare.CLAUDE.md— the rule this behavior embodies (folded into the PR that first honors it, by design).Plan + 3-round Codex architect review converged at blockers=0/majors=0 on #125.
Test plan
go vet,go build ./...,golangci-lint(0 issues),gofmtcleaninternal/cmd/mail/draft_quote_test.go): plain exact-bytes, HTMLgmail_quotemarkup + escaping, markup-injection escape, bare quote-only,--no-quotebody+fetch-mode spy, default-fetches-body spy,--no-quotebare blank,--no-quoterequires--reply-to, empty-source-body skip, TZ-crossing attribution (offset where UTC would change weekday/day/hour), date-parse-failure fallbackTestSafeOutputPath*(untouched attachment path code; POSIX-abs paths aren't absolute on Windows) — unrelated; CI is Linux.Closes #125