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_pre_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",
])
}
fn starttls_post_upgrade() -> Vec<u8> {
flatten(&[
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_pre_upgrade()[..]],
&[&starttls_post_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 pre = 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 post = flatten(&[
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(&[&pre[..]], &[&post[..]], 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_pre_upgrade()[..]],
&[&starttls_post_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 pre = 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 post = flatten(&[
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(&[&pre[..]], &[&post[..]], 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);
}
#[test]
fn starttls_buffer_residue_aborts_upgrade() {
let pre_with_injected_residue = 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"NOOP smuggled\r\n",
b"MAIL FROM:<attacker@example.com>\r\n",
]);
let (transport, _w, closed, upgrades) = MockTransport::with_starttls(
&[&pre_with_injected_residue[..]],
&[],
UpgradeBehavior::Succeed,
);
let err = block_on(SmtpClient::connect_starttls(transport, "c.example"))
.expect_err("must reject the injected residue");
match err {
SmtpError::Protocol(ProtocolError::StartTlsBufferResidue { byte_count }) => {
assert!(byte_count > 0, "byte_count must be positive: {byte_count}");
}
other => panic!("expected StartTlsBufferResidue, got {other:?}"),
}
assert_eq!(
*upgrades.borrow(),
0,
"upgrade_to_tls must not be called when residue is detected"
);
let _ = closed; }
#[test]
fn starttls_buffer_residue_byte_count_is_residual_length() {
let pre = 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"X\r\n", ]);
let (transport, _w, _c, _u) =
MockTransport::with_starttls(&[&pre[..]], &[], UpgradeBehavior::Succeed);
let err =
block_on(SmtpClient::connect_starttls(transport, "c.example")).expect_err("must reject");
match err {
SmtpError::Protocol(ProtocolError::StartTlsBufferResidue { byte_count }) => {
assert_eq!(byte_count, 3, "expected exactly the 3 bytes of `X\\r\\n`");
}
other => panic!("unexpected: {other:?}"),
}
}
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 starttls_aborts_upgrade_when_buffer_holds_residue() {
let pre = flatten(&[
b"220 mail.example.com ESMTP\r\n",
b"250-mail.example.com\r\n",
b"250 STARTTLS\r\n",
b"220 ready to start TLS\r\n",
b"250 INJECTED capability\r\n",
]);
let (transport, _w, _c, upgrades) =
MockTransport::with_starttls(&[&pre[..]], &[], UpgradeBehavior::Succeed);
let err = block_on(SmtpClient::connect_starttls(transport, "client.example"))
.expect_err("must fail with residue error");
match err {
SmtpError::Protocol(ProtocolError::StartTlsBufferResidue { byte_count }) => {
assert_eq!(byte_count, 25);
}
other => panic!("expected StartTlsBufferResidue, got {other:?}"),
}
assert_eq!(*upgrades.borrow(), 0);
}
#[test]
fn enhancedstatuscodes_disabled_after_starttls_re_ehlo_without_it() {
let pre = 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",
]);
let post = flatten(&[
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(&[&pre[..]], &[&post[..]], 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:?}"),
}
}