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
@@ -300,7 +300,7 @@ export const EmailList = () => {
) : ( ) : (
<div className="flex flex-1 flex-col divide-y divide-white/10 overflow-y-auto"> <div className="flex flex-1 flex-col divide-y divide-white/10 overflow-y-auto">
{messages.map((msg: EmailSummary) => { {messages.map((msg: EmailSummary) => {
const unread = !msg.read; const unread = msg.threadUnread !== undefined ? msg.threadUnread > 0 : !msg.read;
return ( return (
<button <button
key={msg.id} key={msg.id}
@@ -311,7 +311,14 @@ export const EmailList = () => {
}`} }`}
> >
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className={`truncate text-sm ${unread ? 'font-semibold' : 'font-medium opacity-70'}`}>{msg.from}</span> <div className="flex min-w-0 items-center gap-1.5">
<span className={`truncate text-sm ${unread ? 'font-semibold' : 'font-medium opacity-70'}`}>{msg.from}</span>
{!!msg.threadCount && (
<span className="shrink-0 rounded-full bg-muted px-1.5 text-xs tabular-nums opacity-60" title={`${msg.threadCount} messages`}>
{msg.threadCount}
</span>
)}
</div>
<span className="shrink-0 text-xs opacity-50">{formatDate(msg.date)}</span> <span className="shrink-0 text-xs opacity-50">{formatDate(msg.date)}</span>
</div> </div>
<span className={`truncate text-sm ${unread ? 'font-medium' : 'opacity-70'}`}>{msg.subject}</span> <span className={`truncate text-sm ${unread ? 'font-medium' : 'opacity-70'}`}>{msg.subject}</span>
@@ -1,11 +1,11 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Mail, Reply } from 'lucide-react'; import { Mail, Reply, Paperclip } from 'lucide-react';
import { FileViewerProvider, FileViewerHeader, FileViewerBody } from 'officerdev'; import { FileViewerProvider, FileViewerHeader, FileViewerBody } from 'officerdev';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { useGlobal } from 'hooks/useGlobal'; import { useGlobal } from 'hooks/useGlobal';
import type { EmailMessage } from 'types'; import type { EmailMessage, EmailThread } from 'types';
import { useComposer, replyDraft } from './Compose'; import { useComposer, replyDraft } from './Compose';
type OpenAttachment = { type OpenAttachment = {
@@ -14,6 +14,8 @@ type OpenAttachment = {
root: string; root: string;
}; };
const senderName = (from: string) => from.replace(/\s*<[^>]+>$/, '').trim() || from;
const HtmlBody = ({ html }: { html: string }) => { const HtmlBody = ({ html }: { html: string }) => {
const iframeRef = useRef<HTMLIFrameElement>(null); const iframeRef = useRef<HTMLIFrameElement>(null);
@@ -37,9 +39,84 @@ const HtmlBody = ({ html }: { html: string }) => {
</html> </html>
`); `);
doc.close(); doc.close();
// Auto-size to content so each message sits at its natural height inside the stacked thread.
const resize = () => {
if (doc.body) iframe.style.height = `${doc.body.scrollHeight + 8}px`;
};
resize();
doc.querySelectorAll('img').forEach((img) => img.addEventListener('load', resize));
const t = setTimeout(resize, 300);
return () => clearTimeout(t);
}, [html]); }, [html]);
return <iframe ref={iframeRef} className="h-full w-full border-0" sandbox="allow-same-origin" title="Email body" />; return <iframe ref={iframeRef} className="w-full border-0" sandbox="allow-same-origin" title="Email body" />;
};
type MessagePanelProps = {
message: EmailMessage;
open: boolean;
onToggle: () => void;
onReply: () => void;
onOpenAttachment: (index: number) => void;
};
const MessagePanel = ({ message, open, onToggle, onReply, onOpenAttachment }: MessagePanelProps) => {
if (!open) {
return (
<button onClick={onToggle} className="flex w-full items-center gap-2 px-4 py-2.5 text-left hover:bg-accent/40 cursor-pointer">
<span className={`shrink-0 text-sm ${message.read ? 'opacity-70' : 'font-semibold'}`}>{senderName(message.from)}</span>
<span className="min-w-0 flex-1 truncate text-xs opacity-50">{message.snippet}</span>
{!!message.attachmentCount && <Paperclip className="h-3 w-3 shrink-0 opacity-40" />}
<span className="shrink-0 text-xs opacity-50">{new Date(message.date).toLocaleDateString()}</span>
</button>
);
}
return (
<div className="flex flex-col">
<div className="flex items-start justify-between gap-3 px-4 py-2.5">
<button onClick={onToggle} className="min-w-0 flex-1 text-left cursor-pointer">
<div className="flex flex-col gap-0.5 text-sm opacity-80">
<div>
<span className="font-medium">{message.from}</span>
</div>
<div className="text-xs opacity-70">
<span className="font-medium">To:</span> {message.to}
</div>
{message.cc && (
<div className="text-xs opacity-70">
<span className="font-medium">Cc:</span> {message.cc}
</div>
)}
<div className="text-xs opacity-60">{new Date(message.date).toLocaleString()}</div>
</div>
</button>
<button
onClick={onReply}
className="shrink-0 flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs hover:bg-accent cursor-pointer"
title="Reply"
>
<Reply className="h-3.5 w-3.5" /> Reply
</button>
</div>
{message.attachments.length > 0 && (
<div className="flex flex-wrap gap-1.5 px-4 pb-2">
{message.attachments.map((att, i) => (
<button
key={i}
className="cursor-pointer rounded bg-accent px-2 py-0.5 text-xs hover:bg-accent/80"
onClick={() => onOpenAttachment(i)}
>
{att.filename} ({Math.round(att.size / 1024)}KB)
</button>
))}
</div>
)}
{message.html ? <HtmlBody html={message.html} /> : <pre className="whitespace-pre-wrap px-4 pb-4 text-sm">{message.text}</pre>}
</div>
);
}; };
export const EmailReader = () => { export const EmailReader = () => {
@@ -48,27 +125,42 @@ export const EmailReader = () => {
const [, openCompose] = useComposer(); const [, openCompose] = useComposer();
const [selectedId] = useGlobal<string | null>('EMAIL_SELECTED', null); const [selectedId] = useGlobal<string | null>('EMAIL_SELECTED', null);
const [openAttachment, setOpenAttachment] = useState<OpenAttachment | null>(null); const [openAttachment, setOpenAttachment] = useState<OpenAttachment | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const { data: message, isLoading } = useQuery({ const { data: thread, isLoading } = useQuery({
queryKey: ['email-message', selectedId], queryKey: ['email-thread', selectedId],
queryFn: () => client.get<EmailMessage>(`/email/messages/${selectedId}`), queryFn: () => client.get<EmailThread>(`/email/thread/${selectedId}`),
enabled: !!selectedId, enabled: !!selectedId,
}); });
// Mark as read when message loads // Whenever the thread changes, expand just the latest message (Gmail-style).
useEffect(() => { useEffect(() => {
if (!message || message.read) return; const messages = thread?.messages;
client.patch(`/email/messages/${message.id}/read`).then(() => { if (messages?.length) setExpanded(new Set([messages[messages.length - 1]!.id]));
queryClient.invalidateQueries({ queryKey: ['email-messages'] }); }, [thread?.id, thread?.messages.length]);
}).catch(() => {});
}, [message?.id]);
const extractAttachment = async (index: number) => { // Mark the whole conversation as read when opened.
if (!selectedId) return; useEffect(() => {
const result = await client.post<OpenAttachment>(`/email/messages/${selectedId}/attachments/${index}/extract`); if (!thread || !selectedId || !thread.messages.some((m: EmailMessage) => !m.read)) return;
client
.patch(`/email/thread/${selectedId}/read`)
.then(() => queryClient.invalidateQueries({ queryKey: ['email-messages'] }))
.catch(() => {});
}, [thread?.id]);
const extractAttachment = async (messageId: string, index: number) => {
const result = await client.post<OpenAttachment>(`/email/messages/${messageId}/attachments/${index}/extract`);
setOpenAttachment(result); setOpenAttachment(result);
}; };
const toggle = (id: string) =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
if (!selectedId) { if (!selectedId) {
return ( return (
<div className="flex h-full flex-col items-center justify-center gap-2 text-sm opacity-50"> <div className="flex h-full flex-col items-center justify-center gap-2 text-sm opacity-50">
@@ -79,73 +171,44 @@ export const EmailReader = () => {
} }
if (isLoading) { if (isLoading) {
return ( return <div className="flex h-full items-center justify-center text-sm opacity-50">Loading...</div>;
<div className="flex h-full items-center justify-center text-sm opacity-50">
Loading...
</div>
);
} }
if (!message) return null; if (!thread || thread.messages.length === 0) return null;
const latest = thread.messages[thread.messages.length - 1]!;
return ( return (
<div className="flex h-full flex-col overflow-hidden"> <div className="flex h-full flex-col overflow-hidden">
<div className="flex flex-col gap-1 border-b px-4 py-3"> <div className="flex items-start justify-between gap-3 border-b px-4 py-3">
<div className="flex items-start justify-between gap-3"> <h2 className="text-lg font-semibold">{thread.subject}</h2>
<h2 className="text-lg font-semibold">{message.subject}</h2> <button
<button onClick={() => openCompose(replyDraft(latest))}
onClick={() => openCompose(replyDraft(message))} className="shrink-0 flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs hover:bg-accent cursor-pointer"
className="shrink-0 flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs hover:bg-accent cursor-pointer" title="Reply"
title="Reply" >
> <Reply className="h-3.5 w-3.5" /> Reply
<Reply className="h-3.5 w-3.5" /> Reply </button>
</button>
</div>
<div className="flex flex-col gap-0.5 text-sm opacity-70">
<div>
<span className="font-medium">From:</span> {message.from}
</div>
<div>
<span className="font-medium">To:</span> {message.to}
</div>
{message.cc && (
<div>
<span className="font-medium">Cc:</span> {message.cc}
</div>
)}
<div className="text-xs opacity-60">{new Date(message.date).toLocaleString()}</div>
</div>
{message.attachments.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{message.attachments.map((att: { filename: string; size: number; contentType: string }, i: number) => (
<button
key={i}
className="cursor-pointer rounded bg-accent px-2 py-0.5 text-xs hover:bg-accent/80"
onClick={() => extractAttachment(i)}
>
{att.filename} ({Math.round(att.size / 1024)}KB)
</button>
))}
</div>
)}
</div> </div>
<div className="flex-1 overflow-auto">
{message.html ? ( <div className="flex-1 divide-y divide-white/10 overflow-auto">
<HtmlBody html={message.html} /> {thread.messages.map((m: EmailMessage) => (
) : ( <MessagePanel
<pre className="whitespace-pre-wrap p-4 text-sm">{message.text}</pre> key={m.id}
)} message={m}
open={expanded.has(m.id)}
onToggle={() => toggle(m.id)}
onReply={() => openCompose(replyDraft(m))}
onOpenAttachment={(index) => extractAttachment(m.id, index)}
/>
))}
</div> </div>
<Dialog open={!!openAttachment} onOpenChange={(open) => !open && setOpenAttachment(null)}> <Dialog open={!!openAttachment} onOpenChange={(open) => !open && setOpenAttachment(null)}>
<DialogContent className="flex h-[80vh] max-w-4xl flex-col gap-0 p-0"> <DialogContent className="flex h-[80vh] max-w-4xl flex-col gap-0 p-0">
<DialogTitle className="sr-only">{openAttachment?.fileName}</DialogTitle> <DialogTitle className="sr-only">{openAttachment?.fileName}</DialogTitle>
{openAttachment && ( {openAttachment && (
<FileViewerProvider <FileViewerProvider filePath={openAttachment.filePath} fileName={openAttachment.fileName} root={openAttachment.root}>
filePath={openAttachment.filePath}
fileName={openAttachment.fileName}
root={openAttachment.root}
>
<div className="flex shrink-0 items-center gap-2 border-b px-4 pr-12 py-1.5"> <div className="flex shrink-0 items-center gap-2 border-b px-4 pr-12 py-1.5">
<FileViewerHeader /> <FileViewerHeader />
</div> </div>
+89 -4
View File
@@ -1,4 +1,5 @@
import { Database } from 'bun:sqlite'; import { Database } from 'bun:sqlite';
import { createHash } from 'node:crypto';
import { join } from 'node:path'; import { join } from 'node:path';
import { chmodSync } from 'node:fs'; import { chmodSync } from 'node:fs';
import { DATA_PATH } from '@@/data-path'; import { DATA_PATH } from '@@/data-path';
@@ -22,7 +23,8 @@ CREATE TABLE IF NOT EXISTS emails (
attachment_count INTEGER DEFAULT 0, attachment_count INTEGER DEFAULT 0,
read INTEGER DEFAULT 0, read INTEGER DEFAULT 0,
deleted INTEGER DEFAULT 0, deleted INTEGER DEFAULT 0,
labels TEXT labels TEXT,
thread_id TEXT
); );
CREATE TABLE IF NOT EXISTS attachments ( CREATE TABLE IF NOT EXISTS attachments (
@@ -48,6 +50,7 @@ CREATE INDEX IF NOT EXISTS idx_emails_from_address ON emails(from_address);
CREATE INDEX IF NOT EXISTS idx_emails_integration ON emails(integration); CREATE INDEX IF NOT EXISTS idx_emails_integration ON emails(integration);
CREATE INDEX IF NOT EXISTS idx_emails_email_account ON emails(email_account); CREATE INDEX IF NOT EXISTS idx_emails_email_account ON emails(email_account);
CREATE INDEX IF NOT EXISTS idx_emails_labels ON emails(labels); CREATE INDEX IF NOT EXISTS idx_emails_labels ON emails(labels);
CREATE INDEX IF NOT EXISTS idx_emails_thread ON emails(thread_id);
`; `;
/** Convert label IDs to lowercase comma-separated string for storage */ /** Convert label IDs to lowercase comma-separated string for storage */
@@ -74,6 +77,59 @@ function extractDomain(address: string): string {
return at >= 0 ? address.slice(at + 1) : ''; return at >= 0 ? address.slice(at + 1) : '';
} }
// ── Conversation threading ──
// `id` is sha1(Message-Id) (see resync.messageIdToStableId), so hashing a referenced Message-Id the
// same way yields the *id of that referenced email*. That makes header-based threading trivial:
// a reply's thread_id is the hash of its root Message-Id, which equals the root email's own id.
const hashMsgId = (msgId: string): string => createHash('sha1').update(msgId).digest('hex').slice(0, 16);
/** Ordered Message-Ids this email references (References first, root→leaf; else In-Reply-To). */
function extractReferenceIds(raw: string): string[] {
const refs = extractFullHeader(raw, 'References') || extractFullHeader(raw, 'In-Reply-To');
return Array.from(refs.matchAll(/<([^>]+)>/g), (m) => m[1]!.trim()).filter(Boolean);
}
/** Compute a header-based thread_id for a freshly ingested email (falls back to its own id = new thread). */
function computeThreadId(db: Database, id: string, raw: string): string {
const refIds = extractReferenceIds(raw);
if (refIds.length === 0) return id; // no ancestors → this email is a thread root
const hashed = refIds.map(hashMsgId);
// Adopt an ancestor's thread if we already have one stored (robust to In-Reply-To-only clients).
const placeholders = hashed.map(() => '?').join(',');
const found = db
.query(`SELECT thread_id FROM emails WHERE id IN (${placeholders}) AND thread_id IS NOT NULL LIMIT 1`)
.get(...hashed) as { thread_id: string } | null;
return found?.thread_id ?? hashMsgId(refIds[0]!);
}
const RE_PREFIX = /^\s*((re|fwd?|aw|wg|sv|vs|res|antw)\s*(\[\d+\])?\s*:\s*)+/i;
/** Normalize a subject for fallback grouping: strip reply/forward prefixes, fold whitespace, lowercase. */
function normalizeSubject(subject: string): string {
return subject.replace(RE_PREFIX, '').replace(/\s+/g, ' ').trim().toLowerCase();
}
const firstAddress = (value: unknown): string => {
if (typeof value !== 'string') return '';
const first = value.split(',')[0] ?? '';
return (first.match(/<([^>]+)>/)?.[1] ?? first).trim().toLowerCase();
};
/**
* Subject-based thread key for mail synced before header capture (no References available).
* Groups by normalized subject + the counterpart address, so recurring 1:1 conversations collapse
* while unrelated same-subject mail from different people stays apart. Trivial subjects stay ungrouped.
*/
function fallbackThreadId(row: { id: string; subject: unknown; from_address: unknown; to_address: unknown; email_account: unknown }): string {
const norm = normalizeSubject(typeof row.subject === 'string' ? row.subject : '');
if (!norm) return row.id;
const me = typeof row.email_account === 'string' ? row.email_account.toLowerCase() : '';
const from = typeof row.from_address === 'string' ? row.from_address.toLowerCase() : '';
const counterpart = from && from !== me ? from : firstAddress(row.to_address) || from;
return `s:${norm}|${counterpart}`;
}
export function openEmailDb(email: string): Database { export function openEmailDb(email: string): Database {
const dbPath = join(DATA_PATH, email, 'emails.db'); const dbPath = join(DATA_PATH, email, 'emails.db');
const db = new Database(dbPath, { create: true }); const db = new Database(dbPath, { create: true });
@@ -108,6 +164,13 @@ function migrate(db: Database): void {
if (!colNames.has('labels')) { if (!colNames.has('labels')) {
db.exec('ALTER TABLE emails ADD COLUMN labels TEXT'); db.exec('ALTER TABLE emails ADD COLUMN labels TEXT');
} }
if (!colNames.has('thread_id')) {
db.exec('ALTER TABLE emails ADD COLUMN thread_id TEXT');
}
// Backfill thread_id for any rows missing it. Header data isn't kept for already-synced mail, so
// these use the subject-based fallback. New mail gets an exact header-based thread_id at insert.
backfillThreadIds(db);
// Ensure sync_meta table exists (for DBs created before it was added to SCHEMA_TABLES) // Ensure sync_meta table exists (for DBs created before it was added to SCHEMA_TABLES)
db.exec('CREATE TABLE IF NOT EXISTS sync_meta (key TEXT PRIMARY KEY, value TEXT)'); db.exec('CREATE TABLE IF NOT EXISTS sync_meta (key TEXT PRIMARY KEY, value TEXT)');
@@ -120,6 +183,23 @@ function migrate(db: Database): void {
} }
} }
function backfillThreadIds(db: Database): void {
const rows = db
.query('SELECT id, subject, from_address, to_address, email_account FROM emails WHERE thread_id IS NULL')
.all() as Array<{ id: string; subject: unknown; from_address: unknown; to_address: unknown; email_account: unknown }>;
if (rows.length === 0) return;
const update = db.prepare('UPDATE emails SET thread_id = ? WHERE id = ?');
db.exec('BEGIN');
try {
for (const row of rows) update.run(fallbackThreadId(row), row.id);
db.exec('COMMIT');
} catch (err) {
db.exec('ROLLBACK');
throw err;
}
}
// ── Full-text search (FTS5) ── // ── Full-text search (FTS5) ──
// A standalone FTS5 index kept in sync with `emails` on every upsert. unicode61 + diacritic folding // A standalone FTS5 index kept in sync with `emails` on every upsert. unicode61 + diacritic folding
// gives accent-insensitive matching; per-term prefix queries make it feel incremental. // gives accent-insensitive matching; per-term prefix queries make it feel incremental.
@@ -267,11 +347,12 @@ type ParsedEmail = {
text?: string; text?: string;
attachments: Array<{ filename: string; size: number; contentType: string; content: string }>; attachments: Array<{ filename: string; size: number; contentType: string; content: string }>;
labels?: string[]; labels?: string[];
threadId?: string;
}; };
const upsertEmailStmt = ` const upsertEmailStmt = `
INSERT OR REPLACE INTO emails (id, integration, email_account, from_name, from_address, from_domain, to_address, cc, subject, date, snippet, html, text_body, attachment_count, labels) INSERT OR REPLACE INTO emails (id, integration, email_account, from_name, from_address, from_domain, to_address, cc, subject, date, snippet, html, text_body, attachment_count, labels, thread_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`; `;
const deleteAttachmentsStmt = 'DELETE FROM attachments WHERE email_id = ?'; const deleteAttachmentsStmt = 'DELETE FROM attachments WHERE email_id = ?';
@@ -298,6 +379,7 @@ export function upsertEmail(db: Database, email: ParsedEmail): void {
email.text ?? null, email.text ?? null,
email.attachments.length, email.attachments.length,
labelsToString(email.labels), labelsToString(email.labels),
email.threadId ?? email.id,
]); ]);
db.run(deleteAttachmentsStmt, [email.id]); db.run(deleteAttachmentsStmt, [email.id]);
@@ -338,9 +420,10 @@ export function upsertFromRawEml({ db, id, raw, integration, emailAccount, label
const domain = extractDomain(address); const domain = extractDomain(address);
const { html, text } = extractBody(raw); const { html, text } = extractBody(raw);
const threadId = computeThreadId(db, id, raw);
db.run(upsertEmailStmt, [ db.run(upsertEmailStmt, [
id, integration, emailAccount, name, address, domain, to, cc, subject, date, snippet, html, text, attachments.length, labelsToString(labels), id, integration, emailAccount, name, address, domain, to, cc, subject, date, snippet, html, text, attachments.length, labelsToString(labels), threadId,
]); ]);
if (attachments.length > 0) { if (attachments.length > 0) {
@@ -372,6 +455,8 @@ export function rowToSummary(row: Record<string, unknown>): EmailSummary {
...(row.attachment_count ? { attachmentCount: row.attachment_count as number } : {}), ...(row.attachment_count ? { attachmentCount: row.attachment_count as number } : {}),
...(row.read ? { read: true } : {}), ...(row.read ? { read: true } : {}),
...(labels ? { labels } : {}), ...(labels ? { labels } : {}),
...(row.thread_count && (row.thread_count as number) > 1 ? { threadCount: row.thread_count as number } : {}),
...(row.thread_unread ? { threadUnread: row.thread_unread as number } : {}),
}; };
} }
+79 -26
View File
@@ -173,10 +173,24 @@ emailRouter.get('/messages', async (ctx) => {
const db = openEmailDb(email); const db = openEmailDb(email);
try { 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 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>[]; .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); const messages = rows.map(rowToSummary);
return ctx.json({ messages, total: countRow.total }); return ctx.json({ messages, total: countRow.total });
} finally { } 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) => { emailRouter.get('/messages/:id', async (ctx) => {
const email = ctx.get('user').email; const email = ctx.get('user').email;
const id = ctx.req.param('id'); const id = ctx.req.param('id');
@@ -192,34 +232,47 @@ emailRouter.get('/messages/:id', async (ctx) => {
try { try {
const row = db.query('SELECT * FROM emails WHERE id = ? AND deleted = 0').get(id) as Record<string, unknown> | null; 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); 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< // GET /thread/:id — the full conversation containing message :id, oldest message first.
Record<string, unknown> 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 = { const threadKey = head.thread_id ?? id;
id: row.id as string, const rows = db
from, .query('SELECT * FROM emails WHERE COALESCE(thread_id, id) = ? AND deleted = 0 ORDER BY date ASC')
to: row.to_address as string, .all(threadKey) as Record<string, unknown>[];
cc: (row.cc as string) ?? undefined, const messages = rows.map((row) => buildMessage(db, row));
subject: row.subject as string, return ctx.json({ id, subject: head.subject, messages });
date: row.date as string, } finally {
snippet: row.snippet as string, db.close();
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(',') } : {}),
};
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 { } finally {
db.close(); db.close();
} }
+10
View File
@@ -9,6 +9,9 @@ export type EmailSummary = {
read?: boolean; read?: boolean;
fromDomain?: string; fromDomain?: string;
labels?: string[]; labels?: string[];
// Conversation grouping: total messages in the thread, and how many are unread (folder view only).
threadCount?: number;
threadUnread?: number;
}; };
export type EmailMessage = EmailSummary & { export type EmailMessage = EmailSummary & {
@@ -17,3 +20,10 @@ export type EmailMessage = EmailSummary & {
text?: string; text?: string;
attachments: Array<{ filename: string; size: number; contentType: string }>; attachments: Array<{ filename: string; size: number; contentType: string }>;
}; };
// A full conversation: every non-deleted message in the thread, oldest first.
export type EmailThread = {
id: string; // the representative message id used to open the thread
subject: string;
messages: EmailMessage[];
};
+1 -1
View File
@@ -2,7 +2,7 @@ export type {} from './globals';
export * from 'officerdb/types'; export * from 'officerdb/types';
export type { Job, JobStep, JobStatus, JobStepStatus, JobProgress } from './queue'; export type { Job, JobStep, JobStatus, JobStepStatus, JobProgress } from './queue';
export type { EmailSummary, EmailMessage } from './email'; export type { EmailSummary, EmailMessage, EmailThread } from './email';
export type SelectOption = { value: number | string; label?: string; href?: string }; export type SelectOption = { value: number | string; label?: string; href?: string };