use alloc::string::String;
use alloc::vec::Vec;
use crate::config::{AddressLiteral, Config, Strictness};
use crate::error::{Error, ErrorKind};
const MAX_RECURSION_DEPTH: usize = 128;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Quoted {
Smtp,
Header,
Obsolete,
}
impl Quoted {
fn header(allow_obs: bool) -> Self {
if allow_obs {
Self::Obsolete
} else {
Self::Header
}
}
}
#[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<'a>(input: &'a str, config: &Config) -> Result<Parsed<'a>, Error> {
let strictness = config.strictness;
let address_literal = config.address_literal;
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 config.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, config.quoted_local_part)?;
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, address_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, Quoted::header(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,
quoted_local_part: bool,
) -> 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) {
if !quoted_local_part {
return Err(parser.error(ErrorKind::InvalidLocalPartChar { ch: '"' }));
}
parse_quoted_string(parser, Quoted::Smtp)?;
return Ok((Span::new(start, parser.pos), None));
}
if !allow_obs {
parse_quoted_string(parser, Quoted::Header)?;
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, Quoted::header(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, Quoted::header(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<'_>, flavor: Quoted) -> 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, flavor) => {}
_ => return Err(parser.error(ErrorKind::InvalidQuotedPair)),
}
}
Some(ch) if is_qtext(ch, flavor) => {
parser.advance();
}
Some(ch) if flavor != Quoted::Smtp && (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<'_>, flavor: Quoted) -> bool {
if parser.peek() != Some('"') {
return false;
}
let save = parser.save();
if parse_quoted_string(parser, flavor).is_ok() {
true
} else {
parser.restore(save);
false
}
}
fn parse_domain(
parser: &mut Parser<'_>,
strictness: Strictness,
address_literal: AddressLiteral,
) -> Result<(Span, Option<String>), Error> {
let start = parser.pos;
if parser.peek() == Some('[') {
if address_literal == AddressLiteral::Reject {
return Err(parser.error(ErrorKind::InvalidDomainChar { ch: '[' }));
}
parse_domain_literal(parser, address_literal)?;
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<'_>, policy: AddressLiteral) -> 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, policy) {
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, policy: AddressLiteral) -> bool {
if policy == AddressLiteral::Reject {
return false;
}
if ipv6_address_literal(content).is_some() {
return true;
}
if policy == AddressLiteral::Rfc5321 && ipv6_address_literal_with_padded_tail(content).is_some()
{
return true;
}
if has_ipv6_tag(content) {
return false;
}
match policy {
AddressLiteral::Reject => false,
AddressLiteral::Routable => content.parse::<core::net::Ipv4Addr>().is_ok(),
AddressLiteral::Rfc5321 => {
ipv4_address_literal(content).is_some() || is_general_address_literal(content)
}
}
}
fn has_ipv6_tag(content: &str) -> bool {
content
.get(..5)
.is_some_and(|tag| tag.eq_ignore_ascii_case("IPv6:"))
}
pub(crate) fn ipv6_address_literal(content: &str) -> Option<core::net::Ipv6Addr> {
content
.get(5..)
.filter(|_| has_ipv6_tag(content))?
.parse()
.ok()
}
const MAX_IPV6_ADDR_LEN: usize = 64;
pub(crate) fn ipv6_address_literal_with_padded_tail(content: &str) -> Option<core::net::Ipv6Addr> {
let addr = content.get(5..).filter(|_| has_ipv6_tag(content))?;
let (head, tail) = addr.rsplit_once(':')?;
ipv4_address_literal(tail)?;
let mut buf = [0_u8; MAX_IPV6_ADDR_LEN];
let mut len = 0;
let mut push = |bytes: &[u8]| -> Option<()> {
let end = len + bytes.len();
if end > buf.len() {
return None;
}
buf[len..end].copy_from_slice(bytes);
len = end;
Some(())
};
push(head.as_bytes())?;
push(b":")?;
for (i, octet) in tail.split('.').enumerate() {
let trimmed = octet.trim_start_matches('0');
let digits = if trimmed.is_empty() { "0" } else { trimmed };
if i > 0 {
push(b".")?;
}
push(digits.as_bytes())?;
}
core::str::from_utf8(&buf[..len]).ok()?.parse().ok()
}
pub(crate) fn is_ip_address_literal(content: &str) -> bool {
ipv6_address_literal(content).is_some()
|| ipv6_address_literal_with_padded_tail(content).is_some()
|| ipv4_address_literal(content).is_some()
}
pub(crate) fn ipv4_address_literal(content: &str) -> Option<core::net::Ipv4Addr> {
let mut octets = [0_u8; 4];
let mut seen = 0;
for part in content.split('.') {
if seen == 4 {
return None;
}
if part.is_empty() || part.len() > 3 || !part.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let value: u16 = part
.bytes()
.fold(0, |acc, b| acc * 10 + u16::from(b - b'0'));
octets[seen] = u8::try_from(value).ok()?;
seen += 1;
}
(seen == 4).then(|| core::net::Ipv4Addr::from(octets))
}
fn is_general_address_literal(content: &str) -> bool {
let Some((tag, body)) = content.split_once(':') else {
return false;
};
is_ldh_str(tag) && !body.is_empty() && body.bytes().all(is_dcontent)
}
fn is_ldh_str(tag: &str) -> bool {
tag.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
&& tag
.bytes()
.next_back()
.is_some_and(|b| b.is_ascii_alphanumeric())
}
fn is_dcontent(b: u8) -> bool {
matches!(b, 33..=90 | 94..=126)
}
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, flavor: Quoted) -> bool {
if ch == '"' || ch == '\\' {
return false;
}
match flavor {
Quoted::Smtp => ch == ' ' || is_printable_ascii(ch) || is_utf8_non_ascii(ch),
Quoted::Header => is_printable_ascii(ch) || is_utf8_non_ascii(ch),
Quoted::Obsolete => is_printable_ascii(ch) || is_utf8_non_ascii(ch) || 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, flavor: Quoted) -> bool {
match flavor {
Quoted::Smtp => ch == ' ' || is_printable_ascii(ch),
Quoted::Header => is_printable_ascii(ch) || is_wsp(ch),
Quoted::Obsolete => {
is_printable_ascii(ch)
|| is_wsp(ch)
|| 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;