Skip to main content

max / goingson

64.2 KB · 1714 lines History Blame Raw
1 //! The mail list and the thread, described rather than built.
2 //!
3 //! <!-- wiki: quasi-overview -->
4 //!
5 //! Mail is the one subject the app did not write: a message arrives from
6 //! somewhere else, carrying a body in a format nobody here chose.
7 //!
8 //! # The shape
9 //!
10 //! - `GET /emails` — the list, under `?folder=`, `?label=`, `?archived=`,
11 //! `?shown=`.
12 //! - `GET /emails/list` — the list alone, which is what a filter swaps.
13 //! - `POST /emails/read-all` — every message read, whatever the view.
14 //! - `POST /emails/list/read` — every ticked thread read.
15 //! - `POST /emails/list/archive` — every ticked thread archived.
16 //! - `POST /emails/list/snooze` — every ticked thread snoozed, under `until`.
17 //! - `POST /emails/list/delete` — every ticked thread deleted.
18 //! - `GET /emails/{id}` — the thread, read.
19 //! - `POST /emails/{id}/read` — read or unread, under `read`.
20 //! - `POST /emails/{id}/archive` — archive or unarchive, under `on`.
21 //! - `POST /emails/{id}/delete` — delete it.
22 //! - `POST /emails/{id}/labels` — set the labels, under `labels`.
23 //! - `POST /emails/{id}/folder` — move it, under `to`.
24 //! - `POST /emails/{id}/snooze` — snooze until `until`, or clear it.
25 //! - `POST /emails/{id}/task` — make a task of it.
26 //! - `POST /emails/{id}/event` — make an event of it.
27 //!
28 //! Every described control reaches one of those.
29 //!
30 //! # What is left out, and why
31 //!
32 //! One cause: **a route handler is `fn(&AppState, Request)`, and these are
33 //! about the host or the network rather than about the app.**
34 //!
35 //! - **Compose, reply, forward, drafts.** Attachments come from a native file
36 //! picker and sending is SMTP from an async command. The prefill halves
37 //! (`build_reply_prefill`, `build_forward_prefill`) are pure and would
38 //! describe fine; a compose form that can be filled and not sent is worse than
39 //! no compose form, so the whole of it waits.
40 //! - **Open in browser, open and save an attachment.** A temp file, a native
41 //! dialog, and the shell.
42 //! - **Accounts, OAuth, sync.** The same wall Sync and Sharing hit on the
43 //! settings screen.
44 //! - **Search.** [`super::search`] is its own screen. The box on this screen
45 //! calls into it.
46
47 // Handlers take their request by value because `quasi_router::Handler` is a
48 // plain `fn(&S, Request)` pointer, so the signature is the router's and not a
49 // choice made here. Same allow, for the same reason, as quasi-axum's tests.
50 #![allow(clippy::needless_pass_by_value)]
51
52 use chrono::{DateTime, Utc};
53 use goingson_core::{
54 BodyFormat, Email, EmailId, EmailSource, EmailThread, Validate as _, date_utils, email_compose,
55 event_from_email, task_from_email,
56 };
57 use makeover_layout::Tone;
58 use quasi_declare::declare;
59 use quasi_router::screen::{Choice, Consult, Figure, Rest, Tag};
60 use quasi_router::{Action, Response, RouteError, Router};
61
62 use crate::commands::get_snooze_options;
63 use crate::state::{AppState, DESKTOP_USER_ID};
64
65 #[cfg(test)]
66 mod tests;
67
68 /// How many threads a page of the list holds.
69 ///
70 /// `emails.js:EMAIL_PAGE_SIZE`. The number is the JS's; what it means here is
71 /// not, and [`View::shown`] is where that difference lives.
72 const PAGE: i64 = 200;
73
74 /// The region the list is drawn in.
75 const LIST: &str = "emails-list";
76
77 /// The region one thread is drawn in.
78 const THREAD: &str = "emails-thread";
79
80 /// The name of the set the list's ticks go into.
81 ///
82 /// The same word the task list uses for its own set. A selection is
83 /// screen-scoped — [`Screen::selection`](quasi_router::Screen::selection) holds
84 /// one name and [`Act::over`] names it back — so two screens sharing a spelling
85 /// is a reader's convenience and not a shared set.
86 const SELECTION: &str = "chosen";
87
88 /// The list as it was being looked at.
89 ///
90 /// `emails.js` holds this across four places — `emailPaging.baseFilters`,
91 /// `emailsFilter`'s two module-scope strings, and the scroller's own idea of how
92 /// far it has streamed — and re-renders from them. Here it is the address, per
93 /// decision 2, which is the same move the projects filters and the weekly
94 /// review's week made and has the same consequence: every action the screen
95 /// offers has to carry the view it was offered under, or acting drops the user
96 /// back into an unfiltered inbox and writes there. [`View::carry`] is that,
97 /// applied to every control on the screen.
98 #[derive(Debug, Clone, Default)]
99 struct View {
100 /// The source folder being looked at, if it is one folder.
101 folder: Option<String>,
102 /// The label being looked at, if it is one label.
103 label: Option<String>,
104 /// Whether archived mail is included.
105 archived: bool,
106 /// Whether the rows arrive ticked.
107 ///
108 /// Select-all, and it is an address, for the reason
109 /// [`super::task_list`]'s own `ticked` gives at length: a renderer could
110 /// tick every box it drew, but a webview one would need a script this
111 /// crate does not ship and a terminal a key it invents, and neither
112 /// survives the fragment swap that replaces the boxes. Answering it from
113 /// the server is one query against a local SQLite file and every host gets
114 /// it.
115 ///
116 /// Only the arriving state. What the user ticks or unticks afterwards is
117 /// the renderer's, which is the whole point of `5f2b8753`: this screen
118 /// never holds which rows are ticked.
119 ticked: bool,
120 /// How many threads are on screen.
121 ///
122 /// The JS appends: it holds what it has fetched and asks for the next 200
123 /// from where it stopped. An address cannot append, so this says how many
124 /// the list is showing and the query asks for that many from the top. The
125 /// same rows arrive either way, and the difference is that this address
126 /// re-opens to what it described. Re-reading rows 1..200 to show 400 is the
127 /// honest cost; the repository is a single indexed query against a local
128 /// SQLite file, and the alternative is state that survives between two
129 /// clicks.
130 shown: i64,
131 }
132
133 impl View {
134 /// The view a route was addressed at.
135 fn of(request: &quasi_router::Request) -> Self {
136 Self {
137 folder: text(&request.carried, "folder"),
138 label: text(&request.carried, "label"),
139 archived: matches!(request.carried.get("archived"), Some("1" | "true")),
140 // `carried`, and the ticks themselves arrive under `ticked` in
141 // `payload` ([`Node::TICKED`]). Two bags, so the select-all address
142 // and the set it produces cannot be read as each other. Same
143 // arrangement `View::carry` records for `folder` and `archived`.
144 ticked: matches!(request.carried.get("ticked"), Some("all")),
145 // A hand-typed `shown` is clamped rather than refused: this is an
146 // address, and landing on the first page is a more useful answer
147 // than an error page. The ceiling is the one the JS's own paging
148 // would reach in ten scrolls and stops a typo asking for a million
149 // rows.
150 shown: request
151 .carried
152 .get("shown")
153 .and_then(|raw| raw.parse::<i64>().ok())
154 .unwrap_or(PAGE)
155 .clamp(PAGE, PAGE * 10),
156 }
157 }
158
159 /// The same action, still pointed at the view it was offered under.
160 ///
161 /// A default is never written, so two addresses for one view cannot exist.
162 /// That is [`super::projects::filtered_by`]'s rule, applied to four params
163 /// instead of two.
164 ///
165 /// # The sixth finding, which this port walked into, and which is now closed
166 ///
167 /// **A write's parameters and the view's parameters shared one namespace,
168 /// and nothing warned when they collided.**
169 ///
170 /// This screen writes to `POST /emails/{id}/folder` and reads a `folder`
171 /// filter, and it archives through `POST /emails/{id}/archive` while reading
172 /// an `archived` filter. Written the obvious way — the destination under
173 /// `folder`, the desired state under `archived` — both routes compiled,
174 /// answered, and were wrong in the same silent way: the write landed
175 /// correctly and then [`View::of`] read the write's own parameter back as
176 /// the view, so moving a message to Archive from the INBOX answered with the
177 /// Archive folder as though the user had navigated there. Nothing was lost
178 /// and nothing errored; the screen just moved under them.
179 ///
180 /// The fix was naming — the destination was `to` and the archive state `on`
181 /// — a convention held by hand, which is why it was recorded rather than
182 /// just done. The problems inbox hit the same wall the same day, on a screen
183 /// with two filters rather than four, which killed the theory that this was
184 /// about how many filters a screen carries.
185 ///
186 /// A request arrives in three bags: `captures` from the path, `payload`
187 /// from what the control sent, and `carried` from the address it was sent
188 /// from. So this method writes the view with [`Action::carrying`], the
189 /// writes send their values under their own names, and neither can be read
190 /// as the other.
191 fn carry(&self, action: Action) -> Action {
192 let action = match &self.folder {
193 Some(folder) => action.carrying("folder", folder.clone()),
194 None => action,
195 };
196 let action = match &self.label {
197 Some(label) => action.carrying("label", label.clone()),
198 None => action,
199 };
200 let action = if self.archived {
201 action.carrying("archived", "1")
202 } else {
203 action
204 };
205 let action = if self.ticked {
206 action.carrying("ticked", "all")
207 } else {
208 action
209 };
210 if self.shown == PAGE {
211 action
212 } else {
213 action.carrying("shown", self.shown.to_string())
214 }
215 }
216
217 /// The address of the list under this view.
218 fn list(&self) -> Action {
219 self.carry(Action::get("/emails/list"))
220 }
221 }
222
223 /// A param that means something only when it is not empty.
224 ///
225 /// The two filters arrive from a select whose "all" option has an empty value,
226 /// so absent and empty are the same fact and are read as the same fact.
227 fn text(params: &quasi_router::Params, name: &str) -> Option<String> {
228 params
229 .get(name)
230 .map(str::trim)
231 .filter(|value| !value.is_empty())
232 .map(ToOwned::to_owned)
233 }
234
235 /// The email a route was addressed at.
236 fn email_id(request: &quasi_router::Request) -> Result<EmailId, RouteError> {
237 let raw = request
238 .captures
239 .get("id")
240 .ok_or_else(|| RouteError::not_found("no email id"))?;
241 Ok(EmailId::from(
242 uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not an email id"))?,
243 ))
244 }
245
246 /// Read one email, or say it is not there.
247 fn load(state: &AppState, id: EmailId) -> Result<Email, RouteError> {
248 state
249 .emails
250 .get_by_id(id, DESKTOP_USER_ID)
251 .map_err(|error| RouteError::internal(error.to_string()))?
252 .ok_or_else(|| RouteError::not_found("no such email"))
253 }
254
255 /// The threads in a view, and how many there are in total.
256 fn threads(state: &AppState, view: &View) -> Result<(Vec<EmailThread>, i64), RouteError> {
257 state
258 .emails
259 .list_threaded(
260 DESKTOP_USER_ID,
261 view.archived,
262 Some(0),
263 Some(view.shown),
264 view.folder.as_deref(),
265 view.label.as_deref(),
266 )
267 .map_err(|error| RouteError::internal(error.to_string()))
268 }
269
270 declare! {
271 ///
272 /// # Selection
273 ///
274 /// A screen names the set with
275 /// [`Screen::selecting`](quasi_router::Screen::selecting), a row says what its
276 /// tick contributes with [`Row::ticking`], and a control says it runs over the
277 /// whole of it with [`Act::over`], which sends every ticked value under
278 /// [`Node::TICKED`]. So the row ticks under its own id and [`bulk`] is the bar.
279 ///
280 /// The tick state itself stays where it was put. A renderer holds which rows
281 /// are ticked, the running count and the clearing; this screen holds only
282 /// whether the rows *arrive* ticked, which is [`View::ticked`] and is select-all.
283 ///
284 /// Selection must not survive a filter change, or bulk actions target rows the
285 /// user can no longer see. A filter here is an address, so a different view is
286 /// a different page and the ticks a user made do not travel. The half that does
287 /// need saying is `ticked=all`, which rides on the address: [`filters`] drops
288 /// it, so "all" can never quietly come to mean a different all.
289 ///
290 /// # What the row does have
291 ///
292 /// The context menu's seven items are the row's actions, plainly. `components.js`
293 /// hides them behind a right-click and a kebab and `contextMenus.showEmail`
294 /// rebuilds them from four `data-email-*` attributes on the element; described,
295 /// they are what the row offers, and whether that becomes a menu, a swipe or a
296 /// trailing button strip is the renderer's business.
297 shape row_for(thread: &EmailThread, view: &View, open: Option<EmailId>) -> Row;
298
299 row &thread.most_recent_email.subject {
300 secondary &thread.most_recent_email.from;
301 meta thread.most_recent_email.received_formatted();
302
303 // The unread badge is on the thread and not on the message: `has_unread`
304 // is true when any message in it is unread, which is what the JS's
305 // `unread` class on the row means.
306 token Tag::badge("Unread").tone(Tone::Info) when thread.has_unread;
307
308 // The JS draws the bare number in a `thread-badge` and puts "N messages
309 // in thread" in a `title`, which is the tooltip carrying the meaning and
310 // the badge carrying a digit. A description has no tooltip to hide the
311 // noun in, and does not need one.
312 token Tag::badge("{thread.thread_count} messages") when thread.thread_count over 1;
313
314 for label in thread.most_recent_email.labels.iter() {
315 token Tag::badge(label);
316 }
317
318 token Tag::badge(snooze_word(&thread.most_recent_email)).tone(Tone::Warning)
319 when thread.most_recent_email.is_snoozed();
320
321 current is_open(thread, open);
322 activate to doing view.carry(Action::get("/emails/{thread.most_recent_email.id}"));
323
324 // The tick joins the screen's set under the message's own id, which is
325 // what the bar acts on. `emails.js` gathers the same ids from the
326 // checkboxes by hand (`SelectionManager.setItems`, over
327 // `mostRecentEmail.id`).
328 ticking thread.most_recent_email.id.to_string() view.ticked;
329
330 // `Row::act` is a part in `Actions`, which is what `beside` says here:
331 // the same eight acts the open thread offers, placed on the row.
332 for act in row_acts(&thread.most_recent_email, view) {
333 beside Actions include act;
334 }
335 }
336 }
337
338 /// Whether this thread is the one the detail pane is showing.
339 fn is_open(thread: &EmailThread, open: Option<EmailId>) -> bool {
340 open == Some(thread.most_recent_email.id)
341 }
342
343 /// What the snoozed badge reads.
344 fn snooze_word(email: &Email) -> String {
345 snoozed_until(email).map_or_else(
346 || "Snoozed".to_owned(),
347 |when| format!("Snoozed until {when}"),
348 )
349 }
350
351 /// When a snoozed email comes back, said the way the list says it.
352 ///
353 /// `EmailResponse` computes this at the serialisation boundary, which a
354 /// described screen does not cross, so the same `format_relative_future` is
355 /// called here. One formatter, two callers, rather than a second wording.
356 fn snoozed_until(email: &Email) -> Option<String> {
357 email
358 .snoozed_until
359 .map(|until| date_utils::format_relative_future(until, Utc::now()))
360 }
361
362 declare! {
363 /// What a row offers, which is what the context menu offers.
364 ///
365 /// Read/unread and archive/unarchive are one route each with a param rather
366 /// than two addresses, for the reason the weekly review's focus toggle
367 /// gives: the caller always knows which way it is going, and a route that
368 /// read the current state and flipped it would race a second window.
369 shape row_acts(email: &Email, view: &View) -> Vec<Node>;
370
371 act "Mark unread" to doing view.carry(Action::post("/emails/{email.id}/read"))
372 with "read" "false"
373 when email.is_read;
374
375 act "Mark read" to doing view.carry(Action::post("/emails/{email.id}/read"))
376 with "read" "true"
377 unless email.is_read;
378
379 act "Unarchive" to doing view.carry(Action::post("/emails/{email.id}/archive"))
380 with "archived" "false"
381 when email.is_archived {
382 key "a";
383 }
384
385 act "Archive" to doing view.carry(Action::post("/emails/{email.id}/archive"))
386 with "archived" "true"
387 unless email.is_archived {
388 key "a";
389 }
390
391 act "Create task" to doing view.carry(Action::post("/emails/{email.id}/task")) {
392 key "t";
393 }
394
395 act "Create event" to doing view.carry(Action::post("/emails/{email.id}/event")) {
396 key "e";
397 }
398
399 act "Unsnooze" to doing view.carry(Action::post("/emails/{email.id}/snooze"))
400 with "clear" "true"
401 when email.is_snoozed();
402
403 act "Delete" to doing view.carry(Action::post("/emails/{email.id}/delete")) {
404 tone Danger;
405 confirm "Are you sure you want to delete this email? This cannot be undone.";
406 }
407 }
408
409 /// The list, as the pane draws it.
410 struct Listing {
411 /// The filters and the page it is being looked at through.
412 view: View,
413 /// Which message is open, so its row reads as current.
414 open: Option<EmailId>,
415 /// The page of threads.
416 threads: Vec<EmailThread>,
417 /// How many the query holds in all, which is the other half of `more`.
418 total: i64,
419 /// Whether the view is narrowed, which decides which empty state applies.
420 filtered: bool,
421 /// Whether an account is set up at all, which decides between the other two.
422 configured: bool,
423 }
424
425 /// The list, under the filters it is being looked at through.
426 fn listing(state: &AppState, view: &View, open: Option<EmailId>) -> Result<Listing, RouteError> {
427 let (threads, total) = threads(state, view)?;
428 Ok(Listing {
429 // A filtered view that finds nothing is empty because of the filter,
430 // whatever else is true, so that answer comes first and carries the way
431 // out. `emails.js` has no filter-specific empty state at all -- it asks
432 // `getEmailAccountsCache().length` and picks one of two -- so a folder
433 // holding no mail tells the user to set up an account they already have.
434 filtered: view.folder.is_some() || view.label.is_some(),
435 // The remaining two are the JS's, and which one shows is a question
436 // about accounts rather than about mail. The repository answers it
437 // without a cache.
438 configured: !state
439 .email_accounts
440 .list_by_user(DESKTOP_USER_ID)
441 .map_err(|error| RouteError::internal(error.to_string()))?
442 .is_empty(),
443 view: view.clone(),
444 open,
445 threads,
446 total,
447 })
448 }
449
450 impl Listing {
451 /// Whether the page is the whole of it.
452 fn all_shown(&self) -> bool {
453 self.total <= i64::try_from(self.threads.len()).unwrap_or(i64::MAX)
454 }
455
456 /// What is left over, and the address that fetches it.
457 ///
458 /// The window and the total rather than the subtraction of the two: `Rest`
459 /// derives what is left, and it is the pair `list_threaded` already hands
460 /// back in one call.
461 fn rest(&self) -> Rest {
462 Rest::more(
463 self.threads.len(),
464 View {
465 shown: self.view.shown + PAGE,
466 ..self.view.clone()
467 }
468 .list(),
469 )
470 .of(usize::try_from(self.total).unwrap_or(usize::MAX))
471 }
472
473 /// The view with every filter cleared, which is the way out of an empty
474 /// filtered list.
475 fn unfiltered(&self) -> Action {
476 View {
477 archived: self.view.archived,
478 ..View::default()
479 }
480 .list()
481 }
482 }
483
484 declare! {
485 /// The list, and what to say when it is empty.
486 ///
487 /// # The second finding, which is a confirmation rather than a gap
488 ///
489 /// **`Rest` gets its first consumer with a remainder it actually knows.**
490 /// `list_threaded` returns `(threads, total)` in one call, so the count line
491 /// can read "X of N" and the description carries both numbers.
492 ///
493 /// Windowing rows a renderer already holds is a performance technique rather
494 /// than a fact about the data, and `Rest` is not it.
495 ///
496 /// The JS offers "Add Account" on the third empty state and this does not:
497 /// adding one is OAuth and a network round trip, so the described screen
498 /// says the sentence and stops rather than growing a control that leads
499 /// nowhere. The settings port drew the same line.
500 shape list(listing: &Listing) -> Node;
501
502 given listing.nothing() {
503 Nothing::Filtered -> empty "No mail matching this filter." {
504 offering "Clear filters" to doing listing.unfiltered();
505 }
506 Nothing::NoAccount -> empty "Set up an email account to get started.";
507 Nothing::NoMail -> empty "No emails yet.";
508 otherwise -> list {
509 for thread in listing.threads.iter() {
510 include row_for(thread, &listing.view, listing.open);
511 }
512
513 more listing.rest() unless listing.all_shown();
514 }
515 }
516 }
517
518 /// Why the list has nothing in it, or that it has something.
519 enum Nothing {
520 /// The filter matched nothing, whatever else is true.
521 Filtered,
522 /// No account is set up, so no mail has ever arrived.
523 NoAccount,
524 /// An account is set up and the mailbox is empty.
525 NoMail,
526 /// There are rows.
527 Some,
528 }
529
530 impl Listing {
531 /// Which of the four this is.
532 const fn nothing(&self) -> Nothing {
533 if !self.threads.is_empty() {
534 Nothing::Some
535 } else if self.filtered {
536 Nothing::Filtered
537 } else if self.configured {
538 Nothing::NoMail
539 } else {
540 Nothing::NoAccount
541 }
542 }
543 }
544
545 /// The two filters, the archive switch and the count over them.
546 struct Band {
547 /// The view every control on the band carries.
548 view: View,
549 /// The base every control writes from: a filter change is a new page of
550 /// results, so `shown` goes back to one page. Carrying it would ask for 400
551 /// rows of a folder holding nine.
552 ///
553 /// `ticked` goes with it, and for the sharper reason `row_for` records:
554 /// carrying select-all through a filter change is exactly what
555 /// `emails.js`'s charter rule forbids, since "everything" would silently
556 /// come to mean a different everything.
557 base: View,
558 /// The folders the server has, with "all" at the head. Empty means the
559 /// control is not drawn at all.
560 folders: Vec<Choice>,
561 /// The labels, on the same terms.
562 labels: Vec<Choice>,
563 /// How many are unread, which the band says only when there are any.
564 unread: i64,
565 }
566
567 /// The band, read once.
568 fn band(state: &AppState, view: &View) -> Result<Band, RouteError> {
569 let folders = state
570 .emails
571 .list_folders(DESKTOP_USER_ID)
572 .map_err(|error| RouteError::internal(error.to_string()))?;
573 let labels = state
574 .emails
575 .list_labels(DESKTOP_USER_ID)
576 .map_err(|error| RouteError::internal(error.to_string()))?;
577
578 let offered = |all: &str, values: Vec<String>| -> Vec<Choice> {
579 if values.is_empty() {
580 return Vec::new();
581 }
582 let mut options = vec![Choice::new("", all)];
583 options.extend(values.iter().map(|value| Choice::new(value, value)));
584 options
585 };
586
587 Ok(Band {
588 folders: offered("All folders", folders),
589 labels: offered("All labels", labels),
590 unread: state
591 .emails
592 .count_unread(DESKTOP_USER_ID)
593 .map_err(|error| RouteError::internal(error.to_string()))?,
594 base: View {
595 shown: PAGE,
596 ticked: false,
597 ..view.clone()
598 },
599 view: view.clone(),
600 })
601 }
602
603 impl Band {
604 /// The address the folder control asks about, which carries every filter
605 /// but its own. That is what keeps picking a label from resetting the
606 /// folder.
607 fn asking_folder(&self) -> Action {
608 View {
609 folder: None,
610 ..self.base.clone()
611 }
612 .list()
613 }
614
615 /// The same, for labels.
616 fn asking_label(&self) -> Action {
617 View {
618 label: None,
619 ..self.base.clone()
620 }
621 .list()
622 }
623
624 /// The address the archive chip toggles to.
625 fn toggling_archived(&self) -> Action {
626 View {
627 archived: !self.view.archived,
628 ..self.base.clone()
629 }
630 .list()
631 }
632 }
633
634 declare! {
635 /// The folder filter.
636 ///
637 /// A field with a `consulting` rather than a strip of options: the folder
638 /// set is whatever the server has and can be any length, and a strip is a
639 /// shape for a handful. The JS reaches the same conclusion by using a
640 /// `<select>`, and `14612ed8` is what lets a control call a route without a
641 /// form around it.
642 ///
643 /// A question and not a write (`aeb44860`): narrowing the list puts nothing
644 /// in the database, and the member that says so is the one that means "ask
645 /// a route about this value". `Consult::at_once` because a select is at its
646 /// next value or its last one and has nothing to wait out.
647 shape folder_field(band: &Band) -> Field;
648
649 field Select "folder" "Folder" {
650 options band.folders.clone();
651 consulting Consult::at_once(band.asking_folder());
652
653 for folder in band.view.folder.iter() {
654 value folder;
655 }
656 }
657 }
658
659 declare! {
660 /// The label filter, on the folder filter's terms.
661 shape label_field(band: &Band) -> Field;
662
663 field Select "label" "Label" {
664 options band.labels.clone();
665 consulting Consult::at_once(band.asking_label());
666
667 for label in band.view.label.iter() {
668 value label;
669 }
670 }
671 }
672
673 declare! {
674 /// The band over the list: what is unread, the filters, and the controls
675 /// over the selection.
676 ///
677 /// Mark all read is offered whatever the count says, which is what the
678 /// filter row does. The gate the rest of this file applies -- say it rather
679 /// than offer it when the control leads nowhere -- does not catch here:
680 /// marking an already-read mailbox read is a write that succeeds and
681 /// changes nothing, not a button that calls something the described screen
682 /// cannot reach.
683 shape band_region(band: &Band) -> Slot;
684
685 region "emails-band" as Band {
686 page "Emails";
687
688 stats [] when band.unread over 0 {
689 figure Figure::new("{band.unread}", "Unread").tone(Tone::Info);
690 }
691
692 include folder_field(band) unless band.folders.is_empty();
693 include label_field(band) unless band.labels.is_empty();
694
695 chip "Include archived" to doing band.toggling_archived() {
696 latched band.view.archived;
697 }
698
699 act "Mark all read" to doing band.view.carry(Action::post("/emails/read-all"));
700
701 extend bulk(&band.view);
702 }
703 }
704
705 declare! {
706 /// The controls over the selection.
707 ///
708 /// The shipped bar's five buttons, in its order: Mark Selected Read,
709 /// Archive, Snooze, Delete, Select All. Four of them run over the set and
710 /// the fifth is an address, which is the split [`View::ticked`] describes.
711 ///
712 /// Snooze is the one that needs a value before it can go, and it asks for
713 /// it with a field in the act's body rather than through a form.
714 /// `bulk-actions.js` opens a modal for the same moment
715 /// (`openBulkSnoozeModal`), which is a whole screen raised to collect one
716 /// time; described, it is the verb carrying the question it has to ask, and
717 /// whether that becomes a popover, a line under the button or a prompt is
718 /// the renderer's.
719 ///
720 /// The options are [`get_snooze_options`]'s, the same ones the open thread
721 /// offers, computed from the local clock per render so "Later Today" stops
722 /// being offered once it means nothing. No blank leading option, unlike the
723 /// task list's snooze picker: that one writes on change and needed a
724 /// resting state, and this one is answered by the press.
725 ///
726 /// # What is not here
727 ///
728 /// The count. The bar says "3 selected" and hides itself at zero, and
729 /// neither is sayable here, because the ticks belong to the renderer until
730 /// a press sends them. That is the right place for it: a renderer knows
731 /// exactly how many boxes it drew ticked, and a renderer draws a control
732 /// over an empty selection as disabled. The bar is always on screen, which
733 /// is the honest version of not knowing.
734 shape bulk(view: &View) -> Vec<Node>;
735
736 act "Mark read" to doing view.carry(Action::post("/emails/list/read")) {
737 over SELECTION;
738 }
739
740 act "Archive" to doing view.carry(Action::post("/emails/list/archive")) {
741 over SELECTION;
742 }
743
744 act "Snooze" to doing view.carry(Action::post("/emails/list/snooze")) {
745 over SELECTION;
746 field Select "until" "Snooze until" {
747 options snooze_choices();
748 }
749 }
750
751 act "Delete" to doing view.carry(Action::post("/emails/list/delete")) {
752 tone Danger;
753 over SELECTION;
754 confirm "Delete every selected email? This cannot be undone.";
755 }
756
757 // Select-all is an address, so its opposite is the same address without it,
758 // and it is only offered when there is something to clear.
759 act "Select all" to doing ticking(view, true);
760 act "Clear selection" to doing ticking(view, false) when view.ticked;
761 }
762
763 /// The same view, with the select-all flag set or cleared.
764 fn ticking(view: &View, ticked: bool) -> Action {
765 View {
766 ticked,
767 ..view.clone()
768 }
769 .list()
770 }
771
772 /// The snooze presets, computed from the local clock per render.
773 fn snooze_choices() -> Vec<Choice> {
774 get_snooze_options()
775 .options
776 .into_iter()
777 .map(|option| Choice::new(option.time.to_rfc3339(), option.label))
778 .collect()
779 }
780
781 declare! {
782 /// The whole screen, with one thread open or none.
783 ///
784 /// Built here rather than inside each route for the reason the projects
785 /// screen gives: a write lands in more than one region -- marking read
786 /// clears the row's badge and changes what the thread offers -- and a
787 /// `Response` names one.
788 shape screen(band: &Band, listing: &Listing, thread: &Option<Thread>) -> Screen;
789
790 screen list_detail "Emails" false {
791 at_place super::shell::EMAILS;
792 selecting SELECTION;
793
794 include band_region(band);
795
796 region LIST as Pane {
797 include list(listing);
798 }
799
800 include thread_slot(thread);
801 }
802 }
803
804 /// One message of an open thread.
805 struct Message {
806 /// Who it is from, and which way it went.
807 heading: String,
808 /// The body, as it was stored.
809 body: String,
810 /// Which format that body is in.
811 format: BodyFormat,
812 /// Whether the sync cut it short.
813 truncated: bool,
814 }
815
816 /// One attachment the thread carries.
817 struct Attachment {
818 /// What it is called.
819 name: String,
820 /// How big it is, in words.
821 size: String,
822 /// What kind of file it is.
823 kind: String,
824 }
825
826 /// Whoever the open message is from, when they are somebody we know.
827 struct Sender {
828 /// Their contact id, which the row opens.
829 contact: String,
830 /// What they are called.
831 name: String,
832 /// Where they work, if the card says.
833 company: Option<String>,
834 }
835
836 /// An open thread, oldest message first.
837 struct Thread {
838 /// The view the pane's controls carry.
839 view: View,
840 /// The subject, which is the pane's heading.
841 subject: String,
842 /// Who the opened message is from.
843 from: String,
844 /// When it arrived.
845 received: String,
846 /// The folder it sits in, if it has one.
847 folder: Option<String>,
848 /// Whether it is archived.
849 archived: bool,
850 /// The labels it carries.
851 labels: Vec<String>,
852 /// When the latest message comes back, if it is snoozed.
853 snoozed: Option<String>,
854 /// The contact card, when the address matches one.
855 sender: Option<Sender>,
856 /// What the thread carries, pooled across its messages.
857 attachments: Vec<Attachment>,
858 /// The messages, oldest first. Their headings are drawn only when there is
859 /// more than one, which is what `threaded` says.
860 messages: Vec<Message>,
861 /// Whether this is a thread rather than a message on its own.
862 threaded: bool,
863 /// The most recent message, which is what the pane's controls act on.
864 latest: Email,
865 /// The snooze presets, absent once it is already snoozed.
866 snooze_choices: Vec<Choice>,
867 /// The labels it carries, as the box shows them.
868 label_box: String,
869 /// The folder it sits in, as the box shows it.
870 folder_box: String,
871 }
872
873 /// One thread, oldest message first.
874 ///
875 /// # The third finding
876 ///
877 /// **The reader is a modal because the list is stateful, and described it is
878 /// simply an address.**
879 ///
880 /// A reader is a modal only where the list underneath holds a scroller, a
881 /// paging cursor and a selection that navigating away would cost. The list here
882 /// is an address and re-opens to itself, so the thread is the detail pane of a
883 /// list-detail screen. Not every modal is a missing arrangement; some are a
884 /// symptom of state the description layer does not have.
885 ///
886 /// # The body is markdown, or it is plain text, and a column records which
887 ///
888 /// `Email::body` is written at sync by `mime_parse::extract_body_with_html`: a
889 /// `text/plain` part arrives as it was sent, and anything else goes through
890 /// `pter::convert`, which produces **markdown**. One column, two formats, and
891 /// no flag to tell them apart.
892 ///
893 /// `Node::Rich` carries markdown source, so the format has to be known rather
894 /// than guessed: rendering a `text/plain` message whose asterisks or leading
895 /// `#` happen to be markdown is wrong. `emails.body_format` is written at sync
896 /// by the one function that knows which branch it took, and this reads it.
897 ///
898 /// One limit: rows written before migration 066 all read `plain`, because
899 /// reclassifying them would mean re-parsing source messages the database does
900 /// not keep. They age out as folders resync.
901 fn open_thread(state: &AppState, id: EmailId, view: &View) -> Result<Thread, RouteError> {
902 let email = load(state, id)?;
903
904 // The backend returns a thread ordered by `received_at` ascending, which is
905 // the order the reader draws. A message with no thread id is a thread of one.
906 let messages = match &email.thread_id {
907 Some(thread_id) => {
908 let found = state
909 .emails
910 .list_by_thread(DESKTOP_USER_ID, thread_id)
911 .map_err(|error| RouteError::internal(error.to_string()))?;
912 if found.len() > 1 { found } else { vec![email] }
913 }
914 None => vec![email],
915 };
916 let opened = messages
917 .iter()
918 .find(|message| message.id == id)
919 .unwrap_or_else(|| &messages[0]);
920 let latest = messages.last().unwrap_or(opened).clone();
921
922 Ok(Thread {
923 subject: opened.subject.clone(),
924 from: opened.from.clone(),
925 received: opened.received_formatted(),
926 folder: opened.source_folder.clone(),
927 archived: opened.is_archived,
928 labels: opened.labels.clone(),
929 snoozed: snoozed_until(&latest).filter(|_| latest.is_snoozed()),
930 sender: sender(state, opened),
931 attachments: attachments(&messages),
932 threaded: messages.len() > 1,
933 messages: messages
934 .iter()
935 .map(|message| Message {
936 // The JS draws an arrow glyph for the direction and the address
937 // beside it. The direction is a fact and the arrow is one
938 // renderer's spelling of it.
939 heading: format!(
940 "{} {}",
941 if message.is_outgoing { "To" } else { "From" },
942 message.from
943 ),
944 body: message.body.clone(),
945 format: message.body_format,
946 truncated: message.body_truncated,
947 })
948 .collect(),
949 // The snooze options are computed from the local clock, so they are
950 // read per render rather than held: "Later Today" stops being offered
951 // at some point in the afternoon, which is the point of asking each
952 // time.
953 snooze_choices: if latest.is_snoozed() {
954 Vec::new()
955 } else {
956 get_snooze_options()
957 .options
958 .into_iter()
959 .map(|option| {
960 Choice::new(
961 option.time.to_rfc3339(),
962 format!("{} \u{2014} {}", option.label, option.formatted),
963 )
964 })
965 .collect()
966 },
967 label_box: latest.labels.join(", "),
968 folder_box: latest.source_folder.clone().unwrap_or_default(),
969 view: view.clone(),
970 latest,
971 })
972 }
973
974 /// Who it is from, and whether we know them.
975 ///
976 /// The JS's sender card, which is a contact when the address matches one and an
977 /// offer to save it when it does not. The offer is left out: creating a contact
978 /// from here is `contacts.create` followed by `addEmail`, two writes the
979 /// contacts screen already owns, and a second address for a thing that exists
980 /// is how two screens start disagreeing about what creating a contact means.
981 fn sender(state: &AppState, email: &Email) -> Option<Sender> {
982 let address = email_compose::extract_email_address(&email.from);
983 if address.is_empty() {
984 return None;
985 }
986 // Best-effort, as the conversion commands treat the same lookup: a failure
987 // to resolve a contact is a card that does not appear, never a thread that
988 // will not open.
989 let Ok(Some(contact)) = state.contacts.find_by_email(DESKTOP_USER_ID, address) else {
990 return None;
991 };
992 Some(Sender {
993 contact: contact.id.to_string(),
994 name: contact.display_name,
995 company: contact.company,
996 })
997 }
998
999 /// What the thread carries, pooled across its messages.
1000 ///
1001 /// Named and sized and no more. Opening one writes a temp file and asks the OS
1002 /// to open it, and saving one is a native dialog; both are the host's, so the
1003 /// description says what is attached and offers nothing to do with it. A row
1004 /// with two buttons that call nothing would be worse than a row with none.
1005 fn attachments(messages: &[Email]) -> Vec<Attachment> {
1006 messages
1007 .iter()
1008 .flat_map(|message| {
1009 message
1010 .attachment_meta
1011 .as_deref()
1012 .and_then(|json| {
1013 serde_json::from_str::<Vec<goingson_core::AttachmentMeta>>(json).ok()
1014 })
1015 .unwrap_or_default()
1016 })
1017 .map(|meta| Attachment {
1018 name: meta.filename,
1019 size: goingson_core::format_file_size(i64::try_from(meta.size).unwrap_or(i64::MAX)),
1020 kind: meta.mime_type,
1021 })
1022 .collect()
1023 }
1024
1025 declare! {
1026 /// One attachment.
1027 shape attachment_row(attachment: &Attachment) -> Row;
1028
1029 row &attachment.name {
1030 meta &attachment.size;
1031 token Tag::badge(&attachment.kind);
1032 }
1033 }
1034
1035 declare! {
1036 /// The contact card, when the address matches one.
1037 shape sender_row(sender: &Sender) -> Row;
1038
1039 row &sender.name {
1040 for company in sender.company.iter() {
1041 secondary company;
1042 }
1043 activate to get "/contacts/{sender.contact}";
1044 }
1045 }
1046
1047 declare! {
1048 /// One message's body, with the heading a thread of more than one wants.
1049 shape message_body(message: &Message, threaded: bool) -> Vec<Node>;
1050
1051 subsection &message.heading when threaded;
1052
1053 // The body says which format it is in now, so this reads the column instead
1054 // of betting on one. `rich` carries markdown source and `text` carries text
1055 // a renderer must escape, and the difference is visible either way round it
1056 // is guessed wrong.
1057 given message.format {
1058 BodyFormat::Markdown -> rich &message.body;
1059 otherwise -> text &message.body;
1060 }
1061
1062 // The JS offers "Load full message", which re-fetches from the provider
1063 // over the network. Said rather than offered, for the reason the fifth
1064 // finding gives.
1065 banner Tone::Warning
1066 "This message was truncated when it was synced. The rest of it is on the server."
1067 when message.truncated;
1068 }
1069
1070 declare! {
1071 /// The open thread.
1072 shape thread_slot(thread: &Option<Thread>) -> Slot;
1073
1074 region THREAD as Pane {
1075 empty "Nothing selected" when thread.is_none();
1076
1077 for thread in thread.iter() {
1078 section &thread.subject;
1079
1080 for sender in thread.sender.iter() {
1081 list {
1082 include sender_row(sender);
1083 }
1084 }
1085
1086 // The meta line, as facts rather than as one interpolated string:
1087 // folder, archived and the labels are a row's trailing parts.
1088 list {
1089 row &thread.from {
1090 meta &thread.received;
1091
1092 for folder in thread.folder.iter() {
1093 token Tag::badge(folder);
1094 }
1095
1096 token Tag::badge("Archived") when thread.archived;
1097
1098 for label in thread.labels.iter() {
1099 token Tag::badge(label);
1100 }
1101
1102 for when in thread.snoozed.iter() {
1103 token Tag::badge("Snoozed until {when}").tone(Tone::Warning);
1104 }
1105 }
1106 }
1107
1108 subsection "Attachments ({thread.attachments.len()})"
1109 unless thread.attachments.is_empty();
1110
1111 list {
1112 for attachment in thread.attachments.iter() {
1113 include attachment_row(attachment);
1114 }
1115 } unless thread.attachments.is_empty();
1116
1117 for message in thread.messages.iter() {
1118 extend message_body(message, thread.threaded);
1119 }
1120
1121 extend thread_acts(thread);
1122 }
1123 }
1124 }
1125
1126 declare! {
1127 /// What the open thread offers, on its most recent message.
1128 ///
1129 /// The JS's action bar, minus reply, reply-all, forward and open-in-browser,
1130 /// which are the host's and are recorded in the module header. What is left
1131 /// is the row's own actions plus the two that only make sense with the
1132 /// thread open: the labels it carries and the folder it sits in.
1133 ///
1134 /// The JS also offers a custom snooze datetime, and that is the seventh
1135 /// finding: **`FieldKind` has no date, time or datetime member**, so a
1136 /// moment a user picks cannot be described at all. Text, Secret, Number,
1137 /// Email, Url, Tel, Textarea, Select, Radio, Checkbox, File, Hidden --
1138 /// every `<input type=...>` the two apps had reached for, and this is the
1139 /// first screen to want one. The floor it needs is not the problem:
1140 /// `Field::at_least` takes the host's own spelling of a bound, which is
1141 /// exactly what `min` on a `datetime-local` is.
1142 ///
1143 /// So the presets are the whole of it here, and they are the better half of
1144 /// the control anyway: `get_snooze_options` computes them from the local
1145 /// clock and drops "Later Today" once it is too late for it to mean
1146 /// anything, which a bare picker cannot do. Filed against quasicoherent
1147 /// rather than worked around with a text box that parses RFC 3339.
1148 shape thread_acts(thread: &Thread) -> Vec<Node>;
1149
1150 extend row_acts(&thread.latest, &thread.view);
1151
1152 subsection "Snooze" unless thread.snooze_choices.is_empty();
1153
1154 for offered in thread.snooze_choices.iter() {
1155 act offered.label.clone()
1156 to doing thread.view.carry(Action::post("/emails/{thread.latest.id}/snooze"))
1157 with "until" offered.value.clone();
1158 }
1159
1160 subsection "Organise";
1161
1162 form doing thread.view.carry(Action::post("/emails/{thread.latest.id}/labels")) {
1163 submit "Save labels";
1164
1165 field Text "labels" "Labels" {
1166 value &thread.label_box;
1167 hint "Comma-separated.";
1168 }
1169 }
1170
1171 form doing thread.view.carry(Action::post("/emails/{thread.latest.id}/folder")) {
1172 submit "Move";
1173
1174 // `to`, not `folder`, for the reason `View::carry` records: `folder` is
1175 // the view's filter, and a destination under the same name would move
1176 // the message and the screen at once.
1177 field Text "folder" "Folder" {
1178 value &thread.folder_box;
1179 required;
1180 }
1181 }
1182 }
1183
1184 /// The whole screen.
1185 fn index(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1186 wrote(state, &View::of(&request), None)
1187 }
1188
1189 /// The list alone, which is what a filter or another page replaces.
1190 fn list_only(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1191 let view = View::of(&request);
1192 let node = list(&listing(state, &view, None)?);
1193 Ok(Response::fragment(LIST, node))
1194 }
1195
1196 /// One thread, read.
1197 ///
1198 /// Opening marks it read, which is what `emails-reader.js:open` does with a
1199 /// `markRead` call between fetching the email and drawing it. So this is a GET
1200 /// that writes, and that is worth saying out loud rather than leaving to be
1201 /// noticed: it is the shipped behaviour, and the alternative — opening a message
1202 /// and leaving it bold — is not the screen this stands in for.
1203 ///
1204 /// The consequence is that the answer is the whole screen and not the pane: the
1205 /// row behind it just lost its unread badge, and the unread figure in the band
1206 /// changed with it.
1207 fn thread(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1208 let id = email_id(&request)?;
1209 let view = View::of(&request);
1210 // Marked before the read, so the screen that comes back is the one after the
1211 // write rather than the one before it.
1212 state
1213 .emails
1214 .mark_read(id, DESKTOP_USER_ID)
1215 .map_err(|error| RouteError::internal(error.to_string()))?;
1216 wrote(state, &view, Some(id))
1217 }
1218
1219 /// Answer a write with the screen it happened on.
1220 ///
1221 /// `open` is what the write left open: itself, for a write that changes a
1222 /// message in place, and nothing for one that takes it out of the view.
1223 fn wrote(state: &AppState, view: &View, open: Option<EmailId>) -> Result<Response, RouteError> {
1224 let thread = open.map(|id| open_thread(state, id, view)).transpose()?;
1225 Ok(screen(&band(state, view)?, &listing(state, view, open)?, &thread).into())
1226 }
1227
1228 /// Read or unread.
1229 fn set_read(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1230 let id = email_id(&request)?;
1231 let view = View::of(&request);
1232 let read = request.payload.get("read") == Some("true");
1233
1234 let found = if read {
1235 state.emails.mark_read(id, DESKTOP_USER_ID)
1236 } else {
1237 state.emails.mark_unread(id, DESKTOP_USER_ID)
1238 }
1239 .map_err(|error| RouteError::internal(error.to_string()))?;
1240 if !found {
1241 return Err(RouteError::not_found("no such email"));
1242 }
1243
1244 // Marking unread from an open thread closes it. Leaving it open would put
1245 // the message back to unread and then immediately show it, which is the one
1246 // combination the user cannot have meant.
1247 wrote(state, &view, read.then_some(id))
1248 }
1249
1250 /// Every message read, whatever is being looked at.
1251 ///
1252 /// The one write on this screen that ignores the view it was sent from.
1253 /// `mark_all_read` takes a user and nothing else, and the shipped control is the
1254 /// same: `emails.js:markAllRead` calls the command and then clears unread across
1255 /// the streamed threads without consulting a filter. Reading only the filtered
1256 /// set would be a different feature, and inventing it here would make the
1257 /// described screen do something the screen it stands in for does not. The view
1258 /// is still carried, because the answer is the same page the user was on.
1259 fn read_all(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1260 let view = View::of(&request);
1261 let marked = state
1262 .emails
1263 .mark_all_read(DESKTOP_USER_ID)
1264 .map_err(|error| RouteError::internal(error.to_string()))?;
1265
1266 // The count, because it is the only thing that distinguishes a mailbox that
1267 // had unread mail from one that did not. The JS says "All emails marked as
1268 // read!" either way, which tells a user who clicked it twice nothing.
1269 Ok(wrote(state, &view, None)?.toast(
1270 makeover_layout::Tone::Success,
1271 match marked {
1272 0 => "Nothing was unread".to_owned(),
1273 1 => "1 email marked read".to_owned(),
1274 many => format!("{many} emails marked read"),
1275 },
1276 ))
1277 }
1278
1279 /// Every message the user ticked, in the order they arrived.
1280 ///
1281 /// The ticks come back under one repeated name, [`Node::TICKED`], which is what
1282 /// [`quasi_router::Params::get_all`] is for and why no delimiter had to be one
1283 /// no id can contain.
1284 ///
1285 /// An id that does not parse is dropped rather than refused, on the task list's
1286 /// reasoning: a bulk write is answered by the list it happened in, and failing
1287 /// the whole press over one malformed value would lose the other thirty-nine.
1288 /// The count in the toast is what the user actually gets, so a drop shows up as
1289 /// a smaller number.
1290 ///
1291 /// An empty set is not an error either. A renderer draws a control over an
1292 /// empty selection as disabled, so the ways left to arrive here with nothing —
1293 /// a hand-typed request, a webview host serving no selection script — deserve
1294 /// the unchanged list rather than a 404.
1295 fn chosen(request: &quasi_router::Request) -> Vec<EmailId> {
1296 request
1297 .payload
1298 .get_all(quasi_router::Node::TICKED)
1299 .filter_map(|raw| uuid::Uuid::parse_str(raw.trim()).ok())
1300 .map(EmailId::from)
1301 .collect()
1302 }
1303
1304 /// `N emails` or `1 email`, for a toast that counts.
1305 fn counted(n: usize) -> String {
1306 if n == 1 {
1307 "1 email".to_owned()
1308 } else {
1309 format!("{n} emails")
1310 }
1311 }
1312
1313 /// The list after a bulk write, with nothing ticked.
1314 ///
1315 /// The ticks are cleared by answering a view that has none, which is
1316 /// `bulk-actions.js`'s `clearSelection()` in each of its five paths arrived at
1317 /// from the other side. Nothing to clear renderer-side either: the rows are
1318 /// redrawn, and a row that comes back unticked is unticked.
1319 fn bulk_wrote(
1320 state: &AppState,
1321 request: &quasi_router::Request,
1322 message: String,
1323 ) -> Result<Response, RouteError> {
1324 let view = View {
1325 ticked: false,
1326 ..View::of(request)
1327 };
1328 Ok(wrote(state, &view, None)?.toast(makeover_layout::Tone::Success, message))
1329 }
1330
1331 /// Mark every ticked message read.
1332 ///
1333 /// One at a time through the same repository call a row's own Mark read takes,
1334 /// which is what `bulk-actions.js` does with its `Promise.allSettled` over the
1335 /// per-message API. A message that has gone since the list was drawn is skipped
1336 /// rather than failing the press.
1337 fn read_chosen(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1338 let mut done = 0;
1339 for id in chosen(&request) {
1340 if state
1341 .emails
1342 .mark_read(id, DESKTOP_USER_ID)
1343 .map_err(|error| RouteError::internal(error.to_string()))?
1344 {
1345 done += 1;
1346 }
1347 }
1348 bulk_wrote(state, &request, format!("{} marked read.", counted(done)))
1349 }
1350
1351 /// Archive every ticked message.
1352 ///
1353 /// The local half only; see [`set_archived`] for why the IMAP half cannot be
1354 /// described from here.
1355 fn archive_chosen(
1356 state: &AppState,
1357 request: quasi_router::Request,
1358 ) -> Result<Response, RouteError> {
1359 let mut done = 0;
1360 for id in chosen(&request) {
1361 if state
1362 .emails
1363 .archive(id, DESKTOP_USER_ID)
1364 .map_err(|error| RouteError::internal(error.to_string()))?
1365 {
1366 done += 1;
1367 }
1368 }
1369 bulk_wrote(state, &request, format!("{} archived.", counted(done)))
1370 }
1371
1372 /// Snooze every ticked message until the time the verb asked for.
1373 ///
1374 /// The time arrives under its own [`Field::name`] because that is how
1375 /// [`Act::asks`] sends it, so this reads `until` exactly as [`set_snooze`]
1376 /// does, and refuses a past one for the same reason: the repository would take
1377 /// it, `is_snoozed` would read false the moment it landed, and the user would
1378 /// be told forty messages were hidden when none of them were.
1379 fn snooze_chosen(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1380 let until = request
1381 .payload
1382 .get("until")
1383 .and_then(|raw| DateTime::parse_from_rfc3339(raw).ok())
1384 .map(|when| when.with_timezone(&Utc))
1385 .filter(|when| *when > Utc::now())
1386 .ok_or_else(|| RouteError::not_found("not a time to snooze until"))?;
1387
1388 let mut done = 0;
1389 for id in chosen(&request) {
1390 if state
1391 .emails
1392 .snooze(id, DESKTOP_USER_ID, until)
1393 .map_err(|error| RouteError::internal(error.to_string()))?
1394 .is_some()
1395 {
1396 done += 1;
1397 }
1398 }
1399 bulk_wrote(
1400 state,
1401 &request,
1402 format!(
1403 "{} snoozed until {}.",
1404 counted(done),
1405 date_utils::format_relative_future(until, Utc::now())
1406 ),
1407 )
1408 }
1409
1410 /// Delete every ticked message.
1411 fn delete_chosen(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1412 let mut done = 0;
1413 for id in chosen(&request) {
1414 if state
1415 .emails
1416 .delete(id, DESKTOP_USER_ID)
1417 .map_err(|error| RouteError::internal(error.to_string()))?
1418 {
1419 done += 1;
1420 }
1421 }
1422 bulk_wrote(state, &request, format!("{} deleted.", counted(done)))
1423 }
1424
1425 /// Archive, or bring it back.
1426 ///
1427 /// # The fifth finding
1428 ///
1429 /// **A write with a best-effort remote half cannot be described.**
1430 ///
1431 /// `archive_email` moves the message on the IMAP server and then archives it
1432 /// locally, and it warns and carries on when the server is unreachable, because
1433 /// "the next sync will reconcile the mismatch". `move_email_to_folder` has the
1434 /// same two halves. A handler here is `fn(&AppState, Params)` — synchronous, no
1435 /// runtime, no way to start work that outlives the response — so the described
1436 /// screen does the local half and lets sync reconcile.
1437 ///
1438 /// That is not a silent downgrade: it is exactly the path the command already
1439 /// takes whenever the server is down, so the behaviour is one the app has and
1440 /// tests, rather than one this port invented. What is lost is the fast path, and
1441 /// what it costs is one sync interval of a mailbox that disagrees with its
1442 /// server.
1443 ///
1444 /// Filed against quasicoherent as the general shape, which is not about email: a
1445 /// screen that writes locally and wants to tell something else about it has
1446 /// nowhere to say so. An outbox the app drains is one answer and a handler that
1447 /// can return work is another, and picking between them wants a second consumer.
1448 fn set_archived(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1449 let id = email_id(&request)?;
1450 let view = View::of(&request);
1451 // `on`, not `archived`: `archived` is the view's own filter, and a write
1452 // that reused the name would rewrite the view it answers with. See
1453 // `View::carry`.
1454 let archived = request.payload.get("archived") == Some("true");
1455
1456 let found = if archived {
1457 state.emails.archive(id, DESKTOP_USER_ID)
1458 } else {
1459 state.emails.unarchive(id, DESKTOP_USER_ID)
1460 }
1461 .map_err(|error| RouteError::internal(error.to_string()))?;
1462 if !found {
1463 return Err(RouteError::not_found("no such email"));
1464 }
1465
1466 // Either way it leaves the view it was in, unless the view holds both.
1467 let still_here = view.archived;
1468 Ok(wrote(state, &view, still_here.then_some(id))?.toast(
1469 makeover_layout::Tone::Success,
1470 if archived {
1471 "Email archived"
1472 } else {
1473 "Email unarchived"
1474 },
1475 ))
1476 }
1477
1478 /// Delete it.
1479 ///
1480 /// A 404 for a message that is not there rather than a quiet success, which is
1481 /// the rule the projects delete set.
1482 fn remove(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1483 let id = email_id(&request)?;
1484 let view = View::of(&request);
1485 let deleted = state
1486 .emails
1487 .delete(id, DESKTOP_USER_ID)
1488 .map_err(|error| RouteError::internal(error.to_string()))?;
1489 if !deleted {
1490 return Err(RouteError::not_found("no such email"));
1491 }
1492 Ok(wrote(state, &view, None)?.toast(makeover_layout::Tone::Success, "Email deleted"))
1493 }
1494
1495 /// Set the labels.
1496 ///
1497 /// A comma-separated box, split on commas with empty entries dropped. A set of
1498 /// labels typed as text is a weaker thing than a set picked from what exists,
1499 /// which is why the existing ones are printed under the box as a hint.
1500 fn set_labels(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1501 let id = email_id(&request)?;
1502 let view = View::of(&request);
1503 let labels: Vec<String> = request
1504 .payload
1505 .get("labels")
1506 .unwrap_or_default()
1507 .split(',')
1508 .map(|label| label.trim().to_owned())
1509 .filter(|label| !label.is_empty())
1510 .collect();
1511
1512 state
1513 .emails
1514 .update_labels(id, DESKTOP_USER_ID, &labels)
1515 .map_err(|error| RouteError::internal(error.to_string()))?
1516 .ok_or_else(|| RouteError::not_found("no such email"))?;
1517
1518 Ok(wrote(state, &view, Some(id))?.toast(makeover_layout::Tone::Success, "Labels updated"))
1519 }
1520
1521 /// Move it to another folder.
1522 ///
1523 /// The local half only; see [`set_archived`] for why.
1524 fn set_folder(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1525 let id = email_id(&request)?;
1526 let view = View::of(&request);
1527 let folder = request
1528 .payload
1529 .get("folder")
1530 .unwrap_or_default()
1531 .trim()
1532 .to_owned();
1533 if folder.is_empty() {
1534 return Err(RouteError::not_found("no folder"));
1535 }
1536
1537 let moved = state
1538 .emails
1539 .update_source_folder(id, DESKTOP_USER_ID, &folder)
1540 .map_err(|error| RouteError::internal(error.to_string()))?;
1541 if !moved {
1542 return Err(RouteError::not_found("no such email"));
1543 }
1544
1545 // It has left the folder that was being looked at, unless that is where it
1546 // went.
1547 let still_here = view.folder.as_deref() == Some(folder.as_str()) || view.folder.is_none();
1548 Ok(wrote(state, &view, still_here.then_some(id))?
1549 .toast(makeover_layout::Tone::Success, format!("Moved to {folder}")))
1550 }
1551
1552 /// Snooze it until a time, or bring it back now.
1553 fn set_snooze(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1554 let id = email_id(&request)?;
1555 let view = View::of(&request);
1556
1557 if request.payload.get("clear") == Some("true") {
1558 state
1559 .emails
1560 .unsnooze(id, DESKTOP_USER_ID)
1561 .map_err(|error| RouteError::internal(error.to_string()))?
1562 .ok_or_else(|| RouteError::not_found("no such email"))?;
1563 return Ok(
1564 wrote(state, &view, Some(id))?.toast(makeover_layout::Tone::Success, "Snooze cleared")
1565 );
1566 }
1567
1568 // A time in the past is refused rather than stored: the repository would
1569 // take it, `is_snoozed` would read false the moment it landed, and the user
1570 // would be told the message was hidden when it was not. The JS enforces the
1571 // same floor with the picker's `min`.
1572 let until = request
1573 .payload
1574 .get("until")
1575 .and_then(|raw| DateTime::parse_from_rfc3339(raw).ok())
1576 .map(|when| when.with_timezone(&Utc))
1577 .filter(|when| *when > Utc::now())
1578 .ok_or_else(|| RouteError::not_found("not a time to snooze until"))?;
1579
1580 state
1581 .emails
1582 .snooze(id, DESKTOP_USER_ID, until)
1583 .map_err(|error| RouteError::internal(error.to_string()))?
1584 .ok_or_else(|| RouteError::not_found("no such email"))?;
1585
1586 Ok(wrote(state, &view, Some(id))?.toast(
1587 makeover_layout::Tone::Success,
1588 format!(
1589 "Snoozed until {}",
1590 date_utils::format_relative_future(until, Utc::now())
1591 ),
1592 ))
1593 }
1594
1595 /// The sender's contact, when there is one.
1596 ///
1597 /// The conversion commands resolve this best-effort and so does this: an
1598 /// unparseable `From` or a lookup failure means a task with no contact on it,
1599 /// never a refused conversion.
1600 fn sender_contact(state: &AppState, from: &str) -> Option<goingson_core::ContactId> {
1601 let address = email_compose::extract_email_address(from);
1602 if address.is_empty() {
1603 return None;
1604 }
1605 state
1606 .contacts
1607 .find_by_email(DESKTOP_USER_ID, address)
1608 .ok()
1609 .flatten()
1610 .map(|contact| contact.id)
1611 }
1612
1613 /// Make a task of it.
1614 ///
1615 /// The derivation is `goingson_core::task_from_email`, which is the same
1616 /// function the command calls. Only the command's Tauri wrapper is out of reach
1617 /// from here, and that is the whole of what a described screen has to route
1618 /// around: the rules are in core, where the settings port's finding says host
1619 /// facts should be, and for the same reason.
1620 fn to_task(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1621 let id = email_id(&request)?;
1622 let view = View::of(&request);
1623 let email = load(state, id)?;
1624
1625 let contact_id = sender_contact(state, &email.from);
1626 let new_task = task_from_email(
1627 &EmailSource {
1628 id: email.id,
1629 subject: &email.subject,
1630 from: &email.from,
1631 body: &email.body,
1632 project_id: email.project_id,
1633 },
1634 contact_id,
1635 Utc::now(),
1636 );
1637 new_task
1638 .validate()
1639 .map_err(|error| RouteError::internal(error.to_string()))?;
1640 state
1641 .tasks
1642 .create(DESKTOP_USER_ID, new_task)
1643 .map_err(|error| RouteError::internal(error.to_string()))?;
1644
1645 // The JS offers a Start Timer action on the toast here, "since converting an
1646 // email is usually the moment work starts". A toast carries a tone and a
1647 // sentence and nothing else, so the offer is dropped rather than faked. It
1648 // is the same shape as the empty focus slot on the weekly review — a real
1649 // thing the vocabulary has no room for — and it is one more consumer for
1650 // whatever answers that.
1651 Ok(wrote(state, &view, Some(id))?
1652 .toast(makeover_layout::Tone::Success, "Task created from email"))
1653 }
1654
1655 /// Make an event of it.
1656 fn to_event(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
1657 let id = email_id(&request)?;
1658 let view = View::of(&request);
1659 let email = load(state, id)?;
1660
1661 let contact_id = sender_contact(state, &email.from);
1662 let mut new_event = event_from_email(
1663 &EmailSource {
1664 id: email.id,
1665 subject: &email.subject,
1666 from: &email.from,
1667 body: &email.body,
1668 project_id: email.project_id,
1669 },
1670 contact_id,
1671 Utc::now(),
1672 );
1673 new_event.user_id = Some(DESKTOP_USER_ID);
1674 new_event
1675 .validate()
1676 .map_err(|error| RouteError::internal(error.to_string()))?;
1677 state
1678 .events
1679 .create(DESKTOP_USER_ID, new_event)
1680 .map_err(|error| RouteError::internal(error.to_string()))?;
1681
1682 Ok(wrote(state, &view, Some(id))?
1683 .toast(makeover_layout::Tone::Success, "Event created from email"))
1684 }
1685
1686 /// The mail screen's routes.
1687 #[must_use]
1688 pub fn routes(router: Router<AppState>) -> Router<AppState> {
1689 router
1690 // Ahead of the capture below, which the path matcher settles on its own
1691 // by specificity. Written in this order anyway, because a reader should
1692 // not have to know that to be sure `list` never arrives as an id.
1693 .get("/emails/list", list_only)
1694 .get("/emails", index)
1695 .post("/emails/read-all", read_all)
1696 // Ahead of the `{id}` writes below for the same reason `/emails/list`
1697 // is ahead of `/emails`: a literal segment outranks a capture, and a
1698 // reader should not have to know that to be sure `list` never arrives
1699 // as an id.
1700 .post("/emails/list/read", read_chosen)
1701 .post("/emails/list/archive", archive_chosen)
1702 .post("/emails/list/snooze", snooze_chosen)
1703 .post("/emails/list/delete", delete_chosen)
1704 .get("/emails/{id}", thread)
1705 .post("/emails/{id}/read", set_read)
1706 .post("/emails/{id}/archive", set_archived)
1707 .post("/emails/{id}/delete", remove)
1708 .post("/emails/{id}/labels", set_labels)
1709 .post("/emails/{id}/folder", set_folder)
1710 .post("/emails/{id}/snooze", set_snooze)
1711 .post("/emails/{id}/task", to_task)
1712 .post("/emails/{id}/event", to_event)
1713 }
1714