logicspike/docs

Mailbox

Mailbox Service — Backlog: Issues, Gaps & Improvement Scope

Last Updated: 2026-10-07 Status: Active Purpose: Known bugs, missing features, and improvement opportunities found during the 2026-09-14 mailbox + newsletter audit. Mark items done with a date when fixed. Do not delete fixed items.

This is the mailbox worker's half of the audit report at .claude/plans/take-your-time-bubbly-hejlsberg.md (finding ids MB- backend, MF- dashboard). Newsletter and comms findings are tracked in docs/newsletter/backlog.md and docs/communication/backlog.md.


Fixed Items

2026-09-14 — Mailbox + Newsletter audit, Phase 0 (security & money)

Branch audit/phase-0-security, on main as 321757ea. Mailbox-relevant fixes:

Finding Fix Test
MF-01 / MF-02 the composer could silently drop a message or double-send after a failed send markPending/clearPending around the undo window; markDispatched runs only after the keepalive flush resolves; oversize flushes keep the draft instead of dropping it inbox/drafts.test.ts, inbox/send.test.ts
MF-07 / MF-27 mailbox cost was shown as one-time; the backend registers a monthly recurring charge Copy updated to "N credits/month"; DTO carries canReceive for the send-only warning manual
MB-02 (interim) custom-domain mailboxes cannot receive mail, with no warning in the UI canReceive on the account DTO; setup and reply/compose flag it manual

2026-09-15 — Mailbox + Newsletter audit, Phase 1 (correctness hotfixes)

Branch audit/phase-1; merged to main and deployed (Phases 0–3 are on main and live). 9 PRs (P1–P9b); rows below cover mailbox (P1, P2, P3).

Finding Fix Test
MB-01 / MF-04 thread messages paginated from the oldest end; the newest page was unreachable past 50 messages, and the thread list was hard-capped at 25 with no pager Keyset cursors on (created_at, id), before/after on GET /threads/:id; GET /threads returns nextCursor and a per-thread cursor; a bare legacy ISO cursor still works test/paging-and-errors.test.ts, pages/MailboxPage merge tests
MB-03 ensureAccount's post-conflict re-select ignored tenant, so a lost race could return another tenant's account onConflictDoNothing() with no target, then a tenant-checked re-select; throws AddressTakenError on a real cross-tenant collision test/integrity.test.ts
MB-08 comms 4xx errors (invalid address, insufficient credits, rate limited) were flattened into a generic 502 mapSendStatus passes through 402/403/422/429 (with Retry-After) and maps 400 to 422; to validated locally test/paging-and-errors.test.ts
MB-09 a worker eviction between thread-create and send-record could leave a phantom empty thread Compose derives ids and sends before any thread row exists; a record failure deletes the thread only when it has zero messages test/threads-route.test.ts
MB-19 the deterministic Message-ID used FNV-1a on a client-chosen key — not collision-resistant against a chosen input HMAC-SHA256 keyed with INTERNAL_KEY; legacy FNV id kept for replay lookup only test/integrity.test.ts
MB-21 attachment downloads had no nosniff header and stripped non-ASCII filenames instead of RFC 5987-encoding them X-Content-Type-Options: nosniff; filename ASCII fallback plus filename* test/attachments.test.ts
MB-34 the mailbox's minted Message-ID never reached the wire, so a reply's References could never match an HMAC id — only the 30-day subject/participant fallback threaded it findThread also matches outbound providerMessageId against non-vlozi.app reference local-parts test/integrity.test.ts
P1-N5 two concurrent composes with the same idempotency key could each create a thread createOutboundThread uses onConflictDoNothing({ target: id }) on the deterministic thread id test/integrity.test.ts
P1-N6 two "first mailbox" provisions for one tenant could both become the default Unique index mailbox_accounts_one_default (tenant_id WHERE is_default), applied via scripts/apply-2026-09-default-idx.mjs test/integrity.test.ts
MF-06 an account-level admin (not mailbox:accounts.manage) couldn't open the Manage page the backend already allowed them into MailboxAdminPage gates on mailbox:accounts.manage OR mailbox:access.manage; GET /accounts?view=admin; canAdmin on the DTO pages/MailboxAdminPage.test.tsx
MF-11 a silent poll during "load earlier" could wipe paged-in history mergeMessages/detectGap/mergeThreadList reconcile a poll against what's loaded instead of replacing it inbox/thread-merge.test.ts
MF-20 a teammates/domains load failure rendered a misleading empty state fetchVerifiedDomains/fetchTeamMembers throw on non-2xx instead of resolving to [] manual
NF-11 / MF-05 mailbox's own comms-backed routes (sending domains, sender settings) 403'd for a mailbox-only tenant See docs/communication/backlog.md — gateway commsEntitlementFor widens these routes to comms/mailbox/newsletter apps/gateway/test/comms-entitlement.test.ts

2026-09-28 — Phase 5 PR1 (Inbox / Sent / Archived + delivery status, backend)

Branch audit/phase-5; deployed 2026-10-05 together with Phase 4 (workers, schemas, dashboard, website). It needed scripts/apply-2026-09-phase5.mjs (--columns before the worker, --backfill and --indexes after) and scripts/backfill-delivery-status.mjs after comms ships. The rollout record does not say whether the delivery-status backfill was run: confirm it, because mail sent before the deploy shows "sent" forever without it.

Finding Fix Test
No Sent view: a thread you composed was mixed into the inbox, and once archived there was no way to find what you had sent GET /threads?filter=inbox|unread|sent|archived (Gmail semantics; all unchanged for old clients); mailbox_threads.last_outbound_at orders and pages Sent test/sections.test.ts
sent_status was written once as "sent" and never updated, so a bounced reply looked delivered forever comms forwards delivered / hard bounce / complaint for mbx: sends to POST /internal/delivery-event; per-recipient recipient_status, monotonic sent_status, delivery_problem_at on the thread for the newest send test/delivery-event.test.ts, apps/communication/test/mailbox-delivery-forward.test.ts
P3-N3 the mailbox stored comms' id (its log UUID when no provider id existed) as provider_message_id Reads providerMessageId first; sends carry metadata { source: "mailbox", messageId, accountId, threadId } test/sections.test.ts
Mail sent before this shipped would show "sent" forever scripts/backfill-delivery-status.mjs copies comms' known outcomes with the same monotonic statements test/phase5-scripts.test.ts

2026-09-29 — Phase 5 PR2–PR5b (sections UI, direct uploads, typed client, inbox helpers + hooks)

Not yet deployed. PR3 needs the mailbox worker and the gateway deployed before the dashboard (the dashboard falls back to the proxied upload until they are).

Finding Fix Test
Sections: Inbox / Sent / Archived were All / Unread / Archived buttons that shrank to bare icons on a phone, with no Sent view Always-labelled section tabs, Unread and Failed toggles, view in the URL, g i/s/a, per-section empty states, "Delivery problem" chip, per-recipient outcomes on sent messages pages/MailboxPage.sections.test.tsx, inbox/sections.test.ts, inbox/MessageCard.test.tsx, inbox/ThreadList.test.tsx
MF-03 a file over ~4.5 MB could never upload: the Vercel proxy buffers the body and Vercel caps it, while the UI promised 10 MB Ticket-authorised direct upload to the gateway (/v1/attachments/upload-ticket → /mailbox/v1/attachments/upload-direct), one fallback to the proxied route test/upload-ticket.test.ts, apps/gateway/test/mailbox-upload-door.test.ts, api/upload.test.ts
MF-12 uploads were filed under the default sendable mailbox, not the one being sent from The composer passes the reply thread's / From mailbox; the ticket (or the proxied form) names it inbox/useAttachments.test.tsx
MF-35 an upload had no timeout 90 s XHR timeout, reported (not retried through the slower proxy) api/upload.test.ts
MF-42 removing a chip or switching thread left its upload running One AbortController per upload; remove, retry, reset and unmount abort it; a cancellation is silent inbox/useAttachments.test.tsx
Typed client (plan 5.1, mailbox): every response was an unchecked as Promise<…> cast (22 of them), and three ways of reporting an error api/mailbox.schemas.ts (zod; the exported types are inferred from it); req/rawJson validate every 2xx (BAD_RESPONSE otherwise) and throw one MailboxError with status, code, retryAfter; comms, manager and the stream ticket go through rawJson; shared useApiQuery hook api/mailbox.schemas.test.ts, api/mailbox.api.test.ts, src/lib/api/useApiQuery.test.tsx
"Load more" disappeared after one page: a cursor page's total: null overwrote the count the footer (and its button) depend on The page keeps the last known total when a page carries none pages/MailboxPage.sections.test.tsx
MF-34 the mailbox and newsletter proxies each hard-coded the gateway URL; ALIAS_DOMAIN was duplicated in the worker and the dashboard Both import @/lib/gateway; ALIAS_DOMAIN comes from @repo/core-types on both sides tsc
MF-30 after composing, the previously open thread's header stayed up over the new thread's loading state handleComposed closes the open thread before selecting the new one pages/MailboxPage.compose.test.tsx
MF-32 a sent reply vanished and reappeared: its bubble was removed on success, before the refetch with the real message landed The bubble is marked settled and dropped by the first thread load that started after it (inbox/optimistic.ts) inbox/format.test.ts
MF-46 the compose dock had no way to throw a draft away (Close keeps it) Discard button: clears the draft, its attachments and idempotency key, then closes inbox/ComposeDock.test.tsx
Plan 5.2 (first slice): relative time, thread title, file size, HTML escaping and the file icon were each copied into two components; forward-draft building lived inside the page inbox/format.ts, inbox/forward.ts (pure, tested) inbox/format.test.ts
Plan 5.2 (second slice): MailboxPage.tsx owned unread-count state, desktop-notification permission, the tab-title badge and the whole keyboard-shortcut handler inline (1114 lines) Three hooks: inbox/useUnreadCounts.ts (cache + refresh + optimistic adjust), inbox/useDesktopNotifications.ts (permission + notifyNewMail + useUnreadTabTitle), inbox/useInboxShortcuts.ts (the whole keydown handler, parameterised) — each independently tested; the page shrank to 984 lines with identical behaviour inbox/useUnreadCounts.test.ts, inbox/useDesktopNotifications.test.ts, inbox/useInboxShortcuts.test.tsx

2026-09-29 — Phase 5 PR6 (mailbox UX)

Finding Fix Test
MF-26 a load failure always said "the mailbox service didn't respond", whether the real cause was an expired session, a plan/billing issue, or the disabled-workspace case already handled errorCopyFor(status) (401/402/403/default), each with its own accurate title and description pages/errorCopyFor.test.ts
MF-28 archiving a thread had no undo — the only way back was finding it again in Archived The "Archived" toast carries an 8s Undo action that calls the same unarchive path; a failed undo says so instead of pretending the thread came back. Unarchiving (already reversible in one click) gets no action pages/MailboxPage.archive.test.tsx
MF-22 a brand-new grant's default caps (["read","send"]) matched no CapabilityPicker preset, so the dialog opened on the raw Advanced checkboxes instead of the friendly role buttons CapabilityPicker exports DEFAULT_ASSIGN_CAPS (the Agent preset); AssignAccessDialog's default and its per-open reset both use it components/CapabilityPicker.test.ts
MF-33 an SVG attachment previewed inline via <img> like any other image — safe only because <img> never executes SVG script; a guardrail against a future switch to <object>/an in-tab open image/svg+xml excluded from the preview filter; still downloadable inbox/AttachmentList.test.tsx
MF-43 SwipeableRow's release decision read the dx STATE value, which is one render behind the true finger position whenever a real device dispatches touchend before React flushes the last touchmove — jsdom's synchronous event model can't reproduce that race, so this is verified by code inspection, not a new test The decision now reads a ref (dxRef) written synchronously in the same handler that computes travel, never a render behind; dx state stays for the visual transform only inbox/SwipeableRow.touch.test.tsx (first component-level coverage at all — previously only the pure swipeOutcome function was tested)
MF-44 the command palette's hover highlight called setActive(i) on every pixel of mousemove, even while stationary over the same option Guarded to skip the call once the option is already active — a harmless call-overhead cleanup; React's own state-setter equality check already made this a no-op render-wise, so no test can observe a difference (confirmed by mutation-check) inbox/CommandPalette.test.tsx (first coverage at all for this component)
MF-48 a grant for a member who left the workspace (or the members list momentarily out of sync with a fresh grant) rendered the raw internal userId — an opaque string with no meaning to whoever's reading it Shared memberLabel(userId, members) falls back to "Former member", never the id, across MailboxAdminPage, MailboxSetupPage, AssignAccessDialog and CreateMailboxDialog inbox/format.test.ts, pages/MailboxAdminPage.test.tsx
MF-27 the compose cost was a hardcoded "1 credit" regardless of how many recipients were on the message (To/Cc/Bcc all bill) The credit estimate is computed from the actual recipient count via CREDIT_COSTS.EMAIL_RECIPIENTS_PER_CREDIT, updating live as Cc/Bcc are edited inbox/ComposeDock.test.tsx
MF-36 splitQuoted treated ANY line starting with "From:" as the start of quoted history, so an ordinary sentence ("From: our vendor, we heard...") could vanish behind the collapse toggle A "From:" line only starts a fold when a real Outlook-style header (Sent/Date/To/Subject) follows within the next few lines inbox/linkify.test.tsx
MF-37 an anchor inside a sanitized HTML email had no rel="noopener noreferrer" — the sandboxed iframe's injected <base target="_blank"> opens every link in a new tab regardless of the sender's own target, so this was the only thing actually blocking window.opener access from it The sanitizer forces rel="noopener noreferrer" on every <a>/<area>, overriding whatever the sender set, in both sanitize modes inbox/HtmlBody.sanitize.test.ts (also the first test coverage at all for sanitizeEmailHtml, the module's one security-critical pure function)
MF-41 an address-validation error (bad To/Cc/Bcc) stuck on screen until the next Send attempt, even after the field was corrected Editing the field an error is reported against clears it immediately, in both ReplyBox and ComposeDock inbox/ReplyBox.test.tsx, inbox/ComposeDock.test.tsx
MF-23 the thread list had no tabIndex, so nothing in it was reachable via Tab at all, and there was no screen-reader summary of the list; separately, the per-row hover-only actions (archive, mark read/unread) were display:none until :hover/:focus-within — a catch-22, since nothing inside a hidden subtree can ever receive focus to trigger :focus-within in the first place, so they were unreachable by keyboard no matter how the list itself was navigated Listbox container is tabIndex=0 (Home/End jump to the first/last thread; the existing global j/k/arrow shortcuts are unaffected); an aria-live="polite" status region announces "N conversations, M unread", as a sibling of the listbox. Row actions: the selected (cursor) row's actions are now always visible (not hover-gated) and the only ones with tabIndex=0 — Tab from the listbox lands on exactly that row's 1–2 buttons, not one Tab stop per loaded row; arrow-key/activedescendant navigation is untouched, so this needed no roving-tabindex rewrite of the list itself inbox/ThreadList.test.tsx
MF-21 the setup wizard had no permission gate at all, let "Next" advance from step 2 to step 3 with zero mailboxes created, then unconditionally said "You're set up" and listed a hardcoded notify@mail.vlozi.app (shared) address that may not correspond to anything the tenant has MailboxSetupWizard self-gates on mailbox:accounts.manage (same check MailboxPage uses before showing the "Set up Mailbox" CTA), showing the same "Admin access required" empty state MailboxAdminPage uses for a non-admin; step 2's "Next: review" is disabled until accounts.length > 0; step 3's title reads "No mailboxes yet" instead of "You're set up" when there are none (defense-in-depth — the button gate makes this branch unreachable through the UI); the "Your addresses" box now lists the tenant's real mailbox addresses instead of the alias-or-hardcoded-literal guess, with verified domains shown separately pages/MailboxSetupPage.test.tsx (first test coverage at all for this page)
MF-24 the rich-email viewer's theme was a one-shot document.documentElement.classList.contains("dark") read at render time, so toggling the app's theme while a message was open left the iframe frozen on the old theme; the "N remote content refs blocked" count was a flat regex scan of the RAW html before sanitizing, inflating it for real marketing mail with Outlook conditional-comment fallback blocks; the iframe was a fixed 45vh with internal scrolling (double scrollbars on anything longer) Theme now via next-themes' useTheme(), re-renders live; the blocked-count scan removes comments and <script> elements via a real parser (a first cut's second flat regex could itself under-count to a false 0, review-caught, fixed). Real auto-height: allow-scripts is granted on the sandbox paired with a hash-pinned CSP inside the srcDoc (script-src 'sha256-<hash of our own trusted height-reporting script>') — the sandbox alone can't distinguish our script from a sender's script sharing the same document, so the CSP is what actually enforces "only this one script runs"; never granted without its CSP, and allow-same-origin is still never granted under any condition. The reported height is validated (event.source/event.origin both checked) and clamped to [120px, 4000px] before being applied; a new message resets to the fixed fallback until its own report lands. A deliberate security-posture tradeoff — explicitly surfaced and confirmed before implementing, not decided unilaterally. cid: image handling was already resolved server-side in Phase 3 inbox/HtmlBody.sanitize.test.ts, inbox/HtmlBody.theme.test.tsx, inbox/HtmlBody.autoheight.test.tsx
MF-38 the Domains page used the browser's native confirm() for removing a domain (out of place next to useConfirmDialog everywhere else in this module, not keyboard-/screen-reader-friendly); the manual-DNS copy always said "these 3 CNAME records" regardless of how many dnsRecords actually came back or what type they were (the STATUS_TONE[d.status] crash on an unknown status was already fixed by an earlier pass, with a ?? fallback) removeDomain now uses the same imperative useConfirmDialog hook as MailboxAdminPage's destructive actions; new recordsPhrase() computes the real count and only names a record type when every record in the set really is that type, otherwise saying "N records" pages/SendingDomainsPage.test.tsx
MF-40 the worker's unread-counts endpoint counts open threads only (archived unread isn't an inbox obligation), but markReadEverywhere decremented the rail/tab-title badge by a thread's own unread count regardless of whether it was archived — silently under-reporting real unread elsewhere until the next full refresh quietly corrected it; toggleRead's mark-unread branch never bumped the badge at all markReadEverywhere/scheduleDwellRead now take the thread's status and skip the badge adjustment for archived threads (the row badge and the server call still happen either way); mark-unread now bumps the badge by 1 for open threads, reverted alongside the row state on a failed request. Review noted the dwell-timer's re-entrant guard can still drop a re-arm carrying a fresher status in a narrow (~1s) concurrent-archive race — judged consistent with the same self-correcting shape already accepted here for unreadCount, documented with a comment rather than fixed pages/MailboxPage.unread.test.tsx (first coverage at all for this accounting path)
MF-39 the all-mailboxes unread badge fell back to summing whatever thread page happened to be loaded (capped) whenever unreadCounts was {} — indistinguishable from "the fetch hasn't resolved yet" from "every mailbox is genuinely at zero" (the server only returns keys for mailboxes with unread), so the fallback ran forever for a genuinely all-read tenant instead of just during the brief pre-load window useUnreadCounts gained a loaded flag (true once a real answer has landed at all, cache hit or first resolve — even to {}); unreadTotalOf() gates on it instead of key count, and the pre-load estimate itself now also excludes archived threads (MF-40) inbox/useUnreadCounts.test.ts, pages/MailboxPage.unread.test.tsx
MF-50 the thread row's Mark as read/unread action (hover button and mobile swipe) was gated behind manage, the same capability Archive needs, even though the backend's /read and /unread routes only ever required read No longer gated on manageable at all — being on the list already implies read on the account (the list is scoped to accessible accounts), so nothing was left to check; Archive stays gated on manage, matching the backend inbox/ThreadList.test.tsx
MF-49 (mailbox worker) the permission catalogue has promised mailbox:send gates sending since Members were given it in their baseline role, but the worker never checked it — a per-account send ACL grant alone was sufficient for reply, compose and minting an upload ticket, with no org-permission check at all hasCap/accessibleAccountIds in lib/access.ts now additionally require mailbox:send (or org-admin) whenever the capability requested is send, closing reply/compose/upload-ticket in one place; the upload-ticket flow's re-check (a signed ticket carrying no live JWT) needed a matching 6th ticket field snapshotting the permission at mint time, the same accepted tradeoff already used for org-admin status test/access.test.ts, test/upload-ticket.test.ts (apps/mailbox)

2026-10-05 — Phase 5 Track M closing notes (decisions, descope, coverage)

Track M (PR1–PR7) is built. Three things were decided or recorded rather than built, and two PR12/PR13 changes touched the mailbox module.

Descoped to Phase 6 (decision of 2026-10-05): the rest of plan 5.2 / PR5. What shipped is the first two slices only (inbox/format.ts, inbox/forward.ts, and the hooks useUnreadCounts, useDesktopNotifications, useInboxShortcuts, plus MF-30/32/46). Not built: the hooks useInboxList, useOpenThread, useOptimisticSends and useUndoableComposer (the composer dedupe between ReplyBox and ComposeDock), and therefore their tests, which drop out of Phase 5's exit list with them. Non-test files under modules/mailbox still over 400 lines (measured 2026-10-05):

File Lines
pages/MailboxPage.tsx 999
inbox/ReplyBox.tsx 597
pages/SendingDomainsPage.tsx 593
inbox/ComposeDock.tsx 552
api/mailbox.api.ts 532
inbox/AttachmentTray.tsx 463

inbox/HtmlBody.tsx (454 at the audit) fell under 400 incidentally when its height mechanism was extracted into the shared src/lib/iframe-height.ts for the newsletter's Custom HTML block (behaviour unchanged; its 8 auto-height, 15 sanitize and 2 theme tests pass untouched). MailboxPage.tsx grew back from 984 after the MF-23/24/40 work, so the split is bigger than the original estimate; it is the largest single item for Phase 6.

MF-47 (the sender name collapses an open message): deliberately not changed. The name is a real <button> with aria-expanded and title="Collapse this message", keyboard- and screen-reader-operable, and it implements the module's documented model ("a collapsed card is a button that expands in place, so nothing is ever hidden behind navigation"), the Gmail conversation pattern. The audit's concern was surprise, not accessibility. If it proves confusing, the lower-risk fix is a visible chevron beside the name rather than changing what the name does.

mailbox:send (MF-49) test coverage. The plan listed a dedicated send-permission.test.ts; none was added because the rule is already pinned where it is enforced: test/access.test.ts (reply and compose both 403 on a per-account send grant alone; org admins still allowed regardless of the permission list) and test/upload-ticket.test.ts (a ticket is refused on an ACL send grant alone and minted once mailbox:send is present), with the catalogue and the Member baseline in packages/core-access/test/catalogue.test.ts. A separate file would only duplicate those.

Accessibility gate (Phase 5 PR13). pnpm --filter seller-dashboard test:a11y runs axe over the inbox surfaces: the thread list (unread, read, selected, delivery-problem, empty), the message card (bounced recipients with the fix-it hint, inbound, collapsed) and the reply box (collapsed and open). No serious or critical findings in any of them. The one violation axe raised first (an unnamed hidden file input) was a jsdom artefact, since Tailwind's .hidden is display:none in the app, handled with a narrow exclusion rather than a disabled rule. A canary suite proves axe reports real problems under jsdom, and mutations of the real components (a listbox losing its name or role, an invalid role on a reply field and on the outcome panel) all fail the suite. Details in docs/newsletter/backlog.md (PR13).

Rich-email viewer, after the Phase 5 review pass (commit 9ba5d4c3). The viewer shares its height script and CSP with the newsletter's Custom HTML block, so two review findings reached it. (1) The reported height was max(documentElement.scrollHeight, body.scrollHeight), which never drops below the frame's own current height, so it could only grow; it now reports the content's box (body.scrollHeight against the html element's bounding height), checked in real Chrome including a live shrink. Its 8 auto-height tests are unchanged. (2) The meta CSP gained frame-src, object-src and form-action 'none'. For the viewer this is defence in depth: DOMPurify already strips iframe, object, embed, form, meta and base before the srcdoc is built (not re-tested in a browser here). Its resetKey is now the sanitized HTML string rather than the memoized object around it. The residual described in docs/newsletter/backlog.md (a remote document reached by a link click or <meta refresh> keeps allow-scripts) applies to the viewer only if the sanitizer is bypassed.

2026-10-07 — Inbox toolbar and the conversation list

Shipped across d07b2b2c, dd65fb89, 891a9390 and the commit that adds the mailbox tag. Recorded here because they were fixes to gaps found while using the inbox, not items from the original audit.

Finding Fix Test
With more mailboxes than fit (the Vlozi workspace has 11) the chip strip scrolled sideways with its scrollbar hidden: mailboxes, and their unread mail, were off the end with nothing to show they existed ("All 8" beside chips adding up to 3) Priority-plus strip: as many chips as fit, always keeping All and the selected mailbox; the rest behind a "+N" button that carries the hidden unread total and opens a searchable list (MailboxPicker); on a phone one button and a bottom sheet; m opens the command palette on the mailbox entries. The room is measured from the toolbar row, never from the strip itself inbox/mailbox-strip-fit.test.ts, inbox/InboxToolbar.test.tsx, inbox/MailboxPicker.test.tsx, pages/MailboxPage.switcher.test.tsx, inbox/useInboxShortcuts.test.tsx
Fourteen equal-weight boxes in one row, two solid-black elements competing, a "Live" label that only spent width, a second ⌘K box beside the search box, and Unread floating beside the tabs although it only narrows the section you are in Three zones (mailbox, view, search + actions); the current mailbox and tab are outlined and tinted, an unselected chip has no visible border, Compose is the only solid element; the tabs and Unread/Failed are one joined group; ⌘K lives inside the search box; the Live/Polling indicator is gone (the page still uses the connection state for its poll intervals) inbox/InboxToolbar.test.tsx
On a ~360px phone the mailbox button sat after the ~340px of tabs and Unread, entirely off-screen The mailbox button comes first on the phone row and is pinned (sticky, opaque) while the tabs scroll behind it inbox/InboxToolbar.test.tsx
/ and the palette's "Search conversations" focused nothing on a desktop window: both search inputs shared one ref, the phone's (mounted last, display:none on desktop) won Each input keeps its own ref; the page's ref gets a focus() that picks the visible one inbox/InboxToolbar.test.tsx
In the All view a conversation row never said which mailbox it was in: every row showed only who wrote to you (the screenshot had hello@vlozi.app on every row while the mail had arrived at info@, billing@, support@...) A quiet tag on line 1 of each row: a dot in that mailbox's avatar colour and its short name, shown only in the All view of a workspace with several mailboxes; tintFor is now exported from Avatar.tsx so the tag, the toolbar chip and the finder share one colour. No backend change inbox/ThreadList.test.tsx, inbox/Avatar.test.tsx, inbox/inbox.a11y.test.tsx, pages/MailboxPage.mailboxTag.test.tsx

Open (found 2026-10-07, not fixed here): conversation rows with their hover actions are role="option" elements that contain buttons. axe reports this as nested-interactive (serious). The existing accessibility test for the list never gives rows any actions, which is why it has always passed, so the gate does not cover rows as a keyboard or mouse user sees them. It predates the mailbox tag (a list with the tag but no actions is clean). Not fixed because the cure is structural: the actions would have to move out of the option (a sibling positioned over the row), which changes the Tab-order model MF-23 built (only the selected row's two buttons are Tab stops, located by closest('[role=option]') in its tests) and the swipe wrapper. Owner: later, with a test that renders a row with actions through axe. Until then the accessibility claim above ("no serious findings") holds for the list without row actions only.

2026-10-07 — Designed email shown the way Gmail shows it

The same message looked right in Gmail and broken in the Mailbox. It showed plain text first. The rich view had no styling, so a black button became a bare blue link. Images were dashed boxes until you clicked "Load images". Two commits: 087d896a (viewer) and the image-proxy commit that follows it.

Finding Fix Test
The viewer removed every style attribute and <style> block while images were blocked, which removed the whole design CSS stays; only @import and remote url() are cleaned out. The real guarantee is now one Content-Security-Policy inside the frame: in blocked mode nothing remote may load. Head <style> blocks survive. The email sits on a white letter in both themes. The rich version opens by default inbox/HtmlBody.sanitize.test.ts, inbox/HtmlBody.theme.test.tsx
The server's sanitized copy (the default body) dropped every <style> block, all of <head> (where they live), class, and any style containing url(). So even a perfect viewer had no design to show Sanitizer v2 keeps <style> (cleaned: @import removed, < re-escaped so no </style> can be built from escapes) and class. A url() is rewritten rather than the whole style dropped. CSS is decoded to the exact text the browser parses, checked, then re-escaped. Copies made by v1 are rebuilt from the raw original on next read (.sanitized.v2.html) test/sanitize-email-html.test.ts, test/messages-route.test.ts
A body with an out-of-range character reference (&#99999999;, \FFFFFF) made the sanitizer throw, and with it the ingest of that message Invalid code points become U+FFFD, as browsers do test/sanitize-email-html.test.ts
CSS escapes were decoded in two passes, so \\75rl( (a literal backslash, then "75rl(") was read as url( One left-to-right pass, as the CSS tokenizer does test/sanitize-email-html.test.ts
Images needed "Load images", and loading them gave the sender the reader's IP, location and every open (MB-20) Image proxy. A served body points every remote image (<img> and CSS url()) at /mailbox/v1/img/<sig>/<url> on the API. The signature is an HMAC of the exact URL with a key derived from GATEWAY_SECRET under its own label. The mailbox worker fetches the image server-side: public http(s) only, no private addresses, redirects re-checked, 8 s, 5 MB, image types only, SVG sandboxed. The gateway serves repeats from the edge cache and gives the door its own rate limit (IMAGE_RATE_LIMITER, 1200/min per IP). Tracking pixels (1–2px or hidden) are dropped, not proxied, so the sender doesn't learn the open time from them either. The viewer's CSP allows only that path prefix; anything still pointing at a sender's server stays blocked behind "Load images" test/image-proxy.test.ts, test/messages-route.test.ts, gateway test/mailbox-image-door.test.ts, inbox/HtmlBody.sanitize.test.ts, inbox/HtmlBody.theme.test.tsx

Deploy order (not done): mailbox worker, then gateway (it adds the IMAGE_RATE_LIMITER binding in its tracked wrangler.toml), then push the dashboard. Pushed first, the dashboard would only see unproxied bodies and block their images, as today. The mailbox needs no new secret; PUBLIC_API_ORIGIN defaults to https://api.vlozi.app.

Limits: images are fetched when the reader opens the message, so a sender with no pixel can still see that an image was requested and when; they get our server's address, not the reader's. Web fonts in @font-face still don't load in blocked mode (the CSP keeps font-src to data:); text falls back to the email's own font stack, as in Gmail.

2026-10-10 — The Gmail-style redesign (branch mailbox/gmail-redesign, not pushed)

The inbox page was rebuilt: folders in the header's row 2, a full-width list with Inbox tabs and a bottom bar, several mailboxes at once with saved groups, a full-width reader with a docked reply bar, compose with the From pill, and a phone layout with its own bottom bar. It replaces InboxToolbar and MailboxPicker (the chip strip above). The mailbox worker gained accountIds, categories, sender names on threads and GET /v1/messages/:id/unsubscribe (49f9a327, not deployed). Everything that needs bigger server work (star, server drafts, snooze, spam/trash, scheduled, important, AI drafting) is shown disabled as "coming soon". What shipped, the rollout order, the backend-later list (MR-1 to MR-11) and the gap log (MR-G1 to MR-G7): docs/seller-dashboard/mailbox-redesign.md.

2026-10-10 — Starred, Drafts, Spam and synced groups (branch mailbox/folders-backend, not pushed)

Phase 6.2 (part) and 6.3 (drafts). Per-person stars, private drafts on every device, a Spam folder with reported/blocked-sender/DMARC rules and "Empty spam now" (the worker's first delete, lib/purge.ts), saved mailbox groups on the server, POST /threads/bulk, and personal realtime events. Migration scripts/apply-2026-10-folders.mjs (founder runs it). Details, rollout and the feature gate: docs/seller-dashboard/mailbox-redesign.md, section "Starred, Drafts, Spam and synced groups".

2026-10-10 — Trash (branch mailbox/trash)

What it adds:

  • Delete moves a conversation to Trash; Move to Inbox restores it; Delete forever and Empty Trash delete for good.
  • Everything in Trash is deleted for good after 30 days, by a per-workspace Durable Object alarm. This is the mailbox's first automatic deletion, and a step on G30.
  • Migration: scripts/apply-2026-10-trash.mjs.

Details: docs/seller-dashboard/mailbox-redesign.md, section "Trash".

2026-10-11 — Strict team access (branch mailbox/strict-access, not pushed or deployed)

Nobody reads a mailbox without being added to it, the workspace Owner and Admins included. Each mailbox has its own Viewer, Sender, Manager and Owner roles on top of the workspace role. Access can be temporary, people ask for it with a request that only that mailbox's Owners answer, and every change goes in the mailbox's activity log. Someone who leaves the workspace loses their access at once, and ownership passes on.

Touches manager, comms, the mailbox worker and the dashboard (the Mailboxes page, inbox, navigation, bell and Team page). It needs one migration, apps/mailbox/scripts/apply-2026-10-access.mjs (five phases), and one core-DB script, apps/manager/scripts/catalogue-mailbox-2026-10.mjs. The founder runs both.

Decisions D1 to D31, the build log, the rollout order and the gap log TA-G1 to TA-G20: docs/mailbox/team-access.md. This is the foundation for Phase 6.1 (assignment, notes, presence). Live since 2026-10-11.

2026-10-11 — "Sent by" (live 2026-10-12: mailbox worker 95b5ab06, main ffaf0356)

Every outgoing message records which teammate pressed Send. The mailbox's own people see "You" or the teammate's name on each sent message, and a "Rahul: …" prefix in the list when the newest message went out. The recipient still sees only the mailbox address. Before this round, every outgoing message in a shared mailbox said "You", whoever sent it.

It needs apps/mailbox/scripts/apply-2026-10-sent-by.mjs --columns, run before the worker deploy. Decisions D32 to D36, rollout and gaps TA-G21 to TA-G25: docs/mailbox/team-access.md, section "Sent by".

2026-10-14 — Review of the 2026-10-09 to 10-12 work (RV-1 to RV-12 fixed on branch mailbox/review-fixes, not deployed; RV-13 to RV-15 open)

A read-only review of everything since 00ccd56e (62 commits: redesign, folders, Trash, strict access, "Sent by", private attachment storage) at e071663d. At review time the worker (795 tests) and the dashboard mailbox module (569 tests) passed, and both typechecked. No critical findings. Access control holds: both locks are checked on every read path, there is no owner bypass, ids from requests are tenant-scoped, requests can't be answered twice, and realtime events reach only the right sockets. The dashboard–worker contract matches; the one-click unsubscribe's SSRF guard matches the image proxy's; the email viewer's proxy exemption, CSP and sandbox are unchanged. "Verified" means re-read in the code after the review; the rest are high-confidence code paths from the review.

Id Sev Where What goes wrong Verified
RV-1 High MailboxPage.tsx requestRefresh (≈634) The 300 ms refresh timer calls the loadThreads captured when it was scheduled. Switch folder inside that window: the stale loader resets listViewRef to the old view, the new folder's response is discarded and the old folder's rows show under the new tab until the next poll; bulk actions then hit those rows. Fix: call loaders through a latest-value ref yes
RV-2 Medium lib/purge.ts 114–178 Messages, stars, read state and drafts are deleted by the batch's ids without re-checking that the thread is still in Trash/Spam; only the final thread delete re-checks. A restore, or new mail landing, during Empty trash or the 30-day alarm leaves the conversation with its history and attachments gone. Fix: delete children only for threads still inScope yes
RV-3 Medium lib/category.ts 38–72, 132–139 People's mail is filed in Updates when the subject says invoice / receipt / your order / verify your / security alert / sign-in, or the sender is billing@, orders@, payments@, alerts@… The page opens on Primary, so a customer's "wrong amount on invoice #4521" is easy to miss. Category is computed at read time, so a fix applies to existing mail yes
RV-4 Medium lib/ingest.ts 396–402 A blocked sender's reply-all moves the whole shared thread to Spam, and the sticky CASE keeps every later message from unblocked people there too, with no alert. The same stickiness applies after one DMARC failure yes
RV-5 Medium mailbox.api.ts 423–532, use-logout.ts The mailbox's browser caches (thread lists, mailboxes, drafts) are cleared on workspace switch only, never on sign-out. With strict access, the next person on the same browser sees the previous person's subjects and mailboxes until the refetch lands yes
RV-6 Medium ReplyBox.tsx 230–235, serverDrafts.ts 138–145 Opening a conversation by URL / Back / Forward skips seedReplyDraft; the empty reply box then calls clearDraft, which deletes the server draft written on another device
RV-7 Medium MailboxPage.tsx ≈1388–1406 #, !, s, Shift+I/U act on ticked rows (which stay ticked while you open another conversation) instead of the open conversation; ! then reports and blocks those senders
RV-8 Medium useUnreadCounts.ts 87–92 One failed unread-counts call turns the Inbox tabs off, resets the page to 1 and clears the ticks; the next good poll flips back
RV-9 Medium access/DurationPicker.tsx 46, 76–83 "Until" sends 23:59 on the chosen day, past the cap on the last allowed date: refused (400) when approving and when adding someone for the maximum; on a 1-day request the only date offered is always refused yes
RV-10 Med-low lib/purge.ts 118–161 Batches are capped by threads, not messages; a huge alert thread (subject threading keeps it growing) passes over 65,535 parameters, so Empty trash/spam and the auto-delete fail on that batch every time
RV-11 Low routes/people.ts 150–165, routes/accounts.ts 60–71 Making someone Owner via People, or as a member at create, doesn't check they can use mailboxes (D31). The real Owner can then leave, and the mailbox is stuck with an Owner who can't act and no takeover yes
RV-12 Low routes/accounts.ts 247–254 setRole after the mailbox is created is outside the try: an error gives a 500, a mailbox with no Owner, and no refund on the shared lane yes
RV-13 Low UI k on the first row or j on the last blanks the open conversation; a discarded stale list response still drops the skeleton (flash of "No matches"); reading unread Spam/Trash lowers the Inbox badge; Escape that closes a menu or confirm also closes the conversation; a saved-group link is lost on a new browser before the server's groups load; the folder in the URL isn't checked against the worker's features
RV-14 Low access UI Role change in People wipes the note and makes temporary access permanent by default; the person a request is for can't cancel it in the UI (the worker allows it); "Ends tomorrow" shown for access ending later today; Domains shows Delete to people the server refuses; Setup shows "Just you" for mailboxes you don't own and offers "Activate" for an alias you aren't in; "Ask for Sender" offered to Viewers whose workspace role can't send
RV-15 Low worker Mark-all-read leaves threads whose Date header is up to 5 min ahead (use GREATEST(now(), last_inbound_at)); one-click unsubscribe doesn't require a DKIM signature covering the headers (RFC 8058) and a mailto:-only list opens the reader's own mail client; owner.named always logs "No members at the time"; approving can drop someone's revert-to role (D15); reply answers 403 instead of 404 to a non-member; a socket isn't re-scoped when the workspace role loses mailbox use (up to ~1 h of id-only nudges); forward drafts survive losing access (D25); ACCESS_CHANGED after retries in departFromMailbox is a 500

How RV-1 to RV-12 were fixed (2026-10-14, founder's decisions: conversations stay in Primary; a blocked person's reply goes to Spam on its own; polish RV-13 to RV-15 later). No migration. Every fix has a test that fails without it (mutation-checked: 11 purge/ingest, 12 categories/spam/access, 11 dashboard).

Id Fix Test
RV-1 The refresh timer calls the loaders through latest-value refs, so it always loads the view showing when it fires pages/MailboxPage.review.test.tsx
RV-2, RV-10 purgeThreads is one statement per call: the batch's threads are locked FOR UPDATE and re-checked against the scope (a restored/not-spammed/archived thread is skipped whole), at most 2,000 messages go per call oldest first, a thread is deleted only once all its messages are gone (and only then its stars, read state and drafts; a draining one gets its counters reduced), and R2 bytes go after the rows, for those rows only (a failed R2 chunk is retried once, then logged as orphaned). Statements bind a few hundred values whatever the thread size. Delete forever repeats up to 20 rounds and answers 202 {pending:true} (bulk: PURGE_PENDING) past that; the dashboard says "still deleting". Ingest and recordOutbound re-create a thread a purge deleted between their message insert and thread update (reviveThread), or re-file the message once if it went too. Accepted: a restore landing between two calls of a >2,000-message thread brings it back without its oldest, already-deleted messages apps/mailbox/test/purge-safety.test.ts
RV-3 Category rule: a thread the team has written in → Primary; auto/bounce or a strict no-reply sender → Updates; a role sender (billing@, receipts@, orders@, alerts@…) only when the mail isn't a reply; a receipt-like subject only on bulk (list) mail; a person's own mail is Primary whatever the subject test/categories-and-scope.test.ts (SQL vs code parity over 120 combinations)
RV-4 A blocked sender's message never joins a conversation that isn't already in Spam: it starts its own Spam thread. A thread in Spam only for auth_fail comes back when a reply passes DMARC; reported/blocked_sender stay sticky test/spam.test.ts
RV-5 signOutCleanly (hooks/sign-out.ts) clears every vlozi.mbx.* key and fires the workspace-reset event before signOut, at all six sign-out sites; the login page does the same on open, which covers an expired session. (The plan's per-person owner marker was dropped: the sign-out + login clean-up covers the same cases with less code.) hooks/sign-out.test.ts, app/login/page.test.tsx
RV-6 An empty reply box clears a draft only if it had content in this mount; a link/Back/Forward open seeds the server draft first pages/MailboxPage.review.test.tsx
RV-7 #, !, s, Shift+I/U use ticked rows only while the list is showing; with a conversation open they act on it pages/MailboxPage.review.test.tsx
RV-8 fetchUnreadSummary returns null on failure and the store keeps what it had; the per-tab counts are cached too, so a cold start doesn't switch the tabs off and on pages/MailboxPage.review.test.tsx
RV-9 "Until" is clamped to the cap (asked-for end, or 365 days) minus 5 minutes for clock skew; presets get a 1-minute slack so the asked-for one stays enabled access/DurationPicker.test.tsx
RV-11 PUT people and create refuse to make someone Owner whose workspace role can't use mailboxes (422 CANT_USE_MAILBOXES) test/access-people.test.ts
RV-12 A failed people write after create deletes the new mailbox and its people rows and refunds the charge (503 PROVISION_FAILED) test/access-people.test.ts

Doc drift: TA-G1 says the attention count cleans up departed people (it doesn't; People and the directory do); the old POST/DELETE /accounts/:id/access routes "kept for one deploy" are still there; approve/deny check the approver's workspace role from the token, not live (TA-G15); MailboxPage.tsx is about 1,880 lines (MR-G7 says about 1,330).

2026-10-15 — Review of the RV fixes themselves (branch mailbox/review-fixes @ befaf754, not deployed)

A second, read-only review of the 8 fix commits. Each finding below was re-checked in the code; "probe" means a throwaway test reproduced it (since deleted). Owner for all: the next fix pass on this branch, before it is pushed — except RF-2, which needs the founder's decision first. The branch should not be deployed until at least RF-1, RF-2, RF-4 and RF-5 are fixed: they leave RV-4, RV-11 and RV-10 only partly fixed.

Id Sev Where What goes wrong Fix
RF-1 High lib/ingest.ts 277–280 + lib/threading.ts 71–75, 96–101 RV-4 is incomplete. The blocked person's split-off Spam thread has the same subject and participants as the shared one, and holds a Message-ID that later replies cite. The reference match is .limit(1) with no order, and the subject fallback picks the newest thread, so the customer's next reply (citing the blocked person's message, or with no References) can land in the sticky blocked_sender thread and stay in Spam with no alert. Probe findThread skips status='spam' AND spam_reason='blocked_sender' threads for an unblocked sender in both steps, with a deterministic order in step 1
RF-2 High (security) lib/ingest.ts 411–418 The auth_fail un-stick accepts a DMARC pass from any sender. A spoofed ceo@bank.com mail lands in Spam; the attacker then sends from their own DMARC-passing domain with In-Reply-To set to the spoofed id (they minted it), and the thread returns to the Inbox with a desktop alert. The per-message DMARC warning still shows. Probe Founder decision: un-stick only when the passing mail's From domain equals the failing mail's, or drop the automatic un-stick ("Not spam" and replying already bring it back)
RF-3 Medium lib/ingest.ts 277–280, 411–416 A blocked sender may join a thread already in Spam for auth_fail; the reason stays auth_fail, so a later passing reply from someone else returns the thread with the blocked person's mail in it. Probe A blocked sender's message sets the reason to blocked_sender
RF-4 Medium routes/accounts.ts 452–481 RV-11 bypass: the old POST /accounts/:id/access {role:"owner"} route only checks membership, not Use mailboxes. Probe: 200 and an Owner row for a user with no workspace permissions Same 422 CANT_USE_MAILBOXES via lookupPeople, or retire the route (no dashboard caller)
RF-5 Medium routes/threads.ts 869, 893 + MailboxPage.tsx 1023, 1170 Empty Trash / Empty Spam return only {deleted, remaining} and the dashboard stops when deleted is 0. A round that only part-drains a huge conversation deletes 0 threads, so the loop ends after 2,000 messages and says "Deleted 0 conversations" — RV-10 is only partly usable from these two buttons. Probe Return messagesDeleted, loop while there is progress, say "still deleting" when it stops with remaining > 0
RF-6 Medium MailboxPage.tsx 393 + serverDrafts.ts 227 + ReplyBox.tsx RV-6 on a cold link: seedReplyDraft runs before the drafts list has loaded, so it finds nothing and nothing seeds later. Clicking Reply auto-fills To, which counts as content, and the autosave overwrites the other device's draft with an empty body. Probe Seed when the drafts load (or hold autosave until they have), and don't count an auto-filled To as content
RF-7 Medium routes/accounts.ts 246–272 + lib/access-writes.ts discardNewMailbox The RV-12 rollback is two statements with no try of its own: in a DB outage (the likely cause) it throws, giving a 500 with no refund, or leaves a paid mailbox with zero people. The displayName update is still outside the try (original RV-12 symptom). Mail arriving for the new address during the create is orphaned when the mailbox is discarded. A failed refund is silent (recurring charge stays). Probe One-statement discard in its own try, only when the mailbox has no threads; displayName inside the try; check the refund result and keep the mailbox (needs an owner) if it fails
RF-8 Low-med lib/purge.ts 184–228 + lib/ingest.ts 411–418, 475–479 A new message on a Spam thread stays in scope (it stays Spam), so a purge whose snapshot predates the message can delete the thread and leave the message orphaned, with no revive (ingest's update already succeeded). Also, ingest's R2/attachment step still runs for a message a purge just deleted. Trash is safe (new mail moves it to Inbox before the lock). By reasoning After the statement, sweep messages still pointing at deleted thread ids; skip ingest step 4 when its row is gone
RF-9 Low lib/ingest.ts 691–718 A revive racing a normal update on the same thread double-counts message_count (revive counts both rows, the other update adds 1) Recount from rows on the conflict branch
RF-10 Low lib/ingest.ts 446–460, 860–874 Revived threads don't follow the normal folder rules: a DMARC-failing reply to a purged Trash thread is revived into Spam; the outbound revive doesn't set deliveryProblemAt; the conflict branch doesn't refresh snippet/time Reuse the normal-path expressions
RF-11 Low lib/purge.ts 192–197, 288–298 + routes/threads.ts 780–793, 916 Bulk Delete forever spends the message cap on the first (biggest) thread, so small ones behind it are reported PURGE_PENDING; partly deleted threads get no realtime nudge in bulk; a restore between rounds that already deleted messages answers 409 "not in Trash" Prefer threads that can finish; nudge pending threads; a distinct code when messages were deleted
RF-12 Low MailboxPage.tsx 1412 RV-7: shortcuts still act on hidden ticked rows while the "Sending your message…" pane covers the list (selectedId is null there). Probe ticksTarget = checked.size > 0 && !readerOpen
RF-13 Low hooks/sign-out.ts 9 + inbox/groups.ts 28 Sign-out clears vlozi.mbx.* but not vlozi.mbx-groups.<tenant> (the next person's first load can import the previous person's groups). Signing out from the mailbox page refetches and rewrites the caches it just cleared (masked today by the login page's clear-out). Unsynced local drafts are dropped on session expiry Clear the vlozi.mbx prefix without the dot; a signing-out flag that stops cache writes; flush pending draft saves first
RF-14 Low access/DurationPicker.tsx 46 The 60 s preset slack also applies to a request with a fixed end, enabling a preset the server refuses ("shorten, not lengthen") Slack only for days-based requests
RF-15 Low tests, deploy PGlite is one connection, so no test exercises FOR UPDATE (dropping it passes); shrink's unread maths isn't tested with read or auto mail; no test for "creator listed as owner in members without Use mailboxes". Purge locks threads in folder order while bulk updates lock in id order: a deadlock is possible (40P01, plausible). An older dashboard reads the new 202 pending as "Deleted forever", so ship the dashboard with the worker, not after Add the tests; lock in id order or retry once on 40P01
RF-16 Low routes/accounts.ts + comms /internal/mailbox-refund A discarded new mailbox that was the tenant's default leaves no default. A refund cancels the recurring charge by address, so a double-submitted create can cancel the winning call's charge (an older issue, now with a second trigger) Promote another default; cancel only the registration this call created

Fixed 2026-10-15 (same branch, then pushed to main and deployed): RF-1, RF-2, RF-3 in 9988908b (one rule, threadAccepts, for which threads a message may join — both findThread steps and a routed reply; the reference match prefers the newest cited message; the DMARC return needs the same address as every failing mail in the thread, the founder's option 1 made stricter because anyone can forge someone@gmail.com and reply from their own Gmail; a blocked sender on an auth_fail thread makes it blocked_sender); RF-4 and RF-7 in 8fc1771f (the old route checks Use mailboxes; the rollback refunds first, deletes in one statement only while no mail has arrived, and otherwise keeps the mailbox with its charge and answers 503 PROVISION_INCOMPLETE, which the create dialog shows and reloads on); RF-5 in d424b0aa (messagesDeleted from both routes; the buttons keep going on progress and say "still deleting" when they stop with some left); RF-6 in ed2bfe4b (seed once the drafts load and remount an untouched box; auto-filled recipients aren't content; the autosave writes only on a change). Every fix mutation-checked (27 breaks, all caught).

Second pass, 2026-10-15 (branch mailbox/rf-followups): RF-12, RF-13, RF-14 in 22ee6343 (ticks aren't the target under the "Sending your message…" pane; sign-out also clears vlozi.mbx-groups.* and turns browser-storage writes off until the page goes — the login page turns them back on; presets compare days for an N-day request and end before the cap for a fixed end). RF-8, RF-11, RF-15, RF-16 in a9414eab (the purge leaves a thread whose message count changed after its snapshot for the next call, and ingest skips a Spam message's bytes when an Empty spam deleted it; the threads that can finish go first; bulk nudges part-deleted ones and a single Delete forever that loses to a restore answers PARTLY_DELETED; locks in id order and retries a deadlock once; an undone first mailbox hands the default on; a create refunds only a charge it made). 19 more breaks, all caught.

Not changed, with reasons (owner: founder, if any becomes worth it):

  • RF-9: a revive racing a normal ingest on the same thread can over-count message_count by one. It needs two messages for one thread at the instant it is purged, and the fix would be a count on every inbound message.
  • RF-10: a revived thread follows the new-thread rules (a DMARC fail starts it in Spam). That is the conservative choice for a conversation that was being deleted for good. Outbound revives need no delivery-problem flag, since the pending events are drained right after.
  • RF-16 (other half): comms /internal/mailbox-refund cancels the recurring charge by address, so a double-submitted create that loses can cancel the winner's. The fix (return and cancel only this call's registration) is a communication-worker change and is left for that worker's next deploy.
  • Untestable here: the RF-8 deferral and the id-order locking need two connections, and PGlite has one. Both were checked by reasoning about READ COMMITTED. A one-off check on a Neon branch is possible if wanted.

RV-13 to RV-15, fixed 2026-10-15 (branch mailbox/rv-polish, the founder said go). Each item was re-checked against the code and reproduced first.

  • RV-14 (f13ec532, access screens):
    • a role change keeps the end and the note;
    • the person a request is for may cancel it;
    • "ends today" is counted in calendar days;
    • Remove on Domains only with comms' sender permission;
    • Setup's review reads People (no false "Just you") and says when the alias is already a mailbox you aren't in;
    • "Ask for Sender" only when the workspace role can send.
  • RV-13 (5227e262, inbox):
    • j/k at either end keep the open conversation;
    • a stale list answer neither ends the skeleton nor shows an error;
    • the Inbox badge counts open threads only (not Spam/Trash);
    • an Escape a menu or dialog used doesn't close the conversation;
    • keys inside an open menu or dialog stay there (found with it: # in "More actions" trashed the conversation);
    • a ?mb=g: link and a ?view= folder wait for the worker's features. A folder it lacks falls back to the Inbox quietly.
  • RV-15 (80ab4467, worker):
    • read marks reach a message whose sender's clock runs ahead;
    • "named Owner" says why nobody took over;
    • approving keeps a step-down's way back (D15);
    • reply is a 404 to non-members;
    • socket age limit 15 min;
    • a busy mailbox is a 503-and-retry on the removal call and never fails a page;
    • no mailto: unsubscribe link.
  • 31 deliberate breaks, all caught.

Still open, with owner and reason:

Id What Why not now Owner
RV-15b One-click unsubscribe should require a DKIM signature covering List-Unsubscribe and List-Unsubscribe-Post (RFC 8058) The check depends on whether the stored raw mail carries a trustworthy Authentication-Results with header.d=. One real stored email has to be looked at first (read-only). If it doesn't, the worker must verify DKIM itself (DNS + WebCrypto), which is bigger. The risk today is low: a human click, a fixed body, the SSRF guard founder (look at one stored mail) → then the fix
RV-15g Forward drafts survive losing access (D25): a no-thread draft keeps the source mailbox's quoted mail Needs a source_account_id column on mailbox_drafts (schema, apply script, DDL parity) and a dashboard change to send it founder (decide: accept, or migrate)
RV-15h2 Found: GET /v1/sending-domains needs comms:sender.read, so a custom role with only Set up domains sees "Couldn't reach the sending service" Communication-worker change next comms deploy
RV-14f2 Found: People's "Ask for more" also offers Sender to Viewers whose workspace role can't send Needs a role-filter prop on RequestAccessDialog next UI pass
RV-14c2 Found: the older askedFor / describeEvent tests in access/format.test.ts use UTC dates and fail in far time zones (Honolulu, Kiritimati) Test hygiene only; CI runs in IST/UTC next UI pass
RV-13f2 A folder the worker lacks falls back to the Inbox silently; a "coming soon" toast like the g chords give is possible Small product call founder

Checked and correct (summary): the purge's snapshot logic, zero-message threads, concurrent purges, tenant scoping, bind counts, neon-http result handling and termination; RV-3's SQL and code agree (120 combinations) and last_outbound_at is backfilled; every sign-out site uses signOutCleanly, SSR-safe; RV-1's ref timing; RV-9's clamp agrees with the server; RV-12 never deletes an existing mailbox (made.created is the right signal), and a retry after a refund is charged again.

What is left (read 2026-10-07; replaces the older "Still open" paragraph, which was out of date)

The old paragraph said the shared-inbox foundation (per-user read state, reply-to/reply-all, participants, mailbox lifecycle, send bounds, the server-side sanitiser) and the migrations baseline were open. They are not: Phases 0–3 are on main and live, and Phases 4 and 5 were deployed on 2026-10-05. The mailbox migrations baseline exists (apps/mailbox/drizzle/0000_phase3_baseline.sql) as a review artefact only; production keeps using the hand-written scripts/apply-*.mjs, because Neon's HTTP driver has no interactive transactions. Bookkeeping still owed: this file has Fixed rows only for Phases 0, 1, 5 and the toolbar work; the mailbox items shipped in Phases 2–4 (the plan's PR maps for MB-04 to MB-07, MB-10 to MB-14, MB-16 to MB-18, MB-20, MB-22 (indexes), MB-23 to MB-25, MB-27 and P3-N1 to P3-N14, and dashboard MF-08, MF-10, MF-13, MF-15 to MF-19, MF-29, MF-31, MF-45) were never added as rows.

Bugs and gaps still open (the first three re-checked in the code on 2026-10-07):

Id What Size
MF-14 A downloaded attachment whose own type is application/json is parsed and re-serialised by the dashboard proxy, and its content-disposition (the filename) is dropped (modules/mailbox/api/proxy.ts, the JSON branch); only non-JSON types pass through untouched. Never given a row here small
MB-28 A reply sets the thread back to open (recordOutbound in lib/ingest.ts), so replying from the Archived view silently un-archives it, and the UI does not say so small
MB-26 The mailbox's request logger redacts ?ticket= only; ?q= search terms are still logged (src/index.ts) small
Row accessibility Fixed 2026-10-10 (c110fe90): the list is a grid (row > gridcell), so its controls are allowed; an axe test renders rows with every control —
MB-13 (rest) No split-thread or move-message, so a wrong subject-fallback merge cannot be undone medium
Unverified Forwarding drops cc and attachments (P3-N11 tail); you cannot reply to an older message (MF-25). (The image proxy and "Load images", MB-20, shipped on 2026-10-07; see above) not checked in code
Phase 5 backfill Confirm scripts/backfill-delivery-status.mjs was run (see the Phase 5 note above) one check

Page split, done 2026-10-21 (branch dashboard/mailbox-split, not pushed): MailboxPage.tsx went from 1,940 lines to 629. Its state and behaviour now live beside it in inbox/: useInboxList (the list, paging, ticked rows), useOpenThread (the open conversation, its URL, dwell-to-read, older pages, scrolling), useOptimisticSends, useInboxRefresh (the coalesced refresh of RV-1, and the polls), useMailAccounts, useCategoryPreviews, useScopeFlip, thread-actions and bulk-actions, shortcut-actions, commands, scope-title, error-copy, ListNotices (FolderNotice, ListContent, PhonePager) and startForward. Code moved word for word; the page tests passed unchanged after every step. Breaking one thing in each new module showed nine guarantees no test covered (a late list or thread answer landing in the view you left, dwell-to-read with the tab hidden, losing a mailbox closing its open conversation, the picker exclusivity, tab previews, Forward keeping a draft, the no-results copy, Compose in ⌘K needing send rights); each now has a test, and all 17 breaks are caught. The page is still over 400 lines: what is left is the wiring between the hooks and the layout, and splitting the layout further would mean components taking 30-odd props.

Still open from plan 5.2, deliberately: useUndoableComposer (ReplyBox, 724 lines, and ComposeDock, 588, keep their duplicated send and error code), and SendingDomainsPage, mailbox.api.ts, MailboxAdminPage, AttachmentTray and ThreadList are over 400. Also left: MF-47, the zod contract packages, and a dedicated send-permission test.

Found while verifying the split (not mailbox code), fixed 2026-10-21 on chore/lint-and-flaky: notification-bell.test.tsx (and, it turned out, service-bar.test.tsx) failed now and then in the full dashboard suite. Both wait for code loaded with dynamic import(), and under full-suite load the first, cold import outran the tests' 1-second wait. The tests now load those modules once before running and wait 5 seconds, as EmailComposer.test.tsx already did; two full runs in a row passed. Two lint errors already on main are fixed too: src/hooks/use-billing.ts used any (an error body that isn't text no longer becomes "[object Object]"), and src/modules/engagement/components/sources-panel.tsx set state inside an effect (loading is now worked out from which breakdown the answer is for, so a late answer for another breakdown never shows; new test).

Product features not started (Phase 6 menu): 6.1 assignment, notes and collision presence; 6.2 labels, snooze and bulk actions (see the planned section below); 6.3 server-side drafts, canned replies and scheduled send; 6.4 message-body search with from: / to: / has: (the trigram indexes exist); 6.5 custom-domain inbound (until then custom-domain mailboxes are send-only); 6.6 mailbox MCP tools, Flows triggers and contact-intelligence ingest; 6.7 AI triage and drafting. 6.8 (delivery status in the thread) is done.

Ops and hygiene (not started): stuck-state alerting for the mailbox (R2 write failures, realtime publish failures); per-caller internal keys instead of one shared INTERNAL_KEY; plan gating (mailbox seats from the plan registry); the mailbox wrangler.toml is untracked, so its bindings (MANAGER_SERVICE, MBX_SEND_LIMITER) exist only in the runbook; the mailbox is also reachable on a public workers.dev address (gap G54).

Owned by the data-lifecycle work (SES gap log, Track B): the mailbox deletes nothing, with no message, thread, attachment or R2 delete (G30); mail to an unknown recipient is accepted and silently dropped (G11, G12): fixed, deployed 2026-10-21 (main 4e24ab6b; comms 94c8834c, rollback fd6d99ec; mailbox 3b790d0e, rollback 6dfeb7b6): the sender now gets a bounce; a failed lookup never bounces.

Launch gate, not a code task: SES is still in the sandbox, so mailbox mail to arbitrary outside addresses is blocked until the separate tenant-mail request (Phase B). Replies from the vlozi.app role mailboxes go through Resend and work; a reply from a mail.vlozi.app mailbox is refused until tenant sending is enabled.

The audit report, for the full finding text: .claude/plans/take-your-time-bubbly-hejlsberg.md.


Planned, not started: manageable labels and grouping (noted 2026-10-07)

Decision (founder, 2026-10-07): the derived mailbox tag on each All-view row (b81c3e41) is enough for now. Some day we build the complete version: labels you can create and manage, and grouping of the list. This section keeps what was worked out so that day starts from a plan, not from zero. Nothing here is built.

What "complete" means

Piece What it is Notes from the design pass
Labels Workspace-defined labels (Billing, VIP, Follow up) put on conversations, shown as chips on rows and in the open conversation Roadmap 6.2 in the audit plan: mailbox_labels + thread_labels. The thread DTO field name labels is deliberately unused so far. Decide first: shared per workspace or per user, and who may create them (mailbox:access.manage, or the manage capability on a mailbox)
A management panel Create, rename, recolour, reorder, delete (and merge) labels, with a count of conversations each Belongs with the Manage page, or a Labels section next to it. Deleting a label that is on conversations needs a confirm that says how many
Applying and filtering A picker on a conversation and on rows, a filter or view per label, bulk apply once multi-select exists (6.2 also plans POST /threads/bulk) Labels would become views next to Inbox / Sent / Archived, so the section model in inbox/sections.ts needs a decision (a label is a filter on a section, or a section of its own)
Grouping A "Group by" switch in the list: none, mailbox, label Server-side grouping breaks the single global keyset cursor (timestamp, id) (apps/mailbox/src/lib/cursor.ts); client-side grouping over the loaded page leaves groups incomplete because "Load more" adds older rows to groups already shown, and total is global. A real version needs one of: a per-group cursor, a per-group "Load more", or fetching a group at a time via the existing ?accountId= filter, which is already paginatable
Mailbox colour you choose A colour picker in the existing edit dialog on the Manage page One new mailbox_accounts.color column through PATCH /accounts/:id, accountDTO, /accounts and the zod schema, then tintFor honours it. About half a day, so worth doing earlier than the rest if two mailboxes sharing a colour (8 tints, ~11 mailboxes) ever annoys anyone

Smaller ideas that fit in the same work

  • Click the mailbox tag on a row to filter to that mailbox (needs care with Tab order inside a row).
  • A "Personal / Shared" tag derived from the grant count (already suggested in docs/communication/communication-mailbox.md).
  • Automatic "Automated" / "Newsletter" tags from the message kind the worker already classifies (the kind would need to be on the thread DTO).
  • A Flows trigger such as mailbox.thread.labeled, and MCP tools to list and apply labels (roadmap 6.6).
  • Rules that label automatically (by sender, subject or mailbox). Later, once manual labels are in use.

Do first, before adding any new control to a row: fix the open accessibility item above (a row with its hover actions is a role="option" containing buttons). A label picker on every row would be one more nested control, so the row structure should be fixed once, not worked around again.

Effort, as estimated in the design pass: labels with the panel, applying and filtering about 5 to 8 days; grouping about a day on top, more if it has to page per group; the mailbox colour picker about half a day. The audit plan's Phase 6.2 (.claude/plans/take-your-time-bubbly-hejlsberg.md) is the roadmap entry this belongs to.

Mailbox