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:
2026-09-18 10:21:56 -07:00
commit 7dae9b29fd
1650 changed files with 485521 additions and 0 deletions
+773
View File
@@ -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),
}
}
+627
View File
@@ -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
);
}
}
+8
View File
@@ -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;
+242
View File
@@ -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
+150
View File
@@ -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)
}
}
+145
View File
@@ -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<_>>(),
);
}
}
}
}
+296
View File
@@ -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()
}
}
}
+379
View File
@@ -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 }
}
}
+158
View File
@@ -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)
}
}
+16
View File
@@ -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),
}
+110
View File
@@ -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
}
}
+481
View File
@@ -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(&params).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, &params).await {
Ok(capabilities) => capabilities,
Err(status) => {
trc::event!(
context = "ehlo",
event = "rejected",
mx = &params.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, &params)
.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
}
}