thread: render HTML mail in a sandboxed frame
The raw part is stored and sanitized when served, so a tightened
sanitizer covers mail already imported; the CSP on the page keeps the
sandbox even when it's opened outside the frame.

Assisted-by: Claude Opus 5.5 via Claude Code
change wvvmupqpuzrsxyqrxslxwusxnsootmrn
commit b49628462279582133ad4c8aa9647b60ae0f0ace
author Alpha Chen <alpha@kejadlen.dev>
date
parent kwywzszw
diff --git a/Cargo.lock b/Cargo.lock
index af784db..dab4d4c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -26,6 +26,18 @@ dependencies = [
  "memchr",
 ]
 
+[[package]]
+name = "ammonia"
+version = "4.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "850babe4998b8b9e2dad65a56be4ee1bb977bc3cb3934fa2b28feb2297c30a55"
+dependencies = [
+ "cssparser",
+ "html5ever 0.40.1",
+ "maplit",
+ "url",
+]
+
 [[package]]
 name = "anstream"
 version = "1.0.0"
@@ -402,6 +414,17 @@ dependencies = [
  "cfg-if",
 ]
 
+[[package]]
+name = "cssparser"
+version = "0.38.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11119743ad110e8c1bdccd930d7f5c30c99e5fc76a7b63ec9807e84eef0c5f59"
+dependencies = [
+ "dtoa-short",
+ "itoa",
+ "smallvec",
+]
+
 [[package]]
 name = "defmt"
 version = "1.1.1"
@@ -448,6 +471,7 @@ dependencies = [
 name = "docket"
 version = "0.1.0"
 dependencies = [
+ "ammonia",
  "axum",
  "camino",
  "clap",
@@ -471,6 +495,21 @@ dependencies = [
  "tracing-subscriber",
 ]
 
+[[package]]
+name = "dtoa"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
+
+[[package]]
+name = "dtoa-short"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
+dependencies = [
+ "dtoa",
+]
+
 [[package]]
 name = "dunce"
 version = "1.0.5"
@@ -729,7 +768,7 @@ version = "0.17.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d1c97eea9d3e6524d8d2a4643ce0e3135500c4c7ca1d02393a485f871bef69d8"
 dependencies = [
- "html5ever",
+ "html5ever 0.39.0",
  "tendril",
  "thiserror",
  "unicode-width 0.2.2",
@@ -742,7 +781,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
 dependencies = [
  "log",
- "markup5ever",
+ "markup5ever 0.39.0",
+]
+
+[[package]]
+name = "html5ever"
+version = "0.40.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "456a1a377e608e555d22ddab27ac0114bc7a7b4199078108e34c2aeae6c9b130"
+dependencies = [
+ "log",
+ "markup5ever 0.40.0",
+ "memchr",
 ]
 
 [[package]]
@@ -1230,6 +1280,12 @@ version = "0.1.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
 
+[[package]]
+name = "maplit"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
+
 [[package]]
 name = "markup5ever"
 version = "0.39.0"
@@ -1238,7 +1294,18 @@ checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
 dependencies = [
  "log",
  "tendril",
- "web_atoms",
+ "web_atoms 0.2.6",
+]
+
+[[package]]
+name = "markup5ever"
+version = "0.40.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ab3dc68ac4a0f5719e560136778c1ee716e296030d75dbd4484e37e39e3a842"
+dependencies = [
+ "log",
+ "tendril",
+ "web_atoms 0.3.0",
 ]
 
 [[package]]
@@ -1450,7 +1517,17 @@ version = "0.13.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
 dependencies = [
- "phf_shared",
+ "phf_shared 0.13.1",
+ "serde",
+]
+
+[[package]]
+name = "phf"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
+dependencies = [
+ "phf_shared 0.14.0",
  "serde",
 ]
 
@@ -1460,8 +1537,18 @@ version = "0.13.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
 dependencies = [
- "phf_generator",
- "phf_shared",
+ "phf_generator 0.13.1",
+ "phf_shared 0.13.1",
+]
+
+[[package]]
+name = "phf_codegen"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41b585a510fb76fdebead6897982ef2a03a21d8e6cbcca904999742a4afc6ffe"
+dependencies = [
+ "phf_generator 0.14.0",
+ "phf_shared 0.14.0",
 ]
 
 [[package]]
@@ -1471,7 +1558,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
 dependencies = [
  "fastrand",
- "phf_shared",
+ "phf_shared 0.13.1",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100"
+dependencies = [
+ "fastrand",
+ "phf_shared 0.14.0",
 ]
 
 [[package]]
@@ -1483,6 +1580,15 @@ dependencies = [
  "siphasher",
 ]
 
+[[package]]
+name = "phf_shared"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89"
+dependencies = [
+ "siphasher",
+]
+
 [[package]]
 name = "pin-project-lite"
 version = "0.2.17"
@@ -2094,7 +2200,19 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
 dependencies = [
  "new_debug_unreachable",
  "parking_lot",
- "phf_shared",
+ "phf_shared 0.13.1",
+ "precomputed-hash",
+]
+
+[[package]]
+name = "string_cache"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ffa8a5dbe8b3f0bbe29d4c3225daafaeead63afdc1b65fc4c01a1384166038e6"
+dependencies = [
+ "new_debug_unreachable",
+ "parking_lot",
+ "phf_shared 0.14.0",
  "precomputed-hash",
 ]
 
@@ -2104,8 +2222,20 @@ version = "0.6.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
 dependencies = [
- "phf_generator",
- "phf_shared",
+ "phf_generator 0.13.1",
+ "phf_shared 0.13.1",
+ "proc-macro2",
+ "quote",
+]
+
+[[package]]
+name = "string_cache_codegen"
+version = "0.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "928dcdf75e47626b3617a976ec205d9f057584c371c1f23b782129268d0e6edc"
+dependencies = [
+ "phf_generator 0.14.0",
+ "phf_shared 0.14.0",
  "proc-macro2",
  "quote",
 ]
@@ -2654,10 +2784,22 @@ version = "0.2.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3"
 dependencies = [
- "phf",
- "phf_codegen",
- "string_cache",
- "string_cache_codegen",
+ "phf 0.13.1",
+ "phf_codegen 0.13.1",
+ "string_cache 0.9.0",
+ "string_cache_codegen 0.6.1",
+]
+
+[[package]]
+name = "web_atoms"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7572660c8890448ba236b7376f27e389c6a7e1c70195622faced601f855c0ada"
+dependencies = [
+ "phf 0.14.0",
+ "phf_codegen 0.14.0",
+ "string_cache 0.11.0",
+ "string_cache_codegen 0.11.2",
 ]
 
 [[package]]
diff --git a/Cargo.toml b/Cargo.toml
index 70335be..5fa23a5 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -14,6 +14,7 @@ path = "src/bin/docket/main.rs"
 dev = []
 
 [dependencies]
+ammonia = "*"
 axum = "*"
 camino = "*"
 clap = { version = "*", features = ["derive", "env"] }
diff --git a/assets/docket.css b/assets/docket.css
index fbe72e0..da91645 100644
--- a/assets/docket.css
+++ b/assets/docket.css
@@ -91,15 +91,20 @@ button { font: inherit; color: inherit; }
 .item .copies { font-size: var(--gl-step--2); }
 .item .text { line-height: 1.6; white-space: pre-line; overflow-wrap: anywhere; }
 /* A reply's quoted history: folded behind a quiet toggle, faint when open. */
-.quote-btn { all: unset; box-sizing: border-box; display: inline-flex; align-items: center; align-self: flex-start; cursor: pointer; padding: 4px 6px; margin-left: -6px; border-radius: var(--gl-radius); color: var(--gl-color-text-tertiary); font: var(--gl-step--2) var(--gl-font-mono); transition: background var(--gl-dur-fast) var(--gl-ease); }
-.quote-btn:hover { background: var(--gl-color-surface-fill); color: var(--gl-color-text-primary); }
-.quote-btn:focus-visible { outline: 2px solid var(--gl-color-accent); outline-offset: 1px; }
+.quote-btn, .images-btn { all: unset; box-sizing: border-box; display: inline-flex; align-items: center; align-self: flex-start; cursor: pointer; padding: 4px 6px; margin-left: -6px; border-radius: var(--gl-radius); color: var(--gl-color-text-tertiary); font: var(--gl-step--2) var(--gl-font-mono); transition: background var(--gl-dur-fast) var(--gl-ease); }
+.quote-btn:hover, .images-btn:hover { background: var(--gl-color-surface-fill); color: var(--gl-color-text-primary); }
+.quote-btn:focus-visible, .images-btn:focus-visible { outline: 2px solid var(--gl-color-accent); outline-offset: 1px; }
 .item .text.quoted { color: var(--gl-color-text-tertiary); }
 /* Body links stay in the text's ink — teal is spoken for — and say
    they're links with the underline alone. */
 .item .text a { color: inherit; }
 .item .text a:hover { color: var(--gl-color-text-primary); }
 .item .text.closed { cursor: pointer; color: var(--gl-color-text-tertiary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+/* HTML mail keeps its own design on the white ground senders assume;
+   the frame can't be measured from outside, so it takes a tall default
+   and can be dragged taller. */
+.html-mail { display: flex; flex-direction: column; gap: 6px; }
+.html-body { width: 100%; height: 70vh; border: var(--gl-border-w) solid var(--gl-color-border-default); border-radius: var(--gl-radius); background: #fff; resize: vertical; overflow: auto; }
 
 /* The gutter: date, then the email's values, right-aligned. */
 .gutter { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; text-align: right; }
@@ -184,7 +189,7 @@ dialog form { display: flex; justify-content: flex-end; gap: var(--gl-space-xs);
    more, below which iOS Safari zooms the page on focus. */
 @media (pointer: coarse) {
   .field { font-size: max(16px, var(--gl-step--1)); }
-  .ctrl-btn, .del-btn, .quote-btn, .opt, .nav-item, .who-btn { box-sizing: border-box; min-height: 44px; align-items: center; }
+  .ctrl-btn, .del-btn, .quote-btn, .images-btn, .opt, .nav-item, .who-btn { box-sizing: border-box; min-height: 44px; align-items: center; }
   .who-btn { align-items: stretch; justify-content: center; }
   .icon-btn, .back, .search-btn { min-width: 44px; min-height: 44px; justify-content: center; align-items: center; }
   .thread-head .back { margin-left: -14px; }
diff --git a/migrations/0009_html.sql b/migrations/0009_html.sql
new file mode 100644
index 0000000..02addb2
--- /dev/null
+++ b/migrations/0009_html.sql
@@ -0,0 +1,6 @@
+-- HTML mail renders as HTML (task uylo): the message's first text/html
+-- part, raw as the server sent it. Sanitizing happens when it's served,
+-- so a tightened sanitizer covers mail already stored. body stays the
+-- text that lists, search, and quote folding read. NULL for text-only
+-- mail, and for mail imported before this until its next reimport.
+ALTER TABLE messages ADD COLUMN html TEXT;
diff --git a/src/fixtures.rs b/src/fixtures.rs
index 5e857df..00f71ce 100644
--- a/src/fixtures.rs
+++ b/src/fixtures.rs
@@ -12,8 +12,19 @@ use crate::store::{Clock, Store};
 
 /// Seeds for the rows tests name by number.
 pub const WATER: u32 = 7;
+pub const BILL: u32 = 8;
 pub const ELI_PRACTICE: u32 = 20;
 
+/// The bill's HTML part: a remote logo to block, a link to send to a
+/// new tab, and a script to strip.
+const BILL_HTML: &str = r##"<table width="100%" cellpadding="16" bgcolor="#f3f6f9"><tr><td>
+<img src="https://www.pge.com/logo.png" alt="PG&amp;E" width="96">
+<h2 style="color:#00539b">Your September bill is ready</h2>
+<p>Amount due: <b>$142.18</b> by October 21.</p>
+<p><a href="https://www.pge.com/myaccount" style="background:#00539b;color:#fff;padding:8px 16px;text-decoration:none">View your bill</a></p>
+<script>document.title = "pwned"</script>
+</td></tr></table>"##;
+
 pub const ALEX: &str = "alex@example.com";
 pub const SAM: &str = "sam@example.com";
 
@@ -85,6 +96,7 @@ impl Builder {
             cc: strings(cc),
             bcc: Vec::new(),
             body: body.to_owned(),
+            has_html: false,
             kind: Kind::Received {
                 from: from.to_owned(),
                 addr: addr.to_owned(),
@@ -117,6 +129,7 @@ impl Builder {
             cc: strings(cc),
             bcc: strings(bcc),
             body: body.to_owned(),
+            has_html: false,
             kind: Kind::Sent {
                 by: Some(by.to_owned()),
                 to: strings(to),
@@ -246,7 +259,7 @@ pub fn seed(store: &Store) -> Result<(), Error> {
 
     b.thread(4, "household", "Your September bill is ready");
     b.recv(
-        8,
+        BILL,
         4,
         at(9, 30, 7, 45),
         ("PG&E", "billing@pge.com"),
@@ -367,6 +380,7 @@ pub fn seed(store: &Store) -> Result<(), Error> {
         for message in &b.messages {
             tx.message(message)?;
         }
+        tx.html(&id(BILL), BILL_HTML)?;
         for comment in &b.comments {
             tx.comment(comment)?;
         }
diff --git a/src/html.rs b/src/html.rs
new file mode 100644
index 0000000..5cd977e
--- /dev/null
+++ b/src/html.rs
@@ -0,0 +1,161 @@
+//! HTML mail as the thread view frames it (task uylo). Two layers keep
+//! it inert: ammonia strips what could act — scripts, forms, event
+//! handlers — while keeping the tables, inline styles, and `<style>`
+//! blocks mail layout leans on; and the page is served under a CSP
+//! that sandboxes it (no script, no same-origin) and blocks remote
+//! loads, so a remote image, which doubles as a tracking pixel, loads
+//! only when someone asks.
+
+use std::collections::HashSet;
+use std::sync::LazyLock;
+
+use ammonia::Builder;
+
+/// Attributes on any tag that carry mail's layout. `style` can't run
+/// script in a sandbox, and what it could fetch, the CSP blocks.
+const LAYOUT_ATTRIBUTES: &[&str] = &[
+    "align",
+    "bgcolor",
+    "border",
+    "cellpadding",
+    "cellspacing",
+    "class",
+    "color",
+    "dir",
+    "face",
+    "height",
+    "id",
+    "size",
+    "style",
+    "valign",
+    "width",
+];
+
+static SANITIZER: LazyLock<Builder<'static>> = LazyLock::new(|| {
+    let mut builder = Builder::default();
+    // `<style>` is dropped wholesale by default; mail styles itself
+    // with it, so it's kept as a tag instead.
+    builder
+        .rm_clean_content_tags(HashSet::from(["style"]))
+        .add_tags(["style", "font", "center"])
+        .add_generic_attributes(LAYOUT_ATTRIBUTES)
+        .link_rel(Some("noopener noreferrer"))
+        // Links leave the frame for a new tab, never navigate it.
+        .set_tag_attribute_value("a", "target", "_blank");
+    builder
+});
+
+/// The mail's HTML with anything that could act stripped out.
+pub fn sanitize(raw: &str) -> String {
+    SANITIZER.clean(raw).to_string()
+}
+
+/// The page the frame loads: the sanitized mail on a plain white ground,
+/// as senders design for, with images scaled to fit.
+pub fn document(raw: &str) -> String {
+    format!(
+        "<!doctype html><html><head><meta charset=\"utf-8\">\
+         <meta name=\"color-scheme\" content=\"light\">\
+         <style>html{{background:#fff;color:#1a1a1a}}\
+         body{{margin:0;font:15px/1.5 system-ui,sans-serif;overflow-wrap:anywhere}}\
+         img{{max-width:100%;height:auto}}</style>\
+         </head><body>{}</body></html>",
+        sanitize(raw)
+    )
+}
+
+/// The page's Content-Security-Policy. The sandbox allows only popups,
+/// so links open in new tabs that escape it; nothing else runs or
+/// loads, except inline images and — once asked for — remote ones.
+pub fn csp(remote_images: bool) -> String {
+    let images = if remote_images {
+        "data: https: http:"
+    } else {
+        "data:"
+    };
+    format!(
+        "default-src 'none'; style-src 'unsafe-inline'; img-src {images}; font-src data:; \
+         form-action 'none'; base-uri 'none'; frame-ancestors 'self'; \
+         sandbox allow-popups allow-popups-to-escape-sandbox"
+    )
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn what_could_act_is_stripped() {
+        let clean = sanitize(
+            r#"<p onclick="steal()">Hi<script>steal()</script></p>
+               <form action="https://evil.example"><input name="pw"><button>Go</button></form>
+               <a href="javascript:steal()">bad</a><iframe src="https://evil.example"></iframe>"#,
+        );
+        for gone in [
+            "onclick",
+            "script",
+            "steal",
+            "form",
+            "input",
+            "button",
+            "javascript",
+            "iframe",
+        ] {
+            assert!(!clean.contains(gone), "{gone} survived: {clean}");
+        }
+        assert!(clean.contains("<p>Hi</p>"), "{clean}");
+    }
+
+    #[test]
+    fn layout_survives() {
+        let clean = sanitize(
+            r##"<style>.cta { color: red; }</style>
+               <table width="600" cellpadding="0" bgcolor="#eee"><tr>
+               <td class="cta" style="padding: 8px" align="center">
+               <font face="Georgia" color="red">Sale</font></td></tr></table>
+               <img src="https://shop.example/hero.png" width="600" alt="Hero">"##,
+        );
+        for kept in [
+            "<style>.cta { color: red; }</style>",
+            r#"width="600""#,
+            r#"cellpadding="0""#,
+            r##"bgcolor="#eee""##,
+            r#"class="cta""#,
+            r#"style="padding: 8px""#,
+            r#"align="center""#,
+            r#"<font face="Georgia" color="red">"#,
+            r#"src="https://shop.example/hero.png""#,
+        ] {
+            assert!(clean.contains(kept), "{kept} lost: {clean}");
+        }
+    }
+
+    #[test]
+    fn links_open_in_a_new_tab() {
+        let clean = sanitize(r#"<a href="https://example.com" target="_self">Track</a>"#);
+        assert!(clean.contains(r#"target="_blank""#), "{clean}");
+        assert!(clean.contains(r#"rel="noopener noreferrer""#), "{clean}");
+        assert!(!clean.contains("_self"), "{clean}");
+    }
+
+    #[test]
+    fn the_document_wraps_the_sanitized_mail() {
+        let page = document("<p>Hi</p><script>x()</script>");
+        assert!(page.starts_with("<!doctype html>"));
+        assert!(page.contains("<body><p>Hi</p></body>"), "{page}");
+    }
+
+    #[test]
+    fn remote_images_load_only_when_asked() {
+        let blocked = csp(false);
+        assert!(blocked.contains("img-src data:;"), "{blocked}");
+        assert!(!blocked.contains("https:"), "{blocked}");
+        assert!(csp(true).contains("img-src data: https: http:;"));
+        for policy in [blocked, csp(true)] {
+            assert!(policy.contains("default-src 'none'"));
+            assert!(policy.contains("sandbox allow-popups allow-popups-to-escape-sandbox"));
+            assert!(!policy.contains("allow-scripts"));
+            assert!(!policy.contains("allow-same-origin"));
+        }
+    }
+}
diff --git a/src/jmap.rs b/src/jmap.rs
index 6c5dd61..1b05c72 100644
--- a/src/jmap.rs
+++ b/src/jmap.rs
@@ -341,24 +341,10 @@ struct Email {
 /// part converted to text, then the server's preview. A message with
 /// none of those says so rather than rendering blank.
 fn body_of(email: &Email) -> String {
-    let first = |parts: Option<&[BodyPart]>, media_type: &str| {
-        parts
-            .unwrap_or_default()
-            .iter()
-            .filter(|part| part.media_type.starts_with(media_type))
-            .filter_map(|part| {
-                email
-                    .body_values
-                    .as_ref()
-                    .and_then(|values| values.get(&part.part_id))
-            })
-            .map(|value| value.value.as_str())
-            .find(|value| !value.trim().is_empty())
-    };
-    if let Some(plain) = first(email.text_body.as_deref(), "text/plain") {
+    if let Some(plain) = first_part(email, email.text_body.as_deref(), "text/plain") {
         return plain.into();
     }
-    if let Some(html) = first(email.html_body.as_deref(), "text/html") {
+    if let Some(html) = html_of(email) {
         // from_read over a byte slice can't fail; the raw HTML is an
         // unreachable stand-in for that error.
         return html2text::from_read(html.as_bytes(), usize::MAX).unwrap_or_else(|_| html.into());
@@ -370,6 +356,33 @@ fn body_of(email: &Email) -> String {
         .map_or_else(|| "(no body)".into(), str::to_owned)
 }
 
+/// The message's first non-blank HTML part, raw. `htmlBody` falls back
+/// to the text parts for text-only mail (RFC 8621 §4.1.4), so the media
+/// type filter is what tells HTML mail apart.
+fn html_of(email: &Email) -> Option<&str> {
+    first_part(email, email.html_body.as_deref(), "text/html")
+}
+
+/// The first of `parts` of `media_type` whose fetched value isn't blank.
+fn first_part<'a>(
+    email: &'a Email,
+    parts: Option<&'a [BodyPart]>,
+    media_type: &str,
+) -> Option<&'a str> {
+    parts
+        .unwrap_or_default()
+        .iter()
+        .filter(|part| part.media_type.starts_with(media_type))
+        .filter_map(|part| {
+            email
+                .body_values
+                .as_ref()
+                .and_then(|values| values.get(&part.part_id))
+        })
+        .map(|value| value.value.as_str())
+        .find(|value| !value.trim().is_empty())
+}
+
 /// Shapes a fetched email for the store. `None` skips a senderless
 /// oddity (nothing to show for it).
 fn incoming(email: &Email, layout: &Layout, users: &[User]) -> Option<Incoming> {
@@ -421,6 +434,7 @@ fn incoming(email: &Email, layout: &Layout, users: &[User]) -> Option<Incoming>
         cc: names(empty(&email.cc)),
         bcc: names(empty(&email.bcc)),
         body: body_of(email),
+        html: html_of(email).map(str::to_owned),
         kind,
     })
 }
diff --git a/src/lib.rs b/src/lib.rs
index 6e4132b..14b58cc 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -4,6 +4,7 @@ pub mod dates;
 pub mod dev;
 pub mod error;
 pub mod fixtures;
+pub mod html;
 pub mod jmap;
 pub mod lists;
 pub mod model;
diff --git a/src/model.rs b/src/model.rs
index 56e6147..c89d9a2 100644
--- a/src/model.rs
+++ b/src/model.rs
@@ -146,7 +146,10 @@ pub struct Message {
     pub cc: Vec<String>,
     /// Only known for messages we sent.
     pub bcc: Vec<String>,
+    /// The text: what lists, search, and quote folding read.
     pub body: String,
+    /// An HTML part is stored too, for the thread view to render.
+    pub has_html: bool,
     pub kind: Kind,
 }
 
diff --git a/src/routes.rs b/src/routes.rs
index 8989c69..86d1035 100644
--- a/src/routes.rs
+++ b/src/routes.rs
@@ -1,7 +1,9 @@
 use std::sync::Arc;
 
 use axum::extract::{FromRequestParts, OriginalUri, Path, Query, State as Extract};
-use axum::http::header::{CONTENT_TYPE, HOST, ORIGIN};
+use axum::http::header::{
+    CONTENT_SECURITY_POLICY, CONTENT_TYPE, HOST, ORIGIN, REFERRER_POLICY, X_CONTENT_TYPE_OPTIONS,
+};
 use axum::http::request::Parts;
 use axum::http::{HeaderMap, Method};
 use axum::response::{IntoResponse, Redirect};
@@ -12,11 +14,11 @@ use serde::Deserialize;
 use tower_http::compression::CompressionLayer;
 use tower_http::trace::TraceLayer;
 
-use crate::Error;
 use crate::lists::{self, View};
 use crate::model::{MessageId, State, ThreadId, User};
 use crate::store::{Change, Store};
 use crate::views::{self, Page, ThreadView};
+use crate::{Error, html};
 
 // caddy-tailscale sets both from the tailnet identity, overwriting whatever
 // the client sent.
@@ -42,6 +44,7 @@ pub fn router(state: AppState) -> Router {
         .route("/", get(for_me))
         .route("/{lane}", get(lane))
         .route("/search", get(search))
+        .route("/messages/{id}/html", get(message_html))
         .route("/messages/{id}/state", post(set_state))
         .route("/messages/{id}/folder", post(set_folder))
         .route("/messages/{id}/delete", post(delete_message))
@@ -300,6 +303,34 @@ async fn undo(
     Ok(Redirect::to(safe_back(&form.back)))
 }
 
+#[derive(Deserialize)]
+struct HtmlQuery {
+    /// Set by the "Show images" link: remote images may load.
+    images: Option<String>,
+}
+
+/// The message's HTML part as a page of its own, for the thread view's
+/// sandboxed frame. The CSP is the frame's real wall: it holds even if
+/// the page is opened directly, outside the frame.
+async fn message_html(
+    Extract(state): Extract<AppState>,
+    Me(_me): Me,
+    Path(id): Path<MessageId>,
+    Query(query): Query<HtmlQuery>,
+) -> Result<impl IntoResponse, Error> {
+    let raw = state.store.html(&id)?.ok_or(Error::NotFound("HTML part"))?;
+    Ok((
+        [
+            (CONTENT_TYPE, "text/html; charset=utf-8".to_owned()),
+            (CONTENT_SECURITY_POLICY, html::csp(query.images.is_some())),
+            (X_CONTENT_TYPE_OPTIONS, "nosniff".to_owned()),
+            // Remote images, once allowed, don't learn where they're seen.
+            (REFERRER_POLICY, "no-referrer".to_owned()),
+        ],
+        html::document(&raw),
+    ))
+}
+
 async fn gloss_css() -> impl IntoResponse {
     (
         [(CONTENT_TYPE, "text/css; charset=utf-8")],
diff --git a/src/store.rs b/src/store.rs
index 4b9f758..83553d5 100644
--- a/src/store.rs
+++ b/src/store.rs
@@ -29,6 +29,7 @@ const MIGRATIONS: &[&str] = &[
     include_str!("../migrations/0006_jj_style_ids.sql"),
     include_str!("../migrations/0007_pending_moves.sql"),
     include_str!("../migrations/0008_adoption.sql"),
+    include_str!("../migrations/0009_html.sql"),
 ];
 
 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -284,6 +285,20 @@ impl Store {
     }
 
     /// The thread's messages, oldest first.
+    /// The message's HTML part as the server sent it: unsanitized, so
+    /// it goes out only through [`crate::html::sanitize`]. None for
+    /// text-only mail.
+    pub fn html(&self, id: &str) -> Result<Option<String>, Error> {
+        let inner = self.lock();
+        let html = inner
+            .conn
+            .query_row("SELECT html FROM messages WHERE id = ?1", [id], |r| {
+                r.get(0)
+            })
+            .optional()?;
+        Ok(html.flatten())
+    }
+
     pub fn thread_messages(&self, thread: &str) -> Result<Vec<Message>, Error> {
         load_messages(&self.lock().conn, "m.thread = ?1", [thread])
     }
@@ -693,6 +708,8 @@ pub struct Incoming {
     pub cc: Vec<String>,
     pub bcc: Vec<String>,
     pub body: String,
+    /// The first text/html part, raw; sanitized when served.
+    pub html: Option<String>,
     pub kind: IncomingKind,
 }
 
@@ -848,6 +865,18 @@ impl Import<'_> {
         Ok(())
     }
 
+    /// Gives an imported message an HTML part, as fixtures do; JMAP
+    /// mail brings its own through [`Import::incoming`].
+    pub fn html(&self, id: &str, raw: &str) -> Result<(), Error> {
+        let updated = self
+            .tx
+            .execute("UPDATE messages SET html = ?2 WHERE id = ?1", [id, raw])?;
+        if updated == 0 {
+            return Err(Error::NotFound("message"));
+        }
+        Ok(())
+    }
+
     pub fn comment(&self, c: &Comment) -> Result<(), Error> {
         insert_comment(&self.tx, Some(&c.id), &c.thread, &c.author, c.at, &c.text)
     }
@@ -911,11 +940,12 @@ impl Import<'_> {
         let bcc = json(&mail.bcc)?;
         let _upserted = self.tx.execute(
             "INSERT INTO messages (id, account, message_id, jmap_id, thread, at, cc, bcc, body,
-                kind, from_name, from_addr, state, folder, sent_by, sent_to, server_state)
-             SELECT ?1, ?2, ?3, ?4, t.id, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?13
+                kind, from_name, from_addr, state, folder, sent_by, sent_to, server_state, html)
+             SELECT ?1, ?2, ?3, ?4, t.id, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?13, ?17
              FROM threads t WHERE t.account = ?2 AND t.jmap_thread_id = ?5
              ON CONFLICT (account, message_id) DO UPDATE SET
                  server_state = excluded.server_state,
+                 html = excluded.html,
                  jmap_id = excluded.jmap_id,
                  thread = excluded.thread,
                  at = excluded.at,
@@ -944,6 +974,7 @@ impl Import<'_> {
                 folder,
                 sent_by,
                 sent_to,
+                mail.html,
             ],
         )?;
         if let (Some(prior), Some(seen)) = (prior, state)
@@ -1109,7 +1140,8 @@ fn load_messages(
         "SELECT m.id, m.message_id, m.thread, m.at, m.cc, m.bcc, m.body, m.kind,
             m.from_name, m.from_addr, m.state, m.folder, m.sent_by, m.sent_to,
             (SELECT json_group_array(user)
-                FROM (SELECT user FROM assignees WHERE message = m.id ORDER BY user))
+                FROM (SELECT user FROM assignees WHERE message = m.id ORDER BY user)),
+            m.html IS NOT NULL
          FROM messages m {MESSAGE_JOINS}
          WHERE {filter}
          ORDER BY m.at, m.id"
@@ -1146,6 +1178,7 @@ fn message_from_row(r: &Row<'_>) -> rusqlite::Result<Message> {
         cc: parse_json(r, 4)?,
         bcc: parse_json(r, 5)?,
         body: r.get(6)?,
+        has_html: r.get(15)?,
         kind,
     })
 }
@@ -1899,6 +1932,7 @@ mod tests {
             cc: Vec::new(),
             bcc: Vec::new(),
             body: body.to_owned(),
+            html: None,
             kind: IncomingKind::Received {
                 from: "Northwind Roofing".into(),
                 addr: "office@northwind.co".into(),
@@ -2162,6 +2196,7 @@ mod tests {
                         cc: Vec::new(),
                         bcc: Vec::new(),
                         body: "Thanks.".into(),
+                        html: None,
                         kind: IncomingKind::Sent {
                             by: None,
                             to: vec!["Northwind".into()],
@@ -2217,6 +2252,9 @@ mod tests {
         let result = store.import(|tx| tx.message(&m));
         assert!(matches!(result, Err(Error::NotFound("thread"))));
         assert!(store.message(&fixtures::id(100)).unwrap().is_none());
+
+        let result = store.import(|tx| tx.html(&fixtures::id(100), "<p>Hi</p>"));
+        assert!(matches!(result, Err(Error::NotFound("message"))));
     }
 
     #[test]
diff --git a/src/views.rs b/src/views.rs
index be8589c..00ac0ea 100644
--- a/src/views.rs
+++ b/src/views.rs
@@ -382,8 +382,12 @@ fn message(p: &Page<'_>, m: &Message, open: bool, selected: bool, account: &Acco
                 @if !copies.is_empty() {
                     span.copies { (copies.join(" · ")) }
                 }
-                span.text x-show="open" x-cloak[!open] { (body::linked(said)) }
-                @if let Some(quoted) = quoted {
+                @if m.has_html {
+                    (html_frame(&m.id, open))
+                } @else {
+                    span.text x-show="open" x-cloak[!open] { (body::linked(said)) }
+                }
+                @if let (Some(quoted), false) = (quoted, m.has_html) {
                     button.quote-btn type="button" x-show="open" x-cloak[!open]
                         "@click"="quoted = !quoted" ":aria-expanded"="quoted"
                         x-text="quoted ? 'Hide quote' : 'Show quote'" { "Show quote" }
@@ -406,6 +410,23 @@ fn message(p: &Page<'_>, m: &Message, open: bool, selected: bool, account: &Acco
     }
 }
 
+/// HTML mail in a frame sandboxed to popups alone: no script, and no
+/// same-origin reach into Docket. Remote images wait for the link,
+/// which reloads the frame (by name) with them allowed; no script is
+/// needed on either side.
+fn html_frame(id: &str, open: bool) -> Markup {
+    let src = format!("/messages/{id}/html");
+    let name = format!("html-{id}");
+    html! {
+        div.html-mail x-show="open" x-cloak[!open] {
+            iframe.html-body name=(name) src=(src) title="Message"
+                sandbox="allow-popups allow-popups-to-escape-sandbox"
+                referrerpolicy="no-referrer" loading="lazy" {}
+            a.images-btn href=(format!("{src}?images=1")) target=(name) { "Show images" }
+        }
+    }
+}
+
 /// A value that opens its menu when clicked. Menus open upward, as in the
 /// design, unless that would run off the top of the pane — measured once
 /// shown, since touch layouts make the options taller.
@@ -540,6 +561,7 @@ mod tests {
             cc: Vec::new(),
             bcc: Vec::new(),
             body: "hi".into(),
+            has_html: false,
             kind: Kind::Sent {
                 by: by.map(str::to_owned),
                 to: vec!["Northwind Roofing".into()],
diff --git a/tests/api.rs b/tests/api.rs
index dc52495..a4171c2 100644
--- a/tests/api.rs
+++ b/tests/api.rs
@@ -233,6 +233,69 @@ async fn thread_view_shows_the_chain_with_values_in_the_gutter() {
     assert!(!body.contains("Read-only"));
 }
 
+#[tokio::test]
+async fn html_mail_renders_in_a_sandboxed_frame() {
+    let addr = spawn().await;
+    let bill = mid(fixtures::BILL);
+    let (_, body) = get(addr, SAM, &format!("/inbox?m={bill}")).await;
+    // The frame may open popups and nothing else; the closed preview
+    // still reads the text.
+    assert!(body.contains(&format!(
+        r#"<iframe class="html-body" name="html-{bill}" src="/messages/{bill}/html" title="Message" sandbox="allow-popups allow-popups-to-escape-sandbox" referrerpolicy="no-referrer" loading="lazy"></iframe>"#
+    )));
+    assert!(body.contains(&format!(
+        r#"href="/messages/{bill}/html?images=1" target="html-{bill}">Show images</a>"#
+    )));
+    assert!(body.contains("Amount due $142.18 by October 21."));
+    // Text-only mail keeps its text body.
+    let (_, body) = get(addr, SAM, &format!("/inbox?m={}", mid(fixtures::WATER))).await;
+    assert!(!body.contains("<iframe"));
+
+    let res = client()
+        .get(format!("http://{addr}/messages/{bill}/html"))
+        .header("Remote-User", SAM)
+        .header("X-User-Slug", "Sam")
+        .send()
+        .await
+        .unwrap();
+    assert_eq!(res.status(), StatusCode::OK);
+    let header = |name: &str| res.headers()[name].to_str().unwrap().to_owned();
+    assert_eq!(header("content-type"), "text/html; charset=utf-8");
+    assert_eq!(header("x-content-type-options"), "nosniff");
+    assert_eq!(header("referrer-policy"), "no-referrer");
+    let csp = header("content-security-policy");
+    assert!(csp.contains("img-src data:;"), "{csp}");
+    assert!(
+        csp.contains("sandbox allow-popups allow-popups-to-escape-sandbox"),
+        "{csp}"
+    );
+    let page = res.text().await.unwrap();
+    assert!(page.contains("$142.18"), "{page}");
+    assert!(!page.contains("<script"), "{page}");
+    assert!(page.contains(r#"target="_blank""#), "{page}");
+
+    let res = client()
+        .get(format!("http://{addr}/messages/{bill}/html?images=1"))
+        .header("Remote-User", SAM)
+        .header("X-User-Slug", "Sam")
+        .send()
+        .await
+        .unwrap();
+    let csp = res.headers()["content-security-policy"].to_str().unwrap();
+    assert!(csp.contains("img-src data: https: http:;"), "{csp}");
+
+    // Text-only and unknown mail have no page; nor does anyone outside.
+    let water = mid(fixtures::WATER);
+    let (status, _) = get(addr, SAM, &format!("/messages/{water}/html")).await;
+    assert_eq!(status, StatusCode::NOT_FOUND);
+    let (status, _) = get(addr, SAM, "/messages/nope/html").await;
+    assert_eq!(status, StatusCode::NOT_FOUND);
+    let res = reqwest::get(format!("http://{addr}/messages/{bill}/html"))
+        .await
+        .unwrap();
+    assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
+}
+
 #[tokio::test]
 async fn read_only_accounts_show_folder_as_plain_text() {
     let addr = spawn().await;
diff --git a/tests/jmap.rs b/tests/jmap.rs
index 4cc6cb8..8e126e8 100644
--- a/tests/jmap.rs
+++ b/tests/jmap.rs
@@ -964,6 +964,46 @@ fn elsewhere(text: &str) -> (Option<String>, String) {
     (None, text.to_owned())
 }
 
+#[tokio::test]
+async fn html_mail_stores_its_html_beside_the_text() {
+    let (world, client, household, mut sync, store, _dir) = household_ready().await;
+    // Text-only mail lists its text part under htmlBody too (RFC 8621
+    // §4.1.4); that's no HTML part.
+    let roofer1 = the(&store, "E-roofer1@chislan.family").unwrap();
+    assert!(!roofer1.has_html);
+    world
+        .lock()
+        .unwrap()
+        .update_email(HOUSEHOLD, "E-roofer1", |mail| {
+            mail["htmlBody"] = json!([{"partId": "p1", "type": "text/plain"}]);
+        });
+
+    // Alternative mail: the text stays the body, the HTML rides along —
+    // and mail imported before HTML was kept picks it up on this sight.
+    world
+        .lock()
+        .unwrap()
+        .update_email(HOUSEHOLD, "E-roofer2", |mail| {
+            mail["htmlBody"] = json!([{"partId": "h1", "type": "text/html"}]);
+            mail["bodyValues"]["h1"] = json!({"value": "<p>Revised: <b>$2,120</b></p>"});
+        });
+    client
+        .poll_once(&household, &mut sync, &store)
+        .await
+        .unwrap();
+
+    let roofer2 = the(&store, "E-roofer2@chislan.family").unwrap();
+    assert!(roofer2.has_html);
+    assert_eq!(roofer2.body, "Revised estimate: $2,120.");
+    assert_eq!(
+        store.html(&roofer2.id).unwrap().as_deref(),
+        Some("<p>Revised: <b>$2,120</b></p>")
+    );
+    let roofer1 = the(&store, "E-roofer1@chislan.family").unwrap();
+    assert!(!roofer1.has_html);
+    assert_eq!(store.html(&roofer1.id).unwrap(), None);
+}
+
 #[tokio::test]
 async fn a_folder_picked_up_elsewhere_refreshes_the_row() {
     let (world, client, household, mut sync, store, _dir) = household_ready().await;