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]
}
#[allow(dead_code)]
pub fn len(&self) -> usize {
self.end - self.start
}
}
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> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(Error::new(ErrorKind::Empty, 0));
}
let mut parser = Parser::new(trimmed);
let display_name = if allow_display_name {
try_parse_display_name(&mut parser)
} else {
None
};
let is_angle = display_name.is_some() || parser.peek() == Some('<');
if is_angle {
skip_cfws(&mut parser, 0);
if !parser.eat('<') {
return Err(parser.error(ErrorKind::Unexpected {
ch: parser.peek().unwrap_or('\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: trimmed,
display_name,
local_part,
domain,
comments: parser.comments,
local_part_clean,
domain_clean,
})
}
fn try_parse_display_name(parser: &mut Parser<'_>) -> Option<Span> {
let save = parser.save();
if parser.peek() == Some('"') {
let start = parser.pos;
if parse_quoted_string(parser).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)?;
return Ok((Span::new(start, parser.pos), None));
}
}
let clean = parse_dot_atom_local(parser, allow_obs)?;
let end = parser.pos;
if end == start {
return Err(parser.error(ErrorKind::EmptyLocalPart));
}
Ok((Span::new(start, end), 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) {
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) {
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<'_>) -> 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) => {}
_ => return Err(parser.error(ErrorKind::InvalidQuotedPair)),
}
}
Some(ch) if is_qtext(ch) => {
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<'_>) -> bool {
if parser.peek() != Some('"') {
return false;
}
let save = parser.save();
if parse_quoted_string(parser).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, strictness)?;
return Ok((Span::new(start, parser.pos), None));
}
let allow_obs = matches!(strictness, Strictness::Lax);
let clean = parse_dot_atom_domain(parser, allow_obs)?;
let end = parser.pos;
if end == start {
return Err(parser.error(ErrorKind::EmptyDomain));
}
Ok((Span::new(start, end), 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<'_>, strictness: Strictness) -> Result<(), Error> {
if !parser.eat('[') {
return Err(parser.error(ErrorKind::UnterminatedDomainLiteral));
}
loop {
match parser.peek() {
Some(']') => {
parser.advance();
return Ok(());
}
Some('\\') if matches!(strictness, Strictness::Lax) => {
parser.advance();
match parser.advance() {
Some(ch) if is_quoted_pair_char(ch) => {}
_ => return Err(parser.error(ErrorKind::InvalidQuotedPair)),
}
}
Some(ch) if is_dtext(ch) => {
parser.advance();
}
Some(ch) if is_wsp(ch) || ch == '\r' => {
if !try_eat_fws(parser) {
return Err(parser.error(ErrorKind::InvalidDomainChar { ch: '\r' }));
}
}
None => return Err(parser.error(ErrorKind::UnterminatedDomainLiteral)),
Some(ch) => {
return Err(parser.error(ErrorKind::InvalidDomainChar { ch }));
}
}
}
}
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) {
if depth >= MAX_RECURSION_DEPTH {
return;
}
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();
match parser.advance() {
Some(ch) if is_quoted_pair_char(ch) => {}
_ => return Err(parser.error(ErrorKind::InvalidQuotedPair)),
}
}
Some(ch) if is_ctext(ch) || is_wsp(ch) => {
parser.advance();
}
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) -> bool {
ch != '"' && ch != '\\' && (is_printable_ascii(ch) || is_utf8_non_ascii(ch))
}
fn is_ctext(ch: char) -> bool {
ch != '(' && ch != ')' && ch != '\\' && (is_printable_ascii(ch) || is_utf8_non_ascii(ch))
}
fn is_dtext(ch: char) -> bool {
ch != '[' && ch != ']' && ch != '\\' && (is_printable_ascii(ch) || is_utf8_non_ascii(ch))
}
fn is_quoted_pair_char(ch: char) -> bool {
is_printable_ascii(ch) || is_wsp(ch) || is_utf8_non_ascii(ch)
}
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_rejected_in_bare_addr_spec() {
let e = parse(
"(leading) user . name@example.com",
Strictness::Lax,
false,
false,
)
.expect_err("leading comment in bare addr-spec must be rejected");
assert_eq!(e.kind(), &ErrorKind::InvalidLocalPartChar { ch: '(' });
}
#[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"
);
}
}