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:ownerormailbox:accounts.manage) skipped the access list inaccessibleAccountIds,hasCap,listAccessibleAccounts,listAdminAccountsandsendCapAllowed.role_adminholds the whole catalogue, so every Admin was an org admin.isOrgAdminandlistAdminAccountsare gone.routes/accounts.ts:mailbox:access.managelet 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:isOrgAdminwas 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
ae2b6d0asucceeded. - 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") |