Skip to content

fix(gmail): never silently drop a requested quote - #996

Open
malob wants to merge 1 commit into
openclaw:mainfrom
malob:fix/gmail-quote-fail-open
Open

fix(gmail): never silently drop a requested quote#996
malob wants to merge 1 commit into
openclaw:mainfrom
malob:fix/gmail-quote-fail-open

Conversation

@malob

@malob malob commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

What

gmail send --thread-id X --quote (and gmail drafts create --thread-id X --quote) could send or save the message without the requested quoted original. The thread-ID reply path refetches the selected message in full format to get the quote body, but ignored the fetch error and fell back to the metadata-only message — which has no body parts — so the quote silently became a no-op and the mail went out anyway:

fullMsg, fullErr := fetchMessageForReplyInfo(ctx, svc, msg.Id, true)
if fullErr == nil && fullMsg != nil {
    msg = fullMsg
}

This PR makes the quote pipeline fail closed end to end: a requested quote is either applied, or the command errors, or it says on stderr why it couldn't quote. Three layers:

  • Propagate quote-source fetch errors on the thread path (fetch message <id> for quoting: <cause>), aborting before any send/draft POST.
  • Treat an empty API response as an error on the reply-target and forward fetch paths. The generated client returns (nil, nil) for a 200 with a literal null body (decode goes through **Message, so JSON null nils the pointer) — real Gmail won't produce this, but a broken proxy can, and previously it panicked (msg.Id / origMsg.Payload nil dereference) when composing with the original's body.
  • Warn on stderr when the fetched original yields no extractable text (e.g. an attachment-only message) instead of silently composing without the quote.

Why

The direct --reply-to-message-id path already failed closed (a fetch error aborts the compose); the thread path was the outlier, and the failure mode is the worst kind — the mail is already sent when you notice the quote is missing. The warning layer covers the remaining case where the fetch succeeds but there is genuinely nothing to quote: hard-failing there would break replying to attachment-only messages, so the compose proceeds but is no longer silent about it.

User-facing changes

  • New stderr warning: Warning: could not extract quotable text from the original message; composing without quote. Because gmail reply / gmail drafts reply quote by default (--no-quote to opt out), the warning can fire there too, not only under an explicit --quote. JSON/plain stdout is unchanged — the warning goes to stderr only (proven below).
  • New error messages: fetch message <id> for quoting: <cause> and … empty response (reply paths), fetch original message <id>: empty response (forward paths). Previously these situations sent without the quote or panicked.
  • No new flags or commands; no output-format changes.

Changes

  • internal/cmd/gmail_reply.go — thread path: propagate the full-fetch error and reject a nil message; message-ID path: reject a nil message (previously panicked when quoting). New replyInfo.hasQuotableText() used by both the quoting no-op branch and the new warning, so the two conditions cannot drift apart.
  • internal/cmd/gmail_compose.goapplyReplyQuote warns on stderr when a requested quote has no quotable text (single chokepoint shared by send, drafts create/update, and reply/reply-all).
  • internal/cmd/gmail_forward.gobuildForwardComposeMessage errors on a nil fetched message instead of panicking (covers gmail forward and gmail drafts forward).
  • internal/cmd/gmail_quote_fetch_fail_test.go (new) — seven tests, detailed under Testing.

Behavior proof

Live against a real account, redacted: the address appears as <me>, message/thread ids as <src> / <reply> / <att-src> / <warn-reply>. Timestamps are literal.

1. Happy path unchanged — a quoted thread reply still carries the quote:

$ ./bin/gog gmail send --to <me> --subject "gog quote proof — source" --body "Source message body for the quote proof." --account <me> --json
{
  "from": "<me>",
  "messageId": "<src>",
  "threadId": "<src>"
}
$ ./bin/gog gmail send --thread-id <src> --quote --to <me> --body "Reply body (happy-path quote proof)." --account <me> --json
{
  "from": "<me>",
  "messageId": "<reply>",
  "threadId": "<src>"
}
$ ./bin/gog gmail get <reply> --account <me> | grep -A3 'wrote:'
On Fri, Aug 14, 2026 at 4:01 PM, <me> wrote:
> Source message body for the quote proof.
>

2. The new warning — quoting an original with no text parts. The original is an attachment-only MIME message (single application/pdf part, no text/* parts), imported verbatim:

$ ./bin/gog gmail import attachment-only.eml --account <me> --json
{
  "internalDate": 0,
  "labelIds": null,
  "messageId": "<att-src>",
  "threadId": ""
}
$ ./bin/gog gmail send --thread-id <att-src> --quote --to <me> --body "Reply to attachment-only original (warning proof)." --account <me> --json 2>warn.txt
{
  "from": "<me>",
  "messageId": "<warn-reply>",
  "threadId": "<att-src>"
}
$ cat warn.txt
Warning: could not extract quotable text from the original message; composing without quote
$ ./bin/gog gmail get <warn-reply> --account <me> | tail -5
subject	Re: gog quote proof - attachment-only source
date	Fri, 14 Aug 2026 18:02:15 -0500

Reply to attachment-only original (warning proof).

The reply threads correctly and contains no quote block — and now says so, on stderr only (stdout above is the clean JSON).

3. The fail-closed core — not live-triggerable, proven by mutation. This path needs Gmail's API to fail mid-compose (a 5xx or a null response on the second fetch), which a real account can't produce on demand. The regression tests pin it: the mock serves the thread fine, 500s the full-format quote fetch, and fails the test on any POST. Restoring the pre-fix files reproduces the fail-open literally — the mock server receives the send POST:

$ go test ./internal/cmd/ -run 'ThreadIDQuote|Quote_NullResponse|BuildForwardComposeMessage_Null'
ok      github.com/openclaw/gogcli/internal/cmd 0.441s
$ git checkout upstream/main -- internal/cmd/gmail_reply.go internal/cmd/gmail_compose.go internal/cmd/gmail_forward.go
$ go test ./internal/cmd/ -run 'ThreadIDQuote|Quote_NullResponse|BuildForwardComposeMessage_Null' 2>&1 \
    | grep -E '^--- FAIL|unexpected POST|panic: runtime|expected '
--- FAIL: TestFetchReplyInfo_ThreadIDQuote_FullFetchFailurePropagates (0.00s)
    gmail_quote_fetch_fail_test.go:60: expected error when full-format fetch fails
--- FAIL: TestGmailSendCmd_ThreadIDQuote_FullFetchFailureAbortsSend (0.00s)
    gmail_quote_fetch_fail_test.go:36: unexpected POST /gmail/v1/users/me/messages/send after quote fetch failure
    gmail_quote_fetch_fail_test.go:82: unexpected error: googleapi: got HTTP response code 500 with body: unexpected compose
--- FAIL: TestFetchReplyInfo_Quote_NullResponseFailsClosed (0.00s)
panic: runtime error: invalid memory address or nil pointer dereference [recovered, repanicked]

(The panic is the pre-fix nil dereference the empty-response guard removes; the run aborts there before reaching the drafts-create test, which fails the same way when run alone.)

Testing

  • Seven new tests in gmail_quote_fetch_fail_test.go, all passing with -race:
    • TestFetchReplyInfo_ThreadIDQuote_FullFetchFailurePropagates — fetch error propagates, wrapped googleapi.Error (500) preserved through %w (checked via errors.As).
    • TestGmailSendCmd_ThreadIDQuote_FullFetchFailureAbortsSend / TestGmailDraftsCreateCmd_ThreadIDQuote_FullFetchFailureAbortsCreate — command-level: the run errors and the mock receives zero POSTs (any POST fails the test).
    • TestFetchReplyInfo_Quote_NullResponseFailsClosed200 nullempty response error on both the message-ID and thread-ID paths (previously: silent metadata fallback / panic).
    • TestBuildForwardComposeMessage_NullResponseFailsClosed — same for the forward fetch (previously: panic).
    • TestGmailSendCmd_ThreadIDQuote_NoQuotableTextWarns — attachment-only original: warning on stderr, absent from stdout, message sent without a quote block.
    • TestGmailSendCmd_ThreadIDQuote_QuotesWithoutWarning — quotable original: quote present in the sent raw, no warning.
  • Every guard is mutation-verified (reverting each change fails its tests, per the proof above).
  • make fmt-check lint deadcode docs-check agent-skills-check clean. docs/spec.md needs no sync (no flag/command changes).

🤖 Generated with Claude Code

The thread-ID reply path fetched the quote source in full format but
ignored fetch errors, so 'gmail send --thread-id X --quote' and
'gmail drafts create --thread-id X --quote' sent or saved the message
without the requested quoted original. Fail closed instead:

- propagate quote-source fetch errors on the thread path
- treat an empty API response as an error on the reply-target and
  forward fetch paths (previously a nil message panicked these paths
  when composing with the original's body)
- warn on stderr when the original yields no extractable quotable
  text, instead of silently composing without the quote; this also
  covers the default-on quoting of 'gmail reply'

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@clawsweeper

clawsweeper Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

🦞👀
ClawSweeper picked this up.

Pull request received. I will update this pull request when review starts.

@clawsweeper clawsweeper Bot added merge-risk: 🚨 compatibility 🚨 Merging this PR could break existing users, config, migrations, defaults, or upgrades. merge-risk: 🚨 message-delivery 🚨 Merging this PR could drop, duplicate, misroute, suppress, or wrongly target messages. P2 Normal priority bug or improvement with limited blast radius. proof: sufficient Contributor real behavior proof is sufficient. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. labels Aug 14, 2026
@clawsweeper

clawsweeper Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs maintainer review before merge. Reviewed August 15, 2026, 6:56 AM ET / 10:56 UTC.

ClawSweeper review

What this changes

The PR makes Gmail quote-enabled replies and forwards error instead of silently omitting a requested quote or dereferencing an empty API response, while warning on stderr for attachment-only originals.

Merge readiness

⚠️ Ready for maintainer review - 2 items remain

This PR remains necessary: current main still silently continues after a failed full-message quote fetch. The patch is focused, proof-backed, and ready for maintainer merge review.

Priority: P2
Reviewed head: 2d4e2655de38480ac7c1b42ff2813baad7f9814f

Review scores

Measure Result What it means
Overall readiness 🦞 diamond lobster (5/6) The narrow patch has strong terminal proof, focused regression coverage, and no identified correctness or security defect.
Proof confidence 🦞 diamond lobster (5/6) Sufficient (terminal): The PR body includes redacted real-account terminal output showing retained quoting and the stderr-only attachment-only warning, supplemented by targeted failure-path tests.
Patch quality 🦞 diamond lobster (5/6) No actionable review findings were identified.

Verification

Check Result Evidence
Real behavior Verified Sufficient (terminal): The PR body includes redacted real-account terminal output showing retained quoting and the stderr-only attachment-only warning, supplemented by targeted failure-path tests.
Evidence reviewed 6 items Current main behavior: Current main ignores a failed full-message fetch on the thread quote path and continues with metadata-only content, leaving the reported silent omission unfixed.
Patch behavior: The branch propagates quote-source fetch errors and rejects an empty full-message response before composing.
Output contract: The new no-text diagnostic is written through the UI error stream, preserving parseable stdout.
Findings None None.
Security None None.

How this fits together

Gog’s Gmail commands fetch a reply target, derive threading and quote content, then compose and send or save a message. This change sits between Gmail API reads and the compose/send paths, where it determines whether a requested quote is included or a failure is reported.

flowchart LR
A[User Gmail command] --> B[Reply target lookup]
B --> C{Quote requested?}
C -->|yes| D[Fetch full original]
D --> E[Build quoted message]
E --> F[Send or save draft]
C -->|no| F
D -->|fetch failure| G[Return error]
E -->|no text found| H[Warn on stderr]
H --> F
Loading

Before merge

  • Resolve merge risk (P2) - Quote-enabled send and draft creation will now abort when the full original cannot be fetched, replacing the prior silent unquoted fallback with an explicit error.
  • Complete next step (P2) - No mechanical follow-up is needed; this focused patch has no review finding and is ready for normal maintainer merge review.
Agent review details

Security

None.

Review metrics

Metric Value Why it matters
Focused regression coverage 7 tests added; 4 files affected The tests cover send, draft-create, empty-response, warning, and retained-quote paths around the changed compose boundary.
Production versus test growth production +28, tests +258 Most added code is focused regression coverage; the production change is directly tied to error propagation and diagnostics.

Merge-risk options

Maintainer options:

  1. Accept fail-closed quote delivery (recommended)
    Land the change so a requested quote never results in a send or saved draft without either the quote, an explicit error, or an stderr warning for genuinely textless originals.

Technical review

Best possible solution:

Adopt the fail-closed source-fetch behavior and stderr-only no-text warning, retaining the focused regression tests for send and draft creation.

Do we have a high-confidence way to reproduce the issue?

Yes. The focused HTTP handler provides a high-confidence path: metadata thread lookup succeeds, the required full-message quote lookup fails, and the command must not POST a send or draft.

Is this the best way to solve the issue?

Yes. Propagating the required quote-source failure at the shared reply-information boundary is narrower and safer than allowing a quote-enabled command to continue unquoted.

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning high; reviewed against 45b5d766e137.

Labels

Label justifications:

  • P2: The PR fixes a bounded Gmail composition defect that can send or save a message missing requested content.
  • merge-risk: 🚨 compatibility: Existing quote-enabled workflows change from silently proceeding to returning an error when the required source fetch fails.
  • merge-risk: 🚨 message-delivery: The patch deliberately prevents send and draft POSTs after a quote-source fetch failure.
  • rating: 🦞 diamond lobster: Overall readiness is 🦞 diamond lobster; proof is 🦞 diamond lobster and patch quality is 🦞 diamond lobster.
  • status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (terminal): The PR body includes redacted real-account terminal output showing retained quoting and the stderr-only attachment-only warning, supplemented by targeted failure-path tests.
  • proof: sufficient: Contributor real behavior proof is sufficient. The PR body includes redacted real-account terminal output showing retained quoting and the stderr-only attachment-only warning, supplemented by targeted failure-path tests.

Evidence

What I checked:

Likely related people:

  • Peter Steinberger: Current-main blame and available history attribute the Gmail reply and forward composition files to the v0.37.0 release commit. (role: recent area contributor; confidence: medium; commits: 45b5d766e137; files: internal/cmd/gmail_reply.go, internal/cmd/gmail_compose.go, internal/cmd/gmail_forward.go)
  • Malo Bourgon: The v0.36.0 changelog credits the shared Gmail draft reply, reply-all, and forward composition work to Malo Bourgon. (role: prior Gmail composition contributor; confidence: medium; files: internal/cmd/gmail_reply.go, internal/cmd/gmail_compose.go, internal/cmd/gmail_forward.go)

Rating scale

Score Internal tier Crab rank Meaning
6/6 S 🦀 challenger crab Exceptional readiness
5/6 A 🦞 diamond lobster Very strong readiness
4/6 B 🐚 platinum hermit Good normal PR; ordinary maintainer review
3/6 C 🦐 gold shrimp Useful, but confidence is limited
2/6 D 🦪 silver shellfish Proof or implementation needs work
1/6 F 🧂 unranked krab Not merge-ready
N/A NA 🌊 off-meta tidepool Rating does not apply

Overall follows the weaker of proof and patch quality.
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics.

Workflow

  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.

History

Review history (1 earlier review cycle)
  • reviewed 2026-08-14T23:19:04.798Z sha 2d4e265 :: needs maintainer review before merge. :: none

@clawsweeper clawsweeper Bot added rating: 🦞 diamond lobster Very strong PR readiness with only minor maintainer review expected. and removed rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. labels Aug 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge-risk: 🚨 compatibility 🚨 Merging this PR could break existing users, config, migrations, defaults, or upgrades. merge-risk: 🚨 message-delivery 🚨 Merging this PR could drop, duplicate, misroute, suppress, or wrongly target messages. P2 Normal priority bug or improvement with limited blast radius. proof: sufficient Contributor real behavior proof is sufficient. rating: 🦞 diamond lobster Very strong PR readiness with only minor maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant