email syncyng
This commit is contained in:
@@ -1,107 +1,67 @@
|
||||
import { mkdir, readdir } from 'node:fs/promises';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { simpleParser } from 'mailparser';
|
||||
import type { EmailSummary, EmailMessage } from 'types';
|
||||
import { rebuildIndex } from '@@/queue/handlers/gmail-sync';
|
||||
import type { EmailMessage } from 'types';
|
||||
import { createRouter } from '../../create-router';
|
||||
import { getUserEmailDir } from '@@/data-path';
|
||||
|
||||
const parseHeadersOnly = async (filePath: string, id: string): Promise<EmailSummary | null> => {
|
||||
try {
|
||||
const file = Bun.file(filePath);
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const parsed = await simpleParser(buffer, { skipHtmlToText: true, skipTextToHtml: true, skipImageLinks: true });
|
||||
|
||||
const text = parsed.text ?? '';
|
||||
const snippet = text.slice(0, 120).replace(/\s+/g, ' ').trim();
|
||||
const attachmentCount = parsed.attachments?.length ?? 0;
|
||||
|
||||
return {
|
||||
id,
|
||||
from: parsed.from?.text ?? '',
|
||||
to: parsed.to ? (Array.isArray(parsed.to) ? parsed.to.map((a) => a.text).join(', ') : parsed.to.text) : '',
|
||||
subject: parsed.subject ?? '(no subject)',
|
||||
date: (parsed.date ?? new Date()).toISOString(),
|
||||
snippet,
|
||||
...(attachmentCount > 0 ? { attachmentCount } : {}),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
import { DATA_PATH } from '@@/data-path';
|
||||
import { openEmailDb, rowToSummary, getSyncMeta } from './email-db';
|
||||
|
||||
export const emailRouter = createRouter();
|
||||
|
||||
emailRouter.get('/messages', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const dir = getUserEmailDir(email);
|
||||
|
||||
let filenames: string[];
|
||||
try {
|
||||
const entries = await readdir(dir);
|
||||
filenames = entries.filter((f) => f.endsWith('.eml'));
|
||||
} catch {
|
||||
return ctx.json({ messages: [], total: 0 });
|
||||
}
|
||||
|
||||
const page = Number(ctx.req.query('page') ?? '1');
|
||||
const limit = Number(ctx.req.query('limit') ?? '50');
|
||||
const start = (page - 1) * limit;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const indexFile = Bun.file(join(dir, 'index.json'));
|
||||
if (await indexFile.exists()) {
|
||||
try {
|
||||
const raw = await indexFile.json();
|
||||
const index = Array.isArray(raw) ? null : (raw as { v?: number; entries: EmailSummary[] });
|
||||
if (index?.v === 3 && index.entries.length === filenames.length) {
|
||||
return ctx.json({ messages: index.entries.slice(start, start + limit), total: index.entries.length });
|
||||
}
|
||||
} catch {
|
||||
/* index corrupted, fall through to rebuild */
|
||||
}
|
||||
const db = openEmailDb(email);
|
||||
try {
|
||||
const rows = db.query('SELECT * FROM emails WHERE deleted = 0 ORDER BY date DESC LIMIT ? OFFSET ?').all(limit, offset) as Record<string, unknown>[];
|
||||
const countRow = db.query('SELECT COUNT(*) as total FROM emails WHERE deleted = 0').get() as { total: number };
|
||||
const messages = rows.map(rowToSummary);
|
||||
return ctx.json({ messages, total: countRow.total });
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
// Fallback: rebuild index from .eml files
|
||||
const summaries = rebuildIndex(dir);
|
||||
return ctx.json({ messages: summaries.slice(start, start + limit), total: summaries.length });
|
||||
});
|
||||
|
||||
emailRouter.get('/messages/:id', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const id = ctx.req.param('id');
|
||||
const filePath = join(getUserEmailDir(email), `${id}.eml`);
|
||||
|
||||
const file = Bun.file(filePath);
|
||||
if (!(await file.exists())) {
|
||||
return ctx.text('Not found', 404);
|
||||
}
|
||||
|
||||
const db = openEmailDb(email);
|
||||
try {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const parsed = await simpleParser(buffer);
|
||||
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);
|
||||
|
||||
const attachments = (parsed.attachments ?? []).map((a) => ({
|
||||
filename: a.filename ?? 'unknown',
|
||||
size: a.size,
|
||||
contentType: a.contentType,
|
||||
}));
|
||||
const attachmentRows = db.query('SELECT * FROM attachments WHERE email_id = ? ORDER BY idx').all(id) as Array<Record<string, unknown>>;
|
||||
|
||||
const from = row.from_name
|
||||
? `${row.from_name} <${row.from_address}>`
|
||||
: (row.from_address as string);
|
||||
|
||||
const message: EmailMessage = {
|
||||
id,
|
||||
from: parsed.from?.text ?? '',
|
||||
to: parsed.to ? (Array.isArray(parsed.to) ? parsed.to.map((a) => a.text).join(', ') : parsed.to.text) : '',
|
||||
cc: parsed.cc ? (Array.isArray(parsed.cc) ? parsed.cc.map((a) => a.text).join(', ') : parsed.cc.text) : undefined,
|
||||
subject: parsed.subject ?? '(no subject)',
|
||||
date: (parsed.date ?? new Date()).toISOString(),
|
||||
snippet: (parsed.text ?? '').slice(0, 120).replace(/\s+/g, ' ').trim(),
|
||||
html: parsed.html || undefined,
|
||||
text: parsed.text || undefined,
|
||||
attachments,
|
||||
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(',') } : {}),
|
||||
};
|
||||
|
||||
return ctx.json(message);
|
||||
} catch {
|
||||
return ctx.text('Failed to parse email', 500);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -109,33 +69,68 @@ 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 filePath = join(getUserEmailDir(email), `${id}.eml`);
|
||||
|
||||
const file = Bun.file(filePath);
|
||||
if (!(await file.exists())) {
|
||||
return ctx.text('Not found', 404);
|
||||
}
|
||||
|
||||
const db = openEmailDb(email);
|
||||
try {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const parsed = await simpleParser(buffer);
|
||||
const attachment = parsed.attachments[index];
|
||||
if (!attachment) {
|
||||
return ctx.text('Attachment not found', 404);
|
||||
}
|
||||
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 = attachment.filename ?? 'unknown';
|
||||
const attachDir = join(getUserEmailDir(email), 'attachments');
|
||||
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 });
|
||||
await Bun.write(destPath, attachment.content);
|
||||
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.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 db = openEmailDb(email);
|
||||
try {
|
||||
const total = (db.query('SELECT COUNT(*) as count FROM emails WHERE deleted = 0').get() as { count: number }).count;
|
||||
const byDomain = db.query('SELECT from_domain, COUNT(*) as count FROM emails WHERE deleted = 0 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 deleted = 0 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();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user