Extract plain text from emails using mailparse

Fetch full raw email and parse MIME structure to find the
text/plain part, removing MIME headers and boundaries from
the preview pane.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Shautvast 2026-02-17 21:38:46 +01:00
parent bdb6dce672
commit 3a2ce88ebf
3 changed files with 77 additions and 9 deletions

43
Cargo.lock generated
View file

@ -150,6 +150,16 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "charset"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1f927b07c74ba84c7e5fe4db2baeb3e996ab2688992e39ac68ce3220a677c7e"
dependencies = [
"base64 0.22.1",
"encoding_rs",
]
[[package]]
name = "chrono"
version = "0.4.43"
@ -292,6 +302,12 @@ dependencies = [
"syn 2.0.116",
]
[[package]]
name = "data-encoding"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
[[package]]
name = "deltae"
version = "0.3.2"
@ -354,6 +370,15 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@ -747,6 +772,17 @@ dependencies = [
"winapi",
]
[[package]]
name = "mailparse"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3da03d5980411a724e8aaf7b61a7b5e386ec55a7fb49ee3d0ff79efc7e5e7c7e"
dependencies = [
"charset",
"data-encoding",
"quoted_printable",
]
[[package]]
name = "memchr"
version = "2.8.0"
@ -1095,6 +1131,12 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "quoted_printable"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73"
[[package]]
name = "r-efi"
version = "5.3.0"
@ -1430,6 +1472,7 @@ dependencies = [
"chrono",
"crossterm",
"imap",
"mailparse",
"native-tls",
"ratatui",
"serde",

View file

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

View file

@ -152,25 +152,49 @@ pub(crate) fn fetch_body(
) -> Result<String, String> {
let s = ensure_session(session, config)?;
let range = seq.to_string();
let body = match s {
let raw = match s {
ImapSession::Plain(s) => {
let messages = s
.fetch(&range, "BODY.PEEK[TEXT]")
.fetch(&range, "BODY.PEEK[]")
.map_err(|e| e.to_string())?;
extract_body(&messages)
extract_raw_body(&messages)
}
ImapSession::Tls(s) => {
let messages = s
.fetch(&range, "BODY.PEEK[TEXT]")
.fetch(&range, "BODY.PEEK[]")
.map_err(|e| e.to_string())?;
extract_body(&messages)
extract_raw_body(&messages)
}
};
body.ok_or_else(|| "No body found".to_string())
let raw = raw.ok_or_else(|| "No body found".to_string())?;
extract_plain_text(&raw)
}
fn extract_body(fetches: &[imap::types::Fetch]) -> Option<String> {
fn extract_raw_body(fetches: &[imap::types::Fetch]) -> Option<Vec<u8>> {
fetches.first().and_then(|f| {
f.text().map(|b| String::from_utf8_lossy(b).to_string())
f.body().map(|b| b.to_vec())
})
}
fn extract_plain_text(raw: &[u8]) -> Result<String, String> {
let parsed = mailparse::parse_mail(raw).map_err(|e| e.to_string())?;
// Try to find a text/plain part
if let Some(text) = find_plain_text(&parsed) {
return Ok(text);
}
// Fallback: return the body of the top-level message
parsed.get_body().map_err(|e| e.to_string())
}
fn find_plain_text(mail: &mailparse::ParsedMail) -> Option<String> {
let content_type = mail.ctype.mimetype.to_lowercase();
if content_type == "text/plain" {
return mail.get_body().ok();
}
for part in &mail.subparts {
if let Some(text) = find_plain_text(part) {
return Some(text);
}
}
None
}