use nom::IResult;
#[cfg(test)]
use nom::{
bytes::streaming::{tag, take_while},
character::streaming::crlf,
combinator::opt,
};
use crate::types::{
AuthMechanism, EnhancedStatusCode, ServerCapabilities, SmtpExtension, SmtpResponse,
};
#[cfg(test)]
pub(crate) fn parse_response(input: &[u8]) -> IResult<&[u8], SmtpResponse> {
let mut remaining = input;
let mut lines: Vec<String> = Vec::new();
let mut first_enhanced: Option<EnhancedStatusCode> = None;
let mut first_code: Option<u16> = None;
let mut final_code: u16;
loop {
let (rest, code) = reply_code(remaining)?;
if let Some(expected) = first_code {
if code != expected {
return Err(nom::Err::Error(nom::error::Error::new(
remaining,
nom::error::ErrorKind::Verify,
)));
}
} else {
first_code = Some(code);
}
if rest.is_empty() {
return Err(nom::Err::Incomplete(nom::Needed::Unknown));
}
let separator = rest[0];
let is_continuation = separator == b'-';
let has_space = separator == b' ';
if !is_continuation && !has_space && separator != b'\r' {
return Err(nom::Err::Error(nom::error::Error::new(
rest,
nom::error::ErrorKind::Char,
)));
}
let rest = if separator == b'\r' { rest } else { &rest[1..] };
let pre_esc = rest;
let (rest_after_esc, enhanced) = opt(enhanced_status_code_with_trailing_space)(rest)?;
let (rest_after_esc, enhanced) = if enhanced.is_none() {
match enhanced_status_code(rest) {
Ok((remaining, esc)) if remaining.first() == Some(&b'\r') => (remaining, Some(esc)),
_ => (rest_after_esc, None),
}
} else {
(rest_after_esc, enhanced)
};
let text_start = if let Some(ref esc) = enhanced {
let reply_class = code / 100;
if u16::from(esc.class) == reply_class {
if first_enhanced.is_none() {
first_enhanced = Some(*esc);
}
rest_after_esc
} else {
pre_esc
}
} else {
rest_after_esc
};
let (rest, text_bytes) = take_while(|b: u8| b != b'\r' && b != b'\n')(text_start)?;
let (rest, _) = crlf(rest)?;
let text = String::from_utf8_lossy(text_bytes).into_owned();
lines.push(text);
final_code = code;
remaining = rest;
if !is_continuation {
break;
}
}
Ok((
remaining,
SmtpResponse {
code: final_code,
enhanced_code: first_enhanced,
lines,
},
))
}
#[cfg(test)]
pub(crate) fn reply_code(input: &[u8]) -> IResult<&[u8], u16> {
if input.len() < 3 {
return Err(nom::Err::Incomplete(nom::Needed::new(3 - input.len())));
}
let d0 = input[0];
let d1 = input[1];
let d2 = input[2];
if !d0.is_ascii_digit() || !d1.is_ascii_digit() || !d2.is_ascii_digit() {
return Err(nom::Err::Error(nom::error::Error::new(
input,
nom::error::ErrorKind::Digit,
)));
}
if !(b'2'..=b'5').contains(&d0) || !(b'0'..=b'5').contains(&d1) {
return Err(nom::Err::Error(nom::error::Error::new(
input,
nom::error::ErrorKind::Verify,
)));
}
let code = u16::from(d0 - b'0') * 100 + u16::from(d1 - b'0') * 10 + u16::from(d2 - b'0');
Ok((&input[3..], code))
}
macro_rules! define_enhanced_status_code_parser {
($name:ident, $(#[$meta:meta])*, $one_of:path, $tag:path, $take_while1:path) => {
$(#[$meta])*
fn $name(input: &[u8]) -> IResult<&[u8], EnhancedStatusCode> {
let (rest, class_char) = $one_of("245")(input)?;
#[allow(clippy::cast_possible_truncation)]
let class = (class_char as u32 - '0' as u32) as u8;
let (rest, _) = $tag(b".")(rest)?;
let (rest, subject_bytes) = $take_while1(|b: u8| b.is_ascii_digit())(rest)?;
if subject_bytes.len() > 3 {
return Err(nom::Err::Error(nom::error::Error::new(
input,
nom::error::ErrorKind::TooLarge,
)));
}
let subject = parse_digits(subject_bytes);
let (rest, _) = $tag(b".")(rest)?;
let (rest, detail_bytes) = $take_while1(|b: u8| b.is_ascii_digit())(rest)?;
if detail_bytes.len() > 3 {
return Err(nom::Err::Error(nom::error::Error::new(
input,
nom::error::ErrorKind::TooLarge,
)));
}
let detail = parse_digits(detail_bytes);
Ok((
rest,
EnhancedStatusCode {
class,
subject,
detail,
},
))
}
};
}
define_enhanced_status_code_parser!(
enhanced_status_code,
#[cfg(test)],
nom::character::streaming::one_of,
nom::bytes::streaming::tag,
nom::bytes::streaming::take_while1
);
#[cfg(test)]
fn enhanced_status_code_with_trailing_space(input: &[u8]) -> IResult<&[u8], EnhancedStatusCode> {
let (rest, esc) = enhanced_status_code(input)?;
let (rest, _) = tag(b" ")(rest)?;
Ok((rest, esc))
}
fn parse_digits(bytes: &[u8]) -> u16 {
let mut val: u16 = 0;
for &b in bytes {
val = val * 10 + u16::from(b - b'0');
}
val
}
pub(crate) fn parse_ehlo_capabilities(response: &SmtpResponse) -> ServerCapabilities {
let mut caps = ServerCapabilities::default();
for (i, line) in response.lines.iter().enumerate() {
if i == 0 {
caps.greeting_name = match line.find(' ') {
Some(pos) => line[..pos].to_owned(),
None => line.clone(),
};
continue;
}
let (keyword, params) = match line.find(' ') {
Some(pos) => (&line[..pos], Some(line[pos + 1..].trim())),
None => (line.as_str(), None),
};
let keyword_upper = keyword.to_ascii_uppercase();
if keyword_upper.starts_with("AUTH=") && keyword_upper.len() > 5 {
let first_mech = &keyword[5..];
let all_mechs = match params {
Some(p) if !p.is_empty() => format!("{first_mech} {p}"),
_ => first_mech.to_owned(),
};
let mechanisms: Vec<AuthMechanism> = all_mechs
.split_whitespace()
.map(parse_auth_mechanism)
.collect();
caps.extensions.push(SmtpExtension::Auth(mechanisms));
continue;
}
let extension = match keyword_upper.as_str() {
"8BITMIME" => SmtpExtension::EightBitMime,
"PIPELINING" => SmtpExtension::Pipelining,
"SIZE" => {
let size_limit = params
.and_then(|p| if p.is_empty() { None } else { Some(p) })
.and_then(|p| p.parse::<u64>().ok())
.and_then(|n| if n == 0 { None } else { Some(n) });
SmtpExtension::Size(size_limit)
}
"STARTTLS" => SmtpExtension::StartTls,
"AUTH" => {
let mechanisms = params
.map(|p| {
p.split_whitespace()
.map(parse_auth_mechanism)
.collect::<Vec<_>>()
})
.unwrap_or_default();
SmtpExtension::Auth(mechanisms)
}
"CHUNKING" => SmtpExtension::Chunking,
"BINARYMIME" | "BINARY" => SmtpExtension::BinaryMime,
"SMTPUTF8" => SmtpExtension::SmtpUtf8,
"ENHANCEDSTATUSCODES" => SmtpExtension::EnhancedStatusCodes,
"SASL-IR" => SmtpExtension::SaslIr,
"DSN" => SmtpExtension::Dsn,
"REQUIRETLS" => SmtpExtension::RequireTls,
"FUTURERELEASE" => {
let (max_interval, max_datetime) = match params {
Some(p) if !p.is_empty() => {
let mut parts = p.splitn(2, ' ');
let interval = parts.next().and_then(|s| s.parse::<u64>().ok());
let datetime = parts.next().map(str::to_owned);
(interval, datetime)
}
_ => (None, None),
};
SmtpExtension::FutureRelease {
max_interval,
max_datetime,
}
}
"DELIVERBY" => {
let max_seconds = params
.and_then(|p| if p.is_empty() { None } else { Some(p) })
.and_then(|p| p.parse::<u64>().ok());
SmtpExtension::DeliverBy(max_seconds)
}
"MT-PRIORITY" => SmtpExtension::MtPriority,
"VRFY" => SmtpExtension::Vrfy,
"EXPN" => SmtpExtension::Expn,
"NO-SOLICITING" => {
let keyword = params
.and_then(|p| if p.is_empty() { None } else { Some(p) })
.map(str::to_owned);
SmtpExtension::NoSoliciting(keyword)
}
_ => SmtpExtension::Other(line.clone()),
};
caps.extensions.push(extension);
}
caps
}
fn parse_auth_mechanism(name: &str) -> AuthMechanism {
match name.to_ascii_uppercase().as_str() {
"PLAIN" => AuthMechanism::Plain,
"LOGIN" => AuthMechanism::Login,
"OAUTHBEARER" => AuthMechanism::OAuthBearer,
"XOAUTH2" => AuthMechanism::XOAuth2,
_ => AuthMechanism::Other(name.to_owned()),
}
}
pub(crate) fn strip_enhanced_code(text: &str) -> Option<(EnhancedStatusCode, &str)> {
use nom::bytes::complete::tag as tag_complete;
let bytes = text.as_bytes();
match enhanced_status_code_complete(bytes) {
Ok((rest, esc)) => {
match tag_complete::<_, _, nom::error::Error<&[u8]>>(b" ")(rest) {
Ok((after_space, _)) => {
let consumed = bytes.len() - after_space.len();
Some((esc, &text[consumed..]))
}
_ => {
if rest.is_empty() {
Some((esc, ""))
} else {
None
}
}
}
}
_ => None,
}
}
#[cfg(test)]
pub(crate) fn parse_enhanced_code_from_str(s: &str) -> Option<EnhancedStatusCode> {
match enhanced_status_code_complete(s.as_bytes()) {
Ok(([], esc)) => Some(esc),
_ => None,
}
}
define_enhanced_status_code_parser!(
enhanced_status_code_complete,
,
nom::character::complete::one_of,
nom::bytes::complete::tag,
nom::bytes::complete::take_while1
);
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::similar_names)]
mod tests {
use super::*;
#[test]
fn reply_code_250() {
let (rest, code) = reply_code(b"250 OK").unwrap();
assert_eq!(code, 250);
assert_eq!(rest, b" OK");
}
#[test]
fn reply_code_421() {
let (rest, code) = reply_code(b"421 ").unwrap();
assert_eq!(code, 421);
assert_eq!(rest, b" ");
}
#[test]
fn reply_code_550() {
let (_, code) = reply_code(b"550-User").unwrap();
assert_eq!(code, 550);
}
#[test]
fn reply_code_out_of_range_100() {
assert!(reply_code(b"100 ").is_err());
}
#[test]
fn reply_code_out_of_range_600() {
assert!(reply_code(b"600 ").is_err());
}
#[test]
fn reply_code_non_digit() {
assert!(reply_code(b"2x0 ").is_err());
}
#[test]
fn reply_code_too_short() {
let result = reply_code(b"25");
assert!(matches!(result, Err(nom::Err::Incomplete(_))));
}
#[test]
fn enhanced_code_2_1_0() {
let (rest, esc) = enhanced_status_code(b"2.1.0 OK").unwrap();
assert_eq!(esc.class, 2);
assert_eq!(esc.subject, 1);
assert_eq!(esc.detail, 0);
assert_eq!(rest, b" OK");
}
#[test]
fn enhanced_code_4_7_0() {
let (rest, esc) = enhanced_status_code(b"4.7.0 Try again").unwrap();
assert_eq!(esc.class, 4);
assert_eq!(esc.subject, 7);
assert_eq!(esc.detail, 0);
assert_eq!(rest, b" Try again");
}
#[test]
fn enhanced_code_5_1_1() {
let (_, esc) = enhanced_status_code(b"5.1.1 ").unwrap();
assert_eq!(esc.class, 5);
assert_eq!(esc.subject, 1);
assert_eq!(esc.detail, 1);
}
#[test]
fn enhanced_code_multi_digit_subject_detail() {
let (rest, esc) = enhanced_status_code(b"2.123.456 ").unwrap();
assert_eq!(esc.class, 2);
assert_eq!(esc.subject, 123);
assert_eq!(esc.detail, 456);
assert_eq!(rest, b" ");
}
#[test]
fn enhanced_code_invalid_class_3() {
assert!(enhanced_status_code(b"3.1.0 ").is_err());
}
#[test]
fn enhanced_code_invalid_class_1() {
assert!(enhanced_status_code(b"1.0.0 ").is_err());
}
#[test]
fn single_line_250_ok() {
let input = b"250 OK\r\n";
let (rest, resp) = parse_response(input).unwrap();
assert!(rest.is_empty());
assert_eq!(resp.code, 250);
assert_eq!(resp.lines, vec!["OK"]);
assert!(resp.enhanced_code.is_none());
}
#[test]
fn single_line_with_enhanced_code() {
let input = b"250 2.1.0 OK\r\n";
let (rest, resp) = parse_response(input).unwrap();
assert!(rest.is_empty());
assert_eq!(resp.code, 250);
assert_eq!(resp.lines, vec!["OK"]);
let esc = resp.enhanced_code.unwrap();
assert_eq!(esc.class, 2);
assert_eq!(esc.subject, 1);
assert_eq!(esc.detail, 0);
}
#[test]
fn multi_line_ehlo_response() {
let input = b"250-mail.example.com\r\n250-PIPELINING\r\n250 SIZE 10485760\r\n";
let (rest, resp) = parse_response(input).unwrap();
assert!(rest.is_empty());
assert_eq!(resp.code, 250);
assert_eq!(resp.lines.len(), 3);
assert_eq!(resp.lines[0], "mail.example.com");
assert_eq!(resp.lines[1], "PIPELINING");
assert_eq!(resp.lines[2], "SIZE 10485760");
}
#[test]
fn response_4xx_with_enhanced() {
let input = b"421 4.7.0 Try again later\r\n";
let (rest, resp) = parse_response(input).unwrap();
assert!(rest.is_empty());
assert_eq!(resp.code, 421);
assert!(resp.is_transient_error());
assert_eq!(resp.lines, vec!["Try again later"]);
let esc = resp.enhanced_code.unwrap();
assert_eq!(esc.class, 4);
assert_eq!(esc.subject, 7);
assert_eq!(esc.detail, 0);
}
#[test]
fn response_5xx_with_enhanced() {
let input = b"550 5.1.1 User unknown\r\n";
let (rest, resp) = parse_response(input).unwrap();
assert!(rest.is_empty());
assert_eq!(resp.code, 550);
assert!(resp.is_permanent_error());
assert_eq!(resp.lines, vec!["User unknown"]);
let esc = resp.enhanced_code.unwrap();
assert_eq!(esc.class, 5);
assert_eq!(esc.subject, 1);
assert_eq!(esc.detail, 1);
}
#[test]
fn truncated_no_crlf() {
let input = b"250 OK";
let result = parse_response(input);
assert!(matches!(result, Err(nom::Err::Incomplete(_))));
}
#[test]
fn truncated_partial_code() {
let input = b"25";
let result = parse_response(input);
assert!(matches!(result, Err(nom::Err::Incomplete(_))));
}
#[test]
fn response_no_text_bare_code() {
let input = b"250\r\n";
let (rest, resp) = parse_response(input).unwrap();
assert!(rest.is_empty());
assert_eq!(resp.code, 250);
assert_eq!(resp.lines, vec![""]);
}
#[test]
fn response_empty_text_after_space() {
let input = b"250 \r\n";
let (rest, resp) = parse_response(input).unwrap();
assert!(rest.is_empty());
assert_eq!(resp.code, 250);
assert_eq!(resp.lines, vec![""]);
}
#[test]
fn multi_line_with_enhanced_on_some_lines() {
let input = b"250-2.1.0 Sender OK\r\n250 Recipient OK\r\n";
let (rest, resp) = parse_response(input).unwrap();
assert!(rest.is_empty());
assert_eq!(resp.code, 250);
assert_eq!(resp.lines.len(), 2);
assert_eq!(resp.lines[0], "Sender OK");
assert_eq!(resp.lines[1], "Recipient OK");
let esc = resp.enhanced_code.unwrap();
assert_eq!(esc.class, 2);
assert_eq!(esc.subject, 1);
assert_eq!(esc.detail, 0);
}
#[test]
fn multi_line_enhanced_only_on_later_line() {
let input = b"250-Hello there\r\n250 2.0.0 OK\r\n";
let (rest, resp) = parse_response(input).unwrap();
assert!(rest.is_empty());
assert_eq!(resp.code, 250);
assert_eq!(resp.lines, vec!["Hello there", "OK"]);
let esc = resp.enhanced_code.unwrap();
assert_eq!(esc.class, 2);
assert_eq!(esc.subject, 0);
assert_eq!(esc.detail, 0);
}
#[test]
fn continuation_lines_with_different_codes_must_fail() {
let input = b"251-Forwarding\r\n250 OK\r\n";
let result = parse_response(input);
assert!(
result.is_err(),
"inconsistent reply codes in multi-line response must be rejected (RFC 5321 Section 4.2)"
);
}
#[test]
fn response_354_intermediate() {
let input = b"354 Start mail input\r\n";
let (rest, resp) = parse_response(input).unwrap();
assert!(rest.is_empty());
assert_eq!(resp.code, 354);
assert!(resp.is_intermediate());
assert_eq!(resp.lines, vec!["Start mail input"]);
}
#[test]
fn response_with_remaining_data() {
let input = b"250 OK\r\n220 Ready\r\n";
let (rest, resp) = parse_response(input).unwrap();
assert_eq!(rest, b"220 Ready\r\n");
assert_eq!(resp.code, 250);
}
#[test]
fn response_non_ascii_text() {
let mut input = Vec::new();
input.extend_from_slice(b"250 Hello \xC0\xC1\r\n");
let (rest, resp) = parse_response(&input).unwrap();
assert!(rest.is_empty());
assert_eq!(resp.code, 250);
assert!(resp.lines[0].contains('\u{FFFD}'));
}
#[test]
fn ehlo_full_capabilities() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec![
"mail.example.com".into(),
"8BITMIME".into(),
"PIPELINING".into(),
"SIZE 10485760".into(),
"STARTTLS".into(),
"AUTH PLAIN XOAUTH2".into(),
"CHUNKING".into(),
"BINARYMIME".into(),
"SMTPUTF8".into(),
"ENHANCEDSTATUSCODES".into(),
],
};
let caps = parse_ehlo_capabilities(&response);
assert_eq!(caps.greeting_name, "mail.example.com");
assert_eq!(caps.extensions.len(), 9);
assert!(caps.extensions.contains(&SmtpExtension::EightBitMime));
assert!(caps.extensions.contains(&SmtpExtension::Pipelining));
assert!(caps
.extensions
.contains(&SmtpExtension::Size(Some(10_485_760))));
assert!(caps.extensions.contains(&SmtpExtension::StartTls));
assert!(caps.extensions.contains(&SmtpExtension::Chunking));
assert!(caps.extensions.contains(&SmtpExtension::BinaryMime));
assert!(caps.extensions.contains(&SmtpExtension::SmtpUtf8));
assert!(caps
.extensions
.contains(&SmtpExtension::EnhancedStatusCodes));
assert!(caps.supports_auth(&AuthMechanism::Plain));
assert!(caps.supports_auth(&AuthMechanism::XOAuth2));
}
#[test]
fn ehlo_size_without_limit() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec!["mail.example.com".into(), "SIZE".into()],
};
let caps = parse_ehlo_capabilities(&response);
assert!(caps.extensions.contains(&SmtpExtension::Size(None)));
}
#[test]
fn ehlo_size_zero_means_no_limit() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec!["mail.example.com".into(), "SIZE 0".into()],
};
let caps = parse_ehlo_capabilities(&response);
assert!(
caps.extensions.contains(&SmtpExtension::Size(None)),
"SIZE 0 must be treated as no limit (RFC 1870 Section 5), \
got: {:?}",
caps.extensions
);
assert!(
caps.size_limit().is_none(),
"size_limit() must return None for SIZE 0"
);
}
#[test]
fn ehlo_unknown_extensions() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec![
"mx.example.org".into(),
"XFORWARD".into(),
"PIPELINING".into(),
"XCLIENT".into(),
],
};
let caps = parse_ehlo_capabilities(&response);
assert_eq!(caps.greeting_name, "mx.example.org");
assert_eq!(caps.extensions.len(), 3);
assert!(caps
.extensions
.contains(&SmtpExtension::Other("XFORWARD".into())));
assert!(caps.extensions.contains(&SmtpExtension::Pipelining));
assert!(caps
.extensions
.contains(&SmtpExtension::Other("XCLIENT".into())));
}
#[test]
fn ehlo_auth_multiple_mechanisms() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec![
"mail.example.com".into(),
"AUTH PLAIN LOGIN XOAUTH2 CRAM-MD5".into(),
],
};
let caps = parse_ehlo_capabilities(&response);
let auth_ext = caps
.extensions
.iter()
.find(|e| matches!(e, SmtpExtension::Auth(_)));
assert!(auth_ext.is_some());
if let Some(SmtpExtension::Auth(mechs)) = auth_ext {
assert_eq!(mechs.len(), 4);
assert_eq!(mechs[0], AuthMechanism::Plain);
assert_eq!(mechs[1], AuthMechanism::Login);
assert_eq!(mechs[2], AuthMechanism::XOAuth2);
assert_eq!(mechs[3], AuthMechanism::Other("CRAM-MD5".into()));
}
}
#[test]
fn ehlo_case_insensitive_keywords() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec![
"mail.example.com".into(),
"pipelining".into(),
"Starttls".into(),
"size 1024".into(),
],
};
let caps = parse_ehlo_capabilities(&response);
assert!(caps.extensions.contains(&SmtpExtension::Pipelining));
assert!(caps.extensions.contains(&SmtpExtension::StartTls));
assert!(caps.extensions.contains(&SmtpExtension::Size(Some(1024))));
}
#[test]
fn ehlo_empty_response() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec!["mail.example.com".into()],
};
let caps = parse_ehlo_capabilities(&response);
assert_eq!(caps.greeting_name, "mail.example.com");
assert!(caps.extensions.is_empty());
}
#[test]
fn ehlo_auth_case_insensitive() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec!["mail.example.com".into(), "AUTH plain xoauth2".into()],
};
let caps = parse_ehlo_capabilities(&response);
assert!(caps.supports_auth(&AuthMechanism::Plain));
assert!(caps.supports_auth(&AuthMechanism::XOAuth2));
}
#[test]
fn parse_enhanced_code_from_str_valid() {
let esc = parse_enhanced_code_from_str("2.1.0").unwrap();
assert_eq!(esc.class, 2);
assert_eq!(esc.subject, 1);
assert_eq!(esc.detail, 0);
}
#[test]
fn parse_enhanced_code_from_str_invalid() {
assert!(parse_enhanced_code_from_str("2.1").is_none());
assert!(parse_enhanced_code_from_str("invalid").is_none());
assert!(parse_enhanced_code_from_str("3.0.0").is_none());
}
#[test]
fn enhanced_status_code_streaming_complete_parity() {
let cases: &[&[u8]] = &[b"2.1.0 ", b"4.7.0 ", b"5.1.1 ", b"2.123.456 ", b"5.0.0 "];
for input in cases {
let (s_rest, s_esc) = enhanced_status_code(input).unwrap();
let (c_rest, c_esc) = enhanced_status_code_complete(input).unwrap();
assert_eq!(
s_esc,
c_esc,
"streaming and complete parsers disagree on {:?}",
String::from_utf8_lossy(input)
);
assert_eq!(
s_rest,
c_rest,
"streaming and complete parsers consumed different amounts on {:?}",
String::from_utf8_lossy(input)
);
}
}
#[test]
fn enhanced_status_code_streaming_complete_reject_same() {
let invalid: &[&[u8]] = &[b"3.1.0 ", b"1.0.0 ", b"0.0.0 "];
for input in invalid {
assert!(
enhanced_status_code(input).is_err(),
"streaming should reject {:?}",
String::from_utf8_lossy(input)
);
assert!(
enhanced_status_code_complete(input).is_err(),
"complete should reject {:?}",
String::from_utf8_lossy(input)
);
}
}
#[test]
fn multi_line_ehlo_real_world() {
let input = b"250-smtp.gmail.com at your service\r\n\
250-SIZE 35882577\r\n\
250-8BITMIME\r\n\
250-STARTTLS\r\n\
250-ENHANCEDSTATUSCODES\r\n\
250-PIPELINING\r\n\
250-CHUNKING\r\n\
250 SMTPUTF8\r\n";
let (rest, resp) = parse_response(input).unwrap();
assert!(rest.is_empty());
assert_eq!(resp.code, 250);
assert_eq!(resp.lines.len(), 8);
let caps = parse_ehlo_capabilities(&resp);
assert_eq!(caps.greeting_name, "smtp.gmail.com");
assert!(caps
.extensions
.contains(&SmtpExtension::Size(Some(35_882_577))));
assert!(caps.extensions.contains(&SmtpExtension::EightBitMime));
assert!(caps.extensions.contains(&SmtpExtension::StartTls));
assert!(caps
.extensions
.contains(&SmtpExtension::EnhancedStatusCodes));
assert!(caps.extensions.contains(&SmtpExtension::Pipelining));
assert!(caps.extensions.contains(&SmtpExtension::Chunking));
assert!(caps.extensions.contains(&SmtpExtension::SmtpUtf8));
}
#[test]
fn multi_line_all_with_enhanced_codes() {
let input = b"550-5.1.1 The email account does not exist\r\n\
550 5.1.1 Try again\r\n";
let (rest, resp) = parse_response(input).unwrap();
assert!(rest.is_empty());
assert_eq!(resp.code, 550);
assert_eq!(resp.lines.len(), 2);
assert_eq!(resp.lines[0], "The email account does not exist");
assert_eq!(resp.lines[1], "Try again");
let esc = resp.enhanced_code.unwrap();
assert_eq!(esc.class, 5);
assert_eq!(esc.subject, 1);
assert_eq!(esc.detail, 1);
}
#[test]
fn response_220_greeting() {
let input = b"220 mail.example.com ESMTP ready\r\n";
let (rest, resp) = parse_response(input).unwrap();
assert!(rest.is_empty());
assert_eq!(resp.code, 220);
assert_eq!(resp.lines, vec!["mail.example.com ESMTP ready"]);
}
#[test]
fn multi_line_continuation_truncated() {
let input = b"250-line1\r\n250-line2\r\n";
let result = parse_response(input);
assert!(matches!(result, Err(nom::Err::Incomplete(_))));
}
#[test]
fn enhanced_code_class_mismatch_discarded() {
let input = b"250 5.1.1 Sender OK\r\n";
let (rest, resp) = parse_response(input).unwrap();
assert!(rest.is_empty());
assert_eq!(resp.code, 250);
assert!(
resp.enhanced_code.is_none(),
"enhanced code with mismatched class must be discarded (RFC 2034 Section 4)"
);
assert_eq!(resp.lines, vec!["5.1.1 Sender OK"]);
}
#[test]
fn enhanced_code_class_match_preserved() {
let input = b"250 2.1.0 Sender OK\r\n";
let (_, resp) = parse_response(input).unwrap();
assert_eq!(resp.code, 250);
let esc = resp.enhanced_code.unwrap();
assert_eq!(esc.class, 2);
}
#[test]
fn enhanced_code_extracted_without_trailing_text() {
let input = b"550 5.1.1\r\n";
let (rest, resp) = parse_response(input).unwrap();
assert!(rest.is_empty());
assert_eq!(resp.code, 550);
let esc = resp.enhanced_code.unwrap();
assert_eq!(esc.class, 5);
assert_eq!(esc.subject, 1);
assert_eq!(esc.detail, 1);
assert_eq!(resp.lines, vec![""]);
}
#[test]
fn enhanced_code_extracted_without_trailing_text_250() {
let input = b"250 2.0.0\r\n";
let (rest, resp) = parse_response(input).unwrap();
assert!(rest.is_empty());
assert_eq!(resp.code, 250);
let esc = resp.enhanced_code.unwrap();
assert_eq!(esc.class, 2);
assert_eq!(esc.subject, 0);
assert_eq!(esc.detail, 0);
assert_eq!(resp.lines, vec![""]);
}
#[test]
fn ehlo_auth_equals_form() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec!["legacy.server.com".into(), "AUTH=PLAIN LOGIN".into()],
};
let caps = parse_ehlo_capabilities(&response);
assert!(
caps.supports_auth(&AuthMechanism::Plain),
"AUTH=PLAIN form must be recognized (RFC 2554 Section 3)"
);
assert!(
caps.supports_auth(&AuthMechanism::Login),
"AUTH=PLAIN LOGIN must include LOGIN mechanism"
);
}
#[test]
fn ehlo_auth_equals_single_mechanism() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec!["legacy.server.com".into(), "AUTH=PLAIN".into()],
};
let caps = parse_ehlo_capabilities(&response);
assert!(
caps.supports_auth(&AuthMechanism::Plain),
"AUTH=PLAIN (single mechanism) must be recognized"
);
}
#[test]
fn ehlo_auth_equals_mixed_case() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec!["legacy.server.com".into(), "auth=plain login".into()],
};
let caps = parse_ehlo_capabilities(&response);
assert!(
caps.supports_auth(&AuthMechanism::Plain),
"auth=plain (lowercase) must be recognized"
);
}
#[test]
fn strip_enhanced_code_entire_text() {
let result = strip_enhanced_code("5.1.1");
assert!(result.is_some(), "expected Some for bare enhanced code");
let (esc, rest) = result.unwrap();
assert_eq!(esc.class, 5);
assert_eq!(esc.subject, 1);
assert_eq!(esc.detail, 1);
assert_eq!(rest, "");
}
#[test]
fn ehlo_greeting_name_is_domain_only() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec!["smtp.gmail.com at your service".into(), "PIPELINING".into()],
};
let caps = parse_ehlo_capabilities(&response);
assert_eq!(
caps.greeting_name, "smtp.gmail.com",
"RFC 5321 Section 4.1.1.1: greeting_name must be the Domain \
only, not the full ehlo-greet text"
);
}
#[test]
fn ehlo_greeting_name_domain_only_no_greet() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec!["mail.example.com".into(), "PIPELINING".into()],
};
let caps = parse_ehlo_capabilities(&response);
assert_eq!(caps.greeting_name, "mail.example.com");
}
#[test]
fn ehlo_recognizes_legacy_binary_keyword() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec![
"legacy.server.com".into(),
"BINARY".into(),
"CHUNKING".into(),
],
};
let caps = parse_ehlo_capabilities(&response);
assert!(
caps.supports_binarymime(),
"RFC 1830: legacy 'BINARY' keyword must be recognized \
as BINARYMIME (RFC 3030 obsoletes RFC 1830 but the old \
keyword may still appear in the wild)"
);
}
#[test]
fn ehlo_recognizes_legacy_binary_keyword_lowercase() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec!["legacy.server.com".into(), "binary".into()],
};
let caps = parse_ehlo_capabilities(&response);
assert!(
caps.supports_binarymime(),
"RFC 1830 / RFC 5321 Section 2.4: lowercase 'binary' must \
be recognized as BINARYMIME"
);
}
#[test]
fn ehlo_parses_login_as_dedicated_variant() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec!["mail.example.com".into(), "AUTH PLAIN LOGIN".into()],
};
let caps = parse_ehlo_capabilities(&response);
assert!(caps.supports_auth(&AuthMechanism::Login));
assert!(caps.supports_auth(&AuthMechanism::Plain));
}
#[test]
fn ehlo_parses_login_case_insensitive() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec!["mail.example.com".into(), "AUTH login".into()],
};
let caps = parse_ehlo_capabilities(&response);
assert!(
caps.supports_auth(&AuthMechanism::Login),
"lowercase 'login' must be parsed as AuthMechanism::Login"
);
}
#[test]
fn ehlo_auth_equals_form_with_login() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec!["legacy.server.com".into(), "AUTH=LOGIN PLAIN".into()],
};
let caps = parse_ehlo_capabilities(&response);
assert!(
caps.supports_auth(&AuthMechanism::Login),
"AUTH=LOGIN form must parse LOGIN as dedicated variant"
);
assert!(caps.supports_auth(&AuthMechanism::Plain));
}
#[test]
fn ehlo_parses_dsn_extension() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec!["mail.example.com".into(), "DSN".into(), "PIPELINING".into()],
};
let caps = parse_ehlo_capabilities(&response);
assert!(caps.extensions.contains(&SmtpExtension::Dsn));
assert!(caps.supports_dsn());
}
#[test]
fn ehlo_parses_dsn_case_insensitive() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec!["mail.example.com".into(), "dsn".into()],
};
let caps = parse_ehlo_capabilities(&response);
assert!(
caps.supports_dsn(),
"lowercase 'dsn' must be recognized as DSN extension"
);
}
#[test]
fn ehlo_parses_requiretls_extension() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec![
"mail.example.com".into(),
"REQUIRETLS".into(),
"STARTTLS".into(),
],
};
let caps = parse_ehlo_capabilities(&response);
assert!(caps.extensions.contains(&SmtpExtension::RequireTls));
assert!(caps.supports_requiretls());
}
#[test]
fn ehlo_parses_requiretls_case_insensitive() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec!["mail.example.com".into(), "requiretls".into()],
};
let caps = parse_ehlo_capabilities(&response);
assert!(
caps.supports_requiretls(),
"lowercase 'requiretls' must be recognized"
);
}
#[test]
fn ehlo_parses_oauthbearer_as_dedicated_variant() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec![
"mail.example.com".into(),
"AUTH PLAIN OAUTHBEARER XOAUTH2".into(),
],
};
let caps = parse_ehlo_capabilities(&response);
assert!(caps.supports_auth(&AuthMechanism::OAuthBearer));
assert!(caps.supports_auth(&AuthMechanism::Plain));
assert!(caps.supports_auth(&AuthMechanism::XOAuth2));
}
#[test]
fn ehlo_parses_oauthbearer_case_insensitive() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec!["mail.example.com".into(), "AUTH oauthbearer".into()],
};
let caps = parse_ehlo_capabilities(&response);
assert!(
caps.supports_auth(&AuthMechanism::OAuthBearer),
"lowercase 'oauthbearer' must be parsed as AuthMechanism::OAuthBearer"
);
}
#[test]
fn ehlo_parses_futurerelease() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec![
"mail.example.com".into(),
"FUTURERELEASE 86400 2024-12-31T23:59:59Z".into(),
],
};
let caps = parse_ehlo_capabilities(&response);
assert!(caps.supports_future_release());
}
#[test]
fn ehlo_parses_futurerelease_no_params() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec!["mail.example.com".into(), "FUTURERELEASE".into()],
};
let caps = parse_ehlo_capabilities(&response);
assert!(caps.supports_future_release());
}
#[test]
fn ehlo_parses_deliverby_with_max() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec!["mail.example.com".into(), "DELIVERBY 240".into()],
};
let caps = parse_ehlo_capabilities(&response);
assert!(caps.supports_deliver_by());
}
#[test]
fn ehlo_parses_deliverby_no_max() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec!["mail.example.com".into(), "DELIVERBY".into()],
};
let caps = parse_ehlo_capabilities(&response);
assert!(caps.supports_deliver_by());
}
#[test]
fn ehlo_parses_mt_priority() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec!["mail.example.com".into(), "MT-PRIORITY".into()],
};
let caps = parse_ehlo_capabilities(&response);
assert!(caps.supports_mt_priority());
}
#[test]
fn ehlo_parses_vrfy() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec!["mail.example.com".into(), "VRFY".into()],
};
let caps = parse_ehlo_capabilities(&response);
assert!(caps.supports_vrfy());
}
#[test]
fn ehlo_parses_expn() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec!["mail.example.com".into(), "EXPN".into()],
};
let caps = parse_ehlo_capabilities(&response);
assert!(caps.supports_expn());
}
#[test]
fn parse_response_incomplete_after_code() {
let input = b"250";
let result = parse_response(input);
assert!(
matches!(result, Err(nom::Err::Incomplete(_))),
"response with code but no separator must be Incomplete (RFC 5321 Section 4.2)"
);
}
#[test]
fn reply_code_non_digit_first() {
assert!(reply_code(b"X50 OK").is_err());
}
#[test]
fn reply_code_non_digit_third() {
assert!(reply_code(b"25X OK").is_err());
}
#[test]
fn parse_response_invalid_separator_byte() {
let input = b"250!OK\r\n";
let result = parse_response(input);
assert!(
result.is_err(),
"invalid separator byte must be rejected (RFC 5321 Section 4.2)"
);
}
#[test]
fn enhanced_code_subject_too_long() {
let input = b"2.1234.0 ";
let result = enhanced_status_code(input);
assert!(
result.is_err(),
"enhanced code with >3 digit subject must be rejected (RFC 1893 Section 2)"
);
}
#[test]
fn enhanced_code_detail_too_long() {
let input = b"2.1.1234 ";
let result = enhanced_status_code(input);
assert!(
result.is_err(),
"enhanced code with >3 digit detail must be rejected (RFC 1893 Section 2)"
);
}
#[test]
fn enhanced_code_complete_subject_too_long() {
let input = b"2.1234.0";
let result = enhanced_status_code_complete(input);
assert!(
result.is_err(),
"complete parser: enhanced code with >3 digit subject must be rejected"
);
}
#[test]
fn enhanced_code_complete_detail_too_long() {
let input = b"2.1.1234";
let result = enhanced_status_code_complete(input);
assert!(
result.is_err(),
"complete parser: enhanced code with >3 digit detail must be rejected"
);
}
#[test]
fn ehlo_parses_nosoliciting_without_keyword() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec!["mail.example.com".into(), "NO-SOLICITING".into()],
};
let caps = parse_ehlo_capabilities(&response);
assert!(
caps.extensions.contains(&SmtpExtension::NoSoliciting(None)),
"NO-SOLICITING without keyword must parse as NoSoliciting(None) (RFC 3865)"
);
}
#[test]
fn ehlo_parses_nosoliciting_with_keyword() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec![
"mail.example.com".into(),
"NO-SOLICITING org.example.adv".into(),
],
};
let caps = parse_ehlo_capabilities(&response);
assert!(
caps.extensions
.contains(&SmtpExtension::NoSoliciting(Some("org.example.adv".into()))),
"NO-SOLICITING with keyword must parse as NoSoliciting(Some(...)) (RFC 3865)"
);
}
#[test]
fn ehlo_parses_nosoliciting_case_insensitive() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec!["mail.example.com".into(), "no-soliciting".into()],
};
let caps = parse_ehlo_capabilities(&response);
let has_nosoliciting = caps
.extensions
.iter()
.any(|e| matches!(e, SmtpExtension::NoSoliciting(_)));
assert!(
has_nosoliciting,
"lowercase 'no-soliciting' must be recognized (RFC 5321 Section 2.4)"
);
}
#[test]
fn ehlo_pipelining_has_no_parameter() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec!["mail.example.com".into(), "PIPELINING".into()],
};
let caps = parse_ehlo_capabilities(&response);
assert!(
caps.extensions.contains(&SmtpExtension::Pipelining),
"PIPELINING must parse to the dedicated variant, not Other"
);
}
#[test]
fn multi_line_continuation_incomplete_at_code() {
let input = b"250-Hello\r\n25";
let result = parse_response(input);
assert!(
matches!(result, Err(nom::Err::Incomplete(_))),
"partial code after continuation must be Incomplete"
);
}
#[test]
fn response_non_utf8_replacement_in_error_text() {
let mut input = Vec::new();
input.extend_from_slice(b"550 5.1.1 ");
input.push(0xFF); input.push(0xFE); input.extend_from_slice(b" rejected\r\n");
let (rest, resp) = parse_response(&input).unwrap();
assert!(rest.is_empty());
assert_eq!(resp.code, 550);
assert!(
resp.lines[0].contains('\u{FFFD}'),
"non-UTF-8 bytes must be replaced with U+FFFD (Postel's law)"
);
assert!(resp.lines[0].contains("rejected"));
}
#[test]
fn multi_line_response_non_utf8_on_continuation() {
let mut input = Vec::new();
input.extend_from_slice(b"250-Hello\r\n250 ");
input.push(0x80); input.push(0x81);
input.extend_from_slice(b"\r\n");
let (rest, resp) = parse_response(&input).unwrap();
assert!(rest.is_empty());
assert_eq!(resp.code, 250);
assert_eq!(resp.lines.len(), 2);
assert!(
resp.lines[1].contains('\u{FFFD}'),
"non-UTF-8 on continuation line must use lossy conversion"
);
}
#[test]
fn response_entirely_non_utf8_text() {
let mut input = Vec::new();
input.extend_from_slice(b"421 ");
input.extend_from_slice(&[0xFF, 0xFE, 0xFD, 0xFC]);
input.extend_from_slice(b"\r\n");
let (rest, resp) = parse_response(&input).unwrap();
assert!(rest.is_empty());
assert_eq!(resp.code, 421);
assert!(resp.lines[0].contains('\u{FFFD}'));
}
#[test]
fn ehlo_parses_sasl_ir() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec![
"mail.example.com".into(),
"AUTH PLAIN".into(),
"SASL-IR".into(),
],
};
let caps = parse_ehlo_capabilities(&response);
assert!(
caps.extensions.contains(&SmtpExtension::SaslIr),
"SASL-IR must be recognized (RFC 4959)"
);
assert!(caps.supports_sasl_ir());
}
#[test]
fn strip_enhanced_code_with_text() {
let result = strip_enhanced_code("2.1.0 Sender OK");
assert!(result.is_some());
let (esc, rest) = result.unwrap();
assert_eq!(esc.class, 2);
assert_eq!(esc.subject, 1);
assert_eq!(esc.detail, 0);
assert_eq!(rest, "Sender OK");
}
#[test]
fn strip_enhanced_code_not_a_code() {
assert!(strip_enhanced_code("Hello world").is_none());
}
#[test]
fn strip_enhanced_code_partial_code_with_trailing_text() {
assert!(strip_enhanced_code("2.1 text").is_none());
}
#[test]
fn strip_enhanced_code_followed_by_non_space() {
assert!(strip_enhanced_code("2.1.0text").is_none());
}
}