#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::sync::Arc;
use std::time::Duration;
use daaki_smtp::{
AuthMechanism, BodyType, Error, ForwardPath, MailFromParams, Protocol, ReversePath,
SmtpConnection, TlsMode,
};
const TIMEOUT: Duration = Duration::from_secs(10);
fn test_message(id: &str) -> Vec<u8> {
format!(
"From: sender@example.com\r\n\
To: recipient@example.com\r\n\
Subject: SMTP integration test {id}\r\n\
Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
Message-ID: <{id}@daaki>\r\n\
MIME-Version: 1.0\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
Hello from daaki SMTP integration tests.\r\n"
)
.into_bytes()
}
mod mailpit {
pub const HOST: &str = "127.0.0.1";
pub const SMTP_PORT: u16 = 11025;
pub const _HTTP_PORT: u16 = 18025;
pub const USER: &str = "testuser";
pub const PASS: &str = "testpass";
}
mod greenmail {
pub const HOST: &str = "127.0.0.1";
pub const SMTP_PORT: u16 = 13025;
pub const SMTPS_PORT: u16 = 13465;
pub const USER: &str = "testuser";
pub const PASS: &str = "testpass";
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn mailpit_connect_and_auth() {
let conn = SmtpConnection::connect(mailpit::HOST, mailpit::SMTP_PORT, TlsMode::None, TIMEOUT)
.await
.unwrap();
conn.auth_plain(mailpit::USER, mailpit::PASS, TIMEOUT)
.await
.unwrap();
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_connect_and_auth() {
let conn = SmtpConnection::connect(
greenmail::HOST,
greenmail::SMTP_PORT,
TlsMode::None,
TIMEOUT,
)
.await
.unwrap();
conn.auth_plain(greenmail::USER, greenmail::PASS, TIMEOUT)
.await
.unwrap();
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_connect_implicit_tls() {
let tls_config = build_danger_tls_config();
let conn = SmtpConnection::connect_with_tls_config(
greenmail::HOST,
greenmail::SMTPS_PORT,
TlsMode::Implicit,
TIMEOUT,
tls_config,
)
.await
.unwrap();
conn.auth_plain(greenmail::USER, greenmail::PASS, TIMEOUT)
.await
.unwrap();
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn mailpit_send_simple() {
let conn = connect_mailpit().await;
let msg = test_message("mailpit-simple");
conn.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("recipient@example.com").unwrap()],
&msg,
TIMEOUT,
)
.await
.unwrap();
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn mailpit_send_multiple_recipients() {
let conn = connect_mailpit().await;
let msg = test_message("mailpit-multi");
conn.send(
&ReversePath::new("sender@example.com").unwrap(),
&[
ForwardPath::new("alice@example.com").unwrap(),
ForwardPath::new("bob@example.com").unwrap(),
ForwardPath::new("carol@example.com").unwrap(),
],
&msg,
TIMEOUT,
)
.await
.unwrap();
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_send_simple() {
let conn = connect_greenmail().await;
let msg = test_message("greenmail-simple");
conn.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("recipient@example.com").unwrap()],
&msg,
TIMEOUT,
)
.await
.unwrap();
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn mailpit_send_8bit_content() {
let conn = connect_mailpit().await;
let msg = "From: sender@example.com\r\n\
To: recipient@example.com\r\n\
Subject: 8-bit test\r\n\
Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
Message-ID: <8bit@daaki>\r\n\
MIME-Version: 1.0\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
Content-Transfer-Encoding: 8bit\r\n\
\r\n\
Héllo wörld! 你好世界 🌍\r\n";
conn.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("recipient@example.com").unwrap()],
msg.as_bytes(),
TIMEOUT,
)
.await
.unwrap();
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn mailpit_rset_between_sends() {
let conn = connect_mailpit().await;
let msg1 = test_message("mailpit-rset-1");
conn.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("recipient@example.com").unwrap()],
&msg1,
TIMEOUT,
)
.await
.unwrap();
conn.reset(TIMEOUT).await.unwrap();
let msg2 = test_message("mailpit-rset-2");
conn.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("other@example.com").unwrap()],
&msg2,
TIMEOUT,
)
.await
.unwrap();
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn mailpit_send_dot_stuffed() {
let conn = connect_mailpit().await;
let msg = "From: sender@example.com\r\n\
To: recipient@example.com\r\n\
Subject: dot-stuff test\r\n\
Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
Message-ID: <dot-stuff@daaki>\r\n\
MIME-Version: 1.0\r\n\
Content-Type: text/plain\r\n\
\r\n\
Normal line.\r\n\
.This line starts with a dot.\r\n\
..Two dots.\r\n\
...Three dots.\r\n\
End.\r\n";
conn.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("recipient@example.com").unwrap()],
msg.as_bytes(),
TIMEOUT,
)
.await
.unwrap();
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn mailpit_send_empty_body() {
let conn = connect_mailpit().await;
let msg = "From: sender@example.com\r\n\
To: recipient@example.com\r\n\
Subject: empty body\r\n\
Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
Message-ID: <empty@daaki>\r\n\
\r\n";
conn.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("recipient@example.com").unwrap()],
msg.as_bytes(),
TIMEOUT,
)
.await
.unwrap();
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn mailpit_quit_immediately() {
let conn = SmtpConnection::connect(mailpit::HOST, mailpit::SMTP_PORT, TlsMode::None, TIMEOUT)
.await
.unwrap();
conn.auth_plain(mailpit::USER, mailpit::PASS, TIMEOUT)
.await
.unwrap();
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_quit_immediately() {
let conn = SmtpConnection::connect(
greenmail::HOST,
greenmail::SMTP_PORT,
TlsMode::None,
TIMEOUT,
)
.await
.unwrap();
conn.auth_plain(greenmail::USER, greenmail::PASS, TIMEOUT)
.await
.unwrap();
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn mailpit_noop_keepalive() {
let conn = connect_mailpit().await;
conn.noop(TIMEOUT).await.unwrap();
let msg = test_message("mailpit-noop");
conn.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("recipient@example.com").unwrap()],
&msg,
TIMEOUT,
)
.await
.unwrap();
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn mailpit_capabilities_advertised() {
let conn = SmtpConnection::connect(mailpit::HOST, mailpit::SMTP_PORT, TlsMode::None, TIMEOUT)
.await
.unwrap();
let caps = conn.capabilities().await;
assert!(
!caps.greeting_name().is_empty(),
"EHLO greeting name must not be empty"
);
assert!(
caps.supports_auth_extension(),
"server must advertise AUTH when authentication is configured"
);
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn mailpit_protocol_is_smtp() {
let conn = SmtpConnection::connect(mailpit::HOST, mailpit::SMTP_PORT, TlsMode::None, TIMEOUT)
.await
.unwrap();
assert_eq!(conn.protocol(), Protocol::Smtp);
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn mailpit_is_authenticated_transitions() {
let conn = SmtpConnection::connect(mailpit::HOST, mailpit::SMTP_PORT, TlsMode::None, TIMEOUT)
.await
.unwrap();
assert!(
!conn.is_authenticated().await,
"must not be authenticated before AUTH"
);
conn.auth_plain(mailpit::USER, mailpit::PASS, TIMEOUT)
.await
.unwrap();
assert!(
conn.is_authenticated().await,
"must be authenticated after successful AUTH PLAIN"
);
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn mailpit_help_command() {
let conn = connect_mailpit().await;
let resp = conn.help(None, TIMEOUT).await.unwrap();
assert!(
resp.code == 211 || resp.code == 214 || resp.code == 502,
"HELP must return 211, 214, or 502; got {}",
resp.code
);
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn mailpit_vrfy_command() {
let conn = connect_mailpit().await;
let resp = conn.vrfy("testuser", TIMEOUT).await.unwrap();
assert!(
[250, 251, 252, 502, 550, 553].contains(&resp.code),
"VRFY must return 250/251/252/502/550/553; got {}",
resp.code
);
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn mailpit_rehlo_refreshes_capabilities() {
let conn = connect_mailpit().await;
let caps_before = conn.capabilities().await;
conn.rehlo(TIMEOUT).await.unwrap();
let caps_after = conn.capabilities().await;
assert!(
!caps_after.greeting_name().is_empty(),
"greeting name must remain present after REHLO"
);
assert_eq!(
caps_before.greeting_name(),
caps_after.greeting_name(),
"greeting name should be consistent across EHLO exchanges"
);
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_auth_login() {
let conn = SmtpConnection::connect(
greenmail::HOST,
greenmail::SMTP_PORT,
TlsMode::None,
TIMEOUT,
)
.await
.unwrap();
let caps = conn.capabilities().await;
if !caps.supports_auth(&AuthMechanism::Login) {
eprintln!("skipping: GreenMail does not advertise AUTH LOGIN");
conn.quit(TIMEOUT).await.unwrap();
return;
}
conn.auth_login(greenmail::USER, greenmail::PASS, TIMEOUT)
.await
.unwrap();
assert!(
conn.is_authenticated().await,
"must be authenticated after AUTH LOGIN"
);
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_invalid_credentials_rejected() {
let conn = SmtpConnection::connect(
greenmail::HOST,
greenmail::SMTP_PORT,
TlsMode::None,
TIMEOUT,
)
.await
.unwrap();
let result = conn
.auth_plain(greenmail::USER, "wrong-password", TIMEOUT)
.await;
assert!(
matches!(result, Err(Error::Auth { .. })),
"bad credentials must produce Error::Auth, got {result:?}"
);
let _ = conn.quit(TIMEOUT).await;
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn mailpit_send_result_all_accepted() {
let conn = connect_mailpit().await;
let msg = test_message("mailpit-sendresult");
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("recipient@example.com").unwrap()],
&msg,
TIMEOUT,
)
.await
.unwrap();
assert!(
result.all_accepted(),
"all recipients must be accepted; rejections: {:?}",
result.rejected_recipients
);
assert!(
!result.has_rejections(),
"must have no rejections for a valid recipient"
);
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn mailpit_send_null_reverse_path() {
let conn = connect_mailpit().await;
let null_sender = ReversePath::new("").unwrap();
let msg = test_message("mailpit-null-sender");
let result = conn
.send(
&null_sender,
&[ForwardPath::new("recipient@example.com").unwrap()],
&msg,
TIMEOUT,
)
.await
.unwrap();
assert!(
result.all_accepted(),
"null reverse-path send must be accepted; rejections: {:?}",
result.rejected_recipients
);
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn mailpit_send_large_message() {
let conn = connect_mailpit().await;
let body_line =
"This is a line of text for the large message test. ABCDEFGHIJKLMNOPQRSTUVWXYZ\r\n";
let body = body_line.repeat(1300); let msg = format!(
"From: sender@example.com\r\n\
To: recipient@example.com\r\n\
Subject: large message test\r\n\
Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
Message-ID: <large@daaki>\r\n\
MIME-Version: 1.0\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
\r\n\
{body}"
);
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("recipient@example.com").unwrap()],
msg.as_bytes(),
TIMEOUT,
)
.await
.unwrap();
assert!(
result.all_accepted(),
"large message send must be accepted; rejections: {:?}",
result.rejected_recipients
);
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn mailpit_multiple_sends_same_connection() {
let conn = connect_mailpit().await;
for i in 1..=3 {
let msg = test_message(&format!("mailpit-multi-send-{i}"));
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("recipient@example.com").unwrap()],
&msg,
TIMEOUT,
)
.await
.unwrap();
assert!(
result.all_accepted(),
"send {i}/3 must be accepted; rejections: {:?}",
result.rejected_recipients
);
}
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_starttls_upgrade() {
let tls_config = build_danger_tls_config();
let result = SmtpConnection::connect_with_tls_config(
greenmail::HOST,
greenmail::SMTP_PORT,
TlsMode::StartTls,
TIMEOUT,
tls_config,
)
.await;
match result {
Ok(conn) => {
let caps = conn.capabilities().await;
assert!(
!caps.greeting_name().is_empty(),
"greeting name must be present after STARTTLS EHLO"
);
conn.auth_plain(greenmail::USER, greenmail::PASS, TIMEOUT)
.await
.unwrap();
conn.quit(TIMEOUT).await.unwrap();
}
Err(Error::StartTlsUnavailable) => {
eprintln!("skipping: GreenMail plaintext port does not advertise STARTTLS");
}
Err(e) => panic!("unexpected error: {e:?}"),
}
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn mailpit_expn_command() {
let conn = connect_mailpit().await;
let resp = conn.expn("testlist", TIMEOUT).await.unwrap();
assert!(
[250, 502, 550].contains(&resp.code),
"EXPN must return 250/502/550; got {}",
resp.code
);
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn mailpit_help_with_topic() {
let conn = connect_mailpit().await;
let resp = conn.help(Some("MAIL"), TIMEOUT).await.unwrap();
assert!(
resp.code == 211 || resp.code == 214 || resp.code == 502,
"HELP MAIL must return 211, 214, or 502; got {}",
resp.code
);
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn mailpit_set_ehlo_domain_and_rehlo() {
let conn = SmtpConnection::connect(mailpit::HOST, mailpit::SMTP_PORT, TlsMode::None, TIMEOUT)
.await
.unwrap();
conn.set_ehlo_domain("test.example.com").await.unwrap();
conn.rehlo(TIMEOUT).await.unwrap();
let caps = conn.capabilities().await;
assert!(
!caps.greeting_name().is_empty(),
"greeting name must be present after REHLO with new domain"
);
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn mailpit_is_shutting_down_false() {
let conn = connect_mailpit().await;
assert!(
!conn.is_shutting_down().await,
"healthy connection must not report shutting down"
);
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn mailpit_capabilities_size_and_8bitmime() {
let conn = SmtpConnection::connect(mailpit::HOST, mailpit::SMTP_PORT, TlsMode::None, TIMEOUT)
.await
.unwrap();
let caps = conn.capabilities().await;
assert!(
caps.supports_size(),
"Mailpit must advertise the SIZE extension (RFC 1870)"
);
assert!(
caps.supports_8bitmime(),
"Mailpit must advertise 8BITMIME (RFC 6152)"
);
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_capabilities_inspection() {
let conn = SmtpConnection::connect(
greenmail::HOST,
greenmail::SMTP_PORT,
TlsMode::None,
TIMEOUT,
)
.await
.unwrap();
let caps = conn.capabilities().await;
assert!(
caps.supports_auth_extension(),
"GreenMail must advertise AUTH (RFC 4954)"
);
assert!(
caps.supports_auth(&AuthMechanism::Plain),
"GreenMail must advertise AUTH PLAIN"
);
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_send_over_implicit_tls() {
let tls_config = build_danger_tls_config();
let conn = SmtpConnection::connect_with_tls_config(
greenmail::HOST,
greenmail::SMTPS_PORT,
TlsMode::Implicit,
TIMEOUT,
tls_config,
)
.await
.unwrap();
conn.auth_plain(greenmail::USER, greenmail::PASS, TIMEOUT)
.await
.unwrap();
let msg = test_message("greenmail-implicit-tls");
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("recipient@example.com").unwrap()],
&msg,
TIMEOUT,
)
.await
.unwrap();
assert!(
result.all_accepted(),
"send over implicit TLS must accept all recipients; rejections: {:?}",
result.rejected_recipients
);
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_send_bdat_if_chunking_supported() {
let conn = connect_greenmail().await;
let caps = conn.capabilities().await;
if !caps.supports_chunking() {
eprintln!("skipping: GreenMail does not advertise CHUNKING");
conn.quit(TIMEOUT).await.unwrap();
return;
}
let msg = test_message("greenmail-bdat");
let result = conn
.send_bdat(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("recipient@example.com").unwrap()],
&msg,
None,
TIMEOUT,
)
.await
.unwrap();
assert!(
result.all_accepted(),
"BDAT send must accept all recipients; rejections: {:?}",
result.rejected_recipients
);
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn mailpit_send_with_body_8bitmime_param() {
let conn = connect_mailpit().await;
let caps = conn.capabilities().await;
if !caps.supports_8bitmime() {
eprintln!("skipping: Mailpit does not advertise 8BITMIME");
conn.quit(TIMEOUT).await.unwrap();
return;
}
let mut params = MailFromParams::default();
params.body = Some(BodyType::EightBitMime);
let msg = "From: sender@example.com\r\n\
To: recipient@example.com\r\n\
Subject: 8BITMIME param test\r\n\
Date: Sun, 15 Mar 2026 00:00:00 +0000\r\n\
Message-ID: <body-param@daaki>\r\n\
MIME-Version: 1.0\r\n\
Content-Type: text/plain; charset=utf-8\r\n\
Content-Transfer-Encoding: 8bit\r\n\
\r\n\
Héllo wörld — explicit BODY=8BITMIME.\r\n";
let result = conn
.send_with_params(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("recipient@example.com").unwrap()],
msg.as_bytes(),
Some(¶ms),
TIMEOUT,
)
.await
.unwrap();
assert!(
result.all_accepted(),
"send_with_params(BODY=8BITMIME) must accept; rejections: {:?}",
result.rejected_recipients
);
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_auth_error_is_permanent() {
let conn = SmtpConnection::connect(
greenmail::HOST,
greenmail::SMTP_PORT,
TlsMode::None,
TIMEOUT,
)
.await
.unwrap();
let result = conn
.auth_plain(greenmail::USER, "wrong-password", TIMEOUT)
.await;
match result {
Err(ref e) => {
assert!(
e.is_permanent(),
"bad-credentials error must be permanent (535); got {e:?}"
);
assert!(
!e.is_transient(),
"bad-credentials error must not be transient; got {e:?}"
);
}
Ok(()) => panic!("auth with bad password should fail"),
}
let _ = conn.quit(TIMEOUT).await;
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn mailpit_multiple_noops() {
let conn = connect_mailpit().await;
for _ in 0..5 {
conn.noop(TIMEOUT).await.unwrap();
}
let msg = test_message("mailpit-multi-noop");
conn.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("recipient@example.com").unwrap()],
&msg,
TIMEOUT,
)
.await
.unwrap();
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_multiple_sends_same_connection() {
let conn = connect_greenmail().await;
for i in 1..=3 {
let msg = test_message(&format!("greenmail-multi-send-{i}"));
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("recipient@example.com").unwrap()],
&msg,
TIMEOUT,
)
.await
.unwrap();
assert!(
result.all_accepted(),
"GreenMail send {i}/3 must be accepted; rejections: {:?}",
result.rejected_recipients
);
}
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_retry_auth_after_failure() {
let conn = SmtpConnection::connect(
greenmail::HOST,
greenmail::SMTP_PORT,
TlsMode::None,
TIMEOUT,
)
.await
.unwrap();
let bad_result = conn
.auth_plain(greenmail::USER, "wrong-password", TIMEOUT)
.await;
assert!(bad_result.is_err(), "auth with wrong password must fail");
assert!(
!conn.is_authenticated().await,
"must not be authenticated after failed auth"
);
conn.auth_plain(greenmail::USER, greenmail::PASS, TIMEOUT)
.await
.unwrap();
assert!(
conn.is_authenticated().await,
"must be authenticated after successful retry"
);
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_send_with_all_params_rejects_length_mismatch() {
use daaki_smtp::RcptToParams;
let conn = connect_greenmail().await;
let from = ReversePath::new("sender@example.com").unwrap();
let to = vec![ForwardPath::new("recipient@example.com").unwrap()];
let msg = test_message("all-params-mismatch");
let result = conn
.send_with_all_params(&from, &to, &msg, None, &[], TIMEOUT)
.await;
assert!(
result.is_err(),
"send_with_all_params must reject length mismatch (0 params, 1 recipient)"
);
let rcpt_params = vec![RcptToParams::default()];
let msg2 = test_message("all-params-default-rcpt");
let result2 = conn
.send_with_all_params(&from, &to, &msg2, None, &rcpt_params, TIMEOUT)
.await
.unwrap();
assert!(
result2.all_accepted(),
"send with matching default RcptToParams should succeed"
);
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_auth_plain_with_authzid_same_as_user() {
let conn = SmtpConnection::connect(
greenmail::HOST,
greenmail::SMTP_PORT,
TlsMode::None,
TIMEOUT,
)
.await
.unwrap();
conn.auth_plain_with_authzid(greenmail::USER, greenmail::USER, greenmail::PASS, TIMEOUT)
.await
.unwrap();
assert!(
conn.is_authenticated().await,
"auth_plain_with_authzid should authenticate successfully"
);
let from = ReversePath::new("sender@example.com").unwrap();
let to = vec![ForwardPath::new("recipient@example.com").unwrap()];
let msg = test_message("authzid-send");
let result = conn.send(&from, &to, &msg, TIMEOUT).await.unwrap();
assert!(result.all_accepted());
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_send_to_postmaster() {
let conn = connect_greenmail().await;
let from = ReversePath::new("sender@example.com").unwrap();
let to = vec![ForwardPath::Postmaster];
let msg = test_message("postmaster-send");
let result = conn.send(&from, &to, &msg, TIMEOUT).await;
match result {
Ok(send_result) => {
assert!(
send_result.all_accepted(),
"Postmaster should be accepted if the server handles it"
);
}
Err(
Error::Permanent { .. } | Error::Transient { .. } | Error::AllRecipientsFailed { .. },
) => {
}
Err(e) => {
panic!("unexpected error sending to Postmaster (library bug?): {e:?}");
}
}
let _ = conn.quit(TIMEOUT).await;
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_send_after_quit_fails() {
let conn = connect_greenmail().await;
conn.quit(TIMEOUT).await.unwrap();
let from = ReversePath::new("sender@example.com").unwrap();
let to = vec![ForwardPath::new("recipient@example.com").unwrap()];
let msg = test_message("after-quit");
let result = conn.send(&from, &to, &msg, TIMEOUT).await;
assert!(
result.is_err(),
"send after QUIT must fail — server closed the connection (RFC 5321 Section 4.1.1.10)"
);
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_send_with_size_param() {
let conn = connect_greenmail().await;
let caps = conn.capabilities().await;
if !caps.supports_size() {
eprintln!("skipping: GreenMail does not advertise SIZE");
conn.quit(TIMEOUT).await.unwrap();
return;
}
let from = ReversePath::new("sender@example.com").unwrap();
let to = vec![ForwardPath::new("recipient@example.com").unwrap()];
let msg = test_message("size-param");
let mut params = MailFromParams::default();
params.size = Some(msg.len() as u64);
let result = conn
.send_with_params(&from, &to, &msg, Some(¶ms), TIMEOUT)
.await
.unwrap();
assert!(
result.all_accepted(),
"send with SIZE param should succeed when server advertises SIZE"
);
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_custom_ehlo_domain_and_send() {
let conn = connect_greenmail().await;
conn.set_ehlo_domain("custom-test.example.com")
.await
.unwrap();
conn.rehlo(TIMEOUT).await.unwrap();
let caps = conn.capabilities().await;
assert!(
!caps.greeting_name().is_empty(),
"rehlo must succeed and produce a non-empty server greeting"
);
let from = ReversePath::new("sender@example.com").unwrap();
let to = vec![ForwardPath::new("recipient@example.com").unwrap()];
let msg = test_message("custom-ehlo-send");
let result = conn.send(&from, &to, &msg, TIMEOUT).await.unwrap();
assert!(result.all_accepted());
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_noop_before_auth() {
let conn = SmtpConnection::connect(
greenmail::HOST,
greenmail::SMTP_PORT,
TlsMode::None,
TIMEOUT,
)
.await
.unwrap();
conn.noop(TIMEOUT).await.unwrap();
assert!(
!conn.is_authenticated().await,
"NOOP should not change authentication state"
);
conn.auth_plain(greenmail::USER, greenmail::PASS, TIMEOUT)
.await
.unwrap();
let from = ReversePath::new("sender@example.com").unwrap();
let to = vec![ForwardPath::new("recipient@example.com").unwrap()];
let msg = test_message("noop-before-auth");
let result = conn.send(&from, &to, &msg, TIMEOUT).await.unwrap();
assert!(result.all_accepted());
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_multiple_reset_send_cycles() {
let conn = connect_greenmail().await;
let from = ReversePath::new("sender@example.com").unwrap();
let to = vec![ForwardPath::new("recipient@example.com").unwrap()];
for i in 0..5 {
conn.reset(TIMEOUT).await.unwrap();
let msg = test_message(&format!("reset-cycle-{i}"));
let result = conn.send(&from, &to, &msg, TIMEOUT).await.unwrap();
assert!(
result.all_accepted(),
"send in cycle {i} should succeed after RSET"
);
}
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_send_bdat_with_all_params_default() {
use daaki_smtp::RcptToParams;
let conn = connect_greenmail().await;
let caps = conn.capabilities().await;
if !caps.supports_chunking() {
conn.quit(TIMEOUT).await.unwrap();
return;
}
let from = ReversePath::new("sender@example.com").unwrap();
let to = vec![ForwardPath::new("recipient@example.com").unwrap()];
let rcpt_params = vec![RcptToParams::default()];
let msg = test_message("bdat-all-params");
let result = conn
.send_bdat_with_all_params(&from, &to, &msg, None, &rcpt_params, TIMEOUT)
.await
.unwrap();
assert!(
result.all_accepted(),
"BDAT with default RcptToParams should succeed"
);
assert!(
!result.has_rejections(),
"BDAT with default RcptToParams should have no rejections"
);
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn mailpit_send_with_params_body_and_size() {
let conn = connect_mailpit().await;
let msg = test_message("params-body-size");
let mut params = MailFromParams::default();
params.body = Some(BodyType::EightBitMime);
params.size = Some(msg.len() as u64);
let result = conn
.send_with_params(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("recipient@example.com").unwrap()],
&msg,
Some(¶ms),
TIMEOUT,
)
.await
.unwrap();
assert!(
result.all_accepted(),
"send with BODY + SIZE params should succeed"
);
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn mailpit_capability_supports_methods() {
let conn = connect_mailpit().await;
let caps = conn.capabilities().await;
assert!(
!caps.greeting_name().is_empty(),
"greeting name from EHLO should be non-empty"
);
assert!(
!caps.extensions().is_empty(),
"EHLO should advertise at least one extension"
);
if caps.supports_pipelining() {
assert!(
caps.extensions()
.iter()
.any(|e| matches!(e, daaki_smtp::SmtpExtension::Pipelining)),
"supports_pipelining() true but PIPELINING not in extensions list"
);
}
if caps.supports_size() {
assert!(
caps.extensions()
.iter()
.any(|e| matches!(e, daaki_smtp::SmtpExtension::Size(_))),
"supports_size() true but SIZE not in extensions list"
);
}
assert!(
caps.supports_auth_extension(),
"AUTH extension should be advertised (we authenticated)"
);
assert!(
caps.supports_auth(&AuthMechanism::Plain),
"AUTH PLAIN should be supported (we used it to authenticate)"
);
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_auth_login_then_send() {
let conn = SmtpConnection::connect(
greenmail::HOST,
greenmail::SMTP_PORT,
TlsMode::None,
TIMEOUT,
)
.await
.unwrap();
conn.auth_login(greenmail::USER, greenmail::PASS, TIMEOUT)
.await
.unwrap();
assert!(
conn.is_authenticated().await,
"should be authenticated after AUTH LOGIN"
);
let msg = test_message("login-then-send");
let result = conn
.send(
&ReversePath::new("sender@example.com").unwrap(),
&[ForwardPath::new("recipient@example.com").unwrap()],
&msg,
TIMEOUT,
)
.await
.unwrap();
assert!(
result.all_accepted(),
"send after AUTH LOGIN should succeed"
);
conn.quit(TIMEOUT).await.unwrap();
}
#[tokio::test]
#[ignore = "requires Docker: docker compose up -d"]
async fn greenmail_send_with_dsn_notify_param() {
use daaki_smtp::{DsnNotify, RcptToParams};
let conn = connect_greenmail().await;
let caps = conn.capabilities().await;
let from = ReversePath::new("sender@example.com").unwrap();
let to = vec![ForwardPath::new("recipient@example.com").unwrap()];
let msg = test_message("dsn-notify");
let mut rtp = RcptToParams::default();
rtp.notify = Some(vec![DsnNotify::Success, DsnNotify::Failure]);
let rcpt_params = vec![rtp];
let result = conn
.send_with_all_params(&from, &to, &msg, None, &rcpt_params, TIMEOUT)
.await;
if caps.supports_dsn() {
let send_result =
result.expect("send with DSN params should succeed when DSN is supported");
assert!(
send_result.all_accepted(),
"send with DSN NOTIFY should succeed"
);
} else {
if let Ok(send_result) = result {
assert!(
send_result.all_accepted(),
"server accepted DSN params despite not advertising DSN"
);
}
}
conn.quit(TIMEOUT).await.unwrap();
}
async fn connect_mailpit() -> SmtpConnection {
let conn = SmtpConnection::connect(mailpit::HOST, mailpit::SMTP_PORT, TlsMode::None, TIMEOUT)
.await
.unwrap();
conn.auth_plain(mailpit::USER, mailpit::PASS, TIMEOUT)
.await
.unwrap();
conn
}
async fn connect_greenmail() -> SmtpConnection {
let conn = SmtpConnection::connect(
greenmail::HOST,
greenmail::SMTP_PORT,
TlsMode::None,
TIMEOUT,
)
.await
.unwrap();
conn.auth_plain(greenmail::USER, greenmail::PASS, TIMEOUT)
.await
.unwrap();
conn
}
fn build_danger_tls_config() -> Arc<rustls::ClientConfig> {
mod danger {
#[derive(Debug)]
pub struct AcceptAll;
impl rustls::client::danger::ServerCertVerifier for AcceptAll {
fn verify_server_cert(
&self,
_end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_server_name: &rustls::pki_types::ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls::pki_types::UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &rustls::pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error>
{
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &rustls::pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error>
{
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
rustls::crypto::ring::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
}
}
let _ = rustls::crypto::ring::default_provider().install_default();
let config = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(danger::AcceptAll))
.with_no_client_auth();
Arc::new(config)
}