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:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user