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;
|
||||
}
|
||||
Reference in New Issue
Block a user