diff --git a/Cargo.lock b/Cargo.lock index 9844dbed..2cb00d86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5289,6 +5289,7 @@ name = "nom-utils" version = "0.1.0" dependencies = [ "bstr", + "hickory-resolver", "k9", "nom 8.0.0", "nom_locate", @@ -6844,18 +6845,21 @@ name = "rfc5321" version = "0.1.0" dependencies = [ "anyhow", + "bstr", "data-encoding", "duration-serde", "hickory-proto", "idna", + "k9", "kumo-tls-helper", "libc", "linkme", "lruttl", "memchr", + "nom 8.0.0", + "nom-utils", "openssl", - "pest", - "pest_derive", + "pastey", "rustls", "rustls-pemfile", "rustls-platform-verifier", diff --git a/crates/integration-tests/src/kumod.rs b/crates/integration-tests/src/kumod.rs index 2a44df75..6cee88d2 100644 --- a/crates/integration-tests/src/kumod.rs +++ b/crates/integration-tests/src/kumod.rs @@ -12,9 +12,8 @@ use maildir::{MailEntry, Maildir}; use mailparsing::MessageBuilder; use nix::unistd::{Uid, User}; use parking_lot::Mutex; -use rfc5321::{ - BatchSendSuccess, ForwardPath, Response, ReversePath, SmtpClient, SmtpClientTimeouts, -}; +use rfc5321::parser::{ForwardPath, ReversePath}; +use rfc5321::{BatchSendSuccess, Response, SmtpClient, SmtpClientTimeouts}; use sqlite::{Connection, State}; use std::collections::BTreeMap; use std::net::SocketAddr; diff --git a/crates/integration-tests/src/test/max_line_length.rs b/crates/integration-tests/src/test/max_line_length.rs index 4f853142..9ab61e1b 100644 --- a/crates/integration-tests/src/test/max_line_length.rs +++ b/crates/integration-tests/src/test/max_line_length.rs @@ -1,5 +1,6 @@ use crate::kumod::{DaemonWithMaildir, MailGenParams}; use k9::assert_equal; +use rfc5321::parser::Command; use rfc5321::*; /// test maximum line length for a single SMTP command diff --git a/crates/integration-tests/src/test/no_ports_in_rcpt_domain.rs b/crates/integration-tests/src/test/no_ports_in_rcpt_domain.rs index ae52ff56..6d058c2f 100644 --- a/crates/integration-tests/src/test/no_ports_in_rcpt_domain.rs +++ b/crates/integration-tests/src/test/no_ports_in_rcpt_domain.rs @@ -1,6 +1,6 @@ use crate::kumod::DaemonWithMaildir; use anyhow::Context; -use rfc5321::Command; +use rfc5321::parser::Command; #[tokio::test] async fn no_ports_in_rcpt_domain() -> anyhow::Result<()> { @@ -18,7 +18,7 @@ async fn no_ports_in_rcpt_domain() -> anyhow::Result<()> { }) .await?; let resp = client - .send_command(&Command::RawLine( + .send_command(&Command::Unknown( "RCPT TO:".into(), )) .await?; @@ -29,12 +29,7 @@ async fn no_ports_in_rcpt_domain() -> anyhow::Result<()> { Response { code: 501, enhanced_code: None, - content: "Syntax error in command or arguments: --> 1:28 - | -1 | RCPT TO: - | ^--- - | - = expected alpha, digit, or utf8_non_ascii", + content: "Syntax error in command or arguments", command: Some( "RCPT TO:\r ", diff --git a/crates/integration-tests/src/test/xclient.rs b/crates/integration-tests/src/test/xclient.rs index 525a24e6..a8d8046e 100644 --- a/crates/integration-tests/src/test/xclient.rs +++ b/crates/integration-tests/src/test/xclient.rs @@ -1,7 +1,7 @@ use crate::kumod::DaemonWithMaildirOptions; use anyhow::Context; use kumo_api_types::TraceSmtpV1Payload::Callback; -use rfc5321::{Command, XClientParameter}; +use rfc5321::parser::{Command, XClientParameter}; use std::time::Duration; #[tokio::test] diff --git a/crates/kumo-api-types/src/shaping.rs b/crates/kumo-api-types/src/shaping.rs index 09426e73..d9dc76cd 100644 --- a/crates/kumo-api-types/src/shaping.rs +++ b/crates/kumo-api-types/src/shaping.rs @@ -342,7 +342,7 @@ impl ShapingInner { } pub async fn match_rules(&self, record: &JsonLogRecord) -> anyhow::Result> { - use rfc5321::ForwardPath; + use rfc5321::parser::ForwardPath; // Extract the domain from the recipient. let recipient = ForwardPath::try_from( record diff --git a/crates/kumo-log-types/src/rfc3464.rs b/crates/kumo-log-types/src/rfc3464.rs index 07d65f39..dac5382f 100644 --- a/crates/kumo-log-types/src/rfc3464.rs +++ b/crates/kumo-log-types/src/rfc3464.rs @@ -8,7 +8,7 @@ use anyhow::{anyhow, Context}; use bstr::{BStr, BString, ByteSlice}; use chrono::{DateTime, Utc}; use mailparsing::MimePart; -use rfc5321::EnvelopeAddress; +use rfc5321::parser::EnvelopeAddress; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::str::FromStr; diff --git a/crates/kumo-log-types/src/rfc5965.rs b/crates/kumo-log-types/src/rfc5965.rs index b0c358d4..ea1c1c5b 100644 --- a/crates/kumo-log-types/src/rfc5965.rs +++ b/crates/kumo-log-types/src/rfc5965.rs @@ -4,7 +4,7 @@ use anyhow::anyhow; use bstr::{BStr, BString, ByteSlice}; use chrono::{DateTime, Utc}; use mailparsing::{Header, HeaderParseResult, MimePart}; -use rfc5321::EnvelopeAddress; +use rfc5321::parser::EnvelopeAddress; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::str::FromStr; diff --git a/crates/kumod/src/smtp_dispatcher.rs b/crates/kumod/src/smtp_dispatcher.rs index 81adbe17..e121c3f6 100644 --- a/crates/kumod/src/smtp_dispatcher.rs +++ b/crates/kumod/src/smtp_dispatcher.rs @@ -20,9 +20,10 @@ use kumo_server_runtime::spawn; use message::message::QueueNameComponents; use message::Message; use mta_sts::policy::PolicyMode; +use rfc5321::parser::{ForwardPath, ReversePath}; use rfc5321::{ - ClientError, EnhancedStatusCode, ForwardPath, IsTooManyRecipients, Response, ReversePath, - SmtpClient, TlsInformation, TlsOptions, TlsStatus, + ClientError, EnhancedStatusCode, IsTooManyRecipients, Response, SmtpClient, TlsInformation, + TlsOptions, TlsStatus, }; use serde::{Deserialize, Serialize}; use spool::SpoolId; @@ -793,7 +794,7 @@ impl SmtpDispatcher { // be busted by the failed handshake and never succeed tokio::time::timeout( tokio::time::Duration::from_secs(2), - client.send_command(&rfc5321::Command::Quit), + client.send_command(&rfc5321::parser::Command::Quit), ) .await .ok(); @@ -990,7 +991,10 @@ impl SmtpDispatcher { impl QueueDispatcher for SmtpDispatcher { async fn close_connection(&mut self, _dispatcher: &mut Dispatcher) -> anyhow::Result { if let Some(mut client) = self.client.take() { - client.send_command(&rfc5321::Command::Quit).await.ok(); + client + .send_command(&rfc5321::parser::Command::Quit) + .await + .ok(); // Close out this dispatcher and let the maintainer spawn // a new connection Ok(true) @@ -1042,10 +1046,12 @@ impl QueueDispatcher for SmtpDispatcher { .sender() .await? .try_into() - .map_err(|err| anyhow::anyhow!("{err}"))?; + .map_err(|err: &str| anyhow::anyhow!("{err}"))?; let mut recipients: Vec = vec![]; for recip in msg.recipient_list().await? { - let recip: ForwardPath = recip.try_into().map_err(|err| anyhow::anyhow!("{err:#}"))?; + let recip: ForwardPath = recip + .try_into() + .map_err(|err: &str| anyhow::anyhow!("{err:#}"))?; recips_this_txn.insert( (spool_id, recip.clone()), 1 + self @@ -1108,7 +1114,7 @@ impl QueueDispatcher for SmtpDispatcher { if recipients_this_batch.len() < path_config.max_recipients_per_batch { recipients_this_batch.push(recip); } else { - revised_recipient_list.push(recip.into()); + revised_recipient_list.push(message::EnvelopeAddress::from(recip)); // The excess is ready to go immediately retry_immediately = true; } @@ -1183,7 +1189,7 @@ impl QueueDispatcher for SmtpDispatcher { detail: 2, }), content: reason.clone(), - command: command.as_ref().map(|c| c.encode()), + command: command.as_ref().map(|c| c.encode().to_string()), }); } } @@ -1300,7 +1306,7 @@ impl QueueDispatcher for SmtpDispatcher { }; if record_type == RecordType::TransientFailure { - revised_recipient_list.push(recipient.clone().into()); + revised_recipient_list.push(message::EnvelopeAddress::from(recipient.clone())); } if record_type != RecordType::Delivery && overall_response.is_none() { overall_response.replace(response.clone()); diff --git a/crates/kumod/src/smtp_server.rs b/crates/kumod/src/smtp_server.rs index 1649069d..0c98075c 100644 --- a/crates/kumod/src/smtp_server.rs +++ b/crates/kumod/src/smtp_server.rs @@ -35,10 +35,8 @@ use mlua::{FromLuaMulti, IntoLuaMulti, LuaSerdeExt, UserData, UserDataMethods}; use openssl::x509::X509; use parking_lot::FairMutex as Mutex; use ppp::{HeaderResult, PartialResult}; -use rfc5321::{ - subject_name, AsyncReadAndWrite, BoxedAsyncReadAndWrite, Command, Response, TlsInformation, - XClientParameter, -}; +use rfc5321::parser::{Command, MaybePartialCommand, XClientParameter}; +use rfc5321::{subject_name, AsyncReadAndWrite, BoxedAsyncReadAndWrite, Response, TlsInformation}; use rustls::ServerConfig; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -1693,7 +1691,16 @@ impl SmtpServerSession { ) .await?; } - Ok(Command::Quit) => { + Ok(MaybePartialCommand::Partial { .. }) => { + self.write_response( + 501, + "Syntax error in command or arguments", + Some(line), + RejectDisconnect::If421, + ) + .await?; + } + Ok(MaybePartialCommand::Full(Command::Quit)) => { self.write_response( 221, "So long, and thanks for all the fish!", @@ -1703,7 +1710,7 @@ impl SmtpServerSession { .await?; return Ok(()); } - Ok(Command::StartTls) => { + Ok(MaybePartialCommand::Full(Command::StartTls)) => { if self.tls_active.is_some() { self.write_response( 501, @@ -1772,17 +1779,17 @@ impl SmtpServerSession { }; self.socket.replace(socket); } - Ok(Command::Auth { + Ok(MaybePartialCommand::Full(Command::Auth { sasl_mech, initial_response, - }) => { + })) => { if self.process_auth(line, sasl_mech, initial_response).await? == CommandDisposition::Terminate { return Ok(()); } } - Ok(Command::Ehlo(domain)) => { + Ok(MaybePartialCommand::Full(Command::Ehlo(domain))) => { let domain = domain.to_string(); let mut extensions = @@ -1823,7 +1830,7 @@ impl SmtpServerSession { self.meta.set_meta("ehlo_domain", domain.clone()); self.said_hello.replace(domain); } - Ok(Command::Helo(domain)) => { + Ok(MaybePartialCommand::Full(Command::Helo(domain))) => { let domain = domain.to_string(); if let Err(rej) = self @@ -1847,10 +1854,10 @@ impl SmtpServerSession { self.meta.set_meta("ehlo_domain", domain.clone()); self.said_hello.replace(domain); } - Ok(Command::MailFrom { + Ok(MaybePartialCommand::Full(Command::MailFrom { address, parameters: _, - }) => { + })) => { if self.state.is_some() { self.write_response( 503, @@ -1861,7 +1868,7 @@ impl SmtpServerSession { .await?; continue; } - let address = match EnvelopeAddress::parse(&address.to_string()) { + let address = match EnvelopeAddress::try_from(address) { Ok(address) => address, Err(err) => { self.write_response( @@ -1899,10 +1906,10 @@ impl SmtpServerSession { ) .await?; } - Ok(Command::RcptTo { + Ok(MaybePartialCommand::Full(Command::RcptTo { address, parameters: _, - }) => { + })) => { if self.state.is_none() { self.write_response( 503, @@ -1913,19 +1920,7 @@ impl SmtpServerSession { .await?; continue; } - let address = match EnvelopeAddress::parse(&address.to_string()) { - Ok(address) => address, - Err(err) => { - self.write_response( - 501, - format!("5.1.3 Invalid recipient address syntax: {err}"), - Some(line), - RejectDisconnect::If421, - ) - .await?; - continue; - } - }; + let address = EnvelopeAddress::from(address); let sender = self.state.as_ref().unwrap().sender.clone(); let relay_disposition = self.check_relaying(&sender, &address).await?; @@ -2012,7 +2007,7 @@ impl SmtpServerSession { .recipients .push(address); } - Ok(Command::Data) => { + Ok(MaybePartialCommand::Full(Command::Data)) => { if self.state.is_none() { self.write_response( 503, @@ -2097,12 +2092,12 @@ impl SmtpServerSession { let _process_data_timer = PROCESS_DATA_LATENCY.start_timer(); Box::pin(self.process_data(data, &activity)).await?; } - Ok(Command::Rset) => { + Ok(MaybePartialCommand::Full(Command::Rset)) => { self.state.take(); self.write_response(250, "Reset state", None, RejectDisconnect::If421) .await?; } - Ok(Command::Noop(_)) => { + Ok(MaybePartialCommand::Full(Command::Noop(_))) => { self.write_response( 250, "the goggles do nothing", @@ -2111,16 +2106,16 @@ impl SmtpServerSession { ) .await?; } - Ok(Command::XClient(params)) => { + Ok(MaybePartialCommand::Full(Command::XClient(params))) => { self.process_xclient(¶ms).await?; } - Ok( + Ok(MaybePartialCommand::Full( Command::Vrfy(_) | Command::Expn(_) | Command::Help(_) | Command::Lhlo(_) - | Command::RawLine(_), - ) => { + | Command::Unknown(_), + )) => { self.write_response( 502, format!("5.5.1 Command unimplemented"), @@ -2129,7 +2124,7 @@ impl SmtpServerSession { ) .await?; } - Ok(Command::DataDot) => unreachable!(), + Ok(MaybePartialCommand::Full(Command::DataDot)) => unreachable!(), } } } @@ -2318,14 +2313,11 @@ impl SmtpServerSession { let mut dest_port: Option = None; for p in params { - let name = &p.name; - let value = &p.value; - - if name.eq_ignore_ascii_case("ADDR") { - let Ok(ip) = value.parse::() else { + if p.is_name("ADDR") { + let Ok(ip) = p.parse::() else { self.write_response( 501, - format!("ADDR {value} is invalid"), + format!("ADDR {} is invalid", p.value), None, RejectDisconnect::If421, ) @@ -2333,11 +2325,11 @@ impl SmtpServerSession { return Ok(()); }; addr.replace(ip); - } else if name.eq_ignore_ascii_case("PORT") { - let Ok(v) = value.parse::() else { + } else if p.is_name("PORT") { + let Ok(v) = p.parse::() else { self.write_response( 501, - format!("PORT {value} is invalid"), + format!("PORT {} is invalid", p.value), None, RejectDisconnect::If421, ) @@ -2345,11 +2337,11 @@ impl SmtpServerSession { return Ok(()); }; port.replace(v); - } else if name.eq_ignore_ascii_case("DESTADDR") { - let Ok(ip) = value.parse::() else { + } else if p.is_name("DESTADDR") { + let Ok(ip) = p.parse::() else { self.write_response( 501, - format!("ADDR {value} is invalid"), + format!("DESTADDR {} is invalid", p.value), None, RejectDisconnect::If421, ) @@ -2357,11 +2349,11 @@ impl SmtpServerSession { return Ok(()); }; dest_addr.replace(ip); - } else if name.eq_ignore_ascii_case("DESTPORT") { - let Ok(v) = value.parse::() else { + } else if p.is_name("DESTPORT") { + let Ok(v) = p.parse::() else { self.write_response( 501, - format!("PORT {value} is invalid"), + format!("DESTPORT {} is invalid", p.value), None, RejectDisconnect::If421, ) @@ -2372,7 +2364,7 @@ impl SmtpServerSession { } else { self.write_response( 501, - format!("parameter {name} is not supported"), + format!("parameter {} is not supported", p.name), None, RejectDisconnect::If421, ) diff --git a/crates/message/src/address.rs b/crates/message/src/address.rs index 9d5c005f..4a7af9c4 100644 --- a/crates/message/src/address.rs +++ b/crates/message/src/address.rs @@ -4,7 +4,7 @@ use config::any_err; use mailparsing::{Address, AddressList, EncodeHeaderValue, Mailbox}; #[cfg(feature = "impl")] use mlua::{FromLua, MetaMethod, UserData, UserDataFields, UserDataMethods}; -use rfc5321::{EnvelopeAddress as EnvelopeAddress5321, ForwardPath, ReversePath}; +use rfc5321::parser::{EnvelopeAddress as EnvelopeAddress5321, ForwardPath, ReversePath}; use serde::{Deserialize, Serialize}; #[derive(Clone, PartialEq, Serialize, Deserialize, Eq)] @@ -97,14 +97,14 @@ impl TryInto for &Address { } impl TryInto for EnvelopeAddress { - type Error = String; + type Error = &'static str; fn try_into(self) -> Result { self.0.try_into() } } impl TryInto for EnvelopeAddress { - type Error = String; + type Error = &'static str; fn try_into(self) -> Result { self.0.try_into() } @@ -116,6 +116,14 @@ impl From for EnvelopeAddress { } } +impl TryFrom for EnvelopeAddress { + type Error = &'static str; + + fn try_from(reverse_path: ReversePath) -> Result { + EnvelopeAddress5321::try_from(reverse_path).map(EnvelopeAddress) + } +} + #[cfg(feature = "impl")] impl UserData for EnvelopeAddress { fn add_fields>(fields: &mut F) { @@ -348,12 +356,11 @@ mod test { k9::snapshot!( EnvelopeAddress::parse("user@example.com:2025").unwrap_err(), " - --> 1:17 - | -1 | user@example.com:2025 - | ^--- - | - = expected EOI, alpha, digit, or utf8_non_ascii +Error at line 1, in Eof: +user@example.com:2025 + ^____ + + " ); } diff --git a/crates/nom-utils/Cargo.toml b/crates/nom-utils/Cargo.toml index 260691f7..170b6ebf 100644 --- a/crates/nom-utils/Cargo.toml +++ b/crates/nom-utils/Cargo.toml @@ -5,8 +5,9 @@ edition = "2021" [dependencies] bstr.workspace = true +hickory-resolver.workspace = true nom = {workspace=true} nom_locate = {workspace=true} [dev-dependencies] -k9 = {workspace=true} \ No newline at end of file +k9 = {workspace=true} diff --git a/crates/nom-utils/src/lib.rs b/crates/nom-utils/src/lib.rs index 0eea1214..5e339db7 100644 --- a/crates/nom-utils/src/lib.rs +++ b/crates/nom-utils/src/lib.rs @@ -1,9 +1,18 @@ use bstr::{BStr, ByteSlice}; -use nom::error::{ContextError, ErrorKind}; -use nom::Input; +use hickory_resolver::Name; +use nom::branch::alt; +use nom::bytes::complete::{take_while1, take_while_m_n}; +use nom::combinator::{map_res, opt, recognize}; +use nom::error::{context, ContextError, ErrorKind, FromExternalError, ParseError as _}; +use nom::multi::{many0, many1}; +use nom::sequence::pair; +use nom::{Input, Parser as _}; use nom_locate::LocatedSpan; -use std::fmt::{Debug, Write}; +use std::fmt::{self, Debug, Write}; +use std::hash::Hash; use std::marker::PhantomData; +use std::net::{Ipv4Addr, Ipv6Addr}; +use std::str::FromStr; pub type Span<'a> = LocatedSpan<&'a [u8]>; pub type IResult<'a, A, B> = nom::IResult>>; @@ -18,6 +27,15 @@ pub fn make_span(s: &'_ [u8]) -> Span<'_> { pub fn tag(tag: &'static str) -> TagParser { TagParser { tag, + no_case: false, + e: PhantomData, + } +} + +pub fn tag_no_case(tag: &'static str) -> TagParser { + TagParser { + tag, + no_case: true, e: PhantomData, } } @@ -25,6 +43,7 @@ pub fn tag(tag: &'static str) -> TagParser { /// Struct to support displaying better errors for tag() pub struct TagParser { tag: &'static str, + no_case: bool, e: PhantomData, } @@ -46,7 +65,13 @@ where let tag_len = self.tag.input_len(); - match i.compare(self.tag) { + let compare_result = if self.no_case { + i.compare_no_case(self.tag) + } else { + i.compare(self.tag) + }; + + match compare_result { CompareResult::Ok => Ok((i.take_from(tag_len), OM::Output::bind(|| i.take(tag_len)))), CompareResult::Incomplete => Err(Err::Error(OM::Error::bind(|| { Error::from_external_error( @@ -252,3 +277,231 @@ pub fn explain_nom(input: Span, err: nom::Err>>) -> String { _ => format!("{err:#}"), } } + +/// See the following RFCs: +/// * +/// * +/// * +/// which define a bunch of ABNF, but then caps it off with: +/// > The authoritative definition of UTF-8 is in [UNICODE]. This +/// > grammar is believed to describe the same thing Unicode describes, but +/// > does not claim to be authoritative. Implementors are urged to rely +/// > on the authoritative source, rather than on this ABNF. +pub fn utf8_non_ascii(input: Span) -> IResult { + use nom::Err; + + match input.char_indices().next() { + Some((start, end, c)) => { + let len = end - start; + if c as u32 <= 0x7f { + // It's ASCII, therefore doesn't match as utf8_non_ascii + return Err(Err::Error(ParseError::from_error_kind( + input, + ErrorKind::Fail, + ))); + } + let slice = &input[start..end]; + if c == std::char::REPLACEMENT_CHARACTER { + let mut verify = [0u8; 4]; + if slice != c.encode_utf8(&mut verify).as_bytes() { + // The original sequence wasn't REPLACEMENT_CHARACTER, + // therefore the input is not valid UTF-8 + return Err(Err::Error(ParseError::from_error_kind( + input, + ErrorKind::Fail, + ))); + } + } + // slice is the first UTF-8 character in the input + Ok((input.take_from(len), input.take(len))) + } + None => { + // There's no input, therefore we cannot match + Err(Err::Error(ParseError::from_error_kind( + input, + ErrorKind::Eof, + ))) + } + } +} + +fn snum(input: Span) -> IResult { + take_while_m_n(1, 3, |c: u8| c.is_ascii_digit()).parse(input) +} + +pub fn ipv4_address(input: Span) -> IResult { + context( + "ipv4_address", + map_res( + recognize((snum, tag("."), snum, tag("."), snum, tag("."), snum)), + |matched| { + let v4str = std::str::from_utf8(&matched).expect("can only be ascii"); + v4str.parse().map_err(|err| { + nom::Err::Error(ParseError::from_external_error( + input, + ErrorKind::Fail, + format!("invalid ipv4_address: {err}"), + )) + }) + }, + ), + ) + .parse(input) +} + +pub fn ipv6_address(input: Span) -> IResult { + context( + "ipv6_address", + map_res( + take_while1(|c: u8| c.is_ascii_hexdigit() || c == b':' || c == b'.'), + |matched: Span| { + let v6str = std::str::from_utf8(&matched).expect("can only be ascii"); + v6str.parse().map_err(|err| { + nom::Err::Error(ParseError::from_external_error( + input, + ErrorKind::Fail, + format!("invalid ipv6_address: {err}"), + )) + }) + }, + ), + ) + .parse(input) +} + +/// A validated DNS domain name, stored in normalized (ASCII/punycode) form. +/// The original wire-format string (which may have been a UTF-8 U-label) +/// is not preserved; only the IDNA-normalized A-label form is kept. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct DomainString(String); + +impl fmt::Display for DomainString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl DomainString { + pub fn name(&self) -> Name { + Name::from_str_relaxed(&self.0) + .expect("cannot construct DomainString with an invalid domain name") + } + + /// Returns a reference to the normalized (ASCII/punycode) domain string. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl FromStr for DomainString { + type Err = String; + fn from_str(s: &str) -> Result { + let name = Name::from_str_relaxed(s)?; + Ok(Self(name.to_ascii())) + } +} + +impl From for Name { + fn from(val: DomainString) -> Self { + val.name() + } +} + +impl From<&DomainString> for Name { + fn from(val: &DomainString) -> Self { + val.name() + } +} + +/// `let-dig = ALPHA / DIGIT / UTF8-non-ASCII` +fn let_dig(input: Span) -> IResult { + recognize(alt(( + take_while_m_n(1, 1, |c: u8| c.is_ascii_alphanumeric()), + utf8_non_ascii, + ))) + .parse(input) +} + +/// `ldh-str = *( ALPHA / DIGIT / "-" / UTF8-non-ASCII )` (one or more) +/// +/// As an extension to the mail RFCs, we allow for underscore +/// in domain names, as those are a commonly deployed name, despite it +/// being in violation of the DNS RFCs. +fn ldh_str(input: Span) -> IResult { + recognize(many1(alt(( + take_while_m_n(1, 1, |c: u8| { + c.is_ascii_alphanumeric() || c == b'-' || c == b'_' + }), + utf8_non_ascii, + )))) + .parse(input) +} + +/// `sub-domain = let-dig [ ldh-str ]` +fn sub_domain(input: Span) -> IResult { + recognize(pair(let_dig, opt(ldh_str))).parse(input) +} + +/// `domain = sub-domain *( "." sub-domain )` +pub fn domain_name(input: Span) -> IResult { + context( + "domain-name", + map_res( + recognize(pair(sub_domain, many0(pair(tag("."), sub_domain)))), + |matched: Span| match std::str::from_utf8(&matched) { + Ok(s) => s.parse().map_err(|err| { + nom::Err::Error(ParseError::from_external_error( + input, + ErrorKind::Fail, + format!("invalid domain name: {err}"), + )) + }), + Err(err) => Err(nom::Err::Error(ParseError::from_external_error( + input, + ErrorKind::Fail, + format!("invalid domain name: {err}"), + ))), + }, + ), + ) + .parse(input) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ipv4_parse() { + // ipv4_address should parse valid IPv4 addresses + let (_, addr) = ipv4_address(make_span(b"192.168.1.1")).unwrap(); + k9::assert_equal!(addr, Ipv4Addr::new(192, 168, 1, 1)); + } + + #[test] + fn test_ipv6_parse() { + // ipv6_address should parse valid IPv6 addresses, + // and different representations of the same address should be equal + let (_, v6a) = ipv6_address(make_span(b"2001:0db8:0000:0000:0000:0000:0000:0001")).unwrap(); + let (_, v6b) = ipv6_address(make_span(b"2001:db8::1")).unwrap(); + k9::assert_equal!(v6a, v6b); + } + + #[test] + fn test_domain_string_partial_eq() { + // DomainString should compare equal if they normalize to the same domain + let d1 = DomainString::from_str("EXAMPLE.COM").unwrap(); + let d2 = DomainString::from_str("example.com").unwrap(); + + assert_eq!(d1, d2); + } + + #[test] + fn test_domain_string_partial_eq_idna() { + // DomainString should compare equal after IDNA normalization + let d1 = DomainString::from_str("münchen.de").unwrap(); + let d2 = DomainString::from_str("xn--mnchen-3ya.de").unwrap(); + + assert_eq!(d1, d2); + } +} diff --git a/crates/rfc5321/Cargo.toml b/crates/rfc5321/Cargo.toml index dd55c022..440972d2 100644 --- a/crates/rfc5321/Cargo.toml +++ b/crates/rfc5321/Cargo.toml @@ -19,6 +19,7 @@ client = [ [dependencies] anyhow = {workspace=true} +bstr.workspace = true data-encoding = {workspace=true} duration-serde = {path="../duration-serde"} hickory-proto = {workspace=true, optional=true} @@ -28,9 +29,10 @@ libc = {workspace=true} linkme.workspace = true lruttl = {path="../lruttl", optional=true} memchr = {workspace=true} +nom.workspace = true +nom-utils = {path="../nom-utils"} openssl = {workspace=true, optional=true} -pest = {workspace=true} -pest_derive = {workspace=true} +pastey.workspace = true rustls = {workspace=true} rustls-pemfile = { workspace = true } rustls-platform-verifier = {workspace=true,optional=true} @@ -40,3 +42,6 @@ tokio = {workspace=true, features=["full"], optional=true} tokio-rustls = {workspace=true, optional=true} tokio-openssl = {workspace=true, optional=true} tracing = {workspace=true, optional=true} + +[dev-dependencies] +k9.workspace = true diff --git a/crates/rfc5321/fuzz/fuzz_targets/parser.rs b/crates/rfc5321/fuzz/fuzz_targets/parser.rs index 2bd35979..ad131d6d 100644 --- a/crates/rfc5321/fuzz/fuzz_targets/parser.rs +++ b/crates/rfc5321/fuzz/fuzz_targets/parser.rs @@ -3,6 +3,6 @@ use libfuzzer_sys::fuzz_target; fuzz_target!(|data: &[u8]| { if let Ok(s) = std::str::from_utf8(data) { - let _ = rfc5321::Command::parse(s); + let _ = rfc5321::parser::Command::parse(s); } }); diff --git a/crates/rfc5321/src/client.rs b/crates/rfc5321/src/client.rs index a4de5fa7..2424cffb 100644 --- a/crates/rfc5321/src/client.rs +++ b/crates/rfc5321/src/client.rs @@ -1,11 +1,11 @@ #![allow(clippy::result_large_err)] use crate::client_types::*; -use crate::{ - AsyncReadAndWrite, BoxedAsyncReadAndWrite, Command, Domain, EsmtpParameter, ForwardPath, - ReversePath, -}; +use crate::parser::{Command, Domain, EsmtpParameter, ForwardPath, ReversePath}; +use crate::{AsyncReadAndWrite, BoxedAsyncReadAndWrite}; +use bstr::ByteSlice; use hickory_proto::rr::rdata::TLSA; use memchr::memmem::Finder; +use nom_utils::DomainString; use openssl::x509::{X509Ref, X509}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -86,12 +86,15 @@ impl ClientError { | Self::ReadError { command: Some(command), .. - } => Some(command.encode()), + } => Some(command.encode().to_string()), Self::TimeOutRequest { commands, .. } | Self::WriteError { commands, .. } if !commands.is_empty() => { - let commands: Vec = commands.into_iter().map(|cmd| cmd.encode()).collect(); - Some(commands.join("")) + let s: String = commands + .iter() + .map(|cmd| cmd.encode().to_string()) + .collect(); + Some(s) } _ => None, } @@ -460,7 +463,7 @@ impl SmtpClient { .map_err(ClientError::MalformedResponseLine)?; } - let response = response_builder.build(command.map(|cmd| cmd.encode())); + let response = response_builder.build(command.map(|cmd| cmd.encode().to_string())); tracing::trace!("{}: {response:?}", self.hostname); @@ -526,25 +529,29 @@ impl SmtpClient { .sum(); let mut lines: Vec = vec![]; - let mut all = String::new(); + let mut all: Vec = vec![]; for cmd in commands { let line = cmd.encode(); - all.push_str(&line); - lines.push(line); + all.extend_from_slice(&line); + lines.push(line.to_string()); } - tracing::trace!("send->{}: (PIPELINE) {all}", self.hostname); + tracing::trace!( + "send->{}: (PIPELINE) {}", + self.hostname, + all.as_bstr().escape_bytes() + ); if self.socket.is_some() { if let Some(tracer) = &self.tracer { // Send the lines individually to the tracer, so that we // don't break --terse mode - for line in lines { - WriteTracer::trace(tracer, &line); + for line in &lines { + WriteTracer::trace(tracer, line); } } } self.write_all_with_timeout( total_timeout, - all.as_bytes(), + &all, || ClientError::TimeOutRequest { duration: total_timeout, commands: commands.to_vec(), @@ -562,14 +569,14 @@ impl SmtpClient { tracing::trace!("send->{}: {line}", self.hostname); if self.socket.is_some() { if let Some(tracer) = &self.tracer { - WriteTracer::trace(tracer, &line); + WriteTracer::trace(tracer, &line.to_string()); } } let timeout_duration = command.client_timeout_request(&self.timeouts); self.write_all_with_timeout( timeout_duration, - line.as_bytes(), + &line, || ClientError::TimeOutRequest { duration: timeout_duration, commands: vec![command.clone()], @@ -681,7 +688,11 @@ impl SmtpClient { ehlo_name: &str, ) -> Result<&HashMap, ClientError> { let response = self - .send_command(&Command::Lhlo(Domain::Name(ehlo_name.to_string()))) + .send_command(&Command::Lhlo(Domain::DomainName( + ehlo_name + .parse::() + .map_err(|_| ClientError::InvalidDnsName(ehlo_name.to_string()))?, + ))) .await?; self.ehlo_common(response) } @@ -691,7 +702,11 @@ impl SmtpClient { ehlo_name: &str, ) -> Result<&HashMap, ClientError> { let response = self - .send_command(&Command::Ehlo(Domain::Name(ehlo_name.to_string()))) + .send_command(&Command::Ehlo(Domain::DomainName( + ehlo_name + .parse::() + .map_err(|_| ClientError::InvalidDnsName(ehlo_name.to_string()))?, + ))) .await?; self.ehlo_common(response) } @@ -1189,7 +1204,7 @@ pub fn subject_name(cert: &X509Ref) -> Vec { #[cfg(test)] mod test { use super::*; - use crate::{MailPath, Mailbox}; + use crate::parser::{EnvelopeAddress, MailPath, Mailbox, ReversePath}; #[test] fn test_stuffing() { @@ -1370,13 +1385,14 @@ mod test { "{:#}", ClientError::TimeOutResponse { command: Some(Command::MailFrom { - address: ReversePath::Path(MailPath { - at_domain_list: vec![], - mailbox: Mailbox { - local_part: "user".to_string(), - domain: Domain::Name("host".to_string()) - } - }), + address: { + let EnvelopeAddress::Path(p) = + EnvelopeAddress::parse("user@host").unwrap() + else { + panic!("expected Path") + }; + ReversePath::Path(p) + }, parameters: vec![], }), duration: Duration::from_secs(10), diff --git a/crates/rfc5321/src/client_types.rs b/crates/rfc5321/src/client_types.rs index ff2c8c44..62640bb6 100644 --- a/crates/rfc5321/src/client_types.rs +++ b/crates/rfc5321/src/client_types.rs @@ -1,4 +1,4 @@ -use crate::Command; +use crate::parser::{Command, MaybePartialCommand}; use serde::{Deserialize, Serialize}; use std::time::Duration; @@ -268,7 +268,7 @@ impl Response { /// a separate connection pub fn was_due_to_message(&self) -> bool { if let Some(command) = &self.command { - if let Ok(cmd) = Command::parse(command) { + if let Ok(MaybePartialCommand::Full(cmd)) = Command::parse(command) { return match cmd { Command::MailFrom { .. } | Command::RcptTo { .. } @@ -285,7 +285,7 @@ impl Response { | Command::Noop(_) | Command::Help(_) | Command::Auth { .. } - | Command::RawLine(_) + | Command::Unknown(_) | Command::XClient(_) => false, }; } diff --git a/crates/rfc5321/src/parser.rs b/crates/rfc5321/src/parser.rs index 43a06f5a..b618d43b 100644 --- a/crates/rfc5321/src/parser.rs +++ b/crates/rfc5321/src/parser.rs @@ -1,418 +1,133 @@ use crate::client_types::SmtpClientTimeouts; -use pest::iterators::{Pair, Pairs}; -use pest::Parser as _; -use pest_derive::*; +#[cfg(test)] +use bstr::BStr; +use bstr::{BString, ByteSlice}; +use nom::branch::alt; +use nom::bytes::complete::{take_while1, take_while_m_n}; +use nom::combinator::{all_consuming, map, map_res, opt, recognize}; +use nom::error::context; +use nom::multi::{many0, many1}; +use nom::sequence::pair; +use nom::Parser; +use nom_utils::{ + domain_name, explain_nom, ipv4_address, ipv6_address, make_span, tag, tag_no_case, + utf8_non_ascii, DomainString, IResult, Span, +}; +use pastey::paste; use std::borrow::Cow; -use std::str::FromStr; +use std::hash::{Hash, Hasher}; +use std::net::{Ipv4Addr, Ipv6Addr}; use std::time::Duration; -#[derive(Parser)] -#[grammar = "rfc5321.pest"] -struct Parser; - -impl Parser { - pub fn parse_command(text: &str) -> Result { - let result = Parser::parse(Rule::command, text) - .map_err(|err| format!("{err:#}"))? - .next() - .unwrap(); - - match result.as_rule() { - Rule::mail => Self::parse_mail(result.into_inner()), - Rule::rcpt => Self::parse_rcpt(result.into_inner()), - Rule::ehlo => Self::parse_ehlo(result.into_inner()), - Rule::helo => Self::parse_helo(result.into_inner()), - Rule::data => Ok(Command::Data), - Rule::rset => Ok(Command::Rset), - Rule::quit => Ok(Command::Quit), - Rule::starttls => Ok(Command::StartTls), - Rule::vrfy => Self::parse_vrfy(result.into_inner()), - Rule::expn => Self::parse_expn(result.into_inner()), - Rule::help => Self::parse_help(result.into_inner()), - Rule::noop => Self::parse_noop(result.into_inner()), - Rule::auth => Self::parse_auth(result.into_inner()), - Rule::xclient => Self::parse_xclient(result.into_inner()), - _ => Err(format!("unexpected {result:?}")), - } - } - - pub fn parse_envelope_address(text: &str) -> Result { - let result = Parser::parse(Rule::parse_envelope_address, text) - .map_err(|err| format!("{err:#}"))? - .next() - .unwrap(); - match result.as_rule() { - Rule::path | Rule::path_no_angles => { - let path = Self::parse_path(result)?; - Ok(EnvelopeAddress::Path(path)) - } - Rule::postmaster_no_angles | Rule::postmaster => Ok(EnvelopeAddress::Postmaster), - Rule::null_sender_no_angles | Rule::null_sender => Ok(EnvelopeAddress::Null), - _ => Err(format!("unexpected {result:?}")), - } - } - - fn parse_ehlo(mut pairs: Pairs) -> Result { - let domain = pairs.next().unwrap(); - Ok(Command::Ehlo(Self::parse_domain(domain)?)) - } - - fn parse_helo(mut pairs: Pairs) -> Result { - let domain = pairs.next().unwrap(); - Ok(Command::Helo(Self::parse_domain(domain)?)) - } - - fn parse_vrfy(mut pairs: Pairs) -> Result { - let param = pairs.next().unwrap().as_str().to_string(); - Ok(Command::Vrfy(param)) - } - - fn parse_expn(mut pairs: Pairs) -> Result { - let param = pairs.next().unwrap().as_str().to_string(); - Ok(Command::Expn(param)) - } - - fn parse_help(mut pairs: Pairs) -> Result { - let param = pairs.next().map(|s| s.as_str().to_string()); - Ok(Command::Help(param)) - } - - fn parse_noop(mut pairs: Pairs) -> Result { - let param = pairs.next().map(|s| s.as_str().to_string()); - Ok(Command::Noop(param)) - } - - fn parse_auth(mut pairs: Pairs) -> Result { - let sasl_mech = pairs.next().map(|s| s.as_str().to_string()).unwrap(); - let initial_response = pairs.next().map(|s| s.as_str().to_string()); - - Ok(Command::Auth { - sasl_mech, - initial_response, - }) - } - - fn parse_xclient(mut pairs: Pairs) -> Result { - let mut params = vec![]; - - while let Some(param_name) = pairs.next() { - let name = param_name.as_str().to_string(); - let value = xtext_decode(pairs.next().unwrap().as_str())?; - params.push(XClientParameter { name, value }); - } - - Ok(Command::XClient(params)) - } - - fn parse_rcpt(mut pairs: Pairs) -> Result { - let forward_path = pairs.next().unwrap().into_inner().next().unwrap(); - let mut no_angles = false; - let address = match forward_path.as_rule() { - Rule::path_no_angles => { - no_angles = true; - ForwardPath::Path(Self::parse_path(forward_path)?) - } - Rule::path => ForwardPath::Path(Self::parse_path(forward_path)?), - Rule::postmaster => ForwardPath::Postmaster, - wat => return Err(format!("unexpected {wat:?}")), - }; - - let mut parameters = vec![]; - - if let Some(params) = pairs.next() { - if no_angles { - return Err( - "must enclose address in <> if you want to use ESMTP parameters".to_string(), - ); - } - for param in params.into_inner() { - let mut iter = param.into_inner(); - let name = iter.next().unwrap().as_str().to_string(); - let value = iter.next().map(|p| p.as_str().to_string()); - parameters.push(EsmtpParameter { name, value }); - } - } - - Ok(Command::RcptTo { - address, - parameters, - }) - } - - fn parse_mail(mut pairs: Pairs) -> Result { - let reverse_path = pairs.next().unwrap().into_inner().next().unwrap(); - let mut no_angles = false; - let address = match reverse_path.as_rule() { - Rule::path_no_angles => { - no_angles = true; - ReversePath::Path(Self::parse_path(reverse_path)?) - } - Rule::path => ReversePath::Path(Self::parse_path(reverse_path)?), - Rule::null_sender => ReversePath::NullSender, - wat => return Err(format!("unexpected {wat:?}")), - }; - - let mut parameters = vec![]; - - if let Some(params) = pairs.next() { - if no_angles { - return Err( - "must enclose address in <> if you want to use ESMTP parameters".to_string(), - ); - } - for param in params.into_inner() { - let mut iter = param.into_inner(); - let name = iter.next().unwrap().as_str().to_string(); - let value = iter.next().map(|p| p.as_str().to_string()); - parameters.push(EsmtpParameter { name, value }); - } - } - - Ok(Command::MailFrom { - address, - parameters, - }) - } - - fn parse_path(path: Pair) -> Result { - let mut at_domain_list: Vec = vec![]; - for p in path.into_inner() { - match p.as_rule() { - Rule::adl => { - for pair in p.into_inner() { - if let Some(dom) = pair.into_inner().next() { - at_domain_list.push(dom.as_str().to_string()); - } - } - } - Rule::mailbox => { - let mailbox = Self::parse_mailbox(p.into_inner())?; - return Ok(MailPath { - at_domain_list, - mailbox, - }); - } - _ => unreachable!(), - } - } - unreachable!() - } - - fn parse_domain(domain: Pair) -> Result { - Ok(match domain.as_rule() { - Rule::domain => Domain::name(domain.as_str())?, - Rule::address_literal => { - let literal = domain.into_inner().next().unwrap(); - match literal.as_rule() { - Rule::ipv4_address_literal => Domain::V4(literal.as_str().to_string()), - Rule::ipv6_address_literal => { - Domain::V6(literal.into_inner().next().unwrap().as_str().to_string()) - } - Rule::general_address_literal => { - let mut literal = literal.into_inner(); - let tag = literal.next().unwrap().as_str().to_string(); - let literal = literal.next().unwrap().as_str().to_string(); - Domain::Tagged { tag, literal } - } - - _ => unreachable!(), - } - } - _ => unreachable!(), - }) - } - - fn parse_mailbox(mut mailbox: Pairs) -> Result { - let local_part = mailbox.next().unwrap().as_str().to_string(); - let domain = Self::parse_domain(mailbox.next().unwrap())?; - Ok(Mailbox { local_part, domain }) - } -} - -pub fn parse_envelope_address(text: &str) -> Result { - Parser::parse_envelope_address(text) -} - #[derive(Debug, Clone, PartialEq, Eq)] -pub enum ReversePath { - Path(MailPath), - NullSender, +pub enum CommandVerb { + Ehlo, + Helo, + Lhlo, + Mail, + Rcpt, + Data, + Rset, + Quit, + Vrfy, + Expn, + Help, + Noop, + StartTls, + Auth, + XClient, + Unknown(BString), } -impl ReversePath { +/// Domain part of a mailbox in a MAIL FROM address. +#[derive(Clone, PartialEq, Eq, Hash)] +pub enum Domain { + /// A valid DNS domain name + DomainName(DomainString), + /// An IPv4 address literal, e.g. from `[10.0.0.1]` + V4(Ipv4Addr), + /// An IPv6 address literal, e.g. from `[IPv6:::1]` + V6(Ipv6Addr), + /// A general/tagged address literal, e.g. from `[future:something]`. + /// Stores the original `"tag:literal"` string; split on the first `:` + /// when the tag or literal parts are needed individually. + Tagged(String), +} + +impl std::fmt::Debug for Domain { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Domain::DomainName(s) => write!(f, "{}", s), + Domain::V4(ip) => write!(f, "[{}]", ip), + Domain::V6(ip) => write!(f, "[IPv6:{}]", ip), + Domain::Tagged(s) => write!(f, "[{}]", s), + } + } +} + +impl std::fmt::Display for Domain { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Domain::DomainName(s) => write!(f, "{}", s), + Domain::V4(ip) => write!(f, "[{}]", ip), + Domain::V6(ip) => write!(f, "[IPv6:{}]", ip), + Domain::Tagged(s) => write!(f, "[{}]", s), + } + } +} + +impl Domain { + /// Returns true if the wire representation of this domain is pure ASCII. + /// + /// `DomainName` is always normalized to ASCII punycode on the wire, so it + /// is always considered ASCII here. IP address literals are inherently + /// ASCII. Tagged literals are checked character-by-character. pub fn is_ascii(&self) -> bool { match self { - Self::Path(path) => path.is_ascii(), - Self::NullSender => true, + Domain::DomainName(_) | Domain::V4(_) | Domain::V6(_) => true, + Domain::Tagged(s) => s.is_ascii(), } } + } -impl TryFrom<&str> for ReversePath { - type Error = String; - fn try_from(s: &str) -> Result { - match s.parse::()? { - EnvelopeAddress::Postmaster => Err("cannot send from postmaster".to_string()), - EnvelopeAddress::Null => Ok(Self::NullSender), - EnvelopeAddress::Path(p) => Ok(Self::Path(p)), - } - } -} - -impl ToString for ReversePath { - fn to_string(&self) -> String { - match self { - Self::Path(p) => p.to_string(), - Self::NullSender => "".to_string(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub enum ForwardPath { - Path(MailPath), - Postmaster, -} - -impl ForwardPath { - pub fn is_ascii(&self) -> bool { - match self { - Self::Path(p) => p.is_ascii(), - Self::Postmaster => true, - } - } -} - -impl TryFrom<&str> for ForwardPath { - type Error = String; - fn try_from(s: &str) -> Result { - match s.parse::()? { - EnvelopeAddress::Postmaster => Ok(Self::Postmaster), - EnvelopeAddress::Null => Err("cannot send to null sender".to_string()), - EnvelopeAddress::Path(p) => Ok(Self::Path(p)), - } - } -} - -impl ToString for ForwardPath { - fn to_string(&self) -> String { - match self { - Self::Path(p) => p.to_string(), - Self::Postmaster => "postmaster".to_string(), - } - } -} - -impl TryInto for EnvelopeAddress { - type Error = String; - fn try_into(self) -> Result { - match self { - EnvelopeAddress::Null => Err("cannot send to null sender".to_string()), - EnvelopeAddress::Postmaster => Ok(ForwardPath::Postmaster), - EnvelopeAddress::Path(p) => Ok(ForwardPath::Path(p)), - } - } -} - -impl TryInto for EnvelopeAddress { - type Error = String; - fn try_into(self) -> Result { - match self { - EnvelopeAddress::Null => Ok(ReversePath::NullSender), - EnvelopeAddress::Postmaster => Err("cannot send from postmaster".to_string()), - EnvelopeAddress::Path(p) => Ok(ReversePath::Path(p)), - } - } -} - -impl Into for ForwardPath { - fn into(self) -> EnvelopeAddress { - match self { - ForwardPath::Postmaster => EnvelopeAddress::Postmaster, - ForwardPath::Path(p) => EnvelopeAddress::Path(p), - } - } -} - -impl Into for &Mailbox { - fn into(self) -> EnvelopeAddress { - EnvelopeAddress::Path(MailPath { - at_domain_list: vec![], - mailbox: self.clone(), - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct MailPath { - pub at_domain_list: Vec, - pub mailbox: Mailbox, -} - -impl MailPath { - pub fn is_ascii(&self) -> bool { - // Note: ignoring at_domain_list here per the to_string() - // implementation - self.mailbox.is_ascii() - } -} - -impl ToString for MailPath { - fn to_string(&self) -> String { - // Note: RFC5321 says about at_domain_list: - // Note that this form, the so-called "source - // route", MUST BE accepted, SHOULD NOT be - // generated, and SHOULD be ignored. - // So we don't include it in the stringified - // version of MailPath - self.mailbox.to_string() - } -} - -#[derive(Debug, Clone, Hash)] +/// An email mailbox: `local-part "@" domain` +#[derive(Clone, Debug)] pub struct Mailbox { - /// Note that the local_part may be a quoted_string. - /// The convention here is to preserve the quoted string as-is, - /// so that we can simply concatenate it with domain when - /// producing the full mailbox string - // FIXME: inconsistent with AddrSpec! - // TODO: Should integrate/unify both of these types where possible. - // Probably should migrate this parser from pest to nom to facilitate that. pub(crate) local_part: String, pub domain: Domain, } impl PartialEq for Mailbox { fn eq(&self, other: &Self) -> bool { - self.domain == other.domain && self.local_part() == other.local_part() + self.local_part() == other.local_part() && self.domain == other.domain } } impl Eq for Mailbox {} impl Mailbox { + /// Returns true if both the local part and domain are pure ASCII. pub fn is_ascii(&self) -> bool { - if !self.local_part.is_ascii() { - return false; - } - match &self.domain { - Domain::V4(s) | Domain::V6(s) | Domain::Name(s) => s.is_ascii(), - Domain::Tagged { tag, literal } => tag.is_ascii() && literal.is_ascii(), - } + self.local_part.is_ascii() && self.domain.is_ascii() } /// Returns the normalized local part. /// Normalization removes any quoting from the local part, /// so that `"\f\o\o"` and `"foo"` will both be returned - /// as `foo` and will compare as equal - pub fn local_part<'a>(&'a self) -> Cow<'a, str> { + /// as `foo` and will compare as equal. + /// Any byte sequences that are invalid UTF-8 will be + /// replaced with the unicode replacement character. + pub fn local_part(&self) -> Cow<'_, str> { + // Check if the local_part is a quoted string if self.local_part.starts_with('"') { + // Quoted string - need to unquote let mut result = String::new(); - let mut iter = self.local_part.chars(); - iter.next(); // skip initial quote - while let Some(c) = iter.next() { + let mut chars = self.local_part.chars(); + chars.next(); // skip initial quote + while let Some(c) = chars.next() { match c { - '\\' => match iter.next() { + '\\' => match chars.next() { Some(c) => { result.push(c); } @@ -432,195 +147,565 @@ impl Mailbox { } Cow::Owned(result) } else { - Cow::Borrowed(&self.local_part) + Cow::Borrowed(self.local_part.as_str()) } } } -impl ToString for Mailbox { - fn to_string(&self) -> String { - let domain = self.domain.to_string(); - format!("{}@{domain}", self.local_part) +#[cfg(test)] +mod mailbox_tests { + use super::*; + + #[test] + fn test_mailbox_local_part_normalized() { + // Test that local_part() normalizes quoted strings + let mb1 = Mailbox { + local_part: String::from("foo"), + domain: Domain::DomainName("example.com".parse().unwrap()), + }; + let mb2 = Mailbox { + local_part: String::from("\"foo\""), + domain: Domain::DomainName("example.com".parse().unwrap()), + }; + let mb3 = Mailbox { + local_part: String::from("\"f\\oo\""), + domain: Domain::DomainName("example.com".parse().unwrap()), + }; + + // All three should normalize to "foo" + k9::assert_equal!(mb1.local_part(), "foo"); + k9::assert_equal!(mb2.local_part(), "foo"); + k9::assert_equal!(mb3.local_part(), "foo"); } -} -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub enum Domain { - Name(String), - V4(String), - V6(String), - Tagged { tag: String, literal: String }, -} + #[test] + fn test_mailbox_local_part_eq_normalized() { + // Test that Mailbox equality uses normalized local_part + let mb1 = Mailbox { + local_part: String::from("foo"), + domain: Domain::DomainName("example.com".parse().unwrap()), + }; + let mb2 = Mailbox { + local_part: String::from("\"foo\""), + domain: Domain::DomainName("example.com".parse().unwrap()), + }; + let mb3 = Mailbox { + local_part: String::from("\"f\\oo\""), + domain: Domain::DomainName("example.com".parse().unwrap()), + }; -impl Domain { - pub fn name(name: &str) -> Result { - match idna::domain_to_ascii(name) { - Ok(name) => Ok(Self::Name(name)), - Err(_empty_error_type) => Err(format!("invalid IDNA domain {name}")), + // All three should be equal due to normalized local_part + k9::assert_equal!(mb1, mb2); + k9::assert_equal!(mb2, mb3); + k9::assert_equal!(mb1, mb3); + } + + #[test] + fn test_mailbox_local_part_unquoted_borrowed() { + // Test that unquoted valid UTF-8 returns Cow::Borrowed + let mb = Mailbox { + local_part: String::from("foo"), + domain: Domain::DomainName("example.com".parse().unwrap()), + }; + + let local_part = mb.local_part(); + match local_part { + Cow::Borrowed(_) => {} + Cow::Owned(_) => panic!("Expected Cow::Borrowed for unquoted valid UTF-8"), + } + } + + #[test] + fn test_mailbox_local_part_quoted_unquoted_borrowed() { + // Test that quoted valid UTF-8 returns Cow::Owned (needs unquoting) + let mb = Mailbox { + local_part: String::from("\"foo\""), + domain: Domain::DomainName("example.com".parse().unwrap()), + }; + + let local_part = mb.local_part(); + match local_part { + Cow::Borrowed(_) => panic!("Expected Cow::Owned for quoted string"), + Cow::Owned(s) => { + k9::assert_equal!(s, "foo"); + } } } } -impl ToString for Domain { - fn to_string(&self) -> String { +impl Hash for Mailbox { + fn hash(&self, state: &mut H) { + self.local_part().hash(state); + self.domain.hash(state); + } +} + +/// A parsed email path: optional source route (at-domain-list) plus mailbox. +/// +/// Per RFC 5321 §4.1.2, the source route (at-domain-list) MUST be accepted +/// when parsing, SHOULD NOT be generated when encoding, and SHOULD be ignored. +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct MailPath { + /// Optional source route: list of domains (without the `@` prefix). + pub at_domain_list: Vec, + /// The final mailbox (local-part@domain). + pub mailbox: Mailbox, +} + +impl std::fmt::Debug for MailPath { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut local_part = Vec::new(); + + // Add source route if present + if !self.at_domain_list.is_empty() { + for (i, domain) in self.at_domain_list.iter().enumerate() { + if i > 0 { + local_part.push(b','); + } + local_part.push(b'@'); // RFC 5321 source route has @ prefix + local_part.extend_from_slice(domain.as_bytes()); + } + local_part.push(b':'); + } + + // Add local-part + local_part.extend_from_slice(self.mailbox.local_part.as_bytes()); + + // Format as MailPath("local_part@domain") with proper escaping using escape_bytes + write!( + f, + "MailPath(\"{}@{:?}\")", + local_part.escape_bytes(), + self.mailbox.domain + ) + } +} + +impl std::fmt::Display for MailPath { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if !self.at_domain_list.is_empty() { + for (i, domain) in self.at_domain_list.iter().enumerate() { + if i > 0 { + f.write_str(",")?; + } + write!(f, "@{domain}")?; + } + f.write_str(":")?; + } + write!(f, "{}@{}", self.mailbox.local_part, self.mailbox.domain) + } +} + +impl MailPath { + /// Returns true if the mailbox address is pure ASCII. + /// + /// The source route (`at_domain_list`) is intentionally ignored, matching + /// the old parser behaviour (RFC 5321 says source routes SHOULD be ignored). + pub fn is_ascii(&self) -> bool { + self.mailbox.is_ascii() + } +} + +/// The reverse path (sender) for a MAIL FROM command +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReversePath { + /// A mailbox path + Path(MailPath), + /// Null sender: `MAIL FROM:<>` + NullSender, +} + +impl ReversePath { + /// Returns true if the address is pure ASCII. + pub fn is_ascii(&self) -> bool { match self { - Self::Name(name) => name.to_string(), - Self::V4(addr) => format!("[{addr}]"), - Self::V6(addr) => format!("[IPv6:{addr}]"), - Self::Tagged { tag, literal } => format!("[{tag}:{literal}]"), + Self::NullSender => true, + Self::Path(p) => p.is_ascii(), } } } -#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize)] +impl TryFrom<&str> for ReversePath { + type Error = String; + fn try_from(s: &str) -> Result { + EnvelopeAddress::parse(s)? + .try_into() + .map_err(|e: &'static str| e.to_string()) + } +} + +impl std::fmt::Display for ReversePath { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NullSender => Ok(()), + Self::Path(p) => p.fmt(f), + } + } +} + +/// The forward path (recipient) for a RCPT TO command +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum ForwardPath { + /// A mailbox path + Path(MailPath), + /// Postmaster: `RCPT TO:` (RFC 5321 §4.1.1.3) + Postmaster, +} + +impl ForwardPath { + /// Returns true if the address is pure ASCII. + pub fn is_ascii(&self) -> bool { + match self { + Self::Postmaster => true, + Self::Path(p) => p.is_ascii(), + } + } +} + +impl TryFrom<&str> for ForwardPath { + type Error = String; + fn try_from(s: &str) -> Result { + EnvelopeAddress::parse(s)? + .try_into() + .map_err(|e: &'static str| e.to_string()) + } +} + +impl std::fmt::Display for ForwardPath { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Postmaster => write!(f, "Postmaster"), + Self::Path(p) => p.fmt(f), + } + } +} + +/// An envelope address: either a path, null sender, or postmaster. +#[derive(Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] #[serde(try_from = "String", into = "String")] pub enum EnvelopeAddress { + /// Null sender: `<>` Null, + /// Postmaster: `` or `Postmaster` Postmaster, + /// A path: `` or bare path Path(MailPath), } +impl std::fmt::Display for EnvelopeAddress { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EnvelopeAddress::Null => write!(f, ""), + EnvelopeAddress::Postmaster => write!(f, "Postmaster"), + EnvelopeAddress::Path(path) => path.fmt(f), + } + } +} + +impl std::fmt::Debug for EnvelopeAddress { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "<{}>", self) + } +} + +impl From for EnvelopeAddress { + fn from(path: MailPath) -> Self { + EnvelopeAddress::Path(path) + } +} + impl TryFrom for EnvelopeAddress { type Error = String; - fn try_from(s: String) -> Result { - crate::parser::parse_envelope_address(&s) + fn try_from(s: String) -> Result { + EnvelopeAddress::parse(&s) } } -impl FromStr for EnvelopeAddress { +impl From for String { + fn from(addr: EnvelopeAddress) -> Self { + addr.to_string() + } +} + +impl std::str::FromStr for EnvelopeAddress { type Err = String; - fn from_str(input: &str) -> Result { - crate::parser::parse_envelope_address(input) + fn from_str(s: &str) -> Result { + EnvelopeAddress::parse(s) } } -impl Into for EnvelopeAddress { - fn into(self) -> String { - self.to_string() - } -} - -impl ToString for EnvelopeAddress { - fn to_string(&self) -> String { - match self { - Self::Null => String::new(), - Self::Postmaster => "postmaster".to_string(), - Self::Path(path) => path.to_string(), +impl From for EnvelopeAddress { + fn from(fp: ForwardPath) -> Self { + match fp { + ForwardPath::Postmaster => EnvelopeAddress::Postmaster, + ForwardPath::Path(p) => EnvelopeAddress::Path(p), } } } -fn xtext_encode(s: &str) -> Result { - let mut result = String::new(); - - for c in s.chars() { - let ival = c as u32; - if (ival >= 33 && ival <= 126) && c != '+' && c != '=' { - result.push(c); - continue; - } - - if ival > 0xff { - return Err(format!("xtext_encode: char {c} cannot be xtext encoded")); - } - - result.push_str(&format!("+{ival:02x}")); - } - Ok(result) -} - -fn xtext_decode(s: &str) -> Result { - let mut bytes = vec![]; - - let mut iter = s.chars(); - while let Some(c) = iter.next() { - if c == '+' { - // Decode a hex char - let hi = iter - .next() - .ok_or_else(|| "xtext_decode: missing high nybble of hexchar".to_string())?; - let lo = iter - .next() - .ok_or_else(|| "xtext_decode: missing low nybble of hexchar".to_string())?; - - let hi = hi - .to_digit(16) - .ok_or_else(|| "xtext_decode: high nybble is not a valid hexchar".to_string())?; - let lo = lo - .to_digit(16) - .ok_or_else(|| "xtext_decode: low nybble is not a valid hexchar".to_string())?; - - let byte = ((hi << 4) | lo) as u8; - - bytes.push(byte); - } else { - let mut utf8 = [0u8; 4]; - bytes.extend_from_slice(c.encode_utf8(&mut utf8).as_bytes()); +impl TryFrom for EnvelopeAddress { + type Error = &'static str; + fn try_from(rp: ReversePath) -> Result { + match rp { + ReversePath::NullSender => Ok(EnvelopeAddress::Null), + ReversePath::Path(p) => Ok(EnvelopeAddress::Path(p)), } } - - String::from_utf8(bytes) - .map_err(|err| format!("xtext_decode: decoded bytes are not valid utf8: {err:#}")) } -#[cfg(test)] -#[test] -fn test_xtext() { - for (input, expect) in [ - ("hello", "hello"), - ("extra+", "extra+2b"), - ("1+1=2", "1+2b1+3d2"), - ] { - let encoded = xtext_encode(input).unwrap(); - assert_eq!(encoded, expect, "encode error input={input}"); - - let decoded = xtext_decode(&encoded).unwrap(); - assert_eq!(decoded, input, "decode error input={input}"); +impl EnvelopeAddress { + /// Parse an envelope address from a string. + /// + /// Accepts either forward or reverse path syntax, with or without angle brackets. + pub fn parse(input: &str) -> Result { + let input = make_span(input.as_bytes()); + let (_, result) = all_consuming(alt(( + map(tag_no_case("<>"), |_| EnvelopeAddress::Null), + map(tag_no_case(""), |_| EnvelopeAddress::Postmaster), + map(tag_no_case("Postmaster"), |_| EnvelopeAddress::Postmaster), + map(path, EnvelopeAddress::Path), + map(mailbox, EnvelopeAddress::from), + ))) + .parse(input) + .map_err(|e| explain_nom(input, e))?; + Ok(result) } - - assert_eq!( - xtext_encode("space👾").unwrap_err(), - "xtext_encode: char 👾 cannot be xtext encoded" - ); } -#[derive(Debug, Clone, PartialEq, Eq)] +impl From for ReversePath { + fn from(path: MailPath) -> Self { + ReversePath::Path(path) + } +} + +impl From for ForwardPath { + fn from(path: MailPath) -> Self { + ForwardPath::Path(path) + } +} + +impl TryFrom for MailPath { + type Error = &'static str; + + fn try_from(path: ReversePath) -> Result { + match path { + ReversePath::Path(mailpath) => Ok(mailpath), + ReversePath::NullSender => Err("Cannot convert NullSender to MailPath"), + } + } +} + +impl TryFrom for MailPath { + type Error = &'static str; + + fn try_from(path: ForwardPath) -> Result { + match path { + ForwardPath::Path(mailpath) => Ok(mailpath), + ForwardPath::Postmaster => Err("Cannot convert Postmaster to MailPath"), + } + } +} + +// ============================================================================ +// Conversions from Mailbox +// ============================================================================ + +/// Infallible conversion: wraps a Mailbox in a MailPath with no source route. +impl From for MailPath { + fn from(mailbox: Mailbox) -> Self { + MailPath { + at_domain_list: vec![], + mailbox, + } + } +} + +/// Infallible conversion: wraps a Mailbox in an EnvelopeAddress::Path. +impl From for EnvelopeAddress { + fn from(mailbox: Mailbox) -> Self { + EnvelopeAddress::Path(MailPath { + at_domain_list: vec![], + mailbox, + }) + } +} + +/// Infallible conversion: wraps a Mailbox in a ReversePath::Path. +impl From for ReversePath { + fn from(mailbox: Mailbox) -> Self { + ReversePath::Path(MailPath { + at_domain_list: vec![], + mailbox, + }) + } +} + +/// Infallible conversion: wraps a Mailbox in a ForwardPath::Path. +impl From for ForwardPath { + fn from(mailbox: Mailbox) -> Self { + ForwardPath::Path(MailPath { + at_domain_list: vec![], + mailbox, + }) + } +} + +// ============================================================================ +// Fallible conversions to Mailbox +// ============================================================================ + +impl TryFrom for Mailbox { + type Error = &'static str; + + fn try_from(addr: EnvelopeAddress) -> Result { + match addr { + EnvelopeAddress::Path(path) => Ok(path.mailbox), + EnvelopeAddress::Null => Err("Cannot convert Null to Mailbox"), + EnvelopeAddress::Postmaster => Err("Cannot convert Postmaster to Mailbox"), + } + } +} + +impl TryFrom for Mailbox { + type Error = &'static str; + + fn try_from(path: ReversePath) -> Result { + match path { + ReversePath::Path(path) => Ok(path.mailbox), + ReversePath::NullSender => Err("Cannot convert NullSender to Mailbox"), + } + } +} + +impl TryFrom for Mailbox { + type Error = &'static str; + + fn try_from(path: ForwardPath) -> Result { + match path { + ForwardPath::Path(path) => Ok(path.mailbox), + ForwardPath::Postmaster => Err("Cannot convert Postmaster to Mailbox"), + } + } +} + +// ============================================================================ +// Fallible conversions to MailPath +// ============================================================================ + +impl TryFrom for MailPath { + type Error = &'static str; + + fn try_from(addr: EnvelopeAddress) -> Result { + match addr { + EnvelopeAddress::Path(path) => Ok(path), + EnvelopeAddress::Null => Err("Cannot convert Null to MailPath"), + EnvelopeAddress::Postmaster => Err("Cannot convert Postmaster to MailPath"), + } + } +} + +impl TryFrom for ForwardPath { + type Error = &'static str; + + fn try_from(path: ReversePath) -> Result { + match path { + ReversePath::Path(mailpath) => Ok(ForwardPath::Path(mailpath)), + ReversePath::NullSender => Err("Cannot convert NullSender to ForwardPath"), + } + } +} + +impl TryFrom for ReversePath { + type Error = &'static str; + + fn try_from(path: ForwardPath) -> Result { + match path { + ForwardPath::Path(mailpath) => Ok(ReversePath::Path(mailpath)), + ForwardPath::Postmaster => Err("Cannot convert Postmaster to ReversePath"), + } + } +} + +impl TryFrom for ReversePath { + type Error = &'static str; + + fn try_from(addr: EnvelopeAddress) -> Result { + match addr { + EnvelopeAddress::Path(path) => Ok(ReversePath::Path(path)), + EnvelopeAddress::Null => Ok(ReversePath::NullSender), + EnvelopeAddress::Postmaster => Err("Cannot convert Postmaster to ReversePath"), + } + } +} + +impl TryFrom for ForwardPath { + type Error = &'static str; + + fn try_from(addr: EnvelopeAddress) -> Result { + match addr { + EnvelopeAddress::Path(path) => Ok(ForwardPath::Path(path)), + EnvelopeAddress::Null => Err("Cannot convert Null to ForwardPath"), + EnvelopeAddress::Postmaster => Ok(ForwardPath::Postmaster), + } + } +} + +/// An ESMTP parameter: `name["=" value]` +#[derive(Clone, Debug, PartialEq, Eq)] pub struct EsmtpParameter { pub name: String, pub value: Option, } -impl ToString for EsmtpParameter { - fn to_string(&self) -> String { - match &self.value { - Some(value) => format!("{}={}", self.name, value), - None => self.name.to_string(), - } - } -} - +/// A single XCLIENT parameter: `name=xtext-value`. +/// The `value` field stores the **xtext-decoded** string; the wire form +/// uses xtext encoding where non-printable bytes appear as `+XX` hex pairs. #[derive(Debug, Clone, PartialEq, Eq)] pub struct XClientParameter { pub name: String, pub value: String, } -impl ToString for XClientParameter { - fn to_string(&self) -> String { - let value = match xtext_encode(&self.value) { - Ok(s) => s, - Err(s) => s, - }; - format!("{}={value}", self.name) +impl XClientParameter { + /// Returns true if the parameter name matches the given name (case-insensitive). + pub fn is_name(&self, name: impl AsRef) -> bool { + self.name.eq_ignore_ascii_case(name.as_ref()) + } + + /// Parse the parameter value as type T. + /// + /// Converts the value to a string and then parses it as T. + pub fn parse(&self) -> Result + where + T: std::str::FromStr, + T::Err: std::fmt::Display, + { + let parsed: Result = self.value.parse(); + parsed.map_err(|e| e.to_string()) } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq)] pub enum Command { Ehlo(Domain), Helo(Domain), Lhlo(Domain), + Noop(Option), + Help(Option), + Vrfy(Option), + Expn(Option), + Data, + /// The end-of-data terminator sent after the message body: `".\r\n"`. + /// + /// This variant is never produced by the parser — it is constructed + /// programmatically by SMTP client code and serialized via + /// [`Command::encode`] when the client needs to signal the end of the + /// DATA content stream (RFC 5321 §4.5.2). + DataDot, + Rset, + Quit, + StartTls, MailFrom { address: ReversePath, parameters: Vec, @@ -629,98 +714,327 @@ pub enum Command { address: ForwardPath, parameters: Vec, }, - Data, - DataDot, - Rset, - Quit, - Vrfy(String), - Expn(String), - Help(Option), - Noop(Option), - StartTls, Auth { sasl_mech: String, initial_response: Option, }, XClient(Vec), - /// For testing non-conforming inputs - RawLine(String), + Unknown(BString), +} + +impl std::fmt::Debug for Command { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Use Command::encode to get the wire format, then escape_bytes for Debug output + let encoded = self.encode(); + write!(f, "Command(\"{}\")", encoded.escape_bytes()) + } +} + +#[derive(Clone, PartialEq, Eq)] +pub enum MaybePartialCommand { + Full(Command), + Partial { + verb: CommandVerb, + remainder: BString, + }, +} + +impl std::fmt::Debug for MaybePartialCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MaybePartialCommand::Full(cmd) => write!(f, "Full({cmd:?})"), + MaybePartialCommand::Partial { verb, remainder } => { + write!( + f, + "Partial {{ verb: {verb:?}, remainder: {:?} }}", + remainder.escape_bytes() + ) + } + } + } +} + +macro_rules! parse_single { + ($func_name:ident, $token:literal, $verb:ident) => { + fn $func_name(input: Span) -> IResult { + context( + $token, + alt(( + map( + all_consuming((tag_no_case($token), wsp, anything)), + |(_cmd, _space, remainder)| MaybePartialCommand::Partial { + verb: CommandVerb::$verb, + remainder: (*remainder).into(), + }, + ), + map(all_consuming(tag_no_case($token)), |_| { + MaybePartialCommand::Full(Command::$verb) + }), + )), + ) + .parse(input) + } + + paste! { + #[cfg(test)] + #[test] + fn []() { + k9::assert_equal!( + unwrapper(Command::parse($token)), + MaybePartialCommand::Full(Command::$verb) + ); + k9::assert_equal!( + unwrapper(Command::parse($token.to_lowercase())), + MaybePartialCommand::Full(Command::$verb) + ); + k9::assert_equal!( + unwrapper(Command::parse(format!("{} trailing garbage", $token))), + MaybePartialCommand::Partial { + verb: CommandVerb::$verb, + remainder:"trailing garbage".into() + } + ); + } + } + }; +} + +macro_rules! parse_opt_arg { + ($func_name:ident, $token:literal, $verb:ident) => { + fn $func_name(input: Span) -> IResult { + context( + $token, + alt(( + map( + all_consuming((tag_no_case($token), wsp, string)), + |(_cmd, _space, param)| match String::from_utf8(param.fragment().to_vec()) { + Ok(s) => MaybePartialCommand::Full(Command::$verb(Some(s))), + Err(_) => MaybePartialCommand::Partial { + verb: CommandVerb::$verb, + remainder: BString::default(), + }, + }, + ), + map( + all_consuming((tag_no_case($token), wsp, anything)), + |(_cmd, _space, remainder)| MaybePartialCommand::Partial { + verb: CommandVerb::$verb, + remainder: (*remainder).into(), + }, + ), + map(all_consuming(tag_no_case($token)), |_| { + MaybePartialCommand::Full(Command::$verb(None)) + }), + )), + ) + .parse(input) + } + + paste! { + #[cfg(test)] + #[test] + fn []() { + k9::assert_equal!( + unwrapper(Command::parse($token)), + MaybePartialCommand::Full(Command::$verb(None)), + "full no param" + ); + k9::assert_equal!( + unwrapper(Command::parse($token.to_lowercase())), + MaybePartialCommand::Full(Command::$verb(None)), + "full no param, different case" + ); + k9::assert_equal!( + unwrapper(Command::parse(format!("{} parameter", $token))), + MaybePartialCommand::Full(Command::$verb(Some("parameter".into()))), + "full with param" + ); + k9::assert_equal!( + unwrapper(Command::parse(format!("{} trailing garbage", $token))), + MaybePartialCommand::Partial { + verb: CommandVerb::$verb, + remainder:"trailing garbage".into() + }, + "should have partial" + ); + } + } + }; +} + +/// Helper that does an unwrap, but rather than print the error string +/// with escapes, uses its Display impl. This makes it easier to see +/// what the error message is, because the error in this context is +/// typically a nom_utils error which is multi-line and uses a caret +/// to point to the appropriate column in the input. +#[cfg(test)] +fn unwrapper(result: Result) -> T { + match result { + Ok(r) => r, + Err(err) => panic!("{err}"), + } +} + +parse_opt_arg!(parse_noop, "NOOP", Noop); +parse_opt_arg!(parse_help, "HELP", Help); +parse_opt_arg!(parse_vrfy, "VRFY", Vrfy); +parse_opt_arg!(parse_expn, "EXPN", Expn); +parse_single!(parse_data, "DATA", Data); +parse_single!(parse_rset, "RSET", Rset); +parse_single!(parse_quit, "QUIT", Quit); +parse_single!(parse_starttls, "STARTTLS", StartTls); + +fn parse_with<'a, R, F>(text: &'a [u8], parser: F) -> Result +where + F: Fn(Span<'a>) -> IResult<'a, Span<'a>, R>, +{ + let input = make_span(text); + let (_, result) = all_consuming(parser) + .parse(input) + .map_err(|err| explain_nom(input, err))?; + Ok(result) } impl Command { - pub fn parse(line: &str) -> Result { - Parser::parse_command(line) + pub fn parse(input: impl AsRef<[u8]>) -> Result { + // Strip a trailing CRLF (or bare LF) so that both wire-format input + // (with CRLF terminator, as produced by encode()) and bare command + // strings (as used in tests and interactive contexts) are accepted. + let bytes = input.as_ref(); + let bytes = bytes + .strip_suffix(b"\r\n") + .or_else(|| bytes.strip_suffix(b"\n")) + .unwrap_or(bytes); + parse_with(bytes, Self::parse_span) } - pub fn encode(&self) -> String { + fn parse_span(input: Span) -> IResult { + context( + "command-verb", + alt(( + parse_ehlo, + parse_helo, + parse_lhlo, + parse_help, + parse_noop, + parse_vrfy, + parse_expn, + parse_data, + parse_rset, + parse_quit, + parse_starttls, + parse_mail_from, + parse_rcpt_to, + parse_auth, + parse_xclient, + Self::parse_unknown, + )), + ) + .parse(input) + } + + /// Re-encode the command as a single line of text ready to send on the + /// wire, including the trailing `\r\n`. + /// + /// The returned `BString` can be fed back to [`Command::parse`] to + /// recover the original `Command` value (round-trip stable), with the + /// one intentional exception that `at_domain_list` source routes are not + /// re-emitted (RFC 5321 says they SHOULD NOT be generated). + pub fn encode(&self) -> BString { + let mut buf: Vec = Vec::new(); match self { - Self::Ehlo(domain) => format!("EHLO {}\r\n", domain.to_string()), - Self::Helo(domain) => format!("HELO {}\r\n", domain.to_string()), - Self::Lhlo(domain) => format!("LHLO {}\r\n", domain.to_string()), + Self::Ehlo(domain) => { + buf.extend_from_slice(b"EHLO "); + buf.extend_from_slice(encode_domain(domain).as_ref()); + } + Self::Helo(domain) => { + buf.extend_from_slice(b"HELO "); + buf.extend_from_slice(encode_domain(domain).as_ref()); + } + Self::Lhlo(domain) => { + buf.extend_from_slice(b"LHLO "); + buf.extend_from_slice(encode_domain(domain).as_ref()); + } + Self::Noop(None) => buf.extend_from_slice(b"NOOP"), + Self::Noop(Some(s)) => { + buf.extend_from_slice(b"NOOP "); + buf.extend_from_slice(s.as_bytes()); + } + Self::Help(None) => buf.extend_from_slice(b"HELP"), + Self::Help(Some(s)) => { + buf.extend_from_slice(b"HELP "); + buf.extend_from_slice(s.as_bytes()); + } + Self::Vrfy(None) => buf.extend_from_slice(b"VRFY"), + Self::Vrfy(Some(s)) => { + buf.extend_from_slice(b"VRFY "); + buf.extend_from_slice(s.as_bytes()); + } + Self::Expn(None) => buf.extend_from_slice(b"EXPN"), + Self::Expn(Some(s)) => { + buf.extend_from_slice(b"EXPN "); + buf.extend_from_slice(s.as_bytes()); + } + Self::Data => buf.extend_from_slice(b"DATA"), + // DataDot encodes as exactly ".\r\n" — return before the + // unconditional CRLF that all other arms rely on below. + Self::DataDot => return BString::from(".\r\n"), + Self::Rset => buf.extend_from_slice(b"RSET"), + Self::Quit => buf.extend_from_slice(b"QUIT"), + Self::StartTls => buf.extend_from_slice(b"STARTTLS"), Self::MailFrom { address, parameters, } => { - let mut params = String::new(); - for p in parameters { - params.push(' '); - params.push_str(&p.to_string()); - } - - format!("MAIL FROM:<{}>{params}\r\n", address.to_string()) + buf.extend_from_slice(b"MAIL FROM:<"); + buf.extend(encode_reverse_path(address)); + buf.push(b'>'); + buf.extend(encode_esmtp_params(parameters)); } Self::RcptTo { address, parameters, } => { - let mut params = String::new(); - for p in parameters { - params.push(' '); - params.push_str(&p.to_string()); - } - - format!("RCPT TO:<{}>{params}\r\n", address.to_string()) + buf.extend_from_slice(b"RCPT TO:<"); + buf.extend(encode_forward_path(address)); + buf.push(b'>'); + buf.extend(encode_esmtp_params(parameters)); } - Self::Data => "DATA\r\n".to_string(), - Self::DataDot => ".\r\n".to_string(), - Self::Rset => "RSET\r\n".to_string(), - Self::Quit => "QUIT\r\n".to_string(), - Self::StartTls => "STARTTLS\r\n".to_string(), - Self::Vrfy(param) => format!("VRFY {param}\r\n"), - Self::Expn(param) => format!("EXPN {param}\r\n"), - Self::Help(Some(param)) => format!("HELP {param}\r\n"), - Self::Help(None) => "HELP\r\n".to_string(), - Self::Noop(Some(param)) => format!("NOOP {param}\r\n"), - Self::Noop(None) => "NOOP\r\n".to_string(), Self::Auth { sasl_mech, initial_response: None, - } => format!("AUTH {sasl_mech}\r\n"), + } => { + buf.extend_from_slice(b"AUTH "); + buf.extend_from_slice(sasl_mech.as_bytes()); + } Self::Auth { sasl_mech, initial_response: Some(resp), - } => format!("AUTH {sasl_mech} {resp}\r\n"), - Self::XClient(params) => { - let mut s = String::new(); - for p in params { - s.push(' '); - s.push_str(&p.to_string()); - } - format!("XCLIENT{s}\r\n") + } => { + buf.extend_from_slice(b"AUTH "); + buf.extend_from_slice(sasl_mech.as_bytes()); + buf.push(b' '); + buf.extend_from_slice(resp.as_bytes()); } - Self::RawLine(line) => { - format!("{line}\r\n") + Self::XClient(params) => { + buf.extend_from_slice(b"XCLIENT"); + buf.extend(encode_xclient_params(params)); + } + Self::Unknown(s) => { + buf.extend_from_slice(s); } } + buf.extend_from_slice(b"\r\n"); + BString::from(buf) } - /// Timeouts for reading the response + /// Timeout for reading the response to this command. pub fn client_timeout(&self, timeouts: &SmtpClientTimeouts) -> Duration { match self { Self::Helo(_) | Self::Ehlo(_) | Self::Lhlo(_) => timeouts.ehlo_timeout, Self::MailFrom { .. } => timeouts.mail_from_timeout, Self::RcptTo { .. } => timeouts.rcpt_to_timeout, - Self::Data { .. } => timeouts.data_timeout, + Self::Data => timeouts.data_timeout, Self::DataDot => timeouts.data_dot_timeout, Self::Rset => timeouts.rset_timeout, Self::StartTls => timeouts.starttls_timeout, @@ -728,20 +1042,869 @@ impl Command { timeouts.idle_timeout } Self::Auth { .. } => timeouts.auth_timeout, - Self::XClient { .. } => timeouts.auth_timeout, // FIXME: xclient specific timeout - Self::RawLine(_) => timeouts.mail_from_timeout, // No good option for this TBH. + Self::XClient(_) => timeouts.auth_timeout, // FIXME: xclient specific timeout + Self::Unknown(_) => timeouts.mail_from_timeout, // No good option for this TBH. } } - /// Timeouts for writing the request + /// Timeout for writing the request. pub fn client_timeout_request(&self, timeouts: &SmtpClientTimeouts) -> Duration { - let one_minute = Duration::from_secs(60); - self.client_timeout(timeouts).min(one_minute) + self.client_timeout(timeouts).min(Duration::from_secs(60)) + } + + fn parse_unknown(input: Span) -> IResult { + context( + "unknown-command", + alt(( + map( + all_consuming(recognize((command_word, wsp, anything))), + |command| MaybePartialCommand::Full(Command::Unknown((*command).into())), + ), + map(all_consuming(command_word), |command| { + MaybePartialCommand::Full(Command::Unknown((*command).into())) + }), + )), + ) + .parse(input) } } -pub fn is_valid_domain(text: &str) -> bool { - Parser::parse(Rule::complete_domain, text).is_ok() +fn command_word(input: Span) -> IResult { + context( + "command-word", + take_while1(|c: u8| c.is_ascii_alphanumeric()), + ) + .parse(input) +} + +fn wsp(input: Span) -> IResult { + context("wsp", take_while1(|c| c == b' ' || c == b'\t')).parse(input) +} + +fn anything(input: Span) -> IResult { + context("anything", take_while1(|_| true)).parse(input) +} + +fn atext(input: Span) -> IResult { + recognize(alt(( + take_while_m_n(1, 1, |c: u8| { + matches!( + c, + b'!' | b'#' | b'$' | b'%' | b'&' | b'\'' | b'*' | b'+' | b'-' | b'/' | b'=' | b'?' + | b'^' | b'_' | b'`' | b'{' | b'|' | b'}' | b'~' + | b'A'..=b'Z' + | b'a'..=b'z' + | b'0'..=b'9' + ) + }), + utf8_non_ascii, + ))) + .parse(input) +} + +fn atom(input: Span) -> IResult { + context("atom", recognize(many1(atext))).parse(input) +} + +fn quoted_string(input: Span) -> IResult { + context( + "quoted-string", + recognize(( + tag("\""), + many0(alt(( + recognize(pair( + tag("\\"), + take_while_m_n(1, 1, |c: u8| c >= 0x20 && c <= 0x7e), + )), + take_while_m_n(1, 1, |c: u8| { + (c >= 0x20 && c <= 0x21) || (c >= 0x23 && c <= 0x5b) || (c >= 0x5d && c <= 0x7e) + }), + utf8_non_ascii, + ))), + tag("\""), + )), + ) + .parse(input) +} + +fn string(input: Span) -> IResult { + context("string", alt((atom, quoted_string))).parse(input) +} + +// --------------------------------------------------------------------------- +// MAIL FROM helpers +// --------------------------------------------------------------------------- + +/// `dot-string = atom *("." atom)` +fn dot_string(input: Span) -> IResult { + context("dot-string", recognize((atom, many0(pair(tag("."), atom))))).parse(input) +} + +/// `local-part = dot-string / quoted-string` +fn local_part(input: Span) -> IResult { + context("local-part", alt((dot_string, quoted_string))).parse(input) +} + +/// `dcontent = %d33-90 / %d94-126` — printable US-ASCII excluding `[`, `\`, `]` +fn dcontent(input: Span) -> IResult { + take_while1(|c: u8| (c >= 33 && c <= 90) || (c >= 94 && c <= 126)).parse(input) +} + +/// The content inside an address literal `[...]`. +/// +/// The IPv6 prefix is checked first so that `[IPv6:bad]` produces a +/// recoverable parse error rather than falling through to the general +/// address-literal branch (which would silently accept `IPv6` as a tag). +fn address_literal_content(input: Span) -> IResult { + let is_ipv6 = input + .fragment() + .get(..5) + .map(|b| b.eq_ignore_ascii_case(b"IPv6:")) + .unwrap_or(false); + + if is_ipv6 { + // Strictly parse as IPv6; no fallthrough to general literal on failure + context( + "ipv6-address-literal", + map((tag_no_case("IPv6:"), ipv6_address), |(_, ip)| { + Domain::V6(ip) + }), + ) + .parse(input) + } else { + alt(( + map(ipv4_address, Domain::V4), + map_res( + ( + recognize(take_while1(|c: u8| c.is_ascii_alphanumeric() || c == b'-')), + tag(":"), + recognize(many1(dcontent)), + ), + |(tag_s, _colon, lit): (Span, _, Span)| -> Result { + // Store the original "tag:literal" string as-is + let mut s = String::from_utf8(tag_s.fragment().to_vec()) + .map_err(|_| "address_literal: invalid UTF-8 in tag".to_string())?; + s.push(':'); + let lit_str = std::str::from_utf8(lit.fragment()) + .map_err(|_| "address_literal: invalid UTF-8 in literal".to_string())?; + s.push_str(lit_str); + Ok(Domain::Tagged(s)) + }, + ), + )) + .parse(input) + } +} + +/// `address-literal = "[" ( IPv4 / "IPv6:" IPv6 / tag ":" dcontent ) "]"` +fn address_literal(input: Span) -> IResult { + context( + "address-literal", + map((tag("["), address_literal_content, tag("]")), |(_, d, _)| d), + ) + .parse(input) +} + +/// `mailbox-domain = address-literal / domain-name` +fn mailbox_domain(input: Span) -> IResult { + context( + "mailbox-domain", + alt((address_literal, map(domain_name, Domain::DomainName))), + ) + .parse(input) +} + +/// `mailbox = local-part "@" mailbox-domain` +fn mailbox(input: Span) -> IResult { + context( + "mailbox", + map_res( + (local_part, tag("@"), mailbox_domain), + |(lp, _, dom): (Span, _, Domain)| -> Result { + // Convert the local_part bytes to a String + // If the bytes are not valid UTF-8, return an error + let local_part = String::from_utf8(lp.fragment().to_vec()) + .map_err(|_| "invalid UTF-8 in local-part".to_string())?; + Ok(Mailbox { + local_part, + domain: dom, + }) + }, + ), + ) + .parse(input) +} + +/// `at-domain = "@" domain-name` — returns the domain string (without the `@`) +fn at_domain(input: Span) -> IResult { + map_res( + (tag("@"), recognize(domain_name)), + |(_, d): (Span, Span)| -> Result { + String::from_utf8(d.fragment().to_vec()) + .map_err(|_| "at_domain: invalid UTF-8 in domain".to_string()) + }, + ) + .parse(input) +} + +/// `at-domain-list = at-domain *("," at-domain) ":"` +fn at_domain_list(input: Span) -> IResult> { + context( + "at-domain-list", + map( + (at_domain, many0((tag(","), at_domain)), tag(":")), + |(first, rest, _)| { + let mut v = vec![first]; + v.extend(rest.into_iter().map(|(_, d)| d)); + v + }, + ), + ) + .parse(input) +} + +/// `null-sender = "<>"` +fn null_sender(input: Span) -> IResult { + context("null-sender", map(tag("<>"), |_| ReversePath::NullSender)).parse(input) +} + +/// `path = "<" [ at-domain-list ] mailbox ">"` +/// +/// This is the core grammar element shared by both reverse-path and +/// forward-path. It parses the content between angle brackets and returns +/// a MailPath (optional source route + mailbox). +fn path(input: Span) -> IResult { + context( + "path", + map( + (tag("<"), opt(at_domain_list), mailbox, tag(">")), + |(_, domains, mb, _)| MailPath { + at_domain_list: domains.unwrap_or_default(), + mailbox: mb, + }, + ), + ) + .parse(input) +} + +/// `reverse-path = null-sender / path / bare-mailbox` +/// +/// Null sender is tried first so that `<>` is not consumed as the opening +/// `<` of a path. Bare mailbox (no angle brackets) is accepted as a +/// leniency for non-conforming senders. +fn reverse_path(input: Span) -> IResult { + context( + "reverse-path", + alt(( + null_sender, + map(path, ReversePath::Path), + map(mailbox, ReversePath::from), + )), + ) + .parse(input) +} + +// --------------------------------------------------------------------------- +// RCPT TO helpers +// --------------------------------------------------------------------------- + +/// `` — the special no-domain postmaster address (RFC 5321 §4.1.1.3) +fn postmaster_path(input: Span) -> IResult { + context( + "postmaster", + map(tag_no_case(""), |_| ForwardPath::Postmaster), + ) + .parse(input) +} + +/// `forward-path = "" / "<" path-content ">" / bare-mailbox` +/// +/// `` is tried first so the literal string is not consumed as +/// the opening `<` of a regular path. Bare mailbox (no angle brackets) is +/// accepted as a leniency for non-conforming senders. +fn forward_path(input: Span) -> IResult { + context( + "forward-path", + alt(( + postmaster_path, + map(path, ForwardPath::Path), + map(mailbox, ForwardPath::from), + )), + ) + .parse(input) +} + +/// `esmtp-keyword = (ALPHA / DIGIT) *(ALPHA / DIGIT / "-")` +fn esmtp_keyword(input: Span) -> IResult { + context( + "esmtp-keyword", + recognize(( + take_while_m_n(1, 1, |c: u8| c.is_ascii_alphanumeric()), + many0(take_while_m_n(1, 1, |c: u8| { + c.is_ascii_alphanumeric() || c == b'-' + })), + )), + ) + .parse(input) +} + +/// `esmtp-value = 1*(%d33-60 / %d62-126 / UTF8-non-ASCII)` +/// +/// RFC 5321 defines the base character range (printable ASCII excluding `=`, +/// SP, and controls). RFC 6531 §3.3 extends this with `UTF8-non-ASCII` to +/// support internationalized ESMTP parameter values. +/// +/// `many1(alt(ascii_run, utf8_non_ascii))` lets the `take_while1` arm greedily +/// consume consecutive ASCII bytes while `utf8_non_ascii` handles each +/// multi-byte codepoint. +fn esmtp_value(input: Span) -> IResult { + context( + "esmtp-value", + recognize(many1(alt(( + take_while1(|c: u8| (c >= 33 && c <= 60) || (c >= 62 && c <= 126)), + utf8_non_ascii, + )))), + ) + .parse(input) +} + +/// `esmtp-param = esmtp-keyword ["=" esmtp-value]` +fn esmtp_param(input: Span) -> IResult { + context( + "esmtp-param", + map_res( + (esmtp_keyword, opt((tag("="), esmtp_value))), + |(name, value): (Span, Option<(Span, Span)>)| -> Result { + let name = String::from_utf8(name.fragment().to_vec()) + .map_err(|_| "esmtp_param: invalid UTF-8 in name".to_string())?; + let value = value + .map(|(_, v)| { + String::from_utf8(v.fragment().to_vec()) + .map_err(|_| "esmtp_param: invalid UTF-8 in value".to_string()) + }) + .transpose()?; + Ok(EsmtpParameter { name, value }) + }, + ), + ) + .parse(input) +} + +/// `mail-parameters = esmtp-param *(SP esmtp-param)` +fn mail_parameters(input: Span) -> IResult> { + context( + "mail-parameters", + map((esmtp_param, many0((wsp, esmtp_param))), |(first, rest)| { + let mut params = vec![first]; + params.extend(rest.into_iter().map(|(_, p)| p)); + params + }), + ) + .parse(input) +} + +// --------------------------------------------------------------------------- +// EHLO / HELO / LHLO parsers +// --------------------------------------------------------------------------- + +/// `ehlo = "EHLO" SP ( Domain / address-literal ) CRLF` +/// +/// Both HELO and LHLO reuse the same domain parser (permissive: address +/// literals are accepted for all three greeting commands). +/// Any failure in `mailbox_domain` (e.g. bad IP) falls through to Partial. +fn parse_ehlo(input: Span) -> IResult { + context( + "ehlo", + alt(( + map( + all_consuming((tag_no_case("EHLO"), wsp, mailbox_domain)), + |(_, _, domain)| MaybePartialCommand::Full(Command::Ehlo(domain)), + ), + map( + all_consuming((tag_no_case("EHLO"), wsp, anything)), + |(_, _, remainder)| MaybePartialCommand::Partial { + verb: CommandVerb::Ehlo, + remainder: (*remainder).into(), + }, + ), + map(all_consuming(tag_no_case("EHLO")), |_| { + MaybePartialCommand::Partial { + verb: CommandVerb::Ehlo, + remainder: BString::default(), + } + }), + )), + ) + .parse(input) +} + +/// `helo = "HELO" SP Domain CRLF` +/// +/// Permissive: also accepts address literals (matches parser.rs behaviour). +fn parse_helo(input: Span) -> IResult { + context( + "helo", + alt(( + map( + all_consuming((tag_no_case("HELO"), wsp, mailbox_domain)), + |(_, _, domain)| MaybePartialCommand::Full(Command::Helo(domain)), + ), + map( + all_consuming((tag_no_case("HELO"), wsp, anything)), + |(_, _, remainder)| MaybePartialCommand::Partial { + verb: CommandVerb::Helo, + remainder: (*remainder).into(), + }, + ), + map(all_consuming(tag_no_case("HELO")), |_| { + MaybePartialCommand::Partial { + verb: CommandVerb::Helo, + remainder: BString::default(), + } + }), + )), + ) + .parse(input) +} + +/// `lhlo = "LHLO" SP ( Domain / address-literal ) CRLF` (RFC 2033 LMTP) +fn parse_lhlo(input: Span) -> IResult { + context( + "lhlo", + alt(( + map( + all_consuming((tag_no_case("LHLO"), wsp, mailbox_domain)), + |(_, _, domain)| MaybePartialCommand::Full(Command::Lhlo(domain)), + ), + map( + all_consuming((tag_no_case("LHLO"), wsp, anything)), + |(_, _, remainder)| MaybePartialCommand::Partial { + verb: CommandVerb::Lhlo, + remainder: (*remainder).into(), + }, + ), + map(all_consuming(tag_no_case("LHLO")), |_| { + MaybePartialCommand::Partial { + verb: CommandVerb::Lhlo, + remainder: BString::default(), + } + }), + )), + ) + .parse(input) +} + +// --------------------------------------------------------------------------- +// AUTH parser +// --------------------------------------------------------------------------- + +/// `sasl-mechanism = 1*(ALPHA / DIGIT / "-")` +fn sasl_mechanism(input: Span) -> IResult { + context( + "sasl-mechanism", + map( + take_while1(|c: u8| c.is_ascii_alphanumeric() || c == b'-'), + |s: Span| { + // sasl_mechanism only accepts ASCII bytes, so UTF-8 conversion always succeeds + String::from_utf8(s.fragment().to_vec()).expect("sasl_mechanism guaranteed ASCII") + }, + ), + ) + .parse(input) +} + +/// `auth-initial-response = base64 / "="` +/// +/// Matches base64 characters `[A-Za-z0-9+/=]+`. The single `"="` (empty +/// initial response) is a subset of this pattern, so no special case is +/// needed. +fn auth_initial_response(input: Span) -> IResult { + context( + "auth-initial-response", + map( + take_while1(|c: u8| c.is_ascii_alphanumeric() || c == b'+' || c == b'/' || c == b'='), + |s: Span| { + // auth_initial_response only accepts ASCII bytes, so UTF-8 conversion always succeeds + String::from_utf8(s.fragment().to_vec()) + .expect("auth_initial_response guaranteed ASCII") + }, + ), + ) + .parse(input) +} + +/// `auth = "AUTH" SP mechanism [SP initial-response]` +fn parse_auth(input: Span) -> IResult { + context( + "auth", + alt(( + // Arm 1: complete successful parse + map( + all_consuming(( + tag_no_case("AUTH"), + wsp, + sasl_mechanism, + opt((wsp, auth_initial_response)), + )), + |(_, _, sasl_mech, resp)| match resp { + Some((_, r)) => MaybePartialCommand::Full(Command::Auth { + sasl_mech, + initial_response: Some(r), + }), + None => MaybePartialCommand::Full(Command::Auth { + sasl_mech, + initial_response: None, + }), + }, + ), + // Arm 2: "AUTH" + whitespace + anything → Partial + map( + all_consuming((tag_no_case("AUTH"), wsp, anything)), + |(_, _, remainder)| MaybePartialCommand::Partial { + verb: CommandVerb::Auth, + remainder: (*remainder).into(), + }, + ), + // Arm 3: "AUTH" alone → Partial + map(all_consuming(tag_no_case("AUTH")), |_| { + MaybePartialCommand::Partial { + verb: CommandVerb::Auth, + remainder: BString::default(), + } + }), + )), + ) + .parse(input) +} + +// --------------------------------------------------------------------------- +// XCLIENT parser +// --------------------------------------------------------------------------- + +/// Decode an xtext-encoded byte slice (RFC 3461 §4). +/// +/// xtext characters are printable ASCII in `\x21`–`\x7e` where `+XX` +/// introduces a hex-encoded byte. Returns an error on a truncated or +/// invalid hex escape. +fn xtext_decode(encoded: &[u8]) -> Result { + let mut result: Vec = Vec::with_capacity(encoded.len()); + let mut i = 0; + while i < encoded.len() { + if encoded[i] == b'+' { + if i + 2 >= encoded.len() { + return Err(format!("xtext_decode: truncated hex escape at byte {i}")); + } + let hi = hex_nibble(encoded[i + 1]).map_err(|e| format!("xtext_decode: {e}"))?; + let lo = hex_nibble(encoded[i + 2]).map_err(|e| format!("xtext_decode: {e}"))?; + result.push((hi << 4) | lo); + i += 3; + } else { + result.push(encoded[i]); + i += 1; + } + } + String::from_utf8(result) + .map_err(|_| "xtext_decode: invalid UTF-8 in decoded value".to_string()) +} + +fn hex_nibble(b: u8) -> Result { + match b { + b'0'..=b'9' => Ok(b - b'0'), + b'a'..=b'f' => Ok(b - b'a' + 10), + b'A'..=b'F' => Ok(b - b'A' + 10), + _ => Err(format!("invalid hex digit '{}'", b as char)), + } +} + +/// Raw xtext value: printable non-space ASCII (`\x21`–`\x7e`), which +/// includes the `+` that introduces a hex escape. Space terminates the +/// value in the XCLIENT parameter list. +fn xclient_xtext_value(input: Span) -> IResult { + context( + "xclient-xtext-value", + take_while1(|c: u8| c >= 33 && c <= 126), + ) + .parse(input) +} + +/// One XCLIENT parameter: `name "=" xtext-value` +/// +/// The value is xtext-decoded via `map_res`; a malformed escape sequence +/// produces a recoverable nom error so `alt` can fall through to Partial. +fn xclient_param(input: Span) -> IResult { + context( + "xclient-param", + map_res( + (esmtp_keyword, tag("="), xclient_xtext_value), + |(name, _, value): (Span, _, Span)| -> Result { + let name = String::from_utf8(name.fragment().to_vec()) + .map_err(|_| "xclient_param: invalid UTF-8 in name".to_string())?; + let value = xtext_decode(value.fragment())?; + Ok(XClientParameter { name, value }) + }, + ), + ) + .parse(input) +} + +/// `xclient-params = xclient-param *(SP xclient-param)` +fn xclient_params(input: Span) -> IResult> { + context( + "xclient-params", + map( + (xclient_param, many0((wsp, xclient_param))), + |(first, rest)| { + let mut params = vec![first]; + params.extend(rest.into_iter().map(|(_, p)| p)); + params + }, + ), + ) + .parse(input) +} + +/// `xclient = "XCLIENT" SP xclient-params` +fn parse_xclient(input: Span) -> IResult { + context( + "xclient", + alt(( + // Arm 1: complete successful parse + map( + all_consuming((tag_no_case("XCLIENT"), wsp, xclient_params)), + |(_, _, params)| MaybePartialCommand::Full(Command::XClient(params)), + ), + // Arm 2: "XCLIENT" + whitespace + anything → Partial + map( + all_consuming((tag_no_case("XCLIENT"), wsp, anything)), + |(_, _, remainder)| MaybePartialCommand::Partial { + verb: CommandVerb::XClient, + remainder: (*remainder).into(), + }, + ), + // Arm 3: "XCLIENT" alone → Partial + map(all_consuming(tag_no_case("XCLIENT")), |_| { + MaybePartialCommand::Partial { + verb: CommandVerb::XClient, + remainder: BString::default(), + } + }), + )), + ) + .parse(input) +} + +/// `mail = "MAIL FROM:" reverse-path [SP mail-parameters]` +/// +/// Returns `Full(Command::MailFrom { … })` on a complete successful parse. +/// +/// Any failure after the `MAIL` keyword — including an invalid IPv4/IPv6 +/// address or domain name in the sender address — falls through to a +/// `Partial { verb: CommandVerb::Mail, … }` result instead of a hard error. +/// No `cut` is used anywhere in the full-parse arm so that all sub-parser +/// failures remain recoverable and `alt` can try the fallback arms. +fn parse_mail_from(input: Span) -> IResult { + context( + "mail-from", + alt(( + // Arm 1: complete successful parse + map( + all_consuming(( + tag_no_case("MAIL"), + wsp, + tag_no_case("FROM:"), + reverse_path, + opt(map((wsp, mail_parameters), |(_, p)| p)), + )), + |(_, _, _, address, parameters)| { + MaybePartialCommand::Full(Command::MailFrom { + address, + parameters: parameters.unwrap_or_default(), + }) + }, + ), + // Arm 2: "MAIL" + whitespace + anything → Partial + // This catches bad addresses (invalid IP, domain, syntax) + map( + all_consuming((tag_no_case("MAIL"), wsp, anything)), + |(_, _, remainder)| MaybePartialCommand::Partial { + verb: CommandVerb::Mail, + remainder: (*remainder).into(), + }, + ), + // Arm 3: "MAIL" alone → Partial (incomplete command) + map(all_consuming(tag_no_case("MAIL")), |_| { + MaybePartialCommand::Partial { + verb: CommandVerb::Mail, + remainder: BString::default(), + } + }), + )), + ) + .parse(input) +} + +/// `rcpt = "RCPT TO:" forward-path [SP mail-parameters]` +/// +/// Returns `Full(Command::RcptTo { … })` on a complete successful parse. +/// +/// Any failure after the `RCPT` keyword — including an invalid IPv4/IPv6 +/// address or domain name in the recipient address — falls through to a +/// `Partial { verb: CommandVerb::Rcpt, … }` result instead of a hard error. +/// No `cut` is used anywhere in the full-parse arm so that all sub-parser +/// failures remain recoverable and `alt` can try the fallback arms. +fn parse_rcpt_to(input: Span) -> IResult { + context( + "rcpt-to", + alt(( + // Arm 1: complete successful parse + map( + all_consuming(( + tag_no_case("RCPT"), + wsp, + tag_no_case("TO:"), + forward_path, + opt(map((wsp, mail_parameters), |(_, p)| p)), + )), + |(_, _, _, address, parameters)| { + MaybePartialCommand::Full(Command::RcptTo { + address, + parameters: parameters.unwrap_or_default(), + }) + }, + ), + // Arm 2: "RCPT" + whitespace + anything → Partial + // This catches bad addresses (invalid IP, domain, syntax) + map( + all_consuming((tag_no_case("RCPT"), wsp, anything)), + |(_, _, remainder)| MaybePartialCommand::Partial { + verb: CommandVerb::Rcpt, + remainder: (*remainder).into(), + }, + ), + // Arm 3: "RCPT" alone → Partial (incomplete command) + map(all_consuming(tag_no_case("RCPT")), |_| { + MaybePartialCommand::Partial { + verb: CommandVerb::Rcpt, + remainder: BString::default(), + } + }), + )), + ) + .parse(input) +} + +// --------------------------------------------------------------------------- +// Encoding helpers +// --------------------------------------------------------------------------- + +/// Return the lowercase hex digit character for a nibble value (0–15). +fn hex_nibble_lower(n: u8) -> u8 { + if n < 10 { + b'0' + n + } else { + b'a' + n - 10 + } +} + +/// Xtext-encode a byte slice (RFC 3461 §4). +/// +/// Bytes in the xchar range (`\x21`–`\x7e` except `+` and `=`) are copied +/// unchanged. All other byte values are encoded as `+XX` where `XX` is two +/// lowercase hex digits. +fn xtext_encode_bytes(value: &[u8]) -> Vec { + let mut result = Vec::with_capacity(value.len()); + for &b in value { + if b >= 33 && b <= 126 && b != b'+' && b != b'=' { + result.push(b); + } else { + result.push(b'+'); + result.push(hex_nibble_lower(b >> 4)); + result.push(hex_nibble_lower(b & 0x0f)); + } + } + result +} + +/// Encode a `Domain` as the ASCII text that appears in a command line. +/// +/// - `DomainName` → ASCII/punycode-normalised domain string +/// - `V4` → `[{ip}]` +/// - `V6` → `[IPv6:{ip}]` +/// - `Tagged` → `[{tag:literal}]` +fn encode_domain(domain: &Domain) -> BString { + match domain { + Domain::DomainName(s) => BString::from(s.to_string()), + Domain::V4(ip) => BString::from(format!("[{}]", ip)), + Domain::V6(ip) => BString::from(format!("[IPv6:{}]", ip)), + Domain::Tagged(s) => BString::from(format!("[{}]", s)), + } +} + +/// Encode a `MailPath` as `local-part "@" domain` bytes. +/// +/// The `at_domain_list` (source route) is intentionally **not** re-encoded: +/// RFC 5321 §4.1.2 says source routes MUST be accepted, SHOULD NOT be +/// generated, and SHOULD be ignored. +fn encode_mail_path(path: &MailPath) -> Vec { + let mut buf = path.mailbox.local_part.as_bytes().to_vec(); + buf.push(b'@'); + buf.extend_from_slice(encode_domain(&path.mailbox.domain).as_ref()); + buf +} + +/// Encode the content that goes **between** the angle brackets of +/// `MAIL FROM:<…>`. +/// +/// `NullSender` → empty (produces `MAIL FROM:<>`). +/// `Path` → `encode_mail_path` result. +fn encode_reverse_path(rp: &ReversePath) -> Vec { + match rp { + ReversePath::NullSender => vec![], + ReversePath::Path(path) => encode_mail_path(path), + } +} + +/// Encode the content that goes **between** the angle brackets of +/// `RCPT TO:<…>`. +/// +/// `Postmaster` → `b"Postmaster"`. +/// `Path` → `encode_mail_path` result. +fn encode_forward_path(fp: &ForwardPath) -> Vec { + match fp { + ForwardPath::Postmaster => b"Postmaster".to_vec(), + ForwardPath::Path(path) => encode_mail_path(path), + } +} + +/// Encode a slice of `EsmtpParameter` as the optional suffix of a +/// `MAIL FROM` or `RCPT TO` command: `*(SP keyword ["=" value])`. +/// +/// Returns an empty `Vec` when `params` is empty. +fn encode_esmtp_params(params: &[EsmtpParameter]) -> Vec { + let mut buf = Vec::new(); + for p in params { + buf.push(b' '); + buf.extend_from_slice(p.name.as_bytes()); + if let Some(v) = &p.value { + buf.push(b'='); + buf.extend_from_slice(v.as_bytes()); + } + } + buf +} + +/// Encode a slice of `XClientParameter` as `*(SP name "=" xtext-value)`. +/// +/// Each parameter value is xtext-encoded before writing. +fn encode_xclient_params(params: &[XClientParameter]) -> Vec { + let mut buf = Vec::new(); + for p in params { + buf.push(b' '); + buf.extend_from_slice(p.name.as_bytes()); + buf.push(b'='); + buf.extend(xtext_encode_bytes(p.value.as_bytes())); + } + buf } #[cfg(test)] @@ -749,674 +1912,1570 @@ mod test { use super::*; #[test] - fn parse_single_verbs() { - assert_eq!(Parser::parse_command("data").unwrap(), Command::Data,); - assert_eq!(Parser::parse_command("Quit").unwrap(), Command::Quit,); - assert_eq!(Parser::parse_command("rset").unwrap(), Command::Rset,); - } + fn test_string() { + k9::snapshot!( + BStr::new(&parse_with("hello".as_bytes(), string).unwrap()), + "hello" + ); + k9::snapshot!( + BStr::new(&parse_with("\"hello\"".as_bytes(), string).unwrap()), + "\"hello\"" + ); + k9::snapshot!( + BStr::new(&parse_with("\"hello world\"".as_bytes(), string).unwrap()), + "\"hello world\"" + ); + k9::snapshot!( + parse_with("hello world".as_bytes(), string), + r#" +Err( + "Error at line 1, in Eof: +hello world + ^_____ - #[test] - fn parse_vrfy() { - assert_eq!( - Parser::parse_command("VRFY someone").unwrap(), - Command::Vrfy("someone".to_string()) +", +) +"# ); } #[test] - fn parse_expn() { - assert_eq!( - Parser::parse_command("expn someone").unwrap(), - Command::Expn("someone".to_string()) + fn test_bogus() { + k9::snapshot!( + Command::parse("bogus"), + r#" +Ok( + Full(Command("bogus\r +")), +) +"# + ); + } + + // ------------------------------------------------------------------ + // EHLO tests + // ------------------------------------------------------------------ + + #[test] + fn test_ehlo_domain_name() { + k9::assert_equal!( + unwrapper(Command::parse("EHLO example.com")), + MaybePartialCommand::Full(Command::Ehlo(Domain::DomainName( + "example.com".parse().unwrap() + ))) ); } #[test] - fn parse_help() { - assert_eq!(Parser::parse_command("help").unwrap(), Command::Help(None),); - assert_eq!( - Parser::parse_command("help me").unwrap(), - Command::Help(Some("me".to_string())), + fn test_ehlo_case_insensitive() { + k9::assert_equal!( + unwrapper(Command::parse("ehlo example.com")), + MaybePartialCommand::Full(Command::Ehlo(Domain::DomainName( + "example.com".parse().unwrap() + ))) ); } #[test] - fn parse_noop() { - assert_eq!(Parser::parse_command("noop").unwrap(), Command::Noop(None),); - assert_eq!( - Parser::parse_command("noop param").unwrap(), - Command::Noop(Some("param".to_string())), + fn test_ehlo_ipv4_literal() { + k9::assert_equal!( + unwrapper(Command::parse("EHLO [10.0.0.1]")), + MaybePartialCommand::Full(Command::Ehlo(Domain::V4("10.0.0.1".parse().unwrap()))) ); } #[test] - fn parse_ehlo() { - assert_eq!( - Parser::parse_command("EHLO there").unwrap(), - Command::Ehlo(Domain::Name("there".to_string())) - ); - assert_eq!( - Parser::parse_command("EHLO [127.0.0.1]").unwrap(), - Command::Ehlo(Domain::V4("127.0.0.1".to_string())) + fn test_ehlo_ipv6_literal() { + k9::assert_equal!( + unwrapper(Command::parse("EHLO [IPv6:::1]")), + MaybePartialCommand::Full(Command::Ehlo(Domain::V6("::1".parse().unwrap()))) ); } #[test] - fn parse_helo() { - assert_eq!( - Parser::parse_command("HELO there").unwrap(), - Command::Helo(Domain::Name("there".to_string())) - ); - // The spec says that we cannot use address literals with, - // HELO, but some tools will still submit it and some MTAs - // will accept it, so we do too. - assert_eq!( - Parser::parse_command("EHLO [127.0.0.1]").unwrap(), - Command::Ehlo(Domain::V4("127.0.0.1".to_string())) + fn test_ehlo_tagged_literal() { + k9::assert_equal!( + unwrapper(Command::parse("EHLO [future:something]")), + MaybePartialCommand::Full(Command::Ehlo(Domain::Tagged("future:something".into(),))) ); } #[test] - fn parse_auth() { - assert_eq!( - Parser::parse_command("AUTH PLAIN dGVzdAB0ZXN0ADEyMzQ=").unwrap(), - Command::Auth { - sasl_mech: "PLAIN".to_string(), - initial_response: Some("dGVzdAB0ZXN0ADEyMzQ=".to_string()), + fn test_ehlo_invalid_ipv4_is_partial() { + k9::assert_equal!( + unwrapper(Command::parse("EHLO [999.999.999.999]")), + MaybePartialCommand::Partial { + verb: CommandVerb::Ehlo, + remainder: "[999.999.999.999]".into(), } ); - assert_eq!( - Parser::parse_command("AUTH PLAIN").unwrap(), - Command::Auth { - sasl_mech: "PLAIN".to_string(), + } + + #[test] + fn test_ehlo_invalid_ipv6_is_partial() { + k9::assert_equal!( + unwrapper(Command::parse("EHLO [IPv6:not-an-ipv6]")), + MaybePartialCommand::Partial { + verb: CommandVerb::Ehlo, + remainder: "[IPv6:not-an-ipv6]".into(), + } + ); + } + + #[test] + fn test_ehlo_alone_is_partial() { + k9::assert_equal!( + unwrapper(Command::parse("EHLO")), + MaybePartialCommand::Partial { + verb: CommandVerb::Ehlo, + remainder: "".into(), + } + ); + } + + #[test] + fn test_ehlo_with_garbage_is_partial() { + k9::assert_equal!( + unwrapper(Command::parse("EHLO !!invalid!!")), + MaybePartialCommand::Partial { + verb: CommandVerb::Ehlo, + remainder: "!!invalid!!".into(), + } + ); + } + + // ------------------------------------------------------------------ + // HELO tests + // ------------------------------------------------------------------ + + #[test] + fn test_helo_domain_name() { + k9::assert_equal!( + unwrapper(Command::parse("HELO example.com")), + MaybePartialCommand::Full(Command::Helo(Domain::DomainName( + "example.com".parse().unwrap() + ))) + ); + } + + #[test] + fn test_helo_case_insensitive() { + k9::assert_equal!( + unwrapper(Command::parse("helo example.com")), + MaybePartialCommand::Full(Command::Helo(Domain::DomainName( + "example.com".parse().unwrap() + ))) + ); + } + + #[test] + fn test_helo_ipv4_literal() { + // Permissive: address literals accepted for HELO + k9::assert_equal!( + unwrapper(Command::parse("HELO [10.0.0.1]")), + MaybePartialCommand::Full(Command::Helo(Domain::V4("10.0.0.1".parse().unwrap()))) + ); + } + + #[test] + fn test_helo_alone_is_partial() { + k9::assert_equal!( + unwrapper(Command::parse("HELO")), + MaybePartialCommand::Partial { + verb: CommandVerb::Helo, + remainder: "".into(), + } + ); + } + + #[test] + fn test_helo_with_garbage_is_partial() { + k9::assert_equal!( + unwrapper(Command::parse("HELO !!invalid!!")), + MaybePartialCommand::Partial { + verb: CommandVerb::Helo, + remainder: "!!invalid!!".into(), + } + ); + } + + // ------------------------------------------------------------------ + // LHLO tests + // ------------------------------------------------------------------ + + #[test] + fn test_lhlo_domain_name() { + k9::assert_equal!( + unwrapper(Command::parse("LHLO example.com")), + MaybePartialCommand::Full(Command::Lhlo(Domain::DomainName( + "example.com".parse().unwrap() + ))) + ); + } + + #[test] + fn test_lhlo_case_insensitive() { + k9::assert_equal!( + unwrapper(Command::parse("lhlo example.com")), + MaybePartialCommand::Full(Command::Lhlo(Domain::DomainName( + "example.com".parse().unwrap() + ))) + ); + } + + #[test] + fn test_lhlo_ipv4_literal() { + k9::assert_equal!( + unwrapper(Command::parse("LHLO [10.0.0.1]")), + MaybePartialCommand::Full(Command::Lhlo(Domain::V4("10.0.0.1".parse().unwrap()))) + ); + } + + #[test] + fn test_lhlo_ipv6_literal() { + k9::assert_equal!( + unwrapper(Command::parse("LHLO [IPv6:::1]")), + MaybePartialCommand::Full(Command::Lhlo(Domain::V6("::1".parse().unwrap()))) + ); + } + + #[test] + fn test_lhlo_invalid_ipv4_is_partial() { + k9::assert_equal!( + unwrapper(Command::parse("LHLO [999.999.999.999]")), + MaybePartialCommand::Partial { + verb: CommandVerb::Lhlo, + remainder: "[999.999.999.999]".into(), + } + ); + } + + #[test] + fn test_lhlo_alone_is_partial() { + k9::assert_equal!( + unwrapper(Command::parse("LHLO")), + MaybePartialCommand::Partial { + verb: CommandVerb::Lhlo, + remainder: "".into(), + } + ); + } + + // ------------------------------------------------------------------ + // AUTH tests + // ------------------------------------------------------------------ + + #[test] + fn test_auth_mechanism_only() { + k9::assert_equal!( + unwrapper(Command::parse("AUTH PLAIN")), + MaybePartialCommand::Full(Command::Auth { + sasl_mech: "PLAIN".into(), initial_response: None, + }) + ); + } + + #[test] + fn test_auth_with_initial_response() { + k9::assert_equal!( + unwrapper(Command::parse("AUTH PLAIN dXNlcjpwYXNz")), + MaybePartialCommand::Full(Command::Auth { + sasl_mech: "PLAIN".into(), + initial_response: Some("dXNlcjpwYXNz".into()), + }) + ); + } + + #[test] + fn test_auth_empty_initial_response() { + // "=" signals an empty initial response (RFC 4954) + k9::assert_equal!( + unwrapper(Command::parse("AUTH PLAIN =")), + MaybePartialCommand::Full(Command::Auth { + sasl_mech: "PLAIN".into(), + initial_response: Some("=".into()), + }) + ); + } + + #[test] + fn test_auth_hyphenated_mechanism() { + k9::assert_equal!( + unwrapper(Command::parse("AUTH CRAM-MD5")), + MaybePartialCommand::Full(Command::Auth { + sasl_mech: "CRAM-MD5".into(), + initial_response: None, + }) + ); + } + + #[test] + fn test_auth_case_insensitive() { + k9::assert_equal!( + unwrapper(Command::parse("auth plain")), + MaybePartialCommand::Full(Command::Auth { + sasl_mech: "plain".into(), + initial_response: None, + }) + ); + } + + #[test] + fn test_auth_alone_is_partial() { + k9::assert_equal!( + unwrapper(Command::parse("AUTH")), + MaybePartialCommand::Partial { + verb: CommandVerb::Auth, + remainder: "".into(), } ); } #[test] - fn parse_rcpt_to_punycode() { - assert_eq!( - Parser::parse_command("Rcpt To:") - .unwrap_err() - .to_string(), - "invalid IDNA domain 4bed.xn--5dbhlacyps5bf4a.com" + fn test_auth_with_garbage_is_partial() { + // Mechanism contains invalid characters → Partial + k9::assert_equal!( + unwrapper(Command::parse("AUTH !!bad!!")), + MaybePartialCommand::Partial { + verb: CommandVerb::Auth, + remainder: "!!bad!!".into(), + } + ); + } + + // ------------------------------------------------------------------ + // XCLIENT tests + // ------------------------------------------------------------------ + + #[test] + fn test_xclient_single_param() { + k9::assert_equal!( + unwrapper(Command::parse("XCLIENT NAME=foo.example.com")), + MaybePartialCommand::Full(Command::XClient(vec![XClientParameter { + name: "NAME".into(), + value: "foo.example.com".into(), + }])) ); } #[test] - fn parse_rcpt_to() { - assert_eq!( - Parser::parse_command("Rcpt To:").unwrap(), - Command::RcptTo { - address: ForwardPath::Path(MailPath { - at_domain_list: vec![], - mailbox: Mailbox { - local_part: "user".to_string(), - domain: Domain::Name("host".to_string()) - } - }), - parameters: vec![], - } - ); - assert_eq!( - Parser::parse_command("Rcpt To:").unwrap(), - Command::RcptTo { - address: ForwardPath::Path(MailPath { - at_domain_list: vec![], - mailbox: Mailbox { - local_part: "user".to_string(), - domain: Domain::Name("xn--xample-9ua.com".to_string()) - } - }), - parameters: vec![], - } - ); - assert_eq!( - Parser::parse_command("Rcpt To:").unwrap(), - Command::RcptTo { - address: ForwardPath::Path(MailPath { - at_domain_list: vec![], - mailbox: Mailbox { - local_part: "rené".to_string(), - domain: Domain::Name("host".to_string()) - } - }), - parameters: vec![], - } - ); - assert_eq!( - Parser::parse_command("Rcpt To:user@host").unwrap(), - Command::RcptTo { - address: ForwardPath::Path(MailPath { - at_domain_list: vec![], - mailbox: Mailbox { - local_part: "user".to_string(), - domain: Domain::Name("host".to_string()) - } - }), - parameters: vec![], - } - ); - - assert_eq!( - Parser::parse_command("Rcpt To: user@host").unwrap(), - Command::RcptTo { - address: ForwardPath::Path(MailPath { - at_domain_list: vec![], - mailbox: Mailbox { - local_part: "user".to_string(), - domain: Domain::Name("host".to_string()) - } - }), - parameters: vec![], - } - ); - - assert_eq!( - Parser::parse_command("Rcpt To:").unwrap(), - Command::RcptTo { - address: ForwardPath::Path(MailPath { - at_domain_list: vec![], - mailbox: Mailbox { - local_part: "admin".to_string(), - domain: Domain::Tagged { - tag: "2001".to_string(), - literal: "aaaa:bbbbb".to_string() - } - } - }), - parameters: vec![], - } - ); - - assert_eq!( - Domain::Tagged { - tag: "2001".to_string(), - literal: "aaaa:bbbbb".to_string() - } - .to_string(), - "[2001:aaaa:bbbbb]".to_string() - ); - - assert_eq!( - Parser::parse_command("Rcpt To:<\"asking for trouble\"@host.name>").unwrap(), - Command::RcptTo { - address: ForwardPath::Path(MailPath { - at_domain_list: vec![], - mailbox: Mailbox { - local_part: "\"asking for trouble\"".to_string(), - domain: Domain::Name("host.name".to_string()) - } - }), - parameters: vec![], - } - ); - - assert_eq!( - Parser::parse_command("Rcpt To:").unwrap(), - Command::RcptTo { - address: ForwardPath::Postmaster, - parameters: vec![], - } - ); - - assert_eq!( - Parser::parse_command("Rcpt To: woot").unwrap(), - Command::RcptTo { - address: ForwardPath::Path(MailPath { - at_domain_list: vec![], - mailbox: Mailbox { - local_part: "user".to_string(), - domain: Domain::Name("host".to_string()) - } - }), - parameters: vec![EsmtpParameter { - name: "woot".to_string(), - value: None - }], - } - ); - - assert_eq!( - Parser::parse_command("Rcpt To:user@host woot").unwrap_err(), - "must enclose address in <> if you want to use ESMTP parameters".to_string() + fn test_xclient_multiple_params() { + k9::assert_equal!( + unwrapper(Command::parse("XCLIENT NAME=foo.example.com ADDR=10.0.0.1")), + MaybePartialCommand::Full(Command::XClient(vec![ + XClientParameter { + name: "NAME".into(), + value: "foo.example.com".into(), + }, + XClientParameter { + name: "ADDR".into(), + value: "10.0.0.1".into(), + }, + ])) ); } #[test] - fn parse_mail_from() { - assert_eq!( - Parser::parse_command("Mail FROM:").unwrap(), - Command::MailFrom { - address: ReversePath::Path(MailPath { - at_domain_list: vec![], - mailbox: Mailbox { - local_part: "user".to_string(), - domain: Domain::Name("host".to_string()) - } - }), - parameters: vec![], + fn test_xclient_xtext_hex_escape() { + // '+40' decodes to '@' + k9::assert_equal!( + unwrapper(Command::parse("XCLIENT NAME=user+40example.com")), + MaybePartialCommand::Full(Command::XClient(vec![XClientParameter { + name: "NAME".into(), + value: "user@example.com".into(), + }])) + ); + } + + #[test] + fn test_xclient_case_insensitive() { + k9::assert_equal!( + unwrapper(Command::parse("xclient NAME=host")), + MaybePartialCommand::Full(Command::XClient(vec![XClientParameter { + name: "NAME".into(), + value: "host".into(), + }])) + ); + } + + #[test] + fn test_xclient_alone_is_partial() { + k9::assert_equal!( + unwrapper(Command::parse("XCLIENT")), + MaybePartialCommand::Partial { + verb: CommandVerb::XClient, + remainder: "".into(), } ); + } - assert_eq!( - Parser::parse_command("Mail FROM:user@host").unwrap(), - Command::MailFrom { - address: ReversePath::Path(MailPath { - at_domain_list: vec![], - mailbox: Mailbox { - local_part: "user".to_string(), - domain: Domain::Name("host".to_string()) - } - }), - parameters: vec![], + #[test] + fn test_xclient_invalid_xtext_is_partial() { + // '+' not followed by two hex digits → xtext_decode fails → Partial + k9::assert_equal!( + unwrapper(Command::parse("XCLIENT NAME=bad+ZZ")), + MaybePartialCommand::Partial { + verb: CommandVerb::XClient, + remainder: "NAME=bad+ZZ".into(), } ); + } - assert_eq!( - Parser::parse_command("Mail FROM:user@host foo bar=baz").unwrap_err(), - "must enclose address in <> if you want to use ESMTP parameters".to_string() + #[test] + fn test_xclient_garbage_is_partial() { + // No '=' separator → xclient_param fails → Partial + k9::assert_equal!( + unwrapper(Command::parse("XCLIENT noequals")), + MaybePartialCommand::Partial { + verb: CommandVerb::XClient, + remainder: "noequals".into(), + } ); + } - assert_eq!( - Parser::parse_command("Mail FROM: foo bar=baz").unwrap(), - Command::MailFrom { - address: ReversePath::Path(MailPath { - at_domain_list: vec![], - mailbox: Mailbox { - local_part: "user".to_string(), - domain: Domain::Name("host".to_string()) - } - }), + #[test] + fn test_xclient_parameter_methods() { + // Parse an XCLIENT command with an IP address + let cmd = unwrapper(Command::parse("XCLIENT ADDR=192.168.1.1")); + let params = match cmd { + MaybePartialCommand::Full(Command::XClient(params)) => params, + _ => panic!("Expected XCLIENT command"), + }; + + // Test is_name method + k9::assert_equal!(params[0].is_name("ADDR"), true); + k9::assert_equal!(params[0].is_name("addr"), true); + k9::assert_equal!(params[0].is_name("NAME"), false); + + // Test parse method with IpAddr + let ip: std::net::IpAddr = params[0].parse().expect("Failed to parse IP address"); + k9::assert_equal!( + ip, + std::net::IpAddr::V4(std::net::Ipv4Addr::new(192, 168, 1, 1)) + ); + } + + #[test] + fn test_xclient_parameter_parse_invalid_ip() { + // Test parsing with an invalid IP address string + let cmd = unwrapper(Command::parse("XCLIENT ADDR=not-an-ip")); + let params = match cmd { + MaybePartialCommand::Full(Command::XClient(params)) => params, + _ => panic!("Expected XCLIENT command"), + }; + + let result: Result = params[0].parse(); + k9::assert_equal!(result.unwrap_err(), "invalid IP address syntax"); + } + + // ------------------------------------------------------------------ + // MAIL FROM tests + // ------------------------------------------------------------------ + + fn mail_path(local: &str, domain: Domain) -> ReversePath { + Mailbox { + local_part: local.into(), + domain, + } + .into() + } + + #[test] + fn test_mail_from_domain_name() { + k9::assert_equal!( + unwrapper(Command::parse("MAIL FROM:")), + MaybePartialCommand::Full(Command::MailFrom { + address: mail_path("user", Domain::DomainName("host".parse().unwrap())), + parameters: vec![], + }) + ); + // Case-insensitive verb and keyword + k9::assert_equal!( + unwrapper(Command::parse("mail from:")), + MaybePartialCommand::Full(Command::MailFrom { + address: mail_path("user", Domain::DomainName("host".parse().unwrap())), + parameters: vec![], + }) + ); + } + + #[test] + fn test_mail_from_null_sender() { + k9::assert_equal!( + unwrapper(Command::parse("MAIL FROM:<>")), + MaybePartialCommand::Full(Command::MailFrom { + address: ReversePath::NullSender, + parameters: vec![], + }) + ); + } + + #[test] + fn test_mail_from_ipv4() { + k9::assert_equal!( + unwrapper(Command::parse("MAIL FROM:")), + MaybePartialCommand::Full(Command::MailFrom { + address: mail_path("user", Domain::V4("10.0.0.1".parse().unwrap())), + parameters: vec![], + }) + ); + } + + #[test] + fn test_mail_from_ipv6() { + k9::assert_equal!( + unwrapper(Command::parse("MAIL FROM:")), + MaybePartialCommand::Full(Command::MailFrom { + address: mail_path("user", Domain::V6("::1".parse().unwrap())), + parameters: vec![], + }) + ); + } + + #[test] + fn test_mail_from_tagged_literal() { + k9::assert_equal!( + unwrapper(Command::parse("MAIL FROM:")), + MaybePartialCommand::Full(Command::MailFrom { + address: mail_path("user", Domain::Tagged("future:something".into())), + parameters: vec![], + }) + ); + } + + #[test] + fn test_mail_from_esmtp_params() { + k9::assert_equal!( + unwrapper(Command::parse("MAIL FROM: foo bar=baz")), + MaybePartialCommand::Full(Command::MailFrom { + address: mail_path("user", Domain::DomainName("host".parse().unwrap())), parameters: vec![ EsmtpParameter { - name: "foo".to_string(), + name: "foo".into(), value: None, }, EsmtpParameter { - name: "bar".to_string(), - value: Some("baz".to_string()), - } + name: "bar".into(), + value: Some("baz".into()), + }, ], - } + }) ); + } - assert_eq!( - Parser::parse_command("mail from:").unwrap(), - Command::MailFrom { + #[test] + fn test_mail_from_bare_address() { + // No angle brackets — accepted as a leniency + k9::assert_equal!( + unwrapper(Command::parse("MAIL FROM:user@host")), + MaybePartialCommand::Full(Command::MailFrom { + address: mail_path("user", Domain::DomainName("host".parse().unwrap())), + parameters: vec![], + }) + ); + } + + #[test] + fn test_mail_from_at_domain_list() { + k9::assert_equal!( + unwrapper(Command::parse( + "MAIL FROM:<@hosta.int,@jkl.org:userc@d.bar.org>" + )), + MaybePartialCommand::Full(Command::MailFrom { address: ReversePath::Path(MailPath { - at_domain_list: vec![], + at_domain_list: vec!["hosta.int".into(), "jkl.org".into()], mailbox: Mailbox { - local_part: "user".to_string(), - domain: Domain::V4("10.0.0.1".to_string()) - } + local_part: "userc".into(), + domain: Domain::DomainName("d.bar.org".parse().unwrap()), + }, }), parameters: vec![], - } + }) ); + } - assert_eq!( - Parser::parse_command("mail from:").unwrap(), - Command::MailFrom { - address: ReversePath::Path(MailPath { - at_domain_list: vec![], - mailbox: Mailbox { - local_part: "user".to_string(), - domain: Domain::V6("::1".to_string()) - } - }), - parameters: vec![], - } - ); - - assert_eq!( - Mailbox { - local_part: "user".to_string(), - domain: Domain::V6("::1".to_string()) - } - .to_string(), - "user@[IPv6:::1]".to_string() - ); - - assert_eq!( - Parser::parse_command("mail from:").unwrap(), - Command::MailFrom { - address: ReversePath::Path(MailPath { - at_domain_list: vec![], - mailbox: Mailbox { - local_part: "user".to_string(), - domain: Domain::Tagged { - tag: "future".to_string(), - literal: "something".to_string() - } - } - }), - parameters: vec![], - } - ); - - assert_eq!( - Parser::parse_command("MAIL FROM:<@hosta.int,@jkl.org:userc@d.bar.org>").unwrap(), - Command::MailFrom { - address: ReversePath::Path(MailPath { - at_domain_list: vec!["hosta.int".to_string(), "jkl.org".to_string()], - mailbox: Mailbox { - local_part: "userc".to_string(), - domain: Domain::Name("d.bar.org".to_string()) - } - }), - parameters: vec![], + #[test] + fn test_mail_from_invalid_ipv4_is_partial() { + // Bad IPv4 inside brackets → Partial, not a hard error + k9::assert_equal!( + unwrapper(Command::parse("MAIL FROM:")), + MaybePartialCommand::Partial { + verb: CommandVerb::Mail, + remainder: "FROM:".into(), } ); } #[test] - fn parse_domain() { - assert!(is_valid_domain("hello")); - assert!(is_valid_domain("he-llo")); - assert!(is_valid_domain("he.llo")); - assert!(is_valid_domain("he.llo-")); - } - - #[test] - fn parse_xclient() { - assert_eq!( - Parser::parse_command("XCLIENT NAME=spike.porcupine.org ADDR=10.0.0.1").unwrap(), - Command::XClient(vec![ - XClientParameter { - name: "NAME".to_string(), - value: "spike.porcupine.org".to_string() - }, - XClientParameter { - name: "ADDR".to_string(), - value: "10.0.0.1".to_string() - }, - ]) - ); - } - - #[test] - fn mailbox() { - assert_eq!( - Mailbox { - local_part: "user".to_string(), - domain: Domain::Name("example.com".to_string()) + fn test_mail_from_invalid_ipv6_is_partial() { + // Bad IPv6 after "IPv6:" prefix → Partial, not a hard error + k9::assert_equal!( + unwrapper(Command::parse("MAIL FROM:")), + MaybePartialCommand::Partial { + verb: CommandVerb::Mail, + remainder: "FROM:".into(), } - .to_string(), - "user@example.com" ); + } - let info_at = MailPath { - at_domain_list: vec![], + #[test] + fn test_mail_alone_is_partial() { + k9::assert_equal!( + unwrapper(Command::parse("MAIL")), + MaybePartialCommand::Partial { + verb: CommandVerb::Mail, + remainder: "".into(), + } + ); + } + + #[test] + fn test_mail_with_garbage_is_partial() { + k9::assert_equal!( + unwrapper(Command::parse("MAIL garbage")), + MaybePartialCommand::Partial { + verb: CommandVerb::Mail, + remainder: "garbage".into(), + } + ); + } + + // ------------------------------------------------------------------ + // RCPT TO tests + // ------------------------------------------------------------------ + + fn rcpt_path(local: &str, domain: Domain) -> ForwardPath { + Mailbox { + local_part: local.into(), + domain, + } + .into() + } + + #[test] + fn test_rcpt_to_domain_name() { + k9::assert_equal!( + unwrapper(Command::parse("RCPT TO:")), + MaybePartialCommand::Full(Command::RcptTo { + address: rcpt_path("user", Domain::DomainName("host".parse().unwrap())), + parameters: vec![], + }) + ); + } + + #[test] + fn test_rcpt_to_case_insensitive() { + // Verb and keyword are case-insensitive + k9::assert_equal!( + unwrapper(Command::parse("rcpt to:")), + MaybePartialCommand::Full(Command::RcptTo { + address: rcpt_path("user", Domain::DomainName("host".parse().unwrap())), + parameters: vec![], + }) + ); + } + + #[test] + fn test_rcpt_to_postmaster() { + k9::assert_equal!( + unwrapper(Command::parse("RCPT TO:")), + MaybePartialCommand::Full(Command::RcptTo { + address: ForwardPath::Postmaster, + parameters: vec![], + }) + ); + } + + #[test] + fn test_rcpt_to_postmaster_lowercase() { + // is case-insensitive per RFC 5321 + k9::assert_equal!( + unwrapper(Command::parse("RCPT TO:")), + MaybePartialCommand::Full(Command::RcptTo { + address: ForwardPath::Postmaster, + parameters: vec![], + }) + ); + } + + #[test] + fn test_rcpt_to_ipv4() { + k9::assert_equal!( + unwrapper(Command::parse("RCPT TO:")), + MaybePartialCommand::Full(Command::RcptTo { + address: rcpt_path("user", Domain::V4("10.0.0.1".parse().unwrap())), + parameters: vec![], + }) + ); + } + + #[test] + fn test_rcpt_to_ipv6() { + k9::assert_equal!( + unwrapper(Command::parse("RCPT TO:")), + MaybePartialCommand::Full(Command::RcptTo { + address: rcpt_path("user", Domain::V6("::1".parse().unwrap())), + parameters: vec![], + }) + ); + } + + #[test] + fn test_rcpt_to_tagged_literal() { + k9::assert_equal!( + unwrapper(Command::parse("RCPT TO:")), + MaybePartialCommand::Full(Command::RcptTo { + address: rcpt_path("user", Domain::Tagged("future:something".into())), + parameters: vec![], + }) + ); + } + + #[test] + fn test_rcpt_to_esmtp_params() { + k9::assert_equal!( + unwrapper(Command::parse("RCPT TO: foo bar=baz")), + MaybePartialCommand::Full(Command::RcptTo { + address: rcpt_path("user", Domain::DomainName("host".parse().unwrap())), + parameters: vec![ + EsmtpParameter { + name: "foo".into(), + value: None, + }, + EsmtpParameter { + name: "bar".into(), + value: Some("baz".into()), + }, + ], + }) + ); + } + + #[test] + fn test_rcpt_to_bare_address() { + // No angle brackets — accepted as a leniency + k9::assert_equal!( + unwrapper(Command::parse("RCPT TO:user@host")), + MaybePartialCommand::Full(Command::RcptTo { + address: rcpt_path("user", Domain::DomainName("host".parse().unwrap())), + parameters: vec![], + }) + ); + } + + #[test] + fn test_rcpt_to_at_domain_list() { + k9::assert_equal!( + unwrapper(Command::parse( + "RCPT TO:<@hosta.int,@jkl.org:userc@d.bar.org>" + )), + MaybePartialCommand::Full(Command::RcptTo { + address: ForwardPath::Path(MailPath { + at_domain_list: vec!["hosta.int".into(), "jkl.org".into()], + mailbox: Mailbox { + local_part: "userc".into(), + domain: Domain::DomainName("d.bar.org".parse().unwrap()), + }, + }), + parameters: vec![], + }) + ); + } + + #[test] + fn test_rcpt_to_invalid_ipv4_is_partial() { + // Bad IPv4 inside brackets → Partial, not a hard error + k9::assert_equal!( + unwrapper(Command::parse("RCPT TO:")), + MaybePartialCommand::Partial { + verb: CommandVerb::Rcpt, + remainder: "TO:".into(), + } + ); + } + + #[test] + fn test_rcpt_to_invalid_ipv6_is_partial() { + // Bad IPv6 after "IPv6:" prefix → Partial, not a hard error + k9::assert_equal!( + unwrapper(Command::parse("RCPT TO:")), + MaybePartialCommand::Partial { + verb: CommandVerb::Rcpt, + remainder: "TO:".into(), + } + ); + } + + #[test] + fn test_rcpt_alone_is_partial() { + k9::assert_equal!( + unwrapper(Command::parse("RCPT")), + MaybePartialCommand::Partial { + verb: CommandVerb::Rcpt, + remainder: "".into(), + } + ); + } + + #[test] + fn test_rcpt_with_garbage_is_partial() { + k9::assert_equal!( + unwrapper(Command::parse("RCPT garbage")), + MaybePartialCommand::Partial { + verb: CommandVerb::Rcpt, + remainder: "garbage".into(), + } + ); + } + + // ------------------------------------------------------------------ + // encode / encode_str tests + // ------------------------------------------------------------------ + + /// Parse a command from a string, encode it, and assert the encoded + /// output equals `expected`. Then parse the encoded output and assert + /// the result equals the original parsed command (round-trip). + fn assert_encode(input: &str, expected: &str) { + let cmd = match unwrapper(Command::parse(input)) { + MaybePartialCommand::Full(c) => c, + other => panic!("expected Full, got {other:?}"), + }; + let encoded = cmd.encode(); + k9::assert_equal!(encoded, BString::from(expected)); + // Round-trip: parsing the encoded form must reproduce the command + k9::assert_equal!( + unwrapper(Command::parse(encoded.clone())), + MaybePartialCommand::Full(cmd) + ); + } + + #[test] + fn test_encode_ehlo_domain() { + assert_encode("EHLO example.com", "EHLO example.com\r\n"); + } + + #[test] + fn test_encode_ehlo_ipv4() { + assert_encode("EHLO [10.0.0.1]", "EHLO [10.0.0.1]\r\n"); + } + + #[test] + fn test_encode_ehlo_ipv6() { + assert_encode("EHLO [IPv6:::1]", "EHLO [IPv6:::1]\r\n"); + } + + #[test] + fn test_encode_ehlo_tagged() { + assert_encode("EHLO [future:something]", "EHLO [future:something]\r\n"); + } + + #[test] + fn test_encode_helo() { + assert_encode("HELO mail.example.com", "HELO mail.example.com\r\n"); + } + + #[test] + fn test_encode_lhlo() { + assert_encode("LHLO mail.example.com", "LHLO mail.example.com\r\n"); + } + + #[test] + fn test_encode_noop_none() { + assert_encode("NOOP", "NOOP\r\n"); + } + + #[test] + fn test_encode_noop_some() { + assert_encode("NOOP something", "NOOP something\r\n"); + } + + #[test] + fn test_encode_help_none() { + assert_encode("HELP", "HELP\r\n"); + } + + #[test] + fn test_encode_help_some() { + assert_encode("HELP MAIL", "HELP MAIL\r\n"); + } + + #[test] + fn test_encode_vrfy_none() { + assert_encode("VRFY", "VRFY\r\n"); + } + + #[test] + fn test_encode_vrfy_some() { + assert_encode("VRFY user", "VRFY user\r\n"); + } + + #[test] + fn test_encode_expn_none() { + assert_encode("EXPN", "EXPN\r\n"); + } + + #[test] + fn test_encode_expn_some() { + assert_encode("EXPN list", "EXPN list\r\n"); + } + + #[test] + fn test_encode_data() { + assert_encode("DATA", "DATA\r\n"); + } + + #[test] + fn test_encode_data_dot() { + // DataDot is never parsed — it is constructed programmatically. + // Verify it encodes to exactly ".\r\n" (not ".\r\n\r\n"). + k9::assert_equal!(Command::DataDot.encode(), BString::from(".\r\n")); + } + + #[test] + fn test_encode_rset() { + assert_encode("RSET", "RSET\r\n"); + } + + #[test] + fn test_encode_quit() { + assert_encode("QUIT", "QUIT\r\n"); + } + + #[test] + fn test_encode_starttls() { + assert_encode("STARTTLS", "STARTTLS\r\n"); + } + + #[test] + fn test_encode_mail_from_path() { + assert_encode( + "MAIL FROM:", + "MAIL FROM:\r\n", + ); + } + + #[test] + fn test_encode_mail_from_null_sender() { + assert_encode("MAIL FROM:<>", "MAIL FROM:<>\r\n"); + } + + #[test] + fn test_encode_mail_from_params() { + assert_encode( + "MAIL FROM: SIZE=1000 BODY=8BITMIME", + "MAIL FROM: SIZE=1000 BODY=8BITMIME\r\n", + ); + } + + #[test] + fn test_encode_mail_from_ipv4() { + assert_encode( + "MAIL FROM:", + "MAIL FROM:\r\n", + ); + } + + #[test] + fn test_encode_mail_from_ipv6() { + assert_encode( + "MAIL FROM:", + "MAIL FROM:\r\n", + ); + } + + #[test] + fn test_encode_rcpt_to_path() { + assert_encode( + "RCPT TO:", + "RCPT TO:\r\n", + ); + } + + #[test] + fn test_encode_rcpt_to_postmaster() { + // Canonical encoding is capital-P Postmaster; parsing is case-insensitive + let cmd = Command::RcptTo { + address: ForwardPath::Postmaster, + parameters: vec![], + }; + k9::assert_equal!(cmd.encode(), BString::from("RCPT TO:\r\n")); + k9::assert_equal!( + unwrapper(Command::parse(cmd.encode())), + MaybePartialCommand::Full(cmd) + ); + } + + #[test] + fn test_encode_rcpt_to_params() { + assert_encode( + "RCPT TO: NOTIFY=SUCCESS", + "RCPT TO: NOTIFY=SUCCESS\r\n", + ); + } + + #[test] + fn test_encode_auth_no_response() { + assert_encode("AUTH PLAIN", "AUTH PLAIN\r\n"); + } + + #[test] + fn test_encode_auth_with_response() { + assert_encode("AUTH PLAIN dXNlcjpwYXNz", "AUTH PLAIN dXNlcjpwYXNz\r\n"); + } + + #[test] + fn test_encode_xclient_single() { + assert_encode( + "XCLIENT NAME=foo.example.com", + "XCLIENT NAME=foo.example.com\r\n", + ); + } + + #[test] + fn test_encode_xclient_multiple() { + assert_encode( + "XCLIENT NAME=foo.example.com ADDR=10.0.0.1", + "XCLIENT NAME=foo.example.com ADDR=10.0.0.1\r\n", + ); + } + + #[test] + fn test_encode_xclient_xtext_roundtrip() { + // '+40' in wire form decodes to '@'. + // '@' (ASCII 64) is a valid xchar (range 33-126, excl. '+' and '='), + // so it is NOT re-encoded as '+40' — it passes through unchanged. + assert_encode( + "XCLIENT NAME=user+40example.com", + "XCLIENT NAME=user@example.com\r\n", + ); + } + + #[test] + fn test_encode_unknown() { + assert_encode("FOOBAR some args", "FOOBAR some args\r\n"); + } + + #[test] + fn test_encode_unknown_bare_verb() { + assert_encode("FOOBAR", "FOOBAR\r\n"); + } + + #[test] + fn test_encode_mail_from_source_route_dropped() { + // A command parsed with a non-empty at_domain_list encodes without the + // source route (RFC 5321 says SHOULD NOT generate). The re-parsed + // result therefore has an empty at_domain_list. + let parsed = unwrapper(Command::parse("MAIL FROM:<@route.example.com:user@host>")); + let cmd = match parsed { + MaybePartialCommand::Full(c) => c, + other => panic!("expected Full, got {other:?}"), + }; + let encoded = cmd.encode(); + k9::assert_equal!(encoded, BString::from("MAIL FROM:\r\n")); + // Re-parsing gives back the same mailbox but with an empty source route + let expected = Mailbox { + local_part: "user".into(), + domain: Domain::DomainName("host".parse().unwrap()), + }; + k9::assert_equal!( + unwrapper(Command::parse(encoded)), + MaybePartialCommand::Full(Command::MailFrom { + address: expected.into(), + parameters: vec![], + }) + ); + } + + // ------------------------------------------------------------------ + // RFC 6531 / non-ASCII tests + // ------------------------------------------------------------------ + + /// Helper: parse a Full command from a known-good string. + fn parse_full(input: &str) -> Command { + match unwrapper(Command::parse(input)) { + MaybePartialCommand::Full(c) => c, + other => panic!("expected Full, got {other:?}"), + } + } + + // --- Non-ASCII local parts --- + + #[test] + fn test_mail_from_utf8_local_part() { + // ü = U+00FC, UTF-8 encoding [0xc3, 0xbc] + let cmd = parse_full("MAIL FROM:<ü@example.com>"); + let mailbox = match cmd { + Command::MailFrom { + address: ReversePath::Path(path), + .. + } => path.mailbox, + other => panic!("unexpected {other:?}"), + }; + k9::assert_equal!(mailbox.local_part, String::from("ü")); + k9::assert_equal!( + mailbox.domain, + Domain::DomainName("example.com".parse().unwrap()) + ); + } + + #[test] + fn test_mail_from_utf8_local_part_roundtrip() { + // Non-ASCII local parts survive encode → parse unchanged because + // encode_mailbox copies the raw bytes and parse stores them verbatim. + let input = "MAIL FROM:<ü@example.com>"; + let cmd = parse_full(input); + k9::assert_equal!( + unwrapper(Command::parse(cmd.encode())), + MaybePartialCommand::Full(cmd) + ); + } + + #[test] + fn test_mail_from_quoted_utf8_local_part() { + // UTF-8 characters are valid inside a quoted-string local part (RFC 6532). + let cmd = parse_full("MAIL FROM:<\"ü\"@example.com>"); + let mailbox = match cmd { + Command::MailFrom { + address: ReversePath::Path(path), + .. + } => path.mailbox, + other => panic!("unexpected {other:?}"), + }; + // local_part stores the string including the surrounding quotes + k9::assert_equal!(mailbox.local_part, String::from("\"ü\"")); + } + + #[test] + fn test_rcpt_to_utf8_local_part() { + // CJK characters in the local part (RFC 6531 EAI) + let cmd = parse_full("RCPT TO:<用户@example.com>"); + let mailbox = match cmd { + Command::RcptTo { + address: ForwardPath::Path(path), + .. + } => path.mailbox, + other => panic!("unexpected {other:?}"), + }; + k9::assert_equal!(mailbox.local_part, String::from("用户")); + } + + // --- U-label (non-ASCII) domain names --- + + #[test] + fn test_mail_from_u_label_domain() { + // münchen.de — stored as normalized ASCII/punycode form + let cmd = parse_full("MAIL FROM:"); + let domain = match cmd { + Command::MailFrom { + address: ReversePath::Path(path), + .. + } => path.mailbox.domain, + other => panic!("unexpected {other:?}"), + }; + match domain { + Domain::DomainName(ref s) => { + k9::assert_equal!(s.as_str(), "xn--mnchen-3ya.de"); + } + other => panic!("unexpected domain variant {other:?}"), + } + } + + #[test] + fn test_mail_from_u_label_domain_encode() { + // encode_domain normalises U-labels to their ASCII/punycode form. + // This is intentional: punycode is always safe for wire transmission. + let cmd = parse_full("MAIL FROM:"); + k9::assert_equal!( + cmd.encode(), + BString::from("MAIL FROM:\r\n") + ); + } + + #[test] + fn test_ehlo_u_label_domain() { + // DomainString::as_str() returns the normalized ASCII/punycode form + let cmd = parse_full("EHLO münchen.de"); + match cmd { + Command::Ehlo(Domain::DomainName(ref s)) => { + k9::assert_equal!(s.as_str(), "xn--mnchen-3ya.de"); + } + other => panic!("unexpected {other:?}"), + } + } + + #[test] + fn test_ehlo_u_label_domain_encode() { + // encode_domain normalises to punycode for EHLO as well + let cmd = parse_full("EHLO münchen.de"); + k9::assert_equal!(cmd.encode(), BString::from("EHLO xn--mnchen-3ya.de\r\n")); + } + + // --- Non-ASCII ESMTP values (RFC 6531 §3.3: esmtp-value =/ UTF8-non-ASCII) --- + + #[test] + fn test_esmtp_value_utf8() { + // A parameter value containing a non-ASCII UTF-8 character (ü = U+00FC) + let cmd = parse_full("MAIL FROM: PARAM=valüe"); + let params = match cmd { + Command::MailFrom { parameters, .. } => parameters, + other => panic!("unexpected {other:?}"), + }; + k9::assert_equal!(params.len(), 1); + k9::assert_equal!(params[0].name, "PARAM"); + k9::assert_equal!(params[0].value, Some("valüe".to_string())); + } + + #[test] + fn test_esmtp_value_utf8_roundtrip() { + // Non-ASCII ESMTP values are stored and re-encoded verbatim (raw bytes). + let input = "MAIL FROM: PARAM=valüe"; + let cmd = parse_full(input); + k9::assert_equal!( + cmd.encode(), + BString::from("MAIL FROM: PARAM=valüe\r\n") + ); + k9::assert_equal!( + unwrapper(Command::parse(cmd.encode())), + MaybePartialCommand::Full(parse_full(input)) + ); + } + + #[test] + fn test_esmtp_value_utf8_only() { + // A value consisting entirely of non-ASCII UTF-8 characters parses successfully + let cmd = parse_full("MAIL FROM: X=ünïcödé"); + let params = match cmd { + Command::MailFrom { parameters, .. } => parameters, + other => panic!("unexpected {other:?}"), + }; + k9::assert_equal!(params[0].name, "X"); + k9::assert_equal!(params[0].value, Some("ünïcödé".to_string())); + } + + // --- Fallible conversion tests for MailPath --- + + #[test] + fn test_reverse_path_null_sender_try_into_mailpath_err() { + use core::convert::TryInto; + let null_sender = ReversePath::NullSender; + let err: &'static str = + >::try_into(null_sender).unwrap_err(); + k9::assert_equal!(err, "Cannot convert NullSender to MailPath"); + } + + #[test] + fn test_forward_path_postmaster_try_into_mailpath_err() { + use core::convert::TryInto; + let postmaster = ForwardPath::Postmaster; + let err: &'static str = + >::try_into(postmaster).unwrap_err(); + k9::assert_equal!(err, "Cannot convert Postmaster to MailPath"); + } + + #[test] + fn test_reverse_path_try_from_null_sender_err() { + let null_sender = ReversePath::NullSender; + let err = MailPath::try_from(null_sender).unwrap_err(); + k9::assert_equal!(err, "Cannot convert NullSender to MailPath"); + } + + #[test] + fn test_forward_path_try_from_postmaster_err() { + let postmaster = ForwardPath::Postmaster; + let err = MailPath::try_from(postmaster).unwrap_err(); + k9::assert_equal!(err, "Cannot convert Postmaster to MailPath"); + } + + // --- Fallible conversion tests for EnvelopeAddress --- + + #[test] + fn test_envelope_address_try_from_mailbox() { + let mailbox = Mailbox { + local_part: String::from("user"), + domain: Domain::DomainName("example.com".parse().unwrap()), + }; + let addr = EnvelopeAddress::from(mailbox); + match addr { + EnvelopeAddress::Path(path) => { + k9::assert_equal!(path.mailbox.local_part(), "user"); + k9::assert_equal!( + path.mailbox.domain, + Domain::DomainName("example.com".parse().unwrap()) + ); + } + _ => panic!("Expected Path variant"), + } + } + + #[test] + fn test_envelope_address_try_from_null_err() { + let addr = EnvelopeAddress::Null; + let err = Mailbox::try_from(addr).unwrap_err(); + k9::assert_equal!(err, "Cannot convert Null to Mailbox"); + } + + #[test] + fn test_envelope_address_try_from_postmaster_err() { + let addr = EnvelopeAddress::Postmaster; + let err = Mailbox::try_from(addr).unwrap_err(); + k9::assert_equal!(err, "Cannot convert Postmaster to Mailbox"); + } + + #[test] + fn test_envelope_address_try_from_null_to_reverse_path() { + let addr = EnvelopeAddress::Null; + let result = ReversePath::try_from(addr).unwrap(); + k9::assert_equal!(result, ReversePath::NullSender); + } + + #[test] + fn test_envelope_address_try_from_postmaster_to_forward_path() { + let addr = EnvelopeAddress::Postmaster; + let result = ForwardPath::try_from(addr).unwrap(); + k9::assert_equal!(result, ForwardPath::Postmaster); + } + + // --- Fallible conversion tests for MailPath --- + + #[test] + fn test_envelope_address_try_into_mailpath_null_err() { + let addr = EnvelopeAddress::Null; + let err = MailPath::try_from(addr).unwrap_err(); + k9::assert_equal!(err, "Cannot convert Null to MailPath"); + } + + #[test] + fn test_envelope_address_try_into_mailpath_postmaster_err() { + let addr = EnvelopeAddress::Postmaster; + let err = MailPath::try_from(addr).unwrap_err(); + k9::assert_equal!(err, "Cannot convert Postmaster to MailPath"); + } + + #[test] + fn test_reverse_path_try_into_forward_path_null_sender_err() { + let rp = ReversePath::NullSender; + let err = ForwardPath::try_from(rp).unwrap_err(); + k9::assert_equal!(err, "Cannot convert NullSender to ForwardPath"); + } + + #[test] + fn test_forward_path_try_into_reverse_path_postmaster_err() { + let fp = ForwardPath::Postmaster; + let err = ReversePath::try_from(fp).unwrap_err(); + k9::assert_equal!(err, "Cannot convert Postmaster to ReversePath"); + } + + // --- Infallible conversion tests for Mailbox --- + + #[test] + fn test_mailbox_into_mailpath() { + let mailbox = Mailbox { + local_part: String::from("user"), + domain: Domain::DomainName("example.com".parse().unwrap()), + }; + let path: MailPath = mailbox.into(); + assert!(path.at_domain_list.is_empty()); + k9::assert_equal!(path.mailbox.local_part(), "user"); + } + + #[test] + fn test_mailbox_into_envelope_address() { + let mailbox = Mailbox { + local_part: String::from("user"), + domain: Domain::DomainName("example.com".parse().unwrap()), + }; + let addr: EnvelopeAddress = mailbox.into(); + match addr { + EnvelopeAddress::Path(path) => { + k9::assert_equal!(path.mailbox.local_part(), "user"); + } + _ => panic!("Expected Path variant"), + } + } + + #[test] + fn test_mailbox_into_reverse_path() { + let mailbox = Mailbox { + local_part: String::from("user"), + domain: Domain::DomainName("example.com".parse().unwrap()), + }; + let path: ReversePath = mailbox.into(); + match path { + ReversePath::Path(p) => { + k9::assert_equal!(p.mailbox.local_part(), "user"); + } + _ => panic!("Expected Path variant"), + } + } + + #[test] + fn test_mailbox_into_forward_path() { + let mailbox = Mailbox { + local_part: String::from("user"), + domain: Domain::DomainName("example.com".parse().unwrap()), + }; + let path: ForwardPath = mailbox.into(); + match path { + ForwardPath::Path(p) => { + k9::assert_equal!(p.mailbox.local_part(), "user"); + } + _ => panic!("Expected Path variant"), + } + } + + // --- Round-trip conversion tests --- + + #[test] + fn test_mailbox_roundtrip() { + let original = Mailbox { + local_part: String::from("user"), + domain: Domain::DomainName("example.com".parse().unwrap()), + }; + let path: MailPath = original.clone().into(); + let mailbox: Mailbox = path.mailbox.into(); + k9::assert_equal!(original.local_part(), mailbox.local_part()); + k9::assert_equal!(original.domain, mailbox.domain); + } + + #[test] + fn test_reverse_path_to_forward_path() { + let path = MailPath { + at_domain_list: vec!["route.com".into()], mailbox: Mailbox { - local_part: "\"info@\"".to_string(), - domain: Domain::Name("example.com".to_string()), + local_part: String::from("user"), + domain: Domain::DomainName("example.com".parse().unwrap()), }, }; - assert_eq!( - EnvelopeAddress::from_str("\"info@\"@example.com").unwrap(), - EnvelopeAddress::Path(info_at.clone()) - ); - assert_eq!(info_at.to_string(), "\"info@\"@example.com"); + let rp = ReversePath::Path(path.clone()); + let fp: ForwardPath = rp.try_into().unwrap(); + match fp { + ForwardPath::Path(p) => { + k9::assert_equal!(p.mailbox.local_part(), "user"); + } + _ => panic!("Expected Path variant"), + } + } - assert_eq!( - EnvelopeAddress::from_str("postmaster").unwrap(), - EnvelopeAddress::Postmaster - ); - assert_eq!( - EnvelopeAddress::from_str("").unwrap(), - EnvelopeAddress::Null - ); + // --- Debug tests for MailPath --- - assert_eq!(EnvelopeAddress::Null.to_string(), ""); - assert_eq!(EnvelopeAddress::Postmaster.to_string(), "postmaster"); - assert_eq!( - EnvelopeAddress::Path(info_at).to_string(), - "\"info@\"@example.com" - ); - - // Equality is based on normalized local part - let foo1 = EnvelopeAddress::from_str("foo@example.com").unwrap(); - let foo2 = EnvelopeAddress::from_str("\"foo\"@example.com").unwrap(); - let foo3 = EnvelopeAddress::from_str("\"f\\oo\"@example.com").unwrap(); - - assert_eq!(foo1, foo2); - assert_eq!(foo2, foo3); - assert_eq!(foo1, foo3); - - let EnvelopeAddress::Path(foo3) = foo3 else { - panic!("Should be a Path"); + #[test] + fn test_mailpath_debug_simple() { + let mailbox = Mailbox { + local_part: "someone".into(), + domain: Domain::DomainName("example.com".parse().unwrap()), }; - - assert_eq!(foo3.mailbox.local_part(), "foo"); - - // Unbalanced quotes are illegal and don't parse - EnvelopeAddress::from_str("\"foo@example.com").unwrap_err(); + let path: MailPath = mailbox.into(); + let debug_str = format!("{:?}", path); + k9::assert_equal!(debug_str, r#"MailPath("someone@example.com")"#); } #[test] - fn parse_envelope_address_with_angles() { - assert_eq!( - parse_envelope_address("").unwrap(), - EnvelopeAddress::Path(MailPath { - at_domain_list: vec![], - mailbox: Mailbox { - local_part: "user".to_string(), - domain: Domain::Name("example.com".to_string()) - } - }) + fn test_mailpath_debug_with_at_domain_list() { + let path = MailPath { + at_domain_list: vec!["route.example.com".into()], + mailbox: Mailbox { + local_part: "user".into(), + domain: Domain::DomainName("host".parse().unwrap()), + }, + }; + let debug_str = format!("{:?}", path); + k9::assert_equal!(debug_str, r#"MailPath("@route.example.com:user@host")"#); + } + + #[test] + fn test_mailpath_debug_multiple_at_domains() { + let path = MailPath { + at_domain_list: vec!["hosta.int".into(), "jkl.org".into()], + mailbox: Mailbox { + local_part: "userc".into(), + domain: Domain::DomainName("d.bar.org".parse().unwrap()), + }, + }; + let debug_str = format!("{:?}", path); + k9::assert_equal!( + debug_str, + r#"MailPath("@hosta.int,@jkl.org:userc@d.bar.org")"# ); } #[test] - fn parse_envelope_address_without_angles() { - assert_eq!( - parse_envelope_address("user@example.com").unwrap(), - EnvelopeAddress::Path(MailPath { - at_domain_list: vec![], - mailbox: Mailbox { - local_part: "user".to_string(), - domain: Domain::Name("example.com".to_string()) - } - }) - ); + fn test_mailpath_debug_non_ascii_utf8() { + // UTF-8 character ü (U+00FC) which is non-ASCII but valid UTF-8 + // should be preserved as-is in debug output + let mailbox = Mailbox { + local_part: "üser".into(), + domain: Domain::DomainName("example.com".parse().unwrap()), + }; + let path: MailPath = mailbox.into(); + let debug_str = format!("{:?}", path); + // ü is valid UTF-8, should appear as-is + k9::assert_equal!(debug_str, r#"MailPath("üser@example.com")"#); } #[test] - fn parse_envelope_address_postmaster_with_angles() { - assert_eq!( - parse_envelope_address("").unwrap(), - EnvelopeAddress::Postmaster - ); - assert_eq!( - parse_envelope_address("").unwrap(), - EnvelopeAddress::Postmaster - ); + fn test_mailpath_debug_with_ipv4() { + let mailbox = Mailbox { + local_part: "user".into(), + domain: Domain::V4("10.0.0.1".parse().unwrap()), + }; + let path: MailPath = mailbox.into(); + let debug_str = format!("{:?}", path); + // IPv4 literals must be in brackets per RFC 5321 + k9::assert_equal!(debug_str, r#"MailPath("user@[10.0.0.1]")"#); } #[test] - fn parse_envelope_address_postmaster_no_angles() { - assert_eq!( - parse_envelope_address("postmaster").unwrap(), - EnvelopeAddress::Postmaster - ); - assert_eq!( - parse_envelope_address("PostMaster").unwrap(), - EnvelopeAddress::Postmaster - ); + fn test_mailpath_debug_with_ipv6() { + let mailbox = Mailbox { + local_part: "user".into(), + domain: Domain::V6("::1".parse().unwrap()), + }; + let path: MailPath = mailbox.into(); + let debug_str = format!("{:?}", path); + // IPv6 literals must be in brackets with IPv6: prefix per RFC 5321 + k9::assert_equal!(debug_str, r#"MailPath("user@[IPv6:::1]")"#); } #[test] - fn parse_envelope_address_postmaster_with_domain() { - assert_eq!( - parse_envelope_address("postmaster@example.com").unwrap(), - EnvelopeAddress::Path(MailPath { - at_domain_list: vec![], - mailbox: Mailbox { - local_part: "postmaster".to_string(), - domain: Domain::Name("example.com".to_string()) - } - }) - ); + fn test_mailpath_debug_with_tagged_literal() { + let mailbox = Mailbox { + local_part: "user".into(), + domain: Domain::Tagged("future:something".into()), + }; + let path: MailPath = mailbox.into(); + let debug_str = format!("{:?}", path); + // Tagged literals must be in brackets per RFC 5321 + k9::assert_equal!(debug_str, r#"MailPath("user@[future:something]")"#); + } + #[test] + fn test_envelope_address_debug_null() { + let addr = EnvelopeAddress::Null; + let debug_str = format!("{:?}", addr); + k9::assert_equal!(debug_str, "<>"); } #[test] - fn parse_envelope_address_null_sender() { - assert_eq!(parse_envelope_address("<>").unwrap(), EnvelopeAddress::Null); + fn test_envelope_address_debug_postmaster() { + let addr = EnvelopeAddress::Postmaster; + let debug_str = format!("{:?}", addr); + k9::assert_equal!(debug_str, ""); } #[test] - fn parse_envelope_address_null_sender_no_angles() { - assert_eq!(parse_envelope_address("").unwrap(), EnvelopeAddress::Null); + fn test_envelope_address_debug_path_with_non_ascii_utf8() { + // UTF-8 character ü (U+00FC) which is non-ASCII but valid UTF-8 + // should be preserved as-is in debug output + let path = MailPath { + at_domain_list: vec!["example.com".into()], + mailbox: Mailbox { + local_part: String::from("üser"), + domain: Domain::DomainName("example.com".parse().unwrap()), + }, + }; + let addr = EnvelopeAddress::Path(path); + let debug_str = format!("{:?}", addr); + // ü is valid UTF-8, should appear as-is + k9::assert_equal!(debug_str, r#"<@example.com:üser@example.com>"#); } } - -/* ABNF from RFC 5321 - -mail = "MAIL FROM:" Reverse-path [SP Mail-parameters] CRLF - -rcpt = "RCPT TO:" ( "" / "" / - Forward-path ) [SP Rcpt-parameters] CRLF - - Note that, in a departure from the usual rules for - local-parts, the "Postmaster" string shown above is - treated as case-insensitive. - -Reverse-path = Path / "<>" -Forward-path = Path -Path = "<" [ A-d-l ":" ] Mailbox ">" -A-d-l = At-domain *( "," At-domain ) - ; Note that this form, the so-called "source - ; route", MUST BE accepted, SHOULD NOT be - ; generated, and SHOULD be ignored. -At-domain = "@" Domain -Mail-parameters = esmtp-param *(SP esmtp-param) - - Rcpt-parameters = esmtp-param *(SP esmtp-param) - - esmtp-param = esmtp-keyword ["=" esmtp-value] - - esmtp-keyword = (ALPHA / DIGIT) *(ALPHA / DIGIT / "-") - - esmtp-value = 1*(%d33-60 / %d62-126) - ; any CHAR excluding "=", SP, and control - ; characters. If this string is an email address, - ; i.e., a Mailbox, then the "xtext" syntax [32] - ; SHOULD be used. - - Keyword = Ldh-str - - Argument = Atom - - Domain = sub-domain *("." sub-domain) - sub-domain = Let-dig [Ldh-str] - - Let-dig = ALPHA / DIGIT - - Ldh-str = *( ALPHA / DIGIT / "-" ) Let-dig - - address-literal = "[" ( IPv4-address-literal / - IPv6-address-literal / - General-address-literal ) "]" - ; See Section 4.1.3 - - Mailbox = Local-part "@" ( Domain / address-literal ) - - Local-part = Dot-string / Quoted-string - ; MAY be case-sensitive - - - Dot-string = Atom *("." Atom) - - Atom = 1*atext - - Quoted-string = DQUOTE *QcontentSMTP DQUOTE - - QcontentSMTP = qtextSMTP / quoted-pairSMTP - - quoted-pairSMTP = %d92 %d32-126 - ; i.e., backslash followed by any ASCII - ; graphic (including itself) or SPace - - qtextSMTP = %d32-33 / %d35-91 / %d93-126 - ; i.e., within a quoted string, any - ; ASCII graphic or space is permitted - ; without blackslash-quoting except - ; double-quote and the backslash itself. - - String = Atom / Quoted-string - - - IPv4-address-literal = Snum 3("." Snum) - - IPv6-address-literal = "IPv6:" IPv6-addr - - General-address-literal = Standardized-tag ":" 1*dcontent - - Standardized-tag = Ldh-str - ; Standardized-tag MUST be specified in a - ; Standards-Track RFC and registered with IANA - - - dcontent = %d33-90 / ; Printable US-ASCII - %d94-126 ; excl. "[", "\", "]" - - Snum = 1*3DIGIT - ; representing a decimal integer - ; value in the range 0 through 255 - - IPv6-addr = IPv6-full / IPv6-comp / IPv6v4-full / IPv6v4-comp - - IPv6-hex = 1*4HEXDIG - - IPv6-full = IPv6-hex 7(":" IPv6-hex) - - IPv6-comp = [IPv6-hex *5(":" IPv6-hex)] "::" - [IPv6-hex *5(":" IPv6-hex)] - ; The "::" represents at least 2 16-bit groups of - ; zeros. No more than 6 groups in addition to the - ; "::" may be present. - - IPv6v4-full = IPv6-hex 5(":" IPv6-hex) ":" IPv4-address-literal - - IPv6v4-comp = [IPv6-hex *3(":" IPv6-hex)] "::" - [IPv6-hex *3(":" IPv6-hex) ":"] - IPv4-address-literal - ; The "::" represents at least 2 16-bit groups of - ; zeros. No more than 4 groups in addition to the - ; "::" and IPv4-address-literal may be present. - - - ehlo = "EHLO" SP ( Domain / address-literal ) CRLF - helo = "HELO" SP Domain CRLF - - ehlo-ok-rsp = ( "250" SP Domain [ SP ehlo-greet ] CRLF ) - / ( "250-" Domain [ SP ehlo-greet ] CRLF - *( "250-" ehlo-line CRLF ) - "250" SP ehlo-line CRLF ) - - ehlo-greet = 1*(%d0-9 / %d11-12 / %d14-127) - ; string of any characters other than CR or LF - - ehlo-line = ehlo-keyword *( SP ehlo-param ) - - ehlo-keyword = (ALPHA / DIGIT) *(ALPHA / DIGIT / "-") - ; additional syntax of ehlo-params depends on - ; ehlo-keyword - - ehlo-param = 1*(%d33-126) - ; any CHAR excluding and all - ; control characters (US-ASCII 0-31 and 127 - ; inclusive) - - data = "DATA" CRLF - rset = "RSET" CRLF - vrfy = "VRFY" SP String CRLF - expn = "EXPN" SP String CRLF - help = "HELP" [ SP String ] CRLF - noop = "NOOP" [ SP String ] CRLF -quit = "QUIT" CRLF - - -*/ diff --git a/crates/rfc5321/src/rfc5321.pest b/crates/rfc5321/src/rfc5321.pest deleted file mode 100644 index 68ca0089..00000000 --- a/crates/rfc5321/src/rfc5321.pest +++ /dev/null @@ -1,93 +0,0 @@ - -alpha = { 'a'..'z' | 'A'..'Z' } -digit = { '0'..'9' } -hexdig = { 'a'..'f' | 'A'..'F' | '0'..'9' } -utf8_non_ascii = { '\u{80}'..'\u{10FFFF}' } - -atext = { "!" | "#" | "$" | "%" | "&" | "'" | "*" | "+" | "-" | "/" | "=" | - "?" | "^" | "_" | "`" | "{" | "|" | "}" | "~" | alpha | digit | utf8_non_ascii } -atom = { atext+ } - -let_dig = { alpha | digit | utf8_non_ascii } -ldh_str = { (alpha | digit | "-" | utf8_non_ascii )+ } // FIXME: validate that it doesn't end with - - -sub_domain = { let_dig ~ ldh_str? } -domain = { sub_domain ~ ("." ~ sub_domain)* } - -dot_string = { atom ~ ("." ~ atom)* } - -quoted_string = { "\"" ~ q_content_smtp* ~ "\"" } -q_content_smtp = { q_text_smtp | quoted_pair_smtp } -quoted_pair_smtp = { "\\" ~ '\u{20}'..'\u{7e}' } -q_text_smtp = { '\u{20}'..'\u{21}' | '\u{23}'..'\u{5b}' | '\u{5d}'..'\u{7e}' | utf8_non_ascii } - -string = { atom | quoted_string } - -local_part = { dot_string | quoted_string } -mailbox = { local_part ~ "@" ~ ( domain | address_literal ) } - -address_literal = { "[" ~ ( ipv4_address_literal | ipv6_address_literal | general_address_literal ) ~ "]" } - -ipv4_address_literal = { snum ~ "." ~ snum ~ "." ~ snum ~ "." ~ snum } -snum = { digit{1,3} } - -ipv6_address_literal = { ^"IPv6:" ~ ipv6_address } -ipv6_address = { (hexdig | ":" | ".")+ } - -general_address_literal = { standardized_tag ~ ":" ~ tag_content } -tag_content = { dcontent+ } -dcontent = { '\u{21}'..'\u{5a}' | '\u{5e}'..'\u{7e}' } -standardized_tag = { ldh_str } - -path = { "<" ~ (adl ~ ":" )? ~ mailbox ~ ">" } -path_no_angles = { (adl ~ ":" )? ~ mailbox } -adl = { at_domain ~ ( "," ~ at_domain )* } -at_domain = { "@" ~ domain } - -// Helper for use outside of the client when parsing addresses -envelope_address = { path | postmaster | null_sender | path_no_angles } -parse_envelope_address = _{ SOI ~ (path | postmaster | null_sender | path_no_angles | postmaster_no_angles | null_sender_no_angles) ~ EOI } - -forward_path = { path | postmaster | path_no_angles } -reverse_path = { path | null_sender | path_no_angles } -null_sender = { "<>" } -null_sender_no_angles = { "" } - -mail = { ^"MAIL FROM:" ~ " "* ~ reverse_path ~ (" " ~ smtp_parameters )? } -rcpt = { ^"RCPT TO:" ~ " "* ~ forward_path ~ ( " " ~ smtp_parameters )? } - -postmaster = { ^"" } -postmaster_no_angles = { ^"postmaster" } - -smtp_parameters = { esmtp_param ~ ( " " ~ esmtp_param )* } -esmtp_param = { esmtp_keyword ~ ("=" ~ esmtp_value )? } -esmtp_keyword = { (alpha | digit | "-")+ } -esmtp_value = { ( '\u{21}' .. '\u{3c}' | '\u{3e}'..'\u{7e}' | utf8_non_ascii )+ } - -complete_domain = _{ SOI ~ domain ~ EOI } - -hexchar = { "+" ~ hexdig{2} } -xchar = { '\u{21}'..'\u{2a}' | '\u{2c}'..'\u{3c}' | '\u{3e}'..'\u{7e}' } -xtext = { ( xchar | hexchar )* } -mech_char = { 'A'..'Z' | '0'..'9' | "-" | "_" } -sasl_mech = { mech_char{1,20} } -initial_response = { base64+ } -base64 = { 'A'..'Z' | 'a'..'z' | '0'..'9' | "+" | "/" | "=" } - -xclient_attr_name = { ^"NAME" | ^"ADDR" | ^"PORT" | ^"HELO" | ^"LOGIN" | ^"DESTADDR" | ^"DESTPORT" } -xclient_attr_value = { xtext } - -ehlo = { ^"EHLO " ~ ( domain | address_literal ) } -helo = { ^"HELO " ~ ( domain | address_literal ) } -data = { ^"DATA" } -rset = { ^"RSET" } -quit = { ^"QUIT" } -vrfy = { ^"VRFY " ~ string } -expn = { ^"EXPN " ~ string } -help = { ^"HELP" ~ (" " ~ string)? } -noop = { ^"NOOP" ~ (" " ~ string)? } -starttls = { ^"STARTTLS" } -auth = { ^"AUTH " ~ sasl_mech ~ (" " ~ initial_response)? } -xclient = { ^"XCLIENT" ~ (" " ~ xclient_attr_name ~ "=" ~ xclient_attr_value )+ } - -command = _{ SOI ~ mail | rcpt | ehlo | helo | data | rset | vrfy | expn | help | noop | quit | starttls | auth | xclient ~ EOI } diff --git a/crates/traffic-gen/src/main.rs b/crates/traffic-gen/src/main.rs index 1a87d9de..e03f6249 100644 --- a/crates/traffic-gen/src/main.rs +++ b/crates/traffic-gen/src/main.rs @@ -12,6 +12,7 @@ use num_format::{Locale, ToFormattedString}; use rand::distributions::WeightedIndex; use rand::prelude::*; use reqwest::{Client as HttpClient, Url}; +use rfc5321::parser::{Command, ForwardPath, ReversePath}; use rfc5321::*; use serde::Serialize; use std::io::Write; diff --git a/crates/tsa-daemon/src/http_server.rs b/crates/tsa-daemon/src/http_server.rs index 32098155..0d6b3499 100644 --- a/crates/tsa-daemon/src/http_server.rs +++ b/crates/tsa-daemon/src/http_server.rs @@ -22,7 +22,7 @@ use kumo_server_common::http_server::{AppError, RouterAndDocs}; use kumo_server_common::router_with_docs; use message::message::QueueNameComponents; use parking_lot::Mutex; -use rfc5321::ForwardPath; +use rfc5321::parser::ForwardPath; use serde_json::Value as JsonValue; use sha2::{Digest, Sha256}; use std::hash::Hash;