The new senders badge appeared beside the urgent-email count on the first test load after E5 closed. Non-zero. New senders from that morning, queued and waiting for a decision. The inbox had a shape, not just a volume.
Five sprints, one day. Here is what the build was.
The problem
Gmail gives you stars, labels, and delivery filters. The filters are rules applied at arrival; once you have a few dozen, they’re unauditable. Stars are manual. Nothing in the default UI distinguishes “sender I’ve never seen before” from “sender I consciously ignored last month.”
The signal I actually need is narrow: unknown senders require a decision. Senders with an allow rule don’t. Senders I’ve blocked don’t. The inbox feels intractable because it collapses those three cases into one scrollable list.
The Inbox Hub doesn’t try to be a better email client. It extracts the one signal that matters and makes it visible.
E1: schema
Five SQLite tables:
inbox_accounts: accounts under managementinbox_sender_rules: allow / block / mute / unsubscribe per sender addressinbox_new_senders_queue: senders with no rule yetinbox_message_actions_audit: every action taken, loggedinbox_sync_state: per-accounthistoryIdcursor for incremental sync
Five indexes. The one that matters is idx_new_senders_pending, a partial index over inbox_new_senders_queue filtered to rows with no decision yet.
The full queue table grows over time. Every sender I’ve ever allowed or blocked accumulates there. The partial index stays small because it only covers undecided rows. Query cost for the badge count stays proportional to open work, not to history.
That is the right constraint. Unresolved decisions should be cheap to count. Past decisions should be cheap to ignore.
E3: the classifier
inbox_sync.run_once() implements classification. Four branches:
- Unknown sender: no rule exists, enqueue in
inbox_new_senders_queue - Blocked: rule exists, action = block; write an audit row, no queue entry, no UI surface
- Muted or unsubscribed: skip entirely; no audit row, no noise
- Allowed: silent pass-through; nothing to surface
These four states are the complete sender space. Unknown, rejected, silenced, permitted. No fifth case. An inference layer could add probabilistic bucketing: “probably spam,” “probably cold outreach.” That would introduce false positives where real unknowns get classified as known. The rules table is the model. Four comparisons, deterministic.
The sync advances the historyId cursor after each successful batch. Gmail’s History API is incremental: pass the last cursor, receive everything since. Lose the cursor, re-sync from the checkpoint. Store it. Runs stay cheap regardless of inbox volume.
E4: the API layer
/api/v1/inbox/* mounts on brain_server.py with bearer auth. Three new modules back the routes. sse.py handles the broadcaster: 64-subscriber cap, 30-second keepalive pings, automatic slow-subscriber drop. routes.py holds 12 pure-function handlers.
The SSE shape mirrors the pipeline events channel. A sender decision (allow, block, mute, or unsubscribe) pushes an event to all live subscribers. The badge updates without a page reload.
The 64-subscriber cap is borrowed from the pipeline channel. Localhost will never approach it. The discipline of having it matters anyway. An unbounded subscriber list is a slow memory leak waiting for an edge case that never arrives until it does.
E5: the badge
One change to the existing dashboard. The email triage card in index.html gained a secondary count: new senders pending alongside the urgent-email count already there. The component polls /api/v1/inbox/new-senders/count every 60 seconds, alongside the existing loadToday() call. Hidden when the count is zero.
Zero is the normal state. The badge is visible only when there is something to decide. A persistent widget trained into invisibility would be worse than none.
E6: inbox.html
The full UI: a three-column grid with two media-query breakpoints collapsing to two columns then one on mobile. Four Shadow DOM components under docs/brain-guide/components/, including <bb-inbox-sidebar> for the account list and sync state.
Shadow DOM for the same reason the pipeline components use it: each component manages its own polling loop, its own SSE subscription, its own styles. Shared state is thin — the bearer token and the selected account ID.
The layout is minimal by design. One panel for the sender queue, one for the message, one for the rule. Decide, advance. The structure encodes the workflow rather than decorating around it.
What the build proved
The partial index holds under load. A full-table scan of inbox_new_senders_queue every 60 seconds is fine today. Six months out, with thousands of historical rows, it isn’t. The partial index on pending rows bounds the count query at open decisions, not at the size of the history.
The four-branch classifier is intentionally non-magical. Each branch is one comparison against inbox_sender_rules. No model, no scoring. If a sender has no rule, they’re unknown. That is the whole logic.
Out of scope: anything that reads the message body. The classifier operates on sender identity alone. The message detail panel shows the body for human review; the classification never touches it. That keeps the sync cheap and closes off any surface for injection via email content.
The result
Five sprints, one day. Schema first. Classifier and sync next. API layer third — sse.py needed its own test suite. UI last.
What shipped: a badge that appears when a decision is needed, a panel to make that decision, a sync loop advancing on Gmail’s incremental history feed. The classification is four branches. The speed is one index. Gmail hasn’t changed. The habits haven’t changed. The surface that was opaque now has a read: who is actually new, what do they want, have I seen them before.
