mirror of
https://github.com/spartanz51/tutabridge.git
synced 2026-06-24 10:54:32 +02:00
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" <john@x.com>` 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.
This commit is contained in:
@@ -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" <j@x.com>`. 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, "<p>Hi Bob</p>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quoted_comma_in_display_name_is_one_recipient() {
|
||||
let raw = "From: me@tuta.io\r\nTo: \"Doe, John\" <john@x.com>, 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 <bob@x.com>, Charlie <charlie@x.com>\r\nCc: Dave <dave@x.com>\r\nSubject: Test\r\n\r\nbody";
|
||||
|
||||
Reference in New Issue
Block a user