Skip to content

fix(gmail): stop sentinel strings from clobbering drafts - #206

Open
shoemoney wants to merge 1 commit into
mozilla-ai:mainfrom
shoemoney:fix/gmail-extract-body-sentinels
Open

fix(gmail): stop sentinel strings from clobbering drafts#206
shoemoney wants to merge 1 commit into
mozilla-ai:mainfrom
shoemoney:fix/gmail-extract-body-sentinels

Conversation

@shoemoney

Copy link
Copy Markdown
Contributor

Fixes #203.

The bug

_extract_body in src/apron_tools/providers/google/gmail/tools.py returns human-readable placeholder strings on failure instead of signalling failure to its callers:

body_data = payload.get("body", {}).get("data", "")
if body_data:
    try:
        return _decode_base64url(body_data).decode("utf-8")
    except ValueError:
        return "(Could not decode email body)"

gmail_edit_draft (:451, now :468) feeds that straight into a real MIME message:

final_body = params.body if params.body is not None else _extract_body(payload)

which is then encoded and PUT to Gmail. So editing only the subject of a draft (params.body is None, the documented normal case) whose stored body cannot be decoded — non-UTF-8 or otherwise corrupt — overwrites the real body with the literal text (Could not decode email body) and reports success=True. Unrecoverable data loss presented as a success.

The multipart recursion guard (:121) has the same root cause:

elif part.get("parts"):
    nested = _extract_body(part)
    if nested and nested != "(No email body found)":
        return nested

It only excludes one of the two sentinels, so a nested part whose own body.data fails to decode returns (Could not decode email body), which passes the guard and is treated as a successful extraction. return nested fires immediately and discards an already-collected valid plain_text from an earlier sibling.

The fix

_extract_body now returns str | None with no sentinel inside the recursion, and raises ValueError only when body data is present but fails to decode — that's the one case a caller must not treat as "no body". Multipart traversal collects plain_text / html_text / nested_text into the same preference resolution instead of short-circuiting on the first nested part with any content, and a nested decode failure is suppressed the same way a direct part decode failure already was (searches other siblings instead of aborting).

The sentinel strings ((Could not decode email body), (No email body found)) are now rendered exactly once, at the gmail_read_email display boundary, so existing read-path output is unchanged.

gmail_edit_draft now distinguishes the two cases: params.body is None and the existing body decodes fine → carries it forward as before. params.body is None and the existing body fails to decode → returns success=False with an error instead of writing a placeholder over it.

Verification

The two new regression tests fail on main:

FAILED TestReadEmail::test_multipart_sibling_text_survives_undecodable_nested_sibling
    AssertionError: assert '(Could not decode email body)' == 'Sibling one text'
FAILED TestEditDraft::test_subject_only_edit_does_not_clobber_undecodable_body
    AssertionError: assert 2 == 1  (a PUT was sent that should not have been)
FAILED TestEditDraft::test_multipart_sibling_survives_undecodable_nested_sibling_on_edit
    AssertionError: assert '(Could not decode email body)' == 'Sibling one text'

With the fix:

$ uv run pytest tests/providers/google/gmail/ -q
122 passed in 0.24s

$ uv run pytest tests/ -q
2341 passed, 49 skipped in 4.52s

$ uv run pre-commit run --all-files
check for merge conflicts................................................Passed
trim trailing whitespace.................................................Passed
fix end of files.........................................................Passed
ruff (legacy alias)......................................................Passed
ruff format..............................................................Passed
Detect secrets...........................................................Passed
ty.......................................................................Passed
uv-lock..................................................................Passed

This is the scope deferred from #200 (base64url padding) to keep that PR focused, per the issue body.

_extract_body returned human-readable placeholder strings on failure
instead of signalling it. gmail_edit_draft fed that straight into a
real MIME message and PUT it back to Gmail, so editing only the
subject of a draft with an undecodable body silently overwrote the
body with the literal text "(Could not decode email body)" and
reported success=True.

The multipart recursion guard had the same root cause: it checked
`nested != "(No email body found)"`, so a nested part whose own
body.data failed to decode looked like a successful extraction and
short-circuited the loop, discarding an already-collected valid
plain_text from an earlier sibling.

_extract_body now returns str | None with no sentinel, and raises
ValueError only when body data is present but fails to decode -
distinguishing "no body" from "couldn't recover the body". The
sentinel strings are rendered exactly once, at the gmail_read_email
display boundary, so existing read-path behavior is unchanged.
gmail_edit_draft now returns success=False with a real error instead
of writing a placeholder when it can't recover the existing body.

Fixes mozilla-ai#203. Deferred scope from mozilla-ai#200 (base64url padding).
@coderabbitai

coderabbitai Bot commented Aug 23, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 371c323d-0448-408b-86b1-33a7ce5f4f57


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Gmail _extract_body: decode failures misreported as absence, and placeholder strings can clobber drafts

1 participant