Skip to main content

max / goingson

19.2 KB · 592 lines History Blame Raw
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 }
350
351 // --- Attachments -----------------------------------------------------------
352
353 /// A file on disk to attach.
354 fn a_file(name: &str, contents: &str) -> std::path::PathBuf {
355 let dir = std::env::temp_dir().join("goingson-compose-attach-tests");
356 std::fs::create_dir_all(&dir).unwrap();
357 let path = dir.join(name);
358 std::fs::write(&path, contents).unwrap();
359 path
360 }
361
362 #[tokio::test]
363 async fn attaching_is_the_hosts_call_and_lands_a_row_against_the_draft() {
364 let state = state().await;
365 let id = started(&state);
366
367 // The control carries no transport: `Action::by_host`, the same as the
368 // imports. Picking a file is not describable.
369 let markup = html(&state, &format!("/compose/{id}"));
370 assert!(markup.contains("Attach a file"), "{markup}");
371 assert!(
372 markup.contains(&format!("data-sends=\"/compose/{id}/attach\"")),
373 "{markup}"
374 );
375 assert!(!markup.contains("type=\"file\""), "{markup}");
376
377 let path = a_file("notes.txt", "hello");
378 post(
379 &state,
380 &format!("/compose/{id}/attach"),
381 Params::new().with("file", path.to_string_lossy().into_owned()),
382 );
383
384 let files = state
385 .attachments
386 .list_for_email(id, DESKTOP_USER_ID)
387 .expect("read");
388 assert_eq!(files.len(), 1);
389 assert_eq!(files[0].filename, "notes.txt");
390
391 // And the header row says so where it said "Nothing".
392 let markup = html(&state, &format!("/compose/{id}"));
393 assert!(markup.contains("notes.txt"), "{markup}");
394 assert!(!markup.contains("Nothing"), "{markup}");
395 }
396
397 #[tokio::test]
398 async fn a_queued_message_lists_its_files_and_offers_no_way_to_change_them() {
399 // The drainer may be reading them.
400 let state = state().await;
401 let account = account(&state);
402 let id = started(&state);
403 for (field, value) in [
404 ("from", account.to_string()),
405 ("to", "them@example.com".to_owned()),
406 ] {
407 post(
408 &state,
409 &format!("/compose/{id}/field"),
410 Params::new().with("field", field).with(field, value),
411 );
412 }
413 post(
414 &state,
415 &format!("/compose/{id}/attach"),
416 Params::new().with(
417 "file",
418 a_file("report.csv", "a,b").to_string_lossy().into_owned(),
419 ),
420 );
421 post(&state, &format!("/compose/{id}/queue"), Params::new());
422
423 let markup = html(&state, &format!("/compose/{id}"));
424 assert!(markup.contains("report.csv"), "still listed: {markup}");
425 assert!(!markup.contains("Attach a file"), "{markup}");
426 assert!(!markup.contains("Remove"), "{markup}");
427 }
428
429 #[tokio::test]
430 async fn removing_a_file_takes_the_row_and_leaves_the_blob_to_the_collector() {
431 let state = state().await;
432 let id = started(&state);
433 post(
434 &state,
435 &format!("/compose/{id}/attach"),
436 Params::new().with(
437 "file",
438 a_file("gone.txt", "bye").to_string_lossy().into_owned(),
439 ),
440 );
441 let files = state
442 .attachments
443 .list_for_email(id, DESKTOP_USER_ID)
444 .expect("read");
445 let hash = files[0].blob_hash.clone();
446
447 post(
448 &state,
449 &format!("/compose/{id}/detach/{}", files[0].id),
450 Params::new(),
451 );
452 assert!(
453 state
454 .attachments
455 .list_for_email(id, DESKTOP_USER_ID)
456 .expect("read")
457 .is_empty()
458 );
459 // The blob is `blob_gc`'s to reclaim, which is what makes removing a file a
460 // row delete rather than a disk operation.
461 assert!(
462 crate::commands::attachment::blob_path(&state.data_dir, &hash).exists(),
463 "the blob is left for the collector"
464 );
465 }
466
467 #[tokio::test]
468 async fn the_drainer_sends_the_blob_rather_than_the_path_it_came_from() {
469 // THE ONE THAT MATTERS for send-later. A queued message may go hours after
470 // it was written, and the file it was written from can be gone by then.
471 let state = state().await;
472 let account = account(&state);
473 let id = started(&state);
474 for (field, value) in [
475 ("from", account.to_string()),
476 ("to", "them@example.com".to_owned()),
477 ] {
478 post(
479 &state,
480 &format!("/compose/{id}/field"),
481 Params::new().with("field", field).with(field, value),
482 );
483 }
484
485 let path = a_file("fleeting.txt", "here for now");
486 post(
487 &state,
488 &format!("/compose/{id}/attach"),
489 Params::new().with("file", path.to_string_lossy().into_owned()),
490 );
491 post(&state, &format!("/compose/{id}/queue"), Params::new());
492
493 // The file the person picked goes away, as it may.
494 std::fs::remove_file(&path).expect("remove");
495
496 // What the message carries is still there, under its hash.
497 let files = state
498 .attachments
499 .list_for_email(id, DESKTOP_USER_ID)
500 .expect("read");
501 assert_eq!(files.len(), 1);
502 let blob = crate::commands::attachment::blob_path(&state.data_dir, &files[0].blob_hash);
503 assert!(blob.exists(), "the blob outlives the file");
504 assert_eq!(std::fs::read_to_string(&blob).unwrap(), "here for now");
505 }
506
507 #[tokio::test]
508 async fn compose_offers_itself_in_a_window_of_its_own() {
509 // `3fb2526a`, the second-mount half. The verb is an ordinary described
510 // control marked `elsewhere`, so what the markup must carry is the address
511 // and no transport: an `href` or an `hx-get` would put the answer in the
512 // window the control is already in, which is the one thing the mark says
513 // not to do.
514 let state = state().await;
515 let id = started(&state);
516
517 let markup = html(&state, &format!("/compose/{id}"));
518
519 assert!(
520 markup.contains(&format!(r#"data-mount="/compose/{id}""#)),
521 "{markup}"
522 );
523 // No transport on it. A read would ordinarily be an anchor with an `href`
524 // and an `hx-get`, and either would open the screen in the window the
525 // control is already in -- the one thing the mark says not to do. Read off
526 // the element itself, since the screen is full of controls that rightly
527 // carry both.
528 let verb = markup
529 .split("<a ")
530 .find(|piece| piece.starts_with("class=\"button\" data-act data-mount="))
531 .expect("the verb is an element of its own");
532 let verb = &verb[..verb.find('>').expect("a tag ends")];
533 assert!(!verb.contains("href="), "{verb}");
534 assert!(!verb.contains("hx-get"), "{verb}");
535 // Not `data-sends`: `host.js` posts to one and opens a window on the other,
536 // so conflating them is a message sent where a window was wanted.
537 assert!(!verb.contains("data-sends"), "{verb}");
538 }
539
540 #[tokio::test]
541 async fn the_compose_window_serves_the_same_screen_as_the_main_window() {
542 // The whole point of the second mount, and the thing that would rot
543 // silently: one description, two windows. If this screen ever learns which
544 // mount it is in, it is back to the two code paths `3fb2526a` measured --
545 // `compose-form.js` shared by two documents with two chrome arrangements.
546 //
547 // Asserted through the router rather than through the protocol, because
548 // what differs between the mounts is the renderer and the frame, and the
549 // description is what must not differ.
550 let state = state().await;
551 let id = started(&state);
552
553 let main = html(&state, &format!("/compose/{id}"));
554
555 let framed = {
556 let response = router()
557 .handle(
558 &state,
559 Request::get(format!("/compose/{id}")).carrying(Params::new()),
560 )
561 .expect("the route answers");
562 let Outcome::Screen(screen) = &response.outcome else {
563 panic!("compose is a screen");
564 };
565 // The compose window's renderer: the same screens, a frame that
566 // reports. See `quasi::compose_protocol`.
567 quasi_webview::Webview::new()
568 .with_frame(quasi_router::Frame::new().reporting())
569 .screen(screen)
570 };
571
572 // Everything the screen says is in both.
573 for said in ["Subject", "Attached", "Queue", "Discard"] {
574 assert!(main.contains(said), "main window lost {said}: {main}");
575 assert!(
576 framed.contains(said),
577 "compose window lost {said}: {framed}"
578 );
579 }
580
581 // And the frame adds a place to speak, which the main window has not. That
582 // is the fifth divergence of `3fb2526a`'s table and the only one left.
583 assert!(
584 framed.contains(quasi_webview::frame::STATUS_ID),
585 "the compose window has no status line: {framed}"
586 );
587 assert!(
588 !main.contains(quasi_webview::frame::STATUS_ID),
589 "the main window grew a status line: {main}"
590 );
591 }
592