Files
Stalwart/tests/src/smtp/inbound/antispam.rs
2026-03-25 18:55:39 +01:00

932 lines
33 KiB
Rust

/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* 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, IprevOutput, IprevResult, MX, SpfOutput,
SpfResult, dkim::Signature, dmarc::Policy,
};
use mail_parser::MessageParser;
use registry::{
schema::{
enums::{AiModelType, TaskSpamFilterMaintenanceType},
structs::{
self, AiModel, MemoryLookupKey, SpamLlm, SpamLlmProperties, SpamSettings,
SpamTrainingSample, Task, TaskSpamFilterMaintenance, TaskStatus,
},
},
types::{float::Float, map::Map},
};
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},
};
const CONFIG: &str = r#"
[spam-filter.score]
spam = "5.0"
[spam-filter.llm]
enable = true
model = "dummy"
prompt = "You are an AI assistant specialized in analyzing email content to detect unsolicited, commercial, or harmful messages. Format your response as follows, separated by commas: Category,Confidence,Explanation
Here's the email to analyze, please provide your analysis based on the above instructions, ensuring your response is in the specified comma-separated format."
separator = ","
categories = ["Unsolicited", "Commercial", "Harmful", "Legitimate"]
confidence = ["High", "Medium", "Low"]
[spam-filter.llm.index]
category = 0
confidence = 1
explanation = 2
[spam-filter.classifier.samples]
min-ham = 10
min-spam = 10
[session.rcpt]
relay = true
[storage]
data = "spamdb"
lookup = "spamdb"
blob = "spamdb"
fts = "spamdb"
directory = "spamdb"
[directory."spamdb"]
type = "internal"
store = "spamdb"
[store."spamdb"]
type = "rocksdb"
path = "{PATH}/test_antispam.db"
#[store."redis"]
#type = "redis"
#url = "redis://127.0.0.1"
[http-lookup.STWT_OPENPHISH]
enable = true
url = "https://openphish.com/feed.txt"
format = "list"
retry = "1h"
refresh = "12h"
timeout = "30s"
limits.size = 104857600
limits.entries = 900000
limits.entry-size = 512
[http-lookup.STWT_PHISHTANK]
enable = true
url = "http://data.phishtank.com/data/online-valid.csv.gz"
format = "csv"
separator = ","
index.key = 1
skip-first = true
gzipped = true
retry = "1h"
refresh = "6h"
timeout = "30s"
limits.size = 104857600
limits.entries = 900000
limits.entry-size = 512
[http-lookup.STWT_DISPOSABLE_DOMAINS]
enable = true
url = "https://disposable.github.io/disposable-email-domains/domains_mx.txt"
format = "list"
retry = "1h"
refresh = "24h"
timeout = "30s"
limits.size = 104857600
limits.entries = 900000
limits.entry-size = 512
[http-lookup.STWT_FREE_DOMAINS]
enable = true
url = "https://gist.githubusercontent.com/okutbay/5b4974b70673dfdcc21c517632c1f984/raw/993a35930a8d24a1faab1b988d19d38d92afbba4/free_email_provider_domains.txt"
format = "list"
retry = "1h"
refresh = "720h"
timeout = "30s"
limits.size = 104857600
limits.entries = 900000
limits.entry-size = 512
[enterprise.ai.dummy]
url = "https://127.0.0.1:9090/v1/chat/completions"
type = "chat"
model = "gpt-dummy"
allow-invalid-certs = true
[spam-filter.list]
"file-extensions" = { "html" = "text/html|BAD",
"pdf" = "application/pdf|NZ",
"txt" = "text/plain|message/disposition-notification|text/rfc822-headers",
"zip" = "AR",
"js" = "BAD|NZ",
"hta" = "BAD|NZ" }
[lookup]
"url-redirectors" = {"bit.ly", "redirect.io", "redirect.me", "redirect.org", "redirect.com", "redirect.net", "t.ly", "tinyurl.com"}
"spam-traps" = {"spamtrap@*"}
"trusted-domains" = {"stalw.art"}
"surbl-hashbl" = {"bit.ly", "drive.google.com", "lnkiy.in"}
"#;
#[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 {
class: 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;
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",
),
(
"637d6717761b5de0c84108c894bb68f2.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,
}],
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_object(SpamTrainingSample {
blob_id,
from: "unknown".to_string(),
is_spam: class == "spam",
subject: "unknown".to_string(),
..Default::default()
})
.await;
}
}
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,
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,
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"),
}
}
}