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 { 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 = { /** 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>( opened: Array<{ account: EmailAccount; db: Database }>, run: (db: Database, take: number) => T[], options: AcrossOptions, ): Array { 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 { 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; }