Skip to main content

max / goingson

The mail list and the thread are described Seventh screen ported, chosen by weight: emails-reader.js is the heaviest un-described file left, and the mail stack is the largest un-described subsystem in the app. Compose, attachments, accounts and sync stay out for the reason the settings port names. Six findings, recorded where each one bites. Two are the description layer's and are filed against quasicoherent: nothing names a selection or an action over one, and a write whose parameters share a namespace with the view's can silently rewrite the view it answers with. Two are goingson's: the body column holds pter markdown or plain text with no flag saying which, and mark_all_emails_read has no control anywhere in the markup.
Author: Max Johnson <me@maxj.phd> · 2026-08-10 16:21 UTC
Signed with PGP, not checked
Commit: 50bfde3c93a6ebda829c05f79e1be8bb46ff767a
Parent: 08c4c71
3 files changed, +1003 insertions, -1 deletion
@@ -35,6 +35,7 @@
35 35 use crate::state::AppState;
36 36
37 37 pub mod contacts;
38 + pub mod emails;
38 39 pub mod projects;
39 40 pub mod settings;
40 41 pub mod tasks;
@@ -48,7 +49,8 @@
48 49 let router = contacts::routes(router);
49 50 let router = tasks::routes(router);
50 51 let router = settings::routes(router);
51 - weekly_review::routes(router)
52 + let router = weekly_review::routes(router);
53 + emails::routes(router)
52 54 }
53 55
54 56 /// The custom protocol serving the screens inside the app.
@@ -1,0 +1,1137 @@
1 + //! The mail list and the thread, described rather than built.
2 + //!
3 + //! <!-- wiki: quasi-overview -->
4 + //!
5 + //! Seventh screen ported, chosen by weight. `emails-reader.js` is the heaviest
6 + //! un-described file left at 13 `esc()` calls, and the mail stack is the largest
7 + //! un-described subsystem in the app: seven files and roughly 2,100 lines
8 + //! against the weekly review's two and 630. The two files above it in the count
9 + //! — `settings-sync.js` at 17 and `settings-sharing.js` at 14 — are the ones the
10 + //! settings port already ruled out as host-bound, so they are not candidates and
11 + //! this is the top of the list that is left.
12 + //!
13 + //! The shipped screen is `frontend/js/emails.js` and its five delegate modules
14 + //! exactly as before; see [the module above](super) for why both exist at once.
15 + //!
16 + //! This is the first port of a screen that reads something the app did not
17 + //! write. A task is ours down to its column types; an email arrived from
18 + //! somewhere else, carrying a body in a format nobody here chose, and two of
19 + //! the six findings below come from that one fact.
20 + //!
21 + //! # The shape
22 + //!
23 + //! - `GET /emails` — the list, under `?folder=`, `?label=`, `?archived=`,
24 + //! `?shown=`.
25 + //! - `GET /emails/list` — the list alone, which is what a filter swaps.
26 + //! - `GET /emails/{id}` — the thread, read.
27 + //! - `POST /emails/{id}/read` — read or unread, under `read`.
28 + //! - `POST /emails/{id}/archive` — archive or unarchive, under `on`.
29 + //! - `POST /emails/{id}/delete` — delete it.
30 + //! - `POST /emails/{id}/labels` — set the labels, under `labels`.
31 + //! - `POST /emails/{id}/folder` — move it, under `to`.
32 + //! - `POST /emails/{id}/snooze` — snooze until `until`, or clear it.
33 + //! - `POST /emails/{id}/task` — make a task of it.
34 + //! - `POST /emails/{id}/event` — make an event of it.
35 + //!
36 + //! Every described control reaches one of those, which is the standard the
37 + //! contacts port set.
38 + //!
39 + //! # What is left out, and why
40 + //!
41 + //! One cause, the settings port's: **a route handler is `fn(&AppState, Params)`,
42 + //! and these are about the host or the network rather than about the app.**
43 + //!
44 + //! - **Compose, reply, forward, drafts.** Attachments come from a native file
45 + //! picker and sending is SMTP from an async command. The prefill halves
46 + //! (`build_reply_prefill`, `build_forward_prefill`) are pure and would
47 + //! describe fine; a compose form that can be filled and not sent is worse than
48 + //! no compose form, so the whole of it waits.
49 + //! - **Open in browser, open and save an attachment.** A temp file, a native
50 + //! dialog, and the shell.
51 + //! - **Accounts, OAuth, sync.** `email-accounts.js`, and the same wall Sync and
52 + //! Sharing hit on the settings screen.
53 + //! - **Search.** `search.js` is its own screen and its own port. The box on this
54 + //! screen calls into it.
55 + //!
56 + //! One omission is not ours: `mark_all_emails_read` is a command with a
57 + //! `GoingsOn.emails.markAllRead` binding and **no control anywhere in the
58 + //! markup**. Nothing described here reaches it because nothing shipped reaches
59 + //! it either. Recorded rather than quietly given a button, which would make the
60 + //! described screen offer something the screen it stands in for does not.
61 +
62 + // Handlers take their params by value because `quasi_router::Handler` is a
63 + // plain `fn(&S, Params)` pointer, so the signature is the router's and not a
64 + // choice made here. Same allow, for the same reason, as quasi-axum's tests.
65 + #![allow(clippy::needless_pass_by_value)]
66 +
67 + use chrono::{DateTime, Utc};
68 + use goingson_core::{
69 + Email, EmailId, EmailSource, EmailThread, Validate as _, date_utils, email_compose,
70 + event_from_email, task_from_email,
71 + };
72 + use quasi_router::screen::{Act, Choice, Field, Figure, Rest, Row, Tag};
73 + use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
74 +
75 + use crate::commands::get_snooze_options;
76 + use crate::state::{AppState, DESKTOP_USER_ID};
77 +
78 + #[cfg(test)]
79 + mod tests;
80 +
81 + /// How many threads a page of the list holds.
82 + ///
83 + /// `emails.js:EMAIL_PAGE_SIZE`. The number is the JS's; what it means here is
84 + /// not, and [`View::shown`] is where that difference lives.
85 + const PAGE: i64 = 200;
86 +
87 + /// The region the list is drawn in.
88 + const LIST: &str = "emails-list";
89 +
90 + /// The region one thread is drawn in.
91 + const THREAD: &str = "emails-thread";
92 +
93 + /// The list as it was being looked at.
94 + ///
95 + /// `emails.js` holds this across four places — `emailPaging.baseFilters`,
96 + /// `emailsFilter`'s two module-scope strings, and the scroller's own idea of how
97 + /// far it has streamed — and re-renders from them. Here it is the address, per
98 + /// decision 2, which is the same move the projects filters and the weekly
99 + /// review's week made and has the same consequence: every action the screen
100 + /// offers has to carry the view it was offered under, or acting drops the user
101 + /// back into an unfiltered inbox and writes there. [`View::carry`] is that,
102 + /// applied to every control on the screen.
103 + #[derive(Debug, Clone, Default)]
104 + struct View {
105 + /// The source folder being looked at, if it is one folder.
106 + folder: Option<String>,
107 + /// The label being looked at, if it is one label.
108 + label: Option<String>,
109 + /// Whether archived mail is included.
110 + archived: bool,
111 + /// How many threads are on screen.
112 + ///
113 + /// The JS appends: it holds what it has fetched and asks for the next 200
114 + /// from where it stopped. An address cannot append, so this says how many
115 + /// the list is showing and the query asks for that many from the top. The
116 + /// same rows arrive either way, and the difference is that this address
117 + /// re-opens to what it described. Re-reading rows 1..200 to show 400 is the
118 + /// honest cost; the repository is a single indexed query against a local
119 + /// SQLite file, and the alternative is state that survives between two
120 + /// clicks.
121 + shown: i64,
122 + }
123 +
124 + impl View {
125 + /// The view a route was addressed at.
126 + fn of(params: &quasi_router::Params) -> Self {
127 + Self {
128 + folder: text(params, "folder"),
129 + label: text(params, "label"),
130 + archived: matches!(params.get("archived"), Some("1" | "true")),
131 + // A hand-typed `shown` is clamped rather than refused: this is an
132 + // address, and landing on the first page is a more useful answer
133 + // than an error page. The ceiling is the one the JS's own paging
134 + // would reach in ten scrolls and stops a typo asking for a million
135 + // rows.
136 + shown: params
137 + .get("shown")
138 + .and_then(|raw| raw.parse::<i64>().ok())
139 + .unwrap_or(PAGE)
140 + .clamp(PAGE, PAGE * 10),
141 + }
142 + }
143 +
144 + /// The same action, still pointed at the view it was offered under.
145 + ///
146 + /// A default is never written, so two addresses for one view cannot exist.
147 + /// That is [`super::projects::filtered_by`]'s rule, applied to four params
148 + /// instead of two.
149 + ///
150 + /// # The sixth finding, which this port walked into
151 + ///
152 + /// **A write's parameters and the view's parameters share one namespace, and
153 + /// nothing warns when they collide.**
154 + ///
155 + /// This screen writes to `POST /emails/{id}/folder` and reads a `folder`
156 + /// filter, and it archives through `POST /emails/{id}/archive` while reading
157 + /// an `archived` filter. Written the obvious way — the destination under
158 + /// `folder`, the desired state under `archived` — both routes compile,
159 + /// answer, and are wrong in the same silent way: the write lands correctly
160 + /// and then [`View::of`] reads the write's own parameter back as the view,
161 + /// so moving a message to Archive from the INBOX answers with the Archive
162 + /// folder as though the user had navigated there. Nothing is lost and
163 + /// nothing errors; the screen just moves under them.
164 + ///
165 + /// The fix here is naming — the destination is `to` and the archive state is
166 + /// `on` — and it is a convention held by hand, which is what makes it worth
167 + /// recording rather than just doing. Every screen that carries its view in
168 + /// the address has this hazard, it grows with the number of filters, and the
169 + /// two ports before this one had one and two filters and never met it. Filed
170 + /// against quasicoherent: the description layer knows which params are the
171 + /// view's, because the handler asked for them, and a `Params` that could say
172 + /// "these are the address and these are the payload" would make the collision
173 + /// a compile-time question instead of a convention.
174 + fn carry(&self, action: Action) -> Action {
175 + let action = match &self.folder {
176 + Some(folder) => action.with("folder", folder.clone()),
177 + None => action,
178 + };
179 + let action = match &self.label {
180 + Some(label) => action.with("label", label.clone()),
181 + None => action,
182 + };
183 + let action = if self.archived {
184 + action.with("archived", "1")
185 + } else {
186 + action
187 + };
188 + if self.shown == PAGE {
189 + action
190 + } else {
191 + action.with("shown", self.shown.to_string())
192 + }
193 + }
194 +
195 + /// The address of the list under this view.
196 + fn list(&self) -> Action {
197 + self.carry(Action::get("/emails/list"))
198 + }
199 + }
200 +
201 + /// A param that means something only when it is not empty.
202 + ///
203 + /// The two filters arrive from a select whose "all" option has an empty value,
204 + /// so absent and empty are the same fact and are read as the same fact.
205 + fn text(params: &quasi_router::Params, name: &str) -> Option<String> {
206 + params
207 + .get(name)
208 + .map(str::trim)
209 + .filter(|value| !value.is_empty())
210 + .map(ToOwned::to_owned)
211 + }
212 +
213 + /// The email a route was addressed at.
214 + fn email_id(params: &quasi_router::Params) -> Result<EmailId, RouteError> {
215 + let raw = params
216 + .get("id")
217 + .ok_or_else(|| RouteError::not_found("no email id"))?;
218 + Ok(EmailId::from(
219 + uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not an email id"))?,
220 + ))
221 + }
222 +
223 + /// Read one email, or say it is not there.
224 + fn load(state: &AppState, id: EmailId) -> Result<Email, RouteError> {
225 + state
226 + .emails
227 + .get_by_id(id, DESKTOP_USER_ID)
228 + .map_err(|error| RouteError::internal(error.to_string()))?
229 + .ok_or_else(|| RouteError::not_found("no such email"))
230 + }
231 +
232 + /// The threads in a view, and how many there are in total.
233 + fn threads(state: &AppState, view: &View) -> Result<(Vec<EmailThread>, i64), RouteError> {
234 + state
235 + .emails
236 + .list_threaded(
237 + DESKTOP_USER_ID,
238 + view.archived,
239 + Some(0),
240 + Some(view.shown),
241 + view.folder.as_deref(),
242 + view.label.as_deref(),
243 + )
244 + .map_err(|error| RouteError::internal(error.to_string()))
245 + }
246 +
247 + /// One thread as a row.
248 + ///
249 + /// # The first finding
250 + ///
251 + /// **Nothing describes a selection, or an action taken over one.**
252 + ///
253 + /// [`Row::selectable`] says a row is ticked, and that is the whole of it. The
254 + /// shipped screen has a `SelectionManager` with shift-range select, a bulk bar
255 + /// that appears at one selected and counts upward, and five actions that run
256 + /// over the set — mark read, archive, snooze, delete, select-all. A description
257 + /// can say each row is ticked and cannot say what the ticks are *for*, so the
258 + /// bar has nowhere to go and the five actions have no address to call.
259 + ///
260 + /// Not worked around. The workaround is a route per bulk action taking a list of
261 + /// ids in a param, which is a real design and not one this port should invent on
262 + /// its own from one consumer's shape. Filed against quasicoherent.
263 + ///
264 + /// One part of it the port gets for free and is worth writing down: `emails.js`
265 + /// carries a charter rule that selection clears on a filter change, "so bulk
266 + /// actions can't target rows the user can no longer see", and it is enforced by
267 + /// a `clearSelectionIfAny` call in each of the four filter handlers. A filter
268 + /// here is an address. A different view is a different page, and there is no
269 + /// selection to carry into it, so the rule holds without anyone maintaining it.
270 + ///
271 + /// # What the row does have
272 + ///
273 + /// The context menu's seven items are the row's actions, plainly. `components.js`
274 + /// hides them behind a right-click and a kebab and `contextMenus.showEmail`
275 + /// rebuilds them from four `data-email-*` attributes on the element; described,
276 + /// they are what the row offers, and whether that becomes a menu, a swipe or a
277 + /// trailing button strip is the renderer's business.
278 + fn row_for(thread: &EmailThread, view: &View, open: Option<EmailId>) -> Row {
279 + let email = &thread.most_recent_email;
280 + let mut row = Row::new(&email.subject)
281 + .secondary(&email.from)
282 + .meta(email.received_formatted());
283 +
284 + // The unread badge is on the thread and not on the message: `has_unread` is
285 + // true when any message in it is unread, which is what the JS's `unread`
286 + // class on the row means.
287 + if thread.has_unread {
288 + row = row.token(Tag::badge("Unread").tone(makeover_layout::Tone::Info));
289 + }
290 + if thread.thread_count > 1 {
291 + // The JS draws the bare number in a `thread-badge` and puts "N messages
292 + // in thread" in a `title`, which is the tooltip carrying the meaning and
293 + // the badge carrying a digit. A description has no tooltip to hide the
294 + // noun in, and does not need one.
295 + row = row.token(Tag::badge(format!("{} messages", thread.thread_count)));
296 + }
297 + for label in &email.labels {
298 + row = row.token(Tag::badge(label));
299 + }
300 + if email.is_snoozed() {
301 + row = row.token(
302 + Tag::badge(match snoozed_until(email) {
303 + Some(when) => format!("Snoozed until {when}"),
304 + None => "Snoozed".to_owned(),
305 + })
306 + .tone(makeover_layout::Tone::Warning),
307 + );
308 + }
309 +
310 + row.current = open == Some(email.id);
311 + row.activate = Some(view.carry(Action::get(format!("/emails/{}", email.id))));
312 + for act in row_acts(email, view) {
313 + row = row.act(act);
314 + }
315 + row
316 + }
317 +
318 + /// When a snoozed email comes back, said the way the list says it.
319 + ///
320 + /// `EmailResponse` computes this at the serialisation boundary, which a
321 + /// described screen does not cross, so the same `format_relative_future` is
322 + /// called here. One formatter, two callers, rather than a second wording.
323 + fn snoozed_until(email: &Email) -> Option<String> {
324 + email
325 + .snoozed_until
326 + .map(|until| date_utils::format_relative_future(until, Utc::now()))
327 + }
328 +
329 + /// What a row offers, which is what the context menu offers.
330 + ///
331 + /// Read/unread and archive/unarchive are one route each with a param rather than
332 + /// two addresses, for the reason the weekly review's focus toggle gives: the
333 + /// caller always knows which way it is going, and a route that read the current
334 + /// state and flipped it would race a second window.
335 + fn row_acts(email: &Email, view: &View) -> Vec<Act> {
336 + let at = |suffix: &str| view.carry(Action::post(format!("/emails/{}/{suffix}", email.id)));
337 +
338 + let mut acts = vec![
339 + if email.is_read {
340 + Act::new("Mark unread", at("read").with("read", "false"))
341 + } else {
342 + Act::new("Mark read", at("read").with("read", "true"))
343 + },
344 + if email.is_archived {
345 + Act::new("Unarchive", at("archive").with("on", "false")).key("a")
346 + } else {
347 + Act::new("Archive", at("archive").with("on", "true")).key("a")
348 + },
349 + Act::new("Create task", at("task")).key("t"),
350 + Act::new("Create event", at("event")).key("e"),
351 + ];
352 +
353 + if email.is_snoozed() {
354 + acts.push(Act::new("Unsnooze", at("snooze").with("clear", "true")));
355 + }
356 +
357 + acts.push(
358 + Act::new("Delete", at("delete"))
359 + .tone(makeover_layout::Tone::Danger)
360 + .confirm("Are you sure you want to delete this email? This cannot be undone."),
361 + );
362 + acts
363 + }
364 +
365 + /// The list, under the filters it is being looked at through.
366 + ///
367 + /// # The second finding, which is a confirmation rather than a gap
368 + ///
369 + /// **[`Rest`] gets its first consumer with a remainder it actually knows.**
370 + ///
371 + /// `346567f9` added it expecting `remaining` to be `None` most of the time — a
372 + /// query that asked for 51 to learn whether there were more than 50 knows that
373 + /// there are and not how many. `list_threaded` returns `(threads, total)` in one
374 + /// call because the JS count line reads "X of N", so both numbers are here and
375 + /// the honest description carries both.
376 + ///
377 + /// The neighbouring thing stays where `Rest`'s own note puts it. `emails.js`
378 + /// drives a `VirtualScroller` over rows it already holds, and windowing rows you
379 + /// have is a renderer's performance technique, not a fact about the data. The
380 + /// scroller keeps its string contract and is untouched by this port.
381 + fn list(state: &AppState, view: &View, open: Option<EmailId>) -> Result<Node, RouteError> {
382 + let (threads, total) = threads(state, view)?;
383 +
384 + if threads.is_empty() {
385 + // A filtered view that finds nothing is empty because of the filter,
386 + // whatever else is true, so that answer comes first and carries the way
387 + // out. `emails.js` has no filter-specific empty state at all — it asks
388 + // `getEmailAccountsCache().length` and picks one of two — so a folder
389 + // holding no mail tells the user to set up an account they already have.
390 + if view.folder.is_some() || view.label.is_some() {
391 + return Ok(
392 + Node::empty("No mail matching this filter.").offering(Act::new(
393 + "Clear filters",
394 + View {
395 + archived: view.archived,
396 + ..View::default()
397 + }
398 + .list(),
399 + )),
400 + );
401 + }
402 +
403 + // The remaining two are the JS's, and which one shows is a question
404 + // about accounts rather than about mail. The repository answers it
405 + // without a cache.
406 + let configured = !state
407 + .email_accounts
408 + .list_by_user(DESKTOP_USER_ID)
409 + .map_err(|error| RouteError::internal(error.to_string()))?
410 + .is_empty();
411 + return Ok(if configured {
412 + Node::empty("No emails yet.")
413 + } else {
414 + // The JS offers "Add Account" here and this does not: adding one is
415 + // OAuth and a network round trip, so the described screen says the
416 + // sentence and stops rather than growing a control that leads
417 + // nowhere. The settings port drew the same line.
418 + Node::empty("Set up an email account to get started.")
419 + });
420 + }
421 +
422 + let rows: Vec<Row> = threads
423 + .iter()
424 + .map(|thread| row_for(thread, view, open))
425 + .collect();
426 +
427 + let shown = i64::try_from(rows.len()).unwrap_or(i64::MAX);
428 + let more = (total > shown).then(|| {
429 + Rest::more(
430 + View {
431 + shown: view.shown + PAGE,
432 + ..view.clone()
433 + }
434 + .list(),
435 + )
436 + .remaining(u32::try_from(total - shown).unwrap_or(u32::MAX))
437 + });
438 +
439 + Ok(Node::List { rows, more })
440 + }
441 +
442 + /// The two filters, and the archive switch.
443 + ///
444 + /// Fields with a [`Field::changes`] rather than a [`Node::Select`]: the folder
445 + /// set is whatever the server has and can be any length, and `Node::Select`'s
446 + /// three kinds — segmented, toggle, tabs — are all shapes for a handful of
447 + /// options. The JS reaches the same conclusion by using a `<select>`, and
448 + /// `14612ed8` is what lets a control write without a form around it.
449 + ///
450 + /// Each carries the *other* filters in its action and supplies its own value,
451 + /// which is what keeps picking a label from resetting the folder.
452 + fn filters(state: &AppState, view: &View) -> Result<Vec<Node>, RouteError> {
453 + let folders = state
454 + .emails
455 + .list_folders(DESKTOP_USER_ID)
456 + .map_err(|error| RouteError::internal(error.to_string()))?;
457 + let labels = state
458 + .emails
459 + .list_labels(DESKTOP_USER_ID)
460 + .map_err(|error| RouteError::internal(error.to_string()))?;
461 +
462 + // A filter change is a new page of results, so `shown` goes back to one
463 + // page. Carrying it would ask for 400 rows of a folder holding nine.
464 + let base = View {
465 + shown: PAGE,
466 + ..view.clone()
467 + };
468 +
469 + let mut out = Vec::new();
470 +
471 + if !folders.is_empty() {
472 + let mut options = vec![Choice::new("", "All folders")];
473 + options.extend(folders.iter().map(|folder| Choice::new(folder, folder)));
474 + let mut field = Field::select("folder", "Folder", options).changes(
475 + View {
476 + folder: None,
477 + ..base.clone()
478 + }
479 + .list(),
480 + );
481 + if let Some(folder) = &view.folder {
482 + field = field.value(folder);
483 + }
484 + out.push(Node::field(field));
485 + }
486 +
487 + if !labels.is_empty() {
488 + let mut options = vec![Choice::new("", "All labels")];
489 + options.extend(labels.iter().map(|label| Choice::new(label, label)));
490 + let mut field = Field::select("label", "Label", options).changes(
491 + View {
492 + label: None,
493 + ..base.clone()
494 + }
495 + .list(),
496 + );
497 + if let Some(label) = &view.label {
498 + field = field.value(label);
499 + }
500 + out.push(Node::field(field));
Lines truncated
@@ -1,0 +1,525 @@
1 + //! The mail list and the thread, driven through the router against a real
2 + //! database.
3 + //!
4 + //! Same standard as the screens before it: no Tauri runtime and no window, the
5 + //! description asserted, and the markup only where the markup is the point.
6 + //! Every workaround this port had to take is asserted here rather than left to
7 + //! be noticed, so closing a finding is a test that has to change.
8 +
9 + use std::sync::Arc;
10 +
11 + use chrono::{Duration, Utc};
12 + use goingson_core::{Email, EmailId, NewEmailWithTracking};
13 + use quasi_http::Render as _;
14 + use quasi_router::screen::Row;
15 + use quasi_router::{Method, Node, Outcome, Params, Response};
16 +
17 + use super::super::router;
18 + use crate::state::{AppState, DESKTOP_USER_ID};
19 +
20 + /// State with the desktop user in place, which is who the handlers read as.
21 + async fn state() -> Arc<AppState> {
22 + let (state, _) = crate::test_utils::setup_test_state().await;
23 + let now = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
24 + state
25 + .db
26 + .conn()
27 + .unwrap()
28 + .execute(
29 + "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) \
30 + VALUES (?, ?, ?, ?, ?)",
31 + rusqlite::params![
32 + DESKTOP_USER_ID.to_string(),
33 + "desktop@localhost",
34 + "x",
35 + "Desktop User",
36 + &now,
37 + ],
38 + )
39 + .unwrap();
40 + state
41 + }
42 +
43 + /// A message with everything off, which each test then says something about.
44 + fn message(subject: &str) -> NewEmailWithTracking {
45 + NewEmailWithTracking {
46 + project_id: None,
47 + from_address: "ada@example.com".to_owned(),
48 + to_address: "desktop@localhost".to_owned(),
49 + subject: subject.to_owned(),
50 + body: "Body.".to_owned(),
51 + html_body: None,
52 + is_read: false,
53 + is_archived: false,
54 + received_at: Some(Utc::now()),
55 + message_id: None,
56 + in_reply_to: None,
57 + thread_id: None,
58 + email_account_id: None,
59 + is_outgoing: false,
60 + imap_uid: None,
61 + source_folder: None,
62 + attachment_meta: None,
63 + body_truncated: false,
64 + jmap_id: None,
65 + }
66 + }
67 +
68 + fn add(state: &AppState, email: NewEmailWithTracking) -> Email {
69 + state
70 + .emails
71 + .create_with_tracking(DESKTOP_USER_ID, email)
72 + .unwrap()
73 + }
74 +
75 + fn get(state: &AppState, path: &str, params: Params) -> Response {
76 + router()
77 + .handle(state, Method::Get, path, params)
78 + .expect("the route answers")
79 + }
80 +
81 + fn post(state: &AppState, path: &str, params: Params) -> Response {
82 + router()
83 + .handle(state, Method::Post, path, params)
84 + .expect("the route answers")
85 + }
86 +
87 + fn html(response: Response) -> String {
88 + match response.outcome {
89 + Outcome::Screen(screen) => quasi_webview::Webview::new().screen(&screen),
90 + Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(&node),
91 + // Deliberately not a wildcard, for the reason the task tests give: a
92 + // redirect has no body, and a fallback returning empty markup would read
93 + // as a screen that rendered nothing.
94 + Outcome::Goto(action) => panic!("expected content, got a redirect to {action:?}"),
95 + }
96 + }
97 +
98 + /// The whole screen under a view.
99 + fn screen(state: &AppState, params: Params) -> String {
100 + html(get(state, "/emails", params))
101 + }
102 +
103 + /// The list's rows, as the description holds them rather than as markup.
104 + fn rows(response: Response) -> Vec<Row> {
105 + match response.outcome {
106 + Outcome::Fragment {
107 + node: Node::List { rows, .. },
108 + ..
109 + } => rows,
110 + other => panic!("expected a list fragment, got {other:?}"),
111 + }
112 + }
113 +
114 + /// What a list fragment says is left, and how to ask for it.
115 + fn remainder(response: Response) -> Option<quasi_router::screen::Rest> {
116 + match response.outcome {
117 + Outcome::Fragment {
118 + node: Node::List { more, .. },
119 + ..
120 + } => more,
121 + other => panic!("expected a list fragment, got {other:?}"),
122 + }
123 + }
124 +
125 + /// What a response said on the way, which is not part of its content.
126 + fn notice(response: &Response) -> String {
127 + response
128 + .notice
129 + .as_ref()
130 + .map(|message| message.text.clone())
131 + .unwrap_or_default()
132 + }
133 +
134 + fn read(state: &AppState, id: EmailId) -> bool {
135 + state
136 + .emails
137 + .get_by_id(id, DESKTOP_USER_ID)
138 + .unwrap()
139 + .expect("the email is there")
140 + .is_read
141 + }
142 +
143 + #[tokio::test]
144 + async fn the_list_carries_each_thread_and_what_it_is() {
145 + let state = state().await;
146 + add(&state, message("Lunch on Thursday"));
147 +
148 + let page = screen(&state, Params::new());
149 +
150 + assert!(page.contains("Lunch on Thursday"));
151 + assert!(page.contains("ada@example.com"));
152 + // The thread is unread, which is the row's own fact carried as a token
153 + // rather than as a class on the markup.
154 + assert!(page.contains("Unread"));
155 + }
156 +
157 + #[tokio::test]
158 + async fn no_row_offers_a_selection_because_nothing_describes_one() {
159 + // The first finding. `Row::selectable` says a row is ticked and there is no
160 + // way to say what a set of ticked rows is for, so the port describes none:
161 + // a tick with no bulk action behind it is a control that collects a value
162 + // nothing reads. The shipped screen has five bulk actions and a counting
163 + // bar.
164 + //
165 + // Closing the finding on quasicoherent is what changes this test.
166 + let state = state().await;
167 + add(&state, message("One"));
168 + add(&state, message("Two"));
169 +
170 + let listed = rows(get(&state, "/emails/list", Params::new()));
171 +
172 + assert_eq!(listed.len(), 2);
173 + assert!(
174 + listed.iter().all(|row| row.selected.is_none()),
175 + "a described row cannot be part of a selection yet",
176 + );
177 + }
178 +
179 + #[tokio::test]
180 + async fn the_remainder_is_a_real_count_and_a_way_to_ask_for_more() {
181 + // The second finding, which is a confirmation: `Rest::remaining` was added
182 + // expecting to be `None` most of the time, and `list_threaded` returns the
183 + // total in the same call, so both numbers are honest here.
184 + let state = state().await;
185 + for index in 0..=super::PAGE {
186 + let mut mail = message(&format!("Message {index}"));
187 + // Distinct receipt times, so "the newest 200" is a stable set rather
188 + // than whatever the tie-break happens to do.
189 + mail.received_at = Some(Utc::now() - Duration::minutes(index));
190 + add(&state, mail);
191 + }
192 +
193 + let rest = remainder(get(&state, "/emails/list", Params::new()))
194 + .expect("201 messages do not fit in one page");
195 + assert_eq!(rest.remaining, Some(1));
196 +
197 + // And asking for more is an address, so the wider list is reachable
198 + // directly rather than by having scrolled to it.
199 + let wider = rows(get(
200 + &state,
201 + "/emails/list",
202 + Params::new().with("shown", (super::PAGE + super::PAGE).to_string()),
203 + ));
204 + assert_eq!(i64::try_from(wider.len()).unwrap(), super::PAGE + 1);
205 + assert!(
206 + remainder(get(
207 + &state,
208 + "/emails/list",
209 + Params::new().with("shown", (super::PAGE + super::PAGE).to_string()),
210 + ))
211 + .is_none()
212 + );
213 + }
214 +
215 + #[tokio::test]
216 + async fn a_filter_is_an_address_and_every_control_carries_it() {
217 + let state = state().await;
218 + let mut filed = message("Filed away");
219 + filed.source_folder = Some("Archive".to_owned());
220 + add(&state, filed);
221 + add(&state, message("In the inbox"));
222 +
223 + let listed = rows(get(
224 + &state,
225 + "/emails/list",
226 + Params::new().with("folder", "Archive"),
227 + ));
228 +
229 + assert_eq!(listed.len(), 1);
230 + assert_eq!(listed[0].primary, "Filed away");
231 +
232 + // Opening it stays in the folder, and so does everything the row offers.
233 + // A control that dropped the filter is a view you fall out of by using it.
234 + let opening = listed[0].activate.as_ref().expect("a row opens its thread");
235 + assert_eq!(opening.params.get("folder"), Some("Archive"));
236 + for act in &listed[0].actions {
237 + assert_eq!(
238 + act.action.params.get("folder"),
239 + Some("Archive"),
240 + "{} drops the folder it was offered under",
241 + act.label,
242 + );
243 + }
244 + }
245 +
246 + #[tokio::test]
247 + async fn a_default_is_never_written_into_an_address() {
248 + // Two addresses for one view is the thing `View::carry` exists to prevent.
249 + let state = state().await;
250 + add(&state, message("Anything"));
251 +
252 + let listed = rows(get(&state, "/emails/list", Params::new()));
253 + let opening = listed[0].activate.as_ref().unwrap();
254 +
255 + assert_eq!(opening.params.get("folder"), None);
256 + assert_eq!(opening.params.get("label"), None);
257 + assert_eq!(opening.params.get("archived"), None);
258 + assert_eq!(opening.params.get("shown"), None);
259 + }
260 +
261 + #[tokio::test]
262 + async fn opening_a_thread_marks_it_read_and_answers_with_the_whole_screen() {
263 + // The reader is an address rather than a modal, which is the third finding.
264 + // Opening writes, because `emails-reader.js:open` writes, and the row behind
265 + // it has just lost its badge — so the answer cannot be the pane alone.
266 + let state = state().await;
267 + let mail = add(&state, message("Read me"));
268 + assert!(!read(&state, mail.id));
269 +
270 + let response = get(&state, &format!("/emails/{}", mail.id), Params::new());
271 + assert!(
272 + matches!(response.outcome, Outcome::Screen(_)),
273 + "a read that changes the list answers with the screen",
274 + );
275 + assert!(read(&state, mail.id));
276 +
277 + let page = html(response);
278 + assert!(page.contains("Read me"));
279 + assert!(!page.contains("Nothing selected"));
280 + }
281 +
282 + #[tokio::test]
283 + async fn the_body_is_carried_as_markdown_source() {
284 + // The fourth finding. `Email::body` is pter's markdown whenever it came from
285 + // HTML, and the shipped reader escapes it and re-links bare URLs by hand, so
286 + // that markdown reaches the screen as literal syntax. `Node::Rich` carries
287 + // the source and the renderer does the pass.
288 + let state = state().await;
289 + let mut mail = message("Formatted");
290 + mail.body = "**bold** and [a link](https://example.com)".to_owned();
291 + let mail = add(&state, mail);
292 +
293 + let page = html(get(&state, &format!("/emails/{}", mail.id), Params::new()));
294 +
295 + assert!(page.contains("<strong>bold</strong>"));
296 + assert!(page.contains(r#"href="https://example.com""#));
297 + assert!(!page.contains("**bold**"));
298 + }
299 +
300 + #[tokio::test]
301 + async fn marking_unread_closes_the_thread() {
302 + let state = state().await;
303 + let mail = add(&state, message("Later"));
304 + get(&state, &format!("/emails/{}", mail.id), Params::new());
305 + assert!(read(&state, mail.id));
306 +
307 + let page = html(post(
308 + &state,
309 + &format!("/emails/{}/read", mail.id),
310 + Params::new().with("read", "false"),
311 + ));
312 +
313 + assert!(!read(&state, mail.id));
314 + // Putting it back to unread and then showing it is the one combination the
315 + // user cannot have meant.
316 + assert!(page.contains("Nothing selected"));
317 + assert!(page.contains("Unread"));
318 + }
319 +
320 + #[tokio::test]
321 + async fn archiving_takes_it_out_of_the_view_it_was_in() {
322 + let state = state().await;
323 + let mail = add(&state, message("Done with"));
324 +
325 + let response = post(
326 + &state,
327 + &format!("/emails/{}/archive", mail.id),
328 + Params::new().with("on", "true"),
329 + );
330 + assert_eq!(notice(&response), "Email archived");
331 + let page = html(response);
332 + assert!(page.contains("Nothing selected"));
333 + // Gone from the inbox view entirely: with no mail left and no account
334 + // configured, what stands there is the account stand-in rather than a list.
335 + let inbox = html(get(&state, "/emails/list", Params::new()));
336 + assert!(!inbox.contains("Done with"));
337 +
338 + // Unless the view is one that holds archived mail, where it stays put and
339 + // the thread stays open.
340 + let held = rows(get(
341 + &state,
342 + "/emails/list",
343 + Params::new().with("archived", "1"),
344 + ));
345 + assert_eq!(held.len(), 1);
346 + // The write's own parameter is `on` and the view's is `archived`, which is
347 + // the sixth finding: written the obvious way these are one name, and
348 + // unarchiving from a view that holds archived mail would silently leave it.
349 + let still_open = html(post(
350 + &state,
351 + &format!("/emails/{}/archive", mail.id),
352 + Params::new().with("on", "false").with("archived", "1"),
353 + ));
354 + assert!(still_open.contains("Done with"));
355 + }
356 +
357 + #[tokio::test]
358 + async fn labels_round_trip_through_the_comma_separated_box() {
359 + let state = state().await;
360 + let mail = add(&state, message("Tag me"));
361 +
362 + let response = post(
363 + &state,
364 + &format!("/emails/{}/labels", mail.id),
365 + Params::new().with("labels", " work , , follow-up "),
366 + );
367 + assert_eq!(notice(&response), "Labels updated");
368 + let page = html(response);
369 + assert!(page.contains("work"));
370 + assert!(page.contains("follow-up"));
371 +
372 + let stored = state
373 + .emails
374 + .get_by_id(mail.id, DESKTOP_USER_ID)
375 + .unwrap()
376 + .unwrap();
377 + // Trimmed, and the empty entry between the commas is dropped rather than
378 + // stored as a label with no name. `saveLabels` does the same two things.
379 + assert_eq!(
380 + stored.labels,
381 + vec!["work".to_owned(), "follow-up".to_owned()]
382 + );
383 + }
384 +
385 + #[tokio::test]
386 + async fn moving_it_to_the_folder_being_looked_at_keeps_it_there() {
387 + let state = state().await;
388 + let mut mail = message("Moving");
389 + mail.source_folder = Some("INBOX".to_owned());
390 + let mail = add(&state, mail);
391 +
392 + // Out of the folder being looked at: it leaves, and the pane closes with it.
393 + // The destination is `to` and the view is `folder`, which is the sixth
394 + // finding — one name for both would move the message and the screen at once,
395 + // and this is the assertion that says so.
396 + let left = post(
397 + &state,
398 + &format!("/emails/{}/folder", mail.id),
399 + Params::new().with("to", "Archive").with("folder", "INBOX"),
400 + );
401 + assert_eq!(notice(&left), "Moved to Archive");
402 + assert!(html(left).contains("Nothing selected"));
403 +
404 + // And into it: it stays, and so does the thread.
405 + let stayed = html(post(
406 + &state,
407 + &format!("/emails/{}/folder", mail.id),
408 + Params::new()
409 + .with("to", "Archive")
410 + .with("folder", "Archive"),
411 + ));
412 + assert!(stayed.contains("Moving"));
413 + }
414 +
415 + #[tokio::test]
416 + async fn a_snooze_in_the_past_is_refused_rather_than_stored() {
417 + let state = state().await;
418 + let mail = add(&state, message("Not yet"));
419 +
420 + let refused = router().handle(
421 + &state,
422 + Method::Post,
423 + &format!("/emails/{}/snooze", mail.id),
424 + Params::new().with("until", (Utc::now() - Duration::hours(1)).to_rfc3339()),
425 + );
426 + assert!(
427 + refused.is_err(),
428 + "storing it would report the message hidden when it is not",
429 + );
430 +
431 + let accepted = post(
432 + &state,
433 + &format!("/emails/{}/snooze", mail.id),
434 + Params::new().with("until", (Utc::now() + Duration::hours(3)).to_rfc3339()),
435 + );
436 + assert!(notice(&accepted).starts_with("Snoozed until"));
437 + let page = html(accepted);
438 + assert!(page.contains("Not yet"));
439 + }
440 +
441 + #[tokio::test]
442 + async fn deleting_something_that_is_not_there_is_a_404() {
443 + // The rule the projects delete set: a delete that reports done for something
444 + // it never saw is how two regions end up disagreeing about what exists.
445 + let state = state().await;
446 +
447 + let missing = router().handle(
448 + &state,
449 + Method::Post,
450 + &format!("/emails/{}/delete", EmailId::new()),
451 + Params::new(),
452 + );
453 +
454 + assert!(missing.is_err());
455 + }
456 +
457 + #[tokio::test]
458 + async fn converting_makes_a_task_and_leaves_the_mail_where_it_was() {
459 + let state = state().await;
460 + let mail = add(&state, message("Ship the port"));
461 +
462 + let response = post(&state, &format!("/emails/{}/task", mail.id), Params::new());
463 + assert_eq!(notice(&response), "Task created from email");
464 + let page = html(response);
465 + let tasks = state.tasks.list_all(DESKTOP_USER_ID).unwrap();
466 + assert!(
467 + tasks
468 + .iter()
469 + .any(|task| task.title.contains("Ship the port"))
470 + );
471 + // The mail is still in the list, and the thread it was converted from is
472 + // still open.
473 + assert!(page.contains("Ship the port"));
474 + }
475 +
476 + #[tokio::test]
477 + async fn a_thread_is_drawn_oldest_first_and_says_how_many_it_holds() {
478 + let state = state().await;
479 + let mut first = message("Re: the plan");
480 + first.thread_id = Some("thread-1".to_owned());
481 + first.body = "The first word.".to_owned();
482 + first.received_at = Some(Utc::now() - Duration::hours(2));
483 + let first = add(&state, first);
484 +
485 + let mut second = message("Re: the plan");
486 + second.thread_id = Some("thread-1".to_owned());
487 + second.body = "The last word.".to_owned();
488 + second.received_at = Some(Utc::now());
489 + add(&state, second);
490 +
491 + let page = html(get(&state, &format!("/emails/{}", first.id), Params::new()));
492 +
493 + let earlier = page.find("The first word.").expect("the first message");
494 + let later = page.find("The last word.").expect("the second message");
495 + assert!(earlier < later, "the thread reads oldest first");
496 +
497 + let listed = rows(get(&state, "/emails/list", Params::new()));
498 + assert_eq!(listed.len(), 1, "a thread is one row");
499 + assert!(
500 + listed[0]
Lines truncated