feat(read): return the HTML body from read_email #35

Merged
jlxq0 merged 3 commits from feat/read-html-body into main 2026-09-01 16:56:29 +00:00
Owner

The sender was the one party unable to check its own output. v0.2.19 gave send_email a body_html; Email/get never asked for htmlBody.

Did the read_email half rather than the Content-Type half, so an agent can also read HTML mail it receives.

The acceptance, not "the field exists"

html_composed_by_send_survives_the_read_path takes build_email_object output, the send path, and feeds it to extract_html_body, the read path, asserting the markup comes back byte-for-byte including a style attribute and an & in a query string. A JMAP Email carries the same bodyValues/textBody/htmlBody shape both directions, so that output is a faithful stand-in for an Email/get result.

No fallback, on purpose

extract_text_body falls back to the first available bodyValue and that is harmless there. extract_html_body must not: it would return the plain-text part as body_html on a text-only email, so a sender checking whether its markup arrived would be told yes for every message ever sent. None means no HTML part and stays distinguishable.

The HTML is untrusted content

It gets its own suspicion verdict and its flag is OR-ed in, because a payload placed only in the HTML would be invisible to a check that reads the text part. Evaluated separately rather than concatenated: body_text carries verdict.wrapped, so a combined input would have put the markup inside the plain-text field. I caught that before it built. The markup is returned unwrapped, since the sandbox wrap is text delimiters and would stop it being valid HTML, and truncated with truncate_text_body so the byte cap cannot land inside a multibyte char.

Mutations, including yours

what I removed red
htmlBody + fetchHTMLBodyValues from the request the_request_asks_for_the_html_part
htmlBody absent → fall back to any bodyValue a_text_only_email_reads_back_no_html
extract_html_body reads textBody's partId all three extractor tests
body_html: None in the handler's result NOTHING

Your mutation redded nothing when you first described it, and that is why the request moved into read_email_get_args. Three tests on the extractor passed with htmlBody and fetchHTMLBodyValues gone from the Email/get, because none of them reached the request. Same shape as #31, one layer up. It now reds.

Two things reported rather than dropped. body_html: None in the handler reds nothing: that is #31's class, the handler assembling ReadEmailResult has no test that reaches it, and I have not closed it here. And my first attempt at the fallback mutation redded nothing because I placed it after the partId lookup had already succeeded, where nothing dangerous happens; placed on the absent-htmlBody path it reds. A mis-placed mutation and a test gap read identically.

Gate

Toolchain read from ci.yml: 1.93.0. Through build-slot.sh.

cargo +1.93.0 fmt --all --check
cargo +1.93.0 clippy --all-targets --all-features --locked -- -D warnings
cargo +1.93.0 test --all-features --locked

All rc=0.

running 191 tests
test result: ok. 191 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.22s

Closes #33

The sender was the one party unable to check its own output. `v0.2.19` gave `send_email` a `body_html`; `Email/get` never asked for `htmlBody`. Did the `read_email` half rather than the `Content-Type` half, so an agent can also read HTML mail it receives. ## The acceptance, not "the field exists" `html_composed_by_send_survives_the_read_path` takes `build_email_object` output, the send path, and feeds it to `extract_html_body`, the read path, asserting the markup comes back byte-for-byte including a `style` attribute and an `&` in a query string. A JMAP `Email` carries the same `bodyValues`/`textBody`/`htmlBody` shape both directions, so that output is a faithful stand-in for an `Email/get` result. ## No fallback, on purpose `extract_text_body` falls back to the first available bodyValue and that is harmless there. `extract_html_body` must not: it would return the plain-text part as `body_html` on a text-only email, so **a sender checking whether its markup arrived would be told yes for every message ever sent**. `None` means no HTML part and stays distinguishable. ## The HTML is untrusted content It gets its own suspicion verdict and its flag is OR-ed in, because a payload placed only in the HTML would be invisible to a check that reads the text part. Evaluated **separately** rather than concatenated: `body_text` carries `verdict.wrapped`, so a combined input would have put the markup inside the plain-text field. I caught that before it built. The markup is returned unwrapped, since the sandbox wrap is text delimiters and would stop it being valid HTML, and truncated with `truncate_text_body` so the byte cap cannot land inside a multibyte char. ## Mutations, including yours | what I removed | red | |---|---| | `htmlBody` + `fetchHTMLBodyValues` from the request | `the_request_asks_for_the_html_part` | | `htmlBody` absent → fall back to any bodyValue | `a_text_only_email_reads_back_no_html` | | `extract_html_body` reads `textBody`'s partId | all three extractor tests | | `body_html: None` in the handler's result | **NOTHING** | **Your mutation redded nothing when you first described it, and that is why the request moved into `read_email_get_args`.** Three tests on the extractor passed with `htmlBody` and `fetchHTMLBodyValues` gone from the `Email/get`, because none of them reached the request. Same shape as `#31`, one layer up. It now reds. **Two things reported rather than dropped.** `body_html: None` in the handler reds nothing: that is `#31`'s class, the handler assembling `ReadEmailResult` has no test that reaches it, and I have not closed it here. And my first attempt at the fallback mutation redded nothing because I placed it after the partId lookup had already succeeded, where nothing dangerous happens; placed on the absent-`htmlBody` path it reds. **A mis-placed mutation and a test gap read identically.** ## Gate Toolchain read from `ci.yml`: `1.93.0`. Through `build-slot.sh`. ``` cargo +1.93.0 fmt --all --check cargo +1.93.0 clippy --all-targets --all-features --locked -- -D warnings cargo +1.93.0 test --all-features --locked ``` All `rc=0`. ``` running 191 tests test result: ok. 191 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.22s ``` Closes #33
feat(read): return the HTML body from read_email
All checks were successful
CI / tag-ancestry (pull_request) Successful in 3s
CI / cargo (pull_request) Successful in 1m0s
CI / docker (pull_request) Successful in 53s
9048106b9c
The sender was the one party unable to check its own output: v0.2.19 gave
send_email a body_html and Email/get never asked for htmlBody, so nothing here
could tell whether the part arrived. Lucy found it on her sent copy rather than
by trusting the success return.

Email/get now asks for htmlBody with fetchHTMLBodyValues, and ReadEmailResult
carries body_html: Option<String>. This also lets an agent read HTML mail it
receives, which was the reason to do this half rather than the Content-Type
half.

extract_html_body has no fallback to "the first available bodyValue", which is
what extract_text_body does. There the fallback is harmless; here it would
return the plain-text part as body_html on a text-only email, so a sender
checking whether its markup arrived would be told yes for every message ever
sent. None means no HTML part and stays distinguishable.

The HTML part is sender-controlled, so it gets its own suspicion verdict and
its flag is OR-ed in: a payload placed only in the HTML would otherwise be
invisible to a check that reads the text part. Evaluated separately rather than
concatenated, because body_text carries verdict.wrapped and a combined input
would have put the markup inside the plain-text field. The markup itself is
returned unwrapped; the sandbox wrap is text delimiters and would stop it being
valid HTML. Truncated with truncate_text_body, so the byte cap cannot land
inside a multibyte char.

The Email/get arguments moved into read_email_get_args because the request was
untested: removing htmlBody and fetchHTMLBodyValues from it redded NOTHING
while three tests on the extractor passed. Same shape as #31 one layer up.

Mutations, on the pinned toolchain:
  htmlBody + fetchHTMLBodyValues out of the request
      -> the_request_asks_for_the_html_part
  htmlBody absent falls back to any bodyValue
      -> a_text_only_email_reads_back_no_html
  extract_html_body reads textBody's partId
      -> all three extractor tests
  body_html: None in the handler's result
      -> NOTHING, reported rather than dropped; it is #31's class, the handler
         assembling ReadEmailResult has no test that reaches it

Gate: cargo +1.93.0 fmt --all --check; cargo +1.93.0 clippy --all-targets
--all-features --locked -- -D warnings; cargo +1.93.0 test --all-features
--locked. All rc=0, 191 passed. Toolchain read from ci.yml, run through
build-slot.sh.

Closes #33

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
body_html was the only untrusted field in ReadEmailResult with no treatment at
all. Every sibling is sanitised: from, to and cc through
sanitize_external_list, subject and attachment names through
sanitize_external_text, body_text wrapped and escaped.

My justification was right about the wrapper and wrong about the escape. Those
are two separable operations and this file already separates them. wrap_body
adds text delimiters and would stop the markup being valid HTML, which is a
reason to skip the wrapper. sanitize_external_text neutralises role/control
delimiters only, which is what the subject and the filenames beside it get, and
its escapes are HTML entity references, so &lt;system&gt; is valid HTML that
renders as the literal characters.

The hole was concrete rather than theoretical, because the escape list and the
suspicion list are not the same set. Measured in content_sandbox.rs:
ANGLE_ROLE_TOKENS carries <user> and </user>; SUSPICIOUS_ROLE_MARKERS does not.
So in an HTML part those two were neither escaped nor flagged and suspicious
came back false. The html_suspicious OR I shipped does not cover it, because
the hole is in the difference between the lists.

Extract, cap, judge and escape now happen in read_email_html_body, one call, so
the escape has somewhere a test can reach it. As four steps in the handler the
escape was simply absent and no test noticed. Order is load-bearing: the
verdict is taken on the raw markup and the escape applied after, because
escaping first turns <system> into an entity and hides it from the check.

Mutations:
  remove the sanitize_external_text call -> two escaping tests
  escape before taking the verdict       -> html_body_suspicion_is_judged_before_escaping
  wrap instead of escaping               -> three tests
  handler discards the escaped html      -> NOTHING, #31's class, reported

Gate: cargo +1.93.0 fmt --all --check; cargo +1.93.0 clippy --all-targets
--all-features --locked -- -D warnings; cargo +1.93.0 test --all-features
--locked. All rc=0, 195 passed.

Refs #33

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs: name the cluster's role rather than the cluster
All checks were successful
CI / tag-ancestry (pull_request) Successful in 3s
CI / cargo (pull_request) Successful in 1m1s
CI / docker (pull_request) Successful in 42s
69cadbbf58
Refs jlxq0/mantis#299. Folded into this change to the file rather than a PR of
its own, as that issue asks.

The doc comment argued which address is reachable from where and named the
cluster to say what holds the /32. "The cluster" carries the point identically,
and "three edges" carries the BGP peering without listing them.

AGENTS.md is deliberately untouched: its two occurrences are one fact across
two lines, and one of them is a clusters/fondue GitOps path that Clark decided
stays. Removing the prose name while the next line spells it out would be a
redaction in appearance only, which is worse than the reference. Raised rather
than resolved either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jlxq0 merged commit 8b90415fd2 into main 2026-09-01 16:56:29 +00:00
jlxq0 deleted branch feat/read-html-body 2026-09-01 16:56:29 +00:00
Sign in to join this conversation.
No reviewers
No labels
waiting-on-julian
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
jlxq0/jmap-mcp!35
No description provided.