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:
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]"
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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-----
|
||||
"#;
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::utils::{dns::DnsCache, server::TestServerBuilder};
|
||||
use common::expr::{tokenizer::TokenMap, *};
|
||||
use mail_auth::{DnssecStatus, MX};
|
||||
use registry::schema::{
|
||||
enums::ExpressionVariable,
|
||||
prelude::{ObjectType, Property},
|
||||
structs::{LookupStore, SqliteStore, StoreLookup},
|
||||
};
|
||||
use smtp::queue::RecipientDomain;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const TESTS: &[(&str, &str)] = &[
|
||||
("dns_query(rcpt_domain, 'mx')[0]", "mx.foobar.org"),
|
||||
(
|
||||
"key_get('sql', 'hello') + '-' + key_exists('sql', 'hello') + '-' + key_set('sql', 'hello', 'world') + '-' + key_get('sql', 'hello') + '-' + key_exists('sql', 'hello')",
|
||||
"-0-1-world-1",
|
||||
),
|
||||
(
|
||||
"counter_get('sql', 'county') + '-' + counter_incr('sql', 'county', 1) + '-' + counter_incr('sql', 'county', 1) + '-' + counter_get('sql', 'county')",
|
||||
"0-1-2-2",
|
||||
),
|
||||
(
|
||||
"sql_query('sql', 'SELECT description FROM domains WHERE name = ?', 'foobar.org')",
|
||||
"Main domain",
|
||||
),
|
||||
(
|
||||
"is_local_domain('foobar.org') + '-' + is_local_domain('unknown.org') + '-' + is_local_address('[email protected]') + '-' + is_local_address('[email protected]')",
|
||||
"1-0-1-0",
|
||||
),
|
||||
(
|
||||
"is_local_domain('FooBar.org') + '-' + is_local_address('[email protected]') + '-' + is_local_address('[email protected]')",
|
||||
"1-1-1",
|
||||
),
|
||||
];
|
||||
|
||||
#[tokio::test]
|
||||
async fn expressions() {
|
||||
let mut test = TestServerBuilder::new("smtp_lookup_test")
|
||||
.await
|
||||
.with_http_listener(19017)
|
||||
.await
|
||||
.disable_services()
|
||||
.capture_queue()
|
||||
.build()
|
||||
.await;
|
||||
|
||||
// Create test data
|
||||
let admin = test.account("admin");
|
||||
for (name, secret, description, aliases) in [
|
||||
("[email protected]", "12345 + extra safety", "John Doe", &[]),
|
||||
("[email protected]", "abcde + extra safety", "Jane Smith", &[]),
|
||||
] {
|
||||
admin
|
||||
.create_user_account(name, secret, description, aliases, vec![])
|
||||
.await;
|
||||
}
|
||||
admin
|
||||
.registry_create_object(StoreLookup {
|
||||
namespace: "sql".into(),
|
||||
store: LookupStore::Sqlite(SqliteStore {
|
||||
path: format!("{}/smtp_sql.db", test.tmp_dir()),
|
||||
pool_max_connections: 10,
|
||||
pool_workers: None,
|
||||
}),
|
||||
})
|
||||
.await;
|
||||
admin.reload_lookup_stores().await;
|
||||
test.reload_core();
|
||||
|
||||
test.server.mx_add(
|
||||
"test.org",
|
||||
vec![MX {
|
||||
exchanges: vec!["mx.foobar.org".into()].into_boxed_slice(),
|
||||
preference: 10,
|
||||
}],
|
||||
DnssecStatus::Secure,
|
||||
Instant::now() + Duration::from_secs(10),
|
||||
);
|
||||
|
||||
let sql = test
|
||||
.server
|
||||
.get_lookup_store("sql")
|
||||
.unwrap()
|
||||
.into_store()
|
||||
.unwrap();
|
||||
sql.create_tables().await.unwrap();
|
||||
for query in [
|
||||
"CREATE TABLE domains (name TEXT PRIMARY KEY, description TEXT);",
|
||||
"INSERT INTO domains (name, description) VALUES ('foobar.org', 'Main domain');",
|
||||
"INSERT INTO domains (name, description) VALUES ('foobar.net', 'Secondary domain');",
|
||||
"CREATE TABLE allowed_ips (addr TEXT PRIMARY KEY);",
|
||||
"INSERT INTO allowed_ips (addr) VALUES ('10.0.0.50');",
|
||||
] {
|
||||
sql.sql_query::<usize>(query, Vec::new()).await.unwrap();
|
||||
}
|
||||
|
||||
// Test expression functions
|
||||
let token_map = TokenMap::default().with_variables(&[
|
||||
ExpressionVariable::Rcpt,
|
||||
ExpressionVariable::RcptDomain,
|
||||
ExpressionVariable::Sender,
|
||||
ExpressionVariable::SenderDomain,
|
||||
ExpressionVariable::Mx,
|
||||
ExpressionVariable::HeloDomain,
|
||||
ExpressionVariable::AuthenticatedAs,
|
||||
ExpressionVariable::Listener,
|
||||
ExpressionVariable::RemoteIp,
|
||||
ExpressionVariable::LocalIp,
|
||||
ExpressionVariable::Priority,
|
||||
]);
|
||||
for (expr, expected) in TESTS {
|
||||
let e = Expression::parse(&token_map, expr);
|
||||
assert_eq!(
|
||||
test.server
|
||||
.eval_expr::<String, _>(
|
||||
&e,
|
||||
&RecipientDomain::new("test.org"),
|
||||
ObjectType::Account.singleton(),
|
||||
Property::AccountName,
|
||||
0
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
*expected,
|
||||
"failed for '{}'",
|
||||
expr
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
pub mod expressions;
|
||||
pub mod utils;
|
||||
@@ -0,0 +1,358 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::utils::server::TestServerBuilder;
|
||||
use ::smtp::outbound::NextHop;
|
||||
use common::config::smtp::{
|
||||
queue::{MxConfig, QueueExpiry, QueueName},
|
||||
report::AggregateFrequency,
|
||||
resolver::{Mode, MxPattern, Policy},
|
||||
};
|
||||
use mail_auth::{DnssecStatus, IpLookupStrategy, MX, RecordSet};
|
||||
use mail_parser::DateTime;
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::MtaIpStrategy,
|
||||
structs::{
|
||||
Expression, MtaConnectionIpHost, MtaConnectionStrategy, MtaOutboundStrategy, MtaRoute,
|
||||
MtaRouteMx,
|
||||
},
|
||||
},
|
||||
types::{ipaddr::IpAddr, list::List},
|
||||
};
|
||||
use smtp::{
|
||||
outbound::{
|
||||
lookup::{SourceIp, ToNextHop},
|
||||
mta_sts::parse::ParsePolicy,
|
||||
},
|
||||
queue::{
|
||||
Error, ErrorDetails, FROM_AUTHENTICATED, Message, QueueEnvelope, Recipient, Schedule,
|
||||
Status,
|
||||
},
|
||||
reporting::AggregateTimestamp,
|
||||
};
|
||||
use std::{str::FromStr, sync::Arc};
|
||||
use store::write::now;
|
||||
|
||||
#[tokio::test]
|
||||
async fn strategies() {
|
||||
let ipv6: [IpAddr; 4] = [
|
||||
"a:b::1".parse().unwrap(),
|
||||
"a:b::2".parse().unwrap(),
|
||||
"a:b::3".parse().unwrap(),
|
||||
"a:b::4".parse().unwrap(),
|
||||
];
|
||||
let ipv4: [IpAddr; 4] = [
|
||||
"10.0.0.1".parse().unwrap(),
|
||||
"10.0.0.2".parse().unwrap(),
|
||||
"10.0.0.3".parse().unwrap(),
|
||||
"10.0.0.4".parse().unwrap(),
|
||||
];
|
||||
let ipv4_hosts = [
|
||||
"test1.example.com".to_string(),
|
||||
"test2.example.com".to_string(),
|
||||
"test3.example.com".to_string(),
|
||||
"test4.example.com".to_string(),
|
||||
];
|
||||
let ipv6_hosts = [
|
||||
"test5.example.com".to_string(),
|
||||
"test6.example.com".to_string(),
|
||||
"test7.example.com".to_string(),
|
||||
"test8.example.com".to_string(),
|
||||
];
|
||||
|
||||
let mut test = TestServerBuilder::new("smtp_strategies_test")
|
||||
.await
|
||||
.with_http_listener(19016)
|
||||
.await
|
||||
.disable_services()
|
||||
.capture_queue()
|
||||
.build()
|
||||
.await;
|
||||
|
||||
// Add test settings
|
||||
let admin = test.account("admin");
|
||||
admin.mta_no_auth().await;
|
||||
admin
|
||||
.registry_create_object(MtaConnectionStrategy {
|
||||
name: "test".into(),
|
||||
ehlo_hostname: "test.example.com".to_string().into(),
|
||||
connect_timeout: 10_000u64.into(),
|
||||
source_ips: List::from_iter([
|
||||
MtaConnectionIpHost {
|
||||
ehlo_hostname: "test1.example.com".to_string().into(),
|
||||
source_ip: IpAddr::from_str("10.0.0.1").unwrap(),
|
||||
},
|
||||
MtaConnectionIpHost {
|
||||
ehlo_hostname: "test2.example.com".to_string().into(),
|
||||
source_ip: IpAddr::from_str("10.0.0.2").unwrap(),
|
||||
},
|
||||
MtaConnectionIpHost {
|
||||
ehlo_hostname: "test3.example.com".to_string().into(),
|
||||
source_ip: IpAddr::from_str("10.0.0.3").unwrap(),
|
||||
},
|
||||
MtaConnectionIpHost {
|
||||
ehlo_hostname: "test4.example.com".to_string().into(),
|
||||
source_ip: IpAddr::from_str("10.0.0.4").unwrap(),
|
||||
},
|
||||
MtaConnectionIpHost {
|
||||
ehlo_hostname: "test5.example.com".to_string().into(),
|
||||
source_ip: IpAddr::from_str("a:b::1").unwrap(),
|
||||
},
|
||||
MtaConnectionIpHost {
|
||||
ehlo_hostname: "test6.example.com".to_string().into(),
|
||||
source_ip: IpAddr::from_str("a:b::2").unwrap(),
|
||||
},
|
||||
MtaConnectionIpHost {
|
||||
ehlo_hostname: "test7.example.com".to_string().into(),
|
||||
source_ip: IpAddr::from_str("a:b::3").unwrap(),
|
||||
},
|
||||
MtaConnectionIpHost {
|
||||
ehlo_hostname: "test8.example.com".to_string().into(),
|
||||
source_ip: IpAddr::from_str("a:b::4").unwrap(),
|
||||
},
|
||||
]),
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
admin
|
||||
.registry_create_object(MtaRoute::Mx(MtaRouteMx {
|
||||
ip_lookup_strategy: MtaIpStrategy::V4ThenV6,
|
||||
name: "test-v4".into(),
|
||||
..Default::default()
|
||||
}))
|
||||
.await;
|
||||
admin
|
||||
.registry_create_object(MtaRoute::Mx(MtaRouteMx {
|
||||
ip_lookup_strategy: MtaIpStrategy::V6ThenV4,
|
||||
name: "test-v6".into(),
|
||||
..Default::default()
|
||||
}))
|
||||
.await;
|
||||
admin
|
||||
.registry_create_object(MtaOutboundStrategy {
|
||||
schedule: Expression {
|
||||
else_: concat!(
|
||||
"source + ' ' + received_from_ip + ' ' + ",
|
||||
"received_via_port + ' ' + queue_name + ' ' + ",
|
||||
"last_error + ' ' + rcpt_domain + ' ' + size + ' ' + queue_age"
|
||||
)
|
||||
.into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
admin.reload_settings().await;
|
||||
test.reload_core();
|
||||
|
||||
let conn = test
|
||||
.server
|
||||
.core
|
||||
.smtp
|
||||
.queue
|
||||
.connection_strategy
|
||||
.get("test")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(conn.ehlo_hostname.as_ref().unwrap(), "test.example.com");
|
||||
|
||||
for is_ipv4 in [true, false] {
|
||||
for _ in 0..10 {
|
||||
let ip_host = conn.source_ip(is_ipv4).unwrap();
|
||||
if is_ipv4 {
|
||||
assert_eq!(
|
||||
&ipv4_hosts[ipv4
|
||||
.iter()
|
||||
.position(|&ip| ip.into_inner() == ip_host.ip)
|
||||
.unwrap()],
|
||||
ip_host.host.as_ref().unwrap()
|
||||
);
|
||||
} else {
|
||||
assert_eq!(
|
||||
&ipv6_hosts[ipv6
|
||||
.iter()
|
||||
.position(|&ip| ip.into_inner() == ip_host.ip)
|
||||
.unwrap()],
|
||||
ip_host.host.as_ref().unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test strategy resolution
|
||||
let message = Message {
|
||||
created: now() - 123,
|
||||
blob_hash: Default::default(),
|
||||
received_from_ip: "1.2.3.4".parse().unwrap(),
|
||||
received_via_port: 7911,
|
||||
return_path: "[email protected]".into(),
|
||||
recipients: vec![Recipient {
|
||||
address: "[email protected]".into(),
|
||||
retry: Schedule::now(),
|
||||
notify: Schedule::now(),
|
||||
expires: QueueExpiry::Ttl(3600),
|
||||
queue: QueueName::new("test").unwrap(),
|
||||
status: Status::TemporaryFailure(ErrorDetails {
|
||||
entity: "test.example.com".into(),
|
||||
details: Error::TlsError("TLS handshake failed".into()),
|
||||
}),
|
||||
flags: 0,
|
||||
orcpt: None,
|
||||
}],
|
||||
flags: FROM_AUTHENTICATED,
|
||||
env_id: None,
|
||||
priority: 0,
|
||||
size: 978,
|
||||
metadata: Default::default(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
test.server
|
||||
.eval_if::<String, _>(
|
||||
&test.server.core.smtp.queue.queue,
|
||||
&QueueEnvelope::new(&message, &message.recipients[0]),
|
||||
0,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|| "default".to_string()),
|
||||
"authenticated 1.2.3.4 7911 test tls foobar.com 978 123"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_remote_hosts() {
|
||||
let mx: RecordSet<MX> = RecordSet {
|
||||
rrset: Arc::from(vec![
|
||||
MX {
|
||||
exchanges: vec!["mx1".into(), "mx2".into()].into_boxed_slice(),
|
||||
preference: 10,
|
||||
},
|
||||
MX {
|
||||
exchanges: vec!["mx3".into(), "mx4".into(), "mx5".into(), "mx6".into()]
|
||||
.into_boxed_slice(),
|
||||
preference: 20,
|
||||
},
|
||||
MX {
|
||||
exchanges: vec!["mx7".into(), "mx8".into()].into_boxed_slice(),
|
||||
preference: 10,
|
||||
},
|
||||
MX {
|
||||
exchanges: vec!["mx9".into(), "mxA".into()].into_boxed_slice(),
|
||||
preference: 10,
|
||||
},
|
||||
]),
|
||||
dnssec_status: DnssecStatus::Indeterminate,
|
||||
};
|
||||
let mx_config = MxConfig {
|
||||
max_mx: 7,
|
||||
max_multi_homed: 2,
|
||||
ip_lookup_strategy: IpLookupStrategy::Ipv4thenIpv6,
|
||||
};
|
||||
let hosts = mx.to_remote_hosts("domain", &mx_config).unwrap();
|
||||
assert_eq!(hosts.len(), 7);
|
||||
for host in hosts {
|
||||
if let NextHop::MX { host, .. } = host {
|
||||
assert!((*host.as_bytes().last().unwrap() - b'0') <= 8);
|
||||
}
|
||||
}
|
||||
let mx: RecordSet<MX> = RecordSet {
|
||||
rrset: Arc::from(vec![MX {
|
||||
exchanges: vec![".".into()].into_boxed_slice(),
|
||||
preference: 0,
|
||||
}]),
|
||||
dnssec_status: DnssecStatus::Indeterminate,
|
||||
};
|
||||
assert!(mx.to_remote_hosts("domain", &mx_config).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_policy() {
|
||||
for (policy, expected_policy) in [
|
||||
(
|
||||
r"version: STSv1
|
||||
mode: enforce
|
||||
mx: mail.example.com
|
||||
mx: *.example.net
|
||||
mx: backupmx.example.com
|
||||
max_age: 604800",
|
||||
Policy {
|
||||
id: "abc".to_string(),
|
||||
mode: Mode::Enforce,
|
||||
mx: vec![
|
||||
MxPattern::Equals("mail.example.com".to_string()),
|
||||
MxPattern::StartsWith("example.net".to_string()),
|
||||
MxPattern::Equals("backupmx.example.com".to_string()),
|
||||
]
|
||||
.into_boxed_slice(),
|
||||
max_age: 604800,
|
||||
},
|
||||
),
|
||||
(
|
||||
r"version: STSv1
|
||||
mode: testing
|
||||
mx: gmail-smtp-in.l.google.com
|
||||
mx: *.gmail-smtp-in.l.google.com
|
||||
max_age: 86400
|
||||
",
|
||||
Policy {
|
||||
id: "abc".to_string(),
|
||||
mode: Mode::Testing,
|
||||
mx: vec![
|
||||
MxPattern::Equals("gmail-smtp-in.l.google.com".to_string()),
|
||||
MxPattern::StartsWith("gmail-smtp-in.l.google.com".to_string()),
|
||||
]
|
||||
.into_boxed_slice(),
|
||||
max_age: 86400,
|
||||
},
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
Policy::parse(policy, expected_policy.id.to_string()).unwrap(),
|
||||
expected_policy
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregate_to_timestamp() {
|
||||
for (freq, date, expected) in [
|
||||
(
|
||||
AggregateFrequency::Hourly,
|
||||
"2023-01-24T09:10:40Z",
|
||||
"2023-01-24T09:00:00Z",
|
||||
),
|
||||
(
|
||||
AggregateFrequency::Daily,
|
||||
"2023-01-24T09:10:40Z",
|
||||
"2023-01-24T00:00:00Z",
|
||||
),
|
||||
(
|
||||
AggregateFrequency::Weekly,
|
||||
"2023-01-24T09:10:40Z",
|
||||
"2023-01-22T00:00:00Z",
|
||||
),
|
||||
(
|
||||
AggregateFrequency::Weekly,
|
||||
"2023-01-28T23:59:59Z",
|
||||
"2023-01-22T00:00:00Z",
|
||||
),
|
||||
(
|
||||
AggregateFrequency::Weekly,
|
||||
"2023-01-22T23:59:59Z",
|
||||
"2023-01-22T00:00:00Z",
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
DateTime::from_timestamp(
|
||||
freq.to_timestamp_(DateTime::parse_rfc3339(date).unwrap()) as i64
|
||||
)
|
||||
.to_rfc3339(),
|
||||
expected,
|
||||
"failed for {freq:?} {date} {expected}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
pub mod queue;
|
||||
pub mod report;
|
||||
@@ -0,0 +1,625 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
smtp::session::TestSession,
|
||||
utils::{dns::DnsCache, server::TestServerBuilder},
|
||||
};
|
||||
use ahash::{AHashMap, HashMap, HashSet};
|
||||
use mail_auth::{DnssecStatus, MX};
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::NetworkListenerProtocol,
|
||||
prelude::{ObjectType, Property},
|
||||
structs::{
|
||||
Expression, MtaDeliveryExpiration, MtaDeliveryExpirationTtl, MtaDeliverySchedule,
|
||||
MtaDeliveryScheduleInterval, MtaDeliveryScheduleIntervals,
|
||||
MtaDeliveryScheduleIntervalsOrDefault, MtaExtensions, MtaOutboundStrategy,
|
||||
MtaStageRcpt, MtaVirtualQueue, QueueExpiry, QueuedMessage, RecipientStatus,
|
||||
},
|
||||
},
|
||||
types::{EnumImpl, datetime::UTCDateTime, list::List},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::time::{Duration, Instant};
|
||||
use types::id::Id;
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn manage_queue() {
|
||||
let mut local = TestServerBuilder::new("smtp_manage_queue_local")
|
||||
.await
|
||||
.with_http_listener(19049)
|
||||
.await
|
||||
.disable_services()
|
||||
.build()
|
||||
.await;
|
||||
let mut remote = TestServerBuilder::new("smtp_manage_queue_remote")
|
||||
.await
|
||||
.with_dummy_tls_cert(["*.foobar.org"])
|
||||
.await
|
||||
.with_http_listener(19050)
|
||||
.await
|
||||
.with_listener(NetworkListenerProtocol::Smtp, "smtp-debug", 9925, false)
|
||||
.await
|
||||
.disable_services()
|
||||
.capture_queue()
|
||||
.build()
|
||||
.await;
|
||||
|
||||
let remote_admin = remote.account("admin");
|
||||
remote_admin.mta_allow_relaying().await;
|
||||
remote_admin.mta_no_auth().await;
|
||||
remote_admin.mta_allow_non_fqdn().await;
|
||||
remote_admin.reload_settings().await;
|
||||
remote.reload_core();
|
||||
remote.expect_reload_settings().await;
|
||||
|
||||
let admin = local.account("admin");
|
||||
admin
|
||||
.registry_create_object(MtaExtensions {
|
||||
dsn: Expression {
|
||||
else_: "true".into(),
|
||||
..Default::default()
|
||||
},
|
||||
future_release: Expression {
|
||||
else_: "1h".into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
admin
|
||||
.registry_create_object(MtaStageRcpt {
|
||||
max_recipients: Expression {
|
||||
else_: "100".into(),
|
||||
..Default::default()
|
||||
},
|
||||
allow_relaying: Expression {
|
||||
else_: "true".into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
let queue_id = admin
|
||||
.registry_create_object(MtaVirtualQueue {
|
||||
name: "myqueue".into(),
|
||||
threads_per_node: 25,
|
||||
description: None,
|
||||
})
|
||||
.await;
|
||||
admin
|
||||
.registry_create_object(MtaDeliverySchedule {
|
||||
name: "default".into(),
|
||||
retry: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
|
||||
intervals: List::from_iter([MtaDeliveryScheduleInterval {
|
||||
duration: 1_000_000u64.into(),
|
||||
}]),
|
||||
}),
|
||||
notify: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
|
||||
intervals: List::from_iter([MtaDeliveryScheduleInterval {
|
||||
duration: 2_000_000u64.into(),
|
||||
}]),
|
||||
}),
|
||||
expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl {
|
||||
expire: 3_000_000u64.into(),
|
||||
}),
|
||||
queue_id,
|
||||
description: None,
|
||||
})
|
||||
.await;
|
||||
admin
|
||||
.registry_create_object(MtaOutboundStrategy {
|
||||
schedule: Expression {
|
||||
else_: "'default'".into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
admin.mta_no_auth().await;
|
||||
admin.mta_allow_non_fqdn().await;
|
||||
admin.reload_settings().await;
|
||||
local.reload_core();
|
||||
let admin = local.account("admin");
|
||||
|
||||
// Add mock DNS entries
|
||||
local.server.mx_add(
|
||||
"foobar.org",
|
||||
vec![MX {
|
||||
exchanges: vec!["mx1.foobar.org".into()].into_boxed_slice(),
|
||||
preference: 10,
|
||||
}],
|
||||
DnssecStatus::Secure,
|
||||
Instant::now() + Duration::from_secs(10),
|
||||
);
|
||||
|
||||
local.server.ipv4_add(
|
||||
"mx1.foobar.org",
|
||||
vec!["127.0.0.1".parse().unwrap()],
|
||||
Instant::now() + Duration::from_secs(10),
|
||||
);
|
||||
|
||||
// Send test messages
|
||||
let envelopes = HashMap::from_iter([
|
||||
(
|
||||
"a",
|
||||
(
|
||||
"[email protected]",
|
||||
vec![
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
],
|
||||
),
|
||||
),
|
||||
(
|
||||
"b",
|
||||
(
|
||||
"[email protected]",
|
||||
vec!["[email protected]", "[email protected]"],
|
||||
),
|
||||
),
|
||||
(
|
||||
"c",
|
||||
(
|
||||
"[email protected]",
|
||||
vec![
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
],
|
||||
),
|
||||
),
|
||||
("d", ("[email protected]", vec!["[email protected]"])),
|
||||
("e", ("[email protected]", vec!["[email protected]"])),
|
||||
("f", ("", vec!["[email protected]", "[email protected]"])),
|
||||
]);
|
||||
let mut session = local.new_mta_session();
|
||||
session.data.remote_ip_str = "10.0.0.1".into();
|
||||
session.eval_session_params().await;
|
||||
session.ehlo("foobar.net").await;
|
||||
for test_num in 0..6 {
|
||||
let env_id = char::from(b'a' + test_num).to_string();
|
||||
let hold_for = ((test_num + 1) as u32) * 100;
|
||||
let (sender, recipients) = envelopes.get(env_id.as_str()).unwrap();
|
||||
session
|
||||
.send_message(
|
||||
&if env_id != "f" {
|
||||
format!("<{sender}> ENVID={env_id} HOLDFOR={hold_for}")
|
||||
} else {
|
||||
format!("<{sender}> ENVID={env_id}")
|
||||
},
|
||||
recipients,
|
||||
"test:no_dkim",
|
||||
"250",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Expect delivery to [email protected]
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
assert_eq!(
|
||||
remote
|
||||
.consume_message()
|
||||
.await
|
||||
.message
|
||||
.recipients
|
||||
.into_iter()
|
||||
.map(|r| r.address().to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["[email protected]"]
|
||||
);
|
||||
|
||||
// Fetch and validate messages
|
||||
assert_eq!(
|
||||
admin
|
||||
.registry_query_ids(
|
||||
ObjectType::QueuedMessage,
|
||||
Vec::<(&str, &str)>::new(),
|
||||
Vec::<&str>::new()
|
||||
)
|
||||
.await
|
||||
.len(),
|
||||
6
|
||||
);
|
||||
let messages = admin.registry_get_all::<QueuedMessage>().await;
|
||||
assert_eq!(messages.len(), 6);
|
||||
let mut id_map = AHashMap::new();
|
||||
let mut id_map_rev = AHashMap::new();
|
||||
let mut test_search = String::new();
|
||||
for (id, message) in messages {
|
||||
let env_id = message.env_id.as_ref().unwrap().clone();
|
||||
|
||||
// Validate return path and recipients
|
||||
let (sender, recipients) = envelopes.get(env_id.as_str()).unwrap();
|
||||
assert_eq!(
|
||||
&message.return_path,
|
||||
if !sender.is_empty() { sender } else { "<>" }
|
||||
);
|
||||
'outer: for recipient in recipients {
|
||||
for (address, _) in message.recipients.iter() {
|
||||
if address == recipient {
|
||||
continue 'outer;
|
||||
}
|
||||
}
|
||||
panic!("Recipient {recipient} not found in message.");
|
||||
}
|
||||
|
||||
// Validate status and datetimes
|
||||
let created = message.created_at.timestamp();
|
||||
let hold_for = (env_id.as_bytes().first().unwrap() - b'a' + 1) as i64 * 100;
|
||||
let next_retry = created + hold_for;
|
||||
let next_notify = created + 2000 + hold_for;
|
||||
let expires = created + 3000 + hold_for;
|
||||
for (rcpt_address, rcpt) in message.recipients.iter() {
|
||||
if env_id == "c" {
|
||||
let mut dt = rcpt.retry_due;
|
||||
dt.add_seconds(-1);
|
||||
test_search = dt.to_string();
|
||||
}
|
||||
if env_id != "f" {
|
||||
// HOLDFOR messages
|
||||
assert_eq!(rcpt.retry_count, 0);
|
||||
assert_timestamp(rcpt.retry_due.timestamp(), next_retry, "retry", &message);
|
||||
assert_timestamp(rcpt.notify_due.timestamp(), next_notify, "notify", &message);
|
||||
assert_timestamp(
|
||||
match &rcpt.expires {
|
||||
QueueExpiry::Ttl(ttl) => ttl.expires_at.timestamp(),
|
||||
QueueExpiry::Attempts(_) => unreachable!(),
|
||||
},
|
||||
expires,
|
||||
"expires",
|
||||
&message,
|
||||
);
|
||||
assert_eq!(&rcpt.status, &RecipientStatus::Scheduled, "{message:#?}");
|
||||
} else if rcpt_address == "[email protected]" {
|
||||
assert_eq!(rcpt.retry_count, 0);
|
||||
assert!(
|
||||
matches!(&rcpt.status, RecipientStatus::Completed(_)),
|
||||
"{:?}",
|
||||
rcpt.status
|
||||
);
|
||||
} else {
|
||||
assert_eq!(rcpt.retry_count, 1);
|
||||
assert!(
|
||||
matches!(&rcpt.status, RecipientStatus::TemporaryFailure(_)),
|
||||
"{:?}",
|
||||
rcpt.status
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
id_map.insert(env_id.clone(), id);
|
||||
id_map_rev.insert(id, env_id);
|
||||
}
|
||||
assert_eq!(id_map.len(), 6);
|
||||
|
||||
// Test list search
|
||||
for (query, expected_ids) in [
|
||||
(
|
||||
vec![(Property::ReturnPath.as_str(), "[email protected]")],
|
||||
vec!["a"],
|
||||
),
|
||||
(
|
||||
vec![(Property::To.as_str(), "foobar.org")],
|
||||
vec!["d", "e", "f"],
|
||||
),
|
||||
(
|
||||
vec![
|
||||
(Property::ReturnPath.as_str(), "[email protected]"),
|
||||
(Property::To.as_str(), "[email protected]"),
|
||||
],
|
||||
vec!["c"],
|
||||
),
|
||||
(
|
||||
vec![("dueIsLessThan", test_search.as_str())],
|
||||
vec!["a", "b"],
|
||||
),
|
||||
(
|
||||
vec![("dueIsGreaterThan", test_search.as_str())],
|
||||
vec!["d", "e", "f", "c"],
|
||||
),
|
||||
] {
|
||||
let ids = admin
|
||||
.registry_query_ids(ObjectType::QueuedMessage, query.clone(), Vec::<&str>::new())
|
||||
.await;
|
||||
assert_eq!(
|
||||
HashSet::from_iter(ids.iter().map(|id| id_map_rev.get(id).unwrap().as_str())),
|
||||
HashSet::from_iter(expected_ids.into_iter()),
|
||||
"failed for query {query:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// Test pagination (forward and reverse)
|
||||
let asc_order: Vec<Id> = admin
|
||||
.registry_query_paginated(
|
||||
ObjectType::QueuedMessage,
|
||||
"due",
|
||||
true,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.object_ids()
|
||||
.collect();
|
||||
assert_eq!(asc_order.len(), 6, "expected 6 messages, got {asc_order:?}");
|
||||
let desc_order: Vec<Id> = asc_order.iter().rev().copied().collect();
|
||||
|
||||
for chunk_start in [0usize, 2, 4] {
|
||||
let asc = admin
|
||||
.registry_query_paginated(
|
||||
ObjectType::QueuedMessage,
|
||||
"due",
|
||||
true,
|
||||
Some(chunk_start as i32),
|
||||
Some(2),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.object_ids()
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
asc,
|
||||
asc_order[chunk_start..chunk_start + 2],
|
||||
"ascending position={chunk_start} limit=2",
|
||||
);
|
||||
|
||||
let desc = admin
|
||||
.registry_query_paginated(
|
||||
ObjectType::QueuedMessage,
|
||||
"due",
|
||||
false,
|
||||
Some(chunk_start as i32),
|
||||
Some(2),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.object_ids()
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
desc,
|
||||
desc_order[chunk_start..chunk_start + 2],
|
||||
"descending position={chunk_start} limit=2",
|
||||
);
|
||||
}
|
||||
|
||||
for anchor_idx in [1usize, 3] {
|
||||
let asc = admin
|
||||
.registry_query_paginated(
|
||||
ObjectType::QueuedMessage,
|
||||
"due",
|
||||
true,
|
||||
None,
|
||||
Some(2),
|
||||
Some(asc_order[anchor_idx]),
|
||||
Some(1),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.object_ids()
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
asc,
|
||||
asc_order[anchor_idx + 1..anchor_idx + 3],
|
||||
"ascending anchor={} offset=1 limit=2",
|
||||
asc_order[anchor_idx],
|
||||
);
|
||||
|
||||
let desc = admin
|
||||
.registry_query_paginated(
|
||||
ObjectType::QueuedMessage,
|
||||
"due",
|
||||
false,
|
||||
None,
|
||||
Some(2),
|
||||
Some(desc_order[anchor_idx]),
|
||||
Some(1),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.object_ids()
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
desc,
|
||||
desc_order[anchor_idx + 1..anchor_idx + 3],
|
||||
"descending anchor={} offset=1 limit=2",
|
||||
desc_order[anchor_idx],
|
||||
);
|
||||
}
|
||||
|
||||
// Retry delivery
|
||||
admin
|
||||
.registry_update_object(
|
||||
ObjectType::QueuedMessage,
|
||||
id_map["e"],
|
||||
json!({
|
||||
"recipients/[email protected]/retryDue": UTCDateTime::now()
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
admin
|
||||
.registry_update_object(
|
||||
ObjectType::QueuedMessage,
|
||||
id_map["f"],
|
||||
json!({
|
||||
"recipients/[email protected]/retryDue": UTCDateTime::now()
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
admin
|
||||
.registry_update_object(
|
||||
ObjectType::QueuedMessage,
|
||||
id_map["a"],
|
||||
json!({
|
||||
"recipients/[email protected]/retryDue": "2200-01-01T00:00:00Z",
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
// Expect delivery to [email protected]
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
assert_eq!(
|
||||
remote
|
||||
.consume_message()
|
||||
.await
|
||||
.message
|
||||
.recipients
|
||||
.into_iter()
|
||||
.map(|r| r.address().to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["[email protected]".to_string()]
|
||||
);
|
||||
|
||||
// Message 'e' should be gone, 'f' should have retry_count == 2
|
||||
// while 'a' should have a retry time of 2200-01-01T00:00:00Z
|
||||
assert_eq!(
|
||||
admin
|
||||
.registry_get_many(ObjectType::QueuedMessage, [id_map["e"]])
|
||||
.await
|
||||
.not_found()
|
||||
.next()
|
||||
.unwrap(),
|
||||
id_map["e"].to_string()
|
||||
);
|
||||
assert_eq!(
|
||||
admin
|
||||
.registry_get::<QueuedMessage>(id_map["f"])
|
||||
.await
|
||||
.recipients
|
||||
.values()
|
||||
.next()
|
||||
.unwrap()
|
||||
.retry_count,
|
||||
2
|
||||
);
|
||||
for (rcpt_address, rcpt) in admin
|
||||
.registry_get::<QueuedMessage>(id_map["a"])
|
||||
.await
|
||||
.recipients
|
||||
{
|
||||
let next_retry = rcpt.retry_due.to_string();
|
||||
let matched =
|
||||
["2200-01-01T00:00:00Z", "2199-12-31T23:59:59Z"].contains(&next_retry.as_str());
|
||||
if rcpt_address.ends_with("example1.org") {
|
||||
assert!(matched, "{next_retry}");
|
||||
} else {
|
||||
assert!(!matched, "{next_retry}");
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel deliveries
|
||||
for (id, filter) in [
|
||||
("a", &["[email protected]", "[email protected]"][..]),
|
||||
("b", &["[email protected]", "[email protected]"][..]),
|
||||
("c", &["[email protected]"][..]),
|
||||
] {
|
||||
let mut map = serde_json::Map::new();
|
||||
for i in filter {
|
||||
map.insert(format!("recipients/{i}"), serde_json::Value::Null);
|
||||
}
|
||||
|
||||
admin
|
||||
.registry_update_object(
|
||||
ObjectType::QueuedMessage,
|
||||
id_map[id],
|
||||
serde_json::Value::Object(map),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
admin
|
||||
.registry_destroy(ObjectType::QueuedMessage, [id_map["d"]])
|
||||
.await
|
||||
.assert_destroyed(&[id_map["d"]]);
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
assert_eq!(admin.registry_get_all::<QueuedMessage>().await.len(), 3);
|
||||
assert_eq!(
|
||||
admin
|
||||
.registry_query_ids(
|
||||
ObjectType::QueuedMessage,
|
||||
Vec::<(&str, &str)>::new(),
|
||||
Vec::<&str>::new()
|
||||
)
|
||||
.await
|
||||
.len(),
|
||||
3
|
||||
);
|
||||
for id in ["b", "d"] {
|
||||
assert_eq!(
|
||||
admin
|
||||
.registry_get_many(ObjectType::QueuedMessage, [id_map[id]])
|
||||
.await
|
||||
.not_found()
|
||||
.next()
|
||||
.unwrap(),
|
||||
id_map[id].to_string()
|
||||
);
|
||||
}
|
||||
for id in ["a", "c"] {
|
||||
let message = admin.registry_get::<QueuedMessage>(id_map[id]).await;
|
||||
|
||||
assert!(!message.recipients.is_empty());
|
||||
for (rcpt_address, rcpt) in message.recipients {
|
||||
match id {
|
||||
"a" => {
|
||||
if rcpt_address.ends_with("example2.org") {
|
||||
assert!(matches!(&rcpt.status, RecipientStatus::PermanentFailure(_)));
|
||||
} else {
|
||||
assert!(matches!(&rcpt.status, RecipientStatus::Scheduled));
|
||||
}
|
||||
}
|
||||
"c" => {
|
||||
if rcpt_address.ends_with("example2.com") {
|
||||
if rcpt_address == "[email protected]" {
|
||||
assert!(matches!(&rcpt.status, RecipientStatus::PermanentFailure(_)));
|
||||
} else {
|
||||
assert!(matches!(&rcpt.status, RecipientStatus::Scheduled));
|
||||
}
|
||||
} else {
|
||||
assert!(matches!(&rcpt.status, RecipientStatus::Scheduled));
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk cancel
|
||||
admin.registry_destroy_all(ObjectType::QueuedMessage).await;
|
||||
assert_eq!(
|
||||
admin
|
||||
.registry_query_ids(
|
||||
ObjectType::QueuedMessage,
|
||||
Vec::<(&str, &str)>::new(),
|
||||
Vec::<&str>::new()
|
||||
)
|
||||
.await
|
||||
.len(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_timestamp(timestamp: i64, expected: i64, ctx: &str, message: &QueuedMessage) {
|
||||
let diff = timestamp - expected;
|
||||
if ![-2, -1, 0, 1, 2].contains(&diff) {
|
||||
panic!(
|
||||
"Got timestamp {timestamp}, expected {expected} (diff {diff} for {ctx}) for {message:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::utils::server::TestServerBuilder;
|
||||
use ahash::AHashMap;
|
||||
use common::{
|
||||
config::smtp::report::AggregateFrequency,
|
||||
ipc::{DmarcEvent, PolicyType, TlsEvent},
|
||||
};
|
||||
use mail_auth::{
|
||||
common::parse::TxtRecordParser,
|
||||
dmarc::Dmarc,
|
||||
mta_sts::TlsRpt,
|
||||
report::{
|
||||
ActionDisposition, DmarcResult, Record,
|
||||
tlsrpt::{FailureDetails, ResultType},
|
||||
},
|
||||
};
|
||||
use registry::schema::{
|
||||
prelude::{ObjectType, Property},
|
||||
structs::{
|
||||
DmarcInternalReport, DmarcReportSettings, Expression, TlsInternalReport, TlsReportSettings,
|
||||
},
|
||||
};
|
||||
use smtp::reporting::send::MtaReportSend;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn manage_reports() {
|
||||
let mut test = TestServerBuilder::new("smtp_report_manage")
|
||||
.await
|
||||
.with_http_listener(19048)
|
||||
.await
|
||||
.disable_services()
|
||||
.capture_queue()
|
||||
.build()
|
||||
.await;
|
||||
|
||||
let admin = test.account("admin");
|
||||
admin
|
||||
.registry_create_object(TlsReportSettings {
|
||||
max_report_size: Expression {
|
||||
else_: "1024".into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
admin
|
||||
.registry_create_object(DmarcReportSettings {
|
||||
aggregate_max_report_size: Expression {
|
||||
else_: "1024".into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
admin.mta_allow_relaying().await;
|
||||
admin.mta_no_auth().await;
|
||||
admin.mta_allow_non_fqdn().await;
|
||||
admin.reload_settings().await;
|
||||
test.reload_core();
|
||||
test.expect_reload_settings().await;
|
||||
let admin = test.account("admin");
|
||||
|
||||
// Send test reporting events
|
||||
test.server
|
||||
.schedule_report(DmarcEvent {
|
||||
domain: "foobar.org".to_string(),
|
||||
report_record: Record::new()
|
||||
.with_source_ip("192.168.1.2".parse().unwrap())
|
||||
.with_action_disposition(ActionDisposition::Pass)
|
||||
.with_dmarc_dkim_result(DmarcResult::Pass)
|
||||
.with_dmarc_spf_result(DmarcResult::Fail)
|
||||
.with_envelope_from("[email protected]")
|
||||
.with_envelope_to("[email protected]")
|
||||
.with_header_from("[email protected]"),
|
||||
dmarc_record: Arc::new(
|
||||
Dmarc::parse(b"v=DMARC1; p=reject; rua=mailto:[email protected]").unwrap(),
|
||||
),
|
||||
interval: AggregateFrequency::Daily,
|
||||
span_id: 0,
|
||||
})
|
||||
.await;
|
||||
test.server
|
||||
.schedule_report(DmarcEvent {
|
||||
domain: "foobar.net".to_string(),
|
||||
report_record: Record::new()
|
||||
.with_source_ip("a:b:c::e:f".parse().unwrap())
|
||||
.with_action_disposition(ActionDisposition::Reject)
|
||||
.with_dmarc_dkim_result(DmarcResult::Fail)
|
||||
.with_dmarc_spf_result(DmarcResult::Pass),
|
||||
dmarc_record: Arc::new(
|
||||
Dmarc::parse(
|
||||
concat!(
|
||||
"v=DMARC1; p=quarantine; rua=mailto:reports",
|
||||
"@foobar.net,mailto:[email protected]"
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.unwrap(),
|
||||
),
|
||||
interval: AggregateFrequency::Weekly,
|
||||
span_id: 0,
|
||||
})
|
||||
.await;
|
||||
test.server
|
||||
.schedule_report(TlsEvent {
|
||||
domain: "foobar.org".to_string(),
|
||||
policy: PolicyType::None,
|
||||
failure: None,
|
||||
tls_record: Arc::new(
|
||||
TlsRpt::parse(b"v=TLSRPTv1;rua=mailto:[email protected]").unwrap(),
|
||||
),
|
||||
interval: AggregateFrequency::Daily,
|
||||
span_id: 0,
|
||||
})
|
||||
.await;
|
||||
test.server
|
||||
.schedule_report(TlsEvent {
|
||||
domain: "foobar.net".to_string(),
|
||||
policy: PolicyType::Sts(None),
|
||||
failure: FailureDetails::new(ResultType::StsPolicyInvalid).into(),
|
||||
tls_record: Arc::new(
|
||||
TlsRpt::parse(b"v=TLSRPTv1;rua=mailto:[email protected]").unwrap(),
|
||||
),
|
||||
interval: AggregateFrequency::Weekly,
|
||||
span_id: 0,
|
||||
})
|
||||
.await;
|
||||
|
||||
// List DMARC reports
|
||||
let mut dmarc_name_to_id = AHashMap::new();
|
||||
let mut dmarc_id_to_name = AHashMap::new();
|
||||
for (id, report) in admin.registry_get_all::<DmarcInternalReport>().await {
|
||||
let diff =
|
||||
report.report.date_range_end.timestamp() - report.report.date_range_begin.timestamp();
|
||||
if report.domain == "foobar.org" {
|
||||
assert_eq!(diff, 86400);
|
||||
} else {
|
||||
assert_eq!(diff, 7 * 86400);
|
||||
}
|
||||
dmarc_name_to_id.insert(report.domain.clone(), id);
|
||||
dmarc_id_to_name.insert(id, report.domain);
|
||||
}
|
||||
assert_eq!(dmarc_name_to_id.len(), 2);
|
||||
|
||||
// List TLS reports
|
||||
let mut tls_name_to_id = AHashMap::new();
|
||||
let mut tls_id_to_name = AHashMap::new();
|
||||
for (id, report) in admin.registry_get_all::<TlsInternalReport>().await {
|
||||
let diff =
|
||||
report.report.date_range_end.timestamp() - report.report.date_range_start.timestamp();
|
||||
if report.domain == "foobar.org" {
|
||||
assert_eq!(diff, 86400);
|
||||
} else {
|
||||
assert_eq!(diff, 7 * 86400);
|
||||
}
|
||||
tls_name_to_id.insert(report.domain.clone(), id);
|
||||
tls_id_to_name.insert(id, report.domain);
|
||||
}
|
||||
assert_eq!(tls_name_to_id.len(), 2);
|
||||
|
||||
// Test list search
|
||||
for (object, query, expected_ids) in [
|
||||
(
|
||||
ObjectType::DmarcInternalReport,
|
||||
vec![],
|
||||
vec![
|
||||
dmarc_name_to_id["foobar.org"],
|
||||
dmarc_name_to_id["foobar.net"],
|
||||
],
|
||||
),
|
||||
(
|
||||
ObjectType::TlsInternalReport,
|
||||
vec![],
|
||||
vec![tls_name_to_id["foobar.org"], tls_name_to_id["foobar.net"]],
|
||||
),
|
||||
(
|
||||
ObjectType::DmarcInternalReport,
|
||||
vec![(Property::Domain, "foobar.org".to_string())],
|
||||
vec![dmarc_name_to_id["foobar.org"]],
|
||||
),
|
||||
(
|
||||
ObjectType::DmarcInternalReport,
|
||||
vec![(Property::Domain, "foobar.net".to_string())],
|
||||
vec![dmarc_name_to_id["foobar.net"]],
|
||||
),
|
||||
(
|
||||
ObjectType::TlsInternalReport,
|
||||
vec![(Property::Domain, "foobar.org".to_string())],
|
||||
vec![tls_name_to_id["foobar.org"]],
|
||||
),
|
||||
(
|
||||
ObjectType::TlsInternalReport,
|
||||
vec![(Property::Domain, "foobar.net".to_string())],
|
||||
vec![tls_name_to_id["foobar.net"]],
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
admin
|
||||
.registry_query_ids(object, query.clone(), Vec::<&str>::new())
|
||||
.await,
|
||||
expected_ids,
|
||||
"failed for {object:?} with query {query:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// Cancel reports
|
||||
for (object, id) in [
|
||||
(
|
||||
ObjectType::DmarcInternalReport,
|
||||
dmarc_name_to_id["foobar.org"],
|
||||
),
|
||||
(ObjectType::TlsInternalReport, tls_name_to_id["foobar.org"]),
|
||||
] {
|
||||
admin
|
||||
.registry_destroy(object, vec![id])
|
||||
.await
|
||||
.assert_destroyed(&[id]);
|
||||
}
|
||||
for (object, id) in [
|
||||
(
|
||||
ObjectType::DmarcInternalReport,
|
||||
dmarc_name_to_id["foobar.net"],
|
||||
),
|
||||
(ObjectType::TlsInternalReport, tls_name_to_id["foobar.net"]),
|
||||
] {
|
||||
assert_eq!(
|
||||
admin
|
||||
.registry_query_ids(object, Vec::<(&str, &str)>::new(), Vec::<&str>::new())
|
||||
.await,
|
||||
vec![id],
|
||||
"failed for {object:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// Cancel all reports
|
||||
admin
|
||||
.registry_destroy_all(ObjectType::DmarcInternalReport)
|
||||
.await;
|
||||
admin
|
||||
.registry_destroy_all(ObjectType::TlsInternalReport)
|
||||
.await;
|
||||
assert_eq!(
|
||||
admin.registry_get_all::<DmarcInternalReport>().await,
|
||||
Vec::new()
|
||||
);
|
||||
assert_eq!(
|
||||
admin.registry_get_all::<TlsInternalReport>().await,
|
||||
Vec::new()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
pub mod inbound;
|
||||
pub mod lookup;
|
||||
pub mod management;
|
||||
pub mod outbound;
|
||||
pub mod queue;
|
||||
pub mod reporting;
|
||||
pub mod session;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
smtp::{
|
||||
inbound::{TestMessage, TestQueueEvent},
|
||||
session::{TestSession, VerifyResponse},
|
||||
},
|
||||
utils::{dns::DnsCache, server::TestServerBuilder},
|
||||
};
|
||||
use mail_auth::{DnssecStatus, MX};
|
||||
use registry::schema::structs::{Expression, MtaStageData};
|
||||
use smtp_proto::{MAIL_REQUIRETLS, MAIL_RET_HDRS, MAIL_SMTPUTF8, RCPT_NOTIFY_NEVER};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn extensions() {
|
||||
let mut local = TestServerBuilder::new("smtp_ext_local")
|
||||
.await
|
||||
.with_http_listener(19020)
|
||||
.await
|
||||
.disable_services()
|
||||
.capture_queue()
|
||||
.build()
|
||||
.await;
|
||||
let mut remote = TestServerBuilder::new("smtp_ext_remote")
|
||||
.await
|
||||
.with_http_listener(19021)
|
||||
.await
|
||||
.with_smtp_listener(9925)
|
||||
.await
|
||||
.disable_services()
|
||||
.capture_queue()
|
||||
.build()
|
||||
.await;
|
||||
|
||||
let local_admin = local.account("admin");
|
||||
local_admin.mta_allow_relaying().await;
|
||||
local_admin.mta_no_auth().await;
|
||||
local_admin.mta_all_extensions().await;
|
||||
local_admin.reload_settings().await;
|
||||
local.reload_core();
|
||||
local.expect_reload_settings().await;
|
||||
|
||||
let remote_admin = remote.account("admin");
|
||||
remote_admin.mta_all_extensions().await;
|
||||
remote_admin.mta_allow_relaying().await;
|
||||
remote_admin.mta_no_auth().await;
|
||||
remote_admin
|
||||
.registry_create_object(MtaStageData {
|
||||
max_message_size: Expression {
|
||||
else_: "1500".into(),
|
||||
..Default::default()
|
||||
},
|
||||
add_date_header: Expression {
|
||||
else_: "true".into(),
|
||||
..Default::default()
|
||||
},
|
||||
add_message_id_header: Expression {
|
||||
else_: "true".into(),
|
||||
..Default::default()
|
||||
},
|
||||
add_received_header: Expression {
|
||||
else_: "true".into(),
|
||||
..Default::default()
|
||||
},
|
||||
add_received_spf_header: Expression {
|
||||
else_: "true".into(),
|
||||
..Default::default()
|
||||
},
|
||||
add_auth_results_header: Expression {
|
||||
else_: "true".into(),
|
||||
..Default::default()
|
||||
},
|
||||
add_return_path_header: Expression {
|
||||
else_: "false".into(),
|
||||
..Default::default()
|
||||
},
|
||||
enable_spam_filter: Expression {
|
||||
else_: "false".into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
remote_admin.reload_settings().await;
|
||||
remote.reload_core();
|
||||
remote.expect_reload_settings().await;
|
||||
|
||||
// Add mock DNS entries
|
||||
local.server.mx_add(
|
||||
"foobar.org",
|
||||
vec![MX {
|
||||
exchanges: vec!["mx.foobar.org".into()].into_boxed_slice(),
|
||||
preference: 10,
|
||||
}],
|
||||
DnssecStatus::Secure,
|
||||
Instant::now() + Duration::from_secs(10),
|
||||
);
|
||||
local.server.ipv4_add(
|
||||
"mx.foobar.org",
|
||||
vec!["127.0.0.1".parse().unwrap()],
|
||||
Instant::now() + Duration::from_secs(10),
|
||||
);
|
||||
|
||||
let mut session = local.new_mta_session();
|
||||
session.data.remote_ip_str = "10.0.0.1".into();
|
||||
session.eval_session_params().await;
|
||||
session.ehlo("mx.test.org").await;
|
||||
session
|
||||
.send_message(
|
||||
"[email protected]",
|
||||
&["<[email protected]> NOTIFY=SUCCESS,FAILURE"],
|
||||
"test:no_dkim",
|
||||
"250",
|
||||
)
|
||||
.await;
|
||||
local
|
||||
.expect_message_then_deliver()
|
||||
.await
|
||||
.try_deliver(local.server.clone());
|
||||
|
||||
local
|
||||
.expect_message()
|
||||
.await
|
||||
.read_lines(&local)
|
||||
.await
|
||||
.assert_contains("<[email protected]> (delivered to")
|
||||
.assert_contains("Final-Recipient: rfc822;[email protected]")
|
||||
.assert_contains("Action: delivered");
|
||||
local.read_event().await.assert_done();
|
||||
remote
|
||||
.expect_message()
|
||||
.await
|
||||
.read_lines(&remote)
|
||||
.await
|
||||
.assert_contains("using TLSv1.3 with cipher");
|
||||
|
||||
// Test SIZE extension
|
||||
session
|
||||
.send_message("[email protected]", &["[email protected]"], "test:arc", "250")
|
||||
.await;
|
||||
local
|
||||
.expect_message_then_deliver()
|
||||
.await
|
||||
.try_deliver(local.server.clone());
|
||||
local
|
||||
.expect_message()
|
||||
.await
|
||||
.read_lines(&local)
|
||||
.await
|
||||
.assert_contains("<[email protected]> (host 'mx.foobar.org' rejected command 'MAIL FROM:")
|
||||
.assert_contains("Action: failed")
|
||||
.assert_contains("Diagnostic-Code: smtp;552")
|
||||
.assert_contains("Status: 5.3.4");
|
||||
local.read_event().await.assert_done();
|
||||
remote.assert_no_events();
|
||||
|
||||
// Test DSN, SMTPUTF8 and REQUIRETLS extensions
|
||||
session
|
||||
.send_message(
|
||||
"<[email protected]> ENVID=abc123 RET=HDRS REQUIRETLS SMTPUTF8",
|
||||
&["<[email protected]> NOTIFY=NEVER"],
|
||||
"test:no_dkim",
|
||||
"250",
|
||||
)
|
||||
.await;
|
||||
local
|
||||
.expect_message_then_deliver()
|
||||
.await
|
||||
.try_deliver(local.server.clone());
|
||||
local.read_event().await.assert_done();
|
||||
let message = remote.expect_message().await;
|
||||
assert_eq!(message.message.env_id, Some("abc123".into()));
|
||||
assert!((message.message.flags & MAIL_RET_HDRS) != 0);
|
||||
assert!((message.message.flags & MAIL_REQUIRETLS) != 0);
|
||||
assert!((message.message.flags & MAIL_SMTPUTF8) != 0);
|
||||
assert!((message.message.recipients.last().unwrap().flags & RCPT_NOTIFY_NEVER) != 0);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
smtp::session::TestSession,
|
||||
utils::{dns::DnsCache, server::TestServerBuilder},
|
||||
};
|
||||
use mail_auth::{DnssecStatus, MX};
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::MtaProtocol,
|
||||
structs::{
|
||||
Expression, ExpressionMatch, MtaOutboundStrategy, MtaRoute, MtaRouteRelay, MtaStageRcpt,
|
||||
},
|
||||
},
|
||||
types::list::List,
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
use store::write::now;
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn fallback_relay() {
|
||||
let mut local = TestServerBuilder::new("smtp_fallback_local")
|
||||
.await
|
||||
.with_http_listener(19022)
|
||||
.await
|
||||
.disable_services()
|
||||
.capture_queue()
|
||||
.build()
|
||||
.await;
|
||||
let mut remote = TestServerBuilder::new("smtp_fallback_remote")
|
||||
.await
|
||||
.with_http_listener(19023)
|
||||
.await
|
||||
.with_smtp_listener(9925)
|
||||
.await
|
||||
.disable_services()
|
||||
.capture_queue()
|
||||
.build()
|
||||
.await;
|
||||
|
||||
let local_admin = local.account("admin");
|
||||
local_admin
|
||||
.registry_create_object(MtaStageRcpt {
|
||||
max_recipients: Expression {
|
||||
else_: "100".into(),
|
||||
..Default::default()
|
||||
},
|
||||
allow_relaying: Expression {
|
||||
else_: "true".into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
local_admin
|
||||
.registry_create_object(MtaOutboundStrategy {
|
||||
route: Expression {
|
||||
match_: List::from_iter([ExpressionMatch {
|
||||
if_: "retry_num > 0".into(),
|
||||
then: "'fallback'".into(),
|
||||
}]),
|
||||
else_: "'mx'".into(),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
local_admin
|
||||
.registry_create_object(MtaRoute::Relay(MtaRouteRelay {
|
||||
address: "fallback.foobar.org".into(),
|
||||
implicit_tls: false,
|
||||
allow_invalid_certs: true,
|
||||
name: "fallback".into(),
|
||||
port: 9925,
|
||||
protocol: MtaProtocol::Smtp,
|
||||
..Default::default()
|
||||
}))
|
||||
.await;
|
||||
local_admin.mta_no_auth().await;
|
||||
local_admin.mta_all_extensions().await;
|
||||
local_admin.reload_settings().await;
|
||||
local.reload_core();
|
||||
local.expect_reload_settings().await;
|
||||
|
||||
let remote_admin = remote.account("admin");
|
||||
remote_admin.mta_allow_relaying().await;
|
||||
remote_admin.mta_no_auth().await;
|
||||
remote_admin.mta_all_extensions().await;
|
||||
remote_admin.mta_allow_non_fqdn().await;
|
||||
remote_admin.reload_settings().await;
|
||||
remote.reload_core();
|
||||
remote.expect_reload_settings().await;
|
||||
|
||||
// Add mock DNS entries
|
||||
local.server.mx_add(
|
||||
"foobar.org",
|
||||
vec![MX {
|
||||
exchanges: vec!["_dns_error.foobar.org".into()].into_boxed_slice(),
|
||||
preference: 10,
|
||||
}],
|
||||
DnssecStatus::Secure,
|
||||
Instant::now() + Duration::from_secs(10),
|
||||
);
|
||||
local.server.ipv4_add(
|
||||
"fallback.foobar.org",
|
||||
vec!["127.0.0.1".parse().unwrap()],
|
||||
Instant::now() + Duration::from_secs(10),
|
||||
);
|
||||
|
||||
let mut session = local.new_mta_session();
|
||||
session.data.remote_ip_str = "10.0.0.1".into();
|
||||
session.eval_session_params().await;
|
||||
session.ehlo("mx.test.org").await;
|
||||
session
|
||||
.send_message("[email protected]", &["[email protected]"], "test:no_dkim", "250")
|
||||
.await;
|
||||
local
|
||||
.expect_message_then_deliver()
|
||||
.await
|
||||
.try_deliver(local.server.clone());
|
||||
let mut retry = local.expect_message().await;
|
||||
let prev_due = retry.message.recipients[0].retry.due;
|
||||
let next_due = now();
|
||||
let queue_id = retry.queue_id;
|
||||
retry.message.recipients[0].retry.due = next_due;
|
||||
retry.save_changes(&local.server, prev_due.into()).await;
|
||||
local
|
||||
.delivery_attempt(queue_id)
|
||||
.await
|
||||
.try_deliver(local.server.clone());
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
remote.expect_message().await;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
smtp::session::TestSession,
|
||||
utils::{dns::DnsCache, server::TestServerBuilder},
|
||||
};
|
||||
use mail_auth::{DnssecStatus, MX};
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::MtaIpStrategy,
|
||||
prelude::{ObjectType, Property},
|
||||
structs::MtaRoute,
|
||||
},
|
||||
types::EnumImpl,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn ip_lookup_strategy() {
|
||||
let mut local = TestServerBuilder::new("smtp_iplookup_local")
|
||||
.await
|
||||
.with_http_listener(19024)
|
||||
.await
|
||||
.disable_services()
|
||||
.capture_queue()
|
||||
.build()
|
||||
.await;
|
||||
let mut remote = TestServerBuilder::new("smtp_iplookup_remote")
|
||||
.await
|
||||
.with_http_listener(19025)
|
||||
.await
|
||||
.with_smtp_listener(9925)
|
||||
.await
|
||||
.disable_services()
|
||||
.capture_queue()
|
||||
.build()
|
||||
.await;
|
||||
|
||||
let local_admin = local.account("admin");
|
||||
local_admin.mta_allow_relaying().await;
|
||||
local_admin.mta_no_auth().await;
|
||||
local_admin.reload_settings().await;
|
||||
let (mx_route_id, _) = local_admin
|
||||
.registry_get_all::<MtaRoute>()
|
||||
.await
|
||||
.into_iter()
|
||||
.find(|(_, s)| matches!(s, MtaRoute::Mx(_)))
|
||||
.unwrap();
|
||||
local.reload_core();
|
||||
local.expect_reload_settings().await;
|
||||
|
||||
let remote_admin = remote.account("admin");
|
||||
remote_admin.mta_allow_relaying().await;
|
||||
remote_admin.mta_no_auth().await;
|
||||
remote_admin.reload_settings().await;
|
||||
remote.reload_core();
|
||||
remote.expect_reload_settings().await;
|
||||
|
||||
for strategy in [MtaIpStrategy::V6Only, MtaIpStrategy::V6ThenV4] {
|
||||
local
|
||||
.account("admin")
|
||||
.registry_update_object(
|
||||
ObjectType::MtaRoute,
|
||||
mx_route_id,
|
||||
json!({
|
||||
Property::IpLookupStrategy: strategy.as_str(),
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
local.account("admin").reload_settings().await;
|
||||
local.expect_reload_settings().await;
|
||||
|
||||
println!("-> Strategy: {:?}", strategy);
|
||||
// Add mock DNS entries
|
||||
local.server.mx_add(
|
||||
"foobar.org",
|
||||
vec![MX {
|
||||
exchanges: vec!["mx.foobar.org".into()].into_boxed_slice(),
|
||||
preference: 10,
|
||||
}],
|
||||
DnssecStatus::Secure,
|
||||
Instant::now() + Duration::from_secs(10),
|
||||
);
|
||||
if matches!(strategy, MtaIpStrategy::V6ThenV4) {
|
||||
local.server.ipv4_add(
|
||||
"mx.foobar.org",
|
||||
vec!["127.0.0.1".parse().unwrap()],
|
||||
Instant::now() + Duration::from_secs(10),
|
||||
);
|
||||
}
|
||||
local.server.ipv6_add(
|
||||
"mx.foobar.org",
|
||||
vec!["::1".parse().unwrap()],
|
||||
Instant::now() + Duration::from_secs(10),
|
||||
);
|
||||
|
||||
// Retry on failed STARTTLS
|
||||
let mut session = local.new_mta_session();
|
||||
session.data.remote_ip_str = "10.0.0.1".into();
|
||||
session.eval_session_params().await;
|
||||
session.ehlo("mx.test.org").await;
|
||||
session
|
||||
.send_message("[email protected]", &["[email protected]"], "test:no_dkim", "250")
|
||||
.await;
|
||||
local
|
||||
.expect_message_then_deliver()
|
||||
.await
|
||||
.try_deliver(local.server.clone());
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
if matches!(strategy, MtaIpStrategy::V6ThenV4) {
|
||||
remote.expect_message().await;
|
||||
} else {
|
||||
let message = local.last_queued_message().await;
|
||||
let status = message.message.recipients[0].status.to_string();
|
||||
assert!(
|
||||
status.contains("Connection refused"),
|
||||
"Message: {:?}",
|
||||
message
|
||||
);
|
||||
local.expect_refresh().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
smtp::{
|
||||
inbound::TestMessage,
|
||||
session::{TestSession, VerifyResponse},
|
||||
},
|
||||
utils::{dns::DnsCache, server::TestServerBuilder},
|
||||
};
|
||||
use common::{config::smtp::queue::QueueName, ipc::QueueEvent};
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::{MtaProtocol, NetworkListenerProtocol},
|
||||
structs::{
|
||||
Expression, ExpressionMatch, MtaConnectionStrategy, MtaDeliveryExpiration,
|
||||
MtaDeliveryExpirationTtl, MtaDeliverySchedule, MtaDeliveryScheduleInterval,
|
||||
MtaDeliveryScheduleIntervals, MtaDeliveryScheduleIntervalsOrDefault,
|
||||
MtaOutboundStrategy, MtaRoute, MtaRouteRelay, MtaStageRcpt, MtaVirtualQueue,
|
||||
},
|
||||
},
|
||||
types::list::List,
|
||||
};
|
||||
use smtp::queue::spool::{QUEUE_REFRESH, SmtpSpool};
|
||||
use std::time::{Duration, Instant};
|
||||
use store::write::now;
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn lmtp_delivery() {
|
||||
let mut local = TestServerBuilder::new("lmtp_delivery_local")
|
||||
.await
|
||||
.with_http_listener(19026)
|
||||
.await
|
||||
.disable_services()
|
||||
.capture_queue()
|
||||
.build()
|
||||
.await;
|
||||
let mut remote = TestServerBuilder::new("lmtp_delivery_remote")
|
||||
.await
|
||||
.with_http_listener(19027)
|
||||
.await
|
||||
.with_listener(NetworkListenerProtocol::Lmtp, "lmtp-debug", 9924, true)
|
||||
.await
|
||||
.disable_services()
|
||||
.capture_queue()
|
||||
.build()
|
||||
.await;
|
||||
|
||||
let local_admin = local.account("admin");
|
||||
local_admin
|
||||
.registry_create_object(MtaStageRcpt {
|
||||
max_recipients: Expression {
|
||||
else_: "100".into(),
|
||||
|
||||
..Default::default()
|
||||
},
|
||||
allow_relaying: Expression {
|
||||
else_: "true".into(),
|
||||
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
local_admin
|
||||
.registry_create_object(MtaOutboundStrategy {
|
||||
route: Expression {
|
||||
match_: List::from_iter([ExpressionMatch {
|
||||
if_: "rcpt_domain = 'foobar.org'".into(),
|
||||
then: "'lmtp'".into(),
|
||||
}]),
|
||||
else_: "'mx'".into(),
|
||||
},
|
||||
schedule: Expression {
|
||||
match_: List::from_iter([ExpressionMatch {
|
||||
if_: "rcpt_domain = 'foobar.org'".into(),
|
||||
then: "'foobar'".into(),
|
||||
}]),
|
||||
else_: "'default'".into(),
|
||||
},
|
||||
connection: Expression {
|
||||
else_: "'impatient'".into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
local_admin
|
||||
.registry_create_object(MtaRoute::Relay(MtaRouteRelay {
|
||||
address: "lmtp.foobar.org".into(),
|
||||
allow_invalid_certs: true,
|
||||
implicit_tls: true,
|
||||
name: "lmtp".into(),
|
||||
port: 9924,
|
||||
protocol: MtaProtocol::Lmtp,
|
||||
..Default::default()
|
||||
}))
|
||||
.await;
|
||||
let queue_id = local_admin
|
||||
.registry_create_object(MtaVirtualQueue {
|
||||
name: "default".into(),
|
||||
threads_per_node: 25,
|
||||
description: None,
|
||||
})
|
||||
.await;
|
||||
local_admin
|
||||
.registry_create_object(MtaDeliverySchedule {
|
||||
name: "foobar".into(),
|
||||
retry: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
|
||||
intervals: List::from_iter([MtaDeliveryScheduleInterval {
|
||||
duration: 1_000u64.into(),
|
||||
}]),
|
||||
}),
|
||||
notify: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
|
||||
intervals: List::from_iter([
|
||||
MtaDeliveryScheduleInterval {
|
||||
duration: 1_000u64.into(),
|
||||
},
|
||||
MtaDeliveryScheduleInterval {
|
||||
duration: 2_000u64.into(),
|
||||
},
|
||||
]),
|
||||
}),
|
||||
expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl {
|
||||
expire: 4_000u64.into(),
|
||||
}),
|
||||
queue_id,
|
||||
description: None,
|
||||
})
|
||||
.await;
|
||||
local_admin
|
||||
.registry_create_object(MtaDeliverySchedule {
|
||||
name: "default".into(),
|
||||
retry: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
|
||||
intervals: List::from_iter([MtaDeliveryScheduleInterval {
|
||||
duration: 1_000u64.into(),
|
||||
}]),
|
||||
}),
|
||||
notify: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
|
||||
intervals: List::from_iter([MtaDeliveryScheduleInterval {
|
||||
duration: 1_000u64.into(),
|
||||
}]),
|
||||
}),
|
||||
expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl {
|
||||
expire: 5_000u64.into(),
|
||||
}),
|
||||
queue_id,
|
||||
description: None,
|
||||
})
|
||||
.await;
|
||||
local_admin
|
||||
.registry_create_object(MtaConnectionStrategy {
|
||||
name: "impatient".into(),
|
||||
connect_timeout: 1_000u64.into(),
|
||||
data_timeout: 50u64.into(),
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
local_admin.mta_no_auth().await;
|
||||
local_admin.mta_all_extensions().await;
|
||||
local_admin.reload_settings().await;
|
||||
local.reload_core();
|
||||
local.expect_reload_settings().await;
|
||||
|
||||
let remote_admin = remote.account("admin");
|
||||
remote_admin.mta_allow_relaying().await;
|
||||
remote_admin.mta_no_auth().await;
|
||||
remote_admin.mta_all_extensions().await;
|
||||
remote_admin.reload_settings().await;
|
||||
remote.reload_core();
|
||||
remote.expect_reload_settings().await;
|
||||
|
||||
// Add mock DNS entries
|
||||
local.server.ipv4_add(
|
||||
"lmtp.foobar.org",
|
||||
vec!["127.0.0.1".parse().unwrap()],
|
||||
Instant::now() + Duration::from_secs(10),
|
||||
);
|
||||
|
||||
let mut session = local.new_mta_session();
|
||||
session.data.remote_ip_str = "10.0.0.1".into();
|
||||
session.eval_session_params().await;
|
||||
session.ehlo("mx.test.org").await;
|
||||
session
|
||||
.send_message(
|
||||
"[email protected]",
|
||||
&[
|
||||
"<[email protected]> NOTIFY=SUCCESS,DELAY,FAILURE",
|
||||
"<[email protected]> NOTIFY=SUCCESS,DELAY,FAILURE",
|
||||
"<[email protected]> NOTIFY=SUCCESS,DELAY,FAILURE",
|
||||
"<[email protected]> NOTIFY=SUCCESS,DELAY,FAILURE",
|
||||
"<[email protected]> NOTIFY=SUCCESS,DELAY,FAILURE",
|
||||
"<[email protected]> NOTIFY=SUCCESS,DELAY,FAILURE",
|
||||
],
|
||||
"test:no_dkim",
|
||||
"250",
|
||||
)
|
||||
.await;
|
||||
local
|
||||
.expect_message_for_queue_then_deliver("default")
|
||||
.await
|
||||
.try_deliver(local.server.clone());
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
let mut dsn = Vec::new();
|
||||
loop {
|
||||
match local.try_read_event().await {
|
||||
Some(QueueEvent::Refresh | QueueEvent::WorkerDone { .. }) => {}
|
||||
Some(QueueEvent::Paused(_)) | Some(QueueEvent::ReloadSettings) => unreachable!(),
|
||||
None | Some(QueueEvent::Stop) => break,
|
||||
}
|
||||
|
||||
let mut events = local.all_queued_messages().await;
|
||||
if events.messages.is_empty() {
|
||||
let now = now();
|
||||
if events.next_refresh < now + QUEUE_REFRESH {
|
||||
tokio::time::sleep(Duration::from_secs(events.next_refresh - now)).await;
|
||||
events = local.all_queued_messages().await;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
for event in events.messages {
|
||||
let message = local
|
||||
.server
|
||||
.read_message(event.queue_id, QueueName::default())
|
||||
.await
|
||||
.unwrap();
|
||||
if message.message.return_path.is_empty() {
|
||||
message
|
||||
.clone()
|
||||
.remove(&local.server, event.due.into())
|
||||
.await;
|
||||
dsn.push(message);
|
||||
} else {
|
||||
event.try_deliver(local.server.clone());
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
local.assert_queue_is_empty().await;
|
||||
assert_eq!(dsn.len(), 4);
|
||||
|
||||
let mut dsn = dsn.into_iter();
|
||||
|
||||
dsn.next()
|
||||
.unwrap()
|
||||
.read_lines(&local)
|
||||
.await
|
||||
.assert_contains("<[email protected]> (delivered to")
|
||||
.assert_contains("<[email protected]> (delivered to")
|
||||
.assert_contains("<[email protected]> (delivered to")
|
||||
.assert_contains("<[email protected]> (failed to lookup")
|
||||
.assert_contains("<[email protected]> (host 'lmtp.foobar.org' rejected command");
|
||||
|
||||
dsn.next()
|
||||
.unwrap()
|
||||
.read_lines(&local)
|
||||
.await
|
||||
.assert_contains("<[email protected]> (host 'lmtp.foobar.org' rejected")
|
||||
.assert_contains("Action: delayed");
|
||||
|
||||
dsn.next()
|
||||
.unwrap()
|
||||
.read_lines(&local)
|
||||
.await
|
||||
.assert_contains("<[email protected]> (host 'lmtp.foobar.org' rejected")
|
||||
.assert_contains("Action: delayed");
|
||||
|
||||
dsn.next()
|
||||
.unwrap()
|
||||
.read_lines(&local)
|
||||
.await
|
||||
.assert_contains("<[email protected]> (host 'lmtp.foobar.org' rejected")
|
||||
.assert_contains("Action: failed");
|
||||
|
||||
assert_eq!(
|
||||
remote
|
||||
.expect_message()
|
||||
.await
|
||||
.message
|
||||
.recipients
|
||||
.into_iter()
|
||||
.map(|r| r.address().to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
"[email protected]".to_string(),
|
||||
"[email protected]".to_string(),
|
||||
"[email protected]".to_string()
|
||||
]
|
||||
);
|
||||
remote.assert_no_events();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
pub mod dane;
|
||||
pub mod extensions;
|
||||
pub mod fallback_relay;
|
||||
pub mod ip_lookup;
|
||||
pub mod lmtp;
|
||||
pub mod mta_sts;
|
||||
pub mod smtp;
|
||||
pub mod throttle;
|
||||
pub mod tls;
|
||||
@@ -0,0 +1,366 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
smtp::{
|
||||
inbound::{TestMessage, TestQueueEvent, TestReportingEvent},
|
||||
session::{TestSession, VerifyResponse},
|
||||
},
|
||||
utils::{dns::DnsCache, server::TestServerBuilder},
|
||||
};
|
||||
use common::{config::smtp::resolver::Policy, ipc::PolicyType};
|
||||
use mail_auth::{
|
||||
DnssecStatus, MX,
|
||||
common::parse::TxtRecordParser,
|
||||
mta_sts::{MtaSts, ReportUri, TlsRpt},
|
||||
report::tlsrpt::ResultType,
|
||||
};
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::{MtaRequiredOrOptional, NetworkListenerProtocol},
|
||||
prelude::ObjectType,
|
||||
structs::{Expression, MtaTlsStrategy, NetworkListener, TlsReportSettings},
|
||||
},
|
||||
types::{map::Map, socketaddr::SocketAddr},
|
||||
};
|
||||
use smtp::outbound::mta_sts::{lookup::STS_TEST_POLICY, parse::ParsePolicy};
|
||||
use std::{
|
||||
str::FromStr,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn mta_sts_verify() {
|
||||
let mut local = TestServerBuilder::new("smtp_mta_sts_local")
|
||||
.await
|
||||
.with_http_listener(19028)
|
||||
.await
|
||||
.disable_services()
|
||||
.capture_queue()
|
||||
.capture_reporting()
|
||||
.build()
|
||||
.await;
|
||||
let mut remote = TestServerBuilder::new("smtp_mta_sts_remote")
|
||||
.await
|
||||
.with_http_listener(19029)
|
||||
.await
|
||||
.with_smtp_listener(9925)
|
||||
.await
|
||||
.with_dummy_tls_cert(["*.foobar.org"])
|
||||
.await
|
||||
.disable_services()
|
||||
.capture_queue()
|
||||
.build()
|
||||
.await;
|
||||
|
||||
let local_admin = local.account("admin");
|
||||
local_admin.mta_allow_relaying().await;
|
||||
local_admin.mta_no_auth().await;
|
||||
local_admin
|
||||
.registry_create_object(TlsReportSettings {
|
||||
send_frequency: Expression {
|
||||
else_: "weekly".into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
let (tls_strategy_id, mut tls_strategy) = local_admin
|
||||
.registry_get_all::<MtaTlsStrategy>()
|
||||
.await
|
||||
.into_iter()
|
||||
.find(|(_, s)| s.name == "default")
|
||||
.unwrap();
|
||||
tls_strategy.mta_sts = MtaRequiredOrOptional::Require;
|
||||
tls_strategy.allow_invalid_certs = false;
|
||||
let mut tls_strategy = serde_json::to_value(tls_strategy).unwrap();
|
||||
tls_strategy
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.retain(|k, _| k != "name");
|
||||
local_admin
|
||||
.registry_update_object(ObjectType::MtaTlsStrategy, tls_strategy_id, tls_strategy)
|
||||
.await;
|
||||
local_admin.reload_settings().await;
|
||||
local.reload_core();
|
||||
local.expect_reload_settings().await;
|
||||
|
||||
let remote_admin = remote.account("admin");
|
||||
remote_admin.mta_no_auth().await;
|
||||
remote_admin.mta_allow_relaying().await;
|
||||
remote_admin.mta_allow_non_fqdn().await;
|
||||
remote_admin.mta_add_all_headers().await;
|
||||
remote_admin.reload_settings().await;
|
||||
remote.reload_core();
|
||||
remote.expect_reload_settings().await;
|
||||
|
||||
// Add mock DNS entries
|
||||
local.server.mx_add(
|
||||
"foobar.org",
|
||||
vec![MX {
|
||||
exchanges: vec!["mx.foobar.org".into()].into_boxed_slice(),
|
||||
preference: 10,
|
||||
}],
|
||||
DnssecStatus::Secure,
|
||||
Instant::now() + Duration::from_secs(10),
|
||||
);
|
||||
local.server.ipv4_add(
|
||||
"mx.foobar.org",
|
||||
vec!["127.0.0.1".parse().unwrap()],
|
||||
Instant::now() + Duration::from_secs(10),
|
||||
);
|
||||
local.server.txt_add(
|
||||
"_smtp._tls.foobar.org",
|
||||
TlsRpt::parse(b"v=TLSRPTv1; rua=mailto:[email protected]").unwrap(),
|
||||
Instant::now() + Duration::from_secs(10),
|
||||
);
|
||||
|
||||
let mut session = local.new_mta_session();
|
||||
session.data.remote_ip_str = "10.0.0.1".into();
|
||||
session.eval_session_params().await;
|
||||
session.ehlo("mx.test.org").await;
|
||||
session
|
||||
.send_message("[email protected]", &["[email protected]"], "test:no_dkim", "250")
|
||||
.await;
|
||||
local
|
||||
.expect_message_then_deliver()
|
||||
.await
|
||||
.try_deliver(local.server.clone());
|
||||
local
|
||||
.expect_message()
|
||||
.await
|
||||
.read_lines(&local)
|
||||
.await
|
||||
.assert_contains("<[email protected]> (MTA-STS failed to authenticate")
|
||||
.assert_contains("Record not =")
|
||||
.assert_contains("found");
|
||||
local.read_event().await.assert_done();
|
||||
|
||||
// Expect TLS failure report
|
||||
let report = local.read_report().await.unwrap_tls();
|
||||
assert_eq!(report.domain, "foobar.org");
|
||||
assert_eq!(report.policy, PolicyType::Sts(None));
|
||||
assert_eq!(
|
||||
report.failure.as_ref().unwrap().result_type,
|
||||
ResultType::Other
|
||||
);
|
||||
assert_eq!(
|
||||
report.tls_record.rua,
|
||||
vec![ReportUri::Mail("[email protected]".to_string())]
|
||||
);
|
||||
|
||||
// MTA-STS policy fetch failure
|
||||
local.server.txt_add(
|
||||
"_mta-sts.foobar.org",
|
||||
MtaSts::parse(b"v=STSv1; id=policy_will_fail;").unwrap(),
|
||||
Instant::now() + Duration::from_secs(10),
|
||||
);
|
||||
session
|
||||
.send_message("[email protected]", &["[email protected]"], "test:no_dkim", "250")
|
||||
.await;
|
||||
local
|
||||
.expect_message_then_deliver()
|
||||
.await
|
||||
.try_deliver(local.server.clone());
|
||||
local
|
||||
.expect_message()
|
||||
.await
|
||||
.read_lines(&local)
|
||||
.await
|
||||
.assert_contains("<[email protected]> (MTA-STS failed to authenticate")
|
||||
.assert_contains("No 'mx' entries found");
|
||||
local.read_event().await.assert_done();
|
||||
|
||||
// Expect TLS failure report
|
||||
let report = local.read_report().await.unwrap_tls();
|
||||
assert_eq!(report.policy, PolicyType::Sts(None));
|
||||
assert_eq!(
|
||||
report.failure.as_ref().unwrap().result_type,
|
||||
ResultType::StsPolicyInvalid
|
||||
);
|
||||
|
||||
// MTA-STS policy does not authorize mx.foobar.org
|
||||
let policy = concat!(
|
||||
"version: STSv1\n",
|
||||
"mode: enforce\n",
|
||||
"mx: mail.foobar.net\n",
|
||||
"max_age: 604800\n"
|
||||
);
|
||||
STS_TEST_POLICY.lock().extend_from_slice(policy.as_bytes());
|
||||
session
|
||||
.send_message("[email protected]", &["[email protected]"], "test:no_dkim", "250")
|
||||
.await;
|
||||
local
|
||||
.expect_message_then_deliver()
|
||||
.await
|
||||
.try_deliver(local.server.clone());
|
||||
local
|
||||
.expect_message()
|
||||
.await
|
||||
.read_lines(&local)
|
||||
.await
|
||||
.assert_contains("<[email protected]> (MTA-STS failed to authenticate")
|
||||
.assert_contains("not authorized by policy");
|
||||
local.read_event().await.assert_done();
|
||||
|
||||
// Expect TLS failure report
|
||||
let report = local.read_report().await.unwrap_tls();
|
||||
assert_eq!(
|
||||
report.policy,
|
||||
PolicyType::Sts(
|
||||
Arc::new(Policy::parse(policy, "policy_will_fail".to_string()).unwrap()).into()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
report.failure.as_ref().unwrap().receiving_mx_hostname,
|
||||
Some("mx.foobar.org".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
report.failure.as_ref().unwrap().result_type,
|
||||
ResultType::ValidationFailure
|
||||
);
|
||||
remote.assert_no_events();
|
||||
|
||||
// MTA-STS successful validation
|
||||
local.server.txt_add(
|
||||
"_mta-sts.foobar.org",
|
||||
MtaSts::parse(b"v=STSv1; id=policy_will_work;").unwrap(),
|
||||
Instant::now() + Duration::from_secs(10),
|
||||
);
|
||||
let policy = concat!(
|
||||
"version: STSv1\n",
|
||||
"mode: enforce\n",
|
||||
"mx: *.foobar.org\n",
|
||||
"max_age: 604800\n"
|
||||
);
|
||||
STS_TEST_POLICY.lock().clear();
|
||||
STS_TEST_POLICY.lock().extend_from_slice(policy.as_bytes());
|
||||
session
|
||||
.send_message("[email protected]", &["[email protected]"], "test:no_dkim", "250")
|
||||
.await;
|
||||
local
|
||||
.expect_message_then_deliver()
|
||||
.await
|
||||
.try_deliver(local.server.clone());
|
||||
local.read_event().await.assert_done();
|
||||
remote
|
||||
.expect_message()
|
||||
.await
|
||||
.read_lines(&remote)
|
||||
.await
|
||||
.assert_contains("using TLSv1.3 with cipher");
|
||||
|
||||
// Expect TLS success report
|
||||
let report = local.read_report().await.unwrap_tls();
|
||||
assert_eq!(
|
||||
report.policy,
|
||||
PolicyType::Sts(
|
||||
Arc::new(Policy::parse(policy, "policy_will_work".to_string()).unwrap()).into()
|
||||
)
|
||||
);
|
||||
assert!(report.failure.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn mta_sts_testing_mode_does_not_enforce_tls() {
|
||||
let mut local = TestServerBuilder::new("smtp_mta_sts_testing_local")
|
||||
.await
|
||||
.with_http_listener(19051)
|
||||
.await
|
||||
.disable_services()
|
||||
.capture_queue()
|
||||
.build()
|
||||
.await;
|
||||
let mut remote = TestServerBuilder::new("smtp_mta_sts_testing_remote")
|
||||
.await
|
||||
.with_http_listener(19052)
|
||||
.await
|
||||
.with_object(NetworkListener {
|
||||
bind: Map::new(vec![SocketAddr::from_str("0.0.0.0:9925").unwrap()]),
|
||||
name: "smtp".to_string(),
|
||||
protocol: NetworkListenerProtocol::Smtp,
|
||||
use_tls: false,
|
||||
tls_implicit: false,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.disable_services()
|
||||
.capture_queue()
|
||||
.build()
|
||||
.await;
|
||||
|
||||
let local_admin = local.account("admin");
|
||||
local_admin.mta_allow_relaying().await;
|
||||
local_admin.mta_no_auth().await;
|
||||
local_admin.reload_settings().await;
|
||||
local.reload_core();
|
||||
local.expect_reload_settings().await;
|
||||
|
||||
let remote_admin = remote.account("admin");
|
||||
remote_admin.mta_no_auth().await;
|
||||
remote_admin.mta_allow_relaying().await;
|
||||
remote_admin.mta_allow_non_fqdn().await;
|
||||
remote_admin.mta_add_all_headers().await;
|
||||
remote_admin.reload_settings().await;
|
||||
remote.reload_core();
|
||||
remote.expect_reload_settings().await;
|
||||
|
||||
local.server.mx_add(
|
||||
"foobar.org",
|
||||
vec![MX {
|
||||
exchanges: vec!["mx.foobar.org".into()].into_boxed_slice(),
|
||||
preference: 10,
|
||||
}],
|
||||
DnssecStatus::Secure,
|
||||
Instant::now() + Duration::from_secs(10),
|
||||
);
|
||||
local.server.ipv4_add(
|
||||
"mx.foobar.org",
|
||||
vec!["127.0.0.1".parse().unwrap()],
|
||||
Instant::now() + Duration::from_secs(10),
|
||||
);
|
||||
local.server.txt_add(
|
||||
"_mta-sts.foobar.org",
|
||||
MtaSts::parse(b"v=STSv1; id=policy_in_testing;").unwrap(),
|
||||
Instant::now() + Duration::from_secs(10),
|
||||
);
|
||||
|
||||
STS_TEST_POLICY.lock().clear();
|
||||
STS_TEST_POLICY.lock().extend_from_slice(
|
||||
concat!(
|
||||
"version: STSv1\n",
|
||||
"mode: testing\n",
|
||||
"mx: *.foobar.org\n",
|
||||
"max_age: 604800\n"
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
|
||||
let mut session = local.new_mta_session();
|
||||
session.data.remote_ip_str = "10.0.0.1".into();
|
||||
session.eval_session_params().await;
|
||||
session.ehlo("mx.test.org").await;
|
||||
session
|
||||
.send_message("[email protected]", &["[email protected]"], "test:no_dkim", "250")
|
||||
.await;
|
||||
local
|
||||
.expect_message_then_deliver()
|
||||
.await
|
||||
.try_deliver(local.server.clone());
|
||||
local.read_event().await.assert_done();
|
||||
|
||||
remote
|
||||
.expect_message()
|
||||
.await
|
||||
.read_lines(&remote)
|
||||
.await
|
||||
.assert_not_contains("using TLSv1.3 with cipher");
|
||||
|
||||
STS_TEST_POLICY.lock().clear();
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
smtp::{
|
||||
inbound::{TestMessage, TestQueueEvent},
|
||||
session::{TestSession, VerifyResponse},
|
||||
},
|
||||
utils::{dns::DnsCache, server::TestServerBuilder},
|
||||
};
|
||||
use common::{config::smtp::queue::QueueName, ipc::QueueEvent};
|
||||
use mail_auth::{DnssecStatus, MX};
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::NetworkListenerProtocol,
|
||||
structs::{
|
||||
Expression, ExpressionMatch, MtaDeliveryExpiration, MtaDeliveryExpirationTtl,
|
||||
MtaDeliverySchedule, MtaDeliveryScheduleInterval, MtaDeliveryScheduleIntervals,
|
||||
MtaDeliveryScheduleIntervalsOrDefault, MtaExtensions, MtaOutboundStrategy,
|
||||
MtaStageRcpt, MtaVirtualQueue,
|
||||
},
|
||||
},
|
||||
types::list::List,
|
||||
};
|
||||
use smtp::queue::spool::{QUEUE_REFRESH, SmtpSpool};
|
||||
use std::time::{Duration, Instant};
|
||||
use store::write::now;
|
||||
|
||||
const SMUGGLER: &str = r#"From: Joe SixPack <[email protected]>
|
||||
To: Suzie Q <[email protected]>
|
||||
Subject: Is dinner ready?
|
||||
|
||||
Hi.
|
||||
|
||||
We lost the game. Are you hungry yet?
|
||||
.hey
|
||||
Joe.
|
||||
|
||||
<SEP>.
|
||||
MAIL FROM:<[email protected]>
|
||||
RCPT TO:<[email protected]>
|
||||
DATA
|
||||
From: Joe SixPack <[email protected]>
|
||||
To: Suzie Q <[email protected]>
|
||||
Subject: smuggled message
|
||||
|
||||
This is a smuggled message
|
||||
"#;
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn smtp_delivery() {
|
||||
let mut local = TestServerBuilder::new("smtp_delivery_local")
|
||||
.await
|
||||
.with_http_listener(19030)
|
||||
.await
|
||||
.disable_services()
|
||||
.capture_queue()
|
||||
.build()
|
||||
.await;
|
||||
let mut remote = TestServerBuilder::new("smtp_delivery_remote")
|
||||
.await
|
||||
.with_http_listener(19031)
|
||||
.await
|
||||
.with_listener(NetworkListenerProtocol::Smtp, "smtp-debug", 9925, false)
|
||||
.await
|
||||
.disable_services()
|
||||
.capture_queue()
|
||||
.build()
|
||||
.await;
|
||||
|
||||
let local_admin = local.account("admin");
|
||||
local_admin
|
||||
.registry_create_object(MtaStageRcpt {
|
||||
max_recipients: Expression {
|
||||
else_: "100".into(),
|
||||
|
||||
..Default::default()
|
||||
},
|
||||
allow_relaying: Expression {
|
||||
else_: "true".into(),
|
||||
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
local_admin
|
||||
.registry_create_object(MtaOutboundStrategy {
|
||||
schedule: Expression {
|
||||
match_: List::from_iter([
|
||||
ExpressionMatch {
|
||||
if_: "rcpt_domain == 'foobar.org'".into(),
|
||||
then: "'foobar-org'".into(),
|
||||
},
|
||||
ExpressionMatch {
|
||||
if_: "rcpt_domain == 'foobar.com'".into(),
|
||||
then: "'foobar-com'".into(),
|
||||
},
|
||||
]),
|
||||
else_: "'default'".into(),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
let queue_id = local_admin
|
||||
.registry_create_object(MtaVirtualQueue {
|
||||
name: "default".into(),
|
||||
threads_per_node: 25,
|
||||
description: None,
|
||||
})
|
||||
.await;
|
||||
local_admin
|
||||
.registry_create_object(MtaDeliverySchedule {
|
||||
name: "default".into(),
|
||||
retry: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
|
||||
intervals: List::from_iter([MtaDeliveryScheduleInterval {
|
||||
duration: 1_000u64.into(),
|
||||
}]),
|
||||
}),
|
||||
notify: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
|
||||
intervals: List::from_iter([MtaDeliveryScheduleInterval {
|
||||
duration: 1_000u64.into(),
|
||||
}]),
|
||||
}),
|
||||
expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl {
|
||||
expire: 7_000u64.into(),
|
||||
}),
|
||||
queue_id,
|
||||
description: None,
|
||||
})
|
||||
.await;
|
||||
local_admin
|
||||
.registry_create_object(MtaDeliverySchedule {
|
||||
name: "foobar-org".into(),
|
||||
retry: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
|
||||
intervals: List::from_iter([MtaDeliveryScheduleInterval {
|
||||
duration: 1_000u64.into(),
|
||||
}]),
|
||||
}),
|
||||
notify: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
|
||||
intervals: List::from_iter([
|
||||
MtaDeliveryScheduleInterval {
|
||||
duration: 1_000u64.into(),
|
||||
},
|
||||
MtaDeliveryScheduleInterval {
|
||||
duration: 2_000u64.into(),
|
||||
},
|
||||
]),
|
||||
}),
|
||||
expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl {
|
||||
expire: 6_000u64.into(),
|
||||
}),
|
||||
queue_id,
|
||||
description: None,
|
||||
})
|
||||
.await;
|
||||
local_admin
|
||||
.registry_create_object(MtaDeliverySchedule {
|
||||
name: "foobar-com".into(),
|
||||
retry: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
|
||||
intervals: List::from_iter([MtaDeliveryScheduleInterval {
|
||||
duration: 1_000u64.into(),
|
||||
}]),
|
||||
}),
|
||||
notify: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
|
||||
intervals: List::from_iter([
|
||||
MtaDeliveryScheduleInterval {
|
||||
duration: 5_000u64.into(),
|
||||
},
|
||||
MtaDeliveryScheduleInterval {
|
||||
duration: 6_000u64.into(),
|
||||
},
|
||||
]),
|
||||
}),
|
||||
expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl {
|
||||
expire: 7_000u64.into(),
|
||||
}),
|
||||
queue_id,
|
||||
description: None,
|
||||
})
|
||||
.await;
|
||||
local_admin.mta_no_auth().await;
|
||||
local_admin.mta_all_extensions().await;
|
||||
local_admin.mta_disable_spam_filter().await;
|
||||
local_admin.reload_settings().await;
|
||||
local.reload_core();
|
||||
local.expect_reload_settings().await;
|
||||
|
||||
let remote_admin = remote.account("admin");
|
||||
remote_admin.mta_allow_relaying().await;
|
||||
remote_admin.mta_no_auth().await;
|
||||
remote_admin.mta_disable_spam_filter().await;
|
||||
remote_admin.mta_allow_non_fqdn().await;
|
||||
remote_admin
|
||||
.registry_create_object(MtaExtensions {
|
||||
chunking: Expression {
|
||||
else_: "false".into(),
|
||||
..Default::default()
|
||||
},
|
||||
dsn: Expression {
|
||||
else_: "true".into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
remote_admin.reload_settings().await;
|
||||
remote.reload_core();
|
||||
remote.expect_reload_settings().await;
|
||||
|
||||
// Add mock DNS entries
|
||||
for domain in ["foobar.org", "foobar.net", "foobar.com"] {
|
||||
local.server.mx_add(
|
||||
domain,
|
||||
vec![MX {
|
||||
exchanges: vec![
|
||||
format!("mx1.{domain}").into(),
|
||||
format!("mx2.{domain}").into(),
|
||||
]
|
||||
.into_boxed_slice(),
|
||||
preference: 10,
|
||||
}],
|
||||
DnssecStatus::Secure,
|
||||
Instant::now() + Duration::from_secs(10),
|
||||
);
|
||||
local.server.ipv4_add(
|
||||
format!("mx1.{domain}"),
|
||||
vec!["127.0.0.1".parse().unwrap()],
|
||||
Instant::now() + Duration::from_secs(30),
|
||||
);
|
||||
local.server.ipv4_add(
|
||||
format!("mx2.{domain}"),
|
||||
vec!["127.0.0.1".parse().unwrap()],
|
||||
Instant::now() + Duration::from_secs(30),
|
||||
);
|
||||
}
|
||||
|
||||
let mut session = local.new_mta_session();
|
||||
session.data.remote_ip_str = "10.0.0.1".into();
|
||||
session.eval_session_params().await;
|
||||
session.ehlo("mx.test.org").await;
|
||||
session
|
||||
.send_message(
|
||||
"[email protected]",
|
||||
&[
|
||||
"<[email protected]> NOTIFY=SUCCESS,DELAY,FAILURE",
|
||||
"<[email protected]> NOTIFY=SUCCESS,DELAY,FAILURE",
|
||||
"<[email protected]> NOTIFY=SUCCESS,DELAY,FAILURE",
|
||||
"<[email protected]> NOTIFY=SUCCESS,DELAY,FAILURE",
|
||||
"<[email protected]> NOTIFY=SUCCESS,DELAY,FAILURE",
|
||||
"<[email protected]> NOTIFY=SUCCESS,DELAY,FAILURE",
|
||||
"<[email protected]> NOTIFY=SUCCESS,DELAY,FAILURE",
|
||||
],
|
||||
"test:no_dkim",
|
||||
"250",
|
||||
)
|
||||
.await;
|
||||
let message = local.expect_message().await;
|
||||
let num_recipients = message.message.recipients.len();
|
||||
assert_eq!(num_recipients, 7);
|
||||
local
|
||||
.delivery_attempt_for_queue(message.queue_id, "default")
|
||||
.await
|
||||
.try_deliver(local.server.clone());
|
||||
let mut dsn = Vec::new();
|
||||
let mut rcpt_retries = vec![0; num_recipients];
|
||||
loop {
|
||||
match local.try_read_event().await {
|
||||
Some(QueueEvent::Refresh | QueueEvent::WorkerDone { .. }) => {}
|
||||
Some(QueueEvent::Paused(_)) | Some(QueueEvent::ReloadSettings) => unreachable!(),
|
||||
None | Some(QueueEvent::Stop) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let mut events = local.all_queued_messages().await;
|
||||
if events.messages.is_empty() {
|
||||
let now = now();
|
||||
if events.next_refresh < now + QUEUE_REFRESH {
|
||||
tokio::time::sleep(Duration::from_secs(events.next_refresh - now)).await;
|
||||
events = local.all_queued_messages().await;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
for event in events.messages {
|
||||
let message = local
|
||||
.server
|
||||
.read_message(event.queue_id, QueueName::default())
|
||||
.await
|
||||
.unwrap();
|
||||
if message.message.return_path.is_empty() {
|
||||
message
|
||||
.clone()
|
||||
.remove(&local.server, event.due.into())
|
||||
.await;
|
||||
dsn.push(message);
|
||||
} else {
|
||||
for (idx, rcpt) in message.message.recipients.iter().enumerate() {
|
||||
rcpt_retries[idx] = rcpt.retry.inner;
|
||||
}
|
||||
event.try_deliver(local.server.clone());
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(rcpt_retries[0], 0, "retries {rcpt_retries:?}");
|
||||
assert!(rcpt_retries[1] >= 5, "retries {rcpt_retries:?}");
|
||||
assert_eq!(rcpt_retries[2], 0, "retries {rcpt_retries:?}");
|
||||
assert_eq!(rcpt_retries[3], 0, "retries {rcpt_retries:?}");
|
||||
assert!(rcpt_retries[4] >= 5, "retries {rcpt_retries:?}");
|
||||
assert_eq!(rcpt_retries[5], 0, "retries {rcpt_retries:?}");
|
||||
assert_eq!(rcpt_retries[6], 0, "retries {rcpt_retries:?}");
|
||||
assert!(
|
||||
rcpt_retries[1] >= rcpt_retries[4],
|
||||
"retries {rcpt_retries:?}"
|
||||
);
|
||||
|
||||
local.assert_queue_is_empty().await;
|
||||
assert_eq!(dsn.len(), 5);
|
||||
|
||||
let mut dsn = dsn.into_iter();
|
||||
|
||||
dsn.next()
|
||||
.unwrap()
|
||||
.read_lines(&local)
|
||||
.await
|
||||
.assert_contains("<[email protected]> (delivered to")
|
||||
.assert_contains("<[email protected]> (delivered to")
|
||||
.assert_contains("<[email protected]> (failed to lookup")
|
||||
.assert_contains("<[email protected]> (host ")
|
||||
.assert_contains("<[email protected]> (host ");
|
||||
|
||||
dsn.next()
|
||||
.unwrap()
|
||||
.read_lines(&local)
|
||||
.await
|
||||
.assert_contains("<[email protected]> (host ")
|
||||
.assert_contains("<[email protected]> (host ")
|
||||
.assert_contains("Action: delayed");
|
||||
|
||||
dsn.next()
|
||||
.unwrap()
|
||||
.read_lines(&local)
|
||||
.await
|
||||
.assert_contains("<[email protected]> (host ")
|
||||
.assert_contains("Action: delayed");
|
||||
|
||||
dsn.next()
|
||||
.unwrap()
|
||||
.read_lines(&local)
|
||||
.await
|
||||
.assert_contains("<[email protected]> (host ");
|
||||
|
||||
dsn.next()
|
||||
.unwrap()
|
||||
.read_lines(&local)
|
||||
.await
|
||||
.assert_contains("<[email protected]> (host ")
|
||||
.assert_contains("Action: failed");
|
||||
|
||||
let mut recipients = remote
|
||||
.consume_message()
|
||||
.await
|
||||
.message
|
||||
.recipients
|
||||
.into_iter()
|
||||
.map(|r| r.address().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
recipients.extend(
|
||||
remote
|
||||
.consume_message()
|
||||
.await
|
||||
.message
|
||||
.recipients
|
||||
.into_iter()
|
||||
.map(|r| r.address().to_string()),
|
||||
);
|
||||
recipients.sort();
|
||||
assert_eq!(
|
||||
recipients,
|
||||
vec!["[email protected]".to_string(), "[email protected]".to_string()]
|
||||
);
|
||||
|
||||
remote.assert_no_events();
|
||||
|
||||
// SMTP smuggling
|
||||
for separator in ["\n", "\r"].iter() {
|
||||
session.data.remote_ip_str = "10.0.0.2".into();
|
||||
session.eval_session_params().await;
|
||||
session.ehlo("mx.test.org").await;
|
||||
|
||||
let out_message = SMUGGLER
|
||||
.replace('\r', "")
|
||||
.replace('\n', "\r\n")
|
||||
.replace("<SEP>", separator);
|
||||
|
||||
session
|
||||
.send_message("[email protected]", &["[email protected]"], &out_message, "250")
|
||||
.await;
|
||||
local
|
||||
.expect_message_for_queue_then_deliver("default")
|
||||
.await
|
||||
.try_deliver(local.server.clone());
|
||||
local.read_event().await.assert_refresh_or_done();
|
||||
|
||||
let message = remote.consume_message().await.read_message(&remote).await;
|
||||
|
||||
assert!(
|
||||
message.contains("This is a smuggled message"),
|
||||
"message: {:?}",
|
||||
message
|
||||
);
|
||||
assert!(
|
||||
message.contains("We lost the game."),
|
||||
"message: {:?}",
|
||||
message
|
||||
);
|
||||
assert!(
|
||||
message.contains(&format!("{separator}..\r\nMAIL FROM:<",)),
|
||||
"Message {message:?} does not contain separator {:?}",
|
||||
format!("{separator}..\r\nMAIL FROM:<",)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
smtp::{
|
||||
queue::{build_rcpt, new_message},
|
||||
session::TestSession,
|
||||
},
|
||||
utils::{dns::DnsCache, server::TestServerBuilder},
|
||||
};
|
||||
use common::config::smtp::queue::QueueName;
|
||||
use mail_auth::{DnssecStatus, MX};
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::MtaOutboundThrottleKey,
|
||||
structs::{
|
||||
Expression, MtaDeliveryExpiration, MtaDeliveryExpirationTtl, MtaDeliverySchedule,
|
||||
MtaDeliveryScheduleInterval, MtaDeliveryScheduleIntervals,
|
||||
MtaDeliveryScheduleIntervalsOrDefault, MtaOutboundStrategy, MtaOutboundThrottle,
|
||||
MtaVirtualQueue, Rate,
|
||||
},
|
||||
},
|
||||
types::{list::List, map::Map},
|
||||
};
|
||||
use smtp::queue::{Message, QueueEnvelope, Recipient, throttle::IsAllowed};
|
||||
use std::{
|
||||
net::{IpAddr, Ipv4Addr},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use store::write::now;
|
||||
|
||||
#[tokio::test]
|
||||
async fn throttle_outbound() {
|
||||
let mut local = TestServerBuilder::new("smtp_throttle_outbound")
|
||||
.await
|
||||
.with_http_listener(19032)
|
||||
.await
|
||||
.disable_services()
|
||||
.capture_queue()
|
||||
.build()
|
||||
.await;
|
||||
|
||||
let admin = local.account("admin");
|
||||
let queue_id = admin
|
||||
.registry_create_object(MtaVirtualQueue {
|
||||
name: "default".into(),
|
||||
threads_per_node: 25,
|
||||
description: None,
|
||||
})
|
||||
.await;
|
||||
admin
|
||||
.registry_create_object(MtaDeliverySchedule {
|
||||
name: "default".into(),
|
||||
retry: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
|
||||
intervals: List::from_iter([MtaDeliveryScheduleInterval {
|
||||
duration: 3_600_000u64.into(),
|
||||
}]),
|
||||
}),
|
||||
notify: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
|
||||
intervals: List::from_iter([MtaDeliveryScheduleInterval {
|
||||
duration: 3_600_000u64.into(),
|
||||
}]),
|
||||
}),
|
||||
expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl {
|
||||
expire: 3_600_000u64.into(),
|
||||
}),
|
||||
queue_id,
|
||||
description: None,
|
||||
})
|
||||
.await;
|
||||
admin
|
||||
.registry_create_object(MtaOutboundStrategy {
|
||||
schedule: Expression {
|
||||
else_: "'default'".into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
|
||||
for (expr, key, rate_count, rate_duration) in [
|
||||
(
|
||||
"sender_domain = 'foobar.net'",
|
||||
MtaOutboundThrottleKey::SenderDomain,
|
||||
1,
|
||||
30 * 60 * 1000,
|
||||
),
|
||||
(
|
||||
"rcpt_domain = 'example.net'",
|
||||
MtaOutboundThrottleKey::RcptDomain,
|
||||
1,
|
||||
40 * 60 * 1000,
|
||||
),
|
||||
("mx = 'mx.test.org'", MtaOutboundThrottleKey::Mx, 1, 99999),
|
||||
(
|
||||
"mx = 'mx.test.net'",
|
||||
MtaOutboundThrottleKey::Mx,
|
||||
1,
|
||||
50 * 60 * 1000,
|
||||
),
|
||||
] {
|
||||
admin
|
||||
.registry_create_object(MtaOutboundThrottle {
|
||||
enable: true,
|
||||
key: Map::new(vec![key]),
|
||||
match_: Expression {
|
||||
else_: expr.into(),
|
||||
..Default::default()
|
||||
},
|
||||
rate: Rate {
|
||||
count: rate_count,
|
||||
period: rate_duration.into(),
|
||||
},
|
||||
description: "Test throttle".into(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
admin.mta_no_auth().await;
|
||||
admin.mta_allow_relaying().await;
|
||||
admin.reload_settings().await;
|
||||
local.reload_core();
|
||||
local.expect_reload_settings().await;
|
||||
|
||||
// Build test message
|
||||
let mut test_message = new_message(0).message;
|
||||
test_message.return_path = "[email protected]".into();
|
||||
test_message
|
||||
.recipients
|
||||
.push(build_rcpt("[email protected]", 0, 0, 0));
|
||||
|
||||
let mut session = local.new_mta_session();
|
||||
session.data.remote_ip_str = "10.0.0.1".into();
|
||||
session.eval_session_params().await;
|
||||
session.ehlo("mx.test.org").await;
|
||||
session
|
||||
.send_message("[email protected]", &["[email protected]"], "test:no_dkim", "250")
|
||||
.await;
|
||||
assert_eq!(local.last_queued_due().await as i64 - now() as i64, 0);
|
||||
|
||||
// Throttle sender
|
||||
let core = local.server.core.clone();
|
||||
let throttle = &core.smtp.queue.outbound_limiters;
|
||||
for t in &throttle.sender {
|
||||
local
|
||||
.server
|
||||
.is_allowed(
|
||||
t,
|
||||
&QueueEnvelope::test(&test_message, &test_message.recipients[0], ""),
|
||||
0,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Expect rate limit throttle for sender domain 'foobar.net'
|
||||
test_message.return_path = "[email protected]".into();
|
||||
for t in &throttle.sender {
|
||||
local
|
||||
.server
|
||||
.is_allowed(
|
||||
t,
|
||||
&QueueEnvelope::test(&test_message, &test_message.recipients[0], ""),
|
||||
0,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
test_message.recipients.clear();
|
||||
|
||||
session
|
||||
.send_message("[email protected]", &["[email protected]"], "test:no_dkim", "250")
|
||||
.await;
|
||||
local
|
||||
.expect_message_for_queue_then_deliver("default")
|
||||
.await
|
||||
.try_deliver(local.server.clone());
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
local.expect_refresh().await;
|
||||
let due = local.last_queued_due().await - now();
|
||||
assert!(due > 0, "Due: {}", due);
|
||||
|
||||
// Expect concurrency throttle for recipient domain 'example.org'
|
||||
test_message.return_path = "[email protected]".into();
|
||||
test_message
|
||||
.recipients
|
||||
.push(build_rcpt("[email protected]", 0, 0, 0));
|
||||
for t in &throttle.rcpt {
|
||||
local
|
||||
.server
|
||||
.is_allowed(
|
||||
t,
|
||||
&QueueEnvelope::test(&test_message, &test_message.recipients[0], ""),
|
||||
0,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Expect rate limit throttle for recipient domain 'example.net'
|
||||
test_message
|
||||
.recipients
|
||||
.push(build_rcpt("[email protected]", 0, 0, 0));
|
||||
for t in &throttle.rcpt {
|
||||
local
|
||||
.server
|
||||
.is_allowed(
|
||||
t,
|
||||
&QueueEnvelope::test(&test_message, &test_message.recipients[1], ""),
|
||||
0,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
session
|
||||
.send_message(
|
||||
"[email protected]",
|
||||
&["[email protected]"],
|
||||
"test:no_dkim",
|
||||
"250",
|
||||
)
|
||||
.await;
|
||||
local
|
||||
.expect_message_for_queue_then_deliver("default")
|
||||
.await
|
||||
.try_deliver(local.server.clone());
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
local.expect_refresh().await;
|
||||
let due = local.last_queued_due().await - now();
|
||||
assert!(due > 0, "Due: {}", due);
|
||||
|
||||
// Expect concurrency throttle for mx 'mx.test.org'
|
||||
local.server.mx_add(
|
||||
"test.org",
|
||||
vec![MX {
|
||||
exchanges: vec!["mx.test.org".into()].into_boxed_slice(),
|
||||
preference: 10,
|
||||
}],
|
||||
DnssecStatus::Secure,
|
||||
Instant::now() + Duration::from_secs(10),
|
||||
);
|
||||
local.server.ipv4_add(
|
||||
"mx.test.org",
|
||||
vec!["127.0.0.1".parse().unwrap()],
|
||||
Instant::now() + Duration::from_secs(10),
|
||||
);
|
||||
test_message
|
||||
.recipients
|
||||
.push(build_rcpt("[email protected]", 0, 0, 0));
|
||||
|
||||
for t in &throttle.remote {
|
||||
local
|
||||
.server
|
||||
.is_allowed(
|
||||
t,
|
||||
&QueueEnvelope::test(&test_message, &test_message.recipients[2], "mx.test.org"),
|
||||
0,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Expect rate limit throttle for mx 'mx.test.net'
|
||||
local.server.mx_add(
|
||||
"test.net",
|
||||
vec![MX {
|
||||
exchanges: vec!["mx.test.net".into()].into_boxed_slice(),
|
||||
preference: 10,
|
||||
}],
|
||||
DnssecStatus::Secure,
|
||||
Instant::now() + Duration::from_secs(10),
|
||||
);
|
||||
local.server.ipv4_add(
|
||||
"mx.test.net",
|
||||
vec!["127.0.0.1".parse().unwrap()],
|
||||
Instant::now() + Duration::from_secs(10),
|
||||
);
|
||||
for t in &throttle.remote {
|
||||
local
|
||||
.server
|
||||
.is_allowed(
|
||||
t,
|
||||
&QueueEnvelope::test(&test_message, &test_message.recipients[1], "mx.test.net"),
|
||||
0,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
session
|
||||
.send_message("[email protected]", &["[email protected]"], "test:no_dkim", "250")
|
||||
.await;
|
||||
local
|
||||
.expect_message_for_queue_then_deliver("default")
|
||||
.await
|
||||
.try_deliver(local.server.clone());
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
local.expect_refresh().await;
|
||||
let due = local.last_queued_due().await - now();
|
||||
assert!(due > 0, "Due: {}", due);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn throttle_outbound_queue_name() {
|
||||
let mut local = TestServerBuilder::new("smtp_throttle_outbound_queue_name")
|
||||
.await
|
||||
.with_http_listener(19033)
|
||||
.await
|
||||
.disable_services()
|
||||
.capture_queue()
|
||||
.build()
|
||||
.await;
|
||||
|
||||
let admin = local.account("admin");
|
||||
let queue_id = admin
|
||||
.registry_create_object(MtaVirtualQueue {
|
||||
name: "default".into(),
|
||||
threads_per_node: 25,
|
||||
description: None,
|
||||
})
|
||||
.await;
|
||||
admin
|
||||
.registry_create_object(MtaDeliverySchedule {
|
||||
name: "default".into(),
|
||||
retry: MtaDeliveryScheduleIntervalsOrDefault::Default,
|
||||
notify: MtaDeliveryScheduleIntervalsOrDefault::Default,
|
||||
expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl {
|
||||
expire: 3_600_000u64.into(),
|
||||
}),
|
||||
queue_id,
|
||||
description: None,
|
||||
})
|
||||
.await;
|
||||
admin
|
||||
.registry_create_object(MtaOutboundStrategy {
|
||||
schedule: Expression {
|
||||
else_: "'default'".into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
admin
|
||||
.registry_create_object(MtaOutboundThrottle {
|
||||
enable: true,
|
||||
key: Map::new(vec![MtaOutboundThrottleKey::SenderDomain]),
|
||||
match_: Expression {
|
||||
else_: "queue_name == 'default'".into(),
|
||||
..Default::default()
|
||||
},
|
||||
rate: Rate {
|
||||
count: 1,
|
||||
period: (30u64 * 60 * 1000).into(),
|
||||
},
|
||||
description: "queue_name throttle".into(),
|
||||
})
|
||||
.await;
|
||||
admin.reload_settings().await;
|
||||
local.reload_core();
|
||||
local.expect_reload_settings().await;
|
||||
|
||||
let core = local.server.core.clone();
|
||||
let throttle = &core.smtp.queue.outbound_limiters;
|
||||
assert_eq!(throttle.sender.len(), 1);
|
||||
assert!(throttle.rcpt.is_empty());
|
||||
assert!(throttle.remote.is_empty());
|
||||
|
||||
let matching = new_message(0);
|
||||
assert_eq!(matching.queue_name, QueueName::default());
|
||||
local
|
||||
.server
|
||||
.is_allowed(&throttle.sender[0], &matching, 0)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
local
|
||||
.server
|
||||
.is_allowed(&throttle.sender[0], &matching, 0)
|
||||
.await
|
||||
.is_err(),
|
||||
"sender-bucket throttle failed to resolve queue_name and never engaged"
|
||||
);
|
||||
|
||||
let mut other_queue = new_message(0);
|
||||
other_queue.queue_name = QueueName::new("remote").unwrap();
|
||||
local
|
||||
.server
|
||||
.is_allowed(&throttle.sender[0], &other_queue, 0)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
pub trait TestQueueEnvelope<'x> {
|
||||
fn test(message: &'x Message, rcpt: &'x Recipient, mx: &'x str) -> Self;
|
||||
}
|
||||
|
||||
impl<'x> TestQueueEnvelope<'x> for QueueEnvelope<'x> {
|
||||
fn test(message: &'x Message, rcpt: &'x Recipient, mx: &'x str) -> Self {
|
||||
QueueEnvelope {
|
||||
message,
|
||||
mx,
|
||||
remote_ip: IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)),
|
||||
local_ip: IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)),
|
||||
domain: rcpt.domain_part(),
|
||||
rcpt,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
smtp::{
|
||||
inbound::TestMessage,
|
||||
session::{TestSession, VerifyResponse},
|
||||
},
|
||||
utils::{dns::DnsCache, server::TestServerBuilder},
|
||||
};
|
||||
use mail_auth::{DnssecStatus, MX};
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::MtaRequiredOrOptional,
|
||||
structs::{
|
||||
Expression, ExpressionMatch, MtaConnectionStrategy, MtaDeliverySchedule,
|
||||
MtaOutboundStrategy, MtaTlsStrategy, MtaVirtualQueue,
|
||||
},
|
||||
},
|
||||
types::list::List,
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
use store::write::now;
|
||||
|
||||
#[tokio::test]
|
||||
#[serial_test::serial]
|
||||
async fn starttls_optional() {
|
||||
let mut local = TestServerBuilder::new("smtp_starttls_local")
|
||||
.await
|
||||
.with_http_listener(19034)
|
||||
.await
|
||||
.disable_services()
|
||||
.capture_queue()
|
||||
.build()
|
||||
.await;
|
||||
let mut remote = TestServerBuilder::new("smtp_starttls_remote")
|
||||
.await
|
||||
.with_http_listener(19035)
|
||||
.await
|
||||
.with_smtp_listener(9925)
|
||||
.await
|
||||
.disable_services()
|
||||
.capture_queue()
|
||||
.build()
|
||||
.await;
|
||||
|
||||
let local_admin = local.account("admin");
|
||||
local_admin.mta_no_auth().await;
|
||||
local_admin.mta_allow_relaying().await;
|
||||
local_admin
|
||||
.registry_create_object(MtaOutboundStrategy {
|
||||
tls: Expression {
|
||||
match_: List::from_iter([ExpressionMatch {
|
||||
if_: "retry_num > 0 && last_error == 'tls'".into(),
|
||||
then: "'no-tls'".into(),
|
||||
}]),
|
||||
else_: "'default'".into(),
|
||||
},
|
||||
connection: Expression {
|
||||
else_: "'badtls'".into(),
|
||||
..Default::default()
|
||||
},
|
||||
schedule: Expression {
|
||||
else_: "'default'".into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
let queue_id = local_admin
|
||||
.registry_create_object(MtaVirtualQueue {
|
||||
name: "default".into(),
|
||||
threads_per_node: 25,
|
||||
description: None,
|
||||
})
|
||||
.await;
|
||||
local_admin
|
||||
.registry_create_object(MtaDeliverySchedule {
|
||||
name: "default".into(),
|
||||
queue_id,
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
local_admin
|
||||
.registry_create_object(MtaConnectionStrategy {
|
||||
name: "badtls".into(),
|
||||
ehlo_hostname: "badtls.foobar.org".to_string().into(),
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
local_admin
|
||||
.registry_create_object(MtaTlsStrategy {
|
||||
name: "no-tls".into(),
|
||||
allow_invalid_certs: true,
|
||||
start_tls: MtaRequiredOrOptional::Disable,
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
local_admin.reload_settings().await;
|
||||
local.reload_core();
|
||||
local.expect_reload_settings().await;
|
||||
|
||||
let remote_admin = remote.account("admin");
|
||||
remote_admin.mta_no_auth().await;
|
||||
remote_admin.mta_allow_relaying().await;
|
||||
remote_admin.mta_allow_non_fqdn().await;
|
||||
remote_admin.mta_all_extensions().await;
|
||||
remote_admin.reload_settings().await;
|
||||
remote.reload_core();
|
||||
remote.expect_reload_settings().await;
|
||||
|
||||
// Add mock DNS entries
|
||||
local.server.mx_add(
|
||||
"foobar.org",
|
||||
vec![MX {
|
||||
exchanges: vec!["mx.foobar.org".into()].into_boxed_slice(),
|
||||
preference: 10,
|
||||
}],
|
||||
DnssecStatus::Secure,
|
||||
Instant::now() + Duration::from_secs(10),
|
||||
);
|
||||
local.server.ipv4_add(
|
||||
"mx.foobar.org",
|
||||
vec!["127.0.0.1".parse().unwrap()],
|
||||
Instant::now() + Duration::from_secs(10),
|
||||
);
|
||||
|
||||
let mut session = local.new_mta_session();
|
||||
session.data.remote_ip_str = "10.0.0.1".into();
|
||||
session.eval_session_params().await;
|
||||
session.ehlo("mx.test.org").await;
|
||||
session
|
||||
.send_message("[email protected]", &["[email protected]"], "test:no_dkim", "250")
|
||||
.await;
|
||||
local
|
||||
.expect_message_for_queue_then_deliver("default")
|
||||
.await
|
||||
.try_deliver(local.server.clone());
|
||||
let mut retry = local.expect_message().await;
|
||||
let prev_due = retry.message.recipients[0].retry.due;
|
||||
let next_due = now();
|
||||
let queue_id = retry.queue_id;
|
||||
retry.message.recipients[0].retry.due = next_due;
|
||||
retry.save_changes(&local.server, prev_due.into()).await;
|
||||
local
|
||||
.delivery_attempt_for_queue(queue_id, "default")
|
||||
.await
|
||||
.try_deliver(local.server.clone());
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
remote
|
||||
.expect_message()
|
||||
.await
|
||||
.read_lines(&remote)
|
||||
.await
|
||||
.assert_not_contains("using TLSv1.3 with cipher");
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
smtp::session::TestSession,
|
||||
utils::{dns::DnsCache, server::TestServerBuilder},
|
||||
};
|
||||
use common::{BuildServer, ipc::QueueEvent};
|
||||
use mail_auth::{DnssecStatus, MX};
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::NetworkListenerProtocol,
|
||||
prelude::ObjectType,
|
||||
structs::{
|
||||
Expression, MtaDeliveryExpiration, MtaDeliveryExpirationTtl, MtaDeliverySchedule,
|
||||
MtaDeliveryScheduleInterval, MtaDeliveryScheduleIntervals,
|
||||
MtaDeliveryScheduleIntervalsOrDefault, MtaOutboundStrategy, MtaStageData,
|
||||
MtaVirtualQueue,
|
||||
},
|
||||
},
|
||||
types::list::List,
|
||||
};
|
||||
use smtp::queue::manager::Queue;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const NUM_MESSAGES: usize = 100;
|
||||
const NUM_QUEUES: usize = 10;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 18)]
|
||||
#[serial_test::serial]
|
||||
async fn concurrent_queue() {
|
||||
let mut local = TestServerBuilder::new("smtp_concurrent_queue_local")
|
||||
.await
|
||||
.with_http_listener(19037)
|
||||
.await
|
||||
.disable_services()
|
||||
.build()
|
||||
.await;
|
||||
let mut remote = TestServerBuilder::new("smtp_concurrent_queue_remote")
|
||||
.await
|
||||
.with_http_listener(19038)
|
||||
.await
|
||||
.with_listener(NetworkListenerProtocol::Smtp, "smtp-debug", 9925, false)
|
||||
.await
|
||||
.disable_services()
|
||||
.capture_queue()
|
||||
.build()
|
||||
.await;
|
||||
|
||||
let local_admin = local.account("admin");
|
||||
local_admin
|
||||
.registry_create_object(MtaStageData {
|
||||
max_messages: Expression {
|
||||
else_: "2000".into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
local_admin
|
||||
.registry_create_object(MtaOutboundStrategy {
|
||||
schedule: Expression {
|
||||
else_: "'default'".into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
let queue_id = local_admin
|
||||
.registry_create_object(MtaVirtualQueue {
|
||||
name: "default".into(),
|
||||
threads_per_node: 4,
|
||||
description: None,
|
||||
})
|
||||
.await;
|
||||
local_admin
|
||||
.registry_create_object(MtaDeliverySchedule {
|
||||
name: "default".into(),
|
||||
retry: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
|
||||
intervals: List::from_iter([MtaDeliveryScheduleInterval {
|
||||
duration: 1_000u64.into(),
|
||||
}]),
|
||||
}),
|
||||
notify: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
|
||||
intervals: List::from_iter([MtaDeliveryScheduleInterval {
|
||||
duration: 86_400_000u64.into(),
|
||||
}]),
|
||||
}),
|
||||
expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl {
|
||||
expire: 86_400_000u64.into(),
|
||||
}),
|
||||
queue_id,
|
||||
description: None,
|
||||
})
|
||||
.await;
|
||||
local_admin.mta_allow_relaying().await;
|
||||
local_admin.mta_disable_spam_filter().await;
|
||||
local_admin.mta_allow_non_fqdn().await;
|
||||
local_admin.mta_no_auth().await;
|
||||
local_admin
|
||||
.registry_destroy_all(ObjectType::MtaInboundThrottle)
|
||||
.await;
|
||||
local_admin.reload_settings().await;
|
||||
local.reload_core();
|
||||
|
||||
let remote_admin = remote.account("admin");
|
||||
remote_admin.mta_allow_relaying().await;
|
||||
remote_admin.mta_disable_spam_filter().await;
|
||||
remote_admin.mta_allow_non_fqdn().await;
|
||||
remote_admin.mta_no_auth().await;
|
||||
remote_admin
|
||||
.registry_destroy_all(ObjectType::MtaInboundThrottle)
|
||||
.await;
|
||||
remote_admin.reload_settings().await;
|
||||
remote.reload_core();
|
||||
remote.expect_reload_settings().await;
|
||||
|
||||
// Add mock DNS entries
|
||||
local.server.mx_add(
|
||||
"foobar.org",
|
||||
vec![MX {
|
||||
exchanges: vec!["mx.foobar.org".into()].into_boxed_slice(),
|
||||
preference: 10,
|
||||
}],
|
||||
DnssecStatus::Secure,
|
||||
Instant::now() + Duration::from_secs(100),
|
||||
);
|
||||
local.server.ipv4_add(
|
||||
"mx.foobar.org",
|
||||
vec!["127.0.0.1".parse().unwrap()],
|
||||
Instant::now() + Duration::from_secs(100),
|
||||
);
|
||||
|
||||
let mut session = local.new_mta_session();
|
||||
session.data.remote_ip_str = "10.0.0.1".into();
|
||||
session.eval_session_params().await;
|
||||
session.ehlo("mx.test.org").await;
|
||||
|
||||
// Spawn concurrent queues
|
||||
let mut inners = vec![];
|
||||
for _ in 0..NUM_QUEUES {
|
||||
let (inner, rxs) = local.inner_with_rxs().await;
|
||||
let server = inner.build_server();
|
||||
server.mx_add(
|
||||
"foobar.org",
|
||||
vec![MX {
|
||||
exchanges: vec!["mx.foobar.org".into()].into_boxed_slice(),
|
||||
preference: 10,
|
||||
}],
|
||||
DnssecStatus::Secure,
|
||||
Instant::now() + Duration::from_secs(100),
|
||||
);
|
||||
server.ipv4_add(
|
||||
"mx.foobar.org",
|
||||
vec!["127.0.0.1".parse().unwrap()],
|
||||
Instant::now() + Duration::from_secs(100),
|
||||
);
|
||||
inners.push(inner.clone());
|
||||
tokio::spawn(async move {
|
||||
Queue::new(inner, rxs.queue_rx.unwrap()).start().await;
|
||||
});
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
// Send 1000 test messages
|
||||
for _ in 0..(NUM_MESSAGES / 2) {
|
||||
session
|
||||
.send_message("[email protected]", &["[email protected]"], "test:no_dkim", "250")
|
||||
.await;
|
||||
}
|
||||
|
||||
// Wake up all queues
|
||||
for inner in &inners {
|
||||
inner.ipc.queue_tx.send(QueueEvent::Refresh).await.unwrap();
|
||||
}
|
||||
for _ in 0..(NUM_MESSAGES / 2) {
|
||||
session
|
||||
.send_message(
|
||||
"[email protected]",
|
||||
&["[email protected]"],
|
||||
"test:no_dkim",
|
||||
"250",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_millis(1500)).await;
|
||||
|
||||
let m = local.read_queued_messages().await.len();
|
||||
let e = local.read_queued_events().await.len();
|
||||
|
||||
if m + e != 0 {
|
||||
println!("Queue still has {} messages and {} events", m, e);
|
||||
/*for inner in &inners {
|
||||
inner.ipc.queue_tx.send(QueueEvent::Refresh).await.unwrap();
|
||||
}*/
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
local.assert_queue_is_empty().await;
|
||||
let remote_messages = remote.read_queued_messages().await;
|
||||
assert_eq!(remote_messages.len(), NUM_MESSAGES);
|
||||
|
||||
// Make sure local store is queue
|
||||
local
|
||||
.account("admin")
|
||||
.registry_destroy_all(ObjectType::MtaConnectionStrategy)
|
||||
.await;
|
||||
local.assert_is_empty().await;
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::utils::server::{TestServer, TestServerBuilder};
|
||||
use common::config::smtp::queue::{QueueExpiry, QueueName};
|
||||
use registry::schema::{
|
||||
enums::CompressionAlgo,
|
||||
structs::{DsnReportSettings, Expression, ReportSettings},
|
||||
};
|
||||
use smtp::queue::{
|
||||
Error, ErrorDetails, HostResponse, Message, MessageWrapper, Recipient, Schedule, Status,
|
||||
UnexpectedResponse, dsn::SendDsn,
|
||||
};
|
||||
use smtp_proto::{RCPT_NOTIFY_DELAY, RCPT_NOTIFY_FAILURE, RCPT_NOTIFY_SUCCESS, Response};
|
||||
use std::{
|
||||
fs,
|
||||
net::{IpAddr, Ipv4Addr},
|
||||
path::PathBuf,
|
||||
time::SystemTime,
|
||||
};
|
||||
use store::write::now;
|
||||
use types::blob_hash::BlobHash;
|
||||
|
||||
#[tokio::test]
|
||||
async fn generate_dsn() {
|
||||
let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
path.push("resources");
|
||||
path.push("smtp");
|
||||
path.push("dsn");
|
||||
path.push("original.txt");
|
||||
let size = fs::metadata(&path).unwrap().len() as u64;
|
||||
let dsn_original = fs::read_to_string(&path).unwrap();
|
||||
|
||||
let flags = RCPT_NOTIFY_FAILURE | RCPT_NOTIFY_DELAY | RCPT_NOTIFY_SUCCESS;
|
||||
let mut message = MessageWrapper {
|
||||
queue_id: 0,
|
||||
span_id: 0,
|
||||
is_multi_queue: false,
|
||||
queue_name: QueueName::default(),
|
||||
message: Message {
|
||||
size,
|
||||
created: SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs()),
|
||||
return_path: "[email protected]".into(),
|
||||
recipients: vec![Recipient {
|
||||
address: "[email protected]".into(),
|
||||
status: Status::PermanentFailure(ErrorDetails {
|
||||
entity: "mx.example.org".into(),
|
||||
details: Error::UnexpectedResponse(UnexpectedResponse {
|
||||
command: "RCPT TO:<[email protected]>".into(),
|
||||
response: Response {
|
||||
code: 550,
|
||||
esc: [5, 1, 2],
|
||||
message: "User does not exist".into(),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
flags: 0,
|
||||
orcpt: None,
|
||||
retry: Schedule::now(),
|
||||
notify: Schedule::now(),
|
||||
expires: QueueExpiry::Ttl(10),
|
||||
queue: QueueName::default(),
|
||||
}],
|
||||
flags: 0,
|
||||
env_id: None,
|
||||
priority: 0,
|
||||
blob_hash: BlobHash::generate(dsn_original.as_bytes()),
|
||||
metadata: Default::default(),
|
||||
received_from_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
received_via_port: 0,
|
||||
},
|
||||
};
|
||||
|
||||
let mut local = TestServerBuilder::new("smtp_queue_dsn")
|
||||
.await
|
||||
.with_http_listener(19039)
|
||||
.await
|
||||
.disable_services()
|
||||
.capture_queue()
|
||||
.build()
|
||||
.await;
|
||||
let local_admin = local.account("admin");
|
||||
local_admin
|
||||
.registry_create_object(ReportSettings {
|
||||
outbound_report_submitter: Expression {
|
||||
else_: "'mx.example.org'".into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
local_admin
|
||||
.registry_create_object(DsnReportSettings {
|
||||
dkim_sign_domain: Expression {
|
||||
else_: "'example.org'".into(),
|
||||
..Default::default()
|
||||
},
|
||||
from_address: Expression {
|
||||
else_: "'[email protected]'".into(),
|
||||
..Default::default()
|
||||
},
|
||||
from_name: Expression {
|
||||
else_: "'Mail Delivery Subsystem'".into(),
|
||||
..Default::default()
|
||||
},
|
||||
})
|
||||
.await;
|
||||
let domain_id = local_admin.find_or_create_domain("example.org").await;
|
||||
local_admin.create_dkim_signatures(domain_id).await;
|
||||
local_admin.mta_allow_non_fqdn().await;
|
||||
local_admin.mta_allow_relaying().await;
|
||||
local_admin.reload_settings().await;
|
||||
local_admin.mta_allow_relaying().await;
|
||||
local.reload_core();
|
||||
local.expect_reload_settings().await;
|
||||
|
||||
// Create temp dir for queue
|
||||
local
|
||||
.server
|
||||
.blob_store()
|
||||
.put_blob(
|
||||
message.message.blob_hash.as_slice(),
|
||||
dsn_original.as_bytes(),
|
||||
CompressionAlgo::Lz4,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Disabled DSN
|
||||
local.server.send_dsn(&mut message).await;
|
||||
local.assert_no_events();
|
||||
local.assert_queue_is_empty().await;
|
||||
|
||||
// Failure DSN
|
||||
message.message.recipients[0].flags = flags;
|
||||
local.server.send_dsn(&mut message).await;
|
||||
let dsn_message = local.expect_message().await;
|
||||
local.compare_dsn(dsn_message.message, "failure.eml").await;
|
||||
|
||||
// Success DSN
|
||||
message.message.recipients.push(Recipient {
|
||||
address: "[email protected]".into(),
|
||||
status: Status::Completed(HostResponse {
|
||||
hostname: "mx2.example.org".into(),
|
||||
response: Response {
|
||||
code: 250,
|
||||
esc: [2, 1, 5],
|
||||
message: "Message accepted for delivery".into(),
|
||||
},
|
||||
}),
|
||||
flags,
|
||||
orcpt: None,
|
||||
retry: Schedule::now(),
|
||||
notify: Schedule::now(),
|
||||
expires: QueueExpiry::Ttl(10),
|
||||
queue: QueueName::default(),
|
||||
});
|
||||
local.server.send_dsn(&mut message).await;
|
||||
let dsn_message = local.expect_message().await;
|
||||
local.compare_dsn(dsn_message.message, "success.eml").await;
|
||||
|
||||
// Delay DSN
|
||||
message.message.recipients.push(Recipient {
|
||||
address: "[email protected]".into(),
|
||||
status: Status::TemporaryFailure(ErrorDetails {
|
||||
entity: "mx.domain.org".into(),
|
||||
details: Error::ConnectionError("Connection timeout".into()),
|
||||
}),
|
||||
flags,
|
||||
orcpt: Some("[email protected]".into()),
|
||||
retry: Schedule::now(),
|
||||
notify: Schedule::now(),
|
||||
expires: QueueExpiry::Ttl(10),
|
||||
queue: QueueName::default(),
|
||||
});
|
||||
local.server.send_dsn(&mut message).await;
|
||||
let dsn_message = local.expect_message().await;
|
||||
local.compare_dsn(dsn_message.message, "delay.eml").await;
|
||||
|
||||
// Mixed DSN
|
||||
for rcpt in &mut message.message.recipients {
|
||||
rcpt.flags = flags;
|
||||
}
|
||||
message.message.recipients.last_mut().unwrap().notify.due = now();
|
||||
local.server.send_dsn(&mut message).await;
|
||||
let dsn_message = local.expect_message().await;
|
||||
local.compare_dsn(dsn_message.message, "mixed.eml").await;
|
||||
|
||||
// Load queue
|
||||
let queue = local.read_queued_messages().await;
|
||||
assert_eq!(queue.len(), 4);
|
||||
}
|
||||
|
||||
impl TestServer {
|
||||
async fn compare_dsn(&self, message: Message, test: &str) {
|
||||
let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
path.push("resources");
|
||||
path.push("smtp");
|
||||
path.push("dsn");
|
||||
path.push(test);
|
||||
|
||||
let bytes = self
|
||||
.server
|
||||
.blob_store()
|
||||
.get_blob(message.blob_hash.as_slice(), 0..usize::MAX)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let dsn = remove_ids(bytes);
|
||||
let dsn_expected = fs::read_to_string(&path).unwrap();
|
||||
|
||||
if dsn != dsn_expected {
|
||||
let mut failed = PathBuf::from(&path);
|
||||
failed.set_extension("failed");
|
||||
fs::write(&failed, dsn.as_bytes()).unwrap();
|
||||
panic!(
|
||||
"Failed for {}, output saved to {}",
|
||||
path.display(),
|
||||
failed.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_ids(message: Vec<u8>) -> String {
|
||||
let old_message = String::from_utf8(message).unwrap();
|
||||
let mut message = String::with_capacity(old_message.len());
|
||||
let mut found_dkim = 0;
|
||||
let mut skip = false;
|
||||
|
||||
let mut boundary = "";
|
||||
for line in old_message.split("\r\n") {
|
||||
if skip {
|
||||
if line.chars().next().unwrap().is_ascii_whitespace() {
|
||||
continue;
|
||||
} else {
|
||||
skip = false;
|
||||
}
|
||||
}
|
||||
if line.starts_with("Date:") || line.starts_with("Message-ID:") {
|
||||
continue;
|
||||
} else if found_dkim < 2 && line.starts_with("DKIM-Signature:") {
|
||||
found_dkim += 1;
|
||||
skip = true;
|
||||
continue;
|
||||
} else if line.starts_with("--") {
|
||||
message.push_str(&line.replace(boundary, "mime_boundary"));
|
||||
} else if let Some((_, boundary_)) = line.split_once("boundary=\"") {
|
||||
boundary = boundary_.split_once('"').unwrap().0;
|
||||
message.push_str(&line.replace(boundary, "mime_boundary"));
|
||||
} else if line.starts_with("Arrival-Date:") {
|
||||
message.push_str("Arrival-Date: <date goes here>");
|
||||
} else if line.starts_with("Will-Retry-Until:") {
|
||||
message.push_str("Will-Retry-Until: <date goes here>");
|
||||
} else {
|
||||
message.push_str(line);
|
||||
}
|
||||
message.push_str("\r\n");
|
||||
}
|
||||
|
||||
if found_dkim == 0 {
|
||||
panic!("No DKIM signature found in: {old_message}");
|
||||
}
|
||||
|
||||
message
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
smtp::queue::{build_rcpt, new_message},
|
||||
utils::server::TestServerBuilder,
|
||||
};
|
||||
use common::config::smtp::queue::QueueName;
|
||||
use smtp::queue::{Error, ErrorDetails, Message, Recipient, Status, spool::SmtpSpool};
|
||||
use std::time::Duration;
|
||||
use store::write::now;
|
||||
|
||||
#[tokio::test]
|
||||
async fn queue_due() {
|
||||
let mut local = TestServerBuilder::new("smtp_queue_manager")
|
||||
.await
|
||||
.with_http_listener(19040)
|
||||
.await
|
||||
.disable_services()
|
||||
.capture_queue()
|
||||
.build()
|
||||
.await;
|
||||
|
||||
let local_admin = local.account("admin");
|
||||
local_admin.mta_allow_relaying().await;
|
||||
local_admin.mta_allow_non_fqdn().await;
|
||||
local_admin.reload_settings().await;
|
||||
local.reload_core();
|
||||
local.expect_reload_settings().await;
|
||||
|
||||
let mut message = new_message(0);
|
||||
message.message.recipients.push(build_rcpt("c", 3, 8, 9));
|
||||
message.save_changes(&local.server, 0.into()).await;
|
||||
|
||||
let mut message = new_message(1);
|
||||
message.message.recipients.push(build_rcpt("b", 2, 6, 7));
|
||||
message.save_changes(&local.server, 0.into()).await;
|
||||
|
||||
let mut message = new_message(2);
|
||||
message.message.recipients.push(build_rcpt("a", 1, 4, 5));
|
||||
message.save_changes(&local.server, 0.into()).await;
|
||||
|
||||
for domain in vec!["a", "b", "c"].into_iter() {
|
||||
let now = now();
|
||||
let queued = local.all_queued_messages().await;
|
||||
if queued.messages.is_empty() {
|
||||
let wake_up = queued.next_refresh - now;
|
||||
assert_eq!(wake_up, 1);
|
||||
std::thread::sleep(Duration::from_secs(wake_up));
|
||||
}
|
||||
|
||||
for queue_event in local.all_queued_messages().await.messages {
|
||||
if let Some(message) = local
|
||||
.server
|
||||
.read_message(queue_event.queue_id, QueueName::default())
|
||||
.await
|
||||
{
|
||||
message.message.rcpt(domain);
|
||||
message.remove(&local.server, queue_event.due.into()).await;
|
||||
} else {
|
||||
panic!("Message not found");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
local.assert_queue_is_empty().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delivery_events() {
|
||||
let mut message = new_message(0).message;
|
||||
message.created = now();
|
||||
|
||||
message.recipients.push(build_rcpt("a", 1, 2, 3));
|
||||
message.recipients.push(build_rcpt("b", 4, 5, 6));
|
||||
message.recipients.push(build_rcpt("c", 7, 8, 9));
|
||||
|
||||
for t in 0..2 {
|
||||
assert_eq!(
|
||||
message.next_event(None).unwrap(),
|
||||
message.rcpt("a").retry.due
|
||||
);
|
||||
assert_eq!(
|
||||
message.next_delivery_event(None).unwrap(),
|
||||
message.rcpt("a").retry.due
|
||||
);
|
||||
assert_eq!(
|
||||
next_event_after(
|
||||
&message,
|
||||
None,
|
||||
message.rcpt("a").expiration_time(message.created).unwrap()
|
||||
)
|
||||
.unwrap(),
|
||||
message.rcpt("b").retry.due
|
||||
);
|
||||
assert_eq!(
|
||||
next_event_after(
|
||||
&message,
|
||||
None,
|
||||
message.rcpt("b").expiration_time(message.created).unwrap()
|
||||
)
|
||||
.unwrap(),
|
||||
message.rcpt("c").retry.due
|
||||
);
|
||||
assert_eq!(
|
||||
next_event_after(&message, None, message.rcpt("c").notify.due).unwrap(),
|
||||
message.rcpt("c").expiration_time(message.created).unwrap()
|
||||
);
|
||||
assert!(
|
||||
next_event_after(
|
||||
&message,
|
||||
None,
|
||||
message.rcpt("c").expiration_time(message.created).unwrap()
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
|
||||
if t == 0 {
|
||||
message.recipients.reverse();
|
||||
} else {
|
||||
message.recipients.swap(0, 1);
|
||||
}
|
||||
}
|
||||
|
||||
message.rcpt_mut("a").status = Status::PermanentFailure(ErrorDetails {
|
||||
entity: "localhost".into(),
|
||||
details: Error::ConcurrencyLimited,
|
||||
});
|
||||
assert_eq!(
|
||||
message.next_event(None).unwrap(),
|
||||
message.rcpt("b").retry.due
|
||||
);
|
||||
assert_eq!(
|
||||
message.next_delivery_event(None).unwrap(),
|
||||
message.rcpt("b").retry.due
|
||||
);
|
||||
|
||||
message.rcpt_mut("b").status = Status::PermanentFailure(ErrorDetails {
|
||||
entity: "localhost".into(),
|
||||
details: Error::ConcurrencyLimited,
|
||||
});
|
||||
assert_eq!(
|
||||
message.next_event(None).unwrap(),
|
||||
message.rcpt("c").retry.due
|
||||
);
|
||||
assert_eq!(
|
||||
message.next_delivery_event(None).unwrap(),
|
||||
message.rcpt("c").retry.due
|
||||
);
|
||||
|
||||
message.rcpt_mut("c").status = Status::PermanentFailure(ErrorDetails {
|
||||
entity: "localhost".into(),
|
||||
details: Error::ConcurrencyLimited,
|
||||
});
|
||||
assert!(message.next_event(None).is_none());
|
||||
}
|
||||
|
||||
fn next_event_after(message: &Message, queue: Option<QueueName>, instant: u64) -> Option<u64> {
|
||||
let mut next_event = None;
|
||||
|
||||
for rcpt in &message.recipients {
|
||||
if matches!(rcpt.status, Status::Scheduled | Status::TemporaryFailure(_))
|
||||
&& queue.is_none_or(|q| rcpt.queue == q)
|
||||
{
|
||||
if rcpt.retry.due > instant
|
||||
&& next_event.as_ref().is_none_or(|ne| rcpt.retry.due.lt(ne))
|
||||
{
|
||||
next_event = rcpt.retry.due.into();
|
||||
}
|
||||
if rcpt.notify.due > instant
|
||||
&& next_event.as_ref().is_none_or(|ne| rcpt.notify.due.lt(ne))
|
||||
{
|
||||
next_event = rcpt.notify.due.into();
|
||||
}
|
||||
if let Some(expires) = rcpt.expiration_time(message.created)
|
||||
&& expires > instant
|
||||
&& next_event.as_ref().is_none_or(|ne| expires.lt(ne))
|
||||
{
|
||||
next_event = expires.into();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
next_event
|
||||
}
|
||||
|
||||
pub trait TestMessage {
|
||||
fn rcpt(&self, name: &str) -> &Recipient;
|
||||
fn rcpt_mut(&mut self, name: &str) -> &mut Recipient;
|
||||
}
|
||||
|
||||
impl TestMessage for Message {
|
||||
fn rcpt(&self, name: &str) -> &Recipient {
|
||||
self.recipients
|
||||
.iter()
|
||||
.find(|d| d.address() == name)
|
||||
.unwrap_or_else(|| panic!("Expected rcpt {name} not found in {:?}", self.recipients))
|
||||
}
|
||||
|
||||
fn rcpt_mut(&mut self, name: &str) -> &mut Recipient {
|
||||
self.recipients
|
||||
.iter_mut()
|
||||
.find(|d| d.address() == name)
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use common::config::smtp::queue::{QueueExpiry, QueueName};
|
||||
use smtp::queue::{Message, MessageWrapper, Recipient, Schedule, Status};
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use store::write::now;
|
||||
|
||||
pub mod concurrent;
|
||||
pub mod dsn;
|
||||
pub mod manager;
|
||||
pub mod retry;
|
||||
pub mod virtualq;
|
||||
|
||||
pub fn build_rcpt(address: &str, retry: u64, notify: u64, expires: u64) -> Recipient {
|
||||
Recipient {
|
||||
address: address.into(),
|
||||
retry: Schedule::later(retry),
|
||||
notify: Schedule::later(notify),
|
||||
expires: QueueExpiry::Ttl(expires),
|
||||
status: Status::Scheduled,
|
||||
flags: 0,
|
||||
orcpt: None,
|
||||
queue: QueueName::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_message(queue_id: u64) -> MessageWrapper {
|
||||
MessageWrapper {
|
||||
queue_id,
|
||||
span_id: 0,
|
||||
queue_name: QueueName::default(),
|
||||
is_multi_queue: false,
|
||||
message: Message {
|
||||
size: 0,
|
||||
created: now(),
|
||||
return_path: "[email protected]".into(),
|
||||
recipients: vec![],
|
||||
flags: 0,
|
||||
env_id: None,
|
||||
priority: 0,
|
||||
metadata: Default::default(),
|
||||
blob_hash: Default::default(),
|
||||
received_from_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
received_via_port: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
smtp::{
|
||||
inbound::{TestMessage, TestQueueEvent},
|
||||
session::{TestSession, VerifyResponse},
|
||||
},
|
||||
utils::server::TestServerBuilder,
|
||||
};
|
||||
use ahash::AHashSet;
|
||||
use common::{
|
||||
config::smtp::queue::QueueName,
|
||||
ipc::{QueueEvent, QueueEventStatus},
|
||||
};
|
||||
use registry::{
|
||||
schema::structs::{
|
||||
Expression, ExpressionMatch, MtaDeliveryExpiration, MtaDeliveryExpirationTtl,
|
||||
MtaDeliverySchedule, MtaDeliveryScheduleInterval, MtaDeliveryScheduleIntervals,
|
||||
MtaDeliveryScheduleIntervalsOrDefault, MtaExtensions, MtaOutboundStrategy, MtaVirtualQueue,
|
||||
},
|
||||
types::list::List,
|
||||
};
|
||||
use smtp::queue::spool::{QUEUE_REFRESH, SmtpSpool};
|
||||
use std::time::Duration;
|
||||
use store::write::now;
|
||||
|
||||
#[tokio::test]
|
||||
async fn queue_retry() {
|
||||
let mut local = TestServerBuilder::new("smtp_queue_retry")
|
||||
.await
|
||||
.with_http_listener(19041)
|
||||
.await
|
||||
.disable_services()
|
||||
.capture_queue()
|
||||
.build()
|
||||
.await;
|
||||
|
||||
let local_admin = local.account("admin");
|
||||
local_admin.mta_allow_relaying().await;
|
||||
local_admin.mta_allow_non_fqdn().await;
|
||||
local_admin.mta_no_auth().await;
|
||||
local_admin
|
||||
.registry_create_object(MtaOutboundStrategy {
|
||||
schedule: Expression {
|
||||
match_: List::from_iter([ExpressionMatch {
|
||||
if_: "sender_domain == 'test.org'".into(),
|
||||
then: "'sender-test'".into(),
|
||||
}]),
|
||||
else_: "'sender-default'".into(),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
local_admin
|
||||
.registry_create_object(MtaExtensions {
|
||||
deliver_by: Expression {
|
||||
else_: "1h".into(),
|
||||
..Default::default()
|
||||
},
|
||||
future_release: Expression {
|
||||
else_: "1h".into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
let queue_id = local_admin
|
||||
.registry_create_object(MtaVirtualQueue {
|
||||
name: "default".into(),
|
||||
threads_per_node: 25,
|
||||
description: None,
|
||||
})
|
||||
.await;
|
||||
local_admin
|
||||
.registry_create_object(MtaDeliverySchedule {
|
||||
name: "sender-default".into(),
|
||||
retry: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
|
||||
intervals: List::from_iter([
|
||||
MtaDeliveryScheduleInterval {
|
||||
duration: 1_000u64.into(),
|
||||
},
|
||||
MtaDeliveryScheduleInterval {
|
||||
duration: 2_000u64.into(),
|
||||
},
|
||||
MtaDeliveryScheduleInterval {
|
||||
duration: 3_000u64.into(),
|
||||
},
|
||||
]),
|
||||
}),
|
||||
notify: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
|
||||
intervals: List::from_iter([MtaDeliveryScheduleInterval {
|
||||
duration: (15 * 60 * 60 * 1000u64).into(),
|
||||
}]),
|
||||
}),
|
||||
expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl {
|
||||
expire: 86_400_000u64.into(),
|
||||
}),
|
||||
queue_id,
|
||||
description: None,
|
||||
})
|
||||
.await;
|
||||
local_admin
|
||||
.registry_create_object(MtaDeliverySchedule {
|
||||
name: "sender-test".into(),
|
||||
retry: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
|
||||
intervals: List::from_iter([
|
||||
MtaDeliveryScheduleInterval {
|
||||
duration: 1_000u64.into(),
|
||||
},
|
||||
MtaDeliveryScheduleInterval {
|
||||
duration: 2_000u64.into(),
|
||||
},
|
||||
MtaDeliveryScheduleInterval {
|
||||
duration: 3_000u64.into(),
|
||||
},
|
||||
]),
|
||||
}),
|
||||
notify: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
|
||||
intervals: List::from_iter([
|
||||
MtaDeliveryScheduleInterval {
|
||||
duration: 1_000u64.into(),
|
||||
},
|
||||
MtaDeliveryScheduleInterval {
|
||||
duration: 2_000u64.into(),
|
||||
},
|
||||
]),
|
||||
}),
|
||||
expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl {
|
||||
expire: 6_000u64.into(),
|
||||
}),
|
||||
queue_id,
|
||||
description: None,
|
||||
})
|
||||
.await;
|
||||
local_admin.reload_settings().await;
|
||||
local.reload_core();
|
||||
local.expect_reload_settings().await;
|
||||
|
||||
let mut session = local.new_mta_session();
|
||||
session.data.remote_ip_str = "10.0.0.1".into();
|
||||
session.eval_session_params().await;
|
||||
session.ehlo("mx.test.org").await;
|
||||
session
|
||||
.send_message("[email protected]", &["[email protected]"], "test:no_dkim", "250")
|
||||
.await;
|
||||
let attempt = local.expect_message_for_queue_then_deliver("default").await;
|
||||
|
||||
// Expect a failed DSN
|
||||
attempt.try_deliver(local.server.clone());
|
||||
let message = local.expect_message().await;
|
||||
assert_eq!(message.message.return_path.as_ref(), "");
|
||||
assert_eq!(
|
||||
message.message.recipients.first().unwrap().address(),
|
||||
"[email protected]"
|
||||
);
|
||||
message
|
||||
.read_lines(&local)
|
||||
.await
|
||||
.assert_contains("Content-Type: multipart/report")
|
||||
.assert_contains("Final-Recipient: rfc822;[email protected]")
|
||||
.assert_contains("Action: failed");
|
||||
local.read_event().await.assert_done();
|
||||
local.clear_queue().await;
|
||||
|
||||
// Expect a failed DSN for foobar.org, followed by two delayed DSN and
|
||||
// a final failed DSN for _dns_error.org.
|
||||
session
|
||||
.send_message(
|
||||
"[email protected]",
|
||||
&["[email protected]", "jane@_dns_error.org"],
|
||||
"test:no_dkim",
|
||||
"250",
|
||||
)
|
||||
.await;
|
||||
let mut in_fight = AHashSet::new();
|
||||
let attempt = local.expect_message_for_queue_then_deliver("default").await;
|
||||
let mut dsn = Vec::new();
|
||||
let mut retries = Vec::new();
|
||||
in_fight.insert(attempt.queue_id);
|
||||
attempt.try_deliver(local.server.clone());
|
||||
|
||||
loop {
|
||||
match local.try_read_event().await {
|
||||
Some(QueueEvent::WorkerDone {
|
||||
queue_id, status, ..
|
||||
}) => {
|
||||
in_fight.remove(&queue_id);
|
||||
match &status {
|
||||
QueueEventStatus::Completed | QueueEventStatus::Deferred => (),
|
||||
_ => panic!("unexpected status {queue_id}: {status:?}"),
|
||||
}
|
||||
}
|
||||
Some(QueueEvent::Refresh) | Some(QueueEvent::ReloadSettings) => (),
|
||||
None | Some(QueueEvent::Stop) | Some(QueueEvent::Paused(_)) => break,
|
||||
}
|
||||
|
||||
let now = now();
|
||||
let mut events = local.all_queued_messages().await;
|
||||
if events.messages.is_empty() {
|
||||
if events.next_refresh < now + QUEUE_REFRESH {
|
||||
tokio::time::sleep(Duration::from_secs(events.next_refresh - now)).await;
|
||||
events = local.all_queued_messages().await;
|
||||
} else if in_fight.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for event in events.messages {
|
||||
if in_fight.contains(&event.queue_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let message = local
|
||||
.server
|
||||
.read_message(event.queue_id, QueueName::default())
|
||||
.await
|
||||
.unwrap();
|
||||
if message.message.return_path.is_empty() {
|
||||
message
|
||||
.clone()
|
||||
.remove(&local.server, event.due.into())
|
||||
.await;
|
||||
dsn.push(message);
|
||||
} else {
|
||||
retries.push(event.due.saturating_sub(now));
|
||||
in_fight.insert(event.queue_id);
|
||||
event.try_deliver(local.server.clone());
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
local.assert_queue_is_empty().await;
|
||||
assert_eq!(retries, vec![1, 2, 3]);
|
||||
assert_eq!(dsn.len(), 4);
|
||||
let mut dsn = dsn.into_iter();
|
||||
|
||||
dsn.next()
|
||||
.unwrap()
|
||||
.read_lines(&local)
|
||||
.await
|
||||
.assert_contains("<[email protected]> (failed to lookup 'foobar.org'")
|
||||
.assert_contains("Final-Recipient: rfc822;[email protected]")
|
||||
.assert_contains("Action: failed");
|
||||
|
||||
dsn.next()
|
||||
.unwrap()
|
||||
.read_lines(&local)
|
||||
.await
|
||||
.assert_contains("<jane@_dns_error.org> (failed to lookup '_dns_error.org'")
|
||||
.assert_contains("Final-Recipient: rfc822;jane@_dns_error.org")
|
||||
.assert_contains("Action: delayed");
|
||||
|
||||
dsn.next()
|
||||
.unwrap()
|
||||
.read_lines(&local)
|
||||
.await
|
||||
.assert_contains("<jane@_dns_error.org> (failed to lookup '_dns_error.org'")
|
||||
.assert_contains("Final-Recipient: rfc822;jane@_dns_error.org")
|
||||
.assert_contains("Action: delayed");
|
||||
|
||||
dsn.next()
|
||||
.unwrap()
|
||||
.read_lines(&local)
|
||||
.await
|
||||
.assert_contains("<jane@_dns_error.org> (failed to lookup '_dns_error.org'")
|
||||
.assert_contains("Final-Recipient: rfc822;jane@_dns_error.org")
|
||||
.assert_contains("Action: failed");
|
||||
|
||||
// Test FUTURERELEASE + DELIVERBY (RETURN)
|
||||
session.data.remote_ip_str = "10.0.0.2".into();
|
||||
session.eval_session_params().await;
|
||||
session
|
||||
.send_message(
|
||||
"<[email protected]> HOLDFOR=60 BY=3600;R",
|
||||
&["[email protected]"],
|
||||
"test:no_dkim",
|
||||
"250",
|
||||
)
|
||||
.await;
|
||||
let now_ = now();
|
||||
let message = local.expect_message().await;
|
||||
assert!([59, 60].contains(&(local.message_due(message.queue_id).await - now_)));
|
||||
assert!([59, 60].contains(&(message.message.next_delivery_event(None).unwrap() - now_)));
|
||||
assert!(
|
||||
[3599, 3600].contains(
|
||||
&(message
|
||||
.message
|
||||
.recipients
|
||||
.first()
|
||||
.unwrap()
|
||||
.expiration_time(message.message.created)
|
||||
.unwrap()
|
||||
- now_)
|
||||
)
|
||||
);
|
||||
assert!(
|
||||
[54059, 54060].contains(&(message.message.recipients.first().unwrap().notify.due - now_)),
|
||||
"diff: {}",
|
||||
message.message.recipients.first().unwrap().notify.due - now_
|
||||
);
|
||||
|
||||
// Test DELIVERBY (NOTIFY)
|
||||
session
|
||||
.send_message(
|
||||
"<[email protected]> BY=3600;N",
|
||||
&["[email protected]"],
|
||||
"test:no_dkim",
|
||||
"250",
|
||||
)
|
||||
.await;
|
||||
let schedule = local.expect_message().await;
|
||||
assert!(
|
||||
[3599, 3600].contains(&(schedule.message.recipients.first().unwrap().notify.due - now())),
|
||||
"diff: {}",
|
||||
schedule.message.recipients.first().unwrap().notify.due - now()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
smtp::session::TestSession,
|
||||
utils::{dns::DnsCache, server::TestServerBuilder},
|
||||
};
|
||||
use common::{BuildServer, config::smtp::queue::QueueName, ipc::QueueEvent};
|
||||
use mail_auth::{DnssecStatus, MX};
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::NetworkListenerProtocol,
|
||||
prelude::ObjectType,
|
||||
structs::{
|
||||
Expression, ExpressionMatch, MtaDeliveryExpiration, MtaDeliveryExpirationTtl,
|
||||
MtaDeliverySchedule, MtaDeliveryScheduleInterval, MtaDeliveryScheduleIntervals,
|
||||
MtaDeliveryScheduleIntervalsOrDefault, MtaOutboundStrategy, MtaStageData,
|
||||
MtaVirtualQueue,
|
||||
},
|
||||
},
|
||||
types::list::List,
|
||||
};
|
||||
use smtp::queue::manager::Queue;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const NUM_MESSAGES: usize = 100;
|
||||
const NUM_QUEUES: usize = 10;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 18)]
|
||||
#[serial_test::serial]
|
||||
async fn virtual_queue() {
|
||||
let mut local = TestServerBuilder::new("smtp_virtual_queue_local")
|
||||
.await
|
||||
.with_http_listener(19042)
|
||||
.await
|
||||
.disable_services()
|
||||
.build()
|
||||
.await;
|
||||
let mut remote = TestServerBuilder::new("smtp_virtual_queue_remote")
|
||||
.await
|
||||
.with_http_listener(19043)
|
||||
.await
|
||||
.with_listener(NetworkListenerProtocol::Smtp, "smtp-debug", 9925, false)
|
||||
.await
|
||||
.disable_services()
|
||||
.capture_queue()
|
||||
.build()
|
||||
.await;
|
||||
|
||||
let local_admin = local.account("admin");
|
||||
local_admin
|
||||
.registry_create_object(MtaOutboundStrategy {
|
||||
schedule: Expression {
|
||||
match_: List::from_iter([ExpressionMatch {
|
||||
if_: "rcpt == '[email protected]'".into(),
|
||||
then: "'q2'".into(),
|
||||
}]),
|
||||
else_: "'q1'".into(),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
local_admin
|
||||
.registry_create_object(MtaStageData {
|
||||
max_messages: Expression {
|
||||
else_: "2000".into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
let queue1_id = local_admin
|
||||
.registry_create_object(MtaVirtualQueue {
|
||||
name: "q1".into(),
|
||||
threads_per_node: 5,
|
||||
description: None,
|
||||
})
|
||||
.await;
|
||||
let queue2_id = local_admin
|
||||
.registry_create_object(MtaVirtualQueue {
|
||||
name: "q2".into(),
|
||||
threads_per_node: 4,
|
||||
description: None,
|
||||
})
|
||||
.await;
|
||||
local_admin
|
||||
.registry_create_object(MtaDeliverySchedule {
|
||||
name: "q1".into(),
|
||||
retry: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
|
||||
intervals: List::from_iter([MtaDeliveryScheduleInterval {
|
||||
duration: 1_000u64.into(),
|
||||
}]),
|
||||
}),
|
||||
notify: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
|
||||
intervals: List::from_iter([MtaDeliveryScheduleInterval {
|
||||
duration: 86_400_000u64.into(),
|
||||
}]),
|
||||
}),
|
||||
expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl {
|
||||
expire: 86_400_000u64.into(),
|
||||
}),
|
||||
queue_id: queue1_id,
|
||||
description: None,
|
||||
})
|
||||
.await;
|
||||
local_admin
|
||||
.registry_create_object(MtaDeliverySchedule {
|
||||
name: "q2".into(),
|
||||
retry: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
|
||||
intervals: List::from_iter([MtaDeliveryScheduleInterval {
|
||||
duration: 1_000u64.into(),
|
||||
}]),
|
||||
}),
|
||||
notify: MtaDeliveryScheduleIntervalsOrDefault::Custom(MtaDeliveryScheduleIntervals {
|
||||
intervals: List::from_iter([MtaDeliveryScheduleInterval {
|
||||
duration: 86_400_000u64.into(),
|
||||
}]),
|
||||
}),
|
||||
expiry: MtaDeliveryExpiration::Ttl(MtaDeliveryExpirationTtl {
|
||||
expire: 86_400_000u64.into(),
|
||||
}),
|
||||
queue_id: queue2_id,
|
||||
description: None,
|
||||
})
|
||||
.await;
|
||||
local_admin.mta_allow_relaying().await;
|
||||
local_admin.mta_disable_spam_filter().await;
|
||||
local_admin.mta_allow_non_fqdn().await;
|
||||
local_admin.mta_no_auth().await;
|
||||
local_admin
|
||||
.registry_destroy_all(ObjectType::MtaInboundThrottle)
|
||||
.await;
|
||||
local_admin.reload_settings().await;
|
||||
local.reload_core();
|
||||
|
||||
let remote_admin = remote.account("admin");
|
||||
remote_admin.mta_allow_relaying().await;
|
||||
remote_admin.mta_disable_spam_filter().await;
|
||||
remote_admin.mta_allow_non_fqdn().await;
|
||||
remote_admin.mta_no_auth().await;
|
||||
remote_admin
|
||||
.registry_destroy_all(ObjectType::MtaInboundThrottle)
|
||||
.await;
|
||||
remote_admin.reload_settings().await;
|
||||
remote.reload_core();
|
||||
remote.expect_reload_settings().await;
|
||||
|
||||
// Validate parsing
|
||||
for value in ["a", "ab", "abcdefgh"] {
|
||||
let queue_name = QueueName::new(value).unwrap();
|
||||
assert_eq!(queue_name.to_string(), value);
|
||||
}
|
||||
assert_eq!(
|
||||
local
|
||||
.server
|
||||
.core
|
||||
.smtp
|
||||
.queue
|
||||
.virtual_queues
|
||||
.get(&QueueName::new("q1").unwrap())
|
||||
.unwrap()
|
||||
.threads,
|
||||
5
|
||||
);
|
||||
assert_eq!(
|
||||
local
|
||||
.server
|
||||
.core
|
||||
.smtp
|
||||
.queue
|
||||
.virtual_queues
|
||||
.get(&QueueName::new("q2").unwrap())
|
||||
.unwrap()
|
||||
.threads,
|
||||
4
|
||||
);
|
||||
|
||||
// Add mock DNS entries
|
||||
local.server.mx_add(
|
||||
"foobar.org",
|
||||
vec![MX {
|
||||
exchanges: vec!["mx.foobar.org".into()].into_boxed_slice(),
|
||||
preference: 10,
|
||||
}],
|
||||
DnssecStatus::Secure,
|
||||
Instant::now() + Duration::from_secs(100),
|
||||
);
|
||||
local.server.ipv4_add(
|
||||
"mx.foobar.org",
|
||||
vec!["127.0.0.1".parse().unwrap()],
|
||||
Instant::now() + Duration::from_secs(100),
|
||||
);
|
||||
|
||||
let mut session = local.new_mta_session();
|
||||
session.data.remote_ip_str = "10.0.0.1".into();
|
||||
session.eval_session_params().await;
|
||||
session.ehlo("mx.test.org").await;
|
||||
|
||||
// Spawn concurrent queues
|
||||
let mut inners = vec![];
|
||||
for _ in 0..NUM_QUEUES {
|
||||
let (inner, rxs) = local.inner_with_rxs().await;
|
||||
let server = inner.build_server();
|
||||
server.mx_add(
|
||||
"foobar.org",
|
||||
vec![MX {
|
||||
exchanges: vec!["mx.foobar.org".into()].into_boxed_slice(),
|
||||
preference: 10,
|
||||
}],
|
||||
DnssecStatus::Secure,
|
||||
Instant::now() + Duration::from_secs(100),
|
||||
);
|
||||
server.ipv4_add(
|
||||
"mx.foobar.org",
|
||||
vec!["127.0.0.1".parse().unwrap()],
|
||||
Instant::now() + Duration::from_secs(100),
|
||||
);
|
||||
inners.push(inner.clone());
|
||||
tokio::spawn(async move {
|
||||
Queue::new(inner, rxs.queue_rx.unwrap()).start().await;
|
||||
});
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
// Send 1000 test messages
|
||||
for _ in 0..(NUM_MESSAGES / 2) {
|
||||
session
|
||||
.send_message(
|
||||
"[email protected]",
|
||||
&["[email protected]", "[email protected]"],
|
||||
"test:no_dkim",
|
||||
"250",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// Wake up all queues
|
||||
for inner in &inners {
|
||||
inner.ipc.queue_tx.send(QueueEvent::Refresh).await.unwrap();
|
||||
}
|
||||
for _ in 0..(NUM_MESSAGES / 2) {
|
||||
session
|
||||
.send_message(
|
||||
"[email protected]",
|
||||
&["[email protected]", "[email protected]"],
|
||||
"test:no_dkim",
|
||||
"250",
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_millis(1500)).await;
|
||||
|
||||
let m = local.read_queued_messages().await;
|
||||
let e = local.read_queued_events().await;
|
||||
|
||||
if m.len() + e.len() != 0 {
|
||||
println!(
|
||||
"Queue still has {} messages and {} events",
|
||||
m.len(),
|
||||
e.len()
|
||||
);
|
||||
/*for inner in &inners {
|
||||
inner.ipc.queue_tx.send(QueueEvent::Refresh).await.unwrap();
|
||||
}*/
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
local.assert_queue_is_empty().await;
|
||||
let remote_messages = remote.read_queued_messages().await;
|
||||
assert_eq!(remote_messages.len(), NUM_MESSAGES * 2);
|
||||
|
||||
// Make sure local store is queue
|
||||
local
|
||||
.account("admin")
|
||||
.registry_destroy_all(ObjectType::MtaConnectionStrategy)
|
||||
.await;
|
||||
local.assert_is_empty().await;
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{smtp::session::TestSession, utils::server::TestServerBuilder};
|
||||
use ahash::AHashMap;
|
||||
use flate2::{Compression, Crc, write::GzEncoder};
|
||||
use mail_builder::{
|
||||
MessageBuilder,
|
||||
mime::{BodyPart, MimePart},
|
||||
};
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::TaskStoreMaintenanceType,
|
||||
structs::{
|
||||
ArfExternalReport, DataRetention, DmarcExternalReport, Expression, MtaStageData,
|
||||
ReportSettings, Task, TaskStatus, TaskStoreMaintenance, TlsExternalReport,
|
||||
},
|
||||
},
|
||||
types::map::Map,
|
||||
};
|
||||
use std::{io::Write, time::Duration};
|
||||
|
||||
const MAX_REPORT_SIZE: i64 = 65536;
|
||||
|
||||
const DMARC_REPORT: &str = concat!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?><feedback><report_metadata>"#,
|
||||
r#"<org_name>Example</org_name><email>[email protected]</email>"#,
|
||||
r#"<report_id>1</report_id><date_range><begin>1</begin><end>2</end></date_range>"#,
|
||||
r#"</report_metadata><policy_published><domain>foobar.org</domain>"#,
|
||||
r#"</policy_published></feedback>"#
|
||||
);
|
||||
|
||||
fn report_message(content_type: &str, file_name: &str, payload: &[u8]) -> String {
|
||||
MessageBuilder::new()
|
||||
.from(("Reporter", "[email protected]"))
|
||||
.to("[email protected]")
|
||||
.subject("Report Domain: foobar.org")
|
||||
.body(MimePart::new(
|
||||
"multipart/report",
|
||||
BodyPart::Multipart(vec![
|
||||
MimePart::new("text/plain", BodyPart::Text("Report attached.".into())),
|
||||
MimePart::new(content_type, BodyPart::Binary(payload.into())).attachment(file_name),
|
||||
]),
|
||||
))
|
||||
.write_to_string()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn report_analyze() {
|
||||
let mut test = TestServerBuilder::new("smtp_analyze_report_test")
|
||||
.await
|
||||
.with_http_listener(19044)
|
||||
.await
|
||||
.capture_queue()
|
||||
.build()
|
||||
.await;
|
||||
|
||||
let admin = test.account("admin");
|
||||
admin
|
||||
.registry_create_object(MtaStageData {
|
||||
max_messages: Expression {
|
||||
else_: "100".into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
admin
|
||||
.registry_create_object(ReportSettings {
|
||||
inbound_report_addresses: Map::new(vec![
|
||||
"reports@*".to_string(),
|
||||
"*@dmarc.foobar.org".to_string(),
|
||||
"[email protected]".to_string(),
|
||||
]),
|
||||
inbound_report_forwarding: false,
|
||||
inbound_report_max_size: MAX_REPORT_SIZE,
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
admin
|
||||
.registry_create_object(DataRetention {
|
||||
hold_mta_reports_for: Some(1u64.into()),
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
admin.mta_no_auth().await;
|
||||
admin.mta_allow_non_fqdn().await;
|
||||
admin.mta_allow_relaying().await;
|
||||
admin.reload_settings().await;
|
||||
test.reload_core();
|
||||
test.expect_reload_settings().await;
|
||||
|
||||
// Create test message
|
||||
let mut session = test.new_mta_session();
|
||||
session.data.remote_ip_str = "10.0.0.1".into();
|
||||
session.eval_session_params().await;
|
||||
session.ehlo("mx.test.org").await;
|
||||
|
||||
let addresses = [
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
];
|
||||
let mut ac = 0;
|
||||
let mut total_reports_received: AHashMap<&str, usize> = AHashMap::new();
|
||||
for (test_name, num_tests) in [("arf", 5), ("dmarc", 5), ("tls", 2)] {
|
||||
for num_test in 1..=num_tests {
|
||||
*total_reports_received.entry(test_name).or_insert(0) += 1;
|
||||
session
|
||||
.send_message(
|
||||
"[email protected]",
|
||||
&[addresses[ac % addresses.len()]],
|
||||
&format!("report:{test_name}{num_test}"),
|
||||
"250",
|
||||
)
|
||||
.await;
|
||||
test.assert_no_events();
|
||||
ac += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Report ingestion is asynchronous, poll until the reports are stored
|
||||
let admin = test.account("admin");
|
||||
for _ in 0..50 {
|
||||
if admin.registry_get_all::<DmarcExternalReport>().await.len()
|
||||
== total_reports_received["dmarc"]
|
||||
&& admin.registry_get_all::<TlsExternalReport>().await.len()
|
||||
== total_reports_received["tls"]
|
||||
&& admin.registry_get_all::<ArfExternalReport>().await.len()
|
||||
== total_reports_received["arf"]
|
||||
{
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
// Purging the database shouldn't remove the reports
|
||||
admin
|
||||
.registry_create_object(Task::StoreMaintenance(TaskStoreMaintenance {
|
||||
maintenance_type: TaskStoreMaintenanceType::PurgeData,
|
||||
shard_index: None,
|
||||
status: TaskStatus::now(),
|
||||
}))
|
||||
.await;
|
||||
test.wait_for_tasks().await;
|
||||
|
||||
// Make sure the reports are in the store
|
||||
assert_eq!(
|
||||
admin.registry_get_all::<DmarcExternalReport>().await.len(),
|
||||
total_reports_received["dmarc"]
|
||||
);
|
||||
assert_eq!(
|
||||
admin.registry_get_all::<TlsExternalReport>().await.len(),
|
||||
total_reports_received["tls"]
|
||||
);
|
||||
assert_eq!(
|
||||
admin.registry_get_all::<ArfExternalReport>().await.len(),
|
||||
total_reports_received["arf"]
|
||||
);
|
||||
|
||||
// Wait one second, purge, and make sure they are gone
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
admin
|
||||
.registry_create_object(Task::StoreMaintenance(TaskStoreMaintenance {
|
||||
maintenance_type: TaskStoreMaintenanceType::PurgeData,
|
||||
shard_index: None,
|
||||
status: TaskStatus::now(),
|
||||
}))
|
||||
.await;
|
||||
test.wait_for_tasks().await;
|
||||
assert_eq!(
|
||||
admin.registry_get_all::<DmarcExternalReport>().await,
|
||||
vec![]
|
||||
);
|
||||
assert_eq!(admin.registry_get_all::<TlsExternalReport>().await, vec![]);
|
||||
assert_eq!(admin.registry_get_all::<ArfExternalReport>().await, vec![]);
|
||||
|
||||
// Reports that lie about their size or exceed the limit must not be ingested
|
||||
let attachment_name = "mx.test.org!foobar.org!1!2.xml";
|
||||
for payload in [
|
||||
report_message(
|
||||
"application/zip",
|
||||
&format!("{attachment_name}.zip"),
|
||||
&zip("report.xml", DMARC_REPORT.as_bytes(), None, Some(u32::MAX)),
|
||||
),
|
||||
report_message(
|
||||
"application/gzip",
|
||||
&format!("{attachment_name}.gz"),
|
||||
&gzip(&vec![b' '; MAX_REPORT_SIZE as usize * 2]),
|
||||
),
|
||||
] {
|
||||
session
|
||||
.send_message("[email protected]", &["[email protected]"], &payload, "250")
|
||||
.await;
|
||||
test.assert_no_events();
|
||||
}
|
||||
|
||||
// A report within the limit is still ingested
|
||||
session
|
||||
.send_message(
|
||||
"[email protected]",
|
||||
&["[email protected]"],
|
||||
&report_message(
|
||||
"application/zip",
|
||||
&format!("{attachment_name}.zip"),
|
||||
&zip("report.xml", DMARC_REPORT.as_bytes(), None, None),
|
||||
),
|
||||
"250",
|
||||
)
|
||||
.await;
|
||||
test.assert_no_events();
|
||||
|
||||
let admin = test.account("admin");
|
||||
for _ in 0..50 {
|
||||
if !admin
|
||||
.registry_get_all::<DmarcExternalReport>()
|
||||
.await
|
||||
.is_empty()
|
||||
{
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
assert_eq!(
|
||||
admin.registry_get_all::<DmarcExternalReport>().await.len(),
|
||||
1
|
||||
);
|
||||
|
||||
// Redeliveries of a previously stored report must not be imported again
|
||||
session
|
||||
.send_message(
|
||||
"[email protected]",
|
||||
&["[email protected]"],
|
||||
&report_message(
|
||||
"application/zip",
|
||||
&format!("{attachment_name}.zip"),
|
||||
&zip("report.xml", DMARC_REPORT.as_bytes(), None, None),
|
||||
),
|
||||
"250",
|
||||
)
|
||||
.await;
|
||||
test.assert_no_events();
|
||||
|
||||
let admin = test.account("admin");
|
||||
for _ in 0..10 {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
assert_eq!(
|
||||
admin.registry_get_all::<DmarcExternalReport>().await.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
// Test delivery to non-report addresses
|
||||
session
|
||||
.send_message("[email protected]", &["[email protected]"], "test:no_dkim", "250")
|
||||
.await;
|
||||
test.expect_refresh().await;
|
||||
test.last_queued_message().await;
|
||||
|
||||
// Messages sent to a report address that contain no report must be delivered
|
||||
session
|
||||
.send_message(
|
||||
"[email protected]",
|
||||
&["[email protected]"],
|
||||
concat!(
|
||||
"From: [email protected]\r\n",
|
||||
"To: [email protected]\r\n",
|
||||
"Subject: Your MX is refusing my connections\r\n",
|
||||
"\r\n",
|
||||
"Could you have a look at this?"
|
||||
),
|
||||
"250",
|
||||
)
|
||||
.await;
|
||||
let message = test.expect_message().await;
|
||||
assert_eq!(
|
||||
message.message.recipients.last().unwrap().address(),
|
||||
"[email protected]"
|
||||
);
|
||||
|
||||
// Reports addressed to both a report address and a regular mailbox are
|
||||
// discarded only for the report address
|
||||
session
|
||||
.send_message(
|
||||
"[email protected]",
|
||||
&["[email protected]", "[email protected]"],
|
||||
&report_message(
|
||||
"application/zip",
|
||||
&format!("{attachment_name}.zip"),
|
||||
&zip("report.xml", DMARC_REPORT.as_bytes(), None, None),
|
||||
),
|
||||
"250",
|
||||
)
|
||||
.await;
|
||||
let message = test.expect_message().await;
|
||||
assert_eq!(
|
||||
message
|
||||
.message
|
||||
.recipients
|
||||
.iter()
|
||||
.map(|rcpt| rcpt.address())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["[email protected]"]
|
||||
);
|
||||
}
|
||||
|
||||
fn gzip(data: &[u8]) -> Vec<u8> {
|
||||
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
|
||||
encoder.write_all(data).unwrap();
|
||||
encoder.finish().unwrap()
|
||||
}
|
||||
|
||||
fn zip(
|
||||
name: &str,
|
||||
data: &[u8],
|
||||
compressed_size: Option<u32>,
|
||||
uncompressed_size: Option<u32>,
|
||||
) -> Vec<u8> {
|
||||
let mut crc = Crc::new();
|
||||
crc.update(data);
|
||||
let crc = crc.sum();
|
||||
let compressed_size = compressed_size.unwrap_or(data.len() as u32);
|
||||
let uncompressed_size = uncompressed_size.unwrap_or(data.len() as u32);
|
||||
let name = name.as_bytes();
|
||||
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
|
||||
out.extend_from_slice(&20u16.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(&crc.to_le_bytes());
|
||||
out.extend_from_slice(&compressed_size.to_le_bytes());
|
||||
out.extend_from_slice(&uncompressed_size.to_le_bytes());
|
||||
out.extend_from_slice(&(name.len() as u16).to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(name);
|
||||
out.extend_from_slice(data);
|
||||
|
||||
let central_offset = out.len() as u32;
|
||||
out.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
|
||||
out.extend_from_slice(&20u16.to_le_bytes());
|
||||
out.extend_from_slice(&20u16.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(&crc.to_le_bytes());
|
||||
out.extend_from_slice(&compressed_size.to_le_bytes());
|
||||
out.extend_from_slice(&uncompressed_size.to_le_bytes());
|
||||
out.extend_from_slice(&(name.len() as u16).to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(&0u32.to_le_bytes());
|
||||
out.extend_from_slice(&0u32.to_le_bytes());
|
||||
out.extend_from_slice(name);
|
||||
|
||||
let central_size = out.len() as u32 - central_offset;
|
||||
out.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
out.extend_from_slice(&1u16.to_le_bytes());
|
||||
out.extend_from_slice(&1u16.to_le_bytes());
|
||||
out.extend_from_slice(¢ral_size.to_le_bytes());
|
||||
out.extend_from_slice(¢ral_offset.to_le_bytes());
|
||||
out.extend_from_slice(&0u16.to_le_bytes());
|
||||
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
smtp::{inbound::TestMessage, session::VerifyResponse},
|
||||
utils::{dns::DnsCache, server::TestServerBuilder},
|
||||
};
|
||||
use common::{config::smtp::report::AggregateFrequency, ipc::DmarcEvent};
|
||||
use mail_auth::{
|
||||
common::parse::TxtRecordParser,
|
||||
dmarc::Dmarc,
|
||||
report::{ActionDisposition, Disposition, DmarcResult, Record, Report},
|
||||
};
|
||||
use registry::schema::structs::{
|
||||
DmarcInternalReport, DmarcReportSettings, Expression, ReportSettings,
|
||||
};
|
||||
use smtp::reporting::dmarc::DmarcReporting;
|
||||
use std::{
|
||||
net::IpAddr,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn report_dmarc() {
|
||||
let mut test = TestServerBuilder::new("smtp_report_dmarc_test")
|
||||
.await
|
||||
.with_http_listener(19045)
|
||||
.await
|
||||
.disable_services()
|
||||
.capture_queue()
|
||||
.build()
|
||||
.await;
|
||||
|
||||
let admin = test.account("admin");
|
||||
let domain_id = admin.find_or_create_domain("example.org").await;
|
||||
admin.create_dkim_signatures(domain_id).await;
|
||||
admin
|
||||
.registry_create_object(ReportSettings {
|
||||
outbound_report_submitter: Expression {
|
||||
else_: "'mx.example.org'".into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
admin
|
||||
.registry_create_object(DmarcReportSettings {
|
||||
aggregate_contact_info: Expression {
|
||||
else_: "'https://foobar.org/contact'".into(),
|
||||
..Default::default()
|
||||
},
|
||||
aggregate_dkim_sign_domain: Expression {
|
||||
else_: "'example.org'".into(),
|
||||
..Default::default()
|
||||
},
|
||||
aggregate_from_address: Expression {
|
||||
else_: "'reports@' + system('domain')".into(),
|
||||
..Default::default()
|
||||
},
|
||||
aggregate_from_name: Expression {
|
||||
else_: "'DMARC Report'".into(),
|
||||
..Default::default()
|
||||
},
|
||||
aggregate_max_report_size: Expression {
|
||||
else_: "4096".into(),
|
||||
..Default::default()
|
||||
},
|
||||
aggregate_org_name: Expression {
|
||||
else_: "'Foobar, Inc.'".into(),
|
||||
..Default::default()
|
||||
},
|
||||
aggregate_send_frequency: Expression {
|
||||
else_: "daily".into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
admin.mta_no_auth().await;
|
||||
admin.mta_allow_non_fqdn().await;
|
||||
admin.mta_allow_relaying().await;
|
||||
admin.reload_settings().await;
|
||||
test.reload_core();
|
||||
test.expect_reload_settings().await;
|
||||
|
||||
// Authorize external report for foobar.org
|
||||
test.server.txt_add(
|
||||
"foobar.org._report._dmarc.foobar.net",
|
||||
Dmarc::parse(b"v=DMARC1;").unwrap(),
|
||||
Instant::now() + Duration::from_secs(10),
|
||||
);
|
||||
|
||||
// Schedule two events with a same policy and another one with a different policy
|
||||
let dmarc_record = Arc::new(
|
||||
Dmarc::parse(
|
||||
b"v=DMARC1; p=quarantine; rua=mailto:[email protected],mailto:[email protected]",
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(dmarc_record.rua().len(), 2);
|
||||
for _ in 0..2 {
|
||||
test.server
|
||||
.schedule_dmarc(Box::new(DmarcEvent {
|
||||
domain: "foobar.org".to_string(),
|
||||
report_record: Record::new()
|
||||
.with_source_ip("192.168.1.2".parse().unwrap())
|
||||
.with_action_disposition(ActionDisposition::Pass)
|
||||
.with_dmarc_dkim_result(DmarcResult::Pass)
|
||||
.with_dmarc_spf_result(DmarcResult::Fail)
|
||||
.with_envelope_from("[email protected]")
|
||||
.with_envelope_to("[email protected]")
|
||||
.with_header_from("[email protected]"),
|
||||
dmarc_record: dmarc_record.clone(),
|
||||
interval: AggregateFrequency::Weekly,
|
||||
span_id: 0,
|
||||
}))
|
||||
.await;
|
||||
}
|
||||
test.server
|
||||
.schedule_dmarc(Box::new(DmarcEvent {
|
||||
domain: "foobar.org".to_string(),
|
||||
report_record: Record::new()
|
||||
.with_source_ip("a:b:c::e:f".parse().unwrap())
|
||||
.with_action_disposition(ActionDisposition::Reject)
|
||||
.with_dmarc_dkim_result(DmarcResult::Fail)
|
||||
.with_dmarc_spf_result(DmarcResult::Pass),
|
||||
dmarc_record: dmarc_record.clone(),
|
||||
interval: AggregateFrequency::Weekly,
|
||||
span_id: 0,
|
||||
}))
|
||||
.await;
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
let reports = test.read_report_events::<DmarcInternalReport>().await;
|
||||
assert_eq!(reports.len(), 1);
|
||||
test.server
|
||||
.send_dmarc_aggregate_report(reports.first().unwrap().0.id())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Expect report
|
||||
let message = test.expect_message().await;
|
||||
test.assert_no_events();
|
||||
assert_eq!(message.message.recipients.len(), 1);
|
||||
assert_eq!(
|
||||
message.message.recipients.last().unwrap().address(),
|
||||
"[email protected]"
|
||||
);
|
||||
assert_eq!(message.message.return_path.as_ref(), "[email protected]");
|
||||
message
|
||||
.read_lines(&test)
|
||||
.await
|
||||
.assert_contains("DKIM-Signature: v=1; a=rsa-sha256; s=rsa; d=example.org;")
|
||||
.assert_contains("To: <[email protected]>")
|
||||
.assert_contains("Report Domain: foobar.org")
|
||||
.assert_contains("Submitter: mx.example.org");
|
||||
|
||||
// Verify generated report
|
||||
let report =
|
||||
Report::parse_rfc5322(message.read_message(&test).await.as_bytes(), usize::MAX).unwrap();
|
||||
assert_eq!(report.domain(), "foobar.org");
|
||||
assert_eq!(report.email(), "[email protected]");
|
||||
assert_eq!(report.org_name(), "Foobar, Inc.");
|
||||
assert_eq!(
|
||||
report.extra_contact_info().unwrap(),
|
||||
"https://foobar.org/contact"
|
||||
);
|
||||
assert_eq!(report.p(), Disposition::Quarantine);
|
||||
assert_eq!(report.records().len(), 2, "records: {:?}", report.records());
|
||||
for record in report.records() {
|
||||
let source_ip = record.source_ip().unwrap();
|
||||
if source_ip == "192.168.1.2".parse::<IpAddr>().unwrap() {
|
||||
assert_eq!(record.count(), 2);
|
||||
assert_eq!(record.action_disposition(), ActionDisposition::Pass);
|
||||
assert_eq!(record.envelope_from(), "[email protected]");
|
||||
assert_eq!(record.header_from(), "[email protected]");
|
||||
assert_eq!(record.envelope_to().unwrap(), "[email protected]");
|
||||
} else if source_ip == "a:b:c::e:f".parse::<IpAddr>().unwrap() {
|
||||
assert_eq!(record.count(), 1);
|
||||
assert_eq!(record.action_disposition(), ActionDisposition::Reject);
|
||||
} else {
|
||||
panic!("unexpected ip {source_ip}");
|
||||
}
|
||||
}
|
||||
test.assert_report_is_empty::<DmarcInternalReport>().await;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
pub mod analyze;
|
||||
pub mod dmarc;
|
||||
pub mod scheduler;
|
||||
pub mod tls;
|
||||
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::utils::server::TestServerBuilder;
|
||||
use common::{
|
||||
config::smtp::report::AggregateFrequency,
|
||||
ipc::{DmarcEvent, PolicyType, TlsEvent},
|
||||
};
|
||||
use mail_auth::{
|
||||
common::parse::TxtRecordParser,
|
||||
dmarc::Dmarc,
|
||||
mta_sts::TlsRpt,
|
||||
report::{ActionDisposition, DmarcResult, Record},
|
||||
};
|
||||
use registry::schema::structs::{
|
||||
DmarcInternalReport, DmarcReportSettings, Expression, TlsInternalReport, TlsReportSettings,
|
||||
};
|
||||
use smtp::reporting::{dmarc::DmarcReporting, tls::TlsReporting};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::test]
|
||||
async fn report_scheduler() {
|
||||
let mut test = TestServerBuilder::new("smtp_report_queue_test")
|
||||
.await
|
||||
.with_http_listener(19046)
|
||||
.await
|
||||
.disable_services()
|
||||
.capture_queue()
|
||||
.build()
|
||||
.await;
|
||||
|
||||
let admin = test.account("admin");
|
||||
admin
|
||||
.registry_create_object(DmarcReportSettings {
|
||||
aggregate_max_report_size: Expression {
|
||||
else_: "500".into(),
|
||||
..Default::default()
|
||||
},
|
||||
aggregate_send_frequency: Expression {
|
||||
else_: "daily".into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
admin
|
||||
.registry_create_object(TlsReportSettings {
|
||||
max_report_size: Expression {
|
||||
else_: "550".into(),
|
||||
..Default::default()
|
||||
},
|
||||
send_frequency: Expression {
|
||||
else_: "daily".into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
admin.mta_no_auth().await;
|
||||
admin.mta_allow_non_fqdn().await;
|
||||
admin.mta_allow_relaying().await;
|
||||
admin.reload_settings().await;
|
||||
test.reload_core();
|
||||
test.expect_reload_settings().await;
|
||||
|
||||
// Schedule two events with a same policy and another one with a different policy
|
||||
let dmarc_record =
|
||||
Arc::new(Dmarc::parse(b"v=DMARC1; p=quarantine; rua=mailto:[email protected]").unwrap());
|
||||
test.server
|
||||
.schedule_dmarc(Box::new(DmarcEvent {
|
||||
domain: "foobar.org".to_string(),
|
||||
report_record: Record::new()
|
||||
.with_source_ip("192.168.1.2".parse().unwrap())
|
||||
.with_action_disposition(ActionDisposition::Pass)
|
||||
.with_dmarc_dkim_result(DmarcResult::Pass)
|
||||
.with_dmarc_spf_result(DmarcResult::Fail)
|
||||
.with_envelope_from("[email protected]")
|
||||
.with_envelope_to("[email protected]")
|
||||
.with_header_from("[email protected]"),
|
||||
dmarc_record: dmarc_record.clone(),
|
||||
interval: AggregateFrequency::Weekly,
|
||||
span_id: 0,
|
||||
}))
|
||||
.await;
|
||||
|
||||
// No records should be added once the 550 bytes max size is reached
|
||||
for _ in 0..10 {
|
||||
test.server
|
||||
.schedule_dmarc(Box::new(DmarcEvent {
|
||||
domain: "foobar.org".to_string(),
|
||||
report_record: Record::new()
|
||||
.with_source_ip("192.168.1.2".parse().unwrap())
|
||||
.with_action_disposition(ActionDisposition::Pass)
|
||||
.with_dmarc_dkim_result(DmarcResult::Pass)
|
||||
.with_dmarc_spf_result(DmarcResult::Fail)
|
||||
.with_envelope_from("[email protected]")
|
||||
.with_envelope_to("[email protected]")
|
||||
.with_header_from("[email protected]"),
|
||||
dmarc_record: dmarc_record.clone(),
|
||||
interval: AggregateFrequency::Weekly,
|
||||
span_id: 0,
|
||||
}))
|
||||
.await;
|
||||
}
|
||||
let dmarc_record =
|
||||
Arc::new(Dmarc::parse(b"v=DMARC1; p=reject; rua=mailto:[email protected]").unwrap());
|
||||
test.server
|
||||
.schedule_dmarc(Box::new(DmarcEvent {
|
||||
domain: "foobar.org".to_string(),
|
||||
report_record: Record::new()
|
||||
.with_source_ip("a:b:c::e:f".parse().unwrap())
|
||||
.with_action_disposition(ActionDisposition::Reject)
|
||||
.with_dmarc_dkim_result(DmarcResult::Fail)
|
||||
.with_dmarc_spf_result(DmarcResult::Pass),
|
||||
dmarc_record: dmarc_record.clone(),
|
||||
interval: AggregateFrequency::Weekly,
|
||||
span_id: 0,
|
||||
}))
|
||||
.await;
|
||||
|
||||
// Schedule TLS event
|
||||
let tls_record = Arc::new(TlsRpt::parse(b"v=TLSRPTv1;rua=mailto:[email protected]").unwrap());
|
||||
test.server
|
||||
.schedule_tls(Box::new(TlsEvent {
|
||||
domain: "foobar.org".to_string(),
|
||||
policy: PolicyType::Tlsa(None),
|
||||
failure: None,
|
||||
tls_record: tls_record.clone(),
|
||||
interval: AggregateFrequency::Daily,
|
||||
span_id: 0,
|
||||
}))
|
||||
.await;
|
||||
test.server
|
||||
.schedule_tls(Box::new(TlsEvent {
|
||||
domain: "foobar.org".to_string(),
|
||||
policy: PolicyType::Tlsa(None),
|
||||
failure: None,
|
||||
tls_record: tls_record.clone(),
|
||||
interval: AggregateFrequency::Daily,
|
||||
span_id: 0,
|
||||
}))
|
||||
.await;
|
||||
test.server
|
||||
.schedule_tls(Box::new(TlsEvent {
|
||||
domain: "foobar.org".to_string(),
|
||||
policy: PolicyType::Sts(None),
|
||||
failure: None,
|
||||
tls_record: tls_record.clone(),
|
||||
interval: AggregateFrequency::Daily,
|
||||
span_id: 0,
|
||||
}))
|
||||
.await;
|
||||
test.server
|
||||
.schedule_tls(Box::new(TlsEvent {
|
||||
domain: "foobar.org".to_string(),
|
||||
policy: PolicyType::None,
|
||||
failure: None,
|
||||
tls_record: tls_record.clone(),
|
||||
interval: AggregateFrequency::Daily,
|
||||
span_id: 0,
|
||||
}))
|
||||
.await;
|
||||
|
||||
// Verify sizes and counts
|
||||
let mut total_tls = 0;
|
||||
let mut total_tls_policies = 0;
|
||||
let mut total_dmarc_policies = 0;
|
||||
for (_, report) in test.read_report_events::<DmarcInternalReport>().await {
|
||||
total_dmarc_policies += 1;
|
||||
assert_eq!(
|
||||
report.deliver_at.timestamp() - report.created_at.timestamp(),
|
||||
7 * 86400
|
||||
);
|
||||
}
|
||||
for (_, report) in test.read_report_events::<TlsInternalReport>().await {
|
||||
total_tls += 1;
|
||||
total_tls_policies += report.report.policies.len();
|
||||
assert_eq!(
|
||||
report.deliver_at.timestamp() - report.created_at.timestamp(),
|
||||
86400
|
||||
);
|
||||
}
|
||||
assert_eq!(total_tls, 1);
|
||||
assert_eq!(total_tls_policies, 3);
|
||||
assert_eq!(total_dmarc_policies, 2);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
smtp::{inbound::TestMessage, session::VerifyResponse},
|
||||
utils::server::TestServerBuilder,
|
||||
};
|
||||
use common::{config::smtp::report::AggregateFrequency, ipc::TlsEvent};
|
||||
use mail_auth::{
|
||||
common::parse::TxtRecordParser,
|
||||
flate2::read::GzDecoder,
|
||||
mta_sts::TlsRpt,
|
||||
report::tlsrpt::{FailureDetails, PolicyType, ResultType, TlsReport},
|
||||
};
|
||||
use registry::schema::structs::{Expression, ReportSettings, TlsInternalReport, TlsReportSettings};
|
||||
use smtp::reporting::tls::{TLS_HTTP_REPORT, TlsReporting};
|
||||
use std::{io::Read, sync::Arc, time::Duration};
|
||||
|
||||
#[tokio::test]
|
||||
async fn report_tls() {
|
||||
let mut test = TestServerBuilder::new("smtp_report_tls_test")
|
||||
.await
|
||||
.with_http_listener(19047)
|
||||
.await
|
||||
.disable_services()
|
||||
.capture_queue()
|
||||
.build()
|
||||
.await;
|
||||
|
||||
let admin = test.account("admin");
|
||||
let domain_id = admin.find_or_create_domain("example.org").await;
|
||||
admin.create_dkim_signatures(domain_id).await;
|
||||
admin
|
||||
.registry_create_object(ReportSettings {
|
||||
outbound_report_submitter: Expression {
|
||||
else_: "'mx.example.org'".into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
admin
|
||||
.registry_create_object(TlsReportSettings {
|
||||
contact_info: Expression {
|
||||
else_: "'https://foobar.org/contact'".into(),
|
||||
..Default::default()
|
||||
},
|
||||
dkim_sign_domain: Expression {
|
||||
else_: "'example.org'".into(),
|
||||
..Default::default()
|
||||
},
|
||||
from_address: Expression {
|
||||
else_: "'[email protected]'".into(),
|
||||
..Default::default()
|
||||
},
|
||||
from_name: Expression {
|
||||
else_: "'Report Subsystem'".into(),
|
||||
..Default::default()
|
||||
},
|
||||
org_name: Expression {
|
||||
else_: "'Foobar, Inc.'".into(),
|
||||
..Default::default()
|
||||
},
|
||||
send_frequency: Expression {
|
||||
else_: "daily".into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
admin.mta_no_auth().await;
|
||||
admin.mta_allow_non_fqdn().await;
|
||||
admin.mta_allow_relaying().await;
|
||||
admin.reload_settings().await;
|
||||
test.reload_core();
|
||||
test.expect_reload_settings().await;
|
||||
|
||||
// Schedule TLS reports to be delivered via email
|
||||
let tls_record = Arc::new(TlsRpt::parse(b"v=TLSRPTv1;rua=mailto:[email protected]").unwrap());
|
||||
|
||||
for _ in 0..2 {
|
||||
// Add two successful records
|
||||
test.server
|
||||
.schedule_tls(Box::new(TlsEvent {
|
||||
domain: "foobar.org".to_string(),
|
||||
policy: common::ipc::PolicyType::None,
|
||||
failure: None,
|
||||
tls_record: tls_record.clone(),
|
||||
interval: AggregateFrequency::Daily,
|
||||
span_id: 0,
|
||||
}))
|
||||
.await;
|
||||
}
|
||||
|
||||
for (policy, rt) in [
|
||||
(
|
||||
common::ipc::PolicyType::None, // Quota limited at 1532 bytes, this should not be included in the report.
|
||||
ResultType::CertificateExpired,
|
||||
),
|
||||
(common::ipc::PolicyType::Tlsa(None), ResultType::TlsaInvalid),
|
||||
(
|
||||
common::ipc::PolicyType::Sts(None),
|
||||
ResultType::StsPolicyFetchError,
|
||||
),
|
||||
(
|
||||
common::ipc::PolicyType::Sts(None),
|
||||
ResultType::StsPolicyInvalid,
|
||||
),
|
||||
(
|
||||
common::ipc::PolicyType::Sts(None),
|
||||
ResultType::StsWebpkiInvalid,
|
||||
),
|
||||
] {
|
||||
test.server
|
||||
.schedule_tls(Box::new(TlsEvent {
|
||||
domain: "foobar.org".to_string(),
|
||||
policy,
|
||||
failure: FailureDetails::new(rt).into(),
|
||||
tls_record: tls_record.clone(),
|
||||
interval: AggregateFrequency::Daily,
|
||||
span_id: 0,
|
||||
}))
|
||||
.await;
|
||||
}
|
||||
|
||||
// Wait for flush
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
let reports = test.read_report_events::<TlsInternalReport>().await;
|
||||
assert_eq!(reports.len(), 1);
|
||||
let (report_id, report) = reports.into_iter().next().unwrap();
|
||||
assert_eq!(report.report.policies.len(), 3);
|
||||
test.server
|
||||
.send_tls_aggregate_report(report_id.id())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Expect report
|
||||
let message = test.expect_message().await;
|
||||
assert_eq!(
|
||||
message.message.recipients.last().unwrap().address(),
|
||||
"[email protected]"
|
||||
);
|
||||
assert_eq!(message.message.return_path.as_ref(), "[email protected]");
|
||||
message
|
||||
.read_lines(&test)
|
||||
.await
|
||||
.assert_contains("DKIM-Signature: v=1; a=rsa-sha256; s=rsa; d=example.org;")
|
||||
.assert_contains("To: <[email protected]>")
|
||||
.assert_contains("Report Domain: foobar.org")
|
||||
.assert_contains("Submitter: mx.example.org");
|
||||
|
||||
// Verify generated report
|
||||
let report =
|
||||
TlsReport::parse_rfc5322(message.read_message(&test).await.as_bytes(), usize::MAX).unwrap();
|
||||
assert_eq!(report.organization_name.unwrap(), "Foobar, Inc.");
|
||||
assert_eq!(report.contact_info.unwrap(), "https://foobar.org/contact");
|
||||
assert_eq!(report.policies.len(), 3);
|
||||
let mut seen = [false; 3];
|
||||
for policy in report.policies {
|
||||
match policy.policy.policy_type {
|
||||
PolicyType::Tlsa => {
|
||||
seen[0] = true;
|
||||
assert_eq!(policy.summary.total_failure, 1);
|
||||
assert_eq!(policy.summary.total_success, 0);
|
||||
assert_eq!(policy.policy.policy_domain, "foobar.org");
|
||||
assert_eq!(policy.failure_details.len(), 1);
|
||||
assert_eq!(
|
||||
policy.failure_details.first().unwrap().result_type,
|
||||
ResultType::TlsaInvalid
|
||||
);
|
||||
}
|
||||
PolicyType::Sts => {
|
||||
seen[1] = true;
|
||||
assert_eq!(policy.summary.total_failure, 3);
|
||||
assert_eq!(policy.summary.total_success, 0);
|
||||
assert_eq!(policy.policy.policy_domain, "foobar.org");
|
||||
assert_eq!(policy.failure_details.len(), 3);
|
||||
assert!(
|
||||
policy
|
||||
.failure_details
|
||||
.iter()
|
||||
.any(|d| d.result_type == ResultType::StsPolicyFetchError)
|
||||
);
|
||||
assert!(
|
||||
policy
|
||||
.failure_details
|
||||
.iter()
|
||||
.any(|d| d.result_type == ResultType::StsPolicyInvalid)
|
||||
);
|
||||
assert!(
|
||||
policy
|
||||
.failure_details
|
||||
.iter()
|
||||
.any(|d| d.result_type == ResultType::StsWebpkiInvalid)
|
||||
);
|
||||
}
|
||||
PolicyType::NoPolicyFound => {
|
||||
seen[2] = true;
|
||||
assert_eq!(policy.summary.total_failure, 1);
|
||||
assert_eq!(policy.summary.total_success, 2);
|
||||
assert_eq!(policy.policy.policy_domain, "foobar.org");
|
||||
assert_eq!(policy.failure_details.len(), 1);
|
||||
/*assert_eq!(
|
||||
policy.failure_details.first().unwrap().result_type,
|
||||
ResultType::CertificateExpired
|
||||
);*/
|
||||
}
|
||||
PolicyType::Other => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
assert!(seen[0]);
|
||||
assert!(seen[1]);
|
||||
assert!(seen[2]);
|
||||
|
||||
// Schedule TLS reports to be delivered via https
|
||||
let tls_record = Arc::new(TlsRpt::parse(b"v=TLSRPTv1;rua=https://127.0.0.1/tls").unwrap());
|
||||
|
||||
for _ in 0..2 {
|
||||
// Add two successful records
|
||||
test.server
|
||||
.schedule_tls(Box::new(TlsEvent {
|
||||
domain: "foobar.org".to_string(),
|
||||
policy: common::ipc::PolicyType::None,
|
||||
failure: None,
|
||||
tls_record: tls_record.clone(),
|
||||
interval: AggregateFrequency::Daily,
|
||||
span_id: 0,
|
||||
}))
|
||||
.await;
|
||||
}
|
||||
|
||||
let reports = test.read_report_events::<TlsInternalReport>().await;
|
||||
assert_eq!(reports.len(), 1);
|
||||
test.server
|
||||
.send_tls_aggregate_report(reports.first().unwrap().0.id())
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
// Uncompress report
|
||||
{
|
||||
let gz_report = TLS_HTTP_REPORT.lock();
|
||||
let mut file = GzDecoder::new(&gz_report[..]);
|
||||
let mut buf = Vec::new();
|
||||
file.read_to_end(&mut buf).unwrap();
|
||||
let report = TlsReport::parse_json(&buf).unwrap();
|
||||
assert_eq!(report.organization_name.unwrap(), "Foobar, Inc.");
|
||||
assert_eq!(report.contact_info.unwrap(), "https://foobar.org/contact");
|
||||
assert_eq!(report.policies.len(), 1);
|
||||
}
|
||||
test.assert_report_is_empty::<TlsInternalReport>().await;
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use base64::{Engine, engine::general_purpose};
|
||||
use common::{
|
||||
Server,
|
||||
config::server::{DEFAULT_TLS_TIMEOUT, ServerProtocol},
|
||||
network::{ServerInstance, SessionStream, TcpAcceptor, limiter::ConcurrencyLimiter},
|
||||
};
|
||||
use rustls::{ServerConfig, server::ResolvesServerCert};
|
||||
use smtp::core::{Session, SessionAddress, SessionData, SessionParameters, State};
|
||||
use smtp::queue::MessageSource;
|
||||
use std::{borrow::Cow, path::PathBuf, sync::Arc};
|
||||
use tokio::{
|
||||
io::{AsyncRead, AsyncWrite},
|
||||
sync::watch,
|
||||
};
|
||||
use tokio_rustls::TlsAcceptor;
|
||||
use utils::snowflake::SnowflakeIdGenerator;
|
||||
|
||||
pub struct DummyIo {
|
||||
pub tx_buf: Vec<u8>,
|
||||
pub rx_buf: Vec<u8>,
|
||||
pub tls: bool,
|
||||
}
|
||||
|
||||
impl AsyncRead for DummyIo {
|
||||
fn poll_read(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
_cx: &mut std::task::Context<'_>,
|
||||
buf: &mut tokio::io::ReadBuf<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
if !self.rx_buf.is_empty() {
|
||||
buf.put_slice(&self.rx_buf);
|
||||
self.rx_buf.clear();
|
||||
std::task::Poll::Ready(Ok(()))
|
||||
} else {
|
||||
std::task::Poll::Pending
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncWrite for DummyIo {
|
||||
fn poll_write(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
_cx: &mut std::task::Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> std::task::Poll<Result<usize, std::io::Error>> {
|
||||
self.tx_buf.extend_from_slice(buf);
|
||||
std::task::Poll::Ready(Ok(buf.len()))
|
||||
}
|
||||
|
||||
fn poll_flush(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
_cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<Result<(), std::io::Error>> {
|
||||
std::task::Poll::Ready(Ok(()))
|
||||
}
|
||||
|
||||
fn poll_shutdown(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
_cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<Result<(), std::io::Error>> {
|
||||
std::task::Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionStream for DummyIo {
|
||||
fn is_tls(&self) -> bool {
|
||||
self.tls
|
||||
}
|
||||
|
||||
fn tls_version_and_cipher(&self) -> (Cow<'static, str>, Cow<'static, str>) {
|
||||
("".into(), "".into())
|
||||
}
|
||||
}
|
||||
|
||||
impl Unpin for DummyIo {}
|
||||
|
||||
#[allow(async_fn_in_trait)]
|
||||
pub trait TestSession {
|
||||
fn test(server: Server) -> Self;
|
||||
fn test_with_shutdown(server: Server, shutdown_rx: watch::Receiver<bool>) -> Self;
|
||||
fn response(&mut self) -> Vec<String>;
|
||||
fn write_rx(&mut self, data: &str);
|
||||
async fn rset(&mut self);
|
||||
async fn cmd(&mut self, cmd: &str, expected_code: &str) -> Vec<String>;
|
||||
async fn auth_plain(&mut self, username: &str, secret: &str, expected_code: &str);
|
||||
async fn auth_login(&mut self, username: &str, secret: &str, expected_code: &str);
|
||||
async fn ehlo(&mut self, host: &str) -> Vec<String>;
|
||||
async fn mail_from(&mut self, from: &str, expected_code: &str);
|
||||
async fn rcpt_to(&mut self, to: &str, expected_code: &str);
|
||||
async fn data(&mut self, data: &str, expected_code: &str);
|
||||
async fn send_message(&mut self, from: &str, to: &[&str], data: &str, expected_code: &str);
|
||||
async fn test_builder(&self);
|
||||
}
|
||||
|
||||
impl TestSession for Session<DummyIo> {
|
||||
fn test_with_shutdown(server: Server, shutdown_rx: watch::Receiver<bool>) -> Self {
|
||||
Self {
|
||||
state: State::default(),
|
||||
instance: Arc::new(ServerInstance::test_with_shutdown(shutdown_rx)),
|
||||
server,
|
||||
stream: DummyIo {
|
||||
rx_buf: vec![],
|
||||
tx_buf: vec![],
|
||||
tls: false,
|
||||
},
|
||||
data: SessionData::new(
|
||||
"127.0.0.1".parse().unwrap(),
|
||||
0,
|
||||
"127.0.0.1".parse().unwrap(),
|
||||
0,
|
||||
Default::default(),
|
||||
0,
|
||||
),
|
||||
params: SessionParameters::default(),
|
||||
hostname: "localhost".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn test(server: Server) -> Self {
|
||||
Self::test_with_shutdown(server, watch::channel(false).1)
|
||||
}
|
||||
|
||||
fn response(&mut self) -> Vec<String> {
|
||||
if !self.stream.tx_buf.is_empty() {
|
||||
let response = std::str::from_utf8(&self.stream.tx_buf)
|
||||
.unwrap()
|
||||
.split("\r\n")
|
||||
.filter_map(|r| {
|
||||
if !r.is_empty() {
|
||||
r.to_string().into()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
self.stream.tx_buf.clear();
|
||||
response
|
||||
} else {
|
||||
panic!("There was no response.");
|
||||
}
|
||||
}
|
||||
|
||||
fn write_rx(&mut self, data: &str) {
|
||||
self.stream.rx_buf.extend_from_slice(data.as_bytes());
|
||||
}
|
||||
|
||||
async fn rset(&mut self) {
|
||||
self.ingest(b"RSET\r\n").await.unwrap();
|
||||
self.response().assert_code("250");
|
||||
}
|
||||
|
||||
async fn cmd(&mut self, cmd: &str, expected_code: &str) -> Vec<String> {
|
||||
self.ingest(format!("{cmd}\r\n").as_bytes()).await.unwrap();
|
||||
self.response().assert_code(expected_code)
|
||||
}
|
||||
|
||||
async fn auth_plain(&mut self, username: &str, secret: &str, expected_code: &str) {
|
||||
let cmd = format!(
|
||||
"AUTH PLAIN {}",
|
||||
general_purpose::STANDARD.encode(format!("\0{username}\0{secret}"))
|
||||
);
|
||||
self.cmd(&cmd, expected_code).await;
|
||||
}
|
||||
|
||||
async fn auth_login(&mut self, username: &str, secret: &str, expected_code: &str) {
|
||||
self.cmd("AUTH LOGIN", "334").await;
|
||||
self.cmd(&general_purpose::STANDARD.encode(username), "334")
|
||||
.await;
|
||||
self.cmd(&general_purpose::STANDARD.encode(secret), expected_code)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn ehlo(&mut self, host: &str) -> Vec<String> {
|
||||
self.ingest(format!("EHLO {host}\r\n").as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
self.response().assert_code("250")
|
||||
}
|
||||
|
||||
async fn mail_from(&mut self, from: &str, expected_code: &str) {
|
||||
self.ingest(
|
||||
if !from.starts_with('<') {
|
||||
format!("MAIL FROM:<{from}>\r\n")
|
||||
} else {
|
||||
format!("MAIL FROM:{from}\r\n")
|
||||
}
|
||||
.as_bytes(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
self.response().assert_code(expected_code);
|
||||
}
|
||||
|
||||
async fn rcpt_to(&mut self, to: &str, expected_code: &str) {
|
||||
self.ingest(
|
||||
if !to.starts_with('<') {
|
||||
format!("RCPT TO:<{to}>\r\n")
|
||||
} else {
|
||||
format!("RCPT TO:{to}\r\n")
|
||||
}
|
||||
.as_bytes(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
self.response().assert_code(expected_code);
|
||||
}
|
||||
|
||||
async fn data(&mut self, data: &str, expected_code: &str) {
|
||||
self.ingest(b"DATA\r\n").await.unwrap();
|
||||
self.response().assert_code("354");
|
||||
if let Some(file) = data.strip_prefix("test:") {
|
||||
self.ingest(load_test_message(file, "messages").as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
} else if let Some(file) = data.strip_prefix("report:") {
|
||||
self.ingest(load_test_message(file, "reports").as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
} else {
|
||||
self.ingest(data.as_bytes()).await.unwrap();
|
||||
}
|
||||
self.ingest(b"\r\n.\r\n").await.unwrap();
|
||||
self.response().assert_code(expected_code);
|
||||
}
|
||||
|
||||
async fn send_message(&mut self, from: &str, to: &[&str], data: &str, expected_code: &str) {
|
||||
self.mail_from(from, "250").await;
|
||||
for to in to {
|
||||
self.rcpt_to(to, "250").await;
|
||||
}
|
||||
self.data(data, expected_code).await;
|
||||
}
|
||||
|
||||
async fn test_builder(&self) {
|
||||
let message = self
|
||||
.build_message(
|
||||
SessionAddress {
|
||||
address: "[email protected]".into(),
|
||||
address_lcase: "[email protected]".into(),
|
||||
domain: "foobar.org".into(),
|
||||
flags: 123,
|
||||
dsn_info: Some("envelope1".into()),
|
||||
},
|
||||
vec![
|
||||
SessionAddress {
|
||||
address: "[email protected]".into(),
|
||||
address_lcase: "[email protected]".into(),
|
||||
domain: "foobar.org".into(),
|
||||
flags: 1,
|
||||
dsn_info: None,
|
||||
},
|
||||
SessionAddress {
|
||||
address: "[email protected]".into(),
|
||||
address_lcase: "[email protected]".into(),
|
||||
domain: "test.net".into(),
|
||||
flags: 2,
|
||||
dsn_info: None,
|
||||
},
|
||||
SessionAddress {
|
||||
address: "[email protected]".into(),
|
||||
address_lcase: "[email protected]".into(),
|
||||
domain: "foobar.org".into(),
|
||||
flags: 3,
|
||||
dsn_info: None,
|
||||
},
|
||||
SessionAddress {
|
||||
address: "[email protected]".into(),
|
||||
address_lcase: "[email protected]".into(),
|
||||
domain: "test.net".into(),
|
||||
flags: 4,
|
||||
dsn_info: None,
|
||||
},
|
||||
],
|
||||
MessageSource::Authenticated,
|
||||
self.server.inner.data.queue_id_gen.generate(),
|
||||
0,
|
||||
)
|
||||
.await;
|
||||
|
||||
let rcpts = ["[email protected]", "[email protected]", "[email protected]", "[email protected]"];
|
||||
for rcpt in &message.message.recipients {
|
||||
let idx = (rcpt.flags - 1) as usize;
|
||||
assert_eq!(rcpts[idx], rcpt.address());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_test_message(file: &str, test: &str) -> String {
|
||||
let mut test_file = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
test_file.push("resources");
|
||||
test_file.push("smtp");
|
||||
test_file.push(test);
|
||||
test_file.push(format!("{file}.eml"));
|
||||
std::fs::read_to_string(test_file).unwrap()
|
||||
}
|
||||
|
||||
pub trait VerifyResponse {
|
||||
fn assert_code(self, expected_code: &str) -> Self;
|
||||
fn assert_contains(self, expected_text: &str) -> Self;
|
||||
fn assert_not_contains(self, expected_text: &str) -> Self;
|
||||
fn assert_count(self, text: &str, occurrences: usize) -> Self;
|
||||
}
|
||||
|
||||
impl VerifyResponse for Vec<String> {
|
||||
fn assert_code(self, expected_code: &str) -> Self {
|
||||
if self.last().expect("response").starts_with(expected_code) {
|
||||
self
|
||||
} else {
|
||||
panic!("Expected {:?} but got {}.", expected_code, self.join("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_contains(self, expected_text: &str) -> Self {
|
||||
if self.iter().any(|line| line.contains(expected_text)) {
|
||||
self
|
||||
} else {
|
||||
panic!("Expected {:?} but got {}.", expected_text, self.join("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_not_contains(self, expected_text: &str) -> Self {
|
||||
if !self.iter().any(|line| line.contains(expected_text)) {
|
||||
self
|
||||
} else {
|
||||
panic!(
|
||||
"Not expecting {:?} but got it {}.",
|
||||
expected_text,
|
||||
self.join("\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_count(self, text: &str, occurrences: usize) -> Self {
|
||||
assert_eq!(
|
||||
self.iter().filter(|l| l.contains(text)).count(),
|
||||
occurrences,
|
||||
"Expected {} occurrences of {:?}, found {}.",
|
||||
occurrences,
|
||||
text,
|
||||
self.iter().filter(|l| l.contains(text)).count()
|
||||
);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub trait TestServerInstance {
|
||||
fn test_with_shutdown(shutdown_rx: watch::Receiver<bool>) -> Self;
|
||||
}
|
||||
|
||||
impl TestServerInstance for ServerInstance {
|
||||
fn test_with_shutdown(shutdown_rx: watch::Receiver<bool>) -> Self {
|
||||
let tls_config = Arc::new(
|
||||
ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_cert_resolver(Arc::new(DummyCertResolver)),
|
||||
);
|
||||
|
||||
Self {
|
||||
id: "smtp".to_string(),
|
||||
protocol: ServerProtocol::Smtp,
|
||||
acceptor: TcpAcceptor::Tls {
|
||||
config: tls_config.clone(),
|
||||
acceptor: TlsAcceptor::from(tls_config),
|
||||
implicit: false,
|
||||
},
|
||||
limiter: ConcurrencyLimiter::new(100),
|
||||
tls_timeout: DEFAULT_TLS_TIMEOUT,
|
||||
shutdown_rx,
|
||||
proxy_networks: vec![],
|
||||
span_id_gen: Arc::new(SnowflakeIdGenerator::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DummyCertResolver;
|
||||
|
||||
impl ResolvesServerCert for DummyCertResolver {
|
||||
fn resolve(&self, _: rustls::server::ClientHello) -> Option<Arc<rustls::sign::CertifiedKey>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn test_server_instance() -> ServerInstance {
|
||||
ServerInstance::test_with_shutdown(watch::channel(false).1)
|
||||
}
|
||||
Reference in New Issue
Block a user