Import upstream v0.16.22, stripped
Upstream commit: 474dd0229cb20cf513036619781ed97bd8073c3f Enterprise-only files removed or emptied: 63 Enterprise-only snippets removed: 117 in 50 files Dangling module declarations removed: 5 Cargo edits turning enterprise off: 14 Verification: clean Enterprise feature gates left for rebuilt features: 19 in 18 files Produced by tools/fork/strip.py. The full report is in docs/fork/strip-reports/ on main.
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
[package]
|
||||
name = "smtp"
|
||||
description = "Stalwart SMTP Server"
|
||||
authors = [ "Stalwart Labs LLC <[email protected]>"]
|
||||
repository = "https://github.com/stalwartlabs/smtp-server"
|
||||
homepage = "https://stalw.art/smtp"
|
||||
keywords = ["smtp", "email", "mail", "server"]
|
||||
categories = ["email"]
|
||||
license = "AGPL-3.0-only OR LicenseRef-SEL"
|
||||
version = "0.16.22"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
store = { path = "../store" }
|
||||
types = { path = "../types" }
|
||||
utils = { path = "../utils" }
|
||||
directory = { path = "../directory" }
|
||||
common = { path = "../common" }
|
||||
email = { path = "../email" }
|
||||
registry = { path = "../registry" }
|
||||
spam-filter = { path = "../spam-filter" }
|
||||
trc = { path = "../trc" }
|
||||
mail-auth = { version = "0.13", features = ["rkyv"] }
|
||||
mail-parser = { version = "0.11", features = ["full_encoding"] }
|
||||
mail-builder = { version = "1.0" }
|
||||
smtp-proto = { version = "0.2.3", features = ["rkyv", "serde"] }
|
||||
sieve-rs = { version = "0.7", features = ["rkyv"] }
|
||||
ahash = { version = "0.8" }
|
||||
rustls = { version = "0.23.43", default-features = false, features = ["std", "aws_lc_rs", "tls12"] }
|
||||
rustls-pki-types = { version = "1", features = ["std"] }
|
||||
rustls-webpki = { version = "0.103", default-features = false, features = ["std", "aws-lc-rs"] }
|
||||
tokio = { version = "1.53", features = ["full"] }
|
||||
tokio-rustls = { version = "0.26", default-features = false, features = ["aws_lc_rs", "tls12"] }
|
||||
sha2 = "0.11"
|
||||
parking_lot = "0.12"
|
||||
blake3 = "1.8"
|
||||
rand = "0.10.2"
|
||||
x509-parser = { version = "0.18", features = ["verify-aws"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["rustls", "http2"] }
|
||||
serde = { version = "1.0", features = ["derive", "rc"] }
|
||||
serde_json = "1.0"
|
||||
rkyv = { version = "0.8.18", features = ["little_endian"] }
|
||||
compact_str = "0.10.0"
|
||||
hashify = { version = "0.2" }
|
||||
base64 = "0.23"
|
||||
|
||||
[features]
|
||||
test_mode = ["mail-auth/test"]
|
||||
enterprise = []
|
||||
|
||||
#[[bench]]
|
||||
#name = "hash"
|
||||
#harness = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,315 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{inbound::auth::SaslToken, queue::QueueId};
|
||||
use common::{
|
||||
Inner, Server,
|
||||
auth::AccountInfo,
|
||||
config::smtp::auth::VerifyStrategy,
|
||||
network::{ServerInstance, asn::AsnGeoLookupResult},
|
||||
};
|
||||
use mail_auth::{IprevOutput, SpfOutput};
|
||||
use smtp_proto::request::receiver::{
|
||||
BdatReceiver, DataReceiver, DummyDataReceiver, DummyLineReceiver, LineReceiver, RequestReceiver,
|
||||
};
|
||||
use std::{
|
||||
hash::Hash,
|
||||
net::IpAddr,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
use utils::DomainPart;
|
||||
|
||||
pub mod params;
|
||||
pub mod throttle;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SmtpSessionManager {
|
||||
pub inner: Arc<Inner>,
|
||||
}
|
||||
|
||||
impl SmtpSessionManager {
|
||||
pub fn new(inner: Arc<Inner>) -> Self {
|
||||
Self { inner }
|
||||
}
|
||||
}
|
||||
|
||||
pub enum State {
|
||||
Request(RequestReceiver),
|
||||
Bdat(BdatReceiver),
|
||||
Data(DataReceiver),
|
||||
Sasl(LineReceiver<SaslToken>),
|
||||
SkipData(DummyDataReceiver, &'static [u8]),
|
||||
RequestTooLarge(DummyLineReceiver),
|
||||
Accepted(QueueId),
|
||||
None,
|
||||
}
|
||||
|
||||
pub struct Session<T: AsyncWrite + AsyncRead> {
|
||||
pub hostname: String,
|
||||
pub state: State,
|
||||
pub instance: Arc<ServerInstance>,
|
||||
pub server: Server,
|
||||
pub stream: T,
|
||||
pub data: SessionData,
|
||||
pub params: SessionParameters,
|
||||
}
|
||||
|
||||
pub struct SessionData {
|
||||
pub session_id: u64,
|
||||
pub local_ip: IpAddr,
|
||||
pub local_ip_str: String,
|
||||
pub local_port: u16,
|
||||
pub remote_ip: IpAddr,
|
||||
pub remote_ip_str: String,
|
||||
pub remote_port: u16,
|
||||
pub asn_geo_data: AsnGeoLookupResult,
|
||||
pub helo_domain: String,
|
||||
|
||||
pub mail_from: Option<SessionAddress>,
|
||||
pub rcpt_to: Vec<SessionAddress>,
|
||||
pub rcpt_errors: usize,
|
||||
pub rcpt_oks: usize,
|
||||
pub message: Vec<u8>,
|
||||
|
||||
pub authenticated_as: Option<AccountInfo>,
|
||||
pub auth_errors: usize,
|
||||
|
||||
pub priority: i16,
|
||||
pub delivery_by: i64,
|
||||
pub future_release: u64,
|
||||
|
||||
pub valid_until: Instant,
|
||||
pub bytes_left: usize,
|
||||
pub messages_sent: usize,
|
||||
|
||||
pub iprev: Option<IprevOutput>,
|
||||
pub spf_ehlo: Option<SpfOutput>,
|
||||
pub spf_mail_from: Option<SpfOutput>,
|
||||
pub dnsbl_error: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SessionAddress {
|
||||
pub address: String,
|
||||
pub address_lcase: String,
|
||||
pub domain: String,
|
||||
pub flags: u64,
|
||||
pub dsn_info: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SessionParameters {
|
||||
// Global parameters
|
||||
pub timeout: Duration,
|
||||
|
||||
// Ehlo parameters
|
||||
pub ehlo_require: bool,
|
||||
pub ehlo_reject_non_fqdn: bool,
|
||||
|
||||
// Auth parameters
|
||||
pub auth_require: bool,
|
||||
pub auth_errors_max: usize,
|
||||
pub auth_errors_wait: Duration,
|
||||
|
||||
// Rcpt parameters
|
||||
pub rcpt_errors_max: usize,
|
||||
pub rcpt_errors_wait: Duration,
|
||||
pub rcpt_max: usize,
|
||||
pub rcpt_dsn: bool,
|
||||
pub can_expn: bool,
|
||||
pub can_vrfy: bool,
|
||||
pub max_message_size: usize,
|
||||
|
||||
// Mail authentication parameters
|
||||
pub iprev: VerifyStrategy,
|
||||
pub spf_ehlo: VerifyStrategy,
|
||||
pub spf_mail_from: VerifyStrategy,
|
||||
}
|
||||
|
||||
impl SessionData {
|
||||
pub fn new(
|
||||
local_ip: IpAddr,
|
||||
local_port: u16,
|
||||
remote_ip: IpAddr,
|
||||
remote_port: u16,
|
||||
asn_geo_data: AsnGeoLookupResult,
|
||||
session_id: u64,
|
||||
) -> Self {
|
||||
SessionData {
|
||||
session_id,
|
||||
local_ip,
|
||||
local_port,
|
||||
remote_ip,
|
||||
local_ip_str: local_ip.to_string(),
|
||||
remote_ip_str: remote_ip.to_string(),
|
||||
remote_port,
|
||||
asn_geo_data,
|
||||
helo_domain: String::new(),
|
||||
mail_from: None,
|
||||
rcpt_to: Vec::new(),
|
||||
authenticated_as: None,
|
||||
priority: 0,
|
||||
valid_until: Instant::now(),
|
||||
rcpt_errors: 0,
|
||||
rcpt_oks: 0,
|
||||
message: Vec::with_capacity(0),
|
||||
auth_errors: 0,
|
||||
messages_sent: 0,
|
||||
bytes_left: 0,
|
||||
delivery_by: 0,
|
||||
future_release: 0,
|
||||
iprev: None,
|
||||
spf_ehlo: None,
|
||||
spf_mail_from: None,
|
||||
dnsbl_error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for State {
|
||||
fn default() -> Self {
|
||||
State::Request(RequestReceiver::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for SessionAddress {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.address_lcase == other.address_lcase
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for SessionAddress {}
|
||||
|
||||
impl Hash for SessionAddress {
|
||||
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||
self.address_lcase.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for SessionAddress {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
match self.domain.cmp(&other.domain) {
|
||||
std::cmp::Ordering::Equal => self.address_lcase.cmp(&other.address_lcase),
|
||||
order => order,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for SessionAddress {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Session<common::network::stream::NullIo> {
|
||||
pub fn local(
|
||||
server: Server,
|
||||
instance: std::sync::Arc<ServerInstance>,
|
||||
data: SessionData,
|
||||
) -> Self {
|
||||
Session {
|
||||
hostname: "localhost".into(),
|
||||
state: State::None,
|
||||
instance,
|
||||
server,
|
||||
stream: common::network::stream::NullIo::default(),
|
||||
data,
|
||||
params: SessionParameters {
|
||||
timeout: Default::default(),
|
||||
ehlo_require: Default::default(),
|
||||
ehlo_reject_non_fqdn: Default::default(),
|
||||
auth_require: Default::default(),
|
||||
auth_errors_max: Default::default(),
|
||||
auth_errors_wait: Default::default(),
|
||||
rcpt_errors_max: Default::default(),
|
||||
rcpt_errors_wait: Default::default(),
|
||||
rcpt_max: Default::default(),
|
||||
rcpt_dsn: Default::default(),
|
||||
max_message_size: Default::default(),
|
||||
iprev: VerifyStrategy::Disable,
|
||||
spf_ehlo: VerifyStrategy::Disable,
|
||||
spf_mail_from: VerifyStrategy::Disable,
|
||||
can_expn: false,
|
||||
can_vrfy: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_failed(&mut self) -> Option<String> {
|
||||
if self.stream.tx_buf.first().is_none_or(|&c| c == b'2') {
|
||||
self.stream.tx_buf.clear();
|
||||
None
|
||||
} else {
|
||||
let response = std::str::from_utf8(&self.stream.tx_buf)
|
||||
.unwrap()
|
||||
.trim()
|
||||
.into();
|
||||
self.stream.tx_buf.clear();
|
||||
Some(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionData {
|
||||
pub fn local(
|
||||
authenticated_as: AccountInfo,
|
||||
mail_from: Option<SessionAddress>,
|
||||
rcpt_to: Vec<SessionAddress>,
|
||||
message: Vec<u8>,
|
||||
session_id: u64,
|
||||
) -> Self {
|
||||
SessionData {
|
||||
local_ip: IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)),
|
||||
remote_ip: IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)),
|
||||
local_ip_str: "127.0.0.1".into(),
|
||||
remote_ip_str: "127.0.0.1".into(),
|
||||
remote_port: 0,
|
||||
local_port: 0,
|
||||
session_id,
|
||||
asn_geo_data: AsnGeoLookupResult::default(),
|
||||
helo_domain: "localhost".into(),
|
||||
mail_from,
|
||||
rcpt_to,
|
||||
rcpt_errors: 0,
|
||||
rcpt_oks: 0,
|
||||
message,
|
||||
authenticated_as: Some(authenticated_as),
|
||||
auth_errors: 0,
|
||||
priority: 0,
|
||||
delivery_by: 0,
|
||||
future_release: 0,
|
||||
valid_until: Instant::now(),
|
||||
bytes_left: 0,
|
||||
messages_sent: 0,
|
||||
iprev: None,
|
||||
spf_ehlo: None,
|
||||
spf_mail_from: None,
|
||||
dnsbl_error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionAddress {
|
||||
pub fn new(address: String) -> Self {
|
||||
let address_lcase = address.to_lowercase();
|
||||
SessionAddress {
|
||||
domain: address_lcase.domain_part().into(),
|
||||
address_lcase,
|
||||
address,
|
||||
flags: 0,
|
||||
dsn_info: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn report_address(&self) -> &str {
|
||||
self.dsn_info
|
||||
.as_ref()
|
||||
.and_then(|v| v.strip_prefix("rfc822;"))
|
||||
.unwrap_or(&self.address_lcase)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use super::Session;
|
||||
use common::{config::smtp::auth::VerifyStrategy, network::SessionStream};
|
||||
use std::time::Duration;
|
||||
|
||||
impl<T: SessionStream> Session<T> {
|
||||
pub async fn eval_session_params(&mut self) {
|
||||
let c = &self.server.core.smtp.session;
|
||||
self.data.bytes_left = self
|
||||
.server
|
||||
.eval_if(&c.transfer_limit, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or(250 * 1024 * 1024);
|
||||
self.data.valid_until += self
|
||||
.server
|
||||
.eval_if(&c.duration, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or_else(|| Duration::from_secs(15 * 60));
|
||||
|
||||
self.params.timeout = self
|
||||
.server
|
||||
.eval_if(&c.timeout, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or_else(|| Duration::from_secs(5 * 60));
|
||||
self.params.spf_ehlo = self
|
||||
.server
|
||||
.eval_if(
|
||||
&self.server.core.smtp.mail_auth.spf.verify_ehlo,
|
||||
self,
|
||||
self.data.session_id,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(VerifyStrategy::Relaxed);
|
||||
self.params.spf_mail_from = self
|
||||
.server
|
||||
.eval_if(
|
||||
&self.server.core.smtp.mail_auth.spf.verify_mail_from,
|
||||
self,
|
||||
self.data.session_id,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(VerifyStrategy::Relaxed);
|
||||
self.params.iprev = self
|
||||
.server
|
||||
.eval_if(
|
||||
&self.server.core.smtp.mail_auth.iprev.verify,
|
||||
self,
|
||||
self.data.session_id,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(VerifyStrategy::Relaxed);
|
||||
|
||||
// Ehlo parameters
|
||||
let ec = &self.server.core.smtp.session.ehlo;
|
||||
self.params.ehlo_require = self
|
||||
.server
|
||||
.eval_if(&ec.require, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or(true);
|
||||
self.params.ehlo_reject_non_fqdn = self
|
||||
.server
|
||||
.eval_if(&ec.reject_non_fqdn, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or(true);
|
||||
|
||||
// Auth parameters
|
||||
let ac = &self.server.core.smtp.session.auth;
|
||||
self.params.auth_require = self
|
||||
.server
|
||||
.eval_if(&ac.require, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
self.params.auth_errors_max = self
|
||||
.server
|
||||
.eval_if(&ac.errors_max, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or(3);
|
||||
self.params.auth_errors_wait = self
|
||||
.server
|
||||
.eval_if(&ac.errors_wait, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or_else(|| Duration::from_secs(30));
|
||||
|
||||
// VRFY/EXPN parameters
|
||||
let ec = &self.server.core.smtp.session.extensions;
|
||||
self.params.can_expn = self
|
||||
.server
|
||||
.eval_if(&ec.expn, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
self.params.can_vrfy = self
|
||||
.server
|
||||
.eval_if(&ec.vrfy, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
}
|
||||
|
||||
pub async fn eval_post_auth_params(&mut self) {
|
||||
// Refresh VRFY/EXPN parameters
|
||||
let ec = &self.server.core.smtp.session.extensions;
|
||||
self.params.can_expn = self
|
||||
.server
|
||||
.eval_if(&ec.expn, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
self.params.can_vrfy = self
|
||||
.server
|
||||
.eval_if(&ec.vrfy, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
}
|
||||
|
||||
pub async fn eval_rcpt_params(&mut self) {
|
||||
let rc = &self.server.core.smtp.session.rcpt;
|
||||
self.params.rcpt_errors_max = self
|
||||
.server
|
||||
.eval_if(&rc.errors_max, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or(10);
|
||||
self.params.rcpt_errors_wait = self
|
||||
.server
|
||||
.eval_if(&rc.errors_wait, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or_else(|| Duration::from_secs(30));
|
||||
self.params.rcpt_max = self
|
||||
.server
|
||||
.eval_if(&rc.max_recipients, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or(100);
|
||||
self.params.rcpt_dsn = self
|
||||
.server
|
||||
.eval_if(
|
||||
&self.server.core.smtp.session.extensions.dsn,
|
||||
self,
|
||||
self.data.session_id,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(true);
|
||||
|
||||
self.params.max_message_size = match self
|
||||
.server
|
||||
.eval_if::<usize, _>(
|
||||
&self.server.core.smtp.session.data.max_message_size,
|
||||
self,
|
||||
self.data.session_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Some(0) => usize::MAX,
|
||||
Some(max_message_size) => max_message_size,
|
||||
None => 25 * 1024 * 1024,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use super::Session;
|
||||
use common::{
|
||||
KV_RATE_LIMIT_SMTP, ThrottleKey, config::smtp::*, expr::functions::ResolveVariable,
|
||||
network::SessionStream,
|
||||
};
|
||||
use queue::QueueQuota;
|
||||
use registry::schema::{enums::ExpressionVariable, structs::Rate};
|
||||
use trc::SmtpEvent;
|
||||
|
||||
pub trait NewKey: Sized {
|
||||
fn new_key(&self, e: &impl ResolveVariable, context: &str) -> ThrottleKey;
|
||||
}
|
||||
|
||||
impl NewKey for QueueQuota {
|
||||
fn new_key(&self, e: &impl ResolveVariable, _: &str) -> ThrottleKey {
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
|
||||
if (self.keys & THROTTLE_RCPT) != 0 {
|
||||
hasher.update(
|
||||
e.resolve_variable(ExpressionVariable::Rcpt)
|
||||
.to_string()
|
||||
.as_bytes(),
|
||||
);
|
||||
}
|
||||
if (self.keys & THROTTLE_RCPT_DOMAIN) != 0 {
|
||||
hasher.update(
|
||||
e.resolve_variable(ExpressionVariable::RcptDomain)
|
||||
.to_string()
|
||||
.as_bytes(),
|
||||
);
|
||||
}
|
||||
if (self.keys & THROTTLE_SENDER) != 0 {
|
||||
let sender = e.resolve_variable(ExpressionVariable::Sender).into_string();
|
||||
hasher.update(
|
||||
if !sender.is_empty() {
|
||||
sender.as_ref()
|
||||
} else {
|
||||
"<>"
|
||||
}
|
||||
.as_bytes(),
|
||||
);
|
||||
}
|
||||
if (self.keys & THROTTLE_SENDER_DOMAIN) != 0 {
|
||||
let sender_domain = e
|
||||
.resolve_variable(ExpressionVariable::SenderDomain)
|
||||
.into_string();
|
||||
hasher.update(
|
||||
if !sender_domain.is_empty() {
|
||||
sender_domain.as_ref()
|
||||
} else {
|
||||
"<>"
|
||||
}
|
||||
.as_bytes(),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(messages) = &self.messages {
|
||||
hasher.update(&messages.to_ne_bytes()[..]);
|
||||
}
|
||||
|
||||
if let Some(size) = &self.size {
|
||||
hasher.update(&size.to_ne_bytes()[..]);
|
||||
}
|
||||
|
||||
ThrottleKey {
|
||||
hash: hasher.finalize().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NewKey for QueueRateLimiter {
|
||||
fn new_key(&self, e: &impl ResolveVariable, context: &str) -> ThrottleKey {
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
|
||||
if (self.keys & THROTTLE_RCPT) != 0 {
|
||||
hasher.update(
|
||||
e.resolve_variable(ExpressionVariable::Rcpt)
|
||||
.to_string()
|
||||
.as_bytes(),
|
||||
);
|
||||
}
|
||||
if (self.keys & THROTTLE_RCPT_DOMAIN) != 0 {
|
||||
hasher.update(
|
||||
e.resolve_variable(ExpressionVariable::RcptDomain)
|
||||
.to_string()
|
||||
.as_bytes(),
|
||||
);
|
||||
}
|
||||
if (self.keys & THROTTLE_SENDER) != 0 {
|
||||
let sender = e.resolve_variable(ExpressionVariable::Sender).into_string();
|
||||
hasher.update(
|
||||
if !sender.is_empty() {
|
||||
sender.as_ref()
|
||||
} else {
|
||||
"<>"
|
||||
}
|
||||
.as_bytes(),
|
||||
);
|
||||
}
|
||||
if (self.keys & THROTTLE_SENDER_DOMAIN) != 0 {
|
||||
let sender_domain = e
|
||||
.resolve_variable(ExpressionVariable::SenderDomain)
|
||||
.into_string();
|
||||
hasher.update(
|
||||
if !sender_domain.is_empty() {
|
||||
sender_domain.as_ref()
|
||||
} else {
|
||||
"<>"
|
||||
}
|
||||
.as_bytes(),
|
||||
);
|
||||
}
|
||||
if (self.keys & THROTTLE_HELO_DOMAIN) != 0 {
|
||||
hasher.update(
|
||||
e.resolve_variable(ExpressionVariable::HeloDomain)
|
||||
.to_string()
|
||||
.as_bytes(),
|
||||
);
|
||||
}
|
||||
if (self.keys & THROTTLE_AUTH_AS) != 0 {
|
||||
hasher.update(
|
||||
e.resolve_variable(ExpressionVariable::AuthenticatedAs)
|
||||
.to_string()
|
||||
.as_bytes(),
|
||||
);
|
||||
}
|
||||
if (self.keys & THROTTLE_LISTENER) != 0 {
|
||||
hasher.update(
|
||||
e.resolve_variable(ExpressionVariable::Listener)
|
||||
.to_string()
|
||||
.as_bytes(),
|
||||
);
|
||||
}
|
||||
if (self.keys & THROTTLE_MX) != 0 {
|
||||
hasher.update(
|
||||
e.resolve_variable(ExpressionVariable::Mx)
|
||||
.to_string()
|
||||
.as_bytes(),
|
||||
);
|
||||
}
|
||||
if (self.keys & THROTTLE_REMOTE_IP) != 0 {
|
||||
hasher.update(
|
||||
e.resolve_variable(ExpressionVariable::RemoteIp)
|
||||
.to_string()
|
||||
.as_bytes(),
|
||||
);
|
||||
}
|
||||
if (self.keys & THROTTLE_LOCAL_IP) != 0 {
|
||||
hasher.update(
|
||||
e.resolve_variable(ExpressionVariable::LocalIp)
|
||||
.to_string()
|
||||
.as_bytes(),
|
||||
);
|
||||
}
|
||||
hasher.update(&self.rate.period.as_secs().to_be_bytes()[..]);
|
||||
hasher.update(&self.rate.count.to_be_bytes()[..]);
|
||||
hasher.update(context.as_bytes());
|
||||
|
||||
ThrottleKey {
|
||||
hash: hasher.finalize().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: SessionStream> Session<T> {
|
||||
pub async fn is_allowed(&mut self) -> bool {
|
||||
let throttles = if !self.data.rcpt_to.is_empty() {
|
||||
&self.server.core.smtp.queue.inbound_limiters.rcpt
|
||||
} else if self.data.mail_from.is_some() {
|
||||
&self.server.core.smtp.queue.inbound_limiters.sender
|
||||
} else {
|
||||
&self.server.core.smtp.queue.inbound_limiters.remote
|
||||
};
|
||||
|
||||
for t in throttles {
|
||||
if t.expr.is_empty()
|
||||
|| self
|
||||
.server
|
||||
.eval_if(&t.expr, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
if (t.keys & THROTTLE_RCPT_DOMAIN) != 0 {
|
||||
let d = self
|
||||
.data
|
||||
.rcpt_to
|
||||
.last()
|
||||
.map(|r| r.domain.as_str())
|
||||
.unwrap_or_default();
|
||||
|
||||
if self.data.rcpt_to.iter().filter(|p| p.domain == d).count() > 1 {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Build throttle key
|
||||
let key = t.new_key(self, "inbound");
|
||||
|
||||
// Check rate
|
||||
match self
|
||||
.server
|
||||
.in_memory_store()
|
||||
.is_rate_allowed(KV_RATE_LIMIT_SMTP, key.hash.as_slice(), &t.rate, false)
|
||||
.await
|
||||
{
|
||||
Ok(Some(_)) => {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::RateLimitExceeded),
|
||||
SpanId = self.data.session_id,
|
||||
Id = t.id.to_string(),
|
||||
Limit = vec![
|
||||
trc::Value::from(t.rate.count),
|
||||
trc::Value::from(t.rate.period.into_inner())
|
||||
],
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
Err(err) => {
|
||||
trc::error!(
|
||||
err.span_id(self.data.session_id)
|
||||
.caused_by(trc::location!())
|
||||
);
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
pub async fn throttle_rcpt(&self, rcpt: &str, rate: &Rate, ctx: &str) -> bool {
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
hasher.update(rcpt.as_bytes());
|
||||
hasher.update(ctx.as_bytes());
|
||||
hasher.update(&rate.period.as_secs().to_ne_bytes()[..]);
|
||||
hasher.update(&rate.count.to_ne_bytes()[..]);
|
||||
|
||||
match self
|
||||
.server
|
||||
.in_memory_store()
|
||||
.is_rate_allowed(
|
||||
KV_RATE_LIMIT_SMTP,
|
||||
hasher.finalize().as_bytes(),
|
||||
rate,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(None) => true,
|
||||
Ok(Some(_)) => false,
|
||||
Err(err) => {
|
||||
trc::error!(
|
||||
err.span_id(self.data.session_id)
|
||||
.caused_by(trc::location!())
|
||||
);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::core::Session;
|
||||
use common::{auth::AuthRequest, network::SessionStream};
|
||||
use directory::Credentials;
|
||||
use mail_parser::decoders::base64::base64_decode;
|
||||
use registry::schema::enums::Permission;
|
||||
use smtp_proto::{AUTH_LOGIN, AUTH_OAUTHBEARER, AUTH_PLAIN, AUTH_XOAUTH2, IntoString};
|
||||
use trc::AuthEvent;
|
||||
|
||||
pub struct SaslToken {
|
||||
mechanism: u64,
|
||||
credentials: Credentials,
|
||||
}
|
||||
|
||||
impl SaslToken {
|
||||
pub fn from_mechanism(mechanism: u64) -> Option<SaslToken> {
|
||||
match mechanism {
|
||||
AUTH_PLAIN | AUTH_LOGIN => SaslToken {
|
||||
mechanism,
|
||||
credentials: Credentials::Basic {
|
||||
username: String::new(),
|
||||
secret: String::new(),
|
||||
mfa_token: None,
|
||||
},
|
||||
}
|
||||
.into(),
|
||||
AUTH_OAUTHBEARER | AUTH_XOAUTH2 => SaslToken {
|
||||
mechanism,
|
||||
credentials: Credentials::Bearer {
|
||||
username: None,
|
||||
token: String::new(),
|
||||
},
|
||||
}
|
||||
.into(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: SessionStream> Session<T> {
|
||||
pub async fn handle_sasl_response(
|
||||
&mut self,
|
||||
token: &mut SaslToken,
|
||||
response: &[u8],
|
||||
) -> Result<bool, ()> {
|
||||
if response.is_empty() {
|
||||
match (token.mechanism, &token.credentials) {
|
||||
(AUTH_PLAIN | AUTH_XOAUTH2 | AUTH_OAUTHBEARER, _) => {
|
||||
self.write(b"334 \r\n").await?;
|
||||
return Ok(true);
|
||||
}
|
||||
(
|
||||
AUTH_LOGIN,
|
||||
Credentials::Basic {
|
||||
username, secret, ..
|
||||
},
|
||||
) if username.is_empty() && secret.is_empty() => {
|
||||
self.write(b"334 VXNlcm5hbWU6\r\n").await?;
|
||||
return Ok(true);
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
} else if let Some(response) = base64_decode(response) {
|
||||
match (token.mechanism, &mut token.credentials) {
|
||||
(AUTH_PLAIN, _) => {
|
||||
if let Some(credentials) = Credentials::decode_sasl_challenge_plain(&response) {
|
||||
return self.authenticate(credentials).await;
|
||||
}
|
||||
}
|
||||
(
|
||||
AUTH_LOGIN,
|
||||
Credentials::Basic {
|
||||
username, secret, ..
|
||||
},
|
||||
) => {
|
||||
return if username.is_empty() {
|
||||
*username = response.into_string();
|
||||
self.write(b"334 UGFzc3dvcmQ6\r\n").await?;
|
||||
Ok(true)
|
||||
} else {
|
||||
*secret = response.into_string();
|
||||
self.authenticate(std::mem::replace(
|
||||
&mut token.credentials,
|
||||
Credentials::Basic {
|
||||
username: String::new(),
|
||||
secret: String::new(),
|
||||
mfa_token: None,
|
||||
},
|
||||
))
|
||||
.await
|
||||
};
|
||||
}
|
||||
(AUTH_OAUTHBEARER | AUTH_XOAUTH2, _) => {
|
||||
if let Some(credentials) = Credentials::decode_sasl_challenge_oauth(&response) {
|
||||
return self.authenticate(credentials).await;
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
self.auth_error(b"500 5.5.6 Invalid challenge.\r\n").await
|
||||
}
|
||||
|
||||
pub async fn authenticate(&mut self, credentials: Credentials) -> Result<bool, ()> {
|
||||
// Authenticate
|
||||
let result = self
|
||||
.server
|
||||
.authenticate(&AuthRequest::from_credentials(
|
||||
credentials,
|
||||
self.data.session_id,
|
||||
self.data.remote_ip,
|
||||
))
|
||||
.await
|
||||
.and_then(|access_token| access_token.assert_has_permission(Permission::EmailSend));
|
||||
|
||||
let result = match result {
|
||||
Ok(access_token) => self.server.account_info(access_token.account_id()).await,
|
||||
Err(err) => Err(err),
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(account_info) => {
|
||||
self.data.authenticated_as = account_info.into();
|
||||
self.eval_post_auth_params().await;
|
||||
self.write(b"235 2.7.0 Authentication succeeded.\r\n")
|
||||
.await?;
|
||||
return Ok(false);
|
||||
}
|
||||
Err(err) => {
|
||||
let reason = *err.as_ref();
|
||||
|
||||
trc::error!(err.span_id(self.data.session_id));
|
||||
|
||||
match reason {
|
||||
trc::EventType::Auth(trc::AuthEvent::Failed) => {
|
||||
return self
|
||||
.auth_error(b"535 5.7.8 Authentication credentials invalid.\r\n")
|
||||
.await;
|
||||
}
|
||||
trc::EventType::Auth(trc::AuthEvent::TokenExpired) => {
|
||||
return self.auth_error(b"535 5.7.8 OAuth token expired.\r\n").await;
|
||||
}
|
||||
trc::EventType::Auth(trc::AuthEvent::MfaRequired) => {
|
||||
return self
|
||||
.auth_error(
|
||||
concat!(
|
||||
"334 5.7.8 This account requires multi-factor authentication. ",
|
||||
"Alternatively, you can use an app password if your account has one.\r\n"
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
trc::EventType::Security(trc::SecurityEvent::Unauthorized) => {
|
||||
self.write(
|
||||
concat!(
|
||||
"550 5.7.1 Your account is not authorized ",
|
||||
"to use this service.\r\n"
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.await?;
|
||||
return Ok(false);
|
||||
}
|
||||
trc::EventType::Security(_) => {
|
||||
return Err(());
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.write(b"454 4.7.0 Temporary authentication failure\r\n")
|
||||
.await?;
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub async fn auth_error(&mut self, response: &[u8]) -> Result<bool, ()> {
|
||||
tokio::time::sleep(self.params.auth_errors_wait).await;
|
||||
self.data.auth_errors += 1;
|
||||
self.write(response).await?;
|
||||
if self.data.auth_errors < self.params.auth_errors_max {
|
||||
Ok(false)
|
||||
} else {
|
||||
trc::event!(
|
||||
Auth(AuthEvent::TooManyAttempts),
|
||||
SpanId = self.data.session_id,
|
||||
);
|
||||
|
||||
self.write(b"455 4.3.0 Too many authentication errors, disconnecting.\r\n")
|
||||
.await?;
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn authenticated_as(&self) -> Option<&str> {
|
||||
self.data
|
||||
.authenticated_as
|
||||
.as_ref()
|
||||
.map(|authenticated_as| authenticated_as.name())
|
||||
}
|
||||
|
||||
pub fn is_authenticated(&self) -> bool {
|
||||
self.data.authenticated_as.is_some()
|
||||
}
|
||||
|
||||
pub fn authenticated_emails(&self) -> &[String] {
|
||||
self.data.authenticated_as.as_ref().unwrap().addresses()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,418 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::queue::{MessageWrapper, Metadata, spool::QueueParams};
|
||||
use common::{
|
||||
Server,
|
||||
config::smtp::auth::{Dkim1Signer, DkimSigners},
|
||||
expr::{functions::ResolveVariable, if_block::IfBlock},
|
||||
};
|
||||
use mail_auth::{
|
||||
AuthenticatedMessage,
|
||||
common::headers::HeaderWriter,
|
||||
dkim2::{Hop, MessageInstance},
|
||||
};
|
||||
use mail_parser::{Address, parsers::MessageStream};
|
||||
use std::{collections::HashSet, sync::Arc};
|
||||
use utils::sanitize_email;
|
||||
|
||||
pub(crate) trait DkimSign: Sync + Send {
|
||||
fn sign_message(
|
||||
&self,
|
||||
message: &mut MessageWrapper,
|
||||
params: &mut QueueParams<'_, '_>,
|
||||
) -> impl Future<Output = Option<Vec<u8>>> + Send;
|
||||
|
||||
fn eval_signers(
|
||||
&self,
|
||||
if_block: &IfBlock,
|
||||
resolver: &impl ResolveVariable,
|
||||
session_id: u64,
|
||||
) -> impl Future<Output = Option<Arc<DkimSigners>>> + Send;
|
||||
}
|
||||
|
||||
impl DkimSign for Server {
|
||||
async fn sign_message(
|
||||
&self,
|
||||
message: &mut MessageWrapper,
|
||||
params: &mut QueueParams<'_, '_>,
|
||||
) -> Option<Vec<u8>> {
|
||||
let signers = params.dkim_signers.as_ref().unwrap();
|
||||
let raw_message = params.raw_message;
|
||||
|
||||
// DKIM1 signing
|
||||
let mut headers = Vec::with_capacity(64);
|
||||
for signer in &signers.dkim1 {
|
||||
let result = match (signer, params.raw_headers) {
|
||||
(Dkim1Signer::RsaSha256(signer), None) => signer.sign(raw_message),
|
||||
(Dkim1Signer::Ed25519Sha256(signer), None) => signer.sign(raw_message),
|
||||
(Dkim1Signer::RsaSha256(signer), Some(headers)) => {
|
||||
signer.sign_chained([headers, raw_message].iter().copied())
|
||||
}
|
||||
(Dkim1Signer::Ed25519Sha256(signer), Some(headers)) => {
|
||||
signer.sign_chained([headers, raw_message].iter().copied())
|
||||
}
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(signature) => {
|
||||
signature.write_header(&mut headers);
|
||||
}
|
||||
Err(err) => {
|
||||
trc::error!(
|
||||
trc::Error::from(err)
|
||||
.span_id(params.session_id)
|
||||
.details("Failed to sign message")
|
||||
.caused_by(trc::location!())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DKIM2 signing
|
||||
if let Some(signer) = &signers.dkim2
|
||||
&& let Some(modified) =
|
||||
AuthenticatedMessage::parse_with_opts(raw_message, params.raw_headers, true)
|
||||
{
|
||||
// Generate message instance
|
||||
let original = params.original_authenticated_message.take().or_else(|| {
|
||||
params
|
||||
.original_raw_message
|
||||
.and_then(AuthenticatedMessage::parse)
|
||||
});
|
||||
let instance = MessageInstance::from_message(&modified, original.as_ref());
|
||||
if let Some(instance) = &instance {
|
||||
instance.write_header(&mut headers);
|
||||
}
|
||||
|
||||
// Obtain disclosed and undisclosed recipients
|
||||
let envelopes = message.undisclosed_recipients(&modified);
|
||||
|
||||
// Generate DKIM2 signature for disclosed recipients
|
||||
if !envelopes.disclosed_recipients.is_empty() {
|
||||
match signer.sign_with_message_instance(
|
||||
&modified,
|
||||
instance.as_ref(),
|
||||
Hop::real(
|
||||
message.message.return_path.as_ref(),
|
||||
envelopes.disclosed_recipients,
|
||||
),
|
||||
) {
|
||||
Ok(signature) => {
|
||||
if envelopes.undisclosed_recipients.is_empty() {
|
||||
// Happy path: no undisclosed recipients, serialize signature straight to blob
|
||||
signature.write_header(&mut headers);
|
||||
} else {
|
||||
// Undisclosed recipients present, serialize signature to metadata
|
||||
let mut header = Vec::with_capacity(64);
|
||||
signature.write_header(&mut header);
|
||||
params.metadata.push(Metadata::Headers {
|
||||
value: header.into_boxed_slice(),
|
||||
id: u64::MAX,
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
trc::error!(
|
||||
trc::Error::from(err)
|
||||
.span_id(params.session_id)
|
||||
.details("Failed to DKIM2 sign message")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate DKIM2 signature for undisclosed recipients
|
||||
for (pos, rcpt) in envelopes.undisclosed_recipients {
|
||||
match signer.sign_with_message_instance(
|
||||
&modified,
|
||||
instance.as_ref(),
|
||||
Hop::real(message.message.return_path.as_ref(), [rcpt]),
|
||||
) {
|
||||
Ok(signature) => {
|
||||
// Serialize signature to metadata
|
||||
let mut header = Vec::with_capacity(64);
|
||||
signature.write_header(&mut header);
|
||||
params.metadata.push(Metadata::Headers {
|
||||
value: header.into_boxed_slice(),
|
||||
id: pos as u64,
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
trc::error!(
|
||||
trc::Error::from(err)
|
||||
.span_id(params.session_id)
|
||||
.details("Failed to DKIM2 sign message")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(!headers.is_empty()).then_some(headers)
|
||||
}
|
||||
|
||||
async fn eval_signers(
|
||||
&self,
|
||||
if_block: &IfBlock,
|
||||
resolver: &impl ResolveVariable,
|
||||
session_id: u64,
|
||||
) -> Option<Arc<DkimSigners>> {
|
||||
let sign_with_domain = self
|
||||
.eval_if::<String, _>(if_block, resolver, session_id)
|
||||
.await?;
|
||||
match self.dkim_signers(&sign_with_domain).await {
|
||||
Ok(signers) => signers,
|
||||
Err(err) => {
|
||||
trc::error!(
|
||||
err.span_id(session_id)
|
||||
.details("Failed to retrieve DKIM signers")
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Dkim2Envelopes<'x> {
|
||||
undisclosed_recipients: Vec<(usize, &'x str)>,
|
||||
disclosed_recipients: Vec<&'x str>,
|
||||
}
|
||||
|
||||
impl MessageWrapper {
|
||||
fn undisclosed_recipients<'x>(
|
||||
&'x self,
|
||||
message: &AuthenticatedMessage<'_>,
|
||||
) -> Dkim2Envelopes<'x> {
|
||||
if self.message.recipients.len() == 1 {
|
||||
return Dkim2Envelopes {
|
||||
undisclosed_recipients: Vec::new(),
|
||||
disclosed_recipients: vec![self.message.recipients[0].address.as_ref()],
|
||||
};
|
||||
}
|
||||
|
||||
let mut recipients = HashSet::with_capacity(self.message.recipients.len());
|
||||
|
||||
for addr in message.headers.iter().filter_map(|(name, value)| {
|
||||
let name = name.trim_ascii();
|
||||
if name.len() == 2
|
||||
&& (name.eq_ignore_ascii_case(b"to") || name.eq_ignore_ascii_case(b"cc"))
|
||||
{
|
||||
MessageStream::new(value).parse_address().into_address()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}) {
|
||||
match addr {
|
||||
Address::List(addrs) => {
|
||||
recipients.extend(
|
||||
addrs
|
||||
.iter()
|
||||
.filter_map(|a| a.address())
|
||||
.map(sanitize_or_lower),
|
||||
);
|
||||
}
|
||||
Address::Group(groups) => {
|
||||
for group in groups {
|
||||
recipients.extend(
|
||||
group
|
||||
.addresses
|
||||
.iter()
|
||||
.filter_map(|a| a.address())
|
||||
.map(sanitize_or_lower),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut undisclosed_recipients = Vec::new();
|
||||
let mut disclosed_recipients = Vec::new();
|
||||
for (i, rcpt) in self.message.recipients.iter().enumerate() {
|
||||
if !recipients.contains(rcpt.address.as_ref())
|
||||
&& !recipients.contains(&sanitize_or_lower(&rcpt.address))
|
||||
{
|
||||
undisclosed_recipients.push((i, rcpt.address.as_ref()));
|
||||
} else {
|
||||
disclosed_recipients.push(rcpt.address.as_ref());
|
||||
}
|
||||
}
|
||||
|
||||
Dkim2Envelopes {
|
||||
undisclosed_recipients,
|
||||
disclosed_recipients,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_or_lower(rcpt: &str) -> String {
|
||||
sanitize_email(rcpt).unwrap_or_else(|| rcpt.to_lowercase())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::queue::{Message, QueueId, Recipient};
|
||||
use common::config::smtp::queue::QueueName;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
|
||||
fn wrapper(recipients: &[&str]) -> MessageWrapper {
|
||||
MessageWrapper {
|
||||
queue_id: 0 as QueueId,
|
||||
queue_name: QueueName::default(),
|
||||
is_multi_queue: false,
|
||||
span_id: 0,
|
||||
message: Message {
|
||||
created: 0,
|
||||
blob_hash: Default::default(),
|
||||
return_path: "[email protected]".into(),
|
||||
recipients: recipients.iter().map(Recipient::new).collect(),
|
||||
received_from_ip: IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
received_via_port: 0,
|
||||
flags: 0,
|
||||
env_id: None,
|
||||
priority: 0,
|
||||
size: 0,
|
||||
metadata: Default::default(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn split(headers: &str, recipients: &[&str]) -> (Vec<String>, Vec<String>) {
|
||||
let raw = format!("{headers}\r\nSubject: test\r\n\r\nbody\r\n");
|
||||
let auth = AuthenticatedMessage::parse(raw.as_bytes()).expect("parse message");
|
||||
let message = wrapper(recipients);
|
||||
let envelopes = message.undisclosed_recipients(&auth);
|
||||
let mut disclosed = envelopes
|
||||
.disclosed_recipients
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
let mut undisclosed = envelopes
|
||||
.undisclosed_recipients
|
||||
.iter()
|
||||
.map(|(_, s)| s.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
disclosed.sort();
|
||||
undisclosed.sort();
|
||||
(disclosed, undisclosed)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_recipient_is_always_disclosed() {
|
||||
let (disclosed, undisclosed) = split("To: [email protected]", &["[email protected]"]);
|
||||
assert_eq!(disclosed, vec!["[email protected]".to_string()]);
|
||||
assert!(undisclosed.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_recipients_disclosed() {
|
||||
let (disclosed, undisclosed) = split(
|
||||
"To: [email protected], [email protected]\r\nCc: [email protected]",
|
||||
&["[email protected]", "[email protected]", "[email protected]"],
|
||||
);
|
||||
assert_eq!(
|
||||
disclosed,
|
||||
vec![
|
||||
"[email protected]".to_string(),
|
||||
"[email protected]".to_string(),
|
||||
"[email protected]".to_string(),
|
||||
]
|
||||
);
|
||||
assert!(undisclosed.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_disclosed_and_undisclosed() {
|
||||
let (disclosed, undisclosed) = split(
|
||||
"To: [email protected]\r\nCc: [email protected]",
|
||||
&[
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
"[email protected]",
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
disclosed,
|
||||
vec![
|
||||
"[email protected]".to_string(),
|
||||
"[email protected]".to_string()
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
undisclosed,
|
||||
vec![
|
||||
"[email protected]".to_string(),
|
||||
"[email protected]".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_to_or_cc_header_all_undisclosed() {
|
||||
let (disclosed, undisclosed) = split(
|
||||
"From: [email protected]",
|
||||
&["[email protected]", "[email protected]"],
|
||||
);
|
||||
assert!(disclosed.is_empty());
|
||||
assert_eq!(
|
||||
undisclosed,
|
||||
vec![
|
||||
"[email protected]".to_string(),
|
||||
"[email protected]".to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_addresses_are_disclosed() {
|
||||
let (disclosed, undisclosed) = split(
|
||||
"To: Team:[email protected],[email protected];",
|
||||
&["[email protected]", "[email protected]", "[email protected]"],
|
||||
);
|
||||
assert_eq!(
|
||||
disclosed,
|
||||
vec![
|
||||
"[email protected]".to_string(),
|
||||
"[email protected]".to_string()
|
||||
]
|
||||
);
|
||||
assert_eq!(undisclosed, vec!["[email protected]".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_address_casing_is_ignored() {
|
||||
let (disclosed, undisclosed) = split(
|
||||
"To: [email protected], [email protected]",
|
||||
&["[email protected]", "[email protected]", "[email protected]"],
|
||||
);
|
||||
assert_eq!(
|
||||
disclosed,
|
||||
vec![
|
||||
"[email protected]".to_string(),
|
||||
"[email protected]".to_string()
|
||||
]
|
||||
);
|
||||
assert_eq!(undisclosed, vec!["[email protected]".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_names_and_brackets_are_ignored() {
|
||||
let (disclosed, undisclosed) = split(
|
||||
"To: \"Alice Doe\" <[email protected]>\r\nCc: Bob <[email protected]>",
|
||||
&["[email protected]", "[email protected]", "[email protected]"],
|
||||
);
|
||||
assert_eq!(
|
||||
disclosed,
|
||||
vec![
|
||||
"[email protected]".to_string(),
|
||||
"[email protected]".to_string()
|
||||
]
|
||||
);
|
||||
assert_eq!(undisclosed, vec!["[email protected]".to_string()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{core::Session, scripts::ScriptResult};
|
||||
use common::{
|
||||
config::smtp::session::{Mechanism, Stage},
|
||||
network::SessionStream,
|
||||
};
|
||||
use mail_auth::{
|
||||
SpfResult,
|
||||
spf::verify::{HasValidLabels, SpfParameters},
|
||||
};
|
||||
use smtp_proto::*;
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
time::{Duration, Instant, SystemTime},
|
||||
};
|
||||
use trc::SmtpEvent;
|
||||
|
||||
impl<T: SessionStream> Session<T> {
|
||||
pub async fn handle_ehlo(&mut self, domain: Cow<'_, str>, is_extended: bool) -> Result<(), ()> {
|
||||
// Set EHLO domain
|
||||
|
||||
if domain != self.data.helo_domain {
|
||||
// Reject non-FQDN EHLO domains - simply checks that the hostname has at least one dot
|
||||
if self.params.ehlo_reject_non_fqdn && !domain.as_ref().has_valid_labels() {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::InvalidEhlo),
|
||||
SpanId = self.data.session_id,
|
||||
Domain = domain.as_ref().to_string(),
|
||||
);
|
||||
|
||||
return self.write(b"550 5.5.0 Invalid EHLO domain.\r\n").await;
|
||||
}
|
||||
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::Ehlo),
|
||||
SpanId = self.data.session_id,
|
||||
Domain = domain.as_ref().to_string(),
|
||||
);
|
||||
|
||||
// SPF check
|
||||
let prev_helo_domain =
|
||||
std::mem::replace(&mut self.data.helo_domain, domain.into_owned());
|
||||
if self.params.spf_ehlo.verify() {
|
||||
let time = Instant::now();
|
||||
let spf_output = self
|
||||
.server
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.verify_spf(self.server.inner.cache.build_auth_parameters(
|
||||
SpfParameters::verify_ehlo(
|
||||
self.data.remote_ip,
|
||||
&self.data.helo_domain,
|
||||
&self.hostname,
|
||||
),
|
||||
))
|
||||
.await;
|
||||
|
||||
trc::event!(
|
||||
Smtp(if matches!(spf_output.result(), SpfResult::Pass) {
|
||||
SmtpEvent::SpfEhloPass
|
||||
} else {
|
||||
SmtpEvent::SpfEhloFail
|
||||
}),
|
||||
SpanId = self.data.session_id,
|
||||
Domain = self.data.helo_domain.clone(),
|
||||
Result = trc::Error::from(&spf_output),
|
||||
Elapsed = time.elapsed(),
|
||||
);
|
||||
|
||||
if self
|
||||
.handle_spf(&spf_output, self.params.spf_ehlo.is_strict())
|
||||
.await?
|
||||
{
|
||||
self.data.spf_ehlo = spf_output.into();
|
||||
} else {
|
||||
self.data.mail_from = None;
|
||||
self.data.helo_domain = prev_helo_domain;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Sieve filtering
|
||||
if let Some((script, script_id)) = self
|
||||
.server
|
||||
.eval_if::<String, _>(
|
||||
&self.server.core.smtp.session.ehlo.script,
|
||||
self,
|
||||
self.data.session_id,
|
||||
)
|
||||
.await
|
||||
.and_then(|name| {
|
||||
self.server
|
||||
.get_trusted_sieve_script(&name, self.data.session_id)
|
||||
.map(|s| (s, name))
|
||||
})
|
||||
&& let ScriptResult::Reject(message) = self
|
||||
.run_script(
|
||||
script_id,
|
||||
script.clone(),
|
||||
self.build_script_parameters("ehlo"),
|
||||
)
|
||||
.await
|
||||
{
|
||||
self.data.mail_from = None;
|
||||
self.data.helo_domain = prev_helo_domain;
|
||||
self.data.spf_ehlo = None;
|
||||
return self.write(message.as_bytes()).await;
|
||||
}
|
||||
|
||||
// Milter filtering
|
||||
if let Err(message) = self.run_milters(Stage::Ehlo, None, None).await {
|
||||
self.data.mail_from = None;
|
||||
self.data.helo_domain = prev_helo_domain;
|
||||
self.data.spf_ehlo = None;
|
||||
return self.write(message.message.as_bytes()).await;
|
||||
}
|
||||
|
||||
// MTAHook filtering
|
||||
if let Err(message) = self.run_mta_hooks(Stage::Ehlo, None, None).await {
|
||||
self.data.mail_from = None;
|
||||
self.data.helo_domain = prev_helo_domain;
|
||||
self.data.spf_ehlo = None;
|
||||
return self.write(message.message.as_bytes()).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset
|
||||
if self.data.mail_from.is_some() {
|
||||
self.reset();
|
||||
}
|
||||
|
||||
if !is_extended {
|
||||
return self
|
||||
.write(format!("250 {} you had me at HELO\r\n", self.hostname).as_bytes())
|
||||
.await;
|
||||
}
|
||||
|
||||
let mut response = EhloResponse::new(self.hostname.as_str());
|
||||
response.capabilities =
|
||||
EXT_ENHANCED_STATUS_CODES | EXT_8BIT_MIME | EXT_BINARY_MIME | EXT_SMTP_UTF8;
|
||||
if !self.stream.is_tls() && self.instance.acceptor.is_tls() {
|
||||
response.capabilities |= EXT_START_TLS;
|
||||
}
|
||||
let ec = &self.server.core.smtp.session.extensions;
|
||||
let ac = &self.server.core.smtp.session.auth;
|
||||
let dc = &self.server.core.smtp.session.data;
|
||||
|
||||
// Pipelining
|
||||
if self
|
||||
.server
|
||||
.eval_if(&ec.pipelining, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or(true)
|
||||
{
|
||||
response.capabilities |= EXT_PIPELINING;
|
||||
}
|
||||
|
||||
// Chunking
|
||||
if self
|
||||
.server
|
||||
.eval_if(&ec.chunking, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or(true)
|
||||
{
|
||||
response.capabilities |= EXT_CHUNKING;
|
||||
}
|
||||
|
||||
// Address Expansion
|
||||
if self
|
||||
.server
|
||||
.eval_if(&ec.expn, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
response.capabilities |= EXT_EXPN;
|
||||
}
|
||||
|
||||
// Recipient Verification
|
||||
if self
|
||||
.server
|
||||
.eval_if(&ec.vrfy, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
response.capabilities |= EXT_VRFY;
|
||||
}
|
||||
|
||||
// Require TLS
|
||||
if self
|
||||
.server
|
||||
.eval_if(&ec.requiretls, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or(true)
|
||||
{
|
||||
response.capabilities |= EXT_REQUIRE_TLS;
|
||||
}
|
||||
|
||||
// DSN
|
||||
if self
|
||||
.server
|
||||
.eval_if(&ec.dsn, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
response.capabilities |= EXT_DSN;
|
||||
}
|
||||
|
||||
// Authentication
|
||||
if !self.is_authenticated() {
|
||||
response.auth_mechanisms = self
|
||||
.server
|
||||
.eval_if::<Mechanism, _>(&ac.mechanisms, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into();
|
||||
if response.auth_mechanisms != 0 {
|
||||
response.capabilities |= EXT_AUTH;
|
||||
}
|
||||
}
|
||||
|
||||
// Future release
|
||||
if let Some(value) = self
|
||||
.server
|
||||
.eval_if::<Duration, _>(&ec.future_release, self, self.data.session_id)
|
||||
.await
|
||||
{
|
||||
response.capabilities |= EXT_FUTURE_RELEASE;
|
||||
response.future_release_interval = value.as_secs();
|
||||
response.future_release_datetime = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
+ value.as_secs();
|
||||
}
|
||||
|
||||
// Deliver By
|
||||
if let Some(value) = self
|
||||
.server
|
||||
.eval_if::<Duration, _>(&ec.deliver_by, self, self.data.session_id)
|
||||
.await
|
||||
{
|
||||
response.capabilities |= EXT_DELIVER_BY;
|
||||
response.deliver_by = value.as_secs();
|
||||
}
|
||||
|
||||
// Priority
|
||||
if let Some(value) = self
|
||||
.server
|
||||
.eval_if::<MtPriority, _>(&ec.mt_priority, self, self.data.session_id)
|
||||
.await
|
||||
{
|
||||
response.capabilities |= EXT_MT_PRIORITY;
|
||||
response.mt_priority = value;
|
||||
}
|
||||
|
||||
// Size
|
||||
response.size = self
|
||||
.server
|
||||
.eval_if(&dc.max_message_size, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or(25 * 1024 * 1024);
|
||||
if response.size > 0 {
|
||||
response.capabilities |= EXT_SIZE;
|
||||
}
|
||||
|
||||
// No soliciting
|
||||
if let Some(value) = self
|
||||
.server
|
||||
.eval_if::<String, _>(&ec.no_soliciting, self, self.data.session_id)
|
||||
.await
|
||||
{
|
||||
response.capabilities |= EXT_NO_SOLICITING;
|
||||
response.no_soliciting = if !value.is_empty() {
|
||||
value.to_string().into()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
|
||||
// Generate response
|
||||
let mut buf = Vec::with_capacity(64);
|
||||
response.write(&mut buf).ok();
|
||||
self.write(&buf).await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use common::config::smtp::session::MTAHook;
|
||||
use utils::HttpLimitResponse;
|
||||
|
||||
use super::{Request, Response};
|
||||
|
||||
pub(super) async fn send_mta_hook_request(
|
||||
mta_hook: &MTAHook,
|
||||
request: Request,
|
||||
) -> Result<Response, String> {
|
||||
let response = mta_hook
|
||||
.client
|
||||
.post(&mta_hook.url)
|
||||
.timeout(mta_hook.timeout)
|
||||
.headers(mta_hook.headers.clone())
|
||||
.body(
|
||||
serde_json::to_string(&request)
|
||||
.map_err(|err| format!("Failed to serialize Hook request: {}", err))?,
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("Hook request failed: {err}"))?;
|
||||
|
||||
if response.status().is_success() {
|
||||
serde_json::from_slice(
|
||||
response
|
||||
.bytes_with_limit(mta_hook.max_response_size)
|
||||
.await
|
||||
.map_err(|err| format!("Failed to parse Hook response: {}", err))?
|
||||
.ok_or_else(|| "Hook response too large".to_string())?
|
||||
.as_ref(),
|
||||
)
|
||||
.map_err(|err| format!("Failed to parse Hook response: {}", err))
|
||||
} else {
|
||||
Err(format!(
|
||||
"Hook request failed with code {}: {}",
|
||||
response.status().as_u16(),
|
||||
response.status().canonical_reason().unwrap_or("Unknown")
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use super::{Action, Queue, Response, client::send_mta_hook_request};
|
||||
use crate::{
|
||||
core::Session,
|
||||
inbound::{
|
||||
FilterResponse,
|
||||
hooks::{
|
||||
Address, Client, Context, Envelope, Message, Protocol, Request, Sasl, Server, Tls,
|
||||
},
|
||||
milter::Modification,
|
||||
},
|
||||
queue::QueueId,
|
||||
};
|
||||
use ahash::AHashMap;
|
||||
use common::{
|
||||
DAEMON_NAME,
|
||||
config::smtp::session::{MTAHook, Stage},
|
||||
network::SessionStream,
|
||||
};
|
||||
use mail_auth::AuthenticatedMessage;
|
||||
use std::time::Instant;
|
||||
use trc::MtaHookEvent;
|
||||
|
||||
impl<T: SessionStream> Session<T> {
|
||||
pub async fn run_mta_hooks(
|
||||
&self,
|
||||
stage: Stage,
|
||||
message: Option<&AuthenticatedMessage<'_>>,
|
||||
queue_id: Option<QueueId>,
|
||||
) -> Result<Vec<Modification>, FilterResponse> {
|
||||
let mta_hooks = &self.server.core.smtp.session.hooks;
|
||||
if mta_hooks.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut modifications = Vec::new();
|
||||
for mta_hook in mta_hooks {
|
||||
if !mta_hook.run_on_stage.contains(&stage)
|
||||
|| !self
|
||||
.server
|
||||
.eval_if(&mta_hook.enable, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let time = Instant::now();
|
||||
match self.run_mta_hook(stage, mta_hook, message, queue_id).await {
|
||||
Ok(response) => {
|
||||
trc::event!(
|
||||
MtaHook(match response.action {
|
||||
Action::Accept => MtaHookEvent::ActionAccept,
|
||||
Action::Discard => MtaHookEvent::ActionDiscard,
|
||||
Action::Reject => MtaHookEvent::ActionReject,
|
||||
Action::Quarantine => MtaHookEvent::ActionQuarantine,
|
||||
}),
|
||||
SpanId = self.data.session_id,
|
||||
QueueId = queue_id,
|
||||
Id = mta_hook.id.to_string(),
|
||||
Elapsed = time.elapsed(),
|
||||
);
|
||||
|
||||
let mut new_modifications = Vec::with_capacity(response.modifications.len());
|
||||
for modification in response.modifications {
|
||||
new_modifications.push(match modification {
|
||||
super::Modification::ChangeFrom { value, parameters } => {
|
||||
Modification::ChangeFrom {
|
||||
sender: value,
|
||||
args: flatten_parameters(parameters),
|
||||
}
|
||||
}
|
||||
super::Modification::AddRecipient { value, parameters } => {
|
||||
Modification::AddRcpt {
|
||||
recipient: value,
|
||||
args: flatten_parameters(parameters),
|
||||
}
|
||||
}
|
||||
super::Modification::DeleteRecipient { value } => {
|
||||
Modification::DeleteRcpt { recipient: value }
|
||||
}
|
||||
super::Modification::ReplaceContents { value } => {
|
||||
Modification::ReplaceBody {
|
||||
value: value.as_bytes().to_vec(),
|
||||
}
|
||||
}
|
||||
super::Modification::AddHeader { name, value } => {
|
||||
Modification::AddHeader { name, value }
|
||||
}
|
||||
super::Modification::InsertHeader { index, name, value } => {
|
||||
Modification::InsertHeader { index, name, value }
|
||||
}
|
||||
super::Modification::ChangeHeader { index, name, value } => {
|
||||
Modification::ChangeHeader { index, name, value }
|
||||
}
|
||||
super::Modification::DeleteHeader { index, name } => {
|
||||
Modification::ChangeHeader {
|
||||
index,
|
||||
name,
|
||||
value: String::new(),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if !modifications.is_empty() {
|
||||
// The message body can only be replaced once, so we need to remove
|
||||
// any previous replacements.
|
||||
if new_modifications
|
||||
.iter()
|
||||
.any(|m| matches!(m, Modification::ReplaceBody { .. }))
|
||||
{
|
||||
modifications
|
||||
.retain(|m| !matches!(m, Modification::ReplaceBody { .. }));
|
||||
}
|
||||
modifications.extend(new_modifications);
|
||||
} else {
|
||||
modifications = new_modifications;
|
||||
}
|
||||
|
||||
let mut message = match response.action {
|
||||
Action::Accept => continue,
|
||||
Action::Discard => FilterResponse::accept(),
|
||||
Action::Reject => FilterResponse::reject(),
|
||||
Action::Quarantine => {
|
||||
modifications.push(Modification::AddHeader {
|
||||
name: "X-Quarantine".into(),
|
||||
value: "true".into(),
|
||||
});
|
||||
FilterResponse::accept()
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(response) = response.response {
|
||||
if let (Some(status), Some(text)) = (response.status, response.message) {
|
||||
if let Some(enhanced) = response.enhanced_status {
|
||||
message.message = format!("{status} {enhanced} {text}\r\n").into();
|
||||
} else {
|
||||
message.message = format!("{status} {text}\r\n").into();
|
||||
}
|
||||
}
|
||||
message.disconnect = response.disconnect;
|
||||
}
|
||||
|
||||
return Err(message);
|
||||
}
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
MtaHook(MtaHookEvent::Error),
|
||||
SpanId = self.data.session_id,
|
||||
Id = mta_hook.id.to_string(),
|
||||
Reason = err,
|
||||
Elapsed = time.elapsed(),
|
||||
);
|
||||
|
||||
if mta_hook.tempfail_on_error {
|
||||
return Err(FilterResponse::server_failure());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(modifications)
|
||||
}
|
||||
|
||||
pub async fn run_mta_hook(
|
||||
&self,
|
||||
stage: Stage,
|
||||
mta_hook: &MTAHook,
|
||||
message: Option<&AuthenticatedMessage<'_>>,
|
||||
queue_id: Option<QueueId>,
|
||||
) -> Result<Response, String> {
|
||||
// Build request
|
||||
let (tls_version, tls_cipher) = self.stream.tls_version_and_cipher();
|
||||
let request = Request {
|
||||
context: Context {
|
||||
stage: stage.into(),
|
||||
client: Client {
|
||||
ip: self.data.remote_ip.to_string(),
|
||||
port: self.data.remote_port,
|
||||
ptr: self
|
||||
.data
|
||||
.iprev
|
||||
.as_ref()
|
||||
.and_then(|ip_rev| ip_rev.ptr.as_ref())
|
||||
.and_then(|ptrs| ptrs.first())
|
||||
.map(|ip| ip.to_string()),
|
||||
helo: (!self.data.helo_domain.is_empty())
|
||||
.then(|| self.data.helo_domain.clone()),
|
||||
active_connections: 1,
|
||||
},
|
||||
sasl: self.authenticated_as().map(|name| Sasl {
|
||||
login: name.into(),
|
||||
method: None,
|
||||
}),
|
||||
tls: (!tls_version.is_empty()).then(|| Tls {
|
||||
version: tls_version.as_ref().into(),
|
||||
cipher: tls_cipher.as_ref().into(),
|
||||
bits: None,
|
||||
issuer: None,
|
||||
subject: None,
|
||||
}),
|
||||
server: Server {
|
||||
name: Some(DAEMON_NAME.into()),
|
||||
port: self.data.local_port,
|
||||
ip: self.data.local_ip.to_string().into(),
|
||||
},
|
||||
queue: queue_id.map(|id| Queue {
|
||||
id: format!("{:x}", id),
|
||||
}),
|
||||
protocol: Protocol { version: 1 },
|
||||
},
|
||||
envelope: self.data.mail_from.as_ref().map(|from| Envelope {
|
||||
from: Address {
|
||||
address: from.address_lcase.clone(),
|
||||
parameters: None,
|
||||
},
|
||||
to: self
|
||||
.data
|
||||
.rcpt_to
|
||||
.iter()
|
||||
.map(|to| Address {
|
||||
address: to.address_lcase.clone(),
|
||||
parameters: None,
|
||||
})
|
||||
.collect(),
|
||||
}),
|
||||
message: message.map(|message| Message {
|
||||
headers: message
|
||||
.raw_parsed_headers()
|
||||
.iter()
|
||||
.map(|(k, v)| {
|
||||
(
|
||||
String::from_utf8_lossy(k).into_owned(),
|
||||
String::from_utf8_lossy(v).into_owned(),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
server_headers: vec![],
|
||||
contents: String::from_utf8_lossy(message.raw_body()).into_owned(),
|
||||
size: message.raw_message().len(),
|
||||
}),
|
||||
};
|
||||
|
||||
send_mta_hook_request(mta_hook, request).await
|
||||
}
|
||||
}
|
||||
|
||||
fn flatten_parameters(parameters: AHashMap<String, Option<String>>) -> String {
|
||||
let mut arguments = String::new();
|
||||
for (key, value) in parameters {
|
||||
if !arguments.is_empty() {
|
||||
arguments.push(' ');
|
||||
}
|
||||
arguments.push_str(key.as_str());
|
||||
if let Some(value) = value {
|
||||
arguments.push('=');
|
||||
arguments.push_str(value.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
arguments
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
pub mod client;
|
||||
pub mod message;
|
||||
|
||||
use ahash::AHashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Request {
|
||||
pub context: Context,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub envelope: Option<Envelope>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message: Option<Message>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Context {
|
||||
pub stage: Stage,
|
||||
pub client: Client,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sasl: Option<Sasl>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tls: Option<Tls>,
|
||||
pub server: Server,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub queue: Option<Queue>,
|
||||
pub protocol: Protocol,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Sasl {
|
||||
pub login: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub method: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Client {
|
||||
pub ip: String,
|
||||
pub port: u16,
|
||||
pub ptr: Option<String>,
|
||||
pub helo: Option<String>,
|
||||
#[serde(rename = "activeConnections")]
|
||||
pub active_connections: u32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Tls {
|
||||
pub version: String,
|
||||
pub cipher: String,
|
||||
#[serde(rename = "cipherBits")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bits: Option<u16>,
|
||||
#[serde(rename = "certIssuer")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub issuer: Option<String>,
|
||||
#[serde(rename = "certSubject")]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub subject: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Server {
|
||||
pub name: Option<String>,
|
||||
pub port: u16,
|
||||
pub ip: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Queue {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Protocol {
|
||||
pub version: u32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub enum Stage {
|
||||
#[serde(rename = "connect")]
|
||||
Connect,
|
||||
#[serde(rename = "ehlo")]
|
||||
Ehlo,
|
||||
#[serde(rename = "auth")]
|
||||
Auth,
|
||||
#[serde(rename = "mail")]
|
||||
Mail,
|
||||
#[serde(rename = "rcpt")]
|
||||
Rcpt,
|
||||
#[serde(rename = "data")]
|
||||
Data,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Address {
|
||||
pub address: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parameters: Option<AHashMap<String, String>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Envelope {
|
||||
pub from: Address,
|
||||
pub to: Vec<Address>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Message {
|
||||
pub headers: Vec<(String, String)>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
#[serde(rename = "serverHeaders")]
|
||||
#[serde(default)]
|
||||
pub server_headers: Vec<(String, String)>,
|
||||
pub contents: String,
|
||||
pub size: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Response {
|
||||
pub action: Action,
|
||||
#[serde(default)]
|
||||
pub response: Option<SmtpResponse>,
|
||||
#[serde(default)]
|
||||
pub modifications: Vec<Modification>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub enum Action {
|
||||
#[serde(rename = "accept")]
|
||||
Accept,
|
||||
#[serde(rename = "discard")]
|
||||
Discard,
|
||||
#[serde(rename = "reject")]
|
||||
Reject,
|
||||
#[serde(rename = "quarantine")]
|
||||
Quarantine,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
pub struct SmtpResponse {
|
||||
#[serde(default)]
|
||||
pub status: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub enhanced_status: Option<String>,
|
||||
#[serde(default)]
|
||||
pub message: Option<String>,
|
||||
#[serde(default)]
|
||||
pub disconnect: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum Modification {
|
||||
#[serde(rename = "changeFrom")]
|
||||
ChangeFrom {
|
||||
value: String,
|
||||
#[serde(default)]
|
||||
parameters: AHashMap<String, Option<String>>,
|
||||
},
|
||||
#[serde(rename = "addRecipient")]
|
||||
AddRecipient {
|
||||
value: String,
|
||||
#[serde(default)]
|
||||
parameters: AHashMap<String, Option<String>>,
|
||||
},
|
||||
#[serde(rename = "deleteRecipient")]
|
||||
DeleteRecipient { value: String },
|
||||
#[serde(rename = "replaceContents")]
|
||||
ReplaceContents { value: String },
|
||||
#[serde(rename = "addHeader")]
|
||||
AddHeader { name: String, value: String },
|
||||
#[serde(rename = "insertHeader")]
|
||||
InsertHeader {
|
||||
index: u32,
|
||||
name: String,
|
||||
value: String,
|
||||
},
|
||||
#[serde(rename = "changeHeader")]
|
||||
ChangeHeader {
|
||||
index: u32,
|
||||
name: String,
|
||||
value: String,
|
||||
},
|
||||
#[serde(rename = "deleteHeader")]
|
||||
DeleteHeader { index: u32, name: String },
|
||||
}
|
||||
|
||||
impl From<common::config::smtp::session::Stage> for Stage {
|
||||
fn from(value: common::config::smtp::session::Stage) -> Self {
|
||||
match value {
|
||||
common::config::smtp::session::Stage::Connect => Stage::Connect,
|
||||
common::config::smtp::session::Stage::Ehlo => Stage::Ehlo,
|
||||
common::config::smtp::session::Stage::Auth => Stage::Auth,
|
||||
common::config::smtp::session::Stage::Mail => Stage::Mail,
|
||||
common::config::smtp::session::Stage::Rcpt => Stage::Rcpt,
|
||||
common::config::smtp::session::Stage::Data => Stage::Data,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
core::{Session, SessionAddress},
|
||||
scripts::ScriptResult,
|
||||
};
|
||||
use common::{config::smtp::session::Stage, network::SessionStream, scripts::ScriptModification};
|
||||
use mail_auth::{IprevOutput, IprevResult, SpfOutput, SpfResult, spf::verify::SpfParameters};
|
||||
use mail_parser::DateTime;
|
||||
use registry::schema::structs::Rate;
|
||||
use smtp_proto::{MAIL_BY_NOTIFY, MAIL_BY_RETURN, MAIL_REQUIRETLS, MailFrom, MtPriority};
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
time::{Duration, Instant, SystemTime},
|
||||
};
|
||||
use trc::SmtpEvent;
|
||||
use utils::DomainPart;
|
||||
|
||||
impl<T: SessionStream> Session<T> {
|
||||
pub async fn handle_mail_from(&mut self, from: MailFrom<Cow<'_, str>>) -> Result<(), ()> {
|
||||
if self.data.helo_domain.is_empty()
|
||||
&& (self.params.ehlo_require
|
||||
|| self.params.spf_ehlo.verify()
|
||||
|| self.params.spf_mail_from.verify())
|
||||
{
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::DidNotSayEhlo),
|
||||
SpanId = self.data.session_id,
|
||||
);
|
||||
|
||||
return self
|
||||
.write(b"503 5.5.1 Polite people say EHLO first.\r\n")
|
||||
.await;
|
||||
} else if self.data.mail_from.is_some() {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::MultipleMailFrom),
|
||||
SpanId = self.data.session_id,
|
||||
);
|
||||
|
||||
return self
|
||||
.write(b"503 5.5.1 Multiple MAIL commands not allowed.\r\n")
|
||||
.await;
|
||||
} else if self.params.auth_require && !self.is_authenticated() {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::MailFromUnauthenticated),
|
||||
SpanId = self.data.session_id,
|
||||
);
|
||||
|
||||
return self
|
||||
.write(b"503 5.5.1 You must authenticate first.\r\n")
|
||||
.await;
|
||||
} else if self.data.iprev.is_none() && self.params.iprev.verify() {
|
||||
let time = Instant::now();
|
||||
let iprev = self
|
||||
.server
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.verify_iprev(
|
||||
self.server
|
||||
.inner
|
||||
.cache
|
||||
.build_auth_parameters(self.data.remote_ip),
|
||||
)
|
||||
.await;
|
||||
|
||||
trc::event!(
|
||||
Smtp(if matches!(iprev.result(), IprevResult::Pass) {
|
||||
SmtpEvent::IprevPass
|
||||
} else {
|
||||
SmtpEvent::IprevFail
|
||||
}),
|
||||
SpanId = self.data.session_id,
|
||||
Domain = self.data.helo_domain.clone(),
|
||||
Result = trc::Error::from(&iprev),
|
||||
Elapsed = time.elapsed(),
|
||||
);
|
||||
|
||||
self.data.iprev = iprev.into();
|
||||
}
|
||||
|
||||
// In strict mode reject messages from hosts that fail the reverse DNS lookup check
|
||||
if self.params.iprev.is_strict()
|
||||
&& !matches!(
|
||||
&self.data.iprev,
|
||||
Some(IprevOutput {
|
||||
result: IprevResult::Pass,
|
||||
..
|
||||
})
|
||||
)
|
||||
{
|
||||
let message = if matches!(
|
||||
&self.data.iprev,
|
||||
Some(IprevOutput {
|
||||
result: IprevResult::TempError(_),
|
||||
..
|
||||
})
|
||||
) {
|
||||
&b"451 4.7.25 Temporary error validating reverse DNS.\r\n"[..]
|
||||
} else {
|
||||
&b"550 5.7.25 Reverse DNS validation failed.\r\n"[..]
|
||||
};
|
||||
|
||||
return self.write(message).await;
|
||||
}
|
||||
|
||||
let (address, address_lcase, domain) = if !from.address.is_empty() {
|
||||
let address_lcase = from.address.to_lowercase_address(true);
|
||||
let domain = address_lcase.domain_part().into();
|
||||
(from.address.into_owned(), address_lcase, domain)
|
||||
} else {
|
||||
(String::new(), String::new(), String::new())
|
||||
};
|
||||
|
||||
let has_dsn = from.env_id.is_some();
|
||||
self.data.mail_from = SessionAddress {
|
||||
address,
|
||||
address_lcase,
|
||||
domain,
|
||||
flags: from.flags,
|
||||
dsn_info: from.env_id.map(|e| e.into_owned()),
|
||||
}
|
||||
.into();
|
||||
|
||||
// Check whether the address is allowed
|
||||
if !self
|
||||
.server
|
||||
.eval_if::<bool, _>(
|
||||
&self.server.core.smtp.session.mail.is_allowed,
|
||||
self,
|
||||
self.data.session_id,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(true)
|
||||
{
|
||||
let mail_from = self.data.mail_from.take().unwrap();
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::MailFromNotAllowed),
|
||||
From = mail_from.address_lcase,
|
||||
SpanId = self.data.session_id,
|
||||
);
|
||||
return self
|
||||
.write(b"550 5.7.1 Sender address not allowed.\r\n")
|
||||
.await;
|
||||
}
|
||||
|
||||
// Sieve filtering
|
||||
if let Some((script, script_id)) = self
|
||||
.server
|
||||
.eval_if::<String, _>(
|
||||
&self.server.core.smtp.session.mail.script,
|
||||
self,
|
||||
self.data.session_id,
|
||||
)
|
||||
.await
|
||||
.and_then(|name| {
|
||||
self.server
|
||||
.get_trusted_sieve_script(&name, self.data.session_id)
|
||||
.map(|s| (s, name))
|
||||
})
|
||||
{
|
||||
match self
|
||||
.run_script(
|
||||
script_id,
|
||||
script.clone(),
|
||||
self.build_script_parameters("mail"),
|
||||
)
|
||||
.await
|
||||
{
|
||||
ScriptResult::Accept { modifications } if !modifications.is_empty() => {
|
||||
for modification in modifications {
|
||||
if let ScriptModification::SetEnvelope { name, value } = modification {
|
||||
self.data.apply_envelope_modification(name, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
ScriptResult::Reject(message) => {
|
||||
self.data.mail_from = None;
|
||||
return self.write(message.as_bytes()).await;
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
// Milter filtering
|
||||
if let Err(message) = self.run_milters(Stage::Mail, None, None).await {
|
||||
self.data.mail_from = None;
|
||||
return self.write(message.message.as_bytes()).await;
|
||||
}
|
||||
|
||||
// MTAHook filtering
|
||||
if let Err(message) = self.run_mta_hooks(Stage::Mail, None, None).await {
|
||||
self.data.mail_from = None;
|
||||
return self.write(message.message.as_bytes()).await;
|
||||
}
|
||||
|
||||
// Address rewriting
|
||||
if let Some(new_address) = self
|
||||
.server
|
||||
.eval_if::<String, _>(
|
||||
&self.server.core.smtp.session.mail.rewrite,
|
||||
self,
|
||||
self.data.session_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let mail_from = self.data.mail_from.as_mut().unwrap();
|
||||
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::MailFromRewritten),
|
||||
SpanId = self.data.session_id,
|
||||
Details = mail_from.address_lcase.clone(),
|
||||
From = new_address.clone(),
|
||||
);
|
||||
|
||||
if new_address.contains('@') {
|
||||
mail_from.address_lcase = new_address.to_lowercase_address(true);
|
||||
mail_from.domain = mail_from.address_lcase.domain_part().into();
|
||||
mail_from.address = new_address;
|
||||
} else if new_address.is_empty() {
|
||||
mail_from.address_lcase.clear();
|
||||
mail_from.domain.clear();
|
||||
mail_from.address.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure that the authenticated user is allowed to send from this address
|
||||
match self.authenticated_as() {
|
||||
Some(authenticated_as)
|
||||
if self
|
||||
.server
|
||||
.eval_if(
|
||||
&self.server.core.smtp.session.auth.must_match_sender,
|
||||
self,
|
||||
self.data.session_id,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(true) =>
|
||||
{
|
||||
let address_lcase = self.data.mail_from.as_ref().unwrap().address_lcase.as_str();
|
||||
if authenticated_as != address_lcase
|
||||
&& !self
|
||||
.authenticated_emails()
|
||||
.iter()
|
||||
.any(|e| e == address_lcase)
|
||||
{
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::MailFromUnauthorized),
|
||||
SpanId = self.data.session_id,
|
||||
From = address_lcase.to_string(),
|
||||
Details = [trc::Value::String(authenticated_as.into())]
|
||||
.into_iter()
|
||||
.chain(
|
||||
self.authenticated_emails()
|
||||
.iter()
|
||||
.map(|e| trc::Value::String(e.into()))
|
||||
)
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
self.data.mail_from = None;
|
||||
return self
|
||||
.write(b"501 5.5.4 You are not allowed to send from this address.\r\n")
|
||||
.await;
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
|
||||
// Validate parameters
|
||||
let config = &self.server.core.smtp.session.extensions;
|
||||
let config_data = &self.server.core.smtp.session.data;
|
||||
if (from.flags & MAIL_REQUIRETLS) != 0
|
||||
&& !self
|
||||
.server
|
||||
.eval_if(&config.requiretls, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::RequireTlsDisabled),
|
||||
SpanId = self.data.session_id,
|
||||
);
|
||||
self.data.mail_from = None;
|
||||
return self
|
||||
.write(b"501 5.5.4 REQUIRETLS has been disabled.\r\n")
|
||||
.await;
|
||||
}
|
||||
if (from.flags & (MAIL_BY_NOTIFY | MAIL_BY_RETURN)) != 0 {
|
||||
if let Some(duration) = self
|
||||
.server
|
||||
.eval_if::<Duration, _>(&config.deliver_by, self, self.data.session_id)
|
||||
.await
|
||||
{
|
||||
if from.by.checked_abs().unwrap_or(0) as u64 <= duration.as_secs()
|
||||
&& (from.by.is_positive() || (from.flags & MAIL_BY_NOTIFY) != 0)
|
||||
{
|
||||
self.data.delivery_by = from.by;
|
||||
} else {
|
||||
self.data.mail_from = None;
|
||||
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::DeliverByInvalid),
|
||||
SpanId = self.data.session_id,
|
||||
Details = from.by,
|
||||
);
|
||||
|
||||
return self
|
||||
.write(
|
||||
format!(
|
||||
"501 5.5.4 BY parameter exceeds maximum of {} seconds.\r\n",
|
||||
duration.as_secs()
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
} else {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::DeliverByDisabled),
|
||||
SpanId = self.data.session_id,
|
||||
);
|
||||
self.data.mail_from = None;
|
||||
return self
|
||||
.write(b"501 5.5.4 DELIVERBY extension has been disabled.\r\n")
|
||||
.await;
|
||||
}
|
||||
}
|
||||
if from.mt_priority != 0 {
|
||||
if self
|
||||
.server
|
||||
.eval_if::<MtPriority, _>(&config.mt_priority, self, self.data.session_id)
|
||||
.await
|
||||
.is_some()
|
||||
{
|
||||
if (-6..6).contains(&from.mt_priority) {
|
||||
self.data.priority = from.mt_priority as i16;
|
||||
} else {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::MtPriorityInvalid),
|
||||
SpanId = self.data.session_id,
|
||||
Details = from.mt_priority,
|
||||
);
|
||||
self.data.mail_from = None;
|
||||
return self.write(b"501 5.5.4 Invalid priority value.\r\n").await;
|
||||
}
|
||||
} else {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::MtPriorityDisabled),
|
||||
SpanId = self.data.session_id,
|
||||
);
|
||||
self.data.mail_from = None;
|
||||
return self
|
||||
.write(b"501 5.5.4 MT-PRIORITY extension has been disabled.\r\n")
|
||||
.await;
|
||||
}
|
||||
}
|
||||
if from.size > 0 {
|
||||
let max_message_size = self
|
||||
.server
|
||||
.eval_if::<usize, _>(&config_data.max_message_size, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or(25 * 1024 * 1024);
|
||||
|
||||
if max_message_size > 0 && from.size > max_message_size {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::MessageTooLarge),
|
||||
SpanId = self.data.session_id,
|
||||
Size = from.size,
|
||||
Limit = max_message_size,
|
||||
);
|
||||
|
||||
self.data.mail_from = None;
|
||||
return self
|
||||
.write(b"552 5.3.4 Message too big for system.\r\n")
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
if from.hold_for != 0 || from.hold_until != 0 {
|
||||
if from.hold_for != 0 && from.hold_until != 0 {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::FutureReleaseInvalid),
|
||||
SpanId = self.data.session_id,
|
||||
Details = "Both HOLDFOR and HOLDUNTIL were specified",
|
||||
);
|
||||
self.data.mail_from = None;
|
||||
return self
|
||||
.write(b"501 5.5.4 Only one of HOLDFOR or HOLDUNTIL may be specified.\r\n")
|
||||
.await;
|
||||
}
|
||||
if let Some(max_hold) = self
|
||||
.server
|
||||
.eval_if::<Duration, _>(&config.future_release, self, self.data.session_id)
|
||||
.await
|
||||
{
|
||||
let max_hold = max_hold.as_secs();
|
||||
let now = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs());
|
||||
let hold_for = if from.hold_for != 0 {
|
||||
from.hold_for
|
||||
} else if from.hold_until > now {
|
||||
from.hold_until - now
|
||||
} else {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::FutureReleaseInvalid),
|
||||
SpanId = self.data.session_id,
|
||||
Details = from.hold_until,
|
||||
);
|
||||
self.data.mail_from = None;
|
||||
return self
|
||||
.write(b"501 5.5.4 HOLDUNTIL must be a date and time in the future.\r\n")
|
||||
.await;
|
||||
};
|
||||
if hold_for <= max_hold {
|
||||
self.data.future_release = hold_for;
|
||||
} else {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::FutureReleaseInvalid),
|
||||
SpanId = self.data.session_id,
|
||||
Details = hold_for,
|
||||
);
|
||||
self.data.mail_from = None;
|
||||
let response = if from.hold_for != 0 {
|
||||
format!(
|
||||
"501 5.5.4 Requested hold time exceeds maximum of {max_hold} seconds.\r\n"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"501 5.5.4 Requested release time exceeds maximum of {}.\r\n",
|
||||
DateTime::from_timestamp((now + max_hold) as i64).to_rfc3339()
|
||||
)
|
||||
};
|
||||
return self.write(response.as_bytes()).await;
|
||||
}
|
||||
} else {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::FutureReleaseDisabled),
|
||||
SpanId = self.data.session_id,
|
||||
);
|
||||
self.data.mail_from = None;
|
||||
return self
|
||||
.write(b"501 5.5.4 FUTURERELEASE extension has been disabled.\r\n")
|
||||
.await;
|
||||
}
|
||||
}
|
||||
if has_dsn
|
||||
&& !self
|
||||
.server
|
||||
.eval_if(&config.dsn, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
trc::event!(Smtp(SmtpEvent::DsnDisabled), SpanId = self.data.session_id,);
|
||||
self.data.mail_from = None;
|
||||
return self
|
||||
.write(b"501 5.5.4 DSN extension has been disabled.\r\n")
|
||||
.await;
|
||||
}
|
||||
|
||||
if self.is_allowed().await {
|
||||
// Verify SPF
|
||||
if self.params.spf_mail_from.verify() {
|
||||
let time = Instant::now();
|
||||
let mail_from = self.data.mail_from.as_ref().unwrap();
|
||||
let spf_output = if !mail_from.address.is_empty() {
|
||||
self.server
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.check_host(self.server.inner.cache.build_auth_parameters(
|
||||
SpfParameters::new(
|
||||
self.data.remote_ip,
|
||||
&mail_from.domain,
|
||||
&self.data.helo_domain,
|
||||
&self.hostname,
|
||||
&mail_from.address_lcase,
|
||||
),
|
||||
))
|
||||
.await
|
||||
} else {
|
||||
self.server
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.check_host(self.server.inner.cache.build_auth_parameters(
|
||||
SpfParameters::new(
|
||||
self.data.remote_ip,
|
||||
&self.data.helo_domain,
|
||||
&self.data.helo_domain,
|
||||
&self.hostname,
|
||||
&format!("postmaster@{}", self.data.helo_domain),
|
||||
),
|
||||
))
|
||||
.await
|
||||
};
|
||||
|
||||
trc::event!(
|
||||
Smtp(if matches!(spf_output.result(), SpfResult::Pass) {
|
||||
SmtpEvent::SpfFromPass
|
||||
} else {
|
||||
SmtpEvent::SpfFromFail
|
||||
}),
|
||||
SpanId = self.data.session_id,
|
||||
Domain = self.data.helo_domain.clone(),
|
||||
From = if !mail_from.address.is_empty() {
|
||||
mail_from.address.as_str()
|
||||
} else {
|
||||
"<>"
|
||||
}
|
||||
.to_string(),
|
||||
Result = trc::Error::from(&spf_output),
|
||||
Elapsed = time.elapsed(),
|
||||
);
|
||||
|
||||
if self
|
||||
.handle_spf(&spf_output, self.params.spf_mail_from.is_strict())
|
||||
.await?
|
||||
{
|
||||
self.data.spf_mail_from = spf_output.into();
|
||||
} else {
|
||||
self.data.mail_from = None;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::MailFrom),
|
||||
SpanId = self.data.session_id,
|
||||
From = self.data.mail_from.as_ref().unwrap().address_lcase.clone(),
|
||||
);
|
||||
|
||||
self.eval_rcpt_params().await;
|
||||
self.write(b"250 2.1.0 OK\r\n").await
|
||||
} else {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::RateLimitExceeded),
|
||||
SpanId = self.data.session_id,
|
||||
From = self.data.mail_from.as_ref().unwrap().address_lcase.clone(),
|
||||
);
|
||||
|
||||
self.data.mail_from = None;
|
||||
self.write(b"452 4.4.5 Rate limit exceeded, try again later.\r\n")
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_spf(&mut self, spf_output: &SpfOutput, strict: bool) -> Result<bool, ()> {
|
||||
let result = match spf_output.result() {
|
||||
SpfResult::Pass => true,
|
||||
SpfResult::TempError if strict => {
|
||||
self.write(b"451 4.7.24 Temporary SPF validation error.\r\n")
|
||||
.await?;
|
||||
false
|
||||
}
|
||||
result => {
|
||||
if strict {
|
||||
self.write(
|
||||
format!("550 5.7.23 SPF validation failed, status: {result}.\r\n")
|
||||
.as_bytes(),
|
||||
)
|
||||
.await?;
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Send report
|
||||
if let (Some(recipient), Some(rate)) = (
|
||||
spf_output.report_address(),
|
||||
self.server
|
||||
.eval_if::<Rate, _>(
|
||||
&self.server.core.smtp.report.spf.send,
|
||||
self,
|
||||
self.data.session_id,
|
||||
)
|
||||
.await,
|
||||
) {
|
||||
// Do not send SPF auth failures to local domains, as they are likely relay attempts (which are blocked later on)
|
||||
match self.server.domain(recipient.domain_part()).await {
|
||||
Ok(Some(_)) => return Ok(result),
|
||||
Ok(None) => (),
|
||||
Err(err) => {
|
||||
trc::error!(
|
||||
err.caused_by(trc::location!())
|
||||
.span_id(self.data.session_id)
|
||||
.details("Failed to lookup local domain")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
self.send_spf_report(recipient, &rate, !result, spf_output)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use common::config::smtp::session::Milter;
|
||||
use rustls_pki_types::ServerName;
|
||||
use tokio::{
|
||||
io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt},
|
||||
net::TcpStream,
|
||||
};
|
||||
use tokio_rustls::{TlsConnector, client::TlsStream};
|
||||
use trc::MilterEvent;
|
||||
|
||||
use super::{
|
||||
protocol::{SMFIC_CONNECT, SMFIC_HELO, SMFIC_MAIL, SMFIC_RCPT},
|
||||
receiver::{FrameResult, Receiver},
|
||||
*,
|
||||
};
|
||||
|
||||
const MILTER_CHUNK_SIZE: usize = 65535;
|
||||
|
||||
impl MilterClient<TcpStream> {
|
||||
pub async fn connect(config: &Milter, session_id: u64) -> Result<Self> {
|
||||
tokio::time::timeout(config.timeout_command, async {
|
||||
let mut last_err = Error::Disconnected;
|
||||
for addr in &config.addrs {
|
||||
match TcpStream::connect(addr).await {
|
||||
Ok(stream) => {
|
||||
return Ok(MilterClient {
|
||||
stream,
|
||||
timeout_cmd: config.timeout_command,
|
||||
timeout_data: config.timeout_data,
|
||||
buf: vec![0u8; 8192],
|
||||
bytes_read: 0,
|
||||
receiver: Receiver::with_max_frame_len(config.max_frame_len),
|
||||
options: 0,
|
||||
version: config.protocol_version,
|
||||
session_id,
|
||||
flags_actions: config.flags_actions.unwrap_or(
|
||||
SMFIF_ADDHDRS
|
||||
| SMFIF_CHGBODY
|
||||
| SMFIF_ADDRCPT
|
||||
| SMFIF_DELRCPT
|
||||
| SMFIF_CHGHDRS
|
||||
| SMFIF_QUARANTINE
|
||||
| SMFIF_CHGFROM
|
||||
| SMFIF_ADDRCPT_PAR,
|
||||
),
|
||||
flags_protocol: config.flags_protocol.unwrap_or(0x42),
|
||||
id: config.id,
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
last_err = Error::Io(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(last_err)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| Error::Timeout)?
|
||||
}
|
||||
|
||||
pub async fn into_tls(
|
||||
self,
|
||||
tls_connector: &TlsConnector,
|
||||
tls_hostname: &str,
|
||||
) -> Result<MilterClient<TlsStream<TcpStream>>> {
|
||||
tokio::time::timeout(self.timeout_cmd, async {
|
||||
Ok(MilterClient {
|
||||
stream: tls_connector
|
||||
.connect(
|
||||
ServerName::try_from(tls_hostname)
|
||||
.map_err(|_| Error::TLSInvalidName)?
|
||||
.to_owned(),
|
||||
self.stream,
|
||||
)
|
||||
.await?,
|
||||
buf: self.buf,
|
||||
timeout_cmd: self.timeout_cmd,
|
||||
timeout_data: self.timeout_data,
|
||||
receiver: self.receiver,
|
||||
bytes_read: self.bytes_read,
|
||||
options: self.options,
|
||||
version: self.version,
|
||||
session_id: self.session_id,
|
||||
flags_actions: self.flags_actions,
|
||||
flags_protocol: self.flags_protocol,
|
||||
id: self.id,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|_| Error::Timeout)?
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: AsyncRead + AsyncWrite + Unpin> MilterClient<T> {
|
||||
pub async fn init(&mut self) -> super::Result<Options> {
|
||||
self.write(Command::OptionNegotiation(Options {
|
||||
version: match self.version {
|
||||
MilterVersion::V2 => 2,
|
||||
MilterVersion::V6 => 6,
|
||||
},
|
||||
actions: self.flags_actions,
|
||||
protocol: self.flags_protocol,
|
||||
}))
|
||||
.await?;
|
||||
match self.read().await? {
|
||||
Response::OptionNegotiation(options) => {
|
||||
self.options = options.protocol;
|
||||
Ok(options)
|
||||
}
|
||||
response => Err(Error::Unexpected(response)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn connection(
|
||||
&mut self,
|
||||
hostname: impl AsRef<[u8]>,
|
||||
remote_ip: IpAddr,
|
||||
remote_port: u16,
|
||||
macros: Macros<'_>,
|
||||
) -> super::Result<Action> {
|
||||
if !self.has_option(SMFIP_NOCONNECT) {
|
||||
self.write(Command::Macro {
|
||||
macros: macros.with_cmd_code(SMFIC_CONNECT),
|
||||
})
|
||||
.await?;
|
||||
self.write(Command::Connect {
|
||||
hostname: hostname.as_ref(),
|
||||
port: remote_port,
|
||||
address: remote_ip,
|
||||
})
|
||||
.await?;
|
||||
if !self.has_option(SMFIP_NR_CONN) {
|
||||
return self.read().await?.into_action();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Action::Accept)
|
||||
}
|
||||
|
||||
pub async fn helo(
|
||||
&mut self,
|
||||
hostname: impl AsRef<[u8]>,
|
||||
macros: Macros<'_>,
|
||||
) -> super::Result<Action> {
|
||||
if !self.has_option(SMFIP_NOHELO) {
|
||||
self.write(Command::Macro {
|
||||
macros: macros.with_cmd_code(SMFIC_HELO),
|
||||
})
|
||||
.await?;
|
||||
self.write(Command::Helo {
|
||||
hostname: hostname.as_ref(),
|
||||
})
|
||||
.await?;
|
||||
if !self.has_option(SMFIP_NR_HELO) {
|
||||
return self.read().await?.into_action();
|
||||
}
|
||||
}
|
||||
Ok(Action::Accept)
|
||||
}
|
||||
|
||||
pub async fn mail_from<A, V>(
|
||||
&mut self,
|
||||
addr: A,
|
||||
params: Option<&[V]>,
|
||||
macros: Macros<'_>,
|
||||
) -> super::Result<Action>
|
||||
where
|
||||
A: AsRef<[u8]>,
|
||||
V: AsRef<[u8]>,
|
||||
{
|
||||
if !self.has_option(SMFIP_NOMAIL) {
|
||||
self.write(Command::Macro {
|
||||
macros: macros.with_cmd_code(SMFIC_MAIL),
|
||||
})
|
||||
.await?;
|
||||
self.write(Command::MailFrom {
|
||||
sender: addr.as_ref(),
|
||||
args: params.map(|params| params.iter().map(|value| value.as_ref()).collect()),
|
||||
})
|
||||
.await?;
|
||||
if !self.has_option(SMFIP_NR_MAIL) {
|
||||
return self.read().await?.into_action();
|
||||
}
|
||||
}
|
||||
Ok(Action::Accept)
|
||||
}
|
||||
|
||||
pub async fn rcpt_to<A, V>(
|
||||
&mut self,
|
||||
addr: A,
|
||||
params: Option<&[V]>,
|
||||
macros: Macros<'_>,
|
||||
) -> super::Result<Action>
|
||||
where
|
||||
A: AsRef<[u8]>,
|
||||
V: AsRef<[u8]>,
|
||||
{
|
||||
if !self.has_option(SMFIP_NORCPT) {
|
||||
self.write(Command::Macro {
|
||||
macros: macros.with_cmd_code(SMFIC_RCPT),
|
||||
})
|
||||
.await?;
|
||||
self.write(Command::Rcpt {
|
||||
recipient: addr.as_ref(),
|
||||
args: params.map(|params| params.iter().map(|value| value.as_ref()).collect()),
|
||||
})
|
||||
.await?;
|
||||
if !self.has_option(SMFIP_NR_RCPT) {
|
||||
return self.read().await?.into_action();
|
||||
}
|
||||
}
|
||||
Ok(Action::Accept)
|
||||
}
|
||||
|
||||
pub async fn headers<I, H, V>(&mut self, headers: I) -> super::Result<Action>
|
||||
where
|
||||
I: Iterator<Item = (H, V)>,
|
||||
H: AsRef<str>,
|
||||
V: AsRef<str>,
|
||||
{
|
||||
if !self.has_option(SMFIP_NOHDRS) {
|
||||
for (name, value) in headers {
|
||||
self.write(Command::Header {
|
||||
name: name.as_ref().trim().as_bytes(),
|
||||
value: value.as_ref().trim().as_bytes(),
|
||||
})
|
||||
.await?;
|
||||
if !self.has_option(SMFIP_NR_HDR) {
|
||||
match self.read().await? {
|
||||
Response::Action(Action::Accept | Action::Continue) => (),
|
||||
Response::Action(action) => return Ok(action),
|
||||
response => return Err(Error::Unexpected(response)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write EndOfHeaders
|
||||
self.write(Command::EndOfHeader).await?;
|
||||
if !self.has_option(SMFIP_NR_EOH) {
|
||||
return self.read().await?.into_action();
|
||||
}
|
||||
}
|
||||
Ok(Action::Accept)
|
||||
}
|
||||
|
||||
pub async fn data(&mut self) -> super::Result<Action> {
|
||||
if matches!(self.version, MilterVersion::V6) && !self.has_option(SMFIP_NODATA) {
|
||||
self.write(Command::Data).await?;
|
||||
if !self.has_option(SMFIP_NR_DATA) {
|
||||
return self.read().await?.into_action();
|
||||
}
|
||||
}
|
||||
Ok(Action::Accept)
|
||||
}
|
||||
|
||||
pub async fn body(&mut self, body: &[u8]) -> super::Result<(Action, Vec<Modification>)> {
|
||||
if !self.has_option(SMFIP_NOBODY) {
|
||||
// Write body chunks
|
||||
for value in body.chunks(MILTER_CHUNK_SIZE) {
|
||||
self.write(Command::Body { value }).await?;
|
||||
if !self.has_option(SMFIP_NR_BODY) {
|
||||
match self.read().await? {
|
||||
Response::Action(Action::Accept | Action::Continue)
|
||||
| Response::Progress => (),
|
||||
Response::Skip => break,
|
||||
Response::Action(reject) => {
|
||||
return Ok((reject, Vec::new()));
|
||||
}
|
||||
response => return Err(Error::Unexpected(response)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write EndOfBody
|
||||
self.write(Command::EndOfBody).await?;
|
||||
|
||||
// Collect responses
|
||||
let mut modifications = Vec::new();
|
||||
loop {
|
||||
match self.read().await? {
|
||||
Response::Action(action) => {
|
||||
return Ok((action, modifications));
|
||||
}
|
||||
Response::Modification(modification) => {
|
||||
modifications.push(modification);
|
||||
}
|
||||
Response::Progress => (),
|
||||
unexpected => {
|
||||
return Err(Error::Unexpected(unexpected));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Ok((Action::Accept, vec![]))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn abort(&mut self) -> super::Result<()> {
|
||||
self.write(Command::Abort).await
|
||||
}
|
||||
|
||||
pub async fn quit(&mut self) -> super::Result<()> {
|
||||
self.write(Command::Quit).await
|
||||
}
|
||||
|
||||
async fn write(&mut self, action: Command<'_>) -> super::Result<()> {
|
||||
trc::event!(
|
||||
Milter(MilterEvent::Write),
|
||||
SpanId = self.session_id,
|
||||
Id = self.id.to_string(),
|
||||
Contents = action.to_string(),
|
||||
);
|
||||
|
||||
tokio::time::timeout(self.timeout_cmd, async {
|
||||
self.stream.write_all(action.serialize().as_ref()).await?;
|
||||
self.stream.flush().await.map_err(Error::Io)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| Error::Timeout)?
|
||||
}
|
||||
|
||||
async fn read(&mut self) -> super::Result<Response> {
|
||||
loop {
|
||||
match self.receiver.read_frame(&self.buf[..self.bytes_read]) {
|
||||
FrameResult::Frame(frame) => {
|
||||
if let Some(response) = Response::deserialize(&frame) {
|
||||
trc::event!(
|
||||
Milter(MilterEvent::Read),
|
||||
SpanId = self.session_id,
|
||||
Id = self.id.to_string(),
|
||||
Contents = response.to_string(),
|
||||
);
|
||||
|
||||
return Ok(response);
|
||||
} else {
|
||||
return Err(Error::FrameInvalid(frame.into_owned()));
|
||||
}
|
||||
}
|
||||
FrameResult::Incomplete => {
|
||||
self.bytes_read = tokio::time::timeout(self.timeout_data, async {
|
||||
self.stream.read(&mut self.buf).await.map_err(Error::Io)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| Error::Timeout)??;
|
||||
if self.bytes_read == 0 {
|
||||
return Err(Error::Disconnected);
|
||||
}
|
||||
}
|
||||
FrameResult::TooLarge(size) => return Err(Error::FrameTooLarge(size)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn has_option(&self, opt: u32) -> bool {
|
||||
self.options & opt == opt
|
||||
}
|
||||
|
||||
pub fn with_version(mut self, version: MilterVersion) -> Self {
|
||||
self.version = version;
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use std::{borrow::Cow, net::IpAddr};
|
||||
|
||||
use super::{Macro, Macros};
|
||||
|
||||
pub trait IntoMacroValue<'x> {
|
||||
fn into_macro_value(self) -> Cow<'x, [u8]>;
|
||||
}
|
||||
|
||||
impl<'x> Macros<'x> {
|
||||
pub fn new() -> Self {
|
||||
Macros::default()
|
||||
}
|
||||
|
||||
pub fn with_cmd_code(mut self, cmd_code: u8) -> Self {
|
||||
self.cmdcode = cmd_code;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_macro(mut self, name: &'static [u8], value: impl IntoMacroValue<'x>) -> Self {
|
||||
self.macros.push(Macro {
|
||||
name,
|
||||
value: value.into_macro_value(),
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_queue_id(self, queue_id: impl IntoMacroValue<'x>) -> Self {
|
||||
self.with_macro(b"i", queue_id)
|
||||
}
|
||||
|
||||
pub fn with_local_hostname(self, my_hostname: impl IntoMacroValue<'x>) -> Self {
|
||||
self.with_macro(b"j", my_hostname)
|
||||
}
|
||||
|
||||
pub fn with_validated_client_name(self, client_name: impl IntoMacroValue<'x>) -> Self {
|
||||
self.with_macro(b"_", client_name)
|
||||
}
|
||||
|
||||
pub fn with_sasl_login_name(self, sasl_login_name: impl IntoMacroValue<'x>) -> Self {
|
||||
self.with_macro(b"{auth_authen}", sasl_login_name)
|
||||
}
|
||||
|
||||
pub fn with_sasl_sender(self, sasl_sender: impl IntoMacroValue<'x>) -> Self {
|
||||
self.with_macro(b"{auth_author}", sasl_sender)
|
||||
}
|
||||
|
||||
pub fn with_sasl_method(self, sasl_method: impl IntoMacroValue<'x>) -> Self {
|
||||
self.with_macro(b"{auth_type}", sasl_method)
|
||||
}
|
||||
|
||||
pub fn with_client_address(self, client_address: impl IntoMacroValue<'x>) -> Self {
|
||||
self.with_macro(b"{client_addr}", client_address)
|
||||
}
|
||||
|
||||
pub fn with_client_connections(self, client_connections: impl IntoMacroValue<'x>) -> Self {
|
||||
self.with_macro(b"{client_connections}", client_connections)
|
||||
}
|
||||
|
||||
pub fn with_client_name(self, client_name: impl IntoMacroValue<'x>) -> Self {
|
||||
self.with_macro(b"{client_name}", client_name)
|
||||
}
|
||||
|
||||
pub fn with_client_port(self, client_port: impl IntoMacroValue<'x>) -> Self {
|
||||
self.with_macro(b"{client_port}", client_port)
|
||||
}
|
||||
|
||||
pub fn with_client_ptr(self, client_ptr: impl IntoMacroValue<'x>) -> Self {
|
||||
self.with_macro(b"{client_ptr}", client_ptr)
|
||||
}
|
||||
|
||||
pub fn with_cert_issuer(self, cert_issuer: impl IntoMacroValue<'x>) -> Self {
|
||||
self.with_macro(b"{cert_issuer}", cert_issuer)
|
||||
}
|
||||
|
||||
pub fn with_cert_subject(self, cert_subject: impl IntoMacroValue<'x>) -> Self {
|
||||
self.with_macro(b"{cert_subject}", cert_subject)
|
||||
}
|
||||
|
||||
pub fn with_cipher_bits(self, cipher_bits: impl IntoMacroValue<'x>) -> Self {
|
||||
self.with_macro(b"{cipher_bits}", cipher_bits)
|
||||
}
|
||||
|
||||
pub fn with_cipher(self, cipher: impl IntoMacroValue<'x>) -> Self {
|
||||
self.with_macro(b"{cipher}", cipher)
|
||||
}
|
||||
|
||||
pub fn with_daemon_address(self, daemon_address: impl IntoMacroValue<'x>) -> Self {
|
||||
self.with_macro(b"{daemon_addr}", daemon_address)
|
||||
}
|
||||
|
||||
pub fn with_daemon_name(self, daemon_name: impl IntoMacroValue<'x>) -> Self {
|
||||
self.with_macro(b"{daemon_name}", daemon_name)
|
||||
}
|
||||
|
||||
pub fn with_daemon_port(self, daemon_port: impl IntoMacroValue<'x>) -> Self {
|
||||
self.with_macro(b"{daemon_port}", daemon_port)
|
||||
}
|
||||
|
||||
pub fn with_mail_address(self, mail_address: impl IntoMacroValue<'x>) -> Self {
|
||||
self.with_macro(b"{mail_addr}", mail_address)
|
||||
}
|
||||
|
||||
pub fn with_mail_host(self, mail_host_address: impl IntoMacroValue<'x>) -> Self {
|
||||
self.with_macro(b"{mail_host}", mail_host_address)
|
||||
}
|
||||
|
||||
pub fn with_mail_mailer(self, mail_mailer: impl IntoMacroValue<'x>) -> Self {
|
||||
self.with_macro(b"{mail_mailer}", mail_mailer)
|
||||
}
|
||||
|
||||
pub fn with_rcpt_address(self, rcpt_address: impl IntoMacroValue<'x>) -> Self {
|
||||
self.with_macro(b"{rcpt_addr}", rcpt_address)
|
||||
}
|
||||
|
||||
pub fn with_rcpt_host(self, rcpt_host: impl IntoMacroValue<'x>) -> Self {
|
||||
self.with_macro(b"{rcpt_host}", rcpt_host)
|
||||
}
|
||||
|
||||
pub fn with_rcpt_mailer(self, rcpt_mailer: impl IntoMacroValue<'x>) -> Self {
|
||||
self.with_macro(b"{rcpt_mailer}", rcpt_mailer)
|
||||
}
|
||||
|
||||
pub fn with_tls_version(self, tls_version: impl IntoMacroValue<'x>) -> Self {
|
||||
self.with_macro(b"{tls_version}", tls_version)
|
||||
}
|
||||
|
||||
pub fn with_version(self, version: impl IntoMacroValue<'x>) -> Self {
|
||||
self.with_macro(b"{v}", version)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> IntoMacroValue<'x> for IpAddr {
|
||||
fn into_macro_value(self) -> Cow<'x, [u8]> {
|
||||
Cow::Owned(self.to_string().into_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> IntoMacroValue<'x> for u16 {
|
||||
fn into_macro_value(self) -> Cow<'x, [u8]> {
|
||||
Cow::Owned(self.to_string().into_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> IntoMacroValue<'x> for &'x [u8] {
|
||||
fn into_macro_value(self) -> Cow<'x, [u8]> {
|
||||
Cow::Borrowed(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> IntoMacroValue<'x> for &'x str {
|
||||
fn into_macro_value(self) -> Cow<'x, [u8]> {
|
||||
Cow::Borrowed(self.as_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> IntoMacroValue<'x> for &'x String {
|
||||
fn into_macro_value(self) -> Cow<'x, [u8]> {
|
||||
Cow::Borrowed(self.as_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> IntoMacroValue<'x> for String {
|
||||
fn into_macro_value(self) -> Cow<'x, [u8]> {
|
||||
Cow::Owned(self.into_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> IntoMacroValue<'x> for Vec<u8> {
|
||||
fn into_macro_value(self) -> Cow<'x, [u8]> {
|
||||
Cow::Owned(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> IntoMacroValue<'x> for &'x Vec<u8> {
|
||||
fn into_macro_value(self) -> Cow<'x, [u8]> {
|
||||
Cow::Borrowed(self)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use super::{Action, Error, Macros, Modification};
|
||||
use crate::{
|
||||
core::{Session, SessionAddress, SessionData},
|
||||
inbound::{FilterResponse, milter::MilterClient},
|
||||
queue::QueueId,
|
||||
};
|
||||
use common::{
|
||||
DAEMON_NAME,
|
||||
config::smtp::session::{Milter, Stage},
|
||||
network::SessionStream,
|
||||
};
|
||||
use mail_auth::AuthenticatedMessage;
|
||||
use smtp_proto::{IntoString, request::parser::Rfc5321Parser};
|
||||
use std::{borrow::Cow, time::Instant};
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
use trc::MilterEvent;
|
||||
use utils::DomainPart;
|
||||
|
||||
enum Rejection {
|
||||
Action(Action),
|
||||
Error(Error),
|
||||
}
|
||||
|
||||
impl<T: SessionStream> Session<T> {
|
||||
pub async fn run_milters(
|
||||
&self,
|
||||
stage: Stage,
|
||||
message: Option<&AuthenticatedMessage<'_>>,
|
||||
queue_id: Option<QueueId>,
|
||||
) -> Result<Vec<Modification>, FilterResponse> {
|
||||
let milters = &self.server.core.smtp.session.milters;
|
||||
if milters.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut modifications = Vec::new();
|
||||
for milter in milters {
|
||||
if !milter.run_on_stage.contains(&stage)
|
||||
|| !self
|
||||
.server
|
||||
.eval_if(&milter.enable, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let time = Instant::now();
|
||||
match self.connect_and_run(milter, message).await {
|
||||
Ok(new_modifications) => {
|
||||
trc::event!(
|
||||
Milter(MilterEvent::ActionAccept),
|
||||
SpanId = self.data.session_id,
|
||||
Id = milter.id.to_string(),
|
||||
Elapsed = time.elapsed(),
|
||||
);
|
||||
|
||||
if !modifications.is_empty() {
|
||||
// The message body can only be replaced once, so we need to remove
|
||||
// any previous replacements.
|
||||
if new_modifications
|
||||
.iter()
|
||||
.any(|m| matches!(m, Modification::ReplaceBody { .. }))
|
||||
{
|
||||
modifications
|
||||
.retain(|m| !matches!(m, Modification::ReplaceBody { .. }));
|
||||
}
|
||||
modifications.extend(new_modifications);
|
||||
} else {
|
||||
modifications = new_modifications;
|
||||
}
|
||||
}
|
||||
Err(Rejection::Action(action)) => {
|
||||
trc::event!(
|
||||
Milter(match &action {
|
||||
Action::Discard => MilterEvent::ActionDiscard,
|
||||
Action::Reject => MilterEvent::ActionReject,
|
||||
Action::TempFail => MilterEvent::ActionTempFail,
|
||||
Action::ReplyCode { .. } => {
|
||||
MilterEvent::ActionReplyCode
|
||||
}
|
||||
Action::Shutdown => MilterEvent::ActionShutdown,
|
||||
Action::ConnectionFailure => MilterEvent::ActionConnectionFailure,
|
||||
Action::Accept | Action::Continue => unreachable!(),
|
||||
}),
|
||||
SpanId = self.data.session_id,
|
||||
QueueId = queue_id,
|
||||
Id = milter.id.to_string(),
|
||||
Elapsed = time.elapsed(),
|
||||
);
|
||||
|
||||
return Err(match action {
|
||||
Action::Discard => FilterResponse::accept(),
|
||||
Action::Reject => FilterResponse::reject(),
|
||||
Action::TempFail => FilterResponse::temp_fail(),
|
||||
Action::ReplyCode { code, text } => {
|
||||
let mut response = Vec::with_capacity(text.len() + 6);
|
||||
response.extend_from_slice(code.as_slice());
|
||||
response.push(b' ');
|
||||
response.extend_from_slice(text.as_bytes());
|
||||
if !text.ends_with('\n') {
|
||||
response.extend_from_slice(b"\r\n");
|
||||
}
|
||||
FilterResponse {
|
||||
message: response.into_string().into(),
|
||||
disconnect: false,
|
||||
}
|
||||
}
|
||||
Action::Shutdown => FilterResponse::shutdown(),
|
||||
Action::ConnectionFailure => FilterResponse::default().disconnect(),
|
||||
Action::Accept | Action::Continue => unreachable!(),
|
||||
});
|
||||
}
|
||||
Err(Rejection::Error(err)) => {
|
||||
let (code, details) = match err {
|
||||
Error::Io(details) => {
|
||||
(MilterEvent::IoError, trc::Value::from(details.to_string()))
|
||||
}
|
||||
Error::FrameTooLarge(size) => {
|
||||
(MilterEvent::FrameTooLarge, trc::Value::from(size))
|
||||
}
|
||||
Error::FrameInvalid(bytes) => {
|
||||
(MilterEvent::FrameInvalid, trc::Value::from(bytes))
|
||||
}
|
||||
Error::Unexpected(response) => (
|
||||
MilterEvent::UnexpectedResponse,
|
||||
trc::Value::from(response.to_string()),
|
||||
),
|
||||
Error::Timeout => (MilterEvent::Timeout, trc::Value::None),
|
||||
Error::TLSInvalidName => (MilterEvent::TlsInvalidName, trc::Value::None),
|
||||
Error::Disconnected => (MilterEvent::Disconnected, trc::Value::None),
|
||||
};
|
||||
|
||||
trc::event!(
|
||||
Milter(code),
|
||||
SpanId = self.data.session_id,
|
||||
Id = milter.id.to_string(),
|
||||
Details = details,
|
||||
Elapsed = time.elapsed(),
|
||||
);
|
||||
|
||||
if milter.tempfail_on_error {
|
||||
return Err(FilterResponse::server_failure());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(modifications)
|
||||
}
|
||||
|
||||
async fn connect_and_run(
|
||||
&self,
|
||||
milter: &Milter,
|
||||
message: Option<&AuthenticatedMessage<'_>>,
|
||||
) -> Result<Vec<Modification>, Rejection> {
|
||||
// Build client
|
||||
let client = MilterClient::connect(milter, self.data.session_id).await?;
|
||||
if !milter.tls {
|
||||
self.run(client, message).await
|
||||
} else {
|
||||
self.run(
|
||||
client
|
||||
.into_tls(
|
||||
if !milter.tls_allow_invalid_certs {
|
||||
&self.server.inner.data.smtp_connectors.pki_verify
|
||||
} else {
|
||||
&self.server.inner.data.smtp_connectors.dummy_verify
|
||||
},
|
||||
&milter.hostname,
|
||||
)
|
||||
.await?,
|
||||
message,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
async fn run<S: AsyncRead + AsyncWrite + Unpin>(
|
||||
&self,
|
||||
mut client: MilterClient<S>,
|
||||
message: Option<&AuthenticatedMessage<'_>>,
|
||||
) -> Result<Vec<Modification>, Rejection> {
|
||||
// Option negotiation
|
||||
client.init().await?;
|
||||
|
||||
// Connect stage
|
||||
let client_ptr = self
|
||||
.data
|
||||
.iprev
|
||||
.as_ref()
|
||||
.and_then(|ip_rev| ip_rev.ptr.as_ref())
|
||||
.and_then(|ptrs| ptrs.first())
|
||||
.map(|s| s.as_ref());
|
||||
client
|
||||
.connection(
|
||||
client_ptr.unwrap_or(self.data.helo_domain.as_str()),
|
||||
self.data.remote_ip,
|
||||
self.data.remote_port,
|
||||
Macros::new()
|
||||
.with_daemon_name(DAEMON_NAME)
|
||||
.with_local_hostname(&self.hostname)
|
||||
.with_client_address(self.data.remote_ip)
|
||||
.with_client_port(self.data.remote_port)
|
||||
.with_client_ptr(client_ptr.unwrap_or("unknown")),
|
||||
)
|
||||
.await?
|
||||
.assert_continue()?;
|
||||
|
||||
// EHLO/HELO
|
||||
let (tls_version, tls_cipher) = self.stream.tls_version_and_cipher();
|
||||
client
|
||||
.helo(
|
||||
&self.data.helo_domain,
|
||||
Macros::new()
|
||||
.with_cipher(tls_cipher.as_ref())
|
||||
.with_tls_version(tls_version.as_ref()),
|
||||
)
|
||||
.await?
|
||||
.assert_continue()?;
|
||||
|
||||
// Mail from
|
||||
if let Some(mail_from) = &self.data.mail_from {
|
||||
let addr = &mail_from.address_lcase;
|
||||
client
|
||||
.mail_from(
|
||||
&format!("<{addr}>"),
|
||||
None::<&[&str]>,
|
||||
if let Some(name) = self.authenticated_as() {
|
||||
Macros::new()
|
||||
.with_mail_address(addr)
|
||||
.with_sasl_login_name(name)
|
||||
} else {
|
||||
Macros::new().with_mail_address(addr)
|
||||
},
|
||||
)
|
||||
.await?
|
||||
.assert_continue()?;
|
||||
|
||||
// Rcpt to
|
||||
for rcpt in &self.data.rcpt_to {
|
||||
client
|
||||
.rcpt_to(
|
||||
&format!("<{}>", rcpt.address_lcase),
|
||||
None::<&[&str]>,
|
||||
Macros::new().with_rcpt_address(&rcpt.address_lcase),
|
||||
)
|
||||
.await?
|
||||
.assert_continue()?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(message) = message {
|
||||
// Data
|
||||
client.data().await?.assert_continue()?;
|
||||
|
||||
// Headers
|
||||
client
|
||||
.headers(message.raw_parsed_headers().iter().map(|(k, v)| {
|
||||
(
|
||||
std::str::from_utf8(k).unwrap_or_default(),
|
||||
std::str::from_utf8(v).unwrap_or_default(),
|
||||
)
|
||||
}))
|
||||
.await?
|
||||
.assert_continue()?;
|
||||
|
||||
// Message body
|
||||
let (action, modifications) = client.body(message.raw_message()).await?;
|
||||
action.assert_continue()?;
|
||||
|
||||
// Quit
|
||||
let _ = client.quit().await;
|
||||
|
||||
// Return modifications
|
||||
Ok(modifications)
|
||||
} else {
|
||||
// Quit
|
||||
let _ = client.quit().await;
|
||||
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionData {
|
||||
pub fn apply_milter_modifications(
|
||||
&mut self,
|
||||
modifications: Vec<Modification>,
|
||||
message: &AuthenticatedMessage<'_>,
|
||||
) -> Option<Vec<u8>> {
|
||||
let mut body = Vec::new();
|
||||
let mut header_changes = Vec::new();
|
||||
let mut needs_rewrite = false;
|
||||
|
||||
for modification in modifications {
|
||||
match modification {
|
||||
Modification::ChangeFrom { sender, mut args } => {
|
||||
// Change sender
|
||||
let sender = strip_brackets(&sender);
|
||||
let address_lcase = sender.to_lowercase();
|
||||
let mut mail_from = SessionAddress {
|
||||
domain: address_lcase.domain_part().into(),
|
||||
address_lcase,
|
||||
address: sender,
|
||||
flags: 0,
|
||||
dsn_info: None,
|
||||
};
|
||||
if !args.is_empty() {
|
||||
args.push('\n');
|
||||
match Rfc5321Parser::new(&mut args.as_bytes().iter())
|
||||
.mail_from_parameters(Cow::Borrowed(""))
|
||||
{
|
||||
Ok(addr) => {
|
||||
mail_from.flags = addr.flags;
|
||||
mail_from.dsn_info = addr.env_id.map(|e| e.into_owned());
|
||||
}
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
Milter(MilterEvent::ParseError),
|
||||
SpanId = self.session_id,
|
||||
Details = "Failed to parse milter mailFrom parameters",
|
||||
Reason = err.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.mail_from = Some(mail_from);
|
||||
}
|
||||
Modification::AddRcpt {
|
||||
recipient,
|
||||
mut args,
|
||||
} => {
|
||||
// Add recipient
|
||||
let recipient = strip_brackets(&recipient);
|
||||
if recipient.contains('@') {
|
||||
let address_lcase = recipient.to_lowercase();
|
||||
let mut rcpt = SessionAddress {
|
||||
domain: address_lcase.domain_part().into(),
|
||||
address_lcase,
|
||||
address: recipient,
|
||||
flags: 0,
|
||||
dsn_info: None,
|
||||
};
|
||||
if !args.is_empty() {
|
||||
args.push('\n');
|
||||
match Rfc5321Parser::new(&mut args.as_bytes().iter())
|
||||
.rcpt_to_parameters(Cow::Borrowed(""))
|
||||
{
|
||||
Ok(addr) => {
|
||||
rcpt.flags = addr.flags;
|
||||
rcpt.dsn_info = addr.orcpt.map(|e| e.into_owned());
|
||||
}
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
Milter(MilterEvent::ParseError),
|
||||
SpanId = self.session_id,
|
||||
Details = "Failed to parse milter rcptTo parameters",
|
||||
Reason = err.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !self.rcpt_to.contains(&rcpt) {
|
||||
self.rcpt_to.push(rcpt);
|
||||
}
|
||||
}
|
||||
}
|
||||
Modification::DeleteRcpt { recipient } => {
|
||||
let recipient = strip_brackets(&recipient);
|
||||
self.rcpt_to.retain(|r| r.address_lcase != recipient);
|
||||
}
|
||||
Modification::ReplaceBody { value } => {
|
||||
body.extend(value);
|
||||
}
|
||||
Modification::AddHeader { name, value } => {
|
||||
header_changes.push((0, name, value, false));
|
||||
}
|
||||
Modification::InsertHeader { index, name, value } => {
|
||||
header_changes.push((index, name, value, false));
|
||||
needs_rewrite = true;
|
||||
}
|
||||
Modification::ChangeHeader { index, name, value } => {
|
||||
if value.is_empty()
|
||||
|| message
|
||||
.raw_parsed_headers()
|
||||
.iter()
|
||||
.any(|(n, _)| n.eq_ignore_ascii_case(name.as_bytes()))
|
||||
{
|
||||
header_changes.push((index, name, value, true));
|
||||
needs_rewrite = true;
|
||||
} else {
|
||||
header_changes.push((0, name, value, false));
|
||||
}
|
||||
}
|
||||
Modification::Quarantine { reason } => {
|
||||
header_changes.push((0, "X-Quarantine".into(), reason, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If there are no header changes return
|
||||
if header_changes.is_empty() {
|
||||
return if !body.is_empty() {
|
||||
let mut new_message = Vec::with_capacity(body.len() + message.raw_headers().len());
|
||||
new_message.extend_from_slice(message.raw_headers());
|
||||
new_message.extend(body);
|
||||
Some(new_message)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
|
||||
let new_body = if !body.is_empty() {
|
||||
&body[..]
|
||||
} else {
|
||||
message.raw_body()
|
||||
};
|
||||
|
||||
if needs_rewrite {
|
||||
let mut headers = message
|
||||
.raw_parsed_headers()
|
||||
.iter()
|
||||
.map(|(h, v)| (Cow::from(*h), Cow::from(*v)))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// Perform changes
|
||||
for (index, header_name, header_value, is_change) in header_changes {
|
||||
if is_change {
|
||||
let mut header_count = 0;
|
||||
for (pos, (name, value)) in headers.iter_mut().enumerate() {
|
||||
if name.eq_ignore_ascii_case(header_name.as_bytes()) {
|
||||
header_count += 1;
|
||||
if header_count == index {
|
||||
if !header_value.is_empty() {
|
||||
*value = Cow::from(header_value.as_bytes().to_vec());
|
||||
} else {
|
||||
headers.remove(pos);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let mut header_pos = 0;
|
||||
if index > 0 {
|
||||
let mut header_count = 0;
|
||||
for (pos, (name, _)) in headers.iter().enumerate() {
|
||||
if name.eq_ignore_ascii_case(header_name.as_bytes()) {
|
||||
header_pos = pos;
|
||||
header_count += 1;
|
||||
if header_count == index {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
headers.insert(
|
||||
header_pos,
|
||||
(
|
||||
Cow::from(header_name.as_bytes().to_vec()),
|
||||
Cow::from(header_value.as_bytes().to_vec()),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Write new headers
|
||||
let mut new_message = Vec::with_capacity(
|
||||
new_body.len()
|
||||
+ message.raw_headers().len()
|
||||
+ headers
|
||||
.iter()
|
||||
.map(|(h, v)| h.len() + v.len() + 4)
|
||||
.sum::<usize>(),
|
||||
);
|
||||
for (header, value) in headers {
|
||||
new_message.extend_from_slice(header.as_ref());
|
||||
if value.first().is_some_and(|c| c.is_ascii_whitespace()) {
|
||||
new_message.extend_from_slice(b":");
|
||||
} else {
|
||||
new_message.extend_from_slice(b": ");
|
||||
}
|
||||
new_message.extend_from_slice(value.as_ref());
|
||||
if value.last().is_none_or(|c| *c != b'\n') {
|
||||
new_message.extend_from_slice(b"\r\n");
|
||||
}
|
||||
}
|
||||
new_message.extend_from_slice(b"\r\n");
|
||||
new_message.extend(new_body);
|
||||
Some(new_message)
|
||||
} else {
|
||||
let mut new_message = Vec::with_capacity(
|
||||
new_body.len()
|
||||
+ message.raw_headers().len()
|
||||
+ header_changes
|
||||
.iter()
|
||||
.map(|(_, h, v, _)| h.len() + v.len() + 4)
|
||||
.sum::<usize>(),
|
||||
);
|
||||
for (_, header, value, _) in header_changes {
|
||||
new_message.extend_from_slice(header.as_bytes());
|
||||
new_message.extend_from_slice(b": ");
|
||||
new_message.extend_from_slice(value.as_bytes());
|
||||
if !value.ends_with('\n') {
|
||||
new_message.extend_from_slice(b"\r\n");
|
||||
}
|
||||
}
|
||||
new_message.extend_from_slice(message.raw_headers());
|
||||
new_message.extend(new_body);
|
||||
Some(new_message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Action {
|
||||
fn assert_continue(self) -> Result<(), Rejection> {
|
||||
match self {
|
||||
Action::Continue | Action::Accept => Ok(()),
|
||||
action => Err(Rejection::Action(action)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Error> for Rejection {
|
||||
fn from(err: Error) -> Self {
|
||||
Rejection::Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_brackets(addr: &str) -> String {
|
||||
let addr = addr.trim();
|
||||
if let Some(addr) = addr.strip_prefix('<') {
|
||||
if let Some((addr, _)) = addr.rsplit_once('>') {
|
||||
addr.trim().into()
|
||||
} else {
|
||||
addr.trim().into()
|
||||
}
|
||||
} else {
|
||||
addr.into()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use self::receiver::Receiver;
|
||||
use common::config::smtp::session::MilterVersion;
|
||||
use registry::types::id::ObjectId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{borrow::Cow, fmt::Display, net::IpAddr, time::Duration};
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
|
||||
pub mod client;
|
||||
pub mod macros;
|
||||
pub mod message;
|
||||
pub mod protocol;
|
||||
pub mod receiver;
|
||||
|
||||
pub struct MilterClient<T: AsyncRead + AsyncWrite> {
|
||||
stream: T,
|
||||
buf: Vec<u8>,
|
||||
bytes_read: usize,
|
||||
timeout_cmd: Duration,
|
||||
timeout_data: Duration,
|
||||
receiver: Receiver,
|
||||
version: MilterVersion,
|
||||
options: u32,
|
||||
flags_actions: u32,
|
||||
flags_protocol: u32,
|
||||
id: ObjectId,
|
||||
session_id: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
Io(std::io::Error),
|
||||
FrameTooLarge(usize),
|
||||
FrameInvalid(Vec<u8>),
|
||||
Unexpected(Response),
|
||||
Timeout,
|
||||
TLSInvalidName,
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for Error {
|
||||
fn from(err: std::io::Error) -> Self {
|
||||
Error::Io(err)
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Command<'x> {
|
||||
Abort,
|
||||
Body {
|
||||
value: &'x [u8],
|
||||
},
|
||||
EndOfBody,
|
||||
Data,
|
||||
Connect {
|
||||
hostname: &'x [u8],
|
||||
port: u16,
|
||||
address: IpAddr,
|
||||
},
|
||||
Macro {
|
||||
macros: Macros<'x>,
|
||||
},
|
||||
Header {
|
||||
name: &'x [u8],
|
||||
value: &'x [u8],
|
||||
},
|
||||
EndOfHeader,
|
||||
Helo {
|
||||
hostname: &'x [u8],
|
||||
},
|
||||
MailFrom {
|
||||
sender: &'x [u8],
|
||||
args: Option<Vec<&'x [u8]>>,
|
||||
},
|
||||
Rcpt {
|
||||
recipient: &'x [u8],
|
||||
args: Option<Vec<&'x [u8]>>,
|
||||
},
|
||||
OptionNegotiation(Options),
|
||||
Quit,
|
||||
QuitNewConnection,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Response {
|
||||
Action(Action),
|
||||
Modification(Modification),
|
||||
Progress,
|
||||
Skip,
|
||||
SetSymbols,
|
||||
OptionNegotiation(Options),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Action {
|
||||
Accept,
|
||||
Continue,
|
||||
Discard,
|
||||
Reject,
|
||||
TempFail,
|
||||
ReplyCode { code: [u8; 3], text: String },
|
||||
Shutdown,
|
||||
ConnectionFailure,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum Modification {
|
||||
ChangeFrom {
|
||||
sender: String,
|
||||
args: String,
|
||||
},
|
||||
AddRcpt {
|
||||
recipient: String,
|
||||
args: String,
|
||||
},
|
||||
DeleteRcpt {
|
||||
recipient: String,
|
||||
},
|
||||
ReplaceBody {
|
||||
value: Vec<u8>,
|
||||
},
|
||||
AddHeader {
|
||||
name: String,
|
||||
value: String,
|
||||
},
|
||||
InsertHeader {
|
||||
index: u32,
|
||||
name: String,
|
||||
value: String,
|
||||
},
|
||||
ChangeHeader {
|
||||
index: u32,
|
||||
name: String,
|
||||
value: String,
|
||||
},
|
||||
Quarantine {
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Options {
|
||||
pub version: u32,
|
||||
pub actions: u32,
|
||||
pub protocol: u32,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Macros<'x> {
|
||||
cmdcode: u8,
|
||||
macros: Vec<Macro<'x>>,
|
||||
}
|
||||
|
||||
pub struct Macro<'x> {
|
||||
name: &'x [u8],
|
||||
value: Cow<'x, [u8]>,
|
||||
}
|
||||
|
||||
pub const SMFIF_NONE: u32 = 0x00000000; /* no flags */
|
||||
pub const SMFIF_ADDHDRS: u32 = 0x00000001; /* filter may add headers */
|
||||
pub const SMFIF_CHGBODY: u32 = 0x00000002; /* filter may replace body */
|
||||
pub const SMFIF_MODBODY: u32 = SMFIF_CHGBODY; /* backwards compatible */
|
||||
pub const SMFIF_ADDRCPT: u32 = 0x00000004; /* filter may add recipients */
|
||||
pub const SMFIF_DELRCPT: u32 = 0x00000008; /* filter may delete recipients */
|
||||
pub const SMFIF_CHGHDRS: u32 = 0x00000010; /* filter may change/delete headers */
|
||||
pub const SMFIF_QUARANTINE: u32 = 0x00000020; /* filter may quarantine envelope */
|
||||
pub const SMFIF_CHGFROM: u32 = 0x00000040; /* filter may change "from" (envelope sender) */
|
||||
pub const SMFIF_ADDRCPT_PAR: u32 = 0x00000080; /* add recipients incl. args */
|
||||
pub const SMFIF_SETSYMLIST: u32 = 0x00000100; /* filter can send set of symbols (macros) that it wants */
|
||||
|
||||
pub const SMFIP_NOCONNECT: u32 = 0x00000001; /* MTA should not send connect info */
|
||||
pub const SMFIP_NOHELO: u32 = 0x00000002; /* MTA should not send HELO info */
|
||||
pub const SMFIP_NOMAIL: u32 = 0x00000004; /* MTA should not send MAIL info */
|
||||
pub const SMFIP_NORCPT: u32 = 0x00000008; /* MTA should not send RCPT info */
|
||||
pub const SMFIP_NOBODY: u32 = 0x00000010; /* MTA should not send body */
|
||||
pub const SMFIP_NOHDRS: u32 = 0x00000020; /* MTA should not send headers */
|
||||
pub const SMFIP_NOEOH: u32 = 0x00000040; /* MTA should not send EOH */
|
||||
pub const SMFIP_NR_HDR: u32 = 0x00000080; /* No reply for headers */
|
||||
pub const SMFIP_NOHREPL: u32 = SMFIP_NR_HDR; /* No reply for headers */
|
||||
pub const SMFIP_NOUNKNOWN: u32 = 0x00000100; /* MTA should not send unknown commands */
|
||||
pub const SMFIP_NODATA: u32 = 0x00000200; /* MTA should not send DATA */
|
||||
pub const SMFIP_SKIP: u32 = 0x00000400; /* MTA understands SMFIS_SKIP */
|
||||
pub const SMFIP_RCPT_REJ: u32 = 0x00000800; /* MTA should also send rejected RCPTs */
|
||||
pub const SMFIP_NR_CONN: u32 = 0x00001000; /* No reply for connect */
|
||||
pub const SMFIP_NR_HELO: u32 = 0x00002000; /* No reply for HELO */
|
||||
pub const SMFIP_NR_MAIL: u32 = 0x00004000; /* No reply for MAIL */
|
||||
pub const SMFIP_NR_RCPT: u32 = 0x00008000; /* No reply for RCPT */
|
||||
pub const SMFIP_NR_DATA: u32 = 0x00010000; /* No reply for DATA */
|
||||
pub const SMFIP_NR_UNKN: u32 = 0x00020000; /* No reply for UNKN */
|
||||
pub const SMFIP_NR_EOH: u32 = 0x00040000; /* No reply for eoh */
|
||||
pub const SMFIP_NR_BODY: u32 = 0x00080000; /* No reply for body chunk */
|
||||
pub const SMFIP_HDR_LEADSPC: u32 = 0x00100000; /* header value leading space */
|
||||
pub const SMFIP_MDS_256K: u32 = 0x10000000; /* MILTER_MAX_DATA_SIZE=256K */
|
||||
pub const SMFIP_MDS_1M: u32 = 0x20000000; /* MILTER_MAX_DATA_SIZE=1M */
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
impl Display for Command<'_> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Command::Abort => write!(f, "ABORT"),
|
||||
Command::Body { value } => write!(f, "BODY [{} bytes]", value.len()),
|
||||
Command::EndOfBody => write!(f, "EOB"),
|
||||
Command::Connect {
|
||||
hostname,
|
||||
port,
|
||||
address,
|
||||
} => write!(
|
||||
f,
|
||||
"CONNECT (host: {:?}, port: {}, address: {})",
|
||||
std::str::from_utf8(hostname).unwrap_or_default(),
|
||||
port,
|
||||
address
|
||||
),
|
||||
Command::Macro { macros } => {
|
||||
write!(f, "MACRO (code: {}, params: ", macros.cmdcode)?;
|
||||
for macro_ in ¯os.macros {
|
||||
write!(
|
||||
f,
|
||||
"({:?}, {:?})",
|
||||
std::str::from_utf8(macro_.name).unwrap_or_default(),
|
||||
std::str::from_utf8(macro_.value.as_ref()).unwrap_or_default()
|
||||
)?;
|
||||
}
|
||||
write!(f, ")")
|
||||
}
|
||||
Command::Header { name, value } => {
|
||||
write!(
|
||||
f,
|
||||
"HEADER ({}: {:?})",
|
||||
std::str::from_utf8(name).unwrap_or_default(),
|
||||
std::str::from_utf8(value).unwrap_or_default()
|
||||
)
|
||||
}
|
||||
Command::EndOfHeader => write!(f, "EOH"),
|
||||
Command::Helo { hostname } => write!(
|
||||
f,
|
||||
"HELO {:?}",
|
||||
std::str::from_utf8(hostname).unwrap_or_default()
|
||||
),
|
||||
Command::MailFrom { sender, args } => {
|
||||
write!(
|
||||
f,
|
||||
"MAIL (from: {}, params: ",
|
||||
std::str::from_utf8(sender).unwrap_or_default()
|
||||
)?;
|
||||
if let Some(args) = args {
|
||||
for arg in args {
|
||||
write!(f, " {}", std::str::from_utf8(arg).unwrap_or_default())?;
|
||||
}
|
||||
}
|
||||
write!(f, ")")
|
||||
}
|
||||
Command::Rcpt { recipient, args } => {
|
||||
write!(
|
||||
f,
|
||||
"RCPT (to: {}, params: ",
|
||||
std::str::from_utf8(recipient).unwrap_or_default()
|
||||
)?;
|
||||
if let Some(args) = args {
|
||||
for arg in args {
|
||||
write!(f, " {}", std::str::from_utf8(arg).unwrap_or_default())?;
|
||||
}
|
||||
}
|
||||
write!(f, ")")
|
||||
}
|
||||
Command::OptionNegotiation(opt) => write!(f, "OPTNEG ({})", opt),
|
||||
Command::Quit => write!(f, "QUIT"),
|
||||
Command::Data => write!(f, "DATA"),
|
||||
Command::QuitNewConnection => write!(f, "QUIT_NC"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Response {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Response::Action(action) => write!(f, "ACTION ({})", action),
|
||||
Response::Modification(modification) => write!(f, "MODIFICATION ({})", modification),
|
||||
Response::Progress => write!(f, "PROGRESS"),
|
||||
Response::OptionNegotiation(opt) => write!(f, "OPTNEG ({})", opt),
|
||||
Response::Skip => write!(f, "SKIP"),
|
||||
Response::SetSymbols => write!(f, "SET_SYMBOLS"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Action {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Action::Accept => write!(f, "ACCEPT"),
|
||||
Action::Continue => write!(f, "CONTINUE"),
|
||||
Action::Discard => write!(f, "DISCARD"),
|
||||
Action::Reject => write!(f, "REJECT"),
|
||||
Action::TempFail => write!(f, "TEMPFAIL"),
|
||||
Action::ReplyCode { code, text } => {
|
||||
write!(f, "REPLYCODE (code: {:?}, text: {})", code, text)
|
||||
}
|
||||
Action::Shutdown => write!(f, "SHUTDOWN"),
|
||||
Action::ConnectionFailure => write!(f, "CONN_FAIL"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Modification {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Modification::AddRcpt { recipient, args } => {
|
||||
write!(f, "ADD_RCPT (recipient: {}, args: {})", recipient, args)
|
||||
}
|
||||
Modification::DeleteRcpt { recipient } => {
|
||||
write!(f, "DEL_RCPT (recipient: {})", recipient)
|
||||
}
|
||||
Modification::ReplaceBody { value } => {
|
||||
write!(f, "REPLACE_BODY ({} bytes)", value.len())
|
||||
}
|
||||
Modification::AddHeader { name, value } => {
|
||||
write!(f, "ADD_HEADER ({}: {})", name, value)
|
||||
}
|
||||
Modification::ChangeHeader { index, name, value } => {
|
||||
write!(f, "CHANGE_HEADER (index: {}, {}: {})", index, name, value)
|
||||
}
|
||||
Modification::Quarantine { reason } => write!(f, "QUARANTINE ({})", reason),
|
||||
Modification::ChangeFrom { sender, args } => {
|
||||
write!(f, "CHANGE_FROM (<{}> {})", sender, args)
|
||||
}
|
||||
Modification::InsertHeader { index, name, value } => {
|
||||
write!(f, "INSERT_HEADER (index: {}, {}: {})", index, name, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Options {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "version: {}, actions: [", self.version,)?;
|
||||
|
||||
if self.actions & SMFIF_ADDHDRS != 0 {
|
||||
write!(f, "ADDHDRS ")?;
|
||||
}
|
||||
if self.actions & SMFIF_CHGBODY != 0 {
|
||||
write!(f, "CHGBODY ")?;
|
||||
}
|
||||
if self.actions & SMFIF_CHGHDRS != 0 {
|
||||
write!(f, "CHGHDRS ")?;
|
||||
}
|
||||
if self.actions & SMFIF_ADDRCPT != 0 {
|
||||
write!(f, "ADDRCPT ")?;
|
||||
}
|
||||
if self.actions & SMFIF_DELRCPT != 0 {
|
||||
write!(f, "DELRCPT ")?;
|
||||
}
|
||||
if self.actions & SMFIF_CHGFROM != 0 {
|
||||
write!(f, "CHGFROM ")?;
|
||||
}
|
||||
if self.actions & SMFIF_QUARANTINE != 0 {
|
||||
write!(f, "QUARANTINE ")?;
|
||||
}
|
||||
if self.actions & SMFIF_CHGFROM != 0 {
|
||||
write!(f, "CHGFROM ")?;
|
||||
}
|
||||
if self.actions & SMFIF_ADDRCPT_PAR != 0 {
|
||||
write!(f, "ADDRCPT_PAR ")?;
|
||||
}
|
||||
if self.actions & SMFIF_SETSYMLIST != 0 {
|
||||
write!(f, "SETSYMLIST ")?;
|
||||
}
|
||||
write!(f, "], options: [",)?;
|
||||
|
||||
if self.protocol & SMFIP_NOCONNECT != 0 {
|
||||
write!(f, "NOCONNECT ")?;
|
||||
}
|
||||
|
||||
if self.protocol & SMFIP_NOHELO != 0 {
|
||||
write!(f, "NOHELO ")?;
|
||||
}
|
||||
|
||||
if self.protocol & SMFIP_NOMAIL != 0 {
|
||||
write!(f, "NOMAIL ")?;
|
||||
}
|
||||
|
||||
if self.protocol & SMFIP_NORCPT != 0 {
|
||||
write!(f, "NORCPT ")?;
|
||||
}
|
||||
|
||||
if self.protocol & SMFIP_NOBODY != 0 {
|
||||
write!(f, "NOBODY ")?;
|
||||
}
|
||||
|
||||
if self.protocol & SMFIP_NOHDRS != 0 {
|
||||
write!(f, "NOHDRS ")?;
|
||||
}
|
||||
|
||||
if self.protocol & SMFIP_NOEOH != 0 {
|
||||
write!(f, "NOEOH ")?;
|
||||
}
|
||||
|
||||
if self.protocol & SMFIP_NR_HDR != 0 {
|
||||
write!(f, "NR_HDR ")?;
|
||||
}
|
||||
|
||||
if self.protocol & SMFIP_NOUNKNOWN != 0 {
|
||||
write!(f, "NOUNKNOWN ")?;
|
||||
}
|
||||
|
||||
if self.protocol & SMFIP_NODATA != 0 {
|
||||
write!(f, "NODATA ")?;
|
||||
}
|
||||
|
||||
if self.protocol & SMFIP_SKIP != 0 {
|
||||
write!(f, "SKIP ")?;
|
||||
}
|
||||
|
||||
if self.protocol & SMFIP_RCPT_REJ != 0 {
|
||||
write!(f, "RCPT_REJ ")?;
|
||||
}
|
||||
|
||||
if self.protocol & SMFIP_NR_CONN != 0 {
|
||||
write!(f, "NR_CONN ")?;
|
||||
}
|
||||
|
||||
if self.protocol & SMFIP_NR_HELO != 0 {
|
||||
write!(f, "NR_HELO ")?;
|
||||
}
|
||||
|
||||
if self.protocol & SMFIP_NR_MAIL != 0 {
|
||||
write!(f, "NR_MAIL ")?;
|
||||
}
|
||||
|
||||
if self.protocol & SMFIP_NR_RCPT != 0 {
|
||||
write!(f, "NR_RCPT ")?;
|
||||
}
|
||||
|
||||
if self.protocol & SMFIP_NR_DATA != 0 {
|
||||
write!(f, "NR_DATA ")?;
|
||||
}
|
||||
|
||||
if self.protocol & SMFIP_NR_UNKN != 0 {
|
||||
write!(f, "NR_UNKN ")?;
|
||||
}
|
||||
|
||||
if self.protocol & SMFIP_NR_EOH != 0 {
|
||||
write!(f, "NR_EOH ")?;
|
||||
}
|
||||
|
||||
if self.protocol & SMFIP_NR_BODY != 0 {
|
||||
write!(f, "NR_BODY ")?;
|
||||
}
|
||||
|
||||
if self.protocol & SMFIP_HDR_LEADSPC != 0 {
|
||||
write!(f, "HDR_LEADSPC ")?;
|
||||
}
|
||||
|
||||
if self.protocol & SMFIP_MDS_256K != 0 {
|
||||
write!(f, "MDS_256K ")?;
|
||||
}
|
||||
|
||||
if self.protocol & SMFIP_MDS_1M != 0 {
|
||||
write!(f, "MDS_1M ")?;
|
||||
}
|
||||
|
||||
write!(f, "]")
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Error::Io(err) => write!(f, "IO error: {}", err),
|
||||
Error::FrameTooLarge(size) => {
|
||||
write!(f, "Milter response of {} bytes is too large.", size)
|
||||
}
|
||||
Error::FrameInvalid(frame) => write!(
|
||||
f,
|
||||
"Invalid milter response: {:?}",
|
||||
frame.get(0..100).unwrap_or(frame.as_ref())
|
||||
),
|
||||
Error::Unexpected(response) => write!(f, "Unexpected response: {}", response),
|
||||
Error::Timeout => write!(f, "Connection timed out"),
|
||||
Error::TLSInvalidName => write!(f, "Invalid TLS name"),
|
||||
Error::Disconnected => write!(f, "Disconnected unexpectedly"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use std::net::IpAddr;
|
||||
|
||||
use crate::inbound::milter::Action;
|
||||
|
||||
use super::{Command, Error, Modification, Options, Response};
|
||||
|
||||
pub const SMFIR_ADDRCPT: u8 = b'+'; /* add recipient */
|
||||
pub const SMFIR_DELRCPT: u8 = b'-'; /* remove recipient */
|
||||
pub const SMFIR_ADDRCPT_PAR: u8 = b'2'; /* add recipient (incl. ESMTP args) */
|
||||
pub const SMFIR_SHUTDOWN: u8 = b'4'; /* 421: shutdown (internal to MTA) */
|
||||
pub const SMFIR_ACCEPT: u8 = b'a'; /* accept */
|
||||
pub const SMFIR_REPLBODY: u8 = b'b'; /* replace body (chunk) */
|
||||
pub const SMFIR_CONTINUE: u8 = b'c'; /* continue */
|
||||
pub const SMFIR_DISCARD: u8 = b'd'; /* discard */
|
||||
pub const SMFIR_CHGFROM: u8 = b'e'; /* change envelope sender (from) */
|
||||
pub const SMFIR_CONN_FAIL: u8 = b'f'; /* cause a connection failure */
|
||||
pub const SMFIR_ADDHEADER: u8 = b'h'; /* add header */
|
||||
pub const SMFIR_INSHEADER: u8 = b'i'; /* insert header */
|
||||
pub const SMFIR_SETSYMLIST: u8 = b'l'; /* set list of symbols (macros) */
|
||||
pub const SMFIR_CHGHEADER: u8 = b'm'; /* change header */
|
||||
pub const SMFIR_PROGRESS: u8 = b'p'; /* progress */
|
||||
pub const SMFIR_QUARANTINE: u8 = b'q'; /* quarantine */
|
||||
pub const SMFIR_REJECT: u8 = b'r'; /* reject */
|
||||
pub const SMFIR_SKIP: u8 = b's'; /* skip */
|
||||
pub const SMFIR_TEMPFAIL: u8 = b't'; /* tempfail */
|
||||
pub const SMFIR_REPLYCODE: u8 = b'y'; /* reply code etc */
|
||||
|
||||
pub const SMFIC_ABORT: u8 = b'A'; /* Abort */
|
||||
pub const SMFIC_BODY: u8 = b'B'; /* Body chunk */
|
||||
pub const SMFIC_CONNECT: u8 = b'C'; /* Connection information */
|
||||
pub const SMFIC_MACRO: u8 = b'D'; /* Define macro */
|
||||
pub const SMFIC_BODYEOB: u8 = b'E'; /* final body chunk (End) */
|
||||
pub const SMFIC_HELO: u8 = b'H'; /* HELO/EHLO */
|
||||
pub const SMFIC_QUIT_NC: u8 = b'K'; /* QUIT but new connection follows */
|
||||
pub const SMFIC_HEADER: u8 = b'L'; /* Header */
|
||||
pub const SMFIC_MAIL: u8 = b'M'; /* MAIL from */
|
||||
pub const SMFIC_EOH: u8 = b'N'; /* EOH */
|
||||
pub const SMFIC_OPTNEG: u8 = b'O'; /* Option negotiation */
|
||||
pub const SMFIC_QUIT: u8 = b'Q'; /* QUIT */
|
||||
pub const SMFIC_RCPT: u8 = b'R'; /* RCPT to */
|
||||
pub const SMFIC_DATA: u8 = b'T'; /* DATA */
|
||||
pub const SMFIC_UNKNOWN: u8 = b'U'; /* Any unknown command */
|
||||
|
||||
impl Command<'_> {
|
||||
fn build(command: u8, len: u32) -> Vec<u8> {
|
||||
let mut buf = Vec::with_capacity(len as usize + 1 + std::mem::size_of::<u32>());
|
||||
buf.extend_from_slice((len + 1).to_be_bytes().as_slice());
|
||||
buf.push(command);
|
||||
buf
|
||||
}
|
||||
|
||||
pub fn serialize(self) -> Vec<u8> {
|
||||
match self {
|
||||
Command::Abort => Command::build(SMFIC_ABORT, 0),
|
||||
Command::Body { value } => {
|
||||
let mut buf = Command::build(SMFIC_BODY, value.len() as u32);
|
||||
buf.extend(value);
|
||||
buf
|
||||
}
|
||||
Command::EndOfBody => Command::build(SMFIC_BODYEOB, 0),
|
||||
Command::Connect {
|
||||
hostname,
|
||||
port,
|
||||
address,
|
||||
} => {
|
||||
/*
|
||||
|
||||
char hostname[] Hostname, NUL terminated
|
||||
char family Protocol family (see below)
|
||||
uint16 port Port number (SMFIA_INET or SMFIA_INET6 only)
|
||||
char address[] IP address (ASCII) or unix socket path, NUL terminated
|
||||
|
||||
*/
|
||||
|
||||
let (address, family) = match address {
|
||||
IpAddr::V4(address) => (address.to_string(), b'4'),
|
||||
IpAddr::V6(address) => (address.to_string(), b'6'),
|
||||
};
|
||||
|
||||
let mut buf = Command::build(
|
||||
SMFIC_CONNECT,
|
||||
hostname.len() as u32 // hostname
|
||||
+ 1 // NUL
|
||||
+ 1 // family
|
||||
+ std::mem::size_of::<u16>() as u32 // port
|
||||
+ address.len() as u32 // address
|
||||
+ 1, // NUL
|
||||
);
|
||||
buf.extend(hostname);
|
||||
buf.push(0x00);
|
||||
buf.push(family);
|
||||
buf.extend(port.to_be_bytes().as_slice());
|
||||
buf.extend(address.as_bytes());
|
||||
buf.push(0x00);
|
||||
buf
|
||||
}
|
||||
Command::Macro { macros } => {
|
||||
let mut buf = Command::build(
|
||||
SMFIC_MACRO,
|
||||
macros.macros.iter().fold(1, |acc, macro_| {
|
||||
acc + macro_.name.len() as u32 + 1 + macro_.value.len() as u32 + 1
|
||||
}),
|
||||
);
|
||||
buf.push(macros.cmdcode);
|
||||
for macro_ in macros.macros {
|
||||
buf.extend(macro_.name);
|
||||
buf.push(0x00);
|
||||
buf.extend(macro_.value.as_ref());
|
||||
buf.push(0x00);
|
||||
}
|
||||
buf
|
||||
}
|
||||
Command::Header { name, value } => {
|
||||
let mut buf =
|
||||
Command::build(SMFIC_HEADER, name.len() as u32 + 1 + value.len() as u32 + 1);
|
||||
buf.extend(name);
|
||||
buf.push(0x00);
|
||||
buf.extend(value);
|
||||
buf.push(0x00);
|
||||
buf
|
||||
}
|
||||
Command::EndOfHeader => Command::build(SMFIC_EOH, 0),
|
||||
Command::Helo { hostname } => {
|
||||
let mut buf = Command::build(SMFIC_HELO, hostname.len() as u32 + 1);
|
||||
buf.extend(hostname);
|
||||
buf.push(0x00);
|
||||
buf
|
||||
}
|
||||
Command::MailFrom { sender, args } => {
|
||||
let mut buf = Command::build(
|
||||
SMFIC_MAIL,
|
||||
sender.len() as u32 // sender
|
||||
+ 1 // NUL
|
||||
+ args.as_ref().map_or(0, |args| args.iter().fold(0, |acc, arg| acc + arg.len() as u32) + 1), // args
|
||||
);
|
||||
buf.extend(sender);
|
||||
buf.push(0x00);
|
||||
if let Some(args) = args {
|
||||
for arg in args {
|
||||
buf.extend(arg);
|
||||
buf.push(0x00);
|
||||
}
|
||||
}
|
||||
buf
|
||||
}
|
||||
Command::Rcpt { recipient, args } => {
|
||||
let mut buf = Command::build(
|
||||
SMFIC_RCPT,
|
||||
recipient.len() as u32 // recipient
|
||||
+ 1 // NUL
|
||||
+ args.as_ref().map_or(0, |args| args.iter().fold(0, |acc, arg| acc + arg.len() as u32) + 1), // args
|
||||
);
|
||||
buf.extend(recipient);
|
||||
buf.push(0x00);
|
||||
if let Some(args) = args {
|
||||
for arg in args {
|
||||
buf.extend(arg);
|
||||
buf.push(0x00);
|
||||
}
|
||||
}
|
||||
buf
|
||||
}
|
||||
Command::OptionNegotiation(opt) => {
|
||||
let mut buf = Command::build(SMFIC_OPTNEG, 3 * std::mem::size_of::<u32>() as u32);
|
||||
buf.extend(opt.version.to_be_bytes().as_slice());
|
||||
buf.extend(opt.actions.to_be_bytes().as_slice());
|
||||
buf.extend(opt.protocol.to_be_bytes().as_slice());
|
||||
buf
|
||||
}
|
||||
Command::Quit => Command::build(SMFIC_QUIT, 0),
|
||||
|
||||
// Version 6
|
||||
Command::Data => Command::build(SMFIC_DATA, 0),
|
||||
Command::QuitNewConnection => Command::build(SMFIC_QUIT_NC, 0),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "test_mode")]
|
||||
pub fn deserialize(bytes: &[u8]) -> Command<'_> {
|
||||
let mut reader = PacketReader::new(bytes);
|
||||
match reader.byte() {
|
||||
SMFIC_ABORT => Command::Abort,
|
||||
SMFIC_BODY => Command::Body { value: &bytes[1..] },
|
||||
SMFIC_BODYEOB => Command::EndOfBody,
|
||||
SMFIC_CONNECT => {
|
||||
let hostname = reader.read_nul_terminated().unwrap();
|
||||
let family = reader.byte();
|
||||
let port = reader.read_u16();
|
||||
let address = std::str::from_utf8(reader.read_nul_terminated().unwrap()).unwrap();
|
||||
Command::Connect {
|
||||
hostname,
|
||||
port,
|
||||
address: match family {
|
||||
b'4' => IpAddr::V4(address.parse().unwrap()),
|
||||
b'6' => IpAddr::V6(address.parse().unwrap()),
|
||||
_ => unreachable!(),
|
||||
},
|
||||
}
|
||||
}
|
||||
SMFIC_MACRO => {
|
||||
let cmdcode = reader.byte();
|
||||
let mut macros = Vec::new();
|
||||
while let Some(name) = reader.read_nul_terminated() {
|
||||
let value = reader.read_nul_terminated().unwrap();
|
||||
macros.push(super::Macro {
|
||||
name,
|
||||
value: value.into(),
|
||||
});
|
||||
}
|
||||
Command::Macro {
|
||||
macros: super::Macros { cmdcode, macros },
|
||||
}
|
||||
}
|
||||
SMFIC_HEADER => {
|
||||
let name = reader.read_nul_terminated().unwrap();
|
||||
let value = reader.read_nul_terminated().unwrap();
|
||||
Command::Header { name, value }
|
||||
}
|
||||
SMFIC_EOH => Command::EndOfHeader,
|
||||
SMFIC_HELO => {
|
||||
let hostname = reader.read_nul_terminated().unwrap();
|
||||
Command::Helo { hostname }
|
||||
}
|
||||
SMFIC_MAIL => {
|
||||
let sender = reader.read_nul_terminated().unwrap();
|
||||
let mut args = Vec::new();
|
||||
while let Some(arg) = reader.read_nul_terminated() {
|
||||
args.push(arg);
|
||||
}
|
||||
Command::MailFrom {
|
||||
sender,
|
||||
args: Some(args),
|
||||
}
|
||||
}
|
||||
SMFIC_RCPT => {
|
||||
let recipient = reader.read_nul_terminated().unwrap();
|
||||
let mut args = Vec::new();
|
||||
while let Some(arg) = reader.read_nul_terminated() {
|
||||
args.push(arg);
|
||||
}
|
||||
Command::Rcpt {
|
||||
recipient,
|
||||
args: Some(args),
|
||||
}
|
||||
}
|
||||
SMFIC_OPTNEG => Command::OptionNegotiation(super::Options {
|
||||
version: reader.read_u32(),
|
||||
actions: reader.read_u32(),
|
||||
protocol: reader.read_u32(),
|
||||
}),
|
||||
SMFIC_QUIT => Command::Quit,
|
||||
SMFIC_DATA => Command::Data,
|
||||
SMFIC_QUIT_NC => Command::QuitNewConnection,
|
||||
c => panic!("Unknown command: {}", char::from(c)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Response {
|
||||
pub fn deserialize(bytes: &[u8]) -> Option<Self> {
|
||||
let frame_len = bytes.len().saturating_sub(1);
|
||||
let mut bytes = bytes.iter();
|
||||
match *bytes.next()? {
|
||||
SMFIR_ADDRCPT => Response::Modification(Modification::AddRcpt {
|
||||
recipient: read_nul_terminated(&mut bytes, frame_len)?,
|
||||
args: String::new(),
|
||||
}),
|
||||
SMFIR_DELRCPT => Response::Modification(Modification::DeleteRcpt {
|
||||
recipient: read_nul_terminated(&mut bytes, frame_len)?,
|
||||
}),
|
||||
SMFIR_ACCEPT => Response::Action(Action::Accept),
|
||||
SMFIR_REPLBODY => {
|
||||
let mut body = Vec::with_capacity(frame_len);
|
||||
body.extend(bytes);
|
||||
Response::Modification(Modification::ReplaceBody { value: body })
|
||||
}
|
||||
SMFIR_CONTINUE => Response::Action(Action::Continue),
|
||||
SMFIR_DISCARD => Response::Action(Action::Discard),
|
||||
SMFIR_ADDHEADER => Response::Modification(Modification::AddHeader {
|
||||
name: read_nul_terminated(&mut bytes, 16)?,
|
||||
value: read_nul_terminated(&mut bytes, frame_len)?,
|
||||
}),
|
||||
SMFIR_CHGHEADER => Response::Modification(Modification::ChangeHeader {
|
||||
index: read_u32(&mut bytes)?,
|
||||
name: read_nul_terminated(&mut bytes, 16)?,
|
||||
value: read_nul_terminated(&mut bytes, frame_len)?,
|
||||
}),
|
||||
SMFIR_PROGRESS => Response::Progress,
|
||||
SMFIR_QUARANTINE => Response::Modification(Modification::Quarantine {
|
||||
reason: read_nul_terminated(&mut bytes, frame_len)?,
|
||||
}),
|
||||
SMFIR_REJECT => Response::Action(Action::Reject),
|
||||
SMFIR_TEMPFAIL => Response::Action(Action::TempFail),
|
||||
SMFIR_REPLYCODE => {
|
||||
let code = [*bytes.next()?, *bytes.next()?, *bytes.next()?];
|
||||
bytes.next()?; // Space
|
||||
Response::Action(Action::ReplyCode {
|
||||
code,
|
||||
text: read_nul_terminated(&mut bytes, frame_len)?,
|
||||
})
|
||||
}
|
||||
SMFIC_OPTNEG => Response::OptionNegotiation(Options {
|
||||
version: read_u32(&mut bytes)?,
|
||||
actions: read_u32(&mut bytes)?,
|
||||
protocol: read_u32(&mut bytes)?,
|
||||
}),
|
||||
|
||||
// V6
|
||||
SMFIR_ADDRCPT_PAR => Response::Modification(Modification::AddRcpt {
|
||||
recipient: read_nul_terminated(&mut bytes, frame_len)?,
|
||||
args: read_nul_terminated(&mut bytes, frame_len)?,
|
||||
}),
|
||||
SMFIR_CHGFROM => Response::Modification(Modification::ChangeFrom {
|
||||
sender: read_nul_terminated(&mut bytes, frame_len)?,
|
||||
args: read_nul_terminated(&mut bytes, frame_len)?,
|
||||
}),
|
||||
SMFIR_SKIP => Response::Skip,
|
||||
SMFIR_SETSYMLIST => Response::SetSymbols,
|
||||
SMFIR_SHUTDOWN => Response::Action(Action::Shutdown),
|
||||
SMFIR_CONN_FAIL => Response::Action(Action::ConnectionFailure),
|
||||
SMFIR_INSHEADER => Response::Modification(Modification::InsertHeader {
|
||||
index: read_u32(&mut bytes)?,
|
||||
name: read_nul_terminated(&mut bytes, 16)?,
|
||||
value: read_nul_terminated(&mut bytes, frame_len)?,
|
||||
}),
|
||||
_ => return None,
|
||||
}
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn can_continue(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Response::Progress | Response::Action(Action::Accept | Action::Continue)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn into_action(self) -> super::Result<Action> {
|
||||
match self {
|
||||
Response::Action(action) => Ok(action),
|
||||
response => Err(Error::Unexpected(response)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "test_mode")]
|
||||
pub fn serialize(&self) -> Vec<u8> {
|
||||
match self {
|
||||
Response::Action(action) => match action {
|
||||
Action::Accept => Command::build(SMFIR_ACCEPT, 0),
|
||||
Action::Continue => Command::build(SMFIR_CONTINUE, 0),
|
||||
Action::Discard => Command::build(SMFIR_DISCARD, 0),
|
||||
Action::Reject => Command::build(SMFIR_REJECT, 0),
|
||||
Action::TempFail => Command::build(SMFIR_TEMPFAIL, 0),
|
||||
Action::ReplyCode { code, text } => {
|
||||
let mut buf = Command::build(SMFIR_REPLYCODE, text.len() as u32 + 4 + 1);
|
||||
buf.extend(code);
|
||||
buf.push(b' ');
|
||||
buf.extend(text.as_bytes());
|
||||
buf.push(0x00);
|
||||
buf
|
||||
}
|
||||
Action::Shutdown => Command::build(SMFIR_SHUTDOWN, 0),
|
||||
Action::ConnectionFailure => Command::build(SMFIR_CONN_FAIL, 0),
|
||||
},
|
||||
Response::Modification(modif) => match modif {
|
||||
Modification::ChangeFrom { sender, args } => {
|
||||
let mut buf =
|
||||
Command::build(SMFIR_CHGFROM, sender.len() as u32 + args.len() as u32 + 2);
|
||||
buf.extend(sender.as_bytes());
|
||||
buf.push(0x00);
|
||||
buf.extend(args.as_bytes());
|
||||
buf.push(0x00);
|
||||
buf
|
||||
}
|
||||
Modification::AddRcpt { recipient, args } => {
|
||||
let mut buf = Command::build(
|
||||
SMFIR_ADDRCPT_PAR,
|
||||
recipient.len() as u32 + args.len() as u32 + 2,
|
||||
);
|
||||
buf.extend(recipient.as_bytes());
|
||||
buf.push(0x00);
|
||||
buf.extend(args.as_bytes());
|
||||
buf.push(0x00);
|
||||
buf
|
||||
}
|
||||
Modification::DeleteRcpt { recipient } => {
|
||||
let mut buf = Command::build(SMFIR_DELRCPT, recipient.len() as u32 + 1);
|
||||
buf.extend(recipient.as_bytes());
|
||||
buf.push(0x00);
|
||||
buf
|
||||
}
|
||||
Modification::ReplaceBody { value } => {
|
||||
let mut buf = Command::build(SMFIR_REPLBODY, value.len() as u32);
|
||||
buf.extend(value);
|
||||
buf
|
||||
}
|
||||
Modification::AddHeader { name, value } => {
|
||||
let mut buf =
|
||||
Command::build(SMFIR_ADDHEADER, name.len() as u32 + value.len() as u32 + 2);
|
||||
buf.extend(name.as_bytes());
|
||||
buf.push(0x00);
|
||||
buf.extend(value.as_bytes());
|
||||
buf.push(0x00);
|
||||
buf
|
||||
}
|
||||
Modification::InsertHeader { index, name, value } => {
|
||||
let mut buf = Command::build(
|
||||
SMFIR_INSHEADER,
|
||||
name.len() as u32
|
||||
+ value.len() as u32
|
||||
+ std::mem::size_of::<u32>() as u32
|
||||
+ 2,
|
||||
);
|
||||
buf.extend(index.to_be_bytes().as_slice());
|
||||
buf.extend(name.as_bytes());
|
||||
buf.push(0x00);
|
||||
buf.extend(value.as_bytes());
|
||||
buf.push(0x00);
|
||||
buf
|
||||
}
|
||||
Modification::ChangeHeader { index, name, value } => {
|
||||
let mut buf = Command::build(
|
||||
SMFIR_CHGHEADER,
|
||||
name.len() as u32
|
||||
+ value.len() as u32
|
||||
+ std::mem::size_of::<u32>() as u32
|
||||
+ 2,
|
||||
);
|
||||
buf.extend(index.to_be_bytes().as_slice());
|
||||
buf.extend(name.as_bytes());
|
||||
buf.push(0x00);
|
||||
buf.extend(value.as_bytes());
|
||||
buf.push(0x00);
|
||||
buf
|
||||
}
|
||||
Modification::Quarantine { reason } => {
|
||||
let mut buf = Command::build(SMFIR_QUARANTINE, reason.len() as u32 + 1);
|
||||
buf.extend(reason.as_bytes());
|
||||
buf.push(0x00);
|
||||
buf
|
||||
}
|
||||
},
|
||||
Response::Progress => Command::build(SMFIR_PROGRESS, 0),
|
||||
Response::Skip => Command::build(SMFIR_SKIP, 0),
|
||||
Response::SetSymbols => Command::build(SMFIR_SETSYMLIST, 0),
|
||||
Response::OptionNegotiation(opt) => {
|
||||
let mut buf = Command::build(SMFIC_OPTNEG, 3 * std::mem::size_of::<u32>() as u32);
|
||||
buf.extend(opt.version.to_be_bytes().as_slice());
|
||||
buf.extend(opt.actions.to_be_bytes().as_slice());
|
||||
buf.extend(opt.protocol.to_be_bytes().as_slice());
|
||||
buf
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_nul_terminated(bytes: &mut std::slice::Iter<u8>, expected_len: usize) -> Option<String> {
|
||||
let mut buf = Vec::with_capacity(expected_len);
|
||||
loop {
|
||||
match bytes.next()? {
|
||||
0x00 => break,
|
||||
byte => buf.push(*byte),
|
||||
}
|
||||
}
|
||||
String::from_utf8(buf).ok()
|
||||
}
|
||||
|
||||
fn read_u32(bytes: &mut std::slice::Iter<u8>) -> Option<u32> {
|
||||
let mut buf = [0u8; 4];
|
||||
for byte in buf.iter_mut() {
|
||||
*byte = *bytes.next()?;
|
||||
}
|
||||
Some(u32::from_be_bytes(buf))
|
||||
}
|
||||
|
||||
#[cfg(feature = "test_mode")]
|
||||
pub struct PacketReader<'x> {
|
||||
bytes: &'x [u8],
|
||||
iter: std::iter::Enumerate<std::slice::Iter<'x, u8>>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "test_mode")]
|
||||
impl<'x> PacketReader<'x> {
|
||||
pub fn new(bytes: &'x [u8]) -> PacketReader<'x> {
|
||||
Self {
|
||||
bytes,
|
||||
iter: bytes.iter().enumerate(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn byte(&mut self) -> u8 {
|
||||
*self.iter.next().unwrap().1
|
||||
}
|
||||
|
||||
pub fn read_nul_terminated(&mut self) -> Option<&'x [u8]> {
|
||||
let (start_pos, ch) = self.iter.next()?;
|
||||
let mut end_pos = start_pos;
|
||||
|
||||
if *ch != 0x00 {
|
||||
loop {
|
||||
match self.iter.next().unwrap().1 {
|
||||
0x00 => break,
|
||||
_ => end_pos += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(&self.bytes[start_pos..end_pos + 1])
|
||||
}
|
||||
|
||||
pub fn read_u32(&mut self) -> u32 {
|
||||
let mut buf = [0u8; 4];
|
||||
for byte in buf.iter_mut() {
|
||||
*byte = self.byte();
|
||||
}
|
||||
u32::from_be_bytes(buf)
|
||||
}
|
||||
|
||||
pub fn read_u16(&mut self) -> u16 {
|
||||
let mut buf = [0u8; 2];
|
||||
for byte in buf.iter_mut() {
|
||||
*byte = self.byte();
|
||||
}
|
||||
u16::from_be_bytes(buf)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
enum State {
|
||||
Len {
|
||||
buf: [u8; std::mem::size_of::<u32>()],
|
||||
bytes_read: usize,
|
||||
},
|
||||
Frame {
|
||||
buf: Vec<u8>,
|
||||
frame_len: usize,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct Receiver {
|
||||
packet_pos: usize,
|
||||
state: State,
|
||||
max_frame_len: usize,
|
||||
}
|
||||
|
||||
pub enum FrameResult<'x> {
|
||||
Frame(Cow<'x, [u8]>),
|
||||
Incomplete,
|
||||
TooLarge(usize),
|
||||
}
|
||||
|
||||
impl Default for State {
|
||||
fn default() -> Self {
|
||||
State::Len {
|
||||
buf: [0; std::mem::size_of::<u32>()],
|
||||
bytes_read: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Receiver {
|
||||
pub fn with_max_frame_len(max_frame_len: usize) -> Self {
|
||||
Receiver {
|
||||
packet_pos: 0,
|
||||
state: State::default(),
|
||||
max_frame_len,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_frame<'x>(&mut self, packet: &'x [u8]) -> FrameResult<'x> {
|
||||
if !packet.is_empty() {
|
||||
match &mut self.state {
|
||||
State::Len { buf, bytes_read } => {
|
||||
while *bytes_read < std::mem::size_of::<u32>() {
|
||||
if let Some(byte) = packet.get(self.packet_pos) {
|
||||
buf[*bytes_read] = *byte;
|
||||
*bytes_read += 1;
|
||||
self.packet_pos += 1;
|
||||
} else {
|
||||
self.packet_pos = 0;
|
||||
return FrameResult::Incomplete;
|
||||
}
|
||||
}
|
||||
let length = u32::from_be_bytes(*buf) as usize;
|
||||
if length <= self.max_frame_len {
|
||||
if let Some(frame) = packet.get(self.packet_pos..self.packet_pos + length) {
|
||||
self.packet_pos += length;
|
||||
self.state = State::default();
|
||||
FrameResult::Frame(frame.into())
|
||||
} else {
|
||||
let mut buf = Vec::with_capacity(length);
|
||||
if let Some(bytes_available) = packet.get(self.packet_pos..) {
|
||||
buf.extend(bytes_available);
|
||||
}
|
||||
self.state = State::Frame {
|
||||
buf,
|
||||
frame_len: length,
|
||||
};
|
||||
self.packet_pos = 0;
|
||||
FrameResult::Incomplete
|
||||
}
|
||||
} else {
|
||||
FrameResult::TooLarge(length)
|
||||
}
|
||||
}
|
||||
State::Frame { buf, frame_len } => {
|
||||
let bytes_pending = *frame_len - buf.len();
|
||||
if let Some(bytes) =
|
||||
packet.get(self.packet_pos..self.packet_pos + bytes_pending)
|
||||
{
|
||||
let mut buf = std::mem::take(buf);
|
||||
buf.extend(bytes);
|
||||
self.packet_pos += bytes_pending;
|
||||
self.state = State::default();
|
||||
FrameResult::Frame(buf.into())
|
||||
} else if let Some(bytes_available) = packet.get(self.packet_pos..) {
|
||||
buf.extend(bytes_available);
|
||||
self.packet_pos = 0;
|
||||
FrameResult::Incomplete
|
||||
} else {
|
||||
self.packet_pos = 0;
|
||||
FrameResult::Incomplete
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
FrameResult::Incomplete
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use mail_auth::{DkimResult, DmarcResult, IprevResult, SpfResult, dmarc::Policy};
|
||||
use std::borrow::Cow;
|
||||
|
||||
pub mod auth;
|
||||
pub mod data;
|
||||
pub mod dkim;
|
||||
pub mod ehlo;
|
||||
pub mod hooks;
|
||||
pub mod mail;
|
||||
pub mod milter;
|
||||
pub mod rcpt;
|
||||
pub mod session;
|
||||
pub mod spam;
|
||||
pub mod spawn;
|
||||
pub mod vrfy;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct FilterResponse {
|
||||
pub message: Cow<'static, str>,
|
||||
pub disconnect: bool,
|
||||
}
|
||||
|
||||
pub trait AuthResult {
|
||||
fn as_str(&self) -> &'static str;
|
||||
}
|
||||
|
||||
impl AuthResult for SpfResult {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
SpfResult::Pass => "pass",
|
||||
SpfResult::Fail => "fail",
|
||||
SpfResult::SoftFail => "softfail",
|
||||
SpfResult::Neutral => "neutral",
|
||||
SpfResult::None => "none",
|
||||
SpfResult::TempError => "temperror",
|
||||
SpfResult::PermError => "permerror",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthResult for IprevResult {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
IprevResult::Pass => "pass",
|
||||
IprevResult::Fail(_) => "fail",
|
||||
IprevResult::TempError(_) => "temperror",
|
||||
IprevResult::PermError(_) => "permerror",
|
||||
IprevResult::None => "none",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthResult for DkimResult {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
DkimResult::Pass => "pass",
|
||||
DkimResult::None => "none",
|
||||
DkimResult::Neutral(_) => "neutral",
|
||||
DkimResult::Fail(_) => "fail",
|
||||
DkimResult::PermError(_) => "permerror",
|
||||
DkimResult::TempError(_) => "temperror",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthResult for DmarcResult {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
DmarcResult::Pass => "pass",
|
||||
DmarcResult::Fail(_) => "fail",
|
||||
DmarcResult::TempError(_) => "temperror",
|
||||
DmarcResult::PermError(_) => "permerror",
|
||||
DmarcResult::None => "none",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthResult for Policy {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Policy::Reject => "reject",
|
||||
Policy::Quarantine => "quarantine",
|
||||
Policy::None | Policy::Unspecified => "none",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FilterResponse {
|
||||
pub fn accept() -> Self {
|
||||
Self {
|
||||
message: Cow::Borrowed("250 2.0.0 Message queued for delivery.\r\n"),
|
||||
disconnect: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reject() -> Self {
|
||||
Self {
|
||||
message: Cow::Borrowed("503 5.5.3 Message rejected.\r\n"),
|
||||
disconnect: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn temp_fail() -> Self {
|
||||
Self {
|
||||
message: Cow::Borrowed("451 4.3.5 Unable to accept message at this time.\r\n"),
|
||||
disconnect: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn shutdown() -> Self {
|
||||
Self {
|
||||
message: Cow::Borrowed("421 4.3.0 Server shutting down.\r\n"),
|
||||
disconnect: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn server_failure() -> Self {
|
||||
Self {
|
||||
message: Cow::Borrowed("451 4.3.5 Unable to accept message at this time.\r\n"),
|
||||
disconnect: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn disconnect(self) -> Self {
|
||||
Self {
|
||||
disconnect: true,
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_bytes(self) -> Cow<'static, [u8]> {
|
||||
match self.message {
|
||||
Cow::Borrowed(s) => Cow::Borrowed(s.as_bytes()),
|
||||
Cow::Owned(s) => Cow::Owned(s.into_bytes()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
core::{Session, SessionAddress},
|
||||
scripts::ScriptResult,
|
||||
};
|
||||
use common::{
|
||||
KV_GREYLIST,
|
||||
config::smtp::session::Stage,
|
||||
network::{RcptResolution, SessionStream},
|
||||
scripts::ScriptModification,
|
||||
};
|
||||
use smtp_proto::{
|
||||
RCPT_NOTIFY_DELAY, RCPT_NOTIFY_FAILURE, RCPT_NOTIFY_NEVER, RCPT_NOTIFY_SUCCESS, RcptTo,
|
||||
};
|
||||
use std::borrow::Cow;
|
||||
use store::dispatch::lookup::KeyValue;
|
||||
use trc::{SecurityEvent, SmtpEvent};
|
||||
use utils::DomainPart;
|
||||
|
||||
impl<T: SessionStream> Session<T> {
|
||||
pub async fn handle_rcpt_to(&mut self, to: RcptTo<Cow<'_, str>>) -> Result<(), ()> {
|
||||
#[cfg(feature = "test_mode")]
|
||||
if self.instance.id.ends_with("-debug") {
|
||||
if to.address.contains("fail@") {
|
||||
return self.write(b"503 5.5.1 Invalid recipient.\r\n").await;
|
||||
} else if (to.address.contains("delay-random@") && rand::random())
|
||||
|| to.address.contains("delay@")
|
||||
{
|
||||
return self.write(b"451 4.5.3 Try again later.\r\n").await;
|
||||
} else if to.address.contains("slow@") {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(
|
||||
rand::random::<u64>() % 5 + 5,
|
||||
))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
if self.data.mail_from.is_none() {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::MailFromMissing),
|
||||
SpanId = self.data.session_id,
|
||||
);
|
||||
return self.write(b"503 5.5.1 MAIL is required first.\r\n").await;
|
||||
} else if std::cmp::min(self.data.rcpt_to.len(), self.data.rcpt_oks) >= self.params.rcpt_max
|
||||
{
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::TooManyRecipients),
|
||||
SpanId = self.data.session_id,
|
||||
Limit = self.params.rcpt_max,
|
||||
);
|
||||
return self.write(b"455 4.5.3 Too many recipients.\r\n").await;
|
||||
}
|
||||
|
||||
// Verify parameters
|
||||
if ((to.flags
|
||||
& (RCPT_NOTIFY_DELAY | RCPT_NOTIFY_NEVER | RCPT_NOTIFY_SUCCESS | RCPT_NOTIFY_FAILURE)
|
||||
!= 0)
|
||||
|| to.orcpt.is_some())
|
||||
&& !self.params.rcpt_dsn
|
||||
{
|
||||
trc::event!(Smtp(SmtpEvent::DsnDisabled), SpanId = self.data.session_id,);
|
||||
return self
|
||||
.write(b"501 5.5.4 DSN extension has been disabled.\r\n")
|
||||
.await;
|
||||
}
|
||||
|
||||
// Build RCPT
|
||||
let address_lcase = to.address.to_lowercase_address(true);
|
||||
let rcpt = SessionAddress {
|
||||
domain: address_lcase.domain_part().into(),
|
||||
address_lcase,
|
||||
address: to.address.into_owned(),
|
||||
flags: to.flags,
|
||||
dsn_info: to.orcpt.map(|e| e.into_owned()),
|
||||
};
|
||||
|
||||
if self.data.rcpt_to.contains(&rcpt) {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::RcptToDuplicate),
|
||||
SpanId = self.data.session_id,
|
||||
To = rcpt.address_lcase,
|
||||
);
|
||||
self.data.rcpt_oks += 1;
|
||||
return self.write(b"250 2.1.5 OK\r\n").await;
|
||||
}
|
||||
self.data.rcpt_to.push(rcpt);
|
||||
|
||||
// Address rewriting and Sieve filtering
|
||||
let rcpt_config = &self.server.core.smtp.session.rcpt;
|
||||
let rcpt_script = self
|
||||
.server
|
||||
.eval_if::<String, _>(&rcpt_config.script, self, self.data.session_id)
|
||||
.await
|
||||
.and_then(|name| {
|
||||
self.server
|
||||
.get_trusted_sieve_script(&name, self.data.session_id)
|
||||
.map(|s| (s.clone(), name))
|
||||
});
|
||||
|
||||
let session_config = &self.server.core.smtp.session;
|
||||
if rcpt_script.is_some()
|
||||
|| !rcpt_config.rewrite.is_empty()
|
||||
|| session_config
|
||||
.milters
|
||||
.iter()
|
||||
.any(|m| m.run_on_stage.contains(&Stage::Rcpt))
|
||||
|| session_config
|
||||
.hooks
|
||||
.iter()
|
||||
.any(|h| h.run_on_stage.contains(&Stage::Rcpt))
|
||||
{
|
||||
// Sieve filtering
|
||||
if let Some((script, script_id)) = rcpt_script {
|
||||
match self
|
||||
.run_script(
|
||||
script_id,
|
||||
script.clone(),
|
||||
self.build_script_parameters("rcpt"),
|
||||
)
|
||||
.await
|
||||
{
|
||||
ScriptResult::Accept { modifications } if !modifications.is_empty() => {
|
||||
for modification in modifications {
|
||||
if let ScriptModification::SetEnvelope { name, value } = modification {
|
||||
self.data.apply_envelope_modification(name, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
ScriptResult::Reject(message) => {
|
||||
self.data.rcpt_to.pop();
|
||||
return self.write(message.as_bytes()).await;
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
// Milter filtering
|
||||
if let Err(message) = self.run_milters(Stage::Rcpt, None, None).await {
|
||||
self.data.rcpt_to.pop();
|
||||
return self.write(message.message.as_bytes()).await;
|
||||
}
|
||||
|
||||
// MTAHook filtering
|
||||
if let Err(message) = self.run_mta_hooks(Stage::Rcpt, None, None).await {
|
||||
self.data.rcpt_to.pop();
|
||||
return self.write(message.message.as_bytes()).await;
|
||||
}
|
||||
|
||||
// Address rewriting
|
||||
if let Some(new_address) = self
|
||||
.server
|
||||
.eval_if::<String, _>(&rcpt_config.rewrite, self, self.data.session_id)
|
||||
.await
|
||||
{
|
||||
let rcpt = self.data.rcpt_to.last_mut().unwrap();
|
||||
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::RcptToRewritten),
|
||||
SpanId = self.data.session_id,
|
||||
Details = rcpt.address_lcase.clone(),
|
||||
To = new_address.clone(),
|
||||
);
|
||||
|
||||
if new_address.contains('@') {
|
||||
rcpt.address_lcase = new_address.to_lowercase_address(true);
|
||||
rcpt.domain = rcpt.address_lcase.domain_part().into();
|
||||
rcpt.address = new_address;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
let rcpt = self.data.rcpt_to.last().unwrap();
|
||||
if self.data.rcpt_to.iter().filter(|r| r == &rcpt).count() > 1 {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::RcptToDuplicate),
|
||||
SpanId = self.data.session_id,
|
||||
To = rcpt.address_lcase.clone(),
|
||||
);
|
||||
self.data.rcpt_to.pop();
|
||||
self.data.rcpt_oks += 1;
|
||||
return self.write(b"250 2.1.5 OK\r\n").await;
|
||||
}
|
||||
}
|
||||
|
||||
// Verify address
|
||||
let rcpt = self.data.rcpt_to.last().unwrap();
|
||||
let mut rcpt_members = None;
|
||||
|
||||
match self
|
||||
.server
|
||||
.rcpt_resolve(&rcpt.address_lcase, true, self.data.session_id)
|
||||
.await
|
||||
{
|
||||
Ok(RcptResolution::Accept) => {}
|
||||
Ok(RcptResolution::Rewrite(address)) => {
|
||||
let orig_addr = self.data.rcpt_to.pop().unwrap();
|
||||
let mut new_addr = SessionAddress::new(address);
|
||||
|
||||
if !self.data.rcpt_to.contains(&new_addr) {
|
||||
new_addr.dsn_info = format!("rfc822;{}", orig_addr.address_lcase).into();
|
||||
new_addr.flags = orig_addr.flags;
|
||||
self.data.rcpt_to.push(new_addr);
|
||||
} else {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::RcptToDuplicate),
|
||||
SpanId = self.data.session_id,
|
||||
To = new_addr.address_lcase.clone(),
|
||||
);
|
||||
self.data.rcpt_oks += 1;
|
||||
return self.write(b"250 2.1.5 OK\r\n").await;
|
||||
}
|
||||
}
|
||||
Ok(RcptResolution::Expand(members)) => {
|
||||
rcpt_members = Some(members);
|
||||
}
|
||||
Ok(RcptResolution::UnknownRecipient) => {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::MailboxDoesNotExist),
|
||||
SpanId = self.data.session_id,
|
||||
To = rcpt.address_lcase.clone(),
|
||||
);
|
||||
|
||||
let rcpt_to = self.data.rcpt_to.pop().unwrap().address_lcase;
|
||||
return self
|
||||
.rcpt_error(b"550 5.1.2 Mailbox does not exist.\r\n", rcpt_to)
|
||||
.await;
|
||||
}
|
||||
Ok(RcptResolution::UnknownDomain) => {
|
||||
if !self
|
||||
.server
|
||||
.eval_if(&rcpt_config.relay, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::RelayNotAllowed),
|
||||
SpanId = self.data.session_id,
|
||||
To = rcpt.address_lcase.clone(),
|
||||
);
|
||||
|
||||
let rcpt_to = self.data.rcpt_to.pop().unwrap().address_lcase;
|
||||
return self
|
||||
.rcpt_error(b"550 5.1.2 Relay not allowed.\r\n", rcpt_to)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
trc::error!(
|
||||
err.span_id(self.data.session_id)
|
||||
.caused_by(trc::location!())
|
||||
.details("Failed to verify address.")
|
||||
);
|
||||
|
||||
self.data.rcpt_to.pop();
|
||||
return self
|
||||
.write(b"451 4.4.3 Unable to verify address at this time.\r\n")
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
if self.is_allowed().await {
|
||||
// Greylist
|
||||
if let Some(greylist_duration) = self
|
||||
.server
|
||||
.core
|
||||
.spam
|
||||
.grey_list_expiry
|
||||
.filter(|_| self.data.authenticated_as.is_none())
|
||||
{
|
||||
let from_addr = self
|
||||
.data
|
||||
.mail_from
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.address_lcase
|
||||
.as_bytes();
|
||||
let to_addr = self.data.rcpt_to.last().unwrap().address_lcase.as_bytes();
|
||||
let mut key = Vec::with_capacity(from_addr.len() + to_addr.len() + 1);
|
||||
key.push(KV_GREYLIST);
|
||||
key.extend_from_slice(from_addr);
|
||||
key.extend_from_slice(to_addr);
|
||||
|
||||
match self.server.in_memory_store().key_exists(key.clone()).await {
|
||||
Ok(true) => (),
|
||||
Ok(false) => {
|
||||
match self
|
||||
.server
|
||||
.in_memory_store()
|
||||
.key_set(KeyValue::new(key, vec![]).expires(greylist_duration))
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
let rcpt = self.data.rcpt_to.pop().unwrap();
|
||||
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::RcptToGreylisted),
|
||||
SpanId = self.data.session_id,
|
||||
To = rcpt.address_lcase,
|
||||
);
|
||||
|
||||
return self
|
||||
.write(
|
||||
concat!(
|
||||
"452 4.2.2 Greylisted, please try ",
|
||||
"again in a few moments.\r\n"
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(err) => {
|
||||
trc::error!(
|
||||
err.span_id(self.data.session_id)
|
||||
.caused_by(trc::location!())
|
||||
.details("Failed to set greylist.")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
trc::error!(
|
||||
err.span_id(self.data.session_id)
|
||||
.caused_by(trc::location!())
|
||||
.details("Failed to check greylist.")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::RcptTo),
|
||||
SpanId = self.data.session_id,
|
||||
To = self.data.rcpt_to.last().unwrap().address_lcase.clone(),
|
||||
);
|
||||
} else {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::RateLimitExceeded),
|
||||
SpanId = self.data.session_id,
|
||||
To = self.data.rcpt_to.last().unwrap().address_lcase.clone(),
|
||||
);
|
||||
|
||||
self.data.rcpt_to.pop();
|
||||
return self
|
||||
.write(b"452 4.4.5 Rate limit exceeded, try again later.\r\n")
|
||||
.await;
|
||||
}
|
||||
|
||||
// Expand list
|
||||
if let Some(members) = rcpt_members {
|
||||
let list_addr = self.data.rcpt_to.pop().unwrap();
|
||||
let orcpt = format!("rfc822;{}", list_addr.address_lcase);
|
||||
for member in members.as_ref() {
|
||||
let member_lcase = member.to_lowercase();
|
||||
let is_local = match self
|
||||
.server
|
||||
.account_id_from_email(&member_lcase, false)
|
||||
.await
|
||||
{
|
||||
Ok(account_id) => account_id.is_some(),
|
||||
Err(err) => {
|
||||
trc::error!(
|
||||
err.span_id(self.data.session_id)
|
||||
.caused_by(trc::location!())
|
||||
.details("Failed to look up mailing list member.")
|
||||
.ctx(trc::Key::To, member.to_string())
|
||||
);
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
let address = if is_local {
|
||||
member.to_string()
|
||||
} else {
|
||||
match self
|
||||
.server
|
||||
.rcpt_resolve(&member_lcase, false, self.data.session_id)
|
||||
.await
|
||||
{
|
||||
Ok(RcptResolution::Rewrite(address)) => address,
|
||||
Ok(_) => member.to_string(),
|
||||
Err(err) => {
|
||||
trc::error!(
|
||||
err.span_id(self.data.session_id)
|
||||
.caused_by(trc::location!())
|
||||
.details("Failed to resolve mailing list member.")
|
||||
.ctx(trc::Key::To, member.to_string())
|
||||
);
|
||||
member.to_string()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut member_addr = SessionAddress::new(address);
|
||||
if !self.data.rcpt_to.contains(&member_addr)
|
||||
&& member_addr.address_lcase != list_addr.address_lcase
|
||||
{
|
||||
member_addr.dsn_info = orcpt.clone().into();
|
||||
member_addr.flags = list_addr.flags;
|
||||
self.data.rcpt_to.push(member_addr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.data.rcpt_oks += 1;
|
||||
self.write(b"250 2.1.5 OK\r\n").await
|
||||
}
|
||||
|
||||
async fn rcpt_error(&mut self, response: &[u8], rcpt: String) -> Result<(), ()> {
|
||||
tokio::time::sleep(self.params.rcpt_errors_wait).await;
|
||||
self.data.rcpt_errors += 1;
|
||||
let has_too_many_errors = self.data.rcpt_errors >= self.params.rcpt_errors_max;
|
||||
|
||||
match self
|
||||
.server
|
||||
.is_rcpt_fail2banned(self.data.remote_ip, &rcpt)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {
|
||||
trc::event!(
|
||||
Security(SecurityEvent::AbuseBan),
|
||||
SpanId = self.data.session_id,
|
||||
RemoteIp = self.data.remote_ip,
|
||||
To = rcpt,
|
||||
);
|
||||
}
|
||||
Ok(false) => {
|
||||
if has_too_many_errors {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::TooManyInvalidRcpt),
|
||||
SpanId = self.data.session_id,
|
||||
Limit = self.params.rcpt_errors_max,
|
||||
To = rcpt,
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
trc::error!(
|
||||
err.span_id(self.data.session_id)
|
||||
.caused_by(trc::location!())
|
||||
.details("Failed to check if IP should be banned.")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if !has_too_many_errors {
|
||||
self.write(response).await
|
||||
} else {
|
||||
self.write(b"451 4.3.0 Too many errors, disconnecting.\r\n")
|
||||
.await?;
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,639 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use common::{
|
||||
config::{server::ServerProtocol, smtp::session::Mechanism},
|
||||
expr::{self, functions::ResolveVariable, *},
|
||||
network::SessionStream,
|
||||
};
|
||||
|
||||
use compact_str::ToCompactString;
|
||||
use registry::schema::enums::ExpressionVariable;
|
||||
use smtp_proto::{
|
||||
request::receiver::{
|
||||
BdatReceiver, DataReceiver, DummyDataReceiver, DummyLineReceiver, LineReceiver,
|
||||
MAX_LINE_LENGTH,
|
||||
},
|
||||
*,
|
||||
};
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
||||
use trc::{NetworkEvent, SecurityEvent, SmtpEvent};
|
||||
|
||||
use crate::core::{Session, State};
|
||||
|
||||
use super::auth::SaslToken;
|
||||
|
||||
impl<T: SessionStream> Session<T> {
|
||||
pub async fn ingest(&mut self, bytes: &[u8]) -> Result<bool, ()> {
|
||||
let mut iter = bytes.iter();
|
||||
let mut state = std::mem::replace(&mut self.state, State::None);
|
||||
|
||||
'outer: loop {
|
||||
match &mut state {
|
||||
State::Request(receiver) => loop {
|
||||
match receiver.ingest(&mut iter) {
|
||||
Ok(request) => match request {
|
||||
Request::Rcpt { to } => {
|
||||
self.handle_rcpt_to(to).await?;
|
||||
}
|
||||
Request::Mail { from } => {
|
||||
self.handle_mail_from(from).await?;
|
||||
}
|
||||
Request::Ehlo { host } => {
|
||||
if self.instance.protocol == ServerProtocol::Smtp {
|
||||
self.handle_ehlo(host, true).await?;
|
||||
} else {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::LhloExpected),
|
||||
SpanId = self.data.session_id,
|
||||
);
|
||||
|
||||
self.write(b"500 5.5.1 Invalid command.\r\n").await?;
|
||||
}
|
||||
}
|
||||
Request::Data => {
|
||||
if let Some(response) = self.can_send_data().await {
|
||||
self.write(response).await?;
|
||||
} else {
|
||||
self.write(b"354 Start mail input; end with <CRLF>.<CRLF>\r\n")
|
||||
.await?;
|
||||
self.data.message = Vec::with_capacity(1024);
|
||||
state = State::Data(DataReceiver::new());
|
||||
continue 'outer;
|
||||
}
|
||||
}
|
||||
Request::Bdat {
|
||||
chunk_size,
|
||||
is_last,
|
||||
} => {
|
||||
state = if let Some(response) = self.can_send_data().await {
|
||||
State::SkipData(
|
||||
DummyDataReceiver::new_bdat(chunk_size),
|
||||
response,
|
||||
)
|
||||
} else if chunk_size.saturating_add(self.data.message.len())
|
||||
< self.params.max_message_size
|
||||
{
|
||||
if self.data.message.is_empty() {
|
||||
self.data.message = Vec::with_capacity(chunk_size);
|
||||
} else {
|
||||
self.data.message.reserve(chunk_size);
|
||||
}
|
||||
State::Bdat(BdatReceiver::new(chunk_size, is_last))
|
||||
} else {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::MessageTooLarge),
|
||||
SpanId = self.data.session_id,
|
||||
Size = chunk_size.saturating_add(self.data.message.len()),
|
||||
Limit = self.params.max_message_size,
|
||||
);
|
||||
|
||||
State::SkipData(
|
||||
DummyDataReceiver::new_bdat(chunk_size),
|
||||
b"552 5.3.4 Message too big for system.\r\n",
|
||||
)
|
||||
};
|
||||
continue 'outer;
|
||||
}
|
||||
Request::Auth {
|
||||
mechanism,
|
||||
initial_response,
|
||||
} => {
|
||||
let auth: u64 = self
|
||||
.server
|
||||
.eval_if::<Mechanism, _>(
|
||||
&self.server.core.smtp.session.auth.mechanisms,
|
||||
self,
|
||||
self.data.session_id,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into();
|
||||
if auth == 0 {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::AuthNotAllowed),
|
||||
SpanId = self.data.session_id,
|
||||
);
|
||||
|
||||
self.write(b"503 5.5.1 AUTH not allowed.\r\n").await?;
|
||||
} else if let Some(authenticated_as) = self.authenticated_as() {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::AlreadyAuthenticated),
|
||||
SpanId = self.data.session_id,
|
||||
AccountName = authenticated_as.to_string(),
|
||||
);
|
||||
|
||||
self.write(b"503 5.5.1 Already authenticated.\r\n").await?;
|
||||
} else if let Some(mut token) =
|
||||
SaslToken::from_mechanism(mechanism & auth)
|
||||
{
|
||||
if self
|
||||
.handle_sasl_response(
|
||||
&mut token,
|
||||
initial_response.as_bytes(),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
state = State::Sasl(LineReceiver::new(token));
|
||||
continue 'outer;
|
||||
}
|
||||
} else {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::AuthMechanismNotSupported),
|
||||
SpanId = self.data.session_id,
|
||||
);
|
||||
|
||||
self.write(
|
||||
b"554 5.7.8 Authentication mechanism not supported.\r\n",
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Request::Noop { .. } => {
|
||||
trc::event!(Smtp(SmtpEvent::Noop), SpanId = self.data.session_id,);
|
||||
|
||||
self.write(b"250 2.0.0 OK\r\n").await?;
|
||||
}
|
||||
Request::Vrfy { value } => {
|
||||
self.handle_vrfy(value).await?;
|
||||
}
|
||||
Request::Expn { value } => {
|
||||
self.handle_expn(value).await?;
|
||||
}
|
||||
Request::StartTls => {
|
||||
if !self.stream.is_tls() {
|
||||
if self.instance.acceptor.is_tls() {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::StartTls),
|
||||
SpanId = self.data.session_id,
|
||||
);
|
||||
|
||||
self.write(b"220 2.0.0 Ready to start TLS.\r\n").await?;
|
||||
#[cfg(any(test, feature = "test_mode"))]
|
||||
if self.data.helo_domain.contains("badtls") {
|
||||
return Err(());
|
||||
}
|
||||
self.state = State::default();
|
||||
self.reset_tls();
|
||||
return Ok(false);
|
||||
} else {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::StartTlsUnavailable),
|
||||
SpanId = self.data.session_id,
|
||||
);
|
||||
|
||||
self.write(b"502 5.7.0 TLS not available.\r\n").await?;
|
||||
}
|
||||
} else {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::StartTlsAlready),
|
||||
SpanId = self.data.session_id,
|
||||
);
|
||||
|
||||
self.write(b"504 5.7.4 Already in TLS mode.\r\n").await?;
|
||||
}
|
||||
}
|
||||
Request::Rset => {
|
||||
trc::event!(Smtp(SmtpEvent::Rset), SpanId = self.data.session_id,);
|
||||
|
||||
self.reset();
|
||||
self.write(b"250 2.0.0 OK\r\n").await?;
|
||||
}
|
||||
Request::Quit => {
|
||||
trc::event!(Smtp(SmtpEvent::Quit), SpanId = self.data.session_id,);
|
||||
|
||||
self.write(b"221 2.0.0 Bye.\r\n").await?;
|
||||
return Err(());
|
||||
}
|
||||
Request::Help { .. } => {
|
||||
trc::event!(Smtp(SmtpEvent::Help), SpanId = self.data.session_id,);
|
||||
|
||||
self.write(b"250 2.0.0 Help can be found at https://stalw.art\r\n")
|
||||
.await?;
|
||||
}
|
||||
Request::Helo { host } => {
|
||||
if self.instance.protocol == ServerProtocol::Smtp {
|
||||
self.handle_ehlo(host, false).await?;
|
||||
} else {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::LhloExpected),
|
||||
SpanId = self.data.session_id,
|
||||
);
|
||||
|
||||
self.write(b"500 5.5.1 Invalid command: LHLO expected.\r\n")
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Request::Lhlo { host } => {
|
||||
if self.instance.protocol == ServerProtocol::Lmtp {
|
||||
self.handle_ehlo(host, true).await?;
|
||||
} else {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::EhloExpected),
|
||||
SpanId = self.data.session_id,
|
||||
);
|
||||
|
||||
self.write(b"502 5.5.1 Invalid command: EHLO expected.\r\n")
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
cmd @ (Request::Etrn { .. }
|
||||
| Request::Atrn { .. }
|
||||
| Request::Burl { .. }) => {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::CommandNotImplemented),
|
||||
SpanId = self.data.session_id,
|
||||
Details = format!("{cmd:?}"),
|
||||
);
|
||||
|
||||
self.write(b"502 5.5.1 Command not implemented.\r\n")
|
||||
.await?;
|
||||
}
|
||||
},
|
||||
Err(err) => match err {
|
||||
Error::NeedsMoreData { .. } => break 'outer,
|
||||
Error::UnknownCommand | Error::InvalidResponse { .. } => {
|
||||
// Check for port scanners
|
||||
if !self.is_authenticated() {
|
||||
match self
|
||||
.server
|
||||
.is_scanner_fail2banned(self.data.remote_ip)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {
|
||||
trc::event!(
|
||||
Security(SecurityEvent::ScanBan),
|
||||
SpanId = self.data.session_id,
|
||||
RemoteIp = self.data.remote_ip,
|
||||
Reason = "Invalid SMTP command",
|
||||
);
|
||||
|
||||
return Err(());
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(err) => {
|
||||
trc::error!(
|
||||
err.span_id(self.data.session_id)
|
||||
.details("Failed to check for fail2ban")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::InvalidCommand),
|
||||
SpanId = self.data.session_id,
|
||||
);
|
||||
|
||||
self.write(b"500 5.5.1 Invalid command.\r\n").await?;
|
||||
}
|
||||
Error::InvalidSenderAddress => {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::InvalidSenderAddress),
|
||||
SpanId = self.data.session_id,
|
||||
);
|
||||
|
||||
self.write(b"501 5.1.8 Bad sender's system address.\r\n")
|
||||
.await?;
|
||||
}
|
||||
Error::InvalidRecipientAddress => {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::InvalidRecipientAddress),
|
||||
SpanId = self.data.session_id,
|
||||
);
|
||||
|
||||
self.write(
|
||||
b"501 5.1.3 Bad destination mailbox address syntax.\r\n",
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Error::SyntaxError { syntax } => {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::SyntaxError),
|
||||
SpanId = self.data.session_id,
|
||||
Details = syntax
|
||||
);
|
||||
|
||||
if !self.params.ehlo_reject_non_fqdn && syntax.starts_with("EHLO ")
|
||||
{
|
||||
self.handle_ehlo("null".into(), true).await?
|
||||
} else {
|
||||
self.write(
|
||||
format!("501 5.5.2 Syntax error, expected: {syntax}\r\n")
|
||||
.as_bytes(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Error::InvalidParameter { param } => {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::InvalidParameter),
|
||||
SpanId = self.data.session_id,
|
||||
Details = param
|
||||
);
|
||||
|
||||
self.write(
|
||||
format!("501 5.5.4 Invalid parameter {param:?}.\r\n")
|
||||
.as_bytes(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Error::UnsupportedParameter { param } => {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::UnsupportedParameter),
|
||||
SpanId = self.data.session_id,
|
||||
Details = param.clone()
|
||||
);
|
||||
|
||||
self.write(
|
||||
format!("504 5.5.4 Unsupported parameter {param:?}.\r\n")
|
||||
.as_bytes(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Error::ResponseTooLong => {
|
||||
state = State::RequestTooLarge(DummyLineReceiver::default());
|
||||
continue 'outer;
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
State::Data(receiver) => {
|
||||
if self.data.message.len() + bytes.len() < self.params.max_message_size {
|
||||
if receiver.ingest(&mut iter, &mut self.data.message) {
|
||||
let message = self.queue_message().await;
|
||||
let num_responses = if self.instance.protocol == ServerProtocol::Smtp {
|
||||
1
|
||||
} else {
|
||||
self.data.rcpt_oks
|
||||
};
|
||||
if !message.is_empty() {
|
||||
for _ in 0..num_responses {
|
||||
self.write(message.as_ref()).await?;
|
||||
}
|
||||
self.reset();
|
||||
state = State::default();
|
||||
} else {
|
||||
// Disconnect requested
|
||||
return Err(());
|
||||
}
|
||||
} else {
|
||||
break 'outer;
|
||||
}
|
||||
} else {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::MessageTooLarge),
|
||||
SpanId = self.data.session_id,
|
||||
Size = self.data.message.len() + bytes.len(),
|
||||
Limit = self.params.max_message_size,
|
||||
);
|
||||
|
||||
state = State::SkipData(
|
||||
DummyDataReceiver::new_data(receiver),
|
||||
b"552 5.3.4 Message too big for system.\r\n",
|
||||
);
|
||||
}
|
||||
}
|
||||
State::Bdat(receiver) => {
|
||||
if receiver.ingest(&mut iter, &mut self.data.message) {
|
||||
if receiver.is_last {
|
||||
let message = self.queue_message().await;
|
||||
if !message.is_empty() {
|
||||
let num_responses =
|
||||
if self.instance.protocol == ServerProtocol::Smtp {
|
||||
1
|
||||
} else {
|
||||
self.data.rcpt_oks
|
||||
};
|
||||
for _ in 0..num_responses {
|
||||
self.write(message.as_ref()).await?;
|
||||
}
|
||||
self.reset();
|
||||
} else {
|
||||
// Disconnect requested
|
||||
return Err(());
|
||||
}
|
||||
} else {
|
||||
self.write(b"250 2.6.0 Chunk accepted.\r\n").await?;
|
||||
}
|
||||
state = State::default();
|
||||
} else {
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
State::Sasl(receiver) => {
|
||||
if receiver.ingest(&mut iter) {
|
||||
if receiver.buf.len() < MAX_LINE_LENGTH {
|
||||
if self
|
||||
.handle_sasl_response(&mut receiver.state, &receiver.buf)
|
||||
.await?
|
||||
{
|
||||
receiver.buf.clear();
|
||||
continue 'outer;
|
||||
}
|
||||
} else {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::AuthExchangeTooLong),
|
||||
SpanId = self.data.session_id,
|
||||
Limit = MAX_LINE_LENGTH,
|
||||
);
|
||||
|
||||
self.auth_error(
|
||||
b"500 5.5.6 Authentication Exchange line is too long.\r\n",
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
state = State::default();
|
||||
} else {
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
State::SkipData(receiver, response) => {
|
||||
if receiver.ingest(&mut iter) {
|
||||
self.data.message = Vec::with_capacity(0);
|
||||
self.write(response).await?;
|
||||
state = State::default();
|
||||
} else {
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
State::RequestTooLarge(receiver) => {
|
||||
if receiver.ingest(&mut iter) {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::RequestTooLarge),
|
||||
SpanId = self.data.session_id,
|
||||
);
|
||||
|
||||
self.write(b"554 5.3.4 Line is too long.\r\n").await?;
|
||||
state = State::default();
|
||||
} else {
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
State::None | State::Accepted(_) => unreachable!(),
|
||||
}
|
||||
}
|
||||
self.state = state;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: AsyncWrite + AsyncRead + Unpin> Session<T> {
|
||||
pub fn reset(&mut self) {
|
||||
self.data.mail_from = None;
|
||||
self.data.spf_mail_from = None;
|
||||
self.data.rcpt_to.clear();
|
||||
self.data.message = Vec::with_capacity(0);
|
||||
self.data.priority = 0;
|
||||
self.data.delivery_by = 0;
|
||||
self.data.future_release = 0;
|
||||
self.data.rcpt_oks = 0;
|
||||
}
|
||||
|
||||
pub fn reset_tls(&mut self) {
|
||||
self.reset();
|
||||
self.data.helo_domain.clear();
|
||||
self.data.spf_ehlo = None;
|
||||
self.data.authenticated_as = None;
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub async fn write(&mut self, bytes: &[u8]) -> Result<(), ()> {
|
||||
match self.stream.write_all(bytes).await {
|
||||
Ok(_) => match self.stream.flush().await {
|
||||
Ok(_) => {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::RawOutput),
|
||||
SpanId = self.data.session_id,
|
||||
Size = bytes.len(),
|
||||
Contents = trc::Value::from_maybe_string(bytes),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
Network(NetworkEvent::FlushError),
|
||||
SpanId = self.data.session_id,
|
||||
Reason = err.to_string(),
|
||||
);
|
||||
Err(())
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
Network(NetworkEvent::WriteError),
|
||||
SpanId = self.data.session_id,
|
||||
Reason = err.to_string(),
|
||||
);
|
||||
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub async fn read(&mut self, bytes: &mut [u8]) -> Result<usize, ()> {
|
||||
match self.stream.read(bytes).await {
|
||||
Ok(len) => {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::RawInput),
|
||||
SpanId = self.data.session_id,
|
||||
Size = len,
|
||||
Contents =
|
||||
String::from_utf8_lossy(bytes.get(0..len).unwrap_or_default()).into_owned(),
|
||||
);
|
||||
|
||||
Ok(len)
|
||||
}
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
Network(NetworkEvent::ReadError),
|
||||
SpanId = self.data.session_id,
|
||||
Reason = err.to_string(),
|
||||
);
|
||||
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: SessionStream> ResolveVariable for Session<T> {
|
||||
fn resolve_variable(&self, variable: ExpressionVariable) -> expr::Variable<'_> {
|
||||
match variable {
|
||||
ExpressionVariable::Rcpt => self
|
||||
.data
|
||||
.rcpt_to
|
||||
.last()
|
||||
.map(|r| r.address_lcase.as_str())
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
ExpressionVariable::RcptDomain => self
|
||||
.data
|
||||
.rcpt_to
|
||||
.last()
|
||||
.map(|r| r.domain.as_str())
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
ExpressionVariable::Recipients => self
|
||||
.data
|
||||
.rcpt_to
|
||||
.iter()
|
||||
.map(|r| Variable::from(r.address_lcase.as_str()))
|
||||
.collect::<Vec<_>>()
|
||||
.into(),
|
||||
ExpressionVariable::Sender => self
|
||||
.data
|
||||
.mail_from
|
||||
.as_ref()
|
||||
.map(|m| m.address_lcase.as_str())
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
ExpressionVariable::SenderDomain => self
|
||||
.data
|
||||
.mail_from
|
||||
.as_ref()
|
||||
.map(|m| m.domain.as_str())
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
ExpressionVariable::HeloDomain => self.data.helo_domain.as_str().into(),
|
||||
ExpressionVariable::AuthenticatedAs => {
|
||||
self.authenticated_as().unwrap_or_default().into()
|
||||
}
|
||||
ExpressionVariable::Listener => self.instance.id.as_str().into(),
|
||||
ExpressionVariable::RemoteIp => self.data.remote_ip_str.as_str().into(),
|
||||
ExpressionVariable::RemotePort => self.data.remote_port.into(),
|
||||
ExpressionVariable::LocalIp => self.data.local_ip_str.as_str().into(),
|
||||
ExpressionVariable::LocalPort => self.data.local_port.into(),
|
||||
ExpressionVariable::IsTls => self.stream.is_tls().into(),
|
||||
ExpressionVariable::Priority => self.data.priority.to_compact_string().into(),
|
||||
ExpressionVariable::Protocol => self.instance.protocol.as_str().into(),
|
||||
ExpressionVariable::Asn => self
|
||||
.data
|
||||
.asn_geo_data
|
||||
.asn
|
||||
.as_ref()
|
||||
.map(|a| a.id)
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
ExpressionVariable::Country => self
|
||||
.data
|
||||
.asn_geo_data
|
||||
.country
|
||||
.as_ref()
|
||||
.map(|c| c.as_str())
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
_ => expr::Variable::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_global(&self, _: &str) -> Variable<'_> {
|
||||
Variable::Integer(0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::core::Session;
|
||||
use common::{config::mailstore::spamfilter::SpamFilterAction, network::SessionStream};
|
||||
use mail_auth::{ArcOutput, DkimOutput, DmarcResult, dkim2::Dkim2Output, dmarc::Policy};
|
||||
use mail_parser::Message;
|
||||
use spam_filter::{
|
||||
SpamFilterInput,
|
||||
analysis::{
|
||||
init::SpamFilterInit,
|
||||
score::{SpamFilterAnalyzeScore, SpamFilterScore},
|
||||
},
|
||||
};
|
||||
|
||||
impl<T: SessionStream> Session<T> {
|
||||
pub async fn spam_classify<'x>(
|
||||
&'x self,
|
||||
message: &'x Message<'x>,
|
||||
dkim_result: &'x [DkimOutput<'x>],
|
||||
dkim2_result: Option<&'x Dkim2Output<'x>>,
|
||||
arc_result: Option<&'x ArcOutput<'x>>,
|
||||
dmarc_result: Option<&'x DmarcResult>,
|
||||
dmarc_policy: Option<&'x Policy>,
|
||||
) -> SpamFilterAction<SpamFilterScore> {
|
||||
let server = &self.server;
|
||||
let mut ctx = server.spam_filter_init(self.build_spam_input(
|
||||
message,
|
||||
dkim_result,
|
||||
dkim2_result,
|
||||
arc_result,
|
||||
dmarc_result,
|
||||
dmarc_policy,
|
||||
));
|
||||
|
||||
if !self.is_authenticated() {
|
||||
// Spam classification
|
||||
server.spam_filter_classify(&mut ctx).await
|
||||
} else {
|
||||
// Do not classify authenticated sessions
|
||||
SpamFilterAction::Disabled
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_spam_input<'x>(
|
||||
&'x self,
|
||||
message: &'x Message<'x>,
|
||||
dkim_result: &'x [DkimOutput<'x>],
|
||||
dkim2_result: Option<&'x Dkim2Output<'x>>,
|
||||
arc_result: Option<&'x ArcOutput>,
|
||||
dmarc_result: Option<&'x DmarcResult>,
|
||||
dmarc_policy: Option<&'x Policy>,
|
||||
) -> SpamFilterInput<'x> {
|
||||
SpamFilterInput {
|
||||
message,
|
||||
span_id: self.data.session_id,
|
||||
arc_result,
|
||||
spf_ehlo_result: self.data.spf_ehlo.as_ref(),
|
||||
spf_mail_from_result: self.data.spf_mail_from.as_ref(),
|
||||
dkim_result,
|
||||
dkim2_result,
|
||||
dmarc_result,
|
||||
dmarc_policy,
|
||||
iprev_result: self.data.iprev.as_ref(),
|
||||
remote_ip: self.data.remote_ip,
|
||||
ehlo_domain: self.data.helo_domain.as_str().into(),
|
||||
authenticated_as: self.data.authenticated_as.as_ref().map(|a| a.name()),
|
||||
asn: self.data.asn_geo_data.asn.as_ref().map(|a| a.id),
|
||||
country: self.data.asn_geo_data.country.as_ref().map(|c| c.as_str()),
|
||||
is_tls: self.stream.is_tls(),
|
||||
env_from: self
|
||||
.data
|
||||
.mail_from
|
||||
.as_ref()
|
||||
.map(|m| m.address_lcase.as_str())
|
||||
.unwrap_or_default(),
|
||||
env_from_flags: self
|
||||
.data
|
||||
.mail_from
|
||||
.as_ref()
|
||||
.map(|m| m.flags)
|
||||
.unwrap_or_default(),
|
||||
env_rcpt_rewritten_to: self
|
||||
.data
|
||||
.rcpt_to
|
||||
.iter()
|
||||
.map(|r| r.address_lcase.as_str())
|
||||
.collect(),
|
||||
env_rcpt_orig_to: self
|
||||
.data
|
||||
.rcpt_to
|
||||
.iter()
|
||||
.map(|r| {
|
||||
r.dsn_info
|
||||
.as_deref()
|
||||
.and_then(|info| info.strip_prefix("rfc822;"))
|
||||
.unwrap_or(r.address_lcase.as_str())
|
||||
})
|
||||
.collect(),
|
||||
is_test: false,
|
||||
is_train: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
core::{Session, SessionData, SessionParameters, SmtpSessionManager, State},
|
||||
scripts::ScriptResult,
|
||||
};
|
||||
use common::{
|
||||
BuildServer,
|
||||
config::smtp::session::Stage,
|
||||
network::{self, SessionManager, SessionStream},
|
||||
};
|
||||
use std::time::Instant;
|
||||
use tokio_rustls::server::TlsStream;
|
||||
use trc::{SecurityEvent, SmtpEvent};
|
||||
|
||||
impl SessionManager for SmtpSessionManager {
|
||||
async fn handle<T: SessionStream>(self, session: network::SessionData<T>) {
|
||||
// Build server and create session
|
||||
let server = self.inner.build_server();
|
||||
let _in_flight = session.in_flight;
|
||||
let mut session = Session {
|
||||
data: SessionData::new(
|
||||
session.local_ip,
|
||||
session.local_port,
|
||||
session.remote_ip,
|
||||
session.remote_port,
|
||||
server.lookup_asn_country(session.remote_ip).await,
|
||||
session.session_id,
|
||||
),
|
||||
hostname: "".into(),
|
||||
server,
|
||||
instance: session.instance,
|
||||
state: State::default(),
|
||||
stream: session.stream,
|
||||
params: SessionParameters::default(),
|
||||
};
|
||||
|
||||
// Enforce throttle
|
||||
if session.is_allowed().await
|
||||
&& session.init_conn().await
|
||||
&& session.handle_conn().await
|
||||
&& session.instance.acceptor.is_tls()
|
||||
&& let Ok(mut session) = session.into_tls().await
|
||||
{
|
||||
session.handle_conn().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::manual_async_fn)]
|
||||
fn shutdown(&self) -> impl std::future::Future<Output = ()> + Send {
|
||||
async {
|
||||
let _ = self
|
||||
.inner
|
||||
.ipc
|
||||
.queue_tx
|
||||
.send(common::ipc::QueueEvent::Stop)
|
||||
.await;
|
||||
let _ = self
|
||||
.inner
|
||||
.ipc
|
||||
.report_tx
|
||||
.send(common::ipc::ReportingEvent::Stop)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: SessionStream> Session<T> {
|
||||
pub async fn init_conn(&mut self) -> bool {
|
||||
self.eval_session_params().await;
|
||||
|
||||
let config = &self.server.core.smtp.session.connect;
|
||||
|
||||
// Sieve filtering
|
||||
if let Some((script, script_id)) = self
|
||||
.server
|
||||
.eval_if::<String, _>(&config.script, self, self.data.session_id)
|
||||
.await
|
||||
.and_then(|name| {
|
||||
self.server
|
||||
.get_trusted_sieve_script(&name, self.data.session_id)
|
||||
.map(|s| (s, name))
|
||||
})
|
||||
&& let ScriptResult::Reject(message) = self
|
||||
.run_script(
|
||||
script_id,
|
||||
script.clone(),
|
||||
self.build_script_parameters("connect"),
|
||||
)
|
||||
.await
|
||||
{
|
||||
let _ = self.write(message.as_bytes()).await;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Milter filtering
|
||||
if let Err(message) = self.run_milters(Stage::Connect, None, None).await {
|
||||
let _ = self.write(message.message.as_bytes()).await;
|
||||
return false;
|
||||
}
|
||||
|
||||
// MTAHook filtering
|
||||
if let Err(message) = self.run_mta_hooks(Stage::Connect, None, None).await {
|
||||
let _ = self.write(message.message.as_bytes()).await;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Obtain hostname
|
||||
self.hostname = self
|
||||
.server
|
||||
.eval_if::<String, _>(&config.hostname, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if self.hostname.is_empty() {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::MissingLocalHostname),
|
||||
SpanId = self.data.session_id,
|
||||
);
|
||||
self.hostname = "localhost".into();
|
||||
}
|
||||
|
||||
// Obtain greeting
|
||||
let greeting = self
|
||||
.server
|
||||
.eval_if::<String, _>(&config.greeting, self, self.data.session_id)
|
||||
.await
|
||||
.filter(|g| !g.is_empty())
|
||||
.map(|g| format!("220 {}\r\n", g))
|
||||
.unwrap_or_else(|| "220 Stalwart ESMTP at your service.\r\n".to_string());
|
||||
|
||||
if self.write(greeting.as_bytes()).await.is_err() {
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
pub async fn handle_conn(&mut self) -> bool {
|
||||
let mut buf = vec![0; 8192];
|
||||
let mut shutdown_rx = self.instance.shutdown_rx.clone();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
result = tokio::time::timeout(
|
||||
self.params.timeout,
|
||||
self.read(&mut buf)) => {
|
||||
match result {
|
||||
Ok(Ok(bytes_read)) => {
|
||||
if bytes_read > 0 {
|
||||
if Instant::now() < self.data.valid_until && bytes_read <= self.data.bytes_left {
|
||||
self.data.bytes_left -= bytes_read;
|
||||
match Box::pin(self.ingest(&buf[..bytes_read])).await {
|
||||
Ok(true) => (),
|
||||
Ok(false) => {
|
||||
return true;
|
||||
}
|
||||
Err(_) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if bytes_read > self.data.bytes_left {
|
||||
self
|
||||
.write(format!("452 4.7.28 {} Session exceeded transfer quota.\r\n", self.hostname).as_bytes())
|
||||
.await
|
||||
.ok();
|
||||
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::TransferLimitExceeded),
|
||||
SpanId = self.data.session_id,
|
||||
);
|
||||
|
||||
break;
|
||||
} else {
|
||||
self
|
||||
.write(format!("421 4.3.2 {} Session open for too long.\r\n", self.hostname).as_bytes())
|
||||
.await
|
||||
.ok();
|
||||
|
||||
match self.server.is_loiter_fail2banned(self.data.remote_ip)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {
|
||||
trc::event!(
|
||||
Security(SecurityEvent::LoiterBan),
|
||||
SpanId = self.data.session_id,
|
||||
RemoteIp = self.data.remote_ip,
|
||||
);
|
||||
}
|
||||
Ok(false) => {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::TimeLimitExceeded),
|
||||
SpanId = self.data.session_id,
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
trc::error!(err
|
||||
.span_id(self.data.session_id)
|
||||
.caused_by(trc::location!())
|
||||
.details("Failed to check if IP should be banned."));
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
trc::event!(
|
||||
Network(trc::NetworkEvent::Closed),
|
||||
SpanId = self.data.session_id,
|
||||
CausedBy = trc::location!()
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Err(_)) => {
|
||||
break;
|
||||
}
|
||||
Err(_) => {
|
||||
trc::event!(
|
||||
Network(trc::NetworkEvent::Timeout),
|
||||
SpanId = self.data.session_id,
|
||||
CausedBy = trc::location!()
|
||||
);
|
||||
|
||||
self
|
||||
.write(format!("221 2.0.0 {} Disconnecting inactive client.\r\n", self.hostname).as_bytes())
|
||||
.await
|
||||
.ok();
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
_ = shutdown_rx.changed() => {
|
||||
trc::event!(
|
||||
Network(trc::NetworkEvent::Closed),
|
||||
SpanId = self.data.session_id,
|
||||
Reason = "Server shutting down",
|
||||
CausedBy = trc::location!()
|
||||
);
|
||||
self.write(format!("421 4.3.0 {} Server shutting down.\r\n", self.hostname).as_bytes()).await.ok();
|
||||
break;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
pub async fn into_tls(self) -> Result<Session<TlsStream<T>>, ()> {
|
||||
Ok(Session {
|
||||
hostname: self.hostname,
|
||||
stream: self
|
||||
.instance
|
||||
.tls_accept(self.stream, self.data.session_id)
|
||||
.await?,
|
||||
state: self.state,
|
||||
data: self.data,
|
||||
instance: self.instance,
|
||||
server: self.server,
|
||||
params: self.params,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::core::Session;
|
||||
use common::network::{RcptResolution, SessionStream};
|
||||
use std::{borrow::Cow, fmt::Write};
|
||||
use trc::SmtpEvent;
|
||||
use utils::DomainPart;
|
||||
|
||||
impl<T: SessionStream> Session<T> {
|
||||
pub async fn handle_vrfy(&mut self, address: Cow<'_, str>) -> Result<(), ()> {
|
||||
if self.params.can_vrfy {
|
||||
match self
|
||||
.server
|
||||
.rcpt_resolve(
|
||||
&address.to_lowercase_address(true),
|
||||
true,
|
||||
self.data.session_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(RcptResolution::Accept | RcptResolution::Rewrite(_)) => {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::Vrfy),
|
||||
SpanId = self.data.session_id,
|
||||
To = address.as_ref().to_string(),
|
||||
);
|
||||
|
||||
self.write(format!("250 {}\r\n", address.as_ref()).as_bytes())
|
||||
.await
|
||||
}
|
||||
Ok(
|
||||
RcptResolution::UnknownRecipient
|
||||
| RcptResolution::UnknownDomain
|
||||
| RcptResolution::Expand(_),
|
||||
) => {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::VrfyNotFound),
|
||||
SpanId = self.data.session_id,
|
||||
To = address.as_ref().to_string(),
|
||||
);
|
||||
|
||||
self.write(b"550 5.1.2 Address not found.\r\n").await
|
||||
}
|
||||
Err(err) => {
|
||||
trc::error!(
|
||||
err.span_id(self.data.session_id)
|
||||
.caused_by(trc::location!())
|
||||
.details("Failed to verify address.")
|
||||
);
|
||||
|
||||
self.write(b"252 2.4.3 Unable to verify address at this time.\r\n")
|
||||
.await
|
||||
}
|
||||
}
|
||||
} else {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::VrfyDisabled),
|
||||
SpanId = self.data.session_id,
|
||||
To = address.as_ref().to_string(),
|
||||
);
|
||||
|
||||
self.write(b"252 2.5.1 VRFY is disabled.\r\n").await
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_expn(&mut self, address: Cow<'_, str>) -> Result<(), ()> {
|
||||
if self.params.can_expn {
|
||||
match self
|
||||
.server
|
||||
.rcpt_resolve(
|
||||
&address.to_lowercase_address(true),
|
||||
true,
|
||||
self.data.session_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(RcptResolution::Expand(addresses)) => {
|
||||
let mut result = String::with_capacity(32);
|
||||
for (pos, value) in addresses.iter().enumerate() {
|
||||
let _ = write!(
|
||||
result,
|
||||
"250{}{}\r\n",
|
||||
if pos == addresses.len() - 1 { " " } else { "-" },
|
||||
value
|
||||
);
|
||||
}
|
||||
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::Expn),
|
||||
SpanId = self.data.session_id,
|
||||
To = address.as_ref().to_string(),
|
||||
);
|
||||
|
||||
self.write(result.as_bytes()).await
|
||||
}
|
||||
Ok(_) => {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::ExpnNotFound),
|
||||
SpanId = self.data.session_id,
|
||||
To = address.as_ref().to_string(),
|
||||
);
|
||||
|
||||
self.write(b"550 5.1.2 Mailing list not found.\r\n").await
|
||||
}
|
||||
Err(err) => {
|
||||
trc::error!(
|
||||
err.span_id(self.data.session_id)
|
||||
.caused_by(trc::location!())
|
||||
.details("Failed to verify address.")
|
||||
);
|
||||
|
||||
self.write(b"252 2.4.3 Unable to expand mailing list at this time.\r\n")
|
||||
.await
|
||||
}
|
||||
}
|
||||
} else {
|
||||
trc::event!(
|
||||
Smtp(SmtpEvent::ExpnDisabled),
|
||||
SpanId = self.data.session_id,
|
||||
To = address.as_ref().to_string(),
|
||||
);
|
||||
|
||||
self.write(b"252 2.5.1 EXPN is disabled.\r\n").await
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
#![warn(clippy::large_futures)]
|
||||
|
||||
use common::{
|
||||
Inner,
|
||||
manager::boot::{BootManager, IpcReceivers},
|
||||
};
|
||||
use queue::manager::SpawnQueue;
|
||||
use reporting::scheduler::SpawnReport;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub mod core;
|
||||
pub mod inbound;
|
||||
pub mod outbound;
|
||||
pub mod queue;
|
||||
pub mod reporting;
|
||||
pub mod scripts;
|
||||
|
||||
pub trait StartQueueManager {
|
||||
fn start_queue_manager(&mut self);
|
||||
}
|
||||
|
||||
pub trait SpawnQueueManager {
|
||||
fn spawn_queue_manager(&mut self, inner: Arc<Inner>);
|
||||
}
|
||||
|
||||
impl StartQueueManager for BootManager {
|
||||
fn start_queue_manager(&mut self) {
|
||||
self.ipc_rxs.spawn_queue_manager(self.inner.clone());
|
||||
}
|
||||
}
|
||||
|
||||
impl SpawnQueueManager for IpcReceivers {
|
||||
fn spawn_queue_manager(&mut self, inner: Arc<Inner>) {
|
||||
let core = inner.shared_core.load();
|
||||
if !core.storage.registry.is_recovery_mode() && core.network.roles.outbound_mta {
|
||||
// Spawn queue manager
|
||||
self.queue_rx.take().unwrap().spawn(inner.clone());
|
||||
|
||||
// Spawn report manager
|
||||
self.report_rx.take().unwrap().spawn(inner);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,773 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use super::session::SessionParams;
|
||||
use crate::{
|
||||
outbound::error::{AssertReply, ClientError, ClientResult},
|
||||
queue::{Error, ErrorDetails, HostResponse, MessageWrapper, Status},
|
||||
};
|
||||
use base64::{Engine, engine::general_purpose};
|
||||
use directory::Credentials;
|
||||
use rustls::ClientConnection;
|
||||
use rustls_pki_types::ServerName;
|
||||
use smtp_proto::{
|
||||
AUTH_LOGIN, AUTH_OAUTHBEARER, AUTH_PLAIN, AUTH_XOAUTH2, EXT_START_TLS, EhloResponse, Response,
|
||||
response::{
|
||||
generate::BitToString,
|
||||
parser::{MAX_RESPONSE_LENGTH, ResponseReceiver},
|
||||
},
|
||||
};
|
||||
use std::{
|
||||
net::{IpAddr, SocketAddr},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::{
|
||||
io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt},
|
||||
net::{TcpSocket, TcpStream},
|
||||
};
|
||||
use tokio_rustls::{TlsConnector, client::TlsStream};
|
||||
use trc::DeliveryEvent;
|
||||
|
||||
pub struct SmtpClient<T: AsyncRead + AsyncWrite> {
|
||||
pub stream: T,
|
||||
pub timeout: Duration,
|
||||
pub session_id: u64,
|
||||
}
|
||||
|
||||
impl<T: AsyncRead + AsyncWrite + Unpin> SmtpClient<T> {
|
||||
pub async fn authenticate(
|
||||
&mut self,
|
||||
credentials: &Credentials,
|
||||
capabilities: impl AsRef<EhloResponse<String>>,
|
||||
) -> ClientResult<&mut Self> {
|
||||
let capabilities = capabilities.as_ref();
|
||||
let mut available_mechanisms = match &credentials {
|
||||
Credentials::Basic { .. } => AUTH_LOGIN | AUTH_PLAIN,
|
||||
Credentials::Bearer { .. } => AUTH_OAUTHBEARER | AUTH_XOAUTH2,
|
||||
} & capabilities.auth_mechanisms;
|
||||
|
||||
// Try authenticating from most secure to least secure
|
||||
let mut has_err = None;
|
||||
let mut has_failed = false;
|
||||
|
||||
while available_mechanisms != 0 && !has_failed {
|
||||
let mechanism = 1 << ((63 - available_mechanisms.leading_zeros()) as u64);
|
||||
available_mechanisms ^= mechanism;
|
||||
match self.auth(mechanism, credentials).await {
|
||||
Ok(_) => {
|
||||
return Ok(self);
|
||||
}
|
||||
Err(err) => match err {
|
||||
ClientError::UnexpectedReply(reply) => {
|
||||
has_failed = reply.code() == 535;
|
||||
has_err = reply.into();
|
||||
}
|
||||
ClientError::UnsupportedAuthMechanism => (),
|
||||
_ => return Err(err),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(has_err) = has_err {
|
||||
Err(ClientError::AuthenticationFailed(has_err))
|
||||
} else {
|
||||
Err(ClientError::UnsupportedAuthMechanism)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn auth(
|
||||
&mut self,
|
||||
mechanism: u64,
|
||||
credentials: &Credentials,
|
||||
) -> ClientResult<()> {
|
||||
let mut reply = if (mechanism & (AUTH_PLAIN | AUTH_XOAUTH2 | AUTH_OAUTHBEARER)) != 0 {
|
||||
self.cmd(
|
||||
format!(
|
||||
"AUTH {} {}\r\n",
|
||||
mechanism.to_mechanism(),
|
||||
encode_credentials(credentials, mechanism, "")?,
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
self.cmd(format!("AUTH {}\r\n", mechanism.to_mechanism()).as_bytes())
|
||||
.await?
|
||||
};
|
||||
|
||||
for _ in 0..3 {
|
||||
match reply.code() {
|
||||
334 => {
|
||||
reply = self
|
||||
.cmd(
|
||||
format!(
|
||||
"{}\r\n",
|
||||
encode_credentials(credentials, mechanism, reply.message())?
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
235 => {
|
||||
return Ok(());
|
||||
}
|
||||
_ => {
|
||||
return Err(ClientError::UnexpectedReply(Box::new(reply)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(ClientError::UnexpectedReply(Box::new(reply)))
|
||||
}
|
||||
|
||||
pub async fn read_greeting(
|
||||
&mut self,
|
||||
hostname: &str,
|
||||
) -> Result<(), Status<HostResponse<Box<str>>, ErrorDetails>> {
|
||||
tokio::time::timeout(self.timeout, self.read())
|
||||
.await
|
||||
.map_err(|_| Status::timeout(hostname, "reading greeting"))?
|
||||
.and_then(|r| r.assert_code(220))
|
||||
.map_err(|err| Status::from_smtp_error(hostname, "", err))
|
||||
}
|
||||
|
||||
pub async fn read_smtp_data_response(
|
||||
&mut self,
|
||||
hostname: &str,
|
||||
bdat_cmd: &Option<String>,
|
||||
) -> Result<Response<String>, Status<HostResponse<Box<str>>, ErrorDetails>> {
|
||||
tokio::time::timeout(self.timeout, self.read())
|
||||
.await
|
||||
.map_err(|_| Status::timeout(hostname, "reading SMTP DATA response"))?
|
||||
.map_err(|err| {
|
||||
Status::from_smtp_error(hostname, bdat_cmd.as_deref().unwrap_or("DATA"), err)
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn read_lmtp_data_response(
|
||||
&mut self,
|
||||
hostname: &str,
|
||||
num_responses: usize,
|
||||
) -> Result<Vec<Response<Box<str>>>, Status<HostResponse<Box<str>>, ErrorDetails>> {
|
||||
tokio::time::timeout(self.timeout, async { self.read_many(num_responses).await })
|
||||
.await
|
||||
.map_err(|_| Status::timeout(hostname, "reading LMTP DATA responses"))?
|
||||
.map_err(|err| Status::from_smtp_error(hostname, "", err))
|
||||
}
|
||||
|
||||
pub async fn write_chunks(&mut self, chunks: &[&[u8]]) -> Result<(), ClientError> {
|
||||
for chunk in chunks {
|
||||
self.stream
|
||||
.write_all(chunk)
|
||||
.await
|
||||
.map_err(ClientError::from)?;
|
||||
}
|
||||
self.stream.flush().await.map_err(ClientError::from)
|
||||
}
|
||||
|
||||
pub async fn send_message(
|
||||
&mut self,
|
||||
message: &MessageWrapper,
|
||||
rcpt_headers: Option<&[u8]>,
|
||||
bdat_cmd: &mut Option<String>,
|
||||
params: &SessionParams<'_>,
|
||||
) -> Result<(), Status<HostResponse<Box<str>>, ErrorDetails>> {
|
||||
match params
|
||||
.server
|
||||
.blob_store()
|
||||
.get_blob(message.message.blob_hash.as_slice(), 0..usize::MAX)
|
||||
.await
|
||||
{
|
||||
Ok(Some(raw_message)) => {
|
||||
tokio::time::timeout(params.conn_strategy.timeout_data, async {
|
||||
if let Some(bdat_cmd) = bdat_cmd {
|
||||
*bdat_cmd = format!(
|
||||
"BDAT {} LAST\r\n",
|
||||
raw_message.len() + rcpt_headers.map(|h| h.len()).unwrap_or(0)
|
||||
);
|
||||
|
||||
trc::event!(
|
||||
Delivery(DeliveryEvent::RawOutput),
|
||||
SpanId = self.session_id,
|
||||
Contents = bdat_cmd.clone(),
|
||||
Size = bdat_cmd.len()
|
||||
);
|
||||
|
||||
let chunks = if let Some(rcpt_headers) = rcpt_headers {
|
||||
&[bdat_cmd.as_bytes(), rcpt_headers, &raw_message][..]
|
||||
} else {
|
||||
&[bdat_cmd.as_bytes(), &raw_message][..]
|
||||
};
|
||||
|
||||
self.write_chunks(chunks).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)?;
|
||||
if let Some(rcpt_headers) = rcpt_headers
|
||||
&& let Err(err) = self.write_chunks(&[rcpt_headers]).await
|
||||
{
|
||||
Err(err)
|
||||
} else {
|
||||
self.write_message(&raw_message)
|
||||
.await
|
||||
.map_err(ClientError::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.message.blob_hash.to_hex(),
|
||||
CausedBy = trc::location!()
|
||||
);
|
||||
Err(Status::TemporaryFailure(ErrorDetails {
|
||||
entity: "localhost".into(),
|
||||
details: Error::Io("Queue system error.".into()),
|
||||
}))
|
||||
}
|
||||
Err(err) => {
|
||||
trc::error!(
|
||||
err.span_id(message.span_id)
|
||||
.details("Failed to fetch blobId")
|
||||
.caused_by(trc::location!())
|
||||
);
|
||||
|
||||
Err(Status::TemporaryFailure(ErrorDetails {
|
||||
entity: "localhost".into(),
|
||||
details: Error::Io("Queue system error.".into()),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn say_helo(
|
||||
&mut self,
|
||||
params: &SessionParams<'_>,
|
||||
) -> Result<EhloResponse<String>, Status<HostResponse<Box<str>>, ErrorDetails>> {
|
||||
let cmd = if params.is_smtp {
|
||||
format!("EHLO {}\r\n", params.local_hostname)
|
||||
} else {
|
||||
format!("LHLO {}\r\n", params.local_hostname)
|
||||
};
|
||||
|
||||
trc::event!(
|
||||
Delivery(DeliveryEvent::RawOutput),
|
||||
SpanId = self.session_id,
|
||||
Contents = cmd.clone(),
|
||||
Size = cmd.len()
|
||||
);
|
||||
|
||||
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
|
||||
})
|
||||
.await
|
||||
.map_err(|_| Status::timeout(params.hostname, "reading EHLO response"))?
|
||||
.map_err(|err| Status::from_smtp_error(params.hostname, &cmd, err))
|
||||
}
|
||||
|
||||
pub async fn quit(mut self: SmtpClient<T>) {
|
||||
trc::event!(
|
||||
Delivery(DeliveryEvent::RawOutput),
|
||||
SpanId = self.session_id,
|
||||
Contents = "QUIT\r\n",
|
||||
Size = 6
|
||||
);
|
||||
|
||||
let _ = tokio::time::timeout(Duration::from_secs(10), async {
|
||||
if self.stream.write_all(b"QUIT\r\n").await.is_ok() && self.stream.flush().await.is_ok()
|
||||
{
|
||||
let mut buf = [0u8; 128];
|
||||
let _ = self.stream.read(&mut buf).await;
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
pub async fn read_ehlo(&mut self) -> ClientResult<EhloResponse<String>> {
|
||||
let mut buf = vec![0u8; 8192];
|
||||
let mut buf_concat = Vec::with_capacity(0);
|
||||
|
||||
loop {
|
||||
let br = self.stream.read(&mut buf).await?;
|
||||
|
||||
if br == 0 {
|
||||
return Err(ClientError::UnparseableReply);
|
||||
}
|
||||
|
||||
trc::event!(
|
||||
Delivery(DeliveryEvent::RawInput),
|
||||
SpanId = self.session_id,
|
||||
Contents = trc::Value::from_maybe_string(&buf[..br]),
|
||||
Size = br,
|
||||
);
|
||||
|
||||
let mut iter = if buf_concat.is_empty() {
|
||||
buf[..br].iter()
|
||||
} else if br + buf_concat.len() < MAX_RESPONSE_LENGTH {
|
||||
buf_concat.extend_from_slice(&buf[..br]);
|
||||
buf_concat.iter()
|
||||
} else {
|
||||
return Err(ClientError::UnparseableReply);
|
||||
};
|
||||
|
||||
match EhloResponse::parse(&mut iter) {
|
||||
Ok(reply) => return Ok(reply),
|
||||
Err(err) => match err {
|
||||
smtp_proto::Error::NeedsMoreData { .. } => {
|
||||
if buf_concat.is_empty() {
|
||||
buf_concat = buf[..br].to_vec();
|
||||
}
|
||||
}
|
||||
smtp_proto::Error::InvalidResponse { code } => {
|
||||
match ResponseReceiver::from_code(code).parse(&mut iter) {
|
||||
Ok(response) => {
|
||||
return Err(ClientError::UnexpectedReply(Box::new(response)));
|
||||
}
|
||||
Err(smtp_proto::Error::NeedsMoreData { .. }) => {
|
||||
if buf_concat.is_empty() {
|
||||
buf_concat = buf[..br].to_vec();
|
||||
}
|
||||
}
|
||||
Err(_) => return Err(ClientError::UnparseableReply),
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(ClientError::UnparseableReply);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn read(&mut self) -> ClientResult<Response<String>> {
|
||||
let mut buf = vec![0u8; 8192];
|
||||
let mut parser = ResponseReceiver::default();
|
||||
|
||||
loop {
|
||||
let br = self.stream.read(&mut buf).await?;
|
||||
|
||||
if br > 0 {
|
||||
trc::event!(
|
||||
Delivery(DeliveryEvent::RawInput),
|
||||
SpanId = self.session_id,
|
||||
Contents = trc::Value::from_maybe_string(&buf[..br]),
|
||||
Size = br
|
||||
);
|
||||
|
||||
match parser.parse(&mut buf[..br].iter()) {
|
||||
Ok(reply) => return Ok(reply),
|
||||
Err(err) => match err {
|
||||
smtp_proto::Error::NeedsMoreData { .. } => (),
|
||||
_ => {
|
||||
return Err(ClientError::UnparseableReply);
|
||||
}
|
||||
},
|
||||
}
|
||||
} else {
|
||||
return Err(ClientError::UnparseableReply);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn read_many(&mut self, num: usize) -> ClientResult<Vec<Response<Box<str>>>> {
|
||||
let mut buf = vec![0u8; 1024];
|
||||
let mut response = Vec::with_capacity(num);
|
||||
let mut parser = ResponseReceiver::default();
|
||||
|
||||
'outer: loop {
|
||||
let br = self.stream.read(&mut buf).await?;
|
||||
|
||||
if br > 0 {
|
||||
let mut iter = buf[..br].iter();
|
||||
|
||||
trc::event!(
|
||||
Delivery(DeliveryEvent::RawInput),
|
||||
SpanId = self.session_id,
|
||||
Contents = trc::Value::from_maybe_string(&buf[..br]),
|
||||
Size = br
|
||||
);
|
||||
|
||||
loop {
|
||||
match parser.parse(&mut iter) {
|
||||
Ok(reply) => {
|
||||
response.push(reply.into_box());
|
||||
if response.len() != num {
|
||||
parser.reset();
|
||||
} else {
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
Err(err) => match err {
|
||||
smtp_proto::Error::NeedsMoreData { .. } => break,
|
||||
_ => {
|
||||
return Err(ClientError::UnparseableReply);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return Err(ClientError::UnparseableReply);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// Sends a command to the SMTP server and waits for a reply.
|
||||
pub async fn cmd(&mut self, cmd: impl AsRef<[u8]>) -> ClientResult<Response<String>> {
|
||||
tokio::time::timeout(self.timeout, async {
|
||||
let cmd = cmd.as_ref();
|
||||
|
||||
trc::event!(
|
||||
Delivery(DeliveryEvent::RawOutput),
|
||||
SpanId = self.session_id,
|
||||
Contents = trc::Value::from_maybe_string(cmd),
|
||||
Size = cmd.len()
|
||||
);
|
||||
|
||||
self.stream.write_all(cmd).await?;
|
||||
self.stream.flush().await?;
|
||||
self.read().await
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ClientError::Timeout)?
|
||||
}
|
||||
|
||||
pub async fn write_message(&mut self, message: &[u8]) -> tokio::io::Result<()> {
|
||||
// Transparency procedure
|
||||
let mut is_cr_or_lf = false;
|
||||
|
||||
// As per RFC 5322bis, section 2.3:
|
||||
// CR and LF MUST only occur together as CRLF; they MUST NOT appear
|
||||
// independently in the body.
|
||||
// For this reason, we apply the transparency procedure when there is
|
||||
// a CR or LF followed by a dot.
|
||||
|
||||
trc::event!(
|
||||
Delivery(DeliveryEvent::RawOutput),
|
||||
SpanId = self.session_id,
|
||||
Contents = "[message]",
|
||||
Size = message.len() + 5
|
||||
);
|
||||
|
||||
let mut last_pos = 0;
|
||||
for (pos, byte) in message.iter().enumerate() {
|
||||
if *byte == b'.' && is_cr_or_lf {
|
||||
if let Some(bytes) = message.get(last_pos..pos) {
|
||||
self.stream.write_all(bytes).await?;
|
||||
self.stream.write_all(b".").await?;
|
||||
last_pos = pos;
|
||||
}
|
||||
is_cr_or_lf = false;
|
||||
} else {
|
||||
is_cr_or_lf = *byte == b'\n' || *byte == b'\r';
|
||||
}
|
||||
}
|
||||
if let Some(bytes) = message.get(last_pos..) {
|
||||
self.stream.write_all(bytes).await?;
|
||||
}
|
||||
self.stream.write_all("\r\n.\r\n".as_bytes()).await?;
|
||||
self.stream.flush().await
|
||||
}
|
||||
}
|
||||
|
||||
impl SmtpClient<TcpStream> {
|
||||
/// Upgrade the connection to TLS.
|
||||
pub async fn start_tls(
|
||||
mut self,
|
||||
tls_connector: &TlsConnector,
|
||||
hostname: &str,
|
||||
) -> ClientResult<SmtpClient<TlsStream<TcpStream>>> {
|
||||
// Send STARTTLS command
|
||||
self.cmd(b"STARTTLS\r\n")
|
||||
.await?
|
||||
.assert_positive_completion()?;
|
||||
|
||||
self.into_tls(tls_connector, hostname).await
|
||||
}
|
||||
|
||||
pub async fn into_tls(
|
||||
self,
|
||||
tls_connector: &TlsConnector,
|
||||
hostname: &str,
|
||||
) -> ClientResult<SmtpClient<TlsStream<TcpStream>>> {
|
||||
tokio::time::timeout(self.timeout, async {
|
||||
Ok(SmtpClient {
|
||||
stream: tls_connector
|
||||
.connect(
|
||||
ServerName::try_from(hostname)
|
||||
.map_err(|_| ClientError::InvalidTLSName)?
|
||||
.to_owned(),
|
||||
self.stream,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
let kind = err.kind();
|
||||
if let Some(inner) = err.into_inner() {
|
||||
match inner.downcast::<rustls::Error>() {
|
||||
Ok(error) => ClientError::Tls(error),
|
||||
Err(error) => ClientError::Io(std::io::Error::new(kind, error)),
|
||||
}
|
||||
} else {
|
||||
ClientError::Io(std::io::Error::new(kind, "Unspecified"))
|
||||
}
|
||||
})?,
|
||||
timeout: self.timeout,
|
||||
session_id: self.session_id,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ClientError::Timeout)?
|
||||
}
|
||||
}
|
||||
|
||||
impl SmtpClient<TcpStream> {
|
||||
/// Connects to a remote host address
|
||||
pub async fn connect(
|
||||
remote_addr: SocketAddr,
|
||||
timeout: Duration,
|
||||
session_id: u64,
|
||||
) -> ClientResult<Self> {
|
||||
tokio::time::timeout(timeout, async {
|
||||
Ok(SmtpClient {
|
||||
stream: TcpStream::connect(remote_addr).await?,
|
||||
timeout,
|
||||
session_id,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ClientError::Timeout)?
|
||||
}
|
||||
|
||||
/// Connects to a remote host address using the provided local IP
|
||||
pub async fn connect_using(
|
||||
local_ip: IpAddr,
|
||||
remote_addr: SocketAddr,
|
||||
timeout: Duration,
|
||||
session_id: u64,
|
||||
) -> ClientResult<Self> {
|
||||
tokio::time::timeout(timeout, async {
|
||||
let socket = if local_ip.is_ipv4() {
|
||||
TcpSocket::new_v4()?
|
||||
} else {
|
||||
TcpSocket::new_v6()?
|
||||
};
|
||||
socket.bind(SocketAddr::new(local_ip, 0))?;
|
||||
|
||||
Ok(SmtpClient {
|
||||
stream: socket.connect(remote_addr).await?,
|
||||
timeout,
|
||||
session_id,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|_| ClientError::Timeout)?
|
||||
}
|
||||
|
||||
pub async fn try_start_tls(
|
||||
mut self,
|
||||
tls_connector: &TlsConnector,
|
||||
hostname: &str,
|
||||
capabilities: &EhloResponse<String>,
|
||||
) -> StartTlsResult {
|
||||
if capabilities.has_capability(EXT_START_TLS) {
|
||||
match self.cmd("STARTTLS\r\n").await {
|
||||
Ok(response) => {
|
||||
if response.code() == 220 {
|
||||
match self.into_tls(tls_connector, hostname).await {
|
||||
Ok(smtp_client) => StartTlsResult::Success { smtp_client },
|
||||
Err(error) => StartTlsResult::Error { error },
|
||||
}
|
||||
} else {
|
||||
StartTlsResult::Unavailable {
|
||||
response: response.into_box().into(),
|
||||
smtp_client: self,
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => StartTlsResult::Error { error },
|
||||
}
|
||||
} else {
|
||||
StartTlsResult::Unavailable {
|
||||
smtp_client: self,
|
||||
response: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_credentials(
|
||||
credentials: &Credentials,
|
||||
mechanism: u64,
|
||||
challenge: &str,
|
||||
) -> ClientResult<String> {
|
||||
Ok(general_purpose::STANDARD.encode(
|
||||
match (mechanism, credentials) {
|
||||
(
|
||||
AUTH_PLAIN,
|
||||
Credentials::Basic {
|
||||
username, secret, ..
|
||||
},
|
||||
) => {
|
||||
format!("\u{0}{}\u{0}{}", username, secret)
|
||||
}
|
||||
(
|
||||
AUTH_LOGIN,
|
||||
Credentials::Basic {
|
||||
username, secret, ..
|
||||
},
|
||||
) => {
|
||||
let challenge = general_purpose::STANDARD.decode(challenge)?;
|
||||
|
||||
if b"user name"
|
||||
.eq_ignore_ascii_case(challenge.get(0..9).ok_or(ClientError::InvalidChallenge)?)
|
||||
|| b"username".eq_ignore_ascii_case(
|
||||
// Because Google makes its own standards
|
||||
challenge.get(0..8).ok_or(ClientError::InvalidChallenge)?,
|
||||
)
|
||||
{
|
||||
&username
|
||||
} else if b"password"
|
||||
.eq_ignore_ascii_case(challenge.get(0..8).ok_or(ClientError::InvalidChallenge)?)
|
||||
{
|
||||
&secret
|
||||
} else {
|
||||
return Err(ClientError::InvalidChallenge);
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
(AUTH_XOAUTH2, Credentials::Bearer { token, username }) => format!(
|
||||
"user={}\x01auth=Bearer {}\x01\x01",
|
||||
username.as_deref().unwrap_or_default(),
|
||||
token
|
||||
),
|
||||
(AUTH_OAUTHBEARER, Credentials::Bearer { token, .. }) => token.to_string(),
|
||||
_ => return Err(ClientError::UnsupportedAuthMechanism),
|
||||
}
|
||||
.as_bytes(),
|
||||
))
|
||||
}
|
||||
|
||||
impl SmtpClient<TlsStream<TcpStream>> {
|
||||
pub fn tls_connection(&self) -> &ClientConnection {
|
||||
self.stream.get_ref().1
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum StartTlsResult {
|
||||
Success {
|
||||
smtp_client: SmtpClient<TlsStream<TcpStream>>,
|
||||
},
|
||||
Error {
|
||||
error: ClientError,
|
||||
},
|
||||
Unavailable {
|
||||
response: Option<Response<Box<str>>>,
|
||||
smtp_client: SmtpClient<TcpStream>,
|
||||
},
|
||||
}
|
||||
|
||||
pub(crate) trait BoxResponse {
|
||||
fn into_box(self) -> Response<Box<str>>;
|
||||
}
|
||||
|
||||
impl BoxResponse for Response<String> {
|
||||
fn into_box(self) -> Response<Box<str>> {
|
||||
Response {
|
||||
code: self.code,
|
||||
esc: self.esc,
|
||||
message: self.message.into_boxed_str(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_mail_send_error(error: &ClientError) -> trc::Error {
|
||||
let event = trc::EventType::Smtp(trc::SmtpEvent::Error).into_err();
|
||||
match error {
|
||||
ClientError::Io(err) => event.details("I/O Error").reason(err),
|
||||
ClientError::Tls(err) => event.details("TLS Error").reason(err),
|
||||
ClientError::Base64(err) => event.details("Base64 Error").reason(err),
|
||||
ClientError::InvalidChallenge => event
|
||||
.details("SMTP Authentication Error")
|
||||
.reason("Invalid Challenge"),
|
||||
ClientError::UnparseableReply => event.details("Unparseable SMTP Reply"),
|
||||
ClientError::UnexpectedReply(reply) => event
|
||||
.details("Unexpected SMTP Response")
|
||||
.ctx(trc::Key::Code, reply.code)
|
||||
.ctx(trc::Key::Reason, reply.message.clone()),
|
||||
ClientError::AuthenticationFailed(reply) => event
|
||||
.details("SMTP Authentication Failed")
|
||||
.ctx(trc::Key::Code, reply.code)
|
||||
.ctx(trc::Key::Reason, reply.message.clone()),
|
||||
ClientError::InvalidTLSName => event.details("Invalid TLS Name"),
|
||||
ClientError::MissingCredentials => event.details("Missing Authentication Credentials"),
|
||||
ClientError::MissingMailFrom => event.details("Missing Message Sender"),
|
||||
ClientError::MissingRcptTo => event.details("Missing Message Recipients"),
|
||||
ClientError::UnsupportedAuthMechanism => {
|
||||
event.details("Unsupported Authentication Mechanism")
|
||||
}
|
||||
ClientError::Timeout => event.details("Connection Timeout"),
|
||||
ClientError::MissingStartTls => event.details("STARTTLS not available"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_error_status(err: &Status<HostResponse<Box<str>>, 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.clone()),
|
||||
Error::TlsError(err) => event
|
||||
.details("TLS Error")
|
||||
.ctx(trc::Key::Reason, err.clone()),
|
||||
Error::DaneError(err) => event
|
||||
.details("DANE Error")
|
||||
.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"),
|
||||
Error::Io(err) => event.details("I/O Error").reason(err),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,627 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use common::{
|
||||
Server,
|
||||
config::smtp::resolver::{Tlsa, TlsaEntry, TlsaMatching},
|
||||
};
|
||||
pub use mail_auth::DnssecStatus;
|
||||
use mail_auth::{
|
||||
MX, RecordSet,
|
||||
common::resolver::ToFqdn,
|
||||
hickory_resolver::{
|
||||
net::{DnsError, NetError},
|
||||
proto::{
|
||||
dnssec::Proof,
|
||||
op::ResponseCode,
|
||||
rr::{
|
||||
Name, RData, Record, RecordType,
|
||||
rdata::tlsa::{CertUsage, Matching, Selector},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
use std::{
|
||||
future::Future,
|
||||
net::{Ipv4Addr, Ipv6Addr},
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
pub trait TlsaLookup: Sync + Send {
|
||||
fn mx_lookup(
|
||||
&self,
|
||||
key: impl ToFqdn + Sync + Send,
|
||||
) -> impl Future<Output = mail_auth::Result<RecordSet<MX>>> + Send;
|
||||
|
||||
fn tlsa_lookup(
|
||||
&self,
|
||||
key: impl ToFqdn + Sync + Send,
|
||||
) -> impl Future<Output = mail_auth::Result<TlsaResult>> + Send;
|
||||
|
||||
fn ipv4_lookup_dnssec(
|
||||
&self,
|
||||
key: impl ToFqdn + Sync + Send,
|
||||
) -> impl Future<Output = mail_auth::Result<RecordSet<Ipv4Addr>>> + Send;
|
||||
|
||||
fn ipv6_lookup_dnssec(
|
||||
&self,
|
||||
key: impl ToFqdn + Sync + Send,
|
||||
) -> impl Future<Output = mail_auth::Result<RecordSet<Ipv6Addr>>> + Send;
|
||||
}
|
||||
|
||||
pub enum TlsaResult {
|
||||
Secure(Arc<Tlsa>),
|
||||
Bogus,
|
||||
Missing,
|
||||
}
|
||||
|
||||
impl TlsaLookup for Server {
|
||||
async fn mx_lookup(&self, key: impl ToFqdn + Sync + Send) -> mail_auth::Result<RecordSet<MX>> {
|
||||
if !self.core.smtp.resolvers.dnssec_available {
|
||||
return self
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.mx_lookup(key, Some(&self.inner.cache.dns_mx))
|
||||
.await;
|
||||
}
|
||||
|
||||
let key = key.to_fqdn().into_owned().into_boxed_str();
|
||||
if let Some(value) = self.inner.cache.dns_mx.get::<str>(key.as_ref())
|
||||
&& value.dnssec_status != DnssecStatus::Indeterminate
|
||||
{
|
||||
return Ok(value);
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test_mode"))]
|
||||
if true {
|
||||
return mail_auth::common::resolver::mock_resolve(key.as_ref());
|
||||
}
|
||||
|
||||
let mx_lookup = match self
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dnssec
|
||||
.resolver
|
||||
.mx_lookup(Name::from_str_relaxed::<&str>(key.as_ref())?)
|
||||
.await
|
||||
{
|
||||
Ok(mx_lookup) => mx_lookup,
|
||||
Err(err) => {
|
||||
if let Some(denial) = NegativeAnswer::from_error(&err)
|
||||
&& denial.response_code == ResponseCode::NoError
|
||||
{
|
||||
let records = RecordSet {
|
||||
rrset: Arc::new([]),
|
||||
dnssec_status: denial.dnssec_status,
|
||||
};
|
||||
if let Some(valid_until) = denial.valid_until {
|
||||
self.inner.cache.dns_mx.insert_with_expiry(
|
||||
key,
|
||||
records.clone(),
|
||||
valid_until,
|
||||
);
|
||||
}
|
||||
return Ok(records);
|
||||
}
|
||||
return Err(err.into());
|
||||
}
|
||||
};
|
||||
let mx_records = mx_lookup.answers();
|
||||
let mut dnssec_status: Option<DnssecStatus> = None;
|
||||
let mut records: Vec<(u16, Vec<Box<str>>)> = Vec::with_capacity(mx_records.len());
|
||||
for mx_record in mx_records {
|
||||
if let RData::MX(mx) = &mx_record.data {
|
||||
dnssec_status = Some(match dnssec_status {
|
||||
Some(status) => least_secure(status, proof_to_dnssec_status(mx_record.proof)),
|
||||
None => proof_to_dnssec_status(mx_record.proof),
|
||||
});
|
||||
|
||||
let preference = mx.preference;
|
||||
let exchange = mx.exchange.to_lowercase().to_ascii().into_boxed_str();
|
||||
|
||||
if let Some(record) = records.iter_mut().find(|r| r.0 == preference) {
|
||||
record.1.push(exchange);
|
||||
} else {
|
||||
records.push((preference, vec![exchange]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
records.sort_unstable_by_key(|a| a.0);
|
||||
let rrset: Arc<[MX]> = records
|
||||
.into_iter()
|
||||
.map(|(preference, exchanges)| MX {
|
||||
preference,
|
||||
exchanges: exchanges.into_boxed_slice(),
|
||||
})
|
||||
.collect::<Arc<[MX]>>();
|
||||
let records = RecordSet {
|
||||
rrset,
|
||||
dnssec_status: dnssec_status.unwrap_or(DnssecStatus::Indeterminate),
|
||||
};
|
||||
|
||||
self.inner
|
||||
.cache
|
||||
.dns_mx
|
||||
.insert_with_expiry(key, records.clone(), mx_lookup.valid_until());
|
||||
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
async fn tlsa_lookup(&self, key: impl ToFqdn + Sync + Send) -> mail_auth::Result<TlsaResult> {
|
||||
let key = key.to_fqdn().into_owned().into_boxed_str();
|
||||
if let Some(value) = self.inner.cache.dns_tlsa.get(key.as_ref()) {
|
||||
return Ok(TlsaResult::Secure(value));
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test_mode"))]
|
||||
if true {
|
||||
if key.as_ref().contains("_dnssec_bogus.") {
|
||||
return Ok(TlsaResult::Bogus);
|
||||
}
|
||||
return mail_auth::common::resolver::mock_resolve(key.as_ref());
|
||||
}
|
||||
|
||||
let tlsa_lookup = match self
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dnssec
|
||||
.resolver
|
||||
.tlsa_lookup(Name::from_str_relaxed(key.as_ref())?)
|
||||
.await
|
||||
{
|
||||
Ok(tlsa_lookup) => tlsa_lookup,
|
||||
Err(err) => {
|
||||
if let Some(denial) = NegativeAnswer::from_error(&err) {
|
||||
return Ok(if denial.dnssec_status == DnssecStatus::Bogus {
|
||||
TlsaResult::Bogus
|
||||
} else {
|
||||
TlsaResult::Missing
|
||||
});
|
||||
}
|
||||
return Err(err.into());
|
||||
}
|
||||
};
|
||||
|
||||
let mut entries = Vec::new();
|
||||
let mut has_end_entities = false;
|
||||
let mut has_intermediates = false;
|
||||
let mut dnssec_status: Option<DnssecStatus> = None;
|
||||
|
||||
for record in tlsa_lookup.answers() {
|
||||
if let RData::TLSA(tlsa) = &record.data {
|
||||
dnssec_status = Some(match dnssec_status {
|
||||
Some(status) => least_secure(status, proof_to_dnssec_status(record.proof)),
|
||||
None => proof_to_dnssec_status(record.proof),
|
||||
});
|
||||
|
||||
if !record.proof.is_secure() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let is_end_entity = match tlsa.cert_usage {
|
||||
CertUsage::DaneEe => true,
|
||||
CertUsage::DaneTa => false,
|
||||
_ => continue,
|
||||
};
|
||||
let matching = match tlsa.matching {
|
||||
Matching::Raw => TlsaMatching::Full,
|
||||
Matching::Sha256 => TlsaMatching::Sha256,
|
||||
Matching::Sha512 => TlsaMatching::Sha512,
|
||||
_ => continue,
|
||||
};
|
||||
let is_spki = match tlsa.selector {
|
||||
Selector::Spki => true,
|
||||
Selector::Full => false,
|
||||
_ => continue,
|
||||
};
|
||||
if is_end_entity {
|
||||
has_end_entities = true;
|
||||
} else {
|
||||
has_intermediates = true;
|
||||
}
|
||||
entries.push(TlsaEntry {
|
||||
is_end_entity,
|
||||
is_spki,
|
||||
matching,
|
||||
data: tlsa.cert_data.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
match dnssec_status {
|
||||
Some(DnssecStatus::Bogus) => Ok(TlsaResult::Bogus),
|
||||
Some(DnssecStatus::Secure) => {
|
||||
let tlsa = Arc::new(Tlsa {
|
||||
entries,
|
||||
has_end_entities,
|
||||
has_intermediates,
|
||||
});
|
||||
|
||||
self.inner.cache.dns_tlsa.insert_with_expiry(
|
||||
key,
|
||||
tlsa.clone(),
|
||||
tlsa_lookup.valid_until(),
|
||||
);
|
||||
|
||||
Ok(TlsaResult::Secure(tlsa))
|
||||
}
|
||||
_ => Ok(TlsaResult::Missing),
|
||||
}
|
||||
}
|
||||
|
||||
async fn ipv4_lookup_dnssec(
|
||||
&self,
|
||||
key: impl ToFqdn + Sync + Send,
|
||||
) -> mail_auth::Result<RecordSet<Ipv4Addr>> {
|
||||
if !self.core.smtp.resolvers.dnssec_available {
|
||||
return self
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.ipv4_lookup(key, Some(&self.inner.cache.dns_ipv4))
|
||||
.await;
|
||||
}
|
||||
|
||||
let key = key.to_fqdn().into_owned().into_boxed_str();
|
||||
if let Some(value) = self.inner.cache.dns_ipv4.get::<str>(key.as_ref())
|
||||
&& value.dnssec_status != DnssecStatus::Indeterminate
|
||||
{
|
||||
return Ok(value);
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test_mode"))]
|
||||
if true {
|
||||
return mail_auth::common::resolver::mock_resolve(key.as_ref());
|
||||
}
|
||||
|
||||
let name = Name::from_str_relaxed::<&str>(key.as_ref())?;
|
||||
let lookup = match self
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dnssec
|
||||
.resolver
|
||||
.ipv4_lookup(name.clone())
|
||||
.await
|
||||
{
|
||||
Ok(lookup) => lookup,
|
||||
Err(err) => {
|
||||
if let Some(denial) = NegativeAnswer::from_error(&err)
|
||||
&& denial.response_code == ResponseCode::NoError
|
||||
{
|
||||
let records = RecordSet {
|
||||
rrset: Arc::new([]),
|
||||
dnssec_status: denial.dnssec_status,
|
||||
};
|
||||
if let Some(valid_until) = denial.valid_until {
|
||||
self.inner.cache.dns_ipv4.insert_with_expiry(
|
||||
key,
|
||||
records.clone(),
|
||||
valid_until,
|
||||
);
|
||||
}
|
||||
return Ok(records);
|
||||
}
|
||||
return Err(err.into());
|
||||
}
|
||||
};
|
||||
|
||||
let answers = lookup.answers();
|
||||
let records = RecordSet {
|
||||
rrset: answers
|
||||
.iter()
|
||||
.filter_map(|record| match &record.data {
|
||||
RData::A(addr) => Some(addr.0),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Arc<[Ipv4Addr]>>(),
|
||||
dnssec_status: tlsa_base_status(&name, answers, RecordType::A),
|
||||
};
|
||||
|
||||
self.inner
|
||||
.cache
|
||||
.dns_ipv4
|
||||
.insert_with_expiry(key, records.clone(), lookup.valid_until());
|
||||
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
async fn ipv6_lookup_dnssec(
|
||||
&self,
|
||||
key: impl ToFqdn + Sync + Send,
|
||||
) -> mail_auth::Result<RecordSet<Ipv6Addr>> {
|
||||
if !self.core.smtp.resolvers.dnssec_available {
|
||||
return self
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.ipv6_lookup(key, Some(&self.inner.cache.dns_ipv6))
|
||||
.await;
|
||||
}
|
||||
|
||||
let key = key.to_fqdn().into_owned().into_boxed_str();
|
||||
if let Some(value) = self.inner.cache.dns_ipv6.get::<str>(key.as_ref())
|
||||
&& value.dnssec_status != DnssecStatus::Indeterminate
|
||||
{
|
||||
return Ok(value);
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test_mode"))]
|
||||
if true {
|
||||
return mail_auth::common::resolver::mock_resolve(key.as_ref());
|
||||
}
|
||||
|
||||
let name = Name::from_str_relaxed::<&str>(key.as_ref())?;
|
||||
let lookup = match self
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dnssec
|
||||
.resolver
|
||||
.ipv6_lookup(name.clone())
|
||||
.await
|
||||
{
|
||||
Ok(lookup) => lookup,
|
||||
Err(err) => {
|
||||
if let Some(denial) = NegativeAnswer::from_error(&err)
|
||||
&& denial.response_code == ResponseCode::NoError
|
||||
{
|
||||
let records = RecordSet {
|
||||
rrset: Arc::new([]),
|
||||
dnssec_status: denial.dnssec_status,
|
||||
};
|
||||
if let Some(valid_until) = denial.valid_until {
|
||||
self.inner.cache.dns_ipv6.insert_with_expiry(
|
||||
key,
|
||||
records.clone(),
|
||||
valid_until,
|
||||
);
|
||||
}
|
||||
return Ok(records);
|
||||
}
|
||||
return Err(err.into());
|
||||
}
|
||||
};
|
||||
|
||||
let answers = lookup.answers();
|
||||
let records = RecordSet {
|
||||
rrset: answers
|
||||
.iter()
|
||||
.filter_map(|record| match &record.data {
|
||||
RData::AAAA(addr) => Some(addr.0),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Arc<[Ipv6Addr]>>(),
|
||||
dnssec_status: tlsa_base_status(&name, answers, RecordType::AAAA),
|
||||
};
|
||||
|
||||
self.inner
|
||||
.cache
|
||||
.dns_ipv6
|
||||
.insert_with_expiry(key, records.clone(), lookup.valid_until());
|
||||
|
||||
Ok(records)
|
||||
}
|
||||
}
|
||||
|
||||
struct NegativeAnswer {
|
||||
response_code: ResponseCode,
|
||||
dnssec_status: DnssecStatus,
|
||||
valid_until: Option<Instant>,
|
||||
}
|
||||
|
||||
impl NegativeAnswer {
|
||||
fn from_error(err: &NetError) -> Option<Self> {
|
||||
let NetError::Dns(dns_error) = err else {
|
||||
return None;
|
||||
};
|
||||
|
||||
match dns_error {
|
||||
DnsError::NoRecordsFound(no_records) => Some(NegativeAnswer {
|
||||
response_code: no_records.response_code,
|
||||
dnssec_status: no_records
|
||||
.authorities
|
||||
.as_deref()
|
||||
.map(denial_dnssec_status)
|
||||
.unwrap_or(DnssecStatus::Indeterminate),
|
||||
valid_until: no_records
|
||||
.negative_ttl
|
||||
.map(|ttl| Instant::now() + Duration::from_secs(ttl as u64)),
|
||||
}),
|
||||
DnsError::Nsec {
|
||||
response, proof, ..
|
||||
} => Some(NegativeAnswer {
|
||||
response_code: response.response_code,
|
||||
dnssec_status: proof_to_dnssec_status(*proof),
|
||||
valid_until: None,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn denial_dnssec_status(authorities: &[Record]) -> DnssecStatus {
|
||||
authorities
|
||||
.iter()
|
||||
.filter(|record| matches!(record.record_type(), RecordType::NSEC | RecordType::NSEC3))
|
||||
.map(|record| proof_to_dnssec_status(record.proof))
|
||||
.reduce(least_secure)
|
||||
.unwrap_or(DnssecStatus::Indeterminate)
|
||||
}
|
||||
|
||||
fn proof_to_dnssec_status(proof: Proof) -> DnssecStatus {
|
||||
match proof {
|
||||
Proof::Secure => DnssecStatus::Secure,
|
||||
Proof::Insecure => DnssecStatus::Insecure,
|
||||
Proof::Bogus => DnssecStatus::Bogus,
|
||||
Proof::Indeterminate => DnssecStatus::Indeterminate,
|
||||
}
|
||||
}
|
||||
|
||||
fn tlsa_base_status(query: &Name, answers: &[Record], address_type: RecordType) -> DnssecStatus {
|
||||
let mut addresses: Option<DnssecStatus> = None;
|
||||
let mut alias: Option<DnssecStatus> = None;
|
||||
|
||||
for record in answers {
|
||||
let status = proof_to_dnssec_status(record.proof);
|
||||
if record.record_type() == address_type {
|
||||
addresses = Some(match addresses {
|
||||
Some(current) => least_secure(current, status),
|
||||
None => status,
|
||||
});
|
||||
} else if record.record_type() == RecordType::CNAME && &record.name == query {
|
||||
alias = Some(match alias {
|
||||
Some(current) => least_secure(current, status),
|
||||
None => status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
match (addresses, alias) {
|
||||
(Some(DnssecStatus::Insecure), Some(DnssecStatus::Secure)) => DnssecStatus::Secure,
|
||||
(Some(status), _) => status,
|
||||
(None, _) => DnssecStatus::Indeterminate,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn least_secure(a: DnssecStatus, b: DnssecStatus) -> DnssecStatus {
|
||||
fn rank(status: DnssecStatus) -> u8 {
|
||||
match status {
|
||||
DnssecStatus::Bogus => 0,
|
||||
DnssecStatus::Indeterminate => 1,
|
||||
DnssecStatus::Insecure => 2,
|
||||
DnssecStatus::Secure => 3,
|
||||
}
|
||||
}
|
||||
|
||||
if rank(a) <= rank(b) { a } else { b }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use mail_auth::hickory_resolver::proto::rr::rdata::{A, CNAME};
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
fn name(value: &str) -> Name {
|
||||
Name::from_ascii(value).unwrap()
|
||||
}
|
||||
|
||||
fn address(owner: &str, proof: Proof) -> Record {
|
||||
let mut record =
|
||||
Record::from_rdata(name(owner), 3600, RData::A(A(Ipv4Addr::new(192, 0, 2, 1))));
|
||||
record.proof = proof;
|
||||
record
|
||||
}
|
||||
|
||||
fn alias(owner: &str, target: &str, proof: Proof) -> Record {
|
||||
let mut record = Record::from_rdata(name(owner), 3600, RData::CNAME(CNAME(name(target))));
|
||||
record.proof = proof;
|
||||
record
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tlsa_base_status_follows_address_records() {
|
||||
let query = name("mx.example.org.");
|
||||
|
||||
for (proof, expected) in [
|
||||
(Proof::Secure, DnssecStatus::Secure),
|
||||
(Proof::Insecure, DnssecStatus::Insecure),
|
||||
(Proof::Bogus, DnssecStatus::Bogus),
|
||||
(Proof::Indeterminate, DnssecStatus::Indeterminate),
|
||||
] {
|
||||
assert_eq!(
|
||||
tlsa_base_status(&query, &[address("mx.example.org.", proof)], RecordType::A),
|
||||
expected,
|
||||
"proof {proof}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tlsa_base_status_is_indeterminate_without_addresses() {
|
||||
assert_eq!(
|
||||
tlsa_base_status(&name("mx.example.org."), &[], RecordType::A),
|
||||
DnssecStatus::Indeterminate
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tlsa_base_status_takes_least_secure_address() {
|
||||
let query = name("mx.example.org.");
|
||||
|
||||
assert_eq!(
|
||||
tlsa_base_status(
|
||||
&query,
|
||||
&[
|
||||
address("mx.example.org.", Proof::Secure),
|
||||
address("mx.example.org.", Proof::Insecure),
|
||||
],
|
||||
RecordType::A
|
||||
),
|
||||
DnssecStatus::Insecure
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tlsa_base_status_keeps_secure_alias_to_insecure_zone() {
|
||||
let query = name("mx.example.org.");
|
||||
|
||||
assert_eq!(
|
||||
tlsa_base_status(
|
||||
&query,
|
||||
&[
|
||||
alias("mx.example.org.", "mx.provider.net.", Proof::Secure),
|
||||
address("mx.provider.net.", Proof::Insecure),
|
||||
],
|
||||
RecordType::A
|
||||
),
|
||||
DnssecStatus::Secure
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tlsa_base_status_skips_insecure_alias() {
|
||||
let query = name("mx.example.org.");
|
||||
|
||||
assert_eq!(
|
||||
tlsa_base_status(
|
||||
&query,
|
||||
&[
|
||||
alias("mx.example.org.", "mx.provider.net.", Proof::Insecure),
|
||||
address("mx.provider.net.", Proof::Insecure),
|
||||
],
|
||||
RecordType::A
|
||||
),
|
||||
DnssecStatus::Insecure
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tlsa_base_status_ignores_alias_below_query_name() {
|
||||
let query = name("mx.example.org.");
|
||||
|
||||
assert_eq!(
|
||||
tlsa_base_status(
|
||||
&query,
|
||||
&[
|
||||
alias("mx.provider.net.", "mx.other.net.", Proof::Secure),
|
||||
address("mx.other.net.", Proof::Insecure),
|
||||
],
|
||||
RecordType::A
|
||||
),
|
||||
DnssecStatus::Insecure
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
pub mod dnssec;
|
||||
pub mod verify;
|
||||
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::queue::{Error, ErrorDetails, HostResponse, Status};
|
||||
use common::config::smtp::resolver::{Tlsa, TlsaEntry, TlsaMatching};
|
||||
use rustls_pki_types::{CertificateDer, Der, ServerName, TrustAnchor, UnixTime};
|
||||
use sha2::{Digest, Sha256, Sha512};
|
||||
use trc::DaneEvent;
|
||||
use webpki::{ALL_VERIFICATION_ALGS, EndEntityCert, KeyUsage, anchor_from_trusted_cert};
|
||||
use x509_parser::asn1_rs::Any;
|
||||
use x509_parser::prelude::{FromDer, X509Certificate};
|
||||
|
||||
pub trait TlsaVerify {
|
||||
fn verify(
|
||||
&self,
|
||||
session_id: u64,
|
||||
hostname: &str,
|
||||
reference_ids: &[&str],
|
||||
certificates: Option<&[CertificateDer<'_>]>,
|
||||
) -> Result<(), Status<HostResponse<Box<str>>, ErrorDetails>>;
|
||||
}
|
||||
|
||||
impl TlsaVerify for Tlsa {
|
||||
fn verify(
|
||||
&self,
|
||||
session_id: u64,
|
||||
hostname: &str,
|
||||
reference_ids: &[&str],
|
||||
certificates: Option<&[CertificateDer<'_>]>,
|
||||
) -> Result<(), Status<HostResponse<Box<str>>, ErrorDetails>> {
|
||||
let certificates = match certificates {
|
||||
Some(certificates) if !certificates.is_empty() => certificates,
|
||||
_ => {
|
||||
trc::event!(
|
||||
Dane(DaneEvent::NoCertificatesFound),
|
||||
SpanId = session_id,
|
||||
Hostname = hostname.to_string(),
|
||||
);
|
||||
|
||||
return Err(Status::TemporaryFailure(ErrorDetails {
|
||||
entity: hostname.into(),
|
||||
details: Error::DaneError("No certificates were provided by host".into()),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
let mut parsed = Vec::with_capacity(certificates.len());
|
||||
for der_certificate in certificates {
|
||||
match X509Certificate::from_der(der_certificate.as_ref()) {
|
||||
Ok((_, cert)) => parsed.push(cert),
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
Dane(DaneEvent::CertificateParseError),
|
||||
SpanId = session_id,
|
||||
Hostname = hostname.to_string(),
|
||||
Reason = err.to_string(),
|
||||
);
|
||||
|
||||
return Err(Status::TemporaryFailure(ErrorDetails {
|
||||
entity: hostname.into(),
|
||||
details: Error::DaneError("Failed to parse X.509 certificate".into()),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if verify_end_entity(self, session_id, hostname, certificates, &parsed)
|
||||
|| verify_trust_anchor(
|
||||
self,
|
||||
session_id,
|
||||
hostname,
|
||||
reference_ids,
|
||||
certificates,
|
||||
&parsed,
|
||||
)
|
||||
{
|
||||
trc::event!(
|
||||
Dane(DaneEvent::AuthenticationSuccess),
|
||||
SpanId = session_id,
|
||||
Hostname = hostname.to_string(),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
} else {
|
||||
trc::event!(
|
||||
Dane(DaneEvent::AuthenticationFailure),
|
||||
SpanId = session_id,
|
||||
Hostname = hostname.to_string(),
|
||||
);
|
||||
|
||||
Err(Status::TemporaryFailure(ErrorDetails {
|
||||
entity: hostname.into(),
|
||||
details: Error::DaneError("No matching certificates found in TLSA records".into()),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_end_entity(
|
||||
tlsa: &Tlsa,
|
||||
session_id: u64,
|
||||
hostname: &str,
|
||||
certificates: &[CertificateDer<'_>],
|
||||
parsed: &[X509Certificate<'_>],
|
||||
) -> bool {
|
||||
if tlsa.has_end_entities {
|
||||
for record in tlsa.entries.iter().filter(|record| record.is_end_entity) {
|
||||
if record_matches(record, &parsed[0], certificates[0].as_ref()) {
|
||||
trc::event!(
|
||||
Dane(DaneEvent::TlsaRecordMatch),
|
||||
SpanId = session_id,
|
||||
Hostname = hostname.to_string(),
|
||||
Type = "end-entity",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn verify_trust_anchor(
|
||||
tlsa: &Tlsa,
|
||||
session_id: u64,
|
||||
hostname: &str,
|
||||
reference_ids: &[&str],
|
||||
certificates: &[CertificateDer<'_>],
|
||||
parsed: &[X509Certificate<'_>],
|
||||
) -> bool {
|
||||
if !tlsa.has_intermediates {
|
||||
return false;
|
||||
}
|
||||
|
||||
let end_entity = match EndEntityCert::try_from(&certificates[0]) {
|
||||
Ok(end_entity) => end_entity,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
let mut anchors: Vec<TrustAnchor<'static>> = Vec::new();
|
||||
|
||||
for record in tlsa.entries.iter().filter(|record| !record.is_end_entity) {
|
||||
match (record.is_spki, record.matching) {
|
||||
(false, TlsaMatching::Full) => {
|
||||
let der = CertificateDer::from(record.data.clone());
|
||||
if let Ok(anchor) = anchor_from_trusted_cert(&der) {
|
||||
anchors.push(anchor.to_owned());
|
||||
}
|
||||
}
|
||||
(true, TlsaMatching::Full) => {
|
||||
if let Some(depth) = (1..certificates.len())
|
||||
.find(|&depth| parsed[depth].public_key().raw == record.data.as_slice())
|
||||
{
|
||||
if let Ok(anchor) = anchor_from_trusted_cert(&certificates[depth]) {
|
||||
anchors.push(anchor.to_owned());
|
||||
}
|
||||
} else if let Some(spki) = der_value(&record.data) {
|
||||
for depth in 1..certificates.len() {
|
||||
if is_chain_top(parsed, depth)
|
||||
&& let Some(subject) = der_value(parsed[depth].issuer().as_raw())
|
||||
{
|
||||
anchors.push(TrustAnchor {
|
||||
subject: Der::from(subject.to_vec()),
|
||||
subject_public_key_info: Der::from(spki.to_vec()),
|
||||
name_constraints: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
for depth in 1..certificates.len() {
|
||||
if record_matches(record, &parsed[depth], certificates[depth].as_ref())
|
||||
&& let Ok(anchor) = anchor_from_trusted_cert(&certificates[depth])
|
||||
{
|
||||
anchors.push(anchor.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if anchors.is_empty()
|
||||
|| end_entity
|
||||
.verify_for_usage(
|
||||
ALL_VERIFICATION_ALGS,
|
||||
&anchors,
|
||||
&certificates[1..],
|
||||
UnixTime::now(),
|
||||
KeyUsage::server_auth(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.is_err()
|
||||
|| !reference_ids.iter().any(|reference| {
|
||||
ServerName::try_from(*reference)
|
||||
.map(|name| end_entity.verify_is_valid_for_subject_name(&name).is_ok())
|
||||
.unwrap_or(false)
|
||||
})
|
||||
{
|
||||
false
|
||||
} else {
|
||||
trc::event!(
|
||||
Dane(DaneEvent::TlsaRecordMatch),
|
||||
SpanId = session_id,
|
||||
Hostname = hostname.to_string(),
|
||||
Type = "trust-anchor",
|
||||
);
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn is_chain_top(parsed: &[X509Certificate<'_>], depth: usize) -> bool {
|
||||
let issuer = parsed[depth].issuer().as_raw();
|
||||
!parsed
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(other, cert)| other != depth && cert.subject().as_raw() == issuer)
|
||||
}
|
||||
|
||||
fn record_matches(record: &TlsaEntry, cert: &X509Certificate<'_>, raw: &[u8]) -> bool {
|
||||
let selected: &[u8] = if record.is_spki {
|
||||
cert.public_key().raw
|
||||
} else {
|
||||
raw
|
||||
};
|
||||
|
||||
match record.matching {
|
||||
TlsaMatching::Full => selected == record.data.as_slice(),
|
||||
TlsaMatching::Sha256 => Sha256::digest(selected).as_slice() == record.data.as_slice(),
|
||||
TlsaMatching::Sha512 => Sha512::digest(selected).as_slice() == record.data.as_slice(),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn der_value(der: &[u8]) -> Option<&[u8]> {
|
||||
Any::from_der(der).ok().map(|(_, any)| any.data)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use std::fmt::Display;
|
||||
|
||||
use smtp_proto::{Response, Severity};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ClientError {
|
||||
/// I/O error
|
||||
Io(std::io::Error),
|
||||
|
||||
/// TLS error
|
||||
Tls(Box<rustls::Error>),
|
||||
|
||||
/// Base64 decode error
|
||||
Base64(base64::DecodeError),
|
||||
|
||||
// SMTP authentication error.
|
||||
InvalidChallenge,
|
||||
|
||||
/// Failure parsing SMTP reply
|
||||
UnparseableReply,
|
||||
|
||||
/// Unexpected SMTP reply.
|
||||
UnexpectedReply(Box<smtp_proto::Response<String>>),
|
||||
|
||||
/// SMTP authentication failure.
|
||||
AuthenticationFailed(Box<smtp_proto::Response<String>>),
|
||||
|
||||
/// Invalid TLS name provided.
|
||||
InvalidTLSName,
|
||||
|
||||
/// Missing authentication credentials.
|
||||
MissingCredentials,
|
||||
|
||||
/// Missing message sender.
|
||||
MissingMailFrom,
|
||||
|
||||
/// Missing message recipients.
|
||||
MissingRcptTo,
|
||||
|
||||
/// The server does no support any of the available authentication methods.
|
||||
UnsupportedAuthMechanism,
|
||||
|
||||
/// Connection timeout.
|
||||
Timeout,
|
||||
|
||||
/// STARTTLS not available
|
||||
MissingStartTls,
|
||||
}
|
||||
|
||||
pub trait AssertReply: Sized {
|
||||
fn is_positive_completion(&self) -> bool;
|
||||
fn assert_positive_completion(self) -> ClientResult<()>;
|
||||
fn assert_severity(self, severity: Severity) -> ClientResult<()>;
|
||||
fn assert_code(self, code: u16) -> ClientResult<()>;
|
||||
}
|
||||
|
||||
impl AssertReply for Response<String> {
|
||||
/// Returns `true` if the reply is a positive completion.
|
||||
#[inline(always)]
|
||||
fn is_positive_completion(&self) -> bool {
|
||||
(200..=299).contains(&self.code)
|
||||
}
|
||||
|
||||
/// Returns Ok if the reply has the specified severity.
|
||||
#[inline(always)]
|
||||
fn assert_severity(self, severity: Severity) -> ClientResult<()> {
|
||||
if self.severity() == severity {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ClientError::UnexpectedReply(Box::new(self)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns Ok if the reply returned a 2xx code.
|
||||
#[inline(always)]
|
||||
fn assert_positive_completion(self) -> ClientResult<()> {
|
||||
if (200..=299).contains(&self.code) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ClientError::UnexpectedReply(Box::new(self)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns Ok if the reply has the specified status code.
|
||||
#[inline(always)]
|
||||
fn assert_code(self, code: u16) -> ClientResult<()> {
|
||||
if self.code() == code {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ClientError::UnexpectedReply(Box::new(self)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ClientError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
ClientError::Io(err) => err.source(),
|
||||
ClientError::Tls(err) => err.source(),
|
||||
ClientError::Base64(err) => err.source(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type ClientResult<T> = std::result::Result<T, ClientError>;
|
||||
|
||||
impl Display for ClientError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ClientError::Io(e) => write!(f, "I/O error: {e}"),
|
||||
ClientError::Tls(e) => write!(f, "TLS error: {e}"),
|
||||
ClientError::Base64(e) => write!(f, "Base64 decode error: {e}"),
|
||||
ClientError::InvalidChallenge => {
|
||||
write!(f, "SMTP authentication error: Invalid challenge")
|
||||
}
|
||||
ClientError::UnparseableReply => write!(f, "Unparseable SMTP reply"),
|
||||
ClientError::UnexpectedReply(e) => write!(f, "Unexpected reply: {e}"),
|
||||
ClientError::AuthenticationFailed(e) => write!(f, "Authentication failed: {e}"),
|
||||
ClientError::InvalidTLSName => write!(f, "Invalid TLS name provided"),
|
||||
ClientError::MissingCredentials => write!(f, "Missing authentication credentials"),
|
||||
ClientError::MissingMailFrom => write!(f, "Missing message sender"),
|
||||
ClientError::MissingRcptTo => write!(f, "Missing message recipients"),
|
||||
ClientError::UnsupportedAuthMechanism => write!(
|
||||
f,
|
||||
"The server does no support any of the available authentication methods"
|
||||
),
|
||||
ClientError::Timeout => write!(f, "Connection timeout"),
|
||||
ClientError::MissingStartTls => write!(f, "STARTTLS extension unavailable"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for ClientError {
|
||||
fn from(err: std::io::Error) -> Self {
|
||||
ClientError::Io(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<base64::DecodeError> for ClientError {
|
||||
fn from(err: base64::DecodeError) -> Self {
|
||||
ClientError::Base64(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
inbound::dkim::DkimSign,
|
||||
outbound::DeliveryResult,
|
||||
queue::{
|
||||
Error, ErrorDetails, FROM_AUTHENTICATED, FROM_UNAUTHENTICATED_DMARC, HostResponse,
|
||||
MessageSource, MessageWrapper, Status, UnexpectedResponse,
|
||||
quota::HasQueueQuota,
|
||||
rcpt_spam_percentage,
|
||||
spool::{QueueParams, SmtpSpool},
|
||||
},
|
||||
};
|
||||
use common::Server;
|
||||
use email::message::delivery::{IngestMessage, IngestRecipient, LocalDeliveryStatus, MailDelivery};
|
||||
use smtp_proto::Response;
|
||||
use trc::SieveEvent;
|
||||
|
||||
impl MessageWrapper {
|
||||
pub(super) async fn deliver_local(
|
||||
&self,
|
||||
rcpt_idxs: &[usize],
|
||||
statuses: &mut Vec<DeliveryResult>,
|
||||
server: &Server,
|
||||
) {
|
||||
// Prepare recipients list
|
||||
let mut pending_recipients = Vec::new();
|
||||
let mut recipients = Vec::new();
|
||||
for &rcpt_idx in rcpt_idxs {
|
||||
let rcpt = &self.message.recipients[rcpt_idx];
|
||||
let rcpt_addr = rcpt.address();
|
||||
recipients.push(IngestRecipient {
|
||||
address: rcpt_addr.to_lowercase(),
|
||||
orcpt: rcpt.orcpt.as_ref().map(|orcpt| orcpt.to_string()),
|
||||
spam_percentage: rcpt_spam_percentage(rcpt.flags),
|
||||
});
|
||||
pending_recipients.push((rcpt_idx, rcpt_addr));
|
||||
}
|
||||
|
||||
// Deliver message
|
||||
let delivery_result = server
|
||||
.deliver_message(IngestMessage {
|
||||
sender_address: self.message.return_path.to_string(),
|
||||
sender_authenticated: self.message.flags
|
||||
& (FROM_UNAUTHENTICATED_DMARC | FROM_AUTHENTICATED)
|
||||
!= 0,
|
||||
recipients,
|
||||
message_blob: self.message.blob_hash.clone(),
|
||||
message_size: self.message.size,
|
||||
session_id: self.span_id,
|
||||
})
|
||||
.await;
|
||||
|
||||
// Process delivery results
|
||||
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 } => {
|
||||
Status::TemporaryFailure(ErrorDetails {
|
||||
entity: "localhost".into(),
|
||||
details: Error::UnexpectedResponse(UnexpectedResponse {
|
||||
command: format!("RCPT TO:<{rcpt_addr}>").into_boxed_str(),
|
||||
response: Response {
|
||||
code: 451,
|
||||
esc: [4, 3, 0],
|
||||
message: reason.into(),
|
||||
},
|
||||
}),
|
||||
})
|
||||
}
|
||||
LocalDeliveryStatus::PermanentFailure { code, reason } => {
|
||||
Status::PermanentFailure(ErrorDetails {
|
||||
entity: "localhost".into(),
|
||||
details: Error::UnexpectedResponse(UnexpectedResponse {
|
||||
command: format!("RCPT TO:<{rcpt_addr}>").into_boxed_str(),
|
||||
response: Response {
|
||||
code: 550,
|
||||
esc: code,
|
||||
message: reason.into(),
|
||||
},
|
||||
}),
|
||||
})
|
||||
}
|
||||
};
|
||||
statuses.push(DeliveryResult::account(status, rcpt_idx));
|
||||
}
|
||||
|
||||
// Process autogenerated messages
|
||||
for autogenerated in delivery_result.autogenerated {
|
||||
let mut message = server.new_message(
|
||||
autogenerated.sender_address,
|
||||
MessageSource::Autogenerated,
|
||||
self.span_id,
|
||||
);
|
||||
for rcpt in autogenerated.recipients {
|
||||
message.expand_and_add_recipient(rcpt, server).await;
|
||||
}
|
||||
|
||||
// Queue Message
|
||||
message.message.size = autogenerated.message.len() as u64;
|
||||
if let Some(metadata) = server.has_quota(&mut message).await {
|
||||
let dkim_signers = server
|
||||
.eval_signers(
|
||||
&server.core.sieve.untrusted_sign,
|
||||
&message.message,
|
||||
self.span_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
message
|
||||
.queue(
|
||||
QueueParams::new(&autogenerated.message, self.span_id, server)
|
||||
.with_dkim_signers(dkim_signers)
|
||||
.with_metadata(metadata),
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
trc::event!(
|
||||
Sieve(SieveEvent::QuotaExceeded),
|
||||
SpanId = self.span_id,
|
||||
From = message.message.return_path,
|
||||
To = message
|
||||
.message
|
||||
.recipients
|
||||
.into_iter()
|
||||
.map(|r| trc::Value::from(r.address().to_string()))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use super::NextHop;
|
||||
use super::dane::dnssec::{TlsaLookup, least_secure};
|
||||
use crate::queue::{Error, ErrorDetails, HostResponse, Status};
|
||||
use common::{
|
||||
Server,
|
||||
config::smtp::queue::{ConnectionStrategy, HostOrIp, IpAndHost, MxConfig},
|
||||
expr::functions::ResolveVariable,
|
||||
};
|
||||
use mail_auth::{DnssecStatus, IpLookupStrategy, MX, RecordSet};
|
||||
use rand::{RngExt, seq::SliceRandom};
|
||||
use registry::schema::enums::ExpressionVariable;
|
||||
use std::{future::Future, net::IpAddr, sync::Arc};
|
||||
|
||||
pub struct ResolvedHost {
|
||||
pub ips: Vec<IpAddr>,
|
||||
pub dnssec_status: DnssecStatus,
|
||||
}
|
||||
|
||||
pub trait DnsLookup: Sync + Send {
|
||||
fn ip_lookup(
|
||||
&self,
|
||||
key: &str,
|
||||
strategy: IpLookupStrategy,
|
||||
max_results: usize,
|
||||
dnssec: bool,
|
||||
) -> impl Future<Output = mail_auth::Result<(Vec<IpAddr>, DnssecStatus)>> + Send;
|
||||
|
||||
fn resolve_host(
|
||||
&self,
|
||||
remote_host: &NextHop<'_>,
|
||||
envelope: &impl ResolveVariable,
|
||||
dnssec: bool,
|
||||
) -> impl Future<Output = Result<ResolvedHost, Status<HostResponse<Box<str>>, ErrorDetails>>> + Send;
|
||||
}
|
||||
|
||||
impl DnsLookup for Server {
|
||||
async fn ip_lookup(
|
||||
&self,
|
||||
key: &str,
|
||||
strategy: IpLookupStrategy,
|
||||
max_results: usize,
|
||||
dnssec: bool,
|
||||
) -> mail_auth::Result<(Vec<IpAddr>, DnssecStatus)> {
|
||||
let (has_ipv4, has_ipv6, v4_first) = match strategy {
|
||||
IpLookupStrategy::Ipv4Only => (true, false, false),
|
||||
IpLookupStrategy::Ipv6Only => (false, true, false),
|
||||
IpLookupStrategy::Ipv4thenIpv6 => (true, true, true),
|
||||
IpLookupStrategy::Ipv6thenIpv4 => (true, true, false),
|
||||
};
|
||||
let mut dnssec_status: Option<DnssecStatus> = None;
|
||||
|
||||
let ipv4_addrs = if has_ipv4 {
|
||||
let result = if dnssec {
|
||||
self.ipv4_lookup_dnssec(key).await
|
||||
} else {
|
||||
self.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.ipv4_lookup(key, Some(&self.inner.cache.dns_ipv4))
|
||||
.await
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(addrs) => {
|
||||
if !addrs.rrset.is_empty() {
|
||||
dnssec_status = Some(addrs.dnssec_status);
|
||||
}
|
||||
addrs.rrset
|
||||
}
|
||||
Err(_) if has_ipv6 => Arc::new([]),
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
} else {
|
||||
Arc::new([])
|
||||
};
|
||||
|
||||
let ipv6_addrs = if has_ipv6 {
|
||||
let result = if dnssec {
|
||||
self.ipv6_lookup_dnssec(key).await
|
||||
} else {
|
||||
self.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.ipv6_lookup(key, Some(&self.inner.cache.dns_ipv6))
|
||||
.await
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(addrs) => {
|
||||
if !addrs.rrset.is_empty() {
|
||||
dnssec_status = Some(match dnssec_status {
|
||||
Some(status) => least_secure(status, addrs.dnssec_status),
|
||||
None => addrs.dnssec_status,
|
||||
});
|
||||
}
|
||||
addrs.rrset
|
||||
}
|
||||
Err(_) if !ipv4_addrs.is_empty() => Arc::new([]),
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
} else {
|
||||
Arc::new([])
|
||||
};
|
||||
|
||||
let remote_ips = if v4_first {
|
||||
ipv4_addrs
|
||||
.iter()
|
||||
.copied()
|
||||
.map(IpAddr::from)
|
||||
.chain(ipv6_addrs.iter().copied().map(IpAddr::from))
|
||||
.take(max_results)
|
||||
.collect()
|
||||
} else {
|
||||
ipv6_addrs
|
||||
.iter()
|
||||
.copied()
|
||||
.map(IpAddr::from)
|
||||
.chain(ipv4_addrs.iter().copied().map(IpAddr::from))
|
||||
.take(max_results)
|
||||
.collect()
|
||||
};
|
||||
|
||||
Ok((
|
||||
remote_ips,
|
||||
dnssec_status.unwrap_or(DnssecStatus::Indeterminate),
|
||||
))
|
||||
}
|
||||
|
||||
async fn resolve_host(
|
||||
&self,
|
||||
remote_host: &NextHop<'_>,
|
||||
envelope: &impl ResolveVariable,
|
||||
dnssec: bool,
|
||||
) -> Result<ResolvedHost, Status<HostResponse<Box<str>>, ErrorDetails>> {
|
||||
let (mut remote_ips, dnssec_status) = match remote_host.fqdn_hostname() {
|
||||
HostOrIp::Host(hostname) => self
|
||||
.ip_lookup(
|
||||
hostname.as_ref(),
|
||||
remote_host.ip_lookup_strategy(),
|
||||
remote_host.max_multi_homed(),
|
||||
dnssec,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
if let mail_auth::Error::Dns(mail_auth::DnsError::RecordNotFound(_)) = &err {
|
||||
if matches!(
|
||||
remote_host,
|
||||
NextHop::MX {
|
||||
is_implicit: true,
|
||||
..
|
||||
}
|
||||
) {
|
||||
Status::PermanentFailure(ErrorDetails {
|
||||
entity: remote_host.hostname().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(ErrorDetails {
|
||||
entity: remote_host.hostname().into(),
|
||||
details: Error::ConnectionError(
|
||||
format!("lookup error: {err}").into_boxed_str(),
|
||||
),
|
||||
})
|
||||
}
|
||||
})?,
|
||||
HostOrIp::Ip(ip) => (vec![ip], DnssecStatus::Indeterminate),
|
||||
};
|
||||
|
||||
if !remote_ips.is_empty() {
|
||||
if !remote_host.allow_loopback() && remote_ips.iter().any(|ip| ip.is_loopback()) {
|
||||
remote_ips.retain(|ip| !ip.is_loopback());
|
||||
if remote_ips.is_empty() {
|
||||
return Err(Status::PermanentFailure(ErrorDetails {
|
||||
entity: remote_host.hostname().into(),
|
||||
details: Error::ConnectionError("host resolves loopback address".into()),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ResolvedHost {
|
||||
ips: remote_ips,
|
||||
dnssec_status,
|
||||
})
|
||||
} else {
|
||||
Err(Status::TemporaryFailure(ErrorDetails {
|
||||
entity: remote_host.hostname().into(),
|
||||
details: Error::DnsError(
|
||||
format!(
|
||||
"No IP addresses found for {:?}.",
|
||||
envelope
|
||||
.resolve_variable(ExpressionVariable::Mx)
|
||||
.to_string()
|
||||
)
|
||||
.into_boxed_str(),
|
||||
),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ToNextHop {
|
||||
fn to_remote_hosts<'x, 'y: 'x>(
|
||||
&'x self,
|
||||
domain: &'y str,
|
||||
config: &'x MxConfig,
|
||||
) -> Option<Vec<NextHop<'x>>>;
|
||||
}
|
||||
|
||||
impl ToNextHop for RecordSet<MX> {
|
||||
fn to_remote_hosts<'x, 'y: 'x>(
|
||||
&'x self,
|
||||
domain: &'y str,
|
||||
config: &'x MxConfig,
|
||||
) -> Option<Vec<NextHop<'x>>> {
|
||||
if !self.rrset.is_empty() {
|
||||
// Obtain max number of MX hosts to process
|
||||
let mut remote_hosts = Vec::with_capacity(config.max_mx);
|
||||
|
||||
'outer: for mx in self.rrset.iter() {
|
||||
if mx.exchanges.len() > 1 {
|
||||
let mut slice = mx.exchanges.iter().collect::<Vec<_>>();
|
||||
slice.shuffle(&mut rand::rng());
|
||||
for remote_host in slice {
|
||||
remote_hosts.push(NextHop::MX {
|
||||
host: remote_host.as_ref(),
|
||||
is_implicit: false,
|
||||
dnssec_status: self.dnssec_status,
|
||||
config,
|
||||
});
|
||||
if remote_hosts.len() == config.max_mx {
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
} else if let Some(remote_host) = mx.exchanges.first() {
|
||||
// Check for Null MX
|
||||
if mx.preference == 0 && remote_host.as_ref() == "." {
|
||||
return None;
|
||||
}
|
||||
remote_hosts.push(NextHop::MX {
|
||||
host: remote_host.as_ref(),
|
||||
is_implicit: false,
|
||||
dnssec_status: self.dnssec_status,
|
||||
config,
|
||||
});
|
||||
if remote_hosts.len() == config.max_mx {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
remote_hosts.into()
|
||||
} else {
|
||||
// If an empty list of MXs is returned, the address is treated as if it was
|
||||
// associated with an implicit MX RR with a preference of 0, pointing to that host.
|
||||
vec![NextHop::MX {
|
||||
host: domain,
|
||||
is_implicit: true,
|
||||
dnssec_status: self.dnssec_status,
|
||||
config,
|
||||
}]
|
||||
.into()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
outbound::{client::BoxResponse, error::ClientError},
|
||||
queue::{Error, ErrorDetails, HostResponse, Status, UnexpectedResponse},
|
||||
};
|
||||
use common::config::{
|
||||
server::ServerProtocol,
|
||||
smtp::queue::{HostOrIp, MxConfig, RelayConfig},
|
||||
};
|
||||
use directory::Credentials;
|
||||
use mail_auth::{DnssecStatus, IpLookupStrategy};
|
||||
use smtp_proto::{Response, Severity};
|
||||
use std::{borrow::Cow, net::IpAddr};
|
||||
|
||||
pub mod client;
|
||||
pub mod dane;
|
||||
pub mod delivery;
|
||||
pub mod error;
|
||||
pub mod local;
|
||||
pub mod lookup;
|
||||
pub mod mta_sts;
|
||||
pub mod session;
|
||||
|
||||
pub(super) enum DeliveryResult {
|
||||
Domain {
|
||||
status: Status<HostResponse<Box<str>>, ErrorDetails>,
|
||||
rcpt_idxs: Vec<usize>,
|
||||
},
|
||||
Account {
|
||||
status: Status<HostResponse<Box<str>>, ErrorDetails>,
|
||||
rcpt_idx: usize,
|
||||
},
|
||||
RateLimited {
|
||||
rcpt_idxs: Vec<usize>,
|
||||
retry_at: u64,
|
||||
},
|
||||
}
|
||||
|
||||
impl Status<HostResponse<Box<str>>, ErrorDetails> {
|
||||
pub fn from_smtp_error(hostname: &str, command: &str, err: ClientError) -> Self {
|
||||
match err {
|
||||
ClientError::Io(_)
|
||||
| ClientError::Tls(_)
|
||||
| ClientError::Base64(_)
|
||||
| ClientError::UnparseableReply
|
||||
| ClientError::AuthenticationFailed(_)
|
||||
| ClientError::MissingCredentials
|
||||
| ClientError::MissingMailFrom
|
||||
| ClientError::MissingRcptTo
|
||||
| ClientError::Timeout => Status::TemporaryFailure(ErrorDetails {
|
||||
entity: hostname.into(),
|
||||
details: Error::ConnectionError(err.to_string().into_boxed_str()),
|
||||
}),
|
||||
|
||||
ClientError::UnexpectedReply(response) => {
|
||||
if response.severity() == Severity::PermanentNegativeCompletion {
|
||||
Status::PermanentFailure(ErrorDetails {
|
||||
entity: hostname.into(),
|
||||
details: Error::UnexpectedResponse(UnexpectedResponse {
|
||||
command: command.trim().into(),
|
||||
response: response.into_box(),
|
||||
}),
|
||||
})
|
||||
} else {
|
||||
Status::TemporaryFailure(ErrorDetails {
|
||||
entity: hostname.into(),
|
||||
details: Error::UnexpectedResponse(UnexpectedResponse {
|
||||
command: command.trim().into(),
|
||||
response: response.into_box(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
ClientError::InvalidChallenge
|
||||
| ClientError::UnsupportedAuthMechanism
|
||||
| ClientError::InvalidTLSName
|
||||
| ClientError::MissingStartTls => Status::PermanentFailure(ErrorDetails {
|
||||
entity: hostname.into(),
|
||||
details: Error::ConnectionError(err.to_string().into_boxed_str()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_starttls_error(hostname: &str, response: Option<Response<Box<str>>>) -> Self {
|
||||
let entity = hostname.into();
|
||||
if let Some(response) = response {
|
||||
if response.severity() == Severity::PermanentNegativeCompletion {
|
||||
Status::PermanentFailure(ErrorDetails {
|
||||
entity,
|
||||
details: Error::UnexpectedResponse(UnexpectedResponse {
|
||||
command: "STARTTLS".into(),
|
||||
response,
|
||||
}),
|
||||
})
|
||||
} else {
|
||||
Status::TemporaryFailure(ErrorDetails {
|
||||
entity,
|
||||
details: Error::UnexpectedResponse(UnexpectedResponse {
|
||||
command: "STARTTLS".into(),
|
||||
response,
|
||||
}),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
Status::PermanentFailure(ErrorDetails {
|
||||
entity,
|
||||
details: Error::TlsError("STARTTLS not advertised by host.".into()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_tls_error(hostname: &str, err: ClientError) -> Self {
|
||||
match err {
|
||||
ClientError::InvalidTLSName => Status::PermanentFailure(ErrorDetails {
|
||||
entity: hostname.into(),
|
||||
details: Error::TlsError("Invalid hostname".into()),
|
||||
}),
|
||||
ClientError::Timeout => Status::TemporaryFailure(ErrorDetails {
|
||||
entity: hostname.into(),
|
||||
details: Error::TlsError("TLS handshake timed out".into()),
|
||||
}),
|
||||
ClientError::Tls(err) => Status::TemporaryFailure(ErrorDetails {
|
||||
entity: hostname.into(),
|
||||
details: Error::TlsError(format!("Handshake failed: {err}").into_boxed_str()),
|
||||
}),
|
||||
ClientError::Io(err) => Status::TemporaryFailure(ErrorDetails {
|
||||
entity: hostname.into(),
|
||||
details: Error::TlsError(format!("I/O error: {err}").into_boxed_str()),
|
||||
}),
|
||||
_ => Status::PermanentFailure(ErrorDetails {
|
||||
entity: hostname.into(),
|
||||
details: Error::TlsError("Other TLS error".into()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn timeout(hostname: &str, stage: &str) -> Self {
|
||||
Status::TemporaryFailure(ErrorDetails {
|
||||
entity: hostname.into(),
|
||||
details: Error::ConnectionError(format!("Timeout while {stage}").into_boxed_str()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn local_error() -> Self {
|
||||
Status::TemporaryFailure(ErrorDetails {
|
||||
entity: "localhost".into(),
|
||||
details: Error::ConnectionError("Could not deliver message locally.".into()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn from_mail_auth_error(entity: &str, err: mail_auth::Error) -> Self {
|
||||
match &err {
|
||||
mail_auth::Error::Dns(mail_auth::DnsError::RecordNotFound(code)) => {
|
||||
Status::PermanentFailure(ErrorDetails {
|
||||
entity: entity.into(),
|
||||
details: Error::DnsError(
|
||||
format!("Domain not found: {code:?}").into_boxed_str(),
|
||||
),
|
||||
})
|
||||
}
|
||||
_ => Status::TemporaryFailure(ErrorDetails {
|
||||
entity: entity.into(),
|
||||
details: Error::DnsError(err.to_string().into_boxed_str()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
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::Dns(mail_auth::DnsError::RecordNotFound(code)) => {
|
||||
Status::PermanentFailure(ErrorDetails {
|
||||
entity: entity.into(),
|
||||
details: Error::MtaStsError(
|
||||
format!("Record not found: {code:?}").into_boxed_str(),
|
||||
),
|
||||
})
|
||||
}
|
||||
mail_auth::Error::Dns(mail_auth::DnsError::InvalidRecordType) => {
|
||||
Status::PermanentFailure(ErrorDetails {
|
||||
entity: entity.into(),
|
||||
details: Error::MtaStsError("Failed to parse MTA-STS DNS record.".into()),
|
||||
})
|
||||
}
|
||||
_ => Status::TemporaryFailure(ErrorDetails {
|
||||
entity: entity.into(),
|
||||
details: Error::MtaStsError(
|
||||
format!("DNS lookup error: {err}").into_boxed_str(),
|
||||
),
|
||||
}),
|
||||
},
|
||||
mta_sts::Error::Http(err) => {
|
||||
if err.is_timeout() {
|
||||
Status::TemporaryFailure(ErrorDetails {
|
||||
entity: entity.into(),
|
||||
details: Error::MtaStsError("Timeout fetching policy.".into()),
|
||||
})
|
||||
} else if err.is_connect() {
|
||||
Status::TemporaryFailure(ErrorDetails {
|
||||
entity: entity.into(),
|
||||
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(ErrorDetails {
|
||||
entity: entity.into(),
|
||||
details: Error::MtaStsError("Policy not found.".into()),
|
||||
})
|
||||
} else {
|
||||
Status::TemporaryFailure(ErrorDetails {
|
||||
entity: entity.into(),
|
||||
details: Error::MtaStsError("Failed to fetch policy.".into()),
|
||||
})
|
||||
}
|
||||
}
|
||||
mta_sts::Error::InvalidPolicy(err) => Status::PermanentFailure(ErrorDetails {
|
||||
entity: entity.into(),
|
||||
details: Error::MtaStsError(
|
||||
format!("Failed to parse policy: {err}").into_boxed_str(),
|
||||
),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum NextHop<'x> {
|
||||
Relay(&'x RelayConfig),
|
||||
MX {
|
||||
is_implicit: bool,
|
||||
host: &'x str,
|
||||
config: &'x MxConfig,
|
||||
dnssec_status: DnssecStatus,
|
||||
},
|
||||
}
|
||||
|
||||
impl NextHop<'_> {
|
||||
#[inline(always)]
|
||||
pub fn hostname(&self) -> &str {
|
||||
match self {
|
||||
NextHop::MX { host, .. } => {
|
||||
if let Some(host) = host.strip_suffix('.') {
|
||||
host
|
||||
} else {
|
||||
host
|
||||
}
|
||||
}
|
||||
NextHop::Relay(host) => match &host.address {
|
||||
HostOrIp::Host(host) => host.as_ref(),
|
||||
HostOrIp::Ip(ip) => ip.ip_str.as_ref(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn fqdn_hostname(&self) -> HostOrIp<Cow<'_, str>, IpAddr> {
|
||||
match self {
|
||||
NextHop::MX { host, .. } => {
|
||||
if !host.ends_with('.') {
|
||||
HostOrIp::Host(format!("{host}.").into())
|
||||
} else {
|
||||
HostOrIp::Host((*host).into())
|
||||
}
|
||||
}
|
||||
NextHop::Relay(host) => match &host.address {
|
||||
HostOrIp::Host(host) => HostOrIp::Host(host.as_ref().into()),
|
||||
HostOrIp::Ip(ip) => HostOrIp::Ip(ip.ip),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[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 {
|
||||
#[cfg(feature = "test_mode")]
|
||||
NextHop::MX { .. } => 9925,
|
||||
#[cfg(not(feature = "test_mode"))]
|
||||
NextHop::MX { .. } => 25,
|
||||
NextHop::Relay(host) => host.port,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn allow_loopback(&self) -> bool {
|
||||
match self {
|
||||
NextHop::MX { .. } => cfg!(feature = "test_mode"),
|
||||
NextHop::Relay(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn credentials(&self) -> Option<&Credentials> {
|
||||
match self {
|
||||
NextHop::MX { .. } => None,
|
||||
NextHop::Relay(host) => host.auth.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn allow_invalid_certs(&self) -> bool {
|
||||
#[cfg(feature = "test_mode")]
|
||||
{
|
||||
true
|
||||
}
|
||||
#[cfg(not(feature = "test_mode"))]
|
||||
match self {
|
||||
NextHop::MX { .. } => false,
|
||||
NextHop::Relay(host) => host.tls_allow_invalid_certs,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn implicit_tls(&self) -> bool {
|
||||
match self {
|
||||
NextHop::MX { .. } => false,
|
||||
NextHop::Relay(host) => host.tls_implicit,
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn is_smtp(&self) -> bool {
|
||||
match self {
|
||||
NextHop::MX { .. } => true,
|
||||
NextHop::Relay(host) => host.protocol == ServerProtocol::Smtp,
|
||||
}
|
||||
}
|
||||
|
||||
fn dnssec_status(&self) -> DnssecStatus {
|
||||
match self {
|
||||
NextHop::MX { dnssec_status, .. } => *dnssec_status,
|
||||
NextHop::Relay(_) => DnssecStatus::Indeterminate,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DeliveryResult {
|
||||
pub fn domain(
|
||||
status: Status<HostResponse<Box<str>>, ErrorDetails>,
|
||||
rcpt_idxs: Vec<usize>,
|
||||
) -> Self {
|
||||
DeliveryResult::Domain { status, rcpt_idxs }
|
||||
}
|
||||
|
||||
pub fn rate_limited(rcpt_idxs: Vec<usize>, retry_at: u64) -> Self {
|
||||
DeliveryResult::RateLimited {
|
||||
rcpt_idxs,
|
||||
retry_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn account(status: Status<HostResponse<Box<str>>, ErrorDetails>, rcpt_idx: usize) -> Self {
|
||||
DeliveryResult::Account { status, rcpt_idx }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use std::{fmt::Display, sync::Arc, time::Duration};
|
||||
|
||||
#[cfg(feature = "test_mode")]
|
||||
pub static STS_TEST_POLICY: parking_lot::Mutex<Vec<u8>> = parking_lot::Mutex::new(Vec::new());
|
||||
|
||||
use common::{Server, config::smtp::resolver::Policy};
|
||||
use mail_auth::{mta_sts::MtaSts, report::tlsrpt::ResultType};
|
||||
|
||||
use super::{Error, parse::ParsePolicy};
|
||||
|
||||
#[cfg(not(feature = "test_mode"))]
|
||||
use utils::HttpLimitResponse;
|
||||
|
||||
#[cfg(not(feature = "test_mode"))]
|
||||
const MAX_POLICY_SIZE: usize = 1024 * 1024;
|
||||
|
||||
pub trait MtaStsLookup: Sync + Send {
|
||||
fn lookup_mta_sts_policy(
|
||||
&self,
|
||||
domain: &str,
|
||||
timeout: Duration,
|
||||
) -> impl std::future::Future<Output = Result<Arc<Policy>, Error>> + Send;
|
||||
}
|
||||
|
||||
#[allow(unused_variables)]
|
||||
impl MtaStsLookup for Server {
|
||||
async fn lookup_mta_sts_policy(
|
||||
&self,
|
||||
domain: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<Arc<Policy>, Error> {
|
||||
// Lookup MTA-STS TXT record
|
||||
let record = match self
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.txt_lookup::<MtaSts>(
|
||||
format!("_mta-sts.{domain}."),
|
||||
Some(&self.inner.cache.dns_txt),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(record) => record,
|
||||
Err(err) => {
|
||||
// Return the cached policy in case of failure
|
||||
return if let Some(value) = self.inner.cache.dns_mta_sts.get(domain) {
|
||||
Ok(value)
|
||||
} else {
|
||||
Err(err.into())
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Check if the policy has been cached
|
||||
if let Some(value) = self.inner.cache.dns_mta_sts.get(domain)
|
||||
&& value.id == record.id
|
||||
{
|
||||
return Ok(value);
|
||||
}
|
||||
|
||||
// Fetch policy
|
||||
#[cfg(not(feature = "test_mode"))]
|
||||
let bytes = self
|
||||
.core
|
||||
.smtp
|
||||
.mta_sts_client
|
||||
.get(format!("https://mta-sts.{domain}/.well-known/mta-sts.txt"))
|
||||
.timeout(timeout)
|
||||
.send()
|
||||
.await?
|
||||
.bytes_with_limit(MAX_POLICY_SIZE)
|
||||
.await?
|
||||
.ok_or_else(|| Error::InvalidPolicy("Policy too large".to_string()))?;
|
||||
#[cfg(feature = "test_mode")]
|
||||
let bytes = STS_TEST_POLICY.lock().clone();
|
||||
|
||||
// Parse policy
|
||||
let policy = Arc::new(Policy::parse(
|
||||
std::str::from_utf8(&bytes).map_err(|err| Error::InvalidPolicy(err.to_string()))?,
|
||||
record.id.clone(),
|
||||
)?);
|
||||
|
||||
self.inner.cache.dns_mta_sts.insert(
|
||||
domain.into(),
|
||||
policy.clone(),
|
||||
Duration::from_secs(if (3600..31557600).contains(&policy.max_age) {
|
||||
policy.max_age
|
||||
} else {
|
||||
86400
|
||||
}),
|
||||
);
|
||||
|
||||
Ok(policy)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Error> for ResultType {
|
||||
fn from(err: &Error) -> Self {
|
||||
match &err {
|
||||
Error::InvalidPolicy(_) => ResultType::StsPolicyInvalid,
|
||||
_ => ResultType::StsPolicyFetchError,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Error::Dns(err) => match err {
|
||||
mail_auth::Error::Dns(mail_auth::DnsError::RecordNotFound(code)) => {
|
||||
write!(f, "Record not found: {code:?}")
|
||||
}
|
||||
mail_auth::Error::Dns(mail_auth::DnsError::InvalidRecordType) => {
|
||||
f.write_str("Failed to parse MTA-STS DNS record.")
|
||||
}
|
||||
_ => write!(f, "DNS lookup error: {err}"),
|
||||
},
|
||||
Error::Http(err) => {
|
||||
if err.is_timeout() {
|
||||
f.write_str("Timeout fetching policy.")
|
||||
} else if err.is_connect() {
|
||||
f.write_str("Could not reach policy host.")
|
||||
} else if err.is_status() && (err.status() == Some(reqwest::StatusCode::NOT_FOUND))
|
||||
{
|
||||
f.write_str("Policy not found.")
|
||||
} else {
|
||||
f.write_str("Failed to fetch policy.")
|
||||
}
|
||||
}
|
||||
Error::InvalidPolicy(err) => write!(f, "Failed to parse policy: {err}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<mail_auth::Error> for Error {
|
||||
fn from(value: mail_auth::Error) -> Self {
|
||||
Error::Dns(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for Error {
|
||||
fn from(value: reqwest::Error) -> Self {
|
||||
Error::Http(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for Error {
|
||||
fn from(value: String) -> Self {
|
||||
Error::InvalidPolicy(value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
pub mod lookup;
|
||||
pub mod parse;
|
||||
pub mod verify;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
Dns(mail_auth::Error),
|
||||
Http(reqwest::Error),
|
||||
InvalidPolicy(String),
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use common::config::smtp::resolver::{Mode, MxPattern, Policy};
|
||||
use utils::DomainPart;
|
||||
|
||||
fn to_a_label(domain: &str) -> String {
|
||||
domain
|
||||
.to_ascii_domain()
|
||||
.map(|domain| domain.to_lowercase())
|
||||
.unwrap_or_else(|| domain.to_lowercase())
|
||||
}
|
||||
|
||||
pub trait ParsePolicy {
|
||||
fn parse(data: &str, id: String) -> Result<Self, String>
|
||||
where
|
||||
Self: Sized;
|
||||
}
|
||||
|
||||
impl ParsePolicy for Policy {
|
||||
fn parse(mut data: &str, id: String) -> Result<Policy, String> {
|
||||
let mut mode = Mode::None;
|
||||
let mut max_age: u64 = 86400;
|
||||
let mut mx = Vec::new();
|
||||
|
||||
while !data.is_empty() {
|
||||
if let Some((key, next_data)) = data.split_once(':') {
|
||||
let value = if let Some((value, next_data)) = next_data.split_once('\n') {
|
||||
data = next_data;
|
||||
value.trim()
|
||||
} else {
|
||||
data = "";
|
||||
next_data.trim()
|
||||
};
|
||||
hashify::fnc_map!(key.trim().as_bytes(),
|
||||
b"mx" => {
|
||||
if let Some(suffix) = value.strip_prefix("*.") {
|
||||
if !suffix.is_empty() {
|
||||
mx.push(MxPattern::StartsWith(to_a_label(suffix)));
|
||||
}
|
||||
} else if !value.is_empty() {
|
||||
mx.push(MxPattern::Equals(to_a_label(value)));
|
||||
}
|
||||
},
|
||||
b"max_age" => {
|
||||
if let Ok(value) = value.parse() {
|
||||
max_age = value;
|
||||
}
|
||||
},
|
||||
b"mode" => {
|
||||
mode = match value {
|
||||
"enforce" => Mode::Enforce,
|
||||
"testing" => Mode::Testing,
|
||||
"none" => Mode::None,
|
||||
_ => return Err(format!("Unsupported mode {value:?}.")),
|
||||
};
|
||||
},
|
||||
b"version" => {
|
||||
if !value.eq_ignore_ascii_case("STSv1") {
|
||||
return Err(format!("Unsupported version {value:?}."));
|
||||
}
|
||||
},
|
||||
_ => {}
|
||||
);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if !mx.is_empty() {
|
||||
Ok(Policy {
|
||||
id,
|
||||
mode,
|
||||
mx: mx.into_boxed_slice(),
|
||||
max_age,
|
||||
})
|
||||
} else {
|
||||
Err("No 'mx' entries found.".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::ParsePolicy;
|
||||
use crate::outbound::mta_sts::verify::VerifyPolicy;
|
||||
use common::config::smtp::resolver::Policy;
|
||||
|
||||
#[test]
|
||||
fn mx_patterns_are_a_labels() {
|
||||
let policy = Policy::parse(
|
||||
concat!(
|
||||
"version: STSv1\n",
|
||||
"mode: enforce\n",
|
||||
"mx: *.\u{5de}\u{5d9}\u{5d9}\u{5dc}.\u{5e7}\u{5d5}\u{5dd}\n",
|
||||
"mx: MAIL.\u{5de}\u{5d9}\u{5d9}\u{5dc}.\u{5e7}\u{5d5}\u{5dd}\n",
|
||||
"max_age: 604800\n"
|
||||
),
|
||||
"test".to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(policy.verify("mx.xn--eebajf.xn--9dbq2a"));
|
||||
assert!(policy.verify("mail.xn--eebajf.xn--9dbq2a"));
|
||||
assert!(!policy.verify("mx.example.org"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use common::config::smtp::resolver::{Mode, MxPattern, Policy};
|
||||
|
||||
pub trait VerifyPolicy {
|
||||
fn verify(&self, mx_host: &str) -> bool;
|
||||
fn enforce(&self) -> bool;
|
||||
}
|
||||
|
||||
impl VerifyPolicy for Policy {
|
||||
fn verify(&self, mx_host: &str) -> bool {
|
||||
if self.mode != Mode::None {
|
||||
for mx_pattern in &self.mx {
|
||||
match mx_pattern {
|
||||
MxPattern::Equals(host) => {
|
||||
if host == mx_host {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
MxPattern::StartsWith(domain) => {
|
||||
if let Some((_, suffix)) = mx_host.split_once('.')
|
||||
&& suffix == domain
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn enforce(&self) -> bool {
|
||||
self.mode == Mode::Enforce
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use super::client::SmtpClient;
|
||||
use crate::outbound::DeliveryResult;
|
||||
use crate::outbound::client::{BoxResponse, from_error_status, from_mail_send_error};
|
||||
use crate::outbound::error::ClientError;
|
||||
use crate::queue::{Error, MessageWrapper, Recipient, Status};
|
||||
use crate::queue::{ErrorDetails, HostResponse, UnexpectedResponse};
|
||||
use common::Server;
|
||||
use common::config::smtp::queue::ConnectionStrategy;
|
||||
use directory::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::{fmt::Write, time::Instant};
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
use trc::DeliveryEvent;
|
||||
|
||||
pub struct SessionParams<'x> {
|
||||
pub server: &'x Server,
|
||||
pub hostname: &'x str,
|
||||
pub credentials: Option<&'x Credentials>,
|
||||
pub capabilities: Option<EhloResponse<String>>,
|
||||
pub is_smtp: bool,
|
||||
pub local_hostname: &'x str,
|
||||
pub conn_strategy: &'x ConnectionStrategy,
|
||||
pub session_id: u64,
|
||||
}
|
||||
|
||||
impl MessageWrapper {
|
||||
pub(super) async fn deliver<T: AsyncRead + AsyncWrite + Unpin>(
|
||||
&self,
|
||||
mut smtp_client: SmtpClient<T>,
|
||||
rcpt_idxs: Vec<usize>,
|
||||
rcpt_headers: Option<&[u8]>,
|
||||
statuses: &mut Vec<DeliveryResult>,
|
||||
mut params: SessionParams<'_>,
|
||||
) {
|
||||
// Obtain capabilities
|
||||
let time = Instant::now();
|
||||
let capabilities = if let Some(capabilities) = params.capabilities.take() {
|
||||
capabilities
|
||||
} else {
|
||||
match smtp_client.say_helo(¶ms).await {
|
||||
Ok(capabilities) => {
|
||||
trc::event!(
|
||||
Delivery(DeliveryEvent::Ehlo),
|
||||
SpanId = params.session_id,
|
||||
Hostname = params.hostname.to_string(),
|
||||
Details = capabilities.capabilities(),
|
||||
Elapsed = time.elapsed(),
|
||||
);
|
||||
|
||||
capabilities
|
||||
}
|
||||
Err(status) => {
|
||||
trc::event!(
|
||||
Delivery(DeliveryEvent::EhloRejected),
|
||||
SpanId = params.session_id,
|
||||
Hostname = params.hostname.to_string(),
|
||||
CausedBy = from_error_status(&status),
|
||||
Elapsed = time.elapsed(),
|
||||
);
|
||||
smtp_client.quit().await;
|
||||
statuses.push(DeliveryResult::domain(status, rcpt_idxs));
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Authenticate
|
||||
if let Some(credentials) = params.credentials {
|
||||
let time = Instant::now();
|
||||
if let Err(err) = smtp_client.authenticate(credentials, &capabilities).await {
|
||||
trc::event!(
|
||||
Delivery(DeliveryEvent::AuthFailed),
|
||||
SpanId = params.session_id,
|
||||
Hostname = params.hostname.to_string(),
|
||||
CausedBy = from_mail_send_error(&err),
|
||||
Elapsed = time.elapsed(),
|
||||
);
|
||||
|
||||
smtp_client.quit().await;
|
||||
statuses.push(DeliveryResult::domain(
|
||||
Status::from_smtp_error(params.hostname, "AUTH ...", err),
|
||||
rcpt_idxs,
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
trc::event!(
|
||||
Delivery(DeliveryEvent::Auth),
|
||||
SpanId = params.session_id,
|
||||
Hostname = params.hostname.to_string(),
|
||||
Elapsed = time.elapsed(),
|
||||
);
|
||||
|
||||
// Refresh capabilities
|
||||
// Disabled as some SMTP servers deauthenticate after EHLO
|
||||
/*capabilities = match say_helo(&mut smtp_client, ¶ms).await {
|
||||
Ok(capabilities) => capabilities,
|
||||
Err(status) => {
|
||||
trc::event!(
|
||||
|
||||
context = "ehlo",
|
||||
event = "rejected",
|
||||
mx = ¶ms.hostname,
|
||||
reason = %status,
|
||||
);
|
||||
smtp_client.quit().await;
|
||||
return status;
|
||||
}
|
||||
};*/
|
||||
}
|
||||
|
||||
// MAIL FROM
|
||||
let time = Instant::now();
|
||||
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() {
|
||||
Ok(r)
|
||||
} else {
|
||||
Err(ClientError::UnexpectedReply(Box::new(r)))
|
||||
}
|
||||
}) {
|
||||
Ok(response) => {
|
||||
trc::event!(
|
||||
Delivery(DeliveryEvent::MailFrom),
|
||||
SpanId = params.session_id,
|
||||
Hostname = params.hostname.to_string(),
|
||||
From = self.message.return_path.to_string(),
|
||||
Code = response.code,
|
||||
Details = response.message.to_string(),
|
||||
Elapsed = time.elapsed(),
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
Delivery(DeliveryEvent::MailFromRejected),
|
||||
SpanId = params.session_id,
|
||||
Hostname = params.hostname.to_string(),
|
||||
CausedBy = from_mail_send_error(&err),
|
||||
Elapsed = time.elapsed(),
|
||||
);
|
||||
|
||||
smtp_client.quit().await;
|
||||
statuses.push(DeliveryResult::domain(
|
||||
Status::from_smtp_error(params.hostname, &cmd, err),
|
||||
rcpt_idxs,
|
||||
));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// RCPT TO
|
||||
let mut accepted_rcpts = Vec::new();
|
||||
smtp_client.timeout = params.conn_strategy.timeout_rcpt;
|
||||
for rcpt_idx in &rcpt_idxs {
|
||||
let time = Instant::now();
|
||||
let rcpt = &self.message.recipients[*rcpt_idx];
|
||||
if matches!(
|
||||
&rcpt.status,
|
||||
Status::Completed(_) | Status::PermanentFailure(_)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let cmd = self.build_rcpt_to(rcpt, &capabilities);
|
||||
match smtp_client.cmd(cmd.as_bytes()).await {
|
||||
Ok(response) => match response.severity() {
|
||||
Severity::PositiveCompletion => {
|
||||
trc::event!(
|
||||
Delivery(DeliveryEvent::RcptTo),
|
||||
SpanId = params.session_id,
|
||||
Hostname = params.hostname.to_string(),
|
||||
To = rcpt.address().to_string(),
|
||||
Code = response.code,
|
||||
Details = response.message.to_string(),
|
||||
Elapsed = time.elapsed(),
|
||||
);
|
||||
|
||||
accepted_rcpts.push((
|
||||
rcpt,
|
||||
rcpt_idx,
|
||||
Status::Completed(HostResponse {
|
||||
hostname: params.hostname.into(),
|
||||
response: response.into_box(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
severity => {
|
||||
trc::event!(
|
||||
Delivery(DeliveryEvent::RcptToRejected),
|
||||
SpanId = params.session_id,
|
||||
Hostname = params.hostname.to_string(),
|
||||
To = rcpt.address().to_string(),
|
||||
Code = response.code,
|
||||
Details = response.message.to_string(),
|
||||
Elapsed = time.elapsed(),
|
||||
);
|
||||
|
||||
let response = ErrorDetails {
|
||||
entity: params.hostname.into(),
|
||||
details: Error::UnexpectedResponse(UnexpectedResponse {
|
||||
command: cmd.trim().into(),
|
||||
response: response.into_box(),
|
||||
}),
|
||||
};
|
||||
statuses.push(DeliveryResult::account(
|
||||
if severity == Severity::PermanentNegativeCompletion {
|
||||
Status::PermanentFailure(response)
|
||||
} else {
|
||||
Status::TemporaryFailure(response)
|
||||
},
|
||||
*rcpt_idx,
|
||||
));
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
Delivery(DeliveryEvent::RcptToFailed),
|
||||
SpanId = params.session_id,
|
||||
Hostname = params.hostname.to_string(),
|
||||
To = rcpt.address().to_string(),
|
||||
CausedBy = from_mail_send_error(&err),
|
||||
Elapsed = time.elapsed(),
|
||||
);
|
||||
|
||||
// Something went wrong, abort.
|
||||
smtp_client.quit().await;
|
||||
statuses.push(DeliveryResult::domain(
|
||||
Status::from_smtp_error(params.hostname, "", err),
|
||||
rcpt_idxs,
|
||||
));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send message
|
||||
if !accepted_rcpts.is_empty() {
|
||||
let time = Instant::now();
|
||||
let mut bdat_cmd = capabilities.has_capability(EXT_CHUNKING).then(String::new);
|
||||
|
||||
if let Err(status) = smtp_client
|
||||
.send_message(self, rcpt_headers, &mut bdat_cmd, ¶ms)
|
||||
.await
|
||||
{
|
||||
trc::event!(
|
||||
Delivery(DeliveryEvent::MessageRejected),
|
||||
SpanId = params.session_id,
|
||||
Hostname = params.hostname.to_string(),
|
||||
CausedBy = from_error_status(&status),
|
||||
Elapsed = time.elapsed(),
|
||||
);
|
||||
|
||||
smtp_client.quit().await;
|
||||
statuses.push(DeliveryResult::domain(status, rcpt_idxs));
|
||||
return;
|
||||
}
|
||||
|
||||
if params.is_smtp {
|
||||
// Handle SMTP response
|
||||
match smtp_client
|
||||
.read_smtp_data_response(params.hostname, &bdat_cmd)
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
// Mark recipients as delivered
|
||||
if response.code() == 250 {
|
||||
for (rcpt, rcpt_idx, status) in accepted_rcpts {
|
||||
trc::event!(
|
||||
Delivery(DeliveryEvent::Delivered),
|
||||
SpanId = params.session_id,
|
||||
Hostname = params.hostname.to_string(),
|
||||
To = rcpt.address().to_string(),
|
||||
Code = response.code,
|
||||
Details = response.message.to_string(),
|
||||
Elapsed = time.elapsed(),
|
||||
);
|
||||
|
||||
statuses.push(DeliveryResult::account(status, *rcpt_idx));
|
||||
}
|
||||
} else {
|
||||
trc::event!(
|
||||
Delivery(DeliveryEvent::MessageRejected),
|
||||
SpanId = params.session_id,
|
||||
Hostname = params.hostname.to_string(),
|
||||
Code = response.code,
|
||||
Details = response.message.to_string(),
|
||||
Elapsed = time.elapsed(),
|
||||
);
|
||||
|
||||
smtp_client.quit().await;
|
||||
statuses.push(DeliveryResult::domain(
|
||||
Status::from_smtp_error(
|
||||
params.hostname,
|
||||
bdat_cmd.as_deref().unwrap_or("DATA"),
|
||||
ClientError::UnexpectedReply(Box::new(response)),
|
||||
),
|
||||
rcpt_idxs,
|
||||
));
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(status) => {
|
||||
trc::event!(
|
||||
Delivery(DeliveryEvent::MessageRejected),
|
||||
SpanId = params.session_id,
|
||||
Hostname = params.hostname.to_string(),
|
||||
CausedBy = from_error_status(&status),
|
||||
Elapsed = time.elapsed(),
|
||||
);
|
||||
|
||||
smtp_client.quit().await;
|
||||
statuses.push(DeliveryResult::domain(status, rcpt_idxs));
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Handle LMTP responses
|
||||
match smtp_client
|
||||
.read_lmtp_data_response(params.hostname, accepted_rcpts.len())
|
||||
.await
|
||||
{
|
||||
Ok(responses) => {
|
||||
for ((rcpt, rcpt_idx, _), response) in
|
||||
accepted_rcpts.into_iter().zip(responses)
|
||||
{
|
||||
let status: Status<HostResponse<Box<str>>, ErrorDetails> =
|
||||
match response.severity() {
|
||||
Severity::PositiveCompletion => {
|
||||
trc::event!(
|
||||
Delivery(DeliveryEvent::Delivered),
|
||||
SpanId = params.session_id,
|
||||
Hostname = params.hostname.to_string(),
|
||||
To = rcpt.address().to_string(),
|
||||
Code = response.code,
|
||||
Details = response.message.to_string(),
|
||||
Elapsed = time.elapsed(),
|
||||
);
|
||||
|
||||
Status::Completed(HostResponse {
|
||||
hostname: params.hostname.into(),
|
||||
response,
|
||||
})
|
||||
}
|
||||
severity => {
|
||||
trc::event!(
|
||||
Delivery(DeliveryEvent::RcptToRejected),
|
||||
SpanId = params.session_id,
|
||||
Hostname = params.hostname.to_string(),
|
||||
To = rcpt.address().to_string(),
|
||||
Code = response.code,
|
||||
Details = response.message.to_string(),
|
||||
Elapsed = time.elapsed(),
|
||||
);
|
||||
|
||||
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 {
|
||||
Status::PermanentFailure(response)
|
||||
} else {
|
||||
Status::TemporaryFailure(response)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
statuses.push(DeliveryResult::account(status, *rcpt_idx));
|
||||
}
|
||||
}
|
||||
Err(status) => {
|
||||
trc::event!(
|
||||
Delivery(DeliveryEvent::MessageRejected),
|
||||
SpanId = params.session_id,
|
||||
Hostname = params.hostname.to_string(),
|
||||
CausedBy = from_error_status(&status),
|
||||
Elapsed = time.elapsed(),
|
||||
);
|
||||
|
||||
smtp_client.quit().await;
|
||||
statuses.push(DeliveryResult::domain(status, rcpt_idxs));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
smtp_client.quit().await;
|
||||
}
|
||||
|
||||
fn build_mail_from(&self, capabilities: &EhloResponse<String>) -> String {
|
||||
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.message.size);
|
||||
}
|
||||
if self.has_flag(MAIL_REQUIRETLS) & capabilities.has_capability(EXT_REQUIRE_TLS) {
|
||||
mail_from.push_str(" REQUIRETLS");
|
||||
}
|
||||
if self.has_flag(MAIL_SMTPUTF8) & capabilities.has_capability(EXT_SMTP_UTF8) {
|
||||
mail_from.push_str(" SMTPUTF8");
|
||||
}
|
||||
if capabilities.has_capability(EXT_DSN) {
|
||||
if self.has_flag(MAIL_RET_FULL) {
|
||||
mail_from.push_str(" RET=FULL");
|
||||
} else if self.has_flag(MAIL_RET_HDRS) {
|
||||
mail_from.push_str(" RET=HDRS");
|
||||
}
|
||||
if let Some(env_id) = &self.message.env_id {
|
||||
let _ = write!(mail_from, " ENVID={env_id}");
|
||||
}
|
||||
}
|
||||
|
||||
mail_from.push_str("\r\n");
|
||||
mail_from
|
||||
}
|
||||
|
||||
fn build_rcpt_to(&self, rcpt: &Recipient, capabilities: &EhloResponse<String>) -> String {
|
||||
let mut rcpt_to = String::with_capacity(rcpt.address().len() + 60);
|
||||
let _ = write!(rcpt_to, "RCPT TO:<{}>", rcpt.address());
|
||||
if capabilities.has_capability(EXT_DSN) {
|
||||
if rcpt.has_flag(RCPT_NOTIFY_SUCCESS | RCPT_NOTIFY_FAILURE | RCPT_NOTIFY_DELAY) {
|
||||
rcpt_to.push_str(" NOTIFY=");
|
||||
let mut add_comma = if rcpt.has_flag(RCPT_NOTIFY_SUCCESS) {
|
||||
rcpt_to.push_str("SUCCESS");
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if rcpt.has_flag(RCPT_NOTIFY_DELAY) {
|
||||
if add_comma {
|
||||
rcpt_to.push(',');
|
||||
} else {
|
||||
add_comma = true;
|
||||
}
|
||||
rcpt_to.push_str("DELAY");
|
||||
}
|
||||
if rcpt.has_flag(RCPT_NOTIFY_FAILURE) {
|
||||
if add_comma {
|
||||
rcpt_to.push(',');
|
||||
}
|
||||
rcpt_to.push_str("FAILURE");
|
||||
}
|
||||
} else if rcpt.has_flag(RCPT_NOTIFY_NEVER) {
|
||||
rcpt_to.push_str(" NOTIFY=NEVER");
|
||||
}
|
||||
}
|
||||
rcpt_to.push_str("\r\n");
|
||||
rcpt_to
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn has_flag(&self, flag: u64) -> bool {
|
||||
(self.message.flags & flag) != 0
|
||||
}
|
||||
}
|
||||
|
||||
impl Recipient {
|
||||
#[inline(always)]
|
||||
pub fn has_flag(&self, flag: u64) -> bool {
|
||||
(self.flags & flag) != 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,669 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* 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,
|
||||
Recipient, Status,
|
||||
};
|
||||
use crate::inbound::dkim::DkimSign;
|
||||
use crate::queue::spool::QueueParams;
|
||||
use crate::queue::{MessageWrapper, UnexpectedResponse};
|
||||
use common::Server;
|
||||
use mail_builder::MessageBuilder;
|
||||
use mail_builder::headers::HeaderType;
|
||||
use mail_builder::headers::content_type::ContentType;
|
||||
use mail_builder::mime::{BodyPart, MimePart, make_boundary};
|
||||
use mail_parser::DateTime;
|
||||
use smtp_proto::{
|
||||
RCPT_NOTIFY_DELAY, RCPT_NOTIFY_FAILURE, RCPT_NOTIFY_NEVER, RCPT_NOTIFY_SUCCESS, Response,
|
||||
};
|
||||
use std::fmt::Write;
|
||||
use std::future::Future;
|
||||
use store::write::now;
|
||||
|
||||
pub trait SendDsn: Sync + Send {
|
||||
fn send_dsn(&self, message: &mut MessageWrapper) -> impl Future<Output = ()> + Send;
|
||||
fn log_dsn(&self, message: &MessageWrapper) -> impl Future<Output = ()> + Send;
|
||||
}
|
||||
|
||||
impl SendDsn for Server {
|
||||
async fn send_dsn(&self, message: &mut MessageWrapper) {
|
||||
// Send DSN events
|
||||
self.log_dsn(message).await;
|
||||
|
||||
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("", MessageSource::Dsn, message.span_id);
|
||||
dsn_message
|
||||
.expand_and_add_recipient(message.message.return_path.as_ref(), self)
|
||||
.await;
|
||||
|
||||
// Queue DSN
|
||||
let dkim_signers = self
|
||||
.eval_signers(
|
||||
&self.core.smtp.queue.dsn.sign,
|
||||
&message.message,
|
||||
message.span_id,
|
||||
)
|
||||
.await;
|
||||
dsn_message
|
||||
.queue(
|
||||
QueueParams::new(&dsn, message.span_id, self)
|
||||
.with_dkim_signers(dkim_signers),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
} else {
|
||||
// Handle double bounce
|
||||
message.handle_double_bounce();
|
||||
}
|
||||
|
||||
// Update next DSN notify times
|
||||
message.update_next_dsn(self).await;
|
||||
}
|
||||
|
||||
async fn log_dsn(&self, message: &MessageWrapper) {
|
||||
let now = now();
|
||||
|
||||
for rcpt in &message.message.recipients {
|
||||
if rcpt.has_flag(RCPT_DSN_SENT) {
|
||||
continue;
|
||||
}
|
||||
|
||||
match &rcpt.status {
|
||||
Status::Completed(response) => {
|
||||
trc::event!(
|
||||
Delivery(trc::DeliveryEvent::DsnSuccess),
|
||||
SpanId = message.span_id,
|
||||
To = rcpt.address.clone(),
|
||||
Hostname = response.hostname.clone(),
|
||||
Code = response.response.code,
|
||||
Details = response.response.message.to_string(),
|
||||
);
|
||||
}
|
||||
Status::TemporaryFailure(response) if rcpt.notify.due <= now => {
|
||||
trc::event!(
|
||||
Delivery(trc::DeliveryEvent::DsnTempFail),
|
||||
SpanId = message.span_id,
|
||||
To = rcpt.address.clone(),
|
||||
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) => {
|
||||
trc::event!(
|
||||
Delivery(trc::DeliveryEvent::DsnPermFail),
|
||||
SpanId = message.span_id,
|
||||
To = rcpt.address.clone(),
|
||||
Hostname = response.entity.clone(),
|
||||
Details = response.details.to_string(),
|
||||
Total = rcpt.retry.inner,
|
||||
);
|
||||
}
|
||||
Status::Scheduled if rcpt.notify.due <= now => {
|
||||
trc::event!(
|
||||
Delivery(trc::DeliveryEvent::DsnTempFail),
|
||||
SpanId = message.span_id,
|
||||
To = rcpt.address.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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_HEADER_SIZE: usize = 4096;
|
||||
|
||||
impl MessageWrapper {
|
||||
pub async fn build_dsn(&mut self, server: &Server) -> Option<Vec<u8>> {
|
||||
let config = &server.core.smtp.queue;
|
||||
let now = now();
|
||||
|
||||
let mut txt_success = String::new();
|
||||
let mut txt_delay = String::new();
|
||||
let mut txt_failed = String::new();
|
||||
let mut dsn = String::new();
|
||||
|
||||
for rcpt in &mut self.message.recipients {
|
||||
if rcpt.has_flag(RCPT_DSN_SENT | RCPT_NOTIFY_NEVER) {
|
||||
continue;
|
||||
}
|
||||
match &rcpt.status {
|
||||
Status::Completed(response) => {
|
||||
rcpt.flags |= RCPT_DSN_SENT;
|
||||
if !rcpt.has_flag(RCPT_NOTIFY_SUCCESS) {
|
||||
continue;
|
||||
}
|
||||
rcpt.write_dsn(&mut dsn);
|
||||
rcpt.status.write_dsn(&mut dsn);
|
||||
response.write_dsn_text(&rcpt.address, &mut txt_success);
|
||||
}
|
||||
Status::TemporaryFailure(response)
|
||||
if rcpt.notify.due <= now && rcpt.has_flag(RCPT_NOTIFY_DELAY) =>
|
||||
{
|
||||
rcpt.write_dsn(&mut dsn);
|
||||
rcpt.status.write_dsn(&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) => {
|
||||
rcpt.flags |= RCPT_DSN_SENT;
|
||||
if !rcpt.has_flag(RCPT_NOTIFY_FAILURE) {
|
||||
continue;
|
||||
}
|
||||
rcpt.write_dsn(&mut dsn);
|
||||
rcpt.status.write_dsn(&mut dsn);
|
||||
response.write_dsn_text(&rcpt.address, &mut txt_failed);
|
||||
}
|
||||
Status::Scheduled 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,
|
||||
}
|
||||
|
||||
dsn.push_str("\r\n");
|
||||
}
|
||||
|
||||
let txt_len = txt_success.len() + txt_delay.len() + txt_failed.len();
|
||||
if txt_len == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let has_success = !txt_success.is_empty();
|
||||
let has_delay = !txt_delay.is_empty();
|
||||
let has_failure = !txt_failed.is_empty();
|
||||
|
||||
let mut txt = String::with_capacity(txt_len + 128);
|
||||
let (subject, is_mixed) = if has_success && !has_delay && !has_failure {
|
||||
txt.push_str(
|
||||
"Your message has been successfully delivered to the following recipients:\r\n\r\n",
|
||||
);
|
||||
("Successfully delivered message", false)
|
||||
} else if has_delay && !has_success && !has_failure {
|
||||
txt.push_str("There was a temporary problem delivering your message to the following recipients:\r\n\r\n");
|
||||
("Warning: Delay in message delivery", false)
|
||||
} else if has_failure && !has_success && !has_delay {
|
||||
txt.push_str(
|
||||
"Your message could not be delivered to the following recipients:\r\n\r\n",
|
||||
);
|
||||
("Failed to deliver message", false)
|
||||
} else if has_success {
|
||||
txt.push_str("Your message has been partially delivered:\r\n\r\n");
|
||||
("Partially delivered message", true)
|
||||
} else {
|
||||
txt.push_str("Your message could not be delivered to some recipients:\r\n\r\n");
|
||||
(
|
||||
"Warning: Temporary and permanent failures during message delivery",
|
||||
true,
|
||||
)
|
||||
};
|
||||
|
||||
if has_success {
|
||||
if is_mixed {
|
||||
txt.push_str(
|
||||
" ----- Delivery to the following addresses was successful -----\r\n",
|
||||
);
|
||||
}
|
||||
|
||||
txt.push_str(&txt_success);
|
||||
txt.push_str("\r\n");
|
||||
}
|
||||
|
||||
if has_delay {
|
||||
if is_mixed {
|
||||
txt.push_str(
|
||||
" ----- There was a temporary problem delivering to these addresses -----\r\n",
|
||||
);
|
||||
}
|
||||
txt.push_str(&txt_delay);
|
||||
txt.push_str("\r\n");
|
||||
}
|
||||
|
||||
if has_failure {
|
||||
if is_mixed {
|
||||
txt.push_str(" ----- Delivery to the following addresses failed -----\r\n");
|
||||
}
|
||||
txt.push_str(&txt_failed);
|
||||
txt.push_str("\r\n");
|
||||
}
|
||||
|
||||
// Obtain hostname and sender addresses
|
||||
let from_name = server
|
||||
.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.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.message,
|
||||
self.span_id,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|| String::from("localhost"));
|
||||
|
||||
// Prepare DSN
|
||||
let mut dsn_header = String::with_capacity(dsn.len() + 128);
|
||||
self.message
|
||||
.write_dsn_headers(&mut dsn_header, &reporting_mta);
|
||||
let dsn = dsn_header + dsn.as_str();
|
||||
|
||||
// Fetch up to MAX_HEADER_SIZE bytes of message headers
|
||||
let headers = match server
|
||||
.blob_store()
|
||||
.get_blob(self.message.blob_hash.as_slice(), 0..MAX_HEADER_SIZE)
|
||||
.await
|
||||
{
|
||||
Ok(Some(mut buf)) => {
|
||||
let mut prev_ch = 0;
|
||||
let mut last_lf = buf.len();
|
||||
for (pos, &ch) in buf.iter().enumerate() {
|
||||
match ch {
|
||||
b'\n' => {
|
||||
last_lf = pos + 1;
|
||||
if prev_ch != b'\n' {
|
||||
prev_ch = ch;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
b'\r' => (),
|
||||
0 => break,
|
||||
_ => {
|
||||
prev_ch = ch;
|
||||
}
|
||||
}
|
||||
}
|
||||
if last_lf < MAX_HEADER_SIZE {
|
||||
buf.truncate(last_lf);
|
||||
}
|
||||
String::from_utf8(buf).unwrap_or_default()
|
||||
}
|
||||
Ok(None) => {
|
||||
trc::event!(
|
||||
Queue(trc::QueueEvent::BlobNotFound),
|
||||
SpanId = self.span_id,
|
||||
BlobId = self.message.blob_hash.to_hex(),
|
||||
CausedBy = trc::location!()
|
||||
);
|
||||
|
||||
String::new()
|
||||
}
|
||||
Err(err) => {
|
||||
trc::error!(
|
||||
err.span_id(self.span_id)
|
||||
.details("Failed to fetch blobId")
|
||||
.caused_by(trc::location!())
|
||||
);
|
||||
|
||||
String::new()
|
||||
}
|
||||
};
|
||||
|
||||
// Build message
|
||||
MessageBuilder::new()
|
||||
.from((from_name.as_str(), from_addr.as_str()))
|
||||
.header(
|
||||
"To",
|
||||
HeaderType::Text(self.message.return_path.as_ref().into()),
|
||||
)
|
||||
.header("Auto-Submitted", HeaderType::Text("auto-generated".into()))
|
||||
.message_id(format!("{}@{}", make_boundary("."), reporting_mta))
|
||||
.subject(subject)
|
||||
.body(MimePart::new(
|
||||
ContentType::new("multipart/report").attribute("report-type", "delivery-status"),
|
||||
BodyPart::Multipart(vec![
|
||||
MimePart::new(ContentType::new("text/plain"), BodyPart::Text(txt.into())),
|
||||
MimePart::new(
|
||||
ContentType::new("message/delivery-status"),
|
||||
BodyPart::Text(dsn.into()),
|
||||
),
|
||||
MimePart::new(
|
||||
ContentType::new("message/rfc822"),
|
||||
BodyPart::Text(headers.into()),
|
||||
),
|
||||
]),
|
||||
))
|
||||
.write_to_vec()
|
||||
.unwrap_or_default()
|
||||
.into()
|
||||
}
|
||||
|
||||
pub async fn update_next_dsn(&mut self, server: &Server) {
|
||||
let now = now();
|
||||
let mut notify_changes = Vec::new();
|
||||
for (rcpt_idx, rcpt) in self.message.recipients.iter().enumerate() {
|
||||
if matches!(
|
||||
&rcpt.status,
|
||||
Status::TemporaryFailure(_) | Status::Scheduled
|
||||
) && rcpt.notify.due <= now
|
||||
{
|
||||
let envelope = QueueEnvelope::new(&self.message, rcpt);
|
||||
|
||||
let queue_id = server
|
||||
.eval_if::<String, _>(&server.core.smtp.queue.queue, &envelope, self.span_id)
|
||||
.await
|
||||
.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()
|
||||
{
|
||||
notify_changes.push((rcpt_idx, 1, now + next_notify));
|
||||
} else {
|
||||
notify_changes.push((rcpt_idx, 0, u64::MAX));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (rcpt_idx, inner, due) in notify_changes {
|
||||
let rcpt = &mut self.message.recipients[rcpt_idx];
|
||||
rcpt.notify.inner += inner;
|
||||
rcpt.notify.due = due;
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_double_bounce(&mut self) {
|
||||
let mut is_double_bounce = Vec::with_capacity(0);
|
||||
let now = now();
|
||||
|
||||
for rcpt in &mut self.message.recipients {
|
||||
if !rcpt.has_flag(RCPT_DSN_SENT | RCPT_NOTIFY_NEVER)
|
||||
&& 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);
|
||||
}
|
||||
|
||||
if rcpt.notify.due <= now {
|
||||
rcpt.notify.due = rcpt
|
||||
.expiration_time(self.message.created)
|
||||
.map(|d| d + 10)
|
||||
.unwrap_or(u64::MAX);
|
||||
}
|
||||
}
|
||||
|
||||
if !is_double_bounce.is_empty() {
|
||||
trc::event!(
|
||||
Delivery(trc::DeliveryEvent::DoubleBounce),
|
||||
SpanId = self.span_id,
|
||||
To = is_double_bounce
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HostResponse<Box<str>> {
|
||||
fn write_dsn_text(&self, addr: &str, dsn: &mut String) {
|
||||
let _ = write!(
|
||||
dsn,
|
||||
"<{}> (delivered to '{}' with code {} ({}.{}.{}) '",
|
||||
addr,
|
||||
self.hostname,
|
||||
self.response.code,
|
||||
self.response.esc[0],
|
||||
self.response.esc[1],
|
||||
self.response.esc[2]
|
||||
);
|
||||
self.response.write_response(dsn);
|
||||
dsn.push_str("')\r\n");
|
||||
}
|
||||
}
|
||||
|
||||
impl UnexpectedResponse {
|
||||
fn write_dsn_text(&self, host: &str, addr: &str, dsn: &mut String) {
|
||||
let _ = write!(dsn, "<{addr}> (host '{host}' rejected ");
|
||||
|
||||
if !self.command.is_empty() {
|
||||
let _ = write!(dsn, "command '{}'", self.command);
|
||||
} else {
|
||||
dsn.push_str("transaction");
|
||||
}
|
||||
|
||||
let _ = write!(
|
||||
dsn,
|
||||
" with code {} ({}.{}.{}) '",
|
||||
self.response.code, self.response.esc[0], self.response.esc[1], self.response.esc[2]
|
||||
);
|
||||
self.response.write_response(dsn);
|
||||
dsn.push_str("')\r\n");
|
||||
}
|
||||
}
|
||||
|
||||
impl ErrorDetails {
|
||||
fn write_dsn_text(&self, addr: &str, dsn: &mut String) {
|
||||
let entity = self.entity.as_ref();
|
||||
match &self.details {
|
||||
Error::UnexpectedResponse(response) => {
|
||||
response.write_dsn_text(entity, addr, dsn);
|
||||
}
|
||||
Error::DnsError(err) => {
|
||||
let _ = write!(dsn, "<{addr}> (failed to lookup '{entity}': {err})\r\n",);
|
||||
}
|
||||
Error::ConnectionError(details) => {
|
||||
let _ = write!(
|
||||
dsn,
|
||||
"<{addr}> (connection to '{entity}' failed: {details})\r\n",
|
||||
);
|
||||
}
|
||||
Error::TlsError(details) => {
|
||||
let _ = write!(dsn, "<{addr}> (TLS error from '{entity}': {details})\r\n",);
|
||||
}
|
||||
Error::DaneError(details) => {
|
||||
let _ = write!(
|
||||
dsn,
|
||||
"<{addr}> (DANE failed to authenticate '{entity}': {details})\r\n",
|
||||
);
|
||||
}
|
||||
Error::MtaStsError(details) => {
|
||||
let _ = write!(
|
||||
dsn,
|
||||
"<{addr}> (MTA-STS failed to authenticate '{entity}': {details})\r\n",
|
||||
);
|
||||
}
|
||||
Error::RateLimited => {
|
||||
let _ = write!(dsn, "<{addr}> (rate limited)\r\n");
|
||||
}
|
||||
Error::ConcurrencyLimited => {
|
||||
let _ = write!(
|
||||
dsn,
|
||||
"<{addr}> (too many concurrent connections to remote server)\r\n",
|
||||
);
|
||||
}
|
||||
Error::Io(err) => {
|
||||
let _ = write!(dsn, "<{addr}> (queue error: {err})\r\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Message {
|
||||
fn write_dsn_headers(&self, dsn: &mut String, reporting_mta: &str) {
|
||||
let _ = write!(dsn, "Reporting-MTA: dns;{reporting_mta}\r\n");
|
||||
dsn.push_str("Arrival-Date: ");
|
||||
dsn.push_str(&DateTime::from_timestamp(self.created as i64).to_rfc822());
|
||||
dsn.push_str("\r\n");
|
||||
if let Some(env_id) = &self.env_id {
|
||||
let _ = write!(dsn, "Original-Envelope-Id: {env_id}\r\n");
|
||||
}
|
||||
dsn.push_str("\r\n");
|
||||
}
|
||||
}
|
||||
|
||||
impl Recipient {
|
||||
fn write_dsn(&self, dsn: &mut String) {
|
||||
if let Some(orcpt) = &self.orcpt {
|
||||
let _ = write!(dsn, "Original-Recipient: rfc822;{orcpt}\r\n");
|
||||
}
|
||||
let _ = write!(dsn, "Final-Recipient: rfc822;{}\r\n", self.address);
|
||||
}
|
||||
|
||||
fn write_dsn_will_retry_until(&self, created: u64, dsn: &mut String) {
|
||||
if let Some(expires) = self.expiration_time(created)
|
||||
&& expires > now()
|
||||
{
|
||||
dsn.push_str("Will-Retry-Until: ");
|
||||
dsn.push_str(&DateTime::from_timestamp(expires as i64).to_rfc822());
|
||||
dsn.push_str("\r\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T, E> Status<T, E> {
|
||||
pub fn into_permanent(self) -> Self {
|
||||
match self {
|
||||
Status::TemporaryFailure(v) => Status::PermanentFailure(v),
|
||||
v => v,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_temporary(self) -> Self {
|
||||
match self {
|
||||
Status::PermanentFailure(err) => Status::TemporaryFailure(err),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_permanent(&self) -> bool {
|
||||
matches!(self, Status::PermanentFailure(_))
|
||||
}
|
||||
|
||||
fn write_dsn_action(&self, dsn: &mut String) {
|
||||
dsn.push_str("Action: ");
|
||||
dsn.push_str(match self {
|
||||
Status::Completed(_) => "delivered",
|
||||
Status::PermanentFailure(_) => "failed",
|
||||
Status::TemporaryFailure(_) | Status::Scheduled => "delayed",
|
||||
});
|
||||
dsn.push_str("\r\n");
|
||||
}
|
||||
}
|
||||
|
||||
impl Status<HostResponse<Box<str>>, ErrorDetails> {
|
||||
fn write_dsn(&self, dsn: &mut String) {
|
||||
self.write_dsn_action(dsn);
|
||||
self.write_dsn_status(dsn);
|
||||
self.write_dsn_diagnostic(dsn);
|
||||
self.write_dsn_remote_mta(dsn);
|
||||
}
|
||||
|
||||
fn write_dsn_status(&self, dsn: &mut String) {
|
||||
dsn.push_str("Status: ");
|
||||
match self {
|
||||
Status::Completed(response) => {
|
||||
response.response.write_dsn_status(dsn);
|
||||
}
|
||||
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_remote_mta(&self, dsn: &mut String) {
|
||||
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(&err.entity);
|
||||
dsn.push_str("\r\n");
|
||||
}
|
||||
_ => (),
|
||||
},
|
||||
Status::Scheduled => (),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_dsn_diagnostic(&self, dsn: &mut String) {
|
||||
if let Status::PermanentFailure(err) | Status::TemporaryFailure(err) = self
|
||||
&& let Error::UnexpectedResponse(response) = &err.details
|
||||
{
|
||||
response.response.write_dsn_diagnostic(dsn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WriteDsn for Response<Box<str>> {
|
||||
fn write_dsn_status(&self, dsn: &mut String) {
|
||||
if self.esc[0] > 0 {
|
||||
let _ = write!(dsn, "{}.{}.{}", self.esc[0], self.esc[1], self.esc[2]);
|
||||
} else {
|
||||
let _ = write!(
|
||||
dsn,
|
||||
"{}.{}.{}",
|
||||
self.code / 100,
|
||||
(self.code / 10) % 10,
|
||||
self.code % 10
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_dsn_diagnostic(&self, dsn: &mut String) {
|
||||
let _ = write!(dsn, "Diagnostic-Code: smtp;{} ", self.code);
|
||||
self.write_response(dsn);
|
||||
dsn.push_str("\r\n");
|
||||
}
|
||||
|
||||
fn write_response(&self, dsn: &mut String) {
|
||||
for ch in self.message.chars() {
|
||||
if ch != '\n' && ch != '\r' {
|
||||
dsn.push(ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trait WriteDsn {
|
||||
fn write_dsn_status(&self, dsn: &mut String);
|
||||
fn write_dsn_diagnostic(&self, dsn: &mut String);
|
||||
fn write_response(&self, dsn: &mut String);
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use super::{Message, QueueId, Status, spool::SmtpSpool};
|
||||
use crate::queue::{
|
||||
Recipient,
|
||||
spool::{INFINITE_LOCK, LOCK_EXPIRY, QUEUE_REFRESH},
|
||||
};
|
||||
use ahash::AHashMap;
|
||||
use common::{
|
||||
BuildServer, Inner,
|
||||
config::smtp::queue::{QueueExpiry, QueueName},
|
||||
ipc::{QueueEvent, QueueEventStatus},
|
||||
};
|
||||
use rand::{RngExt, 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<Inner>,
|
||||
pub locked: AHashMap<(QueueId, QueueName), LockedMessage>,
|
||||
pub locked_revision: u64,
|
||||
pub stats: AHashMap<QueueName, QueueStats>,
|
||||
pub next_refresh: Instant,
|
||||
pub rx: mpsc::Receiver<QueueEvent>,
|
||||
pub is_paused: bool,
|
||||
pub scan_from: u64,
|
||||
pub scan_ceiling: u64,
|
||||
pub has_pending_work: bool,
|
||||
pub pending_refresh: bool,
|
||||
pub urgent_refresh: bool,
|
||||
pub last_scan: Instant,
|
||||
pub last_full_scan: Instant,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct QueueStats {
|
||||
pub in_flight: usize,
|
||||
pub max_in_flight: usize,
|
||||
pub budget: usize,
|
||||
pub last_warning: Instant,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LockedMessage {
|
||||
pub expires: u64,
|
||||
pub revision: u64,
|
||||
pub due: u64,
|
||||
}
|
||||
|
||||
impl SpawnQueue for mpsc::Receiver<QueueEvent> {
|
||||
fn spawn(self, core: Arc<Inner>) {
|
||||
tokio::spawn(async move {
|
||||
Queue::new(core, self).start().await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const BACK_PRESSURE_WARN_INTERVAL: Duration = Duration::from_secs(60);
|
||||
const MIN_SCAN_INTERVAL: Duration = Duration::from_millis(100);
|
||||
const FULL_SCAN_INTERVAL: Duration = Duration::from_secs(QUEUE_REFRESH / 2);
|
||||
|
||||
impl Queue {
|
||||
pub fn new(core: Arc<Inner>, rx: mpsc::Receiver<QueueEvent>) -> Self {
|
||||
let now = Instant::now();
|
||||
|
||||
Queue {
|
||||
core,
|
||||
locked: AHashMap::with_capacity(128),
|
||||
locked_revision: 0,
|
||||
stats: AHashMap::new(),
|
||||
next_refresh: now + Duration::from_secs(1),
|
||||
is_paused: false,
|
||||
rx,
|
||||
scan_from: 0,
|
||||
scan_ceiling: u64::MAX,
|
||||
has_pending_work: false,
|
||||
pending_refresh: false,
|
||||
urgent_refresh: false,
|
||||
last_scan: now.checked_sub(MIN_SCAN_INTERVAL).unwrap_or(now),
|
||||
last_full_scan: now,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start(&mut self) {
|
||||
trc::event!(Queue(trc::QueueEvent::Started));
|
||||
|
||||
loop {
|
||||
let mut refresh_queue;
|
||||
|
||||
match tokio::time::timeout(
|
||||
self.next_refresh.duration_since(Instant::now()),
|
||||
self.rx.recv(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(event)) => {
|
||||
refresh_queue = self.handle_event(event).await;
|
||||
|
||||
while let Ok(event) = self.rx.try_recv() {
|
||||
refresh_queue = self.handle_event(event).await || refresh_queue;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
refresh_queue = true;
|
||||
self.urgent_refresh = true;
|
||||
}
|
||||
Ok(None) => {
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
if self.is_paused {
|
||||
self.next_refresh = Instant::now() + Duration::from_secs(86400);
|
||||
continue;
|
||||
}
|
||||
|
||||
self.pending_refresh |= refresh_queue;
|
||||
if !self.pending_refresh && self.next_refresh > Instant::now() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Coalesce bursts of worker notifications into a single scan
|
||||
let scan_at = self.last_scan + MIN_SCAN_INTERVAL;
|
||||
if !self.urgent_refresh && scan_at > Instant::now() {
|
||||
self.next_refresh = scan_at;
|
||||
continue;
|
||||
}
|
||||
|
||||
if self.scan_from != 0 && self.last_full_scan.elapsed() >= FULL_SCAN_INTERVAL {
|
||||
self.scan_from = 0;
|
||||
}
|
||||
if self.scan_from == 0 {
|
||||
self.last_full_scan = Instant::now();
|
||||
}
|
||||
let scan_floor = self.scan_from;
|
||||
self.pending_refresh = false;
|
||||
self.urgent_refresh = false;
|
||||
|
||||
// Process queue events
|
||||
let server = self.core.build_server();
|
||||
let mut queue_events = server.next_event(self).await;
|
||||
self.last_scan = Instant::now();
|
||||
|
||||
if queue_events.messages.len() > 3 {
|
||||
queue_events.messages.shuffle(&mut rand::rng());
|
||||
}
|
||||
|
||||
// A truncated scan left events behind
|
||||
let now = now();
|
||||
self.has_pending_work = self.scan_ceiling != u64::MAX;
|
||||
|
||||
for queue_event in &queue_events.messages {
|
||||
// A message may hold more than one event key, dispatch it only once
|
||||
if self
|
||||
.locked
|
||||
.get(&(queue_event.queue_id, queue_event.queue_name))
|
||||
.is_some_and(|locked| locked.expires > now)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fetch queue stats
|
||||
let stats = match self.stats.get_mut(&queue_event.queue_name) {
|
||||
Some(stats) => stats,
|
||||
None => {
|
||||
let queue_config =
|
||||
server.get_virtual_queue_or_default(&queue_event.queue_name);
|
||||
self.stats.insert(
|
||||
queue_event.queue_name,
|
||||
QueueStats::new(queue_config.threads),
|
||||
);
|
||||
self.stats.get_mut(&queue_event.queue_name).unwrap()
|
||||
}
|
||||
};
|
||||
|
||||
// Enforce concurrency limits
|
||||
if stats.has_capacity() {
|
||||
// Deliver message
|
||||
stats.in_flight += 1;
|
||||
self.locked.insert(
|
||||
(queue_event.queue_id, queue_event.queue_name),
|
||||
LockedMessage {
|
||||
expires: now + INFINITE_LOCK,
|
||||
revision: self.locked_revision,
|
||||
due: queue_event.due,
|
||||
},
|
||||
);
|
||||
queue_event.try_deliver(server.clone());
|
||||
} else {
|
||||
if stats.last_warning.elapsed() >= BACK_PRESSURE_WARN_INTERVAL {
|
||||
stats.last_warning = Instant::now();
|
||||
trc::event!(
|
||||
Queue(trc::QueueEvent::BackPressure),
|
||||
Reason = "Processing capacity for this queue exceeded.",
|
||||
QueueName = queue_event.queue_name.to_string(),
|
||||
Limit = stats.max_in_flight,
|
||||
);
|
||||
}
|
||||
self.has_pending_work = true;
|
||||
if queue_event.due < self.scan_from {
|
||||
self.scan_from = queue_event.due;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove expired locks, revisiting any event they were holding back
|
||||
let scan_ceiling = self.scan_ceiling;
|
||||
let mut dropped_due = u64::MAX;
|
||||
self.locked.retain(|_, locked| {
|
||||
let keep = locked.expires > now
|
||||
&& (locked.revision == self.locked_revision
|
||||
|| locked.due < scan_floor
|
||||
|| locked.due >= scan_ceiling);
|
||||
if !keep && locked.due < dropped_due {
|
||||
dropped_due = locked.due;
|
||||
}
|
||||
keep
|
||||
});
|
||||
|
||||
// Do not wait for the next scheduled event while there is work left over
|
||||
let mut next_refresh = queue_events.next_refresh.saturating_sub(now);
|
||||
if self.has_pending_work {
|
||||
next_refresh = std::cmp::min(next_refresh, FULL_SCAN_INTERVAL.as_secs());
|
||||
}
|
||||
let mut next_refresh = Instant::now() + Duration::from_secs(next_refresh);
|
||||
|
||||
// A released lock uncovered an event below the floor that no scan can see
|
||||
if dropped_due < self.scan_from {
|
||||
self.scan_from = dropped_due;
|
||||
self.has_pending_work = true;
|
||||
self.pending_refresh = true;
|
||||
|
||||
let scan_at = self.last_scan + MIN_SCAN_INTERVAL;
|
||||
if scan_at < next_refresh {
|
||||
next_refresh = scan_at;
|
||||
}
|
||||
}
|
||||
|
||||
self.next_refresh = next_refresh;
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_event(&mut self, event: QueueEvent) -> bool {
|
||||
match event {
|
||||
QueueEvent::WorkerDone {
|
||||
queue_id,
|
||||
queue_name,
|
||||
status,
|
||||
} => {
|
||||
let has_capacity = match self.stats.get_mut(&queue_name) {
|
||||
Some(queue_stats) => {
|
||||
queue_stats.in_flight = queue_stats.in_flight.saturating_sub(1);
|
||||
queue_stats.has_capacity()
|
||||
}
|
||||
None => true,
|
||||
};
|
||||
|
||||
match status {
|
||||
QueueEventStatus::Completed => {
|
||||
self.core.ipc.task_tx.notify_one();
|
||||
self.locked.remove(&(queue_id, queue_name));
|
||||
!self.locked.is_empty() || !has_capacity || self.has_pending_work
|
||||
}
|
||||
QueueEventStatus::Locked => {
|
||||
let expires = LOCK_EXPIRY + rand::rng().random_range(5..10);
|
||||
let due_in = Instant::now() + Duration::from_secs(expires);
|
||||
if due_in < self.next_refresh {
|
||||
self.next_refresh = due_in;
|
||||
}
|
||||
|
||||
// The event was not delivered, so it has to be visited again
|
||||
// once the remote lock expires.
|
||||
let expires = now() + expires;
|
||||
let due = match self.locked.entry((queue_id, queue_name)) {
|
||||
Entry::Occupied(mut entry) => {
|
||||
let locked = entry.get_mut();
|
||||
locked.expires = expires;
|
||||
locked.revision = self.locked_revision;
|
||||
locked.due
|
||||
}
|
||||
Entry::Vacant(entry) => {
|
||||
entry.insert(LockedMessage {
|
||||
expires,
|
||||
revision: self.locked_revision,
|
||||
due: 0,
|
||||
});
|
||||
0
|
||||
}
|
||||
};
|
||||
if due < self.scan_from {
|
||||
self.scan_from = due;
|
||||
}
|
||||
self.locked.len() > 1 || !has_capacity || self.has_pending_work
|
||||
}
|
||||
QueueEventStatus::Deferred => {
|
||||
self.locked.remove(&(queue_id, queue_name));
|
||||
self.scan_from = 0;
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
QueueEvent::Refresh => {
|
||||
self.scan_from = 0;
|
||||
self.urgent_refresh = true;
|
||||
true
|
||||
}
|
||||
QueueEvent::Paused(paused) => {
|
||||
self.core
|
||||
.data
|
||||
.queue_status
|
||||
.store(!paused, Ordering::Relaxed);
|
||||
self.is_paused = paused;
|
||||
self.scan_from = 0;
|
||||
self.urgent_refresh = !paused;
|
||||
!paused
|
||||
}
|
||||
QueueEvent::ReloadSettings => {
|
||||
let server = self.core.build_server();
|
||||
let virtual_queues = &server.core.smtp.queue.virtual_queues;
|
||||
for (name, settings) in virtual_queues {
|
||||
if let Some(stats) = self.stats.get_mut(name) {
|
||||
stats.max_in_flight = settings.threads;
|
||||
} else {
|
||||
self.stats.insert(*name, QueueStats::new(settings.threads));
|
||||
}
|
||||
}
|
||||
self.stats
|
||||
.retain(|name, stats| stats.in_flight > 0 || virtual_queues.contains_key(name));
|
||||
self.scan_from = 0;
|
||||
false
|
||||
}
|
||||
QueueEvent::Stop => {
|
||||
self.rx.close();
|
||||
self.is_paused = true;
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Message {
|
||||
pub fn next_event(&self, queue: Option<QueueName>) -> Option<u64> {
|
||||
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)
|
||||
{
|
||||
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 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<QueueName>) -> Option<u64> {
|
||||
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<QueueName>) -> Option<u64> {
|
||||
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<QueueName>) -> Option<u64> {
|
||||
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_events(&self) -> AHashMap<QueueName, u64> {
|
||||
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<u64> {
|
||||
match self.expires {
|
||||
QueueExpiry::Ttl(time) => Some(created + time),
|
||||
QueueExpiry::Attempts(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_expired(&self, created: u64, now: u64) -> bool {
|
||||
match self.expires {
|
||||
QueueExpiry::Ttl(time) => created + time <= now,
|
||||
QueueExpiry::Attempts(count) => self.retry.inner >= count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait SpawnQueue {
|
||||
fn spawn(self, core: Arc<Inner>);
|
||||
}
|
||||
|
||||
impl QueueStats {
|
||||
pub(crate) fn new(max_in_flight: usize) -> Self {
|
||||
QueueStats {
|
||||
in_flight: 0,
|
||||
max_in_flight,
|
||||
budget: 0,
|
||||
last_warning: Instant::now()
|
||||
.checked_sub(BACK_PRESSURE_WARN_INTERVAL)
|
||||
.unwrap_or_else(Instant::now),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn has_capacity(&self) -> bool {
|
||||
self.in_flight < self.max_in_flight
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* 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 registry::schema::enums::ExpressionVariable;
|
||||
use smtp_proto::Response;
|
||||
use std::{
|
||||
fmt::Display,
|
||||
net::{IpAddr, Ipv4Addr},
|
||||
time::{Duration, Instant, SystemTime},
|
||||
};
|
||||
use store::write::now;
|
||||
use types::blob_hash::BlobHash;
|
||||
use utils::DomainPart;
|
||||
|
||||
pub mod dsn;
|
||||
pub mod manager;
|
||||
pub mod quota;
|
||||
pub mod spool;
|
||||
pub mod throttle;
|
||||
|
||||
pub type QueueId = u64;
|
||||
|
||||
#[derive(Debug, Clone, rkyv::Serialize, rkyv::Deserialize, rkyv::Archive, serde::Deserialize)]
|
||||
pub struct Schedule<T> {
|
||||
pub due: u64,
|
||||
pub inner: T,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct QueuedMessage {
|
||||
pub due: u64,
|
||||
pub queue_id: QueueId,
|
||||
pub queue_name: QueueName,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum MessageSource {
|
||||
Authenticated,
|
||||
Unauthenticated { dmarc_pass: bool },
|
||||
Dsn,
|
||||
Report,
|
||||
Autogenerated,
|
||||
}
|
||||
|
||||
impl MessageSource {
|
||||
pub fn flags(&self) -> u64 {
|
||||
match self {
|
||||
MessageSource::Authenticated => FROM_AUTHENTICATED,
|
||||
MessageSource::Unauthenticated { dmarc_pass: true } => FROM_UNAUTHENTICATED_DMARC,
|
||||
MessageSource::Unauthenticated { dmarc_pass: false } => FROM_UNAUTHENTICATED,
|
||||
MessageSource::Dsn => FROM_DSN,
|
||||
MessageSource::Report => FROM_REPORT,
|
||||
MessageSource::Autogenerated => FROM_AUTOGENERATED,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive, Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Message {
|
||||
pub created: u64,
|
||||
pub blob_hash: BlobHash,
|
||||
|
||||
pub return_path: Box<str>,
|
||||
pub recipients: Vec<Recipient>,
|
||||
|
||||
pub received_from_ip: IpAddr,
|
||||
pub received_via_port: u16,
|
||||
|
||||
pub flags: u64,
|
||||
pub env_id: Option<Box<str>>,
|
||||
pub priority: i16,
|
||||
|
||||
pub size: u64,
|
||||
pub metadata: Box<[Metadata]>,
|
||||
}
|
||||
|
||||
impl Message {
|
||||
pub fn queued_event(&self) -> trc::QueueEvent {
|
||||
if (self.flags & FROM_AUTHENTICATED) != 0 {
|
||||
trc::QueueEvent::AuthenticatedMessageQueued
|
||||
} else if (self.flags & FROM_DSN) != 0 {
|
||||
trc::QueueEvent::DsnQueued
|
||||
} else if (self.flags & FROM_REPORT) != 0 {
|
||||
trc::QueueEvent::ReportQueued
|
||||
} else if (self.flags & FROM_AUTOGENERATED) != 0 {
|
||||
trc::QueueEvent::AutogeneratedQueued
|
||||
} else {
|
||||
trc::QueueEvent::MessageQueued
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MessageWrapper {
|
||||
pub queue_id: QueueId,
|
||||
pub queue_name: QueueName,
|
||||
pub is_multi_queue: bool,
|
||||
pub span_id: u64,
|
||||
pub message: Message,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
rkyv::Serialize,
|
||||
rkyv::Deserialize,
|
||||
rkyv::Archive,
|
||||
Debug,
|
||||
Clone,
|
||||
PartialEq,
|
||||
Eq,
|
||||
serde::Deserialize,
|
||||
)]
|
||||
pub enum Metadata {
|
||||
QueueSize { key: Box<[u8]>, id: u64 },
|
||||
QueueCount { key: Box<[u8]>, id: u64 },
|
||||
Headers { value: Box<[u8]>, id: u64 },
|
||||
}
|
||||
|
||||
#[derive(
|
||||
rkyv::Serialize,
|
||||
rkyv::Deserialize,
|
||||
rkyv::Archive,
|
||||
Debug,
|
||||
Clone,
|
||||
PartialEq,
|
||||
Eq,
|
||||
serde::Deserialize,
|
||||
)]
|
||||
pub struct Recipient {
|
||||
pub address: Box<str>,
|
||||
|
||||
pub retry: Schedule<u32>,
|
||||
pub notify: Schedule<u32>,
|
||||
pub expires: QueueExpiry,
|
||||
|
||||
pub queue: QueueName,
|
||||
pub status: Status<HostResponse<Box<str>>, ErrorDetails>,
|
||||
pub flags: u64,
|
||||
pub orcpt: Option<Box<str>>,
|
||||
}
|
||||
|
||||
pub const FROM_AUTHENTICATED: u64 = 1 << 32;
|
||||
pub const FROM_UNAUTHENTICATED: u64 = 1 << 33;
|
||||
pub const FROM_UNAUTHENTICATED_DMARC: u64 = 1 << 34;
|
||||
pub const FROM_DSN: u64 = 1 << 35;
|
||||
pub const FROM_REPORT: u64 = 1 << 36;
|
||||
pub const FROM_AUTOGENERATED: u64 = 1 << 37;
|
||||
|
||||
pub const RCPT_DSN_SENT: u64 = 1 << 32;
|
||||
|
||||
pub const RCPT_SPAM_SHIFT: u64 = 56;
|
||||
pub const RCPT_SPAM_MASK: u64 = 0xff << RCPT_SPAM_SHIFT;
|
||||
|
||||
pub const fn rcpt_spam_flag(percentage: u8) -> u64 {
|
||||
(percentage as u64 + 1) << RCPT_SPAM_SHIFT
|
||||
}
|
||||
|
||||
pub const fn rcpt_spam_percentage(flags: u64) -> Option<u8> {
|
||||
match (flags >> RCPT_SPAM_SHIFT) as u8 {
|
||||
0 => None,
|
||||
percentage => Some(percentage - 1),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
PartialEq,
|
||||
Eq,
|
||||
rkyv::Serialize,
|
||||
rkyv::Deserialize,
|
||||
rkyv::Archive,
|
||||
serde::Serialize,
|
||||
serde::Deserialize,
|
||||
)]
|
||||
pub enum Status<T, E> {
|
||||
#[serde(rename = "scheduled")]
|
||||
Scheduled,
|
||||
#[serde(rename = "completed")]
|
||||
Completed(T),
|
||||
#[serde(rename = "temp_fail")]
|
||||
TemporaryFailure(E),
|
||||
#[serde(rename = "perm_fail")]
|
||||
PermanentFailure(E),
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
PartialEq,
|
||||
Eq,
|
||||
rkyv::Serialize,
|
||||
rkyv::Deserialize,
|
||||
rkyv::Archive,
|
||||
serde::Deserialize,
|
||||
)]
|
||||
pub struct HostResponse<T> {
|
||||
pub hostname: T,
|
||||
pub response: Response<Box<str>>,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
PartialEq,
|
||||
Eq,
|
||||
rkyv::Serialize,
|
||||
rkyv::Deserialize,
|
||||
rkyv::Archive,
|
||||
serde::Deserialize,
|
||||
Default,
|
||||
)]
|
||||
pub enum Error {
|
||||
DnsError(Box<str>),
|
||||
UnexpectedResponse(UnexpectedResponse),
|
||||
ConnectionError(Box<str>),
|
||||
TlsError(Box<str>),
|
||||
DaneError(Box<str>),
|
||||
MtaStsError(Box<str>),
|
||||
RateLimited,
|
||||
#[default]
|
||||
ConcurrencyLimited,
|
||||
Io(Box<str>),
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
PartialEq,
|
||||
Eq,
|
||||
rkyv::Serialize,
|
||||
rkyv::Deserialize,
|
||||
rkyv::Archive,
|
||||
serde::Deserialize,
|
||||
)]
|
||||
pub struct UnexpectedResponse {
|
||||
pub command: Box<str>,
|
||||
pub response: Response<Box<str>>,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug,
|
||||
Clone,
|
||||
PartialEq,
|
||||
Eq,
|
||||
rkyv::Serialize,
|
||||
rkyv::Deserialize,
|
||||
rkyv::Archive,
|
||||
Default,
|
||||
serde::Deserialize,
|
||||
)]
|
||||
pub struct ErrorDetails {
|
||||
pub entity: Box<str>,
|
||||
pub details: Error,
|
||||
}
|
||||
|
||||
impl<T> Ord for Schedule<T> {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
other.due.cmp(&self.due)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> PartialOrd for Schedule<T> {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> PartialEq for Schedule<T> {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.due == other.due
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Eq for Schedule<T> {}
|
||||
|
||||
impl<T: Default> Schedule<T> {
|
||||
pub fn now() -> Self {
|
||||
Schedule {
|
||||
due: now(),
|
||||
inner: T::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn later(duration: u64) -> Self {
|
||||
Schedule {
|
||||
due: now() + duration,
|
||||
inner: T::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct QueueEnvelope<'x> {
|
||||
pub message: &'x Message,
|
||||
pub domain: &'x str,
|
||||
pub mx: &'x str,
|
||||
pub rcpt: &'x Recipient,
|
||||
pub remote_ip: IpAddr,
|
||||
pub local_ip: IpAddr,
|
||||
}
|
||||
|
||||
impl<'x> QueueEnvelope<'x> {
|
||||
pub fn new(message: &'x Message, rcpt: &'x Recipient) -> Self {
|
||||
Self {
|
||||
message,
|
||||
domain: rcpt.address.domain_part(),
|
||||
rcpt,
|
||||
mx: "",
|
||||
remote_ip: IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)),
|
||||
local_ip: IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> ResolveVariable for QueueEnvelope<'x> {
|
||||
fn resolve_variable(&self, variable: ExpressionVariable) -> expr::Variable<'x> {
|
||||
match variable {
|
||||
ExpressionVariable::Sender => self.message.return_path.as_ref().into(),
|
||||
ExpressionVariable::SenderDomain => self.message.return_path.domain_part().into(),
|
||||
ExpressionVariable::RcptDomain => self.domain.into(),
|
||||
ExpressionVariable::Rcpt => self.rcpt.address.as_ref().into(),
|
||||
ExpressionVariable::Recipients => self
|
||||
.message
|
||||
.recipients
|
||||
.iter()
|
||||
.map(|r| Variable::from(r.address.as_ref()))
|
||||
.collect::<Vec<_>>()
|
||||
.into(),
|
||||
ExpressionVariable::RetryNum => self.rcpt.retry.inner.into(),
|
||||
ExpressionVariable::NotifyNum => self.rcpt.notify.inner.into(),
|
||||
ExpressionVariable::ExpiresIn => match &self.rcpt.expires {
|
||||
QueueExpiry::Ttl(time) => (*time + self.message.created).saturating_sub(now()),
|
||||
QueueExpiry::Attempts(count) => {
|
||||
(count.saturating_sub(self.rcpt.retry.inner)) as u64
|
||||
}
|
||||
}
|
||||
.into(),
|
||||
ExpressionVariable::LastStatus => self.rcpt.status.to_compact_string().into(),
|
||||
ExpressionVariable::LastError => match &self.rcpt.status {
|
||||
Status::Scheduled | Status::Completed(_) => "none",
|
||||
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",
|
||||
}
|
||||
}
|
||||
}
|
||||
.into(),
|
||||
ExpressionVariable::QueueName => self.rcpt.queue.as_str().into(),
|
||||
ExpressionVariable::QueueAge => now().saturating_sub(self.message.created).into(),
|
||||
ExpressionVariable::Source => if (self.message.flags & FROM_AUTHENTICATED) != 0 {
|
||||
"authenticated"
|
||||
} else if (self.message.flags & FROM_UNAUTHENTICATED_DMARC) != 0 {
|
||||
"dmarc_pass"
|
||||
} else if (self.message.flags & FROM_UNAUTHENTICATED) != 0 {
|
||||
"unauthenticated"
|
||||
} else if (self.message.flags & FROM_DSN) != 0 {
|
||||
"dsn"
|
||||
} else if (self.message.flags & FROM_REPORT) != 0 {
|
||||
"report"
|
||||
} else if (self.message.flags & FROM_AUTOGENERATED) != 0 {
|
||||
"autogenerated"
|
||||
} else {
|
||||
"unknown"
|
||||
}
|
||||
.into(),
|
||||
ExpressionVariable::Mx => self.mx.into(),
|
||||
ExpressionVariable::Priority => self.message.priority.into(),
|
||||
ExpressionVariable::RemoteIp => self.remote_ip.to_compact_string().into(),
|
||||
ExpressionVariable::LocalIp => self.local_ip.to_compact_string().into(),
|
||||
ExpressionVariable::ReceivedFromIp => {
|
||||
self.message.received_from_ip.to_compact_string().into()
|
||||
}
|
||||
ExpressionVariable::ReceivedViaPort => self.message.received_via_port.into(),
|
||||
ExpressionVariable::Size => self.message.size.into(),
|
||||
_ => "".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_global(&self, _: &str) -> Variable<'_> {
|
||||
Variable::Integer(0)
|
||||
}
|
||||
}
|
||||
|
||||
impl ResolveVariable for Message {
|
||||
fn resolve_variable(&self, variable: ExpressionVariable) -> expr::Variable<'_> {
|
||||
match variable {
|
||||
ExpressionVariable::Sender => self.return_path.as_ref().into(),
|
||||
ExpressionVariable::SenderDomain => self.return_path.domain_part().into(),
|
||||
ExpressionVariable::Recipients => self
|
||||
.recipients
|
||||
.iter()
|
||||
.map(|r| Variable::from(r.address.as_ref()))
|
||||
.collect::<Vec<_>>()
|
||||
.into(),
|
||||
ExpressionVariable::Priority => self.priority.into(),
|
||||
_ => "".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_global(&self, _: &str) -> Variable<'_> {
|
||||
Variable::Integer(0)
|
||||
}
|
||||
}
|
||||
|
||||
impl ResolveVariable for MessageWrapper {
|
||||
fn resolve_variable(&self, variable: ExpressionVariable) -> expr::Variable<'_> {
|
||||
match variable {
|
||||
ExpressionVariable::Sender => self.message.return_path.as_ref().into(),
|
||||
ExpressionVariable::SenderDomain => self.message.return_path.domain_part().into(),
|
||||
ExpressionVariable::Recipients => self
|
||||
.message
|
||||
.recipients
|
||||
.iter()
|
||||
.map(|r| Variable::from(r.address.as_ref()))
|
||||
.collect::<Vec<_>>()
|
||||
.into(),
|
||||
ExpressionVariable::Priority => self.message.priority.into(),
|
||||
ExpressionVariable::QueueName => self.queue_name.as_str().into(),
|
||||
ExpressionVariable::QueueAge => now().saturating_sub(self.message.created).into(),
|
||||
ExpressionVariable::Source => if (self.message.flags & FROM_AUTHENTICATED) != 0 {
|
||||
"authenticated"
|
||||
} else if (self.message.flags & FROM_UNAUTHENTICATED_DMARC) != 0 {
|
||||
"dmarc_pass"
|
||||
} else if (self.message.flags & FROM_UNAUTHENTICATED) != 0 {
|
||||
"unauthenticated"
|
||||
} else if (self.message.flags & FROM_DSN) != 0 {
|
||||
"dsn"
|
||||
} else if (self.message.flags & FROM_REPORT) != 0 {
|
||||
"report"
|
||||
} else if (self.message.flags & FROM_AUTOGENERATED) != 0 {
|
||||
"autogenerated"
|
||||
} else {
|
||||
"unknown"
|
||||
}
|
||||
.into(),
|
||||
ExpressionVariable::ReceivedFromIp => {
|
||||
self.message.received_from_ip.to_compact_string().into()
|
||||
}
|
||||
ExpressionVariable::ReceivedViaPort => self.message.received_via_port.into(),
|
||||
ExpressionVariable::Size => self.message.size.into(),
|
||||
_ => "".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_global(&self, _: &str) -> Variable<'_> {
|
||||
Variable::Integer(0)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RecipientDomain<'x>(&'x str);
|
||||
|
||||
impl<'x> RecipientDomain<'x> {
|
||||
pub fn new(domain: &'x str) -> Self {
|
||||
Self(domain)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> ResolveVariable for RecipientDomain<'x> {
|
||||
fn resolve_variable(&self, variable: ExpressionVariable) -> expr::Variable<'x> {
|
||||
match variable {
|
||||
ExpressionVariable::RcptDomain => self.0.into(),
|
||||
_ => "".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_global(&self, _: &str) -> Variable<'_> {
|
||||
Variable::Integer(0)
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
pub fn instant_to_timestamp(now: Instant, time: Instant) -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs())
|
||||
+ time.checked_duration_since(now).map_or(0, |d| d.as_secs())
|
||||
}
|
||||
|
||||
impl Recipient {
|
||||
pub fn new(address: impl AsRef<str>) -> Self {
|
||||
Recipient {
|
||||
address: address.to_lowercase_address(false).into_boxed_str(),
|
||||
status: Status::Scheduled,
|
||||
flags: 0,
|
||||
orcpt: None,
|
||||
retry: Schedule::now(),
|
||||
notify: Schedule::now(),
|
||||
expires: QueueExpiry::Attempts(0),
|
||||
queue: QueueName::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_flags(mut self, flags: u64) -> Self {
|
||||
self.flags = flags;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_orcpt(mut self, orcpt: Option<Box<str>>) -> Self {
|
||||
self.orcpt = orcpt;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn address(&self) -> &str {
|
||||
&self.address
|
||||
}
|
||||
|
||||
pub fn domain_part(&self) -> &str {
|
||||
self.address.domain_part()
|
||||
}
|
||||
}
|
||||
|
||||
impl ArchivedRecipient {
|
||||
pub fn address(&self) -> &str {
|
||||
self.address.as_ref()
|
||||
}
|
||||
|
||||
pub fn domain_part(&self) -> &str {
|
||||
self.address.domain_part()
|
||||
}
|
||||
}
|
||||
|
||||
pub trait InstantFromTimestamp {
|
||||
fn to_instant(&self) -> Instant;
|
||||
}
|
||||
|
||||
impl InstantFromTimestamp for u64 {
|
||||
fn to_instant(&self) -> Instant {
|
||||
let timestamp = *self;
|
||||
let current_timestamp = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs());
|
||||
if timestamp > current_timestamp {
|
||||
Instant::now() + Duration::from_secs(timestamp - current_timestamp)
|
||||
} else {
|
||||
Instant::now()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Error::UnexpectedResponse(response) => {
|
||||
write!(
|
||||
f,
|
||||
"Unexpected response for {}: {}",
|
||||
response.command, response.response
|
||||
)
|
||||
}
|
||||
Error::DnsError(err) => {
|
||||
write!(f, "DNS lookup failed: {err}")
|
||||
}
|
||||
Error::ConnectionError(details) => {
|
||||
write!(f, "Connection failed: {details}",)
|
||||
}
|
||||
Error::TlsError(details) => {
|
||||
write!(f, "TLS error: {details}",)
|
||||
}
|
||||
Error::DaneError(details) => {
|
||||
write!(f, "DANE authentication failure: {details}",)
|
||||
}
|
||||
Error::MtaStsError(details) => {
|
||||
write!(f, "MTA-STS auth failed: {details}")
|
||||
}
|
||||
Error::RateLimited => {
|
||||
write!(f, "Rate limited")
|
||||
}
|
||||
Error::ConcurrencyLimited => {
|
||||
write!(f, "Too many concurrent connections to remote server")
|
||||
}
|
||||
Error::Io(err) => {
|
||||
write!(f, "Queue error: {err}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for ArchivedError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ArchivedError::UnexpectedResponse(response) => {
|
||||
write!(
|
||||
f,
|
||||
"Unexpected response for {}: {}",
|
||||
response.command, response.response
|
||||
)
|
||||
}
|
||||
ArchivedError::DnsError(err) => {
|
||||
write!(f, "DNS lookup failed: {err}")
|
||||
}
|
||||
ArchivedError::ConnectionError(details) => {
|
||||
write!(f, "Connection failed: {details}",)
|
||||
}
|
||||
ArchivedError::TlsError(details) => {
|
||||
write!(f, "TLS error: {details}",)
|
||||
}
|
||||
ArchivedError::DaneError(details) => {
|
||||
write!(f, "DANE authentication failure: {details}",)
|
||||
}
|
||||
ArchivedError::MtaStsError(details) => {
|
||||
write!(f, "MTA-STS auth failed: {details}")
|
||||
}
|
||||
ArchivedError::RateLimited => {
|
||||
write!(f, "Rate limited")
|
||||
}
|
||||
ArchivedError::ConcurrencyLimited => {
|
||||
write!(f, "Too many concurrent connections to remote server")
|
||||
}
|
||||
ArchivedError::Io(err) => {
|
||||
write!(f, "Queue error: {err}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Status<HostResponse<Box<str>>, 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 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;
|
||||
}
|
||||
|
||||
impl DisplayArchivedResponse for ArchivedResponse<Box<str>> {
|
||||
fn to_string(&self) -> String {
|
||||
format!(
|
||||
"Code: {}, Enhanced code: {}.{}.{}, Message: {}",
|
||||
self.code, self.esc[0], self.esc[1], self.esc[2], self.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
*/
|
||||
@@ -0,0 +1,230 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use super::{Metadata, QueueEnvelope, Status};
|
||||
use crate::{core::throttle::NewKey, queue::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 utils::DomainPart;
|
||||
|
||||
pub trait HasQueueQuota: Sync + Send {
|
||||
fn has_quota(
|
||||
&self,
|
||||
message: &mut MessageWrapper,
|
||||
) -> impl Future<Output = Option<Vec<Metadata>>> + Send;
|
||||
fn check_quota<'x>(
|
||||
&'x self,
|
||||
quota: &'x QueueQuota,
|
||||
envelope: &impl ResolveVariable,
|
||||
size: u64,
|
||||
id: u64,
|
||||
refs: &mut Vec<Metadata>,
|
||||
session_id: u64,
|
||||
) -> impl Future<Output = bool> + Send;
|
||||
}
|
||||
|
||||
impl HasQueueQuota for Server {
|
||||
async fn has_quota(&self, message: &mut MessageWrapper) -> Option<Vec<Metadata>> {
|
||||
let mut quota_keys = Vec::new();
|
||||
|
||||
if !self.core.smtp.queue.quota.sender.is_empty() {
|
||||
for quota in &self.core.smtp.queue.quota.sender {
|
||||
if !self
|
||||
.check_quota(
|
||||
quota,
|
||||
&message.message,
|
||||
message.message.size,
|
||||
0,
|
||||
&mut quota_keys,
|
||||
message.span_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
trc::event!(
|
||||
Queue(QueueEvent::QuotaExceeded),
|
||||
SpanId = message.span_id,
|
||||
Id = quota.id.to_string(),
|
||||
Type = "Sender"
|
||||
);
|
||||
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.domain_part())
|
||||
&& !self
|
||||
.check_quota(
|
||||
quota,
|
||||
&QueueEnvelope::new(&message.message, rcpt),
|
||||
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.to_string(),
|
||||
Type = "Domain"
|
||||
);
|
||||
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for quota in &self.core.smtp.queue.quota.rcpt {
|
||||
for (rcpt_idx, rcpt) in message.message.recipients.iter().enumerate() {
|
||||
if !self
|
||||
.check_quota(
|
||||
quota,
|
||||
&QueueEnvelope::new(&message.message, rcpt),
|
||||
message.message.size,
|
||||
(rcpt_idx + 1) as u64,
|
||||
&mut quota_keys,
|
||||
message.span_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
trc::event!(
|
||||
Queue(QueueEvent::QuotaExceeded),
|
||||
SpanId = message.span_id,
|
||||
Id = quota.id.to_string(),
|
||||
Type = "Recipient"
|
||||
);
|
||||
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(quota_keys)
|
||||
}
|
||||
|
||||
async fn check_quota<'x>(
|
||||
&'x self,
|
||||
quota: &'x QueueQuota,
|
||||
envelope: &impl ResolveVariable,
|
||||
size: u64,
|
||||
id: u64,
|
||||
refs: &mut Vec<Metadata>,
|
||||
session_id: u64,
|
||||
) -> bool {
|
||||
if !quota.expr.is_empty()
|
||||
&& self
|
||||
.eval_if("a.expr, envelope, session_id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let key = quota.new_key(envelope, "");
|
||||
if let Some(max_size) = quota.size {
|
||||
let used_size = self
|
||||
.core
|
||||
.storage
|
||||
.data
|
||||
.get_counter(ValueKey::from(ValueClass::Queue(QueueClass::QuotaSize(
|
||||
key.as_ref().to_vec(),
|
||||
))))
|
||||
.await
|
||||
.unwrap_or(0) as u64;
|
||||
if used_size + size > max_size {
|
||||
return false;
|
||||
} else {
|
||||
refs.push(Metadata::QueueSize {
|
||||
key: key.as_ref().into(),
|
||||
id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(max_messages) = quota.messages {
|
||||
let total_messages = self
|
||||
.core
|
||||
.storage
|
||||
.data
|
||||
.get_counter(ValueKey::from(ValueClass::Queue(QueueClass::QuotaCount(
|
||||
key.as_ref().to_vec(),
|
||||
))))
|
||||
.await
|
||||
.unwrap_or(0) as u64;
|
||||
if total_messages + 1 > max_messages {
|
||||
return false;
|
||||
} else {
|
||||
refs.push(Metadata::QueueCount {
|
||||
key: key.as_ref().into(),
|
||||
id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl MessageWrapper {
|
||||
pub fn release_quota(&mut self, batch: &mut BatchBuilder) {
|
||||
if !self.message.metadata.iter().any(|metadata| {
|
||||
matches!(
|
||||
metadata,
|
||||
Metadata::QueueSize { .. } | Metadata::QueueCount { .. }
|
||||
)
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
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.domain_part()) {
|
||||
quota_ids.push(((pos + 1) as u64) << 32);
|
||||
}
|
||||
quota_ids.push((pos + 1) as u64);
|
||||
}
|
||||
}
|
||||
|
||||
if !quota_ids.is_empty() {
|
||||
let mut metadata = Vec::new();
|
||||
for entry in std::mem::take(&mut self.message.metadata) {
|
||||
match entry {
|
||||
Metadata::QueueCount { id, key } if quota_ids.contains(&id) => {
|
||||
batch.add(
|
||||
ValueClass::Queue(QueueClass::QuotaCount(key.into_vec())),
|
||||
-1,
|
||||
);
|
||||
}
|
||||
Metadata::QueueSize { id, key } if quota_ids.contains(&id) => {
|
||||
batch.add(
|
||||
ValueClass::Queue(QueueClass::QuotaSize(key.into_vec())),
|
||||
-(self.message.size as i64),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
metadata.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.message.metadata = metadata.into_boxed_slice();
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
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;
|
||||
|
||||
pub trait IsAllowed: Sync + Send {
|
||||
fn is_allowed<'x>(
|
||||
&'x self,
|
||||
throttle: &'x QueueRateLimiter,
|
||||
envelope: &impl ResolveVariable,
|
||||
session_id: u64,
|
||||
) -> impl Future<Output = Result<(), u64>> + Send;
|
||||
}
|
||||
|
||||
impl IsAllowed for Server {
|
||||
async fn is_allowed<'x>(
|
||||
&'x self,
|
||||
throttle: &'x QueueRateLimiter,
|
||||
envelope: &impl ResolveVariable,
|
||||
session_id: u64,
|
||||
) -> Result<(), u64> {
|
||||
if throttle.expr.is_empty()
|
||||
|| self
|
||||
.eval_if(&throttle.expr, envelope, session_id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let key = throttle.new_key(envelope, "outbound");
|
||||
|
||||
match self
|
||||
.in_memory_store()
|
||||
.is_rate_allowed(KV_RATE_LIMIT_SMTP, key.as_ref(), &throttle.rate, false)
|
||||
.await
|
||||
{
|
||||
Ok(Some(next_refill)) => {
|
||||
trc::event!(
|
||||
Queue(trc::QueueEvent::RateLimitExceeded),
|
||||
SpanId = session_id,
|
||||
Id = throttle.id.to_string(),
|
||||
Limit = vec![
|
||||
trc::Value::from(throttle.rate.count),
|
||||
trc::Value::from(throttle.rate.period.into_inner())
|
||||
],
|
||||
);
|
||||
|
||||
return Err(now() + next_refill);
|
||||
}
|
||||
Err(err) => {
|
||||
trc::error!(err.span_id(session_id).caused_by(trc::location!()));
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use ahash::AHashSet;
|
||||
use common::{Server, psl};
|
||||
use mail_auth::{
|
||||
flate2::read::GzDecoder,
|
||||
report::{Feedback, Report, tlsrpt::TlsReport},
|
||||
zip,
|
||||
};
|
||||
use mail_parser::{Message, MessagePart, MimeHeaders, PartType};
|
||||
use registry::{
|
||||
schema::structs::{ArfExternalReport, DmarcExternalReport, TlsExternalReport},
|
||||
types::datetime::UTCDateTime,
|
||||
};
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
io::{Cursor, Read},
|
||||
};
|
||||
use store::write::{BatchBuilder, now};
|
||||
use trc::IncomingReportEvent;
|
||||
use types::id::Id;
|
||||
|
||||
use crate::reporting::{inbound::LogReport, index::ExternalReportIndex};
|
||||
|
||||
enum Compression {
|
||||
None,
|
||||
Gzip,
|
||||
Zip,
|
||||
}
|
||||
|
||||
enum Format<D, T, A> {
|
||||
Dmarc(D),
|
||||
Tls(T),
|
||||
Arf(A),
|
||||
}
|
||||
|
||||
pub(crate) struct ReportData<'x> {
|
||||
compression: Compression,
|
||||
format: Format<(), (), ()>,
|
||||
data: &'x [u8],
|
||||
}
|
||||
|
||||
impl<'x> ReportData<'x> {
|
||||
fn from_part(part: &'x MessagePart<'x>) -> Option<Self> {
|
||||
match &part.body {
|
||||
PartType::Text(report) => {
|
||||
if part
|
||||
.content_type()
|
||||
.and_then(|ct| ct.subtype())
|
||||
.is_some_and(|t| t.eq_ignore_ascii_case("xml"))
|
||||
|| part
|
||||
.attachment_name()
|
||||
.and_then(|n| n.rsplit_once('.'))
|
||||
.is_some_and(|(_, e)| e.eq_ignore_ascii_case("xml"))
|
||||
{
|
||||
Some(ReportData {
|
||||
compression: Compression::None,
|
||||
format: Format::Dmarc(()),
|
||||
data: report.as_bytes(),
|
||||
})
|
||||
} else if part.is_content_type("message", "feedback-report") {
|
||||
Some(ReportData {
|
||||
compression: Compression::None,
|
||||
format: Format::Arf(()),
|
||||
data: report.as_bytes(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
PartType::Binary(report) | PartType::InlineBinary(report) => {
|
||||
if part.is_content_type("message", "feedback-report") {
|
||||
return Some(ReportData {
|
||||
compression: Compression::None,
|
||||
format: Format::Arf(()),
|
||||
data: report.as_ref(),
|
||||
});
|
||||
}
|
||||
|
||||
let subtype = part
|
||||
.content_type()
|
||||
.and_then(|ct| ct.subtype())
|
||||
.unwrap_or("");
|
||||
let attachment_name = part.attachment_name();
|
||||
let ext = attachment_name
|
||||
.and_then(|f| f.rsplit_once('.'))
|
||||
.map_or("", |(_, e)| e);
|
||||
let tls_parts = subtype.rsplit_once('+');
|
||||
let compression = match (tls_parts.map(|(_, c)| c).unwrap_or(subtype), ext) {
|
||||
("gzip", _) => Compression::Gzip,
|
||||
("zip", _) => Compression::Zip,
|
||||
(_, "gz") => Compression::Gzip,
|
||||
(_, "zip") => Compression::Zip,
|
||||
_ => Compression::None,
|
||||
};
|
||||
let format = match (tls_parts.map(|(c, _)| c).unwrap_or(subtype), ext) {
|
||||
("xml", _) => Format::Dmarc(()),
|
||||
("tlsrpt", _) | (_, "json") => Format::Tls(()),
|
||||
_ => {
|
||||
if attachment_name.is_some_and(|n| n.contains(".xml") || n.contains('!')) {
|
||||
Format::Dmarc(())
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Some(ReportData {
|
||||
compression,
|
||||
format,
|
||||
data: report.as_ref(),
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract(message: &'x Message<'x>) -> Vec<Self> {
|
||||
message.parts.iter().filter_map(Self::from_part).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn is_present(message: &Message<'_>) -> bool {
|
||||
message
|
||||
.parts
|
||||
.iter()
|
||||
.any(|part| ReportData::from_part(part).is_some())
|
||||
}
|
||||
}
|
||||
|
||||
pub trait AnalyzeReport: Sync + Send {
|
||||
fn analyze_report(&self, message: Message<'static>, session_id: u64);
|
||||
}
|
||||
|
||||
impl AnalyzeReport for Server {
|
||||
fn analyze_report(&self, message: Message<'static>, session_id: u64) {
|
||||
let core = self.clone();
|
||||
tokio::spawn(async move {
|
||||
let from: String = message
|
||||
.from()
|
||||
.and_then(|a| a.last())
|
||||
.and_then(|a| a.address())
|
||||
.unwrap_or_default()
|
||||
.into();
|
||||
let to: Vec<String> = message.to().map_or_else(Vec::new, |a| {
|
||||
a.iter()
|
||||
.filter_map(|a| a.address())
|
||||
.map(|a| a.into())
|
||||
.collect()
|
||||
});
|
||||
let subject: String = message.subject().unwrap_or_default().into();
|
||||
let reports = ReportData::extract(&message);
|
||||
let max_size = core.core.smtp.report.analysis.max_size;
|
||||
|
||||
for report in reports {
|
||||
let data = match report.compression {
|
||||
Compression::None => Cow::Borrowed(report.data),
|
||||
Compression::Gzip => {
|
||||
match read_capped(GzDecoder::new(report.data), 0, max_size) {
|
||||
Ok(buf) => Cow::Owned(buf),
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
IncomingReport(IncomingReportEvent::DecompressError),
|
||||
SpanId = session_id,
|
||||
From = from.to_string(),
|
||||
Reason = err.to_string(),
|
||||
CausedBy = trc::location!()
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
Compression::Zip => {
|
||||
let data = report.data.to_vec();
|
||||
let result = tokio::task::spawn_blocking(
|
||||
move || -> Result<Vec<u8>, std::io::Error> {
|
||||
let mut archive = zip::ZipArchive::new(Cursor::new(data))
|
||||
.map_err(std::io::Error::other)?;
|
||||
if archive.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut file =
|
||||
archive.by_index(0).map_err(std::io::Error::other)?;
|
||||
let size_hint = file.size();
|
||||
read_capped(&mut file, size_hint, max_size)
|
||||
},
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
Ok(Ok(buf)) => Cow::Owned(buf),
|
||||
Ok(Err(err)) => {
|
||||
trc::event!(
|
||||
IncomingReport(IncomingReportEvent::DecompressError),
|
||||
SpanId = session_id,
|
||||
From = from.to_string(),
|
||||
Reason = err.to_string(),
|
||||
CausedBy = trc::location!()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
IncomingReport(IncomingReportEvent::DecompressError),
|
||||
SpanId = session_id,
|
||||
From = from.to_string(),
|
||||
Reason = err.to_string(),
|
||||
CausedBy = trc::location!()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let report = match report.format {
|
||||
Format::Dmarc(_) => match Report::parse_xml(&data) {
|
||||
Ok(report) => {
|
||||
// Log
|
||||
report.log();
|
||||
Format::Dmarc(report)
|
||||
}
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
IncomingReport(IncomingReportEvent::DmarcParseFailed),
|
||||
SpanId = session_id,
|
||||
From = from.to_string(),
|
||||
Reason = err,
|
||||
CausedBy = trc::location!()
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
},
|
||||
Format::Tls(_) => match TlsReport::parse_json(&data) {
|
||||
Ok(report) => {
|
||||
// Log
|
||||
report.log();
|
||||
Format::Tls(report)
|
||||
}
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
IncomingReport(IncomingReportEvent::TlsRpcParseFailed),
|
||||
SpanId = session_id,
|
||||
From = from.to_string(),
|
||||
Reason = format!("{err:?}"),
|
||||
CausedBy = trc::location!()
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
},
|
||||
Format::Arf(_) => match Feedback::parse_arf(&data) {
|
||||
Some(report) => {
|
||||
// Log
|
||||
report.log();
|
||||
Format::Arf(report.into_owned())
|
||||
}
|
||||
None => {
|
||||
trc::event!(
|
||||
IncomingReport(IncomingReportEvent::ArfParseFailed),
|
||||
SpanId = session_id,
|
||||
From = from.to_string(),
|
||||
CausedBy = trc::location!()
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Store report
|
||||
if let Some(expires_in) = &core.core.smtp.report.analysis.store {
|
||||
let expires = now() + expires_in.as_secs();
|
||||
let item_id = core.inner.data.queue_id_gen.generate();
|
||||
let mut batch = BatchBuilder::new();
|
||||
|
||||
match report {
|
||||
Format::Dmarc(report) => {
|
||||
let mut report = DmarcExternalReport {
|
||||
from,
|
||||
to: to.into(),
|
||||
subject,
|
||||
member_tenant_id: None,
|
||||
expires_at: UTCDateTime::from_timestamp(expires as i64),
|
||||
received_at: UTCDateTime::now(),
|
||||
report: report.into(),
|
||||
};
|
||||
report.member_tenant_id = tenant_ids(
|
||||
&core,
|
||||
report
|
||||
.domains()
|
||||
.filter_map(psl::domain_str)
|
||||
.collect::<AHashSet<_>>(),
|
||||
)
|
||||
.await;
|
||||
report.write_ops(&mut batch, item_id, true);
|
||||
}
|
||||
Format::Tls(report) => {
|
||||
let mut report = TlsExternalReport {
|
||||
from,
|
||||
to: to.into(),
|
||||
subject,
|
||||
member_tenant_id: None,
|
||||
expires_at: UTCDateTime::from_timestamp(expires as i64),
|
||||
received_at: UTCDateTime::now(),
|
||||
report: report.into(),
|
||||
};
|
||||
report.member_tenant_id = tenant_ids(
|
||||
&core,
|
||||
report
|
||||
.domains()
|
||||
.filter_map(psl::domain_str)
|
||||
.collect::<AHashSet<_>>(),
|
||||
)
|
||||
.await;
|
||||
report.write_ops(&mut batch, item_id, true);
|
||||
}
|
||||
Format::Arf(report) => {
|
||||
let mut report = ArfExternalReport {
|
||||
from,
|
||||
to: to.into(),
|
||||
subject,
|
||||
member_tenant_id: None,
|
||||
expires_at: UTCDateTime::from_timestamp(expires as i64),
|
||||
received_at: UTCDateTime::now(),
|
||||
report: report.into(),
|
||||
};
|
||||
report.member_tenant_id = tenant_ids(
|
||||
&core,
|
||||
report
|
||||
.domains()
|
||||
.filter_map(psl::domain_str)
|
||||
.collect::<AHashSet<_>>(),
|
||||
)
|
||||
.await;
|
||||
report.write_ops(&mut batch, item_id, true);
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(err) = core.core.storage.data.write(batch.build_all()).await
|
||||
&& !err.is_assertion_failure()
|
||||
{
|
||||
trc::error!(
|
||||
err.span_id(session_id)
|
||||
.caused_by(trc::location!())
|
||||
.details("Failed to write report")
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn tenant_ids(server: &Server, domains: AHashSet<&str>) -> Option<Id> {
|
||||
let mut tenant_ids = Vec::with_capacity(domains.len());
|
||||
for domain in domains {
|
||||
if let Some(tenant_id) = server
|
||||
.domain(domain)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
trc::error!(
|
||||
err.caused_by(trc::location!())
|
||||
.details("Failed to lookup domain")
|
||||
);
|
||||
})
|
||||
.unwrap_or_default()
|
||||
.and_then(|domain| domain.id_tenant)
|
||||
.map(Id::from)
|
||||
&& !tenant_ids.contains(&tenant_id)
|
||||
{
|
||||
tenant_ids.push(tenant_id);
|
||||
}
|
||||
}
|
||||
|
||||
if tenant_ids.len() == 1 {
|
||||
tenant_ids.into_iter().next()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn read_capped(
|
||||
reader: impl Read,
|
||||
size_hint: u64,
|
||||
max_size: usize,
|
||||
) -> Result<Vec<u8>, std::io::Error> {
|
||||
let max_size = max_size as u64;
|
||||
if size_hint > max_size {
|
||||
return Err(std::io::Error::other(format!(
|
||||
"Report is larger than the {max_size} byte limit"
|
||||
)));
|
||||
}
|
||||
|
||||
let mut buf = Vec::with_capacity(size_hint.min(64 * 1024) as usize);
|
||||
reader
|
||||
.take(max_size.saturating_add(1))
|
||||
.read_to_end(&mut buf)?;
|
||||
|
||||
if buf.len() as u64 > max_size {
|
||||
return Err(std::io::Error::other(format!(
|
||||
"Report is larger than the {max_size} byte limit"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(buf)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{core::Session, reporting::send::MtaReportSend};
|
||||
use common::network::SessionStream;
|
||||
use mail_auth::{
|
||||
AuthenticatedMessage, AuthenticationResults, DkimOutput, common::verify::VerifySignature,
|
||||
};
|
||||
use registry::schema::structs::Rate;
|
||||
use trc::OutgoingReportEvent;
|
||||
|
||||
impl<T: SessionStream> Session<T> {
|
||||
pub async fn send_dkim_report(
|
||||
&self,
|
||||
rcpt: &str,
|
||||
message: &AuthenticatedMessage<'_>,
|
||||
rate: &Rate,
|
||||
rejected: bool,
|
||||
output: &DkimOutput<'_>,
|
||||
) {
|
||||
// Generate report
|
||||
let signature = if let Some(signature) = output.signature() {
|
||||
signature
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
|
||||
if self
|
||||
.server
|
||||
.is_local_report_domain(signature.domain(), self.data.session_id)
|
||||
.await
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Throttle recipient
|
||||
if !self.throttle_rcpt(rcpt, rate, "dkim").await {
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::DkimRateLimited),
|
||||
SpanId = self.data.session_id,
|
||||
To = rcpt.to_string(),
|
||||
Limit = vec![
|
||||
trc::Value::from(rate.count),
|
||||
trc::Value::from(rate.period.into_inner())
|
||||
],
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let config = &self.server.core.smtp.report.dkim;
|
||||
let from_addr = self
|
||||
.server
|
||||
.eval_if(&config.address, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or_else(|| "MAILER-DAEMON@localhost".to_string());
|
||||
let mut report = Vec::with_capacity(128);
|
||||
self.new_auth_failure(output.result().into(), rejected)
|
||||
.with_authentication_results(
|
||||
AuthenticationResults::new(&self.hostname)
|
||||
.with_dkim_result(output, message.from())
|
||||
.to_string(),
|
||||
)
|
||||
.with_dkim_domain(signature.domain())
|
||||
.with_dkim_selector(signature.selector())
|
||||
.with_dkim_identity(signature.identity())
|
||||
.with_headers(std::str::from_utf8(message.raw_headers()).unwrap_or_default())
|
||||
.write_rfc5322(
|
||||
(
|
||||
self.server
|
||||
.eval_if(&config.name, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or_else(|| "Mail Delivery Subsystem".to_string())
|
||||
.as_str(),
|
||||
from_addr.as_str(),
|
||||
),
|
||||
rcpt,
|
||||
&self
|
||||
.server
|
||||
.eval_if(&config.subject, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or_else(|| "DKIM Report".to_string()),
|
||||
&mut report,
|
||||
)
|
||||
.ok();
|
||||
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::DkimReport),
|
||||
SpanId = self.data.session_id,
|
||||
From = from_addr.to_string(),
|
||||
To = rcpt.to_string(),
|
||||
);
|
||||
|
||||
// Send report
|
||||
self.server
|
||||
.send_report(
|
||||
&from_addr,
|
||||
[rcpt].into_iter(),
|
||||
report,
|
||||
&config.sign,
|
||||
true,
|
||||
self.data.session_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,695 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use super::AggregateTimestamp;
|
||||
use crate::{
|
||||
core::Session,
|
||||
queue::RecipientDomain,
|
||||
reporting::{index::InternalReportIndex, send::MtaReportSend},
|
||||
};
|
||||
use common::{
|
||||
Server,
|
||||
config::smtp::report::AggregateFrequency,
|
||||
ipc::{DmarcEvent, ToHash},
|
||||
network::SessionStream,
|
||||
};
|
||||
use compact_str::ToCompactString;
|
||||
use mail_auth::{
|
||||
ArcOutput, AuthenticatedMessage, AuthenticationResults, DkimOutput, DkimResult, DmarcOutput,
|
||||
DmarcResult, SpfResult,
|
||||
common::verify::VerifySignature,
|
||||
dkim2::Dkim2Output,
|
||||
dmarc::{self},
|
||||
report::{AuthFailureType, IdentityAlignment, PolicyPublished, Record, SPFDomainScope},
|
||||
};
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::FailureReportingOption,
|
||||
prelude::{ObjectType, Property},
|
||||
structs::{DmarcInternalReport, DmarcReport, DmarcReportRecord, Rate},
|
||||
},
|
||||
types::{EnumImpl, ObjectImpl, datetime::UTCDateTime, map::Map},
|
||||
};
|
||||
use std::{borrow::Cow, future::Future};
|
||||
use store::{
|
||||
SerializeInfallible, U64_LEN, ValueKey,
|
||||
registry::ObjectIdVersioned,
|
||||
write::{BatchBuilder, RegistryClass, ValueClass, assert::AssertValue, key::KeySerializer},
|
||||
};
|
||||
use trc::{AddContext, OutgoingReportEvent};
|
||||
use utils::DomainPart;
|
||||
|
||||
impl<T: SessionStream> Session<T> {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn send_dmarc_report(
|
||||
&self,
|
||||
message: &AuthenticatedMessage<'_>,
|
||||
auth_results: &AuthenticationResults<'_>,
|
||||
rejected: bool,
|
||||
dmarc_output: DmarcOutput,
|
||||
dkim_output: &[DkimOutput<'_>],
|
||||
dkim2_output: Option<&Dkim2Output<'_>>,
|
||||
arc_output: &Option<ArcOutput<'_>>,
|
||||
) {
|
||||
let dmarc_record = dmarc_output.dmarc_record_cloned().unwrap();
|
||||
let config = &self.server.core.smtp.report.dmarc;
|
||||
|
||||
if self
|
||||
.server
|
||||
.is_local_report_domain(dmarc_output.domain(), self.data.session_id)
|
||||
.await
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Send failure report. RFC 9991 Section 2: report generators MUST NOT
|
||||
// honor "ruf" for policy records published with "psd=y".
|
||||
if !matches!(dmarc_record.psd, dmarc::Psd::Yes)
|
||||
&& let (Some(failure_rate), Some(report_options)) = (
|
||||
self.server
|
||||
.eval_if::<Rate, _>(&config.send, self, self.data.session_id)
|
||||
.await,
|
||||
dmarc_output.failure_report(),
|
||||
)
|
||||
{
|
||||
// Verify that any external reporting addresses are authorized
|
||||
let rcpts = match self
|
||||
.server
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.verify_dmarc_report_address(
|
||||
dmarc_output.domain(),
|
||||
dmarc_record.ruf(),
|
||||
Some(&self.server.inner.cache.dns_txt),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Some(rcpts) => {
|
||||
if !rcpts.is_empty() {
|
||||
let mut new_rcpts = Vec::with_capacity(rcpts.len());
|
||||
|
||||
for rcpt in rcpts {
|
||||
if self.throttle_rcpt(rcpt.uri(), &failure_rate, "dmarc").await {
|
||||
new_rcpts.push(rcpt.uri());
|
||||
}
|
||||
}
|
||||
|
||||
new_rcpts
|
||||
} else {
|
||||
if !dmarc_record.ruf().is_empty() {
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::UnauthorizedReportingAddress),
|
||||
SpanId = self.data.session_id,
|
||||
Url = dmarc_record
|
||||
.ruf()
|
||||
.iter()
|
||||
.map(|u| trc::Value::String(u.uri().to_compact_string()))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
None => {
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::ReportingAddressValidationError),
|
||||
SpanId = self.data.session_id,
|
||||
Url = dmarc_record
|
||||
.ruf()
|
||||
.iter()
|
||||
.map(|u| trc::Value::String(u.uri().to_compact_string()))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
// Throttle recipient
|
||||
if !rcpts.is_empty() {
|
||||
let mut report = Vec::with_capacity(128);
|
||||
let from_addr = self
|
||||
.server
|
||||
.eval_if(&config.address, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or_else(|| "MAILER-DAEMON@localhost".to_compact_string());
|
||||
let mut auth_failure = self
|
||||
.new_auth_failure(AuthFailureType::Dmarc, rejected)
|
||||
.with_authentication_results(auth_results.to_string())
|
||||
.with_headers(std::str::from_utf8(message.raw_headers()).unwrap_or_default());
|
||||
|
||||
let dkim_aligned = matches!(dmarc_output.dkim_result(), DmarcResult::Pass);
|
||||
let spf_aligned = matches!(dmarc_output.spf_result(), DmarcResult::Pass);
|
||||
|
||||
// Report the first failed signature
|
||||
if let (
|
||||
dmarc::Report::Dkim
|
||||
| dmarc::Report::DkimSpf
|
||||
| dmarc::Report::All
|
||||
| dmarc::Report::Any,
|
||||
Some(signature),
|
||||
) = (
|
||||
&report_options,
|
||||
if !dkim_aligned {
|
||||
dkim_output
|
||||
.iter()
|
||||
.find_map(|o| {
|
||||
let s = o.signature()?;
|
||||
if !matches!(o.result(), DkimResult::Pass) {
|
||||
Some(s)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.or_else(|| dkim_output.iter().find_map(|o| o.signature()))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
) {
|
||||
auth_failure = auth_failure
|
||||
.with_dkim_domain(signature.domain())
|
||||
.with_dkim_selector(signature.selector())
|
||||
.with_dkim_identity(signature.identity());
|
||||
}
|
||||
|
||||
// Report SPF failure
|
||||
if let (
|
||||
dmarc::Report::Spf
|
||||
| dmarc::Report::DkimSpf
|
||||
| dmarc::Report::All
|
||||
| dmarc::Report::Any,
|
||||
Some(output),
|
||||
) = (
|
||||
&report_options,
|
||||
if !spf_aligned {
|
||||
self.data
|
||||
.spf_ehlo
|
||||
.as_ref()
|
||||
.and_then(|s| {
|
||||
if s.result() != SpfResult::Pass {
|
||||
s.into()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.or_else(|| {
|
||||
self.data.spf_mail_from.as_ref().and_then(|s| {
|
||||
if s.result() != SpfResult::Pass {
|
||||
s.into()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
.or(self.data.spf_mail_from.as_ref())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
) {
|
||||
auth_failure =
|
||||
auth_failure.with_spf_dns(format!("txt : {} : v=SPF1", output.domain()));
|
||||
// TODO use DNS record
|
||||
}
|
||||
|
||||
auth_failure
|
||||
.with_identity_alignment(match (dkim_aligned, spf_aligned) {
|
||||
(false, false) => IdentityAlignment::DkimSpf,
|
||||
(false, true) => IdentityAlignment::Dkim,
|
||||
(true, false) => IdentityAlignment::Spf,
|
||||
(true, true) => IdentityAlignment::None,
|
||||
})
|
||||
.write_rfc5322(
|
||||
(
|
||||
self.server
|
||||
.eval_if(&config.name, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or_else(|| "Mail Delivery Subsystem".to_compact_string())
|
||||
.as_str(),
|
||||
from_addr.as_str(),
|
||||
),
|
||||
&rcpts.join(", "),
|
||||
&self
|
||||
.server
|
||||
.eval_if(&config.subject, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or_else(|| "DMARC Report".to_compact_string()),
|
||||
&mut report,
|
||||
)
|
||||
.ok();
|
||||
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::DmarcReport),
|
||||
SpanId = self.data.session_id,
|
||||
From = from_addr.to_string(),
|
||||
To = rcpts
|
||||
.iter()
|
||||
.map(|a| trc::Value::String(a.to_compact_string()))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
|
||||
// Send report
|
||||
self.server
|
||||
.send_report(
|
||||
&from_addr,
|
||||
rcpts.into_iter(),
|
||||
report,
|
||||
&config.sign,
|
||||
true,
|
||||
self.data.session_id,
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::DmarcRateLimited),
|
||||
SpanId = self.data.session_id,
|
||||
Limit = vec![
|
||||
trc::Value::from(failure_rate.count),
|
||||
trc::Value::from(failure_rate.period.into_inner())
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Send aggregate reports
|
||||
let interval = self
|
||||
.server
|
||||
.eval_if(
|
||||
&self.server.core.smtp.report.dmarc_aggregate.send,
|
||||
self,
|
||||
self.data.session_id,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(AggregateFrequency::Never);
|
||||
|
||||
if matches!(interval, AggregateFrequency::Never) || dmarc_record.rua().is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Report the same identifier forms that were used for alignment
|
||||
let message_from = message.from();
|
||||
let header_from = message_from.domain_part();
|
||||
let header_from = header_from
|
||||
.to_ascii_domain()
|
||||
.unwrap_or(Cow::Borrowed(header_from));
|
||||
let envelope_from = self
|
||||
.data
|
||||
.mail_from
|
||||
.as_ref()
|
||||
.map(|mf| mf.domain.as_str())
|
||||
.unwrap_or_else(|| self.data.helo_domain.as_str());
|
||||
let envelope_from = envelope_from
|
||||
.to_ascii_domain()
|
||||
.unwrap_or(Cow::Borrowed(envelope_from));
|
||||
|
||||
// Create DMARC report record
|
||||
let mut report_record = Record::new()
|
||||
.with_dmarc_output(&dmarc_output)
|
||||
.with_dkim_output(dkim_output)
|
||||
.with_source_ip(self.data.remote_ip)
|
||||
.with_header_from(header_from.as_ref())
|
||||
.with_envelope_from(envelope_from.as_ref());
|
||||
if let Some(dkim2_output) = dkim2_output {
|
||||
report_record = report_record.with_dkim2_output(dkim2_output);
|
||||
}
|
||||
if let Some(spf_ehlo) = &self.data.spf_ehlo {
|
||||
report_record = report_record.with_spf_output(spf_ehlo, SPFDomainScope::Helo);
|
||||
}
|
||||
if let Some(spf_mail_from) = &self.data.spf_mail_from {
|
||||
report_record = report_record.with_spf_output(spf_mail_from, SPFDomainScope::MailFrom);
|
||||
}
|
||||
if let Some(arc_output) = arc_output {
|
||||
report_record = report_record.with_arc_output(arc_output);
|
||||
}
|
||||
|
||||
// Submit DMARC report event
|
||||
self.server
|
||||
.schedule_report(DmarcEvent {
|
||||
domain: dmarc_output.into_domain(),
|
||||
report_record,
|
||||
dmarc_record,
|
||||
interval,
|
||||
span_id: self.data.session_id,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
pub trait DmarcReporting: Sync + Send {
|
||||
fn send_dmarc_aggregate_report(
|
||||
&self,
|
||||
report_id: u64,
|
||||
) -> impl Future<Output = trc::Result<()>> + Send;
|
||||
fn schedule_dmarc(&self, event: Box<DmarcEvent>) -> impl Future<Output = ()> + Send;
|
||||
}
|
||||
|
||||
impl DmarcReporting for Server {
|
||||
async fn send_dmarc_aggregate_report(&self, item_id: u64) -> trc::Result<()> {
|
||||
let object_id = ObjectType::DmarcInternalReport.to_id();
|
||||
let key = ValueClass::Registry(RegistryClass::Item { object_id, item_id });
|
||||
|
||||
let Some(report) = self
|
||||
.store()
|
||||
.get_value::<DmarcInternalReport>(ValueKey::from(key.clone()))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// Delete report
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.clear(key).clear(RegistryClass::PrimaryKey {
|
||||
object_id: object_id.into(),
|
||||
index_id: Property::Domain.to_id(),
|
||||
key: KeySerializer::new(report.domain.len() + U64_LEN)
|
||||
.write(&report.domain)
|
||||
.write(report.policy_identifier)
|
||||
.finalize(),
|
||||
});
|
||||
self.store()
|
||||
.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
let span_id = self.inner.data.span_id_gen.generate();
|
||||
let event_from = report.report.date_range_begin.timestamp() as u64;
|
||||
let event_to = report.report.date_range_end.timestamp() as u64;
|
||||
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::DmarcAggregateReport),
|
||||
SpanId = span_id,
|
||||
ReportId = event_from,
|
||||
Domain = report.domain.clone(),
|
||||
RangeFrom = trc::Value::Timestamp(event_from),
|
||||
RangeTo = trc::Value::Timestamp(event_to),
|
||||
);
|
||||
|
||||
// Verify external reporting addresses
|
||||
let rua = match self
|
||||
.core
|
||||
.smtp
|
||||
.resolvers
|
||||
.dns
|
||||
.verify_dmarc_report_address(
|
||||
&report.domain,
|
||||
report.rua.as_slice(),
|
||||
Some(&self.inner.cache.dns_txt),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Some(rcpts) => {
|
||||
if !rcpts.is_empty() {
|
||||
rcpts
|
||||
} else {
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::UnauthorizedReportingAddress),
|
||||
SpanId = span_id,
|
||||
Url = report
|
||||
.rua
|
||||
.into_iter()
|
||||
.map(|u| trc::Value::String(u.into()))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
None => {
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::ReportingAddressValidationError),
|
||||
SpanId = span_id,
|
||||
Url = report
|
||||
.rua
|
||||
.into_iter()
|
||||
.map(|u| trc::Value::String(u.into()))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
// Serialize report
|
||||
let config = &self.core.smtp.report.dmarc_aggregate;
|
||||
let from_addr = self
|
||||
.eval_if(
|
||||
&config.address,
|
||||
&RecipientDomain::new(report.domain.as_str()),
|
||||
span_id,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|| "MAILER-DAEMON@localhost".to_compact_string());
|
||||
let mut message = Vec::with_capacity(2048);
|
||||
let _ = mail_auth::report::Report::from(report.report).write_rfc5322(
|
||||
&self
|
||||
.eval_if(
|
||||
&self.core.smtp.report.submitter,
|
||||
&RecipientDomain::new(report.domain.as_str()),
|
||||
span_id,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|| "localhost".to_compact_string()),
|
||||
(
|
||||
self.eval_if(
|
||||
&config.name,
|
||||
&RecipientDomain::new(report.domain.as_str()),
|
||||
span_id,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|| "Mail Delivery Subsystem".to_compact_string())
|
||||
.as_str(),
|
||||
from_addr.as_str(),
|
||||
),
|
||||
rua.iter().map(|a| a.as_str()),
|
||||
&mut message,
|
||||
);
|
||||
|
||||
// Send report
|
||||
self.send_report(
|
||||
&from_addr,
|
||||
rua.iter(),
|
||||
message,
|
||||
&config.sign,
|
||||
false,
|
||||
span_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn schedule_dmarc(&self, event: Box<DmarcEvent>) {
|
||||
let object_id = ObjectType::DmarcInternalReport.to_id();
|
||||
let policy_hash = event.dmarc_record.to_hash();
|
||||
let pk = ValueClass::Registry(RegistryClass::PrimaryKey {
|
||||
object_id: object_id.into(),
|
||||
index_id: Property::Domain.to_id(),
|
||||
key: KeySerializer::new(event.domain.len() + U64_LEN)
|
||||
.write(&event.domain)
|
||||
.write(policy_hash)
|
||||
.finalize(),
|
||||
});
|
||||
let mut rety_count = 0;
|
||||
|
||||
loop {
|
||||
// Find the report by domain name
|
||||
let mut batch = BatchBuilder::new();
|
||||
let report = match self
|
||||
.store()
|
||||
.get_value::<ObjectIdVersioned>(ValueKey::from(pk.clone()))
|
||||
.await
|
||||
{
|
||||
Ok(Some(object_id_v)) => {
|
||||
match self
|
||||
.store()
|
||||
.get_value::<DmarcInternalReport>(ValueKey::from(ValueClass::Registry(
|
||||
RegistryClass::Item {
|
||||
object_id,
|
||||
item_id: object_id_v.object_id.id().id(),
|
||||
},
|
||||
)))
|
||||
.await
|
||||
{
|
||||
Ok(Some(report)) => Some((object_id_v, report)),
|
||||
Ok(None) => {
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::NotFound),
|
||||
Id = object_id_v.object_id.id().id(),
|
||||
CausedBy = trc::location!(),
|
||||
Details = "Failed to find DMARC report for domain"
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
trc::error!(
|
||||
err.caused_by(trc::location!())
|
||||
.details("Failed to query registry for DMARC report")
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => None,
|
||||
Err(err) => {
|
||||
trc::error!(
|
||||
err.caused_by(trc::location!())
|
||||
.details("Failed to query registry for DMARC report")
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Create report if missing
|
||||
let config = &self.core.smtp.report.dmarc_aggregate;
|
||||
let (item_id, mut report) = if let Some((mut object_id_v, report)) = report {
|
||||
batch.assert_value(pk.clone(), AssertValue::U32(object_id_v.version));
|
||||
object_id_v.version += 1;
|
||||
batch.set(pk.clone(), object_id_v.serialize());
|
||||
|
||||
(object_id_v.object_id.id().id(), report)
|
||||
} else {
|
||||
let item_id = self.inner.data.queue_id_gen.generate();
|
||||
let date_range_begin = UTCDateTime::now();
|
||||
let date_range_end = UTCDateTime::from_timestamp(
|
||||
date_range_begin.timestamp() + event.interval.as_secs() as i64,
|
||||
);
|
||||
let policy =
|
||||
PolicyPublished::from_record(event.domain.clone(), &event.dmarc_record);
|
||||
|
||||
let report = DmarcInternalReport {
|
||||
created_at: date_range_begin,
|
||||
deliver_at: date_range_end,
|
||||
domain: event.domain.clone(),
|
||||
report: DmarcReport {
|
||||
report_id: format!("{}_{policy_hash}", date_range_begin.timestamp()),
|
||||
date_range_begin,
|
||||
date_range_end,
|
||||
email: self
|
||||
.eval_if(
|
||||
&config.address,
|
||||
&RecipientDomain::new(event.domain.as_str()),
|
||||
event.span_id,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|| "MAILER-DAEMON@localhost".to_string()),
|
||||
extra_contact_info: self
|
||||
.eval_if::<String, _>(
|
||||
&config.contact_info,
|
||||
&RecipientDomain::new(event.domain.as_str()),
|
||||
event.span_id,
|
||||
)
|
||||
.await,
|
||||
org_name: self
|
||||
.eval_if::<String, _>(
|
||||
&config.org_name,
|
||||
&RecipientDomain::new(event.domain.as_str()),
|
||||
event.span_id,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default(),
|
||||
policy_adkim: policy.adkim.into(),
|
||||
policy_aspf: policy.aspf.into(),
|
||||
policy_disposition: policy.p.into(),
|
||||
policy_domain: policy.domain,
|
||||
policy_failure_reporting_options: match event.dmarc_record.fo {
|
||||
dmarc::Report::All => vec![FailureReportingOption::All],
|
||||
dmarc::Report::Any => vec![FailureReportingOption::Any],
|
||||
dmarc::Report::Dkim => vec![FailureReportingOption::DkimFailure],
|
||||
dmarc::Report::Spf => vec![FailureReportingOption::SpfFailure],
|
||||
dmarc::Report::DkimSpf => vec![
|
||||
FailureReportingOption::DkimFailure,
|
||||
FailureReportingOption::SpfFailure,
|
||||
],
|
||||
}
|
||||
.into(),
|
||||
policy_subdomain_disposition: policy.sp.into(),
|
||||
policy_np: policy.np.into(),
|
||||
policy_discovery_method: policy.discovery_method.into(),
|
||||
policy_testing_mode: policy.testing,
|
||||
policy_version: None,
|
||||
version: 1.0.into(),
|
||||
..Default::default()
|
||||
},
|
||||
policy_identifier: policy_hash,
|
||||
rua: Map::new(
|
||||
event
|
||||
.dmarc_record
|
||||
.rua()
|
||||
.iter()
|
||||
.map(|u| u.uri.clone())
|
||||
.collect(),
|
||||
),
|
||||
};
|
||||
|
||||
report.write_ops(&mut batch, item_id, true);
|
||||
|
||||
(item_id, report)
|
||||
};
|
||||
|
||||
// Add record
|
||||
let mut record = DmarcReportRecord::from(event.report_record.clone());
|
||||
if let Some(idx) = report
|
||||
.report
|
||||
.records
|
||||
.0
|
||||
.inner
|
||||
.iter()
|
||||
.position(|d| d.value.eq_except_count(&record))
|
||||
{
|
||||
report.report.records.0.inner[idx].value.count += 1;
|
||||
} else {
|
||||
record.count = 1;
|
||||
report.report.records.push(record);
|
||||
}
|
||||
|
||||
// Write entry
|
||||
let report_bytes = report.to_pickled_vec();
|
||||
let max_report_size = self
|
||||
.eval_if(
|
||||
&config.max_size,
|
||||
&RecipientDomain::new(&event.domain),
|
||||
event.span_id,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(5 * 1024 * 1024);
|
||||
if max_report_size != 0 && report_bytes.len() > max_report_size {
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::MaxSizeExceeded),
|
||||
SpanId = event.span_id,
|
||||
Domain = event.domain.clone(),
|
||||
Details = report_bytes.len(),
|
||||
Limit = max_report_size,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
batch.set(
|
||||
ValueClass::Registry(RegistryClass::Item { object_id, item_id }),
|
||||
report_bytes,
|
||||
);
|
||||
|
||||
match self.core.storage.data.write(batch.build_all()).await {
|
||||
Ok(_) => {
|
||||
break;
|
||||
}
|
||||
Err(err) => {
|
||||
if err.is_assertion_failure() && rety_count < 3 {
|
||||
rety_count += 1;
|
||||
continue;
|
||||
}
|
||||
trc::error!(
|
||||
err.caused_by(trc::location!())
|
||||
.details("Failed to write DMARC report")
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::core::Session;
|
||||
use ahash::AHashMap;
|
||||
use common::USER_AGENT;
|
||||
use mail_auth::report::{
|
||||
ActionDisposition, AuthFailureType, DeliveryResult, DmarcResult, Feedback, FeedbackType,
|
||||
Report, tlsrpt::TlsReport,
|
||||
};
|
||||
use std::{collections::hash_map::Entry, time::SystemTime};
|
||||
use store::write::now;
|
||||
use tokio::io::{AsyncRead, AsyncWrite};
|
||||
use trc::IncomingReportEvent;
|
||||
|
||||
impl<T: AsyncWrite + AsyncRead + Unpin> Session<T> {
|
||||
pub fn new_auth_failure(&self, ft: AuthFailureType, rejected: bool) -> Feedback<'_> {
|
||||
Feedback::new(FeedbackType::AuthFailure)
|
||||
.with_auth_failure(ft)
|
||||
.with_arrival_date(
|
||||
SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs()) as i64,
|
||||
)
|
||||
.with_source_ip(self.data.remote_ip)
|
||||
.with_reporting_mta(&self.hostname)
|
||||
.with_user_agent(USER_AGENT)
|
||||
.with_delivery_result(if rejected {
|
||||
DeliveryResult::Reject
|
||||
} else {
|
||||
DeliveryResult::Unspecified
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_report(&self) -> bool {
|
||||
let analysis = &self.server.core.smtp.report.analysis;
|
||||
|
||||
self.data
|
||||
.rcpt_to
|
||||
.iter()
|
||||
.any(|addr| analysis.is_report_address(addr.report_address()))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) trait LogReport {
|
||||
fn log(&self);
|
||||
}
|
||||
|
||||
impl LogReport for Report {
|
||||
fn log(&self) {
|
||||
let mut dmarc_pass = 0;
|
||||
let mut dmarc_quarantine = 0;
|
||||
let mut dmarc_reject = 0;
|
||||
let mut dmarc_none = 0;
|
||||
let mut dkim_pass = 0;
|
||||
let mut dkim_fail = 0;
|
||||
let mut dkim_none = 0;
|
||||
let mut spf_pass = 0;
|
||||
let mut spf_fail = 0;
|
||||
let mut spf_none = 0;
|
||||
|
||||
for record in self.records() {
|
||||
let count = std::cmp::min(record.count(), 1);
|
||||
|
||||
match record.action_disposition() {
|
||||
ActionDisposition::Pass => {
|
||||
dmarc_pass += count;
|
||||
}
|
||||
ActionDisposition::Quarantine => {
|
||||
dmarc_quarantine += count;
|
||||
}
|
||||
ActionDisposition::Reject => {
|
||||
dmarc_reject += count;
|
||||
}
|
||||
ActionDisposition::None | ActionDisposition::Unspecified => {
|
||||
dmarc_none += count;
|
||||
}
|
||||
}
|
||||
match record.dmarc_dkim_result() {
|
||||
DmarcResult::Pass => {
|
||||
dkim_pass += count;
|
||||
}
|
||||
DmarcResult::Fail => {
|
||||
dkim_fail += count;
|
||||
}
|
||||
DmarcResult::Unspecified => {
|
||||
dkim_none += count;
|
||||
}
|
||||
}
|
||||
match record.dmarc_spf_result() {
|
||||
DmarcResult::Pass => {
|
||||
spf_pass += count;
|
||||
}
|
||||
DmarcResult::Fail => {
|
||||
spf_fail += count;
|
||||
}
|
||||
DmarcResult::Unspecified => {
|
||||
spf_none += count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trc::event!(
|
||||
IncomingReport(
|
||||
if (dmarc_reject + dmarc_quarantine + dkim_fail + spf_fail) > 0 {
|
||||
IncomingReportEvent::DmarcReportWithWarnings
|
||||
} else {
|
||||
IncomingReportEvent::DmarcReport
|
||||
}
|
||||
),
|
||||
RangeFrom = trc::Value::Timestamp(self.date_range_begin()),
|
||||
RangeTo = trc::Value::Timestamp(self.date_range_end()),
|
||||
Domain = self.domain().to_string(),
|
||||
From = self.email().to_string(),
|
||||
Id = self.report_id().to_string(),
|
||||
DmarcPass = dmarc_pass,
|
||||
DmarcQuarantine = dmarc_quarantine,
|
||||
DmarcReject = dmarc_reject,
|
||||
DmarcNone = dmarc_none,
|
||||
DkimPass = dkim_pass,
|
||||
DkimFail = dkim_fail,
|
||||
DkimNone = dkim_none,
|
||||
SpfPass = spf_pass,
|
||||
SpfFail = spf_fail,
|
||||
SpfNone = spf_none,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl LogReport for TlsReport {
|
||||
fn log(&self) {
|
||||
for policy in self.policies.iter().take(5) {
|
||||
let mut details = AHashMap::with_capacity(policy.failure_details.len());
|
||||
for failure in &policy.failure_details {
|
||||
let num_failures = std::cmp::min(1, failure.failed_session_count);
|
||||
match details.entry(failure.result_type) {
|
||||
Entry::Occupied(mut e) => {
|
||||
*e.get_mut() += num_failures;
|
||||
}
|
||||
Entry::Vacant(e) => {
|
||||
e.insert(num_failures);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trc::event!(
|
||||
IncomingReport(if policy.summary.total_failure > 0 {
|
||||
IncomingReportEvent::TlsReportWithWarnings
|
||||
} else {
|
||||
IncomingReportEvent::TlsReport
|
||||
}),
|
||||
RangeFrom =
|
||||
trc::Value::Timestamp(self.date_range.start_datetime.to_timestamp() as u64),
|
||||
RangeTo = trc::Value::Timestamp(self.date_range.end_datetime.to_timestamp() as u64),
|
||||
Domain = policy.policy.policy_domain.clone(),
|
||||
From = self.contact_info.as_deref().unwrap_or_default().to_string(),
|
||||
Id = self.report_id.clone(),
|
||||
Policy = format!("{:?}", policy.policy.policy_type),
|
||||
TotalSuccesses = policy.summary.total_success,
|
||||
TotalFailures = policy.summary.total_failure,
|
||||
Details = format!("{details:?}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LogReport for Feedback<'_> {
|
||||
fn log(&self) {
|
||||
trc::event!(
|
||||
IncomingReport(match self.feedback_type() {
|
||||
mail_auth::report::FeedbackType::Abuse => IncomingReportEvent::AbuseReport,
|
||||
mail_auth::report::FeedbackType::AuthFailure =>
|
||||
IncomingReportEvent::AuthFailureReport,
|
||||
mail_auth::report::FeedbackType::Fraud => IncomingReportEvent::FraudReport,
|
||||
mail_auth::report::FeedbackType::NotSpam => IncomingReportEvent::NotSpamReport,
|
||||
mail_auth::report::FeedbackType::Other => IncomingReportEvent::OtherReport,
|
||||
mail_auth::report::FeedbackType::Virus => IncomingReportEvent::VirusReport,
|
||||
}),
|
||||
RangeFrom = trc::Value::Timestamp(
|
||||
self.arrival_date()
|
||||
.map(|d| d as u64)
|
||||
.unwrap_or_else(|| { now() })
|
||||
),
|
||||
Domain = self
|
||||
.reported_domain()
|
||||
.iter()
|
||||
.map(|d| trc::Value::String(d.as_ref().into()))
|
||||
.collect::<Vec<_>>(),
|
||||
Hostname = self.reporting_mta().map(|d| trc::Value::String(d.into())),
|
||||
Url = self
|
||||
.reported_uri()
|
||||
.iter()
|
||||
.map(|d| trc::Value::String(d.as_ref().into()))
|
||||
.collect::<Vec<_>>(),
|
||||
RemoteIp = self.source_ip(),
|
||||
Total = self.incidents(),
|
||||
Result = format!("{:?}", self.delivery_result()),
|
||||
Details = self
|
||||
.authentication_results()
|
||||
.iter()
|
||||
.map(|d| trc::Value::String(d.as_ref().into()))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::DmarcActionDisposition,
|
||||
prelude::{ObjectType, Property},
|
||||
structs::{
|
||||
ArfExternalReport, DmarcExternalReport, DmarcInternalReport, Task, TaskDmarcReport,
|
||||
TaskStatus, TaskTlsReport, TlsExternalReport, TlsInternalReport,
|
||||
},
|
||||
},
|
||||
types::{
|
||||
EnumImpl, ObjectImpl,
|
||||
datetime::UTCDateTime,
|
||||
id::ObjectId,
|
||||
index::{IndexBuilder, IndexValue},
|
||||
},
|
||||
};
|
||||
use store::{
|
||||
SerializeInfallible, U64_LEN,
|
||||
registry::ObjectIdVersioned,
|
||||
write::{
|
||||
BatchBuilder, RegistryClass, TaskQueueClass, ValueClass, assert::AssertValue,
|
||||
key::KeySerializer,
|
||||
},
|
||||
xxhash_rust::xxh3::Xxh3,
|
||||
};
|
||||
use types::id::Id;
|
||||
|
||||
pub trait InternalReportIndex: ObjectImpl {
|
||||
fn deliver_at(&self) -> UTCDateTime;
|
||||
|
||||
fn set_deliver_at(&mut self, at: UTCDateTime);
|
||||
|
||||
fn task(&self, item_id: u64) -> Task;
|
||||
|
||||
fn primary_key(&self) -> ValueClass;
|
||||
|
||||
fn reschedule_ops(
|
||||
&mut self,
|
||||
batch: &mut BatchBuilder,
|
||||
item_id: u64,
|
||||
revision: u64,
|
||||
at: UTCDateTime,
|
||||
) {
|
||||
let current_deliver_at = self.deliver_at();
|
||||
|
||||
if current_deliver_at != at {
|
||||
let object = Self::OBJECT;
|
||||
let object_id = object.to_id();
|
||||
let key = ValueClass::Registry(RegistryClass::Item { object_id, item_id });
|
||||
|
||||
self.set_deliver_at(at);
|
||||
|
||||
batch
|
||||
.assert_value(key.clone(), AssertValue::Hash(revision))
|
||||
.clear(ValueClass::TaskQueue(TaskQueueClass::Due {
|
||||
id: item_id,
|
||||
due: current_deliver_at.timestamp() as u64,
|
||||
}))
|
||||
.set(
|
||||
ValueClass::TaskQueue(TaskQueueClass::Due {
|
||||
id: item_id,
|
||||
due: at.timestamp() as u64,
|
||||
}),
|
||||
object_id.serialize(),
|
||||
)
|
||||
.set(key, self.to_pickled_vec());
|
||||
}
|
||||
}
|
||||
|
||||
fn write_ops(&self, batch: &mut BatchBuilder, item_id: u64, is_set: bool) {
|
||||
let object = Self::OBJECT;
|
||||
let object_id = object.to_id();
|
||||
let pk = self.primary_key();
|
||||
|
||||
if is_set {
|
||||
batch
|
||||
.assert_value(pk.clone(), ())
|
||||
.set(
|
||||
pk,
|
||||
ObjectIdVersioned {
|
||||
object_id: ObjectId::new(object, item_id.into()),
|
||||
version: 0,
|
||||
}
|
||||
.serialize(),
|
||||
)
|
||||
.schedule_task_with_id(item_id, self.task(item_id));
|
||||
} else {
|
||||
batch
|
||||
.clear(ValueClass::Registry(RegistryClass::Item {
|
||||
object_id,
|
||||
item_id,
|
||||
}))
|
||||
.clear(pk)
|
||||
.clear(ValueClass::TaskQueue(TaskQueueClass::Task { id: item_id }))
|
||||
.clear(ValueClass::TaskQueue(TaskQueueClass::Due {
|
||||
id: item_id,
|
||||
due: self.deliver_at().timestamp() as u64,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ExternalReportIndex: ObjectImpl {
|
||||
fn text(&self) -> impl Iterator<Item = &str>;
|
||||
|
||||
fn tenant_id(&self) -> Option<Id>;
|
||||
|
||||
fn expires_at(&self) -> u64;
|
||||
|
||||
fn domains(&self) -> impl Iterator<Item = &str>;
|
||||
|
||||
fn success_fail_count(&self) -> (u64, u64);
|
||||
|
||||
fn unique_key(&self) -> Option<[u8; 16]>;
|
||||
|
||||
fn write_ops(&self, batch: &mut BatchBuilder, item_id: u64, is_set: bool) {
|
||||
let object_id = Self::OBJECT.to_id();
|
||||
let mut index_builder = IndexBuilder::default();
|
||||
for text in self.text() {
|
||||
index_builder.text(Property::Text, text);
|
||||
}
|
||||
|
||||
if let Some(tenant_id) = self.tenant_id() {
|
||||
index_builder.search(Property::MemberTenantId, tenant_id.id());
|
||||
}
|
||||
|
||||
let (success_count, fail_count) = self.success_fail_count();
|
||||
index_builder.search(Property::TotalSuccessfulSessions, success_count);
|
||||
index_builder.search(Property::TotalFailedSessions, fail_count);
|
||||
|
||||
index_builder.search(Property::ExpiresAt, self.expires_at());
|
||||
|
||||
if let Some(unique_key) = self.unique_key() {
|
||||
index_builder.unique(Property::ReportId, IndexValue::Bytes(unique_key.to_vec()));
|
||||
}
|
||||
|
||||
batch.registry_index(object_id, item_id, index_builder.keys.iter(), is_set);
|
||||
|
||||
let key = ValueClass::Registry(RegistryClass::Item { object_id, item_id });
|
||||
if is_set {
|
||||
batch.set(key, self.to_pickled_vec());
|
||||
} else {
|
||||
batch.clear(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl InternalReportIndex for DmarcInternalReport {
|
||||
fn deliver_at(&self) -> UTCDateTime {
|
||||
self.deliver_at
|
||||
}
|
||||
|
||||
fn set_deliver_at(&mut self, at: UTCDateTime) {
|
||||
self.deliver_at = at;
|
||||
}
|
||||
|
||||
fn task(&self, item_id: u64) -> Task {
|
||||
Task::DmarcReport(TaskDmarcReport {
|
||||
report_id: item_id.into(),
|
||||
status: TaskStatus::at(self.deliver_at.timestamp()),
|
||||
})
|
||||
}
|
||||
|
||||
fn primary_key(&self) -> ValueClass {
|
||||
ValueClass::Registry(RegistryClass::PrimaryKey {
|
||||
object_id: ObjectType::DmarcInternalReport.to_id().into(),
|
||||
index_id: Property::Domain.to_id(),
|
||||
key: KeySerializer::new(self.domain.len() + U64_LEN)
|
||||
.write(self.domain.as_str())
|
||||
.write(self.policy_identifier)
|
||||
.finalize(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl InternalReportIndex for TlsInternalReport {
|
||||
fn deliver_at(&self) -> UTCDateTime {
|
||||
self.deliver_at
|
||||
}
|
||||
|
||||
fn set_deliver_at(&mut self, at: UTCDateTime) {
|
||||
self.deliver_at = at;
|
||||
}
|
||||
|
||||
fn task(&self, item_id: u64) -> Task {
|
||||
Task::TlsReport(TaskTlsReport {
|
||||
report_id: item_id.into(),
|
||||
status: TaskStatus::at(self.deliver_at.timestamp()),
|
||||
})
|
||||
}
|
||||
|
||||
fn primary_key(&self) -> ValueClass {
|
||||
ValueClass::Registry(RegistryClass::PrimaryKey {
|
||||
object_id: ObjectType::TlsInternalReport.to_id().into(),
|
||||
index_id: Property::Domain.to_id(),
|
||||
key: self.domain.as_bytes().to_vec(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ExternalReportIndex for ArfExternalReport {
|
||||
fn domains(&self) -> impl Iterator<Item = &str> {
|
||||
let report = &self.report;
|
||||
|
||||
report
|
||||
.reported_domains
|
||||
.iter()
|
||||
.filter_map(|s| non_empty(s))
|
||||
.chain(
|
||||
[report.dkim_domain.as_deref()]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(non_empty),
|
||||
)
|
||||
}
|
||||
|
||||
fn text(&self) -> impl Iterator<Item = &str> {
|
||||
let report = &self.report;
|
||||
|
||||
report
|
||||
.reported_domains
|
||||
.iter()
|
||||
.filter_map(|s| non_empty(s))
|
||||
.chain(
|
||||
[
|
||||
report.dkim_domain.as_deref(),
|
||||
report.reporting_mta.as_deref(),
|
||||
report.original_mail_from.as_deref(),
|
||||
report.original_rcpt_to.as_deref(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(non_empty),
|
||||
)
|
||||
.chain(non_empty(&self.from))
|
||||
}
|
||||
|
||||
fn tenant_id(&self) -> Option<Id> {
|
||||
self.member_tenant_id
|
||||
}
|
||||
|
||||
fn expires_at(&self) -> u64 {
|
||||
self.expires_at.timestamp() as u64
|
||||
}
|
||||
|
||||
fn success_fail_count(&self) -> (u64, u64) {
|
||||
(self.report.incidents, 0)
|
||||
}
|
||||
|
||||
fn unique_key(&self) -> Option<[u8; 16]> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl ExternalReportIndex for DmarcExternalReport {
|
||||
fn domains(&self) -> impl Iterator<Item = &str> {
|
||||
let report = &self.report;
|
||||
|
||||
non_empty(&report.policy_domain)
|
||||
.into_iter()
|
||||
.filter_map(non_empty)
|
||||
}
|
||||
|
||||
fn text(&self) -> impl Iterator<Item = &str> {
|
||||
let report = &self.report;
|
||||
|
||||
non_empty(&report.email)
|
||||
.into_iter()
|
||||
.filter_map(non_empty)
|
||||
.chain(non_empty(&report.policy_domain))
|
||||
.chain(report.records.iter().flat_map(|r| {
|
||||
r.envelope_to
|
||||
.as_deref()
|
||||
.into_iter()
|
||||
.filter_map(non_empty)
|
||||
.chain(non_empty(&r.envelope_from))
|
||||
.chain(non_empty(&r.header_from))
|
||||
.chain(r.dkim_results.iter().filter_map(|d| non_empty(&d.domain)))
|
||||
.chain(r.spf_results.iter().filter_map(|s| non_empty(&s.domain)))
|
||||
}))
|
||||
.chain(non_empty(&self.from))
|
||||
}
|
||||
|
||||
fn tenant_id(&self) -> Option<Id> {
|
||||
self.member_tenant_id
|
||||
}
|
||||
|
||||
fn expires_at(&self) -> u64 {
|
||||
self.expires_at.timestamp() as u64
|
||||
}
|
||||
|
||||
fn success_fail_count(&self) -> (u64, u64) {
|
||||
let mut success_count = 0;
|
||||
let mut fail_count = 0;
|
||||
|
||||
for record in self.report.records.iter() {
|
||||
if record.evaluated_disposition == DmarcActionDisposition::Pass {
|
||||
success_count += std::cmp::min(record.count, 1);
|
||||
} else {
|
||||
fail_count += std::cmp::min(record.count, 1);
|
||||
}
|
||||
}
|
||||
|
||||
(success_count, fail_count)
|
||||
}
|
||||
|
||||
fn unique_key(&self) -> Option<[u8; 16]> {
|
||||
let report = &self.report;
|
||||
|
||||
Some(report_key(
|
||||
[
|
||||
report.org_name.as_str(),
|
||||
report.policy_domain.as_str(),
|
||||
report.report_id.as_str(),
|
||||
],
|
||||
report.date_range_begin,
|
||||
report.date_range_end,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl ExternalReportIndex for TlsExternalReport {
|
||||
fn domains(&self) -> impl Iterator<Item = &str> {
|
||||
let report = &self.report;
|
||||
|
||||
report
|
||||
.policies
|
||||
.iter()
|
||||
.flat_map(|p| non_empty(&p.policy_domain).into_iter())
|
||||
}
|
||||
|
||||
fn text(&self) -> impl Iterator<Item = &str> {
|
||||
let report = &self.report;
|
||||
|
||||
report
|
||||
.policies
|
||||
.iter()
|
||||
.flat_map(|p| {
|
||||
non_empty(&p.policy_domain)
|
||||
.into_iter()
|
||||
.chain(p.mx_hosts.iter().filter_map(|s| non_empty(s)))
|
||||
.chain(p.failure_details.iter().flat_map(|fd| {
|
||||
non_empty_opt(&fd.receiving_mx_hostname)
|
||||
.into_iter()
|
||||
.chain(non_empty_opt(&fd.receiving_mx_helo))
|
||||
}))
|
||||
})
|
||||
.chain(non_empty(&self.from))
|
||||
}
|
||||
|
||||
fn tenant_id(&self) -> Option<Id> {
|
||||
self.member_tenant_id
|
||||
}
|
||||
|
||||
fn expires_at(&self) -> u64 {
|
||||
self.expires_at.timestamp() as u64
|
||||
}
|
||||
|
||||
fn success_fail_count(&self) -> (u64, u64) {
|
||||
let mut success_count = 0;
|
||||
let mut fail_count = 0;
|
||||
|
||||
for policy in self.report.policies.iter() {
|
||||
success_count += std::cmp::min(policy.total_successful_sessions, 1);
|
||||
fail_count += std::cmp::min(policy.total_failed_sessions, 1);
|
||||
}
|
||||
|
||||
(success_count, fail_count)
|
||||
}
|
||||
|
||||
fn unique_key(&self) -> Option<[u8; 16]> {
|
||||
let report = &self.report;
|
||||
|
||||
Some(report_key(
|
||||
[
|
||||
report.organization_name.as_deref().unwrap_or_default(),
|
||||
report.report_id.as_str(),
|
||||
],
|
||||
report.date_range_start,
|
||||
report.date_range_end,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn report_key<const N: usize>(fields: [&str; N], from: UTCDateTime, to: UTCDateTime) -> [u8; 16] {
|
||||
let mut hasher = Xxh3::new();
|
||||
|
||||
for field in fields {
|
||||
hasher.update(field.as_bytes());
|
||||
hasher.update(&[0u8]);
|
||||
}
|
||||
hasher.update(&(from.timestamp() as u64).to_be_bytes());
|
||||
hasher.update(&(to.timestamp() as u64).to_be_bytes());
|
||||
|
||||
hasher.digest128().to_be_bytes()
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn non_empty(s: &str) -> Option<&str> {
|
||||
if s.is_empty() { None } else { Some(s) }
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn non_empty_opt(s: &Option<String>) -> Option<&str> {
|
||||
s.as_deref().filter(|s| !s.is_empty())
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use common::config::smtp::report::AggregateFrequency;
|
||||
use mail_parser::DateTime;
|
||||
use std::time::SystemTime;
|
||||
|
||||
pub mod analysis;
|
||||
pub mod dkim;
|
||||
pub mod dmarc;
|
||||
pub mod inbound;
|
||||
pub mod index;
|
||||
pub mod scheduler;
|
||||
pub mod send;
|
||||
pub mod spf;
|
||||
pub mod tls;
|
||||
|
||||
pub trait AggregateTimestamp {
|
||||
fn to_timestamp(&self) -> u64;
|
||||
fn to_timestamp_(&self, dt: DateTime) -> u64;
|
||||
fn as_secs(&self) -> u64;
|
||||
fn due(&self) -> u64;
|
||||
}
|
||||
|
||||
impl AggregateTimestamp for AggregateFrequency {
|
||||
fn to_timestamp(&self) -> u64 {
|
||||
self.to_timestamp_(DateTime::from_timestamp(
|
||||
SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs()) as i64,
|
||||
))
|
||||
}
|
||||
|
||||
fn to_timestamp_(&self, mut dt: DateTime) -> u64 {
|
||||
(match self {
|
||||
AggregateFrequency::Hourly => {
|
||||
dt.minute = 0;
|
||||
dt.second = 0;
|
||||
dt.to_timestamp()
|
||||
}
|
||||
AggregateFrequency::Daily => {
|
||||
dt.hour = 0;
|
||||
dt.minute = 0;
|
||||
dt.second = 0;
|
||||
dt.to_timestamp()
|
||||
}
|
||||
AggregateFrequency::Weekly => {
|
||||
let dow = dt.day_of_week();
|
||||
dt.hour = 0;
|
||||
dt.minute = 0;
|
||||
dt.second = 0;
|
||||
dt.to_timestamp() - (86400 * dow as i64)
|
||||
}
|
||||
AggregateFrequency::Never => dt.to_timestamp(),
|
||||
}) as u64
|
||||
}
|
||||
|
||||
fn as_secs(&self) -> u64 {
|
||||
match self {
|
||||
AggregateFrequency::Hourly => 3600,
|
||||
AggregateFrequency::Daily => 86400,
|
||||
AggregateFrequency::Weekly => 7 * 86400,
|
||||
AggregateFrequency::Never => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn due(&self) -> u64 {
|
||||
self.to_timestamp() + self.as_secs()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use super::{dmarc::DmarcReporting, tls::TlsReporting};
|
||||
use common::{BuildServer, Inner, ipc::ReportingEvent};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
pub trait SpawnReport {
|
||||
fn spawn(self, core: Arc<Inner>);
|
||||
}
|
||||
|
||||
impl SpawnReport for mpsc::Receiver<ReportingEvent> {
|
||||
fn spawn(mut self, inner: Arc<Inner>) {
|
||||
tokio::spawn(async move {
|
||||
while let Some(event) = self.recv().await {
|
||||
let server = inner.build_server();
|
||||
match event {
|
||||
ReportingEvent::Dmarc(event) => server.schedule_dmarc(event).await,
|
||||
ReportingEvent::Tls(event) => server.schedule_tls(event).await,
|
||||
ReportingEvent::Stop => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
inbound::dkim::DkimSign,
|
||||
queue::{
|
||||
MessageSource,
|
||||
spool::{QueueParams, SmtpSpool},
|
||||
},
|
||||
};
|
||||
use common::{Server, expr::if_block::IfBlock, ipc::ReportingEvent};
|
||||
|
||||
pub trait MtaReportSend: Sync + Send {
|
||||
fn is_local_report_domain(
|
||||
&self,
|
||||
domain: &str,
|
||||
session_id: u64,
|
||||
) -> impl Future<Output = bool> + Send;
|
||||
|
||||
fn send_report(
|
||||
&self,
|
||||
from_addr: &str,
|
||||
rcpts: impl Iterator<Item = impl AsRef<str> + Sync + Send> + Sync + Send,
|
||||
report: Vec<u8>,
|
||||
sign_config: &IfBlock,
|
||||
deliver_now: bool,
|
||||
parent_session_id: u64,
|
||||
) -> impl Future<Output = ()> + Send;
|
||||
|
||||
fn send_autogenerated(
|
||||
&self,
|
||||
from_addr: impl AsRef<str> + Sync + Send,
|
||||
rcpts: impl Iterator<Item = impl AsRef<str> + Sync + Send> + Sync + Send,
|
||||
raw_message: Vec<u8>,
|
||||
sign_config: Option<&IfBlock>,
|
||||
parent_session_id: u64,
|
||||
) -> impl Future<Output = ()> + Send;
|
||||
|
||||
fn schedule_report(
|
||||
&self,
|
||||
report: impl Into<ReportingEvent> + Sync + Send,
|
||||
) -> impl Future<Output = ()> + Send;
|
||||
}
|
||||
|
||||
impl MtaReportSend for Server {
|
||||
async fn is_local_report_domain(&self, domain: &str, session_id: u64) -> bool {
|
||||
match self.domain(domain).await {
|
||||
Ok(domain) => domain.is_some(),
|
||||
Err(err) => {
|
||||
trc::error!(
|
||||
err.caused_by(trc::location!())
|
||||
.span_id(session_id)
|
||||
.details("Failed to lookup local domain")
|
||||
);
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_report(
|
||||
&self,
|
||||
from_addr: &str,
|
||||
rcpts: impl Iterator<Item = impl AsRef<str> + Sync + Send> + Sync + Send,
|
||||
report: Vec<u8>,
|
||||
sign_config: &IfBlock,
|
||||
deliver_now: bool,
|
||||
parent_session_id: u64,
|
||||
) {
|
||||
// Build message
|
||||
let mut message = self.new_message(from_addr, MessageSource::Report, parent_session_id);
|
||||
for rcpt_ in rcpts {
|
||||
message.add_expanded_recipient(rcpt_.as_ref(), self).await;
|
||||
}
|
||||
|
||||
// Schedule delivery at a random time between now and the next 3 hours
|
||||
if !deliver_now {
|
||||
#[cfg(not(feature = "test_mode"))]
|
||||
{
|
||||
use common::config::smtp::queue::QueueExpiry;
|
||||
use rand::RngExt;
|
||||
|
||||
let delivery_time = rand::rng().random_range(0u64..10800u64);
|
||||
for rcpt in &mut message.message.recipients {
|
||||
rcpt.retry.due += delivery_time;
|
||||
rcpt.notify.due += delivery_time;
|
||||
if let QueueExpiry::Ttl(expires) = &mut rcpt.expires {
|
||||
*expires += delivery_time;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Queue message
|
||||
let dkim_signers = self
|
||||
.eval_signers(sign_config, &message.message, parent_session_id)
|
||||
.await;
|
||||
message
|
||||
.queue(
|
||||
QueueParams::new(&report, parent_session_id, self).with_dkim_signers(dkim_signers),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn send_autogenerated(
|
||||
&self,
|
||||
from_addr: impl AsRef<str> + Sync + Send,
|
||||
rcpts: impl Iterator<Item = impl AsRef<str> + Sync + Send> + Sync + Send,
|
||||
raw_message: Vec<u8>,
|
||||
sign_config: Option<&IfBlock>,
|
||||
parent_session_id: u64,
|
||||
) {
|
||||
// Build message
|
||||
let mut message = self.new_message(
|
||||
from_addr.as_ref(),
|
||||
MessageSource::Autogenerated,
|
||||
parent_session_id,
|
||||
);
|
||||
for rcpt in rcpts {
|
||||
message.add_expanded_recipient(rcpt, self).await;
|
||||
}
|
||||
|
||||
// Queue message
|
||||
let dkim_signers = if let Some(sign_config) = sign_config {
|
||||
self.eval_signers(sign_config, &message.message, parent_session_id)
|
||||
.await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
message
|
||||
.queue(
|
||||
QueueParams::new(&raw_message, parent_session_id, self)
|
||||
.with_dkim_signers(dkim_signers),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn schedule_report(&self, report: impl Into<ReportingEvent> + Sync + Send) {
|
||||
if self.inner.ipc.report_tx.send(report.into()).await.is_err() {
|
||||
trc::event!(
|
||||
Server(trc::ServerEvent::ThreadError),
|
||||
CausedBy = trc::location!(),
|
||||
Details = "Failed to send event to ReportScheduler"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::{core::Session, reporting::send::MtaReportSend};
|
||||
use common::network::SessionStream;
|
||||
use mail_auth::{AuthenticationResults, SpfOutput, report::AuthFailureType};
|
||||
use registry::schema::structs::Rate;
|
||||
use trc::OutgoingReportEvent;
|
||||
|
||||
impl<T: SessionStream> Session<T> {
|
||||
pub async fn send_spf_report(
|
||||
&self,
|
||||
rcpt: &str,
|
||||
rate: &Rate,
|
||||
rejected: bool,
|
||||
output: &SpfOutput,
|
||||
) {
|
||||
// Throttle recipient
|
||||
if !self.throttle_rcpt(rcpt, rate, "spf").await {
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::SpfRateLimited),
|
||||
SpanId = self.data.session_id,
|
||||
To = rcpt.to_string(),
|
||||
Limit = vec![
|
||||
trc::Value::from(rate.count),
|
||||
trc::Value::from(rate.period.into_inner())
|
||||
],
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate report
|
||||
let config = &self.server.core.smtp.report.spf;
|
||||
let from_addr = self
|
||||
.server
|
||||
.eval_if(&config.address, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or_else(|| "MAILER-DAEMON@localhost".to_string());
|
||||
let mut report = Vec::with_capacity(128);
|
||||
self.new_auth_failure(AuthFailureType::Spf, rejected)
|
||||
.with_authentication_results(
|
||||
if let Some(mail_from) = &self.data.mail_from {
|
||||
AuthenticationResults::new(&self.hostname).with_spf_mailfrom_result(
|
||||
output,
|
||||
self.data.remote_ip,
|
||||
&mail_from.address,
|
||||
&self.data.helo_domain,
|
||||
)
|
||||
} else {
|
||||
AuthenticationResults::new(&self.hostname).with_spf_ehlo_result(
|
||||
output,
|
||||
self.data.remote_ip,
|
||||
&self.data.helo_domain,
|
||||
)
|
||||
}
|
||||
.to_string(),
|
||||
)
|
||||
.with_spf_dns(format!("txt : {} : v=SPF1", output.domain())) // TODO use DNS record
|
||||
.write_rfc5322(
|
||||
(
|
||||
self.server
|
||||
.eval_if(&config.name, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or_else(|| "Mailer Daemon".to_string())
|
||||
.as_str(),
|
||||
from_addr.as_str(),
|
||||
),
|
||||
rcpt,
|
||||
&self
|
||||
.server
|
||||
.eval_if(&config.subject, self, self.data.session_id)
|
||||
.await
|
||||
.unwrap_or_else(|| "SPF Report".to_string()),
|
||||
&mut report,
|
||||
)
|
||||
.ok();
|
||||
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::SpfReport),
|
||||
SpanId = self.data.session_id,
|
||||
To = rcpt.to_string(),
|
||||
From = from_addr.to_string(),
|
||||
);
|
||||
|
||||
// Send report
|
||||
self.server
|
||||
.send_report(
|
||||
&from_addr,
|
||||
[rcpt].into_iter(),
|
||||
report,
|
||||
&config.sign,
|
||||
true,
|
||||
self.data.session_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use super::AggregateTimestamp;
|
||||
use crate::{
|
||||
queue::RecipientDomain,
|
||||
reporting::{index::InternalReportIndex, send::MtaReportSend},
|
||||
};
|
||||
use common::{
|
||||
Server, USER_AGENT,
|
||||
config::smtp::{
|
||||
report::AggregateFrequency,
|
||||
resolver::{Mode, MxPattern, TlsaMatching},
|
||||
},
|
||||
ipc::{TlsEvent, ToHash},
|
||||
};
|
||||
use mail_auth::{
|
||||
flate2::{Compression, write::GzEncoder},
|
||||
mta_sts::{ReportUri, TlsRpt},
|
||||
report::tlsrpt::{FailureDetails, PolicyDetails},
|
||||
};
|
||||
use registry::{
|
||||
schema::{
|
||||
enums::TlsPolicyType,
|
||||
prelude::{ObjectType, Property},
|
||||
structs::{TlsFailureDetails, TlsInternalReport, TlsReport, TlsReportPolicy},
|
||||
},
|
||||
types::{EnumImpl, ObjectImpl, datetime::UTCDateTime},
|
||||
};
|
||||
use reqwest::header::CONTENT_TYPE;
|
||||
use std::fmt::Write;
|
||||
use std::{future::Future, sync::Arc, time::Duration};
|
||||
use store::{
|
||||
SerializeInfallible, ValueKey,
|
||||
registry::ObjectIdVersioned,
|
||||
write::{BatchBuilder, RegistryClass, ValueClass, assert::AssertValue},
|
||||
};
|
||||
use trc::{AddContext, OutgoingReportEvent};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TlsRptOptions {
|
||||
pub record: Arc<TlsRpt>,
|
||||
pub interval: AggregateFrequency,
|
||||
}
|
||||
|
||||
#[derive(Debug, rkyv::Serialize, rkyv::Deserialize, rkyv::Archive, serde::Serialize)]
|
||||
pub struct TlsFormat {
|
||||
pub rua: Vec<ReportUri>,
|
||||
pub policy: PolicyDetails,
|
||||
pub records: Vec<Option<FailureDetails>>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "test_mode")]
|
||||
pub static TLS_HTTP_REPORT: parking_lot::Mutex<Vec<u8>> = parking_lot::Mutex::new(Vec::new());
|
||||
|
||||
pub trait TlsReporting: Sync + Send {
|
||||
fn send_tls_aggregate_report(
|
||||
&self,
|
||||
report_id: u64,
|
||||
) -> impl Future<Output = trc::Result<()>> + Send;
|
||||
|
||||
fn schedule_tls(&self, event: Box<TlsEvent>) -> impl Future<Output = ()> + Send;
|
||||
}
|
||||
|
||||
impl TlsReporting for Server {
|
||||
async fn send_tls_aggregate_report(&self, item_id: u64) -> trc::Result<()> {
|
||||
let object_id = ObjectType::TlsInternalReport.to_id();
|
||||
let key = ValueClass::Registry(RegistryClass::Item { object_id, item_id });
|
||||
|
||||
let Some(report) = self
|
||||
.store()
|
||||
.get_value::<TlsInternalReport>(ValueKey::from(key.clone()))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// Delete report
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.clear(key).clear(RegistryClass::PrimaryKey {
|
||||
object_id: object_id.into(),
|
||||
index_id: Property::Domain.to_id(),
|
||||
key: report.domain.as_bytes().to_vec(),
|
||||
});
|
||||
self.core
|
||||
.storage
|
||||
.data
|
||||
.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
let domain_name = report.domain.as_str();
|
||||
let event_from = report.report.date_range_start.timestamp() as u64;
|
||||
let event_to = report.report.date_range_end.timestamp() as u64;
|
||||
let span_id = self.inner.data.span_id_gen.generate();
|
||||
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::TlsAggregate),
|
||||
SpanId = span_id,
|
||||
ReportId = event_from,
|
||||
Domain = domain_name.to_string(),
|
||||
RangeFrom = trc::Value::Timestamp(event_from),
|
||||
RangeTo = trc::Value::Timestamp(event_to),
|
||||
);
|
||||
|
||||
// Generate report
|
||||
let exported_report = mail_auth::report::tlsrpt::TlsReport::from(report.report);
|
||||
let json = exported_report.to_json();
|
||||
let mut e = GzEncoder::new(Vec::with_capacity(json.len()), Compression::default());
|
||||
let json = match std::io::Write::write_all(&mut e, json.as_bytes()).and_then(|_| e.finish())
|
||||
{
|
||||
Ok(report) => report,
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::SubmissionError),
|
||||
SpanId = span_id,
|
||||
Reason = err.to_string(),
|
||||
Details = "Failed to compress report"
|
||||
);
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
// Try delivering report over HTTP
|
||||
for uri in report.http_rua.as_slice() {
|
||||
{
|
||||
#[cfg(feature = "test_mode")]
|
||||
if uri == "https://127.0.0.1/tls" {
|
||||
TLS_HTTP_REPORT.lock().extend_from_slice(&json);
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match self
|
||||
.core
|
||||
.smtp
|
||||
.tls_report_client
|
||||
.post(uri)
|
||||
.timeout(Duration::from_secs(2 * 60))
|
||||
.header(reqwest::header::USER_AGENT, USER_AGENT)
|
||||
.header(CONTENT_TYPE, "application/tlsrpt+gzip")
|
||||
.body(json.to_vec())
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
if response.status().is_success() {
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::HttpSubmission),
|
||||
SpanId = span_id,
|
||||
Url = uri.to_string(),
|
||||
Code = response.status().as_u16(),
|
||||
);
|
||||
|
||||
return Ok(());
|
||||
} else {
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::SubmissionError),
|
||||
SpanId = span_id,
|
||||
Url = uri.to_string(),
|
||||
Code = response.status().as_u16(),
|
||||
Details = "Invalid HTTP response"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::SubmissionError),
|
||||
SpanId = span_id,
|
||||
Url = uri.to_string(),
|
||||
Reason = err.to_string(),
|
||||
Details = "HTTP submission error"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deliver report over SMTP
|
||||
if !report.mail_rua.is_empty() {
|
||||
let config = &self.core.smtp.report.tls;
|
||||
let from_addr = self
|
||||
.eval_if(&config.address, &RecipientDomain::new(domain_name), span_id)
|
||||
.await
|
||||
.unwrap_or_else(|| "MAILER-DAEMON@localhost".to_string());
|
||||
let mut message = Vec::with_capacity(2048);
|
||||
let _ = exported_report.write_rfc5322_from_bytes(
|
||||
domain_name,
|
||||
&self
|
||||
.eval_if(
|
||||
&self.core.smtp.report.submitter,
|
||||
&RecipientDomain::new(domain_name),
|
||||
span_id,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|| "localhost".to_string()),
|
||||
(
|
||||
self.eval_if(&config.name, &RecipientDomain::new(domain_name), span_id)
|
||||
.await
|
||||
.unwrap_or_else(|| "Mail Delivery Subsystem".to_string())
|
||||
.as_str(),
|
||||
from_addr.as_str(),
|
||||
),
|
||||
report.mail_rua.iter().map(|v| v.as_str()),
|
||||
&json,
|
||||
&mut message,
|
||||
);
|
||||
|
||||
// Send report
|
||||
self.send_report(
|
||||
&from_addr,
|
||||
report.mail_rua.iter().map(|v| v.as_str()),
|
||||
message,
|
||||
&config.sign,
|
||||
false,
|
||||
span_id,
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::NoRecipientsFound),
|
||||
SpanId = span_id,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn schedule_tls(&self, event: Box<TlsEvent>) {
|
||||
let object_id = ObjectType::TlsInternalReport.to_id();
|
||||
let pk = ValueClass::Registry(RegistryClass::PrimaryKey {
|
||||
object_id: object_id.into(),
|
||||
index_id: Property::Domain.to_id(),
|
||||
key: event.domain.as_bytes().to_vec(),
|
||||
});
|
||||
let mut rety_count = 0;
|
||||
let policy_hash = event.policy.to_hash();
|
||||
|
||||
loop {
|
||||
// Find the report by domain name
|
||||
let mut batch = BatchBuilder::new();
|
||||
let report = match self
|
||||
.store()
|
||||
.get_value::<ObjectIdVersioned>(ValueKey::from(pk.clone()))
|
||||
.await
|
||||
{
|
||||
Ok(Some(object_id_v)) => {
|
||||
match self
|
||||
.store()
|
||||
.get_value::<TlsInternalReport>(ValueKey::from(ValueClass::Registry(
|
||||
RegistryClass::Item {
|
||||
object_id,
|
||||
item_id: object_id_v.object_id.id().id(),
|
||||
},
|
||||
)))
|
||||
.await
|
||||
{
|
||||
Ok(Some(report)) => Some((object_id_v, report)),
|
||||
Ok(None) => {
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::NotFound),
|
||||
Id = object_id_v.object_id.id().id(),
|
||||
CausedBy = trc::location!(),
|
||||
Details = "Failed to find TLS report for domain"
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
Err(err) => {
|
||||
trc::error!(
|
||||
err.caused_by(trc::location!())
|
||||
.details("Failed to query registry for TLS report")
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => None,
|
||||
Err(err) => {
|
||||
trc::error!(
|
||||
err.caused_by(trc::location!())
|
||||
.details("Failed to query registry for TLS report")
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Create report if missing
|
||||
let config = &self.core.smtp.report.tls;
|
||||
let (item_id, mut report) = if let Some((mut object_id_v, report)) = report {
|
||||
batch.assert_value(pk.clone(), AssertValue::U32(object_id_v.version));
|
||||
object_id_v.version += 1;
|
||||
batch.set(pk.clone(), object_id_v.serialize());
|
||||
|
||||
(object_id_v.object_id.id().id(), report)
|
||||
} else {
|
||||
let item_id = self.inner.data.queue_id_gen.generate();
|
||||
let date_range_start = UTCDateTime::now();
|
||||
let date_range_end = UTCDateTime::from_timestamp(
|
||||
date_range_start.timestamp() + event.interval.as_secs() as i64,
|
||||
);
|
||||
|
||||
let report = TlsInternalReport {
|
||||
created_at: date_range_start,
|
||||
deliver_at: date_range_end,
|
||||
domain: event.domain.clone(),
|
||||
report: TlsReport {
|
||||
report_id: format!("{}_{policy_hash}", date_range_start.timestamp()),
|
||||
organization_name: self
|
||||
.eval_if::<String, _>(
|
||||
&config.org_name,
|
||||
&RecipientDomain::new(&event.domain),
|
||||
event.span_id,
|
||||
)
|
||||
.await
|
||||
.clone(),
|
||||
contact_info: self
|
||||
.eval_if::<String, _>(
|
||||
&config.contact_info,
|
||||
&RecipientDomain::new(&event.domain),
|
||||
event.span_id,
|
||||
)
|
||||
.await
|
||||
.clone(),
|
||||
date_range_end,
|
||||
date_range_start,
|
||||
policies: Default::default(),
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
report.write_ops(&mut batch, item_id, true);
|
||||
|
||||
(item_id, report)
|
||||
};
|
||||
|
||||
let policy = if let Some(policy) = report
|
||||
.policy_identifiers
|
||||
.as_slice()
|
||||
.iter()
|
||||
.position(|id| *id == policy_hash)
|
||||
.and_then(|idx| report.report.policies.0.inner.get_mut(idx))
|
||||
{
|
||||
&mut policy.value
|
||||
} else {
|
||||
// Create policy
|
||||
let mut policy = TlsReportPolicy {
|
||||
policy_type: TlsPolicyType::NoPolicyFound,
|
||||
policy_domain: report.domain.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
match &event.policy {
|
||||
common::ipc::PolicyType::Tlsa(tlsa) => {
|
||||
policy.policy_type = TlsPolicyType::Tlsa;
|
||||
if let Some(tlsa) = tlsa {
|
||||
for entry in &tlsa.entries {
|
||||
policy.policy_strings.push(format!(
|
||||
"{} {} {} {}",
|
||||
if entry.is_end_entity { 3 } else { 2 },
|
||||
i32::from(entry.is_spki),
|
||||
match entry.matching {
|
||||
TlsaMatching::Full => 0,
|
||||
TlsaMatching::Sha256 => 1,
|
||||
TlsaMatching::Sha512 => 2,
|
||||
},
|
||||
entry.data.iter().fold(
|
||||
String::with_capacity(64),
|
||||
|mut s, b| {
|
||||
write!(s, "{b:02X}").ok();
|
||||
s
|
||||
}
|
||||
)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
common::ipc::PolicyType::Sts(sts) => {
|
||||
policy.policy_type = TlsPolicyType::Sts;
|
||||
if let Some(sts) = sts {
|
||||
policy.policy_strings.push("version: STSv1".to_string());
|
||||
policy.policy_strings.push(format!(
|
||||
"mode: {}",
|
||||
match sts.mode {
|
||||
Mode::Enforce => "enforce",
|
||||
Mode::Testing => "testing",
|
||||
Mode::None => "none",
|
||||
}
|
||||
));
|
||||
policy
|
||||
.policy_strings
|
||||
.push(format!("max_age: {}", sts.max_age));
|
||||
for mx in &sts.mx {
|
||||
let mx = match mx {
|
||||
MxPattern::Equals(mx) => mx.to_string(),
|
||||
MxPattern::StartsWith(mx) => format!("*.{mx}"),
|
||||
};
|
||||
policy.policy_strings.push(format!("mx: {mx}"));
|
||||
policy.mx_hosts.push(mx);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
|
||||
for rua in &event.tls_record.rua {
|
||||
match rua {
|
||||
ReportUri::Mail(mail) => {
|
||||
report.mail_rua.push(mail.clone());
|
||||
}
|
||||
ReportUri::Http(uri) => {
|
||||
report.http_rua.push(uri.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
report.policy_identifiers.push(policy_hash);
|
||||
report.report.policies.push(policy);
|
||||
&mut report.report.policies.0.inner.last_mut().unwrap().value
|
||||
};
|
||||
|
||||
// Add failure details
|
||||
if let Some(mut failure) = event.failure.clone().map(TlsFailureDetails::from) {
|
||||
if let Some(idx) = policy
|
||||
.failure_details
|
||||
.0
|
||||
.inner
|
||||
.iter()
|
||||
.position(|d| d.value.eq_except_count(&failure))
|
||||
{
|
||||
policy.failure_details.0.inner[idx]
|
||||
.value
|
||||
.failed_session_count += 1;
|
||||
} else {
|
||||
failure.failed_session_count = 1;
|
||||
policy.failure_details.push(failure);
|
||||
}
|
||||
|
||||
policy.total_failed_sessions += 1;
|
||||
} else {
|
||||
policy.total_successful_sessions += 1;
|
||||
}
|
||||
|
||||
// Write entry
|
||||
let report_bytes = report.to_pickled_vec();
|
||||
let max_report_size = self
|
||||
.eval_if(
|
||||
&config.max_size,
|
||||
&RecipientDomain::new(&event.domain),
|
||||
event.span_id,
|
||||
)
|
||||
.await
|
||||
.unwrap_or(5 * 1024 * 1024);
|
||||
if max_report_size != 0 && report_bytes.len() > max_report_size {
|
||||
trc::event!(
|
||||
OutgoingReport(OutgoingReportEvent::MaxSizeExceeded),
|
||||
SpanId = event.span_id,
|
||||
Domain = event.domain.clone(),
|
||||
Details = report_bytes.len(),
|
||||
Limit = max_report_size,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
batch.set(
|
||||
ValueClass::Registry(RegistryClass::Item { object_id, item_id }),
|
||||
report_bytes,
|
||||
);
|
||||
|
||||
match self.core.storage.data.write(batch.build_all()).await {
|
||||
Ok(_) => {
|
||||
break;
|
||||
}
|
||||
Err(err) => {
|
||||
if err.is_assertion_failure() && rety_count < 3 {
|
||||
rety_count += 1;
|
||||
continue;
|
||||
}
|
||||
trc::error!(
|
||||
err.caused_by(trc::location!())
|
||||
.details("Failed to write TLS report")
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use sieve::Envelope;
|
||||
use smtp_proto::{
|
||||
MAIL_BY_NOTIFY, MAIL_BY_RETURN, MAIL_BY_TRACE, MAIL_RET_FULL, MAIL_RET_HDRS, RCPT_NOTIFY_DELAY,
|
||||
RCPT_NOTIFY_FAILURE, RCPT_NOTIFY_NEVER, RCPT_NOTIFY_SUCCESS,
|
||||
};
|
||||
use utils::DomainPart;
|
||||
|
||||
use crate::core::{SessionAddress, SessionData};
|
||||
|
||||
impl SessionData {
|
||||
pub fn apply_envelope_modification(&mut self, envelope: Envelope, value: String) {
|
||||
match envelope {
|
||||
Envelope::From => {
|
||||
let (address, address_lcase, domain) = if value.contains('@') {
|
||||
let address_lcase = value.to_lowercase();
|
||||
let domain = address_lcase.domain_part().into();
|
||||
(value, address_lcase, domain)
|
||||
} else if value.is_empty() {
|
||||
(String::new(), String::new(), String::new())
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
if let Some(mail_from) = &mut self.mail_from {
|
||||
mail_from.address = address;
|
||||
mail_from.address_lcase = address_lcase;
|
||||
mail_from.domain = domain;
|
||||
} else {
|
||||
self.mail_from = SessionAddress {
|
||||
address,
|
||||
address_lcase,
|
||||
domain,
|
||||
flags: 0,
|
||||
dsn_info: None,
|
||||
}
|
||||
.into();
|
||||
}
|
||||
}
|
||||
Envelope::To => {
|
||||
if value.contains('@') {
|
||||
let address_lcase = value.to_lowercase();
|
||||
let domain = address_lcase.domain_part().into();
|
||||
if let Some(rcpt_to) = self.rcpt_to.last_mut() {
|
||||
rcpt_to.address = value;
|
||||
rcpt_to.address_lcase = address_lcase;
|
||||
rcpt_to.domain = domain;
|
||||
} else {
|
||||
self.rcpt_to.push(SessionAddress {
|
||||
address: value,
|
||||
address_lcase,
|
||||
domain,
|
||||
flags: 0,
|
||||
dsn_info: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Envelope::ByMode => {
|
||||
if let Some(mail_from) = &mut self.mail_from {
|
||||
mail_from.flags &= !(MAIL_BY_NOTIFY | MAIL_BY_RETURN);
|
||||
if value == "N" {
|
||||
mail_from.flags |= MAIL_BY_NOTIFY;
|
||||
} else if value == "R" {
|
||||
mail_from.flags |= MAIL_BY_RETURN;
|
||||
}
|
||||
}
|
||||
}
|
||||
Envelope::ByTrace => {
|
||||
if let Some(mail_from) = &mut self.mail_from {
|
||||
if value == "T" {
|
||||
mail_from.flags |= MAIL_BY_TRACE;
|
||||
} else {
|
||||
mail_from.flags &= !MAIL_BY_TRACE;
|
||||
}
|
||||
}
|
||||
}
|
||||
Envelope::Notify => {
|
||||
if let Some(rcpt_to) = self.rcpt_to.last_mut() {
|
||||
rcpt_to.flags &= !(RCPT_NOTIFY_DELAY
|
||||
| RCPT_NOTIFY_FAILURE
|
||||
| RCPT_NOTIFY_SUCCESS
|
||||
| RCPT_NOTIFY_NEVER);
|
||||
if value == "NEVER" {
|
||||
rcpt_to.flags |= RCPT_NOTIFY_NEVER;
|
||||
} else {
|
||||
for value in value.split(',') {
|
||||
match value.trim() {
|
||||
"SUCCESS" => rcpt_to.flags |= RCPT_NOTIFY_SUCCESS,
|
||||
"FAILURE" => rcpt_to.flags |= RCPT_NOTIFY_FAILURE,
|
||||
"DELAY" => rcpt_to.flags |= RCPT_NOTIFY_DELAY,
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Envelope::Ret => {
|
||||
if let Some(mail_from) = &mut self.mail_from {
|
||||
mail_from.flags &= !(MAIL_RET_FULL | MAIL_RET_HDRS);
|
||||
if value == "FULL" {
|
||||
mail_from.flags |= MAIL_RET_FULL;
|
||||
} else if value == "HDRS" {
|
||||
mail_from.flags |= MAIL_RET_HDRS;
|
||||
}
|
||||
}
|
||||
}
|
||||
Envelope::Orcpt => {
|
||||
if let Some(rcpt_to) = self.rcpt_to.last_mut() {
|
||||
rcpt_to.dsn_info = value.into();
|
||||
}
|
||||
}
|
||||
Envelope::Envid => {
|
||||
if let Some(mail_from) = &mut self.mail_from {
|
||||
mail_from.dsn_info = value.into();
|
||||
}
|
||||
}
|
||||
Envelope::ByTimeAbsolute | Envelope::ByTimeRelative => (),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use crate::queue::{
|
||||
MessageSource,
|
||||
quota::HasQueueQuota,
|
||||
spool::{QueueParams, SmtpSpool},
|
||||
};
|
||||
use common::{Server, config::smtp::queue::QueueExpiry, scripts::plugins::PluginContext};
|
||||
use mail_parser::{Encoding, Message, MessagePart, PartType};
|
||||
use sieve::{
|
||||
Event, Input, MatchAs, Recipient, Sieve,
|
||||
compiler::grammar::actions::action_redirect::{ByMode, ByTime, Notify, NotifyItem, Ret},
|
||||
};
|
||||
use smtp_proto::{
|
||||
MAIL_BY_TRACE, MAIL_RET_FULL, MAIL_RET_HDRS, RCPT_NOTIFY_DELAY, RCPT_NOTIFY_FAILURE,
|
||||
RCPT_NOTIFY_NEVER, RCPT_NOTIFY_SUCCESS,
|
||||
};
|
||||
use std::{future::Future, sync::Arc, time::Instant};
|
||||
use trc::SieveEvent;
|
||||
|
||||
use super::{ScriptModification, ScriptParameters, ScriptResult};
|
||||
|
||||
pub trait RunScript: Sync + Send {
|
||||
fn run_script(
|
||||
&self,
|
||||
script_id: String,
|
||||
script: Arc<Sieve>,
|
||||
params: ScriptParameters<'_>,
|
||||
) -> impl Future<Output = ScriptResult> + Send;
|
||||
}
|
||||
|
||||
impl RunScript for Server {
|
||||
async fn run_script(
|
||||
&self,
|
||||
script_id: String,
|
||||
script: Arc<Sieve>,
|
||||
params: ScriptParameters<'_>,
|
||||
) -> ScriptResult {
|
||||
// Create filter instance
|
||||
let time = Instant::now();
|
||||
let mut instance = self
|
||||
.core
|
||||
.sieve
|
||||
.trusted_runtime
|
||||
.filter_parsed(params.message.unwrap_or_else(|| Message {
|
||||
parts: vec![MessagePart {
|
||||
headers: vec![],
|
||||
is_encoding_problem: false,
|
||||
body: PartType::Text("".into()),
|
||||
encoding: Encoding::None,
|
||||
offset_header: 0,
|
||||
offset_body: 0,
|
||||
offset_end: 0,
|
||||
}],
|
||||
raw_message: b""[..].into(),
|
||||
..Default::default()
|
||||
}))
|
||||
.with_vars_env(params.variables)
|
||||
.with_envelope_list(params.envelope)
|
||||
.with_user_address(¶ms.from_addr)
|
||||
.with_user_full_name(¶ms.from_name);
|
||||
if let Some(spam_status) = params.spam_status {
|
||||
instance.set_spam_status(spam_status);
|
||||
}
|
||||
let mut input = Input::script("__script", script);
|
||||
let mut messages: Vec<Vec<u8>> = Vec::new();
|
||||
let session_id = params.session_id;
|
||||
|
||||
let mut reject_reason = None;
|
||||
let mut modifications = vec![];
|
||||
let mut keep_id = usize::MAX;
|
||||
|
||||
// Start event loop
|
||||
while let Some(result) = instance.run(input) {
|
||||
match result {
|
||||
Ok(event) => match event {
|
||||
Event::IncludeScript { name, optional } => {
|
||||
if let Some(script) = self.core.sieve.trusted_script(name.as_str()) {
|
||||
input = Input::script(name, script.clone());
|
||||
} else if optional {
|
||||
input = false.into();
|
||||
} else {
|
||||
trc::event!(
|
||||
Sieve(SieveEvent::ScriptNotFound),
|
||||
Id = script_id.clone(),
|
||||
SpanId = session_id,
|
||||
Details = name.as_str().to_string(),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
Event::ListContains {
|
||||
lists,
|
||||
values,
|
||||
match_as,
|
||||
} => {
|
||||
input = false.into();
|
||||
'outer: for list in lists {
|
||||
if let Some(store) = self.get_lookup_store(&list) {
|
||||
for value in &values {
|
||||
if let Ok(true) = store
|
||||
.key_exists(if !matches!(match_as, MatchAs::Lowercase) {
|
||||
value.clone()
|
||||
} else {
|
||||
value.to_lowercase()
|
||||
})
|
||||
.await
|
||||
{
|
||||
input = true.into();
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
trc::event!(
|
||||
Sieve(SieveEvent::ListNotFound),
|
||||
Id = script_id.clone(),
|
||||
SpanId = session_id,
|
||||
Details = list,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Function { id, arguments } => {
|
||||
input = self
|
||||
.core
|
||||
.run_plugin(
|
||||
id,
|
||||
PluginContext {
|
||||
session_id,
|
||||
server: self,
|
||||
message: instance.message(),
|
||||
modifications: &mut modifications,
|
||||
access_token: params.access_token,
|
||||
arguments,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Event::Keep { message_id, .. } => {
|
||||
keep_id = message_id;
|
||||
input = true.into();
|
||||
}
|
||||
Event::Discard => {
|
||||
keep_id = usize::MAX - 1;
|
||||
input = true.into();
|
||||
}
|
||||
Event::Reject { reason, .. } => {
|
||||
reject_reason = reason.into();
|
||||
input = true.into();
|
||||
}
|
||||
Event::SendMessage {
|
||||
recipient,
|
||||
notify,
|
||||
return_of_content,
|
||||
by_time,
|
||||
message_id,
|
||||
} => {
|
||||
// Build message
|
||||
let mut message = self.new_message(
|
||||
params.return_path.as_str(),
|
||||
MessageSource::Autogenerated,
|
||||
session_id,
|
||||
);
|
||||
match recipient {
|
||||
Recipient::Address(rcpt) => {
|
||||
message.expand_and_add_recipient(rcpt, self).await;
|
||||
}
|
||||
Recipient::Group(rcpt_list) => {
|
||||
for rcpt in rcpt_list {
|
||||
message.expand_and_add_recipient(rcpt, self).await;
|
||||
}
|
||||
}
|
||||
Recipient::List(list) => {
|
||||
trc::event!(
|
||||
Sieve(SieveEvent::NotSupported),
|
||||
Id = script_id.clone(),
|
||||
SpanId = session_id,
|
||||
Details = list,
|
||||
Reason = "Sending to lists is not supported.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Set notify flags
|
||||
let mut flags = 0;
|
||||
match notify {
|
||||
Notify::Never => {
|
||||
flags = RCPT_NOTIFY_NEVER;
|
||||
}
|
||||
Notify::Items(items) => {
|
||||
for item in items {
|
||||
flags |= match item {
|
||||
NotifyItem::Success => RCPT_NOTIFY_SUCCESS,
|
||||
NotifyItem::Failure => RCPT_NOTIFY_FAILURE,
|
||||
NotifyItem::Delay => RCPT_NOTIFY_DELAY,
|
||||
};
|
||||
}
|
||||
}
|
||||
Notify::Default => (),
|
||||
}
|
||||
if flags > 0 {
|
||||
for rcpt in &mut message.message.recipients {
|
||||
rcpt.flags |= flags;
|
||||
}
|
||||
}
|
||||
|
||||
// Set ByTime flags
|
||||
match by_time {
|
||||
ByTime::Relative {
|
||||
rlimit,
|
||||
mode,
|
||||
trace,
|
||||
} => {
|
||||
if trace {
|
||||
message.message.flags |= MAIL_BY_TRACE;
|
||||
}
|
||||
match mode {
|
||||
ByMode::Notify => {
|
||||
for domain in &mut message.message.recipients {
|
||||
domain.notify.due += rlimit;
|
||||
}
|
||||
}
|
||||
ByMode::Return => {
|
||||
for domain in &mut message.message.recipients {
|
||||
domain.notify.due += rlimit;
|
||||
}
|
||||
}
|
||||
ByMode::Default => (),
|
||||
}
|
||||
}
|
||||
ByTime::Absolute {
|
||||
alimit,
|
||||
mode,
|
||||
trace,
|
||||
} => {
|
||||
if trace {
|
||||
message.message.flags |= MAIL_BY_TRACE;
|
||||
}
|
||||
match mode {
|
||||
ByMode::Notify => {
|
||||
for domain in &mut message.message.recipients {
|
||||
domain.notify.due = alimit as u64;
|
||||
}
|
||||
}
|
||||
ByMode::Return => {
|
||||
let expires =
|
||||
(alimit as u64).saturating_sub(message.message.created);
|
||||
if expires > 0 {
|
||||
for domain in &mut message.message.recipients {
|
||||
domain.expires = QueueExpiry::Ttl(expires);
|
||||
}
|
||||
}
|
||||
}
|
||||
ByMode::Default => (),
|
||||
}
|
||||
}
|
||||
ByTime::None => (),
|
||||
};
|
||||
|
||||
// Set ret
|
||||
match return_of_content {
|
||||
Ret::Full => {
|
||||
message.message.flags |= MAIL_RET_FULL;
|
||||
}
|
||||
Ret::Hdrs => {
|
||||
message.message.flags |= MAIL_RET_HDRS;
|
||||
}
|
||||
Ret::Default => (),
|
||||
}
|
||||
|
||||
// Queue message
|
||||
let is_forward = message_id == 0;
|
||||
let raw_message = if !is_forward {
|
||||
messages.get(message_id - 1).map(|m| m.as_slice())
|
||||
} else {
|
||||
instance.message().raw_message().into()
|
||||
};
|
||||
if let Some(raw_message) = raw_message.filter(|m| !m.is_empty()) {
|
||||
if let Some(metadata) = self.has_quota(&mut message).await {
|
||||
let dkim_signers = if let Some(sign_domain) = ¶ms.sign_domain {
|
||||
match self.dkim_signers(sign_domain).await {
|
||||
Ok(signers) => signers,
|
||||
Err(err) => {
|
||||
trc::error!(
|
||||
err.details("Failed to obtain DKIM signers")
|
||||
.caused_by(trc::location!())
|
||||
);
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
message
|
||||
.queue(
|
||||
QueueParams::new(raw_message, session_id, self)
|
||||
.with_dkim_signers(dkim_signers)
|
||||
.with_raw_headers_opt(
|
||||
params.headers.filter(|_| is_forward),
|
||||
)
|
||||
.with_original_raw_message(
|
||||
instance.message().raw_message(),
|
||||
)
|
||||
.with_metadata(metadata),
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
trc::event!(
|
||||
Sieve(SieveEvent::QuotaExceeded),
|
||||
SpanId = session_id,
|
||||
Id = script_id.clone(),
|
||||
From = message.message.return_path,
|
||||
To = message
|
||||
.message
|
||||
.recipients
|
||||
.into_iter()
|
||||
.map(|r| trc::Value::from(r.address().to_string()))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
input = true.into();
|
||||
}
|
||||
Event::CreatedMessage { message, .. } => {
|
||||
messages.push(message);
|
||||
input = true.into();
|
||||
}
|
||||
Event::SetEnvelope { envelope, value } => {
|
||||
modifications.push(ScriptModification::SetEnvelope {
|
||||
name: envelope,
|
||||
value,
|
||||
});
|
||||
input = true.into();
|
||||
}
|
||||
unsupported => {
|
||||
trc::event!(
|
||||
Sieve(SieveEvent::NotSupported),
|
||||
Id = script_id.clone(),
|
||||
SpanId = session_id,
|
||||
Reason = "Unsupported event",
|
||||
Details = format!("{unsupported:?}"),
|
||||
);
|
||||
break;
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
trc::event!(
|
||||
Sieve(SieveEvent::RuntimeError),
|
||||
Id = script_id.clone(),
|
||||
SpanId = session_id,
|
||||
Reason = err.to_string(),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Keep id
|
||||
// 0 = use original message
|
||||
// MAX = implicit keep
|
||||
// MAX - 1 = discard message
|
||||
|
||||
if keep_id == 0 {
|
||||
trc::event!(
|
||||
Sieve(SieveEvent::ActionAccept),
|
||||
SpanId = session_id,
|
||||
Id = script_id,
|
||||
Elapsed = time.elapsed(),
|
||||
);
|
||||
|
||||
ScriptResult::Accept { modifications }
|
||||
} else if let Some(mut reject_reason) = reject_reason {
|
||||
trc::event!(
|
||||
Sieve(SieveEvent::ActionReject),
|
||||
Id = script_id,
|
||||
SpanId = session_id,
|
||||
Details = reject_reason.clone(),
|
||||
Elapsed = time.elapsed(),
|
||||
);
|
||||
|
||||
if !reject_reason.ends_with('\n') {
|
||||
reject_reason.push_str("\r\n");
|
||||
}
|
||||
let mut reject_bytes = reject_reason.as_bytes().iter();
|
||||
if matches!(reject_bytes.next(), Some(ch) if ch.is_ascii_digit())
|
||||
&& matches!(reject_bytes.next(), Some(ch) if ch.is_ascii_digit())
|
||||
&& matches!(reject_bytes.next(), Some(ch) if ch.is_ascii_digit())
|
||||
&& matches!(reject_bytes.next(), Some(ch) if ch == &b' ' )
|
||||
{
|
||||
ScriptResult::Reject(reject_reason)
|
||||
} else {
|
||||
ScriptResult::Reject(format!("503 5.5.3 {reject_reason}"))
|
||||
}
|
||||
} else if keep_id != usize::MAX - 1 {
|
||||
if let Some(message) = messages.into_iter().nth(keep_id - 1) {
|
||||
trc::event!(
|
||||
Sieve(SieveEvent::ActionAccept),
|
||||
SpanId = session_id,
|
||||
Id = script_id,
|
||||
Elapsed = time.elapsed(),
|
||||
);
|
||||
|
||||
ScriptResult::Replace {
|
||||
message,
|
||||
modifications,
|
||||
}
|
||||
} else {
|
||||
trc::event!(
|
||||
Sieve(SieveEvent::ActionAcceptReplace),
|
||||
SpanId = session_id,
|
||||
Id = script_id,
|
||||
Elapsed = time.elapsed(),
|
||||
);
|
||||
|
||||
ScriptResult::Accept { modifications }
|
||||
}
|
||||
} else {
|
||||
trc::event!(
|
||||
Sieve(SieveEvent::ActionDiscard),
|
||||
SpanId = session_id,
|
||||
Id = script_id,
|
||||
Elapsed = time.elapsed()
|
||||
);
|
||||
|
||||
ScriptResult::Discard
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use std::{sync::Arc, time::SystemTime};
|
||||
|
||||
use common::network::SessionStream;
|
||||
|
||||
use mail_auth::common::resolver::ToReverseName;
|
||||
use sieve::{Envelope, Sieve, runtime::Variable};
|
||||
use smtp_proto::*;
|
||||
|
||||
use crate::{core::Session, inbound::AuthResult};
|
||||
|
||||
use super::{ScriptParameters, ScriptResult, event_loop::RunScript};
|
||||
|
||||
impl<T: SessionStream> Session<T> {
|
||||
pub fn build_script_parameters(&self, stage: &'static str) -> ScriptParameters<'_> {
|
||||
let (tls_version, tls_cipher) = self.stream.tls_version_and_cipher();
|
||||
let mut params = ScriptParameters::new()
|
||||
.set_variable("remote_ip", self.data.remote_ip.to_string())
|
||||
.set_variable("remote_ip.reverse", self.data.remote_ip.to_reverse_name())
|
||||
.set_variable("helo_domain", self.data.helo_domain.as_str().to_lowercase())
|
||||
.set_variable(
|
||||
"authenticated_as",
|
||||
self.authenticated_as().unwrap_or_default().to_string(),
|
||||
)
|
||||
.set_variable(
|
||||
"now",
|
||||
SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs()),
|
||||
)
|
||||
.set_variable(
|
||||
"asn",
|
||||
self.data
|
||||
.asn_geo_data
|
||||
.asn
|
||||
.as_ref()
|
||||
.map(|r| r.id)
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.set_variable(
|
||||
"country",
|
||||
self.data
|
||||
.asn_geo_data
|
||||
.country
|
||||
.as_ref()
|
||||
.map(|r| r.as_str())
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.set_variable(
|
||||
"spf.result",
|
||||
self.data
|
||||
.spf_mail_from
|
||||
.as_ref()
|
||||
.map(|r| r.result().as_str())
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.set_variable(
|
||||
"spf_ehlo.result",
|
||||
self.data
|
||||
.spf_ehlo
|
||||
.as_ref()
|
||||
.map(|r| r.result().as_str())
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.set_variable("tls.version", tls_version)
|
||||
.set_variable("tls.cipher", tls_cipher)
|
||||
.set_variable("stage", stage);
|
||||
if let Some(ip_rev) = &self.data.iprev {
|
||||
params = params.set_variable("iprev.result", ip_rev.result().as_str());
|
||||
if let Some(ptr) = ip_rev.ptr.as_ref().and_then(|addrs| addrs.first()) {
|
||||
params = params.set_variable(
|
||||
"iprev.ptr",
|
||||
ptr.strip_suffix('.').unwrap_or(ptr).to_lowercase(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(mail_from) = &self.data.mail_from {
|
||||
params
|
||||
.envelope
|
||||
.push((Envelope::From, mail_from.address_lcase.to_string().into()));
|
||||
if let Some(env_id) = &mail_from.dsn_info {
|
||||
params
|
||||
.envelope
|
||||
.push((Envelope::Envid, env_id.as_str().to_lowercase().into()));
|
||||
}
|
||||
|
||||
if stage != "data" {
|
||||
if let Some(rcpt) = self.data.rcpt_to.last() {
|
||||
params
|
||||
.envelope
|
||||
.push((Envelope::To, rcpt.address_lcase.to_string().into()));
|
||||
if let Some(orcpt) = &rcpt.dsn_info {
|
||||
params
|
||||
.envelope
|
||||
.push((Envelope::Orcpt, orcpt.as_str().to_lowercase().into()));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Build recipients list
|
||||
let mut recipients = Vec::with_capacity(self.data.rcpt_to.len());
|
||||
let mut orcpts = Vec::with_capacity(self.data.rcpt_to.len());
|
||||
let mut has_orcpts = false;
|
||||
|
||||
for rcpt in &self.data.rcpt_to {
|
||||
recipients.push(Variable::from(rcpt.address_lcase.to_string()));
|
||||
orcpts.push(match &rcpt.dsn_info {
|
||||
Some(orcpt) => {
|
||||
has_orcpts = true;
|
||||
Variable::from(orcpt.as_str().to_lowercase())
|
||||
}
|
||||
None => Variable::default(),
|
||||
});
|
||||
}
|
||||
|
||||
params.envelope.push((Envelope::To, recipients.into()));
|
||||
if has_orcpts {
|
||||
params.envelope.push((Envelope::Orcpt, orcpts.into()));
|
||||
}
|
||||
}
|
||||
|
||||
if (mail_from.flags & MAIL_RET_FULL) != 0 {
|
||||
params.envelope.push((Envelope::Ret, "FULL".into()));
|
||||
} else if (mail_from.flags & MAIL_RET_HDRS) != 0 {
|
||||
params.envelope.push((Envelope::Ret, "HDRS".into()));
|
||||
}
|
||||
if (mail_from.flags & MAIL_BY_NOTIFY) != 0 {
|
||||
params.envelope.push((Envelope::ByMode, "N".into()));
|
||||
} else if (mail_from.flags & MAIL_BY_RETURN) != 0 {
|
||||
params.envelope.push((Envelope::ByMode, "R".into()));
|
||||
}
|
||||
|
||||
if (mail_from.flags & MAIL_BODY_7BIT) != 0 {
|
||||
params = params.set_variable("param.body", "7bit");
|
||||
} else if (mail_from.flags & MAIL_BODY_8BITMIME) != 0 {
|
||||
params = params.set_variable("param.body", "8bitmime");
|
||||
} else if (mail_from.flags & MAIL_BODY_BINARYMIME) != 0 {
|
||||
params = params.set_variable("param.body", "binarymime");
|
||||
}
|
||||
|
||||
if (mail_from.flags & MAIL_SMTPUTF8) != 0 {
|
||||
params = params.set_variable("param.smtputf8", Variable::Integer(1));
|
||||
}
|
||||
if (mail_from.flags & MAIL_REQUIRETLS) != 0 {
|
||||
params = params.set_variable("param.requiretls", Variable::Integer(1));
|
||||
}
|
||||
}
|
||||
|
||||
params
|
||||
}
|
||||
|
||||
pub async fn run_script(
|
||||
&self,
|
||||
script_id: String,
|
||||
script: Arc<Sieve>,
|
||||
params: ScriptParameters<'_>,
|
||||
) -> ScriptResult {
|
||||
Box::pin(
|
||||
self.server.run_script(
|
||||
script_id,
|
||||
script,
|
||||
params
|
||||
.with_session_id(self.data.session_id)
|
||||
.with_envelope(&self.server, self, self.data.session_id)
|
||||
.await,
|
||||
),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use ahash::AHashMap;
|
||||
use common::{
|
||||
Server, auth::AccessToken, expr::functions::ResolveVariable, scripts::ScriptModification,
|
||||
};
|
||||
|
||||
use mail_parser::Message;
|
||||
use sieve::{Envelope, SpamStatus, runtime::Variable};
|
||||
|
||||
pub mod envelope;
|
||||
pub mod event_loop;
|
||||
pub mod exec;
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub enum ScriptResult {
|
||||
Accept {
|
||||
modifications: Vec<ScriptModification>,
|
||||
},
|
||||
Replace {
|
||||
message: Vec<u8>,
|
||||
modifications: Vec<ScriptModification>,
|
||||
},
|
||||
Reject(String),
|
||||
Discard,
|
||||
}
|
||||
|
||||
pub struct ScriptParameters<'x> {
|
||||
message: Option<Message<'x>>,
|
||||
headers: Option<&'x [u8]>,
|
||||
variables: AHashMap<Cow<'static, str>, Variable>,
|
||||
envelope: Vec<(Envelope, Variable)>,
|
||||
from_addr: String,
|
||||
from_name: String,
|
||||
return_path: String,
|
||||
sign_domain: Option<String>,
|
||||
access_token: Option<&'x AccessToken>,
|
||||
spam_status: Option<SpamStatus>,
|
||||
session_id: u64,
|
||||
}
|
||||
|
||||
impl<'x> ScriptParameters<'x> {
|
||||
pub fn new() -> Self {
|
||||
ScriptParameters {
|
||||
variables: AHashMap::with_capacity(10),
|
||||
envelope: Vec::with_capacity(6),
|
||||
message: None,
|
||||
headers: None,
|
||||
from_addr: Default::default(),
|
||||
from_name: Default::default(),
|
||||
return_path: Default::default(),
|
||||
sign_domain: Default::default(),
|
||||
access_token: None,
|
||||
spam_status: None,
|
||||
session_id: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn with_envelope(
|
||||
mut self,
|
||||
server: &Server,
|
||||
vars: &impl ResolveVariable,
|
||||
session_id: u64,
|
||||
) -> Self {
|
||||
for (variable, expr) in [
|
||||
(&mut self.from_addr, &server.core.sieve.from_addr),
|
||||
(&mut self.from_name, &server.core.sieve.from_name),
|
||||
(&mut self.return_path, &server.core.sieve.return_path),
|
||||
] {
|
||||
if let Some(value) = server.eval_if(expr, vars, session_id).await {
|
||||
*variable = value;
|
||||
}
|
||||
}
|
||||
self.sign_domain = server
|
||||
.eval_if(&server.core.sieve.sign, vars, session_id)
|
||||
.await;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_message(self, message: Message<'x>) -> Self {
|
||||
Self {
|
||||
message: message.into(),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_auth_headers(self, headers: &'x [u8]) -> Self {
|
||||
Self {
|
||||
headers: headers.into(),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_spam_status(self, status: SpamStatus) -> Self {
|
||||
Self {
|
||||
spam_status: status.into(),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_variable(
|
||||
mut self,
|
||||
name: impl Into<Cow<'static, str>>,
|
||||
value: impl Into<Variable>,
|
||||
) -> Self {
|
||||
self.variables.insert(name.into(), value.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_envelope(mut self, envelope: Envelope, value: impl Into<Variable>) -> Self {
|
||||
self.envelope.push((envelope, value.into()));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_access_token(mut self, access_token: &'x AccessToken) -> Self {
|
||||
self.access_token = Some(access_token);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_session_id(mut self, session_id: u64) -> Self {
|
||||
self.session_id = session_id;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ScriptParameters<'_> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user