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