crates/monetize-mail/src/lib.rs
//! **monetize's ONE mail path**: an SMTP submission relay, read from
//! `MONETIZE_SMTP_URL` / `MONETIZE_SMTP_FROM` (or the host's `secret.smtp_url` /
//! `secret.smtp_from`), TLS always, the relay's acceptance returned as the
//! audit record's proof. The welcome mail (monetize-impl's signup sink) and the
//! notices to existing customers — nonpay warnings, the renewal reminder, a
//! declined card (owner rulings g-nonpay and g-renewal, 2026-09-29) — go
//! through this and nothing else. Moved here from monetize-impl's
//! `monetize-signup-impl::mail` on 2026-09-29 so the OPEN server on the front
//! (which links no private sink) can deliver them; one implementation.
//!
//! # Not configured is not an error
//!
//! `MONETIZE_SMTP_URL` and `MONETIZE_SMTP_FROM` unset means this deployment has
//! no relay, and a signup must not fail over it. The line
//!
//! ```text
//! no mailer configured; the return page is the only door
//! ```
//!
//! is written once per signup and the account is created anyway. A sink that
//! answered `Unavailable` here would hold a paid customer PENDING over a
//! *convenience*, and after twenty-four hours the engine would turn that into a
//! refusal (`monetize_server::signup::PENDING_CEILING_MS`) — a customer refused
//! for a mail nobody was going to read.
//!
//! **A send that FAILS is not an error either**, for the same reason and one
//! more: by the time it is attempted the principal exists and the entitlement
//! is on the appliance. There is nothing to undo, and reporting the signup as
//! unavailable would make the engine run the whole thing again. It is written
//! to the [`WelcomeLog`], and `monetize-server --resend-welcome` sends it again.
//!
//! # The transport, and the defect it exists to prevent
//!
//! Measured 2026-09-15 on the first paid signup: `MONETIZE_SMTP_URL` was
//! `smtp://…@smtp.protonmail.ch:587` and lettre's `from_url` reads a bare
//! `smtp://` as **plaintext, no STARTTLS ever**. Proton offers AUTH only after
//! STARTTLS, so the mail died with *"No compatible authentication mechanism was
//! found"* — at the first customer, not at start. So this module reads the URL
//! itself:
//!
//! | URL | port default | security |
//! |------------------------------|--------------|--------------------------------------------|
//! | `smtp://user:pw@host[:port]` | 587 | **STARTTLS required** (never opportunistic) |
//! | `smtp://user:pw@host:465` | — | implicit TLS (465 is SMTPS whatever the scheme says) |
//! | `smtps://user:pw@host[:port]`| 465 | implicit TLS |
//!
//! `?tls=opportunistic` (or anything but `required`) is refused by name: this
//! mailer sends a relay password and a customer's address, and neither goes over
//! plaintext. And [`Mailer::verify`] connects, negotiates TLS and authenticates
//! **at start**, so a relay that cannot take a mail is an operator's refusal
//! while an operator is watching.
//!
//! # `lettre`, on rustls
//!
//! Pure Rust (LAW): `default-features = false`, `rustls-tls` + `ring` +
//! `webpki-roots`. lettre's default set turns on `native-tls`, which is openssl
//! on Linux — a C library and a second TLS stack beside the rustls this
//! workspace already carries for `reqwest`. See the manifest for the whole
use std::time::Duration;
use lettre::message::Mailbox;
use lettre::transport::smtp::authentication::Credentials;
use lettre::transport::smtp::client::{Certificate, Tls, TlsParameters};
use lettre::transport::smtp::extension::ClientId;
use lettre::transport::smtp::SmtpTransport;
use lettre::{Message, Transport};
/// The two environment names a relay is configured with.
pub const CONFIG_ENVS: &[&str] = &["MONETIZE_SMTP_URL", "MONETIZE_SMTP_FROM"];
/// Mail submission with STARTTLS (RFC 6409).
pub const SUBMISSION_PORT: u16 = 587;
/// Mail submission over implicit TLS (RFC 8314).
pub const SUBMISSIONS_PORT: u16 = 465;
/// One relay conversation may not hold a signup — or the start — for longer.
const TIMEOUT: Duration = Duration::from_secs(30);
/// How the relay connection is protected. There is no plaintext variant.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Security {
/// TLS from the first byte (SMTPS, 465).
ImplicitTls,
/// Plain greeting, then `STARTTLS` — REQUIRED: a relay that does not offer
/// it is a refusal, never a plaintext fallback.
StartTls,
}
/// `MONETIZE_SMTP_URL`, read. The password is held but never printed.
#[derive(Clone, PartialEq, Eq)]
pub struct Relay {
pub host: String,
pub port: u16,
pub security: Security,
pub user: Option<String>,
password: Option<String>,
/// The EHLO name, from the URL's path; `None` is the host name.
pub hello: Option<String>,
}
impl std::fmt::Debug for Relay {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Relay")
.field("host", &self.host)
.field("port", &self.port)
.field("security", &self.security)
.field("user", &self.user)
.field("password", &self.password.as_ref().map(|_| "<set>"))
.finish()
}
}
impl Relay {
/// `host:port (STARTTLS)` — the words every line about this relay uses.
pub fn describe(&self) -> String {
let how = match self.security {
Security::ImplicitTls => "implicit TLS",
Security::StartTls => "STARTTLS required",
};
format!("{}:{} ({how})", self.host, self.port)
}
}
/// The URL with its credentials replaced, for an error message. A URL that does
/// not parse is not echoed at all: its password could be anywhere in it.
pub fn redact(url: &str) -> String {
match url::Url::parse(url) {
Ok(mut u) => {
let had = !u.username().is_empty() || u.password().is_some();
let _ = u.set_password(None);
let _ = u.set_username("");
let s = u.to_string();
if had {
s.replacen("://", "://<credentials>@", 1)
} else {
s
}
}
Err(_) => "<a value that is not a URL>".into(),
}
}
fn decode(s: &str, what: &str) -> Result<String, String> {
percent_encoding::percent_decode_str(s)
.decode_utf8()
.map(|c| c.into_owned())
.map_err(|e| format!("MONETIZE_SMTP_URL's {what} is not UTF-8 once decoded: {e}"))
}
/// **Read `MONETIZE_SMTP_URL`** by the table in the module header. Every
/// refusal names what was wrong and never contains the password.
pub fn parse_relay(url: &str) -> Result<Relay, String> {
let shown = redact(url);
let u =
url::Url::parse(url).map_err(|e| format!("MONETIZE_SMTP_URL {shown} is not a URL: {e}"))?;
let host = u
.host_str()
.filter(|h| !h.is_empty())
.ok_or_else(|| format!("MONETIZE_SMTP_URL {shown} names no host"))?
.to_owned();
let (security, port) = match u.scheme() {
"smtps" => (Security::ImplicitTls, u.port().unwrap_or(SUBMISSIONS_PORT)),
"smtp" => {
let port = u.port().unwrap_or(SUBMISSION_PORT);
(if port == SUBMISSIONS_PORT { Security::ImplicitTls } else { Security::StartTls }, port)
}
other => {
return Err(format!(
"MONETIZE_SMTP_URL {shown} has scheme {other:?}; use smtp://user:pw@host:587 (STARTTLS) or smtps://user:pw@host:465 (implicit TLS)"
))
}
};
if let Some((_, tls)) = u.query_pairs().find(|(k, _)| k == "tls") {
if tls != "required" {
return Err(format!(
"MONETIZE_SMTP_URL {shown} asks for tls={tls}; refused — this mailer sends a relay password and a customer's address and \
neither goes over plaintext. Drop the parameter: smtp:// always requires STARTTLS, smtps:// is TLS from the first byte"
));
}
}
let user = Some(decode(u.username(), "user")?).filter(|s| !s.is_empty());
let password = match u.password() {
Some(p) => Some(decode(p, "password")?),
None => None,
};
match (&user, &password) {
(Some(_), None) => {
return Err(format!(
"MONETIZE_SMTP_URL {shown} names a user and no password"
))
}
(None, Some(_)) => {
return Err(format!(
"MONETIZE_SMTP_URL {shown} carries a password and no user"
))
}
_ => {}
}
let path = u.path().trim_matches('/');
let hello = (!path.is_empty()).then(|| path.to_owned());
Ok(Relay {
host,
port,
security,
user,
password,
hello,
})
}
/// What [`Mailer::verify`] found that is not a refusal.
#[derive(Debug, PartialEq, Eq)]
pub enum RelayCheck {
/// Connected, TLS negotiated, authenticated (when credentials are set).
Ready,
/// The relay could not be REACHED (network, timeout, a transient 4xx). Not a
/// configuration fault, so the service starts and says so loudly: a relay
/// that is down for a minute must not stop payments from being taken.
Unreachable(String),
}
/// A configured relay. `None` from [`Mailer::from_env`] is a deployment with no
/// mailer, which is a supported deployment.
pub struct Mailer {
transport: SmtpTransport,
from: Mailbox,
relay: Relay,
/// What a transport through a front's door is built with (feature `egress`): the same TLS
/// (the RELAY's name), credentials and EHLO name as [`Mailer::transport`].
#[cfg_attr(not(feature = "egress"), allow(dead_code))]
how: How,
}
#[derive(Clone)]
struct How {
tls: Tls,
credentials: Option<Credentials>,
hello: ClientId,
}
impl std::fmt::Debug for Mailer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// The relay's own Debug never prints the password.
f.debug_struct("Mailer")
.field("relay", &self.relay.describe())
.field("from", &self.from.to_string())
.finish()
}
}
impl Mailer {
/// `MONETIZE_SMTP_URL` (`smtp://user:pass@host:587`) and
/// `MONETIZE_SMTP_FROM` (`gunnar.rs <no-reply@gunnar.rs>` or a bare
/// address).
///
/// * both unset → `Ok(None)`, the supported no-mailer deployment;
/// * one set and not the other, or either unparseable → `Err`, at BUILD
/// time. A half-configured relay is a typo, and finding it when the
/// service starts is the difference between an operator fixing it and a
/// customer not getting a mail nobody knew was configured.
///
/// **Set-and-empty is unset**, the rule `StripeConfig::from_env` already
/// enforces: `MONETIZE_SMTP_URL=` is one keystroke from the real thing and
/// `std::env::var` answers `Ok("")` for it.
pub fn from_env() -> Result<Option<Mailer>, String> {
let var = |k: &str| std::env::var(k).ok().filter(|v| !v.trim().is_empty());
match (var("MONETIZE_SMTP_URL"), var("MONETIZE_SMTP_FROM")) {
(None, None) => Ok(None),
(Some(url), Some(from)) => Mailer::new(&url, &from).map(Some),
(Some(_), None) => Err("MONETIZE_SMTP_URL is set and MONETIZE_SMTP_FROM is not; a relay with no sender sends nothing".into()),
(None, Some(_)) => Err("MONETIZE_SMTP_FROM is set and MONETIZE_SMTP_URL is not; there is no relay to send through".into()),
}
}
pub fn new(url: &str, from: &str) -> Result<Mailer, String> {
Mailer::with_root(url, from, None)
}
/// [`Mailer::new`], trusting ONE more root beside the compiled-in set: a PEM
/// certificate (`MONETIZE_SMTP_CA` / the host's `secret.smtp_ca`). For a
/// relay on a private CA — the mock estate's own relay, whose certificate
/// the bench's CA issued. TLS is still required and still verified.
pub fn with_root(url: &str, from: &str, root_pem: Option<&[u8]>) -> Result<Mailer, String> {
let relay = parse_relay(url)?;
let from: Mailbox = from
.parse()
.map_err(|e| format!("MONETIZE_SMTP_FROM {from:?} is not a mailbox: {e}"))?;
let mut params = TlsParameters::builder(relay.host.clone());
if let Some(pem) = root_pem {
params = params.add_root_certificate(
Certificate::from_pem(pem)
.map_err(|e| format!("MONETIZE_SMTP_CA is not a PEM certificate: {e}"))?,
);
}
let params = params.build().map_err(|e| {
format!(
"MONETIZE_SMTP_URL: no TLS parameters for {}: {e}",
relay.host
)
})?;
let tls = match relay.security {
Security::ImplicitTls => Tls::Wrapper(params),
Security::StartTls => Tls::Required(params),
};
let credentials = match (&relay.user, &relay.password) {
(Some(user), Some(password)) => Some(Credentials::new(user.clone(), password.clone())),
_ => None,
};
// The EHLO name: the URL's path, else the sender's own domain — never
// lettre's placeholder, which strict relays refuse.
let hello = ClientId::Domain(
relay
.hello
.clone()
.unwrap_or_else(|| from.email.domain().to_owned()),
);
let how = How {
tls,
credentials,
hello,
};
let transport = transport_at(&relay.host, relay.port, &how);
Ok(Mailer {
transport,
from,
relay,
how,
})
}
/// **The relay a box was FED** (`secret.smtp_url` / `secret.smtp_from`, monetize
/// `estate_order::relay_feed`): the config switch that turns a box's mail on. Nothing fed is
/// the ordinary state, and it is a refusal by name: no relay, no transport, no socket. Half a
/// relay is refused by name too. Blank is unset.
pub fn from_feed(fed: &std::collections::BTreeMap<String, String>) -> Result<Mailer, String> {
let get = |k: &str| {
fed.get(k)
.map(|v| v.trim().to_owned())
.filter(|v| !v.is_empty())
};
match (get(FEED_SMTP_URL), get(FEED_SMTP_FROM)) {
(Some(url), Some(from)) => Mailer::new(&url, &from),
(None, _) => Err(format!(
"mail is off: this box was fed no relay ({FEED_SMTP_URL}), so it dials nothing"
)),
(Some(_), None) => Err(format!(
"mail is off: this box was fed a relay and no sender ({FEED_SMTP_FROM})"
)),
}
}
pub fn relay(&self) -> &Relay {
&self.relay
}
/// **Can this relay take a mail — asked at START, not at the first
/// customer.** One conversation: connect, TLS (implicit or STARTTLS), AUTH
/// with the configured credentials, QUIT. Nothing is sent.
///
/// * `Ok(Ready)` — it can.
/// * `Ok(Unreachable)` — it could not be reached; see [`RelayCheck`].
/// * `Err(reason)` — it was reached and REFUSED this mailer: no STARTTLS, a
/// certificate that does not verify, no AUTH mechanism in common, a wrong
/// password. A configuration fault, refused by name.
pub fn verify(&self) -> Result<RelayCheck, String> {
let at = self.relay.describe();
match self.transport.test_connection() {
Ok(true) => Ok(RelayCheck::Ready),
Ok(false) => Ok(RelayCheck::Unreachable(format!("the relay {at} dropped the connection after the handshake"))),
Err(e) if e.is_permanent() || e.is_client() || e.is_tls() || e.is_response() || tls_broke(&e) => Err(format!(
"MONETIZE_SMTP_URL: the relay {at} refused this mailer at start: {e}. \
A relay on 587 must offer STARTTLS and, after it, an AUTH mechanism for the configured user; \
465 is TLS from the first byte. Fix the URL or the credential — a mailer that cannot authenticate \
is refused here, not at the first paying customer"
)),
Err(e) => Ok(RelayCheck::Unreachable(format!("the relay {at} could not be reached at start: {e}"))),
}
}
/// **One mail, the caller's subject and text, through this relay** — a
/// welcome, a nonpay warning, a renewal reminder (g-nonpay, g-renewal).
/// `kind` names it in a refusal. `Ok` carries the relay's own acceptance
/// (`250 2.0.0 Ok: queued as …`), which is the audit record's proof.
pub fn notice(
&self,
to: &str,
kind: &str,
subject: &str,
body: &str,
) -> Result<String, String> {
let message = compose(&self.from, to, kind, subject, body)?;
self.hand(&self.transport, &message, kind)
}
fn hand(
&self,
transport: &SmtpTransport,
message: &Message,
kind: &str,
) -> Result<String, String> {
let reply = transport.send(message).map_err(|e| {
format!(
"the relay {} refused the {kind} mail: {e}",
self.relay.describe()
)
})?;
Ok(format!(
"{} {}",
reply.code(),
reply.message().collect::<Vec<_>>().join(" ")
))
}
/// [`Mailer::notice`] **from a box that may sit behind its front** (feature `egress`; the
/// lean estate, A19 row 13): `door` is the egress door the box was fed (`http://addr:port`,
/// `nordisk_pairing::egress::proxy()` or the product's feed in force). lettre speaks no
/// proxy, so the mail dials a loopback forward carried through the door as `CONNECT
/// relay:port`, held until the relay answered; TLS still names the RELAY, so its certificate
/// is checked as ever. `None`: the box has its own way out, and this is [`Mailer::notice`].
#[cfg(feature = "egress")]
pub fn notice_via(
&self,
door: Option<&str>,
to: &str,
kind: &str,
subject: &str,
body: &str,
) -> Result<String, String> {
let Some(door) = door else {
return self.notice(to, kind, subject, body);
};
let message = compose(&self.from, to, kind, subject, body)?;
let forward =
nordisk_pairing::egress::Forward::open(door, &self.relay.host, self.relay.port)
.map_err(|e| {
format!(
"the relay {} through the egress door: {e}",
self.relay.describe()
)
})?;
let at = forward.addr();
let transport = transport_at(&at.ip().to_string(), at.port(), &self.how);
let said = self.hand(&transport, &message, kind);
drop(forward);
said
}
}
/// The feed keys a sold box's relay is named under (monetize `estate_order::relay_feed`).
pub const FEED_SMTP_URL: &str = "secret.smtp_url";
pub const FEED_SMTP_FROM: &str = "secret.smtp_from";
/// One transport to `host:port` with `how`'s TLS, credentials and EHLO name.
fn transport_at(host: &str, port: u16, how: &How) -> SmtpTransport {
let mut builder = SmtpTransport::builder_dangerous(host.to_owned())
.port(port)
.tls(how.tls.clone())
.timeout(Some(TIMEOUT))
.hello_name(how.hello.clone());
if let Some(c) = &how.credentials {
builder = builder.credentials(c.clone());
}
builder.build()
}
/// **One notice as a proper MIME message** (live 2026-10-14: the "ready" mail went out
/// quoted-printable with no `MIME-Version` and no `Content-Type`, so the owner's mail client
/// showed `invite=3D…` and the soft line breaks, and the first-login link broke).
///
/// `MIME-Version: 1.0`, `Content-Type: text/plain; charset=utf-8`, and the body sent `8bit`
/// whenever its lines allow it (every line under 990 bytes): no line is wrapped, so a link
/// stays one unbroken line. A body that cannot go `8bit` falls back to the encoding lettre
/// chooses, always declared.
pub fn compose(
from: &Mailbox,
to: &str,
kind: &str,
subject: &str,
body: &str,
) -> Result<Message, String> {
use lettre::message::header::{ContentTransferEncoding, ContentType};
use lettre::message::{Body, SinglePart};
let to: Mailbox = to
.parse()
.map_err(|e| format!("{:?} is not a mailbox: {e}", masked(to)))?;
// lettre picks quoted-printable for any line of 76 or more, which a link is; 8bit allows
// lines up to 998 bytes (RFC 5322 2.1.1), so a body whose lines all fit goes 8bit as it is,
// its line ends CRLF.
let crlf = body.replace("\r\n", "\n").replace('\n', "\r\n");
let body = if crlf
.split("\r\n")
.all(|l| l.len() < 990 && !l.contains('\r') && !l.contains('\0'))
{
Body::dangerous_pre_encoded(crlf.into_bytes(), ContentTransferEncoding::EightBit)
} else {
Body::new(body.to_owned())
};
Message::builder()
.from(from.clone())
.to(to)
.subject(subject)
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_PLAIN)
.body(body),
)
.map_err(|e| format!("the {kind} mail would not build: {e}"))
}
/// [`compose`] as the bytes a relay is handed — for a test that reads the message back.
pub fn render(from: &str, to: &str, subject: &str, body: &str) -> Result<Vec<u8>, String> {
let from: Mailbox = from
.parse()
.map_err(|e| format!("{from:?} is not a mailbox: {e}"))?;
Ok(compose(&from, to, "notice", subject, body)?.formatted())
}
/// **Did the TLS session itself fail** — a certificate that does not verify, a
/// peer that answered STARTTLS with something that is not TLS? lettre reports
/// those as a NETWORK error (rustls fails inside the stream's read/write), so
/// the kind alone would file a misconfigured relay under "unreachable". rustls
/// surfaces them as `io::ErrorKind::InvalidData`; a relay that merely went away
/// is `UnexpectedEof`/`ConnectionReset`/a timeout and stays unreachable.
fn tls_broke(e: &lettre::transport::smtp::Error) -> bool {
let mut source = std::error::Error::source(e);
while let Some(s) = source {
if let Some(io) = s.downcast_ref::<std::io::Error>() {
if io.kind() == std::io::ErrorKind::InvalidData {
return true;
}
}
source = s.source();
}
false
}
/// `elvira@example.com` → `el***@example.com`, for a log line. The audit file
/// holds the whole address; journald does not need it.
pub fn masked(addr: &str) -> String {
match addr.split_once('@') {
Some((local, domain)) => {
format!("{}***@{domain}", local.chars().take(2).collect::<String>())
}
None => "***".into(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpListener;
#[test]
fn a_relay_with_no_sender_is_refused_by_name() {
assert!(Mailer::new("not a url", "a@b.se").is_err());
assert!(Mailer::new("smtp://localhost:25", "not a mailbox at all").is_err());
assert!(Mailer::new("smtp://localhost:25", "gunnar.rs <no-reply@gunnar.rs>").is_ok());
}
/// ★ **The table in the module header, one row at a time.** The 2026-09-15
/// front's URL shape is the first row: `smtp://` on 587 is STARTTLS, required.
#[test]
fn the_url_decides_the_security_and_there_is_no_plaintext_row() {
let r =
parse_relay("smtp://no-reply%40gunnar.rs:s3cr3t-pw@smtp.protonmail.ch:587").unwrap();
assert_eq!(
(r.host.as_str(), r.port, r.security),
("smtp.protonmail.ch", 587, Security::StartTls)
);
assert_eq!(
r.user.as_deref(),
Some("no-reply@gunnar.rs"),
"the %40 is decoded"
);
assert_eq!(
parse_relay("smtp://u:p@relay.example").unwrap().port,
587,
"smtp:// defaults to submission"
);
assert_eq!(
parse_relay("smtp://u:p@relay.example:465")
.unwrap()
.security,
Security::ImplicitTls,
"465 is TLS from the first byte"
);
let s = parse_relay("smtps://u:p@relay.example").unwrap();
assert_eq!((s.port, s.security), (465, Security::ImplicitTls));
assert_eq!(
parse_relay("smtp://u:p@relay.example:2525?tls=required")
.unwrap()
.security,
Security::StartTls
);
let err = parse_relay("smtp://u:s3cr3t-pw@relay.example:25?tls=opportunistic").unwrap_err();
assert!(
err.contains("tls=opportunistic") && err.contains("plaintext"),
"{err}"
);
assert!(parse_relay("http://u:p@relay.example")
.unwrap_err()
.contains("scheme"));
assert!(parse_relay("smtp://u@relay.example")
.unwrap_err()
.contains("no password"));
for bad in [
"smtp://u:s3cr3t-pw@relay.example:25?tls=none",
"ftp://u:s3cr3t-pw@relay.example",
"smtp://u:s3cr3t-pw@",
] {
let err = parse_relay(bad).unwrap_err();
assert!(
!err.contains("s3cr3t-pw"),
"a refusal printed the password: {err}"
);
}
let m = Mailer::new("smtp://u:s3cr3t-pw@relay.example:587", "a@b.se").unwrap();
assert!(!format!("{m:?}").contains("s3cr3t-pw"), "{m:?}");
assert!(!format!("{:?}", m.relay()).contains("s3cr3t-pw"));
}
/// What [`fake_relay`] heard: every plaintext line, and the first byte
/// after the client asked for TLS.
type Heard = (Vec<String>, Option<u8>);
/// A one-connection fake relay that records every line the client sends in
/// plaintext and the first byte after it asked for TLS.
fn fake_relay(greet: bool, ehlo_reply: &'static str) -> (u16, std::thread::JoinHandle<Heard>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let handle = std::thread::spawn(move || {
let (mut sock, _) = listener.accept().unwrap();
sock.set_read_timeout(Some(Duration::from_secs(10)))
.unwrap();
let mut lines = Vec::new();
if !greet {
// Implicit TLS: the CLIENT speaks first, and it speaks TLS.
let mut b = [0u8; 1];
let first = sock.read_exact(&mut b).ok().map(|_| b[0]);
return (lines, first);
}
sock.write_all(b"220 fake.relay ESMTP\r\n").unwrap();
let mut reader = BufReader::new(sock.try_clone().unwrap());
loop {
let mut line = String::new();
if reader.read_line(&mut line).unwrap_or(0) == 0 {
return (lines, None);
}
let cmd = line.trim_end().to_owned();
lines.push(cmd.clone());
let upper = cmd.to_ascii_uppercase();
if upper.starts_with("EHLO") {
sock.write_all(ehlo_reply.as_bytes()).unwrap();
} else if upper == "STARTTLS" {
sock.write_all(b"220 2.0.0 Ready to start TLS\r\n").unwrap();
let mut b = [0u8; 1];
let first = reader.read_exact(&mut b).ok().map(|_| b[0]);
// Answer the ClientHello with bytes that are not TLS — the
// shape of a relay whose TLS is broken, not one that went away.
let _ =
sock.write_all(b"500 this is not a TLS record at all, not even close\r\n");
std::thread::sleep(Duration::from_millis(200));
return (lines, first);
} else {
// AUTH, MAIL, anything: the point is that it was recorded.
sock.write_all(b"530 5.7.0 not like this\r\n").unwrap();
}
}
});
(port, handle)
}
/// ★ **The 2026-09-15 defect, as a test.** A relay that tempts the client
/// with `AUTH PLAIN` in plaintext and offers STARTTLS: the mailer must ask
/// for STARTTLS FIRST, send no AUTH in the clear, and speak TLS (a 0x16
/// handshake record) right after. The fake cannot finish the handshake, so
/// `verify` refuses — by name, naming the relay.
#[test]
fn smtp_on_submission_negotiates_starttls_before_any_auth_and_refuses_at_start() {
let (port, relay) = fake_relay(
true,
"250-fake.relay\r\n250-AUTH PLAIN LOGIN\r\n250 STARTTLS\r\n",
);
let m = Mailer::new(
&format!("smtp://u%40gunnar.rs:s3cr3t-pw@127.0.0.1:{port}"),
"gunnar.rs <no-reply@gunnar.rs>",
)
.unwrap();
assert_eq!(m.relay().security, Security::StartTls);
let out = m.verify();
let (lines, first_tls_byte) = relay.join().unwrap();
assert_eq!(lines.len(), 2, "{lines:?}");
assert!(lines[0].starts_with("EHLO"), "{lines:?}");
assert_eq!(
lines[1], "STARTTLS",
"the second word on the wire must be STARTTLS: {lines:?}"
);
assert!(
!lines
.iter()
.any(|l| l.to_ascii_uppercase().starts_with("AUTH")),
"a credential went over plaintext: {lines:?}"
);
assert_eq!(
first_tls_byte,
Some(0x16),
"after STARTTLS the client must speak TLS"
);
let err = out.expect_err("a relay whose TLS fails is a refusal at start");
assert!(
err.contains(&format!("127.0.0.1:{port}"))
&& err.contains("STARTTLS required")
&& err.contains("at start"),
"{err}"
);
assert!(!err.contains("s3cr3t-pw"), "{err}");
}
/// A relay that offers no STARTTLS at all is refused, never used in plaintext.
#[test]
fn a_relay_without_starttls_is_refused_and_never_sent_a_credential() {
let (port, relay) = fake_relay(true, "250-fake.relay\r\n250 AUTH PLAIN LOGIN\r\n");
let m = Mailer::new(&format!("smtp://u:s3cr3t-pw@127.0.0.1:{port}"), "a@b.se").unwrap();
let err = m.verify().expect_err("no STARTTLS, no mailer");
let (lines, _) = relay.join().unwrap();
assert!(
!lines
.iter()
.any(|l| l.to_ascii_uppercase().starts_with("AUTH")),
"{lines:?}"
);
assert!(err.contains("refused this mailer at start"), "{err}");
}
/// 465: TLS from the first byte — the client does not wait for a greeting.
#[test]
fn smtps_speaks_tls_before_the_relay_says_anything() {
let (port, relay) = fake_relay(false, "");
let m = Mailer::new(&format!("smtps://u:p@127.0.0.1:{port}"), "a@b.se").unwrap();
let _ = m.verify();
let (_, first) = relay.join().unwrap();
assert_eq!(
first,
Some(0x16),
"implicit TLS opens with a handshake record"
);
}
/// Nothing listening is not a configuration fault: the service starts.
#[test]
fn a_relay_that_cannot_be_reached_is_unreachable_not_refused() {
let port = TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port();
let m = Mailer::new(&format!("smtp://u:p@127.0.0.1:{port}"), "a@b.se").unwrap();
assert!(
matches!(m.verify(), Ok(RelayCheck::Unreachable(_))),
"{:?}",
m.verify()
);
}
/// ★ **A box's mail is OFF until a relay is fed** (P4c; owner 2026-10-25, the airgap
/// default): nothing fed, or half a relay, is a refusal by name and no transport at all.
#[test]
fn a_box_fed_no_relay_sends_nothing_and_says_so_by_name() {
use std::collections::BTreeMap;
let none = Mailer::from_feed(&BTreeMap::new()).unwrap_err();
assert!(
none.contains("mail is off") && none.contains(FEED_SMTP_URL),
"{none}"
);
let blank = BTreeMap::from([
(FEED_SMTP_URL.to_string(), " ".to_string()),
(FEED_SMTP_FROM.to_string(), "a@mock.invalid".to_string()),
]);
assert!(
Mailer::from_feed(&blank)
.unwrap_err()
.contains(FEED_SMTP_URL),
"blank is unset"
);
let half = BTreeMap::from([(
FEED_SMTP_URL.to_string(),
"smtp://u:s3cr3t-pw@relay.mock.invalid".to_string(),
)]);
let e = Mailer::from_feed(&half).unwrap_err();
assert!(
e.contains(FEED_SMTP_FROM) && !e.contains("s3cr3t-pw"),
"{e}"
);
let fed = BTreeMap::from([
(
FEED_SMTP_URL.to_string(),
"smtps://u:p@relay.mock.invalid".to_string(),
),
(
FEED_SMTP_FROM.to_string(),
"Holger <estate@acme.mock.invalid>".to_string(),
),
]);
let m = Mailer::from_feed(&fed).unwrap();
assert_eq!(
(m.relay().host.as_str(), m.relay().port, m.relay().security),
("relay.mock.invalid", 465, Security::ImplicitTls)
);
}
/// ★ **Through the front's door** (feature `egress`; the lean estate, A19 row 13): the
/// relay is reached THROUGH the door (`CONNECT relay:port`), greeted with the sender's own
/// EHLO name, and a relay that offers no STARTTLS is refused: nothing goes in the clear.
#[cfg(feature = "egress")]
#[test]
fn a_notice_through_the_door_reaches_the_relay_through_it_and_stays_tls() {
use std::sync::Mutex;
static ASKED: Mutex<Vec<(String, u16)>> = Mutex::new(Vec::new());
fn record(host: &str, port: u16, _: &[std::net::SocketAddr]) -> Result<(), String> {
ASKED.lock().unwrap().push((host.to_string(), port));
Ok(())
}
let door = nordisk_pairing::egress::serve(
"127.0.0.1:0".parse().unwrap(),
nordisk_pairing::egress::Policy {
peer: |_| true,
admits: record,
},
)
.unwrap();
let (port, relay) = fake_relay(true, "250-fake.relay\r\n250 AUTH PLAIN LOGIN\r\n");
let m = Mailer::new(
&format!("smtp://u:s3cr3t-pw@127.0.0.1:{port}"),
"Holger <estate@acme.mock.invalid>",
)
.unwrap();
let e = m
.notice_via(
Some(&format!("http://{door}")),
"buyer@mock.invalid",
"sign-in link",
"sign in",
"a link",
)
.unwrap_err();
let (lines, _) = relay.join().unwrap();
assert!(
ASKED
.lock()
.unwrap()
.iter()
.any(|(h, p)| h == "127.0.0.1" && *p == port),
"the door carried it: {:?}",
ASKED.lock().unwrap()
);
assert_eq!(
lines.first().map(String::as_str),
Some("EHLO acme.mock.invalid"),
"{lines:?}"
);
assert!(
!lines
.iter()
.any(|l| l.to_ascii_uppercase().starts_with("AUTH")
|| l.to_ascii_uppercase().starts_with("MAIL")),
"{lines:?}"
);
assert!(
e.contains("refused the sign-in link mail") && !e.contains("s3cr3t-pw"),
"{e}"
);
}
/// A door that is not one is said by name before any relay is dialled.
#[cfg(feature = "egress")]
#[test]
fn a_door_that_does_not_answer_is_said_by_name() {
let dead = TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap();
let m = Mailer::new("smtp://u:p@relay.mock.invalid", "a@mock.invalid").unwrap();
let e = m
.notice_via(
Some(&format!("http://{dead}")),
"b@mock.invalid",
"sign-in link",
"s",
"b",
)
.unwrap_err();
assert!(e.contains("egress door"), "{e}");
}
#[test]
fn a_log_line_never_carries_the_whole_address() {
assert_eq!(masked("elvira@example.com"), "el***@example.com");
assert_eq!(masked("nope"), "***");
}
/// ★ **The ready mail reads back whole through a standard MIME parser** (live 2026-10-14):
/// declared MIME, text/plain in UTF-8, its transfer encoding said, and the first-login link
/// on ONE unbroken line of the raw message, equal to the link that was written.
#[test]
fn a_notice_is_proper_mime_and_its_link_reads_back_unbroken() {
let link = format!(
"https://acme.test2.gunnar.rs/login?invite={}&user=alice",
"3d27".repeat(16)
);
let body = format!("Your Gunnar acme.test2.gunnar.rs is ready — its first admin is alice, and this line is long enough to wrap at seventy-six.\n\n {link}\n\nAfter that, sign in with your passkey.\n");
let raw = render(
"gunnar.rs <no-reply@gunnar.rs>",
"buyer@example.com",
"Your Gunnar is ready",
&body,
)
.unwrap();
let text = String::from_utf8(raw.clone()).unwrap();
assert!(text.contains("MIME-Version: 1.0"), "{text}");
assert!(
text.to_ascii_lowercase()
.contains("content-type: text/plain; charset=utf-8"),
"{text}"
);
assert!(text.contains("Content-Transfer-Encoding: "), "{text}");
assert!(
text.lines().any(|l| l.trim() == link),
"the link is not one unbroken line of the raw message:\n{text}"
);
let parsed = mail_parser::MessageParser::default()
.parse(&raw)
.expect("a MIME message");
let read = parsed.body_text(0).expect("a text body").into_owned();
let found = read
.lines()
.map(str::trim)
.find(|l| l.starts_with("https://"))
.expect("the link");
assert_eq!(found, link);
assert!(
read.contains("ready — its first admin"),
"UTF-8 reads back: {read}"
);
}
}