diff options
Diffstat (limited to 'crates')
34 files changed, 1073 insertions, 216 deletions
diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 09e6274f..7fb7decf 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Stalwart Labs Ltd. <hello@stalw.art>"] license = "AGPL-3.0-only" repository = "https://github.com/stalwartlabs/cli" homepage = "https://github.com/stalwartlabs/cli" -version = "0.3.1" +version = "0.3.2" edition = "2021" readme = "README.md" resolver = "2" diff --git a/crates/directory/Cargo.toml b/crates/directory/Cargo.toml index 16b7336a..8db1ba83 100644 --- a/crates/directory/Cargo.toml +++ b/crates/directory/Cargo.toml @@ -30,6 +30,7 @@ sha1 = "0.10.5" sha2 = "0.10.6" md5 = "0.7.0" futures = "0.3" +regex = "1.7.0" [dev-dependencies] tokio = { version = "1.23", features = ["full"] } diff --git a/crates/directory/src/config.rs b/crates/directory/src/config.rs index c6833b6a..1d2885fa 100644 --- a/crates/directory/src/config.rs +++ b/crates/directory/src/config.rs @@ -22,6 +22,7 @@ */ use bb8::{ManageConnection, Pool}; +use regex::Regex; use std::{ fs::File, io::{BufRead, BufReader}, @@ -34,7 +35,7 @@ use ahash::{AHashMap, AHashSet}; use crate::{ imap::ImapDirectory, ldap::LdapDirectory, memory::MemoryDirectory, smtp::SmtpDirectory, - sql::SqlDirectory, DirectoryConfig, DirectoryOptions, Lookup, + sql::SqlDirectory, AddressMapping, DirectoryConfig, DirectoryOptions, Lookup, }; pub trait ConfigDirectory { @@ -141,8 +142,8 @@ impl DirectoryOptions { pub fn from_config(config: &Config, key: impl AsKey) -> utils::config::Result<Self> { let key = key.as_key(); Ok(DirectoryOptions { - catch_all: config.property_or_static((&key, "options.catch-all"), "false")?, - subaddressing: config.property_or_static((&key, "options.subaddressing"), "true")?, + catch_all: AddressMapping::from_config(config, (&key, "options.catch-all"))?, + subaddressing: AddressMapping::from_config(config, (&key, "options.subaddressing"))?, superuser_group: config .value("options.superuser-group") .unwrap_or("superusers") @@ -151,6 +152,35 @@ impl DirectoryOptions { } } +impl AddressMapping { + pub fn from_config(config: &Config, key: impl AsKey) -> utils::config::Result<Self> { + let key = key.as_key(); + if let Some(value) = config.value(key.as_str()) { + match value { + "true" => Ok(AddressMapping::Enable), + "false" => Ok(AddressMapping::Disable), + _ => Err(format!( + "Invalid value for address mapping {key:?}: {value:?}", + )), + } + } else if let Some(regex) = config.value((key.as_str(), "map")) { + Ok(AddressMapping::Custom { + regex: Regex::new(regex).map_err(|err| { + format!( + "Failed to compile regular expression {:?} for key {:?}: {}.", + regex, + (&key, "map").as_key(), + err + ) + })?, + mapping: config.property_require((key.as_str(), "to"))?, + }) + } else { + Ok(AddressMapping::Disable) + } + } +} + pub(crate) fn build_pool<M: ManageConnection>( config: &Config, prefix: &str, diff --git a/crates/directory/src/ldap/lookup.rs b/crates/directory/src/ldap/lookup.rs index e32b7a6b..75b7df8a 100644 --- a/crates/directory/src/ldap/lookup.rs +++ b/crates/directory/src/ldap/lookup.rs @@ -24,7 +24,7 @@ use ldap3::{ResultEntry, Scope, SearchEntry}; use mail_send::Credentials; -use crate::{to_catch_all_address, unwrap_subaddress, Directory, Principal, Type}; +use crate::{Directory, Principal, Type}; use super::{LdapDirectory, LdapMappings}; @@ -101,24 +101,23 @@ impl Directory for LdapDirectory { &self .mappings .filter_email - .build(unwrap_subaddress(address, self.opt.subaddressing).as_ref()), + .build(self.opt.subaddressing.to_subaddress(address).as_ref()), &self.mappings.attr_name, ) .await? .success() .map(|(rs, _res)| self.extract_names(rs))?; - if names.is_empty() && self.opt.catch_all { + if !names.is_empty() { + Ok(names) + } else if let Some(address) = self.opt.catch_all.to_catch_all(address) { self.pool .get() .await? .search( &self.mappings.base_dn, Scope::Subtree, - &self - .mappings - .filter_email - .build(&to_catch_all_address(address)), + &self.mappings.filter_email.build(address.as_ref()), &self.mappings.attr_name, ) .await? @@ -141,7 +140,7 @@ impl Directory for LdapDirectory { &self .mappings .filter_email - .build(unwrap_subaddress(address, self.opt.subaddressing).as_ref()), + .build(self.opt.subaddressing.to_subaddress(address).as_ref()), &self.mappings.attr_email_address, ) .await? @@ -149,25 +148,27 @@ impl Directory for LdapDirectory { .await { Ok(Some(_)) => Ok(true), - Ok(None) if self.opt.catch_all => self - .pool - .get() - .await? - .streaming_search( - &self.mappings.base_dn, - Scope::Subtree, - &self - .mappings - .filter_email - .build(&to_catch_all_address(address)), - &self.mappings.attr_email_address, - ) - .await? - .next() - .await - .map(|entry| entry.is_some()) - .map_err(|e| e.into()), - Ok(None) => Ok(false), + Ok(None) => { + if let Some(address) = self.opt.catch_all.to_catch_all(address) { + self.pool + .get() + .await? + .streaming_search( + &self.mappings.base_dn, + Scope::Subtree, + &self.mappings.filter_email.build(address.as_ref()), + &self.mappings.attr_email_address, + ) + .await? + .next() + .await + .map(|entry| entry.is_some()) + .map_err(|e| e.into()) + } else { + Ok(false) + } + } + Err(e) => Err(e.into()), } } @@ -183,7 +184,7 @@ impl Directory for LdapDirectory { &self .mappings .filter_verify - .build(unwrap_subaddress(address, self.opt.subaddressing).as_ref()), + .build(self.opt.subaddressing.to_subaddress(address).as_ref()), &self.mappings.attr_email_address, ) .await?; @@ -216,7 +217,7 @@ impl Directory for LdapDirectory { &self .mappings .filter_expand - .build(unwrap_subaddress(address, self.opt.subaddressing).as_ref()), + .build(self.opt.subaddressing.to_subaddress(address).as_ref()), &self.mappings.attr_email_address, ) .await?; diff --git a/crates/directory/src/lib.rs b/crates/directory/src/lib.rs index eaff7de7..5a72405b 100644 --- a/crates/directory/src/lib.rs +++ b/crates/directory/src/lib.rs @@ -28,6 +28,7 @@ use bb8::RunError; use imap::ImapError; use ldap3::LdapError; use mail_send::Credentials; +use utils::config::DynValue; pub mod cache; pub mod config; @@ -170,11 +171,22 @@ impl Type { #[derive(Debug, Default)] struct DirectoryOptions { - catch_all: bool, - subaddressing: bool, + catch_all: AddressMapping, + subaddressing: AddressMapping, superuser_group: String, } +#[derive(Debug, Default)] +pub enum AddressMapping { + Enable, + Custom { + regex: regex::Regex, + mapping: DynValue, + }, + #[default] + Disable, +} + #[derive(Default, Clone, Debug)] pub struct DirectoryConfig { pub directories: AHashMap<String, Arc<dyn Directory>>, @@ -289,23 +301,54 @@ impl DirectoryError { } } -#[inline(always)] -fn unwrap_subaddress(address: &str, allow_subaddessing: bool) -> Cow<'_, str> { - if allow_subaddessing { - if let Some((local_part, domain_part)) = address.rsplit_once('@') { - if let Some((local_part, _)) = local_part.split_once('+') { - return format!("{}@{}", local_part, domain_part).into(); +impl AddressMapping { + pub fn to_subaddress<'x, 'y: 'x>(&'x self, address: &'y str) -> Cow<'x, str> { + match self { + AddressMapping::Enable => { + if let Some((local_part, domain_part)) = address.rsplit_once('@') { + if let Some((local_part, _)) = local_part.split_once('+') { + return format!("{}@{}", local_part, domain_part).into(); + } + } + } + AddressMapping::Custom { regex, mapping } => { + let mut regex_capture = Vec::new(); + for captures in regex.captures_iter(address) { + for capture in captures.iter() { + regex_capture.push(capture.map_or("", |m| m.as_str()).to_string()); + } + } + + if !regex_capture.is_empty() { + return mapping.apply(regex_capture); + } } + AddressMapping::Disable => (), } - } - address.into() -} + address.into() + } -#[inline(always)] -fn to_catch_all_address(address: &str) -> String { - address - .rsplit_once('@') - .map(|(_, domain_part)| format!("@{}", domain_part)) - .unwrap_or_else(|| address.into()) + pub fn to_catch_all<'x, 'y: 'x>(&'x self, address: &'y str) -> Option<Cow<'x, str>> { + match self { + AddressMapping::Enable => address + .rsplit_once('@') + .map(|(_, domain_part)| format!("@{}", domain_part)) + .map(Cow::Owned), + AddressMapping::Custom { regex, mapping } => { + let mut regex_capture = Vec::new(); + for captures in regex.captures_iter(address) { + for capture in captures.iter() { + regex_capture.push(capture.map_or("", |m| m.as_str()).to_string()); + } + } + if !regex_capture.is_empty() { + Some(mapping.apply(regex_capture)) + } else { + None + } + } + AddressMapping::Disable => None, + } + } } diff --git a/crates/directory/src/memory/lookup.rs b/crates/directory/src/memory/lookup.rs index 252e2ddf..82e46a61 100644 --- a/crates/directory/src/memory/lookup.rs +++ b/crates/directory/src/memory/lookup.rs @@ -23,7 +23,7 @@ use mail_send::Credentials; -use crate::{to_catch_all_address, unwrap_subaddress, Directory, DirectoryError, Principal}; +use crate::{Directory, DirectoryError, Principal}; use super::{EmailType, MemoryDirectory}; @@ -67,13 +67,12 @@ impl Directory for MemoryDirectory { async fn names_by_email(&self, address: &str) -> crate::Result<Vec<String>> { Ok(self .emails_to_names - .get(unwrap_subaddress(address, self.opt.subaddressing).as_ref()) + .get(self.opt.subaddressing.to_subaddress(address).as_ref()) .or_else(|| { - if self.opt.catch_all { - self.emails_to_names.get(&to_catch_all_address(address)) - } else { - None - } + self.opt + .catch_all + .to_catch_all(address) + .and_then(|address| self.emails_to_names.get(address.as_ref())) }) .map(|names| { names @@ -91,13 +90,19 @@ impl Directory for MemoryDirectory { async fn rcpt(&self, address: &str) -> crate::Result<bool> { Ok(self .emails_to_names - .contains_key(unwrap_subaddress(address, self.opt.subaddressing).as_ref()) - || (self.opt.catch_all && self.domains.contains(&to_catch_all_address(address)))) + .contains_key(self.opt.subaddressing.to_subaddress(address).as_ref()) + || self + .opt + .catch_all + .to_catch_all(address) + .map_or(false, |address| { + self.emails_to_names.contains_key(address.as_ref()) + })) } async fn vrfy(&self, address: &str) -> crate::Result<Vec<String>> { let mut result = Vec::new(); - let address = unwrap_subaddress(address, self.opt.subaddressing); + let address = self.opt.subaddressing.to_subaddress(address); for (key, value) in &self.emails_to_names { if key.contains(address.as_ref()) && value.iter().any(|t| matches!(t, EmailType::Primary(_))) @@ -110,7 +115,7 @@ impl Directory for MemoryDirectory { async fn expn(&self, address: &str) -> crate::Result<Vec<String>> { let mut result = Vec::new(); - let address = unwrap_subaddress(address, self.opt.subaddressing); + let address = self.opt.subaddressing.to_subaddress(address); for (key, value) in &self.emails_to_names { if key == address.as_ref() { for item in value { diff --git a/crates/directory/src/sql/lookup.rs b/crates/directory/src/sql/lookup.rs index 20739db3..701498b5 100644 --- a/crates/directory/src/sql/lookup.rs +++ b/crates/directory/src/sql/lookup.rs @@ -25,7 +25,7 @@ use futures::TryStreamExt; use mail_send::Credentials; use sqlx::{any::AnyRow, Column, Row}; -use crate::{to_catch_all_address, unwrap_subaddress, Directory, Principal, Type}; +use crate::{Directory, Principal, Type}; use super::{SqlDirectory, SqlMappings}; @@ -91,49 +91,54 @@ impl Directory for SqlDirectory { } async fn names_by_email(&self, address: &str) -> crate::Result<Vec<String>> { - let result = sqlx::query_scalar::<_, String>(&self.mappings.query_recipients) - .bind(unwrap_subaddress(address, self.opt.subaddressing).as_ref()) + let ids = sqlx::query_scalar::<_, String>(&self.mappings.query_recipients) + .bind(self.opt.subaddressing.to_subaddress(address).as_ref()) .fetch(&self.pool) .try_collect::<Vec<_>>() - .await; - match result { - Ok(ids) if !ids.is_empty() => Ok(ids), - Ok(_) if self.opt.catch_all => { - sqlx::query_scalar::<_, String>(&self.mappings.query_recipients) - .bind(to_catch_all_address(address)) - .fetch(&self.pool) - .try_collect::<Vec<_>>() - .await - .map_err(Into::into) - } - Ok(_) => Ok(vec![]), - Err(err) => Err(err.into()), + .await?; + if !ids.is_empty() { + Ok(ids) + } else if let Some(address) = self.opt.catch_all.to_catch_all(address) { + sqlx::query_scalar::<_, String>(&self.mappings.query_recipients) + .bind(address.as_ref()) + .fetch(&self.pool) + .try_collect::<Vec<_>>() + .await + .map_err(Into::into) + } else { + Ok(ids) } } async fn rcpt(&self, address: &str) -> crate::Result<bool> { let result = sqlx::query(&self.mappings.query_recipients) - .bind(unwrap_subaddress(address, self.opt.subaddressing).as_ref()) + .bind(self.opt.subaddressing.to_subaddress(address).as_ref()) .fetch(&self.pool) .try_next() .await; match result { Ok(Some(_)) => Ok(true), - Ok(None) if self.opt.catch_all => sqlx::query(&self.mappings.query_recipients) - .bind(to_catch_all_address(address)) - .fetch(&self.pool) - .try_next() - .await - .map(|id| id.is_some()) - .map_err(Into::into), - Ok(None) => Ok(false), + Ok(None) => { + if let Some(address) = self.opt.catch_all.to_catch_all(address) { + sqlx::query(&self.mappings.query_recipients) + .bind(address.as_ref()) + .fetch(&self.pool) + .try_next() + .await + .map(|id| id.is_some()) + .map_err(Into::into) + } else { + Ok(false) + } + } + Err(err) => Err(err.into()), } } async fn vrfy(&self, address: &str) -> crate::Result<Vec<String>> { sqlx::query_scalar::<_, String>(&self.mappings.query_verify) - .bind(unwrap_subaddress(address, self.opt.subaddressing).as_ref()) + .bind(self.opt.subaddressing.to_subaddress(address).as_ref()) .fetch(&self.pool) .try_collect::<Vec<_>>() .await @@ -142,7 +147,7 @@ impl Directory for SqlDirectory { async fn expn(&self, address: &str) -> crate::Result<Vec<String>> { sqlx::query_scalar::<_, String>(&self.mappings.query_expand) - .bind(unwrap_subaddress(address, self.opt.subaddressing).as_ref()) + .bind(self.opt.subaddressing.to_subaddress(address).as_ref()) .fetch(&self.pool) .try_collect::<Vec<_>>() .await diff --git a/crates/imap/Cargo.toml b/crates/imap/Cargo.toml index 57c60ed0..9ed1f597 100644 --- a/crates/imap/Cargo.toml +++ b/crates/imap/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "imap" -version = "0.3.1" +version = "0.3.2" edition = "2021" resolver = "2" diff --git a/crates/install/Cargo.toml b/crates/install/Cargo.toml index 2fa24529..c32da5bb 100644 --- a/crates/install/Cargo.toml +++ b/crates/install/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Stalwart Labs Ltd. <hello@stalw.art>"] license = "AGPL-3.0-only" repository = "https://github.com/stalwartlabs/mail-server" homepage = "https://github.com/stalwartlabs/mail-server" -version = "0.3.1" +version = "0.3.2" edition = "2021" readme = "README.md" resolver = "2" diff --git a/crates/install/src/main.rs b/crates/install/src/main.rs index e2d91638..53fd66ad 100644 --- a/crates/install/src/main.rs +++ b/crates/install/src/main.rs @@ -445,7 +445,7 @@ fn main() -> std::io::Result<()> { } if args.docker { cfg_file = cfg_file - .replace("127.0.0.1:8686", "0.0.0.0:8686") + .replace("127.0.0.1:8080", "0.0.0.0:8080") .replace("[server.run-as]", "#[server.run-as]") .replace("user = \"stalwart-mail\"", "#user = \"stalwart-mail\"") .replace("group = \"stalwart-mail\"", "#group = \"stalwart-mail\""); diff --git a/crates/jmap/Cargo.toml b/crates/jmap/Cargo.toml index b1184d9b..f0f17049 100644 --- a/crates/jmap/Cargo.toml +++ b/crates/jmap/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jmap" -version = "0.3.1" +version = "0.3.2" edition = "2021" resolver = "2" diff --git a/crates/jmap/src/api/config.rs b/crates/jmap/src/api/config.rs index e826a206..8d60d7c4 100644 --- a/crates/jmap/src/api/config.rs +++ b/crates/jmap/src/api/config.rs @@ -105,8 +105,7 @@ impl crate::Config { .property("jmap.rate-limit.use-forwarded")? .unwrap_or(false), oauth_key: settings - .value("oauth.key") - .map(|k| k.into()) + .text_file_contents("oauth.key")? .unwrap_or_else(|| { thread_rng() .sample_iter(Alphanumeric) diff --git a/crates/jmap/src/sieve/ingest.rs b/crates/jmap/src/sieve/ingest.rs index ac2d1566..bc389ecd 100644 --- a/crates/jmap/src/sieve/ingest.rs +++ b/crates/jmap/src/sieve/ingest.rs @@ -381,7 +381,10 @@ impl JMAP { continue; } } - Event::ListContains { .. } | Event::Execute { .. } | Event::Notify { .. } => { + Event::ListContains { .. } + | Event::Execute { .. } + | Event::Notify { .. } + | Event::SetEnvelope { .. } => { // Not allowed input = false.into(); } @@ -393,8 +396,6 @@ impl JMAP { }); input = true.into(); } - #[allow(unreachable_patterns)] - _ => unreachable!(), }, #[cfg(feature = "test_mode")] diff --git a/crates/main/Cargo.toml b/crates/main/Cargo.toml index 17bf6292..505f910c 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.1" +version = "0.3.2" edition = "2021" resolver = "2" diff --git a/crates/smtp/Cargo.toml b/crates/smtp/Cargo.toml index 13403c74..9529ddff 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.1" +version = "0.3.2" edition = "2021" resolver = "2" diff --git a/crates/smtp/src/config/auth.rs b/crates/smtp/src/config/auth.rs index c0b224bf..8ff6b2c6 100644 --- a/crates/smtp/src/config/auth.rs +++ b/crates/smtp/src/config/auth.rs @@ -30,7 +30,7 @@ use mail_auth::{ use mail_parser::decoders::base64::base64_decode; use utils::config::{ utils::{AsKey, ParseValue}, - Config, + Config, DynValue, }; use super::{ @@ -69,7 +69,7 @@ impl ConfigAuth for Config { .parse_if_block("auth.dkim.verify", ctx, &envelope_sender_keys)? .unwrap_or_else(|| IfBlock::new(VerifyStrategy::Relaxed)), sign: self - .parse_if_block::<Vec<String>>("auth.dkim.sign", ctx, &envelope_sender_keys)? + .parse_if_block::<Vec<DynValue>>("auth.dkim.sign", ctx, &envelope_sender_keys)? .unwrap_or_default() .map_if_block(&ctx.signers, "auth.dkim.sign", "signature")?, }, @@ -78,7 +78,11 @@ impl ConfigAuth for Config { .parse_if_block("auth.arc.verify", ctx, &envelope_sender_keys)? .unwrap_or_else(|| IfBlock::new(VerifyStrategy::Relaxed)), seal: self - .parse_if_block::<Option<String>>("auth.arc.seal", ctx, &envelope_sender_keys)? + .parse_if_block::<Option<DynValue>>( + "auth.arc.seal", + ctx, + &envelope_sender_keys, + )? .unwrap_or_default() .map_if_block(&ctx.sealers, "auth.arc.seal", "signature")?, }, @@ -194,22 +198,43 @@ impl ConfigAuth for Config { (DkimSigner::RsaSha256(signer), ArcSealer::RsaSha256(sealer)) } Algorithm::Ed25519Sha256 => { - let public_key = - base64_decode(&self.file_contents(("signature", id, "public-key"))?) - .ok_or_else(|| { - format!( - "Failed to base64 decode public key for {}.", - ("signature", id, "public-key",).as_key(), - ) - })?; - let private_key = - base64_decode(&self.file_contents(("signature", id, "private-key"))?) - .ok_or_else(|| { - format!( - "Failed to base64 decode private key for {}.", - ("signature", id, "private-key",).as_key(), - ) + let mut public_key = vec![]; + let mut private_key = vec![]; + + for (key, key_bytes) in [ + (("signature", id, "public-key"), &mut public_key), + (("signature", id, "private-key"), &mut private_key), + ] { + let mut contents = self.file_contents(key)?.into_iter(); + let mut base64 = vec![]; + + 'outer: while let Some(ch) = contents.next() { + if !ch.is_ascii_whitespace() { + if ch == b'-' { + for ch in contents.by_ref() { + if ch == b'\n' { + break; + } + } + } else { + base64.push(ch); + } + + for ch in contents.by_ref() { + if ch == b'-' { + break 'outer; + } else if !ch.is_ascii_whitespace() { + base64.push(ch); + } + } + } + } + + *key_bytes = base64_decode(&base64).ok_or_else(|| { + format!("Failed to base64 decode key for {}.", key.as_key(),) })?; + } + let key = Ed25519Key::from_seed_and_public_key(&private_key, &public_key) .map_err(|err| { format!("Failed to build ED25519 key for signature {id:?}: {err}") diff --git a/crates/smtp/src/config/if_block.rs b/crates/smtp/src/config/if_block.rs index ab3856ed..2d01ea5c 100644 --- a/crates/smtp/src/config/if_block.rs +++ b/crates/smtp/src/config/if_block.rs @@ -25,10 +25,12 @@ use std::sync::Arc; use ahash::AHashMap; -use super::{condition::ConfigCondition, ConfigContext, EnvelopeKey, IfBlock, IfThen}; +use super::{ + condition::ConfigCondition, ConfigContext, EnvelopeKey, IfBlock, IfThen, MaybeDynValue, +}; use utils::config::{ utils::{AsKey, ParseValues}, - Config, + Config, DynValue, }; pub trait ConfigIf { @@ -187,6 +189,12 @@ impl<T: Default> IfBlock<Option<T>> { } } +impl<T> IfBlock<Option<T>> { + pub fn is_empty(&self) -> bool { + self.default.is_none() && self.if_then.is_empty() + } +} + impl IfBlock<Option<String>> { pub fn map_if_block<T: ?Sized>( self, @@ -229,6 +237,7 @@ impl IfBlock<Option<String>> { } } +/* impl IfBlock<Vec<String>> { pub fn map_if_block<T: ?Sized>( self, @@ -269,6 +278,104 @@ impl IfBlock<Vec<String>> { Ok(result) } } +*/ + +impl IfBlock<Vec<DynValue>> { + pub fn map_if_block<T: ?Sized>( + self, + map: &AHashMap<String, Arc<T>>, + key_name: &str, + object_name: &str, + ) -> super::Result<IfBlock<Vec<MaybeDynValue<T>>>> { + let mut if_then = Vec::with_capacity(self.if_then.len()); + for if_clause in self.if_then.into_iter() { + if_then.push(IfThen { + conditions: if_clause.conditions, + then: Self::map_value(map, if_clause.then, object_name, key_name)?, + }); + } + + Ok(IfBlock { + if_then, + default: Self::map_value(map, self.default, object_name, key_name)?, + }) + } + + fn map_value<T: ?Sized>( + map: &AHashMap<String, Arc<T>>, + values: Vec<DynValue>, + object_name: &str, + key_name: &str, + ) -> super::Result<Vec<MaybeDynValue<T>>> { + let mut result = Vec::with_capacity(values.len()); + for value in values { + if let DynValue::String(value) = &value { + if let Some(value) = map.get(value) { + result.push(MaybeDynValue::Static(value.clone())); + } else { + return Err(format!( + "Unable to find {object_name} {value:?} declared for {key_name:?}", + )); + } + } else { + result.push(MaybeDynValue::Dynamic { + eval: value, + items: map.clone(), + }); + } + } + Ok(result) + } +} + +impl IfBlock<Option<DynValue>> { + pub fn map_if_block<T: ?Sized>( + self, + map: &AHashMap<String, Arc<T>>, + key_name: impl AsKey, + object_name: &str, + ) -> super::Result<IfBlock<Option<MaybeDynValue<T>>>> { + let key_name = key_name.as_key(); + let mut if_then = Vec::with_capacity(self.if_then.len()); + for if_clause in self.if_then.into_iter() { + if_then.push(IfThen { + conditions: if_clause.conditions, + then: Self::map_value(map, if_clause.then, object_name, &key_name)?, + }); + } + + Ok(IfBlock { + if_then, + default: Self::map_value(map, self.default, object_name, &key_name)?, + }) + } + + fn map_value<T: ?Sized>( + map: &AHashMap<String, Arc<T>>, + value: Option<DynValue>, + object_name: &str, + key_name: &str, + ) -> super::Result<Option<MaybeDynValue<T>>> { + if let Some(value) = value { + if let DynValue::String(value) = &value { + if let Some(value) = map.get(value) { + Ok(Some(MaybeDynValue::Static(value.clone()))) + } else { + Err(format!( + "Unable to find {object_name} {value:?} declared for {key_name:?}", + )) + } + } else { + Ok(Some(MaybeDynValue::Dynamic { + eval: value, + items: map.clone(), + })) + } + } else { + Ok(None) + } + } +} impl<T> IfBlock<Vec<T>> { pub fn has_empty_list(&self) -> bool { diff --git a/crates/smtp/src/config/mod.rs b/crates/smtp/src/config/mod.rs index f9746d4f..0b149d98 100644 --- a/crates/smtp/src/config/mod.rs +++ b/crates/smtp/src/config/mod.rs @@ -50,7 +50,7 @@ use mail_send::Credentials; use regex::Regex; use sieve::Sieve; use smtp_proto::MtPriority; -use utils::config::{Rate, Server, ServerProtocol}; +use utils::config::{DynValue, Rate, Server, ServerProtocol}; use crate::inbound::milter; @@ -222,7 +222,7 @@ pub struct Extensions { } pub struct Auth { - pub directory: IfBlock<Option<Arc<dyn Directory>>>, + pub directory: IfBlock<Option<MaybeDynValue<dyn Directory>>>, pub mechanisms: IfBlock<u64>, pub require: IfBlock<bool>, pub errors_max: IfBlock<usize>, @@ -231,12 +231,14 @@ pub struct Auth { pub struct Mail { pub script: IfBlock<Option<Arc<Sieve>>>, + pub rewrite: IfBlock<Option<DynValue>>, } pub struct Rcpt { pub script: IfBlock<Option<Arc<Sieve>>>, pub relay: IfBlock<bool>, - pub directory: IfBlock<Option<Arc<dyn Directory>>>, + pub directory: IfBlock<Option<MaybeDynValue<dyn Directory>>>, + pub rewrite: IfBlock<Option<DynValue>>, // Errors pub errors_max: IfBlock<usize>, @@ -375,10 +377,19 @@ pub enum AddressMatch { Equals(String), } +#[derive(Clone)] +pub enum MaybeDynValue<T: ?Sized> { + Dynamic { + eval: DynValue, + items: AHashMap<String, Arc<T>>, + }, + Static(Arc<T>), +} + pub struct Dsn { pub name: IfBlock<String>, pub address: IfBlock<String>, - pub sign: IfBlock<Vec<Arc<DkimSigner>>>, + pub sign: IfBlock<Vec<MaybeDynValue<DkimSigner>>>, } pub struct AggregateReport { @@ -387,7 +398,7 @@ pub struct AggregateReport { pub org_name: IfBlock<Option<String>>, pub contact_info: IfBlock<Option<String>>, pub send: IfBlock<AggregateFrequency>, - pub sign: IfBlock<Vec<Arc<DkimSigner>>>, + pub sign: IfBlock<Vec<MaybeDynValue<DkimSigner>>>, pub max_size: IfBlock<usize>, } @@ -395,7 +406,7 @@ pub struct Report { pub name: IfBlock<String>, pub address: IfBlock<String>, pub subject: IfBlock<String>, - pub sign: IfBlock<Vec<Arc<DkimSigner>>>, + pub sign: IfBlock<Vec<MaybeDynValue<DkimSigner>>>, pub send: IfBlock<Option<Rate>>, } @@ -481,12 +492,12 @@ pub enum ArcSealer { pub struct DkimAuthConfig { pub verify: IfBlock<VerifyStrategy>, - pub sign: IfBlock<Vec<Arc<DkimSigner>>>, + pub sign: IfBlock<Vec<MaybeDynValue<DkimSigner>>>, } pub struct ArcAuthConfig { pub verify: IfBlock<VerifyStrategy>, - pub seal: IfBlock<Option<Arc<ArcSealer>>>, + pub seal: IfBlock<Option<MaybeDynValue<ArcSealer>>>, } pub struct SpfAuthConfig { diff --git a/crates/smtp/src/config/queue.rs b/crates/smtp/src/config/queue.rs index 60d7dfb8..ba9419de 100644 --- a/crates/smtp/src/config/queue.rs +++ b/crates/smtp/src/config/queue.rs @@ -34,7 +34,7 @@ use super::{ }; use utils::config::{ utils::{AsKey, ParseValue}, - Config, + Config, DynValue, }; pub trait ConfigQueue { @@ -189,7 +189,7 @@ impl ConfigQueue for Config { .parse_if_block("report.dsn.from-address", ctx, &sender_envelope_keys)? .unwrap_or_else(|| IfBlock::new(format!("MAILER-DAEMON@{default_hostname}"))), sign: self - .parse_if_block::<Vec<String>>("report.dsn.sign", ctx, &sender_envelope_keys)? + .parse_if_block::<Vec<DynValue>>("report.dsn.sign", ctx, &sender_envelope_keys)? .unwrap_or_default() .map_if_block(&ctx.signers, "report.dsn.sign", "signature")?, }, diff --git a/crates/smtp/src/config/report.rs b/crates/smtp/src/config/report.rs index 7ad93472..dce1b3e6 100644 --- a/crates/smtp/src/config/report.rs +++ b/crates/smtp/src/config/report.rs @@ -27,7 +27,7 @@ use super::{ }; use utils::config::{ utils::{AsKey, ParseValue}, - Config, + Config, DynValue, }; pub trait ConfigReport { @@ -120,7 +120,7 @@ impl ConfigReport for Config { .parse_if_block(("report", id, "subject"), ctx, available_keys)? .unwrap_or_else(|| IfBlock::new(format!("{} Report", id.to_ascii_uppercase()))), sign: self - .parse_if_block::<Vec<String>>(("report", id, "sign"), ctx, available_keys)? + .parse_if_block::<Vec<DynValue>>(("report", id, "sign"), ctx, available_keys)? .unwrap_or_default() .map_if_block(&ctx.signers, &("report", id, "sign").as_key(), "signature")?, send: self @@ -173,7 +173,7 @@ impl ConfigReport for Config { .parse_if_block(("report", id, "aggregate.send"), ctx, available_keys)? .unwrap_or_default(), sign: self - .parse_if_block::<Vec<String>>( + .parse_if_block::<Vec<DynValue>>( ("report", id, "aggregate.sign"), ctx, &rcpt_envelope_keys, diff --git a/crates/smtp/src/config/session.rs b/crates/smtp/src/config/session.rs index 96041ada..6eb60eb4 100644 --- a/crates/smtp/src/config/session.rs +++ b/crates/smtp/src/config/session.rs @@ -28,7 +28,7 @@ use smtp_proto::*; use super::{if_block::ConfigIf, throttle::ConfigThrottle, *}; use utils::config::{ utils::{AsKey, ParseValue}, - Config, + Config, DynValue, }; pub trait ConfigSession { @@ -250,7 +250,7 @@ impl ConfigSession for Config { Ok(Auth { directory: self - .parse_if_block::<Option<String>>("session.auth.directory", ctx, &available_keys)? + .parse_if_block::<Option<DynValue>>("session.auth.directory", ctx, &available_keys)? .unwrap_or_default() .map_if_block( &ctx.directory.directories, @@ -296,6 +296,9 @@ impl ConfigSession for Config { .parse_if_block::<Option<String>>("session.mail.script", ctx, &available_keys)? .unwrap_or_default() .map_if_block(&ctx.scripts, "session.mail.script", "script")?, + rewrite: self + .parse_if_block::<Option<DynValue>>("session.mail.rewrite", ctx, &available_keys)? + .unwrap_or_default(), }) } @@ -318,7 +321,7 @@ impl ConfigSession for Config { .parse_if_block("session.rcpt.relay", ctx, &available_keys)? .unwrap_or_else(|| IfBlock::new(false)), directory: self - .parse_if_block::<Option<String>>("session.rcpt.directory", ctx, &available_keys)? + .parse_if_block::<Option<DynValue>>("session.rcpt.directory", ctx, &available_keys)? .unwrap_or_default() .map_if_block( &ctx.directory.directories, @@ -334,6 +337,9 @@ impl ConfigSession for Config { max_recipients: self .parse_if_block("session.rcpt.max-recipients", ctx, &available_keys)? .unwrap_or_else(|| IfBlock::new(100)), + rewrite: self + .parse_if_block::<Option<DynValue>>("session.rcpt.rewrite", ctx, &available_keys)? + .unwrap_or_default(), }) } diff --git a/crates/smtp/src/core/if_block.rs b/crates/smtp/src/core/if_block.rs index 738cb050..fbfaf03c 100644 --- a/crates/smtp/src/core/if_block.rs +++ b/crates/smtp/src/core/if_block.rs @@ -21,14 +21,26 @@ * for more details. */ -use std::net::{IpAddr, Ipv4Addr}; +use std::{ + borrow::Cow, + net::{IpAddr, Ipv4Addr}, + sync::Arc, +}; + +use utils::config::DynValue; use crate::config::{ - Condition, ConditionMatch, Conditions, EnvelopeKey, IfBlock, IpAddrMask, StringMatch, + Condition, ConditionMatch, Conditions, EnvelopeKey, IfBlock, IpAddrMask, MaybeDynValue, + StringMatch, }; use super::Envelope; +pub struct Captures<'x, T> { + value: &'x T, + captures: Vec<String>, +} + impl<T: Default> IfBlock<T> { pub async fn eval(&self, envelope: &impl Envelope) -> &T { for if_then in &self.if_then { @@ -39,6 +51,22 @@ impl<T: Default> IfBlock<T> { &self.default } + + pub async fn eval_and_capture(&self, envelope: &impl Envelope) -> Captures<'_, T> { + for if_then in &self.if_then { + if let Some(captures) = if_then.conditions.eval_and_capture(envelope).await { + return Captures { + value: &if_then.then, + captures, + }; + } + } + + Captures { + value: &self.default, + captures: vec![], + } + } } impl Conditions { @@ -116,6 +144,97 @@ impl Conditions { matched } + + pub async fn eval_and_capture(&self, envelope: &impl Envelope) -> Option<Vec<String>> { + let mut conditions = self.conditions.iter(); + let mut matched = false; + let mut last_capture = vec![]; + let mut regex_capture = vec![]; + + while let Some(rule) = conditions.next() { + match rule { + Condition::Match { key, value, not } => { + let ctx_value = envelope.key_to_string(key); + matched = match value { + ConditionMatch::String(value) => match value { + StringMatch::Equal(value) => value.eq(ctx_value.as_ref()), + StringMatch::StartsWith(value) => ctx_value.starts_with(value), + StringMatch::EndsWith(value) => ctx_value.ends_with(value), + }, + ConditionMatch::IpAddrMask(value) => value.matches(&match key { + EnvelopeKey::RemoteIp => envelope.remote_ip(), + EnvelopeKey::LocalIp => envelope.local_ip(), + _ => IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), + }), + ConditionMatch::UInt(value) => { + *value + == if key == &EnvelopeKey::Listener { + envelope.listener_id() + } else { + debug_assert!(false, "Invalid value for UInt context key."); + u16::MAX + } + } + ConditionMatch::Int(value) => { + *value + == if key == &EnvelopeKey::Listener { + envelope.priority() + } else { + debug_assert!(false, "Invalid value for UInt context key."); + i16::MAX + } + } + ConditionMatch::Lookup(lookup) => { + lookup.contains(ctx_value.as_ref()).await? + } + ConditionMatch::Regex(value) => { + regex_capture.clear(); + + for captures in value.captures_iter(ctx_value.as_ref()) { + for capture in captures.iter() { + regex_capture + .push(capture.map_or("", |m| m.as_str()).to_string()); + } + } + + !regex_capture.is_empty() + } + } ^ not; + + // Save last capture + if matched { + last_capture = if regex_capture.is_empty() { + vec![ctx_value.into_owned()] + } else { + std::mem::take(&mut regex_capture) + }; + } + } + Condition::JumpIfTrue { positions } => { + if matched { + //TODO use advance_by when stabilized + for _ in 0..*positions { + conditions.next(); + } + } + } + Condition::JumpIfFalse { positions } => { + if !matched { + //TODO use advance_by when stabilized + for _ in 0..*positions { + conditions.next(); + } + } + } + } + } + + if matched { + Some(last_capture) + } else { + None + } + } } impl IpAddrMask { @@ -168,3 +287,95 @@ impl IpAddrMask { } } } + +impl<'x> Captures<'x, DynValue> { + pub fn into_value(self) -> Cow<'x, str> { + self.value.apply(self.captures) + } +} + +impl<'x> Captures<'x, Option<DynValue>> { + pub fn into_value(self) -> Option<Cow<'x, str>> { + self.value.as_ref().map(|v| v.apply(self.captures)) + } +} + +impl<'x, T: ?Sized> Captures<'x, MaybeDynValue<T>> { + pub fn into_value(self) -> Option<Arc<T>> { + match &self.value { + MaybeDynValue::Dynamic { eval, items } => { + let r = eval.apply(self.captures); + + match items.get(r.as_ref()) { + Some(value) => value.clone().into(), + None => { + tracing::warn!( + context = "eval", + event = "error", + expression = ?eval, + result = ?r, + "Failed to resolve rule: value {r:?} not found in item list", + ); + None + } + } + } + MaybeDynValue::Static(value) => value.clone().into(), + } + } +} + +impl<'x, T: ?Sized> Captures<'x, Vec<MaybeDynValue<T>>> { + pub fn into_value(self) -> Vec<Arc<T>> { + let mut results = Vec::with_capacity(self.value.len()); + for value in self.value.iter() { + match value { + MaybeDynValue::Dynamic { eval, items } => { + let r = eval.apply_borrowed(&self.captures); + match items.get(r.as_ref()) { + Some(value) => { + results.push(value.clone()); + } + None => { + tracing::warn!( + context = "eval", + event = "error", + expression = ?eval, + result = ?r, + "Failed to resolve rule: value {r:?} not found in item list", + ); + } + } + } + MaybeDynValue::Static(value) => { + results.push(value.clone()); + } + } + } + results + } +} + +impl<'x, T: ?Sized> Captures<'x, Option<MaybeDynValue<T>>> { + pub fn into_value(self) -> Option<Arc<T>> { + match self.value.as_ref()? { + MaybeDynValue::Dynamic { eval, items } => { + let r = eval.apply(self.captures); + match items.get(r.as_ref()) { + Some(value) => value.clone().into(), + None => { + tracing::warn!( + context = "eval", + event = "error", + expression = ?eval, + result = ?r, + "Failed to resolve rule: value {r:?} not found in item list", + ); + None + } + } + } + MaybeDynValue::Static(value) => value.clone().into(), + } + } +} diff --git a/crates/smtp/src/core/mod.rs b/crates/smtp/src/core/mod.rs index 4d4d1fe5..36b959fc 100644 --- a/crates/smtp/src/core/mod.rs +++ b/crates/smtp/src/core/mod.rs @@ -230,13 +230,11 @@ pub struct SessionParameters { pub auth_errors_wait: Duration, // Rcpt parameters - pub rcpt_script: Option<Arc<Sieve>>, pub rcpt_relay: bool, pub rcpt_errors_max: usize, pub rcpt_errors_wait: Duration, pub rcpt_max: usize, pub rcpt_dsn: bool, - pub rcpt_directory: Option<Arc<dyn Directory>>, pub can_expn: bool, pub can_vrfy: bool, pub max_message_size: usize, @@ -463,13 +461,11 @@ impl Session<NullIo> { auth_require: Default::default(), auth_errors_max: Default::default(), auth_errors_wait: Default::default(), - rcpt_script: Default::default(), rcpt_relay: Default::default(), rcpt_errors_max: Default::default(), rcpt_errors_wait: Default::default(), rcpt_max: Default::default(), rcpt_dsn: Default::default(), - rcpt_directory: Default::default(), max_message_size: Default::default(), iprev: crate::config::VerifyStrategy::Disable, spf_ehlo: crate::config::VerifyStrategy::Disable, diff --git a/crates/smtp/src/core/params.rs b/crates/smtp/src/core/params.rs index c683e73a..2380adcc 100644 --- a/crates/smtp/src/core/params.rs +++ b/crates/smtp/src/core/params.rs @@ -44,7 +44,7 @@ impl<T: AsyncRead + AsyncWrite> Session<T> { // Auth parameters let ac = &self.core.session.config.auth; - self.params.auth_directory = ac.directory.eval(self).await.clone(); + self.params.auth_directory = ac.directory.eval_and_capture(self).await.into_value(); self.params.auth_require = *ac.require.eval(self).await; self.params.auth_errors_max = *ac.errors_max.eval(self).await; self.params.auth_errors_wait = *ac.errors_wait.eval(self).await; @@ -53,15 +53,6 @@ impl<T: AsyncRead + AsyncWrite> Session<T> { let ec = &self.core.session.config.extensions; self.params.can_expn = *ec.expn.eval(self).await; self.params.can_vrfy = *ec.vrfy.eval(self).await; - self.params.rcpt_directory = self - .core - .session - .config - .rcpt - .directory - .eval(self) - .await - .clone(); } pub async fn eval_post_auth_params(&mut self) { @@ -69,25 +60,14 @@ impl<T: AsyncRead + AsyncWrite> Session<T> { let ec = &self.core.session.config.extensions; self.params.can_expn = *ec.expn.eval(self).await; self.params.can_vrfy = *ec.vrfy.eval(self).await; - self.params.rcpt_directory = self - .core - .session - .config - .rcpt - .directory - .eval(self) - .await - .clone(); } pub async fn eval_rcpt_params(&mut self) { let rc = &self.core.session.config.rcpt; - self.params.rcpt_script = rc.script.eval(self).await.clone(); self.params.rcpt_relay = *rc.relay.eval(self).await; self.params.rcpt_errors_max = *rc.errors_max.eval(self).await; self.params.rcpt_errors_wait = *rc.errors_wait.eval(self).await; self.params.rcpt_max = *rc.max_recipients.eval(self).await; - self.params.rcpt_directory = rc.directory.eval(self).await.clone(); self.params.rcpt_dsn = *self.core.session.config.extensions.dsn.eval(self).await; self.params.max_message_size = *self diff --git a/crates/smtp/src/core/scripts.rs b/crates/smtp/src/core/scripts.rs index febd8988..c126417a 100644 --- a/crates/smtp/src/core/scripts.rs +++ b/crates/smtp/src/core/scripts.rs @@ -41,11 +41,16 @@ use tokio::{ use crate::queue::{DomainPart, InstantFromTimestamp, Message}; -use super::{Session, SMTP}; +use super::{Session, SessionAddress, SessionData, SMTP}; pub enum ScriptResult { - Accept, - Replace(Vec<u8>), + Accept { + modifications: Vec<(Envelope, String)>, + }, + Replace { + message: Vec<u8>, + modifications: Vec<(Envelope, String)>, + }, Reject(String), Discard, } @@ -108,7 +113,9 @@ impl<T: AsyncWrite + AsyncRead + Unpin> Session<T> { core.run_script_blocking(script, vars_env, envelope, message, handle, span) }) .await - .unwrap_or(ScriptResult::Accept) + .unwrap_or(ScriptResult::Accept { + modifications: vec![], + }) } } @@ -135,6 +142,7 @@ impl SMTP { let mut messages: Vec<Vec<u8>> = Vec::new(); let mut reject_reason = None; + let mut modifications = vec![]; let mut keep_id = usize::MAX; // Start event loop @@ -416,6 +424,10 @@ impl SMTP { messages.push(message); input = true.into(); } + Event::SetEnvelope { envelope, value } => { + modifications.push((envelope, value)); + input = true.into(); + } unsupported => { tracing::warn!( parent: &span, @@ -443,7 +455,7 @@ impl SMTP { // MAX - 1 = discard message if keep_id == 0 { - ScriptResult::Accept + ScriptResult::Accept { modifications } } else if let Some(mut reject_reason) = reject_reason { if !reject_reason.ends_with('\n') { reject_reason.push_str("\r\n"); @@ -459,13 +471,129 @@ impl SMTP { ScriptResult::Reject(format!("503 5.5.3 {reject_reason}")) } } else if keep_id != usize::MAX - 1 { - messages - .into_iter() - .nth(keep_id - 1) - .map(ScriptResult::Replace) - .unwrap_or(ScriptResult::Accept) + if let Some(message) = messages.into_iter().nth(keep_id - 1) { + ScriptResult::Replace { + message, + modifications, + } + } else { + ScriptResult::Accept { modifications } + } } else { ScriptResult::Discard } } } + +impl SessionData { + pub fn apply_sieve_modifications(&mut self, modifications: Vec<(Envelope, String)>) { + for (envelope, value) in modifications { + match envelope { + Envelope::From => { + let (address, address_lcase, domain) = if value.contains('@') { + let address_lcase = value.to_lowercase(); + let domain = address_lcase.domain_part().to_string(); + (value, address_lcase, domain) + } else if value.is_empty() { + (String::new(), String::new(), String::new()) + } else { + continue; + }; + if let Some(mail_from) = &mut self.mail_from { + mail_from.address = address; + mail_from.address_lcase = address_lcase; + mail_from.domain = domain; + } else { + self.mail_from = SessionAddress { + address, + address_lcase, + domain, + flags: 0, + dsn_info: None, + } + .into(); + } + } + Envelope::To => { + if value.contains('@') { + let address_lcase = value.to_lowercase(); + let domain = address_lcase.domain_part().to_string(); + if let Some(rcpt_to) = self.rcpt_to.last_mut() { + rcpt_to.address = value; + rcpt_to.address_lcase = address_lcase; + rcpt_to.domain = domain; + } else { + self.rcpt_to.push(SessionAddress { + address: value, + address_lcase, + domain, + flags: 0, + dsn_info: None, + }); + } + } + } + Envelope::ByMode => { + if let Some(mail_from) = &mut self.mail_from { + mail_from.flags &= !(MAIL_BY_NOTIFY | MAIL_BY_RETURN); + if value == "N" { + mail_from.flags |= MAIL_BY_NOTIFY; + } else if value == "R" { + mail_from.flags |= MAIL_BY_RETURN; + } + } + } + Envelope::ByTrace => { + if let Some(mail_from) = &mut self.mail_from { + if value == "T" { + mail_from.flags |= MAIL_BY_TRACE; + } else { + mail_from.flags &= !MAIL_BY_TRACE; + } + } + } + Envelope::Notify => { + if let Some(rcpt_to) = self.rcpt_to.last_mut() { + rcpt_to.flags &= !(RCPT_NOTIFY_DELAY + | RCPT_NOTIFY_FAILURE + | RCPT_NOTIFY_SUCCESS + | RCPT_NOTIFY_NEVER); + if value == "NEVER" { + rcpt_to.flags |= RCPT_NOTIFY_NEVER; + } else { + for value in value.split(',') { + match value.trim() { + "SUCCESS" => rcpt_to.flags |= RCPT_NOTIFY_SUCCESS, + "FAILURE" => rcpt_to.flags |= RCPT_NOTIFY_FAILURE, + "DELAY" => rcpt_to.flags |= RCPT_NOTIFY_DELAY, + _ => (), + } + } + } + } + } + Envelope::Ret => { + if let Some(mail_from) = &mut self.mail_from { + mail_from.flags &= !(MAIL_RET_FULL | MAIL_RET_HDRS); + if value == "FULL" { + mail_from.flags |= MAIL_RET_FULL; + } else if value == "HDRS" { + mail_from.flags |= MAIL_RET_HDRS; + } + } + } + Envelope::Orcpt => { + if let Some(rcpt_to) = self.rcpt_to.last_mut() { + rcpt_to.dsn_info = value.into(); + } + } + Envelope::Envid => { + if let Some(mail_from) = &mut self.mail_from { + mail_from.dsn_info = value.into(); + } + } + Envelope::ByTimeAbsolute | Envelope::ByTimeRelative => (), + } + } + } +} diff --git a/crates/smtp/src/inbound/data.rs b/crates/smtp/src/inbound/data.rs index 1ec1d8ec..0d94a50a 100644 --- a/crates/smtp/src/inbound/data.rs +++ b/crates/smtp/src/inbound/data.rs @@ -145,7 +145,7 @@ impl<T: AsyncWrite + AsyncRead + IsTls + Unpin> Session<T> { // Verify ARC let arc = *ac.arc.verify.eval(self).await; - let arc_sealer = ac.arc.seal.eval(self).await; + let arc_sealer = ac.arc.seal.eval_and_capture(self).await.into_value(); let arc_output = if arc.verify() || arc_sealer.is_some() { let arc_output = self.core.resolvers.dns.verify_arc(&auth_message).await; @@ -302,7 +302,7 @@ impl<T: AsyncWrite + AsyncRead + IsTls + Unpin> Session<T> { "Milter filter(s) accepted message."); self.data - .apply_modifications(modifications, &auth_message) + .apply_milter_modifications(modifications, &auth_message) .map(Arc::new) } Err(response) => return response, @@ -404,9 +404,19 @@ impl<T: AsyncWrite + AsyncRead + IsTls + Unpin> Session<T> { ) .await { - ScriptResult::Accept => (), - ScriptResult::Replace(new_message) => { - edited_message = Arc::new(new_message).into(); + ScriptResult::Accept { modifications } => { + if !modifications.is_empty() { + self.data.apply_sieve_modifications(modifications) + } + } + ScriptResult::Replace { + message, + modifications, + } => { + if !modifications.is_empty() { + self.data.apply_sieve_modifications(modifications) + } + edited_message = Arc::new(message).into(); } ScriptResult::Reject(message) => { tracing::debug!(parent: &self.span, @@ -492,7 +502,7 @@ impl<T: AsyncWrite + AsyncRead + IsTls + Unpin> Session<T> { // DKIM sign let raw_message = edited_message.unwrap_or(raw_message); - for signer in ac.dkim.sign.eval(self).await.iter() { + for signer in ac.dkim.sign.eval_and_capture(self).await.into_value() { match signer.sign_chained(&[headers.as_ref(), &raw_message]) { Ok(signature) => { signature.write_header(&mut headers); diff --git a/crates/smtp/src/inbound/mail.rs b/crates/smtp/src/inbound/mail.rs index e275e7f7..b0b0a5c5 100644 --- a/crates/smtp/src/inbound/mail.rs +++ b/crates/smtp/src/inbound/mail.rs @@ -139,14 +139,50 @@ impl<T: AsyncWrite + AsyncRead + Unpin + IsTls> Session<T> { // Sieve filtering if let Some(script) = self.core.session.config.mail.script.eval(self).await { - if let ScriptResult::Reject(message) = self.run_script(script.clone(), None).await { - tracing::debug!(parent: &self.span, - context = "mail-from", - event = "sieve-reject", + match self.run_script(script.clone(), None).await { + ScriptResult::Accept { modifications } => { + if !modifications.is_empty() { + tracing::debug!(parent: &self.span, + context = "sieve", + event = "modify", + address = &self.data.mail_from.as_ref().unwrap().address, + modifications = ?modifications); + self.data.apply_sieve_modifications(modifications) + } + } + ScriptResult::Reject(message) => { + tracing::debug!(parent: &self.span, + context = "sieve", + event = "reject", address = &self.data.mail_from.as_ref().unwrap().address, reason = message); - self.data.mail_from = None; - return self.write(message.as_bytes()).await; + self.data.mail_from = None; + return self.write(message.as_bytes()).await; + } + _ => (), + } + } + + // Address rewriting + if let Some(new_address) = self + .core + .session + .config + .mail + .rewrite + .eval_and_capture(self) + .await + .into_value() + { + let mut mail_from = self.data.mail_from.as_mut().unwrap(); + if new_address.contains('@') { + mail_from.address_lcase = new_address.to_lowercase(); + mail_from.domain = mail_from.address_lcase.domain_part().to_string(); + mail_from.address = new_address.into_owned(); + } else if new_address.is_empty() { + mail_from.address_lcase.clear(); + mail_from.domain.clear(); + mail_from.address.clear(); } } diff --git a/crates/smtp/src/inbound/milter/message.rs b/crates/smtp/src/inbound/milter/message.rs index eccb16b5..3792d605 100644 --- a/crates/smtp/src/inbound/milter/message.rs +++ b/crates/smtp/src/inbound/milter/message.rs @@ -239,7 +239,7 @@ impl<T: AsyncWrite + AsyncRead + IsTls + Unpin> Session<T> { } impl SessionData { - pub fn apply_modifications( + pub fn apply_milter_modifications( &mut self, modifications: Vec<Modification>, message: &AuthenticatedMessage<'_>, diff --git a/crates/smtp/src/inbound/rcpt.rs b/crates/smtp/src/inbound/rcpt.rs index 7ab3883f..84002c57 100644 --- a/crates/smtp/src/inbound/rcpt.rs +++ b/crates/smtp/src/inbound/rcpt.rs @@ -70,8 +70,87 @@ impl<T: AsyncWrite + AsyncRead + Unpin> Session<T> { dsn_info: to.orcpt, }; + if self.data.rcpt_to.contains(&rcpt) { + return self.write(b"250 2.1.5 OK\r\n").await; + } + self.data.rcpt_to.push(rcpt); + + // Address rewriting and Sieve filtering + let rcpt_script = self + .core + .session + .config + .rcpt + .script + .eval(self) + .await + .clone(); + if rcpt_script.is_some() || !self.core.session.config.rcpt.rewrite.is_empty() { + // Sieve filtering + if let Some(script) = rcpt_script { + match self.run_script(script.clone(), None).await { + ScriptResult::Accept { modifications } => { + if !modifications.is_empty() { + tracing::debug!(parent: &self.span, + context = "sieve", + event = "modify", + address = self.data.rcpt_to.last().unwrap().address, + modifications = ?modifications); + self.data.apply_sieve_modifications(modifications); + } + } + ScriptResult::Reject(message) => { + tracing::debug!(parent: &self.span, + context = "sieve", + event = "reject", + address = self.data.rcpt_to.last().unwrap().address, + reason = message); + self.data.rcpt_to.pop(); + return self.write(message.as_bytes()).await; + } + _ => (), + } + } + + // Address rewriting + if let Some(new_address) = self + .core + .session + .config + .rcpt + .rewrite + .eval_and_capture(self) + .await + .into_value() + { + let mut rcpt = self.data.rcpt_to.last_mut().unwrap(); + if new_address.contains('@') { + rcpt.address_lcase = new_address.to_lowercase(); + rcpt.domain = rcpt.address_lcase.domain_part().to_string(); + rcpt.address = new_address.into_owned(); + } + } + + // Check for duplicates + let rcpt = self.data.rcpt_to.last().unwrap(); + if self.data.rcpt_to.iter().filter(|r| r == &rcpt).count() > 1 { + self.data.rcpt_to.pop(); + return self.write(b"250 2.1.5 OK\r\n").await; + } + } + // Verify address - if let Some(directory) = &self.params.rcpt_directory { + let rcpt = self.data.rcpt_to.last().unwrap(); + if let Some(directory) = self + .core + .session + .config + .rcpt + .directory + .eval_and_capture(self) + .await + .into_value() + { if let Ok(is_local_domain) = directory.is_local_domain(&rcpt.domain).await { if is_local_domain { if let Ok(is_local_address) = directory.rcpt(&rcpt.address_lcase).await { @@ -81,6 +160,8 @@ impl<T: AsyncWrite + AsyncRead + Unpin> Session<T> { event = "error", address = &rcpt.address_lcase, "Mailbox does not exist."); + + self.data.rcpt_to.pop(); return self .rcpt_error(b"550 5.1.2 Mailbox does not exist.\r\n") .await; @@ -91,6 +172,8 @@ impl<T: AsyncWrite + AsyncRead + Unpin> Session<T> { event = "error", address = &rcpt.address_lcase, "Temporary address verification failure."); + + self.data.rcpt_to.pop(); return self .write(b"451 4.4.3 Unable to verify address at this time.\r\n") .await; @@ -101,6 +184,8 @@ impl<T: AsyncWrite + AsyncRead + Unpin> Session<T> { event = "error", address = &rcpt.address_lcase, "Relay not allowed."); + + self.data.rcpt_to.pop(); return self.rcpt_error(b"550 5.1.2 Relay not allowed.\r\n").await; } } else { @@ -110,6 +195,7 @@ impl<T: AsyncWrite + AsyncRead + Unpin> Session<T> { address = &rcpt.address_lcase, "Temporary address verification failure."); + self.data.rcpt_to.pop(); return self .write(b"451 4.4.3 Unable to verify address at this time.\r\n") .await; @@ -120,36 +206,21 @@ impl<T: AsyncWrite + AsyncRead + Unpin> Session<T> { event = "error", address = &rcpt.address_lcase, "Relay not allowed."); + + self.data.rcpt_to.pop(); return self.rcpt_error(b"550 5.1.2 Relay not allowed.\r\n").await; } - if !self.data.rcpt_to.contains(&rcpt) { - self.data.rcpt_to.push(rcpt); - - // Sieve filtering - if let Some(script) = &self.params.rcpt_script { - if let ScriptResult::Reject(message) = self.run_script(script.clone(), None).await { - tracing::debug!(parent: &self.span, - context = "rcpt", - event = "sieve-reject", - address = &self.data.rcpt_to.last().unwrap().address, - reason = message); - self.data.rcpt_to.pop(); - return self.write(message.as_bytes()).await; - } - } - - if self.is_allowed().await { - tracing::debug!(parent: &self.span, + if self.is_allowed().await { + tracing::debug!(parent: &self.span, context = "rcpt", event = "success", address = &self.data.rcpt_to.last().unwrap().address); - } else { - self.data.rcpt_to.pop(); - return self - .write(b"451 4.4.5 Rate limit exceeded, try again later.\r\n") - .await; - } + } else { + self.data.rcpt_to.pop(); + return self + .write(b"451 4.4.5 Rate limit exceeded, try again later.\r\n") + .await; } self.write(b"250 2.1.5 OK\r\n").await diff --git a/crates/smtp/src/inbound/vrfy.rs b/crates/smtp/src/inbound/vrfy.rs index ce266177..ffd171a0 100644 --- a/crates/smtp/src/inbound/vrfy.rs +++ b/crates/smtp/src/inbound/vrfy.rs @@ -29,7 +29,16 @@ use std::fmt::Write; impl<T: AsyncWrite + AsyncRead + Unpin> Session<T> { pub async fn handle_vrfy(&mut self, address: String) -> Result<(), ()> { - match &self.params.rcpt_directory { + match self + .core + .session + .config + .rcpt + .directory + .eval_and_capture(self) + .await + .into_value() + { Some(address_lookup) if self.params.can_vrfy => { match address_lookup.vrfy(&address.to_lowercase()).await { Ok(values) if !values.is_empty() => { @@ -81,7 +90,16 @@ impl<T: AsyncWrite + AsyncRead + Unpin> Session<T> { } pub async fn handle_expn(&mut self, address: String) -> Result<(), ()> { - match &self.params.rcpt_directory { + match self + .core + .session + .config + .rcpt + .directory + .eval_and_capture(self) + .await + .into_value() + { Some(address_lookup) if self.params.can_expn => { match address_lookup.expn(&address.to_lowercase()).await { Ok(values) if !values.is_empty() => { diff --git a/crates/smtp/src/reporting/mod.rs b/crates/smtp/src/reporting/mod.rs index 966c8ffb..d562a43a 100644 --- a/crates/smtp/src/reporting/mod.rs +++ b/crates/smtp/src/reporting/mod.rs @@ -36,7 +36,7 @@ use mail_parser::DateTime; use tokio::io::{AsyncRead, AsyncWrite}; use crate::{ - config::{AddressMatch, AggregateFrequency, DkimSigner, IfBlock}, + config::{AddressMatch, AggregateFrequency, DkimSigner, IfBlock, MaybeDynValue}, core::{management, Session, SMTP}, outbound::{dane::Tlsa, mta_sts::Policy}, queue::{DomainPart, Message}, @@ -129,7 +129,7 @@ impl SMTP { from_addr: &str, rcpts: impl Iterator<Item = impl AsRef<str>>, report: Vec<u8>, - sign_config: &IfBlock<Vec<Arc<DkimSigner>>>, + sign_config: &IfBlock<Vec<MaybeDynValue<DkimSigner>>>, span: &tracing::Span, deliver_now: bool, ) { @@ -178,11 +178,11 @@ impl SMTP { impl Message { pub async fn sign( &mut self, - config: &IfBlock<Vec<Arc<DkimSigner>>>, + config: &IfBlock<Vec<MaybeDynValue<DkimSigner>>>, bytes: &[u8], span: &tracing::Span, ) -> Option<Vec<u8>> { - let signers = config.eval(self).await; + let signers = config.eval_and_capture(self).await.into_value(); if !signers.is_empty() { let mut headers = Vec::with_capacity(64); for signer in signers.iter() { diff --git a/crates/utils/src/config/dynvalue.rs b/crates/utils/src/config/dynvalue.rs new file mode 100644 index 00000000..f1c473f1 --- /dev/null +++ b/crates/utils/src/config/dynvalue.rs @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2023 Stalwart Labs Ltd. + * + * This file is part of Stalwart Mail Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use std::borrow::Cow; + +use super::{ + utils::{AsKey, ParseValue}, + DynValue, +}; + +impl ParseValue for DynValue { + #[allow(clippy::while_let_on_iterator)] + fn parse_value(key: impl AsKey, value: &str) -> super::Result<Self> { + let mut items = vec![]; + let mut buf = vec![]; + let mut iter = value.as_bytes().iter().peekable(); + + while let Some(&ch) = iter.next() { + if ch == b'$' && matches!(iter.peek(), Some(b'{')) { + iter.next(); + if matches!(iter.peek(), Some(ch) if ch.is_ascii_digit()) { + if !buf.is_empty() { + items.push(DynValue::String(String::from_utf8(buf).unwrap())); + buf = vec![]; + } + + while let Some(&ch) = iter.next() { + if ch.is_ascii_digit() { + buf.push(ch); + } else if ch == b'}' && !buf.is_empty() { + let str_num = std::str::from_utf8(&buf).unwrap(); + items.push(DynValue::Position(str_num.parse().map_err(|_| { + format!( + "Failed to parse position {str_num:?} in value {value:?} for key {}", + key.as_key() + ) + })?)); + buf.clear(); + break; + } else { + return Err(format!( + "Invalid dynamic string {value:?} for key {}", + key.as_key() + )); + } + } + } else { + buf.push(b'$'); + buf.push(b'{'); + } + } else { + buf.push(ch); + } + } + + if !buf.is_empty() { + let item = DynValue::String(String::from_utf8(buf).unwrap()); + if !items.is_empty() { + items.push(item); + } else { + return Ok(item); + } + } + + Ok(match items.len() { + 0 => DynValue::String(String::new()), + 1 => items.pop().unwrap(), + _ => DynValue::List(items), + }) + } +} + +impl DynValue { + pub fn apply(&self, captures: Vec<String>) -> Cow<str> { + match self { + DynValue::String(value) => Cow::Borrowed(value.as_str()), + DynValue::Position(pos) => captures + .into_iter() + .nth(*pos) + .map(Cow::Owned) + .unwrap_or(Cow::Borrowed("")), + DynValue::List(items) => { + let mut result = String::new(); + + for item in items { + match item { + DynValue::String(value) => result.push_str(value), + DynValue::Position(pos) => { + if let Some(capture) = captures.get(*pos) { + result.push_str(capture); + } + } + DynValue::List(_) => unreachable!(), + } + } + + Cow::Owned(result) + } + } + } + + pub fn apply_borrowed<'x, 'y: 'x>(&'x self, captures: &'y [String]) -> Cow<'x, str> { + match self { + DynValue::String(value) => Cow::Borrowed(value.as_str()), + DynValue::Position(pos) => captures + .get(*pos) + .map(|v| Cow::Borrowed(v.as_str())) + .unwrap_or(Cow::Borrowed("")), + DynValue::List(items) => { + let mut result = String::new(); + + for item in items { + match item { + DynValue::String(value) => result.push_str(value), + DynValue::Position(pos) => { + if let Some(capture) = captures.get(*pos) { + result.push_str(capture); + } + } + DynValue::List(_) => unreachable!(), + } + } + + Cow::Owned(result) + } + } + } +} diff --git a/crates/utils/src/config/mod.rs b/crates/utils/src/config/mod.rs index e3ebe52e..d45cbc68 100644 --- a/crates/utils/src/config/mod.rs +++ b/crates/utils/src/config/mod.rs @@ -22,6 +22,7 @@ */ pub mod certificate; +pub mod dynvalue; pub mod listener; pub mod parser; pub mod utils; @@ -74,6 +75,13 @@ pub enum ServerProtocol { ManageSieve, } +#[derive(Debug, Clone)] +pub enum DynValue { + String(String), + Position(usize), + List(Vec<DynValue>), +} + #[derive(Debug, Default, PartialEq, Eq, Clone)] pub struct Rate { pub requests: u64, diff --git a/crates/utils/src/config/utils.rs b/crates/utils/src/config/utils.rs index 9bf7239d..ab95862e 100644 --- a/crates/utils/src/config/utils.rs +++ b/crates/utils/src/config/utils.rs @@ -177,6 +177,23 @@ impl Config { Err(format!("Property {key:?} not found in configuration file.")) } } + + pub fn text_file_contents(&self, key: impl AsKey) -> super::Result<Option<String>> { + let key = key.as_key(); + if let Some(value) = self.keys.get(&key) { + if let Some(value) = value.strip_prefix("file://") { + std::fs::read_to_string(value) + .map_err(|err| { + format!("Failed to read file {value:?} for property {key:?}: {err}") + }) + .map(Some) + } else { + Ok(Some(value.to_string())) + } + } else { + Ok(None) + } + } } pub trait ParseValues: Sized + Default { |