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;
}