diff --git a/Cargo.lock b/Cargo.lock index 24291ed3..1e310aae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -612,6 +612,12 @@ version = "3.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1" +[[package]] +name = "bytecount" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c676a478f63e9fa2dd5368a42f28bba0d6c560b775f38583c8bbaa7fcd67c9c" + [[package]] name = "byteorder" version = "1.4.3" @@ -2834,6 +2840,9 @@ dependencies = [ "charset", "data-encoding", "k9", + "nom 7.1.3", + "nom-tracable", + "nom_locate", "paste", "pest", "pest_derive", @@ -3276,6 +3285,38 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom-tracable" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "160767ce1eed2cdadc2256015a6dc51d9632226ea02e0f0ce4590b904e1d80e2" +dependencies = [ + "nom 7.1.3", + "nom-tracable-macros", + "nom_locate", +] + +[[package]] +name = "nom-tracable-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7ac681ea0c3d468b003bdebe3a65d1632e340302452f95c3ffadf515704c48d" +dependencies = [ + "quote 1.0.31", + "syn 1.0.109", +] + +[[package]] +name = "nom_locate" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e299bf5ea7b212e811e71174c5d1a5d065c4c0ad0c8691ecb1f97e3e66025e" +dependencies = [ + "bytecount", + "memchr", + "nom 7.1.3", +] + [[package]] name = "nonempty" version = "0.7.0" diff --git a/crates/mailparsing/Cargo.toml b/crates/mailparsing/Cargo.toml index e3a744ac..99591e0b 100644 --- a/crates/mailparsing/Cargo.toml +++ b/crates/mailparsing/Cargo.toml @@ -9,6 +9,9 @@ edition = "2021" bitflags = "2.3" charset = "0.1" data-encoding = "2.4" +nom = "7.1" +nom_locate = "4.1" +nom-tracable = "0.9" paste = "1.0" pest = "2.5" pest_derive = "2.5" @@ -16,5 +19,8 @@ quoted_printable = "0.5" textwrap = "0.16" thiserror = "1.0" +[features] +trace = ["nom-tracable/trace"] + [dev-dependencies] k9 = "0.11" diff --git a/crates/mailparsing/src/rfc5322_parser.rs b/crates/mailparsing/src/rfc5322_parser.rs index 5a8cd80a..ebdb87b1 100644 --- a/crates/mailparsing/src/rfc5322_parser.rs +++ b/crates/mailparsing/src/rfc5322_parser.rs @@ -1,29 +1,835 @@ use crate::headermap::EncodeHeaderValue; use crate::{MailParsingError, Result, SharedString}; use charset::Charset; +use nom::branch::alt; +use nom::bytes::complete::{tag, take_while1}; +use nom::character::complete::{char, satisfy}; +use nom::combinator::{all_consuming, map, opt, recognize}; +use nom::error::{context, ContextError, VerboseError}; +use nom::multi::{many0, many1, separated_list1}; +use nom::sequence::{delimited, preceded, separated_pair, terminated, tuple}; +use nom_locate::LocatedSpan; +use nom_tracable::{tracable_parser, TracableInfo}; use pest::iterators::{Pair, Pairs}; use pest::Parser as _; use pest_derive::*; +pub(crate) type Span<'a> = LocatedSpan<&'a str, TracableInfo>; +type IResult<'a, A, B> = nom::IResult>>; + +impl MailParsingError { + pub fn from_nom(input: Span, err: nom::Err>>) -> Self { + use nom::error::VerboseErrorKind; + use std::fmt::Write; + match err { + nom::Err::Error(e) => { + let mut result = String::new(); + for (i, (span, kind)) in e.errors.iter().enumerate() { + if input.is_empty() { + match kind { + VerboseErrorKind::Char(c) => { + write!(&mut result, "{i}: expected '{c}', got empty input\n\n") + } + VerboseErrorKind::Context(s) => { + write!(&mut result, "{i}: in {s}, got empty input\n\n") + } + VerboseErrorKind::Nom(e) => { + write!(&mut result, "{i}: in {e:?}, got empty input\n\n") + } + } + .ok(); + continue; + } + + let line_number = span.location_line(); + let line = std::str::from_utf8(span.get_line_beginning()) + .unwrap_or(""); + let column = span.get_utf8_column(); + + match kind { + VerboseErrorKind::Char(expected) => { + if let Some(actual) = span.fragment().chars().next() { + write!( + &mut result, + "{i}: at line {line_number}:\n\ + {line}\n\ + {caret:>column$}\n\ + expected '{expected}', found {actual}\n\n", + caret = '^', + ) + } else { + write!( + &mut result, + "{i}: at line {line_number}:\n\ + {line}\n\ + {caret:>column$}\n\ + expected '{expected}', got end of input\n\n", + caret = '^', + ) + } + } + VerboseErrorKind::Context(context) => { + write!( + &mut result, + "{i}: at line {line_number}, in {context}:\n\ + {line}\n\ + {caret:>column$}\n\n", + caret = '^', + ) + } + VerboseErrorKind::Nom(nom_err) => { + write!( + &mut result, + "{i}: at line {line_number}, in {nom_err:?}:\n\ + {line}\n\ + {caret:>column$}\n\n", + caret = '^', + ) + } + } + .ok(); + } + MailParsingError::HeaderParse(result) + } + _ => MailParsingError::HeaderParse(format!("{err:#}")), + } + } +} + +fn make_context_error<'a>( + input: Span<'a>, + context: &'static str, +) -> nom::Err>> { + let err = nom::error::make_error(input, nom::error::ErrorKind::Fail); + + nom::Err::Error(VerboseError::add_context(input, context, err)) +} + +fn make_span(s: &str) -> Span { + let info = TracableInfo::new() + .forward(true) + .backward(true) + .parser_width(42); + LocatedSpan::new_extra(s, info) +} + +fn is_utf8_non_ascii(c: char) -> bool { + let c = c as u32; + c == 0 || c >= 0x80 +} + +// ctl = { '\u{00}'..'\u{1f}' | "\u{7f}" } +fn is_ctl(c: char) -> bool { + match c { + '\u{00}'..='\u{1f}' | '\u{7f}' => true, + _ => false, + } +} + +// char = { '\u{01}'..'\u{7f}' } +fn is_char(c: char) -> bool { + match c { + '\u{01}'..='\u{ff}' => true, + _ => false, + } +} + +fn is_especial(c: char) -> bool { + match c { + '(' | ')' | '<' | '>' | '@' | ',' | ';' | ':' | '/' | '[' | ']' | '?' | '.' | '=' => true, + _ => false, + } +} + +fn is_token(c: char) -> bool { + is_char(c) && c != ' ' && !is_especial(c) && !is_ctl(c) +} + +// vchar = { '\u{21}'..'\u{7e}' | utf8_non_ascii } +fn is_vchar(c: char) -> bool { + let u = c as u32; + (u >= 0x21 && u <= 0x7e) || is_utf8_non_ascii(c) +} + +#[tracable_parser] +fn atext(input: Span) -> IResult { + context( + "atext", + take_while1(|c| match c { + '!' | '#' | '$' | '%' | '&' | '\'' | '*' | '+' | '-' | '/' | '=' | '?' | '^' | '_' + | '`' | '{' | '|' | '}' | '~' => true, + c => c.is_ascii_alphanumeric() || is_utf8_non_ascii(c), + }), + )(input) +} + +fn is_obs_no_ws_ctl(c: char) -> bool { + match c { + '\u{01}'..='\u{08}' | '\u{0b}'..='\u{0c}' | '\u{0e}'..='\u{1f}' | '\u{7f}' => true, + _ => false, + } +} + +fn is_obs_ctext(c: char) -> bool { + is_obs_no_ws_ctl(c) +} + +// ctext = { '\u{21}'..'\u{27}' | '\u{2a}'..'\u{5b}' | '\u{5d}'..'\u{7e}' | obs_ctext | utf8_non_ascii } +fn is_ctext(c: char) -> bool { + match c { + '\u{21}'..='\u{27}' | '\u{2a}'..='\u{5b}' | '\u{5d}'..='\u{7e}' => true, + c => is_obs_ctext(c) || is_utf8_non_ascii(c), + } +} + +// dtext = { '\u{21}'..'\u{5a}' | '\u{5e}'..'\u{7e}' | obs_dtext | utf8_non_ascii } +// obs_dtext = { obs_no_ws_ctl | quoted_pair } +fn is_dtext(c: char) -> bool { + match c { + '\u{21}'..='\u{5a}' | '\u{5e}'..='\u{7e}' => true, + c => is_obs_no_ws_ctl(c) || is_utf8_non_ascii(c), + } +} + +// qtext = { "\u{21}" | '\u{23}'..'\u{5b}' | '\u{5d}'..'\u{7e}' | obs_qtext | utf8_non_ascii } +// obs_qtext = { obs_no_ws_ctl } +fn is_qtext(c: char) -> bool { + match c { + '\u{21}' | '\u{23}'..='\u{5b}' | '\u{5d}'..='\u{7e}' => true, + c => is_obs_no_ws_ctl(c) || is_utf8_non_ascii(c), + } +} + +#[tracable_parser] +fn wsp(input: Span) -> IResult { + context("wsp", take_while1(|c| c == ' ' || c == '\t'))(input) +} + +#[tracable_parser] +fn newline(input: Span) -> IResult { + context("newline", recognize(preceded(opt(char('\r')), char('\n'))))(input) +} + +// fws = { ((wsp* ~ "\r"? ~ "\n")* ~ wsp+) | obs_fws } +#[tracable_parser] +fn fws(input: Span) -> IResult { + context( + "fws", + alt(( + recognize(preceded(many0(preceded(many0(wsp), newline)), many1(wsp))), + obs_fws, + )), + )(input) +} + +// obs_fws = { wsp+ ~ ("\r"? ~ "\n" ~ wsp+)* } +#[tracable_parser] +fn obs_fws(input: Span) -> IResult { + context( + "obs_fws", + recognize(preceded(many1(wsp), preceded(newline, many1(wsp)))), + )(input) +} + +// mailbox_list = { (mailbox ~ ("," ~ mailbox)*) | obs_mbox_list } +#[tracable_parser] +fn mailbox_list(input: Span) -> IResult { + let (loc, mailboxes) = context( + "mailbox_list", + alt((separated_list1(char(','), mailbox), obs_mbox_list)), + )(input)?; + Ok((loc, MailboxList(mailboxes))) +} + +// obs_mbox_list = { ((cfws? ~ ",")* ~ mailbox ~ ("," ~ (mailbox | cfws))*)+ } +#[tracable_parser] +fn obs_mbox_list(input: Span) -> IResult> { + let (loc, entries) = context( + "obs_mbox_list", + many1(preceded( + many0(preceded(opt(cfws), char(','))), + tuple(( + mailbox, + many0(preceded( + char(','), + alt((map(mailbox, |m| Some(m)), map(cfws, |_| None))), + )), + )), + )), + )(input)?; + + let mut result: Vec = vec![]; + + for (first, boxes) in entries { + result.push(first); + for b in boxes { + if let Some(m) = b { + result.push(m); + } + } + } + + Ok((loc, result)) +} + +// mailbox = { name_addr | addr_spec } +#[tracable_parser] +fn mailbox(input: Span) -> IResult { + if let Ok(res) = name_addr(input) { + Ok(res) + } else { + let (loc, aspec) = context("mailbox", addr_spec)(input)?; + Ok(( + loc, + Mailbox { + name: None, + address: format!("{}@{}", aspec.local_part, aspec.domain), // FIXME + }, + )) + } +} + +// name_addr = { display_name? ~ angle_addr } +#[tracable_parser] +fn name_addr(input: Span) -> IResult { + let (loc, (name, aspec)) = context("name_addr", tuple((opt(display_name), angle_addr)))(input)?; + Ok(( + loc, + Mailbox { + name, + address: format!("{}@{}", aspec.local_part, aspec.domain), // FIXME + }, + )) +} + +// display_name = { phrase } +#[tracable_parser] +fn display_name(input: Span) -> IResult { + context("display_name", phrase)(input) +} + +// phrase = { (encoded_word | word)+ | obs_phrase } +// obs_phrase = { (encoded_word | word) ~ (encoded_word | word | dot | cfws)* } +#[tracable_parser] +fn phrase(input: Span) -> IResult { + let (loc, (a, b)): (Span, (String, Vec>)) = context( + "phrase", + tuple(( + alt((encoded_word, word)), + many0(alt(( + map(encoded_word, Option::Some), + map(word, Option::Some), + map(char('.'), |dot| Some(dot.to_string())), + map(cfws, |_| None), + ))), + )), + )(input)?; + let mut result = vec![]; + result.push(a); + for item in b { + if let Some(item) = item { + result.push(item); + } + } + let result = result.join(" "); + Ok((loc, result)) +} + +// angle_addr = { cfws? ~ "<" ~ addr_spec ~ ">" ~ cfws? | obs_angle_addr } +#[tracable_parser] +fn angle_addr(input: Span) -> IResult { + context( + "angle_addr", + alt(( + delimited( + opt(cfws), + delimited(char('<'), addr_spec, char('>')), + opt(cfws), + ), + obs_angle_addr, + )), + )(input) +} + +#[tracable_parser] +// obs_angle_addr = { cfws? ~ "<" ~ obs_route ~ addr_spec ~ ">" ~ cfws? } +fn obs_angle_addr(input: Span) -> IResult { + context( + "obs_angle_addr", + delimited( + opt(cfws), + delimited(char('<'), preceded(obs_route, addr_spec), char('>')), + opt(cfws), + ), + )(input) +} + +// obs_route = { obs_domain_list ~ ":" } +// obs_domain_list = { (cfws | ",")* ~ "@" ~ domain ~ ("," ~ cfws? ~ ("@" ~ domain)?)* } +#[tracable_parser] +fn obs_route(input: Span) -> IResult { + context( + "obs_route", + recognize(terminated( + tuple(( + many0(alt((cfws, recognize(char(','))))), + recognize(char('@')), + recognize(domain), + many0(tuple(( + char(','), + opt(cfws), + opt(tuple((char('@'), domain))), + ))), + )), + char(':'), + )), + )(input) +} + +// addr_spec = { local_part ~ "@" ~ domain } +#[tracable_parser] +fn addr_spec(input: Span) -> IResult { + let (loc, (local_part, domain)) = + context("addr_spec", separated_pair(local_part, char('@'), domain))(input)?; + Ok((loc, AddrSpec { local_part, domain })) +} + +fn parse_with<'a, R, F>(text: &'a str, parser: F) -> Result +where + F: Fn(Span<'a>) -> IResult, R>, +{ + let input = make_span(text); + let (_, result) = + all_consuming(parser)(input).map_err(|err| MailParsingError::from_nom(input, err))?; + Ok(result) +} + +#[cfg(test)] +#[test] +fn test_addr_spec() { + k9::snapshot!( + parse_with("darth.vader@a.galaxy.far.far.away", addr_spec), + r#" +Ok( + AddrSpec { + local_part: "darth.vader", + domain: "a.galaxy.far.far.away", + }, +) +"# + ); + + k9::snapshot!( + parse_with("\"darth.vader\"@a.galaxy.far.far.away", addr_spec), + r#" +Ok( + AddrSpec { + local_part: "darth.vader", + domain: "a.galaxy.far.far.away", + }, +) +"# + ); + + k9::snapshot!( + parse_with("\"darth\".vader@a.galaxy.far.far.away", addr_spec), + r#" +Err( + HeaderParse( + "0: at line 1: +"darth".vader@a.galaxy.far.far.away + ^ +expected '@', found . + +1: at line 1, in addr_spec: +"darth".vader@a.galaxy.far.far.away +^ + +", + ), +) +"# + ); + + k9::snapshot!( + parse_with("a@[127.0.0.1]", addr_spec), + r#" +Ok( + AddrSpec { + local_part: "a", + domain: "[127.0.0.1]", + }, +) +"# + ); + + k9::snapshot!( + parse_with("a@[IPv6::1]", addr_spec), + r#" +Ok( + AddrSpec { + local_part: "a", + domain: "[IPv6::1]", + }, +) +"# + ); +} + +// atom = { cfws? ~ atext ~ cfws? } +fn atom(input: Span) -> IResult { + let (loc, text) = context("atom", delimited(opt(cfws), atext, opt(cfws)))(input)?; + Ok((loc, text.to_string())) +} + +// word = { atom | quoted_string } +fn word(input: Span) -> IResult { + context("word", alt((atom, quoted_string)))(input) +} + +// obs_local_part = { word ~ (dot ~ word)* } +#[tracable_parser] +fn obs_local_part(input: Span) -> IResult { + let (loc, (word, dotted_words)) = context( + "obs_local_part", + tuple((word, many0(tuple((char('.'), word))))), + )(input)?; + let mut result = String::new(); + + result.push_str(&word); + for (dot, w) in dotted_words { + result.push(dot); + result.push_str(&w); + } + + Ok((loc, result)) +} + +// local_part = { dot_atom | quoted_string | obs_local_part } +#[tracable_parser] +fn local_part(input: Span) -> IResult { + context("local_part", alt((dot_atom, quoted_string, obs_local_part)))(input) +} + +// domain = { dot_atom | domain_literal | obs_domain } +#[tracable_parser] +fn domain(input: Span) -> IResult { + context("domain", alt((dot_atom, domain_literal, obs_domain)))(input) +} + +// obs_domain = { atom ~ ( dot ~ atom)* } +#[tracable_parser] +fn obs_domain(input: Span) -> IResult { + let (loc, (atom, dotted_atoms)) = + context("obs_domain", tuple((atom, many0(tuple((char('.'), atom))))))(input)?; + let mut result = String::new(); + + result.push_str(&atom); + for (dot, w) in dotted_atoms { + result.push(dot); + result.push_str(&w); + } + + Ok((loc, result)) +} + +// domain_literal = { cfws? ~ "[" ~ (fws? ~ dtext)* ~ fws? ~ "]" ~ cfws? } +#[tracable_parser] +fn domain_literal(input: Span) -> IResult { + let (loc, (bits, trailer)) = context( + "domain_literal", + delimited( + opt(cfws), + delimited( + char('['), + tuple(( + many0(tuple((opt(fws), alt((satisfy(is_dtext), quoted_pair))))), + opt(fws), + )), + char(']'), + ), + opt(cfws), + ), + )(input)?; + + let mut result = String::new(); + result.push('['); + for (a, b) in bits { + if let Some(a) = a { + result.push_str(&a); + } + result.push(b); + } + if let Some(t) = trailer { + result.push_str(&t); + } + result.push(']'); + Ok((loc, result)) +} + +// dot_atom_text = @{ atext ~ ("." ~ atext)* } +// dot_atom = { cfws? ~ dot_atom_text ~ cfws? } +#[tracable_parser] +fn dot_atom(input: Span) -> IResult { + let (loc, (a, b)) = context( + "dot_atom", + delimited( + opt(cfws), + tuple((atext, many0(preceded(char('.'), atext)))), + opt(cfws), + ), + )(input)?; + + let mut result = String::new(); + result.push_str(&a); + for item in b { + result.push('.'); + result.push_str(&item); + } + + Ok((loc, result)) +} + +#[cfg(test)] +#[test] +fn test_dot_atom() { + k9::snapshot!( + parse_with("hello", dot_atom), + r#" +Ok( + "hello", +) +"# + ); + + k9::snapshot!( + parse_with("hello.there", dot_atom), + r#" +Ok( + "hello.there", +) +"# + ); + + k9::snapshot!( + parse_with("hello.", dot_atom), + r#" +Err( + HeaderParse( + "0: at line 1, in Eof: +hello. + ^ + +", + ), +) +"# + ); + + k9::snapshot!( + parse_with("(wat)hello", dot_atom), + r#" +Ok( + "hello", +) +"# + ); +} + +// cfws = { ( (fws? ~ comment)+ ~ fws?) | fws } +#[tracable_parser] +fn cfws(input: Span) -> IResult { + context( + "cfws", + recognize(alt(( + recognize(tuple((many1(tuple((opt(fws), comment))), opt(fws)))), + fws, + ))), + )(input) +} + +// comment = { "(" ~ (fws? ~ ccontent)* ~ fws? ~ ")" } +#[tracable_parser] +fn comment(input: Span) -> IResult { + context( + "comment", + recognize(tuple(( + char('('), + many0(tuple((opt(fws), ccontent))), + opt(fws), + char(')'), + ))), + )(input) +} + +#[cfg(test)] +#[test] +fn test_comment() { + k9::snapshot!( + parse_with("(wat)", comment), + r#" +Ok( + LocatedSpan { + offset: 0, + line: 1, + fragment: "(wat)", + extra: TracableInfo, + }, +) +"# + ); +} + +// ccontent = { ctext | quoted_pair | comment | encoded_word } +#[tracable_parser] +fn ccontent(input: Span) -> IResult { + context( + "ccontent", + recognize(alt(( + recognize(satisfy(is_ctext)), + recognize(quoted_pair), + comment, + recognize(encoded_word), + ))), + )(input) +} + +// quoted_pair = { ( "\\" ~ (vchar | wsp)) | obs_qp } +// obs_qp = { "\\" ~ ( "\u{00}" | obs_no_ws_ctl | "\r" | "\n") } +#[tracable_parser] +fn quoted_pair(input: Span) -> IResult { + context( + "quoted_pair", + preceded( + char('\\'), + satisfy(|c| match c { + '\u{00}' | '\r' | '\n' | ' ' => true, + c => is_obs_no_ws_ctl(c) || is_vchar(c), + }), + ), + )(input) +} + +// encoded_word = { "=?" ~ charset ~ ("*" ~ language)? ~ "?" ~ encoding ~ "?" ~ encoded_text ~ "?=" } +#[tracable_parser] +fn encoded_word(input: Span) -> IResult { + let (loc, (charset, _language, _, encoding, _, text)) = context( + "encoded_word", + delimited( + tag("=?"), + tuple(( + charset, + opt(preceded(char('*'), language)), + char('?'), + encoding, + char('?'), + encoded_text, + )), + tag("?="), + ), + )(input)?; + + let bytes = match *encoding.fragment() { + "B" | "b" => data_encoding::BASE64_MIME + .decode(text.as_bytes()) + .map_err(|_err| make_context_error(input, "encoded_word: base64 decode failed"))?, + "Q" | "q" => { + quoted_printable::decode(text.replace("_", " "), quoted_printable::ParseMode::Robust) + .map_err(|_err| { + make_context_error(input, "encoded_word: quoted printable decode failed") + })? + } + _ => { + return Err(make_context_error( + input, + "encoded_word: invalid encoding, expected one of b, B, q or Q", + )); + } + }; + + let charset = Charset::for_label_no_replacement(charset.as_bytes()) + .ok_or_else(|| make_context_error(input, "encoded_word: unsupported charset"))?; + + let (decoded, _malformed) = charset.decode_without_bom_handling(&bytes); + + Ok((loc, decoded.to_string())) +} + +// token = { !(" " | especials | ctl) ~ char } +#[tracable_parser] +fn token(input: Span) -> IResult { + context("token", satisfy(is_token))(input) +} + +// charset = @{ (!"*" ~ token)+ } +#[tracable_parser] +fn charset(input: Span) -> IResult { + context("charset", take_while1(|c| c != '*' && is_token(c)))(input) +} + +// language = @{ token+ } +#[tracable_parser] +fn language(input: Span) -> IResult { + context("language", take_while1(|c| c != '*' && is_token(c)))(input) +} + +// encoding = @{ token+ } +#[tracable_parser] +fn encoding(input: Span) -> IResult { + context("encoding", take_while1(|c| c != '*' && is_token(c)))(input) +} + +// encoded_text = @{ (!( " " | "?") ~ vchar)+ } +#[tracable_parser] +fn encoded_text(input: Span) -> IResult { + context( + "encoded_text", + take_while1(|c| is_vchar(c) && c != ' ' && c != '?'), + )(input) +} + +// quoted_string = { cfws? ~ "\"" ~ (fws? ~ qcontent)* ~ fws? ~ "\"" ~ cfws? } +#[tracable_parser] +fn quoted_string(input: Span) -> IResult { + let (loc, (bits, trailer)) = context( + "quoted_string", + delimited( + opt(cfws), + delimited( + char('"'), + tuple((many0(tuple((opt(fws), qcontent))), opt(fws))), + char('"'), + ), + opt(cfws), + ), + )(input)?; + + let mut result = String::new(); + for (a, b) in bits { + if let Some(a) = a { + result.push_str(&a); + } + result.push(b); + } + if let Some(t) = trailer { + result.push_str(&t); + } + Ok((loc, result)) +} + +// qcontent = { qtext | quoted_pair } +#[tracable_parser] +fn qcontent(input: Span) -> IResult { + context("qcontent", alt((satisfy(is_qtext), quoted_pair)))(input) +} + #[derive(Parser)] #[grammar = "rfc5322.pest"] pub struct Parser; impl Parser { pub fn parse_mailbox_list_header(text: &str) -> Result { - let mut pairs = Self::parse(Rule::parse_mailbox_list, text) - .map_err(|err| MailParsingError::HeaderParse(format!("{err:#}")))? - .next() - .unwrap() - .into_inner(); - - let mut result: Vec = vec![]; - - while let Some(pair) = pairs.next() { - result.push(Self::parse_mailbox(pair.into_inner())?); - } - - Ok(MailboxList(result)) + parse_with(text, mailbox_list) } fn parse_mailbox_list(pairs: Pairs) -> Result { @@ -37,13 +843,7 @@ impl Parser { } pub fn parse_mailbox_header(text: &str) -> Result { - let pairs = Self::parse(Rule::parse_mailbox, text) - .map_err(|err| MailParsingError::HeaderParse(format!("{err:#}")))? - .next() - .unwrap() - .into_inner(); - - Self::parse_mailbox(pairs) + parse_with(text, mailbox) } pub fn parse_address_list_header(text: &str) -> Result { @@ -784,6 +1584,12 @@ impl Parser { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AddrSpec { + pub local_part: String, + pub domain: String, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum Address { Mailbox(Mailbox), @@ -799,7 +1605,7 @@ pub struct MailboxList(Vec); #[derive(Debug, Clone, PartialEq, Eq)] pub struct Mailbox { pub name: Option, - pub address: String, + pub address: String, // FIXME: AddrSpec } #[derive(Debug, Clone, PartialEq, Eq)]