Skip to main content

max / goingson

26.1 KB · 708 lines History Blame Raw
1 //! Writing a message, and the Out box it goes to.
2 //!
3 //! <!-- wiki: quasi-overview -->
4 //!
5 //! Eudora-shaped, and three of that design's choices are load-bearing here.
6 //!
7 //! # The header is a grid, and `Attached:` is a row of it
8 //!
9 //! A right-aligned label column with the fields beside it: From, To, Cc, Bcc,
10 //! Subject, Attached. The attachments bar is a header row, so there is one
11 //! arrangement and nowhere else to put it. Cc and Bcc are never hidden: nothing
12 //! in the vocabulary names "hidden until asked for", and if that is ever wanted
13 //! the answer is an address, the way [`super::settings::email`] says its
14 //! advanced block with `?advanced=1`.
15 //!
16 //! # Queue, not Send, and that is the whole reason this screen exists
17 //!
18 //! `send_email` is async and a route handler is synchronous, so a described
19 //! compose cannot send. You Queue, and something else drains the Out box.
20 //! Queueing is a local write.
21 //!
22 //! So the outbox is the feature: a message you can see before it goes,
23 //! schedule, and take back. See [`crate::outbox`] for the drainer.
24 //!
25 //! # The draft saves as you write it, which is why there is no Save button
26 //!
27 //! Every field here carries [`Field::writes`], so leaving a field writes it to
28 //! the draft. That is also what gets this screen past a known gap: a
29 //! [`Node::Form`] has one submit, and compose wants three verbs over one set of
30 //! values. It does not need them once the values are already saved. Queue and
31 //! Discard act on a draft that is already on disk, and there is no form at all.
32 //!
33 //! `Field::writes` is the DOM `change` event in a webview, which fires when a
34 //! field settles rather than per keystroke. On a terminal it is per keystroke,
35 //! which is that renderer's to answer; a draft written more often than
36 //! necessary is the harmless end of it.
37 //!
38 //! # A new message is a POST
39 //!
40 //! `GET /compose` would have to create a draft to have somewhere to write to,
41 //! and a GET that writes is a GET that a reload repeats. So `POST /compose`
42 //! makes the draft and goes to it, and the screen itself is
43 //! `GET /compose/{id}`: an address that can be reloaded, linked and reopened.
44 //!
45 //! An abandoned blank draft is the cost: an empty message in Out that you throw
46 //! away. Discard is one press.
47 //!
48 //! # Attachments are blobs, because a queued message outlives a path
49 //!
50 //! Attaching is [`Action::by_host`], like every other file pick in this app:
51 //! `frontend/js/host.js` opens the dialog and posts the path, and
52 //! `attach_path` hashes the bytes into the content-addressed blob store and
53 //! writes a row against this draft.
54 //!
55 //! What the drainer sends is the blob, not the path. A message queued at nine
56 //! may go at five, and the file it was written from can be moved, renamed or
57 //! deleted in between. A blob cannot, and `blob_gc` keeps it alive while a row
58 //! references it. The recipient sees the row's `filename`, since a blob is
59 //! named by its hash.
60 //!
61 //! The total size is checked here as well as at send time in `send.rs`. Under
62 //! an outbox the send-time refusal lands as a `send_error` on a row nobody is
63 //! looking at, minutes or hours after the person walked away. Both checks stay:
64 //! the send path is reached by more than this screen.
65 //!
66 //! # A window of its own, and one description in both
67 //!
68 //! [`super::compose_protocol`] is a second scheme serving **this same screen**
69 //! with no app chrome and a frame that reports what happened. Nothing here is
70 //! conditional on which window it is in, and nothing should be: a screen that
71 //! knew would be two code paths.
72 //!
73 //! Asking for the window is [`Action::elsewhere`], rather than a bespoke branch
74 //! in `host.js` or a menu item, because both of those put the second window
75 //! outside the description where no other renderer could ever have one.
76 //!
77 //! The frame offers no verbs. Queue, Queue later, Discard and Take it back are
78 //! on the screen, so both mounts get them from one place, and a frame carrying
79 //! them too would draw each one twice here.
80
81 // Handlers take their request by value because `quasi_router::Handler` is a
82 // plain `fn(&S, Request)` pointer, so the signature is the router's.
83 #![allow(clippy::needless_pass_by_value)]
84
85 use chrono::{DateTime, Utc};
86 use goingson_core::EmailId;
87 use quasi_declare::declare;
88 use quasi_router::layout::{FieldKind, Tone};
89 use quasi_router::screen::{Choice, Field, Tag};
90 use quasi_router::{Action, Node, Response, RouteError, Router};
91
92 use crate::state::{AppState, DESKTOP_USER_ID};
93
94 #[cfg(test)]
95 mod tests;
96
97 /// The region the header and body live in.
98 const BODY: &str = "compose";
99 /// Where a saved field's acknowledgement lands.
100 ///
101 /// Its own region rather than the header's, because answering into the header
102 /// would redraw the control the reader is still in.
103 const SAVED: &str = "compose-saved";
104
105 /// The field names, which are also what a write reads out of the payload.
106 const FROM: &str = "from";
107 const TO: &str = "to";
108 const CC: &str = "cc";
109 const BCC: &str = "bcc";
110 const SUBJECT: &str = "subject";
111 const MESSAGE: &str = "body";
112 /// The instant Queue Later asks for.
113 const SEND_AFTER: &str = "send_after";
114
115 /// What a message's files may come to, together.
116 ///
117 /// The same number `commands::email::send` enforces, stated here because this
118 /// is where it can still be said to somebody. Kept as two checks rather than
119 /// one: the send path is reached by more than this screen, and a limit that
120 /// only the screen enforced would be a limit the drainer could walk past.
121 const MAX_TOTAL_ATTACHMENT_BYTES: i64 = 25 * 1024 * 1024;
122
123 /// A new message: make the draft, then go to it.
124 fn start(state: &AppState, _request: quasi_router::Request) -> Result<Response, RouteError> {
125 let draft = save(state, EmailId::new(), Fields::default())?;
126 Ok(Response::goto(Action::get(format!(
127 "/compose/{}",
128 draft.id
129 ))))
130 }
131
132 /// What the screen writes, all of it optional so one field can be saved alone.
133 #[derive(Default)]
134 struct Fields {
135 from: Option<EmailId>,
136 to: Option<String>,
137 cc: Option<String>,
138 bcc: Option<String>,
139 subject: Option<String>,
140 body: Option<String>,
141 }
142
143 /// Write a draft, keeping whatever this call did not name.
144 ///
145 /// `save_draft` replaces rather than patches, so a single-field write has to
146 /// read the row first or it blanks the other five. That is the repository's
147 /// shape and not worth changing for this: a draft is small and the read is
148 /// local.
149 fn save(state: &AppState, id: EmailId, fields: Fields) -> Result<goingson_core::Email, RouteError> {
150 let existing = state
151 .emails
152 .get_by_id(id, DESKTOP_USER_ID)
153 .map_err(|error| RouteError::internal(error.to_string()))?;
154
155 let held = existing.as_ref();
156 let account = fields
157 .from
158 .map(|id| goingson_core::EmailAccountId::from(uuid::Uuid::from(id)))
159 .or_else(|| held.and_then(|email| email.draft_account_id));
160
161 let from = match account {
162 Some(id) => state
163 .email_accounts
164 .get_by_id(id, DESKTOP_USER_ID)
165 .map_err(|error| RouteError::internal(error.to_string()))?
166 .map(|account| account.email_address)
167 .unwrap_or_default(),
168 None => held.map(|email| email.from.clone()).unwrap_or_default(),
169 };
170
171 let pick = |given: Option<String>, kept: Option<String>| given.or(kept).unwrap_or_default();
172
173 state
174 .emails
175 .save_draft(
176 id,
177 DESKTOP_USER_ID,
178 &from,
179 &pick(fields.to, held.map(|email| email.to.clone())),
180 Some(&pick(
181 fields.cc,
182 held.and_then(|email| email.cc_address.clone()),
183 )),
184 Some(&pick(
185 fields.bcc,
186 held.and_then(|email| email.bcc_address.clone()),
187 )),
188 &pick(fields.subject, held.map(|email| email.subject.clone())),
189 &pick(fields.body, held.map(|email| email.body.clone())),
190 account,
191 held.and_then(|email| email.in_reply_to.clone()).as_deref(),
192 None,
193 held.and_then(|email| email.thread_id.clone()).as_deref(),
194 )
195 .map_err(|error| RouteError::internal(error.to_string()))
196 }
197
198 /// Everything the screen draws, read once.
199 ///
200 /// The reads are the handler's: the draft, the accounts and the attachments are
201 /// three queries, and two of the three used to happen inside a shape. A
202 /// description says what is on the screen.
203 struct Loaded {
204 id: EmailId,
205 subject: String,
206 to: String,
207 cc: String,
208 bcc: String,
209 body: String,
210 /// Which account it is from, if one has been chosen.
211 from: Option<String>,
212 /// The accounts there are to choose between.
213 accounts: Vec<Choice>,
214 /// In the Out box, so read-only until it is taken back.
215 queued: bool,
216 files: Vec<goingson_core::Attachment>,
217 }
218
219 /// Read it.
220 fn read(state: &AppState, id: EmailId) -> Result<Loaded, RouteError> {
221 let draft = state
222 .emails
223 .get_by_id(id, DESKTOP_USER_ID)
224 .map_err(|error| RouteError::internal(error.to_string()))?
225 .filter(|email| email.is_draft)
226 .ok_or_else(|| RouteError::not_found("no such draft"))?;
227
228 let accounts = state
229 .email_accounts
230 .list_by_user(DESKTOP_USER_ID)
231 .map_err(|error| RouteError::internal(error.to_string()))?
232 .into_iter()
233 .map(|account| Choice::new(account.id.to_string(), account.email_address))
234 .collect();
235
236 let files = state
237 .attachments
238 .list_for_email(id, DESKTOP_USER_ID)
239 .map_err(|error| RouteError::internal(error.to_string()))?;
240
241 Ok(Loaded {
242 id,
243 queued: draft.is_queued(),
244 subject: draft.subject,
245 to: draft.to,
246 cc: draft.cc_address.unwrap_or_default(),
247 bcc: draft.bcc_address.unwrap_or_default(),
248 body: draft.body,
249 from: draft.draft_account_id.map(|id| id.to_string()),
250 accounts,
251 files,
252 })
253 }
254
255 /// The route one field writes to, carrying which field it is.
256 fn field_route(id: EmailId, name: &str) -> Action {
257 Action::post(format!("/compose/{id}/field")).with("field", name)
258 }
259
260 /// What the page is called before it has a subject.
261 fn heading(loaded: &Loaded) -> &str {
262 if loaded.subject.is_empty() {
263 "New message"
264 } else {
265 &loaded.subject
266 }
267 }
268
269 /// The account the message is sent from, once one is chosen.
270 fn from_value(loaded: &Loaded) -> Option<&str> {
271 loaded.from.as_deref()
272 }
273
274 /// Whether the message carries nothing.
275 fn no_files(loaded: &Loaded) -> bool {
276 loaded.files.is_empty()
277 }
278
279 declare! {
280 /// The `Attached:` rows, and the way to add one.
281 ///
282 /// Drawn even when empty, because it is a header row rather than a bar that
283 /// appears: the point of Eudora's shape is that the message says what it
284 /// carries in the same place every time.
285 ///
286 /// Attaching is [`Action::by_host`], the same as the imports and the
287 /// project dashboard: picking a file is not describable, and
288 /// `frontend/js/host.js` opens the dialog and posts the path back. What
289 /// lands is a row against this draft with the bytes in the
290 /// content-addressed blob store, which is what makes an attachment survive
291 /// until the outbox drains.
292 ///
293 /// A queued message's files are listed and not removable: the drainer may
294 /// be reading them.
295 shape attached(loaded: &Loaded) -> Vec<Node>;
296
297 list {
298 row "Attached" when no_files(loaded) {
299 meta "Nothing";
300 }
301
302 for file in loaded.files.iter() {
303 row "Attached" {
304 secondary file.filename.clone();
305 meta size(file.file_size);
306 act "Remove" to post "/compose/{loaded.id}/detach/{file.id}"
307 unless loaded.queued {
308 tone Danger;
309 }
310 }
311 }
312 }
313
314 act "Attach a file" to post "/compose/{loaded.id}/attach" by_host awaiting
315 unless loaded.queued;
316 }
317
318 declare! {
319 /// The screen.
320 ///
321 /// The header grid is in Eudora's order. From first because it is the one
322 /// choice rather than a thing typed, and because a message with no account
323 /// cannot leave the outbox. Cc and Bcc are always drawn; Eudora never hid
324 /// them, which is why this screen needs no word for progressive disclosure.
325 ///
326 /// Attached is the last header row and the body goes under it, which is the
327 /// order Eudora drew and the reason the attachments bar has nowhere else to
328 /// go.
329 ///
330 /// The verbs are not a form's submit: every field writes as it settles, so
331 /// these act on a draft that is already saved. See the module header.
332 shape screen(loaded: &Loaded) -> Screen;
333
334 screen list_detail "Compose" false {
335 at_place super::shell::EMAILS;
336
337 region BODY as Pane {
338 page heading(loaded);
339
340 field Select FROM "From" {
341 options loaded.accounts.clone();
342 for from in from_value(loaded).into_iter() {
343 value from;
344 }
345 writes field_route(loaded.id, FROM);
346 }
347
348 field Text TO "To" {
349 value loaded.to.clone();
350 writes field_route(loaded.id, TO);
351 }
352
353 field Text CC "Cc" {
354 value loaded.cc.clone();
355 writes field_route(loaded.id, CC);
356 }
357
358 field Text BCC "Bcc" {
359 value loaded.bcc.clone();
360 writes field_route(loaded.id, BCC);
361 }
362
363 field Text SUBJECT "Subject" {
364 value loaded.subject.clone();
365 writes field_route(loaded.id, SUBJECT);
366 }
367
368 extend attached(loaded);
369
370 field Textarea MESSAGE "Message" {
371 value loaded.body.clone();
372 writes field_route(loaded.id, MESSAGE);
373 }
374
375 text "This message is in the Out box. Take it back to edit it."
376 when loaded.queued;
377 act "Take it back" to post "/compose/{loaded.id}/unqueue" when loaded.queued;
378
379 act "Queue" to post "/compose/{loaded.id}/queue" unless loaded.queued {
380 tone Success;
381 }
382
383 act "Queue later" to post "/compose/{loaded.id}/queue" unless loaded.queued {
384 asking Field::new(FieldKind::DateTime, SEND_AFTER, "Send after");
385 }
386 }
387
388 // The same address this screen is already at, put up in a mount of its
389 // own. `Action::elsewhere` is the whole of it: this screen does not know
390 // which window it is in, and that is what makes one description serve
391 // both. See the module header.
392 region "compose-aside" as Pane {
393 act "Open in a window" to get "/compose/{loaded.id}" elsewhere;
394 act "Discard" to post "/compose/{loaded.id}/discard" {
395 tone Danger;
396 confirm "Throw this message away?";
397 }
398 }
399 }
400 }
401
402 /// A file size in words. `data::size` says the same thing and is private to it.
403 fn size(bytes: i64) -> String {
404 let bytes = bytes.max(0);
405 if bytes < 1024 {
406 return format!("{bytes} bytes");
407 }
408 let units = ["KB", "MB", "GB"];
409 let mut value = bytes as f64 / 1024.0;
410 let mut unit = 0;
411 while value >= 1024.0 && unit < units.len() - 1 {
412 value /= 1024.0;
413 unit += 1;
414 }
415 format!("{value:.1} {}", units[unit])
416 }
417
418 /// Attach a file the host picked.
419 fn attach(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
420 let id = asked_for(&request)?;
421 let picked = request
422 .payload
423 .get("file")
424 .unwrap_or_default()
425 .trim()
426 .to_owned();
427 if picked.is_empty() {
428 return Err(RouteError::conflict("No file was picked.").as_toast());
429 }
430
431 match crate::commands::attachment::attach_path(state, None, None, Some(id), &picked) {
432 Ok(file) => Ok(Response::screen(screen(&read(state, id)?))
433 .toast(Tone::Success, format!("Attached {}.", file.filename))),
434 // Ours is a fault; everything else is the person's to fix by picking a
435 // different file, so it is said and the screen stays put. Same split
436 // the project dashboard's attach makes.
437 Err(crate::commands::attachment::AttachFailure::Failed(message)) => {
438 Err(RouteError::internal(message))
439 }
440 Err(failure) => Err(RouteError::conflict(failure.message()).as_toast()),
441 }
442 }
443
444 /// Take a file off the message.
445 fn detach(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
446 let id = asked_for(&request)?;
447 let file = request
448 .captures
449 .get("file")
450 .unwrap_or_default()
451 .parse::<uuid::Uuid>()
452 .map(goingson_core::AttachmentId::from)
453 .map_err(|_| RouteError::not_found("not an attachment id"))?;
454
455 state
456 .attachments
457 .delete(file, DESKTOP_USER_ID)
458 .map_err(|error| RouteError::internal(error.to_string()))?;
459 // The blob stays until `blob_gc` sees nothing references it, which is what
460 // makes removing a file a row delete rather than a disk operation.
461 Ok(Response::screen(screen(&read(state, id)?)).toast(Tone::Success, "Taken off."))
462 }
463
464 /// The screen, as an answer.
465 fn show(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
466 Ok(screen(&read(state, asked_for(&request)?)?).into())
467 }
468
469 /// The id in the address.
470 fn asked_for(request: &quasi_router::Request) -> Result<EmailId, RouteError> {
471 request
472 .captures
473 .get("id")
474 .unwrap_or_default()
475 .parse::<uuid::Uuid>()
476 .map(Into::into)
477 .map_err(|_| RouteError::not_found("not a message id"))
478 }
479
480 /// One field, written because it settled.
481 fn write_field(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
482 let id = asked_for(&request)?;
483 // Read from either, the way `time_tracking::View::of` does: a control's own
484 // params and what a caller sends arrive in different halves of the request,
485 // and which half is the renderer's business rather than this handler's.
486 let which = request
487 .payload
488 .get("field")
489 .or_else(|| request.carried.get("field"))
490 .unwrap_or_default()
491 .to_owned();
492 let value = request.payload.get(&which).unwrap_or_default().to_owned();
493
494 let mut fields = Fields::default();
495 match which.as_str() {
496 FROM => {
497 fields.from = value.parse::<uuid::Uuid>().ok().map(Into::into);
498 }
499 TO => fields.to = Some(value),
500 CC => fields.cc = Some(value),
501 BCC => fields.bcc = Some(value),
502 SUBJECT => fields.subject = Some(value),
503 MESSAGE => fields.body = Some(value),
504 // A name this screen does not draw. Refused rather than ignored: it did
505 // not come from a control here.
506 _ => return Err(RouteError::not_found("no such field")),
507 }
508
509 save(state, id, fields)?;
510 // Nothing is answered back. The value is already on screen, it is what the
511 // reader typed, and replacing the field under a caret is the failure
512 // quasicoherent `a135f898` records.
513 // Nothing is answered back into the field. The value is already on screen,
514 // it is what the reader typed, and replacing a control under a caret is the
515 // failure quasicoherent `a135f898` records. The empty region is the
516 // acknowledgement.
517 Ok(Response::fragment(SAVED, Node::text("")))
518 }
519
520 /// Put the message in the Out box.
521 fn queue(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
522 let id = asked_for(&request)?;
523 let send_after = read_instant(&request)?;
524
525 // Refused here rather than left to fail once a minute in the drainer: a
526 // message with nobody to send to, or nothing to send it from, is a mistake
527 // to catch while the person is still looking at it.
528 let draft = state
529 .emails
530 .get_by_id(id, DESKTOP_USER_ID)
531 .map_err(|error| RouteError::internal(error.to_string()))?
532 .filter(|email| email.is_draft)
533 .ok_or_else(|| RouteError::not_found("no such draft"))?;
534
535 if draft.draft_account_id.is_none() {
536 return Err(
537 RouteError::conflict("Choose which account this is from before queueing it.")
538 .as_toast(),
539 );
540 }
541 if draft.to.trim().is_empty() {
542 return Err(RouteError::conflict("Say who it is going to.").as_toast());
543 }
544
545 // The total is checked here rather than at send time, which is where
546 // `send.rs` checks it. Under an outbox that refusal would land minutes or
547 // hours after the person walked away, as a `send_error` on a row nobody is
548 // looking at; said now, it is something they can act on.
549 let files = state
550 .attachments
551 .list_for_email(id, DESKTOP_USER_ID)
552 .map_err(|error| RouteError::internal(error.to_string()))?;
553 let total: i64 = files.iter().map(|file| file.file_size.max(0)).sum();
554 if total > MAX_TOTAL_ATTACHMENT_BYTES {
555 return Err(RouteError::conflict(format!(
556 "The files come to {}, over the {} MB a message can carry.",
557 size(total),
558 MAX_TOTAL_ATTACHMENT_BYTES / (1024 * 1024)
559 ))
560 .as_toast());
561 }
562
563 state
564 .emails
565 .queue_draft(id, DESKTOP_USER_ID, send_after)
566 .map_err(|error| RouteError::internal(error.to_string()))?
567 .ok_or_else(|| RouteError::not_found("no such draft"))?;
568
569 Ok(Response::goto(Action::get("/outbox")).toast(
570 Tone::Success,
571 match send_after {
572 Some(at) => format!(
573 "In the Out box, going after {}.",
574 at.format("%b %-d, %H:%M")
575 ),
576 None => "In the Out box.".to_owned(),
577 },
578 ))
579 }
580
581 /// The instant Queue Later asked for, if it asked.
582 fn read_instant(request: &quasi_router::Request) -> Result<Option<DateTime<Utc>>, RouteError> {
583 let raw = request.payload.get(SEND_AFTER).unwrap_or_default();
584 if raw.trim().is_empty() {
585 return Ok(None);
586 }
587 // A local wall-clock instant, which is what a datetime control gives and
588 // what the person meant. Stored as UTC, compared against UTC by the
589 // drainer.
590 chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M")
591 .ok()
592 .and_then(|naive| naive.and_local_timezone(chrono::Local).single())
593 .map(|local| local.with_timezone(&Utc))
594 .map(Some)
595 .ok_or_else(|| RouteError::conflict("That is not a time this understands.").as_toast())
596 }
597
598 /// Take a message back out of the Out box.
599 fn unqueue(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
600 let id = asked_for(&request)?;
601 state
602 .emails
603 .unqueue_draft(id, DESKTOP_USER_ID)
604 .map_err(|error| RouteError::internal(error.to_string()))?
605 .ok_or_else(|| RouteError::not_found("not in the Out box"))?;
606 Ok(Response::screen(screen(&read(state, id)?)).toast(
607 Tone::Success,
608 "Taken back. It will not go until you queue it.",
609 ))
610 }
611
612 /// Throw the message away.
613 fn discard(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
614 let id = asked_for(&request)?;
615 state
616 .emails
617 .delete(id, DESKTOP_USER_ID)
618 .map_err(|error| RouteError::internal(error.to_string()))?;
619 Ok(Response::goto(Action::get("/emails")).toast(Tone::Success, "Thrown away."))
620 }
621
622 /// What a waiting message is called.
623 fn waiting_subject(email: &goingson_core::Email) -> &str {
624 if email.subject.is_empty() {
625 "(no subject)"
626 } else {
627 &email.subject
628 }
629 }
630
631 /// Whether the drainer has given up on it for now.
632 fn is_stuck(email: &goingson_core::Email) -> bool {
633 email.send_error.is_some()
634 }
635
636 /// Why it is stuck, or nothing. R9: read whether or not it is placed.
637 fn stuck_reason(email: &goingson_core::Email) -> &str {
638 email.send_error.as_deref().unwrap_or_default()
639 }
640
641 /// When it goes.
642 ///
643 /// A stuck message's timing is not the interesting fact about it, which is why
644 /// this and [`stuck_reason`] are the two halves of one `meta`.
645 fn when_going(email: &goingson_core::Email) -> String {
646 match email.send_after {
647 Some(at) => format!("after {}", at.format("%b %-d, %H:%M")),
648 None => "next pass".to_owned(),
649 }
650 }
651
652 declare! {
653 /// The Out box: what is waiting, when it goes, and why one is stuck.
654 ///
655 /// One part per role: `meta` sets rather than appends, so the reason and
656 /// the timing are one fact said two ways rather than two facts, and the
657 /// guards are what pick between them.
658 shape outbox_screen(waiting: &[goingson_core::Email]) -> Screen;
659
660 screen list_detail "Out box" false {
661 at_place super::shell::OUTBOX;
662
663 region "outbox" as Pane {
664 page "Out box";
665
666 empty "Nothing waiting to go." when waiting.is_empty();
667
668 list {
669 for email in waiting.iter() {
670 row waiting_subject(email) {
671 secondary email.to.clone();
672 token Tag::badge("Stuck after {email.send_attempts}").tone(Tone::Danger)
673 when is_stuck(email);
674 meta stuck_reason(email) when is_stuck(email);
675 meta when_going(email) unless is_stuck(email);
676 act "Take it back" to post "/compose/{email.id}/unqueue";
677 activate to get "/compose/{email.id}";
678 }
679 }
680 } unless waiting.is_empty();
681 }
682 }
683 }
684
685 /// The Out box, as an answer.
686 fn outbox(state: &AppState, _request: quasi_router::Request) -> Result<Response, RouteError> {
687 let waiting = state
688 .emails
689 .list_outbox(DESKTOP_USER_ID)
690 .map_err(|error| RouteError::internal(error.to_string()))?;
691 Ok(outbox_screen(&waiting).into())
692 }
693
694 /// This module's routes.
695 #[must_use]
696 pub fn routes(router: Router<AppState>) -> Router<AppState> {
697 router
698 .post("/compose", start)
699 .get("/outbox", outbox)
700 .get("/compose/{id}", show)
701 .post("/compose/{id}/field", write_field)
702 .post("/compose/{id}/queue", queue)
703 .post("/compose/{id}/unqueue", unqueue)
704 .post("/compose/{id}/attach", attach)
705 .post("/compose/{id}/detach/{file}", detach)
706 .post("/compose/{id}/discard", discard)
707 }
708