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>
403 lines
15 KiB
TypeScript
403 lines
15 KiB
TypeScript
import { mkdir } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import nodemailer from 'nodemailer';
|
|
import type { EmailMessage } from 'types';
|
|
import { createRouter } from '../../create-router';
|
|
import * as errors from '@@/custom-errors';
|
|
import { DATA_PATH } from '@@/data-path';
|
|
import { getEmailAccounts } from 'officerdb';
|
|
import { openEmailDb, rowToSummary, getSyncMeta, searchEmails } from './email-db';
|
|
import { accountsRouter } from './accounts';
|
|
|
|
export const emailRouter = createRouter();
|
|
|
|
emailRouter.route('/accounts', accountsRouter);
|
|
|
|
// ── Sending (SMTP) — sends as the connected account using its app password ──
|
|
async function getSmtpTransport(userId: number): Promise<{ transport: ReturnType<typeof nodemailer.createTransport>; from: string }> {
|
|
const accounts = await getEmailAccounts(userId);
|
|
const acct = accounts.find((a) => a.enabled) ?? accounts[0];
|
|
if (!acct) throw errors.BAD_REQUEST('No email account configured');
|
|
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.
|
|
const host = acct.provider === 'gmail' ? 'smtp.gmail.com' : acct.imapHost.replace(/^imap\./, 'smtp.');
|
|
const transport = nodemailer.createTransport({ host, port: 465, secure: true, auth: { user: acct.email, pass } });
|
|
return { transport, from: acct.email };
|
|
}
|
|
|
|
// POST /send — compose/reply (multipart; `files` are attachments). Gmail auto-files the sent copy in
|
|
// "Sent", so IMAP sync picks it up.
|
|
emailRouter.post('/send', async (ctx) => {
|
|
const user = ctx.get('user');
|
|
const form = await ctx.req.parseBody({ all: true });
|
|
const str = (v: unknown) => (typeof v === 'string' ? v : '');
|
|
const to = str(form.to).trim();
|
|
if (!to) throw errors.BAD_REQUEST('At least one recipient is required');
|
|
|
|
const toFiles = (raw: unknown) => (Array.isArray(raw) ? raw : raw ? [raw] : []).filter((f): f is File => f instanceof File);
|
|
const attachments = await Promise.all(
|
|
toFiles(form.files).map(async (f) => ({ filename: f.name, content: Buffer.from(await f.arrayBuffer()), contentType: f.type || undefined })),
|
|
);
|
|
// Inline images: cid `inline-<i>` matches the `<img src="cid:inline-i">` the composer put in the html.
|
|
const inline = await Promise.all(
|
|
toFiles(form.inline).map(async (f, i) => ({
|
|
filename: f.name,
|
|
content: Buffer.from(await f.arrayBuffer()),
|
|
contentType: f.type || undefined,
|
|
cid: `inline-${i}`,
|
|
contentDisposition: 'inline' as const,
|
|
})),
|
|
);
|
|
const allAttachments = [...attachments, ...inline];
|
|
|
|
const html = str(form.html).trim();
|
|
const inReplyTo = str(form.inReplyTo).trim();
|
|
const { transport, from } = await getSmtpTransport(user.id);
|
|
await transport.sendMail({
|
|
from,
|
|
to,
|
|
cc: str(form.cc).trim() || undefined,
|
|
bcc: str(form.bcc).trim() || undefined,
|
|
subject: str(form.subject).trim() || '(no subject)',
|
|
text: str(form.body),
|
|
...(html ? { html } : {}),
|
|
...(allAttachments.length ? { attachments: allAttachments } : {}),
|
|
...(inReplyTo ? { headers: { 'In-Reply-To': inReplyTo, References: inReplyTo } } : {}),
|
|
});
|
|
return ctx.json({ ok: true });
|
|
});
|
|
|
|
// GET /contacts?q= — address autocomplete from people you've received mail from, ranked by frequency.
|
|
emailRouter.get('/contacts', (ctx) => {
|
|
const email = ctx.get('user').email;
|
|
const like = `%${(ctx.req.query('q') ?? '').trim().toLowerCase()}%`;
|
|
const db = openEmailDb(email);
|
|
try {
|
|
const rows = db
|
|
.query(
|
|
`SELECT lower(from_address) AS address, from_name AS name, count(*) AS c
|
|
FROM emails
|
|
WHERE from_address IS NOT NULL AND from_address != '' AND deleted = 0
|
|
AND (lower(from_address) LIKE ? OR lower(from_name) LIKE ?)
|
|
GROUP BY lower(from_address)
|
|
ORDER BY c DESC LIMIT 10`,
|
|
)
|
|
.all(like, like) as Array<{ address: string; name: string | null }>;
|
|
return ctx.json(rows.map((r) => ({ address: r.address, name: r.name || '' })));
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
// ── Real-time: per-user SSE stream of email events (fed by the IMAP IDLE watcher) ──
|
|
const emailSseClients = new Map<string, Set<ReadableStreamDefaultController<Uint8Array>>>();
|
|
|
|
// Called from the sidecar-message handler when the IDLE watcher saves new mail for a user.
|
|
export function broadcastEmailNew(userEmail: string): void {
|
|
const set = emailSseClients.get(userEmail);
|
|
if (!set || set.size === 0) return;
|
|
const payload = new TextEncoder().encode(`data: ${JSON.stringify({ type: 'new-mail' })}\n\n`);
|
|
for (const ctrl of set) {
|
|
try {
|
|
ctrl.enqueue(payload);
|
|
} catch {
|
|
/* dead controller — cleaned up on cancel */
|
|
}
|
|
}
|
|
}
|
|
|
|
// EventSource endpoint (auth via ?token= handled by userMiddleware). The frontend opens this only
|
|
// while on /email, so events stop the moment you navigate away.
|
|
emailRouter.get('/events', (ctx) => {
|
|
const email = ctx.get('user').email;
|
|
const enc = new TextEncoder();
|
|
let controllerRef: ReadableStreamDefaultController<Uint8Array>;
|
|
let ping: ReturnType<typeof setInterval>;
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controllerRef = controller;
|
|
let set = emailSseClients.get(email);
|
|
if (!set) {
|
|
set = new Set();
|
|
emailSseClients.set(email, set);
|
|
}
|
|
set.add(controller);
|
|
controller.enqueue(enc.encode('retry: 3000\n\n'));
|
|
ping = setInterval(() => {
|
|
try {
|
|
controller.enqueue(enc.encode(': ping\n\n'));
|
|
} catch {
|
|
/* closed */
|
|
}
|
|
}, 25_000);
|
|
},
|
|
cancel() {
|
|
clearInterval(ping);
|
|
const set = emailSseClients.get(email);
|
|
set?.delete(controllerRef);
|
|
if (set && set.size === 0) emailSseClients.delete(email);
|
|
},
|
|
});
|
|
return new Response(stream, {
|
|
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' },
|
|
});
|
|
});
|
|
|
|
// Full-text search across all mail (subject / sender / recipients / snippet / body), newest first.
|
|
emailRouter.get('/search', async (ctx) => {
|
|
const email = ctx.get('user').email;
|
|
const q = (ctx.req.query('q') ?? '').trim();
|
|
const page = Math.max(1, Number(ctx.req.query('page') ?? '1') || 1);
|
|
const limit = Math.min(100, Math.max(1, Number(ctx.req.query('limit') ?? '50') || 50));
|
|
const offset = (page - 1) * limit;
|
|
if (!q) return ctx.json({ messages: [], total: 0 });
|
|
|
|
const db = openEmailDb(email);
|
|
try {
|
|
const { rows, total } = searchEmails(db, q, limit, offset);
|
|
return ctx.json({ messages: rows.map(rowToSummary), total });
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
emailRouter.get('/messages', async (ctx) => {
|
|
const email = ctx.get('user').email;
|
|
const page = Number(ctx.req.query('page') ?? '1');
|
|
const limit = Number(ctx.req.query('limit') ?? '50');
|
|
const folder = ctx.req.query('folder') ?? 'inbox';
|
|
const offset = (page - 1) * limit;
|
|
|
|
const folderWhere = folder === 'all' ? 'deleted = 0' : `deleted = 0 AND labels LIKE '%${folder}%'`;
|
|
|
|
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 (
|
|
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(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 {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
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');
|
|
|
|
const db = openEmailDb(email);
|
|
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();
|
|
}
|
|
});
|
|
|
|
// 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 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 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();
|
|
}
|
|
});
|
|
|
|
// 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();
|
|
}
|
|
});
|
|
|
|
emailRouter.post('/messages/:id/attachments/:index/extract', async (ctx) => {
|
|
const email = ctx.get('user').email;
|
|
const id = ctx.req.param('id');
|
|
const index = Number(ctx.req.param('index'));
|
|
|
|
const db = openEmailDb(email);
|
|
try {
|
|
const row = db.query('SELECT filename, content FROM attachments WHERE email_id = ? AND idx = ?').get(id, index) as {
|
|
filename: string;
|
|
content: string | null;
|
|
} | null;
|
|
if (!row || !row.content) return ctx.text('Attachment not found', 404);
|
|
|
|
const fileName = row.filename ?? 'unknown';
|
|
const attachDir = join(DATA_PATH, email, 'Gmail', 'emails', 'attachments');
|
|
const destPath = join(attachDir, fileName);
|
|
|
|
const destFile = Bun.file(destPath);
|
|
if (!(await destFile.exists())) {
|
|
await mkdir(attachDir, { recursive: true });
|
|
const binary = Buffer.from(row.content, 'base64');
|
|
await Bun.write(destPath, binary);
|
|
}
|
|
|
|
return ctx.json({ filePath: `Gmail/emails/attachments/${fileName}`, fileName, root: 'user-data' });
|
|
} catch {
|
|
return ctx.text('Failed to extract attachment', 500);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
emailRouter.patch('/messages/:id/read', async (ctx) => {
|
|
const email = ctx.get('user').email;
|
|
const id = ctx.req.param('id');
|
|
|
|
const db = openEmailDb(email);
|
|
try {
|
|
db.run('UPDATE emails SET read = 1 WHERE id = ? AND read = 0', [id]);
|
|
return ctx.json({ ok: true });
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
emailRouter.delete('/messages/:id', async (ctx) => {
|
|
const email = ctx.get('user').email;
|
|
const id = ctx.req.param('id');
|
|
|
|
const db = openEmailDb(email);
|
|
try {
|
|
const result = db.run('UPDATE emails SET deleted = 1 WHERE id = ? AND deleted = 0', [id]);
|
|
if (result.changes === 0) return ctx.text('Not found', 404);
|
|
return ctx.json({ ok: true });
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
emailRouter.get('/sync-status', async (ctx) => {
|
|
const email = ctx.get('user').email;
|
|
|
|
const db = openEmailDb(email);
|
|
try {
|
|
const lastSyncAt = getSyncMeta(db, 'last_sync_at');
|
|
return ctx.json({ lastSyncAt });
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
emailRouter.get('/stats', async (ctx) => {
|
|
const email = ctx.get('user').email;
|
|
const folder = ctx.req.query('folder') ?? 'inbox';
|
|
|
|
const folderWhere = folder === 'all' ? 'deleted = 0' : `deleted = 0 AND labels LIKE '%${folder}%'`;
|
|
|
|
const db = openEmailDb(email);
|
|
try {
|
|
const total = (db.query(`SELECT COUNT(*) as count FROM emails WHERE ${folderWhere}`).get() as { count: number })
|
|
.count;
|
|
const byDomain = db
|
|
.query(
|
|
`SELECT from_domain, COUNT(*) as count FROM emails WHERE ${folderWhere} GROUP BY from_domain ORDER BY count DESC LIMIT 20`,
|
|
)
|
|
.all() as Array<{ from_domain: string; count: number }>;
|
|
const bySender = db
|
|
.query(
|
|
`SELECT from_address, from_name, COUNT(*) as count FROM emails WHERE ${folderWhere} GROUP BY from_address ORDER BY count DESC LIMIT 20`,
|
|
)
|
|
.all() as Array<{ from_address: string; from_name: string; count: number }>;
|
|
|
|
return ctx.json({ total, byDomain, bySender });
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
emailRouter.get('/labels', async (ctx) => {
|
|
const email = ctx.get('user').email;
|
|
|
|
const db = openEmailDb(email);
|
|
try {
|
|
const rows = db.query('SELECT labels FROM emails WHERE deleted = 0 AND labels IS NOT NULL').all() as Array<{
|
|
labels: string;
|
|
}>;
|
|
|
|
const counts = new Map<string, number>();
|
|
for (const row of rows) {
|
|
for (const label of row.labels.split(',')) {
|
|
const trimmed = label.trim().toLowerCase();
|
|
if (trimmed) counts.set(trimmed, (counts.get(trimmed) ?? 0) + 1);
|
|
}
|
|
}
|
|
|
|
const labels = [...counts.entries()].map(([label, count]) => ({ label, count })).sort((a, b) => b.count - a.count);
|
|
|
|
return ctx.json({ labels });
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|