The inbox filter model drops strangers silently. I built a queue instead.

The four-branch classifier in inbox_sync.run_once() has exactly one silent branch: mail from senders already on the allow list. Every other branch writes a row somewhere. That asymmetry is the entire design of the Inbox Hub, stated in two sentences.

The filter assumption

Most inbox tooling runs on a filter model. Rules applied in sequence, each one routing or dropping mail. What doesn’t match a rule ends up in the noise or the bin, depending on how aggressive the setup is. The implicit assumption is that relevant mail will eventually match a rule you’ve already written, and senders you haven’t seen before are probably not worth tracking until they prove otherwise.

That assumption fails slowly. Slowly enough that you don’t notice until you go looking. A prospect who emailed once and heard nothing back because the routing rule didn’t exist yet. A blocked sender, suppressed without a log entry. A domain you’d care about, lost in the wrong bucket. None of these surface unless you audit, and auditing is manual by default.

There’s also a compounding problem. Once you’ve been running a filter model for a few years, you have no idea what it’s been silently dropping. The filter only acts on what arrives after it exists. Mail from before you tightened the rules is gone, and you can’t even see the shape of what you missed.

The Inbox Hub is built on a different premise: unknown senders are worth reviewing, blocked senders are worth auditing, and nothing should be discarded without a record. Filter models drop. This one parks.

The schema

Phase E1 landed five SQLite tables: inbox_accounts, inbox_sender_rules, inbox_new_senders_queue, inbox_message_actions_audit, and inbox_sync_state.

The names are self-explanatory; the relationships matter more. inbox_sender_rules is the classification layer: allow, block, mute, unsubscribe. inbox_new_senders_queue is the parking lot for anything that doesn’t match a rule. inbox_message_actions_audit records what happened to each message, write-once, and inbox_sync_state holds the historyId cursor per account.

That last table is load-bearing in a subtle way. Gmail’s incremental history API doesn’t give you “all mail since date X.” It gives you “all changes since historyId Y.” A historyId is an opaque sequence number Gmail increments on each change. Persisting it per account is what makes the sync resumable: if the process restarts, the next run_once() call picks up exactly where it left off rather than re-scanning from scratch.

Five indexes ship with the schema, including one partial: idx_new_senders_pending, covering only the rows in the queue with status = 'pending'. Almost all inbox read paths care about pending items, not resolved history. The full table grows indefinitely; the partial index stays small.

The sync loop

inbox_sync.run_once() fetches the Gmail history delta for the account, processes each new sender, and routes them into one of four branches.

Allowed: Silent. The sender is on the allow list. Nothing written, cursor advances.

Blocked: Audit-only. The sender is blocked. A row lands in inbox_message_actions_audit: arrival recorded, suppression recorded. You know it came in; there’s a row if you look for it later.

Muted or unsubscribed: Skip. No queue entry, no audit row. These are senders explicitly classified as noise: marketing lists, automated digests, anything you’ve opted out of tracking.

Unknown: Queue. A row lands in inbox_new_senders_queue with status pending. The cursor in inbox_sync_state advances only after the classification pass completes, so a crash mid-sync doesn’t silently skip senders. The next run re-processes the same delta.

The distinction matters. Both produce a row, but for different reasons. A blocked sender writes an audit row: known, arrived, suppressed. An unknown sender writes a queue row that records the arrival and flags it as a pending decision. The audit table is history. The queue is the active workflow.

The fourth branch is the gap. In a filter model, an unknown sender lands in noise or gets binned. Here they get a pending row, and the queue surfaces them for classification.

Routes and SSE

E4 mounted /api/v1/inbox/* on brain_server.py with bearer auth. Twelve handlers, all pure functions returning (status, body). State writes delegated to the DB helpers from E1; nothing stateful inside the route layer itself.

The SSE broadcaster follows the conventions established by the pipeline event channel: 64-subscriber cap, 30-second keepalives, slow-subscriber drop on back-pressure. Dashboard components subscribe directly to live queue state; no polling needed for updates.

One trade-off worth naming: the 64-subscriber cap is shared with the pipeline broadcaster. Two SSE consumers against the same ceiling. At current usage that’s irrelevant. If the editorial pipeline ever runs concurrent sessions it becomes a real constraint.

The dashboard

E5 is the minimal surface: a New Senders badge beside the urgent-email count on the existing triage card in index.html. It polls /api/v1/inbox/new-senders/count every 60 seconds alongside the rest of loadToday(). Hidden when the count is zero. One number, one endpoint, no new component.

E6 is the dedicated view: inbox.html, a 3-column grid collapsing to 2-column then single-column at two breakpoints. Four Shadow DOM components handle the layout under docs/brain-guide/components/: <bb-inbox-sidebar> for account selection and rule summary, plus panels for the queue and audit history.

Shadow DOM has been the established pattern since Sprint 2.3. Inbox components follow the same conventions: self-contained, no shared global state, slot-based customisation where the parent page needs to inject content. Each component manages its own SSE subscription and live state. Nothing coordinates through a shared store.

What this isn’t

A replacement for Gmail’s server-side filtering. The inbox_sender_rules table classifies senders for the editorial pipeline’s purposes only. Gmail’s own labels and filters run independently.

A triage interface for acting on queued mail. E6 shows the queue and lets senders be classified. It doesn’t draft replies, surface prior context, or hand off to the /dream pipeline. Those are later surfaces.

Out of scope: multi-account sync beyond the schema support already in inbox_accounts. The table is there. run_once() isn’t yet looping across accounts.

Where this goes

The queue fills over time. Every unknown sender who arrives once gets a pending row. The ongoing work is triaging those rows by assigning allow, block, mute, or unsubscribe. Eventually the queue settles toward empty, and new entries are genuinely novel rather than accumulated backlog.

The audit trail in inbox_message_actions_audit is useful before that point. Every blocked arrival is on record. Every queued stranger is on record. The historyId cursor means the record is complete from the moment sync was enabled. Not just from the moment a rule existed.

Phase E7 is the next gate: a manual smoke runbook, then a 7-day soak with real Gmail traffic, before any deeper integration work starts.

All writing