Render HTML emails as plain text using html2text

Fall back to html2text when no text/plain part is available,
converting HTML emails to readable terminal output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Shautvast 2026-02-17 21:45:44 +01:00
parent 3a2ce88ebf
commit d0df411c57
3 changed files with 145 additions and 7 deletions

129
Cargo.lock generated
View file

@ -482,6 +482,16 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "futf"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
dependencies = [
"mac",
"new_debug_unreachable",
]
[[package]] [[package]]
name = "generic-array" name = "generic-array"
version = "0.14.7" version = "0.14.7"
@ -549,6 +559,30 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "html2text"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1637acec3b965bab873352189d887b12c87b4f8d7571f4d185e796be5654ad8"
dependencies = [
"html5ever",
"tendril",
"thiserror 2.0.18",
"unicode-width",
]
[[package]]
name = "html5ever"
version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953cbbe631aae7fc0a112702ad5d3aaf09da38beaf45ea84610d6e1c358f569c"
dependencies = [
"log",
"mac",
"markup5ever",
"match_token",
]
[[package]] [[package]]
name = "iana-time-zone" name = "iana-time-zone"
version = "0.1.65" version = "0.1.65"
@ -762,6 +796,12 @@ dependencies = [
"hashbrown 0.16.1", "hashbrown 0.16.1",
] ]
[[package]]
name = "mac"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]] [[package]]
name = "mac_address" name = "mac_address"
version = "1.1.8" version = "1.1.8"
@ -783,6 +823,28 @@ dependencies = [
"quoted_printable", "quoted_printable",
] ]
[[package]]
name = "markup5ever"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e4cd8c02f18a011991a039855480c64d74291c5792fcc160d55d77dc4de4a39"
dependencies = [
"log",
"tendril",
"web_atoms",
]
[[package]]
name = "match_token"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.116",
]
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.0" version = "2.8.0"
@ -839,6 +901,12 @@ dependencies = [
"tempfile", "tempfile",
] ]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]] [[package]]
name = "nix" name = "nix"
version = "0.29.0" version = "0.29.0"
@ -1103,6 +1171,12 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "precomputed-hash"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]] [[package]]
name = "prettyplease" name = "prettyplease"
version = "0.2.37" version = "0.2.37"
@ -1471,6 +1545,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"crossterm", "crossterm",
"html2text",
"imap", "imap",
"mailparse", "mailparse",
"native-tls", "native-tls",
@ -1491,6 +1566,31 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "string_cache"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared",
"precomputed-hash",
"serde",
]
[[package]]
name = "string_cache_codegen"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
dependencies = [
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
]
[[package]] [[package]]
name = "strsim" name = "strsim"
version = "0.11.1" version = "0.11.1"
@ -1553,6 +1653,17 @@ dependencies = [
"windows-sys", "windows-sys",
] ]
[[package]]
name = "tendril"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
dependencies = [
"futf",
"mac",
"utf-8",
]
[[package]] [[package]]
name = "terminfo" name = "terminfo"
version = "0.9.0" version = "0.9.0"
@ -1763,6 +1874,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]] [[package]]
name = "utf8parse" name = "utf8parse"
version = "0.2.2" version = "0.2.2"
@ -1905,6 +2022,18 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "web_atoms"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
dependencies = [
"phf",
"phf_codegen",
"string_cache",
"string_cache_codegen",
]
[[package]] [[package]]
name = "wezterm-bidi" name = "wezterm-bidi"
version = "0.2.3" version = "0.2.3"

View file

@ -11,4 +11,5 @@ native-tls = "0.2"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
toml = "1.0" toml = "1.0"
chrono = "0.4" chrono = "0.4"
mailparse = "0.15" mailparse = "0.15"
html2text = "0.14"

View file

@ -178,23 +178,31 @@ fn extract_raw_body(fetches: &[imap::types::Fetch]) -> Option<Vec<u8>> {
fn extract_plain_text(raw: &[u8]) -> Result<String, String> { fn extract_plain_text(raw: &[u8]) -> Result<String, String> {
let parsed = mailparse::parse_mail(raw).map_err(|e| e.to_string())?; let parsed = mailparse::parse_mail(raw).map_err(|e| e.to_string())?;
// Try to find a text/plain part // Try text/plain first
if let Some(text) = find_plain_text(&parsed) { if let Some(text) = find_part(&parsed, "text/plain") {
return Ok(text); return Ok(text);
} }
// Fallback: return the body of the top-level message // Fall back to text/html rendered as text
if let Some(html) = find_part(&parsed, "text/html") {
return Ok(html_to_text(&html));
}
// Last resort: top-level body
parsed.get_body().map_err(|e| e.to_string()) parsed.get_body().map_err(|e| e.to_string())
} }
fn find_plain_text(mail: &mailparse::ParsedMail) -> Option<String> { fn find_part(mail: &mailparse::ParsedMail, mime_type: &str) -> Option<String> {
let content_type = mail.ctype.mimetype.to_lowercase(); let content_type = mail.ctype.mimetype.to_lowercase();
if content_type == "text/plain" { if content_type == mime_type {
return mail.get_body().ok(); return mail.get_body().ok();
} }
for part in &mail.subparts { for part in &mail.subparts {
if let Some(text) = find_plain_text(part) { if let Some(text) = find_part(part, mime_type) {
return Some(text); return Some(text);
} }
} }
None None
} }
fn html_to_text(html: &str) -> String {
html2text::from_read(html.as_bytes(), 80).unwrap_or_else(|_| html.to_string())
}