//! The mail list and the thread, described rather than built. //! //! //! //! Mail is the one subject the app did not write: a message arrives from //! somewhere else, carrying a body in a format nobody here chose. //! //! # The shape //! //! - `GET /emails` — the list, under `?folder=`, `?label=`, `?archived=`, //! `?shown=`. //! - `GET /emails/list` — the list alone, which is what a filter swaps. //! - `POST /emails/read-all` — every message read, whatever the view. //! - `POST /emails/list/read` — every ticked thread read. //! - `POST /emails/list/archive` — every ticked thread archived. //! - `POST /emails/list/snooze` — every ticked thread snoozed, under `until`. //! - `POST /emails/list/delete` — every ticked thread deleted. //! - `GET /emails/{id}` — the thread, read. //! - `POST /emails/{id}/read` — read or unread, under `read`. //! - `POST /emails/{id}/archive` — archive or unarchive, under `on`. //! - `POST /emails/{id}/delete` — delete it. //! - `POST /emails/{id}/labels` — set the labels, under `labels`. //! - `POST /emails/{id}/folder` — move it, under `to`. //! - `POST /emails/{id}/snooze` — snooze until `until`, or clear it. //! - `POST /emails/{id}/task` — make a task of it. //! - `POST /emails/{id}/event` — make an event of it. //! //! Every described control reaches one of those. //! //! # What is left out, and why //! //! One cause: **a route handler is `fn(&AppState, Request)`, and these are //! about the host or the network rather than about the app.** //! //! - **Compose, reply, forward, drafts.** Attachments come from a native file //! picker and sending is SMTP from an async command. The prefill halves //! (`build_reply_prefill`, `build_forward_prefill`) are pure and would //! describe fine; a compose form that can be filled and not sent is worse than //! no compose form, so the whole of it waits. //! - **Open in browser, open and save an attachment.** A temp file, a native //! dialog, and the shell. //! - **Accounts, OAuth, sync.** The same wall Sync and Sharing hit on the //! settings screen. //! - **Search.** [`super::search`] is its own screen. The box on this screen //! calls into it. // Handlers take their request by value because `quasi_router::Handler` is a // plain `fn(&S, Request)` pointer, so the signature is the router's and not a // choice made here. Same allow, for the same reason, as quasi-axum's tests. #![allow(clippy::needless_pass_by_value)] use chrono::{DateTime, Utc}; use goingson_core::{ BodyFormat, Email, EmailId, EmailSource, EmailThread, Validate as _, date_utils, email_compose, event_from_email, task_from_email, }; use makeover_layout::Tone; use quasi_declare::declare; use quasi_router::screen::{Choice, Consult, Figure, Rest, Tag}; use quasi_router::{Action, Response, RouteError, Router}; use crate::commands::get_snooze_options; use crate::state::{AppState, DESKTOP_USER_ID}; #[cfg(test)] mod tests; /// How many threads a page of the list holds. /// /// `emails.js:EMAIL_PAGE_SIZE`. The number is the JS's; what it means here is /// not, and [`View::shown`] is where that difference lives. const PAGE: i64 = 200; /// The region the list is drawn in. const LIST: &str = "emails-list"; /// The region one thread is drawn in. const THREAD: &str = "emails-thread"; /// The name of the set the list's ticks go into. /// /// The same word the task list uses for its own set. A selection is /// screen-scoped — [`Screen::selection`](quasi_router::Screen::selection) holds /// one name and [`Act::over`] names it back — so two screens sharing a spelling /// is a reader's convenience and not a shared set. const SELECTION: &str = "chosen"; /// The list as it was being looked at. /// /// `emails.js` holds this across four places — `emailPaging.baseFilters`, /// `emailsFilter`'s two module-scope strings, and the scroller's own idea of how /// far it has streamed — and re-renders from them. Here it is the address, per /// decision 2, which is the same move the projects filters and the weekly /// review's week made and has the same consequence: every action the screen /// offers has to carry the view it was offered under, or acting drops the user /// back into an unfiltered inbox and writes there. [`View::carry`] is that, /// applied to every control on the screen. #[derive(Debug, Clone, Default)] struct View { /// The source folder being looked at, if it is one folder. folder: Option, /// The label being looked at, if it is one label. label: Option, /// Whether archived mail is included. archived: bool, /// Whether the rows arrive ticked. /// /// Select-all, and it is an address, for the reason /// [`super::task_list`]'s own `ticked` gives at length: a renderer could /// tick every box it drew, but a webview one would need a script this /// crate does not ship and a terminal a key it invents, and neither /// survives the fragment swap that replaces the boxes. Answering it from /// the server is one query against a local SQLite file and every host gets /// it. /// /// Only the arriving state. What the user ticks or unticks afterwards is /// the renderer's, which is the whole point of `5f2b8753`: this screen /// never holds which rows are ticked. ticked: bool, /// How many threads are on screen. /// /// The JS appends: it holds what it has fetched and asks for the next 200 /// from where it stopped. An address cannot append, so this says how many /// the list is showing and the query asks for that many from the top. The /// same rows arrive either way, and the difference is that this address /// re-opens to what it described. Re-reading rows 1..200 to show 400 is the /// honest cost; the repository is a single indexed query against a local /// SQLite file, and the alternative is state that survives between two /// clicks. shown: i64, } impl View { /// The view a route was addressed at. fn of(request: &quasi_router::Request) -> Self { Self { folder: text(&request.carried, "folder"), label: text(&request.carried, "label"), archived: matches!(request.carried.get("archived"), Some("1" | "true")), // `carried`, and the ticks themselves arrive under `ticked` in // `payload` ([`Node::TICKED`]). Two bags, so the select-all address // and the set it produces cannot be read as each other. Same // arrangement `View::carry` records for `folder` and `archived`. ticked: matches!(request.carried.get("ticked"), Some("all")), // A hand-typed `shown` is clamped rather than refused: this is an // address, and landing on the first page is a more useful answer // than an error page. The ceiling is the one the JS's own paging // would reach in ten scrolls and stops a typo asking for a million // rows. shown: request .carried .get("shown") .and_then(|raw| raw.parse::().ok()) .unwrap_or(PAGE) .clamp(PAGE, PAGE * 10), } } /// The same action, still pointed at the view it was offered under. /// /// A default is never written, so two addresses for one view cannot exist. /// That is [`super::projects::filtered_by`]'s rule, applied to four params /// instead of two. /// /// # The sixth finding, which this port walked into, and which is now closed /// /// **A write's parameters and the view's parameters shared one namespace, /// and nothing warned when they collided.** /// /// This screen writes to `POST /emails/{id}/folder` and reads a `folder` /// filter, and it archives through `POST /emails/{id}/archive` while reading /// an `archived` filter. Written the obvious way — the destination under /// `folder`, the desired state under `archived` — both routes compiled, /// answered, and were wrong in the same silent way: the write landed /// correctly and then [`View::of`] read the write's own parameter back as /// the view, so moving a message to Archive from the INBOX answered with the /// Archive folder as though the user had navigated there. Nothing was lost /// and nothing errored; the screen just moved under them. /// /// The fix was naming — the destination was `to` and the archive state `on` /// — a convention held by hand, which is why it was recorded rather than /// just done. The problems inbox hit the same wall the same day, on a screen /// with two filters rather than four, which killed the theory that this was /// about how many filters a screen carries. /// /// A request arrives in three bags: `captures` from the path, `payload` /// from what the control sent, and `carried` from the address it was sent /// from. So this method writes the view with [`Action::carrying`], the /// writes send their values under their own names, and neither can be read /// as the other. fn carry(&self, action: Action) -> Action { let action = match &self.folder { Some(folder) => action.carrying("folder", folder.clone()), None => action, }; let action = match &self.label { Some(label) => action.carrying("label", label.clone()), None => action, }; let action = if self.archived { action.carrying("archived", "1") } else { action }; let action = if self.ticked { action.carrying("ticked", "all") } else { action }; if self.shown == PAGE { action } else { action.carrying("shown", self.shown.to_string()) } } /// The address of the list under this view. fn list(&self) -> Action { self.carry(Action::get("/emails/list")) } } /// A param that means something only when it is not empty. /// /// The two filters arrive from a select whose "all" option has an empty value, /// so absent and empty are the same fact and are read as the same fact. fn text(params: &quasi_router::Params, name: &str) -> Option { params .get(name) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) } /// The email a route was addressed at. fn email_id(request: &quasi_router::Request) -> Result { let raw = request .captures .get("id") .ok_or_else(|| RouteError::not_found("no email id"))?; Ok(EmailId::from( uuid::Uuid::parse_str(raw).map_err(|_| RouteError::not_found("not an email id"))?, )) } /// Read one email, or say it is not there. fn load(state: &AppState, id: EmailId) -> Result { state .emails .get_by_id(id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))? .ok_or_else(|| RouteError::not_found("no such email")) } /// The threads in a view, and how many there are in total. fn threads(state: &AppState, view: &View) -> Result<(Vec, i64), RouteError> { state .emails .list_threaded( DESKTOP_USER_ID, view.archived, Some(0), Some(view.shown), view.folder.as_deref(), view.label.as_deref(), ) .map_err(|error| RouteError::internal(error.to_string())) } declare! { /// /// # Selection /// /// A screen names the set with /// [`Screen::selecting`](quasi_router::Screen::selecting), a row says what its /// tick contributes with [`Row::ticking`], and a control says it runs over the /// whole of it with [`Act::over`], which sends every ticked value under /// [`Node::TICKED`]. So the row ticks under its own id and [`bulk`] is the bar. /// /// The tick state itself stays where it was put. A renderer holds which rows /// are ticked, the running count and the clearing; this screen holds only /// whether the rows *arrive* ticked, which is [`View::ticked`] and is select-all. /// /// Selection must not survive a filter change, or bulk actions target rows the /// user can no longer see. A filter here is an address, so a different view is /// a different page and the ticks a user made do not travel. The half that does /// need saying is `ticked=all`, which rides on the address: [`filters`] drops /// it, so "all" can never quietly come to mean a different all. /// /// # What the row does have /// /// The context menu's seven items are the row's actions, plainly. `components.js` /// hides them behind a right-click and a kebab and `contextMenus.showEmail` /// rebuilds them from four `data-email-*` attributes on the element; described, /// they are what the row offers, and whether that becomes a menu, a swipe or a /// trailing button strip is the renderer's business. shape row_for(thread: &EmailThread, view: &View, open: Option) -> Row; row &thread.most_recent_email.subject { secondary &thread.most_recent_email.from; meta thread.most_recent_email.received_formatted(); // The unread badge is on the thread and not on the message: `has_unread` // is true when any message in it is unread, which is what the JS's // `unread` class on the row means. token Tag::badge("Unread").tone(Tone::Info) when thread.has_unread; // The JS draws the bare number in a `thread-badge` and puts "N messages // in thread" in a `title`, which is the tooltip carrying the meaning and // the badge carrying a digit. A description has no tooltip to hide the // noun in, and does not need one. token Tag::badge("{thread.thread_count} messages") when thread.thread_count over 1; for label in thread.most_recent_email.labels.iter() { token Tag::badge(label); } token Tag::badge(snooze_word(&thread.most_recent_email)).tone(Tone::Warning) when thread.most_recent_email.is_snoozed(); current is_open(thread, open); activate to doing view.carry(Action::get("/emails/{thread.most_recent_email.id}")); // The tick joins the screen's set under the message's own id, which is // what the bar acts on. `emails.js` gathers the same ids from the // checkboxes by hand (`SelectionManager.setItems`, over // `mostRecentEmail.id`). ticking thread.most_recent_email.id.to_string() view.ticked; // `Row::act` is a part in `Actions`, which is what `beside` says here: // the same eight acts the open thread offers, placed on the row. for act in row_acts(&thread.most_recent_email, view) { beside Actions include act; } } } /// Whether this thread is the one the detail pane is showing. fn is_open(thread: &EmailThread, open: Option) -> bool { open == Some(thread.most_recent_email.id) } /// What the snoozed badge reads. fn snooze_word(email: &Email) -> String { snoozed_until(email).map_or_else( || "Snoozed".to_owned(), |when| format!("Snoozed until {when}"), ) } /// When a snoozed email comes back, said the way the list says it. /// /// `EmailResponse` computes this at the serialisation boundary, which a /// described screen does not cross, so the same `format_relative_future` is /// called here. One formatter, two callers, rather than a second wording. fn snoozed_until(email: &Email) -> Option { email .snoozed_until .map(|until| date_utils::format_relative_future(until, Utc::now())) } declare! { /// What a row offers, which is what the context menu offers. /// /// Read/unread and archive/unarchive are one route each with a param rather /// than two addresses, for the reason the weekly review's focus toggle /// gives: the caller always knows which way it is going, and a route that /// read the current state and flipped it would race a second window. shape row_acts(email: &Email, view: &View) -> Vec; act "Mark unread" to doing view.carry(Action::post("/emails/{email.id}/read")) with "read" "false" when email.is_read; act "Mark read" to doing view.carry(Action::post("/emails/{email.id}/read")) with "read" "true" unless email.is_read; act "Unarchive" to doing view.carry(Action::post("/emails/{email.id}/archive")) with "archived" "false" when email.is_archived { key "a"; } act "Archive" to doing view.carry(Action::post("/emails/{email.id}/archive")) with "archived" "true" unless email.is_archived { key "a"; } act "Create task" to doing view.carry(Action::post("/emails/{email.id}/task")) { key "t"; } act "Create event" to doing view.carry(Action::post("/emails/{email.id}/event")) { key "e"; } act "Unsnooze" to doing view.carry(Action::post("/emails/{email.id}/snooze")) with "clear" "true" when email.is_snoozed(); act "Delete" to doing view.carry(Action::post("/emails/{email.id}/delete")) { tone Danger; confirm "Are you sure you want to delete this email? This cannot be undone."; } } /// The list, as the pane draws it. struct Listing { /// The filters and the page it is being looked at through. view: View, /// Which message is open, so its row reads as current. open: Option, /// The page of threads. threads: Vec, /// How many the query holds in all, which is the other half of `more`. total: i64, /// Whether the view is narrowed, which decides which empty state applies. filtered: bool, /// Whether an account is set up at all, which decides between the other two. configured: bool, } /// The list, under the filters it is being looked at through. fn listing(state: &AppState, view: &View, open: Option) -> Result { let (threads, total) = threads(state, view)?; Ok(Listing { // A filtered view that finds nothing is empty because of the filter, // whatever else is true, so that answer comes first and carries the way // out. `emails.js` has no filter-specific empty state at all -- it asks // `getEmailAccountsCache().length` and picks one of two -- so a folder // holding no mail tells the user to set up an account they already have. filtered: view.folder.is_some() || view.label.is_some(), // The remaining two are the JS's, and which one shows is a question // about accounts rather than about mail. The repository answers it // without a cache. configured: !state .email_accounts .list_by_user(DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))? .is_empty(), view: view.clone(), open, threads, total, }) } impl Listing { /// Whether the page is the whole of it. fn all_shown(&self) -> bool { self.total <= i64::try_from(self.threads.len()).unwrap_or(i64::MAX) } /// What is left over, and the address that fetches it. /// /// The window and the total rather than the subtraction of the two: `Rest` /// derives what is left, and it is the pair `list_threaded` already hands /// back in one call. fn rest(&self) -> Rest { Rest::more( self.threads.len(), View { shown: self.view.shown + PAGE, ..self.view.clone() } .list(), ) .of(usize::try_from(self.total).unwrap_or(usize::MAX)) } /// The view with every filter cleared, which is the way out of an empty /// filtered list. fn unfiltered(&self) -> Action { View { archived: self.view.archived, ..View::default() } .list() } } declare! { /// The list, and what to say when it is empty. /// /// # The second finding, which is a confirmation rather than a gap /// /// **`Rest` gets its first consumer with a remainder it actually knows.** /// `list_threaded` returns `(threads, total)` in one call, so the count line /// can read "X of N" and the description carries both numbers. /// /// Windowing rows a renderer already holds is a performance technique rather /// than a fact about the data, and `Rest` is not it. /// /// The JS offers "Add Account" on the third empty state and this does not: /// adding one is OAuth and a network round trip, so the described screen /// says the sentence and stops rather than growing a control that leads /// nowhere. The settings port drew the same line. shape list(listing: &Listing) -> Node; given listing.nothing() { Nothing::Filtered -> empty "No mail matching this filter." { offering "Clear filters" to doing listing.unfiltered(); } Nothing::NoAccount -> empty "Set up an email account to get started."; Nothing::NoMail -> empty "No emails yet."; otherwise -> list { for thread in listing.threads.iter() { include row_for(thread, &listing.view, listing.open); } more listing.rest() unless listing.all_shown(); } } } /// Why the list has nothing in it, or that it has something. enum Nothing { /// The filter matched nothing, whatever else is true. Filtered, /// No account is set up, so no mail has ever arrived. NoAccount, /// An account is set up and the mailbox is empty. NoMail, /// There are rows. Some, } impl Listing { /// Which of the four this is. const fn nothing(&self) -> Nothing { if !self.threads.is_empty() { Nothing::Some } else if self.filtered { Nothing::Filtered } else if self.configured { Nothing::NoMail } else { Nothing::NoAccount } } } /// The two filters, the archive switch and the count over them. struct Band { /// The view every control on the band carries. view: View, /// The base every control writes from: a filter change is a new page of /// results, so `shown` goes back to one page. Carrying it would ask for 400 /// rows of a folder holding nine. /// /// `ticked` goes with it, and for the sharper reason `row_for` records: /// carrying select-all through a filter change is exactly what /// `emails.js`'s charter rule forbids, since "everything" would silently /// come to mean a different everything. base: View, /// The folders the server has, with "all" at the head. Empty means the /// control is not drawn at all. folders: Vec, /// The labels, on the same terms. labels: Vec, /// How many are unread, which the band says only when there are any. unread: i64, } /// The band, read once. fn band(state: &AppState, view: &View) -> Result { let folders = state .emails .list_folders(DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; let labels = state .emails .list_labels(DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; let offered = |all: &str, values: Vec| -> Vec { if values.is_empty() { return Vec::new(); } let mut options = vec![Choice::new("", all)]; options.extend(values.iter().map(|value| Choice::new(value, value))); options }; Ok(Band { folders: offered("All folders", folders), labels: offered("All labels", labels), unread: state .emails .count_unread(DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?, base: View { shown: PAGE, ticked: false, ..view.clone() }, view: view.clone(), }) } impl Band { /// The address the folder control asks about, which carries every filter /// but its own. That is what keeps picking a label from resetting the /// folder. fn asking_folder(&self) -> Action { View { folder: None, ..self.base.clone() } .list() } /// The same, for labels. fn asking_label(&self) -> Action { View { label: None, ..self.base.clone() } .list() } /// The address the archive chip toggles to. fn toggling_archived(&self) -> Action { View { archived: !self.view.archived, ..self.base.clone() } .list() } } declare! { /// The folder filter. /// /// A field with a `consulting` rather than a strip of options: the folder /// set is whatever the server has and can be any length, and a strip is a /// shape for a handful. The JS reaches the same conclusion by using a /// `` the two apps had reached for, and this is the /// first screen to want one. The floor it needs is not the problem: /// `Field::at_least` takes the host's own spelling of a bound, which is /// exactly what `min` on a `datetime-local` is. /// /// So the presets are the whole of it here, and they are the better half of /// the control anyway: `get_snooze_options` computes them from the local /// clock and drops "Later Today" once it is too late for it to mean /// anything, which a bare picker cannot do. Filed against quasicoherent /// rather than worked around with a text box that parses RFC 3339. shape thread_acts(thread: &Thread) -> Vec; extend row_acts(&thread.latest, &thread.view); subsection "Snooze" unless thread.snooze_choices.is_empty(); for offered in thread.snooze_choices.iter() { act offered.label.clone() to doing thread.view.carry(Action::post("/emails/{thread.latest.id}/snooze")) with "until" offered.value.clone(); } subsection "Organise"; form doing thread.view.carry(Action::post("/emails/{thread.latest.id}/labels")) { submit "Save labels"; field Text "labels" "Labels" { value &thread.label_box; hint "Comma-separated."; } } form doing thread.view.carry(Action::post("/emails/{thread.latest.id}/folder")) { submit "Move"; // `to`, not `folder`, for the reason `View::carry` records: `folder` is // the view's filter, and a destination under the same name would move // the message and the screen at once. field Text "folder" "Folder" { value &thread.folder_box; required; } } } /// The whole screen. fn index(state: &AppState, request: quasi_router::Request) -> Result { wrote(state, &View::of(&request), None) } /// The list alone, which is what a filter or another page replaces. fn list_only(state: &AppState, request: quasi_router::Request) -> Result { let view = View::of(&request); let node = list(&listing(state, &view, None)?); Ok(Response::fragment(LIST, node)) } /// One thread, read. /// /// Opening marks it read, which is what `emails-reader.js:open` does with a /// `markRead` call between fetching the email and drawing it. So this is a GET /// that writes, and that is worth saying out loud rather than leaving to be /// noticed: it is the shipped behaviour, and the alternative — opening a message /// and leaving it bold — is not the screen this stands in for. /// /// The consequence is that the answer is the whole screen and not the pane: the /// row behind it just lost its unread badge, and the unread figure in the band /// changed with it. fn thread(state: &AppState, request: quasi_router::Request) -> Result { let id = email_id(&request)?; let view = View::of(&request); // Marked before the read, so the screen that comes back is the one after the // write rather than the one before it. state .emails .mark_read(id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; wrote(state, &view, Some(id)) } /// Answer a write with the screen it happened on. /// /// `open` is what the write left open: itself, for a write that changes a /// message in place, and nothing for one that takes it out of the view. fn wrote(state: &AppState, view: &View, open: Option) -> Result { let thread = open.map(|id| open_thread(state, id, view)).transpose()?; Ok(screen(&band(state, view)?, &listing(state, view, open)?, &thread).into()) } /// Read or unread. fn set_read(state: &AppState, request: quasi_router::Request) -> Result { let id = email_id(&request)?; let view = View::of(&request); let read = request.payload.get("read") == Some("true"); let found = if read { state.emails.mark_read(id, DESKTOP_USER_ID) } else { state.emails.mark_unread(id, DESKTOP_USER_ID) } .map_err(|error| RouteError::internal(error.to_string()))?; if !found { return Err(RouteError::not_found("no such email")); } // Marking unread from an open thread closes it. Leaving it open would put // the message back to unread and then immediately show it, which is the one // combination the user cannot have meant. wrote(state, &view, read.then_some(id)) } /// Every message read, whatever is being looked at. /// /// The one write on this screen that ignores the view it was sent from. /// `mark_all_read` takes a user and nothing else, and the shipped control is the /// same: `emails.js:markAllRead` calls the command and then clears unread across /// the streamed threads without consulting a filter. Reading only the filtered /// set would be a different feature, and inventing it here would make the /// described screen do something the screen it stands in for does not. The view /// is still carried, because the answer is the same page the user was on. fn read_all(state: &AppState, request: quasi_router::Request) -> Result { let view = View::of(&request); let marked = state .emails .mark_all_read(DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; // The count, because it is the only thing that distinguishes a mailbox that // had unread mail from one that did not. The JS says "All emails marked as // read!" either way, which tells a user who clicked it twice nothing. Ok(wrote(state, &view, None)?.toast( makeover_layout::Tone::Success, match marked { 0 => "Nothing was unread".to_owned(), 1 => "1 email marked read".to_owned(), many => format!("{many} emails marked read"), }, )) } /// Every message the user ticked, in the order they arrived. /// /// The ticks come back under one repeated name, [`Node::TICKED`], which is what /// [`quasi_router::Params::get_all`] is for and why no delimiter had to be one /// no id can contain. /// /// An id that does not parse is dropped rather than refused, on the task list's /// reasoning: a bulk write is answered by the list it happened in, and failing /// the whole press over one malformed value would lose the other thirty-nine. /// The count in the toast is what the user actually gets, so a drop shows up as /// a smaller number. /// /// An empty set is not an error either. A renderer draws a control over an /// empty selection as disabled, so the ways left to arrive here with nothing — /// a hand-typed request, a webview host serving no selection script — deserve /// the unchanged list rather than a 404. fn chosen(request: &quasi_router::Request) -> Vec { request .payload .get_all(quasi_router::Node::TICKED) .filter_map(|raw| uuid::Uuid::parse_str(raw.trim()).ok()) .map(EmailId::from) .collect() } /// `N emails` or `1 email`, for a toast that counts. fn counted(n: usize) -> String { if n == 1 { "1 email".to_owned() } else { format!("{n} emails") } } /// The list after a bulk write, with nothing ticked. /// /// The ticks are cleared by answering a view that has none, which is /// `bulk-actions.js`'s `clearSelection()` in each of its five paths arrived at /// from the other side. Nothing to clear renderer-side either: the rows are /// redrawn, and a row that comes back unticked is unticked. fn bulk_wrote( state: &AppState, request: &quasi_router::Request, message: String, ) -> Result { let view = View { ticked: false, ..View::of(request) }; Ok(wrote(state, &view, None)?.toast(makeover_layout::Tone::Success, message)) } /// Mark every ticked message read. /// /// One at a time through the same repository call a row's own Mark read takes, /// which is what `bulk-actions.js` does with its `Promise.allSettled` over the /// per-message API. A message that has gone since the list was drawn is skipped /// rather than failing the press. fn read_chosen(state: &AppState, request: quasi_router::Request) -> Result { let mut done = 0; for id in chosen(&request) { if state .emails .mark_read(id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))? { done += 1; } } bulk_wrote(state, &request, format!("{} marked read.", counted(done))) } /// Archive every ticked message. /// /// The local half only; see [`set_archived`] for why the IMAP half cannot be /// described from here. fn archive_chosen( state: &AppState, request: quasi_router::Request, ) -> Result { let mut done = 0; for id in chosen(&request) { if state .emails .archive(id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))? { done += 1; } } bulk_wrote(state, &request, format!("{} archived.", counted(done))) } /// Snooze every ticked message until the time the verb asked for. /// /// The time arrives under its own [`Field::name`] because that is how /// [`Act::asks`] sends it, so this reads `until` exactly as [`set_snooze`] /// does, and refuses a past one for the same reason: the repository would take /// it, `is_snoozed` would read false the moment it landed, and the user would /// be told forty messages were hidden when none of them were. fn snooze_chosen(state: &AppState, request: quasi_router::Request) -> Result { let until = request .payload .get("until") .and_then(|raw| DateTime::parse_from_rfc3339(raw).ok()) .map(|when| when.with_timezone(&Utc)) .filter(|when| *when > Utc::now()) .ok_or_else(|| RouteError::not_found("not a time to snooze until"))?; let mut done = 0; for id in chosen(&request) { if state .emails .snooze(id, DESKTOP_USER_ID, until) .map_err(|error| RouteError::internal(error.to_string()))? .is_some() { done += 1; } } bulk_wrote( state, &request, format!( "{} snoozed until {}.", counted(done), date_utils::format_relative_future(until, Utc::now()) ), ) } /// Delete every ticked message. fn delete_chosen(state: &AppState, request: quasi_router::Request) -> Result { let mut done = 0; for id in chosen(&request) { if state .emails .delete(id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))? { done += 1; } } bulk_wrote(state, &request, format!("{} deleted.", counted(done))) } /// Archive, or bring it back. /// /// # The fifth finding /// /// **A write with a best-effort remote half cannot be described.** /// /// `archive_email` moves the message on the IMAP server and then archives it /// locally, and it warns and carries on when the server is unreachable, because /// "the next sync will reconcile the mismatch". `move_email_to_folder` has the /// same two halves. A handler here is `fn(&AppState, Params)` — synchronous, no /// runtime, no way to start work that outlives the response — so the described /// screen does the local half and lets sync reconcile. /// /// That is not a silent downgrade: it is exactly the path the command already /// takes whenever the server is down, so the behaviour is one the app has and /// tests, rather than one this port invented. What is lost is the fast path, and /// what it costs is one sync interval of a mailbox that disagrees with its /// server. /// /// Filed against quasicoherent as the general shape, which is not about email: a /// screen that writes locally and wants to tell something else about it has /// nowhere to say so. An outbox the app drains is one answer and a handler that /// can return work is another, and picking between them wants a second consumer. fn set_archived(state: &AppState, request: quasi_router::Request) -> Result { let id = email_id(&request)?; let view = View::of(&request); // `on`, not `archived`: `archived` is the view's own filter, and a write // that reused the name would rewrite the view it answers with. See // `View::carry`. let archived = request.payload.get("archived") == Some("true"); let found = if archived { state.emails.archive(id, DESKTOP_USER_ID) } else { state.emails.unarchive(id, DESKTOP_USER_ID) } .map_err(|error| RouteError::internal(error.to_string()))?; if !found { return Err(RouteError::not_found("no such email")); } // Either way it leaves the view it was in, unless the view holds both. let still_here = view.archived; Ok(wrote(state, &view, still_here.then_some(id))?.toast( makeover_layout::Tone::Success, if archived { "Email archived" } else { "Email unarchived" }, )) } /// Delete it. /// /// A 404 for a message that is not there rather than a quiet success, which is /// the rule the projects delete set. fn remove(state: &AppState, request: quasi_router::Request) -> Result { let id = email_id(&request)?; let view = View::of(&request); let deleted = state .emails .delete(id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; if !deleted { return Err(RouteError::not_found("no such email")); } Ok(wrote(state, &view, None)?.toast(makeover_layout::Tone::Success, "Email deleted")) } /// Set the labels. /// /// A comma-separated box, split on commas with empty entries dropped. A set of /// labels typed as text is a weaker thing than a set picked from what exists, /// which is why the existing ones are printed under the box as a hint. fn set_labels(state: &AppState, request: quasi_router::Request) -> Result { let id = email_id(&request)?; let view = View::of(&request); let labels: Vec = request .payload .get("labels") .unwrap_or_default() .split(',') .map(|label| label.trim().to_owned()) .filter(|label| !label.is_empty()) .collect(); state .emails .update_labels(id, DESKTOP_USER_ID, &labels) .map_err(|error| RouteError::internal(error.to_string()))? .ok_or_else(|| RouteError::not_found("no such email"))?; Ok(wrote(state, &view, Some(id))?.toast(makeover_layout::Tone::Success, "Labels updated")) } /// Move it to another folder. /// /// The local half only; see [`set_archived`] for why. fn set_folder(state: &AppState, request: quasi_router::Request) -> Result { let id = email_id(&request)?; let view = View::of(&request); let folder = request .payload .get("folder") .unwrap_or_default() .trim() .to_owned(); if folder.is_empty() { return Err(RouteError::not_found("no folder")); } let moved = state .emails .update_source_folder(id, DESKTOP_USER_ID, &folder) .map_err(|error| RouteError::internal(error.to_string()))?; if !moved { return Err(RouteError::not_found("no such email")); } // It has left the folder that was being looked at, unless that is where it // went. let still_here = view.folder.as_deref() == Some(folder.as_str()) || view.folder.is_none(); Ok(wrote(state, &view, still_here.then_some(id))? .toast(makeover_layout::Tone::Success, format!("Moved to {folder}"))) } /// Snooze it until a time, or bring it back now. fn set_snooze(state: &AppState, request: quasi_router::Request) -> Result { let id = email_id(&request)?; let view = View::of(&request); if request.payload.get("clear") == Some("true") { state .emails .unsnooze(id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))? .ok_or_else(|| RouteError::not_found("no such email"))?; return Ok( wrote(state, &view, Some(id))?.toast(makeover_layout::Tone::Success, "Snooze cleared") ); } // A time in the past is refused rather than stored: the repository would // take it, `is_snoozed` would read false the moment it landed, and the user // would be told the message was hidden when it was not. The JS enforces the // same floor with the picker's `min`. let until = request .payload .get("until") .and_then(|raw| DateTime::parse_from_rfc3339(raw).ok()) .map(|when| when.with_timezone(&Utc)) .filter(|when| *when > Utc::now()) .ok_or_else(|| RouteError::not_found("not a time to snooze until"))?; state .emails .snooze(id, DESKTOP_USER_ID, until) .map_err(|error| RouteError::internal(error.to_string()))? .ok_or_else(|| RouteError::not_found("no such email"))?; Ok(wrote(state, &view, Some(id))?.toast( makeover_layout::Tone::Success, format!( "Snoozed until {}", date_utils::format_relative_future(until, Utc::now()) ), )) } /// The sender's contact, when there is one. /// /// The conversion commands resolve this best-effort and so does this: an /// unparseable `From` or a lookup failure means a task with no contact on it, /// never a refused conversion. fn sender_contact(state: &AppState, from: &str) -> Option { let address = email_compose::extract_email_address(from); if address.is_empty() { return None; } state .contacts .find_by_email(DESKTOP_USER_ID, address) .ok() .flatten() .map(|contact| contact.id) } /// Make a task of it. /// /// The derivation is `goingson_core::task_from_email`, which is the same /// function the command calls. Only the command's Tauri wrapper is out of reach /// from here, and that is the whole of what a described screen has to route /// around: the rules are in core, where the settings port's finding says host /// facts should be, and for the same reason. fn to_task(state: &AppState, request: quasi_router::Request) -> Result { let id = email_id(&request)?; let view = View::of(&request); let email = load(state, id)?; let contact_id = sender_contact(state, &email.from); let new_task = task_from_email( &EmailSource { id: email.id, subject: &email.subject, from: &email.from, body: &email.body, project_id: email.project_id, }, contact_id, Utc::now(), ); new_task .validate() .map_err(|error| RouteError::internal(error.to_string()))?; state .tasks .create(DESKTOP_USER_ID, new_task) .map_err(|error| RouteError::internal(error.to_string()))?; // The JS offers a Start Timer action on the toast here, "since converting an // email is usually the moment work starts". A toast carries a tone and a // sentence and nothing else, so the offer is dropped rather than faked. It // is the same shape as the empty focus slot on the weekly review — a real // thing the vocabulary has no room for — and it is one more consumer for // whatever answers that. Ok(wrote(state, &view, Some(id))? .toast(makeover_layout::Tone::Success, "Task created from email")) } /// Make an event of it. fn to_event(state: &AppState, request: quasi_router::Request) -> Result { let id = email_id(&request)?; let view = View::of(&request); let email = load(state, id)?; let contact_id = sender_contact(state, &email.from); let mut new_event = event_from_email( &EmailSource { id: email.id, subject: &email.subject, from: &email.from, body: &email.body, project_id: email.project_id, }, contact_id, Utc::now(), ); new_event.user_id = Some(DESKTOP_USER_ID); new_event .validate() .map_err(|error| RouteError::internal(error.to_string()))?; state .events .create(DESKTOP_USER_ID, new_event) .map_err(|error| RouteError::internal(error.to_string()))?; Ok(wrote(state, &view, Some(id))? .toast(makeover_layout::Tone::Success, "Event created from email")) } /// The mail screen's routes. #[must_use] pub fn routes(router: Router) -> Router { router // Ahead of the capture below, which the path matcher settles on its own // by specificity. Written in this order anyway, because a reader should // not have to know that to be sure `list` never arrives as an id. .get("/emails/list", list_only) .get("/emails", index) .post("/emails/read-all", read_all) // Ahead of the `{id}` writes below for the same reason `/emails/list` // is ahead of `/emails`: a literal segment outranks a capture, and a // reader should not have to know that to be sure `list` never arrives // as an id. .post("/emails/list/read", read_chosen) .post("/emails/list/archive", archive_chosen) .post("/emails/list/snooze", snooze_chosen) .post("/emails/list/delete", delete_chosen) .get("/emails/{id}", thread) .post("/emails/{id}/read", set_read) .post("/emails/{id}/archive", set_archived) .post("/emails/{id}/delete", remove) .post("/emails/{id}/labels", set_labels) .post("/emails/{id}/folder", set_folder) .post("/emails/{id}/snooze", set_snooze) .post("/emails/{id}/task", to_task) .post("/emails/{id}/event", to_event) }