# Forwarding an HTML message with its formatting
Item 171. Design, 2026-08-27. **Read this before the backlog row**, which
records only the cause.
## The defect
`ComposeContextBuilder::quoteBody()` reads `ParsedMessage::plainBody` and
nothing else, so the forward path drops the original's `htmlBody` entirely.
Two consequences, and they are not the same severity:
- An original with both parts forwards its text/plain alternative. The words
survive, the sender's formatting does not. This is what the user reported.
- **An original with an HTML part ONLY has an empty `plainBody`**, so the
forward carries an attribution line and an empty quote. The content is gone
and nothing says so.
Measured on the developer's own inbox 2026-08-27: **30 of 342 sampled
messages** (~9%) declare `text/html` with no `text/plain` part. The silent
half is not an edge case.
## What was decided, and by whom
The user chose, 2026-08-27, from three routes put to them:
1. **Inline, carrying the original's markup** — CHOSEN. The forward carries
the original's own HTML rather than a flattened quote. Highest inline
fidelity, and the most work, because it is the only route that puts a
stranger's markup into an outgoing message.
**Amended 2026-08-27, after the first build**: a forward sends ONE part,
not a `multipart/alternative`. The Send-as-HTML toggle chooses which — the
original's markup when on, the text quote when off. The user's reasoning is
that a forward's shape is something they have already decided by flipping
that toggle, and sending both halves hands the choice to the recipient's
client instead. An ordinary (non-forward) message still sends the
alternative as before; only the forward path is single-part.
2. Attach the original as `message/rfc822` (item 130's mechanism). Rejected
here, though item 130 may still build it for its own sake: the user wants
the content inline, not as an attachment.
3. Text fallback only. Rejected: it fixes the silent-loss half and does not
answer the note at all, since formatting is still lost on every forward.
**Remote content is STRIPPED BY DEFAULT, with a per-forward opt-out**, also
the user's choice against "always strip" and "keep everything". A control in
the composer, checked by default, reading roughly "Strip remote content from
the forwarded message".
## Why this is the security-critical item in the backlog
Every other HTML path in this application renders a stranger's markup *to the
user*, behind protections that live in `MessageView`: an off-the-record
profile, JavaScript disabled, and `RequestInterceptor` blocking every request
by default and failing closed.
**None of those protections apply here.** The markup leaves this process and
is rendered by somebody else's mail client, under their policy, on their
machine. The interceptor cannot help: it intercepts requests *we* would make.
So the sanitising has to happen to the bytes, before they are handed to
`MessageSender`, and there is no second line of defence behind it.
The concrete harm, in the user's own words when the decision was put to them:
forwarding a tracking pixel forwards the tracking. The original sender learns
that the forwarded copy was opened, by whom, and how many times, and the
user's recipient never consented to that.
## The allow-list rule, which is not negotiable
`HtmlBuilder::namespaceCids()` is the closest prior art and it is a
**block-list**: it names the attributes that can carry a `cid:` and rewrites
those. It documents scoping `srcset=` out, on the reasoning that its quoting
grammar differs and `cid:` in `srcset` is not seen in the wild.
**That trade is correct for rewriting and WRONG for stripping**, and the
asymmetry is the whole design:
| | a missed reference means |
|---|---|
| `namespaceCids` (rewrite) | one broken image |
| this sanitiser (strip) | a tracking beacon reaching the recipient |
So the sanitiser must **allow-list what may remain**, not block-list what must
go. Anything not recognised is removed. A new HTML attribute, a quoting form
not anticipated, a `srcset`, a CSS `image-set()`, an `@import` — each is
handled by the default, which is removal, rather than by having been
enumerated in advance.
Stated as the invariant to test against: **after sanitising, no attribute
value and no CSS construct in the output may contain a URL whose scheme is
anything other than `cid:`, and no element that fetches may remain without
one.**
## What is kept and what goes
Kept:
- `cid:` references. They travel inside the message, fetch nothing, and are
what makes an inline logo survive. Item 129 will need the same machinery.
- Structural and presentational markup: tables, lists, headings, spans,
`style=""` attributes with their remote constructs removed.
Removed:
- Any `src`, `href`, `background`, `poster`, `srcset`, `data-*` or other
attribute value carrying a non-`cid:` URL scheme. `http:`, `https:`,
`//host/path`, `data:` (which can carry markup), `file:` above all.
- ``, `