Skip to main content

max / goingson

Describe the email accounts section; the "host-bound" claim was too broad `quasi::settings`'s header lists Email among five sections left out because "a route handler is fn(&AppState, Params), and those sections are about the host rather than about the app". That is right about Sync, Sharing and About. It is wrong about Email, and reading the commands rather than the screen is what shows it: `create_email_account`, `update_email_account` and `delete_email_account` take `State<Arc<AppState>>` and nothing else. There is no `AppHandle` anywhere in `commands/email_account.rs`, the repository calls are synchronous, and `CredentialStore::store_password` is a process-level call. What is host-bound here is OAuth, not the account. 82 lines of `email-accounts.js` mention it, and that volume is what made the whole section read as unreachable. The general form is worth keeping: a section is not undescribable because its loudest feature is, and the test is what the data needs rather than what the busiest control does. GET /settings/email the accounts, as a settings section GET /settings/email/new the manual IMAP/SMTP form POST /settings/email create one GET /settings/email/{id}/edit the same form, filled in POST /settings/email/{id} save one POST /settings/email/{id}/delete delete one Three things worth knowing about the writes. The password is `FieldKind::Secret` and is never given a value, so the stored secret is not round-tripped through the form even where it could be; it goes to the keychain and the column stays empty, which is `NewEmailAccount`'s own instruction rather than this screen's invention. The advanced block is `?advanced=1` rather than a toggle, and a submission made with it shut keeps the stored servers instead of blanking four required columns. And the folder-name control-character check is carried over verbatim, because a folder name is interpolated into IMAP. Refused, and recorded: the OAuth handshake (browser, localhost callback poll, code exchange), Test Connection and Sync Now (network round-trips a synchronous handler cannot await), auto-detect (one field writing another's value, which is quasicoherent f35aafee), and the provider note's link, which is the one refusal here that loses something a user needs. Fifteen tests. Four of them were passing against a submission they were meant to refuse: `Params::with` appends and `get` answers with the first value, so overriding a default by chaining kept the default. The helper builds the map before it builds the params now.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 00:56 UTC
Signed with PGP, not checked
Commit: 012ba4a4b9ba58ffc80cc031413d9cd0739ee9e3
Parent: c649509
3 files changed, +878 insertions, -5 deletions
@@ -87,6 +87,8 @@
87 87 use crate::commands::{all_config, write_config};
88 88 use crate::state::AppState;
89 89
90 + pub(crate) mod email;
91 +
90 92 #[cfg(test)]
91 93 mod tests;
92 94
@@ -104,7 +106,7 @@
104 106 /// absent rather than disabled, for the reason the task overview left Edit out:
105 107 /// a control that is drawn and does nothing is worse than a control that is not
106 108 /// drawn, and the module header says which and why.
107 - const SECTIONS: [Section; 3] = [
109 + const SECTIONS: [Section; 4] = [
108 110 Section {
109 111 slug: "appearance",
110 112 title: "Appearance",
@@ -117,6 +119,13 @@
117 119 slug: "planning",
118 120 title: "Planning & Review",
119 121 },
122 + // Added 2026-08-21. The header above lists Email among the sections that
123 + // are "about the host rather than about the app"; that was measured wrong.
124 + // See `email`.
125 + Section {
126 + slug: "email",
127 + title: "Email",
128 + },
120 129 ];
121 130
122 131 /// What the app falls back to when a key has never been written.
@@ -400,7 +409,7 @@
400 409 }
401 410
402 411 /// The whole screen, showing one section.
403 - fn screen(state: &AppState, slug: &str) -> Result<Screen, RouteError> {
412 + pub(super) fn screen(state: &AppState, slug: &str) -> Result<Screen, RouteError> {
404 413 let section = section_of(slug)?;
405 414 let config = all_config(state).map_err(|error| RouteError::internal(error.to_string()))?;
406 415
@@ -427,6 +436,7 @@
427 436 pane = pane.extend(match section.slug {
428 437 "notifications" => notifications(&config),
429 438 "planning" => planning(&config),
439 + "email" => email::pane(state)?,
430 440 _ => appearance(state, &config),
431 441 });
432 442
@@ -527,9 +537,12 @@
527 537 /// The settings screen's routes.
528 538 #[must_use]
529 539 pub fn routes(router: Router<AppState>) -> Router<AppState> {
530 - router
540 + let router = router
531 541 .get("/settings", index)
532 - .get("/settings/{section}", section)
533 542 .post("/settings/config/{key}", set)
534 - .post("/settings/notifications", set_notifications)
543 + .post("/settings/notifications", set_notifications);
544 + // Above the section capture, so `email` is a literal rather than a section
545 + // name that happens to match.
546 + let router = email::routes(router);
547 + router.get("/settings/{section}", section)
535 548 }
@@ -1,0 +1,651 @@
1 + //! Email accounts, the settings section this port said was not describable.
2 + //!
3 + //! <!-- wiki: quasi-overview -->
4 + //!
5 + //! # The parent module's claim, corrected
6 + //!
7 + //! [`super`]'s header lists Email among five sections "left out rather than
8 + //! half-described", under one cause: "a route handler is `fn(&AppState,
9 + //! Params)`, and those sections are about the host rather than about the app."
10 + //!
11 + //! That is right about Sync, Sharing and About, and it is wrong about Email —
12 + //! measured 2026-08-21 by reading the commands rather than the screen.
13 + //! `create_email_account`, `update_email_account` and `delete_email_account`
14 + //! take `State<Arc<AppState>>` and nothing else. No `AppHandle` appears in
15 + //! `commands/email_account.rs` at all. The repository calls are synchronous,
16 + //! and `CredentialStore::store_password` is a process-level call rather than
17 + //! something asked of a Tauri handle.
18 + //!
19 + //! What is host-bound here is **OAuth**, not the account. 82 of
20 + //! `email-accounts.js`'s lines mention it, and that volume is what made the
21 + //! whole section read as unreachable. The distinction matters beyond this file:
22 + //! a section is not undescribable because its loudest feature is, and the test
23 + //! is what the *data* needs rather than what the busiest control does.
24 + //!
25 + //! # What is here
26 + //!
27 + //! - `GET /settings/email` — the accounts, as a settings section.
28 + //! - `GET /settings/email/new` — the manual IMAP/SMTP form.
29 + //! - `POST /settings/email` — create one.
30 + //! - `GET /settings/email/{id}/edit` — the same form, filled in.
31 + //! - `POST /settings/email/{id}` — save one.
32 + //! - `POST /settings/email/{id}/delete` — delete one.
33 + //!
34 + //! The advanced block is `?advanced=1` rather than a toggle button, which is
35 + //! decision 2 again and the same answer the project dashboard's completed
36 + //! milestones got: the JS opens it when the server fields already have values
37 + //! and closes it otherwise, so which state you are looking at depends on
38 + //! module-scope facts and has no address. Here the form remembers it because
39 + //! the address does.
40 + //!
41 + //! # What refused
42 + //!
43 + //! - **The OAuth handshake.** `startOAuth` opens the system browser, then
44 + //! `listenForOAuthCallback(port)` polls a localhost callback and
45 + //! `completeOAuth` exchanges the code. Three host interactions in sequence
46 + //! with a modal in between; a route handler is a function from a request to
47 + //! an answer. An OAuth account still lists and still deletes — it is adding
48 + //! and reconnecting one that has nowhere to go.
49 + //! - **Test Connection and Sync Now.** Both are network round-trips whose
50 + //! result is the screen's content (the folder list, a progress count). A
51 + //! handler is synchronous, so neither can be awaited inside one.
52 + //! - **Auto-detect.** Typing a domain fills the four server fields from
53 + //! `PROVIDER_SETTINGS`. That is one field's value writing another's, which is
54 + //! quasicoherent `f35aafee` — "nothing can say put this chosen value into
55 + //! that other field" — and the reason the form here asks for the servers
56 + //! plainly instead. The provider defaults are not lost: they are what the
57 + //! placeholders show.
58 + //! - **The provider note.** Each detected domain carries an app-password
59 + //! instruction with a link to where you generate one. `Field::hint` is plain
60 + //! text, and the instruction without the link is the useful half, so the hint
61 + //! carries the instruction and the link has nowhere to be. Worth knowing that
62 + //! this is the one refusal here that loses something a user actually needs.
63 +
64 + #![allow(clippy::needless_pass_by_value)]
65 +
66 + use goingson_core::{EmailAccount, EmailAccountId, EmailAuthType, NewEmailAccount};
67 + use quasi_router::screen::{Act, Choice, Field, Row};
68 + use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
69 +
70 + use crate::state::{AppState, DESKTOP_USER_ID};
71 +
72 + #[cfg(test)]
73 + mod tests;
74 +
75 + /// The intervals the JS offers, as its own `SYNC_INTERVAL_OPTIONS`.
76 + const SYNC_INTERVALS: [(&str, &str); 5] = [
77 + ("", "Manual only"),
78 + ("5", "Every 5 minutes"),
79 + ("15", "Every 15 minutes"),
80 + ("30", "Every 30 minutes"),
81 + ("60", "Hourly"),
82 + ];
83 +
84 + /// How an account authenticates, for the row that says so.
85 + fn auth_label(auth: &EmailAuthType) -> &'static str {
86 + match auth {
87 + EmailAuthType::Password => "Password",
88 + EmailAuthType::OAuth2Fastmail => "Fastmail (OAuth)",
89 + EmailAuthType::OAuth2Google => "Google (OAuth)",
90 + EmailAuthType::OAuth2Microsoft => "Microsoft (OAuth)",
91 + EmailAuthType::OAuth2Yahoo => "Yahoo (OAuth)",
92 + }
93 + }
94 +
95 + /// One account.
96 + ///
97 + /// Edit is offered on a password account and withheld on an OAuth one, because
98 + /// the form below is the IMAP/SMTP form and an OAuth account has no servers,
99 + /// username or password to edit. Delete is offered on both: removing an account
100 + /// is the same act either way.
101 + fn row_for(account: &EmailAccount) -> Row {
102 + let mut row = Row::new(&account.account_name)
103 + .secondary(&account.email_address)
104 + .meta(auth_label(&account.auth_type));
105 +
106 + if account.auth_type == EmailAuthType::Password {
107 + row = row.act(Act::new(
108 + "Edit",
109 + Action::get(format!("/settings/email/{}/edit", account.id)),
110 + ));
111 + }
112 +
113 + row.act(
114 + Act::new(
115 + "Delete",
116 + Action::post(format!("/settings/email/{}/delete", account.id)),
117 + )
118 + .tone(makeover_layout::Tone::Danger),
119 + )
120 + }
121 +
122 + /// The section's body: the accounts, and the way to add one.
123 + pub(super) fn pane(state: &AppState) -> Result<Vec<Node>, RouteError> {
124 + let accounts = state
125 + .email_accounts
126 + .list_by_user(DESKTOP_USER_ID)
127 + .map_err(|error| RouteError::internal(error.to_string()))?;
128 +
129 + let mut out = vec![Node::section("Email accounts")];
130 + if accounts.is_empty() {
131 + out.push(Node::empty("No accounts yet."));
132 + } else {
133 + out.push(Node::list(accounts.iter().map(row_for)));
134 + }
135 + out.push(Node::act(
136 + "Add an account".to_owned(),
137 + Action::get("/settings/email/new"),
138 + ));
139 + Ok(out)
140 + }
141 +
142 + /// The account form, for adding and for editing.
143 + ///
144 + /// `existing` is the account being edited, or `None` for a new one. The two
145 + /// differ in exactly one place — the password field, which is required when
146 + /// there is no stored secret and optional when leaving it empty means keep the
147 + /// current one. `buildAccountFormHtml` differs them the same way and says so in
148 + /// the label.
149 + fn fields(
150 + existing: Option<&EmailAccount>,
151 + advanced: bool,
152 + errors: &[(&str, String)],
153 + submitted: Option<&quasi_router::Params>,
154 + ) -> Vec<Field> {
155 + let error_for = |name: &str| {
156 + errors
157 + .iter()
158 + .find(|(field, _)| *field == name)
159 + .map(|(_, message)| message.clone())
160 + };
161 + let apply = |field: Field, name: &str| match error_for(name) {
162 + Some(message) => field.error(message),
163 + None => field,
164 + };
165 + // A refused submission beats the stored value, which beats the default.
166 + let value_of = |name: &str, stored: String| {
167 + submitted
168 + .and_then(|params| params.get(name).map(std::borrow::ToOwned::to_owned))
169 + .unwrap_or(stored)
170 + };
171 +
172 + let mut name = Field::new(
173 + makeover_layout::FieldKind::Text,
174 + "account_name",
175 + "Account Name",
176 + )
177 + .required()
178 + .value(value_of(
179 + "account_name",
180 + existing.map(|a| a.account_name.clone()).unwrap_or_default(),
181 + ));
182 + name.placeholder = Some("Personal, Work, etc.".to_owned());
183 +
184 + let mut address = Field::new(
185 + makeover_layout::FieldKind::Email,
186 + "email_address",
187 + "Email Address",
188 + )
189 + .required()
190 + .value(value_of(
191 + "email_address",
192 + existing
193 + .map(|a| a.email_address.clone())
194 + .unwrap_or_default(),
195 + ));
196 + address.placeholder = Some("you@example.com".to_owned());
197 +
198 + let mut username = Field::new(makeover_layout::FieldKind::Text, "username", "Username")
199 + .required()
200 + .value(value_of(
201 + "username",
202 + existing.map(|a| a.username.clone()).unwrap_or_default(),
203 + ));
204 + username.placeholder = Some("Usually your email address".to_owned());
205 +
206 + // Never `Text`. `FieldKind::Secret` is the kind whose contract is that the
207 + // value is not echoed or round-tripped, which is exactly what a password
208 + // typed into a form wants, and it is never given a `value` here: the stored
209 + // secret lives in the OS keychain and the form has no business carrying it
210 + // back out even when it could.
211 + let mut password = Field::new(makeover_layout::FieldKind::Secret, "password", {
212 + if existing.is_some() {
213 + "Password (leave empty to keep current)"
214 + } else {
215 + "Password"
216 + }
217 + });
218 + if existing.is_none() {
219 + password = password.required();
220 + }
221 + password.placeholder = Some(
222 + if existing.is_some() {
223 + "Enter new password or leave empty"
224 + } else {
225 + "your password"
226 + }
227 + .to_owned(),
228 + );
229 +
230 + let mut archive = Field::new(
231 + makeover_layout::FieldKind::Text,
232 + "archive_folder_name",
233 + "Archive Folder Name",
234 + )
235 + .hint("Gmail: [Gmail]/All Mail, Fastmail: Archive.")
236 + .value(value_of(
237 + "archive_folder_name",
238 + existing
239 + .and_then(|a| a.archive_folder_name.clone())
240 + .unwrap_or_else(|| "Archive".to_owned()),
241 + ));
242 + archive.placeholder = Some("Archive".to_owned());
243 +
244 + let mut signature = Field::new(
245 + makeover_layout::FieldKind::Textarea,
246 + "email_signature",
247 + "Email Signature",
248 + )
249 + .hint("Appended to outbound emails. Plain text only.")
250 + .value(value_of(
251 + "email_signature",
252 + existing
253 + .and_then(|a| a.email_signature.clone())
254 + .unwrap_or_default(),
255 + ));
256 + signature.placeholder = Some("-- \nYour Name".to_owned());
257 +
258 + let mut out = vec![
259 + apply(name, "account_name"),
260 + apply(address, "email_address"),
261 + apply(username, "username"),
262 + apply(password, "password"),
263 + apply(archive, "archive_folder_name"),
264 + apply(signature, "email_signature"),
265 + ];
266 +
267 + if !advanced {
268 + return out;
269 + }
270 +
271 + let mut imap = Field::new(
272 + makeover_layout::FieldKind::Text,
273 + "imap_server",
274 + "IMAP Server",
275 + )
276 + .required()
277 + .value(value_of(
278 + "imap_server",
279 + existing.map(|a| a.imap_server.clone()).unwrap_or_default(),
280 + ));
281 + imap.placeholder = Some("imap.example.com".to_owned());
282 +
283 + let mut smtp = Field::new(
284 + makeover_layout::FieldKind::Text,
285 + "smtp_server",
286 + "SMTP Server",
287 + )
288 + .required()
289 + .value(value_of(
290 + "smtp_server",
291 + existing.map(|a| a.smtp_server.clone()).unwrap_or_default(),
292 + ));
293 + smtp.placeholder = Some("smtp.example.com".to_owned());
294 +
295 + out.extend([
296 + apply(imap, "imap_server"),
297 + apply(
298 + Field::new(makeover_layout::FieldKind::Number, "imap_port", "IMAP Port")
299 + .required()
300 + .value(value_of(
301 + "imap_port",
302 + existing.map_or_else(|| "993".to_owned(), |a| a.imap_port.to_string()),
303 + )),
304 + "imap_port",
305 + ),
306 + apply(smtp, "smtp_server"),
307 + apply(
308 + Field::new(makeover_layout::FieldKind::Number, "smtp_port", "SMTP Port")
309 + .required()
310 + .value(value_of(
311 + "smtp_port",
312 + existing.map_or_else(|| "587".to_owned(), |a| a.smtp_port.to_string()),
313 + )),
314 + "smtp_port",
315 + ),
316 + apply(
317 + Field::new(
318 + makeover_layout::FieldKind::Checkbox,
319 + "notify_new_emails",
320 + "Notify on new emails",
321 + )
322 + .hint("A system notification when new mail arrives during auto-sync. Off by default.")
323 + .value(match existing {
324 + Some(account) if account.notify_new_emails => "1",
325 + _ => "",
326 + }),
327 + "notify_new_emails",
328 + ),
329 + apply(
330 + Field::select(
331 + "sync_interval_minutes",
332 + "Auto-sync Interval",
333 + SYNC_INTERVALS
334 + .iter()
335 + .map(|(value, label)| Choice::new(*value, *label))
336 + .collect(),
337 + )
338 + .hint("Check for new email at this interval.")
339 + .value(value_of(
340 + "sync_interval_minutes",
341 + existing
342 + .and_then(|a| a.sync_interval_minutes)
343 + .map_or_else(|| "15".to_owned(), |minutes| minutes.to_string()),
344 + )),
345 + "sync_interval_minutes",
346 + ),
347 + ]);
348 +
349 + out
350 + }
351 +
352 + /// The form as a screen of its own, on the shape every other form here takes.
353 + fn form(
354 + existing: Option<&EmailAccount>,
355 + advanced: bool,
356 + errors: &[(&str, String)],
357 + submitted: Option<&quasi_router::Params>,
358 + ) -> Screen {
359 + let (title, action) = match existing {
360 + Some(account) => (
361 + format!("Edit {}", account.account_name),
362 + Action::post(format!("/settings/email/{}", account.id)),
363 + ),
364 + None => (
365 + "Add an email account".to_owned(),
366 + Action::post("/settings/email"),
367 + ),
368 + };
369 +
370 + // The disclosure is an address, so a form reopened after a refusal is open
371 + // to the same depth it was.
372 + let toggle = {
373 + let here = match existing {
374 + Some(account) => format!("/settings/email/{}/edit", account.id),
375 + None => "/settings/email/new".to_owned(),
376 + };
377 + let action = Action::get(here);
378 + if advanced {
379 + Node::act("Hide advanced settings".to_owned(), action)
380 + } else {
381 + Node::act(
382 + "Advanced settings".to_owned(),
383 + action.carrying("advanced", "1"),
384 + )
385 + }
386 + };
387 +
388 + let band = Slot::new("email-band", RegionKind::Band)
389 + .with(Node::page(title))
390 + .with(Node::act("Cancel", Action::get("/settings/email")));
391 +
392 + let pane = Slot::new("email-form", RegionKind::Pane)
393 + .with(Node::Form {
394 + action,
395 + submit: if existing.is_some() {
396 + "Save account".to_owned()
397 + } else {
398 + "Add account".to_owned()
399 + },
400 + fields: fields(existing, advanced, errors, submitted),
401 + })
402 + .with(toggle);
403 +
404 + Screen::list_detail("Email account", false)
405 + .with(band)
406 + .with(pane)
407 + }
408 +
409 + /// Whether the advanced block is open, from whichever half carries it.
410 + fn advanced_on(request: &quasi_router::Request) -> bool {
411 + let set = |params: &quasi_router::Params| params.get("advanced").is_some_and(|v| v == "1");
412 + set(&request.carried) || set(&request.payload)
413 + }
414 +
415 + /// The account a route was addressed at.
416 + fn account_id(request: &quasi_router::Request) -> Result<EmailAccountId, RouteError> {
417 + let raw = request
418 + .captures
419 + .get("id")
420 + .ok_or_else(|| RouteError::not_found("no account id"))?;
421 + Ok(EmailAccountId::from(
422 + uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not an account id"))?,
423 + ))
424 + }
425 +
426 + fn load(state: &AppState, id: EmailAccountId) -> Result<EmailAccount, RouteError> {
427 + state
428 + .email_accounts
429 + .get_by_id(id, DESKTOP_USER_ID)
430 + .map_err(|error| RouteError::internal(error.to_string()))?
431 + .ok_or_else(|| RouteError::not_found("no such account"))
432 + }
433 +
434 + /// What `create_email_account` refuses, refused here.
435 + ///
436 + /// The same four checks in the same order, so a described submission and a
437 + /// commanded one are refused for the same reasons. The control-character check
438 + /// on the folder name is not decoration: it is what stops a folder name from
439 + /// carrying a second IMAP command.
440 + fn validate(
441 + name: &str,
442 + address: &str,
443 + imap: &str,
444 + smtp: &str,
445 + archive: &str,
446 + ) -> Vec<(&'static str, String)> {
447 + let mut errors = Vec::new();
448 + if name.is_empty() {
449 + errors.push(("account_name", "An account needs a name.".to_owned()));
450 + }
451 + if !address.contains('@') || address.starts_with('@') || address.ends_with('@') {
452 + errors.push(("email_address", "That is not an email address.".to_owned()));
453 + }
454 + if imap.is_empty() {
455 + errors.push(("imap_server", "An IMAP server is required.".to_owned()));
456 + }
457 + if smtp.is_empty() {
458 + errors.push(("smtp_server", "An SMTP server is required.".to_owned()));
459 + }
460 + if archive.contains(['\r', '\n']) || archive.chars().any(char::is_control) {
461 + errors.push((
462 + "archive_folder_name",
463 + "A folder name cannot carry control characters.".to_owned(),
464 + ));
465 + }
466 + errors
467 + }
468 +
469 + /// A port, defaulted rather than refused when the field is blank.
470 + fn port(raw: &str, fallback: i32) -> i32 {
471 + raw.parse().unwrap_or(fallback)
472 + }
473 +
474 + /// The add form.
475 + fn new_account(_state: &AppState, _request: quasi_router::Request) -> Result<Response, RouteError> {
476 + // Advanced opens by default on a new account, because the server fields are
477 + // required and a form whose required fields are hidden cannot be submitted.
478 + // The JS reaches the same state by a different route: it opens the block
479 + // once autodetect has filled those fields in.
480 + Ok(form(None, true, &[], None).into())
481 + }
482 +
483 + /// Create it, or answer with the form saying why not.
484 + fn create(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
485 + let field = |name: &str| {
486 + request
487 + .payload
488 + .get(name)
489 + .unwrap_or_default()
490 + .trim()
491 + .to_owned()
492 + };
493 + let name = field("account_name");
494 + let address = field("email_address");
495 + let imap = field("imap_server");
496 + let smtp = field("smtp_server");
497 + let archive = field("archive_folder_name");
498 + let password = field("password");
499 +
500 + let mut errors = validate(&name, &address, &imap, &smtp, &archive);
Lines truncated
@@ -1,0 +1,360 @@
1 + //! The email accounts section, driven through the router against a real database.
2 +
3 + use std::sync::Arc;
4 +
5 + use goingson_core::{EmailAccount, EmailAccountId, NewEmailAccount};
6 + use quasi_http::Serves as _;
7 + use quasi_router::Outcome;
8 + use quasi_router::{Params, Request, Response};
9 +
10 + use crate::quasi::router;
11 + use crate::state::{AppState, DESKTOP_USER_ID};
12 +
13 + async fn state() -> Arc<AppState> {
14 + let (state, _) = crate::test_utils::setup_test_state().await;
15 + let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
16 + state
17 + .db
18 + .conn()
19 + .unwrap()
20 + .execute(
21 + "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) \
22 + VALUES (?, ?, ?, ?, ?)",
23 + rusqlite::params![
24 + DESKTOP_USER_ID.to_string(),
25 + "desktop@localhost",
26 + "x",
27 + "Desktop User",
28 + &now,
29 + ],
30 + )
31 + .unwrap();
32 + state
33 + }
34 +
35 + fn account(state: &AppState, name: &str) -> EmailAccount {
36 + state
37 + .email_accounts
38 + .create(
39 + DESKTOP_USER_ID,
40 + NewEmailAccount {
41 + account_name: name,
42 + email_address: "someone@example.com",
43 + imap_server: "imap.example.com",
44 + imap_port: 993,
45 + smtp_server: "smtp.example.com",
46 + smtp_port: 587,
47 + username: "someone@example.com",
48 + password: "",
49 + use_tls: true,
50 + archive_folder_name: Some("Archive"),
51 + },
52 + )
53 + .unwrap()
54 + }
55 +
56 + fn html(response: Response) -> String {
57 + match response.outcome {
58 + Outcome::Screen(screen) => quasi_webview::Webview::new().screen(&screen),
59 + Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(&node),
60 + Outcome::Goto(action) => panic!("expected content, got a redirect to {action:?}"),
61 + Outcome::Over(_) => panic!("expected content, got a screen drawn over it"),
62 + Outcome::Suggestions { field, .. } => {
63 + panic!("expected content, got a suggestion list for `{field}`")
64 + }
65 + }
66 + }
67 +
68 + fn get(state: &AppState, path: &str, params: Params) -> Response {
69 + router()
70 + .handle(state, Request::get(path).carrying(params))
71 + .expect("the route answers")
72 + }
73 +
74 + fn post(state: &AppState, path: &str, params: Params) -> Response {
75 + router()
76 + .handle(state, Request::post(path).sending(params))
77 + .expect("the route answers")
78 + }
79 +
80 + fn section(state: &AppState) -> String {
81 + html(get(state, "/settings/email", Params::new()))
82 + }
83 +
84 + /// A complete, valid submission. Tests override the one field they are about.
85 + ///
86 + /// The override replaces the default rather than being appended after it.
87 + /// `Params::with` appends, and `get` answers with the first value, so building
88 + /// this by chaining defaults and then overrides would silently keep every
89 + /// default -- which is exactly what it did until 2026-08-21 and made four
90 + /// refusal tests pass a submission they were meant to refuse.
91 + fn submission(overrides: &[(&str, &str)]) -> Params {
92 + const DEFAULTS: [(&str, &str); 9] = [
93 + ("account_name", "Personal"),
94 + ("email_address", "someone@example.com"),
95 + ("username", "someone@example.com"),
96 + ("password", "hunter2"),
97 + ("archive_folder_name", "Archive"),
98 + ("imap_server", "imap.example.com"),
99 + ("imap_port", "993"),
100 + ("smtp_server", "smtp.example.com"),
101 + ("smtp_port", "587"),
102 + ];
103 +
104 + let mut params = Params::new();
105 + for (name, default) in DEFAULTS {
106 + let value = overrides
107 + .iter()
108 + .find(|(over, _)| *over == name)
109 + .map_or(default, |(_, value)| *value);
110 + params = params.with(name, value);
111 + }
112 + // Anything the defaults do not name, such as `advanced`.
113 + for (name, value) in overrides {
114 + if !DEFAULTS.iter().any(|(known, _)| known == name) {
115 + params = params.with(*name, *value);
116 + }
117 + }
118 + params
119 + }
120 +
121 + #[tokio::test]
122 + async fn email_is_a_section_of_settings() {
123 + // The claim being corrected: settings.rs's header lists Email among the
124 + // sections that are "about the host rather than about the app".
125 + let state = state().await;
126 + let page = html(get(&state, "/settings", Params::new()));
127 + assert!(page.contains("Email"), "{page}");
128 + assert!(page.contains("/settings/email"), "{page}");
129 + }
130 +
131 + #[tokio::test]
132 + async fn an_empty_section_says_so_and_offers_the_form() {
133 + let state = state().await;
134 + let page = section(&state);
135 + assert!(page.contains("No accounts yet."), "{page}");
136 + assert!(page.contains("/settings/email/new"), "{page}");
137 + }
138 +
139 + #[tokio::test]
140 + async fn email_is_a_literal_rather_than_a_section_named_email() {
141 + // Mounted above `/settings/{section}`. Read the other way it would be a
142 + // section slug, and `section_of` has no such entry, so this would 404.
143 + let state = state().await;
144 + let form = html(get(&state, "/settings/email/new", Params::new()));
145 + assert!(form.contains("Add account"), "{form}");
146 + }
147 +
148 + #[tokio::test]
149 + async fn the_form_asks_what_the_modal_asks() {
150 + let state = state().await;
151 + let form = html(get(&state, "/settings/email/new", Params::new()));
152 + for name in [
153 + "account_name",
154 + "email_address",
155 + "username",
156 + "password",
157 + "archive_folder_name",
158 + "email_signature",
159 + "imap_server",
160 + "imap_port",
161 + "smtp_server",
162 + "smtp_port",
163 + ] {
164 + assert!(form.contains(&format!("name=\"{name}\"")), "{name}: {form}");
165 + }
166 + }
167 +
168 + #[tokio::test]
169 + async fn the_password_is_a_secret_rather_than_text() {
170 + // `FieldKind::Secret` is the kind whose contract is that the value is never
171 + // echoed or round-tripped. A password in a `Text` field would render as one
172 + // anybody looking at the screen can read.
173 + let state = state().await;
174 + let form = html(get(&state, "/settings/email/new", Params::new()));
175 + assert!(form.contains("type=\"password\""), "{form}");
176 + }
177 +
178 + #[tokio::test]
179 + async fn creating_an_account_puts_it_in_the_section() {
180 + let state = state().await;
181 +
182 + let page = html(post(&state, "/settings/email", submission(&[])));
183 + assert!(page.contains("Personal"), "{page}");
184 +
185 + let stored = state.email_accounts.list_by_user(DESKTOP_USER_ID).unwrap();
186 + assert_eq!(stored.len(), 1);
187 + assert_eq!(stored[0].imap_server, "imap.example.com");
188 + assert_eq!(stored[0].smtp_port, 587);
189 + }
190 +
191 + #[tokio::test]
192 + async fn the_password_never_reaches_the_row() {
193 + // `NewEmailAccount`'s own instruction: the column is written empty and the
194 + // secret goes to the OS keychain. A described form must not be the place
195 + // that quietly changes where a password lives.
196 + let state = state().await;
197 + post(&state, "/settings/email", submission(&[]));
198 +
199 + let stored = state.email_accounts.list_by_user(DESKTOP_USER_ID).unwrap();
200 + assert_eq!(stored[0].password, "");
201 + }
202 +
203 + #[tokio::test]
204 + async fn a_nameless_account_is_refused_and_the_typing_survives() {
205 + let state = state().await;
206 + let page = html(post(
207 + &state,
208 + "/settings/email",
209 + submission(&[("account_name", ""), ("username", "typed and nearly lost")]),
210 + ));
211 + assert!(page.contains("An account needs a name."), "{page}");
212 + assert!(page.contains("typed and nearly lost"), "{page}");
213 + assert!(
214 + state
215 + .email_accounts
216 + .list_by_user(DESKTOP_USER_ID)
217 + .unwrap()
218 + .is_empty()
219 + );
220 + }
221 +
222 + #[tokio::test]
223 + async fn a_new_account_without_a_password_is_refused() {
224 + let state = state().await;
225 + let page = html(post(
226 + &state,
227 + "/settings/email",
228 + submission(&[("password", "")]),
229 + ));
230 + assert!(page.contains("A new account needs a password."), "{page}");
231 + }
232 +
233 + #[tokio::test]
234 + async fn a_folder_name_cannot_carry_a_second_imap_command() {
235 + // The control-character check is what `create_email_account` refuses on,
236 + // and it is not decoration: a folder name is interpolated into IMAP.
237 + let state = state().await;
238 + let page = html(post(
239 + &state,
240 + "/settings/email",
241 + submission(&[("archive_folder_name", "Archive\r\nLOGOUT")]),
242 + ));
243 + assert!(page.contains("control characters"), "{page}");
244 + assert!(
245 + state
246 + .email_accounts
247 + .list_by_user(DESKTOP_USER_ID)
248 + .unwrap()
249 + .is_empty()
250 + );
251 + }
252 +
253 + #[tokio::test]
254 + async fn editing_an_account_saves_what_changed() {
255 + let state = state().await;
256 + let existing = account(&state, "Old name");
257 +
258 + post(
259 + &state,
260 + &format!("/settings/email/{}", existing.id),
261 + submission(&[
262 + ("account_name", "New name"),
263 + ("advanced", "1"),
264 + ("imap_server", "imap.elsewhere.com"),
265 + ]),
266 + );
267 +
268 + let stored = state
269 + .email_accounts
270 + .get_by_id(existing.id, DESKTOP_USER_ID)
271 + .unwrap()
272 + .expect("still there");
273 + assert_eq!(stored.account_name, "New name");
274 + assert_eq!(stored.imap_server, "imap.elsewhere.com");
275 + }
276 +
277 + #[tokio::test]
278 + async fn a_closed_advanced_block_does_not_blank_the_servers() {
279 + // The submission carries no server fields when the block is shut. Reading
280 + // them as empty would fail validation on four required columns the user
281 + // never touched -- or worse, store the blanks.
282 + let state = state().await;
283 + let existing = account(&state, "Personal");
284 +
285 + let mut params = Params::new()
286 + .with("account_name", "Renamed")
287 + .with("email_address", "someone@example.com")
288 + .with("username", "someone@example.com")
289 + .with("archive_folder_name", "Archive");
290 + params = params.with("password", "");
291 +
292 + post(&state, &format!("/settings/email/{}", existing.id), params);
293 +
294 + let stored = state
295 + .email_accounts
296 + .get_by_id(existing.id, DESKTOP_USER_ID)
297 + .unwrap()
298 + .expect("still there");
299 + assert_eq!(stored.account_name, "Renamed");
300 + assert_eq!(stored.imap_server, "imap.example.com");
301 + assert_eq!(stored.imap_port, 993);
302 + assert_eq!(stored.smtp_server, "smtp.example.com");
303 + }
304 +
305 + #[tokio::test]
306 + async fn the_advanced_disclosure_is_an_address() {
307 + // `?advanced=1`, not a toggle button holding module state. Same answer the
308 + // project dashboard's completed milestones got.
309 + let state = state().await;
310 + let existing = account(&state, "Personal");
311 +
312 + let shut = html(get(
313 + &state,
314 + &format!("/settings/email/{}/edit", existing.id),
315 + Params::new(),
316 + ));
317 + assert!(!shut.contains("name=\"imap_server\""), "{shut}");
318 +
319 + let open = html(get(
320 + &state,
321 + &format!("/settings/email/{}/edit", existing.id),
322 + Params::new().with("advanced", "1"),
323 + ));
324 + assert!(open.contains("name=\"imap_server\""), "{open}");
325 + }
326 +
327 + #[tokio::test]
328 + async fn deleting_an_account_takes_it_out_of_the_section() {
329 + let state = state().await;
330 + let going = account(&state, "Going");
331 +
332 + let page = html(post(
333 + &state,
334 + &format!("/settings/email/{}/delete", going.id),
335 + Params::new(),
336 + ));
337 + assert!(!page.contains("Going"), "{page}");
338 + assert!(
339 + state
340 + .email_accounts
341 + .list_by_user(DESKTOP_USER_ID)
342 + .unwrap()
343 + .is_empty()
344 + );
345 + }
346 +
347 + #[tokio::test]
348 + async fn an_account_that_is_not_there_is_a_not_found() {
349 + let state = state().await;
350 + let error = router()
351 + .handle(
352 + &state,
353 + Request::get(format!(
354 + "/settings/email/{}/edit",
355 + EmailAccountId::from(uuid::Uuid::nil())
356 + )),
357 + )
358 + .expect_err("no such account");
359 + assert_eq!(error.class.http_status(), 404);
360 + }