#![allow(
// These pedantic lints are useful in production code but produce a lot
// of noise in test fixtures, where short scripts and explicit byte
// literals are the norm.
clippy::needless_pass_by_value,
clippy::similar_names,
clippy::too_many_lines,
clippy::unreadable_literal,
clippy::missing_panics_doc
)]
mod harness {
use crate::error::IoError;
use crate::transport::{StartTlsCapable, Transport};
use core::future::Future;
use core::pin::pin;
use core::task::{Context, Poll, Waker};
use std::cell::RefCell;
use std::collections::VecDeque;
use std::rc::Rc;
#[derive(Debug, Clone)]
pub enum UpgradeBehavior {
Succeed,
Fail(&'static str),
}
pub fn block_on<F: Future>(fut: F) -> F::Output {
let waker = Waker::noop();
let mut cx = Context::from_waker(waker);
let mut fut = pin!(fut);
match fut.as_mut().poll(&mut cx) {
Poll::Ready(value) => value,
Poll::Pending => panic!("mock-driven future returned Pending"),
}
}
pub type MockHandles = (MockTransport, Rc<RefCell<Vec<u8>>>, Rc<RefCell<bool>>);
pub type MockStartTlsHandles = (
MockTransport,
Rc<RefCell<Vec<u8>>>,
Rc<RefCell<bool>>,
Rc<RefCell<u32>>,
);
pub struct MockTransport {
incoming: VecDeque<Vec<u8>>,
written: Rc<RefCell<Vec<u8>>>,
closed: Rc<RefCell<bool>>,
upgrades: Rc<RefCell<u32>>,
upgrade_behavior: UpgradeBehavior,
}
impl MockTransport {
pub fn new(chunks: &[&[u8]]) -> MockHandles {
let (t, w, c, _u) = Self::build(chunks, UpgradeBehavior::Succeed);
(t, w, c)
}
pub fn with_starttls(chunks: &[&[u8]], behavior: UpgradeBehavior) -> MockStartTlsHandles {
Self::build(chunks, behavior)
}
fn build(chunks: &[&[u8]], behavior: UpgradeBehavior) -> MockStartTlsHandles {
let written = Rc::new(RefCell::new(Vec::new()));
let closed = Rc::new(RefCell::new(false));
let upgrades = Rc::new(RefCell::new(0u32));
let mut q: VecDeque<Vec<u8>> = VecDeque::new();
for c in chunks {
q.push_back((*c).to_vec());
}
(
Self {
incoming: q,
written: Rc::clone(&written),
closed: Rc::clone(&closed),
upgrades: Rc::clone(&upgrades),
upgrade_behavior: behavior,
},
written,
closed,
upgrades,
)
}
}
impl Transport for MockTransport {
async fn read(&mut self, buf: &mut [u8]) -> Result<usize, IoError> {
let Some(chunk) = self.incoming.front_mut() else {
return Ok(0);
};
let n = buf.len().min(chunk.len());
buf[..n].copy_from_slice(&chunk[..n]);
chunk.drain(..n);
if chunk.is_empty() {
self.incoming.pop_front();
}
Ok(n)
}
async fn write_all(&mut self, buf: &[u8]) -> Result<(), IoError> {
self.written.borrow_mut().extend_from_slice(buf);
Ok(())
}
async fn close(&mut self) -> Result<(), IoError> {
*self.closed.borrow_mut() = true;
Ok(())
}
}
impl StartTlsCapable for MockTransport {
async fn upgrade_to_tls(&mut self) -> Result<(), IoError> {
*self.upgrades.borrow_mut() += 1;
match &self.upgrade_behavior {
UpgradeBehavior::Succeed => Ok(()),
UpgradeBehavior::Fail(msg) => Err(IoError::new(*msg)),
}
}
}
pub fn flatten(parts: &[&[u8]]) -> Vec<u8> {
let mut v = Vec::new();
for p in parts {
v.extend_from_slice(p);
}
v
}
}
mod protocol_tests {
use crate::error::ProtocolError;
use crate::protocol::{
AuthMechanism, Reply, base64_encode, build_auth_plain_initial_response,
dot_stuff_and_terminate, ehlo_advertises_auth, ehlo_advertises_enhanced_status_codes,
ehlo_advertises_starttls, format_command, format_command_arg, format_mail_from,
format_rcpt_to, parse_reply_line, select_auth_mechanism, validate_address,
validate_ehlo_domain, validate_login_password, validate_login_username,
validate_plain_password, validate_plain_username,
};
#[cfg(feature = "xoauth2")]
use crate::protocol::{
build_xoauth2_initial_response, validate_oauth2_token, validate_xoauth2_user,
};
#[test]
fn parse_reply_line_single_line() {
let r = parse_reply_line(b"250 OK").expect("must parse");
assert_eq!(r.code, 250);
assert!(r.is_last);
assert_eq!(r.text, b"OK");
}
#[test]
fn parse_reply_line_continuation() {
let r = parse_reply_line(b"250-mail.example.com Hello").expect("must parse");
assert_eq!(r.code, 250);
assert!(!r.is_last);
assert_eq!(r.text, b"mail.example.com Hello");
}
#[test]
fn parse_reply_line_three_digit_only_is_last() {
let r = parse_reply_line(b"220").expect("must parse");
assert_eq!(r.code, 220);
assert!(r.is_last);
assert_eq!(r.text, b"");
}
#[test]
fn parse_reply_line_separator_with_empty_text() {
let r = parse_reply_line(b"250 ").expect("must parse");
assert_eq!(r.code, 250);
assert!(r.is_last);
assert_eq!(r.text, b"");
}
#[test]
fn parse_reply_line_too_short() {
assert!(matches!(
parse_reply_line(b""),
Err(ProtocolError::Malformed(_))
));
assert!(matches!(
parse_reply_line(b"22"),
Err(ProtocolError::Malformed(_))
));
}
#[test]
fn parse_reply_line_non_digit_code() {
assert!(matches!(
parse_reply_line(b"abc OK"),
Err(ProtocolError::Malformed(_))
));
assert!(matches!(
parse_reply_line(b"2x0 OK"),
Err(ProtocolError::Malformed(_))
));
}
#[test]
fn parse_reply_line_invalid_separator() {
assert!(matches!(
parse_reply_line(b"250?Something"),
Err(ProtocolError::Malformed(_))
));
assert!(matches!(
parse_reply_line(b"250\tSomething"),
Err(ProtocolError::Malformed(_))
));
}
#[test]
fn reply_class_and_joined_text() {
let r = Reply::new(451, vec!["temporary".into(), "failure".into()]);
assert_eq!(r.class(), 4);
assert_eq!(r.joined_text(), "temporary\nfailure");
let collected: Vec<&str> = r.iter_lines().collect();
assert_eq!(collected, vec!["temporary", "failure"]);
}
#[test]
fn format_command_basic() {
assert_eq!(format_command("QUIT"), b"QUIT\r\n");
assert_eq!(format_command("RSET"), b"RSET\r\n");
assert_eq!(format_command("DATA"), b"DATA\r\n");
}
#[test]
fn format_command_arg_basic() {
assert_eq!(
format_command_arg("EHLO", "client.example.com"),
b"EHLO client.example.com\r\n"
);
}
#[test]
fn format_mail_from_wraps_in_brackets() {
assert_eq!(
format_mail_from("user@example.com"),
b"MAIL FROM:<user@example.com>\r\n"
);
}
#[test]
fn format_rcpt_to_wraps_in_brackets() {
assert_eq!(
format_rcpt_to("recipient@example.org"),
b"RCPT TO:<recipient@example.org>\r\n"
);
}
#[test]
fn dot_stuff_simple_body() {
let out = dot_stuff_and_terminate(b"Hello world");
assert_eq!(out, b"Hello world\r\n.\r\n");
}
#[test]
fn dot_stuff_already_crlf_terminated() {
let out = dot_stuff_and_terminate(b"Hello\r\n");
assert_eq!(out, b"Hello\r\n.\r\n");
}
#[test]
fn dot_stuff_dot_at_first_byte() {
let out = dot_stuff_and_terminate(b".dotted");
assert_eq!(out, b"..dotted\r\n.\r\n");
}
#[test]
fn dot_stuff_dot_after_crlf() {
let out = dot_stuff_and_terminate(b"first\r\n.second\r\n");
assert_eq!(out, b"first\r\n..second\r\n.\r\n");
}
#[test]
fn dot_stuff_dot_only_line() {
let out = dot_stuff_and_terminate(b".\r\n");
assert_eq!(out, b"..\r\n.\r\n");
}
#[test]
fn dot_stuff_dot_inside_line_not_stuffed() {
let out = dot_stuff_and_terminate(b"a.b\r\n");
assert_eq!(out, b"a.b\r\n.\r\n");
}
#[test]
fn dot_stuff_multiple_consecutive_dot_lines() {
let out = dot_stuff_and_terminate(b".a\r\n.b\r\n.c\r\n");
assert_eq!(out, b"..a\r\n..b\r\n..c\r\n.\r\n");
}
#[test]
fn dot_stuff_double_dot_only_first_is_at_line_start() {
let out = dot_stuff_and_terminate(b"..line\r\n");
assert_eq!(out, b"...line\r\n.\r\n");
}
#[test]
fn dot_stuff_empty_body() {
let out = dot_stuff_and_terminate(b"");
assert_eq!(out, b"\r\n.\r\n");
}
#[test]
fn dot_stuff_terminator_pattern_inside_body_is_stuffed() {
let out = dot_stuff_and_terminate(b"line\r\n.\r\nmore\r\n");
assert_eq!(out, b"line\r\n..\r\nmore\r\n.\r\n");
}
#[test]
fn base64_encode_rfc4648_vectors() {
assert_eq!(base64_encode(b""), "");
assert_eq!(base64_encode(b"f"), "Zg==");
assert_eq!(base64_encode(b"fo"), "Zm8=");
assert_eq!(base64_encode(b"foo"), "Zm9v");
assert_eq!(base64_encode(b"foob"), "Zm9vYg==");
assert_eq!(base64_encode(b"fooba"), "Zm9vYmE=");
assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
}
#[test]
fn base64_encode_auth_login_canonical_examples() {
assert_eq!(base64_encode(b"user"), "dXNlcg==");
assert_eq!(base64_encode(b"pass"), "cGFzcw==");
assert_eq!(base64_encode(b"Username:"), "VXNlcm5hbWU6");
assert_eq!(base64_encode(b"Password:"), "UGFzc3dvcmQ6");
}
#[test]
fn base64_encode_handles_high_bytes() {
let out = base64_encode(&[0xFF, 0x00, 0xAA]);
assert_eq!(out, "/wCq");
}
#[test]
fn validate_address_accepts_simple() {
assert!(validate_address("a@b.com").is_ok());
assert!(validate_address("first.last+tag@example.co.jp").is_ok());
}
#[test]
fn validate_address_rejects_empty() {
assert!(validate_address("").is_err());
}
#[test]
fn validate_address_rejects_crlf_injection() {
assert!(validate_address("a\r\n@b.com").is_err());
assert!(validate_address("a@b.com\r").is_err());
assert!(validate_address("a@b.com\n").is_err());
assert!(validate_address("a@b.com\r\nRSET").is_err());
}
#[test]
fn validate_address_rejects_brackets() {
assert!(validate_address("<a@b.com>").is_err());
assert!(validate_address("a@b<.com").is_err());
}
#[test]
fn validate_address_rejects_whitespace() {
assert!(validate_address("a @b.com").is_err());
assert!(validate_address("a@b.com ").is_err());
assert!(validate_address("a\tb@c.com").is_err());
}
#[test]
fn validate_address_rejects_non_ascii() {
assert!(validate_address("\u{30E6}\u{30FC}\u{30B6}@example.com").is_err());
}
#[test]
fn validate_address_rejects_nul() {
assert!(validate_address("a\0b@example.com").is_err());
}
#[test]
fn validate_ehlo_domain_accepts_fqdn_and_address_literal() {
assert!(validate_ehlo_domain("client.example.com").is_ok());
assert!(validate_ehlo_domain("[192.0.2.1]").is_ok());
assert!(validate_ehlo_domain("[IPv6:2001:db8::1]").is_ok());
}
#[test]
fn validate_ehlo_domain_rejects_empty() {
assert!(validate_ehlo_domain("").is_err());
}
#[test]
fn validate_ehlo_domain_rejects_whitespace_and_crlf() {
assert!(validate_ehlo_domain("client example com").is_err());
assert!(validate_ehlo_domain("client.example.com\r\nRSET").is_err());
}
#[test]
fn validate_ehlo_domain_rejects_non_ascii() {
assert!(validate_ehlo_domain("\u{4F8B}.example").is_err());
}
#[test]
fn validate_login_credentials_reject_empty() {
assert!(validate_login_username("").is_err());
assert!(validate_login_password("").is_err());
assert!(validate_login_username("user").is_ok());
assert!(validate_login_password("pass").is_ok());
}
#[test]
fn ehlo_advertises_auth_finds_listed_mechanisms() {
let lines: Vec<String> = vec![
"PIPELINING".into(),
"AUTH LOGIN PLAIN".into(),
"8BITMIME".into(),
];
assert!(ehlo_advertises_auth(&lines, "LOGIN"));
assert!(ehlo_advertises_auth(&lines, "PLAIN"));
assert!(!ehlo_advertises_auth(&lines, "CRAM-MD5"));
}
#[test]
fn ehlo_advertises_auth_is_case_insensitive() {
let lines: Vec<String> = vec!["auth login".into()];
assert!(ehlo_advertises_auth(&lines, "LOGIN"));
assert!(ehlo_advertises_auth(&lines, "login"));
}
#[test]
fn ehlo_advertises_auth_no_auth_line_means_false() {
let lines: Vec<String> = vec!["PIPELINING".into(), "8BITMIME".into()];
assert!(!ehlo_advertises_auth(&lines, "LOGIN"));
}
#[test]
fn ehlo_advertises_starttls_finds_listed_extension() {
let lines: Vec<String> = vec!["PIPELINING".into(), "STARTTLS".into(), "8BITMIME".into()];
assert!(ehlo_advertises_starttls(&lines));
}
#[test]
fn ehlo_advertises_starttls_is_case_insensitive() {
let lines: Vec<String> = vec!["starttls".into()];
assert!(ehlo_advertises_starttls(&lines));
}
#[test]
fn ehlo_advertises_starttls_returns_false_when_absent() {
let lines: Vec<String> = vec!["PIPELINING".into(), "AUTH PLAIN".into()];
assert!(!ehlo_advertises_starttls(&lines));
}
#[test]
fn ehlo_advertises_starttls_handles_empty_caps() {
let lines: Vec<String> = Vec::new();
assert!(!ehlo_advertises_starttls(&lines));
}
#[test]
fn ehlo_advertises_starttls_does_not_match_substrings() {
let lines: Vec<String> = vec!["STARTTLSPLUS".into()];
assert!(!ehlo_advertises_starttls(&lines));
}
#[test]
fn ehlo_advertises_enhancedstatuscodes_finds_listed_extension() {
let lines: Vec<String> = vec![
"PIPELINING".into(),
"ENHANCEDSTATUSCODES".into(),
"8BITMIME".into(),
];
assert!(ehlo_advertises_enhanced_status_codes(&lines));
}
#[test]
fn ehlo_advertises_enhancedstatuscodes_is_case_insensitive() {
let lines: Vec<String> = vec!["enhancedstatuscodes".into()];
assert!(ehlo_advertises_enhanced_status_codes(&lines));
}
#[test]
fn ehlo_advertises_enhancedstatuscodes_returns_false_when_absent() {
let lines: Vec<String> = vec!["PIPELINING".into(), "AUTH PLAIN".into()];
assert!(!ehlo_advertises_enhanced_status_codes(&lines));
}
#[test]
fn ehlo_advertises_enhancedstatuscodes_does_not_match_substrings() {
let lines: Vec<String> = vec!["ENHANCEDSTATUSCODESPLUS".into()];
assert!(!ehlo_advertises_enhanced_status_codes(&lines));
}
#[test]
fn reply_parses_enhanced_status_basic() {
let reply = Reply::new(550, vec!["5.7.1 relay denied".into()]);
let es = reply.try_parse_enhanced().expect("should parse");
assert_eq!(es.class, 5);
assert_eq!(es.subject, 7);
assert_eq!(es.detail, 1);
assert_eq!(es.to_dotted(), "5.7.1");
assert_eq!(format!("{es}"), "5.7.1");
}
#[test]
fn reply_parses_enhanced_status_class_2_and_4() {
for (class_byte, want) in [(b'2', 2), (b'4', 4), (b'5', 5)] {
let line = format!("{}.0.0 ok", class_byte as char);
let reply = Reply::new(250, vec![line]);
let es = reply.try_parse_enhanced().expect("should parse");
assert_eq!(es.class, want);
}
}
#[test]
fn reply_rejects_invalid_enhanced_class_digits() {
for bad in [b'0', b'1', b'3', b'6', b'9'] {
let line = format!("{}.0.0 something", bad as char);
let reply = Reply::new(250, vec![line]);
assert!(
reply.try_parse_enhanced().is_none(),
"class {} must not parse",
bad as char
);
}
}
#[test]
fn reply_rejects_malformed_enhanced_status() {
for bad in [
"5..1 missing subject",
"5.7. missing detail",
"5-7-1 wrong separator",
"5.7 too short",
"noenhanced text only",
"",
] {
let reply = Reply::new(550, vec![bad.into()]);
assert!(
reply.try_parse_enhanced().is_none(),
"{bad:?} must not parse"
);
}
}
#[test]
fn reply_message_text_strips_enhanced_prefix_when_present() {
let mut reply = Reply::new(550, vec!["5.7.1 relay access denied".into()]);
let es = reply.try_parse_enhanced().unwrap();
reply.attach_enhanced_status(es);
assert_eq!(reply.joined_text(), "5.7.1 relay access denied");
assert_eq!(reply.message_text(), "relay access denied");
}
#[test]
fn reply_message_text_unchanged_without_enhanced() {
let reply = Reply::new(550, vec!["something or other".into()]);
assert_eq!(reply.message_text(), reply.joined_text());
}
#[test]
fn auth_plain_initial_response_canonical_example() {
assert_eq!(
build_auth_plain_initial_response("user", "pass"),
"AHVzZXIAcGFzcw=="
);
}
#[test]
fn auth_plain_initial_response_round_trips_through_base64() {
let user = "alice@example.com";
let pass = "s3cr3t!";
let b64 = build_auth_plain_initial_response(user, pass);
let decoded = decode_b64_in_test(&b64);
let mut expected = Vec::new();
expected.push(0u8);
expected.extend_from_slice(user.as_bytes());
expected.push(0u8);
expected.extend_from_slice(pass.as_bytes());
assert_eq!(decoded, expected);
}
#[test]
fn auth_plain_initial_response_handles_utf8_password() {
let pass = "p\u{00E1}ssw\u{00F8}rd";
let b64 = build_auth_plain_initial_response("u", pass);
let decoded = decode_b64_in_test(&b64);
assert_eq!(decoded[0], 0);
assert_eq!(&decoded[1..2], b"u");
assert_eq!(decoded[2], 0);
assert_eq!(&decoded[3..], pass.as_bytes());
}
fn decode_b64_in_test(s: &str) -> Vec<u8> {
const ALPHABET: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut idx = [255u8; 256];
for (i, &b) in ALPHABET.iter().enumerate() {
idx[b as usize] = u8::try_from(i).expect("alphabet fits in u8");
}
let chars: Vec<u8> = s.bytes().filter(|&b| b != b'=').collect();
let mut out = Vec::new();
for quad in chars.chunks(4) {
let mut n = 0u32;
for (i, &c) in quad.iter().enumerate() {
let v = idx[c as usize];
assert!(v != 255, "non-base64 byte in test input");
n |= u32::from(v) << (18 - 6 * i);
}
let bytes_out = match quad.len() {
4 => 3,
3 => 2,
2 => 1,
_ => panic!("invalid base64 length"),
};
for i in 0..bytes_out {
out.push(((n >> (16 - 8 * i)) & 0xFF) as u8);
}
}
out
}
#[test]
fn select_auth_mechanism_prefers_plain() {
let lines: Vec<String> = vec!["AUTH PLAIN LOGIN".into()];
assert_eq!(select_auth_mechanism(&lines), Some(AuthMechanism::Plain));
}
#[test]
fn select_auth_mechanism_falls_back_to_login() {
let lines: Vec<String> = vec!["AUTH LOGIN".into()];
assert_eq!(select_auth_mechanism(&lines), Some(AuthMechanism::Login));
}
#[test]
fn select_auth_mechanism_returns_none_when_unsupported_only() {
let lines: Vec<String> = vec!["AUTH CRAM-MD5".into(), "PIPELINING".into()];
assert_eq!(select_auth_mechanism(&lines), None);
}
#[test]
fn select_auth_mechanism_returns_none_when_no_auth_advertised() {
let lines: Vec<String> = vec!["PIPELINING".into(), "8BITMIME".into()];
assert_eq!(select_auth_mechanism(&lines), None);
}
#[test]
fn select_auth_mechanism_handles_empty_capabilities() {
let lines: Vec<String> = Vec::new();
assert_eq!(select_auth_mechanism(&lines), None);
}
#[test]
fn select_auth_mechanism_handles_multiple_auth_lines() {
let lines: Vec<String> = vec!["AUTH LOGIN".into(), "AUTH PLAIN".into()];
assert_eq!(select_auth_mechanism(&lines), Some(AuthMechanism::Plain));
}
#[test]
fn auth_mechanism_name_and_display() {
assert_eq!(AuthMechanism::Plain.name(), "PLAIN");
assert_eq!(AuthMechanism::Login.name(), "LOGIN");
assert_eq!(format!("{}", AuthMechanism::Plain), "PLAIN");
assert_eq!(format!("{}", AuthMechanism::Login), "LOGIN");
}
#[test]
fn validate_plain_credentials_reject_empty() {
assert!(validate_plain_username("").is_err());
assert!(validate_plain_password("").is_err());
assert!(validate_plain_username("user").is_ok());
assert!(validate_plain_password("pass").is_ok());
}
#[test]
fn validate_plain_credentials_reject_nul_bytes() {
assert!(validate_plain_username("a\0b").is_err());
assert!(validate_plain_password("c\0d").is_err());
}
#[test]
fn validate_plain_password_accepts_utf8_and_special_chars() {
assert!(validate_plain_password("\u{00E1}\u{00F1}\u{4E2D}").is_ok());
assert!(validate_plain_password("a b\tc").is_ok());
assert!(validate_plain_password("p@ss w0rd!").is_ok());
}
#[cfg(feature = "xoauth2")]
#[test]
fn xoauth2_initial_response_canonical_example() {
let response = build_xoauth2_initial_response("someuser@example.com", "ya29.test_token");
let mut expected_payload = Vec::new();
expected_payload.extend_from_slice(b"user=someuser@example.com");
expected_payload.push(0x01);
expected_payload.extend_from_slice(b"auth=Bearer ya29.test_token");
expected_payload.push(0x01);
expected_payload.push(0x01);
let expected_b64 = base64_encode(&expected_payload);
assert_eq!(response, expected_b64);
}
#[cfg(feature = "xoauth2")]
#[test]
fn xoauth2_initial_response_uses_soh_separators() {
let r1 = build_xoauth2_initial_response("u", "t");
let mut payload = Vec::new();
payload.extend_from_slice(b"user=u\x01auth=Bearer t\x01\x01");
assert_eq!(r1, base64_encode(&payload));
}
#[cfg(feature = "xoauth2")]
#[test]
fn validate_xoauth2_user_rejects_empty_and_control_bytes() {
assert!(validate_xoauth2_user("").is_err());
assert!(validate_xoauth2_user("u\0v").is_err());
assert!(validate_xoauth2_user("u\rv").is_err());
assert!(validate_xoauth2_user("u\nv").is_err());
assert!(validate_xoauth2_user("u\x01v").is_err());
}
#[cfg(feature = "xoauth2")]
#[test]
fn validate_xoauth2_user_accepts_typical_email_addresses() {
assert!(validate_xoauth2_user("user@example.com").is_ok());
assert!(validate_xoauth2_user("first.last+tag@example.co.uk").is_ok());
}
#[cfg(feature = "xoauth2")]
#[test]
fn validate_oauth2_token_rejects_empty_and_whitespace() {
assert!(validate_oauth2_token("").is_err());
assert!(validate_oauth2_token("token with space").is_err());
assert!(validate_oauth2_token("token\twith\ttab").is_err());
assert!(validate_oauth2_token("token\nwith\nnewline").is_err());
}
#[cfg(feature = "xoauth2")]
#[test]
fn validate_oauth2_token_rejects_non_ascii() {
assert!(validate_oauth2_token("\u{00FF}token").is_err());
assert!(validate_oauth2_token("token\u{4E2D}").is_err());
}
#[cfg(feature = "xoauth2")]
#[test]
fn validate_oauth2_token_accepts_typical_bearer_tokens() {
assert!(
validate_oauth2_token("ya29.A0AfH6SMBx-LAUH4xRcZbqK_pE7Hk0_lOxe2eGdt9CD8s8I").is_ok()
);
assert!(
validate_oauth2_token("eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIifQ.signature_part")
.is_ok()
);
assert!(validate_oauth2_token("a-b_c.d+e/f=g~h").is_ok());
}
#[cfg(feature = "xoauth2")]
#[test]
fn select_auth_mechanism_does_not_pick_xoauth2() {
let lines: Vec<String> = vec!["AUTH XOAUTH2".into()];
assert!(select_auth_mechanism(&lines).is_none());
}
#[test]
fn auth_mechanism_xoauth2_name_is_exact_keyword() {
assert_eq!(AuthMechanism::XOAuth2.name(), "XOAUTH2");
assert_eq!(format!("{}", AuthMechanism::XOAuth2), "XOAUTH2");
}
}
mod session_tests {
use crate::session::SessionState::{
Authentication, Closed, Data, Ehlo, Greeting, MailFrom, Quit, RcptTo, StartTls,
};
#[test]
fn forward_progression_is_allowed() {
assert!(Greeting.can_transition_to(Ehlo));
assert!(Ehlo.can_transition_to(Authentication));
assert!(Authentication.can_transition_to(MailFrom));
assert!(MailFrom.can_transition_to(RcptTo));
assert!(RcptTo.can_transition_to(Data));
assert!(Data.can_transition_to(MailFrom));
}
#[test]
fn skipping_authentication_is_allowed() {
assert!(Ehlo.can_transition_to(MailFrom));
}
#[test]
fn starting_a_second_transaction_is_allowed() {
assert!(MailFrom.can_transition_to(MailFrom));
}
#[test]
fn multiple_recipients_stay_in_rcptto() {
assert!(RcptTo.can_transition_to(RcptTo));
}
#[test]
fn quit_is_allowed_from_every_active_state() {
for from in [Greeting, Ehlo, Authentication, MailFrom, RcptTo, Data] {
assert!(from.can_transition_to(Quit), "{from:?} should allow QUIT");
}
}
#[test]
fn closed_is_reachable_from_every_state() {
for from in [
Greeting,
Ehlo,
Authentication,
StartTls,
MailFrom,
RcptTo,
Data,
Quit,
Closed,
] {
assert!(from.can_transition_to(Closed), "{from:?} -> Closed");
}
}
#[test]
fn invalid_transitions_are_rejected() {
assert!(!Greeting.can_transition_to(Authentication));
assert!(!Greeting.can_transition_to(MailFrom));
assert!(!Ehlo.can_transition_to(RcptTo));
assert!(!Ehlo.can_transition_to(Data));
assert!(!MailFrom.can_transition_to(Data));
assert!(!MailFrom.can_transition_to(Authentication));
assert!(!Data.can_transition_to(RcptTo));
assert!(!Closed.can_transition_to(Ehlo));
assert!(!Closed.can_transition_to(MailFrom));
}
#[test]
fn closed_is_the_only_terminal_state() {
assert!(Closed.is_terminal());
for s in [
Greeting,
Ehlo,
Authentication,
StartTls,
MailFrom,
RcptTo,
Data,
Quit,
] {
assert!(!s.is_terminal(), "{s:?} should not be terminal");
}
}
#[test]
fn starttls_is_reachable_from_authentication_only() {
assert!(Authentication.can_transition_to(StartTls));
for from in [Greeting, Ehlo, MailFrom, RcptTo, Data, Quit, Closed] {
assert!(
!from.can_transition_to(StartTls),
"{from:?} should not be able to enter StartTls"
);
}
}
#[test]
fn starttls_returns_to_ehlo_after_upgrade() {
assert!(StartTls.can_transition_to(Ehlo));
assert!(Ehlo.can_transition_to(Authentication));
}
#[test]
fn starttls_cannot_skip_to_later_states() {
for to in [Authentication, MailFrom, RcptTo, Data, Quit] {
assert!(
!StartTls.can_transition_to(to),
"StartTls should not skip directly to {to:?}"
);
}
}
}
mod error_tests {
use crate::error::{AuthError, InvalidInputError, IoError, ProtocolError, SmtpError, SmtpOp};
use std::error::Error;
#[test]
fn smtp_error_display_protocol_includes_code_and_message() {
let e = SmtpError::Protocol(ProtocolError::UnexpectedCode {
during: SmtpOp::MailFrom,
expected_class: 2,
actual: 451,
enhanced: None,
message: "temporary local problem".into(),
});
let s = format!("{e}");
assert!(s.contains("451"), "should include actual code: {s}");
assert!(
s.contains("temporary local problem"),
"should include server text: {s}"
);
assert!(
s.contains("MAIL FROM"),
"should mention the SMTP operation in progress: {s}"
);
}
#[test]
fn smtp_op_display_uses_wire_keyword() {
for (op, expected) in [
(SmtpOp::Greeting, "greeting"),
(SmtpOp::Ehlo, "EHLO"),
(SmtpOp::StartTls, "STARTTLS"),
(SmtpOp::AuthPlain, "AUTH PLAIN"),
(SmtpOp::AuthLogin, "AUTH LOGIN"),
(SmtpOp::AuthXOAuth2, "AUTH XOAUTH2"),
(SmtpOp::MailFrom, "MAIL FROM"),
(SmtpOp::RcptTo, "RCPT TO"),
(SmtpOp::Data, "DATA"),
(SmtpOp::Quit, "QUIT"),
] {
assert_eq!(format!("{op}"), expected);
assert_eq!(op.as_str(), expected);
}
}
#[test]
fn auth_rejected_carries_server_code_and_text() {
let e = SmtpError::Auth(AuthError::Rejected {
code: 535,
enhanced: None,
message: "5.7.8 invalid".into(),
});
let s = format!("{e}");
assert!(s.contains("535"));
assert!(s.contains("5.7.8 invalid"));
}
#[test]
fn invalid_input_takes_only_static_strings() {
let e = InvalidInputError::new("test reason");
assert_eq!(e.reason(), "test reason");
assert_eq!(format!("{e}"), "test reason");
}
#[test]
fn from_conversions_wrap_in_correct_variant() {
let e: SmtpError = IoError::new("transport gone").into();
assert!(matches!(e, SmtpError::Io(_)));
let e: SmtpError = ProtocolError::UnexpectedClose.into();
assert!(matches!(e, SmtpError::Protocol(_)));
let e: SmtpError = AuthError::UnsupportedMechanism.into();
assert!(matches!(e, SmtpError::Auth(_)));
let e: SmtpError = InvalidInputError::new("x").into();
assert!(matches!(e, SmtpError::InvalidInput(_)));
}
#[test]
fn smtp_error_source_chains_to_inner_variant() {
let e: SmtpError = IoError::new("inner").into();
let src = e.source().expect("should have source");
assert!(format!("{src}").contains("inner"));
}
}
mod client_tests {
use super::harness::{MockTransport, UpgradeBehavior, block_on, flatten};
use crate::client::SmtpClient;
use crate::error::{AuthError, ProtocolError, SmtpError, SmtpOp};
use crate::protocol::AuthMechanism;
use crate::session::SessionState;
fn greeting_then_ehlo() -> Vec<u8> {
flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com Hello [192.0.2.1]\r\n",
b"250-PIPELINING\r\n",
b"250-8BITMIME\r\n",
b"250 AUTH LOGIN PLAIN\r\n",
])
}
#[test]
fn connect_reads_greeting_and_sends_ehlo() {
let script = greeting_then_ehlo();
let (transport, written, _closed) = MockTransport::new(&[&script[..]]);
let client =
block_on(SmtpClient::connect(transport, "client.example.com")).expect("connect");
assert_eq!(client.state(), SessionState::Authentication);
let caps = client.capabilities();
assert_eq!(caps.len(), 3);
assert_eq!(caps[0], "PIPELINING");
assert_eq!(caps[1], "8BITMIME");
assert_eq!(caps[2], "AUTH LOGIN PLAIN");
assert_eq!(&*written.borrow(), b"EHLO client.example.com\r\n");
}
#[test]
fn connect_fails_on_non_220_greeting() {
let script: &[u8] = b"554 Service unavailable\r\n";
let (transport, _written, _closed) = MockTransport::new(&[script]);
let err = block_on(SmtpClient::connect(transport, "client.example.com"))
.expect_err("greeting should fail");
match err {
SmtpError::Protocol(ProtocolError::UnexpectedCode { actual, .. }) => {
assert_eq!(actual, 554);
}
other => panic!("expected ProtocolError::UnexpectedCode, got {other:?}"),
}
}
#[test]
fn invalid_ehlo_domain_is_rejected_before_io() {
let (transport, written, _closed) = MockTransport::new(&[]);
let err = block_on(SmtpClient::connect(transport, "")).expect_err("must fail");
assert!(matches!(err, SmtpError::InvalidInput(_)));
assert!(written.borrow().is_empty());
}
#[test]
fn login_sends_correct_auth_login_sequence() {
let server_script = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250 AUTH LOGIN\r\n",
b"334 VXNlcm5hbWU6\r\n",
b"334 UGFzc3dvcmQ6\r\n",
b"235 Authentication succeeded\r\n",
]);
let (transport, written, _closed) = MockTransport::new(&[&server_script[..]]);
let mut client =
block_on(SmtpClient::connect(transport, "client.example")).expect("connect");
block_on(client.login("user", "pass")).expect("login");
let expected = b"EHLO client.example\r\n\
AUTH LOGIN\r\n\
dXNlcg==\r\n\
cGFzcw==\r\n";
assert_eq!(&*written.borrow(), expected);
assert_eq!(client.state(), SessionState::MailFrom);
}
#[test]
fn login_fails_when_auth_login_not_advertised() {
let server_script = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250 8BITMIME\r\n", ]);
let (transport, _written, _closed) = MockTransport::new(&[&server_script[..]]);
let mut client =
block_on(SmtpClient::connect(transport, "client.example")).expect("connect");
let err = block_on(client.login("user", "pass")).expect_err("must fail");
assert!(matches!(
err,
SmtpError::Auth(AuthError::UnsupportedMechanism)
));
assert_eq!(client.state(), SessionState::Closed);
}
#[test]
fn login_fails_on_535_rejection() {
let server_script = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250 AUTH LOGIN\r\n",
b"334 VXNlcm5hbWU6\r\n",
b"334 UGFzc3dvcmQ6\r\n",
b"535 5.7.8 Authentication credentials invalid\r\n",
]);
let (transport, _written, _closed) = MockTransport::new(&[&server_script[..]]);
let mut client =
block_on(SmtpClient::connect(transport, "client.example")).expect("connect");
let err = block_on(client.login("user", "pass")).expect_err("must fail");
match err {
SmtpError::Auth(AuthError::Rejected { code, .. }) => assert_eq!(code, 535),
other => panic!("expected AuthError::Rejected, got {other:?}"),
}
}
#[test]
fn login_rejects_empty_username_before_io() {
let server_script = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250 AUTH LOGIN\r\n",
]);
let (transport, written, _closed) = MockTransport::new(&[&server_script[..]]);
let mut client = block_on(SmtpClient::connect(transport, "c.example")).expect("connect");
let pre_login_writes_len = written.borrow().len();
let err = block_on(client.login("", "pass")).expect_err("empty user must fail");
assert!(matches!(err, SmtpError::InvalidInput(_)));
assert_eq!(written.borrow().len(), pre_login_writes_len);
}
#[test]
fn send_mail_full_transaction_no_auth() {
let server_script = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250 8BITMIME\r\n",
b"250 OK\r\n",
b"250 OK\r\n",
b"251 User not local; will forward\r\n",
b"354 End data with <CR><LF>.<CR><LF>\r\n",
b"250 Queued\r\n",
]);
let (transport, written, _closed) = MockTransport::new(&[&server_script[..]]);
let mut client =
block_on(SmtpClient::connect(transport, "client.example")).expect("connect");
let body = "From: a@example.com\r\nTo: b@example.org\r\nSubject: hi\r\n\r\nHello.\r\n";
block_on(client.send_mail("a@example.com", &["b@example.org", "c@example.org"], body))
.expect("send_mail");
let expected = b"EHLO client.example\r\n\
MAIL FROM:<a@example.com>\r\n\
RCPT TO:<b@example.org>\r\n\
RCPT TO:<c@example.org>\r\n\
DATA\r\n\
From: a@example.com\r\nTo: b@example.org\r\nSubject: hi\r\n\r\nHello.\r\n.\r\n";
assert_eq!(&*written.borrow(), expected);
assert_eq!(client.state(), SessionState::MailFrom);
}
#[test]
fn send_mail_dot_stuffs_leading_dot_lines() {
let server_script = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250 8BITMIME\r\n",
b"250 OK\r\n",
b"250 OK\r\n",
b"354 OK\r\n",
b"250 Queued\r\n",
]);
let (transport, written, _closed) = MockTransport::new(&[&server_script[..]]);
let mut client = block_on(SmtpClient::connect(transport, "c.example")).expect("connect");
let body = "Subject: t\r\n\r\n.line1\r\n..line2\r\n";
block_on(client.send_mail("a@b.com", &["c@d.com"], body)).expect("send");
let got = written.borrow();
let after_data = b"DATA\r\n";
let pos = got
.windows(after_data.len())
.position(|w| w == after_data)
.expect("DATA marker in capture");
let payload = &got[pos + after_data.len()..];
let expected = b"Subject: t\r\n\r\n..line1\r\n...line2\r\n.\r\n";
assert_eq!(payload, expected);
}
#[test]
fn send_mail_rejects_empty_recipients() {
let server_script = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250 8BITMIME\r\n",
]);
let (transport, _written, _closed) = MockTransport::new(&[&server_script[..]]);
let mut client = block_on(SmtpClient::connect(transport, "c.example")).expect("connect");
let err = block_on(client.send_mail("a@b.com", &[], "x")).expect_err("must fail");
assert!(matches!(err, SmtpError::InvalidInput(_)));
}
#[test]
fn send_mail_rejects_crlf_injection_in_address() {
let server_script = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250 8BITMIME\r\n",
]);
let (transport, written, _closed) = MockTransport::new(&[&server_script[..]]);
let mut client = block_on(SmtpClient::connect(transport, "c.example")).expect("connect");
let pre = written.borrow().len();
let err = block_on(client.send_mail("a@b.com\r\nRSET", &["c@d.com"], "x"))
.expect_err("must reject");
assert!(matches!(err, SmtpError::InvalidInput(_)));
assert_eq!(written.borrow().len(), pre);
}
#[test]
fn send_mail_after_mail_from_rejection_marks_session_closed() {
let server_script = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250 8BITMIME\r\n",
b"550 No such user\r\n",
]);
let (transport, _written, _closed) = MockTransport::new(&[&server_script[..]]);
let mut client = block_on(SmtpClient::connect(transport, "c.example")).expect("connect");
let err = block_on(client.send_mail("a@b.com", &["c@d.com"], "Subject: x\r\n\r\nx\r\n"))
.expect_err("server should reject");
assert!(matches!(
err,
SmtpError::Protocol(ProtocolError::UnexpectedCode { .. })
));
assert_eq!(client.state(), SessionState::Closed);
}
#[test]
fn two_send_mails_in_one_session_succeed() {
let server_script = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250 8BITMIME\r\n",
b"250 OK\r\n", b"250 OK\r\n", b"354 OK\r\n", b"250 Queued\r\n",
b"250 OK\r\n",
b"250 OK\r\n",
b"354 OK\r\n",
b"250 Queued\r\n",
]);
let (transport, _written, _closed) = MockTransport::new(&[&server_script[..]]);
let mut client = block_on(SmtpClient::connect(transport, "c.example")).expect("connect");
let body = "Subject: t\r\n\r\nbody\r\n";
block_on(client.send_mail("a@b.com", &["c@d.com"], body)).expect("first send");
block_on(client.send_mail("a@b.com", &["e@f.com"], body)).expect("second send");
assert_eq!(client.state(), SessionState::MailFrom);
}
#[test]
fn quit_sends_quit_and_closes_transport() {
let server_script = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250 AUTH LOGIN\r\n",
b"221 Bye\r\n",
]);
let (transport, written, closed) = MockTransport::new(&[&server_script[..]]);
let client = block_on(SmtpClient::connect(transport, "c.example")).expect("connect");
block_on(client.quit()).expect("quit");
assert!(written.borrow().ends_with(b"QUIT\r\n"));
assert!(*closed.borrow(), "transport.close() must be called");
}
#[test]
fn unexpected_close_during_reply_is_classified() {
let server_script = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n", ]);
let (transport, _written, _closed) = MockTransport::new(&[&server_script[..]]);
let err = block_on(SmtpClient::connect(transport, "c.example")).expect_err("must fail");
match err {
SmtpError::Protocol(ProtocolError::UnexpectedClose) => {}
other => panic!("expected UnexpectedClose, got {other:?}"),
}
}
#[test]
fn inconsistent_multiline_codes_are_rejected() {
let server_script = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-line1\r\n",
b"251 line2\r\n",
]);
let (transport, _written, _closed) = MockTransport::new(&[&server_script[..]]);
let err = block_on(SmtpClient::connect(transport, "c.example")).expect_err("must fail");
assert!(matches!(
err,
SmtpError::Protocol(ProtocolError::InconsistentMultiline { .. })
));
}
#[test]
fn malformed_reply_line_is_rejected() {
let server_script: &[u8] = b"abc not a real reply\r\n";
let (transport, _written, _closed) = MockTransport::new(&[server_script]);
let err = block_on(SmtpClient::connect(transport, "c.example")).expect_err("must fail");
assert!(matches!(
err,
SmtpError::Protocol(ProtocolError::Malformed(_))
));
}
#[test]
fn read_handles_chunks_split_arbitrarily() {
let chunks: Vec<&[u8]> = vec![
b"220 mail.exam",
b"ple.com ESMTP\r\n250-mail.example.com\r\n",
b"250 AUTH LOGIN\r\n",
];
let (transport, _written, _closed) = MockTransport::new(&chunks);
let client = block_on(SmtpClient::connect(transport, "c.example")).expect("connect");
assert_eq!(client.state(), SessionState::Authentication);
}
#[test]
fn login_uses_plain_when_advertised() {
let server_script = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250 AUTH PLAIN LOGIN\r\n",
b"235 Authentication succeeded\r\n",
]);
let (transport, written, _closed) = MockTransport::new(&[&server_script[..]]);
let mut client =
block_on(SmtpClient::connect(transport, "client.example")).expect("connect");
block_on(client.login("user", "pass")).expect("login");
let expected = b"EHLO client.example\r\n\
AUTH PLAIN AHVzZXIAcGFzcw==\r\n";
assert_eq!(&*written.borrow(), expected);
assert_eq!(client.state(), SessionState::MailFrom);
}
#[test]
fn login_falls_back_to_login_when_only_login_advertised() {
let server_script = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250 AUTH LOGIN\r\n",
b"334 VXNlcm5hbWU6\r\n",
b"334 UGFzc3dvcmQ6\r\n",
b"235 OK\r\n",
]);
let (transport, written, _closed) = MockTransport::new(&[&server_script[..]]);
let mut client =
block_on(SmtpClient::connect(transport, "client.example")).expect("connect");
block_on(client.login("user", "pass")).expect("login");
let expected = b"EHLO client.example\r\n\
AUTH LOGIN\r\n\
dXNlcg==\r\n\
cGFzcw==\r\n";
assert_eq!(&*written.borrow(), expected);
assert_eq!(client.state(), SessionState::MailFrom);
}
#[test]
fn login_fails_when_no_supported_mechanism_is_advertised() {
let server_script = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250 AUTH CRAM-MD5\r\n",
]);
let (transport, written, _closed) = MockTransport::new(&[&server_script[..]]);
let mut client =
block_on(SmtpClient::connect(transport, "client.example")).expect("connect");
let pre_login = written.borrow().len();
let err = block_on(client.login("user", "pass")).expect_err("must fail");
assert!(matches!(
err,
SmtpError::Auth(AuthError::UnsupportedMechanism)
));
assert_eq!(written.borrow().len(), pre_login);
assert_eq!(client.state(), SessionState::Closed);
}
#[test]
fn login_plain_handles_535_rejection_as_auth_error() {
let server_script = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250 AUTH PLAIN\r\n",
b"535 5.7.8 invalid credentials\r\n",
]);
let (transport, _written, _closed) = MockTransport::new(&[&server_script[..]]);
let mut client =
block_on(SmtpClient::connect(transport, "client.example")).expect("connect");
let err = block_on(client.login("user", "pass")).expect_err("must fail");
match err {
SmtpError::Auth(AuthError::Rejected {
code,
enhanced,
message,
}) => {
assert_eq!(code, 535);
assert!(message.contains("5.7.8"));
assert!(
enhanced.is_none(),
"without EHLO advertisement, no enhanced parse"
);
}
other => panic!("expected AuthError::Rejected, got {other:?}"),
}
assert_eq!(client.state(), SessionState::Closed);
}
#[test]
fn login_with_plain_explicit_uses_plain_even_when_login_also_advertised() {
let server_script = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250 AUTH PLAIN LOGIN\r\n",
b"235 OK\r\n",
]);
let (transport, written, _closed) = MockTransport::new(&[&server_script[..]]);
let mut client =
block_on(SmtpClient::connect(transport, "client.example")).expect("connect");
block_on(client.login_with(AuthMechanism::Plain, "user", "pass")).expect("login");
let expected = b"EHLO client.example\r\n\
AUTH PLAIN AHVzZXIAcGFzcw==\r\n";
assert_eq!(&*written.borrow(), expected);
}
#[test]
fn login_with_login_explicit_uses_login_even_when_plain_also_advertised() {
let server_script = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250 AUTH PLAIN LOGIN\r\n",
b"334 VXNlcm5hbWU6\r\n",
b"334 UGFzc3dvcmQ6\r\n",
b"235 OK\r\n",
]);
let (transport, written, _closed) = MockTransport::new(&[&server_script[..]]);
let mut client =
block_on(SmtpClient::connect(transport, "client.example")).expect("connect");
block_on(client.login_with(AuthMechanism::Login, "user", "pass")).expect("login");
let expected = b"EHLO client.example\r\n\
AUTH LOGIN\r\n\
dXNlcg==\r\n\
cGFzcw==\r\n";
assert_eq!(&*written.borrow(), expected);
}
#[test]
fn login_with_plain_fails_when_only_login_advertised() {
let server_script = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250 AUTH LOGIN\r\n",
]);
let (transport, _written, _closed) = MockTransport::new(&[&server_script[..]]);
let mut client =
block_on(SmtpClient::connect(transport, "client.example")).expect("connect");
let err = block_on(client.login_with(AuthMechanism::Plain, "user", "pass"))
.expect_err("must fail");
assert!(matches!(
err,
SmtpError::Auth(AuthError::UnsupportedMechanism)
));
}
#[test]
fn login_rejects_credentials_with_nul_byte_before_io() {
let server_script = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250 AUTH PLAIN\r\n",
]);
let (transport, written, _closed) = MockTransport::new(&[&server_script[..]]);
let mut client =
block_on(SmtpClient::connect(transport, "client.example")).expect("connect");
let pre_login = written.borrow().len();
let err = block_on(client.login("user\0evil", "pass")).expect_err("must fail");
assert!(matches!(err, SmtpError::InvalidInput(_)));
assert_eq!(written.borrow().len(), pre_login);
}
#[test]
fn unsupported_mechanism_message_lists_supported_options() {
let err = SmtpError::Auth(AuthError::UnsupportedMechanism);
let s = format!("{err}");
assert!(s.contains("PLAIN"), "should mention PLAIN: {s}");
assert!(s.contains("LOGIN"), "should mention LOGIN: {s}");
}
fn during_of(err: SmtpError) -> SmtpOp {
match err {
SmtpError::Protocol(ProtocolError::UnexpectedCode { during, .. }) => during,
other => panic!("expected UnexpectedCode, got {other:?}"),
}
}
#[test]
fn unexpected_code_during_greeting() {
let (transport, _w, _c) = MockTransport::new(&[b"554 service unavailable\r\n"]);
let err = block_on(SmtpClient::connect(transport, "c.example")).expect_err("must fail");
assert_eq!(during_of(err), SmtpOp::Greeting);
}
#[test]
fn unexpected_code_during_ehlo() {
let script = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"502 EHLO not implemented\r\n",
]);
let (transport, _w, _c) = MockTransport::new(&[&script[..]]);
let err = block_on(SmtpClient::connect(transport, "c.example")).expect_err("must fail");
assert_eq!(during_of(err), SmtpOp::Ehlo);
}
#[test]
fn unexpected_code_during_mail_from() {
let script = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250 8BITMIME\r\n",
b"550 sender domain refused\r\n",
]);
let (transport, _w, _c) = MockTransport::new(&[&script[..]]);
let mut client = block_on(SmtpClient::connect(transport, "c.example")).expect("connect");
let err = block_on(client.send_mail("a@b.com", &["c@d.com"], "Subject: x\r\n\r\nx\r\n"))
.expect_err("must fail");
assert_eq!(during_of(err), SmtpOp::MailFrom);
}
#[test]
fn unexpected_code_during_rcpt_to() {
let script = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250 8BITMIME\r\n",
b"250 OK\r\n", b"550 no such user\r\n", ]);
let (transport, _w, _c) = MockTransport::new(&[&script[..]]);
let mut client = block_on(SmtpClient::connect(transport, "c.example")).expect("connect");
let err = block_on(client.send_mail("a@b.com", &["c@d.com"], "Subject: x\r\n\r\nx\r\n"))
.expect_err("must fail");
assert_eq!(during_of(err), SmtpOp::RcptTo);
}
#[test]
fn unexpected_code_during_data_command() {
let script = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250 8BITMIME\r\n",
b"250 OK\r\n", b"250 OK\r\n", b"503 bad sequence\r\n", ]);
let (transport, _w, _c) = MockTransport::new(&[&script[..]]);
let mut client = block_on(SmtpClient::connect(transport, "c.example")).expect("connect");
let err = block_on(client.send_mail("a@b.com", &["c@d.com"], "Subject: x\r\n\r\nx\r\n"))
.expect_err("must fail");
assert_eq!(during_of(err), SmtpOp::Data);
}
#[test]
fn unexpected_code_during_data_body() {
let script = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250 8BITMIME\r\n",
b"250 OK\r\n", b"250 OK\r\n", b"354 go ahead\r\n", b"552 message too large\r\n", ]);
let (transport, _w, _c) = MockTransport::new(&[&script[..]]);
let mut client = block_on(SmtpClient::connect(transport, "c.example")).expect("connect");
let err = block_on(client.send_mail("a@b.com", &["c@d.com"], "Subject: x\r\n\r\nx\r\n"))
.expect_err("must fail");
assert_eq!(during_of(err), SmtpOp::Data);
}
#[test]
fn unexpected_code_during_quit_propagates_after_close() {
let script = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250 8BITMIME\r\n",
b"500 unrecognized\r\n", ]);
let (transport, _w, closed) = MockTransport::new(&[&script[..]]);
let client = block_on(SmtpClient::connect(transport, "c.example")).expect("connect");
let err = block_on(client.quit()).expect_err("must fail");
assert_eq!(during_of(err), SmtpOp::Quit);
assert!(*closed.borrow());
}
#[test]
fn auth_plain_unexpected_non_5xx_keeps_protocol_error_with_op() {
let script = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250 AUTH PLAIN\r\n",
b"432 password expired\r\n", ]);
let (transport, _w, _c) = MockTransport::new(&[&script[..]]);
let mut client = block_on(SmtpClient::connect(transport, "c.example")).expect("connect");
let err = block_on(client.login("user", "pass")).expect_err("must fail");
assert_eq!(during_of(err), SmtpOp::AuthPlain);
}
fn starttls_greeting_and_upgrade() -> Vec<u8> {
flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250-PIPELINING\r\n",
b"250 STARTTLS\r\n",
b"220 ready to start TLS\r\n",
b"250-mail.example.com\r\n",
b"250-PIPELINING\r\n",
b"250 AUTH PLAIN LOGIN\r\n",
])
}
#[test]
fn connect_starttls_runs_full_upgrade_sequence() {
let (transport, written, _closed, upgrades) = MockTransport::with_starttls(
&[&starttls_greeting_and_upgrade()[..]],
UpgradeBehavior::Succeed,
);
let client = block_on(SmtpClient::connect_starttls(transport, "client.example"))
.expect("connect_starttls");
assert_eq!(client.state(), SessionState::Authentication);
let caps = client.capabilities();
assert_eq!(caps.len(), 2);
assert_eq!(caps[0], "PIPELINING");
assert_eq!(caps[1], "AUTH PLAIN LOGIN");
let expected = b"EHLO client.example\r\n\
STARTTLS\r\n\
EHLO client.example\r\n";
assert_eq!(&*written.borrow(), expected);
assert_eq!(*upgrades.borrow(), 1);
}
#[test]
fn starttls_then_login_uses_post_tls_capabilities() {
let script = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250 STARTTLS\r\n",
b"220 ready\r\n",
b"250-mail.example.com\r\n",
b"250 AUTH PLAIN\r\n",
b"235 OK\r\n",
]);
let (transport, written, _c, _u) =
MockTransport::with_starttls(&[&script[..]], UpgradeBehavior::Succeed);
let mut client = block_on(SmtpClient::connect_starttls(transport, "c.example"))
.expect("connect_starttls");
block_on(client.login("user", "pass")).expect("login");
let expected = b"EHLO c.example\r\n\
STARTTLS\r\n\
EHLO c.example\r\n\
AUTH PLAIN AHVzZXIAcGFzcw==\r\n";
assert_eq!(&*written.borrow(), expected);
assert_eq!(client.state(), SessionState::MailFrom);
}
#[test]
fn starttls_fails_when_extension_not_advertised() {
let script = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250 8BITMIME\r\n",
]);
let (transport, written, _c, upgrades) =
MockTransport::with_starttls(&[&script[..]], UpgradeBehavior::Succeed);
let pre_upgrade_writes_len = 0;
let err =
block_on(SmtpClient::connect_starttls(transport, "c.example")).expect_err("must fail");
match err {
SmtpError::Protocol(ProtocolError::ExtensionUnavailable { name }) => {
assert_eq!(name, "STARTTLS");
}
other => panic!("expected ExtensionUnavailable, got {other:?}"),
}
assert_eq!(&*written.borrow(), b"EHLO c.example\r\n");
assert!(written.borrow().len() > pre_upgrade_writes_len);
assert_eq!(*upgrades.borrow(), 0);
}
#[test]
fn starttls_fails_when_server_rejects_command() {
let script = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250 STARTTLS\r\n",
b"502 STARTTLS not configured\r\n",
]);
let (transport, _w, _c, upgrades) =
MockTransport::with_starttls(&[&script[..]], UpgradeBehavior::Succeed);
let err =
block_on(SmtpClient::connect_starttls(transport, "c.example")).expect_err("must fail");
match err {
SmtpError::Protocol(ProtocolError::UnexpectedCode { during, actual, .. }) => {
assert_eq!(during, SmtpOp::StartTls);
assert_eq!(actual, 502);
}
other => panic!("expected UnexpectedCode for StartTls, got {other:?}"),
}
assert_eq!(*upgrades.borrow(), 0);
}
#[test]
fn starttls_propagates_transport_upgrade_failure_as_io_error() {
let script = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250 STARTTLS\r\n",
b"220 ready\r\n",
]);
let (transport, _w, _c, upgrades) = MockTransport::with_starttls(
&[&script[..]],
UpgradeBehavior::Fail("simulated TLS handshake failure"),
);
let err =
block_on(SmtpClient::connect_starttls(transport, "c.example")).expect_err("must fail");
match err {
SmtpError::Io(e) => {
assert!(format!("{e}").contains("TLS handshake"));
}
other => panic!("expected Io for upgrade failure, got {other:?}"),
}
assert_eq!(*upgrades.borrow(), 1);
}
#[test]
fn explicit_starttls_method_works_post_connect() {
let (transport, written, _c, _u) = MockTransport::with_starttls(
&[&starttls_greeting_and_upgrade()[..]],
UpgradeBehavior::Succeed,
);
let mut client =
block_on(SmtpClient::connect(transport, "client.example")).expect("connect");
assert!(client.capabilities().iter().any(|c| c == "STARTTLS"));
block_on(client.starttls()).expect("starttls");
assert!(
client
.capabilities()
.iter()
.any(|c| c == "AUTH PLAIN LOGIN"),
"post-TLS caps should include AUTH advertisement: {:?}",
client.capabilities()
);
assert!(
!client.capabilities().iter().any(|c| c == "STARTTLS"),
"STARTTLS should not appear in post-TLS caps: {:?}",
client.capabilities()
);
assert_eq!(client.state(), SessionState::Authentication);
assert_eq!(
&*written.borrow(),
b"EHLO client.example\r\nSTARTTLS\r\nEHLO client.example\r\n"
);
}
#[test]
fn starttls_rejects_call_after_login() {
let script = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250 STARTTLS\r\n",
b"220 ready\r\n",
b"250-mail.example.com\r\n",
b"250 AUTH PLAIN\r\n",
b"235 OK\r\n",
]);
let (transport, _w, _c, upgrades) =
MockTransport::with_starttls(&[&script[..]], UpgradeBehavior::Succeed);
let mut client = block_on(SmtpClient::connect_starttls(transport, "c.example"))
.expect("connect_starttls");
block_on(client.login("user", "pass")).expect("login");
let err = block_on(client.starttls()).expect_err("must fail");
assert!(matches!(err, SmtpError::InvalidInput(_)));
assert_eq!(*upgrades.borrow(), 1);
}
fn greeting_then_ehlo_with_esmtp() -> Vec<u8> {
flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250-PIPELINING\r\n",
b"250-ENHANCEDSTATUSCODES\r\n",
b"250 AUTH PLAIN\r\n",
])
}
#[test]
fn unexpected_code_carries_enhanced_when_advertised() {
let script = flatten(&[
&greeting_then_ehlo_with_esmtp()[..],
b"235 2.7.0 ok\r\n", b"550 5.7.1 relay access denied\r\n", ]);
let (transport, _w, _c) = MockTransport::new(&[&script[..]]);
let mut client =
block_on(SmtpClient::connect(transport, "client.example")).expect("connect");
block_on(client.login("user", "pass")).expect("login");
let err = block_on(client.send_mail(
"a@example.com",
&["b@example.org"],
"Subject: x\r\n\r\nx\r\n",
))
.expect_err("must fail");
match err {
SmtpError::Protocol(ProtocolError::UnexpectedCode {
during,
actual,
enhanced,
message,
..
}) => {
assert_eq!(during, SmtpOp::MailFrom);
assert_eq!(actual, 550);
let es = enhanced.expect("enhanced should be Some when advertised");
assert_eq!(es.class, 5);
assert_eq!(es.subject, 7);
assert_eq!(es.detail, 1);
assert!(message.contains("5.7.1"));
}
other => panic!("expected UnexpectedCode with enhanced, got {other:?}"),
}
}
#[test]
fn unexpected_code_no_enhanced_when_not_advertised() {
let script = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250 AUTH PLAIN\r\n",
b"235 OK\r\n",
b"550 5.7.1 relay access denied\r\n",
]);
let (transport, _w, _c) = MockTransport::new(&[&script[..]]);
let mut client =
block_on(SmtpClient::connect(transport, "client.example")).expect("connect");
block_on(client.login("user", "pass")).expect("login");
let err = block_on(client.send_mail(
"a@example.com",
&["b@example.org"],
"Subject: x\r\n\r\nx\r\n",
))
.expect_err("must fail");
match err {
SmtpError::Protocol(ProtocolError::UnexpectedCode { enhanced, .. }) => {
assert!(
enhanced.is_none(),
"without EHLO advertisement, enhanced must be None"
);
}
other => panic!("unexpected error variant: {other:?}"),
}
}
#[test]
fn unexpected_code_display_includes_enhanced_bracket() {
let script = flatten(&[
&greeting_then_ehlo_with_esmtp()[..],
b"235 OK\r\n",
b"550 5.7.1 relay access denied\r\n",
]);
let (transport, _w, _c) = MockTransport::new(&[&script[..]]);
let mut client =
block_on(SmtpClient::connect(transport, "client.example")).expect("connect");
block_on(client.login("user", "pass")).expect("login");
let err = block_on(client.send_mail(
"a@example.com",
&["b@example.org"],
"Subject: x\r\n\r\nx\r\n",
))
.expect_err("must fail");
let s = format!("{err}");
assert!(
s.contains("[5.7.1]"),
"Display should include enhanced bracket: {s}"
);
assert!(s.contains("550"), "Display should include basic code: {s}");
}
#[test]
fn auth_rejected_carries_enhanced_when_advertised() {
let script = flatten(&[
&greeting_then_ehlo_with_esmtp()[..],
b"535 5.7.8 invalid credentials\r\n",
]);
let (transport, _w, _c) = MockTransport::new(&[&script[..]]);
let mut client =
block_on(SmtpClient::connect(transport, "client.example")).expect("connect");
let err = block_on(client.login("user", "pass")).expect_err("must fail");
match err {
SmtpError::Auth(AuthError::Rejected {
code,
enhanced,
message,
}) => {
assert_eq!(code, 535);
let es = enhanced.expect("enhanced should be Some");
assert_eq!((es.class, es.subject, es.detail), (5, 7, 8));
assert!(message.contains("5.7.8"));
}
other => panic!("expected Auth::Rejected with enhanced, got {other:?}"),
}
}
#[test]
fn enhancedstatuscodes_disabled_after_starttls_re_ehlo_without_it() {
let script = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250-STARTTLS\r\n",
b"250 ENHANCEDSTATUSCODES\r\n",
b"220 ready\r\n",
b"250-mail.example.com\r\n",
b"250 AUTH PLAIN\r\n",
b"535 5.7.8 invalid\r\n", ]);
let (transport, _w, _c, _u) =
MockTransport::with_starttls(&[&script[..]], UpgradeBehavior::Succeed);
let mut client = block_on(SmtpClient::connect_starttls(transport, "client.example"))
.expect("connect_starttls");
let err = block_on(client.login("user", "pass")).expect_err("must fail");
match err {
SmtpError::Auth(AuthError::Rejected { enhanced, .. }) => {
assert!(
enhanced.is_none(),
"post-TLS EHLO dropped ENHANCEDSTATUSCODES: enhanced must be None"
);
}
other => panic!("unexpected: {other:?}"),
}
}
#[cfg(feature = "xoauth2")]
fn greeting_then_ehlo_with_xoauth2() -> Vec<u8> {
flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250-PIPELINING\r\n",
b"250 AUTH PLAIN LOGIN XOAUTH2\r\n",
])
}
#[cfg(feature = "xoauth2")]
#[test]
fn xoauth2_happy_path_succeeds_directly() {
let script = flatten(&[
&greeting_then_ehlo_with_xoauth2()[..],
b"235 2.7.0 Accepted\r\n",
]);
let (transport, written, _c) = MockTransport::new(&[&script[..]]);
let mut client =
block_on(SmtpClient::connect(transport, "client.example")).expect("connect");
block_on(client.login_xoauth2("user@example.com", "ya29.token")).expect("login");
let mut payload = Vec::new();
payload.extend_from_slice(b"user=user@example.com\x01auth=Bearer ya29.token\x01\x01");
let b64 = crate::protocol::base64_encode(&payload);
let expected = format!("EHLO client.example\r\nAUTH XOAUTH2 {b64}\r\n");
assert_eq!(&*written.borrow(), expected.as_bytes());
assert_eq!(client.state(), SessionState::MailFrom);
}
#[cfg(feature = "xoauth2")]
#[test]
fn xoauth2_login_with_explicit_mechanism_works() {
let script = flatten(&[&greeting_then_ehlo_with_xoauth2()[..], b"235 OK\r\n"]);
let (transport, _w, _c) = MockTransport::new(&[&script[..]]);
let mut client =
block_on(SmtpClient::connect(transport, "client.example")).expect("connect");
block_on(client.login_with(AuthMechanism::XOAuth2, "user@example.com", "ya29.token"))
.expect("login_with XOAuth2");
assert_eq!(client.state(), SessionState::MailFrom);
}
#[cfg(feature = "xoauth2")]
#[test]
fn xoauth2_handles_334_error_continuation() {
let script = flatten(&[
&greeting_then_ehlo_with_xoauth2()[..],
b"334 eyJzdGF0dXMiOiI0MDEifQ==\r\n", b"535 5.7.8 Username and Password not accepted\r\n",
]);
let (transport, written, _c) = MockTransport::new(&[&script[..]]);
let mut client =
block_on(SmtpClient::connect(transport, "client.example")).expect("connect");
let err = block_on(client.login_xoauth2("user@example.com", "ya29.token"))
.expect_err("must fail");
match err {
SmtpError::Auth(AuthError::Rejected { code, message, .. }) => {
assert_eq!(code, 535);
assert!(message.contains("Username and Password"));
}
other => panic!("expected Auth::Rejected, got {other:?}"),
}
let bytes = written.borrow();
let s = std::str::from_utf8(&bytes).unwrap();
assert!(
s.ends_with("\r\n\r\n"),
"must end with empty continuation line: {s:?}"
);
assert_eq!(client.state(), SessionState::Closed);
}
#[cfg(feature = "xoauth2")]
#[test]
fn xoauth2_returns_unsupported_when_not_advertised() {
let script = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250 AUTH PLAIN LOGIN\r\n", ]);
let (transport, _w, _c) = MockTransport::new(&[&script[..]]);
let mut client =
block_on(SmtpClient::connect(transport, "client.example")).expect("connect");
let err = block_on(client.login_xoauth2("user", "ya29.token")).expect_err("must fail");
assert!(matches!(
err,
SmtpError::Auth(AuthError::UnsupportedMechanism)
));
assert_eq!(client.state(), SessionState::Closed);
}
#[cfg(feature = "xoauth2")]
#[test]
fn xoauth2_validates_token_before_io() {
let script = flatten(&[&greeting_then_ehlo_with_xoauth2()[..]]);
let (transport, written, _c) = MockTransport::new(&[&script[..]]);
let mut client =
block_on(SmtpClient::connect(transport, "client.example")).expect("connect");
let after_ehlo = written.borrow().len();
let err = block_on(client.login_xoauth2("user", "bad token")).expect_err("must fail");
assert!(matches!(err, SmtpError::InvalidInput(_)));
assert_eq!(written.borrow().len(), after_ehlo);
assert_eq!(client.state(), SessionState::Authentication);
}
#[cfg(feature = "xoauth2")]
#[test]
fn xoauth2_validates_user_before_io() {
let script = flatten(&[&greeting_then_ehlo_with_xoauth2()[..]]);
let (transport, _w, _c) = MockTransport::new(&[&script[..]]);
let mut client =
block_on(SmtpClient::connect(transport, "client.example")).expect("connect");
let err = block_on(client.login_xoauth2("u\x01v", "ya29.token")).expect_err("must fail");
assert!(matches!(err, SmtpError::InvalidInput(_)));
assert_eq!(client.state(), SessionState::Authentication);
}
#[cfg(feature = "xoauth2")]
#[test]
fn xoauth2_with_enhanced_status_propagates_code() {
let script = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250-ENHANCEDSTATUSCODES\r\n",
b"250 AUTH XOAUTH2\r\n",
b"334 eyJzdGF0dXMiOiI0MDEifQ==\r\n",
b"535 5.7.8 Bad credentials\r\n",
]);
let (transport, _w, _c) = MockTransport::new(&[&script[..]]);
let mut client =
block_on(SmtpClient::connect(transport, "client.example")).expect("connect");
let err = block_on(client.login_xoauth2("user@example.com", "ya29.token"))
.expect_err("must fail");
match err {
SmtpError::Auth(AuthError::Rejected { enhanced, .. }) => {
let es = enhanced.expect("enhanced should be Some");
assert_eq!((es.class, es.subject, es.detail), (5, 7, 8));
}
other => panic!("unexpected: {other:?}"),
}
}
}
#[cfg(feature = "smtputf8")]
mod smtputf8_tests {
use super::harness::{MockTransport, block_on, flatten};
use crate::client::SmtpClient;
use crate::error::{ProtocolError, SmtpError, SmtpOp};
use crate::protocol::{
ehlo_advertises_smtputf8, format_mail_from_smtputf8, validate_address_utf8,
};
use crate::session::SessionState;
#[test]
fn ehlo_advertises_smtputf8_finds_listed_extension() {
let lines: Vec<String> = vec!["PIPELINING".into(), "SMTPUTF8".into()];
assert!(ehlo_advertises_smtputf8(&lines));
}
#[test]
fn ehlo_advertises_smtputf8_is_case_insensitive() {
let lines: Vec<String> = vec!["smtputf8".into()];
assert!(ehlo_advertises_smtputf8(&lines));
}
#[test]
fn ehlo_advertises_smtputf8_returns_false_when_absent() {
let lines: Vec<String> = vec!["PIPELINING".into(), "AUTH PLAIN".into()];
assert!(!ehlo_advertises_smtputf8(&lines));
}
#[test]
fn ehlo_advertises_smtputf8_does_not_match_substrings() {
let lines: Vec<String> = vec!["SMTPUTF8X".into()];
assert!(!ehlo_advertises_smtputf8(&lines));
}
#[test]
fn validate_address_utf8_accepts_ascii() {
assert!(validate_address_utf8("user@example.com").is_ok());
assert!(validate_address_utf8("a.b+c@d.example").is_ok());
}
#[test]
fn validate_address_utf8_accepts_japanese_local_part() {
assert!(validate_address_utf8("\u{9001}\u{4FE1}@example.jp").is_ok());
}
#[test]
fn validate_address_utf8_accepts_idn_domain() {
assert!(validate_address_utf8("user@\u{4F8B}\u{3048}.jp").is_ok());
}
#[test]
fn validate_address_utf8_accepts_combined_local_and_domain() {
assert!(validate_address_utf8("\u{9001}\u{4FE1}@\u{4F8B}\u{3048}.jp").is_ok());
}
#[test]
fn validate_address_utf8_rejects_empty() {
assert!(validate_address_utf8("").is_err());
}
#[test]
fn validate_address_utf8_rejects_crlf() {
assert!(validate_address_utf8("a\r@b.com").is_err());
assert!(validate_address_utf8("a\n@b.com").is_err());
}
#[test]
fn validate_address_utf8_rejects_nul() {
assert!(validate_address_utf8("a\0b@c.com").is_err());
}
#[test]
fn validate_address_utf8_rejects_angle_brackets() {
assert!(validate_address_utf8("<a@b.com>").is_err());
assert!(validate_address_utf8("a@b<c.com").is_err());
}
#[test]
fn validate_address_utf8_rejects_ascii_whitespace() {
assert!(validate_address_utf8("a b@c.com").is_err());
assert!(validate_address_utf8("a\tb@c.com").is_err());
}
#[test]
fn validate_address_utf8_accepts_ideographic_space() {
assert!(validate_address_utf8("a\u{3000}b@c.com").is_ok());
}
#[test]
fn validate_address_utf8_rejects_ascii_control_chars() {
assert!(validate_address_utf8("a\u{007F}b@c.com").is_err());
assert!(validate_address_utf8("a\u{0007}b@c.com").is_err());
}
#[test]
fn validate_address_utf8_rejects_c1_control_chars() {
assert!(validate_address_utf8("a\u{0085}b@c.com").is_err());
assert!(validate_address_utf8("a\u{0095}b@c.com").is_err());
}
#[test]
fn format_mail_from_smtputf8_appends_parameter() {
let bytes = format_mail_from_smtputf8("user@example.com");
assert_eq!(bytes, b"MAIL FROM:<user@example.com> SMTPUTF8\r\n");
}
#[test]
fn format_mail_from_smtputf8_carries_utf8_address() {
let bytes = format_mail_from_smtputf8("\u{9001}\u{4FE1}@example.jp");
let mut expected: Vec<u8> = Vec::new();
expected.extend_from_slice(b"MAIL FROM:<");
expected.extend_from_slice("\u{9001}\u{4FE1}@example.jp".as_bytes());
expected.extend_from_slice(b"> SMTPUTF8\r\n");
assert_eq!(bytes, expected);
}
fn greeting_then_ehlo_with_smtputf8() -> Vec<u8> {
flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250-PIPELINING\r\n",
b"250-SMTPUTF8\r\n",
b"250 AUTH PLAIN\r\n",
])
}
#[test]
fn send_mail_smtputf8_full_flow_with_japanese_addresses() {
let script = flatten(&[
&greeting_then_ehlo_with_smtputf8()[..],
b"235 OK\r\n", b"250 OK\r\n", b"250 OK\r\n", b"354 go ahead\r\n", b"250 Queued\r\n", ]);
let (transport, written, _c) = MockTransport::new(&[&script[..]]);
let mut client =
block_on(SmtpClient::connect(transport, "client.example")).expect("connect");
block_on(client.login("user", "pass")).expect("login");
block_on(client.send_mail_smtputf8(
"\u{9001}\u{4FE1}@example.jp",
&["\u{53D7}\u{4FE1}@\u{4F8B}\u{3048}.jp"],
"Subject: hi\r\n\r\nbody\r\n",
))
.expect("send_mail_smtputf8");
let bytes = written.borrow();
let s = std::str::from_utf8(&bytes).expect("bytes are valid UTF-8");
assert!(s.contains("MAIL FROM:<\u{9001}\u{4FE1}@example.jp> SMTPUTF8\r\n"));
assert!(s.contains("RCPT TO:<\u{53D7}\u{4FE1}@\u{4F8B}\u{3048}.jp>\r\n"));
assert_eq!(client.state(), SessionState::MailFrom);
}
#[test]
fn send_mail_smtputf8_fails_when_extension_not_advertised() {
let script = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250 AUTH PLAIN\r\n",
b"235 OK\r\n",
]);
let (transport, written, _c) = MockTransport::new(&[&script[..]]);
let mut client =
block_on(SmtpClient::connect(transport, "client.example")).expect("connect");
block_on(client.login("user", "pass")).expect("login");
let after_login = written.borrow().len();
let err = block_on(client.send_mail_smtputf8(
"\u{9001}\u{4FE1}@example.jp",
&["\u{53D7}\u{4FE1}@example.jp"],
"Subject: x\r\n\r\nx\r\n",
))
.expect_err("must fail");
match err {
SmtpError::Protocol(ProtocolError::ExtensionUnavailable { name }) => {
assert_eq!(name, "SMTPUTF8");
}
other => panic!("expected ExtensionUnavailable, got {other:?}"),
}
assert_eq!(written.borrow().len(), after_login);
assert_eq!(client.state(), SessionState::Closed);
}
#[test]
fn send_mail_smtputf8_validates_addresses_before_io() {
let script = flatten(&[&greeting_then_ehlo_with_smtputf8()[..], b"235 OK\r\n"]);
let (transport, written, _c) = MockTransport::new(&[&script[..]]);
let mut client =
block_on(SmtpClient::connect(transport, "client.example")).expect("connect");
block_on(client.login("user", "pass")).expect("login");
let after_login = written.borrow().len();
let err = block_on(client.send_mail_smtputf8(
"u\rser@example.com",
&["b@example.org"],
"Subject: x\r\n\r\nx\r\n",
))
.expect_err("must fail");
assert!(matches!(err, SmtpError::InvalidInput(_)));
assert_eq!(written.borrow().len(), after_login);
assert_eq!(client.state(), SessionState::MailFrom);
}
#[test]
fn send_mail_smtputf8_rejects_server_error_during_mail_from() {
let script = flatten(&[
&greeting_then_ehlo_with_smtputf8()[..],
b"235 OK\r\n", b"550 sender domain refused\r\n", ]);
let (transport, _w, _c) = MockTransport::new(&[&script[..]]);
let mut client =
block_on(SmtpClient::connect(transport, "client.example")).expect("connect");
block_on(client.login("user", "pass")).expect("login");
let err = block_on(client.send_mail_smtputf8(
"\u{9001}\u{4FE1}@example.jp",
&["b@example.org"],
"Subject: x\r\n\r\nx\r\n",
))
.expect_err("must fail");
match err {
SmtpError::Protocol(ProtocolError::UnexpectedCode { during, actual, .. }) => {
assert_eq!(during, SmtpOp::MailFrom);
assert_eq!(actual, 550);
}
other => panic!("expected UnexpectedCode for MailFrom: {other:?}"),
}
}
#[test]
fn ascii_send_mail_unchanged_when_smtputf8_feature_enabled() {
let script = flatten(&[&greeting_then_ehlo_with_smtputf8()[..], b"235 OK\r\n"]);
let (transport, _w, _c) = MockTransport::new(&[&script[..]]);
let mut client =
block_on(SmtpClient::connect(transport, "client.example")).expect("connect");
block_on(client.login("user", "pass")).expect("login");
let err = block_on(client.send_mail(
"\u{9001}\u{4FE1}@example.jp",
&["b@example.org"],
"Subject: x\r\n\r\nx\r\n",
))
.expect_err("must fail");
assert!(matches!(err, SmtpError::InvalidInput(_)));
}
}