//! Writing a message, and the Out box it goes to. //! //! //! //! Eudora-shaped, and three of that design's choices are load-bearing here. //! //! # The header is a grid, and `Attached:` is a row of it //! //! A right-aligned label column with the fields beside it: From, To, Cc, Bcc, //! Subject, Attached. The attachments bar is a header row, so there is one //! arrangement and nowhere else to put it. Cc and Bcc are never hidden: nothing //! in the vocabulary names "hidden until asked for", and if that is ever wanted //! the answer is an address, the way [`super::settings::email`] says its //! advanced block with `?advanced=1`. //! //! # Queue, not Send, and that is the whole reason this screen exists //! //! `send_email` is async and a route handler is synchronous, so a described //! compose cannot send. You Queue, and something else drains the Out box. //! Queueing is a local write. //! //! So the outbox is the feature: a message you can see before it goes, //! schedule, and take back. See [`crate::outbox`] for the drainer. //! //! # The draft saves as you write it, which is why there is no Save button //! //! Every field here carries [`Field::writes`], so leaving a field writes it to //! the draft. That is also what gets this screen past a known gap: a //! [`Node::Form`] has one submit, and compose wants three verbs over one set of //! values. It does not need them once the values are already saved. Queue and //! Discard act on a draft that is already on disk, and there is no form at all. //! //! `Field::writes` is the DOM `change` event in a webview, which fires when a //! field settles rather than per keystroke. On a terminal it is per keystroke, //! which is that renderer's to answer; a draft written more often than //! necessary is the harmless end of it. //! //! # A new message is a POST //! //! `GET /compose` would have to create a draft to have somewhere to write to, //! and a GET that writes is a GET that a reload repeats. So `POST /compose` //! makes the draft and goes to it, and the screen itself is //! `GET /compose/{id}`: an address that can be reloaded, linked and reopened. //! //! An abandoned blank draft is the cost: an empty message in Out that you throw //! away. Discard is one press. //! //! # Attachments are blobs, because a queued message outlives a path //! //! Attaching is [`Action::by_host`], like every other file pick in this app: //! `frontend/js/host.js` opens the dialog and posts the path, and //! `attach_path` hashes the bytes into the content-addressed blob store and //! writes a row against this draft. //! //! What the drainer sends is the blob, not the path. A message queued at nine //! may go at five, and the file it was written from can be moved, renamed or //! deleted in between. A blob cannot, and `blob_gc` keeps it alive while a row //! references it. The recipient sees the row's `filename`, since a blob is //! named by its hash. //! //! The total size is checked here as well as at send time in `send.rs`. Under //! an outbox the send-time refusal lands as a `send_error` on a row nobody is //! looking at, minutes or hours after the person walked away. Both checks stay: //! the send path is reached by more than this screen. //! //! # A window of its own, and one description in both //! //! [`super::compose_protocol`] is a second scheme serving **this same screen** //! with no app chrome and a frame that reports what happened. Nothing here is //! conditional on which window it is in, and nothing should be: a screen that //! knew would be two code paths. //! //! Asking for the window is [`Action::elsewhere`], rather than a bespoke branch //! in `host.js` or a menu item, because both of those put the second window //! outside the description where no other renderer could ever have one. //! //! The frame offers no verbs. Queue, Queue later, Discard and Take it back are //! on the screen, so both mounts get them from one place, and a frame carrying //! them too would draw each one twice here. // Handlers take their request by value because `quasi_router::Handler` is a // plain `fn(&S, Request)` pointer, so the signature is the router's. #![allow(clippy::needless_pass_by_value)] use chrono::{DateTime, Utc}; use goingson_core::EmailId; use quasi_declare::declare; use quasi_router::layout::{FieldKind, Tone}; use quasi_router::screen::{Choice, Field, Tag}; use quasi_router::{Action, Node, Response, RouteError, Router}; use crate::state::{AppState, DESKTOP_USER_ID}; #[cfg(test)] mod tests; /// The region the header and body live in. const BODY: &str = "compose"; /// Where a saved field's acknowledgement lands. /// /// Its own region rather than the header's, because answering into the header /// would redraw the control the reader is still in. const SAVED: &str = "compose-saved"; /// The field names, which are also what a write reads out of the payload. const FROM: &str = "from"; const TO: &str = "to"; const CC: &str = "cc"; const BCC: &str = "bcc"; const SUBJECT: &str = "subject"; const MESSAGE: &str = "body"; /// The instant Queue Later asks for. const SEND_AFTER: &str = "send_after"; /// What a message's files may come to, together. /// /// The same number `commands::email::send` enforces, stated here because this /// is where it can still be said to somebody. Kept as two checks rather than /// one: the send path is reached by more than this screen, and a limit that /// only the screen enforced would be a limit the drainer could walk past. const MAX_TOTAL_ATTACHMENT_BYTES: i64 = 25 * 1024 * 1024; /// A new message: make the draft, then go to it. fn start(state: &AppState, _request: quasi_router::Request) -> Result { let draft = save(state, EmailId::new(), Fields::default())?; Ok(Response::goto(Action::get(format!( "/compose/{}", draft.id )))) } /// What the screen writes, all of it optional so one field can be saved alone. #[derive(Default)] struct Fields { from: Option, to: Option, cc: Option, bcc: Option, subject: Option, body: Option, } /// Write a draft, keeping whatever this call did not name. /// /// `save_draft` replaces rather than patches, so a single-field write has to /// read the row first or it blanks the other five. That is the repository's /// shape and not worth changing for this: a draft is small and the read is /// local. fn save(state: &AppState, id: EmailId, fields: Fields) -> Result { let existing = state .emails .get_by_id(id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; let held = existing.as_ref(); let account = fields .from .map(|id| goingson_core::EmailAccountId::from(uuid::Uuid::from(id))) .or_else(|| held.and_then(|email| email.draft_account_id)); let from = match account { Some(id) => state .email_accounts .get_by_id(id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))? .map(|account| account.email_address) .unwrap_or_default(), None => held.map(|email| email.from.clone()).unwrap_or_default(), }; let pick = |given: Option, kept: Option| given.or(kept).unwrap_or_default(); state .emails .save_draft( id, DESKTOP_USER_ID, &from, &pick(fields.to, held.map(|email| email.to.clone())), Some(&pick( fields.cc, held.and_then(|email| email.cc_address.clone()), )), Some(&pick( fields.bcc, held.and_then(|email| email.bcc_address.clone()), )), &pick(fields.subject, held.map(|email| email.subject.clone())), &pick(fields.body, held.map(|email| email.body.clone())), account, held.and_then(|email| email.in_reply_to.clone()).as_deref(), None, held.and_then(|email| email.thread_id.clone()).as_deref(), ) .map_err(|error| RouteError::internal(error.to_string())) } /// Everything the screen draws, read once. /// /// The reads are the handler's: the draft, the accounts and the attachments are /// three queries, and two of the three used to happen inside a shape. A /// description says what is on the screen. struct Loaded { id: EmailId, subject: String, to: String, cc: String, bcc: String, body: String, /// Which account it is from, if one has been chosen. from: Option, /// The accounts there are to choose between. accounts: Vec, /// In the Out box, so read-only until it is taken back. queued: bool, files: Vec, } /// Read it. fn read(state: &AppState, id: EmailId) -> Result { let draft = state .emails .get_by_id(id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))? .filter(|email| email.is_draft) .ok_or_else(|| RouteError::not_found("no such draft"))?; let accounts = state .email_accounts .list_by_user(DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))? .into_iter() .map(|account| Choice::new(account.id.to_string(), account.email_address)) .collect(); let files = state .attachments .list_for_email(id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; Ok(Loaded { id, queued: draft.is_queued(), subject: draft.subject, to: draft.to, cc: draft.cc_address.unwrap_or_default(), bcc: draft.bcc_address.unwrap_or_default(), body: draft.body, from: draft.draft_account_id.map(|id| id.to_string()), accounts, files, }) } /// The route one field writes to, carrying which field it is. fn field_route(id: EmailId, name: &str) -> Action { Action::post(format!("/compose/{id}/field")).with("field", name) } /// What the page is called before it has a subject. fn heading(loaded: &Loaded) -> &str { if loaded.subject.is_empty() { "New message" } else { &loaded.subject } } /// The account the message is sent from, once one is chosen. fn from_value(loaded: &Loaded) -> Option<&str> { loaded.from.as_deref() } /// Whether the message carries nothing. fn no_files(loaded: &Loaded) -> bool { loaded.files.is_empty() } declare! { /// The `Attached:` rows, and the way to add one. /// /// Drawn even when empty, because it is a header row rather than a bar that /// appears: the point of Eudora's shape is that the message says what it /// carries in the same place every time. /// /// Attaching is [`Action::by_host`], the same as the imports and the /// project dashboard: picking a file is not describable, and /// `frontend/js/host.js` opens the dialog and posts the path back. What /// lands is a row against this draft with the bytes in the /// content-addressed blob store, which is what makes an attachment survive /// until the outbox drains. /// /// A queued message's files are listed and not removable: the drainer may /// be reading them. shape attached(loaded: &Loaded) -> Vec; list { row "Attached" when no_files(loaded) { meta "Nothing"; } for file in loaded.files.iter() { row "Attached" { secondary file.filename.clone(); meta size(file.file_size); act "Remove" to post "/compose/{loaded.id}/detach/{file.id}" unless loaded.queued { tone Danger; } } } } act "Attach a file" to post "/compose/{loaded.id}/attach" by_host awaiting unless loaded.queued; } declare! { /// The screen. /// /// The header grid is in Eudora's order. From first because it is the one /// choice rather than a thing typed, and because a message with no account /// cannot leave the outbox. Cc and Bcc are always drawn; Eudora never hid /// them, which is why this screen needs no word for progressive disclosure. /// /// Attached is the last header row and the body goes under it, which is the /// order Eudora drew and the reason the attachments bar has nowhere else to /// go. /// /// The verbs are not a form's submit: every field writes as it settles, so /// these act on a draft that is already saved. See the module header. shape screen(loaded: &Loaded) -> Screen; screen list_detail "Compose" false { at_place super::shell::EMAILS; region BODY as Pane { page heading(loaded); field Select FROM "From" { options loaded.accounts.clone(); for from in from_value(loaded).into_iter() { value from; } writes field_route(loaded.id, FROM); } field Text TO "To" { value loaded.to.clone(); writes field_route(loaded.id, TO); } field Text CC "Cc" { value loaded.cc.clone(); writes field_route(loaded.id, CC); } field Text BCC "Bcc" { value loaded.bcc.clone(); writes field_route(loaded.id, BCC); } field Text SUBJECT "Subject" { value loaded.subject.clone(); writes field_route(loaded.id, SUBJECT); } extend attached(loaded); field Textarea MESSAGE "Message" { value loaded.body.clone(); writes field_route(loaded.id, MESSAGE); } text "This message is in the Out box. Take it back to edit it." when loaded.queued; act "Take it back" to post "/compose/{loaded.id}/unqueue" when loaded.queued; act "Queue" to post "/compose/{loaded.id}/queue" unless loaded.queued { tone Success; } act "Queue later" to post "/compose/{loaded.id}/queue" unless loaded.queued { asking Field::new(FieldKind::DateTime, SEND_AFTER, "Send after"); } } // The same address this screen is already at, put up in a mount of its // own. `Action::elsewhere` is the whole of it: this screen does not know // which window it is in, and that is what makes one description serve // both. See the module header. region "compose-aside" as Pane { act "Open in a window" to get "/compose/{loaded.id}" elsewhere; act "Discard" to post "/compose/{loaded.id}/discard" { tone Danger; confirm "Throw this message away?"; } } } } /// A file size in words. `data::size` says the same thing and is private to it. fn size(bytes: i64) -> String { let bytes = bytes.max(0); if bytes < 1024 { return format!("{bytes} bytes"); } let units = ["KB", "MB", "GB"]; let mut value = bytes as f64 / 1024.0; let mut unit = 0; while value >= 1024.0 && unit < units.len() - 1 { value /= 1024.0; unit += 1; } format!("{value:.1} {}", units[unit]) } /// Attach a file the host picked. fn attach(state: &AppState, request: quasi_router::Request) -> Result { let id = asked_for(&request)?; let picked = request .payload .get("file") .unwrap_or_default() .trim() .to_owned(); if picked.is_empty() { return Err(RouteError::conflict("No file was picked.").as_toast()); } match crate::commands::attachment::attach_path(state, None, None, Some(id), &picked) { Ok(file) => Ok(Response::screen(screen(&read(state, id)?)) .toast(Tone::Success, format!("Attached {}.", file.filename))), // Ours is a fault; everything else is the person's to fix by picking a // different file, so it is said and the screen stays put. Same split // the project dashboard's attach makes. Err(crate::commands::attachment::AttachFailure::Failed(message)) => { Err(RouteError::internal(message)) } Err(failure) => Err(RouteError::conflict(failure.message()).as_toast()), } } /// Take a file off the message. fn detach(state: &AppState, request: quasi_router::Request) -> Result { let id = asked_for(&request)?; let file = request .captures .get("file") .unwrap_or_default() .parse::() .map(goingson_core::AttachmentId::from) .map_err(|_| RouteError::not_found("not an attachment id"))?; state .attachments .delete(file, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; // The blob stays until `blob_gc` sees nothing references it, which is what // makes removing a file a row delete rather than a disk operation. Ok(Response::screen(screen(&read(state, id)?)).toast(Tone::Success, "Taken off.")) } /// The screen, as an answer. fn show(state: &AppState, request: quasi_router::Request) -> Result { Ok(screen(&read(state, asked_for(&request)?)?).into()) } /// The id in the address. fn asked_for(request: &quasi_router::Request) -> Result { request .captures .get("id") .unwrap_or_default() .parse::() .map(Into::into) .map_err(|_| RouteError::not_found("not a message id")) } /// One field, written because it settled. fn write_field(state: &AppState, request: quasi_router::Request) -> Result { let id = asked_for(&request)?; // Read from either, the way `time_tracking::View::of` does: a control's own // params and what a caller sends arrive in different halves of the request, // and which half is the renderer's business rather than this handler's. let which = request .payload .get("field") .or_else(|| request.carried.get("field")) .unwrap_or_default() .to_owned(); let value = request.payload.get(&which).unwrap_or_default().to_owned(); let mut fields = Fields::default(); match which.as_str() { FROM => { fields.from = value.parse::().ok().map(Into::into); } TO => fields.to = Some(value), CC => fields.cc = Some(value), BCC => fields.bcc = Some(value), SUBJECT => fields.subject = Some(value), MESSAGE => fields.body = Some(value), // A name this screen does not draw. Refused rather than ignored: it did // not come from a control here. _ => return Err(RouteError::not_found("no such field")), } save(state, id, fields)?; // Nothing is answered back. The value is already on screen, it is what the // reader typed, and replacing the field under a caret is the failure // quasicoherent `a135f898` records. // Nothing is answered back into the field. The value is already on screen, // it is what the reader typed, and replacing a control under a caret is the // failure quasicoherent `a135f898` records. The empty region is the // acknowledgement. Ok(Response::fragment(SAVED, Node::text(""))) } /// Put the message in the Out box. fn queue(state: &AppState, request: quasi_router::Request) -> Result { let id = asked_for(&request)?; let send_after = read_instant(&request)?; // Refused here rather than left to fail once a minute in the drainer: a // message with nobody to send to, or nothing to send it from, is a mistake // to catch while the person is still looking at it. let draft = state .emails .get_by_id(id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))? .filter(|email| email.is_draft) .ok_or_else(|| RouteError::not_found("no such draft"))?; if draft.draft_account_id.is_none() { return Err( RouteError::conflict("Choose which account this is from before queueing it.") .as_toast(), ); } if draft.to.trim().is_empty() { return Err(RouteError::conflict("Say who it is going to.").as_toast()); } // The total is checked here rather than at send time, which is where // `send.rs` checks it. Under an outbox that refusal would land minutes or // hours after the person walked away, as a `send_error` on a row nobody is // looking at; said now, it is something they can act on. let files = state .attachments .list_for_email(id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; let total: i64 = files.iter().map(|file| file.file_size.max(0)).sum(); if total > MAX_TOTAL_ATTACHMENT_BYTES { return Err(RouteError::conflict(format!( "The files come to {}, over the {} MB a message can carry.", size(total), MAX_TOTAL_ATTACHMENT_BYTES / (1024 * 1024) )) .as_toast()); } state .emails .queue_draft(id, DESKTOP_USER_ID, send_after) .map_err(|error| RouteError::internal(error.to_string()))? .ok_or_else(|| RouteError::not_found("no such draft"))?; Ok(Response::goto(Action::get("/outbox")).toast( Tone::Success, match send_after { Some(at) => format!( "In the Out box, going after {}.", at.format("%b %-d, %H:%M") ), None => "In the Out box.".to_owned(), }, )) } /// The instant Queue Later asked for, if it asked. fn read_instant(request: &quasi_router::Request) -> Result>, RouteError> { let raw = request.payload.get(SEND_AFTER).unwrap_or_default(); if raw.trim().is_empty() { return Ok(None); } // A local wall-clock instant, which is what a datetime control gives and // what the person meant. Stored as UTC, compared against UTC by the // drainer. chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M") .ok() .and_then(|naive| naive.and_local_timezone(chrono::Local).single()) .map(|local| local.with_timezone(&Utc)) .map(Some) .ok_or_else(|| RouteError::conflict("That is not a time this understands.").as_toast()) } /// Take a message back out of the Out box. fn unqueue(state: &AppState, request: quasi_router::Request) -> Result { let id = asked_for(&request)?; state .emails .unqueue_draft(id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))? .ok_or_else(|| RouteError::not_found("not in the Out box"))?; Ok(Response::screen(screen(&read(state, id)?)).toast( Tone::Success, "Taken back. It will not go until you queue it.", )) } /// Throw the message away. fn discard(state: &AppState, request: quasi_router::Request) -> Result { let id = asked_for(&request)?; state .emails .delete(id, DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; Ok(Response::goto(Action::get("/emails")).toast(Tone::Success, "Thrown away.")) } /// What a waiting message is called. fn waiting_subject(email: &goingson_core::Email) -> &str { if email.subject.is_empty() { "(no subject)" } else { &email.subject } } /// Whether the drainer has given up on it for now. fn is_stuck(email: &goingson_core::Email) -> bool { email.send_error.is_some() } /// Why it is stuck, or nothing. R9: read whether or not it is placed. fn stuck_reason(email: &goingson_core::Email) -> &str { email.send_error.as_deref().unwrap_or_default() } /// When it goes. /// /// A stuck message's timing is not the interesting fact about it, which is why /// this and [`stuck_reason`] are the two halves of one `meta`. fn when_going(email: &goingson_core::Email) -> String { match email.send_after { Some(at) => format!("after {}", at.format("%b %-d, %H:%M")), None => "next pass".to_owned(), } } declare! { /// The Out box: what is waiting, when it goes, and why one is stuck. /// /// One part per role: `meta` sets rather than appends, so the reason and /// the timing are one fact said two ways rather than two facts, and the /// guards are what pick between them. shape outbox_screen(waiting: &[goingson_core::Email]) -> Screen; screen list_detail "Out box" false { at_place super::shell::OUTBOX; region "outbox" as Pane { page "Out box"; empty "Nothing waiting to go." when waiting.is_empty(); list { for email in waiting.iter() { row waiting_subject(email) { secondary email.to.clone(); token Tag::badge("Stuck after {email.send_attempts}").tone(Tone::Danger) when is_stuck(email); meta stuck_reason(email) when is_stuck(email); meta when_going(email) unless is_stuck(email); act "Take it back" to post "/compose/{email.id}/unqueue"; activate to get "/compose/{email.id}"; } } } unless waiting.is_empty(); } } } /// The Out box, as an answer. fn outbox(state: &AppState, _request: quasi_router::Request) -> Result { let waiting = state .emails .list_outbox(DESKTOP_USER_ID) .map_err(|error| RouteError::internal(error.to_string()))?; Ok(outbox_screen(&waiting).into()) } /// This module's routes. #[must_use] pub fn routes(router: Router) -> Router { router .post("/compose", start) .get("/outbox", outbox) .get("/compose/{id}", show) .post("/compose/{id}/field", write_field) .post("/compose/{id}/queue", queue) .post("/compose/{id}/unqueue", unqueue) .post("/compose/{id}/attach", attach) .post("/compose/{id}/detach/{file}", detach) .post("/compose/{id}/discard", discard) }