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; 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 | 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-` matches the `` 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>>(); // 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; let ping: ReturnType; const stream = new ReadableStream({ 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[]; 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, row: Record): EmailMessage { const attachmentRows = db.query('SELECT * FROM attachments WHERE email_id = ? ORDER BY idx').all(row.id as string) as Array< Record >; 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 | 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[]; 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(); 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(); } });