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
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&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;