logicspike/docs

Seller Dashboard

Mailbox: the Gmail-style redesign

The inbox page (apps/seller-dashboard/src/modules/mailbox) rebuilt to work like Gmail inside Vlozi's two-row header. Designed on the canvas (frames 15A–18F, rounds 9–12) and built on the local branch mailbox/gmail-redesign, cut from nav/remove-sidebar. Nothing is pushed or deployed.

Most of it is frontend. Features that need real backend work are shown but disabled, marked "coming soon" with the reason, never hidden. They are listed under Backend later.

What changed for users

Area Before Now
Folders "Inbox" twice (sidebar page tab and in-page tab), tabs mixed into the toolbar Row 2 of the header: Inbox (unread count), Starred, Sent, Drafts, Archived, More ▾ (Unread, Failed to send, All mail, and the coming-soon folders), Settings ▾ at the right end (Mailboxes, Domains, Setup, what archiving does next, desktop alerts)
List A 384px column beside the reader, up to three scrollbars Full width. Gmail rows: tick box, ☆ (soon), unread dot, sender name, mailbox tag in mixed views, subject and snippet, icons, date. Hover actions replace the date
Inbox tabs none Primary / Updates / Promotions, each with "N new" and who wrote. They scroll with the list. Disabled until the mail service with categories is deployed
Bottom bar none Select ▾, refresh, "1–25 of 153 ‹ ›". With rows ticked: Archive, Mark read/unread, Star (soon), Done, with one Undo
Which mailboxes A chip strip and a "+N" picker The list's title ("All mailboxes ▾", m) flips the row over into mailbox tiles, the same motion as ⊞. Pick one, or several (Ctrl-click or "Select several"), or name them and Save as group. Groups appear as ★ tiles and in ⌘K. The choice lives in the URL (?mb=)
Reader Squeezed beside the list Full width, with its own toolbar: back to the folder, Archive, Mark unread, Snooze (soon), ⋮, "3 of 48 ‹ ›", About {sender}, Compose. Big subject with the mailbox and category tags. Long threads fold into "N more messages". The email is shown at full height, so there is one scrollbar
Replying A box above the messages A reply bar docked at the bottom: "Reply to Priya… from ●support". Reply all, Forward, and Draft with AI (soon)
After archiving Back to the list The next conversation opens (Undo is in the toast). Settings ▾ can switch it back to "Go back to the list" (per browser)
About {sender} none What Contacts knows about the sender (mood, stage, memories, a link to the profile), or a plain note when they aren't a contact
Newsletters A "Mailing list" chip An Unsubscribe link when the mail carries one
Compose Square dock, From as a plain select Rounded dock, From as a pill with the mailbox's colour dot. From follows what you're looking at: the open conversation's mailbox, the one picked, or the default within a group. Phones: full screen, Send at the top right
Phones The shell's page bar, plus a floating button over the reply box The inbox's own bottom bar: Inbox (count), which mailboxes (a sheet), Folders (every folder as a tile, plus settings), coral Compose. No floating button. Press and hold a row to select; with rows ticked a tap selects too. Swipes kept
Alerts A bell button in the toolbar A one-time "Get desktop alerts for new mail? Turn on / Not now" above the list. The switch also lives in Settings ▾
Keys j/k, r, e, c, /, u, m, 1–9, g i/s/a Plus shift+i / shift+u (mark read/unread), and s, #, g d answering "coming soon"

After the founder's first look (2026-10-10):

  • Row 2 while reading: an open conversation hides the header's row 2 (useHideServiceBar), so its own toolbar sits right under row 1, as in Gmail.
  • Subject line: thin (22px, regular weight) with small grey tags after it. The "participants · N messages" line is gone.
  • No boxes: messages are open sections divided by lines, with the avatar in the gutter and everything else starting at the name column. The email has no frame and no width cap. In the light theme the reading surface is white and the email has no margin, so it blends in; in the dark theme it stays a white letter with a margin.
  • Bars: the reply bar is slimmer (36px controls on desktop, 44px kept on phones) and the list bar is 40px.

Commits (branch mailbox/gmail-redesign)

Commit Step
74cb50af Folders in row 2 (a registry lets a service own row 2)
49f9a327 Mailbox service: several mailboxes per list (accountIds), categories, sender name and attachments on rows, the unsubscribe endpoint
f4122415 The full-width list, category tabs, bottom bar, scope and thread in the URL
3d2e14c4 The mailbox flip, several mailboxes, saved groups
5bcdd6f5 The full-width reader and docked reply bar
f4479ed3 Compose
5b0d65e3 Phones
90f47fe9 The alerts prompt, keys and palette

Rolling it out (founder)

  1. Deploy the mailbox worker first (49f9a327). Until then the new page still works against today's API: the category tabs stay disabled, sender names fall back to addresses, Unsubscribe never shows, and picking several mailboxes (or a group) shows every mailbox, because today's API ignores accountIds; picking one works as before. No migration: categories are computed on the fly.
  2. Push the branch (it sits on nav/remove-sidebar), and Vercel redeploys the dashboard.
  3. Done 2026-10-10: pushed to main, the mailbox worker deployed (version 87e148b7), and the sidebar navigation removed, so row 2 folders and the phone bottom bar are what everyone sees.

Before turning it on: manual checks

Only jsdom has seen the layout. Check on localhost with the flag on, at 360, 390, 768, 1024 and 1440px:

  • Back from a conversation returns to the same scroll position, and so does the browser's Back button.
  • The mailbox flip with 12+ mailboxes; a three-mailbox group; delete a group.
  • A wide newsletter shows no inner scrollbar (it scrolls sideways inside the letter only).
  • The phone keyboard with the reply open: the reply bar and Send stay above it.
  • Phone compose: Send at the top right, never covered.
  • Long-press on a real phone (Android and iOS): no text selection or callout menu; scrolling never selects.
  • The Folders and Mailboxes sheets on a phone.

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

Built with their backend; nothing pushed or deployed yet.

Feature What people get Where it lives
Starred (MR-1) A star on rows (desktop), in the reader toolbar and in the bulk bar, the s key, and a Starred folder. Stars are per person: each teammate has their own Worker: mailbox_thread_stars, POST /threads/:id/star and /unstar, filter=starred, starred on every thread. Dashboard: toggleStar / bulkStar in MailboxPage
Spam (MR-4, the spam half) "Report spam" (toolbar, bulk, !) moves the conversation and blocks the sender for that mailbox, with Undo. Later mail from a blocked sender, and a new conversation whose first mail fails DMARC, lands in Spam. "Not spam" moves it back and unblocks. The reader says why mail is in Spam. "Empty spam now" deletes it for good (no automatic deletion: G30). Blocked senders are listed on the Manage page with Unblock Worker: status='spam' + spam_reason/spam_at, mailbox_sender_blocks, ingest (lib/ingest.ts), lib/thread-actions.ts, lib/purge.ts (the worker's first delete, R2 included). Spam is left out of Inbox, Sent, All mail, Starred and unread counts. No desktop alert for spam
Drafts (MR-2) A Drafts folder with every unsent message, private to its writer and on every device. Each new message (and forward) is its own draft; your reply in a conversation is one draft there, and that row says "Draft". Sending deletes the draft Worker: mailbox_drafts, /drafts and /threads/:id/draft, /compose and /reply delete after a successful send. Dashboard: drafts.ts stays the instant local copy; serverDrafts.ts writes every save through after 1.5s. Last write wins
Synced groups (MR-8) Saved groups of mailboxes follow you to every device; groups saved in a browser before upload once and keep their links Worker: mailbox_scope_groups, /groups. Dashboard: groups.ts
Bulk (part of MR-11) Bulk star / spam go through one request POST /threads/bulk (star, unstar, read, unread, archive, unarchive, spam, notspam)
Realtime A star, draft or group change reaches only your own other tabs and devices The hub delivers a frame with a userId only to that person's sockets

Feature gate. GET /accounts returns features: ["star","drafts","spam","groups"]. Each control stays "coming soon" until its feature is listed (inbox/features.ts), because an older worker treats an unknown folder as "all open mail". So the dashboard and the worker can be deployed in either order.

Rollout (founder). All three steps are needed before the features show.

  1. Migrate the mailbox database: node apps/mailbox/scripts/apply-2026-10-folders.mjs (a dry run), then --apply --columns, then --apply --indexes.
  2. Deploy the worker: turbo run deploy with CLOUDFLARE_ACCOUNT_ID set.
  3. Push main, and Vercel rebuilds the dashboard.

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

The founder's decisions, all taken as recommended:

  • Only people who manage the mailbox can delete.
  • Whole conversations only.
  • Deleted for good after 30 days.
  • Trash sits under More.
  • Swiping left on a phone is a setting (archive by default).
Piece What people get Where it lives
Delete Reader toolbar, row hover, bulk bar and # move a conversation to Trash, with Undo. Not offered in Spam (spam is deleted forever directly) Worker: POST /threads/:id/trash; status trash with trashed_at/trashed_by/trashed_from. Dashboard: moveToTrash / bulkTrash in MailboxPage
Trash folder Under More. Rows show the days left. The reader says who moved it and when it goes. Move to Inbox sends it back where it was (Inbox or Archived). Delete forever is confirmed. Empty Trash now deletes in rounds. Spam gets Delete forever too filter=trash, POST /threads/:id/restore, DELETE /threads/:id (Trash or Spam only), DELETE /threads/trash, bulk trash/restore/delete
30-day auto-delete Nothing to do: past 30 days it is gone No cron slot is used. The per-workspace MailboxHub Durable Object sets a daily alarm when something is trashed, purges what is over 30 days, and stops once Trash is empty. Listing Trash and Empty Trash also purge expired items first. TRASH_RETENTION_DAYS is in apps/mailbox/src/lib/trash.ts; deletion goes through the shared lib/purge.ts, which since 2026-10-14 (RV-2, RV-10) locks and re-checks each batch, deletes at most 2,000 messages per call so a huge thread drains over several runs, and never takes a conversation restored mid-purge
Coming back New mail on a deleted conversation brings it back to the Inbox (to Spam if the sender is blocked). Replying, Archive, Unarchive and Report spam also move it out lib/ingest.ts, recordOutbound, thread-actions.ts
Phone swipe Settings ▾: "Swipe left on a phone": Archive or Delete inbox/prefs.ts (swipeLeft)

Trash is left out of Inbox, Sent, All mail, Starred, search and unread counts. It is gated on the worker's trash feature like the other folders.

Rollout (founder): from apps/mailbox:

  1. node scripts/apply-2026-10-trash.mjs --columns (dry run), then --columns --apply.
  2. Deploy the worker.
  3. --indexes --apply.
  4. Push main.

Deleted mail can still exist in the database host's (Neon) point-in-time backups until its retention window passes. The privacy page makes no promise either way.

Backend later

Shown in the UI as disabled "coming soon" controls. Each needs server work first.

Id Feature What the server needs Where it shows today
MR-1 Star Done 2026-10-10 (see above)
MR-2 Server drafts Done 2026-10-10 (see above)
MR-3 Snooze snoozed_until and a wake-up job Row hover, reader toolbar, More ▾
MR-4 Spam and Trash Done 2026-10-10 (see above). Spam still has no automatic deletion, only "Empty spam now"
MR-5 Scheduled send Queue with a send time (roadmap 6.3) More ▾
MR-6 Important A signal to rank by More ▾
MR-7 Draft with AI A drafting endpoint using Contacts memory (roadmap 6.7) Reply bar
MR-8 Saved groups on the server Done 2026-10-10 (see above)
MR-9 Persisted category Category is computed per request from the newest inbound message; at volume it should be a stored, indexed column Category tabs
MR-10 Labels See "Planned, not started: manageable labels" in docs/mailbox/backlog.md Not shown (never in the design)
MR-11 Mark all as read Done 2026-10-10: POST /threads/read-all (per person, the mailboxes and Inbox tab in view, 500 a call); ⋮ in the list's bottom bar

Gap log

Found while building. Fixed in the same change, or recorded here with an owner.

Fixed

Gap Fix
Archiving then going back left you at the list after every message Auto-advance to the next conversation, with the choice in Settings ▾
The email's own frame scrolled inside the page (two scrollbars) The frame grows to its content (cap 20000px). Only a wide email scrolls sideways, inside its own wrapper
Compose on phones: the floating service button sat over Send No floating button on the inbox; Send moved to the top right on phones
Palette listed sections without a chord as "g " No hint when there is no chord
On phones the mailbox sheet held the flip's panel, itself a role="dialog": a dialog inside a dialog In the sheet the panel is a group; the sheet is the dialog
vi.clearAllMocks keeps mockResolvedValueOnce queues, so one failing page test broke the next Noted here. The paging test is now scoped so it can't fail on the phone pager's duplicate text

| MR-G1: list rows were listbox options holding buttons (axe nested-interactive) | The list is a grid now (Gmail's model): row > gridcell, where controls are allowed. Whole-page axe checks are back (2026-10-10, c110fe90) | | A draft cleared while its save was on the wire came back | A save that lands after its clear deletes itself (serverDrafts.ts) | | Migration scripts checked out with CRLF broke the script tests (#!…node\r) | apps/mailbox/.gitattributes keeps scripts/*.mjs LF |

| MR-G2: no list cursor, so no Gmail x / o | j/k move a cursor through the list while nothing is open (o / Enter open it, x ticks it); inside a conversation j/k still open the next one. Closing a conversation leaves the cursor on it (2026-10-10) | | MR-G3: receipts with a List-Unsubscribe header landed in Promotions | Automated senders and transactional subjects (receipt, invoice, your order, has shipped, verify your, sign-in, one-time code…) file under Updates before the list rule (lib/category.ts, 2026-10-10). Narrowed 2026-10-14 (RV-3, "conversations stay Primary"): the subject rule applies to bulk mail only, role senders (billing@, orders@…) only when the mail isn't a reply, and any thread the team has written in stays Primary — a customer's "invoice #4521" question was landing in Updates (docs/mailbox/backlog.md, 2026-10-14) | | MR-G5: Unsubscribe only opened the sender's page | One-click unsubscribe (RFC 8058) from our server: POST /messages/:id/unsubscribe, public https only, no redirects followed; falls back to the link (2026-10-10) |

Open

Id Gap Owner / next step
MR-G4 About {sender} matching. It searches Contacts by the address and takes an exact externalId match, so a contact keyed by a phone or chat id isn't found Contacts: a lookup by email channel
MR-G6 Phone selection header. The design turned the top bar into "N selected" too. The bottom actions bar already says "N selected" with Done, so the top bar stays as search Founder to confirm on a phone
MR-G7 The page is still one large file. MailboxPage.tsx is about 1,330 lines. Splitting it was skipped: the MF-* fixes inside it are delicate Same item as plan 5.2's descoped split in the mailbox backlog
Seller Dashboard