use super::*;
impl SmtpConnection {
#[cfg(test)]
async fn read_response_public(&self) -> Result<SmtpResponse, Error> {
self.inner.lock().await.read_response().await
}
#[cfg(test)]
async fn validate_params_public(
&self,
params: Option<&crate::types::MailFromParams>,
) -> Result<(), Error> {
let inner = self.inner.lock().await;
Self::validate_params(&inner.capabilities, params, inner.stream.is_tls())
}
#[cfg(test)]
#[allow(clippy::expect_used)]
fn from_plain(stream: TcpStream, capabilities: ServerCapabilities, protocol: Protocol) -> Self {
Self {
inner: tokio::sync::Mutex::new(SmtpInner {
stream: SmtpStream::Plain(stream),
read_buf: BytesMut::with_capacity(4096),
capabilities,
ehlo_domain: default_ehlo_domain().expect("default EHLO address-literal is valid"),
authenticated: false,
server_shutting_down: false,
helo_mode: false,
}),
protocol,
}
}
}
use crate::types::SmtpExtension;
use base64::Engine;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpListener;
async fn mock_server(responses: Vec<&'static str>) -> TcpStream {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
for resp in responses {
socket.write_all(resp.as_bytes()).await.unwrap();
socket.flush().await.unwrap();
}
tokio::time::sleep(Duration::from_millis(200)).await;
});
TcpStream::connect(addr).await.unwrap()
}
async fn mock_interactive_server(interactions: Vec<(&'static str, &'static str)>) -> TcpStream {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut tmp = [0u8; 4096];
for (expected_contains, response) in interactions {
if !expected_contains.is_empty() {
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut tmp).await.unwrap();
assert!(
n > 0,
"connection closed while waiting for {expected_contains:?}"
);
accumulated.extend_from_slice(&tmp[..n]);
let text = String::from_utf8_lossy(&accumulated);
if text.contains(expected_contains) {
break;
}
}
}
socket.write_all(response.as_bytes()).await.unwrap();
socket.flush().await.unwrap();
}
tokio::time::sleep(Duration::from_millis(200)).await;
});
TcpStream::connect(addr).await.unwrap()
}
fn smtp_caps_with_pipelining() -> ServerCapabilities {
ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Pipelining],
}
}
fn smtp_caps_no_pipelining() -> ServerCapabilities {
ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![],
}
}
#[tokio::test]
async fn size_param_sent_when_extension_advertised_without_limit() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 4096];
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0);
accumulated.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&accumulated);
if text.contains("MAIL FROM:") {
assert!(
text.contains("SIZE="),
"MAIL FROM must include SIZE= when SIZE extension is \
advertised (RFC 1870 Section 3). Got: {text}"
);
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("RCPT TO:") {
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("DATA") {
break;
}
}
socket.write_all(b"354 Start\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("\r\n.\r\n") {
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Size(None)],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello",
Duration::from_secs(5),
)
.await;
assert!(result.is_ok(), "send failed: {result:?}");
server.await.unwrap();
}
#[tokio::test]
async fn size_param_uses_original_message_size() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let message = b"Subject: Test\r\n\r\n.line1\r\n.line2\r\n";
let raw_len = message.len();
let stuffed_len = encode::dot_stuff(message).len();
assert!(
stuffed_len > raw_len,
"test setup: stuffed size ({stuffed_len}) must exceed \
raw size ({raw_len})"
);
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 4096];
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0);
accumulated.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&accumulated);
if text.contains("MAIL FROM:") {
let size_idx = text.find("SIZE=").expect(
"MAIL FROM must include SIZE= when server \
advertises SIZE",
);
let after_size = &text[size_idx + 5..];
let size_str: String = after_size
.chars()
.take_while(char::is_ascii_digit)
.collect();
let declared_size: usize = size_str.parse().unwrap();
assert_eq!(
declared_size, raw_len,
"RFC 1870 Section 5: SIZE must be the raw message \
size ({raw_len}), not the dot-stuffed wire \
size ({stuffed_len}). Got: {declared_size}"
);
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("RCPT TO:") {
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("DATA") {
break;
}
}
socket.write_all(b"354 Start\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("\r\n.\r\n") {
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Size(None)],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
message,
Duration::from_secs(5),
)
.await;
assert!(result.is_ok(), "send failed: {result:?}");
server.await.unwrap();
}
#[tokio::test]
async fn pipelined_send_all_recipients_ok() {
let stream = mock_server(vec![
"250 OK\r\n",
"250 OK\r\n",
"250 OK\r\n",
"354 Start\r\n",
"250 Message accepted\r\n",
])
.await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_with_pipelining(), Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[
ForwardPath::new("rcpt1@example.com").unwrap(),
ForwardPath::new("rcpt2@example.com").unwrap(),
],
b"Subject: Test\r\n\r\nHello",
Duration::from_secs(5),
)
.await;
assert!(result.is_ok(), "expected success, got: {result:?}");
}
#[tokio::test]
async fn pipelined_send_mail_from_failure() {
let stream = mock_server(vec![
"550 Bad sender\r\n",
"250 OK\r\n",
"354 Start\r\n",
])
.await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_with_pipelining(), Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("bad@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello",
Duration::from_secs(5),
)
.await;
assert!(result.is_err());
match result.unwrap_err() {
Error::Permanent { code, .. } => assert_eq!(code, 550),
other => panic!("expected Permanent error, got: {other:?}"),
}
}
#[tokio::test]
async fn pipelined_mail_from_rejected_with_354_sends_dot_terminator() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 4096];
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(
n > 0,
"connection closed while waiting for pipelined commands"
);
accumulated.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&accumulated);
if text.contains("DATA") {
break;
}
}
socket
.write_all(b"550 Bad sender\r\n250 OK\r\n354 Start mail input\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
let dot_received = tokio::time::timeout(Duration::from_secs(3), async {
loop {
let n = socket.read(&mut buf).await.unwrap();
if n == 0 {
return false;
}
accumulated.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&accumulated);
if text.contains("\r\n.\r\n") || text.starts_with(".\r\n") {
return true;
}
}
})
.await;
assert!(
matches!(dot_received, Ok(true)),
"client must send dot terminator after pipelined MAIL FROM \
rejection when DATA returned 354 (RFC 1854 Section 3 / \
RFC 5321 Section 3.3): dot_received={dot_received:?}"
);
socket
.write_all(b"250 OK message accepted\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let conn = SmtpConnection::from_plain(stream, smtp_caps_with_pipelining(), Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("bad@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello",
Duration::from_secs(5),
)
.await;
assert!(result.is_err());
match result.unwrap_err() {
Error::Permanent { code, .. } => assert_eq!(code, 550),
other => panic!("expected Permanent error, got: {other:?}"),
}
server.await.unwrap();
}
#[tokio::test]
async fn pipelined_drain_checks_data_response_by_index_not_last_read() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 4096];
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0, "connection closed before pipelined commands");
accumulated.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&accumulated);
if text.contains("DATA") {
break;
}
}
socket
.write_all(b"550 Bad sender\r\n354 Go ahead\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
drop(socket);
});
let stream = TcpStream::connect(addr).await.unwrap();
let conn = SmtpConnection::from_plain(stream, smtp_caps_with_pipelining(), Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("bad@example.com").unwrap(),
&[
ForwardPath::new("rcpt1@example.com").unwrap(),
ForwardPath::new("rcpt2@example.com").unwrap(),
ForwardPath::new("rcpt3@example.com").unwrap(),
],
b"Subject: Test\r\n\r\nHello",
Duration::from_secs(5),
)
.await;
assert!(result.is_err());
match result.unwrap_err() {
Error::Permanent { code, .. } => assert_eq!(code, 550),
other => panic!("expected Permanent 550 error, got: {other:?}"),
}
server.await.unwrap();
}
#[tokio::test]
async fn pipelined_drain_no_dot_terminator_on_non_data_354() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 4096];
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0, "connection closed before pipelined commands");
accumulated.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&accumulated);
if text.contains("DATA") {
break;
}
}
socket
.write_all(b"550 Bad sender\r\n354 Go ahead\r\nXX\r\n503 Bad sequence\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
let received_data = tokio::time::timeout(Duration::from_millis(500), async {
loop {
let n = match socket.read(&mut buf).await {
Ok(0) | Err(_) => return false,
Ok(n) => n,
};
accumulated.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&accumulated);
if text.contains(".\r\n") {
return true; }
}
})
.await;
assert!(
!matches!(received_data, Ok(true)),
"client must NOT send dot terminator when the 354 was an \
RCPT TO response, not the DATA response (RFC 1854 Section 3)"
);
tokio::time::sleep(Duration::from_millis(100)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let conn = SmtpConnection::from_plain(stream, smtp_caps_with_pipelining(), Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("bad@example.com").unwrap(),
&[
ForwardPath::new("rcpt1@example.com").unwrap(),
ForwardPath::new("rcpt2@example.com").unwrap(),
],
b"Subject: Test\r\n\r\nHello",
Duration::from_secs(5),
)
.await;
assert!(result.is_err());
match result.unwrap_err() {
Error::Permanent { code, .. } => assert_eq!(code, 550),
other => panic!("expected Permanent 550 error, got: {other:?}"),
}
server.await.unwrap();
}
#[tokio::test]
async fn pipelined_send_all_rcpt_tos_fail() {
let stream = mock_server(vec![
"250 OK\r\n",
"550 User unknown\r\n",
"550 User unknown\r\n",
"354 Start\r\n",
"250 OK\r\n",
])
.await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_with_pipelining(), Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[
ForwardPath::new("bad1@example.com").unwrap(),
ForwardPath::new("bad2@example.com").unwrap(),
],
b"Subject: Test\r\n\r\nHello",
Duration::from_secs(5),
)
.await;
assert!(result.is_err());
match result.unwrap_err() {
Error::AllRecipientsFailed { count, responses } => {
assert_eq!(count, 2);
assert_eq!(responses.len(), 2);
}
other => panic!("expected AllRecipientsFailed, got: {other:?}"),
}
}
#[tokio::test]
async fn pipelined_all_rcpt_rejected_server_closes_returns_all_recipients_failed() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 4096];
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(
n > 0,
"connection closed while waiting for pipelined commands"
);
accumulated.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&accumulated);
if text.contains("DATA") {
break;
}
}
socket
.write_all(
b"250 OK\r\n\
550 User unknown\r\n\
550 User unknown\r\n\
354 Start mail input\r\n",
)
.await
.unwrap();
socket.flush().await.unwrap();
let std_sock = socket.into_std().unwrap();
let sock2 = socket2::Socket::from(std_sock);
sock2.set_linger(Some(Duration::from_secs(0))).unwrap();
drop(sock2);
});
let stream = TcpStream::connect(addr).await.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_with_pipelining(), Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[
ForwardPath::new("bad1@example.com").unwrap(),
ForwardPath::new("bad2@example.com").unwrap(),
],
b"Subject: Test\r\n\r\nHello",
Duration::from_secs(5),
)
.await;
assert!(result.is_err());
match result.unwrap_err() {
Error::AllRecipientsFailed { count, responses } => {
assert_eq!(count, 2, "must report both rejected recipients");
assert_eq!(responses.len(), 2);
for resp in &responses {
assert_eq!(resp.code, 550);
}
}
other => panic!(
"expected AllRecipientsFailed, got: {other:?} \
(bug: cleanup write propagated I/O error instead of \
returning AllRecipientsFailed per RFC 1854 Section 3)"
),
}
server.await.unwrap();
}
#[tokio::test]
async fn sequential_send_success() {
let stream = mock_interactive_server(vec![
("MAIL FROM:", "250 OK\r\n"),
("RCPT TO:", "250 OK\r\n"),
("DATA", "354 Start\r\n"),
("\r\n.\r\n", "250 Message accepted\r\n"),
])
.await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello",
Duration::from_secs(5),
)
.await;
assert!(result.is_ok(), "expected success, got: {result:?}");
}
#[tokio::test]
async fn sequential_send_mail_from_failure() {
let stream = mock_interactive_server(vec![
("MAIL FROM:", "550 Bad sender\r\n"),
])
.await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("bad@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello",
Duration::from_secs(5),
)
.await;
assert!(result.is_err());
match result.unwrap_err() {
Error::Permanent { code, .. } => assert_eq!(code, 550),
other => panic!("expected Permanent error, got: {other:?}"),
}
}
#[tokio::test]
async fn lmtp_send_per_recipient_responses() {
let stream = mock_interactive_server(vec![
("MAIL FROM:", "250 OK\r\n"),
("RCPT TO:", "250 OK\r\n"),
("RCPT TO:", "250 OK\r\n"),
("RCPT TO:", "550 Unknown user\r\n"),
("DATA", "354 Start\r\n"),
(
"\r\n.\r\n",
"250 Delivered to rcpt1\r\n550 Delivery failed for rcpt2\r\n",
),
])
.await;
let conn = SmtpConnection::from_plain(stream, ServerCapabilities::default(), Protocol::Lmtp);
let results = conn
.send_lmtp(
&ReversePath::new("sender@example.com").unwrap(),
&[
ForwardPath::new("rcpt1@example.com").unwrap(),
ForwardPath::new("rcpt2@example.com").unwrap(),
ForwardPath::new("rcpt3@example.com").unwrap(),
],
b"Subject: Test\r\n\r\nHello",
None,
Duration::from_secs(5),
)
.await
.unwrap();
assert_eq!(results.results.len(), 2);
assert_eq!(results.results[0].recipient.as_str(), "rcpt1@example.com");
assert!(results.results[0].response.is_success());
assert_eq!(results.results[1].recipient.as_str(), "rcpt2@example.com");
assert!(results.results[1].response.is_permanent_error());
}
#[tokio::test]
async fn lmtp_send_with_params_includes_body_8bitmime() {
use crate::types::{BodyType, MailFromParams};
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 4096];
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0);
accumulated.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&accumulated);
if text.contains("MAIL FROM:") {
assert!(
text.contains("BODY=8BITMIME"),
"MAIL FROM must include BODY=8BITMIME when caller \
requests it (RFC 1652 Section 3). Got: {text}"
);
assert!(
text.contains("SMTPUTF8"),
"MAIL FROM must include SMTPUTF8 when caller \
requests it (RFC 6531 Section 3.4). Got: {text}"
);
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("RCPT TO:") {
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("DATA") {
break;
}
}
socket.write_all(b"354 Start\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("\r\n.\r\n") {
break;
}
}
socket.write_all(b"250 Delivered\r\n").await.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::EightBitMime, SmtpExtension::SmtpUtf8],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Lmtp);
let params = MailFromParams {
body: Some(BodyType::EightBitMime),
smtputf8: true,
..Default::default()
};
let results = conn
.send_lmtp(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
"Subject: Test\r\n\r\nHéllo".as_bytes(),
Some(¶ms),
Duration::from_secs(5),
)
.await;
assert!(results.is_ok(), "send_lmtp with params failed: {results:?}");
let results = results.unwrap();
assert_eq!(results.results.len(), 1);
assert!(results.results[0].response.is_success());
server.await.unwrap();
}
#[tokio::test]
async fn send_lmtp_on_smtp_connection_returns_protocol_error() {
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, ServerCapabilities::default(), Protocol::Smtp);
let result = conn
.send_lmtp(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Hello",
None,
Duration::from_secs(1),
)
.await;
assert!(result.is_err());
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(msg.contains("LMTP"), "expected LMTP mention: {msg}");
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn send_on_lmtp_connection_returns_protocol_error() {
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, ServerCapabilities::default(), Protocol::Lmtp);
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello\r\n",
Duration::from_secs(1),
)
.await;
assert!(result.is_err(), "send() must reject LMTP connections");
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("LMTP") && msg.contains("send_lmtp"),
"error must mention LMTP and direct to send_lmtp: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn send_with_params_on_lmtp_connection_returns_protocol_error() {
use crate::types::{BodyType, MailFromParams};
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::EightBitMime],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Lmtp);
let params = MailFromParams {
body: Some(BodyType::EightBitMime),
..Default::default()
};
let result = conn
.send_with_params(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello\r\n",
Some(¶ms),
Duration::from_secs(1),
)
.await;
assert!(
result.is_err(),
"send_with_params() must reject LMTP connections"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(msg.contains("LMTP"), "error must mention LMTP: {msg}");
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn reset_non_250_2xx_rejected() {
let stream = mock_interactive_server(vec![("RSET", "251 Not exactly reset\r\n")]).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let result = conn.reset(Duration::from_secs(5)).await;
assert!(
matches!(result, Err(Error::Protocol(_))),
"RSET must reject non-250 2xx responses (RFC 5321 Section 4.1.1.5): {result:?}"
);
}
#[tokio::test]
async fn noop_success() {
let stream = mock_server(vec!["250 OK\r\n"]).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let result = conn.noop(Duration::from_secs(5)).await;
assert!(result.is_ok(), "noop should succeed: {result:?}");
}
#[tokio::test]
async fn noop_error() {
let stream = mock_server(vec!["421 Service not available\r\n"]).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let result = conn.noop(Duration::from_secs(5)).await;
assert!(result.is_err());
}
#[tokio::test]
async fn noop_non_250_2xx_rejected() {
let stream = mock_server(vec!["251 Not exactly noop\r\n"]).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let result = conn.noop(Duration::from_secs(5)).await;
assert!(
matches!(result, Err(Error::Protocol(_))),
"NOOP must reject non-250 2xx responses (RFC 5321 Section 4.1.1.9): {result:?}"
);
}
#[tokio::test]
async fn quit_success() {
let stream = mock_interactive_server(vec![("QUIT", "221 Bye\r\n")]).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let result = conn.quit(Duration::from_secs(5)).await;
assert!(result.is_ok(), "quit should succeed: {result:?}");
}
#[tokio::test]
async fn quit_failure() {
let stream = mock_interactive_server(vec![("QUIT", "500 Error\r\n")]).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let result = conn.quit(Duration::from_secs(5)).await;
assert!(result.is_err(), "quit should fail on 500 response");
}
#[tokio::test]
async fn quit_non_221_2xx_rejected() {
let stream = mock_interactive_server(vec![("QUIT", "250 Not closing\r\n")]).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let result = conn.quit(Duration::from_secs(5)).await;
assert!(
matches!(result, Err(Error::Protocol(_))),
"QUIT must reject non-221 2xx responses (RFC 5321 Section 4.1.1.10): {result:?}"
);
}
#[tokio::test]
async fn send_timeout() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_secs(60)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Hello",
Duration::from_millis(200),
)
.await;
assert!(result.is_err());
match result.unwrap_err() {
Error::Timeout => {}
other => panic!("expected Timeout, got: {other:?}"),
}
}
#[test]
fn parse_single_line_response() {
let buf = b"250 OK\r\n";
let (resp, _consumed) = SmtpConnection::try_parse_response(buf).unwrap().unwrap();
assert_eq!(resp.code, 250);
assert_eq!(resp.lines, vec!["OK"]);
}
#[test]
fn parse_multi_line_response() {
let buf = b"250-mail.example.com\r\n250-PIPELINING\r\n250 SIZE 10240000\r\n";
let (resp, _consumed) = SmtpConnection::try_parse_response(buf).unwrap().unwrap();
assert_eq!(resp.code, 250);
assert_eq!(
resp.lines,
vec!["mail.example.com", "PIPELINING", "SIZE 10240000"]
);
}
#[test]
fn parse_response_enhanced_code_single_line() {
let buf = b"250 2.1.0 Sender OK\r\n";
let (resp, _consumed) = SmtpConnection::try_parse_response(buf).unwrap().unwrap();
assert_eq!(resp.code, 250);
let esc = resp.enhanced_code.unwrap();
assert_eq!(esc.class, 2);
assert_eq!(esc.subject, 1);
assert_eq!(esc.detail, 0);
assert_eq!(resp.lines, vec!["Sender OK"]);
}
#[test]
fn parse_response_enhanced_code_multi_line() {
let buf = b"550-5.1.1 User unknown\r\n550 try another mailbox\r\n";
let (resp, _consumed) = SmtpConnection::try_parse_response(buf).unwrap().unwrap();
assert_eq!(resp.code, 550);
let esc = resp.enhanced_code.unwrap();
assert_eq!(esc.class, 5);
assert_eq!(esc.subject, 1);
assert_eq!(esc.detail, 1);
assert_eq!(resp.lines, vec!["User unknown", "try another mailbox"]);
}
#[test]
fn parse_response_no_enhanced_code() {
let buf = b"250 OK\r\n";
let (resp, _consumed) = SmtpConnection::try_parse_response(buf).unwrap().unwrap();
assert!(resp.enhanced_code.is_none());
}
#[test]
fn parse_response_enhanced_code_class_mismatch_discarded() {
let buf = b"250 5.1.1 Sender OK\r\n";
let (resp, _consumed) = SmtpConnection::try_parse_response(buf).unwrap().unwrap();
assert_eq!(resp.code, 250);
assert!(
resp.enhanced_code.is_none(),
"enhanced code with mismatched class must be discarded (RFC 2034 Section 4)"
);
assert_eq!(resp.lines, vec!["5.1.1 Sender OK"]);
}
#[test]
fn parse_response_rejects_code_below_200() {
let buf = b"100 OK\r\n";
let result = SmtpConnection::try_parse_response(buf);
assert!(
result.is_err(),
"reply code 100 is outside 200-599 and must be rejected"
);
}
#[test]
fn parse_response_rejects_code_above_599() {
let buf = b"600 Weird\r\n";
let result = SmtpConnection::try_parse_response(buf);
assert!(
result.is_err(),
"reply code 600 is outside 200-599 and must be rejected"
);
}
#[test]
fn parse_response_accepts_boundary_codes() {
let buf = b"200 OK\r\n";
let (resp, _consumed) = SmtpConnection::try_parse_response(buf).unwrap().unwrap();
assert_eq!(resp.code, 200);
let buf = b"559 Error\r\n";
let (resp, _consumed) = SmtpConnection::try_parse_response(buf).unwrap().unwrap();
assert_eq!(resp.code, 559);
let buf = b"599 Error\r\n";
let (resp, _consumed) = SmtpConnection::try_parse_response(buf).unwrap().unwrap();
assert_eq!(resp.code, 599);
let buf = b"260 OK\r\n";
let (resp, _consumed) = SmtpConnection::try_parse_response(buf).unwrap().unwrap();
assert_eq!(resp.code, 260);
}
#[test]
fn parse_response_accepts_second_digit_6_through_9() {
let buf = b"268 OK\r\n";
let (resp, _consumed) = SmtpConnection::try_parse_response(buf).unwrap().unwrap();
assert_eq!(resp.code, 268);
let buf = b"569 Error\r\n";
let (resp, _consumed) = SmtpConnection::try_parse_response(buf).unwrap().unwrap();
assert_eq!(resp.code, 569);
let buf = b"297 Something\r\n";
let (resp, _consumed) = SmtpConnection::try_parse_response(buf).unwrap().unwrap();
assert_eq!(resp.code, 297);
let buf = b"480 Temporary\r\n";
let (resp, _consumed) = SmtpConnection::try_parse_response(buf).unwrap().unwrap();
assert_eq!(resp.code, 480);
let buf = b"168 Bad\r\n";
assert!(
SmtpConnection::try_parse_response(buf).is_err(),
"first digit 1 must still be rejected"
);
let buf = b"668 Bad\r\n";
assert!(
SmtpConnection::try_parse_response(buf).is_err(),
"first digit 6 must still be rejected"
);
}
#[test]
fn parse_incomplete_response() {
let buf = b"250-mail.example.com\r\n250-PIPE";
let result = SmtpConnection::try_parse_response(buf).unwrap();
assert!(result.is_none(), "expected None for incomplete response");
}
#[test]
fn parse_response_rejects_invalid_separator() {
let buf = b"250X foo\r\n";
let result = SmtpConnection::try_parse_response(buf);
assert!(
result.is_err(),
"reply line with invalid separator 'X' must be rejected (RFC 5321 Section 4.2)"
);
}
#[test]
fn parse_multiline_response_rejects_code_mismatch_as_protocol_error() {
let buf = b"250-OK\r\n451 Error\r\n";
let result = SmtpConnection::try_parse_response(buf);
let err = result.unwrap_err();
assert!(
matches!(err, Error::Protocol(_)),
"multiline reply code mismatch must be Error::Protocol, got: {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("RFC 5321"),
"error message must cite RFC 5321, got: {msg}"
);
}
fn cross_validate_parsers(input: &[u8]) {
let production = SmtpConnection::try_parse_response(input);
let nom_result = crate::codec::decode::parse_response(input);
match (&production, &nom_result) {
(Ok(Some((prod_resp, consumed))), Ok((remaining, nom_resp))) => {
let nom_consumed = input.len() - remaining.len();
assert_eq!(
*consumed,
nom_consumed,
"byte count mismatch: try_parse_response={consumed}, \
nom consumed={nom_consumed} for input {:?}",
String::from_utf8_lossy(input)
);
assert!(
remaining.is_empty(),
"nom parser did not consume all input: {} bytes remaining",
remaining.len()
);
assert_eq!(
prod_resp.code,
nom_resp.code,
"parsers disagree on reply code for input {:?}: \
production={}, nom={}",
String::from_utf8_lossy(input),
prod_resp.code,
nom_resp.code
);
assert_eq!(
prod_resp.enhanced_code,
nom_resp.enhanced_code,
"parsers disagree on enhanced code for input {:?}: \
production={:?}, nom={:?}",
String::from_utf8_lossy(input),
prod_resp.enhanced_code,
nom_resp.enhanced_code
);
assert_eq!(
prod_resp.lines,
nom_resp.lines,
"parsers disagree on lines for input {:?}: \
production={:?}, nom={:?}",
String::from_utf8_lossy(input),
prod_resp.lines,
nom_resp.lines
);
}
(Ok(None), Err(nom::Err::Incomplete(_))) | (Err(_), Err(_)) => {}
_ => {
panic!(
"parsers disagree on input {:?}:\n production: {:?}\n nom: {:?}",
String::from_utf8_lossy(input),
production,
nom_result
);
}
}
}
#[test]
fn cross_validate_single_line() {
cross_validate_parsers(b"250 OK\r\n");
}
#[test]
fn cross_validate_multi_line() {
cross_validate_parsers(b"250-mail.example.com\r\n250-PIPELINING\r\n250 SIZE 10240000\r\n");
}
#[test]
fn cross_validate_enhanced_code_single_line() {
cross_validate_parsers(b"250 2.1.0 Sender OK\r\n");
}
#[test]
fn cross_validate_enhanced_code_multi_line() {
cross_validate_parsers(b"550-5.1.1 User unknown\r\n550 try another mailbox\r\n");
}
#[test]
fn cross_validate_no_enhanced_code() {
cross_validate_parsers(b"250 OK\r\n");
}
#[test]
fn cross_validate_enhanced_code_class_mismatch() {
cross_validate_parsers(b"250 5.1.1 Sender OK\r\n");
}
#[test]
fn cross_validate_354_intermediate() {
cross_validate_parsers(b"354 Start mail input; end with <CRLF>.<CRLF>\r\n");
}
#[test]
fn cross_validate_421_service_unavailable() {
cross_validate_parsers(b"421 4.7.0 Service not available\r\n");
}
#[test]
fn cross_validate_bare_code_no_text() {
cross_validate_parsers(b"250\r\n");
}
#[test]
fn cross_validate_enhanced_code_no_trailing_text() {
cross_validate_parsers(b"250 2.1.0\r\n");
}
#[test]
fn cross_validate_535_auth_failure() {
cross_validate_parsers(b"535 5.7.8 Authentication credentials invalid\r\n");
}
#[test]
fn cross_validate_multiline_all_enhanced() {
cross_validate_parsers(
b"550-5.1.1 The email account does not exist\r\n\
550 5.1.1 Please try again\r\n",
);
}
#[test]
fn cross_validate_empty_text_after_separator() {
cross_validate_parsers(b"250 \r\n");
}
#[test]
fn cross_validate_multiline_with_lossy_utf8() {
cross_validate_parsers(b"250 OK \xff\xfe\r\n");
}
#[test]
fn cross_validate_second_digit_6_through_9() {
cross_validate_parsers(b"268 OK\r\n");
cross_validate_parsers(b"569 Error\r\n");
}
#[test]
fn parse_capabilities_from_ehlo() {
let resp = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec![
"mail.example.com".into(),
"PIPELINING".into(),
"SIZE 10240000".into(),
"8BITMIME".into(),
"STARTTLS".into(),
"AUTH PLAIN XOAUTH2".into(),
"SMTPUTF8".into(),
"CHUNKING".into(),
"ENHANCEDSTATUSCODES".into(),
],
};
let caps = SmtpConnection::parse_capabilities(&resp);
assert_eq!(caps.greeting_name, "mail.example.com");
assert!(caps.supports_pipelining());
assert!(caps.supports_starttls());
assert!(caps.supports_chunking());
assert!(caps.supports_auth(&crate::types::AuthMechanism::Plain));
assert!(caps.supports_auth(&crate::types::AuthMechanism::XOAuth2));
}
#[test]
fn parse_capabilities_preserves_auth_other_case() {
let resp = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec![
"mail.example.com".into(),
"AUTH PLAIN LOGIN CRAM-MD5".into(),
],
};
let caps = SmtpConnection::parse_capabilities(&resp);
assert!(
caps.supports_auth(&crate::types::AuthMechanism::Other("LOGIN".into())),
"Other(\"LOGIN\") must match when server sends uppercase"
);
}
#[test]
fn parse_capabilities_auth_case_insensitive_keywords() {
let resp = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec!["mx.example.org".into(), "auth plain login".into()],
};
let caps = SmtpConnection::parse_capabilities(&resp);
assert!(
caps.supports_auth(&crate::types::AuthMechanism::Plain),
"PLAIN must be recognized case-insensitively"
);
assert!(
caps.supports_auth(&crate::types::AuthMechanism::Other("login".into())),
"Other mechanism name should preserve original case from server"
);
}
#[test]
fn parse_capabilities_auth_equals_form() {
let resp = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec![
"legacy.server.com".into(),
"AUTH=PLAIN LOGIN".into(),
"PIPELINING".into(),
],
};
let caps = SmtpConnection::parse_capabilities(&resp);
assert!(
caps.supports_auth(&crate::types::AuthMechanism::Plain),
"AUTH=PLAIN form must be recognized (RFC 2554 Section 3)"
);
assert!(
caps.supports_auth(&crate::types::AuthMechanism::Other("LOGIN".into())),
"AUTH=PLAIN LOGIN must include LOGIN mechanism"
);
assert!(
caps.supports_pipelining(),
"other extensions must still be parsed correctly"
);
}
#[test]
fn protocol_getter() {
assert_eq!(Protocol::Smtp, Protocol::Smtp);
assert_ne!(Protocol::Smtp, Protocol::Lmtp);
}
#[tokio::test]
async fn connect_sends_quit_on_554_greeting() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
socket
.write_all(b"554 Service unavailable\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
let mut buf = [0u8; 4096];
let mut accumulated = Vec::new();
let quit_received = tokio::time::timeout(Duration::from_secs(3), async {
loop {
let n = socket.read(&mut buf).await.unwrap();
if n == 0 {
return false;
}
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("QUIT") {
return true;
}
}
})
.await;
assert!(
matches!(quit_received, Ok(true)),
"client must send QUIT after receiving 554 greeting \
(RFC 5321 Section 3.1): quit_received={quit_received:?}"
);
socket.write_all(b"221 Bye\r\n").await.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let result = SmtpConnection::connect(
"127.0.0.1",
addr.port(),
TlsMode::None,
Duration::from_secs(5),
)
.await;
assert!(result.is_err(), "connect must fail on 554 greeting");
server.await.unwrap();
}
#[tokio::test]
async fn helo_fallback_when_ehlo_rejected() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
socket
.write_all(b"220 legacy.server.com SMTP\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
let mut buf = [0u8; 4096];
let n = socket.read(&mut buf).await.unwrap();
let cmd = String::from_utf8_lossy(&buf[..n]);
assert!(
cmd.starts_with("EHLO "),
"client should try EHLO first, got: {cmd}"
);
socket
.write_all(b"502 Command not implemented\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
let n = socket.read(&mut buf).await.unwrap();
let cmd = String::from_utf8_lossy(&buf[..n]);
assert!(
cmd.starts_with("HELO "),
"client must fall back to HELO after EHLO rejection: {cmd}"
);
socket
.write_all(b"250 legacy.server.com\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let result = SmtpConnection::connect(
"127.0.0.1",
addr.port(),
TlsMode::None,
Duration::from_secs(5),
)
.await;
match result {
Ok(_) => {}
Err(e) => panic!("connection must succeed with HELO fallback: {e}"),
}
server.await.unwrap();
}
#[tokio::test]
async fn ehlo_421_does_not_fall_back_to_helo() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
socket
.write_all(b"220 shutting-down.example.com SMTP\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
let mut buf = [0u8; 4096];
let n = socket.read(&mut buf).await.unwrap();
let cmd = String::from_utf8_lossy(&buf[..n]);
assert!(cmd.starts_with("EHLO "), "expected EHLO, got: {cmd}");
socket
.write_all(b"421 Service not available, closing\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
let helo_sent = tokio::time::timeout(Duration::from_secs(1), async {
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
if n == 0 {
return false;
}
accumulated.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&accumulated);
if text.contains("HELO ") {
return true;
}
}
})
.await;
assert!(
!matches!(helo_sent, Ok(true)),
"client must NOT fall back to HELO after 421 EHLO response \
(RFC 5321 Section 4.1.4): only 5xx codes indicate ESMTP \
is not supported"
);
tokio::time::sleep(Duration::from_millis(100)).await;
});
let result = SmtpConnection::connect(
"127.0.0.1",
addr.port(),
TlsMode::None,
Duration::from_secs(5),
)
.await;
match result {
Err(Error::Transient { code, .. }) => {
assert_eq!(code, 421, "error must preserve the 421 code");
}
Err(other) => panic!("expected Transient error with code 421, got: {other:?}"),
Ok(_) => panic!("connect must fail on 421 EHLO response, not fall back to HELO"),
}
server.await.unwrap();
}
#[tokio::test]
async fn connect_sends_quit_on_ehlo_421_failure() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
socket
.write_all(b"220 mail.example.com ESMTP\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
let mut buf = [0u8; 4096];
let n = socket.read(&mut buf).await.unwrap();
let cmd = String::from_utf8_lossy(&buf[..n]);
assert!(cmd.starts_with("EHLO "), "expected EHLO, got: {cmd}");
socket
.write_all(b"421 Service not available\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
let mut accumulated = Vec::new();
let quit_received = tokio::time::timeout(Duration::from_secs(3), async {
loop {
let n = socket.read(&mut buf).await.unwrap();
if n == 0 {
return false;
}
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("QUIT") {
return true;
}
}
})
.await;
assert!(
matches!(quit_received, Ok(true)),
"client must send QUIT after EHLO 421 failure before \
closing the connection (RFC 5321 Section 4.1.1.10): \
quit_received={quit_received:?}"
);
socket.write_all(b"221 Bye\r\n").await.unwrap();
let _ = socket.flush().await;
tokio::time::sleep(Duration::from_millis(200)).await;
});
let result = SmtpConnection::connect(
"127.0.0.1",
addr.port(),
TlsMode::None,
Duration::from_secs(5),
)
.await;
assert!(result.is_err(), "connect must fail on 421 EHLO response");
server.await.unwrap();
}
#[tokio::test]
async fn ehlo_non_250_2xx_rejected() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
socket
.write_all(b"220 mail.example.com ESMTP\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
let mut buf = [0u8; 4096];
let n = socket.read(&mut buf).await.unwrap();
let cmd = String::from_utf8_lossy(&buf[..n]);
assert!(cmd.starts_with("EHLO "), "expected EHLO, got: {cmd}");
socket.write_all(b"200 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
let mut accumulated = Vec::new();
let quit_received = tokio::time::timeout(Duration::from_secs(3), async {
loop {
let n = socket.read(&mut buf).await.unwrap();
if n == 0 {
return false;
}
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("QUIT") {
return true;
}
}
})
.await;
assert!(
matches!(quit_received, Ok(true)),
"client must send QUIT after rejecting non-250 EHLO response"
);
socket.write_all(b"221 Bye\r\n").await.unwrap();
let _ = socket.flush().await;
tokio::time::sleep(Duration::from_millis(200)).await;
});
let result = SmtpConnection::connect(
"127.0.0.1",
addr.port(),
TlsMode::None,
Duration::from_secs(5),
)
.await;
assert!(
result.is_err(),
"connect must fail on non-250 EHLO response"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("250") && msg.contains("RFC 5321"),
"error must mention 250 and RFC 5321: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
server.await.unwrap();
}
#[tokio::test]
async fn connect_sends_quit_when_starttls_unavailable() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
socket
.write_all(b"220 mail.example.com ESMTP\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
let mut buf = [0u8; 4096];
let n = socket.read(&mut buf).await.unwrap();
let cmd = String::from_utf8_lossy(&buf[..n]);
assert!(cmd.starts_with("EHLO "), "expected EHLO, got: {cmd}");
socket
.write_all(b"250-mail.example.com\r\n250 PIPELINING\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
let mut accumulated = Vec::new();
let quit_received = tokio::time::timeout(Duration::from_secs(3), async {
loop {
let n = socket.read(&mut buf).await.unwrap();
if n == 0 {
return false;
}
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("QUIT") {
return true;
}
}
})
.await;
assert!(
matches!(quit_received, Ok(true)),
"client must send QUIT when STARTTLS is unavailable \
(RFC 5321 Section 4.1.1.10): quit_received={quit_received:?}"
);
socket.write_all(b"221 Bye\r\n").await.unwrap();
let _ = socket.flush().await;
tokio::time::sleep(Duration::from_millis(200)).await;
});
let result = SmtpConnection::connect(
"127.0.0.1",
addr.port(),
TlsMode::StartTls,
Duration::from_secs(5),
)
.await;
match result {
Err(Error::StartTlsUnavailable) => {}
Err(other) => panic!("expected StartTlsUnavailable, got: {other:?}"),
Ok(_) => panic!("connect must fail when STARTTLS unavailable"),
}
server.await.unwrap();
}
#[tokio::test]
async fn helo_fallback_preserves_greeting_name() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
socket
.write_all(b"220 mail.example.com ESMTP\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
let mut buf = [0u8; 4096];
let n = socket.read(&mut buf).await.unwrap();
let cmd = String::from_utf8_lossy(&buf[..n]);
assert!(cmd.starts_with("EHLO "), "expected EHLO, got: {cmd}");
socket.write_all(b"502 Not implemented\r\n").await.unwrap();
socket.flush().await.unwrap();
let n = socket.read(&mut buf).await.unwrap();
let cmd = String::from_utf8_lossy(&buf[..n]);
assert!(cmd.starts_with("HELO "), "expected HELO, got: {cmd}");
socket
.write_all(b"250 mail.example.com Hello client\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let conn = SmtpConnection::connect(
"127.0.0.1",
addr.port(),
TlsMode::None,
Duration::from_secs(5),
)
.await
.expect("connection must succeed with HELO fallback");
assert_eq!(
conn.capabilities().await.greeting_name,
"mail.example.com",
"HELO fallback must preserve the server's greeting domain \
(RFC 5321 Section 4.1.1.1: helo-ok-rsp = \"250\" SP Domain \
[ SP helo-greet ] CRLF)"
);
server.await.unwrap();
}
#[tokio::test]
async fn helo_fallback_non_250_2xx_rejected() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
socket
.write_all(b"220 server.example.com SMTP\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
let mut buf = [0u8; 4096];
let n = socket.read(&mut buf).await.unwrap();
let cmd = String::from_utf8_lossy(&buf[..n]);
assert!(cmd.starts_with("EHLO "), "expected EHLO, got: {cmd}");
socket.write_all(b"500 Not understood\r\n").await.unwrap();
socket.flush().await.unwrap();
let n = socket.read(&mut buf).await.unwrap();
let cmd = String::from_utf8_lossy(&buf[..n]);
assert!(cmd.starts_with("HELO "), "expected HELO, got: {cmd}");
socket.write_all(b"251 Not local\r\n").await.unwrap();
socket.flush().await.unwrap();
let mut accumulated = Vec::new();
let quit_received = tokio::time::timeout(Duration::from_secs(3), async {
loop {
let n = socket.read(&mut buf).await.unwrap();
if n == 0 {
return false;
}
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("QUIT") {
return true;
}
}
})
.await;
assert!(
matches!(quit_received, Ok(true)),
"client must send QUIT after rejecting non-250 HELO response"
);
socket.write_all(b"221 Bye\r\n").await.unwrap();
let _ = socket.flush().await;
tokio::time::sleep(Duration::from_millis(200)).await;
});
let result = SmtpConnection::connect(
"127.0.0.1",
addr.port(),
TlsMode::None,
Duration::from_secs(5),
)
.await;
assert!(
result.is_err(),
"connect must fail when HELO response is 251 (not 250)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("250") && msg.contains("RFC 5321"),
"error must mention 250 and RFC 5321: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
server.await.unwrap();
}
#[tokio::test]
async fn ehlo_uses_client_domain_not_server_hostname() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
socket
.write_all(b"220 mock.server.com ESMTP\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
let mut buf = [0u8; 4096];
let n = socket.read(&mut buf).await.unwrap();
let ehlo_line = String::from_utf8_lossy(&buf[..n]);
assert!(
!ehlo_line.contains("EHLO 127.0.0.1"),
"EHLO must use client FQDN, not server address: {ehlo_line}"
);
assert!(
ehlo_line.starts_with("EHLO [127.0.0.1]\r\n"),
"expected EHLO to use the default address-literal fallback, got: {ehlo_line}"
);
socket.write_all(b"250 mock.server.com\r\n").await.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let result = SmtpConnection::connect(
"127.0.0.1",
addr.port(),
TlsMode::None,
Duration::from_secs(5),
)
.await;
match result {
Ok(_) => {}
Err(e) => panic!("connect failed: {e}"),
}
server.await.unwrap();
}
#[tokio::test]
async fn bdat_send_success() {
let stream = mock_interactive_server(vec![
("MAIL FROM:", "250 OK\r\n"),
("RCPT TO:", "250 OK\r\n"),
("BDAT ", "250 OK\r\n"),
])
.await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Chunking],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.send_bdat(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello",
None,
Duration::from_secs(5),
)
.await;
assert!(result.is_ok(), "expected success, got: {result:?}");
}
#[tokio::test]
async fn bdat_send_with_params_includes_body_binarymime() {
use crate::types::{BodyType, MailFromParams};
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 4096];
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0);
accumulated.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&accumulated);
if text.contains("MAIL FROM:") {
assert!(
text.contains("BODY=BINARYMIME"),
"MAIL FROM must include BODY=BINARYMIME for binary \
content via BDAT (RFC 3030 Section 2). Got: {text}"
);
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("RCPT TO:") {
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("BDAT ") {
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Chunking, SmtpExtension::BinaryMime],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
body: Some(BodyType::BinaryMime),
..Default::default()
};
let result = conn
.send_bdat(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"\x00\x01\x02binary content\xff\xfe",
Some(¶ms),
Duration::from_secs(5),
)
.await;
assert!(result.is_ok(), "send_bdat with params failed: {result:?}");
server.await.unwrap();
}
#[tokio::test]
async fn bdat_send_without_chunking_returns_error() {
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let result = conn
.send_bdat(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Hello",
None,
Duration::from_secs(1),
)
.await;
assert!(result.is_err());
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(msg.contains("CHUNKING"), "expected CHUNKING mention: {msg}");
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn bdat_send_failure_sends_rset() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 4096];
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0, "connection closed while waiting for MAIL FROM");
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("MAIL FROM:") {
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0, "connection closed while waiting for RCPT TO");
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("RCPT TO:") {
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0, "connection closed while waiting for BDAT");
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("BDAT ") {
break;
}
}
socket
.write_all(b"554 Transaction failed\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
let rset_received = tokio::time::timeout(Duration::from_secs(3), async {
loop {
let n = socket.read(&mut buf).await.unwrap();
if n == 0 {
return false;
}
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("RSET") {
return true;
}
}
})
.await;
assert!(
matches!(rset_received, Ok(true)),
"client must send RSET after failed BDAT to clear \
indeterminate state (RFC 3030 Section 3): \
rset_received={rset_received:?}"
);
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Chunking],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.send_bdat(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello",
None,
Duration::from_secs(5),
)
.await;
assert!(result.is_err());
server.await.unwrap();
}
#[tokio::test]
async fn send_with_params_includes_body_and_smtputf8() {
use crate::types::{BodyType, MailFromParams};
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 4096];
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0);
accumulated.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&accumulated);
if text.contains("MAIL FROM:") {
assert!(
text.contains("BODY=8BITMIME"),
"expected BODY=8BITMIME in: {text}"
);
assert!(text.contains("SMTPUTF8"), "expected SMTPUTF8 in: {text}");
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("RCPT TO:") {
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("DATA") {
break;
}
}
socket.write_all(b"354 Start\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("\r\n.\r\n") {
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::EightBitMime, SmtpExtension::SmtpUtf8],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
body: Some(BodyType::EightBitMime),
smtputf8: true,
..Default::default()
};
let result = conn
.send_with_params(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\n\r\nH\xc3\xa9llo",
Some(¶ms),
Duration::from_secs(5),
)
.await;
assert!(result.is_ok(), "send_with_params failed: {result:?}");
server.await.unwrap();
}
#[tokio::test]
async fn send_empty_recipients_returns_protocol_error() {
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[],
b"Subject: Test\r\n\r\nHello",
Duration::from_secs(1),
)
.await;
assert!(result.is_err(), "empty recipients must fail");
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("recipient"),
"error should mention recipients: {msg}"
);
}
other => panic!("expected Protocol error for empty recipients, got: {other:?}"),
}
}
#[tokio::test]
async fn send_bdat_empty_recipients_returns_protocol_error() {
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Chunking],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.send_bdat(
&ReversePath::new("sender@example.com").unwrap(),
&[],
b"Hello",
None,
Duration::from_secs(1),
)
.await;
assert!(result.is_err());
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("recipient"),
"error should mention recipients: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn send_lmtp_empty_recipients_returns_protocol_error() {
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, ServerCapabilities::default(), Protocol::Lmtp);
let result = conn
.send_lmtp(
&ReversePath::new("sender@example.com").unwrap(),
&[],
b"Hello",
None,
Duration::from_secs(1),
)
.await;
assert!(result.is_err());
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("recipient"),
"error should mention recipients: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn sequential_send_all_rcpt_tos_fail() {
let stream = mock_interactive_server(vec![
("MAIL FROM:", "250 OK\r\n"),
("RCPT TO:", "550 User unknown\r\n"),
("RCPT TO:", "550 User unknown\r\n"),
])
.await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[
ForwardPath::new("bad1@example.com").unwrap(),
ForwardPath::new("bad2@example.com").unwrap(),
],
b"Subject: Test\r\n\r\nHello",
Duration::from_secs(5),
)
.await;
assert!(result.is_err());
match result.unwrap_err() {
Error::AllRecipientsFailed { count, responses } => {
assert_eq!(count, 2);
assert_eq!(responses.len(), 2);
}
other => panic!("expected AllRecipientsFailed, got: {other:?}"),
}
}
#[tokio::test]
async fn send_rejects_message_exceeding_size_limit() {
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Size(Some(100))],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let big_message = vec![b'X'; 200];
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
&big_message,
Duration::from_secs(1),
)
.await;
assert!(
result.is_err(),
"send() must reject messages exceeding server SIZE limit (RFC 1870 Section 4)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("SIZE") || msg.contains("size") || msg.contains("exceeds"),
"error should mention SIZE limit: {msg}"
);
}
other => panic!("expected Protocol error for SIZE limit, got: {other:?}"),
}
}
#[tokio::test]
async fn send_bdat_rejects_message_exceeding_size_limit() {
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Chunking, SmtpExtension::Size(Some(50))],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let big_message = vec![b'X'; 100];
let result = conn
.send_bdat(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
&big_message,
None,
Duration::from_secs(1),
)
.await;
assert!(
result.is_err(),
"send_bdat() must reject messages exceeding server SIZE limit"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("SIZE") || msg.contains("size") || msg.contains("exceeds"),
"error should mention SIZE limit: {msg}"
);
}
other => panic!("expected Protocol error for SIZE limit, got: {other:?}"),
}
}
#[tokio::test]
async fn send_allows_message_within_size_limit() {
let stream = mock_interactive_server(vec![
("MAIL FROM:", "250 OK\r\n"),
("RCPT TO:", "250 OK\r\n"),
("DATA", "354 Start\r\n"),
("\r\n.\r\n", "250 OK\r\n"),
])
.await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Size(Some(10000))],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\n\r\nSmall message",
Duration::from_secs(5),
)
.await;
assert!(
result.is_ok(),
"message within SIZE limit must succeed: {result:?}"
);
}
#[tokio::test]
async fn auth_failure_preserves_reply_code() {
let stream = mock_server(vec!["535 5.7.8 Authentication credentials invalid\r\n"]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![
SmtpExtension::Auth(vec![crate::types::AuthMechanism::Plain]),
SmtpExtension::SaslIr,
],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.auth_plain("user", "wrongpass", Duration::from_secs(5))
.await;
assert!(result.is_err());
match result.unwrap_err() {
Error::Auth { response, .. } => {
assert_eq!(
response.code, 535,
"auth error must preserve the reply code"
);
}
other => panic!("expected Auth error, got: {other:?}"),
}
}
#[tokio::test]
async fn auth_transient_failure_is_retryable() {
let stream = mock_server(vec!["454 4.7.0 Temporary authentication failure\r\n"]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![
SmtpExtension::Auth(vec![crate::types::AuthMechanism::Plain]),
SmtpExtension::SaslIr,
],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.auth_plain("user", "pass", Duration::from_secs(5))
.await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.is_transient(),
"454 auth failure must be transient (retryable), got: {err:?}"
);
}
#[tokio::test]
async fn capabilities_getter_returns_server_extensions() {
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![
SmtpExtension::Pipelining,
SmtpExtension::Chunking,
SmtpExtension::EightBitMime,
],
};
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let stream = TcpStream::connect(addr).await.unwrap();
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let retrieved = conn.capabilities().await;
assert!(
retrieved.supports_pipelining(),
"capabilities() must expose server extensions"
);
assert!(
retrieved.supports_chunking(),
"capabilities() must expose CHUNKING"
);
assert!(
retrieved.supports_8bitmime(),
"capabilities() must expose 8BITMIME"
);
}
#[tokio::test]
async fn send_with_params_rejects_binarymime_on_data_path() {
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Chunking, SmtpExtension::BinaryMime],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = crate::types::MailFromParams {
body: Some(crate::types::BodyType::BinaryMime),
..Default::default()
};
let result = conn
.send_with_params(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"\x00\x01binary content\xff",
Some(¶ms),
Duration::from_secs(1),
)
.await;
assert!(
result.is_err(),
"BODY=BINARYMIME must not be used with DATA (RFC 3030 Section 2)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("BINARYMIME") || msg.contains("BDAT"),
"error should mention BINARYMIME/BDAT: {msg}"
);
}
other => panic!("expected Protocol error for BINARYMIME+DATA, got: {other:?}"),
}
}
#[tokio::test]
async fn read_response_rejects_oversized_response() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let huge = vec![b'X'; 100_000];
socket.write_all(b"250 ").await.unwrap();
socket.write_all(&huge).await.unwrap();
tokio::time::sleep(Duration::from_secs(5)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let result = tokio::time::timeout(Duration::from_secs(3), conn.read_response_public()).await;
match result {
Ok(Err(Error::Protocol(msg))) => {
assert!(
msg.contains("response") || msg.contains("buffer") || msg.contains("limit"),
"error should mention buffer limit: {msg}"
);
}
Ok(Err(_)) => {} Ok(Ok(_)) => {
panic!("read_response must reject oversized responses (RFC 5321 Section 4.5.3.1.5)")
}
Err(_) => panic!("read_response should not time out — it should reject quickly"),
}
}
#[tokio::test]
async fn send_lmtp_rejects_binarymime_with_data() {
use crate::types::{BodyType, MailFromParams};
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, ServerCapabilities::default(), Protocol::Lmtp);
let params = MailFromParams {
body: Some(BodyType::BinaryMime),
..Default::default()
};
let result = conn
.send_lmtp(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"binary content",
Some(¶ms),
Duration::from_secs(1),
)
.await;
assert!(result.is_err(), "send_lmtp with BODY=BINARYMIME must fail");
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("BINARYMIME"),
"error should mention BINARYMIME: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn send_bdat_rejects_lmtp_connections() {
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Chunking],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Lmtp);
let result = conn
.send_bdat(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Hello",
None,
Duration::from_secs(1),
)
.await;
assert!(result.is_err(), "send_bdat on LMTP must fail");
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(msg.contains("LMTP"), "error should mention LMTP: {msg}");
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn send_with_params_rejects_smtputf8_without_server_support() {
use crate::types::MailFromParams;
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let params = MailFromParams {
smtputf8: true,
..Default::default()
};
let result = conn
.send_with_params(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Hello",
Some(¶ms),
Duration::from_secs(1),
)
.await;
assert!(result.is_err(), "SMTPUTF8 without server support must fail");
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("SMTPUTF8"),
"error should mention SMTPUTF8: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn send_with_params_rejects_8bitmime_without_server_support() {
use crate::types::{BodyType, MailFromParams};
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let params = MailFromParams {
body: Some(BodyType::EightBitMime),
..Default::default()
};
let result = conn
.send_with_params(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Hello",
Some(¶ms),
Duration::from_secs(1),
)
.await;
assert!(
result.is_err(),
"BODY=8BITMIME without server support must fail"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("8BITMIME"),
"error should mention 8BITMIME: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[test]
fn body_7bit_omitted_without_8bitmime_capability() {
use crate::types::{BodyType, MailFromParams};
use bytes::BytesMut;
let caps = smtp_caps_no_pipelining();
let params = MailFromParams {
body: Some(BodyType::SevenBit),
..Default::default()
};
let mut buf = BytesMut::new();
let rp_val = ReversePath::new("sender@example.com").unwrap();
SmtpConnection::encode_mail_from_cmd(&caps, &mut buf, &rp_val, 5, Some(¶ms), false)
.unwrap();
let cmd = String::from_utf8(buf.to_vec()).unwrap();
assert!(
!cmd.contains("BODY="),
"BODY=7BIT must be silently omitted when the server lacks \
8BITMIME support (RFC 5321 Section 4.5.2). Got: {cmd}"
);
}
#[tokio::test]
async fn send_with_body_7bit_rejects_8bit_content() {
use crate::types::{BodyType, MailFromParams};
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::EightBitMime],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
body: Some(BodyType::SevenBit),
..Default::default()
};
let result = conn
.send_with_params(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\n\r\nH\xc3\xa9llo\r\n",
Some(¶ms),
Duration::from_secs(1),
)
.await;
assert!(
result.is_err(),
"BODY=7BIT with 8-bit content must be rejected \
(RFC 6152 Section 3)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("8-bit") || msg.contains("7-bit") || msg.contains("0x7F"),
"error should mention 8-bit/7-bit content: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn send_bdat_with_body_7bit_rejects_8bit_content() {
use crate::types::{BodyType, MailFromParams};
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::EightBitMime, SmtpExtension::Chunking],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
body: Some(BodyType::SevenBit),
..Default::default()
};
let result = conn
.send_bdat(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\n\r\nH\xc3\xa9llo\r\n",
Some(¶ms),
Duration::from_secs(1),
)
.await;
assert!(
result.is_err(),
"BDAT with BODY=7BIT and 8-bit content must be rejected \
(RFC 6152 Section 3)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("8-bit") || msg.contains("7-bit") || msg.contains("0x7F"),
"error should mention 8-bit/7-bit content: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn send_lmtp_with_body_7bit_rejects_8bit_content() {
use crate::types::{BodyType, MailFromParams};
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::EightBitMime],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Lmtp);
let params = MailFromParams {
body: Some(BodyType::SevenBit),
..Default::default()
};
let result = conn
.send_lmtp(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\n\r\nH\xc3\xa9llo\r\n",
Some(¶ms),
Duration::from_secs(1),
)
.await;
assert!(
result.is_err(),
"LMTP with BODY=7BIT and 8-bit content must be rejected \
(RFC 6152 Section 3)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("8-bit") || msg.contains("7-bit") || msg.contains("0x7F"),
"error should mention 8-bit/7-bit content: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn send_lmtp_bdat_with_body_7bit_rejects_8bit_content() {
use crate::types::{BodyType, MailFromParams};
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::EightBitMime, SmtpExtension::Chunking],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Lmtp);
let params = MailFromParams {
body: Some(BodyType::SevenBit),
..Default::default()
};
let result = conn
.send_lmtp_bdat(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\n\r\nH\xc3\xa9llo\r\n",
Some(¶ms),
Duration::from_secs(1),
)
.await;
assert!(
result.is_err(),
"LMTP BDAT with BODY=7BIT and 8-bit content must be rejected \
(RFC 6152 Section 3)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("8-bit") || msg.contains("7-bit") || msg.contains("0x7F"),
"error should mention 8-bit/7-bit content: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn sequential_send_all_rcpt_tos_fail_sends_rset() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 4096];
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0, "connection closed while waiting for MAIL FROM");
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("MAIL FROM:") {
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0, "connection closed while waiting for RCPT TO");
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("RCPT TO:") {
break;
}
}
socket.write_all(b"550 User unknown\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
let rset_received = tokio::time::timeout(Duration::from_secs(3), async {
loop {
let n = socket.read(&mut buf).await.unwrap();
if n == 0 {
return false; }
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("RSET") {
return true;
}
}
})
.await;
assert!(
matches!(rset_received, Ok(true)),
"client must send RSET after all RCPT TOs fail \
(RFC 5321 Section 3.3): rset_received={rset_received:?}"
);
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("bad@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello",
Duration::from_secs(5),
)
.await;
assert!(result.is_err());
match result.unwrap_err() {
Error::AllRecipientsFailed { count, .. } => {
assert_eq!(count, 1);
}
other => panic!("expected AllRecipientsFailed, got: {other:?}"),
}
server.await.unwrap();
}
#[tokio::test]
async fn send_bdat_rejects_binarymime_without_server_support() {
use crate::types::{BodyType, MailFromParams};
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Chunking],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
body: Some(BodyType::BinaryMime),
..Default::default()
};
let result = conn
.send_bdat(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"\x00\x01binary",
Some(¶ms),
Duration::from_secs(1),
)
.await;
assert!(
result.is_err(),
"BODY=BINARYMIME without BINARYMIME extension must fail"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("BINARYMIME"),
"error should mention BINARYMIME: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[test]
fn reverse_path_rejects_crlf_in_sender() {
let result = ReversePath::new("evil@x.com\r\nRCPT TO:<victim@x.com>");
assert!(
result.is_err(),
"CRLF in sender must be rejected by ReversePath"
);
}
#[test]
fn forward_path_rejects_crlf_in_recipient() {
let result = ForwardPath::new("evil@x.com\r\nMAIL FROM:<attacker@x.com>");
assert!(
result.is_err(),
"CRLF in recipient must be rejected by ForwardPath"
);
}
#[tokio::test]
async fn auth_plain_cancels_on_334_challenge() {
let stream = mock_interactive_server(vec![
("AUTH PLAIN", "334 Y2hhbGxlbmdl\r\n"),
("*", "535 5.7.8 Authentication failed\r\n"),
])
.await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![
SmtpExtension::Auth(vec![crate::types::AuthMechanism::Plain]),
SmtpExtension::SaslIr,
],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.auth_plain("user", "pass", Duration::from_secs(5))
.await;
assert!(result.is_err(), "auth must fail when server sends 334");
match result.unwrap_err() {
Error::Auth { response, .. } => {
assert_eq!(
response.code, 535,
"after cancelling the 334 challenge, the final error must \
be the server's 535 reply (RFC 4954 Section 6)"
);
}
other => panic!("expected Auth error, got: {other:?}"),
}
}
#[tokio::test]
async fn auth_plain_cancelled_exchange_rejects_spurious_success() {
let stream = mock_interactive_server(vec![
("AUTH PLAIN", "334 Y2hhbGxlbmdl\r\n"),
("*", "235 2.7.0 Authentication successful\r\n"),
])
.await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![
SmtpExtension::Auth(vec![crate::types::AuthMechanism::Plain]),
SmtpExtension::SaslIr,
],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.auth_plain("user", "pass", Duration::from_secs(5))
.await;
match result {
Err(Error::Protocol(msg)) => {
assert!(
msg.contains("501") || msg.contains("cancel") || msg.contains("RFC 4954 Section 6"),
"protocol error should explain the post-cancel AUTH violation: {msg}"
);
}
other => panic!(
"AUTH success after client cancellation must be a protocol error \
(RFC 4954 Section 6), got: {other:?}"
),
}
let authenticated = conn.inner.lock().await.authenticated;
assert!(
!authenticated,
"client must remain unauthenticated after cancelling AUTH"
);
}
#[tokio::test]
async fn auth_xoauth2_cancels_on_334_challenge() {
let stream = mock_interactive_server(vec![
("AUTH XOAUTH2", "334 eyJzdGF0dXMiOiI0MDEifQ==\r\n"),
("*", "535 5.7.8 Authentication failed\r\n"),
])
.await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![
SmtpExtension::Auth(vec![crate::types::AuthMechanism::XOAuth2]),
SmtpExtension::SaslIr,
],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.auth_xoauth2("user", "token", Duration::from_secs(5))
.await;
assert!(result.is_err(), "auth must fail when server sends 334");
match result.unwrap_err() {
Error::Auth { response, .. } => {
assert_eq!(
response.code, 535,
"after cancelling the 334 challenge, the final error must \
be the server's 535 reply (RFC 4954 Section 6)"
);
}
other => panic!("expected Auth error, got: {other:?}"),
}
}
#[tokio::test]
async fn auth_oauthbearer_sends_aq_on_334_challenge() {
let stream = mock_interactive_server(vec![
("AUTH OAUTHBEARER", "334 eyJzdGF0dXMiOiI0MDEifQ==\r\n"),
("AQ==", "535 5.7.8 Authentication failed\r\n"),
])
.await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![
SmtpExtension::Auth(vec![crate::types::AuthMechanism::OAuthBearer]),
SmtpExtension::SaslIr,
],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.auth_oauthbearer("token123", Duration::from_secs(5))
.await;
assert!(result.is_err(), "auth must fail when server sends 334");
match result.unwrap_err() {
Error::Auth { response, .. } => {
assert_eq!(
response.code, 535,
"after acknowledging the 334 challenge with AQ==, the final error \
must be the server's 535 reply (RFC 7628 Section 3.2.3)"
);
}
other => panic!("expected Auth error, got: {other:?}"),
}
}
#[test]
fn parse_response_enhanced_code_no_trailing_text() {
let buf = b"550 5.1.1\r\n";
let (resp, _consumed) = SmtpConnection::try_parse_response(buf).unwrap().unwrap();
assert_eq!(resp.code, 550);
let esc = resp.enhanced_code.unwrap();
assert_eq!(esc.class, 5);
assert_eq!(esc.subject, 1);
assert_eq!(esc.detail, 1);
assert!(
resp.lines[0].is_empty(),
"text should be empty when enhanced code is the entire text, got: {:?}",
resp.lines[0]
);
}
#[tokio::test]
async fn sequential_send_data_rejected_sends_rset() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 4096];
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0, "connection closed while waiting for MAIL FROM");
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("MAIL FROM:") {
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0, "connection closed while waiting for RCPT TO");
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("RCPT TO:") {
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0, "connection closed while waiting for DATA");
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("DATA") {
break;
}
}
socket
.write_all(b"554 Transaction failed\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
let rset_received = tokio::time::timeout(Duration::from_secs(3), async {
loop {
let n = socket.read(&mut buf).await.unwrap();
if n == 0 {
return false;
}
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("RSET") {
return true;
}
}
})
.await;
assert!(
matches!(rset_received, Ok(true)),
"client must send RSET after DATA rejection to clean up the \
mail transaction (RFC 5321 Section 3.3): rset_received={rset_received:?}"
);
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello",
Duration::from_secs(5),
)
.await;
assert!(result.is_err());
server.await.unwrap();
}
#[tokio::test]
async fn pipelined_send_data_rejected_sends_rset() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 4096];
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(
n > 0,
"connection closed while waiting for pipelined commands"
);
accumulated.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&accumulated);
if text.contains("DATA") {
break;
}
}
socket
.write_all(b"250 OK\r\n250 OK\r\n554 Transaction failed\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
let rset_received = tokio::time::timeout(Duration::from_secs(3), async {
loop {
let n = socket.read(&mut buf).await.unwrap();
if n == 0 {
return false;
}
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("RSET") {
return true;
}
}
})
.await;
assert!(
matches!(rset_received, Ok(true)),
"client must send RSET after pipelined DATA rejection to clean up \
the mail transaction (RFC 5321 Section 3.3): rset_received={rset_received:?}"
);
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let conn = SmtpConnection::from_plain(stream, smtp_caps_with_pipelining(), Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello",
Duration::from_secs(5),
)
.await;
assert!(result.is_err());
server.await.unwrap();
}
#[tokio::test]
async fn lmtp_send_data_rejected_sends_rset() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 4096];
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0, "connection closed while waiting for MAIL FROM");
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("MAIL FROM:") {
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0, "connection closed while waiting for RCPT TO");
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("RCPT TO:") {
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0, "connection closed while waiting for DATA");
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("DATA") {
break;
}
}
socket
.write_all(b"554 Transaction failed\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
let rset_received = tokio::time::timeout(Duration::from_secs(3), async {
loop {
let n = socket.read(&mut buf).await.unwrap();
if n == 0 {
return false;
}
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("RSET") {
return true;
}
}
})
.await;
assert!(
matches!(rset_received, Ok(true)),
"LMTP client must send RSET after DATA rejection to clean up \
the mail transaction (RFC 2033 Section 4.2 / RFC 5321 Section 3.3): \
rset_received={rset_received:?}"
);
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let conn = SmtpConnection::from_plain(stream, ServerCapabilities::default(), Protocol::Lmtp);
let result = conn
.send_lmtp(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello",
None,
Duration::from_secs(5),
)
.await;
assert!(result.is_err());
server.await.unwrap();
}
#[tokio::test]
async fn lmtp_bdat_per_recipient_responses() {
use crate::types::{BodyType, MailFromParams};
let stream = mock_interactive_server(vec![
("MAIL FROM:", "250 OK\r\n"),
("RCPT TO:", "250 OK\r\n"),
("RCPT TO:", "550 Unknown user\r\n"),
("RCPT TO:", "250 OK\r\n"),
(
"BDAT ",
"250 Delivered to rcpt1\r\n550 Delivery failed for rcpt3\r\n",
),
])
.await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Chunking, SmtpExtension::BinaryMime],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Lmtp);
let params = MailFromParams {
body: Some(BodyType::BinaryMime),
..Default::default()
};
let results = conn
.send_lmtp_bdat(
&ReversePath::new("sender@example.com").unwrap(),
&[
ForwardPath::new("rcpt1@example.com").unwrap(),
ForwardPath::new("rcpt2@example.com").unwrap(),
ForwardPath::new("rcpt3@example.com").unwrap(),
],
b"\x00\x01\x02binary content\xff\xfe",
Some(¶ms),
Duration::from_secs(5),
)
.await
.unwrap();
assert_eq!(results.results.len(), 2);
assert_eq!(results.results[0].recipient.as_str(), "rcpt1@example.com");
assert!(results.results[0].response.is_success());
assert_eq!(results.results[1].recipient.as_str(), "rcpt3@example.com");
assert!(results.results[1].response.is_permanent_error());
}
#[tokio::test]
async fn auth_plain_long_credentials_uses_two_step() {
use tokio::io::AsyncReadExt;
let long_user = "u".repeat(200);
let long_pass = "p".repeat(200);
let mut check_buf = BytesMut::new();
encode::encode_auth_plain(&mut check_buf, &long_user, &long_pass);
assert!(
check_buf.len() > 512,
"test setup: one-line AUTH PLAIN must exceed 512 octets, got {}",
check_buf.len()
);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let long_user_clone = long_user.clone();
let long_pass_clone = long_pass.clone();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 8192];
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0, "connection closed before reading AUTH command");
accumulated.extend_from_slice(&buf[..n]);
if accumulated.windows(2).any(|w| w == b"\r\n") {
break;
}
}
let first_line = String::from_utf8_lossy(&accumulated);
assert!(
first_line.starts_with("AUTH PLAIN\r\n"),
"RFC 4954 §4: long credentials must use two-step AUTH. \
Expected 'AUTH PLAIN\\r\\n', got: {:?}",
&first_line[..first_line.len().min(80)]
);
socket.write_all(b"334\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0, "connection closed before reading base64 credentials");
accumulated.extend_from_slice(&buf[..n]);
if accumulated.windows(2).any(|w| w == b"\r\n") {
break;
}
}
let cred_line = String::from_utf8_lossy(&accumulated);
let b64 = cred_line.trim_end_matches("\r\n");
let decoded = base64::engine::general_purpose::STANDARD
.decode(b64)
.unwrap();
let mut expected = Vec::new();
expected.push(0u8);
expected.extend_from_slice(long_user_clone.as_bytes());
expected.push(0u8);
expected.extend_from_slice(long_pass_clone.as_bytes());
assert_eq!(
decoded, expected,
"base64 credentials must decode correctly"
);
socket
.write_all(b"235 2.7.0 Authentication successful\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![
SmtpExtension::Auth(vec![crate::types::AuthMechanism::Plain]),
SmtpExtension::SaslIr,
],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.auth_plain(&long_user, &long_pass, Duration::from_secs(5))
.await;
assert!(
result.is_ok(),
"auth_plain with long credentials should succeed via two-step: {:?}",
result.unwrap_err()
);
server.await.unwrap();
}
#[tokio::test]
async fn auth_xoauth2_long_token_uses_two_step() {
use tokio::io::AsyncReadExt;
let user = "user@example.com";
let long_token = "t".repeat(500);
let mut check_buf = BytesMut::new();
encode::encode_auth_xoauth2(&mut check_buf, user, &long_token);
assert!(
check_buf.len() > 512,
"test setup: one-line AUTH XOAUTH2 must exceed 512 octets, got {}",
check_buf.len()
);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let long_token_clone = long_token.clone();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 8192];
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0, "connection closed before reading AUTH command");
accumulated.extend_from_slice(&buf[..n]);
if accumulated.windows(2).any(|w| w == b"\r\n") {
break;
}
}
let first_line = String::from_utf8_lossy(&accumulated);
assert!(
first_line.starts_with("AUTH XOAUTH2\r\n"),
"RFC 4954 §4: long token must use two-step AUTH. \
Expected 'AUTH XOAUTH2\\r\\n', got: {:?}",
&first_line[..first_line.len().min(80)]
);
socket.write_all(b"334\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0, "connection closed before reading base64 token");
accumulated.extend_from_slice(&buf[..n]);
if accumulated.windows(2).any(|w| w == b"\r\n") {
break;
}
}
let cred_line = String::from_utf8_lossy(&accumulated);
let b64 = cred_line.trim_end_matches("\r\n");
let decoded = base64::engine::general_purpose::STANDARD
.decode(b64)
.unwrap();
let expected = format!(
"user={}\x01auth=Bearer {}\x01\x01",
"user@example.com", long_token_clone
);
assert_eq!(
decoded,
expected.as_bytes(),
"base64 XOAUTH2 token must decode correctly"
);
socket
.write_all(b"235 2.7.0 Authentication successful\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![
SmtpExtension::Auth(vec![crate::types::AuthMechanism::XOAuth2]),
SmtpExtension::SaslIr,
],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.auth_xoauth2(user, &long_token, Duration::from_secs(5))
.await;
assert!(
result.is_ok(),
"auth_xoauth2 with long token should succeed via two-step: {:?}",
result.unwrap_err()
);
server.await.unwrap();
}
#[tokio::test]
async fn auth_plain_short_credentials_uses_one_step() {
use tokio::io::AsyncReadExt;
let user = "alice";
let pass = "secret";
let mut check_buf = BytesMut::new();
encode::encode_auth_plain(&mut check_buf, user, pass);
assert!(
check_buf.len() <= 512,
"test setup: one-line AUTH PLAIN must fit in 512 octets, got {}",
check_buf.len()
);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 4096];
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0, "connection closed before reading AUTH command");
accumulated.extend_from_slice(&buf[..n]);
if accumulated.windows(2).any(|w| w == b"\r\n") {
break;
}
}
let first_line = String::from_utf8_lossy(&accumulated);
assert!(
first_line.starts_with("AUTH PLAIN "),
"Short credentials should use one-step AUTH PLAIN. \
Expected line starting with 'AUTH PLAIN ', got: {:?}",
&first_line[..first_line.len().min(80)]
);
socket
.write_all(b"235 2.7.0 Authentication successful\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![
SmtpExtension::Auth(vec![crate::types::AuthMechanism::Plain]),
SmtpExtension::SaslIr,
],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn.auth_plain(user, pass, Duration::from_secs(5)).await;
assert!(
result.is_ok(),
"auth_plain with short credentials should succeed: {:?}",
result.unwrap_err()
);
server.await.unwrap();
}
#[test]
fn parse_response_rejects_overlong_reply_line() {
let mut line = Vec::new();
line.extend_from_slice(b"250 ");
line.extend(std::iter::repeat(b'X').take(8187));
line.extend_from_slice(b"\r\n");
assert!(
line.len() > 8192,
"test setup: line must exceed 8192 octets"
);
let result = SmtpConnection::try_parse_response(&line);
assert!(
result.is_err(),
"reply line exceeding 8192 octets must be rejected"
);
}
#[test]
fn parse_response_accepts_max_length_reply_line() {
let mut line = Vec::new();
line.extend_from_slice(b"250 ");
line.extend(std::iter::repeat(b'X').take(8186));
line.extend_from_slice(b"\r\n");
assert_eq!(line.len(), 8192);
let result = SmtpConnection::try_parse_response(&line);
assert!(
result.unwrap().is_some(),
"reply line of exactly 8192 octets must be accepted"
);
}
#[test]
fn reverse_path_rejects_overlong_address() {
let long_local = "a".repeat(499 - "@example.com".len());
let overlong_addr = format!("{long_local}@example.com");
assert_eq!(overlong_addr.len(), 499);
let result = ReversePath::new(&overlong_addr);
assert!(
result.is_err(),
"overlong reverse-path must be rejected (RFC 5321 Section 4.5.3.1.3)"
);
}
#[test]
fn forward_path_rejects_overlong_address() {
let long_local = "b".repeat(501 - "@example.com".len());
let overlong_rcpt = format!("{long_local}@example.com");
assert_eq!(overlong_rcpt.len(), 501);
let result = ForwardPath::new(&overlong_rcpt);
assert!(
result.is_err(),
"overlong forward-path must be rejected (RFC 5321 Section 4.5.3.1.3)"
);
}
#[tokio::test]
async fn send_accepts_mail_from_at_path_limit() {
let domain_189 = format!("{}.{}.{}", "q".repeat(63), "r".repeat(63), "s".repeat(61));
let max_addr = format!("{}@{domain_189}", "c".repeat(64));
assert_eq!(max_addr.len(), 254);
let stream = mock_interactive_server(vec![
("MAIL FROM:", "250 OK\r\n"),
("RCPT TO:", "250 OK\r\n"),
("DATA", "354 Start\r\n"),
("\r\n.\r\n", "250 OK\r\n"),
])
.await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let result = conn
.send(
&ReversePath::new(&max_addr).unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello",
Duration::from_secs(5),
)
.await;
assert!(
result.is_ok(),
"MAIL FROM at exactly 256-octet path must be accepted: {result:?}"
);
}
#[test]
fn pipelined_forward_path_rejects_overlong_rcpt_to() {
let long_local = "d".repeat(501 - "@example.com".len());
let overlong_rcpt = format!("{long_local}@example.com");
assert_eq!(overlong_rcpt.len(), 501);
let result = ForwardPath::new(&overlong_rcpt);
assert!(
result.is_err(),
"overlong forward-path must be rejected (RFC 5321 Section 4.5.3.1.3)"
);
}
#[test]
fn bdat_forward_path_rejects_overlong_rcpt_to() {
let long_local = "e".repeat(501 - "@example.com".len());
let overlong_rcpt = format!("{long_local}@example.com");
let result = ForwardPath::new(&overlong_rcpt);
assert!(
result.is_err(),
"overlong forward-path must be rejected (RFC 5321 Section 4.5.3.1.3)"
);
}
#[test]
fn reverse_path_rejects_overlong_255_octet_address() {
let domain_190 = format!("{}.{}.{}", "u".repeat(63), "v".repeat(63), "w".repeat(62));
let overlong_addr = format!("{}@{domain_190}", "a".repeat(64));
assert_eq!(overlong_addr.len(), 255);
let result = ReversePath::new(&overlong_addr);
assert!(
result.is_err(),
"reverse-path exceeding 256 octets must be rejected (RFC 5321 Section 4.5.3.1.3)"
);
}
#[test]
fn forward_path_rejects_overlong_255_octet_address() {
let domain_190 = format!("{}.{}.{}", "y".repeat(63), "z".repeat(63), "k".repeat(62));
let overlong_rcpt = format!("{}@{domain_190}", "b".repeat(64));
assert_eq!(overlong_rcpt.len(), 255);
let result = ForwardPath::new(&overlong_rcpt);
assert!(
result.is_err(),
"forward-path exceeding 256 octets must be rejected (RFC 5321 Section 4.5.3.1.3)"
);
}
#[test]
fn path_types_accept_address_at_path_limit() {
let domain_189 = format!("{}.{}.{}", "m".repeat(63), "n".repeat(63), "o".repeat(61));
let max_addr = format!("{}@{domain_189}", "c".repeat(64));
assert_eq!(max_addr.len(), 254);
let rp = ReversePath::new(&max_addr);
assert!(
rp.is_ok(),
"reverse-path at exactly 254 octets must be accepted: {rp:?}"
);
let fp = ForwardPath::new(&max_addr);
assert!(
fp.is_ok(),
"forward-path at exactly 254 octets must be accepted: {fp:?}"
);
}
#[test]
fn bdat_reverse_path_rejects_overlong_address() {
let overlong_addr = format!("{}@example.com", "a".repeat(255 - "@example.com".len()));
assert!(overlong_addr.len() > 254);
let result = ReversePath::new(&overlong_addr);
assert!(
result.is_err(),
"BDAT: overlong reverse-path must be rejected (RFC 5321 Section 4.5.3.1.3)"
);
}
#[test]
fn lmtp_forward_path_rejects_overlong_address() {
let overlong_rcpt = format!("{}@example.com", "b".repeat(255 - "@example.com".len()));
assert!(overlong_rcpt.len() > 254);
let result = ForwardPath::new(&overlong_rcpt);
assert!(
result.is_err(),
"LMTP: overlong forward-path must be rejected (RFC 5321 Section 4.5.3.1.3)"
);
}
#[tokio::test]
async fn vrfy_success() {
let stream = mock_server(vec!["250 User Name <user@example.com>\r\n"]).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let resp = conn.vrfy("user@example.com", Duration::from_secs(5)).await;
assert!(resp.is_ok(), "vrfy should succeed: {resp:?}");
let resp = resp.unwrap();
assert_eq!(resp.code, 250);
assert!(resp.lines[0].contains("user@example.com"));
}
#[tokio::test]
async fn vrfy_not_implemented() {
let stream = mock_server(vec!["502 VRFY command is disabled\r\n"]).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let resp = conn
.vrfy("user@example.com", Duration::from_secs(5))
.await
.unwrap();
assert_eq!(resp.code, 502);
}
#[tokio::test]
async fn vrfy_rejects_crlf_injection() {
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let result = conn
.vrfy("user\r\nMAIL FROM:<evil@x.com>", Duration::from_secs(1))
.await;
assert!(result.is_err(), "CRLF in VRFY argument must be rejected");
}
#[tokio::test]
async fn expn_success() {
let stream = mock_server(vec![
"250-Alice <alice@example.com>\r\n250 Bob <bob@example.com>\r\n",
])
.await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let resp = conn.expn("staff", Duration::from_secs(5)).await;
assert!(resp.is_ok(), "expn should succeed: {resp:?}");
let resp = resp.unwrap();
assert_eq!(resp.code, 250);
assert_eq!(resp.lines.len(), 2);
}
#[tokio::test]
async fn expn_not_implemented() {
let stream = mock_server(vec!["502 EXPN command is disabled\r\n"]).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let resp = conn.expn("staff", Duration::from_secs(5)).await.unwrap();
assert_eq!(resp.code, 502);
}
#[tokio::test]
async fn expn_rejects_crlf_injection() {
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let result = conn
.expn("list\r\nRCPT TO:<evil@x.com>", Duration::from_secs(1))
.await;
assert!(result.is_err(), "CRLF in EXPN argument must be rejected");
}
#[tokio::test]
async fn help_without_argument_succeeds() {
let stream = mock_server(vec!["214 Supported commands: EHLO MAIL RCPT DATA\r\n"]).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let resp = conn.help(None, Duration::from_secs(5)).await;
assert!(resp.is_ok(), "help(None) should succeed: {resp:?}");
let resp = resp.unwrap();
assert_eq!(resp.code, 214);
assert!(resp.lines[0].contains("Supported commands"));
}
#[tokio::test]
async fn help_with_argument_succeeds() {
let stream = mock_server(vec![
"211 MAIL FROM:<reverse-path> [SP Mail-parameters]\r\n",
])
.await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let resp = conn.help(Some("MAIL"), Duration::from_secs(5)).await;
assert!(
resp.is_ok(),
"help(Some(\"MAIL\")) should succeed: {resp:?}"
);
let resp = resp.unwrap();
assert_eq!(resp.code, 211);
assert!(resp.lines[0].contains("MAIL FROM"));
}
#[tokio::test]
async fn help_rejects_empty_argument() {
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let result = conn.help(Some(""), Duration::from_secs(1)).await;
assert!(
result.is_err(),
"HELP with an empty String argument must be rejected"
);
}
#[tokio::test]
async fn send_with_params_rejects_size_without_server_support() {
use crate::types::MailFromParams;
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let params = MailFromParams {
size: Some(1024),
..Default::default()
};
let result = conn
.send_with_params(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Hello",
Some(¶ms),
Duration::from_secs(1),
)
.await;
assert!(
result.is_err(),
"SIZE parameter without server SIZE support must fail \
(RFC 5321 Section 2.2.1 / RFC 1870 Section 3)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(msg.contains("SIZE"), "error should mention SIZE: {msg}");
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn send_rejects_bare_lf_in_message() {
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\n\nBare LF body",
Duration::from_secs(1),
)
.await;
assert!(
result.is_err(),
"bare LF in message data must be rejected (RFC 5321 Section 2.3.8)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("bare LF") || msg.contains("CRLF"),
"error should mention bare LF or CRLF: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn send_rejects_bare_cr_in_message() {
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\rBare CR body\r\n",
Duration::from_secs(1),
)
.await;
assert!(
result.is_err(),
"bare CR in message data must be rejected (RFC 5321 Section 2.3.8)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("bare CR") || msg.contains("CRLF"),
"error should mention bare CR or CRLF: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn send_rejects_null_byte_in_data() {
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello\x00World\r\n",
Duration::from_secs(1),
)
.await;
assert!(
result.is_err(),
"NUL byte in DATA content must be rejected (RFC 5321 Section 4.5.2)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("NUL") || msg.contains("0x00"),
"error should mention NUL byte: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn send_lmtp_rejects_null_byte_in_data() {
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, ServerCapabilities::default(), Protocol::Lmtp);
let result = conn
.send_lmtp(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\n\r\nContains\x00NUL\r\n",
None,
Duration::from_secs(1),
)
.await;
assert!(
result.is_err(),
"LMTP: NUL byte in DATA content must be rejected (RFC 5321 Section 4.5.2)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("NUL") || msg.contains("0x00"),
"error should mention NUL byte: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn send_bdat_allows_null_byte_in_content() {
use crate::types::{BodyType, MailFromParams};
let stream = mock_interactive_server(vec![
("MAIL FROM:", "250 OK\r\n"),
("RCPT TO:", "250 OK\r\n"),
("BDAT ", "250 OK\r\n"),
])
.await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Chunking, SmtpExtension::BinaryMime],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
body: Some(BodyType::BinaryMime),
..Default::default()
};
let result = conn
.send_bdat(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"\x00\x01\x02binary content\xff\xfe",
Some(¶ms),
Duration::from_secs(5),
)
.await;
assert!(
result.is_ok(),
"BDAT with BODY=BINARYMIME must allow NUL bytes \
(RFC 3030 Section 4): {result:?}"
);
}
#[tokio::test]
async fn send_accepts_proper_crlf_message() {
let stream = mock_interactive_server(vec![
("MAIL FROM:", "250 OK\r\n"),
("RCPT TO:", "250 OK\r\n"),
("DATA", "354 Start\r\n"),
("\r\n.\r\n", "250 OK\r\n"),
])
.await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\n\r\nProper CRLF body\r\n",
Duration::from_secs(5),
)
.await;
assert!(
result.is_ok(),
"message with proper CRLF must be accepted: {result:?}"
);
}
#[tokio::test]
async fn send_lmtp_rejects_bare_lf_in_message() {
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, ServerCapabilities::default(), Protocol::Lmtp);
let result = conn
.send_lmtp(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\n\nBare LF",
None,
Duration::from_secs(1),
)
.await;
assert!(
result.is_err(),
"LMTP: bare LF in message data must be rejected (RFC 5321 Section 2.3.8)"
);
}
#[tokio::test]
async fn send_rejects_overlong_text_line() {
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let mut message = Vec::new();
message.extend_from_slice(b"Subject: Test\r\n\r\n");
message.extend(std::iter::repeat(b'X').take(999));
message.extend_from_slice(b"\r\n");
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
&message,
Duration::from_secs(1),
)
.await;
assert!(
result.is_err(),
"text line exceeding 1000 octets must be rejected \
(RFC 5321 Section 4.5.3.1.6)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("1000") || msg.contains("text line"),
"error should mention 1000-octet limit: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn send_accepts_text_line_at_1000_limit() {
let stream = mock_interactive_server(vec![
("MAIL FROM:", "250 OK\r\n"),
("RCPT TO:", "250 OK\r\n"),
("DATA", "354 Start\r\n"),
("\r\n.\r\n", "250 OK\r\n"),
])
.await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let mut message = Vec::new();
message.extend_from_slice(b"Subject: Test\r\n\r\n");
message.extend(std::iter::repeat(b'X').take(998));
message.extend_from_slice(b"\r\n");
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
&message,
Duration::from_secs(5),
)
.await;
assert!(
result.is_ok(),
"text line of exactly 1000 octets must be accepted: {result:?}"
);
}
#[tokio::test]
async fn send_rejects_overlong_trailing_line_without_crlf() {
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let mut message = Vec::new();
message.extend_from_slice(b"Subject: Test\r\n\r\n");
message.extend(std::iter::repeat(b'Y').take(999));
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
&message,
Duration::from_secs(1),
)
.await;
assert!(
result.is_err(),
"trailing line exceeding 1000 octets (with CRLF) must be rejected"
);
}
#[test]
fn test_dot_stuffing_transparency_dot_excluded_from_line_length() {
let mut msg_at_limit = Vec::new();
msg_at_limit.extend_from_slice(b"Subject: Test\r\n\r\n");
msg_at_limit.push(b'.');
msg_at_limit.extend(std::iter::repeat(b'a').take(997));
msg_at_limit.extend_from_slice(b"\r\n");
let dot_line_len = 1 + 997 + 2; assert_eq!(
dot_line_len, 1000,
"test setup: raw line must be 1000 bytes"
);
let result = SmtpConnection::validate_data_message(&msg_at_limit);
assert!(
result.is_ok(),
"a dot-prefixed line of 1000 bytes raw must be accepted because \
the transparency dot is not counted \
(RFC 5321 Section 4.5.3.1.6): {result:?}"
);
let mut msg_over_limit = Vec::new();
msg_over_limit.extend_from_slice(b"Subject: Test\r\n\r\n");
msg_over_limit.push(b'.');
msg_over_limit.extend(std::iter::repeat(b'a').take(998));
msg_over_limit.extend_from_slice(b"\r\n");
let dot_line_len2 = 1 + 998 + 2; assert_eq!(
dot_line_len2, 1001,
"test setup: raw line must be 1001 bytes"
);
let result = SmtpConnection::validate_data_message(&msg_over_limit);
assert!(
result.is_err(),
"a dot-prefixed line of 1001 bytes raw must be rejected \
(RFC 5321 Section 4.5.3.1.6)"
);
let mut msg_no_dot = Vec::new();
msg_no_dot.extend_from_slice(b"Subject: Test\r\n\r\n");
msg_no_dot.extend(std::iter::repeat(b'a').take(998));
msg_no_dot.extend_from_slice(b"\r\n");
let result = SmtpConnection::validate_data_message(&msg_no_dot);
assert!(
result.is_ok(),
"a non-dot line of exactly 1000 bytes must be accepted: {result:?}"
);
}
#[test]
fn text_line_exactly_1000_bytes_including_crlf_is_accepted() {
let mut msg = Vec::new();
msg.extend_from_slice(b"Subject: Test\r\n\r\n");
msg.extend(std::iter::repeat(b'X').take(998));
msg.extend_from_slice(b"\r\n");
let result = SmtpConnection::validate_data_message(&msg);
assert!(
result.is_ok(),
"a text line of exactly 1000 octets (998 content + CRLF) must be \
accepted per RFC 5321 Section 4.5.3.1.6: {result:?}"
);
}
#[test]
fn text_line_1001_bytes_including_crlf_is_rejected() {
let mut msg = Vec::new();
msg.extend_from_slice(b"Subject: Test\r\n\r\n");
msg.extend(std::iter::repeat(b'X').take(999));
msg.extend_from_slice(b"\r\n");
let result = SmtpConnection::validate_data_message(&msg);
assert!(
result.is_err(),
"a text line of 1001 octets (999 content + CRLF) must be rejected \
per RFC 5321 Section 4.5.3.1.6"
);
}
#[test]
fn dot_prefixed_line_at_1000_bytes_is_accepted_transparency_dot_excluded() {
let mut msg = Vec::new();
msg.extend_from_slice(b"Subject: Test\r\n\r\n");
msg.push(b'.');
msg.extend(std::iter::repeat(b'a').take(997));
msg.extend_from_slice(b"\r\n");
let raw_line_len = 1 + 997 + 2;
assert_eq!(
raw_line_len, 1000,
"test setup: raw line must be 1000 bytes"
);
let result = SmtpConnection::validate_data_message(&msg);
assert!(
result.is_ok(),
"a dot-prefixed line of 1000 octets must be accepted because the \
transparency dot is not counted per RFC 5321 Section 4.5.3.1.6: {result:?}"
);
}
#[tokio::test]
async fn send_rejects_8bit_content_without_8bitmime() {
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
"Subject: Test\r\n\r\nHéllo\r\n".as_bytes(),
Duration::from_secs(1),
)
.await;
assert!(
result.is_err(),
"8-bit content must be rejected when server does not support \
8BITMIME (RFC 1652 Section 1)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("8-bit") || msg.contains("8BITMIME") || msg.contains("7-bit"),
"error should mention 8-bit content restriction: {msg}"
);
}
other => panic!("expected Protocol error for 8-bit content, got: {other:?}"),
}
}
#[tokio::test]
async fn send_allows_8bit_content_with_8bitmime_support() {
let stream = mock_interactive_server(vec![
("MAIL FROM:", "250 OK\r\n"),
("RCPT TO:", "250 OK\r\n"),
("DATA", "354 Start\r\n"),
("\r\n.\r\n", "250 OK\r\n"),
])
.await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::EightBitMime],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
"Subject: Test\r\n\r\nHéllo\r\n".as_bytes(),
Duration::from_secs(5),
)
.await;
assert!(
result.is_ok(),
"8-bit content must be allowed when server supports 8BITMIME \
(RFC 1652 Section 3): {result:?}"
);
}
#[tokio::test]
async fn send_allows_7bit_content_without_8bitmime() {
let stream = mock_interactive_server(vec![
("MAIL FROM:", "250 OK\r\n"),
("RCPT TO:", "250 OK\r\n"),
("DATA", "354 Start\r\n"),
("\r\n.\r\n", "250 OK\r\n"),
])
.await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello world\r\n",
Duration::from_secs(5),
)
.await;
assert!(
result.is_ok(),
"7-bit content must always be allowed: {result:?}"
);
}
#[tokio::test]
async fn send_lmtp_rejects_8bit_content_without_8bitmime() {
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, ServerCapabilities::default(), Protocol::Lmtp);
let result = conn
.send_lmtp(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
"Subject: Test\r\n\r\nHéllo\r\n".as_bytes(),
None,
Duration::from_secs(1),
)
.await;
assert!(
result.is_err(),
"LMTP: 8-bit content must be rejected without 8BITMIME \
(RFC 1652 Section 1)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("8-bit") || msg.contains("8BITMIME") || msg.contains("7-bit"),
"error should mention 8-bit content restriction: {msg}"
);
}
other => panic!("expected Protocol error for 8-bit content, got: {other:?}"),
}
}
#[tokio::test]
async fn send_rejects_8bit_content_with_binarymime_without_8bitmime() {
let stream = mock_interactive_server(vec![
("MAIL FROM:", "250 OK\r\n"),
("RCPT TO:", "250 OK\r\n"),
("DATA", "354 Start\r\n"),
("\r\n.\r\n", "250 OK\r\n"),
])
.await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::BinaryMime, SmtpExtension::Chunking],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
"Subject: Test\r\n\r\nHéllo\r\n".as_bytes(),
Duration::from_secs(5),
)
.await;
assert!(
result.is_err(),
"8-bit DATA must be rejected when the server omits 8BITMIME, \
even if it advertises BINARYMIME: {result:?}"
);
}
#[tokio::test]
async fn auth_plain_uses_initial_response_without_sasl_ir() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 4096];
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0, "connection closed while waiting for AUTH");
accumulated.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&accumulated);
if text.contains("\r\n") {
assert!(
text.starts_with("AUTH PLAIN ") && !text.starts_with("AUTH PLAIN\r\n"),
"RFC 4954 Section 4: AUTH PLAIN should include an \
initial response even without SASL-IR. Got: {text:?}"
);
break;
}
}
socket
.write_all(b"235 2.7.0 Authentication successful\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Auth(vec![
crate::types::AuthMechanism::Plain,
])],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.auth_plain("user", "pass", Duration::from_secs(5))
.await;
assert!(result.is_ok(), "auth_plain should succeed: {result:?}");
server.await.unwrap();
}
#[tokio::test]
async fn auth_plain_uses_initial_response_with_sasl_ir() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 4096];
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0, "connection closed while waiting for AUTH");
accumulated.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&accumulated);
if text.contains("\r\n") {
assert!(
text.starts_with("AUTH PLAIN ") && !text.starts_with("AUTH PLAIN\r\n"),
"with SASL-IR, AUTH PLAIN must include the initial \
response (RFC 4954 Section 4). Got: {text:?}"
);
break;
}
}
socket
.write_all(b"235 2.7.0 Authentication successful\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![
SmtpExtension::Auth(vec![crate::types::AuthMechanism::Plain]),
SmtpExtension::SaslIr,
],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.auth_plain("user", "pass", Duration::from_secs(5))
.await;
assert!(result.is_ok(), "auth_plain should succeed: {result:?}");
server.await.unwrap();
}
#[tokio::test]
async fn auth_plain_with_authzid_uses_initial_response_with_sasl_ir() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 4096];
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0, "connection closed while waiting for AUTH");
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("\r\n") {
break;
}
}
let line = String::from_utf8_lossy(&accumulated);
assert!(
line.starts_with("AUTH PLAIN "),
"AUTH PLAIN must include an initial response. Got: {line:?}"
);
let b64 = line
.strip_prefix("AUTH PLAIN ")
.and_then(|s| s.strip_suffix("\r\n"))
.expect("AUTH line should have base64 payload");
let decoded = base64::engine::general_purpose::STANDARD
.decode(b64)
.expect("AUTH PLAIN payload must be valid base64");
assert_eq!(
decoded, b"admin@example.com\0user\0pass",
"AUTH PLAIN with authzid must encode authzid NUL authcid NUL passwd \
(RFC 4616 Section 2)"
);
socket
.write_all(b"235 2.7.0 Authentication successful\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![
SmtpExtension::Auth(vec![crate::types::AuthMechanism::Plain]),
SmtpExtension::SaslIr,
],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.auth_plain_with_authzid("admin@example.com", "user", "pass", Duration::from_secs(5))
.await;
assert!(
result.is_ok(),
"AUTH PLAIN with authzid should succeed: {result:?}"
);
server.await.unwrap();
}
#[tokio::test]
async fn auth_plain_with_authzid_uses_initial_response_without_sasl_ir() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 4096];
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0, "connection closed while waiting for AUTH");
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("\r\n") {
break;
}
}
let line = String::from_utf8_lossy(&accumulated);
assert!(
line.starts_with("AUTH PLAIN ") && !line.starts_with("AUTH PLAIN\r\n"),
"RFC 4954 Section 4: AUTH PLAIN with authzid should include an \
initial response even without SASL-IR. Got: {line:?}"
);
let b64 = line
.strip_prefix("AUTH PLAIN ")
.and_then(|s| s.strip_suffix("\r\n"))
.expect("AUTH line should include a base64 payload");
let decoded = base64::engine::general_purpose::STANDARD
.decode(b64)
.expect("AUTH PLAIN payload must be valid base64");
assert_eq!(
decoded, b"admin@example.com\0user\0pass",
"AUTH PLAIN with authzid must encode authzid NUL authcid NUL passwd \
(RFC 4616 Section 2)"
);
socket
.write_all(b"235 2.7.0 Authentication successful\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Auth(vec![
crate::types::AuthMechanism::Plain,
])],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.auth_plain_with_authzid("admin@example.com", "user", "pass", Duration::from_secs(5))
.await;
assert!(
result.is_ok(),
"AUTH PLAIN with authzid should succeed: {result:?}"
);
server.await.unwrap();
}
#[tokio::test]
async fn auth_plain_with_authzid_rejects_empty_authzid() {
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Auth(vec![
crate::types::AuthMechanism::Plain,
])],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.auth_plain_with_authzid("", "user", "pass", Duration::from_secs(1))
.await;
assert!(
result.is_err(),
"AUTH PLAIN with an explicit empty authzid must be rejected \
(RFC 4616 Section 2: authzid = 1*SAFE)"
);
}
#[tokio::test]
async fn auth_plain_with_authzid_rejects_nul_in_authzid() {
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Auth(vec![
crate::types::AuthMechanism::Plain,
])],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.auth_plain_with_authzid("admin\0example.com", "user", "pass", Duration::from_secs(1))
.await;
assert!(
result.is_err(),
"AUTH PLAIN authzid must reject embedded NUL delimiters \
(RFC 4616 Section 2)"
);
}
#[test]
fn build_plain_credentials_rejects_control_chars_in_username() {
let result = SmtpConnection::build_plain_credentials(None, "us\ner", "pass");
assert!(
matches!(result, Err(Error::Protocol(ref msg)) if msg.contains("control")),
"AUTH PLAIN authcid must reject control characters \
(RFC 4616 Section 2 / StringPrep handling), got: {result:?}"
);
}
#[test]
fn build_plain_credentials_rejects_control_chars_in_password() {
let result = SmtpConnection::build_plain_credentials(None, "user", "pa\rss");
assert!(
matches!(result, Err(Error::Protocol(ref msg)) if msg.contains("control")),
"AUTH PLAIN passwd must reject control characters \
(RFC 4616 Section 2 / StringPrep handling), got: {result:?}"
);
}
#[test]
fn ehlo_parses_sasl_ir_extension() {
let resp = SmtpResponse {
code: 250,
enhanced_code: None,
lines: vec![
"mail.example.com".into(),
"AUTH PLAIN".into(),
"SASL-IR".into(),
"PIPELINING".into(),
],
};
let caps = SmtpConnection::parse_capabilities(&resp);
assert!(
caps.supports_sasl_ir(),
"SASL-IR must be recognized as an EHLO extension"
);
}
#[tokio::test]
async fn ehlo_rejects_empty_domain() {
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, ServerCapabilities::default(), Protocol::Smtp);
let result = conn.set_ehlo_domain("").await;
assert!(
result.is_err(),
"empty EHLO domain must be rejected (RFC 5321 Section 4.1.1.1)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("EHLO") || msg.contains("empty") || msg.contains("domain"),
"error should mention EHLO domain: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn ehlo_rejects_non_ascii_domain() {
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, ServerCapabilities::default(), Protocol::Smtp);
let result = conn.set_ehlo_domain("ünïcödé.example.com").await;
assert!(
result.is_err(),
"non-ASCII EHLO domain must be rejected (RFC 5321 Section 4.1.1.1)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("EHLO")
|| msg.contains("printable")
|| msg.contains("ASCII")
|| msg.contains("RFC 5321"),
"error should cite RFC 5321 domain rules: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn ehlo_rejects_control_char_in_domain() {
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, ServerCapabilities::default(), Protocol::Smtp);
let result = conn.set_ehlo_domain("mail\x00.example.com").await;
assert!(
result.is_err(),
"NUL byte in EHLO domain must be rejected (RFC 5321 Section 4.1.1.1)"
);
}
#[tokio::test]
async fn set_ehlo_domain_rejects_empty_eagerly() {
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, ServerCapabilities::default(), Protocol::Smtp);
let result = conn.set_ehlo_domain("").await;
assert!(
result.is_err(),
"set_ehlo_domain must reject empty domain eagerly \
(RFC 5321 Section 4.1.1.1)"
);
}
#[tokio::test]
async fn set_ehlo_domain_rejects_control_chars_eagerly() {
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, ServerCapabilities::default(), Protocol::Smtp);
let result = conn.set_ehlo_domain("host\x00.example.com").await;
assert!(
result.is_err(),
"set_ehlo_domain must reject NUL byte eagerly \
(RFC 5321 Section 4.1.1.1)"
);
}
#[tokio::test]
async fn set_ehlo_domain_rejects_non_ascii_eagerly() {
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, ServerCapabilities::default(), Protocol::Smtp);
let result = conn.set_ehlo_domain("ünïcödé.example.com").await;
assert!(
result.is_err(),
"set_ehlo_domain must reject non-ASCII domain eagerly \
(RFC 5321 Section 4.1.1.1)"
);
}
#[tokio::test]
async fn set_ehlo_domain_rejects_empty_label_eagerly() {
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, ServerCapabilities::default(), Protocol::Smtp);
let result = conn.set_ehlo_domain("mail..example.com").await;
assert!(
result.is_err(),
"set_ehlo_domain must reject empty labels eagerly \
(RFC 5321 Section 4.1.2)"
);
}
#[tokio::test]
async fn set_ehlo_domain_accepts_valid_domain() {
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, ServerCapabilities::default(), Protocol::Smtp);
let result = conn.set_ehlo_domain("mail.example.com").await;
assert!(
result.is_ok(),
"set_ehlo_domain must accept valid domain: {:?}",
result.unwrap_err()
);
}
#[tokio::test]
async fn set_ehlo_domain_accepts_address_literal() {
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, ServerCapabilities::default(), Protocol::Smtp);
let result = conn.set_ehlo_domain("[127.0.0.1]").await;
assert!(
result.is_ok(),
"set_ehlo_domain must accept address literal: {:?}",
result.unwrap_err()
);
}
#[test]
fn default_ehlo_domain_uses_address_literal_fallback() {
let domain = default_ehlo_domain().expect("default EHLO domain must be valid");
assert_eq!(domain.as_str(), "[127.0.0.1]");
}
#[tokio::test]
async fn rehlo_reissues_ehlo_with_new_domain() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 4096];
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
if n == 0 {
break;
}
accumulated.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&accumulated);
if text.contains("EHLO ") && text.contains("\r\n") {
assert!(
text.contains("EHLO client.example.com"),
"rehlo must send EHLO with the updated domain \
(RFC 5321 Section 4.1.1.1). Got: {text}"
);
break;
}
}
socket
.write_all(
b"250-mail.example.com\r\n\
250-PIPELINING\r\n\
250 CHUNKING\r\n",
)
.await
.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Pipelining],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
assert!(
!conn.capabilities().await.supports_chunking(),
"pre-condition: CHUNKING must not be supported before rehlo"
);
conn.set_ehlo_domain("client.example.com").await.unwrap();
let result = conn.rehlo(Duration::from_secs(5)).await;
assert!(result.is_ok(), "rehlo must succeed: {result:?}");
assert!(
conn.capabilities().await.supports_chunking(),
"rehlo must refresh capabilities — CHUNKING must now be supported \
(RFC 5321 Section 4.1.1.1)"
);
server.await.unwrap();
}
#[tokio::test]
async fn rehlo_uses_helo_after_helo_fallback() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 4096];
socket
.write_all(b"220 legacy.server.com SMTP\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
let n = socket.read(&mut buf).await.unwrap();
let cmd = String::from_utf8_lossy(&buf[..n]);
assert!(
cmd.starts_with("EHLO "),
"client should try EHLO first, got: {cmd}"
);
socket
.write_all(b"502 Command not implemented\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
let n = socket.read(&mut buf).await.unwrap();
let cmd = String::from_utf8_lossy(&buf[..n]);
assert!(
cmd.starts_with("HELO "),
"client must fall back to HELO: {cmd}"
);
socket
.write_all(b"250 legacy.server.com\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
let n = socket.read(&mut buf).await.unwrap();
let cmd = String::from_utf8_lossy(&buf[..n]);
assert!(
cmd.starts_with("HELO "),
"rehlo must send HELO (not EHLO) after HELO fallback \
(RFC 5321 Section 4.1.4). Got: {cmd}"
);
socket
.write_all(b"250 legacy.server.com\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let conn = SmtpConnection::connect(
"127.0.0.1",
addr.port(),
TlsMode::None,
Duration::from_secs(5),
)
.await
.unwrap();
conn.rehlo(Duration::from_secs(5)).await.unwrap();
server.await.unwrap();
}
#[tokio::test]
async fn rehlo_uses_helo_with_address_literal_after_helo_fallback() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 4096];
let n = socket.read(&mut buf).await.unwrap();
let cmd = String::from_utf8_lossy(&buf[..n]);
assert!(
cmd.starts_with("HELO [127.0.0.1]\r\n"),
"rehlo must preserve the configured address-literal in HELO fallback mode. Got: {cmd}"
);
socket
.write_all(b"250 legacy.server.com\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let conn = SmtpConnection::from_plain(stream, ServerCapabilities::default(), Protocol::Smtp);
conn.set_ehlo_domain("[127.0.0.1]").await.unwrap();
{
let mut inner = conn.inner.lock().await;
inner.helo_mode = true;
}
let result = conn.rehlo(Duration::from_secs(5)).await;
assert!(
result.is_ok(),
"HELO fallback must accept configured address-literals: {result:?}"
);
server.await.unwrap();
}
#[tokio::test]
async fn validate_params_rejects_binarymime_without_chunking() {
use crate::types::{BodyType, MailFromParams};
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::BinaryMime],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
body: Some(BodyType::BinaryMime),
..Default::default()
};
let result = conn.validate_params_public(Some(¶ms)).await;
assert!(
result.is_err(),
"BODY=BINARYMIME without CHUNKING must be rejected (RFC 3030 Section 2)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("CHUNKING"),
"error should mention CHUNKING: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn size_param_excludes_dot_stuffing_overhead() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let message = b"Subject: Test\r\n\r\n.line1\r\n.line2\r\n";
let raw_len = message.len();
let stuffed_len = encode::dot_stuff(message).len();
assert!(
stuffed_len > raw_len,
"test setup: stuffed size ({stuffed_len}) must exceed \
raw size ({raw_len})"
);
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 4096];
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0);
accumulated.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&accumulated);
if text.contains("MAIL FROM:") {
let size_idx = text.find("SIZE=").expect(
"MAIL FROM must include SIZE= when server \
advertises SIZE",
);
let after_size = &text[size_idx + 5..];
let size_str: String = after_size
.chars()
.take_while(char::is_ascii_digit)
.collect();
let declared_size: usize = size_str.parse().unwrap();
assert_eq!(
declared_size, raw_len,
"RFC 1870 Section 5: SIZE must be the raw message \
size ({raw_len}), not the dot-stuffed wire \
size ({stuffed_len}). Got: {declared_size}"
);
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("RCPT TO:") {
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("DATA") {
break;
}
}
socket.write_all(b"354 Start\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("\r\n.\r\n") {
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Size(None)],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
message,
Duration::from_secs(5),
)
.await;
assert!(result.is_ok(), "send failed: {result:?}");
server.await.unwrap();
}
#[tokio::test]
async fn size_limit_check_uses_raw_message_size() {
let stream = mock_server(vec![]).await;
let message = b".X\r\n.X\r\n.X\r\n.X\r\n.X\r\n";
let raw_len = message.len();
let limit = raw_len - 1;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Size(Some(limit as u64))],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
message,
Duration::from_secs(5),
)
.await;
assert!(
result.is_err(),
"RFC 1870 Section 5 / Section 4: raw message \
({raw_len} bytes) exceeds server limit ({limit} bytes); \
must be rejected"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("SIZE") && msg.contains("exceeds"),
"error must mention SIZE limit: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn starttls_rejects_non_220_success_code() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
socket
.write_all(b"220 mail.example.com ESMTP\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
let mut buf = [0u8; 4096];
let _ = socket.read(&mut buf).await.unwrap();
socket
.write_all(b"250-mail.example.com\r\n250 STARTTLS\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
let _ = socket.read(&mut buf).await.unwrap();
socket.write_all(b"250 Go ahead\r\n").await.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(500)).await;
});
let result = SmtpConnection::connect(
"127.0.0.1",
addr.port(),
TlsMode::StartTls,
Duration::from_secs(5),
)
.await;
match result {
Ok(_) => panic!("STARTTLS with non-220 must fail"),
Err(Error::Protocol(msg)) => {
assert!(
msg.contains("220") || msg.contains("STARTTLS"),
"error should mention 220 or STARTTLS: {msg}"
);
}
Err(other) => panic!(
"STARTTLS with non-220 response (250) must produce a \
Protocol error, not {other:?} (RFC 3207 Section 4)"
),
}
}
#[tokio::test]
async fn non_ascii_sender_rejected_without_smtputf8() {
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("ünïcödé@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello",
Duration::from_secs(1),
)
.await;
assert!(
result.is_err(),
"non-ASCII sender without SMTPUTF8 must be rejected"
);
assert!(
matches!(result, Err(Error::SmtpUtf8Required)),
"expected Error::SmtpUtf8Required, got: {result:?}"
);
}
#[tokio::test]
async fn non_ascii_recipient_rejected_without_smtputf8() {
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("ünïcödé@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello",
Duration::from_secs(1),
)
.await;
assert!(
result.is_err(),
"non-ASCII recipient without SMTPUTF8 must be rejected"
);
assert!(
matches!(result, Err(Error::SmtpUtf8Required)),
"expected Error::SmtpUtf8Required, got: {result:?}"
);
}
#[tokio::test]
async fn non_ascii_address_allowed_with_smtputf8() {
use crate::types::MailFromParams;
let stream = mock_interactive_server(vec![
("MAIL FROM:", "250 OK\r\n"),
("RCPT TO:", "250 OK\r\n"),
("DATA", "354 Start\r\n"),
("\r\n.\r\n", "250 OK\r\n"),
])
.await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::SmtpUtf8, SmtpExtension::EightBitMime],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
smtputf8: true,
..Default::default()
};
let result = conn
.send_with_params(
&ReversePath::new("ünïcödé@example.com").unwrap(),
&[ForwardPath::new("réçîpïënt@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello",
Some(¶ms),
Duration::from_secs(5),
)
.await;
assert!(
result.is_ok(),
"non-ASCII addresses with SMTPUTF8 must succeed: {result:?}"
);
}
#[tokio::test]
async fn send_lmtp_bdat_rejects_non_ascii_sender_without_smtputf8() {
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Chunking],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Lmtp);
let result = conn
.send_lmtp_bdat(
&ReversePath::new("sénder@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Hello",
None,
Duration::from_secs(1),
)
.await;
assert!(
result.is_err(),
"send_lmtp_bdat must reject non-ASCII sender without SMTPUTF8 \
(RFC 5321 Section 4.1.2 / RFC 6531 Section 3.3)"
);
assert!(
matches!(result, Err(Error::SmtpUtf8Required)),
"expected Error::SmtpUtf8Required, got: {result:?}"
);
}
#[tokio::test]
async fn send_lmtp_bdat_rejects_non_ascii_recipient_without_smtputf8() {
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Chunking],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Lmtp);
let result = conn
.send_lmtp_bdat(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("réçîpïënt@example.com").unwrap()],
b"Hello",
None,
Duration::from_secs(1),
)
.await;
assert!(
result.is_err(),
"send_lmtp_bdat must reject non-ASCII recipient without SMTPUTF8 \
(RFC 5321 Section 4.1.2 / RFC 6531 Section 3.3)"
);
assert!(
matches!(result, Err(Error::SmtpUtf8Required)),
"expected Error::SmtpUtf8Required, got: {result:?}"
);
}
#[test]
fn reverse_path_rejects_null_byte_in_sender() {
let result = ReversePath::new("user\x00@example.com");
assert!(
result.is_err(),
"NUL byte in sender address must be rejected (RFC 5321 Section 4.1.2)"
);
}
#[test]
fn forward_path_rejects_control_char_in_recipient() {
let result = ForwardPath::new("user\x7F@example.com");
assert!(
result.is_err(),
"DEL (0x7F) in recipient address must be rejected (RFC 5321 Section 4.1.2)"
);
}
#[test]
fn reverse_path_rejects_angle_bracket_in_sender() {
let result = ReversePath::new("user>@example.com");
assert!(
result.is_err(),
"'>' in sender address must be rejected (RFC 5321 Section 4.1.2)"
);
}
#[test]
fn forward_path_rejects_angle_bracket_in_recipient() {
let result = ForwardPath::new("user<name@example.com");
assert!(
result.is_err(),
"'<' in recipient address must be rejected (RFC 5321 Section 4.1.2)"
);
}
#[tokio::test]
async fn auth_plain_two_step_cancels_on_334_after_credentials() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 4096];
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0, "connection closed while waiting for AUTH");
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("\r\n") {
break;
}
}
socket.write_all(b"334\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0, "connection closed while waiting for credentials");
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("\r\n") {
break;
}
}
socket.write_all(b"334 Y2hhbGxlbmdl\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
let cancel_received = tokio::time::timeout(Duration::from_secs(3), async {
loop {
let n = socket.read(&mut buf).await.unwrap();
if n == 0 {
return false;
}
accumulated.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&accumulated);
if text.contains('*') {
return true;
}
}
})
.await;
assert!(
matches!(cancel_received, Ok(true)),
"two-step AUTH PLAIN must send '*' to cancel after \
unexpected 334 challenge (RFC 4954 Section 6): \
cancel_received={cancel_received:?}"
);
socket
.write_all(b"535 5.7.8 Authentication failed\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Auth(vec![
crate::types::AuthMechanism::Plain,
])],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let encoded_creds = SmtpConnection::build_plain_credentials(None, "user", "pass").unwrap();
let result = {
let mut inner = conn.inner.lock().await;
SmtpConnection::auth_two_step(&mut inner, "PLAIN", &encoded_creds, b"*\r\n").await
};
assert!(result.is_err(), "auth must fail on unexpected 334");
match result.unwrap_err() {
Error::Auth { response, .. } => {
assert_eq!(
response.code, 535,
"after cancelling the 334, the final error must be the \
server's 535 reply (RFC 4954 Section 6)"
);
}
other => panic!("expected Auth error, got: {other:?}"),
}
server.await.unwrap();
}
#[tokio::test]
async fn auth_xoauth2_two_step_cancels_on_334_after_credentials() {
use base64::Engine;
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 4096];
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0, "connection closed while waiting for AUTH");
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("\r\n") {
break;
}
}
socket.write_all(b"334\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0, "connection closed while waiting for token");
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("\r\n") {
break;
}
}
socket
.write_all(b"334 eyJzdGF0dXMiOiI0MDEifQ==\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
let cancel_received = tokio::time::timeout(Duration::from_secs(3), async {
loop {
let n = socket.read(&mut buf).await.unwrap();
if n == 0 {
return false;
}
accumulated.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&accumulated);
if text.contains('*') {
return true;
}
}
})
.await;
assert!(
matches!(cancel_received, Ok(true)),
"two-step AUTH XOAUTH2 must send '*' to cancel after \
334 error detail (RFC 4954 Section 6): \
cancel_received={cancel_received:?}"
);
socket
.write_all(b"535 5.7.8 Authentication failed\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Auth(vec![
crate::types::AuthMechanism::XOAuth2,
])],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let encoded_creds = base64::engine::general_purpose::STANDARD
.encode(b"user=user@example.com\x01auth=Bearer token\x01\x01");
let result = {
let mut inner = conn.inner.lock().await;
SmtpConnection::auth_two_step(&mut inner, "XOAUTH2", &encoded_creds, b"*\r\n").await
};
assert!(result.is_err(), "auth must fail on 334 error detail");
match result.unwrap_err() {
Error::Auth { response, .. } => {
assert_eq!(
response.code, 535,
"after cancelling the 334, the final error must be 535"
);
}
other => panic!("expected Auth error, got: {other:?}"),
}
server.await.unwrap();
}
#[test]
fn reverse_path_rejects_control_char_even_with_smtputf8_intent() {
let result = ReversePath::new("user\x00@example.com");
assert!(
result.is_err(),
"NUL byte in sender must be rejected even with SMTPUTF8 intent \
(RFC 6531 Section 3.3 does not permit control characters)"
);
}
#[tokio::test]
async fn auth_plain_rejects_unadvertised_mechanism() {
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![
SmtpExtension::Auth(vec![crate::types::AuthMechanism::XOAuth2]),
SmtpExtension::SaslIr,
],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.auth_plain("user", "pass", Duration::from_secs(1))
.await;
assert!(
result.is_err(),
"AUTH PLAIN must be rejected when server does not advertise \
PLAIN (RFC 4954 Section 3)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("PLAIN") || msg.contains("AUTH") || msg.contains("advertis"),
"error should mention unadvertised mechanism: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn auth_xoauth2_rejects_unadvertised_mechanism() {
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![
SmtpExtension::Auth(vec![crate::types::AuthMechanism::Plain]),
SmtpExtension::SaslIr,
],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.auth_xoauth2("user", "token", Duration::from_secs(1))
.await;
assert!(
result.is_err(),
"AUTH XOAUTH2 must be rejected when server does not advertise \
XOAUTH2 (RFC 4954 Section 3)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("XOAUTH2") || msg.contains("AUTH") || msg.contains("advertis"),
"error should mention unadvertised mechanism: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn auth_plain_rejects_when_no_auth_extension() {
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Pipelining],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.auth_plain("user", "pass", Duration::from_secs(1))
.await;
assert!(
result.is_err(),
"AUTH PLAIN must be rejected when server does not advertise \
any AUTH extension (RFC 4954 Section 3)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("PLAIN") || msg.contains("AUTH") || msg.contains("advertis"),
"error should mention unadvertised mechanism: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn auth_plain_rejects_empty_username() {
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![
SmtpExtension::Auth(vec![crate::types::AuthMechanism::Plain]),
SmtpExtension::SaslIr,
],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn.auth_plain("", "pass", Duration::from_secs(1)).await;
assert!(
result.is_err(),
"AUTH PLAIN with empty username must be rejected (RFC 4616 Section 2)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("empty") || msg.contains("username"),
"error should mention empty username: {msg}"
);
}
other => panic!("expected Protocol error for empty username, got: {other:?}"),
}
}
#[tokio::test]
async fn auth_plain_rejects_empty_password() {
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![
SmtpExtension::Auth(vec![crate::types::AuthMechanism::Plain]),
SmtpExtension::SaslIr,
],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn.auth_plain("user", "", Duration::from_secs(1)).await;
assert!(
result.is_err(),
"AUTH PLAIN with empty password must be rejected (RFC 4616 Section 2)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("empty") || msg.contains("password"),
"error should mention empty password: {msg}"
);
}
other => panic!("expected Protocol error for empty password, got: {other:?}"),
}
}
#[tokio::test]
async fn auth_plain_rejects_nul_in_username() {
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![
SmtpExtension::Auth(vec![crate::types::AuthMechanism::Plain]),
SmtpExtension::SaslIr,
],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.auth_plain("user\0evil", "pass", Duration::from_secs(1))
.await;
assert!(
result.is_err(),
"AUTH PLAIN must reject username containing NUL byte (RFC 4616 Section 2)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(msg.contains("NUL"), "error should mention NUL byte: {msg}");
}
other => panic!("expected Protocol error for NUL in username, got: {other:?}"),
}
}
#[tokio::test]
async fn auth_plain_rejects_nul_in_password() {
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![
SmtpExtension::Auth(vec![crate::types::AuthMechanism::Plain]),
SmtpExtension::SaslIr,
],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.auth_plain("user", "pass\0word", Duration::from_secs(1))
.await;
assert!(
result.is_err(),
"AUTH PLAIN must reject password containing NUL byte (RFC 4616 Section 2)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(msg.contains("NUL"), "error should mention NUL byte: {msg}");
}
other => panic!("expected Protocol error for NUL in password, got: {other:?}"),
}
}
#[tokio::test]
async fn auth_xoauth2_rejects_soh_in_username() {
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![
SmtpExtension::Auth(vec![crate::types::AuthMechanism::XOAuth2]),
SmtpExtension::SaslIr,
],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.auth_xoauth2("user\x01evil", "token", Duration::from_secs(1))
.await;
assert!(
result.is_err(),
"AUTH XOAUTH2 must reject username containing SOH byte"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("SOH") || msg.contains("0x01"),
"error should mention SOH/0x01: {msg}"
);
}
other => panic!("expected Protocol error for SOH in username, got: {other:?}"),
}
}
#[tokio::test]
async fn auth_xoauth2_rejects_soh_in_token() {
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![
SmtpExtension::Auth(vec![crate::types::AuthMechanism::XOAuth2]),
SmtpExtension::SaslIr,
],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.auth_xoauth2("user", "token\x01bad", Duration::from_secs(1))
.await;
assert!(
result.is_err(),
"AUTH XOAUTH2 must reject token containing SOH byte"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("SOH") || msg.contains("0x01"),
"error should mention SOH/0x01: {msg}"
);
}
other => panic!("expected Protocol error for SOH in token, got: {other:?}"),
}
}
#[tokio::test]
async fn auth_plain_two_step_rejects_overlong_credentials() {
let stream = mock_server(vec!["334\r\n", "501 Authentication cancelled\r\n"]).await;
let long_user = "u".repeat(7000);
let long_pass = "p".repeat(7000);
use base64::Engine;
let mut creds = Vec::with_capacity(2 + long_user.len() + long_pass.len());
creds.push(0);
creds.extend_from_slice(long_user.as_bytes());
creds.push(0);
creds.extend_from_slice(long_pass.as_bytes());
let encoded_len = base64::engine::general_purpose::STANDARD
.encode(&creds)
.len();
assert!(
encoded_len > 12288,
"test setup: base64 credentials must exceed 12288 octets, got {encoded_len}"
);
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Auth(vec![
crate::types::AuthMechanism::Plain,
])],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.auth_plain(&long_user, &long_pass, Duration::from_secs(5))
.await;
assert!(
result.is_err(),
"auth-response exceeding 12288 octets must be rejected \
(RFC 4954 Section 12)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("12288") || msg.contains("auth-response"),
"error should mention 12288-octet limit: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn auth_login_rejects_overlong_username() {
let stream = mock_server(vec![
"334 VXNlcm5hbWU6\r\n",
"501 Authentication cancelled\r\n",
])
.await;
let long_user = "u".repeat(9217);
let normal_pass = "password";
use base64::Engine;
let encoded_len = base64::engine::general_purpose::STANDARD
.encode(long_user.as_bytes())
.len();
assert!(
encoded_len > 12288,
"test setup: base64 username must exceed 12288 octets, got {encoded_len}"
);
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Auth(vec![
crate::types::AuthMechanism::Login,
])],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.auth_login(&long_user, normal_pass, Duration::from_secs(5))
.await;
assert!(
result.is_err(),
"auth_login must reject username whose base64 exceeds 12288 octets \
(RFC 4954 Section 12)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("12288") || msg.contains("auth-response"),
"error should mention 12288-octet limit: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn auth_login_rejects_overlong_password() {
let stream = mock_server(vec![
"334 VXNlcm5hbWU6\r\n",
"334 UGFzc3dvcmQ6\r\n",
"501 Authentication cancelled\r\n",
])
.await;
let normal_user = "user";
let long_pass = "p".repeat(9217);
use base64::Engine;
let encoded_len = base64::engine::general_purpose::STANDARD
.encode(long_pass.as_bytes())
.len();
assert!(
encoded_len > 12288,
"test setup: base64 password must exceed 12288 octets, got {encoded_len}"
);
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Auth(vec![
crate::types::AuthMechanism::Login,
])],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.auth_login(normal_user, &long_pass, Duration::from_secs(5))
.await;
assert!(
result.is_err(),
"auth_login must reject password whose base64 exceeds 12288 octets \
(RFC 4954 Section 12)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("12288") || msg.contains("auth-response"),
"error should mention 12288-octet limit: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn auth_login_rejects_nul_in_username() {
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Auth(vec![
crate::types::AuthMechanism::Login,
])],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.auth_login("user\0evil", "pass", Duration::from_secs(1))
.await;
assert!(
result.is_err(),
"AUTH LOGIN must reject username containing NUL byte \
(draft-murchison-sasl-login; cf. RFC 4616 Section 2)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(msg.contains("NUL"), "error should mention NUL byte: {msg}");
}
other => panic!("expected Protocol error for NUL in username, got: {other:?}"),
}
}
#[tokio::test]
async fn auth_login_rejects_nul_in_password() {
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Auth(vec![
crate::types::AuthMechanism::Login,
])],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.auth_login("user", "pass\0word", Duration::from_secs(1))
.await;
assert!(
result.is_err(),
"AUTH LOGIN must reject password containing NUL byte \
(draft-murchison-sasl-login; cf. RFC 4616 Section 2)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(msg.contains("NUL"), "error should mention NUL byte: {msg}");
}
other => panic!("expected Protocol error for NUL in password, got: {other:?}"),
}
}
#[tokio::test]
async fn validate_params_accepts_body_7bit_with_binarymime() {
use crate::types::{BodyType, MailFromParams};
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::BinaryMime, SmtpExtension::Chunking],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
body: Some(BodyType::SevenBit),
..Default::default()
};
let result = conn.validate_params_public(Some(¶ms)).await;
assert!(
result.is_ok(),
"RFC 6152 Section 3: BODY=7BIT must be accepted when server \
advertises BINARYMIME. Got error: {:?}",
result.unwrap_err()
);
}
#[tokio::test]
async fn validate_params_rejects_body_8bitmime_with_binarymime_without_8bitmime() {
use crate::types::{BodyType, MailFromParams};
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::BinaryMime, SmtpExtension::Chunking],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
body: Some(BodyType::EightBitMime),
..Default::default()
};
let result = conn.validate_params_public(Some(¶ms)).await;
assert!(
result.is_err(),
"BODY=8BITMIME must be rejected when the server omits 8BITMIME, even if it advertises BINARYMIME"
);
}
#[tokio::test]
async fn validate_params_accepts_body_7bit_without_either_extension() {
use crate::types::{BodyType, MailFromParams};
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Pipelining],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
body: Some(BodyType::SevenBit),
..Default::default()
};
let result = conn.validate_params_public(Some(¶ms)).await;
assert!(
result.is_ok(),
"BODY=7BIT without 8BITMIME should be accepted because 7-bit \
is already the default (RFC 5321 Section 4.5.2). \
Got error: {:?}",
result.err()
);
}
#[tokio::test]
async fn size_param_includes_implicit_trailing_crlf() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let message = b"Subject: Test\r\n\r\nHello";
let raw_len = message.len();
let expected_size = raw_len + 2;
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 4096];
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0);
accumulated.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&accumulated);
if text.contains("MAIL FROM:") {
let size_idx = text.find("SIZE=").expect("MAIL FROM must include SIZE=");
let after_size = &text[size_idx + 5..];
let size_str: String = after_size
.chars()
.take_while(char::is_ascii_digit)
.collect();
let declared_size: usize = size_str.parse().unwrap();
assert_eq!(
declared_size, expected_size,
"RFC 1870 Section 3: SIZE must include the implicit \
trailing CRLF. Message is {raw_len} bytes but does \
not end with CRLF, so SIZE must be {expected_size} \
(raw + 2). Got: {declared_size}"
);
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("RCPT TO:") {
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("DATA") {
break;
}
}
socket.write_all(b"354 Start\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("\r\n.\r\n") {
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Size(None)],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
message,
Duration::from_secs(5),
)
.await;
assert!(result.is_ok(), "send failed: {result:?}");
server.await.unwrap();
}
#[tokio::test]
async fn vrfy_rejects_non_ascii_argument() {
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let result = conn.vrfy("usér@example.com", Duration::from_secs(1)).await;
assert!(
result.is_err(),
"VRFY with non-ASCII argument must be rejected \
(RFC 5321 Section 4.1.1.6)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("non-ASCII") || msg.contains("printable"),
"error should mention non-ASCII or printable: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn expn_rejects_non_ascii_argument() {
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let result = conn.expn("stäff", Duration::from_secs(1)).await;
assert!(
result.is_err(),
"EXPN with non-ASCII argument must be rejected \
(RFC 5321 Section 4.1.1.7)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("non-ASCII") || msg.contains("printable"),
"error should mention non-ASCII or printable: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn vrfy_rejects_control_character_argument() {
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let result = conn
.vrfy("user\x01@example.com", Duration::from_secs(1))
.await;
assert!(
result.is_err(),
"VRFY with control character must be rejected \
(RFC 5321 Section 4.1.1.6)"
);
}
#[tokio::test]
async fn vrfy_accepts_valid_ascii_argument() {
let stream = mock_server(vec!["252 Cannot VRFY\r\n"]).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let result = conn.vrfy("user@example.com", Duration::from_secs(1)).await;
assert!(
result.is_ok(),
"VRFY with valid ASCII argument must succeed: {:?}",
result.unwrap_err()
);
}
#[tokio::test]
async fn bdat_rejects_nul_bytes_without_binarymime() {
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Chunking],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let message = b"Subject: Test\r\n\r\nHello\x00World\r\n";
let result = conn
.send_bdat(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
message,
None,
Duration::from_secs(5),
)
.await;
assert!(
result.is_err(),
"BDAT must reject NUL bytes without BINARYMIME (RFC 5321 Section 4.5.2)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("NUL") || msg.contains("0x00"),
"error should mention NUL bytes: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn lmtp_bdat_rejects_nul_bytes_without_binarymime() {
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Chunking],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Lmtp);
let message = b"Subject: Test\r\n\r\nHello\x00World\r\n";
let result = conn
.send_lmtp_bdat(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
message,
None,
Duration::from_secs(5),
)
.await;
assert!(
result.is_err(),
"LMTP BDAT must reject NUL bytes without BINARYMIME (RFC 5321 Section 4.5.2)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("NUL") || msg.contains("0x00"),
"error should mention NUL bytes: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn bdat_rejects_8bit_content_without_8bitmime() {
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Chunking],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let message = "Subject: Test\r\n\r\nHéllo\r\n".as_bytes();
let result = conn
.send_bdat(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
message,
None,
Duration::from_secs(5),
)
.await;
assert!(
result.is_err(),
"BDAT must reject 8-bit content without 8BITMIME (RFC 1652 Section 1)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("8-bit") || msg.contains("8BITMIME"),
"error should mention 8-bit content: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn lmtp_bdat_rejects_8bit_content_without_8bitmime() {
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Chunking],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Lmtp);
let message = "Subject: Test\r\n\r\nHéllo\r\n".as_bytes();
let result = conn
.send_lmtp_bdat(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
message,
None,
Duration::from_secs(5),
)
.await;
assert!(
result.is_err(),
"LMTP BDAT must reject 8-bit content without 8BITMIME (RFC 1652 Section 1)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("8-bit") || msg.contains("8BITMIME"),
"error should mention 8-bit content: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn bdat_allows_nul_bytes_with_binarymime() {
use crate::types::{BodyType, MailFromParams};
let stream = mock_interactive_server(vec![
("MAIL FROM:", "250 OK\r\n"),
("RCPT TO:", "250 OK\r\n"),
("BDAT ", "250 OK\r\n"),
])
.await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Chunking, SmtpExtension::BinaryMime],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
body: Some(BodyType::BinaryMime),
..Default::default()
};
let result = conn
.send_bdat(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"\x00\x01\x02binary\xff\xfe",
Some(¶ms),
Duration::from_secs(5),
)
.await;
assert!(
result.is_ok(),
"BDAT with BODY=BINARYMIME must allow NUL bytes: {result:?}"
);
}
#[tokio::test]
async fn bdat_allows_8bit_content_with_8bitmime() {
let stream = mock_interactive_server(vec![
("MAIL FROM:", "250 OK\r\n"),
("RCPT TO:", "250 OK\r\n"),
("BDAT ", "250 OK\r\n"),
])
.await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Chunking, SmtpExtension::EightBitMime],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let message = "Subject: Test\r\n\r\nHéllo\r\n".as_bytes();
let result = conn
.send_bdat(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
message,
None,
Duration::from_secs(5),
)
.await;
assert!(
result.is_ok(),
"BDAT with 8BITMIME must allow 8-bit content: {result:?}"
);
}
#[tokio::test]
async fn greeting_rejects_non_220_success() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
socket
.write_all(b"250 mail.example.com Service ready\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
let mut buf = [0u8; 4096];
let _ = socket.read(&mut buf).await;
socket
.write_all(b"250-mail.example.com\r\n250 OK\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(500)).await;
});
let result = SmtpConnection::connect(
"127.0.0.1",
addr.port(),
TlsMode::None,
Duration::from_secs(5),
)
.await;
let err = match result {
Ok(_) => panic!("non-220 2xx greeting must be rejected (RFC 5321 Section 3.1)"),
Err(e) => e,
};
match err {
Error::Protocol(msg) => {
assert!(
msg.contains("220"),
"error should mention expected code 220: {msg}"
);
}
other => panic!("expected Protocol error for non-220 greeting, got: {other:?}"),
}
}
#[tokio::test]
async fn connect_sends_quit_on_non_220_2xx_greeting() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
socket.write_all(b"250 Welcome\r\n").await.unwrap();
socket.flush().await.unwrap();
let mut buf = [0u8; 4096];
let mut accumulated = Vec::new();
let quit_received = tokio::time::timeout(Duration::from_secs(3), async {
loop {
let n = socket.read(&mut buf).await.unwrap();
if n == 0 {
return false;
}
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("QUIT") {
return true;
}
}
})
.await;
assert!(
matches!(quit_received, Ok(true)),
"client must send QUIT after receiving non-220 2xx greeting \
(RFC 5321 Section 4.1.1.10): quit_received={quit_received:?}"
);
socket.write_all(b"221 Bye\r\n").await.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let result = SmtpConnection::connect(
"127.0.0.1",
addr.port(),
TlsMode::None,
Duration::from_secs(5),
)
.await;
assert!(result.is_err(), "connect must fail on non-220 2xx greeting");
let err = result.expect_err("result must be Err");
match &err {
Error::Protocol(msg) => {
assert!(
msg.contains("220"),
"error should mention expected code 220: {msg}"
);
}
other => panic!("expected Protocol error for non-220 2xx greeting, got: {other:?}"),
}
server.await.unwrap();
}
#[tokio::test]
async fn vrfy_allowed_on_lmtp_connection() {
let stream = mock_server(vec!["250 User Name <user@example.com>\r\n"]).await;
let conn = SmtpConnection::from_plain(stream, ServerCapabilities::default(), Protocol::Lmtp);
let result = conn.vrfy("user@example.com", Duration::from_secs(5)).await;
assert!(
result.is_ok(),
"RFC 2033 Section 4.3: VRFY is valid in LMTP (only HELO, \
EHLO, TURN are prohibited); got error: {result:?}"
);
let resp = result.unwrap();
assert_eq!(resp.code, 250);
}
#[tokio::test]
async fn expn_allowed_on_lmtp_connection() {
let stream = mock_server(vec![
"250-Alice <alice@example.com>\r\n250 Bob <bob@example.com>\r\n",
])
.await;
let conn = SmtpConnection::from_plain(stream, ServerCapabilities::default(), Protocol::Lmtp);
let result = conn.expn("staff", Duration::from_secs(5)).await;
assert!(
result.is_ok(),
"RFC 2033 Section 4.3: EXPN is valid in LMTP (only HELO, \
EHLO, TURN are prohibited); got error: {result:?}"
);
let resp = result.unwrap();
assert_eq!(resp.code, 250);
assert_eq!(resp.lines.len(), 2);
}
#[tokio::test]
async fn vrfy_non_ascii_uses_smtputf8_when_server_supports_it() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 1024];
let n = tokio::time::timeout(Duration::from_secs(1), socket.read(&mut buf))
.await
.expect("server read timed out")
.expect("server read failed");
let received = std::str::from_utf8(&buf[..n]).expect("VRFY command must be UTF-8");
assert_eq!(received, "VRFY josé SMTPUTF8\r\n");
socket
.write_all("250 José <josé@example.com>\r\n".as_bytes())
.await
.unwrap();
socket.flush().await.unwrap();
});
let stream = TcpStream::connect(addr).await.unwrap();
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::SmtpUtf8],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn.vrfy("josé", Duration::from_secs(5)).await;
let response = result.expect("VRFY with SMTPUTF8 support should succeed");
assert_eq!(response.code, 250);
assert_eq!(response.text(), "José <josé@example.com>");
server.await.unwrap();
}
#[tokio::test]
async fn expn_non_ascii_uses_smtputf8_when_server_supports_it() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 1024];
let n = tokio::time::timeout(Duration::from_secs(1), socket.read(&mut buf))
.await
.expect("server read timed out")
.expect("server read failed");
let received = std::str::from_utf8(&buf[..n]).expect("EXPN command must be UTF-8");
assert_eq!(received, "EXPN équipe SMTPUTF8\r\n");
socket
.write_all(
"250-Équipe <equipe@example.com>\r\n250 José <josé@example.com>\r\n".as_bytes(),
)
.await
.unwrap();
socket.flush().await.unwrap();
});
let stream = TcpStream::connect(addr).await.unwrap();
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::SmtpUtf8],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn.expn("équipe", Duration::from_secs(5)).await;
let response = result.expect("EXPN with SMTPUTF8 support should succeed");
assert_eq!(response.code, 250);
assert_eq!(response.lines.len(), 2);
assert_eq!(response.lines[0], "Équipe <equipe@example.com>");
assert_eq!(response.lines[1], "José <josé@example.com>");
server.await.unwrap();
}
#[tokio::test]
async fn send_auto_declares_body_8bitmime_for_8bit_content() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 4096];
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0);
accumulated.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&accumulated);
if text.contains("MAIL FROM:") {
assert!(
text.contains("BODY=8BITMIME"),
"RFC 6152 Section 3: send() with 8-bit content must \
auto-declare BODY=8BITMIME in MAIL FROM when server \
supports 8BITMIME. Got: {text}"
);
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("RCPT TO:") {
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("DATA") {
break;
}
}
socket.write_all(b"354 Start\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("\r\n.\r\n") {
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::EightBitMime],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\n\r\nH\xc3\xa9llo\r\n",
Duration::from_secs(5),
)
.await;
assert!(
result.is_ok(),
"send() with 8-bit content should succeed: {result:?}"
);
server.await.unwrap();
}
#[tokio::test]
async fn send_does_not_declare_body_8bitmime_for_7bit_content() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 4096];
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0);
accumulated.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&accumulated);
if text.contains("MAIL FROM:") {
assert!(
!text.contains("BODY="),
"send() with 7-bit content must not add BODY= parameter. \
Got: {text}"
);
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("RCPT TO:") {
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("DATA") {
break;
}
}
socket.write_all(b"354 Start\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("\r\n.\r\n") {
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::EightBitMime],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello world\r\n",
Duration::from_secs(5),
)
.await;
assert!(result.is_ok(), "send with 7-bit content failed: {result:?}");
server.await.unwrap();
}
#[tokio::test]
async fn send_auto_declares_smtputf8_for_utf8_headers() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 4096];
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0);
accumulated.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&accumulated);
if text.contains("MAIL FROM:") {
assert!(
text.contains("SMTPUTF8"),
"RFC 6531 Section 3.4: send() with UTF-8 header octets \
must auto-declare SMTPUTF8 in MAIL FROM. Got: {text}"
);
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("RCPT TO:") {
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("DATA") {
break;
}
}
socket.write_all(b"354 Start\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("\r\n.\r\n") {
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::SmtpUtf8, SmtpExtension::EightBitMime],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Caf\xc3\xa9\r\n\r\nHello\r\n",
Duration::from_secs(5),
)
.await;
assert!(
result.is_ok(),
"send() with UTF-8 header octets should succeed with SMTPUTF8 support: {result:?}"
);
server.await.unwrap();
}
#[tokio::test]
async fn send_auto_declares_smtputf8_for_non_ascii_addresses() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 4096];
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0);
accumulated.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&accumulated);
if text.contains("MAIL FROM:") {
assert!(
text.contains("SMTPUTF8"),
"RFC 6531 Section 3.4: send() with non-ASCII envelope \
addresses must auto-declare SMTPUTF8 in MAIL FROM. \
Got: {text}"
);
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("RCPT TO:") {
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("DATA") {
break;
}
}
socket.write_all(b"354 Start\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("\r\n.\r\n") {
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::SmtpUtf8, SmtpExtension::EightBitMime],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("ünïcödé@example.com").unwrap(),
&[ForwardPath::new("réçîpïënt@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello\r\n",
Duration::from_secs(5),
)
.await;
assert!(
result.is_ok(),
"send() with non-ASCII envelope addresses should succeed with SMTPUTF8 support: {result:?}"
);
server.await.unwrap();
}
#[tokio::test]
async fn ehlo_521_does_not_fall_back_to_helo() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
socket
.write_all(b"220 no-mail.example.com ESMTP\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
let mut buf = [0u8; 4096];
let n = socket.read(&mut buf).await.unwrap();
let cmd = String::from_utf8_lossy(&buf[..n]);
assert!(cmd.starts_with("EHLO "), "expected EHLO, got: {cmd}");
socket
.write_all(b"521 I do not accept mail\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
let helo_sent = tokio::time::timeout(Duration::from_secs(1), async {
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
if n == 0 {
return false;
}
accumulated.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&accumulated);
if text.contains("HELO ") {
return true;
}
}
})
.await;
assert!(
!matches!(helo_sent, Ok(true)),
"client must NOT fall back to HELO after 521 EHLO response \
(RFC 5321 Section 4.1.4 / RFC 7504): 521 means 'does not \
accept mail', not 'does not support ESMTP'"
);
tokio::time::sleep(Duration::from_millis(100)).await;
});
let result = SmtpConnection::connect(
"127.0.0.1",
addr.port(),
TlsMode::None,
Duration::from_secs(5),
)
.await;
match result {
Err(Error::Permanent { code, .. }) => {
assert_eq!(code, 521, "error must preserve the 521 code");
}
Err(other) => panic!("expected Permanent error with code 521, got: {other:?}"),
Ok(_) => panic!("connect must fail on 521 EHLO response, not fall back to HELO"),
}
server.await.unwrap();
}
#[tokio::test]
async fn ehlo_550_does_not_fall_back_to_helo() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
socket
.write_all(b"220 policy.example.com ESMTP\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
let mut buf = [0u8; 4096];
let n = socket.read(&mut buf).await.unwrap();
let cmd = String::from_utf8_lossy(&buf[..n]);
assert!(cmd.starts_with("EHLO "), "expected EHLO, got: {cmd}");
socket.write_all(b"550 Access denied\r\n").await.unwrap();
socket.flush().await.unwrap();
let helo_sent = tokio::time::timeout(Duration::from_secs(1), async {
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
if n == 0 {
return false;
}
accumulated.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&accumulated);
if text.contains("HELO ") {
return true;
}
}
})
.await;
assert!(
!matches!(helo_sent, Ok(true)),
"client must NOT fall back to HELO after 550 EHLO response \
(RFC 5321 Section 4.3.2): 550 is a policy rejection, not \
a lack of ESMTP support"
);
tokio::time::sleep(Duration::from_millis(100)).await;
});
let result = SmtpConnection::connect(
"127.0.0.1",
addr.port(),
TlsMode::None,
Duration::from_secs(5),
)
.await;
match result {
Err(Error::Permanent { code, .. }) => {
assert_eq!(code, 550, "error must preserve the 550 code");
}
Err(other) => panic!("expected Permanent error with code 550, got: {other:?}"),
Ok(_) => panic!("connect must fail on 550 EHLO response, not fall back to HELO"),
}
server.await.unwrap();
}
#[tokio::test]
async fn helo_fallback_when_ehlo_rejected_with_501() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
socket
.write_all(b"220 legacy.server.com SMTP\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
let mut buf = [0u8; 4096];
let n = socket.read(&mut buf).await.unwrap();
let cmd = String::from_utf8_lossy(&buf[..n]);
assert!(
cmd.starts_with("EHLO "),
"client should try EHLO first, got: {cmd}"
);
socket
.write_all(b"501 Syntax error in parameters\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
let n = socket.read(&mut buf).await.unwrap();
let cmd = String::from_utf8_lossy(&buf[..n]);
assert!(
cmd.starts_with("HELO "),
"client must fall back to HELO after 501 EHLO rejection: {cmd}"
);
socket
.write_all(b"250 legacy.server.com\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let result = SmtpConnection::connect(
"127.0.0.1",
addr.port(),
TlsMode::None,
Duration::from_secs(5),
)
.await;
match result {
Ok(_) => {}
Err(e) => panic!(
"connection must succeed with HELO fallback on 501 EHLO rejection \
(RFC 5321 Section 4.1.4): {e}"
),
}
server.await.unwrap();
}
#[test]
fn forward_path_rejects_empty_address() {
let result = ForwardPath::new("");
assert!(
result.is_err(),
"ForwardPath must reject empty address (RFC 5321 Section 4.1.1.3)"
);
}
#[test]
fn reverse_path_accepts_obsolete_source_routes() {
let rp = ReversePath::new("@old.example,@relay.example:sender@example.com");
assert!(
rp.is_ok(),
"ReversePath must accept obsolete source-routed paths \
per RFC 5321 Section 4.1.2: {rp:?}"
);
let fp = ForwardPath::new("@old.example,@relay.example:rcpt@example.com");
assert!(
fp.is_ok(),
"ForwardPath must accept obsolete source-routed paths \
per RFC 5321 Section 4.1.2: {fp:?}"
);
}
#[test]
fn reverse_path_counts_obsolete_source_route_toward_path_limit() {
let route_domain = format!("{}.{}.{}", "u".repeat(63), "v".repeat(63), "w".repeat(62));
let sender = format!("@{route_domain},@{route_domain}:user@example.com");
assert!(
sender.len() + 2 > SmtpConnection::SMTP_MAX_PATH_LENGTH,
"test precondition: full source-routed reverse-path must exceed the RFC 5321 256-octet limit"
);
assert!(
"user@example.com".len() + 2 < SmtpConnection::SMTP_MAX_PATH_LENGTH,
"test precondition: bare mailbox should remain within the path limit so the source route is what triggers the rejection"
);
let result = ReversePath::new(&sender);
assert!(
result.is_err(),
"source-routed reverse-path must count toward the 256-octet path limit \
(RFC 5321 Section 4.5.3.1.3)"
);
}
#[tokio::test]
async fn vrfy_rejects_empty_argument() {
let stream = mock_server(vec![]).await;
let caps = smtp_caps_no_pipelining();
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn.vrfy("", Duration::from_secs(5)).await;
let err = result.expect_err("vrfy must reject empty argument (RFC 5321 Section 4.1.1.6)");
assert!(
matches!(&err, Error::Protocol(msg) if msg.to_lowercase().contains("empty")),
"expected Error::Protocol mentioning empty argument, got: {err:?}"
);
}
#[tokio::test]
async fn expn_rejects_empty_argument() {
let stream = mock_server(vec![]).await;
let caps = smtp_caps_no_pipelining();
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn.expn("", Duration::from_secs(5)).await;
let err = result.expect_err("expn must reject empty argument (RFC 5321 Section 4.1.1.7)");
assert!(
matches!(&err, Error::Protocol(msg) if msg.to_lowercase().contains("empty")),
"expected Error::Protocol mentioning empty argument, got: {err:?}"
);
}
#[tokio::test]
async fn debug_impl_shows_connection_metadata() {
let stream = mock_server(vec!["220 test ready\r\n"]).await;
let caps = ServerCapabilities {
greeting_name: "mail.example.com".into(),
extensions: Vec::new(),
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let debug_str = format!("{conn:?}");
assert!(
debug_str.contains("SmtpConnection"),
"Debug output must contain type name, got: {debug_str}"
);
assert!(
debug_str.contains("ehlo_domain"),
"Debug output must contain ehlo_domain field, got: {debug_str}"
);
assert!(
debug_str.contains("protocol"),
"Debug output must contain protocol field, got: {debug_str}"
);
assert!(
debug_str.contains("transport"),
"Debug output must contain transport field, got: {debug_str}"
);
}
#[test]
fn message_size_crlf_adjustment() {
let with_crlf = b"Subject: test\r\n\r\nBody\r\n";
let without_crlf = b"Subject: test\r\n\r\nBody";
let size_with = if with_crlf.ends_with(b"\r\n") {
with_crlf.len()
} else {
with_crlf.len() + 2
};
assert_eq!(size_with, with_crlf.len());
let size_without = if without_crlf.ends_with(b"\r\n") {
without_crlf.len()
} else {
without_crlf.len() + 2
};
assert_eq!(size_without, without_crlf.len() + 2);
}
#[test]
fn validate_no_nul_rejects_nul_accepts_clean() {
assert!(SmtpConnection::validate_no_nul_bytes(b"clean data").is_ok());
assert!(SmtpConnection::validate_no_nul_bytes(b"has \x00 nul").is_err());
assert!(SmtpConnection::validate_no_nul_bytes(b"").is_ok());
}
#[test]
fn validate_7bit_rejects_high_bytes() {
assert!(SmtpConnection::validate_7bit_content(b"all ascii").is_ok());
assert!(SmtpConnection::validate_7bit_content(b"has \x80 high").is_err());
assert!(SmtpConnection::validate_7bit_content(b"\x7F is ok").is_ok());
assert!(SmtpConnection::validate_7bit_content(b"").is_ok());
}
#[test]
fn message_8bit_detection() {
assert!(!SmtpConnection::message_contains_8bit(b"pure ascii"));
assert!(SmtpConnection::message_contains_8bit(b"caf\xC3\xA9"));
assert!(!SmtpConnection::message_contains_8bit(b""));
}
#[test]
fn message_headers_require_smtputf8_detects_utf8_header_octets() {
let raw = b"Subject: Test\r\nReturn-Path: <us\xC3\xADr@example.com>\r\n\r\nbody";
assert!(SmtpConnection::message_headers_require_smtputf8(raw));
}
#[test]
fn message_headers_require_smtputf8_ignores_utf8_body_octets() {
let raw = b"Subject: Test\r\n\r\ncaf\xC3\xA9";
assert!(
!SmtpConnection::message_headers_require_smtputf8(raw),
"UTF-8 confined to the body must not force SMTPUTF8"
);
}
#[test]
fn message_headers_require_smtputf8_ignores_messages_without_header_block() {
let raw = b"\x00\xFFbinary payload";
assert!(
!SmtpConnection::message_headers_require_smtputf8(raw),
"messages without a header block must not be treated as \
internationalized headers"
);
}
#[test]
fn message_headers_require_smtputf8_ignores_body_only_colon_line() {
let raw = "Note: café".as_bytes();
assert!(
!SmtpConnection::message_headers_require_smtputf8(raw),
"body-only payloads must not be reclassified as SMTPUTF8 headers"
);
}
#[tokio::test]
async fn send_lmtp_bdat_rejects_missing_chunking() {
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, ServerCapabilities::default(), Protocol::Lmtp);
let result = conn
.send_lmtp_bdat(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Hello",
None,
Duration::from_secs(1),
)
.await;
assert!(
result.is_err(),
"send_lmtp_bdat must reject when server lacks CHUNKING"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(msg.contains("CHUNKING"), "expected CHUNKING mention: {msg}");
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn send_lmtp_rejects_message_exceeding_size_limit() {
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Size(Some(50))],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Lmtp);
let big_message = b"Subject: test\r\n\r\nXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX\r\n";
let result = conn
.send_lmtp(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
big_message,
None,
Duration::from_secs(1),
)
.await;
assert!(
result.is_err(),
"send_lmtp must reject messages exceeding server SIZE limit"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("SIZE") || msg.contains("size") || msg.contains("exceeds"),
"error should mention SIZE limit: {msg}"
);
}
other => panic!("expected Protocol error for SIZE limit, got: {other:?}"),
}
}
#[tokio::test]
async fn send_lmtp_bdat_rejects_message_exceeding_size_limit() {
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Chunking, SmtpExtension::Size(Some(50))],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Lmtp);
let big_message = vec![b'X'; 100];
let result = conn
.send_lmtp_bdat(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
&big_message,
None,
Duration::from_secs(1),
)
.await;
assert!(
result.is_err(),
"send_lmtp_bdat must reject messages exceeding server SIZE limit"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("SIZE") || msg.contains("size") || msg.contains("exceeds"),
"error should mention SIZE limit: {msg}"
);
}
other => panic!("expected Protocol error for SIZE limit, got: {other:?}"),
}
}
#[tokio::test]
async fn auth_xoauth2_rejects_when_no_auth_extension() {
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Pipelining],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.auth_xoauth2("user", "token", Duration::from_secs(1))
.await;
assert!(
result.is_err(),
"AUTH XOAUTH2 must be rejected when server does not advertise \
any AUTH extension (RFC 4954 Section 3)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("XOAUTH2") || msg.contains("AUTH") || msg.contains("advertis"),
"error should mention unadvertised mechanism: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn auth_xoauth2_uses_initial_response_without_sasl_ir() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 4096];
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0, "connection closed while waiting for AUTH");
accumulated.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&accumulated);
if text.contains("\r\n") {
assert!(
text.starts_with("AUTH XOAUTH2 ") && !text.starts_with("AUTH XOAUTH2\r\n"),
"RFC 4954 Section 4: AUTH XOAUTH2 should include an \
initial response even without SASL-IR. Got: {text:?}"
);
break;
}
}
socket
.write_all(b"235 2.7.0 Authentication successful\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Auth(vec![
crate::types::AuthMechanism::XOAuth2,
])],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.auth_xoauth2("user@example.com", "ya29.token", Duration::from_secs(5))
.await;
assert!(result.is_ok(), "auth_xoauth2 should succeed: {result:?}");
server.await.unwrap();
}
#[tokio::test]
async fn auth_xoauth2_uses_initial_response_with_sasl_ir() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 4096];
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0, "connection closed while waiting for AUTH");
accumulated.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&accumulated);
if text.contains("\r\n") {
assert!(
text.starts_with("AUTH XOAUTH2 ") && !text.starts_with("AUTH XOAUTH2\r\n"),
"AUTH XOAUTH2 must include the initial response \
on the command line (RFC 4954 Section 4). Got: {text:?}"
);
break;
}
}
socket
.write_all(b"235 2.7.0 Authentication successful\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![
SmtpExtension::Auth(vec![crate::types::AuthMechanism::XOAuth2]),
SmtpExtension::SaslIr,
],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.auth_xoauth2("user@example.com", "ya29.token", Duration::from_secs(5))
.await;
assert!(result.is_ok(), "auth_xoauth2 should succeed: {result:?}");
server.await.unwrap();
}
#[tokio::test]
async fn auth_xoauth2_two_step_rejects_overlong_token() {
let stream = mock_server(vec!["334\r\n", "501 Authentication cancelled\r\n"]).await;
let long_token = "t".repeat(9200);
let user = "u@x.com";
let sasl_string = format!("user={user}\x01auth=Bearer {long_token}\x01\x01");
let encoded_len = base64::engine::general_purpose::STANDARD
.encode(sasl_string.as_bytes())
.len();
assert!(
encoded_len > 12288,
"test setup: base64 XOAUTH2 SASL string must exceed 12288 octets, got {encoded_len}"
);
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Auth(vec![
crate::types::AuthMechanism::XOAuth2,
])],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.auth_xoauth2(user, &long_token, Duration::from_secs(5))
.await;
assert!(
result.is_err(),
"auth-response exceeding 12288 octets must be rejected \
(RFC 4954 Section 12)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("12288") || msg.contains("auth-response"),
"error should mention 12288-octet limit: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[test]
fn bdat_rejects_bare_cr_without_binarymime() {
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Chunking, SmtpExtension::EightBitMime],
};
let message = b"Subject: Test\r\n\r\nHello\rWorld\r\n";
let result = SmtpConnection::validate_bdat_prerequisites(&caps, message, None, false);
assert!(
result.is_err(),
"BDAT without BINARYMIME must reject bare CR \
(RFC 3030 Section 3 / RFC 5321 Section 2.3.8)"
);
let msg = format!("{}", result.unwrap_err());
assert!(
msg.contains("bare CR"),
"error should mention bare CR: {msg}"
);
}
#[test]
fn bdat_rejects_bare_lf_without_binarymime() {
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Chunking, SmtpExtension::EightBitMime],
};
let message = b"Subject: Test\r\n\r\nHello\nWorld\r\n";
let result = SmtpConnection::validate_bdat_prerequisites(&caps, message, None, false);
assert!(
result.is_err(),
"BDAT without BINARYMIME must reject bare LF \
(RFC 3030 Section 3 / RFC 5321 Section 2.3.8)"
);
let msg = format!("{}", result.unwrap_err());
assert!(
msg.contains("bare LF"),
"error should mention bare LF: {msg}"
);
}
#[test]
fn bdat_allows_binary_octets_with_binarymime_non_text_content() {
use crate::types::{BodyType, MailFromParams};
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![
SmtpExtension::Chunking,
SmtpExtension::BinaryMime,
SmtpExtension::EightBitMime,
],
};
let message =
b"MIME-Version: 1.0\r\nContent-Type: application/octet-stream\r\n\r\nbinary\rcontent\nwith\x00nul\r\n";
let params = MailFromParams {
body: Some(BodyType::BinaryMime),
..Default::default()
};
let result = SmtpConnection::validate_bdat_prerequisites(&caps, message, Some(¶ms), false);
assert!(
result.is_ok(),
"BDAT with BINARYMIME must allow binary octets for non-text MIME \
content (RFC 3030 Section 2): {result:?}"
);
}
#[test]
fn bdat_binarymime_rejects_non_canonical_text_lines() {
use crate::types::{BodyType, MailFromParams};
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![
SmtpExtension::Chunking,
SmtpExtension::BinaryMime,
SmtpExtension::EightBitMime,
],
};
let message =
b"MIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nhello\nworld\r\n";
let params = MailFromParams {
body: Some(BodyType::BinaryMime),
..Default::default()
};
let result = SmtpConnection::validate_bdat_prerequisites(&caps, message, Some(¶ms), false);
assert!(
result.is_err(),
"BINARYMIME must still reject bare LF in text/* content \
(RFC 3030 Section 2)"
);
}
#[test]
fn bdat_binarymime_rejects_non_canonical_multipart_lines() {
use crate::types::{BodyType, MailFromParams};
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![
SmtpExtension::Chunking,
SmtpExtension::BinaryMime,
SmtpExtension::EightBitMime,
],
};
let message = b"MIME-Version: 1.0\r\n\
Content-Type: multipart/mixed; boundary=\"b\"\r\n\
\r\n\
--b\n\
Content-Type: text/plain\r\n\
\r\n\
hello\r\n\
--b--\r\n";
let params = MailFromParams {
body: Some(BodyType::BinaryMime),
..Default::default()
};
let result = SmtpConnection::validate_bdat_prerequisites(&caps, message, Some(¶ms), false);
assert!(
result.is_err(),
"BINARYMIME must still reject non-canonical multipart lines \
(RFC 3030 Section 2)"
);
}
#[test]
fn bdat_binarymime_rejects_non_canonical_message_rfc822_lines() {
use crate::types::{BodyType, MailFromParams};
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![
SmtpExtension::Chunking,
SmtpExtension::BinaryMime,
SmtpExtension::EightBitMime,
],
};
let message = b"MIME-Version: 1.0\r\n\
Content-Type: message/rfc822\r\n\
\r\n\
Subject: nested\n\
\r\n\
body\r\n";
let params = MailFromParams {
body: Some(BodyType::BinaryMime),
..Default::default()
};
let result = SmtpConnection::validate_bdat_prerequisites(&caps, message, Some(¶ms), false);
assert!(
result.is_err(),
"BINARYMIME must still reject non-canonical message/rfc822 lines \
(RFC 3030 Section 2)"
);
}
#[test]
fn bdat_rejects_long_line_without_binarymime() {
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Chunking, SmtpExtension::EightBitMime],
};
let mut message = Vec::new();
message.extend_from_slice(b"Subject: Test\r\n\r\n");
message.extend(std::iter::repeat(b'X').take(999));
message.extend_from_slice(b"\r\n");
let result = SmtpConnection::validate_bdat_prerequisites(&caps, &message, None, false);
assert!(
result.is_err(),
"BDAT without BINARYMIME must reject lines exceeding 1000 octets \
(RFC 3030 Section 3 / RFC 5321 Section 4.5.3.1.6)"
);
let msg = format!("{}", result.unwrap_err());
assert!(
msg.contains("1000") || msg.contains("line"),
"error should mention line length limit: {msg}"
);
}
#[test]
fn bdat_allows_long_line_with_binarymime_non_text_content() {
use crate::types::{BodyType, MailFromParams};
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![
SmtpExtension::Chunking,
SmtpExtension::BinaryMime,
SmtpExtension::EightBitMime,
],
};
let mut message = Vec::new();
message
.extend_from_slice(b"MIME-Version: 1.0\r\nContent-Type: application/octet-stream\r\n\r\n");
message.extend(std::iter::repeat(b'X').take(2000));
let params = MailFromParams {
body: Some(BodyType::BinaryMime),
..Default::default()
};
let result = SmtpConnection::validate_bdat_prerequisites(&caps, &message, Some(¶ms), false);
assert!(
result.is_ok(),
"BDAT with BINARYMIME must allow long non-text octet runs \
(RFC 3030 Section 2): {result:?}"
);
}
#[test]
fn bdat_valid_crlf_content_passes_without_binarymime() {
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Chunking, SmtpExtension::EightBitMime],
};
let message = b"Subject: Test\r\n\r\nHello World\r\n";
let result = SmtpConnection::validate_bdat_prerequisites(&caps, message, None, false);
assert!(
result.is_ok(),
"well-formed BDAT content must pass validation: {result:?}"
);
}
#[tokio::test]
async fn validate_params_rejects_smtputf8_without_8bitmime() {
use crate::types::MailFromParams;
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::SmtpUtf8],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
smtputf8: true,
..Default::default()
};
let result = conn.validate_params_public(Some(¶ms)).await;
assert!(
result.is_err(),
"RFC 6531 Section 3.4: SMTPUTF8 must be rejected when the server \
does not advertise 8BITMIME"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("8BITMIME"),
"error must mention 8BITMIME requirement: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn validate_params_accepts_smtputf8_with_8bitmime() {
use crate::types::MailFromParams;
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::SmtpUtf8, SmtpExtension::EightBitMime],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
smtputf8: true,
..Default::default()
};
let result = conn.validate_params_public(Some(¶ms)).await;
assert!(
result.is_ok(),
"RFC 6531 Section 3.2: SMTPUTF8 with 8BITMIME must be accepted. \
Got error: {:?}",
result.unwrap_err()
);
}
#[tokio::test]
async fn validate_params_rejects_smtputf8_with_binarymime_without_8bitmime() {
use crate::types::MailFromParams;
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![
SmtpExtension::SmtpUtf8,
SmtpExtension::BinaryMime,
SmtpExtension::Chunking,
],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
smtputf8: true,
..Default::default()
};
let result = conn.validate_params_public(Some(¶ms)).await;
assert!(
result.is_err(),
"SMTPUTF8 must be rejected when the server omits 8BITMIME, even if it advertises BINARYMIME"
);
}
#[tokio::test]
async fn validate_params_rejects_smtputf8_with_body_7bit() {
use crate::types::{BodyType, MailFromParams};
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::SmtpUtf8, SmtpExtension::EightBitMime],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
smtputf8: true,
body: Some(BodyType::SevenBit),
..Default::default()
};
let result = conn.validate_params_public(Some(¶ms)).await;
assert!(
result.is_err(),
"RFC 6531 Section 3.4: SMTPUTF8 + BODY=7BIT must be rejected; \
SMTPUTF8 requires BODY=8BITMIME"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("SMTPUTF8") && msg.contains("7BIT"),
"error must mention SMTPUTF8 and 7BIT: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn validate_params_accepts_smtputf8_with_body_8bitmime() {
use crate::types::{BodyType, MailFromParams};
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::SmtpUtf8, SmtpExtension::EightBitMime],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
smtputf8: true,
body: Some(BodyType::EightBitMime),
..Default::default()
};
let result = conn.validate_params_public(Some(¶ms)).await;
assert!(
result.is_ok(),
"RFC 6531 Section 3.6: SMTPUTF8 + BODY=8BITMIME is valid; \
got error: {result:?}"
);
}
#[tokio::test]
async fn validate_params_accepts_smtputf8_with_body_binarymime() {
use crate::types::{BodyType, MailFromParams};
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![
SmtpExtension::SmtpUtf8,
SmtpExtension::BinaryMime,
SmtpExtension::Chunking,
SmtpExtension::EightBitMime,
],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
smtputf8: true,
body: Some(BodyType::BinaryMime),
..Default::default()
};
let result = conn.validate_params_public(Some(¶ms)).await;
assert!(
result.is_ok(),
"RFC 6531 Section 3.6: SMTPUTF8 + BODY=BINARYMIME is valid \
when the server advertises BINARYMIME; got error: {result:?}"
);
}
#[tokio::test]
async fn validate_params_rejects_requiretls_without_capability() {
use crate::types::MailFromParams;
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::EightBitMime],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
requiretls: true,
..Default::default()
};
let result = conn.validate_params_public(Some(¶ms)).await;
assert!(
result.is_err(),
"REQUIRETLS without server support must be rejected (RFC 8689 / RFC 5321 Section 2.2.1)"
);
}
#[tokio::test]
async fn validate_params_rejects_requiretls_on_plain_connection() {
use crate::types::MailFromParams;
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::RequireTls],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
requiretls: true,
..Default::default()
};
let result = conn.validate_params_public(Some(¶ms)).await;
assert!(
result.is_err(),
"REQUIRETLS on a plain (non-TLS) connection must be rejected (RFC 8689 Section 3)"
);
}
#[tokio::test]
async fn validate_params_rejects_dsn_ret_without_capability() {
use crate::types::{DsnRet, MailFromParams};
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::EightBitMime],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
ret: Some(DsnRet::Full),
..Default::default()
};
let result = conn.validate_params_public(Some(¶ms)).await;
assert!(
result.is_err(),
"DSN RET without server DSN support must be rejected (RFC 3461 / RFC 5321 Section 2.2.1)"
);
}
#[tokio::test]
async fn validate_params_rejects_envid_without_capability() {
use crate::types::MailFromParams;
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::EightBitMime],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
envid: Some(crate::types::EnvidValue::new("test-id").unwrap()),
..Default::default()
};
let result = conn.validate_params_public(Some(¶ms)).await;
assert!(
result.is_err(),
"DSN ENVID without server DSN support must be rejected (RFC 3461 / RFC 5321 Section 2.2.1)"
);
}
#[tokio::test]
async fn validate_params_rejects_auth_without_capability() {
use crate::types::{MailFromParams, Mailbox, SmtpAuthParam};
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::EightBitMime],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
auth: Some(SmtpAuthParam::Mailbox(
Mailbox::new("submitter@example.com").unwrap(),
)),
..Default::default()
};
let result = conn.validate_params_public(Some(¶ms)).await;
assert!(
result.is_err(),
"MAIL FROM AUTH=... must be rejected unless the server advertises AUTH (RFC 4954 Section 5)"
);
}
#[tokio::test]
async fn validate_params_rejects_empty_auth_mailbox() {
assert!(
crate::types::Mailbox::new("").is_err(),
"MAIL FROM AUTH= with an empty mailbox must be rejected (RFC 4954 Section 8)"
);
}
#[tokio::test]
async fn validate_params_rejects_holdfor_without_capability() {
use crate::types::MailFromParams;
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::EightBitMime],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
hold_for: Some(3600),
..Default::default()
};
let result = conn.validate_params_public(Some(¶ms)).await;
assert!(
result.is_err(),
"HOLDFOR without server FUTURERELEASE support must be rejected (RFC 4865 / RFC 5321 Section 2.2.1)"
);
}
#[tokio::test]
async fn validate_params_rejects_holduntil_without_capability() {
use crate::types::MailFromParams;
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::EightBitMime],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
hold_until: Some("2026-01-01T00:00:00Z".into()),
..Default::default()
};
let result = conn.validate_params_public(Some(¶ms)).await;
assert!(
result.is_err(),
"HOLDUNTIL without server FUTURERELEASE support must be rejected (RFC 4865 / RFC 5321 Section 2.2.1)"
);
}
#[tokio::test]
async fn validate_params_rejects_holdfor_and_holduntil_both_set() {
use crate::types::MailFromParams;
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![
SmtpExtension::EightBitMime,
SmtpExtension::FutureRelease {
max_interval: None,
max_datetime: None,
},
],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
hold_for: Some(3600),
hold_until: Some("2026-01-01T00:00:00Z".into()),
..Default::default()
};
let result = conn.validate_params_public(Some(¶ms)).await;
assert!(
result.is_err(),
"HOLDFOR and HOLDUNTIL together must be rejected (RFC 4865 Section 5)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("HOLDFOR") && msg.contains("HOLDUNTIL"),
"error must mention both HOLDFOR and HOLDUNTIL: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn validate_params_rejects_deliverby_without_capability() {
use crate::types::{DeliverBy, DeliverByMode, MailFromParams};
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::EightBitMime],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
deliver_by: Some(DeliverBy {
seconds: 3600,
mode: DeliverByMode::Return,
trace: false,
}),
..Default::default()
};
let result = conn.validate_params_public(Some(¶ms)).await;
assert!(
result.is_err(),
"DELIVERBY without server DELIVERBY support must be rejected (RFC 2852 / RFC 5321 Section 2.2.1)"
);
}
#[tokio::test]
async fn validate_params_rejects_mt_priority_without_capability() {
use crate::types::MailFromParams;
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::EightBitMime],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
mt_priority: Some(3),
..Default::default()
};
let result = conn.validate_params_public(Some(¶ms)).await;
assert!(
result.is_err(),
"MT-PRIORITY without server support must be rejected (RFC 6758 / RFC 5321 Section 2.2.1)"
);
}
#[tokio::test]
async fn validate_params_rejects_mt_priority_out_of_range() {
use crate::types::MailFromParams;
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::MtPriority],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
mt_priority: Some(-10),
..Default::default()
};
let result = conn.validate_params_public(Some(¶ms)).await;
assert!(
result.is_err(),
"MT-PRIORITY value -10 is below the valid range and must be rejected (RFC 6758 Section 4)"
);
let stream2 = mock_server(vec![]).await;
let caps2 = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::MtPriority],
};
let conn2 = SmtpConnection::from_plain(stream2, caps2, Protocol::Smtp);
let params2 = MailFromParams {
mt_priority: Some(10),
..Default::default()
};
let result2 = conn2.validate_params_public(Some(¶ms2)).await;
assert!(
result2.is_err(),
"MT-PRIORITY value 10 is above the valid range and must be rejected (RFC 6758 Section 4)"
);
let stream3 = mock_server(vec![]).await;
let caps3 = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::MtPriority],
};
let conn3 = SmtpConnection::from_plain(stream3, caps3, Protocol::Smtp);
let params3 = MailFromParams {
mt_priority: Some(-9),
..Default::default()
};
assert!(
conn3.validate_params_public(Some(¶ms3)).await.is_ok(),
"MT-PRIORITY value -9 is the lower bound and must be accepted (RFC 6758 Section 4)"
);
let stream4 = mock_server(vec![]).await;
let caps4 = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::MtPriority],
};
let conn4 = SmtpConnection::from_plain(stream4, caps4, Protocol::Smtp);
let params4 = MailFromParams {
mt_priority: Some(9),
..Default::default()
};
assert!(
conn4.validate_params_public(Some(¶ms4)).await.is_ok(),
"MT-PRIORITY value 9 is the upper bound and must be accepted (RFC 6758 Section 4)"
);
}
#[tokio::test]
async fn validate_params_accepts_full_mt_priority_range() {
use crate::types::MailFromParams;
for value in [-9, -6, 0, 5, 9] {
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::MtPriority],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
mt_priority: Some(value),
..Default::default()
};
assert!(
conn.validate_params_public(Some(¶ms)).await.is_ok(),
"MT-PRIORITY value {value} is within -9..=9 and must be accepted (RFC 6758 Section 4)"
);
}
}
#[tokio::test]
async fn validate_params_rejects_mt_priority_outside_full_range() {
use crate::types::MailFromParams;
for value in [-10, 10] {
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::MtPriority],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
mt_priority: Some(value),
..Default::default()
};
let result = conn.validate_params_public(Some(¶ms)).await;
assert!(
result.is_err(),
"MT-PRIORITY value {value} is outside -9..=9 and must be rejected (RFC 6758 Section 4)"
);
}
}
#[tokio::test]
async fn smtputf8_auto_adds_body_8bitmime_for_7bit_message() {
use crate::types::MailFromParams;
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 4096];
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0);
accumulated.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&accumulated);
if text.contains("MAIL FROM:") {
assert!(
text.contains("BODY=8BITMIME"),
"RFC 6531 Section 3.4: MAIL FROM with SMTPUTF8 must \
include BODY=8BITMIME even for 7-bit message. Got: {text}"
);
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("RCPT TO:") {
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("DATA") {
break;
}
}
socket.write_all(b"354 Start\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("\r\n.\r\n") {
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::SmtpUtf8, SmtpExtension::EightBitMime],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
smtputf8: true,
..Default::default()
};
let result = conn
.send_with_params(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello\r\n",
Some(¶ms),
Duration::from_secs(5),
)
.await;
assert!(result.is_ok(), "send failed: {result:?}");
server.await.unwrap();
}
#[tokio::test]
async fn auth_rejects_second_auth_after_success() {
let stream = mock_interactive_server(vec![
("AUTH", "235 2.7.0 Authentication successful\r\n"),
])
.await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![
SmtpExtension::Auth(vec![crate::types::AuthMechanism::Plain]),
SmtpExtension::SaslIr,
],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.auth_plain("user", "pass", Duration::from_secs(5))
.await;
assert!(result.is_ok(), "first auth should succeed: {result:?}");
let result = conn
.auth_plain("user2", "pass2", Duration::from_secs(5))
.await;
assert!(
result.is_err(),
"second auth must be rejected (RFC 4954 Section 3)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(msg.contains("RFC 4954"), "error must cite RFC 4954: {msg}");
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn auth_xoauth2_rejects_second_auth_after_success() {
let stream =
mock_interactive_server(vec![("AUTH", "235 2.7.0 Authentication successful\r\n")]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![
SmtpExtension::Auth(vec![
crate::types::AuthMechanism::Plain,
crate::types::AuthMechanism::XOAuth2,
]),
SmtpExtension::SaslIr,
],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.auth_plain("user", "pass", Duration::from_secs(5))
.await;
assert!(result.is_ok(), "first auth should succeed: {result:?}");
let result = conn
.auth_xoauth2("user", "token", Duration::from_secs(5))
.await;
assert!(
result.is_err(),
"second auth (different mechanism) must also be rejected \
(RFC 4954 Section 3)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(msg.contains("RFC 4954"), "error must cite RFC 4954: {msg}");
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn handle_auth_response_non_235_success_returns_protocol_error() {
let stream = mock_server(vec!["250 2.0.0 OK\r\n"]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let mut inner = conn.inner.lock().await;
let result = SmtpConnection::handle_auth_response(&mut inner, b"*\r\n").await;
assert!(
result.is_err(),
"non-235 success must be an error: {result:?}"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(msg.contains("RFC 4954"), "error must cite RFC 4954: {msg}");
assert!(
msg.contains("250"),
"error must include the offending code: {msg}"
);
}
other => panic!("expected Protocol error for non-235 2xx, got: {other:?}"),
}
let authenticated = inner.authenticated;
drop(inner);
assert!(
!authenticated,
"authenticated must remain false for non-235 success \
(RFC 4954 Section 6)"
);
}
#[tokio::test]
async fn handle_auth_response_sets_authenticated_on_235() {
let stream = mock_server(vec!["235 2.7.0 Authentication successful\r\n"]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let mut inner = conn.inner.lock().await;
assert!(
!inner.authenticated,
"test setup: authenticated must start false"
);
let result = SmtpConnection::handle_auth_response(&mut inner, b"*\r\n").await;
assert!(
result.is_ok(),
"handle_auth_response should succeed on 235: {result:?}"
);
let authenticated = inner.authenticated;
drop(inner);
assert!(
authenticated,
"handle_auth_response must set authenticated = true on 235 \
(RFC 4954 Section 3: centralize the flag to prevent omission \
in new auth methods)"
);
}
#[test]
fn auto_body_8bitmime_with_binarymime_only_no_params() {
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::BinaryMime, SmtpExtension::Chunking],
};
let message = b"Subject: Test\r\n\r\nCaf\xc3\xa9\r\n";
let is_8bit = SmtpConnection::message_contains_8bit(message);
assert!(is_8bit, "test setup: message must contain 8-bit content");
let rp_val = ReversePath::new("sender@example.com").unwrap();
let mut buf = BytesMut::new();
SmtpConnection::encode_mail_from_cmd(&caps, &mut buf, &rp_val, message.len(), None, is_8bit)
.unwrap();
let cmd = String::from_utf8_lossy(&buf);
assert!(
!cmd.contains("BODY=8BITMIME"),
"RFC 6152 Section 3: MAIL FROM must not auto-add BODY=8BITMIME \
when the server omits the 8BITMIME keyword. Got: {cmd}"
);
}
#[test]
fn auto_body_8bitmime_with_binarymime_only_with_params() {
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::BinaryMime, SmtpExtension::Chunking],
};
let message = b"Subject: Test\r\n\r\nCaf\xc3\xa9\r\n";
let is_8bit = SmtpConnection::message_contains_8bit(message);
let params = crate::types::MailFromParams {
body: None,
smtputf8: false,
size: None,
..Default::default()
};
let rp_val = ReversePath::new("sender@example.com").unwrap();
let mut buf = BytesMut::new();
SmtpConnection::encode_mail_from_cmd(
&caps,
&mut buf,
&rp_val,
message.len(),
Some(¶ms),
is_8bit,
)
.unwrap();
let cmd = String::from_utf8_lossy(&buf);
assert!(
!cmd.contains("BODY=8BITMIME"),
"RFC 6152 Section 3: MAIL FROM must not auto-add BODY=8BITMIME \
when the server omits the 8BITMIME keyword, even with explicit params. \
Got: {cmd}"
);
}
#[test]
fn mail_from_line_limit_accounts_for_all_esmtp_extensions() {
const {
assert!(SmtpConnection::SMTP_MAX_MAIL_FROM_LINE >= 1252);
assert!(SmtpConnection::SMTP_MAX_MAIL_FROM_LINE > SmtpConnection::SMTP_MAX_COMMAND_LINE,);
}
}
#[test]
fn mail_from_line_limit_matches_registered_extension_budget() {
assert!(
SmtpConnection::validate_mail_from_line_length(1252).is_ok(),
"MAIL FROM at the RFC-extended 1252-octet limit must pass"
);
assert!(
SmtpConnection::validate_mail_from_line_length(1253).is_err(),
"MAIL FROM above the RFC-extended 1252-octet limit must fail"
);
}
#[test]
fn mail_from_line_length_validation_uses_extended_limit() {
assert!(
SmtpConnection::validate_mail_from_line_length(600).is_ok(),
"600-byte MAIL FROM must be within the extended limit"
);
let limit = SmtpConnection::SMTP_MAX_MAIL_FROM_LINE;
assert!(
SmtpConnection::validate_mail_from_line_length(limit).is_ok(),
"line at exactly the limit must pass"
);
assert!(
SmtpConnection::validate_mail_from_line_length(limit + 1).is_err(),
"line exceeding the limit must fail"
);
assert!(
SmtpConnection::validate_command_line_length(520, "RCPT TO").is_err(),
"520-byte RCPT TO must exceed base 512 limit"
);
}
#[test]
fn rcpt_to_dsn_line_length_uses_extended_limit() {
assert!(
SmtpConnection::validate_rcpt_to_line_length(600, true).is_ok(),
"600-byte RCPT TO with DSN params must be within the extended 1012 limit \
(RFC 3461 Section 5)"
);
assert!(
SmtpConnection::validate_rcpt_to_line_length(1012, true).is_ok(),
"1012-byte RCPT TO with DSN params must be at the limit"
);
assert!(
SmtpConnection::validate_rcpt_to_line_length(1013, true).is_err(),
"1013-byte RCPT TO with DSN params must exceed the limit"
);
assert!(
SmtpConnection::validate_rcpt_to_line_length(512, false).is_ok(),
"512-byte RCPT TO without DSN params must be at the base limit"
);
assert!(
SmtpConnection::validate_rcpt_to_line_length(513, false).is_err(),
"513-byte RCPT TO without DSN params must exceed the base limit"
);
}
#[test]
fn data_message_size_uses_raw_size_not_dot_stuffed() {
let message = b".line1\r\n.line2\r\n";
let size = SmtpConnection::data_message_size(message);
let raw_len = message.len();
assert_eq!(
size, raw_len,
"RFC 1870 Section 5: SIZE must be the raw message size \
({raw_len}), not the dot-stuffed size. Got: {size}",
);
}
#[test]
fn data_message_size_excludes_dot_stuffing() {
let message = b"Subject: Test\r\n\r\n.line1\r\n.line2\r\n";
let raw_len = message.len(); let dot_stuffed_len = crate::codec::encode::dot_stuff(message).len(); assert!(
dot_stuffed_len > raw_len,
"test setup: stuffed size ({dot_stuffed_len}) must exceed \
raw size ({raw_len})"
);
let size = SmtpConnection::data_message_size(message);
assert_eq!(
size, raw_len,
"RFC 1870 Section 5: SIZE must be the raw message size \
({raw_len}), not the dot-stuffed size ({dot_stuffed_len}). \
Got: {size}"
);
}
#[tokio::test]
async fn auth_sasl_ir_zero_length_initial_response_sends_equals() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 4096];
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0, "connection closed before reading AUTH command");
accumulated.extend_from_slice(&buf[..n]);
if accumulated.windows(2).any(|w| w == b"\r\n") {
break;
}
}
let auth_line = String::from_utf8_lossy(&accumulated);
assert!(
auth_line.starts_with("AUTH PLAIN =\r\n"),
"RFC 4954 Section 4: zero-length initial response must be '='; \
expected 'AUTH PLAIN =\\r\\n', got: {auth_line:?}",
);
socket
.write_all(b"235 2.7.0 Authentication successful\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![
SmtpExtension::Auth(vec![crate::types::AuthMechanism::Plain]),
SmtpExtension::SaslIr,
],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = {
let mut inner = conn.inner.lock().await;
SmtpConnection::auth_send_with_initial_response(
&mut inner, "PLAIN", "", b"*\r\n",
)
.await
};
assert!(
result.is_ok(),
"auth with empty credentials should succeed: {result:?}"
);
server.await.unwrap();
}
#[tokio::test]
async fn auth_two_step_zero_length_response_sends_equals() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 4096];
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0);
accumulated.extend_from_slice(&buf[..n]);
if accumulated.windows(2).any(|w| w == b"\r\n") {
break;
}
}
let auth_line = String::from_utf8_lossy(&accumulated);
assert!(
auth_line.starts_with("AUTH PLAIN\r\n"),
"expected two-step AUTH command, got: {auth_line:?}",
);
socket.write_all(b"334\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0, "connection closed before reading credentials");
accumulated.extend_from_slice(&buf[..n]);
if accumulated.windows(2).any(|w| w == b"\r\n") {
break;
}
}
let cred_line = String::from_utf8_lossy(&accumulated);
assert_eq!(
cred_line.as_ref(),
"=\r\n",
"RFC 4954 Section 4: zero-length response in two-step \
exchange must be '=\\r\\n', got: {cred_line:?}",
);
socket
.write_all(b"235 2.7.0 Authentication successful\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Auth(vec![
crate::types::AuthMechanism::Plain,
])],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = {
let mut inner = conn.inner.lock().await;
SmtpConnection::auth_two_step(&mut inner, "PLAIN", "", b"*\r\n").await
};
assert!(
result.is_ok(),
"auth two-step with empty credentials should succeed: {result:?}"
);
server.await.unwrap();
}
#[tokio::test]
async fn send_with_all_params_rcpt_params_length_mismatch_returns_error() {
use crate::types::RcptToParams;
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let rcpt_params = vec![RcptToParams::default()]; let result = conn
.send_with_all_params(
&ReversePath::new("sender@example.com").unwrap(),
&[
ForwardPath::new("a@example.com").unwrap(),
ForwardPath::new("b@example.com").unwrap(),
],
b"Subject: Test\r\n\r\nHello\r\n",
None,
&rcpt_params,
Duration::from_secs(1),
)
.await;
assert!(result.is_err(), "expected error for length mismatch");
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("rcpt_params length") && msg.contains("recipients length"),
"error must describe the mismatch: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn send_with_all_params_on_lmtp_returns_protocol_error() {
use crate::types::RcptToParams;
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Lmtp);
let rcpt_params = vec![RcptToParams::default()];
let result = conn
.send_with_all_params(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("a@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello\r\n",
None,
&rcpt_params,
Duration::from_secs(1),
)
.await;
assert!(result.is_err(), "expected error for LMTP");
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(msg.contains("LMTP"), "error must mention LMTP: {msg}");
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn send_with_all_params_sequential_includes_dsn_params() {
use crate::types::{DsnNotify, RcptToParams};
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 4096];
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0);
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("MAIL FROM:") {
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0);
accumulated.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&accumulated);
if text.contains("RCPT TO:") {
assert!(
text.contains("NOTIFY=SUCCESS,FAILURE"),
"RCPT TO must include NOTIFY=SUCCESS,FAILURE (RFC 3461 Section 4.1). Got: {text}"
);
assert!(
text.contains("ORCPT=rfc822;original@example.com"),
"RCPT TO must include ORCPT (RFC 3461 Section 4.2). Got: {text}"
);
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0);
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("DATA") {
break;
}
}
socket.write_all(b"354 Start\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0);
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("\r\n.\r\n") {
break;
}
}
socket.write_all(b"250 Message accepted\r\n").await.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Dsn],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let rcpt_params = vec![RcptToParams {
notify: Some(vec![DsnNotify::Success, DsnNotify::Failure]),
orcpt: Some("original@example.com".into()),
}];
let result = conn
.send_with_all_params(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello\r\n",
None,
&rcpt_params,
Duration::from_secs(5),
)
.await;
assert!(result.is_ok(), "expected success, got: {result:?}");
server.await.unwrap();
}
#[tokio::test]
async fn validate_rcpt_params_rejects_dsn_without_capability() {
use crate::types::{DsnNotify, RcptToParams};
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::EightBitMime],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let rcpt_params = vec![RcptToParams {
notify: Some(vec![DsnNotify::Success]),
orcpt: None,
}];
let result = conn
.send_with_all_params(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello\r\n",
None,
&rcpt_params,
Duration::from_secs(1),
)
.await;
assert!(
result.is_err(),
"DSN RCPT TO params without server DSN support must be rejected \
(RFC 3461 / RFC 5321 Section 2.2.1)"
);
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("DSN") && msg.contains("NOTIFY"),
"error must mention DSN/NOTIFY: {msg}"
);
}
other => panic!("expected Protocol error about DSN, got: {other:?}"),
}
}
#[tokio::test]
async fn send_with_all_params_empty_notify_vector_omits_notify_without_dsn() {
use crate::types::RcptToParams;
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 4096];
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0);
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("MAIL FROM:") {
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0);
accumulated.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&accumulated);
if text.contains("RCPT TO:") {
assert!(
!text.contains("NOTIFY="),
"empty NOTIFY vector must be omitted, not sent as a DSN parameter \
(RFC 3461 Section 4.1). Got: {text}"
);
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0);
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("DATA") {
break;
}
}
socket.write_all(b"354 Start\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0);
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("\r\n.\r\n") {
break;
}
}
socket.write_all(b"250 Message accepted\r\n").await.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let rcpt_params = vec![RcptToParams {
notify: Some(vec![]),
..Default::default()
}];
let result = conn
.send_with_all_params(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello\r\n",
None,
&rcpt_params,
Duration::from_secs(5),
)
.await;
assert!(
result.is_ok(),
"empty NOTIFY vector must behave like no DSN parameters and succeed without DSN capability: {result:?}"
);
server.await.unwrap();
}
#[tokio::test]
async fn send_with_all_params_pipelined_includes_dsn_params() {
use crate::types::{DsnNotify, RcptToParams};
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 8192];
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0);
accumulated.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&accumulated);
if text.contains("DATA") {
assert!(
text.contains("NOTIFY=NEVER"),
"pipelined RCPT TO must include NOTIFY=NEVER. Got: {text}"
);
break;
}
}
socket
.write_all(b"250 OK\r\n250 OK\r\n354 Start\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0);
accumulated.extend_from_slice(&buf[..n]);
if String::from_utf8_lossy(&accumulated).contains("\r\n.\r\n") {
break;
}
}
socket.write_all(b"250 Message accepted\r\n").await.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Pipelining, SmtpExtension::Dsn],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let rcpt_params = vec![RcptToParams {
notify: Some(vec![DsnNotify::Never]),
orcpt: None,
}];
let result = conn
.send_with_all_params(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello\r\n",
None,
&rcpt_params,
Duration::from_secs(5),
)
.await;
assert!(result.is_ok(), "expected success, got: {result:?}");
server.await.unwrap();
}
#[tokio::test]
async fn send_bdat_with_all_params_rcpt_params_length_mismatch_returns_error() {
use crate::types::RcptToParams;
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Chunking],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let rcpt_params = vec![RcptToParams::default(), RcptToParams::default()]; let result = conn
.send_bdat_with_all_params(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("a@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello\r\n",
None,
&rcpt_params,
Duration::from_secs(1),
)
.await;
assert!(result.is_err(), "expected error for length mismatch");
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("rcpt_params length"),
"error must describe the mismatch: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn send_bdat_with_all_params_on_lmtp_returns_protocol_error() {
use crate::types::RcptToParams;
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Chunking],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Lmtp);
let rcpt_params = vec![RcptToParams::default()];
let result = conn
.send_bdat_with_all_params(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("a@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello\r\n",
None,
&rcpt_params,
Duration::from_secs(1),
)
.await;
assert!(result.is_err(), "expected error for LMTP");
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(msg.contains("LMTP"), "error must mention LMTP: {msg}");
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn send_bdat_with_all_params_success() {
use crate::types::{DsnNotify, RcptToParams};
let stream = mock_interactive_server(vec![
("MAIL FROM:", "250 OK\r\n"),
("NOTIFY=SUCCESS", "250 OK\r\n"),
("BDAT", "250 OK\r\n"),
])
.await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Chunking, SmtpExtension::Dsn],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let rcpt_params = vec![RcptToParams {
notify: Some(vec![DsnNotify::Success]),
orcpt: None,
}];
let result = conn
.send_bdat_with_all_params(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello\r\n",
None,
&rcpt_params,
Duration::from_secs(5),
)
.await;
assert!(result.is_ok(), "expected success, got: {result:?}");
}
#[tokio::test]
async fn send_lmtp_with_all_params_rcpt_params_length_mismatch_returns_error() {
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Lmtp);
let rcpt_params: Vec<crate::types::RcptToParams> = vec![]; let result = conn
.send_lmtp_with_all_params(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("a@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello\r\n",
None,
&rcpt_params,
Duration::from_secs(1),
)
.await;
assert!(result.is_err(), "expected error for length mismatch");
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("rcpt_params length"),
"error must describe the mismatch: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn send_lmtp_with_all_params_on_smtp_returns_protocol_error() {
use crate::types::RcptToParams;
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let rcpt_params = vec![RcptToParams::default()];
let result = conn
.send_lmtp_with_all_params(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("a@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello\r\n",
None,
&rcpt_params,
Duration::from_secs(1),
)
.await;
assert!(result.is_err(), "expected error for non-LMTP");
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(msg.contains("LMTP"), "error must mention LMTP: {msg}");
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn send_lmtp_with_all_params_success() {
use crate::types::{DsnNotify, RcptToParams};
let stream = mock_interactive_server(vec![
("MAIL FROM:", "250 OK\r\n"),
("NOTIFY=FAILURE", "250 OK\r\n"),
("DATA", "354 Start\r\n"),
("\r\n.\r\n", "250 Delivered\r\n"),
])
.await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Dsn],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Lmtp);
let rcpt_params = vec![RcptToParams {
notify: Some(vec![DsnNotify::Failure]),
orcpt: None,
}];
let result = conn
.send_lmtp_with_all_params(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello\r\n",
None,
&rcpt_params,
Duration::from_secs(5),
)
.await;
assert!(result.is_ok(), "expected success, got: {result:?}");
let results = result.unwrap();
assert_eq!(results.results.len(), 1);
assert_eq!(results.results[0].recipient.as_str(), "rcpt@example.com");
assert!(results.results[0].response.is_success());
}
#[tokio::test]
async fn send_lmtp_bdat_with_all_params_rcpt_params_length_mismatch_returns_error() {
use crate::types::RcptToParams;
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Chunking],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Lmtp);
let rcpt_params = vec![RcptToParams::default(), RcptToParams::default()]; let result = conn
.send_lmtp_bdat_with_all_params(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("a@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello\r\n",
None,
&rcpt_params,
Duration::from_secs(1),
)
.await;
assert!(result.is_err(), "expected error for length mismatch");
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(
msg.contains("rcpt_params length"),
"error must describe the mismatch: {msg}"
);
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn send_lmtp_bdat_with_all_params_on_smtp_returns_protocol_error() {
use crate::types::RcptToParams;
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Chunking],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let rcpt_params = vec![RcptToParams::default()];
let result = conn
.send_lmtp_bdat_with_all_params(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("a@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello\r\n",
None,
&rcpt_params,
Duration::from_secs(1),
)
.await;
assert!(result.is_err(), "expected error for non-LMTP");
match result.unwrap_err() {
Error::Protocol(msg) => {
assert!(msg.contains("LMTP"), "error must mention LMTP: {msg}");
}
other => panic!("expected Protocol error, got: {other:?}"),
}
}
#[tokio::test]
async fn send_lmtp_bdat_with_all_params_success() {
use crate::types::{DsnNotify, RcptToParams};
let stream = mock_interactive_server(vec![
("MAIL FROM:", "250 OK\r\n"),
("NOTIFY=DELAY", "250 OK\r\n"),
("BDAT", "250 Delivered\r\n"),
])
.await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Chunking, SmtpExtension::Dsn],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Lmtp);
let rcpt_params = vec![RcptToParams {
notify: Some(vec![DsnNotify::Delay]),
orcpt: None,
}];
let result = conn
.send_lmtp_bdat_with_all_params(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello\r\n",
None,
&rcpt_params,
Duration::from_secs(5),
)
.await;
assert!(result.is_ok(), "expected success, got: {result:?}");
let results = result.unwrap();
assert_eq!(results.results.len(), 1);
assert_eq!(results.results[0].recipient.as_str(), "rcpt@example.com");
assert!(results.results[0].response.is_success());
}
#[test]
fn try_parse_response_rejects_invalid_separator_byte() {
let buf = b"250\t OK\r\n";
let result = SmtpConnection::try_parse_response(buf);
assert!(
result.is_err(),
"try_parse_response must reject invalid separator byte \
(RFC 5321 Section 4.2)"
);
}
#[test]
fn try_parse_response_byte_count_multiline() {
let buf = b"250-First\r\n250 Second\r\n";
let (_resp, consumed) = SmtpConnection::try_parse_response(buf).unwrap().unwrap();
assert_eq!(consumed, buf.len());
}
#[test]
fn try_parse_response_byte_count_back_to_back_responses() {
let first = b"250-mail.example.com\r\n250-PIPELINING\r\n250 SIZE 10240000\r\n";
let second = b"354 Start mail input\r\n";
let mut buf = Vec::new();
buf.extend_from_slice(first);
buf.extend_from_slice(second);
let (resp1, consumed1) = SmtpConnection::try_parse_response(&buf).unwrap().unwrap();
assert_eq!(resp1.code, 250);
assert_eq!(consumed1, first.len());
let (resp2, consumed2) = SmtpConnection::try_parse_response(&buf[consumed1..])
.unwrap()
.unwrap();
assert_eq!(resp2.code, 354);
assert_eq!(consumed2, second.len());
assert_eq!(consumed1 + consumed2, buf.len());
}
#[test]
fn try_parse_response_byte_count_single_line() {
let buf = b"220 mail.example.com ESMTP\r\n";
let (resp, consumed) = SmtpConnection::try_parse_response(buf).unwrap().unwrap();
assert_eq!(resp.code, 220);
assert_eq!(consumed, buf.len());
}
#[test]
fn try_parse_response_byte_count_bare_code() {
let buf = b"250\r\nmore data here";
let (resp, consumed) = SmtpConnection::try_parse_response(buf).unwrap().unwrap();
assert_eq!(resp.code, 250);
assert_eq!(consumed, 5); }
#[tokio::test]
async fn auth_success_requires_exactly_235() {
let stream = mock_server(vec!["250 2.0.0 OK\r\n"]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![
SmtpExtension::Auth(vec![crate::types::AuthMechanism::Plain]),
SmtpExtension::SaslIr,
],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.auth_plain("user", "pass", Duration::from_secs(5))
.await;
assert!(
result.is_err(),
"auth must reject non-235 success code (RFC 4954 Section 6); got Ok(())"
);
}
#[tokio::test]
async fn validate_params_rejects_holdfor_exceeding_server_max_interval() {
use crate::types::MailFromParams;
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::FutureRelease {
max_interval: Some(86400), max_datetime: None,
}],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
hold_for: Some(172_800), ..Default::default()
};
let result = conn.validate_params_public(Some(¶ms)).await;
assert!(
result.is_err(),
"HOLDFOR exceeding server max_interval must be rejected (RFC 4865 Section 4)"
);
}
#[tokio::test]
async fn validate_params_accepts_holdfor_within_server_max_interval() {
use crate::types::MailFromParams;
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::FutureRelease {
max_interval: Some(86400),
max_datetime: None,
}],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
hold_for: Some(3600), ..Default::default()
};
let result = conn.validate_params_public(Some(¶ms)).await;
assert!(
result.is_ok(),
"HOLDFOR within server max_interval must be accepted"
);
}
#[tokio::test]
async fn validate_params_rejects_zero_holdfor() {
use crate::types::MailFromParams;
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::FutureRelease {
max_interval: None,
max_datetime: None,
}],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
hold_for: Some(0),
..Default::default()
};
let result = conn.validate_params_public(Some(¶ms)).await;
assert!(
result.is_err(),
"HOLDFOR=0 must be rejected because RFC 4865 Section 5 requires a positive interval"
);
}
#[tokio::test]
async fn validate_params_rejects_ten_digit_holdfor() {
use crate::types::MailFromParams;
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::FutureRelease {
max_interval: None,
max_datetime: None,
}],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
hold_for: Some(1_000_000_000),
..Default::default()
};
let result = conn.validate_params_public(Some(¶ms)).await;
assert!(
result.is_err(),
"HOLDFOR with more than 9 digits must be rejected (RFC 4865 Section 5)"
);
}
#[tokio::test]
async fn validate_params_rejects_holduntil_exceeding_server_max_datetime() {
use crate::types::MailFromParams;
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::FutureRelease {
max_interval: None,
max_datetime: Some("2026-01-01T00:00:00Z".into()),
}],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
hold_until: Some("2027-06-15T12:00:00Z".into()),
..Default::default()
};
let result = conn.validate_params_public(Some(¶ms)).await;
assert!(
result.is_err(),
"HOLDUNTIL exceeding server max_datetime must be rejected (RFC 4865 Section 4)"
);
}
#[tokio::test]
async fn validate_params_accepts_holduntil_within_server_max_datetime() {
use crate::types::MailFromParams;
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::FutureRelease {
max_interval: None,
max_datetime: Some("2026-12-31T23:59:59Z".into()),
}],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
hold_until: Some("2026-06-15T12:00:00Z".into()),
..Default::default()
};
let result = conn.validate_params_public(Some(¶ms)).await;
assert!(
result.is_ok(),
"HOLDUNTIL within server max_datetime must be accepted"
);
}
#[tokio::test]
async fn validate_params_rejects_malformed_holduntil_without_server_max() {
use crate::types::MailFromParams;
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::FutureRelease {
max_interval: None,
max_datetime: None,
}],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
hold_until: Some("2026-01-01 00:00:00Z".into()),
..Default::default()
};
let result = conn.validate_params_public(Some(¶ms)).await;
assert!(
result.is_err(),
"malformed HOLDUNTIL must be rejected even without a server max-datetime"
);
}
#[tokio::test]
async fn validate_params_rejects_deliver_by_below_server_min() {
use crate::types::{DeliverBy, DeliverByMode, MailFromParams};
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::DeliverBy(Some(86400))],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
deliver_by: Some(DeliverBy {
seconds: 3600, mode: DeliverByMode::Return,
trace: false,
}),
..Default::default()
};
let result = conn.validate_params_public(Some(¶ms)).await;
assert!(
result.is_err(),
"DELIVERBY below server minimum must be rejected (RFC 2852 Section 2)"
);
}
#[tokio::test]
async fn validate_params_accepts_deliver_by_above_server_min() {
use crate::types::{DeliverBy, DeliverByMode, MailFromParams};
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::DeliverBy(Some(86400))],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
deliver_by: Some(DeliverBy {
seconds: 172_800, mode: DeliverByMode::Return,
trace: false,
}),
..Default::default()
};
let result = conn.validate_params_public(Some(¶ms)).await;
assert!(
result.is_ok(),
"DELIVERBY above server minimum must be accepted (RFC 2852 Section 2)"
);
}
#[test]
fn parse_response_accepts_overlong_reply_line_postel() {
let mut line = Vec::new();
line.extend_from_slice(b"250 ");
line.extend(std::iter::repeat(b'X').take(1000));
line.extend_from_slice(b"\r\n");
assert!(line.len() > 512, "test setup: line must exceed 512 octets");
let result = SmtpConnection::try_parse_response(&line);
assert!(
result.is_ok(),
"overlong reply line should be accepted per Postel's law: {result:?}"
);
let (resp, _consumed) = result.unwrap().unwrap();
assert_eq!(resp.code, 250);
}
#[tokio::test]
async fn auth_two_step_cancel_uses_cancel_payload_not_hardcoded() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 16384];
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0);
accumulated.extend_from_slice(&buf[..n]);
if accumulated.windows(2).any(|w| w == b"\r\n") {
break;
}
}
socket.write_all(b"334\r\n").await.unwrap();
socket.flush().await.unwrap();
accumulated.clear();
loop {
let n = socket.read(&mut buf).await.unwrap();
if n == 0 {
break;
}
accumulated.extend_from_slice(&buf[..n]);
if accumulated.windows(2).any(|w| w == b"\r\n") {
break;
}
}
let cancel_line = String::from_utf8_lossy(&accumulated);
assert_eq!(
cancel_line.as_ref(),
"AQ==\r\n",
"Expected cancel_payload 'AQ==\\r\\n' (RFC 7628 Section 3.2.3), \
got: {cancel_line:?} — auth_two_step must use cancel_payload, \
not hardcoded '*\\r\\n'"
);
socket
.write_all(b"535 5.7.8 Authentication failed\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Auth(vec![
crate::types::AuthMechanism::Plain,
])],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let oversized_creds = "X".repeat(13000);
let result = {
let mut inner = conn.inner.lock().await;
SmtpConnection::auth_two_step(&mut inner, "OAUTHBEARER", &oversized_creds, b"AQ==\r\n")
.await
};
assert!(
result.is_err(),
"oversized credentials must produce an error"
);
server.await.unwrap();
}
#[tokio::test]
async fn sequential_send_partial_rcpt_rejection_returns_rejected_recipients() {
let stream = mock_interactive_server(vec![
("MAIL FROM:", "250 OK\r\n"),
("good@example.com", "250 OK\r\n"),
("bad@example.com", "550 User unknown\r\n"),
("DATA", "354 Start\r\n"),
("\r\n.\r\n", "250 Message accepted\r\n"),
])
.await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[
ForwardPath::new("good@example.com").unwrap(),
ForwardPath::new("bad@example.com").unwrap(),
],
b"Subject: Test\r\n\r\nHello",
Duration::from_secs(5),
)
.await;
let send_result = result.expect("send should succeed with partial rejection");
assert!(
send_result.has_rejections(),
"SendResult must report partial RCPT TO rejections"
);
assert_eq!(
send_result.rejected_recipients.len(),
1,
"exactly one recipient should be rejected"
);
assert_eq!(
send_result.rejected_recipients[0].recipient.as_str(),
"bad@example.com",
"rejected recipient address must match"
);
assert_eq!(
send_result.rejected_recipients[0].response.code, 550,
"rejection response code must be preserved"
);
}
#[tokio::test]
async fn pipelined_send_partial_rcpt_rejection_returns_rejected_recipients() {
let stream = mock_server(vec![
"250 OK\r\n",
"250 OK\r\n",
"550 User unknown\r\n",
"354 Start\r\n",
"250 Message accepted\r\n",
])
.await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_with_pipelining(), Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[
ForwardPath::new("good@example.com").unwrap(),
ForwardPath::new("bad@example.com").unwrap(),
],
b"Subject: Test\r\n\r\nHello",
Duration::from_secs(5),
)
.await;
let send_result = result.expect("send should succeed with partial rejection");
assert!(
send_result.has_rejections(),
"SendResult must report partial RCPT TO rejections"
);
assert_eq!(
send_result.rejected_recipients.len(),
1,
"exactly one recipient should be rejected"
);
assert_eq!(
send_result.rejected_recipients[0].recipient.as_str(),
"bad@example.com",
"rejected recipient address must match"
);
assert_eq!(
send_result.rejected_recipients[0].response.code, 550,
"rejection response code must be preserved"
);
}
#[tokio::test]
async fn send_all_accepted_returns_empty_rejections() {
let stream = mock_interactive_server(vec![
("MAIL FROM:", "250 OK\r\n"),
("RCPT TO:", "250 OK\r\n"),
("DATA", "354 Start\r\n"),
("\r\n.\r\n", "250 OK\r\n"),
])
.await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("good@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello",
Duration::from_secs(5),
)
.await;
let send_result = result.expect("send should succeed");
assert!(
send_result.all_accepted(),
"all_accepted() must return true when no recipients were rejected"
);
assert!(
send_result.rejected_recipients.is_empty(),
"rejected_recipients must be empty when all accepted"
);
}
#[tokio::test]
async fn bdat_send_partial_rcpt_rejection_returns_rejected_recipients() {
let stream = mock_interactive_server(vec![
("MAIL FROM:", "250 OK\r\n"),
("good@example.com", "250 OK\r\n"),
("bad@example.com", "550 User unknown\r\n"),
("BDAT", "250 OK\r\n"),
])
.await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Chunking],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.send_bdat(
&ReversePath::new("sender@example.com").unwrap(),
&[
ForwardPath::new("good@example.com").unwrap(),
ForwardPath::new("bad@example.com").unwrap(),
],
b"Subject: Test\r\n\r\nHello",
None,
Duration::from_secs(5),
)
.await;
let send_result = result.expect("send_bdat should succeed with partial rejection");
assert!(
send_result.has_rejections(),
"SendResult must report partial RCPT TO rejections via BDAT path"
);
assert_eq!(
send_result.rejected_recipients.len(),
1,
"exactly one recipient should be rejected"
);
assert_eq!(
send_result.rejected_recipients[0].recipient.as_str(),
"bad@example.com",
"rejected recipient address must match"
);
assert_eq!(
send_result.rejected_recipients[0].response.code, 550,
"rejection response code must be preserved"
);
}
#[tokio::test]
async fn sequential_send_multiple_rejections_reports_all() {
let stream = mock_interactive_server(vec![
("MAIL FROM:", "250 OK\r\n"),
("good@example.com", "250 OK\r\n"),
("bad1@example.com", "550 User unknown\r\n"),
("bad2@example.com", "452 Mailbox full\r\n"),
("DATA", "354 Start\r\n"),
("\r\n.\r\n", "250 OK\r\n"),
])
.await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[
ForwardPath::new("good@example.com").unwrap(),
ForwardPath::new("bad1@example.com").unwrap(),
ForwardPath::new("bad2@example.com").unwrap(),
],
b"Subject: Test\r\n\r\nHello",
Duration::from_secs(5),
)
.await;
let send_result = result.expect("send should succeed with partial rejection");
assert_eq!(
send_result.rejected_recipients.len(),
2,
"both rejected recipients must be reported"
);
assert_eq!(
send_result.rejected_recipients[0].recipient.as_str(),
"bad1@example.com"
);
assert_eq!(send_result.rejected_recipients[0].response.code, 550);
assert_eq!(
send_result.rejected_recipients[1].recipient.as_str(),
"bad2@example.com"
);
assert_eq!(send_result.rejected_recipients[1].response.code, 452);
}
#[tokio::test]
async fn lmtp_send_partial_rcpt_rejection_returns_rejected_recipients() {
let stream = mock_interactive_server(vec![
("MAIL FROM:", "250 OK\r\n"),
("good@example.com", "250 OK\r\n"),
("bad@example.com", "550 User unknown\r\n"),
("DATA", "354 Start\r\n"),
("\r\n.\r\n", "250 Delivered to good@example.com\r\n"),
])
.await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Lmtp);
let result = conn
.send_lmtp(
&ReversePath::new("sender@example.com").unwrap(),
&[
ForwardPath::new("good@example.com").unwrap(),
ForwardPath::new("bad@example.com").unwrap(),
],
b"Subject: Test\r\n\r\nHello\r\n",
None,
Duration::from_secs(5),
)
.await;
let lmtp_result = result.expect("send_lmtp should succeed with partial rejection");
assert_eq!(
lmtp_result.results.len(),
1,
"one accepted recipient should have a delivery result"
);
assert_eq!(
lmtp_result.results[0].recipient.as_str(),
"good@example.com"
);
assert!(lmtp_result.results[0].response.is_success());
assert_eq!(
lmtp_result.rejected_recipients.len(),
1,
"one recipient should be rejected at RCPT TO time"
);
assert_eq!(
lmtp_result.rejected_recipients[0].recipient.as_str(),
"bad@example.com",
"rejected recipient address must match"
);
assert_eq!(
lmtp_result.rejected_recipients[0].response.code, 550,
"rejection response code must be preserved"
);
}
#[tokio::test]
async fn validate_params_deliverby_minimum_not_maximum() {
use crate::types::{DeliverBy, DeliverByMode, MailFromParams};
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::DeliverBy(Some(240))],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let below = MailFromParams {
deliver_by: Some(DeliverBy {
seconds: 100,
mode: DeliverByMode::Return,
trace: false,
}),
..Default::default()
};
let result_below = conn.validate_params_public(Some(&below)).await;
assert!(
result_below.is_err(),
"DELIVERBY 100s is below server minimum 240s and must be rejected (RFC 2852 Section 2)"
);
let stream2 = mock_server(vec![]).await;
let caps2 = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::DeliverBy(Some(240))],
};
let conn2 = SmtpConnection::from_plain(stream2, caps2, Protocol::Smtp);
let above = MailFromParams {
deliver_by: Some(DeliverBy {
seconds: 300,
mode: DeliverByMode::Return,
trace: false,
}),
..Default::default()
};
let result_above = conn2.validate_params_public(Some(&above)).await;
assert!(
result_above.is_ok(),
"DELIVERBY 300s is above server minimum 240s and must be accepted (RFC 2852 Section 2)"
);
}
#[tokio::test]
async fn validate_params_deliverby_minimum_applies_only_to_return_mode() {
use crate::types::{DeliverBy, DeliverByMode, MailFromParams};
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::DeliverBy(Some(240))],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let negative = MailFromParams {
deliver_by: Some(DeliverBy {
seconds: -100,
mode: DeliverByMode::Notify,
trace: false,
}),
..Default::default()
};
let result_neg = conn.validate_params_public(Some(&negative)).await;
assert!(
result_neg.is_ok(),
"DELIVERBY -100s (negative, expired deadline) must not be rejected \
by the minimum-window check (RFC 2852 Section 4)"
);
let stream2 = mock_server(vec![]).await;
let caps2 = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::DeliverBy(Some(240))],
};
let conn2 = SmtpConnection::from_plain(stream2, caps2, Protocol::Smtp);
let below_min = MailFromParams {
deliver_by: Some(DeliverBy {
seconds: 100,
mode: DeliverByMode::Notify,
trace: false,
}),
..Default::default()
};
let result_below = conn2.validate_params_public(Some(&below_min)).await;
assert!(
result_below.is_ok(),
"DELIVERBY 100s with Notify mode must ignore the server minimum \
(RFC 2852 Section 2)"
);
let stream3 = mock_server(vec![]).await;
let caps3 = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::DeliverBy(Some(240))],
};
let conn3 = SmtpConnection::from_plain(stream3, caps3, Protocol::Smtp);
let below_min_return = MailFromParams {
deliver_by: Some(DeliverBy {
seconds: 100,
mode: DeliverByMode::Return,
trace: false,
}),
..Default::default()
};
let result_above = conn3.validate_params_public(Some(&below_min_return)).await;
assert!(
result_above.is_err(),
"DELIVERBY 100s with Return mode must honor the server minimum \
(RFC 2852 Section 2)"
);
}
#[tokio::test]
async fn validate_params_rejects_negative_deliverby_return_mode() {
use crate::types::{DeliverBy, DeliverByMode, MailFromParams};
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::DeliverBy(None)],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
deliver_by: Some(DeliverBy {
seconds: -60,
mode: DeliverByMode::Return,
trace: false,
}),
..Default::default()
};
let result = conn.validate_params_public(Some(¶ms)).await;
assert!(
result.is_err(),
"BY=-60;R must be rejected because RFC 2852 Section 4 forbids non-positive by-time with Return mode"
);
}
#[tokio::test]
async fn validate_params_rejects_ten_digit_deliverby() {
use crate::types::{DeliverBy, DeliverByMode, MailFromParams};
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::DeliverBy(None)],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
deliver_by: Some(DeliverBy {
seconds: 1_000_000_000,
mode: DeliverByMode::Notify,
trace: false,
}),
..Default::default()
};
let result = conn.validate_params_public(Some(¶ms)).await;
assert!(
result.is_err(),
"DELIVERBY with more than 9 digits must be rejected (RFC 2852 Section 4)"
);
}
#[tokio::test]
async fn validate_params_holduntil_cross_timezone_accepts_earlier_utc() {
use crate::types::MailFromParams;
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::FutureRelease {
max_interval: None,
max_datetime: Some("2026-03-26T20:00:00-05:00".into()),
}],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
hold_until: Some("2026-03-27T00:00:00+00:00".into()),
..Default::default()
};
let result = conn.validate_params_public(Some(¶ms)).await;
assert!(
result.is_ok(),
"HOLDUNTIL 2026-03-27T00:00:00+00:00 (= midnight UTC) is before \
max 2026-03-26T20:00:00-05:00 (= 01:00 UTC next day) and must be \
accepted (RFC 4865 Section 4)"
);
}
#[tokio::test]
async fn validate_params_holduntil_cross_timezone_rejects_later_utc() {
use crate::types::MailFromParams;
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::FutureRelease {
max_interval: None,
max_datetime: Some("2026-03-27T01:00:00+00:00".into()),
}],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
hold_until: Some("2026-03-26T23:00:00-05:00".into()),
..Default::default()
};
let result = conn.validate_params_public(Some(¶ms)).await;
assert!(
result.is_err(),
"HOLDUNTIL 2026-03-26T23:00:00-05:00 (= 04:00 UTC) exceeds \
max 2026-03-27T01:00:00+00:00 (= 01:00 UTC) and must be \
rejected (RFC 4865 Section 4)"
);
}
#[tokio::test]
async fn validate_params_holduntil_fractional_seconds_rejects_later_timestamp() {
use crate::types::MailFromParams;
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::FutureRelease {
max_interval: None,
max_datetime: Some("2026-03-27T00:00:00Z".into()),
}],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
hold_until: Some("2026-03-27T00:00:00.1Z".into()),
..Default::default()
};
let result = conn.validate_params_public(Some(¶ms)).await;
assert!(
result.is_err(),
"HOLDUNTIL with fractional seconds that exceeds the server maximum \
must be rejected (RFC 4865 Section 4 / RFC 3339 Section 5.6)"
);
}
#[tokio::test]
async fn validate_params_holduntil_lowercase_t_rejects_later_timestamp() {
use crate::types::MailFromParams;
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::FutureRelease {
max_interval: None,
max_datetime: Some("2026-03-27t00:00:00Z".into()),
}],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
hold_until: Some("2026-03-27T00:00:01Z".into()),
..Default::default()
};
let result = conn.validate_params_public(Some(¶ms)).await;
assert!(
result.is_err(),
"HOLDUNTIL later than a lower-case 't' max-datetime must be rejected \
(RFC 4865 Section 4 / RFC 3339 Section 5.6)"
);
}
#[tokio::test]
async fn validate_params_rejects_holdfor_beyond_deliverby_window() {
use crate::types::{DeliverBy, DeliverByMode, MailFromParams};
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![
SmtpExtension::FutureRelease {
max_interval: None,
max_datetime: None,
},
SmtpExtension::DeliverBy(None),
],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
hold_for: Some(7200),
deliver_by: Some(DeliverBy {
seconds: 3600,
mode: DeliverByMode::Return,
trace: false,
}),
..Default::default()
};
let result = conn.validate_params_public(Some(¶ms)).await;
assert!(
result.is_err(),
"HOLDFOR must be rejected when it extends past the DELIVERBY deadline (RFC 4865 Section 5.2.1)"
);
}
#[tokio::test]
async fn validate_params_rejects_holduntil_beyond_deliverby_window() {
use crate::types::{DeliverBy, DeliverByMode, MailFromParams};
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![
SmtpExtension::FutureRelease {
max_interval: None,
max_datetime: None,
},
SmtpExtension::DeliverBy(None),
],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let params = MailFromParams {
hold_until: Some("2099-01-01T00:00:00Z".into()),
deliver_by: Some(DeliverBy {
seconds: 3600,
mode: DeliverByMode::Return,
trace: false,
}),
..Default::default()
};
let result = conn.validate_params_public(Some(¶ms)).await;
assert!(
result.is_err(),
"HOLDUNTIL must be rejected when it is later than the DELIVERBY deadline (RFC 4865 Section 5.2.1)"
);
}
#[tokio::test]
async fn connection_marks_shutting_down_on_421() {
let stream = mock_interactive_server(vec![
("MAIL FROM:", "421 4.7.0 Service shutting down\r\n"),
])
.await;
let caps = smtp_caps_no_pipelining();
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello",
Duration::from_secs(5),
)
.await;
assert!(result.is_err(), "send should fail on 421 response");
assert!(
conn.is_shutting_down().await,
"connection must be marked as shutting down after 421 \
(RFC 5321 Section 3.8)"
);
let result2 = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test2\r\n\r\nHello",
Duration::from_secs(5),
)
.await;
assert!(result2.is_err(), "second send should fail immediately");
let err_msg = format!("{}", result2.unwrap_err());
assert!(
err_msg.contains("shutting down"),
"error message should mention shutting down, got: {err_msg}"
);
}
#[tokio::test]
async fn noop_rejects_when_connection_is_shutting_down() {
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
conn.inner.lock().await.server_shutting_down = true;
let err = conn
.noop(Duration::from_millis(10))
.await
.expect_err("NOOP must fail immediately after a 421 shutdown");
assert!(
matches!(err, Error::Protocol(ref message) if message.contains("shutting down")),
"expected shutting-down protocol error, got {err:?}"
);
}
#[tokio::test]
async fn auth_plain_rejects_when_connection_is_shutting_down() {
let stream = mock_server(vec![]).await;
let caps = ServerCapabilities {
extensions: vec![SmtpExtension::Auth(vec![
crate::types::AuthMechanism::Plain,
])],
..ServerCapabilities::default()
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
conn.inner.lock().await.server_shutting_down = true;
let err = conn
.auth_plain("user", "pass", Duration::from_millis(10))
.await
.expect_err("AUTH PLAIN must fail immediately after a 421 shutdown");
assert!(
matches!(err, Error::Protocol(ref message) if message.contains("shutting down")),
"expected shutting-down protocol error, got {err:?}"
);
}
#[tokio::test]
async fn rehlo_rejects_when_connection_is_shutting_down() {
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
conn.inner.lock().await.server_shutting_down = true;
let err = conn
.rehlo(Duration::from_millis(10))
.await
.expect_err("rehlo must fail immediately after a 421 shutdown");
assert!(
matches!(err, Error::Protocol(ref message) if message.contains("shutting down")),
"expected shutting-down protocol error, got {err:?}"
);
}
#[test]
fn try_parse_response_tolerates_bare_lf() {
let input = b"250 OK\n";
let result = SmtpConnection::try_parse_response(input);
assert!(
result.is_ok(),
"bare LF should be tolerated per Postel's law: {result:?}"
);
let parsed = result.unwrap();
assert!(
parsed.is_some(),
"response with bare LF should parse as complete, not incomplete"
);
let (resp, consumed) = parsed.unwrap();
assert_eq!(resp.code, 250);
assert_eq!(resp.lines, vec!["OK"]);
assert_eq!(consumed, input.len());
}
#[test]
fn try_parse_response_tolerates_bare_lf_multiline() {
let input = b"250-First line\n250 Second line\n";
let result = SmtpConnection::try_parse_response(input);
assert!(
result.is_ok(),
"multi-line bare LF should be tolerated: {result:?}"
);
let parsed = result.unwrap();
assert!(parsed.is_some(), "should parse as complete");
let (resp, consumed) = parsed.unwrap();
assert_eq!(resp.code, 250);
assert_eq!(resp.lines, vec!["First line", "Second line"]);
assert_eq!(consumed, input.len());
}
#[test]
fn try_parse_response_tolerates_mixed_crlf_and_bare_lf() {
let input = b"250-CRLF line\r\n250 bare LF line\n";
let result = SmtpConnection::try_parse_response(input);
assert!(
result.is_ok(),
"mixed CRLF/LF should be tolerated: {result:?}"
);
let parsed = result.unwrap();
assert!(parsed.is_some(), "should parse as complete");
let (resp, _consumed) = parsed.unwrap();
assert_eq!(resp.code, 250);
assert_eq!(resp.lines.len(), 2);
}
#[test]
fn try_parse_response_bare_lf_before_crlf() {
let buf = b"250-Hello\n250 OK\r\n";
let result = SmtpConnection::try_parse_response(buf).unwrap().unwrap();
let (resp, consumed) = result;
assert_eq!(resp.code, 250, "reply code should be 250");
assert_eq!(consumed, buf.len(), "should consume the entire buffer");
assert_eq!(
resp.lines.len(),
2,
"should parse two lines, not merge them into one; got: {:?}",
resp.lines
);
assert_eq!(resp.lines[0], "Hello");
assert_eq!(resp.lines[1], "OK");
}
fn smtp_caps_with_pipelining_and_chunking() -> ServerCapabilities {
ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Pipelining, SmtpExtension::Chunking],
}
}
#[tokio::test]
async fn pipelined_bdat_all_recipients_ok() {
let stream = mock_server(vec![
"250 OK\r\n",
"250 OK\r\n",
"250 OK\r\n",
"250 Message accepted\r\n",
])
.await;
let conn = SmtpConnection::from_plain(
stream,
smtp_caps_with_pipelining_and_chunking(),
Protocol::Smtp,
);
let result = conn
.send_bdat(
&ReversePath::new("sender@example.com").unwrap(),
&[
ForwardPath::new("rcpt1@example.com").unwrap(),
ForwardPath::new("rcpt2@example.com").unwrap(),
],
b"Subject: Test\r\n\r\nHello",
None,
Duration::from_secs(5),
)
.await;
assert!(result.is_ok(), "expected success, got: {result:?}");
let send_result = result.unwrap();
assert!(
send_result.all_accepted(),
"all recipients should be accepted"
);
}
#[tokio::test]
async fn pipelined_bdat_sends_all_commands_in_single_write() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = vec![0u8; 8192];
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0, "expected data from client");
let text = String::from_utf8_lossy(&buf[..n]);
assert!(
text.contains("MAIL FROM:"),
"pipelined write must contain MAIL FROM; got: {text}"
);
assert!(
text.contains("RCPT TO:"),
"pipelined write must contain RCPT TO; got: {text}"
);
assert!(
text.contains("BDAT "),
"pipelined write must contain BDAT; got: {text}"
);
assert!(
text.contains("LAST"),
"pipelined write must contain LAST; got: {text}"
);
assert!(
text.contains("Hello"),
"pipelined write must contain message body; got: {text}"
);
socket
.write_all(b"250 OK\r\n250 OK\r\n250 Message accepted\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let conn = SmtpConnection::from_plain(
stream,
smtp_caps_with_pipelining_and_chunking(),
Protocol::Smtp,
);
let result = conn
.send_bdat(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello",
None,
Duration::from_secs(5),
)
.await;
assert!(result.is_ok(), "pipelined BDAT should succeed: {result:?}");
server.await.unwrap();
}
#[tokio::test]
async fn pipelined_bdat_mail_from_failure() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 8192];
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0);
socket
.write_all(b"550 Bad sender\r\n250 OK\r\n250 OK\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
accumulated.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&accumulated);
if text.contains("RSET") {
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let conn = SmtpConnection::from_plain(
stream,
smtp_caps_with_pipelining_and_chunking(),
Protocol::Smtp,
);
let result = conn
.send_bdat(
&ReversePath::new("bad@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello",
None,
Duration::from_secs(5),
)
.await;
assert!(result.is_err());
match result.unwrap_err() {
Error::Permanent { code, .. } => assert_eq!(code, 550),
other => panic!("expected Permanent error, got: {other:?}"),
}
server.await.unwrap();
}
#[tokio::test]
async fn pipelined_bdat_all_rcpt_tos_fail() {
let stream = mock_server(vec![
"250 OK\r\n",
"550 User unknown\r\n",
"550 User unknown\r\n",
"250 OK\r\n",
"250 OK\r\n",
])
.await;
let conn = SmtpConnection::from_plain(
stream,
smtp_caps_with_pipelining_and_chunking(),
Protocol::Smtp,
);
let result = conn
.send_bdat(
&ReversePath::new("sender@example.com").unwrap(),
&[
ForwardPath::new("bad1@example.com").unwrap(),
ForwardPath::new("bad2@example.com").unwrap(),
],
b"Subject: Test\r\n\r\nHello",
None,
Duration::from_secs(5),
)
.await;
assert!(result.is_err());
match result.unwrap_err() {
Error::AllRecipientsFailed { count, responses } => {
assert_eq!(count, 2, "should report 2 recipients");
assert_eq!(responses.len(), 2, "should have 2 rejection responses");
}
other => panic!("expected AllRecipientsFailed, got: {other:?}"),
}
}
#[tokio::test]
async fn pipelined_bdat_partial_rcpt_rejection() {
let stream = mock_server(vec![
"250 OK\r\n",
"250 OK\r\n",
"550 User unknown\r\n",
"250 Message accepted\r\n",
])
.await;
let conn = SmtpConnection::from_plain(
stream,
smtp_caps_with_pipelining_and_chunking(),
Protocol::Smtp,
);
let result = conn
.send_bdat(
&ReversePath::new("sender@example.com").unwrap(),
&[
ForwardPath::new("good@example.com").unwrap(),
ForwardPath::new("bad@example.com").unwrap(),
],
b"Subject: Test\r\n\r\nHello",
None,
Duration::from_secs(5),
)
.await;
let send_result = result.expect("send_bdat should succeed with partial rejection");
assert!(
send_result.has_rejections(),
"SendResult must report partial RCPT TO rejections"
);
assert_eq!(
send_result.rejected_recipients.len(),
1,
"exactly one recipient should be rejected"
);
assert_eq!(
send_result.rejected_recipients[0].recipient.as_str(),
"bad@example.com",
);
assert_eq!(send_result.rejected_recipients[0].response.code, 550);
}
#[tokio::test]
async fn pipelined_bdat_last_failure_sends_rset() {
use tokio::io::AsyncReadExt;
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 8192];
let n = socket.read(&mut buf).await.unwrap();
assert!(n > 0);
socket
.write_all(b"250 OK\r\n250 OK\r\n452 Insufficient storage\r\n")
.await
.unwrap();
socket.flush().await.unwrap();
let mut accumulated = Vec::new();
loop {
let n = socket.read(&mut buf).await.unwrap();
accumulated.extend_from_slice(&buf[..n]);
let text = String::from_utf8_lossy(&accumulated);
if text.contains("RSET") {
break;
}
}
socket.write_all(b"250 OK\r\n").await.unwrap();
socket.flush().await.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
});
let stream = TcpStream::connect(addr).await.unwrap();
let conn = SmtpConnection::from_plain(
stream,
smtp_caps_with_pipelining_and_chunking(),
Protocol::Smtp,
);
let result = conn
.send_bdat(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello",
None,
Duration::from_secs(5),
)
.await;
assert!(result.is_err());
match result.unwrap_err() {
Error::Transient { code, .. } => assert_eq!(code, 452),
other => panic!("expected Transient error (452), got: {other:?}"),
}
server.await.unwrap();
}
#[tokio::test]
async fn bdat_without_pipelining_uses_sequential_path() {
let stream = mock_interactive_server(vec![
("MAIL FROM:", "250 OK\r\n"),
("RCPT TO:", "250 OK\r\n"),
("BDAT ", "250 OK\r\n"),
])
.await;
let caps = ServerCapabilities {
greeting_name: "test".into(),
extensions: vec![SmtpExtension::Chunking],
};
let conn = SmtpConnection::from_plain(stream, caps, Protocol::Smtp);
let result = conn
.send_bdat(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("rcpt@example.com").unwrap()],
b"Subject: Test\r\n\r\nHello",
None,
Duration::from_secs(5),
)
.await;
assert!(result.is_ok(), "sequential BDAT should succeed: {result:?}");
}
#[tokio::test]
async fn is_authenticated_returns_false_initially() {
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
assert!(
!conn.is_authenticated().await,
"new connection must not be authenticated (RFC 4954 Section 3)"
);
}
#[tokio::test]
async fn is_authenticated_returns_true_after_auth() {
let stream = mock_server(vec![]).await;
let conn = SmtpConnection::from_plain(stream, smtp_caps_no_pipelining(), Protocol::Smtp);
conn.inner.lock().await.authenticated = true;
assert!(
conn.is_authenticated().await,
"connection must report authenticated after AUTH success (RFC 4954 Section 3)"
);
}