From b90dad121d7232de9cf7a8f1eb51460aa2d7806f Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Mon, 4 May 2026 12:41:04 +0530 Subject: [PATCH] fix(composio/gmail): phase out html2md, prefer text/plain MIME part (#1170) Co-authored-by: Claude Opus 4.7 (1M context) --- Cargo.lock | 92 +----------- Cargo.toml | 17 ++- .../composio/providers/gmail/post_process.rs | 106 ++++++++------ .../providers/gmail/post_process_tests.rs | 133 ++++++++++++++++-- 4 files changed, 194 insertions(+), 154 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ef1724a91..99acf669b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1163,7 +1163,7 @@ dependencies = [ "core-foundation-sys 0.8.7", "coreaudio-rs", "dasp_sample", - "jni 0.21.1", + "jni", "js-sys", "libc", "mach2 0.4.3", @@ -2566,34 +2566,6 @@ version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" -[[package]] -name = "html2md" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cff9891f2e0d9048927fbdfc28b11bf378f6a93c7ba70b23d0fbee9af6071b4" -dependencies = [ - "html5ever 0.27.0", - "jni 0.19.0", - "lazy_static", - "markup5ever_rcdom", - "percent-encoding", - "regex", -] - -[[package]] -name = "html5ever" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4" -dependencies = [ - "log", - "mac", - "markup5ever 0.12.1", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "html5ever" version = "0.35.0" @@ -2601,7 +2573,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4" dependencies = [ "log", - "markup5ever 0.35.0", + "markup5ever", "match_token", ] @@ -3151,20 +3123,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "jni" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6df18c2e3db7e453d3c6ac5b3e9d5182664d28788126d39b91f2d1e22b017ec" -dependencies = [ - "cesu8", - "combine", - "jni-sys 0.3.1", - "log", - "thiserror 1.0.69", - "walkdir", -] - [[package]] name = "jni" version = "0.21.1" @@ -3552,20 +3510,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" -[[package]] -name = "markup5ever" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45" -dependencies = [ - "log", - "phf 0.11.3", - "phf_codegen 0.11.3", - "string_cache", - "string_cache_codegen", - "tendril", -] - [[package]] name = "markup5ever" version = "0.35.0" @@ -3577,18 +3521,6 @@ dependencies = [ "web_atoms", ] -[[package]] -name = "markup5ever_rcdom" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edaa21ab3701bfee5099ade5f7e1f84553fd19228cf332f13cd6e964bf59be18" -dependencies = [ - "html5ever 0.27.0", - "markup5ever 0.12.1", - "tendril", - "xml5ever", -] - [[package]] name = "match_token" version = "0.35.0" @@ -4531,7 +4463,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" dependencies = [ - "jni 0.21.1", + "jni", "ndk", "ndk-context", "num-derive", @@ -4568,7 +4500,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openhuman" -version = "0.53.12" +version = "0.53.13" dependencies = [ "aes-gcm", "anyhow", @@ -4600,7 +4532,6 @@ dependencies = [ "hmac 0.12.1", "hostname", "hound", - "html2md", "iana-time-zone", "image", "landlock", @@ -5949,7 +5880,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a6dcd6e9823e177d15460d3cd3a413f38a2beea381f26aca1001c05cd6954ff" dependencies = [ "as_variant", - "html5ever 0.35.0", + "html5ever", "tracing", "wildmatch", ] @@ -6091,7 +6022,7 @@ checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys 0.8.7", - "jni 0.21.1", + "jni", "log", "once_cell", "rustls", @@ -8956,17 +8887,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" -[[package]] -name = "xml5ever" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bbb26405d8e919bc1547a5aa9abc95cbfa438f04844f5fdd9dc7596b748bf69" -dependencies = [ - "log", - "mac", - "markup5ever 0.12.1", -] - [[package]] name = "xxhash-rust" version = "0.8.15" diff --git a/Cargo.toml b/Cargo.toml index 3b9f56d39..00a7ff05e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,13 +25,16 @@ crate-type = ["rlib"] serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" -# Used by composio post-processing to convert Gmail HTML bodies to -# markdown. `html2md` is the canonical pure-Rust crate (no system deps); -# `htmd` (>0.5) is more actively maintained but our usage is one -# function call (`parse_html`) and the migration cost outweighs the -# upside today. Re-evaluate if html2md goes unmaintained or we need -# GFM/table fidelity beyond what html2md emits. -html2md = "0.2" +# (Removed `html2md` dep. dhat-rs profiling on real Gmail inboxes +# showed `html2md::walk` and `html2md::tables::handle` allocating +# ~894 MB peak heap on a 10 KB HTML input from Otter.ai-style emails +# (deeply-nested table-as-layout HTML). Cause: recursive walker holding +# per-frame Vec state across nesting layers + 5 sequential +# `regex::replace_all` passes in `clean_markdown` each producing a +# fresh full-size String. We now use a linear-time tag-and-entity +# stripper (`fast_html_to_text` in +# providers/gmail/post_process.rs) and prefer the email's +# `text/plain` MIME part when available.) reqwest = { version = "0.12", default-features = false, features = ["json", "blocking", "rustls-tls", "native-tls", "stream", "http2", "multipart", "socks"] } tokio = { version = "1", features = ["full", "sync"] } once_cell = "1.19" diff --git a/src/openhuman/composio/providers/gmail/post_process.rs b/src/openhuman/composio/providers/gmail/post_process.rs index 9e0b9b7a8..20f9f4712 100644 --- a/src/openhuman/composio/providers/gmail/post_process.rs +++ b/src/openhuman/composio/providers/gmail/post_process.rs @@ -49,11 +49,6 @@ use once_cell::sync::Lazy; use regex::Regex; use serde_json::{json, Map, Value}; -/// `html2md` is fine for normal transactional emails, but large marketing -/// HTML can explode CPU / latency. Above this size we switch to a bounded -/// fast-strip path that preserves readable text and link labels. -const MAX_HTML2MD_INPUT_BYTES: usize = 24_000; - static HTML_NOISE_BLOCK_RE: Lazy = Lazy::new(|| Regex::new(r"(?is)").expect("valid html comment regex")); @@ -194,14 +189,27 @@ fn pick_header(msg: &Map, name: &str) -> Option { /// Extract the best body representation and return it as markdown. /// Walks `payload.parts[]` recursively — Gmail nests multipart/alternative /// inside multipart/mixed when attachments are present. +/// +/// Preference order: +/// 1. `text/plain` — author-provided plaintext fallback. Standard MIME +/// multipart/alternative. Bypasses html2md entirely. For LLM +/// extraction + retrieval embedding, plaintext is generally the +/// better input: less noise (no tracking URLs, inline CSS, table +/// formatting artifacts), zero conversion cost, no allocator +/// pressure from `html2md`'s pathological walk over nested HTML +/// tables (verified GB-scale heap peaks via dhat-rs profiling). +/// 2. `text/html` → html2md — only used when the email shipped no +/// plaintext part (rare; some HTML-only marketing senders). +/// 3. Top-level `messageText` (Composio convenience field) — html or +/// plaintext depending on what the source had. fn extract_markdown_body(msg: &Map) -> String { if let Some(parts) = msg.get("payload").and_then(|p| p.get("parts")) { - if let Some(html) = find_decoded_part(parts, "text/html") { - return html_email_to_markdown(&html); - } if let Some(text) = find_decoded_part(parts, "text/plain") { return normalize_markdownish_text(&text); } + if let Some(html) = find_decoded_part(parts, "text/html") { + return html_email_to_markdown(&html); + } } // Fallback: top-level decoded plain text (Composio convenience field). if let Some(text) = msg.get("messageText").and_then(|v| v.as_str()) { @@ -210,51 +218,47 @@ fn extract_markdown_body(msg: &Map) -> String { text_bytes = text.len(), "[composio:gmail][post-process] messageText looked like html, using fast html strip" ); - return fast_html_email_to_markdown(text); + return html_email_to_markdown(text); } return normalize_markdownish_text(text); } String::new() } -/// Convert raw HTML email into markdown-ish text that is safe and cheap for -/// LLM consumption. Small / normal HTML uses `html2md`; oversized HTML falls -/// back to a linear-time stripper so one pathological newsletter cannot stall -/// the whole tool call. +/// Convert raw HTML email into markdown-ish text suitable for LLM +/// consumption. +/// +/// Pipeline: +/// 1. `strip_html_noise_blocks` — remove `