one resolver for where an email chat lives

the picker above an email composer listed conversations from the owner's home —
a chat started somewhere else entirely, offered as if it belonged to the mailbox.

the address had two resolvers. the chat socket knew `context: 'email'` and ran
the turn in the account's directory; `GET /chat/sessions` did not, and fell
through to `who.home`. so a message went into the mailbox and could not be found
in the list directly above where it was typed. two answers to "where does this
chat live" is one too many, and the one that was missing failed quietly.

`resolveEmailAccountDir` in data-path.ts is now the only answer, and both call
it. it verifies a client-named mailbox against disk rather than sanitising it
into shape, which matters because the socket's caller ends in mkdirSync — an
unrecognised value would not fail, it would CREATE a directory named after
whatever arrived and run turns in it. `all`, the list's own sentinel for the
unified view, is exactly the value most likely to arrive by accident.

pure: no mkdir, no writes. the caller that needs the directory says so itself.

tested, because a missing branch is what this was: the named mailbox wins, `all`
never becomes a directory, a mailbox with no emails.db is not a mailbox, and no
account at all resolves to the accounts root rather than to something invented.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-26 18:47:37 +00:00
co-authored by Claude Opus 5
parent be210c3ed4
commit 7e6cfc85af
4 changed files with 99 additions and 21 deletions
+29
View File
@@ -371,6 +371,35 @@ export function findEmailAccountName(ownerEmail: string): string | null {
}
}
/**
* The directory an email chat belongs to — the mailbox's own, the one holding `emails.db`.
*
* ── Why this is a function and not two lines at each call site ──
*
* It IS an address, and it had two resolvers. The chat socket resolved it to run the turn; the sessions
* route resolved it to list the conversations. Only the socket knew about `email`, so the picker fell
* through to the owner's home and offered chats from somewhere else entirely — you could send a message
* into the mailbox and then not find it in the list above the composer. Two answers to "where does this
* chat live" is one too many.
*
* `requested` reaches here from the client (the mailbox picker's `?account=`) and is verified against disk
* before it is joined, never sanitised into shape. That matters because the socket's caller ends in
* `mkdirSync`: an unrecognised value would not fail, it would CREATE a directory named after whatever
* arrived. The list's unified view uses the sentinel `all`, which is the value most likely to show up by
* accident.
*
* Falls back to the most recently synced account, and to the accounts root when there is no account at
* all — a real state for a fresh install, and a directory that exists rather than one invented here.
*
* Pure: no mkdir, no writes. The caller that needs the directory to exist says so itself.
*/
export function resolveEmailAccountDir(ownerEmail: string, requested?: string): string {
const named = requested?.trim();
const known = named && existsSync(join(getEmailAccountsDir(ownerEmail), named, 'emails.db')) ? named : null;
const account = known ?? findEmailAccountName(ownerEmail);
return account ? join(getEmailAccountsDir(ownerEmail), account) : getEmailAccountsDir(ownerEmail);
}
// Sanitises a display username or email into a bare, lowercase, shell-safe token. The name and the
// 32-char Linux limit are the last trace of the per-container architecture, where this really did name
// a Linux user inside the user's container. Nothing creates a Linux user now — the value is carried