use super::*;
fn parse_ok(input: &str) -> Parsed<'_> {
parse(input, Strictness::Standard, false, false)
.unwrap_or_else(|e| panic!("failed to parse '{input}': {e}"))
}
fn parse_ok_lax(input: &str) -> Parsed<'_> {
parse(input, Strictness::Lax, false, false)
.unwrap_or_else(|e| panic!("failed to parse '{input}': {e}"))
}
fn parse_err(input: &str) -> Error {
parse(input, Strictness::Standard, false, false)
.expect_err(&format!("expected error for '{input}'"))
}
#[test]
fn simple_address() {
let p = parse_ok("user@example.com");
assert_eq!(p.local_part.as_str(p.input), "user");
assert_eq!(p.domain.as_str(p.input), "example.com");
}
#[test]
fn subaddress_preserved() {
let p = parse_ok("user+tag@example.com");
assert_eq!(p.local_part.as_str(p.input), "user+tag");
}
#[test]
fn dotted_local() {
let p = parse_ok("first.last@example.com");
assert_eq!(p.local_part.as_str(p.input), "first.last");
}
#[test]
fn utf8_local() {
let p = parse_ok("дмитрий@example.com");
assert_eq!(p.local_part.as_str(p.input), "дмитрий");
}
#[test]
fn utf8_domain() {
let p = parse_ok("user@münchen.de");
assert_eq!(p.domain.as_str(p.input), "münchen.de");
}
#[test]
fn quoted_local_part() {
let p = parse_ok("\"user@name\"@example.com");
assert_eq!(p.local_part.as_str(p.input), "\"user@name\"");
}
#[test]
fn quoted_local_with_spaces() {
let p = parse_ok("\"user name\"@example.com");
assert_eq!(p.local_part.as_str(p.input), "\"user name\"");
}
#[test]
fn empty_input() {
let e = parse_err("");
assert_eq!(e.kind(), &ErrorKind::Empty);
}
#[test]
fn no_at_sign() {
let e = parse_err("userexample.com");
assert_eq!(e.kind(), &ErrorKind::MissingAtSign);
}
#[test]
fn empty_local() {
let e = parse_err("@example.com");
assert_eq!(e.kind(), &ErrorKind::EmptyLocalPart);
}
#[test]
fn empty_domain() {
let e = parse_err("user@");
assert_eq!(e.kind(), &ErrorKind::EmptyDomain);
}
#[test]
fn trailing_dot_in_local_part_is_not_missing_at_sign() {
let e = parse_err("user.@example.com");
assert_ne!(e.kind(), &ErrorKind::MissingAtSign);
}
#[test]
fn obs_local_part_quoted_first_word() {
let p = parse("\"a\".b@example.com", Strictness::Lax, false, false).unwrap_or_else(|e| {
panic!("Lax must accept obs-local-part starting with quoted word: {e}")
});
assert_eq!(p.local_part.as_str(p.input), "\"a\".b");
assert_eq!(p.domain.as_str(p.input), "example.com");
}
#[test]
fn obs_local_part_rejected_in_standard() {
let e = parse("a.\"b\"@example.com", Strictness::Standard, false, false)
.expect_err("expected obs-local-part to be rejected in Standard strictness");
assert_ne!(e.kind(), &ErrorKind::MissingAtSign);
}
#[test]
fn obs_local_part_accepted_in_lax() {
let p = parse("a.\"b\"@example.com", Strictness::Lax, false, false)
.unwrap_or_else(|e| panic!("parse failed in Lax strictness: {e}"));
assert_eq!(p.local_part.as_str(p.input), "a.\"b\"");
assert_eq!(p.domain.as_str(p.input), "example.com");
}
#[test]
fn display_name_angle() {
let p = parse(
"John Doe <user@example.com>",
Strictness::Standard,
true,
false,
)
.unwrap_or_else(|e| panic!("parse failed: {e}"));
assert_eq!(p.display_name.map(|s| s.as_str(p.input)), Some("John Doe"));
assert_eq!(p.local_part.as_str(p.input), "user");
assert_eq!(p.domain.as_str(p.input), "example.com");
}
#[test]
fn quoted_display_name() {
let p = parse(
"\"John Doe\" <user@example.com>",
Strictness::Standard,
true,
false,
)
.unwrap_or_else(|e| panic!("parse failed: {e}"));
assert_eq!(p.display_name.map(|s| s.as_str(p.input)), Some("John Doe"));
}
#[test]
fn domain_literal_allowed() {
let p = parse("user@[192.168.1.1]", Strictness::Standard, false, true)
.unwrap_or_else(|e| panic!("parse failed: {e}"));
assert_eq!(p.domain.as_str(p.input), "[192.168.1.1]");
}
#[test]
fn trailing_dot_in_domain_gives_domain_error() {
let e = parse_err("user@example.");
assert!(
matches!(e.kind(), ErrorKind::EmptyDomain),
"expected EmptyDomain, got {:?}",
e.kind()
);
}
#[test]
fn consecutive_dots_in_domain_gives_domain_error() {
let e = parse_err("user@example..com");
assert!(
matches!(e.kind(), ErrorKind::EmptyDomain),
"expected EmptyDomain, got {:?}",
e.kind()
);
}
#[test]
fn strict_rejects_trailing_comment() {
let e = parse(
"user@example.com (comment)",
Strictness::Strict,
false,
false,
)
.expect_err("Strict mode must reject trailing comment");
assert!(matches!(e.kind(), ErrorKind::Unexpected { .. }));
}
#[test]
fn strict_rejects_trailing_cfws_in_angle() {
let e = parse(
"<user@example.com (comment)>",
Strictness::Strict,
false,
false,
)
.expect_err("Strict mode must reject CFWS before closing angle bracket");
assert!(matches!(e.kind(), ErrorKind::Unexpected { .. }));
}
#[test]
fn strict_rejects_quoted_local_part() {
let e = parse("\"quoted\"@example.com", Strictness::Strict, false, false)
.expect_err("Strict mode must reject quoted-string local part");
assert_eq!(e.kind(), &ErrorKind::InvalidLocalPartChar { ch: '"' });
}
#[test]
fn strict_rejects_leading_comment() {
let e = parse(
"(comment)user@example.com",
Strictness::Strict,
false,
false,
)
.expect_err("Strict mode must reject leading comment");
assert_eq!(e.kind(), &ErrorKind::InvalidLocalPartChar { ch: '(' });
}
#[test]
fn standard_accepts_quoted_string_and_comments() {
let p = parse("\"quoted\"@example.com", Strictness::Standard, false, false)
.unwrap_or_else(|e| panic!("Standard must accept quoted-string: {e}"));
assert_eq!(p.local_part.as_str(p.input), "\"quoted\"");
assert_eq!(p.domain.as_str(p.input), "example.com");
let p = parse(
"user@example.com (comment)",
Strictness::Standard,
false,
false,
)
.unwrap_or_else(|e| panic!("Standard must accept trailing comment: {e}"));
assert_eq!(p.local_part.as_str(p.input), "user");
assert_eq!(p.domain.as_str(p.input), "example.com");
}
#[test]
fn domain_literal_rejected_by_default() {
let e = parse("user@[192.168.1.1]", Strictness::Standard, false, false)
.expect_err("expected error");
assert_eq!(e.kind(), &ErrorKind::InvalidDomainChar { ch: '[' });
}
#[test]
fn obs_local_part_cfws_comment_stripped() {
let p = parse_ok_lax("user (comment) . name@example.com");
assert_eq!(
p.local_part_str(),
"user.name",
"CFWS comment must be stripped from obs-local-part"
);
}
#[test]
fn obs_local_part_whitespace_stripped() {
let p = parse_ok_lax("user . name@example.com");
assert_eq!(
p.local_part_str(),
"user.name",
"whitespace must be stripped from obs-local-part"
);
}
#[test]
fn obs_domain_cfws_comment_stripped() {
let p = parse_ok_lax("user@example (comment) . com");
assert_eq!(
p.domain_str(),
"example.com",
"CFWS comment must be stripped from obs-domain"
);
}
#[test]
fn obs_domain_whitespace_stripped() {
let p = parse_ok_lax("user@example . com");
assert_eq!(
p.domain_str(),
"example.com",
"whitespace must be stripped from obs-domain"
);
}
#[test]
fn obs_local_no_cfws_zero_copy() {
let p = parse_ok_lax("user.name@example.com");
assert!(
p.local_part_clean.is_none(),
"no CFWS → local_part_clean must be None (zero-copy)"
);
assert_eq!(p.local_part_str(), "user.name");
}
#[test]
fn obs_domain_no_cfws_zero_copy() {
let p = parse_ok_lax("user@example.com");
assert!(
p.domain_clean.is_none(),
"no CFWS → domain_clean must be None (zero-copy)"
);
assert_eq!(p.domain_str(), "example.com");
}
#[test]
fn obs_local_part_multiple_comments_stripped() {
let p = parse_ok_lax("a (c1) . b (c2) . c@example.com");
assert_eq!(p.local_part_str(), "a.b.c");
}
#[test]
fn obs_leading_comment_accepted_in_bare_addr_spec() {
let p = parse(
"(leading) user . name@example.com",
Strictness::Lax,
false,
false,
)
.unwrap_or_else(|e| panic!("leading comment must be accepted: {e}"));
assert_eq!(p.local_part_str(), "user.name");
assert_eq!(p.domain_str(), "example.com");
}
#[test]
fn obs_local_cfws_after_dot_no_double_dots() {
let p = parse_ok_lax("user. name@example.com");
assert_eq!(p.local_part_str(), "user.name");
}
#[test]
fn obs_domain_cfws_after_dot_no_double_dots() {
let p = parse_ok_lax("user@example. com");
assert_eq!(p.domain_str(), "example.com");
}
#[test]
fn obs_trailing_cfws_before_at_preserves_zero_copy() {
let p = parse_ok_lax("user (trailing)@example.com");
assert!(
p.local_part_clean.is_none(),
"trailing CFWS before @ must not trigger allocation"
);
assert_eq!(p.local_part_str(), "user");
assert_eq!(
p.comments.len(),
1,
"backtracked comment must not be duplicated"
);
}
#[test]
fn obs_trailing_cfws_after_domain_preserves_zero_copy() {
let p = parse_ok_lax("user@example.com (trailing)");
assert!(
p.domain_clean.is_none(),
"trailing CFWS after domain must not trigger allocation"
);
assert_eq!(p.domain_str(), "example.com");
assert_eq!(
p.comments.len(),
1,
"backtracked comment must not be duplicated"
);
}
#[test]
fn leading_comment_accepted_standard_and_lax() {
for strictness in [Strictness::Standard, Strictness::Lax] {
let p = parse("(comment)jane.smith@example.com", strictness, false, false)
.unwrap_or_else(|e| panic!("{strictness:?}: leading comment must parse: {e}"));
assert_eq!(p.local_part_str(), "jane.smith");
assert_eq!(p.domain_str(), "example.com");
}
}
#[test]
fn leading_comment_in_angle_addr() {
let p = parse(
"<(comment)user@example.com>",
Strictness::Standard,
false,
false,
)
.unwrap_or_else(|e| panic!("leading comment in angle-addr must parse: {e}"));
assert_eq!(p.local_part_str(), "user");
}
#[test]
fn strict_still_rejects_leading_comment() {
let e = parse(
"(comment)user@example.com",
Strictness::Strict,
false,
false,
)
.expect_err("Strict must reject leading comment");
assert_eq!(e.kind(), &ErrorKind::InvalidLocalPartChar { ch: '(' });
}
#[test]
fn comment_only_local_part_is_empty() {
let e = parse("(comment)@example.com", Strictness::Lax, false, false)
.expect_err("comment-only local part must be rejected");
assert_eq!(e.kind(), &ErrorKind::EmptyLocalPart);
}
#[test]
fn rejects_bare_trailing_lf() {
for input in ["test@iana.org\n", "test@iana.org\r", "test@iana.org\r\n"] {
assert!(
parse(input, Strictness::Lax, false, false).is_err(),
"must reject trailing bare CR/LF: {input:?}"
);
}
}
#[test]
fn rejects_bare_leading_cr_lf() {
for input in ["\rtest@iana.org", "\ntest@iana.org", "\r\ntest@iana.org"] {
assert!(
parse(input, Strictness::Lax, false, false).is_err(),
"must reject leading bare CR/LF: {input:?}"
);
}
}
#[test]
fn rejects_bare_cr_in_comment() {
let e = parse("test@iana.org(\r)", Strictness::Lax, false, false)
.expect_err("bare CR in comment must be rejected");
assert!(matches!(e.kind(), ErrorKind::Unexpected { .. }));
}
#[test]
fn comment_may_contain_folding_whitespace() {
let p = parse("(a\r\n b)test@iana.org", Strictness::Lax, false, false)
.unwrap_or_else(|e| panic!("folded comment must parse: {e}"));
assert_eq!(p.local_part_str(), "test");
}
#[test]
fn accepts_valid_folding_whitespace() {
let leading = parse(" \r\n test@iana.org", Strictness::Lax, false, false)
.unwrap_or_else(|e| panic!("leading FWS must parse: {e}"));
assert_eq!(leading.local_part_str(), "test");
let trailing = parse("test@iana.org \r\n ", Strictness::Lax, false, false)
.unwrap_or_else(|e| panic!("trailing FWS must parse: {e}"));
assert_eq!(trailing.domain_str(), "iana.org");
}
#[test]
fn accepts_trailing_and_leading_space() {
assert!(parse(" test@iana.org", Strictness::Standard, false, false).is_ok());
assert!(parse("test@iana.org ", Strictness::Standard, false, false).is_ok());
}
fn parse_lit(input: &str) -> Result<Parsed<'_>, Error> {
parse(input, Strictness::Lax, false, true)
}
#[test]
fn accepts_valid_ipv4_literal() {
let p = parse_lit("test@[255.255.255.255]").unwrap_or_else(|e| panic!("{e}"));
assert_eq!(p.domain_str(), "[255.255.255.255]");
}
#[test]
fn accepts_valid_ipv6_literal() {
for v6 in [
"test@[IPv6:1111:2222:3333:4444:5555:6666:7777:8888]",
"test@[IPv6:1111:2222:3333:4444:5555::8888]",
"test@[IPv6:::]",
"test@[IPv6:1111:2222:3333:4444::255.255.255.255]",
] {
assert!(parse_lit(v6).is_ok(), "valid IPv6 literal must parse: {v6}");
}
}
#[test]
fn rejects_malformed_ip_literal() {
for bad in [
"test@[255.255.255]", "test@[255.255.255.256]", "test@[IPv6:1111:2222:3333:4444:5555:6666:7777]", "test@[IPv6:1111:2222:3333:4444:5555:6666:7777:888G]", "test@[IPv6:1::2:]", "test@[RFC-5322-domain-literal]", ] {
let e = parse_lit(bad).expect_err(&format!("must reject: {bad}"));
assert_eq!(
e.kind(),
&ErrorKind::InvalidAddressLiteral,
"wrong error for {bad}"
);
assert!(
e.to_string().contains("address literal"),
"unexpected Display for {bad}: {e}"
);
}
}
#[test]
fn parse_domain_literal_requires_open_bracket() {
let mut parser = Parser::new("nope");
assert_eq!(
parse_domain_literal(&mut parser).unwrap_err().kind(),
&ErrorKind::UnterminatedDomainLiteral
);
}
#[test]
fn is_qtext_excludes_quote_and_backslash() {
assert!(!is_qtext('"', false));
assert!(!is_qtext('"', true));
assert!(!is_qtext('\\', true));
assert!(is_qtext('a', false));
}
#[test]
fn lax_accepts_obs_qtext_and_obs_qp() {
for input in [
"\"\u{07}\"@iana.org", "\"\u{7f}\"@iana.org", "\"\\\u{00}\"@iana.org", "\"\\\u{0a}\"@iana.org", ] {
assert!(
parse(input, Strictness::Lax, false, false).is_ok(),
"Lax must accept obs-qp/qtext: {input:?}"
);
}
}
#[test]
fn standard_rejects_obs_qtext() {
let e = parse("\"\u{07}\"@iana.org", Strictness::Standard, false, false)
.expect_err("Standard must reject obs-qtext");
assert_eq!(e.kind(), &ErrorKind::InvalidLocalPartChar { ch: '\u{07}' });
}
#[test]
fn rejects_quoted_pair_of_non_ascii() {
let e = parse(
"\"test\\\u{a9}\"@iana.org",
Strictness::Standard,
false,
false,
)
.expect_err("quoted-pair of non-ASCII must be rejected");
assert_eq!(e.kind(), &ErrorKind::InvalidQuotedPair);
assert!(parse("\"test\\\u{a9}\"@iana.org", Strictness::Lax, false, false).is_err());
}
#[test]
fn quoted_string_consumes_consecutive_wsp() {
let p = parse("\"a b\"@example.com", Strictness::Standard, false, false)
.unwrap_or_else(|e| panic!("quoted string with double space: {e}"));
assert_eq!(p.local_part.as_str(p.input), "\"a b\"");
}
#[test]
fn parse_quoted_string_requires_open_quote() {
let mut parser = Parser::new("x");
assert_eq!(
parse_quoted_string(&mut parser, false).unwrap_err().kind(),
&ErrorKind::UnterminatedQuotedString
);
}
#[test]
fn deeply_nested_comment_is_rejected() {
let input = format!(
"{}x{}test@iana.org",
"(".repeat(MAX_RECURSION_DEPTH + 2),
")".repeat(MAX_RECURSION_DEPTH + 2)
);
assert!(parse(&input, Strictness::Lax, false, false).is_err());
}
#[test]
fn quoted_local_part_not_treated_as_display_name() {
let p = parse("\"quoted\"@example.com", Strictness::Standard, true, false)
.unwrap_or_else(|e| panic!("quoted local with display_name enabled: {e}"));
assert_eq!(p.display_name, None);
assert_eq!(p.local_part.as_str(p.input), "\"quoted\"");
}
#[test]
fn malformed_quoted_display_name_backtracks() {
let e = parse(
"\"unterminated@example.com",
Strictness::Standard,
true,
false,
)
.expect_err("unterminated quoted must fail");
assert_eq!(e.kind(), &ErrorKind::UnterminatedQuotedString);
}
#[test]
fn control_char_aborts_display_name_scan() {
let e = parse("\u{01}user@example.com", Strictness::Standard, true, false)
.expect_err("control char must be rejected");
assert_eq!(e.kind(), &ErrorKind::InvalidLocalPartChar { ch: '\u{01}' });
}
#[test]
fn unquoted_text_without_angle_is_not_display_name() {
let e = parse("plainname", Strictness::Standard, true, false)
.expect_err("bare text without '@' must fail");
assert_eq!(e.kind(), &ErrorKind::MissingAtSign);
}
#[test]
fn leading_cfws_before_quoted_display_name() {
let p = parse(
" \"John Doe\" <user@example.com>",
Strictness::Standard,
true,
false,
)
.unwrap_or_else(|e| panic!("leading CFWS + quoted display name: {e}"));
assert_eq!(p.display_name.map(|s| s.as_str(p.input)), Some("John Doe"));
assert_eq!(p.local_part.as_str(p.input), "user");
}
#[test]
fn ipv6_address_literal_tag_is_case_insensitive() {
for input in [
"user@[ipv6:::1]",
"user@[IPV6:2001:db8::1]",
"user@[IPv6:::1]",
] {
assert!(
parse(input, Strictness::Lax, false, true).is_ok(),
"case-insensitive IPv6 tag must parse: {input}"
);
}
}