max / goingson
| 1 | -- An outbox, and send-later with it. |
| 2 | -- |
| 3 | -- Ruled by Max 2026-08-22 (task a3c76a24): "have GoingsOn use an outbox model |
| 4 | -- explicitly and leverage that to make send-later a first class feature." |
| 5 | -- |
| 6 | -- WHAT LED HERE, so the columns are not mistaken for plumbing. `send_email` is |
| 7 | -- async: it opens an SMTP connection, and `commands/email/send.rs` has seven |
| 8 | -- `.await` sites. A described route handler is synchronous, by |
| 9 | -- `quasi_router`'s Decision 6, which exists so egui and a terminal do not need |
| 10 | -- a runtime. So compose could not be described at all while sending was |
| 11 | -- something a button did. |
| 12 | -- |
| 13 | -- Queueing is a local write, so it can be. And an outbox is not the consolation |
| 14 | -- prize for that: it is a message you can see before it goes and stop, a |
| 15 | -- message you can schedule, and a send that survives being offline instead of |
| 16 | -- failing at the instant you pressed the button. Eudora shipped exactly this |
| 17 | -- and named the distinction Send versus Queue. |
| 18 | -- |
| 19 | -- A QUEUED MESSAGE IS A DRAFT THAT HAS BEEN COMMITTED TO SEND, so it stays in |
| 20 | -- `emails` rather than moving to a table of its own. Eudora's Out mailbox is |
| 21 | -- the same idea. `save_draft`, the drafts list and the compose screen loading |
| 22 | -- one all keep working with no change, and the outbox is a query rather than a |
| 23 | -- second store that has to be kept in step: |
| 24 | -- |
| 25 | -- is_draft = 1 AND queued_at IS NOT NULL |
| 26 | -- |
| 27 | -- Every column is NULL for a message nobody queued, so this migration changes |
| 28 | -- nothing about an existing row and needs no backfill. |
| 29 | -- |
| 30 | -- `emails` is per-device and not synced (see `syncstore::manifest`), so there |
| 31 | -- are no sync triggers to rewrite here. That is also the honest behaviour: a |
| 32 | -- queued message belongs to the machine that will send it, and two machines |
| 33 | -- draining one outbox would send twice. |
| 34 | |
| 35 | emails ADD COLUMN queued_at TEXT; |
| 36 | |
| 37 | -- The instant the message may leave. NULL means as soon as the drainer next |
| 38 | -- wakes, which is the ordinary case; a value is send-later, and it is the whole |
| 39 | -- of that feature. Compared against `now` by the drainer rather than by a |
| 40 | -- timer, so a message scheduled while the app was shut still goes when the app |
| 41 | -- comes back rather than being missed. |
| 42 | emails ADD COLUMN send_after TEXT; |
| 43 | |
| 44 | -- How many times the drainer has tried. Kept so a message that cannot go says |
| 45 | -- so rather than retrying silently forever, and so the outbox can back off. |
| 46 | emails ADD COLUMN send_attempts INTEGER NOT NULL DEFAULT 0; |
| 47 | |
| 48 | -- Why the last attempt failed, for the outbox to show. NULL once an attempt |
| 49 | -- succeeds, which never persists: a sent message stops being a draft. |
| 50 | emails ADD COLUMN send_error TEXT; |
| 51 | |
| 52 | -- What the drainer asks for on every wake: queued, due, oldest first. Partial, |
| 53 | -- because the outbox is a handful of rows in a table of tens of thousands. |
| 54 | ON emails(queued_at) WHERE queued_at IS NOT NULL; |
| 55 |