From 3e25d9b6e2e8c277564eae744ba348139f377291 Mon Sep 17 00:00:00 2001 From: DefinitelyNotJosh1 Date: Tue, 11 Aug 2026 18:18:19 -0700 Subject: [PATCH] fix(gmail): preserve rich message bodies in read --- .changeset/fix-gmail-read-body-extraction.md | 5 + Cargo.lock | 136 ++++++++- crates/google-workspace-cli/Cargo.toml | 1 + .../src/helpers/gmail/mod.rs | 260 +++++++++++++++++- 4 files changed, 390 insertions(+), 12 deletions(-) create mode 100644 .changeset/fix-gmail-read-body-extraction.md diff --git a/.changeset/fix-gmail-read-body-extraction.md b/.changeset/fix-gmail-read-body-extraction.md new file mode 100644 index 000000000..e76d6c12a --- /dev/null +++ b/.changeset/fix-gmail-read-body-extraction.md @@ -0,0 +1,5 @@ +--- +"@googleworkspace/cli": patch +--- + +Preserve complete, structured Gmail message content when `+read` converts HTML bodies to text. diff --git a/Cargo.lock b/Cargo.lock index b7e7baecd..9ec750958 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -929,6 +929,7 @@ dependencies = [ "futures-util", "google-workspace", "hostname", + "html2text", "iana-time-zone", "keyring", "mail-builder", @@ -1016,6 +1017,28 @@ dependencies = [ "windows-link", ] +[[package]] +name = "html2text" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c97eea9d3e6524d8d2a4643ce0e3135500c4c7ca1d02393a485f871bef69d8" +dependencies = [ + "html5ever", + "tendril", + "thiserror 2.0.18", + "unicode-width", +] + +[[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.4.0" @@ -1487,6 +1510,17 @@ dependencies = [ "gethostname", ] +[[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" @@ -1553,6 +1587,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "nix" version = "0.29.0" @@ -1750,16 +1790,36 @@ dependencies = [ "phf_shared 0.12.1", ] +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared 0.13.1", + "serde", +] + [[package]] name = "phf_codegen" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ - "phf_generator", + "phf_generator 0.11.3", "phf_shared 0.11.3", ] +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + [[package]] name = "phf_generator" version = "0.11.3" @@ -1770,13 +1830,23 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + [[package]] name = "phf_macros" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" dependencies = [ - "phf_generator", + "phf_generator 0.11.3", "phf_shared 0.11.3", "proc-macro2", "quote", @@ -1803,6 +1873,15 @@ dependencies = [ "siphasher", ] +[[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" @@ -1851,6 +1930,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "prettyplease" version = "0.2.37" @@ -2552,6 +2637,30 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[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 0.13.1", + "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 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", +] + [[package]] name = "strsim" version = "0.11.1" @@ -2640,6 +2749,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 = "terminfo" version = "0.9.0" @@ -2649,7 +2767,7 @@ dependencies = [ "fnv", "nom", "phf 0.11.3", - "phf_codegen", + "phf_codegen 0.11.3", ] [[package]] @@ -3321,6 +3439,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 0.13.1", + "phf_codegen 0.13.1", + "string_cache", + "string_cache_codegen", +] + [[package]] name = "wezterm-bidi" version = "0.2.3" diff --git a/crates/google-workspace-cli/Cargo.toml b/crates/google-workspace-cli/Cargo.toml index 058b109e6..db91b7cf7 100644 --- a/crates/google-workspace-cli/Cargo.toml +++ b/crates/google-workspace-cli/Cargo.toml @@ -38,6 +38,7 @@ clap = { version = "4", features = ["derive", "string"] } dirs = "5" dotenvy = "0.15" hostname = "0.4" +html2text = "0.17.1" reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls-native-roots", "socks"], default-features = false } rand = "0.8" serde = { version = "1", features = ["derive"] } diff --git a/crates/google-workspace-cli/src/helpers/gmail/mod.rs b/crates/google-workspace-cli/src/helpers/gmail/mod.rs index caeb8b6b0..94a23dfb2 100644 --- a/crates/google-workspace-cli/src/helpers/gmail/mod.rs +++ b/crates/google-workspace-cli/src/helpers/gmail/mod.rs @@ -38,6 +38,7 @@ pub(super) use mail_builder::headers::address::Address as MbAddress; pub(super) use serde::Serialize; pub(super) use serde_json::{json, Value}; use std::future::Future; +use std::io::Read; use std::pin::Pin; pub struct GmailHelper; @@ -176,10 +177,10 @@ impl OriginalPart { /// A parsed Gmail message fetched via the API, used as context for reply/forward. /// /// `from` is always populated — `parse_original_message` returns an error when -/// `From` is missing. `body_text` always has a value — it falls back to the -/// message snippet when no `text/plain` MIME part is found. Semantically optional -/// fields (`cc`, `reply_to`, `date`, `body_html`) use `Option` so the compiler -/// enforces absence checks. +/// `From` is missing. `body_text` always has a value — HTML-only messages are +/// rendered as readable text, with the message snippet as a final fallback. +/// Semantically optional fields (`cc`, `reply_to`, `date`, `body_html`) use +/// `Option` so the compiler enforces absence checks. #[derive(Default, Serialize)] pub(super) struct OriginalMessage { pub thread_id: Option, @@ -334,7 +335,12 @@ fn parse_original_message(msg: &Value) -> Result { .map(extract_payload_contents) .unwrap_or_default(); - let body_text = extracted_text.unwrap_or(snippet); + let converted_html = body_html.as_deref().and_then(convert_html_to_readable_text); + let body_text = select_readable_body( + extracted_text.as_deref(), + converted_html.as_deref(), + &snippet, + ); // Parse references: split on whitespace and strip any angle brackets, producing bare IDs let references = parsed_headers @@ -364,6 +370,55 @@ fn parse_original_message(msg: &Value) -> Result { }) } +/// Convert HTML to readable plain text without dropping link targets or document structure. +/// A wide render width minimizes presentation-only wrapping while retaining block breaks. +fn convert_html_to_readable_text(html: &str) -> Option { + render_html_to_readable_text(html.as_bytes()) +} + +fn render_html_to_readable_text(reader: impl Read) -> Option { + match html2text::from_read(reader, 120) { + Ok(text) => Some(text.trim().to_string()).filter(|text| !text.is_empty()), + Err(e) => { + eprintln!( + "Warning: text/html body could not be rendered as text: {}", + sanitize_for_terminal(&e.to_string()) + ); + None + } + } +} + +/// Prefer a converted HTML alternative only when it contains substantially more readable +/// content than the supplied plain-text alternative. This avoids replacing a good plain part +/// for minor formatting differences while recovering messages whose plain part is only a stub. +fn select_readable_body( + plain_text: Option<&str>, + converted_html: Option<&str>, + snippet: &str, +) -> String { + let plain_text = plain_text.filter(|text| !text.trim().is_empty()); + let converted_html = converted_html.filter(|text| !text.trim().is_empty()); + + match (plain_text, converted_html) { + (None, Some(html)) => html.to_string(), + (Some(plain), Some(html)) if html_is_substantially_richer(plain, html) => html.to_string(), + (Some(plain), _) => plain.to_string(), + (None, None) => snippet.to_string(), + } +} + +fn html_is_substantially_richer(plain_text: &str, converted_html: &str) -> bool { + const MIN_EXTRA_CHARS: usize = 80; + const MIN_RATIO: usize = 2; + + let plain_chars = plain_text.trim().chars().count(); + let html_chars = converted_html.trim().chars().count(); + + html_chars >= plain_chars.saturating_mul(MIN_RATIO) + && html_chars.saturating_sub(plain_chars) >= MIN_EXTRA_CHARS +} + pub(super) async fn fetch_message_metadata( client: &reqwest::Client, token: &str, @@ -2225,8 +2280,8 @@ mod tests { } #[test] - fn test_parse_original_message_snippet_fallback() { - // When only text/html is present (no text/plain), body_text falls back to snippet + fn test_parse_original_message_html_fallback() { + // When only text/html is present, body_text is rendered from the complete HTML body. let msg = json!({ "threadId": "t1", "snippet": "Snippet fallback text", @@ -2240,7 +2295,7 @@ mod tests { } }); let original = parse_original_message(&msg).unwrap(); - assert_eq!(original.body_text, "Snippet fallback text"); + assert_eq!(original.body_text, "HTML only"); assert_eq!(original.body_html.unwrap(), "

HTML only

"); } @@ -2423,7 +2478,7 @@ mod tests { original.references, vec!["ref-1@example.com", "ref-2@example.com"] ); - assert_eq!(original.body_text, "Snippet fallback"); + assert_eq!(original.body_text, "HTML only"); assert_eq!(original.body_html.as_deref(), Some("

HTML only

")); } @@ -2460,6 +2515,193 @@ mod tests { assert_eq!(original.body_html.as_deref(), Some("

Rich HTML body

")); } + fn synthetic_message_with_payload(snippet: &str, mut payload: Value) -> Value { + payload.as_object_mut().unwrap().insert( + "headers".to_string(), + json!([ + { "name": "From", "value": "sender@example.com" }, + { "name": "To", "value": "recipient@example.com" }, + { "name": "Message-ID", "value": "" } + ]), + ); + + json!({ + "threadId": "synthetic-thread", + "snippet": snippet, + "payload": payload, + }) + } + + #[test] + fn test_read_prefers_substantially_richer_html_alternative() { + let payload = json!({ + "mimeType": "multipart/alternative", + "parts": [ + { + "mimeType": "text/plain", + "body": { "data": URL_SAFE.encode("View this message online.") } + }, + { + "mimeType": "text/html", + "body": { + "data": URL_SAFE.encode( + "

Your synthetic quarterly report is ready.

\ +

Revenue increased across every region, and the detailed \ + analysis contains the unique marker RICH-ALTERNATIVE-CONTENT.

" + ) + } + } + ] + }); + let msg = synthetic_message_with_payload("unused snippet", payload); + + let original = parse_original_message(&msg).unwrap(); + + assert!(original.body_text.contains("RICH-ALTERNATIVE-CONTENT")); + } + + #[test] + fn test_read_uses_html_alternative_when_plain_part_is_empty() { + let payload = json!({ + "mimeType": "multipart/alternative", + "parts": [ + { + "mimeType": "text/plain", + "body": { "data": URL_SAFE.encode("") } + }, + { + "mimeType": "text/html", + "body": { "data": URL_SAFE.encode("

Complete synthetic HTML body

") } + } + ] + }); + let msg = synthetic_message_with_payload("incomplete snippet", payload); + + let original = parse_original_message(&msg).unwrap(); + + assert_eq!(original.body_text, "Complete synthetic HTML body"); + } + + #[test] + fn test_read_keeps_comparable_plain_text_alternative() { + let payload = json!({ + "mimeType": "multipart/alternative", + "parts": [ + { + "mimeType": "text/plain", + "body": { "data": URL_SAFE.encode("Preferred plain wording") } + }, + { + "mimeType": "text/html", + "body": { "data": URL_SAFE.encode("

Similar HTML wording

") } + } + ] + }); + let msg = synthetic_message_with_payload("unused snippet", payload); + + let original = parse_original_message(&msg).unwrap(); + + assert_eq!(original.body_text, "Preferred plain wording"); + } + + #[test] + fn test_html_conversion_empty_and_read_error_fall_back() { + struct FailingReader; + + impl std::io::Read for FailingReader { + fn read(&mut self, _buf: &mut [u8]) -> std::io::Result { + Err(std::io::Error::other("synthetic read failure")) + } + } + + assert!(convert_html_to_readable_text("

").is_none()); + assert!(render_html_to_readable_text(FailingReader).is_none()); + } + + #[test] + fn test_read_html_only_body_is_not_replaced_by_truncated_snippet() { + let long_body = format!( + "

{}

NON-TRUNCATED-TAIL

", + "Synthetic long-form message content. ".repeat(120) + ); + let payload = json!({ + "mimeType": "text/html", + "body": { "data": URL_SAFE.encode(&long_body) } + }); + let msg = synthetic_message_with_payload( + "Synthetic long-form message content. Synthetic long-form message con", + payload, + ); + + let original = parse_original_message(&msg).unwrap(); + + assert!(original.body_text.len() > 3_200); + assert!(original.body_text.contains("NON-TRUNCATED-TAIL")); + } + + #[test] + fn test_read_html_only_body_preserves_anchor_href() { + let payload = json!({ + "mimeType": "text/html", + "body": { + "data": URL_SAFE.encode( + "

\ + Accept invitation

" + ) + } + }); + let msg = synthetic_message_with_payload("Accept invitation", payload); + + let original = parse_original_message(&msg).unwrap(); + + assert!(original + .body_text + .contains("https://example.test/invitations/synthetic-token")); + } + + #[test] + fn test_read_html_only_body_decodes_entities() { + let payload = json!({ + "mimeType": "text/html", + "body": { + "data": URL_SAFE.encode("

Tom & Jerry's synthetic report

") + } + }); + let msg = synthetic_message_with_payload("Tom & Jerry's synthetic report", payload); + + let original = parse_original_message(&msg).unwrap(); + + assert!(original + .body_text + .contains("Tom & Jerry's synthetic report")); + } + + #[test] + fn test_read_html_only_body_preserves_block_line_breaks() { + let payload = json!({ + "mimeType": "text/html", + "body": { + "data": URL_SAFE.encode( + "

First synthetic paragraph

Second synthetic block
" + ) + } + }); + let msg = synthetic_message_with_payload( + "First synthetic paragraphSecond synthetic block", + payload, + ); + + let original = parse_original_message(&msg).unwrap(); + let first_end = original + .body_text + .find("First synthetic paragraph") + .unwrap() + + "First synthetic paragraph".len(); + let second_start = original.body_text.find("Second synthetic block").unwrap(); + + assert!(original.body_text[first_end..second_start].contains('\n')); + } + #[test] fn test_resolve_send_method_finds_gmail_send_method() { let mut doc = crate::discovery::RestDescription::default();