Compare commits
No commits in common. "b701ce5eebc906d13eef8058a6efdd066fbaff47" and "2e5b4e147f160ba44570b5d6e0892a8225e51d92" have entirely different histories.
b701ce5eeb
...
2e5b4e147f
10 changed files with 229 additions and 2394 deletions
|
|
@ -1,704 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
||||||
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 |
|
|
||||||
|---|---|
|
|
||||||
| `28d0608` | **Phase C2 (early)** — `email_account.rs` + `email_store.rs`: session state, account validation, sender-thread grouping, char-safe previews. 38 host tests. |
|
|
||||||
| `1ea9ad6` | **Phase 0.1 DONE** — `main.rs` braces fixed; the binary compiles for the first time. |
|
|
||||||
| `50760e0` | **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). |
|
|
||||||
|
|
||||||
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` at `86c9595` does not
|
|
||||||
resolve — the makepad bump dropped the `maps` feature `pageflipnav`
|
|
||||||
requires. Pre-existing and unrelated; confirmed by stashing all my
|
|
||||||
changes and reproducing on a pristine tree. Numbers above are from
|
|
||||||
`2faadb7`, the commit before that bump.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 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** (`28d0608`) |
|
|
||||||
| Inbox list of senders | C4 | **done** (`50760e0`) |
|
|
||||||
| Thread reader with SMS-style transition | C4 | **done** (`50760e0`) |
|
|
||||||
| Signed-out → connection form | new, A-adjacent | **done** (`50760e0`) |
|
|
||||||
| Real mail in the list | **C1 + C4** | **blocked on your C1 decision** |
|
|
||||||
|
|
||||||
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** (`1ea9ad6`) — 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.** Resolve `5efe6e24c` → full SHA, rewrite all 42 declarations. This is mechanical and must land in its own commit. |
|
|
||||||
| 0.3 | **Create `.forgejo/workflows/email.yml`**: `gates` (source scans), `nigig-email` (check + clippy + test), `supply-chain` (`cargo deny`, lockfile, unused deps). |
|
|
||||||
| ~~0.4~~ | ~~**Delete or wire `drafts.rs`.**~~ **DONE** (`50760e0`) — deleted. 157 lines never declared in `pages/mod.rs`, so never compiled. |
|
|
||||||
| 0.5 | **Drop unused deps**: `serde_json`, `robius-location`. *Revised:* `chrono` is now used (`format_thread_time`) and `serde` is used by `email_account`/`email_store`, so only two remain. Extend the "removed platform deps must not come back" gate to cover `nigig-email`. |
|
|
||||||
| 0.7 | **NEW — unblock `origin/main`.** The makepad bump `86c9595` dropped the `maps` feature `pageflipnav` declares, so the workspace does not resolve at HEAD. Nothing can be CI-verified until this is fixed. Higher priority than 0.2. |
|
|
||||||
| 0.6 | **Add `nigig-email` to a fmt gate**, or record explicitly why not (SMS chose report-only; email is small enough to just format). |
|
|
||||||
|
|
||||||
Exit: `cargo check`/`clippy`/`test -p nigig-email` green on a real runner.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase A — Security (critical)
|
|
||||||
|
|
||||||
| ID | Task |
|
|
||||||
|---|---|
|
|
||||||
| A1 | **`Secret<String>` newtype for the password.** 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 | **Replace the hand-rolled 465 branch with `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 | **Pin the TLS policy with a test**, so the inherited defaults cannot be silently removed: assert `Tls::Wrapper`/`Tls::Required` per port and never `Tls::None`. |
|
|
||||||
| A4 | **Validate before dispatch**: non-empty server/username/from, `from` parses, bounded subject (≤998 bytes per RFC 5322) and body, recipient count cap. All host-testable. |
|
|
||||||
| A5 | **Scheme-check `set_email_api_url`**; reject non-HTTPS absolute URLs. Document that the wasm endpoint needs auth. |
|
|
||||||
| A6 | **Add a `THREAT_MODEL.md` section** 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 | **Multi-recipient send.** 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 | **`EmailSendRequest` domain type** in `nigig-core` with `validate()` — the seam that makes everything above testable. Mirrors `BulkSendRequest`. |
|
|
||||||
| B3 | **Move config assembly out of `handle_event`.** Read inputs on `.changed()` only, or read once at click time. Kills 10 allocations/event. |
|
|
||||||
| B4 | **Strict port parsing.** Empty → default *with a visible note*; invalid → refuse and say so. Never silently rewrite. |
|
|
||||||
| B5 | **In-flight guard + two-tap confirmation** before spending real sends (SMS A7/D5 pattern). |
|
|
||||||
| B6 | **Timeout + cancel** 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** (`28d0608`) — `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** — reuse `nigig-core::persistence`; bodies encrypted at rest, per SMS E1/E3. Note `EmailMessage.body` is *not* `#[serde(skip)]`-ed the way `OfflineSmsMessage.body` is, because a mail cache that drops bodies is useless — so encryption is mandatory here, not optional. |
|
|
||||||
| ~~C4a~~ | ~~**Inbox list + thread reader.**~~ **DONE** (`50760e0`) — sender list, thread push/pop, unread handling, signed-out gate. |
|
|
||||||
| C4b | **Wire it to real data.** Replace `sample_thread()` with the C1 fetch. Add loading / error / empty states — currently only empty exists. |
|
|
||||||
| C5 | **Give Compose a send button** wired to B2, replacing the scaffold. |
|
|
||||||
| C6 | **Bulk pacing.** Email providers rate-limit harder than carriers. Port `SendPacing` from `robius-sms` — already generic arithmetic. |
|
|
||||||
| C7 | **NEW — pull-to-refresh + background fetch** once C1 lands. The SMS crate's D1/D2 pattern (worker thread → results queue → `SignalToUI` → drained on the UI thread) applies directly; do not fetch from `draw_walk`. |
|
|
||||||
|
|
||||||
#### C1 — the decision I need from you
|
|
||||||
|
|
||||||
| | 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** |
|
|
||||||
|
|
||||||
My read: **the proxy wins on merit**, mostly because the wasm target
|
|
||||||
already forces one and because it retires the password-storage problem
|
|
||||||
rather than mitigating it. But it is infrastructure you may not want to
|
|
||||||
run, and IMAP-on-device is the only option that works with no backend at
|
|
||||||
all. This is a product call, not a technical one, which is why I have not
|
|
||||||
made it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase D — Design & duplication
|
|
||||||
|
|
||||||
| ID | Task |
|
|
||||||
|---|---|
|
|
||||||
| D1 | **Extract the shared page scaffold.** One parameterised pane replaces 4 copies (~450 lines deleted). SMS Phase F5 precedent. |
|
|
||||||
| D2 | **Delete the `lib.rs` shims** or finish the migration they were staged for. A UI enum declared in a compat shim is not acceptable long-term. |
|
|
||||||
| D3 | **Reconsider `CachedWidget` on the inbox** once it holds a real list. |
|
|
||||||
| D4 | **Connection pooling** — actually use the `pool` feature already compiled in. |
|
|
||||||
| D5 | Drop `EmailWorkerAction::None` if `ActionDefaultRef` can be satisfied otherwise. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Phase E — Tests & ratchets
|
|
||||||
|
|
||||||
| ID | Task |
|
|
||||||
|---|---|
|
|
||||||
| E1 | **Unit-test the pure logic**: port→transport, address parsing/splitting, validation, error mapping. Target ≥40 tests; these need no network. |
|
|
||||||
| E2 | **Property-test address parsing** (proptest is already a dev-dep in SMS) — never panic on arbitrary input. |
|
|
||||||
| E3 | **Test-count floor gate**, as SMS has (`FLOOR=100`). |
|
|
||||||
| E4 | **Clippy ratchet** at the measured baseline. |
|
|
||||||
| 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. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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:
|
|
||||||
|
|
||||||
- **No SMTP path has been executed.** No live server, no MITM test. The S1/S3
|
|
||||||
analysis is a read of `lettre` 0.11.23's vendored source (`relay()`,
|
|
||||||
`TlsParameters::new`, `TlsVersion`), which is why I was able to catch my own
|
|
||||||
error — but it is still a read, not an observed handshake.
|
|
||||||
- **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.
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
use crate::features::action_page_navigation::ActionPageNavigationAction;
|
use crate::features::action_page_navigation::ActionPageNavigationAction;
|
||||||
use crate::home::navigation_tab_bar::NavigationBarAction;
|
use crate::home::navigation_tab_bar::NavigationBarAction;
|
||||||
use crate::shared::navigation_bar_button::NavigationBarButtonWidgetExt;
|
use crate::shared::{
|
||||||
|
navigation_bar_button::{NavigationBarButton, NavigationBarButtonWidgetExt},
|
||||||
|
styles::*,
|
||||||
|
};
|
||||||
use makepad_widgets::*;
|
use makepad_widgets::*;
|
||||||
|
|
||||||
const DOUBLE_TAP_HOME_SECS: f64 = 0.55;
|
const DOUBLE_TAP_HOME_SECS: f64 = 0.55;
|
||||||
|
|
|
||||||
|
|
@ -1,247 +0,0 @@
|
||||||
// Account setup form, shown when no email account is connected.
|
|
||||||
//
|
|
||||||
// This is the "not logged in" half of the Inbox page. It is the form that
|
|
||||||
// used to live on the Bulk tab -- SMTP server, port, username, password,
|
|
||||||
// from -- moved here, because that is where it belongs: you connect an
|
|
||||||
// account once, then you read mail. Leaving it on a tab called "Bulk"
|
|
||||||
// meant the app had a credentials form and no way to see your inbox.
|
|
||||||
//
|
|
||||||
// All validation goes through nigig_core::email_account::AccountDraft,
|
|
||||||
// which is unit tested on the host. This widget only moves strings.
|
|
||||||
|
|
||||||
use makepad_widgets::*;
|
|
||||||
use nigig_core::email_account::{guess_provider, AccountDraft};
|
|
||||||
|
|
||||||
script_mod! {
|
|
||||||
use mod.prelude.widgets.*
|
|
||||||
use mod.widgets.*
|
|
||||||
|
|
||||||
mod.widgets.EmailAccountSetup = #(EmailAccountSetup::register_widget(vm)) {
|
|
||||||
width: Fill, height: Fill
|
|
||||||
flow: Down
|
|
||||||
|
|
||||||
setup_scroll := ScrollYView {
|
|
||||||
width: Fill, height: Fill
|
|
||||||
flow: Down
|
|
||||||
padding: Inset{left: 18, right: 18, top: 12, bottom: 22}
|
|
||||||
spacing: 12
|
|
||||||
|
|
||||||
intro_card := RoundedView {
|
|
||||||
width: Fill, height: Fit
|
|
||||||
flow: Down
|
|
||||||
spacing: 6
|
|
||||||
padding: 18
|
|
||||||
show_bg: true
|
|
||||||
draw_bg +: { color: #xF8FAFC, border_radius: 22.0, border_size: 1.0, border_color: #xE2E8F0 }
|
|
||||||
|
|
||||||
Label {
|
|
||||||
text: "Connect your email"
|
|
||||||
draw_text +: { color: #x1C274C, text_style: theme.font_bold { font_size: 22.0 } }
|
|
||||||
}
|
|
||||||
Label {
|
|
||||||
width: Fill, height: Fit
|
|
||||||
text: "Add an account to read your inbox and send mail. Your password stays on this device for the current session only."
|
|
||||||
draw_text +: { color: #x64748B, text_style: theme.font_regular { font_size: 11.0 } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
form_card := RoundedView {
|
|
||||||
width: Fill, height: Fit
|
|
||||||
flow: Down
|
|
||||||
spacing: 8
|
|
||||||
padding: 18
|
|
||||||
show_bg: true
|
|
||||||
draw_bg +: { color: #xFFFFFF, border_radius: 22.0, border_size: 1.0, border_color: #xE2E8F0 }
|
|
||||||
|
|
||||||
Label { text: "Email address" draw_text +: { color: #x64748B, text_style: theme.font_regular { font_size: 10.5 } } }
|
|
||||||
address_input := TextInput {
|
|
||||||
width: Fill, height: 42
|
|
||||||
empty_text: "you@example.com"
|
|
||||||
draw_bg +: { border_radius: 10.0, border_size: 1.0, border_color: #xE2E8F0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
Label { text: "SMTP server" draw_text +: { color: #x64748B, text_style: theme.font_regular { font_size: 10.5 } } }
|
|
||||||
server_input := TextInput {
|
|
||||||
width: Fill, height: 42
|
|
||||||
empty_text: "smtp.example.com"
|
|
||||||
draw_bg +: { border_radius: 10.0, border_size: 1.0, border_color: #xE2E8F0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
Label { text: "Port" draw_text +: { color: #x64748B, text_style: theme.font_regular { font_size: 10.5 } } }
|
|
||||||
port_input := TextInput {
|
|
||||||
width: Fill, height: 42
|
|
||||||
text: "587"
|
|
||||||
empty_text: "587"
|
|
||||||
draw_bg +: { border_radius: 10.0, border_size: 1.0, border_color: #xE2E8F0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
Label { text: "Username" draw_text +: { color: #x64748B, text_style: theme.font_regular { font_size: 10.5 } } }
|
|
||||||
username_input := TextInput {
|
|
||||||
width: Fill, height: 42
|
|
||||||
empty_text: "Usually your email address"
|
|
||||||
draw_bg +: { border_radius: 10.0, border_size: 1.0, border_color: #xE2E8F0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
Label { text: "Password or app password" draw_text +: { color: #x64748B, text_style: theme.font_regular { font_size: 10.5 } } }
|
|
||||||
password_input := TextInput {
|
|
||||||
width: Fill, height: 42
|
|
||||||
empty_text: "••••••••"
|
|
||||||
is_password: true
|
|
||||||
draw_bg +: { border_radius: 10.0, border_size: 1.0, border_color: #xE2E8F0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
Label { text: "Display name (optional)" draw_text +: { color: #x64748B, text_style: theme.font_regular { font_size: 10.5 } } }
|
|
||||||
display_name_input := TextInput {
|
|
||||||
width: Fill, height: 42
|
|
||||||
empty_text: "Shown to people you email"
|
|
||||||
draw_bg +: { border_radius: 10.0, border_size: 1.0, border_color: #xE2E8F0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
connect_btn := Button {
|
|
||||||
width: Fill, height: 48
|
|
||||||
text: "Connect account"
|
|
||||||
draw_bg +: { color: #x1C274C, color_hover: #x2A3F6E, border_radius: 14.0 }
|
|
||||||
draw_text +: { color: #xFFFFFF, text_style: theme.font_bold { font_size: 14.0 } }
|
|
||||||
}
|
|
||||||
|
|
||||||
setup_status := Label {
|
|
||||||
width: Fill, height: Fit
|
|
||||||
text: ""
|
|
||||||
draw_text +: { color: #xB4232C, text_style: theme.font_regular { font_size: 10.5 } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Emitted when the user asks to connect. Carries the raw draft; the
|
|
||||||
/// screen validates and drives the connection test.
|
|
||||||
#[derive(Clone, Debug, Default)]
|
|
||||||
pub enum EmailAccountSetupAction {
|
|
||||||
#[default]
|
|
||||||
None,
|
|
||||||
Connect(AccountDraft),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ActionDefaultRef for EmailAccountSetupAction {
|
|
||||||
fn default_ref() -> &'static Self {
|
|
||||||
static DEFAULT: EmailAccountSetupAction = EmailAccountSetupAction::None;
|
|
||||||
&DEFAULT
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Script, ScriptHook, Widget)]
|
|
||||||
pub struct EmailAccountSetup {
|
|
||||||
#[deref]
|
|
||||||
view: View,
|
|
||||||
/// Set when the address field last auto-filled the server, so a user
|
|
||||||
/// who typed their own server is never overwritten.
|
|
||||||
#[rust]
|
|
||||||
autofilled_server: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Widget for EmailAccountSetup {
|
|
||||||
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
|
|
||||||
self.view.handle_event(cx, event, scope);
|
|
||||||
|
|
||||||
let Event::Actions(actions) = event else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Autofill the server for known providers as soon as the address
|
|
||||||
// has a recognisable domain. This is why the form is not five
|
|
||||||
// mandatory fields for a Gmail user.
|
|
||||||
if self
|
|
||||||
.view
|
|
||||||
.text_input(cx, ids!(address_input))
|
|
||||||
.changed(actions)
|
|
||||||
.is_some()
|
|
||||||
{
|
|
||||||
let address = self.view.text_input(cx, ids!(address_input)).text();
|
|
||||||
if let Some((server, port)) = guess_provider(&address) {
|
|
||||||
let current = self.view.text_input(cx, ids!(server_input)).text();
|
|
||||||
// Only fill a blank field, or one we filled ourselves.
|
|
||||||
if current.trim().is_empty() || self.autofilled_server {
|
|
||||||
self.view
|
|
||||||
.text_input(cx, ids!(server_input))
|
|
||||||
.set_text(cx, server);
|
|
||||||
self.view
|
|
||||||
.text_input(cx, ids!(port_input))
|
|
||||||
.set_text(cx, &port.to_string());
|
|
||||||
self.autofilled_server = true;
|
|
||||||
self.view.redraw(cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.view.button(cx, ids!(connect_btn)).clicked(actions) {
|
|
||||||
let draft = self.read_draft(cx);
|
|
||||||
// Validate here so the form can report every bad field at
|
|
||||||
// once; the screen re-validates before touching the network.
|
|
||||||
match draft.validate() {
|
|
||||||
Err(errors) => {
|
|
||||||
let msg = errors
|
|
||||||
.iter()
|
|
||||||
.map(|e| e.message())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(" ");
|
|
||||||
self.view.label(cx, ids!(setup_status)).set_text(cx, &msg);
|
|
||||||
self.view.redraw(cx);
|
|
||||||
}
|
|
||||||
Ok(_) => {
|
|
||||||
self.view
|
|
||||||
.label(cx, ids!(setup_status))
|
|
||||||
.set_text(cx, "Checking connection…");
|
|
||||||
self.view.redraw(cx);
|
|
||||||
cx.widget_action(self.widget_uid(), EmailAccountSetupAction::Connect(draft));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
|
|
||||||
self.view.draw_walk(cx, scope, walk)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EmailAccountSetup {
|
|
||||||
fn read_draft(&mut self, cx: &mut Cx) -> AccountDraft {
|
|
||||||
AccountDraft {
|
|
||||||
address: self.view.text_input(cx, ids!(address_input)).text(),
|
|
||||||
smtp_server: self.view.text_input(cx, ids!(server_input)).text(),
|
|
||||||
smtp_port: self.view.text_input(cx, ids!(port_input)).text(),
|
|
||||||
username: self.view.text_input(cx, ids!(username_input)).text(),
|
|
||||||
password: self.view.text_input(cx, ids!(password_input)).text(),
|
|
||||||
display_name: self.view.text_input(cx, ids!(display_name_input)).text(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Show why a connection failed, and keep what the user typed.
|
|
||||||
pub fn show_error(&mut self, cx: &mut Cx, message: &str) {
|
|
||||||
self.view
|
|
||||||
.label(cx, ids!(setup_status))
|
|
||||||
.set_text(cx, message);
|
|
||||||
self.view.redraw(cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Prefill from a previously-entered account after a failure, so the
|
|
||||||
/// user fixes one field instead of retyping six.
|
|
||||||
pub fn prefill(&mut self, cx: &mut Cx, account: &nigig_core::email_account::EmailAccount) {
|
|
||||||
self.view
|
|
||||||
.text_input(cx, ids!(address_input))
|
|
||||||
.set_text(cx, &account.address);
|
|
||||||
self.view
|
|
||||||
.text_input(cx, ids!(server_input))
|
|
||||||
.set_text(cx, &account.smtp_server);
|
|
||||||
self.view
|
|
||||||
.text_input(cx, ids!(port_input))
|
|
||||||
.set_text(cx, &account.smtp_port.to_string());
|
|
||||||
self.view
|
|
||||||
.text_input(cx, ids!(username_input))
|
|
||||||
.set_text(cx, &account.username);
|
|
||||||
self.view
|
|
||||||
.text_input(cx, ids!(display_name_input))
|
|
||||||
.set_text(cx, &account.display_name);
|
|
||||||
self.view.redraw(cx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
157
crates/apps/nigig-email/src/email_frame/pages/drafts.rs
Normal file
157
crates/apps/nigig-email/src/email_frame/pages/drafts.rs
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
|
||||||
|
use makepad_widgets::*;
|
||||||
|
use crate::shared::context_nav_action::ContextNavAction;
|
||||||
|
|
||||||
|
script_mod! {
|
||||||
|
use mod.prelude.widgets.*
|
||||||
|
use mod.widgets.*
|
||||||
|
|
||||||
|
mod.widgets.EmailDraftsPage = #(EmailDraftsPage::register_widget(vm)) {
|
||||||
|
width: Fill, height: Fill
|
||||||
|
|
||||||
|
page_stack := StackNavigation {
|
||||||
|
root_view +: {
|
||||||
|
width: Fill, height: Fill
|
||||||
|
flow: Down
|
||||||
|
|
||||||
|
page_top_bar := SolidView {
|
||||||
|
width: Fill, height: 52
|
||||||
|
flow: Right
|
||||||
|
align: Align{y: 0.5}
|
||||||
|
padding: Inset{left: 16, right: 16, top: 0, bottom: 0}
|
||||||
|
show_bg: true
|
||||||
|
draw_bg +: { color: #xFFFFFF }
|
||||||
|
|
||||||
|
Label {
|
||||||
|
width: Fill, height: Fit
|
||||||
|
text: "Drafts"
|
||||||
|
draw_text +: { color: #x1C274C, text_style: theme.font_bold { font_size: 20.0 } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
page_body := ScrollYView {
|
||||||
|
width: Fill, height: Fill
|
||||||
|
flow: Down
|
||||||
|
padding: Inset{left: 18, right: 18, top: 12, bottom: 22}
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
RoundedView {
|
||||||
|
width: Fill, height: Fit
|
||||||
|
flow: Down
|
||||||
|
spacing: 8
|
||||||
|
padding: 18
|
||||||
|
show_bg: true
|
||||||
|
draw_bg +: { color: #xF8FAFC, border_radius: 22.0, border_size: 1.0, border_color: #xE2E8F0 }
|
||||||
|
Label { text: "Drafts" draw_text +: { color: #x1C274C, text_style: theme.font_bold { font_size: 22.0 } } }
|
||||||
|
Label { width: Fill, height: Fit, text: "Top app bar page. Tap below to open a stack screen with RobrixStackNavigationView back navigation." draw_text +: { color: #x64748B, text_style: theme.font_regular { font_size: 11.0 } } }
|
||||||
|
}
|
||||||
|
|
||||||
|
open_detail_btn := Button {
|
||||||
|
width: Fill, height: 54
|
||||||
|
text: "Open Drafts workflow"
|
||||||
|
draw_bg +: { color: #xEEF4FF, color_hover: #xDDEBFF, color_down: #xCFE2FF, border_radius: 16.0, border_size: 1.0, border_color: #xD7E5FF }
|
||||||
|
draw_text +: { color: #x1C274C, text_style: theme.font_bold { font_size: 12.0 } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stack_templates: {
|
||||||
|
EmailDraftsPageDetailStackView := mod.widgets.RobrixStackNavigationView {
|
||||||
|
body +: {
|
||||||
|
detail_body := ScrollYView {
|
||||||
|
width: Fill, height: Fill
|
||||||
|
flow: Down
|
||||||
|
padding: Inset{left: 18, right: 18, top: 18, bottom: 22}
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
Label {
|
||||||
|
width: Fill, height: Fit
|
||||||
|
text: "Drafts details"
|
||||||
|
draw_text +: { color: #x1C274C, text_style: theme.font_bold { font_size: 22.0 } }
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
width: Fill, height: Fit
|
||||||
|
text: "This is a RobrixStackNavigationView destination. The built-in header above supplies the title and back arrow, just like SMS conversation screens."
|
||||||
|
draw_text +: { color: #x64748B, text_style: theme.font_regular { font_size: 11.0 } }
|
||||||
|
}
|
||||||
|
RoundedView {
|
||||||
|
width: Fill, height: Fit
|
||||||
|
flow: Down
|
||||||
|
padding: 16
|
||||||
|
spacing: 8
|
||||||
|
show_bg: true
|
||||||
|
draw_bg +: { color: #xF8FAFC, border_radius: 18.0, border_size: 1.0, border_color: #xE2E8F0 }
|
||||||
|
Label { text: "Next screen content" draw_text +: { color: #x1C274C, text_style: theme.font_bold { font_size: 13.0 } } }
|
||||||
|
Label { width: Fill, height: Fit, text: "Replace this scaffold with the real workflow for Drafts." draw_text +: { color: #x64748B, text_style: theme.font_regular { font_size: 10.5 } } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Script, ScriptHook, Widget)]
|
||||||
|
pub struct EmailDraftsPage {
|
||||||
|
#[deref]
|
||||||
|
view: View,
|
||||||
|
#[rust]
|
||||||
|
current_detail_view: Option<LiveId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget for EmailDraftsPage {
|
||||||
|
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
|
||||||
|
// First forward the event so dynamic StackNavigation children can produce actions.
|
||||||
|
// Then handle the Event::Actions carried by this turn. This follows the SMS/Home pattern.
|
||||||
|
self.view.handle_event(cx, event, scope);
|
||||||
|
|
||||||
|
if let Event::Actions(actions) = event {
|
||||||
|
if self.view.button(cx, ids!(open_detail_btn)).clicked(actions) {
|
||||||
|
self.push_detail(cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
for action in actions {
|
||||||
|
if let StackNavigationTransitionAction::ViewReleased(view_id) = action.as_widget_action().cast() {
|
||||||
|
if self.current_detail_view == Some(view_id) {
|
||||||
|
self.current_detail_view = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let StackNavigationAction::Pop = action.as_widget_action().cast() {
|
||||||
|
self.pop_detail(cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
|
||||||
|
self.view.draw_walk(cx, scope, walk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EmailDraftsPage {
|
||||||
|
fn push_detail(&mut self, cx: &mut Cx) {
|
||||||
|
let stack = self.view.stack_navigation(cx, ids!(page_stack));
|
||||||
|
if stack.is_transitioning() { return; }
|
||||||
|
if let Some((view_id, _view)) = stack.create_view_from_template(cx, id!(EmailDraftsPageDetailStackView)) {
|
||||||
|
self.current_detail_view = Some(view_id);
|
||||||
|
stack.set_title(cx, view_id, "Drafts");
|
||||||
|
stack.push(cx, view_id);
|
||||||
|
cx.action(ContextNavAction::HideBottomNav);
|
||||||
|
self.view.redraw(cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pop_detail(&mut self, cx: &mut Cx) {
|
||||||
|
let stack = self.view.stack_navigation(cx, ids!(page_stack));
|
||||||
|
if stack.is_transitioning() { return; }
|
||||||
|
self.current_detail_view = None;
|
||||||
|
stack.pop_to_root(cx);
|
||||||
|
cx.action(ContextNavAction::ShowBottomNav);
|
||||||
|
self.view.redraw(cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,38 +1,5 @@
|
||||||
// Email inbox: a list of senders, and a thread per sender.
|
|
||||||
//
|
|
||||||
// Structure mirrors the SMS inbox deliberately, because the interaction
|
|
||||||
// is the same and users move between the two features:
|
|
||||||
//
|
|
||||||
// PortalList of preview rows
|
|
||||||
// -> tap a row
|
|
||||||
// -> SharedConversationPreviewAction::Clicked
|
|
||||||
// -> push a RobrixStackNavigationView with the thread timeline
|
|
||||||
// -> back arrow pops to the list
|
|
||||||
//
|
|
||||||
// It reuses `nigig_uikit::shared::conversation`, which already exists for
|
|
||||||
// exactly this: `SharedConversationKind::Email` is a variant in that
|
|
||||||
// module's `types.rs`, and the row widget, message bubbles and date
|
|
||||||
// dividers are all generic. Nothing here reimplements what SMS has.
|
|
||||||
//
|
|
||||||
// The page is gated on account state. Signed out, it shows the setup
|
|
||||||
// form; signed in, the list. Before this, the Inbox tab rendered the text
|
|
||||||
// "Top app bar page. Tap below to open a stack screen." -- a placeholder
|
|
||||||
// with no path to any mail, and the credentials form was on the Bulk tab.
|
|
||||||
|
|
||||||
use makepad_widgets::*;
|
|
||||||
use nigig_core::email_account::{AccountDraft, EmailAccount, SessionState};
|
|
||||||
use nigig_core::email_store::{
|
|
||||||
group_by_sender, sample_thread, thread_for_sender, total_unread, EmailMessage,
|
|
||||||
EmailThreadSummary,
|
|
||||||
};
|
|
||||||
use nigig_core::email_worker::{spawn_smtp_test, EmailWorkerAction, SmtpConfig};
|
|
||||||
use nigig_uikit::shared::conversation::conversation_preview::{
|
|
||||||
SharedConversationPreviewAction, SharedConversationPreviewProps,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::shared::context_nav_action::ContextNavAction;
|
use crate::shared::context_nav_action::ContextNavAction;
|
||||||
|
use makepad_widgets::*;
|
||||||
use super::account_setup::{EmailAccountSetupAction, EmailAccountSetupWidgetExt};
|
|
||||||
|
|
||||||
script_mod! {
|
script_mod! {
|
||||||
use mod.prelude.widgets.*
|
use mod.prelude.widgets.*
|
||||||
|
|
@ -54,87 +21,67 @@ script_mod! {
|
||||||
show_bg: true
|
show_bg: true
|
||||||
draw_bg +: { color: #xFFFFFF }
|
draw_bg +: { color: #xFFFFFF }
|
||||||
|
|
||||||
inbox_title := Label {
|
Label {
|
||||||
width: Fill, height: Fit
|
width: Fill, height: Fit
|
||||||
text: "Inbox"
|
text: "Inbox"
|
||||||
draw_text +: { color: #x1C274C, text_style: theme.font_bold { font_size: 20.0 } }
|
draw_text +: { color: #x1C274C, text_style: theme.font_bold { font_size: 20.0 } }
|
||||||
}
|
}
|
||||||
unread_badge := Label {
|
|
||||||
width: Fit, height: Fit
|
|
||||||
text: ""
|
|
||||||
draw_text +: { color: #x1a73e8, text_style: theme.font_bold { font_size: 12.0 } }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Signed out vs signed in. Only one is ever visible.
|
page_body := ScrollYView {
|
||||||
inbox_body_flip := PageFlip {
|
|
||||||
width: Fill, height: Fill
|
|
||||||
active_page: @signed_out_page
|
|
||||||
|
|
||||||
signed_out_page := View {
|
|
||||||
width: Fill, height: Fill
|
|
||||||
account_setup := mod.widgets.EmailAccountSetup {}
|
|
||||||
}
|
|
||||||
|
|
||||||
signed_in_page := View {
|
|
||||||
width: Fill, height: Fill
|
width: Fill, height: Fill
|
||||||
flow: Down
|
flow: Down
|
||||||
|
padding: Inset{left: 18, right: 18, top: 12, bottom: 22}
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
threads_list := PortalList {
|
RoundedView {
|
||||||
keep_invisible: false
|
width: Fill, height: Fit
|
||||||
auto_tail: false
|
|
||||||
width: Fill, height: Fill
|
|
||||||
flow: Down
|
flow: Down
|
||||||
spacing: 0.0
|
|
||||||
|
|
||||||
conversation_preview := mod.widgets.SharedConversationPreview {}
|
|
||||||
empty_state := View {
|
|
||||||
width: Fill, height: 220
|
|
||||||
flow: Down
|
|
||||||
align: Align{x: 0.5, y: 0.5}
|
|
||||||
spacing: 8
|
spacing: 8
|
||||||
Label {
|
padding: 18
|
||||||
text: "No mail yet"
|
show_bg: true
|
||||||
draw_text +: { color: #x1C274C, text_style: theme.font_bold { font_size: 15.0 } }
|
draw_bg +: { color: #xF8FAFC, border_radius: 22.0, border_size: 1.0, border_color: #xE2E8F0 }
|
||||||
}
|
Label { text: "Inbox" draw_text +: { color: #x1C274C, text_style: theme.font_bold { font_size: 22.0 } } }
|
||||||
Label {
|
Label { width: Fill, height: Fit, text: "Top app bar page. Tap below to open a stack screen with RobrixStackNavigationView back navigation." draw_text +: { color: #x64748B, text_style: theme.font_regular { font_size: 11.0 } } }
|
||||||
width: 260, height: Fit
|
|
||||||
text: "Messages from your account will appear here."
|
|
||||||
draw_text +: { color: #x64748B, text_style: theme.font_regular { font_size: 11.0 } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bottom_filler := View { width: Fill, height: 80.0 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
open_detail_btn := Button {
|
||||||
|
width: Fill, height: 54
|
||||||
|
text: "Open Inbox workflow"
|
||||||
|
draw_bg +: { color: #xEEF4FF, color_hover: #xDDEBFF, color_down: #xCFE2FF, border_radius: 16.0, border_size: 1.0, border_color: #xD7E5FF }
|
||||||
|
draw_text +: { color: #x1C274C, text_style: theme.font_bold { font_size: 12.0 } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stack_templates: {
|
stack_templates: {
|
||||||
// The thread screen. Same shell SMS conversations use, so
|
EmailInboxPageDetailStackView := mod.widgets.RobrixStackNavigationView {
|
||||||
// the header, back arrow and transition behave identically.
|
|
||||||
EmailThreadStackView := mod.widgets.RobrixStackNavigationView {
|
|
||||||
body +: {
|
body +: {
|
||||||
thread_body := View {
|
detail_body := ScrollYView {
|
||||||
width: Fill, height: Fill
|
width: Fill, height: Fill
|
||||||
flow: Down
|
flow: Down
|
||||||
|
padding: Inset{left: 18, right: 18, top: 18, bottom: 22}
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
thread_subject := Label {
|
Label {
|
||||||
width: Fill, height: Fit
|
width: Fill, height: Fit
|
||||||
padding: Inset{left: 18, right: 18, top: 12, bottom: 4}
|
text: "Inbox details"
|
||||||
text: ""
|
draw_text +: { color: #x1C274C, text_style: theme.font_bold { font_size: 22.0 } }
|
||||||
draw_text +: { color: #x1C274C, text_style: theme.font_bold { font_size: 15.0 } }
|
|
||||||
}
|
}
|
||||||
|
Label {
|
||||||
thread_timeline := PortalList {
|
width: Fill, height: Fit
|
||||||
keep_invisible: false
|
text: "This is a RobrixStackNavigationView destination. The built-in header above supplies the title and back arrow, just like SMS conversation screens."
|
||||||
auto_tail: true
|
draw_text +: { color: #x64748B, text_style: theme.font_regular { font_size: 11.0 } }
|
||||||
width: Fill, height: Fill
|
}
|
||||||
|
RoundedView {
|
||||||
|
width: Fill, height: Fit
|
||||||
flow: Down
|
flow: Down
|
||||||
spacing: 0.0
|
padding: 16
|
||||||
|
spacing: 8
|
||||||
sent_message := mod.widgets.SharedSentMessageBubble {}
|
show_bg: true
|
||||||
received_message := mod.widgets.SharedReceivedMessageBubble {}
|
draw_bg +: { color: #xF8FAFC, border_radius: 18.0, border_size: 1.0, border_color: #xE2E8F0 }
|
||||||
thread_filler := View { width: Fill, height: 40.0 }
|
Label { text: "Next screen content" draw_text +: { color: #x1C274C, text_style: theme.font_bold { font_size: 13.0 } } }
|
||||||
|
Label { width: Fill, height: Fit, text: "Replace this scaffold with the real workflow for Inbox." draw_text +: { color: #x64748B, text_style: theme.font_regular { font_size: 10.5 } } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -148,350 +95,66 @@ script_mod! {
|
||||||
pub struct EmailInboxPage {
|
pub struct EmailInboxPage {
|
||||||
#[deref]
|
#[deref]
|
||||||
view: View,
|
view: View,
|
||||||
|
|
||||||
#[rust]
|
#[rust]
|
||||||
session: SessionState,
|
current_detail_view: Option<LiveId>,
|
||||||
/// Session-only. Never written to disk -- see the note in
|
|
||||||
/// `nigig_core::email_account`: `EmailAccount` is the persistable half
|
|
||||||
/// and deliberately has no password field.
|
|
||||||
#[rust]
|
|
||||||
password: String,
|
|
||||||
/// The draft awaiting a connection result, so a failure can report
|
|
||||||
/// against the account the user actually typed.
|
|
||||||
#[rust]
|
|
||||||
pending: Option<EmailAccount>,
|
|
||||||
|
|
||||||
#[rust]
|
|
||||||
messages: Vec<EmailMessage>,
|
|
||||||
#[rust]
|
|
||||||
threads: Vec<EmailThreadSummary>,
|
|
||||||
|
|
||||||
#[rust]
|
|
||||||
current_thread_view: Option<LiveId>,
|
|
||||||
/// Sender whose thread is open, so the timeline can be rebuilt if new
|
|
||||||
/// mail arrives while it is on screen.
|
|
||||||
#[rust]
|
|
||||||
open_sender: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Widget for EmailInboxPage {
|
impl Widget for EmailInboxPage {
|
||||||
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
|
fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
|
||||||
// Forward first so PortalList children and the dynamically created
|
// First forward the event so dynamic StackNavigation children can produce actions.
|
||||||
// stack view can emit their actions this turn. Same ordering the
|
// Then handle the Event::Actions carried by this turn. This follows the SMS/Home pattern.
|
||||||
// SMS pages use, and the reason the original scaffold commented it.
|
|
||||||
let child_actions = cx.capture_actions(|cx| {
|
|
||||||
self.view.handle_event(cx, event, scope);
|
self.view.handle_event(cx, event, scope);
|
||||||
});
|
|
||||||
|
|
||||||
for action in &child_actions {
|
if let Event::Actions(actions) = event {
|
||||||
// Row tapped -> open that sender's thread.
|
if self.view.button(cx, ids!(open_detail_btn)).clicked(actions) {
|
||||||
if let SharedConversationPreviewAction::Clicked {
|
self.push_detail(cx);
|
||||||
address,
|
|
||||||
display_name,
|
|
||||||
} = action.as_widget_action().cast()
|
|
||||||
{
|
|
||||||
self.open_thread(cx, &address, &display_name);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Back arrow / swipe dismissed the thread.
|
for action in actions {
|
||||||
if let StackNavigationTransitionAction::ViewReleased(view_id) =
|
if let StackNavigationTransitionAction::ViewReleased(view_id) =
|
||||||
action.as_widget_action().cast()
|
action.as_widget_action().cast()
|
||||||
{
|
{
|
||||||
if self.current_thread_view == Some(view_id) {
|
if self.current_detail_view == Some(view_id) {
|
||||||
self.current_thread_view = None;
|
self.current_detail_view = None;
|
||||||
self.open_sender = None;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let StackNavigationAction::Pop = action.as_widget_action().cast() {
|
if let StackNavigationAction::Pop = action.as_widget_action().cast() {
|
||||||
self.close_thread(cx);
|
self.pop_detail(cx);
|
||||||
}
|
|
||||||
|
|
||||||
// Setup form asked to connect.
|
|
||||||
if let EmailAccountSetupAction::Connect(draft) = action.as_widget_action().cast() {
|
|
||||||
self.begin_connect(cx, draft);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SMTP test result decides signed-in vs failed.
|
|
||||||
if let Event::Actions(actions) = event {
|
|
||||||
for action in actions {
|
|
||||||
if let Some(EmailWorkerAction::SmtpTestResult(result)) = action.downcast_ref() {
|
|
||||||
self.finish_connect(cx, result.clone());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cx.extend_actions(child_actions);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
|
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
|
||||||
while let Some(next) = self.view.draw_walk(cx, scope, walk).step() {
|
self.view.draw_walk(cx, scope, walk)
|
||||||
// The inbox list.
|
|
||||||
if let Some(mut list) = next.as_portal_list().borrow_mut() {
|
|
||||||
let count = self.threads.len();
|
|
||||||
// One full-height item when empty keeps the empty state
|
|
||||||
// centred and the list scrollable.
|
|
||||||
let range_end = if count == 0 { 1 } else { count + 1 };
|
|
||||||
list.set_item_range(cx, 0, range_end);
|
|
||||||
|
|
||||||
while let Some(item_id) = list.next_visible_item(cx) {
|
|
||||||
if count == 0 {
|
|
||||||
if item_id == 0 {
|
|
||||||
let item = list.item(cx, item_id, id!(empty_state));
|
|
||||||
item.draw_all(cx, &mut Scope::empty());
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if item_id >= count {
|
|
||||||
let item = list.item(cx, item_id, id!(bottom_filler));
|
|
||||||
item.draw_all(cx, &mut Scope::empty());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let thread = &self.threads[item_id];
|
|
||||||
let item = list.item(cx, item_id, id!(conversation_preview));
|
|
||||||
|
|
||||||
// Populate through the shared row's documented paths.
|
|
||||||
item.label(cx, ids!(preview_content.top_row.sender_label))
|
|
||||||
.set_text(cx, &thread.sender_display);
|
|
||||||
item.label(cx, ids!(preview_content.top_row.time_label))
|
|
||||||
.set_text(cx, &format_thread_time(thread.latest_date_ms));
|
|
||||||
item.label(cx, ids!(preview_content.preview_body))
|
|
||||||
.set_text(cx, &row_body(thread));
|
|
||||||
|
|
||||||
// The row needs address + display name to emit Clicked.
|
|
||||||
// Bind the props to a local first: `Scope::with_props`
|
|
||||||
// borrows, so an inline temporary would be dropped
|
|
||||||
// before draw_all runs.
|
|
||||||
let props = SharedConversationPreviewProps {
|
|
||||||
address: thread.sender_address.clone(),
|
|
||||||
display_name: thread.sender_display.clone(),
|
|
||||||
was_scrolling: false,
|
|
||||||
};
|
|
||||||
let mut item_scope = Scope::with_props(&props);
|
|
||||||
item.draw_all(cx, &mut item_scope);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DrawStep::done()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Row body: subject then preview, which is the information order a mail
|
|
||||||
/// client uses. Subject alone is often boilerplate ("Statement ready");
|
|
||||||
/// preview alone loses the topic.
|
|
||||||
fn row_body(thread: &EmailThreadSummary) -> String {
|
|
||||||
if thread.latest_preview.is_empty() {
|
|
||||||
thread.latest_subject.clone()
|
|
||||||
} else {
|
|
||||||
format!("{} — {}", thread.latest_subject, thread.latest_preview)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Short timestamp for a list row.
|
|
||||||
fn format_thread_time(ms: i64) -> String {
|
|
||||||
use chrono::{Local, TimeZone};
|
|
||||||
match Local.timestamp_millis_opt(ms) {
|
|
||||||
chrono::LocalResult::Single(dt) => dt.format("%d %b").to_string(),
|
|
||||||
// Provider garbage must not blank the row or panic.
|
|
||||||
_ => String::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EmailInboxPage {
|
impl EmailInboxPage {
|
||||||
/// Validate the draft and start an SMTP connection test.
|
fn push_detail(&mut self, cx: &mut Cx) {
|
||||||
fn begin_connect(&mut self, cx: &mut Cx, draft: AccountDraft) {
|
|
||||||
let (account, password) = match draft.validate() {
|
|
||||||
Ok(v) => v,
|
|
||||||
Err(errors) => {
|
|
||||||
let msg = errors
|
|
||||||
.iter()
|
|
||||||
.map(|e| e.message())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(" ");
|
|
||||||
self.setup_error(cx, &msg);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
self.session = SessionState::Verifying;
|
|
||||||
self.pending = Some(account.clone());
|
|
||||||
self.password = password.clone();
|
|
||||||
|
|
||||||
// Reuse the existing worker. A successful SMTP handshake with
|
|
||||||
// AUTH is the only credential check available without an IMAP
|
|
||||||
// client, and it is the honest one: it proves the account can
|
|
||||||
// send, which is what this app can currently do with it.
|
|
||||||
spawn_smtp_test(SmtpConfig {
|
|
||||||
server: account.smtp_server.clone(),
|
|
||||||
port: account.smtp_port,
|
|
||||||
username: account.username.clone(),
|
|
||||||
password,
|
|
||||||
from: account.address.clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn finish_connect(&mut self, cx: &mut Cx, result: Result<(), String>) {
|
|
||||||
let Some(account) = self.pending.take() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
match result {
|
|
||||||
Ok(()) => {
|
|
||||||
self.session = SessionState::SignedIn(account);
|
|
||||||
// No receive path exists yet (assessment A1), so the list
|
|
||||||
// is populated from sample data. When a real fetch lands
|
|
||||||
// it fills `self.messages` and nothing else changes.
|
|
||||||
self.messages = sample_thread();
|
|
||||||
self.rebuild_threads();
|
|
||||||
self.show_signed_in(cx, true);
|
|
||||||
}
|
|
||||||
Err(reason) => {
|
|
||||||
self.setup_error(cx, &reason);
|
|
||||||
self.setup_prefill(cx, &account);
|
|
||||||
self.session = SessionState::Failed { account, reason };
|
|
||||||
// Drop the secret the moment it is known to be unusable.
|
|
||||||
self.password.clear();
|
|
||||||
self.show_signed_in(cx, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.view.redraw(cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Show a message on the setup form.
|
|
||||||
///
|
|
||||||
/// `email_account_setup(..)` yields a `Ref`, which exposes only the
|
|
||||||
/// Widget trait; inherent methods need the concrete type, so borrow it.
|
|
||||||
fn setup_error(&mut self, cx: &mut Cx, message: &str) {
|
|
||||||
let setup = self.view.email_account_setup(cx, ids!(account_setup));
|
|
||||||
if let Some(mut s) = setup.borrow_mut() {
|
|
||||||
s.show_error(cx, message);
|
|
||||||
}
|
|
||||||
drop(setup);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Repopulate the form after a failure so one field can be fixed
|
|
||||||
/// without retyping the rest.
|
|
||||||
fn setup_prefill(&mut self, cx: &mut Cx, account: &EmailAccount) {
|
|
||||||
let setup = self.view.email_account_setup(cx, ids!(account_setup));
|
|
||||||
if let Some(mut s) = setup.borrow_mut() {
|
|
||||||
s.prefill(cx, account);
|
|
||||||
}
|
|
||||||
drop(setup);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rebuild_threads(&mut self) {
|
|
||||||
self.threads = group_by_sender(&self.messages);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn show_signed_in(&mut self, cx: &mut Cx, signed_in: bool) {
|
|
||||||
let page = if signed_in {
|
|
||||||
id!(signed_in_page)
|
|
||||||
} else {
|
|
||||||
id!(signed_out_page)
|
|
||||||
};
|
|
||||||
self.view
|
|
||||||
.page_flip(cx, ids!(inbox_body_flip))
|
|
||||||
.set_active_page(cx, page);
|
|
||||||
|
|
||||||
let unread = total_unread(&self.messages);
|
|
||||||
self.view.label(cx, ids!(unread_badge)).set_text(
|
|
||||||
cx,
|
|
||||||
&if signed_in && unread > 0 {
|
|
||||||
format!("{unread} unread")
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
self.view.redraw(cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Push the thread screen for one sender.
|
|
||||||
fn open_thread(&mut self, cx: &mut Cx, sender: &str, display_name: &str) {
|
|
||||||
let stack = self.view.stack_navigation(cx, ids!(page_stack));
|
let stack = self.view.stack_navigation(cx, ids!(page_stack));
|
||||||
if stack.is_transitioning() {
|
if stack.is_transitioning() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let Some((view_id, view)) = stack.create_view_from_template(cx, id!(EmailThreadStackView))
|
if let Some((view_id, _view)) =
|
||||||
else {
|
stack.create_view_from_template(cx, id!(EmailInboxPageDetailStackView))
|
||||||
return;
|
{
|
||||||
};
|
self.current_detail_view = Some(view_id);
|
||||||
|
stack.set_title(cx, view_id, "Inbox");
|
||||||
let thread = thread_for_sender(&self.messages, sender);
|
|
||||||
let subject = thread
|
|
||||||
.last()
|
|
||||||
.map(|m| m.subject.clone())
|
|
||||||
.filter(|s| !s.trim().is_empty())
|
|
||||||
.unwrap_or_else(|| "(no subject)".to_string());
|
|
||||||
|
|
||||||
view.label(cx, ids!(thread_subject)).set_text(cx, &subject);
|
|
||||||
|
|
||||||
self.current_thread_view = Some(view_id);
|
|
||||||
self.open_sender = Some(sender.to_string());
|
|
||||||
stack.set_title(cx, view_id, display_name);
|
|
||||||
stack.push(cx, view_id);
|
stack.push(cx, view_id);
|
||||||
|
|
||||||
// Hide the bottom nav while a thread is open, matching SMS.
|
|
||||||
cx.action(ContextNavAction::HideBottomNav);
|
cx.action(ContextNavAction::HideBottomNav);
|
||||||
|
self.view.redraw(cx);
|
||||||
// Reading a thread clears its unread count.
|
|
||||||
let key = nigig_core::email_store::normalise_sender(sender);
|
|
||||||
for m in self.messages.iter_mut() {
|
|
||||||
if nigig_core::email_store::normalise_sender(&m.from_address) == key {
|
|
||||||
m.is_read = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.rebuild_threads();
|
|
||||||
let signed_in = self.session.is_signed_in();
|
|
||||||
self.show_signed_in(cx, signed_in);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn close_thread(&mut self, cx: &mut Cx) {
|
fn pop_detail(&mut self, cx: &mut Cx) {
|
||||||
let stack = self.view.stack_navigation(cx, ids!(page_stack));
|
let stack = self.view.stack_navigation(cx, ids!(page_stack));
|
||||||
if stack.is_transitioning() {
|
if stack.is_transitioning() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.current_thread_view = None;
|
self.current_detail_view = None;
|
||||||
self.open_sender = None;
|
|
||||||
stack.pop_to_root(cx);
|
stack.pop_to_root(cx);
|
||||||
cx.action(ContextNavAction::ShowBottomNav);
|
cx.action(ContextNavAction::ShowBottomNav);
|
||||||
self.view.redraw(cx);
|
self.view.redraw(cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
/// Row text must carry both subject and preview; a mail row showing
|
|
||||||
/// only "Statement ready" for every message is useless.
|
|
||||||
#[test]
|
|
||||||
fn row_body_combines_subject_and_preview() {
|
|
||||||
let t = EmailThreadSummary {
|
|
||||||
latest_subject: "Statement ready".into(),
|
|
||||||
latest_preview: "Your January statement is available.".into(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let body = row_body(&t);
|
|
||||||
assert!(body.contains("Statement ready"));
|
|
||||||
assert!(body.contains("January statement"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn row_body_falls_back_to_subject_when_there_is_no_preview() {
|
|
||||||
let t = EmailThreadSummary {
|
|
||||||
latest_subject: "(no subject)".into(),
|
|
||||||
latest_preview: String::new(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
assert_eq!(row_body(&t), "(no subject)");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A provider timestamp we cannot represent must blank the field, not
|
|
||||||
/// panic mid-draw -- this runs inside draw_walk for every visible row.
|
|
||||||
#[test]
|
|
||||||
fn format_thread_time_never_panics_on_extreme_values() {
|
|
||||||
for ms in [i64::MIN, i64::MAX, 0, -1, 1_767_225_600_000] {
|
|
||||||
let _ = format_thread_time(ms);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
pub mod account_setup;
|
|
||||||
pub mod bulk;
|
pub mod bulk;
|
||||||
pub mod compose;
|
pub mod compose;
|
||||||
pub mod inbox;
|
pub mod inbox;
|
||||||
|
|
@ -7,7 +6,6 @@ pub mod more;
|
||||||
use makepad_widgets::ScriptVm;
|
use makepad_widgets::ScriptVm;
|
||||||
|
|
||||||
pub fn script_mod(vm: &mut ScriptVm) {
|
pub fn script_mod(vm: &mut ScriptVm) {
|
||||||
account_setup::script_mod(vm);
|
|
||||||
inbox::script_mod(vm);
|
inbox::script_mod(vm);
|
||||||
compose::script_mod(vm);
|
compose::script_mod(vm);
|
||||||
bulk::script_mod(vm);
|
bulk::script_mod(vm);
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ script_mod! {
|
||||||
window.title: "nigig-email"
|
window.title: "nigig-email"
|
||||||
body +: {
|
body +: {
|
||||||
root := mod.widgets.StandaloneFeatureShell {
|
root := mod.widgets.StandaloneFeatureShell {
|
||||||
standalone_body := mod.widgets.StandaloneFeatureBody {
|
|
||||||
root_screen := mod.widgets.EmailScreen {}
|
root_screen := mod.widgets.EmailScreen {}
|
||||||
}
|
}
|
||||||
standalone_bottom_nav := mod.widgets.StandaloneFeatureBottomNav {
|
standalone_bottom_nav := mod.widgets.StandaloneFeatureBottomNav {
|
||||||
|
|
|
||||||
|
|
@ -1,506 +0,0 @@
|
||||||
//! Email account identity and session state.
|
|
||||||
//!
|
|
||||||
//! The email UI has to answer one question before it can draw anything:
|
|
||||||
//! **is an account configured?** If yes, show the inbox. If no, show the
|
|
||||||
//! setup form. Everything about that decision lives here rather than in a
|
|
||||||
//! widget, so it can be unit tested on a host with no display and no
|
|
||||||
//! network -- the same reasoning that put `BulkSendRequest::validate` and
|
|
||||||
//! `SendPacing` in `robius-sms` instead of in a Makepad page.
|
|
||||||
//!
|
|
||||||
//! ## On the password
|
|
||||||
//!
|
|
||||||
//! `SmtpConfig` in `email_worker.rs` derives `Serialize` and carries a
|
|
||||||
//! plaintext `password: String`. That is finding S2 of the assessment and
|
|
||||||
//! it is the reason this module does NOT persist the password: it stores
|
|
||||||
//! everything needed to identify and display an account, and treats the
|
|
||||||
//! secret as session-only until a keystore-backed store exists.
|
|
||||||
//!
|
|
||||||
//! Concretely: `EmailAccount` is safe to write to disk. The password is
|
|
||||||
//! held separately in memory by the caller. A future phase can add
|
|
||||||
//! platform-keystore storage (the SMS crate's `SmsScheduleCrypto.java`
|
|
||||||
//! AES-256-GCM/AndroidKeyStore path is the in-repo precedent) without
|
|
||||||
//! changing anything here.
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// A configured email account, minus the secret.
|
|
||||||
///
|
|
||||||
/// Deliberately has no `password` field. See the module note.
|
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct EmailAccount {
|
|
||||||
/// The user's address, e.g. `jane@example.com`. Also the SMTP `from`.
|
|
||||||
pub address: String,
|
|
||||||
/// SMTP host, e.g. `smtp.gmail.com`.
|
|
||||||
pub smtp_server: String,
|
|
||||||
pub smtp_port: u16,
|
|
||||||
/// Login name. Often the same as `address`, but not always -- some
|
|
||||||
/// providers want a bare username, so this is stored separately
|
|
||||||
/// rather than derived.
|
|
||||||
pub username: String,
|
|
||||||
/// Optional display name for outgoing mail.
|
|
||||||
pub display_name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Why an account was rejected. One variant per user-fixable mistake, so
|
|
||||||
/// the UI can say what to change instead of showing a parser error.
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub enum AccountError {
|
|
||||||
AddressMissing,
|
|
||||||
AddressMalformed,
|
|
||||||
ServerMissing,
|
|
||||||
ServerMalformed,
|
|
||||||
PortInvalid,
|
|
||||||
UsernameMissing,
|
|
||||||
PasswordMissing,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AccountError {
|
|
||||||
/// Message shown directly to the user. Says what to do, not what
|
|
||||||
/// failed internally.
|
|
||||||
pub fn message(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
AccountError::AddressMissing => "Enter your email address.",
|
|
||||||
AccountError::AddressMalformed => {
|
|
||||||
"That email address does not look right. Expected something like you@example.com."
|
|
||||||
}
|
|
||||||
AccountError::ServerMissing => "Enter your provider's SMTP server.",
|
|
||||||
AccountError::ServerMalformed => {
|
|
||||||
"That server name does not look right. Expected something like smtp.example.com."
|
|
||||||
}
|
|
||||||
AccountError::PortInvalid => "Port must be a number between 1 and 65535.",
|
|
||||||
AccountError::UsernameMissing => "Enter the username for your mail account.",
|
|
||||||
AccountError::PasswordMissing => "Enter your password or app password.",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Minimal address shape check.
|
|
||||||
///
|
|
||||||
/// Deliberately not an RFC 5322 validator -- that grammar permits
|
|
||||||
/// quoted local parts and comments that no consumer provider accepts, and
|
|
||||||
/// a strict implementation rejects addresses that work in practice. This
|
|
||||||
/// checks the properties whose absence is always a typo: exactly one `@`,
|
|
||||||
/// non-empty both sides, a dot in the domain, no whitespace.
|
|
||||||
pub fn looks_like_email(s: &str) -> bool {
|
|
||||||
let s = s.trim();
|
|
||||||
if s.is_empty() || s.chars().any(char::is_whitespace) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let mut parts = s.split('@');
|
|
||||||
let (Some(local), Some(domain), None) = (parts.next(), parts.next(), parts.next()) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
!local.is_empty()
|
|
||||||
&& !domain.is_empty()
|
|
||||||
&& domain.contains('.')
|
|
||||||
&& !domain.starts_with('.')
|
|
||||||
&& !domain.ends_with('.')
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Minimal hostname check: non-empty, dotted, no whitespace, no scheme.
|
|
||||||
///
|
|
||||||
/// Rejects `https://smtp.example.com` explicitly -- pasting a URL into a
|
|
||||||
/// server field is the single most common setup mistake and produces a
|
|
||||||
/// DNS error that reads like the provider is down.
|
|
||||||
pub fn looks_like_hostname(s: &str) -> bool {
|
|
||||||
let s = s.trim();
|
|
||||||
!s.is_empty()
|
|
||||||
&& !s.contains("://")
|
|
||||||
&& !s.contains('/')
|
|
||||||
&& !s.chars().any(char::is_whitespace)
|
|
||||||
&& s.contains('.')
|
|
||||||
&& !s.starts_with('.')
|
|
||||||
&& !s.ends_with('.')
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A filled-in setup form, straight from the UI.
|
|
||||||
///
|
|
||||||
/// Strings, because that is what a `TextInput` holds. `validate` is the
|
|
||||||
/// single place that turns them into something trustworthy.
|
|
||||||
#[derive(Clone, Debug, Default)]
|
|
||||||
pub struct AccountDraft {
|
|
||||||
pub address: String,
|
|
||||||
pub smtp_server: String,
|
|
||||||
pub smtp_port: String,
|
|
||||||
pub username: String,
|
|
||||||
pub password: String,
|
|
||||||
pub display_name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AccountDraft {
|
|
||||||
/// Validate and split into (persistable account, session secret).
|
|
||||||
///
|
|
||||||
/// Returns every error found, not just the first, so the form can
|
|
||||||
/// mark all bad fields in one pass instead of making the user
|
|
||||||
/// resubmit five times.
|
|
||||||
pub fn validate(&self) -> Result<(EmailAccount, String), Vec<AccountError>> {
|
|
||||||
let mut errors = Vec::new();
|
|
||||||
|
|
||||||
let address = self.address.trim();
|
|
||||||
if address.is_empty() {
|
|
||||||
errors.push(AccountError::AddressMissing);
|
|
||||||
} else if !looks_like_email(address) {
|
|
||||||
errors.push(AccountError::AddressMalformed);
|
|
||||||
}
|
|
||||||
|
|
||||||
let server = self.smtp_server.trim();
|
|
||||||
if server.is_empty() {
|
|
||||||
errors.push(AccountError::ServerMissing);
|
|
||||||
} else if !looks_like_hostname(server) {
|
|
||||||
errors.push(AccountError::ServerMalformed);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Empty port is not an error: it means "use the default". A
|
|
||||||
// non-numeric port IS an error -- the old code did
|
|
||||||
// `parse().unwrap_or(587)`, which silently rewrote a typo and
|
|
||||||
// thereby silently changed the transport (finding B3).
|
|
||||||
let port_raw = self.smtp_port.trim();
|
|
||||||
let port = if port_raw.is_empty() {
|
|
||||||
DEFAULT_SUBMISSION_PORT
|
|
||||||
} else {
|
|
||||||
match port_raw.parse::<u16>() {
|
|
||||||
Ok(0) | Err(_) => {
|
|
||||||
errors.push(AccountError::PortInvalid);
|
|
||||||
DEFAULT_SUBMISSION_PORT
|
|
||||||
}
|
|
||||||
Ok(p) => p,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Username defaults to the address, which is right for most
|
|
||||||
// providers, so a blank field is only an error when we have no
|
|
||||||
// address to fall back on.
|
|
||||||
let username = if self.username.trim().is_empty() {
|
|
||||||
address.to_string()
|
|
||||||
} else {
|
|
||||||
self.username.trim().to_string()
|
|
||||||
};
|
|
||||||
if username.is_empty() {
|
|
||||||
errors.push(AccountError::UsernameMissing);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not trimmed: leading and trailing spaces can be significant in
|
|
||||||
// a password, and silently stripping them causes an auth failure
|
|
||||||
// the user cannot explain.
|
|
||||||
if self.password.is_empty() {
|
|
||||||
errors.push(AccountError::PasswordMissing);
|
|
||||||
}
|
|
||||||
|
|
||||||
if !errors.is_empty() {
|
|
||||||
return Err(errors);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((
|
|
||||||
EmailAccount {
|
|
||||||
address: address.to_string(),
|
|
||||||
smtp_server: server.to_string(),
|
|
||||||
smtp_port: port,
|
|
||||||
username,
|
|
||||||
display_name: self.display_name.trim().to_string(),
|
|
||||||
},
|
|
||||||
self.password.clone(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Default SMTP submission port (STARTTLS).
|
|
||||||
pub const DEFAULT_SUBMISSION_PORT: u16 = 587;
|
|
||||||
/// Implicit-TLS submission port.
|
|
||||||
pub const IMPLICIT_TLS_PORT: u16 = 465;
|
|
||||||
|
|
||||||
/// Guess SMTP settings from an address, so the common case is one field.
|
|
||||||
///
|
|
||||||
/// Returns `None` for domains we do not know, in which case the user must
|
|
||||||
/// supply the server themselves. Guessing `smtp.<domain>` for arbitrary
|
|
||||||
/// domains is tempting and wrong: it is right often enough to look like a
|
|
||||||
/// feature and wrong often enough to produce confusing failures.
|
|
||||||
pub fn guess_provider(address: &str) -> Option<(&'static str, u16)> {
|
|
||||||
let domain = address.trim().rsplit_once('@')?.1.to_ascii_lowercase();
|
|
||||||
let settings = match domain.as_str() {
|
|
||||||
"gmail.com" | "googlemail.com" => ("smtp.gmail.com", IMPLICIT_TLS_PORT),
|
|
||||||
"outlook.com" | "hotmail.com" | "live.com" | "msn.com" => {
|
|
||||||
("smtp-mail.outlook.com", DEFAULT_SUBMISSION_PORT)
|
|
||||||
}
|
|
||||||
"yahoo.com" | "ymail.com" => ("smtp.mail.yahoo.com", IMPLICIT_TLS_PORT),
|
|
||||||
"icloud.com" | "me.com" | "mac.com" => ("smtp.mail.me.com", DEFAULT_SUBMISSION_PORT),
|
|
||||||
"zoho.com" => ("smtp.zoho.com", IMPLICIT_TLS_PORT),
|
|
||||||
"protonmail.com" | "proton.me" => ("smtp.protonmail.ch", DEFAULT_SUBMISSION_PORT),
|
|
||||||
_ => return None,
|
|
||||||
};
|
|
||||||
Some(settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What the email screen should show.
|
|
||||||
///
|
|
||||||
/// The UI reads exactly this to decide between the inbox and the setup
|
|
||||||
/// form. Keeping it an enum rather than a bare `bool` leaves room for
|
|
||||||
/// `Verifying` without another refactor.
|
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
|
||||||
pub enum SessionState {
|
|
||||||
/// No account configured: show the setup form.
|
|
||||||
#[default]
|
|
||||||
SignedOut,
|
|
||||||
/// Credentials submitted, connection test in flight.
|
|
||||||
Verifying,
|
|
||||||
/// Account usable: show the inbox.
|
|
||||||
SignedIn(EmailAccount),
|
|
||||||
/// Verification failed. Carries the message to display, and keeps the
|
|
||||||
/// draft-derived account so the form can be repopulated rather than
|
|
||||||
/// making the user retype everything.
|
|
||||||
Failed {
|
|
||||||
account: EmailAccount,
|
|
||||||
reason: String,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SessionState {
|
|
||||||
/// Should the inbox be shown?
|
|
||||||
pub fn is_signed_in(&self) -> bool {
|
|
||||||
matches!(self, SessionState::SignedIn(_))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The account, if there is one to display -- including after a
|
|
||||||
/// failure, so the form can be prefilled.
|
|
||||||
pub fn account(&self) -> Option<&EmailAccount> {
|
|
||||||
match self {
|
|
||||||
SessionState::SignedIn(a) | SessionState::Failed { account: a, .. } => Some(a),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Text for the account row in the More page.
|
|
||||||
pub fn status_line(&self) -> String {
|
|
||||||
match self {
|
|
||||||
SessionState::SignedOut => "No account connected".into(),
|
|
||||||
SessionState::Verifying => "Checking connection…".into(),
|
|
||||||
SessionState::SignedIn(a) => format!("Connected as {}", a.address),
|
|
||||||
SessionState::Failed { account, reason } => {
|
|
||||||
format!("{} — not connected: {}", account.address, reason)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn good_draft() -> AccountDraft {
|
|
||||||
AccountDraft {
|
|
||||||
address: "jane@example.com".into(),
|
|
||||||
smtp_server: "smtp.example.com".into(),
|
|
||||||
smtp_port: "587".into(),
|
|
||||||
username: "jane@example.com".into(),
|
|
||||||
password: "hunter2".into(),
|
|
||||||
display_name: "Jane".into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_complete_draft_validates() {
|
|
||||||
let (account, secret) = good_draft().validate().expect("should validate");
|
|
||||||
assert_eq!(account.address, "jane@example.com");
|
|
||||||
assert_eq!(account.smtp_port, 587);
|
|
||||||
assert_eq!(secret, "hunter2");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The persistable half must never carry the secret. This is the
|
|
||||||
/// structural half of finding S2: if EmailAccount grows a password
|
|
||||||
/// field, this test is where it should be noticed.
|
|
||||||
#[test]
|
|
||||||
fn the_account_type_has_no_password_field() {
|
|
||||||
let (account, _) = good_draft().validate().unwrap();
|
|
||||||
let json = serde_json::to_string(&account).unwrap();
|
|
||||||
assert!(
|
|
||||||
!json.contains("hunter2"),
|
|
||||||
"serialised account leaked the password: {json}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!json.contains("password"),
|
|
||||||
"account has a password field: {json}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn every_error_is_reported_not_just_the_first() {
|
|
||||||
let draft = AccountDraft::default();
|
|
||||||
let errors = draft.validate().unwrap_err();
|
|
||||||
assert!(errors.contains(&AccountError::AddressMissing));
|
|
||||||
assert!(errors.contains(&AccountError::ServerMissing));
|
|
||||||
assert!(errors.contains(&AccountError::PasswordMissing));
|
|
||||||
assert!(
|
|
||||||
errors.len() >= 3,
|
|
||||||
"expected several errors in one pass, got {errors:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// B3: the old code did `parse().unwrap_or(587)`, so "465x" silently
|
|
||||||
/// became 587 and silently changed the transport. It must be an error.
|
|
||||||
#[test]
|
|
||||||
fn a_malformed_port_is_an_error_not_a_silent_default() {
|
|
||||||
for bad in ["465x", "abc", "-1", "99999", "0", "5 8 7"] {
|
|
||||||
let draft = AccountDraft {
|
|
||||||
smtp_port: bad.into(),
|
|
||||||
..good_draft()
|
|
||||||
};
|
|
||||||
let errors = draft.validate().unwrap_err();
|
|
||||||
assert!(
|
|
||||||
errors.contains(&AccountError::PortInvalid),
|
|
||||||
"port {bad:?} should be rejected, got {errors:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn an_empty_port_means_the_default() {
|
|
||||||
let draft = AccountDraft {
|
|
||||||
smtp_port: " ".into(),
|
|
||||||
..good_draft()
|
|
||||||
};
|
|
||||||
let (account, _) = draft.validate().unwrap();
|
|
||||||
assert_eq!(account.smtp_port, DEFAULT_SUBMISSION_PORT);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn username_falls_back_to_the_address() {
|
|
||||||
let draft = AccountDraft {
|
|
||||||
username: "".into(),
|
|
||||||
..good_draft()
|
|
||||||
};
|
|
||||||
let (account, _) = draft.validate().unwrap();
|
|
||||||
assert_eq!(account.username, "jane@example.com");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spaces can be significant in a password; trimming causes an auth
|
|
||||||
/// failure the user cannot diagnose.
|
|
||||||
#[test]
|
|
||||||
fn the_password_is_not_trimmed() {
|
|
||||||
let draft = AccountDraft {
|
|
||||||
password: " spaced ".into(),
|
|
||||||
..good_draft()
|
|
||||||
};
|
|
||||||
let (_, secret) = draft.validate().unwrap();
|
|
||||||
assert_eq!(secret, " spaced ");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn accepts_ordinary_addresses() {
|
|
||||||
for good in [
|
|
||||||
"a@b.co",
|
|
||||||
"jane.doe@example.com",
|
|
||||||
"jane+tag@example.co.ke",
|
|
||||||
"j_d-1@sub.example.org",
|
|
||||||
] {
|
|
||||||
assert!(looks_like_email(good), "should accept {good}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_addresses_that_are_always_typos() {
|
|
||||||
for bad in [
|
|
||||||
"",
|
|
||||||
" ",
|
|
||||||
"no-at-sign",
|
|
||||||
"@example.com",
|
|
||||||
"jane@",
|
|
||||||
"jane@nodot",
|
|
||||||
"two@at@signs.com",
|
|
||||||
"jane doe@example.com",
|
|
||||||
"jane@.example.com",
|
|
||||||
"jane@example.",
|
|
||||||
] {
|
|
||||||
assert!(!looks_like_email(bad), "should reject {bad:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pasting a URL into the server field is the most common setup
|
|
||||||
/// mistake and yields a DNS error that looks like an outage.
|
|
||||||
#[test]
|
|
||||||
fn a_url_in_the_server_field_is_rejected() {
|
|
||||||
for bad in [
|
|
||||||
"https://smtp.example.com",
|
|
||||||
"smtp://smtp.example.com",
|
|
||||||
"smtp.example.com/path",
|
|
||||||
] {
|
|
||||||
assert!(!looks_like_hostname(bad), "should reject {bad:?}");
|
|
||||||
let draft = AccountDraft {
|
|
||||||
smtp_server: bad.into(),
|
|
||||||
..good_draft()
|
|
||||||
};
|
|
||||||
let errors = draft.validate().unwrap_err();
|
|
||||||
assert!(errors.contains(&AccountError::ServerMalformed));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn guesses_settings_for_the_common_providers() {
|
|
||||||
assert_eq!(
|
|
||||||
guess_provider("jane@gmail.com"),
|
|
||||||
Some(("smtp.gmail.com", IMPLICIT_TLS_PORT))
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
guess_provider("JANE@GMail.COM"),
|
|
||||||
Some(("smtp.gmail.com", IMPLICIT_TLS_PORT)),
|
|
||||||
"domain match must be case-insensitive"
|
|
||||||
);
|
|
||||||
assert!(guess_provider("jane@outlook.com").is_some());
|
|
||||||
assert!(guess_provider("jane@yahoo.com").is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Guessing smtp.<domain> for unknown hosts is wrong often enough to
|
|
||||||
/// be worse than asking.
|
|
||||||
#[test]
|
|
||||||
fn does_not_guess_for_unknown_domains() {
|
|
||||||
assert_eq!(guess_provider("jane@my-company.co.ke"), None);
|
|
||||||
assert_eq!(guess_provider("not-an-address"), None);
|
|
||||||
assert_eq!(guess_provider(""), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn session_state_gates_the_inbox() {
|
|
||||||
let (account, _) = good_draft().validate().unwrap();
|
|
||||||
assert!(!SessionState::SignedOut.is_signed_in());
|
|
||||||
assert!(!SessionState::Verifying.is_signed_in());
|
|
||||||
assert!(SessionState::SignedIn(account.clone()).is_signed_in());
|
|
||||||
assert!(
|
|
||||||
!SessionState::Failed {
|
|
||||||
account: account.clone(),
|
|
||||||
reason: "bad password".into()
|
|
||||||
}
|
|
||||||
.is_signed_in(),
|
|
||||||
"a failed session must NOT show the inbox"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// After a failure the form must be repopulatable, or the user
|
|
||||||
/// retypes five fields to fix one.
|
|
||||||
#[test]
|
|
||||||
fn a_failed_session_still_exposes_the_account_for_the_form() {
|
|
||||||
let (account, _) = good_draft().validate().unwrap();
|
|
||||||
let failed = SessionState::Failed {
|
|
||||||
account: account.clone(),
|
|
||||||
reason: "auth failed".into(),
|
|
||||||
};
|
|
||||||
assert_eq!(failed.account(), Some(&account));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn status_lines_are_never_empty_and_name_the_account() {
|
|
||||||
let (account, _) = good_draft().validate().unwrap();
|
|
||||||
for state in [
|
|
||||||
SessionState::SignedOut,
|
|
||||||
SessionState::Verifying,
|
|
||||||
SessionState::SignedIn(account.clone()),
|
|
||||||
SessionState::Failed {
|
|
||||||
account,
|
|
||||||
reason: "nope".into(),
|
|
||||||
},
|
|
||||||
] {
|
|
||||||
assert!(!state.status_line().is_empty());
|
|
||||||
}
|
|
||||||
let (a, _) = good_draft().validate().unwrap();
|
|
||||||
assert!(SessionState::SignedIn(a)
|
|
||||||
.status_line()
|
|
||||||
.contains("jane@example.com"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,526 +0,0 @@
|
||||||
//! Email message model and sender-thread grouping.
|
|
||||||
//!
|
|
||||||
//! The SMS inbox groups messages by `address` into conversations, and
|
|
||||||
//! tapping one opens a timeline. Email needs the same shape: a list of
|
|
||||||
//! senders, and a thread per sender. This module is that grouping, as
|
|
||||||
//! pure functions over plain data.
|
|
||||||
//!
|
|
||||||
//! Deliberately transport-free. There is no IMAP client in this
|
|
||||||
//! repository yet (assessment finding A1), and the receive strategy --
|
|
||||||
//! IMAP on device vs a server-side proxy -- is still an open product
|
|
||||||
//! decision. Rather than block the UI on it, this defines the model the
|
|
||||||
//! UI binds to and provides an explicit `sample_thread` for development.
|
|
||||||
//! When a real fetch lands it populates these same types and the UI does
|
|
||||||
//! not change.
|
|
||||||
//!
|
|
||||||
//! Host-testable on purpose: grouping, sorting and preview truncation are
|
|
||||||
//! where the bugs live, and none of them need a network.
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// One message. `Serialize` is for a future local cache; note that unlike
|
|
||||||
/// `SmsConfig` there is no secret in here, so it is safe.
|
|
||||||
///
|
|
||||||
/// The body is NOT `#[serde(skip)]`-ed the way `OfflineSmsMessage.body`
|
|
||||||
/// is, because email bodies are the thing the user came to read and a
|
|
||||||
/// cache that drops them is useless. When persistence lands it must be
|
|
||||||
/// encrypted at rest instead -- tracked as Phase C3.
|
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct EmailMessage {
|
|
||||||
/// Provider-assigned id, or a synthesised one for local drafts.
|
|
||||||
pub id: String,
|
|
||||||
/// Envelope sender address, e.g. `alerts@bank.co.ke`.
|
|
||||||
pub from_address: String,
|
|
||||||
/// Human name if the provider supplied one, else empty.
|
|
||||||
pub from_name: String,
|
|
||||||
pub subject: String,
|
|
||||||
pub body: String,
|
|
||||||
/// Epoch millis. i64 so pre-1970 and provider garbage cannot panic.
|
|
||||||
pub date_ms: i64,
|
|
||||||
pub is_read: bool,
|
|
||||||
/// True when this message was sent by the account owner, which
|
|
||||||
/// decides which side of the timeline it renders on.
|
|
||||||
pub is_outgoing: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EmailMessage {
|
|
||||||
/// Best available display name for the sender.
|
|
||||||
pub fn sender_display(&self) -> &str {
|
|
||||||
if self.from_name.trim().is_empty() {
|
|
||||||
&self.from_address
|
|
||||||
} else {
|
|
||||||
&self.from_name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One row in the inbox list: a sender and the state of their thread.
|
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
|
||||||
pub struct EmailThreadSummary {
|
|
||||||
/// Grouping key -- the normalised sender address.
|
|
||||||
pub sender_address: String,
|
|
||||||
pub sender_display: String,
|
|
||||||
/// Subject of the most recent message, used as the row title.
|
|
||||||
pub latest_subject: String,
|
|
||||||
/// Body preview of the most recent message.
|
|
||||||
pub latest_preview: String,
|
|
||||||
pub latest_date_ms: i64,
|
|
||||||
pub unread_count: usize,
|
|
||||||
pub message_count: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Normalise an address for grouping.
|
|
||||||
///
|
|
||||||
/// Lowercased and trimmed, because `Alerts@Bank.co.ke` and
|
|
||||||
/// `alerts@bank.co.ke` are one sender and showing them as two rows is the
|
|
||||||
/// email equivalent of the SMS duplicate-recipient bug.
|
|
||||||
pub fn normalise_sender(address: &str) -> String {
|
|
||||||
address.trim().to_ascii_lowercase()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Truncate a body to a single-line preview.
|
|
||||||
///
|
|
||||||
/// **Char-safe.** `&s[..n]` panics when `n` is not a UTF-8 boundary, which
|
|
||||||
/// is bug A3 in the SMS crate: one inbound message containing emoji or
|
|
||||||
/// non-Latin text took down the whole list on every frame. Email bodies
|
|
||||||
/// are equally untrusted and equally likely to contain both, and there is
|
|
||||||
/// a CI gate in this repo forbidding byte-offset slicing in SMS text
|
|
||||||
/// helpers for exactly this reason. Uses `char_indices`.
|
|
||||||
pub fn preview_line(body: &str, max_chars: usize) -> String {
|
|
||||||
// Collapse all whitespace first: a body starting with ten newlines
|
|
||||||
// otherwise previews as an empty row.
|
|
||||||
let flat: String = body.split_whitespace().collect::<Vec<_>>().join(" ");
|
|
||||||
if max_chars == 0 {
|
|
||||||
return String::new();
|
|
||||||
}
|
|
||||||
let mut count = 0;
|
|
||||||
for (idx, _) in flat.char_indices() {
|
|
||||||
if count == max_chars {
|
|
||||||
// idx is a real char boundary from char_indices.
|
|
||||||
return format!("{}…", &flat[..idx]);
|
|
||||||
}
|
|
||||||
count += 1;
|
|
||||||
}
|
|
||||||
flat
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Default preview length, matching the SMS list's density.
|
|
||||||
pub const PREVIEW_CHARS: usize = 78;
|
|
||||||
|
|
||||||
/// Group messages into one summary per sender, newest thread first.
|
|
||||||
///
|
|
||||||
/// Outgoing messages are included in a thread's count and can supply its
|
|
||||||
/// preview -- a thread you replied to should show your reply as the latest
|
|
||||||
/// activity, the way SMS conversations do -- but they never count as
|
|
||||||
/// unread.
|
|
||||||
pub fn group_by_sender(messages: &[EmailMessage]) -> Vec<EmailThreadSummary> {
|
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
let mut by_sender: HashMap<String, EmailThreadSummary> = HashMap::new();
|
|
||||||
|
|
||||||
for msg in messages {
|
|
||||||
let key = normalise_sender(&msg.from_address);
|
|
||||||
if key.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let entry = by_sender
|
|
||||||
.entry(key.clone())
|
|
||||||
.or_insert_with(|| EmailThreadSummary {
|
|
||||||
sender_address: key.clone(),
|
|
||||||
sender_display: msg.sender_display().to_string(),
|
|
||||||
latest_date_ms: i64::MIN,
|
|
||||||
..Default::default()
|
|
||||||
});
|
|
||||||
|
|
||||||
entry.message_count += 1;
|
|
||||||
if !msg.is_read && !msg.is_outgoing {
|
|
||||||
entry.unread_count += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only the newest message supplies the row's title and preview.
|
|
||||||
if msg.date_ms >= entry.latest_date_ms {
|
|
||||||
entry.latest_date_ms = msg.date_ms;
|
|
||||||
entry.latest_subject = if msg.subject.trim().is_empty() {
|
|
||||||
"(no subject)".to_string()
|
|
||||||
} else {
|
|
||||||
msg.subject.clone()
|
|
||||||
};
|
|
||||||
entry.latest_preview = preview_line(&msg.body, PREVIEW_CHARS);
|
|
||||||
// Prefer a real name over a bare address if any message in
|
|
||||||
// the thread has one.
|
|
||||||
if !msg.from_name.trim().is_empty() {
|
|
||||||
entry.sender_display = msg.from_name.clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut out: Vec<EmailThreadSummary> = by_sender.into_values().collect();
|
|
||||||
// Newest first; tie-break on address so the order is deterministic
|
|
||||||
// rather than HashMap-iteration order, which would make the list
|
|
||||||
// reshuffle between frames.
|
|
||||||
out.sort_by(|a, b| {
|
|
||||||
b.latest_date_ms
|
|
||||||
.cmp(&a.latest_date_ms)
|
|
||||||
.then_with(|| a.sender_address.cmp(&b.sender_address))
|
|
||||||
});
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// All messages from one sender, oldest first (timeline order).
|
|
||||||
pub fn thread_for_sender(messages: &[EmailMessage], sender_address: &str) -> Vec<EmailMessage> {
|
|
||||||
let key = normalise_sender(sender_address);
|
|
||||||
let mut out: Vec<EmailMessage> = messages
|
|
||||||
.iter()
|
|
||||||
.filter(|m| normalise_sender(&m.from_address) == key)
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
out.sort_by(|a, b| a.date_ms.cmp(&b.date_ms).then_with(|| a.id.cmp(&b.id)));
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Total unread across all threads, for the tab badge.
|
|
||||||
pub fn total_unread(messages: &[EmailMessage]) -> usize {
|
|
||||||
messages
|
|
||||||
.iter()
|
|
||||||
.filter(|m| !m.is_read && !m.is_outgoing)
|
|
||||||
.count()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Case-insensitive substring filter over sender, subject and body.
|
|
||||||
pub fn filter_messages(messages: &[EmailMessage], needle: &str) -> Vec<EmailMessage> {
|
|
||||||
let n = needle.trim().to_ascii_lowercase();
|
|
||||||
if n.is_empty() {
|
|
||||||
return messages.to_vec();
|
|
||||||
}
|
|
||||||
messages
|
|
||||||
.iter()
|
|
||||||
.filter(|m| {
|
|
||||||
m.from_address.to_ascii_lowercase().contains(&n)
|
|
||||||
|| m.from_name.to_ascii_lowercase().contains(&n)
|
|
||||||
|| m.subject.to_ascii_lowercase().contains(&n)
|
|
||||||
|| m.body.to_ascii_lowercase().contains(&n)
|
|
||||||
})
|
|
||||||
.cloned()
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Development sample data.
|
|
||||||
///
|
|
||||||
/// There is no receive path yet, so without this the inbox can only ever
|
|
||||||
/// render an empty state and the list/thread transition cannot be
|
|
||||||
/// exercised at all. Explicitly named `sample_` so it is obvious in a
|
|
||||||
/// diff when the real fetch replaces it.
|
|
||||||
pub fn sample_thread() -> Vec<EmailMessage> {
|
|
||||||
let base = 1_767_225_600_000i64; // 2026-01-01T00:00:00Z
|
|
||||||
let hour = 3_600_000i64;
|
|
||||||
vec![
|
|
||||||
EmailMessage {
|
|
||||||
id: "s1".into(),
|
|
||||||
from_address: "alerts@bank.co.ke".into(),
|
|
||||||
from_name: "Equity Alerts".into(),
|
|
||||||
subject: "Statement ready".into(),
|
|
||||||
body: "Your January statement is now available in the portal.".into(),
|
|
||||||
date_ms: base,
|
|
||||||
is_read: false,
|
|
||||||
is_outgoing: false,
|
|
||||||
},
|
|
||||||
EmailMessage {
|
|
||||||
id: "s2".into(),
|
|
||||||
from_address: "alerts@bank.co.ke".into(),
|
|
||||||
from_name: "Equity Alerts".into(),
|
|
||||||
subject: "Statement ready".into(),
|
|
||||||
body: "Reminder: your January statement is still unread.".into(),
|
|
||||||
date_ms: base + hour,
|
|
||||||
is_read: false,
|
|
||||||
is_outgoing: false,
|
|
||||||
},
|
|
||||||
EmailMessage {
|
|
||||||
id: "s3".into(),
|
|
||||||
from_address: "jane@example.com".into(),
|
|
||||||
from_name: "Jane Wanjiku".into(),
|
|
||||||
subject: "Re: delivery schedule".into(),
|
|
||||||
body: "Wednesday works for the Nairobi drop-off. Confirming now.".into(),
|
|
||||||
date_ms: base + 4 * hour,
|
|
||||||
is_read: true,
|
|
||||||
is_outgoing: false,
|
|
||||||
},
|
|
||||||
EmailMessage {
|
|
||||||
id: "s4".into(),
|
|
||||||
from_address: "jane@example.com".into(),
|
|
||||||
from_name: "Jane Wanjiku".into(),
|
|
||||||
subject: "Re: delivery schedule".into(),
|
|
||||||
body: "Thanks Jane, Wednesday is confirmed on our side too.".into(),
|
|
||||||
date_ms: base + 5 * hour,
|
|
||||||
is_read: true,
|
|
||||||
is_outgoing: true,
|
|
||||||
},
|
|
||||||
EmailMessage {
|
|
||||||
id: "s5".into(),
|
|
||||||
from_address: "no-reply@marikiti.co.ke".into(),
|
|
||||||
from_name: String::new(),
|
|
||||||
subject: String::new(),
|
|
||||||
body: "Order #4471 dispatched. Track it in the app. 🚚".into(),
|
|
||||||
date_ms: base + 8 * hour,
|
|
||||||
is_read: false,
|
|
||||||
is_outgoing: false,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn groups_messages_into_one_row_per_sender() {
|
|
||||||
let threads = group_by_sender(&sample_thread());
|
|
||||||
assert_eq!(threads.len(), 3, "3 distinct senders in the sample");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn newest_thread_comes_first() {
|
|
||||||
let threads = group_by_sender(&sample_thread());
|
|
||||||
assert_eq!(threads[0].sender_address, "no-reply@marikiti.co.ke");
|
|
||||||
assert!(threads[0].latest_date_ms >= threads[1].latest_date_ms);
|
|
||||||
assert!(threads[1].latest_date_ms >= threads[2].latest_date_ms);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Address case must not split one sender into two rows.
|
|
||||||
#[test]
|
|
||||||
fn sender_grouping_is_case_insensitive() {
|
|
||||||
let msgs = vec![
|
|
||||||
EmailMessage {
|
|
||||||
id: "a".into(),
|
|
||||||
from_address: "Alerts@Bank.CO.KE".into(),
|
|
||||||
date_ms: 1,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
EmailMessage {
|
|
||||||
id: "b".into(),
|
|
||||||
from_address: "alerts@bank.co.ke".into(),
|
|
||||||
date_ms: 2,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
];
|
|
||||||
let threads = group_by_sender(&msgs);
|
|
||||||
assert_eq!(threads.len(), 1, "same sender, different case");
|
|
||||||
assert_eq!(threads[0].message_count, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unread_counts_exclude_our_own_sent_mail() {
|
|
||||||
let msgs = vec![
|
|
||||||
EmailMessage {
|
|
||||||
id: "in".into(),
|
|
||||||
from_address: "a@b.com".into(),
|
|
||||||
is_read: false,
|
|
||||||
is_outgoing: false,
|
|
||||||
date_ms: 1,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
EmailMessage {
|
|
||||||
id: "out".into(),
|
|
||||||
from_address: "a@b.com".into(),
|
|
||||||
is_read: false,
|
|
||||||
is_outgoing: true,
|
|
||||||
date_ms: 2,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
];
|
|
||||||
let threads = group_by_sender(&msgs);
|
|
||||||
assert_eq!(
|
|
||||||
threads[0].unread_count, 1,
|
|
||||||
"outgoing must not count as unread"
|
|
||||||
);
|
|
||||||
assert_eq!(threads[0].message_count, 2);
|
|
||||||
assert_eq!(total_unread(&msgs), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A reply should surface as the thread's latest activity.
|
|
||||||
#[test]
|
|
||||||
fn an_outgoing_reply_can_supply_the_preview() {
|
|
||||||
let threads = group_by_sender(&sample_thread());
|
|
||||||
let jane = threads
|
|
||||||
.iter()
|
|
||||||
.find(|t| t.sender_address == "jane@example.com")
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
jane.latest_preview.contains("confirmed on our side"),
|
|
||||||
"expected the reply as latest, got {:?}",
|
|
||||||
jane.latest_preview
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn an_empty_subject_reads_as_no_subject_not_blank() {
|
|
||||||
let threads = group_by_sender(&sample_thread());
|
|
||||||
let m = threads
|
|
||||||
.iter()
|
|
||||||
.find(|t| t.sender_address == "no-reply@marikiti.co.ke")
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(m.latest_subject, "(no subject)");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sender_display_falls_back_to_the_address() {
|
|
||||||
let m = EmailMessage {
|
|
||||||
from_address: "x@y.com".into(),
|
|
||||||
from_name: " ".into(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
assert_eq!(m.sender_display(), "x@y.com");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_blank_sender_is_dropped_rather_than_grouped_under_empty() {
|
|
||||||
let msgs = vec![EmailMessage {
|
|
||||||
from_address: " ".into(),
|
|
||||||
date_ms: 1,
|
|
||||||
..Default::default()
|
|
||||||
}];
|
|
||||||
assert!(group_by_sender(&msgs).is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn thread_is_oldest_first_for_timeline_order() {
|
|
||||||
let t = thread_for_sender(&sample_thread(), "alerts@bank.co.ke");
|
|
||||||
assert_eq!(t.len(), 2);
|
|
||||||
assert!(t[0].date_ms < t[1].date_ms);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn thread_lookup_is_case_insensitive() {
|
|
||||||
let t = thread_for_sender(&sample_thread(), "ALERTS@BANK.CO.KE");
|
|
||||||
assert_eq!(t.len(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn thread_for_an_unknown_sender_is_empty_not_a_panic() {
|
|
||||||
assert!(thread_for_sender(&sample_thread(), "nobody@nowhere.test").is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- preview_line: the A3 class of bug -------------------------------
|
|
||||||
|
|
||||||
/// The whole reason this is a function and not `&body[..78]`.
|
|
||||||
#[test]
|
|
||||||
fn preview_never_panics_on_multibyte_text() {
|
|
||||||
for body in [
|
|
||||||
"🚚🚚🚚🚚🚚🚚🚚🚚🚚🚚🚚🚚🚚🚚🚚🚚🚚🚚🚚🚚🚚🚚🚚🚚🚚🚚🚚🚚🚚🚚🚚🚚🚚🚚🚚🚚🚚🚚🚚🚚",
|
|
||||||
"Habari ya asubuhi, tunatumaini kwamba unaendelea vizuri sana leo hii rafiki",
|
|
||||||
"مرحبا بك في تطبيقنا الجديد نرجو أن تستمتع بالتجربة",
|
|
||||||
"こんにちは、これはとても長いメッセージのテストです。よろしくお願いします。",
|
|
||||||
"Hi 🚚 mixed ascii and emoji that lands mid-character at the boundary",
|
|
||||||
] {
|
|
||||||
let out = preview_line(body, PREVIEW_CHARS);
|
|
||||||
assert!(!out.is_empty(), "empty preview for {body:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Misaligned multibyte text is the case that actually triggered A3 --
|
|
||||||
/// uniform emoji happens to land on a boundary.
|
|
||||||
#[test]
|
|
||||||
fn preview_truncates_misaligned_multibyte_at_a_char_boundary() {
|
|
||||||
let body = format!("Hi {}", "🚚".repeat(200));
|
|
||||||
let out = preview_line(&body, 40);
|
|
||||||
assert!(out.ends_with('…'));
|
|
||||||
assert_eq!(out.chars().count(), 41, "40 chars plus the ellipsis");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn preview_collapses_whitespace_so_a_row_is_never_blank() {
|
|
||||||
let out = preview_line("\n\n\n \t hello world \n", 40);
|
|
||||||
assert_eq!(out, "hello world");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn short_bodies_are_returned_whole_without_an_ellipsis() {
|
|
||||||
let out = preview_line("short", PREVIEW_CHARS);
|
|
||||||
assert_eq!(out, "short");
|
|
||||||
assert!(!out.ends_with('…'));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn preview_of_zero_chars_is_empty_not_a_panic() {
|
|
||||||
assert_eq!(preview_line("anything", 0), "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn preview_at_exactly_the_limit_is_not_truncated() {
|
|
||||||
let body = "a".repeat(10);
|
|
||||||
assert_eq!(preview_line(&body, 10), body);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- filtering -------------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn filter_matches_sender_subject_and_body_case_insensitively() {
|
|
||||||
let all = sample_thread();
|
|
||||||
assert_eq!(filter_messages(&all, "EQUITY").len(), 2, "by sender name");
|
|
||||||
assert_eq!(filter_messages(&all, "delivery").len(), 2, "by subject");
|
|
||||||
assert_eq!(filter_messages(&all, "dispatched").len(), 1, "by body");
|
|
||||||
assert_eq!(filter_messages(&all, "bank.co.ke").len(), 2, "by address");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn an_empty_filter_returns_everything() {
|
|
||||||
let all = sample_thread();
|
|
||||||
assert_eq!(filter_messages(&all, " ").len(), all.len());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_filter_matching_nothing_returns_empty_not_everything() {
|
|
||||||
assert!(filter_messages(&sample_thread(), "zzzz-no-match").is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// HashMap iteration order must not leak into the UI, or rows
|
|
||||||
/// reshuffle between frames.
|
|
||||||
#[test]
|
|
||||||
fn grouping_is_deterministic_across_runs() {
|
|
||||||
let msgs = sample_thread();
|
|
||||||
let a = group_by_sender(&msgs);
|
|
||||||
for _ in 0..20 {
|
|
||||||
assert_eq!(group_by_sender(&msgs), a);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn ties_on_timestamp_are_broken_deterministically() {
|
|
||||||
let msgs = vec![
|
|
||||||
EmailMessage {
|
|
||||||
id: "1".into(),
|
|
||||||
from_address: "b@x.com".into(),
|
|
||||||
date_ms: 5,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
EmailMessage {
|
|
||||||
id: "2".into(),
|
|
||||||
from_address: "a@x.com".into(),
|
|
||||||
date_ms: 5,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
];
|
|
||||||
let t = group_by_sender(&msgs);
|
|
||||||
assert_eq!(t[0].sender_address, "a@x.com", "tie broken by address");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn extreme_timestamps_do_not_panic() {
|
|
||||||
let msgs = vec![
|
|
||||||
EmailMessage {
|
|
||||||
id: "min".into(),
|
|
||||||
from_address: "a@x.com".into(),
|
|
||||||
date_ms: i64::MIN,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
EmailMessage {
|
|
||||||
id: "max".into(),
|
|
||||||
from_address: "b@x.com".into(),
|
|
||||||
date_ms: i64::MAX,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
];
|
|
||||||
let t = group_by_sender(&msgs);
|
|
||||||
assert_eq!(t.len(), 2);
|
|
||||||
assert_eq!(t[0].sender_address, "b@x.com");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -18,8 +18,6 @@ pub mod platform;
|
||||||
pub mod syncing;
|
pub mod syncing;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub mod tile_service;
|
pub mod tile_service;
|
||||||
pub mod email_account;
|
|
||||||
pub mod email_store;
|
|
||||||
pub mod email_worker;
|
pub mod email_worker;
|
||||||
|
|
||||||
pub use dir::app_data_dir;
|
pub use dir::app_data_dir;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue