From a2084b3dff8148e740bf78994e256aed54a793fe Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Fri, 7 Aug 2026 01:20:29 +0200 Subject: [PATCH] Recognize comments in the remaining addrparse states 3e29988 added comment handling to the Unquoted and AfterBracketedAddr states and noted that "in general comment support is still lacking". RFC 5322 allows CFWS between any two tokens of an address, but the other states push '(' into whatever string they are accumulating, so a comment ends up in the addr or the display name: addrparse("(ab) x@y.com") -> addr "(ab) x@y.com" addrparse("\"Foo\" (c) ") -> display_name "Foo (c)" Comment bodies containing '<', ',', ':' or ';' were worse than that: they derailed the parse into a hard error. Handle '(' in Initial, AfterQuotedName and NameWithEncodedWord as well. The QuotedName state is deliberately left alone, since a paren inside a quoted-string is qtext (RFC 5322 3.2.4), as is BracketedAddr, whose contents this parser passes through verbatim. --- src/addrparse.rs | 167 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) diff --git a/src/addrparse.rs b/src/addrparse.rs index 23120e9..e578679 100644 --- a/src/addrparse.rs +++ b/src/addrparse.rs @@ -327,6 +327,9 @@ fn addrparse_inner( )); } return Ok(MailAddrList(result)); + } else if c == '(' { + comment_return = Some(AddrParseState::Initial); + state = AddrParseState::Comment; } else { state = AddrParseState::Unquoted; addr = Some(String::new()); @@ -418,6 +421,9 @@ fn addrparse_inner( .collect(), ))); name = None; + } else if c == '(' { + comment_return = Some(AddrParseState::AfterQuotedName); + state = AddrParseState::Comment; } else { // I think technically not valid, but this occurs in real-world corpus, so // handle gracefully @@ -540,6 +546,9 @@ fn addrparse_inner( .collect(), ))); addr = None; + } else if c == '(' { + comment_return = Some(AddrParseState::NameWithEncodedWord); + state = AddrParseState::Comment; } else { addr.as_mut().unwrap().push(c); } @@ -738,6 +747,164 @@ mod tests { ); } + #[test] + fn parse_comments() { + fn single(name: Option<&str>, addr: &str) -> MailAddrList { + MailAddrList(vec![MailAddr::Single( + SingleInfo::new(name.map(String::from), addr.to_string()).unwrap(), + )]) + } + + // A comment before an unbracketed address; the case from issue #65, but leading + // rather than trailing. + assert_eq!(addrparse("(ab) x@y.com").unwrap(), single(None, "x@y.com")); + assert_eq!( + addrparse("(a)(b) x@y.com").unwrap(), + single(None, "x@y.com") + ); + assert_eq!( + addrparse(" (c) x@y.com").unwrap(), + single(None, "x@y.com") + ); + + // ...before the other three ways an address can start. + assert_eq!(addrparse("(c) ").unwrap(), single(None, "x@y.com")); + assert_eq!(addrparse("(c)").unwrap(), single(None, "x@y.com")); + assert_eq!( + addrparse("(c) Foo ").unwrap(), + single(Some("Foo"), "x@y.com") + ); + assert_eq!( + addrparse(r#"(c) "Foo" "#).unwrap(), + single(Some("Foo"), "x@y.com") + ); + + // A comment between a quoted display name and what follows it. + assert_eq!( + addrparse(r#""Foo" (c) "#).unwrap(), + single(Some("Foo"), "x@y.com") + ); + // The doubled space is pre-existing and not comment-specific: master produces it + // for `Foo (c) Bar ` too. + assert_eq!( + addrparse(r#""Foo" (c) Bar "#).unwrap(), + single(Some("Foo Bar"), "x@y.com") + ); + + // Comments in a list, and either side of a group. + assert_eq!( + addrparse("a@b.com, (c) x@y.com").unwrap(), + MailAddrList(vec![ + MailAddr::Single(SingleInfo::new(None, "a@b.com".to_string()).unwrap()), + MailAddr::Single(SingleInfo::new(None, "x@y.com".to_string()).unwrap()), + ]) + ); + assert_eq!( + addrparse("(c) grp: x@y.com;").unwrap(), + MailAddrList(vec![MailAddr::Group(GroupInfo::new( + "grp".to_string(), + vec![SingleInfo::new(None, "x@y.com".to_string()).unwrap()] + ))]) + ); + assert_eq!( + addrparse("grp: (c) x@y.com;").unwrap(), + MailAddrList(vec![MailAddr::Group(GroupInfo::new( + "grp".to_string(), + vec![SingleInfo::new(None, "x@y.com".to_string()).unwrap()] + ))]) + ); + assert_eq!( + addrparse(r#""grp" (c): x@y.com;"#).unwrap(), + MailAddrList(vec![MailAddr::Group(GroupInfo::new( + "grp".to_string(), + vec![SingleInfo::new(None, "x@y.com".to_string()).unwrap()] + ))]) + ); + + // ctext covers everything but "(", ")" and "\", so none of these terminate the + // comment or split the address list. Several of them used to be hard errors. + for body in [ + "a@b", "ab", "a,b", "a:b", "a;b", "a\"b", "", " c ", + ] { + assert_eq!( + addrparse(&format!("({}) x@y.com", body)).unwrap(), + single(None, "x@y.com"), + "comment body {:?}", + body + ); + } + + // A comment is not an address, so a header made only of comments is empty rather + // than an error. + assert_eq!(addrparse("(c)").unwrap(), MailAddrList(vec![])); + assert_eq!(addrparse("x@y.com, (c)").unwrap(), single(None, "x@y.com")); + assert_eq!( + addrparse("grp: x@y.com; (c)").unwrap(), + MailAddrList(vec![MailAddr::Group(GroupInfo::new( + "grp".to_string(), + vec![SingleInfo::new(None, "x@y.com".to_string()).unwrap()] + ))]) + ); + // ...but an unterminated one is still an error, as it already was in the trailing + // position. + assert!(addrparse("(c x@y.com").is_err()); + assert!(addrparse("x@y.com (c").is_err()); + + // Parentheses inside a quoted-string are qtext, not a comment. + assert_eq!( + addrparse(r#""F(o)o" "#).unwrap(), + single(Some("F(o)o"), "x@y.com") + ); + assert_eq!( + addrparse(r#""(c)" "#).unwrap(), + single(Some("(c)"), "x@y.com") + ); + // A stray ")" outside a comment stays put. + assert_eq!( + addrparse("Fo)o ").unwrap(), + single(Some("Fo)o"), "x@y.com") + ); + assert_eq!(addrparse(")x@y.com").unwrap(), single(None, ")x@y.com")); + + // The contents of an angle-addr are still passed through verbatim. + assert_eq!( + addrparse("<(c)x@y.com>").unwrap(), + single(None, "(c)x@y.com") + ); + } + + #[test] + fn parse_comments_with_encoded_words() { + let cases = [ + ("From: =?UTF-8?B?Rm9v?= (c) ", "Foo"), + ("From: (c) =?UTF-8?B?Rm9v?= ", "Foo"), + ( + "From: =?UTF-8?B?Rm9v?= (c) =?UTF-8?B?QmFy?= ", + "Foo Bar", + ), + ("From: \"=?utf-8?q?G=C3=B6tz?= C\" (x) ", "Götz C"), + ]; + for (header, name) in cases { + let (parsed, _) = crate::parse_header(header.as_bytes()).unwrap(); + let addrs = addrparse_header(&parsed).unwrap(); + assert_eq!( + addrs.extract_single_info().unwrap().display_name, + Some(name.to_string()), + "header {:?}", + header + ); + } + + let (parsed, _) = crate::parse_header(b"From: =?UTF-8?B?Z3Jw?= (c) : x@y.com;").unwrap(); + assert_eq!( + addrparse_header(&parsed).unwrap(), + MailAddrList(vec![MailAddr::Group(GroupInfo::new( + "grp".to_string(), + vec![SingleInfo::new(None, "x@y.com".to_string()).unwrap()] + ))]) + ); + } + #[test] fn parse_multi() { assert_eq!(