SMTP codebase import

This commit is contained in:
Mauro D 2023-05-16 18:25:38 +00:00
parent 4d44e2fa77
commit 77ced9e7fd
169 changed files with 30767 additions and 164 deletions

1
.gitignore vendored
View file

@ -3,3 +3,4 @@
.vscode
*.failed
*_failed
stalwart.toml

View file

@ -19,6 +19,7 @@ path = "crates/main/src/main.rs"
store = { path = "crates/store" }
jmap = { path = "crates/jmap" }
jmap_proto = { path = "crates/jmap-proto" }
smtp = { path = "crates/smtp" }
utils = { path = "crates/utils" }
tests = { path = "tests" }
@ -26,6 +27,7 @@ tests = { path = "tests" }
members = [
"crates/jmap",
"crates/jmap-proto",
"crates/smtp",
"crates/store",
"crates/utils",
"crates/maybe-async",

View file

@ -24,7 +24,7 @@ aes-gcm-siv = "0.11.1"
bincode = "1.3.3"
form-data = { version = "0.4.2", features = ["sync"], default-features = false }
mime = "0.3.17"
sqlx = { git = "https://github.com/mdecimus/sqlx", features = [ "runtime-tokio-rustls", "postgres", "mysql", "sqlite" ] }
sqlx = { version = "0.7.0-alpha.3", features = [ "runtime-tokio-rustls", "postgres", "mysql", "sqlite" ] }
futures-util = "0.3.28"
async-stream = "0.3.5"
base64 = "0.21"

View file

@ -29,9 +29,6 @@ impl crate::Config {
request_max_concurrent: settings
.property("jmap.protocol.request.max-concurrent")?
.unwrap_or(4),
request_max_concurrent_total: settings
.property("jmap.protocol.request.max-concurrent-total")?
.unwrap_or(4),
get_max_objects: settings
.property("jmap.protocol.get.max-objects")?
.unwrap_or(500),

View file

@ -242,10 +242,6 @@ impl SessionManager for super::SessionManager {
}
});
}
fn max_concurrent(&self) -> u64 {
self.inner.config.request_max_concurrent_total
}
}
async fn handle_request<T: AsyncRead + AsyncWrite + Unpin + 'static>(

View file

@ -58,7 +58,6 @@ pub struct Config {
pub request_max_size: usize,
pub request_max_calls: usize,
pub request_max_concurrent: u64,
pub request_max_concurrent_total: u64,
pub get_max_objects: usize,
pub set_max_objects: usize,

62
crates/smtp/Cargo.toml Normal file
View file

@ -0,0 +1,62 @@
[package]
name = "smtp"
description = "Stalwart SMTP Server"
authors = [ "Stalwart Labs Ltd. <hello@stalw.art>"]
repository = "https://github.com/stalwartlabs/smtp-server"
homepage = "https://stalw.art/smtp"
keywords = ["smtp", "email", "mail", "server"]
categories = ["email"]
license = "AGPL-3.0-only"
version = "0.1.1"
edition = "2021"
resolver = "2"
[dependencies]
utils = { path = "../utils" }
mail-auth = { git = "https://github.com/stalwartlabs/mail-auth" }
mail-send = { git = "https://github.com/stalwartlabs/mail-send", default-features = false, features = ["cram-md5", "skip-ehlo"] }
mail-parser = { git = "https://github.com/stalwartlabs/mail-parser", features = ["full_encoding", "ludicrous_mode"] }
mail-builder = { git = "https://github.com/stalwartlabs/mail-builder", features = ["ludicrous_mode"] }
smtp-proto = { git = "https://github.com/stalwartlabs/smtp-proto" }
sieve-rs = { git = "https://github.com/stalwartlabs/sieve" }
ahash = { version = "0.8" }
rustls = "0.21.0"
rustls-pemfile = "1.0"
tokio = { version = "1.23", features = ["full"] }
tokio-rustls = { version = "0.24.0"}
webpki-roots = { version = "0.23.0"}
hyper = { version = "1.0.0-rc.3", features = ["server", "http1", "http2"] }
http-body-util = "0.1.0-rc.2"
form_urlencoded = "1.1.0"
sha1 = "0.10"
sha2 = "0.10.6"
rayon = "1.5"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-appender = "0.2"
tracing-opentelemetry = "0.18.0"
opentelemetry = { version = "0.18.0", features = ["rt-tokio"] }
opentelemetry-otlp = { version = "0.11.0", features = ["http-proto", "reqwest-client", "reqwest-rustls"] }
opentelemetry-semantic-conventions = { version = "0.10.0" }
parking_lot = "0.12"
regex = "1.7.0"
dashmap = "5.4"
blake3 = "1.3"
lru-cache = "0.1.2"
rand = "0.8.5"
x509-parser = "0.15.0"
sqlx = { version = "0.7.0-alpha.3", features = [ "runtime-tokio-rustls", "postgres", "mysql", "sqlite" ] }
reqwest = { version = "0.11", default-features = false, features = ["rustls-tls", "blocking"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
num_cpus = "1.15.0"
[target.'cfg(unix)'.dependencies]
privdrop = "0.5.3"
[features]
test_mode = []
#[[bench]]
#name = "hash"
#harness = false

View file

@ -0,0 +1,404 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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::{sync::Arc, time::Duration};
use mail_auth::{
common::crypto::{Algorithm, Ed25519Key, HashAlgorithm, RsaKey, Sha256, SigningKey},
dkim::{Canonicalization, Done},
};
use mail_parser::decoders::base64::base64_decode;
use utils::config::{
utils::{AsKey, ParseValue},
Config,
};
use super::{
if_block::ConfigIf, ArcAuthConfig, ArcSealer, ConfigContext, DkimAuthConfig,
DkimCanonicalization, DkimSigner, DmarcAuthConfig, DnsBlConfig, EnvelopeKey, IfBlock, IfThen,
IpRevAuthConfig, MailAuthConfig, SpfAuthConfig, VerifyStrategy, DNSBL_EHLO, DNSBL_FROM,
DNSBL_IP, DNSBL_IPREV, DNSBL_RETURN_PATH,
};
pub trait ConfigAuth {
fn parse_mail_auth(&self, ctx: &ConfigContext) -> super::Result<MailAuthConfig>;
fn parse_dnsbl(&self, ctx: &ConfigContext) -> super::Result<DnsBlConfig>;
fn parse_signatures(&self, ctx: &mut ConfigContext) -> super::Result<()>;
}
impl ConfigAuth for Config {
fn parse_mail_auth(&self, ctx: &ConfigContext) -> super::Result<MailAuthConfig> {
let envelope_sender_keys = [
EnvelopeKey::Sender,
EnvelopeKey::SenderDomain,
EnvelopeKey::Priority,
EnvelopeKey::AuthenticatedAs,
EnvelopeKey::Listener,
EnvelopeKey::RemoteIp,
EnvelopeKey::LocalIp,
];
let envelope_conn_keys = [
EnvelopeKey::Listener,
EnvelopeKey::RemoteIp,
EnvelopeKey::LocalIp,
];
Ok(MailAuthConfig {
dkim: DkimAuthConfig {
verify: self
.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)?
.unwrap_or_default()
.map_if_block(&ctx.signers, "auth.dkim.sign", "signature")?,
},
arc: ArcAuthConfig {
verify: self
.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)?
.unwrap_or_default()
.map_if_block(&ctx.sealers, "auth.arc.seal", "signature")?,
},
spf: SpfAuthConfig {
verify_ehlo: self
.parse_if_block("auth.spf.verify.ehlo", ctx, &envelope_conn_keys)?
.unwrap_or_else(|| IfBlock::new(VerifyStrategy::Relaxed)),
verify_mail_from: self
.parse_if_block("auth.spf.verify.mail-from", ctx, &envelope_conn_keys)?
.unwrap_or_else(|| IfBlock::new(VerifyStrategy::Relaxed)),
},
dmarc: DmarcAuthConfig {
verify: self
.parse_if_block("auth.dmarc.verify", ctx, &envelope_sender_keys)?
.unwrap_or_else(|| IfBlock::new(VerifyStrategy::Relaxed)),
},
iprev: IpRevAuthConfig {
verify: self
.parse_if_block("auth.iprev.verify", ctx, &envelope_conn_keys)?
.unwrap_or_else(|| IfBlock::new(VerifyStrategy::Relaxed)),
},
dnsbl: self.parse_dnsbl(ctx)?,
})
}
fn parse_dnsbl(&self, ctx: &ConfigContext) -> super::Result<DnsBlConfig> {
let verify = self
.parse_if_block::<Vec<String>>(
"auth.dnsbl.verify",
ctx,
&[EnvelopeKey::RemoteIp, EnvelopeKey::Listener],
)?
.unwrap_or_default();
Ok(DnsBlConfig {
verify: IfBlock {
if_then: {
let mut if_then = Vec::with_capacity(verify.if_then.len());
for cond in verify.if_then {
if_then.push(IfThen {
conditions: cond.conditions,
then: cond.then.into_dnsbl("auth.dnsbl.verify.if")?,
});
}
if_then
},
default: verify.default.into_dnsbl("auth.dnsbl.verify.else")?,
},
ip_lookup: self
.values("auth.dnsbl.lookup.ip")
.filter_map(|(_, v)| {
if !v.is_empty() {
if !v.ends_with('.') {
format!("{v}.")
} else {
v.to_string()
}
.into()
} else {
None
}
})
.collect(),
domain_lookup: self
.values("auth.dnsbl.lookup.domain")
.filter_map(|(_, v)| {
if !v.is_empty() {
if !v.ends_with('.') {
format!("{v}.")
} else {
v.to_string()
}
.into()
} else {
None
}
})
.collect(),
})
}
#[allow(clippy::type_complexity)]
fn parse_signatures(&self, ctx: &mut ConfigContext) -> super::Result<()> {
for id in self.sub_keys("signature") {
let (signer, sealer) =
match self.property_require::<Algorithm>(("signature", id, "algorithm"))? {
Algorithm::RsaSha256 => {
let key = RsaKey::<Sha256>::from_rsa_pem(
&String::from_utf8(self.file_contents((
"signature",
id,
"private-key",
))?)
.unwrap_or_default(),
)
.map_err(|err| {
format!(
"Failed to build RSA key for {}: {}",
("signature", id, "private-key",).as_key(),
err
)
})?;
let key_clone = RsaKey::<Sha256>::from_rsa_pem(
&String::from_utf8(self.file_contents((
"signature",
id,
"private-key",
))?)
.unwrap_or_default(),
)
.map_err(|err| {
format!(
"Failed to build RSA key for {}: {}",
("signature", id, "private-key",).as_key(),
err
)
})?;
let (signer, sealer) = parse_signature(self, id, key_clone, key)?;
(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 key = Ed25519Key::from_seed_and_public_key(&private_key, &public_key)
.map_err(|err| {
format!("Failed to build ED25519 key for signature {id:?}: {err}")
})?;
let key_clone =
Ed25519Key::from_seed_and_public_key(&private_key, &public_key)
.map_err(|err| {
format!(
"Failed to build ED25519 key for signature {id:?}: {err}"
)
})?;
let (signer, sealer) = parse_signature(self, id, key_clone, key)?;
(
DkimSigner::Ed25519Sha256(signer),
ArcSealer::Ed25519Sha256(sealer),
)
}
Algorithm::RsaSha1 => {
return Err(format!(
"Could not build signature {id:?}: SHA1 signatures are deprecated.",
))
}
};
ctx.signers.insert(id.to_string(), Arc::new(signer));
ctx.sealers.insert(id.to_string(), Arc::new(sealer));
}
Ok(())
}
}
fn parse_signature<T: SigningKey, U: SigningKey<Hasher = Sha256>>(
config: &Config,
id: &str,
key_dkim: T,
key_arc: U,
) -> super::Result<(
mail_auth::dkim::DkimSigner<T, Done>,
mail_auth::arc::ArcSealer<U, Done>,
)> {
let domain = config.value_require(("signature", id, "domain"))?;
let selector = config.value_require(("signature", id, "selector"))?;
let mut headers = config
.values(("signature", id, "headers"))
.filter_map(|(_, v)| {
if !v.is_empty() {
v.to_string().into()
} else {
None
}
})
.collect::<Vec<_>>();
if headers.is_empty() {
headers = vec![
"From".to_string(),
"To".to_string(),
"Date".to_string(),
"Subject".to_string(),
"Message-ID".to_string(),
];
}
let mut signer = mail_auth::dkim::DkimSigner::from_key(key_dkim)
.domain(domain)
.selector(selector)
.headers(headers.clone());
if !headers
.iter()
.any(|h| h.eq_ignore_ascii_case("DKIM-Signature"))
{
headers.push("DKIM-Signature".to_string());
}
let mut sealer = mail_auth::arc::ArcSealer::from_key(key_arc)
.domain(domain)
.selector(selector)
.headers(headers);
if let Some(c) =
config.property::<DkimCanonicalization>(("signature", id, "canonicalization"))?
{
signer = signer
.body_canonicalization(c.body)
.header_canonicalization(c.headers);
sealer = sealer
.body_canonicalization(c.body)
.header_canonicalization(c.headers);
}
if let Some(c) = config.property::<Duration>(("signature", id, "expire"))? {
signer = signer.expiration(c.as_secs());
sealer = sealer.expiration(c.as_secs());
}
if let Some(true) = config.property::<bool>(("signature", id, "set-body-length"))? {
signer = signer.body_length(true);
sealer = sealer.body_length(true);
}
if let Some(true) = config.property::<bool>(("signature", id, "report"))? {
signer = signer.reporting(true);
}
if let Some(auid) = config.property::<String>(("signature", id, "auid"))? {
signer = signer.agent_user_identifier(auid);
}
if let Some(atps) = config.property::<String>(("signature", id, "third-party"))? {
signer = signer.atps(atps);
}
if let Some(atpsh) = config.property::<HashAlgorithm>(("signature", id, "third-party-algo"))? {
signer = signer.atpsh(atpsh);
}
Ok((signer, sealer))
}
impl ParseValue for VerifyStrategy {
fn parse_value(key: impl AsKey, value: &str) -> super::Result<Self> {
match value {
"relaxed" => Ok(VerifyStrategy::Relaxed),
"strict" => Ok(VerifyStrategy::Strict),
"disable" | "disabled" | "never" | "none" => Ok(VerifyStrategy::Disable),
_ => Err(format!(
"Invalid value {:?} for key {:?}.",
value,
key.as_key()
)),
}
}
}
impl ParseValue for DkimCanonicalization {
fn parse_value(key: impl AsKey, value: &str) -> super::Result<Self> {
if let Some((headers, body)) = value.split_once('/') {
Ok(DkimCanonicalization {
headers: Canonicalization::parse_value(key.clone(), headers.trim())?,
body: Canonicalization::parse_value(key, body.trim())?,
})
} else {
let c = Canonicalization::parse_value(key, value)?;
Ok(DkimCanonicalization {
headers: c,
body: c,
})
}
}
}
impl Default for DkimCanonicalization {
fn default() -> Self {
Self {
headers: Canonicalization::Relaxed,
body: Canonicalization::Relaxed,
}
}
}
trait IntoDnsbl {
fn into_dnsbl(self, key: impl AsKey) -> super::Result<u32>;
}
impl IntoDnsbl for Vec<String> {
fn into_dnsbl(self, key: impl AsKey) -> super::Result<u32> {
let mut dns_bl = 0;
for value in self {
dns_bl |= match value.as_str() {
"ip" => DNSBL_IP,
"iprev" => DNSBL_IPREV,
"ehlo" | "helo" => DNSBL_EHLO,
"return-path" | "sender" | "mail-from" => DNSBL_RETURN_PATH,
"from" => DNSBL_FROM,
_ => {
return Err(format!(
"Invalid DNSBL value {:?} for key {:?}.",
value,
key.as_key()
))
}
};
}
Ok(dns_bl)
}
}

View file

@ -0,0 +1,491 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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::net::IpAddr;
use regex::Regex;
use crate::config::StringMatch;
use super::{Condition, ConditionMatch, Conditions, ConfigContext, EnvelopeKey, IpAddrMask};
use utils::config::{
utils::{AsKey, ParseKey, ParseValue},
Config,
};
pub trait ConfigCondition {
fn parse_condition(
&self,
key: impl AsKey,
ctx: &ConfigContext,
available_keys: &[EnvelopeKey],
) -> super::Result<Conditions>;
fn parse_conditions(
&self,
ctx: &ConfigContext,
) -> super::Result<ahash::AHashMap<String, Conditions>>;
}
impl ConfigCondition for Config {
fn parse_condition(
&self,
key_: impl AsKey,
ctx: &ConfigContext,
available_keys: &[EnvelopeKey],
) -> super::Result<Conditions> {
let mut conditions = Vec::new();
let mut stack = Vec::new();
let mut iter = None;
let mut jmp_pos = Vec::new();
let mut prefix = key_.as_key();
let mut is_all = false;
let mut is_not = false;
'outer: loop {
let mut op_str = "";
for key in self.sub_keys(prefix.as_str()) {
if !["if", "then"].contains(&key) {
if op_str.is_empty() {
op_str = key;
} else {
return Err(format!(
"Multiple operations found for condition {prefix:?}.",
));
}
}
}
if op_str.is_empty() {
return Err(format!("Missing operation for condition {prefix:?}."));
} else if ["any-of", "all-of", "none-of"].contains(&op_str) {
stack.push((
std::mem::replace(
&mut iter,
self.sub_keys((&prefix, op_str).as_key()).peekable().into(),
),
(&prefix, op_str).as_key(),
std::mem::take(&mut jmp_pos),
is_all,
is_not,
));
match op_str {
"any-of" => {
if !is_not {
is_all = false;
is_not = false;
} else {
is_all = true;
is_not = true;
}
}
"all-of" => {
if !is_not {
is_all = true;
is_not = false;
} else {
is_all = false;
is_not = true;
}
}
_ => {
is_not = !is_not;
if !is_not {
is_all = true;
is_not = false;
} else {
is_all = false;
is_not = true;
}
}
}
} else {
let key = self.property_require::<EnvelopeKey>((&prefix, "if"))?;
if !available_keys.contains(&key) {
return Err(format!(
"Envelope key {key:?} is not available in this context for property {prefix:?}",
));
}
enum MatchType {
Equal,
Regex,
Lookup,
StartsWith,
EndsWith,
}
let (op, op_is_not) = match op_str {
"eq" | "equal-to" | "ne" | "not-equal-to" => {
(MatchType::Equal, op_str == "ne" || op_str == "not-equal-to")
}
"in-list" | "not-in-list" => (MatchType::Lookup, op_str == "not-in-list"),
"matches" | "not-matches" => (MatchType::Regex, op_str.starts_with("not-")),
"starts-with" | "not-starts-with" => {
(MatchType::StartsWith, op_str == "not-starts-with")
}
"ends-with" | "not-ends-with" => {
(MatchType::EndsWith, op_str == "not-ends-with")
}
_ => {
return Err(format!("Invalid operation {op_str:?} for key {prefix:?}."));
}
};
let value_str = self.value_require((&prefix, op_str))?;
let value = match (key, &op) {
(EnvelopeKey::Listener, MatchType::Equal) => ConditionMatch::UInt(
ctx.servers
.iter()
.find_map(|s| {
if s.id == value_str {
s.internal_id.into()
} else {
None
}
})
.ok_or_else(|| {
format!(
"Listener {:?} does not exist for property {:?}.",
value_str,
(&prefix, op_str).as_key()
)
})?,
),
(EnvelopeKey::LocalIp | EnvelopeKey::RemoteIp, MatchType::Equal) => {
ConditionMatch::IpAddrMask(value_str.parse_key((&prefix, op_str))?)
}
(EnvelopeKey::Priority, MatchType::Equal) => {
ConditionMatch::Int(value_str.parse_key((&prefix, op_str))?)
}
(
EnvelopeKey::Recipient
| EnvelopeKey::RecipientDomain
| EnvelopeKey::Sender
| EnvelopeKey::SenderDomain
| EnvelopeKey::AuthenticatedAs
| EnvelopeKey::Mx
| EnvelopeKey::LocalIp
| EnvelopeKey::RemoteIp,
_,
) => match op {
MatchType::Equal => {
ConditionMatch::String(StringMatch::Equal(value_str.to_string()))
}
MatchType::StartsWith => {
ConditionMatch::String(StringMatch::StartsWith(value_str.to_string()))
}
MatchType::EndsWith => {
ConditionMatch::String(StringMatch::EndsWith(value_str.to_string()))
}
MatchType::Regex => {
ConditionMatch::Regex(Regex::new(value_str).map_err(|err| {
format!(
"Failed to compile regular expression {:?} for key {:?}: {}.",
value_str,
(&prefix, value_str).as_key(),
err
)
})?)
}
MatchType::Lookup => {
if let Some(list) = ctx.lookup.get(value_str) {
ConditionMatch::Lookup(list.clone())
} else {
return Err(format!(
"Lookup {:?} not found for property {:?}.",
value_str,
(&prefix, value_str).as_key()
));
}
}
},
_ => {
return Err(format!(
"Invalid 'op'/'value' combination for key {:?}.",
key_.as_key()
));
}
};
conditions.push(Condition::Match {
key,
value,
not: is_not ^ op_is_not,
});
if iter.as_mut().map_or(false, |it| it.peek().is_some()) {
jmp_pos.push(conditions.len());
conditions.push(if is_all {
Condition::JumpIfFalse {
positions: usize::MAX,
}
} else {
Condition::JumpIfTrue {
positions: usize::MAX,
}
});
}
}
loop {
if let Some(array_pos) = iter.as_mut().and_then(|it| it.next()) {
prefix = (stack.last().unwrap().1.as_str(), array_pos).as_key();
break;
} else if let Some((prev_iter, _, prev_jmp_pos, prev_is_all, prev_is_not)) =
stack.pop()
{
let cur_pos = conditions.len() - 1;
for pos in jmp_pos {
if let Condition::JumpIfFalse { positions }
| Condition::JumpIfTrue { positions } = &mut conditions[pos]
{
*positions = cur_pos - pos;
}
}
iter = prev_iter;
jmp_pos = prev_jmp_pos;
is_all = prev_is_all;
is_not = prev_is_not;
} else {
break 'outer;
}
}
}
Ok(Conditions { conditions })
}
#[cfg(feature = "test_mode")]
fn parse_conditions(
&self,
ctx: &ConfigContext,
) -> super::Result<ahash::AHashMap<String, Conditions>> {
use ahash::AHashMap;
let mut conditions = AHashMap::new();
let available_keys = vec![
EnvelopeKey::Recipient,
EnvelopeKey::RecipientDomain,
EnvelopeKey::Sender,
EnvelopeKey::SenderDomain,
EnvelopeKey::AuthenticatedAs,
EnvelopeKey::Listener,
EnvelopeKey::RemoteIp,
EnvelopeKey::LocalIp,
EnvelopeKey::Priority,
EnvelopeKey::Mx,
];
for rule_name in self.sub_keys("rule") {
conditions.insert(
rule_name.to_string(),
self.parse_condition(("rule", rule_name), ctx, &available_keys)?,
);
}
Ok(conditions)
}
}
impl ParseValue for IpAddrMask {
fn parse_value(key: impl AsKey, value: &str) -> super::Result<Self> {
if let Some((addr, mask)) = value.rsplit_once('/') {
if let (Ok(addr), Ok(mask)) =
(addr.trim().parse::<IpAddr>(), mask.trim().parse::<u32>())
{
match addr {
IpAddr::V4(addr) if (8..=32).contains(&mask) => {
return Ok(IpAddrMask::V4 {
addr,
mask: u32::MAX << (32 - mask),
})
}
IpAddr::V6(addr) if (8..=128).contains(&mask) => {
return Ok(IpAddrMask::V6 {
addr,
mask: u128::MAX << (128 - mask),
})
}
_ => (),
}
}
} else {
match value.trim().parse::<IpAddr>() {
Ok(IpAddr::V4(addr)) => {
return Ok(IpAddrMask::V4 {
addr,
mask: u32::MAX,
})
}
Ok(IpAddr::V6(addr)) => {
return Ok(IpAddrMask::V6 {
addr,
mask: u128::MAX,
})
}
_ => (),
}
}
Err(format!(
"Invalid IP address {:?} for property {:?}.",
value,
key.as_key()
))
}
}
#[cfg(test)]
mod tests {
use std::{fs, path::PathBuf, sync::Arc};
use ahash::AHashMap;
use utils::config::{Config, Server};
use crate::{
config::{
Condition, ConditionMatch, Conditions, ConfigContext, EnvelopeKey, IpAddrMask,
StringMatch,
},
lookup::Lookup,
};
use super::ConfigCondition;
#[test]
fn parse_conditions() {
let mut file = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
file.push("resources");
file.push("smtp");
file.push("config");
file.push("rules.toml");
let config = Config::parse(&fs::read_to_string(file).unwrap()).unwrap();
let mut context = ConfigContext::default();
let list = Arc::new(Lookup::default());
context.lookup.insert("test-list".to_string(), list.clone());
context.servers.push(Server {
id: "smtp".to_string(),
internal_id: 123,
..Default::default()
});
let mut conditions = config.parse_conditions(&context).unwrap();
let expected_rules = AHashMap::from_iter([
(
"simple".to_string(),
Conditions {
conditions: vec![Condition::Match {
key: EnvelopeKey::Listener,
value: ConditionMatch::UInt(123),
not: false,
}],
},
),
(
"is-authenticated".to_string(),
Conditions {
conditions: vec![Condition::Match {
key: EnvelopeKey::AuthenticatedAs,
value: ConditionMatch::String(StringMatch::Equal("".to_string())),
not: true,
}],
},
),
(
"expanded".to_string(),
Conditions {
conditions: vec![
Condition::Match {
key: EnvelopeKey::SenderDomain,
value: ConditionMatch::String(StringMatch::StartsWith(
"example".to_string(),
)),
not: false,
},
Condition::JumpIfFalse { positions: 1 },
Condition::Match {
key: EnvelopeKey::Sender,
value: ConditionMatch::Lookup(list),
not: false,
},
],
},
),
(
"my-nested-rule".to_string(),
Conditions {
conditions: vec![
Condition::Match {
key: EnvelopeKey::RecipientDomain,
value: ConditionMatch::String(StringMatch::Equal(
"example.org".to_string(),
)),
not: false,
},
Condition::JumpIfTrue { positions: 9 },
Condition::Match {
key: EnvelopeKey::RemoteIp,
value: ConditionMatch::IpAddrMask(IpAddrMask::V4 {
addr: "192.168.0.0".parse().unwrap(),
mask: u32::MAX << (32 - 24),
}),
not: false,
},
Condition::JumpIfTrue { positions: 7 },
Condition::Match {
key: EnvelopeKey::Recipient,
value: ConditionMatch::String(StringMatch::StartsWith(
"no-reply@".to_string(),
)),
not: false,
},
Condition::JumpIfFalse { positions: 5 },
Condition::Match {
key: EnvelopeKey::Sender,
value: ConditionMatch::String(StringMatch::EndsWith(
"@domain.org".to_string(),
)),
not: false,
},
Condition::JumpIfFalse { positions: 3 },
Condition::Match {
key: EnvelopeKey::Priority,
value: ConditionMatch::Int(1),
not: true,
},
Condition::JumpIfTrue { positions: 1 },
Condition::Match {
key: EnvelopeKey::Priority,
value: ConditionMatch::Int(-2),
not: false,
},
],
},
),
]);
for (key, rule) in expected_rules {
assert_eq!(Some(rule), conditions.remove(&key), "failed for {key}");
}
}
}

View file

@ -0,0 +1,170 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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::{sync::Arc, time::Duration};
use parking_lot::Mutex;
use sqlx::{mysql::MySqlPoolOptions, postgres::PgPoolOptions, sqlite::SqlitePoolOptions};
use crate::lookup::{cache::LookupCache, Lookup, SqlDatabase, SqlQuery};
use utils::config::{utils::AsKey, Config};
use super::ConfigContext;
pub trait ConfigDatabase {
fn parse_databases(&self, ctx: &mut ConfigContext) -> super::Result<()>;
fn parse_database(&self, id: &str, ctx: &mut ConfigContext) -> super::Result<()>;
}
impl ConfigDatabase for Config {
fn parse_databases(&self, ctx: &mut ConfigContext) -> super::Result<()> {
for id in self.sub_keys("database") {
self.parse_database(id, ctx)?;
}
Ok(())
}
fn parse_database(&self, id: &str, ctx: &mut ConfigContext) -> super::Result<()> {
let address = self.value_require(("database", id, "address"))?;
let pool = if address.starts_with("postgres:") {
SqlDatabase::Postgres(
PgPoolOptions::new()
.max_connections(
self.property(("database", id, "max-connections"))?
.unwrap_or(10),
)
.min_connections(
self.property(("database", id, "min-connections"))?
.unwrap_or(0),
)
.idle_timeout(self.property(("database", id, "idle-timeout"))?)
.connect_lazy(address)
.map_err(|err| {
format!("Failed to create connection pool for {address:?}: {err}")
})?,
)
} else if address.starts_with("mysql:") {
SqlDatabase::MySql(
MySqlPoolOptions::new()
.max_connections(
self.property(("database", id, "max-connections"))?
.unwrap_or(10),
)
.min_connections(
self.property(("database", id, "min-connections"))?
.unwrap_or(0),
)
.idle_timeout(self.property(("database", id, "idle-timeout"))?)
.connect_lazy(address)
.map_err(|err| {
format!("Failed to create connection pool for {address:?}: {err}")
})?,
)
} else if address.starts_with("mssql:") {
unimplemented!("MSSQL support is not yet implemented")
/*SqlDatabase::MsSql(
MssqlPoolOptions::new()
.max_connections(
self.property(("database", id, "max-connections"))?
.unwrap_or(10),
)
.min_connections(
self.property(("database", id, "min-connections"))?
.unwrap_or(0),
)
.idle_timeout(self.property(("database", id, "idle-timeout"))?)
.connect_lazy(address)
.map_err(|err| {
format!("Failed to create connection pool for {address:?}: {err}")
})?,
)*/
} else if address.starts_with("sqlite:") {
SqlDatabase::SqlLite(
SqlitePoolOptions::new()
.max_connections(
self.property(("database", id, "max-connections"))?
.unwrap_or(10),
)
.min_connections(
self.property(("database", id, "min-connections"))?
.unwrap_or(0),
)
.idle_timeout(self.property(("database", id, "idle-timeout"))?)
.connect_lazy(address)
.map_err(|err| {
format!("Failed to create connection pool for {address:?}: {err}")
})?,
)
} else {
return Err(format!(
"Invalid database address {:?} for key {:?}",
address,
("database", id, "address").as_key()
));
};
// Add database
ctx.databases.insert(id.to_string(), pool.clone());
// Parse cache
let cache_entries = self
.property(("database", id, "cache.entries"))?
.unwrap_or(1024);
let cache_ttl_positive = self
.property(("database", id, "cache.ttl.positive"))?
.unwrap_or(Duration::from_secs(86400));
let cache_ttl_negative = self
.property(("database", id, "cache.ttl.positive"))?
.unwrap_or(Duration::from_secs(3600));
let cache_enable = self
.values(("database", id, "cache.enable"))
.map(|(_, v)| v)
.collect::<Vec<_>>();
// Parse lookups
for lookup_id in self.sub_keys(("database", id, "lookup")) {
ctx.lookup.insert(
format!("db/{id}/{lookup_id}"),
Arc::new(Lookup::Sql(SqlQuery {
query: self
.value_require(("database", id, "lookup", lookup_id))?
.to_string(),
db: pool.clone(),
cache: if cache_enable.contains(&lookup_id) {
Mutex::new(LookupCache::new(
cache_entries,
cache_ttl_positive,
cache_ttl_negative,
))
.into()
} else {
None
},
})),
);
}
Ok(())
}
}

View file

@ -0,0 +1,468 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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::sync::Arc;
use ahash::AHashMap;
use super::{condition::ConfigCondition, ConfigContext, EnvelopeKey, IfBlock, IfThen};
use utils::config::{
utils::{AsKey, ParseValues},
Config,
};
pub trait ConfigIf {
fn parse_if_block<T: Default + ParseValues>(
&self,
prefix: impl AsKey,
ctx: &ConfigContext,
available_keys: &[EnvelopeKey],
) -> super::Result<Option<IfBlock<T>>>;
}
impl ConfigIf for Config {
fn parse_if_block<T: Default + ParseValues>(
&self,
prefix: impl AsKey,
ctx: &ConfigContext,
available_keys: &[EnvelopeKey],
) -> super::Result<Option<IfBlock<T>>> {
let key = prefix.as_key();
let prefix = prefix.as_prefix();
let mut found_if = false;
let mut found_else = "";
let mut found_then = false;
// Parse conditions
let mut if_block = IfBlock::new(T::default());
let mut last_array_pos = "";
for item in self.keys.keys() {
if let Some(suffix_) = item.strip_prefix(&prefix) {
if let Some((array_pos, suffix)) = suffix_.split_once('.') {
let if_key = suffix.split_once('.').map(|(v, _)| v).unwrap_or(suffix);
if ["if", "any-of", "all-of", "none-of"].contains(&if_key) {
if array_pos != last_array_pos {
if !last_array_pos.is_empty() && !found_then && !T::is_multivalue() {
return Err(format!(
"Missing 'then' in 'if' condition {} for property {:?}.",
last_array_pos.parse().unwrap_or(0) + 1,
key
));
}
if_block.if_then.push(IfThen {
conditions: self.parse_condition(
(key.as_str(), array_pos),
ctx,
available_keys,
)?,
then: T::default(),
});
found_then = false;
last_array_pos = array_pos;
}
found_if = true;
} else if if_key == "else" {
if found_else.is_empty() {
if found_if {
if_block.default = T::parse_values(
(key.as_str(), suffix_.split_once(".else").unwrap().0, "else"),
self,
)?;
found_else = array_pos;
} else {
return Err(format!(
"Found 'else' before 'if' for property {key:?}.",
));
}
} else if array_pos != found_else {
return Err(format!("Multiple 'else' found for property {key:?}."));
}
} else if if_key == "then" {
if found_else.is_empty() {
if array_pos == last_array_pos {
if !found_then {
if_block.if_then.last_mut().unwrap().then = T::parse_values(
(
key.as_str(),
suffix_.split_once(".then").unwrap().0,
"then",
),
self,
)?;
found_then = true;
}
} else {
return Err(format!(
"Found 'then' without 'if' for property {key:?}.",
));
}
} else {
return Err(format!(
"Found 'then' in 'else' block for property {key:?}.",
));
}
}
} else if !found_if {
// Found probably a multi-value, parse and return
if_block.default = T::parse_values(key.as_str(), self)?;
return Ok(Some(if_block));
} else {
return Err(format!("Invalid property {item:?} found in 'if' block."));
}
} else if item == &key {
// There is a single value, parse and return
if_block.default = T::parse_values(key.as_str(), self)?;
return Ok(Some(if_block));
}
}
if !found_if {
Ok(None)
} else if !found_then && !T::is_multivalue() {
Err(format!(
"Missing 'then' in 'if' condition {} for property {:?}.",
last_array_pos.parse().unwrap_or(0) + 1,
key
))
} else if found_else.is_empty() && !T::is_multivalue() {
Err(format!("Missing 'else' for property {key:?}."))
} else {
Ok(Some(if_block))
}
}
}
impl<T: Default> IfBlock<T> {
pub fn new(value: T) -> Self {
Self {
if_then: Vec::with_capacity(0),
default: value,
}
}
}
impl<T: Default> IfBlock<Option<T>> {
pub fn try_unwrap(self, key: &str) -> super::Result<IfBlock<T>> {
let mut if_then = Vec::with_capacity(self.if_then.len());
for if_clause in self.if_then {
if_then.push(IfThen {
conditions: if_clause.conditions,
then: if_clause
.then
.ok_or_else(|| format!("Property {key:?} cannot contain null values."))?,
});
}
Ok(IfBlock {
if_then,
default: self
.default
.ok_or_else(|| format!("Property {key:?} cannot contain null values."))?,
})
}
}
impl IfBlock<Option<String>> {
pub fn map_if_block<T>(
self,
map: &AHashMap<String, Arc<T>>,
key_name: impl AsKey,
object_name: &str,
) -> super::Result<IfBlock<Option<Arc<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>(
map: &AHashMap<String, Arc<T>>,
value: Option<String>,
object_name: &str,
key_name: &str,
) -> super::Result<Option<Arc<T>>> {
if let Some(value) = value {
if let Some(value) = map.get(&value) {
Ok(Some(value.clone()))
} else {
Err(format!(
"Unable to find {object_name} {value:?} declared for {key_name:?}",
))
}
} else {
Ok(None)
}
}
}
impl IfBlock<Vec<String>> {
pub fn map_if_block<T>(
self,
map: &AHashMap<String, Arc<T>>,
key_name: &str,
object_name: &str,
) -> super::Result<IfBlock<Vec<Arc<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>(
map: &AHashMap<String, Arc<T>>,
values: Vec<String>,
object_name: &str,
key_name: &str,
) -> super::Result<Vec<Arc<T>>> {
let mut result = Vec::with_capacity(values.len());
for value in values {
if let Some(value) = map.get(&value) {
result.push(value.clone());
} else {
return Err(format!(
"Unable to find {object_name} {value:?} declared for {key_name:?}",
));
}
}
Ok(result)
}
}
impl<T> IfBlock<Vec<T>> {
pub fn has_empty_list(&self) -> bool {
self.default.is_empty() || self.if_then.iter().any(|v| v.then.is_empty())
}
}
#[cfg(test)]
mod tests {
use std::{fs, path::PathBuf, time::Duration};
use utils::config::Config;
use crate::config::{
if_block::ConfigIf, Condition, ConditionMatch, Conditions, ConfigContext, EnvelopeKey,
IfBlock, IfThen, StringMatch,
};
#[test]
fn parse_if_blocks() {
let mut file = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
file.push("resources");
file.push("smtp");
file.push("config");
file.push("if-blocks.toml");
let config = Config::parse(&fs::read_to_string(file).unwrap()).unwrap();
// Create context and add some conditions
let context = ConfigContext::default();
let available_keys = vec![
EnvelopeKey::Recipient,
EnvelopeKey::RecipientDomain,
EnvelopeKey::Sender,
EnvelopeKey::SenderDomain,
EnvelopeKey::AuthenticatedAs,
EnvelopeKey::Listener,
EnvelopeKey::RemoteIp,
EnvelopeKey::LocalIp,
EnvelopeKey::Priority,
];
assert_eq!(
config
.parse_if_block::<Option<Duration>>("durations", &context, &available_keys)
.unwrap()
.unwrap(),
IfBlock {
if_then: vec![
IfThen {
conditions: Conditions {
conditions: vec![Condition::Match {
key: EnvelopeKey::Sender,
value: ConditionMatch::String(StringMatch::Equal(
"jdoe".to_string()
)),
not: false
}]
},
then: Duration::from_secs(5 * 86400).into()
},
IfThen {
conditions: Conditions {
conditions: vec![
Condition::Match {
key: EnvelopeKey::Priority,
value: ConditionMatch::Int(-1),
not: false
},
Condition::JumpIfTrue { positions: 1 },
Condition::Match {
key: EnvelopeKey::Recipient,
value: ConditionMatch::String(StringMatch::StartsWith(
"jane".to_string()
)),
not: false
}
]
},
then: Duration::from_secs(3600).into()
}
],
default: None
}
);
assert_eq!(
config
.parse_if_block::<Vec<String>>("string-list", &context, &available_keys)
.unwrap()
.unwrap(),
IfBlock {
if_then: vec![
IfThen {
conditions: Conditions {
conditions: vec![Condition::Match {
key: EnvelopeKey::Sender,
value: ConditionMatch::String(StringMatch::Equal(
"jdoe".to_string()
)),
not: false
}]
},
then: vec!["From".to_string(), "To".to_string(), "Date".to_string()]
},
IfThen {
conditions: Conditions {
conditions: vec![
Condition::Match {
key: EnvelopeKey::Priority,
value: ConditionMatch::Int(-1),
not: false
},
Condition::JumpIfTrue { positions: 1 },
Condition::Match {
key: EnvelopeKey::Recipient,
value: ConditionMatch::String(StringMatch::StartsWith(
"jane".to_string()
)),
not: false
}
]
},
then: vec!["Other-ID".to_string()]
}
],
default: vec![]
}
);
assert_eq!(
config
.parse_if_block::<Vec<String>>("string-list-bis", &context, &available_keys)
.unwrap()
.unwrap(),
IfBlock {
if_then: vec![
IfThen {
conditions: Conditions {
conditions: vec![Condition::Match {
key: EnvelopeKey::Sender,
value: ConditionMatch::String(StringMatch::Equal(
"jdoe".to_string()
)),
not: false
}]
},
then: vec!["From".to_string(), "To".to_string(), "Date".to_string()]
},
IfThen {
conditions: Conditions {
conditions: vec![
Condition::Match {
key: EnvelopeKey::Priority,
value: ConditionMatch::Int(-1),
not: false
},
Condition::JumpIfTrue { positions: 1 },
Condition::Match {
key: EnvelopeKey::Recipient,
value: ConditionMatch::String(StringMatch::StartsWith(
"jane".to_string()
)),
not: false
}
]
},
then: vec![]
}
],
default: vec!["ID-Bis".to_string()]
}
);
assert_eq!(
config
.parse_if_block::<String>("single-value", &context, &available_keys)
.unwrap()
.unwrap(),
IfBlock {
if_then: vec![],
default: "hello world".to_string()
}
);
for bad_rule in [
"bad-multi-value",
"bad-if-without-then",
"bad-if-without-else",
"bad-multiple-else",
] {
if let Ok(value) = config.parse_if_block::<u32>(bad_rule, &context, &available_keys) {
panic!("Condition {bad_rule:?} had unexpected result {value:?}");
}
}
}
}

View file

@ -0,0 +1,159 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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::{
fs::File,
io::{BufRead, BufReader},
sync::Arc,
};
use ahash::AHashSet;
use utils::config::Config;
use crate::lookup::Lookup;
use super::ConfigContext;
pub trait ConfigList {
fn parse_lists(&self, ctx: &mut ConfigContext) -> super::Result<()>;
fn parse_list(&self, id: &str) -> super::Result<Lookup>;
}
impl ConfigList for Config {
fn parse_lists(&self, ctx: &mut ConfigContext) -> super::Result<()> {
for id in self.sub_keys("list") {
ctx.lookup
.insert(format!("list/{id}"), Arc::new(self.parse_list(id)?));
}
Ok(())
}
fn parse_list(&self, id: &str) -> super::Result<Lookup> {
let mut entries = AHashSet::new();
for (_, value) in self.values(("list", id)) {
if let Some(path) = value.strip_prefix("file://") {
for line in BufReader::new(File::open(path).map_err(|err| {
format!("Failed to read file {path:?} for list {id:?}: {err}")
})?)
.lines()
{
let line_ = line.map_err(|err| {
format!("Failed to read file {path:?} for list {id:?}: {err}")
})?;
let line = line_.trim();
if !line.is_empty() {
entries.insert(line.to_string());
}
}
} else {
entries.insert(value.to_string());
}
}
Ok(Lookup::Local(entries))
}
}
#[cfg(test)]
mod tests {
use std::{fs, path::PathBuf, sync::Arc};
use ahash::{AHashMap, AHashSet};
use utils::config::Config;
use crate::{
config::{remote::ConfigHost, ConfigContext},
lookup::Lookup,
};
use super::ConfigList;
#[test]
fn parse_lists() {
let mut file = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
file.push("resources");
file.push("smtp");
file.push("config");
file.push("lists.toml");
let mut list_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
list_path.push("resources");
list_path.push("smtp");
list_path.push("lists");
let mut list1 = list_path.clone();
list1.push("test-list1.txt");
let mut list2 = list_path.clone();
list2.push("test-list2.txt");
let toml = fs::read_to_string(file)
.unwrap()
.replace("{LIST1}", list1.as_path().to_str().unwrap())
.replace("{LIST2}", list2.as_path().to_str().unwrap());
let config = Config::parse(&toml).unwrap();
let mut context = ConfigContext::default();
config.parse_remote_hosts(&mut context).unwrap();
config.parse_lists(&mut context).unwrap();
let mut expected_lists = AHashMap::from_iter([
(
"list/local-domains".to_string(),
Arc::new(Lookup::Local(AHashSet::from_iter([
"example.org".to_string(),
"example.net".to_string(),
]))),
),
(
"list/spammer-domains".to_string(),
Arc::new(Lookup::Local(AHashSet::from_iter([
"thatdomain.net".to_string()
]))),
),
(
"list/local-users".to_string(),
Arc::new(Lookup::Local(AHashSet::from_iter([
"user1@domain.org".to_string(),
"user2@domain.org".to_string(),
]))),
),
(
"list/power-users".to_string(),
Arc::new(Lookup::Local(AHashSet::from_iter([
"user1@domain.org".to_string(),
"user2@domain.org".to_string(),
"user3@example.net".to_string(),
"user4@example.net".to_string(),
"user5@example.net".to_string(),
]))),
),
(
"remote/lmtp".to_string(),
context.lookup.get("remote/lmtp").unwrap().clone(),
),
]);
for (key, list) in context.lookup {
assert_eq!(Some(list), expected_lists.remove(&key), "failed for {key}");
}
}
}

View file

@ -0,0 +1,540 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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.
*/
pub mod auth;
pub mod condition;
pub mod database;
pub mod if_block;
pub mod list;
pub mod queue;
pub mod remote;
pub mod report;
pub mod resolver;
pub mod scripts;
pub mod session;
pub mod throttle;
use std::{
net::{Ipv4Addr, Ipv6Addr},
path::PathBuf,
sync::{atomic::AtomicU64, Arc},
time::Duration,
};
use ahash::AHashMap;
use mail_auth::{
common::crypto::{Ed25519Key, RsaKey, Sha256},
dkim::{Canonicalization, Done},
IpLookupStrategy,
};
use mail_send::Credentials;
use regex::Regex;
use sieve::Sieve;
use smtp_proto::MtPriority;
use tokio::sync::mpsc;
use utils::config::{Server, ServerProtocol};
use crate::lookup::{self, Lookup, SqlDatabase};
#[derive(Debug)]
pub struct Host {
pub address: String,
pub port: u16,
pub protocol: ServerProtocol,
pub concurrency: usize,
pub timeout: Duration,
pub tls_implicit: bool,
pub tls_allow_invalid_certs: bool,
pub username: Option<String>,
pub secret: Option<String>,
pub max_errors: usize,
pub max_requests: usize,
pub cache_entries: usize,
pub cache_ttl_positive: Duration,
pub cache_ttl_negative: Duration,
pub channel_tx: mpsc::Sender<lookup::Event>,
pub channel_rx: mpsc::Receiver<lookup::Event>,
pub lookup: bool,
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "test_mode", derive(PartialEq, Eq))]
pub enum Condition {
Match {
key: EnvelopeKey,
value: ConditionMatch,
not: bool,
},
JumpIfTrue {
positions: usize,
},
JumpIfFalse {
positions: usize,
},
}
#[derive(Debug, PartialEq, Eq, Clone)]
pub enum StringMatch {
Equal(String),
StartsWith(String),
EndsWith(String),
}
#[derive(Debug, Clone)]
pub enum ConditionMatch {
String(StringMatch),
UInt(u16),
Int(i16),
IpAddrMask(IpAddrMask),
Lookup(Arc<Lookup>),
Regex(Regex),
}
#[cfg(feature = "test_mode")]
impl PartialEq for ConditionMatch {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::String(l0), Self::String(r0)) => l0 == r0,
(Self::UInt(l0), Self::UInt(r0)) => l0 == r0,
(Self::Int(l0), Self::Int(r0)) => l0 == r0,
(Self::IpAddrMask(l0), Self::IpAddrMask(r0)) => l0 == r0,
(Self::Lookup(l0), Self::Lookup(r0)) => l0 == r0,
(Self::Regex(_), Self::Regex(_)) => false,
_ => false,
}
}
}
#[cfg(feature = "test_mode")]
impl Eq for ConditionMatch {}
#[cfg(feature = "test_mode")]
impl PartialEq for Lookup {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Local(l0), Self::Local(r0)) => l0 == r0,
(Self::Remote(_), Self::Remote(_)) => true,
_ => false,
}
}
}
impl Default for Condition {
fn default() -> Self {
Condition::JumpIfFalse { positions: 0 }
}
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum EnvelopeKey {
Recipient,
RecipientDomain,
Sender,
SenderDomain,
Mx,
HeloDomain,
AuthenticatedAs,
Listener,
RemoteIp,
LocalIp,
Priority,
}
#[derive(Debug, Clone, Default)]
#[cfg_attr(feature = "test_mode", derive(PartialEq, Eq))]
pub struct IfThen<T: Default> {
pub conditions: Conditions,
pub then: T,
}
#[derive(Debug, Clone, Default)]
#[cfg_attr(feature = "test_mode", derive(PartialEq, Eq))]
pub struct Conditions {
pub conditions: Vec<Condition>,
}
#[derive(Debug, Clone, Default)]
#[cfg_attr(feature = "test_mode", derive(PartialEq, Eq))]
pub struct IfBlock<T: Default> {
pub if_then: Vec<IfThen<T>>,
pub default: T,
}
#[derive(Debug, Default)]
#[cfg_attr(feature = "test_mode", derive(PartialEq, Eq))]
pub struct Throttle {
pub conditions: Conditions,
pub keys: u16,
pub concurrency: Option<u64>,
pub rate: Option<Rate>,
}
pub const THROTTLE_RCPT: u16 = 1 << 0;
pub const THROTTLE_RCPT_DOMAIN: u16 = 1 << 1;
pub const THROTTLE_SENDER: u16 = 1 << 2;
pub const THROTTLE_SENDER_DOMAIN: u16 = 1 << 3;
pub const THROTTLE_AUTH_AS: u16 = 1 << 4;
pub const THROTTLE_LISTENER: u16 = 1 << 5;
pub const THROTTLE_MX: u16 = 1 << 6;
pub const THROTTLE_REMOTE_IP: u16 = 1 << 7;
pub const THROTTLE_LOCAL_IP: u16 = 1 << 8;
pub const THROTTLE_HELO_DOMAIN: u16 = 1 << 9;
#[derive(Debug, Default, PartialEq, Eq, Clone)]
pub struct Rate {
pub requests: u64,
pub period: Duration,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IpAddrMask {
V4 { addr: Ipv4Addr, mask: u32 },
V6 { addr: Ipv6Addr, mask: u128 },
}
pub struct Connect {
pub script: IfBlock<Option<Arc<Sieve>>>,
}
pub struct Ehlo {
pub script: IfBlock<Option<Arc<Sieve>>>,
pub require: IfBlock<bool>,
pub reject_non_fqdn: IfBlock<bool>,
}
pub struct Extensions {
pub pipelining: IfBlock<bool>,
pub chunking: IfBlock<bool>,
pub requiretls: IfBlock<bool>,
pub dsn: IfBlock<bool>,
pub no_soliciting: IfBlock<Option<String>>,
pub future_release: IfBlock<Option<Duration>>,
pub deliver_by: IfBlock<Option<Duration>>,
pub mt_priority: IfBlock<Option<MtPriority>>,
}
pub struct Auth {
pub lookup: IfBlock<Option<Arc<Lookup>>>,
pub mechanisms: IfBlock<u64>,
pub require: IfBlock<bool>,
pub errors_max: IfBlock<usize>,
pub errors_wait: IfBlock<Duration>,
}
pub struct Mail {
pub script: IfBlock<Option<Arc<Sieve>>>,
}
pub struct Rcpt {
pub script: IfBlock<Option<Arc<Sieve>>>,
pub relay: IfBlock<bool>,
pub lookup_domains: IfBlock<Option<Arc<Lookup>>>,
pub lookup_addresses: IfBlock<Option<Arc<Lookup>>>,
pub lookup_expn: IfBlock<Option<Arc<Lookup>>>,
pub lookup_vrfy: IfBlock<Option<Arc<Lookup>>>,
// Errors
pub errors_max: IfBlock<usize>,
pub errors_wait: IfBlock<Duration>,
// Limits
pub max_recipients: IfBlock<usize>,
}
pub struct Data {
pub script: IfBlock<Option<Arc<Sieve>>>,
pub pipe_commands: Vec<Pipe>,
// Limits
pub max_messages: IfBlock<usize>,
pub max_message_size: IfBlock<usize>,
pub max_received_headers: IfBlock<usize>,
// Headers
pub add_received: IfBlock<bool>,
pub add_received_spf: IfBlock<bool>,
pub add_return_path: IfBlock<bool>,
pub add_auth_results: IfBlock<bool>,
pub add_message_id: IfBlock<bool>,
pub add_date: IfBlock<bool>,
}
pub struct Pipe {
pub command: IfBlock<Option<String>>,
pub arguments: IfBlock<Vec<String>>,
pub timeout: IfBlock<Duration>,
}
pub struct SessionConfig {
pub timeout: IfBlock<Duration>,
pub duration: IfBlock<Duration>,
pub transfer_limit: IfBlock<usize>,
pub throttle: SessionThrottle,
pub connect: Connect,
pub ehlo: Ehlo,
pub auth: Auth,
pub mail: Mail,
pub rcpt: Rcpt,
pub data: Data,
pub extensions: Extensions,
}
pub struct SessionThrottle {
pub connect: Vec<Throttle>,
pub mail_from: Vec<Throttle>,
pub rcpt_to: Vec<Throttle>,
}
pub struct RelayHost {
pub address: String,
pub port: u16,
pub protocol: ServerProtocol,
pub auth: Option<Credentials<String>>,
pub tls_implicit: bool,
pub tls_allow_invalid_certs: bool,
}
pub struct QueueConfig {
pub path: IfBlock<PathBuf>,
pub hash: IfBlock<u64>,
// Schedule
pub retry: IfBlock<Vec<Duration>>,
pub notify: IfBlock<Vec<Duration>>,
pub expire: IfBlock<Duration>,
// Outbound
pub hostname: IfBlock<String>,
pub next_hop: IfBlock<Option<RelayHost>>,
pub max_mx: IfBlock<usize>,
pub max_multihomed: IfBlock<usize>,
pub ip_strategy: IfBlock<IpLookupStrategy>,
pub source_ip: QueueOutboundSourceIp,
pub tls: QueueOutboundTls,
pub dsn: Dsn,
// Timeouts
pub timeout: QueueOutboundTimeout,
// Throttle and Quotas
pub throttle: QueueThrottle,
pub quota: QueueQuotas,
pub management_lookup: Arc<Lookup>,
}
pub struct QueueOutboundSourceIp {
pub ipv4: IfBlock<Vec<Ipv4Addr>>,
pub ipv6: IfBlock<Vec<Ipv6Addr>>,
}
pub struct ReportConfig {
pub path: IfBlock<PathBuf>,
pub hash: IfBlock<u64>,
pub submitter: IfBlock<String>,
pub analysis: ReportAnalysis,
pub dkim: Report,
pub spf: Report,
pub dmarc: Report,
pub dmarc_aggregate: AggregateReport,
pub tls: AggregateReport,
}
pub struct ReportAnalysis {
pub addresses: Vec<AddressMatch>,
pub forward: bool,
pub store: Option<PathBuf>,
pub report_id: AtomicU64,
}
pub enum AddressMatch {
StartsWith(String),
EndsWith(String),
Equals(String),
}
pub struct Dsn {
pub name: IfBlock<String>,
pub address: IfBlock<String>,
pub sign: IfBlock<Vec<Arc<DkimSigner>>>,
}
pub struct AggregateReport {
pub name: IfBlock<String>,
pub address: IfBlock<String>,
pub org_name: IfBlock<Option<String>>,
pub contact_info: IfBlock<Option<String>>,
pub send: IfBlock<AggregateFrequency>,
pub sign: IfBlock<Vec<Arc<DkimSigner>>>,
pub max_size: IfBlock<usize>,
}
pub struct Report {
pub name: IfBlock<String>,
pub address: IfBlock<String>,
pub subject: IfBlock<String>,
pub sign: IfBlock<Vec<Arc<DkimSigner>>>,
pub send: IfBlock<Option<Rate>>,
}
pub struct QueueOutboundTls {
pub dane: IfBlock<RequireOptional>,
pub mta_sts: IfBlock<RequireOptional>,
pub start: IfBlock<RequireOptional>,
}
pub struct QueueOutboundTimeout {
pub connect: IfBlock<Duration>,
pub greeting: IfBlock<Duration>,
pub tls: IfBlock<Duration>,
pub ehlo: IfBlock<Duration>,
pub mail: IfBlock<Duration>,
pub rcpt: IfBlock<Duration>,
pub data: IfBlock<Duration>,
pub mta_sts: IfBlock<Duration>,
}
#[derive(Debug)]
pub struct QueueThrottle {
pub sender: Vec<Throttle>,
pub rcpt: Vec<Throttle>,
pub host: Vec<Throttle>,
}
pub struct QueueQuotas {
pub sender: Vec<QueueQuota>,
pub rcpt: Vec<QueueQuota>,
pub rcpt_domain: Vec<QueueQuota>,
}
pub struct QueueQuota {
pub conditions: Conditions,
pub keys: u16,
pub size: Option<usize>,
pub messages: Option<usize>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum AggregateFrequency {
Hourly,
Daily,
Weekly,
#[default]
Never,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct TlsStrategy {
pub dane: RequireOptional,
pub mta_sts: RequireOptional,
pub tls: RequireOptional,
}
#[derive(Debug, Clone, Copy, Default)]
pub enum RequireOptional {
#[default]
Optional,
Require,
Disable,
}
pub struct MailAuthConfig {
pub dkim: DkimAuthConfig,
pub arc: ArcAuthConfig,
pub spf: SpfAuthConfig,
pub dmarc: DmarcAuthConfig,
pub iprev: IpRevAuthConfig,
pub dnsbl: DnsBlConfig,
}
pub enum DkimSigner {
RsaSha256(mail_auth::dkim::DkimSigner<RsaKey<Sha256>, Done>),
Ed25519Sha256(mail_auth::dkim::DkimSigner<Ed25519Key, Done>),
}
pub enum ArcSealer {
RsaSha256(mail_auth::arc::ArcSealer<RsaKey<Sha256>, Done>),
Ed25519Sha256(mail_auth::arc::ArcSealer<Ed25519Key, Done>),
}
pub struct DkimAuthConfig {
pub verify: IfBlock<VerifyStrategy>,
pub sign: IfBlock<Vec<Arc<DkimSigner>>>,
}
pub struct ArcAuthConfig {
pub verify: IfBlock<VerifyStrategy>,
pub seal: IfBlock<Option<Arc<ArcSealer>>>,
}
pub struct SpfAuthConfig {
pub verify_ehlo: IfBlock<VerifyStrategy>,
pub verify_mail_from: IfBlock<VerifyStrategy>,
}
pub struct DmarcAuthConfig {
pub verify: IfBlock<VerifyStrategy>,
}
pub struct IpRevAuthConfig {
pub verify: IfBlock<VerifyStrategy>,
}
pub struct DnsBlConfig {
pub verify: IfBlock<u32>,
pub ip_lookup: Vec<String>,
pub domain_lookup: Vec<String>,
}
pub const DNSBL_IP: u32 = 1;
pub const DNSBL_IPREV: u32 = 1 << 1;
pub const DNSBL_EHLO: u32 = 1 << 2;
pub const DNSBL_RETURN_PATH: u32 = 1 << 3;
pub const DNSBL_FROM: u32 = 1 << 4;
#[derive(Debug, Clone)]
pub struct DkimCanonicalization {
pub headers: Canonicalization,
pub body: Canonicalization,
}
#[derive(Debug, Clone, Copy, Default)]
pub enum VerifyStrategy {
#[default]
Relaxed,
Strict,
Disable,
}
#[derive(Default)]
pub struct ConfigContext {
pub servers: Vec<Server>,
pub hosts: AHashMap<String, Host>,
pub scripts: AHashMap<String, Arc<Sieve>>,
pub lookup: AHashMap<String, Arc<Lookup>>,
pub databases: AHashMap<String, SqlDatabase>,
pub signers: AHashMap<String, Arc<DkimSigner>>,
pub sealers: AHashMap<String, Arc<ArcSealer>>,
}
pub type Result<T> = std::result::Result<T, String>;

View file

@ -0,0 +1,457 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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::time::Duration;
use mail_send::Credentials;
use super::{
condition::ConfigCondition,
if_block::ConfigIf,
throttle::{ConfigThrottle, ParseTrottleKey},
*,
};
use utils::config::{
utils::{AsKey, ParseValue},
Config,
};
pub trait ConfigQueue {
fn parse_queue(&self, ctx: &ConfigContext) -> super::Result<QueueConfig>;
fn parse_queue_throttle(&self, ctx: &ConfigContext) -> super::Result<QueueThrottle>;
fn parse_queue_quota(&self, ctx: &ConfigContext) -> super::Result<QueueQuotas>;
fn parse_queue_quota_item(
&self,
prefix: impl AsKey,
ctx: &ConfigContext,
) -> super::Result<QueueQuota>;
}
impl ConfigQueue for Config {
fn parse_queue(&self, ctx: &ConfigContext) -> super::Result<QueueConfig> {
let rcpt_envelope_keys = [
EnvelopeKey::RecipientDomain,
EnvelopeKey::Sender,
EnvelopeKey::SenderDomain,
EnvelopeKey::Priority,
];
let sender_envelope_keys = [
EnvelopeKey::Sender,
EnvelopeKey::SenderDomain,
EnvelopeKey::Priority,
];
let mx_envelope_keys = [
EnvelopeKey::RecipientDomain,
EnvelopeKey::Sender,
EnvelopeKey::SenderDomain,
EnvelopeKey::Priority,
EnvelopeKey::Mx,
];
let host_envelope_keys = [
EnvelopeKey::RecipientDomain,
EnvelopeKey::Sender,
EnvelopeKey::SenderDomain,
EnvelopeKey::Priority,
EnvelopeKey::LocalIp,
EnvelopeKey::RemoteIp,
EnvelopeKey::Mx,
];
let next_hop = self
.parse_if_block::<Option<String>>("queue.outbound.next-hop", ctx, &rcpt_envelope_keys)?
.unwrap_or_else(|| IfBlock::new(None));
let default_hostname = self.value_require("server.hostname")?;
let config = QueueConfig {
path: self
.parse_if_block("queue.path", ctx, &sender_envelope_keys)?
.ok_or("Missing \"queue.path\" property.")?,
hash: self
.parse_if_block("queue.hash", ctx, &sender_envelope_keys)?
.unwrap_or_else(|| IfBlock::new(32)),
retry: self
.parse_if_block("queue.schedule.retry", ctx, &host_envelope_keys)?
.unwrap_or_else(|| {
IfBlock::new(vec![
Duration::from_secs(60),
Duration::from_secs(2 * 60),
Duration::from_secs(5 * 60),
Duration::from_secs(10 * 60),
Duration::from_secs(15 * 60),
Duration::from_secs(30 * 60),
Duration::from_secs(3600),
Duration::from_secs(2 * 3600),
])
}),
notify: self
.parse_if_block("queue.schedule.notify", ctx, &rcpt_envelope_keys)?
.unwrap_or_else(|| {
IfBlock::new(vec![
Duration::from_secs(86400),
Duration::from_secs(3 * 86400),
])
}),
expire: self
.parse_if_block("queue.schedule.expire", ctx, &rcpt_envelope_keys)?
.unwrap_or_else(|| IfBlock::new(Duration::from_secs(5 * 86400))),
hostname: self
.parse_if_block("queue.outbound.hostname", ctx, &sender_envelope_keys)?
.unwrap_or_else(|| IfBlock::new(default_hostname.to_string())),
max_mx: self
.parse_if_block("queue.outbound.limits.mx", ctx, &rcpt_envelope_keys)?
.unwrap_or_else(|| IfBlock::new(5)),
max_multihomed: self
.parse_if_block("queue.outbound.limits.multihomed", ctx, &rcpt_envelope_keys)?
.unwrap_or_else(|| IfBlock::new(2)),
ip_strategy: self
.parse_if_block("queue.outbound.ip-strategy", ctx, &sender_envelope_keys)?
.unwrap_or_else(|| IfBlock::new(IpLookupStrategy::Ipv4thenIpv6)),
source_ip: QueueOutboundSourceIp {
ipv4: self
.parse_if_block("queue.outbound.source-ip.v4", ctx, &mx_envelope_keys)?
.unwrap_or_else(|| IfBlock::new(Vec::new())),
ipv6: self
.parse_if_block("queue.outbound.source-ip.v6", ctx, &mx_envelope_keys)?
.unwrap_or_else(|| IfBlock::new(Vec::new())),
},
next_hop: next_hop.into_relay_host(ctx)?,
tls: QueueOutboundTls {
dane: self
.parse_if_block("queue.outbound.tls.dane", ctx, &mx_envelope_keys)?
.unwrap_or_else(|| IfBlock::new(RequireOptional::Optional)),
mta_sts: self
.parse_if_block("queue.outbound.tls.mta-sts", ctx, &rcpt_envelope_keys)?
.unwrap_or_else(|| IfBlock::new(RequireOptional::Optional)),
start: self
.parse_if_block("queue.outbound.tls.starttls", ctx, &mx_envelope_keys)?
.unwrap_or_else(|| IfBlock::new(RequireOptional::Optional)),
},
throttle: self.parse_queue_throttle(ctx)?,
quota: self.parse_queue_quota(ctx)?,
timeout: QueueOutboundTimeout {
connect: self
.parse_if_block("queue.outbound.timeouts.connect", ctx, &host_envelope_keys)?
.unwrap_or_else(|| IfBlock::new(Duration::from_secs(5 * 60))),
greeting: self
.parse_if_block("queue.outbound.timeouts.greeting", ctx, &host_envelope_keys)?
.unwrap_or_else(|| IfBlock::new(Duration::from_secs(5 * 60))),
tls: self
.parse_if_block("queue.outbound.timeouts.tls", ctx, &host_envelope_keys)?
.unwrap_or_else(|| IfBlock::new(Duration::from_secs(3 * 60))),
ehlo: self
.parse_if_block("queue.outbound.timeouts.ehlo", ctx, &host_envelope_keys)?
.unwrap_or_else(|| IfBlock::new(Duration::from_secs(5 * 60))),
mail: self
.parse_if_block(
"queue.outbound.timeouts.mail-from",
ctx,
&host_envelope_keys,
)?
.unwrap_or_else(|| IfBlock::new(Duration::from_secs(5 * 60))),
rcpt: self
.parse_if_block("queue.outbound.timeouts.rcpt-to", ctx, &host_envelope_keys)?
.unwrap_or_else(|| IfBlock::new(Duration::from_secs(5 * 60))),
data: self
.parse_if_block("queue.outbound.timeouts.data", ctx, &host_envelope_keys)?
.unwrap_or_else(|| IfBlock::new(Duration::from_secs(10 * 60))),
mta_sts: self
.parse_if_block("queue.outbound.timeouts.mta-sts", ctx, &rcpt_envelope_keys)?
.unwrap_or_else(|| IfBlock::new(Duration::from_secs(10 * 60))),
},
dsn: Dsn {
name: self
.parse_if_block("report.dsn.from-name", ctx, &sender_envelope_keys)?
.unwrap_or_else(|| IfBlock::new("Mail Delivery Subsystem".to_string())),
address: self
.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)?
.unwrap_or_default()
.map_if_block(&ctx.signers, "report.dsn.sign", "signature")?,
},
management_lookup: if let Some(lookup) = self.value("management.auth.lookup") {
ctx.lookup
.get(lookup)
.ok_or_else(|| {
format!("Lookup {lookup:?} not found for key \"management.auth.lookup\".")
})?
.clone()
} else {
Arc::new(Lookup::default())
},
};
if config.retry.has_empty_list() {
Err("Property \"queue.schedule.retry\" cannot contain empty lists.".to_string())
} else if config.notify.has_empty_list() {
Err("Property \"queue.schedule.notify\" cannot contain empty lists.".to_string())
} else {
Ok(config)
}
}
fn parse_queue_throttle(&self, ctx: &ConfigContext) -> super::Result<QueueThrottle> {
// Parse throttle
let mut throttle = QueueThrottle {
sender: Vec::new(),
rcpt: Vec::new(),
host: Vec::new(),
};
let envelope_keys = [
EnvelopeKey::RecipientDomain,
EnvelopeKey::Sender,
EnvelopeKey::SenderDomain,
EnvelopeKey::Priority,
EnvelopeKey::Mx,
EnvelopeKey::RemoteIp,
EnvelopeKey::LocalIp,
];
let all_throttles = self.parse_throttle(
"queue.throttle",
ctx,
&envelope_keys,
THROTTLE_RCPT_DOMAIN
| THROTTLE_SENDER
| THROTTLE_SENDER_DOMAIN
| THROTTLE_MX
| THROTTLE_REMOTE_IP
| THROTTLE_LOCAL_IP,
)?;
for t in all_throttles {
if (t.keys & (THROTTLE_MX | THROTTLE_REMOTE_IP | THROTTLE_LOCAL_IP)) != 0
|| t.conditions.conditions.iter().any(|c| {
matches!(
c,
Condition::Match {
key: EnvelopeKey::Mx | EnvelopeKey::RemoteIp | EnvelopeKey::LocalIp,
..
}
)
})
{
throttle.host.push(t);
} else if (t.keys & (THROTTLE_RCPT_DOMAIN)) != 0
|| t.conditions.conditions.iter().any(|c| {
matches!(
c,
Condition::Match {
key: EnvelopeKey::RecipientDomain,
..
}
)
})
{
throttle.rcpt.push(t);
} else {
throttle.sender.push(t);
}
}
Ok(throttle)
}
fn parse_queue_quota(&self, ctx: &ConfigContext) -> super::Result<QueueQuotas> {
let mut capacities = QueueQuotas {
sender: Vec::new(),
rcpt: Vec::new(),
rcpt_domain: Vec::new(),
};
for array_pos in self.sub_keys("queue.quota") {
let quota = self.parse_queue_quota_item(("queue.quota", array_pos), ctx)?;
if (quota.keys & THROTTLE_RCPT) != 0
|| quota.conditions.conditions.iter().any(|c| {
matches!(
c,
Condition::Match {
key: EnvelopeKey::Recipient,
..
}
)
})
{
capacities.rcpt.push(quota);
} else if (quota.keys & THROTTLE_RCPT_DOMAIN) != 0
|| quota.conditions.conditions.iter().any(|c| {
matches!(
c,
Condition::Match {
key: EnvelopeKey::RecipientDomain,
..
}
)
})
{
capacities.rcpt_domain.push(quota);
} else {
capacities.sender.push(quota);
}
}
Ok(capacities)
}
fn parse_queue_quota_item(
&self,
prefix: impl AsKey,
ctx: &ConfigContext,
) -> super::Result<QueueQuota> {
let prefix = prefix.as_key();
let mut keys = 0;
for (key_, value) in self.values((&prefix, "key")) {
let key = value.parse_throttle_key(key_)?;
if (key
& (THROTTLE_RCPT_DOMAIN | THROTTLE_RCPT | THROTTLE_SENDER | THROTTLE_SENDER_DOMAIN))
!= 0
{
keys |= key;
} else {
return Err(format!(
"Key {value:?} is not available in this context for property {key_:?}"
));
}
}
let quota = QueueQuota {
conditions: if self.values((&prefix, "match")).next().is_some() {
self.parse_condition(
(&prefix, "match"),
ctx,
&[
EnvelopeKey::Recipient,
EnvelopeKey::RecipientDomain,
EnvelopeKey::Sender,
EnvelopeKey::SenderDomain,
EnvelopeKey::Priority,
],
)?
} else {
Conditions {
conditions: Vec::with_capacity(0),
}
},
keys,
size: self
.property::<usize>((prefix.as_str(), "size"))?
.filter(|&v| v > 0),
messages: self
.property::<usize>((prefix.as_str(), "messages"))?
.filter(|&v| v > 0),
};
// Validate
if quota.size.is_none() && quota.messages.is_none() {
Err(format!(
concat!(
"Queue quota {:?} needs to define a ",
"valid 'size' and/or 'messages' property."
),
prefix
))
} else {
Ok(quota)
}
}
}
impl IfBlock<Option<String>> {
pub fn into_relay_host(self, ctx: &ConfigContext) -> super::Result<IfBlock<Option<RelayHost>>> {
Ok(IfBlock {
if_then: {
let mut if_then = Vec::with_capacity(self.if_then.len());
for i in self.if_then {
if_then.push(IfThen {
conditions: i.conditions,
then: if let Some(then) = i.then {
Some(
ctx.hosts
.get(&then)
.ok_or_else(|| {
format!(
"Host {then:?} not found for property \"queue.next-hop\".",
)
})?
.into(),
)
} else {
None
},
});
}
if_then
},
default: if let Some(default) = self.default {
Some(
ctx.hosts
.get(&default)
.ok_or_else(|| {
format!(
"Relay host {default:?} not found for property \"queue.next-hop\".",
)
})?
.into(),
)
} else {
None
},
})
}
}
impl From<&Host> for RelayHost {
fn from(host: &Host) -> Self {
RelayHost {
address: host.address.to_string(),
port: host.port,
protocol: host.protocol,
auth: if let (Some(username), Some(secret)) = (&host.username, &host.secret) {
Credentials::new(username.to_string(), secret.to_string()).into()
} else {
None
},
tls_implicit: host.tls_implicit,
tls_allow_invalid_certs: host.tls_allow_invalid_certs,
}
}
}
impl ParseValue for RequireOptional {
fn parse_value(key: impl AsKey, value: &str) -> super::Result<Self> {
match value {
"optional" => Ok(RequireOptional::Optional),
"require" | "required" => Ok(RequireOptional::Require),
"disable" | "disabled" | "none" | "false" => Ok(RequireOptional::Disable),
_ => Err(format!(
"Invalid TLS option value {:?} for key {:?}.",
value,
key.as_key()
)),
}
}
}

View file

@ -0,0 +1,91 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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::{sync::Arc, time::Duration};
use tokio::sync::mpsc;
use utils::config::Config;
use crate::lookup::Lookup;
use super::{ConfigContext, Host};
pub trait ConfigHost {
fn parse_remote_hosts(&self, ctx: &mut ConfigContext) -> super::Result<()>;
fn parse_host(&self, id: &str) -> super::Result<Host>;
}
impl ConfigHost for Config {
fn parse_remote_hosts(&self, ctx: &mut ConfigContext) -> super::Result<()> {
for id in self.sub_keys("remote") {
let host = self.parse_host(id)?;
if host.lookup {
ctx.lookup.insert(
format!("remote/{id}"),
Arc::new(Lookup::Remote(host.channel_tx.clone().into())),
);
}
ctx.hosts.insert(id.to_string(), host);
}
Ok(())
}
fn parse_host(&self, id: &str) -> super::Result<Host> {
let (channel_tx, channel_rx) = mpsc::channel(1024);
Ok(Host {
address: self.property_require(("remote", id, "address"))?,
port: self.property_require(("remote", id, "port"))?,
protocol: self.property_require(("remote", id, "protocol"))?,
concurrency: self.property(("remote", id, "concurrency"))?.unwrap_or(10),
tls_implicit: self
.property(("remote", id, "tls.implicit"))?
.unwrap_or(true),
tls_allow_invalid_certs: self
.property(("remote", id, "tls.allow-invalid-certs"))?
.unwrap_or(false),
username: self.property(("remote", id, "auth.username"))?,
secret: self.property(("remote", id, "auth.secret"))?,
cache_entries: self
.property(("remote", id, "cache.entries"))?
.unwrap_or(1024),
cache_ttl_positive: self
.property(("remote", id, "cache.ttl.positive"))?
.unwrap_or(Duration::from_secs(86400)),
cache_ttl_negative: self
.property(("remote", id, "cache.ttl.positive"))?
.unwrap_or(Duration::from_secs(3600)),
timeout: self
.property(("remote", id, "timeout"))?
.unwrap_or(Duration::from_secs(60)),
max_errors: self.property(("remote", id, "limits.errors"))?.unwrap_or(3),
max_requests: self
.property(("remote", id, "limits.requests"))?
.unwrap_or(50),
channel_tx,
channel_rx,
lookup: self.property(("remote", id, "lookup"))?.unwrap_or(false),
})
}
}

View file

@ -0,0 +1,233 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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 super::{
if_block::ConfigIf, AddressMatch, AggregateFrequency, AggregateReport, ConfigContext,
EnvelopeKey, IfBlock, Report, ReportAnalysis, ReportConfig,
};
use utils::config::{
utils::{AsKey, ParseValue},
Config,
};
pub trait ConfigReport {
fn parse_reports(&self, ctx: &ConfigContext) -> super::Result<ReportConfig>;
fn parse_report(
&self,
ctx: &ConfigContext,
id: &str,
default_hostname: &str,
available_keys: &[EnvelopeKey],
) -> super::Result<Report>;
fn parse_aggregate_report(
&self,
ctx: &ConfigContext,
id: &str,
default_hostname: &str,
available_keys: &[EnvelopeKey],
) -> super::Result<AggregateReport>;
}
impl ConfigReport for Config {
fn parse_reports(&self, ctx: &ConfigContext) -> super::Result<ReportConfig> {
let sender_envelope_keys = [
EnvelopeKey::Sender,
EnvelopeKey::SenderDomain,
EnvelopeKey::Priority,
EnvelopeKey::AuthenticatedAs,
EnvelopeKey::Listener,
EnvelopeKey::RemoteIp,
EnvelopeKey::LocalIp,
];
let rcpt_envelope_keys = [
EnvelopeKey::Sender,
EnvelopeKey::SenderDomain,
EnvelopeKey::Priority,
EnvelopeKey::RemoteIp,
EnvelopeKey::LocalIp,
EnvelopeKey::RecipientDomain,
];
let mut addresses = Vec::new();
for address in self.properties::<AddressMatch>("report.analysis.addresses") {
addresses.push(address?.1);
}
let default_hostname = self.value_require("server.hostname")?;
Ok(ReportConfig {
dkim: self.parse_report(ctx, "dkim", default_hostname, &sender_envelope_keys)?,
spf: self.parse_report(ctx, "spf", default_hostname, &sender_envelope_keys)?,
dmarc: self.parse_report(ctx, "dmarc", default_hostname, &sender_envelope_keys)?,
dmarc_aggregate: self.parse_aggregate_report(
ctx,
"dmarc",
default_hostname,
&sender_envelope_keys,
)?,
tls: self.parse_aggregate_report(ctx, "tls", default_hostname, &rcpt_envelope_keys)?,
path: self
.parse_if_block("report.path", ctx, &sender_envelope_keys)?
.ok_or("Missing \"report.path\" property.")?,
submitter: self
.parse_if_block("report.submitter", ctx, &[EnvelopeKey::RecipientDomain])?
.unwrap_or_else(|| IfBlock::new(default_hostname.to_string())),
hash: self
.parse_if_block("report.hash", ctx, &sender_envelope_keys)?
.unwrap_or_else(|| IfBlock::new(32)),
analysis: ReportAnalysis {
addresses,
forward: self.property("report.analysis.forward")?.unwrap_or(false),
store: self.property("report.analysis.store")?,
report_id: 0.into(),
},
})
}
fn parse_report(
&self,
ctx: &ConfigContext,
id: &str,
default_hostname: &str,
available_keys: &[EnvelopeKey],
) -> super::Result<Report> {
Ok(Report {
name: self
.parse_if_block(("report", id, "from-name"), ctx, available_keys)?
.unwrap_or_else(|| IfBlock::new("Mail Delivery Subsystem".to_string())),
address: self
.parse_if_block(("report", id, "from-address"), ctx, available_keys)?
.unwrap_or_else(|| IfBlock::new(format!("MAILER-DAEMON@{default_hostname}"))),
subject: self
.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)?
.unwrap_or_default()
.map_if_block(&ctx.signers, &("report", id, "sign").as_key(), "signature")?,
send: self
.parse_if_block(("report", id, "send"), ctx, available_keys)?
.unwrap_or_default(),
})
}
fn parse_aggregate_report(
&self,
ctx: &ConfigContext,
id: &str,
default_hostname: &str,
available_keys: &[EnvelopeKey],
) -> super::Result<AggregateReport> {
let rcpt_envelope_keys = [EnvelopeKey::RecipientDomain];
Ok(AggregateReport {
name: self
.parse_if_block(
("report", id, "aggregate.from-name"),
ctx,
&rcpt_envelope_keys,
)?
.unwrap_or_else(|| {
IfBlock::new(format!("{} Aggregate Report", id.to_ascii_uppercase()))
}),
address: self
.parse_if_block(
("report", id, "aggregate.from-address"),
ctx,
&rcpt_envelope_keys,
)?
.unwrap_or_else(|| IfBlock::new(format!("noreply-{id}@{default_hostname}"))),
org_name: self
.parse_if_block(
("report", id, "aggregate.org-name"),
ctx,
&rcpt_envelope_keys,
)?
.unwrap_or_default(),
contact_info: self
.parse_if_block(
("report", id, "aggregate.contact-info"),
ctx,
&rcpt_envelope_keys,
)?
.unwrap_or_default(),
send: self
.parse_if_block(("report", id, "aggregate.send"), ctx, available_keys)?
.unwrap_or_default(),
sign: self
.parse_if_block::<Vec<String>>(
("report", id, "aggregate.sign"),
ctx,
&rcpt_envelope_keys,
)?
.unwrap_or_default()
.map_if_block(
&ctx.signers,
&("report", id, "aggregate.sign").as_key(),
"signature",
)?,
max_size: self
.parse_if_block(
("report", id, "aggregate.max-size"),
ctx,
&rcpt_envelope_keys,
)?
.unwrap_or_else(|| IfBlock::new(25 * 1024 * 1024)),
})
}
}
impl ParseValue for AggregateFrequency {
fn parse_value(key: impl AsKey, value: &str) -> super::Result<Self> {
match value {
"daily" | "day" => Ok(AggregateFrequency::Daily),
"hourly" | "hour" => Ok(AggregateFrequency::Hourly),
"weekly" | "week" => Ok(AggregateFrequency::Weekly),
"never" | "disable" | "false" => Ok(AggregateFrequency::Never),
_ => Err(format!(
"Invalid aggregate frequency value {:?} for key {:?}.",
value,
key.as_key()
)),
}
}
}
impl ParseValue for AddressMatch {
fn parse_value(key: impl AsKey, value: &str) -> super::Result<Self> {
if let Some(value) = value.strip_prefix('*').map(|v| v.trim()) {
if !value.is_empty() {
return Ok(AddressMatch::EndsWith(value.to_lowercase()));
}
} else if let Some(value) = value.strip_suffix('*').map(|v| v.trim()) {
if !value.is_empty() {
return Ok(AddressMatch::StartsWith(value.to_lowercase()));
}
} else if value.contains('@') {
return Ok(AddressMatch::Equals(value.trim().to_lowercase()));
}
Err(format!(
"Invalid address match value {:?} for key {:?}.",
value,
key.as_key()
))
}
}

View file

@ -0,0 +1,103 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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 mail_auth::{
common::lru::{DnsCache, LruCache},
trust_dns_resolver::{
config::{ResolverConfig, ResolverOpts},
system_conf::read_system_conf,
},
Resolver,
};
use crate::{core::Resolvers, outbound::dane::DnssecResolver};
use utils::config::Config;
pub trait ConfigResolver {
fn build_resolvers(&self) -> super::Result<Resolvers>;
}
impl ConfigResolver for Config {
fn build_resolvers(&self) -> super::Result<Resolvers> {
let (config, mut opts) = match self.value_require("resolver.type")? {
"cloudflare" => (ResolverConfig::cloudflare(), ResolverOpts::default()),
"cloudflare-tls" => (ResolverConfig::cloudflare_tls(), ResolverOpts::default()),
"quad9" => (ResolverConfig::quad9(), ResolverOpts::default()),
"quad9-tls" => (ResolverConfig::quad9_tls(), ResolverOpts::default()),
"google" => (ResolverConfig::google(), ResolverOpts::default()),
"system" => read_system_conf()
.map_err(|err| format!("Failed to read system DNS config: {err}"))?,
other => return Err(format!("Unknown resolver type {other:?}.")),
};
if let Some(concurrency) = self.property("resolver.concurrency")? {
opts.num_concurrent_reqs = concurrency;
}
if let Some(timeout) = self.property("resolver.timeout")? {
opts.timeout = timeout;
}
if let Some(preserve) = self.property("resolver.preserve-intermediates")? {
opts.preserve_intermediates = preserve;
}
if let Some(try_tcp_on_error) = self.property("resolver.try-tcp-on-error")? {
opts.try_tcp_on_error = try_tcp_on_error;
}
if let Some(attempts) = self.property("resolver.attempts")? {
opts.attempts = attempts;
}
// Prepare DNSSEC resolver options
let config_dnssec = config.clone();
let mut opts_dnssec = opts;
opts_dnssec.validate = true;
let mut capacities = [1024usize; 5];
for (pos, key) in ["txt", "mx", "ipv4", "ipv6", "ptr"].into_iter().enumerate() {
if let Some(capacity) = self.property(("resolver.cache", key))? {
capacities[pos] = capacity;
}
}
Ok(Resolvers {
dns: Resolver::with_capacities(
config,
opts,
capacities[0],
capacities[1],
capacities[2],
capacities[3],
capacities[4],
)
.map_err(|err| format!("Failed to build DNS resolver: {err}"))?,
dnssec: DnssecResolver::with_capacity(config_dnssec, opts_dnssec)
.map_err(|err| format!("Failed to build DNSSEC resolver: {err}"))?,
cache: crate::core::DnsCache {
tlsa: LruCache::with_capacity(
self.property("resolver.cache.tlsa")?.unwrap_or(1024),
),
mta_sts: LruCache::with_capacity(
self.property("resolver.cache.mta-sts")?.unwrap_or(1024),
),
},
})
}
}

View file

@ -0,0 +1,152 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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::time::Duration;
use sieve::{compiler::grammar::Capability, Compiler, Runtime};
use crate::core::{SieveConfig, SieveCore};
use utils::config::{utils::AsKey, Config};
use super::ConfigContext;
pub trait ConfigSieve {
fn parse_sieve(&self, ctx: &mut ConfigContext) -> super::Result<SieveCore>;
}
impl ConfigSieve for Config {
fn parse_sieve(&self, ctx: &mut ConfigContext) -> super::Result<SieveCore> {
// Allocate compiler and runtime
let compiler = Compiler::new()
.with_max_string_size(52428800)
.with_max_string_size(10240)
.with_max_variable_name_size(100)
.with_max_nested_blocks(50)
.with_max_nested_tests(50)
.with_max_nested_foreverypart(10)
.with_max_local_variables(128)
.with_max_header_size(10240)
.with_max_includes(10);
let mut runtime = Runtime::new()
.without_capabilities([
Capability::FileInto,
Capability::Vacation,
Capability::VacationSeconds,
Capability::Fcc,
Capability::Mailbox,
Capability::MailboxId,
Capability::MboxMetadata,
Capability::ServerMetadata,
Capability::ImapSieve,
Capability::Duplicate,
])
.with_capability(Capability::Execute)
.with_max_variable_size(102400)
.with_max_header_size(10240)
.with_valid_notification_uri("mailto")
.with_valid_ext_lists(ctx.lookup.keys().map(|k| k.to_string()));
if let Some(value) = self.property("sieve.limits.redirects")? {
runtime.set_max_redirects(value);
}
if let Some(value) = self.property("sieve.limits.out-messages")? {
runtime.set_max_out_messages(value);
}
if let Some(value) = self.property("sieve.limits.cpu")? {
runtime.set_cpu_limit(value);
}
if let Some(value) = self.property("sieve.limits.nested-includes")? {
runtime.set_max_nested_includes(value);
}
if let Some(value) = self.property("sieve.limits.received-headers")? {
runtime.set_max_received_headers(value);
}
if let Some(value) = self.property::<Duration>("sieve.limits.duplicate-expiry")? {
runtime.set_default_duplicate_expiry(value.as_secs());
}
let hostname = if let Some(hostname) = self.value("sieve.hostname") {
hostname
} else {
self.value_require("server.hostname")?
};
runtime.set_local_hostname(hostname.to_string());
// Parse scripts
for id in self.sub_keys("sieve.scripts") {
let script = self.file_contents(("sieve.scripts", id))?;
ctx.scripts.insert(
id.to_string(),
compiler
.compile(&script)
.map_err(|err| format!("Failed to compile Sieve script {id:?}: {err}"))?
.into(),
);
}
// Parse DKIM signatures
let mut sign = Vec::new();
for (pos, id) in self.values("sieve.sign") {
if let Some(dkim) = ctx.signers.get(id) {
sign.push(dkim.clone());
} else {
return Err(format!(
"No DKIM signer found with id {:?} for key {:?}.",
id,
("sieve.sign", pos).as_key()
));
}
}
Ok(SieveCore {
runtime,
scripts: ctx.scripts.clone(),
lookup: ctx.lookup.clone(),
config: SieveConfig {
from_addr: self
.value("sieve.from-addr")
.map(|a| a.to_string())
.unwrap_or(format!("MAILER-DAEMON@{hostname}")),
from_name: self
.value("sieve.from-name")
.unwrap_or("Mailer Daemon")
.to_string(),
return_path: self
.value("sieve.return-path")
.unwrap_or_default()
.to_string(),
sign,
db: if let Some(db) = self.value("sieve.use-database") {
if let Some(db) = ctx.databases.get(db) {
Some(db.clone())
} else {
return Err(format!(
"Database {db:?} not found for key \"sieve.use-database\"."
));
}
} else {
None
},
},
})
}
}

View file

@ -0,0 +1,480 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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::time::Duration;
use smtp_proto::*;
use super::{if_block::ConfigIf, throttle::ConfigThrottle, *};
use utils::config::{
utils::{AsKey, ParseValue},
Config,
};
pub trait ConfigSession {
fn parse_session_config(&self, ctx: &ConfigContext) -> super::Result<SessionConfig>;
fn parse_session_throttle(&self, ctx: &ConfigContext) -> super::Result<SessionThrottle>;
fn parse_session_connect(&self, ctx: &ConfigContext) -> super::Result<Connect>;
fn parse_extensions(&self, ctx: &ConfigContext) -> super::Result<Extensions>;
fn parse_session_ehlo(&self, ctx: &ConfigContext) -> super::Result<Ehlo>;
fn parse_session_auth(&self, ctx: &ConfigContext) -> super::Result<Auth>;
fn parse_session_mail(&self, ctx: &ConfigContext) -> super::Result<Mail>;
fn parse_session_rcpt(&self, ctx: &ConfigContext) -> super::Result<Rcpt>;
fn parse_session_data(&self, ctx: &ConfigContext) -> super::Result<Data>;
fn parse_pipes(
&self,
ctx: &ConfigContext,
available_keys: &[EnvelopeKey],
) -> super::Result<Vec<Pipe>>;
}
impl ConfigSession for Config {
fn parse_session_config(&self, ctx: &ConfigContext) -> super::Result<SessionConfig> {
let available_keys = [
EnvelopeKey::Listener,
EnvelopeKey::RemoteIp,
EnvelopeKey::LocalIp,
];
Ok(SessionConfig {
duration: self
.parse_if_block("session.duration", ctx, &available_keys)?
.unwrap_or_else(|| IfBlock::new(Duration::from_secs(15 * 60))),
transfer_limit: self
.parse_if_block("session.transfer-limit", ctx, &available_keys)?
.unwrap_or_else(|| IfBlock::new(250 * 1024 * 1024)),
timeout: self
.parse_if_block::<Option<Duration>>("session.timeout", ctx, &available_keys)?
.unwrap_or_else(|| IfBlock::new(Some(Duration::from_secs(5 * 60))))
.try_unwrap("session.timeout")
.unwrap_or_else(|_| IfBlock::new(Duration::from_secs(5 * 60))),
throttle: self.parse_session_throttle(ctx)?,
connect: self.parse_session_connect(ctx)?,
ehlo: self.parse_session_ehlo(ctx)?,
auth: self.parse_session_auth(ctx)?,
mail: self.parse_session_mail(ctx)?,
rcpt: self.parse_session_rcpt(ctx)?,
data: self.parse_session_data(ctx)?,
extensions: self.parse_extensions(ctx)?,
})
}
fn parse_session_throttle(&self, ctx: &ConfigContext) -> super::Result<SessionThrottle> {
// Parse throttle
let mut throttle = SessionThrottle {
connect: Vec::new(),
mail_from: Vec::new(),
rcpt_to: Vec::new(),
};
let all_throttles = self.parse_throttle(
"session.throttle",
ctx,
&[
EnvelopeKey::Sender,
EnvelopeKey::SenderDomain,
EnvelopeKey::Recipient,
EnvelopeKey::RecipientDomain,
EnvelopeKey::AuthenticatedAs,
EnvelopeKey::Listener,
EnvelopeKey::RemoteIp,
EnvelopeKey::LocalIp,
EnvelopeKey::Priority,
EnvelopeKey::HeloDomain,
],
THROTTLE_LISTENER
| THROTTLE_REMOTE_IP
| THROTTLE_LOCAL_IP
| THROTTLE_AUTH_AS
| THROTTLE_HELO_DOMAIN
| THROTTLE_RCPT
| THROTTLE_RCPT_DOMAIN
| THROTTLE_SENDER
| THROTTLE_SENDER_DOMAIN,
)?;
for t in all_throttles {
if (t.keys & (THROTTLE_RCPT | THROTTLE_RCPT_DOMAIN)) != 0
|| t.conditions.conditions.iter().any(|c| {
matches!(
c,
Condition::Match {
key: EnvelopeKey::Recipient | EnvelopeKey::RecipientDomain,
..
}
)
})
{
throttle.rcpt_to.push(t);
} else if (t.keys
& (THROTTLE_SENDER
| THROTTLE_SENDER_DOMAIN
| THROTTLE_HELO_DOMAIN
| THROTTLE_AUTH_AS))
!= 0
|| t.conditions.conditions.iter().any(|c| {
matches!(
c,
Condition::Match {
key: EnvelopeKey::Sender
| EnvelopeKey::SenderDomain
| EnvelopeKey::HeloDomain
| EnvelopeKey::AuthenticatedAs,
..
}
)
})
{
throttle.mail_from.push(t);
} else {
throttle.connect.push(t);
}
}
Ok(throttle)
}
fn parse_session_connect(&self, ctx: &ConfigContext) -> super::Result<Connect> {
let available_keys = [
EnvelopeKey::Listener,
EnvelopeKey::RemoteIp,
EnvelopeKey::LocalIp,
];
Ok(Connect {
script: self
.parse_if_block::<Option<String>>("session.connect.script", ctx, &available_keys)?
.unwrap_or_default()
.map_if_block(&ctx.scripts, "session.connect.script", "script")?,
})
}
fn parse_extensions(&self, ctx: &ConfigContext) -> super::Result<Extensions> {
let available_keys = [
EnvelopeKey::Listener,
EnvelopeKey::RemoteIp,
EnvelopeKey::LocalIp,
EnvelopeKey::Sender,
EnvelopeKey::SenderDomain,
EnvelopeKey::AuthenticatedAs,
];
Ok(Extensions {
pipelining: self
.parse_if_block("session.extensions.pipelining", ctx, &available_keys)?
.unwrap_or_else(|| IfBlock::new(true)),
dsn: self
.parse_if_block("session.extensions.dsn", ctx, &available_keys)?
.unwrap_or_else(|| IfBlock::new(true)),
chunking: self
.parse_if_block("session.extensions.chunking", ctx, &available_keys)?
.unwrap_or_else(|| IfBlock::new(true)),
requiretls: self
.parse_if_block("session.extensions.requiretls", ctx, &available_keys)?
.unwrap_or_default(),
no_soliciting: self
.parse_if_block("session.extensions.no-soliciting", ctx, &available_keys)?
.unwrap_or_default(),
future_release: self
.parse_if_block("session.extensions.future-release", ctx, &available_keys)?
.unwrap_or_default(),
deliver_by: self
.parse_if_block("session.extensions.deliver-by", ctx, &available_keys)?
.unwrap_or_default(),
mt_priority: self
.parse_if_block("session.extensions.mt-priority", ctx, &available_keys)?
.unwrap_or_default(),
})
}
fn parse_session_ehlo(&self, ctx: &ConfigContext) -> super::Result<Ehlo> {
let available_keys = [
EnvelopeKey::Listener,
EnvelopeKey::RemoteIp,
EnvelopeKey::LocalIp,
];
Ok(Ehlo {
script: self
.parse_if_block::<Option<String>>("session.ehlo.script", ctx, &available_keys)?
.unwrap_or_default()
.map_if_block(&ctx.scripts, "session.ehlo.script", "script")?,
require: self
.parse_if_block("session.ehlo.require", ctx, &available_keys)?
.unwrap_or_else(|| IfBlock::new(true)),
reject_non_fqdn: self
.parse_if_block("session.ehlo.reject-non-fqdn", ctx, &available_keys)?
.unwrap_or_else(|| IfBlock::new(true)),
})
}
fn parse_session_auth(&self, ctx: &ConfigContext) -> super::Result<Auth> {
let available_keys = [
EnvelopeKey::Listener,
EnvelopeKey::RemoteIp,
EnvelopeKey::LocalIp,
EnvelopeKey::HeloDomain,
];
let mechanisms = self
.parse_if_block::<Vec<Mechanism>>("session.auth.mechanisms", ctx, &available_keys)?
.unwrap_or_default();
Ok(Auth {
lookup: self
.parse_if_block::<Option<String>>("session.auth.lookup", ctx, &available_keys)?
.unwrap_or_default()
.map_if_block(&ctx.lookup, "session.auth.lookup", "lookup list")?,
mechanisms: IfBlock {
if_then: mechanisms
.if_then
.into_iter()
.map(|i| IfThen {
conditions: i.conditions,
then: i.then.into_iter().fold(0, |acc, m| acc | m.mechanism),
})
.collect(),
default: mechanisms
.default
.into_iter()
.fold(0, |acc, m| acc | m.mechanism),
},
require: self
.parse_if_block("session.auth.require", ctx, &available_keys)?
.unwrap_or_else(|| IfBlock::new(false)),
errors_max: self
.parse_if_block("session.auth.errors.max", ctx, &available_keys)?
.unwrap_or_else(|| IfBlock::new(3)),
errors_wait: self
.parse_if_block("session.auth.errors.wait", ctx, &available_keys)?
.unwrap_or_else(|| IfBlock::new(Duration::from_secs(30))),
})
}
fn parse_session_mail(&self, ctx: &ConfigContext) -> super::Result<Mail> {
let available_keys = [
EnvelopeKey::AuthenticatedAs,
EnvelopeKey::Listener,
EnvelopeKey::RemoteIp,
EnvelopeKey::LocalIp,
EnvelopeKey::HeloDomain,
];
Ok(Mail {
script: self
.parse_if_block::<Option<String>>("session.mail.script", ctx, &available_keys)?
.unwrap_or_default()
.map_if_block(&ctx.scripts, "session.mail.script", "script")?,
})
}
fn parse_session_rcpt(&self, ctx: &ConfigContext) -> super::Result<Rcpt> {
let available_keys = [
EnvelopeKey::Sender,
EnvelopeKey::SenderDomain,
EnvelopeKey::AuthenticatedAs,
EnvelopeKey::Listener,
EnvelopeKey::RemoteIp,
EnvelopeKey::LocalIp,
EnvelopeKey::HeloDomain,
];
Ok(Rcpt {
script: self
.parse_if_block::<Option<String>>("session.rcpt.script", ctx, &available_keys)?
.unwrap_or_default()
.map_if_block(&ctx.scripts, "session.rcpt.script", "script")?,
relay: self
.parse_if_block("session.rcpt.relay", ctx, &available_keys)?
.unwrap_or_else(|| IfBlock::new(false)),
lookup_domains: self
.parse_if_block::<Option<String>>(
"session.rcpt.lookup.domains",
ctx,
&available_keys,
)?
.unwrap_or_default()
.map_if_block(&ctx.lookup, "session.rcpt.lookup.domains", "lookup list")?,
lookup_addresses: self
.parse_if_block::<Option<String>>(
"session.rcpt.lookup.addresses",
ctx,
&available_keys,
)?
.unwrap_or_default()
.map_if_block(&ctx.lookup, "session.rcpt.lookup.addresses", "lookup list")?,
lookup_expn: self
.parse_if_block::<Option<String>>("session.rcpt.lookup.expn", ctx, &available_keys)?
.unwrap_or_default()
.map_if_block(&ctx.lookup, "session.rcpt.lookup.expn", "lookup list")?,
lookup_vrfy: self
.parse_if_block::<Option<String>>("session.rcpt.lookup.vrfy", ctx, &available_keys)?
.unwrap_or_default()
.map_if_block(&ctx.lookup, "session.rcpt.lookup.vrfy", "lookup list")?,
errors_max: self
.parse_if_block("session.rcpt.errors.max", ctx, &available_keys)?
.unwrap_or_else(|| IfBlock::new(10)),
errors_wait: self
.parse_if_block("session.rcpt.errors.wait", ctx, &available_keys)?
.unwrap_or_else(|| IfBlock::new(Duration::from_secs(30))),
max_recipients: self
.parse_if_block("session.rcpt.max-recipients", ctx, &available_keys)?
.unwrap_or_else(|| IfBlock::new(100)),
})
}
fn parse_session_data(&self, ctx: &ConfigContext) -> super::Result<Data> {
let available_keys = [
EnvelopeKey::Sender,
EnvelopeKey::SenderDomain,
EnvelopeKey::AuthenticatedAs,
EnvelopeKey::Listener,
EnvelopeKey::RemoteIp,
EnvelopeKey::LocalIp,
EnvelopeKey::Priority,
EnvelopeKey::HeloDomain,
];
Ok(Data {
script: self
.parse_if_block::<Option<String>>("session.data.script", ctx, &available_keys)?
.unwrap_or_default()
.map_if_block(&ctx.scripts, "session.data.script", "script")?,
max_messages: self
.parse_if_block("session.data.limits.messages", ctx, &available_keys)?
.unwrap_or_else(|| IfBlock::new(10)),
max_message_size: self
.parse_if_block("session.data.limits.size", ctx, &available_keys)?
.unwrap_or_else(|| IfBlock::new(25 * 1024 * 1024)),
max_received_headers: self
.parse_if_block("session.data.limits.received-headers", ctx, &available_keys)?
.unwrap_or_else(|| IfBlock::new(50)),
add_received: self
.parse_if_block("session.data.add-headers.received", ctx, &available_keys)?
.unwrap_or_else(|| IfBlock::new(true)),
add_received_spf: self
.parse_if_block(
"session.data.add-headers.received-spf",
ctx,
&available_keys,
)?
.unwrap_or_else(|| IfBlock::new(true)),
add_return_path: self
.parse_if_block("session.data.add-headers.return-path", ctx, &available_keys)?
.unwrap_or_else(|| IfBlock::new(true)),
add_auth_results: self
.parse_if_block(
"session.data.add-headers.auth-results",
ctx,
&available_keys,
)?
.unwrap_or_else(|| IfBlock::new(true)),
add_message_id: self
.parse_if_block("session.data.add-headers.message-id", ctx, &available_keys)?
.unwrap_or_else(|| IfBlock::new(true)),
add_date: self
.parse_if_block("session.data.add-headers.date", ctx, &available_keys)?
.unwrap_or_else(|| IfBlock::new(true)),
pipe_commands: self.parse_pipes(ctx, &available_keys)?,
})
}
fn parse_pipes(
&self,
ctx: &ConfigContext,
available_keys: &[EnvelopeKey],
) -> super::Result<Vec<Pipe>> {
let mut pipes = Vec::new();
for id in self.sub_keys("session.data.pipe") {
pipes.push(Pipe {
command: self
.parse_if_block(("session.data.pipe", id, "command"), ctx, available_keys)?
.unwrap_or_default(),
arguments: self
.parse_if_block(("session.data.pipe", id, "arguments"), ctx, available_keys)?
.unwrap_or_default(),
timeout: self
.parse_if_block(("session.data.pipe", id, "timeout"), ctx, available_keys)?
.unwrap_or_else(|| IfBlock::new(Duration::from_secs(30))),
})
}
Ok(pipes)
}
}
struct Mechanism {
mechanism: u64,
}
impl ParseValue for Mechanism {
fn parse_value(key: impl AsKey, value: &str) -> super::Result<Self> {
Ok(Mechanism {
mechanism: match value.to_ascii_uppercase().as_str() {
"LOGIN" => AUTH_LOGIN,
"PLAIN" => AUTH_PLAIN,
"XOAUTH2" => AUTH_XOAUTH2,
"OAUTHBEARER" => AUTH_OAUTHBEARER,
/*"SCRAM-SHA-256-PLUS" => AUTH_SCRAM_SHA_256_PLUS,
"SCRAM-SHA-256" => AUTH_SCRAM_SHA_256,
"SCRAM-SHA-1-PLUS" => AUTH_SCRAM_SHA_1_PLUS,
"SCRAM-SHA-1" => AUTH_SCRAM_SHA_1,
"XOAUTH" => AUTH_XOAUTH,
"9798-M-DSA-SHA1" => AUTH_9798_M_DSA_SHA1,
"9798-M-ECDSA-SHA1" => AUTH_9798_M_ECDSA_SHA1,
"9798-M-RSA-SHA1-ENC" => AUTH_9798_M_RSA_SHA1_ENC,
"9798-U-DSA-SHA1" => AUTH_9798_U_DSA_SHA1,
"9798-U-ECDSA-SHA1" => AUTH_9798_U_ECDSA_SHA1,
"9798-U-RSA-SHA1-ENC" => AUTH_9798_U_RSA_SHA1_ENC,
"EAP-AES128" => AUTH_EAP_AES128,
"EAP-AES128-PLUS" => AUTH_EAP_AES128_PLUS,
"ECDH-X25519-CHALLENGE" => AUTH_ECDH_X25519_CHALLENGE,
"ECDSA-NIST256P-CHALLENGE" => AUTH_ECDSA_NIST256P_CHALLENGE,
"EXTERNAL" => AUTH_EXTERNAL,
"GS2-KRB5" => AUTH_GS2_KRB5,
"GS2-KRB5-PLUS" => AUTH_GS2_KRB5_PLUS,
"GSS-SPNEGO" => AUTH_GSS_SPNEGO,
"GSSAPI" => AUTH_GSSAPI,
"KERBEROS_V4" => AUTH_KERBEROS_V4,
"KERBEROS_V5" => AUTH_KERBEROS_V5,
"NMAS-SAMBA-AUTH" => AUTH_NMAS_SAMBA_AUTH,
"NMAS_AUTHEN" => AUTH_NMAS_AUTHEN,
"NMAS_LOGIN" => AUTH_NMAS_LOGIN,
"NTLM" => AUTH_NTLM,
"OAUTH10A" => AUTH_OAUTH10A,
"OPENID20" => AUTH_OPENID20,
"OTP" => AUTH_OTP,
"SAML20" => AUTH_SAML20,
"SECURID" => AUTH_SECURID,
"SKEY" => AUTH_SKEY,
"SPNEGO" => AUTH_SPNEGO,
"SPNEGO-PLUS" => AUTH_SPNEGO_PLUS,
"SXOVER-PLUS" => AUTH_SXOVER_PLUS,
"CRAM-MD5" => AUTH_CRAM_MD5,
"DIGEST-MD5" => AUTH_DIGEST_MD5,
"ANONYMOUS" => AUTH_ANONYMOUS,*/
_ => {
return Err(format!(
"Unsupported mechanism {:?} for property {:?}.",
value,
key.as_key()
))
}
},
})
}
}

View file

@ -0,0 +1,267 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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 super::{condition::ConfigCondition, *};
use utils::config::{
utils::{AsKey, ParseKey, ParseValue},
Config,
};
pub trait ConfigThrottle {
fn parse_throttle(
&self,
prefix: impl AsKey,
ctx: &ConfigContext,
available_envelope_keys: &[EnvelopeKey],
available_throttle_keys: u16,
) -> super::Result<Vec<Throttle>>;
fn parse_throttle_item(
&self,
prefix: impl AsKey,
ctx: &ConfigContext,
available_envelope_keys: &[EnvelopeKey],
available_throttle_keys: u16,
) -> super::Result<Throttle>;
}
impl ConfigThrottle for Config {
fn parse_throttle(
&self,
prefix: impl AsKey,
ctx: &ConfigContext,
available_envelope_keys: &[EnvelopeKey],
available_throttle_keys: u16,
) -> super::Result<Vec<Throttle>> {
let prefix_ = prefix.as_key();
let mut throttles = Vec::new();
for array_pos in self.sub_keys(prefix) {
throttles.push(self.parse_throttle_item(
(&prefix_, array_pos),
ctx,
available_envelope_keys,
available_throttle_keys,
)?);
}
Ok(throttles)
}
fn parse_throttle_item(
&self,
prefix: impl AsKey,
ctx: &ConfigContext,
available_envelope_keys: &[EnvelopeKey],
available_throttle_keys: u16,
) -> super::Result<Throttle> {
let prefix = prefix.as_key();
let mut keys = 0;
for (key_, value) in self.values((&prefix, "key")) {
let key = value.parse_throttle_key(key_)?;
if (key & available_throttle_keys) != 0 {
keys |= key;
} else {
return Err(format!(
"Throttle key {value:?} is not available in this context for property {key_:?}"
));
}
}
let throttle = Throttle {
conditions: if self.values((&prefix, "match")).next().is_some() {
self.parse_condition((&prefix, "match"), ctx, available_envelope_keys)?
} else {
Conditions {
conditions: Vec::with_capacity(0),
}
},
keys,
concurrency: self
.property::<u64>((prefix.as_str(), "concurrency"))?
.filter(|&v| v > 0),
rate: self
.property::<Rate>((prefix.as_str(), "rate"))?
.filter(|v| v.requests > 0),
};
// Validate
if throttle.rate.is_none() && throttle.concurrency.is_none() {
Err(format!(
concat!(
"Throttle {:?} needs to define a ",
"valid 'rate' and/or 'concurrency' property."
),
prefix
))
} else {
Ok(throttle)
}
}
}
impl ParseValue for Rate {
fn parse_value(key: impl AsKey, value: &str) -> super::Result<Self> {
if let Some((requests, period)) = value.split_once('/') {
Ok(Rate {
requests: requests
.trim()
.parse::<u64>()
.ok()
.and_then(|r| if r > 0 { Some(r) } else { None })
.ok_or_else(|| {
format!(
"Invalid rate value {:?} for property {:?}.",
value,
key.as_key()
)
})?,
period: period.parse_key(key)?,
})
} else if ["false", "none", "unlimited"].contains(&value) {
Ok(Rate::default())
} else {
Err(format!(
"Invalid rate value {:?} for property {:?}.",
value,
key.as_key()
))
}
}
}
impl ParseValue for EnvelopeKey {
fn parse_value(key: impl AsKey, value: &str) -> super::Result<Self> {
Ok(match value {
"rcpt" => EnvelopeKey::Recipient,
"rcpt-domain" => EnvelopeKey::RecipientDomain,
"sender" => EnvelopeKey::Sender,
"sender-domain" => EnvelopeKey::SenderDomain,
"listener" => EnvelopeKey::Listener,
"remote-ip" => EnvelopeKey::RemoteIp,
"local-ip" => EnvelopeKey::LocalIp,
"priority" => EnvelopeKey::Priority,
"authenticated-as" => EnvelopeKey::AuthenticatedAs,
"mx" => EnvelopeKey::Mx,
_ => {
return Err(format!(
"Invalid context key {:?} for property {:?}.",
value,
key.as_key()
))
}
})
}
}
pub trait ParseTrottleKey {
fn parse_throttle_key(&self, key: &str) -> super::Result<u16>;
}
impl ParseTrottleKey for &str {
fn parse_throttle_key(&self, key: &str) -> super::Result<u16> {
match *self {
"rcpt" => Ok(THROTTLE_RCPT),
"rcpt-domain" => Ok(THROTTLE_RCPT_DOMAIN),
"sender" => Ok(THROTTLE_SENDER),
"sender-domain" => Ok(THROTTLE_SENDER_DOMAIN),
"authenticated-as" => Ok(THROTTLE_AUTH_AS),
"listener" => Ok(THROTTLE_LISTENER),
"mx" => Ok(THROTTLE_MX),
"remote-ip" => Ok(THROTTLE_REMOTE_IP),
"local-ip" => Ok(THROTTLE_LOCAL_IP),
"helo-domain" => Ok(THROTTLE_HELO_DOMAIN),
_ => Err(format!("Invalid throttle key {self:?} found in {key:?}")),
}
}
}
#[cfg(test)]
mod tests {
use std::{fs, path::PathBuf, time::Duration};
use utils::config::Config;
use crate::config::{
throttle::ConfigThrottle, Condition, ConditionMatch, Conditions, ConfigContext,
EnvelopeKey, IpAddrMask, Rate, Throttle, THROTTLE_AUTH_AS, THROTTLE_REMOTE_IP,
THROTTLE_SENDER_DOMAIN,
};
#[test]
fn parse_throttle() {
let mut file = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
file.push("resources");
file.push("smtp");
file.push("config");
file.push("throttle.toml");
let available_keys = vec![
EnvelopeKey::Recipient,
EnvelopeKey::RecipientDomain,
EnvelopeKey::Sender,
EnvelopeKey::SenderDomain,
EnvelopeKey::AuthenticatedAs,
EnvelopeKey::Listener,
EnvelopeKey::RemoteIp,
EnvelopeKey::LocalIp,
EnvelopeKey::Priority,
];
let config = Config::parse(&fs::read_to_string(file).unwrap()).unwrap();
let context = ConfigContext::default();
let throttle = config
.parse_throttle("throttle", &context, &available_keys, u16::MAX)
.unwrap();
assert_eq!(
throttle,
vec![
Throttle {
conditions: Conditions {
conditions: vec![Condition::Match {
key: EnvelopeKey::RemoteIp,
value: ConditionMatch::IpAddrMask(IpAddrMask::V4 {
addr: "127.0.0.1".parse().unwrap(),
mask: u32::MAX
}),
not: false
}]
},
keys: THROTTLE_REMOTE_IP | THROTTLE_AUTH_AS,
concurrency: 100.into(),
rate: Rate {
requests: 50,
period: Duration::from_secs(30)
}
.into()
},
Throttle {
conditions: Conditions { conditions: vec![] },
keys: THROTTLE_SENDER_DOMAIN,
concurrency: 10000.into(),
rate: None
}
]
);
}
}

View file

@ -0,0 +1,296 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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::net::{IpAddr, Ipv4Addr};
use crate::config::{
Condition, ConditionMatch, Conditions, EnvelopeKey, IfBlock, IpAddrMask, StringMatch,
};
use super::Envelope;
impl<T: Default> IfBlock<T> {
pub async fn eval(&self, envelope: &impl Envelope) -> &T {
for if_then in &self.if_then {
if if_then.conditions.eval(envelope).await {
return &if_then.then;
}
}
&self.default
}
}
impl Conditions {
pub async fn eval(&self, envelope: &impl Envelope) -> bool {
let mut conditions = self.conditions.iter();
let mut matched = false;
while let Some(rule) = conditions.next() {
match rule {
Condition::Match { key, value, not } => {
matched = match value {
ConditionMatch::String(value) => {
let ctx_value = envelope.key_to_string(key);
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) => {
if let Some(result) =
lookup.contains(envelope.key_to_string(key).as_ref()).await
{
result
} else {
return false;
}
}
ConditionMatch::Regex(value) => {
value.is_match(envelope.key_to_string(key).as_ref())
}
} ^ not;
}
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();
}
}
}
}
}
matched
}
}
impl IpAddrMask {
pub fn matches(&self, remote: &IpAddr) -> bool {
match self {
IpAddrMask::V4 { addr, mask } => {
if *mask == u32::MAX {
match remote {
IpAddr::V4(remote) => addr == remote,
IpAddr::V6(remote) => {
if let Some(remote) = remote.to_ipv4_mapped() {
addr == &remote
} else {
false
}
}
}
} else {
u32::from_be_bytes(match remote {
IpAddr::V4(ip) => ip.octets(),
IpAddr::V6(ip) => {
if let Some(ip) = ip.to_ipv4() {
ip.octets()
} else {
return false;
}
}
}) & mask
== u32::from_be_bytes(addr.octets()) & mask
}
}
IpAddrMask::V6 { addr, mask } => {
if mask == &u128::MAX {
match remote {
IpAddr::V6(remote) => remote == addr,
IpAddr::V4(remote) => &remote.to_ipv6_mapped() == addr,
}
} else {
u128::from_be_bytes(match remote {
IpAddr::V6(ip) => ip.octets(),
IpAddr::V4(ip) => ip.to_ipv6_mapped().octets(),
}) & mask
== u128::from_be_bytes(addr.octets()) & mask
}
}
}
}
}
#[cfg(test)]
mod tests {
use std::{fs, net::IpAddr, path::PathBuf};
use utils::config::{Config, Server};
use crate::{
config::{condition::ConfigCondition, list::ConfigList, ConfigContext, IfBlock, IfThen},
core::Envelope,
};
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,
}
impl Envelope for TestEnvelope {
fn local_ip(&self) -> IpAddr {
self.local_ip
}
fn remote_ip(&self) -> IpAddr {
self.remote_ip
}
fn sender_domain(&self) -> &str {
self.sender_domain.as_str()
}
fn sender(&self) -> &str {
self.sender.as_str()
}
fn rcpt_domain(&self) -> &str {
self.rcpt_domain.as_str()
}
fn rcpt(&self) -> &str {
self.rcpt.as_str()
}
fn helo_domain(&self) -> &str {
self.helo_domain.as_str()
}
fn authenticated_as(&self) -> &str {
self.authenticated_as.as_str()
}
fn mx(&self) -> &str {
self.mx.as_str()
}
fn listener_id(&self) -> u16 {
self.listener_id
}
fn priority(&self) -> i16 {
self.priority
}
}
#[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 mut context = ConfigContext::default();
context.servers.push(Server {
id: "smtp".to_string(),
internal_id: 123,
..Default::default()
});
context.servers.push(Server {
id: "smtps".to_string(),
internal_id: 456,
..Default::default()
});
config.parse_lists(&mut context).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:?}"
);
}
}
}

View file

@ -0,0 +1,962 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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, fmt::Display, net::IpAddr, sync::Arc, time::Instant};
use http_body_util::{combinators::BoxBody, BodyExt, Empty, Full};
use hyper::{
body::{self, Bytes},
header::{self, AUTHORIZATION},
server::conn::http1,
service::service_fn,
Method, StatusCode,
};
use mail_parser::{decoders::base64::base64_decode, DateTime};
use mail_send::Credentials;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use tokio::{
io::{AsyncRead, AsyncWrite},
sync::oneshot,
};
use utils::listener::{limiter::InFlight, SessionManager};
use crate::{
lookup::{Item, LookupResult},
queue::{self, instant_to_timestamp, InstantFromTimestamp, QueueId, Status},
reporting::{
self,
scheduler::{ReportKey, ReportPolicy, ReportType, ReportValue},
},
};
use super::{Core, HttpAdminSessionManager};
#[derive(Debug)]
pub enum QueueRequest {
List {
from: Option<String>,
to: Option<String>,
before: Option<Instant>,
after: Option<Instant>,
result_tx: oneshot::Sender<Vec<u64>>,
},
Status {
queue_ids: Vec<QueueId>,
result_tx: oneshot::Sender<Vec<Option<Message>>>,
},
Cancel {
queue_ids: Vec<QueueId>,
item: Option<String>,
result_tx: oneshot::Sender<Vec<bool>>,
},
Retry {
queue_ids: Vec<QueueId>,
item: Option<String>,
time: Instant,
result_tx: oneshot::Sender<Vec<bool>>,
},
}
#[derive(Debug)]
pub enum ReportRequest {
List {
type_: Option<ReportType<(), ()>>,
domain: Option<String>,
result_tx: oneshot::Sender<Vec<String>>,
},
Status {
report_ids: Vec<ReportKey>,
result_tx: oneshot::Sender<Vec<Option<Report>>>,
},
Cancel {
report_ids: Vec<ReportKey>,
result_tx: oneshot::Sender<Vec<bool>>,
},
}
#[derive(Debug, Serialize)]
pub struct Response<T> {
data: T,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Message {
pub return_path: String,
pub domains: Vec<Domain>,
#[serde(deserialize_with = "deserialize_datetime")]
#[serde(serialize_with = "serialize_datetime")]
pub created: DateTime,
pub size: usize,
#[serde(skip_serializing_if = "is_zero")]
#[serde(default)]
pub priority: i16,
#[serde(skip_serializing_if = "Option::is_none")]
pub env_id: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Domain {
pub name: String,
pub status: Status<String, String>,
pub recipients: Vec<Recipient>,
pub retry_num: u32,
#[serde(deserialize_with = "deserialize_maybe_datetime")]
#[serde(serialize_with = "serialize_maybe_datetime")]
pub next_retry: Option<DateTime>,
#[serde(deserialize_with = "deserialize_maybe_datetime")]
#[serde(serialize_with = "serialize_maybe_datetime")]
pub next_notify: Option<DateTime>,
#[serde(deserialize_with = "deserialize_datetime")]
#[serde(serialize_with = "serialize_datetime")]
pub expires: DateTime,
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Recipient {
pub address: String,
pub status: Status<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub orcpt: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Report {
pub domain: String,
#[serde(rename = "type")]
pub type_: String,
#[serde(deserialize_with = "deserialize_datetime")]
#[serde(serialize_with = "serialize_datetime")]
pub range_from: DateTime,
#[serde(deserialize_with = "deserialize_datetime")]
#[serde(serialize_with = "serialize_datetime")]
pub range_to: DateTime,
pub size: usize,
}
impl SessionManager for HttpAdminSessionManager {
fn spawn(&self, session: utils::listener::SessionData<tokio::net::TcpStream>) {
let core = self.inner.clone();
tokio::spawn(async move {
if let Some(tls_acceptor) = &session.instance.tls_acceptor {
match tls_acceptor.accept(session.stream).await {
Ok(stream) => {
handle_request(stream, core, session.remote_ip, session.in_flight).await;
}
Err(err) => {
tracing::debug!(
context = "tls",
event = "error",
remote.ip = session.remote_ip.to_string(),
"Failed to accept TLS management connection: {}",
err
);
}
}
} else {
handle_request(session.stream, core, session.remote_ip, session.in_flight).await;
}
});
}
}
async fn handle_request(
stream: impl AsyncRead + AsyncWrite + Unpin + 'static,
core: Arc<Core>,
remote_addr: IpAddr,
_in_flight: InFlight,
) {
if let Err(http_err) = http1::Builder::new()
.keep_alive(true)
.serve_connection(
stream,
service_fn(|req: hyper::Request<body::Incoming>| {
let core = core.clone();
async move {
let response = core.parse_request(&req).await;
tracing::debug!(
context = "management",
event = "request",
remote.ip = remote_addr.to_string(),
uri = req.uri().to_string(),
status = match &response {
Ok(response) => response.status().to_string(),
Err(error) => error.to_string(),
}
);
response
}
}),
)
.await
{
tracing::debug!(
context = "management",
event = "http-error",
remote.ip = remote_addr.to_string(),
reason = %http_err,
);
}
}
impl Core {
async fn parse_request(
&self,
req: &hyper::Request<hyper::body::Incoming>,
) -> Result<hyper::Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
// Authenticate request
let mut is_authenticated = false;
if let Some((mechanism, payload)) = req
.headers()
.get(AUTHORIZATION)
.and_then(|h| h.to_str().ok())
.and_then(|h| h.trim().split_once(' '))
{
if mechanism.eq_ignore_ascii_case("basic") {
// Decode the base64 encoded credentials
if let Some((username, secret)) = base64_decode(payload.as_bytes())
.and_then(|token| String::from_utf8(token).ok())
.and_then(|token| {
token.split_once(':').map(|(login, secret)| {
(login.trim().to_lowercase(), secret.to_string())
})
})
{
match self
.queue
.config
.management_lookup
.lookup(Item::Authenticate(Credentials::Plain { username, secret }))
.await
{
Some(LookupResult::True) => {
is_authenticated = true;
}
Some(LookupResult::False) => {
tracing::debug!(
context = "management",
event = "auth-error",
"Invalid username or password."
);
}
_ => {
tracing::debug!(
context = "management",
event = "auth-error",
"Temporary authentication failure."
);
}
}
} else {
tracing::debug!(
context = "management",
event = "auth-error",
"Failed to decode base64 Authorization header."
);
}
} else {
tracing::debug!(
context = "management",
event = "auth-error",
mechanism = mechanism,
"Unsupported authentication mechanism."
);
}
}
if !is_authenticated {
return Ok(hyper::Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(header::WWW_AUTHENTICATE, "Basic realm=\"Stalwart SMTP\"")
.body(
Empty::<Bytes>::new()
.map_err(|never| match never {})
.boxed(),
)
.unwrap());
}
let mut path = req.uri().path().split('/');
path.next();
let (status, response) = match (req.method(), path.next(), path.next()) {
(&Method::GET, Some("queue"), Some("list")) => {
let mut from = None;
let mut to = None;
let mut before = None;
let mut after = None;
let mut error = None;
if let Some(query) = req.uri().query() {
for (key, value) in form_urlencoded::parse(query.as_bytes()) {
match key.as_ref() {
"from" => {
from = value.into_owned().into();
}
"to" => {
to = value.into_owned().into();
}
"after" => match value.parse_timestamp() {
Ok(dt) => {
after = dt.into();
}
Err(reason) => {
error = reason.into();
break;
}
},
"before" => match value.parse_timestamp() {
Ok(dt) => {
before = dt.into();
}
Err(reason) => {
error = reason.into();
break;
}
},
_ => {
error = format!("Invalid parameter {key:?}.").into();
break;
}
}
}
}
match error {
None => {
let (result_tx, result_rx) = oneshot::channel();
self.send_queue_event(
QueueRequest::List {
from,
to,
before,
after,
result_tx,
},
result_rx,
)
.await
}
Some(error) => error.into_bad_request(),
}
}
(&Method::GET, Some("queue"), Some("status")) => {
let mut queue_ids = Vec::new();
let mut error = None;
if let Some(query) = req.uri().query() {
for (key, value) in form_urlencoded::parse(query.as_bytes()) {
match key.as_ref() {
"id" | "ids" => match value.parse_queue_ids() {
Ok(ids) => {
queue_ids = ids;
}
Err(reason) => {
error = reason.into();
break;
}
},
_ => {
error = format!("Invalid parameter {key:?}.").into();
break;
}
}
}
}
match error {
None => {
let (result_tx, result_rx) = oneshot::channel();
self.send_queue_event(
QueueRequest::Status {
queue_ids,
result_tx,
},
result_rx,
)
.await
}
Some(error) => error.into_bad_request(),
}
}
(&Method::GET, Some("queue"), Some("retry")) => {
let mut queue_ids = Vec::new();
let mut time = Instant::now();
let mut item = None;
let mut error = None;
if let Some(query) = req.uri().query() {
for (key, value) in form_urlencoded::parse(query.as_bytes()) {
match key.as_ref() {
"id" | "ids" => match value.parse_queue_ids() {
Ok(ids) => {
queue_ids = ids;
}
Err(reason) => {
error = reason.into();
break;
}
},
"at" => match value.parse_timestamp() {
Ok(dt) => {
time = dt;
}
Err(reason) => {
error = reason.into();
break;
}
},
"filter" => {
item = value.into_owned().into();
}
_ => {
error = format!("Invalid parameter {key:?}.").into();
break;
}
}
}
}
match error {
None => {
let (result_tx, result_rx) = oneshot::channel();
self.send_queue_event(
QueueRequest::Retry {
queue_ids,
item,
time,
result_tx,
},
result_rx,
)
.await
}
Some(error) => error.into_bad_request(),
}
}
(&Method::GET, Some("queue"), Some("cancel")) => {
let mut queue_ids = Vec::new();
let mut item = None;
let mut error = None;
if let Some(query) = req.uri().query() {
for (key, value) in form_urlencoded::parse(query.as_bytes()) {
match key.as_ref() {
"id" | "ids" => match value.parse_queue_ids() {
Ok(ids) => {
queue_ids = ids;
}
Err(reason) => {
error = reason.into();
break;
}
},
"filter" => {
item = value.into_owned().into();
}
_ => {
error = format!("Invalid parameter {key:?}.").into();
break;
}
}
}
}
match error {
None => {
let (result_tx, result_rx) = oneshot::channel();
self.send_queue_event(
QueueRequest::Cancel {
queue_ids,
item,
result_tx,
},
result_rx,
)
.await
}
Some(error) => error.into_bad_request(),
}
}
(&Method::GET, Some("report"), Some("list")) => {
let mut domain = None;
let mut type_ = None;
let mut error = None;
if let Some(query) = req.uri().query() {
for (key, value) in form_urlencoded::parse(query.as_bytes()) {
match key.as_ref() {
"type" => match value.as_ref() {
"dmarc" => {
type_ = ReportType::Dmarc(()).into();
}
"tls" => {
type_ = ReportType::Tls(()).into();
}
_ => {
error = format!("Invalid report type {value:?}.").into();
break;
}
},
"domain" => {
domain = value.into_owned().into();
}
_ => {
error = format!("Invalid parameter {key:?}.").into();
break;
}
}
}
}
match error {
None => {
let (result_tx, result_rx) = oneshot::channel();
self.send_report_event(
ReportRequest::List {
type_,
domain,
result_tx,
},
result_rx,
)
.await
}
Some(error) => error.into_bad_request(),
}
}
(&Method::GET, Some("report"), Some("status")) => {
let mut report_ids = Vec::new();
let mut error = None;
if let Some(query) = req.uri().query() {
for (key, value) in form_urlencoded::parse(query.as_bytes()) {
match key.as_ref() {
"id" | "ids" => match value.parse_report_ids() {
Ok(ids) => {
report_ids = ids;
}
Err(reason) => {
error = reason.into();
break;
}
},
_ => {
error = format!("Invalid parameter {key:?}.").into();
break;
}
}
}
}
match error {
None => {
let (result_tx, result_rx) = oneshot::channel();
self.send_report_event(
ReportRequest::Status {
report_ids,
result_tx,
},
result_rx,
)
.await
}
Some(error) => error.into_bad_request(),
}
}
(&Method::GET, Some("report"), Some("cancel")) => {
let mut report_ids = Vec::new();
let mut error = None;
if let Some(query) = req.uri().query() {
for (key, value) in form_urlencoded::parse(query.as_bytes()) {
match key.as_ref() {
"id" | "ids" => match value.parse_report_ids() {
Ok(ids) => {
report_ids = ids;
}
Err(reason) => {
error = reason.into();
break;
}
},
_ => {
error = format!("Invalid parameter {key:?}.").into();
break;
}
}
}
}
match error {
None => {
let (result_tx, result_rx) = oneshot::channel();
self.send_report_event(
ReportRequest::Cancel {
report_ids,
result_tx,
},
result_rx,
)
.await
}
Some(error) => error.into_bad_request(),
}
}
_ => (
StatusCode::NOT_FOUND,
format!(
"{{\"error\": \"not-found\", \"details\": \"URL {} does not exist.\"}}",
req.uri().path()
),
),
};
Ok(hyper::Response::builder()
.status(status)
.header(header::CONTENT_TYPE, "application/json; charset=utf-8")
.body(
Full::new(Bytes::from(response))
.map_err(|never| match never {})
.boxed(),
)
.unwrap())
}
async fn send_queue_event<T: Serialize>(
&self,
request: QueueRequest,
rx: oneshot::Receiver<T>,
) -> (StatusCode, String) {
match self.queue.tx.send(queue::Event::Manage(request)).await {
Ok(_) => match rx.await {
Ok(result) => {
return (
StatusCode::OK,
serde_json::to_string(&Response { data: result }).unwrap_or_default(),
)
}
Err(_) => {
tracing::debug!(
context = "queue",
event = "recv-error",
reason = "Failed to receive manage request response."
);
}
},
Err(_) => {
tracing::debug!(
context = "queue",
event = "send-error",
reason = "Failed to send manage request event."
);
}
}
(
StatusCode::INTERNAL_SERVER_ERROR,
"{\"error\": \"internal-error\", \"details\": \"Resource unavailable, try again later.\"}"
.to_string(),
)
}
async fn send_report_event<T: Serialize>(
&self,
request: ReportRequest,
rx: oneshot::Receiver<T>,
) -> (StatusCode, String) {
match self.report.tx.send(reporting::Event::Manage(request)).await {
Ok(_) => match rx.await {
Ok(result) => {
return (
StatusCode::OK,
serde_json::to_string(&Response { data: result }).unwrap_or_default(),
)
}
Err(_) => {
tracing::debug!(
context = "queue",
event = "recv-error",
reason = "Failed to receive manage request response."
);
}
},
Err(_) => {
tracing::debug!(
context = "queue",
event = "send-error",
reason = "Failed to send manage request event."
);
}
}
(
StatusCode::INTERNAL_SERVER_ERROR,
"{\"error\": \"internal-error\", \"details\": \"Resource unavailable, try again later.\"}"
.to_string(),
)
}
}
impl From<&queue::Message> for Message {
fn from(message: &queue::Message) -> Self {
let now = Instant::now();
Message {
return_path: message.return_path.clone(),
created: DateTime::from_timestamp(message.created as i64),
size: message.size,
priority: message.priority,
env_id: message.env_id.clone(),
domains: message
.domains
.iter()
.enumerate()
.map(|(idx, domain)| Domain {
name: domain.domain.clone(),
status: match &domain.status {
Status::Scheduled => Status::Scheduled,
Status::Completed(_) => Status::Completed(String::new()),
Status::TemporaryFailure(status) => {
Status::TemporaryFailure(status.to_string())
}
Status::PermanentFailure(status) => {
Status::PermanentFailure(status.to_string())
}
},
retry_num: domain.retry.inner,
next_retry: if domain.retry.due > now {
DateTime::from_timestamp(instant_to_timestamp(now, domain.retry.due) as i64)
.into()
} else {
None
},
next_notify: if domain.notify.due > now {
DateTime::from_timestamp(
instant_to_timestamp(
now,
domain.notify.due,
)
as i64,
)
.into()
} else {
None
},
recipients: message
.recipients
.iter()
.filter(|rcpt| rcpt.domain_idx == idx)
.map(|rcpt| Recipient {
address: rcpt.address.clone(),
status: match &rcpt.status {
Status::Scheduled => Status::Scheduled,
Status::Completed(status) => {
Status::Completed(status.response.to_string())
}
Status::TemporaryFailure(status) => {
Status::TemporaryFailure(status.response.to_string())
}
Status::PermanentFailure(status) => {
Status::PermanentFailure(status.response.to_string())
}
},
orcpt: rcpt.orcpt.clone(),
})
.collect(),
expires: DateTime::from_timestamp(
instant_to_timestamp(now, domain.expires) as i64
),
})
.collect(),
}
}
}
impl From<(&ReportKey, &ReportValue)> for Report {
fn from((key, value): (&ReportKey, &ReportValue)) -> Self {
match (key, value) {
(ReportType::Dmarc(domain), ReportType::Dmarc(value)) => Report {
domain: domain.inner.clone(),
range_from: DateTime::from_timestamp(value.created as i64),
range_to: DateTime::from_timestamp(
(value.created + value.deliver_at.as_secs()) as i64,
),
size: value.size,
type_: "dmarc".to_string(),
},
(ReportType::Tls(domain), ReportType::Tls(value)) => Report {
domain: domain.clone(),
range_from: DateTime::from_timestamp(value.created as i64),
range_to: DateTime::from_timestamp(
(value.created + value.deliver_at.as_secs()) as i64,
),
size: value.size,
type_: "tls".to_string(),
},
_ => unreachable!(),
}
}
}
impl Display for ReportKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ReportType::Dmarc(policy) => write!(f, "d!{}!{}", policy.inner, policy.policy),
ReportType::Tls(domain) => write!(f, "t!{domain}"),
}
}
}
trait ParseValues {
fn parse_timestamp(&self) -> Result<Instant, String>;
fn parse_queue_ids(&self) -> Result<Vec<QueueId>, String>;
fn parse_report_ids(&self) -> Result<Vec<ReportKey>, String>;
}
impl ParseValues for Cow<'_, str> {
fn parse_timestamp(&self) -> Result<Instant, String> {
if let Some(dt) = DateTime::parse_rfc3339(self.as_ref()) {
let instant = (dt.to_timestamp() as u64).to_instant();
if instant >= Instant::now() {
return Ok(instant);
}
}
Err(format!("Invalid timestamp {self:?}."))
}
fn parse_queue_ids(&self) -> Result<Vec<QueueId>, String> {
let mut ids = Vec::new();
for id in self.split(',') {
if !id.is_empty() {
match id.parse() {
Ok(id) => {
ids.push(id);
}
Err(_) => {
return Err(format!("Failed to parse id {id:?}."));
}
}
}
}
Ok(ids)
}
fn parse_report_ids(&self) -> Result<Vec<ReportKey>, String> {
let mut ids = Vec::new();
for id in self.split(',') {
if !id.is_empty() {
let mut parts = id.split('!');
match (parts.next(), parts.next()) {
(Some("d"), Some(domain)) if !domain.is_empty() => {
if let Some(policy) = parts.next().and_then(|policy| policy.parse().ok()) {
ids.push(ReportType::Dmarc(ReportPolicy {
inner: domain.to_string(),
policy,
}));
continue;
}
}
(Some("t"), Some(domain)) if !domain.is_empty() => {
ids.push(ReportType::Tls(domain.to_string()));
continue;
}
_ => (),
}
return Err(format!("Failed to parse id {id:?}."));
}
}
Ok(ids)
}
}
trait BadRequest {
fn into_bad_request(self) -> (StatusCode, String);
}
impl BadRequest for String {
fn into_bad_request(self) -> (StatusCode, String) {
(
StatusCode::BAD_REQUEST,
format!(
"{{\"error\": \"bad-parameters\", \"details\": {}}}",
serde_json::to_string(&self).unwrap()
),
)
}
}
fn is_zero(num: &i16) -> bool {
*num == 0
}
fn serialize_maybe_datetime<S>(value: &Option<DateTime>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match value {
Some(value) => serializer.serialize_some(&value.to_rfc3339()),
None => serializer.serialize_none(),
}
}
fn deserialize_maybe_datetime<'de, D>(deserializer: D) -> Result<Option<DateTime>, D::Error>
where
D: Deserializer<'de>,
{
if let Some(value) = Option::<&str>::deserialize(deserializer)? {
if let Some(value) = DateTime::parse_rfc3339(value) {
Ok(Some(value))
} else {
Err(serde::de::Error::custom(
"Failed to parse RFC3339 timestamp",
))
}
} else {
Ok(None)
}
}
fn serialize_datetime<S>(value: &DateTime, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&value.to_rfc3339())
}
fn deserialize_datetime<'de, D>(deserializer: D) -> Result<DateTime, D::Error>
where
D: Deserializer<'de>,
{
if let Some(value) = DateTime::parse_rfc3339(<&str>::deserialize(deserializer)?) {
Ok(value)
} else {
Err(serde::de::Error::custom(
"Failed to parse RFC3339 timestamp",
))
}
}

350
crates/smtp/src/core/mod.rs Normal file
View file

@ -0,0 +1,350 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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,
hash::Hash,
net::IpAddr,
sync::{atomic::AtomicU32, Arc},
time::{Duration, Instant},
};
use ahash::AHashMap;
use dashmap::DashMap;
use mail_auth::{common::lru::LruCache, IprevOutput, Resolver, SpfOutput};
use sieve::{Runtime, Sieve};
use smtp_proto::request::receiver::{
BdatReceiver, DataReceiver, DummyDataReceiver, DummyLineReceiver, LineReceiver, RequestReceiver,
};
use tokio::{
io::{AsyncRead, AsyncWrite},
sync::mpsc,
};
use tokio_rustls::TlsConnector;
use tracing::Span;
use utils::listener::{limiter::InFlight, ServerInstance};
use crate::{
config::{
DkimSigner, EnvelopeKey, MailAuthConfig, QueueConfig, ReportConfig, SessionConfig,
VerifyStrategy,
},
inbound::auth::SaslToken,
lookup::{Lookup, SqlDatabase},
outbound::{
dane::{DnssecResolver, Tlsa},
mta_sts,
},
queue::{self, QuotaLimiter},
reporting,
};
use self::throttle::{Limiter, ThrottleKey, ThrottleKeyHasherBuilder};
pub mod if_block;
pub mod management;
pub mod params;
pub mod scripts;
pub mod throttle;
pub mod worker;
#[derive(Clone)]
pub struct SmtpSessionManager {
pub inner: Arc<Core>,
}
#[derive(Clone)]
pub struct HttpAdminSessionManager {
pub inner: Arc<Core>,
}
impl SmtpSessionManager {
pub fn new(inner: Arc<Core>) -> Self {
Self { inner }
}
}
impl HttpAdminSessionManager {
pub fn new(inner: Arc<Core>) -> Self {
Self { inner }
}
}
pub struct Core {
pub worker_pool: rayon::ThreadPool,
pub session: SessionCore,
pub queue: QueueCore,
pub resolvers: Resolvers,
pub mail_auth: MailAuthConfig,
pub report: ReportCore,
pub sieve: SieveCore,
}
pub struct SieveCore {
pub runtime: Runtime,
pub scripts: AHashMap<String, Arc<Sieve>>,
pub lookup: AHashMap<String, Arc<Lookup>>,
pub config: SieveConfig,
}
pub struct SieveConfig {
pub from_addr: String,
pub from_name: String,
pub return_path: String,
pub sign: Vec<Arc<DkimSigner>>,
pub db: Option<SqlDatabase>,
}
pub struct Resolvers {
pub dns: Resolver,
pub dnssec: DnssecResolver,
pub cache: DnsCache,
}
pub struct DnsCache {
pub tlsa: LruCache<String, Arc<Tlsa>>,
pub mta_sts: LruCache<String, Arc<mta_sts::Policy>>,
}
pub struct SessionCore {
pub config: SessionConfig,
pub throttle: DashMap<ThrottleKey, Limiter, ThrottleKeyHasherBuilder>,
}
pub struct QueueCore {
pub config: QueueConfig,
pub throttle: DashMap<ThrottleKey, Limiter, ThrottleKeyHasherBuilder>,
pub quota: DashMap<ThrottleKey, Arc<QuotaLimiter>, ThrottleKeyHasherBuilder>,
pub tx: mpsc::Sender<queue::Event>,
pub id_seq: AtomicU32,
pub connectors: TlsConnectors,
}
pub struct ReportCore {
pub config: ReportConfig,
pub tx: mpsc::Sender<reporting::Event>,
}
pub struct TlsConnectors {
pub pki_verify: TlsConnector,
pub dummy_verify: TlsConnector,
}
pub enum State {
Request(RequestReceiver),
Bdat(BdatReceiver),
Data(DataReceiver),
Sasl(LineReceiver<SaslToken>),
DataTooLarge(DummyDataReceiver),
RequestTooLarge(DummyLineReceiver),
None,
}
pub struct Session<T: AsyncWrite + AsyncRead> {
pub state: State,
pub instance: Arc<ServerInstance>,
pub core: Arc<Core>,
pub span: Span,
pub stream: T,
pub data: SessionData,
pub params: SessionParameters,
pub in_flight: Vec<InFlight>,
}
pub struct SessionData {
pub local_ip: IpAddr,
pub remote_ip: IpAddr,
pub helo_domain: String,
pub mail_from: Option<SessionAddress>,
pub rcpt_to: Vec<SessionAddress>,
pub rcpt_errors: usize,
pub message: Vec<u8>,
pub authenticated_as: String,
pub auth_errors: usize,
pub priority: i16,
pub delivery_by: i64,
pub future_release: u64,
pub valid_until: Instant,
pub bytes_left: usize,
pub messages_sent: usize,
pub iprev: Option<IprevOutput>,
pub spf_ehlo: Option<SpfOutput>,
pub spf_mail_from: Option<SpfOutput>,
pub dnsbl_error: Option<Vec<u8>>,
}
#[derive(Clone)]
pub struct SessionAddress {
pub address: String,
pub address_lcase: String,
pub domain: String,
pub flags: u64,
pub dsn_info: Option<String>,
}
#[derive(Debug, Default)]
pub struct SessionParameters {
// Global parameters
pub timeout: Duration,
// Ehlo parameters
pub ehlo_require: bool,
pub ehlo_reject_non_fqdn: bool,
// Auth parameters
pub auth_lookup: Option<Arc<Lookup>>,
pub auth_require: bool,
pub auth_errors_max: usize,
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_lookup_domain: Option<Arc<Lookup>>,
pub rcpt_lookup_addresses: Option<Arc<Lookup>>,
pub rcpt_lookup_expn: Option<Arc<Lookup>>,
pub rcpt_lookup_vrfy: Option<Arc<Lookup>>,
pub max_message_size: usize,
// Mail authentication parameters
pub iprev: VerifyStrategy,
pub spf_ehlo: VerifyStrategy,
pub spf_mail_from: VerifyStrategy,
pub dnsbl_policy: u32,
}
impl SessionData {
pub fn new(local_ip: IpAddr, remote_ip: IpAddr) -> Self {
SessionData {
local_ip,
remote_ip,
helo_domain: String::new(),
mail_from: None,
rcpt_to: Vec::new(),
authenticated_as: String::new(),
priority: 0,
valid_until: Instant::now(),
rcpt_errors: 0,
message: Vec::with_capacity(0),
auth_errors: 0,
messages_sent: 0,
bytes_left: 0,
delivery_by: 0,
future_release: 0,
iprev: None,
spf_ehlo: None,
spf_mail_from: None,
dnsbl_error: None,
}
}
}
impl Default for State {
fn default() -> Self {
State::Request(RequestReceiver::default())
}
}
pub trait Envelope {
fn local_ip(&self) -> IpAddr;
fn remote_ip(&self) -> IpAddr;
fn sender_domain(&self) -> &str;
fn sender(&self) -> &str;
fn rcpt_domain(&self) -> &str;
fn rcpt(&self) -> &str;
fn helo_domain(&self) -> &str;
fn authenticated_as(&self) -> &str;
fn mx(&self) -> &str;
fn listener_id(&self) -> u16;
fn priority(&self) -> i16;
#[inline(always)]
fn key_to_string(&self, key: &EnvelopeKey) -> Cow<'_, str> {
match key {
EnvelopeKey::Recipient => self.rcpt().into(),
EnvelopeKey::RecipientDomain => self.rcpt_domain().into(),
EnvelopeKey::Sender => self.sender().into(),
EnvelopeKey::SenderDomain => self.sender_domain().into(),
EnvelopeKey::Mx => self.mx().into(),
EnvelopeKey::AuthenticatedAs => self.authenticated_as().into(),
EnvelopeKey::HeloDomain => self.helo_domain().into(),
EnvelopeKey::Listener => self.listener_id().to_string().into(),
EnvelopeKey::RemoteIp => self.remote_ip().to_string().into(),
EnvelopeKey::LocalIp => self.local_ip().to_string().into(),
EnvelopeKey::Priority => self.priority().to_string().into(),
}
}
}
impl VerifyStrategy {
#[inline(always)]
pub fn verify(&self) -> bool {
matches!(self, VerifyStrategy::Strict | VerifyStrategy::Relaxed)
}
#[inline(always)]
pub fn is_strict(&self) -> bool {
matches!(self, VerifyStrategy::Strict)
}
}
impl PartialEq for SessionAddress {
fn eq(&self, other: &Self) -> bool {
self.address_lcase == other.address_lcase
}
}
impl Eq for SessionAddress {}
impl Hash for SessionAddress {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.address_lcase.hash(state);
}
}
impl Ord for SessionAddress {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
match self.domain.cmp(&other.domain) {
std::cmp::Ordering::Equal => self.address_lcase.cmp(&other.address_lcase),
order => order,
}
}
}
impl PartialOrd for SessionAddress {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
match self.domain.partial_cmp(&other.domain) {
Some(std::cmp::Ordering::Equal) => self.address_lcase.partial_cmp(&other.address_lcase),
order => order,
}
}
}

View file

@ -0,0 +1,85 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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 tokio::io::{AsyncRead, AsyncWrite};
use super::Session;
impl<T: AsyncRead + AsyncWrite> Session<T> {
pub async fn eval_session_params(&mut self) {
let c = &self.core.session.config;
self.data.bytes_left = *c.transfer_limit.eval(self).await;
self.data.valid_until += *c.duration.eval(self).await;
self.params.timeout = *c.timeout.eval(self).await;
self.params.spf_ehlo = *self.core.mail_auth.spf.verify_ehlo.eval(self).await;
self.params.spf_mail_from = *self.core.mail_auth.spf.verify_mail_from.eval(self).await;
self.params.iprev = *self.core.mail_auth.iprev.verify.eval(self).await;
self.params.dnsbl_policy = *self.core.mail_auth.dnsbl.verify.eval(self).await;
// Ehlo parameters
let ec = &self.core.session.config.ehlo;
self.params.ehlo_require = *ec.require.eval(self).await;
self.params.ehlo_reject_non_fqdn = *ec.reject_non_fqdn.eval(self).await;
// Auth parameters
let ac = &self.core.session.config.auth;
self.params.auth_lookup = ac.lookup.eval(self).await.clone();
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;
// VRFY/EXPN parameters
let rc = &self.core.session.config.rcpt;
self.params.rcpt_lookup_expn = rc.lookup_expn.eval(self).await.clone();
self.params.rcpt_lookup_vrfy = rc.lookup_vrfy.eval(self).await.clone();
}
pub async fn eval_post_auth_params(&mut self) {
// Refresh VRFY/EXPN parameters
let rc = &self.core.session.config.rcpt;
self.params.rcpt_lookup_expn = rc.lookup_expn.eval(self).await.clone();
self.params.rcpt_lookup_vrfy = rc.lookup_vrfy.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_lookup_domain = rc.lookup_domains.eval(self).await.clone();
self.params.rcpt_lookup_addresses = rc.lookup_addresses.eval(self).await.clone();
self.params.rcpt_dsn = *self.core.session.config.extensions.dsn.eval(self).await;
self.params.max_message_size = *self
.core
.session
.config
.data
.max_message_size
.eval(self)
.await;
}
}

View file

@ -0,0 +1,476 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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, process::Command, sync::Arc, time::Duration};
use ahash::AHashMap;
use mail_auth::common::headers::HeaderWriter;
use sieve::{
compiler::grammar::actions::action_redirect::{ByMode, ByTime, Notify, NotifyItem, Ret},
CommandType, Envelope, Event, Input, MatchAs, Recipient, Sieve,
};
use smtp_proto::{
MAIL_BY_NOTIFY, MAIL_BY_RETURN, MAIL_BY_TRACE, MAIL_RET_FULL, MAIL_RET_HDRS, RCPT_NOTIFY_DELAY,
RCPT_NOTIFY_FAILURE, RCPT_NOTIFY_NEVER, RCPT_NOTIFY_SUCCESS,
};
use tokio::{
io::{AsyncRead, AsyncWrite},
runtime::Handle,
};
use crate::{
lookup::Lookup,
queue::{DomainPart, InstantFromTimestamp, Message},
};
use super::{Core, Session};
pub enum ScriptResult {
Accept,
Replace(Vec<u8>),
Reject(String),
}
impl<T: AsyncWrite + AsyncRead + Unpin> Session<T> {
pub async fn run_script(
&self,
script: Arc<Sieve>,
message: Option<Arc<Vec<u8>>>,
) -> ScriptResult {
let core = self.core.clone();
let span = self.span.clone();
// Set environment variables
let mut vars_env: AHashMap<String, Cow<str>> = AHashMap::with_capacity(6);
vars_env.insert(
"remote_ip".to_string(),
self.data.remote_ip.to_string().into(),
);
vars_env.insert(
"helo_domain".to_string(),
self.data.helo_domain.clone().into(),
);
vars_env.insert(
"authenticated_as".to_string(),
self.data.authenticated_as.clone().into(),
);
// Set envelope
let envelope = if let Some(mail_from) = &self.data.mail_from {
let mut envelope: Vec<(Envelope, Cow<str>)> = Vec::with_capacity(6);
envelope.push((Envelope::From, mail_from.address.clone().into()));
if let Some(env_id) = &mail_from.dsn_info {
envelope.push((Envelope::Envid, env_id.clone().into()));
}
if let Some(rcpt) = self.data.rcpt_to.last() {
envelope.push((Envelope::To, rcpt.address.clone().into()));
if let Some(orcpt) = &rcpt.dsn_info {
envelope.push((Envelope::Orcpt, orcpt.clone().into()));
}
}
if (mail_from.flags & MAIL_RET_FULL) != 0 {
envelope.push((Envelope::Ret, "FULL".into()));
} else if (mail_from.flags & MAIL_RET_HDRS) != 0 {
envelope.push((Envelope::Ret, "HDRS".into()));
}
if (mail_from.flags & MAIL_BY_NOTIFY) != 0 {
envelope.push((Envelope::ByMode, "N".into()));
} else if (mail_from.flags & MAIL_BY_RETURN) != 0 {
envelope.push((Envelope::ByMode, "R".into()));
}
envelope
} else {
Vec::with_capacity(0)
};
let handle = Handle::current();
self.core
.spawn_worker(move || {
core.run_script_blocking(script, vars_env, envelope, message, handle, span)
})
.await
.unwrap_or(ScriptResult::Accept)
}
}
impl Core {
fn run_script_blocking(
&self,
script: Arc<Sieve>,
vars_env: AHashMap<String, Cow<'static, str>>,
envelope: Vec<(Envelope, Cow<'static, str>)>,
message: Option<Arc<Vec<u8>>>,
handle: Handle,
span: tracing::Span,
) -> ScriptResult {
// Create filter instance
let mut instance = self
.sieve
.runtime
.filter(message.as_deref().map_or(b"", |m| &m[..]))
.with_vars_env(vars_env)
.with_envelope_list(envelope)
.with_user_address(&self.sieve.config.from_addr)
.with_user_full_name(&self.sieve.config.from_name);
let mut input = Input::script("__script", script);
let mut messages: Vec<Vec<u8>> = Vec::new();
let mut reject_reason = None;
let mut keep_id = usize::MAX;
// Start event loop
while let Some(result) = instance.run(input) {
match result {
Ok(event) => match event {
Event::IncludeScript { name, optional } => {
if let Some(script) = self.sieve.scripts.get(name.as_str()) {
input = Input::script(name, script.clone());
} else if optional {
input = false.into();
} else {
tracing::warn!(
parent: &span,
context = "sieve",
event = "script-not-found",
script = name.as_str()
);
break;
}
}
Event::ListContains {
lists,
values,
match_as,
} => {
input = false.into();
'outer: for list in lists {
if let Some(list) = self.sieve.lookup.get(&list) {
for value in &values {
let result = if !matches!(match_as, MatchAs::Lowercase) {
handle.block_on(list.contains(value))
} else {
handle.block_on(list.contains(&value.to_lowercase()))
};
if let Some(true) = result {
input = true.into();
break 'outer;
}
}
} else {
tracing::debug!(
parent: &span,
context = "sieve",
event = "list-not-found",
list = list,
);
}
}
}
Event::Execute {
command_type,
command,
arguments,
} => match command_type {
CommandType::Query => {
if let Some(db) = &self.sieve.config.db {
if command
.as_bytes()
.get(..6)
.map_or(false, |q| q.eq_ignore_ascii_case(b"SELECT"))
{
input = handle
.block_on(db.exists(&command, arguments.into_iter()))
.unwrap_or(false)
.into();
} else {
input = handle
.block_on(db.execute(&command, arguments.into_iter()))
.into();
}
} else {
tracing::warn!(
parent: &span,
context = "sieve",
event = "config-error",
reason = "No database configured",
);
input = false.into();
}
}
CommandType::Binary => {
match Command::new(command).args(arguments).output() {
Ok(result) => {
input = result.status.success().into();
}
Err(err) => {
tracing::warn!(
parent: &span,
context = "sieve",
event = "execute-failed",
reason = %err,
);
input = false.into();
}
}
}
},
Event::Keep { message_id, .. } => {
keep_id = message_id;
input = true.into();
}
Event::Discard => {
reject_reason = "503 5.5.3 Message rejected.\r\n".to_string().into();
input = true.into();
}
Event::Reject { reason, .. } => {
reject_reason = reason.into();
input = true.into();
}
Event::SendMessage {
recipient,
notify,
return_of_content,
by_time,
message_id,
} => {
// Build message
let return_path_lcase = self.sieve.config.return_path.to_lowercase();
let return_path_domain = return_path_lcase.domain_part().to_string();
let mut message = Message::new_boxed(
self.sieve.config.return_path.clone(),
return_path_lcase,
return_path_domain,
);
match recipient {
Recipient::Address(rcpt) => {
handle.block_on(message.add_recipient(rcpt, &self.queue.config));
}
Recipient::Group(rcpt_list) => {
for rcpt in rcpt_list {
handle
.block_on(message.add_recipient(rcpt, &self.queue.config));
}
}
Recipient::List(list) => {
if let Some(list) = self.sieve.lookup.get(&list) {
match list.as_ref() {
Lookup::Local(items) => {
for rcpt in items {
handle.block_on(
message.add_recipient(rcpt, &self.queue.config),
);
}
}
Lookup::Sql(sql) => {
if let Some(items) = handle.block_on(sql.fetch_many(""))
{
for rcpt in items {
handle.block_on(
message.add_recipient(
rcpt,
&self.queue.config,
),
);
}
}
}
_ => (),
}
} else {
tracing::warn!(
parent: &span,
context = "sieve",
event = "send-failed",
reason = format!("Lookup {list:?} not found.")
);
}
}
}
// Set notify flags
let mut flags = 0;
match notify {
Notify::Never => {
flags = RCPT_NOTIFY_NEVER;
}
Notify::Items(items) => {
for item in items {
flags |= match item {
NotifyItem::Success => RCPT_NOTIFY_SUCCESS,
NotifyItem::Failure => RCPT_NOTIFY_FAILURE,
NotifyItem::Delay => RCPT_NOTIFY_DELAY,
};
}
}
Notify::Default => (),
}
if flags > 0 {
for rcpt in &mut message.recipients {
rcpt.flags |= flags;
}
}
// Set ByTime flags
match by_time {
ByTime::Relative {
rlimit,
mode,
trace,
} => {
if trace {
message.flags |= MAIL_BY_TRACE;
}
let rlimit = Duration::from_secs(rlimit);
match mode {
ByMode::Notify => {
for domain in &mut message.domains {
domain.notify.due += rlimit;
}
}
ByMode::Return => {
for domain in &mut message.domains {
domain.notify.due += rlimit;
}
}
ByMode::Default => (),
}
}
ByTime::Absolute {
alimit,
mode,
trace,
} => {
if trace {
message.flags |= MAIL_BY_TRACE;
}
let alimit = (alimit as u64).to_instant();
match mode {
ByMode::Notify => {
for domain in &mut message.domains {
domain.notify.due = alimit;
}
}
ByMode::Return => {
for domain in &mut message.domains {
domain.expires = alimit;
}
}
ByMode::Default => (),
}
}
ByTime::None => (),
};
// Set ret
match return_of_content {
Ret::Full => {
message.flags |= MAIL_RET_FULL;
}
Ret::Hdrs => {
message.flags |= MAIL_RET_HDRS;
}
Ret::Default => (),
}
// Queue message
if let Some(raw_message) = messages.get(message_id - 1) {
let headers = if !self.sieve.config.sign.is_empty() {
let mut headers = Vec::new();
for dkim in &self.sieve.config.sign {
match dkim.sign(raw_message) {
Ok(signature) => {
signature.write_header(&mut headers);
}
Err(err) => {
tracing::warn!(parent: &span,
context = "dkim",
event = "sign-failed",
reason = %err);
}
}
}
Some(headers)
} else {
None
};
handle.block_on(self.queue.queue_message(
message,
headers.as_deref(),
raw_message,
&span,
));
}
input = true.into();
}
Event::CreatedMessage { message, .. } => {
messages.push(message);
input = true.into();
}
unsupported => {
tracing::warn!(
parent: &span,
context = "sieve",
event = "runtime-error",
reason = format!("Unsupported event: {unsupported:?}")
);
break;
}
},
Err(err) => {
tracing::warn!(parent: &span,
context = "sieve",
event = "runtime-error",
reason = %err
);
break;
}
}
}
if keep_id == 0 {
ScriptResult::Accept
} else if let Some(mut reject_reason) = reject_reason {
if !reject_reason.ends_with('\n') {
reject_reason.push_str("\r\n");
}
let mut reject_bytes = reject_reason.as_bytes().iter();
if matches!(reject_bytes.next(), Some(ch) if ch.is_ascii_digit())
&& matches!(reject_bytes.next(), Some(ch) if ch.is_ascii_digit())
&& matches!(reject_bytes.next(), Some(ch) if ch.is_ascii_digit())
&& matches!(reject_bytes.next(), Some(ch) if ch == &b' ' )
{
ScriptResult::Reject(reject_reason)
} else {
ScriptResult::Reject(format!("503 5.5.3 {reject_reason}"))
}
} else {
messages
.into_iter()
.nth(keep_id - 1)
.map(ScriptResult::Replace)
.unwrap_or(ScriptResult::Accept)
}
}
}

View file

@ -0,0 +1,309 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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 ::utils::listener::limiter::{ConcurrencyLimiter, RateLimiter};
use dashmap::mapref::entry::Entry;
use tokio::io::{AsyncRead, AsyncWrite};
use std::{
hash::{BuildHasher, Hash, Hasher},
net::IpAddr,
};
use crate::config::*;
use super::{Envelope, Session};
#[derive(Debug)]
pub struct Limiter {
pub rate: Option<RateLimiter>,
pub concurrency: Option<ConcurrencyLimiter>,
}
#[derive(Debug, Clone, Eq)]
pub struct ThrottleKey {
hash: [u8; 32],
}
impl PartialEq for ThrottleKey {
fn eq(&self, other: &Self) -> bool {
self.hash == other.hash
}
}
impl Hash for ThrottleKey {
fn hash<H: Hasher>(&self, state: &mut H) {
self.hash.hash(state);
}
}
#[derive(Default)]
pub struct ThrottleKeyHasher {
hash: u64,
}
impl Hasher for ThrottleKeyHasher {
fn finish(&self) -> u64 {
self.hash
}
fn write(&mut self, bytes: &[u8]) {
self.hash = u64::from_ne_bytes((&bytes[..std::mem::size_of::<u64>()]).try_into().unwrap());
}
}
#[derive(Clone, Default)]
pub struct ThrottleKeyHasherBuilder {}
impl BuildHasher for ThrottleKeyHasherBuilder {
type Hasher = ThrottleKeyHasher;
fn build_hasher(&self) -> Self::Hasher {
ThrottleKeyHasher::default()
}
}
impl QueueQuota {
pub fn new_key(&self, e: &impl Envelope) -> ThrottleKey {
let mut hasher = blake3::Hasher::new();
if (self.keys & THROTTLE_RCPT) != 0 {
hasher.update(e.rcpt().as_bytes());
}
if (self.keys & THROTTLE_RCPT_DOMAIN) != 0 {
hasher.update(e.rcpt_domain().as_bytes());
}
if (self.keys & THROTTLE_SENDER) != 0 {
let sender = e.sender();
hasher.update(if !sender.is_empty() { sender } else { "<>" }.as_bytes());
}
if (self.keys & THROTTLE_SENDER_DOMAIN) != 0 {
let sender_domain = e.sender_domain();
hasher.update(
if !sender_domain.is_empty() {
sender_domain
} else {
"<>"
}
.as_bytes(),
);
}
if let Some(messages) = &self.messages {
hasher.update(&messages.to_ne_bytes()[..]);
}
if let Some(size) = &self.size {
hasher.update(&size.to_ne_bytes()[..]);
}
ThrottleKey {
hash: hasher.finalize().into(),
}
}
}
impl Throttle {
pub fn new_key(&self, e: &impl Envelope) -> ThrottleKey {
let mut hasher = blake3::Hasher::new();
if (self.keys & THROTTLE_RCPT) != 0 {
hasher.update(e.rcpt().as_bytes());
}
if (self.keys & THROTTLE_RCPT_DOMAIN) != 0 {
hasher.update(e.rcpt_domain().as_bytes());
}
if (self.keys & THROTTLE_SENDER) != 0 {
let sender = e.sender();
hasher.update(if !sender.is_empty() { sender } else { "<>" }.as_bytes());
}
if (self.keys & THROTTLE_SENDER_DOMAIN) != 0 {
let sender_domain = e.sender_domain();
hasher.update(
if !sender_domain.is_empty() {
sender_domain
} else {
"<>"
}
.as_bytes(),
);
}
if (self.keys & THROTTLE_HELO_DOMAIN) != 0 {
hasher.update(e.helo_domain().as_bytes());
}
if (self.keys & THROTTLE_AUTH_AS) != 0 {
hasher.update(e.authenticated_as().as_bytes());
}
if (self.keys & THROTTLE_LISTENER) != 0 {
hasher.update(&e.listener_id().to_ne_bytes()[..]);
}
if (self.keys & THROTTLE_MX) != 0 {
hasher.update(e.mx().as_bytes());
}
if (self.keys & THROTTLE_REMOTE_IP) != 0 {
match &e.remote_ip() {
IpAddr::V4(ip) => {
hasher.update(&ip.octets()[..]);
}
IpAddr::V6(ip) => {
hasher.update(&ip.octets()[..]);
}
}
}
if (self.keys & THROTTLE_LOCAL_IP) != 0 {
match &e.local_ip() {
IpAddr::V4(ip) => {
hasher.update(&ip.octets()[..]);
}
IpAddr::V6(ip) => {
hasher.update(&ip.octets()[..]);
}
}
}
if let Some(rate_limit) = &self.rate {
hasher.update(&rate_limit.period.as_secs().to_ne_bytes()[..]);
hasher.update(&rate_limit.requests.to_ne_bytes()[..]);
}
if let Some(concurrency) = &self.concurrency {
hasher.update(&concurrency.to_ne_bytes()[..]);
}
ThrottleKey {
hash: hasher.finalize().into(),
}
}
}
impl<T: AsyncRead + AsyncWrite> Session<T> {
pub async fn is_allowed(&mut self) -> bool {
let throttles = if !self.data.rcpt_to.is_empty() {
&self.core.session.config.throttle.rcpt_to
} else if self.data.mail_from.is_some() {
&self.core.session.config.throttle.mail_from
} else {
&self.core.session.config.throttle.connect
};
for t in throttles {
if t.conditions.conditions.is_empty() || t.conditions.eval(self).await {
if (t.keys & THROTTLE_RCPT_DOMAIN) != 0 {
let d = self
.data
.rcpt_to
.last()
.map(|r| r.domain.as_str())
.unwrap_or_default();
if self.data.rcpt_to.iter().filter(|p| p.domain == d).count() > 1 {
continue;
}
}
// Build throttle key
match self.core.session.throttle.entry(t.new_key(self)) {
Entry::Occupied(mut e) => {
let limiter = e.get_mut();
if let Some(limiter) = &limiter.concurrency {
if let Some(inflight) = limiter.is_allowed() {
self.in_flight.push(inflight);
} else {
tracing::debug!(
parent: &self.span,
context = "throttle",
event = "too-many-requests",
max_concurrent = limiter.max_concurrent,
"Too many concurrent requests."
);
return false;
}
}
if let Some(limiter) = &mut limiter.rate {
if !limiter.is_allowed() {
tracing::debug!(
parent: &self.span,
context = "throttle",
event = "rate-limit-exceeded",
max_requests = limiter.max_requests as u64,
max_interval = limiter.max_interval as u64,
"Rate limit exceeded."
);
return false;
}
}
}
Entry::Vacant(e) => {
let concurrency = t.concurrency.map(|concurrency| {
let limiter = ConcurrencyLimiter::new(concurrency);
if let Some(inflight) = limiter.is_allowed() {
self.in_flight.push(inflight);
}
limiter
});
let rate = t.rate.as_ref().map(|rate| {
let mut r = RateLimiter::new(
rate.requests,
std::cmp::min(rate.period.as_secs(), 1),
);
r.is_allowed();
r
});
e.insert(Limiter { rate, concurrency });
}
}
}
}
true
}
pub fn throttle_rcpt(&self, rcpt: &str, rate: &Rate, ctx: &str) -> bool {
let mut hasher = blake3::Hasher::new();
hasher.update(rcpt.as_bytes());
hasher.update(ctx.as_bytes());
hasher.update(&rate.period.as_secs().to_ne_bytes()[..]);
hasher.update(&rate.requests.to_ne_bytes()[..]);
let key = ThrottleKey {
hash: hasher.finalize().into(),
};
match self.core.session.throttle.entry(key) {
Entry::Occupied(mut e) => {
if let Some(limiter) = &mut e.get_mut().rate {
limiter.is_allowed()
} else {
false
}
}
Entry::Vacant(e) => {
let mut limiter = RateLimiter::new(rate.requests, rate.period.as_secs());
limiter.is_allowed();
e.insert(Limiter {
rate: limiter.into(),
concurrency: None,
});
true
}
}
}
}

View file

@ -0,0 +1,83 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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::sync::{atomic::Ordering, Arc};
use tokio::sync::oneshot;
use super::Core;
impl Core {
pub async fn spawn_worker<U, V>(&self, f: U) -> Option<V>
where
U: FnOnce() -> V + Send + 'static,
V: Sync + Send + 'static,
{
let (tx, rx) = oneshot::channel();
self.worker_pool.spawn(move || {
tx.send(f()).ok();
});
match rx.await {
Ok(result) => Some(result),
Err(err) => {
tracing::warn!(
context = "worker-pool",
event = "error",
reason = %err,
);
None
}
}
}
fn cleanup(&self) {
for throttle in [&self.session.throttle, &self.queue.throttle] {
throttle.retain(|_, v| {
v.concurrency
.as_ref()
.map_or(false, |c| c.concurrent.load(Ordering::Relaxed) > 0)
|| v.rate
.as_ref()
.map_or(false, |r| r.elapsed().as_secs_f64() < r.max_interval)
});
}
self.queue.quota.retain(|_, v| {
v.messages.load(Ordering::Relaxed) > 0 || v.size.load(Ordering::Relaxed) > 0
});
}
}
pub trait SpawnCleanup {
fn spawn_cleanup(&self);
}
impl SpawnCleanup for Arc<Core> {
fn spawn_cleanup(&self) {
let core = self.clone();
self.worker_pool.spawn(move || {
core.cleanup();
});
}
}

View file

@ -0,0 +1,237 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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 mail_parser::decoders::base64::base64_decode;
use mail_send::Credentials;
use smtp_proto::{IntoString, AUTH_LOGIN, AUTH_OAUTHBEARER, AUTH_PLAIN, AUTH_XOAUTH2};
use tokio::io::{AsyncRead, AsyncWrite};
use crate::{core::Session, lookup::Item};
pub struct SaslToken {
mechanism: u64,
credentials: Credentials<String>,
}
impl SaslToken {
pub fn from_mechanism(mechanism: u64) -> Option<SaslToken> {
match mechanism {
AUTH_PLAIN | AUTH_LOGIN => SaslToken {
mechanism,
credentials: Credentials::Plain {
username: String::new(),
secret: String::new(),
},
}
.into(),
AUTH_OAUTHBEARER => SaslToken {
mechanism,
credentials: Credentials::OAuthBearer {
token: String::new(),
},
}
.into(),
AUTH_XOAUTH2 => SaslToken {
mechanism,
credentials: Credentials::XOauth2 {
username: String::new(),
secret: String::new(),
},
}
.into(),
_ => None,
}
}
}
impl<T: AsyncWrite + AsyncRead + Unpin> Session<T> {
pub async fn handle_sasl_response(
&mut self,
token: &mut SaslToken,
response: &[u8],
) -> Result<bool, ()> {
if response.is_empty() {
match (token.mechanism, &token.credentials) {
(AUTH_PLAIN | AUTH_XOAUTH2 | AUTH_OAUTHBEARER, _) => {
self.write(b"334 Go ahead.\r\n").await?;
return Ok(true);
}
(AUTH_LOGIN, Credentials::Plain { username, secret }) => {
if username.is_empty() && secret.is_empty() {
self.write(b"334 VXNlciBOYW1lAA==\r\n").await?;
return Ok(true);
}
}
_ => (),
}
} else if let Some(response) = base64_decode(response) {
match (token.mechanism, &mut token.credentials) {
(AUTH_PLAIN, Credentials::Plain { username, secret }) => {
let mut b_username = Vec::new();
let mut b_secret = Vec::new();
let mut arg_num = 0;
for ch in response {
if ch != 0 {
if arg_num == 1 {
b_username.push(ch);
} else if arg_num == 2 {
b_secret.push(ch);
}
} else {
arg_num += 1;
}
}
match (String::from_utf8(b_username), String::from_utf8(b_secret)) {
(Ok(s_username), Ok(s_secret)) if !s_username.is_empty() => {
*username = s_username;
*secret = s_secret;
return self
.authenticate(std::mem::take(&mut token.credentials))
.await;
}
_ => (),
}
}
(AUTH_LOGIN, Credentials::Plain { username, secret }) => {
return if username.is_empty() {
*username = response.into_string();
self.write(b"334 UGFzc3dvcmQA\r\n").await?;
Ok(true)
} else {
*secret = response.into_string();
self.authenticate(std::mem::take(&mut token.credentials))
.await
};
}
(AUTH_OAUTHBEARER, Credentials::OAuthBearer { token: token_ }) => {
let response = response.into_string();
if response.contains("auth=") {
*token_ = response;
return self
.authenticate(std::mem::take(&mut token.credentials))
.await;
}
}
(AUTH_XOAUTH2, Credentials::XOauth2 { username, secret }) => {
let mut b_username = Vec::new();
let mut b_secret = Vec::new();
let mut arg_num = 0;
let mut in_arg = false;
for ch in response {
if in_arg {
if ch != 1 {
if arg_num == 1 {
b_username.push(ch);
} else if arg_num == 2 {
b_secret.push(ch);
}
} else {
in_arg = false;
}
} else if ch == b'=' {
arg_num += 1;
in_arg = true;
}
}
match (String::from_utf8(b_username), String::from_utf8(b_secret)) {
(Ok(s_username), Ok(s_secret)) if !s_username.is_empty() => {
*username = s_username;
*secret = s_secret;
return self
.authenticate(std::mem::take(&mut token.credentials))
.await;
}
_ => (),
}
}
_ => (),
}
}
self.auth_error(b"500 5.5.6 Invalid challenge.\r\n").await
}
pub async fn authenticate(&mut self, credentials: Credentials<String>) -> Result<bool, ()> {
if let Some(lookup) = &self.params.auth_lookup {
let authenticated_as = match &credentials {
Credentials::Plain { username, .. }
| Credentials::XOauth2 { username, .. }
| Credentials::OAuthBearer { token: username } => username.to_string(),
};
if let Some(is_authenticated) = lookup
.lookup(Item::Authenticate(credentials))
.await
.map(bool::from)
{
tracing::debug!(
parent: &self.span,
context = "auth",
event = "authenticate",
result = if is_authenticated {"success"} else {"failed"}
);
return if is_authenticated {
self.data.authenticated_as = authenticated_as;
self.eval_post_auth_params().await;
self.write(b"235 2.7.0 Authentication succeeded.\r\n")
.await?;
Ok(false)
} else {
self.auth_error(b"535 5.7.8 Authentication credentials invalid.\r\n")
.await
};
}
} else {
tracing::warn!(
parent: &self.span,
context = "auth",
event = "error",
"No lookup list configured for authentication."
);
}
self.write(b"454 4.7.0 Temporary authentication failure\r\n")
.await?;
Ok(false)
}
pub async fn auth_error(&mut self, response: &[u8]) -> Result<bool, ()> {
tokio::time::sleep(self.params.auth_errors_wait).await;
self.data.auth_errors += 1;
self.write(response).await?;
if self.data.auth_errors < self.params.auth_errors_max {
Ok(false)
} else {
self.write(b"421 4.3.0 Too many authentication errors, disconnecting.\r\n")
.await?;
tracing::debug!(
parent: &self.span,
event = "disconnect",
reason = "auth-errors",
"Too many authentication errors."
);
Err(())
}
}
}

View file

@ -0,0 +1,680 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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,
path::PathBuf,
process::Stdio,
sync::Arc,
time::{Duration, Instant, SystemTime},
};
use mail_auth::{
common::headers::HeaderWriter, dmarc, AuthenticatedMessage, AuthenticationResults, DkimResult,
DmarcResult, ReceivedSpf,
};
use mail_builder::headers::{date::Date, message_id::generate_message_id_header};
use smtp_proto::{
MAIL_BY_RETURN, RCPT_NOTIFY_DELAY, RCPT_NOTIFY_FAILURE, RCPT_NOTIFY_NEVER, RCPT_NOTIFY_SUCCESS,
};
use tokio::{
io::{AsyncRead, AsyncWrite, AsyncWriteExt},
process::Command,
};
use crate::{
config::DNSBL_FROM,
core::{scripts::ScriptResult, Session, SessionAddress},
queue::{self, DomainPart, Message, SimpleEnvelope},
reporting::analysis::AnalyzeReport,
};
use super::IsTls;
impl<T: AsyncWrite + AsyncRead + IsTls + Unpin> Session<T> {
pub async fn queue_message(&mut self) -> Cow<'static, [u8]> {
// Authenticate message
let raw_message = Arc::new(std::mem::take(&mut self.data.message));
let auth_message = if let Some(auth_message) = AuthenticatedMessage::parse(&raw_message) {
auth_message
} else {
tracing::info!(parent: &self.span,
context = "data",
event = "parse-failed",
size = raw_message.len());
return (&b"550 5.7.7 Failed to parse message.\r\n"[..]).into();
};
// Validate DNSBL
let from = auth_message.from();
let from_domain = from.domain_part();
if !from_domain.is_empty()
&& !self
.is_domain_dnsbl_allowed(from_domain, "from", DNSBL_FROM)
.await
{
return self.reset_dnsbl_error().unwrap().into();
}
// Loop detection
let dc = &self.core.session.config.data;
let ac = &self.core.mail_auth;
let rc = &self.core.report.config;
if auth_message.received_headers_count() > *dc.max_received_headers.eval(self).await {
tracing::info!(parent: &self.span,
context = "data",
event = "loop-detected",
return_path = self.data.mail_from.as_ref().unwrap().address,
from = auth_message.from(),
received_headers = auth_message.received_headers_count());
return (&b"450 4.4.6 Too many Received headers. Possible loop detected.\r\n"[..])
.into();
}
// Verify DKIM
let dkim = *ac.dkim.verify.eval(self).await;
let dmarc = *ac.dmarc.verify.eval(self).await;
let dkim_output = if dkim.verify() || dmarc.verify() {
let dkim_output = self.core.resolvers.dns.verify_dkim(&auth_message).await;
let rejected = dkim.is_strict()
&& !dkim_output
.iter()
.any(|d| matches!(d.result(), DkimResult::Pass));
// Send reports for failed signatures
if let Some(rate) = rc.dkim.send.eval(self).await {
for output in &dkim_output {
if let Some(rcpt) = output.failure_report_addr() {
self.send_dkim_report(rcpt, &auth_message, rate, rejected, output)
.await;
}
}
}
if rejected {
tracing::info!(parent: &self.span,
context = "dkim",
event = "failed",
return_path = self.data.mail_from.as_ref().unwrap().address,
from = auth_message.from(),
result = ?dkim_output.iter().map(|d| d.result().to_string()).collect::<Vec<_>>(),
"No passing DKIM signatures found.");
// 'Strict' mode violates the advice of Section 6.1 of RFC6376
return if dkim_output
.iter()
.any(|d| matches!(d.result(), DkimResult::TempError(_)))
{
(&b"451 4.7.20 No passing DKIM signatures found.\r\n"[..]).into()
} else {
(&b"550 5.7.20 No passing DKIM signatures found.\r\n"[..]).into()
};
} else {
tracing::debug!(parent: &self.span,
context = "dkim",
event = "verify",
return_path = self.data.mail_from.as_ref().unwrap().address,
from = auth_message.from(),
result = ?dkim_output.iter().map(|d| d.result().to_string()).collect::<Vec<_>>());
}
dkim_output
} else {
vec![]
};
// Verify ARC
let arc = *ac.arc.verify.eval(self).await;
let arc_sealer = ac.arc.seal.eval(self).await;
let arc_output = if arc.verify() || arc_sealer.is_some() {
let arc_output = self.core.resolvers.dns.verify_arc(&auth_message).await;
if arc.is_strict()
&& !matches!(arc_output.result(), DkimResult::Pass | DkimResult::None)
{
tracing::info!(parent: &self.span,
context = "arc",
event = "auth-failed",
return_path = self.data.mail_from.as_ref().unwrap().address,
from = auth_message.from(),
result = %arc_output.result(),
"ARC validation failed.");
return if matches!(arc_output.result(), DkimResult::TempError(_)) {
(&b"451 4.7.29 ARC validation failed.\r\n"[..]).into()
} else {
(&b"550 5.7.29 ARC validation failed.\r\n"[..]).into()
};
} else {
tracing::debug!(parent: &self.span,
context = "arc",
event = "verify",
return_path = self.data.mail_from.as_ref().unwrap().address,
from = auth_message.from(),
result = %arc_output.result());
}
arc_output.into()
} else {
None
};
// Build authentication results header
let mail_from = self.data.mail_from.as_ref().unwrap();
let mut auth_results = AuthenticationResults::new(&self.instance.hostname);
if !dkim_output.is_empty() {
auth_results = auth_results.with_dkim_results(&dkim_output, auth_message.from())
}
if let Some(spf_ehlo) = &self.data.spf_ehlo {
auth_results = auth_results.with_spf_ehlo_result(
spf_ehlo,
self.data.remote_ip,
&self.data.helo_domain,
);
}
if let Some(spf_mail_from) = &self.data.spf_mail_from {
auth_results = auth_results.with_spf_mailfrom_result(
spf_mail_from,
self.data.remote_ip,
&mail_from.address,
&self.data.helo_domain,
);
}
if let Some(iprev) = &self.data.iprev {
auth_results = auth_results.with_iprev_result(iprev, self.data.remote_ip);
}
// Verify DMARC
match &self.data.spf_mail_from {
Some(spf_output) if dmarc.verify() => {
let dmarc_output = self
.core
.resolvers
.dns
.verify_dmarc(
&auth_message,
&dkim_output,
if !mail_from.domain.is_empty() {
&mail_from.domain
} else {
&self.data.helo_domain
},
spf_output,
)
.await;
let rejected = dmarc.is_strict()
&& dmarc_output.policy() == dmarc::Policy::Reject
&& !(matches!(dmarc_output.spf_result(), DmarcResult::Pass)
|| matches!(dmarc_output.dkim_result(), DmarcResult::Pass));
let is_temp_fail = rejected
&& matches!(dmarc_output.spf_result(), DmarcResult::TempError(_))
|| matches!(dmarc_output.dkim_result(), DmarcResult::TempError(_));
// Add to DMARC output to the Authentication-Results header
auth_results = auth_results.with_dmarc_result(&dmarc_output);
if !rejected {
tracing::debug!(parent: &self.span,
context = "dmarc",
event = "verify",
return_path = mail_from.address,
from = auth_message.from(),
dkim_result = %dmarc_output.dkim_result(),
spf_result = %dmarc_output.spf_result());
} else {
tracing::info!(parent: &self.span,
context = "dmarc",
event = "auth-failed",
return_path = mail_from.address,
from = auth_message.from(),
dkim_result = %dmarc_output.dkim_result(),
spf_result = %dmarc_output.spf_result());
}
// Send DMARC report
if dmarc_output.requested_reports() {
self.send_dmarc_report(
&auth_message,
&auth_results,
rejected,
dmarc_output,
&dkim_output,
&arc_output,
)
.await;
}
if rejected {
return if is_temp_fail {
(&b"451 4.7.1 Email temporarily rejected per DMARC policy.\r\n"[..]).into()
} else {
(&b"550 5.7.1 Email rejected per DMARC policy.\r\n"[..]).into()
};
}
}
_ => (),
}
// Analyze reports
if self.is_report() {
self.core.analyze_report(raw_message.clone());
if !rc.analysis.forward {
self.data.messages_sent += 1;
return (b"250 2.0.0 Message queued for delivery.\r\n"[..]).into();
}
}
// Pipe message
let mut edited_message = None;
for pipe in &dc.pipe_commands {
if let Some(command_) = pipe.command.eval(self).await {
let piped_message = edited_message.as_ref().unwrap_or(&raw_message).clone();
let timeout = *pipe.timeout.eval(self).await;
let mut command = Command::new(command_);
for argument in pipe.arguments.eval(self).await {
command.arg(argument);
}
match command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.kill_on_drop(true)
.spawn()
{
Ok(mut child) => {
if let Some(mut stdin) = child.stdin.take() {
match tokio::time::timeout(timeout, stdin.write_all(&piped_message))
.await
{
Ok(Ok(_)) => {
drop(stdin);
match tokio::time::timeout(timeout, child.wait_with_output())
.await
{
Ok(Ok(output)) => {
if output.status.success()
&& !output.stdout.is_empty()
&& output.stdout[..] != piped_message[..]
{
edited_message = Arc::new(output.stdout).into();
}
tracing::debug!(parent: &self.span,
context = "pipe",
event = "success",
command = command_,
status = output.status.to_string());
}
Ok(Err(err)) => {
tracing::warn!(parent: &self.span,
context = "pipe",
event = "exec-error",
command = command_,
reason = %err);
}
Err(_) => {
tracing::warn!(parent: &self.span,
context = "pipe",
event = "timeout",
command = command_);
}
}
}
Ok(Err(err)) => {
tracing::warn!(parent: &self.span,
context = "pipe",
event = "write-error",
command = command_,
reason = %err);
}
Err(_) => {
tracing::warn!(parent: &self.span,
context = "pipe",
event = "stdin-timeout",
command = command_);
}
}
} else {
tracing::warn!(parent: &self.span,
context = "pipe",
event = "stdin-failed",
command = command_);
}
}
Err(err) => {
tracing::warn!(parent: &self.span,
context = "pipe",
event = "spawn-error",
command = command_,
reason = %err);
}
}
}
}
// Sieve filtering
if let Some(script) = dc.script.eval(self).await {
match self
.run_script(
script.clone(),
Some(edited_message.as_ref().unwrap_or(&raw_message).clone()),
)
.await
{
ScriptResult::Accept => (),
ScriptResult::Replace(new_message) => {
edited_message = Arc::new(new_message).into();
}
ScriptResult::Reject(message) => {
tracing::debug!(parent: &self.span,
context = "data",
event = "sieve-reject",
reason = message);
return message.into_bytes().into();
}
}
}
// Build message
let mail_from = self.data.mail_from.clone().unwrap();
let rcpt_to = std::mem::take(&mut self.data.rcpt_to);
let mut message = self.build_message(mail_from, rcpt_to).await;
// Add Received header
let mut headers = Vec::with_capacity(64);
if *dc.add_received.eval(self).await {
self.write_received(&mut headers, message.id)
}
// Add authentication results header
if *dc.add_auth_results.eval(self).await {
auth_results.write_header(&mut headers);
}
// Add Received-SPF header
if let Some(spf_output) = &self.data.spf_mail_from {
if *dc.add_received_spf.eval(self).await {
ReceivedSpf::new(
spf_output,
self.data.remote_ip,
&self.data.helo_domain,
&message.return_path,
&self.instance.hostname,
)
.write_header(&mut headers);
}
}
// ARC Seal
if let (Some(arc_sealer), Some(arc_output)) = (arc_sealer, &arc_output) {
if !dkim_output.is_empty() && arc_output.can_be_sealed() {
match arc_sealer.seal(&auth_message, &auth_results, arc_output) {
Ok(set) => {
set.write_header(&mut headers);
}
Err(err) => {
tracing::info!(parent: &self.span,
context = "arc",
event = "seal-failed",
return_path = message.return_path,
from = auth_message.from(),
"Failed to seal message: {}", err);
}
}
}
}
// Add any missing headers
if !auth_message.has_date_header() && *dc.add_date.eval(self).await {
headers.extend_from_slice(b"Date: ");
headers.extend_from_slice(Date::now().to_rfc822().as_bytes());
headers.extend_from_slice(b"\r\n");
}
if !auth_message.has_message_id_header() && *dc.add_message_id.eval(self).await {
headers.extend_from_slice(b"Message-ID: ");
let _ = generate_message_id_header(&mut headers, &self.instance.hostname);
headers.extend_from_slice(b"\r\n");
}
// Add Return-Path
if *dc.add_return_path.eval(self).await {
headers.extend_from_slice(b"Return-Path: <");
headers.extend_from_slice(message.return_path.as_bytes());
headers.extend_from_slice(b">\r\n");
}
// DKIM sign
let raw_message = edited_message.unwrap_or(raw_message);
for signer in ac.dkim.sign.eval(self).await.iter() {
match signer.sign_chained(&[headers.as_ref(), &raw_message]) {
Ok(signature) => {
signature.write_header(&mut headers);
}
Err(err) => {
tracing::info!(parent: &self.span,
context = "dkim",
event = "sign-failed",
return_path = message.return_path,
"Failed to sign message: {}", err);
}
}
}
// Update size
message.size = raw_message.len() + headers.len();
// Verify queue quota
if self.core.queue.has_quota(&mut message).await {
if self
.core
.queue
.queue_message(message, Some(&headers), &raw_message, &self.span)
.await
{
self.data.messages_sent += 1;
(b"250 2.0.0 Message queued for delivery.\r\n"[..]).into()
} else {
(b"451 4.3.5 Unable to accept message at this time.\r\n"[..]).into()
}
} else {
tracing::warn!(
parent: &self.span,
context = "queue",
event = "quota-exceeded",
from = message.return_path,
"Queue quota exceeded, rejecting message."
);
(b"452 4.3.1 Mail system full, try again later.\r\n"[..]).into()
}
}
pub async fn build_message(
&self,
mail_from: SessionAddress,
mut rcpt_to: Vec<SessionAddress>,
) -> Box<Message> {
// Build message
let mut message = Box::new(Message {
id: self.core.queue.queue_id(),
path: PathBuf::new(),
created: SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_secs()),
return_path: mail_from.address,
return_path_lcase: mail_from.address_lcase,
return_path_domain: mail_from.domain,
recipients: Vec::with_capacity(rcpt_to.len()),
domains: Vec::with_capacity(3),
flags: mail_from.flags,
priority: self.data.priority,
size: 0,
env_id: mail_from.dsn_info,
queue_refs: Vec::with_capacity(0),
});
// Add recipients
let future_release = Duration::from_secs(self.data.future_release);
rcpt_to.sort_unstable();
for rcpt in rcpt_to {
if message
.domains
.last()
.map_or(true, |d| d.domain != rcpt.domain)
{
let envelope = SimpleEnvelope::new(message.as_ref(), &rcpt.domain);
// Set next retry time
let retry = if self.data.future_release == 0 {
queue::Schedule::now()
} else {
queue::Schedule::later(future_release)
};
// Set expiration and notification times
let config = &self.core.queue.config;
let notify_intervals = config.notify.eval(&envelope).await;
let (notify, expires) = if self.data.delivery_by == 0 {
(
queue::Schedule::later(future_release + *notify_intervals.first().unwrap()),
Instant::now() + future_release + *config.expire.eval(&envelope).await,
)
} else if (message.flags & MAIL_BY_RETURN) != 0 {
(
queue::Schedule::later(future_release + *notify_intervals.first().unwrap()),
Instant::now() + Duration::from_secs(self.data.delivery_by as u64),
)
} else {
let expire = *config.expire.eval(&envelope).await;
let expire_secs = expire.as_secs();
let notify = if self.data.delivery_by.is_positive() {
let notify_at = self.data.delivery_by as u64;
if expire_secs > notify_at {
Duration::from_secs(notify_at)
} else {
*notify_intervals.first().unwrap()
}
} else {
let notify_at = -self.data.delivery_by as u64;
if expire_secs > notify_at {
Duration::from_secs(expire_secs - notify_at)
} else {
*notify_intervals.first().unwrap()
}
};
let mut notify = queue::Schedule::later(future_release + notify);
notify.inner = (notify_intervals.len() - 1) as u32; // Disable further notification attempts
(notify, Instant::now() + expire)
};
message.domains.push(queue::Domain {
retry,
notify,
expires,
status: queue::Status::Scheduled,
domain: rcpt.domain,
changed: false,
});
}
message.recipients.push(queue::Recipient {
address: rcpt.address,
address_lcase: rcpt.address_lcase,
status: queue::Status::Scheduled,
flags: if rcpt.flags
& (RCPT_NOTIFY_DELAY
| RCPT_NOTIFY_FAILURE
| RCPT_NOTIFY_SUCCESS
| RCPT_NOTIFY_NEVER)
!= 0
{
rcpt.flags
} else {
rcpt.flags | RCPT_NOTIFY_DELAY | RCPT_NOTIFY_FAILURE
},
domain_idx: message.domains.len() - 1,
orcpt: rcpt.dsn_info,
});
}
message
}
pub async fn can_send_data(&mut self) -> Result<bool, ()> {
if !self.data.rcpt_to.is_empty() {
if self.data.messages_sent
< *self.core.session.config.data.max_messages.eval(self).await
{
Ok(true)
} else {
tracing::debug!(
parent: &self.span,
context = "data",
event = "too-many-messages",
"Maximum number of messages per session exceeded."
);
self.write(b"451 4.4.5 Maximum number of messages per session exceeded.\r\n")
.await?;
Ok(false)
}
} else {
self.write(b"503 5.5.1 RCPT is required first.\r\n").await?;
Ok(false)
}
}
fn write_received(&self, headers: &mut Vec<u8>, id: u64) {
headers.extend_from_slice(b"Received: from ");
headers.extend_from_slice(self.data.helo_domain.as_bytes());
headers.extend_from_slice(b" (");
headers.extend_from_slice(
self.data
.iprev
.as_ref()
.and_then(|ir| ir.ptr.as_ref())
.and_then(|ptr| ptr.first().map(|s| s.as_str()))
.unwrap_or("unknown")
.as_bytes(),
);
headers.extend_from_slice(b" [");
headers.extend_from_slice(self.data.remote_ip.to_string().as_bytes());
headers.extend_from_slice(b"])\r\n\t");
self.stream.write_tls_header(headers);
headers.extend_from_slice(b"by ");
headers.extend_from_slice(self.instance.hostname.as_bytes());
headers.extend_from_slice(b" (Stalwart SMTP) with ");
headers.extend_from_slice(
if self.stream.is_tls() {
"ESMTPS"
} else {
"ESMTP"
}
.as_bytes(),
);
headers.extend_from_slice(b" id ");
headers.extend_from_slice(format!("{id:X}").as_bytes());
headers.extend_from_slice(b";\r\n\t");
headers.extend_from_slice(Date::now().to_rfc822().as_bytes());
headers.extend_from_slice(b"\r\n");
}
}

View file

@ -0,0 +1,392 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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::{net::IpAddr, time::SystemTime};
use crate::{
config::{DNSBL_EHLO, DNSBL_IP},
core::{scripts::ScriptResult, Session},
};
use mail_auth::spf::verify::HasLabels;
use smtp_proto::*;
use tokio::io::{AsyncRead, AsyncWrite};
use super::IsTls;
impl<T: AsyncWrite + AsyncRead + IsTls + Unpin> Session<T> {
pub async fn handle_ehlo(&mut self, domain: String) -> Result<(), ()> {
// Set EHLO domain
if domain != self.data.helo_domain {
// Reject non-FQDN EHLO domains - simply checks that the hostname has at least one dot
if self.params.ehlo_reject_non_fqdn && !domain.as_str().has_labels() {
tracing::debug!(parent: &self.span,
context = "ehlo",
event = "reject",
reason = "invalid",
domain = domain,
);
return self.write(b"550 5.5.0 Invalid EHLO domain.\r\n").await;
}
// Check DNSBL
if !self
.is_domain_dnsbl_allowed(&domain, "ehlo", DNSBL_EHLO)
.await
{
self.write_dnsbl_error().await?;
self.reset_dnsbl_error(); // Reset error in case a new EHLO is issued
return Ok(());
}
// SPF check
let prev_helo_domain = std::mem::replace(&mut self.data.helo_domain, domain);
if self.params.spf_ehlo.verify() {
let spf_output = self
.core
.resolvers
.dns
.verify_spf_helo(
self.data.remote_ip,
&self.data.helo_domain,
&self.instance.hostname,
)
.await;
tracing::debug!(parent: &self.span,
context = "spf",
event = "lookup",
identity = "ehlo",
domain = self.data.helo_domain,
result = %spf_output.result(),
);
if self
.handle_spf(&spf_output, self.params.spf_ehlo.is_strict())
.await?
{
self.data.spf_ehlo = spf_output.into();
} else {
self.data.mail_from = None;
self.data.helo_domain = prev_helo_domain;
return Ok(());
}
}
// Sieve filtering
if let Some(script) = self.core.session.config.ehlo.script.eval(self).await {
match self.run_script(script.clone(), None).await {
ScriptResult::Accept | ScriptResult::Replace(_) => (),
ScriptResult::Reject(message) => {
tracing::debug!(parent: &self.span,
context = "ehlo",
event = "sieve-reject",
domain = &self.data.helo_domain,
reason = message);
self.data.mail_from = None;
self.data.helo_domain = prev_helo_domain;
self.data.spf_ehlo = None;
return self.write(message.as_bytes()).await;
}
}
}
tracing::debug!(parent: &self.span,
context = "ehlo",
event = "ehlo",
domain = self.data.helo_domain,
);
}
// Reset
if self.data.mail_from.is_some() {
self.reset();
}
let mut response = EhloResponse::new(self.instance.hostname.as_str());
response.capabilities =
EXT_ENHANCED_STATUS_CODES | EXT_8BIT_MIME | EXT_BINARY_MIME | EXT_SMTP_UTF8;
if !self.stream.is_tls() {
response.capabilities |= EXT_START_TLS;
}
let ec = &self.core.session.config.extensions;
let rc = &self.core.session.config.rcpt;
let ac = &self.core.session.config.auth;
let dc = &self.core.session.config.data;
// Pipelining
if *ec.pipelining.eval(self).await {
response.capabilities |= EXT_PIPELINING;
}
// Chunking
if *ec.chunking.eval(self).await {
response.capabilities |= EXT_CHUNKING;
}
// Address Expansion
if rc.lookup_expn.eval(self).await.is_some() {
response.capabilities |= EXT_EXPN;
}
// Recipient Verification
if rc.lookup_vrfy.eval(self).await.is_some() {
response.capabilities |= EXT_VRFY;
}
// Require TLS
if *ec.requiretls.eval(self).await {
response.capabilities |= EXT_REQUIRE_TLS;
}
// DSN
if *ec.dsn.eval(self).await {
response.capabilities |= EXT_DSN;
}
// Authentication
if self.data.authenticated_as.is_empty() {
response.auth_mechanisms = *ac.mechanisms.eval(self).await;
if response.auth_mechanisms != 0 {
if !self.stream.is_tls() {
response.auth_mechanisms &= !(AUTH_PLAIN | AUTH_LOGIN);
}
if response.auth_mechanisms != 0 {
response.capabilities |= EXT_AUTH;
}
}
}
// Future release
if let Some(value) = ec.future_release.eval(self).await {
response.capabilities |= EXT_FUTURE_RELEASE;
response.future_release_interval = value.as_secs();
response.future_release_datetime = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
+ value.as_secs();
}
// Deliver By
if let Some(value) = ec.deliver_by.eval(self).await {
response.capabilities |= EXT_DELIVER_BY;
response.deliver_by = value.as_secs();
}
// Priority
if let Some(value) = ec.mt_priority.eval(self).await {
response.capabilities |= EXT_MT_PRIORITY;
response.mt_priority = *value;
}
// Size
response.size = *dc.max_message_size.eval(self).await;
if response.size > 0 {
response.capabilities |= EXT_SIZE;
}
// No soliciting
if let Some(value) = ec.no_soliciting.eval(self).await {
response.capabilities |= EXT_NO_SOLICITING;
response.no_soliciting = if !value.is_empty() {
value.to_string().into()
} else {
None
};
}
// Generate response
let mut buf = Vec::with_capacity(64);
response.write(&mut buf).ok();
self.write(&buf).await
}
pub async fn is_domain_dnsbl_allowed(
&mut self,
domain: &str,
context: &str,
policy_type: u32,
) -> bool {
let domain_ = domain.to_lowercase();
let is_fqdn = domain.ends_with('.');
if (self.params.dnsbl_policy & policy_type) != 0 {
for dnsbl in &self.core.mail_auth.dnsbl.domain_lookup {
if self
.is_dns_blocked(if is_fqdn {
format!("{domain_}{dnsbl}")
} else {
format!("{domain_}.{dnsbl}")
})
.await
{
tracing::debug!(parent: &self.span,
context = context,
event = "reject",
reason = "dnsbl",
list = dnsbl,
domain = domain,
);
self.data.dnsbl_error = format!(
"554 5.7.1 Service unavailable; Domain '{domain}' blocked using {dnsbl}\r\n"
)
.into_bytes()
.into();
return false;
}
}
}
true
}
pub async fn verify_ip_dnsbl(&mut self) -> bool {
if (self.params.dnsbl_policy & DNSBL_IP) != 0 {
for dnsbl in &self.core.mail_auth.dnsbl.ip_lookup {
if self
.is_dns_blocked(self.data.remote_ip.to_dnsbl(dnsbl))
.await
{
tracing::debug!(parent: &self.span,
context = "connect",
event = "reject",
reason = "dnsbl",
list = dnsbl,
ip = self.data.remote_ip.to_string(),
);
self.data.dnsbl_error = format!(
"554 5.7.1 Service unavailable; IP address {} blocked using {}\r\n",
self.data.remote_ip, dnsbl
)
.into_bytes()
.into();
return false;
}
}
}
true
}
async fn is_dns_blocked(&self, domain: String) -> bool {
match self.core.resolvers.dns.ipv4_lookup(&domain).await {
Ok(ips) => {
for ip in ips.iter() {
if ip.octets()[0..2] == [127, 0] {
return true;
}
}
tracing::debug!(parent: &self.span,
context = "dnsbl",
event = "invalid-reply",
query = domain,
reply = ?ips,
);
}
Err(mail_auth::Error::DnsRecordNotFound(_)) => (),
Err(err) => {
tracing::debug!(parent: &self.span,
context = "dnsbl",
event = "dnserror",
query = domain,
reson = %err,
);
}
}
false
}
pub async fn write_dnsbl_error(&mut self) -> Result<(), ()> {
if let Some(error) = &self.data.dnsbl_error {
self.write(&error.to_vec()).await
} else {
Ok(())
}
}
pub fn has_dnsbl_error(&mut self) -> bool {
self.data.dnsbl_error.is_some()
}
pub fn reset_dnsbl_error(&mut self) -> Option<Vec<u8>> {
self.data.dnsbl_error.take()
}
}
trait ToDnsbl {
fn to_dnsbl(&self, host: &str) -> String;
}
impl ToDnsbl for IpAddr {
fn to_dnsbl(&self, dnsbl: &str) -> String {
use std::fmt::Write;
match self {
IpAddr::V4(ip) => {
let mut host = String::with_capacity(dnsbl.len() + 16);
for octet in ip.octets().iter().rev() {
let _ = write!(host, "{octet}.");
}
host.push_str(dnsbl);
host
}
IpAddr::V6(ip) => {
let mut host = Vec::with_capacity(dnsbl.len() + 64);
for segment in ip.segments().iter().rev() {
for &p in format!("{segment:04x}").as_bytes().iter().rev() {
host.push(p);
host.push(b'.');
}
}
host.extend_from_slice(dnsbl.as_bytes());
String::from_utf8(host).unwrap_or_default()
}
}
}
}
#[cfg(test)]
mod test {
use std::net::IpAddr;
use crate::inbound::ehlo::ToDnsbl;
#[test]
fn ip_to_dnsbl() {
assert_eq!(
"2001:DB8:abc:123::42"
.parse::<IpAddr>()
.unwrap()
.to_dnsbl("zen.spamhaus.org"),
"2.4.0.0.0.0.0.0.0.0.0.0.0.0.0.0.3.2.1.0.c.b.a.0.8.b.d.0.1.0.0.2.zen.spamhaus.org"
);
assert_eq!(
"1.2.3.4"
.parse::<IpAddr>()
.unwrap()
.to_dnsbl("zen.spamhaus.org"),
"4.3.2.1.zen.spamhaus.org"
);
}
}

View file

@ -0,0 +1,350 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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::time::SystemTime;
use mail_auth::{IprevOutput, IprevResult, SpfOutput, SpfResult};
use smtp_proto::{MailFrom, MAIL_BY_NOTIFY, MAIL_BY_RETURN, MAIL_REQUIRETLS};
use tokio::io::{AsyncRead, AsyncWrite};
use crate::{
config::{DNSBL_IPREV, DNSBL_RETURN_PATH},
core::{scripts::ScriptResult, Session, SessionAddress},
queue::DomainPart,
};
use super::IsTls;
impl<T: AsyncWrite + AsyncRead + Unpin + IsTls> Session<T> {
pub async fn handle_mail_from(&mut self, from: MailFrom<String>) -> Result<(), ()> {
if self.data.helo_domain.is_empty()
&& (self.params.ehlo_require
|| self.params.spf_ehlo.verify()
|| self.params.spf_mail_from.verify())
{
return self
.write(b"503 5.5.1 Polite people say EHLO first.\r\n")
.await;
} else if self.data.mail_from.is_some() {
return self
.write(b"503 5.5.1 Multiple MAIL commands not allowed.\r\n")
.await;
} else if self.params.auth_require && self.data.authenticated_as.is_empty() {
return self
.write(b"503 5.5.1 You must authenticate first.\r\n")
.await;
} else if self.has_dnsbl_error() {
// There was a previous DNSBL error
return self.write_dnsbl_error().await;
} else if self.data.iprev.is_none()
&& (self.params.iprev.verify() || (self.params.dnsbl_policy & DNSBL_IPREV) != 0)
{
let iprev = self
.core
.resolvers
.dns
.verify_iprev(self.data.remote_ip)
.await;
tracing::debug!(parent: &self.span,
context = "iprev",
event = "lookup",
result = %iprev.result,
ptr = iprev.ptr.as_ref().and_then(|p| p.first()).map(|p| p.as_str()).unwrap_or_default()
);
// Validate reverse hostname against DNSBL
if let Some(ptr) = iprev.ptr.as_ref().and_then(|l| l.first()) {
if !self.is_domain_dnsbl_allowed(ptr, "ptr", DNSBL_IPREV).await {
return self.write_dnsbl_error().await;
}
}
self.data.iprev = iprev.into();
}
// In strict mode reject messages from hosts that fail the reverse DNS lookup check
if self.params.iprev.is_strict()
&& !matches!(
&self.data.iprev,
Some(IprevOutput {
result: IprevResult::Pass,
..
})
)
{
let message = if matches!(
&self.data.iprev,
Some(IprevOutput {
result: IprevResult::TempError(_),
..
})
) {
&b"451 4.7.25 Temporary error validating reverse DNS.\r\n"[..]
} else {
&b"550 5.7.25 Reverse DNS validation failed.\r\n"[..]
};
return self.write(message).await;
}
let (address, address_lcase, domain) = if !from.address.is_empty() {
let address_lcase = from.address.to_lowercase();
let domain = address_lcase.domain_part().to_string();
(from.address, address_lcase, domain)
} else {
(String::new(), String::new(), String::new())
};
// Validate domain against DNSBL
if !domain.is_empty()
&& !self
.is_domain_dnsbl_allowed(&domain, "mail-from", DNSBL_RETURN_PATH)
.await
{
self.write_dnsbl_error().await?;
self.reset_dnsbl_error(); // Reset error in case a new MAIL-FROM is issued later
return Ok(());
}
let has_dsn = from.env_id.is_some();
self.data.mail_from = SessionAddress {
address,
address_lcase,
domain,
flags: from.flags,
dsn_info: from.env_id,
}
.into();
// Sieve filtering
if let Some(script) = self.core.session.config.mail.script.eval(self).await {
match self.run_script(script.clone(), None).await {
ScriptResult::Accept | ScriptResult::Replace(_) => (),
ScriptResult::Reject(message) => {
tracing::debug!(parent: &self.span,
context = "mail-from",
event = "sieve-reject",
address = &self.data.mail_from.as_ref().unwrap().address,
reason = message);
self.data.mail_from = None;
return self.write(message.as_bytes()).await;
}
}
}
// Validate parameters
let config = &self.core.session.config.extensions;
let config_data = &self.core.session.config.data;
if (from.flags & MAIL_REQUIRETLS) != 0 && !*config.requiretls.eval(self).await {
self.data.mail_from = None;
return self
.write(b"501 5.5.4 REQUIRETLS has been disabled.\r\n")
.await;
}
if (from.flags & (MAIL_BY_NOTIFY | MAIL_BY_RETURN)) != 0 {
if let Some(duration) = config.deliver_by.eval(self).await {
if from.by.checked_abs().unwrap_or(0) as u64 <= duration.as_secs()
&& (from.by.is_positive() || (from.flags & MAIL_BY_NOTIFY) != 0)
{
self.data.delivery_by = from.by;
} else {
self.data.mail_from = None;
return self
.write(
format!(
"501 5.5.4 BY parameter exceeds maximum of {} seconds.\r\n",
duration.as_secs()
)
.as_bytes(),
)
.await;
}
} else {
self.data.mail_from = None;
return self
.write(b"501 5.5.4 DELIVERBY extension has been disabled.\r\n")
.await;
}
}
if from.mt_priority != 0 {
if config.mt_priority.eval(self).await.is_some() {
if (-6..6).contains(&from.mt_priority) {
self.data.priority = from.mt_priority as i16;
} else {
self.data.mail_from = None;
return self.write(b"501 5.5.4 Invalid priority value.\r\n").await;
}
} else {
self.data.mail_from = None;
return self
.write(b"501 5.5.4 MT-PRIORITY extension has been disabled.\r\n")
.await;
}
}
if from.size > 0 && from.size > *config_data.max_message_size.eval(self).await {
self.data.mail_from = None;
return self
.write(b"552 5.3.4 Message too big for system.\r\n")
.await;
}
if from.hold_for != 0 || from.hold_until != 0 {
if let Some(max_hold) = config.future_release.eval(self).await {
let max_hold = max_hold.as_secs();
let hold_for = if from.hold_for != 0 {
from.hold_for
} else {
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
if from.hold_until > now {
from.hold_until - now
} else {
0
}
};
if hold_for <= max_hold {
self.data.future_release = hold_for;
} else {
self.data.mail_from = None;
return self
.write(
format!(
"501 5.5.4 Requested hold time exceeds maximum of {max_hold} seconds.\r\n"
)
.as_bytes(),
)
.await;
}
} else {
self.data.mail_from = None;
return self
.write(b"501 5.5.4 FUTURERELEASE extension has been disabled.\r\n")
.await;
}
}
if has_dsn && !*config.dsn.eval(self).await {
self.data.mail_from = None;
return self
.write(b"501 5.5.4 DSN extension has been disabled.\r\n")
.await;
}
if self.is_allowed().await {
// Verify SPF
if self.params.spf_mail_from.verify() {
let mail_from = self.data.mail_from.as_ref().unwrap();
let spf_output = if !mail_from.address.is_empty() {
self.core
.resolvers
.dns
.check_host(
self.data.remote_ip,
&mail_from.domain,
&self.data.helo_domain,
&self.instance.hostname,
&mail_from.address_lcase,
)
.await
} else {
self.core
.resolvers
.dns
.check_host(
self.data.remote_ip,
&self.data.helo_domain,
&self.data.helo_domain,
&self.instance.hostname,
&format!("postmaster@{}", self.data.helo_domain),
)
.await
};
tracing::debug!(parent: &self.span,
context = "spf",
event = "lookup",
identity = "mail-from",
domain = self.data.helo_domain,
sender = if !mail_from.address.is_empty() {mail_from.address.as_str()} else {"<>"},
result = %spf_output.result(),
);
if self
.handle_spf(&spf_output, self.params.spf_mail_from.is_strict())
.await?
{
self.data.spf_mail_from = spf_output.into();
} else {
self.data.mail_from = None;
return Ok(());
}
}
tracing::debug!(parent: &self.span,
context = "mail-from",
event = "success",
address = &self.data.mail_from.as_ref().unwrap().address);
self.eval_rcpt_params().await;
self.write(b"250 2.1.0 OK\r\n").await
} else {
self.data.mail_from = None;
self.write(b"451 4.4.5 Rate limit exceeded, try again later.\r\n")
.await
}
}
pub async fn handle_spf(&mut self, spf_output: &SpfOutput, strict: bool) -> Result<bool, ()> {
let result = match spf_output.result() {
SpfResult::Pass => true,
SpfResult::TempError if strict => {
self.write(b"451 4.7.24 Temporary SPF validation error.\r\n")
.await?;
false
}
result => {
if strict {
self.write(
format!("550 5.7.23 SPF validation failed, status: {result}.\r\n")
.as_bytes(),
)
.await?;
false
} else {
true
}
}
};
// Send report
if let (Some(recipient), Some(rate)) = (
spf_output.report_address(),
self.core.report.config.spf.send.eval(self).await,
) {
self.send_spf_report(recipient, rate, !result, spf_output)
.await;
}
Ok(result)
}
}

View file

@ -0,0 +1,123 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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 mail_auth::{
arc::ArcSet, dkim::Signature, ArcOutput, AuthenticatedMessage, AuthenticationResults,
};
use tokio::net::TcpStream;
use tokio_rustls::server::TlsStream;
use crate::config::{ArcSealer, DkimSigner};
pub mod auth;
pub mod data;
pub mod ehlo;
pub mod mail;
pub mod rcpt;
pub mod session;
pub mod spawn;
pub mod vrfy;
pub trait IsTls {
fn is_tls(&self) -> bool;
fn write_tls_header(&self, headers: &mut Vec<u8>);
}
impl IsTls for TcpStream {
fn is_tls(&self) -> bool {
false
}
fn write_tls_header(&self, _headers: &mut Vec<u8>) {}
}
impl IsTls for TlsStream<TcpStream> {
fn is_tls(&self) -> bool {
true
}
fn write_tls_header(&self, headers: &mut Vec<u8>) {
let (_, conn) = self.get_ref();
headers.extend_from_slice(b"(using ");
headers.extend_from_slice(
match conn
.protocol_version()
.unwrap_or(rustls::ProtocolVersion::Unknown(0))
{
rustls::ProtocolVersion::SSLv2 => "SSLv2",
rustls::ProtocolVersion::SSLv3 => "SSLv3",
rustls::ProtocolVersion::TLSv1_0 => "TLSv1.0",
rustls::ProtocolVersion::TLSv1_1 => "TLSv1.1",
rustls::ProtocolVersion::TLSv1_2 => "TLSv1.2",
rustls::ProtocolVersion::TLSv1_3 => "TLSv1.3",
rustls::ProtocolVersion::DTLSv1_0 => "DTLSv1.0",
rustls::ProtocolVersion::DTLSv1_2 => "DTLSv1.2",
rustls::ProtocolVersion::DTLSv1_3 => "DTLSv1.3",
_ => "unknown",
}
.as_bytes(),
);
headers.extend_from_slice(b" with cipher ");
headers.extend_from_slice(
match conn.negotiated_cipher_suite() {
Some(rustls::SupportedCipherSuite::Tls13(cs)) => {
cs.common.suite.as_str().unwrap_or("unknown")
}
Some(rustls::SupportedCipherSuite::Tls12(cs)) => {
cs.common.suite.as_str().unwrap_or("unknown")
}
None => "unknown",
}
.as_bytes(),
);
headers.extend_from_slice(b")\r\n\t");
}
}
impl ArcSealer {
pub fn seal<'x>(
&self,
message: &'x AuthenticatedMessage,
results: &'x AuthenticationResults,
arc_output: &'x ArcOutput,
) -> mail_auth::Result<ArcSet<'x>> {
match self {
ArcSealer::RsaSha256(sealer) => sealer.seal(message, results, arc_output),
ArcSealer::Ed25519Sha256(sealer) => sealer.seal(message, results, arc_output),
}
}
}
impl DkimSigner {
pub fn sign(&self, message: &[u8]) -> mail_auth::Result<Signature> {
match self {
DkimSigner::RsaSha256(signer) => signer.sign(message),
DkimSigner::Ed25519Sha256(signer) => signer.sign(message),
}
}
pub fn sign_chained(&self, message: &[&[u8]]) -> mail_auth::Result<Signature> {
match self {
DkimSigner::RsaSha256(signer) => signer.sign_chained(message.iter().copied()),
DkimSigner::Ed25519Sha256(signer) => signer.sign_chained(message.iter().copied()),
}
}
}

View file

@ -0,0 +1,185 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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 smtp_proto::{
RcptTo, RCPT_NOTIFY_DELAY, RCPT_NOTIFY_FAILURE, RCPT_NOTIFY_NEVER, RCPT_NOTIFY_SUCCESS,
};
use tokio::io::{AsyncRead, AsyncWrite};
use crate::{
core::{scripts::ScriptResult, Session, SessionAddress},
queue::DomainPart,
};
impl<T: AsyncWrite + AsyncRead + Unpin> Session<T> {
pub async fn handle_rcpt_to(&mut self, to: RcptTo<String>) -> Result<(), ()> {
#[cfg(feature = "test_mode")]
if self.instance.id.ends_with("-debug") {
if to.address.contains("fail@") {
return self.write(b"503 5.5.1 Invalid recipient.\r\n").await;
} else if to.address.contains("delay@") {
return self.write(b"451 4.5.3 Try again later.\r\n").await;
}
}
if self.data.mail_from.is_none() {
return self.write(b"503 5.5.1 MAIL is required first.\r\n").await;
} else if self.data.rcpt_to.len() >= self.params.rcpt_max {
return self.write(b"451 4.5.3 Too many recipients.\r\n").await;
}
// Verify parameters
if ((to.flags
& (RCPT_NOTIFY_DELAY | RCPT_NOTIFY_NEVER | RCPT_NOTIFY_SUCCESS | RCPT_NOTIFY_FAILURE)
!= 0)
|| to.orcpt.is_some())
&& !self.params.rcpt_dsn
{
return self
.write(b"501 5.5.4 DSN extension has been disabled.\r\n")
.await;
}
// Build RCPT
let address_lcase = to.address.to_lowercase();
let rcpt = SessionAddress {
domain: address_lcase.domain_part().to_string(),
address_lcase,
address: to.address,
flags: to.flags,
dsn_info: to.orcpt,
};
// Verify address
if let (Some(domain_lookup), Some(address_lookup)) = (
&self.params.rcpt_lookup_domain,
&self.params.rcpt_lookup_addresses,
) {
if let Some(is_local_domain) = domain_lookup.contains(&rcpt.domain).await {
if is_local_domain {
if let Some(is_local_address) =
address_lookup.contains(&rcpt.address_lcase).await
{
if !is_local_address {
tracing::debug!(parent: &self.span,
context = "rcpt",
event = "error",
address = &rcpt.address_lcase,
"Mailbox does not exist.");
return self
.rcpt_error(b"550 5.1.2 Mailbox does not exist.\r\n")
.await;
}
} else {
tracing::debug!(parent: &self.span,
context = "rcpt",
event = "error",
address = &rcpt.address_lcase,
"Temporary address verification failure.");
return self
.write(b"451 4.4.3 Unable to verify address at this time.\r\n")
.await;
}
} else if !self.params.rcpt_relay {
tracing::debug!(parent: &self.span,
context = "rcpt",
event = "error",
address = &rcpt.address_lcase,
"Relay not allowed.");
return self.rcpt_error(b"550 5.1.2 Relay not allowed.\r\n").await;
}
} else {
tracing::debug!(parent: &self.span,
context = "rcpt",
event = "error",
address = &rcpt.address_lcase,
"Temporary address verification failure.");
return self
.write(b"451 4.4.3 Unable to verify address at this time.\r\n")
.await;
}
} else if !self.params.rcpt_relay {
tracing::debug!(parent: &self.span,
context = "rcpt",
event = "error",
address = &rcpt.address_lcase,
"Relay not allowed.");
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 {
match self.run_script(script.clone(), None).await {
ScriptResult::Accept | ScriptResult::Replace(_) => (),
ScriptResult::Reject(message) => {
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,
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;
}
}
self.write(b"250 2.1.5 OK\r\n").await
}
async fn rcpt_error(&mut self, response: &[u8]) -> Result<(), ()> {
tokio::time::sleep(self.params.rcpt_errors_wait).await;
self.data.rcpt_errors += 1;
self.write(response).await?;
if self.data.rcpt_errors < self.params.rcpt_errors_max {
Ok(())
} else {
self.write(b"421 4.3.0 Too many errors, disconnecting.\r\n")
.await?;
tracing::debug!(
parent: &self.span,
context = "rcpt",
event = "disconnect",
reason = "too-many-errors",
"Too many invalid RCPT commands."
);
Err(())
}
}
}

View file

@ -0,0 +1,456 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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::net::IpAddr;
use smtp_proto::{
request::receiver::{
BdatReceiver, DataReceiver, DummyDataReceiver, DummyLineReceiver, LineReceiver,
MAX_LINE_LENGTH,
},
*,
};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use utils::config::ServerProtocol;
use crate::core::{Envelope, Session, State};
use super::{auth::SaslToken, IsTls};
impl<T: AsyncWrite + AsyncRead + IsTls + Unpin> Session<T> {
pub async fn ingest(&mut self, bytes: &[u8]) -> Result<bool, ()> {
let mut iter = bytes.iter();
let mut state = std::mem::replace(&mut self.state, State::None);
'outer: loop {
match &mut state {
State::Request(receiver) => loop {
match receiver.ingest(&mut iter, bytes) {
Ok(request) => match request {
Request::Rcpt { to } => {
self.handle_rcpt_to(to).await?;
}
Request::Mail { from } => {
self.handle_mail_from(from).await?;
}
Request::Ehlo { host } => {
if self.instance.protocol == ServerProtocol::Smtp {
self.handle_ehlo(host).await?;
} else {
self.write(b"500 5.5.1 Invalid command.\r\n").await?;
}
}
Request::Data => {
if self.can_send_data().await? {
self.write(b"354 Start mail input; end with <CRLF>.<CRLF>\r\n")
.await?;
self.data.message = Vec::with_capacity(1024);
state = State::Data(DataReceiver::new());
continue 'outer;
}
}
Request::Bdat {
chunk_size,
is_last,
} => {
state = if chunk_size + self.data.message.len()
< self.params.max_message_size
{
if self.data.message.is_empty() {
self.data.message = Vec::with_capacity(chunk_size);
} else {
self.data.message.reserve(chunk_size);
}
State::Bdat(BdatReceiver::new(chunk_size, is_last))
} else {
// Chunk is too large, ignore.
State::DataTooLarge(DummyDataReceiver::new_bdat(chunk_size))
};
continue 'outer;
}
Request::Auth {
mechanism,
initial_response,
} => {
let auth =
*self.core.session.config.auth.mechanisms.eval(self).await;
if auth == 0 || self.params.auth_lookup.is_none() {
self.write(b"503 5.5.1 AUTH not allowed.\r\n").await?;
} else if !self.data.authenticated_as.is_empty() {
self.write(b"503 5.5.1 Already authenticated.\r\n").await?;
} else if mechanism & (AUTH_LOGIN | AUTH_PLAIN) != 0
&& !self.stream.is_tls()
{
self.write(b"503 5.5.1 Clear text authentication without TLS is forbidden.\r\n").await?;
} else if let Some(mut token) =
SaslToken::from_mechanism(mechanism & auth)
{
if self
.handle_sasl_response(
&mut token,
initial_response.as_bytes(),
)
.await?
{
state = State::Sasl(LineReceiver::new(token));
continue 'outer;
}
} else {
self.write(
b"554 5.7.8 Authentication mechanism not supported.\r\n",
)
.await?;
}
}
Request::Noop { .. } => {
self.write(b"250 2.0.0 OK\r\n").await?;
}
Request::Vrfy { value } => {
self.handle_vrfy(value).await?;
}
Request::Expn { value } => {
self.handle_expn(value).await?;
}
Request::StartTls => {
if !self.stream.is_tls() {
self.write(b"220 2.0.0 Ready to start TLS.\r\n").await?;
self.state = State::default();
return Ok(false);
} else {
self.write(b"504 5.7.4 Already in TLS mode.\r\n").await?;
}
}
Request::Rset => {
self.reset();
self.write(b"250 2.0.0 OK\r\n").await?;
}
Request::Quit => {
self.write(b"221 2.0.0 Bye.\r\n").await?;
return Err(());
}
Request::Help { .. } => {
self.write(
b"250 2.0.0 Help can be found at https://stalw.art/smtp/\r\n",
)
.await?;
}
Request::Helo { host } => {
if self.instance.protocol == ServerProtocol::Smtp
&& self.data.helo_domain.is_empty()
{
self.data.helo_domain = host;
self.write(
format!("250 {} says hello\r\n", self.instance.hostname)
.as_bytes(),
)
.await?;
} else {
self.write(b"503 5.5.1 Invalid command.\r\n").await?;
}
}
Request::Lhlo { host } => {
if self.instance.protocol == ServerProtocol::Lmtp {
self.handle_ehlo(host).await?;
} else {
self.write(b"502 5.5.1 Invalid command.\r\n").await?;
}
}
Request::Etrn { .. } | Request::Atrn { .. } | Request::Burl { .. } => {
self.write(b"502 5.5.1 Command not implemented.\r\n")
.await?;
}
},
Err(err) => match err {
Error::NeedsMoreData { .. } => break 'outer,
Error::UnknownCommand | Error::InvalidResponse { .. } => {
self.write(b"500 5.5.1 Invalid command.\r\n").await?;
}
Error::InvalidSenderAddress => {
self.write(b"501 5.1.8 Bad sender's system address.\r\n")
.await?;
}
Error::InvalidRecipientAddress => {
self.write(
b"501 5.1.3 Bad destination mailbox address syntax.\r\n",
)
.await?;
}
Error::SyntaxError { syntax } => {
self.write(
format!("501 5.5.2 Syntax error, expected: {syntax}\r\n")
.as_bytes(),
)
.await?;
}
Error::InvalidParameter { param } => {
self.write(
format!("501 5.5.4 Invalid parameter {param:?}.\r\n")
.as_bytes(),
)
.await?;
}
Error::UnsupportedParameter { param } => {
self.write(
format!("504 5.5.4 Unsupported parameter {param:?}.\r\n")
.as_bytes(),
)
.await?;
}
Error::ResponseTooLong => {
state = State::RequestTooLarge(DummyLineReceiver::default());
continue 'outer;
}
},
}
},
State::Data(receiver) => {
if self.data.message.len() + bytes.len() < self.params.max_message_size {
if receiver.ingest(&mut iter, &mut self.data.message) {
let num_rcpts = self.data.rcpt_to.len();
let message = self.queue_message().await;
if self.instance.protocol == ServerProtocol::Smtp {
self.write(message.as_ref()).await?;
} else {
for _ in 0..num_rcpts {
self.write(message.as_ref()).await?;
}
}
self.reset();
state = State::default();
} else {
break 'outer;
}
} else {
state = State::DataTooLarge(DummyDataReceiver::new_data(receiver));
}
}
State::Bdat(receiver) => {
if receiver.ingest(&mut iter, &mut self.data.message) {
if self.can_send_data().await? {
if receiver.is_last {
let num_rcpts = self.data.rcpt_to.len();
let message = self.queue_message().await;
if self.instance.protocol == ServerProtocol::Smtp {
self.write(message.as_ref()).await?;
} else {
for _ in 0..num_rcpts {
self.write(message.as_ref()).await?;
}
}
self.reset();
} else {
self.write(b"250 2.6.0 Chunk accepted.\r\n").await?;
}
} else {
self.data.message = Vec::with_capacity(0);
}
state = State::default();
} else {
break 'outer;
}
}
State::Sasl(receiver) => {
if receiver.ingest(&mut iter) {
if receiver.buf.len() < MAX_LINE_LENGTH {
if self
.handle_sasl_response(&mut receiver.state, &receiver.buf)
.await?
{
receiver.buf.clear();
continue 'outer;
}
} else {
self.auth_error(
b"500 5.5.6 Authentication Exchange line is too long.\r\n",
)
.await?;
}
state = State::default();
} else {
break 'outer;
}
}
State::DataTooLarge(receiver) => {
if receiver.ingest(&mut iter) {
tracing::debug!(
parent: &self.span,
context = "data",
event = "too-large",
"Message is too large."
);
self.data.message = Vec::with_capacity(0);
self.write(b"552 5.3.4 Message too big for system.\r\n")
.await?;
state = State::default();
} else {
break 'outer;
}
}
State::RequestTooLarge(receiver) => {
if receiver.ingest(&mut iter) {
self.write(b"554 5.3.4 Line is too long.\r\n").await?;
state = State::default();
} else {
break 'outer;
}
}
State::None => unreachable!(),
}
}
self.state = state;
Ok(true)
}
}
impl<T: AsyncWrite + AsyncRead + Unpin> Session<T> {
pub fn reset(&mut self) {
self.data.mail_from = None;
self.data.spf_mail_from = None;
self.data.rcpt_to.clear();
self.data.message = Vec::with_capacity(0);
self.data.priority = 0;
self.data.delivery_by = 0;
self.data.future_release = 0;
}
#[inline(always)]
pub async fn write(&mut self, bytes: &[u8]) -> Result<(), ()> {
let err = match self.stream.write_all(bytes).await {
Ok(_) => match self.stream.flush().await {
Ok(_) => {
tracing::trace!(parent: &self.span,
event = "write",
data = std::str::from_utf8(bytes).unwrap_or_default() ,
size = bytes.len());
return Ok(());
}
Err(err) => err,
},
Err(err) => err,
};
tracing::debug!(parent: &self.span,
event = "error",
"Failed to write to stream: {:?}", err);
Err(())
}
#[inline(always)]
pub async fn read(&mut self, bytes: &mut [u8]) -> Result<usize, ()> {
match self.stream.read(bytes).await {
Ok(len) => {
tracing::trace!(parent: &self.span,
event = "read",
data = if matches!(self.state, State::Request(_)) {bytes
.get(0..len)
.and_then(|bytes| std::str::from_utf8(bytes).ok())
.unwrap_or("[invalid UTF8]")} else {"[DATA]"},
size = len);
Ok(len)
}
Err(err) => {
tracing::debug!(
parent: &self.span,
event = "error",
"Failed to read from stream: {:?}", err
);
Err(())
}
}
}
}
impl<T: AsyncRead + AsyncWrite> Envelope for Session<T> {
#[inline(always)]
fn local_ip(&self) -> IpAddr {
self.data.local_ip
}
#[inline(always)]
fn remote_ip(&self) -> IpAddr {
self.data.remote_ip
}
#[inline(always)]
fn sender_domain(&self) -> &str {
self.data
.mail_from
.as_ref()
.map(|a| a.domain.as_str())
.unwrap_or_default()
}
#[inline(always)]
fn sender(&self) -> &str {
self.data
.mail_from
.as_ref()
.map(|a| a.address_lcase.as_str())
.unwrap_or_default()
}
#[inline(always)]
fn rcpt_domain(&self) -> &str {
self.data
.rcpt_to
.last()
.map(|r| r.domain.as_str())
.unwrap_or_default()
}
#[inline(always)]
fn rcpt(&self) -> &str {
self.data
.rcpt_to
.last()
.map(|r| r.address_lcase.as_str())
.unwrap_or_default()
}
#[inline(always)]
fn helo_domain(&self) -> &str {
self.data.helo_domain.as_str()
}
#[inline(always)]
fn authenticated_as(&self) -> &str {
self.data.authenticated_as.as_str()
}
#[inline(always)]
fn mx(&self) -> &str {
""
}
#[inline(always)]
fn listener_id(&self) -> u16 {
self.instance.listener_id
}
#[inline(always)]
fn priority(&self) -> i16 {
self.data.priority
}
}

View file

@ -0,0 +1,248 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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::time::Instant;
use tokio::{
io::{AsyncRead, AsyncWrite},
net::TcpStream,
};
use tokio_rustls::server::TlsStream;
use utils::listener::SessionManager;
use crate::core::{
scripts::ScriptResult, Session, SessionData, SessionParameters, SmtpSessionManager, State,
};
use super::IsTls;
impl SessionManager for SmtpSessionManager {
fn spawn(&self, session: utils::listener::SessionData<TcpStream>) {
// Create session
let mut session = Session {
core: self.inner.clone(),
instance: session.instance,
state: State::default(),
span: session.span,
stream: session.stream,
in_flight: vec![session.in_flight],
data: SessionData::new(session.local_ip, session.remote_ip),
params: SessionParameters::default(),
};
tokio::spawn(async move {
// Enforce throttle
if session.is_allowed().await {
if session.instance.is_tls_implicit {
if let Ok(mut session) = session.into_tls().await {
if session.init_conn().await {
session.handle_conn().await;
}
}
} else if session.init_conn().await {
session.handle_conn().await;
}
}
});
}
}
impl Session<TcpStream> {
pub async fn into_tls(self) -> Result<Session<TlsStream<TcpStream>>, ()> {
let span = self.span;
Ok(Session {
stream: match self
.instance
.tls_acceptor
.as_ref()
.unwrap()
.accept(self.stream)
.await
{
Ok(stream) => {
tracing::info!(
parent: &span,
context = "tls",
event = "handshake",
version = ?stream.get_ref().1.protocol_version().unwrap_or(rustls::ProtocolVersion::TLSv1_3),
cipher = ?stream.get_ref().1.negotiated_cipher_suite().unwrap_or(rustls::cipher_suite::TLS13_AES_128_GCM_SHA256),
);
stream
}
Err(err) => {
tracing::debug!(
parent: &span,
context = "tls",
event = "error",
"Failed to accept TLS connection: {}",
err
);
return Err(());
}
},
state: self.state,
data: self.data,
instance: self.instance,
core: self.core,
in_flight: self.in_flight,
params: self.params,
span,
})
}
pub async fn handle_conn(mut self) {
if self.handle_conn_().await && self.instance.tls_acceptor.is_some() {
if let Ok(session) = self.into_tls().await {
session.handle_conn().await;
}
}
}
}
impl Session<TlsStream<TcpStream>> {
pub async fn handle_conn(mut self) {
self.handle_conn_().await;
}
}
impl<T: AsyncRead + AsyncWrite + IsTls + Unpin> Session<T> {
pub async fn init_conn(&mut self) -> bool {
self.eval_session_params().await;
self.verify_ip_dnsbl().await;
// Sieve filtering
if let Some(script) = self.core.session.config.connect.script.eval(self).await {
match self.run_script(script.clone(), None).await {
ScriptResult::Accept | ScriptResult::Replace(_) => (),
ScriptResult::Reject(message) => {
tracing::debug!(parent: &self.span,
context = "connect",
event = "sieve-reject",
reason = message);
let _ = self.write(message.as_bytes()).await;
return false;
}
}
}
let instance = self.instance.clone();
if self.write(instance.data.as_bytes()).await.is_err() {
return false;
}
true
}
pub async fn handle_conn_(&mut self) -> bool {
let mut buf = vec![0; 8192];
let mut shutdown_rx = self.instance.shutdown_rx.clone();
loop {
tokio::select! {
result = tokio::time::timeout(
self.params.timeout,
self.read(&mut buf)) => {
match result {
Ok(Ok(bytes_read)) => {
if bytes_read > 0 {
if Instant::now() < self.data.valid_until && bytes_read <= self.data.bytes_left {
self.data.bytes_left -= bytes_read;
match self.ingest(&buf[..bytes_read]).await {
Ok(true) => (),
Ok(false) => {
return true;
}
Err(_) => {
break;
}
}
} else if bytes_read > self.data.bytes_left {
self
.write(format!("451 4.7.28 {} Session exceeded transfer quota.\r\n", self.instance.hostname).as_bytes())
.await
.ok();
tracing::debug!(
parent: &self.span,
event = "disconnect",
reason = "transfer-limit",
"Client exceeded incoming transfer limit."
);
break;
} else {
self
.write(format!("453 4.3.2 {} Session open for too long.\r\n", self.instance.hostname).as_bytes())
.await
.ok();
tracing::debug!(
parent: &self.span,
event = "disconnect",
reason = "loiter",
"Session open for too long."
);
break;
}
} else {
tracing::debug!(
parent: &self.span,
event = "disconnect",
reason = "peer",
"Connection closed by peer."
);
break;
}
}
Ok(Err(_)) => {
break;
}
Err(_) => {
tracing::debug!(
parent: &self.span,
event = "disconnect",
reason = "timeout",
"Connection timed out."
);
self
.write(format!("221 2.0.0 {} Disconnecting inactive client.\r\n", self.instance.hostname).as_bytes())
.await
.ok();
break;
}
}
},
_ = shutdown_rx.changed() => {
tracing::debug!(
parent: &self.span,
event = "disconnect",
reason = "shutdown",
"Server shutting down."
);
self.write(b"421 4.3.0 Server shutting down.\r\n").await.ok();
break;
}
};
}
false
}
}

View file

@ -0,0 +1,130 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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 tokio::io::{AsyncRead, AsyncWrite};
use crate::{
core::Session,
lookup::{Item, LookupResult},
};
use std::fmt::Write;
impl<T: AsyncWrite + AsyncRead + Unpin> Session<T> {
pub async fn handle_vrfy(&mut self, address: String) -> Result<(), ()> {
if let Some(address_lookup) = &self.params.rcpt_lookup_vrfy {
if let Some(result) = address_lookup
.lookup(Item::Verify(address.to_lowercase()))
.await
{
if let LookupResult::Values(values) = result {
let mut result = String::with_capacity(32);
for (pos, value) in values.iter().enumerate() {
let _ = write!(
result,
"250{}{}\r\n",
if pos == values.len() - 1 { " " } else { "-" },
value
);
}
tracing::debug!(parent: &self.span,
context = "vrfy",
event = "success",
address = &address);
self.write(result.as_bytes()).await
} else {
tracing::debug!(parent: &self.span,
context = "vrfy",
event = "not-found",
address = &address);
self.write(b"550 5.1.2 Address not found.\r\n").await
}
} else {
tracing::debug!(parent: &self.span,
context = "vrfy",
event = "temp-fail",
address = &address);
self.write(b"252 2.4.3 Unable to verify address at this time.\r\n")
.await
}
} else {
tracing::debug!(parent: &self.span,
context = "vrfy",
event = "forbidden",
address = &address);
self.write(b"252 2.5.1 VRFY is disabled.\r\n").await
}
}
pub async fn handle_expn(&mut self, address: String) -> Result<(), ()> {
if let Some(address_lookup) = &self.params.rcpt_lookup_expn {
if let Some(result) = address_lookup
.lookup(Item::Expand(address.to_lowercase()))
.await
{
if let LookupResult::Values(values) = result {
let mut result = String::with_capacity(32);
for (pos, value) in values.iter().enumerate() {
let _ = write!(
result,
"250{}{}\r\n",
if pos == values.len() - 1 { " " } else { "-" },
value
);
}
tracing::debug!(parent: &self.span,
context = "expn",
event = "success",
address = &address);
self.write(result.as_bytes()).await
} else {
tracing::debug!(parent: &self.span,
context = "expn",
event = "not-found",
address = &address);
self.write(b"550 5.1.2 Mailing list not found.\r\n").await
}
} else {
tracing::debug!(parent: &self.span,
context = "expn",
event = "temp-fail",
address = &address);
self.write(b"252 2.4.3 Unable to expand mailing list at this time.\r\n")
.await
}
} else {
tracing::debug!(parent: &self.span,
context = "expn",
event = "forbidden",
address = &address);
self.write(b"252 2.5.1 EXPN is disabled.\r\n").await
}
}
}

65
crates/smtp/src/lib.rs Normal file
View file

@ -0,0 +1,65 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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.
*/
pub mod config;
pub mod core;
pub mod inbound;
pub mod lookup;
pub mod outbound;
pub mod queue;
pub mod reporting;
pub static USER_AGENT: &str = concat!("StalwartSMTP/", env!("CARGO_PKG_VERSION"),);
pub trait UnwrapFailure<T> {
fn failed(self, action: &str) -> T;
}
impl<T> UnwrapFailure<T> for Option<T> {
fn failed(self, message: &str) -> T {
match self {
Some(result) => result,
None => {
eprintln!("{message}");
std::process::exit(1);
}
}
}
}
impl<T, E: std::fmt::Display> UnwrapFailure<T> for Result<T, E> {
fn failed(self, message: &str) -> T {
match self {
Ok(result) => result,
Err(err) => {
eprintln!("{message}: {err}");
std::process::exit(1);
}
}
}
}
pub fn failed(message: &str) -> ! {
eprintln!("{message}");
std::process::exit(1);
}

View file

@ -0,0 +1,85 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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::Borrow,
hash::Hash,
time::{Duration, Instant},
};
#[allow(clippy::type_complexity)]
#[derive(Debug)]
pub struct LookupCache<T: Hash + Eq> {
cache_pos: lru_cache::LruCache<T, Instant, ahash::RandomState>,
cache_neg: lru_cache::LruCache<T, Instant, ahash::RandomState>,
ttl_pos: Duration,
ttl_neg: Duration,
}
impl<T: Hash + Eq> LookupCache<T> {
pub fn new(capacity: usize, ttl_pos: Duration, ttl_neg: Duration) -> Self {
Self {
cache_pos: lru_cache::LruCache::with_hasher(capacity, ahash::RandomState::new()),
cache_neg: lru_cache::LruCache::with_hasher(capacity, ahash::RandomState::new()),
ttl_pos,
ttl_neg,
}
}
pub fn get<Q: ?Sized>(&mut self, name: &Q) -> Option<bool>
where
T: Borrow<Q>,
Q: Hash + Eq,
{
// Check positive cache
if let Some(valid_until) = self.cache_pos.get_mut(name) {
if *valid_until >= Instant::now() {
return Some(true);
} else {
self.cache_pos.remove(name);
}
}
// Check negative cache
let valid_until = self.cache_neg.get_mut(name)?;
if *valid_until >= Instant::now() {
Some(false)
} else {
self.cache_pos.remove(name);
None
}
}
pub fn insert_pos(&mut self, item: T) {
self.cache_pos.insert(item, Instant::now() + self.ttl_pos);
}
pub fn insert_neg(&mut self, item: T) {
self.cache_neg.insert(item, Instant::now() + self.ttl_neg);
}
pub fn clear(&mut self) {
self.cache_pos.clear();
self.cache_neg.clear();
}
}

View file

@ -0,0 +1,93 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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 mail_send::Credentials;
use super::{Item, Lookup, LookupResult};
impl Lookup {
pub async fn contains(&self, entry: &str) -> Option<bool> {
match self {
Lookup::Remote(tx) => tx
.lookup(Item::IsAccount(entry.to_string()))
.await
.map(|r| r.into()),
Lookup::Sql(sql) => sql.exists(entry).await,
Lookup::Local(entries) => Some(entries.contains(entry)),
}
}
pub async fn lookup(&self, item: Item) -> Option<LookupResult> {
match self {
Lookup::Remote(tx) => tx.lookup(item).await,
Lookup::Sql(sql) => match item {
Item::IsAccount(account) => sql.exists(&account).await.map(LookupResult::from),
Item::Authenticate(credentials) => match credentials {
Credentials::Plain { username, secret }
| Credentials::XOauth2 { username, secret } => sql
.fetch_one(&username)
.await
.map(|pwd| LookupResult::from(pwd.map_or(false, |pwd| pwd == secret))),
Credentials::OAuthBearer { token } => {
sql.exists(&token).await.map(LookupResult::from)
}
},
Item::Verify(account) => sql.fetch_many(&account).await.map(LookupResult::from),
Item::Expand(list) => sql.fetch_many(&list).await.map(LookupResult::from),
},
Lookup::Local(list) => match item {
Item::IsAccount(item) => Some(list.contains(&item).into()),
Item::Verify(_item) | Item::Expand(_item) => {
#[cfg(feature = "test_mode")]
for list_item in list {
if let Some((prefix, suffix)) = list_item.split_once(':') {
if prefix == _item {
return Some(LookupResult::Values(
suffix.split(',').map(|i| i.to_string()).collect::<Vec<_>>(),
));
}
}
}
Some(LookupResult::False)
}
Item::Authenticate(credentials) => {
let entry = match credentials {
Credentials::Plain { username, secret }
| Credentials::XOauth2 { username, secret } => {
format!("{username}:{secret}")
}
Credentials::OAuthBearer { token } => token,
};
if !list.is_empty() {
Some(list.contains(&entry).into())
} else {
None
}
}
},
}
}
}

View file

@ -0,0 +1,485 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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::{fmt::Display, sync::Arc, time::Duration};
use mail_send::Credentials;
use rustls::ServerName;
use smtp_proto::{
request::{parser::Rfc5321Parser, AUTH},
response::generate::BitToString,
IntoString, AUTH_CRAM_MD5, AUTH_LOGIN, AUTH_OAUTHBEARER, AUTH_PLAIN, AUTH_XOAUTH2,
};
use tokio::{
io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt},
net::{TcpStream, ToSocketAddrs},
sync::mpsc,
};
use tokio_rustls::{client::TlsStream, TlsConnector};
use crate::lookup::spawn::LoggedUnwrap;
use super::{Event, Item, LookupItem, RemoteLookup};
pub struct ImapAuthClient<T: AsyncRead + AsyncWrite> {
stream: T,
timeout: Duration,
}
pub struct ImapAuthClientBuilder {
pub addr: String,
timeout: Duration,
tls_connector: TlsConnector,
tls_hostname: String,
tls_implicit: bool,
mechanisms: u64,
}
impl ImapAuthClientBuilder {
pub fn new(
addr: String,
timeout: Duration,
tls_connector: TlsConnector,
tls_hostname: String,
tls_implicit: bool,
) -> Self {
Self {
addr,
timeout,
tls_connector,
tls_hostname,
tls_implicit,
mechanisms: AUTH_PLAIN,
}
}
pub async fn init(mut self) -> Self {
let err = match self.connect().await {
Ok(mut client) => match client.authentication_mechanisms().await {
Ok(mechanisms) => {
client.logout().await.ok();
self.mechanisms = mechanisms;
return self;
}
Err(err) => err,
},
Err(err) => err,
};
tracing::warn!(
context = "remote",
event = "error",
remote.addr = &self.addr,
remote.protocol = "imap",
"Could not obtain auth mechanisms: {}",
err
);
self
}
pub async fn connect(&self) -> Result<ImapAuthClient<TlsStream<TcpStream>>, Error> {
ImapAuthClient::connect(
&self.addr,
self.timeout,
&self.tls_connector,
&self.tls_hostname,
self.tls_implicit,
)
.await
}
}
#[derive(Debug)]
pub enum Error {
Io(std::io::Error),
Timeout,
InvalidResponse(String),
InvalidChallenge(String),
AuthenticationFailed,
TLSInvalidName,
Disconnected,
}
impl RemoteLookup for Arc<ImapAuthClientBuilder> {
fn spawn_lookup(&self, lookup: LookupItem, tx: mpsc::Sender<Event>) {
let builder = self.clone();
tokio::spawn(async move {
if let Err(err) = builder.lookup(lookup, &tx).await {
tracing::warn!(
context = "remote",
event = "error",
remote.addr = &builder.addr,
remote.protocol = "imap",
"Remote lookup failed: {}",
err
);
tx.send(Event::WorkerFailed).await.logged_unwrap();
}
});
}
}
impl ImapAuthClientBuilder {
pub async fn lookup(&self, lookup: LookupItem, tx: &mpsc::Sender<Event>) -> Result<(), Error> {
match &lookup.item {
Item::Authenticate(credentials) => {
let mut client = self.connect().await?;
let mechanism = match credentials {
Credentials::Plain { .. }
if (self.mechanisms & (AUTH_PLAIN | AUTH_LOGIN | AUTH_CRAM_MD5)) != 0 =>
{
if self.mechanisms & AUTH_CRAM_MD5 != 0 {
AUTH_CRAM_MD5
} else if self.mechanisms & AUTH_PLAIN != 0 {
AUTH_PLAIN
} else {
AUTH_LOGIN
}
}
Credentials::OAuthBearer { .. } if self.mechanisms & AUTH_OAUTHBEARER != 0 => {
AUTH_OAUTHBEARER
}
Credentials::XOauth2 { .. } if self.mechanisms & AUTH_XOAUTH2 != 0 => {
AUTH_XOAUTH2
}
_ => {
tracing::warn!(
context = "remote",
event = "error",
remote.addr = &self.addr,
remote.protocol = "imap",
"IMAP server does not offer any supported auth mechanisms.",
);
tx.send(Event::WorkerFailed).await.logged_unwrap();
return Ok(());
}
};
let result = match client.authenticate(mechanism, credentials).await {
Ok(_) => true,
Err(err) => match &err {
Error::AuthenticationFailed => false,
_ => return Err(err),
},
};
tx.send(Event::WorkerReady {
item: lookup.item,
result: Some(result),
next_lookup: None,
})
.await
.logged_unwrap();
lookup.result.send(result.into()).logged_unwrap();
}
_ => {
tracing::warn!(
context = "remote",
event = "error",
remote.addr = &self.addr,
remote.protocol = "imap",
"IMAP does not support validating recipients.",
);
tx.send(Event::WorkerFailed).await.logged_unwrap();
}
}
Ok(())
}
}
impl ImapAuthClient<TcpStream> {
async fn start_tls(
mut self,
tls_connector: &TlsConnector,
tls_hostname: &str,
) -> Result<ImapAuthClient<TlsStream<TcpStream>>, Error> {
let line = tokio::time::timeout(self.timeout, async {
self.write(b"C7 STARTTLS\r\n").await?;
self.read_line().await
})
.await
.map_err(|_| Error::Timeout)??;
if matches!(line.get(..5), Some(b"C7 OK")) {
self.into_tls(tls_connector, tls_hostname).await
} else {
Err(Error::InvalidResponse(line.into_string()))
}
}
async fn into_tls(
self,
tls_connector: &TlsConnector,
tls_hostname: &str,
) -> Result<ImapAuthClient<TlsStream<TcpStream>>, Error> {
tokio::time::timeout(self.timeout, async {
Ok(ImapAuthClient {
stream: tls_connector
.connect(
ServerName::try_from(tls_hostname).map_err(|_| Error::TLSInvalidName)?,
self.stream,
)
.await?,
timeout: self.timeout,
})
})
.await
.map_err(|_| Error::Timeout)?
}
}
impl ImapAuthClient<TlsStream<TcpStream>> {
pub async fn connect(
addr: impl ToSocketAddrs,
timeout: Duration,
tls_connector: &TlsConnector,
tls_hostname: &str,
tls_implicit: bool,
) -> Result<Self, Error> {
let mut client: ImapAuthClient<TcpStream> = tokio::time::timeout(timeout, async {
match TcpStream::connect(addr).await {
Ok(stream) => Ok(ImapAuthClient { stream, timeout }),
Err(err) => Err(Error::Io(err)),
}
})
.await
.map_err(|_| Error::Timeout)??;
if tls_implicit {
let mut client = client.into_tls(tls_connector, tls_hostname).await?;
client.expect_greeting().await?;
Ok(client)
} else {
client.expect_greeting().await?;
client.start_tls(tls_connector, tls_hostname).await
}
}
}
impl<T: AsyncRead + AsyncWrite + Unpin> ImapAuthClient<T> {
pub async fn authenticate(
&mut self,
mechanism: u64,
credentials: &Credentials<String>,
) -> Result<(), Error> {
if (mechanism & (AUTH_PLAIN | AUTH_XOAUTH2 | AUTH_OAUTHBEARER)) != 0 {
self.write(
format!(
"C3 AUTHENTICATE {} {}\r\n",
mechanism.to_mechanism(),
credentials
.encode(mechanism, "")
.map_err(|err| Error::InvalidChallenge(err.to_string()))?
)
.as_bytes(),
)
.await?;
} else {
self.write(format!("C3 AUTHENTICATE {}\r\n", mechanism.to_mechanism()).as_bytes())
.await?;
}
let mut line = self.read_line().await?;
for _ in 0..3 {
if matches!(line.first(), Some(b'+')) {
self.write(
format!(
"{}\r\n",
credentials
.encode(
mechanism,
std::str::from_utf8(line.get(2..).unwrap_or_default())
.unwrap_or_default()
)
.map_err(|err| Error::InvalidChallenge(err.to_string()))?
)
.as_bytes(),
)
.await?;
line = self.read_line().await?;
} else if matches!(line.get(..5), Some(b"C3 OK")) {
return Ok(());
} else if matches!(line.get(..5), Some(b"C3 NO"))
|| matches!(line.get(..6), Some(b"C3 BAD"))
{
return Err(Error::AuthenticationFailed);
} else {
return Err(Error::InvalidResponse(line.into_string()));
}
}
Err(Error::InvalidResponse(line.into_string()))
}
pub async fn authentication_mechanisms(&mut self) -> Result<u64, Error> {
tokio::time::timeout(self.timeout, async {
self.write(b"C0 CAPABILITY\r\n").await?;
let line = self.read_line().await?;
if !matches!(line.get(..12), Some(b"* CAPABILITY")) {
return Err(Error::InvalidResponse(line.into_string()));
}
let mut line_iter = line.iter();
let mut parser = Rfc5321Parser::new(&mut line_iter);
let mut mechanisms = 0;
'outer: while let Ok(ch) = parser.read_char() {
if ch == b' ' {
loop {
if parser.hashed_value().unwrap_or(0) == AUTH && parser.stop_char == b'=' {
if let Ok(Some(mechanism)) = parser.mechanism() {
mechanisms |= mechanism;
}
match parser.stop_char {
b' ' => (),
b'\n' => break 'outer,
_ => break,
}
}
}
} else if ch == b'\n' {
break;
}
}
Ok(mechanisms)
})
.await
.map_err(|_| Error::Timeout)?
}
pub async fn noop(&mut self) -> Result<(), Error> {
tokio::time::timeout(self.timeout, async {
self.write(b"C8 NOOP\r\n").await?;
self.read_line().await?;
Ok(())
})
.await
.map_err(|_| Error::Timeout)?
}
pub async fn logout(&mut self) -> Result<(), Error> {
tokio::time::timeout(self.timeout, async {
self.write(b"C9 LOGOUT\r\n").await?;
Ok(())
})
.await
.map_err(|_| Error::Timeout)?
}
pub async fn expect_greeting(&mut self) -> Result<(), Error> {
tokio::time::timeout(self.timeout, async {
let line = self.read_line().await?;
if matches!(line.get(..4), Some(b"* OK")) {
Ok(())
} else {
Err(Error::InvalidResponse(line.into_string()))
}
})
.await
.map_err(|_| Error::Timeout)?
}
pub async fn read_line(&mut self) -> Result<Vec<u8>, Error> {
let mut buf = vec![0u8; 1024];
let mut buf_extended = Vec::with_capacity(0);
loop {
let br = self.stream.read(&mut buf).await?;
if br > 0 {
if matches!(buf.get(br - 1), Some(b'\n')) {
//println!("{:?}", std::str::from_utf8(&buf[..br]).unwrap());
return Ok(if buf_extended.is_empty() {
buf.truncate(br);
buf
} else {
buf_extended.extend_from_slice(&buf[..br]);
buf_extended
});
} else if buf_extended.is_empty() {
buf_extended = buf[..br].to_vec();
} else {
buf_extended.extend_from_slice(&buf[..br]);
}
} else {
return Err(Error::Disconnected);
}
}
}
async fn write(&mut self, bytes: &[u8]) -> Result<(), std::io::Error> {
self.stream.write_all(bytes).await?;
self.stream.flush().await
}
}
impl From<std::io::Error> for Error {
fn from(error: std::io::Error) -> Self {
Error::Io(error)
}
}
impl Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::Io(io) => write!(f, "I/O error: {io}"),
Error::Timeout => f.write_str("Connection time-out"),
Error::InvalidResponse(response) => write!(f, "Unexpected response: {response:?}"),
Error::InvalidChallenge(response) => write!(f, "Invalid auth challenge: {response}"),
Error::TLSInvalidName => f.write_str("Invalid TLS name"),
Error::Disconnected => f.write_str("Connection disconnected by peer"),
Error::AuthenticationFailed => f.write_str("Authentication failed"),
}
}
}
#[cfg(test)]
mod test {
use crate::lookup::imap::ImapAuthClient;
use mail_send::smtp::tls::build_tls_connector;
use smtp_proto::{AUTH_OAUTHBEARER, AUTH_PLAIN, AUTH_XOAUTH, AUTH_XOAUTH2};
use std::time::Duration;
#[ignore]
#[tokio::test]
async fn imap_auth() {
let connector = build_tls_connector(false);
let mut client = ImapAuthClient::connect(
"imap.gmail.com:993",
Duration::from_secs(5),
&connector,
"imap.gmail.com",
true,
)
.await
.unwrap();
assert_eq!(
AUTH_PLAIN | AUTH_XOAUTH | AUTH_XOAUTH2 | AUTH_OAUTHBEARER,
client.authentication_mechanisms().await.unwrap()
);
client.logout().await.unwrap();
}
}

View file

@ -0,0 +1,113 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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 ahash::AHashSet;
use mail_send::Credentials;
use parking_lot::Mutex;
use tokio::sync::{mpsc, oneshot};
use self::cache::LookupCache;
pub mod cache;
pub mod dispatch;
pub mod imap;
pub mod smtp;
pub mod spawn;
pub mod sql;
#[derive(Debug)]
pub enum Lookup {
Local(AHashSet<String>),
Remote(LookupChannel),
Sql(SqlQuery),
}
#[derive(Debug, Clone)]
pub enum SqlDatabase {
Postgres(sqlx::Pool<sqlx::Postgres>),
MySql(sqlx::Pool<sqlx::MySql>),
//MsSql(sqlx::Pool<sqlx::Mssql>),
SqlLite(sqlx::Pool<sqlx::Sqlite>),
}
#[derive(Debug)]
pub struct SqlQuery {
pub query: String,
pub db: SqlDatabase,
pub cache: Option<Mutex<LookupCache<String>>>,
}
impl Default for Lookup {
fn default() -> Self {
Lookup::Local(AHashSet::default())
}
}
#[derive(Debug)]
pub enum Event {
Lookup(LookupItem),
WorkerReady {
item: Item,
result: Option<bool>,
next_lookup: Option<oneshot::Sender<Option<LookupItem>>>,
},
WorkerFailed,
Reload,
Stop,
}
#[derive(Clone, PartialEq, Eq, Hash)]
pub enum Item {
IsAccount(String),
Authenticate(Credentials<String>),
Verify(String),
Expand(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LookupResult {
True,
False,
Values(Vec<String>),
}
#[derive(Debug)]
pub struct LookupItem {
pub item: Item,
pub result: oneshot::Sender<LookupResult>,
}
#[derive(Debug, Clone)]
pub struct LookupChannel {
pub tx: mpsc::Sender<Event>,
}
#[derive(Clone)]
struct RemoteHost<T: RemoteLookup> {
tx: mpsc::Sender<Event>,
host: T,
}
pub trait RemoteLookup: Clone {
fn spawn_lookup(&self, lookup: LookupItem, tx: mpsc::Sender<Event>);
}

View file

@ -0,0 +1,180 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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::sync::Arc;
use mail_send::smtp::AssertReply;
use smtp_proto::Severity;
use tokio::sync::{mpsc, oneshot};
use super::{spawn::LoggedUnwrap, Event, Item, LookupItem, LookupResult, RemoteLookup};
pub struct SmtpClientBuilder {
pub builder: mail_send::SmtpClientBuilder<String>,
pub max_rcpt: usize,
pub max_auth_errors: usize,
}
impl SmtpClientBuilder {
pub async fn lookup_smtp(
&self,
mut lookup: LookupItem,
tx: &mpsc::Sender<Event>,
) -> Result<(), mail_send::Error> {
let mut client = self.builder.connect().await?;
let mut sent_mail_from = false;
let mut num_rcpts = 0;
let mut num_auth_failures = 0;
let capabilities = client
.capabilities(&self.builder.local_host, self.builder.is_lmtp)
.await?;
loop {
let (result, is_reusable): (LookupResult, bool) = match &lookup.item {
Item::IsAccount(rcpt_to) => {
if !sent_mail_from {
client
.cmd(b"MAIL FROM:<>\r\n")
.await?
.assert_positive_completion()?;
sent_mail_from = true;
}
let reply = client
.cmd(format!("RCPT TO:<{rcpt_to}>\r\n").as_bytes())
.await?;
let result = match reply.severity() {
Severity::PositiveCompletion => {
num_rcpts += 1;
LookupResult::True
}
Severity::PermanentNegativeCompletion => LookupResult::False,
_ => return Err(mail_send::Error::UnexpectedReply(reply)),
};
// Try to reuse the connection with any queued requests
(result, num_rcpts < self.max_rcpt)
}
Item::Authenticate(credentials) => {
let result = match client.authenticate(credentials, &capabilities).await {
Ok(_) => true,
Err(err) => match &err {
mail_send::Error::AuthenticationFailed(err) if err.code() == 535 => {
num_auth_failures += 1;
false
}
_ => {
return Err(err);
}
},
};
(
result.into(),
!result && num_auth_failures < self.max_auth_errors,
)
}
Item::Verify(address) | Item::Expand(address) => {
let reply = client
.cmd(
if matches!(&lookup.item, Item::Verify(_)) {
format!("VRFY {address}\r\n")
} else {
format!("EXPN {address}\r\n")
}
.as_bytes(),
)
.await?;
match reply.code() {
250 | 251 => (
reply
.message()
.split('\n')
.map(|p| p.to_string())
.collect::<Vec<String>>()
.into(),
true,
),
550 | 551 | 553 | 500 | 502 => (LookupResult::False, true),
_ => {
return Err(mail_send::Error::UnexpectedReply(reply));
}
}
}
};
// Try to reuse the connection with any queued requests
let cached_result = match &result {
LookupResult::True => Some(true),
LookupResult::False => Some(false),
LookupResult::Values(_) => None,
};
lookup.result.send(result).logged_unwrap();
if is_reusable {
let (next_lookup_tx, next_lookup_rx) = oneshot::channel::<Option<LookupItem>>();
if tx
.send(Event::WorkerReady {
item: lookup.item,
result: cached_result,
next_lookup: next_lookup_tx.into(),
})
.await
.logged_unwrap()
{
if let Ok(Some(next_lookup)) = next_lookup_rx.await {
lookup = next_lookup;
continue;
}
}
} else {
tx.send(Event::WorkerReady {
item: lookup.item,
result: cached_result,
next_lookup: None,
})
.await
.logged_unwrap();
}
break;
}
Ok(())
}
}
impl RemoteLookup for Arc<SmtpClientBuilder> {
fn spawn_lookup(&self, lookup: LookupItem, tx: mpsc::Sender<Event>) {
let builder = self.clone();
tokio::spawn(async move {
if let Err(err) = builder.lookup_smtp(lookup, &tx).await {
tracing::warn!(
context = "remote",
event = "lookup-failed",
remote.addr = &builder.builder.addr,
remote.protocol = "smtp",
"Remote lookup failed: {}",
err
);
tx.send(Event::WorkerFailed).await.logged_unwrap();
}
});
}
}

View file

@ -0,0 +1,259 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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::{collections::VecDeque, fmt::Debug, sync::Arc, time::Duration};
use crate::config::Host;
use mail_send::smtp::tls::build_tls_connector;
use tokio::sync::{mpsc, oneshot};
use utils::config::{Config, ServerProtocol};
use super::{
cache::LookupCache, imap::ImapAuthClientBuilder, smtp::SmtpClientBuilder, Event, Item,
LookupChannel, LookupItem, LookupResult, RemoteHost, RemoteLookup,
};
impl Host {
pub fn spawn(self, config: &Config) -> LookupChannel {
// Create channel
let local_host = config
.value("server.hostname")
.unwrap_or("[127.0.0.1]")
.to_string();
let tx_ = self.channel_tx.clone();
tokio::spawn(async move {
// Prepare builders
match self.protocol {
ServerProtocol::Smtp | ServerProtocol::Lmtp => {
RemoteHost {
tx: self.channel_tx,
host: Arc::new(SmtpClientBuilder {
builder: mail_send::SmtpClientBuilder {
addr: format!("{}:{}", self.address, self.port),
timeout: self.timeout,
tls_connector: build_tls_connector(self.tls_allow_invalid_certs),
tls_hostname: self.address,
tls_implicit: self.tls_implicit,
is_lmtp: matches!(self.protocol, ServerProtocol::Lmtp),
credentials: None,
local_host,
},
max_rcpt: self.max_requests,
max_auth_errors: self.max_errors,
}),
}
.run(
self.channel_rx,
self.cache_entries,
self.cache_ttl_positive,
self.cache_ttl_negative,
self.concurrency,
)
.await;
}
ServerProtocol::Imap => {
RemoteHost {
tx: self.channel_tx,
host: Arc::new(
ImapAuthClientBuilder::new(
format!("{}:{}", self.address, self.port),
self.timeout,
build_tls_connector(self.tls_allow_invalid_certs),
self.address,
self.tls_implicit,
)
.init()
.await,
),
}
.run(
self.channel_rx,
self.cache_entries,
self.cache_ttl_positive,
self.cache_ttl_negative,
self.concurrency,
)
.await;
}
ServerProtocol::Http | ServerProtocol::Jmap => {
eprintln!("HTTP/JMAP lookups are not supported.");
std::process::exit(0);
}
}
});
LookupChannel { tx: tx_ }
}
}
impl<T: RemoteLookup> RemoteHost<T> {
pub async fn run(
&self,
mut rx: mpsc::Receiver<Event>,
entries: usize,
ttl_pos: Duration,
ttl_neg: Duration,
max_concurrent: usize,
) {
// Create caches and queue
let mut cache = LookupCache::new(entries, ttl_pos, ttl_neg);
let mut queue = VecDeque::new();
let mut active_lookups = 0;
while let Some(event) = rx.recv().await {
match event {
Event::Lookup(lookup) => {
if let Some(result) = cache.get(&lookup.item) {
lookup.result.send(result.into()).logged_unwrap();
} else if active_lookups < max_concurrent {
active_lookups += 1;
self.host.spawn_lookup(lookup, self.tx.clone());
} else {
queue.push_back(lookup);
}
}
Event::WorkerReady {
item,
result,
next_lookup,
} => {
match result {
Some(true) => cache.insert_pos(item),
Some(false) => cache.insert_neg(item),
_ => (),
}
let mut lookup = None;
while let Some(queued_lookup) = queue.pop_front() {
if let Some(result) = cache.get(&queued_lookup.item) {
queued_lookup.result.send(result.into()).logged_unwrap();
} else {
lookup = queued_lookup.into();
break;
}
}
if let Some(next_lookup) = next_lookup {
if lookup.is_none() {
active_lookups -= 1;
}
next_lookup.send(lookup).logged_unwrap();
} else if let Some(lookup) = lookup {
self.host.spawn_lookup(lookup, self.tx.clone());
} else {
active_lookups -= 1;
}
}
Event::WorkerFailed => {
if let Some(queued_lookup) = queue.pop_front() {
self.host.spawn_lookup(queued_lookup, self.tx.clone());
} else {
active_lookups -= 1;
}
}
Event::Stop => {
queue.clear();
break;
}
Event::Reload => {
cache.clear();
}
}
}
}
}
impl LookupChannel {
pub async fn lookup(&self, item: Item) -> Option<LookupResult> {
let (tx, rx) = oneshot::channel();
if self
.tx
.send(Event::Lookup(LookupItem { item, result: tx }))
.await
.is_ok()
{
rx.await.ok()
} else {
None
}
}
}
impl From<mpsc::Sender<Event>> for LookupChannel {
fn from(tx: mpsc::Sender<Event>) -> Self {
LookupChannel { tx }
}
}
impl From<LookupResult> for bool {
fn from(value: LookupResult) -> Self {
matches!(value, LookupResult::True | LookupResult::Values(_))
}
}
impl From<bool> for LookupResult {
fn from(value: bool) -> Self {
if value {
LookupResult::True
} else {
LookupResult::False
}
}
}
impl From<Vec<String>> for LookupResult {
fn from(value: Vec<String>) -> Self {
if !value.is_empty() {
LookupResult::Values(value)
} else {
LookupResult::False
}
}
}
pub trait LoggedUnwrap {
fn logged_unwrap(self) -> bool;
}
impl<T, E: std::fmt::Debug> LoggedUnwrap for Result<T, E> {
fn logged_unwrap(self) -> bool {
match self {
Ok(_) => true,
Err(err) => {
tracing::debug!("Failed to send message over channel: {:?}", err);
false
}
}
}
}
impl Debug for Item {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::IsAccount(arg0) => f.debug_tuple("Rcpt").field(arg0).finish(),
Self::Authenticate(_) => f.debug_tuple("Auth").finish(),
Self::Expand(arg0) => f.debug_tuple("Expn").field(arg0).finish(),
Self::Verify(arg0) => f.debug_tuple("Vrfy").field(arg0).finish(),
}
}
}

View file

@ -0,0 +1,237 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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 super::{SqlDatabase, SqlQuery};
impl SqlQuery {
pub async fn exists(&self, param: &str) -> Option<bool> {
if let Some(result) = self
.cache
.as_ref()
.and_then(|cache| cache.lock().get(param))
{
return Some(result);
}
let result = match &self.db {
super::SqlDatabase::Postgres(pool) => {
sqlx::query_scalar::<_, bool>(&self.query)
.bind(param)
.fetch_one(pool)
.await
}
super::SqlDatabase::MySql(pool) => {
sqlx::query_scalar::<_, bool>(&self.query)
.bind(param)
.fetch_one(pool)
.await
}
/*super::SqlDatabase::MsSql(pool) => {
sqlx::query_scalar::<_, bool>(&self.query)
.bind(param)
.fetch_one(pool)
.await
}*/
super::SqlDatabase::SqlLite(pool) => {
sqlx::query_scalar::<_, bool>(&self.query)
.bind(param)
.fetch_one(pool)
.await
}
};
match result {
Ok(result) => {
if let Some(cache) = &self.cache {
if result {
cache.lock().insert_pos(param.to_string());
} else {
cache.lock().insert_neg(param.to_string());
}
}
Some(result)
}
Err(err) => {
tracing::warn!(context = "sql", event = "error", query = self.query, reason = ?err);
None
}
}
}
pub async fn fetch_one(&self, param: &str) -> Option<Option<String>> {
let result = match &self.db {
super::SqlDatabase::Postgres(pool) => {
sqlx::query_scalar::<_, String>(&self.query)
.bind(param)
.fetch_optional(pool)
.await
}
super::SqlDatabase::MySql(pool) => {
sqlx::query_scalar::<_, String>(&self.query)
.bind(param)
.fetch_optional(pool)
.await
}
/*super::SqlDatabase::MsSql(pool) => {
sqlx::query_scalar::<_, String>(&self.query)
.bind(param)
.fetch_optional(pool)
.await
}*/
super::SqlDatabase::SqlLite(pool) => {
sqlx::query_scalar::<_, String>(&self.query)
.bind(param)
.fetch_optional(pool)
.await
}
};
match result {
Ok(result) => Some(result),
Err(err) => {
tracing::warn!(context = "sql", event = "error", query = self.query, reason = ?err);
None
}
}
}
pub async fn fetch_many(&self, param: &str) -> Option<Vec<String>> {
let result = match &self.db {
super::SqlDatabase::Postgres(pool) => {
sqlx::query_scalar::<_, String>(&self.query)
.bind(param)
.fetch_all(pool)
.await
}
super::SqlDatabase::MySql(pool) => {
sqlx::query_scalar::<_, String>(&self.query)
.bind(param)
.fetch_all(pool)
.await
}
/*super::SqlDatabase::MsSql(pool) => {
sqlx::query_scalar::<_, String>(&self.query)
.bind(param)
.fetch_all(pool)
.await
}*/
super::SqlDatabase::SqlLite(pool) => {
sqlx::query_scalar::<_, String>(&self.query)
.bind(param)
.fetch_all(pool)
.await
}
};
match result {
Ok(result) => Some(result),
Err(err) => {
tracing::warn!(context = "sql", event = "error", query = self.query, reason = ?err);
None
}
}
}
}
impl SqlDatabase {
pub async fn exists(&self, query: &str, params: impl Iterator<Item = String>) -> Option<bool> {
let result = match self {
super::SqlDatabase::Postgres(pool) => {
let mut q = sqlx::query_scalar::<_, bool>(query);
for param in params {
q = q.bind(param);
}
q.fetch_one(pool).await
}
super::SqlDatabase::MySql(pool) => {
let mut q = sqlx::query_scalar::<_, bool>(query);
for param in params {
q = q.bind(param);
}
q.fetch_one(pool).await
}
/*super::SqlDatabase::MsSql(pool) => {
let mut q = sqlx::query_scalar::<_, bool>(query);
for param in params {
q = q.bind(param);
}
q.fetch_one(pool).await
}*/
super::SqlDatabase::SqlLite(pool) => {
let mut q = sqlx::query_scalar::<_, bool>(query);
for param in params {
q = q.bind(param);
}
q.fetch_one(pool).await
}
};
match result {
Ok(result) => Some(result),
Err(err) => {
tracing::warn!(context = "sql", event = "error", query = query, reason = ?err);
None
}
}
}
pub async fn execute(&self, query: &str, params: impl Iterator<Item = String>) -> bool {
let result = match self {
super::SqlDatabase::Postgres(pool) => {
let mut q = sqlx::query(query);
for param in params {
q = q.bind(param);
}
q.execute(pool).await.map(|_| ())
}
super::SqlDatabase::MySql(pool) => {
let mut q = sqlx::query(query);
for param in params {
q = q.bind(param);
}
q.execute(pool).await.map(|_| ())
}
/*super::SqlDatabase::MsSql(pool) => {
let mut q = sqlx::query(query);
for param in params {
q = q.bind(param);
}
q.execute(pool).await.map(|_| ())
}*/
super::SqlDatabase::SqlLite(pool) => {
let mut q = sqlx::query(query);
for param in params {
q = q.bind(param);
}
q.execute(pool).await.map(|_| ())
}
};
match result {
Ok(_) => true,
Err(err) => {
tracing::warn!(context = "sql", event = "error", query = query, reason = ?err);
false
}
}
}
}

385
crates/smtp/src/old_main.rs Normal file
View file

@ -0,0 +1,385 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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::{collections::HashMap, fs, sync::Arc, time::Duration};
use dashmap::DashMap;
use mail_send::smtp::tls::build_tls_connector;
use opentelemetry::{
sdk::{
trace::{self, Sampler},
Resource,
},
KeyValue,
};
use opentelemetry_otlp::WithExportConfig;
use opentelemetry_semantic_conventions::resource::{SERVICE_NAME, SERVICE_VERSION};
use stalwart_smtp::{
config::{Config, ConfigContext, ServerProtocol},
core::{
throttle::{ConcurrencyLimiter, ThrottleKeyHasherBuilder},
Core, QueueCore, ReportCore, SessionCore, TlsConnectors,
},
failed,
queue::{self, manager::SpawnQueue},
reporting::{self, scheduler::SpawnReport},
UnwrapFailure,
};
use tokio::sync::{mpsc, watch};
use tracing_appender::non_blocking::WorkerGuard;
use tracing_subscriber::{prelude::__tracing_subscriber_SubscriberExt, EnvFilter};
#[tokio::main]
async fn main() -> std::io::Result<()> {
// Read configuration parameters
let config = parse_config();
let mut config_context = ConfigContext::default();
config
.parse_servers(&mut config_context)
.failed("Configuration error");
config
.parse_remote_hosts(&mut config_context)
.failed("Configuration error");
config
.parse_databases(&mut config_context)
.failed("Configuration error");
config
.parse_lists(&mut config_context)
.failed("Configuration error");
config
.parse_signatures(&mut config_context)
.failed("Configuration error");
let sieve_config = config
.parse_sieve(&mut config_context)
.failed("Configuration error");
let session_config = config
.parse_session_config(&config_context)
.failed("Configuration error");
let queue_config = config
.parse_queue(&config_context)
.failed("Configuration error");
let mail_auth_config = config
.parse_mail_auth(&config_context)
.failed("Configuration error");
let report_config = config
.parse_reports(&config_context)
.failed("Configuration error");
// Build core
let (queue_tx, queue_rx) = mpsc::channel(1024);
let (report_tx, report_rx) = mpsc::channel(1024);
let core = Arc::new(Core {
worker_pool: rayon::ThreadPoolBuilder::new()
.num_threads(
config
.property::<usize>("global.thread-pool")
.failed("Failed to parse thread pool size")
.filter(|v| *v > 0)
.unwrap_or_else(num_cpus::get),
)
.build()
.unwrap(),
resolvers: config.build_resolvers().failed("Failed to build resolvers"),
session: SessionCore {
config: session_config,
throttle: DashMap::with_capacity_and_hasher_and_shard_amount(
config
.property("global.shared-map.capacity")
.failed("Failed to parse shared map capacity")
.unwrap_or(2),
ThrottleKeyHasherBuilder::default(),
config
.property::<u64>("global.shared-map.shard")
.failed("Failed to parse shared map shard amount")
.unwrap_or(32)
.next_power_of_two() as usize,
),
},
queue: QueueCore {
config: queue_config,
throttle: DashMap::with_capacity_and_hasher_and_shard_amount(
config
.property("global.shared-map.capacity")
.failed("Failed to parse shared map capacity")
.unwrap_or(2),
ThrottleKeyHasherBuilder::default(),
config
.property::<u64>("global.shared-map.shard")
.failed("Failed to parse shared map shard amount")
.unwrap_or(32)
.next_power_of_two() as usize,
),
id_seq: 0.into(),
quota: DashMap::with_capacity_and_hasher_and_shard_amount(
config
.property("global.shared-map.capacity")
.failed("Failed to parse shared map capacity")
.unwrap_or(2),
ThrottleKeyHasherBuilder::default(),
config
.property::<u64>("global.shared-map.shard")
.failed("Failed to parse shared map shard amount")
.unwrap_or(32)
.next_power_of_two() as usize,
),
tx: queue_tx,
connectors: TlsConnectors {
pki_verify: build_tls_connector(false),
dummy_verify: build_tls_connector(true),
},
},
report: ReportCore {
tx: report_tx,
config: report_config,
},
mail_auth: mail_auth_config,
sieve: sieve_config,
});
// Bind ports before dropping privileges
for server in &config_context.servers {
for listener in &server.listeners {
listener
.socket
.bind(listener.addr)
.failed(&format!("Failed to bind to {}", listener.addr));
}
}
// Drop privileges
#[cfg(not(target_env = "msvc"))]
{
if let Some(run_as_user) = config.value("server.run-as.user") {
let mut pd = privdrop::PrivDrop::default().user(run_as_user);
if let Some(run_as_group) = config.value("server.run-as.group") {
pd = pd.group(run_as_group);
}
pd.apply().failed("Failed to drop privileges");
}
}
// Enable tracing
let _tracer = enable_tracing(&config).failed("Failed to enable tracing");
tracing::info!(
"Starting Stalwart SMTP server v{}...",
env!("CARGO_PKG_VERSION")
);
// Spawn queue manager
queue_rx.spawn(core.clone(), core.queue.read_queue().await);
// Spawn report manager
report_rx.spawn(core.clone(), core.report.read_reports().await);
// Spawn remote hosts
for host in config_context.hosts.into_values() {
if host.lookup {
host.spawn(&config);
}
}
// Spawn listeners
let (shutdown_tx, shutdown_rx) = watch::channel(false);
for server in config_context.servers {
match server.protocol {
ServerProtocol::Smtp | ServerProtocol::Lmtp => server
.spawn(core.clone(), shutdown_rx.clone())
.failed("Failed to start listener"),
ServerProtocol::Http => server
.spawn_management(core.clone(), shutdown_rx.clone())
.failed("Failed to start management interface"),
ServerProtocol::Imap => {
eprintln!("Invalid protocol 'imap' for listener '{}'.", server.id);
std::process::exit(0);
}
}
}
// Wait for shutdown signal
#[cfg(not(target_env = "msvc"))]
{
use tokio::signal::unix::{signal, SignalKind};
let mut h_term = signal(SignalKind::terminate()).failed("start signal handler");
let mut h_int = signal(SignalKind::interrupt()).failed("start signal handler");
tokio::select! {
_ = h_term.recv() => tracing::debug!("Received SIGTERM."),
_ = h_int.recv() => tracing::debug!("Received SIGINT."),
};
}
#[cfg(target_env = "msvc")]
{
match tokio::signal::ctrl_c().await {
Ok(()) => {}
Err(err) => {
eprintln!("Unable to listen for shutdown signal: {}", err);
}
}
}
// Shutdown the system
tracing::info!(
"Shutting down Stalwart SMTP server v{}...",
env!("CARGO_PKG_VERSION")
);
// Stop services
shutdown_tx.send(true).ok();
core.queue.tx.send(queue::Event::Stop).await.ok();
core.report.tx.send(reporting::Event::Stop).await.ok();
// Wait for services to finish
tokio::time::sleep(Duration::from_secs(1)).await;
Ok(())
}
fn enable_tracing(config: &Config) -> stalwart_smtp::config::Result<Option<WorkerGuard>> {
let level = config.value("global.tracing.level").unwrap_or("info");
let env_filter = EnvFilter::builder()
.parse(format!("stalwart_smtp={}", level))
.failed("Failed to log level");
match config.value("global.tracing.method").unwrap_or_default() {
"log" => {
let path = config.value_require("global.tracing.path")?;
let prefix = config.value_require("global.tracing.prefix")?;
let file_appender = match config.value("global.tracing.rotate").unwrap_or("daily") {
"daily" => tracing_appender::rolling::daily(path, prefix),
"hourly" => tracing_appender::rolling::hourly(path, prefix),
"minutely" => tracing_appender::rolling::minutely(path, prefix),
"never" => tracing_appender::rolling::never(path, prefix),
rotate => {
return Err(format!("Unsupported log rotation strategy {rotate:?}"));
}
};
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
tracing::subscriber::set_global_default(
tracing_subscriber::FmtSubscriber::builder()
.with_env_filter(env_filter)
.with_writer(non_blocking)
.finish(),
)
.failed("Failed to set subscriber");
Ok(guard.into())
}
"stdout" => {
tracing::subscriber::set_global_default(
tracing_subscriber::FmtSubscriber::builder()
.with_env_filter(env_filter)
.finish(),
)
.failed("Failed to set subscriber");
Ok(None)
}
"otel" | "open-telemetry" => {
let tracer = match config.value_require("global.tracing.transport")? {
"grpc" => {
let mut exporter = opentelemetry_otlp::new_exporter().tonic();
if let Some(endpoint) = config.value("global.tracing.endpoint") {
exporter = exporter.with_endpoint(endpoint);
}
opentelemetry_otlp::new_pipeline()
.tracing()
.with_exporter(exporter)
}
"http" => {
let mut headers = HashMap::new();
for (_, value) in config.values("global.tracing.headers") {
if let Some((key, value)) = value.split_once(':') {
headers.insert(key.trim().to_string(), value.trim().to_string());
} else {
return Err(format!("Invalid open-telemetry header {value:?}"));
}
}
let mut exporter = opentelemetry_otlp::new_exporter()
.http()
.with_endpoint(config.value_require("global.tracing.endpoint")?);
if !headers.is_empty() {
exporter = exporter.with_headers(headers);
}
opentelemetry_otlp::new_pipeline()
.tracing()
.with_exporter(exporter)
}
transport => {
return Err(format!(
"Unsupported open-telemetry transport {transport:?}"
));
}
}
.with_trace_config(
trace::config()
.with_resource(Resource::new(vec![
KeyValue::new(SERVICE_NAME, "stalwart-smtp".to_string()),
KeyValue::new(SERVICE_VERSION, env!("CARGO_PKG_VERSION").to_string()),
]))
.with_sampler(Sampler::AlwaysOn),
)
.install_batch(opentelemetry::runtime::Tokio)
.failed("Failed to create tracer");
tracing::subscriber::set_global_default(
tracing_subscriber::Registry::default()
.with(tracing_opentelemetry::layer().with_tracer(tracer))
.with(env_filter),
)
.failed("Failed to set subscriber");
Ok(None)
}
_ => Ok(None),
}
}
fn parse_config() -> Config {
let mut config_path = None;
let mut found_param = false;
for arg in std::env::args().skip(1) {
if let Some((key, value)) = arg.split_once('=') {
if key.starts_with("--config") {
config_path = value.trim().to_string().into();
break;
} else {
failed(&format!("Invalid command line argument: {key}"));
}
} else if found_param {
config_path = arg.into();
break;
} else if arg.starts_with("--config") {
found_param = true;
} else {
failed(&format!("Invalid command line argument: {arg}"));
}
}
Config::parse(
&fs::read_to_string(config_path.failed("Missing parameter --config=<path-to-config>."))
.failed("Could not read configuration file"),
)
.failed("Invalid configuration file")
}

View file

@ -0,0 +1,137 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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 mail_auth::{
common::{lru::DnsCache, resolver::IntoFqdn},
trust_dns_resolver::{
config::{ResolverConfig, ResolverOpts},
error::{ResolveError, ResolveErrorKind},
proto::{
error::ProtoErrorKind,
rr::rdata::tlsa::{CertUsage, Matching, Selector},
},
AsyncResolver,
},
};
use std::sync::Arc;
use crate::core::Resolvers;
use super::{DnssecResolver, Tlsa, TlsaEntry};
impl DnssecResolver {
pub fn with_capacity(
config: ResolverConfig,
options: ResolverOpts,
) -> Result<Self, ResolveError> {
Ok(Self {
resolver: AsyncResolver::tokio(config, options)?,
})
}
}
impl Resolvers {
pub async fn tlsa_lookup<'x>(
&self,
key: impl IntoFqdn<'x>,
) -> mail_auth::Result<Option<Arc<Tlsa>>> {
let key = key.into_fqdn();
if let Some(value) = self.cache.tlsa.get(key.as_ref()) {
return Ok(Some(value));
}
#[cfg(any(test, feature = "test_mode"))]
if true {
return mail_auth::common::resolver::mock_resolve(key.as_ref());
}
let mut entries = Vec::new();
let tlsa_lookup = match self.dnssec.resolver.tlsa_lookup(key.as_ref()).await {
Ok(tlsa_lookup) => tlsa_lookup,
Err(err) => {
return match &err.kind() {
ResolveErrorKind::Proto(proto_err)
if matches!(proto_err.kind(), ProtoErrorKind::RrsigsNotPresent { .. }) =>
{
Ok(None)
}
_ => Err(err.into()),
};
}
};
let mut has_end_entities = false;
let mut has_intermediates = false;
for record in tlsa_lookup.as_lookup().record_iter() {
if let Some(tlsa) = record.data().and_then(|r| r.as_tlsa()) {
let is_end_entity = match tlsa.cert_usage() {
CertUsage::DomainIssued => true,
CertUsage::TrustAnchor => false,
_ => continue,
};
if is_end_entity {
has_end_entities = true;
} else {
has_intermediates = true;
}
entries.push(TlsaEntry {
is_end_entity,
is_sha256: match tlsa.matching() {
Matching::Sha256 => true,
Matching::Sha512 => false,
_ => continue,
},
is_spki: match tlsa.selector() {
Selector::Spki => true,
Selector::Full => false,
_ => continue,
},
data: tlsa.cert_data().to_vec(),
});
}
}
Ok(Some(self.cache.tlsa.insert(
key.into_owned(),
Arc::new(Tlsa {
entries,
has_end_entities,
has_intermediates,
}),
tlsa_lookup.valid_until(),
)))
}
#[cfg(feature = "test_mode")]
pub fn tlsa_add<'x>(
&self,
key: impl IntoFqdn<'x>,
value: impl Into<Arc<Tlsa>>,
valid_until: std::time::Instant,
) {
self.cache
.tlsa
.insert(key.into_fqdn().into_owned(), value.into(), valid_until);
}
}

View file

@ -0,0 +1,46 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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 mail_auth::trust_dns_resolver::TokioAsyncResolver;
pub mod dnssec;
pub mod verify;
pub struct DnssecResolver {
pub resolver: TokioAsyncResolver,
}
#[derive(Debug, Hash, PartialEq, Eq)]
pub struct TlsaEntry {
pub is_end_entity: bool,
pub is_sha256: bool,
pub is_spki: bool,
pub data: Vec<u8>,
}
#[derive(Debug, Hash, PartialEq, Eq)]
pub struct Tlsa {
pub entries: Vec<TlsaEntry>,
pub has_end_entities: bool,
pub has_intermediates: bool,
}

View file

@ -0,0 +1,307 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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 rustls::Certificate;
use sha1::Digest;
use sha2::{Sha256, Sha512};
use x509_parser::prelude::{FromDer, X509Certificate};
use crate::queue::{Error, ErrorDetails, Status};
use super::Tlsa;
impl Tlsa {
pub fn verify(
&self,
span: &tracing::Span,
hostname: &str,
certificates: Option<&[Certificate]>,
) -> Result<(), Status<(), Error>> {
let certificates = if let Some(certificates) = certificates {
certificates
} else {
tracing::info!(
parent: span,
context = "dane",
event = "no-server-certs-found",
mx = hostname,
"No certificates were provided."
);
return Err(Status::TemporaryFailure(Error::DaneError(ErrorDetails {
entity: hostname.to_string(),
details: "No certificates were provided by host".to_string(),
})));
};
let mut matched_end_entity = false;
let mut matched_intermediate = false;
'outer: for (pos, der_certificate) in certificates.iter().enumerate() {
// Parse certificate
let certificate = match X509Certificate::from_der(der_certificate.as_ref()) {
Ok((_, certificate)) => certificate,
Err(err) => {
tracing::debug!(
parent: span,
context = "dane",
event = "cert-parse-error",
"Failed to parse X.509 certificate for host {}: {}",
hostname,
err
);
return Err(Status::TemporaryFailure(Error::DaneError(ErrorDetails {
entity: hostname.to_string(),
details: "Failed to parse X.509 certificate".to_string(),
})));
}
};
// Match against TLSA records
let is_end_entity = pos == 0;
let mut sha256 = [None, None];
let mut sha512 = [None, None];
for record in self.entries.iter() {
if record.is_end_entity == is_end_entity {
let hash: &[u8] = if record.is_sha256 {
&sha256[usize::from(record.is_spki)].get_or_insert_with(|| {
let mut hasher = Sha256::new();
hasher.update(if record.is_spki {
certificate.public_key().raw
} else {
der_certificate.as_ref()
});
hasher.finalize()
})[..]
} else {
&sha512[usize::from(record.is_spki)].get_or_insert_with(|| {
let mut hasher = Sha512::new();
hasher.update(if record.is_spki {
certificate.public_key().raw
} else {
der_certificate.as_ref()
});
hasher.finalize()
})[..]
};
if hash == record.data {
tracing::debug!(
parent: span,
context = "dane",
event = "info",
mx = hostname,
certificate = if is_end_entity {
"end-entity"
} else {
"intermediate"
},
"Matched TLSA record with hash {:x?}.",
hash
);
if is_end_entity {
matched_end_entity = true;
if !self.has_intermediates {
break 'outer;
}
} else {
matched_intermediate = true;
break 'outer;
}
}
}
}
}
if (self.has_end_entities == matched_end_entity)
&& (self.has_intermediates == matched_intermediate)
{
tracing::info!(
parent: span,
context = "dane",
event = "authenticated",
mx = hostname,
"DANE authentication successful.",
);
Ok(())
} else {
tracing::warn!(
parent: span,
context = "dane",
event = "auth-failure",
mx = hostname,
"No matching certificates found in TLSA records.",
);
Err(Status::PermanentFailure(Error::DaneError(ErrorDetails {
entity: hostname.to_string(),
details: "No matching certificates found in TLSA records".to_string(),
})))
}
}
}
#[cfg(test)]
mod test {
use std::{
collections::BTreeSet,
fs::{self, File},
io::{BufRead, BufReader},
num::ParseIntError,
path::PathBuf,
time::{Duration, Instant},
};
use mail_auth::{
common::lru::{DnsCache, LruCache},
trust_dns_resolver::{
config::{ResolverConfig, ResolverOpts},
AsyncResolver,
},
Resolver,
};
use rustls::Certificate;
use crate::{
core::Resolvers,
outbound::dane::{DnssecResolver, Tlsa, TlsaEntry},
queue::{Error, ErrorDetails, Status},
};
#[tokio::test]
async fn dane_test() {
let conf = ResolverConfig::cloudflare_tls();
let mut opts = ResolverOpts::default();
opts.validate = true;
opts.try_tcp_on_error = true;
let r = Resolvers {
dns: Resolver::new_cloudflare().unwrap(),
dnssec: DnssecResolver {
resolver: AsyncResolver::tokio(conf, opts).unwrap(),
},
cache: crate::core::DnsCache {
tlsa: LruCache::with_capacity(10),
mta_sts: LruCache::with_capacity(10),
},
};
// Add dns entries
let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
path.push("resources");
path.push("smtp");
path.push("dane");
let mut file = path.clone();
file.push("dns.txt");
let mut hosts = BTreeSet::new();
let mut tlsa = Tlsa {
entries: Vec::new(),
has_end_entities: false,
has_intermediates: false,
};
let mut hostname = String::new();
for line in BufReader::new(File::open(file).unwrap()).lines() {
let line = line.unwrap();
let mut is_end_entity = false;
for (pos, item) in line.split_whitespace().enumerate() {
match pos {
0 => {
if hostname != item && !hostname.is_empty() {
r.tlsa_add(hostname, tlsa, Instant::now() + Duration::from_secs(30));
tlsa = Tlsa {
entries: Vec::new(),
has_end_entities: false,
has_intermediates: false,
};
}
hosts.insert(item.strip_prefix("_25._tcp.").unwrap().to_string());
hostname = item.to_string();
}
1 => {
is_end_entity = item == "3";
}
4 => {
if is_end_entity {
tlsa.has_end_entities = true;
} else {
tlsa.has_intermediates = true;
}
tlsa.entries.push(TlsaEntry {
is_end_entity,
is_sha256: true,
is_spki: true,
data: decode_hex(item).unwrap(),
});
}
_ => (),
}
}
}
r.tlsa_add(hostname, tlsa, Instant::now() + Duration::from_secs(30));
// Add certificates
assert!(!hosts.is_empty());
for host in hosts {
// Add certificates
let mut certs = Vec::new();
for num in 0..6 {
let mut file = path.clone();
file.push(format!("{host}.{num}.cert"));
if file.exists() {
certs.push(Certificate(fs::read(file).unwrap()));
} else {
break;
}
}
// Successful DANE verification
let tlsa = r
.tlsa_lookup(format!("_25._tcp.{host}."))
.await
.unwrap()
.unwrap();
assert_eq!(
tlsa.verify(&tracing::info_span!("test_span"), &host, Some(&certs)),
Ok(())
);
// Failed DANE verification
certs.remove(0);
assert_eq!(
tlsa.verify(&tracing::info_span!("test_span"), &host, Some(&certs)),
Err(Status::PermanentFailure(Error::DaneError(ErrorDetails {
entity: host.to_string(),
details: "No matching certificates found in TLSA records".to_string()
})))
);
}
}
pub fn decode_hex(s: &str) -> Result<Vec<u8>, ParseIntError> {
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16))
.collect()
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,155 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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::net::IpAddr;
use mail_auth::MX;
use rand::{seq::SliceRandom, Rng};
use crate::{
core::{Core, Envelope},
queue::{Error, ErrorDetails, Status},
};
use super::RemoteHost;
impl Core {
pub(super) async fn resolve_host(
&self,
remote_host: &RemoteHost<'_>,
envelope: &impl Envelope,
max_multihomed: usize,
) -> Result<(Option<IpAddr>, Vec<IpAddr>), Status<(), Error>> {
let remote_ips = self
.resolvers
.dns
.ip_lookup(
remote_host.fqdn_hostname().as_ref(),
*self.queue.config.ip_strategy.eval(envelope).await,
max_multihomed,
)
.await
.map_err(|err| {
if let mail_auth::Error::DnsRecordNotFound(_) = &err {
Status::PermanentFailure(Error::ConnectionError(ErrorDetails {
entity: remote_host.hostname().to_string(),
details: "record not found for MX".to_string(),
}))
} else {
Status::TemporaryFailure(Error::ConnectionError(ErrorDetails {
entity: remote_host.hostname().to_string(),
details: format!("lookup error: {err}"),
}))
}
})?;
if let Some(remote_ip) = remote_ips.first() {
let mut source_ip = None;
if remote_ip.is_ipv4() {
let source_ips = self.queue.config.source_ip.ipv4.eval(envelope).await;
match source_ips.len().cmp(&1) {
std::cmp::Ordering::Equal => {
source_ip = IpAddr::from(*source_ips.first().unwrap()).into();
}
std::cmp::Ordering::Greater => {
source_ip = IpAddr::from(
source_ips[rand::thread_rng().gen_range(0..source_ips.len())],
)
.into();
}
std::cmp::Ordering::Less => (),
}
} else {
let source_ips = self.queue.config.source_ip.ipv6.eval(envelope).await;
match source_ips.len().cmp(&1) {
std::cmp::Ordering::Equal => {
source_ip = IpAddr::from(*source_ips.first().unwrap()).into();
}
std::cmp::Ordering::Greater => {
source_ip = IpAddr::from(
source_ips[rand::thread_rng().gen_range(0..source_ips.len())],
)
.into();
}
std::cmp::Ordering::Less => (),
}
}
Ok((source_ip, remote_ips))
} else {
Err(Status::TemporaryFailure(Error::DnsError(format!(
"No IP addresses found for {:?}.",
envelope.mx()
))))
}
}
}
pub(super) trait ToRemoteHost {
fn to_remote_hosts<'x, 'y: 'x>(
&'x self,
domain: &'y str,
max_mx: usize,
) -> Option<Vec<RemoteHost<'_>>>;
}
impl ToRemoteHost for Vec<MX> {
fn to_remote_hosts<'x, 'y: 'x>(
&'x self,
domain: &'y str,
max_mx: usize,
) -> Option<Vec<RemoteHost<'_>>> {
if !self.is_empty() {
// Obtain max number of MX hosts to process
let mut remote_hosts = Vec::with_capacity(max_mx);
'outer: for mx in self.iter() {
if mx.exchanges.len() > 1 {
let mut slice = mx.exchanges.iter().collect::<Vec<_>>();
slice.shuffle(&mut rand::thread_rng());
for remote_host in slice {
remote_hosts.push(RemoteHost::MX(remote_host.as_str()));
if remote_hosts.len() == max_mx {
break 'outer;
}
}
} else if let Some(remote_host) = mx.exchanges.first() {
// Check for Null MX
if mx.preference == 0 && remote_host == "." {
return None;
}
remote_hosts.push(RemoteHost::MX(remote_host.as_str()));
if remote_hosts.len() == max_mx {
break;
}
}
}
remote_hosts.into()
} else {
// If an empty list of MXs is returned, the address is treated as if it was
// associated with an implicit MX RR with a preference of 0, pointing to that host.
vec![RemoteHost::MX(domain)].into()
}
}
}

View file

@ -0,0 +1,304 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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 mail_send::Credentials;
use smtp_proto::{Response, Severity};
use utils::config::ServerProtocol;
use crate::{
config::RelayHost,
queue::{DeliveryAttempt, Error, ErrorDetails, HostResponse, Message, Status},
};
pub mod dane;
pub mod delivery;
pub mod lookup;
pub mod mta_sts;
pub mod session;
impl Status<(), Error> {
pub fn from_smtp_error(hostname: &str, command: &str, err: mail_send::Error) -> Self {
match err {
mail_send::Error::Io(_)
| mail_send::Error::Tls(_)
| mail_send::Error::Base64(_)
| mail_send::Error::UnparseableReply
| mail_send::Error::AuthenticationFailed(_)
| mail_send::Error::MissingCredentials
| mail_send::Error::MissingMailFrom
| mail_send::Error::MissingRcptTo
| mail_send::Error::Timeout => {
Status::TemporaryFailure(Error::ConnectionError(ErrorDetails {
entity: hostname.to_string(),
details: err.to_string(),
}))
}
mail_send::Error::UnexpectedReply(reply) => {
let details = ErrorDetails {
entity: hostname.to_string(),
details: command.trim().to_string(),
};
if reply.severity() == Severity::PermanentNegativeCompletion {
Status::PermanentFailure(Error::UnexpectedResponse(HostResponse {
hostname: details,
response: reply,
}))
} else {
Status::TemporaryFailure(Error::UnexpectedResponse(HostResponse {
hostname: details,
response: reply,
}))
}
}
mail_send::Error::Auth(_)
| mail_send::Error::UnsupportedAuthMechanism
| mail_send::Error::InvalidTLSName
| mail_send::Error::MissingStartTls => {
Status::PermanentFailure(Error::ConnectionError(ErrorDetails {
entity: hostname.to_string(),
details: err.to_string(),
}))
}
}
}
pub fn from_starttls_error(hostname: &str, response: Option<Response<String>>) -> Self {
let entity = hostname.to_string();
if let Some(response) = response {
let hostname = ErrorDetails {
entity,
details: "STARTTLS".to_string(),
};
if response.severity() == Severity::PermanentNegativeCompletion {
Status::PermanentFailure(Error::UnexpectedResponse(HostResponse {
hostname,
response,
}))
} else {
Status::TemporaryFailure(Error::UnexpectedResponse(HostResponse {
hostname,
response,
}))
}
} else {
Status::PermanentFailure(Error::TlsError(ErrorDetails {
entity,
details: "STARTTLS not advertised by host.".to_string(),
}))
}
}
pub fn from_tls_error(hostname: &str, err: mail_send::Error) -> Self {
match err {
mail_send::Error::InvalidTLSName => {
Status::PermanentFailure(Error::TlsError(ErrorDetails {
entity: hostname.to_string(),
details: "Invalid hostname".to_string(),
}))
}
mail_send::Error::Timeout => Status::TemporaryFailure(Error::TlsError(ErrorDetails {
entity: hostname.to_string(),
details: "TLS handshake timed out".to_string(),
})),
mail_send::Error::Tls(err) => Status::TemporaryFailure(Error::TlsError(ErrorDetails {
entity: hostname.to_string(),
details: format!("Handshake failed: {err}"),
})),
mail_send::Error::Io(err) => Status::TemporaryFailure(Error::TlsError(ErrorDetails {
entity: hostname.to_string(),
details: format!("I/O error: {err}"),
})),
_ => Status::PermanentFailure(Error::TlsError(ErrorDetails {
entity: hostname.to_string(),
details: "Other TLS error".to_string(),
})),
}
}
pub fn timeout(hostname: &str, stage: &str) -> Self {
Status::TemporaryFailure(Error::ConnectionError(ErrorDetails {
entity: hostname.to_string(),
details: format!("Timeout while {stage}"),
}))
}
}
impl From<mail_auth::Error> for Status<(), Error> {
fn from(err: mail_auth::Error) -> Self {
match &err {
mail_auth::Error::DnsRecordNotFound(code) => {
Status::PermanentFailure(Error::DnsError(format!("Domain not found: {code:?}")))
}
_ => Status::TemporaryFailure(Error::DnsError(err.to_string())),
}
}
}
impl From<mta_sts::Error> for Status<(), Error> {
fn from(err: mta_sts::Error) -> Self {
match &err {
mta_sts::Error::Dns(err) => match err {
mail_auth::Error::DnsRecordNotFound(code) => Status::PermanentFailure(
Error::MtaStsError(format!("Record not found: {code:?}")),
),
mail_auth::Error::InvalidRecordType => Status::PermanentFailure(
Error::MtaStsError("Failed to parse MTA-STS DNS record.".to_string()),
),
_ => {
Status::TemporaryFailure(Error::MtaStsError(format!("DNS lookup error: {err}")))
}
},
mta_sts::Error::Http(err) => {
if err.is_timeout() {
Status::TemporaryFailure(Error::MtaStsError(
"Timeout fetching policy.".to_string(),
))
} else if err.is_connect() {
Status::TemporaryFailure(Error::MtaStsError(
"Could not reach policy host.".to_string(),
))
} else if err.is_status()
& err
.status()
.map_or(false, |s| s == reqwest::StatusCode::NOT_FOUND)
{
Status::PermanentFailure(Error::MtaStsError("Policy not found.".to_string()))
} else {
Status::TemporaryFailure(Error::MtaStsError(
"Failed to fetch policy.".to_string(),
))
}
}
mta_sts::Error::InvalidPolicy(err) => Status::PermanentFailure(Error::MtaStsError(
format!("Failed to parse policy: {err}"),
)),
}
}
}
impl From<Box<Message>> for DeliveryAttempt {
fn from(message: Box<Message>) -> Self {
DeliveryAttempt {
span: tracing::info_span!(
"delivery",
"id" = message.id,
"return_path" = if !message.return_path.is_empty() {
message.return_path.as_ref()
} else {
"<>"
},
"nrcpt" = message.recipients.len(),
"size" = message.size
),
in_flight: Vec::new(),
message,
}
}
}
enum RemoteHost<'x> {
Relay(&'x RelayHost),
MX(&'x str),
}
impl<'x> RemoteHost<'x> {
#[inline(always)]
fn hostname(&self) -> &str {
match self {
RemoteHost::MX(host) => {
if let Some(host) = host.strip_suffix('.') {
host
} else {
host
}
}
RemoteHost::Relay(host) => host.address.as_str(),
}
}
#[inline(always)]
fn fqdn_hostname(&self) -> Cow<'_, str> {
let host = match self {
RemoteHost::MX(host) => host,
RemoteHost::Relay(host) => host.address.as_str(),
};
if !host.ends_with('.') {
format!("{host}.").into()
} else {
(*host).into()
}
}
#[inline(always)]
fn port(&self) -> u16 {
match self {
#[cfg(feature = "test_mode")]
RemoteHost::MX(_) => 9925,
#[cfg(not(feature = "test_mode"))]
RemoteHost::MX(_) => 25,
RemoteHost::Relay(host) => host.port,
}
}
#[inline(always)]
fn credentials(&self) -> Option<&Credentials<String>> {
match self {
RemoteHost::MX(_) => None,
RemoteHost::Relay(host) => host.auth.as_ref(),
}
}
#[inline(always)]
fn allow_invalid_certs(&self) -> bool {
#[cfg(feature = "test_mode")]
{
true
}
#[cfg(not(feature = "test_mode"))]
match self {
RemoteHost::MX(_) => false,
RemoteHost::Relay(host) => host.tls_allow_invalid_certs,
}
}
#[inline(always)]
fn implicit_tls(&self) -> bool {
match self {
RemoteHost::MX(_) => false,
RemoteHost::Relay(host) => host.tls_implicit,
}
}
#[inline(always)]
fn is_smtp(&self) -> bool {
match self {
RemoteHost::MX(_) => true,
RemoteHost::Relay(host) => host.protocol == ServerProtocol::Smtp,
}
}
}

View file

@ -0,0 +1,177 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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::{
fmt::Display,
sync::Arc,
time::{Duration, Instant},
};
#[cfg(feature = "test_mode")]
pub static STS_TEST_POLICY: parking_lot::Mutex<Vec<u8>> = parking_lot::Mutex::new(Vec::new());
use mail_auth::{common::lru::DnsCache, mta_sts::MtaSts, report::tlsrpt::ResultType};
use crate::core::Core;
use super::{Error, Policy};
#[allow(unused_variables)]
impl Core {
pub async fn lookup_mta_sts_policy<'x>(
&self,
domain: &str,
timeout: Duration,
) -> Result<Arc<Policy>, Error> {
// Lookup MTA-STS TXT record
let record = match self
.resolvers
.dns
.txt_lookup::<MtaSts>(format!("_mta-sts.{domain}."))
.await
{
Ok(record) => record,
Err(err) => {
// Return the cached policy in case of failure
return if let Some(value) = self.resolvers.cache.mta_sts.get(domain) {
Ok(value)
} else {
Err(err.into())
};
}
};
// Check if the policy has been cached
if let Some(value) = self.resolvers.cache.mta_sts.get(domain) {
if value.id == record.id {
return Ok(value);
}
}
// Fetch policy
#[cfg(not(feature = "test_mode"))]
let bytes = reqwest::Client::builder()
.user_agent(crate::USER_AGENT)
.timeout(timeout)
.redirect(reqwest::redirect::Policy::none())
.build()?
.get(&format!("https://mta-sts.{domain}/.well-known/mta-sts.txt"))
.send()
.await?
.bytes()
.await?;
#[cfg(feature = "test_mode")]
let bytes = STS_TEST_POLICY.lock().clone();
// Parse policy
let policy = Policy::parse(
std::str::from_utf8(&bytes).map_err(|err| Error::InvalidPolicy(err.to_string()))?,
record.id.clone(),
)?;
let valid_until = Instant::now()
+ Duration::from_secs(if (3600..31557600).contains(&policy.max_age) {
policy.max_age
} else {
86400
});
Ok(self
.resolvers
.cache
.mta_sts
.insert(domain.to_string(), Arc::new(policy), valid_until))
}
#[cfg(feature = "test_mode")]
pub fn policy_add<'x>(
&self,
key: impl mail_auth::common::resolver::IntoFqdn<'x>,
value: Policy,
valid_until: std::time::Instant,
) {
self.resolvers.cache.mta_sts.insert(
key.into_fqdn().into_owned(),
Arc::new(value),
valid_until,
);
}
}
impl From<&Error> for ResultType {
fn from(err: &Error) -> Self {
match &err {
Error::InvalidPolicy(_) => ResultType::StsPolicyInvalid,
_ => ResultType::StsPolicyFetchError,
}
}
}
impl Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::Dns(err) => match err {
mail_auth::Error::DnsRecordNotFound(code) => {
write!(f, "Record not found: {code:?}")
}
mail_auth::Error::InvalidRecordType => {
f.write_str("Failed to parse MTA-STS DNS record.")
}
_ => write!(f, "DNS lookup error: {err}"),
},
Error::Http(err) => {
if err.is_timeout() {
f.write_str("Timeout fetching policy.")
} else if err.is_connect() {
f.write_str("Could not reach policy host.")
} else if err.is_status()
& err
.status()
.map_or(false, |s| s == reqwest::StatusCode::NOT_FOUND)
{
f.write_str("Policy not found.")
} else {
f.write_str("Failed to fetch policy.")
}
}
Error::InvalidPolicy(err) => write!(f, "Failed to parse policy: {err}"),
}
}
}
impl From<mail_auth::Error> for Error {
fn from(value: mail_auth::Error) -> Self {
Error::Dns(value)
}
}
impl From<reqwest::Error> for Error {
fn from(value: reqwest::Error) -> Self {
Error::Http(value)
}
}
impl From<String> for Error {
fn from(value: String) -> Self {
Error::InvalidPolicy(value)
}
}

View file

@ -0,0 +1,54 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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.
*/
pub mod lookup;
pub mod parse;
pub mod verify;
#[derive(Debug, PartialEq, Eq, Hash)]
pub enum Mode {
Enforce,
Testing,
None,
}
#[derive(Debug, PartialEq, Eq, Hash)]
pub enum MxPattern {
Equals(String),
StartsWith(String),
}
#[derive(Debug, PartialEq, Eq, Hash)]
pub struct Policy {
pub id: String,
pub mode: Mode,
pub mx: Vec<MxPattern>,
pub max_age: u64,
}
#[derive(Debug)]
pub enum Error {
Dns(mail_auth::Error),
Http(reqwest::Error),
InvalidPolicy(String),
}

View file

@ -0,0 +1,138 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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 super::{Mode, MxPattern, Policy};
impl Policy {
pub fn parse(mut data: &str, id: String) -> Result<Policy, String> {
let mut mode = Mode::None;
let mut max_age: u64 = 86400;
let mut mx = Vec::new();
while !data.is_empty() {
if let Some((key, next_data)) = data.split_once(':') {
let value = if let Some((value, next_data)) = next_data.split_once('\n') {
data = next_data;
value.trim()
} else {
data = "";
next_data.trim()
};
match key.trim() {
"mx" => {
if let Some(suffix) = value.strip_prefix("*.") {
if !suffix.is_empty() {
mx.push(MxPattern::StartsWith(suffix.to_lowercase()));
}
} else if !value.is_empty() {
mx.push(MxPattern::Equals(value.to_lowercase()));
}
}
"max_age" => {
if let Ok(value) = value.parse() {
max_age = value;
}
}
"mode" => {
mode = match value {
"enforce" => Mode::Enforce,
"testing" => Mode::Testing,
"none" => Mode::None,
_ => return Err(format!("Unsupported mode {value:?}.")),
};
}
"version" => {
if !value.eq_ignore_ascii_case("STSv1") {
return Err(format!("Unsupported version {value:?}."));
}
}
_ => (),
}
} else {
break;
}
}
if !mx.is_empty() {
Ok(Policy {
id,
mode,
mx,
max_age,
})
} else {
Err("No 'mx' entries found.".to_string())
}
}
}
#[cfg(test)]
mod tests {
use crate::outbound::mta_sts::{Mode, MxPattern, Policy};
#[test]
fn parse_policy() {
for (policy, expected_policy) in [
(
r"version: STSv1
mode: enforce
mx: mail.example.com
mx: *.example.net
mx: backupmx.example.com
max_age: 604800",
Policy {
id: "abc".to_string(),
mode: Mode::Enforce,
mx: vec![
MxPattern::Equals("mail.example.com".to_string()),
MxPattern::StartsWith("example.net".to_string()),
MxPattern::Equals("backupmx.example.com".to_string()),
],
max_age: 604800,
},
),
(
r"version: STSv1
mode: testing
mx: gmail-smtp-in.l.google.com
mx: *.gmail-smtp-in.l.google.com
max_age: 86400
",
Policy {
id: "abc".to_string(),
mode: Mode::Testing,
mx: vec![
MxPattern::Equals("gmail-smtp-in.l.google.com".to_string()),
MxPattern::StartsWith("gmail-smtp-in.l.google.com".to_string()),
],
max_age: 86400,
},
),
] {
assert_eq!(
Policy::parse(policy, expected_policy.id.to_string()).unwrap(),
expected_policy
);
}
}
}

View file

@ -0,0 +1,55 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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 super::{Mode, MxPattern, Policy};
impl Policy {
pub fn verify(&self, mx_host: &str) -> bool {
if self.mode != Mode::None {
for mx_pattern in &self.mx {
match mx_pattern {
MxPattern::Equals(host) => {
if host == mx_host {
return true;
}
}
MxPattern::StartsWith(domain) => {
if let Some((_, suffix)) = mx_host.split_once('.') {
if suffix == domain {
return true;
}
}
}
}
}
false
} else {
true
}
}
pub fn enforce(&self) -> bool {
self.mode == Mode::Enforce
}
}

View file

@ -0,0 +1,637 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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 mail_send::{smtp::AssertReply, Credentials, SmtpClient};
use smtp_proto::{
EhloResponse, Response, Severity, EXT_CHUNKING, EXT_DSN, EXT_REQUIRE_TLS, EXT_SIZE,
EXT_SMTP_UTF8, EXT_START_TLS, MAIL_REQUIRETLS, MAIL_RET_FULL, MAIL_RET_HDRS, MAIL_SMTPUTF8,
RCPT_NOTIFY_DELAY, RCPT_NOTIFY_FAILURE, RCPT_NOTIFY_NEVER, RCPT_NOTIFY_SUCCESS,
};
use std::fmt::Write;
use std::time::Duration;
use tokio::{
fs,
io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt},
net::TcpStream,
};
use tokio_rustls::{client::TlsStream, TlsConnector};
use crate::{
config::{RequireOptional, TlsStrategy},
queue::{ErrorDetails, HostResponse, RCPT_STATUS_CHANGED},
};
use crate::queue::{Error, Message, Recipient, Status};
pub struct SessionParams<'x> {
pub span: &'x tracing::Span,
pub hostname: &'x str,
pub credentials: Option<&'x Credentials<String>>,
pub is_smtp: bool,
pub local_hostname: &'x str,
pub timeout_ehlo: Duration,
pub timeout_mail: Duration,
pub timeout_rcpt: Duration,
pub timeout_data: Duration,
}
impl Message {
pub async fn deliver<T: AsyncRead + AsyncWrite + Unpin>(
&self,
mut smtp_client: SmtpClient<T>,
recipients: impl Iterator<Item = &mut Recipient>,
params: SessionParams<'_>,
) -> Status<(), Error> {
// Obtain capabilities
let mut capabilities = match say_helo(&mut smtp_client, &params).await {
Ok(capabilities) => capabilities,
Err(status) => {
tracing::info!(
parent: params.span,
context = "ehlo",
event = "rejected",
mx = &params.hostname,
reason = %status,
);
quit(smtp_client).await;
return status;
}
};
// Authenticate
if let Some(credentials) = params.credentials {
if let Err(err) = smtp_client.authenticate(credentials, &capabilities).await {
tracing::info!(
parent: params.span,
context = "auth",
event = "failed",
mx = &params.hostname,
reason = %err,
);
quit(smtp_client).await;
return Status::from_smtp_error(params.hostname, "AUTH ...", err);
}
// Refresh capabilities
capabilities = match say_helo(&mut smtp_client, &params).await {
Ok(capabilities) => capabilities,
Err(status) => {
tracing::info!(
parent: params.span,
context = "ehlo",
event = "rejected",
mx = &params.hostname,
reason = %status,
);
quit(smtp_client).await;
return status;
}
};
}
// MAIL FROM
smtp_client.timeout = params.timeout_mail;
let cmd = self.build_mail_from(&capabilities);
if let Err(err) = smtp_client
.cmd(cmd.as_bytes())
.await
.and_then(|r| r.assert_positive_completion())
{
tracing::info!(
parent: params.span,
context = "sender",
event = "rejected",
mx = &params.hostname,
reason = %err,
);
quit(smtp_client).await;
return Status::from_smtp_error(params.hostname, &cmd, err);
}
// RCPT TO
let mut total_rcpt = 0;
let mut total_completed = 0;
let mut accepted_rcpts = Vec::new();
smtp_client.timeout = params.timeout_rcpt;
for rcpt in recipients {
total_rcpt += 1;
if matches!(
&rcpt.status,
Status::Completed(_) | Status::PermanentFailure(_)
) {
total_completed += 1;
continue;
}
let cmd = self.build_rcpt_to(rcpt, &capabilities);
match smtp_client.cmd(cmd.as_bytes()).await {
Ok(response) => match response.severity() {
Severity::PositiveCompletion => {
accepted_rcpts.push((
rcpt,
Status::Completed(HostResponse {
hostname: params.hostname.to_string(),
response,
}),
));
}
severity => {
tracing::info!(
parent: params.span,
context = "rcpt",
event = "rejected",
rcpt = rcpt.address,
mx = &params.hostname,
reason = %response,
);
let response = HostResponse {
hostname: ErrorDetails {
entity: params.hostname.to_string(),
details: cmd.trim().to_string(),
},
response,
};
rcpt.flags |= RCPT_STATUS_CHANGED;
rcpt.status = if severity == Severity::PermanentNegativeCompletion {
total_completed += 1;
Status::PermanentFailure(response)
} else {
Status::TemporaryFailure(response)
};
}
},
Err(err) => {
tracing::info!(
parent: params.span,
context = "rcpt",
event = "failed",
mx = &params.hostname,
rcpt = rcpt.address,
reason = %err,
);
// Something went wrong, abort.
quit(smtp_client).await;
return Status::from_smtp_error(params.hostname, "", err);
}
}
}
// Send message
if !accepted_rcpts.is_empty() {
let bdat_cmd = if capabilities.has_capability(EXT_CHUNKING) {
format!("BDAT {} LAST\r\n", self.size).into()
} else {
None
};
if let Err(status) = send_message(&mut smtp_client, self, &bdat_cmd, &params).await {
tracing::info!(
parent: params.span,
context = "message",
event = "rejected",
mx = &params.hostname,
reason = %status,
);
quit(smtp_client).await;
return status;
}
if params.is_smtp {
// Handle SMTP response
match read_smtp_data_respone(&mut smtp_client, params.hostname, &bdat_cmd).await {
Ok(response) => {
// Mark recipients as delivered
if response.code() == 250 {
for (rcpt, status) in accepted_rcpts {
tracing::info!(
parent: params.span,
context = "rcpt",
event = "delivered",
rcpt = rcpt.address,
mx = &params.hostname,
response = %status,
);
rcpt.status = status;
rcpt.flags |= RCPT_STATUS_CHANGED;
total_completed += 1;
}
} else {
tracing::info!(
parent: params.span,
context = "message",
event = "rejected",
mx = &params.hostname,
reason = %response,
);
quit(smtp_client).await;
return Status::from_smtp_error(
params.hostname,
bdat_cmd.as_deref().unwrap_or("DATA"),
mail_send::Error::UnexpectedReply(response),
);
}
}
Err(status) => {
tracing::info!(
parent: params.span,
context = "message",
event = "failed",
mx = &params.hostname,
reason = %status,
);
quit(smtp_client).await;
return status;
}
}
} else {
// Handle LMTP responses
match read_lmtp_data_respone(
&mut smtp_client,
params.hostname,
accepted_rcpts.len(),
)
.await
{
Ok(responses) => {
for ((rcpt, _), response) in accepted_rcpts.into_iter().zip(responses) {
rcpt.flags |= RCPT_STATUS_CHANGED;
rcpt.status = match response.severity() {
Severity::PositiveCompletion => {
tracing::info!(
parent: params.span,
context = "rcpt",
event = "delivered",
rcpt = rcpt.address,
mx = &params.hostname,
response = %response,
);
total_completed += 1;
Status::Completed(HostResponse {
hostname: params.hostname.to_string(),
response,
})
}
severity => {
tracing::info!(
parent: params.span,
context = "rcpt",
event = "rejected",
rcpt = rcpt.address,
mx = &params.hostname,
reason = %response,
);
let response = HostResponse {
hostname: ErrorDetails {
entity: params.hostname.to_string(),
details: bdat_cmd
.as_deref()
.unwrap_or("DATA")
.to_string(),
},
response,
};
if severity == Severity::PermanentNegativeCompletion {
total_completed += 1;
Status::PermanentFailure(response)
} else {
Status::TemporaryFailure(response)
}
}
};
}
}
Err(status) => {
tracing::info!(
parent: params.span,
context = "message",
event = "rejected",
mx = &params.hostname,
reason = %status,
);
quit(smtp_client).await;
return status;
}
}
}
}
quit(smtp_client).await;
if total_completed == total_rcpt {
Status::Completed(())
} else {
Status::Scheduled
}
}
fn build_mail_from(&self, capabilities: &EhloResponse<String>) -> String {
let mut mail_from = String::with_capacity(self.return_path.len() + 60);
let _ = write!(mail_from, "MAIL FROM:<{}>", self.return_path);
if capabilities.has_capability(EXT_SIZE) {
let _ = write!(mail_from, " SIZE={}", self.size);
}
if self.has_flag(MAIL_REQUIRETLS) & capabilities.has_capability(EXT_REQUIRE_TLS) {
mail_from.push_str(" REQUIRETLS");
}
if self.has_flag(MAIL_SMTPUTF8) & capabilities.has_capability(EXT_SMTP_UTF8) {
mail_from.push_str(" SMTPUTF8");
}
if capabilities.has_capability(EXT_DSN) {
if self.has_flag(MAIL_RET_FULL) {
mail_from.push_str(" RET=FULL");
} else if self.has_flag(MAIL_RET_HDRS) {
mail_from.push_str(" RET=HDRS");
}
if let Some(env_id) = &self.env_id {
let _ = write!(mail_from, " ENVID={env_id}");
}
}
mail_from.push_str("\r\n");
mail_from
}
fn build_rcpt_to(&self, rcpt: &Recipient, capabilities: &EhloResponse<String>) -> String {
let mut rcpt_to = String::with_capacity(rcpt.address.len() + 60);
let _ = write!(rcpt_to, "RCPT TO:<{}>", rcpt.address);
if capabilities.has_capability(EXT_DSN) {
if rcpt.has_flag(RCPT_NOTIFY_SUCCESS | RCPT_NOTIFY_FAILURE | RCPT_NOTIFY_DELAY) {
rcpt_to.push_str(" NOTIFY=");
let mut add_comma = if rcpt.has_flag(RCPT_NOTIFY_SUCCESS) {
rcpt_to.push_str("SUCCESS");
true
} else {
false
};
if rcpt.has_flag(RCPT_NOTIFY_DELAY) {
if add_comma {
rcpt_to.push(',');
} else {
add_comma = true;
}
rcpt_to.push_str("DELAY");
}
if rcpt.has_flag(RCPT_NOTIFY_FAILURE) {
if add_comma {
rcpt_to.push(',');
}
rcpt_to.push_str("FAILURE");
}
} else if rcpt.has_flag(RCPT_NOTIFY_NEVER) {
rcpt_to.push_str(" NOTIFY=NEVER");
}
}
rcpt_to.push_str("\r\n");
rcpt_to
}
#[inline(always)]
pub fn has_flag(&self, flag: u64) -> bool {
(self.flags & flag) != 0
}
}
impl Recipient {
#[inline(always)]
pub fn has_flag(&self, flag: u64) -> bool {
(self.flags & flag) != 0
}
}
pub enum StartTlsResult {
Success {
smtp_client: SmtpClient<TlsStream<TcpStream>>,
},
Error {
error: mail_send::Error,
},
Unavailable {
response: Option<Response<String>>,
smtp_client: SmtpClient<TcpStream>,
},
}
pub async fn try_start_tls(
mut smtp_client: SmtpClient<TcpStream>,
tls_connector: &TlsConnector,
hostname: &str,
capabilities: &EhloResponse<String>,
) -> StartTlsResult {
if capabilities.has_capability(EXT_START_TLS) {
match smtp_client.cmd("STARTTLS\r\n").await {
Ok(response) => {
if response.code() == 220 {
match smtp_client.into_tls(tls_connector, hostname).await {
Ok(smtp_client) => StartTlsResult::Success { smtp_client },
Err(error) => StartTlsResult::Error { error },
}
} else {
StartTlsResult::Unavailable {
response: response.into(),
smtp_client,
}
}
}
Err(error) => StartTlsResult::Error { error },
}
} else {
StartTlsResult::Unavailable {
smtp_client,
response: None,
}
}
}
pub async fn read_greeting<T: AsyncRead + AsyncWrite + Unpin>(
smtp_client: &mut SmtpClient<T>,
hostname: &str,
) -> Result<(), Status<(), Error>> {
tokio::time::timeout(smtp_client.timeout, smtp_client.read())
.await
.map_err(|_| Status::timeout(hostname, "reading greeting"))?
.and_then(|r| r.assert_code(220))
.map_err(|err| Status::from_smtp_error(hostname, "", err))
}
pub async fn read_smtp_data_respone<T: AsyncRead + AsyncWrite + Unpin>(
smtp_client: &mut SmtpClient<T>,
hostname: &str,
bdat_cmd: &Option<String>,
) -> Result<Response<String>, Status<(), Error>> {
tokio::time::timeout(smtp_client.timeout, smtp_client.read())
.await
.map_err(|_| Status::timeout(hostname, "reading SMTP DATA response"))?
.map_err(|err| {
Status::from_smtp_error(hostname, bdat_cmd.as_deref().unwrap_or("DATA"), err)
})
}
pub async fn read_lmtp_data_respone<T: AsyncRead + AsyncWrite + Unpin>(
smtp_client: &mut SmtpClient<T>,
hostname: &str,
num_responses: usize,
) -> Result<Vec<Response<String>>, Status<(), Error>> {
tokio::time::timeout(smtp_client.timeout, async {
smtp_client.read_many(num_responses).await
})
.await
.map_err(|_| Status::timeout(hostname, "reading LMTP DATA responses"))?
.map_err(|err| Status::from_smtp_error(hostname, "", err))
}
pub async fn write_chunks<T: AsyncRead + AsyncWrite + Unpin>(
smtp_client: &mut SmtpClient<T>,
chunks: &[&[u8]],
) -> Result<(), mail_send::Error> {
for chunk in chunks {
smtp_client
.stream
.write_all(chunk)
.await
.map_err(mail_send::Error::from)?;
}
smtp_client
.stream
.flush()
.await
.map_err(mail_send::Error::from)
}
pub async fn send_message<T: AsyncRead + AsyncWrite + Unpin>(
smtp_client: &mut SmtpClient<T>,
message: &Message,
bdat_cmd: &Option<String>,
params: &SessionParams<'_>,
) -> Result<(), Status<(), Error>> {
let mut raw_message = vec![0u8; message.size];
let mut file = fs::File::open(&message.path).await.map_err(|err| {
tracing::error!(parent: params.span,
context = "queue",
event = "error",
"Failed to open message file {}: {}",
message.path.display(),
err);
Status::TemporaryFailure(Error::Io("Queue system error.".to_string()))
})?;
file.read_exact(&mut raw_message).await.map_err(|err| {
tracing::error!(parent: params.span,
context = "queue",
event = "error",
"Failed to read {} bytes file {} from disk: {}",
message.size,
message.path.display(),
err);
Status::TemporaryFailure(Error::Io("Queue system error.".to_string()))
})?;
tokio::time::timeout(params.timeout_data, async {
if let Some(bdat_cmd) = bdat_cmd {
write_chunks(smtp_client, &[bdat_cmd.as_bytes(), &raw_message]).await
} else {
write_chunks(smtp_client, &[b"DATA\r\n"]).await?;
smtp_client.read().await?.assert_code(354)?;
smtp_client
.write_message(&raw_message)
.await
.map_err(mail_send::Error::from)
}
})
.await
.map_err(|_| Status::timeout(params.hostname, "sending message"))?
.map_err(|err| {
Status::from_smtp_error(params.hostname, bdat_cmd.as_deref().unwrap_or("DATA"), err)
})
}
pub async fn say_helo<T: AsyncRead + AsyncWrite + Unpin>(
smtp_client: &mut SmtpClient<T>,
params: &SessionParams<'_>,
) -> Result<EhloResponse<String>, Status<(), Error>> {
let cmd = if params.is_smtp {
format!("EHLO {}\r\n", params.local_hostname)
} else {
format!("LHLO {}\r\n", params.local_hostname)
};
tokio::time::timeout(params.timeout_ehlo, async {
smtp_client.stream.write_all(cmd.as_bytes()).await?;
smtp_client.stream.flush().await?;
smtp_client.read_ehlo().await
})
.await
.map_err(|_| Status::timeout(params.hostname, "reading EHLO response"))?
.map_err(|err| Status::from_smtp_error(params.hostname, &cmd, err))
}
pub async fn quit<T: AsyncRead + AsyncWrite + Unpin>(mut smtp_client: SmtpClient<T>) {
let _ = tokio::time::timeout(Duration::from_secs(10), async {
if smtp_client.stream.write_all(b"QUIT\r\n").await.is_ok()
&& smtp_client.stream.flush().await.is_ok()
{
let mut buf = [0u8; 128];
let _ = smtp_client.stream.read(&mut buf).await;
}
})
.await;
}
impl TlsStrategy {
#[inline(always)]
pub fn try_dane(&self) -> bool {
matches!(
self.dane,
RequireOptional::Require | RequireOptional::Optional
)
}
#[inline(always)]
pub fn is_dane_required(&self) -> bool {
matches!(self.dane, RequireOptional::Require)
}
#[inline(always)]
pub fn try_mta_sts(&self) -> bool {
matches!(
self.mta_sts,
RequireOptional::Require | RequireOptional::Optional
)
}
#[inline(always)]
pub fn is_mta_sts_required(&self) -> bool {
matches!(self.mta_sts, RequireOptional::Require)
}
#[inline(always)]
pub fn is_tls_required(&self) -> bool {
matches!(self.tls, RequireOptional::Require)
|| self.is_dane_required()
|| self.is_mta_sts_required()
}
}

View file

@ -0,0 +1,671 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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 mail_builder::headers::content_type::ContentType;
use mail_builder::headers::HeaderType;
use mail_builder::mime::{make_boundary, BodyPart, MimePart};
use mail_builder::MessageBuilder;
use mail_parser::DateTime;
use smtp_proto::{
Response, RCPT_NOTIFY_DELAY, RCPT_NOTIFY_FAILURE, RCPT_NOTIFY_NEVER, RCPT_NOTIFY_SUCCESS,
};
use std::fmt::Write;
use std::time::{Duration, Instant};
use tokio::fs::File;
use tokio::io::AsyncReadExt;
use crate::config::QueueConfig;
use crate::core::QueueCore;
use super::{
instant_to_timestamp, DeliveryAttempt, Domain, Error, ErrorDetails, HostResponse, Message,
Recipient, SimpleEnvelope, Status, RCPT_DSN_SENT, RCPT_STATUS_CHANGED,
};
impl QueueCore {
pub async fn send_dsn(&self, attempt: &mut DeliveryAttempt) {
if !attempt.message.return_path.is_empty() {
if let Some(dsn) = attempt.build_dsn(&self.config).await {
let mut dsn_message = Message::new_boxed("", "", "");
dsn_message
.add_recipient_parts(
&attempt.message.return_path,
&attempt.message.return_path_lcase,
&attempt.message.return_path_domain,
&self.config,
)
.await;
// Sign message
let signature = attempt
.message
.sign(&self.config.dsn.sign, &dsn, &attempt.span)
.await;
self.queue_message(dsn_message, signature.as_deref(), &dsn, &attempt.span)
.await;
}
} else {
attempt.handle_double_bounce();
}
}
}
impl DeliveryAttempt {
pub async fn build_dsn(&mut self, config: &QueueConfig) -> Option<Vec<u8>> {
let now = Instant::now();
let mut txt_success = String::new();
let mut txt_delay = String::new();
let mut txt_failed = String::new();
let mut dsn = String::new();
for rcpt in &mut self.message.recipients {
if rcpt.has_flag(RCPT_DSN_SENT | RCPT_NOTIFY_NEVER) {
continue;
}
let domain = &self.message.domains[rcpt.domain_idx];
match &rcpt.status {
Status::Completed(response) => {
rcpt.flags |= RCPT_DSN_SENT | RCPT_STATUS_CHANGED;
if !rcpt.has_flag(RCPT_NOTIFY_SUCCESS) {
continue;
}
rcpt.write_dsn(&mut dsn);
rcpt.status.write_dsn(&mut dsn);
response.write_dsn_text(&rcpt.address, &mut txt_success);
}
Status::TemporaryFailure(response)
if domain.notify.due <= now && rcpt.has_flag(RCPT_NOTIFY_DELAY) =>
{
rcpt.write_dsn(&mut dsn);
rcpt.status.write_dsn(&mut dsn);
domain.write_dsn_will_retry_until(&mut dsn);
response.write_dsn_text(&rcpt.address, &mut txt_delay);
}
Status::PermanentFailure(response) => {
rcpt.flags |= RCPT_DSN_SENT | RCPT_STATUS_CHANGED;
if !rcpt.has_flag(RCPT_NOTIFY_FAILURE) {
continue;
}
rcpt.write_dsn(&mut dsn);
rcpt.status.write_dsn(&mut dsn);
response.write_dsn_text(&rcpt.address, &mut txt_failed);
}
Status::Scheduled => {
// There is no status for this address, use the domain's status.
match &domain.status {
Status::PermanentFailure(err) => {
rcpt.flags |= RCPT_DSN_SENT | RCPT_STATUS_CHANGED;
if !rcpt.has_flag(RCPT_NOTIFY_FAILURE) {
continue;
}
rcpt.write_dsn(&mut dsn);
domain.status.write_dsn(&mut dsn);
err.write_dsn_text(&rcpt.address, &domain.domain, &mut txt_failed);
}
Status::TemporaryFailure(err)
if domain.notify.due <= now && rcpt.has_flag(RCPT_NOTIFY_DELAY) =>
{
rcpt.write_dsn(&mut dsn);
domain.status.write_dsn(&mut dsn);
domain.write_dsn_will_retry_until(&mut dsn);
err.write_dsn_text(&rcpt.address, &domain.domain, &mut txt_delay);
}
Status::Scheduled
if domain.notify.due <= now && rcpt.has_flag(RCPT_NOTIFY_DELAY) =>
{
// This case should not happen under normal circumstances
rcpt.write_dsn(&mut dsn);
domain.status.write_dsn(&mut dsn);
domain.write_dsn_will_retry_until(&mut dsn);
Error::ConcurrencyLimited.write_dsn_text(
&rcpt.address,
&domain.domain,
&mut txt_delay,
);
}
Status::Completed(_) => {
#[cfg(feature = "test_mode")]
panic!("This should not have happened.");
}
_ => continue,
}
}
_ => continue,
}
dsn.push_str("\r\n");
}
// Build text response
let txt_len = txt_success.len() + txt_delay.len() + txt_failed.len();
if txt_len == 0 {
return None;
}
let has_success = !txt_success.is_empty();
let has_delay = !txt_delay.is_empty();
let has_failure = !txt_failed.is_empty();
let mut txt = String::with_capacity(txt_len + 128);
let (subject, is_mixed) = if has_success && !has_delay && !has_failure {
txt.push_str(
"Your message has been successfully delivered to the following recipients:\r\n\r\n",
);
("Successfully delivered message", false)
} else if has_delay && !has_success && !has_failure {
txt.push_str("There was a temporary problem delivering your message to the following recipients:\r\n\r\n");
("Warning: Delay in message delivery", false)
} else if has_failure && !has_success && !has_delay {
txt.push_str(
"Your message could not be delivered to the following recipients:\r\n\r\n",
);
("Failed to deliver message", false)
} else if has_success {
txt.push_str("Your message has been partially delivered:\r\n\r\n");
("Partially delivered message", true)
} else {
txt.push_str("Your message could not be delivered to some recipients:\r\n\r\n");
(
"Warning: Temporary and permanent failures during message delivery",
true,
)
};
if has_success {
if is_mixed {
txt.push_str(
" ----- Delivery to the following addresses was succesful -----\r\n",
);
}
txt.push_str(&txt_success);
txt.push_str("\r\n");
}
if has_delay {
if is_mixed {
txt.push_str(
" ----- There was a temporary problem delivering to these addresses -----\r\n",
);
}
txt.push_str(&txt_delay);
txt.push_str("\r\n");
}
if has_failure {
if is_mixed {
txt.push_str(" ----- Delivery to the following addresses failed -----\r\n");
}
txt.push_str(&txt_failed);
txt.push_str("\r\n");
}
// Update next delay notification time
if has_delay {
let mut domains = std::mem::take(&mut self.message.domains);
for domain in &mut domains {
if matches!(
&domain.status,
Status::TemporaryFailure(_) | Status::Scheduled
) && domain.notify.due <= now
{
let envelope = SimpleEnvelope::new(&self.message, &domain.domain);
if let Some(next_notify) = config
.notify
.eval(&envelope)
.await
.get((domain.notify.inner + 1) as usize)
{
domain.notify.inner += 1;
domain.notify.due = Instant::now() + *next_notify;
} else {
domain.notify.due = domain.expires + Duration::from_secs(10);
}
domain.changed = true;
}
}
self.message.domains = domains;
}
// Obtain hostname and sender addresses
let from_name = config.dsn.name.eval(self.message.as_ref()).await;
let from_addr = config.dsn.address.eval(self.message.as_ref()).await;
let reporting_mta = config.hostname.eval(self.message.as_ref()).await;
// Prepare DSN
let mut dsn_header = String::with_capacity(dsn.len() + 128);
self.message
.write_dsn_headers(&mut dsn_header, reporting_mta);
let dsn = dsn_header + &dsn;
// Fetch up to 1024 bytes of message headers
let headers = match File::open(&self.message.path).await {
Ok(mut file) => {
let mut buf = vec![0u8; std::cmp::min(self.message.size, 1024)];
match file.read(&mut buf).await {
Ok(br) => {
let mut prev_ch = 0;
let mut last_lf = br;
for (pos, &ch) in buf.iter().enumerate() {
match ch {
b'\n' => {
last_lf = pos + 1;
if prev_ch != b'\n' {
prev_ch = ch;
} else {
break;
}
}
b'\r' => (),
0 => break,
_ => {
prev_ch = ch;
}
}
}
if last_lf < 1024 {
buf.truncate(last_lf);
}
String::from_utf8(buf).unwrap_or_default()
}
Err(err) => {
tracing::error!(
parent: &self.span,
context = "queue",
event = "error",
"Failed to read from {}: {}",
self.message.path.display(),
err
);
String::new()
}
}
}
Err(err) => {
tracing::error!(
parent: &self.span,
context = "queue",
event = "error",
"Failed to open file {}: {}",
self.message.path.display(),
err
);
String::new()
}
};
// Build message
MessageBuilder::new()
.from((from_name.as_str(), from_addr.as_str()))
.header(
"To",
HeaderType::Text(self.message.return_path.as_str().into()),
)
.header("Auto-Submitted", HeaderType::Text("auto-generated".into()))
.message_id(format!("<{}@{}>", make_boundary("."), reporting_mta))
.subject(subject)
.body(MimePart::new(
ContentType::new("multipart/report").attribute("report-type", "delivery-status"),
BodyPart::Multipart(vec![
MimePart::new(ContentType::new("text/plain"), BodyPart::Text(txt.into())),
MimePart::new(
ContentType::new("message/delivery-status"),
BodyPart::Text(dsn.into()),
),
MimePart::new(
ContentType::new("message/rfc822"),
BodyPart::Text(headers.into()),
),
]),
))
.write_to_vec()
.unwrap_or_default()
.into()
}
fn handle_double_bounce(&mut self) {
let mut is_double_bounce = Vec::with_capacity(0);
let message = &mut self.message;
for rcpt in &mut message.recipients {
if !rcpt.has_flag(RCPT_DSN_SENT | RCPT_NOTIFY_NEVER) {
match &rcpt.status {
Status::PermanentFailure(err) => {
rcpt.flags |= RCPT_DSN_SENT;
let mut dsn = String::new();
err.write_dsn_text(&rcpt.address, &mut dsn);
is_double_bounce.push(dsn);
}
Status::Scheduled => {
let domain = &message.domains[rcpt.domain_idx];
if let Status::PermanentFailure(err) = &domain.status {
rcpt.flags |= RCPT_DSN_SENT;
let mut dsn = String::new();
err.write_dsn_text(&rcpt.address, &domain.domain, &mut dsn);
is_double_bounce.push(dsn);
}
}
_ => (),
}
}
}
let now = Instant::now();
for domain in &mut message.domains {
if domain.notify.due <= now {
domain.notify.due = domain.expires + Duration::from_secs(10);
}
}
if !is_double_bounce.is_empty() {
tracing::info!(
parent: &self.span,
context = "queue",
event = "double-bounce",
id = self.message.id,
failures = ?is_double_bounce,
"Failed delivery of message with null return path.",
);
}
}
}
impl HostResponse<String> {
fn write_dsn_text(&self, addr: &str, dsn: &mut String) {
let _ = write!(
dsn,
"<{}> (delivered to '{}' with code {} ({}.{}.{}) '",
addr,
self.hostname,
self.response.code,
self.response.esc[0],
self.response.esc[1],
self.response.esc[2]
);
self.response.write_response(dsn);
dsn.push_str("')\r\n");
}
}
impl HostResponse<ErrorDetails> {
fn write_dsn_text(&self, addr: &str, dsn: &mut String) {
let _ = write!(dsn, "<{}> (host '{}' rejected ", addr, self.hostname.entity);
if !self.hostname.details.is_empty() {
let _ = write!(dsn, "command '{}'", self.hostname.details,);
} else {
dsn.push_str("transaction");
}
let _ = write!(
dsn,
" with code {} ({}.{}.{}) '",
self.response.code, self.response.esc[0], self.response.esc[1], self.response.esc[2]
);
self.response.write_response(dsn);
dsn.push_str("')\r\n");
}
}
impl Error {
fn write_dsn_text(&self, addr: &str, domain: &str, dsn: &mut String) {
match self {
Error::UnexpectedResponse(response) => {
response.write_dsn_text(addr, dsn);
}
Error::DnsError(err) => {
let _ = write!(dsn, "<{addr}> (failed to lookup '{domain}': {err})\r\n",);
}
Error::ConnectionError(details) => {
let _ = write!(
dsn,
"<{}> (connection to '{}' failed: {})\r\n",
addr, details.entity, details.details
);
}
Error::TlsError(details) => {
let _ = write!(
dsn,
"<{}> (TLS error from '{}': {})\r\n",
addr, details.entity, details.details
);
}
Error::DaneError(details) => {
let _ = write!(
dsn,
"<{}> (DANE failed to authenticate '{}': {})\r\n",
addr, details.entity, details.details
);
}
Error::MtaStsError(details) => {
let _ = write!(
dsn,
"<{addr}> (MTA-STS failed to authenticate '{domain}': {details})\r\n",
);
}
Error::RateLimited => {
let _ = write!(dsn, "<{addr}> (rate limited)\r\n");
}
Error::ConcurrencyLimited => {
let _ = write!(
dsn,
"<{addr}> (too many concurrent connections to remote server)\r\n",
);
}
Error::Io(err) => {
let _ = write!(dsn, "<{addr}> (queue error: {err})\r\n");
}
}
}
}
impl Message {
fn write_dsn_headers(&self, dsn: &mut String, reporting_mta: &str) {
let _ = write!(dsn, "Reporting-MTA: dns;{reporting_mta}\r\n");
dsn.push_str("Arrival-Date: ");
dsn.push_str(&DateTime::from_timestamp(self.created as i64).to_rfc822());
dsn.push_str("\r\n");
if let Some(env_id) = &self.env_id {
let _ = write!(dsn, "Original-Envelope-Id: {env_id}\r\n");
}
dsn.push_str("\r\n");
}
}
impl Recipient {
fn write_dsn(&self, dsn: &mut String) {
if let Some(orcpt) = &self.orcpt {
let _ = write!(dsn, "Original-Recipient: rfc822;{orcpt}\r\n");
}
let _ = write!(dsn, "Final-Recipient: rfc822;{}\r\n", self.address);
}
}
impl Domain {
fn write_dsn_will_retry_until(&self, dsn: &mut String) {
let now = Instant::now();
if self.expires > now {
dsn.push_str("Will-Retry-Until: ");
dsn.push_str(
&DateTime::from_timestamp(instant_to_timestamp(now, self.expires) as i64)
.to_rfc822(),
);
dsn.push_str("\r\n");
}
}
}
impl<T, E> Status<T, E> {
pub fn into_permanent(self) -> Self {
match self {
Status::TemporaryFailure(v) => Status::PermanentFailure(v),
v => v,
}
}
fn write_dsn_action(&self, dsn: &mut String) {
dsn.push_str("Action: ");
dsn.push_str(match self {
Status::Completed(_) => "delivered",
Status::PermanentFailure(_) => "failed",
Status::TemporaryFailure(_) | Status::Scheduled => "delayed",
});
dsn.push_str("\r\n");
}
}
impl Status<HostResponse<String>, HostResponse<ErrorDetails>> {
fn write_dsn(&self, dsn: &mut String) {
self.write_dsn_action(dsn);
self.write_dsn_status(dsn);
self.write_dsn_diagnostic(dsn);
self.write_dsn_remote_mta(dsn);
}
fn write_dsn_status(&self, dsn: &mut String) {
dsn.push_str("Status: ");
if let Status::Completed(HostResponse { response, .. })
| Status::PermanentFailure(HostResponse { response, .. })
| Status::TemporaryFailure(HostResponse { response, .. }) = self
{
response.write_dsn_status(dsn);
}
dsn.push_str("\r\n");
}
fn write_dsn_remote_mta(&self, dsn: &mut String) {
dsn.push_str("Remote-MTA: dns;");
if let Status::Completed(HostResponse { hostname, .. })
| Status::PermanentFailure(HostResponse {
hostname: ErrorDetails {
entity: hostname, ..
},
..
})
| Status::TemporaryFailure(HostResponse {
hostname: ErrorDetails {
entity: hostname, ..
},
..
}) = self
{
dsn.push_str(hostname);
}
dsn.push_str("\r\n");
}
fn write_dsn_diagnostic(&self, dsn: &mut String) {
if let Status::PermanentFailure(details) | Status::TemporaryFailure(details) = self {
details.response.write_dsn_diagnostic(dsn);
}
}
}
impl Status<(), Error> {
fn write_dsn(&self, dsn: &mut String) {
self.write_dsn_action(dsn);
self.write_dsn_status(dsn);
self.write_dsn_diagnostic(dsn);
self.write_dsn_remote_mta(dsn);
}
fn write_dsn_status(&self, dsn: &mut String) {
if let Status::PermanentFailure(err) | Status::TemporaryFailure(err) = self {
dsn.push_str("Status: ");
if let Error::UnexpectedResponse(response) = err {
response.response.write_dsn_status(dsn);
} else {
dsn.push_str(if matches!(self, Status::PermanentFailure(_)) {
"5.0.0"
} else {
"4.0.0"
});
}
dsn.push_str("\r\n");
}
}
fn write_dsn_remote_mta(&self, dsn: &mut String) {
if let Status::PermanentFailure(err) | Status::TemporaryFailure(err) = self {
match err {
Error::UnexpectedResponse(HostResponse {
hostname: details, ..
})
| Error::ConnectionError(details)
| Error::TlsError(details)
| Error::DaneError(details) => {
dsn.push_str("Remote-MTA: dns;");
dsn.push_str(&details.entity);
dsn.push_str("\r\n");
}
_ => (),
}
}
}
fn write_dsn_diagnostic(&self, dsn: &mut String) {
if let Status::PermanentFailure(Error::UnexpectedResponse(response))
| Status::TemporaryFailure(Error::UnexpectedResponse(response)) = self
{
response.response.write_dsn_diagnostic(dsn);
}
}
}
impl WriteDsn for Response<String> {
fn write_dsn_status(&self, dsn: &mut String) {
if self.esc[0] > 0 {
let _ = write!(dsn, "{}.{}.{}", self.esc[0], self.esc[1], self.esc[2]);
} else {
let _ = write!(
dsn,
"{}.{}.{}",
self.code / 100,
(self.code / 10) % 10,
self.code % 10
);
}
}
fn write_dsn_diagnostic(&self, dsn: &mut String) {
let _ = write!(dsn, "Diagnostic-Code: smtp;{} ", self.code);
self.write_response(dsn);
dsn.push_str("\r\n");
}
fn write_response(&self, dsn: &mut String) {
for ch in self.message.chars() {
if ch != '\n' && ch != '\r' {
dsn.push(ch);
}
}
}
}
trait WriteDsn {
fn write_dsn_status(&self, dsn: &mut String);
fn write_dsn_diagnostic(&self, dsn: &mut String);
fn write_response(&self, dsn: &mut String);
}

View file

@ -0,0 +1,561 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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::{
collections::BinaryHeap,
sync::{atomic::Ordering, Arc},
time::{Duration, Instant},
};
use ahash::AHashMap;
use smtp_proto::Response;
use tokio::sync::mpsc;
use crate::core::{
management::{self},
Core, QueueCore,
};
use super::{
DeliveryAttempt, Event, HostResponse, Message, OnHold, QueueId, Schedule, Status, WorkerResult,
RCPT_STATUS_CHANGED,
};
#[derive(Debug)]
pub struct Queue {
short_wait: Duration,
long_wait: Duration,
pub scheduled: BinaryHeap<Schedule<QueueId>>,
pub on_hold: Vec<OnHold<QueueId>>,
pub messages: AHashMap<QueueId, Box<Message>>,
}
impl SpawnQueue for mpsc::Receiver<Event> {
fn spawn(mut self, core: Arc<Core>, mut queue: Queue) {
tokio::spawn(async move {
loop {
let result = tokio::time::timeout(queue.wake_up_time(), self.recv()).await;
// Deliver scheduled messages
while let Some(message) = queue.next_due() {
DeliveryAttempt::from(message)
.try_deliver(core.clone(), &mut queue)
.await;
}
match result {
Ok(Some(event)) => match event {
Event::Queue(item) => {
// Deliver any concurrency limited messages
while let Some(message) = queue.next_on_hold() {
DeliveryAttempt::from(message)
.try_deliver(core.clone(), &mut queue)
.await;
}
if item.due <= Instant::now() {
DeliveryAttempt::from(item.inner)
.try_deliver(core.clone(), &mut queue)
.await;
} else {
queue.schedule(item);
}
}
Event::Done(result) => {
// A worker is done, try delivering concurrency limited messages
while let Some(message) = queue.next_on_hold() {
DeliveryAttempt::from(message)
.try_deliver(core.clone(), &mut queue)
.await;
}
match result {
WorkerResult::Done => (),
WorkerResult::Retry(schedule) => {
queue.schedule(schedule);
}
WorkerResult::OnHold(on_hold) => {
queue.on_hold(on_hold);
}
}
}
Event::Manage(request) => match request {
management::QueueRequest::List {
from,
to,
before,
after,
result_tx,
} => {
let mut result = Vec::with_capacity(queue.messages.len());
for message in queue.messages.values() {
if from.as_ref().map_or(false, |from| {
!message.return_path_lcase.contains(from)
}) {
continue;
}
if to.as_ref().map_or(false, |to| {
!message
.recipients
.iter()
.any(|rcpt| rcpt.address_lcase.contains(to))
}) {
continue;
}
if (before.is_some() || after.is_some())
&& !message.domains.iter().any(|domain| {
matches!(
&domain.status,
Status::Scheduled | Status::TemporaryFailure(_)
) && match (&before, &after) {
(Some(before), Some(after)) => {
domain.retry.due.lt(before)
&& domain.retry.due.gt(after)
}
(Some(before), None) => domain.retry.due.lt(before),
(None, Some(after)) => domain.retry.due.gt(after),
(None, None) => false,
}
})
{
continue;
}
result.push(message.id);
}
result.sort_unstable_by_key(|id| *id & 0xFFFFFFFF);
let _ = result_tx.send(result);
}
management::QueueRequest::Status {
queue_ids,
result_tx,
} => {
let mut result = Vec::with_capacity(queue_ids.len());
for queue_id in queue_ids {
result.push(
queue
.messages
.get(&queue_id)
.map(|message| message.as_ref().into()),
);
}
let _ = result_tx.send(result);
}
management::QueueRequest::Cancel {
queue_ids,
item,
result_tx,
} => {
let mut result = Vec::with_capacity(queue_ids.len());
for queue_id in &queue_ids {
let mut found = false;
if let Some(item) = &item {
if let Some(message) = queue.messages.get_mut(queue_id) {
// Cancel delivery for all recipients that match
for rcpt in &mut message.recipients {
if rcpt.address_lcase.contains(item) {
rcpt.flags |= RCPT_STATUS_CHANGED;
rcpt.status = Status::Completed(HostResponse {
hostname: String::new(),
response: Response {
code: 0,
esc: [0, 0, 0],
message: "Delivery canceled."
.to_string(),
},
});
found = true;
}
}
if found {
// Mark as completed domains without any pending deliveries
for (domain_idx, domain) in
message.domains.iter_mut().enumerate()
{
if matches!(
domain.status,
Status::TemporaryFailure(_)
| Status::Scheduled
) {
let mut total_rcpt = 0;
let mut total_completed = 0;
for rcpt in &message.recipients {
if rcpt.domain_idx == domain_idx {
total_rcpt += 1;
if matches!(
rcpt.status,
Status::PermanentFailure(_)
| Status::Completed(_)
) {
total_completed += 1;
}
}
}
if total_rcpt == total_completed {
domain.status = Status::Completed(());
domain.changed = true;
}
}
}
// Delete message if there are no pending deliveries
if message.domains.iter().any(|domain| {
matches!(
domain.status,
Status::TemporaryFailure(_)
| Status::Scheduled
)
}) {
message.save_changes().await;
} else {
message.remove().await;
queue.messages.remove(queue_id);
}
}
}
} else if let Some(message) = queue.messages.remove(queue_id) {
message.remove().await;
found = true;
}
result.push(found);
}
let _ = result_tx.send(result);
}
management::QueueRequest::Retry {
queue_ids,
item,
time,
result_tx,
} => {
let mut result = Vec::with_capacity(queue_ids.len());
for queue_id in &queue_ids {
let mut found = false;
if let Some(message) = queue.messages.get_mut(queue_id) {
for domain in &mut message.domains {
if matches!(
domain.status,
Status::Scheduled | Status::TemporaryFailure(_)
) && item
.as_ref()
.map_or(true, |item| domain.domain.contains(item))
{
domain.retry.due = time;
if domain.expires > time {
domain.expires = time + Duration::from_secs(10);
}
domain.changed = true;
found = true;
}
}
if found {
queue.on_hold.retain(|oh| &oh.message != queue_id);
message.save_changes().await;
if let Some(next_event) = message.next_event() {
queue.scheduled.push(Schedule {
due: next_event,
inner: *queue_id,
});
}
}
}
result.push(found);
}
let _ = result_tx.send(result);
}
},
Event::Stop => break,
},
Ok(None) => break,
Err(_) => (),
}
}
});
}
}
impl Queue {
pub fn schedule(&mut self, message: Schedule<Box<Message>>) {
self.scheduled.push(Schedule {
due: message.due,
inner: message.inner.id,
});
self.messages.insert(message.inner.id, message.inner);
}
pub fn on_hold(&mut self, message: OnHold<Box<Message>>) {
self.on_hold.push(OnHold {
next_due: message.next_due,
limiters: message.limiters,
message: message.message.id,
});
self.messages.insert(message.message.id, message.message);
}
pub fn next_due(&mut self) -> Option<Box<Message>> {
let item = self.scheduled.peek()?;
if item.due <= Instant::now() {
self.scheduled
.pop()
.and_then(|i| self.messages.remove(&i.inner))
} else {
None
}
}
pub fn next_on_hold(&mut self) -> Option<Box<Message>> {
let now = Instant::now();
self.on_hold
.iter()
.position(|o| {
o.limiters
.iter()
.any(|l| l.concurrent.load(Ordering::Relaxed) < l.max_concurrent)
|| o.next_due.map_or(false, |due| due <= now)
})
.and_then(|pos| self.messages.remove(&self.on_hold.remove(pos).message))
}
pub fn wake_up_time(&self) -> Duration {
self.scheduled
.peek()
.map(|item| {
item.due
.checked_duration_since(Instant::now())
.unwrap_or(self.short_wait)
})
.unwrap_or(self.long_wait)
}
}
impl Message {
pub fn next_event(&self) -> Option<Instant> {
let mut next_event = Instant::now();
let mut has_events = false;
for domain in &self.domains {
if matches!(
domain.status,
Status::Scheduled | Status::TemporaryFailure(_)
) {
if !has_events || domain.retry.due < next_event {
next_event = domain.retry.due;
has_events = true;
}
if domain.notify.due < next_event {
next_event = domain.notify.due;
}
if domain.expires < next_event {
next_event = domain.expires;
}
}
}
if has_events {
next_event.into()
} else {
None
}
}
pub fn next_delivery_event(&self) -> Instant {
let mut next_delivery = Instant::now();
for (pos, domain) in self
.domains
.iter()
.filter(|d| matches!(d.status, Status::Scheduled | Status::TemporaryFailure(_)))
.enumerate()
{
if pos == 0 || domain.retry.due < next_delivery {
next_delivery = domain.retry.due;
}
}
next_delivery
}
pub fn next_event_after(&self, instant: Instant) -> Option<Instant> {
let mut next_event = None;
for domain in &self.domains {
if matches!(
domain.status,
Status::Scheduled | Status::TemporaryFailure(_)
) {
if domain.retry.due > instant
&& next_event
.as_ref()
.map_or(true, |ne| domain.retry.due.lt(ne))
{
next_event = domain.retry.due.into();
}
if domain.notify.due > instant
&& next_event
.as_ref()
.map_or(true, |ne| domain.notify.due.lt(ne))
{
next_event = domain.notify.due.into();
}
if domain.expires > instant
&& next_event.as_ref().map_or(true, |ne| domain.expires.lt(ne))
{
next_event = domain.expires.into();
}
}
}
next_event
}
}
impl QueueCore {
pub async fn read_queue(&self) -> Queue {
let mut queue = Queue::default();
let mut messages = Vec::new();
for path in self
.config
.path
.if_then
.iter()
.map(|t| &t.then)
.chain([&self.config.path.default])
{
let mut dir = match tokio::fs::read_dir(path).await {
Ok(dir) => dir,
Err(_) => continue,
};
loop {
match dir.next_entry().await {
Ok(Some(file)) => {
let file = file.path();
if file.is_dir() {
match tokio::fs::read_dir(&file).await {
Ok(mut dir) => {
let file_ = file;
loop {
match dir.next_entry().await {
Ok(Some(file)) => {
let file = file.path();
if file.extension().map_or(false, |e| e == "msg") {
messages.push(tokio::spawn(
Message::from_path(file),
));
}
}
Ok(None) => break,
Err(err) => {
tracing::warn!(
"Failed to read queue directory {}: {}",
file_.display(),
err
);
break;
}
}
}
}
Err(err) => {
tracing::warn!(
"Failed to read queue directory {}: {}",
file.display(),
err
)
}
};
} else if file.extension().map_or(false, |e| e == "msg") {
messages.push(tokio::spawn(Message::from_path(file)));
}
}
Ok(None) => {
break;
}
Err(err) => {
tracing::warn!(
"Failed to read queue directory {}: {}",
path.display(),
err
);
break;
}
}
}
}
// Join all futures
for message in messages {
match message.await {
Ok(Ok(mut message)) => {
// Reserve quota
self.has_quota(&mut message).await;
// Schedule message
queue.schedule(Schedule {
due: message.next_event().unwrap_or_else(|| {
tracing::warn!(
context = "queue",
event = "warn",
"No due events found for message {}",
message.path.display()
);
Instant::now()
}),
inner: Box::new(message),
});
}
Ok(Err(err)) => {
tracing::warn!(
context = "queue",
event = "error",
"Queue startup error: {}",
err
);
}
Err(err) => {
tracing::error!("Join error while starting queue: {}", err);
}
}
}
queue
}
}
impl Default for Queue {
fn default() -> Self {
Queue {
short_wait: Duration::from_millis(1),
long_wait: Duration::from_secs(86400 * 365),
scheduled: BinaryHeap::with_capacity(128),
on_hold: Vec::with_capacity(128),
messages: AHashMap::with_capacity(128),
}
}
}
pub trait SpawnQueue {
fn spawn(self, core: Arc<Core>, queue: Queue);
}

View file

@ -0,0 +1,550 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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::{
fmt::Display,
net::{IpAddr, Ipv4Addr},
path::PathBuf,
sync::{atomic::AtomicUsize, Arc},
time::{Duration, Instant, SystemTime},
};
use serde::{Deserialize, Serialize};
use smtp_proto::Response;
use utils::listener::limiter::{ConcurrencyLimiter, InFlight};
use crate::core::{management, Envelope};
pub mod dsn;
pub mod manager;
pub mod quota;
pub mod serialize;
pub mod spool;
pub mod throttle;
pub type QueueId = u64;
#[derive(Debug)]
pub enum Event {
Queue(Schedule<Box<Message>>),
Manage(management::QueueRequest),
Done(WorkerResult),
Stop,
}
#[derive(Debug)]
pub enum WorkerResult {
Done,
Retry(Schedule<Box<Message>>),
OnHold(OnHold<Box<Message>>),
}
#[derive(Debug)]
pub struct OnHold<T> {
pub next_due: Option<Instant>,
pub limiters: Vec<ConcurrencyLimiter>,
pub message: T,
}
#[derive(Debug)]
pub struct Schedule<T> {
pub due: Instant,
pub inner: T,
}
#[derive(Debug)]
pub struct Message {
pub id: QueueId,
pub created: u64,
pub path: PathBuf,
pub return_path: String,
pub return_path_lcase: String,
pub return_path_domain: String,
pub recipients: Vec<Recipient>,
pub domains: Vec<Domain>,
pub flags: u64,
pub env_id: Option<String>,
pub priority: i16,
pub size: usize,
pub queue_refs: Vec<UsedQuota>,
}
#[derive(Debug, PartialEq, Eq)]
pub struct Domain {
pub domain: String,
pub retry: Schedule<u32>,
pub notify: Schedule<u32>,
pub expires: Instant,
pub status: Status<(), Error>,
pub changed: bool,
}
#[derive(Debug, PartialEq, Eq)]
pub struct Recipient {
pub domain_idx: usize,
pub address: String,
pub address_lcase: String,
pub status: Status<HostResponse<String>, HostResponse<ErrorDetails>>,
pub flags: u64,
pub orcpt: Option<String>,
}
pub const RCPT_DSN_SENT: u64 = 1 << 32;
pub const RCPT_STATUS_CHANGED: u64 = 2 << 32;
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Status<T, E> {
#[serde(rename = "scheduled")]
Scheduled,
#[serde(rename = "completed")]
Completed(T),
#[serde(rename = "temp_fail")]
TemporaryFailure(E),
#[serde(rename = "perm_fail")]
PermanentFailure(E),
}
#[derive(Debug, PartialEq, Eq)]
pub struct HostResponse<T> {
pub hostname: T,
pub response: Response<String>,
}
#[derive(Debug, PartialEq, Eq)]
pub enum Error {
DnsError(String),
UnexpectedResponse(HostResponse<ErrorDetails>),
ConnectionError(ErrorDetails),
TlsError(ErrorDetails),
DaneError(ErrorDetails),
MtaStsError(String),
RateLimited,
ConcurrencyLimited,
Io(String),
}
#[derive(Debug, PartialEq, Eq)]
pub struct ErrorDetails {
pub entity: String,
pub details: String,
}
pub struct DeliveryAttempt {
pub span: tracing::Span,
pub in_flight: Vec<InFlight>,
pub message: Box<Message>,
}
#[derive(Debug)]
pub struct QuotaLimiter {
pub max_size: usize,
pub max_messages: usize,
pub size: AtomicUsize,
pub messages: AtomicUsize,
}
#[derive(Debug)]
pub struct UsedQuota {
id: u64,
size: usize,
limiter: Arc<QuotaLimiter>,
}
impl PartialEq for UsedQuota {
fn eq(&self, other: &Self) -> bool {
self.id == other.id && self.size == other.size
}
}
impl Eq for UsedQuota {}
impl<T> Ord for Schedule<T> {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
other.due.cmp(&self.due)
}
}
impl<T> PartialOrd for Schedule<T> {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
other.due.partial_cmp(&self.due)
}
}
impl<T> PartialEq for Schedule<T> {
fn eq(&self, other: &Self) -> bool {
self.due == other.due
}
}
impl<T> Eq for Schedule<T> {}
impl<T: Default> Schedule<T> {
pub fn now() -> Self {
Schedule {
due: Instant::now(),
inner: T::default(),
}
}
pub fn later(duration: Duration) -> Self {
Schedule {
due: Instant::now() + duration,
inner: T::default(),
}
}
}
pub struct SimpleEnvelope<'x> {
pub message: &'x Message,
pub domain: &'x str,
pub recipient: &'x str,
}
impl<'x> SimpleEnvelope<'x> {
pub fn new(message: &'x Message, domain: &'x str) -> Self {
Self {
message,
domain,
recipient: "",
}
}
pub fn new_rcpt(message: &'x Message, domain: &'x str, recipient: &'x str) -> Self {
Self {
message,
domain,
recipient,
}
}
}
impl<'x> Envelope for SimpleEnvelope<'x> {
fn local_ip(&self) -> IpAddr {
IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))
}
fn remote_ip(&self) -> IpAddr {
IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))
}
fn sender_domain(&self) -> &str {
&self.message.return_path_domain
}
fn sender(&self) -> &str {
&self.message.return_path_lcase
}
fn rcpt_domain(&self) -> &str {
self.domain
}
fn rcpt(&self) -> &str {
self.recipient
}
fn helo_domain(&self) -> &str {
""
}
fn authenticated_as(&self) -> &str {
""
}
fn mx(&self) -> &str {
""
}
fn listener_id(&self) -> u16 {
0
}
fn priority(&self) -> i16 {
self.message.priority
}
}
pub struct QueueEnvelope<'x> {
pub message: &'x Message,
pub domain: &'x str,
pub mx: &'x str,
pub remote_ip: IpAddr,
pub local_ip: IpAddr,
}
impl<'x> Envelope for QueueEnvelope<'x> {
fn local_ip(&self) -> IpAddr {
self.local_ip
}
fn remote_ip(&self) -> IpAddr {
self.remote_ip
}
fn sender_domain(&self) -> &str {
&self.message.return_path_domain
}
fn sender(&self) -> &str {
&self.message.return_path_lcase
}
fn rcpt_domain(&self) -> &str {
self.domain
}
fn rcpt(&self) -> &str {
""
}
fn helo_domain(&self) -> &str {
""
}
fn authenticated_as(&self) -> &str {
""
}
fn mx(&self) -> &str {
self.mx
}
fn listener_id(&self) -> u16 {
0
}
fn priority(&self) -> i16 {
self.message.priority
}
}
impl Envelope for Message {
fn local_ip(&self) -> IpAddr {
IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))
}
fn remote_ip(&self) -> IpAddr {
IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))
}
fn sender_domain(&self) -> &str {
&self.return_path_domain
}
fn sender(&self) -> &str {
&self.return_path_lcase
}
fn rcpt_domain(&self) -> &str {
""
}
fn rcpt(&self) -> &str {
""
}
fn helo_domain(&self) -> &str {
""
}
fn authenticated_as(&self) -> &str {
""
}
fn mx(&self) -> &str {
""
}
fn listener_id(&self) -> u16 {
0
}
fn priority(&self) -> i16 {
self.priority
}
}
impl Envelope for &str {
fn local_ip(&self) -> IpAddr {
IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))
}
fn remote_ip(&self) -> IpAddr {
IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))
}
fn sender_domain(&self) -> &str {
""
}
fn sender(&self) -> &str {
""
}
fn rcpt_domain(&self) -> &str {
self
}
fn rcpt(&self) -> &str {
""
}
fn helo_domain(&self) -> &str {
""
}
fn authenticated_as(&self) -> &str {
""
}
fn mx(&self) -> &str {
""
}
fn listener_id(&self) -> u16 {
0
}
fn priority(&self) -> i16 {
0
}
}
#[inline(always)]
pub fn instant_to_timestamp(now: Instant, time: Instant) -> u64 {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
+ time.checked_duration_since(now).map_or(0, |d| d.as_secs())
}
pub trait InstantFromTimestamp {
fn to_instant(&self) -> Instant;
}
impl InstantFromTimestamp for u64 {
fn to_instant(&self) -> Instant {
let timestamp = *self;
let current_timestamp = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
if timestamp > current_timestamp {
Instant::now() + Duration::from_secs(timestamp - current_timestamp)
} else {
Instant::now()
}
}
}
pub trait DomainPart {
fn domain_part(&self) -> &str;
}
impl DomainPart for &str {
#[inline(always)]
fn domain_part(&self) -> &str {
self.rsplit_once('@').map(|(_, d)| d).unwrap_or_default()
}
}
impl DomainPart for String {
#[inline(always)]
fn domain_part(&self) -> &str {
self.rsplit_once('@').map(|(_, d)| d).unwrap_or_default()
}
}
impl Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::UnexpectedResponse(response) => {
write!(
f,
"Unexpected response from '{}': {}",
response.hostname.entity, response.response
)
}
Error::DnsError(err) => {
write!(f, "DNS lookup failed: {err}")
}
Error::ConnectionError(details) => {
write!(
f,
"Connection to '{}' failed: {}",
details.entity, details.details
)
}
Error::TlsError(details) => {
write!(
f,
"TLS error from '{}': {}",
details.entity, details.details
)
}
Error::DaneError(details) => {
write!(
f,
"DANE failed to authenticate '{}': {}",
details.entity, details.details
)
}
Error::MtaStsError(details) => {
write!(f, "MTA-STS auth failed: {details}")
}
Error::RateLimited => {
write!(f, "Rate limited")
}
Error::ConcurrencyLimited => {
write!(f, "Too many concurrent connections to remote server")
}
Error::Io(err) => {
write!(f, "Queue error: {err}")
}
}
}
}
impl Display for Status<(), Error> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Status::Scheduled => write!(f, "Scheduled"),
Status::Completed(_) => write!(f, "Completed"),
Status::TemporaryFailure(err) => write!(f, "Temporary Failure: {err}"),
Status::PermanentFailure(err) => write!(f, "Permanent Failure: {err}"),
}
}
}
impl Display for Status<HostResponse<String>, HostResponse<ErrorDetails>> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Status::Scheduled => write!(f, "Scheduled"),
Status::Completed(response) => write!(f, "Delivered: {}", response.response),
Status::TemporaryFailure(err) => write!(f, "Temporary Failure: {}", err.response),
Status::PermanentFailure(err) => write!(f, "Permanent Failure: {}", err.response),
}
}
}

View file

@ -0,0 +1,195 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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::sync::{atomic::Ordering, Arc};
use dashmap::mapref::entry::Entry;
use crate::{
config::QueueQuota,
core::{Envelope, QueueCore},
};
use super::{Message, QuotaLimiter, SimpleEnvelope, Status, UsedQuota};
impl QueueCore {
pub async fn has_quota(&self, message: &mut Message) -> bool {
let mut queue_refs = Vec::new();
if !self.config.quota.sender.is_empty() {
for quota in &self.config.quota.sender {
if !self
.reserve_quota(quota, message, message.size, 0, &mut queue_refs)
.await
{
return false;
}
}
}
for quota in &self.config.quota.rcpt_domain {
for (pos, domain) in message.domains.iter().enumerate() {
if !self
.reserve_quota(
quota,
&SimpleEnvelope::new(message, &domain.domain),
message.size,
((pos + 1) << 32) as u64,
&mut queue_refs,
)
.await
{
return false;
}
}
}
for quota in &self.config.quota.rcpt {
for (pos, rcpt) in message.recipients.iter().enumerate() {
if !self
.reserve_quota(
quota,
&SimpleEnvelope::new_rcpt(
message,
&message.domains[rcpt.domain_idx].domain,
&rcpt.address_lcase,
),
message.size,
(pos + 1) as u64,
&mut queue_refs,
)
.await
{
return false;
}
}
}
message.queue_refs = queue_refs;
true
}
async fn reserve_quota(
&self,
quota: &QueueQuota,
envelope: &impl Envelope,
size: usize,
id: u64,
refs: &mut Vec<UsedQuota>,
) -> bool {
if !quota.conditions.conditions.is_empty() && quota.conditions.eval(envelope).await {
match self.quota.entry(quota.new_key(envelope)) {
Entry::Occupied(e) => {
if let Some(qref) = e.get().is_allowed(id, size) {
refs.push(qref);
} else {
return false;
}
}
Entry::Vacant(e) => {
let limiter = Arc::new(QuotaLimiter {
max_size: quota.size.unwrap_or(0),
max_messages: quota.messages.unwrap_or(0),
size: 0.into(),
messages: 0.into(),
});
if let Some(qref) = limiter.is_allowed(id, size) {
refs.push(qref);
e.insert(limiter);
} else {
return false;
}
}
}
}
true
}
}
impl Message {
pub fn release_quota(&mut self) {
let mut quota_ids = Vec::with_capacity(self.domains.len() + self.recipients.len());
for (pos, domain) in self.domains.iter().enumerate() {
if matches!(
&domain.status,
Status::Completed(_) | Status::PermanentFailure(_)
) {
quota_ids.push(((pos + 1) << 32) as u64);
}
}
for (pos, rcpt) in self.recipients.iter().enumerate() {
if matches!(
&rcpt.status,
Status::Completed(_) | Status::PermanentFailure(_)
) {
quota_ids.push((pos + 1) as u64);
}
}
if !quota_ids.is_empty() {
self.queue_refs.retain(|q| !quota_ids.contains(&q.id));
}
}
}
trait QuotaLimiterAllowed {
fn is_allowed(&self, id: u64, size: usize) -> Option<UsedQuota>;
}
impl QuotaLimiterAllowed for Arc<QuotaLimiter> {
fn is_allowed(&self, id: u64, size: usize) -> Option<UsedQuota> {
if self.max_messages > 0 {
if self.messages.load(Ordering::Relaxed) < self.max_messages {
self.messages.fetch_add(1, Ordering::Relaxed);
} else {
return None;
}
}
if self.max_size > 0 {
if self.size.load(Ordering::Relaxed) + size < self.max_size {
self.size.fetch_add(size, Ordering::Relaxed);
} else {
return None;
}
}
Some(UsedQuota {
id,
size,
limiter: self.clone(),
})
}
}
impl Drop for UsedQuota {
fn drop(&mut self) {
if self.limiter.max_messages > 0 {
self.limiter.messages.fetch_sub(1, Ordering::Relaxed);
}
if self.limiter.max_size > 0 {
self.limiter.size.fetch_sub(self.size, Ordering::Relaxed);
}
}
}

View file

@ -0,0 +1,564 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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 mail_auth::common::base32::Base32Reader;
use smtp_proto::Response;
use std::io::SeekFrom;
use std::path::PathBuf;
use std::slice::Iter;
use std::{fmt::Write, time::Instant};
use tokio::fs;
use tokio::fs::File;
use tokio::io::{AsyncReadExt, AsyncSeekExt};
use super::{
instant_to_timestamp, Domain, DomainPart, Error, ErrorDetails, HostResponse,
InstantFromTimestamp, Message, Recipient, Schedule, Status, RCPT_STATUS_CHANGED,
};
pub trait QueueSerializer: Sized {
fn serialize(&self, buf: &mut String);
fn deserialize(bytes: &mut Iter<'_, u8>) -> Option<Self>;
}
impl Message {
pub fn serialize(&self) -> Vec<u8> {
let mut buf = String::with_capacity(
self.return_path.len()
+ self.env_id.as_ref().map_or(0, |e| e.len())
+ (self.domains.len() * 64)
+ (self.recipients.len() * 64)
+ 50,
);
// Serialize message properties
(self.created as usize).serialize(&mut buf);
self.return_path.serialize(&mut buf);
(self.env_id.as_deref().unwrap_or_default()).serialize(&mut buf);
(self.flags as usize).serialize(&mut buf);
self.priority.serialize(&mut buf);
// Serialize domains
let now = Instant::now();
self.domains.len().serialize(&mut buf);
for domain in &self.domains {
domain.domain.serialize(&mut buf);
(instant_to_timestamp(now, domain.expires) as usize).serialize(&mut buf);
}
// Serialize recipients
self.recipients.len().serialize(&mut buf);
for rcpt in &self.recipients {
rcpt.domain_idx.serialize(&mut buf);
rcpt.address.serialize(&mut buf);
(rcpt.orcpt.as_deref().unwrap_or_default()).serialize(&mut buf);
}
// Serialize domain status
for (idx, domain) in self.domains.iter().enumerate() {
domain.serialize(idx, now, &mut buf);
}
// Serialize recipient status
for (idx, rcpt) in self.recipients.iter().enumerate() {
rcpt.serialize(idx, &mut buf);
}
buf.into_bytes()
}
pub fn serialize_changes(&mut self) -> Vec<u8> {
let now = Instant::now();
let mut buf = String::with_capacity(128);
for (idx, domain) in self.domains.iter_mut().enumerate() {
if domain.changed {
domain.changed = false;
domain.serialize(idx, now, &mut buf);
}
}
for (idx, rcpt) in self.recipients.iter_mut().enumerate() {
if rcpt.has_flag(RCPT_STATUS_CHANGED) {
rcpt.flags &= !RCPT_STATUS_CHANGED;
rcpt.serialize(idx, &mut buf);
}
}
buf.into_bytes()
}
pub async fn from_path(path: PathBuf) -> Result<Self, String> {
let filename = path
.file_name()
.and_then(|f| f.to_str())
.and_then(|f| f.rsplit_once('.'))
.map(|(f, _)| f)
.ok_or_else(|| format!("Invalid queue file name {}", path.display()))?;
// Decode file name
let mut id = [0u8; std::mem::size_of::<u64>()];
let mut size = [0u8; std::mem::size_of::<u32>()];
for (pos, byte) in Base32Reader::new(filename.as_bytes()).enumerate() {
match pos {
0..=7 => {
id[pos] = byte;
}
8..=11 => {
size[pos - 8] = byte;
}
_ => {
return Err(format!("Invalid queue file name {}", path.display()));
}
}
}
let id = u64::from_le_bytes(id);
let size = u32::from_le_bytes(size) as u64;
// Obtail file size
let file_size = fs::metadata(&path)
.await
.map_err(|err| {
format!(
"Failed to obtain file metadata for {}: {}",
path.display(),
err
)
})?
.len();
if size == 0 || size >= file_size {
return Err(format!(
"Invalid queue file name size {} for {}",
size,
path.display()
));
}
let mut buf = Vec::with_capacity((file_size - size) as usize);
let mut file = File::open(&path)
.await
.map_err(|err| format!("Failed to open queue file {}: {}", path.display(), err))?;
file.seek(SeekFrom::Start(size))
.await
.map_err(|err| format!("Failed to seek queue file {}: {}", path.display(), err))?;
file.read_to_end(&mut buf)
.await
.map_err(|err| format!("Failed to read queue file {}: {}", path.display(), err))?;
let mut message = Self::deserialize(&buf)
.ok_or_else(|| format!("Failed to deserialize metadata for file {}", path.display()))?;
message.path = path;
message.size = size as usize;
message.id = id;
Ok(message)
}
pub fn deserialize(bytes: &[u8]) -> Option<Self> {
let mut bytes = bytes.iter();
let created = usize::deserialize(&mut bytes)? as u64;
let return_path = String::deserialize(&mut bytes)?;
let return_path_lcase = return_path.to_lowercase();
let env_id = String::deserialize(&mut bytes)?;
let mut message = Message {
id: 0,
path: PathBuf::new(),
created,
return_path_domain: return_path_lcase.domain_part().to_string(),
return_path_lcase,
return_path,
env_id: if !env_id.is_empty() {
env_id.into()
} else {
None
},
flags: usize::deserialize(&mut bytes)? as u64,
priority: i16::deserialize(&mut bytes)?,
size: 0,
recipients: vec![],
domains: vec![],
queue_refs: vec![],
};
// Deserialize domains
let num_domains = usize::deserialize(&mut bytes)?;
message.domains = Vec::with_capacity(num_domains);
for _ in 0..num_domains {
message.domains.push(Domain {
domain: String::deserialize(&mut bytes)?,
expires: Instant::deserialize(&mut bytes)?,
retry: Schedule::now(),
notify: Schedule::now(),
status: Status::Scheduled,
changed: false,
});
}
// Deserialize recipients
let num_recipients = usize::deserialize(&mut bytes)?;
message.recipients = Vec::with_capacity(num_recipients);
for _ in 0..num_recipients {
let domain_idx = usize::deserialize(&mut bytes)?;
let address = String::deserialize(&mut bytes)?;
let orcpt = String::deserialize(&mut bytes)?;
message.recipients.push(Recipient {
domain_idx,
address_lcase: address.to_lowercase(),
address,
status: Status::Scheduled,
flags: 0,
orcpt: if !orcpt.is_empty() {
orcpt.into()
} else {
None
},
});
}
// Deserialize status
while let Some((ch, idx)) = bytes
.next()
.and_then(|ch| (ch, usize::deserialize(&mut bytes)?).into())
{
match ch {
b'D' => {
if let (Some(domain), Some(retry), Some(notify), Some(status)) = (
message.domains.get_mut(idx),
Schedule::deserialize(&mut bytes),
Schedule::deserialize(&mut bytes),
Status::deserialize(&mut bytes),
) {
domain.retry = retry;
domain.notify = notify;
domain.status = status;
} else {
break;
}
}
b'R' => {
if let (Some(rcpt), Some(flags), Some(status)) = (
message.recipients.get_mut(idx),
usize::deserialize(&mut bytes),
Status::deserialize(&mut bytes),
) {
rcpt.flags = flags as u64;
rcpt.status = status;
} else {
break;
}
}
_ => break,
}
}
message.into()
}
}
impl<T: QueueSerializer, E: QueueSerializer> QueueSerializer for Status<T, E> {
fn serialize(&self, buf: &mut String) {
match self {
Status::Scheduled => buf.push('S'),
Status::Completed(s) => {
buf.push('C');
s.serialize(buf);
}
Status::TemporaryFailure(s) => {
buf.push('T');
s.serialize(buf);
}
Status::PermanentFailure(s) => {
buf.push('F');
s.serialize(buf);
}
}
}
fn deserialize(bytes: &mut Iter<'_, u8>) -> Option<Self> {
match bytes.next()? {
b'S' => Self::Scheduled.into(),
b'C' => Self::Completed(T::deserialize(bytes)?).into(),
b'T' => Self::TemporaryFailure(E::deserialize(bytes)?).into(),
b'F' => Self::PermanentFailure(E::deserialize(bytes)?).into(),
_ => None,
}
}
}
impl QueueSerializer for Response<String> {
fn serialize(&self, buf: &mut String) {
let _ = write!(
buf,
"{} {} {} {} {} {}",
self.code,
self.esc[0],
self.esc[1],
self.esc[2],
self.message.len(),
self.message
);
}
fn deserialize(bytes: &mut Iter<'_, u8>) -> Option<Self> {
Response {
code: usize::deserialize(bytes)? as u16,
esc: [
usize::deserialize(bytes)? as u8,
usize::deserialize(bytes)? as u8,
usize::deserialize(bytes)? as u8,
],
message: String::deserialize(bytes)?,
}
.into()
}
}
impl QueueSerializer for usize {
fn serialize(&self, buf: &mut String) {
let _ = write!(buf, "{self} ");
}
fn deserialize(bytes: &mut Iter<'_, u8>) -> Option<Self> {
let mut num = 0;
loop {
match bytes.next()? {
ch @ (b'0'..=b'9') => {
num = (num * 10) + (*ch - b'0') as usize;
}
b' ' => {
return num.into();
}
_ => {
return None;
}
}
}
}
}
impl QueueSerializer for i16 {
fn serialize(&self, buf: &mut String) {
let _ = write!(buf, "{self} ");
}
fn deserialize(bytes: &mut Iter<'_, u8>) -> Option<Self> {
let mut num = 0;
let mut mul = 1;
loop {
match bytes.next()? {
ch @ (b'0'..=b'9') => {
num = (num * 10) + (*ch - b'0') as i16;
}
b' ' => {
return (num * mul).into();
}
b'-' => {
mul = -1;
}
_ => {
return None;
}
}
}
}
}
impl QueueSerializer for ErrorDetails {
fn serialize(&self, buf: &mut String) {
self.entity.serialize(buf);
self.details.serialize(buf);
}
fn deserialize(bytes: &mut Iter<'_, u8>) -> Option<Self> {
ErrorDetails {
entity: String::deserialize(bytes)?,
details: String::deserialize(bytes)?,
}
.into()
}
}
impl<T: QueueSerializer> QueueSerializer for HostResponse<T> {
fn serialize(&self, buf: &mut String) {
self.hostname.serialize(buf);
self.response.serialize(buf);
}
fn deserialize(bytes: &mut Iter<'_, u8>) -> Option<Self> {
HostResponse {
hostname: T::deserialize(bytes)?,
response: Response::deserialize(bytes)?,
}
.into()
}
}
impl QueueSerializer for String {
fn serialize(&self, buf: &mut String) {
if !self.is_empty() {
let _ = write!(buf, "{} {}", self.len(), self);
} else {
buf.push_str("0 ");
}
}
fn deserialize(bytes: &mut Iter<'_, u8>) -> Option<Self> {
match usize::deserialize(bytes)? {
len @ (1..=4096) => {
String::from_utf8(bytes.take(len).copied().collect::<Vec<_>>()).ok()
}
0 => String::new().into(),
_ => None,
}
}
}
impl QueueSerializer for &str {
fn serialize(&self, buf: &mut String) {
if !self.is_empty() {
let _ = write!(buf, "{} {}", self.len(), self);
} else {
buf.push_str("0 ");
}
}
fn deserialize(_bytes: &mut Iter<'_, u8>) -> Option<Self> {
unimplemented!()
}
}
impl QueueSerializer for Instant {
fn serialize(&self, buf: &mut String) {
let _ = write!(buf, "{} ", instant_to_timestamp(Instant::now(), *self),);
}
fn deserialize(bytes: &mut Iter<'_, u8>) -> Option<Self> {
(usize::deserialize(bytes)? as u64).to_instant().into()
}
}
impl QueueSerializer for Schedule<u32> {
fn serialize(&self, buf: &mut String) {
let _ = write!(
buf,
"{} {} ",
self.inner,
instant_to_timestamp(Instant::now(), self.due),
);
}
fn deserialize(bytes: &mut Iter<'_, u8>) -> Option<Self> {
Schedule {
inner: usize::deserialize(bytes)? as u32,
due: Instant::deserialize(bytes)?,
}
.into()
}
}
impl QueueSerializer for Error {
fn serialize(&self, buf: &mut String) {
match self {
Error::DnsError(e) => {
buf.push('0');
e.serialize(buf);
}
Error::UnexpectedResponse(e) => {
buf.push('1');
e.serialize(buf);
}
Error::ConnectionError(e) => {
buf.push('2');
e.serialize(buf);
}
Error::TlsError(e) => {
buf.push('3');
e.serialize(buf);
}
Error::DaneError(e) => {
buf.push('4');
e.serialize(buf);
}
Error::MtaStsError(e) => {
buf.push('5');
e.serialize(buf);
}
Error::RateLimited => {
buf.push('6');
}
Error::ConcurrencyLimited => {
buf.push('7');
}
Error::Io(e) => {
buf.push('8');
e.serialize(buf);
}
}
}
fn deserialize(bytes: &mut Iter<'_, u8>) -> Option<Self> {
match bytes.next()? {
b'0' => Error::DnsError(String::deserialize(bytes)?).into(),
b'1' => Error::UnexpectedResponse(HostResponse::deserialize(bytes)?).into(),
b'2' => Error::ConnectionError(ErrorDetails::deserialize(bytes)?).into(),
b'3' => Error::TlsError(ErrorDetails::deserialize(bytes)?).into(),
b'4' => Error::DaneError(ErrorDetails::deserialize(bytes)?).into(),
b'5' => Error::MtaStsError(String::deserialize(bytes)?).into(),
b'6' => Error::RateLimited.into(),
b'7' => Error::ConcurrencyLimited.into(),
b'8' => Error::Io(String::deserialize(bytes)?).into(),
_ => None,
}
}
}
impl QueueSerializer for () {
fn serialize(&self, _buf: &mut String) {}
fn deserialize(_bytes: &mut Iter<'_, u8>) -> Option<Self> {
Some(())
}
}
impl Domain {
fn serialize(&self, idx: usize, now: Instant, buf: &mut String) {
let _ = write!(
buf,
"D{} {} {} {} {} ",
idx,
self.retry.inner,
instant_to_timestamp(now, self.retry.due),
self.notify.inner,
instant_to_timestamp(now, self.notify.due)
);
self.status.serialize(buf);
}
}
impl Recipient {
fn serialize(&self, idx: usize, buf: &mut String) {
let _ = write!(buf, "R{} {} ", idx, self.flags);
self.status.serialize(buf);
}
}

View file

@ -0,0 +1,271 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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::queue::DomainPart;
use mail_auth::common::base32::Base32Writer;
use mail_auth::common::headers::Writer;
use std::path::PathBuf;
use std::sync::atomic::Ordering;
use std::time::Instant;
use std::time::{Duration, SystemTime};
use tokio::fs::OpenOptions;
use tokio::{fs, io::AsyncWriteExt};
use crate::config::QueueConfig;
use crate::core::QueueCore;
use super::{Domain, Event, Message, Recipient, Schedule, SimpleEnvelope, Status};
impl QueueCore {
pub async fn queue_message(
&self,
mut message: Box<Message>,
raw_headers: Option<&[u8]>,
raw_message: &[u8],
span: &tracing::Span,
) -> bool {
// Generate id
if message.id == 0 {
message.id = self.queue_id();
}
if message.size == 0 {
message.size = raw_message.len() + raw_headers.as_ref().map_or(0, |h| h.len());
}
// Build path
message.path = self.config.path.eval(message.as_ref()).await.clone();
let hash = *self.config.hash.eval(message.as_ref()).await;
if hash > 0 {
message.path.push((message.id % hash).to_string());
}
let _ = fs::create_dir(&message.path).await;
// Encode file name
let mut encoder = Base32Writer::with_capacity(20);
encoder.write(&message.id.to_le_bytes()[..]);
encoder.write(&(message.size as u32).to_le_bytes()[..]);
let mut file = encoder.finalize();
file.push_str(".msg");
message.path.push(file);
// Serialize metadata
let metadata = message.serialize();
// Save message
let mut file = match fs::File::create(&message.path).await {
Ok(file) => file,
Err(err) => {
tracing::error!(
parent: span,
context = "queue",
event = "error",
"Failed to create file {}: {}",
message.path.display(),
err
);
return false;
}
};
let iter = if let Some(raw_headers) = raw_headers {
[raw_headers, raw_message, &metadata].into_iter()
} else {
[raw_message, &metadata, b""].into_iter()
};
for bytes in iter {
if !bytes.is_empty() {
if let Err(err) = file.write_all(bytes).await {
tracing::error!(
parent: span,
context = "queue",
event = "error",
"Failed to write to file {}: {}",
message.path.display(),
err
);
return false;
}
}
}
if let Err(err) = file.flush().await {
tracing::error!(
parent: span,
context = "queue",
event = "error",
"Failed to flush file {}: {}",
message.path.display(),
err
);
return false;
}
tracing::info!(
parent: span,
context = "queue",
event = "scheduled",
id = message.id,
from = if !message.return_path.is_empty() {
message.return_path.as_str()
} else {
"<>"
},
nrcpts = message.recipients.len(),
size = message.size,
"Message queued for delivery."
);
// Queue the message
if self
.tx
.send(Event::Queue(Schedule {
due: message.next_event().unwrap(),
inner: message,
}))
.await
.is_err()
{
tracing::warn!(
parent: span,
context = "queue",
event = "error",
"Queue channel closed: Message queued but won't be sent until next restart."
);
}
true
}
pub fn queue_id(&self) -> u64 {
(SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
.saturating_sub(946684800)
& 0xFFFFFFFF)
| (self.id_seq.fetch_add(1, Ordering::Relaxed) as u64) << 32
}
}
impl Message {
pub fn new_boxed(
return_path: impl Into<String>,
return_path_lcase: impl Into<String>,
return_path_domain: impl Into<String>,
) -> Box<Message> {
Box::new(Message {
id: 0,
path: PathBuf::new(),
created: SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
return_path: return_path.into(),
return_path_lcase: return_path_lcase.into(),
return_path_domain: return_path_domain.into(),
recipients: Vec::with_capacity(1),
domains: Vec::with_capacity(1),
flags: 0,
env_id: None,
priority: 0,
size: 0,
queue_refs: vec![],
})
}
pub async fn add_recipient_parts(
&mut self,
rcpt: impl Into<String>,
rcpt_lcase: impl Into<String>,
rcpt_domain: impl Into<String>,
config: &QueueConfig,
) {
let rcpt_domain = rcpt_domain.into();
let domain_idx =
if let Some(idx) = self.domains.iter().position(|d| d.domain == rcpt_domain) {
idx
} else {
let idx = self.domains.len();
let expires = *config
.expire
.eval(&SimpleEnvelope::new(self, &rcpt_domain))
.await;
self.domains.push(Domain {
domain: rcpt_domain,
retry: Schedule::now(),
notify: Schedule::later(expires + Duration::from_secs(10)),
expires: Instant::now() + expires,
status: Status::Scheduled,
changed: false,
});
idx
};
self.recipients.push(Recipient {
domain_idx,
address: rcpt.into(),
address_lcase: rcpt_lcase.into(),
status: Status::Scheduled,
flags: 0,
orcpt: None,
});
}
pub async fn add_recipient(&mut self, rcpt: impl Into<String>, config: &QueueConfig) {
let rcpt = rcpt.into();
let rcpt_lcase = rcpt.to_lowercase();
let rcpt_domain = rcpt_lcase.domain_part().to_string();
self.add_recipient_parts(rcpt, rcpt_lcase, rcpt_domain, config)
.await;
}
pub async fn save_changes(&mut self) {
let buf = self.serialize_changes();
if !buf.is_empty() {
let err = match OpenOptions::new().append(true).open(&self.path).await {
Ok(mut file) => match file.write_all(&buf).await {
Ok(_) => return,
Err(err) => err,
},
Err(err) => err,
};
tracing::error!(
context = "queue",
event = "error",
"Failed to write to {}: {}",
self.path.display(),
err
);
}
}
pub async fn remove(&self) {
if let Err(err) = fs::remove_file(&self.path).await {
tracing::error!(
context = "queue",
event = "error",
"Failed to delete queued message {}: {}",
self.path.display(),
err
);
}
}
}

View file

@ -0,0 +1,123 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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::time::Instant;
use dashmap::mapref::entry::Entry;
use utils::listener::limiter::{ConcurrencyLimiter, InFlight, RateLimiter};
use crate::{
config::Throttle,
core::{throttle::Limiter, Envelope, QueueCore},
};
use super::{Domain, Status};
#[derive(Debug)]
pub enum Error {
Concurrency { limiter: ConcurrencyLimiter },
Rate { retry_at: Instant },
}
impl QueueCore {
pub async fn is_allowed(
&self,
throttle: &Throttle,
envelope: &impl Envelope,
in_flight: &mut Vec<InFlight>,
span: &tracing::Span,
) -> Result<(), Error> {
if throttle.conditions.conditions.is_empty() || throttle.conditions.eval(envelope).await {
match self.throttle.entry(throttle.new_key(envelope)) {
Entry::Occupied(mut e) => {
let limiter = e.get_mut();
if let Some(limiter) = &limiter.concurrency {
if let Some(inflight) = limiter.is_allowed() {
in_flight.push(inflight);
} else {
tracing::info!(
parent: span,
context = "throttle",
event = "too-many-requests",
max_concurrent = limiter.max_concurrent,
"Queue concurrency limit exceeded."
);
return Err(Error::Concurrency {
limiter: limiter.clone(),
});
}
}
if let Some(limiter) = &mut limiter.rate {
if !limiter.is_allowed() {
tracing::info!(
parent: span,
context = "throttle",
event = "rate-limit-exceeded",
max_requests = limiter.max_requests as u64,
max_interval = limiter.max_interval as u64,
"Queue rate limit exceeded."
);
return Err(Error::Rate {
retry_at: limiter.retry_at(),
});
}
}
}
Entry::Vacant(e) => {
let concurrency = throttle.concurrency.map(|concurrency| {
let limiter = ConcurrencyLimiter::new(concurrency);
if let Some(inflight) = limiter.is_allowed() {
in_flight.push(inflight);
}
limiter
});
let rate = throttle.rate.as_ref().map(|rate| {
let mut r = RateLimiter::new(rate.requests, rate.period.as_secs());
r.is_allowed();
r
});
e.insert(Limiter { rate, concurrency });
}
}
}
Ok(())
}
}
impl Domain {
pub fn set_throttle_error(&mut self, err: Error, on_hold: &mut Vec<ConcurrencyLimiter>) {
match err {
Error::Concurrency { limiter } => {
on_hold.push(limiter);
self.status = Status::TemporaryFailure(super::Error::ConcurrencyLimited);
}
Error::Rate { retry_at } => {
self.retry.due = retry_at;
self.status = Status::TemporaryFailure(super::Error::RateLimited);
}
}
self.changed = true;
}
}

View file

@ -0,0 +1,488 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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,
collections::hash_map::Entry,
io::{Cursor, Read},
sync::{atomic::Ordering, Arc},
time::SystemTime,
};
use ahash::AHashMap;
use mail_auth::{
flate2::read::GzDecoder,
report::{tlsrpt::TlsReport, ActionDisposition, DmarcResult, Feedback, Report},
zip,
};
use mail_parser::{DateTime, HeaderValue, Message, MimeHeaders, PartType};
use crate::core::Core;
enum Compression {
None,
Gzip,
Zip,
}
enum Format {
Dmarc,
Tls,
Arf,
}
struct ReportData<'x> {
compression: Compression,
format: Format,
data: &'x [u8],
}
pub trait AnalyzeReport {
fn analyze_report(&self, message: Arc<Vec<u8>>);
}
impl AnalyzeReport for Arc<Core> {
fn analyze_report(&self, message: Arc<Vec<u8>>) {
let core = self.clone();
self.worker_pool.spawn(move || {
let message = if let Some(message) = Message::parse(&message) {
message
} else {
tracing::debug!(context = "report", "Failed to parse message.");
return;
};
let from = match message.from() {
HeaderValue::Address(addr) => addr.address.as_ref().map(|a| a.as_ref()),
HeaderValue::AddressList(addr_list) => addr_list
.last()
.and_then(|a| a.address.as_ref())
.map(|a| a.as_ref()),
_ => None,
}
.unwrap_or("unknown");
let mut reports = Vec::new();
for part in &message.parts {
match &part.body {
PartType::Text(report) => {
if part
.content_type()
.and_then(|ct| ct.subtype())
.map_or(false, |t| t.eq_ignore_ascii_case("xml"))
|| part
.attachment_name()
.and_then(|n| n.rsplit_once('.'))
.map_or(false, |(_, e)| e.eq_ignore_ascii_case("xml"))
{
reports.push(ReportData {
compression: Compression::None,
format: Format::Dmarc,
data: report.as_bytes(),
});
} else if part.is_content_type("message", "feedback-report") {
reports.push(ReportData {
compression: Compression::None,
format: Format::Arf,
data: report.as_bytes(),
});
}
}
PartType::Binary(report) | PartType::InlineBinary(report) => {
if part.is_content_type("message", "feedback-report") {
reports.push(ReportData {
compression: Compression::None,
format: Format::Arf,
data: report.as_ref(),
});
continue;
}
let subtype = part
.content_type()
.and_then(|ct| ct.subtype())
.unwrap_or("");
let attachment_name = part.attachment_name();
let ext = attachment_name
.and_then(|f| f.rsplit_once('.'))
.map_or("", |(_, e)| e);
let tls_parts = subtype.rsplit_once('+');
let compression = match (tls_parts.map(|(_, c)| c).unwrap_or(subtype), ext)
{
("gzip", _) => Compression::Gzip,
("zip", _) => Compression::Zip,
(_, "gz") => Compression::Gzip,
(_, "zip") => Compression::Zip,
_ => Compression::None,
};
let format = match (tls_parts.map(|(c, _)| c).unwrap_or(subtype), ext) {
("xml", _) => Format::Dmarc,
("tlsrpt", _) | (_, "json") => Format::Tls,
_ => {
if attachment_name
.map_or(false, |n| n.contains(".xml") || n.contains('!'))
{
Format::Dmarc
} else {
continue;
}
}
};
reports.push(ReportData {
compression,
format,
data: report.as_ref(),
});
}
_ => (),
}
}
for report in reports {
let data = match report.compression {
Compression::None => Cow::Borrowed(report.data),
Compression::Gzip => {
let mut file = GzDecoder::new(report.data);
let mut buf = Vec::new();
if let Err(err) = file.read_to_end(&mut buf) {
tracing::debug!(
context = "report",
from = from,
"Failed to decompress report: {}",
err
);
continue;
}
Cow::Owned(buf)
}
Compression::Zip => {
let mut archive = match zip::ZipArchive::new(Cursor::new(report.data)) {
Ok(archive) => archive,
Err(err) => {
tracing::debug!(
context = "report",
from = from,
"Failed to decompress report: {}",
err
);
continue;
}
};
let mut buf = Vec::with_capacity(0);
for i in 0..archive.len() {
match archive.by_index(i) {
Ok(mut file) => {
buf = Vec::with_capacity(file.compressed_size() as usize);
if let Err(err) = file.read_to_end(&mut buf) {
tracing::debug!(
context = "report",
from = from,
"Failed to decompress report: {}",
err
);
}
break;
}
Err(err) => {
tracing::debug!(
context = "report",
from = from,
"Failed to decompress report: {}",
err
);
}
}
}
Cow::Owned(buf)
}
};
match report.format {
Format::Dmarc => match Report::parse_xml(&data) {
Ok(report) => {
report.log();
}
Err(err) => {
tracing::debug!(
context = "report",
from = from,
"Failed to parse DMARC report: {}",
err
);
continue;
}
},
Format::Tls => match TlsReport::parse_json(&data) {
Ok(report) => {
report.log();
}
Err(err) => {
tracing::debug!(
context = "report",
from = from,
"Failed to parse TLS report: {:?}",
err
);
continue;
}
},
Format::Arf => match Feedback::parse_arf(&data) {
Some(report) => {
report.log();
}
None => {
tracing::debug!(
context = "report",
from = from,
"Failed to parse Auth Failure report"
);
continue;
}
},
}
// Save report
if let Some(report_path) = &core.report.config.analysis.store {
let (report_format, extension) = match report.format {
Format::Dmarc => ("dmarc", "xml"),
Format::Tls => ("tlsrpt", "json"),
Format::Arf => ("arf", "txt"),
};
let c_extension = match report.compression {
Compression::None => "",
Compression::Gzip => ".gz",
Compression::Zip => ".zip",
};
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let id = core
.report
.config
.analysis
.report_id
.fetch_add(1, Ordering::Relaxed);
// Build path
let mut report_path = report_path.clone();
report_path.push(format!(
"{report_format}_{now}_{id}.{extension}{c_extension}"
));
if let Err(err) = std::fs::write(&report_path, report.data) {
tracing::warn!(
context = "report",
event = "error",
from = from,
"Failed to write incoming report to {}: {}",
report_path.display(),
err
);
}
}
break;
}
});
}
}
trait LogReport {
fn log(&self);
}
impl LogReport for Report {
fn log(&self) {
let mut dmarc_pass = 0;
let mut dmarc_quarantine = 0;
let mut dmarc_reject = 0;
let mut dmarc_none = 0;
let mut dkim_pass = 0;
let mut dkim_fail = 0;
let mut dkim_none = 0;
let mut spf_pass = 0;
let mut spf_fail = 0;
let mut spf_none = 0;
for record in self.records() {
let count = std::cmp::min(record.count(), 1);
match record.action_disposition() {
ActionDisposition::Pass => {
dmarc_pass += count;
}
ActionDisposition::Quarantine => {
dmarc_quarantine += count;
}
ActionDisposition::Reject => {
dmarc_reject += count;
}
ActionDisposition::None | ActionDisposition::Unspecified => {
dmarc_none += count;
}
}
match record.dmarc_dkim_result() {
DmarcResult::Pass => {
dkim_pass += count;
}
DmarcResult::Fail => {
dkim_fail += count;
}
DmarcResult::Unspecified => {
dkim_none += count;
}
}
match record.dmarc_spf_result() {
DmarcResult::Pass => {
spf_pass += count;
}
DmarcResult::Fail => {
spf_fail += count;
}
DmarcResult::Unspecified => {
spf_none += count;
}
}
}
let range_from = DateTime::from_timestamp(self.date_range_begin() as i64).to_rfc3339();
let range_to = DateTime::from_timestamp(self.date_range_end() as i64).to_rfc3339();
if (dmarc_reject + dmarc_quarantine + dkim_fail + spf_fail) > 0 {
tracing::warn!(
context = "dmarc",
event = "analyze",
range_from = range_from,
range_to = range_to,
domain = self.domain(),
report_email = self.email(),
report_id = self.report_id(),
dmarc_pass = dmarc_pass,
dmarc_quarantine = dmarc_quarantine,
dmarc_reject = dmarc_reject,
dmarc_none = dmarc_none,
dkim_pass = dkim_pass,
dkim_fail = dkim_fail,
dkim_none = dkim_none,
spf_pass = spf_pass,
spf_fail = spf_fail,
spf_none = spf_none,
);
} else {
tracing::info!(
context = "dmarc",
event = "analyze",
range_from = range_from,
range_to = range_to,
domain = self.domain(),
report_email = self.email(),
report_id = self.report_id(),
dmarc_pass = dmarc_pass,
dmarc_quarantine = dmarc_quarantine,
dmarc_reject = dmarc_reject,
dmarc_none = dmarc_none,
dkim_pass = dkim_pass,
dkim_fail = dkim_fail,
dkim_none = dkim_none,
spf_pass = spf_pass,
spf_fail = spf_fail,
spf_none = spf_none,
);
}
}
}
impl LogReport for TlsReport {
fn log(&self) {
for policy in self.policies.iter().take(5) {
let mut details = AHashMap::with_capacity(policy.failure_details.len());
for failure in &policy.failure_details {
let num_failures = std::cmp::min(1, failure.failed_session_count);
match details.entry(failure.result_type) {
Entry::Occupied(mut e) => {
*e.get_mut() += num_failures;
}
Entry::Vacant(e) => {
e.insert(num_failures);
}
}
}
if policy.summary.total_failure > 0 {
tracing::warn!(
context = "tlsrpt",
event = "analyze",
range_from = self.date_range.start_datetime.to_rfc3339(),
range_to = self.date_range.end_datetime.to_rfc3339(),
domain = policy.policy.policy_domain,
report_contact = self.contact_info.as_deref().unwrap_or("unknown"),
report_id = self.report_id,
policy_type = ?policy.policy.policy_type,
total_success = policy.summary.total_success,
total_failures = policy.summary.total_failure,
details = ?details,
);
} else {
tracing::info!(
context = "tlsrpt",
event = "analyze",
range_from = self.date_range.start_datetime.to_rfc3339(),
range_to = self.date_range.end_datetime.to_rfc3339(),
domain = policy.policy.policy_domain,
report_contact = self.contact_info.as_deref().unwrap_or("unknown"),
report_id = self.report_id,
policy_type = ?policy.policy.policy_type,
total_success = policy.summary.total_success,
total_failures = policy.summary.total_failure,
details = ?details,
);
}
}
}
}
impl LogReport for Feedback<'_> {
fn log(&self) {
tracing::warn!(
context = "arf",
event = "analyze",
feedback_type = ?self.feedback_type(),
arrival_date = DateTime::from_timestamp(self.arrival_date().unwrap_or_else(|| {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_secs()) as i64
})).to_rfc3339(),
authentication_results = ?self.authentication_results(),
incidents = self.incidents(),
reported_domain = ?self.reported_domain(),
reported_uri = ?self.reported_uri(),
reporting_mta = self.reporting_mta().unwrap_or_default(),
source_ip = ?self.source_ip(),
user_agent = self.user_agent().unwrap_or_default(),
auth_failure = ?self.auth_failure(),
delivery_result = ?self.delivery_result(),
dkim_domain = self.dkim_domain().unwrap_or_default(),
dkim_identity = self.dkim_identity().unwrap_or_default(),
dkim_selector = self.dkim_selector().unwrap_or_default(),
identity_alignment = ?self.identity_alignment(),
);
}
}

View file

@ -0,0 +1,101 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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 mail_auth::{
common::verify::VerifySignature, AuthenticatedMessage, AuthenticationResults, DkimOutput,
};
use tokio::io::{AsyncRead, AsyncWrite};
use crate::{config::Rate, core::Session};
impl<T: AsyncWrite + AsyncRead + Unpin> Session<T> {
pub async fn send_dkim_report(
&self,
rcpt: &str,
message: &AuthenticatedMessage<'_>,
rate: &Rate,
rejected: bool,
output: &DkimOutput<'_>,
) {
// Generate report
let signature = if let Some(signature) = output.signature() {
signature
} else {
return;
};
// Throttle recipient
if !self.throttle_rcpt(rcpt, rate, "dkim") {
tracing::debug!(
parent: &self.span,
context = "report",
report = "dkim",
event = "throttle",
rcpt = rcpt,
);
return;
}
let config = &self.core.report.config.dkim;
let from_addr = config.address.eval(self).await;
let mut report = Vec::with_capacity(128);
self.new_auth_failure(output.result().into(), rejected)
.with_authentication_results(
AuthenticationResults::new(&self.instance.hostname)
.with_dkim_result(output, message.from())
.to_string(),
)
.with_dkim_domain(signature.domain())
.with_dkim_selector(signature.selector())
.with_dkim_identity(signature.identity())
.with_headers(message.raw_headers())
.write_rfc5322(
(config.name.eval(self).await.as_str(), from_addr.as_str()),
rcpt,
config.subject.eval(self).await,
&mut report,
)
.ok();
tracing::info!(
parent: &self.span,
context = "report",
report = "dkim",
event = "queue",
rcpt = rcpt,
"Queueing DKIM authentication failure report."
);
// Send report
self.core
.send_report(
from_addr,
[rcpt].into_iter(),
report,
&config.sign,
&self.span,
true,
)
.await;
}
}

View file

@ -0,0 +1,489 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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::{collections::hash_map::Entry, path::PathBuf, sync::Arc};
use ahash::AHashMap;
use mail_auth::{
common::verify::VerifySignature,
dmarc::{self, URI},
report::{AuthFailureType, IdentityAlignment, PolicyPublished, Record, Report, SPFDomainScope},
ArcOutput, AuthenticatedMessage, AuthenticationResults, DkimOutput, DkimResult, DmarcOutput,
SpfResult,
};
use serde::{Deserialize, Serialize};
use tokio::{
io::{AsyncRead, AsyncWrite},
runtime::Handle,
};
use crate::{
config::AggregateFrequency,
core::{Core, Session},
queue::{DomainPart, InstantFromTimestamp, Schedule},
};
use super::{
scheduler::{
json_append, json_read_blocking, json_write, ReportPath, ReportPolicy, ReportType,
Scheduler, ToHash,
},
DmarcEvent,
};
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DmarcFormat {
pub rua: Vec<URI>,
pub policy: PolicyPublished,
pub records: Vec<Record>,
}
impl<T: AsyncWrite + AsyncRead + Unpin> Session<T> {
#[allow(clippy::too_many_arguments)]
pub async fn send_dmarc_report(
&self,
message: &AuthenticatedMessage<'_>,
auth_results: &AuthenticationResults<'_>,
rejected: bool,
dmarc_output: DmarcOutput,
dkim_output: &[DkimOutput<'_>],
arc_output: &Option<ArcOutput<'_>>,
) {
let dmarc_record = dmarc_output.dmarc_record_cloned().unwrap();
let config = &self.core.report.config.dmarc;
// Send failure report
if let (Some(failure_rate), Some(report_options)) =
(config.send.eval(self).await, dmarc_output.failure_report())
{
// Verify that any external reporting addresses are authorized
let rcpts = match self
.core
.resolvers
.dns
.verify_dmarc_report_address(dmarc_output.domain(), dmarc_record.ruf())
.await
{
Some(rcpts) => {
if !rcpts.is_empty() {
rcpts
.into_iter()
.filter_map(|rcpt| {
if self.throttle_rcpt(rcpt.uri(), failure_rate, "dmarc") {
rcpt.uri().into()
} else {
None
}
})
.collect()
} else {
if !dmarc_record.ruf().is_empty() {
tracing::debug!(
parent: &self.span,
context = "report",
report = "dkim",
event = "unauthorized-ruf",
ruf = ?dmarc_record.ruf(),
"Unauthorized external reporting addresses"
);
}
vec![]
}
}
None => {
tracing::debug!(
parent: &self.span,
context = "report",
report = "dmarc",
event = "dns-failure",
ruf = ?dmarc_record.ruf(),
"Failed to validate external report addresses",
);
vec![]
}
};
// Throttle recipient
if !rcpts.is_empty() {
let mut report = Vec::with_capacity(128);
let from_addr = config.address.eval(self).await;
let mut auth_failure = self
.new_auth_failure(AuthFailureType::Dmarc, rejected)
.with_authentication_results(auth_results.to_string())
.with_headers(message.raw_headers());
// Report the first failed signature
let dkim_failed = if let (
dmarc::Report::Dkim
| dmarc::Report::DkimSpf
| dmarc::Report::All
| dmarc::Report::Any,
Some(signature),
) = (
&report_options,
dkim_output.iter().find_map(|o| {
let s = o.signature()?;
if !matches!(o.result(), DkimResult::Pass) {
Some(s)
} else {
None
}
}),
) {
auth_failure = auth_failure
.with_dkim_domain(signature.domain())
.with_dkim_selector(signature.selector())
.with_dkim_identity(signature.identity());
true
} else {
false
};
// Report SPF failure
let spf_failed = if let (
dmarc::Report::Spf
| dmarc::Report::DkimSpf
| dmarc::Report::All
| dmarc::Report::Any,
Some(output),
) = (
&report_options,
self.data
.spf_ehlo
.as_ref()
.and_then(|s| {
if s.result() != SpfResult::Pass {
s.into()
} else {
None
}
})
.or_else(|| {
self.data.spf_mail_from.as_ref().and_then(|s| {
if s.result() != SpfResult::Pass {
s.into()
} else {
None
}
})
}),
) {
auth_failure =
auth_failure.with_spf_dns(format!("txt : {} : v=SPF1", output.domain()));
// TODO use DNS record
true
} else {
false
};
auth_failure
.with_identity_alignment(if dkim_failed && spf_failed {
IdentityAlignment::DkimSpf
} else if dkim_failed {
IdentityAlignment::Dkim
} else {
IdentityAlignment::Spf
})
.write_rfc5322(
(config.name.eval(self).await.as_str(), from_addr.as_str()),
&rcpts.join(", "),
config.subject.eval(self).await,
&mut report,
)
.ok();
tracing::info!(
parent: &self.span,
context = "report",
report = "dmarc",
event = "queue",
rcpt = ?rcpts,
"Queueing DMARC authentication failure report."
);
// Send report
self.core
.send_report(
from_addr,
rcpts.into_iter(),
report,
&config.sign,
&self.span,
true,
)
.await;
} else {
tracing::debug!(
parent: &self.span,
context = "report",
report = "dmarc",
event = "throttle",
ruf = ?dmarc_record.ruf(),
);
}
}
// Send agregate reports
let interval = self
.core
.report
.config
.dmarc_aggregate
.send
.eval(self)
.await;
if matches!(interval, AggregateFrequency::Never) || dmarc_record.rua().is_empty() {
return;
}
// Create DMARC report record
let mut report_record = Record::new()
.with_dmarc_output(&dmarc_output)
.with_dkim_output(dkim_output)
.with_source_ip(self.data.remote_ip)
.with_header_from(message.from().domain_part())
.with_envelope_from(
self.data
.mail_from
.as_ref()
.map(|mf| mf.domain.as_str())
.unwrap_or_else(|| self.data.helo_domain.as_str()),
);
if let Some(spf_ehlo) = &self.data.spf_ehlo {
report_record = report_record.with_spf_output(spf_ehlo, SPFDomainScope::Helo);
}
if let Some(spf_mail_from) = &self.data.spf_mail_from {
report_record = report_record.with_spf_output(spf_mail_from, SPFDomainScope::MailFrom);
}
if let Some(arc_output) = arc_output {
report_record = report_record.with_arc_output(arc_output);
}
// Submit DMARC report event
self.core
.schedule_report(DmarcEvent {
domain: dmarc_output.into_domain(),
report_record,
dmarc_record,
interval: *interval,
})
.await;
}
}
pub trait GenerateDmarcReport {
fn generate_dmarc_report(&self, domain: ReportPolicy<String>, path: ReportPath<PathBuf>);
}
impl GenerateDmarcReport for Arc<Core> {
fn generate_dmarc_report(&self, domain: ReportPolicy<String>, path: ReportPath<PathBuf>) {
let core = self.clone();
let handle = Handle::current();
self.worker_pool.spawn(move || {
let deliver_at = path.created + path.deliver_at.as_secs();
let span = tracing::info_span!(
"dmarc-report",
domain = domain.inner,
range_from = path.created,
range_to = deliver_at,
size = path.size,
);
// Deserialize report
let dmarc = if let Some(dmarc) = json_read_blocking::<DmarcFormat>(&path.path, &span) {
dmarc
} else {
return;
};
// Verify external reporting addresses
let rua = match handle.block_on(
core.resolvers
.dns
.verify_dmarc_report_address(&domain.inner, &dmarc.rua),
) {
Some(rcpts) => {
if !rcpts.is_empty() {
rcpts
.into_iter()
.map(|u| u.uri().to_string())
.collect::<Vec<_>>()
} else {
tracing::info!(
parent: &span,
event = "failed",
reason = "unauthorized-rua",
rua = ?dmarc.rua,
"Unauthorized external reporting addresses"
);
let _ = std::fs::remove_file(&path.path);
return;
}
}
None => {
tracing::info!(
parent: &span,
event = "failed",
reason = "dns-failure",
rua = ?dmarc.rua,
"Failed to validate external report addresses",
);
let _ = std::fs::remove_file(&path.path);
return;
}
};
let config = &core.report.config.dmarc_aggregate;
// Group duplicates
let mut record_map = AHashMap::with_capacity(dmarc.records.len());
for record in dmarc.records {
match record_map.entry(record) {
Entry::Occupied(mut e) => {
*e.get_mut() += 1;
}
Entry::Vacant(e) => {
e.insert(1u32);
}
}
}
// Create report
let mut report = Report::new()
.with_policy_published(dmarc.policy)
.with_date_range_begin(path.created)
.with_date_range_end(deliver_at)
.with_report_id(format!("{}_{}", domain.policy, path.created))
.with_email(handle.block_on(config.address.eval(&domain.inner.as_str())));
if let Some(org_name) = handle.block_on(config.org_name.eval(&domain.inner.as_str())) {
report = report.with_org_name(org_name);
}
if let Some(contact_info) =
handle.block_on(config.contact_info.eval(&domain.inner.as_str()))
{
report = report.with_extra_contact_info(contact_info);
}
for (record, count) in record_map {
report.add_record(record.with_count(count));
}
let from_addr = handle.block_on(config.address.eval(&domain.inner.as_str()));
let mut message = Vec::with_capacity(path.size);
let _ = report.write_rfc5322(
handle.block_on(core.report.config.submitter.eval(&domain.inner.as_str())),
(
handle
.block_on(config.name.eval(&domain.inner.as_str()))
.as_str(),
from_addr.as_str(),
),
rua.iter().map(|a| a.as_str()),
&mut message,
);
// Send report
handle.block_on(core.send_report(
from_addr,
rua.iter(),
message,
&config.sign,
&span,
false,
));
if let Err(err) = std::fs::remove_file(&path.path) {
tracing::warn!(
context = "report",
event = "error",
"Failed to remove report file {}: {}",
path.path.display(),
err
);
}
});
}
}
impl Scheduler {
pub async fn schedule_dmarc(&mut self, event: Box<DmarcEvent>, core: &Core) {
let max_size = core
.report
.config
.dmarc_aggregate
.max_size
.eval(&event.domain.as_str())
.await;
let policy = event.dmarc_record.to_hash();
let (create, path) = match self.reports.entry(ReportType::Dmarc(ReportPolicy {
inner: event.domain,
policy,
})) {
Entry::Occupied(e) => (None, e.into_mut().dmarc_path()),
Entry::Vacant(e) => {
let domain = e.key().domain_name().to_string();
let created = event.interval.to_timestamp();
let deliver_at = created + event.interval.as_secs();
self.main.push(Schedule {
due: deliver_at.to_instant(),
inner: e.key().clone(),
});
let path = core
.build_report_path(ReportType::Dmarc(&domain), policy, created, event.interval)
.await;
let v = e.insert(ReportType::Dmarc(ReportPath {
path,
deliver_at: event.interval,
created,
size: 0,
}));
(domain.into(), v.dmarc_path())
}
};
if let Some(domain) = create {
// Serialize report
let entry = DmarcFormat {
rua: event.dmarc_record.rua().to_vec(),
policy: PolicyPublished::from_record(domain, &event.dmarc_record),
records: vec![event.report_record],
};
let bytes_written = json_write(&path.path, &entry).await;
if bytes_written > 0 {
path.size += bytes_written;
} else {
// Something went wrong, remove record
self.reports.remove(&ReportType::Dmarc(ReportPolicy {
inner: entry.policy.domain,
policy,
}));
}
} else if path.size < *max_size {
// Append to existing report
path.size += json_append(&path.path, &event.report_record, *max_size - path.size).await;
}
}
}

View file

@ -0,0 +1,383 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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::{sync::Arc, time::SystemTime};
use mail_auth::{
common::headers::HeaderWriter,
dmarc::Dmarc,
mta_sts::TlsRpt,
report::{
tlsrpt::FailureDetails, AuthFailureType, DeliveryResult, Feedback, FeedbackType, Record,
},
};
use mail_parser::DateTime;
use tokio::io::{AsyncRead, AsyncWrite};
use crate::{
config::{AddressMatch, AggregateFrequency, DkimSigner, IfBlock},
core::{management, Core, Session},
outbound::{dane::Tlsa, mta_sts::Policy},
queue::{DomainPart, Message},
USER_AGENT,
};
use self::scheduler::{ReportKey, ReportValue};
pub mod analysis;
pub mod dkim;
pub mod dmarc;
pub mod scheduler;
pub mod spf;
pub mod tls;
#[derive(Debug)]
pub enum Event {
Dmarc(Box<DmarcEvent>),
Tls(Box<TlsEvent>),
Manage(management::ReportRequest),
Stop,
}
#[derive(Debug)]
pub struct DmarcEvent {
pub domain: String,
pub report_record: Record,
pub dmarc_record: Arc<Dmarc>,
pub interval: AggregateFrequency,
}
#[derive(Debug)]
pub struct TlsEvent {
pub domain: String,
pub policy: PolicyType,
pub failure: Option<FailureDetails>,
pub tls_record: Arc<TlsRpt>,
pub interval: AggregateFrequency,
}
#[derive(Debug, Hash, PartialEq, Eq)]
pub enum PolicyType {
Tlsa(Option<Arc<Tlsa>>),
Sts(Option<Arc<Policy>>),
None,
}
impl<T: AsyncWrite + AsyncRead + Unpin> Session<T> {
pub fn new_auth_failure(&self, ft: AuthFailureType, rejected: bool) -> Feedback<'_> {
Feedback::new(FeedbackType::AuthFailure)
.with_auth_failure(ft)
.with_arrival_date(
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_secs()) as i64,
)
.with_source_ip(self.data.remote_ip)
.with_reporting_mta(&self.instance.hostname)
.with_user_agent(USER_AGENT)
.with_delivery_result(if rejected {
DeliveryResult::Reject
} else {
DeliveryResult::Unspecified
})
}
pub fn is_report(&self) -> bool {
for addr_match in &self.core.report.config.analysis.addresses {
for addr in &self.data.rcpt_to {
match addr_match {
AddressMatch::StartsWith(prefix) if addr.address_lcase.starts_with(prefix) => {
return true
}
AddressMatch::EndsWith(suffix) if addr.address_lcase.ends_with(suffix) => {
return true
}
AddressMatch::Equals(value) if addr.address_lcase.eq(value) => return true,
_ => (),
}
}
}
false
}
}
impl Core {
pub async fn send_report(
&self,
from_addr: &str,
rcpts: impl Iterator<Item = impl AsRef<str>>,
report: Vec<u8>,
sign_config: &IfBlock<Vec<Arc<DkimSigner>>>,
span: &tracing::Span,
deliver_now: bool,
) {
// Build message
let from_addr_lcase = from_addr.to_lowercase();
let from_addr_domain = from_addr_lcase.domain_part().to_string();
let mut message = Message::new_boxed(from_addr, from_addr_lcase, from_addr_domain);
for rcpt_ in rcpts {
message
.add_recipient(rcpt_.as_ref(), &self.queue.config)
.await;
}
// Sign message
let signature = message.sign(sign_config, &report, span).await;
// Schedule delivery at a random time between now and the next 3 hours
if !deliver_now {
#[cfg(not(feature = "test_mode"))]
{
use rand::Rng;
use std::time::Duration;
let delivery_time = Duration::from_secs(rand::thread_rng().gen_range(0..10800));
for domain in &mut message.domains {
domain.retry.due += delivery_time;
domain.expires += delivery_time;
domain.notify.due += delivery_time;
}
}
}
// Queue message
self.queue
.queue_message(message, signature.as_deref(), &report, span)
.await;
}
pub async fn schedule_report(&self, report: impl Into<Event>) {
if self.report.tx.send(report.into()).await.is_err() {
tracing::warn!(contex = "report", "Channel send failed.");
}
}
}
impl Message {
pub async fn sign(
&mut self,
config: &IfBlock<Vec<Arc<DkimSigner>>>,
bytes: &[u8],
span: &tracing::Span,
) -> Option<Vec<u8>> {
let signers = config.eval(self).await;
if !signers.is_empty() {
let mut headers = Vec::with_capacity(64);
for signer in signers.iter() {
match signer.sign(bytes) {
Ok(signature) => {
signature.write_header(&mut headers);
}
Err(err) => {
tracing::warn!(parent: span,
context = "dkim",
event = "sign-failed",
reason = %err);
}
}
}
if !headers.is_empty() {
return Some(headers);
}
}
None
}
}
impl AggregateFrequency {
pub fn to_timestamp(&self) -> u64 {
self.to_timestamp_(DateTime::from_timestamp(
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_secs()) as i64,
))
}
pub fn to_timestamp_(&self, mut dt: DateTime) -> u64 {
(match self {
AggregateFrequency::Hourly => {
dt.minute = 0;
dt.second = 0;
dt.to_timestamp()
}
AggregateFrequency::Daily => {
dt.hour = 0;
dt.minute = 0;
dt.second = 0;
dt.to_timestamp()
}
AggregateFrequency::Weekly => {
let dow = dt.day_of_week();
dt.hour = 0;
dt.minute = 0;
dt.second = 0;
dt.to_timestamp() - (86400 * dow as i64)
}
AggregateFrequency::Never => dt.to_timestamp(),
}) as u64
}
pub fn as_secs(&self) -> u64 {
match self {
AggregateFrequency::Hourly => 3600,
AggregateFrequency::Daily => 86400,
AggregateFrequency::Weekly => 7 * 86400,
AggregateFrequency::Never => 0,
}
}
}
impl From<DmarcEvent> for Event {
fn from(value: DmarcEvent) -> Self {
Event::Dmarc(Box::new(value))
}
}
impl From<TlsEvent> for Event {
fn from(value: TlsEvent) -> Self {
Event::Tls(Box::new(value))
}
}
impl From<Arc<Tlsa>> for PolicyType {
fn from(value: Arc<Tlsa>) -> Self {
PolicyType::Tlsa(Some(value))
}
}
impl From<Arc<Policy>> for PolicyType {
fn from(value: Arc<Policy>) -> Self {
PolicyType::Sts(Some(value))
}
}
impl From<&Arc<Tlsa>> for PolicyType {
fn from(value: &Arc<Tlsa>) -> Self {
PolicyType::Tlsa(Some(value.clone()))
}
}
impl From<&Arc<Policy>> for PolicyType {
fn from(value: &Arc<Policy>) -> Self {
PolicyType::Sts(Some(value.clone()))
}
}
impl From<(&Option<Arc<Policy>>, &Option<Arc<Tlsa>>)> for PolicyType {
fn from(value: (&Option<Arc<Policy>>, &Option<Arc<Tlsa>>)) -> Self {
match value {
(Some(value), _) => PolicyType::Sts(Some(value.clone())),
(_, Some(value)) => PolicyType::Tlsa(Some(value.clone())),
_ => PolicyType::None,
}
}
}
impl ReportKey {
pub fn domain(&self) -> &str {
match self {
scheduler::ReportType::Dmarc(p) => &p.inner,
scheduler::ReportType::Tls(d) => d,
}
}
}
impl ReportValue {
pub async fn delete(&self) {
match self {
scheduler::ReportType::Dmarc(path) => {
if let Err(err) = tokio::fs::remove_file(&path.path).await {
tracing::warn!(
context = "report",
event = "error",
"Failed to remove report file {}: {}",
path.path.display(),
err
);
}
}
scheduler::ReportType::Tls(path) => {
for path in &path.path {
if let Err(err) = tokio::fs::remove_file(&path.inner).await {
tracing::warn!(
context = "report",
event = "error",
"Failed to remove report file {}: {}",
path.inner.display(),
err
);
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use mail_parser::DateTime;
use crate::config::AggregateFrequency;
#[test]
fn aggregate_to_timestamp() {
for (freq, date, expected) in [
(
AggregateFrequency::Hourly,
"2023-01-24T09:10:40Z",
"2023-01-24T09:00:00Z",
),
(
AggregateFrequency::Daily,
"2023-01-24T09:10:40Z",
"2023-01-24T00:00:00Z",
),
(
AggregateFrequency::Weekly,
"2023-01-24T09:10:40Z",
"2023-01-22T00:00:00Z",
),
(
AggregateFrequency::Weekly,
"2023-01-28T23:59:59Z",
"2023-01-22T00:00:00Z",
),
(
AggregateFrequency::Weekly,
"2023-01-22T23:59:59Z",
"2023-01-22T00:00:00Z",
),
] {
assert_eq!(
DateTime::from_timestamp(
freq.to_timestamp_(DateTime::parse_rfc3339(date).unwrap()) as i64
)
.to_rfc3339(),
expected,
"failed for {freq:?} {date} {expected}"
);
}
}
}

View file

@ -0,0 +1,649 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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 ahash::{AHashMap, RandomState};
use mail_auth::{
common::{
base32::{Base32Reader, Base32Writer},
headers::Writer,
},
dmarc::Dmarc,
};
use serde::{de::DeserializeOwned, Serialize};
use std::{
collections::{hash_map::Entry, BinaryHeap},
hash::Hash,
path::PathBuf,
sync::Arc,
time::{Duration, Instant, SystemTime},
};
use tokio::{
fs::{self, OpenOptions},
io::AsyncWriteExt,
sync::mpsc,
};
use crate::{
config::AggregateFrequency,
core::{management::ReportRequest, worker::SpawnCleanup, Core, ReportCore},
queue::{InstantFromTimestamp, Schedule},
};
use super::{dmarc::GenerateDmarcReport, tls::GenerateTlsReport, Event};
pub type ReportKey = ReportType<ReportPolicy<String>, String>;
pub type ReportValue = ReportType<ReportPath<PathBuf>, ReportPath<Vec<ReportPolicy<PathBuf>>>>;
pub struct Scheduler {
short_wait: Duration,
long_wait: Duration,
pub main: BinaryHeap<Schedule<ReportKey>>,
pub reports: AHashMap<ReportKey, ReportValue>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
pub enum ReportType<T, U> {
Dmarc(T),
Tls(U),
}
#[derive(Debug, PartialEq, Eq)]
pub struct ReportPath<T> {
pub path: T,
pub size: usize,
pub created: u64,
pub deliver_at: AggregateFrequency,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ReportPolicy<T> {
pub inner: T,
pub policy: u64,
}
impl SpawnReport for mpsc::Receiver<Event> {
fn spawn(mut self, core: Arc<Core>, mut scheduler: Scheduler) {
tokio::spawn(async move {
let mut last_cleanup = Instant::now();
loop {
match tokio::time::timeout(scheduler.wake_up_time(), self.recv()).await {
Ok(Some(event)) => match event {
Event::Dmarc(event) => {
scheduler.schedule_dmarc(event, &core).await;
}
Event::Tls(event) => {
scheduler.schedule_tls(event, &core).await;
}
Event::Manage(request) => match request {
ReportRequest::List {
type_,
domain,
result_tx,
} => {
let mut result = Vec::new();
for key in scheduler.reports.keys() {
if domain
.as_ref()
.map_or(false, |domain| domain != key.domain())
{
continue;
}
if let Some(type_) = &type_ {
if !matches!(
(key, type_),
(ReportType::Dmarc(_), ReportType::Dmarc(_))
| (ReportType::Tls(_), ReportType::Tls(_))
) {
continue;
}
}
result.push(key.to_string());
}
let _ = result_tx.send(result);
}
ReportRequest::Status {
report_ids,
result_tx,
} => {
let mut result = Vec::with_capacity(report_ids.len());
for report_id in &report_ids {
result.push(
scheduler
.reports
.get(report_id)
.map(|report_value| (report_id, report_value).into()),
);
}
let _ = result_tx.send(result);
}
ReportRequest::Cancel {
report_ids,
result_tx,
} => {
let mut result = Vec::with_capacity(report_ids.len());
for report_id in &report_ids {
result.push(
if let Some(report) = scheduler.reports.remove(report_id) {
report.delete().await;
true
} else {
false
},
);
}
let _ = result_tx.send(result);
}
},
Event::Stop => break,
},
Ok(None) => break,
Err(_) => {
while let Some(report) = scheduler.next_due() {
match report {
(ReportType::Dmarc(domain), ReportType::Dmarc(path)) => {
core.generate_dmarc_report(domain, path);
}
(ReportType::Tls(domain), ReportType::Tls(path)) => {
core.generate_tls_report(domain, path);
}
_ => unreachable!(),
}
}
// Cleanup expired throttles
if last_cleanup.elapsed().as_secs() >= 86400 {
last_cleanup = Instant::now();
core.spawn_cleanup();
}
}
}
}
});
}
}
impl Core {
pub async fn build_report_path(
&self,
domain: ReportType<&str, &str>,
policy: u64,
created: u64,
interval: AggregateFrequency,
) -> PathBuf {
let (ext, domain) = match domain {
ReportType::Dmarc(domain) => ("d", domain),
ReportType::Tls(domain) => ("t", domain),
};
// Build base path
let mut path = self.report.config.path.eval(&domain).await.clone();
path.push((policy % *self.report.config.hash.eval(&domain).await).to_string());
let _ = fs::create_dir(&path).await;
// Build filename
let mut w = Base32Writer::with_capacity(domain.len() + 13);
w.write(&policy.to_le_bytes()[..]);
w.write(&(created.saturating_sub(946684800) as u32).to_le_bytes()[..]);
w.push_byte(
match interval {
AggregateFrequency::Hourly => 0,
AggregateFrequency::Daily => 1,
AggregateFrequency::Weekly => 2,
AggregateFrequency::Never => 3,
},
false,
);
w.write(domain.as_bytes());
let mut file = w.finalize();
file.push('.');
file.push_str(ext);
path.push(file);
path
}
}
impl ReportCore {
pub async fn read_reports(&self) -> Scheduler {
let mut scheduler = Scheduler::default();
for path in self
.config
.path
.if_then
.iter()
.map(|t| &t.then)
.chain([&self.config.path.default])
{
let mut dir = match tokio::fs::read_dir(path).await {
Ok(dir) => dir,
Err(_) => continue,
};
loop {
match dir.next_entry().await {
Ok(Some(file)) => {
let file = file.path();
if file.is_dir() {
match tokio::fs::read_dir(&file).await {
Ok(mut dir) => {
let file_ = file;
loop {
match dir.next_entry().await {
Ok(Some(file)) => {
let file = file.path();
if file
.extension()
.map_or(false, |e| e == "t" || e == "d")
{
if let Err(err) = scheduler.add_path(file).await
{
tracing::warn!("{}", err);
}
}
}
Ok(None) => break,
Err(err) => {
tracing::warn!(
"Failed to read report directory {}: {}",
file_.display(),
err
);
break;
}
}
}
}
Err(err) => {
tracing::warn!(
"Failed to read report directory {}: {}",
file.display(),
err
)
}
};
} else if file.extension().map_or(false, |e| e == "t" || e == "d") {
if let Err(err) = scheduler.add_path(file).await {
tracing::warn!("{}", err);
}
}
}
Ok(None) => {
break;
}
Err(err) => {
tracing::warn!(
"Failed to read report directory {}: {}",
path.display(),
err
);
break;
}
}
}
}
scheduler
}
}
impl Scheduler {
pub fn next_due(&mut self) -> Option<(ReportKey, ReportValue)> {
let item = self.main.peek()?;
if item.due <= Instant::now() {
let item = self.main.pop().unwrap();
self.reports
.remove(&item.inner)
.map(|policy| (item.inner, policy))
} else {
None
}
}
pub fn wake_up_time(&self) -> Duration {
self.main
.peek()
.map(|item| {
item.due
.checked_duration_since(Instant::now())
.unwrap_or(self.short_wait)
})
.unwrap_or(self.long_wait)
}
pub async fn add_path(&mut self, path: PathBuf) -> Result<(), String> {
let (file, ext) = path
.file_name()
.and_then(|f| f.to_str())
.and_then(|f| f.rsplit_once('.'))
.ok_or_else(|| format!("Invalid queue file name {}", path.display()))?;
let file_size = fs::metadata(&path)
.await
.map_err(|err| {
format!(
"Failed to obtain file metadata for {}: {}",
path.display(),
err
)
})?
.len();
if file_size == 0 {
let _ = fs::remove_file(&path).await;
return Err(format!(
"Removed zero length report file {}",
path.display()
));
}
// Decode domain name
let mut policy = [0u8; std::mem::size_of::<u64>()];
let mut created = [0u8; std::mem::size_of::<u32>()];
let mut deliver_at = AggregateFrequency::Never;
let mut domain = Vec::new();
for (pos, byte) in Base32Reader::new(file.as_bytes()).enumerate() {
match pos {
0..=7 => {
policy[pos] = byte;
}
8..=11 => {
created[pos - 8] = byte;
}
12 => {
deliver_at = match byte {
0 => AggregateFrequency::Hourly,
1 => AggregateFrequency::Daily,
2 => AggregateFrequency::Weekly,
_ => {
return Err(format!(
"Failed to base32 decode report file {}",
path.display()
));
}
};
}
_ => {
domain.push(byte);
}
}
}
if domain.is_empty() {
return Err(format!(
"Failed to base32 decode report file {}",
path.display()
));
}
let domain = String::from_utf8(domain).map_err(|err| {
format!(
"Failed to base32 decode report file {}: {}",
path.display(),
err
)
})?;
// Rebuild parts
let policy = u64::from_le_bytes(policy);
let created = u32::from_le_bytes(created) as u64 + 946684800;
match ext {
"d" => {
let key = ReportType::Dmarc(ReportPolicy {
inner: domain,
policy,
});
self.reports.insert(
key.clone(),
ReportType::Dmarc(ReportPath {
path,
size: file_size as usize,
created,
deliver_at,
}),
);
self.main.push(Schedule {
due: (created + deliver_at.as_secs()).to_instant(),
inner: key,
});
}
"t" => match self.reports.entry(ReportType::Tls(domain)) {
Entry::Occupied(mut e) => {
if let ReportType::Tls(tls) = e.get_mut() {
tls.size += file_size as usize;
tls.path.push(ReportPolicy {
inner: path,
policy,
});
}
}
Entry::Vacant(e) => {
self.main.push(Schedule {
due: (created + deliver_at.as_secs()).to_instant(),
inner: e.key().clone(),
});
e.insert(ReportType::Tls(ReportPath {
path: vec![ReportPolicy {
inner: path,
policy,
}],
size: file_size as usize,
created,
deliver_at,
}));
}
},
_ => unreachable!(),
}
Ok(())
}
}
pub async fn json_write(path: &PathBuf, entry: &impl Serialize) -> usize {
if let Ok(bytes) = serde_json::to_vec(entry) {
// Save serialized report
let bytes_written = bytes.len() - 2;
match fs::File::create(&path).await {
Ok(mut file) => match file.write_all(&bytes[..bytes_written]).await {
Ok(_) => bytes_written,
Err(err) => {
tracing::error!(
context = "report",
event = "error",
"Failed to write to report file {}: {}",
path.display(),
err
);
0
}
},
Err(err) => {
tracing::error!(
context = "report",
event = "error",
"Failed to create report file {}: {}",
path.display(),
err
);
0
}
}
} else {
0
}
}
pub async fn json_append(path: &PathBuf, entry: &impl Serialize, bytes_left: usize) -> usize {
let mut bytes = Vec::with_capacity(128);
bytes.push(b',');
if serde_json::to_writer(&mut bytes, entry).is_ok() && bytes.len() <= bytes_left {
let err = match OpenOptions::new().append(true).open(&path).await {
Ok(mut file) => match file.write_all(&bytes).await {
Ok(_) => return bytes.len(),
Err(err) => err,
},
Err(err) => err,
};
tracing::error!(
context = "report",
event = "error",
"Failed to append report to {}: {}",
path.display(),
err
);
}
0
}
pub async fn json_read<T: DeserializeOwned>(path: &PathBuf, span: &tracing::Span) -> Option<T> {
match fs::read_to_string(&path).await {
Ok(mut json) => {
json.push_str("]}");
match serde_json::from_str(&json) {
Ok(report) => Some(report),
Err(err) => {
tracing::error!(
parent: span,
context = "deserialize",
event = "error",
"Failed to deserialize report file {}: {}",
path.display(),
err
);
None
}
}
}
Err(err) => {
tracing::error!(
parent: span,
context = "io",
event = "error",
"Failed to read report file {}: {}",
path.display(),
err
);
None
}
}
}
pub fn json_read_blocking<T: DeserializeOwned>(path: &PathBuf, span: &tracing::Span) -> Option<T> {
match std::fs::read_to_string(path) {
Ok(mut json) => {
json.push_str("]}");
match serde_json::from_str(&json) {
Ok(report) => Some(report),
Err(err) => {
tracing::error!(
parent: span,
context = "deserialize",
event = "error",
"Failed to deserialize report file {}: {}",
path.display(),
err
);
None
}
}
}
Err(err) => {
tracing::error!(
parent: span,
context = "io",
event = "error",
"Failed to read report file {}: {}",
path.display(),
err
);
None
}
}
}
impl Default for Scheduler {
fn default() -> Self {
Self {
short_wait: Duration::from_millis(1),
long_wait: Duration::from_secs(86400 * 365),
main: BinaryHeap::with_capacity(128),
reports: AHashMap::with_capacity(128),
}
}
}
impl ReportKey {
pub fn domain_name(&self) -> &str {
match self {
ReportType::Dmarc(domain) => domain.inner.as_str(),
ReportType::Tls(domain) => domain.as_str(),
}
}
}
impl ReportValue {
pub fn dmarc_path(&mut self) -> &mut ReportPath<PathBuf> {
match self {
ReportType::Dmarc(path) => path,
ReportType::Tls(_) => unreachable!(),
}
}
pub fn tls_path(&mut self) -> &mut ReportPath<Vec<ReportPolicy<PathBuf>>> {
match self {
ReportType::Tls(path) => path,
ReportType::Dmarc(_) => unreachable!(),
}
}
}
pub trait ToHash {
fn to_hash(&self) -> u64;
}
impl ToHash for Dmarc {
fn to_hash(&self) -> u64 {
RandomState::with_seeds(1, 9, 7, 9).hash_one(self)
}
}
impl ToHash for super::PolicyType {
fn to_hash(&self) -> u64 {
RandomState::with_seeds(1, 9, 7, 9).hash_one(self)
}
}
pub trait ToTimestamp {
fn to_timestamp(&self) -> u64;
}
impl ToTimestamp for Duration {
fn to_timestamp(&self) -> u64 {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
+ self.as_secs()
}
}
pub trait SpawnReport {
fn spawn(self, core: Arc<Core>, scheduler: Scheduler);
}

View file

@ -0,0 +1,101 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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 mail_auth::{report::AuthFailureType, AuthenticationResults, SpfOutput};
use tokio::io::{AsyncRead, AsyncWrite};
use crate::{config::Rate, core::Session};
impl<T: AsyncWrite + AsyncRead + Unpin> Session<T> {
pub async fn send_spf_report(
&self,
rcpt: &str,
rate: &Rate,
rejected: bool,
output: &SpfOutput,
) {
// Throttle recipient
if !self.throttle_rcpt(rcpt, rate, "spf") {
tracing::debug!(
parent: &self.span,
context = "report",
report = "spf",
event = "throttle",
rcpt = rcpt,
);
return;
}
// Generate report
let config = &self.core.report.config.spf;
let from_addr = config.address.eval(self).await;
let mut report = Vec::with_capacity(128);
self.new_auth_failure(AuthFailureType::Spf, rejected)
.with_authentication_results(
if let Some(mail_from) = &self.data.mail_from {
AuthenticationResults::new(&self.instance.hostname).with_spf_mailfrom_result(
output,
self.data.remote_ip,
&mail_from.address,
&self.data.helo_domain,
)
} else {
AuthenticationResults::new(&self.instance.hostname).with_spf_ehlo_result(
output,
self.data.remote_ip,
&self.data.helo_domain,
)
}
.to_string(),
)
.with_spf_dns(format!("txt : {} : v=SPF1", output.domain())) // TODO use DNS record
.write_rfc5322(
(config.name.eval(self).await.as_str(), from_addr.as_str()),
rcpt,
config.subject.eval(self).await,
&mut report,
)
.ok();
tracing::info!(
parent: &self.span,
context = "report",
report = "spf",
event = "queue",
rcpt = rcpt,
"Queueing SPF authentication failure report."
);
// Send report
self.core
.send_report(
from_addr,
[rcpt].into_iter(),
report,
&config.sign,
&self.span,
true,
)
.await;
}
}

View file

@ -0,0 +1,450 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of the Stalwart SMTP 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::{collections::hash_map::Entry, path::PathBuf, sync::Arc, time::Duration};
use ahash::AHashMap;
use mail_auth::{
flate2::{write::GzEncoder, Compression},
mta_sts::{ReportUri, TlsRpt},
report::tlsrpt::{
DateRange, FailureDetails, Policy, PolicyDetails, PolicyType, Summary, TlsReport,
},
};
use mail_parser::DateTime;
use reqwest::header::CONTENT_TYPE;
use serde::{Deserialize, Serialize};
use std::fmt::Write;
use tokio::runtime::Handle;
use crate::{
config::AggregateFrequency,
core::Core,
outbound::mta_sts::{Mode, MxPattern},
queue::{InstantFromTimestamp, Schedule},
USER_AGENT,
};
use super::{
scheduler::{
json_append, json_read_blocking, json_write, ReportPath, ReportPolicy, ReportType,
Scheduler, ToHash,
},
TlsEvent,
};
#[derive(Debug, Clone)]
pub struct TlsRptOptions {
pub record: Arc<TlsRpt>,
pub interval: AggregateFrequency,
}
#[derive(Debug, Serialize, Deserialize)]
struct TlsFormat {
rua: Vec<ReportUri>,
policy: PolicyDetails,
records: Vec<Option<FailureDetails>>,
}
pub trait GenerateTlsReport {
fn generate_tls_report(&self, domain: String, paths: ReportPath<Vec<ReportPolicy<PathBuf>>>);
}
#[cfg(feature = "test_mode")]
pub static TLS_HTTP_REPORT: parking_lot::Mutex<Vec<u8>> = parking_lot::Mutex::new(Vec::new());
impl GenerateTlsReport for Arc<Core> {
fn generate_tls_report(&self, domain: String, path: ReportPath<Vec<ReportPolicy<PathBuf>>>) {
let core = self.clone();
let handle = Handle::current();
self.worker_pool.spawn(move || {
let deliver_at = path.created + path.deliver_at.as_secs();
let span = tracing::info_span!(
"tls-report",
domain = domain,
range_from = path.created,
range_to = deliver_at,
size = path.size,
);
// Deserialize report
let config = &core.report.config.tls;
let mut report = TlsReport {
organization_name: handle
.block_on(config.org_name.eval(&domain.as_str()))
.clone(),
date_range: DateRange {
start_datetime: DateTime::from_timestamp(path.created as i64),
end_datetime: DateTime::from_timestamp(deliver_at as i64),
},
contact_info: handle
.block_on(config.contact_info.eval(&domain.as_str()))
.clone(),
report_id: format!(
"{}_{}",
path.created,
path.path.first().map_or(0, |p| p.policy)
),
policies: Vec::with_capacity(path.path.len()),
};
let mut rua = Vec::new();
for path in &path.path {
if let Some(tls) = json_read_blocking::<TlsFormat>(&path.inner, &span) {
// Group duplicates
let mut total_success = 0;
let mut total_failure = 0;
let mut record_map = AHashMap::with_capacity(tls.records.len());
for record in tls.records {
if let Some(record) = record {
match record_map.entry(record) {
Entry::Occupied(mut e) => {
*e.get_mut() += 1;
}
Entry::Vacant(e) => {
e.insert(1u32);
}
}
total_failure += 1;
} else {
total_success += 1;
}
}
report.policies.push(Policy {
policy: tls.policy,
summary: Summary {
total_success,
total_failure,
},
failure_details: record_map
.into_iter()
.map(|(mut r, count)| {
r.failed_session_count = count;
r
})
.collect(),
});
rua = tls.rua;
}
}
if report.policies.is_empty() {
// This should not happen
tracing::warn!(
parent: &span,
event = "empty-report",
"No policies found in report"
);
path.cleanup_blocking();
return;
}
// Compress and serialize report
let json = report.to_json();
let mut e = GzEncoder::new(Vec::with_capacity(json.len()), Compression::default());
let json =
match std::io::Write::write_all(&mut e, json.as_bytes()).and_then(|_| e.finish()) {
Ok(report) => report,
Err(err) => {
tracing::error!(
parent: &span,
event = "error",
"Failed to compress report: {}",
err
);
return;
}
};
// Try delivering report over HTTP
let mut rcpts = Vec::with_capacity(rua.len());
for uri in &rua {
match uri {
ReportUri::Http(uri) => {
if let Ok(client) = reqwest::blocking::Client::builder()
.user_agent(USER_AGENT)
.timeout(Duration::from_secs(2 * 60))
.build()
{
#[cfg(feature = "test_mode")]
if uri == "https://127.0.0.1/tls" {
TLS_HTTP_REPORT.lock().extend_from_slice(&json);
path.cleanup_blocking();
return;
}
match client
.post(uri)
.header(CONTENT_TYPE, "application/tlsrpt+gzip")
.body(json.to_vec())
.send()
{
Ok(response) => {
if response.status().is_success() {
tracing::info!(
parent: &span,
context = "http",
event = "success",
url = uri,
);
path.cleanup_blocking();
return;
} else {
tracing::debug!(
parent: &span,
context = "http",
event = "invalid-response",
url = uri,
status = %response.status()
);
}
}
Err(err) => {
tracing::debug!(
parent: &span,
context = "http",
event = "error",
url = uri,
reason = %err
);
}
}
}
}
ReportUri::Mail(mailto) => {
rcpts.push(mailto.as_str());
}
}
}
// Deliver report over SMTP
if !rcpts.is_empty() {
let from_addr = handle.block_on(config.address.eval(&domain.as_str()));
let mut message = Vec::with_capacity(path.size);
let _ = report.write_rfc5322_from_bytes(
&domain,
handle.block_on(core.report.config.submitter.eval(&domain.as_str())),
(
handle.block_on(config.name.eval(&domain.as_str())).as_str(),
from_addr.as_str(),
),
rcpts.iter().copied(),
&json,
&mut message,
);
// Send report
handle.block_on(core.send_report(
from_addr,
rcpts.iter(),
message,
&config.sign,
&span,
false,
));
} else {
tracing::info!(
parent: &span,
event = "delivery-failed",
"No valid recipients found to deliver report to."
);
}
path.cleanup_blocking();
});
}
}
impl Scheduler {
pub async fn schedule_tls(&mut self, event: Box<TlsEvent>, core: &Core) {
let max_size = core
.report
.config
.tls
.max_size
.eval(&event.domain.as_str())
.await;
let policy_hash = event.policy.to_hash();
let (path, pos, create) = match self.reports.entry(ReportType::Tls(event.domain)) {
Entry::Occupied(e) => {
if let ReportType::Tls(path) = e.get() {
if let Some(pos) = path.path.iter().position(|p| p.policy == policy_hash) {
(e.into_mut().tls_path(), pos, None)
} else {
let pos = path.path.len();
let domain = e.key().domain_name().to_string();
let path = e.into_mut().tls_path();
path.path.push(ReportPolicy {
inner: core
.build_report_path(
ReportType::Tls(&domain),
policy_hash,
path.created,
path.deliver_at,
)
.await,
policy: policy_hash,
});
(path, pos, domain.into())
}
} else {
unreachable!()
}
}
Entry::Vacant(e) => {
let created = event.interval.to_timestamp();
let deliver_at = created + event.interval.as_secs();
self.main.push(Schedule {
due: deliver_at.to_instant(),
inner: e.key().clone(),
});
let domain = e.key().domain_name().to_string();
let path = core
.build_report_path(
ReportType::Tls(&domain),
policy_hash,
created,
event.interval,
)
.await;
let v = e.insert(ReportType::Tls(ReportPath {
path: vec![ReportPolicy {
inner: path,
policy: policy_hash,
}],
size: 0,
created,
deliver_at: event.interval,
}));
(v.tls_path(), 0, domain.into())
}
};
if let Some(domain) = create {
let mut policy = PolicyDetails {
policy_type: PolicyType::NoPolicyFound,
policy_string: vec![],
policy_domain: domain,
mx_host: vec![],
};
match event.policy {
super::PolicyType::Tlsa(tlsa) => {
policy.policy_type = PolicyType::Tlsa;
if let Some(tlsa) = tlsa {
for entry in &tlsa.entries {
policy.policy_string.push(format!(
"{} {} {} {}",
if entry.is_end_entity { 3 } else { 2 },
i32::from(entry.is_spki),
if entry.is_sha256 { 1 } else { 2 },
entry
.data
.iter()
.fold(String::with_capacity(64), |mut s, b| {
write!(s, "{b:02X}").ok();
s
})
));
}
}
}
super::PolicyType::Sts(sts) => {
policy.policy_type = PolicyType::Sts;
if let Some(sts) = sts {
policy.policy_string.push("version: STSv1".to_string());
policy.policy_string.push(format!(
"mode: {}",
match sts.mode {
Mode::Enforce => "enforce",
Mode::Testing => "testing",
Mode::None => "none",
}
));
policy
.policy_string
.push(format!("max_age: {}", sts.max_age));
for mx in &sts.mx {
let mx = match mx {
MxPattern::Equals(mx) => mx.to_string(),
MxPattern::StartsWith(mx) => format!("*.{mx}"),
};
policy.policy_string.push(format!("mx: {mx}"));
policy.mx_host.push(mx);
}
}
}
_ => (),
}
// Create report entry
let entry = TlsFormat {
rua: event.tls_record.rua.clone(),
policy,
records: vec![event.failure],
};
let bytes_written = json_write(&path.path[pos].inner, &entry).await;
if bytes_written > 0 {
path.size += bytes_written;
} else {
// Something went wrong, remove record
if let Entry::Occupied(mut e) = self
.reports
.entry(ReportType::Tls(entry.policy.policy_domain))
{
if let ReportType::Tls(path) = e.get_mut() {
path.path.retain(|p| p.policy != policy_hash);
if path.path.is_empty() {
e.remove_entry();
}
}
}
}
} else if path.size < *max_size {
// Append to existing report
path.size +=
json_append(&path.path[pos].inner, &event.failure, *max_size - path.size).await;
}
}
}
impl ReportPath<Vec<ReportPolicy<PathBuf>>> {
fn cleanup_blocking(&self) {
for path in &self.path {
if let Err(err) = std::fs::remove_file(&path.inner) {
tracing::error!(
context = "report",
report = "tls",
event = "error",
"Failed to delete file {}: {}",
path.inner.display(),
err
);
}
}
}
}

View file

@ -11,6 +11,8 @@ tokio = { version = "1.23", features = ["net", "macros"] }
tokio-rustls = { version = "0.24.0"}
serde = { version = "1.0", features = ["derive"]}
tracing = "0.1"
mail-auth = { git = "https://github.com/stalwartlabs/mail-auth" }
smtp-proto = { git = "https://github.com/stalwartlabs/smtp-proto" }
[target.'cfg(unix)'.dependencies]
privdrop = "0.5.3"

View file

@ -286,6 +286,12 @@ impl Config {
.failed(&format!("No 'url' directive found for listener {id:?}"))
.to_string()
},
max_connections: self
.property_or_default(
("server.listener", id, "max-connections"),
"server.max-connections",
)?
.unwrap_or(8192),
protocol,
listeners,
tls,
@ -356,149 +362,3 @@ impl ParseValue for SupportedCipherSuite {
})
}
}
#[cfg(test)]
mod tests {
use std::{fs, path::PathBuf};
use tokio::net::TcpSocket;
use crate::config::{Config, Listener, Server, ServerProtocol};
fn add_test_certs(config: &str) -> String {
let mut cert_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
cert_path.push("resources");
cert_path.push("tests");
cert_path.push("certs");
let mut cert = cert_path.clone();
cert.push("tls_cert.pem");
let mut pk = cert_path.clone();
pk.push("tls_privatekey.pem");
config
.replace("{CERT}", cert.as_path().to_str().unwrap())
.replace("{PK}", pk.as_path().to_str().unwrap())
}
#[test]
fn parse_servers() {
let mut file = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
file.push("resources");
file.push("tests");
file.push("config");
file.push("servers.toml");
let toml = add_test_certs(&fs::read_to_string(file).unwrap());
// Parse servers
let config = Config::parse(&toml).unwrap();
let servers = config.parse_servers().unwrap();
let expected_servers = vec![
Server {
id: "smtp".to_string(),
internal_id: 0,
hostname: "mx.example.org".to_string(),
data: "Stalwart SMTP - hi there!".to_string(),
protocol: ServerProtocol::Smtp,
listeners: vec![Listener {
socket: TcpSocket::new_v4().unwrap(),
addr: "127.0.0.1:9925".parse().unwrap(),
ttl: 3600.into(),
backlog: 1024.into(),
}],
tls: None,
tls_implicit: false,
},
Server {
id: "smtps".to_string(),
internal_id: 1,
hostname: "mx.example.org".to_string(),
data: "Stalwart SMTP - hi there!".to_string(),
protocol: ServerProtocol::Smtp,
listeners: vec![
Listener {
socket: TcpSocket::new_v4().unwrap(),
addr: "127.0.0.1:9465".parse().unwrap(),
ttl: 4096.into(),
backlog: 1024.into(),
},
Listener {
socket: TcpSocket::new_v4().unwrap(),
addr: "127.0.0.1:9466".parse().unwrap(),
ttl: 4096.into(),
backlog: 1024.into(),
},
],
tls: None,
tls_implicit: true,
},
Server {
id: "submission".to_string(),
internal_id: 2,
hostname: "submit.example.org".to_string(),
data: "Stalwart SMTP submission at your service".to_string(),
protocol: ServerProtocol::Smtp,
listeners: vec![Listener {
socket: TcpSocket::new_v4().unwrap(),
addr: "127.0.0.1:9991".parse().unwrap(),
ttl: 3600.into(),
backlog: 2048.into(),
}],
tls: None,
tls_implicit: true,
},
];
for (server, expected_server) in servers.inner.into_iter().zip(expected_servers) {
assert_eq!(
server.id, expected_server.id,
"failed for {}",
expected_server.id
);
assert_eq!(
server.internal_id, expected_server.internal_id,
"failed for {}",
expected_server.id
);
assert_eq!(
server.hostname, expected_server.hostname,
"failed for {}",
expected_server.id
);
assert_eq!(
server.data, expected_server.data,
"failed for {}",
expected_server.id
);
assert_eq!(
server.protocol, expected_server.protocol,
"failed for {}",
expected_server.id
);
assert_eq!(
server.tls_implicit, expected_server.tls_implicit,
"failed for {}",
expected_server.id
);
for (listener, expected_listener) in
server.listeners.into_iter().zip(expected_server.listeners)
{
assert_eq!(
listener.addr, expected_listener.addr,
"failed for {}",
expected_server.id
);
assert_eq!(
listener.ttl, expected_listener.ttl,
"failed for {}",
expected_server.id
);
assert_eq!(
listener.backlog, expected_listener.backlog,
"failed for {}",
expected_server.id
);
}
}
}
}

View file

@ -33,7 +33,7 @@ use tokio::net::TcpSocket;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Config {
keys: BTreeMap<String, String>,
pub keys: BTreeMap<String, String>,
}
#[derive(Debug, Default)]
@ -46,6 +46,7 @@ pub struct Server {
pub listeners: Vec<Listener>,
pub tls: Option<ServerConfig>,
pub tls_implicit: bool,
pub max_connections: u64,
}
pub struct Servers {

View file

@ -21,7 +21,18 @@
* for more details.
*/
use std::{net::IpAddr, time::Duration};
use std::{
net::{IpAddr, Ipv4Addr, Ipv6Addr},
path::PathBuf,
time::Duration,
};
use mail_auth::{
common::crypto::{Algorithm, HashAlgorithm},
dkim::Canonicalization,
IpLookupStrategy,
};
use smtp_proto::MtPriority;
use super::{Config, Rate};
@ -342,6 +353,111 @@ impl ParseValue for bool {
}
}
impl ParseValue for Ipv4Addr {
fn parse_value(key: impl AsKey, value: &str) -> super::Result<Self> {
value
.parse()
.map_err(|_| format!("Invalid IPv4 value {:?} for key {:?}.", value, key.as_key()))
}
}
impl ParseValue for Ipv6Addr {
fn parse_value(key: impl AsKey, value: &str) -> super::Result<Self> {
value
.parse()
.map_err(|_| format!("Invalid IPv6 value {:?} for key {:?}.", value, key.as_key()))
}
}
impl ParseValue for PathBuf {
fn parse_value(_key: impl AsKey, value: &str) -> super::Result<Self> {
let path = PathBuf::from(value);
if path.exists() {
Ok(path)
} else {
Err(format!("Directory {} does not exist.", path.display()))
}
}
}
impl ParseValue for MtPriority {
fn parse_value(key: impl AsKey, value: &str) -> super::Result<Self> {
match value.to_ascii_lowercase().as_str() {
"mixer" => Ok(MtPriority::Mixer),
"stanag4406" => Ok(MtPriority::Stanag4406),
"nsep" => Ok(MtPriority::Nsep),
_ => Err(format!(
"Invalid priority value {:?} for property {:?}.",
value,
key.as_key()
)),
}
}
}
impl ParseValue for Canonicalization {
fn parse_value(key: impl AsKey, value: &str) -> super::Result<Self> {
match value {
"relaxed" => Ok(Canonicalization::Relaxed),
"simple" => Ok(Canonicalization::Simple),
_ => Err(format!(
"Invalid canonicalization value {:?} for key {:?}.",
value,
key.as_key()
)),
}
}
}
impl ParseValue for IpLookupStrategy {
fn parse_value(key: impl AsKey, value: &str) -> super::Result<Self> {
Ok(match value.to_lowercase().as_str() {
"ipv4-only" => IpLookupStrategy::Ipv4Only,
"ipv6-only" => IpLookupStrategy::Ipv6Only,
//"ipv4-and-ipv6" => IpLookupStrategy::Ipv4AndIpv6,
"ipv6-then-ipv4" => IpLookupStrategy::Ipv6thenIpv4,
"ipv4-then-ipv6" => IpLookupStrategy::Ipv4thenIpv6,
_ => {
return Err(format!(
"Invalid IP lookup strategy {:?} for property {:?}.",
value,
key.as_key()
))
}
})
}
}
impl ParseValue for Algorithm {
fn parse_value(key: impl AsKey, value: &str) -> super::Result<Self> {
match value {
"ed25519-sha256" | "ed25519-sha-256" => Ok(Algorithm::Ed25519Sha256),
"rsa-sha-256" | "rsa-sha256" => Ok(Algorithm::RsaSha256),
"rsa-sha-1" | "rsa-sha1" => Ok(Algorithm::RsaSha1),
_ => Err(format!(
"Invalid algorithm {:?} for key {:?}.",
value,
key.as_key()
)),
}
}
}
impl ParseValue for HashAlgorithm {
fn parse_value(key: impl AsKey, value: &str) -> super::Result<Self> {
match value {
"sha256" | "sha-256" => Ok(HashAlgorithm::Sha256),
"sha-1" | "sha1" => Ok(HashAlgorithm::Sha1),
_ => Err(format!(
"Invalid hash algorithm {:?} for key {:?}.",
value,
key.as_key()
)),
}
}
}
impl ParseValue for Duration {
fn parse_value(key: impl AsKey, value: &str) -> super::Result<Self> {
let duration = value.trim_end().to_ascii_lowercase();

View file

@ -27,7 +27,7 @@ impl Server {
hostname: self.hostname,
tls_acceptor: self.tls.map(|config| TlsAcceptor::from(Arc::new(config))),
is_tls_implicit: self.tls_implicit,
limiter: ConcurrencyLimiter::new(manager.max_concurrent()),
limiter: ConcurrencyLimiter::new(self.max_connections),
shutdown_rx,
});

View file

@ -37,5 +37,4 @@ pub struct SessionData<T: AsyncRead + AsyncWrite + Unpin + 'static> {
pub trait SessionManager: Sync + Send + 'static + Clone {
fn spawn(&self, session: SessionData<TcpStream>);
fn max_concurrent(&self) -> u64;
}

View file

@ -0,0 +1,470 @@
[server]
hostname = "__HOST__"
#greeting = "Stalwart SMTP at your service"
protocol = "smtp"
[server.run-as]
user = "stalwart-smtp"
group = "stalwart-smtp"
[server.listener."smtp"]
bind = ["0.0.0.0:25"]
max-connections = 8192
[server.listener."submission"]
bind = ["0.0.0.0:587"]
max-connections = 8192
[server.listener."submissions"]
bind = ["0.0.0.0:465"]
max-connections = 8192
tls.implicit = true
[server.listener."management"]
bind = ["127.0.0.1:8686"]
protocol = "http"
[server.tls]
enable = true
implicit = false
timeout = "1m"
certificate = "default"
#sni = [{subject = "", certificate = ""}]
#protocols = ["TLSv1.2", TLSv1.3"]
#ciphers = []
ignore-client-order = true
[server.socket]
reuse-addr = true
#reuse-port = true
backlog = 1024
#ttl = 3600
#send-buffer-size = 65535
#recv-buffer-size = 65535
#linger = 1
#tos = 1
[global]
shared-map = {shard = 32, capacity = 10}
#thread-pool = 8
#[global.tracing]
#method = "stdout"
#level = "trace"
#[global.tracing]
#method = "open-telemetry"
#transport = "http"
#endpoint = "https://127.0.0.1/otel"
#headers = ["Authorization: <place_auth_here>"]
#level = "debug"
[global.tracing]
method = "log"
path = "/usr/local/stalwart-smtp/logs"
prefix = "smtp.log"
rotate = "daily"
level = "info"
[session]
timeout = "5m"
transfer-limit = 262144000 # 250 MB
duration = "10m"
[session.connect]
#script = "connect.sieve"
[session.ehlo]
require = true
reject-non-fqdn = [ { if = "listener", eq = "smtp", then = true},
{ else = false } ]
#script = "ehlo"
[session.extensions]
pipelining = true
chunking = true
requiretls = true
no-soliciting = ""
dsn = [ { if = "authenticated-as", ne = "", then = true},
{ else = false } ]
future-release = [ { if = "authenticated-as", ne = "", then = "7d"},
{ else = false } ]
deliver-by = [ { if = "authenticated-as", ne = "", then = "15d"},
{ else = false } ]
mt-priority = [ { if = "authenticated-as", ne = "", then = "mixer"},
{ else = false } ]
[session.auth]
mechanisms = [ { if = "listener", ne = "smtp", then = ["plain", "login"]},
{ else = [] } ]
lookup = [ { if = "listener", ne = "smtp", then = "remote/imap" },
{ else = false } ]
require = [ { if = "listener", ne = "smtp", then = true},
{ else = false } ]
[session.auth.errors]
total = 3
wait = "5s"
[session.mail]
#script = "mail-from"
[session.rcpt]
#script = "rcpt-to"
relay = [ { if = "authenticated-as", ne = "", then = true },
{ else = false } ]
max-recipients = 25
[session.rcpt.lookup]
domains = "list/domains"
addresses = "remote/lmtp"
vrfy = [ { if = "authenticated-as", ne = "", then = "remote/lmtp" },
{ else = false } ]
expn = [ { if = "authenticated-as", ne = "", then = "remote/lmtp" },
{ else = false } ]
[session.rcpt.errors]
total = 5
wait = "5s"
[session.data]
#script = "data"
#[session.data.pipe."spam-assassin"]
#command = "spamc"
#arguments = []
#timeout = "10s"
[session.data.limits]
messages = 10
size = 104857600
received-headers = 50
[session.data.add-headers]
received = [ { if = "listener", eq = "smtp", then = true },
{ else = false } ]
received-spf = [ { if = "listener", eq = "smtp", then = true },
{ else = false } ]
auth-results = [ { if = "listener", eq = "smtp", then = true },
{ else = false } ]
message-id = [ { if = "listener", eq = "smtp", then = false },
{ else = true } ]
date = [ { if = "listener", eq = "smtp", then = false },
{ else = true } ]
return-path = false
[[session.throttle]]
#match = {if = "remote-ip", eq = "10.0.0.1"}
key = ["remote-ip"]
concurrency = 5
#rate = "5/1h"
[[session.throttle]]
key = ["sender-domain", "rcpt"]
rate = "25/1h"
[auth.dnsbl]
verify = [ { if = "listener", eq = "smtp", then = ["ip", "iprev", "ehlo", "return-path", "from"] },
{ else = [] } ]
[auth.dnsbl.lookup]
ip = ["zen.spamhaus.org", "bl.spamcop.net", "b.barracudacentral.org"]
domain = ["dbl.spamhaus.org"]
[auth.iprev]
verify = [ { if = "listener", eq = "smtp", then = "relaxed" },
{ else = "disable" } ]
[auth.dkim]
verify = "relaxed"
sign = [ { if = "listener", ne = "smtp", then = ["rsa"] },
{ else = [] } ]
[auth.spf.verify]
ehlo = [ { if = "listener", eq = "smtp", then = "relaxed" },
{ else = "disable" } ]
mail-from = [ { if = "listener", eq = "smtp", then = "relaxed" },
{ else = "disable" } ]
[auth.arc]
verify = "relaxed"
seal = ["rsa"]
[auth.dmarc]
verify = [ { if = "listener", eq = "smtp", then = "relaxed" },
{ else = "disable" } ]
[queue]
path = "/usr/local/stalwart-smtp/queue"
hash = 64
[queue.schedule]
retry = ["2m", "5m", "10m", "15m", "30m", "1h", "2h"]
notify = ["1d", "3d"]
expire = "5d"
[queue.outbound]
#hostname = "__HOST__"
next-hop = [ { if = "rcpt-domain", in-list = "list/domains", then = "lmtp" },
{ else = false } ]
ip-strategy = "ipv4-then-ipv6"
[queue.outbound.tls]
dane = "optional"
mta-sts = "optional"
starttls = "require"
#[queue.outbound.source-ip]
#v4 = ["10.0.0.10", "10.0.0.11"]
#v6 = ["a::b", "a::c"]
[queue.outbound.limits]
mx = 7
multihomed = 2
[queue.outbound.timeouts]
connect = "3m"
greeting = "3m"
tls = "2m"
ehlo = "3m"
mail-from = "3m"
rcpt-to = "3m"
data = "10m"
mta-sts = "2m"
[[queue.quota]]
#match = {if = "sender-domain", eq = "foobar.org"}
#key = ["rcpt"]
messages = 100000
size = 10737418240 # 10gb
[[queue.throttle]]
key = ["rcpt-domain"]
#rate = "100/1h"
concurrency = 5
[resolver]
type = "system"
#preserve-intermediates = true
concurrency = 2
timeout = "5s"
attempts = 2
try-tcp-on-error = true
[resolver.cache]
txt = 2048
mx = 1024
ipv4 = 1024
ipv6 = 1024
ptr = 1024
tlsa = 1024
mta-sts = 1024
[report]
path = "/usr/local/stalwart-smtp/reports"
hash = 64
#submitter = "mx.domain.org"
[report.analysis]
addresses = ["dmarc@*", "abuse@*"]
forward = true
#store = "/usr/local/stalwart-smtp/incoming"
[report.dsn]
from-name = "Mail Delivery Subsystem"
from-address = "MAILER-DAEMON@__DOMAIN__"
sign = ["rsa"]
[report.dkim]
from-name = "Report Subsystem"
from-address = "noreply-dkim@__DOMAIN__"
subject = "DKIM Authentication Failure Report"
sign = ["rsa"]
send = "1/1d"
[report.spf]
from-name = "Report Subsystem"
from-address = "noreply-spf@__DOMAIN__"
subject = "SPF Authentication Failure Report"
send = "1/1d"
sign = ["rsa"]
[report.dmarc]
from-name = "Report Subsystem"
from-address = "noreply-dmarc@__DOMAIN__"
subject = "DMARC Authentication Failure Report"
send = "1/1d"
sign = ["rsa"]
[report.dmarc.aggregate]
from-name = "DMARC Report"
from-address = "noreply-dmarc@__DOMAIN__"
org-name = "__DOMAIN__"
#contact-info = ""
send = "daily"
max-size = 26214400 # 25mb
sign = ["rsa"]
[report.tls.aggregate]
from-name = "TLS Report"
from-address = "noreply-tls@__DOMAIN__"
org-name = "__DOMAIN__"
#contact-info = ""
send = "daily"
max-size = 26214400 # 25 mb
sign = ["rsa"]
[signature."rsa"]
#public-key = "file:///usr/local/stalwart-smtp/etc/certs/dkim.crt"
private-key = "file:///usr/local/stalwart-smtp/etc/private/dkim.key"
domain = "__DOMAIN__"
selector = "stalwart_smtp"
headers = ["From", "To", "Date", "Subject", "Message-ID"]
algorithm = "rsa-sha256"
canonicalization = "relaxed/relaxed"
#expire = "10d"
#third-party = ""
#third-party-algo = ""
#auid = ""
set-body-length = false
report = true
[remote."lmtp"]
address = "__LMTP_HOST__"
port = __LMTP_PORT__
protocol = "lmtp"
concurrency = 10
timeout = "1m"
lookup = true
[remote."lmtp".cache]
entries = 1000
ttl = {positive = "1d", negative = "1h"}
[remote."lmtp".tls]
implicit = false
allow-invalid-certs = true
#[remote."lmtp".auth]
#username = ""
#secret = ""
[remote."lmtp".limits]
errors = 3
requests = 50
[remote."imap"]
address = "localhost"
port = 143
protocol = "imap"
concurrency = 10
timeout = "1m"
lookup = true
[remote."imap".cache]
entries = 1000
ttl = {positive = "1d", negative = "1h"}
[remote."imap".tls]
implicit = false
allow-invalid-certs = true
[database."sql"]
#address = "sqlite:///usr/local/stalwart-smtp/etc/sqlite.db?mode=rwc"
address = "postgres://postgres:password@localhost/test"
max-connections = 10
min-connections = 0
idle-timeout = "5m"
[database."sql".lookup]
auth = "SELECT secret FROM users WHERE email=?"
rcpt = "SELECT EXISTS(SELECT 1 FROM users WHERE email=? LIMIT 1)"
vrfy = "SELECT email FROM users WHERE email LIKE '%' || ? || '%' LIMIT 5"
expn = "SELECT member FROM mailing_lists WHERE id = ?"
domains = "SELECT EXISTS(SELECT 1 FROM domains WHERE name=? LIMIT 1)"
[database."sql".cache]
enable = ["rcpt", "domains"]
entries = 1000
ttl = {positive = "1d", negative = "1h"}
[sieve]
from-name = "Automated Message"
from-addr = "no-reply@__DOMAIN__"
return-path = ""
#hostname = "__HOST__"
sign = ["rsa"]
use-database = "sql"
[sieve.limits]
redirects = 3
out-messages = 5
received-headers = 50
cpu = 10000
nested-includes = 5
duplicate-expiry = "7d"
[sieve.scripts]
# Note: These scripts are included here for demonstration purposes.
# They should not be used in their current form.
connect = '''
require ["variables", "extlists", "reject"];
if string :list "${env.remote_ip}" "list/blocked-ips" {
reject "Your IP '${env.remote_ip}' is not welcomed here.";
}
'''
ehlo = '''
require ["variables", "extlists", "reject"];
if string :list "${env.helo_domain}" "list/blocked-domains" {
reject "551 5.1.1 Your domain '${env.helo_domain}' has been blacklisted.";
}
'''
mail = '''
require ["variables", "envelope", "reject"];
if envelope :localpart :is "from" "known_spammer" {
reject "We do not accept SPAM.";
}
'''
rcpt = '''
require ["variables", "vnd.stalwart.execute", "envelope", "reject"];
set "triplet" "${env.remote_ip}.${envelope.from}.${envelope.to}";
if not execute :query "SELECT EXISTS(SELECT 1 FROM greylist WHERE addr=? LIMIT 1)" ["${triplet}"] {
execute :query "INSERT INTO greylist (addr) VALUES (?)" ["${triplet}"];
reject "422 4.2.2 Greylisted, please try again in a few moments.";
}
'''
data = '''
require ["envelope", "variables", "replace", "mime", "foreverypart", "editheader", "extracttext"];
if envelope :domain :is "to" "foobar.net" {
set "counter" "a";
foreverypart {
if header :mime :contenttype "content-type" "text/html" {
extracttext :upper "text_content";
replace "${text_content}";
}
set :length "part_num" "${counter}";
addheader :last "X-Part-Number" "${part_num}";
set "counter" "${counter}a";
}
}
'''
[management.auth]
lookup = "list/admin"
[list]
domains = ["__DOMAIN__"]
admin = ["admin:__ADMIN_PASS__"]
#blocked-ips = ["10.0.0.1"]
#blocked-domains = ["mail.spammer.com"]
#users = "file:///usr/local/stalwart-smtp/etc/users.txt"
[certificate."default"]
cert = "file:///usr/local/stalwart-smtp/etc/certs/tls.crt"
private-key = "file:///usr/local/stalwart-smtp/etc/private/tls.key"

Binary file not shown.

View file

@ -0,0 +1,21 @@
[Unit]
Description=Stalwart SMTP
Conflicts=postfix.service sendmail.service exim4.service
ConditionPathExists=/usr/local/stalwart-smtp/etc/config.toml
After=network-online.target
[Service]
Type=simple
LimitNOFILE=65536
KillMode=process
KillSignal=SIGINT
Restart=on-failure
RestartSec=5
ExecStart=/usr/local/stalwart-smtp/bin/stalwart-smtp --config=/usr/local/stalwart-smtp/etc/config.toml
PermissionsStartOnly=true
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=stalwart-smtp
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>stalwart.smtp</string>
<key>ServiceDescription</key>
<string>Stalwart SMTP Server</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/stalwart-smtp/bin/stalwart-smtp</string>
<string>--config=/usr/local/stalwart-smtp/etc/config.toml</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
</dict>
</plist>

View file

@ -8,12 +8,19 @@ resolver = "2"
store = { path = "../crates/store", features = ["test_mode"] }
jmap = { path = "../crates/jmap", features = ["test_mode"] }
jmap_proto = { path = "../crates/jmap-proto" }
smtp = { path = "../crates/smtp", features = ["test_mode"] }
smtp-proto = { git = "https://github.com/stalwartlabs/smtp-proto" }
mail-send = { git = "https://github.com/stalwartlabs/mail-send" }
mail-auth = { git = "https://github.com/stalwartlabs/mail-auth", features = ["test"] }
sieve-rs = { git = "https://github.com/stalwartlabs/sieve" }
utils = { path = "../crates/utils" }
#jmap-client = { git = "https://github.com/stalwartlabs/jmap-client", features = ["websockets", "debug", "async"] }
jmap-client = { path = "/home/vagrant/code/jmap-client", features = ["websockets", "debug", "async"] }
mail-parser = { git = "https://github.com/stalwartlabs/mail-parser", features = ["full_encoding", "serde_support", "ludicrous_mode"] }
tokio = { version = "1.23", features = ["full"] }
tokio-rustls = { version = "0.24.0"}
rustls = "0.21.0"
rustls-pemfile = "1.0"
csv = "1.1"
rayon = { version = "1.5.1" }
flate2 = { version = "1.0.17", features = ["zlib"], default-features = false }
@ -28,3 +35,9 @@ ece = "2.2"
hyper = { version = "1.0.0-rc.3", features = ["server", "http1", "http2"] }
http-body-util = "0.1.0-rc.2"
base64 = "0.21"
dashmap = "5.4"
ahash = { version = "0.8" }
serial_test = "2.0.0"
sqlx = { version = "0.7.0-alpha.3", features = [ "runtime-tokio-rustls", "postgres", "mysql", "sqlite" ] }
num_cpus = "1.15.0"
async-trait = "0.1.68"

View file

@ -0,0 +1,29 @@
-----BEGIN CERTIFICATE-----
MIIFCTCCAvGgAwIBAgIUCgHGQYUqtelbHGVSzCVwBL3fyEUwDQYJKoZIhvcNAQEL
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTIyMDUxNjExNDAzNFoXDTIzMDUx
NjExNDAzNFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIICIjANBgkqhkiG9w0BAQEF
AAOCAg8AMIICCgKCAgEAtwS0Fzl3SjaCuKEXgZ/fdWbDoj/qDphyNCAKNevQ0+D0
STNkWCO04aFSH0zcL8zoD9gokNos0i7OU9//ZhZQmex4V6EFdZn8bFwUWN/scUvW
HEFXVjtHldO2isZgIxH9LuwRv7KAgkISuWahqerOVDhe7SeQUV0AJGNEh3cT9PZr
gSY931BxB7n+5k8eoSk8Z1gtBzQzL62kVGpHDKfw8yX8m65owF9eLUBrNzgxmXfC
xpuHwj7hmVhS09PPKeN/RsFS8PsYO7bo0u8jEKalteumjRT7RyUEbioqfo6ZFOGj
FHPIq/uKXS9zN1fpoyNh3ur5hMznQhrqlwBM9KlM7GdBJ0pZ3ad0YjT8IL/GnGKR
85J2WZdLqaQdUZo7nV67FhqdDlNE4MdwiykTMjfmLRXGAVhAzJHKyRKNwmkI2aqe
S7aqeNgvuDBwY80Q9a2rb5py1Aw+L8yCkUBuHboToDpxSVRDNN8DrWNmmsXnxsOG
wRDODy4GICKyxlP+RFSM8xWSQ6y9ktS2OfDBm+Eqcw+3pZKhdz2wgxLkUBJ8X1eh
kJrCA/6LTuhy6m6mMjAfoSOFU7fu88jxaWPgvP7GKyH+LM/t9eucobz2ks5rtSjz
V4Dc5DCS94/OpVRHwHdaFSPbJKBN9Ev8gnNrAyx/aBPGoHBPG/QUiU7dcUNIPt0C
AwEAAaNTMFEwHQYDVR0OBBYEFI167IxBmErB11EqiPPqFLa31ZaMMB8GA1UdIwQY
MBaAFI167IxBmErB11EqiPPqFLa31ZaMMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI
hvcNAQELBQADggIBALU00IOiH5ubEauVCmakms5ermNTZfculnhnDfWTLMeh2+a7
G4cqADErfMhm/mmLbrw33t9s6tCAhQltvewKR40ST9uMPSyiQbYaCXd5DXnuI6Ox
JtNW+UOWIaMf8abnkdLvREOvb8dVQS1i3xq14tAjY5XgpGwCPP8m54b7N3Q7soLn
e5PDhPNTnhRIn2RLuYoZmQmMA5fcqEUDYff4epUww7PhrM1QckZligI3566NlGOf
j1G9JrivBtY0eaJtamIFnGMBT0ThDudxVja2Nv0C2Elry0p4T/o4nc4M67BJ/y1R
vjNLAgFhbxssemU3lZqSd+pykpJBwDBjFSPrZZmQcbk7H6Uz8V1xr/xuzfw6fA13
NWZ5vLgP/DQ13sM+XFlxThKfbPMPVe/UCTvfGtNW+3XyBgPntEkR+fNEawQmzbYl
R+X1ymT9MZnEZqRMf7/UD/SYek1aUJefoew3upjMgxYVvh4F8dqJ+39F+xoFzIA2
1dDAEMzXtjA3zKhZ2cycZbEzpJvYA3eGLuR16Suqfi4kPvfwK0mOhCxQmpayt7/X
vuEzW6dPCH8Hgbb0WvsSppGOvhdbDaZFNfFc5eNSxhyKzu3H3ACNImZRtZE+yixx
0fR8+xz9kDLf8xupV+X9heyFGHSyYU2Lveaevtr2Ij3weLRgJ6LbNALoeKXk
-----END CERTIFICATE-----

View file

@ -0,0 +1,52 @@
-----BEGIN PRIVATE KEY-----
MIIJQgIBADANBgkqhkiG9w0BAQEFAASCCSwwggkoAgEAAoICAQC3BLQXOXdKNoK4
oReBn991ZsOiP+oOmHI0IAo169DT4PRJM2RYI7ThoVIfTNwvzOgP2CiQ2izSLs5T
3/9mFlCZ7HhXoQV1mfxsXBRY3+xxS9YcQVdWO0eV07aKxmAjEf0u7BG/soCCQhK5
ZqGp6s5UOF7tJ5BRXQAkY0SHdxP09muBJj3fUHEHuf7mTx6hKTxnWC0HNDMvraRU
akcMp/DzJfybrmjAX14tQGs3ODGZd8LGm4fCPuGZWFLT088p439GwVLw+xg7tujS
7yMQpqW166aNFPtHJQRuKip+jpkU4aMUc8ir+4pdL3M3V+mjI2He6vmEzOdCGuqX
AEz0qUzsZ0EnSlndp3RiNPwgv8acYpHzknZZl0uppB1RmjudXrsWGp0OU0Tgx3CL
KRMyN+YtFcYBWEDMkcrJEo3CaQjZqp5Ltqp42C+4MHBjzRD1ratvmnLUDD4vzIKR
QG4duhOgOnFJVEM03wOtY2aaxefGw4bBEM4PLgYgIrLGU/5EVIzzFZJDrL2S1LY5
8MGb4SpzD7elkqF3PbCDEuRQEnxfV6GQmsID/otO6HLqbqYyMB+hI4VTt+7zyPFp
Y+C8/sYrIf4sz+3165yhvPaSzmu1KPNXgNzkMJL3j86lVEfAd1oVI9skoE30S/yC
c2sDLH9oE8agcE8b9BSJTt1xQ0g+3QIDAQABAoICABq5oxqpF5RMtXYEgAw7rkPU
h8jPkHwlIrgd3Z/WGZ53APUXfhWo0ScJiZZsgNKyF0kJBZNxaI4gq5xv3zmnFIoF
j+Ur7EIqBERGheoceMhqjI9/syMycNeeHM/S/ALjA5ewfT8C7+UVhOpx5DWNxidi
O+phlp9q9zRZEo69grqIqVYooWxUsMyyCljTQOPDw8BLjfe5VagmsRJqmolslLDM
4UBSjZVZ18S/3Wgo2oVQia660244BHWCAkZQbbXuNI2+eUAbSoSdxw3WQcaSrywL
hzyezbqr2yPDIIVuiUgVUt0Ps0P57VCCN07jlYhvCEGnClysFzD+ATefoZ0wg7za
dQu2E+d166rAjnssyhzcHMn3pxgSdtXD+dQR/xfIGbPABucCupEFqKmhLdMm9+ud
lHay87qzMpIa8cITJwEQROfXqWAhNUU98pKCOx1SVXBqQC7QVqGQ5solDf0eMSVh
ngQ6Dz2WUI2ty75LteiFwlyTgnU9nyPN0NXsrMEET2BHWre7ufTQqiULtQ7+9BwH
AMxEKvrQHjMUjdfbXuzdyc5w5mPYJZfFVSQ1HMslx66h9yCpRIsBZvUGvoaP8Tpe
nQ66FTYRbiOkkdJ7k8DtrnhsJI1oOGjnvj/rvZ8D2pvrlJcIH2AyN3MOL8Jp5Oj1
nCFt77TwpF92pgl0g9gBAoIBAQDcarmP54QboaIQ9S2gE/4gSVC5i44iDJuSRdI8
K081RQcWiNzqQXTRc5nqJ7KzLyPiGlg+6rWsBKLos5l4t+MdhhH+KUvk/OtT/g8V
0NZBNXLIbSb8j8ix4v3/f2qKHN3Co6QOlxb3gFvobKDdoKqUNiSH1zTZ8/Y/BzkM
jqWKhTdaLz6eyzhKfOTA4LO8kJ3VF8HUM1N9/e8Gjorl+gZpJUXUQS0+AIi8W76C
OwDrVb3BPGVnApQJfWF78h4g20RwXrx/GYUW2vOMcLjXXDV5U7+nobPUoJnLxoZC
16o88y0Ivan8dBNXsc1epyPvvEqp6MJbAyyVuNeuRJcgYA0BAoIBAQDUkGRV7fLG
wCr5rNysUO+FKzVtTJnf9KEsqAqUmmVnG4oubxAJJtiB5n2+DT+CtO8Nrtz05BbR
uxfWm+lbEw6lVMj63bywtp0NdULg7/2t+oq2Svv16KrZIRJttXMkdEiFFmkVAEhX
l8Fyl6PJPfSMwbPdXEUPUAaNrXweVFffXczHc4W2G212ZzDB0z7QQSgEntbTDFB/
2Cg5dvuojlM9zw0fuEyLwItZs7n16j/ONZLgBHyroMU9ZPxbnLrVyoZlqtob+RWm
Ju2fSIL9QqG6O4td1TqcUBGvFQYjGvKA+q5fsG26NBJ0Ac48cNK6PS4lMkN3Av2J
ccloYaMEHAXdAoIBAE8WMCy1Ok6byUXiYxOL+OPmyoM40q/e7DcovE2AkLQhZ3Cr
fPDEucCphPFiexkV8f8fysgQeU0WgMmUH54UBPbD81LJyISKR3nkr875Ftdg8SV/
HL0EblN9ifuR4U1bHCrJgoUFq2T09oVH7NR44Ju7bZIcIseNZK6qzcp2qGkycXD3
gLWDX1hCxeV6+qLPFQKvuomEPRH4+jnVDXuFIaW6jPqixDP6BxXmqU2bFDJcmnBq
VkwGvc1F4qORdUP+yOi05VeJdZqEx1x92aTUXg+BgEQKnjbNxUE7o1L6hQfHjUIU
o5iEoagWkQTEXf2YBwY+EPaNBgNWxnSuAbfJHwECggEBALOF95ezTVWauzD/U6ic
+o3n/kl/Zn4FJ5KFodn7xCSe18d7uXlhO34KYqx+l+MWWMefpbGWacdcUjfImf93
SulLgCqP12sP7/iLzp4XUpL7hOeM0NvRU2nqSpwpoUNqik0Mrlc0U+TWoGTduVCf
aMjwV65e3VyfY8mIeclLxqM5n1fcM1OoOnzDjiRE+0n7nYa5eAnq3pn6v4449TZY
belH03e0ucFWLtrltesBmj3YdWGJqJlzQOInRhNBfXJOh8+ZynfRmP0o54udPDQV
cG3PGFd5XPTjkuvhv7sqaSGRlm/um92lWOhtFfdp+i+cuDpmByCef+7zEP19aKZx
3GkCggEAFTs7KNMfvIEaLH0yQUFeq2gLmtcMofmOmeoIECycN1rG7iJo07lJLIs0
bVODH8Z0kX8llu3cjGMAH/6R2uugJSxkmFiZKrngTzKmxDPvTCKWR4RFwXH9j8IO
cPq7FtKN4SgrPy9ciAPdkcGmu3zz/sBKOaoPwvU2PdBRT+v/aoz+GCLXAvzFlKVe
9/7zdg87ilo8+AtV+71EJeR3kyBPKS9JrWYUKfiams12+uuH4/53rMFZfNCAaZ3Z
1sdXEO4o3Loc5TX4DbO9FVdBSBe6klEXx4T0QJboO6uBvTBnnRL2SQriJQQFwYT6
XzVV5pwOxkIDBWDIqMUfwJDChBKfpw==
-----END PRIVATE KEY-----

View file

@ -0,0 +1,40 @@
durations = [
{if = "sender", eq = "jdoe", then = "5d"},
{any-of = [{if = "priority", eq = -1}, {if = "rcpt", starts-with = "jane"}], then = "1h"},
{else = false}
]
string-list = [
{if = "sender", eq = "jdoe", then = ["From", "To", "Date"]},
{any-of = [{if = "priority", eq = -1}, {if = "rcpt", starts-with = "jane"}], then = "Other-ID"},
{else = []}
]
string-list-bis = [
{if = "sender", eq = "jdoe", then = ["From", "To", "Date"]},
{any-of = [{if = "priority", eq = -1}, {if = "rcpt", starts-with = "jane"}], then = []},
{else = ["ID-Bis"]}
]
single-value = "hello world"
bad-multi-value = [
{if = "sender", eq = "jdoe", then = 100},
{any-of = [{if = "priority", eq = -1}, {if = "rcpt", starts-with = "jane"}], then = [1, 2, 3]},
{else = 2}
]
bad-if-without-then = [
{if = "sender", eq = "jdoe"},
{else = 1}
]
bad-if-without-else = [
{if = "sender", eq = "jdoe", then = 1}
]
bad-multiple-else = [
{if = "sender", eq = "jdoe", then = 1},
{else = 1},
{else = 2}
]

View file

@ -0,0 +1,23 @@
[list]
local-domains = ["example.org", "example.net"]
spammer-domains = "thatdomain.net"
local-users = "file://{LIST1}"
power-users = ["file://{LIST1}", "file://{LIST2}"]
[remote."lmtp"]
address = 192.168.0.1
port = 25
protocol = "lmtp"
lookup = true
[remote."lmtp".auth]
username = "hello"
secret = "world"
[remote."lmtp".cache]
entries = 1000
ttl = {positive = 10, negative = 5}
[remote."lmtp".tls]
implicit = true
allow-invalid-certs = true

View file

@ -0,0 +1,168 @@
[envelope]
rcpt-domain = "example.org"
rcpt = "user@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"
[rule]
"eq-true" = {if = "rcpt-domain", eq = "example.org"}
"eq-false" = {if = "rcpt-domain", eq = "example.com"}
"listener-eq-true" = {if = "listener", eq = "smtp"}
"listener-eq-false" = {if = "listener", eq = "smtps"}
"ip-eq-true" = {if = "local-ip", eq = "192.168.9.0/24"}
"ip-eq-false" = {if = "remote-ip", eq = "A:B:C::D:F/128"}
"ne-true" = {if = "authenticated-as", ne = ""}
"ne-false" = {if = "authenticated-as", ne = "john@foobar.org"}
"starts-with-true" = {if = "mx", starts-with = "mx.some"}
"starts-with-false" = {if = "mx", starts-with = "enchilada"}
"ends-with-true" = {if = "sender", ends-with = "@foo.net"}
"ends-with-false" = {if = "sender", ends-with = "chimichanga"}
"in-list-true" = {if = "sender-domain", in-list = "list/domains"}
"in-list-false" = {if = "rcpt-domain", in-list = "list/domains"}
"not-in-list-true" = {if = "rcpt-domain", not-in-list = "list/domains"}
"not-in-list-false" = {if = "sender-domain", not-in-list = "list/domains"}
"regex-true" = {if = "sender", matches = "^(.+)@(.+)$"}
"regex-false" = {if = "mx", matches = "/^\\S+@\\S+\\.\\S+$/"}
"any-of-true" = { any-of = [
{if = "authenticated-as", ne = "john@foobar.org"},
{if = "rcpt-domain", eq = "example.org"},
{if = "mx", starts-with = "mx.some"},
]}
"any-of-false" = { any-of = [
{if = "authenticated-as", eq = "something else"},
{if = "rcpt-domain", eq = "something else"},
{if = "mx", starts-with = "something else"},
]}
"all-of-true" = { all-of = [
{if = "rcpt-domain", eq = "example.org"},
{if = "listener", eq = "smtp"},
{if = "mx", starts-with = "mx.some"}
]}
"all-of-false" = { all-of = [
{if = "rcpt-domain", eq = "example.org"},
{if = "listener", eq = "smtp"},
{if = "mx", starts-with = "something else"}
]}
"none-of-true" = { none-of = [
{if = "authenticated-as", eq = "something else"},
{if = "rcpt-domain", eq = "something else"},
{if = "mx", starts-with = "something else"},
]}
"none-of-false" = { none-of = [
{if = "rcpt-domain", eq = "example.org"},
{if = "listener", eq = "smtp"},
{if = "mx", starts-with = "mx.some"}
]}
nested-any-of-true = { any-of = [
{ all-of = [
{if = "rcpt-domain", eq = "example.org"},
{if = "listener", eq = "smtp"},
{if = "mx", starts-with = "something else"}
]},
{ none-of = [
{if = "rcpt-domain", eq = "example.org"},
{if = "listener", eq = "smtp"},
{if = "mx", starts-with = "mx.some"}
]},
{ any-of = [
{if = "authenticated-as", ne = "john@foobar.org"},
{if = "rcpt-domain", eq = "example.org"},
{if = "mx", starts-with = "mx.some"},
]}
]}
nested-any-of-false = { any-of = [
{ none-of = [
{if = "rcpt-domain", eq = "example.org"},
{if = "listener", eq = "smtp"},
{if = "mx", starts-with = "mx.some"}
]},
{ all-of = [
{if = "rcpt-domain", eq = "example.org"},
{if = "listener", eq = "smtp"},
{if = "mx", starts-with = "something else"}
]},
{ any-of = [
{if = "authenticated-as", eq = "something else"},
{if = "rcpt-domain", eq = "something else"},
{if = "mx", starts-with = "something else"},
]}
]}
nested-all-of-true = { all-of = [
{ any-of = [
{if = "authenticated-as", ne = "john@foobar.org"},
{if = "rcpt-domain", eq = "example.org"},
{if = "mx", starts-with = "mx.some"},
]},
{ all-of = [
{if = "rcpt-domain", eq = "example.org"},
{if = "listener", eq = "smtp"},
{if = "mx", starts-with = "mx.some"}
]},
{ none-of = [
{if = "authenticated-as", eq = "something else"},
{if = "rcpt-domain", eq = "something else"},
{if = "mx", starts-with = "something else"},
]}
]}
nested-all-of-false = { all-of = [
{ any-of = [
{if = "authenticated-as", ne = "john@foobar.org"},
{if = "rcpt-domain", eq = "example.org"},
{if = "mx", starts-with = "mx.some"},
]},
{ all-of = [
{if = "rcpt-domain", eq = "example.org"},
{if = "listener", eq = "smtp"},
{if = "mx", starts-with = "mx.some"}
]},
{ none-of = [
{if = "rcpt-domain", eq = "example.org"},
{if = "listener", eq = "smtp"},
{if = "mx", starts-with = "mx.some"}
]}
]}
nested-none-of-true = { none-of = [
{ none-of = [
{if = "rcpt-domain", eq = "example.org"},
{if = "listener", eq = "smtp"},
{if = "mx", starts-with = "mx.some"}
]},
{ all-of = [
{if = "rcpt-domain", eq = "example.org"},
{if = "listener", eq = "smtp"},
{if = "mx", starts-with = "something else"}
]},
{ any-of = [
{if = "authenticated-as", eq = "something else"},
{if = "rcpt-domain", eq = "something else"},
{if = "mx", starts-with = "something else"},
]}
]}
nested-none-of-false = { none-of = [
{ any-of = [
{if = "authenticated-as", ne = "john@foobar.org"},
{if = "rcpt-domain", eq = "example.org"},
{if = "mx", starts-with = "mx.some"},
]},
{ all-of = [
{if = "rcpt-domain", eq = "example.org"},
{if = "listener", eq = "smtp"},
{if = "mx", starts-with = "mx.some"}
]},
{ none-of = [
{if = "authenticated-as", eq = "something else"},
{if = "rcpt-domain", eq = "something else"},
{if = "mx", starts-with = "something else"},
]}
]}
[list]
domains = ["mydomain1.org", "foo.net", "otherdomain.net"]

View file

@ -0,0 +1,29 @@
[rule]
"my-nested-rule" = { any-of = [
{if = "rcpt-domain", eq = "example.org"},
{if = "remote-ip", eq = "192.168.0.0/24"},
{all-of = [
{if = "rcpt", starts-with = "no-reply@"},
{if = "sender", ends-with = "@domain.org"},
{none-of = [
{if = "priority", eq = 1},
{if = "priority", ne = -2},
]}
]}
]}
[rule."simple"]
if = "listener"
eq = "smtp"
[rule."is-authenticated"]
if = "authenticated-as"
ne = ""
[[rule."expanded".all-of]]
if = "sender-domain"
starts-with = "example"
[[rule."expanded".all-of]]
if = "sender"
in-list = "test-list"

View file

@ -0,0 +1,51 @@
[server]
hostname = "mx.example.org"
greeting = "Stalwart SMTP - hi there!"
protocol = "smtp"
[server.listener."smtp"]
bind = ["127.0.0.1:9925"]
tls.implicit = false
[server.listener."smtps"]
bind = ["127.0.0.1:9465", "127.0.0.1:9466"]
max-connections = 1024
tls.implicit = true
tls.ciphers = ["TLS13_CHACHA20_POLY1305_SHA256", "TLS13_AES_256_GCM_SHA384"]
socket.ttl = 4096
[server.listener."submission"]
greeting = "Stalwart SMTP submission at your service"
hostname = "submit.example.org"
bind = "127.0.0.1:9991"
#tls.sni = [{subject = "submit.example.org", certificate = "other"},
# {subject = "submission.example.org", certificate = "other"}]
socket.backlog = 2048
[server.tls]
enable = true
implicit = true
timeout = 300
certificate = "default"
#sni = [{subject = "other.domain.org", certificate = "default"}]
protocols = ["TLSv1.2", "TLSv1.3"]
ciphers = []
ignore_client_order = true
[server.socket]
reuse-addr = true
reuse-port = true
backlog = 1024
ttl = 3600
send-buffer-size = 65535
recv-buffer-size = 65535
linger = 1
tos = 1
[certificate."default"]
cert = "file://{CERT}"
private-key = "file://{PK}"
[certificate."other"]
cert = "file://{CERT}"
private-key = "file://{PK}"

View file

@ -0,0 +1,10 @@
[[throttle]]
match = {if = "remote-ip", eq = "127.0.0.1"}
key = ["remote-ip", "authenticated-as"]
concurrency = 100
rate = "50/30s"
[[throttle]]
key = "sender-domain"
concurrency = 10000

View file

@ -0,0 +1,61 @@
[database]
enabled = true # ignore
ports = [ 8000, 8001, 8002 ] # ignore
data = [ ["delta", "phi"], [3.14] ]
temp_targets = { cpu = 79.5, case = 72.0 }
[servers]
"127.0.0.1" = "value" # ignore
"character encoding" = "value"
[servers.alpha]
ip = "10.0.0.1"
role = "frontend"
[servers.beta]
ip = "10.0.0.2"
role = "backend"
[[products]]
name = "Hammer"
sku = 738594937
[[products]] # empty table within the array
[[products]] # ignore
name = "Nail"
sku = 284758393 # ignore
color = "gray"
[strings."my \"string\" test"]
str1 = "I'm a string."
str2 = "You can \"quote\" me."
str3 = "Name\tTabs\nNew Line."
lines = '''
The first newline is
trimmed in raw strings.
All other whitespace
is preserved.
'''
[arrays]
integers = [ 1, 2, 3 ]
colors = [ "red", "yellow", "green" ]
nested_arrays_of_ints = [ [ 1, 2 ], [3, 4, 5] ]
nested_mixed_array = [ [ 1, 2 ], ["a", "b", "c"] ]
string_array = [ "all", 'strings', """are the same""", '''type''' ]
# Mixed-type arrays are allowed
numbers = [ 0.1, 0.2, 0.5, 1, 2, 5 ]
integers2 = [
1, 2, 3 # this is ok
]
integers3 = [
4,
# comment in the middle
5, # this is ok
]
contributors = [
"Foo Bar <foo@example.com>" ,
{ name = "Baz Qux", email = "bazqux@example.com", url = "https://example.com/bazqux" }
]

View file

@ -0,0 +1,3 @@
_25._tcp.internet.nl 2 1 1 E1AE9C3DE848ECE1BA72E0D991AE4D0D9EC547C6BAD1DDDAB9D6BEB0A7E0E0D8
_25._tcp.internet.nl 3 1 1 D6FEA64D4E68CAEAB7CBB2E0F905D7F3CA3308B12FD88C5B469F08AD7E05C7C7
_25._tcp.mail.ietf.org 3 1 1 0C72AC70B745AC19998811B131D662C9AC69DBDBE7CB23E5B514B56664C5D3D6

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,47 @@
From: "Mail Delivery Subsystem" <MAILER-DAEMON@example.org>
To: sender@foobar.org
Auto-Submitted: auto-generated
Subject: Warning: Delay in message delivery
Content-Type: multipart/report; report-type="delivery-status";
boundary="mime_boundary"
--mime_boundary
Content-Type: text/plain
Content-Transfer-Encoding: 7bit
There was a temporary problem delivering your message to the following recipients:
<john.doe@example.org> (connection to 'mx.domain.org' failed: Connection timeout)
--mime_boundary
Content-Type: message/delivery-status
Content-Transfer-Encoding: 7bit
Reporting-MTA: dns;mx.example.org
Arrival-Date: <date goes here>
Original-Recipient: rfc822;jdoe@example.org
Final-Recipient: rfc822;john.doe@example.org
Action: delayed
Status: 4.0.0
Remote-MTA: dns;mx.domain.org
Will-Retry-Until: <date goes here>
--mime_boundary
Content-Type: message/rfc822
Content-Transfer-Encoding: 7bit
Disclose-recipients: prohibited
From: Message Router Submission Agent <AMMGR@corp.timeplex.com>
Subject: Status of: Re: Battery current sense
To: owner-ups-mib@CS.UTK.EDU
Message-id: <01HEGJ0WNBY28Y95LN@mr.timeplex.com>
MIME-version: 1.0
Content-Type: text/plain
--mime_boundary--

View file

@ -0,0 +1,46 @@
From: "Mail Delivery Subsystem" <MAILER-DAEMON@example.org>
To: sender@foobar.org
Auto-Submitted: auto-generated
Subject: Failed to deliver message
Content-Type: multipart/report; report-type="delivery-status";
boundary="mime_boundary"
--mime_boundary
Content-Type: text/plain
Content-Transfer-Encoding: 7bit
Your message could not be delivered to the following recipients:
<foobar@example.org> (host 'mx.example.org' rejected command 'RCPT TO:<foobar@example.org>' with code 550 (5.1.2) 'User does not exist')
--mime_boundary
Content-Type: message/delivery-status
Content-Transfer-Encoding: 7bit
Reporting-MTA: dns;mx.example.org
Arrival-Date: <date goes here>
Final-Recipient: rfc822;foobar@example.org
Action: failed
Status: 5.1.2
Diagnostic-Code: smtp;550 User does not exist
Remote-MTA: dns;mx.example.org
--mime_boundary
Content-Type: message/rfc822
Content-Transfer-Encoding: 7bit
Disclose-recipients: prohibited
From: Message Router Submission Agent <AMMGR@corp.timeplex.com>
Subject: Status of: Re: Battery current sense
To: owner-ups-mib@CS.UTK.EDU
Message-id: <01HEGJ0WNBY28Y95LN@mr.timeplex.com>
MIME-version: 1.0
Content-Type: text/plain
--mime_boundary--

View file

@ -0,0 +1,65 @@
From: "Mail Delivery Subsystem" <MAILER-DAEMON@example.org>
To: sender@foobar.org
Auto-Submitted: auto-generated
Subject: Partially delivered message
Content-Type: multipart/report; report-type="delivery-status";
boundary="mime_boundary"
--mime_boundary
Content-Type: text/plain
Content-Transfer-Encoding: 7bit
Your message has been partially delivered:
----- Delivery to the following addresses was succesful -----
<jane@example.org> (delivered to 'mx2.example.org' with code 250 (2.1.5) 'Message accepted for delivery')
----- There was a temporary problem delivering to these addresses -----
<john.doe@example.org> (connection to 'mx.domain.org' failed: Connection timeout)
----- Delivery to the following addresses failed -----
<foobar@example.org> (host 'mx.example.org' rejected command 'RCPT TO:<foobar@example.org>' with code 550 (5.1.2) 'User does not exist')
--mime_boundary
Content-Type: message/delivery-status
Content-Transfer-Encoding: 7bit
Reporting-MTA: dns;mx.example.org
Arrival-Date: <date goes here>
Final-Recipient: rfc822;foobar@example.org
Action: failed
Status: 5.1.2
Diagnostic-Code: smtp;550 User does not exist
Remote-MTA: dns;mx.example.org
Final-Recipient: rfc822;jane@example.org
Action: delivered
Status: 2.1.5
Remote-MTA: dns;mx2.example.org
Original-Recipient: rfc822;jdoe@example.org
Final-Recipient: rfc822;john.doe@example.org
Action: delayed
Status: 4.0.0
Remote-MTA: dns;mx.domain.org
Will-Retry-Until: <date goes here>
--mime_boundary
Content-Type: message/rfc822
Content-Transfer-Encoding: 7bit
Disclose-recipients: prohibited
From: Message Router Submission Agent <AMMGR@corp.timeplex.com>
Subject: Status of: Re: Battery current sense
To: owner-ups-mib@CS.UTK.EDU
Message-id: <01HEGJ0WNBY28Y95LN@mr.timeplex.com>
MIME-version: 1.0
Content-Type: text/plain
--mime_boundary--

Some files were not shown because too many files have changed in this diff Show more