Use safe defaults when settings are missing

This commit is contained in:
mdecimus 2024-03-30 18:12:40 +01:00
parent cb4d2f15ae
commit 35562bb9fd
120 changed files with 11732 additions and 2069 deletions

View file

@ -4,8 +4,10 @@ use directory::Directory;
use utils::config::{utils::AsKey, Config};
use crate::{
config::smtp::{session::AddressMapping, V_RECIPIENT},
expr::{functions::ResolveVariable, if_block::IfBlock, tokenizer::TokenMap, Variable},
config::smtp::session::AddressMapping,
expr::{
functions::ResolveVariable, if_block::IfBlock, tokenizer::TokenMap, Variable, V_RECIPIENT,
},
Core,
};
@ -130,7 +132,7 @@ impl AddressMapping {
} else if let Some(if_block) = IfBlock::try_parse(
config,
key,
&TokenMap::default().with_variables([
&TokenMap::default().with_variables_map([
("address", V_RECIPIENT),
("email", V_RECIPIENT),
("rcpt", V_RECIPIENT),
@ -138,7 +140,7 @@ impl AddressMapping {
) {
AddressMapping::Custom(if_block)
} else {
AddressMapping::Disable
AddressMapping::Enable
}
}
}

View file

@ -39,8 +39,12 @@ impl ImapConfig {
timeout_idle: config
.property_or_default("imap.timeout.idle", "30m")
.unwrap_or_else(|| Duration::from_secs(1800)),
rate_requests: config.property_or_default("imap.rate-limit.requests", "2000/1m"),
rate_concurrent: config.property("imap.rate-limit.concurrent"),
rate_requests: config
.property_or_default::<Option<Rate>>("imap.rate-limit.requests", "2000/1m")
.unwrap_or_default(),
rate_concurrent: config
.property::<Option<u64>>("imap.rate-limit.concurrent")
.unwrap_or_default(),
allow_plain_auth: config
.property_or_default("imap.auth.allow-plain-text", "false")
.unwrap_or(false),

View file

@ -144,9 +144,15 @@ impl JmapConfig {
session_cache_ttl: config
.property("cache.session.ttl")
.unwrap_or(Duration::from_secs(3600)),
rate_authenticated: config.property_or_default("jmap.rate-limit.account", "1000/1m"),
rate_authenticate_req: config.property_or_default("authentication.rate-limit", "10/1m"),
rate_anonymous: config.property_or_default("jmap.rate-limit.anonymous", "100/1m"),
rate_authenticated: config
.property_or_default::<Option<Rate>>("jmap.rate-limit.account", "1000/1m")
.unwrap_or_default(),
rate_authenticate_req: config
.property_or_default::<Option<Rate>>("authentication.rate-limit", "10/1m")
.unwrap_or_default(),
rate_anonymous: config
.property_or_default::<Option<Rate>>("jmap.rate-limit.anonymous", "100/1m")
.unwrap_or_default(),
oauth_key: config
.value("oauth.key")
.map(|s| s.to_string())

View file

@ -5,7 +5,7 @@ use directory::{Directories, Directory};
use store::{BlobBackend, BlobStore, FtsStore, LookupStore, Store, Stores};
use utils::config::Config;
use crate::{listener::tls::TlsManager, Core, Network};
use crate::{expr::*, listener::tls::TlsManager, Core, Network};
use self::{
imap::ImapConfig, jmap::settings::JmapConfig, manager::ConfigManager, scripts::Scripting,
@ -22,6 +22,16 @@ pub mod smtp;
pub mod storage;
pub mod tracers;
pub(crate) const CONNECTION_VARS: &[u32; 7] = &[
V_LISTENER,
V_REMOTE_IP,
V_REMOTE_PORT,
V_LOCAL_IP,
V_LOCAL_PORT,
V_PROTOCOL,
V_TLS,
];
impl Core {
pub async fn parse(config: &mut Config, stores: Stores, config_manager: ConfigManager) -> Self {
let mut data = config
@ -78,7 +88,7 @@ impl Core {
}
})
.unwrap_or_default();
let directories = Directories::parse(config, &stores, data.clone()).await;
let mut directories = Directories::parse(config, &stores, data.clone()).await;
let directory = config
.value_require("storage.directory")
.map(|id| id.to_string())
@ -94,6 +104,9 @@ impl Core {
}
})
.unwrap_or_else(|| Arc::new(Directory::default()));
directories
.directories
.insert("*".to_string(), directory.clone());
// If any of the stores are missing, disable all stores to avoid data loss
if matches!(data, Store::None)

View file

@ -6,14 +6,17 @@ use crate::{
Network,
};
use super::smtp::*;
use super::CONNECTION_VARS;
impl Default for Network {
fn default() -> Self {
Self {
blocked_ips: Default::default(),
hostname: IfBlock::new("localhost".to_string()),
url: IfBlock::new("http://localhost:8080".to_string()),
url: IfBlock::new::<()>(
"server.http.url",
[],
"protocol + '://' + key_get('default', 'hostname') + ':' + local_port",
),
}
}
}
@ -24,17 +27,9 @@ impl Network {
blocked_ips: BlockedIps::parse(config),
..Default::default()
};
let token_map = &TokenMap::default().with_smtp_variables(&[
V_LISTENER,
V_REMOTE_IP,
V_LOCAL_IP,
V_HELO_DOMAIN,
]);
let token_map = &TokenMap::default().with_variables(CONNECTION_VARS);
for (value, key) in [
(&mut network.hostname, "server.hostname"),
(&mut network.url, "server.url"),
] {
for (value, key) in [(&mut network.url, "server.url")] {
if let Some(if_block) = IfBlock::try_parse(config, key, token_map) {
*value = if_block;
}

View file

@ -13,16 +13,16 @@ use utils::config::Config;
use crate::scripts::{functions::register_functions, plugins::RegisterSievePlugins};
use super::smtp::parse_server_hostname;
use super::{if_block::IfBlock, smtp::SMTP_RCPT_TO_VARS, tokenizer::TokenMap};
pub struct Scripting {
pub untrusted_compiler: Compiler,
pub untrusted_runtime: Runtime,
pub trusted_runtime: Runtime,
pub from_addr: String,
pub from_name: String,
pub return_path: String,
pub sign: Vec<String>,
pub from_addr: IfBlock,
pub from_name: IfBlock,
pub return_path: IfBlock,
pub sign: IfBlock,
pub scripts: AHashMap<String, Arc<Sieve>>,
pub bayes_cache: BayesTokenCache,
pub remote_lists: RwLock<AHashMap<String, RemoteList>>,
@ -182,7 +182,7 @@ impl Scripting {
.unwrap_or("Auto: ")
.to_string(),
)
.with_env_variable("name", "Stalwart JMAP")
.with_env_variable("name", "Stalwart Mail Server")
.with_env_variable("version", env!("CARGO_PKG_VERSION"))
.with_env_variable("location", "MS")
.with_env_variable("phase", "during");
@ -250,13 +250,11 @@ impl Scripting {
if let Some(value) = config.property::<Duration>("sieve.trusted.limits.duplicate-expiry") {
trusted_runtime.set_default_duplicate_expiry(value.as_secs());
}
let hostname = if let Some(hostname) = config.value("sieve.trusted.hostname") {
hostname.to_string()
} else {
parse_server_hostname(config)
.and_then(|h| h.into_default_string())
.unwrap_or_else(|| "localhost".to_string())
};
let hostname = config
.value("sieve.trusted.hostname")
.or_else(|| config.value("lookup.default.hostname"))
.unwrap_or("localhost")
.to_string();
trusted_runtime.set_local_hostname(hostname.clone());
// Parse scripts
@ -266,19 +264,12 @@ impl Scripting {
.map(|s| s.to_string())
.collect::<Vec<_>>()
{
// Skip sub-scripts
if config
.property(("sieve.trusted.scripts", id.as_str(), "snippet"))
.unwrap_or(false)
{
continue;
}
let script = config
.value(("sieve.trusted.scripts", id.as_str(), "contents"))
.unwrap();
match trusted_compiler.compile(script.as_bytes()) {
match trusted_compiler.compile(
config
.value(("sieve.trusted.scripts", id.as_str(), "contents"))
.unwrap()
.as_bytes(),
) {
Ok(compiled) => {
scripts.insert(id, compiled.into());
}
@ -289,26 +280,42 @@ impl Scripting {
}
}
let token_map = TokenMap::default().with_variables(SMTP_RCPT_TO_VARS);
Scripting {
untrusted_compiler,
untrusted_runtime,
trusted_runtime,
from_addr: config
.value("sieve.trusted.from-addr")
.map(|a| a.to_string())
.unwrap_or(format!("MAILER-DAEMON@{hostname}")),
from_name: config
.value("sieve.trusted.from-name")
.unwrap_or("Mailer Daemon")
.to_string(),
return_path: config
.value("sieve.trusted.return-path")
.unwrap_or_default()
.to_string(),
sign: config
.values("sieve.trusted.sign")
.map(|(_, v)| v.to_string())
.collect(),
from_addr: IfBlock::try_parse(config, "sieve.trusted.from-addr", &token_map)
.unwrap_or_else(|| {
IfBlock::new::<()>(
"sieve.trusted.from-addr",
[],
"'MAILER-DAEMON@' + key_get('default', 'domain')",
)
}),
from_name: IfBlock::try_parse(config, "sieve.trusted.from-name", &token_map)
.unwrap_or_else(|| {
IfBlock::new::<()>(
"sieve.trusted.from-name",
[],
"'Mailer Daemon'",
)
}),
return_path: IfBlock::try_parse(config, "sieve.trusted.return-path", &token_map)
.unwrap_or_else(|| {
IfBlock::empty(
"sieve.trusted.return-path",
)
}),
sign: IfBlock::try_parse(config, "sieve.trusted.sign", &token_map)
.unwrap_or_else(|| {
IfBlock::new::<()>(
"sieve.trusted.sign",
[],
"['rsa_' + key_get('default', 'domain'), 'ed_' + key_get('default', 'domain')]",
)
}),
scripts,
bayes_cache: BayesTokenCache::new(
config
@ -332,10 +339,18 @@ impl Default for Scripting {
untrusted_compiler: Compiler::new(),
untrusted_runtime: Runtime::new(),
trusted_runtime: Runtime::new(),
from_addr: "MAILER-DAEMON@localhost".to_string(),
from_name: "Mailer Daemon".to_string(),
return_path: "".to_string(),
sign: Vec::new(),
from_addr: IfBlock::new::<()>(
"sieve.trusted.from-addr",
[],
"'MAILER-DAEMON@' + key_get('default', 'domain')",
),
from_name: IfBlock::new::<()>("sieve.trusted.from-name", [], "'Mailer Daemon'"),
return_path: IfBlock::empty("sieve.trusted.return-path"),
sign: IfBlock::new::<()>(
"sieve.trusted.sign",
[],
"['rsa_' + key_get('default', 'domain'), 'ed_' + key_get('default', 'domain')]",
),
scripts: AHashMap::new(),
bayes_cache: BayesTokenCache::new(
8192,

View file

@ -21,7 +21,7 @@
* for more details.
*/
use std::{net::SocketAddr, sync::Arc};
use std::{net::SocketAddr, sync::Arc, time::Duration};
use rustls::{
crypto::ring::{default_provider, ALL_CIPHER_SUITES},
@ -144,23 +144,40 @@ impl Servers {
}
}
// Set default options
if !config.contains_key(("server.listener", id, "socket.reuse-addr")) {
let _ = socket.set_reuseaddr(true);
}
listeners.push(Listener {
socket,
addr,
ttl: config
.property_or_else(("server.listener", id, "socket.ttl"), "server.socket.ttl"),
backlog: config.property_or_else(
("server.listener", id, "socket.backlog"),
"server.socket.backlog",
),
linger: config.property_or_else(
("server.listener", id, "socket.linger"),
"server.socket.linger",
),
.property_or_else::<Option<u32>>(
("server.listener", id, "socket.ttl"),
"server.socket.ttl",
"false",
)
.unwrap_or_default(),
backlog: config
.property_or_else::<Option<u32>>(
("server.listener", id, "socket.backlog"),
"server.socket.backlog",
"1024",
)
.unwrap_or_default(),
linger: config
.property_or_else::<Option<Duration>>(
("server.listener", id, "socket.linger"),
"server.socket.linger",
"false",
)
.unwrap_or_default(),
nodelay: config
.property_or_else(
("server.listener", id, "socket.nodelay"),
"server.socket.nodelay",
"true",
)
.unwrap_or(true),
});
@ -190,6 +207,7 @@ impl Servers {
.property_or_else(
("server.listener", id, "max-connections"),
"server.max-connections",
"8192",
)
.unwrap_or(8192),
id: id_,
@ -218,8 +236,8 @@ impl Servers {
let id = id_.as_str();
// Build TLS config
let acceptor = if config
.property_or_else(("server.listener", id, "tls.enable"), "server.tls.enable")
.unwrap_or(false)
.property_or_default(("server.listener", id, "tls.enable"), "true")
.unwrap_or(true)
{
// Parse protocol versions
let mut tls_v2 = true;
@ -292,6 +310,7 @@ impl Servers {
.property_or_else(
("server.listener", id, "tls.ignore-client-order"),
"server.tls.ignore-client-order",
"true",
)
.unwrap_or(true);
@ -302,11 +321,8 @@ impl Servers {
acme_config: acme_config.clone(),
default_config,
implicit: config
.property_or_else(
("server.listener", id, "tls.implicit"),
"server.tls.implicit",
)
.unwrap_or(true),
.property_or_default(("server.listener", id, "tls.implicit"), "false")
.unwrap_or(false),
}
} else {
TcpAcceptor::Plain

View file

@ -46,14 +46,20 @@ pub enum ServerProtocol {
ManageSieve,
}
impl Display for ServerProtocol {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
impl ServerProtocol {
pub fn as_str(&self) -> &'static str {
match self {
ServerProtocol::Smtp => write!(f, "smtp"),
ServerProtocol::Lmtp => write!(f, "lmtp"),
ServerProtocol::Imap => write!(f, "imap"),
ServerProtocol::Http => write!(f, "http"),
ServerProtocol::ManageSieve => write!(f, "managesieve"),
ServerProtocol::Smtp => "smtp",
ServerProtocol::Lmtp => "lmtp",
ServerProtocol::Imap => "imap",
ServerProtocol::Http => "http",
ServerProtocol::ManageSieve => "managesieve",
}
}
}
impl Display for ServerProtocol {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}

View file

@ -105,6 +105,11 @@ impl TlsManager {
.map(|(_, v)| v.to_string())
.collect::<Vec<_>>();
// This ACME manager is the default when SNI is not available
let default = config
.property::<bool>(("acme", acme_id.as_str(), "default"))
.unwrap_or_default();
// Add domains for self-signed certificate
subject_names.extend(domains.iter().cloned());
@ -115,6 +120,7 @@ impl TlsManager {
domains,
contact,
renew_before,
default,
) {
Ok(acme_provider) => {
acme_providers.insert(acme_id.to_string(), acme_provider);

View file

@ -11,7 +11,10 @@ use utils::config::{
Config,
};
use crate::expr::{self, if_block::IfBlock, tokenizer::TokenMap, Constant, ConstantValue};
use crate::{
config::CONNECTION_VARS,
expr::{self, if_block::IfBlock, tokenizer::TokenMap, Constant, ConstantValue},
};
use super::*;
@ -83,22 +86,62 @@ impl Default for MailAuthConfig {
fn default() -> Self {
Self {
dkim: DkimAuthConfig {
verify: IfBlock::new(VerifyStrategy::Relaxed),
sign: Default::default(),
verify: IfBlock::new::<VerifyStrategy>("auth.dkim.verify", [], "relaxed"),
sign: IfBlock::new::<()>(
"auth.dkim.sign",
[(
"local_port != 25",
"['rsa_' + key_get('default', 'domain'), 'ed_' + key_get('default', 'domain')]",
)],
"false",
),
},
arc: ArcAuthConfig {
verify: IfBlock::new(VerifyStrategy::Relaxed),
seal: Default::default(),
verify: IfBlock::new::<VerifyStrategy>("auth.arc.verify", [], "relaxed"),
seal: IfBlock::new::<()>(
"auth.arc.seal",
[],
"['rsa_' + key_get('default', 'domain'), 'ed_' + key_get('default', 'domain')]",
),
},
spf: SpfAuthConfig {
verify_ehlo: IfBlock::new(VerifyStrategy::Relaxed),
verify_mail_from: IfBlock::new(VerifyStrategy::Relaxed),
verify_ehlo: IfBlock::new::<VerifyStrategy>(
"auth.spf.verify.ehlo",
[("local_port == 25", "relaxed")],
#[cfg(not(feature = "test_mode"))]
"disable",
#[cfg(feature = "test_mode")]
"relaxed",
),
verify_mail_from: IfBlock::new::<VerifyStrategy>(
"auth.spf.verify.mail-from",
[("local_port == 25", "relaxed")],
#[cfg(not(feature = "test_mode"))]
"disable",
#[cfg(feature = "test_mode")]
"relaxed",
),
},
dmarc: DmarcAuthConfig {
verify: IfBlock::new(VerifyStrategy::Relaxed),
verify: IfBlock::new::<VerifyStrategy>(
"auth.dmarc.verify",
[("local_port == 25", "relaxed")],
#[cfg(not(feature = "test_mode"))]
"disable",
#[cfg(feature = "test_mode")]
"relaxed",
),
},
iprev: IpRevAuthConfig {
verify: IfBlock::new(VerifyStrategy::Relaxed),
verify: IfBlock::new::<VerifyStrategy>(
"auth.ipref.verify",
[("local_port == 25", "relaxed")],
#[cfg(not(feature = "test_mode"))]
"disable",
#[cfg(feature = "test_mode")]
"relaxed",
),
},
signers: Default::default(),
sealers: Default::default(),
@ -108,27 +151,19 @@ impl Default for MailAuthConfig {
impl MailAuthConfig {
pub fn parse(config: &mut Config) -> Self {
let sender_vars = TokenMap::default()
.with_smtp_variables(&[
V_SENDER,
V_SENDER_DOMAIN,
V_PRIORITY,
V_AUTHENTICATED_AS,
V_LISTENER,
V_REMOTE_IP,
V_LOCAL_IP,
])
let rcpt_vars = TokenMap::default()
.with_variables(SMTP_RCPT_TO_VARS)
.with_constants::<VerifyStrategy>();
let conn_vars = TokenMap::default()
.with_smtp_variables(&[V_LISTENER, V_REMOTE_IP, V_LOCAL_IP])
.with_variables(CONNECTION_VARS)
.with_constants::<VerifyStrategy>();
let mut mail_auth = Self::default();
for (value, key, token_map) in [
(&mut mail_auth.dkim.verify, "auth.dkim.verify", &sender_vars),
(&mut mail_auth.dkim.sign, "auth.dkim.sign", &sender_vars),
(&mut mail_auth.arc.verify, "auth.arc.verify", &sender_vars),
(&mut mail_auth.arc.seal, "auth.arc.seal", &sender_vars),
(&mut mail_auth.dkim.verify, "auth.dkim.verify", &rcpt_vars),
(&mut mail_auth.dkim.sign, "auth.dkim.sign", &rcpt_vars),
(&mut mail_auth.arc.verify, "auth.arc.verify", &rcpt_vars),
(&mut mail_auth.arc.seal, "auth.arc.seal", &rcpt_vars),
(
&mut mail_auth.spf.verify_ehlo,
"auth.spf.verify.ehlo",
@ -139,16 +174,8 @@ impl MailAuthConfig {
"auth.spf.verify.mail-from",
&conn_vars,
),
(
&mut mail_auth.dmarc.verify,
"auth.dmarc.verify",
&sender_vars,
),
(
&mut mail_auth.iprev.verify,
"auth.iprev.verify",
&sender_vars,
),
(&mut mail_auth.dmarc.verify, "auth.dmarc.verify", &rcpt_vars),
(&mut mail_auth.iprev.verify, "auth.iprev.verify", &conn_vars),
] {
if let Some(if_block) = IfBlock::try_parse(config, key, token_map) {
*value = if_block;

View file

@ -7,13 +7,15 @@ pub mod resolver;
pub mod session;
pub mod throttle;
use crate::expr::{if_block::IfBlock, tokenizer::TokenMap, Expression, ExpressionItem, Token};
use crate::expr::{tokenizer::TokenMap, Expression};
use self::{
auth::MailAuthConfig, queue::QueueConfig, report::ReportConfig, resolver::Resolvers,
session::SessionConfig,
};
use super::*;
#[derive(Default, Clone)]
pub struct SmtpConfig {
pub session: SessionConfig,
@ -43,30 +45,73 @@ pub const THROTTLE_REMOTE_IP: u16 = 1 << 7;
pub const THROTTLE_LOCAL_IP: u16 = 1 << 8;
pub const THROTTLE_HELO_DOMAIN: u16 = 1 << 9;
pub const V_RECIPIENT: u32 = 0;
pub const V_RECIPIENT_DOMAIN: u32 = 1;
pub const V_SENDER: u32 = 2;
pub const V_SENDER_DOMAIN: u32 = 3;
pub const V_MX: u32 = 4;
pub const V_HELO_DOMAIN: u32 = 5;
pub const V_AUTHENTICATED_AS: u32 = 6;
pub const V_LISTENER: u32 = 7;
pub const V_REMOTE_IP: u32 = 8;
pub const V_LOCAL_IP: u32 = 9;
pub const V_PRIORITY: u32 = 10;
pub(crate) const RCPT_DOMAIN_VARS: &[u32; 1] = &[V_RECIPIENT_DOMAIN];
pub const VARIABLES_MAP: &[(&str, u32)] = &[
("rcpt", V_RECIPIENT),
("rcpt_domain", V_RECIPIENT_DOMAIN),
("sender", V_SENDER),
("sender_domain", V_SENDER_DOMAIN),
("mx", V_MX),
("helo_domain", V_HELO_DOMAIN),
("authenticated_as", V_AUTHENTICATED_AS),
("listener", V_LISTENER),
("remote_ip", V_REMOTE_IP),
("local_ip", V_LOCAL_IP),
("priority", V_PRIORITY),
pub(crate) const SMTP_EHLO_VARS: &[u32; 8] = &[
V_LISTENER,
V_REMOTE_IP,
V_REMOTE_PORT,
V_LOCAL_IP,
V_LOCAL_PORT,
V_PROTOCOL,
V_TLS,
V_HELO_DOMAIN,
];
pub(crate) const SMTP_MAIL_FROM_VARS: &[u32; 10] = &[
V_LISTENER,
V_REMOTE_IP,
V_REMOTE_PORT,
V_LOCAL_IP,
V_LOCAL_PORT,
V_PROTOCOL,
V_TLS,
V_SENDER,
V_SENDER_DOMAIN,
V_AUTHENTICATED_AS,
];
pub(crate) const SMTP_RCPT_TO_VARS: &[u32; 15] = &[
V_SENDER,
V_SENDER_DOMAIN,
V_RECIPIENTS,
V_RECIPIENT,
V_RECIPIENT_DOMAIN,
V_AUTHENTICATED_AS,
V_LISTENER,
V_REMOTE_IP,
V_REMOTE_PORT,
V_LOCAL_IP,
V_LOCAL_PORT,
V_PROTOCOL,
V_TLS,
V_PRIORITY,
V_HELO_DOMAIN,
];
pub(crate) const SMTP_QUEUE_HOST_VARS: &[u32; 9] = &[
V_SENDER,
V_SENDER_DOMAIN,
V_RECIPIENT_DOMAIN,
V_RECIPIENT,
V_RECIPIENTS,
V_MX,
V_PRIORITY,
V_REMOTE_IP,
V_LOCAL_IP,
];
pub(crate) const SMTP_QUEUE_RCPT_VARS: &[u32; 5] = &[
V_RECIPIENT_DOMAIN,
V_RECIPIENTS,
V_SENDER,
V_SENDER_DOMAIN,
V_PRIORITY,
];
pub(crate) const SMTP_QUEUE_SENDER_VARS: &[u32; 3] = &[V_SENDER, V_SENDER_DOMAIN, V_PRIORITY];
pub(crate) const SMTP_QUEUE_MX_VARS: &[u32; 6] = &[
V_RECIPIENT_DOMAIN,
V_RECIPIENTS,
V_SENDER,
V_SENDER_DOMAIN,
V_PRIORITY,
V_MX,
];
impl SmtpConfig {
@ -80,23 +125,3 @@ impl SmtpConfig {
}
}
}
impl TokenMap {
pub fn with_smtp_variables(mut self, variables: &[u32]) -> Self {
for (name, idx) in VARIABLES_MAP {
if variables.contains(idx) {
self.tokens.insert(name, Token::Variable(*idx));
}
}
self
}
}
pub(crate) fn parse_server_hostname(config: &mut Config) -> Option<IfBlock> {
IfBlock::try_parse(
config,
"server.hostname",
&TokenMap::default().with_smtp_variables(&[V_LISTENER, V_REMOTE_IP, V_LOCAL_IP]),
)
}

View file

@ -1,5 +1,3 @@
use std::time::Duration;
use ahash::AHashMap;
use mail_auth::IpLookupStrategy;
use mail_send::Credentials;
@ -10,7 +8,7 @@ use utils::config::{
use crate::{
config::server::ServerProtocol,
expr::{if_block::IfBlock, Constant, ConstantValue, Expression, Variable},
expr::{if_block::IfBlock, *},
};
use self::throttle::{parse_throttle, parse_throttle_key};
@ -121,38 +119,80 @@ pub enum RequireOptional {
impl Default for QueueConfig {
fn default() -> Self {
Self {
retry: IfBlock::new(Duration::from_secs(5 * 60)),
notify: IfBlock::new(Duration::from_secs(86400)),
expire: IfBlock::new(Duration::from_secs(5 * 86400)),
hostname: IfBlock::new("localhost".to_string()),
next_hop: Default::default(),
max_mx: IfBlock::new(5),
max_multihomed: IfBlock::new(2),
ip_strategy: IfBlock::new(IpLookupStrategy::Ipv4thenIpv6),
retry: IfBlock::new::<()>(
"queue.schedule.retry",
[],
"[2m, 5m, 10m, 15m, 30m, 1h, 2h]",
),
notify: IfBlock::new::<()>("queue.schedule.notify", [], "[1d, 3d]"),
expire: IfBlock::new::<()>("queue.schedule.expire", [], "5d"),
hostname: IfBlock::new::<()>(
"queue.outbound.hostname",
[],
"key_get('default', 'hostname')",
),
next_hop: IfBlock::new::<()>(
"queue.outbound.next-hop",
#[cfg(not(feature = "test_mode"))]
[("is_local_domain('*', rcpt_domain)", "'local'")],
#[cfg(feature = "test_mode")]
[],
"false",
),
max_mx: IfBlock::new::<()>("queue.outbound.limits.mx", [], "5"),
max_multihomed: IfBlock::new::<()>("queue.outbound.limits.multihomed", [], "2"),
ip_strategy: IfBlock::new::<IpLookupStrategy>(
"queue.outbound.ip-strategy",
[],
"ipv4_then_ipv6",
),
source_ip: QueueOutboundSourceIp {
ipv4: Default::default(),
ipv6: Default::default(),
ipv4: IfBlock::empty("queue.outbound.source-ip.v4"),
ipv6: IfBlock::empty("queue.outbound.source-ip.v6"),
},
tls: QueueOutboundTls {
dane: IfBlock::new(RequireOptional::Optional),
mta_sts: IfBlock::new(RequireOptional::Optional),
start: IfBlock::new(RequireOptional::Optional),
invalid_certs: IfBlock::new(false),
dane: IfBlock::new::<RequireOptional>("queue.outbound.tls.dane", [], "optional"),
mta_sts: IfBlock::new::<RequireOptional>(
"queue.outbound.tls.mta-sts",
[],
"optional",
),
start: IfBlock::new::<RequireOptional>(
"queue.outbound.tls.starttls",
[],
#[cfg(not(feature = "test_mode"))]
"require",
#[cfg(feature = "test_mode")]
"optional",
),
invalid_certs: IfBlock::new::<()>(
"queue.outbound.tls.allow-invalid-certs",
[],
"false",
),
},
dsn: Dsn {
name: IfBlock::new("Mail Delivery Subsystem".to_string()),
address: IfBlock::new("MAILER-DAEMON@localhost".to_string()),
sign: Default::default(),
name: IfBlock::new::<()>("report.dsn.from-name", [], "'Mail Delivery Subsystem'"),
address: IfBlock::new::<()>(
"report.dsn.from-address",
[],
"'MAILER-DAEMON@' + key_get('default', 'domain')",
),
sign: IfBlock::new::<()>(
"report.dsn.sign",
[],
"['rsa_' + key_get('default', 'domain'), 'ed_' + key_get('default', 'domain')]",
),
},
timeout: QueueOutboundTimeout {
connect: IfBlock::new(Duration::from_secs(5 * 60)),
greeting: IfBlock::new(Duration::from_secs(5 * 60)),
tls: IfBlock::new(Duration::from_secs(3 * 60)),
ehlo: IfBlock::new(Duration::from_secs(5 * 60)),
mail: IfBlock::new(Duration::from_secs(5 * 60)),
rcpt: IfBlock::new(Duration::from_secs(5 * 60)),
data: IfBlock::new(Duration::from_secs(10 * 60)),
mta_sts: IfBlock::new(Duration::from_secs(10 * 60)),
connect: IfBlock::new::<()>("queue.outbound.timeouts.connect", [], "5m"),
greeting: IfBlock::new::<()>("queue.outbound.timeouts.greeting", [], "5m"),
tls: IfBlock::new::<()>("queue.outbound.timeouts.tls", [], "3m"),
ehlo: IfBlock::new::<()>("queue.outbound.timeouts.ehlo", [], "5m"),
mail: IfBlock::new::<()>("queue.outbound.timeouts.mail-from", [], "5m"),
rcpt: IfBlock::new::<()>("queue.outbound.timeouts.rcpt-to", [], "5m"),
data: IfBlock::new::<()>("queue.outbound.timeouts.data", [], "10m"),
mta_sts: IfBlock::new::<()>("queue.outbound.timeouts.mta-sts", [], "10m"),
},
throttle: QueueThrottle {
sender: Default::default(),
@ -172,39 +212,14 @@ impl Default for QueueConfig {
impl QueueConfig {
pub fn parse(config: &mut Config) -> Self {
let mut queue = QueueConfig::default();
let rcpt_vars = TokenMap::default().with_smtp_variables(&[
V_RECIPIENT_DOMAIN,
V_SENDER,
V_SENDER_DOMAIN,
V_PRIORITY,
]);
let sender_vars =
TokenMap::default().with_smtp_variables(&[V_SENDER, V_SENDER_DOMAIN, V_PRIORITY]);
let mx_vars = TokenMap::default().with_smtp_variables(&[
V_RECIPIENT_DOMAIN,
V_SENDER,
V_SENDER_DOMAIN,
V_PRIORITY,
V_MX,
]);
let host_vars = TokenMap::default().with_smtp_variables(&[
V_RECIPIENT_DOMAIN,
V_SENDER,
V_SENDER_DOMAIN,
V_PRIORITY,
V_LOCAL_IP,
V_REMOTE_IP,
V_MX,
]);
let rcpt_vars = TokenMap::default().with_variables(SMTP_QUEUE_RCPT_VARS);
let sender_vars = TokenMap::default().with_variables(SMTP_QUEUE_SENDER_VARS);
let mx_vars = TokenMap::default().with_variables(SMTP_QUEUE_MX_VARS);
let host_vars = TokenMap::default().with_variables(SMTP_QUEUE_HOST_VARS);
let ip_strategy_vars = sender_vars.clone().with_constants::<IpLookupStrategy>();
let dane_vars = mx_vars.clone().with_constants::<RequireOptional>();
let mta_sts_vars = rcpt_vars.clone().with_constants::<RequireOptional>();
// Parse default server hostname
if let Some(hostname) = parse_server_hostname(config) {
queue.hostname = hostname.into_default("queue.outbound.hostname");
}
for (value, key, token_map) in [
(&mut queue.retry, "queue.schedule.retry", &host_vars),
(&mut queue.notify, "queue.schedule.notify", &rcpt_vars),
@ -368,15 +383,7 @@ fn parse_queue_throttle(config: &mut Config) -> QueueThrottle {
let all_throttles = parse_throttle(
config,
"queue.throttle",
&TokenMap::default().with_smtp_variables(&[
V_RECIPIENT_DOMAIN,
V_SENDER,
V_SENDER_DOMAIN,
V_PRIORITY,
V_MX,
V_REMOTE_IP,
V_LOCAL_IP,
]),
&TokenMap::default().with_variables(SMTP_QUEUE_HOST_VARS),
THROTTLE_RCPT_DOMAIN
| THROTTLE_SENDER
| THROTTLE_SENDER_DOMAIN
@ -487,22 +494,18 @@ fn parse_queue_quota_item(config: &mut Config, prefix: impl AsKey) -> Option<Que
expr: Expression::try_parse(
config,
(prefix.as_str(), "match"),
&TokenMap::default().with_smtp_variables(&[
V_RECIPIENT,
V_RECIPIENT_DOMAIN,
V_SENDER,
V_SENDER_DOMAIN,
V_PRIORITY,
]),
&TokenMap::default().with_variables(SMTP_QUEUE_HOST_VARS),
)
.unwrap_or_default(),
keys,
size: config
.property::<usize>((prefix.as_str(), "size"))
.filter(|&v| v > 0),
.property::<Option<usize>>((prefix.as_str(), "size"))
.filter(|&v| v.as_ref().map_or(false, |v| *v > 0))
.unwrap_or_default(),
messages: config
.property::<usize>((prefix.as_str(), "messages"))
.filter(|&v| v > 0),
.property::<Option<usize>>((prefix.as_str(), "messages"))
.filter(|&v| v.as_ref().map_or(false, |v| *v > 0))
.unwrap_or_default(),
};
// Validate

View file

@ -63,41 +63,17 @@ pub enum AggregateFrequency {
impl ReportConfig {
pub fn parse(config: &mut Config) -> Self {
let sender_vars = TokenMap::default().with_smtp_variables(&[
V_SENDER,
V_SENDER_DOMAIN,
V_PRIORITY,
V_AUTHENTICATED_AS,
V_LISTENER,
V_REMOTE_IP,
V_LOCAL_IP,
]);
let rcpt_vars = TokenMap::default().with_smtp_variables(&[
V_SENDER,
V_SENDER_DOMAIN,
V_PRIORITY,
V_REMOTE_IP,
V_LOCAL_IP,
V_RECIPIENT_DOMAIN,
]);
let default_hostname_if_block = parse_server_hostname(config);
let default_hostname = default_hostname_if_block
.as_ref()
.and_then(|i| i.default_string())
.unwrap_or("localhost")
.to_string();
let sender_vars = TokenMap::default().with_variables(SMTP_MAIL_FROM_VARS);
let rcpt_vars = TokenMap::default().with_variables(SMTP_RCPT_TO_VARS);
Self {
submitter: IfBlock::try_parse(
config,
"report.submitter",
&TokenMap::default().with_smtp_variables(&[V_RECIPIENT_DOMAIN]),
&TokenMap::default().with_variables(RCPT_DOMAIN_VARS),
)
.unwrap_or_else(|| {
default_hostname_if_block
.map(|i| i.into_default("report.submitter"))
.unwrap_or_else(|| IfBlock::new("localhost".to_string()))
IfBlock::new::<()>("report.submitter", [], "key_get('default', 'hostname')")
}),
analysis: ReportAnalysis {
addresses: config
@ -106,40 +82,52 @@ impl ReportConfig {
.map(|(_, m)| m)
.collect(),
forward: config.property("report.analysis.forward").unwrap_or(true),
store: config.property("report.analysis.store"),
store: config
.property_or_default::<Option<Duration>>("report.analysis.store", "30d")
.unwrap_or_default(),
},
dkim: Report::parse(config, "dkim", &default_hostname, &sender_vars),
spf: Report::parse(config, "spf", &default_hostname, &sender_vars),
dmarc: Report::parse(config, "dmarc", &default_hostname, &sender_vars),
dkim: Report::parse(config, "dkim", &rcpt_vars),
spf: Report::parse(config, "spf", &sender_vars),
dmarc: Report::parse(config, "dmarc", &rcpt_vars),
dmarc_aggregate: AggregateReport::parse(
config,
"dmarc",
&default_hostname,
&sender_vars.with_constants::<AggregateFrequency>(),
&rcpt_vars.with_constants::<AggregateFrequency>(),
),
tls: AggregateReport::parse(
config,
"tls",
&default_hostname,
&rcpt_vars.with_constants::<AggregateFrequency>(),
&TokenMap::default()
.with_variables(SMTP_QUEUE_HOST_VARS)
.with_constants::<AggregateFrequency>(),
),
}
}
}
impl Report {
pub fn parse(
config: &mut Config,
id: &str,
default_hostname: &str,
token_map: &TokenMap,
) -> Self {
pub fn parse(config: &mut Config, id: &str, token_map: &TokenMap) -> Self {
let mut report = Self {
name: IfBlock::new(format!("{} Reporting", id.to_ascii_uppercase())),
address: IfBlock::new(format!("MAILER-DAEMON@{default_hostname}")),
subject: IfBlock::new(format!("{} Report", id.to_ascii_uppercase())),
sign: Default::default(),
send: Default::default(),
name: IfBlock::new::<()>(format!("report.{id}.from-name"), [], "'Report Subsystem'"),
address: IfBlock::new::<()>(
format!("report.{id}.from-address"),
[],
format!("'noreply-{id}@' + key_get('default', 'domain')"),
),
subject: IfBlock::new::<()>(
format!("report.{id}.subject"),
[],
format!(
"'{} Authentication Failure Report'",
id.to_ascii_uppercase()
),
),
sign: IfBlock::new::<()>(
format!("report.{id}.sign"),
[],
"['rsa_' + key_get('default', 'domain'), 'ed_' + key_get('default', 'domain')]",
),
send: IfBlock::new::<()>(format!("report.{id}.send"), [], "[1, 1d]"),
};
for (value, key) in [
(&mut report.name, "from-name"),
@ -158,22 +146,37 @@ impl Report {
}
impl AggregateReport {
pub fn parse(
config: &mut Config,
id: &str,
default_hostname: &str,
token_map: &TokenMap,
) -> Self {
let rcpt_vars = TokenMap::default().with_smtp_variables(&[V_RECIPIENT_DOMAIN]);
pub fn parse(config: &mut Config, id: &str, token_map: &TokenMap) -> Self {
let rcpt_vars = TokenMap::default().with_variables(RCPT_DOMAIN_VARS);
let mut report = Self {
name: IfBlock::new(format!("{} Aggregate Report", id.to_ascii_uppercase())),
address: IfBlock::new(format!("noreply-{id}@{default_hostname}")),
org_name: Default::default(),
contact_info: Default::default(),
send: IfBlock::new(AggregateFrequency::Never),
sign: Default::default(),
max_size: IfBlock::new(25 * 1024 * 1024),
name: IfBlock::new::<()>(
format!("report.{id}.aggregate.from-name"),
[],
format!("'{} Aggregate Report'", id.to_ascii_uppercase()),
),
address: IfBlock::new::<()>(
format!("report.{id}.aggregate.from-address"),
[],
format!("'noreply-{id}@' + key_get('default', 'domain')"),
),
org_name: IfBlock::new::<()>(
format!("report.{id}.aggregate.org-name"),
[],
"key_get('default', 'domain')",
),
contact_info: IfBlock::empty(format!("report.{id}.aggregate.contact-info")),
send: IfBlock::new::<AggregateFrequency>(
format!("report.{id}.aggregate.send"),
[],
"daily",
),
sign: IfBlock::new::<()>(
format!("report.{id}.aggregate.sign"),
[],
"['rsa_' + key_get('default', 'domain'), 'ed_' + key_get('default', 'domain')]",
),
max_size: IfBlock::new::<()>(format!("report.{id}.aggregate.max-size"), [], "26214400"),
};
for (value, key, token_map) in [
@ -200,45 +203,7 @@ impl AggregateReport {
impl Default for ReportConfig {
fn default() -> Self {
Self {
submitter: IfBlock::new("localhost".to_string()),
analysis: ReportAnalysis {
addresses: Default::default(),
forward: true,
store: None,
},
dkim: Default::default(),
spf: Default::default(),
dmarc: Default::default(),
dmarc_aggregate: Default::default(),
tls: Default::default(),
}
}
}
impl Default for Report {
fn default() -> Self {
Self {
name: IfBlock::new("Mail Delivery Subsystem".to_string()),
address: IfBlock::new("MAILER-DAEMON@localhost".to_string()),
subject: IfBlock::new("Report".to_string()),
sign: Default::default(),
send: Default::default(),
}
}
}
impl Default for AggregateReport {
fn default() -> Self {
Self {
name: IfBlock::new("Reporting Subsystem".to_string()),
address: IfBlock::new("no-replyN@localhost".to_string()),
org_name: Default::default(),
contact_info: Default::default(),
send: IfBlock::new(AggregateFrequency::Never),
sign: Default::default(),
max_size: IfBlock::new(25 * 1024 * 1024),
}
Self::parse(&mut Config::default())
}
}

View file

@ -70,10 +70,7 @@ pub struct Policy {
impl Resolvers {
pub async fn parse(config: &mut Config) -> Self {
let (resolver_config, mut opts) = match config
.value_require("resolver.type")
.unwrap_or("system")
{
let (resolver_config, mut opts) = match config.value("resolver.type").unwrap_or("system") {
"cloudflare" => (ResolverConfig::cloudflare(), ResolverOpts::default()),
"cloudflare-tls" => (ResolverConfig::cloudflare_tls(), ResolverOpts::default()),
"quad9" => (ResolverConfig::quad9(), ResolverOpts::default()),

View file

@ -6,7 +6,10 @@ use std::{
use smtp_proto::*;
use utils::config::{utils::ParseValue, Config};
use crate::expr::{if_block::IfBlock, tokenizer::TokenMap, Constant, ConstantValue, Variable};
use crate::{
config::CONNECTION_VARS,
expr::{if_block::IfBlock, tokenizer::TokenMap, *},
};
use self::throttle::parse_throttle;
@ -37,6 +40,7 @@ pub struct SessionThrottle {
#[derive(Clone)]
pub struct Connect {
pub hostname: IfBlock,
pub script: IfBlock,
pub greeting: IfBlock,
}
@ -67,7 +71,6 @@ pub struct Auth {
pub directory: IfBlock,
pub mechanisms: IfBlock,
pub require: IfBlock,
pub allow_plain_text: IfBlock,
pub must_match_sender: IfBlock,
pub errors_max: IfBlock,
pub errors_wait: IfBlock,
@ -160,33 +163,10 @@ pub enum MilterVersion {
impl SessionConfig {
pub fn parse(config: &mut Config) -> Self {
let has_conn_vars =
TokenMap::default().with_smtp_variables(&[V_LISTENER, V_REMOTE_IP, V_LOCAL_IP]);
let has_ehlo_hars = TokenMap::default().with_smtp_variables(&[
V_LISTENER,
V_REMOTE_IP,
V_LOCAL_IP,
V_HELO_DOMAIN,
]);
let has_sender_vars = TokenMap::default().with_smtp_variables(&[
V_LISTENER,
V_REMOTE_IP,
V_LOCAL_IP,
V_SENDER,
V_SENDER_DOMAIN,
V_AUTHENTICATED_AS,
]);
let has_rcpt_vars = TokenMap::default().with_smtp_variables(&[
V_SENDER,
V_SENDER_DOMAIN,
V_RECIPIENT,
V_RECIPIENT_DOMAIN,
V_AUTHENTICATED_AS,
V_LISTENER,
V_REMOTE_IP,
V_LOCAL_IP,
V_HELO_DOMAIN,
]);
let has_conn_vars = TokenMap::default().with_variables(CONNECTION_VARS);
let has_ehlo_hars = TokenMap::default().with_variables(SMTP_EHLO_VARS);
let has_sender_vars = TokenMap::default().with_variables(SMTP_MAIL_FROM_VARS);
let has_rcpt_vars = TokenMap::default().with_variables(SMTP_RCPT_TO_VARS);
let mt_priority_vars = has_sender_vars.clone().with_constants::<MtPriority>();
let mechanisms_vars = has_ehlo_hars.clone().with_constants::<Mechanism>();
@ -222,6 +202,11 @@ impl SessionConfig {
"session.connect.script",
&has_conn_vars,
),
(
&mut session.connect.hostname,
"session.connect.hostname",
&has_conn_vars,
),
(
&mut session.connect.greeting,
"session.connect.greeting",
@ -317,11 +302,6 @@ impl SessionConfig {
"session.auth.errors.wait",
&has_ehlo_hars,
),
(
&mut session.auth.allow_plain_text,
"session.auth.allow-plain-text",
&has_ehlo_hars,
),
(
&mut session.auth.must_match_sender,
"session.auth.must-match-sender",
@ -438,18 +418,7 @@ impl SessionThrottle {
let all_throttles = parse_throttle(
config,
"session.throttle",
&TokenMap::default().with_smtp_variables(&[
V_SENDER,
V_SENDER_DOMAIN,
V_RECIPIENT,
V_RECIPIENT_DOMAIN,
V_AUTHENTICATED_AS,
V_LISTENER,
V_REMOTE_IP,
V_LOCAL_IP,
V_PRIORITY,
V_HELO_DOMAIN,
]),
&TokenMap::default().with_variables(SMTP_RCPT_TO_VARS),
THROTTLE_LISTENER
| THROTTLE_REMOTE_IP
| THROTTLE_LOCAL_IP
@ -500,7 +469,9 @@ fn parse_pipe(config: &mut Config, id: &str, token_map: &TokenMap) -> Option<Pip
command: IfBlock::try_parse(config, ("session.data.pipe", id, "command"), token_map)?,
arguments: IfBlock::try_parse(config, ("session.data.pipe", id, "arguments"), token_map)?,
timeout: IfBlock::try_parse(config, ("session.data.pipe", id, "timeout"), token_map)
.unwrap_or_else(|| IfBlock::new(Duration::from_secs(30))),
.unwrap_or_else(|| {
IfBlock::new::<()>(format!("session.data.pipe.{id}.timeout"), [], "30s")
}),
})
}
@ -511,7 +482,9 @@ fn parse_milter(config: &mut Config, id: &str, token_map: &TokenMap) -> Option<M
let port = config.property_require(("session.data.milter", id, "port"))?;
Some(Milter {
enable: IfBlock::try_parse(config, ("session.data.milter", id, "enable"), token_map)
.unwrap_or_default(),
.unwrap_or_else(|| {
IfBlock::new::<()>(format!("session.data.milter.{id}.enable"), [], "false")
}),
addrs: format!("{}:{}", hostname, port)
.to_socket_addrs()
.map_err(|err| {
@ -573,72 +546,165 @@ fn parse_milter(config: &mut Config, id: &str, token_map: &TokenMap) -> Option<M
impl Default for SessionConfig {
fn default() -> Self {
Self {
timeout: IfBlock::new(Duration::from_secs(15 * 60)),
duration: IfBlock::new(Duration::from_secs(5 * 60)),
transfer_limit: IfBlock::new(250 * 1024 * 1024),
timeout: IfBlock::new::<()>("session.timeout", [], "5m"),
duration: IfBlock::new::<()>("session.duration", [], "10m"),
transfer_limit: IfBlock::new::<()>("session.transfer-limit", [], "262144000"),
throttle: SessionThrottle {
connect: Default::default(),
mail_from: Default::default(),
rcpt_to: Default::default(),
},
connect: Connect {
script: Default::default(),
greeting: IfBlock::new("Stalwart ESMTP at your service".to_string()),
hostname: IfBlock::new::<()>(
"server.connect.hostname",
[],
"key_get('default', 'hostname')",
),
script: IfBlock::empty("session.connect.script"),
greeting: IfBlock::new::<()>(
"session.connect.greeting",
[],
"'Stalwart ESMTP at your service'",
),
},
ehlo: Ehlo {
script: Default::default(),
require: IfBlock::new(true),
reject_non_fqdn: IfBlock::new(true),
script: IfBlock::empty("session.ehlo.script"),
require: IfBlock::new::<()>("session.ehlo.require", [], "true"),
reject_non_fqdn: IfBlock::new::<()>(
"session.ehlo.reject-non-fqdn",
[("local_port == 25", "true")],
"false",
),
},
auth: Auth {
directory: Default::default(),
mechanisms: Default::default(),
require: IfBlock::new(false),
allow_plain_text: IfBlock::new(false),
must_match_sender: IfBlock::new(true),
errors_max: IfBlock::new(3),
errors_wait: IfBlock::new(Duration::from_secs(30)),
directory: IfBlock::new::<()>(
"session.auth.directory",
#[cfg(feature = "test_mode")]
[],
#[cfg(not(feature = "test_mode"))]
[("local_port != 25", "'*'")],
"false",
),
mechanisms: IfBlock::new::<Mechanism>(
"session.auth.mechanisms",
[("local_port != 25 && is_tls", "[plain, login]")],
"false",
),
require: IfBlock::new::<()>(
"session.auth.require",
#[cfg(feature = "test_mode")]
[],
#[cfg(not(feature = "test_mode"))]
[("local_port != 25", "'*'")],
"false",
),
must_match_sender: IfBlock::new::<()>("session.auth.must-match-sender", [], "true"),
errors_max: IfBlock::new::<()>("session.auth.errors.total", [], "3"),
errors_wait: IfBlock::new::<()>("session.auth.errors.wait", [], "5s"),
},
mail: Mail {
script: Default::default(),
rewrite: Default::default(),
script: IfBlock::empty("session.mail.script"),
rewrite: IfBlock::empty("session.mail.rewrite"),
},
rcpt: Rcpt {
script: Default::default(),
relay: IfBlock::new(false),
directory: Default::default(),
rewrite: Default::default(),
errors_max: IfBlock::new(10),
errors_wait: IfBlock::new(Duration::from_secs(30)),
max_recipients: IfBlock::new(100),
catch_all: AddressMapping::Disable,
subaddressing: AddressMapping::Disable,
script: IfBlock::empty("session.rcpt."),
relay: IfBlock::new::<()>(
"session.rcpt.relay",
[("!is_empty(authenticated_as)", "true")],
"false",
),
directory: IfBlock::new::<()>(
"session.rcpt.directory",
[],
#[cfg(feature = "test_mode")]
"false",
#[cfg(not(feature = "test_mode"))]
"'*'",
),
rewrite: IfBlock::empty("session.rcpt.rewrite"),
errors_max: IfBlock::new::<()>("session.rcpt.errors.total", [], "5"),
errors_wait: IfBlock::new::<()>("session.rcpt.errors.wait", [], "5s"),
max_recipients: IfBlock::new::<()>("session.rcpt.max-recipients", [], "100"),
catch_all: AddressMapping::Enable,
subaddressing: AddressMapping::Enable,
},
data: Data {
script: Default::default(),
script: IfBlock::empty("session.data.script"),
pipe_commands: Default::default(),
milters: Default::default(),
max_messages: IfBlock::new(10),
max_message_size: IfBlock::new(25 * 1024 * 1024),
max_received_headers: IfBlock::new(50),
add_received: IfBlock::new(true),
add_received_spf: IfBlock::new(true),
add_return_path: IfBlock::new(true),
add_auth_results: IfBlock::new(true),
add_message_id: IfBlock::new(true),
add_date: IfBlock::new(true),
max_messages: IfBlock::new::<()>("session.data.limits.messages", [], "10"),
max_message_size: IfBlock::new::<()>("session.data.limits.size", [], "104857600"),
max_received_headers: IfBlock::new::<()>(
"session.data.limits.received-headers",
[],
"50",
),
add_received: IfBlock::new::<()>(
"session.data.add-headers.received",
[("local_port == 25", "true")],
"false",
),
add_received_spf: IfBlock::new::<()>(
"session.data.add-headers.received-spf",
[("local_port == 25", "true")],
"false",
),
add_return_path: IfBlock::new::<()>(
"session.data.add-headers.return-path",
[("local_port == 25", "true")],
"false",
),
add_auth_results: IfBlock::new::<()>(
"session.data.add-headers.auth-results",
[("local_port == 25", "true")],
"false",
),
add_message_id: IfBlock::new::<()>(
"session.data.add-headers.message-id",
[("local_port == 25", "true")],
"false",
),
add_date: IfBlock::new::<()>(
"session.data.add-headers.date",
[("local_port == 25", "true")],
"false",
),
},
extensions: Extensions {
pipelining: IfBlock::new(true),
chunking: IfBlock::new(true),
requiretls: IfBlock::new(true),
dsn: IfBlock::new(false),
vrfy: IfBlock::new(false),
expn: IfBlock::new(false),
no_soliciting: IfBlock::new(false),
future_release: Default::default(),
deliver_by: Default::default(),
mt_priority: Default::default(),
pipelining: IfBlock::new::<()>("session.extensions.pipelining", [], "true"),
chunking: IfBlock::new::<()>("session.extensions.chunking", [], "true"),
requiretls: IfBlock::new::<()>("session.extensions.requiretls", [], "true"),
dsn: IfBlock::new::<()>(
"session.extensions.dsn",
[("!is_empty(authenticated_as)", "true")],
"false",
),
vrfy: IfBlock::new::<()>(
"session.extensions.vrfy",
[("!is_empty(authenticated_as)", "true")],
"false",
),
expn: IfBlock::new::<()>(
"session.extensions.expn",
[("!is_empty(authenticated_as)", "true")],
"false",
),
no_soliciting: IfBlock::new::<()>("session.extensions.no-soliciting", [], "''"),
future_release: IfBlock::new::<()>(
"session.extensions.future-release",
[("!is_empty(authenticated_as)", "7d")],
"false",
),
deliver_by: IfBlock::new::<()>(
"session.extensions.deliver-by",
[("!is_empty(authenticated_as)", "15d")],
"false",
),
mt_priority: IfBlock::new::<MtPriority>(
"session.extensions.mt-priority",
[("!is_empty(authenticated_as)", "mixer")],
"false",
),
},
}
}

View file

@ -96,11 +96,13 @@ fn parse_throttle_item(
.unwrap_or_default(),
keys,
concurrency: config
.property::<u64>((prefix.as_str(), "concurrency"))
.filter(|&v| v > 0),
.property::<Option<u64>>((prefix.as_str(), "concurrency"))
.filter(|&v| v.as_ref().map_or(false, |v| *v > 0))
.unwrap_or_default(),
rate: config
.property::<Rate>((prefix.as_str(), "rate"))
.filter(|v| v.requests > 0),
.property::<Option<Rate>>((prefix.as_str(), "rate"))
.filter(|v| v.as_ref().map_or(false, |r| r.requests > 0))
.unwrap_or_default(),
};
// Validate

View file

@ -5,6 +5,7 @@ use tracing::Level;
use tracing_appender::rolling::RollingFileAppender;
use utils::config::Config;
#[derive(Debug)]
pub enum Tracer {
Stdout {
level: Level,
@ -24,11 +25,13 @@ pub enum Tracer {
},
}
#[derive(Debug)]
pub enum OtelTracer {
Gprc(TonicExporterBuilder),
Http(HttpExporterBuilder),
}
#[derive(Debug)]
pub struct Tracers {
pub tracers: Vec<Tracer>,
}

View file

@ -28,17 +28,17 @@ use crate::expr::{Constant, Expression};
use super::{
parser::ExpressionParser,
tokenizer::{TokenMap, Tokenizer},
ExpressionItem,
ConstantValue, ExpressionItem,
};
#[derive(Debug, Clone, Default)]
#[derive(Debug, Clone)]
#[cfg_attr(feature = "test_mode", derive(PartialEq, Eq))]
pub struct IfThen {
pub expr: Expression,
pub then: Expression,
}
#[derive(Debug, Clone, Default)]
#[derive(Debug, Clone)]
#[cfg_attr(feature = "test_mode", derive(PartialEq, Eq))]
pub struct IfBlock {
pub key: String,
@ -47,11 +47,35 @@ pub struct IfBlock {
}
impl IfBlock {
pub fn new<T: Into<Constant>>(value: T) -> Self {
pub fn new<T: ConstantValue>(
key: impl Into<String>,
if_thens: impl IntoIterator<Item = (&'static str, &'static str)>,
default: impl AsRef<str>,
) -> Self {
let token_map = TokenMap::default()
.with_all_variables()
.with_constants::<T>();
Self {
key: String::new(),
if_then: Vec::new(),
default: Expression::from(value),
key: key.into(),
if_then: if_thens
.into_iter()
.map(|(if_, then)| IfThen {
expr: Expression::parse(&token_map, if_),
then: Expression::parse(&token_map, then),
})
.collect(),
default: Expression::parse(&token_map, default.as_ref()),
}
}
pub fn empty(key: impl Into<String>) -> Self {
Self {
key: key.into(),
if_then: Default::default(),
default: Expression {
items: Default::default(),
},
}
}
@ -78,6 +102,12 @@ impl Expression {
None
}
}
fn parse(token_map: &TokenMap, expr: &str) -> Self {
ExpressionParser::new(Tokenizer::new(expr, token_map))
.parse()
.unwrap()
}
}
impl IfBlock {
@ -91,7 +121,10 @@ impl IfBlock {
// Parse conditions
let mut if_block = IfBlock {
key,
..Default::default()
if_then: Default::default(),
default: Expression {
items: Default::default(),
},
};
// Try first with a single value
@ -181,7 +214,6 @@ impl IfBlock {
}
if !found_if {
config.new_missing_property(if_block.key);
None
} else if !found_then {
config.new_parse_error(

View file

@ -27,6 +27,42 @@ use std::{
time::Duration,
};
pub const V_RECIPIENT: u32 = 0;
pub const V_RECIPIENT_DOMAIN: u32 = 1;
pub const V_SENDER: u32 = 2;
pub const V_SENDER_DOMAIN: u32 = 3;
pub const V_MX: u32 = 4;
pub const V_HELO_DOMAIN: u32 = 5;
pub const V_AUTHENTICATED_AS: u32 = 6;
pub const V_LISTENER: u32 = 7;
pub const V_REMOTE_IP: u32 = 8;
pub const V_REMOTE_PORT: u32 = 9;
pub const V_LOCAL_IP: u32 = 10;
pub const V_LOCAL_PORT: u32 = 11;
pub const V_PRIORITY: u32 = 12;
pub const V_PROTOCOL: u32 = 13;
pub const V_TLS: u32 = 14;
pub const V_RECIPIENTS: u32 = 15;
pub const VARIABLES_MAP: &[(&str, u32)] = &[
("rcpt", V_RECIPIENT),
("rcpt_domain", V_RECIPIENT_DOMAIN),
("sender", V_SENDER),
("sender_domain", V_SENDER_DOMAIN),
("mx", V_MX),
("helo_domain", V_HELO_DOMAIN),
("authenticated_as", V_AUTHENTICATED_AS),
("listener", V_LISTENER),
("remote_ip", V_REMOTE_IP),
("local_ip", V_LOCAL_IP),
("priority", V_PRIORITY),
("local_port", V_LOCAL_PORT),
("remote_port", V_REMOTE_PORT),
("protocol", V_PROTOCOL),
("is_tls", V_TLS),
("recipients", V_RECIPIENTS),
];
use regex::Regex;
use utils::config::{utils::ParseValue, Rate};
@ -67,7 +103,7 @@ pub enum Variable<'x> {
impl Default for Variable<'_> {
fn default() -> Self {
Variable::Integer(0)
Variable::String("".into())
}
}
@ -185,6 +221,12 @@ impl From<i32> for Variable<'_> {
}
}
impl From<u16> for Variable<'_> {
fn from(value: u16) -> Self {
Variable::Integer(value as i64)
}
}
impl From<i16> for Variable<'_> {
fn from(value: i16) -> Self {
Variable::Integer(value as i64)
@ -292,12 +334,32 @@ impl PartialEq for Token {
impl Eq for Token {}
pub struct NoConstants;
pub trait ConstantValue:
ParseValue + for<'x> TryFrom<Variable<'x>> + Into<Constant> + Sized
{
fn add_constants(token_map: &mut TokenMap);
}
impl ConstantValue for () {
fn add_constants(_: &mut TokenMap) {}
}
impl From<()> for Constant {
fn from(_: ()) -> Self {
Constant::Integer(0)
}
}
impl<'x> TryFrom<Variable<'x>> for () {
type Error = ();
fn try_from(_: Variable<'x>) -> Result<Self, Self::Error> {
Ok(())
}
}
impl ConstantValue for Duration {
fn add_constants(_: &mut TokenMap) {}
}

View file

@ -29,7 +29,7 @@ use utils::config::utils::ParseValue;
use super::{
functions::{ASYNC_FUNCTIONS, FUNCTIONS},
BinaryOperator, Constant, ConstantValue, Token, UnaryOperator,
*,
};
pub struct Tokenizer<'x> {
@ -348,7 +348,37 @@ impl<'x> Tokenizer<'x> {
}
impl TokenMap {
pub fn with_variables<I>(mut self, vars: I) -> Self
pub fn with_all_variables(self) -> Self {
self.with_variables(&[
V_RECIPIENT,
V_RECIPIENT_DOMAIN,
V_SENDER,
V_SENDER_DOMAIN,
V_MX,
V_HELO_DOMAIN,
V_AUTHENTICATED_AS,
V_LISTENER,
V_REMOTE_IP,
V_REMOTE_PORT,
V_LOCAL_IP,
V_LOCAL_PORT,
V_PRIORITY,
V_PROTOCOL,
V_TLS,
])
}
pub fn with_variables(mut self, variables: &[u32]) -> Self {
for (name, idx) in VARIABLES_MAP {
if variables.contains(idx) {
self.tokens.insert(name, Token::Variable(*idx));
}
}
self
}
pub fn with_variables_map<I>(mut self, vars: I) -> Self
where
I: IntoIterator<Item = (&'static str, u32)>,
{

View file

@ -42,7 +42,7 @@ pub static DAEMON_NAME: &str = concat!("Stalwart Mail Server v", env!("CARGO_PKG
pub type SharedCore = Arc<ArcSwap<Core>>;
#[derive(Default, Clone)]
#[derive(Clone, Default)]
pub struct Core {
pub storage: Storage,
pub sieve: Scripting,
@ -56,7 +56,6 @@ pub struct Core {
#[derive(Clone)]
pub struct Network {
pub blocked_ips: BlockedIps,
pub hostname: IfBlock,
pub url: IfBlock,
}
@ -249,7 +248,7 @@ impl Tracers {
| Tracer::Otel { level, .. } => level,
};
if tracer_level > level {
if tracer_level < level {
level = tracer_level;
}
}

View file

@ -49,6 +49,7 @@ pub struct AcmeProvider {
pub contact: Vec<String>,
renew_before: chrono::Duration,
account_key: ArcSwap<Vec<u8>>,
default: bool,
}
pub struct AcmeResolver {
@ -73,6 +74,7 @@ impl AcmeProvider {
domains: Vec<String>,
contact: Vec<String>,
renew_before: Duration,
default: bool,
) -> utils::config::Result<Self> {
Ok(AcmeProvider {
id,
@ -90,6 +92,7 @@ impl AcmeProvider {
renew_before: chrono::Duration::from_std(renew_before).unwrap(),
domains,
account_key: Default::default(),
default,
})
}
}
@ -139,6 +142,7 @@ impl Clone for AcmeProvider {
contact: self.contact.clone(),
renew_before: self.renew_before,
account_key: ArcSwap::from_pointee(self.account_key.load().as_ref().clone()),
default: self.default,
}
}
}

View file

@ -45,6 +45,12 @@ impl Core {
cert.clone(),
);
}
// Add default certificate
if provider.default {
certificates.insert("*".to_string(), cert);
}
self.tls.certificates.store(certificates.into());
// Remove auth keys

View file

@ -70,7 +70,7 @@ impl BlockedIps {
ip_addresses: RwLock::new(ip_addresses),
has_networks: !ip_networks.is_empty(),
ip_networks,
limiter_rate: config.property::<Rate>("authentication.fail2ban"),
limiter_rate: config.property_or_default::<Rate>("authentication.fail2ban", "100/1d"),
}
}
}

View file

@ -79,7 +79,7 @@ impl Server {
tls = is_tls,
"Starting listener"
);
let local_ip = listener.addr.ip();
let local_addr = listener.addr;
// Obtain TCP options
let opts = SocketOpts {
@ -131,7 +131,7 @@ impl Server {
.proxied_address()
.map(|addr| addr.source)
.unwrap_or(remote_addr);
if let Some(session) = instance.build_session(stream, local_ip, remote_addr, &core) {
if let Some(session) = instance.build_session(stream, local_addr, remote_addr, &core) {
// Spawn session
manager.spawn(session, is_tls, enable_acme);
}
@ -146,7 +146,7 @@ impl Server {
}
}
});
} else if let Some(session) = instance.build_session(stream, local_ip, remote_addr, &core) {
} else if let Some(session) = instance.build_session(stream, local_addr, remote_addr, &core) {
// Set socket options
opts.apply(&session.stream);
@ -183,7 +183,7 @@ trait BuildSession {
fn build_session<T: SessionStream>(
&self,
stream: T,
local_ip: IpAddr,
local_addr: SocketAddr,
remote_addr: SocketAddr,
core: &Core,
) -> Option<SessionData<T>>;
@ -193,7 +193,7 @@ impl BuildSession for Arc<ServerInstance> {
fn build_session<T: SessionStream>(
&self,
stream: T,
local_ip: IpAddr,
local_addr: SocketAddr,
remote_addr: SocketAddr,
core: &Core,
) -> Option<SessionData<T>> {
@ -231,9 +231,11 @@ impl BuildSession for Arc<ServerInstance> {
remote.ip = remote_ip.to_string(),
remote.port = remote_port,
),
local_ip,
local_ip: local_addr.ip(),
local_port: local_addr.port(),
remote_ip,
remote_port,
protocol: self.protocol,
instance: self.clone(),
}
.into()

View file

@ -33,11 +33,8 @@ use tokio_rustls::{Accept, TlsAcceptor};
use utils::config::ipmask::IpAddrMask;
use crate::{
config::{
server::ServerProtocol,
smtp::{V_LISTENER, V_LOCAL_IP, V_REMOTE_IP},
},
expr::functions::ResolveVariable,
config::server::ServerProtocol,
expr::{functions::ResolveVariable, *},
};
use self::limiter::{ConcurrencyLimiter, InFlight};
@ -83,8 +80,10 @@ where
pub struct SessionData<T: SessionStream> {
pub stream: T,
pub local_ip: IpAddr,
pub local_port: u16,
pub remote_ip: IpAddr,
pub remote_port: u16,
pub protocol: ServerProtocol,
pub span: tracing::Span,
pub in_flight: InFlight,
pub instance: Arc<ServerInstance>,
@ -117,8 +116,10 @@ pub trait SessionManager: Sync + Send + 'static + Clone {
let session = SessionData {
stream,
local_ip: session.local_ip,
local_port: session.local_port,
remote_ip: session.remote_ip,
remote_port: session.remote_port,
protocol: session.protocol,
span: session.span,
in_flight: session.in_flight,
instance: session.instance,
@ -157,21 +158,16 @@ pub trait SessionManager: Sync + Send + 'static + Clone {
fn shutdown(&self) -> impl std::future::Future<Output = ()> + Send;
}
impl ResolveVariable for ServerInstance {
fn resolve_variable(&self, variable: u32) -> crate::expr::Variable<'_> {
match variable {
V_LISTENER => self.id.as_str().into(),
_ => crate::expr::Variable::default(),
}
}
}
impl<T: SessionStream> ResolveVariable for SessionData<T> {
fn resolve_variable(&self, variable: u32) -> crate::expr::Variable<'_> {
match variable {
V_REMOTE_IP => self.remote_ip.to_string().into(),
V_REMOTE_PORT => self.remote_port.into(),
V_LOCAL_IP => self.local_ip.to_string().into(),
V_LOCAL_PORT => self.local_port.into(),
V_LISTENER => self.instance.id.as_str().into(),
V_PROTOCOL => self.protocol.as_str().into(),
V_TLS => self.stream.is_tls().into(),
_ => crate::expr::Variable::default(),
}
}
@ -180,7 +176,17 @@ impl<T: SessionStream> ResolveVariable for SessionData<T> {
impl Debug for TcpAcceptor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Tls { .. } => f.debug_tuple("Tls").finish(),
Self::Tls {
acme_config,
default_config,
implicit,
..
} => f
.debug_struct("Tls")
.field("acme_config", acme_config)
.field("default_config", default_config)
.field("implicit", implicit)
.finish(),
Self::Plain => write!(f, "Plain"),
}
}

View file

@ -224,6 +224,7 @@ pub fn exec_classify(ctx: PluginContext<'_>) -> Variable {
context = "sieve:bayes_classify",
event = "skip-classify",
reason = "Not enough training data",
min_learns = classifier.min_learns,
spam_learns = %spam_learns,
ham_learns = %ham_learns);
return Variable::default();

View file

@ -57,7 +57,7 @@ impl SmtpDirectory {
is_lmtp,
credentials: None,
local_host: config
.value("server.hostname")
.value("lookup.default.hostname")
.unwrap_or("[127.0.0.1]")
.to_string(),
say_ehlo: false,

View file

@ -24,8 +24,7 @@
use std::{net::IpAddr, sync::Arc};
use common::{
config::smtp::{V_LISTENER, V_LOCAL_IP, V_REMOTE_IP},
expr::functions::ResolveVariable,
expr::{functions::ResolveVariable, *},
listener::{ServerInstance, SessionData, SessionManager, SessionStream},
Core,
};
@ -57,7 +56,9 @@ use super::{HtmlResponse, HttpRequest, HttpResponse, JmapSessionManager, JsonRes
pub struct HttpSessionData {
pub instance: Arc<ServerInstance>,
pub local_ip: IpAddr,
pub local_port: u16,
pub remote_ip: IpAddr,
pub remote_port: u16,
pub is_tls: bool,
}
@ -366,7 +367,9 @@ impl JMAP {
HttpSessionData {
instance,
local_ip: session.local_ip,
local_port: session.local_port,
remote_ip,
remote_port: session.remote_port,
is_tls,
},
)
@ -423,7 +426,11 @@ impl ResolveVariable for HttpSessionData {
fn resolve_variable(&self, variable: u32) -> common::expr::Variable<'_> {
match variable {
V_REMOTE_IP => self.remote_ip.to_string().into(),
V_REMOTE_PORT => self.remote_port.into(),
V_LOCAL_IP => self.local_ip.to_string().into(),
V_LOCAL_PORT => self.local_port.into(),
V_TLS => self.is_tls.into(),
V_PROTOCOL => if self.is_tls { "https" } else { "http" }.into(),
V_LISTENER => self.instance.id.as_str().into(),
_ => common::expr::Variable::default(),
}
@ -434,7 +441,14 @@ impl HttpSessionData {
pub async fn resolve_url(&self, core: &Core) -> String {
core.eval_if(&core.network.url, self)
.await
.unwrap_or_else(|| format!("http{}://localhost", if self.is_tls { "s" } else { "" }))
.unwrap_or_else(|| {
format!(
"http{}://{}:{}",
if self.is_tls { "s" } else { "" },
self.local_ip,
self.local_port
)
})
}
}

View file

@ -149,6 +149,7 @@ pub struct Session<T: AsyncWrite + AsyncRead> {
pub struct SessionData {
pub local_ip: IpAddr,
pub local_ip_str: String,
pub local_port: u16,
pub remote_ip: IpAddr,
pub remote_ip_str: String,
pub remote_port: u16,
@ -200,7 +201,6 @@ pub struct SessionParameters {
pub auth_require: bool,
pub auth_errors_max: usize,
pub auth_errors_wait: Duration,
pub auth_plain_text: bool,
pub auth_match_sender: bool,
// Rcpt parameters
@ -219,9 +219,10 @@ pub struct SessionParameters {
}
impl SessionData {
pub fn new(local_ip: IpAddr, remote_ip: IpAddr, remote_port: u16) -> Self {
pub fn new(local_ip: IpAddr, local_port: u16, remote_ip: IpAddr, remote_port: u16) -> Self {
SessionData {
local_ip,
local_port,
remote_ip,
local_ip_str: local_ip.to_string(),
remote_ip_str: remote_ip.to_string(),
@ -337,7 +338,6 @@ impl Session<common::listener::stream::NullIo> {
auth_require: Default::default(),
auth_errors_max: Default::default(),
auth_errors_wait: Default::default(),
auth_plain_text: false,
rcpt_errors_max: Default::default(),
rcpt_errors_wait: Default::default(),
rcpt_max: Default::default(),
@ -395,6 +395,7 @@ impl SessionData {
local_ip_str: "127.0.0.1".to_string(),
remote_ip_str: "127.0.0.1".to_string(),
remote_port: 0,
local_port: 0,
helo_domain: "localhost".into(),
mail_from,
rcpt_to,

View file

@ -23,12 +23,11 @@
use std::time::Duration;
use common::config::smtp::auth::VerifyStrategy;
use tokio::io::{AsyncRead, AsyncWrite};
use common::{config::smtp::auth::VerifyStrategy, listener::SessionStream};
use super::Session;
impl<T: AsyncRead + AsyncWrite> Session<T> {
impl<T: SessionStream> Session<T> {
pub async fn eval_session_params(&mut self) {
let c = &self.core.core.smtp.session;
self.data.bytes_left = self
@ -111,12 +110,6 @@ impl<T: AsyncRead + AsyncWrite> Session<T> {
.eval_if(&ac.errors_wait, self)
.await
.unwrap_or_else(|| Duration::from_secs(30));
self.params.auth_plain_text = self
.core
.core
.eval_if(&ac.allow_plain_text, self)
.await
.unwrap_or(false);
self.params.auth_match_sender = self
.core
.core

View file

@ -23,11 +23,10 @@
use common::{
config::smtp::{queue::QueueQuota, *},
expr::functions::ResolveVariable,
listener::limiter::ConcurrencyLimiter,
expr::{functions::ResolveVariable, *},
listener::{limiter::ConcurrencyLimiter, SessionStream},
};
use dashmap::mapref::entry::Entry;
use tokio::io::{AsyncRead, AsyncWrite};
use utils::config::Rate;
use std::hash::{BuildHasher, Hash, Hasher};
@ -210,7 +209,7 @@ impl NewKey for Throttle {
}
}
impl<T: AsyncRead + AsyncWrite> Session<T> {
impl<T: SessionStream> Session<T> {
pub async fn is_allowed(&mut self) -> bool {
let throttles = if !self.data.rcpt_to.is_empty() {
&self.core.core.smtp.session.throttle.rcpt_to

View file

@ -21,11 +21,10 @@
* for more details.
*/
use common::AuthResult;
use common::{listener::SessionStream, AuthResult};
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;
@ -65,7 +64,7 @@ impl SaslToken {
}
}
impl<T: AsyncWrite + AsyncRead + Unpin> Session<T> {
impl<T: SessionStream> Session<T> {
pub async fn handle_sasl_response(
&mut self,
token: &mut SaslToken,

View file

@ -200,12 +200,7 @@ impl<T: SessionStream> Session<T> {
.unwrap_or_default()
.into();
if response.auth_mechanisms != 0 {
if !self.stream.is_tls() && !self.params.auth_plain_text {
response.auth_mechanisms &= !(AUTH_PLAIN | AUTH_LOGIN);
}
if response.auth_mechanisms != 0 {
response.capabilities |= EXT_AUTH;
}
response.capabilities |= EXT_AUTH;
}
}

View file

@ -22,11 +22,8 @@
*/
use common::{
config::{
server::ServerProtocol,
smtp::{session::Mechanism, *},
},
expr::{self, functions::ResolveVariable},
config::{server::ServerProtocol, smtp::session::Mechanism},
expr::{self, functions::ResolveVariable, *},
listener::SessionStream,
};
use smtp_proto::{
@ -111,11 +108,6 @@ impl<T: SessionStream> Session<T> {
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.params.auth_plain_text
{
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)
{
@ -407,7 +399,7 @@ impl<T: AsyncWrite + AsyncRead + Unpin> Session<T> {
}
}
impl<T: AsyncRead + AsyncWrite> ResolveVariable for Session<T> {
impl<T: SessionStream> ResolveVariable for Session<T> {
fn resolve_variable(&self, variable: u32) -> expr::Variable<'_> {
match variable {
V_RECIPIENT => self
@ -424,6 +416,13 @@ impl<T: AsyncRead + AsyncWrite> ResolveVariable for Session<T> {
.map(|r| r.domain.as_str())
.unwrap_or_default()
.into(),
V_RECIPIENTS => self
.data
.rcpt_to
.iter()
.map(|r| Variable::String(r.address_lcase.as_str().into()))
.collect::<Vec<_>>()
.into(),
V_SENDER => self
.data
.mail_from
@ -442,8 +441,12 @@ impl<T: AsyncRead + AsyncWrite> ResolveVariable for Session<T> {
V_AUTHENTICATED_AS => self.data.authenticated_as.as_str().into(),
V_LISTENER => self.instance.id.as_str().into(),
V_REMOTE_IP => self.data.remote_ip_str.as_str().into(),
V_REMOTE_PORT => self.data.remote_port.into(),
V_LOCAL_IP => self.data.local_ip_str.as_str().into(),
V_LOCAL_PORT => self.data.local_port.into(),
V_TLS => self.stream.is_tls().into(),
V_PRIORITY => self.data.priority.to_string().into(),
V_PROTOCOL => self.instance.protocol.as_str().into(),
_ => expr::Variable::default(),
}
}

View file

@ -46,7 +46,12 @@ impl SessionManager for SmtpSessionManager {
span: session.span,
stream: session.stream,
in_flight: vec![session.in_flight],
data: SessionData::new(session.local_ip, session.remote_ip, session.remote_port),
data: SessionData::new(
session.local_ip,
session.local_port,
session.remote_ip,
session.remote_port,
),
params: SessionParameters::default(),
};
@ -89,11 +94,13 @@ impl<T: SessionStream> Session<T> {
pub async fn init_conn(&mut self) -> bool {
self.eval_session_params().await;
let config = &self.core.core.smtp.session.connect;
// Sieve filtering
if let Some(script) = self
.core
.core
.eval_if::<String, _>(&self.core.core.smtp.session.connect.script, self)
.eval_if::<String, _>(&config.script, self)
.await
.and_then(|name| self.core.core.get_sieve_script(&name))
{
@ -115,7 +122,7 @@ impl<T: SessionStream> Session<T> {
self.hostname = self
.core
.core
.eval_if::<String, _>(&self.core.core.network.hostname, self)
.eval_if::<String, _>(&config.hostname, self)
.await
.unwrap_or_default();
if self.hostname.is_empty() {
@ -124,14 +131,14 @@ impl<T: SessionStream> Session<T> {
event = "hostname",
"No hostname configured, using 'localhost'."
);
self.hostname = "locahost".to_string();
self.hostname = "localhost".to_string();
}
// Obtain greeting
let greeting = self
.core
.core
.eval_if::<String, _>(&self.core.core.smtp.session.connect.greeting, self)
.eval_if::<String, _>(&config.greeting, self)
.await
.filter(|g| !g.is_empty())
.map(|g| format!("220 {}\r\n", g))

View file

@ -21,13 +21,13 @@
* for more details.
*/
use common::listener::SessionStream;
use directory::DirectoryError;
use tokio::io::{AsyncRead, AsyncWrite};
use crate::core::Session;
use std::fmt::Write;
impl<T: AsyncWrite + AsyncRead + Unpin> Session<T> {
impl<T: SessionStream> Session<T> {
pub async fn handle_vrfy(&mut self, address: String) -> Result<(), ()> {
match self
.core

View file

@ -726,7 +726,15 @@ impl DeliveryAttempt {
.core
.eval_if::<String, _>(&queue_config.hostname, &envelope)
.await
.unwrap_or_else(|| "localhost".to_string());
.filter(|s| !s.is_empty())
.unwrap_or_else(|| {
tracing::warn!(parent: &span,
context = "queue",
event = "ehlo",
"No outbound hostname configured, using 'local.host'."
);
"local.host".to_string()
});
let params = SessionParams {
span: &span,
core: &core,

View file

@ -26,7 +26,7 @@ use std::{
sync::Arc,
};
use common::{config::smtp::V_MX, expr::functions::ResolveVariable};
use common::expr::{functions::ResolveVariable, V_MX};
use mail_auth::{IpLookupStrategy, MX};
use rand::{seq::SliceRandom, Rng};

View file

@ -28,8 +28,7 @@ use std::{
};
use common::{
config::smtp::*,
expr::{self, functions::ResolveVariable},
expr::{self, functions::ResolveVariable, *},
listener::limiter::{ConcurrencyLimiter, InFlight},
};
use serde::{Deserialize, Serialize};
@ -245,6 +244,13 @@ impl<'x> ResolveVariable for QueueEnvelope<'x> {
V_SENDER => self.message.return_path_lcase.as_str().into(),
V_SENDER_DOMAIN => self.message.return_path_domain.as_str().into(),
V_RECIPIENT_DOMAIN => self.domain.into(),
V_RECIPIENTS => self
.message
.recipients
.iter()
.map(|r| Variable::from(r.address_lcase.as_str()))
.collect::<Vec<_>>()
.into(),
V_MX => self.mx.into(),
V_PRIORITY => self.message.priority.into(),
V_REMOTE_IP => self.remote_ip.to_string().into(),
@ -259,6 +265,12 @@ impl ResolveVariable for Message {
match variable {
V_SENDER => self.return_path_lcase.as_str().into(),
V_SENDER_DOMAIN => self.return_path_domain.as_str().into(),
V_RECIPIENTS => self
.recipients
.iter()
.map(|r| Variable::from(r.address_lcase.as_str()))
.collect::<Vec<_>>()
.into(),
V_PRIORITY => self.priority.into(),
_ => "".into(),
}

View file

@ -21,15 +21,15 @@
* for more details.
*/
use common::listener::SessionStream;
use mail_auth::{
common::verify::VerifySignature, AuthenticatedMessage, AuthenticationResults, DkimOutput,
};
use tokio::io::{AsyncRead, AsyncWrite};
use utils::config::Rate;
use crate::core::Session;
impl<T: AsyncWrite + AsyncRead + Unpin> Session<T> {
impl<T: SessionStream> Session<T> {
pub async fn send_dkim_report(
&self,
rcpt: &str,

View file

@ -24,7 +24,7 @@
use std::collections::hash_map::Entry;
use ahash::AHashMap;
use common::config::smtp::report::AggregateFrequency;
use common::{config::smtp::report::AggregateFrequency, listener::SessionStream};
use mail_auth::{
common::verify::VerifySignature,
dmarc::{self, URI},
@ -36,7 +36,6 @@ use store::{
write::{now, BatchBuilder, Bincode, QueueClass, ReportEvent, ValueClass},
Deserialize, IterateParams, Serialize, ValueKey,
};
use tokio::io::{AsyncRead, AsyncWrite};
use utils::config::Rate;
use crate::{
@ -53,7 +52,7 @@ pub struct DmarcFormat {
pub records: Vec<Record>,
}
impl<T: AsyncWrite + AsyncRead + Unpin> Session<T> {
impl<T: SessionStream> Session<T> {
#[allow(clippy::too_many_arguments)]
pub async fn send_dmarc_report(
&self,

View file

@ -21,13 +21,13 @@
* for more details.
*/
use common::listener::SessionStream;
use mail_auth::{report::AuthFailureType, AuthenticationResults, SpfOutput};
use tokio::io::{AsyncRead, AsyncWrite};
use utils::config::Rate;
use crate::core::Session;
impl<T: AsyncWrite + AsyncRead + Unpin> Session<T> {
impl<T: SessionStream> Session<T> {
pub async fn send_spf_report(
&self,
rcpt: &str,

View file

@ -55,8 +55,8 @@ impl SMTP {
.filter(params.message.as_deref().map_or(b"", |m| &m[..]))
.with_vars_env(params.variables)
.with_envelope_list(params.envelope)
.with_user_address(&self.core.sieve.from_addr)
.with_user_full_name(&self.core.sieve.from_name);
.with_user_address(&params.from_addr)
.with_user_full_name(&params.from_name);
let mut input = Input::script("__script", script);
let mut messages: Vec<Vec<u8>> = Vec::new();
@ -149,10 +149,10 @@ impl SMTP {
message_id,
} => {
// Build message
let return_path_lcase = self.core.sieve.return_path.to_lowercase();
let return_path_lcase = params.return_path.to_lowercase();
let return_path_domain = return_path_lcase.domain_part().to_string();
let mut message = self.new_message(
self.core.sieve.return_path.clone(),
params.return_path.clone(),
return_path_lcase,
return_path_domain,
);
@ -265,9 +265,9 @@ impl SMTP {
instance.message().raw_message().into()
};
if let Some(raw_message) = raw_message {
let headers = if !self.core.sieve.sign.is_empty() {
let headers = if !params.sign.is_empty() {
let mut headers = Vec::new();
for dkim in &self.core.sieve.sign {
for dkim in &params.sign {
if let Some(dkim) = self.core.get_dkim_signer(dkim) {
match dkim.sign(raw_message) {
Ok(signature) => {

View file

@ -139,6 +139,7 @@ impl<T: SessionStream> Session<T> {
pub async fn run_script(&self, script: Arc<Sieve>, params: ScriptParameters) -> ScriptResult {
let core = self.core.clone();
let span = self.span.clone();
let params = params.with_envelope(&self.core.core, self).await;
let handle = Handle::current();
self.core

View file

@ -24,7 +24,7 @@
use std::{borrow::Cow, sync::Arc};
use ahash::AHashMap;
use common::scripts::ScriptModification;
use common::{expr::functions::ResolveVariable, scripts::ScriptModification, Core};
use sieve::{runtime::Variable, Envelope};
pub mod envelope;
@ -48,6 +48,10 @@ pub struct ScriptParameters {
message: Option<Arc<Vec<u8>>>,
variables: AHashMap<Cow<'static, str>, Variable>,
envelope: Vec<(Envelope, Variable)>,
from_addr: String,
from_name: String,
return_path: String,
sign: Vec<String>,
#[cfg(feature = "test_mode")]
expected_variables: Option<AHashMap<String, Variable>>,
}
@ -60,9 +64,29 @@ impl ScriptParameters {
message: None,
#[cfg(feature = "test_mode")]
expected_variables: None,
from_addr: Default::default(),
from_name: Default::default(),
return_path: Default::default(),
sign: Default::default(),
}
}
pub async fn with_envelope(mut self, core: &Core, vars: &impl ResolveVariable) -> Self {
for (variable, expr) in [
(&mut self.from_addr, &core.sieve.from_addr),
(&mut self.from_name, &core.sieve.from_name),
(&mut self.return_path, &core.sieve.return_path),
] {
if let Some(value) = core.eval_if(expr, vars).await {
*variable = value;
}
}
if let Some(value) = core.eval_if(&core.sieve.sign, vars).await {
self.sign = value;
}
self
}
pub fn with_message(self, message: Arc<Vec<u8>>) -> Self {
Self {
message: message.into(),

View file

@ -60,7 +60,10 @@ impl FdbStore {
})
.ok()?;
if let Some(value) = config.property::<Duration>((&prefix, "transaction.timeout")) {
if let Some(value) = config
.property::<Option<Duration>>((&prefix, "transaction.timeout"))
.unwrap_or_default()
{
db.set_option(DatabaseOption::TransactionTimeout(value.as_millis() as i32))
.map_err(|err| {
config.new_build_error(
@ -80,7 +83,10 @@ impl FdbStore {
})
.ok()?;
}
if let Some(value) = config.property::<Duration>((&prefix, "transaction.max-retry-delay")) {
if let Some(value) = config
.property::<Option<Duration>>((&prefix, "transaction.max-retry-delay"))
.unwrap_or_default()
{
db.set_option(DatabaseOption::TransactionMaxRetryDelay(
value.as_millis() as i32
))

View file

@ -93,7 +93,13 @@ impl Stores {
}
if has_others {
Value::Text(value.to_string().into())
if value == "true" {
Value::Integer(1.into())
} else if value == "false" {
Value::Integer(0.into())
} else {
Value::Text(value.to_string().into())
}
} else if has_floats {
value
.parse()

View file

@ -49,7 +49,8 @@ impl MysqlStore {
.max_allowed_packet(config.property((&prefix, "max-allowed-packet")))
.wait_timeout(
config
.property::<Duration>((&prefix, "timeout"))
.property::<Option<Duration>>((&prefix, "timeout"))
.unwrap_or_default()
.map(|t| t.as_secs() as usize),
);
if let Some(port) = config.property((&prefix, "port")) {
@ -68,10 +69,16 @@ impl MysqlStore {
// Configure connection pool
let mut pool_min = PoolConstraints::default().min();
let mut pool_max = PoolConstraints::default().max();
if let Some(n_size) = config.property::<usize>((&prefix, "pool.min-connections")) {
if let Some(n_size) = config
.property::<usize>((&prefix, "pool.min-connections"))
.filter(|&n| n > 0)
{
pool_min = n_size;
}
if let Some(n_size) = config.property::<usize>((&prefix, "pool.max-connections")) {
if let Some(n_size) = config
.property::<usize>((&prefix, "pool.max-connections"))
.filter(|&n| n > 0)
{
pool_max = n_size;
}
opts = opts.pool_opts(

View file

@ -21,6 +21,8 @@
* for more details.
*/
use std::time::Duration;
use crate::{
backend::postgres::tls::MakeRustlsConnect, SUBSPACE_BITMAPS, SUBSPACE_BLOBS, SUBSPACE_COUNTERS,
SUBSPACE_INDEXES, SUBSPACE_LOGS, SUBSPACE_VALUES,
@ -46,7 +48,9 @@ impl PostgresStore {
cfg.user = config.value((&prefix, "user")).map(|s| s.to_string());
cfg.password = config.value((&prefix, "password")).map(|s| s.to_string());
cfg.port = config.property((&prefix, "port"));
cfg.connect_timeout = config.property((&prefix, "timeout"));
cfg.connect_timeout = config
.property::<Option<Duration>>((&prefix, "timeout"))
.unwrap_or_default();
cfg.manager = Some(ManagerConfig {
recycling_method: RecyclingMethod::Fast,
});

View file

@ -67,23 +67,81 @@ impl RedisStore {
return None;
}
Some(match config.value_require((&prefix, "redis-type"))? {
"single" => {
let client = Client::open(urls.into_iter().next().unwrap())
.map_err(|err| {
config.new_build_error(
prefix.as_str(),
format!("Failed to open Redis client: {err:?}"),
)
})
.ok()?;
let timeout = config
.property_or_default((&prefix, "timeout"), "10s")
.unwrap_or_else(|| Duration::from_secs(10));
Some(
match config.value((&prefix, "redis-type")).unwrap_or("single") {
"single" => {
let client = Client::open(urls.into_iter().next().unwrap())
.map_err(|err| {
config.new_build_error(
prefix.as_str(),
format!("Failed to open Redis client: {err:?}"),
)
})
.ok()?;
let timeout = config
.property_or_default((&prefix, "timeout"), "10s")
.unwrap_or_default();
Self {
pool: RedisPool::Single(
build_pool(config, &prefix, RedisConnectionManager { client, timeout })
Self {
pool: RedisPool::Single(
build_pool(config, &prefix, RedisConnectionManager { client, timeout })
.map_err(|err| {
config.new_build_error(
prefix.as_str(),
format!("Failed to build Redis pool: {err:?}"),
)
})
.ok()?,
),
}
}
"cluster" => {
let mut builder = ClusterClientBuilder::new(urls.into_iter());
if let Some(value) = config.property((&prefix, "user")) {
builder = builder.username(value);
}
if let Some(value) = config.property((&prefix, "password")) {
builder = builder.password(value);
}
if let Some(value) = config.property((&prefix, "retry.total")) {
builder = builder.retries(value);
}
if let Some(value) = config
.property::<Option<Duration>>((&prefix, "retry.max-wait"))
.unwrap_or_default()
{
builder = builder.max_retry_wait(value.as_millis() as u64);
}
if let Some(value) = config
.property::<Option<Duration>>((&prefix, "retry.min-wait"))
.unwrap_or_default()
{
builder = builder.min_retry_wait(value.as_millis() as u64);
}
if let Some(true) = config.property::<bool>((&prefix, "read-from-replicas")) {
builder = builder.read_from_replicas();
}
let client = builder
.build()
.map_err(|err| {
config.new_build_error(
prefix.as_str(),
format!("Failed to open Redis client: {err:?}"),
)
})
.ok()?;
let timeout = config
.property_or_default::<Duration>((&prefix, "timeout"), "10s")
.unwrap_or_else(|| Duration::from_secs(10));
Self {
pool: RedisPool::Cluster(
build_pool(
config,
&prefix,
RedisClusterConnectionManager { client, timeout },
)
.map_err(|err| {
config.new_build_error(
prefix.as_str(),
@ -91,66 +149,16 @@ impl RedisStore {
)
})
.ok()?,
),
),
}
}
}
"cluster" => {
let mut builder = ClusterClientBuilder::new(urls.into_iter());
if let Some(value) = config.property((&prefix, "user")) {
builder = builder.username(value);
invalid => {
let err = format!("Invalid Redis type {invalid:?}");
config.new_parse_error((&prefix, "redis-type"), err);
return None;
}
if let Some(value) = config.property((&prefix, "password")) {
builder = builder.password(value);
}
if let Some(value) = config.property((&prefix, "retry.total")) {
builder = builder.retries(value);
}
if let Some(value) = config.property::<Duration>((&prefix, "retry.max-wait")) {
builder = builder.max_retry_wait(value.as_millis() as u64);
}
if let Some(value) = config.property::<Duration>((&prefix, "retry.min-wait")) {
builder = builder.min_retry_wait(value.as_millis() as u64);
}
if let Some(true) = config.property::<bool>((&prefix, "read-from-replicas")) {
builder = builder.read_from_replicas();
}
let client = builder
.build()
.map_err(|err| {
config.new_build_error(
prefix.as_str(),
format!("Failed to open Redis client: {err:?}"),
)
})
.ok()?;
let timeout = config
.property_or_default((&prefix, "timeout"), "10s")
.unwrap_or_else(|| Duration::from_secs(10));
Self {
pool: RedisPool::Cluster(
build_pool(
config,
&prefix,
RedisClusterConnectionManager { client, timeout },
)
.map_err(|err| {
config.new_build_error(
prefix.as_str(),
format!("Failed to build Redis pool: {err:?}"),
)
})
.ok()?,
),
}
}
invalid => {
let err = format!("Invalid Redis type {invalid:?}");
config.new_parse_error((&prefix, "redis-type"), err);
return None;
}
})
},
)
}
}
@ -168,12 +176,19 @@ fn build_pool<M: Manager>(
)
.create_timeout(
config
.property_or_default::<Duration>((prefix, "pool.create-timeout"), "30s")
.unwrap_or_else(|| Duration::from_secs(30))
.into(),
.property_or_default::<Option<Duration>>((prefix, "pool.create-timeout"), "30s")
.unwrap_or_default(),
)
.wait_timeout(
config
.property_or_default::<Option<Duration>>((prefix, "pool.wait-timeout"), "30s")
.unwrap_or_default(),
)
.recycle_timeout(
config
.property_or_default::<Option<Duration>>((prefix, "pool.recycle-timeout"), "30s")
.unwrap_or_default(),
)
.wait_timeout(config.property_or_default((prefix, "pool.wait-timeout"), "30s"))
.recycle_timeout(config.property_or_default((prefix, "pool.recycle-timeout"), "30s"))
.build()
.map_err(|err| {
format!(

View file

@ -207,17 +207,18 @@ impl<'x, 'y> TomlParser<'x, 'y> {
#[allow(clippy::while_let_on_iterator)]
fn key(&mut self, mut key: String, in_curly: bool) -> Result<(String, char)> {
let start_key_len = key.len();
while let Some(ch) = self.iter.next() {
match ch {
'=' => {
if !key.is_empty() {
if start_key_len != key.len() {
return Ok((key, ch));
} else {
return Err(format!("Empty key at line: {}", self.line));
}
}
',' | '}' if in_curly => {
if !key.is_empty() {
if start_key_len != key.len() {
return Ok((key, ch));
} else {
return Err(format!("Empty key at line: {}", self.line));
@ -236,7 +237,7 @@ impl<'x, 'y> TomlParser<'x, 'y> {
}
'\n' => {
return Err(format!(
"Unexpected end of line at line: {}",
"Unexpected end of line while parsing quoted key at line: {}",
self.line
));
}
@ -249,7 +250,14 @@ impl<'x, 'y> TomlParser<'x, 'y> {
}
' ' | '\t' | '\r' => (),
'\n' => {
return Err(format!("Unexpected end of line at line: {}", self.line));
if start_key_len == key.len() {
self.line += 1;
} else {
return Err(format!(
"Unexpected end of line while parsing key {:?} at line: {}",
key, self.line
));
}
}
_ => {
return Err(format!(

View file

@ -60,13 +60,7 @@ impl Config {
let key = key.as_key();
let value = match self.keys.get(&key) {
Some(value) => value.as_str(),
None => {
self.warnings.insert(
key.clone(),
ConfigWarning::AppliedDefault(default.to_string()),
);
default
}
None => default,
};
match T::parse_value(value) {
Ok(value) => Some(value),
@ -80,16 +74,13 @@ impl Config {
pub fn property_or_else<T: ParseValue>(
&mut self,
key: impl AsKey,
default: impl AsKey,
or_else: impl AsKey,
default: &str,
) -> Option<T> {
let key = key.as_key();
let value = match self.value_or_else(key.as_str(), default.clone()) {
let value = match self.value_or_else(key.as_str(), or_else.clone()) {
Some(value) => value,
None => {
self.warnings
.insert(default.as_key(), ConfigWarning::Missing);
return None;
}
None => default,
};
match T::parse_value(value) {
@ -216,10 +207,10 @@ impl Config {
}
}
pub fn value_or_else(&self, key: impl AsKey, default: impl AsKey) -> Option<&str> {
pub fn value_or_else(&self, key: impl AsKey, or_else: impl AsKey) -> Option<&str> {
self.keys
.get(&key.as_key())
.or_else(|| self.keys.get(&default.as_key()))
.or_else(|| self.keys.get(&or_else.as_key()))
.map(|s| s.as_str())
}
@ -260,17 +251,6 @@ impl Config {
self.keys.remove(key)
}
pub fn value_or_warn(&mut self, key: impl AsKey) -> Option<&str> {
let key = key.as_key();
match self.keys.get(&key) {
Some(value) => Some(value.as_str()),
None => {
self.warnings.insert(key, ConfigWarning::Missing);
None
}
}
}
pub fn new_parse_error(&mut self, key: impl AsKey, details: impl Into<String>) {
self.errors
.insert(key.as_key(), ConfigError::Parse(details.into()));
@ -523,6 +503,12 @@ impl ParseValue for Rate {
}
}
impl ParseValue for () {
fn parse_value(_: &str) -> super::Result<Self> {
Ok(())
}
}
pub trait AsKey: Clone {
fn as_key(&self) -> String;
fn as_prefix(&self) -> String;

View file

@ -66,11 +66,18 @@ impl From<&str> for PublicSuffix {
impl PublicSuffix {
#[allow(unused_variables)]
pub async fn parse(config: &mut Config, key: &str) -> PublicSuffix {
let values = config
let mut values = config
.values(key)
.map(|(_, s)| s.to_string())
.collect::<Vec<_>>();
let has_values = !values.is_empty();
if values.is_empty() {
values = vec![
"https://publicsuffix.org/list/public_suffix_list.dat".to_string(),
"https://raw.githubusercontent.com/publicsuffix/list/master/public_suffix_list.dat"
.to_string(),
]
}
for (idx, value) in values.into_iter().enumerate() {
let bytes = if value.starts_with("https://") || value.starts_with("http://") {
let result = match reqwest::get(&value).await {
@ -157,14 +164,7 @@ impl PublicSuffix {
}
#[cfg(not(feature = "test_mode"))]
config.new_build_error(
key,
if has_values {
"Failed to parse public suffixes from any source."
} else {
"No public suffixes list was specified."
},
);
config.new_build_error(key, "Failed to parse public suffixes from any source.");
PublicSuffix::default()
}

94
resources/config/build.py Normal file
View file

@ -0,0 +1,94 @@
import os
# Define the scripts and their component files
scripts = {
"spam-filter": [
"config.sieve",
"prelude.sieve",
"from.sieve",
"recipient.sieve",
"subject.sieve",
"replyto.sieve",
"date.sieve",
"messageid.sieve",
"received.sieve",
"headers.sieve",
"bounce.sieve",
"html.sieve",
"mime.sieve",
"dmarc.sieve",
"ip.sieve",
"helo.sieve",
"replies_in.sieve",
"spamtrap.sieve",
"bayes_classify.sieve",
"url.sieve",
"rbl.sieve",
"pyzor.sieve",
"composites.sieve",
"scores.sieve",
"reputation.sieve",
"epilogue.sieve"
],
"track-replies": [
"config.sieve",
"replies_out.sieve"
],
"greylist": [
"config.sieve",
"greylist.sieve"
]
}
script_names = {
"spam-filter" : "Spam Filter",
"track-replies" : "Track Replies",
"greylist" : "Greylisting"
}
maps = ["scores.map",
"allow_dmarc.list",
"allow_domains.list",
"allow_spf_dkim.list",
"domains_disposable.list",
"domains_free.list",
"mime_types.map",
"url_redirectors.list"]
def read_and_concatenate(files):
content = ""
for file in files:
with open(os.path.join("./spamfilter/scripts", file), "r", encoding="utf-8") as f:
content += "\n#### Script " + file + " ####\n\n"
content += f.read() + "\n"
return content
def read_file(file):
with open(file, "r", encoding="utf-8") as f:
return f.read() + "\n"
def build_spam_filters(scripts):
spam_filter = read_file("./spamfilter/settings.toml")
for script_name, file_list in scripts.items():
script_content = read_and_concatenate(file_list).replace("'''", "\\'\\'\\'")
script_description = script_names[script_name]
spam_filter += f"[sieve.trusted.scripts.{script_name}]\nname = \"{script_description}\"\ncontents = '''\n{script_content}'''\n\n"
spam_filter += "\n[lookup]\n"
for map in maps :
with open(os.path.join("./spamfilter/maps", map), "r", encoding="utf-8") as f:
spam_filter += f.read() + "\n"
return spam_filter
def main():
spam_filter = build_spam_filters(scripts)
with open("spamfilter.toml", "w", encoding="utf-8") as toml_file:
toml_file.write(spam_filter)
config = read_file("./minimal.toml") + read_file("./security.toml") + spam_filter
with open("config.toml", "w", encoding="utf-8") as toml_file:
toml_file.write(config)
print("Stalwart TOML configuration files have been generated.")
if __name__ == "__main__":
main()

View file

@ -1,35 +0,0 @@
#############################################
# Cache configuration
#############################################
[cache]
capacity = 512
shard = 32
[cache.session]
ttl = "1h"
[cache.account]
size = 2048
[cache.mailbox]
size = 2048
[cache.thread]
size = 2048
[cache.bayes]
capacity = 8192
[cache.bayes.ttl]
positive = "1h"
negative = "1h"
[cache.resolver]
txt = 2048
mx = 1024
ipv4 = 1024
ipv6 = 1024
ptr = 1024
tlsa = 1024
mta-sts = 1024

View file

@ -1,37 +0,0 @@
#############################################
# Server configuration
#############################################
[server]
hostname = "%{HOST}%"
max-connections = 8192
#[server.proxy]
#trusted-networks = ["127.0.0.0/8", "::1", "10.0.0.0/8"]
[authentication]
fail2ban = "100/1d"
rate-limit = "10/1m"
[server.run-as]
user = "stalwart-mail"
group = "stalwart-mail"
[server.socket]
nodelay = true
reuse-addr = true
#reuse-port = true
backlog = 1024
#ttl = 3600
#send-buffer-size = 65535
#recv-buffer-size = 65535
#linger = 1
#tos = 1
[global]
#thread-pool = 8
[server.http]
#headers = ["Access-Control-Allow-Origin: *",
# "Access-Control-Allow-Methods: POST, GET, PATCH, PUT, DELETE, HEAD, OPTIONS",
# "Access-Control-Allow-Headers: Authorization, Content-Type, Accept, X-Requested-With"]

View file

@ -1,73 +0,0 @@
#############################################
# Sieve untrusted runtime configuration
#############################################
[sieve.untrusted]
disable-capabilities = []
notification-uris = ["mailto"]
protected-headers = ["Original-Subject", "Original-From", "Received", "Auto-Submitted"]
[sieve.untrusted.limits]
name-length = 512
max-scripts = 256
script-size = 102400
string-length = 4096
variable-name-length = 32
variable-size = 4096
nested-blocks = 15
nested-tests = 15
nested-foreverypart = 3
match-variables = 30
local-variables = 128
header-size = 1024
includes = 3
nested-includes = 3
cpu = 5000
redirects = 1
received-headers = 10
outgoing-messages = 3
[sieve.untrusted.vacation]
default-subject = "Automated reply"
subject-prefix = "Auto: "
[sieve.untrusted.default-expiry]
vacation = "30d"
duplicate = "7d"
#############################################
# Sieve trusted runtime configuration
#############################################
[sieve.trusted]
from-name = "Automated Message"
from-addr = "no-reply@%{DEFAULT_DOMAIN}%"
return-path = ""
#hostname = "%{HOST}%"
no-capability-check = true
sign = ["rsa"]
[sieve.trusted.limits]
redirects = 3
out-messages = 5
received-headers = 50
cpu = 1048576
nested-includes = 5
duplicate-expiry = "7d"
[sieve.trusted.scripts]
#connect = '''require ["variables", "extlists", "reject"];
# if string :list "${env.remote_ip}" "default/blocked-ips" {
# reject "Your IP '${env.remote_ip}' is not welcomed here.";
# }'''
#ehlo = '''require ["variables", "extlists", "reject"];
# if string :list "${env.helo_domain}" "default/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.";
# }'''

View file

@ -1,20 +0,0 @@
#############################################
# Storage configuration
#############################################
[storage]
data = "%{DEFAULT_STORE}%"
fts = "%{DEFAULT_STORE}%"
blob = "%{DEFAULT_STORE}%"
lookup = "%{DEFAULT_STORE}%"
directory = "%{DEFAULT_DIRECTORY}%"
[storage.encryption]
enable = true
append = false
[storage.full-text]
default-language = "en"
[storage.cluster]
node-id = 1

View file

@ -1,30 +0,0 @@
#############################################
# TLS default configuration
#############################################
[server.tls]
enable = true
implicit = false
timeout = "1m"
certificate = "default"
#acme = "letsencrypt"
#protocols = ["TLSv1.2", "TLSv1.3"]
#ciphers = [ "TLS13_AES_256_GCM_SHA384", "TLS13_AES_128_GCM_SHA256",
# "TLS13_CHACHA20_POLY1305_SHA256", "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
# "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256",
# "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
# "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256"]
ignore-client-order = true
[acme."letsencrypt"]
directory = "https://acme-v02.api.letsencrypt.org/directory"
#directory = "https://acme-staging-v02.api.letsencrypt.org/directory"
contact = ["postmaster@%{DEFAULT_DOMAIN}%"]
cache = "%{BASE_PATH}%/etc/acme"
port = 443
renew-before = "30d"
[certificate."default"]
sni-subjects = []
cert = "file://__CERT_PATH__"
private-key = "file://__PK_PATH__"

View file

@ -1,24 +0,0 @@
#############################################
# Tracing & logging configuration
#############################################
[tracing."stdout"]
method = "stdout"
level = "trace"
enable = false
[tracing."ot"]
method = "open-telemetry"
transport = "http"
endpoint = "https://127.0.0.1/otel"
headers = ["Authorization: <place_auth_here>"]
level = "debug"
enable = false
[tracing."log"]
method = "log"
path = "%{BASE_PATH}%/logs"
prefix = "stalwart.log"
rotate = "daily"
level = "info"
enable = true

View file

@ -1,52 +0,0 @@
#############################################
# Stalwart Mail Server Configuration File
#############################################
[macros]
host = "__HOST__"
default_domain = "__DOMAIN__"
base_path = "__BASE_PATH__"
default_directory = "__DIRECTORY__"
default_store = "__STORE__"
[include]
files = [ "%{BASE_PATH}%/etc/common/server.toml",
"%{BASE_PATH}%/etc/common/tls.toml",
"%{BASE_PATH}%/etc/common/store.toml",
"%{BASE_PATH}%/etc/common/tracing.toml",
"%{BASE_PATH}%/etc/common/sieve.toml",
"%{BASE_PATH}%/etc/common/cache.toml",
"%{BASE_PATH}%/etc/directory/imap.toml",
"%{BASE_PATH}%/etc/directory/internal.toml",
"%{BASE_PATH}%/etc/directory/ldap.toml",
"%{BASE_PATH}%/etc/directory/lmtp.toml",
"%{BASE_PATH}%/etc/directory/memory.toml",
"%{BASE_PATH}%/etc/directory/sql.toml",
"%{BASE_PATH}%/etc/store/elasticsearch.toml",
"%{BASE_PATH}%/etc/store/fs.toml",
"%{BASE_PATH}%/etc/store/foundationdb.toml",
"%{BASE_PATH}%/etc/store/mysql.toml",
"%{BASE_PATH}%/etc/store/postgresql.toml",
"%{BASE_PATH}%/etc/store/redis.toml",
"%{BASE_PATH}%/etc/store/rocksdb.toml",
"%{BASE_PATH}%/etc/store/s3.toml",
"%{BASE_PATH}%/etc/store/sqlite.toml",
"%{BASE_PATH}%/etc/imap/listener.toml",
"%{BASE_PATH}%/etc/imap/settings.toml",
"%{BASE_PATH}%/etc/jmap/auth.toml",
"%{BASE_PATH}%/etc/jmap/listener.toml",
"%{BASE_PATH}%/etc/jmap/oauth.toml",
"%{BASE_PATH}%/etc/jmap/protocol.toml",
"%{BASE_PATH}%/etc/jmap/push.toml",
"%{BASE_PATH}%/etc/jmap/ratelimit.toml",
"%{BASE_PATH}%/etc/jmap/websockets.toml",
"%{BASE_PATH}%/etc/smtp/auth.toml",
"%{BASE_PATH}%/etc/smtp/listener.toml",
"%{BASE_PATH}%/etc/smtp/milter.toml",
"%{BASE_PATH}%/etc/smtp/queue.toml",
"%{BASE_PATH}%/etc/smtp/remote.toml",
"%{BASE_PATH}%/etc/smtp/report.toml",
"%{BASE_PATH}%/etc/smtp/resolver.toml",
"%{BASE_PATH}%/etc/smtp/session.toml",
"%{BASE_PATH}%/etc/smtp/signature.toml",
"%{BASE_PATH}%/etc/smtp/spamfilter.toml" ]

View file

@ -1,29 +0,0 @@
#############################################
# IMAP Directory configuration
#############################################
[directory."imap"]
type = "imap"
host = "127.0.0.1"
port = 993
disable = true
[directory."imap".pool]
max-connections = 10
[directory."imap".pool.timeout]
create = "30s"
wait = "30s"
recycle = "30s"
[directory."imap".tls]
enable = true
allow-invalid-certs = true
[directory."imap".cache]
entries = 500
ttl = {positive = '1h', negative = '10m'}
[directory."imap".lookup]
domains = ["%{DEFAULT_DOMAIN}%"]

View file

@ -1,20 +0,0 @@
#############################################
# Internal Directory configuration
#############################################
[directory."internal"]
type = "internal"
store = "%{DEFAULT_STORE}%"
disable = true
[directory."internal".options]
catch-all = true
#catch-all = [ { if = "matches('(.+)@(.+)$', address)", then = "'info@' + $2" },
# { else = false } ]
subaddressing = true
#subaddressing = [ { if = "matches('^([^.]+)\\.([^.]+)@(.+)$', address)", then = "$2 + '@' + $3" },
# { else = false } ]
[directory."internal".cache]
entries = 500
ttl = {positive = '1h', negative = '10m'}

View file

@ -1,60 +0,0 @@
#############################################
# LDAP Directory configuration
#############################################
[directory."ldap"]
type = "ldap"
url = "ldap://localhost:389"
base-dn = "dc=example,dc=org"
timeout = "30s"
disable = true
[directory."ldap".bind]
dn = "cn=serviceuser,ou=svcaccts,dc=example,dc=org"
secret = "mysecret"
[directory."ldap".bind.auth]
enable = false
dn = "cn=?,ou=svcaccts,dc=example,dc=org"
[directory."ldap".tls]
enable = false
allow-invalid-certs = false
[directory."ldap".cache]
entries = 500
ttl = {positive = '1h', negative = '10m'}
[directory."ldap".options]
catch-all = true
#catch-all = [ { if = "matches('(.+)@(.+)$', address)", then = "'info@' + $2" },
# { else = false } ]
subaddressing = true
#subaddressing = [ { if = "matches('^([^.]+)\\.([^.]+)@(.+)$', address)", then = "$2 + '@' + $3" },
# { else = false } ]
[directory."ldap".pool]
max-connections = 10
[directory."ldap".pool.timeout]
create = "30s"
wait = "30s"
recycle = "30s"
[directory."ldap".filter]
name = "(&(|(objectClass=posixAccount)(objectClass=posixGroup))(uid=?))"
email = "(&(|(objectClass=posixAccount)(objectClass=posixGroup))(|(mail=?)(mailAlias=?)(mailList=?)))"
verify = "(&(|(objectClass=posixAccount)(objectClass=posixGroup))(|(mail=*?*)(mailAlias=*?*)))"
expand = "(&(|(objectClass=posixAccount)(objectClass=posixGroup))(mailList=?))"
domains = "(&(|(objectClass=posixAccount)(objectClass=posixGroup))(|(mail=*@?)(mailAlias=*@?)))"
[directory."ldap".attributes]
name = "uid"
class = "objectClass"
description = ["principalName", "description"]
secret = "userPassword"
groups = ["memberOf", "otherGroups"]
email = "mail"
email-alias = "mailAlias"
quota = "diskQuota"

View file

@ -1,33 +0,0 @@
#############################################
# LMTP Directory configuration
#############################################
[directory."lmtp"]
type = "lmtp"
host = "127.0.0.1"
port = 11200
disable = true
[directory."lmtp".limits]
auth-errors = 3
rcpt = 5
[directory."lmtp".pool]
max-connections = 10
[directory."lmtp".pool.timeout]
create = "30s"
wait = "30s"
recycle = "30s"
[directory."lmtp".tls]
enable = false
allow-invalid-certs = true
[directory."lmtp".cache]
entries = 500
ttl = {positive = '1h', negative = '10m'}
[directory."lmtp".lookup]
domains = ["%{DEFAULT_DOMAIN}%"]

View file

@ -1,59 +0,0 @@
#############################################
# In-Memory Directory configuration
#############################################
[directory."memory"]
type = "memory"
disable = true
[directory."memory".options]
catch-all = true
#catch-all = [ { if = "matches('(.+)@(.+)$', address)", then = "'info@' + $2" },
# { else = false } ]
subaddressing = true
#subaddressing = [ { if = "matches('^([^.]+)\\.([^.]+)@(.+)$', address)", then = "$2 + '@' + $3" },
# { else = false } ]
[[directory."memory".principals]]
name = "admin"
class = "admin"
description = "Superuser"
secret = "changeme"
email = ["postmaster@%{DEFAULT_DOMAIN}%"]
[[directory."memory".principals]]
name = "john"
class = "individual"
description = "John Doe"
secret = "12345"
email = ["john@%{DEFAULT_DOMAIN}%", "jdoe@%{DEFAULT_DOMAIN}%", "john.doe@%{DEFAULT_DOMAIN}%"]
email-list = ["info@%{DEFAULT_DOMAIN}%"]
member-of = ["sales"]
[[directory."memory".principals]]
name = "jane"
class = "individual"
description = "Jane Doe"
secret = "abcde"
email = ["jane@%{DEFAULT_DOMAIN}%", "jane.doe@%{DEFAULT_DOMAIN}%"]
email-list = ["info@%{DEFAULT_DOMAIN}%"]
member-of = ["sales", "support"]
[[directory."memory".principals]]
name = "bill"
class = "individual"
description = "Bill Foobar"
secret = "$2y$05$bvIG6Nmid91Mu9RcmmWZfO5HJIMCT8riNW0hEp8f6/FuA2/mHZFpe"
quota = 50000000
email = ["bill@%{DEFAULT_DOMAIN}%", "bill.foobar@%{DEFAULT_DOMAIN}%"]
email-list = ["info@%{DEFAULT_DOMAIN}%"]
[[directory."memory".principals]]
name = "sales"
class = "group"
description = "Sales Team"
[[directory."memory".principals]]
name = "support"
class = "group"
description = "Support Team"

View file

@ -1,26 +0,0 @@
#############################################
# SQL Directory configuration
#############################################
[directory."sql"]
type = "sql"
store = "__SQL_STORE__"
disable = true
[directory."sql".options]
catch-all = true
#catch-all = [ { if = "matches('(.+)@(.+)$', address)", then = "'info@' + $2" },
# { else = false } ]
subaddressing = true
#subaddressing = [ { if = "matches('^([^.]+)\\.([^.]+)@(.+)$', address)", then = "$2 + '@' + $3" },
# { else = false } ]
[directory."sql".cache]
entries = 500
ttl = {positive = '1h', negative = '10m'}
[directory."sql".columns]
class = "type"
secret = "secret"
description = "description"
quota = "quota"

View file

@ -1,16 +0,0 @@
#############################################
# IMAP server listeners configuration
#############################################
[server.listener."imap"]
bind = ["[::]:143"]
protocol = "imap"
[server.listener."imaptls"]
bind = ["[::]:993"]
protocol = "imap"
tls.implicit = true
[server.listener."sieve"]
bind = ["[::]:4190"]
protocol = "managesieve"

View file

@ -1,22 +0,0 @@
#############################################
# IMAP server settings
#############################################
[imap.request]
max-size = 52428800
[imap.auth]
max-failures = 3
allow-plain-text = false
[imap.folders.name]
shared = "Shared Folders"
[imap.timeout]
authenticated = "30m"
anonymous = "1m"
idle = "30m"
[imap.rate-limit]
requests = "2000/1m"
concurrent = 6

View file

@ -1,6 +0,0 @@
#############################################
# JMAP authentication & session configuration
#############################################
[jmap.session.purge]
frequency = "15 * *"

View file

@ -1,14 +0,0 @@
#############################################
# JMAP server listener configuration
#############################################
[server.listener."jmap"]
protocol = "jmap"
bind = ["[::]:443"]
url = "https://%{HOST}%"
[server.listener."jmap".tls]
implicit = true
#bind = ["[::]:8080"]
#url = "https://%{HOST}%:8080"

View file

@ -1,16 +0,0 @@
#############################################
# JMAP OAuth server configuration
#############################################
[oauth]
key = "__OAUTH_KEY__"
[oauth.auth]
max-attempts = 3
[oauth.expiry]
user-code = "30m"
auth-code = "10m"
token = "1h"
refresh-token = "30d"
refresh-token-renew = "4d"

View file

@ -1,43 +0,0 @@
#############################################
# JMAP protocol configuration
#############################################
[jmap.protocol.get]
max-objects = 500
[jmap.protocol.set]
max-objects = 500
[jmap.protocol.request]
max-concurrent = 4
max-size = 10000000
max-calls = 16
[jmap.protocol.query]
max-results = 5000
[jmap.protocol.upload]
max-size = 50000000
max-concurrent = 4
ttl = "1h"
[jmap.protocol.upload.quota]
files = 1000
size = 50000000
[jmap.protocol.changes]
max-results = 5000
[jmap.mailbox]
max-depth = 10
max-name-length = 255
[jmap.email]
max-attachment-size = 50000000
max-size = 75000000
[jmap.email.parse]
max-items = 10
[jmap.principal]
allow-lookups = true

View file

@ -1,21 +0,0 @@
#############################################
# JMAP Push & EventSource configuration
#############################################
[jmap.push]
max-total = 100
throttle = "1ms"
[jmap.push.attempts]
interval = "1m"
max = 3
[jmap.push.retry]
interval = "1s"
[jmap.push.timeout]
request = "10s"
verify = "1s"
[jmap.event-source]
throttle = "1s"

View file

@ -1,9 +0,0 @@
#############################################
# JMAP server rate limiter configuration
#############################################
[jmap.rate-limit]
account = "1000/1m"
anonymous = "100/1m"
use-forwarded = false

View file

@ -1,8 +0,0 @@
#############################################
# JMAP WebSockets server configuration
#############################################
[jmap.web-sockets]
throttle = "1s"
timeout = "10m"
heartbeat = "1m"

View file

@ -0,0 +1,72 @@
#############################################
# Stalwart Mail Server Configuration File
#############################################
[server.listener."smtp"]
bind = ["[::]:25"]
protocol = "smtp"
[server.listener."submission"]
bind = ["[::]:587"]
protocol = "smtp"
[server.listener."submissions"]
bind = ["[::]:465"]
protocol = "smtp"
tls.implicit = true
[server.listener."imap"]
bind = ["[::]:143"]
protocol = "imap"
[server.listener."imaptls"]
bind = ["[::]:993"]
protocol = "imap"
tls.implicit = true
[server.listener."sieve"]
bind = ["[::]:4190"]
protocol = "managesieve"
[server.listener."https"]
protocol = "http"
bind = ["[::]:443"]
tls.implicit = true
[storage]
data = "rocksdb"
fts = "rocksdb"
blob = "rocksdb"
lookup = "rocksdb"
directory = "internal"
[store."rocksdb"]
type = "rocksdb"
path = "%{env:STALWART_PATH}%/data"
compression = "lz4"
[directory."internal"]
type = "internal"
store = "rocksdb"
[lookup.default]
domain = "%{env:DOMAIN}%"
hostname = "%{env:HOSTNAME}%"
[oauth]
key = "%{env:OAUTH_KEY}%"
[tracer."stdout"]
type = "stdout"
level = "info"
ansi = false
enable = true
#[server.run-as]
#user = "stalwart-mail"
#group = "stalwart-mail"
[server.http]
headers = ["Access-Control-Allow-Origin: *",
"Access-Control-Allow-Methods: POST, GET, PATCH, PUT, DELETE, HEAD, OPTIONS",
"Access-Control-Allow-Headers: Authorization, Content-Type, Accept, X-Requested-With"]

View file

@ -0,0 +1,19 @@
[[queue.quota]]
messages = 100000
size = 10737418240 # 10gb
enable = true
[[queue.throttle]]
key = ["rcpt_domain"]
concurrency = 5
enable = true
[[session.throttle]]
key = ["remote_ip"]
concurrency = 5
enable = true
[[session.throttle]]
key = ["sender_domain", "rcpt"]
rate = "25/1h"
enable = true

View file

@ -1,28 +0,0 @@
#############################################
# SMTP DMARC, DKIM, SPF, ARC & IpRev
#############################################
[auth.iprev]
verify = [ { if = "listener = 'smtp'", then = "relaxed" },
{ else = "disable" } ]
[auth.dkim]
verify = "relaxed"
sign = [ { if = "listener != 'smtp'", then = "['rsa']" },
{ else = false } ]
[auth.spf.verify]
ehlo = [ { if = "listener = 'smtp'", then = "relaxed" },
{ else = "disable" } ]
mail-from = [ { if = "listener = 'smtp'", then = "relaxed" },
{ else = "disable" } ]
[auth.arc]
verify = "relaxed"
seal = "['rsa']"
[auth.dmarc]
verify = [ { if = "listener = 'smtp'", then = "relaxed" },
{ else = "disable" } ]

View file

@ -1,21 +0,0 @@
#############################################
# SMTP server listener configuration
#############################################
[server.listener."smtp"]
bind = ["[::]:25"]
#greeting = "Stalwart SMTP at your service"
protocol = "smtp"
[server.listener."submission"]
bind = ["[::]:587"]
protocol = "smtp"
[server.listener."submissions"]
bind = ["[::]:465"]
protocol = "smtp"
tls.implicit = true
#[server.listener."management"]
#bind = ["127.0.0.1:8080"]
#protocol = "http"

View file

@ -1,26 +0,0 @@
#############################################
# SMTP inbound Milter configuration
#############################################
#[session.data.milter."rspamd"]
#enable = [ { if = "listener = 'smtp'", then = true },
# { else = false } ]
#hostname = "127.0.0.1"
#port = 11332
#tls = false
#allow-invalid-certs = false
#[session.data.milter."rspamd".timeout]
#connect = "30s"
#command = "30s"
#data = "60s"
#[session.data.milter."rspamd".options]
#tempfail-on-error = true
#max-response-size = 52428800 # 50mb
#version = 6
#[session.data.pipe."spam-assassin"]
#command = "spamc"
#arguments = []
#timeout = "10s"

View file

@ -1,49 +0,0 @@
#############################################
# SMTP server queue configuration
#############################################
[queue.schedule]
retry = "[2m, 5m, 10m, 15m, 30m, 1h, 2h]"
notify = "[1d, 3d]"
expire = "5d"
[queue.outbound]
#hostname = "%{HOST}%"
next-hop = [ { if = "is_local_domain('%{DEFAULT_DIRECTORY}%', rcpt_domain)", then = "'local'" },
{ else = false } ]
ip-strategy = "ipv4_then_ipv6"
[queue.outbound.tls]
dane = "optional"
mta-sts = "optional"
starttls = "require"
allow-invalid-certs = false
#[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 = "sender_domain = 'foobar.org'"
#key = ["rcpt"]
messages = 100000
size = 10737418240 # 10gb
[[queue.throttle]]
key = ["rcpt_domain"]
#rate = "100/1h"
concurrency = 5

View file

@ -1,18 +0,0 @@
#############################################
# SMTP remote servers configuration
#############################################
[remote."local"]
address = "127.0.0.1"
port = 11200
protocol = "lmtp"
concurrency = 10
timeout = "1m"
[remote."local".tls]
implicit = false
allow-invalid-certs = true
#[remote."local".auth]
#username = ""
#secret = ""

View file

@ -1,55 +0,0 @@
#############################################
# SMTP reporting configuration
#############################################
[report]
#submitter = "'%{HOST}%'"
[report.analysis]
addresses = ["dmarc@*", "abuse@*", "postmaster@*"]
forward = true
store = "30d"
[report.dsn]
from-name = "'Mail Delivery Subsystem'"
from-address = "'MAILER-DAEMON@%{DEFAULT_DOMAIN}%'"
sign = "['rsa']"
[report.dkim]
from-name = "'Report Subsystem'"
from-address = "'noreply-dkim@%{DEFAULT_DOMAIN}%'"
subject = "'DKIM Authentication Failure Report'"
sign = "['rsa']"
send = "[1, 1d]"
[report.spf]
from-name = "'Report Subsystem'"
from-address = "'noreply-spf@%{DEFAULT_DOMAIN}%'"
subject = "'SPF Authentication Failure Report'"
send = "[1, 1d]"
sign = "['rsa']"
[report.dmarc]
from-name = "'Report Subsystem'"
from-address = "'noreply-dmarc@%{DEFAULT_DOMAIN}%'"
subject = "'DMARC Authentication Failure Report'"
send = "[1, 1d]"
sign = "['rsa']"
[report.dmarc.aggregate]
from-name = "'DMARC Report'"
from-address = "'noreply-dmarc@%{DEFAULT_DOMAIN}%'"
org-name = "'%{DEFAULT_DOMAIN}%'"
#contact-info = ""
send = "daily"
max-size = 26214400 # 25mb
sign = "['rsa']"
[report.tls.aggregate]
from-name = "'TLS Report'"
from-address = "'noreply-tls@%{DEFAULT_DOMAIN}%'"
org-name = "'%{DEFAULT_DOMAIN}%'"
#contact-info = ""
send = "daily"
max-size = 26214400 # 25 mb
sign = "['rsa']"

View file

@ -1,13 +0,0 @@
#############################################
# SMTP server resolver configuration
#############################################
[resolver]
type = "system"
#preserve-intermediates = true
concurrency = 2
timeout = "5s"
attempts = 2
try-tcp-on-error = true
public-suffix = ["https://publicsuffix.org/list/public_suffix_list.dat",
"file://%{BASE_PATH}%/etc/spamfilter/maps/suffix_list.dat.gz"]

View file

@ -1,100 +0,0 @@
#############################################
# SMTP inbound session configuration
#############################################
[session]
timeout = "5m"
transfer-limit = 262144000 # 250 MB
duration = "10m"
[session.connect]
#script = "'connect'"
[session.ehlo]
require = true
reject-non-fqdn = [ { if = "listener = 'smtp'", then = true},
{ else = false } ]
#script = "'ehlo'"
[session.extensions]
pipelining = true
chunking = true
requiretls = true
no-soliciting = ""
dsn = [ { if = "!is_empty(authenticated_as)", then = true},
{ else = false } ]
expn = [ { if = "!is_empty(authenticated_as)", then = true},
{ else = false } ]
vrfy = [ { if = "!is_empty(authenticated_as)", then = true},
{ else = false } ]
future-release = [ { if = "!is_empty(authenticated_as)", then = "7d"},
{ else = false } ]
deliver-by = [ { if = "!is_empty(authenticated_as)", then = "15d"},
{ else = false } ]
mt-priority = [ { if = "!is_empty(authenticated_as)", then = "mixer"},
{ else = false } ]
[session.auth]
mechanisms = [ { if = "listener != 'smtp'", then = "[plain, login]"},
{ else = false } ]
directory = [ { if = "listener != 'smtp'", then = "'%{DEFAULT_DIRECTORY}%'" },
{ else = false } ]
require = [ { if = "listener != 'smtp'", then = true},
{ else = false } ]
allow-plain-text = false
[session.auth.errors]
total = 3
wait = "5s"
[session.mail]
#script = "mail-from"
#rewrite = [ { if = "listener != 'smtp' & matches('^([^.]+)@([^.]+)\\.(.+)$', rcpt)", then = "$1 + '@' + $3" },
# { else = false } ]
[session.rcpt]
#script = "greylist"
relay = [ { if = "!is_empty(authenticated_as)", then = true },
{ else = false } ]
#rewrite = [ { if = "is_local_domain('%{DEFAULT_DIRECTORY}%', rcpt_domain) & matches('^([^.]+)\\.([^.]+)@(.+)$', rcpt)", then = "$1 + '+' + $2 + '@' + $3" },
# { else = false } ]
max-recipients = 25
directory = "'%{DEFAULT_DIRECTORY}%'"
[session.rcpt.errors]
total = 5
wait = "5s"
[session.data]
script = [ { if = "is_empty(authenticated_as)", then = "'spam-filter'"},
{ else = "'track-replies'" } ]
[session.data.limits]
messages = 10
size = 104857600
received-headers = 50
[session.data.add-headers]
received = [ { if = "listener = 'smtp'", then = true },
{ else = false } ]
received-spf = [ { if = "listener = 'smtp'", then = true },
{ else = false } ]
auth-results = [ { if = "listener = 'smtp'", then = true },
{ else = false } ]
message-id = [ { if = "listener = 'smtp'", then = false },
{ else = true } ]
date = [ { if = "listener = 'smtp'", then = false },
{ else = true } ]
return-path = false
[[session.throttle]]
#match = "remote_ip = '10.0.0.1'"
key = ["remote_ip"]
concurrency = 5
#rate = "5/1h"
enable = true
[[session.throttle]]
key = ["sender_domain", "rcpt"]
rate = "25/1h"
enable = true

View file

@ -1,18 +0,0 @@
#############################################
# SMTP DKIM & ARC signatures
#############################################
[signature."rsa"]
#public-key = "file://%{BASE_PATH}%/etc/dkim/%{DEFAULT_DOMAIN}%.cert"
private-key = "file://%{BASE_PATH}%/etc/dkim/%{DEFAULT_DOMAIN}%.key"
domain = "%{DEFAULT_DOMAIN}%"
selector = "stalwart"
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

View file

@ -1,62 +0,0 @@
#############################################
# SMTP Spam & Phishing filter configuration
#############################################
[spam.header]
add-spam = true
add-spam-result = true
is-spam = "X-Spam-Status: Yes"
[spam.autolearn]
enable = true
balance = 0.9
[spam.autolearn.ham]
replies = true
threshold = -0.5
[spam.autolearn.spam]
threshold = 6.0
[spam.threshold]
spam = 5.0
discard = 0
reject = 0
[spam.data]
directory = ""
lookup = ""
[sieve.trusted.scripts]
spam-filter = ["file://%{BASE_PATH}%/etc/spamfilter/scripts/config.sieve",
"file://%{BASE_PATH}%/etc/spamfilter/scripts/prelude.sieve",
"file://%{BASE_PATH}%/etc/spamfilter/scripts/from.sieve",
"file://%{BASE_PATH}%/etc/spamfilter/scripts/recipient.sieve",
"file://%{BASE_PATH}%/etc/spamfilter/scripts/subject.sieve",
"file://%{BASE_PATH}%/etc/spamfilter/scripts/replyto.sieve",
"file://%{BASE_PATH}%/etc/spamfilter/scripts/date.sieve",
"file://%{BASE_PATH}%/etc/spamfilter/scripts/messageid.sieve",
"file://%{BASE_PATH}%/etc/spamfilter/scripts/received.sieve",
"file://%{BASE_PATH}%/etc/spamfilter/scripts/headers.sieve",
"file://%{BASE_PATH}%/etc/spamfilter/scripts/bounce.sieve",
"file://%{BASE_PATH}%/etc/spamfilter/scripts/html.sieve",
"file://%{BASE_PATH}%/etc/spamfilter/scripts/mime.sieve",
"file://%{BASE_PATH}%/etc/spamfilter/scripts/dmarc.sieve",
"file://%{BASE_PATH}%/etc/spamfilter/scripts/ip.sieve",
"file://%{BASE_PATH}%/etc/spamfilter/scripts/helo.sieve",
"file://%{BASE_PATH}%/etc/spamfilter/scripts/replies_in.sieve",
"file://%{BASE_PATH}%/etc/spamfilter/scripts/spamtrap.sieve",
"file://%{BASE_PATH}%/etc/spamfilter/scripts/bayes_classify.sieve",
"file://%{BASE_PATH}%/etc/spamfilter/scripts/url.sieve",
"file://%{BASE_PATH}%/etc/spamfilter/scripts/rbl.sieve",
"file://%{BASE_PATH}%/etc/spamfilter/scripts/pyzor.sieve",
"file://%{BASE_PATH}%/etc/spamfilter/scripts/composites.sieve",
"file://%{BASE_PATH}%/etc/spamfilter/scripts/scores.sieve",
"file://%{BASE_PATH}%/etc/spamfilter/scripts/reputation.sieve",
"file://%{BASE_PATH}%/etc/spamfilter/scripts/epilogue.sieve"]
track-replies = ["file://%{BASE_PATH}%/etc/spamfilter/scripts/config.sieve",
"file://%{BASE_PATH}%/etc/spamfilter/scripts/replies_out.sieve"]
greylist = ["file://%{BASE_PATH}%/etc/spamfilter/scripts/config.sieve",
"file://%{BASE_PATH}%/etc/spamfilter/scripts/greylist.sieve"]

File diff suppressed because it is too large Load diff

View file

@ -26,7 +26,6 @@ spam-dmarc = {"18f.gov",
"adp.com",
"advice.hmrc.gov.uk",
"aerocivil.gov.co",
"aerocivil.gov.co",
"afreximbank.com",
"agingstats.gov",
"agro.ru",

View file

@ -150,7 +150,6 @@ spam-spdk = {"1cfresh.com",
"paypal.ca",
"paypal.cn",
"paypal.com",
"paypal.com",
"paypal.co.uk",
"paypal.de",
"paypal.es",

View file

@ -1 +0,0 @@
spam-trap = {}

View file

@ -1,35 +1,35 @@
# Whether to add an X-Spam-Status header
let "ADD_HEADER_SPAM" "%{cfg:spam.header.add-spam}%";
let "ADD_HEADER_SPAM" "key_get('spam-config', 'add-spam')";
# Whether to add an X-Spam-Result header
let "ADD_HEADER_SPAM_RESULT" "%{cfg:spam.header.add-spam-result}%";
let "ADD_HEADER_SPAM_RESULT" "key_get('spam-config', 'add-spam-result')";
# Whether message replies from authenticated users should be learned as ham
let "AUTOLEARN_REPLIES_HAM" "%{cfg:spam.autolearn.ham.replies}%";
let "AUTOLEARN_REPLIES_HAM" "key_get('spam-config', 'learn-ham-replies')";
# Whether the bayes classifier should be trained automatically
let "AUTOLEARN_ENABLE" "%{cfg:spam.autolearn.enable}%";
let "AUTOLEARN_ENABLE" "key_get('spam-config', 'learn-enable')";
# When to learn ham (score >= threshold)
let "AUTOLEARN_HAM_THRESHOLD" "%{cfg:spam.autolearn.ham.threshold}%";
let "AUTOLEARN_HAM_THRESHOLD" "key_get('spam-config', 'learn-ham-threshold')";
# When to learn spam (score <= threshold)
let "AUTOLEARN_SPAM_THRESHOLD" "%{cfg:spam.autolearn.spam.threshold}%";
let "AUTOLEARN_SPAM_THRESHOLD" "key_get('spam-config', 'learn-spam-threshold')";
# Keep difference for spam/ham learns for at least this value
let "AUTOLEARN_SPAM_HAM_BALANCE" "%{cfg:spam.autolearn.balance}%";
let "AUTOLEARN_SPAM_HAM_BALANCE" "key_get('spam-config', 'learn-balance')";
# If ADD_HEADER_SPAM is enabled, mark as SPAM messages with a score above this threshold
let "SCORE_SPAM_THRESHOLD" "%{cfg:spam.threshold.spam}%";
let "SCORE_SPAM_THRESHOLD" "key_get('spam-config', 'threshold-spam')";
# Discard messages with a score above this threshold
let "SCORE_DISCARD_THRESHOLD" "%{cfg:spam.threshold.discard}%";
let "SCORE_DISCARD_THRESHOLD" "key_get('spam-config', 'threshold-discard')";
# Reject messages with a score above this threshold
let "SCORE_REJECT_THRESHOLD" "%{cfg:spam.threshold.reject}%";
let "SCORE_REJECT_THRESHOLD" "key_get('spam-config', 'threshold-reject')";
# Directory name to use for local domain lookups (leave empty for default)
let "DOMAIN_DIRECTORY" "%{cfg:spam.data.directory}%";
let "DOMAIN_DIRECTORY" "key_get('spam-config', 'directory')";
# Store to use for Bayes tokens and ids (leave empty for default)
let "SPAM_DB" "%{cfg:spam.data.lookup}%";
let "SPAM_DB" "key_get('spam-config', 'lookup')";

View file

@ -0,0 +1,20 @@
[spam.header]
is-spam = "X-Spam-Status: Yes"
[lookup.spam-config]
add-spam = true
add-spam-result = true
learn-enable = true
learn-balance = "0.9"
learn-ham-replies = true
learn-ham-threshold = "-0.5"
learn-spam-threshold = "6.0"
threshold-spam = "5.0"
threshold-discard = "0.0"
threshold-reject = "0.0"
directory = ""
lookup = ""
[session.data]
script = [ { if = "is_empty(authenticated_as)", then = "'spam-filter'"},
{ else = "'track-replies'" } ]

View file

@ -1,18 +0,0 @@
#############################################
# ElasticSearch FTS Store configuration
#############################################
[store."elasticsearch"]
type = "elasticsearch"
url = "https://localhost:9200"
user = "elastic"
password = "myelasticpassword"
#cloud-id = "my-cloud-id"
disable = true
[store."elasticsearch".tls]
allow-invalid-certs = true
[store."elasticsearch".index]
shards = 3
replicas = 0

View file

@ -1,20 +0,0 @@
#############################################
# FoundationDB Store configuration
#############################################
[store."foundationdb"]
type = "foundationdb"
#cluster-file = "/etc/foundationdb/fdb.cluster"
disable = true
#[store."foundationdb".transaction]
#timeout = "5s"
#retry-limit = 10
#max-retry-delay = "1s"
#[store."foundationdb".ids]
#machine = "stalwart"
#data-center = "my-datacenter"
[store."foundationdb".purge]
frequency = "0 3 *"

View file

@ -1,12 +0,0 @@
#############################################
# File System Blob Store configuration
#############################################
[store."fs"]
type = "fs"
path = "%{BASE_PATH}%/data/blobs"
depth = 2
disable = true
[store."fs".purge]
frequency = "0 3 *"

View file

@ -1,37 +0,0 @@
#############################################
# MySQL Store configuration
#############################################
[store."mysql"]
type = "mysql"
host = "localhost"
port = 3307
database = "stalwart"
user = "root"
password = "password"
disable = true
#max-allowed-packet = 1073741824
timeout = "15s"
#[store."mysql".pool]
#max-connections = 10
#min-connections = 5
#[store."mysql".init]
#execute = [
# "CREATE TABLE IF NOT EXISTS accounts (name VARCHAR(32) PRIMARY KEY, secret VARCHAR(1024), description VARCHAR(1024), type VARCHAR(32) NOT NULL, quota INTEGER DEFAULT 0, active BOOLEAN DEFAULT 1)",
# "CREATE TABLE IF NOT EXISTS group_members (name VARCHAR(32) NOT NULL, member_of VARCHAR(32) NOT NULL, PRIMARY KEY (name, member_of))",
# "CREATE TABLE IF NOT EXISTS emails (name VARCHAR(32) NOT NULL, address VARCHAR(128) NOT NULL, type VARCHAR(32), PRIMARY KEY (name, address))"
#]
[store."mysql".query]
name = "SELECT name, type, secret, description, quota FROM accounts WHERE name = ? AND active = true"
members = "SELECT member_of FROM group_members WHERE name = ?"
recipients = "SELECT name FROM emails WHERE address = ? ORDER BY name ASC"
emails = "SELECT address FROM emails WHERE name = ? AND type != 'list' ORDER BY type DESC, address ASC"
verify = "SELECT address FROM emails WHERE address LIKE CONCAT('%', ?, '%') AND type = 'primary' ORDER BY address LIMIT 5"
expand = "SELECT p.address FROM emails AS p JOIN emails AS l ON p.name = l.name WHERE p.type = 'primary' AND l.address = ? AND l.type = 'list' ORDER BY p.address LIMIT 50"
domains = "SELECT 1 FROM emails WHERE address LIKE CONCAT('%@', ?) LIMIT 1"
[store."mysql".purge]
frequency = "0 3 *"

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