Compare commits

...

2 commits

Author SHA1 Message Date
189377a3a3 ci(email): build the wasm path; document the closed §8 gaps
Some checks failed
email.yml / ci(email): build the wasm path; document the closed §8 gaps (push) Failing after 0s
repo hygiene / hygiene (push) Has been cancelled
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
doc-engine / engine (push) Has been cancelled
doc-engine / coverage (push) Has been cancelled
doc-engine / consumer (push) Has been cancelled
nigig-map / test (push) Has been cancelled
sms / gates (push) Has been cancelled
sms / robius-sms (push) Has been cancelled
sms / android (push) Has been cancelled
sms / nigig-sms (push) Has been cancelled
sms / supply-chain (push) Has been cancelled
email.yml: install the wasm32-unknown-unknown target and check the email
domain's credential-bearing wasm half (call_email_api, WasmFetchTransport,
set_email_api_url) so a browser-only breakage cannot reach main unseen.
The domain test floor ratchets 205 -> 210.

The review doc's §8 is rewritten: the TLS handshake, the wasm build, B1
and the test/clippy baselines are now executed/measured; the only entries
left are the ones that genuinely cannot run in CI (a live relay's cert, a
browser's fetch), stated with their exact reasons.
2026-08-17 09:39:04 +00:00
216202a90a test(email): execute the TLS handshake, not just read it (§8)
The assessment's 'What I have not verified' listed the TLS handshake and a
MITM test as gaps — the S1/S3 analysis of relay()/TlsParameters::new was a
read of lettre's source, never observed. Two tests now execute the PRODUCTION
build_transport path over a real TCP + TLS socket:

- a_starttls_downgrade_is_refused_without_sending_credentials: a server that
  cannot STARTTLS receives no AUTH/MAIL FROM/RCPT TO/DATA — credentials and
  the message never cross a cleartext link (S3/T-E1 downgrade protection).
- a_self_signed_certificate_is_rejected: a server presenting a self-signed
  cert (minted with rcgen, served by tokio-rustls/rustls) is rejected by the
  transport, whose accept_invalid_certs is false — the active-MITM scenario
  (S1), observed rather than assumed.

The SMTP sink now records every command line so a test can assert a command
was never sent. Domain tests 214 -> 216.
2026-08-17 09:39:04 +00:00
5 changed files with 385 additions and 25 deletions

View file

@ -410,11 +410,12 @@ jobs:
# A floor, not a ratchet: these tests are cheap, pure, and the
# number should only go up. 38 at Phase 0; 154 after C1c/C1d;
# 195 after the Phase C/D completion; 214 after Phase E (proptest +
# the SMTP sink integration tests).
# the SMTP sink integration tests); 216 after the §8 TLS handshake
# tests.
- name: The email domain test suite must not shrink
run: |
set -euo pipefail
FLOOR=205
FLOOR=210
out="$(cargo test --locked -p nigig-core --lib -- email_ secret:: mail_backend:: mail_proxy:: imap_client:: credential_store:: 2>&1)"
# C1e/C1f: the IMAP transport and the platform keystore are both
@ -425,6 +426,20 @@ jobs:
- name: The IMAP and keystore features must compile
run: cargo check --locked -p nigig-core --features imap,keystore
# §8 of the assessment: "the wasm path has not been built." The
# credential-bearing proxy POST (call_email_api, WasmFetchTransport,
# set_email_api_url) is #[cfg(target_arch = "wasm32")], so a host-only
# build never type-checks it — which is how a browser-only breakage
# would reach main. This installs the wasm target and checks the
# email domain's wasm half. (no-default-features: the `native` feature
# would pull tokio/reqwest/lettre, which do not exist for wasm.)
- name: The wasm path must compile
run: |
set -euo pipefail
rustup target add wasm32-unknown-unknown
cargo check --locked --target wasm32-unknown-unknown -p nigig-core \
--no-default-features --features async-rt
# A coverage number that is only printed drifts down. This enforces a
# whole-domain floor plus per-file floors on the files that have
# actually harboured bugs (the SMTP password serialisation, the bulk

93
Cargo.lock generated
View file

@ -395,6 +395,29 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "aws-lc-rs"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.44.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
"pkg-config 0.3.34",
]
[[package]]
name = "base64"
version = "0.21.7"
@ -594,6 +617,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
@ -700,6 +725,15 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "cmake"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
[[package]]
name = "cobs"
version = "0.3.0"
@ -939,6 +973,12 @@ name = "downcast-rs"
version = "1.2.1"
source = "git+https://gitdab.com/andodeki/makepad?rev=ecf5a572ab62a1c1598909971f602f99083671cc#ecf5a572ab62a1c1598909971f602f99083671cc"
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "either"
version = "1.17.0"
@ -1161,6 +1201,12 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "futures"
version = "0.3.34"
@ -1901,6 +1947,16 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "jobserver"
version = "0.1.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
dependencies = [
"getrandom 0.4.3",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.104"
@ -2986,14 +3042,17 @@ dependencies = [
"postcard",
"proptest",
"rand 0.8.7",
"rcgen",
"reqwest",
"robius-directories",
"robius-location",
"robius-open",
"robius-ussd",
"rustls",
"serde",
"serde_json",
"tokio",
"tokio-rustls",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
@ -3744,6 +3803,16 @@ dependencies = [
"windows-link 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "pem"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
"base64 0.22.1",
"serde_core",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@ -4146,6 +4215,19 @@ dependencies = [
"cipher",
]
[[package]]
name = "rcgen"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
dependencies = [
"pem",
"ring",
"rustls-pki-types",
"time",
"yasna",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@ -4479,6 +4561,7 @@ version = "0.23.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
dependencies = [
"aws-lc-rs",
"log 0.4.33",
"once_cell 1.21.4",
"ring",
@ -4504,6 +4587,7 @@ version = "0.103.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
@ -6028,6 +6112,15 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
[[package]]
name = "yasna"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
dependencies = [
"time",
]
[[package]]
name = "yoke"
version = "0.8.3"

View file

@ -548,6 +548,7 @@ snapshot. Commits are on `main`.
| *(this turn)* | **Phase C + D COMPLETE** — C1e (`imap_client.rs`: trait + pure INTERNALDATE parser + feature-gated async-imap transport, native only); C1f (`credential_store.rs`: trait + fail-closed default); C3 (`email_cache.rs`: bodies through a `BodyCipher` before disk); C4b (inbox fetches real mail, loading/error/empty states); C5 (Compose sends via `spawn_send_message`); C6 (`email_pacing.rs`: `SendRateLimiter`+`SendPacing`, 100/hour); C7 (Refresh button re-fetches); D1 (More page is real: account + sign-out); D2 (lib.rs shims deleted, `NavigationBarAction` in a real module); D3 (`CachedWidget` decision documented); D4 (one-slot SMTP transport pool, keying tested); D5 (`EmailWorkerAction::None` dropped). Domain tests **154 → 195**. Coverage now **90.7%** over 12 files, floors enforced. |
| *(this turn)* | **C6/C7/C1f gaps closed** — C6: `email_bulk.rs` actually *uses* the pacing — `bulk_send_plan` batches a list over the 100-recipient cap and `run_bulk_send` sends the batches paced (gap + rate-limiter + abandon), wired into the Bulk page. C7: pull-to-refresh on the inbox (the SMS/M-Pesa `scrolled`+`scroll_position` pattern) in addition to the button. C1f: a real `KeyringCredentialStore` (OS Secret Service / Credential Manager / Keychain via `keyring`) behind the `keystore` feature; the fail-closed default remains when the feature is off. Domain tests **195 → 206**. |
| *(this turn)* | **Phase E COMPLETE** — E1 (pure-logic units, already >40); E2 (`proptest` + `email_properties.rs`: never-panic + structural invariants across the address/hostname/recipient/date parsers and `preview_line`); E3 (`FLOOR=205`); E4 (clippy ratchet at 0); E5 (a real SMTP conversation against a local sink, plus a silent-server timeout test — which **caught a real defect**: lettre's `.timeout()` only bounds the TCP connect, not the greeting/command reads, so the send path now wraps the whole operation in `platform::timeout`); E6 (the shared conversation kit is now unit-tested: `should_emit_clicked` + payload). Domain tests **206 → 214**; `nigig-uikit` gains its first 5 tests. |
| *(this turn)* | **§8 follow-up: close the "not verified" list.** The TLS handshake is now EXECUTED against the production `build_transport` path — a STARTTLS-downgrade test (no `AUTH`/`MAIL`/`RCPT`/`DATA` over a cleartext link) and a self-signed-cert rejection test (`rcgen` + `tokio-rustls` server, real handshake, `accept_invalid_certs: false` observed). The wasm path is now BUILT (`cargo check --target wasm32-unknown-unknown`, gated in CI). B1 and the test/clippy baselines are now runs/measurements, not reads. Domain tests **214 → 216**. |
**Phase 0 is complete.** All seven items done; 0.7 was fixed upstream.
@ -841,25 +842,44 @@ convincingly than any client-side fix can.
## 8. What I have not verified
Stated plainly, because the point of this document is to be trusted:
Stated plainly, because the point of this document is to be trusted. Every
item that could be executed on a host has now been executed; the entries
below are the ones that genuinely cannot be, and exactly why.
- **The SMTP conversation has been executed, but not the TLS handshake.**
Phase E5 runs a real send against a local mock SMTP sink (envelope, every
recipient, and the DATA payload are asserted), and a silent-server test
pins the operation-timeout bound. What is STILL not executed: a TLS
handshake against a real provider, and a MITM test. The S1/S3 analysis of
`relay()`/`TlsParameters::new`/`TlsVersion` remains a read of lettre's
source, not an observed handshake — the sink is plaintext because the
production transport's TLS is pinned separately by `tls_mode_for_port`
and `build_transport_constructs_for_every_port`, not exercised here.
- **The TLS handshake HAS now been executed — locally, not against a real
provider.** Two tests exercise the production `build_transport` path over
a real TCP + TLS socket:
- `a_starttls_downgrade_is_refused_without_sending_credentials` observes
the S3/T-E1 downgrade protection: a server that cannot STARTTLS receives
**no** `AUTH`, `MAIL FROM`, `RCPT TO` or `DATA` — credentials and the
message never cross a cleartext link.
- `a_self_signed_certificate_is_rejected` observes S1's certificate
validation: a server presenting a self-signed certificate is rejected by
the transport (whose `accept_invalid_certs` is `false`), the active-MITM
scenario. `rcgen` mints the certificate and `tokio-rustls`/`rustls` serve
it, so this is a real handshake, not a read.
What remains genuinely unverified: a handshake against a *real* provider
with a *publicly-trusted* certificate (there are no provider credentials in
CI), and a TLS-terminating MITM proxy against such a provider. The two
observable properties — no downgrade, no bad cert — are now pinned; the
happy path against a live relay is not.
- **I got S1 wrong on the first pass** and wrote it up as critical credential
exposure before checking the library source. The corrected entry is in §1.
Flagging it because a document like this is worth nothing if you cannot tell
which claims were verified and which were assumed from a scary method name.
- **The wasm path has not been built.** `call_email_api` is
`#[cfg(target_arch = "wasm32")]`; I compiled for host only.
- **B1 is a code read, not a run** — the binary does not compile, so the
multi-recipient failure is deduced from `Mailbox::parse`'s single-address
contract rather than observed at runtime. It is unambiguous, but it is a read.
- **Test/clippy baselines are not yet measured** for the ratchets in Phase E;
they must be captured from a real run, not guessed.
- **The wasm path has now been built.** `cargo check --target
wasm32-unknown-unknown -p nigig-core --no-default-features --features
async-rt` type-checks `call_email_api`, `api_payload`, `WasmFetchTransport`
and `set_email_api_url` — the credential-bearing proxy POST that a
host-only build never sees. It is a compile check, not a run: the actual
`fetch` behaviour still needs a browser, which CI does not provide.
- **B1 is now a run, not a read.** The original note claimed "the binary
does not compile" (it did not, before Phase 0.1 fixed `main.rs`); the
multi-recipient failure was deduced from `Mailbox::parse`. The binary now
compiles (gated by `cargo check --all-targets`), and the SMTP-sink test
sends to two recipients and asserts both `RCPT TO` lines arrive — the
headline bug is exercised, not inferred.
- **Test/clippy baselines are now measured.** The Phase E ratchets run from
real numbers, not guesses: domain-test floor `FLOOR=210`, clippy ratchet
at `0` nigig-email-owned diagnostics, and the coverage floors in
`tools/test-email-coverage.sh`.

View file

@ -63,6 +63,18 @@ keyring = { version = "4", optional = true }
# E2: property-test the address/date parsers — never panic on arbitrary
# input. Same dev-dependency the SMS crate already uses.
proptest = "1"
# §8 follow-up: execute the TLS handshake against a local server (the
# assessment's "TLS handshake never executed" gap). rcgen mints a
# self-signed certificate; tokio-rustls/rustls serve it so the production
# transport's certificate validation (accept_invalid_certs: false) is
# observed, not just read from lettre's source.
rcgen = "0.13"
tokio-rustls = "0.26"
# rustls directly, so the test can build the SERVER side of the handshake
# (tokio-rustls only re-exports it through a dep). `ring` matches the
# provider lettre selects, so `with_single_cert` can sign an ECDSA key.
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
tokio = { version = "1", features = ["rt", "macros", "net", "time", "io-util", "sync"] }
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = "0.2"

View file

@ -1295,7 +1295,7 @@ mod tests {
/// A tiny SMTP sink that speaks just enough of the protocol to receive
/// one message and record the envelope + data. Plaintext on purpose
/// (the production transport's TLS is pinned elsewhere).
/// (the production transport's TLS is pinned separately).
#[cfg(not(target_arch = "wasm32"))]
mod smtp_sink {
use std::io::{BufRead, BufReader, Write};
@ -1307,13 +1307,18 @@ mod tests {
pub mail_from: Vec<String>,
pub rcpt_to: Vec<String>,
pub data: String,
/// Every command line the client sent, verbatim. Lets a test
/// assert that a command (AUTH, MAIL FROM, …) was NEVER sent.
pub commands: Vec<String>,
}
/// Bind an ephemeral port and serve one SMTP transaction on a
/// background thread. The captured conversation is reported as soon
/// as the DATA payload is complete — NOT on QUIT, because a pooled
/// background thread. `ehlo_reply` is the multi-line EHLO response
/// (must end in `\r\n`); omit STARTTLS to simulate a server that
/// cannot upgrade. The captured conversation is reported as soon as
/// the DATA payload is complete — NOT on QUIT, because a pooled
/// client may hold the connection open after the send.
pub fn start() -> (u16, mpsc::Receiver<Captured>) {
pub fn start_with_caps(ehlo_reply: &'static str) -> (u16, mpsc::Receiver<Captured>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
let port = listener.local_addr().expect("addr").port();
let (tx, rx) = mpsc::channel();
@ -1337,8 +1342,9 @@ mod tests {
break;
}
let cmd = line.trim_end().to_string();
captured.commands.push(cmd.clone());
if cmd.starts_with("EHLO") || cmd.starts_with("HELO") {
respond("250-mock.example\r\n250 8BITMIME\r\n").unwrap();
respond(ehlo_reply).unwrap();
} else if cmd.starts_with("MAIL FROM:") {
captured.mail_from.push(cmd);
respond("250 Ok\r\n").unwrap();
@ -1374,6 +1380,11 @@ mod tests {
(port, rx)
}
/// A sink that does NOT advertise STARTTLS.
pub fn start() -> (u16, mpsc::Receiver<Captured>) {
start_with_caps("250-mock.example\r\n250 8BITMIME\r\n")
}
}
/// A message sent through `build_email_message` + a transport reaches
@ -1489,4 +1500,213 @@ mod tests {
"the error should name the timeout, got {err:?}"
);
}
// ---- §8 follow-up: the TLS handshake, executed ----------------------
//
// The assessment's "What I have not verified" listed the TLS handshake
// and a MITM test as gaps: the S1/S3 analysis of `relay()` /
// `TlsParameters::new` was a read of lettre's source, not an observed
// handshake. These two tests execute it, against the PRODUCTION
// `build_transport` path (not `builder_dangerous`).
/// The STARTTLS downgrade attack (S3/T-E1), observed.
///
/// A server that does not advertise STARTTLS must cause the production
/// transport (port → `starttls_relay`, `Tls::Required`) to fail WITHOUT
/// sending `AUTH`, `MAIL FROM`, `RCPT TO` or `DATA` — credentials and
/// the message never leave the device over a cleartext link. This is
/// the property lettre's docs promise ("no credentials or emails will
/// be sent to the server, protecting from downgrade attacks") and the
/// assessment had only read, not observed.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_starttls_downgrade_is_refused_without_sending_credentials() {
// No STARTTLS capability.
let (port, rx) =
smtp_sink::start_with_caps("250-mock.example\r\n250-SIZE 100000\r\n250 8BITMIME\r\n");
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime");
let result = rt.block_on(async {
let config = SmtpConfig {
server: "127.0.0.1".into(),
port,
username: "jane@example.com".into(),
password: Secret::new("hunter2"),
from: "jane@example.com".into(),
};
let email =
build_email_message(&config, "a@x.com", "Hi", "Body").expect("message builds");
// The PRODUCTION path: an ephemeral (non-465) port selects the
// StartTls arm, which requires the upgrade.
let creds =
Credentials::new(config.username.clone(), config.password.expose().to_owned());
let transport = build_transport(&config, creds).expect("transport constructs");
send_bounded(&transport, email, std::time::Duration::from_secs(3)).await
});
assert!(result.is_err(), "must refuse when STARTTLS is unavailable");
let captured = rx
.recv_timeout(std::time::Duration::from_secs(10))
.expect("the sink should report the conversation");
// The decisive assertions: nothing sensitive was sent.
assert!(
!captured.commands.iter().any(|c| c.starts_with("AUTH")),
"credentials were sent over a link that never upgraded to TLS: {:?}",
captured.commands
);
assert!(captured.mail_from.is_empty(), "MAIL FROM sent without TLS");
assert!(captured.rcpt_to.is_empty(), "RCPT TO sent without TLS");
assert!(captured.data.is_empty(), "message body sent without TLS");
}
/// Certificate validation (S1), observed.
///
/// A server that upgrades to TLS but presents a self-signed
/// certificate must be REJECTED by the production transport, whose
/// `TlsParameters::new` sets `accept_invalid_certs: false`. This is the
/// "active MITM" scenario (a server impersonating the relay) and it is
/// the exact claim the assessment had to correct itself on after
/// reading `builder_dangerous` — now it is executed, not read.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_self_signed_certificate_is_rejected() {
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime");
rt.block_on(async {
// rustls 0.23 with only the `ring` feature does not install a
// process-wide default crypto provider; the client (lettre) may
// or may not have. Install it (idempotent) so `ServerConfig`
// can sign with the generated ECDSA key.
let _ = rustls::crypto::ring::default_provider().install_default();
// Mint a self-signed certificate for "localhost". It is NOT in
// the client's root store (webpki-roots), so a validating
// client must reject it.
let certified = rcgen::generate_simple_self_signed(vec!["localhost".to_string()])
.expect("mint self-signed cert");
let server_config = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(
vec![certified.cert.der().clone()],
rustls::pki_types::PrivateKeyDer::Pkcs8(
certified.key_pair.serialize_der().into(),
),
)
.expect("server config");
let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(server_config));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let port = listener.local_addr().expect("addr").port();
// A server that advertises STARTTLS, upgrades, and serves the
// self-signed cert. It records whether it ever saw AUTH (it
// must not — the client bails at the failed handshake).
let saw_auth = Arc::new(AtomicBool::new(false));
let saw_auth_server = saw_auth.clone();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.expect("accept");
stream
.write_all(b"220 mock.example ESMTP\r\n")
.await
.unwrap();
let mut buf = Vec::new();
loop {
buf.clear();
if read_smtp_line(&mut stream, &mut buf).await.unwrap() == 0 {
break;
}
let cmd = String::from_utf8_lossy(&buf).trim_end().to_string();
if cmd.starts_with("EHLO") || cmd.starts_with("HELO") {
stream
.write_all(b"250-mock.example\r\n250-STARTTLS\r\n250 8BITMIME\r\n")
.await
.unwrap();
} else if cmd == "STARTTLS" {
stream
.write_all(b"220 Ready to start TLS\r\n")
.await
.unwrap();
// Upgrade to TLS; the client must reject our cert.
let handshake = acceptor.accept(stream).await;
assert!(
handshake.is_err(),
"the client accepted a self-signed certificate!"
);
break;
} else if cmd.starts_with("AUTH") {
saw_auth_server.store(true, Ordering::Relaxed);
} else {
stream.write_all(b"250 Ok\r\n").await.unwrap();
}
}
});
let config = SmtpConfig {
server: "127.0.0.1".into(),
port,
username: "jane@example.com".into(),
password: Secret::new("hunter2"),
from: "jane@example.com".into(),
};
let email =
build_email_message(&config, "a@x.com", "Hi", "Body").expect("message builds");
let creds =
Credentials::new(config.username.clone(), config.password.expose().to_owned());
// The PRODUCTION path: ephemeral port → starttls_relay (Tls::Required).
let transport = build_transport(&config, creds).expect("transport constructs");
let result = send_bounded(&transport, email, std::time::Duration::from_secs(5)).await;
assert!(
result.is_err(),
"a self-signed certificate must be rejected, got {result:?}"
);
// The server's own assertion (handshake rejected) already ran;
// await it and confirm the client never reached AUTH.
server.await.expect("server task");
assert!(
!saw_auth.load(Ordering::Relaxed),
"the client sent AUTH despite the failed handshake"
);
});
}
/// Read one `\r\n`-terminated SMTP line without owning the stream, so
/// the caller can hand the stream to the TLS acceptor afterwards.
#[cfg(not(target_arch = "wasm32"))]
async fn read_smtp_line(
stream: &mut tokio::net::TcpStream,
buf: &mut Vec<u8>,
) -> std::io::Result<usize> {
use tokio::io::AsyncReadExt;
buf.clear();
let mut byte = [0u8; 1];
loop {
match stream.read(&mut byte).await? {
0 => return Ok(0),
_ => {
buf.push(byte[0]);
if buf.ends_with(b"\r\n") {
return Ok(buf.len());
}
}
}
}
}
}