Files
platform/seed/tools/email-db/index.ts
T

374 lines
15 KiB
TypeScript

import { execFileSync, spawn } from 'node:child_process';
import { existsSync, mkdirSync, createWriteStream } from 'node:fs';
import { join } from 'node:path';
type ToolResult = {
content: Array<{ type: string; text: string }>;
isError?: boolean;
};
type Params = {
action: string;
sql?: string;
search?: string;
domain?: string;
sender?: string;
before?: string;
after?: string;
content_type?: string;
folder?: string;
email_id?: string;
attachment_idx?: number;
limit?: number;
};
const DEFAULT_LIMIT = 0; // 0 = no limit
function limitClause(limit: number): string {
return limit > 0 ? ` LIMIT ${limit}` : '';
}
function getDbPath(): string {
return process.env.OFFICER_EMAIL_DB ?? '/officer/emails.db';
}
function sqlStr(v: string): string {
return `'${v.replace(/'/g, "''")}'`;
}
function queryJson(sql: string): Record<string, unknown>[] {
const output = execFileSync('sqlite3', ['-json', getDbPath()], {
input: sql,
encoding: 'utf-8',
timeout: 10000,
});
const trimmed = output.trim();
if (!trimmed) return [];
return JSON.parse(trimmed);
}
function execAndCount(sql: string): number {
const output = execFileSync('sqlite3', [getDbPath()], {
input: `${sql};\nSELECT changes();`,
encoding: 'utf-8',
timeout: 10000,
});
return parseInt(output.trim(), 10) || 0;
}
function ok(text: string): ToolResult {
return { content: [{ type: 'text', text }] };
}
function err(text: string): ToolResult {
return { content: [{ type: 'text', text }], isError: true };
}
function formatRows(rows: Record<string, unknown>[]): string {
if (rows.length === 0) return 'No results.';
const cols = Object.keys(rows[0]!);
const lines = rows.map((row, i) => {
const fields = cols.map((c) => `${c}: ${row[c] ?? ''}`).join(' | ');
return `${i + 1}. ${fields}`;
});
const header = `${rows.length} result${rows.length !== 1 ? 's' : ''}:`;
return [header, '', ...lines].join('\n');
}
// ── Helpers ──
function buildFolderCondition(folder: string | undefined): string | null {
if (!folder || folder === 'all') return null;
return `labels LIKE ${sqlStr(`%${folder}%`)}`;
}
function buildWhereClause(params: Params, defaultFolder?: string): string {
const conditions: string[] = [];
const folder = params.folder ?? defaultFolder;
const folderCond = buildFolderCondition(folder);
if (folderCond) conditions.push(folderCond);
if (params.domain) {
conditions.push(`from_domain = ${sqlStr(params.domain.toLowerCase())}`);
}
if (params.sender) {
conditions.push(`from_address = ${sqlStr(params.sender.toLowerCase())}`);
}
if (params.before) {
conditions.push(`date < ${sqlStr(params.before)}`);
}
if (params.after) {
conditions.push(`date > ${sqlStr(params.after)}`);
}
if (params.search) {
const escaped = sqlStr(`%${params.search}%`);
conditions.push(`(subject LIKE ${escaped} OR from_address LIKE ${escaped} OR from_name LIKE ${escaped} OR snippet LIKE ${escaped})`);
}
return conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
}
function withActive(where: string): string {
return where ? `${where} AND deleted = 0` : 'WHERE deleted = 0';
}
// ── Actions ──
function runQuery(sql: string, limit: number): string {
const trimmed = sql.trim().toLowerCase();
if (!trimmed.startsWith('select')) {
throw new Error('Only SELECT statements are allowed in query action.');
}
const rows = queryJson(sql);
return formatRows(rows);
}
function searchEmails(params: Params, limit: number): string {
const where = withActive(buildWhereClause(params, 'inbox'));
const rows = queryJson(`SELECT id, from_name, from_address, subject, date, snippet, attachment_count FROM emails ${where} ORDER BY date DESC ${limitClause(limit)}`);
return formatRows(rows);
}
function getStats(params: Params): string {
const folderWhere = withActive(buildWhereClause({ action: 'stats', folder: params.folder }, 'inbox'));
const totalRows = queryJson(`SELECT COUNT(*) as count FROM emails ${folderWhere}`);
const total = (totalRows[0]?.count as number) ?? 0;
if (total === 0) return 'Email database is empty.';
const dateRange = queryJson(`SELECT MIN(date) as oldest, MAX(date) as newest FROM emails ${folderWhere}`);
const topDomains = queryJson(`SELECT from_domain, COUNT(*) as count FROM emails ${folderWhere} GROUP BY from_domain ORDER BY count DESC LIMIT 10`);
const topSenders = queryJson(`SELECT from_address, from_name, COUNT(*) as count FROM emails ${folderWhere} GROUP BY from_address ORDER BY count DESC LIMIT 10`);
const attRows = queryJson(`SELECT COUNT(*) as count FROM emails ${folderWhere} AND attachment_count > 0`);
const withAttachments = (attRows[0]?.count as number) ?? 0;
const dr = dateRange[0] ?? {};
const lines = [
`**Email Database Statistics**`,
``,
`Total emails: ${total}`,
`With attachments: ${withAttachments}`,
`Date range: ${(dr.oldest as string)?.slice(0, 10)} to ${(dr.newest as string)?.slice(0, 10)}`,
``,
`**Top 10 Domains:**`,
...topDomains.map((d, i) => `${i + 1}. ${d.from_domain} (${d.count})`),
``,
`**Top 10 Senders:**`,
...topSenders.map((s, i) => `${i + 1}. ${s.from_name ? `${s.from_name} <${s.from_address}>` : s.from_address} (${s.count})`),
];
return lines.join('\n');
}
function countEmails(params: Params): string {
const where = withActive(buildWhereClause(params, 'inbox'));
const rows = queryJson(`SELECT COUNT(*) as count FROM emails ${where}`);
const count = (rows[0]?.count as number) ?? 0;
const filters: string[] = [];
if (params.domain) filters.push(`domain=${params.domain}`);
if (params.sender) filters.push(`sender=${params.sender}`);
if (params.before) filters.push(`before=${params.before}`);
if (params.after) filters.push(`after=${params.after}`);
if (params.search) filters.push(`search="${params.search}"`);
const desc = filters.length > 0 ? ` matching ${filters.join(', ')}` : '';
return `${count} email${count !== 1 ? 's' : ''}${desc}.`;
}
function listDomains(params: Params, limit: number): string {
const where = withActive(buildWhereClause({ action: 'domains', folder: params.folder }, 'inbox'));
const rows = queryJson(`SELECT from_domain, COUNT(*) as count FROM emails ${where} GROUP BY from_domain ORDER BY count DESC ${limitClause(limit)}`);
if (rows.length === 0) return 'No emails in database.';
const lines = rows.map((d, i) => `${i + 1}. ${d.from_domain}${d.count} email${(d.count as number) !== 1 ? 's' : ''}`);
return [`**Sender Domains** (${rows.length}):`, '', ...lines].join('\n');
}
function listSenders(params: Params, limit: number): string {
const where = withActive(buildWhereClause({ action: 'senders', folder: params.folder }, 'inbox'));
const rows = queryJson(`SELECT from_address, from_name, COUNT(*) as count FROM emails ${where} GROUP BY from_address ORDER BY count DESC ${limitClause(limit)}`);
if (rows.length === 0) return 'No emails in database.';
const lines = rows.map((s, i) => {
const display = s.from_name ? `${s.from_name} <${s.from_address}>` : s.from_address;
return `${i + 1}. ${display}${s.count} email${(s.count as number) !== 1 ? 's' : ''}`;
});
return [`**Senders** (${rows.length}):`, '', ...lines].join('\n');
}
function listAttachmentTypes(limit: number): string {
const rows = queryJson(`SELECT content_type, COUNT(*) as count FROM attachments a JOIN emails e ON a.email_id = e.id WHERE e.deleted = 0 GROUP BY content_type ORDER BY count DESC ${limitClause(limit)}`);
if (rows.length === 0) return 'No attachments in database.';
const totalRows = queryJson('SELECT COUNT(*) as count FROM attachments a JOIN emails e ON a.email_id = e.id WHERE e.deleted = 0');
const total = (totalRows[0]?.count as number) ?? 0;
const lines = rows.map((r, i) => `${i + 1}. ${r.content_type}${r.count}`);
return [`**Attachment Types** (${total} total):`, '', ...lines].join('\n');
}
function searchAttachments(params: Params, limit: number): string {
const conditions: string[] = ['e.deleted = 0'];
if (params.content_type) {
const ct = params.content_type as string;
if (ct.includes('/')) {
conditions.push(`a.content_type = ${sqlStr(ct)}`);
} else {
conditions.push(`a.content_type LIKE ${sqlStr(ct + '/%')}`);
}
}
if (params.domain) conditions.push(`e.from_domain = ${sqlStr(params.domain.toLowerCase())}`);
if (params.sender) conditions.push(`e.from_address = ${sqlStr(params.sender.toLowerCase())}`);
if (params.before) conditions.push(`e.date < ${sqlStr(params.before)}`);
if (params.after) conditions.push(`e.date > ${sqlStr(params.after)}`);
if (params.search) {
const escaped = sqlStr(`%${params.search}%`);
conditions.push(`(a.filename LIKE ${escaped} OR e.subject LIKE ${escaped})`);
}
const where = `WHERE ${conditions.join(' AND ')}`;
const rows = queryJson(`SELECT a.filename, a.size, a.content_type, e.id as email_id, e.from_address, e.subject, e.date FROM attachments a JOIN emails e ON a.email_id = e.id ${where} ORDER BY e.date DESC ${limitClause(limit)}`);
return formatRows(rows);
}
async function extractAttachment(params: Params): Promise<string> {
if (!params.email_id) throw new Error('email_id is required for extract-attachment action.');
if (params.attachment_idx === undefined) throw new Error('attachment_idx is required for extract-attachment action.');
// Metadata query (small)
const rows = queryJson(
`SELECT a.filename, a.content_type, e.date, e.from_address FROM attachments a JOIN emails e ON a.email_id = e.id WHERE a.email_id = ${sqlStr(params.email_id)} AND a.idx = ${params.attachment_idx}`,
);
if (rows.length === 0) throw new Error(`No attachment found for email_id=${params.email_id} idx=${params.attachment_idx}.`);
const row = rows[0]!;
const rawName = (row.filename as string) || `attachment_${params.attachment_idx}`;
// Decode MIME encoded-words (e.g. =?iso-8859-1?Q?PRE=C7OS?=)
const originalName = rawName.replace(/=\?([^?]+)\?(B|Q)\?([^?]+)\?=/gi, (_, _charset: string, encoding: string, encoded: string) => {
if (encoding.toUpperCase() === 'B') return Buffer.from(encoded, 'base64').toString('utf-8');
return encoded.replace(/=([0-9A-Fa-f]{2})/g, (__, hex: string) => String.fromCharCode(parseInt(hex, 16))).replace(/_/g, ' ');
});
const ext = originalName.includes('.') ? originalName.slice(originalName.lastIndexOf('.')) : '';
const baseName = originalName.includes('.') ? originalName.slice(0, originalName.lastIndexOf('.')) : originalName;
const timestamp = ((row.date as string) ?? '').slice(0, 10).replace(/-/g, '');
const sender = (row.from_address as string) ?? 'unknown';
const filename = `${timestamp}_${sender}_${params.email_id}_${params.attachment_idx}_${baseName}${ext}`;
const outDir = join(process.env.OFFICER_USER_HOME ?? process.env.HOME ?? '/tmp', 'Downloads');
mkdirSync(outDir, { recursive: true });
const outPath = join(outDir, filename);
// Stream base64 content from sqlite3 → decode → write to file (constant memory)
const totalBytes = await new Promise<number>((resolve, reject) => {
const proc = spawn('sqlite3', [getDbPath()]);
const out = createWriteStream(outPath);
let remainder = '';
let bytes = 0;
proc.stdout.on('data', (chunk: Buffer) => {
const str = remainder + chunk.toString().replace(/[\s\r\n]/g, '');
const validLen = str.length - (str.length % 4);
if (validLen > 0) {
const decoded = Buffer.from(str.slice(0, validLen), 'base64');
out.write(decoded);
bytes += decoded.length;
}
remainder = str.slice(validLen);
});
proc.stdout.on('end', () => {
if (remainder.length > 0) {
const decoded = Buffer.from(remainder, 'base64');
out.write(decoded);
bytes += decoded.length;
}
out.end(() => resolve(bytes));
});
proc.stderr.on('data', (chunk: Buffer) => reject(new Error(chunk.toString())));
proc.on('error', reject);
proc.stdin.write(`SELECT content FROM attachments WHERE email_id = ${sqlStr(params.email_id)} AND idx = ${params.attachment_idx};\n`);
proc.stdin.end();
});
if (totalBytes === 0) throw new Error('Attachment content is empty in the database.');
return `Extracted "${filename}" (${row.content_type}, ${totalBytes} bytes) to:\n${outPath}`;
}
function deleteEmails(params: Params): string {
const where = buildWhereClause(params, 'inbox');
if (!where) {
throw new Error('Delete requires at least one filter (domain, sender, before, after, or search).');
}
const activeWhere = withActive(where);
const countRows = queryJson(`SELECT COUNT(*) as count FROM emails ${activeWhere}`);
const count = (countRows[0]?.count as number) ?? 0;
if (count === 0) return 'No emails match the given filters.';
const changes = execAndCount(`UPDATE emails SET deleted = 1 ${activeWhere}`);
return `Deleted ${changes} email${changes !== 1 ? 's' : ''}.`;
}
// ── Main ──
export async function execute(_toolCallId: string, params: Params): Promise<ToolResult> {
const limit = params.limit ?? DEFAULT_LIMIT;
const dbPath = getDbPath();
if (!existsSync(dbPath)) {
return err(`Email database not found at ${dbPath}. Has Gmail been synced?`);
}
try {
switch (params.action) {
case 'query':
if (!params.sql) return err('sql parameter is required for query action.');
return ok(runQuery(params.sql, limit));
case 'search':
if (!params.search && !params.domain && !params.sender && !params.before && !params.after) {
return err('At least one filter is required: search, domain, sender, before, or after.');
}
return ok(searchEmails(params, limit));
case 'stats':
return ok(getStats(params));
case 'count':
return ok(countEmails(params));
case 'domains':
return ok(listDomains(params, limit));
case 'senders':
return ok(listSenders(params, limit));
case 'attachment-types':
return ok(listAttachmentTypes(limit));
case 'attachments':
if (!params.content_type && !params.search && !params.domain && !params.sender && !params.before && !params.after) {
return err('At least one filter is required: content_type, search, domain, sender, before, or after.');
}
return ok(searchAttachments(params, limit));
case 'extract-attachment':
return ok(await extractAttachment(params));
case 'delete':
return ok(deleteEmails(params));
default:
return err(`Unknown action: "${params.action}". Available: query, search, stats, count, domains, senders, attachments, attachment-types, extract-attachment, delete.`);
}
} catch (e) {
return err(`Email DB error: ${e instanceof Error ? e.message : String(e)}`);
}
}