#![allow(clippy::unwrap_used, clippy::similar_names)]
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_accepts_second_digit_6_through_9() {
let (rest, code) = reply_code(b"268 OK").unwrap();
assert_eq!(code, 268);
assert_eq!(rest, b" OK");
let (rest, code) = reply_code(b"569 Error").unwrap();
assert_eq!(code, 569);
assert_eq!(rest, b" Error");
let (_, code) = reply_code(b"297 ").unwrap();
assert_eq!(code, 297);
let (_, code) = reply_code(b"480 ").unwrap();
assert_eq!(code, 480);
}
#[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_auth_and_size_with_tab_separator() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec![
"mail.example.com".into(),
"AUTH\tPLAIN LOGIN".into(),
"SIZE\t10485760".into(),
],
};
let caps = parse_ehlo_capabilities(&response);
assert!(
caps.supports_auth(&AuthMechanism::Plain),
"AUTH advertised with HTAB separator should still parse PLAIN"
);
assert!(
caps.supports_auth(&AuthMechanism::Login),
"AUTH advertised with HTAB separator should still parse LOGIN"
);
assert_eq!(
caps.size_limit(),
Some(10_485_760),
"SIZE advertised with HTAB separator should still parse its numeric limit"
);
}
#[test]
fn ehlo_multiline_missing_greeting_domain_preserves_first_line_as_greeting() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec![
"PIPELINING".into(),
"SIZE 10485760".into(),
"STARTTLS".into(),
],
};
let caps = parse_ehlo_capabilities(&response);
assert_eq!(
caps.greeting_name, "PIPELINING",
"the first line of a multi-line EHLO reply must remain the greeting token"
);
assert!(
!caps.supports_pipelining(),
"the ambiguous first line must not be reinterpreted as an extension"
);
assert!(
caps.supports_starttls(),
"subsequent EHLO lines must still be parsed normally"
);
assert_eq!(
caps.size_limit(),
Some(10_485_760),
"later EHLO extension lines must still be recognized"
);
}
#[test]
fn ehlo_single_line_invalid_greeting_token_recovers_capability() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec!["AUTH=PLAIN LOGIN".into()],
};
let caps = parse_ehlo_capabilities(&response);
assert_eq!(
caps.greeting_name, "",
"a single-line EHLO reply with an impossible domain token should be recovered as an extension"
);
assert!(
caps.supports_auth(&AuthMechanism::Plain) && caps.supports_auth(&AuthMechanism::Login),
"single-line malformed EHLO should still recover AUTH= when the greeting token cannot be a domain"
);
}
#[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_with_invalid_limit_is_not_treated_as_no_limit() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec!["mail.example.com".into(), "SIZE nope".into()],
};
let caps = parse_ehlo_capabilities(&response);
assert!(
!caps.supports_size(),
"malformed SIZE argument must not be exposed as SIZE support: {:?}",
caps.extensions
);
assert!(
caps.extensions
.contains(&SmtpExtension::Other("SIZE nope".into())),
"malformed SIZE line should be preserved as Other(...) for caller inspection: {:?}",
caps.extensions
);
}
#[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_size_with_leading_plus_is_unknown() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec!["mail.example.com".into(), "SIZE +123".into()],
};
let caps = parse_ehlo_capabilities(&response);
assert!(
!caps.supports_size(),
"SIZE +123 must not be exposed as a valid SIZE capability: {:?}",
caps.extensions
);
assert!(
caps.extensions
.contains(&SmtpExtension::Other("SIZE +123".into())),
"invalid SIZE line must be preserved as Other(...): {:?}",
caps.extensions
);
}
#[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 ehlo_auth_rejects_invalid_mechanism_token() {
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_extension(),
"malformed AUTH mechanism token must not advertise AUTH support"
);
assert!(
caps.extensions
.contains(&SmtpExtension::Other("AUTH PLAIN/LOGIN".into())),
"malformed AUTH line must be preserved as Other(...)"
);
}
#[test]
fn ehlo_auth_equals_rejects_invalid_first_mechanism() {
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_extension(),
"malformed AUTH= first mechanism must not advertise AUTH support"
);
assert!(
caps.extensions
.contains(&SmtpExtension::Other("AUTH==PLAIN LOGIN".into())),
"malformed AUTH= line must be preserved as Other(...)"
);
}
#[test]
fn strip_enhanced_code_entire_text() {
let result = strip_enhanced_code("5.1.1", 550);
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_multiline_greeting_domain_named_like_extension_is_not_reparsed() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec!["SIZE".into(), "PIPELINING".into()],
};
let caps = parse_ehlo_capabilities(&response);
assert_eq!(caps.greeting_name, "SIZE");
assert!(
!caps
.extensions
.iter()
.any(|ext| matches!(ext, SmtpExtension::Size(_))),
"the first EHLO line in a multi-line response must remain the greeting domain"
);
assert!(
caps.supports_pipelining(),
"subsequent EHLO lines must still be parsed as extensions"
);
}
#[test]
fn ehlo_single_line_greeting_domain_named_like_extension_is_not_reparsed() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec!["SIZE".into()],
};
let caps = parse_ehlo_capabilities(&response);
assert_eq!(caps.greeting_name, "SIZE");
assert!(
!caps
.extensions
.iter()
.any(|ext| matches!(ext, SmtpExtension::Size(_))),
"a valid single-line EHLO greeting domain must not be reparsed as the SIZE extension"
);
}
#[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_rejects_futurerelease_missing_datetime() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec!["mail.example.com".into(), "FUTURERELEASE 86400".into()],
};
let caps = parse_ehlo_capabilities(&response);
assert!(
!caps.supports_future_release(),
"RFC 4865 Section 3: FUTURERELEASE must not be accepted when the \
required max datetime is missing"
);
assert!(
caps.extensions
.contains(&SmtpExtension::Other("FUTURERELEASE 86400".into())),
"malformed FUTURERELEASE line should be preserved as Other(...) \
for caller inspection: {:?}",
caps.extensions
);
}
#[test]
fn ehlo_rejects_futurerelease_malformed_datetime() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec![
"mail.example.com".into(),
"FUTURERELEASE 86400 not-a-datetime".into(),
],
};
let caps = parse_ehlo_capabilities(&response);
assert!(
!caps.supports_future_release(),
"RFC 4865 Section 3: FUTURERELEASE must not be accepted when the \
advertised max datetime is malformed"
);
assert!(
caps.extensions.contains(&SmtpExtension::Other(
"FUTURERELEASE 86400 not-a-datetime".into()
)),
"malformed FUTURERELEASE line should be preserved as Other(...) \
for caller inspection: {:?}",
caps.extensions
);
}
#[test]
fn ehlo_rejects_futurerelease_without_required_limits() {
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(),
"RFC 4865 Section 3: FUTURERELEASE without the required interval \
and datetime parameters must not advertise extension support"
);
assert!(
caps.extensions
.contains(&SmtpExtension::Other("FUTURERELEASE".into())),
"malformed FUTURERELEASE line should be preserved as Other(...) \
for caller inspection: {:?}",
caps.extensions
);
}
#[test]
fn ehlo_parses_futurerelease_with_ascii_whitespace_params() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec![
"mail.example.com".into(),
"FUTURERELEASE 86400\t 2024-12-31T23:59:59Z".into(),
],
};
let caps = parse_ehlo_capabilities(&response);
assert_eq!(caps.future_release_max_interval(), Some(86400));
assert_eq!(
caps.future_release_max_datetime(),
Some("2024-12-31T23:59:59Z")
);
}
#[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_deliverby_min_with_extension_tokens() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec!["mail.example.com".into(), "DELIVERBY 240,TRACE".into()],
};
let caps = parse_ehlo_capabilities(&response);
assert!(caps.supports_deliver_by());
assert_eq!(
caps.deliver_by_min(),
Some(240),
"RFC 2852 Section 3: DELIVERBY minimum must survive extension tokens"
);
}
#[test]
fn ehlo_rejects_deliverby_extension_token_with_space() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec!["mail.example.com".into(), "DELIVERBY 240,TRACE NEXT".into()],
};
let caps = parse_ehlo_capabilities(&response);
assert!(
!caps.supports_deliver_by(),
"RFC 2852 Sections 2-3: DELIVERBY must not be accepted when an \
extension-token contains SP"
);
assert!(
caps.extensions
.contains(&SmtpExtension::Other("DELIVERBY 240,TRACE NEXT".into())),
"malformed DELIVERBY line should be preserved as Other(...) for \
caller inspection: {:?}",
caps.extensions
);
}
#[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 ehlo_vrfy_and_expn_with_stray_params_are_not_advertised() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec![
"mail.example.com".into(),
"VRFY bogus".into(),
"EXPN bogus".into(),
],
};
let caps = parse_ehlo_capabilities(&response);
assert!(
caps.extensions
.contains(&SmtpExtension::Other("VRFY bogus".into())),
"malformed VRFY EHLO line must be preserved as Other(...): {caps:?}"
);
assert!(
caps.extensions
.contains(&SmtpExtension::Other("EXPN bogus".into())),
"malformed EXPN EHLO line must be preserved as Other(...): {caps:?}"
);
assert!(
!caps.supports_vrfy() && !caps.supports_expn(),
"VRFY/EXPN with stray EHLO parameters must not be advertised: {caps:?}"
);
}
#[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 explicit_no_parameter_keywords_with_stray_params_are_not_advertised() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec![
"mail.example.com".into(),
"8BITMIME bogus".into(),
"PIPELINING bogus".into(),
"ENHANCEDSTATUSCODES bogus".into(),
"STARTTLS bogus".into(),
"BINARYMIME bogus".into(),
],
};
let caps = parse_ehlo_capabilities(&response);
for raw in [
"8BITMIME bogus",
"PIPELINING bogus",
"ENHANCEDSTATUSCODES bogus",
"STARTTLS bogus",
"BINARYMIME bogus",
] {
assert!(
caps.extensions
.contains(&SmtpExtension::Other(raw.to_string())),
"malformed EHLO line must be preserved as Other(...): {raw}; got {caps:?}"
);
}
assert!(
!caps.extensions.contains(&SmtpExtension::EightBitMime)
&& !caps.extensions.contains(&SmtpExtension::Pipelining)
&& !caps
.extensions
.contains(&SmtpExtension::EnhancedStatusCodes)
&& !caps.extensions.contains(&SmtpExtension::StartTls)
&& !caps.extensions.contains(&SmtpExtension::BinaryMime),
"parameterless extensions must not be advertised from malformed EHLO lines: {caps:?}"
);
}
#[test]
fn smtputf8_ignores_nonstandard_ehlo_params() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec!["mail.example.com".into(), "SMTPUTF8 bogus".into()],
};
let caps = parse_ehlo_capabilities(&response);
assert!(
caps.extensions.contains(&SmtpExtension::SmtpUtf8),
"SMTPUTF8 must still be recognized when a server adds stray EHLO parameters"
);
assert!(
!caps
.extensions
.contains(&SmtpExtension::Other("SMTPUTF8 bogus".into())),
"RFC 6531 Section 3.2 requires clients to ignore SMTPUTF8 EHLO parameters"
);
}
#[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),
"legacy SASL-IR keyword must be recognized for compatibility"
);
assert!(caps.supports_sasl_ir());
}
#[test]
fn strip_enhanced_code_with_text() {
let result = strip_enhanced_code("2.1.0 Sender OK", 250);
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", 250).is_none());
}
#[test]
fn strip_enhanced_code_partial_code_with_trailing_text() {
assert!(strip_enhanced_code("2.1 text", 250).is_none());
}
#[test]
fn strip_enhanced_code_followed_by_non_space() {
assert!(strip_enhanced_code("2.1.0text", 250).is_none());
}
#[test]
fn strip_enhanced_code_class_mismatch_returns_none() {
let result = strip_enhanced_code("5.1.1 Sender OK", 250);
assert!(
result.is_none(),
"enhanced code with mismatched class must return None \
(RFC 2034 Section 4)"
);
}
#[test]
fn strip_enhanced_code_class_match_succeeds() {
let result = strip_enhanced_code("5.1.1 User unknown", 550);
assert!(
result.is_some(),
"enhanced code with matching class must return Some"
);
let (esc, rest) = result.unwrap();
assert_eq!(esc.class, 5);
assert_eq!(rest, "User unknown");
}
#[test]
fn strip_enhanced_code_class_4_match() {
let result = strip_enhanced_code("4.7.0 Temporary auth failure", 454);
assert!(result.is_some());
let (esc, rest) = result.unwrap();
assert_eq!(esc.class, 4);
assert_eq!(esc.subject, 7);
assert_eq!(esc.detail, 0);
assert_eq!(rest, "Temporary auth failure");
}
#[test]
fn ehlo_auth_without_mechanisms_is_skipped() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec![
"mail.example.com".into(),
"AUTH".into(),
"PIPELINING".into(),
],
};
let caps = parse_ehlo_capabilities(&response);
assert!(
!caps
.extensions
.iter()
.any(|e| matches!(e, SmtpExtension::Auth(_))),
"AUTH with no mechanisms must not be advertised (RFC 4954 Section 3)"
);
}
#[test]
fn ehlo_duplicate_auth_extensions_are_merged() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec![
"mail.example.com".into(),
"AUTH=PLAIN LOGIN".into(),
"PIPELINING".into(),
"AUTH PLAIN LOGIN XOAUTH2".into(),
],
};
let caps = parse_ehlo_capabilities(&response);
let auth_entries: Vec<_> = caps
.extensions
.iter()
.filter(|e| matches!(e, SmtpExtension::Auth(_)))
.collect();
assert_eq!(
auth_entries.len(),
1,
"duplicate AUTH extensions must be merged into one entry, got {auth_entries:?}"
);
let SmtpExtension::Auth(mechs) = &auth_entries[0] else {
unreachable!()
};
assert_eq!(
mechs.len(),
3,
"merged AUTH must contain PLAIN, LOGIN, XOAUTH2; got {mechs:?}"
);
assert!(mechs.contains(&AuthMechanism::Plain));
assert!(mechs.contains(&AuthMechanism::Login));
assert!(mechs.contains(&AuthMechanism::XOAuth2));
}
#[test]
fn ehlo_duplicate_auth_deprecated_after_standard_merged() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec![
"mail.example.com".into(),
"AUTH PLAIN XOAUTH2".into(),
"AUTH=LOGIN".into(),
],
};
let caps = parse_ehlo_capabilities(&response);
let auth_entries: Vec<_> = caps
.extensions
.iter()
.filter(|e| matches!(e, SmtpExtension::Auth(_)))
.collect();
assert_eq!(
auth_entries.len(),
1,
"AUTH and AUTH= must merge into one entry, got {auth_entries:?}"
);
let SmtpExtension::Auth(mechs) = &auth_entries[0] else {
unreachable!()
};
assert_eq!(
mechs.len(),
3,
"expected PLAIN, XOAUTH2, LOGIN; got {mechs:?}"
);
assert!(mechs.contains(&AuthMechanism::Plain));
assert!(mechs.contains(&AuthMechanism::XOAuth2));
assert!(mechs.contains(&AuthMechanism::Login));
}
#[test]
fn ehlo_duplicate_auth_overlapping_mechanisms_deduped() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec![
"mail.example.com".into(),
"AUTH=PLAIN LOGIN".into(),
"AUTH PLAIN LOGIN".into(),
],
};
let caps = parse_ehlo_capabilities(&response);
let auth_entries: Vec<_> = caps
.extensions
.iter()
.filter(|e| matches!(e, SmtpExtension::Auth(_)))
.collect();
assert_eq!(auth_entries.len(), 1);
let SmtpExtension::Auth(mechs) = &auth_entries[0] else {
unreachable!()
};
assert_eq!(
mechs.len(),
2,
"overlapping PLAIN+LOGIN must not be duplicated; got {mechs:?}"
);
}
#[test]
fn edge_enhanced_status_no_trailing_text_210() {
let input = b"250 2.1.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, 1);
assert_eq!(esc.detail, 0);
assert_eq!(
resp.lines,
vec![""],
"text after enhanced code with no trailing text should be empty"
);
}
#[test]
fn edge_multiline_ehlo_all_capabilities_extracted() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec![
"mail.example.com Hello".into(),
"8BITMIME".into(),
"PIPELINING".into(),
"SIZE 52428800".into(),
"STARTTLS".into(),
"AUTH PLAIN LOGIN XOAUTH2 OAUTHBEARER".into(),
"CHUNKING".into(),
"BINARYMIME".into(),
"SMTPUTF8".into(),
"ENHANCEDSTATUSCODES".into(),
"SASL-IR".into(),
"DSN".into(),
"REQUIRETLS".into(),
"FUTURERELEASE 86400 2025-12-31T23:59:59Z".into(),
"DELIVERBY 240".into(),
"MT-PRIORITY".into(),
"VRFY".into(),
"EXPN".into(),
"NO-SOLICITING org.example.ads".into(),
],
};
let caps = parse_ehlo_capabilities(&response);
assert_eq!(caps.greeting_name, "mail.example.com");
assert!(caps.supports_8bitmime(), "8BITMIME must be parsed");
assert!(caps.supports_pipelining(), "PIPELINING must be parsed");
assert!(caps.supports_size(), "SIZE must be parsed");
assert_eq!(caps.size_limit(), Some(52_428_800));
assert!(caps.supports_starttls(), "STARTTLS must be parsed");
assert!(
caps.supports_auth(&AuthMechanism::Plain),
"AUTH PLAIN must be parsed"
);
assert!(
caps.supports_auth(&AuthMechanism::Login),
"AUTH LOGIN must be parsed"
);
assert!(
caps.supports_auth(&AuthMechanism::XOAuth2),
"AUTH XOAUTH2 must be parsed"
);
assert!(
caps.supports_auth(&AuthMechanism::OAuthBearer),
"AUTH OAUTHBEARER must be parsed"
);
assert!(caps.supports_chunking(), "CHUNKING must be parsed");
assert!(caps.supports_binarymime(), "BINARYMIME must be parsed");
assert!(caps.supports_smtputf8(), "SMTPUTF8 must be parsed");
assert!(
caps.supports_enhanced_status_codes(),
"ENHANCEDSTATUSCODES must be parsed"
);
assert!(caps.supports_sasl_ir(), "SASL-IR must be parsed");
assert!(caps.supports_dsn(), "DSN must be parsed");
assert!(caps.supports_requiretls(), "REQUIRETLS must be parsed");
assert!(
caps.supports_future_release(),
"FUTURERELEASE must be parsed"
);
assert_eq!(caps.future_release_max_interval(), Some(86400));
assert_eq!(
caps.future_release_max_datetime(),
Some("2025-12-31T23:59:59Z")
);
assert!(caps.supports_deliver_by(), "DELIVERBY must be parsed");
assert_eq!(caps.deliver_by_min(), Some(240));
assert!(caps.supports_mt_priority(), "MT-PRIORITY must be parsed");
assert!(caps.supports_vrfy(), "VRFY must be parsed");
assert!(caps.supports_expn(), "EXPN must be parsed");
assert!(
caps.extensions
.contains(&SmtpExtension::NoSoliciting(Some("org.example.ads".into()))),
"NO-SOLICITING must be parsed with keyword"
);
}
#[test]
fn edge_enhanced_code_class_mismatch_452_with_class_5() {
let input = b"452 5.1.0 temp fail\r\n";
let (rest, resp) = parse_response(input).unwrap();
assert!(rest.is_empty());
assert_eq!(resp.code, 452);
assert!(resp.is_transient_error());
assert!(
resp.enhanced_code.is_none(),
"enhanced code with class 5 on a 452 reply must be discarded \
(RFC 2034 Section 4): got {:?}",
resp.enhanced_code
);
assert_eq!(
resp.lines,
vec!["5.1.0 temp fail"],
"discarded enhanced code digits must remain in text"
);
}
#[test]
fn edge_empty_ehlo_no_extensions() {
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(),
"single-line EHLO with no extensions must have empty capabilities, \
got: {:?}",
caps.extensions
);
assert!(!caps.supports_starttls());
assert!(!caps.supports_8bitmime());
assert!(!caps.supports_pipelining());
assert!(!caps.supports_chunking());
assert!(!caps.supports_smtputf8());
assert!(!caps.supports_size());
assert!(!caps.supports_dsn());
assert!(!caps.supports_sasl_ir());
assert!(!caps.supports_requiretls());
assert!(!caps.supports_deliver_by());
assert!(!caps.supports_future_release());
assert!(!caps.supports_mt_priority());
assert!(!caps.supports_enhanced_status_codes());
assert!(!caps.supports_vrfy());
assert!(!caps.supports_expn());
}
#[test]
fn edge_auth_mechanism_deduplication_two_identical_lines() {
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec![
"mail.example.com".into(),
"AUTH PLAIN LOGIN".into(),
"AUTH PLAIN LOGIN".into(),
],
};
let caps = parse_ehlo_capabilities(&response);
let auth_entries: Vec<_> = caps
.extensions
.iter()
.filter(|e| matches!(e, SmtpExtension::Auth(_)))
.collect();
assert_eq!(
auth_entries.len(),
1,
"duplicate AUTH lines must merge into one entry, got {auth_entries:?}"
);
let SmtpExtension::Auth(mechs) = &auth_entries[0] else {
unreachable!()
};
assert_eq!(
mechs.len(),
2,
"AUTH PLAIN LOGIN appearing twice must still have only 2 mechanisms \
(no duplicates per RFC 4954 Section 3); got {mechs:?}"
);
assert!(mechs.contains(&AuthMechanism::Plain));
assert!(mechs.contains(&AuthMechanism::Login));
}
#[test]
fn parse_response_tolerates_bare_lf() {
let (rest, resp) = parse_response(b"250 OK\n").unwrap();
assert!(rest.is_empty());
assert_eq!(resp.code, 250);
assert_eq!(resp.lines, vec!["OK"]);
}
#[test]
fn parse_response_tolerates_bare_lf_multiline() {
let (rest, resp) = parse_response(b"250-First\n250 Second\n").unwrap();
assert!(rest.is_empty());
assert_eq!(resp.code, 250);
assert_eq!(resp.lines, vec!["First", "Second"]);
}
#[test]
fn parse_response_tolerates_mixed_crlf_lf() {
let (rest, resp) = parse_response(b"250-CRLF\r\n250 LF\n").unwrap();
assert!(rest.is_empty());
assert_eq!(resp.code, 250);
assert_eq!(resp.lines.len(), 2);
}
#[test]
fn parse_response_code_only_bare_lf() {
let (rest, resp) = parse_response(b"250\n").unwrap();
assert!(rest.is_empty());
assert_eq!(resp.code, 250);
}
mod prop_invariants {
use super::*;
use proptest::prelude::*;
proptest! {
#![proptest_config(ProptestConfig::with_cases(1000))]
#[test]
fn parse_response_never_panics(data in prop::collection::vec(any::<u8>(), 0..500)) {
let _ = parse_response(&data);
}
#[test]
fn parse_response_consumes_bytes(data in prop::collection::vec(any::<u8>(), 1..500)) {
if let Ok((remaining, _)) = parse_response(&data) {
prop_assert!(
remaining.len() < data.len(),
"parser must consume at least one byte on success"
);
}
}
#[test]
fn reply_code_in_range(data in prop::collection::vec(any::<u8>(), 0..500)) {
if let Ok((_, resp)) = parse_response(&data) {
prop_assert!(
(200..=599).contains(&resp.code),
"SMTP reply code must be 200-599 (RFC 5321 Section 4.2.1), got {}",
resp.code
);
}
}
#[test]
fn enhanced_code_consistent(data in prop::collection::vec(any::<u8>(), 0..500)) {
if let Ok((_, resp)) = parse_response(&data) {
if let Some(ref enhanced) = resp.enhanced_code {
#[allow(clippy::cast_possible_truncation)]
let expected_class = (resp.code / 100) as u8;
prop_assert_eq!(
enhanced.class, expected_class,
"enhanced code class {} must match reply code {}'s first digit {}",
enhanced.class, resp.code, expected_class
);
}
}
}
}
}
#[test]
fn enhanced_code_extracted_with_bare_lf() {
let input = b"250 2.1.0\n";
let (_, resp) = parse_response(input).unwrap();
assert_eq!(resp.code, 250);
assert!(
resp.enhanced_code.is_some(),
"enhanced code must be extracted when response uses bare LF, got lines: {:?}",
resp.lines
);
let esc = resp.enhanced_code.unwrap();
assert_eq!(esc.class, 2);
assert_eq!(esc.subject, 1);
assert_eq!(esc.detail, 0);
}
#[test]
fn enhanced_code_extracted_with_crlf() {
let input = b"250 2.1.0\r\n";
let (_, resp) = parse_response(input).unwrap();
assert_eq!(resp.code, 250);
assert!(
resp.enhanced_code.is_some(),
"enhanced code must be extracted with CRLF"
);
}
mod stuck_tests {
use super::*;
use std::time::{Duration, Instant};
const MAX_PARSE_TIME: Duration = Duration::from_secs(5);
fn assert_terminates<F: FnOnce()>(name: &str, f: F) {
let start = Instant::now();
f();
let elapsed = start.elapsed();
assert!(
elapsed < MAX_PARSE_TIME,
"{name} took {elapsed:?}, exceeds {MAX_PARSE_TIME:?} — parser may be stuck"
);
}
#[test]
fn parse_response_repeated_bytes_100kb() {
let data = vec![0xFFu8; 100_000];
assert_terminates("100KB 0xFF", || {
let _ = parse_response(&data);
});
}
#[test]
fn parse_response_long_multiline_100kb() {
let mut data = Vec::new();
for i in 0..10_000 {
data.extend(format!("250-Line {i}\r\n").as_bytes());
}
data.extend(b"250 OK\r\n");
assert_terminates("10K-line multiline", || {
let _ = parse_response(&data);
});
}
}