Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 173 additions & 5 deletions crates/rustmail-api/src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,43 @@ pub struct ReleaseBody {

const ALLOWED_SMTP_PORTS: &[u16] = &[25, 465, 587, 2525];

/// The SMTPS port: the relay expects TLS from the connection's first byte.
const IMPLICIT_TLS_SMTP_PORT: u16 = 465;

/// How the connection to the release relay is secured.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RelaySecurity {
/// TLS from the first byte (SMTPS).
ImplicitTls,
/// Plaintext greeting and EHLO, then a mandatory STARTTLS upgrade; the
/// message is never sent if the relay cannot upgrade.
RequiredStartTls,
}

impl RelaySecurity {
fn for_port(port: u16) -> Self {
if port == IMPLICIT_TLS_SMTP_PORT {
Self::ImplicitTls
} else {
Self::RequiredStartTls
}
}
}

fn relay_transport(
host: &str,
port: u16,
security: RelaySecurity,
) -> Result<lettre::AsyncSmtpTransport<lettre::Tokio1Executor>, lettre::transport::smtp::Error> {
use lettre::{AsyncSmtpTransport, Tokio1Executor};

let builder = match security {
RelaySecurity::ImplicitTls => AsyncSmtpTransport::<Tokio1Executor>::relay(host),
RelaySecurity::RequiredStartTls => AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(host),
}?;
Ok(builder.port(port).build())
}

pub async fn release_message(
State(state): State<AppState>,
Path(id): Path<String>,
Expand Down Expand Up @@ -645,12 +682,9 @@ pub async fn release_message(

match envelope {
Ok(envelope) => {
use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor};

let mailer_result =
AsyncSmtpTransport::<Tokio1Executor>::relay(&body.host).map(|b| b.port(port).build());
use lettre::AsyncTransport;

let mailer = match mailer_result {
let mailer = match relay_transport(&body.host, port, RelaySecurity::for_port(port)) {
Ok(m) => m,
Err(e) => {
tracing::error!(error = %e, "TLS setup failed for relay host");
Expand Down Expand Up @@ -1072,3 +1106,137 @@ mod auth_parser_tests {
assert_eq!(details, "broken");
}
}

#[cfg(test)]
mod relay_transport_tests {
use super::*;
use lettre::AsyncTransport;
use lettre::address::Envelope;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;

const LOOPBACK: &str = "127.0.0.1";
const STARTTLS_SUBMISSION_PORT: u16 = 2525;
const TLS_HANDSHAKE_RECORD: u8 = 0x16;
const EXCHANGE_TIMEOUT: Duration = Duration::from_secs(10);
const GREETING: &[u8] = b"220 relay.test ESMTP\r\n";
const EHLO_WITH_STARTTLS: &[u8] = b"250-relay.test\r\n250 STARTTLS\r\n";
const EHLO_WITHOUT_STARTTLS: &[u8] = b"250 relay.test\r\n";
const RAW_MESSAGE: &[u8] = b"Subject: relay\r\n\r\nBody.\r\n";

fn envelope() -> Envelope {
Envelope::new(
Some("[email protected]".parse().unwrap()),
vec!["[email protected]".parse().unwrap()],
)
.unwrap()
}

async fn loopback_listener() -> (TcpListener, u16) {
let listener = TcpListener::bind((LOOPBACK, 0)).await.unwrap();
let port = listener.local_addr().unwrap().port();
(listener, port)
}

async fn send_through(port: u16, security: RelaySecurity) -> bool {
let mailer = relay_transport(LOOPBACK, port, security).unwrap();
tokio::time::timeout(EXCHANGE_TIMEOUT, mailer.send_raw(&envelope(), RAW_MESSAGE))
.await
.expect("the client never gave up on the relay")
.is_ok()
}

async fn plaintext_commands_until_close(
listener: TcpListener,
ehlo_reply: &'static [u8],
) -> Vec<String> {
let (socket, _) = listener.accept().await.unwrap();
let mut socket = BufReader::new(socket);
socket.get_mut().write_all(GREETING).await.unwrap();
let mut commands = Vec::new();
let mut line = String::new();
while socket.read_line(&mut line).await.unwrap() > 0 {
let command = line.trim_end().to_string();
line.clear();
if command.starts_with("EHLO ") {
socket.get_mut().write_all(ehlo_reply).await.unwrap();
}
let is_starttls = command == "STARTTLS";
commands.push(command);
if is_starttls {
break;
}
}
commands
}

#[test]
fn port_465_is_implicit_tls_and_every_other_allowed_port_requires_starttls() {
for port in ALLOWED_SMTP_PORTS {
let expected = if *port == IMPLICIT_TLS_SMTP_PORT {
RelaySecurity::ImplicitTls
} else {
RelaySecurity::RequiredStartTls
};
assert_eq!(RelaySecurity::for_port(*port), expected, "port {port}");
}
}

#[tokio::test]
async fn starttls_relay_sends_ehlo_then_starttls_in_plaintext() {
let (listener, port) = loopback_listener().await;
let relay = tokio::spawn(plaintext_commands_until_close(listener, EHLO_WITH_STARTTLS));

assert!(!send_through(port, RelaySecurity::for_port(STARTTLS_SUBMISSION_PORT)).await);

let commands = tokio::time::timeout(EXCHANGE_TIMEOUT, relay)
.await
.unwrap()
.unwrap();
let verbs: Vec<&str> = commands
.iter()
.map(|c| c.split(' ').next().unwrap_or_default())
.collect();
assert_eq!(verbs, ["EHLO", "STARTTLS"]);
}

#[tokio::test]
async fn starttls_relay_refuses_to_send_when_the_upgrade_is_not_offered() {
let (listener, port) = loopback_listener().await;
let relay = tokio::spawn(plaintext_commands_until_close(
listener,
EHLO_WITHOUT_STARTTLS,
));

assert!(!send_through(port, RelaySecurity::RequiredStartTls).await);

let commands = tokio::time::timeout(EXCHANGE_TIMEOUT, relay)
.await
.unwrap()
.unwrap();
assert!(
commands.iter().all(|c| !c.starts_with("MAIL")),
"the client sent the envelope in plaintext: {commands:?}"
);
}

#[tokio::test]
async fn implicit_tls_relay_opens_with_a_tls_handshake_record() {
let (listener, port) = loopback_listener().await;
let relay = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut first = [0u8; 1];
socket.read_exact(&mut first).await.unwrap();
first[0]
});

assert!(!send_through(port, RelaySecurity::for_port(IMPLICIT_TLS_SMTP_PORT)).await);

let first_byte = tokio::time::timeout(EXCHANGE_TIMEOUT, relay)
.await
.unwrap()
.unwrap();
assert_eq!(first_byte, TLS_HANDSHAKE_RECORD);
}
}
37 changes: 28 additions & 9 deletions crates/rustmail-api/tests/goldens/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ use std::fmt::Write as _;
use axum::http::{Method, StatusCode, header};
use rustmail_api::{AppState, WsEvent};
use serde_json::Value;
use tokio::io::AsyncReadExt;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};

use crate::corpus::{Served, corpus};
use crate::golden::assert_golden;
Expand All @@ -37,7 +37,8 @@ const API: &str = "/api/v1";
const UNKNOWN_ID: &str = "01J0000000000000000000000Z";
const RELEASE_HOST: &str = "127.0.0.1";
const RELEASE_PORT: u16 = 2525;
const TLS_HANDSHAKE_RECORD: u8 = 0x16;
const RELAY_GREETING: &[u8] = b"220 relay.test ESMTP\r\n";
const RELAY_EHLO_REPLY: &[u8] = b"250-relay.test\r\n250 STARTTLS\r\n";
const RELEASE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);

fn next_cursor(body: &[u8]) -> Option<String> {
Expand Down Expand Up @@ -634,10 +635,28 @@ async fn release_to_a_relay_that_drops_the_connection(backend: Backend) {
panic!("port {RELEASE_PORT} is needed for the release golden (the handler only relays to 25, 465, 587 or 2525): {error}")
});
let relay = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut first = [0u8; 1];
socket.read_exact(&mut first).await.unwrap();
first[0]
let (socket, _) = listener.accept().await.unwrap();
let mut socket = BufReader::new(socket);
socket.get_mut().write_all(RELAY_GREETING).await.unwrap();
let mut verbs = Vec::new();
let mut line = String::new();
while socket.read_line(&mut line).await.unwrap() > 0 {
let verb = line
.split_whitespace()
.next()
.unwrap_or_default()
.to_string();
line.clear();
if verb == "EHLO" {
socket.get_mut().write_all(RELAY_EHLO_REPLY).await.unwrap();
}
let upgrading = verb == "STARTTLS";
verbs.push(verb);
if upgrading {
break;
}
}
verbs
});

let fx = fixture_with(backend, |state| {
Expand All @@ -659,13 +678,13 @@ async fn release_to_a_relay_that_drops_the_connection(backend: Backend) {
.expect("release did not give up on the dropped connection");
assert_eq!(release.status, StatusCode::BAD_GATEWAY);

let first_byte = tokio::time::timeout(RELEASE_TIMEOUT, relay)
let verbs = tokio::time::timeout(RELEASE_TIMEOUT, relay)
.await
.expect("the relay never saw a connection")
.unwrap();
t.note(&format!(
"relay received a TLS handshake record first: {}",
first_byte == TLS_HANDSHAKE_RECORD
"relay received in plaintext, before dropping the connection: {}",
verbs.join(", ")
));

assert_golden("release_relay_dropped", &t.finish());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,4 @@ body-json:
"error": "SMTP delivery failed"
}

relay received a TLS handshake record first: true
relay received in plaintext, before dropping the connection: EHLO, STARTTLS
8 changes: 5 additions & 3 deletions docs/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -506,9 +506,11 @@ paths:
description: >
Disabled unless the server was started with `--release-host`. The
message's raw source is sent unchanged, with the captured envelope
(MAIL FROM and RCPT TO), to `host` over implicit TLS: the connection
is TLS from its first byte, on whichever port is used, so the relay
must accept SMTPS there. Checks run in this order: release enabled,
(MAIL FROM and RCPT TO), to `host` over TLS. On port 465 the
connection is implicit TLS (SMTPS), TLS from its first byte. On every
other allowed port (25, 587, 2525) it starts in plaintext and STARTTLS
is required: if the relay does not offer it or the upgrade fails, the
message is not sent. Checks run in this order: release enabled,
host, configured port, allowed port, message lookup, envelope.
tags: [Release]
requestBody:
Expand Down
Loading