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[] { 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 { 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 { 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.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((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 { 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)}`); } }