Skip to main content

max / goingson

Compose, Eudora-shaped, and the Out box it goes to Max, 2026-08-22: "let's have the design of the compose be inspired by Eudora." Three of that design's choices are load-bearing, and each removes a problem the port had rather than adding a look. THE HEADER IS A GRID and Attached: is a row of it. 3fb2526a recorded five ways the modal and the standalone window diverged; two stop existing under this shape. The attachments bar sat inside the form in one and outside it in the other, and as a header row there is nowhere else to put it. And Eudora never hid Cc and Bcc, so toggleCcBcc goes, and with it the "nothing names progressive disclosure" gap the task listed as a blocker. QUEUE, NOT SEND, which is why this screen can exist at all. send_email is async and a route handler is synchronous, so a described compose could not send. Eudora's answer was already the right one and it landed yesterday as the outbox (a3c76a24): queueing is a local write, and what drains the queue is not a screen. THE DRAFT SAVES AS IT IS WRITTEN, which is why there is no Save button and no form. quasicoherent 7da72faf records that a Node::Form has one submit, and compose wanted three verbs over one set of values; it does not, once the values are already on disk. Field::changes is the DOM change event in a webview, so a field writes when it settles rather than per keystroke, which is what a mail client does anyway. Queue and Discard then act on a thing that exists. That is three of the four gaps 3fb2526a named, closed by the design rather than by growing the vocabulary. The fourth, attachments, is the one still open. A new message is POST /compose rather than GET, because a GET that writes is a GET a reload repeats. It goes to /compose/{id}, an address that can be reloaded, linked and reopened. An abandoned blank draft is the cost and it was Eudora's too. Queueing refuses a message with no account or no recipient while the person is still looking at it, rather than letting it fail once a minute in the drainer. Queue Later asks for an instant, which is the whole of send-later. THE OUT BOX is a place, at /outbox, beside Email in the nav the way Eudora put Out beside In. It says what is waiting, when each goes, and why one is stuck, with the attempt count in the badge: one failure is a server having a moment, twenty is something that will never work. A queued message opens read-only with Take it back rather than being edited underneath the drainer. WHAT IS NOT HERE. Attachments: Attached: is drawn and says what the message carries, and nothing adds one, because send_email_inner takes paths on this machine and a described file field carries bytes. And a window of its own, which is a second mount carrying a Frame whose verbs are these verbs; this app builds one Webview. Both are the remainder of 3fb2526a. Row::meta sets rather than appends, so the outbox row uses one part per role rather than three metas of which two are discarded. Nine tests. 792 total.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 18:57 UTC
Signed with PGP, not checked
Commit: f8eab8d0e92e5d3c1738a2e8acef0c9fe444b694
Parent: 8b6f45a
5 files changed, +862 insertions, -6 deletions
M Cargo.lock +4 -4
@@ -8493,10 +8493,6 @@
8493 8493 "winnow 1.0.4",
8494 8494 ]
8495 8495
8496 - [[patch.unused]]
8497 - name = "ops-status"
8498 - version = "0.1.0"
8499 -
8500 8496 [[patch.unused]]
8501 8497 name = "quasi-axum"
8502 8498 version = "0.54.0"
@@ -8512,3 +8508,7 @@
8512 8508 [[patch.unused]]
8513 8509 name = "quasi-store"
8514 8510 version = "0.1.0"
8511 +
8512 + [[patch.unused]]
8513 + name = "ops-status"
8514 + version = "0.1.0"
@@ -50,7 +50,7 @@
50 50 //!
51 51 //! | Gone | Comes back when |
52 52 //! |---|---|
53 - //! | Compose, reply, forward | `3fb2526a`, Max's call on the shape |
53 + //! | ~~Compose~~ | back 2026-08-22, Eudora-shaped and queueing rather than sending. See [`compose`] |
54 54 //! | ~~Settings > Sync~~ | back 2026-08-22: measured per command, its reads are local. See [`settings::sync`] |
55 55 //! | Settings > Sharing | its reads are remote, so there is no local state to draw a section from. quasicoherent `82273265` |
56 56 //! | ~~Settings > About~~ | back 2026-08-22: `AppState` holds the version and the platform now, which is what `theme_dirs` did for the theme path. See [`settings::about`] |
@@ -91,6 +91,7 @@
91 91
92 92 pub mod assets;
93 93 pub mod board;
94 + pub mod compose;
94 95 pub mod contacts;
95 96 pub mod data;
96 97 pub mod day_planning;
@@ -309,6 +310,7 @@
309 310 let router = data::routes(router);
310 311 let router = time_tracking::routes(router);
311 312 let router = emails::routes(router);
313 + let router = compose::routes(router);
312 314 events::routes(router)
313 315 }
314 316
@@ -92,8 +92,10 @@
92 92 pub const TIMER: &str = "timer";
93 93 /// The calendar.
94 94 pub const EVENTS: &str = "events";
95 - /// Mail.
95 + /// Mail, and the Out box and compose screens reached from it.
96 96 pub const EMAILS: &str = "emails";
97 + /// The Out box.
98 + pub const OUTBOX: &str = "outbox";
97 99 /// Contacts, and the contact dashboard behind it.
98 100 pub const CONTACTS: &str = "contacts";
99 101 /// Settings, and Import & Export, which its sidebar navigates to.
@@ -120,6 +122,9 @@
120 122 ]),
121 123 Place::new("messages", "Messages", Action::get("/emails")).within([
122 124 Place::new(EMAILS, "Email", Action::get("/emails")),
125 + // Eudora put Out beside In, and this app's outbox is a real place
126 + // rather than a state a message is in: goingson `a3c76a24`.
127 + Place::new(OUTBOX, "Out box", Action::get("/outbox")),
123 128 Place::new(CONTACTS, "Contacts", Action::get("/contacts")),
124 129 ]),
125 130 // No sub-places, so it draws as a place of its own. The shipped header
@@ -1,0 +1,503 @@
1 + //! Writing a message, and the Out box it goes to.
2 + //!
3 + //! <!-- wiki: quasi-overview -->
4 + //!
5 + //! Max, 2026-08-22: "let's have the design of the compose be inspired by
6 + //! Eudora." Three of that design's choices are load-bearing here, and each one
7 + //! removes a problem the port had rather than adding a look.
8 + //!
9 + //! # The header is a grid, and `Attached:` is a row of it
10 + //!
11 + //! Eudora drew a right-aligned label column with the fields beside it: From,
12 + //! To, Cc, Bcc, Subject, Attached. `3fb2526a` recorded that goingson's modal and
13 + //! its standalone window differed in five ways, and two of them stop existing
14 + //! under that shape:
15 + //!
16 + //! - **The attachments bar.** It sat inside the form in the modal and outside it
17 + //! in the window, which is the divergence the task named first. As a header
18 + //! row there is one arrangement and nowhere else to put it.
19 + //! - **Progressive disclosure.** `toggleCcBcc` hid Cc and Bcc behind a link, and
20 + //! nothing in the vocabulary names "hidden until asked for". Eudora never hid
21 + //! them. The gap goes away rather than being worked around, and if it ever
22 + //! comes back the answer is an address, the way [`super::settings::email`]
23 + //! says its advanced block with `?advanced=1`.
24 + //!
25 + //! # Queue, not Send, and that is the whole reason this screen exists
26 + //!
27 + //! `send_email` is async and a route handler is synchronous, so a described
28 + //! compose could not send. Eudora's answer was already the right one: you Queue,
29 + //! and something else drains the Out box. Queueing is a local write.
30 + //!
31 + //! So the outbox is not the workaround, it is the feature — a message you can
32 + //! see before it goes, schedule, and take back. See [`crate::outbox`] for the
33 + //! drainer and goingson `a3c76a24` for the ruling.
34 + //!
35 + //! # The draft saves as you write it, which is why there is no Save button
36 + //!
37 + //! Every field here carries [`Field::changes`], so leaving a field writes it to
38 + //! the draft. That is what a mail client does anyway, and it is also what gets
39 + //! this screen past a known gap: quasicoherent `7da72faf` records that a
40 + //! [`Node::Form`] has one submit, and compose wanted three verbs over one set of
41 + //! values. It does not, once the values are already saved. Queue and Discard act
42 + //! on a draft that is already on disk, and there is no form at all.
43 + //!
44 + //! `Field::changes` is the DOM `change` event in a webview, which fires when a
45 + //! field settles rather than per keystroke. On a terminal it is per keystroke,
46 + //! which is quasicoherent `8032fe61` and is that renderer's to answer; a draft
47 + //! written more often than necessary is the harmless end of that finding.
48 + //!
49 + //! # A new message is a POST
50 + //!
51 + //! `GET /compose` would have to create a draft to have somewhere to write to,
52 + //! and a GET that writes is a GET that a reload repeats. So `POST /compose`
53 + //! makes the draft and goes to it, and the screen itself is
54 + //! `GET /compose/{id}` — an address that can be reloaded, linked and reopened.
55 + //!
56 + //! An abandoned blank draft is the cost, and it is Eudora's cost too: an empty
57 + //! message in Out that you throw away. Discard is one press.
58 + //!
59 + //! # What is not here
60 + //!
61 + //! **Attachments.** `Attached:` is drawn and says what the message carries, and
62 + //! there is no control to add one. `send_email_inner` takes
63 + //! `attachment_paths: Vec<String>` — paths on this machine, read at send time —
64 + //! and a described file field carries bytes. Bridging those is a real piece of
65 + //! work rather than a line, and it is filed rather than half-done.
66 + //!
67 + //! **A window of its own.** [`Frame`](quasi_router::Frame) is what a mount puts
68 + //! around a screen, and a mount is a `Webview`; this app builds one. A compose
69 + //! window is a second mount carrying a frame whose verbs are these verbs, which
70 + //! is the other half of `3fb2526a`.
71 +
72 + // Handlers take their request by value because `quasi_router::Handler` is a
73 + // plain `fn(&S, Request)` pointer, so the signature is the router's.
74 + #![allow(clippy::needless_pass_by_value)]
75 +
76 + use chrono::{DateTime, Utc};
77 + use goingson_core::EmailId;
78 + use quasi_router::layout::Tone;
79 + use quasi_router::screen::{Act, Choice, Field, Row, Tag};
80 + use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
81 +
82 + use crate::state::{AppState, DESKTOP_USER_ID};
83 +
84 + #[cfg(test)]
85 + mod tests;
86 +
87 + /// The region the header and body live in.
88 + const BODY: &str = "compose";
89 + /// Where a saved field's acknowledgement lands.
90 + ///
91 + /// Its own region rather than the header's, because answering into the header
92 + /// would redraw the control the reader is still in.
93 + const SAVED: &str = "compose-saved";
94 +
95 + /// The field names, which are also what a write reads out of the payload.
96 + const FROM: &str = "from";
97 + const TO: &str = "to";
98 + const CC: &str = "cc";
99 + const BCC: &str = "bcc";
100 + const SUBJECT: &str = "subject";
101 + const MESSAGE: &str = "body";
102 + /// The instant Queue Later asks for.
103 + const SEND_AFTER: &str = "send_after";
104 +
105 + /// A new message: make the draft, then go to it.
106 + fn start(state: &AppState, _request: quasi_router::Request) -> Result<Response, RouteError> {
107 + let draft = save(state, EmailId::new(), Fields::default())?;
108 + Ok(Response::goto(Action::get(format!(
109 + "/compose/{}",
110 + draft.id
111 + ))))
112 + }
113 +
114 + /// What the screen writes, all of it optional so one field can be saved alone.
115 + #[derive(Default)]
116 + struct Fields {
117 + from: Option<EmailId>,
118 + to: Option<String>,
119 + cc: Option<String>,
120 + bcc: Option<String>,
121 + subject: Option<String>,
122 + body: Option<String>,
123 + }
124 +
125 + /// Write a draft, keeping whatever this call did not name.
126 + ///
127 + /// `save_draft` replaces rather than patches, so a single-field write has to
128 + /// read the row first or it blanks the other five. That is the repository's
129 + /// shape and not worth changing for this: a draft is small and the read is
130 + /// local.
131 + fn save(state: &AppState, id: EmailId, fields: Fields) -> Result<goingson_core::Email, RouteError> {
132 + let existing = state
133 + .emails
134 + .get_by_id(id, DESKTOP_USER_ID)
135 + .map_err(|error| RouteError::internal(error.to_string()))?;
136 +
137 + let held = existing.as_ref();
138 + let account = fields
139 + .from
140 + .map(|id| goingson_core::EmailAccountId::from(uuid::Uuid::from(id)))
141 + .or_else(|| held.and_then(|email| email.draft_account_id));
142 +
143 + let from = match account {
144 + Some(id) => state
145 + .email_accounts
146 + .get_by_id(id, DESKTOP_USER_ID)
147 + .map_err(|error| RouteError::internal(error.to_string()))?
148 + .map(|account| account.email_address)
149 + .unwrap_or_default(),
150 + None => held.map(|email| email.from.clone()).unwrap_or_default(),
151 + };
152 +
153 + let pick = |given: Option<String>, kept: Option<String>| given.or(kept).unwrap_or_default();
154 +
155 + state
156 + .emails
157 + .save_draft(
158 + id,
159 + DESKTOP_USER_ID,
160 + &from,
161 + &pick(fields.to, held.map(|email| email.to.clone())),
162 + Some(&pick(
163 + fields.cc,
164 + held.and_then(|email| email.cc_address.clone()),
165 + )),
166 + Some(&pick(
167 + fields.bcc,
168 + held.and_then(|email| email.bcc_address.clone()),
169 + )),
170 + &pick(fields.subject, held.map(|email| email.subject.clone())),
171 + &pick(fields.body, held.map(|email| email.body.clone())),
172 + account,
173 + held.and_then(|email| email.in_reply_to.clone()).as_deref(),
174 + None,
175 + held.and_then(|email| email.thread_id.clone()).as_deref(),
176 + )
177 + .map_err(|error| RouteError::internal(error.to_string()))
178 + }
179 +
180 + /// The screen.
181 + fn screen(state: &AppState, id: EmailId) -> Result<Screen, RouteError> {
182 + let draft = state
183 + .emails
184 + .get_by_id(id, DESKTOP_USER_ID)
185 + .map_err(|error| RouteError::internal(error.to_string()))?
186 + .filter(|email| email.is_draft)
187 + .ok_or_else(|| RouteError::not_found("no such draft"))?;
188 +
189 + let accounts = state
190 + .email_accounts
191 + .list_by_user(DESKTOP_USER_ID)
192 + .map_err(|error| RouteError::internal(error.to_string()))?;
193 +
194 + let writes = |name: &str| Action::post(format!("/compose/{id}/field")).with("field", name);
195 + let text = |name: &'static str, label: &str, value: String| {
196 + Node::field(
197 + Field {
198 + value: Some(value),
199 + ..Field::new(makeover_layout::FieldKind::Text, name, label)
200 + }
201 + .changes(writes(name)),
202 + )
203 + };
204 +
205 + let mut header =
206 + Slot::new(BODY, RegionKind::Pane).with(Node::page(if draft.subject.is_empty() {
207 + "New message".to_owned()
208 + } else {
209 + draft.subject.clone()
210 + }));
211 +
212 + // The header grid, in Eudora's order. From first because it is the one
213 + // choice rather than a thing typed, and because a message with no account
214 + // cannot leave the outbox.
215 + header = header.with(Node::field(
216 + Field {
217 + options: accounts
218 + .iter()
219 + .map(|account| Choice::new(account.id.to_string(), account.email_address.clone()))
220 + .collect(),
221 + value: draft.draft_account_id.map(|id| id.to_string()),
222 + ..Field::new(makeover_layout::FieldKind::Select, FROM, "From")
223 + }
224 + .changes(writes(FROM)),
225 + ));
226 + header = header
227 + .with(text(TO, "To", draft.to.clone()))
228 + // Always drawn. Eudora never hid them, which is why this screen needs
229 + // no word for progressive disclosure.
230 + .with(text(CC, "Cc", draft.cc_address.clone().unwrap_or_default()))
231 + .with(text(
232 + BCC,
233 + "Bcc",
234 + draft.bcc_address.clone().unwrap_or_default(),
235 + ))
236 + .with(text(SUBJECT, "Subject", draft.subject.clone()))
237 + .with(attached(&draft))
238 + .with(Node::field(
239 + Field {
240 + value: Some(draft.body.clone()),
241 + ..Field::new(makeover_layout::FieldKind::Textarea, MESSAGE, "Message")
242 + }
243 + .changes(writes(MESSAGE)),
244 + ));
245 +
246 + // The verbs. Not a form's submit: the draft is already saved, so these act
247 + // on a thing that exists. See the module header.
248 + let queued = draft.is_queued();
249 + if queued {
250 + header = header
251 + .with(Node::text(
252 + "This message is in the Out box. Take it back to edit it.",
253 + ))
254 + .with(Node::Act(Act::new(
255 + "Take it back",
256 + Action::post(format!("/compose/{id}/unqueue")),
257 + )));
258 + } else {
259 + header = header
260 + .with(Node::Act(
261 + Act::new("Queue", Action::post(format!("/compose/{id}/queue"))).tone(Tone::Success),
262 + ))
263 + .with(Node::Act(
264 + Act::new("Queue later", Action::post(format!("/compose/{id}/queue"))).asking(
265 + Field::new(
266 + makeover_layout::FieldKind::DateTime,
267 + SEND_AFTER,
268 + "Send after",
269 + ),
270 + ),
271 + ));
272 + }
273 +
274 + Ok(Screen::list_detail("Compose", false)
275 + .at_place(super::shell::EMAILS)
276 + .with(header)
277 + .with(
278 + Slot::new("compose-aside", RegionKind::Pane).with(Node::Act(
279 + Act::new("Discard", Action::post(format!("/compose/{id}/discard")))
280 + .tone(Tone::Danger)
281 + .confirm("Throw this message away?"),
282 + )),
283 + ))
284 + }
285 +
286 + /// The `Attached:` row.
287 + ///
288 + /// Drawn even when empty, because it is a header row rather than a bar that
289 + /// appears: the point of Eudora's shape is that the message says what it
290 + /// carries in the same place every time. There is no control to add one; see
291 + /// the module header.
292 + fn attached(draft: &goingson_core::Email) -> Node {
293 + let names = draft.attachment_meta.as_deref().unwrap_or_default();
294 + Node::list([Row::new("Attached").meta(if names.is_empty() {
295 + "Nothing".to_owned()
296 + } else {
297 + names.to_owned()
298 + })])
299 + }
300 +
301 + /// The screen, as an answer.
302 + fn show(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
303 + Ok(screen(state, asked_for(&request)?)?.into())
304 + }
305 +
306 + /// The id in the address.
307 + fn asked_for(request: &quasi_router::Request) -> Result<EmailId, RouteError> {
308 + request
309 + .captures
310 + .get("id")
311 + .unwrap_or_default()
312 + .parse::<uuid::Uuid>()
313 + .map(Into::into)
314 + .map_err(|_| RouteError::not_found("not a message id"))
315 + }
316 +
317 + /// One field, written because it settled.
318 + fn write_field(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
319 + let id = asked_for(&request)?;
320 + // Read from either, the way `time_tracking::View::of` does: a control's own
321 + // params and what a caller sends arrive in different halves of the request,
322 + // and which half is the renderer's business rather than this handler's.
323 + let which = request
324 + .payload
325 + .get("field")
326 + .or_else(|| request.carried.get("field"))
327 + .unwrap_or_default()
328 + .to_owned();
329 + let value = request.payload.get(&which).unwrap_or_default().to_owned();
330 +
331 + let mut fields = Fields::default();
332 + match which.as_str() {
333 + FROM => {
334 + fields.from = value.parse::<uuid::Uuid>().ok().map(Into::into);
335 + }
336 + TO => fields.to = Some(value),
337 + CC => fields.cc = Some(value),
338 + BCC => fields.bcc = Some(value),
339 + SUBJECT => fields.subject = Some(value),
340 + MESSAGE => fields.body = Some(value),
341 + // A name this screen does not draw. Refused rather than ignored: it did
342 + // not come from a control here.
343 + _ => return Err(RouteError::not_found("no such field")),
344 + }
345 +
346 + save(state, id, fields)?;
347 + // Nothing is answered back. The value is already on screen, it is what the
348 + // reader typed, and replacing the field under a caret is the failure
349 + // quasicoherent `a135f898` records.
350 + // Nothing is answered back into the field. The value is already on screen,
351 + // it is what the reader typed, and replacing a control under a caret is the
352 + // failure quasicoherent `a135f898` records. The empty region is the
353 + // acknowledgement.
354 + Ok(Response::fragment(SAVED, Node::text("")))
355 + }
356 +
357 + /// Put the message in the Out box.
358 + fn queue(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
359 + let id = asked_for(&request)?;
360 + let send_after = read_instant(&request)?;
361 +
362 + // Refused here rather than left to fail once a minute in the drainer: a
363 + // message with nobody to send to, or nothing to send it from, is a mistake
364 + // to catch while the person is still looking at it.
365 + let draft = state
366 + .emails
367 + .get_by_id(id, DESKTOP_USER_ID)
368 + .map_err(|error| RouteError::internal(error.to_string()))?
369 + .filter(|email| email.is_draft)
370 + .ok_or_else(|| RouteError::not_found("no such draft"))?;
371 +
372 + if draft.draft_account_id.is_none() {
373 + return Err(
374 + RouteError::conflict("Choose which account this is from before queueing it.")
375 + .as_toast(),
376 + );
377 + }
378 + if draft.to.trim().is_empty() {
379 + return Err(RouteError::conflict("Say who it is going to.").as_toast());
380 + }
381 +
382 + state
383 + .emails
384 + .queue_draft(id, DESKTOP_USER_ID, send_after)
385 + .map_err(|error| RouteError::internal(error.to_string()))?
386 + .ok_or_else(|| RouteError::not_found("no such draft"))?;
387 +
388 + Ok(Response::goto(Action::get("/outbox")).toast(
389 + Tone::Success,
390 + match send_after {
391 + Some(at) => format!(
392 + "In the Out box, going after {}.",
393 + at.format("%b %-d, %H:%M")
394 + ),
395 + None => "In the Out box.".to_owned(),
396 + },
397 + ))
398 + }
399 +
400 + /// The instant Queue Later asked for, if it asked.
401 + fn read_instant(request: &quasi_router::Request) -> Result<Option<DateTime<Utc>>, RouteError> {
402 + let raw = request.payload.get(SEND_AFTER).unwrap_or_default();
403 + if raw.trim().is_empty() {
404 + return Ok(None);
405 + }
406 + // A local wall-clock instant, which is what a datetime control gives and
407 + // what the person meant. Stored as UTC, compared against UTC by the
408 + // drainer.
409 + chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M")
410 + .ok()
411 + .and_then(|naive| naive.and_local_timezone(chrono::Local).single())
412 + .map(|local| local.with_timezone(&Utc))
413 + .map(Some)
414 + .ok_or_else(|| RouteError::conflict("That is not a time this understands.").as_toast())
415 + }
416 +
417 + /// Take a message back out of the Out box.
418 + fn unqueue(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
419 + let id = asked_for(&request)?;
420 + state
421 + .emails
422 + .unqueue_draft(id, DESKTOP_USER_ID)
423 + .map_err(|error| RouteError::internal(error.to_string()))?
424 + .ok_or_else(|| RouteError::not_found("not in the Out box"))?;
425 + Ok(Response::screen(screen(state, id)?).toast(
426 + Tone::Success,
427 + "Taken back. It will not go until you queue it.",
428 + ))
429 + }
430 +
431 + /// Throw the message away.
432 + fn discard(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> {
433 + let id = asked_for(&request)?;
434 + state
435 + .emails
436 + .delete(id, DESKTOP_USER_ID)
437 + .map_err(|error| RouteError::internal(error.to_string()))?;
438 + Ok(Response::goto(Action::get("/emails")).toast(Tone::Success, "Thrown away."))
439 + }
440 +
441 + /// The Out box: what is waiting, when it goes, and why one is stuck.
442 + fn outbox(state: &AppState, _request: quasi_router::Request) -> Result<Response, RouteError> {
443 + let waiting = state
444 + .emails
445 + .list_outbox(DESKTOP_USER_ID)
446 + .map_err(|error| RouteError::internal(error.to_string()))?;
447 +
448 + let mut pane = Slot::new("outbox", RegionKind::Pane).with(Node::page("Out box"));
449 +
450 + if waiting.is_empty() {
451 + pane = pane.with(Node::empty("Nothing waiting to go."));
452 + } else {
453 + pane = pane.with(Node::list(waiting.iter().map(|email| {
454 + // One part per role: `Row::meta` sets rather than appends, so three
455 + // calls to it would be one fact and two discarded.
456 + let mut row = Row::new(if email.subject.is_empty() {
457 + "(no subject)"
458 + } else {
459 + &email.subject
460 + })
461 + .secondary(email.to.clone());
462 +
463 + // Why it is stuck if it is, and when it goes if it is not. A stuck
464 + // message's timing is not the interesting fact about it.
465 + row = match &email.send_error {
466 + Some(error) => row
467 + .token(
468 + Tag::badge(format!("Stuck after {}", email.send_attempts))
469 + .tone(Tone::Danger),
470 + )
471 + .meta(error.clone()),
472 + None => row.meta(match email.send_after {
473 + Some(at) => format!("after {}", at.format("%b %-d, %H:%M")),
474 + None => "next pass".to_owned(),
475 + }),
476 + };
477 +
478 + row.act(Act::new(
479 + "Take it back",
480 + Action::post(format!("/compose/{}/unqueue", email.id)),
481 + ))
482 + .activate(Action::get(format!("/compose/{}", email.id)))
483 + })));
484 + }
485 +
486 + Ok(Screen::list_detail("Out box", false)
487 + .at_place(super::shell::OUTBOX)
488 + .with(pane)
489 + .into())
490 + }
491 +
492 + /// This module's routes.
493 + #[must_use]
494 + pub fn routes(router: Router<AppState>) -> Router<AppState> {
495 + router
496 + .post("/compose", start)
497 + .get("/outbox", outbox)
498 + .get("/compose/{id}", show)
499 + .post("/compose/{id}/field", write_field)
500 + .post("/compose/{id}/queue", queue)
Lines truncated
@@ -1,0 +1,349 @@
1 + //! Compose and the Out box, driven through the router against a real database.
2 + //!
3 + //! Two assertions carry the design rather than the code. One is that the screen
4 + //! has no form: the draft saves per field, which is what gets past the
5 + //! one-submit gap. The other is that Cc and Bcc are always drawn, which is what
6 + //! Eudora's shape buys and is the reason nothing here needs a word for
7 + //! progressive disclosure. A later pass that "tidies" either would be undoing a
8 + //! decision.
9 +
10 + use std::sync::Arc;
11 +
12 + use chrono::{Duration, Utc};
13 + use goingson_core::NewEmailAccount;
14 +
15 + use quasi_http::Serves as _;
16 + use quasi_router::{Outcome, Params, Request};
17 +
18 + use crate::quasi::router;
19 + use crate::state::{AppState, DESKTOP_USER_ID};
20 +
21 + async fn state() -> Arc<AppState> {
22 + let (state, _) = crate::test_utils::setup_test_state().await;
23 + let now = chrono::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 + /// An account, so a message has somewhere to be from.
44 + fn account(state: &AppState) -> goingson_core::EmailAccountId {
45 + state
46 + .email_accounts
47 + .create(
48 + DESKTOP_USER_ID,
49 + NewEmailAccount {
50 + account_name: "Mine",
51 + email_address: "me@example.com",
52 + imap_server: "imap.example.com",
53 + imap_port: 993,
54 + smtp_server: "smtp.example.com",
55 + smtp_port: 587,
56 + username: "me@example.com",
57 + password: "",
58 + use_tls: true,
59 + archive_folder_name: None,
60 + },
61 + )
62 + .expect("the account is created")
63 + .id
64 + }
65 +
66 + fn post(state: &AppState, path: &str, params: Params) -> quasi_router::Response {
67 + router()
68 + .handle(state, Request::post(path).sending(params))
69 + .expect("the route answers")
70 + }
71 +
72 + fn html(state: &AppState, path: &str) -> String {
73 + let response = router()
74 + .handle(state, Request::get(path).carrying(Params::new()))
75 + .expect("the route answers");
76 + match &response.outcome {
77 + Outcome::Screen(screen) => quasi_webview::Webview::new().screen(screen),
78 + Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(node),
79 + other => panic!("expected content, got {other:?}"),
80 + }
81 + }
82 +
83 + /// Start a message and answer its id.
84 + fn started(state: &AppState) -> goingson_core::EmailId {
85 + let response = post(state, "/compose", Params::new());
86 + let Outcome::Goto(action) = &response.outcome else {
87 + panic!("starting a message goes to it");
88 + };
89 + let path = format!("{:?}", action.destination);
90 + let id = path
91 + .rsplit('/')
92 + .next()
93 + .expect("an id on the end")
94 + .trim_end_matches("\")")
95 + .to_owned();
96 + id.parse::<uuid::Uuid>().expect("a uuid").into()
97 + }
98 +
99 + #[tokio::test]
100 + async fn a_new_message_is_a_post_because_a_get_that_writes_repeats_on_reload() {
101 + let state = state().await;
102 + let id = started(&state);
103 +
104 + let draft = state
105 + .emails
106 + .get_by_id(id, DESKTOP_USER_ID)
107 + .expect("read")
108 + .expect("the draft exists");
109 + assert!(draft.is_draft);
110 + assert!(!draft.is_queued(), "a new message is not in the Out box");
111 +
112 + // And the screen it went to is an ordinary address: reloadable, linkable.
113 + assert!(html(&state, &format!("/compose/{id}")).contains("New message"));
114 + }
115 +
116 + #[tokio::test]
117 + async fn the_header_is_eudoras_and_cc_and_bcc_are_always_drawn() {
118 + // The whole reason this screen needs no word for progressive disclosure.
119 + let state = state().await;
120 + let id = started(&state);
121 + let markup = html(&state, &format!("/compose/{id}"));
122 +
123 + for label in ["From", "To", "Cc", "Bcc", "Subject", "Attached"] {
124 + assert!(markup.contains(label), "the header is missing {label}");
125 + }
126 + // Attached is a header row and says so even when empty, rather than a bar
127 + // that appears. That is the divergence 3fb2526a named first.
128 + assert!(markup.contains("Nothing"), "{markup}");
129 + }
130 +
131 + #[tokio::test]
132 + async fn there_is_no_form_because_the_draft_saves_as_it_is_written() {
133 + // quasicoherent 7da72faf: a form has one submit and compose wanted three
134 + // verbs. It does not, once the values are already on disk.
135 + let state = state().await;
136 + let id = started(&state);
137 + let markup = html(&state, &format!("/compose/{id}"));
138 + assert!(!markup.contains("<form"), "{markup}");
139 +
140 + post(
141 + &state,
142 + &format!("/compose/{id}/field"),
143 + Params::new()
144 + .with("field", "subject")
145 + .with("subject", "Hello"),
146 + );
147 +
148 + let draft = state
149 + .emails
150 + .get_by_id(id, DESKTOP_USER_ID)
151 + .expect("read")
152 + .expect("there");
153 + assert_eq!(draft.subject, "Hello");
154 + }
155 +
156 + #[tokio::test]
157 + async fn saving_one_field_keeps_the_other_five() {
158 + // `save_draft` replaces rather than patches, so a per-field write that did
159 + // not read first would blank the message every keystroke.
160 + let state = state().await;
161 + let id = started(&state);
162 +
163 + for (field, value) in [
164 + ("to", "them@example.com"),
165 + ("subject", "Hello"),
166 + ("body", "Hi there"),
167 + ] {
168 + post(
169 + &state,
170 + &format!("/compose/{id}/field"),
171 + Params::new().with("field", field).with(field, value),
172 + );
173 + }
174 +
175 + let draft = state
176 + .emails
177 + .get_by_id(id, DESKTOP_USER_ID)
178 + .expect("read")
179 + .expect("there");
180 + assert_eq!(draft.to, "them@example.com");
181 + assert_eq!(draft.subject, "Hello");
182 + assert_eq!(draft.body, "Hi there");
183 + }
184 +
185 + #[tokio::test]
186 + async fn queueing_needs_somewhere_to_send_from_and_somewhere_to_send_to() {
187 + // Refused while the person is looking at it, rather than left to fail once
188 + // a minute in the drainer.
189 + let state = state().await;
190 + let id = started(&state);
191 +
192 + let refused = router().handle(
193 + &state,
194 + Request::post(format!("/compose/{id}/queue")).sending(Params::new()),
195 + );
196 + assert!(refused.is_err(), "no account, no recipient");
197 +
198 + let account = account(&state);
199 + post(
200 + &state,
201 + &format!("/compose/{id}/field"),
202 + Params::new()
203 + .with("field", "from")
204 + .with("from", account.to_string()),
205 + );
206 + let still = router().handle(
207 + &state,
208 + Request::post(format!("/compose/{id}/queue")).sending(Params::new()),
209 + );
210 + assert!(still.is_err(), "an account is not a recipient");
211 + }
212 +
213 + #[tokio::test]
214 + async fn queueing_puts_it_in_the_out_box_and_send_later_gives_it_an_instant() {
215 + let state = state().await;
216 + let account = account(&state);
217 +
218 + let now = started(&state);
219 + let later = started(&state);
220 + for id in [now, later] {
221 + post(
222 + &state,
223 + &format!("/compose/{id}/field"),
224 + Params::new()
225 + .with("field", "from")
226 + .with("from", account.to_string()),
227 + );
228 + post(
229 + &state,
230 + &format!("/compose/{id}/field"),
231 + Params::new()
232 + .with("field", "to")
233 + .with("to", "them@example.com"),
234 + );
235 + }
236 +
237 + post(&state, &format!("/compose/{now}/queue"), Params::new());
238 + let at = (Utc::now() + Duration::hours(5)).with_timezone(&chrono::Local);
239 + post(
240 + &state,
241 + &format!("/compose/{later}/queue"),
242 + Params::new().with("send_after", at.format("%Y-%m-%dT%H:%M").to_string()),
243 + );
244 +
245 + let waiting = state.emails.list_outbox(DESKTOP_USER_ID).expect("read");
246 + assert_eq!(waiting.len(), 2);
247 +
248 + // Only the unscheduled one is due, which is the whole of send-later.
249 + let due = state
250 + .emails
251 + .list_due(DESKTOP_USER_ID, Utc::now())
252 + .expect("read");
253 + assert_eq!(due.len(), 1);
254 + assert_eq!(due[0].id, now);
255 + }
256 +
257 + #[tokio::test]
258 + async fn the_out_box_says_what_is_waiting_and_why_one_is_stuck() {
259 + let state = state().await;
260 + let account = account(&state);
261 + let id = started(&state);
262 + post(
263 + &state,
264 + &format!("/compose/{id}/field"),
265 + Params::new()
266 + .with("field", "from")
267 + .with("from", account.to_string()),
268 + );
269 + post(
270 + &state,
271 + &format!("/compose/{id}/field"),
272 + Params::new()
273 + .with("field", "to")
274 + .with("to", "them@example.com"),
275 + );
276 + post(
277 + &state,
278 + &format!("/compose/{id}/field"),
279 + Params::new()
280 + .with("field", "subject")
281 + .with("subject", "Hello"),
282 + );
283 + post(&state, &format!("/compose/{id}/queue"), Params::new());
284 +
285 + let markup = html(&state, "/outbox");
286 + assert!(markup.contains("Hello"), "{markup}");
287 + assert!(markup.contains("them@example.com"), "{markup}");
288 +
289 + // A failure is on the row, with its count: one is a server having a moment,
290 + // twenty is something that will never work.
291 + state
292 + .emails
293 + .record_send_failure(id, DESKTOP_USER_ID, "connection refused")
294 + .expect("record");
295 + let markup = html(&state, "/outbox");
296 + assert!(markup.contains("connection refused"), "{markup}");
297 + assert!(markup.contains("Stuck after 1"), "{markup}");
298 + }
299 +
300 + #[tokio::test]
301 + async fn a_queued_message_is_taken_back_rather_than_edited_in_place() {
302 + let state = state().await;
303 + let account = account(&state);
304 + let id = started(&state);
305 + post(
306 + &state,
307 + &format!("/compose/{id}/field"),
308 + Params::new()
309 + .with("field", "from")
310 + .with("from", account.to_string()),
311 + );
312 + post(
313 + &state,
314 + &format!("/compose/{id}/field"),
315 + Params::new()
316 + .with("field", "to")
317 + .with("to", "them@example.com"),
318 + );
319 + post(&state, &format!("/compose/{id}/queue"), Params::new());
320 +
321 + // The screen says so and offers the way back rather than the verbs.
322 + let markup = html(&state, &format!("/compose/{id}"));
323 + assert!(markup.contains("Out box"), "{markup}");
324 + assert!(markup.contains("Take it back"), "{markup}");
325 + assert!(!markup.contains(">Queue<"), "{markup}");
326 +
327 + post(&state, &format!("/compose/{id}/unqueue"), Params::new());
328 + assert!(
329 + state
330 + .emails
331 + .list_outbox(DESKTOP_USER_ID)
332 + .expect("read")
333 + .is_empty()
334 + );
335 + }
336 +
337 + #[tokio::test]
338 + async fn discarding_throws_the_message_away() {
339 + let state = state().await;
340 + let id = started(&state);
341 + post(&state, &format!("/compose/{id}/discard"), Params::new());
342 + assert!(
343 + state
344 + .emails
345 + .get_by_id(id, DESKTOP_USER_ID)
346 + .expect("read")
347 + .is_none_or(|email| !email.is_draft)
348 + );
349 + }