This commit is contained in:
mdecimus 2023-07-27 20:18:34 +02:00
parent 4f2f673baa
commit 3cea77b65e
58 changed files with 1696 additions and 368 deletions

View file

@ -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

View file

@ -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).
# Change Log
stalwart-mail v0.3.0
================================
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

67
Cargo.lock generated
View file

@ -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]]

View file

@ -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"]

View file

@ -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.

View file

@ -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"

View file

@ -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"] }

View file

@ -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,

View file

@ -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?;

View file

@ -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,
}
}
}

View file

@ -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 {

View file

@ -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

View file

@ -1,6 +1,6 @@
[package]
name = "imap"
version = "0.3.1"
version = "0.3.2"
edition = "2021"
resolver = "2"

View file

@ -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"

View file

@ -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\"");

View file

@ -1,6 +1,6 @@
[package]
name = "jmap"
version = "0.3.1"
version = "0.3.2"
edition = "2021"
resolver = "2"

View file

@ -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)

View file

@ -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")]

View file

@ -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"

View file

@ -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"

View file

@ -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}")

View file

@ -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 {

View file

@ -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 {

View file

@ -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")?,
},

View file

@ -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,

View file

@ -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(),
})
}

View file

@ -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(),
}
}
}

View file

@ -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,

View file

@ -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

View file

@ -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 => (),
}
}
}
}

View file

@ -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);

View file

@ -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();
}
}

View file

@ -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<'_>,

View file

@ -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

View file

@ -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() => {

View file

@ -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() {

View file

@ -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)
}
}
}
}

View file

@ -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,

View file

@ -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 {

View file

@ -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]]

View file

@ -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__"

View file

@ -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"

View file

@ -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:?}"
);
}
}

View file

@ -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(),
}
}
}

View file

@ -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},

View file

@ -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},

View file

@ -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);

View file

@ -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()

View file

@ -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;

View file

@ -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(&[]));

View file

@ -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"
);
}

View file

@ -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();

View file

@ -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},

View file

@ -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);

View file

@ -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),

View file

@ -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();

View file

@ -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);

View file

@ -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);