logicspike/docs

Mailbox

Mailbox team access: design record

Planning for shared mailboxes where the team sees who did what and the person receiving the mail sees only the mailbox. Started 2026-10-10.

Status (2026-10-11): LIVE. Strict access (D2, D6 to D9 and D10 to D31 below) was built on branch mailbox/strict-access (one commit per step, listed under "Build log"), pushed to main (488efb03..ae2b6d0a), migrated and deployed the same day. "Rollout order" records what ran and the rollback versions.

Round 2, "Sent by" (D32 to D36): LIVE 2026-10-12. See the section "Sent by" at the end. Activity lines, presence, assignment and notes (D3) come next.

Decided

# Decision Date
D1 The person receiving mail sees only the mailbox address. A per-mailbox "Priya at Acme Support" display name comes later. 2026-10-10
D2 Strict access. Only people added to a mailbox can see anything inside it: mail, "Sent by", activity, presence. This applies to the workspace Owner and Admins too. 2026-10-10
D3 The build order follows the ranking: Sent by → activity lines and live presence → assignment and internal notes → mailbox activity log → notifications and stats. 2026-10-10
D4 Older mail with no attribution needs no backfill, because there are no real users yet. 2026-10-10
D5 A Viewer's abilities are limited inside the mailbox too, not only in what they can read. 2026-10-10
D6 Workspace owners and managers see only that a mailbox exists, its members, and its credits and validity. No mail. 2026-10-10
D7 Any access (permanent, temporary or emergency) is a request that only the mailbox's owner can approve. There's no workspace override. 2026-10-10
D8 Domains, Create mailbox and Manage mailboxes are visible only to people holding the permission for them. 2026-10-10
D9 Two locks (option A). Lock 1 is the workspace role on the Team page (Use mailboxes, Send, Create, Manage, Domains). Lock 2 is the per-mailbox role (Viewer, Sender, Manager, Owner), set only by that mailbox's Owner. Both must allow an action. The per-mailbox access list stays. Diagram: https://claude.ai/artifact/8Zu7kPESZCx9PYohwYaVs3 2026-10-11

Decided while planning the build (2026-10-11)

The approved plan locked these. The full plan, with routes, scenarios and verification: .claude/plans/can-we-start-planning-misty-kernighan.md.

# Decision
D10 Workspace permissions (Lock 1): mailbox:read Use mailboxes (now enforced), mailbox:send Send from mailboxes, mailbox:accounts.create Create mailboxes (new), mailbox:accounts.manage Manage mailboxes (members, credits, deactivate; no mail), mailbox:domains.manage Set up domains (new). mailbox:access.manage is deleted.
D11 Mailbox roles (Lock 2): Viewer reads; Sender also sends; Manager also archives, marks spam, trashes and blocks senders; Owner also manages people, answers requests and edits the signature. Exactly these four, with no custom sets.
D12 No bypass for anyone: not the workspace Owner, not Admins, not API keys.
D13 A mailbox can have several Owners; any one of them can approve.
D14 Temporary access is for Viewer, Sender or Manager, with an end date. An Owner can't be temporary. A temporary upgrade goes back to the earlier role when it ends.
D15 Requests come from people who manage mailboxes (for themselves or a teammate) or from members asking for a higher role for themselves. Only that mailbox's Owners answer, and they may only lower the role or shorten the time. A reason is required, and the time counts from approval. A request lapses after 14 days. There's one pending request per person per mailbox. Approving never lowers anyone.
D16 Activity log per mailbox, seen only by that mailbox's members and kept for the mailbox's life.
D17 Leaving the workspace: all of that person's mailbox access ends at once, their personal mailbox data in that workspace is deleted, and their pending requests are cancelled. Where they were the only Owner, the next in line takes over: the longest-standing permanent Manager, then Sender, then Viewer, among people whose role includes Use mailboxes.
D18 No Owner but someone who can take over: the next in line is promoted automatically. No Owner and nobody who can: the mailbox needs an owner, and someone who manages mailboxes names one (themselves allowed). That is recorded for good. Mail keeps arriving meanwhile.
D19 Mailboxes auto-created by incoming mail start with no Owner.
D20 Create mailbox picks the first Owner (by default, the creator) and members in one call. An address that already exists returns 409 ADDRESS_EXISTS; a deactivated one returns 409 ADDRESS_DEACTIVATED.
D21 Deactivate and reactivate: the people list is kept, and reactivating gives the person who reactivates it nothing. Nothing is ever hard-deleted.
D22 People who manage mailboxes see "Other mailboxes": the address, its people and their roles, cost, billing status and renewal. No mail, activity, settings or requests.
D23 Domains: add, verify and auto-configure accept comms:sender.write or mailbox:domains.manage. Delete stays comms:sender.write. Listing domains stays open to everyone.
D24 Comms logs (/v1/logs and the MCP logs tool) leave out mailbox sends. The email.sent Flows event drops subject and recipients for mailbox sends.
D25 Losing access to a mailbox deletes your drafts in it. Stars and read state stay, and stay invisible without access.
D26 Strict from launch day. Existing rows are normalised to the four roles. backfill-owner-access.mjs is deleted.
D27 Accepted recovery path: an Admin who removes every member of a mailbox from the workspace can then name an owner. This is loud, because it's logged in both the Team activity and the mailbox's own log.
D28 Already decided for later rounds: no "Seen by" receipts; Viewers show as viewing; Viewers can write internal notes; Senders can assign to themselves; people who left show as "(left)"; conversation activity is deleted with the conversation.

Decided during the build review (2026-10-11)

# Decision
D29 Next in line first, always. Naming an Owner when someone in the mailbox can take over (for example, their workspace role gained Use mailboxes after the Owner left) makes that person Owner instead and says so (succession: true). The directory and People views also promote them when they load. Before this, such a mailbox was stuck: it showed as needing an owner, but naming one was refused.
D30 A temporary change can't end before temporary access the person already has (409 ENDS_TOO_SOON). There is only one role to fall back to, so ending early would cut their current time short. Change it permanently, or pick an end on or after the current one.
D31 An Owner must be able to use mailboxes. Create refuses to make a creator Owner when the creator's role lacks Use mailboxes (422; name someone else). A mailbox created with members must have an Owner (400 OWNER_REQUIRED). An Owner whose role later loses Use mailboxes stays Owner (no automatic transfer, as planned). The People and directory views show "can't use: workspace role", and an Admin fixes the role. 2026-10-14 (RV-11): until then only the first Owner at create and "name an owner" checked this; making someone Owner on the People tab (PUT /accounts/:id/people/:userId) or naming a member Owner at create now answers 422 CANT_USE_MAILBOXES too, so the real Owner can't leave a mailbox stuck with an Owner who can't act.

UI mockups (proposed, under review): https://claude.ai/artifact/7HQfVBJ5mUuKGgH2ZFW4k8

Interactive map of roles, teammates and pages: https://claude.ai/artifact/5B8YbaRgMFCG18pueHQMCo

The bypasses that strict access removed (mailbox worker)

All removed on mailbox/strict-access (step 5, 6b6a62f3):

  • lib/access.ts: isOrgAdmin (system:owner or mailbox:accounts.manage) skipped the access list in accessibleAccountIds, hasCap, listAccessibleAccounts, listAdminAccounts and sendCapAllowed. role_admin holds the whole catalogue, so every Admin was an org admin. isOrgAdmin and listAdminAccounts are gone.
  • routes/accounts.ts: mailbox:access.manage let anyone grant any mailbox, themselves included. The permission is deleted, and only a mailbox's Owners add people.
  • routes/inbound.ts: a mailbox auto-created by inbound mail granted every workspace owner full access. It now starts with nobody (D19).
  • routes/stream.ts: an org admin's live connection got every mailbox. Now every socket is scoped to the mailboxes that person is in.
  • routes/attachments-upload.ts: isOrgAdmin was carried in upload tickets. The tickets now carry the Lock 1 bits.

Build log (branch mailbox/strict-access, from main 488efb03)

Step Commit What
1 89cd229e core-access catalogue (labels, the two new permissions, access.manage deleted) and apps/manager/scripts/catalogue-mailbox-2026-10.mjs
2 3fa89455 comms: /internal/mailbox-billing, the domain gate (D23), mailbox sends kept out of logs and Flows events (D24)
3 c4685e01 manager: POST /internal/memberships/batch, MAILBOX_SERVICE binding, removal notify on remove, leave and account delete
4 3b21f0c9 mailbox schema: scripts/apply-2026-10-access.mjs, DDL mirror, parity
5 6b6a62f3 strict reads: no bypass, Lock 1 enforced, active and expiry predicates, tickets, inbound
6 fdc80954 lib/access-writes.ts (the only writer, CAS on access_version), People routes, activity log, hub alarm for expiry
7 a1b03a8b requests and the bell's counts
8 d7f737fd directory, naming an owner, POST /internal/members/removed
9 86fb4812 dashboard: Mailboxes page rebuilt (People, Requests, Activity), the dialogs, RolePicker
10 cd028d8f dashboard: navigation gating, Domains gate, live refresh and cache clean-up on access change, empty states, Ask for Sender, bell items
review 13da80a1 fixes from the backend review: D29 to D31, signature leak, create race, partial membership answers, request log gaps, script fixes
11 afe05fd6 Team page: each member's mailboxes, and who takes over before removing an only Owner
12 this commit docs: this record, the mailbox backlog

Tests at the end of the build: mailbox 769, dashboard 1,715, manager script 4, all passing; comms 1,137 and manager 442+ at their steps.

Rollout order (run 2026-10-11, on the founder's go-ahead)

# Step Result
1 apps/manager/scripts/catalogue-mailbox-2026-10.mjs --apply 0 custom roles and 0 overrides changed; 3 API keys (CLI keys of one workspace) lost access.manage and gained accounts.create. A re-run finds nothing left.
2 Deploy manager version b187f760 (rollback 76145beb), with the MAILBOX_SERVICE binding
3 apps/mailbox/scripts/apply-2026-10-access.mjs --columns --apply columns and both new tables created
4 Deploy comms, then the mailbox worker comms 1b8a7824 (rollback c0b16993); mailbox dd7ac19d (rollback fb3a004c)
5 --normalize, --constraints, --indexes, --reconcile normalize: every row already held one of the four roles (nothing to do); all three constraints added and validated; mailbox_account_access_expiry_idx built and valid; reconcile: nobody who left still had access, and every mailbox already had an Owner (nothing to do)
6 Push main 488efb03..ae2b6d0a; Vercel builds the dashboard. The old POST/DELETE /accounts/:id/access routes are kept for one deploy, for dashboards still open on the old build.

After the deploy, api.vlozi.app answers 401 without a login on /mailbox/v1/access/attention, /accounts/directory, /access/requests/mine and /threads (so the routes exist and are guarded).

Still the founder's:

  • Confirm the Vercel build of ae2b6d0a succeeded.
  • The manual check, with test mail only to starkdipanshu456@gmail.com:
    • As workspace Owner, confirm another member's mailbox is locked.
    • Request access, approve it as that mailbox's Owner, and watch the inbox appear live.
    • Give 1-day temporary access and confirm it ends.
    • Remove a test teammate and confirm ownership passes on.
  • If Domains or Setup is missing from the Mailbox menu, sign out and back in. A session picks up the new permission names (accounts.create, domains.manage) only when its token is next issued.

Rolling back: roll back the workers to the versions above (wrangler rollback <version>). The new columns, tables and index are additive, and the old workers ignore them. The three CHECK constraints are not: the old mailbox worker can write capability sets outside the four roles. So before rolling the mailbox worker back, drop mailbox_account_access_role_check, _owner_permanent_check and _revert_check.

Team system audit (2026-10-10)

The Team UI is apps/seller-dashboard/src/app/dashboard/settings/members/page.tsx. Its backend is apps/manager/src/routes/team.ts and services/team.ts; roles come from packages/core-access.

ID Gap Where Status
TA-G1 Removing a member (or a member leaving) deleted only the membership row. The mailbox worker was never told: mailbox_account_access rows stayed, came back if the person was re-invited, and still counted toward the LAST_ADMIN guard. The open live connection kept receiving events. routes/team.ts DELETE /members/:id, routes/workspaces.ts /leave Fixed on the branch (steps 3 and 8). Manager calls mailbox POST /internal/members/removed after remove, leave and account delete. Mailbox confirms the person really left, ends their access, passes ownership on and deletes their personal rows. People, directory and attention loads catch a lost call.
TA-G2 A removal takes effect only when the old token expires (about 15 minutes); there's no revocation. core-auth/jwt.ts, auth/session.ts Open. For mailboxes, the access rows are gone at once, so the old token reaches nothing there.
TA-G3 mailbox:read is never checked anywhere. Removing it from a role changes nothing. apps/mailbox/src/lib/access.ts Fixed: it is Lock 1 "Use mailboxes", checked on every mailbox read (step 5).
TA-G4 The label "Provision mailboxes & view ALL org mailboxes" understates mailbox:accounts.manage, which is a full read/send/grant bypass. permission-catalogue.ts L206 Fixed by D2 (relabel)
TA-G5 The Team activity log can be read by anyone with team:members.read, a read-only permission that Viewers have. routes/team.ts GET /audit Open, decision needed
TA-G6 The activity log misses: invite accepted, member left, ownership transfer, invite resend. role.update saves no before. It shows only the last 100 rows, with no paging and no retention policy. services/audit.ts Open
TA-G7 The UI is stricter than the backend: only the Owner can change roles or edit custom roles in the UI, while the API allows Admins. members/page.tsx L796, L956, L988, L1005 Open, decision needed
TA-G8 The Customize sheet lets a non-owner tick permissions they don't hold. The server drops them silently, and the UI still says "Access updated". members/page.tsx sheet, team.ts L263 Open
TA-G9 Per-member overrides survive a role change, so grants and revokes made for the old role carry over. services/team.ts updateMemberRole Open
TA-G10 The Owner can assign role_owner by changing someone's role or through an invite. That creates a "shadow owner": system:owner in the token, but isOwner=false, so the owner-count, leave and delete guards don't see them. routes/team.ts L209, invitations Open
TA-G11 Deleting a custom role checks only members, not pending invites. A role with a pending invite likely fails with a 500 instead of a 409 (not verified). services/team.ts L102-108 Open, verify
TA-G12 Permission groups in the role editor mix services ("Analytics", "Settings"). Cosmetic. members/page.tsx L542-548 Open
TA-G13 Ownership transfer isn't audited, and the old owner's token keeps system:owner until it refreshes. routes/tenants.ts L210-266 Open

TA-G5 to TA-G13 are Team-page fixes for a separate small plan. They are not part of strict access.

Gaps found while building strict access

ID Gap Where Status
TA-G14 /v1/stats and the MCP get-analytics tool still count mailbox sends in their totals (no subjects or recipients). apps/communication/src/index.ts Open, low: counts only
TA-G15 Create mailbox checks "Create mailboxes" from the token, which can be up to 15 minutes old. The plan said to re-check it live. apps/mailbox/src/routes/accounts.ts POST /accounts Accepted: the same as every other workspace action (TA-G2). The ownership-changing actions (naming an owner, requests) are re-checked live.
TA-G16 An Owner whose workspace role loses Use mailboxes stays Owner but can't act. Requests wait, and nobody can add people until an Admin restores the role. design (D31) Accepted. People and directory show "can't use: workspace role" next to them.
TA-G17 The Team page's mailbox line and "who takes over" preview are read once per page load. After removing someone, they show the old owners until reload. The server decides succession itself. modules/mailbox/team/MemberMailboxes.tsx Open, small
TA-G18 apps/mailbox/wrangler.toml is untracked, so its bindings (MANAGER_SERVICE, MAILBOX_HUB, MBX_SEND_LIMITER) live only in the runbook. ops Open (also in the mailbox backlog)
TA-G19 The Mailboxes page and the dialogs are checked for phone widths in jsdom only, never on a device or in devtools at 360/390px. dashboard Open: founder to check
TA-G20 Tests run on PGlite, which has one connection, so the CAS race tests (two Owners removing each other; approve vs cancel) prove the single-statement logic, not real concurrency. The reasoning follows Postgres READ COMMITTED. apps/mailbox/test Accepted

Sent by (round 2, 2026-10-11)

Before this round, a shared mailbox labelled every outgoing message "You", even when a teammate sent it, and nothing recorded who pressed Send. Now each outgoing message records its sender. The mailbox's own people see who sent it; the recipient still sees only the mailbox.

The founder chose (2026-10-11): show it in the conversation and in the list, and record senders only this round.

# Decision
D32 Record the sender only. mailbox_messages.sent_by_user_id holds the person who pressed Send. mailbox_threads.last_outbound_by holds the sender of the newest outgoing message, written in the same statement as the thread's snippet and last_outbound_at. Who archived, deleted or marked spam is the next round (activity lines).
D33 Where it shows. In a conversation, your own sent mail says "You" and a teammate's shows their name and initials. In the list, when the newest message went out, the snippet starts "You: " or "Rahul: " (first name), in the Inbox, Sent and on phones. Mail sent before this round shows the mailbox's name, not the old, wrong "You".
D34 The recipient sees only the mailbox. From, Reply-To, Message-ID, reply quotes and forward headers never carry the teammate. Tests pin the send body, the reply quote and the forward header.
D35 Leaving keeps the attribution. Someone who left the workspace shows as "Rahul (left)". Someone removed from the mailbox but still in the workspace shows by name. sent_by_user_id is not personal data under D17, so it is not purged when someone leaves.
D36 Names are looked up when read (apps/mailbox/src/lib/people-names.ts). Each worker instance caches them for up to 5 minutes. A lookup that takes longer than 1.5 s or fails shows "A teammate" and never fails or slows the inbox. Ids only come from rows the caller can already read.

Build log (branch mailbox/sent-by, from main 28bcc6ca):

Step Commit What
1 3a402b3b the two columns, scripts/apply-2026-10-sent-by.mjs --columns, script test
2 0e56ecbf recording in reply and compose, people-names.ts, sentBy / lastOutboundBy in the API, the "sent-by" feature
3 d9fa4db0 inbox/senders.ts, message card labels and avatars, the list prefix, in-flight messages stamped as yours
4 this commit this section, the mailbox backlog

Tests at the end: mailbox 786, dashboard 1,740, all passing; next build OK.

Rollout (run 2026-10-12, on the founder's go-ahead):

# Step Result
1 apps/mailbox/scripts/apply-2026-10-sent-by.mjs --columns --apply. This had to come before the worker deploy, because the new worker reads both columns. both columns added
2 Deploy the mailbox worker version 95b5ab06 (rollback dd7ac19d). Through api.vlozi.app, /threads, /threads/:id and /accounts still answer 401 without a login.
3 Push main Vercel builds the dashboard

Rolling back: wrangler rollback dd7ac19d. The columns are additive and unconstrained, and the old worker ignores them. Nothing needs undoing in the database.

Still the founder's: confirm the Vercel build. Then, with test mail only to starkdipanshu456@gmail.com:

  • Send a reply as teammate A and open the conversation as B. It should show "A" on the message and "A: …" in the list.
  • As A, the same message should say "You".
  • The received email should show only the mailbox address.

Mail sent before 2026-10-12 shows the mailbox's name (TA-G23).

Gaps found in this round:

ID Gap Status
TA-G21 A changed name, or someone leaving, can take up to 5 minutes to show (the name cache). Accepted (D36)
TA-G22 The Trash banner still says "by a teammate", not the name. Open: comes with the activity-lines round
TA-G23 Mail sent before this round shows the mailbox's name. Who sent it was never recorded (D4). Accepted
TA-G24 List prefixes use first names, so two people named Rahul look alike in the list. The conversation shows full names. Accepted, small
TA-G25 The list's "Delivery problem" tooltip said "Your latest message", which is wrong in a shared mailbox. Fixed in step 3 ("The latest message sent here")
Mailbox