|
|
|
@@ -4,9 +4,9 @@ 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 { getEmailAttachmentCacheDir } from '@@/data-path';
|
|
|
|
|
import { getEmailAccounts } from 'officerdb';
|
|
|
|
|
import { openEmailDb, rowToSummary, getSyncMeta, searchEmails } from './email-db';
|
|
|
|
|
import { openEmailDb, openUserEmailDb, rowToSummary, getSyncMeta, searchEmails } from './email-db';
|
|
|
|
|
import { accountsRouter } from './accounts';
|
|
|
|
|
|
|
|
|
|
export const emailRouter = createRouter();
|
|
|
|
@@ -69,10 +69,11 @@ emailRouter.post('/send', async (ctx) => {
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
emailRouter.get('/contacts', async (ctx) => {
|
|
|
|
|
const user = ctx.get('user');
|
|
|
|
|
const like = `%${(ctx.req.query('q') ?? '').trim().toLowerCase()}%`;
|
|
|
|
|
const db = openEmailDb(email);
|
|
|
|
|
const db = await openUserEmailDb(user.email, user.id);
|
|
|
|
|
if (!db) return ctx.json([]);
|
|
|
|
|
try {
|
|
|
|
|
const rows = db
|
|
|
|
|
.query(
|
|
|
|
@@ -146,14 +147,15 @@ emailRouter.get('/events', (ctx) => {
|
|
|
|
|
|
|
|
|
|
// 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 user = ctx.get('user');
|
|
|
|
|
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);
|
|
|
|
|
const db = await openUserEmailDb(user.email, user.id);
|
|
|
|
|
if (!db) return ctx.json({ messages: [], total: 0 });
|
|
|
|
|
try {
|
|
|
|
|
const { rows, total } = searchEmails(db, q, limit, offset);
|
|
|
|
|
return ctx.json({ messages: rows.map(rowToSummary), total });
|
|
|
|
@@ -163,7 +165,7 @@ emailRouter.get('/search', async (ctx) => {
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
emailRouter.get('/messages', async (ctx) => {
|
|
|
|
|
const email = ctx.get('user').email;
|
|
|
|
|
const user = ctx.get('user');
|
|
|
|
|
const page = Number(ctx.req.query('page') ?? '1');
|
|
|
|
|
const limit = Number(ctx.req.query('limit') ?? '50');
|
|
|
|
|
const folder = ctx.req.query('folder') ?? 'inbox';
|
|
|
|
@@ -171,7 +173,8 @@ emailRouter.get('/messages', async (ctx) => {
|
|
|
|
|
|
|
|
|
|
const folderWhere = folder === 'all' ? 'deleted = 0' : `deleted = 0 AND labels LIKE '%${folder}%'`;
|
|
|
|
|
|
|
|
|
|
const db = openEmailDb(email);
|
|
|
|
|
const db = await openUserEmailDb(user.email, user.id);
|
|
|
|
|
if (!db) return ctx.json({ messages: [], total: 0 });
|
|
|
|
|
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.
|
|
|
|
@@ -225,10 +228,11 @@ function buildMessage(db: ReturnType<typeof openEmailDb>, row: Record<string, un
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
emailRouter.get('/messages/:id', async (ctx) => {
|
|
|
|
|
const email = ctx.get('user').email;
|
|
|
|
|
const user = ctx.get('user');
|
|
|
|
|
const id = ctx.req.param('id');
|
|
|
|
|
|
|
|
|
|
const db = openEmailDb(email);
|
|
|
|
|
const db = await openUserEmailDb(user.email, user.id);
|
|
|
|
|
if (!db) return ctx.text('Not found', 404);
|
|
|
|
|
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);
|
|
|
|
@@ -239,11 +243,12 @@ emailRouter.get('/messages/:id', async (ctx) => {
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// GET /thread/:id — the full conversation containing message :id, oldest message first.
|
|
|
|
|
emailRouter.get('/thread/:id', (ctx) => {
|
|
|
|
|
const email = ctx.get('user').email;
|
|
|
|
|
emailRouter.get('/thread/:id', async (ctx) => {
|
|
|
|
|
const user = ctx.get('user');
|
|
|
|
|
const id = ctx.req.param('id');
|
|
|
|
|
|
|
|
|
|
const db = openEmailDb(email);
|
|
|
|
|
const db = await openUserEmailDb(user.email, user.id);
|
|
|
|
|
if (!db) return ctx.text('Not found', 404);
|
|
|
|
|
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 }
|
|
|
|
@@ -262,11 +267,12 @@ emailRouter.get('/thread/:id', (ctx) => {
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// PATCH /thread/:id/read — mark every message in the conversation as read.
|
|
|
|
|
emailRouter.patch('/thread/:id/read', (ctx) => {
|
|
|
|
|
const email = ctx.get('user').email;
|
|
|
|
|
emailRouter.patch('/thread/:id/read', async (ctx) => {
|
|
|
|
|
const user = ctx.get('user');
|
|
|
|
|
const id = ctx.req.param('id');
|
|
|
|
|
|
|
|
|
|
const db = openEmailDb(email);
|
|
|
|
|
const db = await openUserEmailDb(user.email, user.id);
|
|
|
|
|
if (!db) return ctx.text('Not found', 404);
|
|
|
|
|
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);
|
|
|
|
@@ -279,11 +285,12 @@ emailRouter.patch('/thread/:id/read', (ctx) => {
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
emailRouter.post('/messages/:id/attachments/:index/extract', async (ctx) => {
|
|
|
|
|
const email = ctx.get('user').email;
|
|
|
|
|
const user = ctx.get('user');
|
|
|
|
|
const id = ctx.req.param('id');
|
|
|
|
|
const index = Number(ctx.req.param('index'));
|
|
|
|
|
|
|
|
|
|
const db = openEmailDb(email);
|
|
|
|
|
const db = await openUserEmailDb(user.email, user.id);
|
|
|
|
|
if (!db) return ctx.text('Attachment not found', 404);
|
|
|
|
|
try {
|
|
|
|
|
const row = db.query('SELECT filename, content FROM attachments WHERE email_id = ? AND idx = ?').get(id, index) as {
|
|
|
|
|
filename: string;
|
|
|
|
@@ -292,7 +299,7 @@ emailRouter.post('/messages/:id/attachments/:index/extract', async (ctx) => {
|
|
|
|
|
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 attachDir = getEmailAttachmentCacheDir(user.email);
|
|
|
|
|
const destPath = join(attachDir, fileName);
|
|
|
|
|
|
|
|
|
|
const destFile = Bun.file(destPath);
|
|
|
|
@@ -302,7 +309,7 @@ emailRouter.post('/messages/:id/attachments/:index/extract', async (ctx) => {
|
|
|
|
|
await Bun.write(destPath, binary);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ctx.json({ filePath: `Gmail/emails/attachments/${fileName}`, fileName, root: 'user-data' });
|
|
|
|
|
return ctx.json({ filePath: `email_accounts/attachment_cache/${fileName}`, fileName, root: 'user-data' });
|
|
|
|
|
} catch {
|
|
|
|
|
return ctx.text('Failed to extract attachment', 500);
|
|
|
|
|
} finally {
|
|
|
|
@@ -311,10 +318,11 @@ emailRouter.post('/messages/:id/attachments/:index/extract', async (ctx) => {
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
emailRouter.patch('/messages/:id/read', async (ctx) => {
|
|
|
|
|
const email = ctx.get('user').email;
|
|
|
|
|
const user = ctx.get('user');
|
|
|
|
|
const id = ctx.req.param('id');
|
|
|
|
|
|
|
|
|
|
const db = openEmailDb(email);
|
|
|
|
|
const db = await openUserEmailDb(user.email, user.id);
|
|
|
|
|
if (!db) return ctx.json({ ok: true });
|
|
|
|
|
try {
|
|
|
|
|
db.run('UPDATE emails SET read = 1 WHERE id = ? AND read = 0', [id]);
|
|
|
|
|
return ctx.json({ ok: true });
|
|
|
|
@@ -324,10 +332,11 @@ emailRouter.patch('/messages/:id/read', async (ctx) => {
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
emailRouter.delete('/messages/:id', async (ctx) => {
|
|
|
|
|
const email = ctx.get('user').email;
|
|
|
|
|
const user = ctx.get('user');
|
|
|
|
|
const id = ctx.req.param('id');
|
|
|
|
|
|
|
|
|
|
const db = openEmailDb(email);
|
|
|
|
|
const db = await openUserEmailDb(user.email, user.id);
|
|
|
|
|
if (!db) return ctx.text('Not found', 404);
|
|
|
|
|
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);
|
|
|
|
@@ -338,9 +347,10 @@ emailRouter.delete('/messages/:id', async (ctx) => {
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
emailRouter.get('/sync-status', async (ctx) => {
|
|
|
|
|
const email = ctx.get('user').email;
|
|
|
|
|
const user = ctx.get('user');
|
|
|
|
|
|
|
|
|
|
const db = openEmailDb(email);
|
|
|
|
|
const db = await openUserEmailDb(user.email, user.id);
|
|
|
|
|
if (!db) return ctx.json({ lastSyncAt: null });
|
|
|
|
|
try {
|
|
|
|
|
const lastSyncAt = getSyncMeta(db, 'last_sync_at');
|
|
|
|
|
return ctx.json({ lastSyncAt });
|
|
|
|
@@ -350,12 +360,13 @@ emailRouter.get('/sync-status', async (ctx) => {
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
emailRouter.get('/stats', async (ctx) => {
|
|
|
|
|
const email = ctx.get('user').email;
|
|
|
|
|
const user = ctx.get('user');
|
|
|
|
|
const folder = ctx.req.query('folder') ?? 'inbox';
|
|
|
|
|
|
|
|
|
|
const folderWhere = folder === 'all' ? 'deleted = 0' : `deleted = 0 AND labels LIKE '%${folder}%'`;
|
|
|
|
|
|
|
|
|
|
const db = openEmailDb(email);
|
|
|
|
|
const db = await openUserEmailDb(user.email, user.id);
|
|
|
|
|
if (!db) return ctx.json({ total: 0, byDomain: [], bySender: [] });
|
|
|
|
|
try {
|
|
|
|
|
const total = (db.query(`SELECT COUNT(*) as count FROM emails WHERE ${folderWhere}`).get() as { count: number })
|
|
|
|
|
.count;
|
|
|
|
@@ -377,9 +388,10 @@ emailRouter.get('/stats', async (ctx) => {
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
emailRouter.get('/labels', async (ctx) => {
|
|
|
|
|
const email = ctx.get('user').email;
|
|
|
|
|
const user = ctx.get('user');
|
|
|
|
|
|
|
|
|
|
const db = openEmailDb(email);
|
|
|
|
|
const db = await openUserEmailDb(user.email, user.id);
|
|
|
|
|
if (!db) return ctx.json({ labels: [] });
|
|
|
|
|
try {
|
|
|
|
|
const rows = db.query('SELECT labels FROM emails WHERE deleted = 0 AND labels IS NOT NULL').all() as Array<{
|
|
|
|
|
labels: string;
|
|
|
|
|