Skip to main content

max / goingson

An outbox, and send-later with it Ruled by Max 2026-08-22 (a3c76a24): "have GoingsOn use an outbox model explicitly and leverage that to make send-later a first class feature." WHAT LED HERE, so the columns are not mistaken for plumbing. send_email is async: it opens an SMTP connection, and commands/email/send.rs has seven .await sites. A described route handler is synchronous, by quasi_router's Decision 6, which exists so egui and a terminal need no runtime. So compose could not be described at all while sending was something a button did. Queueing is a local write, so it can be. And the outbox is not the consolation prize for that constraint: it is a message you can see before it goes and stop, a message you can schedule, and a send that survives being offline instead of failing at the instant somebody pressed the button. Eudora shipped exactly this and named the distinction Send versus Queue, which is the design Max picked for compose the same day. A QUEUED MESSAGE IS A DRAFT THAT HAS BEEN COMMITTED TO SEND, so it stays in `emails` rather than moving to a table of its own. Eudora's Out mailbox is the same idea. save_draft, the drafts list and the compose screen loading one all keep working unchanged, and the outbox is a query rather than a second store to keep in step: is_draft = 1 AND queued_at IS NOT NULL. Migration 069 adds queued_at, send_after, send_attempts and send_error, every one NULL for a message nobody queued, so no backfill is owed. `emails` is per-device and unsynced, which is also the honest behaviour here: two machines draining one outbox would send twice. SEND-LATER IS send_after AND NOTHING ELSE. Compared against the clock on each wake rather than armed as a timer, so a message scheduled while the app was shut goes when the app comes back instead of being missed. THE DRAINER is this app's third tokio interval with a cancel token, after the sync scheduler and the notification pass, and deliberately so: a reader who has read either already knows how this one starts, stops and survives a failing tick. Nothing about it is described. The description says "queue this"; what drains the queue is not a screen and has no address, which is why an outbox answers the async problem rather than moving it. A failed send stamps the error, counts the attempt and leaves the message queued. It backs off on the count, capped, because without a cap a message that failed twenty times is a silent drop wearing a backoff's clothes. A queued draft with no account is stamped rather than retried: the account holds the credentials, so that one can never succeed. open_compose_window is deleted here rather than repaired. It built compose.html?to=.. and the swap deleted compose.html along with the JavaScript that called it, so it was unreachable and broken at once. The second window comes back with the described compose screen (3fb2526a), aimed at an address. Nine tests: queueing, send-later's two halves, taking a message back out, failure not dropping it, a received message refused, one person's outbox, and the backoff curve on its own without a clock or a database.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-22 18:04 UTC
Signed with PGP, not checked
Commit: 044cf2d4f6d7da3686a30dd48902828b225a296c
Parent: e37d997
13 files changed, +703 insertions, -111 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"
@@ -69,3 +69,4 @@
69 69 066 ddea0b76234ce4d2aa4482b01ac304efb64624c854aa916ca7ad6a43bc978bce5a8f890378880c5b0d2469c8f753d50d
70 70 067 6ac04d5280e01472180139ba6a5ae83be242dbcae8ca5b1adf90c15ad01507f734047262fd987bc8eb50c238f96621d8
71 71 068 ccdddaa4176f36169bb90b1eec191ae776a87ee1cd56ba2bd789f905fb212bcd2b04b884e820498fab8b6d03fb7e5c6d
72 + 069 afc5492dfbda7dde625f1ca00e06e8ae0c26ad10e3a26399aec7bf0fee66d4beaabb0e4b00ee8fab1ddb695790194c46
@@ -15,6 +15,8 @@
15 15 pub mod jmap;
16 16 pub mod notifs;
17 17 pub mod oauth;
18 + /// The outbox drainer: what sends a queued message. See the module header.
19 + pub mod outbox;
18 20 pub mod problems;
19 21 /// The screens, described rather than built. The app is this as of the
20 22 /// 2026-08-22 swap; see the module header.
@@ -236,7 +238,6 @@
236 238 // Form markup
237 239 $crate::commands::render_form_fields,
238 240 // Window
239 - $crate::commands::open_compose_window,
240 241 $crate::commands::set_window_title,
241 242 $crate::commands::open_external_url,
242 243 // Search
@@ -432,6 +433,14 @@
432 433 email_sync_scheduler::start_email_sync_scheduler(sync_handle, email_cancel).await;
433 434 });
434 435
436 + // The outbox drainer: what actually sends a queued message. See
437 + // `outbox`, and goingson `a3c76a24` for why sending is a queue.
438 + let outbox_handle = app.handle().clone();
439 + let outbox_cancel = cancel_token.clone();
440 + tauri::async_runtime::spawn(async move {
441 + crate::outbox::start_outbox_drainer(outbox_handle, outbox_cancel).await;
442 + });
443 +
435 444 // Cloud sync runs through the SyncStore engine's own scheduler now
436 445 // (its own timer + SSE race + gate chain), reporting via GoSyncObserver.
437 446 let cloud_sync_handle = app.handle().clone();
@@ -415,6 +415,14 @@
415 415 email_sync_scheduler::start_email_sync_scheduler(sync_handle, email_cancel).await;
416 416 });
417 417
418 + // The outbox drainer: what actually sends a queued message. See
419 + // `outbox`, and goingson `a3c76a24` for why sending is a queue.
420 + let outbox_handle = app.handle().clone();
421 + let outbox_cancel = cancel_token.clone();
422 + tauri::async_runtime::spawn(async move {
423 + goingson_desktop::outbox::start_outbox_drainer(outbox_handle, outbox_cancel).await;
424 + });
425 +
418 426 // Cloud sync runs through the SyncStore engine's own scheduler (its
419 427 // own timer + SSE race + gate chain), reporting via GoSyncObserver.
420 428 let cloud_sync_handle = app.handle().clone();
@@ -528,3 +528,187 @@
528 528 .expect("create email");
529 529 assert_eq!(untracked.body_format, BodyFormat::Plain);
530 530 }
531 +
532 + // --- The outbox ------------------------------------------------------------
533 + //
534 + // Ruled 2026-08-22 (goingson a3c76a24): sending is queueing, and send-later is
535 + // a feature of the queue rather than a setting beside it. A queued message is a
536 + // draft that has been committed to send, which is Eudora's Out mailbox and what
537 + // keeps the drafts list working unchanged.
538 +
539 + /// A saved draft, which is what everything below queues.
540 + fn draft(repo: &SqliteEmailRepository, user_id: goingson_core::UserId) -> goingson_core::Email {
541 + repo.save_draft(
542 + goingson_core::EmailId::new(),
543 + user_id,
544 + "me@example.com",
545 + "them@example.com",
546 + None,
547 + None,
548 + "Subject",
549 + "Body",
550 + None,
551 + None,
552 + None,
553 + None,
554 + )
555 + .expect("the draft saves")
556 + }
557 +
558 + #[test]
559 + fn queueing_a_draft_puts_it_in_the_outbox_and_leaves_it_a_draft() {
560 + let db = common::setup_test_db();
561 + let user_id = common::create_test_user(&db);
562 + let repo = SqliteEmailRepository::new(db);
563 + let saved = draft(&repo, user_id);
564 +
565 + // Not in the outbox until it is queued: saving is not sending.
566 + assert!(repo.list_outbox(user_id).expect("read").is_empty());
567 +
568 + let queued = repo
569 + .queue_draft(saved.id, user_id, None)
570 + .expect("queue")
571 + .expect("the draft is there");
572 +
573 + assert!(queued.is_queued());
574 + assert!(
575 + queued.is_draft,
576 + "still a draft, which is what keeps the rest working"
577 + );
578 + assert!(
579 + queued.send_after.is_none(),
580 + "no instant means as soon as possible"
581 + );
582 + assert_eq!(repo.list_outbox(user_id).expect("read").len(), 1);
583 + }
584 +
585 + #[test]
586 + fn send_later_is_the_whole_of_an_instant_on_the_row() {
587 + let db = common::setup_test_db();
588 + let user_id = common::create_test_user(&db);
589 + let repo = SqliteEmailRepository::new(db);
590 +
591 + let soon = draft(&repo, user_id);
592 + let later = draft(&repo, user_id);
593 + let now = Utc::now();
594 +
595 + repo.queue_draft(soon.id, user_id, None).expect("queue");
596 + repo.queue_draft(later.id, user_id, Some(now + Duration::hours(3)))
597 + .expect("queue later");
598 +
599 + // Both are in the outbox, because the outbox is what has been committed to
600 + // send rather than what is going right now.
601 + assert_eq!(repo.list_outbox(user_id).expect("read").len(), 2);
602 +
603 + // Only one is due. This is the whole of send-later: the drainer asks the
604 + // clock, and a message with an instant in the future is simply not in the
605 + // answer.
606 + let due = repo.list_due(user_id, now).expect("read due");
607 + assert_eq!(due.len(), 1);
608 + assert_eq!(due[0].id, soon.id);
609 +
610 + // And it becomes due by time passing, with nothing armed or scheduled. A
611 + // message written while the app was shut goes when the app comes back.
612 + let due = repo
613 + .list_due(user_id, now + Duration::hours(4))
614 + .expect("read due");
615 + assert_eq!(due.len(), 2);
616 + }
617 +
618 + #[test]
619 + fn a_message_taken_back_out_is_an_ordinary_draft_again() {
620 + let db = common::setup_test_db();
621 + let user_id = common::create_test_user(&db);
622 + let repo = SqliteEmailRepository::new(db);
623 + let saved = draft(&repo, user_id);
624 +
625 + repo.queue_draft(saved.id, user_id, Some(Utc::now() + Duration::hours(1)))
626 + .expect("queue");
627 + repo.record_send_failure(saved.id, user_id, "the server said no")
628 + .expect("record");
629 +
630 + let back = repo
631 + .unqueue_draft(saved.id, user_id)
632 + .expect("unqueue")
633 + .expect("it is there");
634 +
635 + assert!(!back.is_queued());
636 + assert!(back.send_after.is_none(), "the schedule goes with it");
637 + // The failure goes too: somebody who has taken a message back to edit it
638 + // should not be looking at the error from before the edit.
639 + assert!(back.send_error.is_none());
640 + assert_eq!(back.send_attempts, 0);
641 + assert!(repo.list_outbox(user_id).expect("read").is_empty());
642 + }
643 +
644 + #[test]
645 + fn a_failed_send_stays_in_the_outbox_and_says_why() {
646 + let db = common::setup_test_db();
647 + let user_id = common::create_test_user(&db);
648 + let repo = SqliteEmailRepository::new(db);
649 + let saved = draft(&repo, user_id);
650 + repo.queue_draft(saved.id, user_id, None).expect("queue");
651 +
652 + repo.record_send_failure(saved.id, user_id, "connection refused")
653 + .expect("record");
654 + repo.record_send_failure(saved.id, user_id, "connection refused")
655 + .expect("record again");
656 +
657 + let still = repo.list_outbox(user_id).expect("read");
658 + assert_eq!(still.len(), 1, "a failure does not drop the message");
659 + assert_eq!(still[0].send_attempts, 2);
660 + assert_eq!(still[0].send_error.as_deref(), Some("connection refused"));
661 + // Still due, so the next tick tries again. The backoff is the drainer's,
662 + // not the store's.
663 + assert_eq!(repo.list_due(user_id, Utc::now()).expect("due").len(), 1);
664 + }
665 +
666 + #[test]
667 + fn a_received_message_cannot_be_queued() {
668 + // Queueing something that is not a draft would be the app offering to send
669 + // a message somebody else sent it. Refused by the write not matching, so
670 + // there is no window between a check and an update.
671 + let db = common::setup_test_db();
672 + let user_id = common::create_test_user(&db);
673 + let repo = SqliteEmailRepository::new(db);
674 +
675 + let received = repo
676 + .create(
677 + user_id,
678 + NewEmail {
679 + project_id: None,
680 + from_address: "them@example.com".to_string(),
681 + to_address: "me@example.com".to_string(),
682 + subject: "Incoming".to_string(),
683 + body: "Hello".to_string(),
684 + is_read: false,
685 + received_at: Some(Utc::now()),
686 + },
687 + )
688 + .expect("create");
689 +
690 + assert!(
691 + repo.queue_draft(received.id, user_id, None)
692 + .expect("ask")
693 + .is_none()
694 + );
695 + assert!(repo.list_outbox(user_id).expect("read").is_empty());
696 + }
697 +
698 + #[test]
699 + fn the_outbox_is_one_persons_own() {
700 + let db = common::setup_test_db();
701 + let user_id = common::create_test_user(&db);
702 + let repo = SqliteEmailRepository::new(db.clone());
703 + let saved = draft(&repo, user_id);
704 + repo.queue_draft(saved.id, user_id, None).expect("queue");
705 +
706 + let other = common::create_test_user(&db);
707 + assert!(repo.list_outbox(other).expect("read").is_empty());
708 + assert!(
709 + repo.queue_draft(saved.id, other, None)
710 + .expect("ask")
711 + .is_none()
712 + );
713 + assert!(repo.unqueue_draft(saved.id, other).expect("ask").is_none());
714 + }