Compare commits

...

4 commits

Author SHA1 Message Date
fc0b1f287f ci(email): run the conversation-kit tests; mark Phase E complete
Some checks failed
email.yml / ci(email): run the conversation-kit tests; mark Phase E complete (push) Failing after 0s
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
repo hygiene / hygiene (push) Has been cancelled
sms / robius-sms (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 / android (push) Has been cancelled
sms / nigig-sms (push) Has been cancelled
sms / supply-chain (push) Has been cancelled
email.yml: the domain test floor ratchets 190 -> 205, and the nigig-email
job runs cargo test -p nigig-uikit --lib -- conversation so an email-driven
regression in the shared kit cannot silently surface in SMS.

The review doc marks E1-E6 done and records the honest correction E5
surfaced: lettre's timeout bounds only the TCP connect, not the
greeting/command reads — the send path now bounds the whole operation.
2026-08-17 05:09:15 +00:00
1262e71f9a test(uikit): pin the shared conversation click guard (E6)
The conversation preview row is consumed by BOTH SMS and email, so a
regression there moves two features. Extract should_emit_clicked as a pure
function and test it: an empty address suppresses the Clicked action, and
so does a scroll in progress. The Clicked payload and props binding are
also pinned. nigig-uikit gains its first tests.
2026-08-17 05:09:15 +00:00
c51d448ba0 test(email): SMTP sink integration test, and bound the whole send (E5)
Extract build_email_message (the pure message construction) and send_bounded
(the whole send wrapped in platform::timeout). A hand-rolled SMTP sink on
127.0.0.1 now receives a real send and asserts the envelope, every
recipient, and the DATA payload — the first time the SMTP conversation has
been executed in this repo.

This surfaced a real defect: lettre's .timeout() only bounds the TCP
connect, not the greeting/command reads, so a server that accepts and never
greets hangs the send indefinitely (the review's A6/P4 '60s default' claim
was wrong for the read path). send_bounded closes that gap, and
a_send_to_a_silent_server_errors_instead_of_hanging pins it.
2026-08-17 05:09:15 +00:00
d65f0cd3b8 test(email): property-test the parsers (E2)
proptest dev-dependency (the same one the SMS crate uses) and a new
email_properties module pinning 'never panic + structural invariants' for
the untrusted-input parsers: looks_like_email, looks_like_hostname,
parse_recipients, is_plausible_address, preview_line (the A3 byte-offset
class of bug) and parse_imap_date. Arbitrary input must not panic, and an
accepted verdict must satisfy the structural checks it exists to enforce.
2026-08-17 05:09:15 +00:00
8 changed files with 492 additions and 32 deletions

View file

@ -409,11 +409,12 @@ jobs:
# A floor, not a ratchet: these tests are cheap, pure, and the # 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; # number should only go up. 38 at Phase 0; 154 after C1c/C1d;
# 195 after the Phase C/D completion. # 195 after the Phase C/D completion; 214 after Phase E (proptest +
# the SMTP sink integration tests).
- name: The email domain test suite must not shrink - name: The email domain test suite must not shrink
run: | run: |
set -euo pipefail set -euo pipefail
FLOOR=190 FLOOR=205
out="$(cargo test --locked -p nigig-core --lib -- email_ secret:: mail_backend:: mail_proxy:: imap_client:: credential_store:: 2>&1)" 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 # C1e/C1f: the IMAP transport and the platform keystore are both
@ -482,6 +483,14 @@ jobs:
- name: Test - name: Test
run: cargo test --locked -p nigig-email run: cargo test --locked -p nigig-email
# E6: the shared conversation kit (nigig-uikit/src/shared/conversation)
# is consumed by BOTH SMS and email, so a regression there moves two
# features at once. It now has its own tests pinning the click guard
# and payload; run them so an email-driven regression cannot silently
# surface in SMS.
- name: Conversation kit tests
run: cargo test --locked -p nigig-uikit --lib -- conversation
# Phase 0.6. A hard gate, not report-only: this crate is 1,100 # Phase 0.6. A hard gate, not report-only: this crate is 1,100
# lines and already formats clean, so there is no pre-existing # lines and already formats clean, so there is no pre-existing
# drift to grandfather in. Contrast sms.yml and nigig-map.yml, # drift to grandfather in. Contrast sms.yml and nigig-map.yml,

1
Cargo.lock generated
View file

@ -2984,6 +2984,7 @@ dependencies = [
"matrix_client", "matrix_client",
"nigig-system-prefs", "nigig-system-prefs",
"postcard", "postcard",
"proptest",
"rand 0.8.7", "rand 0.8.7",
"reqwest", "reqwest",
"robius-directories", "robius-directories",

View file

@ -422,6 +422,21 @@ the unbounded hang I first assumed. But the application sets no timeout of its
own, so the user watches `"Testing..."` for a full minute with no way to abort, own, so the user watches `"Testing..."` for a full minute with no way to abort,
and a `send` that spans several commands can exceed that. and a `send` that spans several commands can exceed that.
> **Corrected (Phase E, from the SMTP-sink integration test):** the
> "60-second default" is NOT what I thought. I read lettre 0.11.23's
> source again, this time to write an actual test rather than to reassure
> myself. `AsyncSmtpTransportBuilder::timeout()` (and its 60s
> `DEFAULT_TIMEOUT`) is passed to the **TCP connect** — but the greeting
> and every command response are read by `AsyncSmtpConnection::read_response()`,
> which calls `stream.read_line()` with **no timeout at all**. A server
> that accepts the connection and then never greets hangs the send
> *indefinitely*, not for 60 seconds. The integration test
> `a_send_to_a_silent_server_errors_instead_of_hanging` demonstrated the
> hang before the fix and its absence after. The send path now wraps the
> whole operation in `platform::timeout(SMTP_TIMEOUT_SECS)`, which is what
> A6 actually meant. This is the same lesson as S1: a claim I had "checked"
> was wrong, and only executing it caught it.
Combined with **B4** (no in-flight guard), a user who taps Send during those 60 Combined with **B4** (no in-flight guard), a user who taps Send during those 60
seconds queues a second delivery. seconds queues a second delivery.
@ -532,6 +547,7 @@ snapshot. Commits are on `main`.
| *(this turn)* | **C1d + C1c DONE**`mail_proxy.rs` (the proxy HTTP client: request/response parsing, error mapping, a transport trait with reqwest/fetch/mock impls); `SetupDraft` (identity + backend, validated together); the backend chooser and two setup forms in `EmailAccountSetup`; the inbox branches to `spawn_proxy_verify` for the proxy backend. Domain tests **126 → 154**. Coverage tooling (`tools/test-email-coverage.sh`, 93.4% line, floors enforced) wired into `email.yml`. | | *(this turn)* | **C1d + C1c DONE**`mail_proxy.rs` (the proxy HTTP client: request/response parsing, error mapping, a transport trait with reqwest/fetch/mock impls); `SetupDraft` (identity + backend, validated together); the backend chooser and two setup forms in `EmailAccountSetup`; the inbox branches to `spawn_proxy_verify` for the proxy backend. Domain tests **126 → 154**. Coverage tooling (`tools/test-email-coverage.sh`, 93.4% line, floors enforced) wired into `email.yml`. |
| *(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)* | **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)* | **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. |
**Phase 0 is complete.** All seven items done; 0.7 was fixed upstream. **Phase 0 is complete.** All seven items done; 0.7 was fixed upstream.
@ -800,12 +816,12 @@ presenting them as equivalent choices.
| ID | Task | | ID | Task |
|---|---| |---|---|
| E1 | **Unit-test the pure logic**: port→transport, address parsing/splitting, validation, error mapping. Target ≥40 tests; these need no network. | | ~~E1~~ | ~~**Unit-test the pure logic.**~~ **DONE** — port→transport, address parsing/splitting, validation, error mapping are all host-tested (214 domain tests at Phase E, up from the 40-target). |
| E2 | **Property-test address parsing** (proptest is already a dev-dep in SMS) — never panic on arbitrary input. | | ~~E2~~ | ~~**Property-test address parsing.**~~ **DONE**`proptest` dev-dependency; `email_properties.rs` pins "never panic + structural invariants" for `looks_like_email`, `looks_like_hostname`, `parse_recipients`, `is_plausible_address`, `preview_line` (the A3 byte-offset class) and `parse_imap_date`. |
| E3 | **Test-count floor gate**, as SMS has (`FLOOR=100`). | | ~~E3~~ | ~~**Test-count floor gate.**~~ **DONE**`email.yml` enforces `FLOOR=205`. |
| E4 | **Clippy ratchet** at the measured baseline. | | ~~E4~~ | ~~**Clippy ratchet.**~~ **DONE** — ratchet at the measured baseline (0 nigig-email-owned diagnostics). |
| E5 | **Integration test** against a local SMTP sink (e.g. a `MockSmtp` listener on 127.0.0.1) — verifies the transport path without a real provider. | | ~~E5~~ | ~~**Integration test against a local SMTP sink.**~~ **DONE** — a hand-rolled SMTP sink on 127.0.0.1 receives a real `build_email_message` + transport send and asserts the envelope, every recipient, and the DATA payload; a silent-server test asserts the operation **fails within the timeout**. This surfaced 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` (see the A6/P4 correction below). |
| **E6** | **NEW — test the shared conversation kit itself.** `nigig-uikit/src/shared/conversation/` is now consumed by both SMS and email, so a change there moves two features and an email-driven regression can surface in SMS. It currently has no tests of its own. At minimum: `SharedConversationPreviewProps` → emitted `Clicked` payload, and that an empty address suppresses the action (the row already guards this, but nothing pins it). | | ~~E6~~ | ~~**Test the shared conversation kit.**~~ **DONE**`should_emit_clicked` extracted as a pure function and pinned (empty address and scroll suppress the `Clicked` action); the `Clicked` payload and props binding are tested. `nigig-uikit` now has its own tests, run in CI. |
--- ---
@ -827,10 +843,15 @@ convincingly than any client-side fix can.
Stated plainly, because the point of this document is to be trusted: Stated plainly, because the point of this document is to be trusted:
- **No SMTP path has been executed.** No live server, no MITM test. The S1/S3 - **The SMTP conversation has been executed, but not the TLS handshake.**
analysis is a read of `lettre` 0.11.23's vendored source (`relay()`, Phase E5 runs a real send against a local mock SMTP sink (envelope, every
`TlsParameters::new`, `TlsVersion`), which is why I was able to catch my own recipient, and the DATA payload are asserted), and a silent-server test
error — but it is still a read, not an observed handshake. 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.
- **I got S1 wrong on the first pass** and wrote it up as critical credential - **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. 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 Flagging it because a document like this is worth nothing if you cannot tell

View file

@ -59,6 +59,11 @@ async-net = { version = "2", optional = true }
# C1f: a real platform keystore (feature `keystore`), native only. # C1f: a real platform keystore (feature `keystore`), native only.
keyring = { version = "4", optional = true } keyring = { version = "4", optional = true }
[dev-dependencies]
# E2: property-test the address/date parsers — never panic on arbitrary
# input. Same dev-dependency the SMS crate already uses.
proptest = "1"
[target.'cfg(target_arch = "wasm32")'.dependencies] [target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = "0.2" wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4" wasm-bindgen-futures = "0.4"

View file

@ -0,0 +1,95 @@
//! Property tests for the email parsers (Phase E2).
//!
//! The address, hostname, recipient and date parsers all take *untrusted*
//! input — a `To` field, a server field, an `INTERNALDATE` header — so they
//! must never panic on arbitrary input, and their accept/reject decisions
//! must satisfy structural invariants. A unit test can only cover the
//! inputs its author happened to think of; these properties are the
//! guarantee that *any* input behaves.
//!
//! `proptest` is a dev-dependency (the same one the SMS crate uses), so
//! these run under `cargo test` and are picked up by the email domain
//! filter in CI.
use proptest::prelude::*;
use crate::email_account::{looks_like_email, looks_like_hostname};
use crate::email_send::{is_plausible_address, parse_recipients};
use crate::email_store::preview_line;
use crate::imap_client::parse_imap_date;
proptest! {
/// `looks_like_email` never panics, and a `true` verdict implies the
/// structural properties the function exists to check: exactly one `@`,
/// non-empty local and domain, and a dot in the domain.
#[test]
fn looks_like_email_never_panics_and_is_structural(s in any::<String>()) {
let verdict = looks_like_email(&s);
if verdict {
let t = s.trim();
prop_assert_eq!(
t.matches('@').count(),
1,
"an accepted address must have exactly one '@'"
);
let (local, domain) = t.split_once('@').expect("one @ was asserted");
prop_assert!(!local.is_empty(), "empty local part");
prop_assert!(!domain.is_empty(), "empty domain");
prop_assert!(domain.contains('.'), "dotless domain");
}
}
/// `looks_like_hostname` never panics, and a `true` verdict rejects the
/// things a hostname must not contain (a scheme, a path, whitespace).
#[test]
fn looks_like_hostname_never_panics_and_rejects_schemes(s in any::<String>()) {
let verdict = looks_like_hostname(&s);
if verdict {
let t = s.trim();
prop_assert!(!t.contains("://"), "scheme in accepted hostname");
prop_assert!(!t.contains('/'), "path in accepted hostname");
prop_assert!(
!t.chars().any(char::is_whitespace),
"whitespace in accepted hostname"
);
}
}
/// `parse_recipients` never panics, and every address it accepts is one
/// `is_plausible_address` also accepts — the parser must not be more
/// lenient than the plausibility check it is built on.
#[test]
fn parse_recipients_never_panics_and_accepts_only_plausible(s in any::<String>()) {
let list = parse_recipients(&s);
for r in &list.accepted {
prop_assert!(
is_plausible_address(&r.address),
"accepted an implausible address"
);
}
}
/// `is_plausible_address` never panics on any input.
#[test]
fn is_plausible_address_never_panics(s in any::<String>()) {
let _ = is_plausible_address(&s);
}
/// `preview_line` never panics on any body and any limit. This is the
/// A3 class of bug — byte-offset slicing panics on multibyte text — and
/// the property is simply "no panic", on every body and every limit.
#[test]
fn preview_line_never_panics_on_any_body_and_limit(
body in any::<String>(),
max in 0usize..2000usize,
) {
let _ = preview_line(&body, max);
}
/// `parse_imap_date` never panics on any input; it returns `Option`,
/// so garbage must simply be `None`, not a panic.
#[test]
fn parse_imap_date_never_panics_on_any_input(s in any::<String>()) {
let _ = parse_imap_date(&s);
}
}

View file

@ -474,21 +474,30 @@ use lettre::{
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
async fn smtp_test_impl(config: &SmtpConfig) -> Result<(), String> { async fn smtp_test_impl(config: &SmtpConfig) -> Result<(), String> {
let mailer = acquire_transport(config)?; let mailer = acquire_transport(config)?;
mailer // A6: bound the WHOLE operation, not just the TCP connect. lettre's
.transport() // `.timeout()` only bounds the connect; a server that accepts and then
.test_connection() // never greets (or never answers a command) would hang the read
.await // forever. `platform::timeout` closes that gap (E5 integration test
.map(|_| ()) // `a_send_to_a_silent_server_errors_instead_of_hanging` pins it).
.map_err(|e| format!("SMTP test failed: {e}")) let outcome = crate::platform::timeout(
std::time::Duration::from_secs(SMTP_TIMEOUT_SECS),
mailer.transport().test_connection(),
)
.await;
match outcome {
Ok(Ok(_)) => Ok(()),
Ok(Err(e)) => Err(format!("SMTP test failed: {e}")),
Err(_) => Err(format!("SMTP test timed out after {SMTP_TIMEOUT_SECS}s")),
}
} }
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
async fn send_email_impl( fn build_email_message(
config: &SmtpConfig, config: &SmtpConfig,
to: &str, to: &str,
subject: &str, subject: &str,
body: &str, body: &str,
) -> Result<(), String> { ) -> Result<Message, String> {
let from_mbox: Mailbox = config let from_mbox: Mailbox = config
.from .from
.parse() .parse()
@ -516,16 +525,48 @@ async fn send_email_impl(
.map_err(|e| format!("Invalid recipient {}: {e}", r.address))?; .map_err(|e| format!("Invalid recipient {}: {e}", r.address))?;
builder = builder.to(mbox); builder = builder.to(mbox);
} }
let email = builder builder
.body(body.to_owned()) .body(body.to_owned())
.map_err(|e| format!("Build error: {e}"))?; .map_err(|e| format!("Build error: {e}"))
}
/// Send one message, bounded by an explicit timeout.
///
/// A6: the whole send — connect AND the SMTP command reads — is bounded.
/// lettre's own `.timeout()` only covers the TCP connect, so a server that
/// accepts and then never greets (or never answers a command) would hang
/// the read forever. This is the one place that guarantee is made, so both
/// the production path and the integration test (`a_send_to_a_silent_…`)
/// go through it.
#[cfg(not(target_arch = "wasm32"))]
async fn send_bounded(
transport: &AsyncSmtpTransport<Tokio1Executor>,
email: Message,
timeout: std::time::Duration,
) -> Result<(), String> {
let outcome = crate::platform::timeout(timeout, transport.send(email)).await;
match outcome {
Ok(Ok(_)) => Ok(()),
Ok(Err(e)) => Err(format!("Send failed: {e}")),
Err(_) => Err(format!("Send timed out after {}s", timeout.as_secs())),
}
}
#[cfg(not(target_arch = "wasm32"))]
async fn send_email_impl(
config: &SmtpConfig,
to: &str,
subject: &str,
body: &str,
) -> Result<(), String> {
let email = build_email_message(config, to, subject, body)?;
let mailer = acquire_transport(config)?; let mailer = acquire_transport(config)?;
mailer send_bounded(
.transport() mailer.transport(),
.send(email) email,
std::time::Duration::from_secs(SMTP_TIMEOUT_SECS),
)
.await .await
.map_err(|e| format!("Send failed: {e}"))?;
Ok(())
} }
/// Which TLS mode a port implies. /// Which TLS mode a port implies.
@ -1239,4 +1280,213 @@ mod tests {
fn protocol_relative_urls_are_rejected() { fn protocol_relative_urls_are_rejected() {
assert!(!email_api_url_is_safe("//mail.example.com/api")); assert!(!email_api_url_is_safe("//mail.example.com/api"));
} }
// ---- E5: a real SMTP conversation against a local sink -------------
//
// This is the first time the SMTP path is *executed* in this repo
// (the assessment's "What I have not verified" listed it). A minimal
// SMTP server runs on 127.0.0.1 in a thread; `build_email_message`
// constructs the message and a PLAINTEXT transport delivers it, and
// the sink records exactly what it received. The production transport
// always uses TLS (A2/A3, gated in CI); that layer is pinned by
// `tls_mode_for_port` and `build_transport_constructs_for_every_port`
// above. This test pins the conversation beneath it: envelope, every
// recipient, and the DATA payload.
/// 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).
#[cfg(not(target_arch = "wasm32"))]
mod smtp_sink {
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
use std::sync::mpsc;
#[derive(Clone, Debug, Default)]
pub struct Captured {
pub mail_from: Vec<String>,
pub rcpt_to: Vec<String>,
pub data: 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
/// client may hold the connection open after the send.
pub fn start() -> (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();
std::thread::spawn(move || {
let (stream, _) = listener.accept().expect("accept");
let mut reader = BufReader::new(stream.try_clone().expect("clone"));
let mut captured = Captured::default();
let respond = |code: &str| -> std::io::Result<()> {
let mut w = stream.try_clone().expect("clone");
w.write_all(code.as_bytes())?;
w.flush()
};
respond("220 mock.example ESMTP\r\n").unwrap();
let mut line = String::new();
loop {
line.clear();
if reader.read_line(&mut line).unwrap() == 0 {
break;
}
let cmd = line.trim_end().to_string();
if cmd.starts_with("EHLO") || cmd.starts_with("HELO") {
respond("250-mock.example\r\n250 8BITMIME\r\n").unwrap();
} else if cmd.starts_with("MAIL FROM:") {
captured.mail_from.push(cmd);
respond("250 Ok\r\n").unwrap();
} else if cmd.starts_with("RCPT TO:") {
captured.rcpt_to.push(cmd);
respond("250 Ok\r\n").unwrap();
} else if cmd == "DATA" {
respond("354 End data with <CR><LF>.<CR><LF>\r\n").unwrap();
loop {
line.clear();
reader.read_line(&mut line).unwrap();
if line == ".\r\n" || line == ".\n" || line == "." {
break;
}
captured.data.push_str(&line);
}
respond("250 Ok: queued\r\n").unwrap();
// Report now; do not wait for QUIT/EOF.
let _ = tx.send(captured.clone());
} else if cmd == "QUIT" {
respond("221 Bye\r\n").unwrap();
break;
} else {
// RSET, NOOP, or anything unexpected: accept it so
// the conversation can continue.
respond("250 Ok\r\n").unwrap();
}
}
// If the client never sent DATA (e.g. it aborted), still
// report what little we saw so the test does not hang.
let _ = tx.send(captured);
});
(port, rx)
}
}
/// A message sent through `build_email_message` + a transport reaches
/// the sink with the right envelope, every recipient, and the body.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_real_send_reaches_a_mock_smtp_server() {
use lettre::AsyncSmtpTransport;
let (port, rx) = smtp_sink::start();
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("runtime");
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, b@y.com",
"Hello from the test",
"Body text line.",
)
.expect("message should build");
// Plaintext, test-only. The production transport always uses
// TLS (see build_transport); this proves the SMTP conversation
// itself over a real TCP socket.
let mailer = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous("127.0.0.1")
.port(port)
.build();
mailer.send(email).await.expect("send should succeed");
});
let captured = rx
.recv_timeout(std::time::Duration::from_secs(10))
.expect("the sink should report the conversation");
assert_eq!(captured.mail_from, vec!["MAIL FROM:<jane@example.com>"]);
assert_eq!(
captured.rcpt_to,
vec!["RCPT TO:<a@x.com>", "RCPT TO:<b@y.com>"]
);
// The DATA payload carries the subject header and the body.
assert!(
captured.data.contains("Hello from the test"),
"subject missing from DATA: {:?}",
captured.data
);
assert!(
captured.data.contains("Body text line."),
"body missing from DATA: {:?}",
captured.data
);
}
/// A send to a server that accepts but never speaks (a black hole)
/// fails within the timeout instead of hanging forever — the A6
/// timeout is wired into the real transport path, not just declared.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn a_send_to_a_silent_server_errors_instead_of_hanging() {
use lettre::AsyncSmtpTransport;
// A server that accepts the connection and then says nothing. It
// holds the socket open (blocked on a channel) so the client hangs
// waiting for the greeting unless it times out.
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let port = listener.local_addr().expect("addr").port();
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
let server = std::thread::spawn(move || {
let _stream = listener.accept().expect("accept");
// Hold the connection open until the test releases us.
let _ = release_rx.recv();
});
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 should build");
let mailer = AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous("127.0.0.1")
.port(port)
.build();
// The same bounded path production uses, with a short timeout.
send_bounded(&mailer, email, std::time::Duration::from_secs(1)).await
});
// Release the server thread so the process can exit.
let _ = release_tx.send(());
let _ = server.join();
let err = result.expect_err("a silent server must produce an error");
assert!(
err.contains("timed out"),
"the error should name the timeout, got {err:?}"
);
}
} }

View file

@ -32,6 +32,9 @@ pub mod email_cache;
pub mod imap_client; pub mod imap_client;
pub mod email_session; pub mod email_session;
#[cfg(test)]
mod email_properties;
pub use dir::app_data_dir; pub use dir::app_data_dir;
pub use persistence::*; pub use persistence::*;

View file

@ -1,6 +1,5 @@
use makepad_widgets::*;
use makepad_widgets::ActionDefaultRef; use makepad_widgets::ActionDefaultRef;
use makepad_widgets::*;
script_mod! { script_mod! {
use mod.prelude.widgets.* use mod.prelude.widgets.*
@ -94,6 +93,18 @@ pub struct SharedConversationPreviewProps {
pub was_scrolling: bool, pub was_scrolling: bool,
} }
/// Should a tap on this row emit a `Clicked` action?
///
/// Pure so the guard is testable without a `Cx` (E6). A row with an empty
/// address is a placeholder and must never open a thread, and a scroll in
/// progress (a fling) must never be read as a tap. Both SMS and email feed
/// this widget; this function is the single source of truth for the guard,
/// so a regression here surfaces in both features rather than silently in
/// one.
pub fn should_emit_clicked(address: &str, was_scrolling: bool) -> bool {
!address.is_empty() && !was_scrolling
}
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
pub enum SharedConversationPreviewAction { pub enum SharedConversationPreviewAction {
#[default] #[default]
@ -134,7 +145,10 @@ impl Widget for SharedConversationPreview {
self.view.handle_event(cx, event, scope); self.view.handle_event(cx, event, scope);
if self.address.is_empty() { // E6: an empty address (a placeholder row) must never open a
// thread, and a scroll in progress must not be read as a tap. The
// decision is pinned by a unit test below.
if !should_emit_clicked(&self.address, self.was_scrolling) {
return; return;
} }
@ -142,7 +156,7 @@ impl Widget for SharedConversationPreview {
match event.hits(cx, area) { match event.hits(cx, area) {
Hit::FingerDown(_fe) => cx.set_key_focus(area), Hit::FingerDown(_fe) => cx.set_key_focus(area),
Hit::FingerUp(fe) => { Hit::FingerUp(fe) => {
if fe.was_tap() && fe.is_over && !self.was_scrolling { if fe.was_tap() && fe.is_over {
cx.widget_action( cx.widget_action(
self.widget_uid(), self.widget_uid(),
SharedConversationPreviewAction::Clicked { SharedConversationPreviewAction::Clicked {
@ -166,4 +180,66 @@ impl Widget for SharedConversationPreview {
} }
} }
#[cfg(test)]
mod tests {
use super::*;
// E6: the conversation kit is consumed by BOTH SMS and email, so a
// regression here moves two features at once. These tests pin the
// click guard and the payload shape, which previously had no coverage.
/// A row with an address, not mid-scroll, may emit Clicked.
#[test]
fn a_row_with_an_address_may_emit_clicked() {
assert!(should_emit_clicked("alerts@bank.co.ke", false));
}
/// The empty-address guard: a placeholder row must never open a thread.
#[test]
fn an_empty_address_suppresses_the_click() {
assert!(!should_emit_clicked("", false));
}
/// A fling must not be read as a tap.
#[test]
fn a_scroll_suppresses_the_click() {
assert!(!should_emit_clicked("alerts@bank.co.ke", true));
assert!(!should_emit_clicked("", true));
}
/// The Clicked payload carries exactly the address and display name the
/// row was bound with, so a feature can open the right thread.
#[test]
fn the_clicked_payload_carries_address_and_display_name() {
let payload = SharedConversationPreviewAction::Clicked {
address: "alerts@bank.co.ke".into(),
display_name: "Equity Alerts".into(),
};
match payload {
SharedConversationPreviewAction::Clicked {
address,
display_name,
} => {
assert_eq!(address, "alerts@bank.co.ke");
assert_eq!(display_name, "Equity Alerts");
}
other => panic!("expected Clicked, got {other:?}"),
}
}
/// The props are the binding between a list row and the emitted action:
/// what a feature puts in (address, display_name) is what a click
/// carries back. Pinned so the two cannot drift apart.
#[test]
fn props_carry_the_address_and_display_name_a_click_will_emit() {
let props = SharedConversationPreviewProps {
address: "jane@example.com".into(),
display_name: "Jane".into(),
was_scrolling: false,
};
assert_eq!(props.address, "jane@example.com");
assert_eq!(props.display_name, "Jane");
assert!(!props.was_scrolling);
assert!(should_emit_clicked(&props.address, props.was_scrolling));
}
}