From 192ae63c0ec14455716476aeded582467ed76830 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 1 Mar 2026 10:37:25 +0000 Subject: [PATCH] email-db tool: extract-attachment action, streaming, filename decoding, copy path fix Co-Authored-By: Claude Opus 4.6 --- seed/tools/email-db/TOOL.md | 19 ++- seed/tools/email-db/index.ts | 108 +++++++++++++++--- src/servers/api/email/email-db.ts | 11 +- .../FileBrowserApp/useFileBrowserApp.ts | 4 +- 4 files changed, 118 insertions(+), 24 deletions(-) diff --git a/seed/tools/email-db/TOOL.md b/seed/tools/email-db/TOOL.md index 28981678..6c87365f 100644 --- a/seed/tools/email-db/TOOL.md +++ b/seed/tools/email-db/TOOL.md @@ -1,5 +1,5 @@ --- -version: 3 +version: 9 name: email_db 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. @@ -7,7 +7,7 @@ language: typescript inputs: action: 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: type: string description: "Raw SQL query for the 'query' action. Only SELECT statements are allowed unless action is 'delete'." @@ -36,13 +36,21 @@ inputs: 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." 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: type: string description: "Gmail folder/label to scope results. Defaults to 'inbox'. Use 'all' for all folders. Common values: inbox, sent, spam, trash." optional: true limit: 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 --- @@ -60,6 +68,7 @@ Query and manage the user's local email database (SQLite). - **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`. - **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). ## Database Schema @@ -90,7 +99,8 @@ attachments ( idx INTEGER, filename TEXT, 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"` - Find image attachments: `action: "attachments", content_type: "image"` - 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"` diff --git a/seed/tools/email-db/index.ts b/seed/tools/email-db/index.ts index b2fcbadc..bcf7bf02 100644 --- a/seed/tools/email-db/index.ts +++ b/seed/tools/email-db/index.ts @@ -1,5 +1,6 @@ -import { execFileSync } from 'node:child_process'; -import { existsSync } from 'node:fs'; +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 }>; @@ -16,11 +17,16 @@ type Params = { after?: string; content_type?: string; folder?: string; + email_id?: string; + attachment_idx?: number; limit?: number; }; -const DEFAULT_LIMIT = 20; -const MAX_LIMIT = 100; +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'; @@ -58,16 +64,16 @@ function err(text: string): ToolResult { return { content: [{ type: 'text', text }], isError: true }; } -function formatRows(rows: Record[], limit: number): string { +function formatRows(rows: Record[]): string { if (rows.length === 0) return 'No results.'; 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(' | '); 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'); } @@ -117,13 +123,13 @@ function runQuery(sql: string, limit: number): string { throw new Error('Only SELECT statements are allowed in query action.'); } const rows = queryJson(sql); - return formatRows(rows, limit); + 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 LIMIT ${limit}`); - return formatRows(rows, 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); } function getStats(params: Params): string { @@ -174,7 +180,7 @@ function countEmails(params: Params): string { 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 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.'; 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 { 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.'; const lines = rows.map((s, i) => { @@ -194,7 +200,7 @@ function listSenders(params: Params, 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.'; 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 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); + 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 { @@ -247,7 +318,7 @@ function deleteEmails(params: Params): string { // ── Main ── export async function execute(_toolCallId: string, params: Params): Promise { - const limit = Math.min(params.limit ?? DEFAULT_LIMIT, MAX_LIMIT); + const limit = params.limit ?? DEFAULT_LIMIT; const dbPath = getDbPath(); if (!existsSync(dbPath)) { @@ -287,11 +358,14 @@ export async function execute(_toolCallId: string, params: Params): Promise String.fromCharCode(parseInt(h, 16))); + rawFilename = new TextDecoder(cs, { fatal: false }).decode(Buffer.from(bytes, 'binary')); + } + const filename = decodeMimeWords(rawFilename); // Extract content-type const ctMatch = headerBlock.match(/^Content-Type:\s*([^\s;]+)/im); diff --git a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts index 35078162..746125ed 100644 --- a/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts +++ b/src/workspaces/officerdev/src/apps/FileBrowser/FileBrowserApp/useFileBrowserApp.ts @@ -384,12 +384,12 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, initi }; const handleCopyPath = (entry: DirEntry) => { - navigator.clipboard.writeText(absPath(entryPath(entry.name))); + navigator.clipboard.writeText(`~${entryPath(entry.name)}`); toast.success('Path copied'); }; const handleCopyCurrentPath = () => { - navigator.clipboard.writeText(absPath(currentPath)); + navigator.clipboard.writeText(`~${currentPath}`); toast.success('Path copied'); };