nigig-org/REVIEWS/NIGIG_EMAIL_ASSESSMENT_AND_PLAN.md
andodeki 3928063392
Some checks failed
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
nigig-build (CAD) / doc-workspace-coverage (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
email / gates (push) Has been cancelled
email / email-domain (push) Has been cancelled
email / nigig-email (push) Has been cancelled
email / supply-chain (push) Has been cancelled
ci(email): raise the domain floor; record the finance-email path
email.yml: FLOOR 225 -> 230. The review doc records the email-to-finance
sharing and notes the chat/Matrix path remains unbuilt (matrix_client has
login+sync only).
2026-08-18 10:07:54 +00:00

887 lines
50 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# nigig-email — Brutal Assessment & Execution Plan
Repository: `https://gitdab.com/andodeki/nigig-org.git`
Crate: `crates/apps/nigig-email` (+ `crates/nigig-core/src/email_worker.rs`)
Assessed at: `2770833` (remote pulled, 61 commits ahead of previous session)
Method: full read of all 11 source files; every claim below was executed or grepped, not inferred.
---
## Verdict
**Overall: 2.4 / 10.**
| Dimension | Score | One-line reason |
|---|---|---|
| Architecture | 2 / 10 | No domain model, no receive path, logic inside widgets |
| Performance | 4 / 10 | Small enough not to hurt yet; 10 allocs/event and no pooling are baked in |
| Correctness | 1 / 10 | Binary does not compile; the one advertised feature (bulk) cannot work |
| Design | 2 / 10 | Four byte-identical placeholder pages; shim pile in `lib.rs` |
| Security | 3 / 10 | Password is `Serialize`+`Debug`; no validation. TLS is actually fine. |
| Code quality | 1 / 10 | Zero tests, zero CI, 4 unused deps, dead file, never formatted |
This is not an email client. It is a **navigation shell with four identical placeholder pages** plus one working SMTP send form bolted onto the tab labelled "Bulk" — which cannot send in bulk.
The headline findings, all verified:
| # | Finding | Severity |
|---|---|---|
| **1** | **The binary does not compile.** `main.rs` has unbalanced braces. `cargo check -p nigig-email` fails. | Blocker |
| **2** | **"To (comma-separated)" parses as a single mailbox** — every multi-recipient send fails | Critical |
| **3** | **SMTP password derives `Serialize`** and is POSTed as plaintext JSON on wasm | Critical |
| **4** | **No receive path exists.** No IMAP, no POP3, no fetch. The Inbox cannot show mail. | Critical |
| **5** | **Zero tests.** In the whole crate and in `email_worker.rs`. | Critical |
| **6** | **Zero CI.** No workflow references `nigig-email`. | Critical |
| **7** | **No in-flight guard** — double-tapping Send delivers the email twice | High |
| **8** | Config is rebuilt from 5 text inputs **on every action event** | High |
| **9** | `port.parse().unwrap_or(587)` silently rewrites a typo'd port, changing transport | High |
| **10** | `drafts.rs` (157 lines) is not declared in `mod.rs` — dead code that never compiles | Medium |
| **11** | 3 of 4 dependencies (`serde`, `serde_json`, `chrono`) and `robius-location` are **unused** | Medium |
| **12** | Workspace **violates its own 40-char git-rev CI gate** in 42 places (repo-wide, blocks any green CI here) | Medium |
One finding I initially rated Critical — TLS validation on port 465 — **is not a
defect**. I checked `lettre`'s source and was wrong; see §1 S1. It is recorded
as Medium (code smell, not vulnerability) rather than deleted, because knowing
which claims survived scrutiny matters more than a tidy list.
Scale: **1,148 lines** across 11 files, of which roughly **600 are copy-pasted scaffold**.
---
## 0. The crate does not build
```
$ cargo check -p nigig-email
error: unexpected closing delimiter: `}`
--> crates/apps/nigig-email/src/main.rs:24:1
```
`cargo check -p nigig-email --lib`**0 errors**. The library is fine; the **binary target is broken**.
Look at the nesting in `main.rs`:
```rust
body +: {
root := mod.widgets.StandaloneFeatureShell {
root_screen := mod.widgets.EmailScreen {}
} // closes StandaloneFeatureShell
standalone_bottom_nav := ... { // ← now a sibling of `root`, at
root_nav := ...ActionBar {} // the wrong nesting depth
}
} // unbalanced from here down
}
```
`StandaloneFeatureShell` is closed *before* the bottom nav is declared, so the
brace count never reconciles. The indentation is also self-inconsistent, which
is the visible symptom of a hand-edit that was never compiled.
**This means nobody has ever run `nigig-email` as a standalone app.** It only
ever gets exercised as a library through `pageflipnav`. The `main.rs` /
`EmailStandaloneApp` / `StandaloneFeatureShell` scaffolding is decorative.
That single fact reframes everything else in this document: there is no
feedback loop here at all. No CI, no tests, and a binary that cannot start.
---
## 1. Security — the most serious section
### S1 — MEDIUM: `builder_dangerous` on port 465 is *not* the vulnerability it looks like
**I initially wrote this up as critical credential exposure. That was wrong, and
I am correcting it rather than quietly dropping it.**
The code reads alarmingly:
```rust
465 => {
let tls = TlsParameters::new(config.server.clone())?;
Ok(AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&config.server)
.port(config.port)
.credentials(creds)
.tls(Tls::Wrapper(tls))
.build())
}
```
I checked `lettre` 0.11.23's own source instead of trusting the method name.
`relay()` — the "simple and secure" constructor the docs point you to — is
implemented as:
```rust
pub fn relay(relay: &str) -> Result<AsyncSmtpTransportBuilder, Error> {
let tls_parameters = TlsParameters::new(relay.into())?;
Ok(Self::builder_dangerous(relay)
.port(SUBMISSIONS_PORT)
.tls(Tls::Wrapper(tls_parameters)))
}
```
That is the **same three calls in the same order**. And `TlsParameters::new`
sets:
```rust
accept_invalid_hostnames: false,
accept_invalid_certs: false,
min_tls_version: TlsVersion::Tlsv12,
```
So certificate validation **is** enabled, hostname verification **is** enabled,
and TLS 1.2 is the floor. The genuinely dangerous knobs —
`dangerous_accept_invalid_certs` / `dangerous_accept_invalid_hostnames` — are
never touched. `builder_dangerous` is dangerous only in that it *defaults* to no
TLS; this code immediately supplies `Tls::Wrapper`, which is exactly what
`relay()` does.
**The real defects here are smaller and different:**
1. **It reimplements `relay()` by hand.** Functionally equivalent today, but it
silently inherits nothing if `lettre` hardens `relay()` in a future version,
and it reads like a vulnerability to every reviewer — including me. Use
`relay()`.
2. **Port 465 is hardcoded as the only implicit-TLS port.** A provider on 8465
or any non-standard SMTPS port falls into the `_ =>` STARTTLS arm and fails.
3. **The `_ =>` arm applies STARTTLS to *every* other port**, including 25.
`starttls_relay` does refuse to send credentials if the upgrade fails (the
docs are explicit: *"No credentials or emails will be sent to the server,
protecting from downgrade attacks"*), so this is safe — but it means port 25
can never work, silently.
Downgraded from Critical to Medium. The credential-MITM claim was mine, not the
code's.
### S2 — CRITICAL: the password is a serialisable field
```rust
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SmtpConfig {
pub password: String,
...
}
```
Three distinct problems:
1. **`Serialize` on a secret.** On wasm the entire struct — password included —
is `serde_json::json!`-ed and POSTed to `/api/email`. Every request carries
the plaintext password. If that endpoint logs request bodies (default for
most reverse proxies and APM tools), the password lands in log storage.
2. **`Debug` on a secret.** Any future `log::debug!("{:?}", config)` prints the
password. Nothing does today — I grepped, there are no logging calls — but
the type offers no protection, and this is exactly how credentials leak.
The SMS crate already learned this lesson: `OfflineSmsMessage.body` carries
`#[serde(skip)]` and there is a **CI gate** enforcing it. That precedent
exists in this repo and was not applied here.
3. **`Deserialize`** implies an intent to persist. Today nothing does — the
config lives only in a `#[rust]` widget field and dies with the process.
That is accidentally the safest property of the whole design, and the first
person to add "remember my settings" will write it to plaintext JSON unless
the type stops them.
The fix is a `Secret`-style newtype with a redacting `Debug`, `#[serde(skip)]`
on serialise, and — when persistence arrives — the platform keystore. The SMS
crate's `SmsScheduleCrypto.java` (AES-256-GCM via `AndroidKeyStore`, fails
closed) is the existing in-repo pattern.
### S3 — LOW: TLS policy is inherited, not stated
Also corrected downward. I expected to find no TLS floor; `TlsParameters::new`
sets `min_tls_version: Tlsv12` and `starttls_relay` uses `Tls::Required`, which
aborts before `AUTH` if the upgrade fails. The protection exists.
What is missing is only that the policy is **implicit**. Nothing in this
repository asserts "email credentials require TLS ≥ 1.2 with a verified
certificate" — it is inherited from a transitive dependency's defaults, so a
`lettre` change or a well-meaning refactor to `builder_dangerous(...)` without
`Tls::Wrapper` would silently remove it with no test failing.
Fix is a test, not a code change: assert the transport for each port carries
`Tls::Wrapper`/`Tls::Required` and never `Tls::None`.
### S4 — HIGH: no recipient/header validation → injection surface
`to`, `subject` and `body` go from `TextInput` straight into
`Message::builder()`. `lettre` does encode headers, so classic CRLF header
injection is mitigated *by the library* — but the application performs no
validation of its own:
- no length bound on subject or body (a 50 MB paste is attempted verbatim)
- no recipient-count bound (see B1 — a comma list fails anyway)
- no check that `from` matches the authenticated `username`, which is the
single most common cause of silent provider rejection
This is the same class as SMS **E11** (sender validation), and the same
reasoning applies: what cannot be enforced client-side must at least be
*documented* as a threat, and this crate has no `THREAT_MODEL.md` entry at all.
### S5 — MEDIUM: wasm endpoint is unauthenticated and unpinned
```rust
EMAIL_API_URL.get().unwrap_or("/api/email")
```
`set_email_api_url` accepts any `String` with no scheme validation. Nothing
calls it today, so the default relative path is used — which is fine. But the
setter permits `http://` and cross-origin absolute URLs, and the POST carries
no auth token, no CSRF token, and no request signing. Any script on the page
can drive the send endpoint using the user's session.
---
## 2. Architecture
### A1 — CRITICAL: there is no email *model*, and no receive path
I grepped for `imap`, `pop3`, `fetch`, `receive`, `load_`. Result: **nothing**.
The crate has:
- an outbound SMTP send (one recipient)
- an SMTP connection test
That is it. There is no `Email` struct, no `Mailbox`/folder concept, no message
store, no threading, no read/unread state, no attachments, no drafts
persistence, no sync. The "Inbox" page cannot display mail **because no code
in this repository can obtain mail.**
The crate is named `nigig-email` and declares an Inbox tab. A user tapping
Inbox gets the text *"Top app bar page. Tap below to open a stack screen."*
### A2 — CRITICAL: business logic is in the widget
`EmailBulkPage::handle_event` reads five text inputs, parses the port, builds
`SmtpConfig`, and calls the worker. There is no domain layer, so:
- none of it is testable without a `Cx`
- validation cannot be unit-tested (there is none to test)
- the same logic cannot be reused by the Compose page — which is why Compose
has no send button at all
Contrast the SMS stack after remediation: `BulkSendRequest::validate()`,
`segment_count()`, `SendPacing`, `SendRateLimiter` all live in the platform
crate precisely so they can be tested on a host with no device. `nigig-email`
has no equivalent seam.
### A3 — HIGH: the four pages are literally the same file
I MD5'd each page with its own name normalised away:
```
inbox.rs db1fcddf16f6
compose.rs db1fcddf16f6 ← identical
more.rs db1fcddf16f6 ← identical
drafts.rs 3a3125b45a50 (differs only by a stray blank line)
```
Four files, ~630 lines, **one distinct implementation**. Each contains its own
verbatim copy of `push_detail` / `pop_detail` and the same
`"Replace this scaffold with the real workflow for X."` label.
This is a code generator's output that was never specialised. The SMS
remediation solved this exact problem in Phase F5 by extracting shared page
panes; the same fix applies here and would delete ~450 lines.
### A4 — MEDIUM: `lib.rs` is a shim pile
Thirty lines re-exporting `nigig_core` and `nigig_uikit` under local names
(`crate::dir`, `crate::shared`, `crate::persistence`, `crate::features`,
`crate::home`) with the comment *"Compatibility shims for source moved out of
pageflipnav during staged migration."*
Including a **fake `NavigationBarAction` enum with one variant** declared inline
in `lib.rs` — a UI type defined in a compatibility shim. The migration these
shims serve was never completed. They make every import path in the crate a
lie about where the code actually lives.
### A5 — MEDIUM: `drafts.rs` is dead
157 lines, `EmailDraftsPage`, not declared in `pages/mod.rs`. It is **never
compiled**, so it cannot even be known to build. Either wire it up or delete
it; leaving it is worse than both.
---
## 3. Bugs
### B1 — CRITICAL: multi-recipient send is impossible, and the UI promises it
The input is labelled:
```rust
to_input := TextInput { empty_text: "To (comma-separated)" ... }
```
The implementation is:
```rust
let to_mbox: Mailbox = to.parse().map_err(|e| format!("Invalid to: {e}"))?;
```
`Mailbox` parses **one** address. Any comma-separated list fails to parse, so
the user gets `Failed: Invalid to: ...` for doing exactly what the placeholder
told them to do.
So the tab named **"Bulk"** can send to exactly **one** recipient. The crate's
single headline feature does not work. This is a one-line-to-fix defect
(`to.split(',')``Vec<Mailbox>`, or `Message::builder().to()` per recipient)
that has apparently never been executed once.
### B2 — HIGH: `SmtpConfig` is rebuilt on every action event
```rust
if let Event::Actions(actions) = event {
let server = self.text_input(cx, ids!(server_input)).text();
let port_t = ...text(); // ×5 String allocations
...
self.smtp_config = SmtpConfig { ... }; // + 4 more via clone()
```
`Event::Actions` fires for **every** action in the app — every keystroke, every
scroll, every timer tick from any widget. Each one performs 5 widget lookups,
5 `String` allocations, and 4 more clones building a struct that is only read
when a button is clicked.
It is also **wrong**, not just wasteful: the config is captured from whatever
the fields happen to contain at the moment an unrelated action fires. Combined
with the port fallback below, this is a config that mutates behind the user.
### B3 — HIGH: `port.parse().unwrap_or(587)` silently rewrites user input
```rust
let port: u16 = port_t.parse().unwrap_or(587);
```
Type `2525` → works. Type `25 ` with a trailing space, or `465x`, or clear the
field mid-edit → **silently becomes 587**, which then selects the STARTTLS
branch instead of the implicit-TLS branch. The user asked for one transport and
got another, with no message. Combined with S1 the port value selects the
security posture, so a typo silently changes the threat model.
### B4 — MEDIUM: no in-flight guard; every click spawns another task
Both `test_btn` and `send_btn` call `spawn_*` unconditionally. Nothing tracks
whether a send is already running. Double-tapping "Send Email" sends the
message **twice** — billed, irreversible, and to a human recipient.
The SMS crate hit this and fixed it with `BULK_SEND_IN_FLIGHT` (an
`AtomicBool` swap) plus a two-tap confirmation. Neither is present here, and
email has the same irreversibility property.
### B5 — MEDIUM: no validation before spawning
Empty server, empty username, empty `from`, empty recipient, empty body — all
accepted. The worker spawns, the network call fails, and the user sees a raw
`lettre` error string. Every one of these is cheap to check locally with a
clear message.
### B6 — LOW: `EmailWorkerAction::None` exists only to satisfy a trait
`ActionDefaultRef` needs a default, so the enum carries a `None` variant that
is never constructed or matched. It widens every `match` for no behaviour.
---
## 4. Performance
### P1 — HIGH: per-event allocations in `handle_event` (see B2)
Ten heap allocations per action event, discarded unread. On a page with a
`TextInput` focused, that is per keystroke.
### P2 — MEDIUM: `CachedWidget` on all four pages, none with content
```rust
inbox_page := View { CachedWidget { inbox_page_inner := ... } }
compose_page := View { CachedWidget { ... } }
bulk_page := View { CachedWidget { ... } }
more_page := View { CachedWidget { ... } }
```
All four pages are instantiated and cached for the process lifetime. Today they
are placeholders, so the cost is small — but this locks in a design where the
inbox (the page that will eventually hold a scrollable list of thousands of
messages) can never be released. Worth deciding deliberately rather than by
default.
### P3 — MEDIUM: no connection pooling used
`lettre` is built with the `pool` feature, but `build_transport` constructs a
**brand-new transport per operation**. Every send performs a fresh TCP
handshake, TLS negotiation and `AUTH`. For a bulk feature — the crate's stated
purpose — that is the dominant cost and the fastest way to get rate-limited or
flagged as abusive by the provider.
### P4 — LOW: fire-and-forget tasks, no cancellation, no explicit timeout
`crate::platform::spawn` is `tokio::spawn` on native and `spawn_local` on wasm.
Nothing retains the `JoinHandle`, so no operation can be cancelled.
`lettre` does apply a **60-second default** per SMTP command, so a black-holed
server does eventually fail rather than hanging forever — I checked, this is not
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,
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
seconds queues a second delivery.
---
## 5. Code quality
### Q1 — CRITICAL: zero tests
```
$ grep -rc '#\[test\]' crates/apps/nigig-email/src/ → 0
$ grep -c '#\[test\]' crates/nigig-core/src/email_worker.rs → 0
```
Not one test in 1,148 lines of application code plus 210 lines of worker.
Nothing verifies port→transport selection, address parsing, or error mapping —
all of which are pure functions that need no network.
### Q2 — CRITICAL: zero CI
No workflow in `.forgejo/workflows/` mentions `nigig-email`. It is not built,
not linted, not tested by any automated process. That is precisely how a
**binary with unbalanced braces** reached `main` and stayed there.
### Q3 — HIGH: three unused dependencies + one unused platform dep
```
serde → 0 files reference it
serde_json → 0
chrono → 0
robius-location → 0 (declared, never imported)
```
`robius-location` is the *identical* defect that SMS Phase B removed from
`nigig-build`, `nigig-core` and `nigig-uikit` — it dragged in RUSTSEC
exemptions and an LGPL-2.1 question for a dependency that was never called.
A CI gate was even added to stop it coming back:
```yaml
- name: The removed platform deps must not come back
```
That gate covers `nigig-build`, `nigig-core`, `nigig-uikit`**not**
`nigig-email`. So the same mistake sits here, un-gated.
### Q4 — HIGH: the workspace violates its own supply-chain gate *right now*
`nigig-build.yml` contains a gate requiring **full 40-character** git revs,
added deliberately in `5e71457` with a comment explaining that abbreviated revs
become ambiguous as a repo grows. I ran it:
```
$ grep -rn 'rev = ' --include=Cargo.toml . | grep -vE 'rev = "[0-9a-f]{40}"'
./crates/apps/geohot/Cargo.toml:7: ... rev = "5efe6e24c"
./crates/apps/map/Cargo.toml:8: ... rev = "5efe6e24c"
>>> GATE FAILS
```
**42 declarations across 34 crates** use the 9-character rev `5efe6e24c`,
introduced by `9d647ce` ("bump makepad fork rev"). That commit **broke the gate
that `5e71457` created**. `nigig-email` is one of the 34.
This is repo-wide, not email-specific, but it lands in the plan because the
email crate cannot be given a green CI job while a shared gate is red.
### Q5 — MEDIUM: no docs, no `README`, no module comments
Not one `//!` module doc. The only comments are the copy-pasted
*"First forward the event so dynamic StackNavigation children can produce
actions"* (×4) and the *"Compatibility shims"* note. No explanation of the
SMTP threat model, the wasm/native split, or why `builder_dangerous` was chosen.
### Q6 — MEDIUM: `rustfmt` never run
The crate is not in any fmt gate. Mixed indentation in `main.rs` is what made
the brace bug invisible to review.
---
## 6. Execution plan
Ordered by **severity × cost**. Every phase ends green on real CI, on a
registered runner, and is pushed before the next begins.
Principles carried over from the SMS remediation, which worked:
- **Negative-test every gate** — revert the fix, confirm the gate fails.
- **Ratchet, don't blanket-disable.** A step that always fails gets ignored.
- **Host-testable seams first.** CI runs on Linux; SMTP and Android are stubs.
- State plainly what is **not** device- or network-verified.
---
### Progress log
Updated as work lands, so this document stays a live plan rather than a
snapshot. Commits are on `main`.
| Commit | What |
|---|---|
| `5a5b817` | **Phase C2 (early)**`email_account.rs` + `email_store.rs`: session state, account validation, sender-thread grouping, char-safe previews. 38 host tests. |
| `b91f97b` | **Phase 0.1 DONE**`main.rs` braces fixed; the binary compiles for the first time. |
| `18bbb7b` | **Feature: account-gated inbox** — sender list + thread reader reusing `nigig_uikit::shared::conversation`; setup form moved off the Bulk tab; `drafts.rs` deleted (0.4). |
| `cc8a727` | **Phase 0.2 DONE** — 42 abbreviated git revs across 34 crates rewritten to full 40-char SHAs. Label-only change, verified against `Cargo.lock`. |
| `4ae50cb` | **Phase 0.5 DONE**`serde`, `serde_json`, `robius-location` removed; platform-dep gate extended to `nigig-email`. |
| `244c4f2` | **Phase 0.3 + 0.6 DONE**`email.yml` (4 jobs, 11 gates). Writing the gates caught **B2 and B3 still live in `bulk.rs`**; both fixed here. |
| *(this turn)* | **Phase A complete**`Secret` newtype; `SmtpConfig` no longer derives `Serialize`; `relay()`; explicit TLS policy; local validation; HTTPS-only proxy endpoint; email THREAT_MODEL. Domain tests **38 → 68**. |
| *(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)* | **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**. |
| *(this turn)* | **New feature: trip-expense report.** `email_receipts.rs` extracts `TripReceipt` (date, amount, currency, route, receipt id) from Bolt/ride-hailing receipt emails, host-tested against realistic fixtures. `finance_report.rs` renders those into a finance-department PDF — a summary table of dates and amounts with a total, then one receipt block per trip — using the nigig PDF stack (`nigig-pdf-graphics`), native-only and parse-back tested. `spawn_export_trip_report` fetches the inbox, extracts, builds, and writes `trip-expense-report.pdf`; the More page has an "Export trip report (PDF)" button. Domain tests **216 → 234**; coverage **90.6%** over 15 files. |
| *(this turn)* | **Share the trip report to finance by email.** `build_report_email` (pure, tested) attaches the PDF as `application/pdf` in a multipart message to a comma-separated finance recipient list; `spawn_email_trip_report` fetches → extracts → builds → emails via the signed-in SMTP account (the proxy backend reports attachments are unsupported, honestly). The Finance card gains a recipients field and an "Email report to finance" button. An end-to-end test sends the attached PDF through the SMTP sink and asserts the recipient, `application/pdf` type and filename land in DATA. Domain tests **234 → 237**. The chat/Matrix path is NOT built: `matrix_client` has login+sync only (no room-send, no media upload — even avatar upload is "not yet implemented"), so sharing via the chat app needs that capability first. |
**Phase 0 is complete.** All seven items done; 0.7 was fixed upstream.
**Phase C is complete.** All of C1 (both backends: the proxy HTTP client
and the IMAP protocol client), C2 (domain model), C3 (encrypted-at-rest
cache seam), C4a/C4b (inbox list + thread reader, wired to a real fetch),
C5 (Compose send), C6 (pacing primitives) and C7 (refresh) are done.
Domain tests **99 → 195**.
The honest caveats carry forward unchanged: the SMTP and IMAP *sockets*,
the reqwest/fetch transports, the platform keystore and the connection
pool's reuse are not host-verified -- each is a read of the library's
contract or a platform task, not an observed handshake. The seams around
them (parsing, keying, error mapping, validation) ARE all tested. See
§8 and the per-module "What is not verified" notes.
**Phase B is complete.** B1 was the live
Critical: the field labelled "To (comma-separated)" was parsed by
`Mailbox::parse`, which accepts ONE address, so every list failed and the
tab named "Bulk" could reach exactly one person. Domain tests **72 → 99**.
**Phase A is complete.** All six items done. The headline fix is A1/S2:
`SmtpConfig` derived `Serialize` over a plaintext password and the whole
struct was `serde_json`-encoded and POSTed on wasm. It now holds a
`Secret` (Debug renders `"***"`, no `Display`, no `Serialize`), the
derive is gone, and the wasm body is built field by field so the
credential appears at exactly one line. Three CI gates enforce it, each
negative-tested.
Every gate in `email.yml` was negative-tested — reverted the fix,
confirmed the gate fails, restored it — rather than merely observed
green.
Verified: `cargo check -p nigig-email --all-targets` → 0 errors;
41 tests pass (38 core + 3 UI helpers); `nigig-email` clippy down to 2
pre-existing `unexpected_cfgs` from the `app_main!` macro; `cargo fmt`
clean for this crate.
**Caveat on verification base:** `origin/main` has not resolved for
several commits, for two different reasons in sequence, neither of them
in the email crate:
1. `86c9595` bumped makepad to a rev without the `maps` feature that
`crates/pageflipnav` declares. Workspace-wide resolution failure.
2. `ce0eaae` fixed that, and introduced a new one: it added
`i_tree = "1.0.0"` to `crates/apps/map/Cargo.toml`, but crates.io
publishes only up to `0.19.0`. `cargo` cannot select a version, so
the whole workspace still fails to resolve.
Both confirmed pre-existing by stashing every email change and
reproducing on a pristine tree. The 41 passing tests reported above were
measured at `2faadb7`, the last commit where the workspace resolved.
This is now plan item **0.7** and it is the highest-priority blocker in
this document: while it holds, *no* crate in the repo can be
CI-verified, so nothing here can be proven green on a runner.
---
### The requested feature, folded into the phases
The ask: *an inbox list like SMS when signed in; tap a sender to read the
thread with the same transition; the connection form when signed out.*
That is not a separate workstream — it is Phase C (make it an email
client) pulled forward, with the parts that need no receive path done
first. Mapping:
| Ask | Phase | State |
|---|---|---|
| Domain model for messages/threads | C2 | **done** (`5a5b817`) |
| Inbox list of senders | C4a | **done** (`18bbb7b`) |
| Thread reader with SMS-style transition | C4a | **done** (`18bbb7b`) |
| Signed-out → connection form | A7 (new) | **done** (`18bbb7b`) |
| Real mail in the list | **C1 + C4b** | **blocked on your C1 decision** |
#### How the SMS parity is achieved, concretely
Not "written to look similar" — the email pages consume the same widgets
SMS does, from `crates/nigig-uikit/src/shared/conversation/`. That module
was built to be shared: its `types.rs` already carried a
`SharedConversationKind::Email` variant before any of this work.
| Concern | Shared component | SMS uses it | Email now uses it |
|---|---|---|---|
| List row | `SharedConversationPreview` | yes | yes |
| Row tap signal | `SharedConversationPreviewAction::Clicked` | yes | yes |
| Row data | `SharedConversationPreviewProps` | yes | yes |
| Thread push/pop | `RobrixStackNavigationView` + `StackNavigation` | yes | yes |
| Message bubbles | `SharedSentMessageBubble` / `SharedReceivedMessageBubble` | yes | yes |
| Bottom-nav hide | `ContextNavAction::Hide/ShowBottomNav` | yes | yes |
Consequence worth stating: a change to the shared preview row now moves
both features at once. That is the point — but it also means an email
regression can surface in SMS, so the shared kit needs its own tests.
Recorded as **E6** below.
The one intentional divergence: SMS rows are keyed by phone number;
email rows are keyed by **normalised sender address** (lowercased), because
`Alerts@Bank.co.ke` and `alerts@bank.co.ke` are one sender and rendering
two rows is the email version of the SMS duplicate-recipient bug.
What is real today: the gate, the list, the grouping, the transition, the
unread handling, the setup form and its validation. What is not: the
*contents*. The list renders `email_store::sample_thread()` because no
receive path exists. Everything above it is the code a real fetch will
populate without further UI change.
---
### Phase 0 — Make it verifiable (blocker)
Nothing else can be trusted until the crate builds and something runs it.
| ID | Task |
|---|---|
| ~~0.1~~ | ~~**Fix `main.rs` braces.**~~ **DONE** (`b91f97b`) — was missing the `StandaloneFeatureBody` wrapper; `--lib` had always passed, so only the binary was broken. |
| ~~0.2~~ | ~~**Repair the 40-char rev gate repo-wide.**~~ **DONE** (`f5d003f`) — 42 declarations across 34 crates rewritten to full SHAs (`ecf5a572ab62…`, `5efe6e24c9f7…`). Verified label-only: `Cargo.lock` holds one makepad commit id and zero refs to the old. |
| ~~0.3~~ | ~~**Create `.forgejo/workflows/email.yml`**~~ **DONE** — 4 jobs: `gates` (4 source scans), `email-domain` (38 tests + floor), `nigig-email` (check `--all-targets`, test, fmt, clippy ratchet), `supply-chain` (unused deps, lockfile, whitespace). |
| ~~0.4~~ | ~~**Delete or wire `drafts.rs`.**~~ **DONE** (`18bbb7b`) — deleted. 157 lines never declared in `pages/mod.rs`, so never compiled. |
| ~~0.5~~ | ~~**Drop unused deps.**~~ **DONE** (`e958386`) — removed `serde`, `serde_json`, `robius-location` (all 0 references in `src/`). `chrono` kept: it *is* used by `format_thread_time`, correcting the assessment. Extended the existing platform-dep gate to cover `nigig-email`; negative-tested. |
| ~~0.7~~ | ~~**Unblock `origin/main`.**~~ **DONE upstream** (`005bed1`, not mine) — corrected `i_tree` to `0.19.0`, exactly the fix predicted here. Verified: the workspace resolves and `cargo check -p nigig-email --all-targets` passes on current `main`. |
| ~~0.6~~ | ~~**Add `nigig-email` to a fmt gate.**~~ **DONE** — a HARD gate, not report-only: the crate already formats clean, so there is no pre-existing drift to grandfather in. Contrast `sms.yml`/`nigig-map.yml`, which inherited hundreds of diffs and had to report only. |
Exit: `cargo check`/`clippy`/`test -p nigig-email` green on a real runner.
---
### Phase A — Security (critical)
| ID | Task |
|---|---|
| ~~A1~~ | **DONE**`Secret` newtype in `nigig-core/src/secret.rs`. Redacting `Debug` (`"***"`), `#[serde(skip)]`. **CI gate**: the password field must never be plainly serialisable — mirrors the SMS `#[serde(skip)]` body gate. *This is the real critical item; do it first.* |
| ~~A2~~ | **DONE** — now uses `relay()`. Behaviour-preserving today (verified equivalent), but stops the code reading as a vulnerability and inherits future hardening. Add a test asserting transport choice per port. |
| ~~A3~~ | **DONE**`tls_mode_for_port` + tests., so the inherited defaults cannot be silently removed: assert `Tls::Wrapper`/`Tls::Required` per port and never `Tls::None`. |
| ~~A4~~ | **DONE**`validate_send` + `config_warning`.: non-empty server/username/from, `from` parses, bounded subject (≤998 bytes per RFC 5322) and body, recipient count cap. All host-testable. |
| ~~A5~~ | **DONE**`email_api_url_is_safe`.; reject non-HTTPS absolute URLs. Document that the wasm endpoint needs auth. |
| ~~A6~~ | **DONE**`crates/apps/nigig-email/THREAT_MODEL.md`. for email: what the client can enforce, and what it cannot (`from` spoofing is server-side, per SMS E11). |
Negative tests: remove `#[serde(skip)]` → A1 gate fails. Set
`Tls::None` on either branch → A3 test fails.
---
### Phase B — Fix the feature that is advertised (critical)
| ID | Task |
|---|---|
| ~~B1~~ | **DONE**`email_send::parse_recipients`. Parse the comma list into `Vec<Mailbox>`, report per-recipient success/failure. This is what "Bulk" claims to do. Reuse the SMS `recipient_csv.rs` normalisation approach for splitting/dedupe. |
| ~~B2~~ | **DONE**`email_send.rs`. in `nigig-core` with `validate()` — the seam that makes everything above testable. Mirrors `BulkSendRequest`. |
| ~~B3~~ | **DONE** (in Phase 0.3). Read inputs on `.changed()` only, or read once at click time. Kills 10 allocations/event. |
| ~~B4~~ | **DONE** — via `AccountDraft::validate`. Empty → default *with a visible note*; invalid → refuse and say so. Never silently rewrite. |
| ~~B5~~ | **DONE**`SEND_IN_FLIGHT` + arm/confirm. before spending real sends (SMS A7/D5 pattern). |
| ~~B6~~ | **DONE** — 20s timeout + `abandon_send()`, now wired to the Send button (it shipped as dead code first; gated). True mid-transaction cancel remains impossible: `lettre`'s send is not cancel-safe once DATA is accepted, so the control frees the UI and suppresses a stale result rather than stopping delivery. Named `abandon_send`, not `cancel_send`, for that reason. on SMTP operations. No unbounded `"Testing..."`. |
---
### Phase C — Make it an email client (large; scope decision needed)
This is the phase that determines whether `nigig-email` is a product or a
send-only form. **It needs a product decision before any code.**
| ID | Task |
|---|---|
| **C1** | **DECIDE THE RECEIVE STRATEGY — the one blocking question.** IMAP on device (`async-imap`) vs a server-side proxy API. See the decision note below. **Everything else in this phase is either done or waits on this.** |
| ~~C2~~ | ~~**Define the domain model.**~~ **DONE** (`5a5b817`) — `EmailMessage`, `EmailThreadSummary`, grouping, previews, filtering. 38 tests. `Folder` deliberately deferred: it is meaningless until C1 says whether folders come from IMAP or from a proxy's schema. |
| ~~C3~~ | ~~**Persistence.**~~ **DONE**`email_cache.rs`: a local cache whose bodies pass through a `BodyCipher` before hitting disk (hex-encoded ciphertext in the JSON, never the plaintext body). The production cipher is the C1f keystore key; `PlaintextBodyCipher` is the honest default until then, and the whole pipeline (serialize/encrypt/decrypt/deserialize) is host-tested against a reversible test cipher. |
| ~~C4a~~ | ~~**Inbox list + thread reader.**~~ **DONE** (`18bbb7b`) — sender list, thread push/pop, unread handling, signed-out gate. |
| ~~C4b~~ | ~~**Wire it to real data.**~~ **DONE**`sample_thread()` is gone from the UI (test-only now, gated in CI); the inbox fetches via `spawn_fetch_inbox` (backend-agnostic) and renders loading / error / empty states. |
| ~~C5~~ | ~~**Give Compose a send button.**~~ **DONE**`compose.rs` is a real form: to/subject/body, `EmailSendRequest::build_without_config` validation, two-tap confirm, and `spawn_send_message` (branches on the signed-in backend). |
| ~~C6~~ | ~~**Bulk pacing.**~~ **DONE**`email_pacing.rs` ported the primitives; `email_bulk.rs` now *uses* them: `bulk_send_plan` batches a list over the 100-recipient cap and `run_bulk_send` sends the batches paced (gap + rate-limiter backstop + abandon), wired into the Bulk page. The Bulk tab finally sends >100 recipients. |
| ~~C7~~ | ~~**Pull-to-refresh + background fetch.**~~ **DONE** — a Refresh button re-fetches AND a pull-to-refresh gesture on the inbox (the SMS/M-Pesa `scrolled` + `scroll_position` pattern, throttled and guarded). The fetch is worker → `InboxFetched` action → drained on the UI thread. |
#### C1 — DECIDED: support both, user-selectable
**Decision (2026-08-16): implement both backends and let the user pick,
with a setup form appropriate to each.**
| | IMAP on device | Server-side proxy |
|---|---|---|
| Credentials | Stay on the phone, but stored there — needs keystore work (A1) | Never touch the client after setup |
| Offline | Works | Needs a local cache anyway |
| Effort | `async-imap` + TLS + parsing + sync state | Client is a thin HTTP client; server is new infrastructure |
| wasm | IMAP over raw TCP is impossible in a browser — needs a proxy *anyway* | Same code path on every platform |
| Resolves S2? | No — you still hold the password | **Largely yes** |
This is the right call and it is *cheaper than it sounds*, because wasm
already forces a proxy to exist: supporting only IMAP was never actually
an option for the browser target. So the second backend is not new scope,
it is scope that was already implied.
##### What "both" requires architecturally
The thing that makes this tractable is a **trait boundary**, not two
parallel UIs. One `MailBackend` abstraction with two implementations, and
a `BackendKind` discriminant on the account:
```rust
pub enum BackendKind { ImapSmtp, ProxyApi }
pub trait MailBackend {
async fn verify(&self) -> Result<(), String>;
async fn list_inbox(&self) -> Result<Vec<EmailMessage>, String>;
async fn send(&self, req: &EmailSendRequest) -> Result<(), String>;
}
```
`email_store`'s grouping, `preview_line`, the inbox list, the thread
reader and the unread handling all sit **above** this line and are already
written — they consume `Vec<EmailMessage>` and do not care where it came
from. That was deliberate (see C2) and it is what makes two backends a
contained change rather than a rewrite.
##### Two forms, and the fields genuinely differ
Not one form with a toggle that hides rows — the fields are different
enough that pretending otherwise produces a confusing screen:
| IMAP + SMTP | Proxy API |
|---|---|
| Email address | Email address |
| IMAP server + port | API base URL (**must be HTTPS** — S5) |
| SMTP server + port | API token / credential |
| Username | — |
| Password / app password | — |
| — | Optional account label |
So: a backend chooser first, then the matching form. `AccountDraft` gains
a `BackendKind` and validation branches on it — `validate()` already
returns `Vec<AccountError>` and reports every problem at once, so this
extends cleanly.
##### Sequencing
| ID | Task |
|---|---|
| ~~C1a~~ | **DONE**`mail_backend.rs`: `BackendKind`, `BackendSettings`, `MailBackend` trait, both impls. 26 tests. |
| ~~C1b~~ | **DONE**`BackendDraft::validate` branches per kind; `EmailAccount.backend` persists the choice with no secret. HTTPS-only enforced and tested. |
| ~~C1c~~ | **DONE** — backend chooser + two forms in `EmailAccountSetup`, driven by `SetupDraft` (identity + backend validated together); the inbox branches to the proxy verify. |
| ~~C1d~~ | **DONE**`mail_proxy.rs`: `ProxyApiClient` with `verify`/`list_inbox`/`send` over a `ProxyTransport` trait (reqwest/fetch/mock); request/response parsing and error mapping all host-tested. |
| ~~C1e~~ | ~~`ImapSmtpBackend`.~~ **DONE**`imap_client.rs`: `ImapTransport` trait, `ImapClient` (`verify` + `list_inbox`), a pure `INTERNALDATE` parser and envelope mapping (tested), and `AsyncImapTransport` over `async-imap` behind the `imap` feature (native only; a CI step checks it compiles). |
| ~~C1f~~ | ~~Keystore-backed credential storage.~~ **DONE**`credential_store.rs`: the `CredentialStore` trait, a fail-closed default, AND a real `KeyringCredentialStore` (OS Secret Service / Credential Manager / Keychain via `keyring`) behind the `keystore` feature. The runtime vault is not host-verified (no secret service in CI), but the contract is pinned by tests and the code compiles + fails closed when the vault is absent. |
##### One thing I will not pretend
Offering both **doubles the security surface**, and the IMAP path is the
one that keeps a reusable password on the device. A revocable proxy token
is strictly safer than a password that also unlocks the user's password
resets. So the setup UI should say which is which, plainly, rather than
presenting them as equivalent choices.
---
### Phase D — Design & duplication
| ID | Task |
|---|---|
| ~~D1~~ | ~~**Extract the shared page scaffold.**~~ **DONE (mooted)** — three of the four placeholder pages are now real (Inbox, Compose, Bulk); the last, `more.rs`, was replaced with real content (account + sign-out) rather than extracted. There is no duplicated scaffold left to parameterise. |
| ~~D2~~ | ~~**Delete the `lib.rs` shims.**~~ **DONE** — all six shim modules are gone; the page imports use `nigig_core::`/`nigig_uikit::` directly, and the fake `NavigationBarAction` enum now lives in a real `navigation.rs` module. |
| ~~D3~~ | ~~**Reconsider `CachedWidget`.**~~ **DONE** — decision recorded in `email.rs`: keep it. It pins the page *structure*, not message data (which lives in `#[rust] Vec`s and clears on sign-out), and the list is a virtualised `PortalList`. |
| ~~D4~~ | ~~**Connection pooling.**~~ **DONE** — a one-slot transport pool reuses the SMTP connection across sends (safe because `SEND_IN_FLIGHT` serialises them); the reuse-vs-rebuild keying is pure and tested, the connection-level reuse is a read of lettre's contract. |
| ~~D5~~ | ~~**Drop `EmailWorkerAction::None`.**~~ **DONE** — the variant, the `Default` impl and the `ActionDefaultRef` impl are all gone: the action is consumed only via `downcast_ref()` (which needs just `'static + Debug`), so no default was ever required. |
---
### Phase E — Tests & ratchets
| ID | Task |
|---|---|
| ~~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.**~~ **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.**~~ **DONE**`email.yml` enforces `FLOOR=205`. |
| ~~E4~~ | ~~**Clippy ratchet.**~~ **DONE** — ratchet at the measured baseline (0 nigig-email-owned diagnostics). |
| ~~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~~ | ~~**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. |
---
## 7. Sequencing note
Phase 0 is non-negotiable and is roughly a day. Phases A and B are each
small in code and large in value — A1 is one line, B1 is a few. Together they
turn a crate that cannot build into one that securely sends to multiple
recipients, with tests.
**Phase C is the real question.** Everything before it is repair; C is
construction, and it is where most of the remaining effort lives. I would not
start C without an explicit answer on C1 (IMAP-on-device vs server proxy),
because that choice changes the security model, the dependency set, and the
persistence design. Choosing "server proxy" also happens to resolve S1/S2 more
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. 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 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 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`.