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>
59 lines
3.1 KiB
TypeScript
59 lines
3.1 KiB
TypeScript
import { describe, expect, it } from 'bun:test';
|
|
import { queryAcross } from './mail-scope';
|
|
|
|
// The merge is the part that can be silently wrong: a page with a hole in it looks like missing mail rather
|
|
// than like a bug. These pin the property that makes it exact — each mailbox is asked for offset+limit.
|
|
const mailbox = (dates: string[]) =>
|
|
({
|
|
account: { id: dates.length, email: `a${dates.length}@x` },
|
|
db: {} as never,
|
|
}) as never;
|
|
|
|
describe('queryAcross', () => {
|
|
const A = ['2026-08-10', '2026-08-08', '2026-08-02'];
|
|
const B = ['2026-08-09', '2026-08-07', '2026-08-01'];
|
|
|
|
const opened = [
|
|
{ account: { id: 1, email: 'a@x' }, db: 'A' },
|
|
{ account: { id: 2, email: 'b@x' }, db: 'B' },
|
|
] as never as Parameters<typeof queryAcross>[0];
|
|
|
|
const run = (db: unknown, take: number) =>
|
|
((db as unknown as string) === 'A' ? A : B).slice(0, take).map((date) => ({ date }));
|
|
|
|
it('interleaves newest-first across mailboxes', () => {
|
|
const rows = queryAcross(opened, run as never, { sortBy: (r) => String(r.date), limit: 4, offset: 0 });
|
|
expect(rows.map((r) => r.date)).toEqual(['2026-08-10', '2026-08-09', '2026-08-08', '2026-08-07']);
|
|
});
|
|
|
|
it('labels every row with the mailbox it came from', () => {
|
|
const rows = queryAcross(opened, run as never, { sortBy: (r) => String(r.date), limit: 2, offset: 0 });
|
|
expect(rows.map((r) => r.accountEmail)).toEqual(['a@x', 'b@x']);
|
|
});
|
|
|
|
// The reason each mailbox is asked for offset+limit rather than limit: page 2 must not skip a row that
|
|
// ranked low locally and high globally.
|
|
it('pages without a hole', () => {
|
|
const p1 = queryAcross(opened, run as never, { sortBy: (r) => String(r.date), limit: 2, offset: 0 });
|
|
const p2 = queryAcross(opened, run as never, { sortBy: (r) => String(r.date), limit: 2, offset: 2 });
|
|
const p3 = queryAcross(opened, run as never, { sortBy: (r) => String(r.date), limit: 2, offset: 4 });
|
|
expect([...p1, ...p2, ...p3].map((r) => r.date)).toEqual([...A, ...B].sort().reverse());
|
|
});
|
|
|
|
// The contract, pinned: local order must match merge order. A mailbox handing back rows in the wrong
|
|
// direction breaks top-K silently, which is why the `descending` option was removed rather than fixed —
|
|
// it flipped the merge while every caller's SQL still said ORDER BY date DESC.
|
|
it('trusts each mailbox to be newest-first', () => {
|
|
const backwards = (db: unknown, take: number) =>
|
|
((db as unknown as string) === 'A' ? [...A].reverse() : [...B].reverse())
|
|
.slice(0, take)
|
|
.map((date) => ({ date }));
|
|
const rows = queryAcross(opened, backwards as never, { sortBy: (r) => String(r.date), limit: 2, offset: 0 });
|
|
// The true two newest are 08-10 and 08-09. Fed oldest-first mailboxes, the helper sorts correctly
|
|
// within what it was handed and returns neither — no error, no gap, just quietly the wrong mail. That
|
|
// is the failure the written contract exists to prevent, and it is why the `descending` option went.
|
|
expect(rows.map((r) => r.date)).toEqual(['2026-08-08', '2026-08-07']);
|
|
expect(rows.map((r) => r.date)).not.toContain('2026-08-10');
|
|
});
|
|
});
|