email: group messages into conversations (Gmail-style threading)

Hybrid grouping via a new thread_id column: new mail threads exactly on
References/In-Reply-To (id is sha1(Message-Id), so a referenced id hashes to
the ancestor's own id); already-synced mail is backfilled with a
normalized-subject + counterpart key. Folder views collapse to one row per
thread with a count badge; the reader shows the thread as a collapsible stack.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 08:21:40 +00:00
co-authored by Claude Opus 4.8
parent 18d69616d2
commit 58373c9d59
6 changed files with 321 additions and 103 deletions
+79 -26
View File
@@ -173,10 +173,24 @@ emailRouter.get('/messages', async (ctx) => {
const db = openEmailDb(email);
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 emails WHERE ${folderWhere} ORDER BY date DESC LIMIT ? OFFSET ?`)
.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(limit, offset) as Record<string, unknown>[];
const countRow = db.query(`SELECT COUNT(*) as total FROM emails WHERE ${folderWhere}`).get() as { total: number };
const countRow = db
.query(`SELECT COUNT(DISTINCT COALESCE(thread_id, id)) as total FROM emails WHERE ${folderWhere}`)
.get() as { total: number };
const messages = rows.map(rowToSummary);
return ctx.json({ messages, total: countRow.total });
} finally {
@@ -184,6 +198,32 @@ emailRouter.get('/messages', async (ctx) => {
}
});
function buildMessage(db: ReturnType<typeof openEmailDb>, row: Record<string, unknown>): EmailMessage {
const attachmentRows = db.query('SELECT * FROM attachments WHERE email_id = ? ORDER BY idx').all(row.id as string) as Array<
Record<string, unknown>
>;
const from = row.from_name ? `${row.from_name} <${row.from_address}>` : (row.from_address as string);
return {
id: row.id as string,
from,
to: row.to_address as string,
cc: (row.cc as string) ?? undefined,
subject: row.subject as string,
date: row.date as string,
snippet: row.snippet as string,
html: (row.html as string) ?? undefined,
text: (row.text_body as string) ?? undefined,
attachments: attachmentRows.map((a) => ({
filename: a.filename as string,
size: a.size as number,
contentType: a.content_type as string,
})),
...(row.attachment_count ? { attachmentCount: row.attachment_count as number } : {}),
...(row.read ? { read: true } : {}),
...(row.labels ? { labels: (row.labels as string).split(',') } : {}),
};
}
emailRouter.get('/messages/:id', async (ctx) => {
const email = ctx.get('user').email;
const id = ctx.req.param('id');
@@ -192,34 +232,47 @@ emailRouter.get('/messages/:id', async (ctx) => {
try {
const row = db.query('SELECT * FROM emails WHERE id = ? AND deleted = 0').get(id) as Record<string, unknown> | null;
if (!row) return ctx.text('Not found', 404);
return ctx.json(buildMessage(db, row));
} finally {
db.close();
}
});
const attachmentRows = db.query('SELECT * FROM attachments WHERE email_id = ? ORDER BY idx').all(id) as Array<
Record<string, unknown>
>;
// GET /thread/:id — the full conversation containing message :id, oldest message first.
emailRouter.get('/thread/:id', (ctx) => {
const email = ctx.get('user').email;
const id = ctx.req.param('id');
const from = row.from_name ? `${row.from_name} <${row.from_address}>` : (row.from_address as string);
const db = openEmailDb(email);
try {
const head = db.query('SELECT thread_id, subject FROM emails WHERE id = ? AND deleted = 0').get(id) as
| { thread_id: string | null; subject: string }
| null;
if (!head) return ctx.text('Not found', 404);
const message: EmailMessage = {
id: row.id as string,
from,
to: row.to_address as string,
cc: (row.cc as string) ?? undefined,
subject: row.subject as string,
date: row.date as string,
snippet: row.snippet as string,
html: (row.html as string) ?? undefined,
text: (row.text_body as string) ?? undefined,
attachments: attachmentRows.map((a) => ({
filename: a.filename as string,
size: a.size as number,
contentType: a.content_type as string,
})),
...(row.attachment_count ? { attachmentCount: row.attachment_count as number } : {}),
...(row.read ? { read: true } : {}),
...(row.labels ? { labels: (row.labels as string).split(',') } : {}),
};
const threadKey = head.thread_id ?? id;
const rows = db
.query('SELECT * FROM emails WHERE COALESCE(thread_id, id) = ? AND deleted = 0 ORDER BY date ASC')
.all(threadKey) as Record<string, unknown>[];
const messages = rows.map((row) => buildMessage(db, row));
return ctx.json({ id, subject: head.subject, messages });
} finally {
db.close();
}
});
return ctx.json(message);
// PATCH /thread/:id/read — mark every message in the conversation as read.
emailRouter.patch('/thread/:id/read', (ctx) => {
const email = ctx.get('user').email;
const id = ctx.req.param('id');
const db = openEmailDb(email);
try {
const head = db.query('SELECT thread_id FROM emails WHERE id = ?').get(id) as { thread_id: string | null } | null;
if (!head) return ctx.text('Not found', 404);
const threadKey = head.thread_id ?? id;
db.run('UPDATE emails SET read = 1 WHERE COALESCE(thread_id, id) = ? AND read = 0', [threadKey]);
return ctx.json({ ok: true });
} finally {
db.close();
}