From 30092ce92c596af0dad00e114545e6c13937a502 Mon Sep 17 00:00:00 2001 From: mdecimus Date: Tue, 17 Oct 2023 20:56:48 +0200 Subject: [PATCH] v0.3.10 --- CHANGELOG.md | 3 +- Cargo.lock | 82 +- crates/cli/Cargo.toml | 2 +- crates/imap/Cargo.toml | 2 +- crates/install/Cargo.toml | 2 +- crates/jmap/Cargo.toml | 2 +- crates/main/Cargo.toml | 2 +- crates/nlp/Cargo.toml | 2 +- crates/smtp/Cargo.toml | 2 +- crates/smtp/src/config/scripts.rs | 9 +- crates/smtp/src/scripts/functions/misc.rs | 4 + crates/smtp/src/scripts/functions/mod.rs | 1 + crates/smtp/src/scripts/plugins/bayes.rs | 115 ++- crates/smtp/src/scripts/plugins/lookup.rs | 29 + crates/smtp/src/scripts/plugins/mod.rs | 8 +- crates/utils/Cargo.toml | 2 +- resources/config/sieve/bayes_classify.sieve | 9 +- resources/config/sieve/config.sieve | 33 + resources/config/sieve/epilogue.sieve | 58 ++ resources/config/sieve/greylist.sieve | 9 + resources/config/sieve/mime.sieve | 14 +- resources/config/sieve/prelude.sieve | 3 + resources/config/sieve/rbl.sieve | 11 +- resources/config/sieve/replies_out.sieve | 5 +- resources/config/sieve/scores.sieve | 0 resources/config/sieve/spamtrap.sieve | 7 +- resources/config/sieve/url.sieve | 10 + tests/resources/smtp/antispam/combined.test | 827 ++++++++++++++++++++ tests/src/smtp/inbound/antispam.rs | 57 +- 29 files changed, 1215 insertions(+), 95 deletions(-) create mode 100644 resources/config/sieve/config.sieve create mode 100644 resources/config/sieve/epilogue.sieve create mode 100644 resources/config/sieve/greylist.sieve create mode 100644 resources/config/sieve/scores.sieve create mode 100644 tests/resources/smtp/antispam/combined.test diff --git a/CHANGELOG.md b/CHANGELOG.md index bb0eaf2b..19514dc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). -## [0.x.x] - 2023-xx-xx +## [0.3.10] - 2023-10-17 ## Added - Option to allow invalid certificates on outbound SMTP connections. @@ -13,7 +13,6 @@ All notable changes to this project will be documented in this file. This projec ### Fixed - ## [0.3.9] - 2023-10-07 ## Added diff --git a/Cargo.lock b/Cargo.lock index 5dff9329..7f4000db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -341,9 +341,9 @@ dependencies = [ [[package]] name = "aws-region" -version = "0.25.3" +version = "0.25.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "056557a61427d0e5ba29dd931031c8ffed4ee7a550e7cd55692a9d8deb0a9dba" +checksum = "42fed2b9fca70f2908268d057a607f2a906f47edbf856ea8587de9038d264e22" dependencies = [ "thiserror", ] @@ -526,9 +526,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.4.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4682ae6287fcf752ecaabbfcc7b6f9b72aa33933dc23a554d853aea8eea8635" +checksum = "327762f6e5a765692301e5bb513e0d9fef63be86bbc14528052b1cd3e6f03e07" dependencies = [ "serde", ] @@ -2147,7 +2147,7 @@ dependencies = [ [[package]] name = "hyper-util" version = "0.0.0" -source = "git+https://github.com/hyperium/hyper-util#63e84bf0603df79bf86b733e7376f1317bf2eefe" +source = "git+https://github.com/hyperium/hyper-util#9a17089cb5097d3617e30019e5202f181e058eb9" dependencies = [ "bytes", "futures-channel", @@ -2166,16 +2166,16 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.57" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fad5b825842d2b38bd206f3e81d6957625fd7f0a361e345c30e01a0ae2dd613" +checksum = "8326b86b6cff230b97d0d312a6c40a60726df3332e721f72a1b035f451663b20" dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", "wasm-bindgen", - "windows", + "windows-core", ] [[package]] @@ -2224,7 +2224,7 @@ checksum = "029d73f573d8e8d63e6d5020011d3255b28c3ba85d6cf870a07184ed23de9284" [[package]] name = "imap" -version = "0.3.9" +version = "0.3.10" dependencies = [ "ahash 0.8.3", "dashmap", @@ -2408,7 +2408,7 @@ dependencies = [ [[package]] name = "jmap" -version = "0.3.9" +version = "0.3.10" dependencies = [ "aes", "aes-gcm", @@ -2730,7 +2730,7 @@ dependencies = [ "mail-parser", "parking_lot", "quick-xml 0.30.0", - "ring 0.17.3", + "ring 0.17.4", "rustls-pemfile", "serde", "serde_json", @@ -2772,7 +2772,7 @@ dependencies = [ [[package]] name = "mail-server" -version = "0.3.9" +version = "0.3.10" dependencies = [ "directory", "imap", @@ -2979,7 +2979,7 @@ dependencies = [ [[package]] name = "nlp" -version = "0.3.9" +version = "0.3.10" dependencies = [ "ahash 0.8.3", "bincode", @@ -3137,7 +3137,7 @@ version = "0.10.57" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bac25ee399abb46215765b1cb35bc0212377e58a061560d8b29b024fd0430e7c" dependencies = [ - "bitflags 2.4.0", + "bitflags 2.4.1", "cfg-if", "foreign-types", "libc", @@ -3802,9 +3802,9 @@ dependencies = [ [[package]] name = "rasn" -version = "0.10.3" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c22b7f7ff0508dae62e1be69fe02f32eb88523090b50ac850637947853cf5b6d" +checksum = "bb030bce0cb6578f484f4b2f9f0bd038d0562e235c856730cd845947c86fbd82" dependencies = [ "arrayvec", "bitvec", @@ -3824,9 +3824,9 @@ dependencies = [ [[package]] name = "rasn-cms" -version = "0.10.3" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b33fab229bc35b33782880040bfc71ecad5dffb040752e7ceb20f6bddbc33c1" +checksum = "97180cf43564a824df365b53339e2dc967db608b2aa2b7a247ccb619e723f990" dependencies = [ "rasn", "rasn-pkix", @@ -3834,9 +3834,9 @@ dependencies = [ [[package]] name = "rasn-derive" -version = "0.10.3" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1e6ddbc9ada563036d59c322cb0886a9b08b346904eebbcd20af2e01caecee7" +checksum = "e6d71113bb47670ae208b1db7308aa35c676db8bb7b7a8f49b0c1e208af5ebf4" dependencies = [ "either", "itertools 0.10.5", @@ -3849,9 +3849,9 @@ dependencies = [ [[package]] name = "rasn-pkix" -version = "0.10.3" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a96a8f3a9c45f9e76eff1b5d72385296cbfe2c2c22ecaccb8f493f4b60be524d" +checksum = "cfdf5c1f5213088c8552ed4dc61dc07dc8211cb4aa5e7c49eb9161b32d005a55" dependencies = [ "rasn", ] @@ -3907,13 +3907,13 @@ dependencies = [ [[package]] name = "regex" -version = "1.10.1" +version = "1.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aaac441002f822bc9705a681810a4dd2963094b9ca0ddc41cb963a4c189189ea" +checksum = "380b951a9c5e80ddfd6136919eef32310721aa4aacd4889a8d39124b026ab343" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.2", + "regex-automata 0.4.3", "regex-syntax 0.8.2", ] @@ -3928,9 +3928,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5011c7e263a695dc8ca064cddb722af1be54e517a280b12a5356f98366899e5d" +checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f" dependencies = [ "aho-corasick", "memchr", @@ -4035,9 +4035,9 @@ dependencies = [ [[package]] name = "ring" -version = "0.17.3" +version = "0.17.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babe80d5c16becf6594aa32ad2be8fe08498e7ae60b77de8df700e67f191d7e" +checksum = "fce3045ffa7c981a6ee93f640b538952e155f1ae3a1a02b84547fc7a56b7059a" dependencies = [ "cc", "getrandom 0.2.10", @@ -4126,7 +4126,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "549b9d036d571d42e6e85d1c1425e2ac83491075078ca9a15be021c56b1641f2" dependencies = [ - "bitflags 2.4.0", + "bitflags 2.4.1", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -4213,7 +4213,7 @@ version = "0.38.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "745ecfa778e66b2b63c88a61cb36e0eea109e803b0b86bf9879fbc77c70e86ed" dependencies = [ - "bitflags 2.4.0", + "bitflags 2.4.1", "errno", "libc", "linux-raw-sys", @@ -4678,7 +4678,7 @@ checksum = "942b4a808e05215192e39f4ab80813e599068285906cc91aa64f923db842bd5a" [[package]] name = "smtp" -version = "0.3.9" +version = "0.3.10" dependencies = [ "ahash 0.8.3", "blake3", @@ -4912,7 +4912,7 @@ checksum = "864b869fdf56263f4c95c45483191ea0af340f9f3e3e7b4d57a61c7c87a970db" dependencies = [ "atoi", "base64 0.21.4", - "bitflags 2.4.0", + "bitflags 2.4.1", "byteorder", "bytes", "crc", @@ -4954,7 +4954,7 @@ checksum = "eb7ae0e6a97fb3ba33b23ac2671a5ce6e3cabe003f451abd5a56e7951d975624" dependencies = [ "atoi", "base64 0.21.4", - "bitflags 2.4.0", + "bitflags 2.4.1", "byteorder", "crc", "dotenvy", @@ -5009,7 +5009,7 @@ dependencies = [ [[package]] name = "stalwart-cli" -version = "0.3.9" +version = "0.3.10" dependencies = [ "clap", "console", @@ -5031,7 +5031,7 @@ dependencies = [ [[package]] name = "stalwart-install" -version = "0.3.9" +version = "0.3.10" dependencies = [ "base64 0.21.4", "clap", @@ -5893,7 +5893,7 @@ checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" [[package]] name = "utils" -version = "0.3.9" +version = "0.3.10" dependencies = [ "ahash 0.8.3", "dashmap", @@ -6059,7 +6059,7 @@ version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53" dependencies = [ - "ring 0.17.3", + "ring 0.17.4", "untrusted 0.9.0", ] @@ -6153,10 +6153,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] -name = "windows" -version = "0.48.0" +name = "windows-core" +version = "0.51.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" +checksum = "f1f8cf84f35d2db49a46868f947758c7a1138116f7fac3bc844f43ade1292e64" dependencies = [ "windows-targets 0.48.5", ] diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 95d0c407..415278a3 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Stalwart Labs Ltd. "] license = "AGPL-3.0-only" repository = "https://github.com/stalwartlabs/cli" homepage = "https://github.com/stalwartlabs/cli" -version = "0.3.9" +version = "0.3.10" edition = "2021" readme = "README.md" resolver = "2" diff --git a/crates/imap/Cargo.toml b/crates/imap/Cargo.toml index 3e1a97f9..9ba74356 100644 --- a/crates/imap/Cargo.toml +++ b/crates/imap/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "imap" -version = "0.3.9" +version = "0.3.10" edition = "2021" resolver = "2" diff --git a/crates/install/Cargo.toml b/crates/install/Cargo.toml index 8ee031cb..67ee2ea4 100644 --- a/crates/install/Cargo.toml +++ b/crates/install/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Stalwart Labs Ltd. "] license = "AGPL-3.0-only" repository = "https://github.com/stalwartlabs/mail-server" homepage = "https://github.com/stalwartlabs/mail-server" -version = "0.3.9" +version = "0.3.10" edition = "2021" readme = "README.md" resolver = "2" diff --git a/crates/jmap/Cargo.toml b/crates/jmap/Cargo.toml index dc98cffe..46b0e6ba 100644 --- a/crates/jmap/Cargo.toml +++ b/crates/jmap/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jmap" -version = "0.3.9" +version = "0.3.10" edition = "2021" resolver = "2" diff --git a/crates/main/Cargo.toml b/crates/main/Cargo.toml index 6dd3bbc2..9feeb60f 100644 --- a/crates/main/Cargo.toml +++ b/crates/main/Cargo.toml @@ -7,7 +7,7 @@ homepage = "https://stalw.art" keywords = ["imap", "jmap", "smtp", "email", "mail", "server"] categories = ["email"] license = "AGPL-3.0-only" -version = "0.3.9" +version = "0.3.10" edition = "2021" resolver = "2" diff --git a/crates/nlp/Cargo.toml b/crates/nlp/Cargo.toml index 0304da94..a9d8102e 100644 --- a/crates/nlp/Cargo.toml +++ b/crates/nlp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nlp" -version = "0.3.9" +version = "0.3.10" edition = "2021" resolver = "2" diff --git a/crates/smtp/Cargo.toml b/crates/smtp/Cargo.toml index dc8f846f..b5cbb916 100644 --- a/crates/smtp/Cargo.toml +++ b/crates/smtp/Cargo.toml @@ -7,7 +7,7 @@ homepage = "https://stalw.art/smtp" keywords = ["smtp", "email", "mail", "server"] categories = ["email"] license = "AGPL-3.0-only" -version = "0.3.9" +version = "0.3.10" edition = "2021" resolver = "2" diff --git a/crates/smtp/src/config/scripts.rs b/crates/smtp/src/config/scripts.rs index 71040842..b318cb8a 100644 --- a/crates/smtp/src/config/scripts.rs +++ b/crates/smtp/src/config/scripts.rs @@ -23,7 +23,7 @@ use std::time::Duration; -use nlp::bayes::{cache::BayesTokenCache, BayesClassifier}; +use nlp::bayes::cache::BayesTokenCache; use sieve::{compiler::grammar::Capability, Compiler, Runtime}; use crate::{ @@ -44,7 +44,6 @@ pub trait ConfigSieve { #[derive(Default)] pub struct SieveContext { pub psl: PublicSuffix, - pub bayes_classify: BayesClassifier, pub bayes_cache: BayesTokenCache, } @@ -54,12 +53,6 @@ impl ConfigSieve for Config { let mut fnc_map = register_functions().register_plugins(); let sieve_ctx = SieveContext { psl: self.parse_public_suffix()?, - bayes_classify: BayesClassifier { - min_token_hits: self.property_or_static("bayes.min-token-hits", "2")?, - min_tokens: self.property_or_static("bayes.min-tokens", "11")?, - min_prob_strength: self.property_or_static("bayes.min-prob-strength", "0.05")?, - min_learns: self.property_or_static("bayes.min-learns", "200")?, - }, bayes_cache: BayesTokenCache::new( self.property_or_static("bayes.cache.capacity", "8192")?, self.property_or_static("bayes.cache.ttl.positive", "1h")?, diff --git a/crates/smtp/src/scripts/functions/misc.rs b/crates/smtp/src/scripts/functions/misc.rs index ed1ae329..0855d48a 100644 --- a/crates/smtp/src/scripts/functions/misc.rs +++ b/crates/smtp/src/scripts/functions/misc.rs @@ -41,6 +41,10 @@ pub fn fn_is_empty<'x>(_: &'x Context<'x, SieveContext>, v: Vec) -> Va .into() } +pub fn fn_is_number<'x>(_: &'x Context<'x, SieveContext>, v: Vec) -> Variable { + matches!(&v[0], Variable::Integer(_) | Variable::Float(_)).into() +} + pub fn fn_is_ip_addr<'x>(_: &'x Context<'x, SieveContext>, v: Vec) -> Variable { v[0].to_string().parse::().is_ok().into() } diff --git a/crates/smtp/src/scripts/functions/mod.rs b/crates/smtp/src/scripts/functions/mod.rs index c09a9239..6ac99470 100644 --- a/crates/smtp/src/scripts/functions/mod.rs +++ b/crates/smtp/src/scripts/functions/mod.rs @@ -47,6 +47,7 @@ pub fn register_functions() -> FunctionMap { .with_function("len", fn_len) .with_function("count", fn_count) .with_function("is_empty", fn_is_empty) + .with_function("is_number", fn_is_number) .with_function("is_ascii", fn_is_ascii) .with_function("to_lowercase", fn_to_lowercase) .with_function("to_uppercase", fn_to_uppercase) diff --git a/crates/smtp/src/scripts/plugins/bayes.rs b/crates/smtp/src/scripts/plugins/bayes.rs index 50648c69..10c0ec67 100644 --- a/crates/smtp/src/scripts/plugins/bayes.rs +++ b/crates/smtp/src/scripts/plugins/bayes.rs @@ -23,7 +23,10 @@ use directory::{DatabaseColumn, Lookup}; use nlp::{ - bayes::{cache::BayesTokenCache, tokenize::BayesTokenizer, BayesModel, TokenHash, Weights}, + bayes::{ + cache::BayesTokenCache, tokenize::BayesTokenizer, BayesClassifier, BayesModel, TokenHash, + Weights, + }, tokenizers::osb::{OsbToken, OsbTokenizer}, }; use sieve::{runtime::Variable, FunctionMap}; @@ -42,7 +45,11 @@ pub fn register_untrain(plugin_id: u32, fnc_map: &mut FunctionMap) } pub fn register_classify(plugin_id: u32, fnc_map: &mut FunctionMap) { - fnc_map.set_external_function("bayes_classify", plugin_id, 2); + fnc_map.set_external_function("bayes_classify", plugin_id, 3); +} + +pub fn register_is_balanced(plugin_id: u32, fnc_map: &mut FunctionMap) { + fnc_map.set_external_function("bayes_is_balanced", plugin_id, 3); } pub fn exec_train(ctx: PluginContext<'_>) -> Variable { @@ -150,6 +157,24 @@ pub fn exec_classify(ctx: PluginContext<'_>) -> Variable { if text.is_empty() { return Variable::default(); } + + // Create classifier from defaults + let mut classifier = BayesClassifier::default(); + if let Some(params) = ctx.arguments[2].as_array() { + if let Some(Variable::Integer(value)) = params.get(0) { + classifier.min_token_hits = *value as u32; + } + if let Some(Variable::Integer(value)) = params.get(1) { + classifier.min_tokens = *value as u32; + } + if let Some(Variable::Float(value)) = params.get(2) { + classifier.min_prob_strength = *value; + } + if let Some(Variable::Integer(value)) = params.get(3) { + classifier.min_learns = *value as u32; + } + } + let handle = ctx.handle; let ctx = ctx.core.sieve.runtime.context(); @@ -160,16 +185,29 @@ pub fn exec_classify(ctx: PluginContext<'_>) -> Variable { { (weights.spam, weights.ham) } else { + tracing::warn!( + parent: span, + context = "sieve:classify", + event = "failed", + reason = "Failed to obtain training counts", + ); return Variable::default(); }; // Make sure we have enough training data - if spam_learns < ctx.bayes_classify.min_learns || ham_learns < ctx.bayes_classify.min_learns { + if spam_learns < classifier.min_learns || ham_learns < classifier.min_learns { + tracing::debug!( + parent: span, + context = "sieve:bayes_classify", + event = "skip-classify", + reason = "Not enough training data", + spam_learns = %spam_learns, + ham_learns = %ham_learns); return Variable::default(); } // Classify the text - ctx.bayes_classify + classifier .classify( OsbTokenizer::<_, TokenHash>::new(BayesTokenizer::new(text.as_ref(), &ctx.psl), 5) .filter_map(|t| { @@ -188,6 +226,75 @@ pub fn exec_classify(ctx: PluginContext<'_>) -> Variable { .unwrap_or_default() } +pub fn exec_is_balanced(ctx: PluginContext<'_>) -> Variable { + let min_balance = match &ctx.arguments[2] { + Variable::Float(n) => *n, + Variable::Integer(n) => *n as f64, + _ => 0.0, + }; + + if min_balance == 0.0 { + return true.into(); + } + + let span = ctx.span; + let lookup_id = ctx.arguments[0].to_string(); + let lookup_classify = + if let Some(lookup_classify) = ctx.core.sieve.lookup.get(lookup_id.as_ref()) { + lookup_classify + } else { + tracing::warn!( + parent: span, + context = "sieve:bayes_is_balanced", + event = "failed", + reason = "Unknown lookup id", + lookup_id = %lookup_id, + ); + return Variable::default(); + }; + let learn_spam = ctx.arguments[2].to_bool(); + + // Obtain training counts + let handle = ctx.handle; + let ctx = ctx.core.sieve.runtime.context(); + let (spam_learns, ham_learns) = if let Some(weights) = + ctx.bayes_cache + .get_or_update(TokenHash::default(), handle, lookup_classify) + { + (weights.spam as f64, weights.ham as f64) + } else { + tracing::warn!( + parent: span, + context = "sieve:bayes_is_balanced", + event = "failed", + reason = "Failed to obtain training counts", + ); + return Variable::default(); + }; + + let result = if spam_learns > 0.0 || ham_learns > 0.0 { + if learn_spam { + (spam_learns / (ham_learns + 1.0)) <= 1.0 / min_balance + } else { + (ham_learns / (spam_learns + 1.0)) <= 1.0 / min_balance + } + } else { + true + }; + + tracing::debug!( + parent: span, + context = "sieve:bayes_is_balanced", + event = "result", + is_balanced = %result, + learn_spam = %learn_spam, + min_balance = %min_balance, + spam_learns = %spam_learns, + ham_learns = %ham_learns); + + result.into() +} + trait LookupOrInsert { fn get_or_update( &self, diff --git a/crates/smtp/src/scripts/plugins/lookup.rs b/crates/smtp/src/scripts/plugins/lookup.rs index 46f4048f..8cd698b7 100644 --- a/crates/smtp/src/scripts/plugins/lookup.rs +++ b/crates/smtp/src/scripts/plugins/lookup.rs @@ -40,6 +40,10 @@ pub fn register_map_many(plugin_id: u32, fnc_map: &mut FunctionMap fnc_map.set_external_function("lookup_map_many", plugin_id, 2); } +pub fn register_local_domain(plugin_id: u32, fnc_map: &mut FunctionMap) { + fnc_map.set_external_function("is_local_domain", plugin_id, 2); +} + pub fn exec(ctx: PluginContext<'_>) -> Variable { let lookup_id = ctx.arguments[0].to_string(); let span = ctx.span; @@ -135,3 +139,28 @@ pub fn exec_map_many(ctx: PluginContext<'_>) -> Variable { Variable::default() } } + +pub fn exec_local_domain(ctx: PluginContext<'_>) -> Variable { + let directory_id = ctx.arguments[0].to_string(); + let domain = ctx.arguments[0].to_string(); + + if !directory_id.is_empty() && !domain.is_empty() { + if let Some(dir) = ctx.core.sieve.config.directories.get(directory_id.as_ref()) { + return ctx + .handle + .block_on(dir.is_local_domain(domain.as_ref())) + .unwrap_or_default() + .into(); + } else { + tracing::warn!( + parent: ctx.span, + context = "sieve:is_local_domain", + event = "failed", + reason = "Unknown directory", + lookup_id = %directory_id, + ); + } + } + + Variable::default() +} diff --git a/crates/smtp/src/scripts/plugins/mod.rs b/crates/smtp/src/scripts/plugins/mod.rs index 7caafe9c..c6d5ec7b 100644 --- a/crates/smtp/src/scripts/plugins/mod.rs +++ b/crates/smtp/src/scripts/plugins/mod.rs @@ -46,32 +46,36 @@ pub struct PluginContext<'x> { pub arguments: Vec, } -const PLUGINS_EXEC: [ExecPluginFnc; 12] = [ +const PLUGINS_EXEC: [ExecPluginFnc; 14] = [ query::exec, exec::exec, lookup::exec, lookup::exec_map, lookup::exec_map_many, + lookup::exec_local_domain, dns::exec, dns::exec_exists, http::exec_header, bayes::exec_train, bayes::exec_untrain, bayes::exec_classify, + bayes::exec_is_balanced, pyzor::exec, ]; -const PLUGINS_REGISTER: [RegisterPluginFnc; 12] = [ +const PLUGINS_REGISTER: [RegisterPluginFnc; 14] = [ query::register, exec::register, lookup::register, lookup::register_map, lookup::register_map_many, + lookup::register_local_domain, dns::register, dns::register_exists, http::register_header, bayes::register_train, bayes::register_untrain, bayes::register_classify, + bayes::register_is_balanced, pyzor::register, ]; diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml index 3cad44aa..63ae2a8c 100644 --- a/crates/utils/Cargo.toml +++ b/crates/utils/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "utils" -version = "0.3.9" +version = "0.3.10" edition = "2021" resolver = "2" diff --git a/resources/config/sieve/bayes_classify.sieve b/resources/config/sieve/bayes_classify.sieve index 21b9d8ec..3467754b 100644 --- a/resources/config/sieve/bayes_classify.sieve +++ b/resources/config/sieve/bayes_classify.sieve @@ -1,5 +1,12 @@ if eval "!t.SPAM_TRAP && !t.TRUSTED_REPLY" { - let "bayes_result" "bayes_classify('spamdb/token-lookup', body_and_subject)"; + + # Classification parameters + # min_token_hits: 2 + # min_tokens: 11 + # min_prob_strength: 0.05 + # min_learns: 200 + + let "bayes_result" "bayes_classify('spamdb/token-lookup', body_and_subject, [2, 11, 0.05, 200])"; if eval "!is_empty(bayes_result)" { if eval "bayes_result > 0.7" { let "t.BAYES_SPAM" "1"; diff --git a/resources/config/sieve/config.sieve b/resources/config/sieve/config.sieve new file mode 100644 index 00000000..2d5d7bbd --- /dev/null +++ b/resources/config/sieve/config.sieve @@ -0,0 +1,33 @@ +# Whether to add an X-Spam-Status header +let "ADD_HEADER_SPAM" "true"; + +# Whether to add an X-Spam-Result header +let "ADD_HEADER_SPAM_RESULT" "true"; + +# Whether message replies from authenticated users should be learned as ham +let "AUTOLEARN_REPLIES_HAM" "true"; + +# Whether the bayes classifier should be trained automatically +let "AUTOLEARN_ENABLE" "true"; + +# When to learn ham (score >= threshold) +let "AUTOLEARN_HAM_THRESHOLD" "-0.5"; + +# When to learn spam (score <= threshold) +let "AUTOLEARN_SPAM_THRESHOLD" "6.0"; + +# Keep difference for spam/ham learns for at least this value +let "AUTOLEARN_SPAM_HAM_BALANCE" "0.9"; + +# If ADD_HEADER_SPAM is enabled, mark as SPAM messages with a score above this threshold +let "SCORE_SPAM_THRESHOLD" "5.0"; + +# Discard messages with a score above this threshold +let "SCORE_DISCARD_THRESHOLD" "0"; + +# Reject messages with a score above this threshold +let "SCORE_REJECT_THRESHOLD" "0"; + +# Directory name to use for local domain lookups +let "DOMAIN_DIRECTORY" "'default'"; + diff --git a/resources/config/sieve/epilogue.sieve b/resources/config/sieve/epilogue.sieve new file mode 100644 index 00000000..2075ef67 --- /dev/null +++ b/resources/config/sieve/epilogue.sieve @@ -0,0 +1,58 @@ + +# Train the bayes classifier automatically +if eval "AUTOLEARN_ENABLE && (score >= AUTOLEARN_SPAM_THRESHOLD || score <= AUTOLEARN_HAM_THRESHOLD)" { + let "is_spam" "score >= AUTOLEARN_SPAM_THRESHOLD"; + eval "bayes_is_balanced('spamdb/token-lookup', is_spam, AUTOLEARN_SPAM_HAM_BALANCE) && + bayes_train('spamdb/token-insert', body_and_subject, is_spam)"; +} + +add balance + +# Iterate over tags +let "tags" "var_names()"; +let "i" "count(tags)"; +let "spam_result" ""; +while "i > 0" { + let "i" "i - 1"; + let "tag" "tags[i]"; + let "tag_score" "map('spam/scores', tag)"; + + if eval "is_number(tag_score)" { + let "score" "score + tag_score"; + if eval "ADD_HEADER_SPAM_RESULT" { + if eval "!is_empty(spam_result)" { + let "spam_result" "spam_result + ',\r\n\t' + tag + ' (' + tag_score + ')'"; + } else { + let "spam_result" "spam_result + tag + ' (' + tag_score + ')'"; + } + } + } elsif eval "tag_score == 'reject' { + let "SCORE_REJECT_THRESHOLD" "1"; + let "score" "2"; + break; + } else if eval "tag_score == 'discard'" { + discard; + stop; + } +} + +# Process score actions +if "SCORE_REJECT_THRESHOLD && score >= SCORE_REJECT_THRESHOLD" { + reject "Your message has been rejected because it has an excessive spam score. If you feel this is an error, please contact the postmaster."; + stop; +} else if "SCORE_DISCARD_THRESHOLD && score >= SCORE_DISCARD_THRESHOLD" { + discard; + stop; +} else if "ADD_HEADER_SPAM" { + let "spam_status" ""; + if eval "score >= SCORE_SPAM_THRESHOLD" { + let "spam_status" "'Yes, score=' + score"; + } else { + let "spam_status" "'No, score=' + score"; + } + eval "add-header('X-Spam-Status', spam_status)"; + if eval "!is_empty(spam_result)" { + eval "add-header('X-Spam-Result', spam_result)"; + } +} + diff --git a/resources/config/sieve/greylist.sieve b/resources/config/sieve/greylist.sieve new file mode 100644 index 00000000..168fbf69 --- /dev/null +++ b/resources/config/sieve/greylist.sieve @@ -0,0 +1,9 @@ + +set "triplet" "${env.remote_ip}.${envelope.from}.${envelope.to}"; + +if eval "!lookup('spamdb/id-lookup', triplet)" { + # Greylist sender for 30 days + eval "lookup_map('spamdb/id-insert', [triplet, 2592000])"; + reject "422 4.2.2 Greylisted, please try again in a few moments."; + stop; +} diff --git a/resources/config/sieve/mime.sieve b/resources/config/sieve/mime.sieve index eb8accc6..b1287604 100644 --- a/resources/config/sieve/mime.sieve +++ b/resources/config/sieve/mime.sieve @@ -9,6 +9,11 @@ if eval "!header.mime-version.exists" { let "has_text_part" "0"; let "is_encrypted" "0"; +if eval "header.Content-Type.exists && !header.Content-Disposition:Content-Transfer-Encoding:MIME-Version.exists && !eq_ignore_case(header.Content-Type, 'text/plain')" { + # Only Content-Type header without other MIME headers + let "t.MIME_HEADER_CTYPE_ONLY" "1"; +} + foreverypart { let "content_type" "to_lowercase(header.content-type)"; let "type" "to_lowercase(header.content-type.type)"; @@ -138,13 +143,8 @@ foreverypart { } } - if eval "is_empty(type)" { - if eval "header.content-type.exists" { - let "t.BROKEN_CONTENT_TYPE" "1"; - } - } elsif eval "!header.Content-Disposition:Content-Transfer-Encoding:MIME-Version.exists && (type != 'text' || subtype != 'plain')" { - # Only Content-Type header without other MIME headers - let "t.MIME_HEADER_CTYPE_ONLY" "1"; + if eval "is_empty(type) && header.content-type.exists" { + let "t.BROKEN_CONTENT_TYPE" "1"; } if eval "part_is_attachment" { diff --git a/resources/config/sieve/prelude.sieve b/resources/config/sieve/prelude.sieve index 8409d4b3..c50de1ef 100644 --- a/resources/config/sieve/prelude.sieve +++ b/resources/config/sieve/prelude.sieve @@ -38,3 +38,6 @@ let "envfrom_domain_sld" "domain_part(envfrom_domain, 'sld')"; # Obtain HELO domain SLD let "helo_domain_sld" "domain_part(env.helo_domain, 'sld')"; + +# Create score variable +let "score" "0.0"; diff --git a/resources/config/sieve/rbl.sieve b/resources/config/sieve/rbl.sieve index 9d854ed7..19edeee5 100644 --- a/resources/config/sieve/rbl.sieve +++ b/resources/config/sieve/rbl.sieve @@ -171,7 +171,8 @@ while "i < domains_len" { let "domain" "domains[i]"; let "i" "i + 1"; - if eval "!contains(domain, '.') || is_ip_addr(domain)" { + # Skip invalid and local domain names + if eval "!contains(domain, '.') || is_ip_addr(domain) || is_local_domain(DOMAIN_DIRECTORY, domain_part(domain, 'sld'))" { continue; } @@ -286,7 +287,8 @@ while "i < emails_len" { let "email" "emails[i]"; let "i" "i + 1"; - if eval "!contains(email, '@')" { + # Skip invalid and local e-mail addresses + if eval "!contains(email, '@') || is_local_domain(DOMAIN_DIRECTORY, domain_part(email_part(email, 'domain'), 'sld'))" { continue; } @@ -319,6 +321,11 @@ while "i < urls_len" { break; } + # Skip URLs pointing to local domains + if eval "is_local_domain(DOMAIN_DIRECTORY, domain_part(uri_part(url, 'host'), 'sld'))" { + continue; + } + # Query SURBL HASHBL let "result" "rsplit_once(dns_query(hash(url, 'md5') + '.hashbl.surbl.org', 'ipv4')[0], '.')"; if eval "starts_with(result[0], '127.0.')" { diff --git a/resources/config/sieve/replies_out.sieve b/resources/config/sieve/replies_out.sieve index bac49b03..26729c92 100644 --- a/resources/config/sieve/replies_out.sieve +++ b/resources/config/sieve/replies_out.sieve @@ -3,9 +3,10 @@ let "message_id" "header.Message-ID"; if eval "!is_empty(message_id)" { - eval "lookup('spamdb/id-insert', message_id)"; + # Store the message ID for 30 days + eval "lookup_map('spamdb/id-insert', [message_id, 2592000])"; - if eval "lookup('spam/options', 'AUTOLEARN_REPLIES')" { + if eval "AUTOLEARN_ENABLE && AUTOLEARN_REPLIES_HAM && bayes_is_balanced('spamdb/token-lookup', false, AUTOLEARN_SPAM_HAM_BALANCE)" { eval "bayes_train('spamdb/token-insert', thread_name(header.subject) + ' ' + body.to_text, false)"; } } diff --git a/resources/config/sieve/scores.sieve b/resources/config/sieve/scores.sieve new file mode 100644 index 00000000..e69de29b diff --git a/resources/config/sieve/spamtrap.sieve b/resources/config/sieve/spamtrap.sieve index 361b6e18..27cae85a 100644 --- a/resources/config/sieve/spamtrap.sieve +++ b/resources/config/sieve/spamtrap.sieve @@ -1,6 +1,9 @@ # Check if the message was sent to a spam trap address -if eval "lookup('spam/trap-address', envelope.to)" { - eval "bayes_train('spamdb/token-insert', body_and_subject, true)"; +if eval "AUTOLEARN_ENABLE && lookup('spam/trap-address', envelope.to)" { + eval "bayes_is_balanced('spamdb/token-lookup', false, AUTOLEARN_SPAM_HAM_BALANCE) && bayes_train('spamdb/token-insert', body_and_subject, true)"; let "t.SPAM_TRAP" "1"; + + # Disable autolearn so the classifier is not trained twice + let "AUTOLEARN_ENABLE" "0"; } diff --git a/resources/config/sieve/url.sieve b/resources/config/sieve/url.sieve index 2377b8a7..33d54fe1 100644 --- a/resources/config/sieve/url.sieve +++ b/resources/config/sieve/url.sieve @@ -12,6 +12,12 @@ let "i" "count(urls)"; while "i > 0" { let "i" "i - 1"; let "url" "urls[i]"; + + # Skip non-URLs such as 'data:' and 'mailto:' + if eval "!contains(url, '://')" { + continue; + } + let "host" "uri_part(url, 'host')"; if eval "!is_empty(host)" { @@ -20,6 +26,10 @@ while "i > 0" { let "host_lc" "to_lowercase(host)"; let "host_sld" "domain_part(host_lc, 'sld')"; + if eval "is_local_domain(DOMAIN_DIRECTORY, host_sld)" { + continue; + } + if eval "!is_ip && (!t.REDIRECTOR_URL || !t.URL_REDIRECTOR_NESTED) && lookup('spam/redirectors', host_sld)" { diff --git a/tests/resources/smtp/antispam/combined.test b/tests/resources/smtp/antispam/combined.test new file mode 100644 index 00000000..4a60a594 --- /dev/null +++ b/tests/resources/smtp/antispam/combined.test @@ -0,0 +1,827 @@ +envelope_from noreply@tetheer.com +envelope_to licensing@stalw.art +helo_domain yphoo.vps.wbsprt.com +iprev.result permerror +spf.result none +spf_ehlo.result none +dmarc.result none +remote_ip 195.210.29.48 +expect hfilter_helo_nores_a_or_mx once_received mid_rhs_match_from r_spf_na has_data_uri arc_na subject_has_exclaim subject_ends_exclaim rdns_dnsfail mime_html_only html_short_link_img_1 to_dn_none rcpt_count_one to_match_envrcpt_all hfilter_fromhost_nores_a_or_mx rcvd_count_zero from_eq_envfrom r_dkim_na rcvd_no_tls_last from_has_dn date_in_past + +From: Client Services +To: licensing@stalw.art +Subject: Tether Important Update ! +Date: 16 Oct 2023 06:40:52 +0200 +Message-ID: <20231016064052.403F7FEF5F005EFB@tetheer.com> +MIME-Version: 1.0 +Content-Type: text/html +Content-Transfer-Encoding: quoted-printable + + + + + + + +3D"If + + +envelope_from l.chant@tenthrevolution.com +envelope_to joe@domain.org +helo_domain eu-smtp-delivery-181.mimecast.com +iprev.result pass +spf.result pass +spf_ehlo.result pass +dkim.result pass +dkim.domains tenthrevolution.com +dmarc.result pass +remote_ip 185.58.86.181 +tls.version TLSv1.3 +expect from_eq_envfrom from_has_dn hfilter_helo_nores_a_or_mx forged_rcvd_trail date_in_past arc_na uri_count_odd dkim_signed has_attachment r_spf_allow rcvd_tls_last rcpt_count_one mime_good subject_ends_spaces hfilter_fromhost_nores_a_or_mx to_dn_eq_addr_all r_dkim_allow dmarc_policy_allow rcvd_count_three to_match_envrcpt_all + +DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=tenthrevolution.com; + s=mimecast20200102; t=1669138703; + h=from:from:reply-to:subject:subject:date:date:message-id:message-id: + to:to:cc:mime-version:mime-version:content-type:content-type; + bh=OiZa5h2Agb47dTPnaYIMS7L31ZUnIkX1UTz8yUhK3ME=; + b=Z0erZ14U5BYcY0CGysOw3K0A7wjF9qqRlOaI4+0XGUmM5QgmgN6UVJc6J5AkypPgwEfOWx + vsCbMrq14SF61IevT2cPrOwaphTL7s3Yf9YqKkk4N9bMiBVeikq1ks0kxJ8pbE8vYsiASn + GEkv9T3YWfRMQR/iH+oD1dVRnCljTxQ= +Received: from EUR01-DB5-obe.outbound.protection.outlook.com + (mail-db5eur01lp2053.outbound.protection.outlook.com [104.47.2.53]) by + relay.mimecast.com with ESMTP with STARTTLS (version=TLSv1.2, + cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id + uk-mta-233-uW3qPnzqMdi1lL-mEjQyCA-4; Tue, 22 Nov 2022 17:38:17 +0000 +X-MC-Unique: uW3qPnzqMdi1lL-mEjQyCA-4 +Received: from AS8PR04MB8071.eurprd04.prod.outlook.com (2603:10a6:20b:3f9::15) + by DU2PR04MB8952.eurprd04.prod.outlook.com (2603:10a6:10:2e3::24) with + Microsoft SMTP Server (version=TLS1_2, + cipher=TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384) id 15.20.5834.9; Tue, 22 Nov + 2022 17:37:46 +0000 +Received: from AS8PR04MB8071.eurprd04.prod.outlook.com + ([fe80::eb0e:b5f3:368c:ff6e]) by AS8PR04MB8071.eurprd04.prod.outlook.com + ([fe80::eb0e:b5f3:368c:ff6e%5]) with mapi id 15.20.5834.015; Tue, 22 Nov 2022 + 17:37:46 +0000 +From: Lee Chant +To: "joe@domain.org" +Subject: =?Windows-1252?Q?You=92re_missing_out_=96_nominate_yourself_or_your_team_?= + =?Windows-1252?Q?for_a_Digital_Revolution_Award_?= +Thread-Topic: =?Windows-1252?Q?You=92re_missing_out_=96_nominate_yourself_or_your_team_?= + =?Windows-1252?Q?for_a_Digital_Revolution_Award_?= +Thread-Index: Adj+l09eH9aJFGDfS8upOMyJ89uCyw== +Date: Tue, 22 Nov 2022 17:37:46 +0000 +Message-ID: +Accept-Language: en-GB, en-US +X-MS-Has-Attach: yes +X-MS-TNEF-Correlator: +x-ms-publictraffictype: Email +x-ms-traffictypediagnostic: AS8PR04MB8071:EE_|DU2PR04MB8952:EE_ +x-ms-office365-filtering-correlation-id: 320b349d-2736-406a-45c9-08daccb044b5 +x-ms-exchange-senderadcheck: 1 +x-ms-exchange-antispam-relay: 0 +x-microsoft-antispam: BCL:0 +x-microsoft-antispam-message-info: RFrNGr7IhMmS4iNfprhPqmj60ReOFkq3iNebFuTE+OovNlEuRaCUYK1lMSdb44RdkJKhfVU53K+aSMHjZ0V9Xdnq9igSL5NKbMPTOCvraH5VB+OYVuXbBG/+Ex2FCJ1HuHYab6Rwjy3ln/pisH9uDVF/Wpn1w6MFZcyTcMXl+5RQ79GOb12a5ybKmmvtxVTjOq53mA10D7qRiWXsxOPTYf7vjmuV6X+J+IF0lQ35AiAJjGAgJrYtu8datK9t3d/k1aIWms2B1tzpL76de0mre2dGVgQIfzu4hhfU99Hejj6opP+ISn2yDsEDRZKPC1FSbggeOMATGLdi7uMSLW3sIMYRm8M6kZ436e9vY718GaCGtYS9cdEaXKjQUeJg/M0MVYDtBdsgxpgOjusyuPj5Wf/KACGdvuNui+SeS+LCjCGrNhe4+uy1cx1XioD9YhNmcrJOHUBmqkmshClu+TdMRqCORGkVGNoRN97KFg0RlX/aYr6erDFDyphdZl65map16nZBjFw+h4JuPI0ZsBd4lWe0ep7a17rcT+xUESmo1/2chtCC6viPwDyGzqelSeZhxRyHa2Tu/VzstxElXSqgs9S8yTsaQ+nsjsRRAnfs5bKbjYzKX2QpXtAEEdQo5R8UnexDRLiB4MupsWkRM24dvSRkyc0Pt2eKO7vVLMSW/RUUxbSNLzTjh2MYOfPCl9WGXKXbJvn4+gZx4iLFZnbQxTxw4Y2lNX/EPDtn80ZpGz5Kab2p36tx/8KAOIYOw3zo5EnTHeHQBjUSmr6qS2cT2GZVyd89bzEZvOSsgsFqT1U= +x-forefront-antispam-report: CIP:255.255.255.255;CTRY:;LANG:en;SCL:1;SRV:;IPV:NLI;SFV:NSPM;H:AS8PR04MB8071.eurprd04.prod.outlook.com;PTR:;CAT:NONE;SFS:(13230022)(39860400002)(366004)(136003)(396003)(346002)(376002)(451199015)(45080400002)(38100700002)(478600001)(33656002)(41300700001)(76116006)(66946007)(64756008)(66556008)(66446008)(66476007)(166002)(316002)(186003)(38070700005)(55016003)(83380400001)(4743002)(26005)(6506007)(7696005)(86362001)(9686003)(71200400001)(55236004)(99936003)(122000001)(6916009)(66899015)(2906002)(8936002)(5660300002)(52536014)(36394005);DIR:OUT;SFP:1101 +x-ms-exchange-antispam-messagedata-chunkcount: 1 +x-ms-exchange-antispam-messagedata-0: =?Windows-1252?Q?4P/FtE9B736TzSCbgF3J1iOGsQRezh6u4SgvpddFWvWNSVWTLtRmRvpF?= + =?Windows-1252?Q?2jqjFWq7ft5r7M4S0JynXShWhP/1VAPc6lZMAfiyyfoQknuWz0rPQEid?= + =?Windows-1252?Q?ay8qW5pzl7Dt3M/2MonJpXaeupRlkkhjREQspgY3DpMRnd8fCMq4O2we?= + =?Windows-1252?Q?sd+rG6VpQ2irOU45M5WrVBGskNcX8C2gJnZzv2IF4BMaYCO9DI9xM6io?= + =?Windows-1252?Q?BKvAgIM3b4Jm3WqwPMmT5bWEnJgRv4OI92cE4eZSzkRpI4u2DlxF4qx5?= + =?Windows-1252?Q?QNX2XKfGHH002dn2lUpYXzMPF4f5jGaX401MDw1iGQWf9M2ZAOVhZoaP?= + =?Windows-1252?Q?Y1BKjdodCrMTCvb1+0fLXxLHfAs4ku2e1gDUez44llQ2POHI6+JtsObx?= + =?Windows-1252?Q?avS9SGfhtFkXDm6x/PLQAuuNhskxxSzuuKbrDQYWbP+LnhbsVVXsQhER?= + =?Windows-1252?Q?qJgff0GlO8M3ICrjY3+moEVqND9Dgmj/opQvPX/J2OW/ULmGddsmUQIi?= + =?Windows-1252?Q?XnwSNTZjtCf13KsGuXu2IIzCZ0vnE6RLJu9vjFJ8zx132jlBmYRfLkBy?= + =?Windows-1252?Q?qNtny11BJny8+bVuodhuohKAYQY3HCGmtE/5GLBs1w3KbkA0rFWMSdg5?= + =?Windows-1252?Q?0gb3egkCIkDN6fHHxo6vlo3Gay63TEGdiIoueTyLPJiMAs6DswGBhRB1?= + =?Windows-1252?Q?B4c34FKxKQXIbCRrVHJME6wZMIfqgjhU6nfC39qMuqn4E/NCfLjoVUTm?= + =?Windows-1252?Q?5IjQS3n+9ObjGWXNHu5nhgUJCGhgeKOcDxAHNL5bRQmruzUm9a5DedWI?= + =?Windows-1252?Q?vlSEzriwrCtwOcwU6ycP0t9XzNTB+TjOv6DPGtSMzlaxRl2mmCgl0sqK?= + =?Windows-1252?Q?1h5vOiAbw6hs/ENFK2n6pSNmFEDyDrM1DZ+eXTqKQixnSvbRLJfsxyyh?= + =?Windows-1252?Q?RARROgIC3Q3grmk8keC1VN5IOjgpXF3tzUo9lKoC/PB0DRJp444jjJYK?= + =?Windows-1252?Q?QpcGxSGUsAvbEK4JkfjFjzQucbW6kewoDp/vwl00JobRePzZymmgnwO3?= + =?Windows-1252?Q?/sRlewjFoih9gkx2G3BD6RBp/UT0rSKlSFdLdOgsXwBea2HtjJYCQbcc?= + =?Windows-1252?Q?qT/zqI8dMeiw2u+BIEcyttiiE95FfgCZ+Jw1eXXSPrKgbheuM3siCQ4l?= + =?Windows-1252?Q?3idTUoQRfVOSGhdi7s9UTyioCQj9cQ6rRNEGoZsBb9rQdEj+IrOirbP5?= + =?Windows-1252?Q?oUpMesdPuX1nS61a63hoYmZNO1o73kgeNrjnQKf4QXycHbucQOmDVrlv?= + =?Windows-1252?Q?4oN+RZRmZ9IQ2cMKxNkwZgJqmPG5SAf31ZF5of8OrfYooS2jwz0Fzerq?= + =?Windows-1252?Q?hLxLTtNQp6FtoVMA7uv7zpP6Nb5HYiCu5qY/kfqhS61Cqo5ulicay35G?= + =?Windows-1252?Q?iAyY5WLHp7+ybc2Zn6eHXZrNzZ5S4E6rCiTcqE3NumT4Ax7vH/fDhT3f?= + =?Windows-1252?Q?ShvqDg98354C9ZEtoV/t1x5vNporSrn/Sbb/FMjXSXOgGE3kxpPV65RB?= + =?Windows-1252?Q?29Fl9M+Z8JmBacbtpZoGnqQ5nKttFjnXtj899djqwoYVuN48/hO4TgnU?= + =?Windows-1252?Q?KqteFtHKVcQDNpdL8vdgo7uFVb9HFQvOU0ECxgTg0F8CpAchtGM3siVN?= + =?Windows-1252?Q?M964A6hx5NPb8YH2C3Pq7IvbCiPf41CC7MSaAMLb04j1VXOKFdTzTQ?= + =?Windows-1252?Q?=3D=3D?= +MIME-Version: 1.0 +X-OriginatorOrg: tenthrevolution.com +X-MS-Exchange-CrossTenant-AuthAs: Internal +X-MS-Exchange-CrossTenant-AuthSource: AS8PR04MB8071.eurprd04.prod.outlook.com +X-MS-Exchange-CrossTenant-Network-Message-Id: 320b349d-2736-406a-45c9-08daccb044b5 +X-MS-Exchange-CrossTenant-originalarrivaltime: 22 Nov 2022 17:37:46.5232 + (UTC) +X-MS-Exchange-CrossTenant-fromentityheader: Hosted +X-MS-Exchange-CrossTenant-id: 9575ad69-b859-42ee-bf2c-7263b3236a16 +X-MS-Exchange-CrossTenant-mailboxtype: HOSTED +X-MS-Exchange-CrossTenant-userprincipalname: uaZau1MOBnlwTPYfr6/hV4Tn/rVEIPMPe31SJJq5jXeno2HbSjV5ElWexVy8mvfaALxr+kV60KBJgJFqS4ftLeUsiciMybCqKPuVBsmtEFg= +X-MS-Exchange-Transport-CrossTenantHeadersStamped: DU2PR04MB8952 +X-Mimecast-Spam-Score: 0 +X-Mimecast-Originator: tenthrevolution.com +Content-Language: en-US +Content-Type: multipart/related; + boundary="_004_AS8PR04MB8071BB5854E10B6EA7161159EF0D9AS8PR04MB8071eurp_"; + type="multipart/alternative" + +--_004_AS8PR04MB8071BB5854E10B6EA7161159EF0D9AS8PR04MB8071eurp_ +Content-Type: multipart/alternative; + boundary="_000_AS8PR04MB8071BB5854E10B6EA7161159EF0D9AS8PR04MB8071eurp_" + +--_000_AS8PR04MB8071BB5854E10B6EA7161159EF0D9AS8PR04MB8071eurp_ +Content-Type: text/plain; charset=WINDOWS-1252 +Content-Transfer-Encoding: quoted-printable + +Hi Joe +My name is Lee and I=92m the Managing Director of Global Customer Solutions= + at Tenth Revolution Group. +I wanted to drop you a note to personally let you know that we have extende= +d the nomination deadline for the Digital Revolution Awards & Fundraiser until 30th November 2022. +These not-for-profit awards celebrate excellence in the cloud and bring tog= +ether the tech community for a night of celebration and fundraising for som= +e truly worthy causes. Founded back in 2020, we have garnered support from = +IBM, AWS, Salesforce and Microsoft and it=92s a perfect opportunity to netw= +ork with likeminded individuals like yourself. +Our 20 specialist categories cover topics from tech for good to the climate emergency, ED&I= +, allyship, outstanding leadership to name but a few. If you or your team w= +ould be interested in discussing a nomination for the Digital Revolution Aw= +ards, do let me know. +With kind regards, +Lee + +Lee Chant +MD - Global Customer Solutions + +Mobile: +44 (0)7971 373432 +Email: l.chant@tenthrevolution.com +Website: www.tenthrevolution.com + +[Tenth Revolution Group] + +Disclaimer +This email and any attachments are confidential and intended for the use of= + the named recipient only. If you have received this email and any attachme= +nts in error, please inform us immediately and then delete it. Any views or= + opinions are solely those of the author and do not necessarily represent t= +hose of Frank Recruitment Group Services Limited or its affiliates, divisio= +ns or brands. Company Registration No. 08142375. +Registered Office: Floor 2, The St. Nicholas Building, St. Nicholas Street,= + Newcastle Upon Tyne, Tyne and Wear, NE1 1RF. +Business registration information of Frank Recruitment Group Services Ltd c= +ompanies and associated brands in the UK, Europe, Singapore, Australia, Jap= +an and North America can be found here. +Our Privacy Notice can be= + found at www.tenthrevolution.com/privacy-notice + + + + + + + +--_000_AS8PR04MB8071BB5854E10B6EA7161159EF0D9AS8PR04MB8071eurp_ +Content-Type: text/html; charset=WINDOWS-1252 +Content-Transfer-Encoding: quoted-printable + + + + + + + + +
+

Hi Joe

+

My name is Lee and I=92m the Managing Director of Gl= +obal Customer Solutions at +Tenth Revolution Group. +

+

I wanted to drop you a note to personally let you kn= +ow that we have extended the nomination deadline for the +Digital Revolution Awa= +rds & Fundraiser until 30th November 2022. + 

+

These not-for-profit awards celebrate excellence in = +the cloud and bring together the tech community for a night of celebration = +and fundraising for some truly worthy causes. Founded back in 2020, we have= + garnered support from IBM, AWS, Salesforce + and Microsoft and it=92s a perfect opportunity to network with likeminded = +individuals like yourself. +

+

Our 20 specialist +categories cover topics from tech for good to the climate emergency, ED= +&I, allyship, outstanding leadership to name but a few. If you or your = +team would be interested in discussing a nomination for the Digital Revolut= +ion Awards, do let me know. +

+

With kind regards,

+

Lee

+

Lee Chant
+MD - Global Customer Solutio= +ns

+

Mobile: +44 (= +0)7971 373432
+Email:
l.chant@tenthrevolution.com
+Website:
www.tenthrevolution.com

+

+=3D"Tenth

+

Disclaimer
+
This email and any= + attachments are confidential and intended for the use of the named recipie= +nt only. If you have received this email and any attachments in error, plea= +se inform us immediately and then + delete it. Any views or opinions are solely those of the author and do not= + necessarily represent those of Frank Recruitment Group Services Limited or= + its affiliates, divisions or brands. Company Registration No. 08142375. +
+Registered Office: Floor 2, The St. Nicholas Building, St. Nicholas Street,= + Newcastle Upon Tyne, Tyne and Wear, NE1 1RF.
+Business registration information of Frank Recruitment Group Services Ltd c= +ompanies and associated brands in the UK, Europe, Singapore, Australia, Jap= +an and North America can be found 
here.
+
Our Privacy Notice= + can be found at +www.tenthrevolution.com/privacy-notice
+

+

 

+

 

+

 

+

 

+

 

+
+ + + +--_000_AS8PR04MB8071BB5854E10B6EA7161159EF0D9AS8PR04MB8071eurp_-- + +--_004_AS8PR04MB8071BB5854E10B6EA7161159EF0D9AS8PR04MB8071eurp_ +Content-Type: image/jpeg; name="image001.jpg" +Content-Description: image001.jpg +Content-Disposition: inline; filename="image001.jpg"; size=13809; + creation-date="Tue, 22 Nov 2022 17:37:45 GMT"; + modification-date="Tue, 22 Nov 2022 17:37:46 GMT" +Content-ID: +Content-Transfer-Encoding: base64 + +/9j/4AAQSkZJRgABAgAAZABkAAD/7AARRHVja3kAAQAEAAAAPAAA/+EDLGh0dHA6Ly9ucy5hZG9i +ZS5jb20veGFwLzEuMC8APD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6 +TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0i +QWRvYmUgWE1QIENvcmUgNi4wLWMwMDIgNzkuMTY0MzUyLCAyMDIwLzAxLzMwLTE1OjUwOjM4ICAg +ICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjIt +cmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXBN +TT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9u +cy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtbG5zOnhtcD0iaHR0cDov +L25zLmFkb2JlLmNvbS94YXAvMS4wLyIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDo4ODIyQUZF +QTc3RkIxMUVBOUQ1REY1N0FCOTE5REQwQiIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDo4ODIy +QUZFOTc3RkIxMUVBOUQ1REY1N0FCOTE5REQwQiIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90 +b3Nob3AgMjEuMCAoV2luZG93cykiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJ +RD0ieG1wLmlpZDozRDE0N0RCRDMzOEExMUVBQTVDMEQyQjIxRUU5OTM5QyIgc3RSZWY6ZG9jdW1l +bnRJRD0ieG1wLmRpZDozRDE0N0RCRTMzOEExMUVBQTVDMEQyQjIxRUU5OTM5QyIvPiA8L3JkZjpE +ZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/Pv/u +AA5BZG9iZQBkwAAAAAH/2wCEAAYEBAQFBAYFBQYJBgUGCQsIBgYICwwKCgsKCgwQDAwMDAwMEAwO +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 +CgFAKAUAoBQCgFAKA//Z +--_004_AS8PR04MB8071BB5854E10B6EA7161159EF0D9AS8PR04MB8071eurp_-- + + +envelope_from noreply@grupokonecta.net +envelope_to joe@domain.org +helo_domain rheology.yeloweditions.com +iprev.result fail +dkim.result pass +dkim.domains rheology.yeloweditions.com +spf.result softfail +spf_ehlo.result softfail +dmarc.result fail +dmarc.policy reject +remote_ip 51.89.165.39 +tls.version TLS1_2 +expect has_replyto once_received r_parts_differ mid_rhs_match_from hfilter_fromhost_nores_a_or_mx from_has_dn r_dkim_allow date_in_past to_match_envrcpt_all html_short_link_img_1 rcpt_count_one arc_na hfilter_helo_nores_a_or_mx r_spf_softfail rcvd_tls_last rcvd_count_zero replyto_dom_eq_from_dom to_dn_none has_list_unsub dkim_signed rdns_none from_eq_envfrom dmarc_policy_reject + +DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; s=sectionalism; d=grupokonecta.net; + h=To:Subject:Message-ID:Date:From:Reply-To:MIME-Version:List-Unsubscribe: + Content-Type:Content-Transfer-Encoding; i=noreply@grupokonecta.net; + bh=dP5ixF9HZsWlj/p3qHOLHJE+ZBdEajVqh0aowOaVxc4=; + b=nxR6ACs3hInMKEyEK9yQIEgu4RuffMNmnA6a2mC6Z1c9Qbao8AsmuWct1i7bvn5lU1mmDSXx6PwO + /w2Z6TSZIW1jZkhQwDo5sJWO/0f26zabNJ3bAsZnN/Yyy0Lf4oUJuaZ97cwHiMPlDIPF++gyuY6s + 30mpjo29dX3ZCSCkMkcmox55AsQSK2nJ2ZIv8dqep02kiXjldaiW5hIRzHwjeh+REy3Mb5zCuIVJ + 4dcKomEGK3JsnnY1mdMVHB1ghlVMnms+HSu2AYi7186j0QSdobMxNuBzAKqzDRfunSljG1IL1im8 + bnZoghgIpHHfEKY5+b5hStqgBQoPTh2CJMAGWNDkdvoFK3m606RRRiKIOP69M4v5a1UjBBoyP7QO + AUNbgcjHWQCl83a1ofW2LKSDPcyfj2TVK4yjQlPAH3Q0TJgeig1beio7Ahnz581H22kkyxPtMB56 + YVA2cG7FvBB40HLmuX1YKu4TAwJ8ZJdCOnt2xZ9lYLb8LuduCFrd6RZH/44lUu/DpnM1lN3Q3oXe + moOQCtyUsI77tu7rzy0vgzZME4v0j3S57CYHJktZGvk/3uFHTWhStYlWNk+Ks/rCfkQjSmQV1u+T + ozHF7td0rxpD0b8534vjwABr/NwpjlSLIgBvt9d0nBtipYYOumSdXpCj9mB6uZbGVq9K0mdusbY= +DKIM-Signature: v=1; a=rsa-sha1; c=relaxed/relaxed; s=sectionalism; + d=rheology.yeloweditions.com; + h=To:Subject:Message-ID:Date:From:Reply-To:MIME-Version:List-Unsubscribe: + Content-Type:Content-Transfer-Encoding; + bh=BwKu50FuSdWv9iToeKQHRPlPqN0=; + b=UT4zz+mBbtBTs9S/YEiEH7B3N9gklIJBZLKAyaCOvddh0wWb33fT3qyqCKo9Vfyhp+gBHlQ/pfvZ + xLRSO8SjfAOLs/0Xs0sy5uVLuNXh5UfdT+IwG5KSgA+NpRvOTmHvfUzFP5DoiykHP0eutX2TxkRh + dJHjLIthv+EISFEubGu1ZgFUBReFTCvhZCrYPYJlxze/tmWWChRK7r804EBR3gJiLEvgXQho5Qh5 + ksdnvvqAAVTHNOWAFbxoojBmNCs/gn0BbXHeJGuR/ZRXUbC1ZzQ4hw/9xNSCHI9n24IoD+vfhldb + k2hSVGQisRxn9vTISgDaNjEgFdr+0hUTqSTvrVEDdJYjCmYK/HrJrb8oeh+G/mz30pYQJlJUYIuJ + VcGkqXdFQdenNJPPD1UccGCpWblaaSlqY2AVk3mjX08Du2jbuzEFnX2g5UA5okGOmUi3h5C1v3vG + zLMuOT3686ZovcbxlW+K1GkOL1IZTkgXiw2X4EDZ6nE191uyVpIi3NTBkRIIWsni2lpDfg9+qq/J + /viVYHHmyG7xpB+gmS9r46HZqZYcYA9g5NQuTaYPIzngwlODdoSVsDb9X7/aU2Mhvp+ayq5g5/fa + 4cbe7mAJMaCifxx5YA2KqkYLqRS3Cm0D2AJGqk9DpkpncHL+g8+jOAtoWCPFa5L+yVnQIexiJqE= +DomainKey-Signature: a=rsa-sha1; c=nofws; q=dns; s=sectionalism; d=grupokonecta.net; + b=k7FzASi0Vd8+owLAAzg7gG2fHkmp7q+kS7fNXMORbpbqrny/kgSjEkfLUDJdmPFyY2PlDQ+/dZcs + W6kMg8n/+wKKEMenIz1O0RSCbKoV5N2gchXV1nEevqyZ+Ndf4XwLhHY+Ttlh6dnlMNblWnXZcxjs + 2bvwWSAVakpbSIbRkYeMHJOfQTw7efqEEhKRqXCWHnHJuC9zkf4C3bUe6WcTjBmlCe12dU+HD18K + lkc7TJKwIm81vBgE+HXpVC3YuJsfsNLM4bb/bZmt3yu5SgfEWss6DgOsOa2yNzLWMx6byVhx3zvg + u9k+c+seOdotD0Rphz6F2GaW3LJwOz+Uuhntwir3FZ45GEeyn2V0jiXwVpg8L2RREs3bqCUm+THD + 8p0gSDNbB3SExCuvRmRnvPCogYvO9XGbwX6eh4gl9dGMrmVSxpjnIoJjoAF/AZSPV2FGBhwFWzjr + P+lD8QOzpAbKXSArKcDmJU6ehafQW1fknB1colA97NVan2HCj6B1YdaKv7BtPPjh+L1hwVd3mFaD + Kp4/hGJnW/UE12ZUaLIFVsioZWf6TcNvFaZQzTlL8U5fShX8iQInbAWh9+c0YGGLzcGcjqUV3BCA + ZXLFzE9yrfkpMDPInBqLOXbIiWzIUopFoCPHuEyEDpalYQQx3rXbUKsNnGs8xEipZueJWmdhaUc=; +To: joe@domain.org +Subject: =?UTF-8?B?UGVkw60gb25saW5lIHR1IFRhcmpldGEgQkJWQSAxMDAlIEJPTklGSUNBREEu?= +Message-ID: <61805635a4a8f8915503cb518cbbaeecc82ce04e861805635@grupokonecta.net> +Return-Path: noreply@grupokonecta.net +Date: Tue, 10 Oct 2023 11:52:03 +0000 +From: "BBVA" +Reply-To: noreply@grupokonecta.net +MIME-Version: 1.0 +List-Unsubscribe: , +Content-Type: multipart/alternative; charset="UTF-8"; boundary="b1_3d217f30a568faa9ce3dd7dc73399561" +Content-Transfer-Encoding: quoted-printable + +--b1_3d217f30a568faa9ce3dd7dc73399561 +Content-Type: text/plain; format=flowed; charset="UTF-8" +Content-Transfer-Encoding: quoted-printable + +Tarjeta de cr=C3=A9dito BBVA + +La tarjeta de cr=C3=A9dito para viajar con tus consumos +Pedila 100% online y empez=C3=A1 a disfrutar + +Conocer oferta +Un mundo de beneficios con las tarjetas de cr=C3=A9dito BBVA +compras en cuotas +Compras en cuotas +Pod=C3=A9s disfrutar hoy de los productos =E2=80=A8que quer=C3=A9s y pagarl= +os en cuotas +descuentos y reintegros +Descuentos y reintegros +Entretenimiento, gastronom=C3=ADa, farmacia, ropa =E2=80=A8y m=C3=A1s rubro= +s con promociones exclusivas +puntos bbva +Viajes con Puntos BBVA +Vuelos, alojamientos y mucho m=C3=A1s canjeando Puntos BBVA que sum=C3= +=A1s con tus compras +Conocer oferta +Descubr=C3=AD la tarjeta que mejor se adapta a vos +Todas las tarjetas + +Black + +Platinum + +Gold + +Internacional + +Todas las tarjetas +visa black +Tarjeta Visa Signature +L=C3=ADmites desde $600.000 + +15% extra en acumulaci=C3=B3n de Puntos BBVA +Acceso a salas VIP en aeropuertos +Asistencia en viajes con cobertura de hasta 250.000 USD +Extracci=C3=B3n de efectivo en el exterior +Seguro de robo en cajero y compra protegida +Atenci=C3=B3n personalizada para resolver tus consultas +Tarjetas adicionales sin costo +Es necesario un ingreso m=C3=ADnimo mensual de $200.000 + + Conocer m=C3=A1s +mastercard black +Tarjeta Mastercard Black +L=C3=ADmites desde $600.000 + +15% extra en acumulaci=C3=B3n de Puntos BBVA +Acceso a salas VIP en aeropuertos +Asistencia en viajes con cobertura de hasta 250.000 USD +Extracci=C3=B3n de efectivo en el exterior +Seguro de robo en cajero y compra protegida +Atenci=C3=B3n personalizada para resolver tus consultas +Tarjetas adicionales sin costo +Es necesario un ingreso m=C3=ADnimo mensual de $200.000 + Conocer m=C3=A1s +tarjeta platinum visa +Tarjeta Visa Platinum +L=C3=ADmites desde $350.000 + +5% extra en acumulaci=C3=B3n de Puntos BBVA +Asistencia en viajes con cobertura de hasta 170.000 USD +Extracci=C3=B3n de efectivo en el exterior +Atenci=C3=B3n personalizada para resolver tus consultas +Tarjetas adicionales sin costo +Es necesario un ingreso m=C3=ADnimo mensual de $120.000 + + Conocer m=C3=A1s +tarjeta platinum mastercard +Tarjeta Mastercard Platinum +L=C3=ADmites desde $350.000 + +5% extra en acumulaci=C3=B3n de Puntos BBVA +Asistencia en viajes con cobertura de hasta 50.000 USD y 30.000 EUR +Extracci=C3=B3n de efectivo en el exterior +Atenci=C3=B3n personalizada para resolver tus consultas +Tarjetas adicionales sin costo +Es necesario un ingreso m=C3=ADnimo mensual de $120.000 + + Conocer m=C3=A1s +tarjeta gold visa +Tarjeta Visa Gold +L=C3=ADmites desde $100.000 + +Puntos BBVA para viajar +Extracci=C3=B3n de efectivo en el exterior +Tarjetas adicionales sin costo +Es necesario un ingreso m=C3=ADnimo mensual de $20.000 + + Conocer m=C3=A1s +tarjeta gold mastercard +Tarjeta Mastercard Gold +L=C3=ADmites desde $100.000 + +Puntos BBVA para viajar +Extracci=C3=B3n de efectivo en el exterior +Tarjetas adicionales sin costo +Es necesario un ingreso m=C3=ADnimo mensual de $35.000 + + Conocer m=C3=A1s + +Tarjeta Visa Internacional +L=C3=ADmites desde $10.000 + +Puntos BBVA para viajar +Extracci=C3=B3n de efectivo en el exterior +Tarjetas adicionales sin costo +Es necesario un ingreso m=C3=ADnimo mensual de $20.000 + + Conocer m=C3=A1s + +Tarjeta Mastercard Internacional +L=C3=ADmites desde $10.000 + +Puntos BBVA para viajar +Extracci=C3=B3n de efectivo en el exterior +Tarjetas adicionales sin costo +Es necesario un ingreso m=C3=ADnimo mensual de $20.000 + + Conocer m=C3=A1s + +--b1_3d217f30a568faa9ce3dd7dc73399561 +Content-Type: text/html; charset="UTF-8" +Content-Transfer-Encoding: quoted-printable + + + + + +
+ + + + +
3D"CLICK
+
+ + + +--b1_3d217f30a568faa9ce3dd7dc73399561-- + + diff --git a/tests/src/smtp/inbound/antispam.rs b/tests/src/smtp/inbound/antispam.rs index 5465ebc0..1af4d95a 100644 --- a/tests/src/smtp/inbound/antispam.rs +++ b/tests/src/smtp/inbound/antispam.rs @@ -57,14 +57,20 @@ token-insert = "INSERT INTO bayes_tokens (h1, h2, ws, wh) VALUES (?, ?, ?, ?) ON CONFLICT(h1, h2) DO UPDATE SET ws = ws + excluded.ws, wh = wh + excluded.wh" token-lookup = "SELECT ws, wh FROM bayes_tokens WHERE h1 = ? AND h2 = ?" -id-insert = "INSERT INTO id_timestamps (id, last_hit) VALUES (?, CURRENT_TIMESTAMP)" -id-lookup = "SELECT 1 FROM id_timestamps WHERE id = ?" -id-cleanup = "DELETE FROM id_timestamps WHERE (CURRENT_TIMESTAMP - last_hit) < ?" +id-insert = "INSERT INTO seen_ids (id, ttl) VALUES (?, datetime('now', ? || ' seconds'))" +id-lookup = "SELECT 1 FROM seen_ids WHERE id = ? AND ttl > CURRENT_TIMESTAMP" +id-cleanup = "DELETE FROM seen_ids WHERE ttl < CURRENT_TIMESTAMP" reputation-insert = "INSERT INTO reputation (token, score, count, last_hit) VALUES (?, ?, 1, CURRENT_TIMESTAMP) ON CONFLICT(token) DO UPDATE SET score = (count + 1) * (excluded.score + 0.98 * score) / (0.98 * count + 1), count = count + 1, last_hit = CURRENT_TIMESTAMP" reputation-lookup = "SELECT score, count FROM reputation WHERE token = ?" +[directory."default"] +type = "memory" + +[directory."default".lookup] +domains = ["local-domain.org"] + [directory."spam"] type = "memory" @@ -119,16 +125,13 @@ type = "glob" comment = '#' values = ["spamtrap@*"] -[directory."spam".lookup."options"] -type = "list" -values = ["AUTOLEARN_REPLIES"] +[directory."spam".lookup."scores"] +type = "map" +values = ["SPAM_TRAP discard"] [resolver] public-suffix = "file://%LIST_PATH%/public-suffix.dat" -[bayes] -min-learns = 10 - [sieve.scripts] "#; @@ -140,15 +143,15 @@ ws INTEGER, wh INTEGER, PRIMARY KEY (h1, h2) )", - "CREATE TABLE IF NOT EXISTS id_timestamps ( + "CREATE TABLE IF NOT EXISTS seen_ids ( id STRING NOT NULL PRIMARY KEY, - last_hit TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ttl DATETIME NOT NULL )", "CREATE TABLE IF NOT EXISTS reputation ( token STRING NOT NULL PRIMARY KEY, score FLOAT NOT NULL DEFAULT '0', count INT(11) NOT NULL DEFAULT '0', -last_hit TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +last_hit DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )", ]; @@ -156,7 +159,13 @@ last_hit TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP async fn antispam() { /*tracing::subscriber::set_global_default( tracing_subscriber::FmtSubscriber::builder() - .with_max_level(tracing::Level::TRACE) + .with_env_filter( + tracing_subscriber::EnvFilter::builder() + .parse( + "smtp=debug,imap=debug,jmap=debug,store=debug,utils=debug,directory=debug", + ) + .unwrap(), + ) .finish(), ) .unwrap();*/ @@ -206,9 +215,15 @@ async fn antispam() { .join("resources") .join("config") .join("sieve"); - let prelude = fs::read_to_string(base_path.join("prelude.sieve")).unwrap(); + let script_config = fs::read_to_string(base_path.join("config.sieve")).unwrap(); + let script_prelude = fs::read_to_string(base_path.join("prelude.sieve")).unwrap(); + let mut all_scripts = script_config.clone() + "\n" + script_prelude.as_str(); for test_name in tests { let mut script = fs::read_to_string(base_path.join(format!("{test_name}.sieve"))).unwrap(); + if !["reputation", "replies_out", "pyzor"].contains(&test_name) { + all_scripts = all_scripts + "\n" + script.as_str(); + } + if test_name == "reputation" { script = "let \"score\" \"env.score\";\n\n".to_string() + script.as_str() @@ -216,10 +231,17 @@ async fn antispam() { "\n\nif eval \"score != env.final_score\" ", "{let \"t.INVALID_SCORE\" \"score\";}\n" ); + } else if test_name == "bayes_classify" { + script = script.replace("200", "10"); } - config.push_str(&format!("{test_name} = '''{prelude}\n{script}\n'''\n")); + + config.push_str(&format!( + "{test_name} = '''{script_config}\n{script_prelude}\n{script}\n'''\n" + )); } + config.push_str(&format!("combined = '''{all_scripts}\n'''\n")); + // Parse config let config = Config::parse(&config).unwrap(); let mut ctx = ConfigContext::new(&[]); @@ -297,7 +319,10 @@ async fn antispam() { .join("smtp") .join("antispam"); let span = tracing::info_span!("sieve_antispam"); - for test_name in tests { + for &test_name in tests.iter().chain(&["combined"]) { + /*if test_name != "combined" { + continue; + }*/ println!("===== {test_name} ====="); let script = ctx.scripts.remove(test_name).unwrap();