diff --git a/crates/common/src/config/jmap/settings.rs b/crates/common/src/config/jmap/settings.rs index 6819f3d7..9acfd93d 100644 --- a/crates/common/src/config/jmap/settings.rs +++ b/crates/common/src/config/jmap/settings.rs @@ -129,11 +129,7 @@ impl JmapConfig { // Parse default folders let mut default_folders = Vec::new(); let mut shared_folder = "Shared Folders".to_string(); - for key in config - .sub_keys("email.folders", ".name") - .map(|v| v.to_string()) - .collect::>() - { + for key in config.sub_keys("email.folders", ".name") { match SpecialUse::parse_value(&key) { Ok(SpecialUse::Shared) => { if let Some(value) = config.value(("email.folders", key.as_str(), "name")) { diff --git a/crates/common/src/config/scripts.rs b/crates/common/src/config/scripts.rs index 9db3d6e0..2d82a239 100644 --- a/crates/common/src/config/scripts.rs +++ b/crates/common/src/config/scripts.rs @@ -275,11 +275,7 @@ impl Scripting { // Parse trusted scripts let mut trusted_scripts = AHashMap::new(); - for id in config - .sub_keys("sieve.trusted.scripts", ".contents") - .map(|s| s.to_string()) - .collect::>() - { + for id in config.sub_keys("sieve.trusted.scripts", ".contents") { match trusted_compiler.compile( config .value(("sieve.trusted.scripts", id.as_str(), "contents")) @@ -298,11 +294,7 @@ impl Scripting { // Parse untrusted scripts let mut untrusted_scripts = AHashMap::new(); - for id in config - .sub_keys("sieve.untrusted.scripts", ".contents") - .map(|s| s.to_string()) - .collect::>() - { + for id in config.sub_keys("sieve.untrusted.scripts", ".contents") { match untrusted_compiler.compile( config .value(("sieve.untrusted.scripts", id.as_str(), "contents")) diff --git a/crates/common/src/config/server/listener.rs b/crates/common/src/config/server/listener.rs index 884baecc..68e8d61f 100644 --- a/crates/common/src/config/server/listener.rs +++ b/crates/common/src/config/server/listener.rs @@ -45,11 +45,7 @@ impl Listeners { }; // Parse servers - for id in config - .sub_keys("server.listener", ".protocol") - .map(|s| s.to_string()) - .collect::>() - { + for id in config.sub_keys("server.listener", ".protocol") { servers.parse_server(config, id); } servers @@ -216,11 +212,7 @@ impl Listeners { pub fn parse_tcp_acceptors(&mut self, config: &mut Config, inner: Arc) { let resolver = Arc::new(CertificateResolver::new(inner.clone())); - for id_ in config - .sub_keys("server.listener", ".protocol") - .map(|s| s.to_string()) - .collect::>() - { + for id_ in config.sub_keys("server.listener", ".protocol") { let id = id_.as_str(); // Build TLS config let acceptor = if config diff --git a/crates/common/src/config/server/mod.rs b/crates/common/src/config/server/mod.rs index 35624671..7dc94816 100644 --- a/crates/common/src/config/server/mod.rs +++ b/crates/common/src/config/server/mod.rs @@ -45,7 +45,7 @@ pub struct TcpListener { pub nodelay: bool, } -#[derive(Debug, PartialEq, Eq, Clone, Copy, Default, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy, Default, Serialize, Deserialize)] pub enum ServerProtocol { #[default] Smtp, diff --git a/crates/common/src/config/server/tls.rs b/crates/common/src/config/server/tls.rs index dd6573ff..e7cb1bee 100644 --- a/crates/common/src/config/server/tls.rs +++ b/crates/common/src/config/server/tls.rs @@ -48,11 +48,7 @@ impl AcmeProviders { let mut providers = AHashMap::new(); // Parse ACME providers - 'outer: for acme_id in config - .sub_keys("acme", ".directory") - .map(|s| s.to_string()) - .collect::>() - { + 'outer: for acme_id in config.sub_keys("acme", ".directory") { let acme_id = acme_id.as_str(); let directory = config .value(("acme", acme_id, "directory")) @@ -269,11 +265,7 @@ pub(crate) fn parse_certificates( subject_names: &mut AHashSet, ) { // Parse certificates - for cert_id in config - .sub_keys("certificate", ".cert") - .map(|s| s.to_string()) - .collect::>() - { + for cert_id in config.sub_keys("certificate", ".cert") { let cert_id = cert_id.as_str(); let key_cert = ("certificate", cert_id, "cert"); let key_pk = ("certificate", cert_id, "private-key"); diff --git a/crates/common/src/config/smtp/mod.rs b/crates/common/src/config/smtp/mod.rs index 02ebba38..199ea1df 100644 --- a/crates/common/src/config/smtp/mod.rs +++ b/crates/common/src/config/smtp/mod.rs @@ -114,7 +114,8 @@ pub(crate) const SMTP_QUEUE_HOST_VARS: &[u32; 14] = &[ V_QUEUE_LAST_STATUS, V_QUEUE_LAST_ERROR, ]; -pub(crate) const SMTP_QUEUE_RCPT_VARS: &[u32; 10] = &[ +pub(crate) const SMTP_QUEUE_RCPT_VARS: &[u32; 13] = &[ + V_RECIPIENT, V_RECIPIENT_DOMAIN, V_RECIPIENTS, V_SENDER, @@ -125,6 +126,8 @@ pub(crate) const SMTP_QUEUE_RCPT_VARS: &[u32; 10] = &[ V_QUEUE_EXPIRES_IN, V_QUEUE_LAST_STATUS, V_QUEUE_LAST_ERROR, + V_REMOTE_IP, + V_LOCAL_PORT, ]; pub(crate) const SMTP_QUEUE_SENDER_VARS: &[u32; 8] = &[ V_SENDER, @@ -136,19 +139,6 @@ pub(crate) const SMTP_QUEUE_SENDER_VARS: &[u32; 8] = &[ V_QUEUE_LAST_STATUS, V_QUEUE_LAST_ERROR, ]; -pub(crate) const SMTP_QUEUE_MX_VARS: &[u32; 11] = &[ - V_RECIPIENT_DOMAIN, - V_RECIPIENTS, - V_SENDER, - V_SENDER_DOMAIN, - V_PRIORITY, - V_MX, - V_QUEUE_RETRY_NUM, - V_QUEUE_NOTIFY_NUM, - V_QUEUE_EXPIRES_IN, - V_QUEUE_LAST_STATUS, - V_QUEUE_LAST_ERROR, -]; impl SmtpConfig { pub async fn parse(config: &mut Config) -> Self { diff --git a/crates/common/src/config/smtp/queue.rs b/crates/common/src/config/smtp/queue.rs index 80a3a1cb..aeefee42 100644 --- a/crates/common/src/config/smtp/queue.rs +++ b/crates/common/src/config/smtp/queue.rs @@ -4,55 +4,77 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use ahash::AHashMap; -use mail_auth::IpLookupStrategy; -use mail_send::Credentials; -use throttle::parse_queue_rate_limiter_key; -use utils::config::{Config, utils::ParseValue}; - +use self::throttle::parse_queue_rate_limiter; +use super::*; use crate::{ config::server::ServerProtocol, expr::{if_block::IfBlock, *}, }; +use ahash::AHashMap; +use mail_auth::IpLookupStrategy; +use mail_send::Credentials; +use std::{ + fmt::Display, + hash::{Hash, Hasher}, + net::IpAddr, + time::Duration, +}; +use throttle::parse_queue_rate_limiter_key; +use utils::config::{Config, utils::ParseValue}; -use self::throttle::parse_queue_rate_limiter; +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + rkyv::Serialize, + rkyv::Deserialize, + rkyv::Archive, + serde::Deserialize, +)] +#[repr(transparent)] +pub struct QueueName([u8; 8]); -use super::*; +pub const DEFAULT_QUEUE_NAME: QueueName = QueueName([b'd', b'e', b'f', b'a', b'u', b'l', b't', 0]); #[derive(Clone)] pub struct QueueConfig { - // Schedule - pub retry: IfBlock, - pub notify: IfBlock, - pub expire: IfBlock, + // Strategy resolver + pub gateway: IfBlock, + pub queue: IfBlock, + pub connection: IfBlock, + pub tls: IfBlock, - // Outbound - pub hostname: IfBlock, - pub next_hop: IfBlock, - pub max_mx: IfBlock, - pub max_multihomed: IfBlock, - pub ip_strategy: IfBlock, - pub source_ip: QueueOutboundSourceIp, - pub tls: QueueOutboundTls, + // DSN pub dsn: Dsn, - // Timeouts - pub timeout: QueueOutboundTimeout, - // Rate limits pub inbound_limiters: QueueRateLimiters, pub outbound_limiters: QueueRateLimiters, pub quota: QueueQuotas, - pub max_threads: usize, - // Relay hosts - pub relay_hosts: AHashMap, + // Strategies + pub queue_strategy: AHashMap, + pub connection_strategy: AHashMap, + pub gateway_strategy: AHashMap, + pub tls_strategy: AHashMap, + pub virtual_queues: AHashMap, } -#[derive(Clone)] -pub struct QueueOutboundSourceIp { - pub ipv4: IfBlock, - pub ipv6: IfBlock, +#[derive(Clone, Hash, PartialEq, Eq, Debug)] +pub enum GatewayStrategy { + Local, + Mx(MxConfig), + Relay(RelayConfig), +} + +#[derive(Clone, Debug)] +pub struct MxConfig { + pub max_mx: usize, + pub max_multi_homed: usize, + pub ip_lookup_strategy: IpLookupStrategy, } #[derive(Clone)] @@ -62,24 +84,64 @@ pub struct Dsn { pub sign: IfBlock, } -#[derive(Clone)] -pub struct QueueOutboundTls { - pub dane: IfBlock, - pub mta_sts: IfBlock, - pub start: IfBlock, - pub invalid_certs: IfBlock, +#[derive(Clone, Debug)] +pub struct VirtualQueue { + pub threads: u32, } -#[derive(Clone)] -pub struct QueueOutboundTimeout { - pub connect: IfBlock, - pub greeting: IfBlock, - pub tls: IfBlock, - pub ehlo: IfBlock, - pub mail: IfBlock, - pub rcpt: IfBlock, - pub data: IfBlock, - pub mta_sts: IfBlock, +#[derive(Clone, Debug)] +pub struct QueueStrategy { + pub retry: Vec, + pub notify: Vec, + pub expiry: QueueExpiry, + pub virtual_queue: QueueName, +} + +#[derive( + rkyv::Serialize, + rkyv::Deserialize, + rkyv::Archive, + Debug, + Clone, + Copy, + PartialEq, + Eq, + serde::Deserialize, +)] +pub enum QueueExpiry { + Duration(u64), + Count(u32), +} + +#[derive(Clone, Debug)] +pub struct TlsStrategy { + pub dane: RequireOptional, + pub mta_sts: RequireOptional, + pub tls: RequireOptional, + pub allow_invalid_certs: bool, + + pub timeout_tls: Duration, + pub timeout_mta_sts: Duration, +} + +#[derive(Clone, Debug)] +pub struct ConnectionStrategy { + pub source_ipv4: Vec, + pub source_ipv6: Vec, + pub ehlo_hostname: Option, + + pub timeout_connect: Duration, + pub timeout_greeting: Duration, + pub timeout_ehlo: Duration, + pub timeout_mail: Duration, + pub timeout_rcpt: Duration, + pub timeout_data: Duration, +} + +#[derive(Clone, Debug)] +pub struct IpAndHost { + pub ip: IpAddr, + pub host: Option, } #[derive(Debug, Clone, Default)] @@ -105,8 +167,8 @@ pub struct QueueQuota { pub messages: Option, } -#[derive(Clone)] -pub struct RelayHost { +#[derive(Clone, Hash, PartialEq, Eq)] +pub struct RelayConfig { pub address: String, pub port: u16, pub protocol: ServerProtocol, @@ -126,61 +188,17 @@ pub enum RequireOptional { impl Default for QueueConfig { fn default() -> Self { Self { - 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", - [], - "config_get('server.hostname')", - ), - next_hop: IfBlock::new::<()>( - "queue.outbound.next-hop", + gateway: IfBlock::new::<()>( + "queue.strategy.gateway", #[cfg(not(feature = "test_mode"))] [("is_local_domain('*', rcpt_domain)", "'local'")], #[cfg(feature = "test_mode")] [], - "false", + "'mx'", ), - max_mx: IfBlock::new::<()>("queue.outbound.limits.mx", [], "5"), - max_multihomed: IfBlock::new::<()>("queue.outbound.limits.multihomed", [], "2"), - ip_strategy: IfBlock::new::( - "queue.outbound.ip-strategy", - [], - "ipv4_then_ipv6", - ), - source_ip: QueueOutboundSourceIp { - ipv4: IfBlock::empty("queue.outbound.source-ip.v4"), - ipv6: IfBlock::empty("queue.outbound.source-ip.v6"), - }, - tls: QueueOutboundTls { - dane: IfBlock::new::("queue.outbound.tls.dane", [], "optional"), - mta_sts: IfBlock::new::( - "queue.outbound.tls.mta-sts", - [], - "optional", - ), - start: IfBlock::new::( - "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", - #[cfg(not(feature = "test_mode"))] - [("retry_num > 0 && last_error == 'tls'", "true")], - #[cfg(feature = "test_mode")] - [], - "false", - ), - }, + queue: IfBlock::new::<()>("queue.strategy.schedule", [], "'default'"), + connection: IfBlock::new::<()>("queue.strategy.connection", [], "'default'"), + tls: IfBlock::new::<()>("queue.strategy.tls", [], "'default'"), dsn: Dsn { name: IfBlock::new::<()>("report.dsn.from-name", [], "'Mail Delivery Subsystem'"), address: IfBlock::new::<()>( @@ -194,21 +212,14 @@ impl Default for QueueConfig { "['rsa-' + config_get('report.domain'), 'ed25519-' + config_get('report.domain')]", ), }, - timeout: QueueOutboundTimeout { - 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"), - }, - max_threads: 25, inbound_limiters: QueueRateLimiters::default(), outbound_limiters: QueueRateLimiters::default(), quota: QueueQuotas::default(), - relay_hosts: Default::default(), + queue_strategy: Default::default(), + virtual_queues: Default::default(), + connection_strategy: Default::default(), + gateway_strategy: Default::default(), + tls_strategy: Default::default(), } } } @@ -218,95 +229,17 @@ impl QueueConfig { let mut queue = QueueConfig::default(); 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::(); - let dane_vars = mx_vars.clone().with_constants::(); - let mta_sts_vars = rcpt_vars.clone().with_constants::(); for (value, key, token_map) in [ - (&mut queue.retry, "queue.schedule.retry", &host_vars), - (&mut queue.notify, "queue.schedule.notify", &rcpt_vars), - (&mut queue.expire, "queue.schedule.expire", &rcpt_vars), - (&mut queue.hostname, "queue.outbound.hostname", &sender_vars), - (&mut queue.max_mx, "queue.outbound.limits.mx", &rcpt_vars), + (&mut queue.gateway, "queue.strategy.gateway", &rcpt_vars), + (&mut queue.queue, "queue.strategy.schedule", &rcpt_vars), ( - &mut queue.max_multihomed, - "queue.outbound.limits.multihomed", - &rcpt_vars, - ), - ( - &mut queue.ip_strategy, - "queue.outbound.ip-strategy", - &ip_strategy_vars, - ), - ( - &mut queue.source_ip.ipv4, - "queue.outbound.source-ip.v4", - &mx_vars, - ), - ( - &mut queue.source_ip.ipv6, - "queue.outbound.source-ip.v6", - &mx_vars, - ), - (&mut queue.next_hop, "queue.outbound.next-hop", &rcpt_vars), - (&mut queue.tls.dane, "queue.outbound.tls.dane", &dane_vars), - ( - &mut queue.tls.mta_sts, - "queue.outbound.tls.mta-sts", - &mta_sts_vars, - ), - ( - &mut queue.tls.start, - "queue.outbound.tls.starttls", - &dane_vars, - ), - ( - &mut queue.tls.invalid_certs, - "queue.outbound.tls.allow-invalid-certs", - &mx_vars, - ), - ( - &mut queue.timeout.connect, - "queue.outbound.timeouts.connect", - &host_vars, - ), - ( - &mut queue.timeout.greeting, - "queue.outbound.timeouts.greeting", - &host_vars, - ), - ( - &mut queue.timeout.tls, - "queue.outbound.timeouts.tls", - &host_vars, - ), - ( - &mut queue.timeout.ehlo, - "queue.outbound.timeouts.ehlo", - &host_vars, - ), - ( - &mut queue.timeout.mail, - "queue.outbound.timeouts.mail-from", - &host_vars, - ), - ( - &mut queue.timeout.rcpt, - "queue.outbound.timeouts.rcpt-to", - &host_vars, - ), - ( - &mut queue.timeout.data, - "queue.outbound.timeouts.data", - &host_vars, - ), - ( - &mut queue.timeout.mta_sts, - "queue.outbound.timeouts.mta-sts", + &mut queue.connection, + "queue.strategy.connection", &host_vars, ), + (&mut queue.tls, "queue.strategy.tls", &host_vars), (&mut queue.dsn.name, "report.dsn.from-name", &sender_vars), ( &mut queue.dsn.address, @@ -319,69 +252,307 @@ impl QueueConfig { *value = if_block; } } + let todo = "test parsing"; + + // Parse strategies + queue.virtual_queues = parse_virtual_queues(config); + queue.queue_strategy = parse_queue_strategies(config, &queue.virtual_queues); + queue.connection_strategy = parse_connection_strategies(config); + queue.gateway_strategy = parse_gateway_strategies(config); + queue.tls_strategy = parse_tls_strategies(config); // Parse rate limiters - queue.max_threads = config - .property_or_default::("queue.threads.remote", "25") - .unwrap_or(25) - .max(1); - queue.inbound_limiters = parse_inbound_rate_limters(config); + queue.inbound_limiters = parse_inbound_rate_limiters(config); queue.outbound_limiters = parse_outbound_rate_limiters(config); queue.quota = parse_queue_quota(config); - - // Parse relay hosts - queue.relay_hosts = config - .sub_keys("remote", ".address") - .map(|id| id.to_string()) - .collect::>() - .into_iter() - .filter_map(|id| parse_relay_host(config, &id).map(|host| (id, host))) - .collect(); - - // Add local delivery host - queue.relay_hosts.insert( - "local".to_string(), - RelayHost { - address: String::new(), - port: 0, - protocol: ServerProtocol::Http, - tls_implicit: Default::default(), - tls_allow_invalid_certs: Default::default(), - auth: None, - }, - ); - queue } } -fn parse_relay_host(config: &mut Config, id: &str) -> Option { - Some(RelayHost { - address: config.property_require(("remote", id, "address"))?, - port: config - .property_require(("remote", id, "port")) - .unwrap_or(25), - protocol: config - .property_require(("remote", id, "protocol")) - .unwrap_or(ServerProtocol::Smtp), - auth: if let (Some(username), Some(secret)) = ( - config.value(("remote", id, "auth.username")), - config.value(("remote", id, "auth.secret")), +fn parse_queue_strategies( + config: &mut Config, + queues: &AHashMap, +) -> AHashMap { + let mut entries = AHashMap::new(); + for key in config.sub_keys_with_suffixes( + "queue.schedule", + &[ + ".queue-name", + ".retry", + ".notify", + ".expire", + ".max-attempts", + ], + ) { + if let Some(strategy) = parse_queue_strategy(config, &key, queues) { + entries.insert(key, strategy); + } + } + entries +} + +fn parse_queue_strategy( + config: &mut Config, + id: &str, + queues: &AHashMap, +) -> Option { + let virtual_queue = config + .property_require::(("queue.schedule", id, "queue-name")) + .unwrap_or_default(); + if virtual_queue != DEFAULT_QUEUE_NAME && !queues.contains_key(&virtual_queue) { + config.new_parse_error( + ("queue.schedule", id, "queue-name"), + format!("Virtual queue '{virtual_queue}' does not exist."), + ); + return None; + } + let mut retry: Vec = config + .properties::(("queue.schedule", id, "retry")) + .into_iter() + .map(|(_, d)| d.as_secs()) + .collect(); + let mut notify: Vec = config + .properties::(("queue.schedule", id, "notify")) + .into_iter() + .map(|(_, d)| d.as_secs()) + .collect(); + if retry.is_empty() { + config.new_parse_error( + ("queue.schedule", id, "retry"), + "At least one 'retry' duration must be specified.".to_string(), + ); + retry.push(60 * 60); // Default to 1 minute + } + if notify.is_empty() { + notify.push(10000 * 86400); // Disable notifications by default + } + + Some(QueueStrategy { + retry, + notify, + expiry: match ( + config.property::(("queue.schedule", id, "expire")), + config.property::(("queue.schedule", id, "max-attempts")), ) { - Credentials::new(username.to_string(), secret.to_string()).into() - } else { - None + (Some(duration), None) => QueueExpiry::Duration(duration.as_secs()), + (None, Some(count)) => QueueExpiry::Count(count), + (Some(_), Some(_)) => { + config.new_parse_error( + ("queue.schedule", id, "expire"), + "Cannot specify both 'expire' and 'max-attempts'.".to_string(), + ); + return None; + } + (None, None) => QueueExpiry::Duration(60 * 60 * 24 * 3), // Default to 3 days }, - tls_implicit: config - .property(("remote", id, "tls.implicit")) - .unwrap_or(true), - tls_allow_invalid_certs: config - .property(("remote", id, "tls.allow-invalid-certs")) - .unwrap_or(false), + virtual_queue, }) } -fn parse_inbound_rate_limters(config: &mut Config) -> QueueRateLimiters { +fn parse_virtual_queues(config: &mut Config) -> AHashMap { + let mut entries = AHashMap::new(); + for key in config.sub_keys("queue.virtual", ".threads-per-node") { + if let Some(queue_name) = QueueName::new(&key) { + if let Some(queue) = parse_virtual_queue(config, &key) { + entries.insert(queue_name, queue); + } + } else { + config.new_parse_error( + ("queue.virtual", &key, "threads-per-node"), + format!("Invalid virtual queue name: {key:?}. Must be 1-8 bytes long."), + ); + } + } + entries +} + +fn parse_virtual_queue(config: &mut Config, id: &str) -> Option { + Some(VirtualQueue { + threads: config + .property_require::(("queue.virtual", id, "threads-per-node")) + .unwrap_or(1), + }) +} + +fn parse_gateway_strategies(config: &mut Config) -> AHashMap { + let mut entries = AHashMap::new(); + for key in config.sub_keys("queue.gateway", ".type") { + if let Some(strategy) = parse_gateway(config, &key) { + entries.insert(key, strategy); + } + } + entries +} + +fn parse_gateway(config: &mut Config, id: &str) -> Option { + match config.value_require_non_empty(("queue.gateway", id, "type"))? { + "relay" => GatewayStrategy::Relay(RelayConfig { + address: config.property_require(("queue.gateway", id, "address"))?, + port: config + .property_require(("queue.gateway", id, "port")) + .unwrap_or(25), + protocol: config + .property_require(("queue.gateway", id, "protocol")) + .unwrap_or(ServerProtocol::Smtp), + auth: if let (Some(username), Some(secret)) = ( + config.value(("queue.gateway", id, "auth.username")), + config.value(("queue.gateway", id, "auth.secret")), + ) { + Credentials::new(username.to_string(), secret.to_string()).into() + } else { + None + }, + tls_implicit: config + .property(("queue.gateway", id, "tls.implicit")) + .unwrap_or(true), + tls_allow_invalid_certs: config + .property(("queue.gateway", id, "tls.allow-invalid-certs")) + .unwrap_or(false), + }) + .into(), + "local" => GatewayStrategy::Local.into(), + "mx" => GatewayStrategy::Mx(MxConfig { + max_mx: config + .property_require(("queue.gateway", id, "limits.mx")) + .unwrap_or(5), + max_multi_homed: config + .property_require(("queue.gateway", id, "limits.multihomed")) + .unwrap_or(2), + ip_lookup_strategy: config + .property_require(("queue.gateway", id, "ip-lookup")) + .unwrap_or(IpLookupStrategy::Ipv4thenIpv6), + }) + .into(), + invalid => { + let details = + format!("Invalid gateway type: {invalid:?}. Expected 'relay', 'local', or 'mx'."); + config.new_parse_error(("queue.gateway", id, "type"), details); + None + } + } +} + +fn parse_tls_strategies(config: &mut Config) -> AHashMap { + let mut entries = AHashMap::new(); + for key in config.sub_keys_with_suffixes( + "queue.tls", + &[ + ".allow-invalid-certs", + ".dane", + ".mta-sts", + ".starttls", + ".timeout.tls", + ".timeout.mta-sts", + ], + ) { + if let Some(strategy) = parse_tls(config, &key) { + entries.insert(key, strategy); + } + } + entries +} + +fn parse_tls(config: &mut Config, id: &str) -> Option { + Some(TlsStrategy { + dane: config + .property_require::(("queue.tls", id, "dane")) + .unwrap_or(RequireOptional::Optional), + mta_sts: config + .property_require::(("queue.tls", id, "mta-sts")) + .unwrap_or(RequireOptional::Optional), + tls: config + .property_require::(("queue.tls", id, "starttls")) + .unwrap_or(RequireOptional::Optional), + allow_invalid_certs: config + .property_require::(("queue.tls", id, "allow-invalid-certs")) + .unwrap_or(false), + timeout_tls: config + .property_require::(("queue.tls", id, "timeout.tls")) + .unwrap_or(Duration::from_secs(3 * 60)), + timeout_mta_sts: config + .property_require::(("queue.tls", id, "timeout.mta-sts")) + .unwrap_or(Duration::from_secs(5 * 60)), + }) +} + +fn parse_connection_strategies(config: &mut Config) -> AHashMap { + let mut entries = AHashMap::new(); + for key in config.sub_keys_with_suffixes( + "queue.connection", + &[ + ".timeout.connect", + ".timeout.greeting", + ".timeout.ehlo", + ".timeout.mail-from", + ".timeout.rcpt-to", + ".timeout.data", + ".ehlo-hostname", + ], + ) { + if let Some(strategy) = parse_connection(config, &key) { + entries.insert(key, strategy); + } + } + entries +} + +fn parse_connection(config: &mut Config, id: &str) -> Option { + let mut source_ipv4 = Vec::new(); + let mut source_ipv6 = Vec::new(); + + for ip_num in config.sub_keys(("queue.connection", id, "source-ip"), ".address") { + if let Some(ip) = config.property_require::(( + "queue.connection", + id, + "source-ip", + ip_num.as_str(), + "address", + )) { + let ip_and_host = IpAndHost { + ip, + host: config.property::(( + "queue.connection", + id, + "source-ip", + ip_num.as_str(), + "ehlo-hostname", + )), + }; + + if ip.is_ipv4() { + source_ipv4.push(ip_and_host); + } else { + source_ipv6.push(ip_and_host); + } + } + } + + Some(ConnectionStrategy { + source_ipv4, + source_ipv6, + ehlo_hostname: config.property::(("queue.connection", id, "ehlo-hostname")), + timeout_connect: config + .property_require::(("queue.connection", id, "timeout.connect")) + .unwrap_or(Duration::from_secs(5 * 60)), + timeout_greeting: config + .property_require::(("queue.connection", id, "timeout.greeting")) + .unwrap_or(Duration::from_secs(5 * 60)), + timeout_ehlo: config + .property_require::(("queue.connection", id, "timeout.ehlo")) + .unwrap_or(Duration::from_secs(5 * 60)), + timeout_mail: config + .property_require::(("queue.connection", id, "timeout.mail-from")) + .unwrap_or(Duration::from_secs(5 * 60)), + timeout_rcpt: config + .property_require::(("queue.connection", id, "timeout.rcpt-to")) + .unwrap_or(Duration::from_secs(5 * 60)), + timeout_data: config + .property_require::(("queue.connection", id, "timeout.data")) + .unwrap_or(Duration::from_secs(10 * 60)), + }) +} + +fn parse_inbound_rate_limiters(config: &mut Config) -> QueueRateLimiters { let mut throttle = QueueRateLimiters::default(); let all_throttles = parse_queue_rate_limiter( config, @@ -473,11 +644,7 @@ fn parse_queue_quota(config: &mut Config) -> QueueQuotas { rcpt_domain: Vec::new(), }; - for quota_id in config - .sub_keys("queue.quota", "") - .map(|s| s.to_string()) - .collect::>() - { + for quota_id in config.sub_keys("queue.quota", "") { if let Some(quota) = parse_queue_quota_item(config, ("queue.quota", "a_id), "a_id) { if (quota.keys & THROTTLE_RCPT) != 0 || quota @@ -665,9 +832,9 @@ impl ConstantValue for IpLookupStrategy { } } -impl std::fmt::Debug for RelayHost { +impl std::fmt::Debug for RelayConfig { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("RelayHost") + f.debug_struct("RelayConfig") .field("address", &self.address) .field("port", &self.port) .field("protocol", &self.protocol) @@ -676,3 +843,116 @@ impl std::fmt::Debug for RelayHost { .finish() } } + +impl TlsStrategy { + #[inline(always)] + pub fn try_dane(&self) -> bool { + matches!( + self.dane, + RequireOptional::Require | RequireOptional::Optional + ) + } + + #[inline(always)] + pub fn try_start_tls(&self) -> bool { + matches!( + self.tls, + RequireOptional::Require | RequireOptional::Optional + ) + } + + #[inline(always)] + pub fn is_dane_required(&self) -> bool { + matches!(self.dane, RequireOptional::Require) + } + + #[inline(always)] + pub fn try_mta_sts(&self) -> bool { + matches!( + self.mta_sts, + RequireOptional::Require | RequireOptional::Optional + ) + } + + #[inline(always)] + pub fn is_mta_sts_required(&self) -> bool { + matches!(self.mta_sts, RequireOptional::Require) + } + + #[inline(always)] + pub fn is_tls_required(&self) -> bool { + matches!(self.tls, RequireOptional::Require) + || self.is_dane_required() + || self.is_mta_sts_required() + } +} + +impl Hash for MxConfig { + fn hash(&self, state: &mut H) { + self.max_mx.hash(state); + self.max_multi_homed.hash(state); + } +} + +impl PartialEq for MxConfig { + fn eq(&self, other: &Self) -> bool { + self.max_mx == other.max_mx && self.max_multi_homed == other.max_multi_homed + } +} + +impl Eq for MxConfig {} + +impl QueueName { + pub fn new(name: impl AsRef<[u8]>) -> Option { + let name_bytes = name.as_ref(); + if (1..=8).contains(&name_bytes.len()) { + let mut bytes = [0; 8]; + bytes[..name_bytes.len()].copy_from_slice(name_bytes); + QueueName(bytes).into() + } else { + None + } + } + + pub fn from_bytes(name: &[u8]) -> Option { + name.try_into().ok().map(|bytes: [u8; 8]| QueueName(bytes)) + } + + pub fn as_str(&self) -> &str { + std::str::from_utf8(&self.0).unwrap_or_default() + } + + pub fn into_inner(self) -> [u8; 8] { + self.0 + } +} + +impl Default for QueueName { + fn default() -> Self { + DEFAULT_QUEUE_NAME + } +} + +impl ParseValue for QueueName { + fn parse_value(value: &str) -> Result { + if let Some(name) = QueueName::new(value.trim().as_bytes()) { + Ok(name) + } else { + Err(format!( + "Queue name '{value}' is too long. Maximum length is 8 bytes." + )) + } + } +} + +impl Display for QueueName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl AsRef<[u8]> for QueueName { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} diff --git a/crates/common/src/config/smtp/session.rs b/crates/common/src/config/smtp/session.rs index eceaf031..18e8a167 100644 --- a/crates/common/src/config/smtp/session.rs +++ b/crates/common/src/config/smtp/session.rs @@ -203,15 +203,11 @@ impl SessionConfig { session.rcpt.subaddressing = AddressMapping::parse(config, "session.rcpt.sub-addressing"); session.milters = config .sub_keys("session.milter", ".hostname") - .map(|s| s.to_string()) - .collect::>() .into_iter() .filter_map(|id| parse_milter(config, &id, &has_rcpt_vars)) .collect(); session.hooks = config .sub_keys("session.hook", ".url") - .map(|s| s.to_string()) - .collect::>() .into_iter() .filter_map(|id| parse_hooks(config, &id, &has_rcpt_vars)) .collect(); diff --git a/crates/common/src/config/smtp/throttle.rs b/crates/common/src/config/smtp/throttle.rs index 823fbfb7..d056c218 100644 --- a/crates/common/src/config/smtp/throttle.rs +++ b/crates/common/src/config/smtp/throttle.rs @@ -18,11 +18,7 @@ pub fn parse_queue_rate_limiter( ) -> Vec { let prefix_ = prefix.as_key(); let mut rate_limiters = Vec::new(); - for rate_limiter_id in config - .sub_keys(prefix, "") - .map(|s| s.to_string()) - .collect::>() - { + for rate_limiter_id in config.sub_keys(prefix, "") { let rate_limiter_id = rate_limiter_id.as_str(); if let Some(rate_limiter) = parse_queue_rate_limiter_item( config, diff --git a/crates/common/src/config/spamfilter.rs b/crates/common/src/config/spamfilter.rs index 52f6b7af..279afae5 100644 --- a/crates/common/src/config/spamfilter.rs +++ b/crates/common/src/config/spamfilter.rs @@ -199,11 +199,7 @@ impl SpamFilterConfig { impl SpamFilterRules { pub fn parse(config: &mut Config) -> SpamFilterRules { let mut rules = vec![]; - for id in config - .sub_keys("spam-filter.rule", ".scope") - .map(|k| k.to_string()) - .collect::>() - { + for id in config.sub_keys("spam-filter.rule", ".scope") { if let Some(rule) = SpamFilterRule::parse(config, id) { rules.push(rule); } @@ -266,11 +262,7 @@ impl SpamFilterRule { impl DnsBlConfig { pub fn parse(config: &mut Config) -> Self { let mut servers = vec![]; - for id in config - .sub_keys("spam-filter.dnsbl.server", ".scope") - .map(|k| k.to_string()) - .collect::>() - { + for id in config.sub_keys("spam-filter.dnsbl.server", ".scope") { if let Some(server) = DnsBlServer::parse(config, id) { servers.push(server); } diff --git a/crates/common/src/config/telemetry.rs b/crates/common/src/config/telemetry.rs index 1b03df99..662a0bab 100644 --- a/crates/common/src/config/telemetry.rs +++ b/crates/common/src/config/telemetry.rs @@ -175,11 +175,7 @@ impl Tracers { // Parse tracers let mut tracers: Vec = Vec::new(); let mut global_interests = Interests::default(); - for tracer_id in config - .sub_keys("tracer", ".type") - .map(|s| s.to_string()) - .collect::>() - { + for tracer_id in config.sub_keys("tracer", ".type") { let id = tracer_id.as_str(); // Skip disabled tracers @@ -546,11 +542,7 @@ impl Tracers { } // Parse webhooks - for id in config - .sub_keys("webhook", ".url") - .map(|s| s.to_string()) - .collect::>() - { + for id in config.sub_keys("webhook", ".url") { if let Some(webhook) = parse_webhook(config, &id, &mut global_interests) { tracers.push(webhook); } @@ -599,6 +591,7 @@ impl Metrics { // Obtain log path for tracer_id in config.sub_keys("tracer", ".type") { + let tracer_id = tracer_id.as_str(); if config .value(("tracer", tracer_id, "enable")) .unwrap_or("true") diff --git a/crates/common/src/core.rs b/crates/common/src/core.rs index 0d6ff6b7..209af3e2 100644 --- a/crates/common/src/core.rs +++ b/crates/common/src/core.rs @@ -9,7 +9,10 @@ use crate::{ auth::{AccessToken, ResourceToken, TenantInfo}, config::smtp::{ auth::{ArcSealer, DkimSigner, LazySignature, ResolvedSignature, build_signature}, - queue::RelayHost, + queue::{ + ConnectionStrategy, DEFAULT_QUEUE_NAME, GatewayStrategy, MxConfig, QueueExpiry, + QueueName, QueueStrategy, RequireOptional, TlsStrategy, VirtualQueue, + }, }, ipc::{BroadcastEvent, StateEvent}, }; @@ -21,8 +24,12 @@ use jmap_proto::types::{ state::StateChange, type_state::DataType, }; +use mail_auth::IpLookupStrategy; use sieve::Sieve; -use std::sync::Arc; +use std::{ + sync::{Arc, LazyLock}, + time::Duration, +}; use store::{ BitmapKey, BlobClass, BlobStore, Deserialize, FtsStore, InMemoryStore, IndexKey, IterateParams, Key, LogKey, SUBSPACE_LOGS, SerializeInfallible, Store, U32_LEN, U64_LEN, ValueKey, @@ -183,16 +190,149 @@ impl Server { }) } - pub fn get_relay_host(&self, name: &str, session_id: u64) -> Option<&RelayHost> { - self.core.smtp.queue.relay_hosts.get(name).or_else(|| { - trc::event!( - Smtp(trc::SmtpEvent::RemoteIdNotFound), - Id = name.to_string(), - SpanId = session_id, - ); + pub fn get_gateway_or_default(&self, name: &str, session_id: u64) -> &GatewayStrategy { + static LOCAL_GATEWAY: GatewayStrategy = GatewayStrategy::Local; + static MX_GATEWAY: GatewayStrategy = GatewayStrategy::Mx(MxConfig { + max_mx: 5, + max_multi_homed: 2, + ip_lookup_strategy: IpLookupStrategy::Ipv4thenIpv6, + }); + self.core + .smtp + .queue + .gateway_strategy + .get(name) + .unwrap_or_else(|| match name { + "local" => &LOCAL_GATEWAY, + "mx" => &MX_GATEWAY, + _ => { + trc::event!( + Smtp(trc::SmtpEvent::IdNotFound), + Id = name.to_string(), + Details = "Gateway not found", + SpanId = session_id, + ); + &MX_GATEWAY + } + }) + } - None - }) + pub fn get_virtual_queue_or_default(&self, name: &QueueName, session_id: u64) -> &VirtualQueue { + static DEFAULT_QUEUE: VirtualQueue = VirtualQueue { threads: 25 }; + self.core + .smtp + .queue + .virtual_queues + .get(name) + .unwrap_or_else(|| { + if name != &DEFAULT_QUEUE_NAME { + trc::event!( + Smtp(trc::SmtpEvent::IdNotFound), + Id = name.to_string(), + Details = "Virtual queue not found", + SpanId = session_id, + ); + } + + &DEFAULT_QUEUE + }) + } + + pub fn get_queue_or_default(&self, name: &str, session_id: u64) -> &QueueStrategy { + static DEFAULT_SCHEDULE: LazyLock = LazyLock::new(|| QueueStrategy { + retry: vec![ + 120, // 2 minutes + 300, // 5 minutes + 600, // 10 minutes + 900, // 15 minutes + 1800, // 30 minutes + 3600, // 1 hour + 7200, // 2 hours + ], + notify: vec![ + 86400, // 1 day + 259200, // 3 days + ], + expiry: QueueExpiry::Duration(432000), // 5 days + virtual_queue: QueueName::default(), + }); + self.core + .smtp + .queue + .queue_strategy + .get(name) + .unwrap_or_else(|| { + if name != "default" { + trc::event!( + Smtp(trc::SmtpEvent::IdNotFound), + Id = name.to_string(), + Details = "Queue strategy not found", + SpanId = session_id, + ); + } + + &DEFAULT_SCHEDULE + }) + } + + pub fn get_tls_or_default(&self, name: &str, session_id: u64) -> &TlsStrategy { + static DEFAULT_TLS: TlsStrategy = TlsStrategy { + dane: RequireOptional::Optional, + mta_sts: RequireOptional::Optional, + tls: RequireOptional::Optional, + allow_invalid_certs: false, + timeout_tls: Duration::from_secs(3 * 60), + timeout_mta_sts: Duration::from_secs(5 * 60), + }; + self.core + .smtp + .queue + .tls_strategy + .get(name) + .unwrap_or_else(|| { + if name != "default" { + trc::event!( + Smtp(trc::SmtpEvent::IdNotFound), + Id = name.to_string(), + Details = "TLS strategy not found", + SpanId = session_id, + ); + } + + &DEFAULT_TLS + }) + } + + pub fn get_connection_or_default(&self, name: &str, session_id: u64) -> &ConnectionStrategy { + static DEFAULT_CONNECTION: ConnectionStrategy = ConnectionStrategy { + source_ipv4: Vec::new(), + source_ipv6: Vec::new(), + ehlo_hostname: None, + timeout_connect: Duration::from_secs(5 * 60), + timeout_greeting: Duration::from_secs(5 * 60), + timeout_ehlo: Duration::from_secs(5 * 60), + timeout_mail: Duration::from_secs(5 * 60), + timeout_rcpt: Duration::from_secs(5 * 60), + timeout_data: Duration::from_secs(10 * 60), + }; + + self.core + .smtp + .queue + .connection_strategy + .get(name) + .unwrap_or_else(|| { + if name != "default" { + trc::event!( + Smtp(trc::SmtpEvent::IdNotFound), + Id = name.to_string(), + Details = "Connection strategy not found", + SpanId = session_id, + ); + } + + &DEFAULT_CONNECTION + }) } pub async fn get_used_quota(&self, account_id: u32) -> trc::Result { diff --git a/crates/common/src/enterprise/config.rs b/crates/common/src/enterprise/config.rs index 81b7392c..1cf7db93 100644 --- a/crates/common/src/enterprise/config.rs +++ b/crates/common/src/enterprise/config.rs @@ -186,11 +186,7 @@ impl Enterprise { // Parse AI APIs let mut ai_apis = AHashMap::new(); - for id in config - .sub_keys("enterprise.ai", ".url") - .map(|s| s.to_string()) - .collect::>() - { + for id in config.sub_keys("enterprise.ai", ".url") { if let Some(api) = AiApiConfig::parse(config, &id) { ai_apis.insert(id, api.into()); } @@ -308,11 +304,7 @@ impl SpamFilterLlmConfig { pub fn parse_metric_alerts(config: &mut Config) -> Vec { let mut alerts = Vec::new(); - for metric_id in config - .sub_keys("metrics.alerts", ".enable") - .map(|s| s.to_string()) - .collect::>() - { + for metric_id in config.sub_keys("metrics.alerts", ".enable") { if let Some(alert) = parse_metric_alert(config, metric_id) { alerts.push(alert); } diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 914aca31..a17d9f64 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -236,7 +236,6 @@ pub struct Ipc { pub queue_tx: mpsc::Sender, pub report_tx: mpsc::Sender, pub broadcast_tx: Option>, - pub local_delivery_sm: Arc, } pub struct TlsConnectors { @@ -484,7 +483,6 @@ impl Default for Ipc { queue_tx: mpsc::channel(IPC_CHANNEL_BUFFER).0, report_tx: mpsc::channel(IPC_CHANNEL_BUFFER).0, broadcast_tx: None, - local_delivery_sm: Arc::new(Semaphore::new(10)), } } } diff --git a/crates/common/src/manager/backup.rs b/crates/common/src/manager/backup.rs index bf50be77..8771639e 100644 --- a/crates/common/src/manager/backup.rs +++ b/crates/common/src/manager/backup.rs @@ -751,6 +751,7 @@ impl Core { class: ValueClass::Queue(QueueClass::MessageEvent(QueueEvent { due: 0, queue_id: 0, + queue_name: [0; 8], })), }, ValueKey { @@ -760,6 +761,7 @@ impl Core { class: ValueClass::Queue(QueueClass::MessageEvent(QueueEvent { due: u64::MAX, queue_id: u64::MAX, + queue_name: [u8::MAX; 8], })), }, ), diff --git a/crates/common/src/manager/boot.rs b/crates/common/src/manager/boot.rs index c31709da..4a479006 100644 --- a/crates/common/src/manager/boot.rs +++ b/crates/common/src/manager/boot.rs @@ -4,37 +4,34 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::{ - net::{IpAddr, Ipv4Addr}, - path::PathBuf, - sync::Arc, +use super::{ + WEBADMIN_KEY, + backup::BackupParams, + config::{ConfigManager, Patterns}, + console::store_console, }; - -use arc_swap::ArcSwap; -use pwhash::sha512_crypt; -use store::{ - Stores, - rand::{Rng, distr::Alphanumeric, rng}, -}; -use tokio::sync::{Notify, Semaphore, mpsc}; -use utils::{ - Semver, UnwrapFailure, - config::{Config, ConfigKey}, - failed, -}; - use crate::{ Caches, Core, Data, IPC_CHANNEL_BUFFER, Inner, Ipc, config::{network::AsnGeoLookupConfig, server::Listeners, telemetry::Telemetry}, core::BuildServer, ipc::{BroadcastEvent, HousekeeperEvent, QueueEvent, ReportingEvent, StateEvent}, }; - -use super::{ - WEBADMIN_KEY, - backup::BackupParams, - config::{ConfigManager, Patterns}, - console::store_console, +use arc_swap::ArcSwap; +use pwhash::sha512_crypt; +use std::{ + net::{IpAddr, Ipv4Addr}, + path::PathBuf, + sync::Arc, +}; +use store::{ + Stores, + rand::{Rng, distr::Alphanumeric, rng}, +}; +use tokio::sync::{Notify, mpsc}; +use utils::{ + Semver, UnwrapFailure, + config::{Config, ConfigKey}, + failed, }; pub struct BootManager { @@ -429,7 +426,7 @@ impl BootManager { core.network.asn_geo_lookup, AsnGeoLookupConfig::Resource { .. } ); - let (ipc, ipc_rxs) = build_ipc(&mut config, !core.storage.pubsub.is_none()); + let (ipc, ipc_rxs) = build_ipc(!core.storage.pubsub.is_none()); let inner = Arc::new(Inner { shared_core: ArcSwap::from_pointee(core), data, @@ -492,7 +489,7 @@ impl BootManager { } } -pub fn build_ipc(config: &mut Config, has_pubsub: bool) -> (Ipc, IpcReceivers) { +pub fn build_ipc(has_pubsub: bool) -> (Ipc, IpcReceivers) { // Build ipc receivers let (state_tx, state_rx) = mpsc::channel(IPC_CHANNEL_BUFFER); let (housekeeper_tx, housekeeper_rx) = mpsc::channel(IPC_CHANNEL_BUFFER); @@ -507,12 +504,6 @@ pub fn build_ipc(config: &mut Config, has_pubsub: bool) -> (Ipc, IpcReceivers) { report_tx, broadcast_tx: has_pubsub.then_some(broadcast_tx), task_tx: Arc::new(Notify::new()), - local_delivery_sm: Arc::new(Semaphore::new( - config - .property_or_default::("queue.threads.local", "10") - .unwrap_or(10) - .max(1), - )), }, IpcReceivers { state_rx: Some(state_rx), diff --git a/crates/common/src/manager/config.rs b/crates/common/src/manager/config.rs index 05cf41b0..e55c1ee1 100644 --- a/crates/common/src/manager/config.rs +++ b/crates/common/src/manager/config.rs @@ -36,13 +36,13 @@ pub struct Patterns { patterns: Vec, } -#[derive(Debug)] +#[derive(Debug, PartialEq, Eq)] enum Pattern { Include(MatchType), Exclude(MatchType), } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum MatchType { Equal(String), StartsWith(String), @@ -538,6 +538,12 @@ impl Patterns { Pattern::Include(MatchType::Equal("storage.directory".to_string())), Pattern::Include(MatchType::Equal("enterprise.license-key".to_string())), ]; + } else if !cfg_local_patterns.contains(&Pattern::Include(MatchType::StartsWith( + "config.local-keys.".to_string(), + ))) { + cfg_local_patterns.push(Pattern::Include(MatchType::StartsWith( + "config.local-keys.".to_string(), + ))); } Patterns { diff --git a/crates/common/src/manager/restore.rs b/crates/common/src/manager/restore.rs index eaea088b..8e7bd5ba 100644 --- a/crates/common/src/manager/restore.rs +++ b/crates/common/src/manager/restore.rs @@ -256,6 +256,10 @@ async fn restore_file(store: Store, blob_store: BlobStore, path: &Path) { queue_id: key .deserialize_be_u64(1 + U64_LEN) .expect("Failed to deserialize queue message id"), + queue_name: key + .get(1 + U64_LEN + U64_LEN..) + .and_then(|bytes| bytes.try_into().ok()) + .unwrap_or_default(), })), value, ); diff --git a/crates/directory/src/backend/memory/config.rs b/crates/directory/src/backend/memory/config.rs index a1c0e68d..76ea5e65 100644 --- a/crates/directory/src/backend/memory/config.rs +++ b/crates/directory/src/backend/memory/config.rs @@ -28,11 +28,7 @@ impl MemoryDirectory { domains: Default::default(), }; - for lookup_id in config - .sub_keys((prefix.as_str(), "principals"), ".name") - .map(|s| s.to_string()) - .collect::>() - { + for lookup_id in config.sub_keys((prefix.as_str(), "principals"), ".name") { let lookup_id = lookup_id.as_str(); let name = config .value_require((prefix.as_str(), "principals", lookup_id, "name"))? diff --git a/crates/directory/src/core/config.rs b/crates/directory/src/core/config.rs index a3bf5bea..5d67576b 100644 --- a/crates/directory/src/core/config.rs +++ b/crates/directory/src/core/config.rs @@ -33,11 +33,7 @@ impl Directories { ) -> Self { let mut directories = AHashMap::new(); - for id in config - .sub_keys("directory", ".type") - .map(|s| s.to_string()) - .collect::>() - { + for id in config.sub_keys("directory", ".type") { // Parse directory let id = id.as_str(); #[cfg(feature = "test_mode")] diff --git a/crates/email/src/message/delivery.rs b/crates/email/src/message/delivery.rs index ef2684d4..e3d19979 100644 --- a/crates/email/src/message/delivery.rs +++ b/crates/email/src/message/delivery.rs @@ -59,28 +59,6 @@ pub trait MailDelivery: Sync + Send { impl MailDelivery for Server { async fn deliver_message(&self, message: IngestMessage) -> LocalDeliveryResult { - // Obtain permit - let _permit = match self.inner.ipc.local_delivery_sm.acquire().await { - Ok(permit) => permit, - Err(_) => { - trc::error!( - trc::Error::new(trc::EventType::Server(trc::ServerEvent::ThreadError)) - .details("Failed to obtain semaphore permit.") - .span_id(message.session_id) - .caused_by(trc::location!()) - ); - - return LocalDeliveryResult { - status: (0..message.recipients.len()) - .map(|_| LocalDeliveryStatus::TemporaryFailure { - reason: "Temporary I/O error.".into(), - }) - .collect::>(), - autogenerated: vec![], - }; - } - }; - // Read message let raw_message = match self .core diff --git a/crates/http/src/management/queue.rs b/crates/http/src/management/queue.rs index bd77b9cc..0e46b525 100644 --- a/crates/http/src/management/queue.rs +++ b/crates/http/src/management/queue.rs @@ -4,12 +4,16 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::{future::Future, sync::atomic::Ordering}; - +use super::FutureTimestamp; use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; -use common::{Server, auth::AccessToken, ipc::QueueEvent}; - +use common::{ + Server, + auth::AccessToken, + config::smtp::queue::{ArchivedQueueExpiry, QueueExpiry, QueueName}, + ipc::QueueEvent, +}; use directory::{Permission, Type, backend::internal::manage::ManageDirectory}; +use http_proto::{request::decode_path_element, *}; use hyper::Method; use mail_auth::{ dmarc::URI, @@ -21,11 +25,12 @@ use serde::{Deserializer, Serializer}; use serde_json::json; use smtp::{ queue::{ - self, ArchivedMessage, ArchivedStatus, DisplayArchivedResponse, ErrorDetails, HostResponse, - QueueId, Status, spool::SmtpSpool, + self, ArchivedMessage, ArchivedStatus, DisplayArchivedResponse, ErrorDetails, QueueId, + Status, spool::SmtpSpool, }, reporting::{dmarc::DmarcReporting, tls::TlsReporting}, }; +use std::{future::Future, sync::atomic::Ordering}; use store::{ Deserialize, IterateParams, ValueKey, write::{ @@ -35,48 +40,53 @@ use store::{ use trc::AddContext; use utils::url_params::UrlParams; -use super::FutureTimestamp; -use http_proto::{request::decode_path_element, *}; - #[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)] pub struct Message { pub id: QueueId, + pub return_path: String, - pub domains: Vec, + + pub recipients: Vec, + #[serde(deserialize_with = "deserialize_datetime")] #[serde(serialize_with = "serialize_datetime")] pub created: DateTime, + pub size: u64, + #[serde(skip_serializing_if = "is_zero")] #[serde(default)] pub priority: i16, + #[serde(skip_serializing_if = "Option::is_none")] pub env_id: Option, + pub blob_hash: String, } -#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)] -pub struct Domain { - pub name: String, - pub status: Status, - pub recipients: Vec, - - pub retry_num: u32, - #[serde(deserialize_with = "deserialize_maybe_datetime")] - #[serde(serialize_with = "serialize_maybe_datetime")] - pub next_retry: Option, - #[serde(deserialize_with = "deserialize_maybe_datetime")] - #[serde(serialize_with = "serialize_maybe_datetime")] - pub next_notify: Option, - #[serde(deserialize_with = "deserialize_datetime")] - #[serde(serialize_with = "serialize_datetime")] - pub expires: DateTime, -} - #[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)] pub struct Recipient { pub address: String, + pub status: Status, + + pub retry_num: u32, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(deserialize_with = "deserialize_maybe_datetime")] + #[serde(serialize_with = "serialize_maybe_datetime")] + pub next_retry: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(deserialize_with = "deserialize_maybe_datetime")] + #[serde(serialize_with = "serialize_maybe_datetime")] + pub next_notify: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(deserialize_with = "deserialize_maybe_datetime")] + #[serde(serialize_with = "serialize_maybe_datetime")] + pub expires: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub orcpt: Option, } @@ -194,14 +204,12 @@ impl QueueManagement for Server { // Validate the access token access_token.assert_has_permission(Permission::MessageQueueGet)?; - if let Some(message_) = self - .read_message_archive(queue_id.parse().unwrap_or_default()) - .await? - { + let queue_id = queue_id.parse().unwrap_or_default(); + if let Some(message_) = self.read_message_archive(queue_id).await? { let message = message_.unarchive::()?; if message.is_tenant_domain(&tenant_domains) { return Ok(JsonResponse::new(json!({ - "data": Message::from(message), + "data": Message::from_archive(queue_id, message), })) .into_http_response()); } @@ -223,28 +231,30 @@ impl QueueManagement for Server { let server = self.clone(); tokio::spawn(async move { for id in result.ids { - if let Some(mut message) = server.read_message(id).await { - let prev_event = message.next_event().unwrap_or_default(); + if let Some(mut message) = + server.read_message(id, QueueName::default()).await + { let mut has_changes = false; - for domain in &mut message.domains { + for recipient in &mut message.message.recipients { if matches!( - domain.status, + recipient.status, Status::Scheduled | Status::TemporaryFailure(_) ) { - domain.retry.due = time; - if domain.expires > time { - domain.expires = time + 10; + recipient.retry.due = time; + if recipient + .expiration_time(message.message.created) + .is_some_and(|expires| expires > time) + { + recipient.expires = + QueueExpiry::Count(recipient.retry.inner + 10); } has_changes = true; } } if has_changes { - let next_event = message.next_event().unwrap_or_default(); - message - .save_changes(&server, prev_event.into(), next_event.into()) - .await; + message.save_changes(&server, None).await; } } } @@ -269,7 +279,7 @@ impl QueueManagement for Server { let item = params.get("filter"); if let Some(mut message) = self - .read_message(queue_id.parse().unwrap_or_default()) + .read_message(queue_id.parse().unwrap_or_default(), QueueName::default()) .await .filter(|message| { tenant_domains @@ -277,30 +287,29 @@ impl QueueManagement for Server { .is_none_or(|domains| message.has_domain(domains)) }) { - let prev_event = message.next_event().unwrap_or_default(); let mut found = false; - for domain in &mut message.domains { + for recipient in &mut message.message.recipients { if matches!( - domain.status, + recipient.status, Status::Scheduled | Status::TemporaryFailure(_) ) && item .as_ref() - .is_none_or(|item| domain.domain.contains(item)) + .is_none_or(|item| recipient.address_lcase.contains(item)) { - domain.retry.due = time; - if domain.expires > time { - domain.expires = time + 10; + recipient.retry.due = time; + if recipient + .expiration_time(message.message.created) + .is_some_and(|expires| expires > time) + { + recipient.expires = QueueExpiry::Count(recipient.retry.inner + 10); } found = true; } } if found { - let next_event = message.next_event().unwrap_or_default(); - message - .save_changes(self, prev_event.into(), next_event.into()) - .await; + message.save_changes(self, None).await; let _ = self.inner.ipc.queue_tx.send(QueueEvent::Refresh).await; } @@ -334,9 +343,10 @@ impl QueueManagement for Server { } for id in result.ids { - if let Some(message) = server.read_message(id).await { - let prev_event = message.next_event().unwrap_or_default(); - message.remove(&server, prev_event).await; + if let Some(message) = + server.read_message(id, QueueName::default()).await + { + message.remove(&server, None).await; } } @@ -361,7 +371,7 @@ impl QueueManagement for Server { access_token.assert_has_permission(Permission::MessageQueueDelete)?; if let Some(mut message) = self - .read_message(queue_id.parse().unwrap_or_default()) + .read_message(queue_id.parse().unwrap_or_default(), QueueName::default()) .await .filter(|message| { tenant_domains @@ -370,68 +380,32 @@ impl QueueManagement for Server { }) { let mut found = false; - let prev_event = message.next_event().unwrap_or_default(); - if let Some(item) = params.get("filter") { // Cancel delivery for all recipients that match - for rcpt in &mut message.recipients { + for rcpt in &mut message.message.recipients { if rcpt.address_lcase.contains(item) { - rcpt.status = Status::PermanentFailure(HostResponse { - hostname: ErrorDetails::default(), - response: smtp_proto::Response { - code: 0, - esc: [0, 0, 0], - message: "Delivery canceled.".to_string(), - }, + rcpt.status = Status::PermanentFailure(ErrorDetails { + entity: "localhost".to_string(), + details: queue::Error::Io("Delivery canceled.".to_string()), }); found = true; } } if found { - // Mark as completed domains without any pending deliveries - for (domain_idx, domain) in message.domains.iter_mut().enumerate() { - if matches!( - domain.status, - Status::TemporaryFailure(_) | Status::Scheduled - ) { - let mut total_rcpt = 0; - let mut total_completed = 0; - - for rcpt in &message.recipients { - if rcpt.domain_idx == domain_idx as u32 { - total_rcpt += 1; - if matches!( - rcpt.status, - Status::PermanentFailure(_) | Status::Completed(_) - ) { - total_completed += 1; - } - } - } - - if total_rcpt == total_completed { - domain.status = Status::Completed(()); - } - } - } - // Delete message if there are no pending deliveries - if message.domains.iter().any(|domain| { + if message.message.recipients.iter().any(|recipient| { matches!( - domain.status, + recipient.status, Status::TemporaryFailure(_) | Status::Scheduled ) }) { - let next_event = message.next_event().unwrap_or_default(); - message - .save_changes(self, next_event.into(), prev_event.into()) - .await; + message.save_changes(self, None).await; } else { - message.remove(self, prev_event).await; + message.remove(self, None).await; } } } else { - message.remove(self, prev_event).await; + message.remove(self, None).await; found = true; } @@ -596,26 +570,27 @@ impl QueueManagement for Server { } } -impl From<&ArchivedMessage> for Message { - fn from(message: &ArchivedMessage) -> Self { +impl Message { + fn from_archive(id: u64, message: &ArchivedMessage) -> Self { let now = now(); Message { - id: message.queue_id.into(), + id, return_path: message.return_path.to_string(), created: DateTime::from_timestamp(u64::from(message.created) as i64), size: message.size.into(), priority: message.priority.into(), env_id: message.env_id.as_ref().map(|id| id.to_string()), - domains: message - .domains + recipients: message + .recipients .iter() - .enumerate() - .map(|(idx, domain)| Domain { - name: domain.domain.to_string(), - status: match &domain.status { + .map(|rcpt| Recipient { + address: rcpt.address.to_string(), + status: match &rcpt.status { ArchivedStatus::Scheduled => Status::Scheduled, - ArchivedStatus::Completed(_) => Status::Completed(String::new()), + ArchivedStatus::Completed(status) => { + Status::Completed(status.response.to_string()) + } ArchivedStatus::TemporaryFailure(status) => { Status::TemporaryFailure(status.to_string()) } @@ -623,37 +598,22 @@ impl From<&ArchivedMessage> for Message { Status::PermanentFailure(status.to_string()) } }, - retry_num: domain.retry.inner.into(), - next_retry: Some(DateTime::from_timestamp(u64::from(domain.retry.due) as i64)), - next_notify: if domain.notify.due > now { - DateTime::from_timestamp(u64::from(domain.notify.due) as i64).into() + retry_num: rcpt.retry.inner.into(), + next_retry: Some(DateTime::from_timestamp(u64::from(rcpt.retry.due) as i64)), + next_notify: if rcpt.notify.due > now { + DateTime::from_timestamp(u64::from(rcpt.notify.due) as i64).into() } else { None }, - recipients: message - .recipients - .iter() - .filter(|rcpt| u32::from(rcpt.domain_idx) == idx as u32) - .map(|rcpt| Recipient { - address: rcpt.address.to_string(), - status: match &rcpt.status { - ArchivedStatus::Scheduled => Status::Scheduled, - ArchivedStatus::Completed(status) => { - Status::Completed(status.response.to_string()) - } - ArchivedStatus::TemporaryFailure(status) => { - Status::TemporaryFailure(status.response.to_string()) - } - ArchivedStatus::PermanentFailure(status) => { - Status::PermanentFailure(status.response.to_string()) - } - }, - orcpt: rcpt.orcpt.as_ref().map(|orcpt| orcpt.to_string()), - }) - .collect(), - expires: DateTime::from_timestamp(u64::from(domain.expires) as i64), + expires: if let ArchivedQueueExpiry::Duration(time) = &rcpt.expires { + DateTime::from_timestamp((u64::from(*time) + message.created) as i64).into() + } else { + None + }, + orcpt: rcpt.orcpt.as_ref().map(|orcpt| orcpt.to_string()), }) .collect(), + blob_hash: URL_SAFE_NO_PAD.encode::<&[u8]>(message.blob_hash.0.as_slice()), } } @@ -744,10 +704,11 @@ async fn fetch_queued_messages( if matches { if offset == 0 { if limit == 0 || total_returned < limit { + let queue_id = key.deserialize_be_u64(0)?; if values { - result.values.push(Message::from(message)); + result.values.push(Message::from_archive(queue_id, message)); } else { - result.ids.push(key.deserialize_be_u64(0)?); + result.ids.push(queue_id); } total_returned += 1; } diff --git a/crates/http/src/management/troubleshoot.rs b/crates/http/src/management/troubleshoot.rs index b9766fff..d5bea914 100644 --- a/crates/http/src/management/troubleshoot.rs +++ b/crates/http/src/management/troubleshoot.rs @@ -13,7 +13,10 @@ use std::{ use common::{ Server, auth::{AccessToken, oauth::GrantType}, - config::smtp::resolver::{Policy, Tlsa}, + config::smtp::{ + queue::MxConfig, + resolver::{Policy, Tlsa}, + }, psl, }; use directory::backend::internal::manage; @@ -341,7 +344,12 @@ async fn delivery_troubleshoot( }; // Obtain remote host list - let hosts = if let Some(hosts) = mxs.to_remote_hosts(&domain, mxs.len()) { + let mx_config = MxConfig { + max_mx: mxs.len(), + max_multi_homed: 10, + ip_lookup_strategy: IpLookupStrategy::Ipv4thenIpv6, + }; + let hosts = if let Some(hosts) = mxs.to_remote_hosts(&domain, &mx_config) { tx.send(DeliveryStage::MxLookupSuccess { mxs: mxs .iter() diff --git a/crates/jmap/src/submission/get.rs b/crates/jmap/src/submission/get.rs index 9fede625..62c73be3 100644 --- a/crates/jmap/src/submission/get.rs +++ b/crates/jmap/src/submission/get.rs @@ -18,7 +18,7 @@ use jmap_proto::{ value::{Object, Value}, }, }; -use smtp::queue::{ArchivedStatus, Message, spool::SmtpSpool}; +use smtp::queue::{ArchivedError, ArchivedErrorDetails, ArchivedStatus, Message, spool::SmtpSpool}; use smtp_proto::ArchivedResponse; use std::future::Future; use store::rkyv::option::ArchivedOption; @@ -121,7 +121,7 @@ impl EmailSubmissionGet for Server { } ArchivedStatus::TemporaryFailure(reply) | ArchivedStatus::PermanentFailure(reply) => { - format_archived_response(&reply.response) + format_archived_error_details(reply) } ArchivedStatus::Scheduled => "250 2.1.5 Queued".to_string(), }, @@ -232,3 +232,17 @@ fn format_archived_response(response: &ArchivedResponse) -> String { response.message.replace('\n', " "), ) } + +fn format_archived_error_details(response: &ArchivedErrorDetails) -> String { + match &response.details { + ArchivedError::UnexpectedResponse(response) => format_archived_response(&response.response), + ArchivedError::DnsError(details) + | ArchivedError::Io(details) + | ArchivedError::ConnectionError(details) + | ArchivedError::TlsError(details) + | ArchivedError::DaneError(details) + | ArchivedError::MtaStsError(details) => details.to_string(), + ArchivedError::RateLimited => "Rate limited".to_string(), + ArchivedError::ConcurrencyLimited => "Concurrency limited".to_string(), + } +} diff --git a/crates/jmap/src/submission/set.rs b/crates/jmap/src/submission/set.rs index bca2f974..a89b0561 100644 --- a/crates/jmap/src/submission/set.rs +++ b/crates/jmap/src/submission/set.rs @@ -4,14 +4,13 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::{collections::HashMap, sync::Arc, time::Duration}; - +use crate::blob::download::BlobDownload; use common::{ Server, + config::smtp::queue::QueueName, listener::{ServerInstance, stream::NullIo}, storage::index::ObjectIndexBuilder, }; - use email::{ identity::Identity, message::metadata::MessageMetadata, @@ -41,13 +40,12 @@ use smtp::{ queue::spool::SmtpSpool, }; use smtp_proto::{MailFrom, RcptTo, request::parser::Rfc5321Parser}; +use std::future::Future; +use std::{collections::HashMap, sync::Arc, time::Duration}; use store::write::{BatchBuilder, now}; use trc::AddContext; use utils::{BlobHash, map::vec_map::VecMap, sanitize_email}; -use crate::blob::download::BlobDownload; -use std::future::Future; - pub trait EmailSubmissionSet: Sync + Send { fn email_submission_set( &self, @@ -166,10 +164,11 @@ impl EmailSubmissionSet for Server { match undo_status { Some(undo_status) if undo_status == "canceled" => { - if let Some(queue_message) = self.read_message(queue_id).await { + if let Some(queue_message) = + self.read_message(queue_id, QueueName::default()).await + { // Delete message from queue - let message_due = queue_message.next_event().unwrap_or_default(); - queue_message.remove(self, message_due).await; + queue_message.remove(self, None).await; // Update record let mut new_submission = submission.inner.clone(); diff --git a/crates/migration/src/lib.rs b/crates/migration/src/lib.rs index 9857f103..d8710970 100644 --- a/crates/migration/src/lib.rs +++ b/crates/migration/src/lib.rs @@ -57,6 +57,9 @@ pub async fn try_migrate(server: &Server) -> trc::Result<()> { return Ok(()); } + let todo = + "migrate queue + new LegacyRecipient with domain_idx u32 / size u64 + migrate error enum"; + match server .store() .get_value::(AnyKey { diff --git a/crates/migration/src/queue.rs b/crates/migration/src/queue.rs index 2158e983..881c5197 100644 --- a/crates/migration/src/queue.rs +++ b/crates/migration/src/queue.rs @@ -5,16 +5,21 @@ */ use crate::LegacyBincode; -use common::Server; -use smtp::queue::{ - Domain, ErrorDetails, HostResponse, Message, QueueId, QuotaKey, Recipient, Status, +use common::{ + Server, + config::smtp::queue::{QueueExpiry, QueueName}, }; +use smtp::queue::{ + Error, ErrorDetails, HostResponse, Message, QueueId, QuotaKey, Recipient, Schedule, Status, + UnexpectedResponse, +}; +use std::net::{IpAddr, Ipv4Addr}; use store::{ IterateParams, Serialize, U64_LEN, ValueKey, ahash::AHashSet, write::{ AlignedBytes, Archive, Archiver, BatchBuilder, QueueClass, ValueClass, - key::DeserializeBigEndian, + key::DeserializeBigEndian, now, }, }; use trc::AddContext; @@ -25,12 +30,14 @@ pub(crate) async fn migrate_queue(server: &Server) -> trc::Result<()> { store::write::QueueEvent { due: 0, queue_id: 0, + queue_name: [0; 8], }, ))); let to_key = ValueKey::from(ValueClass::Queue(QueueClass::MessageEvent( store::write::QueueEvent { due: u64::MAX, queue_id: u64::MAX, + queue_name: [u8::MAX; 8], }, ))); @@ -74,39 +81,10 @@ pub(crate) async fn migrate_queue(server: &Server) -> trc::Result<()> { .await { Ok(Some(bincoded)) => { - let message = bincoded.inner; - let message = Message { - queue_id: message.queue_id, - created: message.created, - blob_hash: message.blob_hash, - return_path: message.return_path, - return_path_lcase: message.return_path_lcase, - return_path_domain: message.return_path_domain, - recipients: message - .recipients - .into_iter() - .map(|r| Recipient { - domain_idx: r.domain_idx as u32, - address: r.address, - address_lcase: r.address_lcase, - status: r.status, - flags: r.flags, - orcpt: r.orcpt, - }) - .collect(), - domains: message.domains, - flags: message.flags, - env_id: message.env_id, - priority: message.priority, - size: message.size as u64, - quota_keys: message.quota_keys, - span_id: message.span_id, - }; - let mut batch = BatchBuilder::new(); batch.set( ValueClass::Queue(QueueClass::Message(queue_id)), - Archiver::new(message) + Archiver::new(Message::from(bincoded.inner)) .serialize() .caused_by(trc::location!())?, ); @@ -145,6 +123,115 @@ pub(crate) async fn migrate_queue(server: &Server) -> trc::Result<()> { Ok(()) } +impl From for Message { + fn from(message: LegacyMessage) -> Self { + let domains = message.domains; + Message { + created: message.created, + blob_hash: message.blob_hash, + return_path: message.return_path, + return_path_lcase: message.return_path_lcase, + return_path_domain: message.return_path_domain, + recipients: message + .recipients + .into_iter() + .map(|r| { + let domain = &domains[r.domain_idx]; + Recipient { + address: r.address, + address_lcase: r.address_lcase, + status: match r.status { + Status::Scheduled => match &domain.status { + Status::Scheduled | Status::Completed(_) => Status::Scheduled, + Status::TemporaryFailure(err) => Status::TemporaryFailure( + migrate_legacy_error(&domain.domain, err), + ), + Status::PermanentFailure(err) => Status::PermanentFailure( + migrate_legacy_error(&domain.domain, err), + ), + }, + Status::Completed(details) => Status::Completed(details), + Status::TemporaryFailure(err) => { + Status::TemporaryFailure(migrate_host_response(err)) + } + Status::PermanentFailure(err) => { + Status::PermanentFailure(migrate_host_response(err)) + } + }, + flags: r.flags, + orcpt: r.orcpt, + retry: domain.retry.clone(), + notify: domain.notify.clone(), + queue: QueueName::default(), + expires: QueueExpiry::Duration(domain.expires.saturating_sub(now())), + } + }) + .collect(), + flags: message.flags, + env_id: message.env_id, + priority: message.priority, + size: message.size as u64, + quota_keys: message.quota_keys, + received_from_ip: IpAddr::V4(Ipv4Addr::LOCALHOST), + received_via_port: 0, + } + } +} + +fn migrate_legacy_error(domain: &str, err: &LegacyError) -> ErrorDetails { + match err { + LegacyError::DnsError(err) => ErrorDetails { + entity: domain.to_string(), + details: Error::DnsError(err.clone()), + }, + LegacyError::UnexpectedResponse(err) => ErrorDetails { + entity: err.hostname.entity.to_string(), + details: Error::UnexpectedResponse(UnexpectedResponse { + command: err.hostname.details.clone(), + response: err.response.clone(), + }), + }, + LegacyError::ConnectionError(err) => ErrorDetails { + entity: err.entity.to_string(), + details: Error::ConnectionError(err.details.clone()), + }, + LegacyError::TlsError(err) => ErrorDetails { + entity: err.entity.to_string(), + details: Error::TlsError(err.details.clone()), + }, + LegacyError::DaneError(err) => ErrorDetails { + entity: err.entity.to_string(), + details: Error::DaneError(err.details.clone()), + }, + LegacyError::MtaStsError(err) => ErrorDetails { + entity: domain.to_string(), + details: Error::MtaStsError(err.clone()), + }, + LegacyError::RateLimited => ErrorDetails { + entity: domain.to_string(), + details: Error::RateLimited, + }, + LegacyError::ConcurrencyLimited => ErrorDetails { + entity: domain.to_string(), + details: Error::ConcurrencyLimited, + }, + LegacyError::Io(err) => ErrorDetails { + entity: domain.to_string(), + details: Error::Io(err.clone()), + }, + } +} + +fn migrate_host_response(response: HostResponse) -> ErrorDetails { + ErrorDetails { + entity: response.hostname.entity, + details: Error::UnexpectedResponse(UnexpectedResponse { + command: response.hostname.details, + response: response.response, + }), + } +} + #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] pub struct LegacyMessage { pub queue_id: QueueId, @@ -155,7 +242,7 @@ pub struct LegacyMessage { pub return_path_lcase: String, pub return_path_domain: String, pub recipients: Vec, - pub domains: Vec, + pub domains: Vec, pub flags: u64, pub env_id: Option, @@ -173,7 +260,35 @@ pub struct LegacyRecipient { pub domain_idx: usize, pub address: String, pub address_lcase: String, - pub status: Status, HostResponse>, + pub status: Status, HostResponse>, pub flags: u64, pub orcpt: Option, } + +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] +pub struct LegacyDomain { + pub domain: String, + pub retry: Schedule, + pub notify: Schedule, + pub expires: u64, + pub status: Status<(), LegacyError>, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] +pub enum LegacyError { + DnsError(String), + UnexpectedResponse(HostResponse), + ConnectionError(LegacyErrorDetails), + TlsError(LegacyErrorDetails), + DaneError(LegacyErrorDetails), + MtaStsError(String), + RateLimited, + ConcurrencyLimited, + Io(String), +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] +pub struct LegacyErrorDetails { + pub entity: String, + pub details: String, +} diff --git a/crates/smtp/src/inbound/data.rs b/crates/smtp/src/inbound/data.rs index 55cca8ef..b18fb0c9 100644 --- a/crates/smtp/src/inbound/data.rs +++ b/crates/smtp/src/inbound/data.rs @@ -9,7 +9,7 @@ use crate::{ core::{Session, SessionAddress, State}, inbound::milter::Modification, queue::{ - self, DMARC_AUTHENTICATED, Message, MessageSource, QueueEnvelope, Schedule, + self, DMARC_AUTHENTICATED, Message, MessageSource, MessageWrapper, QueueEnvelope, Schedule, quota::HasQueueQuota, }, reporting::analysis::AnalyzeReport, @@ -17,7 +17,11 @@ use crate::{ }; use common::{ config::{ - smtp::{auth::VerifyStrategy, session::Stage}, + smtp::{ + auth::VerifyStrategy, + queue::{QueueExpiry, QueueName}, + session::Stage, + }, spamfilter::SpamFilterAction, }, listener::SessionStream, @@ -37,9 +41,8 @@ use smtp_proto::{ }; use std::{ borrow::Cow, - time::{Duration, Instant, SystemTime}, + time::{Instant, SystemTime}, }; -use store::write::now; use trc::SmtpEvent; use utils::config::Rate; @@ -603,7 +606,7 @@ impl Session { .unwrap_or(true) { headers.extend_from_slice(b"Return-Path: <"); - headers.extend_from_slice(message.return_path.as_bytes()); + headers.extend_from_slice(message.message.return_path.as_bytes()); headers.extend_from_slice(b">\r\n"); } @@ -656,7 +659,7 @@ impl Session { } // Update size - message.size = (raw_message.len() + headers.len()) as u64; + message.message.size = (raw_message.len() + headers.len()) as u64; // Verify queue quota if self.server.has_quota(&mut message).await { @@ -672,7 +675,7 @@ impl Session { if self.is_authenticated() || dmarc_result.is_some_and(|result| result == DmarcResult::Pass) { - message.flags |= DMARC_AUTHENTICATED; + message.message.flags |= DMARC_AUTHENTICATED; } if message .queue( @@ -703,115 +706,32 @@ impl Session { mut rcpt_to: Vec, queue_id: u64, span_id: u64, - ) -> Message { + ) -> MessageWrapper { // Build message let created = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .map_or(0, |d| d.as_secs()); let mut message = Message { - queue_id, - span_id, created, return_path: mail_from.address, return_path_lcase: mail_from.address_lcase, return_path_domain: mail_from.domain, recipients: Vec::with_capacity(rcpt_to.len()), - domains: Vec::with_capacity(3), flags: mail_from.flags, priority: self.data.priority, size: 0, env_id: mail_from.dsn_info, blob_hash: Default::default(), quota_keys: Vec::new(), + received_from_ip: self.data.remote_ip, + received_via_port: self.data.local_port, }; // Add recipients - let future_release = Duration::from_secs(self.data.future_release); + let future_release = self.data.future_release; rcpt_to.sort_unstable(); for rcpt in rcpt_to { - if message - .domains - .last() - .is_none_or(|d| d.domain != rcpt.domain) - { - let rcpt_idx = message.domains.len(); - message.domains.push(queue::Domain { - retry: Schedule::now(), - notify: Schedule::now(), - expires: 0, - status: queue::Status::Scheduled, - domain: rcpt.domain, - }); - - let envelope = QueueEnvelope::new(&message, rcpt_idx); - - // Set next retry time - let retry = if self.data.future_release == 0 { - queue::Schedule::now() - } else { - queue::Schedule::later(future_release) - }; - - // Set expiration and notification times - let config = &self.server.core.smtp.queue; - let (num_intervals, next_notify) = self - .server - .eval_if::, _>(&config.notify, &envelope, self.data.session_id) - .await - .and_then(|v| (v.len(), v.into_iter().next()?).into()) - .unwrap_or_else(|| (1, Duration::from_secs(86400))); - let (notify, expires) = if self.data.delivery_by == 0 { - ( - queue::Schedule::later(future_release + next_notify), - now() - + future_release.as_secs() - + self - .server - .eval_if(&config.expire, &envelope, self.data.session_id) - .await - .unwrap_or_else(|| Duration::from_secs(5 * 86400)) - .as_secs(), - ) - } else if (message.flags & MAIL_BY_RETURN) != 0 { - ( - queue::Schedule::later(future_release + next_notify), - now() + self.data.delivery_by as u64, - ) - } else { - let expire = self - .server - .eval_if(&config.expire, &envelope, self.data.session_id) - .await - .unwrap_or_else(|| Duration::from_secs(5 * 86400)); - let expire_secs = expire.as_secs(); - let notify = if self.data.delivery_by.is_positive() { - let notify_at = self.data.delivery_by as u64; - if expire_secs > notify_at { - Duration::from_secs(notify_at) - } else { - next_notify - } - } else { - let notify_at = -self.data.delivery_by as u64; - if expire_secs > notify_at { - Duration::from_secs(expire_secs - notify_at) - } else { - next_notify - } - }; - let mut notify = queue::Schedule::later(future_release + notify); - notify.inner = (num_intervals - 1) as u32; // Disable further notification attempts - - (notify, now() + expire_secs) - }; - - // Update domain - let domain = message.domains.last_mut().unwrap(); - domain.retry = retry; - domain.notify = notify; - domain.expires = expires; - } - + let rcpt_idx = message.recipients.len(); message.recipients.push(queue::Recipient { address: rcpt.address, address_lcase: rcpt.address_lcase, @@ -827,11 +747,98 @@ impl Session { } else { rcpt.flags | RCPT_NOTIFY_DELAY | RCPT_NOTIFY_FAILURE }, - domain_idx: (message.domains.len() - 1) as u32, orcpt: rcpt.dsn_info, + retry: Schedule::now(), + notify: Schedule::now(), + expires: QueueExpiry::Count(0), + queue: QueueName::default(), }); + + let envelope = QueueEnvelope::new_rcpt(&message, rcpt_idx); + + // Set next retry time + let retry = if self.data.future_release == 0 { + queue::Schedule::now() + } else { + queue::Schedule::later(future_release) + }; + + // Resolve queue + let queue = self.server.get_queue_or_default( + &self + .server + .eval_if::( + &self.server.core.smtp.queue.queue, + &envelope, + self.data.session_id, + ) + .await + .unwrap_or_else(|| "default".to_string()), + self.data.session_id, + ); + + // Set expiration and notification times + let num_intervals = std::cmp::max(queue.notify.len(), 1); + let next_notify = queue.notify.first().copied().unwrap_or(86400); + let (notify, expires) = if self.data.delivery_by == 0 { + ( + queue::Schedule::later(future_release + next_notify), + match queue.expiry { + QueueExpiry::Duration(time) => QueueExpiry::Duration(future_release + time), + QueueExpiry::Count(count) => QueueExpiry::Count(count), + }, + ) + } else if (message.flags & MAIL_BY_RETURN) != 0 { + ( + queue::Schedule::later(future_release + next_notify), + QueueExpiry::Duration(self.data.delivery_by as u64), + ) + } else { + let (notify, expires) = match queue.expiry { + QueueExpiry::Duration(expire_secs) => ( + (if self.data.delivery_by.is_positive() { + let notify_at = self.data.delivery_by as u64; + if expire_secs > notify_at { + notify_at + } else { + next_notify + } + } else { + let notify_at = -self.data.delivery_by as u64; + if expire_secs > notify_at { + expire_secs - notify_at + } else { + next_notify + } + }), + QueueExpiry::Duration(expire_secs), + ), + QueueExpiry::Count(_) => ( + next_notify, + QueueExpiry::Duration(self.data.delivery_by.unsigned_abs()), + ), + }; + + let mut notify = queue::Schedule::later(future_release + notify); + notify.inner = (num_intervals - 1) as u32; // Disable further notification attempts + + (notify, expires) + }; + + // Update recipient + let recipient = message.recipients.last_mut().unwrap(); + recipient.retry = retry; + recipient.notify = notify; + recipient.expires = expires; + recipient.queue = queue.virtual_queue; + } + + MessageWrapper { + queue_id, + queue_name: QueueName::default(), + span_id, + message, } - message } pub async fn can_send_data(&mut self) -> Result { diff --git a/crates/smtp/src/outbound/client.rs b/crates/smtp/src/outbound/client.rs index 40a6a847..a031c21b 100644 --- a/crates/smtp/src/outbound/client.rs +++ b/crates/smtp/src/outbound/client.rs @@ -4,11 +4,8 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::{ - net::{IpAddr, SocketAddr}, - time::Duration, -}; - +use super::session::SessionParams; +use crate::queue::{Error, ErrorDetails, HostResponse, MessageWrapper, Status}; use mail_send::{Credentials, smtp::AssertReply}; use rustls::ClientConnection; use rustls_pki_types::ServerName; @@ -20,6 +17,10 @@ use smtp_proto::{ parser::{MAX_RESPONSE_LENGTH, ResponseReceiver}, }, }; +use std::{ + net::{IpAddr, SocketAddr}, + time::Duration, +}; use tokio::{ io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}, net::{TcpSocket, TcpStream}, @@ -27,10 +28,6 @@ use tokio::{ use tokio_rustls::{TlsConnector, client::TlsStream}; use trc::DeliveryEvent; -use crate::queue::{Error, Message, Status}; - -use super::session::SessionParams; - pub struct SmtpClient { pub stream: T, pub timeout: Duration, @@ -128,7 +125,10 @@ impl SmtpClient { Err(mail_send::Error::UnexpectedReply(reply)) } - pub async fn read_greeting(&mut self, hostname: &str) -> Result<(), Status<(), Error>> { + pub async fn read_greeting( + &mut self, + hostname: &str, + ) -> Result<(), Status, ErrorDetails>> { tokio::time::timeout(self.timeout, self.read()) .await .map_err(|_| Status::timeout(hostname, "reading greeting"))? @@ -140,7 +140,7 @@ impl SmtpClient { &mut self, hostname: &str, bdat_cmd: &Option, - ) -> Result, Status<(), Error>> { + ) -> Result, Status, ErrorDetails>> { tokio::time::timeout(self.timeout, self.read()) .await .map_err(|_| Status::timeout(hostname, "reading SMTP DATA response"))? @@ -153,7 +153,7 @@ impl SmtpClient { &mut self, hostname: &str, num_responses: usize, - ) -> Result>, Status<(), Error>> { + ) -> Result>, Status, ErrorDetails>> { tokio::time::timeout(self.timeout, async { self.read_many(num_responses).await }) .await .map_err(|_| Status::timeout(hostname, "reading LMTP DATA responses"))? @@ -172,57 +172,64 @@ impl SmtpClient { pub async fn send_message( &mut self, - message: &Message, + message: &MessageWrapper, bdat_cmd: &Option, params: &SessionParams<'_>, - ) -> Result<(), Status<(), Error>> { + ) -> Result<(), Status, ErrorDetails>> { match params .server .blob_store() - .get_blob(message.blob_hash.as_slice(), 0..usize::MAX) + .get_blob(message.message.blob_hash.as_slice(), 0..usize::MAX) .await { - Ok(Some(raw_message)) => tokio::time::timeout(params.timeout_data, async { - if let Some(bdat_cmd) = bdat_cmd { - trc::event!( - Delivery(DeliveryEvent::RawOutput), - SpanId = self.session_id, - Contents = bdat_cmd.clone(), - Size = bdat_cmd.len() - ); + Ok(Some(raw_message)) => { + tokio::time::timeout(params.conn_strategy.timeout_data, async { + if let Some(bdat_cmd) = bdat_cmd { + trc::event!( + Delivery(DeliveryEvent::RawOutput), + SpanId = self.session_id, + Contents = bdat_cmd.clone(), + Size = bdat_cmd.len() + ); - self.write_chunks(&[bdat_cmd.as_bytes(), &raw_message]) - .await - } else { - trc::event!( - Delivery(DeliveryEvent::RawOutput), - SpanId = self.session_id, - Contents = "DATA\r\n", - Size = 6 - ); + self.write_chunks(&[bdat_cmd.as_bytes(), &raw_message]) + .await + } else { + trc::event!( + Delivery(DeliveryEvent::RawOutput), + SpanId = self.session_id, + Contents = "DATA\r\n", + Size = 6 + ); - self.write_chunks(&[b"DATA\r\n"]).await?; - self.read().await?.assert_code(354)?; - self.write_message(&raw_message) - .await - .map_err(mail_send::Error::from) - } - }) - .await - .map_err(|_| Status::timeout(params.hostname, "sending message"))? - .map_err(|err| { - Status::from_smtp_error(params.hostname, bdat_cmd.as_deref().unwrap_or("DATA"), err) - }), + self.write_chunks(&[b"DATA\r\n"]).await?; + self.read().await?.assert_code(354)?; + self.write_message(&raw_message) + .await + .map_err(mail_send::Error::from) + } + }) + .await + .map_err(|_| Status::timeout(params.hostname, "sending message"))? + .map_err(|err| { + Status::from_smtp_error( + params.hostname, + bdat_cmd.as_deref().unwrap_or("DATA"), + err, + ) + }) + } Ok(None) => { trc::event!( Queue(trc::QueueEvent::BlobNotFound), SpanId = message.span_id, - BlobId = message.blob_hash.to_hex(), + BlobId = message.message.blob_hash.to_hex(), CausedBy = trc::location!() ); - Err(Status::TemporaryFailure(Error::Io( - "Queue system error.".into(), - ))) + Err(Status::TemporaryFailure(ErrorDetails { + entity: "localhost".to_string(), + details: Error::Io("Queue system error.".into()), + })) } Err(err) => { trc::error!( @@ -231,9 +238,10 @@ impl SmtpClient { .caused_by(trc::location!()) ); - Err(Status::TemporaryFailure(Error::Io( - "Queue system error.".into(), - ))) + Err(Status::TemporaryFailure(ErrorDetails { + entity: "localhost".to_string(), + details: Error::Io("Queue system error.".into()), + })) } } } @@ -241,7 +249,7 @@ impl SmtpClient { pub async fn say_helo( &mut self, params: &SessionParams<'_>, - ) -> Result, Status<(), Error>> { + ) -> Result, Status, ErrorDetails>> { let cmd = if params.is_smtp { format!("EHLO {}\r\n", params.local_hostname) } else { @@ -255,7 +263,7 @@ impl SmtpClient { Size = cmd.len() ); - tokio::time::timeout(params.timeout_ehlo, async { + tokio::time::timeout(params.conn_strategy.timeout_ehlo, async { self.stream.write_all(cmd.as_bytes()).await?; self.stream.flush().await?; self.read_ehlo().await @@ -646,28 +654,35 @@ pub(crate) fn from_mail_send_error(error: &mail_send::Error) -> trc::Error { } } -pub(crate) fn from_error_status(status: &Status<(), Error>) -> trc::Error { - let event = trc::EventType::Smtp(trc::SmtpEvent::Error).into_err(); - let err = match status { - Status::TemporaryFailure(err) | Status::PermanentFailure(err) => err, - Status::Scheduled | Status::Completed(_) => return event, // This should not happen - }; +pub(crate) fn from_error_status(err: &Status, ErrorDetails>) -> trc::Error { + match err { + Status::Scheduled | Status::Completed(_) => { + trc::EventType::Smtp(trc::SmtpEvent::Error).into_err() + } + Status::TemporaryFailure(err) | Status::PermanentFailure(err) => { + from_error_details(&err.details) + } + } +} +pub(crate) fn from_error_details(err: &Error) -> trc::Error { + let event = trc::EventType::Smtp(trc::SmtpEvent::Error).into_err(); match err { Error::DnsError(err) => event.details("DNS Error").reason(err), Error::UnexpectedResponse(reply) => event .details("Unexpected SMTP Response") .ctx(trc::Key::Code, reply.response.code) + .ctx(trc::Key::Details, reply.command.clone()) .ctx(trc::Key::Reason, reply.response.message.clone()), Error::ConnectionError(err) => event .details("Connection Error") - .ctx(trc::Key::Reason, err.details.clone()), + .ctx(trc::Key::Reason, err.clone()), Error::TlsError(err) => event .details("TLS Error") - .ctx(trc::Key::Reason, err.details.clone()), + .ctx(trc::Key::Reason, err.clone()), Error::DaneError(err) => event .details("DANE Error") - .ctx(trc::Key::Reason, err.details.clone()), + .ctx(trc::Key::Reason, err.clone()), Error::MtaStsError(err) => event.details("MTA-STS Error").reason(err), Error::RateLimited => event.details("Rate Limited"), Error::ConcurrencyLimited => event.details("Concurrency Limited"), diff --git a/crates/smtp/src/outbound/dane/verify.rs b/crates/smtp/src/outbound/dane/verify.rs index dafef438..68feb461 100644 --- a/crates/smtp/src/outbound/dane/verify.rs +++ b/crates/smtp/src/outbound/dane/verify.rs @@ -11,7 +11,7 @@ use sha2::{Sha256, Sha512}; use trc::DaneEvent; use x509_parser::prelude::{FromDer, X509Certificate}; -use crate::queue::{Error, ErrorDetails, Status}; +use crate::queue::{Error, ErrorDetails, HostResponse, Status}; pub trait TlsaVerify { fn verify( @@ -19,7 +19,7 @@ pub trait TlsaVerify { session_id: u64, hostname: &str, certificates: Option<&[CertificateDer<'_>]>, - ) -> Result<(), Status<(), Error>>; + ) -> Result<(), Status, ErrorDetails>>; } impl TlsaVerify for Tlsa { @@ -28,7 +28,7 @@ impl TlsaVerify for Tlsa { session_id: u64, hostname: &str, certificates: Option<&[CertificateDer<'_>]>, - ) -> Result<(), Status<(), Error>> { + ) -> Result<(), Status, ErrorDetails>> { let certificates = if let Some(certificates) = certificates { certificates } else { @@ -38,10 +38,10 @@ impl TlsaVerify for Tlsa { Hostname = hostname.to_string(), ); - return Err(Status::TemporaryFailure(Error::DaneError(ErrorDetails { + return Err(Status::TemporaryFailure(ErrorDetails { entity: hostname.into(), - details: "No certificates were provided by host".into(), - }))); + details: Error::DaneError("No certificates were provided by host".into()), + })); }; let mut matched_end_entity = false; @@ -58,10 +58,10 @@ impl TlsaVerify for Tlsa { Reason = err.to_string(), ); - return Err(Status::TemporaryFailure(Error::DaneError(ErrorDetails { + return Err(Status::TemporaryFailure(ErrorDetails { entity: hostname.into(), - details: "Failed to parse X.509 certificate".into(), - }))); + details: Error::DaneError("Failed to parse X.509 certificate".into()), + })); } }; @@ -142,10 +142,10 @@ impl TlsaVerify for Tlsa { Hostname = hostname.to_string(), ); - Err(Status::PermanentFailure(Error::DaneError(ErrorDetails { + Err(Status::PermanentFailure(ErrorDetails { entity: hostname.into(), - details: "No matching certificates found in TLSA records".into(), - }))) + details: Error::DaneError("No matching certificates found in TLSA records".into()), + })) } } } diff --git a/crates/smtp/src/outbound/delivery.rs b/crates/smtp/src/outbound/delivery.rs index a4fb54c6..66c46cfa 100644 --- a/crates/smtp/src/outbound/delivery.rs +++ b/crates/smtp/src/outbound/delivery.rs @@ -4,23 +4,30 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use crate::outbound::client::{SmtpClient, from_error_status, from_mail_send_error}; +use super::{NextHop, lookup::ToNextHop, mta_sts, session::SessionParams}; +use crate::outbound::DeliveryResult; +use crate::outbound::client::{ + SmtpClient, from_error_details, from_error_status, from_mail_send_error, +}; use crate::outbound::dane::dnssec::TlsaLookup; -use crate::outbound::lookup::DnsLookup; +use crate::outbound::lookup::{DnsLookup, SourceIp}; use crate::outbound::mta_sts::lookup::MtaStsLookup; use crate::outbound::mta_sts::verify::VerifyPolicy; use crate::outbound::{client::StartTlsResult, dane::verify::TlsaVerify}; use crate::queue::dsn::SendDsn; use crate::queue::spool::{LOCK_EXPIRY, SmtpSpool}; use crate::queue::throttle::IsAllowed; -use crate::reporting::SmtpReporting; -use common::Server; -use common::config::{ - server::ServerProtocol, - smtp::{queue::RequireOptional, report::AggregateFrequency}, +use crate::queue::{ + DomainPart, Error, FROM_REPORT, HostResponse, MessageWrapper, QueueEnvelope, QueuedMessage, + Status, }; +use crate::reporting::SmtpReporting; +use crate::{queue::ErrorDetails, reporting::tls::TlsRptOptions}; +use ahash::AHashMap; +use common::Server; +use common::config::smtp::queue::GatewayStrategy; +use common::config::{server::ServerProtocol, smtp::report::AggregateFrequency}; use common::ipc::{PolicyType, QueueEvent, QueueEventStatus, TlsEvent}; - use compact_str::ToCompactString; use mail_auth::{ mta_sts::TlsRpt, @@ -31,19 +38,11 @@ use smtp_proto::MAIL_REQUIRETLS; use std::sync::Arc; use std::{ net::{IpAddr, Ipv4Addr, SocketAddr}, - time::{Duration, Instant}, + time::Instant, }; use store::write::{BatchBuilder, QueueClass, ValueClass, now}; use trc::{DaneEvent, DeliveryEvent, MtaStsEvent, ServerEvent, TlsRptEvent}; -use crate::{ - queue::{ErrorDetails, Message}, - reporting::tls::TlsRptOptions, -}; - -use super::{NextHop, TlsStrategy, lookup::ToNextHop, mta_sts, session::SessionParams}; -use crate::queue::{Domain, Error, FROM_REPORT, QueueEnvelope, QueuedMessage, Status}; - impl QueuedMessage { pub fn try_deliver(self, server: Server) { #![allow(clippy::large_futures)] @@ -51,7 +50,7 @@ impl QueuedMessage { // Lock queue event let queue_id = self.queue_id; let status = if server.try_lock_event(queue_id).await { - if let Some(mut message) = server.read_message(queue_id).await { + if let Some(mut message) = server.read_message(queue_id, self.queue_name).await { // Generate span id message.span_id = server.inner.data.span_id_gen.generate(); let span_id = message.span_id; @@ -60,27 +59,30 @@ impl QueuedMessage { Delivery(DeliveryEvent::AttemptStart), SpanId = message.span_id, QueueId = message.queue_id, - From = if !message.return_path.is_empty() { - trc::Value::String(message.return_path.as_str().into()) + QueueName = message.queue_name.as_str().to_string(), + From = if !message.message.return_path.is_empty() { + trc::Value::String(message.message.return_path.as_str().into()) } else { trc::Value::String("<>".into()) }, To = message + .message .recipients .iter() .filter_map(|r| { if matches!( r.status, Status::Scheduled | Status::TemporaryFailure(_) - ) { + ) && r.queue == message.queue_name + { Some(trc::Value::String(r.address_lcase.as_str().into())) } else { None } }) .collect::>(), - Size = message.size, - Total = message.recipients.len(), + Size = message.message.size, + Total = message.message.recipients.len(), ); // Attempt delivery @@ -104,6 +106,7 @@ impl QueuedMessage { store::write::QueueEvent { due: self.due, queue_id: self.queue_id, + queue_name: self.queue_name.into_inner(), }, ))); @@ -143,7 +146,7 @@ impl QueuedMessage { }); } - async fn deliver_task(self, server: Server, mut message: Message) -> QueueEventStatus { + async fn deliver_task(self, server: Server, mut message: MessageWrapper) -> QueueEventStatus { // Check that the message still has recipients to be delivered let has_pending_delivery = message.has_pending_delivery(); let span_id = message.span_id; @@ -151,36 +154,45 @@ impl QueuedMessage { // Send any due Delivery Status Notifications server.send_dsn(&mut message).await; - if has_pending_delivery { - // Re-queue the message if its not yet due for delivery - let due = message.next_delivery_event(); - if due > now() { - // Save changes - message - .save_changes(&server, self.due.into(), due.into()) - .await; + match has_pending_delivery { + PendingDelivery::Yes(true) + if message + .message + .next_delivery_event(self.queue_name.into()) + .is_some_and(|due| due <= now()) => {} + PendingDelivery::No => { + trc::event!( + Delivery(DeliveryEvent::Completed), + SpanId = span_id, + Elapsed = trc::Value::Duration((now() - message.message.created) * 1000) + ); + + // All message recipients expired, do not re-queue. (DSN has been already sent) + message.remove(&server, self.due.into()).await; + + return QueueEventStatus::Completed; + } + _ => { + // Re-queue the message if its not yet due for delivery + message.save_changes(&server, self.due.into()).await; return QueueEventStatus::Deferred; } - } else { - trc::event!( - Delivery(DeliveryEvent::Completed), - SpanId = span_id, - Elapsed = trc::Value::Duration((now() - message.created) * 1000) - ); - - // All message recipients expired, do not re-queue. (DSN has been already sent) - message.remove(&server, self.due).await; - - return QueueEventStatus::Completed; } // Throttle sender for throttle in &server.core.smtp.queue.outbound_limiters.sender { - if let Err(retry_at) = server.is_allowed(throttle, &message, message.span_id).await { + if let Err(retry_at) = server + .is_allowed(throttle, &message.message, message.span_id) + .await + { // Save changes to disk + let now = now(); let next_event = std::cmp::min( retry_at, - message.next_event_after(now()).unwrap_or(u64::MAX), + message + .message + .next_event_after(self.queue_name.into(), now) + .unwrap_or(u64::MAX), ); trc::event!( @@ -190,35 +202,65 @@ impl QueuedMessage { NextRetry = trc::Value::Timestamp(next_event) ); - message - .save_changes(&server, self.due.into(), next_event.into()) - .await; + for rcpt in message.message.recipients.iter_mut() { + if matches!( + &rcpt.status, + Status::Scheduled | Status::TemporaryFailure(_) + ) && rcpt.retry.due <= now + && rcpt.queue == message.queue_name + { + rcpt.retry.due = retry_at; + rcpt.status = Status::TemporaryFailure(ErrorDetails { + entity: "localhost".to_string(), + details: Error::RateLimited, + }); + } + } + + message.save_changes(&server, self.due.into()).await; return QueueEventStatus::Deferred; } } + // Group recipients by gateway let queue_config = &server.core.smtp.queue; - let no_ip = IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)); - let mut recipients = std::mem::take(&mut message.recipients); - 'next_domain: for domain_idx in 0..message.domains.len() { - // Only process domains due for delivery - let domain = &message.domains[domain_idx]; - if !matches!(&domain.status, Status::Scheduled | Status::TemporaryFailure(_) - if domain.retry.due <= now()) + let now_ = now(); + let mut gateways: AHashMap<(&str, &GatewayStrategy), Vec> = AHashMap::new(); + for (rcpt_idx, rcpt) in message.message.recipients.iter().enumerate() { + if matches!( + &rcpt.status, + Status::Scheduled | Status::TemporaryFailure(_) + ) && rcpt.retry.due <= now_ + && rcpt.queue == message.queue_name { - continue; - } + let envelope = QueueEnvelope::new_rcpt(&message.message, rcpt_idx); + let gateway = server.get_gateway_or_default( + &server + .eval_if::(&queue_config.gateway, &envelope, message.span_id) + .await + .unwrap_or_else(|| "default".to_string()), + message.span_id, + ); + gateways + .entry((rcpt.address_lcase.domain_part(), gateway)) + .or_default() + .push(rcpt_idx); + } + } + + let no_ip = IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)); + let mut delivery_results: Vec = Vec::new(); + 'next_gateway: for ((domain, gateway), rcpt_idxs) in gateways { trc::event!( Delivery(DeliveryEvent::DomainDeliveryStart), SpanId = message.span_id, - Domain = domain.domain.clone(), - Total = domain.retry.inner, + Domain = domain.to_string(), ); // Build envelope - let mut envelope = QueueEnvelope::new(&message, domain_idx); + let mut envelope = QueueEnvelope::new_rcpt(&message.message, rcpt_idxs[0]); // Throttle recipient domain for throttle in &queue_config.outbound_limiters.rcpt { @@ -230,151 +272,125 @@ impl QueuedMessage { Delivery(DeliveryEvent::RateLimitExceeded), Id = throttle.id.clone(), SpanId = span_id, - Domain = domain.domain.clone(), + Domain = domain.to_string(), ); - message.domains[domain_idx].set_rate_limiter_error(retry_at); - continue 'next_domain; + delivery_results.push(DeliveryResult::rate_limited(rcpt_idxs, retry_at)); + continue 'next_gateway; } } // Obtain next hop - let (mut remote_hosts, is_smtp) = match server - .eval_if::(&queue_config.next_hop, &envelope, message.span_id) - .await - .and_then(|name| server.get_relay_host(&name, message.span_id)) - { - Some(next_hop) if next_hop.protocol == ServerProtocol::Http => { + let (mut remote_hosts, mx_config, is_smtp) = match gateway { + GatewayStrategy::Local => { // Deliver message locally - let delivery_result = message - .deliver_local( - recipients - .iter_mut() - .filter(|r| r.domain_idx == domain_idx as u32), - &server, - ) + message + .deliver_local(&rcpt_idxs, &mut delivery_results, &server) .await; + continue 'next_gateway; + } + GatewayStrategy::Mx(mx_config) => (Vec::with_capacity(0), Some(mx_config), true), + GatewayStrategy::Relay(relay_config) => ( + vec![NextHop::Relay(relay_config)], + None, + relay_config.protocol == ServerProtocol::Smtp, + ), + }; - // Update status for the current domain and continue with the next one - let schedule = server - .eval_if::, _>( - &queue_config.retry, + // Prepare TLS strategy + let mut tls_strategy = server.get_tls_or_default( + &server + .eval_if::(&queue_config.tls, &envelope, message.span_id) + .await + .unwrap_or_else(|| "default".to_string()), + message.span_id, + ); + + // Obtain TLS reporting + let tls_report = + if is_smtp && mx_config.is_some() && (message.message.flags & FROM_REPORT == 0) { + match server + .eval_if( + &server.core.smtp.report.tls.send, &envelope, message.span_id, ) .await - .unwrap_or_else(|| vec![Duration::from_secs(60)]); - message.domains[domain_idx].set_status(delivery_result, &schedule); - continue 'next_domain; - } - Some(next_hop) => ( - vec![NextHop::Relay(next_hop)], - next_hop.protocol == ServerProtocol::Smtp, - ), - None => (Vec::with_capacity(0), true), - }; - - // Prepare TLS strategy - let mut tls_strategy = TlsStrategy { - mta_sts: server - .eval_if(&queue_config.tls.mta_sts, &envelope, message.span_id) - .await - .unwrap_or(RequireOptional::Optional), - ..Default::default() - }; - let allow_invalid_certs = server - .eval_if(&queue_config.tls.invalid_certs, &envelope, message.span_id) - .await - .unwrap_or(false); - - // Obtain TLS reporting - let tls_report = match server - .eval_if( - &server.core.smtp.report.tls.send, - &envelope, - message.span_id, - ) - .await - .unwrap_or(AggregateFrequency::Never) - { - interval @ (AggregateFrequency::Hourly - | AggregateFrequency::Daily - | AggregateFrequency::Weekly) - if is_smtp && (message.flags & FROM_REPORT == 0) => - { - let time = Instant::now(); - match server - .core - .smtp - .resolvers - .dns - .txt_lookup::( - format!("_smtp._tls.{}.", domain.domain), - Some(&server.inner.cache.dns_txt), - ) - .await + .unwrap_or(AggregateFrequency::Never) { - Ok(record) => { - trc::event!( - TlsRpt(TlsRptEvent::RecordFetch), - SpanId = message.span_id, - Domain = domain.domain.clone(), - Details = record - .rua - .iter() - .map(|uri| trc::Value::from(match uri { - mail_auth::mta_sts::ReportUri::Mail(uri) - | mail_auth::mta_sts::ReportUri::Http(uri) => - uri.to_string(), - })) - .collect::>(), - Elapsed = time.elapsed(), - ); + interval @ (AggregateFrequency::Hourly + | AggregateFrequency::Daily + | AggregateFrequency::Weekly) => { + let time = Instant::now(); + match server + .core + .smtp + .resolvers + .dns + .txt_lookup::( + format!("_smtp._tls.{domain}."), + Some(&server.inner.cache.dns_txt), + ) + .await + { + Ok(record) => { + trc::event!( + TlsRpt(TlsRptEvent::RecordFetch), + SpanId = message.span_id, + Domain = domain.to_string(), + Details = record + .rua + .iter() + .map(|uri| trc::Value::from(match uri { + mail_auth::mta_sts::ReportUri::Mail(uri) + | mail_auth::mta_sts::ReportUri::Http(uri) => + uri.to_string(), + })) + .collect::>(), + Elapsed = time.elapsed(), + ); - TlsRptOptions { record, interval }.into() - } - Err(mail_auth::Error::DnsRecordNotFound(_)) => { - trc::event!( - TlsRpt(TlsRptEvent::RecordNotFound), - SpanId = message.span_id, - Domain = domain.domain.clone(), - Elapsed = time.elapsed(), - ); - None - } - Err(err) => { - trc::event!( - TlsRpt(TlsRptEvent::RecordFetchError), - SpanId = message.span_id, - Domain = domain.domain.clone(), - CausedBy = trc::Error::from(err), - Elapsed = time.elapsed(), - ); - None + TlsRptOptions { record, interval }.into() + } + Err(mail_auth::Error::DnsRecordNotFound(_)) => { + trc::event!( + TlsRpt(TlsRptEvent::RecordNotFound), + SpanId = message.span_id, + Domain = domain.to_string(), + Elapsed = time.elapsed(), + ); + None + } + Err(err) => { + trc::event!( + TlsRpt(TlsRptEvent::RecordFetchError), + SpanId = message.span_id, + Domain = domain.to_string(), + CausedBy = trc::Error::from(err), + Elapsed = time.elapsed(), + ); + None + } + } } + _ => None, } - } - _ => None, - }; + } else { + None + }; // Obtain MTA-STS policy for domain - let mta_sts_policy = if tls_strategy.try_mta_sts() && is_smtp { + let mta_sts_policy = if mx_config.is_some() && tls_strategy.try_mta_sts() && is_smtp { let time = Instant::now(); match server - .lookup_mta_sts_policy( - &domain.domain, - server - .eval_if(&queue_config.timeout.mta_sts, &envelope, message.span_id) - .await - .unwrap_or_else(|| Duration::from_secs(10 * 60)), - ) + .lookup_mta_sts_policy(domain, tls_strategy.timeout_mta_sts) .await { Ok(mta_sts_policy) => { trc::event!( MtaSts(MtaStsEvent::PolicyFetch), SpanId = message.span_id, - Domain = domain.domain.clone(), + Domain = domain.to_string(), Strict = mta_sts_policy.enforce(), Details = mta_sts_policy .mx @@ -395,7 +411,7 @@ impl QueuedMessage { if strict { server.schedule_report(TlsEvent { policy: PolicyType::Sts(None), - domain: domain.domain.to_string(), + domain: domain.to_string(), failure: FailureDetails::new(ResultType::Other) .with_failure_reason_code( "MTA-STS is required and no policy was found.", @@ -412,7 +428,7 @@ impl QueuedMessage { server .schedule_report(TlsEvent { policy: PolicyType::Sts(None), - domain: domain.domain.to_string(), + domain: domain.to_string(), failure: FailureDetails::new(&err) .with_failure_reason_code(err.to_string()) .into(), @@ -429,7 +445,7 @@ impl QueuedMessage { trc::event!( MtaSts(MtaStsEvent::PolicyNotFound), SpanId = message.span_id, - Domain = domain.domain.clone(), + Domain = domain.to_string(), Strict = strict, Elapsed = time.elapsed(), ); @@ -438,7 +454,7 @@ impl QueuedMessage { trc::event!( MtaSts(MtaStsEvent::PolicyFetchError), SpanId = message.span_id, - Domain = domain.domain.clone(), + Domain = domain.to_string(), CausedBy = trc::Error::from(err.clone()), Strict = strict, Elapsed = time.elapsed(), @@ -448,7 +464,7 @@ impl QueuedMessage { trc::event!( MtaSts(MtaStsEvent::PolicyFetchError), SpanId = message.span_id, - Domain = domain.domain.clone(), + Domain = domain.to_string(), Reason = err.to_string(), Strict = strict, Elapsed = time.elapsed(), @@ -458,7 +474,7 @@ impl QueuedMessage { trc::event!( MtaSts(MtaStsEvent::InvalidPolicy), SpanId = message.span_id, - Domain = domain.domain.clone(), + Domain = domain.to_string(), Reason = reason.clone(), Strict = strict, Elapsed = time.elapsed(), @@ -467,16 +483,11 @@ impl QueuedMessage { } if strict { - let schedule = server - .eval_if::, _>( - &queue_config.retry, - &envelope, - message.span_id, - ) - .await - .unwrap_or_else(|| vec![Duration::from_secs(60)]); - message.domains[domain_idx].set_status(err, &schedule); - continue 'next_domain; + delivery_results.push(DeliveryResult::domain( + Status::from_mta_sts_error(domain, err), + rcpt_idxs, + )); + continue 'next_gateway; } None @@ -488,7 +499,7 @@ impl QueuedMessage { // Obtain remote hosts list let mx_list; - if is_smtp && remote_hosts.is_empty() { + if let Some(mx_config) = mx_config { // Lookup MX let time = Instant::now(); mx_list = match server @@ -496,7 +507,7 @@ impl QueuedMessage { .smtp .resolvers .dns - .mx_lookup(domain.domain.as_str(), Some(&server.inner.cache.dns_mx)) + .mx_lookup(domain, Some(&server.inner.cache.dns_mx)) .await { Ok(mx) => mx, @@ -504,7 +515,7 @@ impl QueuedMessage { trc::event!( Delivery(DeliveryEvent::MxLookupFailed), SpanId = message.span_id, - Domain = domain.domain.clone(), + Domain = domain.to_string(), Details = "No MX records were found, attempting implicit MX.", Elapsed = time.elapsed(), ); @@ -515,35 +526,24 @@ impl QueuedMessage { trc::event!( Delivery(DeliveryEvent::MxLookupFailed), SpanId = message.span_id, - Domain = domain.domain.clone(), + Domain = domain.to_string(), CausedBy = trc::Error::from(err.clone()), Elapsed = time.elapsed(), ); - let schedule = server - .eval_if::, _>( - &queue_config.retry, - &envelope, - message.span_id, - ) - .await - .unwrap_or_else(|| vec![Duration::from_secs(60)]); - message.domains[domain_idx].set_status(err, &schedule); - continue 'next_domain; + delivery_results.push(DeliveryResult::domain( + Status::from_mail_auth_error(domain, err), + rcpt_idxs, + )); + continue 'next_gateway; } }; - if let Some(remote_hosts_) = mx_list.to_remote_hosts( - &domain.domain, - server - .eval_if(&queue_config.max_mx, &envelope, message.span_id) - .await - .unwrap_or(5), - ) { + if let Some(remote_hosts_) = mx_list.to_remote_hosts(domain, mx_config) { trc::event!( Delivery(DeliveryEvent::MxLookup), SpanId = message.span_id, - Domain = domain.domain.clone(), + Domain = domain.to_string(), Details = remote_hosts_ .iter() .map(|h| trc::Value::String(h.hostname().into())) @@ -555,34 +555,25 @@ impl QueuedMessage { trc::event!( Delivery(DeliveryEvent::NullMx), SpanId = message.span_id, - Domain = domain.domain.clone(), + Domain = domain.to_string(), Elapsed = time.elapsed(), ); - let schedule = server - .eval_if::, _>( - &queue_config.retry, - &envelope, - message.span_id, - ) - .await - .unwrap_or_else(|| vec![Duration::from_secs(60)]); - message.domains[domain_idx].set_status( - Status::PermanentFailure(Error::DnsError( - "Domain does not accept messages (null MX)".into(), - )), - &schedule, - ); - continue 'next_domain; + delivery_results.push(DeliveryResult::domain( + Status::PermanentFailure(ErrorDetails { + entity: domain.to_string(), + details: Error::DnsError( + "Domain does not accept messages (null MX)".into(), + ), + }), + rcpt_idxs, + )); + continue 'next_gateway; } } // Try delivering message - let max_multihomed = server - .eval_if(&queue_config.max_multihomed, &envelope, message.span_id) - .await - .unwrap_or(2); - let mut last_status = Status::Scheduled; + let mut last_status: Status, ErrorDetails> = Status::Scheduled; 'next_host: for remote_host in &remote_hosts { // Validate MTA-STS envelope.mx = remote_host.hostname(); @@ -594,7 +585,7 @@ impl QueuedMessage { server .schedule_report(TlsEvent { policy: mta_sts_policy.into(), - domain: domain.domain.to_string(), + domain: domain.to_string(), failure: FailureDetails::new(ResultType::ValidationFailure) .with_receiving_mx_hostname(envelope.mx) .with_failure_reason_code("MX not authorized by policy.") @@ -608,7 +599,7 @@ impl QueuedMessage { trc::event!( MtaSts(MtaStsEvent::NotAuthorized), SpanId = message.span_id, - Domain = domain.domain.clone(), + Domain = domain.to_string(), Hostname = envelope.mx.to_string(), Details = mta_sts_policy .mx @@ -619,17 +610,20 @@ impl QueuedMessage { ); if strict { - last_status = Status::PermanentFailure(Error::MtaStsError(format!( - "MX {:?} not authorized by policy.", - envelope.mx - ))); + last_status = Status::PermanentFailure(ErrorDetails { + entity: envelope.mx.to_string(), + details: Error::MtaStsError(format!( + "MX {:?} not authorized by policy.", + envelope.mx + )), + }); continue 'next_host; } } else { trc::event!( MtaSts(MtaStsEvent::Authorized), SpanId = message.span_id, - Domain = domain.domain.clone(), + Domain = domain.to_string(), Hostname = envelope.mx.to_string(), Details = mta_sts_policy .mx @@ -643,22 +637,19 @@ impl QueuedMessage { // Obtain source and remote IPs let time = Instant::now(); - let resolve_result = match server - .resolve_host(remote_host, &envelope, max_multihomed, message.span_id) - .await - { + let resolve_result = match server.resolve_host(remote_host, &envelope).await { Ok(result) => { trc::event!( Delivery(DeliveryEvent::IpLookup), SpanId = message.span_id, - Domain = domain.domain.clone(), + Domain = domain.to_string(), Hostname = envelope.mx.to_string(), Details = result .remote_ips .iter() .map(|ip| trc::Value::from(*ip)) .collect::>(), - Limit = max_multihomed, + Limit = remote_host.max_multi_homed(), Elapsed = time.elapsed(), ); @@ -668,7 +659,7 @@ impl QueuedMessage { trc::event!( Delivery(DeliveryEvent::IpLookupFailed), SpanId = message.span_id, - Domain = domain.domain.clone(), + Domain = domain.to_string(), Hostname = envelope.mx.to_string(), Details = status.to_string(), Elapsed = time.elapsed(), @@ -680,14 +671,13 @@ impl QueuedMessage { }; // Update TLS strategy - tls_strategy.dane = server - .eval_if(&queue_config.tls.dane, &envelope, message.span_id) - .await - .unwrap_or(RequireOptional::Optional); - tls_strategy.tls = server - .eval_if(&queue_config.tls.start, &envelope, message.span_id) - .await - .unwrap_or(RequireOptional::Optional); + tls_strategy = server.get_tls_or_default( + &server + .eval_if::(&queue_config.tls, &envelope, message.span_id) + .await + .unwrap_or_else(|| "default".to_string()), + message.span_id, + ); // Lookup DANE policy let dane_policy = if tls_strategy.try_dane() && is_smtp { @@ -702,7 +692,7 @@ impl QueuedMessage { trc::event!( Dane(DaneEvent::TlsaRecordFetch), SpanId = message.span_id, - Domain = domain.domain.clone(), + Domain = domain.to_string(), Hostname = envelope.mx.to_string(), Details = format!("{tlsa:?}"), Strict = strict, @@ -714,7 +704,7 @@ impl QueuedMessage { trc::event!( Dane(DaneEvent::TlsaRecordInvalid), SpanId = message.span_id, - Domain = domain.domain.clone(), + Domain = domain.to_string(), Hostname = envelope.mx.to_string(), Details = format!("{tlsa:?}"), Strict = strict, @@ -726,7 +716,7 @@ impl QueuedMessage { server .schedule_report(TlsEvent { policy: tlsa.into(), - domain: domain.domain.to_string(), + domain: domain.to_string(), failure: FailureDetails::new(ResultType::TlsaInvalid) .with_receiving_mx_hostname(envelope.mx) .with_failure_reason_code("Invalid TLSA record.") @@ -738,11 +728,12 @@ impl QueuedMessage { } if strict { - last_status = - Status::PermanentFailure(Error::DaneError(ErrorDetails { - entity: envelope.mx.into(), - details: "No valid TLSA records were found".into(), - })); + last_status = Status::PermanentFailure(ErrorDetails { + entity: envelope.mx.to_string(), + details: Error::DaneError( + "No valid TLSA records were found".into(), + ), + }); continue 'next_host; } None @@ -752,7 +743,7 @@ impl QueuedMessage { trc::event!( Dane(DaneEvent::TlsaRecordNotDnssecSigned), SpanId = message.span_id, - Domain = domain.domain.clone(), + Domain = domain.to_string(), Hostname = envelope.mx.to_string(), Strict = strict, Elapsed = time.elapsed(), @@ -764,7 +755,7 @@ impl QueuedMessage { server .schedule_report(TlsEvent { policy: PolicyType::Tlsa(None), - domain: domain.domain.to_string(), + domain: domain.to_string(), failure: FailureDetails::new(ResultType::DaneRequired) .with_receiving_mx_hostname(envelope.mx) .with_failure_reason_code( @@ -777,11 +768,12 @@ impl QueuedMessage { .await; } - last_status = - Status::PermanentFailure(Error::DaneError(ErrorDetails { - entity: envelope.mx.into(), - details: "No TLSA DNSSEC records found".into(), - })); + last_status = Status::PermanentFailure(ErrorDetails { + entity: envelope.mx.into(), + details: Error::DaneError( + "No TLSA DNSSEC records found".into(), + ), + }); continue 'next_host; } None @@ -793,7 +785,7 @@ impl QueuedMessage { trc::event!( Dane(DaneEvent::TlsaRecordNotFound), SpanId = message.span_id, - Domain = domain.domain.clone(), + Domain = domain.to_string(), Hostname = envelope.mx.to_string(), Strict = strict, Elapsed = time.elapsed(), @@ -802,7 +794,7 @@ impl QueuedMessage { trc::event!( Dane(DaneEvent::TlsaRecordFetchError), SpanId = message.span_id, - Domain = domain.domain.clone(), + Domain = domain.to_string(), Hostname = envelope.mx.to_string(), CausedBy = trc::Error::from(err.clone()), Strict = strict, @@ -817,7 +809,7 @@ impl QueuedMessage { server .schedule_report(TlsEvent { policy: PolicyType::Tlsa(None), - domain: domain.domain.to_string(), + domain: domain.to_string(), failure: FailureDetails::new( ResultType::DaneRequired, ) @@ -832,12 +824,12 @@ impl QueuedMessage { .await; } - Status::PermanentFailure(Error::DaneError(ErrorDetails { + Status::PermanentFailure(ErrorDetails { entity: envelope.mx.into(), - details: "No TLSA records found".into(), - })) + details: Error::DaneError("No TLSA records found".into()), + }) } else { - err.into() + Status::from_mail_auth_error(envelope.mx, err) }; continue 'next_host; } @@ -850,14 +842,6 @@ impl QueuedMessage { // Try each IP address 'next_ip: for remote_ip in resolve_result.remote_ips { - // Set source IP, if any - let source_ip = if remote_ip.is_ipv4() { - resolve_result.source_ipv4 - } else { - resolve_result.source_ipv6 - }; - envelope.local_ip = source_ip.unwrap_or(no_ip); - // Throttle remote host envelope.remote_ip = remote_ip; for throttle in &queue_config.outbound_limiters.remote { @@ -871,29 +855,44 @@ impl QueuedMessage { Id = throttle.id.clone(), RemoteIp = remote_ip, ); - message.domains[domain_idx].set_rate_limiter_error(retry_at); - continue 'next_domain; + delivery_results + .push(DeliveryResult::rate_limited(rcpt_idxs, retry_at)); + continue 'next_gateway; } } + // Obtain connection parameters + let conn_strategy = server.get_connection_or_default( + &server + .eval_if::( + &queue_config.connection, + &envelope, + message.span_id, + ) + .await + .unwrap_or_else(|| "default".to_string()), + message.span_id, + ); + + // Set source IP, if any + let ip_host = conn_strategy.source_ip(remote_ip.is_ipv4()); + // Connect let time = Instant::now(); - let conn_timeout = server - .eval_if(&queue_config.timeout.connect, &envelope, message.span_id) - .await - .unwrap_or_else(|| Duration::from_secs(5 * 60)); - let mut smtp_client = match if let Some(ip_addr) = source_ip { + let mut smtp_client = match if let Some(ip_host) = ip_host { + envelope.local_ip = ip_host.ip; SmtpClient::connect_using( - ip_addr, + ip_host.ip, SocketAddr::new(remote_ip, remote_host.port()), - conn_timeout, + conn_strategy.timeout_connect, span_id, ) .await } else { + envelope.local_ip = no_ip; SmtpClient::connect( SocketAddr::new(remote_ip, remote_host.port()), - conn_timeout, + conn_strategy.timeout_connect, span_id, ) .await @@ -902,9 +901,9 @@ impl QueuedMessage { trc::event!( Delivery(DeliveryEvent::Connect), SpanId = message.span_id, - Domain = domain.domain.clone(), + Domain = domain.to_string(), Hostname = envelope.mx.to_string(), - LocalIp = source_ip.unwrap_or(no_ip), + LocalIp = envelope.local_ip, RemoteIp = remote_ip, RemotePort = remote_host.port(), Elapsed = time.elapsed(), @@ -916,9 +915,9 @@ impl QueuedMessage { trc::event!( Delivery(DeliveryEvent::ConnectError), SpanId = message.span_id, - Domain = domain.domain.clone(), + Domain = domain.to_string(), Hostname = envelope.mx.to_string(), - LocalIp = source_ip, + LocalIp = envelope.local_ip, RemoteIp = remote_ip, RemotePort = remote_host.port(), CausedBy = from_mail_send_error(&err), @@ -931,49 +930,27 @@ impl QueuedMessage { }; // Obtain session parameters - let local_hostname = server - .eval_if::(&queue_config.hostname, &envelope, message.span_id) - .await - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| { - trc::event!( - Delivery(DeliveryEvent::MissingOutboundHostname), - SpanId = message.span_id, - ); - "local.host".into() - }); + let local_hostname = ip_host + .and_then(|ip| ip.host.as_deref()) + .or(conn_strategy.ehlo_hostname.as_deref()) + .unwrap_or(server.core.network.server_name.as_str()); let params = SessionParams { session_id: message.span_id, server: &server, credentials: remote_host.credentials(), is_smtp: remote_host.is_smtp(), hostname: envelope.mx, - local_hostname: &local_hostname, - timeout_ehlo: server - .eval_if(&queue_config.timeout.ehlo, &envelope, message.span_id) - .await - .unwrap_or_else(|| Duration::from_secs(5 * 60)), - timeout_mail: server - .eval_if(&queue_config.timeout.mail, &envelope, message.span_id) - .await - .unwrap_or_else(|| Duration::from_secs(5 * 60)), - timeout_rcpt: server - .eval_if(&queue_config.timeout.rcpt, &envelope, message.span_id) - .await - .unwrap_or_else(|| Duration::from_secs(5 * 60)), - timeout_data: server - .eval_if(&queue_config.timeout.data, &envelope, message.span_id) - .await - .unwrap_or_else(|| Duration::from_secs(5 * 60)), + local_hostname, + conn_strategy, }; // Prepare TLS connector let is_strict_tls = tls_strategy.is_tls_required() - || (message.flags & MAIL_REQUIRETLS) != 0 + || (message.message.flags & MAIL_REQUIRETLS) != 0 || mta_sts_policy.is_some() || dane_policy.is_some(); // As per RFC7671 Section 5.1, DANE-EE(3) allows name mismatch - let tls_connector = if allow_invalid_certs + let tls_connector = if tls_strategy.allow_invalid_certs || remote_host.allow_invalid_certs() || dane_policy.as_ref().is_some_and(|t| t.has_end_entities) { @@ -982,17 +959,14 @@ impl QueuedMessage { &server.inner.data.smtp_connectors.pki_verify }; - let delivery_result = if !remote_host.implicit_tls() { + if !remote_host.implicit_tls() { // Read greeting - smtp_client.timeout = server - .eval_if(&queue_config.timeout.greeting, &envelope, message.span_id) - .await - .unwrap_or_else(|| Duration::from_secs(5 * 60)); + smtp_client.timeout = conn_strategy.timeout_greeting; if let Err(status) = smtp_client.read_greeting(envelope.mx).await { trc::event!( Delivery(DeliveryEvent::GreetingFailed), SpanId = message.span_id, - Domain = domain.domain.clone(), + Domain = domain.to_string(), Hostname = envelope.mx.to_string(), Details = status.to_string(), ); @@ -1008,7 +982,7 @@ impl QueuedMessage { trc::event!( Delivery(DeliveryEvent::Ehlo), SpanId = message.span_id, - Domain = domain.domain.clone(), + Domain = domain.to_string(), Hostname = envelope.mx.to_string(), Details = capabilities.capabilities(), Elapsed = time.elapsed(), @@ -1020,7 +994,7 @@ impl QueuedMessage { trc::event!( Delivery(DeliveryEvent::EhloRejected), SpanId = message.span_id, - Domain = domain.domain.clone(), + Domain = domain.to_string(), Hostname = envelope.mx.to_string(), Details = status.to_string(), Elapsed = time.elapsed(), @@ -1034,10 +1008,7 @@ impl QueuedMessage { // Try starting TLS if tls_strategy.try_start_tls() { let time = Instant::now(); - smtp_client.timeout = server - .eval_if(&queue_config.timeout.tls, &envelope, message.span_id) - .await - .unwrap_or_else(|| Duration::from_secs(3 * 60)); + smtp_client.timeout = tls_strategy.timeout_tls; match smtp_client .try_start_tls(tls_connector, envelope.mx, &capabilities) .await @@ -1046,7 +1017,7 @@ impl QueuedMessage { trc::event!( Delivery(DeliveryEvent::StartTls), SpanId = message.span_id, - Domain = domain.domain.clone(), + Domain = domain.to_string(), Hostname = envelope.mx.to_string(), Version = format!( "{:?}", @@ -1077,7 +1048,7 @@ impl QueuedMessage { server .schedule_report(TlsEvent { policy: dane_policy.into(), - domain: domain.domain.to_string(), + domain: domain.to_string(), failure: FailureDetails::new( ResultType::ValidationFailure, ) @@ -1103,7 +1074,7 @@ impl QueuedMessage { server .schedule_report(TlsEvent { policy: (&mta_sts_policy, &dane_policy).into(), - domain: domain.domain.to_string(), + domain: domain.to_string(), failure: None, tls_record: tls_report.record.clone(), interval: tls_report.interval, @@ -1115,9 +1086,8 @@ impl QueuedMessage { message .deliver( smtp_client, - recipients - .iter_mut() - .filter(|r| r.domain_idx == domain_idx as u32), + rcpt_idxs, + &mut delivery_results, params, ) .await @@ -1135,7 +1105,7 @@ impl QueuedMessage { trc::event!( Delivery(DeliveryEvent::StartTlsUnavailable), SpanId = message.span_id, - Domain = domain.domain.clone(), + Domain = domain.to_string(), Hostname = envelope.mx.to_string(), Code = response.as_ref().map(|r| r.code()), Details = response @@ -1150,7 +1120,7 @@ impl QueuedMessage { server .schedule_report(TlsEvent { policy: (&mta_sts_policy, &dane_policy).into(), - domain: domain.domain.to_string(), + domain: domain.to_string(), failure: FailureDetails::new( ResultType::StartTlsNotSupported, ) @@ -1173,9 +1143,8 @@ impl QueuedMessage { message .deliver( smtp_client, - recipients - .iter_mut() - .filter(|r| r.domain_idx == domain_idx as u32), + rcpt_idxs, + &mut delivery_results, params, ) .await @@ -1185,7 +1154,7 @@ impl QueuedMessage { trc::event!( Delivery(DeliveryEvent::StartTlsError), SpanId = message.span_id, - Domain = domain.domain.clone(), + Domain = domain.to_string(), Hostname = envelope.mx.to_string(), Reason = from_mail_send_error(&error), Elapsed = time.elapsed(), @@ -1198,7 +1167,7 @@ impl QueuedMessage { server .schedule_report(TlsEvent { policy: (&mta_sts_policy, &dane_policy).into(), - domain: domain.domain.to_string(), + domain: domain.to_string(), failure: FailureDetails::new( ResultType::CertificateNotTrusted, ) @@ -1225,26 +1194,17 @@ impl QueuedMessage { trc::event!( Delivery(DeliveryEvent::StartTlsDisabled), SpanId = message.span_id, - Domain = domain.domain.clone(), + Domain = domain.to_string(), Hostname = envelope.mx.to_string(), ); message - .deliver( - smtp_client, - recipients - .iter_mut() - .filter(|r| r.domain_idx == domain_idx as u32), - params, - ) + .deliver(smtp_client, rcpt_idxs, &mut delivery_results, params) .await } } else { // Start TLS - smtp_client.timeout = server - .eval_if(&queue_config.timeout.tls, &envelope, message.span_id) - .await - .unwrap_or_else(|| Duration::from_secs(3 * 60)); + smtp_client.timeout = tls_strategy.timeout_tls; let mut smtp_client = match smtp_client.into_tls(tls_connector, envelope.mx).await { Ok(smtp_client) => smtp_client, @@ -1252,7 +1212,7 @@ impl QueuedMessage { trc::event!( Delivery(DeliveryEvent::ImplicitTlsError), SpanId = message.span_id, - Domain = domain.domain.clone(), + Domain = domain.to_string(), Hostname = envelope.mx.to_string(), Reason = from_mail_send_error(&error), ); @@ -1263,15 +1223,12 @@ impl QueuedMessage { }; // Read greeting - smtp_client.timeout = server - .eval_if(&queue_config.timeout.greeting, &envelope, message.span_id) - .await - .unwrap_or_else(|| Duration::from_secs(5 * 60)); + smtp_client.timeout = conn_strategy.timeout_greeting; if let Err(status) = smtp_client.read_greeting(envelope.mx).await { trc::event!( Delivery(DeliveryEvent::GreetingFailed), SpanId = message.span_id, - Domain = domain.domain.clone(), + Domain = domain.to_string(), Hostname = envelope.mx.to_string(), Details = from_error_status(&status), ); @@ -1282,157 +1239,183 @@ impl QueuedMessage { // Deliver message message - .deliver( - smtp_client, - recipients - .iter_mut() - .filter(|r| r.domain_idx == domain_idx as u32), - params, - ) + .deliver(smtp_client, rcpt_idxs, &mut delivery_results, params) .await - }; + } - // Update status for the current domain and continue with the next one - let schedule = server - .eval_if::, _>( - &queue_config.retry, - &envelope, - message.span_id, - ) - .await - .unwrap_or_else(|| vec![Duration::from_secs(60)]); - message.domains[domain_idx].set_status(delivery_result, &schedule); - continue 'next_domain; + // Continue with the next domain/gateway + continue 'next_gateway; } } // Update status - let schedule = server - .eval_if::, _>(&queue_config.retry, &envelope, message.span_id) - .await - .unwrap_or_else(|| vec![Duration::from_secs(60)]); - message.domains[domain_idx].set_status(last_status, &schedule); + delivery_results.push(DeliveryResult::domain(last_status, rcpt_idxs)); + } + + // Apply status changes + for delivery_result in delivery_results { + match delivery_result { + DeliveryResult::Domain { status, rcpt_idxs } => { + for rcpt_idx in rcpt_idxs { + message + .set_rcpt_status(status.clone(), rcpt_idx, &server) + .await; + } + } + DeliveryResult::Account { status, rcpt_idx } => { + message.set_rcpt_status(status, rcpt_idx, &server).await; + } + DeliveryResult::RateLimited { + rcpt_idxs, + retry_at, + } => { + for rcpt_idx in rcpt_idxs { + message.set_rcpt_rate_limit(rcpt_idx, retry_at); + } + } + } } - message.recipients = recipients; // Send Delivery Status Notifications server.send_dsn(&mut message).await; // Notify queue manager - if let Some(due) = message.next_event() { + if message.message.next_event(None).is_some() { trc::event!( Queue(trc::QueueEvent::Rescheduled), SpanId = span_id, - NextRetry = trc::Value::Timestamp(message.next_delivery_event()), - NextDsn = trc::Value::Timestamp(message.next_dsn()), - Expires = trc::Value::Timestamp(message.expires()), + NextRetry = message + .message + .next_delivery_event(None) + .map(trc::Value::Timestamp), + NextDsn = message.message.next_dsn(None).map(trc::Value::Timestamp), + Expires = message.message.expires(None).map(trc::Value::Timestamp), ); // Save changes to disk - message - .save_changes(&server, self.due.into(), due.into()) - .await; + message.save_changes(&server, self.due.into()).await; QueueEventStatus::Deferred } else { trc::event!( Delivery(DeliveryEvent::Completed), SpanId = span_id, - Elapsed = trc::Value::Duration((now() - message.created) * 1000) + Elapsed = trc::Value::Duration((now() - message.message.created) * 1000) ); // Delete message from queue - message.remove(&server, self.due).await; + message.remove(&server, self.due.into()).await; QueueEventStatus::Completed } } } -impl Message { +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PendingDelivery { + Yes(bool), + No, +} + +impl MessageWrapper { /// Marks as failed all domains that reached their expiration time - pub fn has_pending_delivery(&mut self) -> bool { + pub fn has_pending_delivery(&mut self) -> PendingDelivery { let now = now(); let mut has_pending_delivery = false; + let mut matches_queue = false; - for (idx, domain) in self.domains.iter_mut().enumerate() { - match &domain.status { - Status::TemporaryFailure(_) if domain.expires <= now => { + for rcpt in self.message.recipients.iter_mut() { + match &rcpt.status { + Status::TemporaryFailure(err) if rcpt.is_expired(self.message.created, now) => { trc::event!( Delivery(DeliveryEvent::Failed), SpanId = self.span_id, - Domain = domain.domain.clone(), - Reason = from_error_status(&domain.status), + QueueId = self.queue_id, + QueueName = self.queue_name.as_str().to_string(), + To = rcpt.address_lcase.clone(), + Reason = from_error_details(&err.details), Details = trc::Value::Timestamp(now), - Expires = trc::Value::Timestamp(domain.expires), - NextRetry = trc::Value::Timestamp(domain.retry.due), - NextDsn = trc::Value::Timestamp(domain.notify.due), + Expires = rcpt + .expiration_time(self.message.created) + .map(trc::Value::Timestamp), + NextRetry = trc::Value::Timestamp(rcpt.retry.due), + NextDsn = trc::Value::Timestamp(rcpt.notify.due), ); - for rcpt in &mut self.recipients { - if rcpt.domain_idx == idx as u32 { - rcpt.status = std::mem::replace(&mut rcpt.status, Status::Scheduled) - .into_permanent(); - } - } - - domain.status = - std::mem::replace(&mut domain.status, Status::Scheduled).into_permanent(); + rcpt.status = + std::mem::replace(&mut rcpt.status, Status::Scheduled).into_permanent(); } - Status::Scheduled if domain.expires <= now => { - let mut had_attempts = false; - for rcpt in &mut self.recipients { - if rcpt.domain_idx == idx as u32 { - had_attempts |= !matches!(rcpt.status, Status::Scheduled); - rcpt.status = std::mem::replace(&mut rcpt.status, Status::Scheduled) - .into_permanent(); - } - } - - let reason = if had_attempts { - "Message delivery failed." - } else { - "Message expired without any delivery attempts made." - }; - + Status::Scheduled if rcpt.is_expired(self.message.created, now) => { trc::event!( Delivery(DeliveryEvent::Failed), SpanId = self.span_id, - Domain = domain.domain.clone(), - Reason = reason, + QueueId = self.queue_id, + QueueName = self.queue_name.as_str().to_string(), + To = rcpt.address_lcase.clone(), + Reason = "Message expired without any delivery attempts made.", Details = trc::Value::Timestamp(now), - Expires = trc::Value::Timestamp(domain.expires), - NextRetry = trc::Value::Timestamp(domain.retry.due), - NextDsn = trc::Value::Timestamp(domain.notify.due), + Expires = rcpt + .expiration_time(self.message.created) + .map(trc::Value::Timestamp), + NextRetry = trc::Value::Timestamp(rcpt.retry.due), + NextDsn = trc::Value::Timestamp(rcpt.notify.due), ); - domain.status = Status::PermanentFailure(Error::Io(reason.into())); + rcpt.status = Status::PermanentFailure(ErrorDetails { + entity: rcpt.address_lcase.domain_part().to_string(), + details: Error::Io( + "Message expired without any delivery attempts made.".into(), + ), + }); } Status::Completed(_) | Status::PermanentFailure(_) => (), _ => { has_pending_delivery = true; + matches_queue = matches_queue || rcpt.queue == self.queue_name; } } } - has_pending_delivery - } -} - -impl Domain { - pub fn set_status(&mut self, status: impl Into>, schedule: &[Duration]) { - self.status = status.into(); - if matches!( - &self.status, - Status::TemporaryFailure(_) | Status::Scheduled - ) { - self.retry(schedule); + if has_pending_delivery { + PendingDelivery::Yes(matches_queue) + } else { + PendingDelivery::No } } - pub fn retry(&mut self, schedule: &[Duration]) { - self.retry.due = now() - + schedule[std::cmp::min(self.retry.inner as usize, schedule.len() - 1)].as_secs(); - self.retry.inner += 1; + pub async fn set_rcpt_status( + &mut self, + status: Status, ErrorDetails>, + rcpt_idx: usize, + server: &Server, + ) { + let needs_retry = matches!(&status, Status::TemporaryFailure(_) | Status::Scheduled); + self.message.recipients[rcpt_idx].status = status; + + if needs_retry { + let envelope = QueueEnvelope::new_rcpt(&self.message, rcpt_idx); + let queue = server.get_queue_or_default( + &server + .eval_if::(&server.core.smtp.queue.queue, &envelope, self.span_id) + .await + .unwrap_or_else(|| "default".to_string()), + self.span_id, + ); + let rcpt = &mut self.message.recipients[rcpt_idx]; + rcpt.retry.due = now() + + queue.retry[std::cmp::min(rcpt.retry.inner as usize, queue.retry.len() - 1)]; + rcpt.retry.inner += 1; + rcpt.expires = queue.expiry; + rcpt.queue = queue.virtual_queue; + } + } + + pub fn set_rcpt_rate_limit(&mut self, rcpt_idx: usize, retry_at: u64) { + let rcpt = &mut self.message.recipients[rcpt_idx]; + rcpt.retry.due = retry_at; + rcpt.status = Status::TemporaryFailure(ErrorDetails { + entity: "localhost".to_string(), + details: Error::RateLimited, + }); } } diff --git a/crates/smtp/src/outbound/local.rs b/crates/smtp/src/outbound/local.rs index 12c03dfc..aa9ac761 100644 --- a/crates/smtp/src/outbound/local.rs +++ b/crates/smtp/src/outbound/local.rs @@ -4,98 +4,88 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +use crate::{ + outbound::DeliveryResult, + queue::{ + DMARC_AUTHENTICATED, DomainPart, Error, ErrorDetails, HostResponse, MessageSource, + MessageWrapper, Status, UnexpectedResponse, quota::HasQueueQuota, spool::SmtpSpool, + }, + reporting::SmtpReporting, +}; use common::Server; use email::message::delivery::{IngestMessage, LocalDeliveryStatus, MailDelivery}; use smtp_proto::Response; use trc::SieveEvent; -use crate::{ - queue::{ - DMARC_AUTHENTICATED, DomainPart, Error, ErrorDetails, HostResponse, Message, MessageSource, - RCPT_STATUS_CHANGED, Recipient, Status, quota::HasQueueQuota, spool::SmtpSpool, - }, - reporting::SmtpReporting, -}; - -impl Message { - pub async fn deliver_local( +impl MessageWrapper { + pub(super) async fn deliver_local( &self, - recipients: impl Iterator, + rcpt_idxs: &[usize], + statuses: &mut Vec, server: &Server, - ) -> Status<(), Error> { + ) { // Prepare recipients list - let mut total_rcpt = 0; - let mut total_completed = 0; let mut pending_recipients = Vec::new(); let mut recipient_addresses = Vec::new(); - for rcpt in recipients { - total_rcpt += 1; - if matches!( - &rcpt.status, - Status::Completed(_) | Status::PermanentFailure(_) - ) { - total_completed += 1; - continue; - } - recipient_addresses.push(rcpt.address_lcase.clone()); - pending_recipients.push(rcpt); + for &rcpt_idx in rcpt_idxs { + let rcpt_addr = &self.message.recipients[rcpt_idx].address_lcase; + recipient_addresses.push(rcpt_addr.clone()); + pending_recipients.push((rcpt_idx, rcpt_addr)); } // Deliver message let delivery_result = server .deliver_message(IngestMessage { - sender_address: self.return_path_lcase.clone(), - sender_authenticated: self.flags & DMARC_AUTHENTICATED != 0, + sender_address: self.message.return_path_lcase.clone(), + sender_authenticated: self.message.flags & DMARC_AUTHENTICATED != 0, recipients: recipient_addresses, - message_blob: self.blob_hash.clone(), - message_size: self.size, + message_blob: self.message.blob_hash.clone(), + message_size: self.message.size, session_id: self.span_id, }) .await; // Process delivery results - for (rcpt, result) in pending_recipients.into_iter().zip(delivery_result.status) { - rcpt.flags |= RCPT_STATUS_CHANGED; - match result { - LocalDeliveryStatus::Success => { - rcpt.status = Status::Completed(HostResponse { - hostname: "localhost".into(), - response: Response { - code: 250, - esc: [2, 1, 5], - message: "OK".into(), - }, - }); - total_completed += 1; - } + for ((rcpt_idx, rcpt_addr), result) in + pending_recipients.into_iter().zip(delivery_result.status) + { + let status = match result { + LocalDeliveryStatus::Success => Status::Completed(HostResponse { + hostname: "localhost".into(), + response: Response { + code: 250, + esc: [2, 1, 5], + message: "OK".into(), + }, + }), LocalDeliveryStatus::TemporaryFailure { reason } => { - rcpt.status = Status::TemporaryFailure(HostResponse { - hostname: ErrorDetails { - entity: "localhost".into(), - details: format!("RCPT TO:<{}>", rcpt.address), - }, - response: Response { - code: 451, - esc: [4, 3, 0], - message: reason.into(), - }, - }); + Status::TemporaryFailure(ErrorDetails { + entity: "localhost".into(), + details: Error::UnexpectedResponse(UnexpectedResponse { + command: format!("RCPT TO:<{rcpt_addr}>"), + response: Response { + code: 451, + esc: [4, 3, 0], + message: reason.into(), + }, + }), + }) } LocalDeliveryStatus::PermanentFailure { code, reason } => { - total_completed += 1; - rcpt.status = Status::PermanentFailure(HostResponse { - hostname: ErrorDetails { - entity: "localhost".into(), - details: format!("RCPT TO:<{}>", rcpt.address), - }, - response: Response { - code: 550, - esc: code, - message: reason.into(), - }, - }); + Status::PermanentFailure(ErrorDetails { + entity: "localhost".into(), + details: Error::UnexpectedResponse(UnexpectedResponse { + command: format!("RCPT TO:<{rcpt_addr}>"), + response: Response { + code: 550, + esc: code, + message: reason.into(), + }, + }), + }) } - } + }; + statuses.push(DeliveryResult::account(status, rcpt_idx)); } // Process autogenerated messages @@ -123,7 +113,7 @@ impl Message { .await; // Queue Message - message.size = + message.message.size = (autogenerated.message.len() + signature.as_ref().map_or(0, |s| s.len())) as u64; if server.has_quota(&mut message).await { message @@ -139,8 +129,9 @@ impl Message { trc::event!( Sieve(SieveEvent::QuotaExceeded), SpanId = self.span_id, - From = message.return_path_lcase, + From = message.message.return_path_lcase, To = message + .message .recipients .into_iter() .map(|r| trc::Value::from(r.address_lcase)) @@ -148,11 +139,5 @@ impl Message { ); } } - - if total_completed == total_rcpt { - Status::Completed(()) - } else { - Status::Scheduled - } } } diff --git a/crates/smtp/src/outbound/lookup.rs b/crates/smtp/src/outbound/lookup.rs index 577375cd..e33e722e 100644 --- a/crates/smtp/src/outbound/lookup.rs +++ b/crates/smtp/src/outbound/lookup.rs @@ -4,26 +4,18 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::{ - future::Future, - net::{IpAddr, Ipv4Addr, Ipv6Addr}, - sync::Arc, -}; - +use super::NextHop; +use crate::queue::{Error, ErrorDetails, HostResponse, Status}; use common::{ Server, + config::smtp::queue::{ConnectionStrategy, IpAndHost, MxConfig}, expr::{V_MX, functions::ResolveVariable}, }; use mail_auth::{IpLookupStrategy, MX}; use rand::{Rng, seq::SliceRandom}; - -use crate::queue::{Error, ErrorDetails, Status}; - -use super::NextHop; +use std::{future::Future, net::IpAddr, sync::Arc}; pub struct IpLookupResult { - pub source_ipv4: Option, - pub source_ipv6: Option, pub remote_ips: Vec, } @@ -39,9 +31,7 @@ pub trait DnsLookup: Sync + Send { &self, remote_host: &NextHop<'_>, envelope: &impl ResolveVariable, - max_multihomed: usize, - session_id: u64, - ) -> impl Future>> + Send; + ) -> impl Future, ErrorDetails>>> + Send; } impl DnsLookup for Server { @@ -119,16 +109,12 @@ impl DnsLookup for Server { &self, remote_host: &NextHop<'_>, envelope: &impl ResolveVariable, - max_multihomed: usize, - session_id: u64, - ) -> Result> { + ) -> Result, ErrorDetails>> { let mut remote_ips = self .ip_lookup( remote_host.fqdn_hostname().as_ref(), - self.eval_if(&self.core.smtp.queue.ip_strategy, envelope, session_id) - .await - .unwrap_or(IpLookupStrategy::Ipv4thenIpv6), - max_multihomed, + remote_host.ip_lookup_strategy(), + remote_host.max_multi_homed(), ) .await .map_err(|err| { @@ -140,18 +126,21 @@ impl DnsLookup for Server { .. } ) { - Status::PermanentFailure(Error::DnsError("no MX record found.".into())) - } else { - Status::PermanentFailure(Error::ConnectionError(ErrorDetails { + Status::PermanentFailure(ErrorDetails { entity: remote_host.hostname().into(), - details: "record not found for MX".into(), - })) + details: Error::DnsError("no MX record found.".into()), + }) + } else { + Status::PermanentFailure(ErrorDetails { + entity: remote_host.hostname().into(), + details: Error::ConnectionError("record not found for MX".into()), + }) } } else { - Status::TemporaryFailure(Error::ConnectionError(ErrorDetails { + Status::TemporaryFailure(ErrorDetails { entity: remote_host.hostname().into(), - details: format!("lookup error: {err}"), - })) + details: Error::ConnectionError(format!("lookup error: {err}")), + }) } })?; @@ -160,69 +149,41 @@ impl DnsLookup for Server { if remote_ips.iter().any(|ip| ip.is_loopback()) { remote_ips.retain(|ip| !ip.is_loopback()); if remote_ips.is_empty() { - return Err(Status::PermanentFailure(Error::ConnectionError( - ErrorDetails { - entity: remote_host.hostname().into(), - details: "host resolves loopback address".into(), - }, - ))); + return Err(Status::PermanentFailure(ErrorDetails { + entity: remote_host.hostname().into(), + details: Error::ConnectionError("host resolves loopback address".into()), + })); } } - let mut result = IpLookupResult { - source_ipv4: None, - source_ipv6: None, - remote_ips, - }; - - // Obtain source IPv4 address - let source_ips = self - .eval_if::, _>( - &self.core.smtp.queue.source_ip.ipv4, - envelope, - session_id, - ) - .await - .unwrap_or_default(); - match source_ips.len().cmp(&1) { - std::cmp::Ordering::Equal => { - result.source_ipv4 = IpAddr::from(*source_ips.first().unwrap()).into(); - } - std::cmp::Ordering::Greater => { - result.source_ipv4 = - IpAddr::from(source_ips[rand::rng().random_range(0..source_ips.len())]) - .into(); - } - std::cmp::Ordering::Less => (), - } - - // Obtain source IPv6 address - let source_ips = self - .eval_if::, _>( - &self.core.smtp.queue.source_ip.ipv6, - envelope, - session_id, - ) - .await - .unwrap_or_default(); - match source_ips.len().cmp(&1) { - std::cmp::Ordering::Equal => { - result.source_ipv6 = IpAddr::from(*source_ips.first().unwrap()).into(); - } - std::cmp::Ordering::Greater => { - result.source_ipv6 = - IpAddr::from(source_ips[rand::rng().random_range(0..source_ips.len())]) - .into(); - } - std::cmp::Ordering::Less => (), - } - - Ok(result) + Ok(IpLookupResult { remote_ips }) } else { - Err(Status::TemporaryFailure(Error::DnsError(format!( - "No IP addresses found for {:?}.", - envelope.resolve_variable(V_MX).to_string() - )))) + Err(Status::TemporaryFailure(ErrorDetails { + entity: remote_host.hostname().into(), + details: Error::DnsError(format!( + "No IP addresses found for {:?}.", + envelope.resolve_variable(V_MX).to_string() + )), + })) + } + } +} + +pub trait SourceIp { + fn source_ip(&self, is_v4: bool) -> Option<&IpAndHost>; +} + +impl SourceIp for ConnectionStrategy { + fn source_ip(&self, is_v4: bool) -> Option<&IpAndHost> { + let ips = if is_v4 { + &self.source_ipv4 + } else { + &self.source_ipv6 + }; + match ips.len().cmp(&1) { + std::cmp::Ordering::Equal => ips.first(), + std::cmp::Ordering::Greater => Some(&ips[rand::rng().random_range(0..ips.len())]), + std::cmp::Ordering::Less => None, } } } @@ -231,7 +192,7 @@ pub trait ToNextHop { fn to_remote_hosts<'x, 'y: 'x>( &'x self, domain: &'y str, - max_mx: usize, + config: &'x MxConfig, ) -> Option>>; } @@ -239,11 +200,11 @@ impl ToNextHop for Vec { fn to_remote_hosts<'x, 'y: 'x>( &'x self, domain: &'y str, - max_mx: usize, + config: &'x MxConfig, ) -> Option>> { if !self.is_empty() { // Obtain max number of MX hosts to process - let mut remote_hosts = Vec::with_capacity(max_mx); + let mut remote_hosts = Vec::with_capacity(config.max_mx); 'outer: for mx in self.iter() { if mx.exchanges.len() > 1 { @@ -253,8 +214,9 @@ impl ToNextHop for Vec { remote_hosts.push(NextHop::MX { host: remote_host.as_str(), is_implicit: false, + config, }); - if remote_hosts.len() == max_mx { + if remote_hosts.len() == config.max_mx { break 'outer; } } @@ -266,8 +228,9 @@ impl ToNextHop for Vec { remote_hosts.push(NextHop::MX { host: remote_host.as_str(), is_implicit: false, + config, }); - if remote_hosts.len() == max_mx { + if remote_hosts.len() == config.max_mx { break; } } @@ -279,6 +242,7 @@ impl ToNextHop for Vec { vec![NextHop::MX { host: domain, is_implicit: true, + config, }] .into() } diff --git a/crates/smtp/src/outbound/mod.rs b/crates/smtp/src/outbound/mod.rs index 10b0eaae..504955ae 100644 --- a/crates/smtp/src/outbound/mod.rs +++ b/crates/smtp/src/outbound/mod.rs @@ -4,17 +4,15 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::borrow::Cow; - +use crate::queue::{Error, ErrorDetails, HostResponse, Status, UnexpectedResponse}; use common::config::{ server::ServerProtocol, - smtp::queue::{RelayHost, RequireOptional}, + smtp::queue::{MxConfig, RelayConfig}, }; - +use mail_auth::IpLookupStrategy; use mail_send::Credentials; use smtp_proto::{Response, Severity}; - -use crate::queue::{Error, ErrorDetails, HostResponse, Status}; +use std::borrow::Cow; pub mod client; pub mod dane; @@ -24,14 +22,22 @@ pub mod lookup; pub mod mta_sts; pub mod session; -#[derive(Debug, Clone, Copy, Default)] -pub struct TlsStrategy { - pub dane: RequireOptional, - pub mta_sts: RequireOptional, - pub tls: RequireOptional, +pub(super) enum DeliveryResult { + Domain { + status: Status, ErrorDetails>, + rcpt_idxs: Vec, + }, + Account { + status: Status, ErrorDetails>, + rcpt_idx: usize, + }, + RateLimited { + rcpt_idxs: Vec, + retry_at: u64, + }, } -impl Status<(), Error> { +impl Status, ErrorDetails> { pub fn from_smtp_error(hostname: &str, command: &str, err: mail_send::Error) -> Self { match err { mail_send::Error::Io(_) @@ -42,165 +48,182 @@ impl Status<(), Error> { | mail_send::Error::MissingCredentials | mail_send::Error::MissingMailFrom | mail_send::Error::MissingRcptTo - | mail_send::Error::Timeout => { - Status::TemporaryFailure(Error::ConnectionError(ErrorDetails { - entity: hostname.into(), - details: err.to_string(), - })) - } + | mail_send::Error::Timeout => Status::TemporaryFailure(ErrorDetails { + entity: hostname.into(), + details: Error::ConnectionError(err.to_string()), + }), - mail_send::Error::UnexpectedReply(reply) => { - let details = ErrorDetails { - entity: hostname.into(), - details: command.trim().into(), - }; - if reply.severity() == Severity::PermanentNegativeCompletion { - Status::PermanentFailure(Error::UnexpectedResponse(HostResponse { - hostname: details, - response: reply, - })) + mail_send::Error::UnexpectedReply(response) => { + if response.severity() == Severity::PermanentNegativeCompletion { + Status::PermanentFailure(ErrorDetails { + entity: hostname.into(), + details: Error::UnexpectedResponse(UnexpectedResponse { + command: command.trim().into(), + response, + }), + }) } else { - Status::TemporaryFailure(Error::UnexpectedResponse(HostResponse { - hostname: details, - response: reply, - })) + Status::TemporaryFailure(ErrorDetails { + entity: hostname.into(), + details: Error::UnexpectedResponse(UnexpectedResponse { + command: command.trim().into(), + response, + }), + }) } } mail_send::Error::Auth(_) | mail_send::Error::UnsupportedAuthMechanism | mail_send::Error::InvalidTLSName - | mail_send::Error::MissingStartTls => { - Status::PermanentFailure(Error::ConnectionError(ErrorDetails { - entity: hostname.into(), - details: err.to_string(), - })) - } + | mail_send::Error::MissingStartTls => Status::PermanentFailure(ErrorDetails { + entity: hostname.into(), + details: Error::ConnectionError(err.to_string()), + }), } } pub fn from_starttls_error(hostname: &str, response: Option>) -> Self { let entity = hostname.into(); if let Some(response) = response { - let hostname = ErrorDetails { - entity, - details: "STARTTLS".into(), - }; - if response.severity() == Severity::PermanentNegativeCompletion { - Status::PermanentFailure(Error::UnexpectedResponse(HostResponse { - hostname, - response, - })) + Status::PermanentFailure(ErrorDetails { + entity, + details: Error::UnexpectedResponse(UnexpectedResponse { + command: "STARTTLS".into(), + response, + }), + }) } else { - Status::TemporaryFailure(Error::UnexpectedResponse(HostResponse { - hostname, - response, - })) + Status::TemporaryFailure(ErrorDetails { + entity, + details: Error::UnexpectedResponse(UnexpectedResponse { + command: "STARTTLS".into(), + response, + }), + }) } } else { - Status::PermanentFailure(Error::TlsError(ErrorDetails { + Status::PermanentFailure(ErrorDetails { entity, - details: "STARTTLS not advertised by host.".into(), - })) + details: Error::TlsError("STARTTLS not advertised by host.".into()), + }) } } pub fn from_tls_error(hostname: &str, err: mail_send::Error) -> Self { match err { - mail_send::Error::InvalidTLSName => { - Status::PermanentFailure(Error::TlsError(ErrorDetails { - entity: hostname.into(), - details: "Invalid hostname".into(), - })) - } - mail_send::Error::Timeout => Status::TemporaryFailure(Error::TlsError(ErrorDetails { + mail_send::Error::InvalidTLSName => Status::PermanentFailure(ErrorDetails { entity: hostname.into(), - details: "TLS handshake timed out".into(), - })), - mail_send::Error::Tls(err) => Status::TemporaryFailure(Error::TlsError(ErrorDetails { + details: Error::TlsError("Invalid hostname".into()), + }), + mail_send::Error::Timeout => Status::TemporaryFailure(ErrorDetails { entity: hostname.into(), - details: format!("Handshake failed: {err}"), - })), - mail_send::Error::Io(err) => Status::TemporaryFailure(Error::TlsError(ErrorDetails { + details: Error::TlsError("TLS handshake timed out".into()), + }), + mail_send::Error::Tls(err) => Status::TemporaryFailure(ErrorDetails { entity: hostname.into(), - details: format!("I/O error: {err}"), - })), - _ => Status::PermanentFailure(Error::TlsError(ErrorDetails { + details: Error::TlsError(format!("Handshake failed: {err}")), + }), + mail_send::Error::Io(err) => Status::TemporaryFailure(ErrorDetails { entity: hostname.into(), - details: "Other TLS error".into(), - })), + details: Error::TlsError(format!("I/O error: {err}")), + }), + _ => Status::PermanentFailure(ErrorDetails { + entity: hostname.into(), + details: Error::TlsError("Other TLS error".into()), + }), } } pub fn timeout(hostname: &str, stage: &str) -> Self { - Status::TemporaryFailure(Error::ConnectionError(ErrorDetails { + Status::TemporaryFailure(ErrorDetails { entity: hostname.into(), - details: format!("Timeout while {stage}"), - })) + details: Error::ConnectionError(format!("Timeout while {stage}")), + }) } pub fn local_error() -> Self { - Status::TemporaryFailure(Error::ConnectionError(ErrorDetails { + Status::TemporaryFailure(ErrorDetails { entity: "localhost".into(), - details: "Could not deliver message locally.".into(), - })) + details: Error::ConnectionError("Could not deliver message locally.".into()), + }) } -} -impl From for Status<(), Error> { - fn from(err: mail_auth::Error) -> Self { + pub fn from_mail_auth_error(entity: &str, err: mail_auth::Error) -> Self { match &err { - mail_auth::Error::DnsRecordNotFound(code) => { - Status::PermanentFailure(Error::DnsError(format!("Domain not found: {code:?}"))) - } - _ => Status::TemporaryFailure(Error::DnsError(err.to_string())), + mail_auth::Error::DnsRecordNotFound(code) => Status::PermanentFailure(ErrorDetails { + entity: entity.to_string(), + details: Error::DnsError(format!("Domain not found: {code:?}")), + }), + _ => Status::TemporaryFailure(ErrorDetails { + entity: entity.to_string(), + details: Error::DnsError(err.to_string()), + }), } } -} -impl From for Status<(), Error> { - fn from(err: mta_sts::Error) -> Self { + pub fn from_mta_sts_error(entity: &str, err: mta_sts::Error) -> Self { match &err { mta_sts::Error::Dns(err) => match err { - mail_auth::Error::DnsRecordNotFound(code) => Status::PermanentFailure( - Error::MtaStsError(format!("Record not found: {code:?}")), - ), - mail_auth::Error::InvalidRecordType => Status::PermanentFailure( - Error::MtaStsError("Failed to parse MTA-STS DNS record.".into()), - ), - _ => { - Status::TemporaryFailure(Error::MtaStsError(format!("DNS lookup error: {err}"))) + mail_auth::Error::DnsRecordNotFound(code) => { + Status::PermanentFailure(ErrorDetails { + entity: entity.to_string(), + details: Error::MtaStsError(format!("Record not found: {code:?}")), + }) } + mail_auth::Error::InvalidRecordType => Status::PermanentFailure(ErrorDetails { + entity: entity.to_string(), + details: Error::MtaStsError("Failed to parse MTA-STS DNS record.".into()), + }), + _ => Status::TemporaryFailure(ErrorDetails { + entity: entity.to_string(), + details: Error::MtaStsError(format!("DNS lookup error: {err}")), + }), }, mta_sts::Error::Http(err) => { if err.is_timeout() { - Status::TemporaryFailure(Error::MtaStsError("Timeout fetching policy.".into())) + Status::TemporaryFailure(ErrorDetails { + entity: entity.to_string(), + details: Error::MtaStsError("Timeout fetching policy.".into()), + }) } else if err.is_connect() { - Status::TemporaryFailure(Error::MtaStsError( - "Could not reach policy host.".into(), - )) + Status::TemporaryFailure(ErrorDetails { + entity: entity.to_string(), + details: Error::MtaStsError("Could not reach policy host.".into()), + }) } else if err.is_status() & err .status() .is_some_and(|s| s == reqwest::StatusCode::NOT_FOUND) { - Status::PermanentFailure(Error::MtaStsError("Policy not found.".into())) + Status::PermanentFailure(ErrorDetails { + entity: entity.to_string(), + details: Error::MtaStsError("Policy not found.".into()), + }) } else { - Status::TemporaryFailure(Error::MtaStsError("Failed to fetch policy.".into())) + Status::TemporaryFailure(ErrorDetails { + entity: entity.to_string(), + details: Error::MtaStsError("Failed to fetch policy.".into()), + }) } } - mta_sts::Error::InvalidPolicy(err) => Status::PermanentFailure(Error::MtaStsError( - format!("Failed to parse policy: {err}"), - )), + mta_sts::Error::InvalidPolicy(err) => Status::PermanentFailure(ErrorDetails { + entity: entity.to_string(), + details: Error::MtaStsError(format!("Failed to parse policy: {err}")), + }), } } } #[derive(Debug)] pub enum NextHop<'x> { - Relay(&'x RelayHost), - MX { is_implicit: bool, host: &'x str }, + Relay(&'x RelayConfig), + MX { + is_implicit: bool, + host: &'x str, + config: &'x MxConfig, + }, } impl NextHop<'_> { @@ -232,6 +255,22 @@ impl NextHop<'_> { } } + #[inline(always)] + pub fn max_multi_homed(&self) -> usize { + match self { + NextHop::MX { config, .. } => config.max_multi_homed, + NextHop::Relay(_) => 10, + } + } + + #[inline(always)] + pub fn ip_lookup_strategy(&self) -> IpLookupStrategy { + match self { + NextHop::MX { config, .. } => config.ip_lookup_strategy, + NextHop::Relay(_) => IpLookupStrategy::Ipv4thenIpv6, + } + } + #[inline(always)] fn port(&self) -> u16 { match self { @@ -280,3 +319,23 @@ impl NextHop<'_> { } } } + +impl DeliveryResult { + pub fn domain( + status: Status, ErrorDetails>, + rcpt_idxs: Vec, + ) -> Self { + DeliveryResult::Domain { status, rcpt_idxs } + } + + pub fn rate_limited(rcpt_idxs: Vec, retry_at: u64) -> Self { + DeliveryResult::RateLimited { + rcpt_idxs, + retry_at, + } + } + + pub fn account(status: Status, ErrorDetails>, rcpt_idx: usize) -> Self { + DeliveryResult::Account { status, rcpt_idx } + } +} diff --git a/crates/smtp/src/outbound/session.rs b/crates/smtp/src/outbound/session.rs index 13526c2f..f10752c7 100644 --- a/crates/smtp/src/outbound/session.rs +++ b/crates/smtp/src/outbound/session.rs @@ -4,46 +4,41 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +use super::client::SmtpClient; +use crate::outbound::DeliveryResult; +use crate::outbound::client::{from_error_status, from_mail_send_error}; +use crate::queue::{Error, MessageWrapper, Recipient, Status}; +use crate::queue::{ErrorDetails, HostResponse, UnexpectedResponse}; use common::Server; -use common::config::smtp::queue::RequireOptional; +use common::config::smtp::queue::ConnectionStrategy; use mail_send::Credentials; use smtp_proto::{ EXT_CHUNKING, EXT_DSN, EXT_REQUIRE_TLS, EXT_SIZE, EXT_SMTP_UTF8, EhloResponse, MAIL_REQUIRETLS, MAIL_RET_FULL, MAIL_RET_HDRS, MAIL_SMTPUTF8, RCPT_NOTIFY_DELAY, RCPT_NOTIFY_FAILURE, RCPT_NOTIFY_NEVER, RCPT_NOTIFY_SUCCESS, Severity, }; -use std::time::Duration; use std::{fmt::Write, time::Instant}; use tokio::io::{AsyncRead, AsyncWrite}; use trc::DeliveryEvent; -use crate::outbound::client::{from_error_status, from_mail_send_error}; -use crate::queue::{ErrorDetails, HostResponse, RCPT_STATUS_CHANGED}; - -use crate::queue::{Error, Message, Recipient, Status}; - -use super::{TlsStrategy, client::SmtpClient}; - pub struct SessionParams<'x> { pub server: &'x Server, pub hostname: &'x str, pub credentials: Option<&'x Credentials>, pub is_smtp: bool, pub local_hostname: &'x str, - pub timeout_ehlo: Duration, - pub timeout_mail: Duration, - pub timeout_rcpt: Duration, - pub timeout_data: Duration, + pub conn_strategy: &'x ConnectionStrategy, pub session_id: u64, } -impl Message { - pub async fn deliver( +impl MessageWrapper { + pub(super) async fn deliver( &self, mut smtp_client: SmtpClient, - recipients: impl Iterator, + rcpt_idxs: Vec, + statuses: &mut Vec, params: SessionParams<'_>, - ) -> Status<(), Error> { + ) { // Obtain capabilities let time = Instant::now(); let capabilities = match smtp_client.say_helo(¶ms).await { @@ -67,7 +62,8 @@ impl Message { Elapsed = time.elapsed(), ); smtp_client.quit().await; - return status; + statuses.push(DeliveryResult::domain(status, rcpt_idxs)); + return; } }; @@ -84,7 +80,11 @@ impl Message { ); smtp_client.quit().await; - return Status::from_smtp_error(params.hostname, "AUTH ...", err); + statuses.push(DeliveryResult::domain( + Status::from_smtp_error(params.hostname, "AUTH ...", err), + rcpt_idxs, + )); + return; } trc::event!( @@ -114,7 +114,7 @@ impl Message { // MAIL FROM let time = Instant::now(); - smtp_client.timeout = params.timeout_mail; + smtp_client.timeout = params.conn_strategy.timeout_mail; let cmd = self.build_mail_from(&capabilities); match smtp_client.cmd(cmd.as_bytes()).await.and_then(|r| { if r.is_positive_completion() { @@ -128,7 +128,7 @@ impl Message { Delivery(DeliveryEvent::MailFrom), SpanId = params.session_id, Hostname = params.hostname.to_string(), - From = self.return_path.to_string(), + From = self.message.return_path.to_string(), Code = response.code, Details = response.message.to_string(), Elapsed = time.elapsed(), @@ -144,23 +144,24 @@ impl Message { ); smtp_client.quit().await; - return Status::from_smtp_error(params.hostname, &cmd, err); + statuses.push(DeliveryResult::domain( + Status::from_smtp_error(params.hostname, &cmd, err), + rcpt_idxs, + )); + return; } } // RCPT TO - let mut total_rcpt = 0; - let mut total_completed = 0; let mut accepted_rcpts = Vec::new(); - smtp_client.timeout = params.timeout_rcpt; - for rcpt in recipients { + smtp_client.timeout = params.conn_strategy.timeout_rcpt; + for rcpt_idx in &rcpt_idxs { let time = Instant::now(); - total_rcpt += 1; + let rcpt = &self.message.recipients[*rcpt_idx]; if matches!( &rcpt.status, Status::Completed(_) | Status::PermanentFailure(_) ) { - total_completed += 1; continue; } @@ -180,6 +181,7 @@ impl Message { accepted_rcpts.push(( rcpt, + rcpt_idx, Status::Completed(HostResponse { hostname: params.hostname.into(), response, @@ -197,20 +199,21 @@ impl Message { Elapsed = time.elapsed(), ); - let response = HostResponse { - hostname: ErrorDetails { - entity: params.hostname.into(), - details: cmd.trim().into(), + let response = ErrorDetails { + entity: params.hostname.into(), + details: Error::UnexpectedResponse(UnexpectedResponse { + command: cmd.trim().into(), + response, + }), + }; + statuses.push(DeliveryResult::account( + if severity == Severity::PermanentNegativeCompletion { + Status::PermanentFailure(response) + } else { + Status::TemporaryFailure(response) }, - response, - }; - rcpt.flags |= RCPT_STATUS_CHANGED; - rcpt.status = if severity == Severity::PermanentNegativeCompletion { - total_completed += 1; - Status::PermanentFailure(response) - } else { - Status::TemporaryFailure(response) - }; + *rcpt_idx, + )); } }, Err(err) => { @@ -225,7 +228,11 @@ impl Message { // Something went wrong, abort. smtp_client.quit().await; - return Status::from_smtp_error(params.hostname, "", err); + statuses.push(DeliveryResult::domain( + Status::from_smtp_error(params.hostname, "", err), + rcpt_idxs, + )); + return; } } } @@ -235,7 +242,7 @@ impl Message { let time = Instant::now(); let bdat_cmd = capabilities .has_capability(EXT_CHUNKING) - .then(|| format!("BDAT {} LAST\r\n", self.size)); + .then(|| format!("BDAT {} LAST\r\n", self.message.size)); if let Err(status) = smtp_client.send_message(self, &bdat_cmd, ¶ms).await { trc::event!( @@ -247,7 +254,8 @@ impl Message { ); smtp_client.quit().await; - return status; + statuses.push(DeliveryResult::domain(status, rcpt_idxs)); + return; } if params.is_smtp { @@ -259,7 +267,7 @@ impl Message { Ok(response) => { // Mark recipients as delivered if response.code() == 250 { - for (rcpt, status) in accepted_rcpts { + for (rcpt, rcpt_idx, status) in accepted_rcpts { trc::event!( Delivery(DeliveryEvent::Delivered), SpanId = params.session_id, @@ -270,9 +278,7 @@ impl Message { Elapsed = time.elapsed(), ); - rcpt.status = status; - rcpt.flags |= RCPT_STATUS_CHANGED; - total_completed += 1; + statuses.push(DeliveryResult::account(status, *rcpt_idx)); } } else { trc::event!( @@ -285,11 +291,15 @@ impl Message { ); smtp_client.quit().await; - return Status::from_smtp_error( - params.hostname, - bdat_cmd.as_deref().unwrap_or("DATA"), - mail_send::Error::UnexpectedReply(response), - ); + statuses.push(DeliveryResult::domain( + Status::from_smtp_error( + params.hostname, + bdat_cmd.as_deref().unwrap_or("DATA"), + mail_send::Error::UnexpectedReply(response), + ), + rcpt_idxs, + )); + return; } } Err(status) => { @@ -302,7 +312,8 @@ impl Message { ); smtp_client.quit().await; - return status; + statuses.push(DeliveryResult::domain(status, rcpt_idxs)); + return; } } } else { @@ -312,9 +323,10 @@ impl Message { .await { Ok(responses) => { - for ((rcpt, _), response) in accepted_rcpts.into_iter().zip(responses) { - rcpt.flags |= RCPT_STATUS_CHANGED; - rcpt.status = match response.severity() { + for ((rcpt, rcpt_idx, _), response) in + accepted_rcpts.into_iter().zip(responses) + { + let status = match response.severity() { Severity::PositiveCompletion => { trc::event!( Delivery(DeliveryEvent::Delivered), @@ -326,7 +338,6 @@ impl Message { Elapsed = time.elapsed(), ); - total_completed += 1; Status::Completed(HostResponse { hostname: params.hostname.to_string(), response, @@ -343,21 +354,22 @@ impl Message { Elapsed = time.elapsed(), ); - let response = HostResponse { - hostname: ErrorDetails { - entity: params.hostname.into(), - details: bdat_cmd.as_deref().unwrap_or("DATA").into(), - }, - response, + let response = ErrorDetails { + entity: params.hostname.into(), + details: Error::UnexpectedResponse(UnexpectedResponse { + command: bdat_cmd.as_deref().unwrap_or("DATA").into(), + response, + }), }; if severity == Severity::PermanentNegativeCompletion { - total_completed += 1; Status::PermanentFailure(response) } else { Status::TemporaryFailure(response) } } }; + + statuses.push(DeliveryResult::account(status, *rcpt_idx)); } } Err(status) => { @@ -370,25 +382,21 @@ impl Message { ); smtp_client.quit().await; - return status; + statuses.push(DeliveryResult::domain(status, rcpt_idxs)); + return; } } } } smtp_client.quit().await; - if total_completed == total_rcpt { - Status::Completed(()) - } else { - Status::Scheduled - } } fn build_mail_from(&self, capabilities: &EhloResponse) -> String { - let mut mail_from = String::with_capacity(self.return_path.len() + 60); - let _ = write!(mail_from, "MAIL FROM:<{}>", self.return_path); + let mut mail_from = String::with_capacity(self.message.return_path.len() + 60); + let _ = write!(mail_from, "MAIL FROM:<{}>", self.message.return_path); if capabilities.has_capability(EXT_SIZE) { - let _ = write!(mail_from, " SIZE={}", self.size); + let _ = write!(mail_from, " SIZE={}", self.message.size); } if self.has_flag(MAIL_REQUIRETLS) & capabilities.has_capability(EXT_REQUIRE_TLS) { mail_from.push_str(" REQUIRETLS"); @@ -402,7 +410,7 @@ impl Message { } else if self.has_flag(MAIL_RET_HDRS) { mail_from.push_str(" RET=HDRS"); } - if let Some(env_id) = &self.env_id { + if let Some(env_id) = &self.message.env_id { let _ = write!(mail_from, " ENVID={env_id}"); } } @@ -447,7 +455,7 @@ impl Message { #[inline(always)] pub fn has_flag(&self, flag: u64) -> bool { - (self.flags & flag) != 0 + (self.message.flags & flag) != 0 } } @@ -457,46 +465,3 @@ impl Recipient { (self.flags & flag) != 0 } } - -impl TlsStrategy { - #[inline(always)] - pub fn try_dane(&self) -> bool { - matches!( - self.dane, - RequireOptional::Require | RequireOptional::Optional - ) - } - - #[inline(always)] - pub fn try_start_tls(&self) -> bool { - matches!( - self.tls, - RequireOptional::Require | RequireOptional::Optional - ) - } - - #[inline(always)] - pub fn is_dane_required(&self) -> bool { - matches!(self.dane, RequireOptional::Require) - } - - #[inline(always)] - pub fn try_mta_sts(&self) -> bool { - matches!( - self.mta_sts, - RequireOptional::Require | RequireOptional::Optional - ) - } - - #[inline(always)] - pub fn is_mta_sts_required(&self) -> bool { - matches!(self.mta_sts, RequireOptional::Require) - } - - #[inline(always)] - pub fn is_tls_required(&self) -> bool { - matches!(self.tls, RequireOptional::Require) - || self.is_dane_required() - || self.is_mta_sts_required() - } -} diff --git a/crates/smtp/src/queue/dsn.rs b/crates/smtp/src/queue/dsn.rs index 408fe9d4..7bb56283 100644 --- a/crates/smtp/src/queue/dsn.rs +++ b/crates/smtp/src/queue/dsn.rs @@ -4,8 +4,14 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +use super::spool::SmtpSpool; +use super::{ + Error, ErrorDetails, HostResponse, Message, MessageSource, QueueEnvelope, RCPT_DSN_SENT, + RCPT_STATUS_CHANGED, Recipient, Status, +}; +use crate::queue::{MessageWrapper, UnexpectedResponse}; +use crate::reporting::SmtpReporting; use common::Server; - use mail_builder::MessageBuilder; use mail_builder::headers::HeaderType; use mail_builder::headers::content_type::ContentType; @@ -16,37 +22,26 @@ use smtp_proto::{ }; use std::fmt::Write; use std::future::Future; -use std::time::Duration; use store::write::now; -use crate::outbound::client::from_error_status; -use crate::reporting::SmtpReporting; - -use super::spool::SmtpSpool; -use super::{ - Domain, Error, ErrorDetails, HostResponse, Message, MessageSource, QueueEnvelope, - RCPT_DSN_SENT, RCPT_STATUS_CHANGED, Recipient, Status, -}; - pub trait SendDsn: Sync + Send { - fn send_dsn(&self, message: &mut Message) -> impl Future + Send; - fn log_dsn(&self, message: &Message) -> impl Future + Send; + fn send_dsn(&self, message: &mut MessageWrapper) -> impl Future + Send; + fn log_dsn(&self, message: &MessageWrapper) -> impl Future + Send; } impl SendDsn for Server { - async fn send_dsn(&self, message: &mut Message) { + async fn send_dsn(&self, message: &mut MessageWrapper) { // Send DSN events self.log_dsn(message).await; - if !message.return_path.is_empty() { + if !message.message.return_path.is_empty() { // Build DSN if let Some(dsn) = message.build_dsn(self).await { let mut dsn_message = self.new_message("", "", "", message.span_id); dsn_message .add_recipient_parts( - message.return_path.as_str(), - message.return_path_lcase.as_str(), - message.return_path_domain.as_str(), + message.message.return_path.as_str(), + message.message.return_path_lcase.as_str(), self, ) .await; @@ -73,15 +68,14 @@ impl SendDsn for Server { } } - async fn log_dsn(&self, message: &Message) { + async fn log_dsn(&self, message: &MessageWrapper) { let now = now(); - for rcpt in &message.recipients { + for rcpt in &message.message.recipients { if rcpt.has_flag(RCPT_DSN_SENT) { continue; } - let domain = &message.domains[rcpt.domain_idx as usize]; match &rcpt.status { Status::Completed(response) => { trc::event!( @@ -93,17 +87,18 @@ impl SendDsn for Server { Details = response.response.message.to_string(), ); } - Status::TemporaryFailure(response) if domain.notify.due <= now => { + Status::TemporaryFailure(response) if rcpt.notify.due <= now => { trc::event!( Delivery(trc::DeliveryEvent::DsnTempFail), SpanId = message.span_id, To = rcpt.address_lcase.clone(), - Hostname = response.hostname.entity.clone(), - Code = response.response.code, - Details = response.response.message.to_string(), - NextRetry = trc::Value::Timestamp(domain.retry.due), - Expires = trc::Value::Timestamp(domain.expires), - Total = domain.retry.inner, + Hostname = response.entity.clone(), + Details = response.details.to_string(), + NextRetry = trc::Value::Timestamp(rcpt.retry.due), + Expires = rcpt + .expiration_time(message.message.created) + .map(trc::Value::Timestamp), + Total = rcpt.retry.inner, ); } Status::PermanentFailure(response) => { @@ -111,48 +106,23 @@ impl SendDsn for Server { Delivery(trc::DeliveryEvent::DsnPermFail), SpanId = message.span_id, To = rcpt.address_lcase.clone(), - Hostname = response.hostname.entity.clone(), - Code = response.response.code, - Details = response.response.message.to_string(), - Total = domain.retry.inner, + Hostname = response.entity.clone(), + Details = response.details.to_string(), + Total = rcpt.retry.inner, ); } - Status::Scheduled => { - // There is no status for this address, use the domain's status. - match &domain.status { - Status::PermanentFailure(_) => { - trc::event!( - Delivery(trc::DeliveryEvent::DsnPermFail), - SpanId = message.span_id, - To = rcpt.address_lcase.clone(), - Details = from_error_status(&domain.status), - Total = domain.retry.inner, - ); - } - Status::TemporaryFailure(_) if domain.notify.due <= now => { - trc::event!( - Delivery(trc::DeliveryEvent::DsnTempFail), - SpanId = message.span_id, - To = rcpt.address_lcase.clone(), - Details = from_error_status(&domain.status), - NextRetry = trc::Value::Timestamp(domain.retry.due), - Expires = trc::Value::Timestamp(domain.expires), - Total = domain.retry.inner, - ); - } - Status::Scheduled if domain.notify.due <= now => { - trc::event!( - Delivery(trc::DeliveryEvent::DsnTempFail), - SpanId = message.span_id, - To = rcpt.address_lcase.clone(), - Details = "Concurrency limited", - NextRetry = trc::Value::Timestamp(domain.retry.due), - Expires = trc::Value::Timestamp(domain.expires), - Total = domain.retry.inner, - ); - } - _ => continue, - } + Status::Scheduled if rcpt.notify.due <= now => { + trc::event!( + Delivery(trc::DeliveryEvent::DsnTempFail), + SpanId = message.span_id, + To = rcpt.address_lcase.clone(), + Details = "Concurrency limited", + NextRetry = trc::Value::Timestamp(rcpt.retry.due), + Expires = rcpt + .expiration_time(message.message.created) + .map(trc::Value::Timestamp), + Total = rcpt.retry.inner, + ); } _ => continue, } @@ -160,7 +130,7 @@ impl SendDsn for Server { } } -impl Message { +impl MessageWrapper { pub async fn build_dsn(&mut self, server: &Server) -> Option> { let config = &server.core.smtp.queue; let now = now(); @@ -170,11 +140,10 @@ impl Message { let mut txt_failed = String::new(); let mut dsn = String::new(); - for rcpt in &mut self.recipients { + for rcpt in &mut self.message.recipients { if rcpt.has_flag(RCPT_DSN_SENT | RCPT_NOTIFY_NEVER) { continue; } - let domain = &self.domains[rcpt.domain_idx as usize]; match &rcpt.status { Status::Completed(response) => { rcpt.flags |= RCPT_DSN_SENT | RCPT_STATUS_CHANGED; @@ -186,11 +155,11 @@ impl Message { response.write_dsn_text(&rcpt.address, &mut txt_success); } Status::TemporaryFailure(response) - if domain.notify.due <= now && rcpt.has_flag(RCPT_NOTIFY_DELAY) => + if rcpt.notify.due <= now && rcpt.has_flag(RCPT_NOTIFY_DELAY) => { rcpt.write_dsn(&mut dsn); rcpt.status.write_dsn(&mut dsn); - domain.write_dsn_will_retry_until(&mut dsn); + rcpt.write_dsn_will_retry_until(self.message.created, &mut dsn); response.write_dsn_text(&rcpt.address, &mut txt_delay); } Status::PermanentFailure(response) => { @@ -202,45 +171,16 @@ impl Message { rcpt.status.write_dsn(&mut dsn); response.write_dsn_text(&rcpt.address, &mut txt_failed); } - Status::Scheduled => { - // There is no status for this address, use the domain's status. - match &domain.status { - Status::PermanentFailure(err) => { - rcpt.flags |= RCPT_DSN_SENT | RCPT_STATUS_CHANGED; - if !rcpt.has_flag(RCPT_NOTIFY_FAILURE) { - continue; - } - rcpt.write_dsn(&mut dsn); - domain.status.write_dsn(&mut dsn); - err.write_dsn_text(&rcpt.address, &domain.domain, &mut txt_failed); - } - Status::TemporaryFailure(err) - if domain.notify.due <= now && rcpt.has_flag(RCPT_NOTIFY_DELAY) => - { - rcpt.write_dsn(&mut dsn); - domain.status.write_dsn(&mut dsn); - domain.write_dsn_will_retry_until(&mut dsn); - err.write_dsn_text(&rcpt.address, &domain.domain, &mut txt_delay); - } - Status::Scheduled - if domain.notify.due <= now && rcpt.has_flag(RCPT_NOTIFY_DELAY) => - { - // This case should not happen under normal circumstances - rcpt.write_dsn(&mut dsn); - domain.status.write_dsn(&mut dsn); - domain.write_dsn_will_retry_until(&mut dsn); - Error::ConcurrencyLimited.write_dsn_text( - &rcpt.address, - &domain.domain, - &mut txt_delay, - ); - } - Status::Completed(_) => { - #[cfg(feature = "test_mode")] - panic!("This should not have happened."); - } - _ => continue, + Status::Scheduled if rcpt.notify.due <= now && rcpt.has_flag(RCPT_NOTIFY_DELAY) => { + // This case should not happen under normal circumstances + rcpt.write_dsn(&mut dsn); + rcpt.status.write_dsn(&mut dsn); + rcpt.write_dsn_will_retry_until(self.message.created, &mut dsn); + ErrorDetails { + entity: "localhost".into(), + details: Error::ConcurrencyLimited, } + .write_dsn_text(&rcpt.address, &mut txt_delay); } _ => continue, } @@ -315,58 +255,69 @@ impl Message { // Update next delay notification time if has_delay { let mut changes = Vec::new(); - for (domain_idx, domain) in self.domains.iter().enumerate() { + for (rcpt_idx, rcpt) in self.message.recipients.iter().enumerate() { if matches!( - &domain.status, + &rcpt.status, Status::TemporaryFailure(_) | Status::Scheduled - ) && domain.notify.due <= now + ) && rcpt.notify.due <= now { - let envelope = QueueEnvelope::new(self, domain_idx); + let envelope = QueueEnvelope::new_rcpt(&self.message, rcpt_idx); - if let Some(next_notify) = server - .eval_if::, _>(&config.notify, &envelope, self.span_id) + let queue_id = server + .eval_if::( + &server.core.smtp.queue.queue, + &envelope, + self.span_id, + ) .await - .and_then(|notify| { - notify.into_iter().nth((domain.notify.inner + 1) as usize) - }) + .unwrap_or_else(|| "default".to_string()); + let queue = server.get_queue_or_default(&queue_id, self.span_id); + + if let Some(next_notify) = + queue.notify.get((rcpt.notify.inner + 1) as usize).copied() { - changes.push((domain_idx, 1, now + next_notify.as_secs())); + changes.push((rcpt_idx, 1, now + next_notify)); } else { - changes.push((domain_idx, 0, domain.expires + 10)); + changes.push((rcpt_idx, 0, u64::MAX)); } } } - for (domain_idx, inner, due) in changes { - let domain = &mut self.domains[domain_idx]; - domain.notify.inner += inner; - domain.notify.due = due; + for (rcpt_idx, inner, due) in changes { + let rcpt = &mut self.message.recipients[rcpt_idx]; + rcpt.notify.inner += inner; + rcpt.notify.due = due; } } // Obtain hostname and sender addresses let from_name = server - .eval_if(&config.dsn.name, self, self.span_id) + .eval_if(&config.dsn.name, &self.message, self.span_id) .await .unwrap_or_else(|| String::from("Mail Delivery Subsystem")); let from_addr = server - .eval_if(&config.dsn.address, self, self.span_id) + .eval_if(&config.dsn.address, &self.message, self.span_id) .await .unwrap_or_else(|| String::from("MAILER-DAEMON@localhost")); let reporting_mta = server - .eval_if(&server.core.smtp.report.submitter, self, self.span_id) + .eval_if( + &server.core.smtp.report.submitter, + &self.message, + self.span_id, + ) .await .unwrap_or_else(|| String::from("localhost")); // Prepare DSN let mut dsn_header = String::with_capacity(dsn.len() + 128); - self.write_dsn_headers(&mut dsn_header, &reporting_mta); + self.message + .write_dsn_headers(&mut dsn_header, &reporting_mta); let dsn = dsn_header + dsn.as_str(); // Fetch up to 1024 bytes of message headers let headers = match server .blob_store() - .get_blob(self.blob_hash.as_slice(), 0..1024) + .get_blob(self.message.blob_hash.as_slice(), 0..1024) .await { Ok(Some(mut buf)) => { @@ -398,7 +349,7 @@ impl Message { trc::event!( Queue(trc::QueueEvent::BlobNotFound), SpanId = self.span_id, - BlobId = self.blob_hash.to_hex(), + BlobId = self.message.blob_hash.to_hex(), CausedBy = trc::location!() ); @@ -418,7 +369,10 @@ impl Message { // Build message MessageBuilder::new() .from((from_name.as_str(), from_addr.as_str())) - .header("To", HeaderType::Text(self.return_path.as_str().into())) + .header( + "To", + HeaderType::Text(self.message.return_path.as_str().into()), + ) .header("Auto-Submitted", HeaderType::Text("auto-generated".into())) .message_id(format!("<{}@{}>", make_boundary("."), reporting_mta)) .subject(subject) @@ -443,34 +397,23 @@ impl Message { fn handle_double_bounce(&mut self) { let mut is_double_bounce = Vec::with_capacity(0); + let now = now(); - for rcpt in &mut self.recipients { + for rcpt in &mut self.message.recipients { if !rcpt.has_flag(RCPT_DSN_SENT | RCPT_NOTIFY_NEVER) { - match &rcpt.status { - Status::PermanentFailure(err) => { - rcpt.flags |= RCPT_DSN_SENT; - let mut dsn = String::new(); - err.write_dsn_text(&rcpt.address, &mut dsn); - is_double_bounce.push(dsn); - } - Status::Scheduled => { - let domain = &self.domains[rcpt.domain_idx as usize]; - if let Status::PermanentFailure(err) = &domain.status { - rcpt.flags |= RCPT_DSN_SENT; - let mut dsn = String::new(); - err.write_dsn_text(&rcpt.address, &domain.domain, &mut dsn); - is_double_bounce.push(dsn); - } - } - _ => (), + if let Status::PermanentFailure(err) = &rcpt.status { + rcpt.flags |= RCPT_DSN_SENT; + let mut dsn = String::new(); + err.write_dsn_text(&rcpt.address, &mut dsn); + is_double_bounce.push(dsn); } } - } - let now = now(); - for domain in &mut self.domains { - if domain.notify.due <= now { - domain.notify.due = domain.expires + 10; + if rcpt.notify.due <= now { + rcpt.notify.due = rcpt + .expiration_time(self.message.created) + .map(|d| d + 10) + .unwrap_or(u64::MAX); } } @@ -501,12 +444,12 @@ impl HostResponse { } } -impl HostResponse { - fn write_dsn_text(&self, addr: &str, dsn: &mut String) { - let _ = write!(dsn, "<{}> (host '{}' rejected ", addr, self.hostname.entity); +impl UnexpectedResponse { + fn write_dsn_text(&self, host: &str, addr: &str, dsn: &mut String) { + let _ = write!(dsn, "<{addr}> (host '{host}' rejected "); - if !self.hostname.details.is_empty() { - let _ = write!(dsn, "command '{}'", self.hostname.details,); + if !self.command.is_empty() { + let _ = write!(dsn, "command '{}'", self.command); } else { dsn.push_str("transaction"); } @@ -521,40 +464,35 @@ impl HostResponse { } } -impl Error { - fn write_dsn_text(&self, addr: &str, domain: &str, dsn: &mut String) { - match self { +impl ErrorDetails { + fn write_dsn_text(&self, addr: &str, dsn: &mut String) { + let entity = self.entity.as_str(); + match &self.details { Error::UnexpectedResponse(response) => { - response.write_dsn_text(addr, dsn); + response.write_dsn_text(entity, addr, dsn); } Error::DnsError(err) => { - let _ = write!(dsn, "<{addr}> (failed to lookup '{domain}': {err})\r\n",); + let _ = write!(dsn, "<{addr}> (failed to lookup '{entity}': {err})\r\n",); } Error::ConnectionError(details) => { let _ = write!( dsn, - "<{}> (connection to '{}' failed: {})\r\n", - addr, details.entity, details.details + "<{addr}> (connection to '{entity}' failed: {details})\r\n", ); } Error::TlsError(details) => { - let _ = write!( - dsn, - "<{}> (TLS error from '{}': {})\r\n", - addr, details.entity, details.details - ); + let _ = write!(dsn, "<{addr}> (TLS error from '{entity}': {details})\r\n",); } Error::DaneError(details) => { let _ = write!( dsn, - "<{}> (DANE failed to authenticate '{}': {})\r\n", - addr, details.entity, details.details + "<{addr}> (DANE failed to authenticate '{entity}': {details})\r\n", ); } Error::MtaStsError(details) => { let _ = write!( dsn, - "<{addr}> (MTA-STS failed to authenticate '{domain}': {details})\r\n", + "<{addr}> (MTA-STS failed to authenticate '{entity}': {details})\r\n", ); } Error::RateLimited => { @@ -593,15 +531,14 @@ impl Recipient { } let _ = write!(dsn, "Final-Recipient: rfc822;{}\r\n", self.address); } -} -impl Domain { - fn write_dsn_will_retry_until(&self, dsn: &mut String) { - let now = now(); - if self.expires > now { - dsn.push_str("Will-Retry-Until: "); - dsn.push_str(&DateTime::from_timestamp(self.expires as i64).to_rfc822()); - dsn.push_str("\r\n"); + fn write_dsn_will_retry_until(&self, created: u64, dsn: &mut String) { + if let Some(expires) = self.expiration_time(created) { + if expires > now() { + dsn.push_str("Will-Retry-Until: "); + dsn.push_str(&DateTime::from_timestamp(expires as i64).to_rfc822()); + dsn.push_str("\r\n"); + } } } } @@ -636,7 +573,7 @@ impl Status { } } -impl Status, HostResponse> { +impl Status, ErrorDetails> { fn write_dsn(&self, dsn: &mut String) { self.write_dsn_action(dsn); self.write_dsn_status(dsn); @@ -646,95 +583,55 @@ impl Status, HostResponse> { fn write_dsn_status(&self, dsn: &mut String) { dsn.push_str("Status: "); - if let Status::Completed(HostResponse { response, .. }) - | Status::PermanentFailure(HostResponse { response, .. }) - | Status::TemporaryFailure(HostResponse { response, .. }) = self - { - response.write_dsn_status(dsn); - } - dsn.push_str("\r\n"); - } - - fn write_dsn_remote_mta(&self, dsn: &mut String) { - dsn.push_str("Remote-MTA: dns;"); match self { - Status::Completed(HostResponse { hostname, .. }) => { - dsn.push_str(hostname); + Status::Completed(response) => { + response.response.write_dsn_status(dsn); } - Status::PermanentFailure(HostResponse { - hostname: ErrorDetails { - entity: hostname, .. - }, - .. - }) - | Status::TemporaryFailure(HostResponse { - hostname: ErrorDetails { - entity: hostname, .. - }, - .. - }) => { - dsn.push_str(hostname); + Status::TemporaryFailure(err) | Status::PermanentFailure(err) => { + if let Error::UnexpectedResponse(response) = &err.details { + response.response.write_dsn_status(dsn); + } else { + dsn.push_str(if matches!(self, Status::PermanentFailure(_)) { + "5.0.0" + } else { + "4.0.0" + }); + } + } + Status::Scheduled => { + dsn.push_str("4.0.0"); } - _ => (), } - dsn.push_str("\r\n"); } - fn write_dsn_diagnostic(&self, dsn: &mut String) { - if let Status::PermanentFailure(details) | Status::TemporaryFailure(details) = self { - details.response.write_dsn_diagnostic(dsn); - } - } -} - -impl Status<(), Error> { - fn write_dsn(&self, dsn: &mut String) { - self.write_dsn_action(dsn); - self.write_dsn_status(dsn); - self.write_dsn_diagnostic(dsn); - self.write_dsn_remote_mta(dsn); - } - - fn write_dsn_status(&self, dsn: &mut String) { - if let Status::PermanentFailure(err) | Status::TemporaryFailure(err) = self { - dsn.push_str("Status: "); - if let Error::UnexpectedResponse(response) = err { - response.response.write_dsn_status(dsn); - } else { - dsn.push_str(if matches!(self, Status::PermanentFailure(_)) { - "5.0.0" - } else { - "4.0.0" - }); - } - dsn.push_str("\r\n"); - } - } - fn write_dsn_remote_mta(&self, dsn: &mut String) { - if let Status::PermanentFailure(err) | Status::TemporaryFailure(err) = self { - match err { - Error::UnexpectedResponse(HostResponse { - hostname: details, .. - }) - | Error::ConnectionError(details) - | Error::TlsError(details) - | Error::DaneError(details) => { + match self { + Status::Completed(response) => { + dsn.push_str("Remote-MTA: dns;"); + dsn.push_str(&response.hostname); + dsn.push_str("\r\n"); + } + Status::TemporaryFailure(err) | Status::PermanentFailure(err) => match &err.details { + Error::UnexpectedResponse(_) + | Error::ConnectionError(_) + | Error::TlsError(_) + | Error::DaneError(_) => { dsn.push_str("Remote-MTA: dns;"); - dsn.push_str(&details.entity); + dsn.push_str(&err.entity); dsn.push_str("\r\n"); } _ => (), - } + }, + Status::Scheduled => (), } } fn write_dsn_diagnostic(&self, dsn: &mut String) { - if let Status::PermanentFailure(Error::UnexpectedResponse(response)) - | Status::TemporaryFailure(Error::UnexpectedResponse(response)) = self - { - response.response.write_dsn_diagnostic(dsn); + if let Status::PermanentFailure(err) | Status::TemporaryFailure(err) = self { + if let Error::UnexpectedResponse(response) = &err.details { + response.response.write_dsn_diagnostic(dsn); + } } } } diff --git a/crates/smtp/src/queue/manager.rs b/crates/smtp/src/queue/manager.rs index 52f9bd55..f9ca8a7f 100644 --- a/crates/smtp/src/queue/manager.rs +++ b/crates/smtp/src/queue/manager.rs @@ -4,26 +4,26 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::{ - sync::{Arc, atomic::Ordering}, - time::{Duration, Instant}, -}; - -use ahash::{AHashMap, AHashSet}; -use common::{ - Inner, - core::BuildServer, - ipc::{QueueEvent, QueueEventStatus}, - listener::limiter::ConcurrencyLimiter, -}; -use rand::seq::SliceRandom; -use store::write::now; -use tokio::sync::mpsc; - use super::{ Message, QueueId, Status, spool::{QUEUE_REFRESH, SmtpSpool}, }; +use crate::queue::Recipient; +use ahash::AHashMap; +use common::{ + Inner, + config::smtp::queue::{QueueExpiry, QueueName}, + core::BuildServer, + ipc::{QueueEvent, QueueEventStatus}, +}; +use rand::seq::SliceRandom; +use std::{ + collections::hash_map::Entry, + sync::{Arc, atomic::Ordering}, + time::{Duration, Instant}, +}; +use store::write::now; +use tokio::sync::mpsc; pub struct Queue { pub core: Arc, @@ -35,13 +35,7 @@ pub struct Queue { #[derive(Debug)] pub enum OnHold { InFlight, - ConcurrencyLimited { - limiters: Vec, - next_due: Option, - }, - Locked { - until: u64, - }, + Locked { until: u64 }, } impl SpawnQueue for mpsc::Receiver { @@ -122,7 +116,8 @@ impl Queue { if refresh_queue || self.next_wake_up <= Instant::now() { // If the number of in-flight messages is greater than the maximum allowed, skip the queue let server = self.core.build_server(); - let max_in_flight = server.core.smtp.queue.max_threads; + let todo = "fix + implement virtual queues"; + let max_in_flight = 4; //server.core.smtp.queue.max_threads; has_back_pressure = in_flight_count >= max_in_flight; if has_back_pressure { self.next_wake_up = Instant::now() + Duration::from_secs(QUEUE_REFRESH); @@ -138,11 +133,10 @@ impl Queue { Details = self .on_hold .values() - .fold([0, 0, 0], |mut acc, v| { + .fold([0, 0], |mut acc, v| { match v { OnHold::InFlight => acc[0] += 1, - OnHold::ConcurrencyLimited { .. } => acc[1] += 1, - OnHold::Locked { .. } => acc[2] += 1, + OnHold::Locked { .. } => acc[1] += 1, } acc }) @@ -180,13 +174,10 @@ impl Queue { Details = self .on_hold .values() - .fold([0, 0, 0], |mut acc, v| { + .fold([0, 0], |mut acc, v| { match v { OnHold::InFlight => acc[0] += 1, - OnHold::ConcurrencyLimited { .. } => { - acc[1] += 1 - } - OnHold::Locked { .. } => acc[2] += 1, + OnHold::Locked { .. } => acc[1] += 1, } acc }) @@ -211,14 +202,6 @@ impl Queue { continue; } } - OnHold::ConcurrencyLimited { limiters, next_due } => { - if !(limiters.iter().any(|l| { - l.concurrent.load(Ordering::Relaxed) < l.max_concurrent - }) || next_due.is_some_and(|due| due <= now)) - { - continue; - } - } OnHold::InFlight => continue, } @@ -243,17 +226,10 @@ impl Queue { next_cleanup = now + CLEANUP_INTERVAL; if !self.on_hold.is_empty() { - let active_queue_ids = queue_events - .into_iter() - .map(|e| e.queue_id) - .collect::>(); let now = store::write::now(); self.on_hold.retain(|queue_id, status| match status { OnHold::InFlight => true, OnHold::Locked { until } => *until > now, - OnHold::ConcurrencyLimited { .. } => { - active_queue_ids.contains(queue_id) - } }); } } @@ -269,112 +245,162 @@ impl Queue { } impl Message { - pub fn next_event(&self) -> Option { - let mut next_event = now(); - let mut has_events = false; - - for domain in &self.domains { - if matches!( - domain.status, - Status::Scheduled | Status::TemporaryFailure(_) - ) { - if !has_events || domain.retry.due < next_event { - next_event = domain.retry.due; - has_events = true; - } - if domain.notify.due < next_event { - next_event = domain.notify.due; - } - if domain.expires < next_event { - next_event = domain.expires; - } - } - } - - if has_events { next_event.into() } else { None } - } - - pub fn next_delivery_event(&self) -> u64 { - let mut next_delivery = now(); - - for (pos, domain) in self - .domains - .iter() - .filter(|d| matches!(d.status, Status::Scheduled | Status::TemporaryFailure(_))) - .enumerate() - { - if pos == 0 || domain.retry.due < next_delivery { - next_delivery = domain.retry.due; - } - } - - next_delivery - } - - pub fn next_dsn(&self) -> u64 { - let mut next_dsn = now(); - - for (pos, domain) in self - .domains - .iter() - .filter(|d| matches!(d.status, Status::Scheduled | Status::TemporaryFailure(_))) - .enumerate() - { - if pos == 0 || domain.notify.due < next_dsn { - next_dsn = domain.notify.due; - } - } - - next_dsn - } - - pub fn expires(&self) -> u64 { - let mut expires = now(); - - for (pos, domain) in self - .domains - .iter() - .filter(|d| matches!(d.status, Status::Scheduled | Status::TemporaryFailure(_))) - .enumerate() - { - if pos == 0 || domain.expires < expires { - expires = domain.expires; - } - } - - expires - } - - pub fn next_event_after(&self, instant: u64) -> Option { + pub fn next_event(&self, queue: Option) -> Option { let mut next_event = None; - for domain in &self.domains { - if matches!( - domain.status, - Status::Scheduled | Status::TemporaryFailure(_) - ) { - if domain.retry.due > instant - && next_event.as_ref().is_none_or(|ne| domain.retry.due.lt(ne)) - { - next_event = domain.retry.due.into(); + for rcpt in &self.recipients { + if matches!(rcpt.status, Status::Scheduled | Status::TemporaryFailure(_)) + && queue.is_none_or(|q| rcpt.queue == q) + { + let mut earlier_event = std::cmp::min(rcpt.retry.due, rcpt.notify.due); + + if let Some(expires) = rcpt.expiration_time(self.created) { + earlier_event = std::cmp::min(earlier_event, expires); } - if domain.notify.due > instant - && next_event - .as_ref() - .is_none_or(|ne| domain.notify.due.lt(ne)) - { - next_event = domain.notify.due.into(); - } - if domain.expires > instant - && next_event.as_ref().is_none_or(|ne| domain.expires.lt(ne)) - { - next_event = domain.expires.into(); + + if let Some(next_event) = &mut next_event { + if earlier_event < *next_event { + *next_event = earlier_event; + } + } else { + next_event = Some(earlier_event); } } } next_event } + + pub fn next_delivery_event(&self, queue: Option) -> Option { + let mut next_delivery = None; + + for rcpt in self.recipients.iter().filter(|rcpt| { + matches!(rcpt.status, Status::Scheduled | Status::TemporaryFailure(_)) + && queue.is_none_or(|q| rcpt.queue == q) + }) { + if let Some(next_delivery) = &mut next_delivery { + if rcpt.retry.due < *next_delivery { + *next_delivery = rcpt.retry.due; + } + } else { + next_delivery = Some(rcpt.retry.due); + } + } + + next_delivery + } + + pub fn next_dsn(&self, queue: Option) -> Option { + let mut next_dsn = None; + + for rcpt in self.recipients.iter().filter(|rcpt| { + matches!(rcpt.status, Status::Scheduled | Status::TemporaryFailure(_)) + && queue.is_none_or(|q| rcpt.queue == q) + }) { + if let Some(next_dsn) = &mut next_dsn { + if rcpt.notify.due < *next_dsn { + *next_dsn = rcpt.notify.due; + } + } else { + next_dsn = Some(rcpt.notify.due); + } + } + + next_dsn + } + + pub fn expires(&self, queue: Option) -> Option { + let mut expires = None; + + for rcpt in self.recipients.iter().filter(|d| { + matches!(d.status, Status::Scheduled | Status::TemporaryFailure(_)) + && queue.is_none_or(|q| d.queue == q) + }) { + if let Some(rcpt_expires) = rcpt.expiration_time(self.created) { + if let Some(expires) = &mut expires { + if rcpt_expires > *expires { + *expires = rcpt_expires; + } + } else { + expires = Some(rcpt_expires) + } + } + } + + expires + } + + pub fn next_event_after(&self, queue: Option, instant: u64) -> Option { + let mut next_event = None; + + for rcpt in &self.recipients { + if matches!(rcpt.status, Status::Scheduled | Status::TemporaryFailure(_)) + && queue.is_none_or(|q| rcpt.queue == q) + { + if rcpt.retry.due > instant + && next_event.as_ref().is_none_or(|ne| rcpt.retry.due.lt(ne)) + { + next_event = rcpt.retry.due.into(); + } + if rcpt.notify.due > instant + && next_event.as_ref().is_none_or(|ne| rcpt.notify.due.lt(ne)) + { + next_event = rcpt.notify.due.into(); + } + if let Some(expires) = rcpt.expiration_time(self.created) { + if expires > instant && next_event.as_ref().is_none_or(|ne| expires.lt(ne)) { + next_event = expires.into(); + } + } + } + } + + next_event + } + + pub fn next_events(&self) -> AHashMap { + let mut next_events = AHashMap::new(); + + for rcpt in &self.recipients { + if matches!(rcpt.status, Status::Scheduled | Status::TemporaryFailure(_)) { + let mut earlier_event = std::cmp::min(rcpt.retry.due, rcpt.notify.due); + + if let Some(expires) = rcpt.expiration_time(self.created) { + earlier_event = std::cmp::min(earlier_event, expires); + } + + match next_events.entry(rcpt.queue) { + Entry::Occupied(mut entry) => { + let entry = entry.get_mut(); + if earlier_event < *entry { + *entry = earlier_event; + } + } + Entry::Vacant(entry) => { + entry.insert(earlier_event); + } + } + } + } + + next_events + } +} + +impl Recipient { + pub fn expiration_time(&self, created: u64) -> Option { + match self.expires { + QueueExpiry::Duration(time) => Some(created + time), + QueueExpiry::Count(_) => None, + } + } + + pub fn is_expired(&self, created: u64, now: u64) -> bool { + match self.expires { + QueueExpiry::Duration(time) => created + time <= now, + QueueExpiry::Count(count) => self.retry.inner >= count, + } + } } pub trait SpawnQueue { diff --git a/crates/smtp/src/queue/mod.rs b/crates/smtp/src/queue/mod.rs index 93c8a29b..03ef0c07 100644 --- a/crates/smtp/src/queue/mod.rs +++ b/crates/smtp/src/queue/mod.rs @@ -4,16 +4,17 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +use common::{ + config::smtp::queue::{QueueExpiry, QueueName}, + expr::{self, functions::ResolveVariable, *}, +}; +use compact_str::ToCompactString; +use smtp_proto::{ArchivedResponse, Response}; use std::{ fmt::Display, net::{IpAddr, Ipv4Addr}, time::{Duration, Instant, SystemTime}, }; - -use common::expr::{self, functions::ResolveVariable, *}; - -use compact_str::ToCompactString; -use smtp_proto::{ArchivedResponse, Response}; use store::write::now; use utils::BlobHash; @@ -34,7 +35,8 @@ pub struct Schedule { #[derive(Debug, Clone, Copy)] pub struct QueuedMessage { pub due: u64, - pub queue_id: u64, + pub queue_id: QueueId, + pub queue_name: QueueName, } #[derive(Debug, Clone, Copy)] @@ -48,15 +50,16 @@ pub enum MessageSource { #[derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive, Debug, Clone, PartialEq, Eq)] pub struct Message { - pub queue_id: QueueId, pub created: u64, pub blob_hash: BlobHash, + pub received_from_ip: IpAddr, + pub received_via_port: u16, + pub return_path: String, pub return_path_lcase: String, pub return_path_domain: String, pub recipients: Vec, - pub domains: Vec, pub flags: u64, pub env_id: Option, @@ -64,9 +67,14 @@ pub struct Message { pub size: u64, pub quota_keys: Vec, +} - #[rkyv(with = rkyv::with::Skip)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MessageWrapper { + pub queue_id: QueueId, + pub queue_name: QueueName, pub span_id: u64, + pub message: Message, } #[derive( @@ -84,24 +92,6 @@ pub enum QuotaKey { Count { key: Vec, id: u64 }, } -#[derive( - rkyv::Serialize, - rkyv::Deserialize, - rkyv::Archive, - Debug, - Clone, - PartialEq, - Eq, - serde::Deserialize, -)] -pub struct Domain { - pub domain: String, - pub retry: Schedule, - pub notify: Schedule, - pub expires: u64, - pub status: Status<(), Error>, -} - #[derive( rkyv::Serialize, rkyv::Deserialize, @@ -113,10 +103,15 @@ pub struct Domain { serde::Deserialize, )] pub struct Recipient { - pub domain_idx: u32, pub address: String, pub address_lcase: String, - pub status: Status, HostResponse>, + + pub retry: Schedule, + pub notify: Schedule, + pub expires: QueueExpiry, + + pub queue: QueueName, + pub status: Status, ErrorDetails>, pub flags: u64, pub orcpt: Option, } @@ -173,19 +168,36 @@ pub struct HostResponse { rkyv::Deserialize, rkyv::Archive, serde::Deserialize, + Default, )] pub enum Error { DnsError(String), - UnexpectedResponse(HostResponse), - ConnectionError(ErrorDetails), - TlsError(ErrorDetails), - DaneError(ErrorDetails), + UnexpectedResponse(UnexpectedResponse), + ConnectionError(String), + TlsError(String), + DaneError(String), MtaStsError(String), RateLimited, + #[default] ConcurrencyLimited, Io(String), } +#[derive( + Debug, + Clone, + PartialEq, + Eq, + rkyv::Serialize, + rkyv::Deserialize, + rkyv::Archive, + serde::Deserialize, +)] +pub struct UnexpectedResponse { + pub command: String, + pub response: Response, +} + #[derive( Debug, Clone, @@ -199,7 +211,7 @@ pub enum Error { )] pub struct ErrorDetails { pub entity: String, - pub details: String, + pub details: Error, } impl Ord for Schedule { @@ -230,9 +242,9 @@ impl Schedule { } } - pub fn later(duration: Duration) -> Self { + pub fn later(duration: u64) -> Self { Schedule { - due: now() + duration.as_secs(), + due: now() + duration, inner: T::default(), } } @@ -243,37 +255,22 @@ pub struct QueueEnvelope<'x> { pub mx: &'x str, pub remote_ip: IpAddr, pub local_ip: IpAddr, - pub current_domain: usize, pub current_rcpt: usize, } impl<'x> QueueEnvelope<'x> { - pub fn new(message: &'x Message, current_domain: usize) -> Self { + pub fn new_rcpt(message: &'x Message, current_rcpt: usize) -> Self { Self { message, - current_domain, - current_rcpt: 0, - mx: "", - remote_ip: IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), - local_ip: IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), - } - } - - pub fn new_rcpt(message: &'x Message, current_domain: usize, current_rcpt: usize) -> Self { - Self { - message, - current_domain, current_rcpt, mx: "", remote_ip: IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), local_ip: IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), } } -} -impl<'x> QueueEnvelope<'x> { - fn current_domain(&self) -> Option<&'x Domain> { - self.message.domains.get(self.current_domain) + fn current_recipient(&self) -> Option<&'x Recipient> { + self.message.recipients.get(self.current_rcpt) } } @@ -283,14 +280,12 @@ 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 - .current_domain() - .map(|d| d.domain.as_str()) + .current_recipient() + .map(|d| d.address_lcase.domain_part()) .unwrap_or_default() .into(), V_RECIPIENT => self - .message - .recipients - .get(self.current_rcpt) + .current_recipient() .map(|r| r.address_lcase.as_str()) .unwrap_or_default() .into(), @@ -302,40 +297,47 @@ impl<'x> ResolveVariable for QueueEnvelope<'x> { .collect::>() .into(), V_QUEUE_RETRY_NUM => self - .current_domain() + .current_recipient() .map(|d| d.retry.inner) .unwrap_or_default() .into(), V_QUEUE_NOTIFY_NUM => self - .current_domain() + .current_recipient() .map(|d| d.notify.inner) .unwrap_or_default() .into(), V_QUEUE_EXPIRES_IN => self - .current_domain() - .map(|d| d.expires.saturating_sub(now())) + .current_recipient() + .map(|d| match &d.expires { + QueueExpiry::Duration(time) => { + (*time + self.message.created).saturating_sub(now()) + } + QueueExpiry::Count(count) => (*count) as u64, + }) .unwrap_or_default() .into(), V_QUEUE_LAST_STATUS => self - .current_domain() + .current_recipient() .map(|d| d.status.to_compact_string()) .unwrap_or_default() .into(), V_QUEUE_LAST_ERROR => self - .current_domain() + .current_recipient() .map(|d| match &d.status { Status::Scheduled | Status::Completed(_) => "none", - Status::TemporaryFailure(err) | Status::PermanentFailure(err) => match err { - Error::DnsError(_) => "dns", - Error::UnexpectedResponse(_) => "unexpected-reply", - Error::ConnectionError(_) => "connection", - Error::TlsError(_) => "tls", - Error::DaneError(_) => "dane", - Error::MtaStsError(_) => "mta-sts", - Error::RateLimited => "rate", - Error::ConcurrencyLimited => "concurrency", - Error::Io(_) => "io", - }, + Status::TemporaryFailure(err) | Status::PermanentFailure(err) => { + match &err.details { + Error::DnsError(_) => "dns", + Error::UnexpectedResponse(_) => "unexpected-reply", + Error::ConnectionError(_) => "connection", + Error::TlsError(_) => "tls", + Error::DaneError(_) => "dane", + Error::MtaStsError(_) => "mta-sts", + Error::RateLimited => "rate", + Error::ConcurrencyLimited => "concurrency", + Error::Io(_) => "io", + } + } }) .unwrap_or_default() .into(), @@ -440,33 +442,21 @@ impl Display for Error { Error::UnexpectedResponse(response) => { write!( f, - "Unexpected response from '{}': {}", - response.hostname.entity, response.response + "Unexpected response for {}: {}", + response.command, response.response ) } Error::DnsError(err) => { write!(f, "DNS lookup failed: {err}") } Error::ConnectionError(details) => { - write!( - f, - "Connection to '{}' failed: {}", - details.entity, details.details - ) + write!(f, "Connection failed: {details}",) } Error::TlsError(details) => { - write!( - f, - "TLS error from '{}': {}", - details.entity, details.details - ) + write!(f, "TLS error: {details}",) } Error::DaneError(details) => { - write!( - f, - "DANE failed to authenticate '{}': {}", - details.entity, details.details - ) + write!(f, "DANE authentication failure: {details}",) } Error::MtaStsError(details) => { write!(f, "MTA-STS auth failed: {details}") @@ -490,8 +480,8 @@ impl Display for ArchivedError { ArchivedError::UnexpectedResponse(response) => { write!( f, - "Unexpected response from '{}': {}", - response.hostname.entity, + "Unexpected response for {}: {}", + response.command, response.response.to_string() ) } @@ -499,25 +489,13 @@ impl Display for ArchivedError { write!(f, "DNS lookup failed: {err}") } ArchivedError::ConnectionError(details) => { - write!( - f, - "Connection to '{}' failed: {}", - details.entity, details.details - ) + write!(f, "Connection failed: {details}",) } ArchivedError::TlsError(details) => { - write!( - f, - "TLS error from '{}': {}", - details.entity, details.details - ) + write!(f, "TLS error: {details}",) } ArchivedError::DaneError(details) => { - write!( - f, - "DANE failed to authenticate '{}': {}", - details.entity, details.details - ) + write!(f, "DANE authentication failure: {details}",) } ArchivedError::MtaStsError(details) => { write!(f, "MTA-STS auth failed: {details}") @@ -535,28 +513,27 @@ impl Display for ArchivedError { } } -impl Display for Status<(), Error> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Status::Scheduled => write!(f, "Scheduled"), - Status::Completed(_) => write!(f, "Completed"), - Status::TemporaryFailure(err) => write!(f, "Temporary Failure: {err}"), - Status::PermanentFailure(err) => write!(f, "Permanent Failure: {err}"), - } - } -} - -impl Display for Status, HostResponse> { +impl Display for Status, ErrorDetails> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Status::Scheduled => write!(f, "Scheduled"), Status::Completed(response) => write!(f, "Delivered: {}", response.response), - Status::TemporaryFailure(err) => write!(f, "Temporary Failure: {}", err.response), - Status::PermanentFailure(err) => write!(f, "Permanent Failure: {}", err.response), + Status::TemporaryFailure(err) => { + write!(f, "Temporary Failure for {}: {}", err.entity, err.details) + } + Status::PermanentFailure(err) => { + write!(f, "Permanent Failure for {}: {}", err.entity, err.details) + } } } } +impl Display for ArchivedErrorDetails { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Error for {}: {}", self.entity, self.details) + } +} + pub trait DisplayArchivedResponse { fn to_string(&self) -> String; } diff --git a/crates/smtp/src/queue/quota.rs b/crates/smtp/src/queue/quota.rs index 6eae82a7..428dd86c 100644 --- a/crates/smtp/src/queue/quota.rs +++ b/crates/smtp/src/queue/quota.rs @@ -4,21 +4,22 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::future::Future; - +use super::{QueueEnvelope, QuotaKey, Status}; +use crate::{ + core::throttle::NewKey, + queue::{DomainPart, MessageWrapper}, +}; +use ahash::AHashSet; use common::{Server, config::smtp::queue::QueueQuota, expr::functions::ResolveVariable}; +use std::future::Future; use store::{ ValueKey, write::{BatchBuilder, QueueClass, ValueClass}, }; use trc::QueueEvent; -use crate::core::throttle::NewKey; - -use super::{Message, QueueEnvelope, QuotaKey, Status}; - pub trait HasQueueQuota: Sync + Send { - fn has_quota(&self, message: &mut Message) -> impl Future + Send; + fn has_quota(&self, message: &mut MessageWrapper) -> impl Future + Send; fn check_quota<'x>( &'x self, quota: &'x QueueQuota, @@ -31,7 +32,7 @@ pub trait HasQueueQuota: Sync + Send { } impl HasQueueQuota for Server { - async fn has_quota(&self, message: &mut Message) -> bool { + async fn has_quota(&self, message: &mut MessageWrapper) -> bool { let mut quota_keys = Vec::new(); if !self.core.smtp.queue.quota.sender.is_empty() { @@ -39,8 +40,8 @@ impl HasQueueQuota for Server { if !self .check_quota( quota, - message, - message.size, + &message.message, + message.message.size, 0, &mut quota_keys, message.span_id, @@ -59,38 +60,42 @@ impl HasQueueQuota for Server { } } - for quota in &self.core.smtp.queue.quota.rcpt_domain { - for domain_idx in 0..message.domains.len() { - if !self - .check_quota( - quota, - &QueueEnvelope::new(message, domain_idx), - message.size, - ((domain_idx + 1) << 32) as u64, - &mut quota_keys, - message.span_id, - ) - .await - { - trc::event!( - Queue(QueueEvent::QuotaExceeded), - SpanId = message.span_id, - Id = quota.id.clone(), - Type = "Domain" - ); + if !self.core.smtp.queue.quota.rcpt_domain.is_empty() { + let mut seen_domains = AHashSet::new(); + for quota in &self.core.smtp.queue.quota.rcpt_domain { + for (rcpt_idx, rcpt) in message.message.recipients.iter().enumerate() { + if seen_domains.insert(rcpt.address_lcase.domain_part()) + && !self + .check_quota( + quota, + &QueueEnvelope::new_rcpt(&message.message, rcpt_idx), + message.message.size, + ((rcpt_idx + 1) << 32) as u64, + &mut quota_keys, + message.span_id, + ) + .await + { + trc::event!( + Queue(QueueEvent::QuotaExceeded), + SpanId = message.span_id, + Id = quota.id.clone(), + Type = "Domain" + ); - return false; + return false; + } } } } for quota in &self.core.smtp.queue.quota.rcpt { - for (rcpt_idx, rcpt) in message.recipients.iter().enumerate() { + for rcpt_idx in 0..message.message.recipients.len() { if !self .check_quota( quota, - &QueueEnvelope::new_rcpt(message, rcpt.domain_idx as usize, rcpt_idx), - message.size, + &QueueEnvelope::new_rcpt(&message.message, rcpt_idx), + message.message.size, (rcpt_idx + 1) as u64, &mut quota_keys, message.span_id, @@ -109,7 +114,7 @@ impl HasQueueQuota for Server { } } - message.quota_keys = quota_keys; + message.message.quota_keys = quota_keys; true } @@ -174,32 +179,29 @@ impl HasQueueQuota for Server { } } -impl Message { +impl MessageWrapper { pub fn release_quota(&mut self, batch: &mut BatchBuilder) { - if self.quota_keys.is_empty() { + if self.message.quota_keys.is_empty() { return; } - let mut quota_ids = Vec::with_capacity(self.domains.len() + self.recipients.len()); - for (pos, domain) in self.domains.iter().enumerate() { - if matches!( - &domain.status, - Status::Completed(_) | Status::PermanentFailure(_) - ) { - quota_ids.push(((pos + 1) as u64) << 32); - } - } - for (pos, rcpt) in self.recipients.iter().enumerate() { + let mut quota_ids = Vec::with_capacity(self.message.recipients.len()); + + let mut seen_domains = AHashSet::new(); + for (pos, rcpt) in self.message.recipients.iter().enumerate() { if matches!( &rcpt.status, Status::Completed(_) | Status::PermanentFailure(_) ) { + if seen_domains.insert(rcpt.address_lcase.domain_part()) { + quota_ids.push(((pos + 1) as u64) << 32); + } quota_ids.push((pos + 1) as u64); } } if !quota_ids.is_empty() { let mut quota_keys = Vec::new(); - for quota_key in std::mem::take(&mut self.quota_keys) { + for quota_key in std::mem::take(&mut self.message.quota_keys) { match quota_key { QuotaKey::Count { id, key } if quota_ids.contains(&id) => { batch.add(ValueClass::Queue(QueueClass::QuotaCount(key)), -1); @@ -207,7 +209,7 @@ impl Message { QuotaKey::Size { id, key } if quota_ids.contains(&id) => { batch.add( ValueClass::Queue(QueueClass::QuotaSize(key)), - -(self.size as i64), + -(self.message.size as i64), ); } _ => { @@ -215,7 +217,7 @@ impl Message { } } } - self.quota_keys = quota_keys; + self.message.quota_keys = quota_keys; } } } diff --git a/crates/smtp/src/queue/spool.rs b/crates/smtp/src/queue/spool.rs index 674d4838..08ef99e2 100644 --- a/crates/smtp/src/queue/spool.rs +++ b/crates/smtp/src/queue/spool.rs @@ -4,13 +4,18 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use crate::queue::DomainPart; +use super::{ + ArchivedMessage, ArchivedStatus, Message, MessageSource, QueueEnvelope, QueueId, QueuedMessage, + QuotaKey, Recipient, Schedule, Status, +}; +use crate::queue::{DomainPart, MessageWrapper}; +use common::config::smtp::queue::{QueueExpiry, QueueName}; use common::ipc::QueueEvent; use common::{KV_LOCK_QUEUE_MESSAGE, Server}; - use std::borrow::Cow; use std::future::Future; -use std::time::{Duration, SystemTime}; +use std::net::{IpAddr, Ipv4Addr}; +use std::time::SystemTime; use store::write::key::DeserializeBigEndian; use store::write::{ AlignedBytes, Archive, Archiver, BatchBuilder, BlobOp, QueueClass, ValueClass, now, @@ -19,11 +24,6 @@ use store::{IterateParams, Serialize, SerializeInfallible, U64_LEN, ValueKey}; use trc::ServerEvent; use utils::BlobHash; -use super::{ - ArchivedMessage, ArchivedStatus, Domain, Message, MessageSource, QueueEnvelope, QueueId, - QueuedMessage, QuotaKey, Recipient, Schedule, Status, -}; - pub const LOCK_EXPIRY: u64 = 300; pub const QUEUE_REFRESH: u64 = 300; @@ -34,7 +34,7 @@ pub trait SmtpSpool: Sync + Send { return_path_lcase: impl Into, return_path_domain: impl Into, span_id: u64, - ) -> Message; + ) -> MessageWrapper; fn next_event(&self) -> impl Future> + Send; @@ -42,7 +42,11 @@ pub trait SmtpSpool: Sync + Send { fn unlock_event(&self, queue_id: QueueId) -> impl Future + Send; - fn read_message(&self, id: QueueId) -> impl Future> + Send; + fn read_message( + &self, + id: QueueId, + queue_name: QueueName, + ) -> impl Future> + Send; fn read_message_archive( &self, @@ -57,25 +61,30 @@ impl SmtpSpool for Server { return_path_lcase: impl Into, return_path_domain: impl Into, span_id: u64, - ) -> Message { + ) -> MessageWrapper { let created = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .map_or(0, |d| d.as_secs()); - Message { + + MessageWrapper { queue_id: self.inner.data.queue_id_gen.generate(), + queue_name: QueueName::default(), span_id, - created, - return_path: return_path.into(), - return_path_lcase: return_path_lcase.into(), - return_path_domain: return_path_domain.into(), - recipients: Vec::with_capacity(1), - domains: Vec::with_capacity(1), - flags: 0, - env_id: None, - priority: 0, - size: 0, - blob_hash: Default::default(), - quota_keys: Vec::new(), + message: Message { + created, + return_path: return_path.into(), + return_path_lcase: return_path_lcase.into(), + return_path_domain: return_path_domain.into(), + recipients: Vec::with_capacity(1), + flags: 0, + env_id: None, + priority: 0, + size: 0, + blob_hash: Default::default(), + quota_keys: Vec::new(), + received_from_ip: IpAddr::V4(Ipv4Addr::LOCALHOST), + received_via_port: 0, + }, } } @@ -85,12 +94,14 @@ impl SmtpSpool for Server { store::write::QueueEvent { due: 0, queue_id: 0, + queue_name: [0; 8], }, ))); let to_key = ValueKey::from(ValueClass::Queue(QueueClass::MessageEvent( store::write::QueueEvent { due: now + QUEUE_REFRESH, queue_id: u64::MAX, + queue_name: [u8::MAX; 8], }, ))); @@ -103,8 +114,15 @@ impl SmtpSpool for Server { |key, _| { let due = key.deserialize_be_u64(0)?; let queue_id = key.deserialize_be_u64(U64_LEN)?; + let queue_name = + QueueName::from_bytes(key.get(U64_LEN + U64_LEN..).unwrap_or_default()) + .unwrap_or_default(); - events.push(QueuedMessage { due, queue_id }); + events.push(QueuedMessage { + due, + queue_id, + queue_name, + }); Ok(due <= now) }, @@ -156,12 +174,24 @@ impl SmtpSpool for Server { } } - async fn read_message(&self, id: QueueId) -> Option { - match self.read_message_archive(id).await.and_then(|a| match a { - Some(a) => a.deserialize::().map(Some), - None => Ok(None), - }) { - Ok(Some(message)) => Some(message), + async fn read_message( + &self, + queue_id: QueueId, + queue_name: QueueName, + ) -> Option { + match self + .read_message_archive(queue_id) + .await + .and_then(|a| match a { + Some(a) => a.deserialize::().map(Some), + None => Ok(None), + }) { + Ok(Some(message)) => Some(MessageWrapper { + queue_id, + queue_name, + span_id: 0, + message, + }), Ok(None) => None, Err(err) => { trc::error!( @@ -186,7 +216,7 @@ impl SmtpSpool for Server { } } -impl Message { +impl MessageWrapper { pub async fn queue( mut self, raw_headers: Option<&[u8]>, @@ -204,11 +234,11 @@ impl Message { } else { raw_message.into() }; - self.blob_hash = BlobHash::generate(message.as_ref()); + self.message.blob_hash = BlobHash::generate(message.as_ref()); // Generate id - if self.size == 0 { - self.size = message.len() as u64; + if self.message.size == 0 { + self.message.size = message.len() as u64; } // Reserve and write blob @@ -216,7 +246,7 @@ impl Message { let reserve_until = now() + 120; batch.set( BlobOp::Reserve { - hash: self.blob_hash.clone(), + hash: self.message.blob_hash.clone(), until: reserve_until, }, 0u32.serialize(), @@ -232,7 +262,7 @@ impl Message { } if let Err(err) = server .blob_store() - .put_blob(self.blob_hash.as_slice(), message.as_ref()) + .put_blob(self.message.blob_hash.as_slice(), message.as_ref()) .await { trc::error!( @@ -254,27 +284,31 @@ impl Message { }), SpanId = session_id, QueueId = self.queue_id, - From = if !self.return_path.is_empty() { - trc::Value::String(self.return_path.as_str().into()) + From = if !self.message.return_path.is_empty() { + trc::Value::String(self.message.return_path.as_str().into()) } else { trc::Value::String("<>".into()) }, To = self + .message .recipients .iter() .map(|r| trc::Value::String(r.address_lcase.as_str().into())) .collect::>(), - Size = self.size, - NextRetry = trc::Value::Timestamp(self.next_delivery_event()), - NextDsn = trc::Value::Timestamp(self.next_dsn()), - Expires = trc::Value::Timestamp(self.expires()), + Size = self.message.size, + NextRetry = self + .message + .next_delivery_event(None) + .map(trc::Value::Timestamp), + NextDsn = self.message.next_dsn(None).map(trc::Value::Timestamp), + Expires = self.message.expires(None).map(trc::Value::Timestamp), ); // Write message to queue let mut batch = BatchBuilder::new(); // Reserve quotas - for quota_key in &self.quota_keys { + for quota_key in &self.message.quota_keys { match quota_key { QuotaKey::Count { key, .. } => { batch.add(ValueClass::Queue(QueueClass::QuotaCount(key.clone())), 1); @@ -282,39 +316,44 @@ impl Message { QuotaKey::Size { key, .. } => { batch.add( ValueClass::Queue(QueueClass::QuotaSize(key.clone())), - self.size as i64, + self.message.size as i64, ); } } } - batch - .set( + + for (queue_name, due) in self.message.next_events() { + batch.set( ValueClass::Queue(QueueClass::MessageEvent(store::write::QueueEvent { - due: self.next_event().unwrap_or_default(), + due, queue_id: self.queue_id, + queue_name: queue_name.into_inner(), })), - 0u64.serialize(), - ) + Vec::new(), + ); + } + + batch .clear(BlobOp::Reserve { - hash: self.blob_hash.clone(), + hash: self.message.blob_hash.clone(), until: reserve_until, }) .set( BlobOp::LinkId { - hash: self.blob_hash.clone(), + hash: self.message.blob_hash.clone(), id: self.queue_id, }, vec![], ) .set( BlobOp::Commit { - hash: self.blob_hash.clone(), + hash: self.message.blob_hash.clone(), }, vec![], ) .set( ValueClass::Queue(QueueClass::Message(self.queue_id)), - match Archiver::new(self).serialize() { + match Archiver::new(self.message).serialize() { Ok(data) => data, Err(err) => { trc::error!( @@ -361,92 +400,77 @@ impl Message { &mut self, rcpt: impl Into, rcpt_lcase: impl Into, - rcpt_domain: impl Into, server: &Server, ) { - let rcpt_domain = rcpt_domain.into(); - let domain_idx = - if let Some(idx) = self.domains.iter().position(|d| d.domain == rcpt_domain) { - idx - } else { - let idx = self.domains.len(); - - self.domains.push(Domain { - domain: rcpt_domain, - retry: Schedule::now(), - notify: Schedule::now(), - expires: 0, - status: Status::Scheduled, - }); - - let expires = server - .eval_if( - &server.core.smtp.queue.expire, - &QueueEnvelope::new(self, idx), - self.span_id, - ) - .await - .unwrap_or_else(|| Duration::from_secs(5 * 86400)); - - // Update expiration - let domain = self.domains.last_mut().unwrap(); - domain.notify = Schedule::later(expires + Duration::from_secs(10)); - domain.expires = now() + expires.as_secs(); - - idx - }; - self.recipients.push(Recipient { - domain_idx: domain_idx as u32, + // Resolve queue + let idx = self.message.recipients.len(); + self.message.recipients.push(Recipient { address: rcpt.into(), address_lcase: rcpt_lcase.into(), status: Status::Scheduled, flags: 0, orcpt: None, + retry: Schedule::now(), + notify: Schedule::now(), + expires: QueueExpiry::Count(0), + queue: QueueName::default(), }); + let queue = server.get_queue_or_default( + &server + .eval_if::( + &server.core.smtp.queue.queue, + &QueueEnvelope::new_rcpt(&self.message, idx), + self.span_id, + ) + .await + .unwrap_or_else(|| "default".to_string()), + self.span_id, + ); + + // Update expiration + let now = now(); + let recipient = self.message.recipients.last_mut().unwrap(); + recipient.notify = Schedule::later(queue.notify.first().copied().unwrap_or(86400) + now); + recipient.expires = queue.expiry; + recipient.queue = queue.virtual_queue; } pub async fn add_recipient(&mut self, rcpt: impl Into, server: &Server) { let rcpt = rcpt.into(); let rcpt_lcase = rcpt.to_lowercase(); - let rcpt_domain = rcpt_lcase.domain_part().to_string(); - self.add_recipient_parts(rcpt, rcpt_lcase, rcpt_domain, server) - .await; + self.add_recipient_parts(rcpt, rcpt_lcase, server).await; } - pub async fn save_changes( - mut self, - server: &Server, - prev_event: Option, - next_event: Option, - ) -> bool { - debug_assert!(prev_event.is_some() == next_event.is_some()); - + pub async fn save_changes(mut self, server: &Server, prev_event: Option) -> bool { // Release quota for completed deliveries let mut batch = BatchBuilder::new(); self.release_quota(&mut batch); // Update message queue - if let (Some(prev_event), Some(next_event)) = (prev_event, next_event) { - batch - .clear(ValueClass::Queue(QueueClass::MessageEvent( - store::write::QueueEvent { - due: prev_event, - queue_id: self.queue_id, - }, - ))) - .set( - ValueClass::Queue(QueueClass::MessageEvent(store::write::QueueEvent { - due: next_event, - queue_id: self.queue_id, - })), - 0u64.serialize(), - ); + if let Some(prev_event) = prev_event { + batch.clear(ValueClass::Queue(QueueClass::MessageEvent( + store::write::QueueEvent { + due: prev_event, + queue_id: self.queue_id, + queue_name: self.queue_name.into_inner(), + }, + ))); + } + for (queue_name, due) in self.message.next_events() { + batch.set( + ValueClass::Queue(QueueClass::MessageEvent(store::write::QueueEvent { + due, + queue_id: self.queue_id, + queue_name: queue_name.into_inner(), + })), + Vec::new(), + ); } let span_id = self.span_id; batch.set( ValueClass::Queue(QueueClass::Message(self.queue_id)), - match Archiver::new(self).serialize() { + match Archiver::new(self.message).serialize() { Ok(data) => data, Err(err) => { trc::error!( @@ -471,11 +495,31 @@ impl Message { } } - pub async fn remove(self, server: &Server, prev_event: u64) -> bool { + pub async fn remove(self, server: &Server, prev_event: Option) -> bool { let mut batch = BatchBuilder::new(); + if let Some(prev_event) = prev_event { + batch.clear(ValueClass::Queue(QueueClass::MessageEvent( + store::write::QueueEvent { + due: prev_event, + queue_id: self.queue_id, + queue_name: self.queue_name.into_inner(), + }, + ))); + } else { + for (queue_name, due) in self.message.next_events() { + batch.clear(ValueClass::Queue(QueueClass::MessageEvent( + store::write::QueueEvent { + due, + queue_id: self.queue_id, + queue_name: queue_name.into_inner(), + }, + ))); + } + } + // Release all quotas - for quota_key in self.quota_keys { + for quota_key in self.message.quota_keys { match quota_key { QuotaKey::Count { key, .. } => { batch.add(ValueClass::Queue(QueueClass::QuotaCount(key)), -1); @@ -483,7 +527,7 @@ impl Message { QuotaKey::Size { key, .. } => { batch.add( ValueClass::Queue(QueueClass::QuotaSize(key)), - -(self.size as i64), + -(self.message.size as i64), ); } } @@ -491,15 +535,9 @@ impl Message { batch .clear(BlobOp::LinkId { - hash: self.blob_hash.clone(), + hash: self.message.blob_hash.clone(), id: self.queue_id, }) - .clear(ValueClass::Queue(QueueClass::MessageEvent( - store::write::QueueEvent { - due: prev_event, - queue_id: self.queue_id, - }, - ))) .clear(ValueClass::Queue(QueueClass::Message(self.queue_id))); if let Err(err) = server.store().write(batch.build_all()).await { @@ -515,30 +553,33 @@ impl Message { } pub fn has_domain(&self, domains: &[String]) -> bool { - self.domains.iter().any(|d| domains.contains(&d.domain)) - || self - .return_path - .rsplit_once('@') - .is_some_and(|(_, domain)| domains.iter().any(|dd| dd == domain)) + self.message.recipients.iter().any(|r| { + let domain = r.address_lcase.domain_part(); + domains.iter().any(|dd| dd == domain) + }) || self + .message + .return_path + .rsplit_once('@') + .is_some_and(|(_, domain)| domains.iter().any(|dd| dd == domain)) } } impl ArchivedMessage { pub fn has_domain(&self, domains: &[String]) -> bool { - self.domains - .iter() - .any(|d| domains.iter().any(|dd| dd == d.domain.as_str())) - || self - .return_path - .rsplit_once('@') - .is_some_and(|(_, domain)| domains.iter().any(|dd| dd == domain)) + self.recipients.iter().any(|r| { + let domain = r.address_lcase.domain_part(); + domains.iter().any(|dd| dd == domain) + }) || self + .return_path + .rsplit_once('@') + .is_some_and(|(_, domain)| domains.iter().any(|dd| dd == domain)) } pub fn next_delivery_event(&self) -> u64 { let mut next_delivery = now(); - for (pos, domain) in self - .domains + for (pos, rcpt) in self + .recipients .iter() .filter(|d| { matches!( @@ -548,8 +589,8 @@ impl ArchivedMessage { }) .enumerate() { - if pos == 0 || domain.retry.due < next_delivery { - next_delivery = domain.retry.due.into(); + if pos == 0 || rcpt.retry.due < next_delivery { + next_delivery = rcpt.retry.due.into(); } } diff --git a/crates/smtp/src/queue/throttle.rs b/crates/smtp/src/queue/throttle.rs index 34e83829..48772b39 100644 --- a/crates/smtp/src/queue/throttle.rs +++ b/crates/smtp/src/queue/throttle.rs @@ -4,17 +4,13 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::future::Future; - +use crate::core::throttle::NewKey; use common::{ KV_RATE_LIMIT_SMTP, Server, config::smtp::QueueRateLimiter, expr::functions::ResolveVariable, }; +use std::future::Future; use store::write::now; -use crate::core::throttle::NewKey; - -use super::{Domain, Status}; - pub trait IsAllowed: Sync + Send { fn is_allowed<'x>( &'x self, @@ -69,10 +65,3 @@ impl IsAllowed for Server { Ok(()) } } - -impl Domain { - pub fn set_rate_limiter_error(&mut self, retry_at: u64) { - self.retry.due = retry_at; - self.status = Status::TemporaryFailure(super::Error::RateLimited); - } -} diff --git a/crates/smtp/src/reporting/mod.rs b/crates/smtp/src/reporting/mod.rs index a75fcab9..6e972abf 100644 --- a/crates/smtp/src/reporting/mod.rs +++ b/crates/smtp/src/reporting/mod.rs @@ -25,7 +25,7 @@ use tokio::io::{AsyncRead, AsyncWrite}; use crate::{ core::Session, inbound::DkimSign, - queue::{DomainPart, FROM_REPORT, Message, MessageSource, spool::SmtpSpool}, + queue::{DomainPart, FROM_REPORT, MessageSource, MessageWrapper, spool::SmtpSpool}, }; pub mod analysis; @@ -101,7 +101,7 @@ pub trait SmtpReporting: Sync + Send { fn sign_message( &self, - message: &mut Message, + message: &mut MessageWrapper, config: &IfBlock, bytes: &[u8], ) -> impl Future>> + Send; @@ -149,7 +149,7 @@ impl SmtpReporting for Server { } // Queue message - message.flags |= FROM_REPORT; + message.message.flags |= FROM_REPORT; message .queue( signature.as_deref(), @@ -215,12 +215,12 @@ impl SmtpReporting for Server { async fn sign_message( &self, - message: &mut Message, + message: &mut MessageWrapper, config: &IfBlock, bytes: &[u8], ) -> Option> { let signers = self - .eval_if::, _>(config, message, message.span_id) + .eval_if::, _>(config, &message.message, message.span_id) .await .unwrap_or_default(); if !signers.is_empty() { diff --git a/crates/smtp/src/scripts/event_loop.rs b/crates/smtp/src/scripts/event_loop.rs index eb8342e6..ed49fc98 100644 --- a/crates/smtp/src/scripts/event_loop.rs +++ b/crates/smtp/src/scripts/event_loop.rs @@ -6,7 +6,7 @@ use std::{borrow::Cow, future::Future, sync::Arc, time::Instant}; -use common::{Server, scripts::plugins::PluginContext}; +use common::{Server, config::smtp::queue::QueueExpiry, scripts::plugins::PluginContext}; use mail_auth::common::headers::HeaderWriter; use mail_parser::{Encoding, Message, MessagePart, PartType}; @@ -207,7 +207,7 @@ impl RunScript for Server { Notify::Default => (), } if flags > 0 { - for rcpt in &mut message.recipients { + for rcpt in &mut message.message.recipients { rcpt.flags |= flags; } } @@ -220,16 +220,16 @@ impl RunScript for Server { trace, } => { if trace { - message.flags |= MAIL_BY_TRACE; + message.message.flags |= MAIL_BY_TRACE; } match mode { ByMode::Notify => { - for domain in &mut message.domains { + for domain in &mut message.message.recipients { domain.notify.due += rlimit; } } ByMode::Return => { - for domain in &mut message.domains { + for domain in &mut message.message.recipients { domain.notify.due += rlimit; } } @@ -242,17 +242,21 @@ impl RunScript for Server { trace, } => { if trace { - message.flags |= MAIL_BY_TRACE; + message.message.flags |= MAIL_BY_TRACE; } match mode { ByMode::Notify => { - for domain in &mut message.domains { + for domain in &mut message.message.recipients { domain.notify.due = alimit as u64; } } ByMode::Return => { - for domain in &mut message.domains { - domain.expires = alimit as u64; + let expires = + (alimit as u64).saturating_sub(message.message.created); + if expires > 0 { + for domain in &mut message.message.recipients { + domain.expires = QueueExpiry::Duration(expires); + } } } ByMode::Default => (), @@ -264,10 +268,10 @@ impl RunScript for Server { // Set ret match return_of_content { Ret::Full => { - message.flags |= MAIL_RET_FULL; + message.message.flags |= MAIL_RET_FULL; } Ret::Hdrs => { - message.flags |= MAIL_RET_HDRS; + message.message.flags |= MAIL_RET_HDRS; } Ret::Default => (), } @@ -327,8 +331,9 @@ impl RunScript for Server { Sieve(SieveEvent::QuotaExceeded), SpanId = session_id, Id = script_id.clone(), - From = message.return_path_lcase, + From = message.message.return_path_lcase, To = message + .message .recipients .into_iter() .map(|r| trc::Value::from(r.address_lcase)) diff --git a/crates/store/src/backend/http/config.rs b/crates/store/src/backend/http/config.rs index 3ccb8678..b93efb12 100644 --- a/crates/store/src/backend/http/config.rs +++ b/crates/store/src/backend/http/config.rs @@ -21,11 +21,7 @@ use super::{HttpStore, HttpStoreConfig, HttpStoreFormat}; impl Stores { pub fn parse_http_stores(&mut self, config: &mut Config, is_reload: bool) { // Parse remote lists - for id in config - .sub_keys("http-lookup", ".url") - .map(|k| k.to_string()) - .collect::>() - { + for id in config.sub_keys("http-lookup", ".url") { let id_ = id.as_str(); if !config .property_or_default(("http-lookup", id_, "enable"), "true") diff --git a/crates/store/src/config.rs b/crates/store/src/config.rs index d3742652..9f4d63e2 100644 --- a/crates/store/src/config.rs +++ b/crates/store/src/config.rs @@ -35,12 +35,8 @@ impl Stores { let is_reload = !self.stores.is_empty(); #[cfg(feature = "enterprise")] let mut composite_stores = Vec::new(); - let store_ids = config - .sub_keys("store", ".type") - .map(|id| id.to_string()) - .collect::>(); - for store_id in store_ids { + for store_id in config.sub_keys("store", ".type") { let id = store_id.as_str(); // Parse store #[cfg(feature = "test_mode")] diff --git a/crates/store/src/write/key.rs b/crates/store/src/write/key.rs index 9890d09d..5ebda851 100644 --- a/crates/store/src/write/key.rs +++ b/crates/store/src/write/key.rs @@ -374,9 +374,10 @@ impl ValueClass { }, ValueClass::Queue(queue) => match queue { QueueClass::Message(queue_id) => serializer.write(*queue_id), - QueueClass::MessageEvent(event) => { - serializer.write(event.due).write(event.queue_id) - } + QueueClass::MessageEvent(event) => serializer + .write(event.due) + .write(event.queue_id) + .write(event.queue_name.as_slice()), QueueClass::DmarcReportHeader(event) => serializer .write(0u8) .write(event.due) @@ -607,7 +608,7 @@ impl ValueClass { }, ValueClass::Queue(q) => match q { QueueClass::Message(_) => U64_LEN, - QueueClass::MessageEvent(_) => U64_LEN * 2, + QueueClass::MessageEvent(_) => U64_LEN * 3, QueueClass::DmarcReportEvent(event) | QueueClass::TlsReportEvent(event) => { event.domain.len() + U64_LEN * 3 } diff --git a/crates/store/src/write/mod.rs b/crates/store/src/write/mod.rs index 55643608..1bab5afa 100644 --- a/crates/store/src/write/mod.rs +++ b/crates/store/src/write/mod.rs @@ -277,6 +277,7 @@ pub enum TelemetryClass { pub struct QueueEvent { pub due: u64, pub queue_id: u64, + pub queue_name: [u8; 8], } #[derive(Debug, PartialEq, Clone, Eq, Hash)] diff --git a/crates/trc/src/event/description.rs b/crates/trc/src/event/description.rs index c56a1bbe..ce39841d 100644 --- a/crates/trc/src/event/description.rs +++ b/crates/trc/src/event/description.rs @@ -396,7 +396,7 @@ impl SmtpEvent { pub fn description(&self) -> &'static str { match self { SmtpEvent::Error => "SMTP error occurred", - SmtpEvent::RemoteIdNotFound => "Remote host ID not found", + SmtpEvent::IdNotFound => "Remote host ID not found", SmtpEvent::ConcurrencyLimitExceeded => "Concurrency limit exceeded", SmtpEvent::TransferLimitExceeded => "Transfer limit exceeded", SmtpEvent::RateLimitExceeded => "Rate limit exceeded", @@ -483,9 +483,7 @@ impl SmtpEvent { pub fn explain(&self) -> &'static str { match self { SmtpEvent::Error => "An error occurred during an SMTP command", - SmtpEvent::RemoteIdNotFound => { - "The remote server ID was not found in the configuration" - } + SmtpEvent::IdNotFound => "The remote server ID was not found in the configuration", SmtpEvent::ConcurrencyLimitExceeded => "The concurrency limit was exceeded", SmtpEvent::TransferLimitExceeded => { "The remote host transferred more data than allowed" diff --git a/crates/trc/src/event/level.rs b/crates/trc/src/event/level.rs index 3e813ee1..59576005 100644 --- a/crates/trc/src/event/level.rs +++ b/crates/trc/src/event/level.rs @@ -162,7 +162,7 @@ impl EventType { | SmtpEvent::UnsupportedParameter | SmtpEvent::SyntaxError | SmtpEvent::Error => Level::Debug, - SmtpEvent::MissingLocalHostname | SmtpEvent::RemoteIdNotFound => Level::Warn, + SmtpEvent::MissingLocalHostname | SmtpEvent::IdNotFound => Level::Warn, SmtpEvent::ConcurrencyLimitExceeded | SmtpEvent::TransferLimitExceeded | SmtpEvent::RateLimitExceeded diff --git a/crates/trc/src/lib.rs b/crates/trc/src/lib.rs index 9fb43b2d..4be4fb5d 100644 --- a/crates/trc/src/lib.rs +++ b/crates/trc/src/lib.rs @@ -139,6 +139,7 @@ pub enum Key { ValidTo, Value, Version, + QueueName, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -351,7 +352,7 @@ pub enum SmtpEvent { ConnectionStart, ConnectionEnd, Error, - RemoteIdNotFound, + IdNotFound, ConcurrencyLimitExceeded, TransferLimitExceeded, RateLimitExceeded, diff --git a/crates/trc/src/serializers/binary.rs b/crates/trc/src/serializers/binary.rs index 2be0bd63..1a9d8fdd 100644 --- a/crates/trc/src/serializers/binary.rs +++ b/crates/trc/src/serializers/binary.rs @@ -766,7 +766,7 @@ impl EventType { EventType::Smtp(SmtpEvent::RcptToMissing) => 466, EventType::Smtp(SmtpEvent::RcptToRewritten) => 467, EventType::Smtp(SmtpEvent::RelayNotAllowed) => 468, - EventType::Smtp(SmtpEvent::RemoteIdNotFound) => 469, + EventType::Smtp(SmtpEvent::IdNotFound) => 469, EventType::Smtp(SmtpEvent::RequestTooLarge) => 470, EventType::Smtp(SmtpEvent::RequireTlsDisabled) => 471, EventType::Smtp(SmtpEvent::Rset) => 472, @@ -1393,7 +1393,7 @@ impl EventType { 466 => Some(EventType::Smtp(SmtpEvent::RcptToMissing)), 467 => Some(EventType::Smtp(SmtpEvent::RcptToRewritten)), 468 => Some(EventType::Smtp(SmtpEvent::RelayNotAllowed)), - 469 => Some(EventType::Smtp(SmtpEvent::RemoteIdNotFound)), + 469 => Some(EventType::Smtp(SmtpEvent::IdNotFound)), 470 => Some(EventType::Smtp(SmtpEvent::RequestTooLarge)), 471 => Some(EventType::Smtp(SmtpEvent::RequireTlsDisabled)), 472 => Some(EventType::Smtp(SmtpEvent::Rset)), @@ -1599,6 +1599,7 @@ impl Key { Key::ValidTo => 62, Key::Value => 63, Key::Version => 64, + Key::QueueName => 65, } } @@ -1669,6 +1670,7 @@ impl Key { 62 => Some(Key::ValidTo), 63 => Some(Key::Value), 64 => Some(Key::Version), + 65 => Some(Key::QueueName), _ => None, } } diff --git a/crates/utils/src/config/parser.rs b/crates/utils/src/config/parser.rs index 2a01fca7..e5348b43 100644 --- a/crates/utils/src/config/parser.rs +++ b/crates/utils/src/config/parser.rs @@ -582,13 +582,10 @@ mod tests { ); assert_eq!( - config.sub_keys("sets.strings", "").collect::>(), + config.sub_keys("sets.strings", ""), vec!["green", "red", "yellow"] ); - assert_eq!( - config.sub_keys("sets", ".red").collect::>(), - vec!["string", "strings"] - ); + assert_eq!(config.sub_keys("sets", ".red"), vec!["string", "strings"]); } } diff --git a/crates/utils/src/config/utils.rs b/crates/utils/src/config/utils.rs index 5987f9d5..e7a1a0ce 100644 --- a/crates/utils/src/config/utils.rs +++ b/crates/utils/src/config/utils.rs @@ -104,30 +104,51 @@ impl Config { } } - pub fn sub_keys<'x, 'y: 'x>( - &'y self, - prefix: impl AsKey, - suffix: &'y str, - ) -> impl Iterator + 'x { + pub fn sub_keys(&self, prefix: impl AsKey, suffix: &str) -> Vec { let mut last_key = ""; let prefix = prefix.as_prefix(); - self.keys.keys().filter_map(move |key| { - let key = key.strip_prefix(&prefix)?; - let key = if !suffix.is_empty() { - key.strip_suffix(suffix)? - } else if let Some((key, _)) = key.split_once('.') { - key - } else { - key - }; - if last_key != key { - last_key = key; - Some(key) - } else { - None - } - }) + self.keys + .keys() + .filter_map(move |key| { + let key = key.strip_prefix(&prefix)?; + let key = if !suffix.is_empty() { + key.strip_suffix(suffix)? + } else if let Some((key, _)) = key.split_once('.') { + key + } else { + key + }; + if last_key != key { + last_key = key; + Some(key.to_string()) + } else { + None + } + }) + .collect() + } + + pub fn sub_keys_with_suffixes(&self, prefix: impl AsKey, suffixes: &[&str]) -> Vec { + let mut last_key = ""; + let prefix = prefix.as_prefix(); + + self.keys + .keys() + .filter_map(move |key| { + let key = key.strip_prefix(&prefix)?; + let key = suffixes + .iter() + .filter_map(|suffix| key.strip_suffix(suffix)) + .next()?; + if last_key != key { + last_key = key; + Some(key.to_string()) + } else { + None + } + }) + .collect() } pub fn prefix<'x, 'y: 'x>(&'y self, prefix: impl AsKey) -> impl Iterator + 'x { @@ -628,16 +649,6 @@ pub trait AsKey: Clone { fn as_prefix(&self) -> String; } -impl AsKey for &str { - fn as_key(&self) -> String { - self.to_string() - } - - fn as_prefix(&self) -> String { - format!("{self}.") - } -} - impl AsKey for String { fn as_key(&self) -> String { self.to_string() @@ -658,53 +669,111 @@ impl AsKey for &String { } } -impl AsKey for (&str, &str) { +impl AsKey for &str { fn as_key(&self) -> String { - format!("{}.{}", self.0, self.1) + self.to_string() } fn as_prefix(&self) -> String { - format!("{}.{}.", self.0, self.1) + format!("{self}.") } } -impl AsKey for (&str, &String) { +impl AsKey for (A, B) +where + A: AsRef + Clone, + B: AsRef + Clone, +{ fn as_key(&self) -> String { - format!("{}.{}", self.0, self.1) + format!("{}.{}", self.0.as_ref(), self.1.as_ref(),) } fn as_prefix(&self) -> String { - format!("{}.{}.", self.0, self.1) + format!("{}.{}.", self.0.as_ref(), self.1.as_ref(),) } } -impl AsKey for (&String, &str) { +impl AsKey for (A, B, C) +where + A: AsRef + Clone, + B: AsRef + Clone, + C: AsRef + Clone, +{ fn as_key(&self) -> String { - format!("{}.{}", self.0, self.1) + format!( + "{}.{}.{}", + self.0.as_ref(), + self.1.as_ref(), + self.2.as_ref() + ) } fn as_prefix(&self) -> String { - format!("{}.{}.", self.0, self.1) + format!( + "{}.{}.{}.", + self.0.as_ref(), + self.1.as_ref(), + self.2.as_ref() + ) } } -impl AsKey for (&str, &str, &str) { +impl AsKey for (A, B, C, D) +where + A: AsRef + Clone, + B: AsRef + Clone, + C: AsRef + Clone, + D: AsRef + Clone, +{ fn as_key(&self) -> String { - format!("{}.{}.{}", self.0, self.1, self.2) + format!( + "{}.{}.{}.{}", + self.0.as_ref(), + self.1.as_ref(), + self.2.as_ref(), + self.3.as_ref() + ) } fn as_prefix(&self) -> String { - format!("{}.{}.{}.", self.0, self.1, self.2) + format!( + "{}.{}.{}.{}.", + self.0.as_ref(), + self.1.as_ref(), + self.2.as_ref(), + self.3.as_ref() + ) } } -impl AsKey for (&str, &str, &str, &str) { +impl AsKey for (A, B, C, D, E) +where + A: AsRef + Clone, + B: AsRef + Clone, + C: AsRef + Clone, + D: AsRef + Clone, + E: AsRef + Clone, +{ fn as_key(&self) -> String { - format!("{}.{}.{}.{}", self.0, self.1, self.2, self.3) + format!( + "{}.{}.{}.{}.{}", + self.0.as_ref(), + self.1.as_ref(), + self.2.as_ref(), + self.3.as_ref(), + self.4.as_ref() + ) } fn as_prefix(&self) -> String { - format!("{}.{}.{}.{}.", self.0, self.1, self.2, self.3) + format!( + "{}.{}.{}.{}.{}.", + self.0.as_ref(), + self.1.as_ref(), + self.2.as_ref(), + self.3.as_ref(), + self.4.as_ref() + ) } } @@ -745,16 +814,10 @@ ip = "a:b::1:1" let mut config = Config::default(); config.parse(toml).unwrap(); + assert_eq!(config.sub_keys("queues", ""), ["a", "x", "z"]); + assert_eq!(config.sub_keys("servers", ""), ["my relay", "submissions"]); assert_eq!( - config.sub_keys("queues", "").collect::>(), - ["a", "x", "z"] - ); - assert_eq!( - config.sub_keys("servers", "").collect::>(), - ["my relay", "submissions"] - ); - assert_eq!( - config.sub_keys("queues.z.retry", "").collect::>(), + config.sub_keys("queues.z.retry", ""), ["0000", "0001", "0002", "0003", "0004"] ); assert_eq!( diff --git a/tests/src/cluster/mod.rs b/tests/src/cluster/mod.rs index 249e712e..2034e96a 100644 --- a/tests/src/cluster/mod.rs +++ b/tests/src/cluster/mod.rs @@ -176,7 +176,7 @@ async fn build_server(mut config: Config, stores: Stores) -> (Server, watch::Sen .enable_enterprise(); let data = Data::parse(&mut config); let cache = Caches::parse(&mut config); - let (ipc, mut ipc_rxs) = build_ipc(&mut config, true); + let (ipc, mut ipc_rxs) = build_ipc(true); let inner = Arc::new(Inner { shared_core: core.into_shared(), data, @@ -290,10 +290,9 @@ directory = "'{STORE}'" [resolver] type = "system" -[queue.outbound] -next-hop = [ { if = "rcpt_domain == 'example.com'", then = "'local'" }, - { if = "contains(['remote.org', 'foobar.com', 'test.com', 'other_domain.com'], rcpt_domain)", then = "'mock-smtp'" }, - { else = false } ] +[queue.strategy] +gateway = [ { if = "rcpt_domain == 'example.com'", then = "'local'" }, + { else = "'mx'" } ] [store."foundationdb"] type = "foundationdb" diff --git a/tests/src/imap/mod.rs b/tests/src/imap/mod.rs index 232e26d3..c9065d40 100644 --- a/tests/src/imap/mod.rs +++ b/tests/src/imap/mod.rs @@ -170,7 +170,7 @@ async fn init_imap_tests(store_id: &str, delete_if_exists: bool) -> IMAPTest { let cache = Caches::parse(&mut config); let store = core.storage.data.clone(); - let (ipc, mut ipc_rxs) = build_ipc(&mut config, false); + let (ipc, mut ipc_rxs) = build_ipc(false); let inner = Arc::new(Inner { shared_core: core.into_shared(), data, @@ -678,17 +678,18 @@ hash = 64 [resolver] type = "system" -[queue.outbound] -next-hop = [ { if = "rcpt_domain == 'example.com'", then = "'local'" }, +[queue.strategy] +gateway = [ { if = "rcpt_domain == 'example.com'", then = "'local'" }, { if = "contains(['remote.org', 'foobar.com', 'test.com', 'other_domain.com'], rcpt_domain)", then = "'mock-smtp'" }, - { else = false } ] + { else = "'mx'" } ] -[remote."mock-smtp"] +[queue.gateway."mock-smtp"] +type = "relay" address = "localhost" port = 9999 protocol = "smtp" -[remote."mock-smtp".tls] +[queue.gateway."mock-smtp".tls] enable = false allow-invalid-certs = true diff --git a/tests/src/jmap/mod.rs b/tests/src/jmap/mod.rs index f9dd9614..899f79cb 100644 --- a/tests/src/jmap/mod.rs +++ b/tests/src/jmap/mod.rs @@ -303,7 +303,7 @@ async fn init_jmap_tests(store_id: &str, delete_if_exists: bool) -> JMAPTest { let data = Data::parse(&mut config); let cache = Caches::parse(&mut config); let store = core.storage.data.clone(); - let (ipc, mut ipc_rxs) = build_ipc(&mut config, false); + let (ipc, mut ipc_rxs) = build_ipc(false); let inner = Arc::new(Inner { shared_core: core.into_shared(), data, @@ -799,18 +799,19 @@ hash = 64 [resolver] type = "system" -[queue.outbound] -next-hop = [ { if = "rcpt_domain == 'example.com'", then = "'local'" }, +[queue.strategy] +gateway = [ { if = "rcpt_domain == 'example.com'", then = "'local'" }, { if = "contains(['remote.org', 'foobar.com', 'test.com', 'other_domain.com'], rcpt_domain)", then = "'mock-smtp'" }, - { else = false } ] + { else = "'mx'" } ] -[remote."mock-smtp"] +[queue.gateway."mock-smtp"] +type = "relay" address = "localhost" port = 9999 protocol = "smtp" -[remote."mock-smtp".tls] -implicit = false +[queue.gateway."mock-smtp".tls] +enable = false allow-invalid-certs = true [session.extensions] diff --git a/tests/src/jmap/quota.rs b/tests/src/jmap/quota.rs index 74d6f3bc..98b30297 100644 --- a/tests/src/jmap/quota.rs +++ b/tests/src/jmap/quota.rs @@ -11,6 +11,7 @@ use crate::{ mailbox::destroy_all_mailboxes, test_account_login, }, }; +use common::config::smtp::queue::QueueName; use email::mailbox::INBOX_ID; use jmap::blob::upload::DISABLE_UPLOAD_QUOTA; use jmap_client::{ @@ -360,10 +361,10 @@ pub async fn test(params: &mut JMAPTest) { } for event in server.next_event().await { server - .read_message(event.queue_id) + .read_message(event.queue_id, QueueName::default()) .await .unwrap() - .remove(&server, event.due) + .remove(&server, event.due.into()) .await; } assert_is_empty(server).await; diff --git a/tests/src/smtp/config.rs b/tests/src/smtp/config.rs index ec5f291f..2198fd97 100644 --- a/tests/src/smtp/config.rs +++ b/tests/src/smtp/config.rs @@ -474,11 +474,7 @@ async fn eval_dynvalue() { ]); let core = Server::default(); - for test_name in config - .sub_keys("eval", "") - .map(|s| s.to_string()) - .collect::>() - { + for test_name in config.sub_keys("eval", "") { //println!("============= Testing {:?} ==================", key); let if_block = IfBlock::try_parse( &mut config, diff --git a/tests/src/smtp/inbound/dmarc.rs b/tests/src/smtp/inbound/dmarc.rs index d50dc9a9..9e691b3c 100644 --- a/tests/src/smtp/inbound/dmarc.rs +++ b/tests/src/smtp/inbound/dmarc.rs @@ -175,7 +175,7 @@ async fn dmarc() { // Expect SPF auth failure report let message = qr.expect_message().await; assert_eq!( - message.recipients.last().unwrap().address, + message.message.recipients.last().unwrap().address, "spf-failures@example.com" ); message @@ -206,7 +206,7 @@ async fn dmarc() { // Expect DKIM auth failure report let message = qr.expect_message().await; assert_eq!( - message.recipients.last().unwrap().address, + message.message.recipients.last().unwrap().address, "dkim-failures@example.com" ); message @@ -257,7 +257,7 @@ async fn dmarc() { // Expect DMARC auth failure report let message = qr.expect_message().await; assert_eq!( - message.recipients.last().unwrap().address, + message.message.recipients.last().unwrap().address, "dmarc-failures@example.com" ); message diff --git a/tests/src/smtp/inbound/mod.rs b/tests/src/smtp/inbound/mod.rs index 8cdeecff..31ca925f 100644 --- a/tests/src/smtp/inbound/mod.rs +++ b/tests/src/smtp/inbound/mod.rs @@ -18,7 +18,7 @@ use store::{ }; use tokio::sync::mpsc::error::TryRecvError; -use smtp::queue::{Message, QueueId, QueuedMessage}; +use smtp::queue::{Message, MessageWrapper, QueueId, QueuedMessage}; use super::{QueueReceiver, ReportReceiver}; @@ -118,17 +118,17 @@ impl QueueReceiver { } } - pub async fn expect_message(&mut self) -> Message { + pub async fn expect_message(&mut self) -> MessageWrapper { self.read_event().await.assert_refresh(); self.last_queued_message().await } - pub async fn consume_message(&mut self, server: &Server) -> Message { + pub async fn consume_message(&mut self, server: &Server) -> MessageWrapper { self.read_event().await.assert_refresh(); let message = self.last_queued_message().await; message .clone() - .remove(server, self.last_queued_due().await) + .remove(server, self.last_queued_due().await.into()) .await; message } @@ -143,6 +143,7 @@ impl QueueReceiver { QueuedMessage { due: self.message_due(queue_id).await, queue_id, + queue_name: Default::default(), } } @@ -153,12 +154,14 @@ impl QueueReceiver { store::write::QueueEvent { due: 0, queue_id: 0, + queue_name: [0; 8], }, ))); let to_key = ValueKey::from(ValueClass::Queue(QueueClass::MessageEvent( store::write::QueueEvent { due: u64::MAX, queue_id: u64::MAX, + queue_name: [u8::MAX; 8], }, ))); @@ -169,6 +172,9 @@ impl QueueReceiver { events.push(store::write::QueueEvent { due: key.deserialize_be_u64(0)?, queue_id: key.deserialize_be_u64(U64_LEN)?, + queue_name: key[U64_LEN + 1..U64_LEN + 9] + .try_into() + .expect("Queue name must be 8 bytes"), }); Ok(true) }, @@ -179,7 +185,7 @@ impl QueueReceiver { events } - pub async fn read_queued_messages(&self) -> Vec { + pub async fn read_queued_messages(&self) -> Vec { let from_key = ValueKey::from(ValueClass::Queue(QueueClass::Message(0))); let to_key = ValueKey::from(ValueClass::Queue(QueueClass::Message(u64::MAX))); let mut messages = Vec::new(); @@ -188,10 +194,13 @@ impl QueueReceiver { .iterate( IterateParams::new(from_key, to_key).descending(), |key, value| { - let value = as Deserialize>::deserialize(value)? - .deserialize::()?; - assert_eq!(key.deserialize_be_u64(0)?, value.queue_id); - messages.push(value); + messages.push(MessageWrapper { + queue_id: key.deserialize_be_u64(0)?, + queue_name: Default::default(), + span_id: 0, + message: as Deserialize>::deserialize(value)? + .deserialize::()?, + }); Ok(true) }, ) @@ -241,7 +250,7 @@ impl QueueReceiver { events } - pub async fn last_queued_message(&self) -> Message { + pub async fn last_queued_message(&self) -> MessageWrapper { self.read_queued_messages() .await .into_iter() @@ -271,7 +280,7 @@ impl QueueReceiver { pub async fn clear_queue(&self, server: &Server) { for message in self.read_queued_messages().await { let due = self.message_due(message.queue_id).await; - message.remove(server, due).await; + message.remove(server, due.into()).await; } } } @@ -367,11 +376,11 @@ pub trait TestMessage { async fn read_lines(&self, core: &QueueReceiver) -> Vec; } -impl TestMessage for Message { +impl TestMessage for MessageWrapper { async fn read_message(&self, core: &QueueReceiver) -> String { String::from_utf8( core.blob_store - .get_blob(self.blob_hash.as_slice(), 0..usize::MAX) + .get_blob(self.message.blob_hash.as_slice(), 0..usize::MAX) .await .unwrap() .expect("Message blob not found"), diff --git a/tests/src/smtp/inbound/scripts.rs b/tests/src/smtp/inbound/scripts.rs index d4f48249..2d1bf13a 100644 --- a/tests/src/smtp/inbound/scripts.rs +++ b/tests/src/smtp/inbound/scripts.rs @@ -243,14 +243,14 @@ async fn sieve_scripts() { assert_eq!(messages.len(), 2); let mut messages = messages.into_iter(); let notification = messages.next().unwrap(); - assert_eq!(notification.return_path, ""); - assert_eq!(notification.recipients.len(), 2); + assert_eq!(notification.message.return_path, ""); + assert_eq!(notification.message.recipients.len(), 2); assert_eq!( - notification.recipients.first().unwrap().address, + notification.message.recipients.first().unwrap().address, "john@example.net" ); assert_eq!( - notification.recipients.last().unwrap().address, + notification.message.recipients.last().unwrap().address, "jane@example.org" ); notification @@ -323,10 +323,10 @@ async fn sieve_scripts() { .await; let redirect = qr.expect_message().await; - assert_eq!(redirect.return_path, ""); - assert_eq!(redirect.recipients.len(), 1); + assert_eq!(redirect.message.return_path, ""); + assert_eq!(redirect.message.recipients.len(), 1); assert_eq!( - redirect.recipients.first().unwrap().address, + redirect.message.recipients.first().unwrap().address, "redirect@here.email" ); redirect @@ -351,10 +351,10 @@ async fn sieve_scripts() { .await; let redirect = qr.expect_message().await; - assert_eq!(redirect.return_path, ""); - assert_eq!(redirect.recipients.len(), 1); + assert_eq!(redirect.message.return_path, ""); + assert_eq!(redirect.message.recipients.len(), 1); assert_eq!( - redirect.recipients.first().unwrap().address, + redirect.message.recipients.first().unwrap().address, "redirect@somewhere.email" ); redirect diff --git a/tests/src/smtp/lookup/utils.rs b/tests/src/smtp/lookup/utils.rs index 2d56d424..c20a574f 100644 --- a/tests/src/smtp/lookup/utils.rs +++ b/tests/src/smtp/lookup/utils.rs @@ -4,48 +4,75 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::time::{Duration, Instant}; +use std::net::IpAddr; +use crate::smtp::TestSMTP; +use ::smtp::outbound::NextHop; use common::{ Core, config::smtp::{ + queue::MxConfig, report::AggregateFrequency, resolver::{Mode, MxPattern, Policy}, }, }; -use mail_auth::MX; - -use ::smtp::outbound::NextHop; +use mail_auth::{IpLookupStrategy, MX}; use mail_parser::DateTime; use smtp::{ outbound::{ - lookup::{DnsLookup, ToNextHop}, + lookup::{SourceIp, ToNextHop}, mta_sts::parse::ParsePolicy, }, - queue::RecipientDomain, reporting::AggregateTimestamp, }; use utils::config::Config; -use crate::smtp::{DnsCache, TestSMTP}; +const CONFIG: &str = r#" +[queue.connection.test.timeout] +connect = "10s" -const CONFIG_V4: &str = r#" -[queue.outbound.source-ip] -v4 = "['10.0.0.1', '10.0.0.2', '10.0.0.3', '10.0.0.4']" -v6 = "['a:b::1', 'a:b::2', 'a:b::3', 'a:b::4']" +[[queue.connection.test.source-ip]] +address = "10.0.0.1" +ehlo-hostname = "test1.example.com" -[queue.outbound] -ip-strategy = "ipv4_then_ipv6" +[[queue.connection.test.source-ip]] +address = "10.0.0.2" +ehlo-hostname = "test2.example.com" -"#; +[[queue.connection.test.source-ip]] +address = "10.0.0.3" +ehlo-hostname = "test3.example.com" -const CONFIG_V6: &str = r#" -[queue.outbound.source-ip] -v4 = "['10.0.0.1', '10.0.0.2', '10.0.0.3', '10.0.0.4']" -v6 = "['a:b::1', 'a:b::2', 'a:b::3', 'a:b::4']" +[[queue.connection.test.source-ip]] +address = "10.0.0.4" +ehlo-hostname = "test4.example.com" -[queue.outbound] -ip-strategy = "ipv6_then_ipv4" +[[queue.connection.test.source-ip]] +address = "a:b::1" +ehlo-hostname = "test5.example.com" + +[[queue.connection.test.source-ip]] +address = "a:b::2" +ehlo-hostname = "test6.example.com" + +[[queue.connection.test.source-ip]] +address = "a:b::3" +ehlo-hostname = "test7.example.com" + +[[queue.connection.test.source-ip]] +address = "a:b::4" +ehlo-hostname = "test8.example.com" + +[queue.connection.test] +ehlo-hostname = "test.example.com" + +[queue.test-v4.type] +type = "mx" +ip-lookup-strategy = "ipv4_then_ipv6" + +[queue.test-v6.type] +type = "mx" +ip-lookup-strategy = "ipv6_then_ipv4" "#; @@ -54,98 +81,62 @@ async fn lookup_ip() { // Enable logging crate::enable_logging(); - let ipv6 = [ + let ipv6: [IpAddr; 4] = [ "a:b::1".parse().unwrap(), "a:b::2".parse().unwrap(), "a:b::3".parse().unwrap(), "a:b::4".parse().unwrap(), ]; - let ipv4 = [ + let ipv4: [IpAddr; 4] = [ "10.0.0.1".parse().unwrap(), "10.0.0.2".parse().unwrap(), "10.0.0.3".parse().unwrap(), "10.0.0.4".parse().unwrap(), ]; - let mut config = Config::new(CONFIG_V4).unwrap(); + let ipv4_hosts = [ + "test1.example.com".to_string(), + "test2.example.com".to_string(), + "test3.example.com".to_string(), + "test4.example.com".to_string(), + ]; + let ipv6_hosts = [ + "test5.example.com".to_string(), + "test6.example.com".to_string(), + "test7.example.com".to_string(), + "test8.example.com".to_string(), + ]; + + let mut config = Config::new(CONFIG).unwrap(); let test = TestSMTP::from_core(Core::parse(&mut config, Default::default(), Default::default()).await); - test.server.ipv4_add( - "mx.foobar.org", - vec![ - "172.168.0.100".parse().unwrap(), - "172.168.0.101".parse().unwrap(), - ], - Instant::now() + Duration::from_secs(10), - ); - test.server.ipv6_add( - "mx.foobar.org", - vec!["e:f::a".parse().unwrap(), "e:f::b".parse().unwrap()], - Instant::now() + Duration::from_secs(10), - ); - // Ipv4 strategy - let resolve_result = test + let conn = test .server - .resolve_host( - &NextHop::MX { - host: "mx.foobar.org", - is_implicit: false, - }, - &RecipientDomain::new("envelope"), - 2, - 0, - ) - .await + .core + .smtp + .queue + .connection_strategy + .get("test") .unwrap(); - assert!(ipv4.contains(&match resolve_result.source_ipv4.unwrap() { - std::net::IpAddr::V4(v4) => v4, - _ => unreachable!(), - })); - assert!( - resolve_result - .remote_ips - .contains(&"172.168.0.100".parse().unwrap()) - ); - // Ipv6 strategy - let mut config = Config::new(CONFIG_V6).unwrap(); - let test = - TestSMTP::from_core(Core::parse(&mut config, Default::default(), Default::default()).await); - test.server.ipv4_add( - "mx.foobar.org", - vec![ - "172.168.0.100".parse().unwrap(), - "172.168.0.101".parse().unwrap(), - ], - Instant::now() + Duration::from_secs(10), - ); - test.server.ipv6_add( - "mx.foobar.org", - vec!["e:f::a".parse().unwrap(), "e:f::b".parse().unwrap()], - Instant::now() + Duration::from_secs(10), - ); - let resolve_result = test - .server - .resolve_host( - &NextHop::MX { - host: "mx.foobar.org", - is_implicit: false, - }, - &RecipientDomain::new("envelope"), - 2, - 0, - ) - .await - .unwrap(); - assert!(ipv6.contains(&match resolve_result.source_ipv6.unwrap() { - std::net::IpAddr::V6(v6) => v6, - _ => unreachable!(), - })); - assert!( - resolve_result - .remote_ips - .contains(&"e:f::a".parse().unwrap()) - ); + assert_eq!(conn.ehlo_hostname.as_ref().unwrap(), "test.example.com"); + + for is_ipv4 in [true, false] { + for _ in 0..10 { + let ip_host = conn.source_ip(is_ipv4).unwrap(); + if is_ipv4 { + assert_eq!( + &ipv4_hosts[ipv4.iter().position(|&ip| ip == ip_host.ip).unwrap()], + ip_host.host.as_ref().unwrap() + ); + } else { + assert_eq!( + &ipv6_hosts[ipv6.iter().position(|&ip| ip == ip_host.ip).unwrap()], + ip_host.host.as_ref().unwrap() + ); + } + } + } } #[test] @@ -173,7 +164,12 @@ fn to_remote_hosts() { preference: 10, }, ]; - let hosts = mx.to_remote_hosts("domain", 7).unwrap(); + let mx_config = MxConfig { + max_mx: 7, + max_multi_homed: 2, + ip_lookup_strategy: IpLookupStrategy::Ipv4thenIpv6, + }; + let hosts = mx.to_remote_hosts("domain", &mx_config).unwrap(); assert_eq!(hosts.len(), 7); for host in hosts { if let NextHop::MX { host, .. } = host { @@ -184,7 +180,7 @@ fn to_remote_hosts() { exchanges: vec![".".to_string()], preference: 0, }]; - assert!(mx.to_remote_hosts("domain", 10).is_none()); + assert!(mx.to_remote_hosts("domain", &mx_config).is_none()); } #[test] diff --git a/tests/src/smtp/management/queue.rs b/tests/src/smtp/management/queue.rs index 8481416b..824d5cc8 100644 --- a/tests/src/smtp/management/queue.rs +++ b/tests/src/smtp/management/queue.rs @@ -34,10 +34,11 @@ description = "Superuser" secret = "secret" class = "admin" -[queue.schedule] +[queue.schedule.default] retry = "1000s" notify = "2000s" expire = "3000s" +queue-name = "default" [session.rcpt] relay = true @@ -166,6 +167,7 @@ async fn manage_queue() { .queue_receiver .consume_message(&remote_core) .await + .message .recipients .into_iter() .map(|r| r.address) @@ -193,11 +195,9 @@ async fn manage_queue() { let (sender, recipients) = envelopes.get(env_id.as_str()).unwrap(); assert_eq!(&message.return_path, sender); 'outer: for recipient in recipients { - for domain in &message.domains { - for rcpt in &domain.recipients { - if &rcpt.address == recipient { - continue 'outer; - } + for rcpt in &message.recipients { + if &rcpt.address == recipient { + continue 'outer; } } panic!("Recipient {recipient} not found in message."); @@ -209,47 +209,43 @@ async fn manage_queue() { let next_retry = created + hold_for; let next_notify = created + 2000 + hold_for; let expires = created + 3000 + hold_for; - for domain in &message.domains { + for rcpt in &message.recipients { if env_id == "c" { - let mut dt = *domain.next_retry.as_ref().unwrap(); + let mut dt = *rcpt.next_retry.as_ref().unwrap(); dt.second -= 1; test_search = dt.to_rfc3339(); } if env_id != "f" { - assert_eq!(domain.retry_num, 0); + // HOLDFOR messages + assert_eq!(rcpt.retry_num, 0); assert_timestamp( - domain.next_retry.as_ref().unwrap(), + rcpt.next_retry.as_ref().unwrap(), next_retry, "retry", &message, ); assert_timestamp( - domain.next_notify.as_ref().unwrap(), + rcpt.next_notify.as_ref().unwrap(), next_notify, "notify", &message, ); - assert_timestamp(&domain.expires, expires, "expires", &message); - for rcpt in &domain.recipients { - assert_eq!(&rcpt.status, &Status::Scheduled, "{message:#?}"); - } + assert_timestamp(&rcpt.expires.unwrap(), expires, "expires", &message); + assert_eq!(&rcpt.status, &Status::Scheduled, "{message:#?}"); + } else if rcpt.address == "success@foobar.org" { + assert_eq!(rcpt.retry_num, 0); + assert!( + matches!(&rcpt.status, Status::Completed(_)), + "{:?}", + rcpt.status + ); } else { - assert_eq!(domain.retry_num, 1); - for rcpt in &domain.recipients { - if rcpt.address == "success@foobar.org" { - assert!( - matches!(&rcpt.status, Status::Completed(_)), - "{:?}", - rcpt.status - ); - } else { - assert!( - matches!(&rcpt.status, Status::TemporaryFailure(_)), - "{:?}", - rcpt.status - ); - } - } + assert_eq!(rcpt.retry_num, 1); + assert!( + matches!(&rcpt.status, Status::TemporaryFailure(_)), + "{:?}", + rcpt.status + ); } } @@ -323,6 +319,7 @@ async fn manage_queue() { .queue_receiver .consume_message(&remote_core) .await + .message .recipients .into_iter() .map(|r| r.address) @@ -346,17 +343,17 @@ async fn manage_queue() { .next() .unwrap() .unwrap() - .domains + .recipients .first() .unwrap() .retry_num, 2 ); - for domain in messages.next().unwrap().unwrap().domains { - let next_retry = domain.next_retry.as_ref().unwrap().to_rfc3339(); + for rcpt in messages.next().unwrap().unwrap().recipients { + let next_retry = rcpt.next_retry.as_ref().unwrap().to_rfc3339(); let matched = ["2200-01-01T00:00:00Z", "2199-12-31T23:59:59Z"].contains(&next_retry.as_str()); - if domain.name == "example1.org" { + if rcpt.address.ends_with("example1.org") { assert!(matched, "{next_retry}"); } else { assert!(!matched, "{next_retry}"); @@ -410,36 +407,25 @@ async fn manage_queue() { assert_eq!(message, None); } else { let message = message.unwrap(); - assert!(!message.domains.is_empty()); - for domain in message.domains { + assert!(!message.recipients.is_empty()); + for rcpt in message.recipients { match id { "a" => { - if domain.name == "example2.org" { - assert_eq!(&domain.status, &Status::Completed("".to_string())); - for rcpt in &domain.recipients { - assert!(matches!(&rcpt.status, Status::PermanentFailure(_))); - } + if rcpt.address.ends_with("example2.org") { + assert!(matches!(&rcpt.status, Status::PermanentFailure(_))); } else { - assert_eq!(&domain.status, &Status::Scheduled); - for rcpt in &domain.recipients { - assert!(matches!(&rcpt.status, Status::Scheduled)); - } + assert!(matches!(&rcpt.status, Status::Scheduled)); } } "c" => { - assert_eq!(&domain.status, &Status::Scheduled); - if domain.name == "example2.com" { - for rcpt in &domain.recipients { - if rcpt.address == "rcpt6@example2.com" { - assert!(matches!(&rcpt.status, Status::PermanentFailure(_))); - } else { - assert!(matches!(&rcpt.status, Status::Scheduled)); - } - } - } else { - for rcpt in &domain.recipients { + if rcpt.address.ends_with("example2.com") { + if rcpt.address == "rcpt6@example2.com" { + assert!(matches!(&rcpt.status, Status::PermanentFailure(_))); + } else { assert!(matches!(&rcpt.status, Status::Scheduled)); } + } else { + assert!(matches!(&rcpt.status, Status::Scheduled)); } } _ => unreachable!(), diff --git a/tests/src/smtp/mod.rs b/tests/src/smtp/mod.rs index 2f05a46d..a4e9441d 100644 --- a/tests/src/smtp/mod.rs +++ b/tests/src/smtp/mod.rs @@ -173,7 +173,7 @@ impl TestSMTP { } pub fn inner_with_rxs(&self) -> (Arc, IpcReceivers) { - let (ipc, ipc_rxs) = build_ipc(&mut Config::default(), false); + let (ipc, ipc_rxs) = build_ipc(false); ( Inner { @@ -191,7 +191,7 @@ impl TestSMTP { let store = core.storage.data.clone(); let blob_store = core.storage.blob.clone(); let shared_core = core.into_shared(); - let (ipc, mut ipc_rxs) = build_ipc(&mut Config::default(), false); + let (ipc, mut ipc_rxs) = build_ipc(false); TestSMTP { queue_receiver: QueueReceiver { diff --git a/tests/src/smtp/outbound/dane.rs b/tests/src/smtp/outbound/dane.rs index e196af0a..cded5ba3 100644 --- a/tests/src/smtp/outbound/dane.rs +++ b/tests/src/smtp/outbound/dane.rs @@ -48,7 +48,7 @@ relay = true [report.tls.aggregate] send = "weekly" -[queue.outbound.tls] +[queue.tls.default] dane = "require" starttls = "require" @@ -343,10 +343,10 @@ async fn dane_test() { certs.remove(0); assert_eq!( tlsa.verify(0, &host, Some(&certs)), - Err(Status::PermanentFailure(Error::DaneError(ErrorDetails { + Err(Status::PermanentFailure(ErrorDetails { entity: host, - details: "No matching certificates found in TLSA records".into() - }))) + details: Error::DaneError("No matching certificates found in TLSA records".into()) + })) ); } } diff --git a/tests/src/smtp/outbound/extensions.rs b/tests/src/smtp/outbound/extensions.rs index ea6f670b..0b944285 100644 --- a/tests/src/smtp/outbound/extensions.rs +++ b/tests/src/smtp/outbound/extensions.rs @@ -150,9 +150,9 @@ async fn extensions() { .try_deliver(core.clone()); local.queue_receiver.read_event().await.assert_done(); let message = remote.queue_receiver.expect_message().await; - assert_eq!(message.env_id, Some("abc123".into())); - assert!((message.flags & MAIL_RET_HDRS) != 0); - assert!((message.flags & MAIL_REQUIRETLS) != 0); - assert!((message.flags & MAIL_SMTPUTF8) != 0); - assert!((message.recipients.last().unwrap().flags & RCPT_NOTIFY_NEVER) != 0); + assert_eq!(message.message.env_id, Some("abc123".into())); + assert!((message.message.flags & MAIL_RET_HDRS) != 0); + assert!((message.message.flags & MAIL_REQUIRETLS) != 0); + assert!((message.message.flags & MAIL_SMTPUTF8) != 0); + assert!((message.message.recipients.last().unwrap().flags & RCPT_NOTIFY_NEVER) != 0); } diff --git a/tests/src/smtp/outbound/fallback_relay.rs b/tests/src/smtp/outbound/fallback_relay.rs index 52d23e0e..a0e4d4a4 100644 --- a/tests/src/smtp/outbound/fallback_relay.rs +++ b/tests/src/smtp/outbound/fallback_relay.rs @@ -13,9 +13,9 @@ use store::write::now; use crate::smtp::{DnsCache, TestSMTP, session::TestSession}; const LOCAL: &str = r#" -[queue.outbound] -next-hop = [{if = "retry_num > 0", then = "'fallback'"}, - {else = false}] +[queue.strategy] +gateway = [{if = "retry_num > 0", then = "'fallback'"}, + {else = "'mx'"}] [session.rcpt] relay = true @@ -24,13 +24,14 @@ max-recipients = 100 [session.extensions] dsn = true -[remote.fallback] +[queue.gateway.fallback] +type = "relay" address = fallback.foobar.org port = 9925 protocol = 'smtp' concurrency = 5 -[remote.fallback.tls] +[queue.gateway.fallback.tls] implicit = false allow-invalid-certs = true @@ -93,13 +94,11 @@ async fn fallback_relay() { .await .try_deliver(core.clone()); let mut retry = local.queue_receiver.expect_message().await; - let prev_due = retry.domains[0].retry.due; + let prev_due = retry.message.recipients[0].retry.due; let next_due = now(); let queue_id = retry.queue_id; - retry.domains[0].retry.due = next_due; - retry - .save_changes(&core, prev_due.into(), next_due.into()) - .await; + retry.message.recipients[0].retry.due = next_due; + retry.save_changes(&core, prev_due.into()).await; local .queue_receiver .delivery_attempt(queue_id) diff --git a/tests/src/smtp/outbound/ip_lookup.rs b/tests/src/smtp/outbound/ip_lookup.rs index 1af8e691..ace005d6 100644 --- a/tests/src/smtp/outbound/ip_lookup.rs +++ b/tests/src/smtp/outbound/ip_lookup.rs @@ -15,8 +15,8 @@ const LOCAL: &str = r#" [session.rcpt] relay = true -[queue.outbound] -ip-strategy = "ipv6_then_ipv4" +[queue.gateway.mx] +ip-lookup = "ipv6_then_ipv4" "#; const REMOTE: &str = r#" @@ -81,7 +81,7 @@ async fn ip_lookup_strategy() { remote.queue_receiver.expect_message().await; } else { let message = local.queue_receiver.last_queued_message().await; - let status = message.domains[0].status.to_string(); + let status = message.message.recipients[0].status.to_string(); assert!( status.contains("Connection refused"), "Message: {:?}", diff --git a/tests/src/smtp/outbound/lmtp.rs b/tests/src/smtp/outbound/lmtp.rs index eb225dc4..ed159a82 100644 --- a/tests/src/smtp/outbound/lmtp.rs +++ b/tests/src/smtp/outbound/lmtp.rs @@ -11,7 +11,10 @@ use crate::smtp::{ inbound::TestMessage, session::{TestSession, VerifyResponse}, }; -use common::{config::server::ServerProtocol, ipc::QueueEvent}; +use common::{ + config::{server::ServerProtocol, smtp::queue::QueueName}, + ipc::QueueEvent, +}; use smtp::queue::spool::SmtpSpool; use store::write::now; @@ -27,9 +30,11 @@ dsn = true "; const LOCAL: &str = r#" -[queue.outbound] -next-hop = [{if = "rcpt_domain = 'foobar.org'", then = "'lmtp'"}, - {else = false}] +[queue.strategy] +gateway = [{if = "rcpt_domain = 'foobar.org'", then = "'lmtp'"}, + {else = "'mx'"}] +schedule = [{if = "rcpt_domain = 'foobar.org'", then = "'foobar'"}, + {else = "'default'"}] [session.rcpt] relay = true @@ -38,23 +43,30 @@ max-recipients = 100 [session.extensions] dsn = true -[queue.schedule] +[queue.schedule.default] retry = "1s" -notify = [{if = "rcpt_domain = 'foobar.org'", then = "[1s, 2s]"}, - {else = [1s]}] -expire = [{if = "rcpt_domain = 'foobar.org'", then = "4s"}, - {else = "5s"}] +notify = "1s" +expire = "5s" +queue-name = "default" -[queue.outbound.timeouts] +[queue.schedule.foobar] +retry = "1s" +notify = ["1s", "2s"] +expire = "4s" +queue-name = "default" + +[queue.connection.default.timeout] +connect = "1s" data = "50ms" -[remote.lmtp] +[queue.gateway.lmtp] +type = "relay" address = lmtp.foobar.org port = 9924 protocol = 'lmtp' concurrency = 5 -[remote.lmtp.tls] +[queue.gateway.lmtp.tls] implicit = true allow-invalid-certs = true "#; @@ -122,9 +134,12 @@ async fn lmtp_delivery() { tokio::time::sleep(Duration::from_secs(event.due - now)).await; } - let message = core.read_message(event.queue_id).await.unwrap(); - if message.return_path.is_empty() { - message.clone().remove(&core, event.due).await; + let message = core + .read_message(event.queue_id, QueueName::default()) + .await + .unwrap(); + if message.message.return_path.is_empty() { + message.clone().remove(&core, event.due.into()).await; dsn.push(message); } else { event.try_deliver(core.clone()); @@ -173,6 +188,7 @@ async fn lmtp_delivery() { .queue_receiver .expect_message() .await + .message .recipients .into_iter() .map(|r| r.address) diff --git a/tests/src/smtp/outbound/mta_sts.rs b/tests/src/smtp/outbound/mta_sts.rs index 2cadcbc1..d167664f 100644 --- a/tests/src/smtp/outbound/mta_sts.rs +++ b/tests/src/smtp/outbound/mta_sts.rs @@ -31,8 +31,9 @@ const LOCAL: &str = r#" [session.rcpt] relay = true -[queue.outbound.tls] +[queue.tls.default] mta-sts = "require" +allow-invalid-certs = false [report.tls.aggregate] send = "weekly" diff --git a/tests/src/smtp/outbound/smtp.rs b/tests/src/smtp/outbound/smtp.rs index d749462f..8fb9ead2 100644 --- a/tests/src/smtp/outbound/smtp.rs +++ b/tests/src/smtp/outbound/smtp.rs @@ -6,7 +6,10 @@ use std::time::{Duration, Instant}; -use common::{config::server::ServerProtocol, ipc::QueueEvent}; +use common::{ + config::{server::ServerProtocol, smtp::queue::QueueName}, + ipc::QueueEvent, +}; use mail_auth::MX; use store::write::now; @@ -25,13 +28,31 @@ max-recipients = 100 [session.extensions] dsn = true -[queue.schedule] +[queue.schedule.default] retry = "1s" -notify = [{if = "rcpt_domain = 'foobar.org'", then = "[1s, 2s]"}, - {if = "rcpt_domain = 'foobar.com'", then = "[5s, 6s]"}, - {else = [1s]}] -expire = [{if = "rcpt_domain = 'foobar.org'", then = "6s"}, - {else = "7s"}] +notify = "1s" +expire = "7s" +queue-name = "default" + +[queue.schedule.foobar-org] +retry = "1s" +notify = ["1s", "2s"] +expire = "6s" +queue-name = "default" + +[queue.schedule.foobar-com] +retry = "1s" +notify = ["5s", "6s"] +expire = "7s" +queue-name = "default" + + +[queue.strategy] +schedule = [{if = "rcpt_domain == 'foobar.org'", then = "'foobar-org'"}, + {if = "rcpt_domain == 'foobar.com'", then = "'foobar-com'"}, + {else = "'default'"}] + + "#; const REMOTE: &str = r#" @@ -125,15 +146,15 @@ async fn smtp_delivery() { ) .await; let message = local.queue_receiver.expect_message().await; - let num_domains = message.domains.len(); - assert_eq!(num_domains, 3); + let num_recipients = message.message.recipients.len(); + assert_eq!(num_recipients, 7); local .queue_receiver .delivery_attempt(message.queue_id) .await .try_deliver(core.clone()); let mut dsn = Vec::new(); - let mut domain_retries = vec![0; num_domains]; + let mut rcpt_retries = vec![0; num_recipients]; loop { match local.queue_receiver.try_read_event().await { Some(QueueEvent::Refresh | QueueEvent::WorkerDone { .. }) => {} @@ -151,25 +172,32 @@ async fn smtp_delivery() { tokio::time::sleep(Duration::from_secs(event.due - now)).await; } - let message = core.read_message(event.queue_id).await.unwrap(); - if message.return_path.is_empty() { - message.clone().remove(&core, event.due).await; + let message = core + .read_message(event.queue_id, QueueName::default()) + .await + .unwrap(); + if message.message.return_path.is_empty() { + message.clone().remove(&core, event.due.into()).await; dsn.push(message); } else { - for (idx, domain) in message.domains.iter().enumerate() { - domain_retries[idx] = domain.retry.inner; + for (idx, rcpt) in message.message.recipients.iter().enumerate() { + rcpt_retries[idx] = rcpt.retry.inner; } event.try_deliver(core.clone()); tokio::time::sleep(Duration::from_millis(100)).await; } } } - assert_eq!(domain_retries[0], 0, "retries {domain_retries:?}"); - assert!(domain_retries[1] >= 5, "retries {domain_retries:?}"); - assert!(domain_retries[2] >= 5, "retries {domain_retries:?}"); + assert_eq!(rcpt_retries[0], 0, "retries {rcpt_retries:?}"); + assert!(rcpt_retries[1] >= 5, "retries {rcpt_retries:?}"); + assert_eq!(rcpt_retries[2], 0, "retries {rcpt_retries:?}"); + assert_eq!(rcpt_retries[3], 0, "retries {rcpt_retries:?}"); + assert!(rcpt_retries[4] >= 5, "retries {rcpt_retries:?}"); + assert_eq!(rcpt_retries[5], 0, "retries {rcpt_retries:?}"); + assert_eq!(rcpt_retries[6], 0, "retries {rcpt_retries:?}"); assert!( - domain_retries[1] >= domain_retries[2], - "retries {domain_retries:?}" + rcpt_retries[1] >= rcpt_retries[4], + "retries {rcpt_retries:?}" ); local.queue_receiver.assert_queue_is_empty().await; @@ -215,27 +243,29 @@ async fn smtp_delivery() { .assert_contains(" (host ") .assert_contains("Action: failed"); - assert_eq!( + let mut recipients = remote + .queue_receiver + .consume_message(&remote_core) + .await + .message + .recipients + .into_iter() + .map(|r| r.address) + .collect::>(); + recipients.extend( remote .queue_receiver .consume_message(&remote_core) .await + .message .recipients .into_iter() - .map(|r| r.address) - .collect::>(), - vec!["ok@foobar.org".to_string()] + .map(|r| r.address), ); + recipients.sort(); assert_eq!( - remote - .queue_receiver - .consume_message(&remote_core) - .await - .recipients - .into_iter() - .map(|r| r.address) - .collect::>(), - vec!["ok@foobar.net".to_string()] + recipients, + vec!["ok@foobar.net".to_string(), "ok@foobar.org".to_string()] ); remote.queue_receiver.assert_no_events(); diff --git a/tests/src/smtp/outbound/throttle.rs b/tests/src/smtp/outbound/throttle.rs index 9234e29e..3c1190cd 100644 --- a/tests/src/smtp/outbound/throttle.rs +++ b/tests/src/smtp/outbound/throttle.rs @@ -4,24 +4,25 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +use crate::smtp::{ + DnsCache, TestSMTP, + inbound::TestQueueEvent, + queue::{build_rcpt, manager::new_message}, + session::TestSession, +}; +use mail_auth::MX; +use smtp::queue::{Message, QueueEnvelope, throttle::IsAllowed}; use std::{ net::{IpAddr, Ipv4Addr}, time::{Duration, Instant}, }; - -use mail_auth::MX; use store::write::now; -use crate::smtp::{ - DnsCache, TestSMTP, inbound::TestQueueEvent, queue::manager::new_message, session::TestSession, -}; -use smtp::queue::{Domain, Message, QueueEnvelope, Schedule, Status, throttle::IsAllowed}; - const CONFIG: &str = r#" [session.rcpt] relay = true -[queue.schedule] +[queue.schedule.default] retry = "1h" notify = "1h" expire = "1h" @@ -66,7 +67,7 @@ async fn throttle_outbound() { crate::enable_logging(); // Build test message - let mut test_message = new_message(0); + let mut test_message = new_message(0).message; test_message.return_path_domain = "foobar.org".into(); let mut local = TestSMTP::new("smtp_throttle_outbound", CONFIG).await; @@ -124,13 +125,9 @@ async fn throttle_outbound() { // Expect concurrency throttle for recipient domain 'example.org' test_message.return_path_domain = "test.net".into(); - test_message.domains.push(Domain { - domain: "example.org".into(), - retry: Schedule::now(), - notify: Schedule::now(), - expires: 0, - status: Status::Scheduled, - }); + test_message + .recipients + .push(build_rcpt("test@example.org", 0, 0, 0)); for t in &throttle.rcpt { core.is_allowed(t, &QueueEnvelope::test(&test_message, 0, ""), 0) .await @@ -154,13 +151,9 @@ async fn throttle_outbound() { local.queue_receiver.read_event().await.assert_on_hold();*/ // Expect rate limit throttle for recipient domain 'example.net' - test_message.domains.push(Domain { - domain: "example.net".into(), - retry: Schedule::now(), - notify: Schedule::now(), - expires: 0, - status: Status::Scheduled, - }); + test_message + .recipients + .push(build_rcpt("test@example.net", 0, 0, 0)); for t in &throttle.rcpt { core.is_allowed(t, &QueueEnvelope::test(&test_message, 1, ""), 0) .await @@ -199,13 +192,10 @@ async fn throttle_outbound() { vec!["127.0.0.1".parse().unwrap()], Instant::now() + Duration::from_secs(10), ); - test_message.domains.push(Domain { - domain: "test.org".into(), - retry: Schedule::now(), - notify: Schedule::now(), - expires: 0, - status: Status::Scheduled, - }); + test_message + .recipients + .push(build_rcpt("test@test.org", 0, 0, 0)); + for t in &throttle.remote { core.is_allowed(t, &QueueEnvelope::test(&test_message, 2, "mx.test.org"), 0) .await @@ -262,14 +252,13 @@ pub trait TestQueueEnvelope<'x> { } impl<'x> TestQueueEnvelope<'x> for QueueEnvelope<'x> { - fn test(message: &'x Message, current_domain: usize, mx: &'x str) -> Self { + fn test(message: &'x Message, current_rcpt: usize, mx: &'x str) -> Self { QueueEnvelope { message, mx, remote_ip: IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), local_ip: IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), - current_domain, - current_rcpt: 0, + current_rcpt, } } } diff --git a/tests/src/smtp/outbound/tls.rs b/tests/src/smtp/outbound/tls.rs index 5b33a769..a088f0a2 100644 --- a/tests/src/smtp/outbound/tls.rs +++ b/tests/src/smtp/outbound/tls.rs @@ -20,12 +20,17 @@ const LOCAL: &str = r#" [session.rcpt] relay = true -[queue.outbound] -hostname = "'badtls.foobar.org'" +[queue.connection.default] +ehlo-hostname = "badtls.foobar.org" + +[queue.strategy] +tls = [ { if = "retry_num > 0 && last_error == 'tls'", then = "'no-tls'"}, + { else = "'default'" }] + +[queue.tls.no-tls] +starttls = false +allow-invalid-certs = true -[queue.outbound.tls] -starttls = [ { if = "retry_num > 0 && last_error == 'tls'", then = "disable"}, - { else = "optional" }] "#; const REMOTE: &str = r#" @@ -82,13 +87,11 @@ async fn starttls_optional() { .await .try_deliver(core.clone()); let mut retry = local.queue_receiver.expect_message().await; - let prev_due = retry.domains[0].retry.due; + let prev_due = retry.message.recipients[0].retry.due; let next_due = now(); let queue_id = retry.queue_id; - retry.domains[0].retry.due = next_due; - retry - .save_changes(&core, prev_due.into(), next_due.into()) - .await; + retry.message.recipients[0].retry.due = next_due; + retry.save_changes(&core, prev_due.into()).await; local .queue_receiver .delivery_attempt(queue_id) diff --git a/tests/src/smtp/queue/concurrent.rs b/tests/src/smtp/queue/concurrent.rs index f5c065c4..4827c3b9 100644 --- a/tests/src/smtp/queue/concurrent.rs +++ b/tests/src/smtp/queue/concurrent.rs @@ -22,13 +22,14 @@ relay = true [session.data.limits] messages = 2000 -[queue.threads] -remote = 4 +[queue.virtual.default] +threads-per-node = 4 -[queue.schedule] +[queue.schedule.default] retry = "1s" notify = "1d" expire = "1d" +queue-name = "default" "#; const REMOTE: &str = r#" @@ -136,12 +137,7 @@ async fn concurrent_queue() { if m + e != 0 { println!("Queue still has {} messages and {} events", m, e); /*for inner in &inners { - inner - .ipc - .queue_tx - .send(QueueEvent::Refresh) - .await - .unwrap(); + inner.ipc.queue_tx.send(QueueEvent::Refresh).await.unwrap(); }*/ } else { break; diff --git a/tests/src/smtp/queue/dsn.rs b/tests/src/smtp/queue/dsn.rs index 16c4ad18..8655dc16 100644 --- a/tests/src/smtp/queue/dsn.rs +++ b/tests/src/smtp/queue/dsn.rs @@ -4,15 +4,22 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::{fs, path::PathBuf, time::SystemTime}; +use std::{ + fs, + net::{IpAddr, Ipv4Addr}, + path::PathBuf, + time::SystemTime, +}; +use common::config::smtp::queue::{QueueExpiry, QueueName}; use smtp_proto::{RCPT_NOTIFY_DELAY, RCPT_NOTIFY_FAILURE, RCPT_NOTIFY_SUCCESS, Response}; use store::write::now; use utils::BlobHash; use crate::smtp::{QueueReceiver, TestSMTP, inbound::sign::SIGNATURES}; use smtp::queue::{ - Domain, Error, ErrorDetails, HostResponse, Message, Recipient, Schedule, Status, dsn::SendDsn, + Error, ErrorDetails, HostResponse, Message, MessageWrapper, Recipient, Schedule, Status, + UnexpectedResponse, dsn::SendDsn, }; const CONFIG: &str = r#" @@ -46,49 +53,47 @@ async fn generate_dsn() { let dsn_original = fs::read_to_string(&path).unwrap(); let flags = RCPT_NOTIFY_FAILURE | RCPT_NOTIFY_DELAY | RCPT_NOTIFY_SUCCESS; - let mut message = Message { - size, + let mut message = MessageWrapper { queue_id: 0, span_id: 0, - created: SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .map_or(0, |d| d.as_secs()), - return_path: "sender@foobar.org".into(), - return_path_lcase: "".into(), - return_path_domain: "foobar.org".into(), - recipients: vec![Recipient { - domain_idx: 0, - address: "foobar@example.org".into(), - address_lcase: "foobar@example.org".into(), - status: Status::PermanentFailure(HostResponse { - hostname: ErrorDetails { + queue_name: QueueName::default(), + message: Message { + size, + created: SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()), + return_path: "sender@foobar.org".into(), + return_path_lcase: "".into(), + return_path_domain: "foobar.org".into(), + recipients: vec![Recipient { + address: "foobar@example.org".into(), + address_lcase: "foobar@example.org".into(), + status: Status::PermanentFailure(ErrorDetails { entity: "mx.example.org".into(), - details: "RCPT TO:".into(), - }, - response: Response { - code: 550, - esc: [5, 1, 2], - message: "User does not exist".into(), - }, - }), + details: Error::UnexpectedResponse(UnexpectedResponse { + command: "RCPT TO:".into(), + response: Response { + code: 550, + esc: [5, 1, 2], + message: "User does not exist".into(), + }, + }), + }), + flags: 0, + orcpt: None, + retry: Schedule::now(), + notify: Schedule::now(), + expires: QueueExpiry::Duration(10), + queue: QueueName::default(), + }], flags: 0, - orcpt: None, - }], - domains: vec![Domain { - domain: "example.org".into(), - retry: Schedule::now(), - notify: Schedule::now(), - expires: now() + 10, - status: Status::TemporaryFailure(Error::ConnectionError(ErrorDetails { - entity: "mx.domain.org".into(), - details: "Connection timeout".into(), - })), - }], - flags: 0, - env_id: None, - priority: 0, - blob_hash: BlobHash::generate(dsn_original.as_bytes()), - quota_keys: vec![], + env_id: None, + priority: 0, + blob_hash: BlobHash::generate(dsn_original.as_bytes()), + quota_keys: vec![], + received_from_ip: IpAddr::V4(Ipv4Addr::LOCALHOST), + received_via_port: 0, + }, }; // Load config @@ -98,7 +103,10 @@ async fn generate_dsn() { // Create temp dir for queue qr.blob_store - .put_blob(message.blob_hash.as_slice(), dsn_original.as_bytes()) + .put_blob( + message.message.blob_hash.as_slice(), + dsn_original.as_bytes(), + ) .await .unwrap(); @@ -108,14 +116,13 @@ async fn generate_dsn() { qr.assert_queue_is_empty().await; // Failure DSN - message.recipients[0].flags = flags; + message.message.recipients[0].flags = flags; core.send_dsn(&mut message).await; let dsn_message = qr.expect_message().await; - qr.compare_dsn(dsn_message, "failure.eml").await; + qr.compare_dsn(dsn_message.message, "failure.eml").await; // Success DSN - message.recipients.push(Recipient { - domain_idx: 0, + message.message.recipients.push(Recipient { address: "jane@example.org".into(), address_lcase: "jane@example.org".into(), status: Status::Completed(HostResponse { @@ -128,32 +135,42 @@ async fn generate_dsn() { }), flags, orcpt: None, + retry: Schedule::now(), + notify: Schedule::now(), + expires: QueueExpiry::Duration(10), + queue: QueueName::default(), }); core.send_dsn(&mut message).await; let dsn_message = qr.expect_message().await; - qr.compare_dsn(dsn_message, "success.eml").await; + qr.compare_dsn(dsn_message.message, "success.eml").await; // Delay DSN - message.recipients.push(Recipient { - domain_idx: 0, + message.message.recipients.push(Recipient { address: "john.doe@example.org".into(), address_lcase: "john.doe@example.org".into(), - status: Status::Scheduled, + status: Status::TemporaryFailure(ErrorDetails { + entity: "mx.domain.org".into(), + details: Error::ConnectionError("Connection timeout".into()), + }), flags, orcpt: Some("jdoe@example.org".into()), + retry: Schedule::now(), + notify: Schedule::now(), + expires: QueueExpiry::Duration(10), + queue: QueueName::default(), }); core.send_dsn(&mut message).await; let dsn_message = qr.expect_message().await; - qr.compare_dsn(dsn_message, "delay.eml").await; + qr.compare_dsn(dsn_message.message, "delay.eml").await; // Mixed DSN - for rcpt in &mut message.recipients { + for rcpt in &mut message.message.recipients { rcpt.flags = flags; } - message.domains[0].notify.due = now(); + message.message.recipients.last_mut().unwrap().notify.due = now(); core.send_dsn(&mut message).await; let dsn_message = qr.expect_message().await; - qr.compare_dsn(dsn_message, "mixed.eml").await; + qr.compare_dsn(dsn_message.message, "mixed.eml").await; // Load queue let queue = qr.read_queued_messages().await; diff --git a/tests/src/smtp/queue/manager.rs b/tests/src/smtp/queue/manager.rs index 3040763f..1e0a982f 100644 --- a/tests/src/smtp/queue/manager.rs +++ b/tests/src/smtp/queue/manager.rs @@ -4,15 +4,17 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use std::time::Duration; - -use mail_auth::hickory_resolver::proto::op::ResponseCode; - -use smtp::queue::{Domain, Message, Schedule, Status, spool::SmtpSpool}; +use crate::smtp::{TestSMTP, queue::build_rcpt}; +use common::config::smtp::queue::QueueName; +use smtp::queue::{ + Error, ErrorDetails, Message, MessageWrapper, Recipient, Status, spool::SmtpSpool, +}; +use std::{ + net::{IpAddr, Ipv4Addr}, + time::Duration, +}; use store::write::now; -use crate::smtp::TestSMTP; - const CONFIG: &str = r#" [session.ehlo] reject-non-fqdn = false @@ -31,19 +33,16 @@ async fn queue_due() { let qr = &local.queue_receiver; let mut message = new_message(0); - message.domains.push(domain("c", 3, 8, 9)); - let due = message.next_delivery_event(); - message.save_changes(&core, 0.into(), due.into()).await; + message.message.recipients.push(build_rcpt("c", 3, 8, 9)); + message.save_changes(&core, 0.into()).await; let mut message = new_message(1); - message.domains.push(domain("b", 2, 6, 7)); - let due = message.next_delivery_event(); - message.save_changes(&core, 0.into(), due.into()).await; + message.message.recipients.push(build_rcpt("b", 2, 6, 7)); + message.save_changes(&core, 0.into()).await; let mut message = new_message(2); - message.domains.push(domain("a", 1, 4, 5)); - let due = message.next_delivery_event(); - message.save_changes(&core, 0.into(), due.into()).await; + message.message.recipients.push(build_rcpt("a", 1, 4, 5)); + message.save_changes(&core, 0.into()).await; for domain in vec!["a", "b", "c"].into_iter() { let now = now(); @@ -53,9 +52,12 @@ async fn queue_due() { assert_eq!(wake_up, 1); std::thread::sleep(Duration::from_secs(wake_up)); } - if let Some(message) = core.read_message(queue_event.queue_id).await { - message.domain(domain); - message.remove(&core, queue_event.due).await; + if let Some(message) = core + .read_message(queue_event.queue_id, QueueName::default()) + .await + { + message.message.rcpt(domain); + message.remove(&core, queue_event.due.into()).await; } else { panic!("Message not found"); } @@ -67,110 +69,135 @@ async fn queue_due() { #[test] fn delivery_events() { - let mut message = new_message(0); + let mut message = new_message(0).message; + message.created = now(); - message.domains.push(domain("a", 1, 2, 3)); - message.domains.push(domain("b", 4, 5, 6)); - message.domains.push(domain("c", 7, 8, 9)); + message.recipients.push(build_rcpt("a", 1, 2, 3)); + message.recipients.push(build_rcpt("b", 4, 5, 6)); + message.recipients.push(build_rcpt("c", 7, 8, 9)); for t in 0..2 { - assert_eq!(message.next_event().unwrap(), message.domain("a").retry.due); - assert_eq!(message.next_delivery_event(), message.domain("a").retry.due); assert_eq!( - message - .next_event_after(message.domain("a").expires) - .unwrap(), - message.domain("b").retry.due + message.next_event(None).unwrap(), + message.rcpt("a").retry.due + ); + assert_eq!( + message.next_delivery_event(None).unwrap(), + message.rcpt("a").retry.due ); assert_eq!( message - .next_event_after(message.domain("b").expires) + .next_event_after( + None, + message.rcpt("a").expiration_time(message.created).unwrap() + ) .unwrap(), - message.domain("c").retry.due + message.rcpt("b").retry.due ); assert_eq!( message - .next_event_after(message.domain("c").notify.due) + .next_event_after( + None, + message.rcpt("b").expiration_time(message.created).unwrap() + ) .unwrap(), - message.domain("c").expires + message.rcpt("c").retry.due + ); + assert_eq!( + message + .next_event_after(None, message.rcpt("c").notify.due) + .unwrap(), + message.rcpt("c").expiration_time(message.created).unwrap() ); assert!( message - .next_event_after(message.domain("c").expires) + .next_event_after( + None, + message.rcpt("c").expiration_time(message.created).unwrap() + ) .is_none() ); if t == 0 { - message.domains.reverse(); + message.recipients.reverse(); } else { - message.domains.swap(0, 1); + message.recipients.swap(0, 1); } } - message.domain_mut("a").set_status( - mail_auth::Error::DnsRecordNotFound(ResponseCode::BADCOOKIE), - &[], + message.rcpt_mut("a").status = Status::PermanentFailure(ErrorDetails { + entity: "localhost".into(), + details: Error::ConcurrencyLimited, + }); + assert_eq!( + message.next_event(None).unwrap(), + message.rcpt("b").retry.due + ); + assert_eq!( + message.next_delivery_event(None).unwrap(), + message.rcpt("b").retry.due ); - assert_eq!(message.next_event().unwrap(), message.domain("b").retry.due); - assert_eq!(message.next_delivery_event(), message.domain("b").retry.due); - message.domain_mut("b").set_status( - mail_auth::Error::DnsRecordNotFound(ResponseCode::BADCOOKIE), - &[], + message.rcpt_mut("b").status = Status::PermanentFailure(ErrorDetails { + entity: "localhost".into(), + details: Error::ConcurrencyLimited, + }); + assert_eq!( + message.next_event(None).unwrap(), + message.rcpt("c").retry.due + ); + assert_eq!( + message.next_delivery_event(None).unwrap(), + message.rcpt("c").retry.due ); - assert_eq!(message.next_event().unwrap(), message.domain("c").retry.due); - assert_eq!(message.next_delivery_event(), message.domain("c").retry.due); - message.domain_mut("c").set_status( - mail_auth::Error::DnsRecordNotFound(ResponseCode::BADCOOKIE), - &[], - ); - assert!(message.next_event().is_none()); + message.rcpt_mut("c").status = Status::PermanentFailure(ErrorDetails { + entity: "localhost".into(), + details: Error::ConcurrencyLimited, + }); + assert!(message.next_event(None).is_none()); } -pub fn new_message(queue_id: u64) -> Message { - Message { - size: 0, +pub fn new_message(queue_id: u64) -> MessageWrapper { + MessageWrapper { queue_id, span_id: 0, - created: 0, - return_path: "sender@foobar.org".into(), - return_path_lcase: "".into(), - return_path_domain: "foobar.org".into(), - recipients: vec![], - domains: vec![], - flags: 0, - env_id: None, - priority: 0, - quota_keys: vec![], - blob_hash: Default::default(), - } -} - -fn domain(domain: &str, retry: u64, notify: u64, expires: u64) -> Domain { - Domain { - domain: domain.into(), - retry: Schedule::later(Duration::from_secs(retry)), - notify: Schedule::later(Duration::from_secs(notify)), - expires: now() + expires, - status: Status::Scheduled, + queue_name: QueueName::default(), + message: Message { + size: 0, + created: now(), + return_path: "sender@foobar.org".into(), + return_path_lcase: "".into(), + return_path_domain: "foobar.org".into(), + recipients: vec![], + flags: 0, + env_id: None, + priority: 0, + quota_keys: vec![], + blob_hash: Default::default(), + received_from_ip: IpAddr::V4(Ipv4Addr::LOCALHOST), + received_via_port: 0, + }, } } pub trait TestMessage { - fn domain(&self, name: &str) -> &Domain; - fn domain_mut(&mut self, name: &str) -> &mut Domain; + fn rcpt(&self, name: &str) -> &Recipient; + fn rcpt_mut(&mut self, name: &str) -> &mut Recipient; } impl TestMessage for Message { - fn domain(&self, name: &str) -> &Domain { - self.domains + fn rcpt(&self, name: &str) -> &Recipient { + self.recipients .iter() - .find(|d| d.domain == name) - .unwrap_or_else(|| panic!("Expected domain {name} not found in {:?}", self.domains)) + .find(|d| d.address_lcase == name) + .unwrap_or_else(|| panic!("Expected rcpt {name} not found in {:?}", self.recipients)) } - fn domain_mut(&mut self, name: &str) -> &mut Domain { - self.domains.iter_mut().find(|d| d.domain == name).unwrap() + fn rcpt_mut(&mut self, name: &str) -> &mut Recipient { + self.recipients + .iter_mut() + .find(|d| d.address_lcase == name) + .unwrap() } } diff --git a/tests/src/smtp/queue/mod.rs b/tests/src/smtp/queue/mod.rs index b66288d4..4276244d 100644 --- a/tests/src/smtp/queue/mod.rs +++ b/tests/src/smtp/queue/mod.rs @@ -4,7 +4,24 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +use common::config::smtp::queue::{QueueExpiry, QueueName}; +use smtp::queue::{Recipient, Schedule, Status}; + pub mod concurrent; pub mod dsn; pub mod manager; pub mod retry; + +pub fn build_rcpt(address: &str, retry: u64, notify: u64, expires: u64) -> Recipient { + Recipient { + address: address.to_string(), + address_lcase: address.to_string(), + retry: Schedule::later(retry), + notify: Schedule::later(notify), + expires: QueueExpiry::Duration(expires), + status: Status::Scheduled, + flags: 0, + orcpt: None, + queue: QueueName::default(), + } +} diff --git a/tests/src/smtp/queue/retry.rs b/tests/src/smtp/queue/retry.rs index 9e0e6145..8c218e88 100644 --- a/tests/src/smtp/queue/retry.rs +++ b/tests/src/smtp/queue/retry.rs @@ -12,7 +12,10 @@ use crate::smtp::{ session::{TestSession, VerifyResponse}, }; use ahash::AHashSet; -use common::ipc::{QueueEvent, QueueEventStatus}; +use common::{ + config::smtp::queue::QueueName, + ipc::{QueueEvent, QueueEventStatus}, +}; use smtp::queue::spool::SmtpSpool; use store::write::now; @@ -27,12 +30,22 @@ relay = true deliver-by = "1h" future-release = "1h" -[queue.schedule] -retry = "[1s, 2s, 3s]" -notify = [{if = "sender_domain = 'test.org'", then = "[1s, 2s]"}, - {else = ['15h', '22h']}] -expire = [{if = "sender_domain = 'test.org'", then = "6s"}, - {else = '1d'}] +[queue.schedule.sender-default] +retry = ["1s", "2s", "3s"] +notify = ["15h", "22h"] +expire = "1d" +queue-name = "default" + +[queue.schedule.sender-test] +retry = ["1s", "2s", "3s"] +notify = ["1s", "2s"] +expire = "6s" +#max-attempts = 3 +queue-name = "default" + +[queue.strategy] +schedule = [{if = "sender_domain == 'test.org'", then = "'sender-test'"}, + {else = "'sender-default'"}] "#; #[tokio::test] @@ -59,9 +72,11 @@ async fn queue_retry() { // Expect a failed DSN attempt.try_deliver(core.clone()); let message = qr.expect_message().await; - assert_eq!(message.return_path, ""); - assert_eq!(message.domains.first().unwrap().domain, "test.org"); - assert_eq!(message.recipients.first().unwrap().address, "john@test.org"); + assert_eq!(message.message.return_path, ""); + assert_eq!( + message.message.recipients.first().unwrap().address, + "john@test.org" + ); message .read_lines(qr) .await @@ -107,6 +122,7 @@ async fn queue_retry() { if events.is_empty() && in_fight.is_empty() { break; } + for event in events { if in_fight.contains(&event.queue_id) { continue; @@ -115,9 +131,12 @@ async fn queue_retry() { tokio::time::sleep(Duration::from_secs(event.due - now)).await; } - let message = core.read_message(event.queue_id).await.unwrap(); - if message.return_path.is_empty() { - message.clone().remove(&core, event.due).await; + let message = core + .read_message(event.queue_id, QueueName::default()) + .await + .unwrap(); + if message.message.return_path.is_empty() { + message.clone().remove(&core, event.due.into()).await; dsn.push(message); } else { retries.push(event.due.saturating_sub(now)); @@ -178,9 +197,22 @@ async fn queue_retry() { let now_ = now(); let message = qr.expect_message().await; assert!([59, 60].contains(&(qr.message_due(message.queue_id).await - now_))); - assert!([59, 60].contains(&(message.next_delivery_event() - now_))); - assert!([3599, 3600].contains(&(message.domains.first().unwrap().expires - now_))); - assert!([54059, 54060].contains(&(message.domains.first().unwrap().notify.due - now_))); + assert!([59, 60].contains(&(message.message.next_delivery_event(None).unwrap() - now_))); + assert!( + [3599, 3600].contains( + &(message + .message + .recipients + .first() + .unwrap() + .expiration_time(message.message.created) + .unwrap() + - now_) + ) + ); + assert!( + [54059, 54060].contains(&(message.message.recipients.first().unwrap().notify.due - now_)) + ); // Test DELIVERBY (NOTIFY) session @@ -192,5 +224,7 @@ async fn queue_retry() { ) .await; let schedule = qr.expect_message().await; - assert!([3599, 3600].contains(&(schedule.domains.first().unwrap().notify.due - now()))); + assert!( + [3599, 3600].contains(&(schedule.message.recipients.first().unwrap().notify.due - now())) + ); } diff --git a/tests/src/smtp/reporting/dmarc.rs b/tests/src/smtp/reporting/dmarc.rs index 66e22ae4..c16b64cb 100644 --- a/tests/src/smtp/reporting/dmarc.rs +++ b/tests/src/smtp/reporting/dmarc.rs @@ -111,12 +111,12 @@ async fn report_dmarc() { // Expect report let message = qr.expect_message().await; qr.assert_no_events(); - assert_eq!(message.recipients.len(), 1); + assert_eq!(message.message.recipients.len(), 1); assert_eq!( - message.recipients.last().unwrap().address, + message.message.recipients.last().unwrap().address, "reports@foobar.net" ); - assert_eq!(message.return_path, "reports@example.org"); + assert_eq!(message.message.return_path, "reports@example.org"); message .read_lines(qr) .await diff --git a/tests/src/smtp/reporting/tls.rs b/tests/src/smtp/reporting/tls.rs index a72a4b1c..db910755 100644 --- a/tests/src/smtp/reporting/tls.rs +++ b/tests/src/smtp/reporting/tls.rs @@ -112,10 +112,10 @@ async fn report_tls() { // Expect report let message = qr.expect_message().await; assert_eq!( - message.recipients.last().unwrap().address, + message.message.recipients.last().unwrap().address, "reports@foobar.org" ); - assert_eq!(message.return_path, "reports@example.org"); + assert_eq!(message.message.return_path, "reports@example.org"); message .read_lines(qr) .await diff --git a/tests/src/smtp/session.rs b/tests/src/smtp/session.rs index d108068f..cf42f626 100644 --- a/tests/src/smtp/session.rs +++ b/tests/src/smtp/session.rs @@ -263,20 +263,11 @@ impl TestSession for Session { 0, ) .await; - assert_eq!( - message - .domains - .iter() - .map(|d| d.domain.clone()) - .collect::>(), - vec!["foobar.org".to_string(), "test.net".to_string()] - ); + let rcpts = ["a@foobar.org", "b@test.net", "c@foobar.org", "d@test.net"]; - let domain_idx = [0, 1, 0, 1]; - for rcpt in &message.recipients { + for rcpt in &message.message.recipients { let idx = (rcpt.flags - 1) as usize; assert_eq!(rcpts[idx], rcpt.address); - assert_eq!(domain_idx[idx], rcpt.domain_idx); } } } diff --git a/tests/src/store/import_export.rs b/tests/src/store/import_export.rs index 211ceb1a..6be030bc 100644 --- a/tests/src/store/import_export.rs +++ b/tests/src/store/import_export.rs @@ -4,6 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ +use crate::store::TempDir; use ahash::AHashSet; use common::{Core, manager::backup::BackupParams}; use jmap_proto::types::{ @@ -20,8 +21,6 @@ use store::{ }; use utils::BlobHash; -use crate::store::TempDir; - pub async fn test(db: Store) { let mut core = Core::default(); core.storage.data = db.clone(); @@ -164,6 +163,7 @@ pub async fn test(db: Store) { ValueClass::Queue(QueueClass::MessageEvent(QueueEvent { due: rand::random(), queue_id: rand::random(), + queue_name: rand::random(), })), random_bytes(idx), ); diff --git a/tests/src/webdav/mod.rs b/tests/src/webdav/mod.rs index 772f0070..dd768d1f 100644 --- a/tests/src/webdav/mod.rs +++ b/tests/src/webdav/mod.rs @@ -152,7 +152,7 @@ async fn init_webdav_tests(store_id: &str, delete_if_exists: bool) -> WebDavTest let cache = Caches::parse(&mut config); let store = core.storage.data.clone(); - let (ipc, mut ipc_rxs) = build_ipc(&mut config, false); + let (ipc, mut ipc_rxs) = build_ipc(false); let inner = Arc::new(Inner { shared_core: core.into_shared(), data, @@ -1065,21 +1065,12 @@ directory = "'{STORE}'" total = 5 wait = "1ms" -[queue] -path = "{TMP}" -hash = 64 - -[report] -path = "{TMP}" -hash = 64 - [resolver] type = "system" -[queue.outbound] -next-hop = [ { if = "rcpt_domain == 'example.com'", then = "'local'" }, - { if = "contains(['remote.org', 'foobar.com', 'test.com', 'other_domain.com'], rcpt_domain)", then = "'mock-smtp'" }, - { else = false } ] +[queue.strategy] +gateway = [ { if = "rcpt_domain == 'example.com'", then = "'local'" }, + { else = "'mx'" } ] [session.data.add-headers] delivered-to = false