mod common;
use std::sync::Arc;
use common::mock_smtp::{Behavior, ensure_crypto_provider, skip_verify_client_config, spawn_mock_smtp};
use common::pg::start_pg;
use mailrs_outbound_queue::queue::{self, QueueStatus};
use mailrs_outbound_queue::worker::{
DeliveryWorker, TlsPolicy, WorkerConfig, deliver_domain_static, try_deliver_via_mx,
try_deliver_via_mx_with_tls,
};
use mailrs_outbound_queue::PgQueueStore;
use mailrs_outbound_queue::store::QueueStore;
use mailrs_smtp_client::TokioResolver;
fn resolver() -> TokioResolver {
TokioResolver::builder_tokio()
.expect("hickory builder_tokio")
.build()
.expect("hickory resolver build")
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn try_deliver_via_mx_happy_path() {
ensure_crypto_provider();
let mock = spawn_mock_smtp(Behavior::AcceptNoStarttls).await;
let (_c, pool) = start_pg().await;
let id = queue::enqueue_ex(&pool, "s@example.com", "r@dest.com", "dest.com", b"Subject: hi\r\n\r\nbody\r\n", None, 0, false)
.await
.unwrap();
let msg = queue::get_message(&pool, id).await.unwrap().unwrap();
let r = resolver();
let result = try_deliver_via_mx(
"client.test",
"127.0.0.1",
mock.addr.port(),
"dest.com",
std::slice::from_ref(&msg),
&r,
None,
)
.await;
assert!(result.is_ok(), "happy delivery should succeed: {result:?}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn try_deliver_via_mx_5xx_after_rcpt_returns_err() {
ensure_crypto_provider();
let mock = spawn_mock_smtp(Behavior::Reject5xxAfterRcpt).await;
let (_c, pool) = start_pg().await;
let id = queue::enqueue_ex(&pool, "s@example.com", "r@dest.com", "dest.com", b"body\r\n", None, 0, false)
.await
.unwrap();
let msg = queue::get_message(&pool, id).await.unwrap().unwrap();
let r = resolver();
let result = try_deliver_via_mx(
"client.test",
"127.0.0.1",
mock.addr.port(),
"dest.com",
std::slice::from_ref(&msg),
&r,
None,
)
.await;
assert!(result.is_err(), "5xx after RCPT must surface as Err");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn try_deliver_via_mx_close_mid_data_returns_err() {
ensure_crypto_provider();
let mock = spawn_mock_smtp(Behavior::CloseMidData).await;
let (_c, pool) = start_pg().await;
let id = queue::enqueue_ex(
&pool,
"s@example.com",
"r@dest.com",
"dest.com",
&vec![b'x'; 4096],
None,
0,
false,
)
.await
.unwrap();
let msg = queue::get_message(&pool, id).await.unwrap().unwrap();
let r = resolver();
let result = try_deliver_via_mx(
"client.test",
"127.0.0.1",
mock.addr.port(),
"dest.com",
std::slice::from_ref(&msg),
&r,
None,
)
.await;
assert!(result.is_err(), "close-mid-DATA must surface as Err");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn try_deliver_via_mx_starttls_rejected_falls_back_to_plain() {
ensure_crypto_provider();
let mock = spawn_mock_smtp(Behavior::StarttlsHandshakeFail).await;
let (_c, pool) = start_pg().await;
let id = queue::enqueue_ex(&pool, "s@example.com", "r@dest.com", "dest.com", b"body\r\n", None, 0, false)
.await
.unwrap();
let msg = queue::get_message(&pool, id).await.unwrap().unwrap();
let r = resolver();
let result = try_deliver_via_mx(
"client.test",
"127.0.0.1",
mock.addr.port(),
"dest.com",
std::slice::from_ref(&msg),
&r,
None,
)
.await;
let _ = result;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn deliver_domain_static_happy_marks_delivered() {
ensure_crypto_provider();
let mock = spawn_mock_smtp(Behavior::AcceptNoStarttls).await;
let (_c, pool) = start_pg().await;
let id = queue::enqueue_ex(&pool, "s@example.com", "r@127.0.0.1", "127.0.0.1", b"body\r\n", None, 0, false)
.await
.unwrap();
let claimed = queue::claim_for_delivery(&pool, 0, 10).await.unwrap();
assert_eq!(claimed.len(), 1);
let r = resolver();
deliver_domain_static(
&r,
"client.test",
"127.0.0.1",
claimed,
&pool,
mock.addr.port(),
50,
None,
)
.await;
let after = queue::get_message(&pool, id).await.unwrap().unwrap();
assert_eq!(after.status, QueueStatus::Delivered, "happy path marks delivered");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn deliver_domain_static_5xx_marks_failed_for_retry() {
ensure_crypto_provider();
let mock = spawn_mock_smtp(Behavior::Reject5xxAfterRcpt).await;
let (_c, pool) = start_pg().await;
let id = queue::enqueue_ex(&pool, "s@example.com", "r@127.0.0.1", "127.0.0.1", b"body\r\n", None, 0, false)
.await
.unwrap();
let claimed = queue::claim_for_delivery(&pool, 0, 10).await.unwrap();
let r = resolver();
deliver_domain_static(
&r,
"client.test",
"127.0.0.1",
claimed,
&pool,
mock.addr.port(),
50,
None,
)
.await;
let after = queue::get_message(&pool, id).await.unwrap().unwrap();
assert_eq!(after.status, QueueStatus::Pending);
assert_eq!(after.attempts, 1);
assert!(after.last_error.is_some());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn deliver_domain_static_5xx_at_max_attempts_bounces() {
ensure_crypto_provider();
let mock = spawn_mock_smtp(Behavior::Reject5xxAfterMail).await;
let (_c, pool) = start_pg().await;
let id = queue::enqueue_ex(&pool, "s@example.com", "r@127.0.0.1", "127.0.0.1", b"body\r\n", None, 0, false)
.await
.unwrap();
sqlx::query("UPDATE outbound_queue SET attempts = max_attempts WHERE id = $1")
.bind(id)
.execute(&pool)
.await
.unwrap();
let claimed = queue::claim_for_delivery(&pool, 0, 10).await.unwrap();
let r = resolver();
deliver_domain_static(
&r,
"client.test",
"127.0.0.1",
claimed,
&pool,
mock.addr.port(),
50,
None,
)
.await;
let after = queue::get_message(&pool, id).await.unwrap().unwrap();
assert_eq!(after.status, QueueStatus::Bounced, "max attempts reached → bounced");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn try_deliver_via_mx_ehlo_rejected_returns_err() {
ensure_crypto_provider();
let mock = spawn_mock_smtp(Behavior::EhloRejected).await;
let (_c, pool) = start_pg().await;
let id = queue::enqueue_ex(&pool, "s@example.com", "r@dest.com", "dest.com", b"body\r\n", None, 0, false)
.await
.unwrap();
let msg = queue::get_message(&pool, id).await.unwrap().unwrap();
let r = resolver();
let result = try_deliver_via_mx(
"client.test",
"127.0.0.1",
mock.addr.port(),
"dest.com",
std::slice::from_ref(&msg),
&r,
None,
)
.await;
assert!(result.is_err(), "EHLO 500 must surface as Err");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn try_deliver_via_mx_starttls_rejected_falls_through_to_delivery() {
ensure_crypto_provider();
let mock = spawn_mock_smtp(Behavior::StarttlsRejected).await;
let (_c, pool) = start_pg().await;
let id = queue::enqueue_ex(&pool, "s@example.com", "r@dest.com", "dest.com", b"body\r\n", None, 0, false)
.await
.unwrap();
let msg = queue::get_message(&pool, id).await.unwrap().unwrap();
let r = resolver();
let result = try_deliver_via_mx(
"client.test",
"127.0.0.1",
mock.addr.port(),
"dest.com",
std::slice::from_ref(&msg),
&r,
None,
)
.await;
assert!(
result.is_ok(),
"STARTTLS-rejected under Opportunistic continues plain: {result:?}",
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn pg_queue_store_pool_helper_returns_pool() {
let (_c, pool) = start_pg().await;
let store = PgQueueStore::new(pool.clone());
let borrowed = store.pool();
let (one,): (i32,) = sqlx::query_as("SELECT 1")
.fetch_one(borrowed)
.await
.expect("SELECT 1 on borrowed pool");
assert_eq!(one, 1);
let _ = store.queue_stats().await.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn delivery_worker_run_drains_pending_via_full_pipeline() {
ensure_crypto_provider();
let mock = spawn_mock_smtp(Behavior::AcceptNoStarttls).await;
let (_c, pool) = start_pg().await;
let _ = mock;
queue::enqueue_ex(&pool, "s@example.com", "r@127.0.0.1", "127.0.0.1", b"body\r\n", None, 0, false)
.await
.unwrap();
let r = resolver();
let worker = DeliveryWorker::new(
WorkerConfig {
poll_interval_secs: 1,
batch_size: 10,
max_attempts: 8,
max_concurrent_domains: 2,
max_messages_per_connection: 5,
},
pool.clone(),
r,
"client.test".to_string(),
)
.with_valkey("redis://127.0.0.1:1".to_string());
let (tx, rx) = tokio::sync::watch::channel(false);
let run_handle = tokio::spawn(async move {
worker.run(rx).await;
});
tokio::time::sleep(std::time::Duration::from_millis(2500)).await;
tx.send(true).unwrap();
let _ = tokio::time::timeout(std::time::Duration::from_secs(3), run_handle).await;
let stats = queue::queue_stats(&pool).await.unwrap();
assert!(!stats.is_empty(), "worker ran and produced stats");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn try_deliver_via_mx_starttls_success_full_deliver() {
ensure_crypto_provider();
let mock = spawn_mock_smtp(Behavior::StarttlsAccept).await;
let (_c, pool) = start_pg().await;
let id = queue::enqueue_ex(&pool, "s@example.com", "r@dest.com", "dest.com", b"Subject: hi\r\n\r\nbody\r\n", None, 0, false)
.await
.unwrap();
let msg = queue::get_message(&pool, id).await.unwrap().unwrap();
let r = resolver();
let tls_config = Arc::new(skip_verify_client_config());
let result = try_deliver_via_mx_with_tls(
"client.test",
"127.0.0.1",
mock.addr.port(),
"dest.com",
std::slice::from_ref(&msg),
TlsPolicy::Opportunistic,
Some(tls_config),
&r,
None,
)
.await;
assert!(result.is_ok(), "STARTTLS-success path must complete deliver: {result:?}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn try_deliver_via_mx_require_policy_rejected_starttls_returns_err() {
ensure_crypto_provider();
let mock = spawn_mock_smtp(Behavior::StarttlsRejected).await;
let (_c, pool) = start_pg().await;
let id = queue::enqueue_ex(&pool, "s@example.com", "r@dest.com", "dest.com", b"body\r\n", None, 0, false)
.await
.unwrap();
let msg = queue::get_message(&pool, id).await.unwrap().unwrap();
let r = resolver();
let tls_config = Arc::new(skip_verify_client_config());
let result = try_deliver_via_mx_with_tls(
"client.test",
"127.0.0.1",
mock.addr.port(),
"dest.com",
std::slice::from_ref(&msg),
TlsPolicy::Require,
Some(tls_config),
&r,
None,
)
.await;
assert!(result.is_err(), "Require policy + STARTTLS rejected must be Err");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn try_deliver_via_mx_require_policy_handshake_fail_returns_err() {
ensure_crypto_provider();
let mock = spawn_mock_smtp(Behavior::StarttlsHandshakeFail).await;
let (_c, pool) = start_pg().await;
let id = queue::enqueue_ex(&pool, "s@example.com", "r@dest.com", "dest.com", b"body\r\n", None, 0, false)
.await
.unwrap();
let msg = queue::get_message(&pool, id).await.unwrap().unwrap();
let r = resolver();
let tls_config = Arc::new(skip_verify_client_config());
let result = try_deliver_via_mx_with_tls(
"client.test",
"127.0.0.1",
mock.addr.port(),
"dest.com",
std::slice::from_ref(&msg),
TlsPolicy::Require,
Some(tls_config),
&r,
None,
)
.await;
assert!(
result.is_err(),
"Require policy + handshake fail must be Err (no plaintext fallback)"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn try_deliver_via_mx_require_policy_no_starttls_returns_err() {
ensure_crypto_provider();
let mock = spawn_mock_smtp(Behavior::AcceptNoStarttls).await;
let (_c, pool) = start_pg().await;
let id = queue::enqueue_ex(&pool, "s@example.com", "r@dest.com", "dest.com", b"body\r\n", None, 0, false)
.await
.unwrap();
let msg = queue::get_message(&pool, id).await.unwrap().unwrap();
let r = resolver();
let result = try_deliver_via_mx_with_tls(
"client.test",
"127.0.0.1",
mock.addr.port(),
"dest.com",
std::slice::from_ref(&msg),
TlsPolicy::Require,
None,
&r,
None,
)
.await;
assert!(
result.is_err(),
"Require policy + server does not advertise STARTTLS must be Err"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn deliver_domain_static_suppressed_recipient_is_skipped() {
ensure_crypto_provider();
let mock = spawn_mock_smtp(Behavior::AcceptNoStarttls).await;
let (_c, pool) = start_pg().await;
let id = queue::enqueue_ex(&pool, "s@example.com", "blocked@127.0.0.1", "127.0.0.1", b"body\r\n", None, 0, false)
.await
.unwrap();
queue::add_suppression(&pool, "blocked@127.0.0.1", "previous 550", Some(550))
.await
.unwrap();
let claimed = queue::claim_for_delivery(&pool, 0, 10).await.unwrap();
let r = resolver();
deliver_domain_static(
&r,
"client.test",
"127.0.0.1",
claimed,
&pool,
mock.addr.port(),
50,
None,
)
.await;
let after = queue::get_message(&pool, id).await.unwrap().unwrap();
assert_eq!(after.status, QueueStatus::Bounced, "suppressed recipient is bounced before MX resolve");
assert!(after.last_error.as_deref().unwrap().contains("suppressed"));
}