#![allow(clippy::unwrap_used)]
use super::*;
use crate::types::{DomainOrLiteral, EnvidValue, ForwardPath, Mailbox, ReversePath};
fn dol(s: &str) -> DomainOrLiteral {
DomainOrLiteral::new(s).unwrap()
}
fn rp(s: &str) -> ReversePath {
ReversePath::new(s).unwrap()
}
fn fp(s: &str) -> ForwardPath {
ForwardPath::new(s).unwrap()
}
#[test]
fn encode_ehlo_command() {
let mut buf = BytesMut::new();
encode_ehlo(&mut buf, &dol("client.example.com")).unwrap();
assert_eq!(&buf[..], b"EHLO client.example.com\r\n");
}
#[test]
fn encode_ehlo_rejects_empty_label() {
let err = DomainOrLiteral::new("mail..example.com").unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("RFC 5321 Section 4.1.2"),
"error should cite the violated EHLO domain grammar: {msg}"
);
}
#[test]
fn encode_ehlo_rejects_overlong_domain_label() {
let domain = format!("{}.example.com", "a".repeat(64));
let err = DomainOrLiteral::new(&domain).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("63-octet") || msg.contains("63 octet"),
"error should cite the per-label DNS limit, got: {msg}"
);
}
#[test]
fn encode_mail_from_without_size() {
let mut buf = BytesMut::new();
encode_mail_from(&mut buf, &rp("sender@example.com"), None).unwrap();
assert_eq!(&buf[..], b"MAIL FROM:<sender@example.com>\r\n");
}
#[test]
fn encode_mail_from_with_size() {
let mut buf = BytesMut::new();
encode_mail_from(&mut buf, &rp("sender@example.com"), Some(1024)).unwrap();
assert_eq!(&buf[..], b"MAIL FROM:<sender@example.com> SIZE=1024\r\n");
}
#[test]
fn encode_rcpt_to_command() {
let mut buf = BytesMut::new();
encode_rcpt_to(&mut buf, &fp("recipient@example.com")).unwrap();
assert_eq!(&buf[..], b"RCPT TO:<recipient@example.com>\r\n");
}
#[test]
fn encode_mail_from_rejects_overlong_domain_label() {
let mailbox = format!("sender@{}.example", "a".repeat(64));
let err = ReversePath::new(&mailbox).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("63-octet") || msg.contains("63 octet") || msg.contains("invalid"),
"MAIL FROM should reject overlong domain labels, got: {msg}"
);
}
#[test]
fn dot_stuffing_no_dots() {
assert_eq!(dot_stuff(b"hello\r\nworld\r\n"), b"hello\r\nworld\r\n");
}
#[test]
fn dot_stuffing_leading_dot() {
assert_eq!(
dot_stuff(b".hidden\r\n.also\r\n"),
b"..hidden\r\n..also\r\n"
);
}
#[test]
fn dot_stuffing_dot_in_middle() {
assert_eq!(dot_stuff(b"no.dot\r\n"), b"no.dot\r\n");
}
#[test]
fn dot_stuffing_at_start_of_data() {
assert_eq!(dot_stuff(b".start"), b"..start");
}
#[test]
fn dot_stuffing_bare_lf_not_treated_as_line_start() {
assert_eq!(
dot_stuff(b"test\n.notastart\r\n"),
b"test\n.notastart\r\n",
"dot after bare LF must not be stuffed (RFC 5321 Section 4.5.2)"
);
}
#[test]
fn dot_stuffing_crlf_dot_is_stuffed() {
assert_eq!(
dot_stuff(b"test\r\n.start\r\n"),
b"test\r\n..start\r\n",
"dot after CRLF must be stuffed (RFC 5321 Section 4.5.2)"
);
}
#[test]
fn dot_stuff_size_matches_dot_stuff_len() {
let cases: &[&[u8]] = &[
b"hello\r\nworld\r\n",
b".hidden\r\n.also\r\n",
b"no.dot\r\n",
b".start",
b"test\n.notastart\r\n",
b"test\r\n.start\r\n",
b"",
b"Subject: Test\r\n\r\n.line1\r\n.line2\r\n",
];
for data in cases {
assert_eq!(
dot_stuff_size(data),
dot_stuff(data).len(),
"dot_stuff_size mismatch for {:?}",
String::from_utf8_lossy(data)
);
}
}
#[test]
fn dot_stuff_capacity_does_not_overflow() {
let data = vec![b'.'; 1024 * 1024]; let stuffed = dot_stuff(&data);
assert_eq!(stuffed.len(), data.len() + 1);
assert_eq!(stuffed[0], b'.');
assert_eq!(stuffed[1], b'.');
}
#[test]
fn auth_plain_encoding() {
use base64::Engine;
let mut buf = BytesMut::new();
encode_auth_plain(&mut buf, "user", "pass");
let line = std::str::from_utf8(&buf).unwrap();
assert!(line.starts_with("AUTH PLAIN "));
assert!(line.ends_with("\r\n"));
let b64 = &line["AUTH PLAIN ".len()..line.len() - 2];
let decoded = base64::engine::general_purpose::STANDARD
.decode(b64)
.unwrap();
assert_eq!(decoded, b"\0user\0pass");
}
#[test]
fn auth_xoauth2_encoding() {
use base64::Engine;
let mut buf = BytesMut::new();
encode_auth_xoauth2(&mut buf, "user@example.com", "ya29.token");
let line = std::str::from_utf8(&buf).unwrap();
assert!(line.starts_with("AUTH XOAUTH2 "));
let b64 = &line["AUTH XOAUTH2 ".len()..line.len() - 2];
let decoded = base64::engine::general_purpose::STANDARD
.decode(b64)
.unwrap();
let expected = "user=user@example.com\x01auth=Bearer ya29.token\x01\x01";
assert_eq!(decoded, expected.as_bytes());
}
#[test]
fn auth_plain_exact_base64_matches_manual_computation() {
use base64::Engine;
let user = "testuser";
let pass = "testpass";
let mut credentials = Vec::with_capacity(1 + user.len() + 1 + pass.len());
credentials.push(0);
credentials.extend_from_slice(user.as_bytes());
credentials.push(0);
credentials.extend_from_slice(pass.as_bytes());
let expected_b64 = base64::engine::general_purpose::STANDARD.encode(&credentials);
let mut buf = BytesMut::new();
encode_auth_plain(&mut buf, user, pass);
let line = std::str::from_utf8(&buf).unwrap();
let actual_b64 = &line["AUTH PLAIN ".len()..line.len() - 2];
assert_eq!(
actual_b64, expected_b64,
"encode_auth_plain base64 must match manual RFC 4616 computation"
);
}
#[test]
fn auth_xoauth2_exact_base64_matches_manual_computation() {
use base64::Engine;
let user = "user@example.com";
let token = "ya29.a0token";
let sasl_string = format!("user={user}\x01auth=Bearer {token}\x01\x01");
let expected_b64 = base64::engine::general_purpose::STANDARD.encode(sasl_string.as_bytes());
let mut buf = BytesMut::new();
encode_auth_xoauth2(&mut buf, user, token);
let line = std::str::from_utf8(&buf).unwrap();
let actual_b64 = &line["AUTH XOAUTH2 ".len()..line.len() - 2];
assert_eq!(
actual_b64, expected_b64,
"encode_auth_xoauth2 base64 must match manual XOAUTH2 SASL computation"
);
}
#[test]
fn encode_bdat_without_last() {
let mut buf = BytesMut::new();
encode_bdat(&mut buf, 1024, false);
assert_eq!(&buf[..], b"BDAT 1024\r\n");
}
#[test]
fn encode_bdat_with_last() {
let mut buf = BytesMut::new();
encode_bdat(&mut buf, 512, true);
assert_eq!(&buf[..], b"BDAT 512 LAST\r\n");
}
#[test]
fn encode_lhlo_command() {
let mut buf = BytesMut::new();
encode_lhlo(&mut buf, &dol("client.example.com")).unwrap();
assert_eq!(&buf[..], b"LHLO client.example.com\r\n");
}
#[test]
fn encode_helo_command() {
let mut buf = BytesMut::new();
encode_helo(&mut buf, &dol("client.example.com")).unwrap();
assert_eq!(&buf[..], b"HELO client.example.com\r\n");
}
#[test]
fn encode_helo_accepts_address_literal() {
let mut buf = BytesMut::new();
encode_helo(&mut buf, &dol("[127.0.0.1]")).unwrap();
assert_eq!(&buf[..], b"HELO [127.0.0.1]\r\n");
}
#[test]
fn encode_ehlo_rejects_invalid_lowercase_ipv6_address_literal() {
let err = DomainOrLiteral::new("[ipv6:not-an-ip]").unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("RFC 5321 Section 4.1.3"),
"invalid lowercase ipv6 literal must be rejected with RFC 5321 Section 4.1.3 context: {msg}"
);
}
#[test]
fn encode_mail_from_full_no_params() {
let mut buf = BytesMut::new();
encode_mail_from_full(
&mut buf,
&rp("sender@example.com"),
&MailFromParams::default(),
)
.unwrap();
assert_eq!(&buf[..], b"MAIL FROM:<sender@example.com>\r\n");
}
#[test]
fn encode_mail_from_full_rejects_invalid_address_literal_domain() {
let err = ReversePath::new("sender@[bad literal]").unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("RFC 5321") || msg.contains("invalid"),
"invalid mailbox address-literal must be rejected: {msg}"
);
}
#[test]
fn encode_mail_from_full_with_size() {
let mut buf = BytesMut::new();
let params = MailFromParams {
size: Some(2048),
..Default::default()
};
encode_mail_from_full(&mut buf, &rp("sender@example.com"), ¶ms).unwrap();
assert_eq!(&buf[..], b"MAIL FROM:<sender@example.com> SIZE=2048\r\n");
}
#[test]
fn encode_mail_from_full_with_body_8bitmime() {
let mut buf = BytesMut::new();
let params = MailFromParams {
body: Some(BodyType::EightBitMime),
..Default::default()
};
encode_mail_from_full(&mut buf, &rp("sender@example.com"), ¶ms).unwrap();
assert_eq!(
&buf[..],
b"MAIL FROM:<sender@example.com> BODY=8BITMIME\r\n"
);
}
#[test]
fn encode_mail_from_full_with_body_binarymime() {
let mut buf = BytesMut::new();
let params = MailFromParams {
body: Some(BodyType::BinaryMime),
..Default::default()
};
encode_mail_from_full(&mut buf, &rp("sender@example.com"), ¶ms).unwrap();
assert_eq!(
&buf[..],
b"MAIL FROM:<sender@example.com> BODY=BINARYMIME\r\n"
);
}
#[test]
fn encode_mail_from_full_with_body_7bit() {
let mut buf = BytesMut::new();
let params = MailFromParams {
body: Some(BodyType::SevenBit),
..Default::default()
};
encode_mail_from_full(&mut buf, &rp("sender@example.com"), ¶ms).unwrap();
assert_eq!(&buf[..], b"MAIL FROM:<sender@example.com> BODY=7BIT\r\n");
}
#[test]
fn encode_mail_from_full_with_smtputf8() {
let mut buf = BytesMut::new();
let params = MailFromParams {
smtputf8: true,
..Default::default()
};
encode_mail_from_full(&mut buf, &rp("sender@example.com"), ¶ms).unwrap();
assert_eq!(
&buf[..],
b"MAIL FROM:<sender@example.com> BODY=8BITMIME SMTPUTF8\r\n"
);
}
#[test]
fn encode_mail_from_rejects_non_ascii_reverse_path_without_smtputf8() {
let mut buf = BytesMut::new();
let result = encode_mail_from(&mut buf, &rp("pelé@example.com"), None);
assert!(
result.is_err(),
"non-ASCII reverse-paths must be rejected unless MAIL FROM declares SMTPUTF8"
);
}
#[test]
fn encode_mail_from_full_rejects_non_ascii_reverse_path_without_smtputf8() {
let mut buf = BytesMut::new();
let result = encode_mail_from_full(
&mut buf,
&rp("pelé@example.com"),
&MailFromParams::default(),
);
assert!(
result.is_err(),
"RFC 6531 Sections 3.3 and 3.4 require SMTPUTF8 for non-ASCII reverse-paths"
);
}
#[test]
fn encode_mail_from_full_allows_non_ascii_reverse_path_with_smtputf8() {
let mut buf = BytesMut::new();
let params = MailFromParams {
smtputf8: true,
..Default::default()
};
encode_mail_from_full(&mut buf, &rp("pelé@example.com"), ¶ms).unwrap();
assert_eq!(
&buf[..],
"MAIL FROM:<pelé@example.com> BODY=8BITMIME SMTPUTF8\r\n".as_bytes()
);
}
#[test]
fn encode_mail_from_full_rejects_smtputf8_with_body_7bit() {
let mut buf = BytesMut::new();
let params = MailFromParams {
body: Some(BodyType::SevenBit),
smtputf8: true,
..Default::default()
};
let result = encode_mail_from_full(&mut buf, &rp("sender@example.com"), ¶ms);
assert!(
result.is_err(),
"SMTPUTF8 + BODY=7BIT must be rejected (RFC 6531 Section 3.6)"
);
}
#[test]
fn encode_mail_from_full_all_params() {
let mut buf = BytesMut::new();
let params = MailFromParams {
size: Some(4096),
body: Some(BodyType::EightBitMime),
smtputf8: true,
..Default::default()
};
encode_mail_from_full(&mut buf, &rp("sender@example.com"), ¶ms).unwrap();
assert_eq!(
&buf[..],
b"MAIL FROM:<sender@example.com> SIZE=4096 BODY=8BITMIME SMTPUTF8\r\n"
);
}
#[test]
fn data_end_no_extra_blank_line_when_data_ends_with_crlf() {
let data = b"Subject: Test\r\n\r\nHello\r\n";
let stuffed = dot_stuff(data);
let mut terminator = BytesMut::new();
encode_data_end(&mut terminator, &stuffed);
let mut wire = Vec::new();
wire.extend_from_slice(&stuffed);
wire.extend_from_slice(&terminator);
assert!(
wire.ends_with(b"Hello\r\n.\r\n"),
"expected wire to end with 'Hello\\r\\n.\\r\\n', got trailing bytes: {:?}",
String::from_utf8_lossy(&wire[wire.len().saturating_sub(20)..])
);
}
#[test]
fn data_end_adds_crlf_when_data_does_not_end_with_crlf() {
let data = b"incomplete line";
let mut buf = BytesMut::new();
encode_data_end(&mut buf, data);
assert_eq!(&buf[..], b"\r\n.\r\n");
}
#[test]
fn data_end_with_empty_data() {
let mut buf = BytesMut::new();
encode_data_end(&mut buf, b"");
assert_eq!(&buf[..], b"\r\n.\r\n");
}
#[test]
fn dot_stuff_and_terminate_combines_body_and_terminator() {
let message = b"Subject: Test\r\n\r\n.Hello\r\n";
let combined = dot_stuff_and_terminate(message);
assert_eq!(
&combined[..],
b"Subject: Test\r\n\r\n..Hello\r\n.\r\n",
"combined buffer must contain dot-stuffed body + terminator \
(RFC 5321 Sections 4.5.2 / 4.1.1.4)"
);
}
#[test]
fn dot_stuff_and_terminate_adds_crlf_when_body_does_not_end_with_crlf() {
let message = b"incomplete line";
let combined = dot_stuff_and_terminate(message);
assert_eq!(
&combined[..],
b"incomplete line\r\n.\r\n",
"combined buffer must insert CRLF before terminator when body \
does not end with CRLF (RFC 5321 Section 4.1.1.4)"
);
}
#[test]
fn dot_stuff_and_terminate_empty_body() {
let combined = dot_stuff_and_terminate(b"");
assert_eq!(
&combined[..],
b"\r\n.\r\n",
"empty body must still produce CRLF + terminator \
(RFC 5321 Section 4.1.1.4)"
);
}
#[test]
fn encode_quit_command() {
let mut buf = BytesMut::new();
encode_quit(&mut buf);
assert_eq!(&buf[..], b"QUIT\r\n");
}
#[test]
fn encode_vrfy_command() {
let mut buf = BytesMut::new();
encode_vrfy(&mut buf, "user@example.com").unwrap();
assert_eq!(&buf[..], b"VRFY \"user@example.com\"\r\n");
}
#[test]
fn encode_vrfy_quotes_argument_with_spaces() {
let mut buf = BytesMut::new();
encode_vrfy(&mut buf, "Jane Doe").unwrap();
assert_eq!(&buf[..], b"VRFY \"Jane Doe\"\r\n");
}
#[test]
fn encode_vrfy_rejects_malformed_prequoted_argument() {
let mut buf = BytesMut::new();
let result = encode_vrfy(&mut buf, "\"bad\"quote\"");
assert!(
result.is_err(),
"malformed pre-quoted VRFY argument must be rejected instead of emitted verbatim"
);
}
#[test]
fn quoted_pair_smtp_rejects_htab_after_backslash() {
let quoted = "\"test\\\tvalue\"";
let result = validate_smtp_quoted_string(quoted, false);
assert!(
result.is_err(),
"quoted-pairSMTP must reject HTAB (0x09) after backslash; \
only %d32-126 is valid (RFC 5321 Section 4.1.2)"
);
}
#[test]
fn quoted_pair_smtp_allows_sp_after_backslash() {
let quoted = "\"test\\ value\"";
let result = validate_smtp_quoted_string(quoted, false);
assert!(
result.is_ok(),
"quoted-pairSMTP must accept SP (0x20) after backslash; \
it is within %d32-126 (RFC 5321 Section 4.1.2)"
);
}
#[test]
fn encode_vrfy_rejects_empty_argument() {
let mut buf = BytesMut::new();
let result = encode_vrfy(&mut buf, "");
assert!(
result.is_err(),
"VRFY with an empty String argument must be rejected (RFC 5321 Section 4.1.1.6)"
);
}
#[test]
fn encode_vrfy_rejects_non_ascii_argument() {
let mut buf = BytesMut::new();
let result = encode_vrfy(&mut buf, "usér@example.com");
assert!(
result.is_err(),
"VRFY String arguments must be printable ASCII only \
(RFC 5321 Section 4.1.1.6 / Section 4.1.2)"
);
}
#[test]
fn encode_expn_command() {
let mut buf = BytesMut::new();
encode_expn(&mut buf, "staff").unwrap();
assert_eq!(&buf[..], b"EXPN staff\r\n");
}
#[test]
fn encode_help_command_without_argument() {
let mut buf = BytesMut::new();
encode_help(&mut buf);
assert_eq!(&buf[..], b"HELP\r\n");
}
#[test]
fn encode_help_command_with_argument() {
let mut buf = BytesMut::new();
encode_help_with_arg(&mut buf, "MAIL").unwrap();
assert_eq!(&buf[..], b"HELP MAIL\r\n");
}
#[test]
fn encode_help_quotes_argument_with_spaces() {
let mut buf = BytesMut::new();
encode_help_with_arg(&mut buf, "MAIL FROM").unwrap();
assert_eq!(&buf[..], b"HELP \"MAIL FROM\"\r\n");
}
#[test]
fn encode_help_rejects_empty_argument() {
let mut buf = BytesMut::new();
let result = encode_help_with_arg(&mut buf, "");
assert!(
result.is_err(),
"HELP with an empty String argument must be rejected (RFC 5321 Section 4.1.1.8)"
);
}
#[test]
fn encode_expn_smtputf8_quotes_argument_with_spaces() {
let mut buf = BytesMut::new();
encode_expn_smtputf8(&mut buf, "équipe support").unwrap();
assert_eq!(&buf[..], "EXPN \"équipe support\" SMTPUTF8\r\n".as_bytes());
}
#[test]
fn encode_vrfy_smtputf8_quotes_mailbox_argument() {
let mut buf = BytesMut::new();
encode_vrfy_smtputf8(&mut buf, "usér@example.com").unwrap();
assert_eq!(
&buf[..],
"VRFY \"usér@example.com\" SMTPUTF8\r\n".as_bytes()
);
}
#[test]
fn encode_vrfy_smtputf8_rejects_escaped_non_ascii_in_prequoted_argument() {
let mut buf = BytesMut::new();
let result = encode_vrfy_smtputf8(&mut buf, "\"\\é\"");
assert!(
result.is_err(),
"escaped non-ASCII in a pre-quoted SMTPUTF8 argument must be rejected \
per RFC 5321 Section 4.1.2 / RFC 6531 Section 3.3"
);
}
#[test]
fn encode_expn_rejects_empty_argument() {
let mut buf = BytesMut::new();
let result = encode_expn(&mut buf, "");
assert!(
result.is_err(),
"EXPN with an empty String argument must be rejected (RFC 5321 Section 4.1.1.7)"
);
}
#[test]
fn encode_expn_rejects_control_character_argument() {
let mut buf = BytesMut::new();
let result = encode_expn(&mut buf, "staff\u{0007}");
assert!(
result.is_err(),
"EXPN String arguments must reject ASCII control characters \
(RFC 5321 Section 4.1.1.7 / Section 4.1.2)"
);
}
#[test]
fn encode_mail_from_equiv_no_params() {
let mut a = BytesMut::new();
let mut b = BytesMut::new();
encode_mail_from(&mut a, &rp("test@example.com"), None).unwrap();
encode_mail_from_full(&mut b, &rp("test@example.com"), &MailFromParams::default()).unwrap();
assert_eq!(&a[..], &b[..]);
}
#[test]
fn encode_mail_from_equiv_with_size() {
let mut a = BytesMut::new();
let mut b = BytesMut::new();
encode_mail_from(&mut a, &rp("test@example.com"), Some(5000)).unwrap();
let params = MailFromParams {
size: Some(5000),
..Default::default()
};
encode_mail_from_full(&mut b, &rp("test@example.com"), ¶ms).unwrap();
assert_eq!(&a[..], &b[..]);
}
#[test]
fn auth_login_initial_encoding() {
let mut buf = BytesMut::new();
encode_auth_login_initial(&mut buf);
assert_eq!(&buf[..], b"AUTH LOGIN\r\n");
}
#[test]
fn encode_rcpt_to_full_empty_params() {
let mut a = BytesMut::new();
let mut b = BytesMut::new();
encode_rcpt_to(&mut a, &fp("user@example.com")).unwrap();
encode_rcpt_to_full(&mut b, &fp("user@example.com"), &RcptToParams::default()).unwrap();
assert_eq!(&a[..], &b[..]);
}
#[test]
fn encode_mail_from_full_with_ret_full() {
let mut buf = BytesMut::new();
let params = MailFromParams {
ret: Some(DsnRet::Full),
..Default::default()
};
encode_mail_from_full(&mut buf, &rp("sender@example.com"), ¶ms).unwrap();
assert_eq!(&buf[..], b"MAIL FROM:<sender@example.com> RET=FULL\r\n");
}
#[test]
fn encode_mail_from_full_with_ret_hdrs() {
let mut buf = BytesMut::new();
let params = MailFromParams {
ret: Some(DsnRet::Hdrs),
..Default::default()
};
encode_mail_from_full(&mut buf, &rp("sender@example.com"), ¶ms).unwrap();
assert_eq!(&buf[..], b"MAIL FROM:<sender@example.com> RET=HDRS\r\n");
}
#[test]
fn encode_mail_from_full_with_envid() {
let mut buf = BytesMut::new();
let params = MailFromParams {
envid: Some(EnvidValue::new("msg-12345").unwrap()),
..Default::default()
};
encode_mail_from_full(&mut buf, &rp("sender@example.com"), ¶ms).unwrap();
assert_eq!(
&buf[..],
b"MAIL FROM:<sender@example.com> ENVID=msg-12345\r\n"
);
}
#[test]
fn encode_mail_from_full_envid_xtext_encoding() {
let mut buf = BytesMut::new();
let params = MailFromParams {
envid: Some(EnvidValue::new("id with+plus").unwrap()),
..Default::default()
};
encode_mail_from_full(&mut buf, &rp("sender@example.com"), ¶ms).unwrap();
assert_eq!(
&buf[..],
b"MAIL FROM:<sender@example.com> ENVID=id+20with+2Bplus\r\n"
);
}
#[test]
fn encode_mail_from_full_with_ret_and_envid() {
let mut buf = BytesMut::new();
let params = MailFromParams {
ret: Some(DsnRet::Hdrs),
envid: Some(EnvidValue::new("envelope-42").unwrap()),
..Default::default()
};
encode_mail_from_full(&mut buf, &rp("sender@example.com"), ¶ms).unwrap();
assert_eq!(
&buf[..],
b"MAIL FROM:<sender@example.com> RET=HDRS ENVID=envelope-42\r\n"
);
}
#[test]
fn encode_rcpt_to_full_with_notify_success() {
let mut buf = BytesMut::new();
let params = RcptToParams {
notify: Some(vec![DsnNotify::Success]),
..Default::default()
};
encode_rcpt_to_full(&mut buf, &fp("user@example.com"), ¶ms).unwrap();
assert_eq!(&buf[..], b"RCPT TO:<user@example.com> NOTIFY=SUCCESS\r\n");
}
#[test]
fn encode_rcpt_to_full_with_notify_multiple() {
let mut buf = BytesMut::new();
let params = RcptToParams {
notify: Some(vec![
DsnNotify::Success,
DsnNotify::Failure,
DsnNotify::Delay,
]),
..Default::default()
};
encode_rcpt_to_full(&mut buf, &fp("user@example.com"), ¶ms).unwrap();
assert_eq!(
&buf[..],
b"RCPT TO:<user@example.com> NOTIFY=SUCCESS,FAILURE,DELAY\r\n"
);
}
#[test]
fn encode_rcpt_to_full_with_notify_never() {
let mut buf = BytesMut::new();
let params = RcptToParams {
notify: Some(vec![DsnNotify::Never]),
..Default::default()
};
encode_rcpt_to_full(&mut buf, &fp("user@example.com"), ¶ms).unwrap();
assert_eq!(&buf[..], b"RCPT TO:<user@example.com> NOTIFY=NEVER\r\n");
}
#[test]
fn encode_rcpt_to_full_with_orcpt() {
let mut buf = BytesMut::new();
let params = RcptToParams {
orcpt: Some("user@example.com".into()),
..Default::default()
};
encode_rcpt_to_full(&mut buf, &fp("user@example.com"), ¶ms).unwrap();
assert_eq!(
&buf[..],
b"RCPT TO:<user@example.com> ORCPT=rfc822;user@example.com\r\n"
);
}
#[test]
fn encode_rcpt_to_full_with_notify_and_orcpt() {
let mut buf = BytesMut::new();
let params = RcptToParams {
notify: Some(vec![DsnNotify::Success, DsnNotify::Failure]),
orcpt: Some("original@example.com".into()),
};
encode_rcpt_to_full(&mut buf, &fp("user@example.com"), ¶ms).unwrap();
assert_eq!(
&buf[..],
b"RCPT TO:<user@example.com> NOTIFY=SUCCESS,FAILURE ORCPT=rfc822;original@example.com\r\n"
);
}
#[test]
fn encode_rcpt_to_full_with_non_ascii_orcpt_uses_utf8_addr_type() {
let mut buf = BytesMut::new();
let params = RcptToParams {
orcpt: Some("user@example.日本".into()),
..Default::default()
};
encode_rcpt_to_full(&mut buf, &fp("user@example.com"), ¶ms).unwrap();
let result = std::str::from_utf8(&buf).unwrap();
assert!(
result.contains("ORCPT=utf-8;"),
"non-ASCII ORCPT must use utf-8 addr-type per RFC 6533 Section 3, got: {result}"
);
}
#[test]
fn encode_rcpt_to_full_with_ascii_orcpt_uses_rfc822_addr_type() {
let mut buf = BytesMut::new();
let params = RcptToParams {
orcpt: Some("user@example.com".into()),
..Default::default()
};
encode_rcpt_to_full(&mut buf, &fp("user@example.com"), ¶ms).unwrap();
let result = std::str::from_utf8(&buf).unwrap();
assert!(
result.contains("ORCPT=rfc822;"),
"ASCII ORCPT must use rfc822 addr-type per RFC 3461 Section 4.2, got: {result}"
);
}
#[test]
fn rcpt_to_params_is_empty() {
assert!(RcptToParams::default().is_empty());
assert!(
RcptToParams {
notify: Some(vec![]),
..Default::default()
}
.is_empty(),
"empty NOTIFY vector must be treated as absent because RFC 3461 Section 4.1 requires at least one notify value"
);
assert!(!RcptToParams {
notify: Some(vec![DsnNotify::Success]),
..Default::default()
}
.is_empty());
assert!(!RcptToParams {
orcpt: Some("user@example.com".into()),
..Default::default()
}
.is_empty());
}
#[test]
fn xtext_encoding_printable_ascii_passthrough() {
let mut buf = BytesMut::new();
encode_xtext(&mut buf, "user@example.com");
assert_eq!(&buf[..], b"user@example.com");
}
#[test]
fn xtext_encoding_plus_is_encoded() {
let mut buf = BytesMut::new();
encode_xtext(&mut buf, "a+b");
assert_eq!(&buf[..], b"a+2Bb");
}
#[test]
fn xtext_encoding_space_is_encoded() {
let mut buf = BytesMut::new();
encode_xtext(&mut buf, "a b");
assert_eq!(&buf[..], b"a+20b");
}
#[test]
fn xtext_encoding_equals_is_encoded() {
let mut buf = BytesMut::new();
encode_xtext(&mut buf, "a=b");
assert_eq!(
&buf[..],
b"a+3Db",
"\"=\" (0x3D) must be hex-encoded per RFC 3461 Section 4: \
xchar = any ASCII CHAR between ! and ~ inclusive, except for + and ="
);
}
#[test]
fn encode_mail_from_full_with_requiretls() {
let mut buf = BytesMut::new();
let params = MailFromParams {
requiretls: true,
..Default::default()
};
encode_mail_from_full(&mut buf, &rp("sender@example.com"), ¶ms).unwrap();
assert_eq!(&buf[..], b"MAIL FROM:<sender@example.com> REQUIRETLS\r\n");
}
#[test]
fn encode_mail_from_full_requiretls_false_omitted() {
let mut buf = BytesMut::new();
let params = MailFromParams {
requiretls: false,
..Default::default()
};
encode_mail_from_full(&mut buf, &rp("sender@example.com"), ¶ms).unwrap();
assert_eq!(&buf[..], b"MAIL FROM:<sender@example.com>\r\n");
}
#[test]
fn auth_oauthbearer_encoding() {
use base64::Engine;
let mut buf = BytesMut::new();
encode_auth_oauthbearer(&mut buf, "ya29.token");
let line = std::str::from_utf8(&buf).unwrap();
assert!(line.starts_with("AUTH OAUTHBEARER "));
assert!(line.ends_with("\r\n"));
let b64 = &line["AUTH OAUTHBEARER ".len()..line.len() - 2];
let decoded = base64::engine::general_purpose::STANDARD
.decode(b64)
.unwrap();
let expected = "n,,\x01auth=Bearer ya29.token\x01\x01";
assert_eq!(decoded, expected.as_bytes());
}
#[test]
fn encode_mail_from_full_with_holdfor() {
let mut buf = BytesMut::new();
let params = MailFromParams {
hold_for: Some(86400),
..Default::default()
};
encode_mail_from_full(&mut buf, &rp("sender@example.com"), ¶ms).unwrap();
assert_eq!(
&buf[..],
b"MAIL FROM:<sender@example.com> HOLDFOR=86400\r\n"
);
}
#[test]
fn encode_mail_from_full_with_holduntil() {
let mut buf = BytesMut::new();
let params = MailFromParams {
hold_until: Some("2024-12-25T00:00:00Z".into()),
..Default::default()
};
encode_mail_from_full(&mut buf, &rp("sender@example.com"), ¶ms).unwrap();
assert_eq!(
&buf[..],
b"MAIL FROM:<sender@example.com> HOLDUNTIL=2024-12-25T00:00:00Z\r\n"
);
}
#[test]
fn encode_mail_from_full_rejects_zero_holdfor() {
let mut buf = BytesMut::new();
let params = MailFromParams {
hold_for: Some(0),
..Default::default()
};
let result = encode_mail_from_full(&mut buf, &rp("sender@example.com"), ¶ms);
assert!(
result.is_err(),
"HOLDFOR=0 must be rejected because RFC 4865 Section 5 requires a positive interval"
);
}
#[test]
fn encode_mail_from_full_rejects_ten_digit_holdfor() {
let mut buf = BytesMut::new();
let params = MailFromParams {
hold_for: Some(1_000_000_000),
..Default::default()
};
let result = encode_mail_from_full(&mut buf, &rp("sender@example.com"), ¶ms);
assert!(
result.is_err(),
"HOLDFOR with more than 9 digits must be rejected (RFC 4865 Section 5)"
);
}
#[test]
fn encode_mail_from_full_rejects_malformed_holduntil() {
let mut buf = BytesMut::new();
let params = MailFromParams {
hold_until: Some("2024-12-25 00:00:00Z".into()),
..Default::default()
};
let result = encode_mail_from_full(&mut buf, &rp("sender@example.com"), ¶ms);
assert!(
result.is_err(),
"malformed HOLDUNTIL must be rejected instead of being emitted on the wire"
);
}
#[test]
fn encode_mail_from_full_rejects_holdfor_and_holduntil_together() {
let mut buf = BytesMut::new();
let params = MailFromParams {
hold_for: Some(3600),
hold_until: Some("2024-12-25T00:00:00Z".into()),
..Default::default()
};
let result = encode_mail_from_full(&mut buf, &rp("sender@example.com"), ¶ms);
assert!(
result.is_err(),
"HOLDFOR and HOLDUNTIL together must be rejected per RFC 4865 Section 5"
);
}
#[test]
fn encode_mail_from_full_with_deliver_by_return() {
use crate::types::{DeliverBy, DeliverByMode};
let mut buf = BytesMut::new();
let params = MailFromParams {
deliver_by: Some(DeliverBy {
seconds: 3600,
mode: DeliverByMode::Return,
trace: false,
}),
..Default::default()
};
encode_mail_from_full(&mut buf, &rp("sender@example.com"), ¶ms).unwrap();
assert_eq!(&buf[..], b"MAIL FROM:<sender@example.com> BY=3600;R\r\n");
}
#[test]
fn encode_mail_from_full_with_deliver_by_notify() {
use crate::types::{DeliverBy, DeliverByMode};
let mut buf = BytesMut::new();
let params = MailFromParams {
deliver_by: Some(DeliverBy {
seconds: -120,
mode: DeliverByMode::Notify,
trace: false,
}),
..Default::default()
};
encode_mail_from_full(&mut buf, &rp("sender@example.com"), ¶ms).unwrap();
assert_eq!(&buf[..], b"MAIL FROM:<sender@example.com> BY=-120;N\r\n");
}
#[test]
fn encode_mail_from_full_with_deliver_by_trace_flag() {
use crate::types::{DeliverBy, DeliverByMode};
let mut buf = BytesMut::new();
let params = MailFromParams {
deliver_by: Some(DeliverBy {
seconds: 3600,
mode: DeliverByMode::Return,
trace: true,
}),
..Default::default()
};
encode_mail_from_full(&mut buf, &rp("sender@example.com"), ¶ms).unwrap();
assert_eq!(&buf[..], b"MAIL FROM:<sender@example.com> BY=3600;RT\r\n");
buf.clear();
let params = MailFromParams {
deliver_by: Some(DeliverBy {
seconds: 3600,
mode: DeliverByMode::Notify,
trace: true,
}),
..Default::default()
};
encode_mail_from_full(&mut buf, &rp("sender@example.com"), ¶ms).unwrap();
assert_eq!(&buf[..], b"MAIL FROM:<sender@example.com> BY=3600;NT\r\n");
buf.clear();
let params = MailFromParams {
deliver_by: Some(DeliverBy {
seconds: 3600,
mode: DeliverByMode::Return,
trace: false,
}),
..Default::default()
};
encode_mail_from_full(&mut buf, &rp("sender@example.com"), ¶ms).unwrap();
assert_eq!(&buf[..], b"MAIL FROM:<sender@example.com> BY=3600;R\r\n");
}
#[test]
fn encode_mail_from_full_with_mt_priority() {
let mut buf = BytesMut::new();
let params = MailFromParams {
mt_priority: Some(3),
..Default::default()
};
encode_mail_from_full(&mut buf, &rp("sender@example.com"), ¶ms).unwrap();
assert_eq!(
&buf[..],
b"MAIL FROM:<sender@example.com> MT-PRIORITY=3\r\n"
);
}
#[test]
fn encode_mail_from_full_with_mt_priority_negative() {
let mut buf = BytesMut::new();
let params = MailFromParams {
mt_priority: Some(-4),
..Default::default()
};
encode_mail_from_full(&mut buf, &rp("sender@example.com"), ¶ms).unwrap();
assert_eq!(
&buf[..],
b"MAIL FROM:<sender@example.com> MT-PRIORITY=-4\r\n"
);
}
#[test]
fn mt_priority_valid_range_accepted() {
for value in [-9, 0, 9] {
let mut buf = BytesMut::new();
let params = MailFromParams {
mt_priority: Some(value),
..Default::default()
};
encode_mail_from_full(&mut buf, &rp("sender@example.com"), ¶ms).unwrap();
let expected = format!("MAIL FROM:<sender@example.com> MT-PRIORITY={value}\r\n");
assert_eq!(
&buf[..],
expected.as_bytes(),
"MT-PRIORITY={value} must be accepted (RFC 6758 Section 4)"
);
}
}
#[test]
fn mt_priority_out_of_range_rejected() {
for value in [-10, 10] {
let mut buf = BytesMut::new();
let params = MailFromParams {
mt_priority: Some(value),
..Default::default()
};
let result = encode_mail_from_full(&mut buf, &rp("sender@example.com"), ¶ms);
assert!(
result.is_err(),
"MT-PRIORITY={value} must be rejected as out of range (RFC 6758 Section 4)"
);
}
}
#[test]
fn encode_rcpt_to_full_notify_never_combined_with_others_is_error() {
let mut buf = BytesMut::new();
let params = RcptToParams {
notify: Some(vec![DsnNotify::Never, DsnNotify::Success]),
..Default::default()
};
let result = encode_rcpt_to_full(&mut buf, &fp("user@example.com"), ¶ms);
assert!(
result.is_err(),
"NOTIFY=NEVER combined with other values must return an error \
(RFC 3461 Section 4.1)"
);
}
#[test]
fn encode_rcpt_to_full_notify_never_after_others_is_error() {
let mut buf = BytesMut::new();
let params = RcptToParams {
notify: Some(vec![DsnNotify::Failure, DsnNotify::Never]),
..Default::default()
};
let result = encode_rcpt_to_full(&mut buf, &fp("user@example.com"), ¶ms);
assert!(
result.is_err(),
"NOTIFY=NEVER combined with other values must return an error \
regardless of ordering (RFC 3461 Section 4.1)"
);
}
#[test]
fn encode_rcpt_to_full_notify_never_alone_unchanged() {
let mut buf = BytesMut::new();
let params = RcptToParams {
notify: Some(vec![DsnNotify::Never]),
..Default::default()
};
encode_rcpt_to_full(&mut buf, &fp("user@example.com"), ¶ms).unwrap();
assert_eq!(&buf[..], b"RCPT TO:<user@example.com> NOTIFY=NEVER\r\n");
}
#[test]
fn encode_rcpt_to_full_empty_notify_vector_omitted() {
let mut buf = BytesMut::new();
let params = RcptToParams {
notify: Some(vec![]),
..Default::default()
};
encode_rcpt_to_full(&mut buf, &fp("user@example.com"), ¶ms).unwrap();
assert_eq!(
&buf[..],
b"RCPT TO:<user@example.com>\r\n",
"empty NOTIFY vector must be omitted, not produce invalid 'NOTIFY=' \
(RFC 3461 Section 4.1)"
);
}
#[test]
fn test_encode_ehlo_rejects_crlf() {
let err = DomainOrLiteral::new("evil.com\r\nMAIL FROM:<bad>");
assert!(err.is_err(), "EHLO with CRLF must return Err");
}
#[test]
fn test_encode_rcpt_to_rejects_crlf() {
let err = ForwardPath::new("a@b\r\nDATA");
assert!(err.is_err(), "RCPT TO with CRLF must return Err");
}
#[test]
fn test_encode_mail_from_rejects_crlf() {
let err = ReversePath::new("a@b\r\nRCPT TO:<evil>");
assert!(err.is_err(), "MAIL FROM with CRLF must return Err");
}
#[test]
fn test_encode_rcpt_to_full_rejects_crlf() {
let err = ForwardPath::new("a@b\r\nDATA");
assert!(err.is_err(), "RCPT TO (full) with CRLF must return Err");
}
#[test]
fn test_encode_mail_from_full_rejects_crlf() {
let err = ReversePath::new("a@b\r\nRCPT TO:<evil>");
assert!(err.is_err(), "MAIL FROM (full) with CRLF must return Err");
}
#[test]
fn encode_vrfy_rejects_crlf() {
let mut buf = BytesMut::new();
let err = encode_vrfy(&mut buf, "user\r\n@example.com");
assert!(err.is_err(), "VRFY with CRLF must return Err");
}
#[test]
fn encode_expn_rejects_crlf() {
let mut buf = BytesMut::new();
let err = encode_expn(&mut buf, "list\r\n@example.com");
assert!(err.is_err(), "EXPN with CRLF must return Err");
}
#[test]
fn holduntil_rejects_crlf_injection() {
let mut buf = BytesMut::new();
let params = MailFromParams {
hold_until: Some("2024-12-25T00:00:00Z\r\nRCPT TO:<evil@attacker.com>".into()),
..Default::default()
};
let result = encode_mail_from_full(&mut buf, &rp("sender@example.com"), ¶ms);
assert!(
result.is_err(),
"HOLDUNTIL with embedded CRLF must return error, not silently strip"
);
}
#[test]
fn holduntil_valid_datetime() {
let mut buf = BytesMut::new();
let params = MailFromParams {
hold_until: Some("2024-12-25T00:00:00Z".into()),
..Default::default()
};
let result = encode_mail_from_full(&mut buf, &rp("sender@example.com"), ¶ms);
assert!(result.is_ok());
let output = String::from_utf8_lossy(&buf);
assert!(
output.contains("HOLDUNTIL=2024-12-25T00:00:00Z"),
"valid HOLDUNTIL must be included verbatim: {output}"
);
}
#[test]
fn edge_dot_stuff_first_line_dot_crlf() {
assert_eq!(
dot_stuff(b".\r\n"),
b"..\r\n",
"First line '.' followed by CRLF must be dot-stuffed \
(RFC 5321 Section 4.5.2)"
);
}
#[test]
fn edge_dot_stuff_single_dot_no_crlf() {
assert_eq!(
dot_stuff(b"."),
b"..",
"Single dot at start of data must be stuffed (RFC 5321 Section 4.5.2)"
);
}
#[test]
fn edge_dot_stuff_consecutive_dot_lines() {
assert_eq!(
dot_stuff(b".\r\n.\r\n.\r\n"),
b"..\r\n..\r\n..\r\n",
"Consecutive dot-only lines must all be stuffed (RFC 5321 Section 4.5.2)"
);
}
#[test]
fn edge_dot_stuff_mixed_line_endings() {
assert_eq!(
dot_stuff(b"line1\r\n.line2\nline3\n.line4\r\n"),
b"line1\r\n..line2\nline3\n.line4\r\n",
"Dot after CRLF must be stuffed; dot after bare LF must not \
(RFC 5321 Section 4.5.2)"
);
}
#[test]
fn edge_dot_stuff_size_termination_sequence() {
let cases: &[&[u8]] = &[b".\r\n", b".", b".\r\n.\r\n.\r\n", b"\r\n.\r\n"];
for data in cases {
assert_eq!(
dot_stuff_size(data),
dot_stuff(data).len(),
"dot_stuff_size mismatch for {:?}",
String::from_utf8_lossy(data)
);
}
}
#[test]
fn mail_from_rejects_overlength_reverse_path() {
let mut buf = BytesMut::new();
let domain_189 = format!("{}.{}.{}", "b".repeat(63), "c".repeat(63), "d".repeat(61));
let addr_254 = format!("{}@{domain_189}", "a".repeat(64));
assert_eq!(addr_254.len(), 254);
let rp_254 = ReversePath::new(&addr_254).unwrap();
assert!(
encode_mail_from(&mut buf, &rp_254, None).is_ok(),
"254-byte address should be accepted"
);
let domain_190 = format!("{}.{}.{}", "b".repeat(63), "c".repeat(63), "d".repeat(62));
let addr_255 = format!("{}@{domain_190}", "a".repeat(64));
assert_eq!(addr_255.len(), 255);
assert!(
ReversePath::new(&addr_255).is_err(),
"255-byte address should be rejected"
);
}
#[test]
fn rcpt_to_rejects_overlength_forward_path() {
let mut buf = BytesMut::new();
let domain_189 = format!("{}.{}.{}", "f".repeat(63), "g".repeat(63), "h".repeat(61));
let addr_254 = format!("{}@{domain_189}", "e".repeat(64));
assert_eq!(addr_254.len(), 254);
let fp_254 = ForwardPath::new(&addr_254).unwrap();
assert!(
encode_rcpt_to(&mut buf, &fp_254).is_ok(),
"254-byte address should be accepted"
);
let domain_190 = format!("{}.{}.{}", "f".repeat(63), "g".repeat(63), "h".repeat(62));
let addr_255 = format!("{}@{domain_190}", "e".repeat(64));
assert_eq!(addr_255.len(), 255);
assert!(
ForwardPath::new(&addr_255).is_err(),
"255-byte address should be rejected"
);
}
#[test]
fn mail_from_rejects_local_part_longer_than_64_octets() {
let address = format!("{}@example.com", "a".repeat(65));
let result = ReversePath::new(&address);
assert!(
result.is_err(),
"MAIL FROM local-part >64 octets must be rejected per RFC 5321 \
Section 4.5.3.1.1"
);
}
#[test]
fn rcpt_to_rejects_local_part_longer_than_64_octets() {
let address = format!("{}@example.com", "a".repeat(65));
let result = ForwardPath::new(&address);
assert!(
result.is_err(),
"RCPT TO local-part >64 octets must be rejected per RFC 5321 \
Section 4.5.3.1.1"
);
}
#[test]
fn mail_from_allows_empty_reverse_path() {
let mut buf = BytesMut::new();
assert!(encode_mail_from(&mut buf, &rp(""), None).is_ok());
assert_eq!(&buf[..], b"MAIL FROM:<>\r\n");
}
#[test]
fn mail_from_rejects_invalid_mailbox_syntax() {
let result = ReversePath::new("sender example.com");
assert!(
result.is_err(),
"MAIL FROM must reject invalid Mailbox syntax (RFC 5321 Section 4.1.1.2 / Section 4.1.2)"
);
}
#[test]
fn rcpt_to_rejects_name_addr_syntax() {
let result = ForwardPath::new("Recipient <user@example.com>");
assert!(
result.is_err(),
"RCPT TO must reject name-addr syntax and require a bare Mailbox \
(RFC 5321 Section 4.1.1.3 / Section 4.1.2)"
);
}
#[test]
fn vrfy_within_512_octet_limit() {
let mut buf = BytesMut::new();
encode_vrfy(&mut buf, "user@example.com").unwrap();
assert_eq!(&buf[..], b"VRFY \"user@example.com\"\r\n");
}
#[test]
fn vrfy_exceeds_512_octet_limit() {
let long_arg = "a".repeat(510);
let mut buf = BytesMut::new();
let result = encode_vrfy(&mut buf, &long_arg);
assert!(
result.is_err(),
"VRFY with a 510-char argument produces a 517-octet line and must be rejected \
(RFC 5321 Section 4.5.3.1.4)"
);
}
#[test]
fn test_ehlo_rejects_overlong_domain_rfc5321() {
let long_domain = "a".repeat(510);
let result = DomainOrLiteral::new(&long_domain);
assert!(
result.is_err(),
"EHLO with overlong domain must be rejected"
);
}
#[test]
fn ehlo_rejects_domain_longer_than_255_octets_even_within_line_limit() {
let long_domain = format!("{}.com", "a".repeat(252));
let result = DomainOrLiteral::new(&long_domain);
assert!(
result.is_err(),
"EHLO domain >255 octets must be rejected even when the command line \
is shorter than 512 octets (RFC 5321 Section 4.5.3.1.2)"
);
}
#[test]
fn lhlo_rejects_address_literal_longer_than_255_octets() {
let long_literal = format!("[TAG:{}]", "a".repeat(250));
let result = DomainOrLiteral::new(&long_literal);
assert!(
result.is_err(),
"LHLO address-literal >255 octets must be rejected per RFC 5321 \
Section 4.5.3.1.2"
);
}
#[test]
fn expn_exceeds_512_octet_limit() {
let long_arg = "a".repeat(510);
let mut buf = BytesMut::new();
let result = encode_expn(&mut buf, &long_arg);
assert!(
result.is_err(),
"EXPN with a 510-char argument produces a 517-octet line and must be rejected \
(RFC 5321 Section 4.5.3.1.4)"
);
}
#[test]
fn edge_mail_from_all_params_simultaneously() {
use crate::types::{BodyType, DeliverBy, DeliverByMode, DsnRet};
let mut buf = BytesMut::new();
let params = MailFromParams {
size: Some(102_400),
body: Some(BodyType::EightBitMime),
smtputf8: true,
requiretls: false,
ret: Some(DsnRet::Full),
envid: Some(EnvidValue::new("msg-id-42").unwrap()),
hold_for: Some(3600),
hold_until: None,
deliver_by: Some(DeliverBy {
seconds: 7200,
mode: DeliverByMode::Return,
trace: false,
}),
mt_priority: None,
auth: None,
};
encode_mail_from_full(&mut buf, &rp("sender@example.com"), ¶ms).unwrap();
let output = std::str::from_utf8(&buf).unwrap();
assert!(
output.starts_with("MAIL FROM:<sender@example.com>"),
"command must start with MAIL FROM:<addr>: {output}"
);
assert!(
output.ends_with("\r\n"),
"command must end with CRLF: {output}"
);
assert!(
output.contains(" SIZE=102400"),
"SIZE param missing: {output}"
);
assert!(
output.contains(" BODY=8BITMIME"),
"BODY param missing: {output}"
);
assert!(
output.contains(" SMTPUTF8"),
"SMTPUTF8 param missing: {output}"
);
assert!(output.contains(" RET=FULL"), "RET param missing: {output}");
assert!(
output.contains(" ENVID=msg-id-42"),
"ENVID param missing: {output}"
);
assert!(
output.contains(" HOLDFOR=3600"),
"HOLDFOR param missing: {output}"
);
assert!(output.contains(" BY=7200;R"), "BY param missing: {output}");
assert!(
!output.contains("REQUIRETLS"),
"REQUIRETLS should not appear when false: {output}"
);
assert!(
!output.contains("MT-PRIORITY"),
"MT-PRIORITY should not appear when None: {output}"
);
let pos_size = output.find("SIZE=").unwrap();
let pos_body = output.find("BODY=").unwrap();
let pos_utf8 = output.find("SMTPUTF8").unwrap();
let pos_ret = output.find("RET=").unwrap();
let pos_envid = output.find("ENVID=").unwrap();
let pos_holdfor = output.find("HOLDFOR=").unwrap();
let pos_by = output.find("BY=").unwrap();
assert!(
pos_size < pos_body
&& pos_body < pos_utf8
&& pos_utf8 < pos_ret
&& pos_ret < pos_envid
&& pos_envid < pos_holdfor
&& pos_holdfor < pos_by,
"parameters must appear in canonical order: {output}"
);
}
#[test]
fn edge_xtext_encoding_plus_and_equals() {
let mut buf = BytesMut::new();
encode_xtext(&mut buf, "a+b=c");
assert_eq!(
&buf[..],
b"a+2Bb+3Dc",
"'+' must become +2B and '=' must become +3D per RFC 3461 Section 4"
);
}
#[test]
fn edge_xtext_encoding_consecutive_specials() {
let mut buf = BytesMut::new();
encode_xtext(&mut buf, "++=");
assert_eq!(
&buf[..],
b"+2B+2B+3D",
"consecutive '+' and '=' must each be hex-encoded per RFC 3461 Section 4"
);
}
#[test]
fn edge_xtext_encoding_control_and_high_chars() {
let mut buf = BytesMut::new();
encode_xtext(&mut buf, "\x00\x09\x7f");
assert_eq!(
&buf[..],
b"+00+09+7F",
"control characters and DEL must be hex-encoded per RFC 3461 Section 4"
);
}
#[test]
fn edge_deliver_by_negative_seconds() {
use crate::types::{DeliverBy, DeliverByMode};
let mut buf = BytesMut::new();
let params = MailFromParams {
deliver_by: Some(DeliverBy {
seconds: -300,
mode: DeliverByMode::Notify,
trace: false,
}),
..Default::default()
};
encode_mail_from_full(&mut buf, &rp("sender@example.com"), ¶ms).unwrap();
assert_eq!(
&buf[..],
b"MAIL FROM:<sender@example.com> BY=-300;N\r\n",
"negative DELIVERBY seconds must be encoded with minus sign \
(RFC 2852 Section 4)"
);
}
#[test]
fn edge_deliver_by_negative_seconds_with_return_mode_rejected() {
use crate::types::{DeliverBy, DeliverByMode};
let mut buf = BytesMut::new();
let params = MailFromParams {
deliver_by: Some(DeliverBy {
seconds: -600,
mode: DeliverByMode::Return,
trace: true,
}),
..Default::default()
};
let result = encode_mail_from_full(&mut buf, &rp("sender@example.com"), ¶ms);
assert!(
result.is_err(),
"BY=-600;RT must be rejected because RFC 2852 Section 4 forbids non-positive by-time with Return mode"
);
}
#[test]
fn encode_mail_from_full_rejects_ten_digit_deliverby() {
use crate::types::{DeliverBy, DeliverByMode};
let mut buf = BytesMut::new();
let params = MailFromParams {
deliver_by: Some(DeliverBy {
seconds: 1_000_000_000,
mode: DeliverByMode::Notify,
trace: false,
}),
..Default::default()
};
let result = encode_mail_from_full(&mut buf, &rp("sender@example.com"), ¶ms);
assert!(
result.is_err(),
"DELIVERBY with more than 9 digits must be rejected (RFC 2852 Section 4)"
);
}
#[test]
fn edge_rcpt_to_at_exact_path_limit() {
let domain_189 = format!("{}.{}.{}", "j".repeat(63), "k".repeat(63), "l".repeat(61));
let addr = format!("{}@{domain_189}", "i".repeat(64));
assert_eq!(addr.len(), 254);
let fp_addr = ForwardPath::new(&addr).unwrap();
let mut buf = BytesMut::new();
assert!(
encode_rcpt_to(&mut buf, &fp_addr).is_ok(),
"254-byte address must be accepted (RFC 5321 Section 4.5.3.1.3)"
);
}
#[test]
fn edge_rcpt_to_exceeds_path_limit() {
let domain_190 = format!("{}.{}.{}", "n".repeat(63), "o".repeat(63), "p".repeat(62));
let addr = format!("{}@{domain_190}", "m".repeat(64));
assert_eq!(addr.len(), 255);
assert!(
ForwardPath::new(&addr).is_err(),
"255-byte address must be rejected (RFC 5321 Section 4.5.3.1.3)"
);
}
#[test]
fn edge_rcpt_to_with_dsn_params_path_limit_is_on_address() {
let domain_189 = format!("{}.{}.{}", "r".repeat(63), "s".repeat(63), "t".repeat(61));
let addr = format!("{}@{domain_189}", "q".repeat(64));
assert_eq!(addr.len(), 254);
let fp_addr = ForwardPath::new(&addr).unwrap();
let mut buf = BytesMut::new();
let params = RcptToParams {
notify: Some(vec![
DsnNotify::Success,
DsnNotify::Failure,
DsnNotify::Delay,
]),
orcpt: Some("original@example.com".into()),
};
assert!(
encode_rcpt_to_full(&mut buf, &fp_addr, ¶ms).is_ok(),
"254-byte address with DSN params must be accepted: the 256-octet \
limit applies to <forward-path>, not the entire command line \
(RFC 5321 Section 4.5.3.1.3)"
);
let output = std::str::from_utf8(&buf).unwrap();
assert!(
output.contains("NOTIFY=SUCCESS,FAILURE,DELAY"),
"NOTIFY params must be present: {output}"
);
assert!(
output.contains("ORCPT=rfc822;original@example.com"),
"ORCPT param must be present: {output}"
);
}
#[test]
fn edge_rcpt_to_full_overlength_address_rejected_with_dsn() {
let local = "a".repeat(246);
let addr = format!("{local}@test.com"); assert_eq!(addr.len(), 255);
assert!(
ForwardPath::new(&addr).is_err(),
"255-byte address must be rejected even with DSN params \
(RFC 5321 Section 4.5.3.1.3)"
);
}
#[test]
fn edge_rcpt_to_full_rejects_line_over_1012_with_dsn() {
let mut buf = BytesMut::new();
let params = RcptToParams {
notify: Some(vec![DsnNotify::Success]),
orcpt: Some("a".repeat(980)),
};
let result = encode_rcpt_to_full(&mut buf, &fp("user@example.com"), ¶ms);
assert!(
result.is_err(),
"RCPT TO with DSN params exceeding 1012 octets must be rejected \
(RFC 3461 Section 5)"
);
}
#[test]
fn encode_mail_from_full_rejects_empty_envid() {
assert!(
EnvidValue::new("").is_err(),
"RFC 3461 Section 4.4: empty ENVID must be rejected (xtext = 1*xchar)"
);
}
#[test]
fn encode_mail_from_full_rejects_non_ascii_envid() {
assert!(
EnvidValue::new("résumé-42").is_err(),
"RFC 3461 Section 4.4: non-ASCII ENVID must be rejected before xtext encoding"
);
}
#[test]
fn encode_mail_from_full_rejects_envid_longer_than_100_chars() {
assert!(
EnvidValue::new("a".repeat(101)).is_err(),
"RFC 3461 Section 4.4: ENVID values longer than 100 characters must be rejected"
);
}
#[test]
fn encode_rcpt_to_full_rejects_empty_orcpt() {
let mut buf = BytesMut::new();
let params = RcptToParams {
orcpt: Some(String::new()),
..Default::default()
};
let result = encode_rcpt_to_full(&mut buf, &fp("user@example.com"), ¶ms);
assert!(
result.is_err(),
"RFC 3461 Section 4.2: empty ORCPT must be rejected (xtext = 1*xchar)"
);
}
#[test]
fn encode_mail_from_auth_mailbox() {
use crate::types::SmtpAuthParam;
let mut buf = BytesMut::new();
let params = MailFromParams {
auth: Some(SmtpAuthParam::Mailbox(
Mailbox::new("user@example.com").unwrap(),
)),
..Default::default()
};
encode_mail_from_full(&mut buf, &rp("sender@example.com"), ¶ms).unwrap();
assert_eq!(
&buf[..],
b"MAIL FROM:<sender@example.com> AUTH=user@example.com\r\n"
);
}
#[test]
fn orcpt_utf8_passes_multibyte_through() {
let mut buf = BytesMut::new();
let params = RcptToParams {
orcpt: Some("user@example.日本".into()),
..Default::default()
};
encode_rcpt_to_full(&mut buf, &fp("user@example.com"), ¶ms).unwrap();
let output = &buf[..];
let expected_suffix = "user@example.日本\r\n";
assert!(
output.ends_with(expected_suffix.as_bytes()),
"multi-byte UTF-8 must pass through literally per RFC 6533 Section 3, got: {:?}",
String::from_utf8_lossy(output)
);
}
#[test]
fn orcpt_utf8_hex_encodes_backslash() {
let mut buf = BytesMut::new();
let params = RcptToParams {
orcpt: Some("user\\name@example.日本".into()),
..Default::default()
};
encode_rcpt_to_full(&mut buf, &fp("user@example.com"), ¶ms).unwrap();
let output = String::from_utf8_lossy(&buf);
assert!(
output.contains("+5C"),
"backslash must be hex-encoded as +5C per RFC 6533 Section 3 QCHAR, got: {output}"
);
let after_semicolon = output.split(';').nth(1).unwrap_or("");
assert!(
!after_semicolon.contains('\\'),
"literal backslash must not appear in utf-8-addr-xtext, got: {output}"
);
}
#[test]
fn orcpt_utf8_hex_encodes_plus_and_equals() {
let mut buf = BytesMut::new();
let params = RcptToParams {
orcpt: Some("user+tag=val@example.日本".into()),
..Default::default()
};
encode_rcpt_to_full(&mut buf, &fp("user@example.com"), ¶ms).unwrap();
let output = String::from_utf8_lossy(&buf);
assert!(
output.contains("+2B"),
"'+' must be hex-encoded as +2B per RFC 6533 Section 3 QCHAR, got: {output}"
);
assert!(
output.contains("+3D"),
"'=' must be hex-encoded as +3D per RFC 6533 Section 3 QCHAR, got: {output}"
);
}
#[test]
fn encode_utf8_addr_xtext_full_coverage() {
let mut buf = BytesMut::new();
encode_utf8_addr_xtext(&mut buf, "abc\\+=@日本");
assert_eq!(
std::str::from_utf8(&buf).unwrap(),
"abc+5C+2B+3D@日本",
"utf-8-addr-xtext must encode \\, +, = and pass through multibyte UTF-8"
);
}
#[test]
fn encode_utf8_addr_xtext_control_chars() {
let mut buf = BytesMut::new();
encode_utf8_addr_xtext(&mut buf, "a b\x00\x7f");
assert_eq!(
&buf[..],
b"a+20b+00+7F",
"SP, NUL, DEL must be hex-encoded per RFC 6533 Section 3"
);
}
#[test]
fn encode_mail_from_auth_empty() {
use crate::types::SmtpAuthParam;
let mut buf = BytesMut::new();
let params = MailFromParams {
auth: Some(SmtpAuthParam::Empty),
..Default::default()
};
encode_mail_from_full(&mut buf, &rp("sender@example.com"), ¶ms).unwrap();
assert_eq!(&buf[..], b"MAIL FROM:<sender@example.com> AUTH=<>\r\n");
}
#[test]
fn encode_mail_from_auth_rejects_empty_mailbox() {
assert!(
Mailbox::new("").is_err(),
"MAIL FROM AUTH= with an empty mailbox must be rejected (RFC 4954 Section 8)"
);
}
#[test]
fn encode_mail_from_auth_rejects_name_addr_form() {
assert!(
Mailbox::new("Submitter <user@example.com>").is_err(),
"MAIL FROM AUTH= must reject name-addr values and accept only mailbox syntax \
(RFC 4954 Section 5 / RFC 5321 Section 4.1.2)"
);
}
#[test]
fn encode_mail_from_auth_rejects_non_ascii_mailbox() {
use crate::types::SmtpAuthParam;
let mut buf = BytesMut::new();
let params = MailFromParams {
auth: Some(SmtpAuthParam::Mailbox(
Mailbox::new("pelé@example.com").unwrap(),
)),
..Default::default()
};
let result = encode_mail_from_full(&mut buf, &rp("sender@example.com"), ¶ms);
assert!(
result.is_err(),
"MAIL FROM AUTH= must reject non-ASCII mailbox identities \
(RFC 4954 Section 5 / RFC 5321 Section 4.1.2)"
);
}
#[test]
fn mail_from_line_limit_matches_registered_extension_budget() {
assert!(
validate_mail_from_line_length(1252).is_ok(),
"MAIL FROM at the RFC-extended 1252-octet limit must pass"
);
assert!(
validate_mail_from_line_length(1253).is_err(),
"MAIL FROM above the RFC-extended 1252-octet limit must fail"
);
}
#[allow(clippy::expect_used)]
mod prop_roundtrip {
use super::*;
use crate::codec::decode;
use crate::types::{AuthMechanism, EnhancedStatusCode, SmtpExtension, SmtpResponse};
use proptest::prelude::*;
fn arb_dns_label() -> impl Strategy<Value = String> {
prop::string::string_regex("[a-zA-Z0-9]([a-zA-Z0-9-]{0,10}[a-zA-Z0-9])?")
.expect("valid regex")
}
fn arb_domain() -> impl Strategy<Value = String> {
prop::collection::vec(arb_dns_label(), 1..=3)
.prop_map(|labels| labels.join("."))
.prop_filter("domain ≤ 255 octets", |d| d.len() <= 255)
}
fn arb_ipv4_literal() -> impl Strategy<Value = String> {
(any::<u8>(), any::<u8>(), any::<u8>(), any::<u8>())
.prop_map(|(a, b, c, d)| format!("[{a}.{b}.{c}.{d}]"))
}
fn arb_ipv6_literal() -> impl Strategy<Value = String> {
any::<u128>().prop_map(|n| {
let addr = std::net::Ipv6Addr::from(n);
format!("[IPv6:{addr}]")
})
}
fn arb_domain_or_literal() -> impl Strategy<Value = String> {
prop_oneof![arb_domain(), arb_ipv4_literal(), arb_ipv6_literal(),]
}
fn arb_local_part() -> impl Strategy<Value = String> {
prop::string::string_regex("[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]{1,30}")
.expect("valid regex")
.prop_filter("local-part ≤ 64 octets", |lp| lp.len() <= 64)
}
fn arb_mailbox() -> impl Strategy<Value = String> {
(arb_local_part(), arb_domain())
.prop_map(|(lp, dom)| format!("{lp}@{dom}"))
.prop_filter("mailbox ≤ 254 octets", |m| m.len() <= 254)
}
fn arb_envid() -> impl Strategy<Value = String> {
prop::string::string_regex("[!-~]{1,50}").expect("valid regex")
}
fn arb_query_string() -> impl Strategy<Value = String> {
prop::string::string_regex("[a-zA-Z0-9.!#$%&'*+/=?^_-]{1,50}").expect("valid regex")
}
fn dot_unstuff(data: &[u8]) -> Vec<u8> {
let mut result = Vec::with_capacity(data.len());
let mut at_line_start = true;
let mut prev_cr = false;
let mut skip_next = false;
for &byte in data {
if skip_next {
skip_next = false;
result.push(byte);
at_line_start = byte == b'\n' && prev_cr;
prev_cr = byte == b'\r';
continue;
}
if at_line_start && byte == b'.' {
skip_next = true;
at_line_start = false;
prev_cr = false;
continue;
}
result.push(byte);
at_line_start = byte == b'\n' && prev_cr;
prev_cr = byte == b'\r';
}
result
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(500))]
#[test]
fn dot_stuff_roundtrip(data in prop::collection::vec(any::<u8>(), 0..1000)) {
let stuffed = dot_stuff(&data);
let unstuffed = dot_unstuff(&stuffed);
prop_assert_eq!(
&unstuffed, &data,
"dot_unstuff(dot_stuff(data)) must equal data"
);
}
#[test]
fn dot_stuff_no_false_terminator(data in prop::collection::vec(any::<u8>(), 0..500)) {
let stuffed = dot_stuff(&data);
let mut at_line_start = true;
let mut prev_cr = false;
for (i, &byte) in stuffed.iter().enumerate() {
if at_line_start && byte == b'.' {
if i + 1 < stuffed.len() {
let next = stuffed[i + 1];
prop_assert_ne!(
next, b'\r',
"stuffed output must not contain a bare dot line \
(would be mistaken for end-of-data)"
);
}
}
at_line_start = byte == b'\n' && prev_cr;
prev_cr = byte == b'\r';
}
}
#[test]
fn dot_stuff_size_matches_len(data in prop::collection::vec(any::<u8>(), 0..500)) {
prop_assert_eq!(
dot_stuff_size(&data),
dot_stuff(&data).len(),
"dot_stuff_size must match dot_stuff().len()"
);
}
}
fn decode_xtext(encoded: &[u8]) -> Option<Vec<u8>> {
let mut result = Vec::with_capacity(encoded.len());
let mut i = 0;
while i < encoded.len() {
if encoded[i] == b'+' {
if i + 2 >= encoded.len() {
return None;
}
let hi = char::from(encoded[i + 1]).to_digit(16)?;
let lo = char::from(encoded[i + 2]).to_digit(16)?;
#[allow(clippy::cast_possible_truncation)]
result.push((hi * 16 + lo) as u8);
i += 3;
} else {
result.push(encoded[i]);
i += 1;
}
}
Some(result)
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(500))]
#[test]
fn xtext_roundtrip(s in "[!-~]{0,80}") {
let mut buf = BytesMut::new();
encode_xtext(&mut buf, &s);
let decoded = decode_xtext(&buf)
.expect("xtext decode must succeed on encode output");
prop_assert_eq!(
&decoded, s.as_bytes(),
"decode_xtext(encode_xtext(s)) must equal s"
);
}
#[test]
fn xtext_output_valid(s in "[!-~]{0,80}") {
let mut buf = BytesMut::new();
encode_xtext(&mut buf, &s);
let mut i = 0;
while i < buf.len() {
let b = buf[i];
if b == b'+' {
prop_assert!(i + 2 < buf.len(), "truncated hexchar at {i}");
prop_assert!(
buf[i + 1].is_ascii_hexdigit() && buf[i + 2].is_ascii_hexdigit(),
"invalid hexchar at {i}: {:?}",
&buf[i..i + 3]
);
i += 3;
} else {
prop_assert!(
(0x21..=0x2A).contains(&b)
|| (0x2C..=0x3C).contains(&b)
|| (0x3E..=0x7E).contains(&b),
"byte {b:#04x} at {i} is not a valid xchar"
);
i += 1;
}
}
}
}
fn arb_extension() -> impl Strategy<Value = SmtpExtension> {
prop_oneof![
Just(SmtpExtension::EightBitMime),
Just(SmtpExtension::Pipelining),
prop_oneof![
Just(SmtpExtension::Size(None)),
any::<u32>().prop_map(|n| SmtpExtension::Size(Some(u64::from(n)))),
],
Just(SmtpExtension::StartTls),
prop::collection::vec(
prop_oneof![
Just(AuthMechanism::Plain),
Just(AuthMechanism::Login),
Just(AuthMechanism::OAuthBearer),
Just(AuthMechanism::XOAuth2),
],
1..=3
)
.prop_map(SmtpExtension::Auth),
Just(SmtpExtension::Chunking),
Just(SmtpExtension::BinaryMime),
Just(SmtpExtension::SmtpUtf8),
Just(SmtpExtension::EnhancedStatusCodes),
Just(SmtpExtension::Dsn),
Just(SmtpExtension::RequireTls),
Just(SmtpExtension::MtPriority),
Just(SmtpExtension::Vrfy),
Just(SmtpExtension::Expn),
prop_oneof![
Just(SmtpExtension::DeliverBy(None)),
(0_u64..=999_999_999_u64).prop_map(|n| SmtpExtension::DeliverBy(Some(n))),
],
]
}
fn format_extension(ext: &SmtpExtension) -> Option<String> {
Some(match ext {
SmtpExtension::EightBitMime => "8BITMIME".into(),
SmtpExtension::Pipelining => "PIPELINING".into(),
SmtpExtension::Size(None) => "SIZE".into(),
SmtpExtension::Size(Some(n)) => format!("SIZE {n}"),
SmtpExtension::StartTls => "STARTTLS".into(),
SmtpExtension::Auth(mechs) => {
let names: Vec<&str> = mechs
.iter()
.map(|m| match m {
AuthMechanism::Plain => "PLAIN",
AuthMechanism::Login => "LOGIN",
AuthMechanism::OAuthBearer => "OAUTHBEARER",
AuthMechanism::XOAuth2 => "XOAUTH2",
AuthMechanism::Other(s) => s.as_str(),
})
.collect();
format!("AUTH {}", names.join(" "))
}
SmtpExtension::Chunking => "CHUNKING".into(),
SmtpExtension::BinaryMime => "BINARYMIME".into(),
SmtpExtension::SmtpUtf8 => "SMTPUTF8".into(),
SmtpExtension::EnhancedStatusCodes => "ENHANCEDSTATUSCODES".into(),
SmtpExtension::Dsn => "DSN".into(),
SmtpExtension::RequireTls => "REQUIRETLS".into(),
SmtpExtension::MtPriority => "MT-PRIORITY".into(),
SmtpExtension::Vrfy => "VRFY".into(),
SmtpExtension::Expn => "EXPN".into(),
SmtpExtension::DeliverBy(None) => "DELIVERBY".into(),
SmtpExtension::DeliverBy(Some(n)) => format!("DELIVERBY {n}"),
_ => return None,
})
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(200))]
#[test]
fn ehlo_capability_roundtrip(
greeting in arb_domain(),
extensions in prop::collection::vec(arb_extension(), 0..=5)
.prop_map(|exts| {
let mut seen_auth = false;
let mut seen_kinds = std::collections::HashSet::new();
exts.into_iter().filter(|ext| {
let kind = std::mem::discriminant(ext);
if matches!(ext, SmtpExtension::Auth(_)) {
if seen_auth { return false; }
seen_auth = true;
return true;
}
seen_kinds.insert(kind)
}).collect::<Vec<_>>()
})
) {
let mut lines = Vec::new();
lines.push(greeting.clone());
for ext in &extensions {
if let Some(line) = format_extension(ext) {
lines.push(line);
}
}
let response = SmtpResponse {
code: 250,
enhanced_code: None,
lines,
};
let caps = decode::parse_ehlo_capabilities(&response);
prop_assert_eq!(&caps.greeting_name, &greeting);
for ext in &extensions {
#[allow(clippy::match_same_arms)]
let found = caps.extensions.iter().any(|parsed| {
match (parsed, ext) {
(SmtpExtension::EightBitMime, SmtpExtension::EightBitMime)
| (SmtpExtension::Pipelining, SmtpExtension::Pipelining)
| (SmtpExtension::StartTls, SmtpExtension::StartTls)
| (SmtpExtension::Chunking, SmtpExtension::Chunking)
| (SmtpExtension::BinaryMime, SmtpExtension::BinaryMime)
| (SmtpExtension::SmtpUtf8, SmtpExtension::SmtpUtf8)
| (SmtpExtension::EnhancedStatusCodes, SmtpExtension::EnhancedStatusCodes)
| (SmtpExtension::Dsn, SmtpExtension::Dsn)
| (SmtpExtension::RequireTls, SmtpExtension::RequireTls)
| (SmtpExtension::MtPriority, SmtpExtension::MtPriority)
| (SmtpExtension::Vrfy, SmtpExtension::Vrfy)
| (SmtpExtension::Expn, SmtpExtension::Expn) => true,
(SmtpExtension::Size(a), SmtpExtension::Size(b)) => a == b,
(SmtpExtension::Auth(a), SmtpExtension::Auth(b)) => {
a.len() == b.len()
&& a.iter().zip(b.iter()).all(|(x, y)| x.eq_mechanism(y))
}
(SmtpExtension::DeliverBy(a), SmtpExtension::DeliverBy(b)) => a == b,
_ => false,
}
});
prop_assert!(
found,
"extension {:?} not found in parsed capabilities: {:?}",
ext, caps.extensions
);
}
}
}
fn format_response(resp: &SmtpResponse) -> Vec<u8> {
let mut out = Vec::new();
let code = resp.code;
let enhanced = resp
.enhanced_code
.map(|e| format!("{}.{}.{} ", e.class, e.subject, e.detail))
.unwrap_or_default();
for (i, line) in resp.lines.iter().enumerate() {
let sep = if i + 1 < resp.lines.len() { '-' } else { ' ' };
out.extend_from_slice(format!("{code}{sep}{enhanced}{line}\r\n").as_bytes());
}
if resp.lines.is_empty() {
out.extend_from_slice(format!("{code} {enhanced}\r\n").as_bytes());
}
out
}
fn arb_response_text() -> impl Strategy<Value = String> {
prop::string::string_regex("[a-zA-Z0-9 _.,-]{0,60}").expect("valid regex")
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(300))]
#[test]
fn smtp_response_roundtrip(
code in prop_oneof![
200u16..=299,
400u16..=499,
500u16..=599,
],
has_enhanced in any::<bool>(),
subject in 0..=99u16,
detail in 0..=99u16,
lines in prop::collection::vec(arb_response_text(), 1..=3),
) {
let enhanced = if has_enhanced {
#[allow(clippy::cast_possible_truncation)]
let class = (code / 100) as u8;
Some(EnhancedStatusCode { class, subject, detail })
} else {
None
};
let original = SmtpResponse {
code,
enhanced_code: enhanced,
lines,
};
let wire = format_response(&original);
let parsed = decode::parse_response(&wire);
match parsed {
Ok((remaining, resp)) => {
prop_assert!(
remaining.is_empty(),
"parser left unconsumed bytes: {:?}",
remaining
);
prop_assert_eq!(resp.code, original.code, "reply code mismatch");
prop_assert_eq!(
resp.enhanced_code, original.enhanced_code,
"enhanced code mismatch"
);
prop_assert_eq!(
resp.lines.len(),
original.lines.len(),
"line count mismatch"
);
for (idx, (parsed_line, orig_line)) in
resp.lines.iter().zip(original.lines.iter()).enumerate()
{
prop_assert!(
parsed_line == orig_line,
"line {} mismatch: {:?} != {:?}",
idx, parsed_line, orig_line
);
}
}
Err(e) => {
prop_assert!(
false,
"parse_response failed on formatted input: {e:?}\nwire: {:?}",
String::from_utf8_lossy(&wire)
);
}
}
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(300))]
#[test]
fn ehlo_well_formed(domain in arb_domain_or_literal()) {
let Ok(dol) = DomainOrLiteral::new(&domain) else { return Ok(()); };
let mut buf = BytesMut::new();
if matches!(encode_ehlo(&mut buf, &dol), Ok(())) {
let bytes = &buf[..];
prop_assert!(
bytes.ends_with(b"\r\n"),
"EHLO must end with CRLF"
);
prop_assert!(
bytes.starts_with(b"EHLO "),
"EHLO must start with 'EHLO '"
);
let body = &bytes[..bytes.len() - 2];
prop_assert!(
!body.windows(2).any(|w| w == b"\r\n"),
"EHLO must not contain internal CRLF"
);
prop_assert!(
bytes.len() <= 512,
"EHLO line must be ≤ 512 octets, got {}",
bytes.len()
);
}
}
#[test]
fn mail_from_well_formed(address in arb_mailbox()) {
let Ok(reverse_path) = ReversePath::new(&address) else { return Ok(()); };
let mut buf = BytesMut::new();
if matches!(encode_mail_from(&mut buf, &reverse_path, None), Ok(())) {
let bytes = &buf[..];
prop_assert!(
bytes.ends_with(b"\r\n"),
"MAIL FROM must end with CRLF"
);
prop_assert!(
bytes.starts_with(b"MAIL FROM:<"),
"MAIL FROM must start with 'MAIL FROM:<'"
);
let body = &bytes[..bytes.len() - 2];
prop_assert!(
!body.windows(2).any(|w| w == b"\r\n"),
"MAIL FROM must not contain internal CRLF"
);
}
}
#[test]
fn mail_from_null_path_well_formed(_dummy in Just(())) {
let mut buf = BytesMut::new();
encode_mail_from(&mut buf, &rp(""), None).unwrap();
prop_assert_eq!(&buf[..], b"MAIL FROM:<>\r\n");
}
#[test]
fn rcpt_to_well_formed(address in arb_mailbox()) {
let Ok(forward_path) = ForwardPath::new(&address) else { return Ok(()); };
let mut buf = BytesMut::new();
if matches!(encode_rcpt_to(&mut buf, &forward_path), Ok(())) {
let bytes = &buf[..];
prop_assert!(
bytes.ends_with(b"\r\n"),
"RCPT TO must end with CRLF"
);
prop_assert!(
bytes.starts_with(b"RCPT TO:<"),
"RCPT TO must start with 'RCPT TO:<'"
);
let line = std::str::from_utf8(bytes).unwrap();
prop_assert!(
line.contains(&format!("<{address}>")),
"RCPT TO must contain the address in angle brackets"
);
}
}
#[test]
fn vrfy_well_formed(query in arb_query_string()) {
let mut buf = BytesMut::new();
if matches!(encode_vrfy(&mut buf, &query), Ok(())) {
let bytes = &buf[..];
prop_assert!(
bytes.ends_with(b"\r\n"),
"VRFY must end with CRLF"
);
prop_assert!(
bytes.starts_with(b"VRFY "),
"VRFY must start with 'VRFY '"
);
prop_assert!(
bytes.len() <= 512,
"VRFY line must be ≤ 512 octets"
);
}
}
#[test]
fn expn_well_formed(query in arb_query_string()) {
let mut buf = BytesMut::new();
if matches!(encode_expn(&mut buf, &query), Ok(())) {
let bytes = &buf[..];
prop_assert!(
bytes.ends_with(b"\r\n"),
"EXPN must end with CRLF"
);
prop_assert!(
bytes.starts_with(b"EXPN "),
"EXPN must start with 'EXPN '"
);
prop_assert!(
bytes.len() <= 512,
"EXPN line must be ≤ 512 octets"
);
}
}
#[test]
fn mail_from_full_well_formed(
address in arb_mailbox(),
size in proptest::option::of(0u64..1_000_000),
body in proptest::option::of(prop_oneof![
Just(BodyType::SevenBit),
Just(BodyType::EightBitMime),
Just(BodyType::BinaryMime),
]),
ret in proptest::option::of(prop_oneof![
Just(DsnRet::Full),
Just(DsnRet::Hdrs),
]),
envid in proptest::option::of(arb_envid()),
mt_priority in proptest::option::of(-9i8..=9),
) {
let Ok(reverse_path) = ReversePath::new(&address) else { return Ok(()); };
let envid = envid.and_then(|s| EnvidValue::new(s).ok());
let params = MailFromParams {
size,
body,
ret,
envid,
mt_priority,
..Default::default()
};
let mut buf = BytesMut::new();
if matches!(encode_mail_from_full(&mut buf, &reverse_path, ¶ms), Ok(())) {
let bytes = &buf[..];
prop_assert!(
bytes.ends_with(b"\r\n"),
"MAIL FROM must end with CRLF"
);
prop_assert!(
bytes.starts_with(b"MAIL FROM:<"),
"MAIL FROM must start with 'MAIL FROM:<'"
);
let body_bytes = &bytes[..bytes.len() - 2];
prop_assert!(
!body_bytes.windows(2).any(|w| w == b"\r\n"),
"MAIL FROM must not contain internal CRLF"
);
if let Some(s) = size {
let line = std::str::from_utf8(bytes).unwrap();
prop_assert!(
line.contains(&format!("SIZE={s}")),
"SIZE parameter must appear in output"
);
}
}
}
#[test]
fn rcpt_to_full_well_formed(
address in arb_mailbox(),
notify in proptest::option::of(prop::collection::vec(
prop_oneof![
Just(DsnNotify::Success),
Just(DsnNotify::Failure),
Just(DsnNotify::Delay),
],
1..=3
)),
orcpt in proptest::option::of(arb_mailbox()),
) {
let Ok(forward_path) = ForwardPath::new(&address) else { return Ok(()); };
let params = RcptToParams {
notify,
orcpt,
};
let mut buf = BytesMut::new();
if matches!(encode_rcpt_to_full(&mut buf, &forward_path, ¶ms), Ok(())) {
let bytes = &buf[..];
prop_assert!(
bytes.ends_with(b"\r\n"),
"RCPT TO must end with CRLF"
);
prop_assert!(
bytes.starts_with(b"RCPT TO:<"),
"RCPT TO must start with 'RCPT TO:<'"
);
}
}
#[test]
fn helo_well_formed(domain in arb_domain()) {
let Ok(dol) = DomainOrLiteral::new(&domain) else { return Ok(()); };
let mut buf = BytesMut::new();
if matches!(encode_helo(&mut buf, &dol), Ok(())) {
let bytes = &buf[..];
prop_assert!(
bytes.starts_with(b"HELO "),
"HELO must start with 'HELO '"
);
prop_assert!(
bytes.ends_with(b"\r\n"),
"HELO must end with CRLF"
);
}
}
#[test]
fn lhlo_well_formed(domain in arb_domain()) {
let Ok(dol) = DomainOrLiteral::new(&domain) else { return Ok(()); };
let mut buf = BytesMut::new();
if matches!(encode_lhlo(&mut buf, &dol), Ok(())) {
let bytes = &buf[..];
prop_assert!(
bytes.starts_with(b"LHLO "),
"LHLO must start with 'LHLO '"
);
prop_assert!(
bytes.ends_with(b"\r\n"),
"LHLO must end with CRLF"
);
}
}
}
}