diff options
author | mdecimus <mauro@stalw.art> | 2023-07-27 20:18:34 +0200 |
---|---|---|
committer | mdecimus <mauro@stalw.art> | 2023-07-27 20:18:34 +0200 |
commit | 3cea77b65e2863fc9a8e5f6b492b81292dc22843 (patch) | |
tree | 132d08058163a509789d007e61cc98d36ba1dd49 | |
parent | 4f2f673baa65899c134d420d4a6375258a25f67c (diff) |
v0.3.2
58 files changed, 1697 insertions, 369 deletions
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 8d981279..6a033d93 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -23,7 +23,7 @@ body: id: problem-related attributes: label: Is your feature request related to a problem? - description: Wrote a clear and concise description of what the problem is. + description: Write a clear and concise description of what the problem is. placeholder: Tell us what the problem is! value: "I'm always frustrated when..." - type: checkboxes @@ -1,20 +1,51 @@ -stalwart-mail v0.3.1 -================================ -- Added: Milter filter support. Documentation is available [here](https://stalw.art/docs/smtp/filter/milter). -- Added: Match IP address type using /0 mask (#16). -- Fix: Support for OpenLDAP password hashing schemes between curly brackets (#8). -- Fix: Add CA certificates to Docker runtime (#5). - -stalwart-mail v0.3.0 -================================ +# Change Log + +All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). + +## [0.3.2] - 2023-07-28 + +### Added +- Sender and recipient address rewriting using regular expressions and sieve scripts. +- Subaddressing and catch-all addresses using regular expressions (#10). +- Dynamic variables in SMTP rules. + +### Changed +- Added CLI to Docker container (#19). + +### Fixed +- Workaround for a bug in `sqlx` that caused SQL time-outs (#15). +- Support for ED25519 certificates in PEM files (#20). +- Better handling of concurrent IMAP UID map modifications (#17). +- LDAP domain lookups from SMTP rules. + +## [0.3.1] - 2023-07-22 + +### Added +- Milter filter support. +- Match IP address type using /0 mask (#16). + +### Changed + +### Fixed +- Support for OpenLDAP password hashing schemes between curly brackets (#8). +- Add CA certificates to Docker runtime (#5). + +## [0.3.0] - 2023-07-16 + +### Added +- **LDAP** and **SQL** authentication. +- **subaddressing** and **catch-all** addresses. +- **S3-compatible** storage. + +### Changed - Merged the `stalwart-jmap`, `stalwart-imap` and `stalwart-smtp` repositories into `stalwart-mail`. -- Added support for **LDAP** and **SQL** authentication. -- Added support for **subaddressing** and **catch-all** addresses. -- Added support for **S3-compatible** storage. - Removed clustering module and replaced it with a **FoundationDB** backend option. - Integrated Stalwart SMTP into Stalwart JMAP. - Rewritten JMAP protocol parser. - Rewritten store backend. - Rewritten IMAP server to have direct access to the message store (no more IMAP proxy). - Replaced `actix` with `hyper`. + +### Fixed + @@ -697,9 +697,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.3.17" +version = "4.3.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0827b011f6f8ab38590295339817b0d26f344aa4932c3ced71b45b0c54b4a9" +checksum = "5fd304a20bff958a57f04c4e96a2e7594cc4490a0e809cbd48bb6437edaa452d" dependencies = [ "clap_builder", "clap_derive", @@ -708,9 +708,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.3.17" +version = "4.3.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9441b403be87be858db6a23edb493e7f694761acdc3343d5a0fcaafd304cbc9e" +checksum = "01c6a3f08f1fe5662a35cfe393aec09c4df95f60ee93b7556505260f75eee9e1" dependencies = [ "anstream", "anstyle", @@ -1040,6 +1040,7 @@ dependencies = [ "password-hash 0.5.0", "pbkdf2 0.12.2", "pwhash", + "regex", "rustls 0.21.5", "scrypt", "sha1", @@ -1150,9 +1151,9 @@ dependencies = [ [[package]] name = "either" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91" +checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" dependencies = [ "serde", ] @@ -1921,7 +1922,7 @@ dependencies = [ [[package]] name = "imap" -version = "0.3.1" +version = "0.3.2" dependencies = [ "ahash 0.8.3", "dashmap", @@ -2085,7 +2086,7 @@ dependencies = [ [[package]] name = "jmap" -version = "0.3.1" +version = "0.3.2" dependencies = [ "aes-gcm", "aes-gcm-siv", @@ -2281,9 +2282,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.9" +version = "1.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56ee889ecc9568871456d42f603d6a0ce59ff328d291063a45cbdf0036baf6db" +checksum = "d97137b25e321a73eef1418d1d5d2eda4d77e12813f8e6dead84bc52c5870a7b" dependencies = [ "cc", "pkg-config", @@ -2391,7 +2392,7 @@ dependencies = [ [[package]] name = "mail-server" -version = "0.3.1" +version = "0.3.2" dependencies = [ "directory", "imap", @@ -3296,9 +3297,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.31" +version = "1.0.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fe8a65d69dd0808184ebb5f836ab526bb259db23c657efa38711b1072ee47f0" +checksum = "50f3b39ccfb720540debaa0164757101c08ecb8d326b15358ce76a62c7e85965" dependencies = [ "proc-macro2", ] @@ -3523,9 +3524,9 @@ dependencies = [ [[package]] name = "roaring" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef0fb5e826a8bde011ecae6a8539dd333884335c57ff0f003fbe27c25bbe8f71" +checksum = "6106b5cf8587f5834158895e9715a3c6c9716c8aefab57f1f7680917191c7873" dependencies = [ "bytemuck", "byteorder", @@ -3705,7 +3706,7 @@ checksum = "79ea77c539259495ce8ca47f53e66ae0330a8819f67e23ac96ca02f50e7b7d36" dependencies = [ "log", "ring", - "rustls-webpki 0.101.1", + "rustls-webpki 0.101.2", "sct", ] @@ -3742,9 +3743,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.101.1" +version = "0.101.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15f36a6828982f422756984e47912a7a51dcbc2a197aa791158f8ca61cd8204e" +checksum = "513722fd73ad80a71f72b61009ea1b584bcfa1483ca93949c8f290298837fa59" dependencies = [ "ring", "untrusted", @@ -3833,9 +3834,9 @@ dependencies = [ [[package]] name = "security-framework" -version = "2.9.1" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc758eb7bffce5b308734e9b0c1468893cae9ff70ebf13e7090be8dcbcc83a8" +checksum = "05b64fb303737d99b81884b2c63433e9ae28abebe5eb5045dcdd175dc2ecf4de" dependencies = [ "bitflags 1.3.2", "core-foundation", @@ -3846,9 +3847,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.9.0" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f51d0c0d83bec45f16480d0ce0058397a69e48fcdc52d1dc8855fb68acbd31a7" +checksum = "e932934257d3b408ed8f30db49d85ea163bfe74961f017f405b025af298f0c7a" dependencies = [ "core-foundation-sys", "libc", @@ -3856,9 +3857,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.174" +version = "1.0.176" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b88756493a5bd5e5395d53baa70b194b05764ab85b59e43e4b8f4e1192fa9b1" +checksum = "76dc28c9523c5d70816e393136b86d48909cfb27cecaa902d338c19ed47164dc" dependencies = [ "serde_derive", ] @@ -3874,9 +3875,9 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.174" +version = "1.0.176" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e5c3a298c7f978e53536f95a63bdc4c4a64550582f31a0359a9afda6aede62e" +checksum = "a4e7b8c5dc823e3b90651ff1d3808419cd14e5ad76de04feaf37da114e7a306f" dependencies = [ "proc-macro2", "quote", @@ -3885,9 +3886,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d03b412469450d4404fe8499a268edd7f8b79fecb074b0d812ad64ca21f4031b" +checksum = "076066c5f1078eac5b722a31827a8832fe108bed65dfa75e233c89f8206e976c" dependencies = [ "itoa", "ryu", @@ -4003,7 +4004,7 @@ checksum = "43b2853a4d09f215c24cc5489c992ce46052d359b5109343cbafbf26bc62f8a3" [[package]] name = "sieve-rs" version = "0.3.1" -source = "git+https://github.com/stalwartlabs/sieve#0ab2dc8cd41ee5dadcc3ab5e932b9b92abc5e067" +source = "git+https://github.com/stalwartlabs/sieve#f9c01ba6947d73855fdd645b17c9a5d347724ee3" dependencies = [ "ahash 0.8.3", "bincode", @@ -4056,7 +4057,7 @@ checksum = "62bb4feee49fdd9f707ef802e22365a35de4b7b299de4763d44bfea899442ff9" [[package]] name = "smtp" -version = "0.3.1" +version = "0.3.2" dependencies = [ "ahash 0.8.3", "blake3", @@ -4356,7 +4357,7 @@ dependencies = [ [[package]] name = "stalwart-cli" -version = "0.3.1" +version = "0.3.2" dependencies = [ "clap", "console", @@ -4378,7 +4379,7 @@ dependencies = [ [[package]] name = "stalwart-install" -version = "0.3.1" +version = "0.3.2" dependencies = [ "base64 0.21.2", "clap", @@ -5319,7 +5320,7 @@ version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b291546d5d9d1eab74f069c77749f2cb8504a12caa20f0f2de93ddbf6f411888" dependencies = [ - "rustls-webpki 0.101.1", + "rustls-webpki 0.101.2", ] [[package]] @@ -17,6 +17,8 @@ RUN useradd stalwart-mail -s /sbin/nologin -M RUN mkdir -p /opt/stalwart-mail RUN chown stalwart-mail:stalwart-mail /opt/stalwart-mail -EXPOSE 8080 25 587 465 8686 143 993 4190 +VOLUME [ "/opt/stalwart-mail" ] + +EXPOSE 8080 25 587 465 143 993 4190 ENTRYPOINT ["/bin/sh", "/usr/local/bin/entrypoint.sh"] @@ -24,8 +24,9 @@ Key features: - **SMTP** server: - Built-in [DMARC](https://datatracker.ietf.org/doc/html/rfc7489), [DKIM](https://datatracker.ietf.org/doc/html/rfc6376), [SPF](https://datatracker.ietf.org/doc/html/rfc7208) and [ARC](https://datatracker.ietf.org/doc/html/rfc8617) support for message authentication. - Strong transport security through [DANE](https://datatracker.ietf.org/doc/html/rfc6698), [MTA-STS](https://datatracker.ietf.org/doc/html/rfc8461) and [SMTP TLS](https://datatracker.ietf.org/doc/html/rfc8460) reporting. - - Inbound throttling and filtering with granular configuration rules, __sieve__ scripting and __milter__ integration. + - Inbound throttling and filtering with granular configuration rules, sieve scripting and milter integration. - Virtual queues with delayed delivery, priority delivery, quotas, routing rules and throttling support. + - Envelope rewriting and message modification. - **Flexible**: - **LDAP** directory and **SQL** database authentication. - Full-text search available in 17 languages. 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 { diff --git a/resources/config/directory.toml b/resources/config/directory.toml index e6a0470f..2899204e 100644 --- a/resources/config/directory.toml +++ b/resources/config/directory.toml @@ -8,7 +8,9 @@ address = "sqlite://__PATH__/data/accounts.sqlite3?mode=rwc" [directory."sql".options] catch-all = true +#catch-all = { map = "(.+)@(.+)$", to = "info@${2}" } subaddressing = true +#subaddressing = { map = "^([^.]+)\.([^.]+)@(.+)$", to = "${2}@${3}" } superuser-group = "superusers" [directory."sql".pool] @@ -52,7 +54,9 @@ ttl = {positive = '1h', negative = '10m'} [directory."ldap".options] catch-all = true +#catch-all = { map = "(.+)@(.+)$", to = "info@${2}" } subaddressing = true +#subaddressing = { map = "^([^.]+)\.([^.]+)@(.+)$", to = "${2}@${3}" } superuser-group = "superusers" [directory."ldap".pool] @@ -137,7 +141,9 @@ type = "memory" [directory."memory".options] catch-all = true +#catch-all = { map = "(.+)@(.+)$", to = "info@${2}" } subaddressing = true +#subaddressing = { map = "^([^.]+)\.([^.]+)@(.+)$", to = "${2}@${3}" } superuser-group = "superusers" [[directory."memory".users]] diff --git a/resources/config/smtp.toml b/resources/config/smtp.toml index 6fb320e3..5cad996a 100644 --- a/resources/config/smtp.toml +++ b/resources/config/smtp.toml @@ -17,7 +17,7 @@ protocol = "smtp" tls.implicit = true [server.listener."management"] -bind = ["127.0.0.1:8686"] +bind = ["127.0.0.1:8080"] protocol = "http" [session] @@ -66,11 +66,19 @@ wait = "5s" [session.mail] #script = "mail-from" +#rewrite = [ { all-of = [ { if = "listener", ne = "smtp" }, +# { if = "rcpt", matches = "^([^.]+)@([^.]+)\.(.+)$"}, +# ], then = "${1}@${3}" }, +# { else = false } ] [session.rcpt] #script = "rcpt-to" relay = [ { if = "authenticated-as", ne = "", then = true }, { else = false } ] +#rewrite = [ { all-of = [ { if = "rcpt-domain", in-list = "__SMTP_DIRECTORY__/domains" }, +# { if = "rcpt", matches = "^([^.]+)\.([^.]+)@(.+)$"}, +# ], then = "${1}+${2}@${3}" }, +# { else = false } ] max-recipients = 25 directory = "__SMTP_DIRECTORY__" diff --git a/tests/resources/smtp/config/rules-dynvalue.toml b/tests/resources/smtp/config/rules-dynvalue.toml new file mode 100644 index 00000000..36951bac --- /dev/null +++ b/tests/resources/smtp/config/rules-dynvalue.toml @@ -0,0 +1,88 @@ + +[envelope] +rcpt-domain = "foo.example.org" +rcpt = "user@foo.example.org" +sender-domain = "foo.net" +sender = "bill@foo.net" +local-ip = "192.168.9.3" +remote-ip = "A:B:C::D:E" +mx = "mx.somedomain.com" +authenticated-as = "john@foobar.org" +priority = -4 +listener = 123 +helo-domain = "hi-domain.net" + +[eval."eq"] +test = [ + {if = "sender", eq = "bill@foo.net", then = "${0}"}, + {else = false} +] +expect = "bill@foo.net" + +[eval."starts-with"] +test = [ + {if = "rcpt-domain", starts-with = "foo", then = "${0}${{0}}"}, + {else = false} +] +expect = "foo.example.org${{0}}" + +[eval."regex"] +test = [ + {if = "rcpt", matches = "^([^.]+)@([^.]+)\.(.+)$", then = "${1}+${2}@${3}"}, + {else = false} +] +expect = "user+foo@example.org" + +[eval."regex-full"] +test = [ + {if = "rcpt", matches = "^([^.]+)@([^.]+)\.(.+)$", then = "${0}"}, + {else = false} +] +expect = "user@foo.example.org" + +[eval."static-match"] +test = [ + {if = "authenticated-as", matches = "^([^.]+)@(.+)$", then = "hello world"}, + {else = false} +] +expect = "hello world" + +[eval."no-match"] +test = [ + {if = "authenticated-as", matches = "^([^.]+)@([^.]+)\.(.+)$org", then = "${1}+${2}@${3}"}, + {else = false} +] +expect = false + +[directory."list_mx"] +type = "memory" +[directory."list_mx".lookup] +domains = ["mx"] + +[directory."list_foo"] +type = "memory" +[directory."list_foo".lookup] +domains = ["foo"] + +[maybe-eval."dyn_mx"] +test = [ + {if = "mx", matches = "([^.]+)\.(.+)$", then = "list_${1}"}, + {else = false} +] +expect = "mx" + +[maybe-eval."dyn_foo"] +test = [ + {if = "sender-domain", matches = "([^.]+)\.(.+)$", then = "list_${1}"}, + {else = false} +] +expect = "foo" + +[maybe-eval."static_mx"] +test = "list_mx" +expect = "mx" + +[maybe-eval."static_foo"] +test = "list_foo" +expect = "foo" + diff --git a/tests/src/directory/mod.rs b/tests/src/directory/mod.rs index 59686cad..f9b925a1 100644 --- a/tests/src/directory/mod.rs +++ b/tests/src/directory/mod.rs @@ -26,11 +26,11 @@ pub mod ldap; pub mod smtp; pub mod sql; -use directory::{config::ConfigDirectory, DirectoryConfig}; +use directory::{config::ConfigDirectory, AddressMapping, DirectoryConfig}; use mail_send::Credentials; use rustls::{Certificate, PrivateKey, ServerConfig}; use rustls_pemfile::{certs, pkcs8_private_keys}; -use std::{io::BufReader, sync::Arc}; +use std::{borrow::Cow, io::BufReader, sync::Arc}; use tokio_rustls::TlsAcceptor; const CONFIG: &str = r#" @@ -389,3 +389,49 @@ impl core::fmt::Debug for Item { } } } + +#[test] +fn address_mappings() { + const MAPPINGS: &str = r#" + [enable] + catch-all = true + subaddressing = true + expected-sub = "john.doe@example.org" + expected-catch = "@example.org" + + [disable] + catch-all = false + subaddressing = false + expected-sub = "john.doe+alias@example.org" + expected-catch = false + + [custom] + catch-all = { map = "(.+)@(.+)$", to = "info@${2}" } + subaddressing = { map = "^([^.]+)\.([^.]+)@(.+)$", to = "${2}@${3}" } + expected-sub = "doe+alias@example.org" + expected-catch = "info@example.org" + "#; + + let config = utils::config::Config::parse(MAPPINGS).unwrap(); + const ADDR: &str = "john.doe+alias@example.org"; + + for test in ["enable", "disable", "custom"] { + let catch_all = AddressMapping::from_config(&config, (test, "catch-all")).unwrap(); + let subaddressing = AddressMapping::from_config(&config, (test, "subaddressing")).unwrap(); + + assert_eq!( + subaddressing.to_subaddress(ADDR), + config.value_require((test, "expected-sub")).unwrap(), + "failed subaddress for {test:?}" + ); + + assert_eq!( + catch_all.to_catch_all(ADDR), + config + .property_require::<Option<String>>((test, "expected-catch")) + .unwrap() + .map(Cow::Owned), + "failed catch-all for {test:?}" + ); + } +} diff --git a/tests/src/smtp/config.rs b/tests/src/smtp/config.rs index 46c029c9..a1b0b13d 100644 --- a/tests/src/smtp/config.rs +++ b/tests/src/smtp/config.rs @@ -21,11 +21,11 @@ * for more details. */ -use std::{fs, net::IpAddr, path::PathBuf, sync::Arc, time::Duration}; +use std::{borrow::Cow, fs, net::IpAddr, path::PathBuf, sync::Arc, time::Duration}; use tokio::net::TcpSocket; -use utils::config::{Config, Listener, Rate, Server, ServerProtocol}; +use utils::config::{Config, DynValue, Listener, Rate, Server, ServerProtocol}; use ahash::{AHashMap, AHashSet}; use directory::{config::ConfigDirectory, Lookup}; @@ -41,6 +41,20 @@ use smtp::{ use super::add_test_certs; +struct TestEnvelope { + pub local_ip: IpAddr, + pub remote_ip: IpAddr, + pub sender_domain: String, + pub sender: String, + pub rcpt_domain: String, + pub rcpt: String, + pub helo_domain: String, + pub authenticated_as: String, + pub mx: String, + pub listener_id: u16, + pub priority: i16, +} + #[test] fn parse_conditions() { let mut file = PathBuf::from(env!("CARGO_MANIFEST_DIR")); @@ -520,18 +534,139 @@ fn parse_servers() { } } -struct TestEnvelope { - pub local_ip: IpAddr, - pub remote_ip: IpAddr, - pub sender_domain: String, - pub sender: String, - pub rcpt_domain: String, - pub rcpt: String, - pub helo_domain: String, - pub authenticated_as: String, - pub mx: String, - pub listener_id: u16, - pub priority: i16, +#[tokio::test] +async fn eval_if() { + let mut file = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + file.push("resources"); + file.push("smtp"); + file.push("config"); + file.push("rules-eval.toml"); + + let config = Config::parse(&fs::read_to_string(file).unwrap()).unwrap(); + let servers = vec![ + Server { + id: "smtp".to_string(), + internal_id: 123, + ..Default::default() + }, + Server { + id: "smtps".to_string(), + internal_id: 456, + ..Default::default() + }, + ]; + let mut context = ConfigContext::new(&servers); + context.directory = config.parse_directory().unwrap(); + let conditions = config.parse_conditions(&context).unwrap(); + + let envelope = TestEnvelope::from_config(&config); + + for (key, conditions) in conditions { + //println!("============= Testing {:?} ==================", key); + let (_, expected_result) = key.rsplit_once('-').unwrap(); + assert_eq!( + IfBlock { + if_then: vec![IfThen { + conditions, + then: true + }], + default: false, + } + .eval(&envelope) + .await, + &expected_result.parse::<bool>().unwrap(), + "failed for {key:?}" + ); + } +} + +#[tokio::test] +async fn eval_dynvalue() { + let mut file = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + file.push("resources"); + file.push("smtp"); + file.push("config"); + file.push("rules-dynvalue.toml"); + + let config = Config::parse(&fs::read_to_string(file).unwrap()).unwrap(); + let mut context = ConfigContext::new(&[]); + context.directory = config.parse_directory().unwrap(); + + let envelope = TestEnvelope::from_config(&config); + + for test_name in config.sub_keys("eval") { + //println!("============= Testing {:?} ==================", key); + let if_block = config + .parse_if_block::<Option<DynValue>>( + ("eval", test_name, "test"), + &context, + &[ + EnvelopeKey::Recipient, + EnvelopeKey::RecipientDomain, + EnvelopeKey::Sender, + EnvelopeKey::SenderDomain, + EnvelopeKey::AuthenticatedAs, + EnvelopeKey::Listener, + EnvelopeKey::RemoteIp, + EnvelopeKey::LocalIp, + EnvelopeKey::Priority, + EnvelopeKey::Mx, + ], + ) + .unwrap() + .unwrap(); + let expected = config + .property_require::<Option<String>>(("eval", test_name, "expect")) + .unwrap() + .map(Cow::Owned); + + assert_eq!( + if_block.eval_and_capture(&envelope).await.into_value(), + expected, + "failed for test {test_name:?}" + ); + } + + for test_name in config.sub_keys("maybe-eval") { + //println!("============= Testing {:?} ==================", key); + let if_block = config + .parse_if_block::<Option<DynValue>>( + ("maybe-eval", test_name, "test"), + &context, + &[ + EnvelopeKey::Recipient, + EnvelopeKey::RecipientDomain, + EnvelopeKey::Sender, + EnvelopeKey::SenderDomain, + EnvelopeKey::AuthenticatedAs, + EnvelopeKey::Listener, + EnvelopeKey::RemoteIp, + EnvelopeKey::LocalIp, + EnvelopeKey::Priority, + EnvelopeKey::Mx, + ], + ) + .unwrap() + .unwrap() + .map_if_block( + &context.directory.directories, + ("maybe-eval", test_name, "test"), + "test", + ) + .unwrap(); + let expected = config + .value_require(("maybe-eval", test_name, "expect")) + .unwrap(); + + assert!(if_block + .eval_and_capture(&envelope) + .await + .into_value() + .unwrap() + .is_local_domain(expected) + .await + .unwrap()); + } } impl Envelope for TestEnvelope { @@ -580,62 +715,22 @@ impl Envelope for TestEnvelope { } } -#[tokio::test] -async fn eval_if() { - let mut file = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - file.push("resources"); - file.push("smtp"); - file.push("config"); - file.push("rules-eval.toml"); - - let config = Config::parse(&fs::read_to_string(file).unwrap()).unwrap(); - let servers = vec![ - Server { - id: "smtp".to_string(), - internal_id: 123, - ..Default::default() - }, - Server { - id: "smtps".to_string(), - internal_id: 456, - ..Default::default() - }, - ]; - let mut context = ConfigContext::new(&servers); - context.directory = config.parse_directory().unwrap(); - let conditions = config.parse_conditions(&context).unwrap(); - - let envelope = TestEnvelope { - local_ip: config.property_require("envelope.local-ip").unwrap(), - remote_ip: config.property_require("envelope.remote-ip").unwrap(), - sender_domain: config.property_require("envelope.sender-domain").unwrap(), - sender: config.property_require("envelope.sender").unwrap(), - rcpt_domain: config.property_require("envelope.rcpt-domain").unwrap(), - rcpt: config.property_require("envelope.rcpt").unwrap(), - authenticated_as: config - .property_require("envelope.authenticated-as") - .unwrap(), - mx: config.property_require("envelope.mx").unwrap(), - listener_id: config.property_require("envelope.listener").unwrap(), - priority: config.property_require("envelope.priority").unwrap(), - helo_domain: config.property_require("envelope.helo-domain").unwrap(), - }; - - for (key, conditions) in conditions { - //println!("============= Testing {:?} ==================", key); - let (_, expected_result) = key.rsplit_once('-').unwrap(); - assert_eq!( - IfBlock { - if_then: vec![IfThen { - conditions, - then: true - }], - default: false, - } - .eval(&envelope) - .await, - &expected_result.parse::<bool>().unwrap(), - "failed for {key:?}" - ); +impl TestEnvelope { + pub fn from_config(config: &Config) -> Self { + Self { + local_ip: config.property_require("envelope.local-ip").unwrap(), + remote_ip: config.property_require("envelope.remote-ip").unwrap(), + sender_domain: config.property_require("envelope.sender-domain").unwrap(), + sender: config.property_require("envelope.sender").unwrap(), + rcpt_domain: config.property_require("envelope.rcpt-domain").unwrap(), + rcpt: config.property_require("envelope.rcpt").unwrap(), + authenticated_as: config + .property_require("envelope.authenticated-as") + .unwrap(), + mx: config.property_require("envelope.mx").unwrap(), + listener_id: config.property_require("envelope.listener").unwrap(), + priority: config.property_require("envelope.priority").unwrap(), + helo_domain: config.property_require("envelope.helo-domain").unwrap(), + } } } diff --git a/tests/src/smtp/inbound/auth.rs b/tests/src/smtp/inbound/auth.rs index 8cf014d0..013cccd8 100644 --- a/tests/src/smtp/inbound/auth.rs +++ b/tests/src/smtp/inbound/auth.rs @@ -23,7 +23,7 @@ use directory::config::ConfigDirectory; use smtp_proto::{AUTH_LOGIN, AUTH_PLAIN}; -use utils::config::Config; +use utils::config::{Config, DynValue}; use crate::smtp::{ session::{TestSession, VerifyResponse}, @@ -68,7 +68,7 @@ async fn auth() { .parse_if(&ctx); config.directory = r"[{if = 'remote-ip', eq = '10.0.0.1', then = 'local'}, {else = false}]" - .parse_if::<Option<String>>(&ctx) + .parse_if::<Option<DynValue>>(&ctx) .map_if_block(&ctx.directory.directories, "", "") .unwrap(); config.errors_max = r"[{if = 'remote-ip', eq = '10.0.0.1', then = 2}, diff --git a/tests/src/smtp/inbound/data.rs b/tests/src/smtp/inbound/data.rs index 411cbbed..c8169160 100644 --- a/tests/src/smtp/inbound/data.rs +++ b/tests/src/smtp/inbound/data.rs @@ -30,7 +30,7 @@ use crate::smtp::{ ParseTestConfig, TestConfig, TestSMTP, }; use smtp::{ - config::{ConfigContext, IfBlock}, + config::{ConfigContext, IfBlock, MaybeDynValue}, core::{Session, SMTP}, }; @@ -81,7 +81,9 @@ async fn data() { let mut qr = core.init_test_queue("smtp_data_test"); let directory = Config::parse(DIRECTORY).unwrap().parse_directory().unwrap(); let mut config = &mut core.session.config.rcpt; - config.directory = IfBlock::new(Some(directory.directories.get("local").unwrap().clone())); + config.directory = IfBlock::new(Some(MaybeDynValue::Static( + directory.directories.get("local").unwrap().clone(), + ))); let mut config = &mut core.session.config; config.data.add_auth_results = "[{if = 'remote-ip', eq = '10.0.0.3', then = true}, diff --git a/tests/src/smtp/inbound/dmarc.rs b/tests/src/smtp/inbound/dmarc.rs index d070e294..eadc1eac 100644 --- a/tests/src/smtp/inbound/dmarc.rs +++ b/tests/src/smtp/inbound/dmarc.rs @@ -34,7 +34,7 @@ use mail_auth::{ report::DmarcResult, spf::Spf, }; -use utils::config::{Config, Rate}; +use utils::config::{Config, DynValue, Rate}; use crate::smtp::{ inbound::{sign::TextConfigContext, TestMessage, TestQueueEvent, TestReportingEvent}, @@ -42,7 +42,7 @@ use crate::smtp::{ ParseTestConfig, TestConfig, TestSMTP, }; use smtp::{ - config::{AggregateFrequency, ConfigContext, IfBlock, VerifyStrategy}, + config::{AggregateFrequency, ConfigContext, IfBlock, MaybeDynValue, VerifyStrategy}, core::{Session, SMTP}, }; @@ -134,7 +134,9 @@ async fn dmarc() { let mut rr = core.init_test_report(); let directory = Config::parse(DIRECTORY).unwrap().parse_directory().unwrap(); let mut config = &mut core.session.config.rcpt; - config.directory = IfBlock::new(Some(directory.directories.get("local").unwrap().clone())); + config.directory = IfBlock::new(Some(MaybeDynValue::Static( + directory.directories.get("local").unwrap().clone(), + ))); let mut config = &mut core.session.config; config.data.add_auth_results = IfBlock::new(true); @@ -166,11 +168,17 @@ async fn dmarc() { let mut config = &mut core.report.config; config.spf.sign = "['rsa']" - .parse_if::<Vec<String>>(&ctx) + .parse_if::<Vec<DynValue>>(&ctx) + .map_if_block(&ctx.signers, "", "") + .unwrap(); + config.dmarc.sign = "['rsa']" + .parse_if::<Vec<DynValue>>(&ctx) + .map_if_block(&ctx.signers, "", "") + .unwrap(); + config.dkim.sign = "['rsa']" + .parse_if::<Vec<DynValue>>(&ctx) .map_if_block(&ctx.signers, "", "") .unwrap(); - config.dmarc.sign = config.spf.sign.clone(); - config.dkim.sign = config.spf.sign.clone(); // SPF must pass let core = Arc::new(core); diff --git a/tests/src/smtp/inbound/milter.rs b/tests/src/smtp/inbound/milter.rs index d9622678..fad22580 100644 --- a/tests/src/smtp/inbound/milter.rs +++ b/tests/src/smtp/inbound/milter.rs @@ -212,7 +212,7 @@ fn milter_address_modifications() { // ChangeFrom assert!(data - .apply_modifications( + .apply_milter_modifications( vec![Modification::ChangeFrom { sender: "<>".to_string(), args: String::new() @@ -227,7 +227,7 @@ fn milter_address_modifications() { // ChangeFrom with parameters assert!(data - .apply_modifications( + .apply_milter_modifications( vec![Modification::ChangeFrom { sender: "john@example.org".to_string(), args: "REQUIRETLS ENVID=abc123".to_string(), //"NOTIFY=SUCCESS,FAILURE ENVID=abc123\n".to_string() @@ -242,7 +242,7 @@ fn milter_address_modifications() { // Add recipients assert!(data - .apply_modifications( + .apply_milter_modifications( vec![ Modification::AddRcpt { recipient: "bill@example.org".to_string(), @@ -276,7 +276,7 @@ fn milter_address_modifications() { // Remove recipients assert!(data - .apply_modifications( + .apply_milter_modifications( vec![ Modification::DeleteRcpt { recipient: "bill@example.org".to_string(), @@ -319,7 +319,7 @@ fn milter_message_modifications() { test.result, String::from_utf8( session_data - .apply_modifications(test.modifications, &parsed_test_message) + .apply_milter_modifications(test.modifications, &parsed_test_message) .unwrap() ) .unwrap() diff --git a/tests/src/smtp/inbound/mod.rs b/tests/src/smtp/inbound/mod.rs index eb0fad89..0032c91f 100644 --- a/tests/src/smtp/inbound/mod.rs +++ b/tests/src/smtp/inbound/mod.rs @@ -42,6 +42,7 @@ pub mod limits; pub mod mail; pub mod milter; pub mod rcpt; +pub mod rewrite; pub mod scripts; pub mod sign; pub mod throttle; diff --git a/tests/src/smtp/inbound/rcpt.rs b/tests/src/smtp/inbound/rcpt.rs index 3441f183..80b50deb 100644 --- a/tests/src/smtp/inbound/rcpt.rs +++ b/tests/src/smtp/inbound/rcpt.rs @@ -32,7 +32,7 @@ use crate::smtp::{ ParseTestConfig, TestConfig, }; use smtp::{ - config::{ConfigContext, IfBlock}, + config::{ConfigContext, IfBlock, MaybeDynValue}, core::{Session, State, SMTP}, }; @@ -75,7 +75,9 @@ async fn rcpt() { let mut config_ext = &mut core.session.config.extensions; let directory = Config::parse(DIRECTORY).unwrap().parse_directory().unwrap(); let mut config = &mut core.session.config.rcpt; - config.directory = IfBlock::new(Some(directory.directories.get("local").unwrap().clone())); + config.directory = IfBlock::new(Some(MaybeDynValue::Static( + directory.directories.get("local").unwrap().clone(), + ))); config.max_recipients = r"[{if = 'remote-ip', eq = '10.0.0.1', then = 3}, {else = 5}]" .parse_if(&ConfigContext::new(&[])); diff --git a/tests/src/smtp/inbound/rewrite.rs b/tests/src/smtp/inbound/rewrite.rs new file mode 100644 index 00000000..eb895f58 --- /dev/null +++ b/tests/src/smtp/inbound/rewrite.rs @@ -0,0 +1,169 @@ +/* + * 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 crate::smtp::{inbound::sign::TextConfigContext, session::TestSession, TestConfig}; +use directory::config::ConfigDirectory; +use smtp::{ + config::{if_block::ConfigIf, scripts::ConfigSieve, ConfigContext, EnvelopeKey, IfBlock}, + core::{Session, SMTP}, +}; +use utils::config::{Config, DynValue}; + +const CONFIG: &str = r#" +[session.mail] +rewrite = [ { all-of = [ { if = "sender-domain", ends-with = ".foobar.net" }, + { if = "sender", matches = "^([^.]+)@([^.]+)\.(.+)$"}, + ], then = "${1}+${2}@${3}" }, + { else = false } ] +script = [ { if = "sender-domain", eq = "foobar.org", then = "mail" }, + { else = false } ] + +[session.rcpt] +rewrite = [ { all-of = [ { if = "rcpt-domain", eq = "foobar.net" }, + { if = "rcpt", matches = "^([^.]+)\.([^.]+)@(.+)$"}, + ], then = "${1}+${2}@${3}" }, + { else = false } ] +script = [ { if = "rcpt-domain", eq = "foobar.org", then = "rcpt" }, + { else = false } ] + +[sieve] +from-name = "Sieve Daemon" +from-addr = "sieve@foobar.org" +return-path = "" +hostname = "mx.foobar.org" + +[sieve.limits] +redirects = 3 +out-messages = 5 +received-headers = 50 +cpu = 10000 +nested-includes = 5 +duplicate-expiry = "7d" + +[sieve.scripts] +mail = ''' +require ["variables", "envelope"]; + +if allof( envelope :domain :is "from" "foobar.org", + envelope :localpart :contains "from" "admin" ) { + set "envelope.from" "MAILER-DAEMON@foobar.org"; +} + +''' + +rcpt = ''' +require ["variables", "envelope", "regex"]; + +if allof( envelope :localpart :contains "to" ".", + envelope :regex "to" "(.+)@(.+)$") { + set :replace "." "" "to" "${1}"; + set "envelope.to" "${to}@${2}"; +} + +''' + +"#; + +#[tokio::test] +async fn address_rewrite() { + /*tracing::subscriber::set_global_default( + tracing_subscriber::FmtSubscriber::builder() + .with_max_level(tracing::Level::TRACE) + .finish(), + ) + .unwrap();*/ + + // Prepare config + let available_keys = [ + EnvelopeKey::Sender, + EnvelopeKey::SenderDomain, + EnvelopeKey::Recipient, + EnvelopeKey::RecipientDomain, + ]; + let mut core = SMTP::test(); + let mut ctx = ConfigContext::new(&[]).parse_signatures(); + let settings = Config::parse(CONFIG).unwrap(); + ctx.directory = settings.parse_directory().unwrap(); + core.sieve = settings.parse_sieve(&mut ctx).unwrap(); + let config = &mut core.session.config; + config.mail.script = settings + .parse_if_block::<Option<String>>("session.mail.script", &ctx, &available_keys) + .unwrap() + .unwrap_or_default() + .map_if_block(&ctx.scripts, "session.mail.script", "script") + .unwrap(); + config.mail.rewrite = settings + .parse_if_block::<Option<DynValue>>("session.mail.rewrite", &ctx, &available_keys) + .unwrap() + .unwrap_or_default(); + config.rcpt.script = settings + .parse_if_block::<Option<String>>("session.rcpt.script", &ctx, &available_keys) + .unwrap() + .unwrap_or_default() + .map_if_block(&ctx.scripts, "session.rcpt.script", "script") + .unwrap(); + config.rcpt.rewrite = settings + .parse_if_block::<Option<DynValue>>("session.rcpt.rewrite", &ctx, &available_keys) + .unwrap() + .unwrap_or_default(); + config.rcpt.relay = IfBlock::new(true); + + // Init session + let mut session = Session::test(core); + session.data.remote_ip = "10.0.0.1".parse().unwrap(); + session.eval_session_params().await; + session.ehlo("mx.doe.org").await; + + // Sender rewrite using regex + session.mail_from("bill@doe.foobar.net", "250").await; + assert_eq!( + session.data.mail_from.as_ref().unwrap().address, + "bill+doe@foobar.net" + ); + session.reset(); + + // Sender rewrite using sieve + session.mail_from("this_is_admin@foobar.org", "250").await; + assert_eq!( + session.data.mail_from.as_ref().unwrap().address_lcase, + "mailer-daemon@foobar.org" + ); + + // Recipient rewrite using regex + session.rcpt_to("mary.smith@foobar.net", "250").await; + assert_eq!( + session.data.rcpt_to.last().unwrap().address, + "mary+smith@foobar.net" + ); + + // Remove duplicates + session.rcpt_to("mary.smith@foobar.net", "250").await; + assert_eq!(session.data.rcpt_to.len(), 1); + + // Recipient rewrite using sieve + session.rcpt_to("m.a.r.y.s.m.i.t.h@foobar.org", "250").await; + assert_eq!( + session.data.rcpt_to.last().unwrap().address, + "marysmith@foobar.org" + ); +} diff --git a/tests/src/smtp/inbound/sign.rs b/tests/src/smtp/inbound/sign.rs index 11c70cbe..6da52cc4 100644 --- a/tests/src/smtp/inbound/sign.rs +++ b/tests/src/smtp/inbound/sign.rs @@ -28,7 +28,7 @@ use mail_auth::{ common::{parse::TxtRecordParser, verify::DomainKey}, spf::Spf, }; -use utils::config::Config; +use utils::config::{Config, DynValue}; use crate::smtp::{ inbound::{TestMessage, TestQueueEvent}, @@ -36,7 +36,7 @@ use crate::smtp::{ ParseTestConfig, TestConfig, TestSMTP, }; use smtp::{ - config::{auth::ConfigAuth, ConfigContext, IfBlock, VerifyStrategy}, + config::{auth::ConfigAuth, ConfigContext, IfBlock, MaybeDynValue, VerifyStrategy}, core::{Session, SMTP}, }; @@ -81,7 +81,9 @@ report = true [signature.ed] public-key = '11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=' -private-key = 'nWGxne/9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A=' +private-key = '-----BEGIN PRIVATE KEY----- +nWGxne/9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A= +-----END PRIVATE KEY-----' domain = 'example.com' selector = 'ed' headers = ['From', 'To', 'Date', 'Subject', 'Message-ID'] @@ -152,7 +154,9 @@ async fn sign_and_seal() { let directory = Config::parse(DIRECTORY).unwrap().parse_directory().unwrap(); let mut config = &mut core.session.config.rcpt; - config.directory = IfBlock::new(Some(directory.directories.get("local").unwrap().clone())); + config.directory = IfBlock::new(Some(MaybeDynValue::Static( + directory.directories.get("local").unwrap().clone(), + ))); let mut config = &mut core.session.config; config.data.add_auth_results = IfBlock::new(true); @@ -170,11 +174,11 @@ async fn sign_and_seal() { config.arc.verify = config.spf.verify_ehlo.clone(); config.dmarc.verify = config.spf.verify_ehlo.clone(); config.dkim.sign = "['rsa']" - .parse_if::<Vec<String>>(&ctx) + .parse_if::<Vec<DynValue>>(&ctx) .map_if_block(&ctx.signers, "", "") .unwrap(); config.arc.seal = "'ed'" - .parse_if::<Option<String>>(&ctx) + .parse_if::<Option<DynValue>>(&ctx) .map_if_block(&ctx.sealers, "", "") .unwrap(); diff --git a/tests/src/smtp/inbound/vrfy.rs b/tests/src/smtp/inbound/vrfy.rs index 3acbb6bb..d88414dc 100644 --- a/tests/src/smtp/inbound/vrfy.rs +++ b/tests/src/smtp/inbound/vrfy.rs @@ -29,7 +29,7 @@ use crate::smtp::{ ParseTestConfig, TestConfig, }; use smtp::{ - config::{ConfigContext, IfBlock}, + config::{ConfigContext, IfBlock, MaybeDynValue}, core::{Session, SMTP}, }; @@ -67,7 +67,9 @@ async fn vrfy_expn() { let directory = Config::parse(DIRECTORY).unwrap().parse_directory().unwrap(); let mut config = &mut core.session.config.rcpt; - config.directory = IfBlock::new(Some(directory.directories.get("local").unwrap().clone())); + config.directory = IfBlock::new(Some(MaybeDynValue::Static( + directory.directories.get("local").unwrap().clone(), + ))); let mut config = &mut core.session.config.extensions; config.vrfy = r"[{if = 'remote-ip', eq = '10.0.0.1', then = true}, diff --git a/tests/src/smtp/lookup/sql.rs b/tests/src/smtp/lookup/sql.rs index 6efe9d3c..8a672074 100644 --- a/tests/src/smtp/lookup/sql.rs +++ b/tests/src/smtp/lookup/sql.rs @@ -25,7 +25,7 @@ use std::time::Duration; use directory::config::ConfigDirectory; use smtp_proto::{AUTH_LOGIN, AUTH_PLAIN}; -use utils::config::Config; +use utils::config::{Config, DynValue}; use crate::{ directory::sql::{create_test_directory, create_test_user_with_email, link_test_address}, @@ -115,7 +115,7 @@ async fn lookup_sql() { // Enable AUTH let mut config = &mut core.session.config.auth; config.directory = r"'sql'" - .parse_if::<Option<String>>(&ctx) + .parse_if::<Option<DynValue>>(&ctx) .map_if_block(&ctx.directory.directories, "", "") .unwrap(); config.mechanisms = IfBlock::new(AUTH_PLAIN | AUTH_LOGIN); @@ -124,7 +124,7 @@ async fn lookup_sql() { // Enable VRFY/EXPN/RCPT let mut config = &mut core.session.config.rcpt; config.directory = r"'sql'" - .parse_if::<Option<String>>(&ctx) + .parse_if::<Option<DynValue>>(&ctx) .map_if_block(&ctx.directory.directories, "", "") .unwrap(); config.relay = IfBlock::new(false); diff --git a/tests/src/smtp/mod.rs b/tests/src/smtp/mod.rs index dc7539d8..6189a353 100644 --- a/tests/src/smtp/mod.rs +++ b/tests/src/smtp/mod.rs @@ -239,6 +239,7 @@ impl TestConfig for SessionConfig { }, mail: Mail { script: IfBlock::new(None), + rewrite: IfBlock::new(None), }, rcpt: Rcpt { script: IfBlock::new(None), @@ -247,6 +248,7 @@ impl TestConfig for SessionConfig { errors_max: IfBlock::new(3), errors_wait: IfBlock::new(Duration::from_secs(1)), max_recipients: IfBlock::new(3), + rewrite: IfBlock::new(None), }, data: Data { script: IfBlock::new(None), diff --git a/tests/src/smtp/queue/dsn.rs b/tests/src/smtp/queue/dsn.rs index f1768dad..cdc8c328 100644 --- a/tests/src/smtp/queue/dsn.rs +++ b/tests/src/smtp/queue/dsn.rs @@ -29,6 +29,7 @@ use std::{ use smtp_proto::{Response, RCPT_NOTIFY_DELAY, RCPT_NOTIFY_FAILURE, RCPT_NOTIFY_SUCCESS}; use tokio::{fs::File, io::AsyncReadExt}; +use utils::config::DynValue; use crate::smtp::{ inbound::{sign::TextConfigContext, TestQueueEvent}, @@ -109,7 +110,7 @@ async fn generate_dsn() { let ctx = ConfigContext::new(&[]).parse_signatures(); let mut config = &mut core.queue.config.dsn; config.sign = "['rsa']" - .parse_if::<Vec<String>>(&ctx) + .parse_if::<Vec<DynValue>>(&ctx) .map_if_block(&ctx.signers, "", "") .unwrap(); diff --git a/tests/src/smtp/reporting/dmarc.rs b/tests/src/smtp/reporting/dmarc.rs index 779604b7..7f929476 100644 --- a/tests/src/smtp/reporting/dmarc.rs +++ b/tests/src/smtp/reporting/dmarc.rs @@ -32,6 +32,7 @@ use mail_auth::{ dmarc::Dmarc, report::{ActionDisposition, Disposition, DmarcResult, Record, Report}, }; +use utils::config::DynValue; use crate::smtp::{ inbound::{sign::TextConfigContext, TestMessage, TestQueueEvent}, @@ -66,7 +67,7 @@ async fn report_dmarc() { config.path = IfBlock::new(temp_dir.temp_dir.clone()); config.hash = IfBlock::new(16); config.dmarc_aggregate.sign = "['rsa']" - .parse_if::<Vec<String>>(&ctx) + .parse_if::<Vec<DynValue>>(&ctx) .map_if_block(&ctx.signers, "", "") .unwrap(); config.dmarc_aggregate.max_size = IfBlock::new(4096); diff --git a/tests/src/smtp/reporting/tls.rs b/tests/src/smtp/reporting/tls.rs index fb72b8f1..2e213009 100644 --- a/tests/src/smtp/reporting/tls.rs +++ b/tests/src/smtp/reporting/tls.rs @@ -29,6 +29,7 @@ use mail_auth::{ mta_sts::TlsRpt, report::tlsrpt::{FailureDetails, PolicyType, ResultType, TlsReport}, }; +use utils::config::DynValue; use crate::smtp::{ inbound::{sign::TextConfigContext, TestMessage, TestQueueEvent}, @@ -63,7 +64,7 @@ async fn report_tls() { config.path = IfBlock::new(temp_dir.temp_dir.clone()); config.hash = IfBlock::new(16); config.tls.sign = "['rsa']" - .parse_if::<Vec<String>>(&ctx) + .parse_if::<Vec<DynValue>>(&ctx) .map_if_block(&ctx.signers, "", "") .unwrap(); config.tls.max_size = IfBlock::new(4096); |