The function is called run_once(). It lives in inbox_sync. On every execution it walks the Gmail history since the last persisted historyId cursor and sorts each message into one of four buckets: queue the sender for review, audit-only on blocked, skip muted or unsubscribed, silent pass-through on allowed. Four branches. No fifth. When I was scoping sprint E3 I wrote that classification logic first and treated everything else as delivery infrastructure around it: the SSE broadcaster, the SQLite schema, the Shadow DOM UI, the launchd cron. That turned out to be the right mental model. It is also the thing worth writing down before the infrastructure detail buries it.

What the four branches actually mean

Before the code: what the branches are, because the naming does real work.

Queue is the only branch that produces user-visible work. An unknown sender, someone not in any rule, lands a row in inbox_new_senders_queue. That row surfaces in the UI. The user makes a decision. The system learns.

Audit is for blocked senders. The message is not actioned, but a row lands in inbox_message_actions_audit. The block is recorded, the audit trail grows. No UI interruption.

Skip is for muted senders and unsubscribed lists. No action, no audit row. The classifier has decided there is nothing to record.

Silent is for allowed senders. The classifier has no opinion. The message passes through Gmail as normal.

Queue is the only branch with side-effects the user sees. Audit has side-effects only the system sees. Skip and silent produce nothing at all. That asymmetry is the whole product in one sentence: most messages do nothing; a small number create work; the classification determines which is which.

The schema reflects this. Five tables (inbox_accounts, inbox_sender_rules, inbox_new_senders_queue, inbox_message_actions_audit, inbox_sync_state), but only two of them accumulate data at runtime. inbox_new_senders_queue and inbox_message_actions_audit are the live outputs of the classifier. The other three are its inputs: account credentials, classification rules, and the cursor that tells it where in the Gmail history to start.

Why this ordering matters

I could have built the UI first. The temptation with a dashboard project is always to start with the visible surface: three-column grid, media-query breakpoints, component files. Sprint E6 is genuinely satisfying to ship: the four Shadow DOM components under docs/brain-guide/components/, the inbox.html scaffold, the two responsive breakpoints collapsing to a single-column mobile layout. It looks like the product.

It is not the product.

If I had built E6 before E3, I would have been designing UI around an inbox I had not yet classified. The columns would have assumed categories that might not exist. The components, <bb-inbox-sidebar> and the rest, would have been leading the data model rather than following it. This is a common failure mode in dashboard work: the UI bakes in assumptions about what the data looks like before you know what the data looks like.

Building the classifier first means the schema is authoritative and the UI is a view over it. inbox_new_senders_queue exists because the queue branch produces rows. The UI component that renders those rows is a consequence, not a cause. That direction of dependency is the one you want.

The infrastructure, in order

Having established what the core is, the surrounding layers are worth describing briefly. Not because they are complex, but because the order in which they were built reflects the dependency chain.

E1: Schema and helpers. Five tables, five indexes. The partial index idx_new_senders_pending is worth noting: it indexes only the pending rows in inbox_new_senders_queue, not the whole table. That is a deliberate trade-off. The queue is expected to be small; the pending subset smaller still. The partial index keeps the common-path query fast without indexing rows the UI never queries directly.

E3: inbox_sync + launchd cron. run_once() is the classifier. The launchd cron is a scheduling wrapper using the same pattern from the earlier GitHub data refresh work, with the .app bundle approach that resolves the TCC FDA problem on external volumes. inbox_sync persists the Gmail historyId cursor to inbox_sync_state after each run, so the next execution starts exactly where the last one ended. Incremental by design; no full-mailbox scans after the initial seed.

E4: HTTP routes + SSE. Twelve route handlers in routes.py, pure functions returning (status, body) tuples. The SSE broadcaster in sse.py enforces three constraints: a 64-subscriber cap, a 30-second keepalive interval, and a slow-subscriber drop policy. The cap and the drop policy are the trade-off. Rather than queue backpressure or unbounded subscriber growth, slow consumers are evicted. For a personal dashboard with at most a handful of simultaneous sessions, 64 is not a constraint that will bind; the cap is there so the constraint is explicit rather than implicit.

E5: New Senders badge. A small addition to the existing email triage card in index.html. The badge polls /api/v1/inbox/new-senders/count every 60 seconds alongside loadToday(). It is hidden when the count is zero. This is the queue branch made visible at the dashboard level without requiring the user to navigate to the Inbox Hub. The count is derived from inbox_new_senders_queue; the badge is a projection of the classifier’s queue output into the main dashboard surface.

E6: inbox.html + Shadow DOM components. The Inbox Hub UI. Three-column grid, collapsing to two columns then one on mobile. Four Shadow DOM components encapsulate the four main UI regions. Built last because it depends on the routes, which depend on the schema, which depends on the classifier having defined what data exists.

What the SSE broadcaster is actually for

Sprint E4 closed with sse.py described as a broadcaster, and it is worth being precise about what it broadcasts and why that matters.

The classifier runs on a cron. The UI polls for badge counts on a 60-second interval. Without SSE, the only way the Inbox Hub updates is on page load or manual refresh. SSE closes that gap: when run_once() processes a batch and new rows land in inbox_new_senders_queue, the broadcaster pushes an event to connected subscribers and the UI updates without a reload.

This is delivery infrastructure. The classifier does not know about SSE. inbox_sync writes rows; sse.py notices and broadcasts. The separation is deliberate. If SSE were removed, the classifier would continue classifying and the rows would continue accumulating; the only thing lost would be real-time push to the browser. The core is independent of the delivery mechanism.

Restate the cap and drop policy in this context. The broadcaster is designed for a personal dashboard, not a multi-tenant service. The 64-subscriber cap is a ceiling on complexity, not a capacity target. The slow-subscriber drop means the broadcaster never blocks on a slow consumer. These are the right trade-offs for the use case; they would be wrong choices for a shared production system.

The partial index is a decision, not a detail

idx_new_senders_pending is the one schema decision that carries a non-obvious justification. A standard index on inbox_new_senders_queue would index every row, including those already actioned. The UI never queries actioned rows directly via the index path; they are retrieved via the audit trail or not at all. A partial index on pending rows only means the index stays small as the queue is worked through over time.

The alternative would have been fine for a small table: full index, simpler schema. The partial index is a statement of intent. The pending queue is the hot path, everything else is cold storage, and the index should reflect that. It is a small decision. It is also the kind of decision that becomes load-bearing if the table grows and nobody can remember why the index was scoped that way.

What I got wrong on the first pass

E3 shipped with run_once() accumulating classification results before persisting. The intent was to batch the SQLite writes. The problem is that a crash mid-batch loses the accumulated results but the historyId cursor has not yet been updated, so the next run re-processes the same messages. The messages get re-queued.

The fix is straightforward: persist each classification result as it is produced, update the cursor last. This means a crash leaves the cursor behind the actual processing position, and the next run re-processes a small tail of already-handled messages. Re-processing a message that is already in inbox_new_senders_queue as pending is handled by the unique constraint on the sender; re-processing a message that was audit-only or skipped produces a duplicate audit row. Neither is catastrophic. The cursor-last ordering makes the failure mode recoverable rather than silent.

Out of scope: deduplication of audit rows, idempotency tokens on the sync state. Both are solvable; neither is blocking.

Where this goes

The classifier has four branches. It will always have four branches. New rules change which branch a given sender lands in; they do not add branches. The UI surfaces the queue branch and the audit trail. The badge surfaces the queue count. The cron drives the whole thing on a schedule.

If the product grows to multiple accounts, shared access, or a rule management UI, the classifier is still the core and everything else is still delivery. The dependency direction stays the same. Build the classification logic, define the schema it produces, then build the surfaces that read it.

The build log at docs/devlog.md has the E1 through E6 entries in full, including the schema DDL and the route handler signatures. This article is the consolidated reasoning; the log is the canonical record.

All writing