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,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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user