287 lines
10 KiB
TypeScript
287 lines
10 KiB
TypeScript
import { execFileSync } from 'node:child_process';
|
|
import { existsSync } from 'node:fs';
|
|
|
|
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;
|
|
limit?: number;
|
|
};
|
|
|
|
const DEFAULT_LIMIT = 20;
|
|
const MAX_LIMIT = 100;
|
|
|
|
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>[], limit: number): string {
|
|
if (rows.length === 0) return 'No results.';
|
|
|
|
const cols = Object.keys(rows[0]!);
|
|
const lines = rows.slice(0, limit).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' : ''}${rows.length > limit ? ` (showing first ${limit})` : ''}:`;
|
|
return [header, '', ...lines].join('\n');
|
|
}
|
|
|
|
// ── Helpers ──
|
|
|
|
function buildWhereClause(params: Params): string {
|
|
const conditions: string[] = [];
|
|
|
|
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, limit);
|
|
}
|
|
|
|
function searchEmails(params: Params, limit: number): string {
|
|
const where = withActive(buildWhereClause(params));
|
|
const rows = queryJson(`SELECT id, from_name, from_address, subject, date, snippet, attachment_count FROM emails ${where} ORDER BY date DESC LIMIT ${limit}`);
|
|
return formatRows(rows, limit);
|
|
}
|
|
|
|
function getStats(): string {
|
|
const totalRows = queryJson('SELECT COUNT(*) as count FROM emails WHERE deleted = 0');
|
|
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 WHERE deleted = 0');
|
|
const topDomains = queryJson('SELECT from_domain, COUNT(*) as count FROM emails WHERE deleted = 0 GROUP BY from_domain ORDER BY count DESC LIMIT 10');
|
|
const topSenders = queryJson('SELECT from_address, from_name, COUNT(*) as count FROM emails WHERE deleted = 0 GROUP BY from_address ORDER BY count DESC LIMIT 10');
|
|
const attRows = queryJson('SELECT COUNT(*) as count FROM emails WHERE deleted = 0 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));
|
|
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(limit: number): string {
|
|
const rows = queryJson(`SELECT from_domain, COUNT(*) as count FROM emails WHERE deleted = 0 GROUP BY from_domain ORDER BY count DESC LIMIT ${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(limit: number): string {
|
|
const rows = queryJson(`SELECT from_address, from_name, COUNT(*) as count FROM emails WHERE deleted = 0 GROUP BY from_address ORDER BY count DESC LIMIT ${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 LIMIT ${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 LIMIT ${limit}`);
|
|
return formatRows(rows, limit);
|
|
}
|
|
|
|
function deleteEmails(params: Params): string {
|
|
const where = buildWhereClause(params);
|
|
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 = Math.min(params.limit ?? DEFAULT_LIMIT, MAX_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());
|
|
|
|
case 'count':
|
|
return ok(countEmails(params));
|
|
|
|
case 'domains':
|
|
return ok(listDomains(limit));
|
|
|
|
case 'senders':
|
|
return ok(listSenders(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 'delete':
|
|
return ok(deleteEmails(params));
|
|
|
|
default:
|
|
return err(`Unknown action: "${params.action}". Available: query, search, stats, count, domains, senders, attachments, attachment-types, delete.`);
|
|
}
|
|
} catch (e) {
|
|
return err(`Email DB error: ${e instanceof Error ? e.message : String(e)}`);
|
|
}
|
|
}
|