email-db tool: extract-attachment action, streaming, filename decoding, copy path fix
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
version: 3
|
version: 9
|
||||||
name: email_db
|
name: email_db
|
||||||
label: Email Database
|
label: Email Database
|
||||||
description: Query, search, aggregate, and manage the user's email database. Use this tool to answer questions about emails, find messages by sender/domain/date/content, get statistics, and delete emails. The database is a local SQLite copy of the user's synced Gmail. All actions default to inbox scope — use folder parameter to query other folders like sent, spam, trash, or 'all' for everything.
|
description: Query, search, aggregate, and manage the user's email database. Use this tool to answer questions about emails, find messages by sender/domain/date/content, get statistics, and delete emails. The database is a local SQLite copy of the user's synced Gmail. All actions default to inbox scope — use folder parameter to query other folders like sent, spam, trash, or 'all' for everything.
|
||||||
@@ -7,7 +7,7 @@ language: typescript
|
|||||||
inputs:
|
inputs:
|
||||||
action:
|
action:
|
||||||
type: string
|
type: string
|
||||||
description: "Action to perform: query, search, stats, count, domains, senders, attachments, attachment-types, delete"
|
description: "Action to perform: query, search, stats, count, domains, senders, attachments, attachment-types, extract-attachment, delete"
|
||||||
sql:
|
sql:
|
||||||
type: string
|
type: string
|
||||||
description: "Raw SQL query for the 'query' action. Only SELECT statements are allowed unless action is 'delete'."
|
description: "Raw SQL query for the 'query' action. Only SELECT statements are allowed unless action is 'delete'."
|
||||||
@@ -36,13 +36,21 @@ inputs:
|
|||||||
type: string
|
type: string
|
||||||
description: "Attachment content type filter. Full MIME type (e.g. 'image/jpeg') or just the type prefix (e.g. 'image' matches all image types). Used with 'attachments' action."
|
description: "Attachment content type filter. Full MIME type (e.g. 'image/jpeg') or just the type prefix (e.g. 'image' matches all image types). Used with 'attachments' action."
|
||||||
optional: true
|
optional: true
|
||||||
|
email_id:
|
||||||
|
type: string
|
||||||
|
description: "Email ID for extract-attachment action."
|
||||||
|
optional: true
|
||||||
|
attachment_idx:
|
||||||
|
type: number
|
||||||
|
description: "Attachment index (0-based) for extract-attachment action."
|
||||||
|
optional: true
|
||||||
folder:
|
folder:
|
||||||
type: string
|
type: string
|
||||||
description: "Gmail folder/label to scope results. Defaults to 'inbox'. Use 'all' for all folders. Common values: inbox, sent, spam, trash."
|
description: "Gmail folder/label to scope results. Defaults to 'inbox'. Use 'all' for all folders. Common values: inbox, sent, spam, trash."
|
||||||
optional: true
|
optional: true
|
||||||
limit:
|
limit:
|
||||||
type: number
|
type: number
|
||||||
description: "Maximum number of results to return (default 20, max 100)."
|
description: "Maximum number of results to return. No limit by default — all results are returned. If a query could return a very large number of results, ask the user if they'd like to set a limit before running it."
|
||||||
optional: true
|
optional: true
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -60,6 +68,7 @@ Query and manage the user's local email database (SQLite).
|
|||||||
- **senders**: List all senders with email counts, sorted by frequency.
|
- **senders**: List all senders with email counts, sorted by frequency.
|
||||||
- **attachments**: Search attachments by type, filename, sender, etc. Use `content_type` for type filtering (e.g. `image` for all images, `image/jpeg` for specific type). Combine with `domain`, `sender`, `before`, `after`, `search`.
|
- **attachments**: Search attachments by type, filename, sender, etc. Use `content_type` for type filtering (e.g. `image` for all images, `image/jpeg` for specific type). Combine with `domain`, `sender`, `before`, `after`, `search`.
|
||||||
- **attachment-types**: List all attachment content types with counts.
|
- **attachment-types**: List all attachment content types with counts.
|
||||||
|
- **extract-attachment**: Extract an attachment file from the database and save it to ~/Downloads/. Requires `email_id` and `attachment_idx`. Use the `attachments` action first to find the email_id and idx. Output filename: `{YYYYMMDD}_{sender}_{email_id}_{idx}_{name}.{ext}` (e.g. `20250115_boss@company.com_abc123_0_invoice.pdf`).
|
||||||
- **delete**: Delete emails matching filters. Requires at least one of: `domain`, `sender`, `before`, `after`, or `sql` (with DELETE statement).
|
- **delete**: Delete emails matching filters. Requires at least one of: `domain`, `sender`, `before`, `after`, or `sql` (with DELETE statement).
|
||||||
|
|
||||||
## Database Schema
|
## Database Schema
|
||||||
@@ -90,7 +99,8 @@ attachments (
|
|||||||
idx INTEGER,
|
idx INTEGER,
|
||||||
filename TEXT,
|
filename TEXT,
|
||||||
size INTEGER,
|
size INTEGER,
|
||||||
content_type TEXT
|
content_type TEXT,
|
||||||
|
content TEXT -- base64-encoded binary content (use extract-attachment action to export)
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -106,4 +116,5 @@ attachments (
|
|||||||
- List attachment types: `action: "attachment-types"`
|
- List attachment types: `action: "attachment-types"`
|
||||||
- Find image attachments: `action: "attachments", content_type: "image"`
|
- Find image attachments: `action: "attachments", content_type: "image"`
|
||||||
- Find PDFs from a sender: `action: "attachments", content_type: "application/pdf", sender: "boss@company.com"`
|
- Find PDFs from a sender: `action: "attachments", content_type: "application/pdf", sender: "boss@company.com"`
|
||||||
|
- Extract an attachment: `action: "extract-attachment", email_id: "abc123", attachment_idx: 0`
|
||||||
- Custom query: `action: "query", sql: "SELECT from_domain, COUNT(*) as n FROM emails GROUP BY from_domain HAVING n > 50 ORDER BY n DESC"`
|
- Custom query: `action: "query", sql: "SELECT from_domain, COUNT(*) as n FROM emails GROUP BY from_domain HAVING n > 50 ORDER BY n DESC"`
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync, spawn } from 'node:child_process';
|
||||||
import { existsSync } from 'node:fs';
|
import { existsSync, mkdirSync, createWriteStream } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
type ToolResult = {
|
type ToolResult = {
|
||||||
content: Array<{ type: string; text: string }>;
|
content: Array<{ type: string; text: string }>;
|
||||||
@@ -16,11 +17,16 @@ type Params = {
|
|||||||
after?: string;
|
after?: string;
|
||||||
content_type?: string;
|
content_type?: string;
|
||||||
folder?: string;
|
folder?: string;
|
||||||
|
email_id?: string;
|
||||||
|
attachment_idx?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_LIMIT = 20;
|
const DEFAULT_LIMIT = 0; // 0 = no limit
|
||||||
const MAX_LIMIT = 100;
|
|
||||||
|
function limitClause(limit: number): string {
|
||||||
|
return limit > 0 ? ` LIMIT ${limit}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
function getDbPath(): string {
|
function getDbPath(): string {
|
||||||
return process.env.OFFICER_EMAIL_DB ?? '/officer/emails.db';
|
return process.env.OFFICER_EMAIL_DB ?? '/officer/emails.db';
|
||||||
@@ -58,16 +64,16 @@ function err(text: string): ToolResult {
|
|||||||
return { content: [{ type: 'text', text }], isError: true };
|
return { content: [{ type: 'text', text }], isError: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRows(rows: Record<string, unknown>[], limit: number): string {
|
function formatRows(rows: Record<string, unknown>[]): string {
|
||||||
if (rows.length === 0) return 'No results.';
|
if (rows.length === 0) return 'No results.';
|
||||||
|
|
||||||
const cols = Object.keys(rows[0]!);
|
const cols = Object.keys(rows[0]!);
|
||||||
const lines = rows.slice(0, limit).map((row, i) => {
|
const lines = rows.map((row, i) => {
|
||||||
const fields = cols.map((c) => `${c}: ${row[c] ?? ''}`).join(' | ');
|
const fields = cols.map((c) => `${c}: ${row[c] ?? ''}`).join(' | ');
|
||||||
return `${i + 1}. ${fields}`;
|
return `${i + 1}. ${fields}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
const header = `${rows.length} result${rows.length !== 1 ? 's' : ''}${rows.length > limit ? ` (showing first ${limit})` : ''}:`;
|
const header = `${rows.length} result${rows.length !== 1 ? 's' : ''}:`;
|
||||||
return [header, '', ...lines].join('\n');
|
return [header, '', ...lines].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,13 +123,13 @@ function runQuery(sql: string, limit: number): string {
|
|||||||
throw new Error('Only SELECT statements are allowed in query action.');
|
throw new Error('Only SELECT statements are allowed in query action.');
|
||||||
}
|
}
|
||||||
const rows = queryJson(sql);
|
const rows = queryJson(sql);
|
||||||
return formatRows(rows, limit);
|
return formatRows(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
function searchEmails(params: Params, limit: number): string {
|
function searchEmails(params: Params, limit: number): string {
|
||||||
const where = withActive(buildWhereClause(params, 'inbox'));
|
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 LIMIT ${limit}`);
|
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, limit);
|
return formatRows(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStats(params: Params): string {
|
function getStats(params: Params): string {
|
||||||
@@ -174,7 +180,7 @@ function countEmails(params: Params): string {
|
|||||||
|
|
||||||
function listDomains(params: Params, limit: number): string {
|
function listDomains(params: Params, limit: number): string {
|
||||||
const where = withActive(buildWhereClause({ action: 'domains', folder: params.folder }, 'inbox'));
|
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 LIMIT ${limit}`);
|
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.';
|
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' : ''}`);
|
const lines = rows.map((d, i) => `${i + 1}. ${d.from_domain} — ${d.count} email${(d.count as number) !== 1 ? 's' : ''}`);
|
||||||
@@ -183,7 +189,7 @@ function listDomains(params: Params, limit: number): string {
|
|||||||
|
|
||||||
function listSenders(params: Params, limit: number): string {
|
function listSenders(params: Params, limit: number): string {
|
||||||
const where = withActive(buildWhereClause({ action: 'senders', folder: params.folder }, 'inbox'));
|
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 LIMIT ${limit}`);
|
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.';
|
if (rows.length === 0) return 'No emails in database.';
|
||||||
|
|
||||||
const lines = rows.map((s, i) => {
|
const lines = rows.map((s, i) => {
|
||||||
@@ -194,7 +200,7 @@ function listSenders(params: Params, limit: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function listAttachmentTypes(limit: number): string {
|
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}`);
|
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.';
|
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 totalRows = queryJson('SELECT COUNT(*) as count FROM attachments a JOIN emails e ON a.email_id = e.id WHERE e.deleted = 0');
|
||||||
@@ -225,8 +231,73 @@ function searchAttachments(params: Params, limit: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const where = `WHERE ${conditions.join(' AND ')}`;
|
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}`);
|
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, 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.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 {
|
function deleteEmails(params: Params): string {
|
||||||
@@ -247,7 +318,7 @@ function deleteEmails(params: Params): string {
|
|||||||
// ── Main ──
|
// ── Main ──
|
||||||
|
|
||||||
export async function execute(_toolCallId: string, params: Params): Promise<ToolResult> {
|
export async function execute(_toolCallId: string, params: Params): Promise<ToolResult> {
|
||||||
const limit = Math.min(params.limit ?? DEFAULT_LIMIT, MAX_LIMIT);
|
const limit = params.limit ?? DEFAULT_LIMIT;
|
||||||
|
|
||||||
const dbPath = getDbPath();
|
const dbPath = getDbPath();
|
||||||
if (!existsSync(dbPath)) {
|
if (!existsSync(dbPath)) {
|
||||||
@@ -287,11 +358,14 @@ export async function execute(_toolCallId: string, params: Params): Promise<Tool
|
|||||||
}
|
}
|
||||||
return ok(searchAttachments(params, limit));
|
return ok(searchAttachments(params, limit));
|
||||||
|
|
||||||
|
case 'extract-attachment':
|
||||||
|
return ok(await extractAttachment(params));
|
||||||
|
|
||||||
case 'delete':
|
case 'delete':
|
||||||
return ok(deleteEmails(params));
|
return ok(deleteEmails(params));
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return err(`Unknown action: "${params.action}". Available: query, search, stats, count, domains, senders, attachments, attachment-types, delete.`);
|
return err(`Unknown action: "${params.action}". Available: query, search, stats, count, domains, senders, attachments, attachment-types, extract-attachment, delete.`);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return err(`Email DB error: ${e instanceof Error ? e.message : String(e)}`);
|
return err(`Email DB error: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
|||||||
@@ -456,7 +456,16 @@ function parseAttachments(raw: string): AttachmentMeta[] {
|
|||||||
|
|
||||||
// Extract filename from Content-Disposition or Content-Type
|
// Extract filename from Content-Disposition or Content-Type
|
||||||
const fnMatch = headerBlock.match(/filename\*?=(?:"([^"]+)"|([^\s;]+))/i);
|
const fnMatch = headerBlock.match(/filename\*?=(?:"([^"]+)"|([^\s;]+))/i);
|
||||||
const filename = fnMatch ? (fnMatch[1] ?? fnMatch[2] ?? 'unknown') : 'unknown';
|
let rawFilename = fnMatch ? (fnMatch[1] ?? fnMatch[2] ?? 'unknown') : 'unknown';
|
||||||
|
// RFC 5987: filename*=charset''percent-encoded
|
||||||
|
const rfc5987Match = rawFilename.match(/^([^']*)'[^']*'(.+)/);
|
||||||
|
if (rfc5987Match) {
|
||||||
|
const cs = normalizeCharset(rfc5987Match[1]!.toLowerCase() || 'utf-8');
|
||||||
|
const encoded = rfc5987Match[2]!;
|
||||||
|
const bytes = encoded.replace(/%([0-9A-Fa-f]{2})/g, (_, h: string) => String.fromCharCode(parseInt(h, 16)));
|
||||||
|
rawFilename = new TextDecoder(cs, { fatal: false }).decode(Buffer.from(bytes, 'binary'));
|
||||||
|
}
|
||||||
|
const filename = decodeMimeWords(rawFilename);
|
||||||
|
|
||||||
// Extract content-type
|
// Extract content-type
|
||||||
const ctMatch = headerBlock.match(/^Content-Type:\s*([^\s;]+)/im);
|
const ctMatch = headerBlock.match(/^Content-Type:\s*([^\s;]+)/im);
|
||||||
|
|||||||
@@ -384,12 +384,12 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, initi
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCopyPath = (entry: DirEntry) => {
|
const handleCopyPath = (entry: DirEntry) => {
|
||||||
navigator.clipboard.writeText(absPath(entryPath(entry.name)));
|
navigator.clipboard.writeText(`~${entryPath(entry.name)}`);
|
||||||
toast.success('Path copied');
|
toast.success('Path copied');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopyCurrentPath = () => {
|
const handleCopyCurrentPath = () => {
|
||||||
navigator.clipboard.writeText(absPath(currentPath));
|
navigator.clipboard.writeText(`~${currentPath}`);
|
||||||
toast.success('Path copied');
|
toast.success('Path copied');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user