feat: https transport channel (#122)

Transport mode:
Implements an additional mail delivery
channel over HTTPS.

Incoming mode:
Adds a http server listening for
incoming messages delivered over HTTPS.

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
This commit is contained in:
Jagoda Estera Ślązak
2026-05-01 10:30:09 +02:00
committed by GitHub
parent 1b1585eb7d
commit 0a652e61ee
9 changed files with 627 additions and 12 deletions
+178
View File
@@ -107,6 +107,12 @@ dependencies = [
"syn",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.5.0"
@@ -463,6 +469,10 @@ dependencies = [
"env_logger",
"governor",
"hickory-resolver",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-util",
"log",
"lru",
"mailparse",
@@ -486,6 +496,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fnv"
version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.2.0"
@@ -643,6 +659,25 @@ dependencies = [
"web-time",
]
[[package]]
name = "h2"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
@@ -721,6 +756,109 @@ dependencies = [
"tracing",
]
[[package]]
name = "http"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "http-body"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
"http",
]
[[package]]
name = "http-body-util"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"pin-project-lite",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
dependencies = [
"atomic-waker",
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
"tokio",
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
dependencies = [
"http",
"hyper",
"hyper-util",
"log",
"rustls",
"tokio",
"tokio-rustls",
"tower-service",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"bytes",
"futures-channel",
"futures-util",
"http",
"http-body",
"hyper",
"libc",
"pin-project-lite",
"socket2 0.6.3",
"tokio",
"tower-service",
"tracing",
]
[[package]]
name = "icu_collections"
version = "2.1.1"
@@ -867,6 +1005,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jiff"
version = "0.2.23"
@@ -1821,6 +1965,19 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"pin-project-lite",
"tokio",
]
[[package]]
name = "toml_datetime"
version = "0.7.5+spec-1.1.0"
@@ -1851,6 +2008,12 @@ dependencies = [
"winnow",
]
[[package]]
name = "tower-service"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tracing"
version = "0.1.44"
@@ -1882,6 +2045,12 @@ dependencies = [
"once_cell",
]
[[package]]
name = "try-lock"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typenum"
version = "1.19.0"
@@ -1973,6 +2142,15 @@ dependencies = [
"tracing",
]
[[package]]
name = "want"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
dependencies = [
"try-lock",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
+15
View File
@@ -31,6 +31,21 @@ tokio-rustls = { version = "0.26.4", default-features = false, features = [
webpki-roots = "1.0.6"
tokio-io-timeout = "1.2.1"
retainer = "0.4.0"
hyper = { version = "1.8.1", features = ["server"] }
hyper-util = { version = "0.1.20", features = [
"tokio",
"server",
"server-auto",
"client-legacy",
] }
http-body-util = "0.1.3"
hyper-rustls = { version = "0.27.9", default-features = false, features = [
"ring",
"http1",
"http2",
"logging",
"tls12",
] }
[dev-dependencies]
rstest = "0.26.1"
+73 -3
View File
@@ -19,6 +19,29 @@ Filtermail can be used in `incoming`, `outgoing` or `transport` mode.
### Incoming mode
```mermaid
flowchart LR
subgraph chatmail relay
subgraph postfix
smtpd1[smtpd]
smtpd2[smtpd] --> queue[...]
end
nginx[nginx]
smtpd1 -.SMTP :10081.-> filtermail[filtermail-incoming]
nginx -.HTTP :10082.-> filtermail
filtermail --SMTP :10026--> smtpd2
end
mta[Sender's relay] -.SMTP :25.-> smtpd1
mta -.HTTPS /mxdeliv.-> nginx
style postfix fill:#363
style smtpd1 fill:#252
style smtpd2 fill:#252
style queue fill:#252
style filtermail fill:#225
```
Filtermail in incoming mode acts as a proxy filter
for messages received from remote MTAs and performs following steps:
@@ -41,8 +64,32 @@ for messages received from remote MTAs and performs following steps:
5. In case of a DKIM failure,
the message is saved to `/tmp/filtermail-rejected/dkim-verify` directory for later inspection.
In contrast to outgoing mode, incoming mode starts with not only SMTP but also HTTP listener.
Built-in HTTP server doesn't handle TLS, and should be placed behind a TLS-terminating reverse proxy.
### Outgoing mode
```mermaid
flowchart LR
subgraph chatmail relay
subgraph postfix
smtpd1[smtpd]
smtpd2[smtpd] --> queue[...]
end
smtpd1 --SMTP :10080--> filtermail[filtermail-outgoing]
filtermail --SMTP :10025--> smtpd2
open-dkim[OpenDKIM] <--milter--> smtpd2
end
client[Client] --SMTP :587--> smtpd1
style postfix fill:#363
style smtpd1 fill:#252
style smtpd2 fill:#252
style queue fill:#252
style filtermail fill:#225
```
Filtermail in outgoing mode acts as a proxy filter
for messages received from clients and performs following steps:
@@ -58,12 +105,33 @@ for messages received from clients and performs following steps:
### Transport mode
```mermaid
flowchart LR
subgraph chatmail relay
subgraph postfix
qmgr[...] --> lmtp[default-transport lmtp]
end
lmtp --LMTP :10083--> filtermail[filtermail-transport]
end
filtermail -.SMTP :25.-> mta[Recipient's relay]
filtermail -.HTTPS /mxdeliv.-> mta
style postfix fill:#363
style qmgr fill:#252
style lmtp fill:#252
style filtermail fill:#225
```
Filtermail in transport mode is used for final delivery to remote MTAs.
As opposed to incoming/outgoing, it accepts connections from postfix over LMTP instead of SMTP,
to allow returning per-recipient status back to postfix.
Received message is split per-domain and sent to recipients' MX servers over SMTP,
Received message is split per-domain and sent to recipients' MX servers over HTTP and SMTP,
enforcing TLS.
As opposed to postfix, IPv4 and IPv6 connections are tried in parallel and first successful connection is used.
HTTP delivery channel is preferred,
and SMTP is used only if HTTP delivery fails.
## Configuration
@@ -74,8 +142,10 @@ but implements a custom parser that only requires a small subset of configuratio
- `filtermail_smtp_port` - port to listen on in outgoing mode,
defaults to `10080`.
- `filtermail_smtp_port_incoming` - port to listen on in incoming mode,
- `filtermail_smtp_port_incoming` - SMTP port to listen on in incoming mode,
defaults to `10081`.
- `filtermail_http_port_incoming` - HTTP port to listen on in incoming mode,
defaults to `10082`.
- `filtermail_lmtp_port_transport` - port to listen on in transport mode,
defaults to `10083`.
- `postfix_reinject_port` - port to reinject messages to postfix in outgoing mode,
@@ -124,7 +194,7 @@ Additional options that can be set using environment variables:
Although unsupported, it may still work outside of this context or even without postfix,
with few considerations:
- Filtermail expects to receive messages from a trusted server,
- Filtermail expects to receive messages from trusted clients,
and thus should not listen on ports exposed directly to the internet.
- Issues outside of chatmail relay context are not necessarily considered bugs;
PRs fixing them are not guaranteed to be accepted.
+6
View File
@@ -14,6 +14,8 @@ pub struct Config {
pub filtermail_smtp_port: u16,
#[serde(default = "Config::default_filtermail_smtp_port_incoming")]
pub filtermail_smtp_port_incoming: u16,
#[serde(default = "Config::default_filtermail_http_port_incoming")]
pub filtermail_http_port_incoming: u16,
#[serde(default = "Config::default_filtermail_lmtp_port_transport")]
pub filtermail_lmtp_port_transport: u16,
#[serde(default = "Config::default_postfix_host")]
@@ -110,6 +112,9 @@ impl Config {
const fn default_filtermail_smtp_port_incoming() -> u16 {
10081
}
const fn default_filtermail_http_port_incoming() -> u16 {
10082
}
const fn default_filtermail_lmtp_port_transport() -> u16 {
10083
}
@@ -143,6 +148,7 @@ impl Default for Config {
filtermail_host: Self::default_filtermail_host(),
filtermail_smtp_port: Self::default_filtermail_smtp_port(),
filtermail_smtp_port_incoming: Self::default_filtermail_smtp_port_incoming(),
filtermail_http_port_incoming: Self::default_filtermail_http_port_incoming(),
filtermail_lmtp_port_transport: Self::default_filtermail_lmtp_port_transport(),
postfix_host: Self::default_postfix_host(),
postfix_reinject_port: Self::default_postfix_reinject_port(),
+6
View File
@@ -27,6 +27,12 @@ pub enum Error {
Tls(#[from] rustls::Error),
#[error(transparent)]
InvalidDnsName(#[from] rustls::pki_types::InvalidDnsNameError),
#[error(transparent)]
Hyper(#[from] hyper::Error),
#[error(transparent)]
HyperHttp(#[from] hyper::http::Error),
#[error(transparent)]
HyperClient(#[from] hyper_util::client::legacy::Error),
}
impl Error {
+150
View File
@@ -0,0 +1,150 @@
use crate::smtp_server::{Envelope, SmtpHandler};
use http_body_util::combinators::BoxBody;
use http_body_util::{BodyExt, Full};
use hyper::body::{Bytes, Incoming};
use hyper::service::Service;
use hyper::{Request, Response};
use hyper_util::rt::TokioIo;
use std::convert::Infallible;
use std::pin::Pin;
use std::sync::Arc;
use tokio::net::{TcpListener, TcpStream};
/// Runs the HTTP server on the specified address with the given handler and maximum message size.
pub async fn run_http_server<H>(
addr: &impl tokio::net::ToSocketAddrs,
handler: Arc<H>,
max_size: usize,
) -> Result<(), crate::error::Error>
where
H: SmtpHandler + 'static,
{
let listener = TcpListener::bind(addr).await?;
loop {
let (socket, _) = listener.accept().await?;
// Disable Nagle's algorithm.
socket.set_nodelay(true)?;
let handler = handler.clone();
tokio::spawn(async move {
if let Err(e) = handle_connection(socket, handler, max_size).await {
log::error!("Error handling connection: {e}");
}
});
}
}
/// Handles a single HTTP connection.
async fn handle_connection<H>(
socket: TcpStream,
handler: Arc<H>,
max_size: usize,
) -> Result<(), String>
where
H: SmtpHandler + 'static,
{
let service = MxDelivService::new(handler, max_size);
hyper_util::server::conn::auto::Builder::new(hyper_util::rt::TokioExecutor::new())
.serve_connection(TokioIo::new(socket), service)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
struct MxDelivService<H: SmtpHandler> {
handler: Arc<H>,
max_size: usize,
}
impl<H: SmtpHandler> MxDelivService<H> {
/// Creates a new [`MxDelivService`].
fn new(handler: Arc<H>, max_size: usize) -> Self {
Self { handler, max_size }
}
}
impl<H: SmtpHandler + 'static> Service<Request<Incoming>> for MxDelivService<H> {
type Response = Response<BoxBody<Bytes, Infallible>>;
type Error = crate::error::Error;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
fn call(&self, req: Request<Incoming>) -> Self::Future {
let handler = self.handler.clone();
let max_size = self.max_size;
let fut = async move {
if req.method() != hyper::Method::POST {
return Ok(Response::builder().status(405).body(
// This is client's implementation error if it happens,
// so we don't care about sending a proper smtp response.
Full::new(Bytes::from("Method Not Allowed")).boxed(),
)?);
}
let mail_from = req
.headers()
.get(crate::transport::HEADER_MAIL_FROM)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
match handler.handle_mail(&mail_from) {
Ok(_) => {}
Err(e) => {
return Ok(Response::builder()
.status(400)
.body(Full::new(Bytes::from(e)).boxed())?);
}
};
let rcpt_to = req
.headers()
.get_all(crate::transport::HEADER_RCPT_TO)
.iter()
.filter_map(|v| v.to_str().ok())
.map(ToString::to_string)
.collect();
let body_limited = http_body_util::Limited::new(req.into_body(), max_size);
let body_bytes = match body_limited.collect().await {
Ok(body) => body.to_bytes(),
Err(_) => {
return Ok(Response::builder().status(413).body(
Full::new(Bytes::from("552 Message exceeds maximum size")).boxed(),
)?);
}
};
let mut envelope = Envelope {
origin_ip: "".to_string(),
mail_from,
rcpt_to,
data: body_bytes.to_vec(),
};
log::debug!("(HTTP) MAIL FROM:<{}>", envelope.mail_from);
for rcpt in &envelope.rcpt_to {
log::debug!("(HTTP) RCPT TO:<{}>", rcpt);
}
log::trace!(
"(HTTP) DATA:\n{:?}",
String::from_utf8_lossy(&envelope.data)
);
match handler.handle_data(&mut envelope).await {
Ok(response) => Ok(Response::builder()
.status(201)
.body(Full::new(Bytes::from(response)).boxed())?),
Err(e) => Ok(Response::builder()
.status(400)
.body(Full::new(Bytes::from(e)).boxed())?),
}
};
Box::pin(fut)
}
}
+13
View File
@@ -27,6 +27,7 @@
mod config;
mod dkim_verifier;
pub(crate) mod error;
mod http_server;
pub(crate) mod inbound;
pub(crate) mod message;
pub(crate) mod openpgp;
@@ -37,6 +38,7 @@ mod tls;
mod transport;
pub(crate) mod utils;
use crate::http_server::run_http_server;
use crate::transport::TransportHandler;
use config::Config;
use env_logger::Env;
@@ -153,6 +155,17 @@ async fn main() -> Result<(), error::Error> {
addr_smtp.1
);
let addr_http = (config.filtermail_host, config.filtermail_http_port_incoming);
let handler_http = handler.clone();
server_set
.spawn(async move { run_http_server(&addr_http, handler_http, max_size).await });
log::debug!(
"Incoming HTTP server listening on {}:{}",
addr_http.0,
addr_http.1
);
while let Some(result) = server_set.join_next().await {
if let Err(e) = result {
eprintln!("Server error: {}", e);
+14 -6
View File
@@ -16,8 +16,19 @@ pub async fn wrap_rustls<IO>(
where
IO: AsyncRead + AsyncWrite + Unpin,
{
let mut root_cert_store = rustls::RootCertStore::empty();
root_cert_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let config = configure_rustls(resumption_store, dangerous_no_cert_verification);
let tls = tokio_rustls::TlsConnector::from(Arc::new(config));
let name = rustls::pki_types::ServerName::try_from(hostname)?.to_owned();
let tls_stream = tls.connect(name, stream).await?;
Ok(tls_stream.into())
}
pub fn configure_rustls(
resumption_store: Arc<ClientSessionMemoryCache>,
dangerous_no_cert_verification: bool,
) -> rustls::ClientConfig {
let root_cert_store =
rustls::RootCertStore::from_iter(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let mut config = rustls::ClientConfig::builder()
.with_root_certificates(root_cert_store)
@@ -39,8 +50,5 @@ where
.set_certificate_verifier(Arc::new(NoCertificateVerification::default()));
}
let tls = tokio_rustls::TlsConnector::from(Arc::new(config));
let name = rustls::pki_types::ServerName::try_from(hostname)?.to_owned();
let tls_stream = tls.connect(name, stream).await?;
Ok(tls_stream.into())
config
}
+172 -3
View File
@@ -1,45 +1,123 @@
use crate::config::Config;
use crate::smtp_client::{SmtpConnectionPool, TlsConfig};
use crate::smtp_server::{Envelope, SmtpHandler};
use crate::tls;
use crate::utils::{AddressDomain, build_resolver};
use async_trait::async_trait;
use hickory_resolver::TokioResolver;
use http_body_util::BodyExt;
use hyper::body::Bytes;
use hyper_rustls::HttpsConnector;
use hyper_util::client::legacy::connect::HttpConnector;
use std::collections::BTreeMap;
use std::str::FromStr;
use std::sync::Arc;
use tokio::task::JoinSet;
use std::time::Duration;
use tokio::task::{JoinHandle, JoinSet};
use tokio_rustls::rustls;
pub const HEADER_MAIL_FROM: &str = "X-MAIL-FROM";
pub const HEADER_RCPT_TO: &str = "X-MAIL-TO";
/// Cheaply clonable HTTPS client.
///
/// Holds regular secure variant and relaxed - without certificate verification.
///
/// Connection pool handled internally by [`hyper_util::client::legacy::Client`].
#[derive(Clone)]
struct HttpsClient {
pub secure: hyper_util::client::legacy::Client<
HttpsConnector<HttpConnector>,
http_body_util::Full<Bytes>,
>,
pub relaxed: hyper_util::client::legacy::Client<
HttpsConnector<HttpConnector>,
http_body_util::Full<Bytes>,
>,
}
impl HttpsClient {
/// Creates a new `[HttpsClient]`.
pub fn new(tls_resumption_store: Arc<rustls::client::ClientSessionMemoryCache>) -> Self {
let tls_client_config = tls::configure_rustls(tls_resumption_store.clone(), false);
let https_connector = hyper_rustls::HttpsConnectorBuilder::new()
.with_tls_config(tls_client_config)
.https_only()
.enable_http1()
.enable_http2()
.build();
let https_client =
hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new())
.build(https_connector);
let tls_client_config_relaxed = tls::configure_rustls(tls_resumption_store, true);
let https_connector_relaxed = hyper_rustls::HttpsConnectorBuilder::new()
.with_tls_config(tls_client_config_relaxed)
.https_only()
.enable_http1()
.enable_http2()
.build();
let https_client_relaxed =
hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new())
.build(https_connector_relaxed);
Self {
secure: https_client,
relaxed: https_client_relaxed,
}
}
}
pub struct TransportHandler {
config: Config,
dns_resolver: Arc<TokioResolver>,
tls_resumption_store: Arc<rustls::client::ClientSessionMemoryCache>,
smtp_connection_pool: Arc<SmtpConnectionPool>,
https_client: HttpsClient,
mxdeliv_unsupported_hosts: Arc<retainer::Cache<String, bool>>,
monitor_handle: JoinHandle<()>,
}
impl TransportHandler {
pub fn new(config: Config) -> Result<Self, crate::error::Error> {
let dns_resolver = Arc::new(build_resolver()?);
let tls_resumption_store = Arc::new(rustls::client::ClientSessionMemoryCache::new(256));
let https_client = HttpsClient::new(tls_resumption_store.clone());
let mxdeliv_cache = Arc::new(retainer::Cache::new());
let mxdeliv_cache_clone = mxdeliv_cache.clone();
let monitor_handle = tokio::spawn(async move {
mxdeliv_cache_clone
.monitor(4, 0.25, Duration::from_secs(10))
.await
});
Ok(Self {
config,
dns_resolver,
tls_resumption_store,
smtp_connection_pool: SmtpConnectionPool::new(),
https_client,
mxdeliv_unsupported_hosts: mxdeliv_cache,
monitor_handle,
})
}
/// Handles a single email transaction for a single recipient domain.
#[expect(clippy::too_many_arguments)]
async fn handle_single_domain(
tls_resumption_store: Arc<rustls::client::ClientSessionMemoryCache>,
smtp_connection_pool: Arc<SmtpConnectionPool>,
mxdeliv_unsupported_hosts: Arc<retainer::Cache<String, bool>>,
https_client: HttpsClient,
dns_resolver: Arc<TokioResolver>,
domain: AddressDomain,
envelope: Envelope,
client_hostname: String,
) -> Result<String, String> {
let mut allow_invalid_cert = false;
let mut skip_tls = false;
let mut skip_tls = false; // only respected by smtp channel
let mx_hosts = match domain {
// no-DNS setup; assume the ip from email address is the destination.
@@ -110,6 +188,34 @@ impl TransportHandler {
// but the IPv4 and IPv6 connections (after `smtp_client::send` resolves mx hostname)
// happens in parallel.
'try_relay: for (_, mx_host) in mx_hosts {
let skip_mxdeliv = mxdeliv_unsupported_hosts
.get(&mx_host)
.await
.map(|guard| *guard.value())
.unwrap_or(false);
// HTTPS channel
if skip_mxdeliv {
log::debug!("Skipping HTTP delivery to host that failed recently: {mx_host}");
} else {
match Self::https_delivery(
https_client.clone(),
mx_host.clone(),
&envelope,
allow_invalid_cert,
)
.await
{
Ok(_) => {
return Ok("250 Ok (HTTPS)".to_string());
}
Err(e) => {
log::debug!("HTTPS delivery to {mx_host} failed: {e}");
}
}
}
// SMTP channel (fallback)
match crate::smtp_client::send(
&mx_host,
25,
@@ -122,7 +228,14 @@ impl TransportHandler {
.await
{
Ok(_) => {
return Ok("250 Ok".to_string());
// Switches this host to SMTP for 30 minutes.
// Note: this MUST happen only after a successful SMTP delivery,
// or otherwise any http error will lock us out of any way to
// deliver to a relay with a blocked port 25 for 30 minutes.
mxdeliv_unsupported_hosts
.insert(mx_host.clone(), true, Duration::from_mins(30))
.await;
return Ok("250 Ok (SMTP)".to_string());
}
Err(error) => {
match error {
@@ -152,6 +265,60 @@ impl TransportHandler {
Err("421 Failed to connect to any mail server".to_string())
}
/// Performs mail delivery to `mx_host` over HTTPS.
///
/// Times out after 60s.
async fn https_delivery(
https_client: HttpsClient,
mx_host: String,
envelope: &Envelope,
allow_invalid_cert: bool,
) -> Result<(), crate::error::Error> {
let request: hyper::Request<http_body_util::Full<Bytes>> = {
let mut builder = hyper::Request::builder()
.method(hyper::Method::POST)
.uri(format!("https://{mx_host}/mxdeliv"));
if !envelope.mail_from.is_empty() {
builder = builder.header(HEADER_MAIL_FROM, &envelope.mail_from);
}
for rcpt_to in &envelope.rcpt_to {
builder = builder.header(HEADER_RCPT_TO, rcpt_to);
}
builder.body(http_body_util::Full::from(envelope.data.clone()))?
};
let client = if allow_invalid_cert {
https_client.relaxed
} else {
https_client.secure
};
let response = tokio::time::timeout(Duration::from_secs(60), client.request(request))
.await
.map_err(|_| crate::error::Error::MailSend {
context: "HTTPS delivery".to_string(),
raw_smtp_answer: "[timeout]".to_string(),
})??;
if response.status().is_success() {
Ok(())
} else {
let response_body = response.collect().await?.to_bytes();
Err(crate::error::Error::MailSend {
context: "HTTPS delivery".to_string(),
raw_smtp_answer: String::from_utf8_lossy(&response_body).into(),
})
}
}
}
impl Drop for TransportHandler {
fn drop(&mut self) {
self.monitor_handle.abort();
}
}
#[async_trait]
@@ -201,6 +368,8 @@ impl SmtpHandler for TransportHandler {
.spawn(Self::handle_single_domain(
self.tls_resumption_store.clone(),
self.smtp_connection_pool.clone(),
self.mxdeliv_unsupported_hosts.clone(),
self.https_client.clone(),
self.dns_resolver.clone(),
rcpt_domain.clone(),
domain_envelope,