Import upstream v0.16.22, stripped

Upstream commit: 474dd0229cb20cf513036619781ed97bd8073c3f
Enterprise-only files removed or emptied: 63
Enterprise-only snippets removed: 117 in 50 files
Dangling module declarations removed: 5
Cargo edits turning enterprise off: 14
Verification: clean
Enterprise feature gates left for rebuilt features: 19 in 18 files

Produced by tools/fork/strip.py. The full report is in docs/fork/strip-reports/ on main.
This commit is contained in:
2026-09-18 10:21:56 -07:00
commit 7dae9b29fd
1650 changed files with 485521 additions and 0 deletions
+825
View File
@@ -0,0 +1,825 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::utils::{
dns::DnsCache,
http_server::{HttpMessage, spawn_mock_http_server},
server::TestServerBuilder,
};
use ahash::AHashSet;
use common::{
Server,
auth::{AccountCache, AccountInfo},
config::mailstore::spamfilter::SpamFilterAction,
enterprise::llm::{
ChatCompletionChoice, ChatCompletionRequest, ChatCompletionResponse, Message,
},
};
use http_proto::{JsonResponse, ToHttpResponse};
use hyper::Method;
use mail_auth::{
ArcOutput, DkimOutput, DkimResult, DmarcResult, DnssecStatus, IprevOutput, IprevResult, MX,
SpfOutput, SpfResult, dkim::Signature, dmarc::Policy,
};
use mail_parser::MessageParser;
use registry::{
schema::{
enums::{AiModelType, TaskSpamFilterMaintenanceType},
prelude::{ObjectType, Property},
structs::{
self, AiModel, MemoryLookupKey, SpamLlm, SpamLlmProperties, SpamSettings, Task,
TaskSpamFilterMaintenance, TaskStatus,
},
},
types::{float::Float, map::Map},
};
use serde_json::json;
use smtp::core::SessionAddress;
use smtp_proto::{MAIL_BODY_8BITMIME, MAIL_SMTPUTF8};
use spam_filter::{
SpamFilterInput,
analysis::{
classifier::SpamFilterAnalyzeClassify, date::SpamFilterAnalyzeDate,
dmarc::SpamFilterAnalyzeDmarc, domain::SpamFilterAnalyzeDomain,
ehlo::SpamFilterAnalyzeEhlo, from::SpamFilterAnalyzeFrom,
headers::SpamFilterAnalyzeHeaders, html::SpamFilterAnalyzeHtml, init::SpamFilterInit,
ip::SpamFilterAnalyzeIp, llm::SpamFilterAnalyzeLlm, messageid::SpamFilterAnalyzeMid,
mime::SpamFilterAnalyzeMime, pyzor::SpamFilterAnalyzePyzor,
received::SpamFilterAnalyzeReceived, recipient::SpamFilterAnalyzeRecipient,
replyto::SpamFilterAnalyzeReplyTo, rules::SpamFilterAnalyzeRules,
score::SpamFilterAnalyzeScore, subject::SpamFilterAnalyzeSubject,
url::SpamFilterAnalyzeUrl,
},
modules::{
classifier::{SpamClassifier, Token},
html::{HtmlToken, html_to_tokens},
},
};
use std::{
fs,
path::PathBuf,
sync::Arc,
time::{Duration, Instant},
};
#[tokio::test(flavor = "multi_thread")]
async fn antispam() {
let mut test = TestServerBuilder::new("smtp_antispam_test")
.await
.with_http_listener(19048)
.await
.build()
.await;
let admin = test.account("admin");
admin
.registry_create_object(SpamSettings {
score_spam: Float::new(5.0),
spam_filter_rules_url: std::env::var("SPAM_RULES_URL")
.unwrap_or_else(|_| {
"file:///Users/me/code/spam-filter/spam-filter-rules.json.gz".to_string()
})
.into(),
..Default::default()
})
.await;
admin
.registry_create_object(structs::SpamClassifier {
min_ham_samples: 10,
min_spam_samples: 10,
..Default::default()
})
.await;
let model_id = admin
.registry_create_object(AiModel {
model_type: AiModelType::Chat,
allow_invalid_certs: true,
model: "gpt-dummy".to_string(),
name: "dummy".to_string(),
url: "https://127.0.0.1:9090/v1/chat/completions".to_string(),
..Default::default()
})
.await;
admin
.registry_create_object(SpamLlm::Enable(SpamLlmProperties {
categories: Map::new(vec![
"Unsolicited".to_string(),
"Commercial".to_string(),
"Harmful".to_string(),
"Legitimate".to_string(),
]),
confidence: Map::new(vec![
"High".to_string(),
"Medium".to_string(),
"Low".to_string(),
]),
model_id,
prompt: "You are an AI assistant specialized in analyzing email content to detect spam"
.to_string(),
response_pos_category: 0,
response_pos_confidence: 1.into(),
response_pos_explanation: 2.into(),
separator: ",".to_string(),
..Default::default()
}))
.await;
admin
.registry_create_object(MemoryLookupKey {
is_glob_pattern: true,
key: "spamtrap@*".into(),
namespace: "spam-traps".into(),
})
.await;
admin
.registry_create_object(MemoryLookupKey {
is_glob_pattern: true,
key: "redirect.*".into(),
namespace: "url-redirectors".into(),
})
.await;
admin.mta_allow_relaying().await;
admin.mta_no_auth().await;
admin.mta_allow_non_fqdn().await;
admin.reload_settings().await;
// Fetch rules
admin
.registry_create_object(Task::SpamFilterMaintenance(TaskSpamFilterMaintenance {
maintenance_type: TaskSpamFilterMaintenanceType::UpdateRules,
status: TaskStatus::now(),
}))
.await;
test.wait_for_tasks().await;
admin.reload_settings().await;
admin.reload_lookup_stores().await;
test.reload_core();
let admin = test.account("admin");
// Add mock DNS entries
for (domain, ip) in [
("bank.com", "127.0.0.1"),
("apple.com", "127.0.0.1"),
("youtube.com", "127.0.0.1"),
("twitter.com", "127.0.0.3"),
("dkimtrusted.org.dwl.dnswl.org", "127.0.0.3"),
("sh-malware.com.dbl.spamhaus.org", "127.0.1.5"),
("surbl-abuse.com.multi.surbl.org", "127.0.0.64"),
("uribl-grey.com.multi.uribl.com", "127.0.0.4"),
("sem-uribl.com.uribl.spameatingmonkey.net", "127.0.0.2"),
("sem-fresh15.com.fresh15.spameatingmonkey.net", "127.0.0.2"),
(
"b4a64d60f67529b0b18df66ea2f292e09e43c975.ebl.msbl.org",
"127.0.0.2",
),
(
"a95bd658068a8315dc1864d6bb79632f47692621.ebl.msbl.org",
"127.0.1.3",
),
(
"ba76e47680ba70a0cbff8d6c92139683.hashbl.surbl.org",
"127.0.0.16",
),
(
"0ac5b387a1c6d8461a78bbf7b172a2a1.hashbl.surbl.org",
"127.0.0.64",
),
(
"ef6f530a68b77d782983e8712ff31fe5.hashbl.surbl.org",
"127.0.0.8",
),
] {
test.server.ipv4_add(
domain,
vec![ip.parse().unwrap()],
Instant::now() + Duration::from_secs(100),
);
test.server.dnsbl_add(
domain,
vec![ip.parse().unwrap()],
Instant::now() + Duration::from_secs(100),
);
}
for mx in [
"domain.org",
"domain.co.uk",
"gmail.com",
"custom.disposable.org",
] {
test.server.mx_add(
mx,
vec![MX {
exchanges: vec!["127.0.0.1".into()].into_boxed_slice(),
preference: 10,
}],
DnssecStatus::Secure,
Instant::now() + Duration::from_secs(100),
);
}
// Spawn mock OpenAI server
let _tx = spawn_mock_http_server(
&test,
Arc::new(|req: HttpMessage| {
assert_eq!(req.uri.path(), "/v1/chat/completions");
assert_eq!(req.method, Method::POST);
let req = serde_json::from_slice::<ChatCompletionRequest>(req.body.as_ref().unwrap())
.unwrap();
assert_eq!(req.model, "gpt-dummy");
let message = &req.messages[0].content;
assert!(message.contains("You are an AI assistant specialized in analyzing email"));
JsonResponse::new(&ChatCompletionResponse {
created: 0,
object: String::new(),
id: String::new(),
model: req.model,
choices: vec![ChatCompletionChoice {
index: 0,
finish_reason: "stop".to_string(),
message: Message {
role: "assistant".to_string(),
content: message.split_once("Subject: ").unwrap().1.to_string(),
},
}],
})
.into_http_response()
}),
9090,
)
.await;
// Run tests
let base_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("resources")
.join("smtp")
.join("antispam");
let filter_test = std::env::var("TEST_NAME").ok();
for test_name in [
"combined",
"ip",
"helo",
"received",
"messageid",
"date",
"from",
"subject",
"replyto",
"recipient",
"headers",
"url",
"html",
"mime",
"bounce",
"dmarc",
"rbl",
"spamtrap",
"classifier_html",
"classifier_features",
"classifier",
"pyzor",
"llm",
] {
if filter_test
.as_ref()
.is_some_and(|s| !s.eq_ignore_ascii_case(test_name))
{
continue;
}
println!("===== {test_name} =====");
let contents = fs::read_to_string(base_path.join(format!("{test_name}.test"))).unwrap();
match test_name {
"classifier_html" => {
html_tokens(contents);
continue;
}
"classifier_features" => {
classifier_features(&test.server, contents).await;
continue;
}
"classifier" => {
for class in ["spam", "ham"] {
let contents =
fs::read_to_string(base_path.join(format!("classifier.{class}"))).unwrap();
for sample in contents.split("<!-- NEXT TEST -->") {
let sample = sample.trim_start();
if sample.is_empty() {
continue;
}
let blob_id = test
.server
.put_jmap_blob(u32::MAX, sample.as_bytes())
.await
.unwrap();
admin
.registry_create_many(
ObjectType::SpamTrainingSample,
[json!({
Property::BlobId: blob_id,
Property::IsSpam: class == "spam",
})],
)
.await
.created_id(0);
}
}
admin
.registry_create_object(Task::SpamFilterMaintenance(
TaskSpamFilterMaintenance {
maintenance_type: TaskSpamFilterMaintenanceType::Train,
status: TaskStatus::now(),
},
))
.await;
test.wait_for_tasks().await;
}
_ => {}
}
let mut lines = contents.lines();
let mut has_more = true;
while has_more {
let mut message = String::new();
let mut in_params = true;
// Build session
let mut session = test.new_mta_session();
let mut arc_result = None;
let mut dkim_result = None;
let mut dkim_signatures = vec![];
let mut dmarc_result = None;
let mut dmarc_policy = None;
let mut expected_tags: AHashSet<String> = AHashSet::new();
let mut expect_headers = String::new();
let mut body_params = 0;
let mut is_tls = false;
for line in lines.by_ref() {
if in_params {
if line.is_empty() {
in_params = false;
continue;
}
let (param, value) = line.split_once(' ').unwrap();
let value = value.trim();
match param {
"remote_ip" => {
session.data.remote_ip_str = value.to_string();
session.data.remote_ip = value.parse().unwrap();
}
"helo_domain" => {
session.data.helo_domain = value.to_string();
}
"authenticated_as" => {
session.data.authenticated_as = Some(AccountInfo {
account_id: u32::MAX,
addresses: vec![value.to_string()],
account: Arc::new(AccountCache {
name: value.into(),
..Default::default()
}),
});
}
"spf.result" | "spf_ehlo.result" => {
session.data.spf_mail_from =
Some(SpfOutput::default().with_result(SpfResult::from_str(value)));
}
"iprev.result" => {
session
.data
.iprev
.get_or_insert(IprevOutput {
result: IprevResult::None,
ptr: None,
})
.result = IprevResult::from_str(value);
}
"dkim.result" => {
dkim_result = match DkimResult::from_str(value) {
DkimResult::Pass => DkimOutput::pass(),
DkimResult::Neutral(error) => DkimOutput::neutral(error),
DkimResult::Fail(error) => DkimOutput::fail(error),
DkimResult::PermError(error) => DkimOutput::perm_err(error),
DkimResult::TempError(error) => DkimOutput::temp_err(error),
DkimResult::None => unreachable!(),
}
.into();
}
"arc.result" => {
arc_result = ArcOutput::default()
.with_result(DkimResult::from_str(value))
.into();
}
"dkim.domains" => {
dkim_signatures = value
.split_ascii_whitespace()
.map(|s| Signature {
d: s.to_lowercase(),
..Default::default()
})
.collect();
}
"envelope_from" => {
session.data.mail_from = Some(SessionAddress::new(value.to_string()));
}
"envelope_to" => {
session
.data
.rcpt_to
.push(SessionAddress::new(value.to_string()));
}
"iprev.ptr" => {
session
.data
.iprev
.get_or_insert(IprevOutput {
result: IprevResult::None,
ptr: None,
})
.ptr = Some(Arc::from(vec![value.into()]));
}
"dmarc.result" => {
dmarc_result = DmarcResult::from_str(value).into();
}
"dmarc.policy" => {
dmarc_policy = Policy::from_str(value).into();
}
"expect" => {
expected_tags
.extend(value.split_ascii_whitespace().map(|v| v.to_uppercase()));
}
"expect_header" => {
let value = value.trim();
if !value.is_empty() {
if !expect_headers.is_empty() {
expect_headers.push(' ');
}
expect_headers.push_str(value);
}
}
"param.smtputf8" => {
body_params |= MAIL_SMTPUTF8;
}
"param.8bitmime" => {
body_params |= MAIL_BODY_8BITMIME;
}
"tls.version" => {
is_tls = true;
}
_ => panic!("Invalid parameter {param:?}"),
}
} else {
has_more = line.trim().eq_ignore_ascii_case("<!-- NEXT TEST -->");
if !has_more {
message.push_str(line);
message.push_str("\r\n");
} else {
break;
}
}
}
if message.is_empty() {
panic!("No message found");
}
if body_params != 0 {
session
.data
.mail_from
.get_or_insert_with(|| SessionAddress::new("".to_string()))
.flags = body_params;
}
// Build input
let mut dkim_domains = vec![];
if let Some(dkim_result) = dkim_result {
if dkim_signatures.is_empty() {
dkim_signatures.push(Signature {
d: "unknown.org".to_string(),
..Default::default()
});
}
for signature in &dkim_signatures {
dkim_domains.push(dkim_result.clone().with_signature(signature));
}
}
let parsed_message = MessageParser::new().parse(&message).unwrap();
// Combined tests
if test_name == "combined" {
match session
.spam_classify(
&parsed_message,
&dkim_domains,
None,
arc_result.as_ref(),
dmarc_result.as_ref(),
dmarc_policy.as_ref(),
)
.await
{
SpamFilterAction::Allow(score) => {
let mut last_ch = 'x';
let mut result = String::with_capacity(score.headers.len());
for ch in score.headers.chars() {
if !ch.is_whitespace() {
if last_ch.is_whitespace() {
result.push(' ');
}
result.push(ch);
}
last_ch = ch;
}
assert_eq!(result, expect_headers);
}
other => panic!("Unexpected action {other:?}"),
}
continue;
}
// Initialize filter
let mut spam_input = session.build_spam_input(
&parsed_message,
&dkim_domains,
None,
arc_result.as_ref(),
dmarc_result.as_ref(),
dmarc_policy.as_ref(),
);
spam_input.is_tls = is_tls;
let server = &test.server;
let mut spam_ctx = server.spam_filter_init(spam_input);
match test_name {
"html" => {
server.spam_filter_analyze_html(&mut spam_ctx).await;
server.spam_filter_analyze_rules(&mut spam_ctx).await;
}
"subject" => {
server.spam_filter_analyze_headers(&mut spam_ctx).await;
spam_ctx.result.tags.retain(|t| t.starts_with("X_HDR_"));
server.spam_filter_analyze_subject(&mut spam_ctx).await;
server.spam_filter_analyze_rules(&mut spam_ctx).await;
spam_ctx.result.tags.retain(|t| !t.starts_with("X_HDR_"));
}
"received" => {
server.spam_filter_analyze_headers(&mut spam_ctx).await;
spam_ctx.result.tags.retain(|t| t.starts_with("X_HDR_"));
server.spam_filter_analyze_received(&mut spam_ctx).await;
server.spam_filter_analyze_rules(&mut spam_ctx).await;
spam_ctx.result.tags.retain(|t| !t.starts_with("X_HDR_"));
}
"messageid" => {
server.spam_filter_analyze_message_id(&mut spam_ctx).await;
}
"date" => {
server.spam_filter_analyze_date(&mut spam_ctx).await;
}
"from" => {
server.spam_filter_analyze_from(&mut spam_ctx).await;
server.spam_filter_analyze_domain(&mut spam_ctx).await;
server.spam_filter_analyze_rules(&mut spam_ctx).await;
}
"replyto" => {
server.spam_filter_analyze_reply_to(&mut spam_ctx).await;
server.spam_filter_analyze_domain(&mut spam_ctx).await;
server.spam_filter_analyze_rules(&mut spam_ctx).await;
}
"recipient" => {
server.spam_filter_analyze_headers(&mut spam_ctx).await;
spam_ctx.result.tags.retain(|t| t.starts_with("X_HDR_"));
server.spam_filter_analyze_recipient(&mut spam_ctx).await;
server.spam_filter_analyze_domain(&mut spam_ctx).await;
server.spam_filter_analyze_subject(&mut spam_ctx).await;
server.spam_filter_analyze_url(&mut spam_ctx).await;
server.spam_filter_analyze_rules(&mut spam_ctx).await;
spam_ctx.result.tags.retain(|t| !t.starts_with("X_HDR_"));
}
"mime" => {
server.spam_filter_analyze_mime(&mut spam_ctx).await;
}
"headers" => {
server.spam_filter_analyze_headers(&mut spam_ctx).await;
server.spam_filter_analyze_rules(&mut spam_ctx).await;
spam_ctx.result.tags.retain(|t| !t.starts_with("X_HDR_"));
}
"url" => {
server.spam_filter_analyze_url(&mut spam_ctx).await;
server.spam_filter_analyze_rules(&mut spam_ctx).await;
}
"dmarc" => {
server.spam_filter_analyze_dmarc(&mut spam_ctx).await;
server.spam_filter_analyze_headers(&mut spam_ctx).await;
server.spam_filter_analyze_rules(&mut spam_ctx).await;
spam_ctx.result.tags.retain(|t| !t.starts_with("X_HDR_"));
}
"ip" => {
server.spam_filter_analyze_ip(&mut spam_ctx).await;
}
"helo" => {
server.spam_filter_analyze_ehlo(&mut spam_ctx).await;
}
"bounce" => {
server.spam_filter_analyze_mime(&mut spam_ctx).await;
server.spam_filter_analyze_headers(&mut spam_ctx).await;
server.spam_filter_analyze_rules(&mut spam_ctx).await;
spam_ctx.result.tags.retain(|t| !t.starts_with("X_HDR_"));
}
"rbl" => {
server.spam_filter_analyze_url(&mut spam_ctx).await;
server.spam_filter_analyze_ip(&mut spam_ctx).await;
server.spam_filter_analyze_domain(&mut spam_ctx).await;
}
"spamtrap" => {
server.spam_filter_analyze_spam_trap(&mut spam_ctx).await;
server.spam_filter_finalize(&mut spam_ctx).await;
}
"classifier" => {
server.spam_filter_analyze_classify(&mut spam_ctx).await;
match server.spam_filter_finalize(&mut spam_ctx).await {
SpamFilterAction::Allow(r) => spam_ctx.result.tags.extend(
r.headers
.split_ascii_whitespace()
.filter(|t| t.starts_with("PROB_"))
.map(|t| t.to_string()),
),
_ => unreachable!(),
}
}
"pyzor" => {
server.spam_filter_analyze_pyzor(&mut spam_ctx).await;
}
"llm" => {
server.spam_filter_analyze_llm(&mut spam_ctx).await;
}
_ => panic!("Invalid test {test_name:?}"),
}
// Compare tags
if spam_ctx.result.tags != expected_tags {
for tag in &spam_ctx.result.tags {
if !expected_tags.contains(tag) {
println!("Unexpected tag: {tag:?}");
}
}
for tag in &expected_tags {
if !spam_ctx.result.tags.contains(tag) {
println!("Missing tag: {tag:?}");
}
}
panic!("Tags mismatch, expected {expected_tags:?}");
} else {
println!("Tags matched: {expected_tags:?}");
}
}
}
}
async fn classifier_features(server: &Server, contents: String) {
let mut num_tests = 0;
for test in contents.split("<!-- NEXT TEST -->") {
let test = test.trim();
if test.is_empty() {
continue;
}
let (input, expected) = test.split_once("<!-- EXPECT -->").unwrap();
let input = input.trim();
let expected = expected.trim();
// Build features
let message = MessageParser::new().parse(input).unwrap_or_default();
let mut ctx =
server.spam_filter_init(SpamFilterInput::from_message(&message, 0).train_mode());
server.spam_filter_analyze_domain(&mut ctx).await;
server.spam_filter_analyze_url(&mut ctx).await;
let mut tokens = server
.spam_build_tokens(&ctx)
.await
.0
.into_keys()
.collect::<Vec<_>>();
tokens.sort();
assert!(!tokens.is_empty(), "No tokens parsed for input: {}", input);
let expected_tokens: Vec<Token<'_>> = serde_json::from_str(expected).unwrap();
if tokens != expected_tokens {
eprintln!("Input: {}", input);
eprintln!("Expected Tokens: {}", expected);
eprintln!(
"Parsed Tokens: {}",
serde_json::to_string_pretty(&tokens).unwrap()
);
panic!("Tokens do not match");
}
num_tests += 1;
}
assert_eq!(num_tests, 11, "Expected number of tests to run");
}
fn html_tokens(contents: String) {
let mut num_tests = 0;
for test in contents.split("<!-- NEXT TEST -->") {
let test = test.trim();
if test.is_empty() {
continue;
}
let (input, expected) = test.split_once("<!-- EXPECT -->").unwrap();
let input = input.trim();
let expected = expected.trim();
let tokens = html_to_tokens(input);
assert!(!tokens.is_empty(), "No tokens parsed for input: {}", input);
let expected_tokens: Vec<HtmlToken> = serde_json::from_str(expected).unwrap();
assert_eq!(tokens, expected_tokens, "Input: {}", input);
num_tests += 1;
}
assert_eq!(num_tests, 12, "Expected number of tests to run");
}
trait ParseConfigValue: Sized {
fn from_str(value: &str) -> Self;
}
impl ParseConfigValue for SpfResult {
fn from_str(value: &str) -> Self {
match value {
"pass" => SpfResult::Pass,
"fail" => SpfResult::Fail,
"softfail" => SpfResult::SoftFail,
"neutral" => SpfResult::Neutral,
"none" => SpfResult::None,
"temperror" => SpfResult::TempError,
"permerror" => SpfResult::PermError,
_ => panic!("Invalid SPF result"),
}
}
}
impl ParseConfigValue for IprevResult {
fn from_str(value: &str) -> Self {
match value {
"pass" => IprevResult::Pass,
"fail" => IprevResult::Fail(mail_auth::Error::NotAligned),
"temperror" => IprevResult::TempError(mail_auth::Error::NotAligned),
"permerror" => IprevResult::PermError(mail_auth::Error::NotAligned),
"none" => IprevResult::None,
_ => panic!("Invalid IPREV result"),
}
}
}
impl ParseConfigValue for DkimResult {
fn from_str(value: &str) -> Self {
match value {
"pass" => DkimResult::Pass,
"none" => DkimResult::None,
"neutral" => DkimResult::Neutral(mail_auth::Error::NotAligned),
"fail" => DkimResult::Fail(mail_auth::Error::NotAligned),
"permerror" => DkimResult::PermError(mail_auth::Error::NotAligned),
"temperror" => DkimResult::TempError(mail_auth::Error::NotAligned),
_ => panic!("Invalid DKIM result"),
}
}
}
impl ParseConfigValue for DmarcResult {
fn from_str(value: &str) -> Self {
match value {
"pass" => DmarcResult::Pass,
"fail" => DmarcResult::Fail(mail_auth::Error::NotAligned),
"temperror" => DmarcResult::TempError(mail_auth::Error::NotAligned),
"permerror" => DmarcResult::PermError(mail_auth::Error::NotAligned),
"none" => DmarcResult::None,
_ => panic!("Invalid DMARC result"),
}
}
}
impl ParseConfigValue for Policy {
fn from_str(value: &str) -> Self {
match value {
"reject" => Policy::Reject,
"quarantine" => Policy::Quarantine,
"none" => Policy::None,
_ => panic!("Invalid DMARC policy"),
}
}
}
+108
View File
@@ -0,0 +1,108 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::utils::server::TestServerBuilder;
use registry::{
schema::structs::{Asn, AsnDns, AsnResource},
types::map::Map,
};
use std::time::{Duration, Instant};
#[ignore]
#[tokio::test]
async fn asn() {
let mut test = TestServerBuilder::new("smtp_asn_test")
.await
.with_http_listener(19011)
.await
.disable_services()
.capture_queue()
.build()
.await;
let admin = test.account("admin");
admin
.registry_create_object(Asn::Dns(AsnDns {
index_asn: 0,
index_asn_name: 3.into(),
index_country: 2.into(),
separator: '|'.to_string(),
zone_ip_v4: "origin.asn.cymru.com".to_string(),
zone_ip_v6: "origin6.asn.cymru.com".to_string(),
}))
.await;
admin.reload_settings().await;
test.reload_core();
let admin = test.account("admin");
for (ip, asn, asn_name, country) in [
("8.8.8.8", 15169, "arin", "US"),
("1.1.1.1", 13335, "apnic", "AU"),
("2a01:4f9:c011:b43c::1", 24940, "ripencc", "DE"),
("1.33.1.1", 2514, "apnic", "JP"),
] {
let result = test.server.lookup_asn_country(ip.parse().unwrap()).await;
println!("{ip}: {result:?}");
assert_eq!(result.asn.as_ref().map(|r| r.id), Some(asn));
assert_eq!(
result.asn.as_ref().and_then(|r| r.name.as_deref()),
Some(asn_name)
);
assert_eq!(result.country.as_ref().map(|s| s.as_str()), Some(country));
}
admin
.registry_create_object(Asn::Resource(AsnResource {
asn_urls: Map::new(vec![
common::manager::defaults::ASN_IPV4.to_string(),
common::manager::defaults::ASN_IPV6.to_string(),
]),
expires: 86_400_100u64.into(),
geo_urls: Map::new(vec![
common::manager::defaults::GEO_IPV4.to_string(),
common::manager::defaults::GEO_IPV6.to_string(),
]),
max_size: 100 * 1024 * 1024,
timeout: 100_000u64.into(),
..Default::default()
}))
.await;
admin.reload_settings().await;
test.reload_core();
test.server
.lookup_asn_country("8.8.8.8".parse().unwrap())
.await;
let time = Instant::now();
loop {
tokio::time::sleep(Duration::from_millis(500)).await;
if test.server.inner.data.asn_geo_data.lock.available_permits() > 0 {
break;
}
}
println!("Fetch took {:?}", time.elapsed());
for (ip, asn, asn_name, country) in [
("8.8.8.8", 15169, "Google LLC", "US"),
("1.1.1.1", 13335, "Cloudflare, Inc.", "AU"),
("2a01:4f9:c011:b43c::1", 24940, "Hetzner Online GmbH", "FI"),
(
"1.33.1.1",
2514,
"InfoSphere - NTT PC Communications, Inc.",
"JP",
),
] {
let result = test.server.lookup_asn_country(ip.parse().unwrap()).await;
println!("{ip}: {result:?}");
assert_eq!(result.asn.as_ref().map(|r| r.id), Some(asn));
assert_eq!(
result.asn.as_ref().and_then(|r| r.name.as_deref()),
Some(asn_name)
);
assert_eq!(result.country.as_ref().map(|s| s.as_str()), Some(country));
}
}
+178
View File
@@ -0,0 +1,178 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
smtp::session::{TestSession, VerifyResponse},
utils::server::TestServerBuilder,
};
use registry::{
schema::structs::{Expression, ExpressionMatch, MtaExtensions, MtaStageAuth},
types::list::List,
};
use smtp::core::State;
#[tokio::test]
async fn auth() {
let mut test = TestServerBuilder::new("smtp_auth_test")
.await
.with_http_listener(19001)
.await
.disable_services()
.build()
.await;
// Create test users
let admin = test.account("admin");
for (name, secret, description, aliases) in [
(
"[email protected]",
"12345 + extra safety",
"John Doe",
&["[email protected]"][..],
),
(
"[email protected]",
"abcde + extra safety",
"Jane Smith",
&["[email protected]"],
),
] {
admin
.create_user_account(name, secret, description, aliases, vec![])
.await;
}
// Add test settings
admin
.registry_create_object(MtaStageAuth {
max_failures: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "remote_ip = '10.0.0.1'".into(),
then: "2".into(),
}]),
else_: "3".into(),
},
must_match_sender: Expression {
else_: "true".into(),
..Default::default()
},
require: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "remote_ip = '10.0.0.1'".into(),
then: "true".into(),
}]),
else_: "false".into(),
},
sasl_mechanisms: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "remote_ip = '10.0.0.1' && is_tls".into(),
then: "[plain, login]".into(),
}]),
else_: "0".into(),
},
wait_on_fail: Expression {
else_: "100ms".into(),
..Default::default()
},
})
.await;
admin
.registry_create_object(MtaExtensions {
future_release: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "!is_empty(authenticated_as)".into(),
then: "1d".into(),
}]),
else_: "false".into(),
},
..Default::default()
})
.await;
admin.reload_settings().await;
test.reload_core();
// EHLO should not advertise plain text auth without TLS
let mut session = test.new_mta_session();
session.data.remote_ip_str = "10.0.0.1".into();
session.eval_session_params().await;
session.stream.tls = false;
session
.ehlo("mx.foobar.org")
.await
.assert_not_contains(" PLAIN")
.assert_not_contains(" LOGIN");
// EHLO should advertise AUTH for 10.0.0.1
session.stream.tls = true;
session
.ehlo("mx.foobar.org")
.await
.assert_contains("AUTH ")
.assert_contains(" PLAIN")
.assert_contains(" LOGIN")
.assert_not_contains("FUTURERELEASE");
// Invalid password should be rejected
session
.auth_plain("[email protected]", "wrong pass", "535 5.7.8")
.await;
// Session should be disconnected after second invalid auth attempt
session
.ingest(b"AUTH PLAIN AGpvaG4AY2hpbWljaGFuZ2Fz\r\n")
.await
.unwrap_err();
session.response().assert_code("455 4.3.0");
// Should not be able to send without authenticating
session.state = State::default();
session.mail_from("[email protected]", "503 5.5.1").await;
// Successful PLAIN authentication
session.data.auth_errors = 0;
session
.auth_plain("[email protected]", "12345 + extra safety", "235 2.7.0")
.await;
// Users should be able to send emails only from their own email addresses
session.mail_from("[email protected]", "501 5.5.4").await;
session.mail_from("[email protected]", "250").await;
session.data.mail_from.take();
// Should not be able to authenticate twice
session
.auth_plain("[email protected]", "12345 + extra safety", "503 5.5.1")
.await;
// FUTURERELEASE extension should be available after authenticating
session
.ehlo("mx.foobar.org")
.await
.assert_not_contains("AUTH ")
.assert_not_contains(" PLAIN")
.assert_not_contains(" LOGIN")
.assert_contains("FUTURERELEASE 86400");
// Successful LOGIN authentication
session.data.authenticated_as.take();
session
.auth_login("[email protected]", "12345 + extra safety", "235 2.7.0")
.await;
// Login should not be advertised to 10.0.0.2
session.data.remote_ip_str = "10.0.0.2".into();
session.eval_session_params().await;
session.stream.tls = true;
session
.ehlo("mx.foobar.org")
.await
.assert_not_contains("AUTH ")
.assert_not_contains(" PLAIN")
.assert_not_contains(" LOGIN");
session
.auth_plain("[email protected]", "12345 + extra safety", "503 5.5.1")
.await;
}
+88
View File
@@ -0,0 +1,88 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
smtp::session::{TestSession, VerifyResponse},
utils::server::TestServerBuilder,
};
use common::auth::{AccountCache, AccountInfo};
use mail_auth::SpfOutput;
use smtp::core::SessionAddress;
use std::sync::Arc;
#[tokio::test]
async fn basic_commands() {
let test = TestServerBuilder::new("smtp_basic_test")
.await
.with_http_listener(19002)
.await
.disable_services()
.build()
.await;
let mut session = test.new_mta_session();
// STARTTLS should be available on clear text connections
session.stream.tls = false;
session
.ehlo("mx.foobar.org")
.await
.assert_contains("STARTTLS");
session.data.mail_from = Some(SessionAddress::new("[email protected]".to_string()));
session
.data
.rcpt_to
.push(SessionAddress::new("[email protected]".to_string()));
session.data.spf_ehlo = Some(SpfOutput::default());
session.data.authenticated_as = Some(AccountInfo {
account_id: u32::MAX,
addresses: vec!["[email protected]".to_string()],
account: Arc::new(AccountCache {
name: "attacker".into(),
..Default::default()
}),
});
session.data.bytes_left = 12345;
session.data.rcpt_errors = 2;
session.data.auth_errors = 1;
assert!(!session.ingest(b"STARTTLS\r\n").await.unwrap());
session.response().assert_contains("220 2.0.0");
assert!(session.data.mail_from.is_none());
assert!(session.data.rcpt_to.is_empty());
assert!(session.data.helo_domain.is_empty());
assert!(session.data.spf_ehlo.is_none());
assert!(session.data.authenticated_as.is_none());
assert_eq!(session.data.bytes_left, 12345);
assert_eq!(session.data.rcpt_errors, 2);
assert_eq!(session.data.auth_errors, 1);
// STARTTLS should not be offered on TLS connections
session.stream.tls = true;
session
.ehlo("mx.foobar.org")
.await
.assert_not_contains("STARTTLS");
session.cmd("STARTTLS", "504 5.7.4").await;
// Test NOOP
session.cmd("NOOP", "250").await;
// Test RSET
session.cmd("RSET", "250").await;
// Test HELP
session.cmd("HELP QUIT", "250").await;
// Test LHLO on SMTP channel
session.cmd("LHLO domain.org", "502").await;
// Test QUIT
session.ingest(b"QUIT\r\n").await.unwrap_err();
session.response().assert_code("221");
}
+391
View File
@@ -0,0 +1,391 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
smtp::{
inbound::TestMessage,
session::{TestSession, VerifyResponse, load_test_message},
},
utils::server::TestServerBuilder,
};
use registry::{
schema::{
enums::MtaQueueQuotaKey,
prelude::ObjectType,
structs::{
Expression, ExpressionMatch, MtaQueueQuota, MtaStageData, SenderAuth, SpamSettings,
},
},
types::{list::List, map::Map},
};
#[tokio::test]
async fn data() {
let mut test = TestServerBuilder::new("smtp_data_test")
.await
.with_http_listener(19004)
.await
.disable_services()
.capture_queue()
.build()
.await;
// Create test users
let admin = test.account("admin");
for (name, secret, description, aliases) in [
("[email protected]", "12345 + extra safety", "John Doe", &[]),
("[email protected]", "abcde + extra safety", "Jane Smith", &[]),
(
"[email protected]",
"p4ssw0rd + extra safety",
"Bill Foobar",
&[],
),
(
"[email protected]",
"p4ssw0rd + extra safety",
"Mike Foobar",
&[],
),
] {
admin
.create_user_account(name, secret, description, aliases, vec![])
.await;
}
// Add test settings
admin.mta_no_auth().await;
admin
.registry_create_object(SpamSettings {
enable: false,
..Default::default()
})
.await;
admin
.registry_create_object(SenderAuth {
dmarc_verify: Expression {
else_: "relaxed".into(),
..Default::default()
},
reverse_ip_verify: Expression {
else_: "relaxed".into(),
..Default::default()
},
spf_ehlo_verify: Expression {
else_: "relaxed".into(),
..Default::default()
},
spf_from_verify: Expression {
else_: "relaxed".into(),
..Default::default()
},
..Default::default()
})
.await;
admin
.registry_create_object(MtaStageData {
add_auth_results_header: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "remote_ip = '10.0.0.3'".into(),
then: "true".into(),
}]),
else_: "false".into(),
},
add_date_header: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "remote_ip = '10.0.0.3'".into(),
then: "true".into(),
}]),
else_: "false".into(),
},
add_message_id_header: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "remote_ip = '10.0.0.3'".into(),
then: "true".into(),
}]),
else_: "false".into(),
},
add_received_header: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "remote_ip = '10.0.0.3'".into(),
then: "true".into(),
}]),
else_: "false".into(),
},
add_received_spf_header: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "remote_ip = '10.0.0.3'".into(),
then: "true".into(),
}]),
else_: "false".into(),
},
add_return_path_header: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "remote_ip = '10.0.0.3'".into(),
then: "true".into(),
}]),
else_: "false".into(),
},
max_messages: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "remote_ip = '10.0.0.1'".into(),
then: "1".into(),
}]),
else_: "100".into(),
},
max_received_headers: Expression {
else_: "3".into(),
..Default::default()
},
max_message_size: Expression {
match_: List::from_iter([
ExpressionMatch {
if_: "remote_ip = '10.0.0.4'".into(),
then: "100".into(),
},
ExpressionMatch {
if_: "remote_ip = '10.0.0.5'".into(),
then: "0".into(),
},
]),
else_: "104857600".into(),
},
..Default::default()
})
.await;
admin
.registry_create_object(MtaQueueQuota {
description: None,
enable: true,
key: Map::new(vec![MtaQueueQuotaKey::Sender]),
match_: Expression {
else_: "sender = '[email protected]'".into(),
..Default::default()
},
messages: Some(1),
size: None,
})
.await;
admin
.registry_create_object(MtaQueueQuota {
description: None,
enable: true,
key: Map::new(vec![MtaQueueQuotaKey::RcptDomain]),
match_: Expression {
else_: "rcpt_domain = 'foobar.org'".into(),
..Default::default()
},
messages: None,
size: Some(450),
})
.await;
admin
.registry_create_object(MtaQueueQuota {
description: None,
enable: true,
key: Map::new(vec![MtaQueueQuotaKey::Rcpt]),
match_: Expression {
else_: "rcpt = '[email protected]'".into(),
..Default::default()
},
messages: None,
size: Some(450),
})
.await;
admin.reload_settings().await;
test.reload_core();
test.expect_reload_settings().await;
// Test queue message builder
let mut session = test.new_mta_session();
session.data.remote_ip_str = "10.0.0.1".into();
session.eval_session_params().await;
session.test_builder().await;
// Send DATA without RCPT
session.ehlo("mx.doe.org").await;
session.ingest(b"DATA\r\n").await.unwrap();
session.response().assert_code("503 5.5.1");
// Send BDAT without MAIL FROM
session.ingest(b"BDAT 10\r\n0123456789").await.unwrap();
session
.response()
.assert_code("503 5.5.1")
.assert_contains("MAIL is required")
.assert_not_contains("552");
// Send BDAT without RCPT
session.mail_from("[email protected]", "250").await;
session.ingest(b"BDAT 10\r\n0123456789").await.unwrap();
session
.response()
.assert_code("503 5.5.1")
.assert_contains("RCPT is required")
.assert_not_contains("552");
session.rset().await;
// Send a BDAT chunk exceeding the maximum message size
let mut size_session = test.new_mta_session();
size_session.data.remote_ip_str = "10.0.0.4".into();
size_session.eval_session_params().await;
size_session.ehlo("mx.doe.org").await;
size_session.mail_from("[email protected]", "250").await;
size_session.rcpt_to("[email protected]", "250").await;
let mut chunk = b"BDAT 200 LAST\r\n".to_vec();
chunk.extend_from_slice(&[b'A'; 200]);
size_session.ingest(&chunk).await.unwrap();
size_session.response().assert_code("552 5.3.4");
size_session.rset().await;
// A maximum message size of zero disables the limit
size_session.data.remote_ip_str = "10.0.0.5".into();
size_session.eval_session_params().await;
size_session
.ingest(b"MAIL FROM:<[email protected]> SIZE=1073741824\r\n")
.await
.unwrap();
size_session.response().assert_code("250");
size_session.rset().await;
size_session
.send_message("[email protected]", &["[email protected]"], "test:no_dkim", "250")
.await;
test.expect_message().await;
// Send broken message
session
.send_message("[email protected]", &["[email protected]"], "invalid", "550 5.7.7")
.await;
// Naive Loop detection
session
.send_message(
"[email protected]",
&["[email protected]"],
"test:loop",
"450 4.4.6",
)
.await;
// No headers should be added to messages from 10.0.0.1
session
.send_message("[email protected]", &["[email protected]"], "test:no_msgid", "250")
.await;
assert_eq!(
test.expect_message().await.read_message(&test).await,
format!("{}\r\n", load_test_message("no_msgid", "messages"))
);
// Maximum one message per session is allowed for 10.0.0.1
session.mail_from("[email protected]", "250").await;
session.rcpt_to("[email protected]", "250").await;
session.ingest(b"DATA\r\n").await.unwrap();
session.response().assert_code("452 4.4.5");
session.rset().await;
// Headers should be added to messages from 10.0.0.3
session.data.remote_ip_str = "10.0.0.3".into();
session.eval_session_params().await;
session
.send_message("[email protected]", &["[email protected]"], "test:no_msgid", "250")
.await;
test.expect_message()
.await
.read_lines(&test)
.await
.assert_contains("From: ")
.assert_contains("To: ")
.assert_contains("Subject: ")
.assert_contains("Date: ")
.assert_contains("Message-ID: ")
.assert_contains("Return-Path: ")
.assert_contains("Received: ")
.assert_contains("Authentication-Results: ")
.assert_contains("Received-SPF: ");
// Send a message using multiple BDAT chunks
session.mail_from("[email protected]", "250").await;
session.rcpt_to("[email protected]", "250").await;
let message = load_test_message("no_msgid", "messages");
let (first, last) = message.as_bytes().split_at(message.len() / 2);
let mut chunk = format!("BDAT {}\r\n", first.len()).into_bytes();
chunk.extend_from_slice(first);
session.ingest(&chunk).await.unwrap();
session.response().assert_code("250 2.6.0");
let mut chunk = format!("BDAT {} LAST\r\n", last.len()).into_bytes();
chunk.extend_from_slice(last);
session.ingest(&chunk).await.unwrap();
session.response().assert_code("250");
test.expect_message()
.await
.read_lines(&test)
.await
.assert_contains("Subject: ")
.assert_contains("Received: ");
// Only one message is allowed in the queue from [email protected]
session.data.remote_ip_str = "10.0.0.2".into();
session.eval_session_params().await;
session
.send_message("[email protected]", &["[email protected]"], "test:no_dkim", "250")
.await;
session
.send_message(
"[email protected]",
&["[email protected]"],
"test:no_dkim",
"452 4.3.1",
)
.await;
// Release quota
test.clear_queue().await;
// Only 1500 bytes are allowed in the queue to domain foobar.org
session
.send_message(
"[email protected]",
&["[email protected]"],
"test:no_dkim",
"250",
)
.await;
session
.send_message(
"[email protected]",
&["[email protected]"],
"test:no_dkim",
"452 4.3.1",
)
.await;
// Only 1500 bytes are allowed in the queue to recipient [email protected]
session
.send_message(
"[email protected]",
&["[email protected]"],
"test:no_dkim",
"250",
)
.await;
session
.send_message(
"[email protected]",
&["[email protected]"],
"test:no_dkim",
"452 4.3.1",
)
.await;
// Make sure store is empty
test.clear_queue().await;
let admin = test.account("admin");
admin.registry_destroy_all(ObjectType::MtaQueueQuota).await;
admin
.registry_destroy_all(ObjectType::MtaInboundThrottle)
.await;
test.assert_is_empty().await;
}
+633
View File
@@ -0,0 +1,633 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
smtp::{inbound::TestMessage, session::TestSession},
utils::{account::Account, dns::DnsCache, server::TestServer, server::TestServerBuilder},
};
use base64::{Engine, engine::general_purpose::STANDARD};
use mail_auth::{
DnssecStatus, MX,
common::{crypto::Ed25519Key, parse::TxtRecordParser, verify::DomainKey},
dkim2::{Dkim2Signer, Hop},
};
use registry::schema::{
enums::{DkimCanonicalization, DkimRotationStage},
structs::{
CertificateManagement, Dkim1Signature, Dkim2Signature, DkimManagement, DkimSignature,
DnsManagement, Domain, DsnReportSettings, Expression, SecretText, SecretTextValue,
SenderAuth,
},
};
use std::time::{Duration, Instant};
use types::id::Id;
const ED_PRIVATE: &str = concat!(
"-----BEGIN PRIVATE KEY-----\n",
"MC4CAQAwBQYDK2VwBCIEIIOQVf8MDGvvmIkpUbgoqtyUIxjlzRqaBR6aP12tcGGE\n",
"-----END PRIVATE KEY-----\n"
);
const ED_PUBLIC: &str = "hwjviTXyzUXSCWayBqE17s/4NSynQKxw58jayHudRAI=";
#[tokio::test]
#[serial_test::serial]
async fn dkim2_all_disclosed() {
let (mut local, remote) = build_signer_and_verifier(19040, 19041, false).await;
let delivered = deliver_and_collect(
&mut local,
&remote,
"[email protected]",
&["[email protected]", "[email protected]"],
&message("To: Alice <[email protected]>, Bob <[email protected]>\r\n"),
1,
)
.await;
assert_eq!(delivered.len(), 1);
let msg = &delivered[0];
assert_eq!(msg.recipients, vec!["[email protected]", "[email protected]"]);
assert!(
msg.body.contains("DKIM2-Signature"),
"missing DKIM2 signature: {}",
msg.body
);
assert!(
without_whitespace(&msg.body).contains("dkim2=pass"),
"verifier did not report dkim2=pass: {}",
msg.body
);
// Both disclosed recipients belong to the same shared signature
let stripped = without_whitespace(&msg.body);
assert!(stripped.contains(&rt_token("[email protected]")));
assert!(stripped.contains(&rt_token("[email protected]")));
}
#[tokio::test]
#[serial_test::serial]
async fn dkim2_mixed_recipients_do_not_leak_bcc() {
let (mut local, remote) = build_signer_and_verifier(19042, 19043, false).await;
let delivered = deliver_and_collect(
&mut local,
&remote,
"[email protected]",
&[
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
],
&message("To: Alice <[email protected]>\r\nCc: Bob <[email protected]>\r\n"),
3,
)
.await;
assert_eq!(delivered.len(), 3);
let eve = rt_token("[email protected]");
let mallory = rt_token("[email protected]");
for msg in &delivered {
let stripped = without_whitespace(&msg.body);
assert!(
stripped.contains("dkim2=pass"),
"verifier did not report dkim2=pass for {:?}: {}",
msg.recipients,
msg.body
);
if msg.recipients == vec!["[email protected]", "[email protected]"] {
// Disclosed copy: the two Bcc recipients must not appear anywhere
assert!(
!stripped.contains(&eve) && !stripped.contains(&mallory),
"Bcc recipient leaked into the disclosed signature: {}",
msg.body
);
assert!(!msg.body.contains("[email protected]"));
assert!(!msg.body.contains("[email protected]"));
assert!(stripped.contains(&rt_token("[email protected]")));
assert!(stripped.contains(&rt_token("[email protected]")));
} else if msg.recipients == vec!["[email protected]"] {
// Bcc copy: only Eve's address is in this signature
assert!(stripped.contains(&eve));
assert!(!stripped.contains(&mallory));
assert!(!msg.body.contains("[email protected]"));
} else if msg.recipients == vec!["[email protected]"] {
assert!(stripped.contains(&mallory));
assert!(!stripped.contains(&eve));
assert!(!msg.body.contains("[email protected]"));
} else {
panic!("unexpected recipient grouping: {:?}", msg.recipients);
}
}
}
#[tokio::test]
#[serial_test::serial]
async fn dkim2_all_undisclosed_do_not_leak() {
let (mut local, remote) = build_signer_and_verifier(19044, 19045, false).await;
let rcpts = ["[email protected]", "[email protected]", "[email protected]"];
let delivered = deliver_and_collect(
&mut local,
&remote,
"[email protected]",
&rcpts,
&message("To: undisclosed-recipients:;\r\n"),
3,
)
.await;
assert_eq!(delivered.len(), 3);
for msg in &delivered {
assert_eq!(msg.recipients.len(), 1, "expected one recipient per copy");
let own = msg.recipients[0].as_str();
let stripped = without_whitespace(&msg.body);
assert!(
stripped.contains("dkim2=pass"),
"verifier did not report dkim2=pass for {own}: {}",
msg.body
);
assert!(stripped.contains(&rt_token(own)));
// No other recipient may appear in this copy
for other in rcpts.iter().filter(|r| **r != own) {
assert!(
!stripped.contains(&rt_token(other)),
"recipient {other} leaked into the copy for {own}: {}",
msg.body
);
assert!(
!msg.body.contains(other),
"recipient {other} leaked into the copy for {own}: {}",
msg.body
);
}
}
}
#[tokio::test]
#[serial_test::serial]
async fn dkim1_and_dkim2_signed_together() {
let (mut local, remote) = build_signer_and_verifier(19046, 19047, true).await;
let delivered = deliver_and_collect(
&mut local,
&remote,
"[email protected]",
&["[email protected]", "[email protected]"],
&message("To: Alice <[email protected]>, Bob <[email protected]>\r\n"),
1,
)
.await;
assert_eq!(delivered.len(), 1);
let stripped = without_whitespace(&delivered[0].body);
assert!(
delivered[0].body.contains("DKIM-Signature"),
"missing DKIM1 signature: {}",
delivered[0].body
);
assert!(
delivered[0].body.contains("DKIM2-Signature"),
"missing DKIM2 signature: {}",
delivered[0].body
);
assert!(
stripped.contains("dkim=pass"),
"DKIM1 did not pass: {}",
delivered[0].body
);
assert!(
stripped.contains("dkim2=pass"),
"DKIM2 did not pass: {}",
delivered[0].body
);
}
#[tokio::test]
#[serial_test::serial]
async fn dkim2_dsn_is_signed() {
let mut local = TestServerBuilder::new("dkim2_dsn_signer")
.await
.with_http_listener(19048)
.await
.disable_services()
.capture_queue()
.build()
.await;
let admin = local.account("admin");
admin.mta_allow_relaying().await;
admin.mta_no_auth().await;
admin.mta_all_extensions().await;
admin.mta_disable_spam_filter().await;
admin.mta_add_all_headers().await;
let domain_id = admin.create_signing_domain(false).await;
admin
.registry_create_object(DsnReportSettings {
dkim_sign_domain: expr("'example.com'"),
..Default::default()
})
.await;
let _ = domain_id;
admin.reload_settings().await;
local.reload_core();
local.expect_reload_settings().await;
// Deliver to a domain with no DNS records: the lookup fails permanently and
// a DSN is generated for the sender.
let mut session = local.new_mta_session();
session.data.remote_ip_str = "10.0.0.1".into();
session.eval_session_params().await;
session.ehlo("mx.example.com").await;
session
.send_message(
"[email protected]",
&["[email protected]"],
&message("To: Bob <[email protected]>\r\n"),
"250",
)
.await;
local
.expect_message_then_deliver()
.await
.try_deliver(local.server.clone());
let dsn = local.expect_message().await;
assert!(
dsn.message.return_path.is_empty(),
"expected a DSN (null return path)"
);
let body = dsn.read_message(&local).await;
assert!(
body.contains("Content-Type: multipart/report"),
"not a DSN: {body}"
);
assert!(
body.contains("DKIM2-Signature"),
"the generated DSN was not DKIM2 signed: {body}"
);
}
#[tokio::test]
#[serial_test::serial]
async fn dkim2_inbound_dsn_validation() {
let mut server = TestServerBuilder::new("dkim2_dsn_receiver")
.await
.with_http_listener(19049)
.await
.disable_services()
.capture_queue()
.build()
.await;
let admin = server.account("admin");
admin.mta_allow_relaying().await;
admin.mta_no_auth().await;
admin.mta_all_extensions().await;
admin.mta_disable_spam_filter().await;
admin.mta_add_all_headers().await;
admin.configure_sender_auth("''").await;
admin.reload_settings().await;
server.reload_core();
server.expect_reload_settings().await;
// The returned message is signed by us (example.com); the DSN is signed by
// the bouncing domain (foobar.org). Publish both keys.
server
.server
.txt_add("ed._domainkey.example.com", dkim_dns_record(), valid());
server
.server
.txt_add("ed._domainkey.foobar.org", dkim_dns_record(), valid());
let returned = dkim2_sign(
"example.com",
"ed",
RETURNED_PLAIN.as_bytes(),
"[email protected]",
&["[email protected]"],
);
// A well-formed, aligned DSN is accepted
let dsn_ok = build_dsn(&returned, true);
let mut session = server.new_mta_session();
session.data.remote_ip_str = "10.0.0.1".into();
session.eval_session_params().await;
session.ehlo("mx.foobar.org").await;
session
.send_message(
"<>",
&["[email protected]"],
&String::from_utf8(dsn_ok).unwrap(),
"250",
)
.await;
// Tampering the returned message body breaks its signature chain, so the DSN
// is rejected.
let returned_tampered = String::from_utf8(returned)
.unwrap()
.replace("DKIM2-ORIGINAL-BODY-CONTENT", "DKIM2-TAMPERED-BODY-CONTENT")
.into_bytes();
let dsn_bad = build_dsn(&returned_tampered, true);
session
.send_message(
"<>",
&["[email protected]"],
&String::from_utf8(dsn_bad).unwrap(),
"550",
)
.await;
}
impl Account {
async fn create_signing_domain(&self, with_dkim1: bool) -> Id {
let domain_id = self
.registry_create_object(Domain {
name: "example.com".into(),
certificate_management: CertificateManagement::Manual,
dns_management: DnsManagement::Manual,
dkim_management: DkimManagement::Manual,
allow_relaying: true,
..Default::default()
})
.await;
self.registry_create_object(DkimSignature::Dkim2Ed25519Sha256(Dkim2Signature {
stage: DkimRotationStage::Active,
selector: "ed2".to_string(),
domain_id,
private_key: SecretText::Text(SecretTextValue {
secret: ED_PRIVATE.to_string(),
}),
..Default::default()
}))
.await;
if with_dkim1 {
self.registry_create_object(DkimSignature::Dkim1Ed25519Sha256(Dkim1Signature {
stage: DkimRotationStage::Active,
selector: "ed1".to_string(),
canonicalization: DkimCanonicalization::RelaxedRelaxed,
domain_id,
private_key: SecretText::Text(SecretTextValue {
secret: ED_PRIVATE.to_string(),
}),
..Default::default()
}))
.await;
}
domain_id
}
async fn configure_sender_auth(&self, dkim_sign_domain: &str) {
self.registry_create_object(SenderAuth {
dmarc_verify: expr("relaxed"),
reverse_ip_verify: expr("relaxed"),
spf_ehlo_verify: expr("relaxed"),
spf_from_verify: expr("relaxed"),
arc_verify: expr("relaxed"),
dkim_sign_domain: expr(dkim_sign_domain),
dkim_verify: expr("relaxed"),
dkim_strict: false,
})
.await;
}
}
async fn build_signer_and_verifier(
http_local: u16,
http_remote: u16,
with_dkim1: bool,
) -> (TestServer, TestServer) {
let mut local = TestServerBuilder::new("dkim2_signer")
.await
.with_http_listener(http_local)
.await
.disable_services()
.capture_queue()
.build()
.await;
let mut remote = TestServerBuilder::new("dkim2_verifier")
.await
.with_http_listener(http_remote)
.await
.with_smtp_listener(9925)
.await
.disable_services()
.capture_queue()
.build()
.await;
// Signer (originating MTA)
let admin = local.account("admin");
admin.mta_allow_relaying().await;
admin.mta_no_auth().await;
admin.mta_all_extensions().await;
admin.mta_disable_spam_filter().await;
admin.mta_add_all_headers().await;
admin.create_signing_domain(with_dkim1).await;
admin.configure_sender_auth("'example.com'").await;
admin.reload_settings().await;
local.reload_core();
local.expect_reload_settings().await;
// Verifier (receiving MTA)
let remote_admin = remote.account("admin");
remote_admin.mta_allow_relaying().await;
remote_admin.mta_no_auth().await;
remote_admin.mta_all_extensions().await;
remote_admin.mta_disable_spam_filter().await;
remote_admin.mta_add_all_headers().await;
remote_admin.configure_sender_auth("''").await;
remote_admin.reload_settings().await;
remote.reload_core();
remote.expect_reload_settings().await;
// Publish the signer's public keys in the verifier's DNS
remote
.server
.txt_add("ed2._domainkey.example.com", dkim_dns_record(), valid());
if with_dkim1 {
remote
.server
.txt_add("ed1._domainkey.example.com", dkim_dns_record(), valid());
}
// Route foobar.org deliveries back to the local (in-process) receiver
local.server.mx_add(
"foobar.org",
vec![MX {
exchanges: vec!["mx.foobar.org".into()].into_boxed_slice(),
preference: 10,
}],
DnssecStatus::Secure,
valid(),
);
local
.server
.ipv4_add("mx.foobar.org", vec!["127.0.0.1".parse().unwrap()], valid());
(local, remote)
}
fn message(to_header: &str) -> String {
format!(
concat!(
"From: John Doe <[email protected]>\r\n",
"{}",
"Subject: DKIM2 privacy test\r\n",
"\r\n",
"This is a DKIM2 test message.\r\n",
),
to_header
)
}
fn build_dsn(returned: &[u8], sign_dsn: bool) -> Vec<u8> {
let mut body = Vec::new();
body.extend_from_slice(b"--BOUNDARY\r\nContent-Type: text/plain\r\n\r\n");
body.extend_from_slice(b"Delivery to [email protected] failed.\r\n");
body.extend_from_slice(b"--BOUNDARY\r\nContent-Type: message/delivery-status\r\n\r\n");
body.extend_from_slice(b"Reporting-MTA: dns; foobar.org\r\n\r\n");
body.extend_from_slice(b"Final-Recipient: rfc822; [email protected]\r\n");
body.extend_from_slice(b"Action: failed\r\nStatus: 5.1.1\r\n");
body.extend_from_slice(b"--BOUNDARY\r\nContent-Type: message/rfc822\r\n\r\n");
body.extend_from_slice(returned);
body.extend_from_slice(b"\r\n--BOUNDARY--\r\n");
let mut dsn = Vec::new();
dsn.extend_from_slice(b"From: [email protected]\r\n");
dsn.extend_from_slice(b"To: [email protected]\r\n");
dsn.extend_from_slice(b"Subject: Delivery Status Notification (Failure)\r\n");
dsn.extend_from_slice(b"Date: Sat, 01 Mar 2026 12:05:00 +0000\r\n");
dsn.extend_from_slice(b"Message-ID: <[email protected]>\r\n");
dsn.extend_from_slice(
b"Content-Type: multipart/report; report-type=delivery-status; boundary=\"BOUNDARY\"\r\n\r\n",
);
dsn.extend_from_slice(&body);
if sign_dsn {
dkim2_sign("foobar.org", "ed", &dsn, "<>", &["[email protected]"])
} else {
dsn
}
}
fn ed25519_key() -> Ed25519Key {
let der = STANDARD
.decode("MC4CAQAwBQYDK2VwBCIEIIOQVf8MDGvvmIkpUbgoqtyUIxjlzRqaBR6aP12tcGGE")
.unwrap();
Ed25519Key::from_pkcs8_maybe_unchecked_der(&der).unwrap()
}
fn dkim2_sign(
domain: &str,
selector: &str,
message: &[u8],
mail_from: &str,
rcpt_to: &[&str],
) -> Vec<u8> {
let signed = Dkim2Signer::from_key(ed25519_key())
.domain(domain)
.selector(selector)
.sign(message, Hop::real(mail_from, rcpt_to))
.expect("dkim2 sign");
let mut out = signed.to_header().into_bytes();
out.extend_from_slice(message);
out
}
const RETURNED_PLAIN: &str = concat!(
"From: John Doe <[email protected]>\r\n",
"To: Bob <[email protected]>\r\n",
"Subject: Original message\r\n",
"Date: Sat, 01 Mar 2026 12:00:00 +0000\r\n",
"Message-ID: <[email protected]>\r\n",
"\r\n",
"DKIM2-ORIGINAL-BODY-CONTENT\r\n",
);
struct Delivered {
recipients: Vec<String>,
body: String,
}
async fn deliver_and_collect(
local: &mut TestServer,
remote: &TestServer,
from: &str,
rcpts: &[&str],
raw: &str,
expected: usize,
) -> Vec<Delivered> {
let mut session = local.new_mta_session();
session.data.remote_ip_str = "10.0.0.1".into();
session.eval_session_params().await;
session.ehlo("mx.example.com").await;
session.send_message(from, rcpts, raw, "250").await;
local
.expect_message_then_deliver()
.await
.try_deliver(local.server.clone());
let mut delivered = Vec::new();
for _ in 0..expected {
let mut waited = 0;
let msg = loop {
if let Some(msg) = remote.read_queued_messages().await.into_iter().next() {
break msg;
}
assert!(waited < 100, "timed out waiting for a delivered message");
tokio::time::sleep(Duration::from_millis(50)).await;
waited += 1;
};
let body = msg.read_message(remote).await;
let mut recipients = msg
.message
.recipients
.iter()
.map(|r| r.address().to_string())
.collect::<Vec<_>>();
recipients.sort();
let due = remote.message_due(msg.queue_id).await;
msg.clone().remove(&remote.server, due.into()).await;
delivered.push(Delivered { recipients, body });
}
delivered
}
fn rt_token(address: &str) -> String {
STANDARD.encode(format!("<{address}>"))
}
fn without_whitespace(value: &str) -> String {
value.chars().filter(|c| !c.is_whitespace()).collect()
}
fn dkim_dns_record() -> DomainKey {
DomainKey::parse(format!("v=DKIM1; k=ed25519; p={ED_PUBLIC}").as_bytes()).unwrap()
}
fn valid() -> Instant {
Instant::now() + Duration::from_secs(300)
}
fn expr(value: &str) -> Expression {
Expression {
else_: value.into(),
..Default::default()
}
}
+483
View File
@@ -0,0 +1,483 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
smtp::{
inbound::{TestMessage, TestReportingEvent},
session::{TestSession, VerifyResponse},
},
utils::{dns::DnsCache, server::TestServerBuilder},
};
use common::config::smtp::report::AggregateFrequency;
use mail_auth::{
common::{parse::TxtRecordParser, verify::DomainKey},
dkim::DomainKeyReport,
dmarc::Dmarc,
report::DmarcResult,
spf::Spf,
};
use registry::{
schema::structs::{
CertificateManagement, DkimManagement, DkimReportSettings, DmarcReportSettings,
DnsManagement, Domain, Expression, ExpressionMatch, SenderAuth, SpfReportSettings,
},
types::list::List,
};
use std::time::{Duration, Instant};
#[tokio::test]
async fn dmarc() {
let mut test = TestServerBuilder::new("smtp_dmarc_test")
.await
.with_http_listener(19012)
.await
.disable_services()
.capture_queue()
.capture_reporting()
.build()
.await;
// Add test settings
let admin = test.account("admin");
let domain_id = admin
.registry_create_object(Domain {
name: "localdomain.org".into(),
certificate_management: CertificateManagement::Manual,
dns_management: DnsManagement::Manual,
dkim_management: DkimManagement::Manual,
allow_relaying: true,
..Default::default()
})
.await;
admin.create_dkim_signatures(domain_id).await;
admin.mta_no_auth().await;
admin.mta_add_all_headers().await;
admin
.registry_create_object(SenderAuth {
dmarc_verify: Expression {
else_: "strict".into(),
..Default::default()
},
reverse_ip_verify: Expression {
else_: "relaxed".into(),
..Default::default()
},
spf_ehlo_verify: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "remote_ip = '10.0.0.2'".into(),
then: "strict".into(),
}]),
else_: "relaxed".into(),
},
spf_from_verify: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "remote_ip = '10.0.0.2'".into(),
then: "strict".into(),
}]),
else_: "relaxed".into(),
},
arc_verify: Expression {
else_: "strict".into(),
..Default::default()
},
dkim_sign_domain: Expression {
else_: "'localdomain.org'".into(),
..Default::default()
},
dkim_verify: Expression {
match_: List::from_iter([
ExpressionMatch {
if_: "sender_domain = 'test.net'".into(),
then: "relaxed".into(),
},
ExpressionMatch {
if_: "sender_domain = 'xn--eebajf.xn--9dbq2a'".into(),
then: "relaxed".into(),
},
ExpressionMatch {
if_: "sender_domain = 'tmp._dns_error.test'".into(),
then: "relaxed".into(),
},
]),
else_: "strict".into(),
},
dkim_strict: false,
})
.await;
admin
.registry_create_object(DkimReportSettings {
dkim_sign_domain: Expression {
else_: "'localdomain.org'".into(),
..Default::default()
},
send_frequency: Expression {
else_: "[1, 1s]".into(),
..Default::default()
},
..Default::default()
})
.await;
admin
.registry_create_object(SpfReportSettings {
dkim_sign_domain: Expression {
else_: "'localdomain.org'".into(),
..Default::default()
},
send_frequency: Expression {
else_: "[1, 1s]".into(),
..Default::default()
},
..Default::default()
})
.await;
admin
.registry_create_object(DmarcReportSettings {
failure_dkim_sign_domain: Expression {
else_: "'localdomain.org'".into(),
..Default::default()
},
failure_send_frequency: Expression {
else_: "[1, 1s]".into(),
..Default::default()
},
aggregate_send_frequency: Expression {
else_: "daily".into(),
..Default::default()
},
..Default::default()
})
.await;
admin.reload_settings().await;
test.reload_core();
test.expect_reload_settings().await;
// Add SPF, DKIM and DMARC records
test.server.txt_add(
"mx.example.com",
Spf::parse(b"v=spf1 ip4:10.0.0.1 ip4:10.0.0.2 -all").unwrap(),
Instant::now() + Duration::from_secs(5),
);
test.server.txt_add(
"example.com",
Spf::parse(b"v=spf1 ip4:10.0.0.1 -all ra=spf-failures rr=e:f:s:n").unwrap(),
Instant::now() + Duration::from_secs(5),
);
test.server.txt_add(
"foobar.com",
Spf::parse(b"v=spf1 ip4:10.0.0.1 -all").unwrap(),
Instant::now() + Duration::from_secs(5),
);
test.server.txt_add(
"ed._domainkey.example.com",
DomainKey::parse(
concat!(
"v=DKIM1; k=ed25519; ",
"p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo="
)
.as_bytes(),
)
.unwrap(),
Instant::now() + Duration::from_secs(5),
);
test.server.txt_add(
"default._domainkey.example.com",
DomainKey::parse(
concat!(
"v=DKIM1; t=s; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQ",
"KBgQDwIRP/UC3SBsEmGqZ9ZJW3/DkMoGeLnQg1fWn7/zYt",
"IxN2SnFCjxOCKG9v3b4jYfcTNh5ijSsq631uBItLa7od+v",
"/RtdC2UzJ1lWT947qR+Rcac2gbto/NMqJ0fzfVjH4OuKhi",
"tdY9tf6mcwGjaNBcWToIMmPSPDdQPNUYckcQ2QIDAQAB",
)
.as_bytes(),
)
.unwrap(),
Instant::now() + Duration::from_secs(5),
);
test.server.txt_add(
"_report._domainkey.example.com",
DomainKeyReport::parse(b"ra=dkim-failures; rp=100; rr=d:o:p:s:u:v:x;").unwrap(),
Instant::now() + Duration::from_secs(5),
);
test.server.txt_add(
"_dmarc.example.com",
Dmarc::parse(
concat!(
"v=DMARC1; p=reject; sp=quarantine; np=None; aspf=s; adkim=s; fo=1;",
"rua=mailto:[email protected];",
"ruf=mailto:[email protected]"
)
.as_bytes(),
)
.unwrap(),
Instant::now() + Duration::from_secs(5),
);
// SPF must pass
let mut session = test.new_mta_session();
session.data.remote_ip_str = "10.0.0.2".into();
session.data.remote_ip = session.data.remote_ip_str.parse().unwrap();
session.eval_session_params().await;
session.ehlo("mx.example.com").await;
session.mail_from("[email protected]", "550 5.7.23").await;
// Expect SPF auth failure report
let message = test.expect_message().await;
assert_eq!(
message.message.recipients.last().unwrap().address(),
"[email protected]"
);
message
.read_lines(&test)
.await
.assert_contains("DKIM-Signature: v=1; a=rsa-sha256; s=rsa; d=localdomain.org;")
.assert_contains("To: [email protected]")
.assert_contains("Feedback-Type: auth-failure")
.assert_contains("Auth-Failure: spf");
// Second DKIM failure report should be rate limited
session.mail_from("[email protected]", "550 5.7.23").await;
test.assert_no_events();
// Invalid DKIM signatures should be rejected
session.data.remote_ip_str = "10.0.0.1".into();
session.data.remote_ip = session.data.remote_ip_str.parse().unwrap();
session.eval_session_params().await;
session
.send_message(
"[email protected]",
&["[email protected]"],
"test:invalid_dkim",
"550 5.7.20",
)
.await;
// Expect DKIM auth failure report
let message = test.expect_message().await;
assert_eq!(
message.message.recipients.last().unwrap().address(),
"[email protected]"
);
message
.read_lines(&test)
.await
.assert_contains("DKIM-Signature: v=1; a=rsa-sha256; s=rsa; d=localdomain.org;")
.assert_contains("To: [email protected]")
.assert_contains("Feedback-Type: auth-failure")
.assert_contains("Auth-Failure: bodyhash");
// Second DKIM failure report should be rate limited
session
.send_message(
"[email protected]",
&["[email protected]"],
"test:invalid_dkim",
"550 5.7.20",
)
.await;
test.assert_no_events();
// Invalid ARC should be rejected
session
.send_message(
"[email protected]",
&["[email protected]"],
"test:invalid_arc",
"550 5.7.29",
)
.await;
test.assert_no_events();
// Unaligned DMARC should be rejected
test.server.txt_add(
"test.net",
Spf::parse(b"v=spf1 -all").unwrap(),
Instant::now() + Duration::from_secs(5),
);
session
.send_message(
"[email protected]",
&["[email protected]"],
"test:invalid_dkim",
"550 5.7.1",
)
.await;
// Expect DMARC auth failure report
let message = test.expect_message().await;
assert_eq!(
message.message.recipients.last().unwrap().address(),
"[email protected]"
);
message
.read_lines(&test)
.await
.assert_contains("DKIM-Signature: v=1; a=rsa-sha256; s=rsa; d=localdomain.org;")
.assert_contains("To: [email protected]")
.assert_contains("Feedback-Type: auth-failure")
.assert_contains("Auth-Failure: dmarc")
.assert_contains("dmarc=3Dfail");
// Expect DMARC aggregate report
let report = test.read_report().await.unwrap_dmarc();
assert_eq!(report.domain, "example.com");
assert_eq!(report.interval, AggregateFrequency::Daily);
assert_eq!(report.dmarc_record.rua().len(), 1);
assert_eq!(report.report_record.dmarc_spf_result(), DmarcResult::Fail);
// Second DMARC failure report should be rate limited
session
.send_message(
"[email protected]",
&["[email protected]"],
"test:invalid_dkim",
"550 5.7.1",
)
.await;
test.assert_no_events();
// Messages passing DMARC should be accepted
session
.send_message(
"[email protected]",
&["[email protected]"],
"test:dkim",
"250",
)
.await;
test.expect_message()
.await
.read_lines(&test)
.await
.assert_contains("dkim=pass")
.assert_contains("spf=pass")
.assert_contains("dmarc=pass")
.assert_contains("Received-SPF: pass");
// A mechanism that authenticates an unaligned identity is reported as failed
test.server.txt_add(
"_dmarc.example.com",
Dmarc::parse(
concat!(
"v=DMARC1; p=reject; sp=quarantine; np=None; aspf=s; adkim=s; fo=1;",
"rua=mailto:[email protected];",
"ruf=mailto:[email protected]"
)
.as_bytes(),
)
.unwrap(),
Instant::now() + Duration::from_secs(5),
);
test.server.txt_add(
"ed._domainkey.example.com",
DomainKey::parse(
concat!(
"v=DKIM1; k=ed25519; ",
"p=21qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo="
)
.as_bytes(),
)
.unwrap(),
Instant::now() + Duration::from_secs(5),
);
test.server.txt_add(
"_report._domainkey.example.com",
DomainKeyReport::parse(b"ra=dkim-failures; rp=0; rr=d:o:p:s:u:v:x;").unwrap(),
Instant::now() + Duration::from_secs(5),
);
session
.send_message(
"[email protected]",
&["[email protected]"],
"test:dkim",
"250",
)
.await;
let mut failure_report = None;
for _ in 0..2 {
let message = test.consume_message().await;
let rcpt = message
.message
.recipients
.last()
.unwrap()
.address()
.to_string();
if rcpt == "[email protected]" {
failure_report = Some(message.read_lines(&test).await);
}
}
failure_report
.expect("no DMARC failure report was sent")
.assert_contains("Feedback-Type: auth-failure")
.assert_contains("Auth-Failure: dmarc")
.assert_contains("Identity-Alignment: spf")
.assert_contains("SPF-DNS: txt : foobar.com")
.assert_not_contains("DKIM-Domain:");
// Aggregate reports identify an IDN author domain by its A-label
test.server.txt_add(
"xn--eebajf.xn--9dbq2a",
Spf::parse(b"v=spf1 ip4:10.0.0.1 -all").unwrap(),
Instant::now() + Duration::from_secs(5),
);
test.server.txt_add(
"_dmarc.xn--eebajf.xn--9dbq2a",
Dmarc::parse(
concat!(
"v=DMARC1; p=none; aspf=s; adkim=s; fo=1;",
"rua=mailto:[email protected]"
)
.as_bytes(),
)
.unwrap(),
Instant::now() + Duration::from_secs(5),
);
session
.send_message(
"[email protected]",
&["[email protected]"],
"test:idn_from",
"250",
)
.await;
test.consume_message().await;
let mut idn_report = None;
for _ in 0..10 {
let Some(report) = test.try_read_report().await else {
break;
};
let report = report.unwrap_dmarc();
if report.domain == "xn--eebajf.xn--9dbq2a" {
idn_report = Some(report);
break;
}
}
let report = idn_report.expect("no aggregate report for the IDN author domain");
assert_eq!(report.report_record.header_from(), "xn--eebajf.xn--9dbq2a");
assert_eq!(
report.report_record.envelope_from(),
"xn--eebajf.xn--9dbq2a"
);
// An aligned SPF temperror under p=reject is temporarily rejected in strict mode
test.server.txt_add(
"_dmarc.tmp._dns_error.test",
Dmarc::parse(b"v=DMARC1; p=reject; psd=n").unwrap(),
Instant::now() + Duration::from_secs(5),
);
session
.send_message(
"joe@tmp._dns_error.test",
&["[email protected]"],
"From: joe@tmp._dns_error.test\r\nTo: [email protected]\r\nSubject: test\r\n\r\ntest",
"451 4.7.1",
)
.await;
test.assert_no_events();
}
+173
View File
@@ -0,0 +1,173 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
smtp::session::{TestSession, VerifyResponse},
utils::{dns::DnsCache, server::TestServerBuilder},
};
use mail_auth::{SpfResult, common::parse::TxtRecordParser, spf::Spf};
use mail_parser::DateTime;
use registry::{
schema::structs::{
Expression, ExpressionMatch, MtaExtensions, MtaStageData, MtaStageEhlo, SenderAuth,
},
types::list::List,
};
use std::time::{Duration, Instant, SystemTime};
#[tokio::test]
async fn ehlo() {
let mut test = TestServerBuilder::new("smtp_ehlo_test")
.await
.with_http_listener(19005)
.await
.disable_services()
.build()
.await;
// Add test settings
let admin = test.account("admin");
admin.mta_no_auth().await;
admin
.registry_create_object(MtaExtensions {
future_release: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "remote_ip = '10.0.0.1'".into(),
then: "1h".into(),
}]),
else_: "false".into(),
},
mt_priority: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "remote_ip = '10.0.0.1'".into(),
then: "nsep".into(),
}]),
else_: "false".into(),
},
..Default::default()
})
.await;
admin
.registry_create_object(MtaStageEhlo {
reject_non_fqdn: Expression {
else_: "starts_with(remote_ip, '10.0.0.')".into(),
..Default::default()
},
..Default::default()
})
.await;
admin
.registry_create_object(MtaStageData {
max_message_size: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "remote_ip = '10.0.0.1'".into(),
then: "1024".into(),
}]),
else_: "2048".into(),
},
..Default::default()
})
.await;
admin
.registry_create_object(SenderAuth {
dmarc_verify: Expression {
else_: "relaxed".into(),
..Default::default()
},
reverse_ip_verify: Expression {
else_: "relaxed".into(),
..Default::default()
},
spf_ehlo_verify: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "remote_ip = '10.0.0.2'".into(),
then: "strict".into(),
}]),
else_: "relaxed".into(),
},
spf_from_verify: Expression {
else_: "relaxed".into(),
..Default::default()
},
..Default::default()
})
.await;
admin.reload_settings().await;
test.reload_core();
test.server.txt_add(
"mx1.foobar.org",
Spf::parse(b"v=spf1 ip4:10.0.0.1 -all").unwrap(),
Instant::now() + Duration::from_secs(5),
);
test.server.txt_add(
"mx2.foobar.org",
Spf::parse(b"v=spf1 ip4:10.0.0.2 -all").unwrap(),
Instant::now() + Duration::from_secs(5),
);
// Reject non-FQDN domains
let mut session = test.new_mta_session();
session.data.remote_ip_str = "10.0.0.1".into();
session.data.remote_ip = session.data.remote_ip_str.parse().unwrap();
session.stream.tls = false;
session.eval_session_params().await;
session.cmd("EHLO domain", "550 5.5.0").await;
// EHLO capabilities evaluation
let response = session
.cmd("EHLO mx1.foobar.org", "250")
.await
.assert_contains("SIZE 1024")
.assert_contains("MT-PRIORITY NSEP")
.assert_contains("FUTURERELEASE 3600 ")
.assert_contains("STARTTLS");
// The advertised max-future-release-date-time is an RFC 3339 date-time
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_secs()) as i64;
let max_datetime = response
.iter()
.find_map(|line| line.split("FUTURERELEASE 3600 ").nth(1))
.and_then(|v| v.split_whitespace().next())
.and_then(DateTime::parse_rfc3339)
.expect("FUTURERELEASE did not advertise an RFC 3339 date-time")
.to_timestamp();
assert!(
((now + 3595)..=(now + 3605)).contains(&max_datetime),
"unexpected max-future-release-date-time {max_datetime}, now is {now}"
);
// SPF should be a Pass for 10.0.0.1
assert_eq!(
session.data.spf_ehlo.as_ref().unwrap().result(),
SpfResult::Pass
);
// Test SPF strict mode
session.data.helo_domain = "".into();
session.data.remote_ip_str = "10.0.0.2".into();
session.data.remote_ip = session.data.remote_ip_str.parse().unwrap();
session.stream.tls = true;
session.eval_session_params().await;
session.ingest(b"EHLO mx1.foobar.org\r\n").await.unwrap();
session.response().assert_code("550 5.7.23");
// EHLO capabilities evaluation
session.ingest(b"EHLO mx2.foobar.org\r\n").await.unwrap();
assert_eq!(
session.data.spf_ehlo.as_ref().unwrap().result(),
SpfResult::Pass
);
session
.response()
.assert_code("250")
.assert_contains("SIZE 2048")
.assert_not_contains("MT-PRIORITY")
.assert_not_contains("FUTURERELEASE")
.assert_not_contains("STARTTLS");
}
+92
View File
@@ -0,0 +1,92 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
smtp::session::{TestSession, VerifyResponse},
utils::server::TestServerBuilder,
};
use registry::{
schema::structs::{Expression, ExpressionMatch, MtaInboundSession},
types::list::List,
};
use std::time::{Duration, Instant};
#[tokio::test]
async fn limits() {
let mut test = TestServerBuilder::new("smtp_inbound_limits_test")
.await
.with_http_listener(19013)
.await
.disable_services()
.build()
.await;
// Add test settings
let admin = test.account("admin");
admin
.registry_create_object(MtaInboundSession {
max_duration: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "remote_ip = '10.0.0.3'".into(),
then: "500ms".into(),
}]),
else_: "60m".into(),
},
timeout: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "remote_ip = '10.0.0.2'".into(),
then: "500ms".into(),
}]),
else_: "30m".into(),
},
transfer_limit: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "remote_ip = '10.0.0.1'".into(),
then: "10".into(),
}]),
else_: "1024".into(),
},
})
.await;
admin.reload_settings().await;
test.reload_core();
// Exceed max line length
let (mut session, _tx) = test.new_mta_session_with_shutdown();
session.data.remote_ip_str = "10.0.0.1".into();
let mut buf = vec![b'A'; 4097];
session.ingest(&buf).await.unwrap();
session.ingest(b"\r\n").await.unwrap();
session.response().assert_code("554 5.3.4");
// Invalid command
buf.extend_from_slice(b"\r\n");
session.ingest(&buf).await.unwrap();
session.response().assert_code("500 5.5.1");
// Exceed transfer quota
session.eval_session_params().await;
session.write_rx("MAIL FROM:<this_is_a_long@command_over_10_chars.com>\r\n");
session.handle_conn().await;
session.response().assert_code("452 4.7.28");
// Loitering
session.data.remote_ip_str = "10.0.0.3".into();
session.data.valid_until = Instant::now();
session.eval_session_params().await;
tokio::time::sleep(Duration::from_millis(600)).await;
session.write_rx("MAIL FROM:<this_is_a_long@command_over_10_chars.com>\r\n");
session.handle_conn().await;
session.response().assert_code("421 4.3.2");
// Timeout
session.data.remote_ip_str = "10.0.0.2".into();
session.data.valid_until = Instant::now();
session.eval_session_params().await;
session.write_rx("MAIL FROM:<this_is_a_long@command_over_10_chars.com>\r\n");
session.handle_conn().await;
session.response().assert_code("221 2.0.0");
}
+423
View File
@@ -0,0 +1,423 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
smtp::session::{TestSession, VerifyResponse},
utils::{dns::DnsCache, server::TestServerBuilder},
};
use mail_auth::{IprevResult, SpfResult, common::parse::TxtRecordParser, spf::Spf};
use mail_parser::DateTime;
use registry::{
schema::{
enums::MtaInboundThrottleKey,
structs::{
Expression, ExpressionMatch, MtaExtensions, MtaInboundThrottle, MtaStageData,
MtaStageEhlo, MtaStageMail, Rate, SenderAuth,
},
},
types::{list::List, map::Map},
};
use smtp_proto::{MAIL_BY_NOTIFY, MAIL_BY_RETURN, MAIL_REQUIRETLS};
use std::time::{Duration, Instant, SystemTime};
#[tokio::test]
async fn mail() {
let mut test = TestServerBuilder::new("smtp_mail_from_test")
.await
.with_http_listener(19003)
.await
.disable_services()
.build()
.await;
// Add test settings
let admin = test.account("admin");
admin
.registry_create_object(MtaStageEhlo {
require: Expression {
else_: "true".into(),
..Default::default()
},
..Default::default()
})
.await;
admin.mta_no_auth().await;
admin
.registry_create_object(SenderAuth {
reverse_ip_verify: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "remote_ip = '10.0.0.2'".into(),
then: "strict".into(),
}]),
else_: "relaxed".into(),
},
spf_ehlo_verify: Expression {
else_: "relaxed".into(),
..Default::default()
},
spf_from_verify: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "remote_ip = '10.0.0.2'".into(),
then: "strict".into(),
}]),
else_: "relaxed".into(),
},
..Default::default()
})
.await;
admin
.registry_create_object(MtaExtensions {
deliver_by: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "remote_ip = '10.0.0.2'".into(),
then: "1d".into(),
}]),
else_: "false".into(),
},
future_release: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "remote_ip = '10.0.0.2'".into(),
then: "1d".into(),
}]),
else_: "false".into(),
},
mt_priority: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "remote_ip = '10.0.0.2'".into(),
then: "nsep".into(),
}]),
else_: "false".into(),
},
require_tls: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "remote_ip = '10.0.0.2'".into(),
then: "true".into(),
}]),
else_: "false".into(),
},
..Default::default()
})
.await;
admin
.registry_create_object(MtaStageMail {
is_sender_allowed: Expression {
else_: "sender_domain != 'blocked.com'".into(),
..Default::default()
},
..Default::default()
})
.await;
admin
.registry_create_object(MtaStageData {
max_message_size: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "remote_ip = '10.0.0.2'".into(),
then: "2048".into(),
}]),
else_: "1024".into(),
},
..Default::default()
})
.await;
admin
.registry_create_object(MtaInboundThrottle {
description: "Test throttle".into(),
enable: true,
key: Map::new(vec![MtaInboundThrottleKey::Sender]),
match_: Expression {
else_: "remote_ip = '10.0.0.1'".into(),
..Default::default()
},
rate: Rate {
count: 2,
period: 1000u64.into(),
},
})
.await;
admin.reload_settings().await;
test.reload_core();
test.server.txt_add(
"foobar.org",
Spf::parse(b"v=spf1 ip4:10.0.0.1 -all").unwrap(),
Instant::now() + Duration::from_secs(5),
);
test.server.txt_add(
"mx1.foobar.org",
Spf::parse(b"v=spf1 ip4:10.0.0.1 -all").unwrap(),
Instant::now() + Duration::from_secs(5),
);
test.server.ptr_add(
"10.0.0.1".parse().unwrap(),
vec!["mx1.foobar.org.".to_string()],
Instant::now() + Duration::from_secs(5),
);
test.server.ipv4_add(
"mx1.foobar.org.",
vec!["10.0.0.1".parse().unwrap()],
Instant::now() + Duration::from_secs(5),
);
test.server.ptr_add(
"10.0.0.2".parse().unwrap(),
vec!["mx2.foobar.org.".to_string()],
Instant::now() + Duration::from_secs(5),
);
// Be rude and do not say EHLO
let mut session = test.new_mta_session();
session.data.remote_ip_str = "10.0.0.1".into();
session.data.remote_ip = session.data.remote_ip_str.parse().unwrap();
session.eval_session_params().await;
session
.ingest(b"MAIL FROM:<[email protected]>\r\n")
.await
.unwrap();
session.response().assert_code("503 5.5.1");
// Test sender not allowed
session.ingest(b"EHLO mx1.foobar.org\r\n").await.unwrap();
session.response().assert_code("250");
session
.ingest(b"MAIL FROM:<[email protected]>\r\n")
.await
.unwrap();
session.response().assert_code("550 5.7.1");
// Both IPREV and SPF should pass
session
.ingest(b"MAIL FROM:<[email protected]>\r\n")
.await
.unwrap();
session.response().assert_code("250");
assert_eq!(
session.data.spf_ehlo.as_ref().unwrap().result(),
SpfResult::Pass
);
assert_eq!(
session.data.spf_mail_from.as_ref().unwrap().result(),
SpfResult::Pass
);
assert_eq!(
session.data.iprev.as_ref().unwrap().result(),
&IprevResult::Pass
);
// Multiple MAIL FROMs should not be allowed
session
.ingest(b"MAIL FROM:<[email protected]>\r\n")
.await
.unwrap();
session.response().assert_code("503 5.5.1");
// Test rate limit
for n in 0..2 {
session.rset().await;
session
.ingest(b"MAIL FROM:<[email protected]>\r\n")
.await
.unwrap();
session
.response()
.assert_code(if n == 0 { "250" } else { "452 4.4.5" });
}
// Test disabled extensions
for param in [
"HOLDFOR=123",
"HOLDUNTIL=2079-11-20T05:00:00Z",
"MT-PRIORITY=3",
"BY=120;R",
"REQUIRETLS",
] {
session
.ingest(format!("MAIL FROM:<[email protected]> {param}\r\n").as_bytes())
.await
.unwrap();
session.response().assert_code("501 5.5.4");
}
// Test size with a large value
session
.ingest(b"MAIL FROM:<[email protected]> SIZE=1512\r\n")
.await
.unwrap();
session.response().assert_code("552 5.3.4");
// Test strict IPREV
session.data.remote_ip_str = "10.0.0.2".into();
session.data.remote_ip = session.data.remote_ip_str.parse().unwrap();
session.data.iprev = None;
session.eval_session_params().await;
session
.ingest(b"MAIL FROM:<[email protected]>\r\n")
.await
.unwrap();
session.response().assert_code("550 5.7.25");
session.data.iprev = None;
test.server.ipv4_add(
"mx2.foobar.org.",
vec!["10.0.0.2".parse().unwrap()],
Instant::now() + Duration::from_secs(5),
);
// Test strict SPF
session
.ingest(b"MAIL FROM:<[email protected]>\r\n")
.await
.unwrap();
session.response().assert_code("550 5.7.23");
test.server.txt_add(
"foobar.org",
Spf::parse(b"v=spf1 ip4:10.0.0.1 ip4:10.0.0.2 -all").unwrap(),
Instant::now() + Duration::from_secs(5),
);
session
.ingest(b"MAIL FROM:<[email protected]>\r\n")
.await
.unwrap();
session.response().assert_code("250");
let mail_from = session.data.mail_from.as_ref().unwrap();
assert_eq!(mail_from.domain, "foobar.org");
assert_eq!(mail_from.address, "[email protected]");
assert_eq!(mail_from.address_lcase, "[email protected]");
session.rset().await;
// Test SIZE extension
session
.ingest(b"MAIL FROM:<[email protected]> SIZE=1023\r\n")
.await
.unwrap();
session.response().assert_code("250");
session.rset().await;
// Test MT-PRIORITY extension
session
.ingest(b"MAIL FROM:<[email protected]> MT-PRIORITY=-3\r\n")
.await
.unwrap();
session.response().assert_code("250");
assert_eq!(session.data.priority, -3);
session.rset().await;
// Test REQUIRETLS extension
session
.ingest(b"MAIL FROM:<[email protected]> REQUIRETLS\r\n")
.await
.unwrap();
session.response().assert_code("250");
assert!((session.data.mail_from.as_ref().unwrap().flags & MAIL_REQUIRETLS) != 0);
session.rset().await;
// Test DELIVERBY extension with by-mode=R
session
.ingest(b"MAIL FROM:<[email protected]> BY=120;R\r\n")
.await
.unwrap();
session.response().assert_code("250");
assert!((session.data.mail_from.as_ref().unwrap().flags & MAIL_BY_RETURN) != 0);
assert_eq!(session.data.delivery_by, 120);
session.rset().await;
// Test DELIVERBY extension with by-mode=N
session
.ingest(b"MAIL FROM:<[email protected]> BY=-456;N\r\n")
.await
.unwrap();
session.response().assert_code("250");
assert!((session.data.mail_from.as_ref().unwrap().flags & MAIL_BY_NOTIFY) != 0);
assert_eq!(session.data.delivery_by, -456);
session.rset().await;
// Test DELIVERBY extension with invalid by-mode=R
session
.ingest(b"MAIL FROM:<[email protected]> BY=-1;R\r\n")
.await
.unwrap();
session.response().assert_code("501 5.5.4");
session.rset().await;
session
.ingest(b"MAIL FROM:<[email protected]> BY=99999;R\r\n")
.await
.unwrap();
session.response().assert_code("501 5.5.4");
session.rset().await;
// Test FUTURERELEASE extension with HOLDFOR
session
.ingest(b"MAIL FROM:<[email protected]> HOLDFOR=1234\r\n")
.await
.unwrap();
session.response().assert_code("250");
assert_eq!(session.data.future_release, 1234);
session.rset().await;
// Test FUTURERELEASE extension with invalid HOLDFOR falue
session
.ingest(b"MAIL FROM:<[email protected]> HOLDFOR=99999\r\n")
.await
.unwrap();
session.response().assert_code("501 5.5.4");
session.rset().await;
// Test FUTURERELEASE extension with HOLDUNTIL
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let hold_until = |offset: u64| {
format!(
"MAIL FROM:<[email protected]> HOLDUNTIL={}\r\n",
DateTime::from_timestamp((now + offset) as i64).to_rfc3339()
)
};
session.ingest(hold_until(10).as_bytes()).await.unwrap();
session.response().assert_code("250");
assert!((9..=10).contains(&session.data.future_release));
session.rset().await;
// Test FUTURERELEASE extension with invalid HOLDUNTIL value
session.ingest(hold_until(99999).as_bytes()).await.unwrap();
session.response().assert_code("501 5.5.4");
session.rset().await;
// Test FUTURERELEASE extension with a HOLDUNTIL value that is not an RFC 3339 date-time
session
.ingest(format!("MAIL FROM:<[email protected]> HOLDUNTIL={}\r\n", now + 10).as_bytes())
.await
.unwrap();
session.response().assert_code("501 5.5.4");
session.rset().await;
// Test FUTURERELEASE extension with a HOLDUNTIL value in the past
session
.ingest(b"MAIL FROM:<[email protected]> HOLDUNTIL=2020-01-01T00:00:00Z\r\n")
.await
.unwrap();
session.response().assert_code("501 5.5.4");
session.rset().await;
// Test FUTURERELEASE extension with both HOLDFOR and HOLDUNTIL
session
.ingest(
format!(
"MAIL FROM:<[email protected]> HOLDFOR=1234 HOLDUNTIL={}\r\n",
DateTime::from_timestamp((now + 10) as i64).to_rfc3339()
)
.as_bytes(),
)
.await
.unwrap();
session.response().assert_code("501 5.5.4");
session.rset().await;
// Test FUTURERELEASE extension with a HOLDFOR value that is not a positive integer
session
.ingest(b"MAIL FROM:<[email protected]> HOLDFOR=0\r\n")
.await
.unwrap();
session.response().assert_code("501 5.5.4");
session.rset().await;
}
+971
View File
@@ -0,0 +1,971 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
smtp::{
inbound::TestMessage,
session::{TestSession, VerifyResponse, load_test_message},
},
utils::server::TestServerBuilder,
};
use ahash::AHashSet;
use common::{
config::smtp::session::{Milter, MilterVersion, Stage},
expr::if_block::IfBlock,
manager::application::Resource,
};
use http_proto::{ToHttpResponse, request::fetch_body};
use hyper::{body, server::conn::http1, service::service_fn};
use hyper_util::rt::TokioIo;
use mail_auth::AuthenticatedMessage;
use mail_parser::MessageParser;
use registry::{
schema::{
enums::{self, MtaStage},
prelude::{ObjectType, Property},
structs::{Expression, MtaHook, MtaMilter, MtaStageRcpt},
},
types::map::Map,
};
use serde::Deserialize;
use smtp::{
core::SessionData,
inbound::{
hooks::{self, Request, SmtpResponse},
milter::{
Action, Command, Macros, MilterClient, Modification, Options, Response,
receiver::{FrameResult, Receiver},
},
},
};
use std::{fs, net::SocketAddr, path::PathBuf, sync::Arc, time::Duration};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::{TcpListener, TcpStream},
sync::watch,
};
#[derive(Debug, Deserialize)]
struct HeaderTest {
modifications: Vec<Modification>,
result: String,
}
#[tokio::test]
async fn milter_session() {
let mut test = TestServerBuilder::new("smtp_milter_test")
.await
.with_http_listener(19014)
.await
.capture_queue()
.disable_services()
.build()
.await;
// Add test settings
let admin = test.account("admin");
admin.mta_no_auth().await;
admin.mta_allow_relaying().await;
admin
.registry_create_object(MtaMilter {
enable: Expression {
else_: "true".into(),
..Default::default()
},
hostname: "127.0.0.1".into(),
port: 9332,
use_tls: false,
stages: Map::new(vec![MtaStage::Data]),
protocol_version: enums::MilterVersion::V6,
..Default::default()
})
.await;
admin.reload_settings().await;
test.reload_core();
test.expect_reload_settings().await;
let _rx = spawn_mock_milter_server();
tokio::time::sleep(Duration::from_millis(100)).await;
// Build session
let mut session = test.new_mta_session();
session.data.remote_ip_str = "10.0.0.1".into();
session.eval_session_params().await;
session.ehlo("mx.doe.org").await;
// Test reject
session
.send_message(
"[email protected]",
&["[email protected]"],
"test:no_dkim",
"503 5.5.3",
)
.await;
test.assert_no_events();
// Test discard
session
.send_message(
"[email protected]",
&["[email protected]"],
"test:no_dkim",
"250 2.0.0",
)
.await;
test.assert_no_events();
// Test temp fail
session
.send_message(
"[email protected]",
&["[email protected]"],
"test:no_dkim",
"451 4.3.5",
)
.await;
test.assert_no_events();
// Test shutdown
session
.send_message(
"[email protected]",
&["[email protected]"],
"test:no_dkim",
"421 4.3.0",
)
.await;
test.assert_no_events();
// Test reply code
session
.send_message(
"[email protected]",
&["[email protected]"],
"test:no_dkim",
"321",
)
.await;
test.assert_no_events();
// Test accept with header addition
session
.send_message(
"[email protected]",
&["[email protected]"],
"test:no_dkim",
"250 2.0.0",
)
.await;
test.expect_message()
.await
.read_lines(&test)
.await
.assert_contains("X-Hello: World")
.assert_contains("Subject: Is dinner ready?")
.assert_contains("Are you hungry yet?");
// Test accept with header replacement
session
.send_message(
"[email protected]",
&["[email protected]"],
"test:no_dkim",
"250 2.0.0",
)
.await;
test.expect_message()
.await
.read_lines(&test)
.await
.assert_contains("Subject: [SPAM] Saying Hello")
.assert_count("References: ", 1)
.assert_contains("Are you hungry yet?");
// Test accept with body replacement
session
.send_message(
"[email protected]",
&["[email protected]"],
"test:no_dkim",
"250 2.0.0",
)
.await;
test.expect_message()
.await
.read_lines(&test)
.await
.assert_contains("X-Spam: Yes")
.assert_contains("123456");
}
#[tokio::test]
async fn mta_hook_session() {
let mut test = TestServerBuilder::new("smtp_mta_hook_test")
.await
.with_http_listener(19015)
.await
.disable_services()
.capture_queue()
.build()
.await;
// Add test settings
let admin = test.account("admin");
admin.mta_no_auth().await;
admin
.registry_create_object(MtaStageRcpt {
allow_relaying: Expression {
else_: "true".into(),
..Default::default()
},
..Default::default()
})
.await;
admin
.registry_create_object(MtaHook {
enable: Expression {
else_: "true".into(),
..Default::default()
},
url: "http://127.0.0.1:9333".into(),
stages: Map::new(vec![MtaStage::Data]),
..Default::default()
})
.await;
admin.reload_settings().await;
test.reload_core();
test.expect_reload_settings().await;
let _rx = spawn_mock_mta_hook_server();
tokio::time::sleep(Duration::from_millis(100)).await;
// Build session
let mut session = test.new_mta_session();
session.data.remote_ip_str = "10.0.0.1".into();
session.eval_session_params().await;
session.ehlo("mx.doe.org").await;
// Test reject
session
.send_message(
"[email protected]",
&["[email protected]"],
"test:no_dkim",
"503 5.5.3",
)
.await;
test.assert_no_events();
// Test discard
session
.send_message(
"[email protected]",
&["[email protected]"],
"test:no_dkim",
"250 2.0.0",
)
.await;
test.assert_no_events();
// Test temp fail
session
.send_message(
"[email protected]",
&["[email protected]"],
"test:no_dkim",
"451 4.3.5",
)
.await;
test.assert_no_events();
// Test shutdown
session
.send_message(
"[email protected]",
&["[email protected]"],
"test:no_dkim",
"421 4.3.0",
)
.await;
test.assert_no_events();
// Test reply code
session
.send_message(
"[email protected]",
&["[email protected]"],
"test:no_dkim",
"321",
)
.await;
test.assert_no_events();
// Test accept with header addition
session
.send_message(
"[email protected]",
&["[email protected]"],
"test:no_dkim",
"250 2.0.0",
)
.await;
test.expect_message()
.await
.read_lines(&test)
.await
.assert_contains("X-Hello: World")
.assert_contains("Subject: Is dinner ready?")
.assert_contains("Are you hungry yet?");
// Test accept with header replacement
session
.send_message(
"[email protected]",
&["[email protected]"],
"test:no_dkim",
"250 2.0.0",
)
.await;
test.expect_message()
.await
.read_lines(&test)
.await
.assert_contains("Subject: [SPAM] Saying Hello")
.assert_count("References: ", 1)
.assert_contains("Are you hungry yet?");
// Test accept with body replacement
session
.send_message(
"[email protected]",
&["[email protected]"],
"test:no_dkim",
"250 2.0.0",
)
.await;
test.expect_message()
.await
.read_lines(&test)
.await
.assert_contains("X-Spam: Yes")
.assert_contains("123456");
}
#[test]
fn milter_address_modifications() {
let test_message = fs::read_to_string(
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("resources")
.join("smtp")
.join("milter")
.join("message.eml"),
)
.unwrap();
let parsed_test_message = AuthenticatedMessage::parse(test_message.as_bytes()).unwrap();
let mut data = SessionData::new(
"127.0.0.1".parse().unwrap(),
0,
"127.0.0.1".parse().unwrap(),
0,
Default::default(),
0,
);
// ChangeFrom
assert!(
data.apply_milter_modifications(
vec![Modification::ChangeFrom {
sender: "<>".into(),
args: "".into(),
}],
&parsed_test_message
)
.is_none()
);
let addr = data.mail_from.as_ref().unwrap();
assert_eq!(addr.address_lcase, "");
assert_eq!(addr.dsn_info, None);
assert_eq!(addr.flags, 0);
// ChangeFrom with parameters
assert!(
data.apply_milter_modifications(
vec![Modification::ChangeFrom {
sender: "[email protected]".into(),
args: "REQUIRETLS ENVID=abc123".into(), //"NOTIFY=SUCCESS,FAILURE ENVID=abc123\n".into()
}],
&parsed_test_message
)
.is_none()
);
let addr = data.mail_from.as_ref().unwrap();
assert_eq!(addr.address_lcase, "[email protected]");
assert_ne!(addr.flags, 0);
assert_eq!(addr.dsn_info, Some("abc123".into()));
// Add recipients
assert!(
data.apply_milter_modifications(
vec![
Modification::AddRcpt {
recipient: "[email protected]".into(),
args: "".into(),
},
Modification::AddRcpt {
recipient: "[email protected]".into(),
args: "NOTIFY=SUCCESS,FAILURE ORCPT=rfc822;[email protected]".into(),
},
Modification::AddRcpt {
recipient: "<[email protected]>".into(),
args: "".into(),
},
Modification::AddRcpt {
recipient: "<>".into(),
args: "".into(),
},
],
&parsed_test_message
)
.is_none()
);
assert_eq!(data.rcpt_to.len(), 2);
let addr = data.rcpt_to.first().unwrap();
assert_eq!(addr.address_lcase, "[email protected]");
assert_eq!(addr.dsn_info, None);
assert_eq!(addr.flags, 0);
let addr = data.rcpt_to.last().unwrap();
assert_eq!(addr.address_lcase, "[email protected]");
assert_ne!(addr.flags, 0);
assert_eq!(addr.dsn_info, Some("[email protected]".into()));
// Remove recipients
assert!(
data.apply_milter_modifications(
vec![
Modification::DeleteRcpt {
recipient: "[email protected]".into(),
},
Modification::DeleteRcpt {
recipient: "<>".into(),
},
],
&parsed_test_message
)
.is_none()
);
assert_eq!(data.rcpt_to.len(), 1);
let addr = data.rcpt_to.last().unwrap();
assert_eq!(addr.address_lcase, "[email protected]");
assert_ne!(addr.flags, 0);
assert_eq!(addr.dsn_info, Some("[email protected]".into()));
}
#[test]
fn milter_message_modifications() {
// Read test message
let milter_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("resources")
.join("smtp")
.join("milter");
let test_message = fs::read_to_string(milter_path.join("message.eml")).unwrap();
let tests = serde_json::from_str::<Vec<HeaderTest>>(
&fs::read_to_string(milter_path.join("message.json")).unwrap(),
)
.unwrap();
let parsed_test_message = AuthenticatedMessage::parse(test_message.as_bytes()).unwrap();
let mut session_data = SessionData::new(
"127.0.0.1".parse().unwrap(),
0,
"127.0.0.1".parse().unwrap(),
0,
Default::default(),
0,
);
for test in tests {
assert_eq!(
test.result,
String::from_utf8(
session_data
.apply_milter_modifications(test.modifications, &parsed_test_message)
.unwrap()
)
.unwrap()
)
}
}
#[test]
fn milter_frame_receiver() {
let mut stream = Vec::new();
for i in 0u32..100u32 {
stream.extend_from_slice((i + 1).to_be_bytes().as_ref());
stream.push(i as u8);
for v in 0..i {
stream.push(v as u8);
}
}
for chunk_size in [stream.len(), 1, 2, 3, 4, 10, 20, 30, 40, 100, 200, 300, 400] {
let mut receiver = Receiver::with_max_frame_len(100);
let mut frame_num = 0;
'outer: for chunk in stream.chunks(chunk_size) {
loop {
match receiver.read_frame(chunk) {
FrameResult::Frame(bytes) => {
/*println!(
"frame {frame_num}, chunk: {chunk_size}, {}",
if matches!(bytes, std::borrow::Cow::Borrowed(_)) {
"borrowed"
} else {
"owned"
}
);*/
assert_eq!(*bytes.first().unwrap(), frame_num);
assert_eq!(bytes.len(), frame_num as usize + 1);
frame_num += 1;
}
FrameResult::Incomplete => continue 'outer,
FrameResult::TooLarge(size) => {
panic!("Frame too large: {size}")
}
}
}
}
assert_eq!(frame_num, 100, "chunk_size: {}", chunk_size);
}
}
#[tokio::test]
#[ignore]
async fn milter_client_test() {
//const PORT : u16 = 11332;
const PORT: u16 = 7357;
let mut client = MilterClient::connect(
&Milter {
enable: IfBlock::empty(ObjectType::MtaMilter.singleton(), Property::Enable),
id: ObjectType::MtaMilter.singleton(),
addrs: vec![SocketAddr::from(([127, 0, 0, 1], PORT))],
hostname: "localhost".into(),
port: PORT,
timeout_connect: Duration::from_secs(10),
timeout_command: Duration::from_secs(30),
timeout_data: Duration::from_secs(30),
tls: false,
tls_allow_invalid_certs: false,
tempfail_on_error: false,
max_frame_len: 5000000,
protocol_version: MilterVersion::V6,
flags_actions: None,
flags_protocol: None,
run_on_stage: AHashSet::from([Stage::Data]),
},
0,
)
.await
.unwrap();
client.init().await.unwrap();
let raw_message = load_test_message("arc", "messages");
let message = MessageParser::new().parse(raw_message.as_bytes()).unwrap();
let r = client
.connection(
"gmail.com",
"127.0.0.1".parse().unwrap(),
1235,
Macros::new(),
)
.await
.unwrap();
println!("CONNECT: {:?}", r);
let r = client
.mail_from("[email protected]", None::<&[&str]>, Macros::new())
.await
.unwrap();
println!("MAIL FROM: {:?}", r);
let r = client
.rcpt_to("[email protected]", None::<&[&str]>, Macros::new())
.await
.unwrap();
println!("RCPT TO: {:?}", r);
let r = client.data().await.unwrap();
println!("DATA: {:?}", r);
let r = client.headers(message.headers_raw()).await.unwrap();
println!("HEADERS: {:?}", r);
let r = client
.body(&message.raw_message()[message.root_part().raw_body_offset() as usize..])
.await
.unwrap();
println!("BODY: {:?}", r);
client.quit().await.unwrap();
}
pub fn spawn_mock_milter_server() -> watch::Sender<bool> {
let (tx, rx) = watch::channel(true);
let tests = Arc::new(
serde_json::from_str::<Vec<HeaderTest>>(
&fs::read_to_string(
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("resources")
.join("smtp")
.join("milter")
.join("message.json"),
)
.unwrap(),
)
.unwrap(),
);
tokio::spawn(async move {
let listener = TcpListener::bind("127.0.0.1:9332")
.await
.unwrap_or_else(|e| {
panic!("Failed to bind mock Milter server to 127.0.0.1:9332: {e}");
});
let mut rx_ = rx.clone();
//println!("Mock Milter server listening on port 9332");
loop {
tokio::select! {
stream = listener.accept() => {
match stream {
Ok((stream, _)) => {
tokio::spawn(accept_milter(stream, rx.clone(), tests.clone()));
}
Err(err) => {
panic!("Something went wrong: {err}" );
}
}
},
_ = rx_.changed() => {
//println!("Mock Milter server stopping");
break;
}
};
}
});
tx
}
async fn accept_milter(
mut stream: TcpStream,
mut rx: watch::Receiver<bool>,
tests: Arc<Vec<HeaderTest>>,
) {
let mut buf = vec![0u8; 1024];
let mut receiver = Receiver::with_max_frame_len(5000000);
let mut action = None;
let mut modifications = None;
'outer: loop {
let br = tokio::select! {
br = stream.read(&mut buf) => {
match br {
Ok(br) => {
br
}
Err(_) => {
break;
}
}
},
_ = rx.changed() => {
break;
}
};
if br == 0 {
break;
}
loop {
match receiver.read_frame(&buf[..br]) {
FrameResult::Frame(bytes) => {
let cmd = Command::deserialize(bytes.as_ref());
println!("CMD: {cmd}");
let response = match cmd {
Command::Abort | Command::Macro { .. } => continue,
Command::Body { .. }
| Command::Data
| Command::Connect { .. }
| Command::Header { .. }
| Command::Helo { .. }
| Command::Rcpt { .. }
| Command::QuitNewConnection
| Command::EndOfHeader => Response::Action(Action::Accept),
Command::OptionNegotiation(_) => Response::OptionNegotiation(Options {
version: 6,
actions: 0,
protocol: 0,
}),
Command::MailFrom { sender, .. } => {
let sender = std::str::from_utf8(sender).unwrap();
action = match sender
.strip_prefix('<')
.unwrap()
.split_once('@')
.unwrap()
.0
{
"accept" => Action::Accept,
"reject" => Action::Reject,
"discard" => Action::Discard,
"temp_fail" => Action::TempFail,
"shutdown" => Action::Shutdown,
"conn_fail" => Action::ConnectionFailure,
"reply_code" => Action::ReplyCode {
code: *b"321",
text: "test".into(),
},
test_num => {
modifications = tests[test_num.parse::<usize>().unwrap()]
.modifications
.clone()
.into();
Action::Accept
}
}
.into();
Response::Action(Action::Accept)
}
Command::Quit => break 'outer,
Command::EndOfBody => {
if let Some(modifications) = modifications.take() {
for modification in modifications {
// Write modifications
stream
.write_all(
&Response::Modification(modification).serialize(),
)
.await
.unwrap();
}
}
Response::Action(action.take().unwrap())
}
};
// Write response
stream.write_all(&response.serialize()).await.unwrap();
}
FrameResult::Incomplete => continue 'outer,
FrameResult::TooLarge(size) => {
panic!("Frame too large: {size}")
}
}
}
}
}
pub fn spawn_mock_mta_hook_server() -> watch::Sender<bool> {
let (tx, rx) = watch::channel(true);
let tests = Arc::new(
serde_json::from_str::<Vec<HeaderTest>>(
&fs::read_to_string(
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("resources")
.join("smtp")
.join("milter")
.join("message.json"),
)
.unwrap(),
)
.unwrap(),
);
tokio::spawn(async move {
let listener = TcpListener::bind("127.0.0.1:9333")
.await
.unwrap_or_else(|e| {
panic!("Failed to bind mock Milter server to 127.0.0.1:9333: {e}");
});
let mut rx_ = rx.clone();
//println!("Mock jMilter server listening on port 9333");
loop {
tokio::select! {
stream = listener.accept() => {
match stream {
Ok((stream, _)) => {
let _ = http1::Builder::new()
.keep_alive(false)
.serve_connection(
TokioIo::new(stream),
service_fn(|mut req: hyper::Request<body::Incoming>| {
let tests = tests.clone();
async move {
let request = serde_json::from_slice::<Request>(&fetch_body(&mut req, 1024 * 1024,0).await.unwrap())
.unwrap();
let response = handle_mta_hook(request, tests);
Ok::<_, hyper::Error>(
Resource::new("application/json", serde_json::to_string(&response).unwrap().into_bytes())
.into_http_response().build(),
)
}
}),
)
.await;
}
Err(err) => {
panic!("Something went wrong: {err}" );
}
}
},
_ = rx_.changed() => {
//println!("Mock jMilter server stopping");
break;
}
};
}
});
tx
}
fn handle_mta_hook(request: Request, tests: Arc<Vec<HeaderTest>>) -> hooks::Response {
match request
.envelope
.unwrap()
.from
.address
.split_once('@')
.unwrap()
.0
{
"accept" => hooks::Response {
action: hooks::Action::Accept,
response: None,
modifications: vec![],
},
"reject" => hooks::Response {
action: hooks::Action::Reject,
response: None,
modifications: vec![],
},
"discard" => hooks::Response {
action: hooks::Action::Discard,
response: None,
modifications: vec![],
},
"temp_fail" => hooks::Response {
action: hooks::Action::Reject,
response: SmtpResponse {
status: 451.into(),
enhanced_status: Some("4.3.5".into()),
message: Some("Unable to accept message at this time.".into()),
disconnect: false,
}
.into(),
modifications: vec![],
},
"shutdown" => hooks::Response {
action: hooks::Action::Reject,
response: SmtpResponse {
status: 421.into(),
enhanced_status: Some("4.3.0".into()),
message: Some("Server shutting down".into()),
disconnect: false,
}
.into(),
modifications: vec![],
},
"conn_fail" => hooks::Response {
action: hooks::Action::Accept,
response: SmtpResponse {
disconnect: true,
..Default::default()
}
.into(),
modifications: vec![],
},
"reply_code" => hooks::Response {
action: hooks::Action::Reject,
response: SmtpResponse {
status: 321.into(),
enhanced_status: Some("3.1.1".into()),
message: Some("Test".into()),
disconnect: false,
}
.into(),
modifications: vec![],
},
test_num => hooks::Response {
action: hooks::Action::Accept,
response: None,
modifications: tests[test_num.parse::<usize>().unwrap()]
.modifications
.iter()
.map(|m| match m {
Modification::ChangeFrom { sender, args } => hooks::Modification::ChangeFrom {
value: sender.clone(),
parameters: args
.split_whitespace()
.map(|arg| {
let (key, value) = arg.split_once('=').unwrap();
(key.into(), Some(value.into()))
})
.collect(),
},
Modification::AddRcpt { recipient, args } => {
hooks::Modification::AddRecipient {
value: recipient.clone(),
parameters: args
.split_whitespace()
.map(|arg| {
let (key, value) = arg.split_once('=').unwrap();
(key.into(), Some(value.into()))
})
.collect(),
}
}
Modification::DeleteRcpt { recipient } => {
hooks::Modification::DeleteRecipient {
value: recipient.clone(),
}
}
Modification::ReplaceBody { value } => hooks::Modification::ReplaceContents {
value: String::from_utf8(value.clone()).unwrap(),
},
Modification::AddHeader { name, value } => hooks::Modification::AddHeader {
name: name.clone(),
value: value.clone(),
},
Modification::InsertHeader { index, name, value } => {
hooks::Modification::InsertHeader {
index: *index,
name: name.clone(),
value: value.clone(),
}
}
Modification::ChangeHeader { index, name, value } => {
hooks::Modification::ChangeHeader {
index: *index,
name: name.clone(),
value: value.clone(),
}
}
Modification::Quarantine { reason } => hooks::Modification::AddHeader {
name: "X-Quarantine".into(),
value: reason.clone(),
},
})
.collect(),
},
}
}
+411
View File
@@ -0,0 +1,411 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::utils::server::TestServer;
use common::{
config::smtp::queue::QueueName,
ipc::{DmarcEvent, QueueEvent, QueueEventStatus, ReportingEvent, TlsEvent},
};
use registry::{schema::prelude::ObjectType, types::ObjectImpl};
use smtp::queue::{Message, MessageWrapper, QueueId, QueuedMessage};
use std::time::Duration;
use store::{
Deserialize, IterateParams, U64_LEN, ValueKey,
write::{AlignedBytes, Archive, QueueClass, ValueClass, key::DeserializeBigEndian},
};
use tokio::sync::mpsc::error::TryRecvError;
use types::id::Id;
pub mod antispam;
pub mod asn;
pub mod auth;
pub mod basic;
pub mod data;
pub mod dkim2;
pub mod dmarc;
pub mod ehlo;
pub mod limits;
pub mod mail;
pub mod milter;
pub mod rcpt;
pub mod rewrite;
pub mod scripts;
pub mod sign;
pub mod throttle;
pub mod vrfy;
const EVENT_TIMEOUT: Duration = Duration::from_secs(5);
impl TestServer {
pub async fn read_event(&mut self) -> QueueEvent {
if let Some(event) = self.queue_events.pop_front() {
return event;
}
match tokio::time::timeout(EVENT_TIMEOUT, self.queue_rx.recv()).await {
Ok(Some(event)) => event,
Ok(None) => panic!("Channel closed."),
Err(_) => panic!("No queue event received."),
}
}
pub async fn read_event_matching(
&mut self,
expected: impl Fn(&QueueEvent) -> bool,
) -> QueueEvent {
if let Some(idx) = self.queue_events.iter().position(&expected) {
return self.queue_events.remove(idx).unwrap();
}
loop {
match tokio::time::timeout(EVENT_TIMEOUT, self.queue_rx.recv()).await {
Ok(Some(event)) => {
if expected(&event) {
return event;
}
self.queue_events.push_back(event);
}
Ok(None) => panic!("Channel closed."),
Err(_) => panic!(
"No matching queue event received, pending events: {:?}",
self.queue_events
),
}
}
}
pub async fn try_read_event(&mut self) -> Option<QueueEvent> {
if let Some(event) = self.queue_events.pop_front() {
return Some(event);
}
match tokio::time::timeout(EVENT_TIMEOUT, self.queue_rx.recv()).await {
Ok(Some(event)) => Some(event),
Ok(None) => panic!("Channel closed."),
Err(_) => None,
}
}
pub fn assert_no_events(&mut self) {
if let Some(event) = self.queue_events.pop_front() {
panic!("Expected empty queue but got {event:?}");
}
match self.queue_rx.try_recv() {
Err(TryRecvError::Empty) => (),
Ok(event) => panic!("Expected empty queue but got {event:?}"),
Err(err) => panic!("Queue error: {err:?}"),
}
}
pub async fn assert_queue_is_empty(&self) {
assert_eq!(self.read_queued_messages().await, vec![]);
assert_eq!(self.read_queued_events().await, vec![]);
}
pub async fn assert_report_is_empty<T: ObjectImpl + PartialEq + std::fmt::Debug>(&self) {
assert_eq!(self.read_report_events::<T>().await, vec![]);
}
pub async fn expect_reload_settings(&mut self) {
self.read_event_matching(QueueEvent::is_reload_settings)
.await;
}
pub async fn expect_refresh(&mut self) {
self.read_event_matching(QueueEvent::is_refresh).await;
}
pub async fn expect_message(&mut self) -> MessageWrapper {
self.expect_refresh().await;
self.last_queued_message().await
}
pub async fn consume_message(&mut self) -> MessageWrapper {
self.expect_refresh().await;
let message = self.last_queued_message().await;
message
.clone()
.remove(&self.server, self.last_queued_due().await.into())
.await;
message
}
pub async fn expect_message_then_deliver(&mut self) -> QueuedMessage {
let message = self.expect_message().await;
self.delivery_attempt(message.queue_id).await
}
pub async fn delivery_attempt(&mut self, queue_id: u64) -> QueuedMessage {
QueuedMessage {
due: self.message_due(queue_id).await,
queue_id,
queue_name: QueueName::new("remote").unwrap(),
}
}
pub async fn expect_message_for_queue_then_deliver(
&mut self,
queue_name: &str,
) -> QueuedMessage {
let message = self.expect_message().await;
self.delivery_attempt_for_queue(message.queue_id, queue_name)
.await
}
pub async fn delivery_attempt_for_queue(
&mut self,
queue_id: u64,
queue_name: &str,
) -> QueuedMessage {
QueuedMessage {
due: self.message_due(queue_id).await,
queue_id,
queue_name: QueueName::new(queue_name).unwrap(),
}
}
pub async fn read_queued_events(&self) -> Vec<store::write::QueueEvent> {
let mut events = Vec::new();
let from_key = ValueKey::from(ValueClass::Queue(QueueClass::MessageEvent(
store::write::QueueEvent {
due: 0,
queue_id: 0,
queue_name: [0; 8],
},
)));
let to_key = ValueKey::from(ValueClass::Queue(QueueClass::MessageEvent(
store::write::QueueEvent {
due: u64::MAX,
queue_id: u64::MAX,
queue_name: [u8::MAX; 8],
},
)));
self.server
.store()
.iterate(
IterateParams::new(from_key, to_key).ascending().no_values(),
|key, _| {
events.push(store::write::QueueEvent {
due: key.deserialize_be_u64(0)?,
queue_id: key.deserialize_be_u64(U64_LEN)?,
queue_name: key[U64_LEN + 1..U64_LEN + 9]
.try_into()
.expect("Queue name must be 8 bytes"),
});
Ok(true)
},
)
.await
.unwrap();
events
}
pub async fn read_queued_messages(&self) -> Vec<MessageWrapper> {
let from_key = ValueKey::from(ValueClass::Queue(QueueClass::Message(0)));
let to_key = ValueKey::from(ValueClass::Queue(QueueClass::Message(u64::MAX)));
let mut messages = Vec::new();
self.server
.store()
.iterate(
IterateParams::new(from_key, to_key).descending(),
|key, value| {
messages.push(MessageWrapper {
queue_id: key.deserialize_be_u64(0)?,
queue_name: Default::default(),
is_multi_queue: false,
span_id: 0,
message: <Archive<AlignedBytes> as Deserialize>::deserialize(value)?
.deserialize::<Message>()?,
});
Ok(true)
},
)
.await
.unwrap();
messages
}
pub async fn read_report_events<T: ObjectImpl>(&self) -> Vec<(Id, T)> {
self.account("admin").registry_get_all().await
}
pub async fn last_queued_message(&self) -> MessageWrapper {
self.read_queued_messages()
.await
.into_iter()
.next()
.expect("No messages found in queue")
}
pub async fn last_queued_due(&self) -> u64 {
self.message_due(self.last_queued_message().await.queue_id)
.await
}
pub async fn message_due(&self, queue_id: QueueId) -> u64 {
self.read_queued_events()
.await
.iter()
.find_map(|event| {
if event.queue_id == queue_id {
Some(event.due)
} else {
None
}
})
.expect("No event found in queue for message")
}
pub async fn clear_queue(&self) {
self.account("admin")
.registry_destroy_all(ObjectType::QueuedMessage)
.await;
}
pub async fn read_report(&mut self) -> ReportingEvent {
match tokio::time::timeout(EVENT_TIMEOUT, self.report_rx.recv()).await {
Ok(Some(event)) => event,
Ok(None) => panic!("Channel closed."),
Err(_) => panic!("No report event received."),
}
}
pub async fn try_read_report(&mut self) -> Option<ReportingEvent> {
match tokio::time::timeout(EVENT_TIMEOUT, self.report_rx.recv()).await {
Ok(Some(event)) => Some(event),
Ok(None) => panic!("Channel closed."),
Err(_) => None,
}
}
pub fn assert_no_reports(&mut self) {
match self.report_rx.try_recv() {
Err(TryRecvError::Empty) => (),
Ok(event) => panic!("Expected no reports but got {event:?}"),
Err(err) => panic!("Report error: {err:?}"),
}
}
}
pub trait TestQueueEvent {
fn assert_reload_settings(self);
fn assert_refresh(self);
fn assert_done(self);
fn assert_refresh_or_done(self);
fn is_reload_settings(&self) -> bool;
fn is_refresh(&self) -> bool;
}
impl TestQueueEvent for QueueEvent {
fn is_reload_settings(&self) -> bool {
matches!(self, QueueEvent::ReloadSettings)
}
fn is_refresh(&self) -> bool {
matches!(
self,
QueueEvent::Refresh
| QueueEvent::WorkerDone {
status: QueueEventStatus::Deferred,
..
}
)
}
fn assert_refresh(self) {
match self {
QueueEvent::Refresh
| QueueEvent::WorkerDone {
status: QueueEventStatus::Deferred,
..
} => (),
e => panic!("Unexpected event: {e:?}"),
}
}
fn assert_reload_settings(self) {
match self {
QueueEvent::ReloadSettings => (),
e => panic!("Unexpected event: {e:?}"),
}
}
fn assert_done(self) {
match self {
QueueEvent::WorkerDone {
status: QueueEventStatus::Completed,
..
} => (),
e => panic!("Unexpected event: {e:?}"),
}
}
fn assert_refresh_or_done(self) {
match self {
QueueEvent::WorkerDone {
status: QueueEventStatus::Completed | QueueEventStatus::Deferred,
..
} => (),
e => panic!("Unexpected event: {e:?}"),
}
}
}
pub trait TestReportingEvent {
fn unwrap_dmarc(self) -> Box<DmarcEvent>;
fn unwrap_tls(self) -> Box<TlsEvent>;
}
impl TestReportingEvent for ReportingEvent {
fn unwrap_dmarc(self) -> Box<DmarcEvent> {
match self {
ReportingEvent::Dmarc(event) => event,
e => panic!("Unexpected event: {e:?}"),
}
}
fn unwrap_tls(self) -> Box<TlsEvent> {
match self {
ReportingEvent::Tls(event) => event,
e => panic!("Unexpected event: {e:?}"),
}
}
}
#[allow(async_fn_in_trait)]
pub trait TestMessage {
async fn read_message(&self, core: &TestServer) -> String;
async fn read_lines(&self, core: &TestServer) -> Vec<String>;
}
impl TestMessage for MessageWrapper {
async fn read_message(&self, core: &TestServer) -> String {
String::from_utf8(
core.server
.blob_store()
.get_blob(self.message.blob_hash.as_slice(), 0..usize::MAX)
.await
.unwrap()
.expect("Message blob not found"),
)
.unwrap()
}
async fn read_lines(&self, core: &TestServer) -> Vec<String> {
self.read_message(core)
.await
.split('\n')
.map(|l| l.to_string())
.collect()
}
}
+220
View File
@@ -0,0 +1,220 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
smtp::session::{TestSession, VerifyResponse},
utils::server::TestServerBuilder,
};
use registry::{
schema::{
enums::MtaInboundThrottleKey,
structs::{
Expression, ExpressionMatch, MtaExtensions, MtaInboundThrottle, MtaStageRcpt, Rate,
},
},
types::{list::List, map::Map},
};
use smtp::core::State;
use smtp_proto::{RCPT_NOTIFY_DELAY, RCPT_NOTIFY_FAILURE, RCPT_NOTIFY_SUCCESS};
use std::time::Duration;
#[tokio::test]
async fn rcpt() {
let mut test = TestServerBuilder::new("smtp_rcpt_test")
.await
.with_http_listener(18999)
.await
.disable_services()
.build()
.await;
// Create test users
let admin = test.account("admin");
for (name, secret, description, aliases) in [
("[email protected]", "12345 + extra safety", "John Doe", &[]),
("[email protected]", "abcde + extra safety", "Jane Smith", &[]),
(
"[email protected]",
"p4ssw0rd + extra safety",
"Bill Foobar",
&[],
),
(
"[email protected]",
"p4ssw0rd + extra safety",
"Mike Foobar",
&[],
),
(
"cornelius@straß6.de",
"p4ssw0rd + extra safety",
"Cornelius Strauss",
&[],
),
] {
admin
.create_user_account(name, secret, description, aliases, vec![])
.await;
}
// Add test settings
admin.mta_no_auth().await;
admin
.registry_create_object(MtaStageRcpt {
allow_relaying: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "remote_ip = '10.0.0.1'".into(),
then: "false".into(),
}]),
else_: "true".into(),
},
max_failures: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "remote_ip = '10.0.0.1'".into(),
then: "3".into(),
}]),
else_: "100".into(),
},
max_recipients: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "remote_ip = '10.0.0.1'".into(),
then: "3".into(),
}]),
else_: "5".into(),
},
wait_on_fail: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "remote_ip = '10.0.0.1'".into(),
then: "5ms".into(),
}]),
else_: "1s".into(),
},
..Default::default()
})
.await;
admin
.registry_create_object(MtaExtensions {
dsn: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "remote_ip = '10.0.0.1'".into(),
then: "false".into(),
}]),
else_: "true".into(),
},
..Default::default()
})
.await;
admin
.registry_create_object(MtaInboundThrottle {
description: "Test throttle".into(),
enable: true,
key: Map::new(vec![MtaInboundThrottleKey::Sender]),
match_: Expression {
else_: "remote_ip = '10.0.0.1' && !is_empty(rcpt)".into(),
..Default::default()
},
rate: Rate {
count: 2,
period: 1000u64.into(),
},
})
.await;
admin.reload_settings().await;
test.reload_core();
// RCPT without MAIL FROM
let mut session = test.new_mta_session();
session.data.remote_ip_str = "10.0.0.1".into();
session.eval_session_params().await;
session.ehlo("mx1.foobar.org").await;
session.rcpt_to("[email protected]", "503 5.5.1").await;
// Relaying is disabled for 10.0.0.1
session.mail_from("[email protected]", "250").await;
session.rcpt_to("[email protected]", "550 5.1.2").await;
// DSN is disabled for 10.0.0.1
session
.ingest(b"RCPT TO:<[email protected]> NOTIFY=SUCCESS,FAILURE,DELAY\r\n")
.await
.unwrap();
session.response().assert_code("501 5.5.4");
// Send to non-existing user
session.rcpt_to("[email protected]", "550 5.1.2").await;
// Exceeding max number of errors
session
.ingest(b"RCPT TO:<[email protected]>\r\n")
.await
.unwrap_err();
session.response().assert_code("451 4.3.0");
// Rate limit
session.data.rcpt_errors = 0;
session.state = State::default();
session.rcpt_to("[email protected]", "250").await;
session.rcpt_to("[email protected]", "250").await;
session.rcpt_to("[email protected]", "452 4.4.5").await;
// Restore rate limit
tokio::time::sleep(Duration::from_millis(1100)).await;
session.rcpt_to("[email protected]", "250").await;
session.rcpt_to("[email protected]", "455 4.5.3").await;
// Check recipients
assert_eq!(session.data.rcpt_to.len(), 3);
for (rcpt, expected) in
session
.data
.rcpt_to
.iter()
.zip(["[email protected]", "[email protected]", "[email protected]"])
{
assert_eq!(rcpt.address, expected);
assert_eq!(rcpt.domain, "foobar.org");
assert_eq!(rcpt.address_lcase, expected.to_lowercase());
}
// Relaying should be allowed for 10.0.0.2
session.data.remote_ip_str = "10.0.0.2".into();
session.eval_session_params().await;
session.rset().await;
session.mail_from("[email protected]", "250").await;
session.rcpt_to("[email protected]", "250").await;
// DSN is enabled for 10.0.0.2
session
.ingest(b"RCPT TO:<[email protected]> NOTIFY=SUCCESS,FAILURE,DELAY ORCPT=rfc822;[email protected]\r\n")
.await
.unwrap();
session.response().assert_code("250");
let rcpt = session.data.rcpt_to.last().unwrap();
assert!((rcpt.flags & (RCPT_NOTIFY_DELAY | RCPT_NOTIFY_SUCCESS | RCPT_NOTIFY_FAILURE)) != 0);
assert_eq!(rcpt.dsn_info.as_ref().unwrap(), "[email protected]");
let mut session = test.new_mta_session();
session.data.remote_ip_str = "10.0.0.1".into();
session.eval_session_params().await;
session.ehlo("mx1.foobar.org").await;
session.mail_from("[email protected]", "250").await;
session.rcpt_to("cornelius@straß6.de", "250").await;
session.rcpt_to("[email protected]", "250").await;
assert_eq!(session.data.rcpt_to.len(), 1);
let rcpt = session.data.rcpt_to.last().unwrap();
assert_eq!(rcpt.address_lcase, "[email protected]");
assert_eq!(rcpt.domain, "xn--stra6-oqa.de");
let mut session = test.new_mta_session();
session.data.remote_ip_str = "10.0.0.1".into();
session.eval_session_params().await;
session.ehlo("mx1.foobar.org").await;
session.mail_from("[email protected]", "250").await;
session.rcpt_to("nobody@straß6.de", "550 5.1.2").await;
session
.rcpt_to("[email protected]", "550 5.1.2")
.await;
}
+166
View File
@@ -0,0 +1,166 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{smtp::session::TestSession, utils::server::TestServerBuilder};
use registry::{
schema::structs::{
Expression, ExpressionMatch, MtaStageMail, MtaStageRcpt, SieveSystemInterpreter,
SieveSystemScript,
},
types::list::List,
};
const MAIL_SCRIPT: &str = r#"require ["variables", "envelope"];
if allof( envelope :domain :is "from" "foobar.org",
envelope :localpart :contains "from" "admin" ) {
set "envelope.from" "[email protected]";
}
"#;
const MAIL_RCPT: &str = r#"require ["variables", "envelope", "regex"];
if allof( envelope :localpart :contains "to" ".",
envelope :regex "to" "(.+)@(.+)$") {
set :replace "." "" "to" "${1}";
set "envelope.to" "${to}@${2}";
}
"#;
#[tokio::test(flavor = "multi_thread")]
pub async fn address_rewrite() {
let mut test = TestServerBuilder::new("smtp_rewrite_test")
.await
.with_http_listener(19007)
.await
.disable_services()
.build()
.await;
// Add test settings
let admin = test.account("admin");
admin.mta_no_auth().await;
admin
.registry_create_object(MtaStageMail {
rewrite: Expression {
match_: List::from_iter([ExpressionMatch {
if_: concat!(
"ends_with(sender_domain, '.foobar.net') & ",
"matches('^([^.]+)@([^.]+)\\.(.+)$', sender)"
)
.into(),
then: "$1 + '+' + $2 + '@' + $3".into(),
}]),
else_: "false".into(),
},
script: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "sender_domain = 'foobar.org'".into(),
then: "'mail'".into(),
}]),
else_: "false".into(),
},
..Default::default()
})
.await;
admin
.registry_create_object(MtaStageRcpt {
rewrite: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "rcpt_domain = 'foobar.net' & matches('^([^.]+)\\.([^.]+)@(.+)$', rcpt)"
.into(),
then: "$1 + '+' + $2 + '@' + $3".into(),
}]),
else_: "false".into(),
},
script: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "rcpt_domain = 'foobar.org'".into(),
then: "'rcpt'".into(),
}]),
else_: "false".into(),
},
allow_relaying: Expression {
else_: "true".into(),
..Default::default()
},
..Default::default()
})
.await;
admin
.registry_create_object(SieveSystemInterpreter {
default_from_address: Expression {
else_: "'[email protected]'".into(),
..Default::default()
},
default_from_name: Expression {
else_: "'Sieve Daemon'".into(),
..Default::default()
},
default_return_path: Expression {
else_: "''".into(),
..Default::default()
},
message_id_hostname: Some("'mx.foobar.org'".into()),
duplicate_expiry: (86_400u64 * 100 * 7).into(),
max_cpu_cycles: 10000,
max_nested_includes: 5,
max_out_messages: 5,
max_received_headers: 50,
max_redirects: 3,
..Default::default()
})
.await;
for (name, contents) in [("mail", MAIL_SCRIPT), ("rcpt", MAIL_RCPT)] {
admin
.registry_create_object(SieveSystemScript {
name: name.to_string(),
contents: contents.to_string(),
is_active: true,
..Default::default()
})
.await;
}
admin.reload_settings().await;
test.reload_core();
// Init session
let mut session = test.new_mta_session();
session.data.remote_ip_str = "10.0.0.1".into();
session.eval_session_params().await;
session.ehlo("mx.doe.org").await;
// Sender rewrite using regex
session.mail_from("[email protected]", "250").await;
assert_eq!(
session.data.mail_from.as_ref().unwrap().address,
"[email protected]"
);
session.reset();
// Sender rewrite using sieve
session.mail_from("[email protected]", "250").await;
assert_eq!(
session.data.mail_from.as_ref().unwrap().address_lcase,
"[email protected]"
);
// Recipient rewrite using regex
session.rcpt_to("[email protected]", "250").await;
assert_eq!(
session.data.rcpt_to.last().unwrap().address,
"[email protected]"
);
// Remove duplicates
session.rcpt_to("[email protected]", "250").await;
assert_eq!(session.data.rcpt_to.len(), 1);
// Recipient rewrite using sieve
session.rcpt_to("[email protected]", "250").await;
assert_eq!(
session.data.rcpt_to.last().unwrap().address,
"[email protected]"
);
}
+478
View File
@@ -0,0 +1,478 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
smtp::{
inbound::TestMessage,
session::{TestSession, VerifyResponse},
},
utils::server::TestServerBuilder,
};
use common::config::mailstore::spamfilter::spam_status;
use core::panic;
use registry::schema::structs::{
CertificateManagement, DkimManagement, DnsManagement, Domain, Expression, LookupStore,
MtaStageConnect, MtaStageData, MtaStageEhlo, MtaStageMail, MtaStageRcpt,
SieveSystemInterpreter, SieveSystemScript, SqliteStore, StoreLookup,
};
use smtp::scripts::{ScriptResult, event_loop::RunScript};
use std::{fs, path::PathBuf};
#[tokio::test]
async fn sieve_scripts() {
let mut test = TestServerBuilder::new("smtp_sieve_test")
.await
.with_http_listener(19008)
.await
.disable_services()
.capture_queue()
.build()
.await;
// Create test data
let admin = test.account("admin");
let domain_id = admin
.registry_create_object(Domain {
name: "foobar.org".into(),
certificate_management: CertificateManagement::Manual,
dns_management: DnsManagement::Manual,
dkim_management: DkimManagement::Manual,
allow_relaying: true,
..Default::default()
})
.await;
admin.create_dkim_signatures(domain_id).await;
admin.mta_no_auth().await;
admin
.registry_create_object(SieveSystemInterpreter {
default_from_address: Expression {
else_: "'[email protected]'".into(),
..Default::default()
},
default_from_name: Expression {
else_: "'Sieve Daemon'".into(),
..Default::default()
},
default_return_path: Expression {
else_: "''".into(),
..Default::default()
},
message_id_hostname: Some("'mx.foobar.org'".into()),
dkim_sign_domain: Expression {
else_: "'foobar.org'".into(),
..Default::default()
},
duplicate_expiry: (86_400u64 * 100 * 7).into(),
max_cpu_cycles: 10000,
max_nested_includes: 5,
max_out_messages: 5,
max_received_headers: 50,
max_redirects: 3,
..Default::default()
})
.await;
admin
.registry_create_object(StoreLookup {
namespace: "sql".into(),
store: LookupStore::Sqlite(SqliteStore {
path: format!("{}/smtp_sieve.db", test.tmp_dir()),
pool_max_connections: 10,
pool_workers: None,
}),
})
.await;
admin
.registry_create_object(MtaStageConnect {
script: Expression {
else_: "'stage_connect'".into(),
..Default::default()
},
smtp_greeting: Expression {
else_: "'mx.example.org at your service'".into(),
..Default::default()
},
..Default::default()
})
.await;
admin
.registry_create_object(MtaStageEhlo {
script: Expression {
else_: "'stage_ehlo'".into(),
..Default::default()
},
..Default::default()
})
.await;
admin
.registry_create_object(MtaStageMail {
script: Expression {
else_: "'stage_mail'".into(),
..Default::default()
},
..Default::default()
})
.await;
admin
.registry_create_object(MtaStageRcpt {
script: Expression {
else_: "'stage_rcpt'".into(),
..Default::default()
},
allow_relaying: Expression {
else_: "true".into(),
..Default::default()
},
..Default::default()
})
.await;
admin
.registry_create_object(MtaStageData {
script: Expression {
else_: "'stage_data'".into(),
..Default::default()
},
add_date_header: Expression {
else_: "true".into(),
..Default::default()
},
add_message_id_header: Expression {
else_: "true".into(),
..Default::default()
},
add_received_header: Expression {
else_: "true".into(),
..Default::default()
},
add_received_spf_header: Expression {
else_: "true".into(),
..Default::default()
},
add_auth_results_header: Expression {
else_: "true".into(),
..Default::default()
},
add_return_path_header: Expression {
else_: "false".into(),
..Default::default()
},
enable_spam_filter: Expression {
else_: "false".into(),
..Default::default()
},
..Default::default()
})
.await;
// Add test scripts
for entry in fs::read_dir(
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("resources")
.join("smtp")
.join("sieve"),
)
.unwrap()
{
let entry = entry.unwrap();
admin
.registry_create_object(SieveSystemScript {
contents: fs::read_to_string(entry.path()).unwrap(),
description: None,
is_active: true,
name: entry
.file_name()
.to_str()
.unwrap()
.split_once('.')
.unwrap()
.0
.to_string(),
})
.await;
}
admin.reload_settings().await;
admin.reload_lookup_stores().await;
test.reload_core();
test.expect_reload_settings().await;
// Build session
let mut session = test.new_mta_session();
session.data.remote_ip_str = "10.0.0.88".parse().unwrap();
session.data.remote_ip = session.data.remote_ip_str.parse().unwrap();
assert!(!session.init_conn().await);
// Run tests
for (name, script) in &test.server.core.sieve.trusted_scripts {
if name.starts_with("stage_") || name.ends_with("_include") {
continue;
}
let script = script.clone();
let params = session
.build_script_parameters("data")
.set_variable("from", "[email protected]")
.with_envelope(&test.server, &session, 0)
.await;
match test.server.run_script(name.into(), script, params).await {
ScriptResult::Accept { .. } => (),
ScriptResult::Reject(message) => panic!("{}", message),
err => {
panic!("Unexpected script result {err:?}");
}
}
}
// Test spamtest normalization
let spamtest_script = test
.server
.core
.sieve
.trusted_scripts
.get("spamtest_include")
.expect("spamtest_include script not found")
.clone();
for (percentage, score, expected) in [
(None, 0.0, "spamtest=0 percent=0 score= is_spam="),
(Some(0), -3.5, "spamtest=1 percent=0 score=-3.5 is_spam=0"),
(Some(25), 2.5, "spamtest=2 percent=25 score=2.5 is_spam=0"),
(Some(49), 4.9, "spamtest=4 percent=49 score=4.9 is_spam=0"),
(Some(50), 5.0, "spamtest=5 percent=50 score=5 is_spam=1"),
(Some(75), 7.5, "spamtest=7 percent=75 score=7.5 is_spam=1"),
(Some(99), 9.9, "spamtest=9 percent=99 score=9.9 is_spam=1"),
(
Some(100),
10.0,
"spamtest=10 percent=100 score=10 is_spam=1",
),
] {
let mut params = session
.build_script_parameters("data")
.with_spam_status(spam_status(percentage));
if percentage.is_some() {
params = params
.set_variable("spam.score", score)
.set_variable("spam.is_spam", score >= 5.0);
}
match test
.server
.run_script("spamtest_include".into(), spamtest_script.clone(), params)
.await
{
ScriptResult::Reject(message) => {
assert!(
message.contains(expected),
"expected {expected:?} for {percentage:?}, got {message:?}"
);
}
other => panic!("Unexpected script result {other:?} for {percentage:?}"),
}
}
// Test connect script
session
.response()
.assert_contains("503 5.5.3 Your IP '10.0.0.88' is not welcomed here");
session.data.remote_ip_str = "10.0.0.5".parse().unwrap();
session.data.remote_ip = session.data.remote_ip_str.parse().unwrap();
assert!(session.init_conn().await);
session
.response()
.assert_contains("220 mx.example.org at your service");
// Test EHLO script
session
.cmd(
"EHLO spammer.org",
"551 5.1.1 Your domain 'spammer.org' has been blocklisted",
)
.await;
session.cmd("EHLO foobar.net", "250").await;
// Test MAIL-FROM script
session
.mail_from("[email protected]", "450 4.1.1 Invalid address")
.await;
session
.mail_from(
"[email protected]",
"503 5.5.3 Your address has been blocked",
)
.await;
session.mail_from("[email protected]", "250").await;
// Test RCPT-TO script
session
.rcpt_to(
"[email protected]",
"422 4.2.2 You have been greylisted '[email protected]@foobar.org'.",
)
.await;
session.rcpt_to("[email protected]", "250").await;
// Expect a modified message
session.data("test:multipart", "250").await;
test.expect_message()
.await
.read_lines(&test)
.await
.assert_contains("X-Part-Number: 5")
.assert_contains("THIS IS A PIECE OF HTML TEXT");
test.assert_no_events();
// Expect rejection for [email protected]
session
.send_message(
"[email protected]",
&["[email protected]"],
"test:multipart",
"503 5.5.3 Bill cannot receive messages",
)
.await;
test.assert_no_events();
test.clear_queue().await;
// Expect message delivery plus a notification
session
.send_message(
"[email protected]",
&["[email protected]"],
"test:multipart",
"250",
)
.await;
test.expect_refresh().await;
test.expect_refresh().await;
let messages = test.read_queued_messages().await;
assert_eq!(messages.len(), 2);
let mut messages = messages.into_iter();
let notification = messages.next().unwrap();
assert_eq!(notification.message.return_path.as_ref(), "");
assert_eq!(notification.message.recipients.len(), 2);
assert_eq!(
notification.message.recipients.first().unwrap().address(),
"[email protected]"
);
assert_eq!(
notification.message.recipients.last().unwrap().address(),
"[email protected]"
);
notification
.read_lines(&test)
.await
.assert_contains("DKIM-Signature: v=1; a=rsa-sha256; s=rsa; d=foobar.org;")
.assert_contains("From: \"Sieve Daemon\" <[email protected]>")
.assert_contains("To: <[email protected]>")
.assert_contains("Cc: <[email protected]>")
.assert_contains("Subject: You have got mail")
.assert_contains("One Two Three Four");
messages
.next()
.unwrap()
.read_lines(&test)
.await
.assert_contains("One Two Three Four")
.assert_contains("multi-part message in MIME format")
.assert_not_contains("X-Part-Number: 5")
.assert_not_contains("THIS IS A PIECE OF HTML TEXT");
test.assert_no_events();
test.clear_queue().await;
// Expect a modified message delivery plus a notification
session
.send_message(
"[email protected]",
&["[email protected]"],
"test:multipart",
"250",
)
.await;
test.expect_refresh().await;
test.expect_refresh().await;
let messages = test.read_queued_messages().await;
assert_eq!(messages.len(), 2);
let mut messages = messages.into_iter();
messages
.next()
.unwrap()
.read_lines(&test)
.await
.assert_contains("DKIM-Signature: v=1; a=rsa-sha256; s=rsa; d=foobar.org;")
.assert_contains("From: \"Sieve Daemon\" <[email protected]>")
.assert_contains("To: <[email protected]>")
.assert_contains("Cc: <[email protected]>")
.assert_contains("Subject: You have got mail")
.assert_contains("One Two Three Four");
messages
.next()
.unwrap()
.read_lines(&test)
.await
.assert_contains("X-Part-Number: 5")
.assert_contains("THIS IS A PIECE OF HTML TEXT")
.assert_not_contains("X-My-Header: true");
test.clear_queue().await;
// Expect a modified redirected message
session
.send_message(
"[email protected]",
&["[email protected]"],
"test:no_dkim",
"250",
)
.await;
let redirect = test.expect_message().await;
assert_eq!(redirect.message.return_path.as_ref(), "");
assert_eq!(redirect.message.recipients.len(), 1);
assert_eq!(
redirect.message.recipients.first().unwrap().address(),
"[email protected]"
);
redirect
.read_lines(&test)
.await
.assert_contains("From: [email protected]")
.assert_contains("To: Suzie Q <[email protected]>")
.assert_contains("Subject: Is dinner ready?")
.assert_contains("Message-ID: <[email protected]>")
.assert_contains("Received: ")
.assert_not_contains("From: Joe SixPack <[email protected]>");
test.assert_no_events();
// Expect an intact redirected message
session
.send_message(
"[email protected]",
&["[email protected]"],
"test:no_dkim",
"250",
)
.await;
let redirect = test.expect_message().await;
assert_eq!(redirect.message.return_path.as_ref(), "");
assert_eq!(redirect.message.recipients.len(), 1);
assert_eq!(
redirect.message.recipients.first().unwrap().address(),
"[email protected]"
);
redirect
.read_lines(&test)
.await
.assert_not_contains("From: [email protected]")
.assert_contains("To: Suzie Q <[email protected]>")
.assert_contains("Subject: Is dinner ready?")
.assert_contains("Message-ID: <[email protected]>")
.assert_contains("From: Joe SixPack <[email protected]>")
.assert_contains("Received: ")
.assert_contains("Authentication-Results: ");
test.assert_no_events();
}
+245
View File
@@ -0,0 +1,245 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
smtp::{
inbound::TestMessage,
session::{TestSession, VerifyResponse},
},
utils::{account::Account, dns::DnsCache, server::TestServerBuilder},
};
use mail_auth::{
common::{parse::TxtRecordParser, verify::DomainKey},
spf::Spf,
};
use registry::schema::{
enums::{DkimCanonicalization, DkimRotationStage},
structs::{
CertificateManagement, Dkim1Signature, DkimManagement, DkimSignature, DnsManagement,
Domain, Expression, SecretText, SecretTextValue, SenderAuth,
},
};
use std::time::{Duration, Instant};
use types::id::Id;
#[tokio::test]
async fn sign_and_seal() {
let mut test = TestServerBuilder::new("smtp_sign_test")
.await
.with_http_listener(19010)
.await
.disable_services()
.capture_queue()
.build()
.await;
// Add test settings
let admin = test.account("admin");
let domain_id = admin
.registry_create_object(Domain {
name: "example.com".into(),
certificate_management: CertificateManagement::Manual,
dns_management: DnsManagement::Manual,
dkim_management: DkimManagement::Manual,
allow_relaying: true,
..Default::default()
})
.await;
admin.create_dkim_signatures(domain_id).await;
admin.mta_no_auth().await;
admin.mta_add_all_headers().await;
admin
.registry_create_object(SenderAuth {
dmarc_verify: Expression {
else_: "relaxed".into(),
..Default::default()
},
reverse_ip_verify: Expression {
else_: "relaxed".into(),
..Default::default()
},
spf_ehlo_verify: Expression {
else_: "relaxed".into(),
..Default::default()
},
spf_from_verify: Expression {
else_: "relaxed".into(),
..Default::default()
},
arc_verify: Expression {
else_: "strict".into(),
..Default::default()
},
dkim_sign_domain: Expression {
else_: "'example.com'".into(),
..Default::default()
},
dkim_verify: Expression {
else_: "relaxed".into(),
..Default::default()
},
dkim_strict: false,
})
.await;
admin.reload_settings().await;
test.reload_core();
test.expect_reload_settings().await;
// Add SPF, DKIM and DMARC records
test.server.txt_add(
"mx.example.com",
Spf::parse(b"v=spf1 ip4:10.0.0.1 ip4:10.0.0.2 -all").unwrap(),
Instant::now() + Duration::from_secs(5),
);
test.server.txt_add(
"example.com",
Spf::parse(b"v=spf1 ip4:10.0.0.1 -all").unwrap(),
Instant::now() + Duration::from_secs(5),
);
test.server.txt_add(
"ed._domainkey.scamorza.org",
DomainKey::parse(
concat!(
"v=DKIM1; k=ed25519; ",
"p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo="
)
.as_bytes(),
)
.unwrap(),
Instant::now() + Duration::from_secs(5),
);
test.server.txt_add(
"rsa._domainkey.manchego.org",
DomainKey::parse(
concat!(
"v=DKIM1; t=s; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQ",
"KBgQDwIRP/UC3SBsEmGqZ9ZJW3/DkMoGeLnQg1fWn7/zYt",
"IxN2SnFCjxOCKG9v3b4jYfcTNh5ijSsq631uBItLa7od+v",
"/RtdC2UzJ1lWT947qR+Rcac2gbto/NMqJ0fzfVjH4OuKhi",
"tdY9tf6mcwGjaNBcWToIMmPSPDdQPNUYckcQ2QIDAQAB",
)
.as_bytes(),
)
.unwrap(),
Instant::now() + Duration::from_secs(5),
);
// Test DKIM signing
let mut session = test.new_mta_session();
session.data.remote_ip_str = "10.0.0.2".into();
session.eval_session_params().await;
session.ehlo("mx.example.com").await;
session
.send_message(
"[email protected]",
&["[email protected]"],
"test:no_dkim",
"250",
)
.await;
test.expect_message()
.await
.read_lines(&test)
.await
.assert_contains(
"DKIM-Signature: v=1; a=rsa-sha256; s=rsa; d=example.com; c=simple/relaxed;",
);
// Test ARC verify
session
.send_message("[email protected]", &["[email protected]"], "test:arc", "250")
.await;
test.expect_message().await;
/*
// DMARC WG is ending the ARC experiment
.read_lines(&test)
.await
.assert_contains("ARC-Seal: i=3; a=ed25519-sha256; s=ed; d=example.com; cv=pass;")
.assert_contains(
"ARC-Message-Signature: i=3; a=ed25519-sha256; s=ed; d=example.com; c=relaxed/simple;",
);
// Test ARC sealing of a DKIM signed message
session
.send_message("[email protected]", &["[email protected]"], "test:dkim", "250")
.await;
test.expect_message()
.await
.read_lines(&test)
.await
.assert_contains("ARC-Seal: i=1; a=ed25519-sha256; s=ed; d=example.com; cv=none;")
.assert_contains(
"ARC-Message-Signature: i=1; a=ed25519-sha256; s=ed; d=example.com; c=relaxed/simple;",
);*/
}
impl Account {
pub async fn create_dkim_signatures(&self, domain_id: Id) -> Vec<Id> {
let rsa_id = self
.registry_create_object(DkimSignature::Dkim1RsaSha256(Dkim1Signature {
stage: DkimRotationStage::Active,
selector: "rsa".to_string(),
canonicalization: DkimCanonicalization::SimpleRelaxed,
domain_id,
private_key: SecretText::Text(SecretTextValue {
secret: RSA_KEY.to_string(),
}),
..Default::default()
}))
.await;
let ed_id = self
.registry_create_object(DkimSignature::Dkim1Ed25519Sha256(Dkim1Signature {
stage: DkimRotationStage::Active,
selector: "ed".to_string(),
canonicalization: DkimCanonicalization::RelaxedSimple,
domain_id,
private_key: SecretText::Text(SecretTextValue {
secret: ED25519_KEY.to_string(),
}),
..Default::default()
}))
.await;
vec![rsa_id, ed_id]
}
}
const RSA_KEY: &str = r#"-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAv9XYXG3uK95115mB4nJ37nGeNe2CrARm1agrbcnSk5oIaEfM
ZLUR/X8gPzoiNHZcfMZEVR6bAytxUhc5EvZIZrjSuEEeny+fFd/cTvcm3cOUUbIa
UmSACj0dL2/KwW0LyUaza9z9zor7I5XdIl1M53qVd5GI62XBB76FH+Q0bWPZNkT4
NclzTLspD/MTpNCCPhySM4Kdg5CuDczTH4aNzyS0TqgXdtw6A4Sdsp97VXT9fkPW
9rso3lrkpsl/9EQ1mR/DWK6PBmRfIuSFuqnLKY6v/z2hXHxF7IoojfZLa2kZr9Ae
d4l9WheQOTA19k5r2BmlRw/W9CrgCBo0Sdj+KQIDAQABAoIBAFPChEi/OvnulReB
ECQWhOUYuNKlFKQU++2YEvZJ4+bMn5UgnE7wfJ1pj2Pr9xlfALz+OMHNrjMxGbaV
KzdrT2uCkYcf78XjnhuH9gKIiXDUv4L4N+P3u6w8yOx4bFgOS9IjS53yDOPM7SC5
g6dIg5aigHaHlffqIuFFv4yQMI/+Ai+zBKxS7wRhxK/7nnAuo28fe5MEdp57ho9/
AGlDNsdg9zCgjwhokwFE3+AaD+bkUFm4gQ1XjkUFrlmnQn8vDQ0i9toEWhCj+UPY
iOKL63MJnr90MXTXWLHoFj99wBp//mYygbF9Lj8fa28/oa8LWp3Jhb7QeMgH46iv
3aLHbTECgYEA5M2dAw+nyMw9vYlkMejhwObKYP8Mr/6zcGMLCalYvRJM5iUAM0JI
H6sM6pV9/nv167cbKocj3xYPdtE7FPOn4132MLM8Ne1f8nPE64Qrcbj5WBXvLnU8
hpWbwe2Z8h7UUMKx6q4F1/TXYkc3ScxYwfjM4mP/pLsAOgVzRSEEgrUCgYEA1qNQ
xaQHNWZ1O8WuTnqWd5JSsic6iURAmUcLeFDZY2PWhVoaQ8L/xMQhDYs1FIbLWArW
4Qq3Ibu8AbSejAKuaJz7Uf26PX+PYVUwAOO0qamCJ8d/qd6So7qWMDyAY2yXI39Y
1nMqRjr7bkEsggAZao7BKqA7ZtmogjOusBT38iUCgYEA06agJ8TDoKvOMRZ26PRU
YO0dKLzGL8eclcoI29cbj0rud7aiiMg3j5PbTuUat95TjsjDCIQaWrM9etvxm2AJ
Xfn9Uu96MyhyKQWOk46f4YMKpMElkARDCPw8KRhx39dE77AqhLyWCz8iPndCXbH6
KPTOEl4OjYOuof2Is9nnIkECgYBh948RdsnXhNlzm8nwhiGRmBbou+EK8D0v+O5y
Tyy6IcKzgSnFzgZh8EdJ4EUtBk1f9SqY8wQdgIvSl3daXorusuA/TzkngsaV3YUY
ktZOLlF7CKLrjOyPkMWmZKcROmpNyH1q/IvKHHfQnizLdXIkYd4nL5WNX0F7lE1i
j1+QhQKBgB2lviBK7rJFwlFYdQUP1NAN2dKxMZk8uJS8JglHrM0+8nRI83HbTdEQ
vB0ManEKBkbS4T5n+gRtdEqKSDmWDTXDlrBfcdCHNQLwYtBpOotCqQn/AmfjcPBl
byAbwh4+HiZ5JISoRZpiZqy67aJNVoXmdtb/E9mi7ozzytpxMNql
-----END RSA PRIVATE KEY-----
"#;
const ED25519_KEY: &str = r#"-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIAO3hAf144lTAVjTkht3ZwBTK0CMCCd1bI0alggneN3B
-----END PRIVATE KEY-----
"#;
+126
View File
@@ -0,0 +1,126 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::utils::server::TestServerBuilder;
use registry::{
schema::{
enums::MtaInboundThrottleKey,
structs::{Expression, MtaInboundThrottle, Rate},
},
types::map::Map,
};
use smtp::core::SessionAddress;
use std::time::Duration;
#[tokio::test]
async fn throttle_inbound() {
let mut test = TestServerBuilder::new("smtp_inbound_throttle_test")
.await
.with_http_listener(19009)
.await
.disable_services()
.build()
.await;
// Add test settings
let admin = test.account("admin");
admin.mta_no_auth().await;
admin
.registry_create_object(MtaInboundThrottle {
description: "Test throttle".into(),
enable: true,
key: Map::new(vec![MtaInboundThrottleKey::RemoteIp]),
match_: Expression {
else_: "remote_ip = '10.0.0.1'".into(),
..Default::default()
},
rate: Rate {
count: 2,
period: 1000u64.into(),
},
})
.await;
admin
.registry_create_object(MtaInboundThrottle {
description: "Test throttle".into(),
enable: true,
key: Map::new(vec![MtaInboundThrottleKey::Sender]),
rate: Rate {
count: 2,
period: 1000u64.into(),
},
..Default::default()
})
.await;
admin
.registry_create_object(MtaInboundThrottle {
enable: true,
key: Map::new(vec![
MtaInboundThrottleKey::RemoteIp,
MtaInboundThrottleKey::Rcpt,
]),
rate: Rate {
count: 2,
period: 1000u64.into(),
},
description: "Test throttle".into(),
..Default::default()
})
.await;
admin.reload_settings().await;
test.reload_core();
// Test connection rate limit
let mut session = test.new_mta_session();
session.data.remote_ip_str = "10.0.0.1".into();
assert!(session.is_allowed().await, "Rate limiter too strict.");
assert!(session.is_allowed().await, "Rate limiter too strict.");
assert!(!session.is_allowed().await, "Rate limiter failed.");
tokio::time::sleep(Duration::from_millis(1100)).await;
assert!(
session.is_allowed().await,
"Rate limiter did not restore quota."
);
// Test mail from rate limit
session.data.mail_from = SessionAddress {
address: "[email protected]".into(),
address_lcase: "[email protected]".into(),
domain: "test.org".into(),
flags: 0,
dsn_info: None,
}
.into();
assert!(session.is_allowed().await, "Rate limiter too strict.");
assert!(session.is_allowed().await, "Rate limiter too strict.");
assert!(!session.is_allowed().await, "Rate limiter failed.");
session.data.mail_from = SessionAddress {
address: "[email protected]".into(),
address_lcase: "[email protected]".into(),
domain: "test.org".into(),
flags: 0,
dsn_info: None,
}
.into();
assert!(session.is_allowed().await, "Rate limiter failed.");
// Test recipient rate limit
session.data.rcpt_to.push(SessionAddress {
address: "[email protected]".into(),
address_lcase: "[email protected]".into(),
domain: "example.org".into(),
flags: 0,
dsn_info: None,
});
assert!(session.is_allowed().await, "Rate limiter too strict.");
assert!(session.is_allowed().await, "Rate limiter too strict.");
assert!(!session.is_allowed().await, "Rate limiter failed.");
session.data.remote_ip_str = "10.0.0.2".into();
assert!(session.is_allowed().await, "Rate limiter too strict.");
}
+119
View File
@@ -0,0 +1,119 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
smtp::session::{TestSession, VerifyResponse},
utils::server::TestServerBuilder,
};
use registry::{
schema::structs::{Expression, ExpressionMatch, MailingList, MtaExtensions},
types::{list::List, map::Map},
};
#[tokio::test]
async fn vrfy_expn() {
let mut test = TestServerBuilder::new("smtp_vrfy_test")
.await
.with_http_listener(19006)
.await
.disable_services()
.build()
.await;
// Create test users
let admin = test.account("admin");
for (name, secret, description, aliases) in [
("[email protected]", "12345 + extra safety", "John Doe", &[]),
("[email protected]", "abcde + extra safety", "Jane Smith", &[]),
(
"[email protected]",
"p4ssw0rd + extra safety",
"Bill Foobar",
&[],
),
] {
admin
.create_user_account(name, secret, description, aliases, vec![])
.await;
}
let domain_id = admin.find_or_create_domain("foobar.org").await;
admin
.registry_create_object(MailingList {
domain_id,
name: "sales".into(),
recipients: Map::new(vec![
"[email protected]".into(),
"[email protected]".into(),
"[email protected]".into(),
]),
..Default::default()
})
.await;
// Add test settings
admin.mta_no_auth().await;
admin
.registry_create_object(MtaExtensions {
vrfy: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "remote_ip = '10.0.0.1'".into(),
then: "true".into(),
}]),
else_: "false".into(),
},
expn: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "remote_ip = '10.0.0.1'".into(),
then: "true".into(),
}]),
else_: "false".into(),
},
..Default::default()
})
.await;
admin.reload_settings().await;
test.reload_core();
// EHLO should not advertise VRFY/EXPN to 10.0.0.2
let mut session = test.new_mta_session();
session.data.remote_ip_str = "10.0.0.2".into();
session.eval_session_params().await;
session
.ehlo("mx.foobar.org")
.await
.assert_not_contains("EXPN")
.assert_not_contains("VRFY");
session.cmd("VRFY [email protected]", "252 2.5.1").await;
session.cmd("EXPN [email protected]", "252 2.5.1").await;
// EHLO should advertise VRFY/EXPN for 10.0.0.1
session.data.remote_ip_str = "10.0.0.1".into();
session.eval_session_params().await;
session
.ehlo("mx.foobar.org")
.await
.assert_contains("EXPN")
.assert_contains("VRFY");
// Successful VRFY
session
.cmd("VRFY [email protected]", "250 [email protected]")
.await;
// Successful EXPN
session
.cmd("EXPN [email protected]", "250")
.await
.assert_contains("[email protected]")
.assert_contains("[email protected]")
.assert_contains("250 [email protected]");
// Non-existent VRFY
session.cmd("VRFY robert", "550 5.1.2").await;
// Non-existent EXPN
session.cmd("EXPN procurement", "550 5.1.2").await;
}
+135
View File
@@ -0,0 +1,135 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::utils::{dns::DnsCache, server::TestServerBuilder};
use common::expr::{tokenizer::TokenMap, *};
use mail_auth::{DnssecStatus, MX};
use registry::schema::{
enums::ExpressionVariable,
prelude::{ObjectType, Property},
structs::{LookupStore, SqliteStore, StoreLookup},
};
use smtp::queue::RecipientDomain;
use std::time::{Duration, Instant};
const TESTS: &[(&str, &str)] = &[
("dns_query(rcpt_domain, 'mx')[0]", "mx.foobar.org"),
(
"key_get('sql', 'hello') + '-' + key_exists('sql', 'hello') + '-' + key_set('sql', 'hello', 'world') + '-' + key_get('sql', 'hello') + '-' + key_exists('sql', 'hello')",
"-0-1-world-1",
),
(
"counter_get('sql', 'county') + '-' + counter_incr('sql', 'county', 1) + '-' + counter_incr('sql', 'county', 1) + '-' + counter_get('sql', 'county')",
"0-1-2-2",
),
(
"sql_query('sql', 'SELECT description FROM domains WHERE name = ?', 'foobar.org')",
"Main domain",
),
(
"is_local_domain('foobar.org') + '-' + is_local_domain('unknown.org') + '-' + is_local_address('[email protected]') + '-' + is_local_address('[email protected]')",
"1-0-1-0",
),
(
"is_local_domain('FooBar.org') + '-' + is_local_address('[email protected]') + '-' + is_local_address('[email protected]')",
"1-1-1",
),
];
#[tokio::test]
async fn expressions() {
let mut test = TestServerBuilder::new("smtp_lookup_test")
.await
.with_http_listener(19017)
.await
.disable_services()
.capture_queue()
.build()
.await;
// Create test data
let admin = test.account("admin");
for (name, secret, description, aliases) in [
("[email protected]", "12345 + extra safety", "John Doe", &[]),
("[email protected]", "abcde + extra safety", "Jane Smith", &[]),
] {
admin
.create_user_account(name, secret, description, aliases, vec![])
.await;
}
admin
.registry_create_object(StoreLookup {
namespace: "sql".into(),
store: LookupStore::Sqlite(SqliteStore {
path: format!("{}/smtp_sql.db", test.tmp_dir()),
pool_max_connections: 10,
pool_workers: None,
}),
})
.await;
admin.reload_lookup_stores().await;
test.reload_core();
test.server.mx_add(
"test.org",
vec![MX {
exchanges: vec!["mx.foobar.org".into()].into_boxed_slice(),
preference: 10,
}],
DnssecStatus::Secure,
Instant::now() + Duration::from_secs(10),
);
let sql = test
.server
.get_lookup_store("sql")
.unwrap()
.into_store()
.unwrap();
sql.create_tables().await.unwrap();
for query in [
"CREATE TABLE domains (name TEXT PRIMARY KEY, description TEXT);",
"INSERT INTO domains (name, description) VALUES ('foobar.org', 'Main domain');",
"INSERT INTO domains (name, description) VALUES ('foobar.net', 'Secondary domain');",
"CREATE TABLE allowed_ips (addr TEXT PRIMARY KEY);",
"INSERT INTO allowed_ips (addr) VALUES ('10.0.0.50');",
] {
sql.sql_query::<usize>(query, Vec::new()).await.unwrap();
}
// Test expression functions
let token_map = TokenMap::default().with_variables(&[
ExpressionVariable::Rcpt,
ExpressionVariable::RcptDomain,
ExpressionVariable::Sender,
ExpressionVariable::SenderDomain,
ExpressionVariable::Mx,
ExpressionVariable::HeloDomain,
ExpressionVariable::AuthenticatedAs,
ExpressionVariable::Listener,
ExpressionVariable::RemoteIp,
ExpressionVariable::LocalIp,
ExpressionVariable::Priority,
]);
for (expr, expected) in TESTS {
let e = Expression::parse(&token_map, expr);
assert_eq!(
test.server
.eval_expr::<String, _>(
&e,
&RecipientDomain::new("test.org"),
ObjectType::Account.singleton(),
Property::AccountName,
0
)
.await
.unwrap(),
*expected,
"failed for '{}'",
expr
);
}
}
+8
View File
@@ -0,0 +1,8 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
pub mod expressions;
pub mod utils;
+358
View File
@@ -0,0 +1,358 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::utils::server::TestServerBuilder;
use ::smtp::outbound::NextHop;
use common::config::smtp::{
queue::{MxConfig, QueueExpiry, QueueName},
report::AggregateFrequency,
resolver::{Mode, MxPattern, Policy},
};
use mail_auth::{DnssecStatus, IpLookupStrategy, MX, RecordSet};
use mail_parser::DateTime;
use registry::{
schema::{
enums::MtaIpStrategy,
structs::{
Expression, MtaConnectionIpHost, MtaConnectionStrategy, MtaOutboundStrategy, MtaRoute,
MtaRouteMx,
},
},
types::{ipaddr::IpAddr, list::List},
};
use smtp::{
outbound::{
lookup::{SourceIp, ToNextHop},
mta_sts::parse::ParsePolicy,
},
queue::{
Error, ErrorDetails, FROM_AUTHENTICATED, Message, QueueEnvelope, Recipient, Schedule,
Status,
},
reporting::AggregateTimestamp,
};
use std::{str::FromStr, sync::Arc};
use store::write::now;
#[tokio::test]
async fn strategies() {
let ipv6: [IpAddr; 4] = [
"a:b::1".parse().unwrap(),
"a:b::2".parse().unwrap(),
"a:b::3".parse().unwrap(),
"a:b::4".parse().unwrap(),
];
let ipv4: [IpAddr; 4] = [
"10.0.0.1".parse().unwrap(),
"10.0.0.2".parse().unwrap(),
"10.0.0.3".parse().unwrap(),
"10.0.0.4".parse().unwrap(),
];
let ipv4_hosts = [
"test1.example.com".to_string(),
"test2.example.com".to_string(),
"test3.example.com".to_string(),
"test4.example.com".to_string(),
];
let ipv6_hosts = [
"test5.example.com".to_string(),
"test6.example.com".to_string(),
"test7.example.com".to_string(),
"test8.example.com".to_string(),
];
let mut test = TestServerBuilder::new("smtp_strategies_test")
.await
.with_http_listener(19016)
.await
.disable_services()
.capture_queue()
.build()
.await;
// Add test settings
let admin = test.account("admin");
admin.mta_no_auth().await;
admin
.registry_create_object(MtaConnectionStrategy {
name: "test".into(),
ehlo_hostname: "test.example.com".to_string().into(),
connect_timeout: 10_000u64.into(),
source_ips: List::from_iter([
MtaConnectionIpHost {
ehlo_hostname: "test1.example.com".to_string().into(),
source_ip: IpAddr::from_str("10.0.0.1").unwrap(),
},
MtaConnectionIpHost {
ehlo_hostname: "test2.example.com".to_string().into(),
source_ip: IpAddr::from_str("10.0.0.2").unwrap(),
},
MtaConnectionIpHost {
ehlo_hostname: "test3.example.com".to_string().into(),
source_ip: IpAddr::from_str("10.0.0.3").unwrap(),
},
MtaConnectionIpHost {
ehlo_hostname: "test4.example.com".to_string().into(),
source_ip: IpAddr::from_str("10.0.0.4").unwrap(),
},
MtaConnectionIpHost {
ehlo_hostname: "test5.example.com".to_string().into(),
source_ip: IpAddr::from_str("a:b::1").unwrap(),
},
MtaConnectionIpHost {
ehlo_hostname: "test6.example.com".to_string().into(),
source_ip: IpAddr::from_str("a:b::2").unwrap(),
},
MtaConnectionIpHost {
ehlo_hostname: "test7.example.com".to_string().into(),
source_ip: IpAddr::from_str("a:b::3").unwrap(),
},
MtaConnectionIpHost {
ehlo_hostname: "test8.example.com".to_string().into(),
source_ip: IpAddr::from_str("a:b::4").unwrap(),
},
]),
..Default::default()
})
.await;
admin
.registry_create_object(MtaRoute::Mx(MtaRouteMx {
ip_lookup_strategy: MtaIpStrategy::V4ThenV6,
name: "test-v4".into(),
..Default::default()
}))
.await;
admin
.registry_create_object(MtaRoute::Mx(MtaRouteMx {
ip_lookup_strategy: MtaIpStrategy::V6ThenV4,
name: "test-v6".into(),
..Default::default()
}))
.await;
admin
.registry_create_object(MtaOutboundStrategy {
schedule: Expression {
else_: concat!(
"source + ' ' + received_from_ip + ' ' + ",
"received_via_port + ' ' + queue_name + ' ' + ",
"last_error + ' ' + rcpt_domain + ' ' + size + ' ' + queue_age"
)
.into(),
..Default::default()
},
..Default::default()
})
.await;
admin.reload_settings().await;
test.reload_core();
let conn = test
.server
.core
.smtp
.queue
.connection_strategy
.get("test")
.unwrap();
assert_eq!(conn.ehlo_hostname.as_ref().unwrap(), "test.example.com");
for is_ipv4 in [true, false] {
for _ in 0..10 {
let ip_host = conn.source_ip(is_ipv4).unwrap();
if is_ipv4 {
assert_eq!(
&ipv4_hosts[ipv4
.iter()
.position(|&ip| ip.into_inner() == ip_host.ip)
.unwrap()],
ip_host.host.as_ref().unwrap()
);
} else {
assert_eq!(
&ipv6_hosts[ipv6
.iter()
.position(|&ip| ip.into_inner() == ip_host.ip)
.unwrap()],
ip_host.host.as_ref().unwrap()
);
}
}
}
// Test strategy resolution
let message = Message {
created: now() - 123,
blob_hash: Default::default(),
received_from_ip: "1.2.3.4".parse().unwrap(),
received_via_port: 7911,
return_path: "[email protected]".into(),
recipients: vec![Recipient {
address: "[email protected]".into(),
retry: Schedule::now(),
notify: Schedule::now(),
expires: QueueExpiry::Ttl(3600),
queue: QueueName::new("test").unwrap(),
status: Status::TemporaryFailure(ErrorDetails {
entity: "test.example.com".into(),
details: Error::TlsError("TLS handshake failed".into()),
}),
flags: 0,
orcpt: None,
}],
flags: FROM_AUTHENTICATED,
env_id: None,
priority: 0,
size: 978,
metadata: Default::default(),
};
assert_eq!(
test.server
.eval_if::<String, _>(
&test.server.core.smtp.queue.queue,
&QueueEnvelope::new(&message, &message.recipients[0]),
0,
)
.await
.unwrap_or_else(|| "default".to_string()),
"authenticated 1.2.3.4 7911 test tls foobar.com 978 123"
);
}
#[test]
fn to_remote_hosts() {
let mx: RecordSet<MX> = RecordSet {
rrset: Arc::from(vec![
MX {
exchanges: vec!["mx1".into(), "mx2".into()].into_boxed_slice(),
preference: 10,
},
MX {
exchanges: vec!["mx3".into(), "mx4".into(), "mx5".into(), "mx6".into()]
.into_boxed_slice(),
preference: 20,
},
MX {
exchanges: vec!["mx7".into(), "mx8".into()].into_boxed_slice(),
preference: 10,
},
MX {
exchanges: vec!["mx9".into(), "mxA".into()].into_boxed_slice(),
preference: 10,
},
]),
dnssec_status: DnssecStatus::Indeterminate,
};
let mx_config = MxConfig {
max_mx: 7,
max_multi_homed: 2,
ip_lookup_strategy: IpLookupStrategy::Ipv4thenIpv6,
};
let hosts = mx.to_remote_hosts("domain", &mx_config).unwrap();
assert_eq!(hosts.len(), 7);
for host in hosts {
if let NextHop::MX { host, .. } = host {
assert!((*host.as_bytes().last().unwrap() - b'0') <= 8);
}
}
let mx: RecordSet<MX> = RecordSet {
rrset: Arc::from(vec![MX {
exchanges: vec![".".into()].into_boxed_slice(),
preference: 0,
}]),
dnssec_status: DnssecStatus::Indeterminate,
};
assert!(mx.to_remote_hosts("domain", &mx_config).is_none());
}
#[test]
fn parse_policy() {
for (policy, expected_policy) in [
(
r"version: STSv1
mode: enforce
mx: mail.example.com
mx: *.example.net
mx: backupmx.example.com
max_age: 604800",
Policy {
id: "abc".to_string(),
mode: Mode::Enforce,
mx: vec![
MxPattern::Equals("mail.example.com".to_string()),
MxPattern::StartsWith("example.net".to_string()),
MxPattern::Equals("backupmx.example.com".to_string()),
]
.into_boxed_slice(),
max_age: 604800,
},
),
(
r"version: STSv1
mode: testing
mx: gmail-smtp-in.l.google.com
mx: *.gmail-smtp-in.l.google.com
max_age: 86400
",
Policy {
id: "abc".to_string(),
mode: Mode::Testing,
mx: vec![
MxPattern::Equals("gmail-smtp-in.l.google.com".to_string()),
MxPattern::StartsWith("gmail-smtp-in.l.google.com".to_string()),
]
.into_boxed_slice(),
max_age: 86400,
},
),
] {
assert_eq!(
Policy::parse(policy, expected_policy.id.to_string()).unwrap(),
expected_policy
);
}
}
#[test]
fn aggregate_to_timestamp() {
for (freq, date, expected) in [
(
AggregateFrequency::Hourly,
"2023-01-24T09:10:40Z",
"2023-01-24T09:00:00Z",
),
(
AggregateFrequency::Daily,
"2023-01-24T09:10:40Z",
"2023-01-24T00:00:00Z",
),
(
AggregateFrequency::Weekly,
"2023-01-24T09:10:40Z",
"2023-01-22T00:00:00Z",
),
(
AggregateFrequency::Weekly,
"2023-01-28T23:59:59Z",
"2023-01-22T00:00:00Z",
),
(
AggregateFrequency::Weekly,
"2023-01-22T23:59:59Z",
"2023-01-22T00:00:00Z",
),
] {
assert_eq!(
DateTime::from_timestamp(
freq.to_timestamp_(DateTime::parse_rfc3339(date).unwrap()) as i64
)
.to_rfc3339(),
expected,
"failed for {freq:?} {date} {expected}"
);
}
}
+8
View File
@@ -0,0 +1,8 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
pub mod queue;
pub mod report;
+625
View File
@@ -0,0 +1,625 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
smtp::session::TestSession,
utils::{dns::DnsCache, server::TestServerBuilder},
};
use ahash::{AHashMap, HashMap, HashSet};
use mail_auth::{DnssecStatus, MX};
use registry::{
schema::{
enums::NetworkListenerProtocol,
prelude::{ObjectType, Property},
structs::{
Expression, MtaDeliveryExpiration, MtaDeliveryExpirationTtl, MtaDeliverySchedule,
MtaDeliveryScheduleInterval, MtaDeliveryScheduleIntervals,
MtaDeliveryScheduleIntervalsOrDefault, MtaExtensions, MtaOutboundStrategy,
MtaStageRcpt, MtaVirtualQueue, QueueExpiry, QueuedMessage, RecipientStatus,
},
},
types::{EnumImpl, datetime::UTCDateTime, list::List},
};
use serde_json::json;
use std::time::{Duration, Instant};
use types::id::Id;
#[tokio::test]
#[serial_test::serial]
async fn manage_queue() {
let mut local = TestServerBuilder::new("smtp_manage_queue_local")
.await
.with_http_listener(19049)
.await
.disable_services()
.build()
.await;
let mut remote = TestServerBuilder::new("smtp_manage_queue_remote")
.await
.with_dummy_tls_cert(["*.foobar.org"])
.await
.with_http_listener(19050)
.await
.with_listener(NetworkListenerProtocol::Smtp, "smtp-debug", 9925, false)
.await
.disable_services()
.capture_queue()
.build()
.await;
let remote_admin = remote.account("admin");
remote_admin.mta_allow_relaying().await;
remote_admin.mta_no_auth().await;
remote_admin.mta_allow_non_fqdn().await;
remote_admin.reload_settings().await;
remote.reload_core();
remote.expect_reload_settings().await;
let admin = local.account("admin");
admin
.registry_create_object(MtaExtensions {
dsn: Expression {
else_: "true".into(),
..Default::default()
},
future_release: Expression {
else_: "1h".into(),
..Default::default()
},
..Default::default()
})
.await;
admin
.registry_create_object(MtaStageRcpt {
max_recipients: Expression {
else_: "100".into(),
..Default::default()
},
allow_relaying: Expression {
else_: "true".into(),
..Default::default()
},
..Default::default()
})
.await;
let queue_id = admin
.registry_create_object(MtaVirtualQueue {
name: "myqueue".into(),
threads_per_node: 25,
description: None,
})
.await;
admin
.registry_create_object(MtaDeliverySchedule {
name: "default".into(),
retry: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
intervals: List::from_iter([MtaDeliveryScheduleInterval {
duration: 1_000_000u64.into(),
}]),
}),
notify: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
intervals: List::from_iter([MtaDeliveryScheduleInterval {
duration: 2_000_000u64.into(),
}]),
}),
expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl {
expire: 3_000_000u64.into(),
}),
queue_id,
description: None,
})
.await;
admin
.registry_create_object(MtaOutboundStrategy {
schedule: Expression {
else_: "'default'".into(),
..Default::default()
},
..Default::default()
})
.await;
admin.mta_no_auth().await;
admin.mta_allow_non_fqdn().await;
admin.reload_settings().await;
local.reload_core();
let admin = local.account("admin");
// Add mock DNS entries
local.server.mx_add(
"foobar.org",
vec![MX {
exchanges: vec!["mx1.foobar.org".into()].into_boxed_slice(),
preference: 10,
}],
DnssecStatus::Secure,
Instant::now() + Duration::from_secs(10),
);
local.server.ipv4_add(
"mx1.foobar.org",
vec!["127.0.0.1".parse().unwrap()],
Instant::now() + Duration::from_secs(10),
);
// Send test messages
let envelopes = HashMap::from_iter([
(
"a",
(
"[email protected]",
vec![
"[email protected]",
"[email protected]",
"[email protected]",
],
),
),
(
"b",
(
"[email protected]",
vec!["[email protected]", "[email protected]"],
),
),
(
"c",
(
"[email protected]",
vec![
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
],
),
),
("d", ("[email protected]", vec!["[email protected]"])),
("e", ("[email protected]", vec!["[email protected]"])),
("f", ("", vec!["[email protected]", "[email protected]"])),
]);
let mut session = local.new_mta_session();
session.data.remote_ip_str = "10.0.0.1".into();
session.eval_session_params().await;
session.ehlo("foobar.net").await;
for test_num in 0..6 {
let env_id = char::from(b'a' + test_num).to_string();
let hold_for = ((test_num + 1) as u32) * 100;
let (sender, recipients) = envelopes.get(env_id.as_str()).unwrap();
session
.send_message(
&if env_id != "f" {
format!("<{sender}> ENVID={env_id} HOLDFOR={hold_for}")
} else {
format!("<{sender}> ENVID={env_id}")
},
recipients,
"test:no_dkim",
"250",
)
.await;
}
// Expect delivery to [email protected]
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(
remote
.consume_message()
.await
.message
.recipients
.into_iter()
.map(|r| r.address().to_string())
.collect::<Vec<_>>(),
vec!["[email protected]"]
);
// Fetch and validate messages
assert_eq!(
admin
.registry_query_ids(
ObjectType::QueuedMessage,
Vec::<(&str, &str)>::new(),
Vec::<&str>::new()
)
.await
.len(),
6
);
let messages = admin.registry_get_all::<QueuedMessage>().await;
assert_eq!(messages.len(), 6);
let mut id_map = AHashMap::new();
let mut id_map_rev = AHashMap::new();
let mut test_search = String::new();
for (id, message) in messages {
let env_id = message.env_id.as_ref().unwrap().clone();
// Validate return path and recipients
let (sender, recipients) = envelopes.get(env_id.as_str()).unwrap();
assert_eq!(
&message.return_path,
if !sender.is_empty() { sender } else { "<>" }
);
'outer: for recipient in recipients {
for (address, _) in message.recipients.iter() {
if address == recipient {
continue 'outer;
}
}
panic!("Recipient {recipient} not found in message.");
}
// Validate status and datetimes
let created = message.created_at.timestamp();
let hold_for = (env_id.as_bytes().first().unwrap() - b'a' + 1) as i64 * 100;
let next_retry = created + hold_for;
let next_notify = created + 2000 + hold_for;
let expires = created + 3000 + hold_for;
for (rcpt_address, rcpt) in message.recipients.iter() {
if env_id == "c" {
let mut dt = rcpt.retry_due;
dt.add_seconds(-1);
test_search = dt.to_string();
}
if env_id != "f" {
// HOLDFOR messages
assert_eq!(rcpt.retry_count, 0);
assert_timestamp(rcpt.retry_due.timestamp(), next_retry, "retry", &message);
assert_timestamp(rcpt.notify_due.timestamp(), next_notify, "notify", &message);
assert_timestamp(
match &rcpt.expires {
QueueExpiry::Ttl(ttl) => ttl.expires_at.timestamp(),
QueueExpiry::Attempts(_) => unreachable!(),
},
expires,
"expires",
&message,
);
assert_eq!(&rcpt.status, &RecipientStatus::Scheduled, "{message:#?}");
} else if rcpt_address == "[email protected]" {
assert_eq!(rcpt.retry_count, 0);
assert!(
matches!(&rcpt.status, RecipientStatus::Completed(_)),
"{:?}",
rcpt.status
);
} else {
assert_eq!(rcpt.retry_count, 1);
assert!(
matches!(&rcpt.status, RecipientStatus::TemporaryFailure(_)),
"{:?}",
rcpt.status
);
}
}
id_map.insert(env_id.clone(), id);
id_map_rev.insert(id, env_id);
}
assert_eq!(id_map.len(), 6);
// Test list search
for (query, expected_ids) in [
(
vec![(Property::ReturnPath.as_str(), "[email protected]")],
vec!["a"],
),
(
vec![(Property::To.as_str(), "foobar.org")],
vec!["d", "e", "f"],
),
(
vec![
(Property::ReturnPath.as_str(), "[email protected]"),
(Property::To.as_str(), "[email protected]"),
],
vec!["c"],
),
(
vec![("dueIsLessThan", test_search.as_str())],
vec!["a", "b"],
),
(
vec![("dueIsGreaterThan", test_search.as_str())],
vec!["d", "e", "f", "c"],
),
] {
let ids = admin
.registry_query_ids(ObjectType::QueuedMessage, query.clone(), Vec::<&str>::new())
.await;
assert_eq!(
HashSet::from_iter(ids.iter().map(|id| id_map_rev.get(id).unwrap().as_str())),
HashSet::from_iter(expected_ids.into_iter()),
"failed for query {query:?}"
);
}
// Test pagination (forward and reverse)
let asc_order: Vec<Id> = admin
.registry_query_paginated(
ObjectType::QueuedMessage,
"due",
true,
None,
None,
None,
None,
false,
)
.await
.object_ids()
.collect();
assert_eq!(asc_order.len(), 6, "expected 6 messages, got {asc_order:?}");
let desc_order: Vec<Id> = asc_order.iter().rev().copied().collect();
for chunk_start in [0usize, 2, 4] {
let asc = admin
.registry_query_paginated(
ObjectType::QueuedMessage,
"due",
true,
Some(chunk_start as i32),
Some(2),
None,
None,
false,
)
.await
.object_ids()
.collect::<Vec<_>>();
assert_eq!(
asc,
asc_order[chunk_start..chunk_start + 2],
"ascending position={chunk_start} limit=2",
);
let desc = admin
.registry_query_paginated(
ObjectType::QueuedMessage,
"due",
false,
Some(chunk_start as i32),
Some(2),
None,
None,
false,
)
.await
.object_ids()
.collect::<Vec<_>>();
assert_eq!(
desc,
desc_order[chunk_start..chunk_start + 2],
"descending position={chunk_start} limit=2",
);
}
for anchor_idx in [1usize, 3] {
let asc = admin
.registry_query_paginated(
ObjectType::QueuedMessage,
"due",
true,
None,
Some(2),
Some(asc_order[anchor_idx]),
Some(1),
false,
)
.await
.object_ids()
.collect::<Vec<_>>();
assert_eq!(
asc,
asc_order[anchor_idx + 1..anchor_idx + 3],
"ascending anchor={} offset=1 limit=2",
asc_order[anchor_idx],
);
let desc = admin
.registry_query_paginated(
ObjectType::QueuedMessage,
"due",
false,
None,
Some(2),
Some(desc_order[anchor_idx]),
Some(1),
false,
)
.await
.object_ids()
.collect::<Vec<_>>();
assert_eq!(
desc,
desc_order[anchor_idx + 1..anchor_idx + 3],
"descending anchor={} offset=1 limit=2",
desc_order[anchor_idx],
);
}
// Retry delivery
admin
.registry_update_object(
ObjectType::QueuedMessage,
id_map["e"],
json!({
"recipients/[email protected]/retryDue": UTCDateTime::now()
}),
)
.await;
admin
.registry_update_object(
ObjectType::QueuedMessage,
id_map["f"],
json!({
"recipients/[email protected]/retryDue": UTCDateTime::now()
}),
)
.await;
admin
.registry_update_object(
ObjectType::QueuedMessage,
id_map["a"],
json!({
"recipients/[email protected]/retryDue": "2200-01-01T00:00:00Z",
}),
)
.await;
// Expect delivery to [email protected]
tokio::time::sleep(Duration::from_millis(200)).await;
assert_eq!(
remote
.consume_message()
.await
.message
.recipients
.into_iter()
.map(|r| r.address().to_string())
.collect::<Vec<_>>(),
vec!["[email protected]".to_string()]
);
// Message 'e' should be gone, 'f' should have retry_count == 2
// while 'a' should have a retry time of 2200-01-01T00:00:00Z
assert_eq!(
admin
.registry_get_many(ObjectType::QueuedMessage, [id_map["e"]])
.await
.not_found()
.next()
.unwrap(),
id_map["e"].to_string()
);
assert_eq!(
admin
.registry_get::<QueuedMessage>(id_map["f"])
.await
.recipients
.values()
.next()
.unwrap()
.retry_count,
2
);
for (rcpt_address, rcpt) in admin
.registry_get::<QueuedMessage>(id_map["a"])
.await
.recipients
{
let next_retry = rcpt.retry_due.to_string();
let matched =
["2200-01-01T00:00:00Z", "2199-12-31T23:59:59Z"].contains(&next_retry.as_str());
if rcpt_address.ends_with("example1.org") {
assert!(matched, "{next_retry}");
} else {
assert!(!matched, "{next_retry}");
}
}
// Cancel deliveries
for (id, filter) in [
("a", &["[email protected]", "[email protected]"][..]),
("b", &["[email protected]", "[email protected]"][..]),
("c", &["[email protected]"][..]),
] {
let mut map = serde_json::Map::new();
for i in filter {
map.insert(format!("recipients/{i}"), serde_json::Value::Null);
}
admin
.registry_update_object(
ObjectType::QueuedMessage,
id_map[id],
serde_json::Value::Object(map),
)
.await;
}
admin
.registry_destroy(ObjectType::QueuedMessage, [id_map["d"]])
.await
.assert_destroyed(&[id_map["d"]]);
tokio::time::sleep(Duration::from_millis(200)).await;
assert_eq!(admin.registry_get_all::<QueuedMessage>().await.len(), 3);
assert_eq!(
admin
.registry_query_ids(
ObjectType::QueuedMessage,
Vec::<(&str, &str)>::new(),
Vec::<&str>::new()
)
.await
.len(),
3
);
for id in ["b", "d"] {
assert_eq!(
admin
.registry_get_many(ObjectType::QueuedMessage, [id_map[id]])
.await
.not_found()
.next()
.unwrap(),
id_map[id].to_string()
);
}
for id in ["a", "c"] {
let message = admin.registry_get::<QueuedMessage>(id_map[id]).await;
assert!(!message.recipients.is_empty());
for (rcpt_address, rcpt) in message.recipients {
match id {
"a" => {
if rcpt_address.ends_with("example2.org") {
assert!(matches!(&rcpt.status, RecipientStatus::PermanentFailure(_)));
} else {
assert!(matches!(&rcpt.status, RecipientStatus::Scheduled));
}
}
"c" => {
if rcpt_address.ends_with("example2.com") {
if rcpt_address == "[email protected]" {
assert!(matches!(&rcpt.status, RecipientStatus::PermanentFailure(_)));
} else {
assert!(matches!(&rcpt.status, RecipientStatus::Scheduled));
}
} else {
assert!(matches!(&rcpt.status, RecipientStatus::Scheduled));
}
}
_ => unreachable!(),
}
}
}
// Bulk cancel
admin.registry_destroy_all(ObjectType::QueuedMessage).await;
assert_eq!(
admin
.registry_query_ids(
ObjectType::QueuedMessage,
Vec::<(&str, &str)>::new(),
Vec::<&str>::new()
)
.await
.len(),
0
);
}
fn assert_timestamp(timestamp: i64, expected: i64, ctx: &str, message: &QueuedMessage) {
let diff = timestamp - expected;
if ![-2, -1, 0, 1, 2].contains(&diff) {
panic!(
"Got timestamp {timestamp}, expected {expected} (diff {diff} for {ctx}) for {message:?}"
);
}
}
+257
View File
@@ -0,0 +1,257 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::utils::server::TestServerBuilder;
use ahash::AHashMap;
use common::{
config::smtp::report::AggregateFrequency,
ipc::{DmarcEvent, PolicyType, TlsEvent},
};
use mail_auth::{
common::parse::TxtRecordParser,
dmarc::Dmarc,
mta_sts::TlsRpt,
report::{
ActionDisposition, DmarcResult, Record,
tlsrpt::{FailureDetails, ResultType},
},
};
use registry::schema::{
prelude::{ObjectType, Property},
structs::{
DmarcInternalReport, DmarcReportSettings, Expression, TlsInternalReport, TlsReportSettings,
},
};
use smtp::reporting::send::MtaReportSend;
use std::sync::Arc;
#[tokio::test]
#[serial_test::serial]
async fn manage_reports() {
let mut test = TestServerBuilder::new("smtp_report_manage")
.await
.with_http_listener(19048)
.await
.disable_services()
.capture_queue()
.build()
.await;
let admin = test.account("admin");
admin
.registry_create_object(TlsReportSettings {
max_report_size: Expression {
else_: "1024".into(),
..Default::default()
},
..Default::default()
})
.await;
admin
.registry_create_object(DmarcReportSettings {
aggregate_max_report_size: Expression {
else_: "1024".into(),
..Default::default()
},
..Default::default()
})
.await;
admin.mta_allow_relaying().await;
admin.mta_no_auth().await;
admin.mta_allow_non_fqdn().await;
admin.reload_settings().await;
test.reload_core();
test.expect_reload_settings().await;
let admin = test.account("admin");
// Send test reporting events
test.server
.schedule_report(DmarcEvent {
domain: "foobar.org".to_string(),
report_record: Record::new()
.with_source_ip("192.168.1.2".parse().unwrap())
.with_action_disposition(ActionDisposition::Pass)
.with_dmarc_dkim_result(DmarcResult::Pass)
.with_dmarc_spf_result(DmarcResult::Fail)
.with_envelope_from("[email protected]")
.with_envelope_to("[email protected]")
.with_header_from("[email protected]"),
dmarc_record: Arc::new(
Dmarc::parse(b"v=DMARC1; p=reject; rua=mailto:[email protected]").unwrap(),
),
interval: AggregateFrequency::Daily,
span_id: 0,
})
.await;
test.server
.schedule_report(DmarcEvent {
domain: "foobar.net".to_string(),
report_record: Record::new()
.with_source_ip("a:b:c::e:f".parse().unwrap())
.with_action_disposition(ActionDisposition::Reject)
.with_dmarc_dkim_result(DmarcResult::Fail)
.with_dmarc_spf_result(DmarcResult::Pass),
dmarc_record: Arc::new(
Dmarc::parse(
concat!(
"v=DMARC1; p=quarantine; rua=mailto:reports",
"@foobar.net,mailto:[email protected]"
)
.as_bytes(),
)
.unwrap(),
),
interval: AggregateFrequency::Weekly,
span_id: 0,
})
.await;
test.server
.schedule_report(TlsEvent {
domain: "foobar.org".to_string(),
policy: PolicyType::None,
failure: None,
tls_record: Arc::new(
TlsRpt::parse(b"v=TLSRPTv1;rua=mailto:[email protected]").unwrap(),
),
interval: AggregateFrequency::Daily,
span_id: 0,
})
.await;
test.server
.schedule_report(TlsEvent {
domain: "foobar.net".to_string(),
policy: PolicyType::Sts(None),
failure: FailureDetails::new(ResultType::StsPolicyInvalid).into(),
tls_record: Arc::new(
TlsRpt::parse(b"v=TLSRPTv1;rua=mailto:[email protected]").unwrap(),
),
interval: AggregateFrequency::Weekly,
span_id: 0,
})
.await;
// List DMARC reports
let mut dmarc_name_to_id = AHashMap::new();
let mut dmarc_id_to_name = AHashMap::new();
for (id, report) in admin.registry_get_all::<DmarcInternalReport>().await {
let diff =
report.report.date_range_end.timestamp() - report.report.date_range_begin.timestamp();
if report.domain == "foobar.org" {
assert_eq!(diff, 86400);
} else {
assert_eq!(diff, 7 * 86400);
}
dmarc_name_to_id.insert(report.domain.clone(), id);
dmarc_id_to_name.insert(id, report.domain);
}
assert_eq!(dmarc_name_to_id.len(), 2);
// List TLS reports
let mut tls_name_to_id = AHashMap::new();
let mut tls_id_to_name = AHashMap::new();
for (id, report) in admin.registry_get_all::<TlsInternalReport>().await {
let diff =
report.report.date_range_end.timestamp() - report.report.date_range_start.timestamp();
if report.domain == "foobar.org" {
assert_eq!(diff, 86400);
} else {
assert_eq!(diff, 7 * 86400);
}
tls_name_to_id.insert(report.domain.clone(), id);
tls_id_to_name.insert(id, report.domain);
}
assert_eq!(tls_name_to_id.len(), 2);
// Test list search
for (object, query, expected_ids) in [
(
ObjectType::DmarcInternalReport,
vec![],
vec![
dmarc_name_to_id["foobar.org"],
dmarc_name_to_id["foobar.net"],
],
),
(
ObjectType::TlsInternalReport,
vec![],
vec![tls_name_to_id["foobar.org"], tls_name_to_id["foobar.net"]],
),
(
ObjectType::DmarcInternalReport,
vec![(Property::Domain, "foobar.org".to_string())],
vec![dmarc_name_to_id["foobar.org"]],
),
(
ObjectType::DmarcInternalReport,
vec![(Property::Domain, "foobar.net".to_string())],
vec![dmarc_name_to_id["foobar.net"]],
),
(
ObjectType::TlsInternalReport,
vec![(Property::Domain, "foobar.org".to_string())],
vec![tls_name_to_id["foobar.org"]],
),
(
ObjectType::TlsInternalReport,
vec![(Property::Domain, "foobar.net".to_string())],
vec![tls_name_to_id["foobar.net"]],
),
] {
assert_eq!(
admin
.registry_query_ids(object, query.clone(), Vec::<&str>::new())
.await,
expected_ids,
"failed for {object:?} with query {query:?}"
);
}
// Cancel reports
for (object, id) in [
(
ObjectType::DmarcInternalReport,
dmarc_name_to_id["foobar.org"],
),
(ObjectType::TlsInternalReport, tls_name_to_id["foobar.org"]),
] {
admin
.registry_destroy(object, vec![id])
.await
.assert_destroyed(&[id]);
}
for (object, id) in [
(
ObjectType::DmarcInternalReport,
dmarc_name_to_id["foobar.net"],
),
(ObjectType::TlsInternalReport, tls_name_to_id["foobar.net"]),
] {
assert_eq!(
admin
.registry_query_ids(object, Vec::<(&str, &str)>::new(), Vec::<&str>::new())
.await,
vec![id],
"failed for {object:?}"
);
}
// Cancel all reports
admin
.registry_destroy_all(ObjectType::DmarcInternalReport)
.await;
admin
.registry_destroy_all(ObjectType::TlsInternalReport)
.await;
assert_eq!(
admin.registry_get_all::<DmarcInternalReport>().await,
Vec::new()
);
assert_eq!(
admin.registry_get_all::<TlsInternalReport>().await,
Vec::new()
);
}
+13
View File
@@ -0,0 +1,13 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
pub mod inbound;
pub mod lookup;
pub mod management;
pub mod outbound;
pub mod queue;
pub mod reporting;
pub mod session;
File diff suppressed because it is too large Load Diff
+183
View File
@@ -0,0 +1,183 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
smtp::{
inbound::{TestMessage, TestQueueEvent},
session::{TestSession, VerifyResponse},
},
utils::{dns::DnsCache, server::TestServerBuilder},
};
use mail_auth::{DnssecStatus, MX};
use registry::schema::structs::{Expression, MtaStageData};
use smtp_proto::{MAIL_REQUIRETLS, MAIL_RET_HDRS, MAIL_SMTPUTF8, RCPT_NOTIFY_NEVER};
use std::time::{Duration, Instant};
#[tokio::test]
#[serial_test::serial]
async fn extensions() {
let mut local = TestServerBuilder::new("smtp_ext_local")
.await
.with_http_listener(19020)
.await
.disable_services()
.capture_queue()
.build()
.await;
let mut remote = TestServerBuilder::new("smtp_ext_remote")
.await
.with_http_listener(19021)
.await
.with_smtp_listener(9925)
.await
.disable_services()
.capture_queue()
.build()
.await;
let local_admin = local.account("admin");
local_admin.mta_allow_relaying().await;
local_admin.mta_no_auth().await;
local_admin.mta_all_extensions().await;
local_admin.reload_settings().await;
local.reload_core();
local.expect_reload_settings().await;
let remote_admin = remote.account("admin");
remote_admin.mta_all_extensions().await;
remote_admin.mta_allow_relaying().await;
remote_admin.mta_no_auth().await;
remote_admin
.registry_create_object(MtaStageData {
max_message_size: Expression {
else_: "1500".into(),
..Default::default()
},
add_date_header: Expression {
else_: "true".into(),
..Default::default()
},
add_message_id_header: Expression {
else_: "true".into(),
..Default::default()
},
add_received_header: Expression {
else_: "true".into(),
..Default::default()
},
add_received_spf_header: Expression {
else_: "true".into(),
..Default::default()
},
add_auth_results_header: Expression {
else_: "true".into(),
..Default::default()
},
add_return_path_header: Expression {
else_: "false".into(),
..Default::default()
},
enable_spam_filter: Expression {
else_: "false".into(),
..Default::default()
},
..Default::default()
})
.await;
remote_admin.reload_settings().await;
remote.reload_core();
remote.expect_reload_settings().await;
// Add mock DNS entries
local.server.mx_add(
"foobar.org",
vec![MX {
exchanges: vec!["mx.foobar.org".into()].into_boxed_slice(),
preference: 10,
}],
DnssecStatus::Secure,
Instant::now() + Duration::from_secs(10),
);
local.server.ipv4_add(
"mx.foobar.org",
vec!["127.0.0.1".parse().unwrap()],
Instant::now() + Duration::from_secs(10),
);
let mut session = local.new_mta_session();
session.data.remote_ip_str = "10.0.0.1".into();
session.eval_session_params().await;
session.ehlo("mx.test.org").await;
session
.send_message(
"[email protected]",
&["<[email protected]> NOTIFY=SUCCESS,FAILURE"],
"test:no_dkim",
"250",
)
.await;
local
.expect_message_then_deliver()
.await
.try_deliver(local.server.clone());
local
.expect_message()
.await
.read_lines(&local)
.await
.assert_contains("<[email protected]> (delivered to")
.assert_contains("Final-Recipient: rfc822;[email protected]")
.assert_contains("Action: delivered");
local.read_event().await.assert_done();
remote
.expect_message()
.await
.read_lines(&remote)
.await
.assert_contains("using TLSv1.3 with cipher");
// Test SIZE extension
session
.send_message("[email protected]", &["[email protected]"], "test:arc", "250")
.await;
local
.expect_message_then_deliver()
.await
.try_deliver(local.server.clone());
local
.expect_message()
.await
.read_lines(&local)
.await
.assert_contains("<[email protected]> (host 'mx.foobar.org' rejected command 'MAIL FROM:")
.assert_contains("Action: failed")
.assert_contains("Diagnostic-Code: smtp;552")
.assert_contains("Status: 5.3.4");
local.read_event().await.assert_done();
remote.assert_no_events();
// Test DSN, SMTPUTF8 and REQUIRETLS extensions
session
.send_message(
"<[email protected]> ENVID=abc123 RET=HDRS REQUIRETLS SMTPUTF8",
&["<[email protected]> NOTIFY=NEVER"],
"test:no_dkim",
"250",
)
.await;
local
.expect_message_then_deliver()
.await
.try_deliver(local.server.clone());
local.read_event().await.assert_done();
let message = remote.expect_message().await;
assert_eq!(message.message.env_id, Some("abc123".into()));
assert!((message.message.flags & MAIL_RET_HDRS) != 0);
assert!((message.message.flags & MAIL_REQUIRETLS) != 0);
assert!((message.message.flags & MAIL_SMTPUTF8) != 0);
assert!((message.message.recipients.last().unwrap().flags & RCPT_NOTIFY_NEVER) != 0);
}
+137
View File
@@ -0,0 +1,137 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
smtp::session::TestSession,
utils::{dns::DnsCache, server::TestServerBuilder},
};
use mail_auth::{DnssecStatus, MX};
use registry::{
schema::{
enums::MtaProtocol,
structs::{
Expression, ExpressionMatch, MtaOutboundStrategy, MtaRoute, MtaRouteRelay, MtaStageRcpt,
},
},
types::list::List,
};
use std::time::{Duration, Instant};
use store::write::now;
#[tokio::test]
#[serial_test::serial]
async fn fallback_relay() {
let mut local = TestServerBuilder::new("smtp_fallback_local")
.await
.with_http_listener(19022)
.await
.disable_services()
.capture_queue()
.build()
.await;
let mut remote = TestServerBuilder::new("smtp_fallback_remote")
.await
.with_http_listener(19023)
.await
.with_smtp_listener(9925)
.await
.disable_services()
.capture_queue()
.build()
.await;
let local_admin = local.account("admin");
local_admin
.registry_create_object(MtaStageRcpt {
max_recipients: Expression {
else_: "100".into(),
..Default::default()
},
allow_relaying: Expression {
else_: "true".into(),
..Default::default()
},
..Default::default()
})
.await;
local_admin
.registry_create_object(MtaOutboundStrategy {
route: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "retry_num > 0".into(),
then: "'fallback'".into(),
}]),
else_: "'mx'".into(),
},
..Default::default()
})
.await;
local_admin
.registry_create_object(MtaRoute::Relay(MtaRouteRelay {
address: "fallback.foobar.org".into(),
implicit_tls: false,
allow_invalid_certs: true,
name: "fallback".into(),
port: 9925,
protocol: MtaProtocol::Smtp,
..Default::default()
}))
.await;
local_admin.mta_no_auth().await;
local_admin.mta_all_extensions().await;
local_admin.reload_settings().await;
local.reload_core();
local.expect_reload_settings().await;
let remote_admin = remote.account("admin");
remote_admin.mta_allow_relaying().await;
remote_admin.mta_no_auth().await;
remote_admin.mta_all_extensions().await;
remote_admin.mta_allow_non_fqdn().await;
remote_admin.reload_settings().await;
remote.reload_core();
remote.expect_reload_settings().await;
// Add mock DNS entries
local.server.mx_add(
"foobar.org",
vec![MX {
exchanges: vec!["_dns_error.foobar.org".into()].into_boxed_slice(),
preference: 10,
}],
DnssecStatus::Secure,
Instant::now() + Duration::from_secs(10),
);
local.server.ipv4_add(
"fallback.foobar.org",
vec!["127.0.0.1".parse().unwrap()],
Instant::now() + Duration::from_secs(10),
);
let mut session = local.new_mta_session();
session.data.remote_ip_str = "10.0.0.1".into();
session.eval_session_params().await;
session.ehlo("mx.test.org").await;
session
.send_message("[email protected]", &["[email protected]"], "test:no_dkim", "250")
.await;
local
.expect_message_then_deliver()
.await
.try_deliver(local.server.clone());
let mut retry = local.expect_message().await;
let prev_due = retry.message.recipients[0].retry.due;
let next_due = now();
let queue_id = retry.queue_id;
retry.message.recipients[0].retry.due = next_due;
retry.save_changes(&local.server, prev_due.into()).await;
local
.delivery_attempt(queue_id)
.await
.try_deliver(local.server.clone());
tokio::time::sleep(Duration::from_millis(100)).await;
remote.expect_message().await;
}
+129
View File
@@ -0,0 +1,129 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
smtp::session::TestSession,
utils::{dns::DnsCache, server::TestServerBuilder},
};
use mail_auth::{DnssecStatus, MX};
use registry::{
schema::{
enums::MtaIpStrategy,
prelude::{ObjectType, Property},
structs::MtaRoute,
},
types::EnumImpl,
};
use serde_json::json;
use std::time::{Duration, Instant};
#[tokio::test]
#[serial_test::serial]
async fn ip_lookup_strategy() {
let mut local = TestServerBuilder::new("smtp_iplookup_local")
.await
.with_http_listener(19024)
.await
.disable_services()
.capture_queue()
.build()
.await;
let mut remote = TestServerBuilder::new("smtp_iplookup_remote")
.await
.with_http_listener(19025)
.await
.with_smtp_listener(9925)
.await
.disable_services()
.capture_queue()
.build()
.await;
let local_admin = local.account("admin");
local_admin.mta_allow_relaying().await;
local_admin.mta_no_auth().await;
local_admin.reload_settings().await;
let (mx_route_id, _) = local_admin
.registry_get_all::<MtaRoute>()
.await
.into_iter()
.find(|(_, s)| matches!(s, MtaRoute::Mx(_)))
.unwrap();
local.reload_core();
local.expect_reload_settings().await;
let remote_admin = remote.account("admin");
remote_admin.mta_allow_relaying().await;
remote_admin.mta_no_auth().await;
remote_admin.reload_settings().await;
remote.reload_core();
remote.expect_reload_settings().await;
for strategy in [MtaIpStrategy::V6Only, MtaIpStrategy::V6ThenV4] {
local
.account("admin")
.registry_update_object(
ObjectType::MtaRoute,
mx_route_id,
json!({
Property::IpLookupStrategy: strategy.as_str(),
}),
)
.await;
local.account("admin").reload_settings().await;
local.expect_reload_settings().await;
println!("-> Strategy: {:?}", strategy);
// Add mock DNS entries
local.server.mx_add(
"foobar.org",
vec![MX {
exchanges: vec!["mx.foobar.org".into()].into_boxed_slice(),
preference: 10,
}],
DnssecStatus::Secure,
Instant::now() + Duration::from_secs(10),
);
if matches!(strategy, MtaIpStrategy::V6ThenV4) {
local.server.ipv4_add(
"mx.foobar.org",
vec!["127.0.0.1".parse().unwrap()],
Instant::now() + Duration::from_secs(10),
);
}
local.server.ipv6_add(
"mx.foobar.org",
vec!["::1".parse().unwrap()],
Instant::now() + Duration::from_secs(10),
);
// Retry on failed STARTTLS
let mut session = local.new_mta_session();
session.data.remote_ip_str = "10.0.0.1".into();
session.eval_session_params().await;
session.ehlo("mx.test.org").await;
session
.send_message("[email protected]", &["[email protected]"], "test:no_dkim", "250")
.await;
local
.expect_message_then_deliver()
.await
.try_deliver(local.server.clone());
tokio::time::sleep(Duration::from_millis(100)).await;
if matches!(strategy, MtaIpStrategy::V6ThenV4) {
remote.expect_message().await;
} else {
let message = local.last_queued_message().await;
let status = message.message.recipients[0].status.to_string();
assert!(
status.contains("Connection refused"),
"Message: {:?}",
message
);
local.expect_refresh().await;
}
}
}
+296
View File
@@ -0,0 +1,296 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
smtp::{
inbound::TestMessage,
session::{TestSession, VerifyResponse},
},
utils::{dns::DnsCache, server::TestServerBuilder},
};
use common::{config::smtp::queue::QueueName, ipc::QueueEvent};
use registry::{
schema::{
enums::{MtaProtocol, NetworkListenerProtocol},
structs::{
Expression, ExpressionMatch, MtaConnectionStrategy, MtaDeliveryExpiration,
MtaDeliveryExpirationTtl, MtaDeliverySchedule, MtaDeliveryScheduleInterval,
MtaDeliveryScheduleIntervals, MtaDeliveryScheduleIntervalsOrDefault,
MtaOutboundStrategy, MtaRoute, MtaRouteRelay, MtaStageRcpt, MtaVirtualQueue,
},
},
types::list::List,
};
use smtp::queue::spool::{QUEUE_REFRESH, SmtpSpool};
use std::time::{Duration, Instant};
use store::write::now;
#[tokio::test]
#[serial_test::serial]
async fn lmtp_delivery() {
let mut local = TestServerBuilder::new("lmtp_delivery_local")
.await
.with_http_listener(19026)
.await
.disable_services()
.capture_queue()
.build()
.await;
let mut remote = TestServerBuilder::new("lmtp_delivery_remote")
.await
.with_http_listener(19027)
.await
.with_listener(NetworkListenerProtocol::Lmtp, "lmtp-debug", 9924, true)
.await
.disable_services()
.capture_queue()
.build()
.await;
let local_admin = local.account("admin");
local_admin
.registry_create_object(MtaStageRcpt {
max_recipients: Expression {
else_: "100".into(),
..Default::default()
},
allow_relaying: Expression {
else_: "true".into(),
..Default::default()
},
..Default::default()
})
.await;
local_admin
.registry_create_object(MtaOutboundStrategy {
route: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "rcpt_domain = 'foobar.org'".into(),
then: "'lmtp'".into(),
}]),
else_: "'mx'".into(),
},
schedule: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "rcpt_domain = 'foobar.org'".into(),
then: "'foobar'".into(),
}]),
else_: "'default'".into(),
},
connection: Expression {
else_: "'impatient'".into(),
..Default::default()
},
..Default::default()
})
.await;
local_admin
.registry_create_object(MtaRoute::Relay(MtaRouteRelay {
address: "lmtp.foobar.org".into(),
allow_invalid_certs: true,
implicit_tls: true,
name: "lmtp".into(),
port: 9924,
protocol: MtaProtocol::Lmtp,
..Default::default()
}))
.await;
let queue_id = local_admin
.registry_create_object(MtaVirtualQueue {
name: "default".into(),
threads_per_node: 25,
description: None,
})
.await;
local_admin
.registry_create_object(MtaDeliverySchedule {
name: "foobar".into(),
retry: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
intervals: List::from_iter([MtaDeliveryScheduleInterval {
duration: 1_000u64.into(),
}]),
}),
notify: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
intervals: List::from_iter([
MtaDeliveryScheduleInterval {
duration: 1_000u64.into(),
},
MtaDeliveryScheduleInterval {
duration: 2_000u64.into(),
},
]),
}),
expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl {
expire: 4_000u64.into(),
}),
queue_id,
description: None,
})
.await;
local_admin
.registry_create_object(MtaDeliverySchedule {
name: "default".into(),
retry: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
intervals: List::from_iter([MtaDeliveryScheduleInterval {
duration: 1_000u64.into(),
}]),
}),
notify: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
intervals: List::from_iter([MtaDeliveryScheduleInterval {
duration: 1_000u64.into(),
}]),
}),
expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl {
expire: 5_000u64.into(),
}),
queue_id,
description: None,
})
.await;
local_admin
.registry_create_object(MtaConnectionStrategy {
name: "impatient".into(),
connect_timeout: 1_000u64.into(),
data_timeout: 50u64.into(),
..Default::default()
})
.await;
local_admin.mta_no_auth().await;
local_admin.mta_all_extensions().await;
local_admin.reload_settings().await;
local.reload_core();
local.expect_reload_settings().await;
let remote_admin = remote.account("admin");
remote_admin.mta_allow_relaying().await;
remote_admin.mta_no_auth().await;
remote_admin.mta_all_extensions().await;
remote_admin.reload_settings().await;
remote.reload_core();
remote.expect_reload_settings().await;
// Add mock DNS entries
local.server.ipv4_add(
"lmtp.foobar.org",
vec!["127.0.0.1".parse().unwrap()],
Instant::now() + Duration::from_secs(10),
);
let mut session = local.new_mta_session();
session.data.remote_ip_str = "10.0.0.1".into();
session.eval_session_params().await;
session.ehlo("mx.test.org").await;
session
.send_message(
"[email protected]",
&[
"<[email protected]> NOTIFY=SUCCESS,DELAY,FAILURE",
"<[email protected]> NOTIFY=SUCCESS,DELAY,FAILURE",
"<[email protected]> NOTIFY=SUCCESS,DELAY,FAILURE",
"<[email protected]> NOTIFY=SUCCESS,DELAY,FAILURE",
"<[email protected]> NOTIFY=SUCCESS,DELAY,FAILURE",
"<[email protected]> NOTIFY=SUCCESS,DELAY,FAILURE",
],
"test:no_dkim",
"250",
)
.await;
local
.expect_message_for_queue_then_deliver("default")
.await
.try_deliver(local.server.clone());
tokio::time::sleep(Duration::from_millis(500)).await;
let mut dsn = Vec::new();
loop {
match local.try_read_event().await {
Some(QueueEvent::Refresh | QueueEvent::WorkerDone { .. }) => {}
Some(QueueEvent::Paused(_)) | Some(QueueEvent::ReloadSettings) => unreachable!(),
None | Some(QueueEvent::Stop) => break,
}
let mut events = local.all_queued_messages().await;
if events.messages.is_empty() {
let now = now();
if events.next_refresh < now + QUEUE_REFRESH {
tokio::time::sleep(Duration::from_secs(events.next_refresh - now)).await;
events = local.all_queued_messages().await;
} else {
break;
}
}
for event in events.messages {
let message = local
.server
.read_message(event.queue_id, QueueName::default())
.await
.unwrap();
if message.message.return_path.is_empty() {
message
.clone()
.remove(&local.server, event.due.into())
.await;
dsn.push(message);
} else {
event.try_deliver(local.server.clone());
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
}
local.assert_queue_is_empty().await;
assert_eq!(dsn.len(), 4);
let mut dsn = dsn.into_iter();
dsn.next()
.unwrap()
.read_lines(&local)
.await
.assert_contains("<[email protected]> (delivered to")
.assert_contains("<[email protected]> (delivered to")
.assert_contains("<[email protected]> (delivered to")
.assert_contains("<[email protected]> (failed to lookup")
.assert_contains("<[email protected]> (host 'lmtp.foobar.org' rejected command");
dsn.next()
.unwrap()
.read_lines(&local)
.await
.assert_contains("<[email protected]> (host 'lmtp.foobar.org' rejected")
.assert_contains("Action: delayed");
dsn.next()
.unwrap()
.read_lines(&local)
.await
.assert_contains("<[email protected]> (host 'lmtp.foobar.org' rejected")
.assert_contains("Action: delayed");
dsn.next()
.unwrap()
.read_lines(&local)
.await
.assert_contains("<[email protected]> (host 'lmtp.foobar.org' rejected")
.assert_contains("Action: failed");
assert_eq!(
remote
.expect_message()
.await
.message
.recipients
.into_iter()
.map(|r| r.address().to_string())
.collect::<Vec<_>>(),
vec![
"[email protected]".to_string(),
"[email protected]".to_string(),
"[email protected]".to_string()
]
);
remote.assert_no_events();
}
+15
View File
@@ -0,0 +1,15 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
pub mod dane;
pub mod extensions;
pub mod fallback_relay;
pub mod ip_lookup;
pub mod lmtp;
pub mod mta_sts;
pub mod smtp;
pub mod throttle;
pub mod tls;
+366
View File
@@ -0,0 +1,366 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
smtp::{
inbound::{TestMessage, TestQueueEvent, TestReportingEvent},
session::{TestSession, VerifyResponse},
},
utils::{dns::DnsCache, server::TestServerBuilder},
};
use common::{config::smtp::resolver::Policy, ipc::PolicyType};
use mail_auth::{
DnssecStatus, MX,
common::parse::TxtRecordParser,
mta_sts::{MtaSts, ReportUri, TlsRpt},
report::tlsrpt::ResultType,
};
use registry::{
schema::{
enums::{MtaRequiredOrOptional, NetworkListenerProtocol},
prelude::ObjectType,
structs::{Expression, MtaTlsStrategy, NetworkListener, TlsReportSettings},
},
types::{map::Map, socketaddr::SocketAddr},
};
use smtp::outbound::mta_sts::{lookup::STS_TEST_POLICY, parse::ParsePolicy};
use std::{
str::FromStr,
sync::Arc,
time::{Duration, Instant},
};
#[tokio::test]
#[serial_test::serial]
async fn mta_sts_verify() {
let mut local = TestServerBuilder::new("smtp_mta_sts_local")
.await
.with_http_listener(19028)
.await
.disable_services()
.capture_queue()
.capture_reporting()
.build()
.await;
let mut remote = TestServerBuilder::new("smtp_mta_sts_remote")
.await
.with_http_listener(19029)
.await
.with_smtp_listener(9925)
.await
.with_dummy_tls_cert(["*.foobar.org"])
.await
.disable_services()
.capture_queue()
.build()
.await;
let local_admin = local.account("admin");
local_admin.mta_allow_relaying().await;
local_admin.mta_no_auth().await;
local_admin
.registry_create_object(TlsReportSettings {
send_frequency: Expression {
else_: "weekly".into(),
..Default::default()
},
..Default::default()
})
.await;
let (tls_strategy_id, mut tls_strategy) = local_admin
.registry_get_all::<MtaTlsStrategy>()
.await
.into_iter()
.find(|(_, s)| s.name == "default")
.unwrap();
tls_strategy.mta_sts = MtaRequiredOrOptional::Require;
tls_strategy.allow_invalid_certs = false;
let mut tls_strategy = serde_json::to_value(tls_strategy).unwrap();
tls_strategy
.as_object_mut()
.unwrap()
.retain(|k, _| k != "name");
local_admin
.registry_update_object(ObjectType::MtaTlsStrategy, tls_strategy_id, tls_strategy)
.await;
local_admin.reload_settings().await;
local.reload_core();
local.expect_reload_settings().await;
let remote_admin = remote.account("admin");
remote_admin.mta_no_auth().await;
remote_admin.mta_allow_relaying().await;
remote_admin.mta_allow_non_fqdn().await;
remote_admin.mta_add_all_headers().await;
remote_admin.reload_settings().await;
remote.reload_core();
remote.expect_reload_settings().await;
// Add mock DNS entries
local.server.mx_add(
"foobar.org",
vec![MX {
exchanges: vec!["mx.foobar.org".into()].into_boxed_slice(),
preference: 10,
}],
DnssecStatus::Secure,
Instant::now() + Duration::from_secs(10),
);
local.server.ipv4_add(
"mx.foobar.org",
vec!["127.0.0.1".parse().unwrap()],
Instant::now() + Duration::from_secs(10),
);
local.server.txt_add(
"_smtp._tls.foobar.org",
TlsRpt::parse(b"v=TLSRPTv1; rua=mailto:[email protected]").unwrap(),
Instant::now() + Duration::from_secs(10),
);
let mut session = local.new_mta_session();
session.data.remote_ip_str = "10.0.0.1".into();
session.eval_session_params().await;
session.ehlo("mx.test.org").await;
session
.send_message("[email protected]", &["[email protected]"], "test:no_dkim", "250")
.await;
local
.expect_message_then_deliver()
.await
.try_deliver(local.server.clone());
local
.expect_message()
.await
.read_lines(&local)
.await
.assert_contains("<[email protected]> (MTA-STS failed to authenticate")
.assert_contains("Record not =")
.assert_contains("found");
local.read_event().await.assert_done();
// Expect TLS failure report
let report = local.read_report().await.unwrap_tls();
assert_eq!(report.domain, "foobar.org");
assert_eq!(report.policy, PolicyType::Sts(None));
assert_eq!(
report.failure.as_ref().unwrap().result_type,
ResultType::Other
);
assert_eq!(
report.tls_record.rua,
vec![ReportUri::Mail("[email protected]".to_string())]
);
// MTA-STS policy fetch failure
local.server.txt_add(
"_mta-sts.foobar.org",
MtaSts::parse(b"v=STSv1; id=policy_will_fail;").unwrap(),
Instant::now() + Duration::from_secs(10),
);
session
.send_message("[email protected]", &["[email protected]"], "test:no_dkim", "250")
.await;
local
.expect_message_then_deliver()
.await
.try_deliver(local.server.clone());
local
.expect_message()
.await
.read_lines(&local)
.await
.assert_contains("<[email protected]> (MTA-STS failed to authenticate")
.assert_contains("No 'mx' entries found");
local.read_event().await.assert_done();
// Expect TLS failure report
let report = local.read_report().await.unwrap_tls();
assert_eq!(report.policy, PolicyType::Sts(None));
assert_eq!(
report.failure.as_ref().unwrap().result_type,
ResultType::StsPolicyInvalid
);
// MTA-STS policy does not authorize mx.foobar.org
let policy = concat!(
"version: STSv1\n",
"mode: enforce\n",
"mx: mail.foobar.net\n",
"max_age: 604800\n"
);
STS_TEST_POLICY.lock().extend_from_slice(policy.as_bytes());
session
.send_message("[email protected]", &["[email protected]"], "test:no_dkim", "250")
.await;
local
.expect_message_then_deliver()
.await
.try_deliver(local.server.clone());
local
.expect_message()
.await
.read_lines(&local)
.await
.assert_contains("<[email protected]> (MTA-STS failed to authenticate")
.assert_contains("not authorized by policy");
local.read_event().await.assert_done();
// Expect TLS failure report
let report = local.read_report().await.unwrap_tls();
assert_eq!(
report.policy,
PolicyType::Sts(
Arc::new(Policy::parse(policy, "policy_will_fail".to_string()).unwrap()).into()
)
);
assert_eq!(
report.failure.as_ref().unwrap().receiving_mx_hostname,
Some("mx.foobar.org".to_string())
);
assert_eq!(
report.failure.as_ref().unwrap().result_type,
ResultType::ValidationFailure
);
remote.assert_no_events();
// MTA-STS successful validation
local.server.txt_add(
"_mta-sts.foobar.org",
MtaSts::parse(b"v=STSv1; id=policy_will_work;").unwrap(),
Instant::now() + Duration::from_secs(10),
);
let policy = concat!(
"version: STSv1\n",
"mode: enforce\n",
"mx: *.foobar.org\n",
"max_age: 604800\n"
);
STS_TEST_POLICY.lock().clear();
STS_TEST_POLICY.lock().extend_from_slice(policy.as_bytes());
session
.send_message("[email protected]", &["[email protected]"], "test:no_dkim", "250")
.await;
local
.expect_message_then_deliver()
.await
.try_deliver(local.server.clone());
local.read_event().await.assert_done();
remote
.expect_message()
.await
.read_lines(&remote)
.await
.assert_contains("using TLSv1.3 with cipher");
// Expect TLS success report
let report = local.read_report().await.unwrap_tls();
assert_eq!(
report.policy,
PolicyType::Sts(
Arc::new(Policy::parse(policy, "policy_will_work".to_string()).unwrap()).into()
)
);
assert!(report.failure.is_none());
}
#[tokio::test]
#[serial_test::serial]
async fn mta_sts_testing_mode_does_not_enforce_tls() {
let mut local = TestServerBuilder::new("smtp_mta_sts_testing_local")
.await
.with_http_listener(19051)
.await
.disable_services()
.capture_queue()
.build()
.await;
let mut remote = TestServerBuilder::new("smtp_mta_sts_testing_remote")
.await
.with_http_listener(19052)
.await
.with_object(NetworkListener {
bind: Map::new(vec![SocketAddr::from_str("0.0.0.0:9925").unwrap()]),
name: "smtp".to_string(),
protocol: NetworkListenerProtocol::Smtp,
use_tls: false,
tls_implicit: false,
..Default::default()
})
.await
.disable_services()
.capture_queue()
.build()
.await;
let local_admin = local.account("admin");
local_admin.mta_allow_relaying().await;
local_admin.mta_no_auth().await;
local_admin.reload_settings().await;
local.reload_core();
local.expect_reload_settings().await;
let remote_admin = remote.account("admin");
remote_admin.mta_no_auth().await;
remote_admin.mta_allow_relaying().await;
remote_admin.mta_allow_non_fqdn().await;
remote_admin.mta_add_all_headers().await;
remote_admin.reload_settings().await;
remote.reload_core();
remote.expect_reload_settings().await;
local.server.mx_add(
"foobar.org",
vec![MX {
exchanges: vec!["mx.foobar.org".into()].into_boxed_slice(),
preference: 10,
}],
DnssecStatus::Secure,
Instant::now() + Duration::from_secs(10),
);
local.server.ipv4_add(
"mx.foobar.org",
vec!["127.0.0.1".parse().unwrap()],
Instant::now() + Duration::from_secs(10),
);
local.server.txt_add(
"_mta-sts.foobar.org",
MtaSts::parse(b"v=STSv1; id=policy_in_testing;").unwrap(),
Instant::now() + Duration::from_secs(10),
);
STS_TEST_POLICY.lock().clear();
STS_TEST_POLICY.lock().extend_from_slice(
concat!(
"version: STSv1\n",
"mode: testing\n",
"mx: *.foobar.org\n",
"max_age: 604800\n"
)
.as_bytes(),
);
let mut session = local.new_mta_session();
session.data.remote_ip_str = "10.0.0.1".into();
session.eval_session_params().await;
session.ehlo("mx.test.org").await;
session
.send_message("[email protected]", &["[email protected]"], "test:no_dkim", "250")
.await;
local
.expect_message_then_deliver()
.await
.try_deliver(local.server.clone());
local.read_event().await.assert_done();
remote
.expect_message()
.await
.read_lines(&remote)
.await
.assert_not_contains("using TLSv1.3 with cipher");
STS_TEST_POLICY.lock().clear();
}
+429
View File
@@ -0,0 +1,429 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
smtp::{
inbound::{TestMessage, TestQueueEvent},
session::{TestSession, VerifyResponse},
},
utils::{dns::DnsCache, server::TestServerBuilder},
};
use common::{config::smtp::queue::QueueName, ipc::QueueEvent};
use mail_auth::{DnssecStatus, MX};
use registry::{
schema::{
enums::NetworkListenerProtocol,
structs::{
Expression, ExpressionMatch, MtaDeliveryExpiration, MtaDeliveryExpirationTtl,
MtaDeliverySchedule, MtaDeliveryScheduleInterval, MtaDeliveryScheduleIntervals,
MtaDeliveryScheduleIntervalsOrDefault, MtaExtensions, MtaOutboundStrategy,
MtaStageRcpt, MtaVirtualQueue,
},
},
types::list::List,
};
use smtp::queue::spool::{QUEUE_REFRESH, SmtpSpool};
use std::time::{Duration, Instant};
use store::write::now;
const SMUGGLER: &str = r#"From: Joe SixPack <[email protected]>
To: Suzie Q <[email protected]>
Subject: Is dinner ready?
Hi.
We lost the game. Are you hungry yet?
.hey
Joe.
<SEP>.
MAIL FROM:<[email protected]>
RCPT TO:<[email protected]>
DATA
From: Joe SixPack <[email protected]>
To: Suzie Q <[email protected]>
Subject: smuggled message
This is a smuggled message
"#;
#[tokio::test]
#[serial_test::serial]
async fn smtp_delivery() {
let mut local = TestServerBuilder::new("smtp_delivery_local")
.await
.with_http_listener(19030)
.await
.disable_services()
.capture_queue()
.build()
.await;
let mut remote = TestServerBuilder::new("smtp_delivery_remote")
.await
.with_http_listener(19031)
.await
.with_listener(NetworkListenerProtocol::Smtp, "smtp-debug", 9925, false)
.await
.disable_services()
.capture_queue()
.build()
.await;
let local_admin = local.account("admin");
local_admin
.registry_create_object(MtaStageRcpt {
max_recipients: Expression {
else_: "100".into(),
..Default::default()
},
allow_relaying: Expression {
else_: "true".into(),
..Default::default()
},
..Default::default()
})
.await;
local_admin
.registry_create_object(MtaOutboundStrategy {
schedule: Expression {
match_: List::from_iter([
ExpressionMatch {
if_: "rcpt_domain == 'foobar.org'".into(),
then: "'foobar-org'".into(),
},
ExpressionMatch {
if_: "rcpt_domain == 'foobar.com'".into(),
then: "'foobar-com'".into(),
},
]),
else_: "'default'".into(),
},
..Default::default()
})
.await;
let queue_id = local_admin
.registry_create_object(MtaVirtualQueue {
name: "default".into(),
threads_per_node: 25,
description: None,
})
.await;
local_admin
.registry_create_object(MtaDeliverySchedule {
name: "default".into(),
retry: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
intervals: List::from_iter([MtaDeliveryScheduleInterval {
duration: 1_000u64.into(),
}]),
}),
notify: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
intervals: List::from_iter([MtaDeliveryScheduleInterval {
duration: 1_000u64.into(),
}]),
}),
expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl {
expire: 7_000u64.into(),
}),
queue_id,
description: None,
})
.await;
local_admin
.registry_create_object(MtaDeliverySchedule {
name: "foobar-org".into(),
retry: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
intervals: List::from_iter([MtaDeliveryScheduleInterval {
duration: 1_000u64.into(),
}]),
}),
notify: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
intervals: List::from_iter([
MtaDeliveryScheduleInterval {
duration: 1_000u64.into(),
},
MtaDeliveryScheduleInterval {
duration: 2_000u64.into(),
},
]),
}),
expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl {
expire: 6_000u64.into(),
}),
queue_id,
description: None,
})
.await;
local_admin
.registry_create_object(MtaDeliverySchedule {
name: "foobar-com".into(),
retry: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
intervals: List::from_iter([MtaDeliveryScheduleInterval {
duration: 1_000u64.into(),
}]),
}),
notify: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
intervals: List::from_iter([
MtaDeliveryScheduleInterval {
duration: 5_000u64.into(),
},
MtaDeliveryScheduleInterval {
duration: 6_000u64.into(),
},
]),
}),
expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl {
expire: 7_000u64.into(),
}),
queue_id,
description: None,
})
.await;
local_admin.mta_no_auth().await;
local_admin.mta_all_extensions().await;
local_admin.mta_disable_spam_filter().await;
local_admin.reload_settings().await;
local.reload_core();
local.expect_reload_settings().await;
let remote_admin = remote.account("admin");
remote_admin.mta_allow_relaying().await;
remote_admin.mta_no_auth().await;
remote_admin.mta_disable_spam_filter().await;
remote_admin.mta_allow_non_fqdn().await;
remote_admin
.registry_create_object(MtaExtensions {
chunking: Expression {
else_: "false".into(),
..Default::default()
},
dsn: Expression {
else_: "true".into(),
..Default::default()
},
..Default::default()
})
.await;
remote_admin.reload_settings().await;
remote.reload_core();
remote.expect_reload_settings().await;
// Add mock DNS entries
for domain in ["foobar.org", "foobar.net", "foobar.com"] {
local.server.mx_add(
domain,
vec![MX {
exchanges: vec![
format!("mx1.{domain}").into(),
format!("mx2.{domain}").into(),
]
.into_boxed_slice(),
preference: 10,
}],
DnssecStatus::Secure,
Instant::now() + Duration::from_secs(10),
);
local.server.ipv4_add(
format!("mx1.{domain}"),
vec!["127.0.0.1".parse().unwrap()],
Instant::now() + Duration::from_secs(30),
);
local.server.ipv4_add(
format!("mx2.{domain}"),
vec!["127.0.0.1".parse().unwrap()],
Instant::now() + Duration::from_secs(30),
);
}
let mut session = local.new_mta_session();
session.data.remote_ip_str = "10.0.0.1".into();
session.eval_session_params().await;
session.ehlo("mx.test.org").await;
session
.send_message(
"[email protected]",
&[
"<[email protected]> NOTIFY=SUCCESS,DELAY,FAILURE",
"<[email protected]> NOTIFY=SUCCESS,DELAY,FAILURE",
"<[email protected]> NOTIFY=SUCCESS,DELAY,FAILURE",
"<[email protected]> NOTIFY=SUCCESS,DELAY,FAILURE",
"<[email protected]> NOTIFY=SUCCESS,DELAY,FAILURE",
"<[email protected]> NOTIFY=SUCCESS,DELAY,FAILURE",
"<[email protected]> NOTIFY=SUCCESS,DELAY,FAILURE",
],
"test:no_dkim",
"250",
)
.await;
let message = local.expect_message().await;
let num_recipients = message.message.recipients.len();
assert_eq!(num_recipients, 7);
local
.delivery_attempt_for_queue(message.queue_id, "default")
.await
.try_deliver(local.server.clone());
let mut dsn = Vec::new();
let mut rcpt_retries = vec![0; num_recipients];
loop {
match local.try_read_event().await {
Some(QueueEvent::Refresh | QueueEvent::WorkerDone { .. }) => {}
Some(QueueEvent::Paused(_)) | Some(QueueEvent::ReloadSettings) => unreachable!(),
None | Some(QueueEvent::Stop) => {
break;
}
}
let mut events = local.all_queued_messages().await;
if events.messages.is_empty() {
let now = now();
if events.next_refresh < now + QUEUE_REFRESH {
tokio::time::sleep(Duration::from_secs(events.next_refresh - now)).await;
events = local.all_queued_messages().await;
} else {
break;
}
}
for event in events.messages {
let message = local
.server
.read_message(event.queue_id, QueueName::default())
.await
.unwrap();
if message.message.return_path.is_empty() {
message
.clone()
.remove(&local.server, event.due.into())
.await;
dsn.push(message);
} else {
for (idx, rcpt) in message.message.recipients.iter().enumerate() {
rcpt_retries[idx] = rcpt.retry.inner;
}
event.try_deliver(local.server.clone());
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
}
assert_eq!(rcpt_retries[0], 0, "retries {rcpt_retries:?}");
assert!(rcpt_retries[1] >= 5, "retries {rcpt_retries:?}");
assert_eq!(rcpt_retries[2], 0, "retries {rcpt_retries:?}");
assert_eq!(rcpt_retries[3], 0, "retries {rcpt_retries:?}");
assert!(rcpt_retries[4] >= 5, "retries {rcpt_retries:?}");
assert_eq!(rcpt_retries[5], 0, "retries {rcpt_retries:?}");
assert_eq!(rcpt_retries[6], 0, "retries {rcpt_retries:?}");
assert!(
rcpt_retries[1] >= rcpt_retries[4],
"retries {rcpt_retries:?}"
);
local.assert_queue_is_empty().await;
assert_eq!(dsn.len(), 5);
let mut dsn = dsn.into_iter();
dsn.next()
.unwrap()
.read_lines(&local)
.await
.assert_contains("<[email protected]> (delivered to")
.assert_contains("<[email protected]> (delivered to")
.assert_contains("<[email protected]> (failed to lookup")
.assert_contains("<[email protected]> (host ")
.assert_contains("<[email protected]> (host ");
dsn.next()
.unwrap()
.read_lines(&local)
.await
.assert_contains("<[email protected]> (host ")
.assert_contains("<[email protected]> (host ")
.assert_contains("Action: delayed");
dsn.next()
.unwrap()
.read_lines(&local)
.await
.assert_contains("<[email protected]> (host ")
.assert_contains("Action: delayed");
dsn.next()
.unwrap()
.read_lines(&local)
.await
.assert_contains("<[email protected]> (host ");
dsn.next()
.unwrap()
.read_lines(&local)
.await
.assert_contains("<[email protected]> (host ")
.assert_contains("Action: failed");
let mut recipients = remote
.consume_message()
.await
.message
.recipients
.into_iter()
.map(|r| r.address().to_string())
.collect::<Vec<_>>();
recipients.extend(
remote
.consume_message()
.await
.message
.recipients
.into_iter()
.map(|r| r.address().to_string()),
);
recipients.sort();
assert_eq!(
recipients,
vec!["[email protected]".to_string(), "[email protected]".to_string()]
);
remote.assert_no_events();
// SMTP smuggling
for separator in ["\n", "\r"].iter() {
session.data.remote_ip_str = "10.0.0.2".into();
session.eval_session_params().await;
session.ehlo("mx.test.org").await;
let out_message = SMUGGLER
.replace('\r', "")
.replace('\n', "\r\n")
.replace("<SEP>", separator);
session
.send_message("[email protected]", &["[email protected]"], &out_message, "250")
.await;
local
.expect_message_for_queue_then_deliver("default")
.await
.try_deliver(local.server.clone());
local.read_event().await.assert_refresh_or_done();
let message = remote.consume_message().await.read_message(&remote).await;
assert!(
message.contains("This is a smuggled message"),
"message: {:?}",
message
);
assert!(
message.contains("We lost the game."),
"message: {:?}",
message
);
assert!(
message.contains(&format!("{separator}..\r\nMAIL FROM:<",)),
"Message {message:?} does not contain separator {:?}",
format!("{separator}..\r\nMAIL FROM:<",)
);
}
}
+412
View File
@@ -0,0 +1,412 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
smtp::{
queue::{build_rcpt, new_message},
session::TestSession,
},
utils::{dns::DnsCache, server::TestServerBuilder},
};
use common::config::smtp::queue::QueueName;
use mail_auth::{DnssecStatus, MX};
use registry::{
schema::{
enums::MtaOutboundThrottleKey,
structs::{
Expression, MtaDeliveryExpiration, MtaDeliveryExpirationTtl, MtaDeliverySchedule,
MtaDeliveryScheduleInterval, MtaDeliveryScheduleIntervals,
MtaDeliveryScheduleIntervalsOrDefault, MtaOutboundStrategy, MtaOutboundThrottle,
MtaVirtualQueue, Rate,
},
},
types::{list::List, map::Map},
};
use smtp::queue::{Message, QueueEnvelope, Recipient, throttle::IsAllowed};
use std::{
net::{IpAddr, Ipv4Addr},
time::{Duration, Instant},
};
use store::write::now;
#[tokio::test]
async fn throttle_outbound() {
let mut local = TestServerBuilder::new("smtp_throttle_outbound")
.await
.with_http_listener(19032)
.await
.disable_services()
.capture_queue()
.build()
.await;
let admin = local.account("admin");
let queue_id = admin
.registry_create_object(MtaVirtualQueue {
name: "default".into(),
threads_per_node: 25,
description: None,
})
.await;
admin
.registry_create_object(MtaDeliverySchedule {
name: "default".into(),
retry: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
intervals: List::from_iter([MtaDeliveryScheduleInterval {
duration: 3_600_000u64.into(),
}]),
}),
notify: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
intervals: List::from_iter([MtaDeliveryScheduleInterval {
duration: 3_600_000u64.into(),
}]),
}),
expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl {
expire: 3_600_000u64.into(),
}),
queue_id,
description: None,
})
.await;
admin
.registry_create_object(MtaOutboundStrategy {
schedule: Expression {
else_: "'default'".into(),
..Default::default()
},
..Default::default()
})
.await;
for (expr, key, rate_count, rate_duration) in [
(
"sender_domain = 'foobar.net'",
MtaOutboundThrottleKey::SenderDomain,
1,
30 * 60 * 1000,
),
(
"rcpt_domain = 'example.net'",
MtaOutboundThrottleKey::RcptDomain,
1,
40 * 60 * 1000,
),
("mx = 'mx.test.org'", MtaOutboundThrottleKey::Mx, 1, 99999),
(
"mx = 'mx.test.net'",
MtaOutboundThrottleKey::Mx,
1,
50 * 60 * 1000,
),
] {
admin
.registry_create_object(MtaOutboundThrottle {
enable: true,
key: Map::new(vec![key]),
match_: Expression {
else_: expr.into(),
..Default::default()
},
rate: Rate {
count: rate_count,
period: rate_duration.into(),
},
description: "Test throttle".into(),
})
.await;
}
admin.mta_no_auth().await;
admin.mta_allow_relaying().await;
admin.reload_settings().await;
local.reload_core();
local.expect_reload_settings().await;
// Build test message
let mut test_message = new_message(0).message;
test_message.return_path = "[email protected]".into();
test_message
.recipients
.push(build_rcpt("[email protected]", 0, 0, 0));
let mut session = local.new_mta_session();
session.data.remote_ip_str = "10.0.0.1".into();
session.eval_session_params().await;
session.ehlo("mx.test.org").await;
session
.send_message("[email protected]", &["[email protected]"], "test:no_dkim", "250")
.await;
assert_eq!(local.last_queued_due().await as i64 - now() as i64, 0);
// Throttle sender
let core = local.server.core.clone();
let throttle = &core.smtp.queue.outbound_limiters;
for t in &throttle.sender {
local
.server
.is_allowed(
t,
&QueueEnvelope::test(&test_message, &test_message.recipients[0], ""),
0,
)
.await
.unwrap();
}
// Expect rate limit throttle for sender domain 'foobar.net'
test_message.return_path = "[email protected]".into();
for t in &throttle.sender {
local
.server
.is_allowed(
t,
&QueueEnvelope::test(&test_message, &test_message.recipients[0], ""),
0,
)
.await
.unwrap();
}
test_message.recipients.clear();
session
.send_message("[email protected]", &["[email protected]"], "test:no_dkim", "250")
.await;
local
.expect_message_for_queue_then_deliver("default")
.await
.try_deliver(local.server.clone());
tokio::time::sleep(Duration::from_millis(100)).await;
local.expect_refresh().await;
let due = local.last_queued_due().await - now();
assert!(due > 0, "Due: {}", due);
// Expect concurrency throttle for recipient domain 'example.org'
test_message.return_path = "[email protected]".into();
test_message
.recipients
.push(build_rcpt("[email protected]", 0, 0, 0));
for t in &throttle.rcpt {
local
.server
.is_allowed(
t,
&QueueEnvelope::test(&test_message, &test_message.recipients[0], ""),
0,
)
.await
.unwrap();
}
// Expect rate limit throttle for recipient domain 'example.net'
test_message
.recipients
.push(build_rcpt("[email protected]", 0, 0, 0));
for t in &throttle.rcpt {
local
.server
.is_allowed(
t,
&QueueEnvelope::test(&test_message, &test_message.recipients[1], ""),
0,
)
.await
.unwrap();
}
session
.send_message(
"[email protected]",
&["[email protected]"],
"test:no_dkim",
"250",
)
.await;
local
.expect_message_for_queue_then_deliver("default")
.await
.try_deliver(local.server.clone());
tokio::time::sleep(Duration::from_millis(100)).await;
local.expect_refresh().await;
let due = local.last_queued_due().await - now();
assert!(due > 0, "Due: {}", due);
// Expect concurrency throttle for mx 'mx.test.org'
local.server.mx_add(
"test.org",
vec![MX {
exchanges: vec!["mx.test.org".into()].into_boxed_slice(),
preference: 10,
}],
DnssecStatus::Secure,
Instant::now() + Duration::from_secs(10),
);
local.server.ipv4_add(
"mx.test.org",
vec!["127.0.0.1".parse().unwrap()],
Instant::now() + Duration::from_secs(10),
);
test_message
.recipients
.push(build_rcpt("[email protected]", 0, 0, 0));
for t in &throttle.remote {
local
.server
.is_allowed(
t,
&QueueEnvelope::test(&test_message, &test_message.recipients[2], "mx.test.org"),
0,
)
.await
.unwrap();
}
// Expect rate limit throttle for mx 'mx.test.net'
local.server.mx_add(
"test.net",
vec![MX {
exchanges: vec!["mx.test.net".into()].into_boxed_slice(),
preference: 10,
}],
DnssecStatus::Secure,
Instant::now() + Duration::from_secs(10),
);
local.server.ipv4_add(
"mx.test.net",
vec!["127.0.0.1".parse().unwrap()],
Instant::now() + Duration::from_secs(10),
);
for t in &throttle.remote {
local
.server
.is_allowed(
t,
&QueueEnvelope::test(&test_message, &test_message.recipients[1], "mx.test.net"),
0,
)
.await
.unwrap();
}
session
.send_message("[email protected]", &["[email protected]"], "test:no_dkim", "250")
.await;
local
.expect_message_for_queue_then_deliver("default")
.await
.try_deliver(local.server.clone());
tokio::time::sleep(Duration::from_millis(100)).await;
local.expect_refresh().await;
let due = local.last_queued_due().await - now();
assert!(due > 0, "Due: {}", due);
}
#[tokio::test]
async fn throttle_outbound_queue_name() {
let mut local = TestServerBuilder::new("smtp_throttle_outbound_queue_name")
.await
.with_http_listener(19033)
.await
.disable_services()
.capture_queue()
.build()
.await;
let admin = local.account("admin");
let queue_id = admin
.registry_create_object(MtaVirtualQueue {
name: "default".into(),
threads_per_node: 25,
description: None,
})
.await;
admin
.registry_create_object(MtaDeliverySchedule {
name: "default".into(),
retry: MtaDeliveryScheduleIntervalsOrDefault::Default,
notify: MtaDeliveryScheduleIntervalsOrDefault::Default,
expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl {
expire: 3_600_000u64.into(),
}),
queue_id,
description: None,
})
.await;
admin
.registry_create_object(MtaOutboundStrategy {
schedule: Expression {
else_: "'default'".into(),
..Default::default()
},
..Default::default()
})
.await;
admin
.registry_create_object(MtaOutboundThrottle {
enable: true,
key: Map::new(vec![MtaOutboundThrottleKey::SenderDomain]),
match_: Expression {
else_: "queue_name == 'default'".into(),
..Default::default()
},
rate: Rate {
count: 1,
period: (30u64 * 60 * 1000).into(),
},
description: "queue_name throttle".into(),
})
.await;
admin.reload_settings().await;
local.reload_core();
local.expect_reload_settings().await;
let core = local.server.core.clone();
let throttle = &core.smtp.queue.outbound_limiters;
assert_eq!(throttle.sender.len(), 1);
assert!(throttle.rcpt.is_empty());
assert!(throttle.remote.is_empty());
let matching = new_message(0);
assert_eq!(matching.queue_name, QueueName::default());
local
.server
.is_allowed(&throttle.sender[0], &matching, 0)
.await
.unwrap();
assert!(
local
.server
.is_allowed(&throttle.sender[0], &matching, 0)
.await
.is_err(),
"sender-bucket throttle failed to resolve queue_name and never engaged"
);
let mut other_queue = new_message(0);
other_queue.queue_name = QueueName::new("remote").unwrap();
local
.server
.is_allowed(&throttle.sender[0], &other_queue, 0)
.await
.unwrap();
}
pub trait TestQueueEnvelope<'x> {
fn test(message: &'x Message, rcpt: &'x Recipient, mx: &'x str) -> Self;
}
impl<'x> TestQueueEnvelope<'x> for QueueEnvelope<'x> {
fn test(message: &'x Message, rcpt: &'x Recipient, mx: &'x str) -> Self {
QueueEnvelope {
message,
mx,
remote_ip: IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)),
local_ip: IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)),
domain: rcpt.domain_part(),
rcpt,
}
}
}
+159
View File
@@ -0,0 +1,159 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
smtp::{
inbound::TestMessage,
session::{TestSession, VerifyResponse},
},
utils::{dns::DnsCache, server::TestServerBuilder},
};
use mail_auth::{DnssecStatus, MX};
use registry::{
schema::{
enums::MtaRequiredOrOptional,
structs::{
Expression, ExpressionMatch, MtaConnectionStrategy, MtaDeliverySchedule,
MtaOutboundStrategy, MtaTlsStrategy, MtaVirtualQueue,
},
},
types::list::List,
};
use std::time::{Duration, Instant};
use store::write::now;
#[tokio::test]
#[serial_test::serial]
async fn starttls_optional() {
let mut local = TestServerBuilder::new("smtp_starttls_local")
.await
.with_http_listener(19034)
.await
.disable_services()
.capture_queue()
.build()
.await;
let mut remote = TestServerBuilder::new("smtp_starttls_remote")
.await
.with_http_listener(19035)
.await
.with_smtp_listener(9925)
.await
.disable_services()
.capture_queue()
.build()
.await;
let local_admin = local.account("admin");
local_admin.mta_no_auth().await;
local_admin.mta_allow_relaying().await;
local_admin
.registry_create_object(MtaOutboundStrategy {
tls: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "retry_num > 0 && last_error == 'tls'".into(),
then: "'no-tls'".into(),
}]),
else_: "'default'".into(),
},
connection: Expression {
else_: "'badtls'".into(),
..Default::default()
},
schedule: Expression {
else_: "'default'".into(),
..Default::default()
},
..Default::default()
})
.await;
let queue_id = local_admin
.registry_create_object(MtaVirtualQueue {
name: "default".into(),
threads_per_node: 25,
description: None,
})
.await;
local_admin
.registry_create_object(MtaDeliverySchedule {
name: "default".into(),
queue_id,
..Default::default()
})
.await;
local_admin
.registry_create_object(MtaConnectionStrategy {
name: "badtls".into(),
ehlo_hostname: "badtls.foobar.org".to_string().into(),
..Default::default()
})
.await;
local_admin
.registry_create_object(MtaTlsStrategy {
name: "no-tls".into(),
allow_invalid_certs: true,
start_tls: MtaRequiredOrOptional::Disable,
..Default::default()
})
.await;
local_admin.reload_settings().await;
local.reload_core();
local.expect_reload_settings().await;
let remote_admin = remote.account("admin");
remote_admin.mta_no_auth().await;
remote_admin.mta_allow_relaying().await;
remote_admin.mta_allow_non_fqdn().await;
remote_admin.mta_all_extensions().await;
remote_admin.reload_settings().await;
remote.reload_core();
remote.expect_reload_settings().await;
// Add mock DNS entries
local.server.mx_add(
"foobar.org",
vec![MX {
exchanges: vec!["mx.foobar.org".into()].into_boxed_slice(),
preference: 10,
}],
DnssecStatus::Secure,
Instant::now() + Duration::from_secs(10),
);
local.server.ipv4_add(
"mx.foobar.org",
vec!["127.0.0.1".parse().unwrap()],
Instant::now() + Duration::from_secs(10),
);
let mut session = local.new_mta_session();
session.data.remote_ip_str = "10.0.0.1".into();
session.eval_session_params().await;
session.ehlo("mx.test.org").await;
session
.send_message("[email protected]", &["[email protected]"], "test:no_dkim", "250")
.await;
local
.expect_message_for_queue_then_deliver("default")
.await
.try_deliver(local.server.clone());
let mut retry = local.expect_message().await;
let prev_due = retry.message.recipients[0].retry.due;
let next_due = now();
let queue_id = retry.queue_id;
retry.message.recipients[0].retry.due = next_due;
retry.save_changes(&local.server, prev_due.into()).await;
local
.delivery_attempt_for_queue(queue_id, "default")
.await
.try_deliver(local.server.clone());
tokio::time::sleep(Duration::from_millis(100)).await;
remote
.expect_message()
.await
.read_lines(&remote)
.await
.assert_not_contains("using TLSv1.3 with cipher");
}
+217
View File
@@ -0,0 +1,217 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
smtp::session::TestSession,
utils::{dns::DnsCache, server::TestServerBuilder},
};
use common::{BuildServer, ipc::QueueEvent};
use mail_auth::{DnssecStatus, MX};
use registry::{
schema::{
enums::NetworkListenerProtocol,
prelude::ObjectType,
structs::{
Expression, MtaDeliveryExpiration, MtaDeliveryExpirationTtl, MtaDeliverySchedule,
MtaDeliveryScheduleInterval, MtaDeliveryScheduleIntervals,
MtaDeliveryScheduleIntervalsOrDefault, MtaOutboundStrategy, MtaStageData,
MtaVirtualQueue,
},
},
types::list::List,
};
use smtp::queue::manager::Queue;
use std::time::{Duration, Instant};
const NUM_MESSAGES: usize = 100;
const NUM_QUEUES: usize = 10;
#[tokio::test(flavor = "multi_thread", worker_threads = 18)]
#[serial_test::serial]
async fn concurrent_queue() {
let mut local = TestServerBuilder::new("smtp_concurrent_queue_local")
.await
.with_http_listener(19037)
.await
.disable_services()
.build()
.await;
let mut remote = TestServerBuilder::new("smtp_concurrent_queue_remote")
.await
.with_http_listener(19038)
.await
.with_listener(NetworkListenerProtocol::Smtp, "smtp-debug", 9925, false)
.await
.disable_services()
.capture_queue()
.build()
.await;
let local_admin = local.account("admin");
local_admin
.registry_create_object(MtaStageData {
max_messages: Expression {
else_: "2000".into(),
..Default::default()
},
..Default::default()
})
.await;
local_admin
.registry_create_object(MtaOutboundStrategy {
schedule: Expression {
else_: "'default'".into(),
..Default::default()
},
..Default::default()
})
.await;
let queue_id = local_admin
.registry_create_object(MtaVirtualQueue {
name: "default".into(),
threads_per_node: 4,
description: None,
})
.await;
local_admin
.registry_create_object(MtaDeliverySchedule {
name: "default".into(),
retry: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
intervals: List::from_iter([MtaDeliveryScheduleInterval {
duration: 1_000u64.into(),
}]),
}),
notify: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
intervals: List::from_iter([MtaDeliveryScheduleInterval {
duration: 86_400_000u64.into(),
}]),
}),
expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl {
expire: 86_400_000u64.into(),
}),
queue_id,
description: None,
})
.await;
local_admin.mta_allow_relaying().await;
local_admin.mta_disable_spam_filter().await;
local_admin.mta_allow_non_fqdn().await;
local_admin.mta_no_auth().await;
local_admin
.registry_destroy_all(ObjectType::MtaInboundThrottle)
.await;
local_admin.reload_settings().await;
local.reload_core();
let remote_admin = remote.account("admin");
remote_admin.mta_allow_relaying().await;
remote_admin.mta_disable_spam_filter().await;
remote_admin.mta_allow_non_fqdn().await;
remote_admin.mta_no_auth().await;
remote_admin
.registry_destroy_all(ObjectType::MtaInboundThrottle)
.await;
remote_admin.reload_settings().await;
remote.reload_core();
remote.expect_reload_settings().await;
// Add mock DNS entries
local.server.mx_add(
"foobar.org",
vec![MX {
exchanges: vec!["mx.foobar.org".into()].into_boxed_slice(),
preference: 10,
}],
DnssecStatus::Secure,
Instant::now() + Duration::from_secs(100),
);
local.server.ipv4_add(
"mx.foobar.org",
vec!["127.0.0.1".parse().unwrap()],
Instant::now() + Duration::from_secs(100),
);
let mut session = local.new_mta_session();
session.data.remote_ip_str = "10.0.0.1".into();
session.eval_session_params().await;
session.ehlo("mx.test.org").await;
// Spawn concurrent queues
let mut inners = vec![];
for _ in 0..NUM_QUEUES {
let (inner, rxs) = local.inner_with_rxs().await;
let server = inner.build_server();
server.mx_add(
"foobar.org",
vec![MX {
exchanges: vec!["mx.foobar.org".into()].into_boxed_slice(),
preference: 10,
}],
DnssecStatus::Secure,
Instant::now() + Duration::from_secs(100),
);
server.ipv4_add(
"mx.foobar.org",
vec!["127.0.0.1".parse().unwrap()],
Instant::now() + Duration::from_secs(100),
);
inners.push(inner.clone());
tokio::spawn(async move {
Queue::new(inner, rxs.queue_rx.unwrap()).start().await;
});
}
tokio::time::sleep(Duration::from_millis(200)).await;
// Send 1000 test messages
for _ in 0..(NUM_MESSAGES / 2) {
session
.send_message("[email protected]", &["[email protected]"], "test:no_dkim", "250")
.await;
}
// Wake up all queues
for inner in &inners {
inner.ipc.queue_tx.send(QueueEvent::Refresh).await.unwrap();
}
for _ in 0..(NUM_MESSAGES / 2) {
session
.send_message(
"[email protected]",
&["[email protected]"],
"test:no_dkim",
"250",
)
.await;
}
loop {
tokio::time::sleep(Duration::from_millis(1500)).await;
let m = local.read_queued_messages().await.len();
let e = local.read_queued_events().await.len();
if m + e != 0 {
println!("Queue still has {} messages and {} events", m, e);
/*for inner in &inners {
inner.ipc.queue_tx.send(QueueEvent::Refresh).await.unwrap();
}*/
} else {
break;
}
}
local.assert_queue_is_empty().await;
let remote_messages = remote.read_queued_messages().await;
assert_eq!(remote_messages.len(), NUM_MESSAGES);
// Make sure local store is queue
local
.account("admin")
.registry_destroy_all(ObjectType::MtaConnectionStrategy)
.await;
local.assert_is_empty().await;
}
+272
View File
@@ -0,0 +1,272 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::utils::server::{TestServer, TestServerBuilder};
use common::config::smtp::queue::{QueueExpiry, QueueName};
use registry::schema::{
enums::CompressionAlgo,
structs::{DsnReportSettings, Expression, ReportSettings},
};
use smtp::queue::{
Error, ErrorDetails, HostResponse, Message, MessageWrapper, Recipient, Schedule, Status,
UnexpectedResponse, dsn::SendDsn,
};
use smtp_proto::{RCPT_NOTIFY_DELAY, RCPT_NOTIFY_FAILURE, RCPT_NOTIFY_SUCCESS, Response};
use std::{
fs,
net::{IpAddr, Ipv4Addr},
path::PathBuf,
time::SystemTime,
};
use store::write::now;
use types::blob_hash::BlobHash;
#[tokio::test]
async fn generate_dsn() {
let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
path.push("resources");
path.push("smtp");
path.push("dsn");
path.push("original.txt");
let size = fs::metadata(&path).unwrap().len() as u64;
let dsn_original = fs::read_to_string(&path).unwrap();
let flags = RCPT_NOTIFY_FAILURE | RCPT_NOTIFY_DELAY | RCPT_NOTIFY_SUCCESS;
let mut message = MessageWrapper {
queue_id: 0,
span_id: 0,
is_multi_queue: false,
queue_name: QueueName::default(),
message: Message {
size,
created: SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_secs()),
return_path: "[email protected]".into(),
recipients: vec![Recipient {
address: "[email protected]".into(),
status: Status::PermanentFailure(ErrorDetails {
entity: "mx.example.org".into(),
details: Error::UnexpectedResponse(UnexpectedResponse {
command: "RCPT TO:<[email protected]>".into(),
response: Response {
code: 550,
esc: [5, 1, 2],
message: "User does not exist".into(),
},
}),
}),
flags: 0,
orcpt: None,
retry: Schedule::now(),
notify: Schedule::now(),
expires: QueueExpiry::Ttl(10),
queue: QueueName::default(),
}],
flags: 0,
env_id: None,
priority: 0,
blob_hash: BlobHash::generate(dsn_original.as_bytes()),
metadata: Default::default(),
received_from_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
received_via_port: 0,
},
};
let mut local = TestServerBuilder::new("smtp_queue_dsn")
.await
.with_http_listener(19039)
.await
.disable_services()
.capture_queue()
.build()
.await;
let local_admin = local.account("admin");
local_admin
.registry_create_object(ReportSettings {
outbound_report_submitter: Expression {
else_: "'mx.example.org'".into(),
..Default::default()
},
..Default::default()
})
.await;
local_admin
.registry_create_object(DsnReportSettings {
dkim_sign_domain: Expression {
else_: "'example.org'".into(),
..Default::default()
},
from_address: Expression {
else_: "'[email protected]'".into(),
..Default::default()
},
from_name: Expression {
else_: "'Mail Delivery Subsystem'".into(),
..Default::default()
},
})
.await;
let domain_id = local_admin.find_or_create_domain("example.org").await;
local_admin.create_dkim_signatures(domain_id).await;
local_admin.mta_allow_non_fqdn().await;
local_admin.mta_allow_relaying().await;
local_admin.reload_settings().await;
local_admin.mta_allow_relaying().await;
local.reload_core();
local.expect_reload_settings().await;
// Create temp dir for queue
local
.server
.blob_store()
.put_blob(
message.message.blob_hash.as_slice(),
dsn_original.as_bytes(),
CompressionAlgo::Lz4,
)
.await
.unwrap();
// Disabled DSN
local.server.send_dsn(&mut message).await;
local.assert_no_events();
local.assert_queue_is_empty().await;
// Failure DSN
message.message.recipients[0].flags = flags;
local.server.send_dsn(&mut message).await;
let dsn_message = local.expect_message().await;
local.compare_dsn(dsn_message.message, "failure.eml").await;
// Success DSN
message.message.recipients.push(Recipient {
address: "[email protected]".into(),
status: Status::Completed(HostResponse {
hostname: "mx2.example.org".into(),
response: Response {
code: 250,
esc: [2, 1, 5],
message: "Message accepted for delivery".into(),
},
}),
flags,
orcpt: None,
retry: Schedule::now(),
notify: Schedule::now(),
expires: QueueExpiry::Ttl(10),
queue: QueueName::default(),
});
local.server.send_dsn(&mut message).await;
let dsn_message = local.expect_message().await;
local.compare_dsn(dsn_message.message, "success.eml").await;
// Delay DSN
message.message.recipients.push(Recipient {
address: "[email protected]".into(),
status: Status::TemporaryFailure(ErrorDetails {
entity: "mx.domain.org".into(),
details: Error::ConnectionError("Connection timeout".into()),
}),
flags,
orcpt: Some("[email protected]".into()),
retry: Schedule::now(),
notify: Schedule::now(),
expires: QueueExpiry::Ttl(10),
queue: QueueName::default(),
});
local.server.send_dsn(&mut message).await;
let dsn_message = local.expect_message().await;
local.compare_dsn(dsn_message.message, "delay.eml").await;
// Mixed DSN
for rcpt in &mut message.message.recipients {
rcpt.flags = flags;
}
message.message.recipients.last_mut().unwrap().notify.due = now();
local.server.send_dsn(&mut message).await;
let dsn_message = local.expect_message().await;
local.compare_dsn(dsn_message.message, "mixed.eml").await;
// Load queue
let queue = local.read_queued_messages().await;
assert_eq!(queue.len(), 4);
}
impl TestServer {
async fn compare_dsn(&self, message: Message, test: &str) {
let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
path.push("resources");
path.push("smtp");
path.push("dsn");
path.push(test);
let bytes = self
.server
.blob_store()
.get_blob(message.blob_hash.as_slice(), 0..usize::MAX)
.await
.unwrap()
.unwrap();
let dsn = remove_ids(bytes);
let dsn_expected = fs::read_to_string(&path).unwrap();
if dsn != dsn_expected {
let mut failed = PathBuf::from(&path);
failed.set_extension("failed");
fs::write(&failed, dsn.as_bytes()).unwrap();
panic!(
"Failed for {}, output saved to {}",
path.display(),
failed.display()
);
}
}
}
fn remove_ids(message: Vec<u8>) -> String {
let old_message = String::from_utf8(message).unwrap();
let mut message = String::with_capacity(old_message.len());
let mut found_dkim = 0;
let mut skip = false;
let mut boundary = "";
for line in old_message.split("\r\n") {
if skip {
if line.chars().next().unwrap().is_ascii_whitespace() {
continue;
} else {
skip = false;
}
}
if line.starts_with("Date:") || line.starts_with("Message-ID:") {
continue;
} else if found_dkim < 2 && line.starts_with("DKIM-Signature:") {
found_dkim += 1;
skip = true;
continue;
} else if line.starts_with("--") {
message.push_str(&line.replace(boundary, "mime_boundary"));
} else if let Some((_, boundary_)) = line.split_once("boundary=\"") {
boundary = boundary_.split_once('"').unwrap().0;
message.push_str(&line.replace(boundary, "mime_boundary"));
} else if line.starts_with("Arrival-Date:") {
message.push_str("Arrival-Date: <date goes here>");
} else if line.starts_with("Will-Retry-Until:") {
message.push_str("Will-Retry-Until: <date goes here>");
} else {
message.push_str(line);
}
message.push_str("\r\n");
}
if found_dkim == 0 {
panic!("No DKIM signature found in: {old_message}");
}
message
}
+209
View File
@@ -0,0 +1,209 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
smtp::queue::{build_rcpt, new_message},
utils::server::TestServerBuilder,
};
use common::config::smtp::queue::QueueName;
use smtp::queue::{Error, ErrorDetails, Message, Recipient, Status, spool::SmtpSpool};
use std::time::Duration;
use store::write::now;
#[tokio::test]
async fn queue_due() {
let mut local = TestServerBuilder::new("smtp_queue_manager")
.await
.with_http_listener(19040)
.await
.disable_services()
.capture_queue()
.build()
.await;
let local_admin = local.account("admin");
local_admin.mta_allow_relaying().await;
local_admin.mta_allow_non_fqdn().await;
local_admin.reload_settings().await;
local.reload_core();
local.expect_reload_settings().await;
let mut message = new_message(0);
message.message.recipients.push(build_rcpt("c", 3, 8, 9));
message.save_changes(&local.server, 0.into()).await;
let mut message = new_message(1);
message.message.recipients.push(build_rcpt("b", 2, 6, 7));
message.save_changes(&local.server, 0.into()).await;
let mut message = new_message(2);
message.message.recipients.push(build_rcpt("a", 1, 4, 5));
message.save_changes(&local.server, 0.into()).await;
for domain in vec!["a", "b", "c"].into_iter() {
let now = now();
let queued = local.all_queued_messages().await;
if queued.messages.is_empty() {
let wake_up = queued.next_refresh - now;
assert_eq!(wake_up, 1);
std::thread::sleep(Duration::from_secs(wake_up));
}
for queue_event in local.all_queued_messages().await.messages {
if let Some(message) = local
.server
.read_message(queue_event.queue_id, QueueName::default())
.await
{
message.message.rcpt(domain);
message.remove(&local.server, queue_event.due.into()).await;
} else {
panic!("Message not found");
}
}
}
local.assert_queue_is_empty().await;
}
#[test]
fn delivery_events() {
let mut message = new_message(0).message;
message.created = now();
message.recipients.push(build_rcpt("a", 1, 2, 3));
message.recipients.push(build_rcpt("b", 4, 5, 6));
message.recipients.push(build_rcpt("c", 7, 8, 9));
for t in 0..2 {
assert_eq!(
message.next_event(None).unwrap(),
message.rcpt("a").retry.due
);
assert_eq!(
message.next_delivery_event(None).unwrap(),
message.rcpt("a").retry.due
);
assert_eq!(
next_event_after(
&message,
None,
message.rcpt("a").expiration_time(message.created).unwrap()
)
.unwrap(),
message.rcpt("b").retry.due
);
assert_eq!(
next_event_after(
&message,
None,
message.rcpt("b").expiration_time(message.created).unwrap()
)
.unwrap(),
message.rcpt("c").retry.due
);
assert_eq!(
next_event_after(&message, None, message.rcpt("c").notify.due).unwrap(),
message.rcpt("c").expiration_time(message.created).unwrap()
);
assert!(
next_event_after(
&message,
None,
message.rcpt("c").expiration_time(message.created).unwrap()
)
.is_none()
);
if t == 0 {
message.recipients.reverse();
} else {
message.recipients.swap(0, 1);
}
}
message.rcpt_mut("a").status = Status::PermanentFailure(ErrorDetails {
entity: "localhost".into(),
details: Error::ConcurrencyLimited,
});
assert_eq!(
message.next_event(None).unwrap(),
message.rcpt("b").retry.due
);
assert_eq!(
message.next_delivery_event(None).unwrap(),
message.rcpt("b").retry.due
);
message.rcpt_mut("b").status = Status::PermanentFailure(ErrorDetails {
entity: "localhost".into(),
details: Error::ConcurrencyLimited,
});
assert_eq!(
message.next_event(None).unwrap(),
message.rcpt("c").retry.due
);
assert_eq!(
message.next_delivery_event(None).unwrap(),
message.rcpt("c").retry.due
);
message.rcpt_mut("c").status = Status::PermanentFailure(ErrorDetails {
entity: "localhost".into(),
details: Error::ConcurrencyLimited,
});
assert!(message.next_event(None).is_none());
}
fn next_event_after(message: &Message, queue: Option<QueueName>, instant: u64) -> Option<u64> {
let mut next_event = None;
for rcpt in &message.recipients {
if matches!(rcpt.status, Status::Scheduled | Status::TemporaryFailure(_))
&& queue.is_none_or(|q| rcpt.queue == q)
{
if rcpt.retry.due > instant
&& next_event.as_ref().is_none_or(|ne| rcpt.retry.due.lt(ne))
{
next_event = rcpt.retry.due.into();
}
if rcpt.notify.due > instant
&& next_event.as_ref().is_none_or(|ne| rcpt.notify.due.lt(ne))
{
next_event = rcpt.notify.due.into();
}
if let Some(expires) = rcpt.expiration_time(message.created)
&& expires > instant
&& next_event.as_ref().is_none_or(|ne| expires.lt(ne))
{
next_event = expires.into();
}
}
}
next_event
}
pub trait TestMessage {
fn rcpt(&self, name: &str) -> &Recipient;
fn rcpt_mut(&mut self, name: &str) -> &mut Recipient;
}
impl TestMessage for Message {
fn rcpt(&self, name: &str) -> &Recipient {
self.recipients
.iter()
.find(|d| d.address() == name)
.unwrap_or_else(|| panic!("Expected rcpt {name} not found in {:?}", self.recipients))
}
fn rcpt_mut(&mut self, name: &str) -> &mut Recipient {
self.recipients
.iter_mut()
.find(|d| d.address() == name)
.unwrap()
}
}
+51
View File
@@ -0,0 +1,51 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use common::config::smtp::queue::{QueueExpiry, QueueName};
use smtp::queue::{Message, MessageWrapper, Recipient, Schedule, Status};
use std::net::{IpAddr, Ipv4Addr};
use store::write::now;
pub mod concurrent;
pub mod dsn;
pub mod manager;
pub mod retry;
pub mod virtualq;
pub fn build_rcpt(address: &str, retry: u64, notify: u64, expires: u64) -> Recipient {
Recipient {
address: address.into(),
retry: Schedule::later(retry),
notify: Schedule::later(notify),
expires: QueueExpiry::Ttl(expires),
status: Status::Scheduled,
flags: 0,
orcpt: None,
queue: QueueName::default(),
}
}
pub fn new_message(queue_id: u64) -> MessageWrapper {
MessageWrapper {
queue_id,
span_id: 0,
queue_name: QueueName::default(),
is_multi_queue: false,
message: Message {
size: 0,
created: now(),
return_path: "[email protected]".into(),
recipients: vec![],
flags: 0,
env_id: None,
priority: 0,
metadata: Default::default(),
blob_hash: Default::default(),
received_from_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
received_via_port: 0,
},
}
}
+321
View File
@@ -0,0 +1,321 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
smtp::{
inbound::{TestMessage, TestQueueEvent},
session::{TestSession, VerifyResponse},
},
utils::server::TestServerBuilder,
};
use ahash::AHashSet;
use common::{
config::smtp::queue::QueueName,
ipc::{QueueEvent, QueueEventStatus},
};
use registry::{
schema::structs::{
Expression, ExpressionMatch, MtaDeliveryExpiration, MtaDeliveryExpirationTtl,
MtaDeliverySchedule, MtaDeliveryScheduleInterval, MtaDeliveryScheduleIntervals,
MtaDeliveryScheduleIntervalsOrDefault, MtaExtensions, MtaOutboundStrategy, MtaVirtualQueue,
},
types::list::List,
};
use smtp::queue::spool::{QUEUE_REFRESH, SmtpSpool};
use std::time::Duration;
use store::write::now;
#[tokio::test]
async fn queue_retry() {
let mut local = TestServerBuilder::new("smtp_queue_retry")
.await
.with_http_listener(19041)
.await
.disable_services()
.capture_queue()
.build()
.await;
let local_admin = local.account("admin");
local_admin.mta_allow_relaying().await;
local_admin.mta_allow_non_fqdn().await;
local_admin.mta_no_auth().await;
local_admin
.registry_create_object(MtaOutboundStrategy {
schedule: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "sender_domain == 'test.org'".into(),
then: "'sender-test'".into(),
}]),
else_: "'sender-default'".into(),
},
..Default::default()
})
.await;
local_admin
.registry_create_object(MtaExtensions {
deliver_by: Expression {
else_: "1h".into(),
..Default::default()
},
future_release: Expression {
else_: "1h".into(),
..Default::default()
},
..Default::default()
})
.await;
let queue_id = local_admin
.registry_create_object(MtaVirtualQueue {
name: "default".into(),
threads_per_node: 25,
description: None,
})
.await;
local_admin
.registry_create_object(MtaDeliverySchedule {
name: "sender-default".into(),
retry: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
intervals: List::from_iter([
MtaDeliveryScheduleInterval {
duration: 1_000u64.into(),
},
MtaDeliveryScheduleInterval {
duration: 2_000u64.into(),
},
MtaDeliveryScheduleInterval {
duration: 3_000u64.into(),
},
]),
}),
notify: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
intervals: List::from_iter([MtaDeliveryScheduleInterval {
duration: (15 * 60 * 60 * 1000u64).into(),
}]),
}),
expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl {
expire: 86_400_000u64.into(),
}),
queue_id,
description: None,
})
.await;
local_admin
.registry_create_object(MtaDeliverySchedule {
name: "sender-test".into(),
retry: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
intervals: List::from_iter([
MtaDeliveryScheduleInterval {
duration: 1_000u64.into(),
},
MtaDeliveryScheduleInterval {
duration: 2_000u64.into(),
},
MtaDeliveryScheduleInterval {
duration: 3_000u64.into(),
},
]),
}),
notify: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
intervals: List::from_iter([
MtaDeliveryScheduleInterval {
duration: 1_000u64.into(),
},
MtaDeliveryScheduleInterval {
duration: 2_000u64.into(),
},
]),
}),
expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl {
expire: 6_000u64.into(),
}),
queue_id,
description: None,
})
.await;
local_admin.reload_settings().await;
local.reload_core();
local.expect_reload_settings().await;
let mut session = local.new_mta_session();
session.data.remote_ip_str = "10.0.0.1".into();
session.eval_session_params().await;
session.ehlo("mx.test.org").await;
session
.send_message("[email protected]", &["[email protected]"], "test:no_dkim", "250")
.await;
let attempt = local.expect_message_for_queue_then_deliver("default").await;
// Expect a failed DSN
attempt.try_deliver(local.server.clone());
let message = local.expect_message().await;
assert_eq!(message.message.return_path.as_ref(), "");
assert_eq!(
message.message.recipients.first().unwrap().address(),
"[email protected]"
);
message
.read_lines(&local)
.await
.assert_contains("Content-Type: multipart/report")
.assert_contains("Final-Recipient: rfc822;[email protected]")
.assert_contains("Action: failed");
local.read_event().await.assert_done();
local.clear_queue().await;
// Expect a failed DSN for foobar.org, followed by two delayed DSN and
// a final failed DSN for _dns_error.org.
session
.send_message(
"[email protected]",
&["[email protected]", "jane@_dns_error.org"],
"test:no_dkim",
"250",
)
.await;
let mut in_fight = AHashSet::new();
let attempt = local.expect_message_for_queue_then_deliver("default").await;
let mut dsn = Vec::new();
let mut retries = Vec::new();
in_fight.insert(attempt.queue_id);
attempt.try_deliver(local.server.clone());
loop {
match local.try_read_event().await {
Some(QueueEvent::WorkerDone {
queue_id, status, ..
}) => {
in_fight.remove(&queue_id);
match &status {
QueueEventStatus::Completed | QueueEventStatus::Deferred => (),
_ => panic!("unexpected status {queue_id}: {status:?}"),
}
}
Some(QueueEvent::Refresh) | Some(QueueEvent::ReloadSettings) => (),
None | Some(QueueEvent::Stop) | Some(QueueEvent::Paused(_)) => break,
}
let now = now();
let mut events = local.all_queued_messages().await;
if events.messages.is_empty() {
if events.next_refresh < now + QUEUE_REFRESH {
tokio::time::sleep(Duration::from_secs(events.next_refresh - now)).await;
events = local.all_queued_messages().await;
} else if in_fight.is_empty() {
break;
}
}
for event in events.messages {
if in_fight.contains(&event.queue_id) {
continue;
}
let message = local
.server
.read_message(event.queue_id, QueueName::default())
.await
.unwrap();
if message.message.return_path.is_empty() {
message
.clone()
.remove(&local.server, event.due.into())
.await;
dsn.push(message);
} else {
retries.push(event.due.saturating_sub(now));
in_fight.insert(event.queue_id);
event.try_deliver(local.server.clone());
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
}
local.assert_queue_is_empty().await;
assert_eq!(retries, vec![1, 2, 3]);
assert_eq!(dsn.len(), 4);
let mut dsn = dsn.into_iter();
dsn.next()
.unwrap()
.read_lines(&local)
.await
.assert_contains("<[email protected]> (failed to lookup 'foobar.org'")
.assert_contains("Final-Recipient: rfc822;[email protected]")
.assert_contains("Action: failed");
dsn.next()
.unwrap()
.read_lines(&local)
.await
.assert_contains("<jane@_dns_error.org> (failed to lookup '_dns_error.org'")
.assert_contains("Final-Recipient: rfc822;jane@_dns_error.org")
.assert_contains("Action: delayed");
dsn.next()
.unwrap()
.read_lines(&local)
.await
.assert_contains("<jane@_dns_error.org> (failed to lookup '_dns_error.org'")
.assert_contains("Final-Recipient: rfc822;jane@_dns_error.org")
.assert_contains("Action: delayed");
dsn.next()
.unwrap()
.read_lines(&local)
.await
.assert_contains("<jane@_dns_error.org> (failed to lookup '_dns_error.org'")
.assert_contains("Final-Recipient: rfc822;jane@_dns_error.org")
.assert_contains("Action: failed");
// Test FUTURERELEASE + DELIVERBY (RETURN)
session.data.remote_ip_str = "10.0.0.2".into();
session.eval_session_params().await;
session
.send_message(
"<[email protected]> HOLDFOR=60 BY=3600;R",
&["[email protected]"],
"test:no_dkim",
"250",
)
.await;
let now_ = now();
let message = local.expect_message().await;
assert!([59, 60].contains(&(local.message_due(message.queue_id).await - now_)));
assert!([59, 60].contains(&(message.message.next_delivery_event(None).unwrap() - now_)));
assert!(
[3599, 3600].contains(
&(message
.message
.recipients
.first()
.unwrap()
.expiration_time(message.message.created)
.unwrap()
- now_)
)
);
assert!(
[54059, 54060].contains(&(message.message.recipients.first().unwrap().notify.due - now_)),
"diff: {}",
message.message.recipients.first().unwrap().notify.due - now_
);
// Test DELIVERBY (NOTIFY)
session
.send_message(
"<[email protected]> BY=3600;N",
&["[email protected]"],
"test:no_dkim",
"250",
)
.await;
let schedule = local.expect_message().await;
assert!(
[3599, 3600].contains(&(schedule.message.recipients.first().unwrap().notify.due - now())),
"diff: {}",
schedule.message.recipients.first().unwrap().notify.due - now()
);
}
+286
View File
@@ -0,0 +1,286 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
smtp::session::TestSession,
utils::{dns::DnsCache, server::TestServerBuilder},
};
use common::{BuildServer, config::smtp::queue::QueueName, ipc::QueueEvent};
use mail_auth::{DnssecStatus, MX};
use registry::{
schema::{
enums::NetworkListenerProtocol,
prelude::ObjectType,
structs::{
Expression, ExpressionMatch, MtaDeliveryExpiration, MtaDeliveryExpirationTtl,
MtaDeliverySchedule, MtaDeliveryScheduleInterval, MtaDeliveryScheduleIntervals,
MtaDeliveryScheduleIntervalsOrDefault, MtaOutboundStrategy, MtaStageData,
MtaVirtualQueue,
},
},
types::list::List,
};
use smtp::queue::manager::Queue;
use std::time::{Duration, Instant};
const NUM_MESSAGES: usize = 100;
const NUM_QUEUES: usize = 10;
#[tokio::test(flavor = "multi_thread", worker_threads = 18)]
#[serial_test::serial]
async fn virtual_queue() {
let mut local = TestServerBuilder::new("smtp_virtual_queue_local")
.await
.with_http_listener(19042)
.await
.disable_services()
.build()
.await;
let mut remote = TestServerBuilder::new("smtp_virtual_queue_remote")
.await
.with_http_listener(19043)
.await
.with_listener(NetworkListenerProtocol::Smtp, "smtp-debug", 9925, false)
.await
.disable_services()
.capture_queue()
.build()
.await;
let local_admin = local.account("admin");
local_admin
.registry_create_object(MtaOutboundStrategy {
schedule: Expression {
match_: List::from_iter([ExpressionMatch {
if_: "rcpt == '[email protected]'".into(),
then: "'q2'".into(),
}]),
else_: "'q1'".into(),
},
..Default::default()
})
.await;
local_admin
.registry_create_object(MtaStageData {
max_messages: Expression {
else_: "2000".into(),
..Default::default()
},
..Default::default()
})
.await;
let queue1_id = local_admin
.registry_create_object(MtaVirtualQueue {
name: "q1".into(),
threads_per_node: 5,
description: None,
})
.await;
let queue2_id = local_admin
.registry_create_object(MtaVirtualQueue {
name: "q2".into(),
threads_per_node: 4,
description: None,
})
.await;
local_admin
.registry_create_object(MtaDeliverySchedule {
name: "q1".into(),
retry: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
intervals: List::from_iter([MtaDeliveryScheduleInterval {
duration: 1_000u64.into(),
}]),
}),
notify: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
intervals: List::from_iter([MtaDeliveryScheduleInterval {
duration: 86_400_000u64.into(),
}]),
}),
expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl {
expire: 86_400_000u64.into(),
}),
queue_id: queue1_id,
description: None,
})
.await;
local_admin
.registry_create_object(MtaDeliverySchedule {
name: "q2".into(),
retry: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
intervals: List::from_iter([MtaDeliveryScheduleInterval {
duration: 1_000u64.into(),
}]),
}),
notify: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
intervals: List::from_iter([MtaDeliveryScheduleInterval {
duration: 86_400_000u64.into(),
}]),
}),
expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl {
expire: 86_400_000u64.into(),
}),
queue_id: queue2_id,
description: None,
})
.await;
local_admin.mta_allow_relaying().await;
local_admin.mta_disable_spam_filter().await;
local_admin.mta_allow_non_fqdn().await;
local_admin.mta_no_auth().await;
local_admin
.registry_destroy_all(ObjectType::MtaInboundThrottle)
.await;
local_admin.reload_settings().await;
local.reload_core();
let remote_admin = remote.account("admin");
remote_admin.mta_allow_relaying().await;
remote_admin.mta_disable_spam_filter().await;
remote_admin.mta_allow_non_fqdn().await;
remote_admin.mta_no_auth().await;
remote_admin
.registry_destroy_all(ObjectType::MtaInboundThrottle)
.await;
remote_admin.reload_settings().await;
remote.reload_core();
remote.expect_reload_settings().await;
// Validate parsing
for value in ["a", "ab", "abcdefgh"] {
let queue_name = QueueName::new(value).unwrap();
assert_eq!(queue_name.to_string(), value);
}
assert_eq!(
local
.server
.core
.smtp
.queue
.virtual_queues
.get(&QueueName::new("q1").unwrap())
.unwrap()
.threads,
5
);
assert_eq!(
local
.server
.core
.smtp
.queue
.virtual_queues
.get(&QueueName::new("q2").unwrap())
.unwrap()
.threads,
4
);
// Add mock DNS entries
local.server.mx_add(
"foobar.org",
vec![MX {
exchanges: vec!["mx.foobar.org".into()].into_boxed_slice(),
preference: 10,
}],
DnssecStatus::Secure,
Instant::now() + Duration::from_secs(100),
);
local.server.ipv4_add(
"mx.foobar.org",
vec!["127.0.0.1".parse().unwrap()],
Instant::now() + Duration::from_secs(100),
);
let mut session = local.new_mta_session();
session.data.remote_ip_str = "10.0.0.1".into();
session.eval_session_params().await;
session.ehlo("mx.test.org").await;
// Spawn concurrent queues
let mut inners = vec![];
for _ in 0..NUM_QUEUES {
let (inner, rxs) = local.inner_with_rxs().await;
let server = inner.build_server();
server.mx_add(
"foobar.org",
vec![MX {
exchanges: vec!["mx.foobar.org".into()].into_boxed_slice(),
preference: 10,
}],
DnssecStatus::Secure,
Instant::now() + Duration::from_secs(100),
);
server.ipv4_add(
"mx.foobar.org",
vec!["127.0.0.1".parse().unwrap()],
Instant::now() + Duration::from_secs(100),
);
inners.push(inner.clone());
tokio::spawn(async move {
Queue::new(inner, rxs.queue_rx.unwrap()).start().await;
});
}
tokio::time::sleep(Duration::from_millis(200)).await;
// Send 1000 test messages
for _ in 0..(NUM_MESSAGES / 2) {
session
.send_message(
"[email protected]",
&["[email protected]", "[email protected]"],
"test:no_dkim",
"250",
)
.await;
}
// Wake up all queues
for inner in &inners {
inner.ipc.queue_tx.send(QueueEvent::Refresh).await.unwrap();
}
for _ in 0..(NUM_MESSAGES / 2) {
session
.send_message(
"[email protected]",
&["[email protected]", "[email protected]"],
"test:no_dkim",
"250",
)
.await;
}
loop {
tokio::time::sleep(Duration::from_millis(1500)).await;
let m = local.read_queued_messages().await;
let e = local.read_queued_events().await;
if m.len() + e.len() != 0 {
println!(
"Queue still has {} messages and {} events",
m.len(),
e.len()
);
/*for inner in &inners {
inner.ipc.queue_tx.send(QueueEvent::Refresh).await.unwrap();
}*/
} else {
break;
}
}
local.assert_queue_is_empty().await;
let remote_messages = remote.read_queued_messages().await;
assert_eq!(remote_messages.len(), NUM_MESSAGES * 2);
// Make sure local store is queue
local
.account("admin")
.registry_destroy_all(ObjectType::MtaConnectionStrategy)
.await;
local.assert_is_empty().await;
}
+376
View File
@@ -0,0 +1,376 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{smtp::session::TestSession, utils::server::TestServerBuilder};
use ahash::AHashMap;
use flate2::{Compression, Crc, write::GzEncoder};
use mail_builder::{
MessageBuilder,
mime::{BodyPart, MimePart},
};
use registry::{
schema::{
enums::TaskStoreMaintenanceType,
structs::{
ArfExternalReport, DataRetention, DmarcExternalReport, Expression, MtaStageData,
ReportSettings, Task, TaskStatus, TaskStoreMaintenance, TlsExternalReport,
},
},
types::map::Map,
};
use std::{io::Write, time::Duration};
const MAX_REPORT_SIZE: i64 = 65536;
const DMARC_REPORT: &str = concat!(
r#"<?xml version="1.0" encoding="UTF-8"?><feedback><report_metadata>"#,
r#"<org_name>Example</org_name><email>[email protected]</email>"#,
r#"<report_id>1</report_id><date_range><begin>1</begin><end>2</end></date_range>"#,
r#"</report_metadata><policy_published><domain>foobar.org</domain>"#,
r#"</policy_published></feedback>"#
);
fn report_message(content_type: &str, file_name: &str, payload: &[u8]) -> String {
MessageBuilder::new()
.from(("Reporter", "[email protected]"))
.to("[email protected]")
.subject("Report Domain: foobar.org")
.body(MimePart::new(
"multipart/report",
BodyPart::Multipart(vec![
MimePart::new("text/plain", BodyPart::Text("Report attached.".into())),
MimePart::new(content_type, BodyPart::Binary(payload.into())).attachment(file_name),
]),
))
.write_to_string()
.unwrap()
}
#[tokio::test(flavor = "multi_thread")]
async fn report_analyze() {
let mut test = TestServerBuilder::new("smtp_analyze_report_test")
.await
.with_http_listener(19044)
.await
.capture_queue()
.build()
.await;
let admin = test.account("admin");
admin
.registry_create_object(MtaStageData {
max_messages: Expression {
else_: "100".into(),
..Default::default()
},
..Default::default()
})
.await;
admin
.registry_create_object(ReportSettings {
inbound_report_addresses: Map::new(vec![
"reports@*".to_string(),
"*@dmarc.foobar.org".to_string(),
"[email protected]".to_string(),
]),
inbound_report_forwarding: false,
inbound_report_max_size: MAX_REPORT_SIZE,
..Default::default()
})
.await;
admin
.registry_create_object(DataRetention {
hold_mta_reports_for: Some(1u64.into()),
..Default::default()
})
.await;
admin.mta_no_auth().await;
admin.mta_allow_non_fqdn().await;
admin.mta_allow_relaying().await;
admin.reload_settings().await;
test.reload_core();
test.expect_reload_settings().await;
// Create test message
let mut session = test.new_mta_session();
session.data.remote_ip_str = "10.0.0.1".into();
session.eval_session_params().await;
session.ehlo("mx.test.org").await;
let addresses = [
"[email protected]",
"[email protected]",
"[email protected]",
];
let mut ac = 0;
let mut total_reports_received: AHashMap<&str, usize> = AHashMap::new();
for (test_name, num_tests) in [("arf", 5), ("dmarc", 5), ("tls", 2)] {
for num_test in 1..=num_tests {
*total_reports_received.entry(test_name).or_insert(0) += 1;
session
.send_message(
"[email protected]",
&[addresses[ac % addresses.len()]],
&format!("report:{test_name}{num_test}"),
"250",
)
.await;
test.assert_no_events();
ac += 1;
}
}
// Report ingestion is asynchronous, poll until the reports are stored
let admin = test.account("admin");
for _ in 0..50 {
if admin.registry_get_all::<DmarcExternalReport>().await.len()
== total_reports_received["dmarc"]
&& admin.registry_get_all::<TlsExternalReport>().await.len()
== total_reports_received["tls"]
&& admin.registry_get_all::<ArfExternalReport>().await.len()
== total_reports_received["arf"]
{
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
// Purging the database shouldn't remove the reports
admin
.registry_create_object(Task::StoreMaintenance(TaskStoreMaintenance {
maintenance_type: TaskStoreMaintenanceType::PurgeData,
shard_index: None,
status: TaskStatus::now(),
}))
.await;
test.wait_for_tasks().await;
// Make sure the reports are in the store
assert_eq!(
admin.registry_get_all::<DmarcExternalReport>().await.len(),
total_reports_received["dmarc"]
);
assert_eq!(
admin.registry_get_all::<TlsExternalReport>().await.len(),
total_reports_received["tls"]
);
assert_eq!(
admin.registry_get_all::<ArfExternalReport>().await.len(),
total_reports_received["arf"]
);
// Wait one second, purge, and make sure they are gone
tokio::time::sleep(Duration::from_secs(1)).await;
admin
.registry_create_object(Task::StoreMaintenance(TaskStoreMaintenance {
maintenance_type: TaskStoreMaintenanceType::PurgeData,
shard_index: None,
status: TaskStatus::now(),
}))
.await;
test.wait_for_tasks().await;
assert_eq!(
admin.registry_get_all::<DmarcExternalReport>().await,
vec![]
);
assert_eq!(admin.registry_get_all::<TlsExternalReport>().await, vec![]);
assert_eq!(admin.registry_get_all::<ArfExternalReport>().await, vec![]);
// Reports that lie about their size or exceed the limit must not be ingested
let attachment_name = "mx.test.org!foobar.org!1!2.xml";
for payload in [
report_message(
"application/zip",
&format!("{attachment_name}.zip"),
&zip("report.xml", DMARC_REPORT.as_bytes(), None, Some(u32::MAX)),
),
report_message(
"application/gzip",
&format!("{attachment_name}.gz"),
&gzip(&vec![b' '; MAX_REPORT_SIZE as usize * 2]),
),
] {
session
.send_message("[email protected]", &["[email protected]"], &payload, "250")
.await;
test.assert_no_events();
}
// A report within the limit is still ingested
session
.send_message(
"[email protected]",
&["[email protected]"],
&report_message(
"application/zip",
&format!("{attachment_name}.zip"),
&zip("report.xml", DMARC_REPORT.as_bytes(), None, None),
),
"250",
)
.await;
test.assert_no_events();
let admin = test.account("admin");
for _ in 0..50 {
if !admin
.registry_get_all::<DmarcExternalReport>()
.await
.is_empty()
{
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
assert_eq!(
admin.registry_get_all::<DmarcExternalReport>().await.len(),
1
);
// Redeliveries of a previously stored report must not be imported again
session
.send_message(
"[email protected]",
&["[email protected]"],
&report_message(
"application/zip",
&format!("{attachment_name}.zip"),
&zip("report.xml", DMARC_REPORT.as_bytes(), None, None),
),
"250",
)
.await;
test.assert_no_events();
let admin = test.account("admin");
for _ in 0..10 {
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(
admin.registry_get_all::<DmarcExternalReport>().await.len(),
1
);
}
// Test delivery to non-report addresses
session
.send_message("[email protected]", &["[email protected]"], "test:no_dkim", "250")
.await;
test.expect_refresh().await;
test.last_queued_message().await;
// Messages sent to a report address that contain no report must be delivered
session
.send_message(
"[email protected]",
&["[email protected]"],
concat!(
"From: [email protected]\r\n",
"To: [email protected]\r\n",
"Subject: Your MX is refusing my connections\r\n",
"\r\n",
"Could you have a look at this?"
),
"250",
)
.await;
let message = test.expect_message().await;
assert_eq!(
message.message.recipients.last().unwrap().address(),
"[email protected]"
);
// Reports addressed to both a report address and a regular mailbox are
// discarded only for the report address
session
.send_message(
"[email protected]",
&["[email protected]", "[email protected]"],
&report_message(
"application/zip",
&format!("{attachment_name}.zip"),
&zip("report.xml", DMARC_REPORT.as_bytes(), None, None),
),
"250",
)
.await;
let message = test.expect_message().await;
assert_eq!(
message
.message
.recipients
.iter()
.map(|rcpt| rcpt.address())
.collect::<Vec<_>>(),
vec!["[email protected]"]
);
}
fn gzip(data: &[u8]) -> Vec<u8> {
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder.write_all(data).unwrap();
encoder.finish().unwrap()
}
fn zip(
name: &str,
data: &[u8],
compressed_size: Option<u32>,
uncompressed_size: Option<u32>,
) -> Vec<u8> {
let mut crc = Crc::new();
crc.update(data);
let crc = crc.sum();
let compressed_size = compressed_size.unwrap_or(data.len() as u32);
let uncompressed_size = uncompressed_size.unwrap_or(data.len() as u32);
let name = name.as_bytes();
let mut out = Vec::new();
out.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
out.extend_from_slice(&20u16.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&crc.to_le_bytes());
out.extend_from_slice(&compressed_size.to_le_bytes());
out.extend_from_slice(&uncompressed_size.to_le_bytes());
out.extend_from_slice(&(name.len() as u16).to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(name);
out.extend_from_slice(data);
let central_offset = out.len() as u32;
out.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
out.extend_from_slice(&20u16.to_le_bytes());
out.extend_from_slice(&20u16.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&crc.to_le_bytes());
out.extend_from_slice(&compressed_size.to_le_bytes());
out.extend_from_slice(&uncompressed_size.to_le_bytes());
out.extend_from_slice(&(name.len() as u16).to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&0u32.to_le_bytes());
out.extend_from_slice(&0u32.to_le_bytes());
out.extend_from_slice(name);
let central_size = out.len() as u32 - central_offset;
out.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(&1u16.to_le_bytes());
out.extend_from_slice(&1u16.to_le_bytes());
out.extend_from_slice(&central_size.to_le_bytes());
out.extend_from_slice(&central_offset.to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out
}
+189
View File
@@ -0,0 +1,189 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
smtp::{inbound::TestMessage, session::VerifyResponse},
utils::{dns::DnsCache, server::TestServerBuilder},
};
use common::{config::smtp::report::AggregateFrequency, ipc::DmarcEvent};
use mail_auth::{
common::parse::TxtRecordParser,
dmarc::Dmarc,
report::{ActionDisposition, Disposition, DmarcResult, Record, Report},
};
use registry::schema::structs::{
DmarcInternalReport, DmarcReportSettings, Expression, ReportSettings,
};
use smtp::reporting::dmarc::DmarcReporting;
use std::{
net::IpAddr,
sync::Arc,
time::{Duration, Instant},
};
#[tokio::test]
async fn report_dmarc() {
let mut test = TestServerBuilder::new("smtp_report_dmarc_test")
.await
.with_http_listener(19045)
.await
.disable_services()
.capture_queue()
.build()
.await;
let admin = test.account("admin");
let domain_id = admin.find_or_create_domain("example.org").await;
admin.create_dkim_signatures(domain_id).await;
admin
.registry_create_object(ReportSettings {
outbound_report_submitter: Expression {
else_: "'mx.example.org'".into(),
..Default::default()
},
..Default::default()
})
.await;
admin
.registry_create_object(DmarcReportSettings {
aggregate_contact_info: Expression {
else_: "'https://foobar.org/contact'".into(),
..Default::default()
},
aggregate_dkim_sign_domain: Expression {
else_: "'example.org'".into(),
..Default::default()
},
aggregate_from_address: Expression {
else_: "'reports@' + system('domain')".into(),
..Default::default()
},
aggregate_from_name: Expression {
else_: "'DMARC Report'".into(),
..Default::default()
},
aggregate_max_report_size: Expression {
else_: "4096".into(),
..Default::default()
},
aggregate_org_name: Expression {
else_: "'Foobar, Inc.'".into(),
..Default::default()
},
aggregate_send_frequency: Expression {
else_: "daily".into(),
..Default::default()
},
..Default::default()
})
.await;
admin.mta_no_auth().await;
admin.mta_allow_non_fqdn().await;
admin.mta_allow_relaying().await;
admin.reload_settings().await;
test.reload_core();
test.expect_reload_settings().await;
// Authorize external report for foobar.org
test.server.txt_add(
"foobar.org._report._dmarc.foobar.net",
Dmarc::parse(b"v=DMARC1;").unwrap(),
Instant::now() + Duration::from_secs(10),
);
// Schedule two events with a same policy and another one with a different policy
let dmarc_record = Arc::new(
Dmarc::parse(
b"v=DMARC1; p=quarantine; rua=mailto:[email protected],mailto:[email protected]",
)
.unwrap(),
);
assert_eq!(dmarc_record.rua().len(), 2);
for _ in 0..2 {
test.server
.schedule_dmarc(Box::new(DmarcEvent {
domain: "foobar.org".to_string(),
report_record: Record::new()
.with_source_ip("192.168.1.2".parse().unwrap())
.with_action_disposition(ActionDisposition::Pass)
.with_dmarc_dkim_result(DmarcResult::Pass)
.with_dmarc_spf_result(DmarcResult::Fail)
.with_envelope_from("[email protected]")
.with_envelope_to("[email protected]")
.with_header_from("[email protected]"),
dmarc_record: dmarc_record.clone(),
interval: AggregateFrequency::Weekly,
span_id: 0,
}))
.await;
}
test.server
.schedule_dmarc(Box::new(DmarcEvent {
domain: "foobar.org".to_string(),
report_record: Record::new()
.with_source_ip("a:b:c::e:f".parse().unwrap())
.with_action_disposition(ActionDisposition::Reject)
.with_dmarc_dkim_result(DmarcResult::Fail)
.with_dmarc_spf_result(DmarcResult::Pass),
dmarc_record: dmarc_record.clone(),
interval: AggregateFrequency::Weekly,
span_id: 0,
}))
.await;
tokio::time::sleep(Duration::from_millis(200)).await;
let reports = test.read_report_events::<DmarcInternalReport>().await;
assert_eq!(reports.len(), 1);
test.server
.send_dmarc_aggregate_report(reports.first().unwrap().0.id())
.await
.unwrap();
// Expect report
let message = test.expect_message().await;
test.assert_no_events();
assert_eq!(message.message.recipients.len(), 1);
assert_eq!(
message.message.recipients.last().unwrap().address(),
"[email protected]"
);
assert_eq!(message.message.return_path.as_ref(), "[email protected]");
message
.read_lines(&test)
.await
.assert_contains("DKIM-Signature: v=1; a=rsa-sha256; s=rsa; d=example.org;")
.assert_contains("To: <[email protected]>")
.assert_contains("Report Domain: foobar.org")
.assert_contains("Submitter: mx.example.org");
// Verify generated report
let report =
Report::parse_rfc5322(message.read_message(&test).await.as_bytes(), usize::MAX).unwrap();
assert_eq!(report.domain(), "foobar.org");
assert_eq!(report.email(), "[email protected]");
assert_eq!(report.org_name(), "Foobar, Inc.");
assert_eq!(
report.extra_contact_info().unwrap(),
"https://foobar.org/contact"
);
assert_eq!(report.p(), Disposition::Quarantine);
assert_eq!(report.records().len(), 2, "records: {:?}", report.records());
for record in report.records() {
let source_ip = record.source_ip().unwrap();
if source_ip == "192.168.1.2".parse::<IpAddr>().unwrap() {
assert_eq!(record.count(), 2);
assert_eq!(record.action_disposition(), ActionDisposition::Pass);
assert_eq!(record.envelope_from(), "[email protected]");
assert_eq!(record.header_from(), "[email protected]");
assert_eq!(record.envelope_to().unwrap(), "[email protected]");
} else if source_ip == "a:b:c::e:f".parse::<IpAddr>().unwrap() {
assert_eq!(record.count(), 1);
assert_eq!(record.action_disposition(), ActionDisposition::Reject);
} else {
panic!("unexpected ip {source_ip}");
}
}
test.assert_report_is_empty::<DmarcInternalReport>().await;
}
+10
View File
@@ -0,0 +1,10 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
pub mod analyze;
pub mod dmarc;
pub mod scheduler;
pub mod tls;
+189
View File
@@ -0,0 +1,189 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::utils::server::TestServerBuilder;
use common::{
config::smtp::report::AggregateFrequency,
ipc::{DmarcEvent, PolicyType, TlsEvent},
};
use mail_auth::{
common::parse::TxtRecordParser,
dmarc::Dmarc,
mta_sts::TlsRpt,
report::{ActionDisposition, DmarcResult, Record},
};
use registry::schema::structs::{
DmarcInternalReport, DmarcReportSettings, Expression, TlsInternalReport, TlsReportSettings,
};
use smtp::reporting::{dmarc::DmarcReporting, tls::TlsReporting};
use std::sync::Arc;
#[tokio::test]
async fn report_scheduler() {
let mut test = TestServerBuilder::new("smtp_report_queue_test")
.await
.with_http_listener(19046)
.await
.disable_services()
.capture_queue()
.build()
.await;
let admin = test.account("admin");
admin
.registry_create_object(DmarcReportSettings {
aggregate_max_report_size: Expression {
else_: "500".into(),
..Default::default()
},
aggregate_send_frequency: Expression {
else_: "daily".into(),
..Default::default()
},
..Default::default()
})
.await;
admin
.registry_create_object(TlsReportSettings {
max_report_size: Expression {
else_: "550".into(),
..Default::default()
},
send_frequency: Expression {
else_: "daily".into(),
..Default::default()
},
..Default::default()
})
.await;
admin.mta_no_auth().await;
admin.mta_allow_non_fqdn().await;
admin.mta_allow_relaying().await;
admin.reload_settings().await;
test.reload_core();
test.expect_reload_settings().await;
// Schedule two events with a same policy and another one with a different policy
let dmarc_record =
Arc::new(Dmarc::parse(b"v=DMARC1; p=quarantine; rua=mailto:[email protected]").unwrap());
test.server
.schedule_dmarc(Box::new(DmarcEvent {
domain: "foobar.org".to_string(),
report_record: Record::new()
.with_source_ip("192.168.1.2".parse().unwrap())
.with_action_disposition(ActionDisposition::Pass)
.with_dmarc_dkim_result(DmarcResult::Pass)
.with_dmarc_spf_result(DmarcResult::Fail)
.with_envelope_from("[email protected]")
.with_envelope_to("[email protected]")
.with_header_from("[email protected]"),
dmarc_record: dmarc_record.clone(),
interval: AggregateFrequency::Weekly,
span_id: 0,
}))
.await;
// No records should be added once the 550 bytes max size is reached
for _ in 0..10 {
test.server
.schedule_dmarc(Box::new(DmarcEvent {
domain: "foobar.org".to_string(),
report_record: Record::new()
.with_source_ip("192.168.1.2".parse().unwrap())
.with_action_disposition(ActionDisposition::Pass)
.with_dmarc_dkim_result(DmarcResult::Pass)
.with_dmarc_spf_result(DmarcResult::Fail)
.with_envelope_from("[email protected]")
.with_envelope_to("[email protected]")
.with_header_from("[email protected]"),
dmarc_record: dmarc_record.clone(),
interval: AggregateFrequency::Weekly,
span_id: 0,
}))
.await;
}
let dmarc_record =
Arc::new(Dmarc::parse(b"v=DMARC1; p=reject; rua=mailto:[email protected]").unwrap());
test.server
.schedule_dmarc(Box::new(DmarcEvent {
domain: "foobar.org".to_string(),
report_record: Record::new()
.with_source_ip("a:b:c::e:f".parse().unwrap())
.with_action_disposition(ActionDisposition::Reject)
.with_dmarc_dkim_result(DmarcResult::Fail)
.with_dmarc_spf_result(DmarcResult::Pass),
dmarc_record: dmarc_record.clone(),
interval: AggregateFrequency::Weekly,
span_id: 0,
}))
.await;
// Schedule TLS event
let tls_record = Arc::new(TlsRpt::parse(b"v=TLSRPTv1;rua=mailto:[email protected]").unwrap());
test.server
.schedule_tls(Box::new(TlsEvent {
domain: "foobar.org".to_string(),
policy: PolicyType::Tlsa(None),
failure: None,
tls_record: tls_record.clone(),
interval: AggregateFrequency::Daily,
span_id: 0,
}))
.await;
test.server
.schedule_tls(Box::new(TlsEvent {
domain: "foobar.org".to_string(),
policy: PolicyType::Tlsa(None),
failure: None,
tls_record: tls_record.clone(),
interval: AggregateFrequency::Daily,
span_id: 0,
}))
.await;
test.server
.schedule_tls(Box::new(TlsEvent {
domain: "foobar.org".to_string(),
policy: PolicyType::Sts(None),
failure: None,
tls_record: tls_record.clone(),
interval: AggregateFrequency::Daily,
span_id: 0,
}))
.await;
test.server
.schedule_tls(Box::new(TlsEvent {
domain: "foobar.org".to_string(),
policy: PolicyType::None,
failure: None,
tls_record: tls_record.clone(),
interval: AggregateFrequency::Daily,
span_id: 0,
}))
.await;
// Verify sizes and counts
let mut total_tls = 0;
let mut total_tls_policies = 0;
let mut total_dmarc_policies = 0;
for (_, report) in test.read_report_events::<DmarcInternalReport>().await {
total_dmarc_policies += 1;
assert_eq!(
report.deliver_at.timestamp() - report.created_at.timestamp(),
7 * 86400
);
}
for (_, report) in test.read_report_events::<TlsInternalReport>().await {
total_tls += 1;
total_tls_policies += report.report.policies.len();
assert_eq!(
report.deliver_at.timestamp() - report.created_at.timestamp(),
86400
);
}
assert_eq!(total_tls, 1);
assert_eq!(total_tls_policies, 3);
assert_eq!(total_dmarc_policies, 2);
}
+256
View File
@@ -0,0 +1,256 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
smtp::{inbound::TestMessage, session::VerifyResponse},
utils::server::TestServerBuilder,
};
use common::{config::smtp::report::AggregateFrequency, ipc::TlsEvent};
use mail_auth::{
common::parse::TxtRecordParser,
flate2::read::GzDecoder,
mta_sts::TlsRpt,
report::tlsrpt::{FailureDetails, PolicyType, ResultType, TlsReport},
};
use registry::schema::structs::{Expression, ReportSettings, TlsInternalReport, TlsReportSettings};
use smtp::reporting::tls::{TLS_HTTP_REPORT, TlsReporting};
use std::{io::Read, sync::Arc, time::Duration};
#[tokio::test]
async fn report_tls() {
let mut test = TestServerBuilder::new("smtp_report_tls_test")
.await
.with_http_listener(19047)
.await
.disable_services()
.capture_queue()
.build()
.await;
let admin = test.account("admin");
let domain_id = admin.find_or_create_domain("example.org").await;
admin.create_dkim_signatures(domain_id).await;
admin
.registry_create_object(ReportSettings {
outbound_report_submitter: Expression {
else_: "'mx.example.org'".into(),
..Default::default()
},
..Default::default()
})
.await;
admin
.registry_create_object(TlsReportSettings {
contact_info: Expression {
else_: "'https://foobar.org/contact'".into(),
..Default::default()
},
dkim_sign_domain: Expression {
else_: "'example.org'".into(),
..Default::default()
},
from_address: Expression {
else_: "'[email protected]'".into(),
..Default::default()
},
from_name: Expression {
else_: "'Report Subsystem'".into(),
..Default::default()
},
org_name: Expression {
else_: "'Foobar, Inc.'".into(),
..Default::default()
},
send_frequency: Expression {
else_: "daily".into(),
..Default::default()
},
..Default::default()
})
.await;
admin.mta_no_auth().await;
admin.mta_allow_non_fqdn().await;
admin.mta_allow_relaying().await;
admin.reload_settings().await;
test.reload_core();
test.expect_reload_settings().await;
// Schedule TLS reports to be delivered via email
let tls_record = Arc::new(TlsRpt::parse(b"v=TLSRPTv1;rua=mailto:[email protected]").unwrap());
for _ in 0..2 {
// Add two successful records
test.server
.schedule_tls(Box::new(TlsEvent {
domain: "foobar.org".to_string(),
policy: common::ipc::PolicyType::None,
failure: None,
tls_record: tls_record.clone(),
interval: AggregateFrequency::Daily,
span_id: 0,
}))
.await;
}
for (policy, rt) in [
(
common::ipc::PolicyType::None, // Quota limited at 1532 bytes, this should not be included in the report.
ResultType::CertificateExpired,
),
(common::ipc::PolicyType::Tlsa(None), ResultType::TlsaInvalid),
(
common::ipc::PolicyType::Sts(None),
ResultType::StsPolicyFetchError,
),
(
common::ipc::PolicyType::Sts(None),
ResultType::StsPolicyInvalid,
),
(
common::ipc::PolicyType::Sts(None),
ResultType::StsWebpkiInvalid,
),
] {
test.server
.schedule_tls(Box::new(TlsEvent {
domain: "foobar.org".to_string(),
policy,
failure: FailureDetails::new(rt).into(),
tls_record: tls_record.clone(),
interval: AggregateFrequency::Daily,
span_id: 0,
}))
.await;
}
// Wait for flush
tokio::time::sleep(Duration::from_millis(200)).await;
let reports = test.read_report_events::<TlsInternalReport>().await;
assert_eq!(reports.len(), 1);
let (report_id, report) = reports.into_iter().next().unwrap();
assert_eq!(report.report.policies.len(), 3);
test.server
.send_tls_aggregate_report(report_id.id())
.await
.unwrap();
// Expect report
let message = test.expect_message().await;
assert_eq!(
message.message.recipients.last().unwrap().address(),
"[email protected]"
);
assert_eq!(message.message.return_path.as_ref(), "[email protected]");
message
.read_lines(&test)
.await
.assert_contains("DKIM-Signature: v=1; a=rsa-sha256; s=rsa; d=example.org;")
.assert_contains("To: <[email protected]>")
.assert_contains("Report Domain: foobar.org")
.assert_contains("Submitter: mx.example.org");
// Verify generated report
let report =
TlsReport::parse_rfc5322(message.read_message(&test).await.as_bytes(), usize::MAX).unwrap();
assert_eq!(report.organization_name.unwrap(), "Foobar, Inc.");
assert_eq!(report.contact_info.unwrap(), "https://foobar.org/contact");
assert_eq!(report.policies.len(), 3);
let mut seen = [false; 3];
for policy in report.policies {
match policy.policy.policy_type {
PolicyType::Tlsa => {
seen[0] = true;
assert_eq!(policy.summary.total_failure, 1);
assert_eq!(policy.summary.total_success, 0);
assert_eq!(policy.policy.policy_domain, "foobar.org");
assert_eq!(policy.failure_details.len(), 1);
assert_eq!(
policy.failure_details.first().unwrap().result_type,
ResultType::TlsaInvalid
);
}
PolicyType::Sts => {
seen[1] = true;
assert_eq!(policy.summary.total_failure, 3);
assert_eq!(policy.summary.total_success, 0);
assert_eq!(policy.policy.policy_domain, "foobar.org");
assert_eq!(policy.failure_details.len(), 3);
assert!(
policy
.failure_details
.iter()
.any(|d| d.result_type == ResultType::StsPolicyFetchError)
);
assert!(
policy
.failure_details
.iter()
.any(|d| d.result_type == ResultType::StsPolicyInvalid)
);
assert!(
policy
.failure_details
.iter()
.any(|d| d.result_type == ResultType::StsWebpkiInvalid)
);
}
PolicyType::NoPolicyFound => {
seen[2] = true;
assert_eq!(policy.summary.total_failure, 1);
assert_eq!(policy.summary.total_success, 2);
assert_eq!(policy.policy.policy_domain, "foobar.org");
assert_eq!(policy.failure_details.len(), 1);
/*assert_eq!(
policy.failure_details.first().unwrap().result_type,
ResultType::CertificateExpired
);*/
}
PolicyType::Other => unreachable!(),
}
}
assert!(seen[0]);
assert!(seen[1]);
assert!(seen[2]);
// Schedule TLS reports to be delivered via https
let tls_record = Arc::new(TlsRpt::parse(b"v=TLSRPTv1;rua=https://127.0.0.1/tls").unwrap());
for _ in 0..2 {
// Add two successful records
test.server
.schedule_tls(Box::new(TlsEvent {
domain: "foobar.org".to_string(),
policy: common::ipc::PolicyType::None,
failure: None,
tls_record: tls_record.clone(),
interval: AggregateFrequency::Daily,
span_id: 0,
}))
.await;
}
let reports = test.read_report_events::<TlsInternalReport>().await;
assert_eq!(reports.len(), 1);
test.server
.send_tls_aggregate_report(reports.first().unwrap().0.id())
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
// Uncompress report
{
let gz_report = TLS_HTTP_REPORT.lock();
let mut file = GzDecoder::new(&gz_report[..]);
let mut buf = Vec::new();
file.read_to_end(&mut buf).unwrap();
let report = TlsReport::parse_json(&buf).unwrap();
assert_eq!(report.organization_name.unwrap(), "Foobar, Inc.");
assert_eq!(report.contact_info.unwrap(), "https://foobar.org/contact");
assert_eq!(report.policies.len(), 1);
}
test.assert_report_is_empty::<TlsInternalReport>().await;
}
+392
View File
@@ -0,0 +1,392 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use base64::{Engine, engine::general_purpose};
use common::{
Server,
config::server::{DEFAULT_TLS_TIMEOUT, ServerProtocol},
network::{ServerInstance, SessionStream, TcpAcceptor, limiter::ConcurrencyLimiter},
};
use rustls::{ServerConfig, server::ResolvesServerCert};
use smtp::core::{Session, SessionAddress, SessionData, SessionParameters, State};
use smtp::queue::MessageSource;
use std::{borrow::Cow, path::PathBuf, sync::Arc};
use tokio::{
io::{AsyncRead, AsyncWrite},
sync::watch,
};
use tokio_rustls::TlsAcceptor;
use utils::snowflake::SnowflakeIdGenerator;
pub struct DummyIo {
pub tx_buf: Vec<u8>,
pub rx_buf: Vec<u8>,
pub tls: bool,
}
impl AsyncRead for DummyIo {
fn poll_read(
mut self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<std::io::Result<()>> {
if !self.rx_buf.is_empty() {
buf.put_slice(&self.rx_buf);
self.rx_buf.clear();
std::task::Poll::Ready(Ok(()))
} else {
std::task::Poll::Pending
}
}
}
impl AsyncWrite for DummyIo {
fn poll_write(
mut self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
buf: &[u8],
) -> std::task::Poll<Result<usize, std::io::Error>> {
self.tx_buf.extend_from_slice(buf);
std::task::Poll::Ready(Ok(buf.len()))
}
fn poll_flush(
self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), std::io::Error>> {
std::task::Poll::Ready(Ok(()))
}
fn poll_shutdown(
self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), std::io::Error>> {
std::task::Poll::Ready(Ok(()))
}
}
impl SessionStream for DummyIo {
fn is_tls(&self) -> bool {
self.tls
}
fn tls_version_and_cipher(&self) -> (Cow<'static, str>, Cow<'static, str>) {
("".into(), "".into())
}
}
impl Unpin for DummyIo {}
#[allow(async_fn_in_trait)]
pub trait TestSession {
fn test(server: Server) -> Self;
fn test_with_shutdown(server: Server, shutdown_rx: watch::Receiver<bool>) -> Self;
fn response(&mut self) -> Vec<String>;
fn write_rx(&mut self, data: &str);
async fn rset(&mut self);
async fn cmd(&mut self, cmd: &str, expected_code: &str) -> Vec<String>;
async fn auth_plain(&mut self, username: &str, secret: &str, expected_code: &str);
async fn auth_login(&mut self, username: &str, secret: &str, expected_code: &str);
async fn ehlo(&mut self, host: &str) -> Vec<String>;
async fn mail_from(&mut self, from: &str, expected_code: &str);
async fn rcpt_to(&mut self, to: &str, expected_code: &str);
async fn data(&mut self, data: &str, expected_code: &str);
async fn send_message(&mut self, from: &str, to: &[&str], data: &str, expected_code: &str);
async fn test_builder(&self);
}
impl TestSession for Session<DummyIo> {
fn test_with_shutdown(server: Server, shutdown_rx: watch::Receiver<bool>) -> Self {
Self {
state: State::default(),
instance: Arc::new(ServerInstance::test_with_shutdown(shutdown_rx)),
server,
stream: DummyIo {
rx_buf: vec![],
tx_buf: vec![],
tls: false,
},
data: SessionData::new(
"127.0.0.1".parse().unwrap(),
0,
"127.0.0.1".parse().unwrap(),
0,
Default::default(),
0,
),
params: SessionParameters::default(),
hostname: "localhost".into(),
}
}
fn test(server: Server) -> Self {
Self::test_with_shutdown(server, watch::channel(false).1)
}
fn response(&mut self) -> Vec<String> {
if !self.stream.tx_buf.is_empty() {
let response = std::str::from_utf8(&self.stream.tx_buf)
.unwrap()
.split("\r\n")
.filter_map(|r| {
if !r.is_empty() {
r.to_string().into()
} else {
None
}
})
.collect::<Vec<_>>();
self.stream.tx_buf.clear();
response
} else {
panic!("There was no response.");
}
}
fn write_rx(&mut self, data: &str) {
self.stream.rx_buf.extend_from_slice(data.as_bytes());
}
async fn rset(&mut self) {
self.ingest(b"RSET\r\n").await.unwrap();
self.response().assert_code("250");
}
async fn cmd(&mut self, cmd: &str, expected_code: &str) -> Vec<String> {
self.ingest(format!("{cmd}\r\n").as_bytes()).await.unwrap();
self.response().assert_code(expected_code)
}
async fn auth_plain(&mut self, username: &str, secret: &str, expected_code: &str) {
let cmd = format!(
"AUTH PLAIN {}",
general_purpose::STANDARD.encode(format!("\0{username}\0{secret}"))
);
self.cmd(&cmd, expected_code).await;
}
async fn auth_login(&mut self, username: &str, secret: &str, expected_code: &str) {
self.cmd("AUTH LOGIN", "334").await;
self.cmd(&general_purpose::STANDARD.encode(username), "334")
.await;
self.cmd(&general_purpose::STANDARD.encode(secret), expected_code)
.await;
}
async fn ehlo(&mut self, host: &str) -> Vec<String> {
self.ingest(format!("EHLO {host}\r\n").as_bytes())
.await
.unwrap();
self.response().assert_code("250")
}
async fn mail_from(&mut self, from: &str, expected_code: &str) {
self.ingest(
if !from.starts_with('<') {
format!("MAIL FROM:<{from}>\r\n")
} else {
format!("MAIL FROM:{from}\r\n")
}
.as_bytes(),
)
.await
.unwrap();
self.response().assert_code(expected_code);
}
async fn rcpt_to(&mut self, to: &str, expected_code: &str) {
self.ingest(
if !to.starts_with('<') {
format!("RCPT TO:<{to}>\r\n")
} else {
format!("RCPT TO:{to}\r\n")
}
.as_bytes(),
)
.await
.unwrap();
self.response().assert_code(expected_code);
}
async fn data(&mut self, data: &str, expected_code: &str) {
self.ingest(b"DATA\r\n").await.unwrap();
self.response().assert_code("354");
if let Some(file) = data.strip_prefix("test:") {
self.ingest(load_test_message(file, "messages").as_bytes())
.await
.unwrap();
} else if let Some(file) = data.strip_prefix("report:") {
self.ingest(load_test_message(file, "reports").as_bytes())
.await
.unwrap();
} else {
self.ingest(data.as_bytes()).await.unwrap();
}
self.ingest(b"\r\n.\r\n").await.unwrap();
self.response().assert_code(expected_code);
}
async fn send_message(&mut self, from: &str, to: &[&str], data: &str, expected_code: &str) {
self.mail_from(from, "250").await;
for to in to {
self.rcpt_to(to, "250").await;
}
self.data(data, expected_code).await;
}
async fn test_builder(&self) {
let message = self
.build_message(
SessionAddress {
address: "[email protected]".into(),
address_lcase: "[email protected]".into(),
domain: "foobar.org".into(),
flags: 123,
dsn_info: Some("envelope1".into()),
},
vec![
SessionAddress {
address: "[email protected]".into(),
address_lcase: "[email protected]".into(),
domain: "foobar.org".into(),
flags: 1,
dsn_info: None,
},
SessionAddress {
address: "[email protected]".into(),
address_lcase: "[email protected]".into(),
domain: "test.net".into(),
flags: 2,
dsn_info: None,
},
SessionAddress {
address: "[email protected]".into(),
address_lcase: "[email protected]".into(),
domain: "foobar.org".into(),
flags: 3,
dsn_info: None,
},
SessionAddress {
address: "[email protected]".into(),
address_lcase: "[email protected]".into(),
domain: "test.net".into(),
flags: 4,
dsn_info: None,
},
],
MessageSource::Authenticated,
self.server.inner.data.queue_id_gen.generate(),
0,
)
.await;
let rcpts = ["[email protected]", "[email protected]", "[email protected]", "[email protected]"];
for rcpt in &message.message.recipients {
let idx = (rcpt.flags - 1) as usize;
assert_eq!(rcpts[idx], rcpt.address());
}
}
}
pub fn load_test_message(file: &str, test: &str) -> String {
let mut test_file = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
test_file.push("resources");
test_file.push("smtp");
test_file.push(test);
test_file.push(format!("{file}.eml"));
std::fs::read_to_string(test_file).unwrap()
}
pub trait VerifyResponse {
fn assert_code(self, expected_code: &str) -> Self;
fn assert_contains(self, expected_text: &str) -> Self;
fn assert_not_contains(self, expected_text: &str) -> Self;
fn assert_count(self, text: &str, occurrences: usize) -> Self;
}
impl VerifyResponse for Vec<String> {
fn assert_code(self, expected_code: &str) -> Self {
if self.last().expect("response").starts_with(expected_code) {
self
} else {
panic!("Expected {:?} but got {}.", expected_code, self.join("\n"));
}
}
fn assert_contains(self, expected_text: &str) -> Self {
if self.iter().any(|line| line.contains(expected_text)) {
self
} else {
panic!("Expected {:?} but got {}.", expected_text, self.join("\n"));
}
}
fn assert_not_contains(self, expected_text: &str) -> Self {
if !self.iter().any(|line| line.contains(expected_text)) {
self
} else {
panic!(
"Not expecting {:?} but got it {}.",
expected_text,
self.join("\n")
);
}
}
fn assert_count(self, text: &str, occurrences: usize) -> Self {
assert_eq!(
self.iter().filter(|l| l.contains(text)).count(),
occurrences,
"Expected {} occurrences of {:?}, found {}.",
occurrences,
text,
self.iter().filter(|l| l.contains(text)).count()
);
self
}
}
pub trait TestServerInstance {
fn test_with_shutdown(shutdown_rx: watch::Receiver<bool>) -> Self;
}
impl TestServerInstance for ServerInstance {
fn test_with_shutdown(shutdown_rx: watch::Receiver<bool>) -> Self {
let tls_config = Arc::new(
ServerConfig::builder()
.with_no_client_auth()
.with_cert_resolver(Arc::new(DummyCertResolver)),
);
Self {
id: "smtp".to_string(),
protocol: ServerProtocol::Smtp,
acceptor: TcpAcceptor::Tls {
config: tls_config.clone(),
acceptor: TlsAcceptor::from(tls_config),
implicit: false,
},
limiter: ConcurrencyLimiter::new(100),
tls_timeout: DEFAULT_TLS_TIMEOUT,
shutdown_rx,
proxy_networks: vec![],
span_id_gen: Arc::new(SnowflakeIdGenerator::new()),
}
}
}
#[derive(Debug)]
pub struct DummyCertResolver;
impl ResolvesServerCert for DummyCertResolver {
fn resolve(&self, _: rustls::server::ClientHello) -> Option<Arc<rustls::sign::CertifiedKey>> {
None
}
}
pub fn test_server_instance() -> ServerInstance {
ServerInstance::test_with_shutdown(watch::channel(false).1)
}