Port Spam filter to Rust - part 2
This commit is contained in:
@@ -10,6 +10,8 @@ use utils::{config::Config, glob::GlobSet};
|
||||
pub struct SpamFilterConfig {
|
||||
pub list_dmarc_allow: GlobSet,
|
||||
pub list_spf_dkim_allow: GlobSet,
|
||||
pub list_freemail_providers: GlobSet,
|
||||
pub list_disposable_providers: GlobSet,
|
||||
}
|
||||
|
||||
impl SpamFilterConfig {
|
||||
|
||||
@@ -243,20 +243,24 @@ pub fn fn_levenshtein_distance<'x>(_: &'x Context<'x>, v: Vec<Variable>) -> Vari
|
||||
let a = v[0].to_string();
|
||||
let b = v[1].to_string();
|
||||
|
||||
levenshtein_distance(a.as_ref(), b.as_ref()).into()
|
||||
}
|
||||
|
||||
pub fn levenshtein_distance(a: &str, b: &str) -> usize {
|
||||
let mut result = 0;
|
||||
|
||||
/* Shortcut optimizations / degenerate cases. */
|
||||
if a == b {
|
||||
return result.into();
|
||||
return result;
|
||||
}
|
||||
|
||||
let length_a = a.chars().count();
|
||||
let length_b = b.chars().count();
|
||||
|
||||
if length_a == 0 {
|
||||
return length_b.into();
|
||||
return length_b;
|
||||
} else if length_b == 0 {
|
||||
return length_a.into();
|
||||
return length_a;
|
||||
}
|
||||
|
||||
/* Initialize the vector.
|
||||
@@ -297,7 +301,7 @@ pub fn fn_levenshtein_distance<'x>(_: &'x Context<'x>, v: Vec<Variable>) -> Vari
|
||||
}
|
||||
}
|
||||
|
||||
result.into()
|
||||
result
|
||||
}
|
||||
|
||||
pub fn fn_detect_language<'x>(_: &'x Context<'x>, v: Vec<Variable>) -> Variable {
|
||||
|
||||
@@ -10,6 +10,7 @@ nlp = { path = "../nlp" }
|
||||
store = { path = "../store" }
|
||||
trc = { path = "../trc" }
|
||||
common = { path = "../common" }
|
||||
smtp-proto = { version = "0.1", features = ["serde_support"] }
|
||||
mail-parser = { version = "0.9", features = ["full_encoding", "ludicrous_mode"] }
|
||||
mail-builder = { version = "0.3", features = ["ludicrous_mode"] }
|
||||
mail-auth = { version = "0.5" }
|
||||
|
||||
@@ -5,14 +5,14 @@ use store::write::now;
|
||||
|
||||
use crate::SpamFilterContext;
|
||||
|
||||
pub trait SpamFilterAnalyzeEhlo: Sync + Send {
|
||||
pub trait SpamFilterAnalyzeDate: Sync + Send {
|
||||
fn spam_filter_analyze_date(
|
||||
&self,
|
||||
ctx: &mut SpamFilterContext<'_>,
|
||||
) -> impl Future<Output = ()> + Send;
|
||||
}
|
||||
|
||||
impl SpamFilterAnalyzeEhlo for Core {
|
||||
impl SpamFilterAnalyzeDate for Core {
|
||||
async fn spam_filter_analyze_date(&self, ctx: &mut SpamFilterContext<'_>) {
|
||||
if let Some(date) = ctx.input.message.date() {
|
||||
let date = date.to_timestamp();
|
||||
@@ -21,16 +21,16 @@ impl SpamFilterAnalyzeEhlo for Core {
|
||||
|
||||
if date_diff > 86400 {
|
||||
// Older than a day
|
||||
ctx.add_tag("DATE_IN_PAST");
|
||||
ctx.result.add_tag("DATE_IN_PAST");
|
||||
} else if -date_diff > 7200 {
|
||||
//# More than 2 hours in the future
|
||||
ctx.add_tag("DATE_IN_FUTURE");
|
||||
ctx.result.add_tag("DATE_IN_FUTURE");
|
||||
}
|
||||
} else {
|
||||
ctx.add_tag("INVALID_DATE");
|
||||
ctx.result.add_tag("INVALID_DATE");
|
||||
}
|
||||
} else {
|
||||
ctx.add_tag("MISSING_DATE");
|
||||
ctx.result.add_tag("MISSING_DATE");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,26 +7,27 @@ use mail_auth::{
|
||||
|
||||
use crate::SpamFilterContext;
|
||||
|
||||
pub trait SpamFilterAnalyzeEhlo: Sync + Send {
|
||||
pub trait SpamFilterAnalyzeDmarc: Sync + Send {
|
||||
fn spam_filter_analyze_dmarc(
|
||||
&self,
|
||||
ctx: &mut SpamFilterContext<'_>,
|
||||
) -> impl Future<Output = ()> + Send;
|
||||
}
|
||||
|
||||
impl SpamFilterAnalyzeEhlo for Core {
|
||||
impl SpamFilterAnalyzeDmarc for Core {
|
||||
async fn spam_filter_analyze_dmarc(&self, ctx: &mut SpamFilterContext<'_>) {
|
||||
ctx.add_tag(match ctx.input.spf_mail_from_result.result() {
|
||||
SpfResult::Pass => "SPF_ALLOW",
|
||||
SpfResult::Fail => "SPF_FAIL",
|
||||
SpfResult::SoftFail => "SPF_SOFTFAIL",
|
||||
SpfResult::Neutral => "SPF_NEUTRAL",
|
||||
SpfResult::TempError => "SPF_DNSFAIL",
|
||||
SpfResult::PermError => "SPF_PERMFAIL",
|
||||
SpfResult::None => "SPF_NA",
|
||||
});
|
||||
ctx.result
|
||||
.add_tag(match ctx.input.spf_mail_from_result.result() {
|
||||
SpfResult::Pass => "SPF_ALLOW",
|
||||
SpfResult::Fail => "SPF_FAIL",
|
||||
SpfResult::SoftFail => "SPF_SOFTFAIL",
|
||||
SpfResult::Neutral => "SPF_NEUTRAL",
|
||||
SpfResult::TempError => "SPF_DNSFAIL",
|
||||
SpfResult::PermError => "SPF_PERMFAIL",
|
||||
SpfResult::None => "SPF_NA",
|
||||
});
|
||||
|
||||
ctx.add_tag(
|
||||
ctx.result.add_tag(
|
||||
match ctx
|
||||
.input
|
||||
.dkim_result
|
||||
@@ -44,7 +45,7 @@ impl SpamFilterAnalyzeEhlo for Core {
|
||||
},
|
||||
);
|
||||
|
||||
ctx.add_tag(match ctx.input.arc_result.result() {
|
||||
ctx.result.add_tag(match ctx.input.arc_result.result() {
|
||||
DkimResult::Pass => "ARC_ALLOW",
|
||||
DkimResult::Fail(_) => "ARC_REJECT",
|
||||
DkimResult::PermError(_) => "ARC_INVALID",
|
||||
@@ -52,7 +53,7 @@ impl SpamFilterAnalyzeEhlo for Core {
|
||||
DkimResult::Neutral(_) | DkimResult::None => "ARC_NA",
|
||||
});
|
||||
|
||||
ctx.add_tag(match ctx.input.dmarc_result {
|
||||
ctx.result.add_tag(match ctx.input.dmarc_result {
|
||||
DmarcResult::Pass => "DMARC_POLICY_ALLOW",
|
||||
DmarcResult::TempError(_) => "DMARC_DNSFAIL",
|
||||
DmarcResult::PermError(_) => "DMARC_BAD_POLICY",
|
||||
@@ -67,55 +68,55 @@ impl SpamFilterAnalyzeEhlo for Core {
|
||||
for header in ctx.input.message.headers() {
|
||||
let header_name = header.name();
|
||||
if header_name.eq_ignore_ascii_case("DKIM-Signature") {
|
||||
ctx.add_tag("DKIM_SIGNED");
|
||||
ctx.result.add_tag("DKIM_SIGNED");
|
||||
} else if header_name.eq_ignore_ascii_case("ARC-Seal") {
|
||||
ctx.add_tag("ARC_SIGNED");
|
||||
ctx.result.add_tag("ARC_SIGNED");
|
||||
}
|
||||
}
|
||||
|
||||
if self
|
||||
.spam
|
||||
.list_dmarc_allow
|
||||
.contains(&ctx.output.from_addr.domain_part.fqdn)
|
||||
.contains(&ctx.output.from.email.domain_part.fqdn)
|
||||
{
|
||||
if matches!(ctx.input.dmarc_result, DmarcResult::Pass) {
|
||||
ctx.add_tag("ALLOWLIST_DMARC");
|
||||
ctx.result.add_tag("ALLOWLIST_DMARC");
|
||||
} else {
|
||||
ctx.add_tag("BLOCKLIST_DMARC");
|
||||
ctx.result.add_tag("BLOCKLIST_DMARC");
|
||||
}
|
||||
} else if self
|
||||
.spam
|
||||
.list_spf_dkim_allow
|
||||
.contains(&ctx.output.from_addr.domain_part.fqdn)
|
||||
.contains(&ctx.output.from.email.domain_part.fqdn)
|
||||
{
|
||||
let is_dkim_pass = matches!(ctx.input.arc_result.result(), DkimResult::Pass)
|
||||
|| ctx.input.dkim_result.iter().any(|r| {
|
||||
matches!(r.result(), DkimResult::Pass)
|
||||
&& r.signature().map_or(false, |s| {
|
||||
s.domain().to_lowercase() == ctx.output.from_addr.domain_part.fqdn
|
||||
s.domain().to_lowercase() == ctx.output.from.email.domain_part.fqdn
|
||||
})
|
||||
});
|
||||
let is_spf_pass = matches!(ctx.input.spf_mail_from_result.result(), SpfResult::Pass);
|
||||
|
||||
if is_dkim_pass && is_spf_pass {
|
||||
ctx.add_tag("ALLOWLIST_SPF_DKIM");
|
||||
ctx.result.add_tag("ALLOWLIST_SPF_DKIM");
|
||||
} else if is_dkim_pass {
|
||||
ctx.add_tag("ALLOWLIST_DKIM");
|
||||
ctx.result.add_tag("ALLOWLIST_DKIM");
|
||||
if !matches!(
|
||||
ctx.input.spf_mail_from_result.result(),
|
||||
SpfResult::TempError
|
||||
) {
|
||||
ctx.add_tag("BLOCKLIST_SPF");
|
||||
ctx.result.add_tag("BLOCKLIST_SPF");
|
||||
}
|
||||
} else if is_spf_pass {
|
||||
ctx.add_tag("ALLOWLIST_SPF");
|
||||
ctx.result.add_tag("ALLOWLIST_SPF");
|
||||
if !ctx
|
||||
.input
|
||||
.dkim_result
|
||||
.iter()
|
||||
.any(|r| matches!(r.result(), DkimResult::TempError(_)))
|
||||
{
|
||||
ctx.add_tag("BLOCKLIST_DKIM");
|
||||
ctx.result.add_tag("BLOCKLIST_DKIM");
|
||||
}
|
||||
} else if !matches!(
|
||||
ctx.input.spf_mail_from_result.result(),
|
||||
@@ -126,7 +127,7 @@ impl SpamFilterAnalyzeEhlo for Core {
|
||||
.iter()
|
||||
.any(|r| matches!(r.result(), DkimResult::TempError(_)))
|
||||
{
|
||||
ctx.add_tag("BLOCKLIST_SPF_DKIM");
|
||||
ctx.result.add_tag("BLOCKLIST_SPF_DKIM");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,11 +15,11 @@ impl SpamFilterAnalyzeEhlo for Core {
|
||||
async fn spam_filter_analyze_ehlo(&self, ctx: &mut SpamFilterContext<'_>) {
|
||||
if let Some(ehlo_ip) = ctx.output.ehlo_host.ip {
|
||||
// Helo host is bare ip
|
||||
ctx.add_tag("HELO_BAREIP");
|
||||
ctx.result.add_tag("HELO_BAREIP");
|
||||
|
||||
if ehlo_ip != ctx.input.remote_ip {
|
||||
// Helo A IP != hostname IP
|
||||
ctx.add_tag("HELO_IP_A");
|
||||
ctx.result.add_tag("HELO_IP_A");
|
||||
}
|
||||
} else if ctx.output.ehlo_host.sld.is_some() {
|
||||
if ctx
|
||||
@@ -29,7 +29,7 @@ impl SpamFilterAnalyzeEhlo for Core {
|
||||
.map_or(false, |ptr| ptr != &ctx.output.ehlo_host.fqdn)
|
||||
{
|
||||
// Helo does not match reverse IP
|
||||
ctx.add_tag("HELO_IPREV_MISMATCH");
|
||||
ctx.result.add_tag("HELO_IPREV_MISMATCH");
|
||||
}
|
||||
|
||||
if matches!(
|
||||
@@ -40,16 +40,16 @@ impl SpamFilterAnalyzeEhlo for Core {
|
||||
(Ok(false), Ok(false))
|
||||
) {
|
||||
// Helo no resolve to A or MX
|
||||
ctx.add_tag("HELO_NORES_A_OR_MX");
|
||||
ctx.result.add_tag("HELO_NORES_A_OR_MX");
|
||||
}
|
||||
} else {
|
||||
if ctx.output.ehlo_host.fqdn.contains("user") {
|
||||
// Helo host contains 'user'
|
||||
ctx.add_tag("RCVD_HELO_USER");
|
||||
ctx.result.add_tag("RCVD_HELO_USER");
|
||||
}
|
||||
|
||||
// Helo not FQDN
|
||||
ctx.add_tag("HELO_NOT_FQDN");
|
||||
ctx.result.add_tag("HELO_NOT_FQDN");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
310
crates/spam-filter/src/analysis/from.rs
Normal file
310
crates/spam-filter/src/analysis/from.rs
Normal file
@@ -0,0 +1,310 @@
|
||||
use std::future::Future;
|
||||
|
||||
use common::Core;
|
||||
use mail_parser::HeaderName;
|
||||
use smtp_proto::{MAIL_BODY_8BITMIME, MAIL_BODY_BINARYMIME, MAIL_SMTPUTF8};
|
||||
|
||||
use crate::{Email, SpamFilterContext};
|
||||
|
||||
pub trait SpamFilterAnalyzeFrom: Sync + Send {
|
||||
fn spam_filter_analyze_from(
|
||||
&self,
|
||||
ctx: &mut SpamFilterContext<'_>,
|
||||
) -> impl Future<Output = ()> + Send;
|
||||
}
|
||||
|
||||
const SERVICE_ACCOUNTS: [&str; 9] = [
|
||||
"www-data",
|
||||
"anonymous",
|
||||
"ftp",
|
||||
"apache",
|
||||
"nobody",
|
||||
"guest",
|
||||
"nginx",
|
||||
"web",
|
||||
"www",
|
||||
];
|
||||
pub(crate) const TITLES: [&str; 7] = ["mr. ", "mrs. ", "ms. ", "dr. ", "prof. ", "rev. ", "hon. "];
|
||||
|
||||
impl SpamFilterAnalyzeFrom for Core {
|
||||
async fn spam_filter_analyze_from(&self, ctx: &mut SpamFilterContext<'_>) {
|
||||
let mut from_count = 0;
|
||||
let mut from_raw = b"".as_slice();
|
||||
let mut crt = None;
|
||||
let mut dnt = None;
|
||||
let mut sender = None;
|
||||
|
||||
for header in ctx.input.message.headers() {
|
||||
match &header.name {
|
||||
HeaderName::From => {
|
||||
from_count += 1;
|
||||
from_raw = ctx
|
||||
.input
|
||||
.message
|
||||
.raw_message()
|
||||
.get(header.offset_start..header.offset_end)
|
||||
.unwrap_or_default();
|
||||
}
|
||||
HeaderName::Sender => {
|
||||
sender = header
|
||||
.value()
|
||||
.as_address()
|
||||
.and_then(|addrs| addrs.first())
|
||||
.and_then(|addr| addr.address())
|
||||
.map(Email::new);
|
||||
}
|
||||
HeaderName::Other(name) => {
|
||||
if name.eq_ignore_ascii_case("X-Confirm-Reading-To") {
|
||||
crt = ctx
|
||||
.input
|
||||
.header_as_address(header)
|
||||
.map(|s| s.to_lowercase());
|
||||
} else if name.eq_ignore_ascii_case("Disposition-Notification-To") {
|
||||
dnt = ctx
|
||||
.input
|
||||
.header_as_address(header)
|
||||
.map(|s| s.to_lowercase());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
match from_count {
|
||||
0 => {
|
||||
ctx.result.add_tag("MISSING_FROM");
|
||||
}
|
||||
1 => {}
|
||||
_ => {
|
||||
ctx.result.add_tag("MULTIPLE_FROM");
|
||||
}
|
||||
}
|
||||
|
||||
let env_from_empty = ctx.output.env_from_addr.address.is_empty();
|
||||
let mut is_from_service_account = false;
|
||||
let mut is_www_dot_domain = false;
|
||||
let from_addr = &ctx.output.from.email;
|
||||
let from_name = ctx.output.from.name.as_deref().unwrap_or_default();
|
||||
if from_count > 0 {
|
||||
// Validate address
|
||||
let from_addr_is_valid = from_addr.is_valid();
|
||||
if from_addr_is_valid {
|
||||
if SERVICE_ACCOUNTS.contains(&from_addr.local_part.as_str()) {
|
||||
is_from_service_account = true;
|
||||
}
|
||||
if from_addr.domain_part.fqdn.starts_with("www.") {
|
||||
is_www_dot_domain = true;
|
||||
}
|
||||
if self
|
||||
.spam
|
||||
.list_freemail_providers
|
||||
.contains(from_addr.domain_part.sld.as_deref().unwrap_or_default())
|
||||
{
|
||||
ctx.result.add_tag("FREEMAIL_FROM");
|
||||
} else if self
|
||||
.spam
|
||||
.list_disposable_providers
|
||||
.contains(from_addr.domain_part.sld.as_deref().unwrap_or_default())
|
||||
{
|
||||
ctx.result.add_tag("DISPOSABLE_FROM");
|
||||
}
|
||||
} else {
|
||||
ctx.result.add_tag("FROM_INVALID");
|
||||
}
|
||||
|
||||
// Validate from name
|
||||
let from_name_trimmed = from_name.trim();
|
||||
if from_name_trimmed.is_empty() {
|
||||
ctx.result.add_tag("FROM_NO_DN");
|
||||
} else if from_name_trimmed == from_addr.address {
|
||||
ctx.result.add_tag("FROM_DN_EQ_ADDR");
|
||||
} else {
|
||||
let from_name_addr = Email::new(from_name_trimmed);
|
||||
if from_addr_is_valid {
|
||||
ctx.result.add_tag("FROM_HAS_DN");
|
||||
}
|
||||
if from_name_addr.is_valid() {
|
||||
if (from_addr_is_valid
|
||||
&& from_name_addr.domain_part.sld != from_addr.domain_part.sld)
|
||||
|| (!env_from_empty
|
||||
&& ctx.output.env_from_addr.domain_part.sld
|
||||
!= from_name_addr.domain_part.sld)
|
||||
|| (env_from_empty
|
||||
&& ctx.output.ehlo_host.sld != from_name_addr.domain_part.sld)
|
||||
{
|
||||
ctx.result.add_tag("SPOOF_DISPLAY_NAME");
|
||||
} else {
|
||||
ctx.result.add_tag("FROM_NEQ_DISPLAY_NAME");
|
||||
}
|
||||
} else {
|
||||
for title in TITLES {
|
||||
if from_name.contains(title) {
|
||||
ctx.result.add_tag("FROM_NAME_HAS_TITLE");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if from_name.contains(" ") {
|
||||
ctx.result.add_tag("FROM_NAME_EXCESS_SPACE");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check sender
|
||||
if ctx.output.env_from_postmaster {
|
||||
ctx.result.add_tag("FROM_BOUNCE");
|
||||
}
|
||||
|
||||
if (!env_from_empty && ctx.output.env_from_addr.address == from_addr.address)
|
||||
|| (!ctx.output.env_from_postmaster
|
||||
&& from_addr_is_valid
|
||||
&& from_addr.domain_part.sld == ctx.output.ehlo_host.sld)
|
||||
{
|
||||
ctx.result.add_tag("FROM_EQ_ENVFROM");
|
||||
} else if from_addr_is_valid {
|
||||
ctx.result.add_tag("FORGED_SENDER");
|
||||
ctx.result.add_tag("FROM_NEQ_ENVFROM");
|
||||
}
|
||||
|
||||
if from_addr.local_part.contains("+") {
|
||||
ctx.result.add_tag("TAGGED_FROM");
|
||||
}
|
||||
|
||||
// Validate FROM/TO relationship
|
||||
if ctx.output.recipients_to.len() + ctx.output.recipients_cc.len() == 1 {
|
||||
let rcpt = ctx
|
||||
.output
|
||||
.recipients_to
|
||||
.first()
|
||||
.or_else(|| ctx.output.recipients_cc.first())
|
||||
.unwrap();
|
||||
if rcpt.email.address == from_addr.address {
|
||||
ctx.result.add_tag("TO_EQ_FROM");
|
||||
} else if rcpt.email.domain_part.fqdn == from_addr.domain_part.fqdn {
|
||||
ctx.result.add_tag("TO_DOM_EQ_FROM_DOM");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate encoding
|
||||
let from_raw_utf8 = std::str::from_utf8(from_raw);
|
||||
if !from_raw.is_ascii() {
|
||||
if (ctx.input.env_from_flags
|
||||
& (MAIL_SMTPUTF8 | MAIL_BODY_8BITMIME | MAIL_BODY_BINARYMIME))
|
||||
== 0
|
||||
{
|
||||
ctx.result.add_tag("FROM_NEEDS_ENCODING");
|
||||
}
|
||||
|
||||
if from_raw_utf8.is_err() {
|
||||
ctx.result.add_tag("INVALID_FROM_8BIT");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate unnecessary encoding
|
||||
let from_raw_utf8 = from_raw_utf8.unwrap_or_default();
|
||||
if from_name.is_ascii()
|
||||
&& from_addr.address.is_ascii()
|
||||
&& from_raw_utf8.contains("=?")
|
||||
&& from_raw_utf8.contains("?=")
|
||||
{
|
||||
if from_raw_utf8.contains("?q?") || from_raw_utf8.contains("?Q?") {
|
||||
// From header is unnecessarily encoded in quoted-printable
|
||||
ctx.result.add_tag("FROM_EXCESS_QP");
|
||||
} else if from_raw_utf8.contains("?b?") || from_raw_utf8.contains("?B?") {
|
||||
// From header is unnecessarily encoded in base64
|
||||
ctx.result.add_tag("FROM_EXCESS_BASE64");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate space in FROM
|
||||
if !from_name.is_empty()
|
||||
&& !from_addr.address.is_empty()
|
||||
&& !from_raw_utf8.contains(" <")
|
||||
{
|
||||
ctx.result.add_tag("R_NO_SPACE_IN_FROM");
|
||||
}
|
||||
|
||||
// Check whether read confirmation address is different to from address
|
||||
if let Some(crt) = crt {
|
||||
if crt != from_addr.address {
|
||||
ctx.result.add_tag("HEADER_RCONFIRM_MISMATCH");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !env_from_empty {
|
||||
// Validate envelope address
|
||||
if ctx.output.env_from_addr.is_valid() {
|
||||
if SERVICE_ACCOUNTS.contains(&ctx.output.env_from_addr.local_part.as_str()) {
|
||||
ctx.result.add_tag("ENVFROM_SERVICE_ACCT");
|
||||
}
|
||||
if self.spam.list_freemail_providers.contains(
|
||||
ctx.output
|
||||
.env_from_addr
|
||||
.domain_part
|
||||
.sld
|
||||
.as_deref()
|
||||
.unwrap_or_default(),
|
||||
) {
|
||||
ctx.result.add_tag("FREEMAIL_ENVFROM");
|
||||
} else if self.spam.list_disposable_providers.contains(
|
||||
ctx.output
|
||||
.env_from_addr
|
||||
.domain_part
|
||||
.sld
|
||||
.as_deref()
|
||||
.unwrap_or_default(),
|
||||
) {
|
||||
ctx.result.add_tag("DISPOSABLE_ENVFROM");
|
||||
}
|
||||
|
||||
// Mail from no resolve to A or MX
|
||||
if matches!(
|
||||
(
|
||||
self.dns_exists_ip(&ctx.output.env_from_addr.domain_part.fqdn)
|
||||
.await,
|
||||
self.dns_exists_mx(&ctx.output.env_from_addr.domain_part.fqdn)
|
||||
.await
|
||||
),
|
||||
(Ok(false), Ok(false))
|
||||
) {
|
||||
// Helo no resolve to A or MX
|
||||
ctx.result.add_tag("FROMHOST_NORES_A_OR_MX");
|
||||
}
|
||||
} else {
|
||||
ctx.result.add_tag("ENVFROM_INVALID");
|
||||
}
|
||||
|
||||
// Check whether disposition notification address is different to return path
|
||||
if let Some(dnt) = dnt {
|
||||
if dnt != ctx.output.env_from_addr.address {
|
||||
ctx.result.add_tag("HEADER_FORGED_MDN");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for addr in [
|
||||
ctx.output.reply_to.as_ref().map(|s| &s.email),
|
||||
sender.as_ref(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if !is_from_service_account && SERVICE_ACCOUNTS.contains(&addr.local_part.as_str()) {
|
||||
is_from_service_account = true;
|
||||
}
|
||||
|
||||
if !is_www_dot_domain && addr.domain_part.fqdn.starts_with("www.") {
|
||||
is_www_dot_domain = true;
|
||||
}
|
||||
}
|
||||
|
||||
if is_from_service_account {
|
||||
ctx.result.add_tag("FROM_SERVICE_ACCT");
|
||||
}
|
||||
|
||||
if is_www_dot_domain {
|
||||
ctx.result.add_tag("WWW_DOT_DOMAIN");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,14 @@ use store::ahash::AHashSet;
|
||||
|
||||
use crate::SpamFilterContext;
|
||||
|
||||
pub trait SpamFilterAnalyzeEhlo: Sync + Send {
|
||||
pub trait SpamFilterAnalyzeHeaders: Sync + Send {
|
||||
fn spam_filter_analyze_headers(
|
||||
&self,
|
||||
ctx: &mut SpamFilterContext<'_>,
|
||||
) -> impl Future<Output = ()> + Send;
|
||||
}
|
||||
|
||||
impl SpamFilterAnalyzeEhlo for Core {
|
||||
impl SpamFilterAnalyzeHeaders for Core {
|
||||
async fn spam_filter_analyze_headers(&self, ctx: &mut SpamFilterContext<'_>) {
|
||||
let mut list_score = 0.0;
|
||||
let mut unique_headers = AHashSet::new();
|
||||
@@ -35,11 +35,11 @@ impl SpamFilterAnalyzeEhlo for Core {
|
||||
| HeaderName::References
|
||||
| HeaderName::InReplyTo => {
|
||||
if !unique_headers.insert(header.name.clone()) {
|
||||
ctx.add_tag("MULTIPLE_UNIQUE_HEADERS");
|
||||
ctx.result.add_tag("MULTIPLE_UNIQUE_HEADERS");
|
||||
}
|
||||
|
||||
if !matches!(raw_message.get(header.offset_field), Some(b' ')) {
|
||||
ctx.add_tag("HEADER_EMPTY_DELIMITER");
|
||||
if !matches!(raw_message.get(header.offset_start), Some(b' ')) {
|
||||
ctx.result.add_tag("HEADER_EMPTY_DELIMITER");
|
||||
}
|
||||
}
|
||||
HeaderName::ListArchive
|
||||
@@ -56,7 +56,7 @@ impl SpamFilterAnalyzeEhlo for Core {
|
||||
}
|
||||
HeaderName::ListUnsubscribe => {
|
||||
list_score += 0.25;
|
||||
ctx.add_tag("HAS_LIST_UNSUB");
|
||||
ctx.result.add_tag("HAS_LIST_UNSUB");
|
||||
}
|
||||
HeaderName::Other(name) => {
|
||||
let value = header
|
||||
@@ -69,7 +69,7 @@ impl SpamFilterAnalyzeEhlo for Core {
|
||||
if name.eq_ignore_ascii_case("Precedence") {
|
||||
if value == "bulk" {
|
||||
list_score += 0.25;
|
||||
ctx.add_tag("PRECEDENCE_BULK");
|
||||
ctx.result.add_tag("PRECEDENCE_BULK");
|
||||
} else if value == "list" {
|
||||
list_score += 0.25;
|
||||
}
|
||||
@@ -78,50 +78,50 @@ impl SpamFilterAnalyzeEhlo for Core {
|
||||
} else if name.eq_ignore_ascii_case("X-Priority") {
|
||||
match value.parse::<i32>().unwrap_or(i32::MAX) {
|
||||
0 => {
|
||||
ctx.add_tag("HAS_X_PRIO_ZERO");
|
||||
ctx.result.add_tag("HAS_X_PRIO_ZERO");
|
||||
}
|
||||
1 => {
|
||||
ctx.add_tag("HAS_X_PRIO_ONE");
|
||||
ctx.result.add_tag("HAS_X_PRIO_ONE");
|
||||
}
|
||||
2 => {
|
||||
ctx.add_tag("HAS_X_PRIO_TWO");
|
||||
ctx.result.add_tag("HAS_X_PRIO_TWO");
|
||||
}
|
||||
3 | 4 => {
|
||||
ctx.add_tag("HAS_X_PRIO_THREE");
|
||||
ctx.result.add_tag("HAS_X_PRIO_THREE");
|
||||
}
|
||||
4..=10000 => {
|
||||
ctx.add_tag("HAS_X_PRIO_FIVE");
|
||||
ctx.result.add_tag("HAS_X_PRIO_FIVE");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
} else if name.eq_ignore_ascii_case("X-Mailer") {
|
||||
if name != "X-Mailer" {
|
||||
ctx.add_tag("XM_CASE");
|
||||
ctx.result.add_tag("XM_CASE");
|
||||
}
|
||||
if !value.is_empty() {
|
||||
if !value.as_bytes().iter().any(|&b| b.is_ascii_digit()) {
|
||||
ctx.add_tag("XM_UA_NO_VERSION");
|
||||
ctx.result.add_tag("XM_UA_NO_VERSION");
|
||||
}
|
||||
|
||||
if value.contains("phpmailer") {
|
||||
ctx.add_tag("HAS_PHPMAILER_SIG");
|
||||
ctx.result.add_tag("HAS_PHPMAILER_SIG");
|
||||
}
|
||||
}
|
||||
} else if name.eq_ignore_ascii_case("User-Agent") {
|
||||
if !value.is_empty()
|
||||
&& !value.as_bytes().iter().any(|&b| b.is_ascii_digit())
|
||||
{
|
||||
ctx.add_tag("XM_UA_NO_VERSION");
|
||||
ctx.result.add_tag("XM_UA_NO_VERSION");
|
||||
}
|
||||
} else if name.eq_ignore_ascii_case("Organization")
|
||||
|| name.eq_ignore_ascii_case("Organisation")
|
||||
{
|
||||
ctx.add_tag("HAS_ORG_HEADER");
|
||||
ctx.result.add_tag("HAS_ORG_HEADER");
|
||||
} else if name.eq_ignore_ascii_case("X-Originating-IP") {
|
||||
ctx.add_tag("HAS_XOIP");
|
||||
ctx.result.add_tag("HAS_XOIP");
|
||||
} else if name.eq_ignore_ascii_case("X-KLMS-AntiSpam-Status") {
|
||||
if value.contains("spam") {
|
||||
ctx.add_tag("KLMS_SPAM");
|
||||
ctx.result.add_tag("KLMS_SPAM");
|
||||
}
|
||||
} else if name.eq_ignore_ascii_case("X-Spam")
|
||||
|| name.eq_ignore_ascii_case("X-Spam-Flag")
|
||||
@@ -129,53 +129,53 @@ impl SpamFilterAnalyzeEhlo for Core {
|
||||
{
|
||||
if value.contains("yes") || value.contains("true") || value.contains("spam")
|
||||
{
|
||||
ctx.add_tag("SPAM_FLAG");
|
||||
ctx.result.add_tag("SPAM_FLAG");
|
||||
}
|
||||
} else if name.eq_ignore_ascii_case("X-UI-Filterresults")
|
||||
|| name.eq_ignore_ascii_case("X-UI-Out-Filterresults")
|
||||
{
|
||||
if value.contains("junk") {
|
||||
ctx.add_tag("UNITEDINTERNET_SPAM");
|
||||
ctx.result.add_tag("UNITEDINTERNET_SPAM");
|
||||
}
|
||||
} else if name.eq_ignore_ascii_case("X-PHP-Originating-Script") {
|
||||
ctx.add_tag("HAS_X_POS");
|
||||
ctx.result.add_tag("HAS_X_POS");
|
||||
if value.contains("eval()") {
|
||||
ctx.add_tag("X_PHP_EVAL");
|
||||
ctx.result.add_tag("X_PHP_EVAL");
|
||||
}
|
||||
if value.contains("../") {
|
||||
ctx.add_tag("HIDDEN_SOURCE_OBJ");
|
||||
ctx.result.add_tag("HIDDEN_SOURCE_OBJ");
|
||||
}
|
||||
} else if name.eq_ignore_ascii_case("X-PHP-Script") {
|
||||
ctx.add_tag("HAS_X_PHP_SCRIPT");
|
||||
ctx.result.add_tag("HAS_X_PHP_SCRIPT");
|
||||
if value.contains("eval()") {
|
||||
ctx.add_tag("X_PHP_EVAL");
|
||||
ctx.result.add_tag("X_PHP_EVAL");
|
||||
}
|
||||
if value.contains("../") {
|
||||
ctx.add_tag("HIDDEN_SOURCE_OBJ");
|
||||
ctx.result.add_tag("HIDDEN_SOURCE_OBJ");
|
||||
}
|
||||
if value.contains("sendmail.php") {
|
||||
ctx.add_tag("PHP_XPS_PATTERN");
|
||||
ctx.result.add_tag("PHP_XPS_PATTERN");
|
||||
}
|
||||
} else if name.eq_ignore_ascii_case("X-Source")
|
||||
|| name.eq_ignore_ascii_case("X-Source-Args")
|
||||
|| name.eq_ignore_ascii_case("X-Source-Dir")
|
||||
{
|
||||
ctx.add_tag("HAS_X_SOURCE");
|
||||
ctx.result.add_tag("HAS_X_SOURCE");
|
||||
if value.contains("'../") {
|
||||
ctx.add_tag("HIDDEN_SOURCE_OBJ");
|
||||
ctx.result.add_tag("HIDDEN_SOURCE_OBJ");
|
||||
}
|
||||
} else if name.eq_ignore_ascii_case("X-Authenticated-Sender") {
|
||||
if value.contains(": ") {
|
||||
ctx.add_tag("HAS_X_AS");
|
||||
ctx.result.add_tag("HAS_X_AS");
|
||||
}
|
||||
} else if name.eq_ignore_ascii_case("X-Get-Message-Sender-Via") {
|
||||
if value.contains("authenticated_id:") {
|
||||
ctx.add_tag("HAS_X_GMSV");
|
||||
ctx.result.add_tag("HAS_X_GMSV");
|
||||
}
|
||||
} else if name.eq_ignore_ascii_case("X-AntiAbuse") {
|
||||
ctx.add_tag("HAS_X_ANTIABUSE");
|
||||
ctx.result.add_tag("HAS_X_ANTIABUSE");
|
||||
} else if name.eq_ignore_ascii_case("X-Authentication-Warning") {
|
||||
ctx.add_tag("HAS_XAW");
|
||||
ctx.result.add_tag("HAS_XAW");
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -183,11 +183,11 @@ impl SpamFilterAnalyzeEhlo for Core {
|
||||
}
|
||||
|
||||
if list_score >= 1.0 {
|
||||
ctx.add_tag("MAILLIST");
|
||||
ctx.result.add_tag("MAILLIST");
|
||||
}
|
||||
|
||||
if unique_headers.is_empty() {
|
||||
ctx.add_tag("MISSING_ESSENTIAL_HEADERS");
|
||||
ctx.result.add_tag("MISSING_ESSENTIAL_HEADERS");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,54 +1,115 @@
|
||||
use common::Core;
|
||||
use mail_parser::{parsers::fields::thread::thread_name, HeaderName};
|
||||
use store::ahash::AHashSet;
|
||||
|
||||
use crate::{Email, Hostname, SpamFilterContext, SpamFilterInput, SpamFilterOutput};
|
||||
use crate::{
|
||||
Email, Hostname, Recipient, SpamFilterContext, SpamFilterInput, SpamFilterOutput,
|
||||
SpamFilterResult,
|
||||
};
|
||||
|
||||
pub trait SpamFilterInit {
|
||||
fn spam_filter_init<'x>(&self, input: SpamFilterInput<'x>) -> SpamFilterContext<'x>;
|
||||
}
|
||||
|
||||
const POSTMASTER_ADDRESSES: [&str; 3] = ["postmaster", "mailer-daemon", "root"];
|
||||
|
||||
impl SpamFilterInit for Core {
|
||||
fn spam_filter_init<'x>(&self, input: SpamFilterInput<'x>) -> SpamFilterContext<'x> {
|
||||
let subject = input.message.subject().unwrap_or_default().to_lowercase();
|
||||
let from = input.message.from().and_then(|f| f.first());
|
||||
let mut recipients = AHashSet::new();
|
||||
let mut subject = String::new();
|
||||
let mut from = None;
|
||||
let mut reply_to = None;
|
||||
let mut recipients_to = Vec::new();
|
||||
let mut recipients_cc = Vec::new();
|
||||
let mut recipients_bcc = Vec::new();
|
||||
|
||||
for header in input.message.headers() {
|
||||
if matches!(
|
||||
header.name,
|
||||
HeaderName::To | HeaderName::Cc | HeaderName::Bcc
|
||||
) {
|
||||
if let Some(addrs) = header.value().as_address() {
|
||||
for addr in addrs.iter() {
|
||||
if let Some(addr) = addr.address() {
|
||||
recipients.insert(Email::new(addr));
|
||||
match &header.name {
|
||||
HeaderName::To | HeaderName::Cc | HeaderName::Bcc => {
|
||||
if let Some(addrs) = header.value().as_address() {
|
||||
for addr in addrs.iter() {
|
||||
let rcpt = Recipient {
|
||||
email: Email::new(addr.address().unwrap_or_default()),
|
||||
name: addr.name().and_then(|s| {
|
||||
let s = s.trim();
|
||||
if !s.is_empty() {
|
||||
Some(s.to_lowercase())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}),
|
||||
};
|
||||
if header.name == HeaderName::To {
|
||||
recipients_to.push(rcpt);
|
||||
} else if header.name == HeaderName::Cc {
|
||||
recipients_cc.push(rcpt);
|
||||
} else {
|
||||
recipients_bcc.push(rcpt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
HeaderName::ReplyTo => {
|
||||
reply_to = header
|
||||
.value()
|
||||
.as_address()
|
||||
.and_then(|addrs| addrs.first())
|
||||
.and_then(|addr| {
|
||||
Some(Recipient {
|
||||
email: Email::new(addr.address()?),
|
||||
name: addr.name().and_then(|s| {
|
||||
let s = s.trim();
|
||||
if !s.is_empty() {
|
||||
Some(s.to_lowercase())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}),
|
||||
})
|
||||
});
|
||||
}
|
||||
HeaderName::Subject => {
|
||||
subject = header.value().as_text().unwrap_or_default().to_lowercase();
|
||||
}
|
||||
HeaderName::From => {
|
||||
from = header.value().as_address().and_then(|addrs| addrs.first());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let output = SpamFilterOutput {
|
||||
tags: Default::default(),
|
||||
ehlo_host: Hostname::new(input.ehlo_domain),
|
||||
iprev_ptr: input
|
||||
.iprev_result
|
||||
.ptr
|
||||
.as_ref()
|
||||
.and_then(|ptr| ptr.first())
|
||||
.map(|ptr| ptr.strip_suffix('.').unwrap_or(ptr).to_lowercase()),
|
||||
env_from_addr: Email::new(input.env_mail_from),
|
||||
from_addr: Email::new(from.and_then(|f| f.address()).unwrap_or_default()),
|
||||
from_name: from
|
||||
.and_then(|f| f.name())
|
||||
.unwrap_or_default()
|
||||
.to_lowercase(),
|
||||
subject_thread: thread_name(&subject).to_string(),
|
||||
subject,
|
||||
recipients,
|
||||
};
|
||||
|
||||
SpamFilterContext { output, input }
|
||||
let env_from_addr = Email::new(input.env_from);
|
||||
SpamFilterContext {
|
||||
output: SpamFilterOutput {
|
||||
ehlo_host: Hostname::new(input.ehlo_domain),
|
||||
iprev_ptr: input
|
||||
.iprev_result
|
||||
.ptr
|
||||
.as_ref()
|
||||
.and_then(|ptr| ptr.first())
|
||||
.map(|ptr| ptr.strip_suffix('.').unwrap_or(ptr).to_lowercase()),
|
||||
env_from_postmaster: env_from_addr.address.is_empty()
|
||||
|| POSTMASTER_ADDRESSES.contains(&env_from_addr.local_part.as_str()),
|
||||
env_from_addr,
|
||||
env_to_addr: input
|
||||
.env_rcpt_to
|
||||
.iter()
|
||||
.map(|rcpt| Email::new(rcpt))
|
||||
.collect(),
|
||||
from: Recipient {
|
||||
email: Email::new(from.and_then(|f| f.address()).unwrap_or_default()),
|
||||
name: from.and_then(|f| f.name()).map(|s| s.to_lowercase()),
|
||||
},
|
||||
reply_to,
|
||||
subject_thread: thread_name(&subject).to_string(),
|
||||
subject,
|
||||
recipients_to,
|
||||
recipients_cc,
|
||||
recipients_bcc,
|
||||
},
|
||||
input,
|
||||
result: SpamFilterResult {
|
||||
tags: Default::default(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,15 +121,15 @@ use common::Core;
|
||||
|
||||
use crate::SpamFilterContext;
|
||||
|
||||
pub trait SpamFilterAnalyzeEhlo: Sync + Send {
|
||||
fn spam_filter_analyze_ehlo(
|
||||
pub trait SpamFilterAnalyze!: Sync + Send {
|
||||
fn spam_filter_analyze_*(
|
||||
&self,
|
||||
ctx: &mut SpamFilterContext<'_>,
|
||||
) -> impl Future<Output = ()> + Send;
|
||||
}
|
||||
|
||||
impl SpamFilterAnalyzeEhlo for Core {
|
||||
async fn spam_filter_analyze_ehlo(&self, ctx: &mut SpamFilterContext<'_>) {
|
||||
impl SpamFilterAnalyze! for Core {
|
||||
async fn spam_filter_analyze_*(&self, ctx: &mut SpamFilterContext<'_>) {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,18 +5,18 @@ use mail_auth::IprevResult;
|
||||
|
||||
use crate::SpamFilterContext;
|
||||
|
||||
pub trait SpamFilterAnalyzeEhlo: Sync + Send {
|
||||
pub trait SpamFilterAnalyzeIpRev: Sync + Send {
|
||||
fn spam_filter_analyze_iprev(
|
||||
&self,
|
||||
ctx: &mut SpamFilterContext<'_>,
|
||||
) -> impl Future<Output = ()> + Send;
|
||||
}
|
||||
|
||||
impl SpamFilterAnalyzeEhlo for Core {
|
||||
impl SpamFilterAnalyzeIpRev for Core {
|
||||
async fn spam_filter_analyze_iprev(&self, ctx: &mut SpamFilterContext<'_>) {
|
||||
match &ctx.input.iprev_result.result {
|
||||
IprevResult::TempError(_) => ctx.add_tag("RDNS_DNSFAIL"),
|
||||
IprevResult::Fail(_) | IprevResult::PermError(_) => ctx.add_tag("RDNS_DNSFAIL"),
|
||||
IprevResult::TempError(_) => ctx.result.add_tag("RDNS_DNSFAIL"),
|
||||
IprevResult::Fail(_) | IprevResult::PermError(_) => ctx.result.add_tag("RDNS_DNSFAIL"),
|
||||
IprevResult::Pass | IprevResult::None => (),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,14 +5,14 @@ use mail_parser::HeaderName;
|
||||
|
||||
use crate::{Hostname, SpamFilterContext};
|
||||
|
||||
pub trait SpamFilterAnalyzeEhlo: Sync + Send {
|
||||
pub trait SpamFilterAnalyzeMid: Sync + Send {
|
||||
fn spam_filter_analyze_message_id(
|
||||
&self,
|
||||
ctx: &mut SpamFilterContext<'_>,
|
||||
) -> impl Future<Output = ()> + Send;
|
||||
}
|
||||
|
||||
impl SpamFilterAnalyzeEhlo for Core {
|
||||
impl SpamFilterAnalyzeMid for Core {
|
||||
async fn spam_filter_analyze_message_id(&self, ctx: &mut SpamFilterContext<'_>) {
|
||||
let mid_raw = ctx
|
||||
.input
|
||||
@@ -31,55 +31,55 @@ impl SpamFilterAnalyzeEhlo for Core {
|
||||
if let Some(mid_host) = mid.rsplit_once('@').map(|(_, host)| Hostname::new(host)) {
|
||||
if mid_host.ip.is_some() {
|
||||
if mid_host.fqdn.starts_with('[') {
|
||||
ctx.add_tag("MID_RHS_IP_LITERAL");
|
||||
ctx.result.add_tag("MID_RHS_IP_LITERAL");
|
||||
} else {
|
||||
ctx.add_tag("MID_BARE_IP");
|
||||
ctx.result.add_tag("MID_BARE_IP");
|
||||
}
|
||||
} else if !mid_host.fqdn.contains('.') {
|
||||
ctx.add_tag("MID_RHS_NOT_FQDN");
|
||||
ctx.result.add_tag("MID_RHS_NOT_FQDN");
|
||||
} else if mid_host.fqdn.starts_with("www.") {
|
||||
ctx.add_tag("MID_RHS_WWW");
|
||||
ctx.result.add_tag("MID_RHS_WWW");
|
||||
}
|
||||
|
||||
if !mid_raw.is_ascii() || mid_raw.contains('(') || mid.starts_with('@') {
|
||||
ctx.add_tag("INVALID_MSGID");
|
||||
ctx.result.add_tag("INVALID_MSGID");
|
||||
}
|
||||
|
||||
if mid_host.fqdn.len() > 255 {
|
||||
ctx.add_tag("MID_RHS_TOO_LONG");
|
||||
ctx.result.add_tag("MID_RHS_TOO_LONG");
|
||||
}
|
||||
|
||||
// From address present in Message-ID checks
|
||||
for sender in [&ctx.output.from_addr, &ctx.output.env_from_addr] {
|
||||
for sender in [&ctx.output.from.email, &ctx.output.env_from_addr] {
|
||||
if !sender.address.is_empty() {
|
||||
if mid.contains(&sender.address) {
|
||||
ctx.output.tags.insert("MID_CONTAINS_FROM".to_string());
|
||||
ctx.result.add_tag("MID_CONTAINS_FROM");
|
||||
} else if mid_host.fqdn == sender.domain_part.fqdn {
|
||||
ctx.output.tags.insert("MID_RHS_MATCH_FROM".to_string());
|
||||
ctx.result.add_tag("MID_RHS_MATCH_FROM");
|
||||
} else if matches!((&mid_host.sld, &sender.domain_part.sld), (Some(mid_sld), Some(sender_sld)) if mid_sld == sender_sld)
|
||||
{
|
||||
ctx.output.tags.insert("MID_RHS_MATCH_FROMTLD".to_string());
|
||||
ctx.result.add_tag("MID_RHS_MATCH_FROMTLD");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// To/Cc addresses present in Message-ID checks
|
||||
for addr in &ctx.output.recipients {
|
||||
if mid.contains(&addr.address) {
|
||||
ctx.output.tags.insert("MID_CONTAINS_TO".to_string());
|
||||
} else if mid_host.fqdn == addr.domain_part.fqdn {
|
||||
ctx.output.tags.insert("MID_RHS_MATCH_TO".to_string());
|
||||
for rcpt in ctx.output.all_recipients() {
|
||||
if mid.contains(&rcpt.email.address) {
|
||||
ctx.result.add_tag("MID_CONTAINS_TO");
|
||||
} else if mid_host.fqdn == rcpt.email.domain_part.fqdn {
|
||||
ctx.result.add_tag("MID_RHS_MATCH_TO");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ctx.add_tag("INVALID_MSGID");
|
||||
ctx.result.add_tag("INVALID_MSGID");
|
||||
}
|
||||
|
||||
if !mid_raw.starts_with('<') || !mid_raw.ends_with('>') {
|
||||
ctx.add_tag("MID_MISSING_BRACKETS");
|
||||
ctx.result.add_tag("MID_MISSING_BRACKETS");
|
||||
}
|
||||
} else {
|
||||
ctx.add_tag("MISSING_MID");
|
||||
ctx.result.add_tag("MISSING_MID");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,43 @@
|
||||
use crate::SpamFilterContext;
|
||||
use std::borrow::Cow;
|
||||
|
||||
use mail_parser::{parsers::MessageStream, Header};
|
||||
|
||||
use crate::{Recipient, SpamFilterInput, SpamFilterOutput, SpamFilterResult};
|
||||
|
||||
pub mod date;
|
||||
pub mod dmarc;
|
||||
pub mod ehlo;
|
||||
pub mod from;
|
||||
pub mod headers;
|
||||
pub mod init;
|
||||
pub mod iprev;
|
||||
pub mod messageid;
|
||||
pub mod recipient;
|
||||
pub mod replyto;
|
||||
|
||||
impl SpamFilterContext<'_> {
|
||||
pub fn add_tag(&mut self, tag: impl Into<String>) {
|
||||
self.output.tags.insert(tag.into());
|
||||
impl SpamFilterInput<'_> {
|
||||
pub fn header_as_address(&self, header: &Header<'_>) -> Option<Cow<'_, str>> {
|
||||
self.message
|
||||
.raw_message()
|
||||
.get(header.offset_start..header.offset_end)
|
||||
.map(|bytes| MessageStream::new(bytes).parse_address())
|
||||
.and_then(|addr| addr.into_address())
|
||||
.and_then(|addr| addr.into_list().into_iter().next())
|
||||
.and_then(|addr| addr.address)
|
||||
}
|
||||
}
|
||||
|
||||
impl SpamFilterOutput {
|
||||
pub fn all_recipients(&self) -> impl Iterator<Item = &Recipient> {
|
||||
self.recipients_to
|
||||
.iter()
|
||||
.chain(self.recipients_cc.iter())
|
||||
.chain(self.recipients_bcc.iter())
|
||||
}
|
||||
}
|
||||
|
||||
impl SpamFilterResult {
|
||||
pub fn add_tag(&mut self, tag: impl Into<String>) {
|
||||
self.tags.insert(tag.into());
|
||||
}
|
||||
}
|
||||
|
||||
301
crates/spam-filter/src/analysis/recipient.rs
Normal file
301
crates/spam-filter/src/analysis/recipient.rs
Normal file
@@ -0,0 +1,301 @@
|
||||
use std::future::Future;
|
||||
|
||||
use common::{scripts::functions::text::levenshtein_distance, Core};
|
||||
use mail_parser::HeaderName;
|
||||
use smtp_proto::{MAIL_BODY_8BITMIME, MAIL_BODY_BINARYMIME, MAIL_SMTPUTF8};
|
||||
use store::ahash::HashSet;
|
||||
|
||||
use crate::SpamFilterContext;
|
||||
|
||||
pub trait SpamFilterAnalyzeRecipient: Sync + Send {
|
||||
fn spam_filter_analyze_recipient(
|
||||
&self,
|
||||
ctx: &mut SpamFilterContext<'_>,
|
||||
) -> impl Future<Output = ()> + Send;
|
||||
}
|
||||
|
||||
impl SpamFilterAnalyzeRecipient for Core {
|
||||
async fn spam_filter_analyze_recipient(&self, ctx: &mut SpamFilterContext<'_>) {
|
||||
let mut to_raw = b"".as_slice();
|
||||
let mut cc_raw = b"".as_slice();
|
||||
let mut bcc_raw = b"".as_slice();
|
||||
let mut has_list_unsubscribe = false;
|
||||
let mut has_list_id = false;
|
||||
|
||||
for header in ctx.input.message.headers() {
|
||||
match &header.name {
|
||||
HeaderName::To | HeaderName::Cc | HeaderName::Bcc => {
|
||||
let raw = ctx
|
||||
.input
|
||||
.message
|
||||
.raw_message()
|
||||
.get(header.offset_start..header.offset_end)
|
||||
.unwrap_or_default();
|
||||
match header.name {
|
||||
HeaderName::To => to_raw = raw,
|
||||
HeaderName::Cc => cc_raw = raw,
|
||||
HeaderName::Bcc => bcc_raw = raw,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
HeaderName::ListUnsubscribe => {
|
||||
has_list_unsubscribe = true;
|
||||
}
|
||||
HeaderName::ListId => {
|
||||
has_list_id = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if to_raw.is_empty() {
|
||||
ctx.result.add_tag("MISSING_TO");
|
||||
}
|
||||
|
||||
let to_raw_utf8 = std::str::from_utf8(to_raw);
|
||||
let cc_raw_utf8 = std::str::from_utf8(cc_raw);
|
||||
let bcc_raw_utf8 = std::str::from_utf8(bcc_raw);
|
||||
|
||||
for (raw, raw_utf8, recipients) in [
|
||||
(to_raw, &to_raw_utf8, &ctx.output.recipients_to),
|
||||
(cc_raw, &cc_raw_utf8, &ctx.output.recipients_cc),
|
||||
(bcc_raw, &bcc_raw_utf8, &ctx.output.recipients_bcc),
|
||||
] {
|
||||
if !raw.is_empty() {
|
||||
// Validate non-ASCII characters in recipient headers
|
||||
if !raw.is_ascii() {
|
||||
if (ctx.input.env_from_flags
|
||||
& (MAIL_SMTPUTF8 | MAIL_BODY_8BITMIME | MAIL_BODY_BINARYMIME))
|
||||
== 0
|
||||
{
|
||||
ctx.result.add_tag("TO_NEEDS_ENCODING");
|
||||
}
|
||||
|
||||
if raw_utf8.is_err() {
|
||||
ctx.result.add_tag("INVALID_TO_8BIT");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate unnecessary encoding in recipient headers
|
||||
let raw_utf8 = raw_utf8.unwrap_or_default();
|
||||
if recipients.iter().all(|rcpt| {
|
||||
rcpt.name.as_ref().map_or(true, |name| name.is_ascii())
|
||||
&& rcpt.email.address.is_ascii()
|
||||
}) && raw_utf8.contains("=?")
|
||||
&& raw_utf8.contains("?=")
|
||||
{
|
||||
if raw_utf8.contains("?q?") || raw_utf8.contains("?Q?") {
|
||||
// To header is unnecessarily encoded in quoted-printable
|
||||
ctx.result.add_tag("TO_EXCESS_QP");
|
||||
} else if raw_utf8.contains("?b?") || raw_utf8.contains("?B?") {
|
||||
// To header is unnecessarily encoded in base64
|
||||
ctx.result.add_tag("TO_EXCESS_BASE64");
|
||||
}
|
||||
}
|
||||
|
||||
// Check for spaces in recipient addresses
|
||||
for token in raw_utf8.split('<') {
|
||||
if let Some((addr, _)) = token.split_once('>') {
|
||||
if addr.starts_with(' ') || addr.ends_with(' ') {
|
||||
ctx.result.add_tag("TO_WRAPPED_IN_SPACES");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let unique_recipients = ctx
|
||||
.output
|
||||
.all_recipients()
|
||||
.filter(|rcpt| !rcpt.email.address.is_empty())
|
||||
.collect::<HashSet<_>>();
|
||||
let rcpt_count = unique_recipients.len();
|
||||
|
||||
match unique_recipients.len() {
|
||||
0 => {
|
||||
ctx.result.add_tag("RCPT_COUNT_ZERO");
|
||||
for raw in &[to_raw_utf8, cc_raw_utf8, bcc_raw_utf8] {
|
||||
if matches!(raw, Ok(raw) if raw.to_ascii_lowercase().contains("undisclosed")) {
|
||||
ctx.result.add_tag("R_UNDISC_RCPT");
|
||||
break;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
1 => {
|
||||
ctx.result.add_tag("RCPT_COUNT_ONE");
|
||||
}
|
||||
2 => {
|
||||
ctx.result.add_tag("RCPT_COUNT_TWO");
|
||||
}
|
||||
3 => {
|
||||
ctx.result.add_tag("RCPT_COUNT_THREE");
|
||||
}
|
||||
4 | 5 => {
|
||||
ctx.result.add_tag("RCPT_COUNT_FIVE");
|
||||
}
|
||||
6 | 7 => {
|
||||
ctx.result.add_tag("RCPT_COUNT_SEVEN");
|
||||
}
|
||||
8..=12 => {
|
||||
ctx.result.add_tag("RCPT_COUNT_TWELVE");
|
||||
}
|
||||
13.. => {
|
||||
ctx.result.add_tag("RCPT_COUNT_GT_50");
|
||||
}
|
||||
}
|
||||
|
||||
let mut to_dn_eq_addr_count = 0;
|
||||
let mut to_dn_count = 0;
|
||||
let mut to_match_envrcpt = 0;
|
||||
let is_from_info = ctx.output.from.email.local_part == "info";
|
||||
|
||||
for rcpt in &unique_recipients {
|
||||
// Validate name
|
||||
if let Some(rcpt_name) = &rcpt.name {
|
||||
if rcpt_name == &rcpt.email.address {
|
||||
to_dn_eq_addr_count += 1;
|
||||
} else {
|
||||
to_dn_count += 1;
|
||||
if ["recipient", "recipients"].contains(&rcpt_name.as_str()) {
|
||||
ctx.result.add_tag("TO_DN_RECIPIENTS");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recipient is present in envelope
|
||||
if ctx.output.env_to_addr.contains(&rcpt.email) {
|
||||
to_match_envrcpt += 1;
|
||||
}
|
||||
|
||||
// Check if the local part is present in the subject
|
||||
if !rcpt.email.local_part.is_empty() {
|
||||
if ctx.output.subject.contains(&rcpt.email.address) {
|
||||
ctx.result.add_tag("RCPT_ADDR_IN_SUBJECT");
|
||||
} else if rcpt.email.local_part.len() > 3
|
||||
&& ctx.output.subject.contains(&rcpt.email.local_part)
|
||||
{
|
||||
ctx.result.add_tag("RCPT_LOCAL_IN_SUBJECT");
|
||||
}
|
||||
|
||||
if rcpt.email.local_part.contains('+') {
|
||||
ctx.result.add_tag("TAGGED_RCPT");
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it is an into to info
|
||||
if has_list_unsubscribe && is_from_info && rcpt.email.local_part == "info" {
|
||||
ctx.result.add_tag("INFO_TO_INFO_LU");
|
||||
}
|
||||
|
||||
// Check for freemail or disposable domains
|
||||
if let Some(domain) = rcpt.email.domain_part.sld.as_deref() {
|
||||
if self.spam.list_freemail_providers.contains(domain) {
|
||||
if ctx
|
||||
.output
|
||||
.recipients_to
|
||||
.iter()
|
||||
.any(|r| r.email == rcpt.email)
|
||||
{
|
||||
ctx.result.add_tag("FREEMAIL_TO");
|
||||
} else {
|
||||
ctx.result.add_tag("FREEMAIL_CC");
|
||||
}
|
||||
} else if self.spam.list_disposable_providers.contains(domain) {
|
||||
if ctx
|
||||
.output
|
||||
.recipients_to
|
||||
.iter()
|
||||
.any(|r| r.email == rcpt.email)
|
||||
{
|
||||
ctx.result.add_tag("DISPOSABLE_TO");
|
||||
} else {
|
||||
ctx.result.add_tag("DISPOSABLE_CC");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if to_dn_count == 0 && to_dn_eq_addr_count == 0 {
|
||||
ctx.result.add_tag("TO_DN_NONE");
|
||||
} else if to_dn_count == rcpt_count {
|
||||
ctx.result.add_tag("TO_DN_ALL");
|
||||
} else if to_dn_count > 0 {
|
||||
ctx.result.add_tag("TO_DN_SOME");
|
||||
}
|
||||
|
||||
if to_dn_eq_addr_count == rcpt_count {
|
||||
ctx.result.add_tag("TO_DN_EQ_ADDR_ALL");
|
||||
} else if to_dn_eq_addr_count > 0 {
|
||||
ctx.result.add_tag("TO_DN_EQ_ADDR_SOME");
|
||||
}
|
||||
|
||||
if to_match_envrcpt == rcpt_count {
|
||||
ctx.result.add_tag("TO_MATCH_ENVRCPT_ALL");
|
||||
} else {
|
||||
if to_match_envrcpt > 0 {
|
||||
ctx.result.add_tag("TO_MATCH_ENVRCPT_SOME");
|
||||
}
|
||||
|
||||
if !has_list_id && !has_list_unsubscribe {
|
||||
for env_rcpt in &ctx.output.env_to_addr {
|
||||
if !unique_recipients.iter().any(|rcpt| rcpt.email == *env_rcpt)
|
||||
&& env_rcpt != &ctx.output.env_from_addr
|
||||
{
|
||||
ctx.result.add_tag("FORGED_RECIPIENTS");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Message from bounce and over 1 recipient
|
||||
if rcpt_count > 1 && ctx.output.env_from_postmaster {
|
||||
ctx.result.add_tag("RCPT_BOUNCEMOREONE");
|
||||
}
|
||||
|
||||
for rcpts in [&ctx.output.recipients_to, &ctx.output.recipients_cc] {
|
||||
let mut is_sorted = false;
|
||||
if rcpts.len() >= 6 {
|
||||
// Check if the recipients list is sorted
|
||||
let mut sorted = true;
|
||||
for i in 1..rcpts.len() {
|
||||
if rcpts[i - 1].email.address > rcpts[i].email.address {
|
||||
sorted = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if sorted {
|
||||
ctx.result.add_tag("SORTED_RECIPS");
|
||||
is_sorted = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !is_sorted && rcpt_count >= 5 {
|
||||
// Look for similar recipients
|
||||
let mut hits = 0;
|
||||
let mut combinations = 0;
|
||||
for i in 0..rcpts.len() {
|
||||
for j in i + 1..rcpts.len() {
|
||||
let a = &rcpts[i].email;
|
||||
let b = &rcpts[j].email;
|
||||
|
||||
if levenshtein_distance(&a.local_part, &b.local_part) < 3
|
||||
|| (a.domain_part.fqdn != b.domain_part.fqdn
|
||||
&& levenshtein_distance(&a.domain_part.fqdn, &b.domain_part.fqdn)
|
||||
< 4)
|
||||
{
|
||||
hits += 1;
|
||||
}
|
||||
combinations += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if hits as f64 / combinations as f64 > 0.65 {
|
||||
ctx.result.add_tag("SUSPICIOUS_RECIPS");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
153
crates/spam-filter/src/analysis/replyto.rs
Normal file
153
crates/spam-filter/src/analysis/replyto.rs
Normal file
@@ -0,0 +1,153 @@
|
||||
use std::future::Future;
|
||||
|
||||
use common::Core;
|
||||
use mail_parser::HeaderName;
|
||||
|
||||
use crate::SpamFilterContext;
|
||||
|
||||
use super::from::TITLES;
|
||||
|
||||
pub trait SpamFilterAnalyzeReplyTo: Sync + Send {
|
||||
fn spam_filter_analyze_reply_to(
|
||||
&self,
|
||||
ctx: &mut SpamFilterContext<'_>,
|
||||
) -> impl Future<Output = ()> + Send;
|
||||
}
|
||||
|
||||
impl SpamFilterAnalyzeReplyTo for Core {
|
||||
async fn spam_filter_analyze_reply_to(&self, ctx: &mut SpamFilterContext<'_>) {
|
||||
let mut reply_to_raw = b"".as_slice();
|
||||
let mut is_from_list = false;
|
||||
|
||||
for header in ctx.input.message.headers() {
|
||||
match &header.name {
|
||||
HeaderName::ReplyTo => {
|
||||
reply_to_raw = ctx
|
||||
.input
|
||||
.message
|
||||
.raw_message()
|
||||
.get(header.offset_start..header.offset_end)
|
||||
.unwrap_or_default();
|
||||
}
|
||||
HeaderName::ListUnsubscribe | HeaderName::ListId => {
|
||||
is_from_list = true;
|
||||
}
|
||||
|
||||
HeaderName::Other(name) => {
|
||||
if !is_from_list {
|
||||
is_from_list = name.eq_ignore_ascii_case("X-To-Get-Off-This-List")
|
||||
|| name.eq_ignore_ascii_case("X-List")
|
||||
|| name.eq_ignore_ascii_case("Auto-Submitted");
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if reply_to_raw.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(reply_to) = &ctx.output.reply_to {
|
||||
let reply_to_name = reply_to.name.as_deref().unwrap_or_default();
|
||||
ctx.result.add_tag("HAS_REPLYTO");
|
||||
|
||||
if reply_to.email == ctx.output.from.email {
|
||||
ctx.result.add_tag("REPLYTO_EQ_FROM");
|
||||
} else {
|
||||
if reply_to.email.domain_part.sld == ctx.output.from.email.domain_part.sld {
|
||||
ctx.result.add_tag("REPLYTO_DOM_EQ_FROM_DOM");
|
||||
} else {
|
||||
if !is_from_list
|
||||
&& ctx
|
||||
.output
|
||||
.all_recipients()
|
||||
.any(|r| r.email == reply_to.email)
|
||||
{
|
||||
ctx.result.add_tag("REPLYTO_EQ_TO_ADDR");
|
||||
} else {
|
||||
ctx.result.add_tag("REPLYTO_DOM_NEQ_FROM_DOM");
|
||||
}
|
||||
|
||||
if !(is_from_list
|
||||
|| ctx
|
||||
.output
|
||||
.recipients_to
|
||||
.iter()
|
||||
.any(|r| r.email == ctx.output.from.email)
|
||||
|| ctx
|
||||
.output
|
||||
.env_to_addr
|
||||
.iter()
|
||||
.any(|r| r.domain_part.sld == ctx.output.from.email.domain_part.sld)
|
||||
|| ctx.output.env_to_addr.len() == 1
|
||||
&& ctx.output.env_to_addr.contains(&ctx.output.from.email))
|
||||
{
|
||||
ctx.result.add_tag("SPOOF_REPLYTO");
|
||||
}
|
||||
}
|
||||
|
||||
if !reply_to_name.is_empty()
|
||||
&& reply_to_name == ctx.output.from.name.as_deref().unwrap_or_default()
|
||||
{
|
||||
ctx.result.add_tag("REPLYTO_DN_EQ_FROM_DN");
|
||||
}
|
||||
}
|
||||
|
||||
if reply_to.email == ctx.output.env_from_addr {
|
||||
ctx.result.add_tag("REPLYTO_ADDR_EQ_FROM");
|
||||
}
|
||||
|
||||
let reply_to_sld = reply_to
|
||||
.email
|
||||
.domain_part
|
||||
.sld
|
||||
.as_deref()
|
||||
.unwrap_or_default();
|
||||
if self.spam.list_freemail_providers.contains(reply_to_sld) {
|
||||
ctx.result.add_tag("FREEMAIL_REPLYTO");
|
||||
let from_domain_sld = ctx
|
||||
.output
|
||||
.from
|
||||
.email
|
||||
.domain_part
|
||||
.sld
|
||||
.as_deref()
|
||||
.unwrap_or_default();
|
||||
if reply_to_sld != from_domain_sld
|
||||
&& self.spam.list_freemail_providers.contains(from_domain_sld)
|
||||
{
|
||||
ctx.result.add_tag("FREEMAIL_REPLYTO_NEQ_FROM_DOM");
|
||||
}
|
||||
} else if self.spam.list_disposable_providers.contains(reply_to_sld) {
|
||||
ctx.result.add_tag("DISPOSABLE_REPLYTO");
|
||||
}
|
||||
|
||||
// Validate unnecessary encoding
|
||||
let reply_to_raw_utf8 = std::str::from_utf8(reply_to_raw).unwrap_or_default();
|
||||
if reply_to.email.address.is_ascii()
|
||||
&& reply_to_name.is_ascii()
|
||||
&& reply_to_raw_utf8.contains("=?")
|
||||
&& reply_to_raw_utf8.contains("?=")
|
||||
{
|
||||
if reply_to_raw_utf8.contains("?q?") || reply_to_raw_utf8.contains("?Q?") {
|
||||
// Reply-To header is unnecessarily encoded in quoted-printable
|
||||
ctx.result.add_tag("REPLYTO_EXCESS_QP");
|
||||
} else if reply_to_raw_utf8.contains("?b?") || reply_to_raw_utf8.contains("?B?") {
|
||||
// Reply-To header is unnecessarily encoded in base64
|
||||
ctx.result.add_tag("REPLYTO_EXCESS_BASE64");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate reply-to name
|
||||
for title in TITLES {
|
||||
if reply_to_name.contains(title) {
|
||||
ctx.result.add_tag("REPLYTO_EMAIL_HAS_TITLE");
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ctx.result.add_tag("REPLYTO_UNPARSABLE");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod analysis;
|
||||
pub mod modules;
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::net::IpAddr;
|
||||
|
||||
@@ -30,43 +31,58 @@ pub struct SpamFilterInput<'x> {
|
||||
pub tls_cipher: &'x str,
|
||||
|
||||
// Envelope
|
||||
pub env_mail_from: &'x str,
|
||||
pub env_from: &'x str,
|
||||
pub env_from_flags: u64,
|
||||
pub env_rcpt_to: &'x [&'x str],
|
||||
}
|
||||
|
||||
pub struct SpamFilterOutput {
|
||||
pub tags: AHashSet<String>,
|
||||
pub ehlo_host: Hostname,
|
||||
pub iprev_ptr: Option<String>,
|
||||
|
||||
pub env_from_addr: Email,
|
||||
pub from_addr: Email,
|
||||
pub from_name: String,
|
||||
pub recipients: AHashSet<Email>,
|
||||
pub env_from_postmaster: bool,
|
||||
pub env_to_addr: HashSet<Email>,
|
||||
pub from: Recipient,
|
||||
pub recipients_to: Vec<Recipient>,
|
||||
pub recipients_cc: Vec<Recipient>,
|
||||
pub recipients_bcc: Vec<Recipient>,
|
||||
pub reply_to: Option<Recipient>,
|
||||
|
||||
pub subject: String,
|
||||
pub subject_thread: String,
|
||||
}
|
||||
|
||||
pub struct SpamFilterResult {
|
||||
pub tags: AHashSet<String>,
|
||||
}
|
||||
|
||||
pub struct SpamFilterContext<'x> {
|
||||
pub input: SpamFilterInput<'x>,
|
||||
pub output: SpamFilterOutput,
|
||||
pub result: SpamFilterResult,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Hostname {
|
||||
pub fqdn: String,
|
||||
pub ip: Option<IpAddr>,
|
||||
pub sld: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Email {
|
||||
pub address: String,
|
||||
pub local_part: String,
|
||||
pub domain_part: Hostname,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Recipient {
|
||||
pub email: Email,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
impl PartialEq for Hostname {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.fqdn.eq(&other.fqdn)
|
||||
@@ -94,3 +110,47 @@ impl Hash for Email {
|
||||
self.address.hash(state)
|
||||
}
|
||||
}
|
||||
|
||||
impl Email {
|
||||
pub fn is_valid(&self) -> bool {
|
||||
self.domain_part.sld.is_some() && !self.local_part.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Recipient {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.email.eq(&other.email)
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for Recipient {}
|
||||
|
||||
impl Hash for Recipient {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.email.hash(state)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for Email {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for Recipient {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for Email {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
self.address.cmp(&other.address)
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for Recipient {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
self.email.cmp(&other.email)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user