use crate::config::Strictness;
use crate::error::{Error, ErrorKind};
const MAX_RECURSION_DEPTH: usize = 128;
#[derive(Debug, Clone)]
pub(crate) struct Parsed<'a> {
pub input: &'a str,
pub display_name: Option<Span>,
pub local_part: Span,
pub domain: Span,
#[allow(dead_code)]
pub comments: Vec<Span>,
pub local_part_clean: Option<String>,
pub domain_clean: Option<String>,
}
impl<'a> Parsed<'a> {
pub fn local_part_str(&self) -> &str {
self.local_part_clean
.as_deref()
.unwrap_or_else(|| self.local_part.as_str(self.input))
}
pub fn domain_str(&self) -> &str {
self.domain_clean
.as_deref()
.unwrap_or_else(|| self.domain.as_str(self.input))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Span {
pub start: usize,
pub end: usize,
}
impl Span {
fn new(start: usize, end: usize) -> Self {
Self { start, end }
}
pub fn as_str<'a>(&self, input: &'a str) -> &'a str {
&input[self.start..self.end]
}
}
struct Parser<'a> {
input: &'a str,
pos: usize,
comments: Vec<Span>,
}
impl<'a> Parser<'a> {
fn new(input: &'a str) -> Self {
Self {
input,
pos: 0,
comments: Vec::new(),
}
}
fn remaining(&self) -> &'a str {
&self.input[self.pos..]
}
fn peek(&self) -> Option<char> {
self.remaining().chars().next()
}
fn advance(&mut self) -> Option<char> {
let ch = self.peek()?;
self.pos += ch.len_utf8();
Some(ch)
}
fn eat(&mut self, expected: char) -> bool {
if self.peek() == Some(expected) {
self.pos += expected.len_utf8();
true
} else {
false
}
}
fn at_end(&self) -> bool {
self.pos >= self.input.len()
}
fn error(&self, kind: ErrorKind) -> Error {
Error::new(kind, self.pos)
}
fn save(&self) -> usize {
self.pos
}
fn restore(&mut self, pos: usize) {
self.pos = pos;
}
}
pub(crate) fn parse(
input: &str,
strictness: Strictness,
allow_display_name: bool,
allow_domain_literal: bool,
) -> Result<Parsed<'_>, Error> {
if input.is_empty() {
return Err(Error::new(ErrorKind::Empty, 0));
}
let mut parser = Parser::new(input);
let allow_obs = matches!(strictness, Strictness::Lax);
if !matches!(strictness, Strictness::Strict) {
skip_cfws(&mut parser, 0);
}
let display_name = if allow_display_name {
try_parse_display_name(&mut parser, allow_obs)
} else {
None
};
let is_angle = display_name.is_some() || parser.peek() == Some('<');
if is_angle {
parser.eat('<');
}
if !matches!(strictness, Strictness::Strict) {
skip_cfws(&mut parser, 0);
}
let (local_part, local_part_clean) = parse_local_part(&mut parser, strictness)?;
if !matches!(strictness, Strictness::Strict) {
skip_cfws(&mut parser, 0);
}
if !parser.eat('@') {
return Err(parser.error(ErrorKind::MissingAtSign));
}
if !matches!(strictness, Strictness::Strict) {
skip_cfws(&mut parser, 0);
}
let (domain, domain_clean) = parse_domain(&mut parser, strictness, allow_domain_literal)?;
if is_angle {
if !matches!(strictness, Strictness::Strict) {
skip_cfws(&mut parser, 0);
}
if !parser.eat('>') {
return Err(parser.error(ErrorKind::Unexpected {
ch: parser.peek().unwrap_or('\0'),
}));
}
}
if !matches!(strictness, Strictness::Strict) {
skip_cfws(&mut parser, 0);
}
if !parser.at_end() {
let ch = parser.peek().unwrap_or('\0');
return Err(parser.error(ErrorKind::Unexpected { ch }));
}
Ok(Parsed {
input,
display_name,
local_part,
domain,
comments: parser.comments,
local_part_clean,
domain_clean,
})
}
fn try_parse_display_name(parser: &mut Parser<'_>, allow_obs: bool) -> Option<Span> {
let save = parser.save();
if parser.peek() == Some('"') {
let start = parser.pos;
if parse_quoted_string(parser, allow_obs).is_err() {
parser.restore(save);
return None;
}
let end = parser.pos;
skip_cfws(parser, 0);
if parser.peek() == Some('<') {
return Some(Span::new(start + 1, end - 1));
}
parser.restore(save);
return None;
}
let start = parser.pos;
let mut found_content = false;
loop {
match parser.peek() {
Some('<') if found_content => {
let name = &parser.input[start..parser.pos];
let trimmed_end = start + name.trim_end().len();
return Some(Span::new(start, trimmed_end));
}
Some(ch) if ch == '@' || ch == '>' => {
parser.restore(save);
return None;
}
Some(ch) if ch < '\u{20}' && ch != '\t' => {
parser.restore(save);
return None;
}
Some(_) => {
found_content = true;
parser.advance();
}
None => {
parser.restore(save);
return None;
}
}
}
}
fn parse_local_part(
parser: &mut Parser<'_>,
strictness: Strictness,
) -> Result<(Span, Option<String>), Error> {
let start = parser.pos;
let allow_obs = matches!(strictness, Strictness::Lax);
if parser.peek() == Some('"') {
if matches!(strictness, Strictness::Strict) {
return Err(parser.error(ErrorKind::InvalidLocalPartChar { ch: '"' }));
}
if !allow_obs {
parse_quoted_string(parser, false)?;
return Ok((Span::new(start, parser.pos), None));
}
}
let clean = parse_dot_atom_local(parser, allow_obs)?;
Ok((Span::new(start, parser.pos), clean))
}
fn parse_dot_atom_local(parser: &mut Parser<'_>, allow_obs: bool) -> Result<Option<String>, Error> {
if !allow_obs {
if !eat_atext_run(parser) {
return Err(match parser.peek() {
Some(ch) if ch != '@' => parser.error(ErrorKind::InvalidLocalPartChar { ch }),
_ => parser.error(ErrorKind::EmptyLocalPart),
});
}
loop {
let save = parser.save();
if !parser.eat('.') {
parser.restore(save);
break;
}
if !eat_atext_run(parser) {
return Err(parser.error(ErrorKind::EmptyLocalPart));
}
}
return Ok(None);
}
let mut clean: Option<String> = None;
let outer_start = parser.pos;
if !eat_atext_run(parser) && !try_quoted_string(parser, allow_obs) {
return Err(match parser.peek() {
Some(ch) if ch != '@' => parser.error(ErrorKind::InvalidLocalPartChar { ch }),
_ => parser.error(ErrorKind::EmptyLocalPart),
});
}
loop {
let last_clean_end = parser.pos;
let save = parser.save();
let comments_len = parser.comments.len();
skip_cfws(parser, 0);
let had_cfws_before_dot = parser.pos > last_clean_end;
if !parser.eat('.') {
parser.restore(save);
parser.comments.truncate(comments_len);
break;
}
if had_cfws_before_dot && clean.is_none() {
let mut s = String::with_capacity(last_clean_end - outer_start);
s.push_str(&parser.input[outer_start..last_clean_end]);
clean = Some(s);
}
skip_cfws(parser, 0);
if clean.is_none() && parser.pos > last_clean_end + 1 {
let mut s = String::with_capacity(last_clean_end - outer_start);
s.push_str(&parser.input[outer_start..last_clean_end]);
clean = Some(s);
}
let atom_start = parser.pos;
if !eat_atext_run(parser) && !try_quoted_string(parser, allow_obs) {
return Err(parser.error(ErrorKind::EmptyLocalPart));
}
if let Some(ref mut s) = clean {
s.push('.');
s.push_str(&parser.input[atom_start..parser.pos]);
}
}
Ok(clean)
}
fn eat_atext_run(parser: &mut Parser<'_>) -> bool {
let start = parser.pos;
while let Some(ch) = parser.peek() {
if is_atext(ch) {
parser.advance();
} else {
break;
}
}
parser.pos > start
}
fn parse_quoted_string(parser: &mut Parser<'_>, allow_obs: bool) -> Result<(), Error> {
if !parser.eat('"') {
return Err(parser.error(ErrorKind::UnterminatedQuotedString));
}
loop {
match parser.peek() {
Some('"') => {
parser.advance();
return Ok(());
}
Some('\\') => {
parser.advance();
match parser.advance() {
Some(ch) if is_quoted_pair_char(ch, allow_obs) => {}
_ => return Err(parser.error(ErrorKind::InvalidQuotedPair)),
}
}
Some(ch) if is_qtext(ch, allow_obs) => {
parser.advance();
}
Some(ch) if is_wsp(ch) || ch == '\r' => {
if !try_eat_fws(parser) {
return Err(parser.error(ErrorKind::InvalidLocalPartChar { ch: '\r' }));
}
}
None => return Err(parser.error(ErrorKind::UnterminatedQuotedString)),
Some(ch) => {
return Err(parser.error(ErrorKind::InvalidLocalPartChar { ch }));
}
}
}
}
fn try_quoted_string(parser: &mut Parser<'_>, allow_obs: bool) -> bool {
if parser.peek() != Some('"') {
return false;
}
let save = parser.save();
if parse_quoted_string(parser, allow_obs).is_ok() {
true
} else {
parser.restore(save);
false
}
}
fn parse_domain(
parser: &mut Parser<'_>,
strictness: Strictness,
allow_domain_literal: bool,
) -> Result<(Span, Option<String>), Error> {
let start = parser.pos;
if parser.peek() == Some('[') {
if !allow_domain_literal {
return Err(parser.error(ErrorKind::InvalidDomainChar { ch: '[' }));
}
parse_domain_literal(parser)?;
return Ok((Span::new(start, parser.pos), None));
}
let allow_obs = matches!(strictness, Strictness::Lax);
let clean = parse_dot_atom_domain(parser, allow_obs)?;
Ok((Span::new(start, parser.pos), clean))
}
fn parse_dot_atom_domain(
parser: &mut Parser<'_>,
allow_obs: bool,
) -> Result<Option<String>, Error> {
if !allow_obs {
parse_domain_label(parser)?;
loop {
let save = parser.save();
if !parser.eat('.') {
parser.restore(save);
break;
}
parse_domain_label(parser)?;
}
return Ok(None);
}
let mut clean: Option<String> = None;
let outer_start = parser.pos;
parse_domain_label(parser)?;
loop {
let last_clean_end = parser.pos;
let save = parser.save();
let comments_len = parser.comments.len();
skip_cfws(parser, 0);
let had_cfws_before_dot = parser.pos > last_clean_end;
if !parser.eat('.') {
parser.restore(save);
parser.comments.truncate(comments_len);
break;
}
if had_cfws_before_dot && clean.is_none() {
let mut s = String::with_capacity(last_clean_end - outer_start);
s.push_str(&parser.input[outer_start..last_clean_end]);
clean = Some(s);
}
skip_cfws(parser, 0);
if clean.is_none() && parser.pos > last_clean_end + 1 {
let mut s = String::with_capacity(last_clean_end - outer_start);
s.push_str(&parser.input[outer_start..last_clean_end]);
clean = Some(s);
}
let label_start = parser.pos;
parse_domain_label(parser)?;
if let Some(ref mut s) = clean {
s.push('.');
s.push_str(&parser.input[label_start..parser.pos]);
}
}
Ok(clean)
}
fn parse_domain_label(parser: &mut Parser<'_>) -> Result<(), Error> {
match parser.peek() {
Some(ch) if ch.is_ascii_alphanumeric() || is_utf8_non_ascii(ch) => {
parser.advance();
}
Some('-') => return Err(parser.error(ErrorKind::DomainLabelHyphen)),
_ => return Err(parser.error(ErrorKind::EmptyDomain)),
}
let mut last_was_hyphen = false;
while let Some(ch) = parser.peek() {
if ch.is_ascii_alphanumeric() || is_utf8_non_ascii(ch) {
last_was_hyphen = false;
parser.advance();
} else if ch == '-' {
last_was_hyphen = true;
parser.advance();
} else {
break;
}
}
if last_was_hyphen {
return Err(parser.error(ErrorKind::DomainLabelHyphen));
}
Ok(())
}
fn parse_domain_literal(parser: &mut Parser<'_>) -> Result<(), Error> {
let open = parser.pos;
if !parser.eat('[') {
return Err(parser.error(ErrorKind::UnterminatedDomainLiteral));
}
let content_start = parser.pos;
loop {
match parser.peek() {
Some(']') => {
let content = &parser.input[content_start..parser.pos];
parser.advance(); if is_address_literal(content) {
return Ok(());
}
return Err(Error::new(ErrorKind::InvalidAddressLiteral, open));
}
Some('\\') => {
parser.advance();
if parser.advance().is_none() {
return Err(parser.error(ErrorKind::UnterminatedDomainLiteral));
}
}
None => return Err(parser.error(ErrorKind::UnterminatedDomainLiteral)),
Some(_) => {
parser.advance();
}
}
}
}
fn is_address_literal(content: &str) -> bool {
use core::net::{Ipv4Addr, Ipv6Addr};
if content
.get(..5)
.is_some_and(|tag| tag.eq_ignore_ascii_case("IPv6:"))
{
return content[5..].parse::<Ipv6Addr>().is_ok();
}
content.parse::<Ipv4Addr>().is_ok()
}
fn try_eat_fws(parser: &mut Parser<'_>) -> bool {
match parser.peek() {
Some(ch) if is_wsp(ch) => {
parser.advance();
while let Some(ch) = parser.peek() {
if is_wsp(ch) {
parser.advance();
} else {
break;
}
}
true
}
Some('\r') => {
let pos = parser.pos;
let bytes = parser.input.as_bytes();
if pos + 2 < bytes.len()
&& bytes[pos] == b'\r'
&& bytes[pos + 1] == b'\n'
&& (bytes[pos + 2] == b' ' || bytes[pos + 2] == b'\t')
{
parser.advance(); parser.advance(); while let Some(ch) = parser.peek() {
if is_wsp(ch) {
parser.advance();
} else {
break;
}
}
true
} else {
false
}
}
_ => false,
}
}
fn skip_cfws(parser: &mut Parser<'_>, depth: usize) {
loop {
loop {
match parser.peek() {
Some(ch) if is_wsp(ch) => {
parser.advance();
}
Some('\r') => {
let pos = parser.pos;
let bytes = parser.input.as_bytes();
if pos + 2 < bytes.len()
&& bytes[pos] == b'\r'
&& bytes[pos + 1] == b'\n'
&& (bytes[pos + 2] == b' ' || bytes[pos + 2] == b'\t')
{
parser.advance(); parser.advance(); while let Some(wch) = parser.peek() {
if is_wsp(wch) {
parser.advance();
} else {
break;
}
}
} else {
break;
}
}
Some('\n') => {
break;
}
_ => break,
}
}
if parser.peek() == Some('(') {
let comment_start = parser.pos;
match parse_comment(parser, depth) {
Ok(()) => {
parser.comments.push(Span::new(comment_start, parser.pos));
continue;
}
Err(_) => {
parser.pos = comment_start;
break;
}
}
}
break;
}
}
fn parse_comment(parser: &mut Parser<'_>, depth: usize) -> Result<(), Error> {
if depth >= MAX_RECURSION_DEPTH || !parser.eat('(') {
return Err(parser.error(ErrorKind::UnterminatedComment));
}
loop {
match parser.peek() {
Some(')') => {
parser.advance();
return Ok(());
}
Some('(') => {
parse_comment(parser, depth + 1)?;
}
Some('\\') => {
parser.advance();
if parser.advance().is_none() {
return Err(parser.error(ErrorKind::UnterminatedComment));
}
}
Some(ch) if is_ctext(ch) || is_wsp(ch) => {
parser.advance();
}
Some('\r') | Some('\n') => {
if !try_eat_fws(parser) {
return Err(parser.error(ErrorKind::UnterminatedComment));
}
}
None => return Err(parser.error(ErrorKind::UnterminatedComment)),
Some(_) => {
parser.advance(); }
}
}
}
fn is_atext(ch: char) -> bool {
ch.is_ascii_alphanumeric()
|| is_utf8_non_ascii(ch)
|| matches!(
ch,
'!' | '#'
| '$'
| '%'
| '&'
| '\''
| '*'
| '+'
| '-'
| '/'
| '='
| '?'
| '^'
| '_'
| '`'
| '{'
| '|'
| '}'
| '~'
)
}
fn is_qtext(ch: char, allow_obs: bool) -> bool {
if ch == '"' || ch == '\\' {
return false;
}
is_printable_ascii(ch) || is_utf8_non_ascii(ch) || (allow_obs && is_obs_no_ws_ctl(ch))
}
fn is_ctext(ch: char) -> bool {
ch != '(' && ch != ')' && ch != '\\' && (is_printable_ascii(ch) || is_utf8_non_ascii(ch))
}
fn is_quoted_pair_char(ch: char, allow_obs: bool) -> bool {
if is_printable_ascii(ch) || is_wsp(ch) {
return true;
}
allow_obs && (matches!(ch, '\0' | '\n' | '\r') || is_obs_no_ws_ctl(ch))
}
fn is_obs_no_ws_ctl(ch: char) -> bool {
matches!(ch as u32, 0x01..=0x08 | 0x0b | 0x0c | 0x0e..=0x1f | 0x7f)
}
fn is_printable_ascii(ch: char) -> bool {
matches!(ch as u32, 0x21..=0x7e)
}
fn is_utf8_non_ascii(ch: char) -> bool {
(ch as u32) >= 0x80
}
fn is_wsp(ch: char) -> bool {
ch == ' ' || ch == '\t'
}
#[cfg(test)]
mod tests {
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}"
);
}
}
}