mirror of
https://github.com/stalwartlabs/mail-server.git
synced 2024-09-20 07:16:18 +08:00
SMTP codebase import
This commit is contained in:
parent
4d44e2fa77
commit
77ced9e7fd
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -3,3 +3,4 @@
|
|||
.vscode
|
||||
*.failed
|
||||
*_failed
|
||||
stalwart.toml
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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>(
|
||||
|
|
|
@ -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
62
crates/smtp/Cargo.toml
Normal 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
|
404
crates/smtp/src/config/auth.rs
Normal file
404
crates/smtp/src/config/auth.rs
Normal 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)
|
||||
}
|
||||
}
|
491
crates/smtp/src/config/condition.rs
Normal file
491
crates/smtp/src/config/condition.rs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
170
crates/smtp/src/config/database.rs
Normal file
170
crates/smtp/src/config/database.rs
Normal 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(())
|
||||
}
|
||||
}
|
468
crates/smtp/src/config/if_block.rs
Normal file
468
crates/smtp/src/config/if_block.rs
Normal 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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
159
crates/smtp/src/config/list.rs
Normal file
159
crates/smtp/src/config/list.rs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
540
crates/smtp/src/config/mod.rs
Normal file
540
crates/smtp/src/config/mod.rs
Normal 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>;
|
457
crates/smtp/src/config/queue.rs
Normal file
457
crates/smtp/src/config/queue.rs
Normal 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()
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
91
crates/smtp/src/config/remote.rs
Normal file
91
crates/smtp/src/config/remote.rs
Normal 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),
|
||||
})
|
||||
}
|
||||
}
|
233
crates/smtp/src/config/report.rs
Normal file
233
crates/smtp/src/config/report.rs
Normal 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()
|
||||
))
|
||||
}
|
||||
}
|
103
crates/smtp/src/config/resolver.rs
Normal file
103
crates/smtp/src/config/resolver.rs
Normal 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),
|
||||
),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
152
crates/smtp/src/config/scripts.rs
Normal file
152
crates/smtp/src/config/scripts.rs
Normal 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
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
480
crates/smtp/src/config/session.rs
Normal file
480
crates/smtp/src/config/session.rs
Normal 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()
|
||||
))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
267
crates/smtp/src/config/throttle.rs
Normal file
267
crates/smtp/src/config/throttle.rs
Normal 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
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
296
crates/smtp/src/core/if_block.rs
Normal file
296
crates/smtp/src/core/if_block.rs
Normal 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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
962
crates/smtp/src/core/management.rs
Normal file
962
crates/smtp/src/core/management.rs
Normal 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
350
crates/smtp/src/core/mod.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
85
crates/smtp/src/core/params.rs
Normal file
85
crates/smtp/src/core/params.rs
Normal 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;
|
||||
}
|
||||
}
|
476
crates/smtp/src/core/scripts.rs
Normal file
476
crates/smtp/src/core/scripts.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
309
crates/smtp/src/core/throttle.rs
Normal file
309
crates/smtp/src/core/throttle.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
83
crates/smtp/src/core/worker.rs
Normal file
83
crates/smtp/src/core/worker.rs
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
237
crates/smtp/src/inbound/auth.rs
Normal file
237
crates/smtp/src/inbound/auth.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
}
|
680
crates/smtp/src/inbound/data.rs
Normal file
680
crates/smtp/src/inbound/data.rs
Normal 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");
|
||||
}
|
||||
}
|
392
crates/smtp/src/inbound/ehlo.rs
Normal file
392
crates/smtp/src/inbound/ehlo.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
350
crates/smtp/src/inbound/mail.rs
Normal file
350
crates/smtp/src/inbound/mail.rs
Normal 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)
|
||||
}
|
||||
}
|
123
crates/smtp/src/inbound/mod.rs
Normal file
123
crates/smtp/src/inbound/mod.rs
Normal 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()),
|
||||
}
|
||||
}
|
||||
}
|
185
crates/smtp/src/inbound/rcpt.rs
Normal file
185
crates/smtp/src/inbound/rcpt.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
}
|
456
crates/smtp/src/inbound/session.rs
Normal file
456
crates/smtp/src/inbound/session.rs
Normal 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
|
||||
}
|
||||
}
|
248
crates/smtp/src/inbound/spawn.rs
Normal file
248
crates/smtp/src/inbound/spawn.rs
Normal 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
|
||||
}
|
||||
}
|
130
crates/smtp/src/inbound/vrfy.rs
Normal file
130
crates/smtp/src/inbound/vrfy.rs
Normal 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
65
crates/smtp/src/lib.rs
Normal 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);
|
||||
}
|
85
crates/smtp/src/lookup/cache.rs
Normal file
85
crates/smtp/src/lookup/cache.rs
Normal 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();
|
||||
}
|
||||
}
|
93
crates/smtp/src/lookup/dispatch.rs
Normal file
93
crates/smtp/src/lookup/dispatch.rs
Normal 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
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
485
crates/smtp/src/lookup/imap.rs
Normal file
485
crates/smtp/src/lookup/imap.rs
Normal 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();
|
||||
}
|
||||
}
|
113
crates/smtp/src/lookup/mod.rs
Normal file
113
crates/smtp/src/lookup/mod.rs
Normal 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>);
|
||||
}
|
180
crates/smtp/src/lookup/smtp.rs
Normal file
180
crates/smtp/src/lookup/smtp.rs
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
259
crates/smtp/src/lookup/spawn.rs
Normal file
259
crates/smtp/src/lookup/spawn.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
237
crates/smtp/src/lookup/sql.rs
Normal file
237
crates/smtp/src/lookup/sql.rs
Normal 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
385
crates/smtp/src/old_main.rs
Normal 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")
|
||||
}
|
137
crates/smtp/src/outbound/dane/dnssec.rs
Normal file
137
crates/smtp/src/outbound/dane/dnssec.rs
Normal 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);
|
||||
}
|
||||
}
|
46
crates/smtp/src/outbound/dane/mod.rs
Normal file
46
crates/smtp/src/outbound/dane/mod.rs
Normal 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,
|
||||
}
|
307
crates/smtp/src/outbound/dane/verify.rs
Normal file
307
crates/smtp/src/outbound/dane/verify.rs
Normal 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()
|
||||
}
|
||||
}
|
1009
crates/smtp/src/outbound/delivery.rs
Normal file
1009
crates/smtp/src/outbound/delivery.rs
Normal file
File diff suppressed because it is too large
Load diff
155
crates/smtp/src/outbound/lookup.rs
Normal file
155
crates/smtp/src/outbound/lookup.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
304
crates/smtp/src/outbound/mod.rs
Normal file
304
crates/smtp/src/outbound/mod.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
177
crates/smtp/src/outbound/mta_sts/lookup.rs
Normal file
177
crates/smtp/src/outbound/mta_sts/lookup.rs
Normal 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)
|
||||
}
|
||||
}
|
54
crates/smtp/src/outbound/mta_sts/mod.rs
Normal file
54
crates/smtp/src/outbound/mta_sts/mod.rs
Normal 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),
|
||||
}
|
138
crates/smtp/src/outbound/mta_sts/parse.rs
Normal file
138
crates/smtp/src/outbound/mta_sts/parse.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
55
crates/smtp/src/outbound/mta_sts/verify.rs
Normal file
55
crates/smtp/src/outbound/mta_sts/verify.rs
Normal 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
|
||||
}
|
||||
}
|
637
crates/smtp/src/outbound/session.rs
Normal file
637
crates/smtp/src/outbound/session.rs
Normal 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, ¶ms).await {
|
||||
Ok(capabilities) => capabilities,
|
||||
Err(status) => {
|
||||
tracing::info!(
|
||||
parent: params.span,
|
||||
context = "ehlo",
|
||||
event = "rejected",
|
||||
mx = ¶ms.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 = ¶ms.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, ¶ms).await {
|
||||
Ok(capabilities) => capabilities,
|
||||
Err(status) => {
|
||||
tracing::info!(
|
||||
parent: params.span,
|
||||
context = "ehlo",
|
||||
event = "rejected",
|
||||
mx = ¶ms.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 = ¶ms.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 = ¶ms.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 = ¶ms.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, ¶ms).await {
|
||||
tracing::info!(
|
||||
parent: params.span,
|
||||
context = "message",
|
||||
event = "rejected",
|
||||
mx = ¶ms.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 = ¶ms.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 = ¶ms.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 = ¶ms.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 = ¶ms.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 = ¶ms.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 = ¶ms.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()
|
||||
}
|
||||
}
|
671
crates/smtp/src/queue/dsn.rs
Normal file
671
crates/smtp/src/queue/dsn.rs
Normal 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);
|
||||
}
|
561
crates/smtp/src/queue/manager.rs
Normal file
561
crates/smtp/src/queue/manager.rs
Normal 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);
|
||||
}
|
550
crates/smtp/src/queue/mod.rs
Normal file
550
crates/smtp/src/queue/mod.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
}
|
195
crates/smtp/src/queue/quota.rs
Normal file
195
crates/smtp/src/queue/quota.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
564
crates/smtp/src/queue/serialize.rs
Normal file
564
crates/smtp/src/queue/serialize.rs
Normal 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);
|
||||
}
|
||||
}
|
271
crates/smtp/src/queue/spool.rs
Normal file
271
crates/smtp/src/queue/spool.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
123
crates/smtp/src/queue/throttle.rs
Normal file
123
crates/smtp/src/queue/throttle.rs
Normal 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;
|
||||
}
|
||||
}
|
488
crates/smtp/src/reporting/analysis.rs
Normal file
488
crates/smtp/src/reporting/analysis.rs
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
101
crates/smtp/src/reporting/dkim.rs
Normal file
101
crates/smtp/src/reporting/dkim.rs
Normal 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;
|
||||
}
|
||||
}
|
489
crates/smtp/src/reporting/dmarc.rs
Normal file
489
crates/smtp/src/reporting/dmarc.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
383
crates/smtp/src/reporting/mod.rs
Normal file
383
crates/smtp/src/reporting/mod.rs
Normal 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
649
crates/smtp/src/reporting/scheduler.rs
Normal file
649
crates/smtp/src/reporting/scheduler.rs
Normal 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);
|
||||
}
|
101
crates/smtp/src/reporting/spf.rs
Normal file
101
crates/smtp/src/reporting/spf.rs
Normal 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;
|
||||
}
|
||||
}
|
450
crates/smtp/src/reporting/tls.rs
Normal file
450
crates/smtp/src/reporting/tls.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
470
resources/config/config.toml
Normal file
470
resources/config/config.toml
Normal 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"
|
BIN
resources/config/stalwart-config.zip
Normal file
BIN
resources/config/stalwart-config.zip
Normal file
Binary file not shown.
21
resources/systemd/stalwart-smtp.service
Normal file
21
resources/systemd/stalwart-smtp.service
Normal 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
|
20
resources/systemd/stalwart.smtp.plist
Normal file
20
resources/systemd/stalwart.smtp.plist
Normal 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>
|
|
@ -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" }
|
||||
mail-send = { git = "https://github.com/stalwartlabs/mail-send" }
|
||||
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"
|
||||
|
|
29
tests/resources/smtp/certs/tls_cert.pem
Normal file
29
tests/resources/smtp/certs/tls_cert.pem
Normal 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-----
|
52
tests/resources/smtp/certs/tls_privatekey.pem
Normal file
52
tests/resources/smtp/certs/tls_privatekey.pem
Normal 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-----
|
40
tests/resources/smtp/config/if-blocks.toml
Normal file
40
tests/resources/smtp/config/if-blocks.toml
Normal 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}
|
||||
]
|
23
tests/resources/smtp/config/lists.toml
Normal file
23
tests/resources/smtp/config/lists.toml
Normal 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
|
168
tests/resources/smtp/config/rules-eval.toml
Normal file
168
tests/resources/smtp/config/rules-eval.toml
Normal 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"]
|
29
tests/resources/smtp/config/rules.toml
Normal file
29
tests/resources/smtp/config/rules.toml
Normal 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"
|
51
tests/resources/smtp/config/servers.toml
Normal file
51
tests/resources/smtp/config/servers.toml
Normal 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}"
|
10
tests/resources/smtp/config/throttle.toml
Normal file
10
tests/resources/smtp/config/throttle.toml
Normal 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
|
||||
|
61
tests/resources/smtp/config/toml-parser.toml
Normal file
61
tests/resources/smtp/config/toml-parser.toml
Normal 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" }
|
||||
]
|
3
tests/resources/smtp/dane/dns.txt
Normal file
3
tests/resources/smtp/dane/dns.txt
Normal 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
|
BIN
tests/resources/smtp/dane/internet.nl.0.cert
Normal file
BIN
tests/resources/smtp/dane/internet.nl.0.cert
Normal file
Binary file not shown.
BIN
tests/resources/smtp/dane/internet.nl.1.cert
Normal file
BIN
tests/resources/smtp/dane/internet.nl.1.cert
Normal file
Binary file not shown.
BIN
tests/resources/smtp/dane/mail.ietf.org.0.cert
Normal file
BIN
tests/resources/smtp/dane/mail.ietf.org.0.cert
Normal file
Binary file not shown.
BIN
tests/resources/smtp/dane/mail.ietf.org.1.cert
Normal file
BIN
tests/resources/smtp/dane/mail.ietf.org.1.cert
Normal file
Binary file not shown.
BIN
tests/resources/smtp/dane/mail.ietf.org.2.cert
Normal file
BIN
tests/resources/smtp/dane/mail.ietf.org.2.cert
Normal file
Binary file not shown.
BIN
tests/resources/smtp/dane/mail.ietf.org.3.cert
Normal file
BIN
tests/resources/smtp/dane/mail.ietf.org.3.cert
Normal file
Binary file not shown.
47
tests/resources/smtp/dsn/delay.eml
Normal file
47
tests/resources/smtp/dsn/delay.eml
Normal 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--
|
||||
|
46
tests/resources/smtp/dsn/failure.eml
Normal file
46
tests/resources/smtp/dsn/failure.eml
Normal 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--
|
||||
|
65
tests/resources/smtp/dsn/mixed.eml
Normal file
65
tests/resources/smtp/dsn/mixed.eml
Normal 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
Loading…
Reference in a new issue