Files
email/sidecar/mail-scope.ts
T
pastilhasandClaude Opus 5 c60e3d6583 every route can name its mailbox, and the list can span them
Multiple accounts were already supported everywhere except where it counted. email_accounts is keyed
(user_id, email), each account syncs into its own emails.db, and IDLE watchers run per account — so three
accounts would all sync happily and only one could ever be read. The assumption lived in two lines:

    store.ts     openUserEmailDb()   accounts.find(a => a.enabled) ?? accounts[0]
    routes.ts    getSmtpTransport()  accounts.find(a => a.enabled) ?? accounts[0]

The first had thirteen callers — every read route. The second decided who sends.

Now `?account=<id>` names one mailbox and `?account=all` spans them. Absent stays the old behaviour, first
enabled, because the mobile app and the agent's email_db tool both call these routes and know nothing about
accounts; making it required would have broken both silently.

ONE DATABASE PER ACCOUNT, and the unified view is a MERGE rather than a join. Not a limitation — emails_fts
is a per-database FTS5 virtual table and MATCH cannot be aimed across an ATTACH boundary through a view, so
a unified search has to be a merge of local searches. The upside is that every SQL string in store.ts is
reused verbatim, searchEmails included, so the operator parser and the OR branching are not reimplemented
for the unified case.

The merge is a distributed top-K and it is exact, not approximate: each mailbox is asked for offset+limit
rows, because a row ranked third locally can belong fortieth globally and anything less leaves a hole in a
later page.

Threads are collapsed per mailbox before merging, deliberately. A conversation does not continue across two
accounts, so local collapse is both cheaper and more correct than any cross-database window function.

Four details worth keeping:

- Routes addressed by an id search the scope for the mailbox HOLDING it. A unified list returns ids from
  every account, so opening the first would 404 on mail plainly on screen. The predicate is `id OR
  thread_id` because these routes take both and the id spaces overlap by design.
- sync-status across mailboxes reports the OLDEST stamp. "Synced as of" is a claim about everything on
  screen and stops being true at the laggard; the freshest would call a unified inbox current while one
  account had not been reached for a day.
- Sending honours the named account and REFUSES an id that is not the caller's, rather than falling back to
  the default. Replying from the wrong account leaks which addresses belong to one person and breaks the
  thread for the recipient.
- queryAcross has no `descending` option. It had one for twenty minutes; a test caught that it flipped the
  merge while every caller's SQL still said ORDER BY date DESC, so asking for the oldest mail returned the
  oldest of the newest page. The requirement — mailboxes hand back newest-first — is now written down and
  pinned by a test that demonstrates the silent wrongness rather than asserting the fix.

UI is next: the picker for switch-or-all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 00:45:57 +00:00

139 lines
6.2 KiB
TypeScript

import type { Database } from 'bun:sqlite';
import type { EmailAccount } from '../db/types';
import { getEmailAccounts } from '../db/queries';
import { openEmailDb } from './store';
/*
Which mailboxes a request is about.
Every read route used to answer this the same way, in a line that appeared twice and was copied a third
time into the web list:
const account = accounts.find((a) => a.enabled) ?? accounts[0];
So an account could be added, could sync, could fill its own database — and could never be read. Thirteen
routes looked at the first one and nothing said so.
── One database per account, and no ATTACH ──
Each account keeps its own `emails.db`. A unified view is therefore a MERGE, not a join, and that is a
choice rather than a limitation: `emails_fts` is a per-database FTS5 virtual table, and `MATCH` cannot be
aimed through a view over an ATTACHed sibling. Merging keeps every SQL string in `store.ts` exactly as it
is — the same query, run per account, combined here.
The merge is a distributed top-K, which is EXACT rather than approximate: to serve rows
[offset, offset+limit) sorted by date across N mailboxes, ask each for its first (offset+limit) rows and
take the window from the combined sort. Nothing in a mailbox's tail can outrank a row it has already been
outranked by locally.
*/
export type MailScope = {
/** The accounts in play. One for a normal request, all enabled ones for `?account=all`. */
accounts: EmailAccount[];
/** True when this is the unified view — routes that cannot merge use it to refuse clearly. */
unified: boolean;
};
/**
* Resolve the `account` query parameter.
*
* Absent means "the first enabled one", which is what every existing client sends and what the mobile app
* and the agent's `email_db` tool still assume. Making it required would have broken both silently, so the
* old behaviour is the default and the parameter is how you opt into knowing better.
*
* A numeric id must belong to the caller. `getEmailAccounts(userId)` is already scoped to them, so the id
* is checked against that list rather than fetched directly — an account id from another user resolves to
* nothing rather than to somebody else's mail.
*/
export async function resolveMailScope(userId: number, param: string | undefined): Promise<MailScope> {
const accounts = await getEmailAccounts(userId);
const enabled = accounts.filter((a) => a.enabled);
if (param === 'all') return { accounts: enabled.length ? enabled : accounts, unified: true };
if (param) {
const id = Number(param);
const one = Number.isFinite(id) ? accounts.find((a) => a.id === id) : undefined;
return { accounts: one ? [one] : [], unified: false };
}
const first = enabled[0] ?? accounts[0];
return { accounts: first ? [first] : [], unified: false };
}
/** Open every mailbox in the scope, tagged so a merged row can say where it came from. */
export function openScope(ownerEmail: string, scope: MailScope): Array<{ account: EmailAccount; db: Database }> {
return scope.accounts.map((account) => ({ account, db: openEmailDb(ownerEmail, account.email) }));
}
type AcrossOptions<T> = {
/** The value each row is ordered by. Dates are ISO strings here, so string compare is chronological. */
sortBy: (row: T) => string | number;
limit: number;
offset: number;
};
/**
* Run one query across several mailboxes and return the correct window of the combined result.
*
* Each mailbox is asked for `offset + limit` rows, not `limit`: a row at position 3 in one mailbox can
* belong at position 40 of the merge, so anything less risks a hole in a later page. That is also the cost
* — page 20 of a unified inbox reads 20 pages from every account. Acceptable for the page depths anybody
* scrolls to by hand, and the reason a unified view should not be the place anyone paginates deeply.
*
* **The per-mailbox query must already be ordered newest-first**, the same direction as the merge. That is
* the whole basis of a top-K merge: taking the first K locally is only safe if local order agrees with
* global order. This briefly had a `descending: false` option, which was wrong in a way a test caught — it
* flipped the merge while the callers' SQL still said `ORDER BY date DESC`, so asking for the oldest mail
* returned the oldest of the newest page. There is no caller for ascending, and an option that is only
* correct if you also remember to flip the SQL is worse than no option.
*/
export function queryAcross<T extends Record<string, unknown>>(
opened: Array<{ account: EmailAccount; db: Database }>,
run: (db: Database, take: number) => T[],
options: AcrossOptions<T>,
): Array<T & { accountId: number; accountEmail: string }> {
const take = options.offset + options.limit;
const rows = opened.flatMap(({ account, db }) =>
run(db, take).map((row) => ({ ...row, accountId: account.id, accountEmail: account.email })),
);
rows.sort((a, b) => {
const av = options.sortBy(a);
const bv = options.sortBy(b);
return av === bv ? 0 : av > bv ? -1 : 1;
});
return rows.slice(options.offset, options.offset + options.limit);
}
/**
* Open the one mailbox holding a message or a thread, closing the rest.
*
* Matches `id` OR `thread_id`, because these routes are addressed by both and the two id spaces overlap by
* design: a thread id is `sha1(Message-Id)` of the root message, so it usually IS a message id — but not
* for mail threaded by the subject fallback, where it is not. One predicate covers both without the caller
* having to know which kind of id it is holding.
*
* Asks each mailbox in turn and stops at the first hit. A collision across accounts would need the same
* message in both, in which case either copy answers the question correctly.
*/
export async function openHolding(
ownerEmail: string,
userId: number,
accountParam: string | undefined,
id: string,
): Promise<Database | null> {
const scope = await resolveMailScope(userId, accountParam);
const opened = openScope(ownerEmail, scope);
let held: Database | null = null;
for (const { db } of opened) {
if (held) {
db.close();
continue;
}
const hit = db.query('SELECT 1 FROM emails WHERE id = ? OR thread_id = ? LIMIT 1').get(id, id);
if (hit) held = db;
else db.close();
}
return held;
}