From 7da9339146a2317e54e5b75b6e52719501a87912 Mon Sep 17 00:00:00 2001 From: Anthony M Date: Sun, 14 Jun 2026 21:12:59 +0200 Subject: [PATCH] SMTP send: fix recipient parsing and non-UTF-8 body handling (#10) * mail: do not split a quoted display name on its comma parse_address_list tracked angle-bracket depth but not quotes, so a recipient like `"Doe, John" ` was split on the comma inside the quoted name, yielding a bogus recipient (`"Doe`) next to the real one. With a real contact named "Last, First" that either gets the whole send rejected by Tuta or delivers to a garbage address. Track the quote state too: inside `"..."`, commas and angle brackets are literal. Tested with a quoted-comma recipient and a plain comma list. * mail: decode non-UTF-8 bodies instead of echoing base64 or QP source When a base64 or quoted-printable body decoded to bytes that were not valid UTF-8 (e.g. a Latin-1 message), the parser fell back to returning the still-encoded source: the recipient saw a wall of base64, or raw =XX sequences. Decode the bytes lossily instead, so the text is readable (non-UTF-8 bytes become the replacement char rather than garbage). Full charset-aware decoding (Content-Type charset via encoding_rs) is a follow-up; this fixes the worst symptom with no new dependency. Tested with a non-UTF-8 base64 body and a non-UTF-8 quoted-printable byte. --- crates/bridge/src/mail/parser.rs | 67 ++++++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 7 deletions(-) diff --git a/crates/bridge/src/mail/parser.rs b/crates/bridge/src/mail/parser.rs index fd75da8..e62f71a 100644 --- a/crates/bridge/src/mail/parser.rs +++ b/crates/bridge/src/mail/parser.rs @@ -134,19 +134,28 @@ fn parse_address_single(raw: &str) -> (String, String) { fn parse_address_list(raw: &str) -> Vec<(String, String)> { let mut result = Vec::new(); let mut depth = 0i32; + let mut in_quotes = false; let mut current = String::new(); for ch in raw.chars() { match ch { - '<' => { + // A quoted display name may contain commas and angle brackets that + // are NOT list separators, e.g. `"Doe, John" `. Track the + // quote state so those stay part of the same entry instead of + // splitting it into bogus recipients. + '"' => { + in_quotes = !in_quotes; + current.push(ch); + } + '<' if !in_quotes => { depth += 1; current.push(ch); } - '>' => { + '>' if !in_quotes => { depth -= 1; current.push(ch); } - ',' if depth == 0 => { + ',' if depth == 0 && !in_quotes => { let trimmed = current.trim().to_string(); if !trimmed.is_empty() { result.push(parse_address_single(&trimmed)); @@ -397,9 +406,11 @@ fn decode_body(body: &str, transfer_encoding: &str, content_type: &str) -> Strin let clean: String = body.chars().filter(|c| !c.is_whitespace()).collect(); base64::engine::general_purpose::STANDARD .decode(&clean) - .ok() - .and_then(|bytes| String::from_utf8(bytes).ok()) - .unwrap_or_else(|| body.to_string()) + // Decode the bytes lossily rather than echoing the raw base64 when + // the payload is not valid UTF-8 (e.g. a Latin-1 body). Returning + // the base64 blob as "the body" was the worst possible fallback. + .map(|bytes| String::from_utf8_lossy(&bytes).into_owned()) + .unwrap_or_else(|_| body.to_string()) } else if transfer_encoding.contains("quoted-printable") { decode_quoted_printable(body) } else { @@ -438,7 +449,9 @@ fn decode_quoted_printable(s: &str) -> String { i += 1; } } - String::from_utf8(result).unwrap_or_else(|_| s.to_string()) + // Use the decoded bytes (lossily) rather than echoing the raw `=XX` + // source when the result is not valid UTF-8. + String::from_utf8_lossy(&result).into_owned() } fn html_escape(s: &str) -> String { @@ -463,6 +476,46 @@ mod tests { assert_eq!(msg.body_html, "

Hi Bob

"); } + #[test] + fn quoted_comma_in_display_name_is_one_recipient() { + let raw = "From: me@tuta.io\r\nTo: \"Doe, John\" , bob@y.com\r\nSubject: t\r\n\r\nbody\r\n"; + let msg = parse_rfc2822(raw); + let addrs: Vec<&str> = msg.to.iter().map(|(_, a)| a.as_str()).collect(); + assert_eq!( + addrs, + vec!["john@x.com", "bob@y.com"], + "a comma inside a quoted display name must not split the recipient" + ); + assert_eq!(msg.to[0].0, "Doe, John", "display name should be preserved"); + } + + #[test] + fn plain_address_list_still_splits_on_commas() { + let raw = "From: me@tuta.io\r\nTo: a@x.com, b@y.com, c@z.com\r\nSubject: t\r\n\r\nbody\r\n"; + let msg = parse_rfc2822(raw); + let addrs: Vec<&str> = msg.to.iter().map(|(_, a)| a.as_str()).collect(); + assert_eq!(addrs, vec!["a@x.com", "b@y.com", "c@z.com"]); + } + + #[test] + fn base64_body_non_utf8_is_not_echoed_as_base64() { + // "caf" + 0xE9 (Latin-1 'é'): valid base64, not valid UTF-8. + let b64 = base64::engine::general_purpose::STANDARD.encode(b"caf\xe9"); + let out = decode_body(&b64, "base64", "text/html"); + assert!(!out.contains(&b64), "must not emit the raw base64 blob"); + assert!( + out.starts_with("caf"), + "decoded text should be readable, got {out:?}" + ); + } + + #[test] + fn qp_non_utf8_byte_is_decoded_not_echoed() { + let out = decode_quoted_printable("caf=E9"); + assert!(!out.contains("=E9"), "QP source must be decoded, not echoed"); + assert!(out.starts_with("caf"), "got {out:?}"); + } + #[test] fn test_parse_multiple_recipients() { let raw = "From: a@b.com\r\nTo: Bob , Charlie \r\nCc: Dave \r\nSubject: Test\r\n\r\nbody";