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 21, 2026, 6:59 PM ET / 22:59 UTC.

ClawSweeper review

What this changes

The PR makes quote-enabled Gmail replies and forwards fail or warn instead of silently omitting the requested quote or panicking on an empty API response.

Merge readiness

⚠️ Ready for maintainer review - 2 items remain

This PR remains necessary: current main still ignores a full quote-source fetch failure and can then send or save without the requested quote. The patch is focused, has no actionable correctness finding, and supplies strong redacted real-behavior proof plus targeted no-POST regression coverage.

Priority: P2
Reviewed head: 2d4e2655de38480ac7c1b42ff2813baad7f9814f

Review scores

Measure Result What it means
Overall readiness 🦞 diamond lobster (5/6) Strong live proof and tightly scoped regression coverage support a correct, focused reliability repair.
Proof confidence 🦞 diamond lobster (5/6) Sufficient (live_output): The PR body contains redacted after-fix Gmail CLI output showing a retained quote and a stderr-only textless-original warning, with focused injected-failure regression evidence for the no-send guarantee.
Patch quality 🦞 diamond lobster (5/6) No actionable review findings were identified.

Verification

Check Result Evidence
Real behavior Verified Sufficient (live_output): The PR body contains redacted after-fix Gmail CLI output showing a retained quote and a stderr-only textless-original warning, with focused injected-failure regression evidence for the no-send guarantee.
Evidence reviewed 4 items Current-main defect: Current main still ignores an error or nil result when refetching the full quote source for a thread, leaving the metadata-only message to compose without quote content.
Affected send and draft path: The shared compose helper is used by draft composition, so propagating its quote-fetch error prevents creation as well as send.
Focused coverage: The added tests assert failed full-format fetches abort send and draft creation, preserve wrapped API errors, reject null responses, and keep the warning off stdout.
Findings None None.
Security None None.

Live Verification

Command: go run ./cmd/gog gmail send --help

Result: FAIL (failed) — execution before step 1 run: sh -lc pnpm install --ignore-scripts --frozen-lockfile failed: ! Corepack is about to download https://registry.npmjs.org/pnpm/-/pnpm-11.22.0.tgz

sh -lc pnpm install --ignore-scripts --frozen-lockfile failed: ! Corepack is about to download https://registry.npmjs.org/pnpm/-/pnpm-11.22.0.tgz

Assertions:

  • FAIL expect_output: --quote

How this fits together

Gmail compose commands select an original message or thread, extract reply context and quote text, then send or save the resulting message through the Gmail API. The quote path must preserve parseable stdout while reporting non-fatal quote omissions on stderr.

flowchart LR
  A[CLI reply or send request] --> B[Select message or thread]
  B --> C[Fetch original message]
  C --> D{Quote text available?}
  D -->|yes| E[Compose quoted message]
  D -->|fetch failure| F[Return error before send]
  D -->|no text| G[Warn on stderr]
  E --> H[Send or save via Gmail API]
  G --> H
Loading

Before merge

  • Resolve merge risk (P1) - Existing users encountering a transient quote-source failure will now receive an error instead of an unquoted sent or saved message; that is the intended compatibility tradeoff and should remain visible to the merger.
  • Complete next step (P2) - No repair lane is needed because the submitted patch has no concrete review finding; it is ready for normal maintainer merge review.
Agent review details

Security

None.

Review metrics

Metric Value Why it matters
Changed surface 4 files affected; production +28/-7, tests +258 The production change is narrow and the bulk of the patch is focused regression coverage for send, draft, null-response, and warning behavior.

Merge-risk options

Maintainer options:

  1. Accept explicit quote-fetch errors (recommended)
    Merge the fail-closed behavior so an explicitly requested quote cannot be silently omitted from a sent or saved message.

Technical review

Best possible solution:

Land the narrow fail-closed quote-fetch handling with its regression coverage, preserving stderr-only warning behavior when an original genuinely has no extractable text.

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

Yes, at source level: current main ignores the full quote-fetch error in the thread path, while the added local HTTP-handler tests provide a deterministic failing-fetch/no-POST path. The PR also includes redacted live Gmail output for the successful quote and textless-original warning paths.

Is this the best way to solve the issue?

Yes: propagating the error before composition is the narrowest way to honor an explicitly requested quote, while a stderr warning preserves normal reply behavior for attachment-only originals.

AGENTS.md: found and applied where relevant.

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

Labels

Label justifications:

  • P2: The defect can send or save a message without an explicitly requested quote, but has bounded Gmail compose impact.
  • merge-risk: 🚨 compatibility: The patch deliberately replaces an existing fallback send/save outcome with a command error when quote retrieval fails.
  • merge-risk: 🚨 message-delivery: The changed Gmail compose path controls whether a requested quoted message is dispatched or halted before dispatch.
  • 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 (live_output): The PR body contains redacted after-fix Gmail CLI output showing a retained quote and a stderr-only textless-original warning, with focused injected-failure regression evidence for the no-send guarantee.
  • proof: sufficient: Contributor real behavior proof is sufficient. The PR body contains redacted after-fix Gmail CLI output showing a retained quote and a stderr-only textless-original warning, with focused injected-failure regression evidence for the no-send guarantee.

Evidence

What I checked:

Likely related people:

  • Peter Steinberger: The release commit that current main inherits is the recorded blame origin for the ignored full-fetch error and adjacent reply/compose/forward code. (role: introduced current reply and compose behavior; confidence: medium; commits: 45b5d766e137; 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 (2 earlier review cycles)
  • reviewed 2026-08-14T23:19:04.798Z sha 2d4e265 :: needs maintainer review before merge. :: none
  • reviewed 2026-08-15T10:56:27.995Z 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