jmap: convert HTML bodies when plain parts run blank
Docket renders bodies as escaped text, so the HTML part converts rather
than rendering as sanitized markup.

Assisted-by: GLM-5.3 via pi
change lntqpwoolnwspqynsvuprvqqmtrmznpq
commit b2b8675d038e1f348cb52df107d646fe3549e569
author Alpha Chen <alpha@kejadlen.dev>
date
parent konknnns
diff --git a/Cargo.lock b/Cargo.lock
index d7475e9..96cc2e7 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -415,6 +415,7 @@ dependencies = [
  "clap",
  "fs-err",
  "hegeltest",
+ "html2text",
  "jiff",
  "kdl",
  "maud",
@@ -673,6 +674,28 @@ dependencies = [
  "syn 2.0.119",
 ]
 
+[[package]]
+name = "html2text"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1c97eea9d3e6524d8d2a4643ce0e3135500c4c7ca1d02393a485f871bef69d8"
+dependencies = [
+ "html5ever",
+ "tendril",
+ "thiserror",
+ "unicode-width 0.2.2",
+]
+
+[[package]]
+name = "html5ever"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
+dependencies = [
+ "log",
+ "markup5ever",
+]
+
 [[package]]
 name = "http"
 version = "1.5.0"
@@ -1158,6 +1181,17 @@ version = "0.1.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
 
+[[package]]
+name = "markup5ever"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
+dependencies = [
+ "log",
+ "tendril",
+ "web_atoms",
+]
+
 [[package]]
 name = "matchers"
 version = "0.2.0"
@@ -1265,6 +1299,12 @@ version = "1.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
 
+[[package]]
+name = "new_debug_unreachable"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
+
 [[package]]
 name = "nu-ansi-term"
 version = "0.50.3"
@@ -1345,6 +1385,45 @@ version = "2.3.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
 
+[[package]]
+name = "phf"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
+dependencies = [
+ "phf_shared",
+ "serde",
+]
+
+[[package]]
+name = "phf_codegen"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
+dependencies = [
+ "fastrand",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
+dependencies = [
+ "siphasher",
+]
+
 [[package]]
 name = "pin-project-lite"
 version = "0.2.17"
@@ -1383,6 +1462,12 @@ dependencies = [
  "zerovec",
 ]
 
+[[package]]
+name = "precomputed-hash"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
+
 [[package]]
 name = "proc-macro2"
 version = "1.0.107"
@@ -1890,6 +1975,12 @@ version = "0.1.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
 
+[[package]]
+name = "siphasher"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
+
 [[package]]
 name = "slab"
 version = "0.4.12"
@@ -1930,6 +2021,30 @@ version = "1.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
 
+[[package]]
+name = "string_cache"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
+dependencies = [
+ "new_debug_unreachable",
+ "parking_lot",
+ "phf_shared",
+ "precomputed-hash",
+]
+
+[[package]]
+name = "string_cache_codegen"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+]
+
 [[package]]
 name = "strsim"
 version = "0.11.1"
@@ -2039,6 +2154,15 @@ dependencies = [
  "windows-sys 0.61.2",
 ]
 
+[[package]]
+name = "tendril"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"
+dependencies = [
+ "new_debug_unreachable",
+]
+
 [[package]]
 name = "terminal_size"
 version = "0.4.4"
@@ -2455,6 +2579,18 @@ dependencies = [
  "wasm-bindgen",
 ]
 
+[[package]]
+name = "web_atoms"
+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",
+]
+
 [[package]]
 name = "webpki-root-certs"
 version = "1.0.9"
diff --git a/Cargo.toml b/Cargo.toml
index a3220a6..f3faa63 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -18,6 +18,7 @@ axum = "*"
 camino = "*"
 clap = { version = "*", features = ["derive", "env"] }
 fs-err = "*"
+html2text = "*"
 jiff = { version = "*", features = ["serde"] }
 kdl = "*"
 maud = { version = "*", features = ["axum"] }
diff --git a/src/jmap.rs b/src/jmap.rs
index 95503f0..b6f48f5 100644
--- a/src/jmap.rs
+++ b/src/jmap.rs
@@ -331,23 +331,41 @@ struct Email {
     pub cc: Option<Vec<EmailAddress>>,
     pub bcc: Option<Vec<EmailAddress>>,
     pub text_body: Option<Vec<BodyPart>>,
+    pub html_body: Option<Vec<BodyPart>>,
     pub body_values: Option<BTreeMap<String, BodyValue>>,
 }
 
-/// The message's text: its first plain-text part, falling back to the
-/// server's preview when there isn't one.
+/// The message's text: its first plain-text part, then its first HTML
+/// 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 {
-    if let (Some(parts), Some(values)) = (&email.text_body, &email.body_values) {
-        for part in parts
+    let first = |parts: Option<&[BodyPart]>, media_type: &str| {
+        parts
+            .unwrap_or_default()
             .iter()
-            .filter(|part| part.media_type.starts_with("text/plain"))
-        {
-            if let Some(value) = values.get(&part.part_id) {
-                return value.value.clone();
-            }
-        }
+            .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") {
+        return plain.into();
+    }
+    if let Some(html) = first(email.html_body.as_deref(), "text/html") {
+        // 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());
     }
-    email.preview.clone().unwrap_or_default()
+    email
+        .preview
+        .as_deref()
+        .filter(|preview| !preview.trim().is_empty())
+        .map_or_else(|| "(no body)".into(), str::to_owned)
 }
 
 /// Shapes a fetched email for the store. `None` skips a senderless
@@ -618,6 +636,8 @@ struct GetEmails<'a> {
     properties: &'static [EmailProperty],
     body_properties: &'static [BodyProperty],
     fetch_text_body_values: bool,
+    #[serde(rename = "fetchHTMLBodyValues")]
+    fetch_html_body_values: bool,
 }
 
 #[derive(Debug, Serialize)]
@@ -636,6 +656,7 @@ enum EmailProperty {
     Bcc,
     Preview,
     TextBody,
+    HtmlBody,
     BodyValues,
 }
 
@@ -653,6 +674,7 @@ const EMAIL_PROPERTIES: &[EmailProperty] = &[
     EmailProperty::Bcc,
     EmailProperty::Preview,
     EmailProperty::TextBody,
+    EmailProperty::HtmlBody,
     EmailProperty::BodyValues,
 ];
 
@@ -810,6 +832,7 @@ impl Client {
                     properties: EMAIL_PROPERTIES,
                     body_properties: BODY_PROPERTIES,
                     fetch_text_body_values: true,
+                    fetch_html_body_values: true,
                 },
             )
             .await?;
@@ -1319,6 +1342,7 @@ mod tests {
             properties: EMAIL_PROPERTIES,
             body_properties: BODY_PROPERTIES,
             fetch_text_body_values: true,
+            fetch_html_body_values: true,
         })
         .unwrap();
         assert_eq!(
@@ -1326,9 +1350,9 @@ mod tests {
             json!({"accountId": "a", "ids": ["e1"],
                    "properties": ["id", "threadId", "messageId", "mailboxIds",
                        "receivedAt", "sentAt", "subject", "from", "to", "cc", "bcc",
-                       "preview", "textBody", "bodyValues"],
+                       "preview", "textBody", "htmlBody", "bodyValues"],
                    "bodyProperties": ["partId", "type"],
-                   "fetchTextBodyValues": true})
+                   "fetchTextBodyValues": true, "fetchHTMLBodyValues": true})
         );
 
         let threads = serde_json::to_value(GetThreads {
@@ -1645,7 +1669,7 @@ mod tests {
     }
 
     #[test]
-    fn the_body_is_the_first_plain_text_part() {
+    fn the_body_falls_through_plain_html_then_preview() {
         let mut mail = email(&[("M-in", true)]);
         mail.text_body = Some(vec![BodyPart {
             part_id: "p1".into(),
@@ -1659,12 +1683,31 @@ mod tests {
         )]));
         assert_eq!(body_of(&mail), "Buses return at 4:15.");
 
-        // HTML-only mail falls back to the preview.
-        mail.text_body = Some(vec![BodyPart {
-            part_id: "p1".into(),
+        // An empty plain part falls through to the HTML part instead of
+        // stopping at the blank.
+        mail.html_body = Some(vec![BodyPart {
+            part_id: "h1".into(),
             media_type: "text/html".into(),
         }]);
-        assert_eq!(body_of(&mail), "A schedule update…");
+        let values = mail.body_values.as_mut().unwrap();
+        values.insert(
+            "h1".into(),
+            BodyValue {
+                value: "<p>Buses return at 4:15.</p>".into(),
+            },
+        );
+        values.insert(
+            "p1".into(),
+            BodyValue {
+                value: "  \n".into(),
+            },
+        );
+        assert_eq!(body_of(&mail), "Buses return at 4:15.\n");
+
+        // HTML-only mail converts instead of showing the truncated
+        // preview.
+        mail.text_body = None;
+        assert_eq!(body_of(&mail), "Buses return at 4:15.\n");
 
         // A plain part with no fetched body value also falls through.
         mail.text_body = Some(vec![
@@ -1677,7 +1720,23 @@ mod tests {
                 media_type: "text/plain".into(),
             },
         ]);
+        mail.body_values.as_mut().unwrap().insert(
+            "p1".into(),
+            BodyValue {
+                value: "Buses return at 4:15.".into(),
+            },
+        );
         assert_eq!(body_of(&mail), "Buses return at 4:15.");
+
+        // With no parts at all, the preview carries the body.
+        mail.text_body = None;
+        mail.html_body = None;
+        assert_eq!(body_of(&mail), "A schedule update…");
+
+        // A message with no text anywhere says so instead of rendering
+        // blank.
+        mail.preview = Some("  \n".into());
+        assert_eq!(body_of(&mail), "(no body)");
     }
 
     #[test]