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>
This commit is contained in:
+133
-41
@@ -8,18 +8,39 @@ import { getEmailAttachmentCacheDir } from '@@/data-path';
|
||||
import { getEmailAccounts } from '../db/queries';
|
||||
import { openEmailDb, openUserEmailDb, rowToSummary, getSyncMeta, searchEmails } from './store';
|
||||
import { accountsRouter } from './accounts';
|
||||
import { openHolding, openScope, queryAcross, resolveMailScope } from './mail-scope';
|
||||
|
||||
export const emailRouter = createRouter();
|
||||
|
||||
emailRouter.route('/accounts', accountsRouter);
|
||||
|
||||
// ── Sending (SMTP) — sends as the connected account using its app password ──
|
||||
/**
|
||||
* The mailbox a message goes out from.
|
||||
*
|
||||
* `accountParam` is the account id the composer names. It matters most for a REPLY: mail arrives on one
|
||||
* account, and answering it from another is not a cosmetic slip — it leaks which addresses belong to the
|
||||
* same person, and the thread breaks for the recipient. `emails.email_account` records where each message
|
||||
* landed, so the composer can name the right one and this can honour it.
|
||||
*
|
||||
* `all` is not a sender. It falls through to the default, because "send from every account" is not a thing
|
||||
* anybody means — and a unified compose with no account chosen has to pick one rather than refuse.
|
||||
*/
|
||||
async function getSmtpTransport(
|
||||
userId: number,
|
||||
accountParam?: string,
|
||||
): Promise<{ transport: ReturnType<typeof nodemailer.createTransport>; from: string }> {
|
||||
const accounts = await getEmailAccounts(userId);
|
||||
const acct = accounts.find((a) => a.enabled) ?? accounts[0];
|
||||
const asked =
|
||||
accountParam && accountParam !== 'all' && Number.isFinite(Number(accountParam))
|
||||
? accounts.find((a) => a.id === Number(accountParam))
|
||||
: undefined;
|
||||
const acct = asked ?? accounts.find((a) => a.enabled) ?? accounts[0];
|
||||
if (!acct) throw errors.BAD_REQUEST('No email account configured');
|
||||
if (accountParam && accountParam !== 'all' && !asked) {
|
||||
// Naming an account that is not yours must not quietly send as you. Refusing is the only safe answer.
|
||||
throw errors.BAD_REQUEST('That mail account does not exist on this login');
|
||||
}
|
||||
const pass = (acct.credentials as Record<string, unknown> | null)?.password as string | undefined;
|
||||
if (!pass) throw errors.BAD_REQUEST('This account has no SMTP password — sending needs an app-password account');
|
||||
// Gmail: smtp.gmail.com:465 (SSL). Derive from the IMAP host for other providers.
|
||||
@@ -60,7 +81,11 @@ emailRouter.post('/send', async (ctx) => {
|
||||
|
||||
const html = str(form.html).trim();
|
||||
const inReplyTo = str(form.inReplyTo).trim();
|
||||
const { transport, from } = await getSmtpTransport(user.id);
|
||||
// The composer names the account; a reply names the one the original arrived on.
|
||||
const { transport, from } = await getSmtpTransport(
|
||||
user.id,
|
||||
typeof form.account === 'string' ? form.account : ctx.req.query('account'),
|
||||
);
|
||||
await transport.sendMail({
|
||||
from,
|
||||
to,
|
||||
@@ -79,7 +104,7 @@ emailRouter.post('/send', async (ctx) => {
|
||||
emailRouter.get('/contacts', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const like = `%${(ctx.req.query('q') ?? '').trim().toLowerCase()}%`;
|
||||
const db = await openUserEmailDb(user.email, user.id);
|
||||
const db = await openUserEmailDb(user.email, user.id, ctx.req.query('account'));
|
||||
if (!db) return ctx.json([]);
|
||||
try {
|
||||
const rows = db
|
||||
@@ -161,13 +186,32 @@ emailRouter.get('/search', async (ctx) => {
|
||||
const offset = (page - 1) * limit;
|
||||
if (!q) return ctx.json({ messages: [], total: 0 });
|
||||
|
||||
const db = await openUserEmailDb(user.email, user.id);
|
||||
if (!db) return ctx.json({ messages: [], total: 0 });
|
||||
const scope = await resolveMailScope(user.id, ctx.req.query('account'));
|
||||
const opened = openScope(user.email, scope);
|
||||
if (!opened.length) return ctx.json({ messages: [], total: 0 });
|
||||
try {
|
||||
const { rows, total } = searchEmails(db, q, limit, offset);
|
||||
return ctx.json({ messages: rows.map(rowToSummary), total });
|
||||
// `searchEmails` is reused verbatim, per mailbox. It has to be: `emails_fts` is a per-database FTS5
|
||||
// virtual table and `MATCH` cannot be aimed across an ATTACH boundary, so a unified search is a merge
|
||||
// of local searches rather than one query. The upside is that the operator parser, the OR branching
|
||||
// and every FTS detail stay in one place and are not reimplemented for the unified case.
|
||||
let total = 0;
|
||||
const merged = queryAcross(
|
||||
opened,
|
||||
(db, take) => {
|
||||
const { rows, total: t } = searchEmails(db, q, take, 0);
|
||||
total += t;
|
||||
return rows as unknown as Array<Record<string, unknown>>;
|
||||
},
|
||||
{ sortBy: (row) => String(row.date ?? ''), limit, offset },
|
||||
);
|
||||
const messages = merged.map((row) => ({
|
||||
...rowToSummary(row),
|
||||
account: row.accountEmail,
|
||||
accountId: row.accountId,
|
||||
}));
|
||||
return ctx.json({ messages, total });
|
||||
} finally {
|
||||
db.close();
|
||||
for (const { db } of opened) db.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -190,31 +234,56 @@ emailRouter.get('/messages', async (ctx) => {
|
||||
|
||||
const { where: folderWhere, params: folderParams } = folderFilter(folder);
|
||||
|
||||
const db = await openUserEmailDb(user.email, user.id);
|
||||
if (!db) return ctx.json({ messages: [], total: 0 });
|
||||
const scope = await resolveMailScope(user.id, ctx.req.query('account'));
|
||||
const opened = openScope(user.email, scope);
|
||||
if (!opened.length) return ctx.json({ messages: [], total: 0 });
|
||||
try {
|
||||
// One row per conversation: the latest message in each thread within this folder, plus the
|
||||
// thread's message count and how many are unread. COALESCE guards any un-backfilled rows.
|
||||
const rows = db
|
||||
.query(
|
||||
`SELECT * FROM (
|
||||
SELECT e.*,
|
||||
COUNT(*) OVER (PARTITION BY COALESCE(thread_id, id)) AS thread_count,
|
||||
SUM(CASE WHEN read = 0 THEN 1 ELSE 0 END) OVER (PARTITION BY COALESCE(thread_id, id)) AS thread_unread,
|
||||
ROW_NUMBER() OVER (PARTITION BY COALESCE(thread_id, id) ORDER BY date DESC, id DESC) AS rn
|
||||
FROM emails e
|
||||
WHERE ${folderWhere}
|
||||
) WHERE rn = 1
|
||||
ORDER BY date DESC LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.all(...folderParams, limit, offset) as Record<string, unknown>[];
|
||||
const countRow = db
|
||||
.query(`SELECT COUNT(DISTINCT COALESCE(thread_id, id)) as total FROM emails WHERE ${folderWhere}`)
|
||||
.get(...folderParams) as { total: number };
|
||||
const messages = rows.map(rowToSummary);
|
||||
return ctx.json({ messages, total: countRow.total });
|
||||
//
|
||||
// Unchanged from the single-account version, and run per mailbox rather than rewritten to span them.
|
||||
// Threading is per account by definition — a conversation does not continue across two mailboxes — so
|
||||
// collapsing threads locally and merging the collapsed rows is both cheaper and more correct than any
|
||||
// cross-database window function would be.
|
||||
const listOne = (db: (typeof opened)[number]['db'], take: number) =>
|
||||
db
|
||||
.query(
|
||||
`SELECT * FROM (
|
||||
SELECT e.*,
|
||||
COUNT(*) OVER (PARTITION BY COALESCE(thread_id, id)) AS thread_count,
|
||||
SUM(CASE WHEN read = 0 THEN 1 ELSE 0 END) OVER (PARTITION BY COALESCE(thread_id, id)) AS thread_unread,
|
||||
ROW_NUMBER() OVER (PARTITION BY COALESCE(thread_id, id) ORDER BY date DESC, id DESC) AS rn
|
||||
FROM emails e
|
||||
WHERE ${folderWhere}
|
||||
) WHERE rn = 1
|
||||
ORDER BY date DESC LIMIT ?`,
|
||||
)
|
||||
.all(...folderParams, take) as Record<string, unknown>[];
|
||||
|
||||
const countOne = (db: (typeof opened)[number]['db']) =>
|
||||
(
|
||||
db
|
||||
.query(`SELECT COUNT(DISTINCT COALESCE(thread_id, id)) as total FROM emails WHERE ${folderWhere}`)
|
||||
.get(...folderParams) as { total: number }
|
||||
).total;
|
||||
|
||||
const merged = queryAcross(opened, listOne, {
|
||||
sortBy: (row) => String(row.date ?? ''),
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
const total = opened.reduce((sum, entry) => sum + countOne(entry.db), 0);
|
||||
|
||||
// `account` rides on every row so a unified list can label who it arrived as, and so a reply can go
|
||||
// out from the right mailbox without a second lookup.
|
||||
const messages = merged.map((row) => ({
|
||||
...rowToSummary(row),
|
||||
account: row.accountEmail,
|
||||
accountId: row.accountId,
|
||||
}));
|
||||
return ctx.json({ messages, total });
|
||||
} finally {
|
||||
db.close();
|
||||
for (const { db } of opened) db.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -248,7 +317,10 @@ emailRouter.get('/messages/:id', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const id = ctx.req.param('id');
|
||||
|
||||
const db = await openUserEmailDb(user.email, user.id);
|
||||
// Which mailbox holds it. Addressed by id, so the account cannot be assumed: a unified list returns ids
|
||||
// from every mailbox, and opening the first would 404 on mail that is plainly on screen.
|
||||
const holder = await openHolding(user.email, user.id, ctx.req.query('account'), id);
|
||||
const db = holder;
|
||||
if (!db) return ctx.text('Not found', 404);
|
||||
try {
|
||||
const row = db.query('SELECT * FROM emails WHERE id = ? AND deleted = 0').get(id) as Record<string, unknown> | null;
|
||||
@@ -264,7 +336,10 @@ emailRouter.get('/thread/:id', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const id = ctx.req.param('id');
|
||||
|
||||
const db = await openUserEmailDb(user.email, user.id);
|
||||
// Which mailbox holds it. Addressed by id, so the account cannot be assumed: a unified list returns ids
|
||||
// from every mailbox, and opening the first would 404 on mail that is plainly on screen.
|
||||
const holder = await openHolding(user.email, user.id, ctx.req.query('account'), id);
|
||||
const db = holder;
|
||||
if (!db) return ctx.text('Not found', 404);
|
||||
try {
|
||||
const head = db.query('SELECT thread_id, subject FROM emails WHERE id = ? AND deleted = 0').get(id) as {
|
||||
@@ -289,7 +364,10 @@ emailRouter.patch('/thread/:id/read', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const id = ctx.req.param('id');
|
||||
|
||||
const db = await openUserEmailDb(user.email, user.id);
|
||||
// Which mailbox holds it. Addressed by id, so the account cannot be assumed: a unified list returns ids
|
||||
// from every mailbox, and opening the first would 404 on mail that is plainly on screen.
|
||||
const holder = await openHolding(user.email, user.id, ctx.req.query('account'), id);
|
||||
const db = holder;
|
||||
if (!db) return ctx.text('Not found', 404);
|
||||
try {
|
||||
const head = db.query('SELECT thread_id FROM emails WHERE id = ?').get(id) as { thread_id: string | null } | null;
|
||||
@@ -307,7 +385,10 @@ emailRouter.post('/messages/:id/attachments/:index/extract', async (ctx) => {
|
||||
const id = ctx.req.param('id');
|
||||
const index = Number(ctx.req.param('index'));
|
||||
|
||||
const db = await openUserEmailDb(user.email, user.id);
|
||||
// Which mailbox holds it. Addressed by id, so the account cannot be assumed: a unified list returns ids
|
||||
// from every mailbox, and opening the first would 404 on mail that is plainly on screen.
|
||||
const holder = await openHolding(user.email, user.id, ctx.req.query('account'), id);
|
||||
const db = holder;
|
||||
if (!db) return ctx.text('Attachment not found', 404);
|
||||
try {
|
||||
const row = db.query('SELECT filename, content FROM attachments WHERE email_id = ? AND idx = ?').get(id, index) as {
|
||||
@@ -339,7 +420,10 @@ emailRouter.patch('/messages/:id/read', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const id = ctx.req.param('id');
|
||||
|
||||
const db = await openUserEmailDb(user.email, user.id);
|
||||
// Which mailbox holds it. Addressed by id, so the account cannot be assumed: a unified list returns ids
|
||||
// from every mailbox, and opening the first would 404 on mail that is plainly on screen.
|
||||
const holder = await openHolding(user.email, user.id, ctx.req.query('account'), id);
|
||||
const db = holder;
|
||||
if (!db) return ctx.json({ ok: true });
|
||||
try {
|
||||
db.run('UPDATE emails SET read = 1 WHERE id = ? AND read = 0', [id]);
|
||||
@@ -353,7 +437,10 @@ emailRouter.delete('/messages/:id', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const id = ctx.req.param('id');
|
||||
|
||||
const db = await openUserEmailDb(user.email, user.id);
|
||||
// Which mailbox holds it. Addressed by id, so the account cannot be assumed: a unified list returns ids
|
||||
// from every mailbox, and opening the first would 404 on mail that is plainly on screen.
|
||||
const holder = await openHolding(user.email, user.id, ctx.req.query('account'), id);
|
||||
const db = holder;
|
||||
if (!db) return ctx.text('Not found', 404);
|
||||
try {
|
||||
const result = db.run('UPDATE emails SET deleted = 1 WHERE id = ? AND deleted = 0', [id]);
|
||||
@@ -367,13 +454,18 @@ emailRouter.delete('/messages/:id', async (ctx) => {
|
||||
emailRouter.get('/sync-status', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
|
||||
const db = await openUserEmailDb(user.email, user.id);
|
||||
if (!db) return ctx.json({ lastSyncAt: null });
|
||||
const scope = await resolveMailScope(user.id, ctx.req.query('account'));
|
||||
const opened = openScope(user.email, scope);
|
||||
if (!opened.length) return ctx.json({ lastSyncAt: null });
|
||||
try {
|
||||
const lastSyncAt = getSyncMeta(db, 'last_sync_at');
|
||||
// Across mailboxes the honest answer is the OLDEST, not the newest. "Synced as of" is a claim about
|
||||
// everything on screen, and it stops being true at the laggard — reporting the freshest would say the
|
||||
// unified inbox is current while one account has not been reached for a day.
|
||||
const stamps = opened.map(({ db }) => getSyncMeta(db, 'last_sync_at')).filter((v): v is string => !!v);
|
||||
const lastSyncAt = stamps.length === opened.length && stamps.length ? stamps.sort()[0] : null;
|
||||
return ctx.json({ lastSyncAt });
|
||||
} finally {
|
||||
db.close();
|
||||
for (const { db } of opened) db.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -383,7 +475,7 @@ emailRouter.get('/stats', async (ctx) => {
|
||||
|
||||
const { where: folderWhere, params: folderParams } = folderFilter(folder);
|
||||
|
||||
const db = await openUserEmailDb(user.email, user.id);
|
||||
const db = await openUserEmailDb(user.email, user.id, ctx.req.query('account'));
|
||||
if (!db) return ctx.json({ total: 0, byDomain: [], bySender: [] });
|
||||
try {
|
||||
const total = (
|
||||
@@ -409,7 +501,7 @@ emailRouter.get('/stats', async (ctx) => {
|
||||
emailRouter.get('/labels', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
|
||||
const db = await openUserEmailDb(user.email, user.id);
|
||||
const db = await openUserEmailDb(user.email, user.id, ctx.req.query('account'));
|
||||
if (!db) return ctx.json({ labels: [] });
|
||||
try {
|
||||
const rows = db.query('SELECT labels FROM emails WHERE deleted = 0 AND labels IS NOT NULL').all() as Array<{
|
||||
|
||||
Reference in New Issue
Block a user