From 0226608d8fedd9aa92f20fb3d1047caabe1cad66 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Wed, 25 Feb 2026 01:34:09 +0000 Subject: [PATCH] email --- scripts/rebuild-email-index.ts | 15 ++ .../Screens/Dashboard/Email/EmailList.tsx | 52 +++++-- .../Screens/Dashboard/Email/EmailReader.tsx | 45 +++++- src/servers/api/email/email.ts | 82 +++++++---- src/servers/api/pi/pi-bridge.ts | 13 +- src/servers/bootstrap.ts | 2 + src/servers/queue/handlers/gmail-sync.ts | 129 +++++++++++++++++- src/workspaces/types/email.ts | 1 + 8 files changed, 296 insertions(+), 43 deletions(-) create mode 100644 scripts/rebuild-email-index.ts diff --git a/scripts/rebuild-email-index.ts b/scripts/rebuild-email-index.ts new file mode 100644 index 00000000..aaa299c8 --- /dev/null +++ b/scripts/rebuild-email-index.ts @@ -0,0 +1,15 @@ +import { join } from 'node:path'; +import { rebuildIndex } from '../src/servers/queue/handlers/gmail-sync'; + +const email = process.argv[2]; +if (!email) { + console.error('Usage: bun scripts/rebuild-email-index.ts '); + process.exit(1); +} + +const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data'); +const dir = join(DATA_PATH, email, 'Gmail', 'emails'); + +console.log(`Rebuilding index for ${dir}...`); +const entries = rebuildIndex(dir); +console.log(`Done — ${entries.length} entries written to index.json`); diff --git a/src/apps/officer-web/Screens/Dashboard/Email/EmailList.tsx b/src/apps/officer-web/Screens/Dashboard/Email/EmailList.tsx index 8629ee75..8bf7ebdf 100644 --- a/src/apps/officer-web/Screens/Dashboard/Email/EmailList.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Email/EmailList.tsx @@ -1,9 +1,12 @@ +import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Mail } from 'lucide-react'; +import { ChevronLeft, ChevronRight, Mail, Paperclip } from 'lucide-react'; import { useClient } from 'hooks/useClient'; import { useGlobal } from 'hooks/useGlobal'; import type { EmailSummary } from 'types'; +const LIMIT = 50; + const formatDate = (iso: string) => { const date = new Date(iso); const now = new Date(); @@ -17,13 +20,17 @@ const formatDate = (iso: string) => { export const EmailList = () => { const client = useClient(); const [selectedId, setSelectedId] = useGlobal('EMAIL_SELECTED', null); + const [page, setPage] = useState(1); const { data, isLoading } = useQuery({ - queryKey: ['email-messages'], - queryFn: () => client.get<{ messages: EmailSummary[]; total: number }>('/email/messages'), + queryKey: ['email-messages', page], + queryFn: () => + client.get<{ messages: EmailSummary[]; total: number }>(`/email/messages?page=${page}&limit=${LIMIT}`), }); const messages = data?.messages ?? []; + const total = data?.total ?? 0; + const totalPages = Math.max(1, Math.ceil(total / LIMIT)); if (isLoading) { return ( @@ -33,7 +40,7 @@ export const EmailList = () => { ); } - if (messages.length === 0) { + if (messages.length === 0 && page === 1) { return (
@@ -47,14 +54,35 @@ export const EmailList = () => {
Inbox - {data?.total ?? 0} + {total} + {totalPages > 1 && ( +
+ + + {page}/{totalPages} + + +
+ )}
-
+
{messages.map((msg: EmailSummary) => (
{msg.subject} - {msg.snippet} +
+ {!!msg.attachmentCount && ( + + + {msg.attachmentCount} + + )} + {msg.snippet} +
))}
diff --git a/src/apps/officer-web/Screens/Dashboard/Email/EmailReader.tsx b/src/apps/officer-web/Screens/Dashboard/Email/EmailReader.tsx index f4b9585a..2d0b8602 100644 --- a/src/apps/officer-web/Screens/Dashboard/Email/EmailReader.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Email/EmailReader.tsx @@ -1,10 +1,18 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Mail } from 'lucide-react'; +import { FileViewerProvider, FileViewerHeader, FileViewerBody } from 'officerdev'; +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; import { useClient } from 'hooks/useClient'; import { useGlobal } from 'hooks/useGlobal'; import type { EmailMessage } from 'types'; +type OpenAttachment = { + filePath: string; + fileName: string; + root: string; +}; + const HtmlBody = ({ html }: { html: string }) => { const iframeRef = useRef(null); @@ -36,6 +44,7 @@ const HtmlBody = ({ html }: { html: string }) => { export const EmailReader = () => { const client = useClient(); const [selectedId] = useGlobal('EMAIL_SELECTED', null); + const [openAttachment, setOpenAttachment] = useState(null); const { data: message, isLoading } = useQuery({ queryKey: ['email-message', selectedId], @@ -43,6 +52,12 @@ export const EmailReader = () => { enabled: !!selectedId, }); + const extractAttachment = async (index: number) => { + if (!selectedId) return; + const result = await client.post(`/email/messages/${selectedId}/attachments/${index}/extract`); + setOpenAttachment(result); + }; + if (!selectedId) { return (
@@ -83,9 +98,13 @@ export const EmailReader = () => { {message.attachments.length > 0 && (
{message.attachments.map((att: { filename: string; size: number; contentType: string }, i: number) => ( - + ))}
)} @@ -97,6 +116,26 @@ export const EmailReader = () => {
{message.text}
)}
+ + !open && setOpenAttachment(null)}> + + {openAttachment?.fileName} + {openAttachment && ( + +
+ +
+
+ +
+
+ )} +
+
); }; diff --git a/src/servers/api/email/email.ts b/src/servers/api/email/email.ts index e5290d4d..db26d09b 100644 --- a/src/servers/api/email/email.ts +++ b/src/servers/api/email/email.ts @@ -1,17 +1,11 @@ -import { readdir } from 'node:fs/promises'; +import { mkdir, readdir } from 'node:fs/promises'; import { join } from 'node:path'; import { simpleParser } from 'mailparser'; import type { EmailSummary, EmailMessage } from 'types'; +import { rebuildIndex } from '@@/queue/handlers/gmail-sync'; import { createRouter } from '../../create-router'; import { getUserEmailDir } from '@@/data-path'; -type CacheEntry = { - summaries: EmailSummary[]; - fileCount: number; -}; - -const cache = new Map(); - const parseHeadersOnly = async (filePath: string, id: string): Promise => { try { const file = Bun.file(filePath); @@ -20,6 +14,7 @@ const parseHeadersOnly = async (filePath: string, id: string): Promise 0 ? { attachmentCount } : {}), }; } catch { return null; @@ -48,28 +44,25 @@ emailRouter.get('/messages', async (ctx) => { return ctx.json({ messages: [], total: 0 }); } - const cached = cache.get(email); - if (cached && cached.fileCount === filenames.length) { - const page = Number(ctx.req.query('page') ?? '1'); - const limit = Number(ctx.req.query('limit') ?? '50'); - const start = (page - 1) * limit; - return ctx.json({ messages: cached.summaries.slice(start, start + limit), total: cached.summaries.length }); - } - - const summaries: EmailSummary[] = []; - for (const filename of filenames) { - const id = filename.replace(/\.eml$/, ''); - const summary = await parseHeadersOnly(join(dir, filename), id); - if (summary) summaries.push(summary); - } - - summaries.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); - - cache.set(email, { summaries, fileCount: filenames.length }); - const page = Number(ctx.req.query('page') ?? '1'); const limit = Number(ctx.req.query('limit') ?? '50'); const start = (page - 1) * limit; + + const indexFile = Bun.file(join(dir, 'index.json')); + if (await indexFile.exists()) { + try { + const raw = await indexFile.json(); + const index = Array.isArray(raw) ? null : (raw as { v?: number; entries: EmailSummary[] }); + if (index?.v === 3 && index.entries.length === filenames.length) { + return ctx.json({ messages: index.entries.slice(start, start + limit), total: index.entries.length }); + } + } catch { + /* index corrupted, fall through to rebuild */ + } + } + + // Fallback: rebuild index from .eml files + const summaries = rebuildIndex(dir); return ctx.json({ messages: summaries.slice(start, start + limit), total: summaries.length }); }); @@ -111,3 +104,38 @@ emailRouter.get('/messages/:id', async (ctx) => { return ctx.text('Failed to parse email', 500); } }); + +emailRouter.post('/messages/:id/attachments/:index/extract', async (ctx) => { + const email = ctx.get('user').email; + const id = ctx.req.param('id'); + const index = Number(ctx.req.param('index')); + const filePath = join(getUserEmailDir(email), `${id}.eml`); + + const file = Bun.file(filePath); + if (!(await file.exists())) { + return ctx.text('Not found', 404); + } + + try { + const buffer = Buffer.from(await file.arrayBuffer()); + const parsed = await simpleParser(buffer); + const attachment = parsed.attachments[index]; + if (!attachment) { + return ctx.text('Attachment not found', 404); + } + + const fileName = attachment.filename ?? 'unknown'; + const attachDir = join(getUserEmailDir(email), 'attachments'); + const destPath = join(attachDir, fileName); + + const destFile = Bun.file(destPath); + if (!(await destFile.exists())) { + await mkdir(attachDir, { recursive: true }); + await Bun.write(destPath, attachment.content); + } + + return ctx.json({ filePath: `Gmail/emails/attachments/${fileName}`, fileName, root: 'user-data' }); + } catch { + return ctx.text('Failed to extract attachment', 500); + } +}); diff --git a/src/servers/api/pi/pi-bridge.ts b/src/servers/api/pi/pi-bridge.ts index 36a4574e..216d991a 100644 --- a/src/servers/api/pi/pi-bridge.ts +++ b/src/servers/api/pi/pi-bridge.ts @@ -54,7 +54,7 @@ function collectExtensionFlags(email: string, containerPaths?: PathOverrides): s return flags; } -function generateResourceSkill(outputDir: string): string | null { +export function generateResourceSkill(outputDir: string): string | null { const nativeDir = getNativeResourcesDir(); const globalDir = getGlobalResourcesDir(); @@ -114,9 +114,14 @@ function generateResourceSkill(outputDir: string): string | null { ].join('\n'); const skillDir = join(outputDir, '.generated', 'available-resources'); - mkdirSync(skillDir, { recursive: true }); - writeFileSync(join(skillDir, 'SKILL.md'), skillContent, 'utf-8'); - return skillDir; + try { + mkdirSync(skillDir, { recursive: true }); + writeFileSync(join(skillDir, 'SKILL.md'), skillContent, 'utf-8'); + return skillDir; + } catch { + logger.error(`Failed to write resource skill to ${skillDir}`); + return null; + } } function buildResourcesEnv(): string { diff --git a/src/servers/bootstrap.ts b/src/servers/bootstrap.ts index b4f4bcff..b3b2ab1e 100644 --- a/src/servers/bootstrap.ts +++ b/src/servers/bootstrap.ts @@ -10,6 +10,7 @@ import { syncSeedTools } from './sync-tools'; import { syncSeedExtensions } from './sync-extensions'; import { syncSeedResources } from './sync-resources'; import { migrateSettingsToResources } from './migrate-resources'; +import { generateResourceSkill } from './api/pi/pi-bridge'; import { initQueue } from './queue'; mkdirSync(DATA_PATH, { recursive: true }); @@ -80,6 +81,7 @@ function seedPiConfig(): void { syncSeedExtensions(); syncSeedResources(); migrateSettingsToResources(); + generateResourceSkill(DATA_PATH); await syncLocalProvidersToPiConfig().catch(err => { console.error('[bootstrap] Failed to sync local providers to Pi config:', err); diff --git a/src/servers/queue/handlers/gmail-sync.ts b/src/servers/queue/handlers/gmail-sync.ts index de79deba..90a4a767 100644 --- a/src/servers/queue/handlers/gmail-sync.ts +++ b/src/servers/queue/handlers/gmail-sync.ts @@ -1,6 +1,7 @@ -import { mkdirSync, readdirSync, writeFileSync } from 'node:fs'; +import { mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; +import type { EmailSummary } from 'types'; import type { JobHandler } from '../types'; import { registerHandler } from '../handler-registry'; import { DATA_PATH } from '../../data-path'; @@ -117,6 +118,129 @@ function buildEmlFilename(id: string, internalDate: string | undefined, rawEmail return `${dateStr}_${slugify(subject)}_${id}.eml`; } +function decodeMimeWords(text: string): string { + return text.replace(/=\?([^?]+)\?(B|Q)\?([^?]+)\?=/gi, (_, _charset, encoding, encoded) => { + try { + if (encoding.toUpperCase() === 'B') { + return Buffer.from(encoded, 'base64').toString('utf-8'); + } + const bytes: number[] = []; + for (let i = 0; i < encoded.length; i++) { + if (encoded[i] === '_') { + bytes.push(0x20); + } else if (encoded[i] === '=' && i + 2 < encoded.length) { + bytes.push(parseInt(encoded.slice(i + 1, i + 3), 16)); + i += 2; + } else { + bytes.push(encoded.charCodeAt(i)); + } + } + return Buffer.from(bytes).toString('utf-8'); + } catch { + return encoded; + } + }); +} + +function extractHeader(raw: string, name: string): string { + const match = raw.match(new RegExp(`^${name}:\\s*(.+)$`, 'mi')); + return match?.[1]?.trim() ? decodeMimeWords(match[1].trim()) : ''; +} + +function findHeaderEnd(text: string): number { + const crlf = text.indexOf('\r\n\r\n'); + const lf = text.indexOf('\n\n'); + if (crlf !== -1) return crlf + 4; + if (lf !== -1) return lf + 2; + return -1; +} + +function extractSnippet(raw: string): string { + const idx = findHeaderEnd(raw); + if (idx === -1) return ''; + + let body = raw.slice(idx); + + // If multipart, skip boundary line + part headers to reach actual content + if (body.trimStart().startsWith('--')) { + const afterBoundary = body.slice(body.indexOf('\n') + 1); + const partBodyStart = findHeaderEnd(afterBoundary); + if (partBodyStart !== -1) body = afterBoundary.slice(partBodyStart); + } + + // Stop at next MIME boundary + const nextBoundary = body.indexOf('\n--'); + if (nextBoundary !== -1) body = body.slice(0, nextBoundary); + + return body.replace(/\s+/g, ' ').trim().slice(0, 120); +} + +function countAttachments(raw: string): number { + const matches = raw.match(/^Content-Disposition:\s*attachment/gim); + return matches?.length ?? 0; +} + +function parseEmlToSummary(raw: string, id: string): EmailSummary { + const dateStr = extractHeader(raw, 'Date'); + const date = dateStr ? new Date(dateStr).toISOString() : new Date(0).toISOString(); + const attachmentCount = countAttachments(raw); + return { + id, + from: extractHeader(raw, 'From'), + to: extractHeader(raw, 'To'), + subject: extractHeader(raw, 'Subject') || '(no subject)', + date, + snippet: extractSnippet(raw), + ...(attachmentCount > 0 ? { attachmentCount } : {}), + }; +} + +type EmailIndex = { v: number; entries: EmailSummary[] }; +const INDEX_VERSION = 3; + +export function rebuildIndex(emailsDir: string): EmailSummary[] { + let filenames: string[]; + try { + filenames = readdirSync(emailsDir).filter((f) => f.endsWith('.eml')); + } catch { + return []; + } + + const indexPath = join(emailsDir, 'index.json'); + const existing = new Map(); + try { + const raw = JSON.parse(readFileSync(indexPath, 'utf-8')); + const index = (Array.isArray(raw) ? null : raw) as EmailIndex | null; + if (index?.v === INDEX_VERSION) { + for (const entry of index.entries) { + existing.set(entry.id, entry); + } + } + } catch { + /* no existing index or corrupted */ + } + + const entries: EmailSummary[] = []; + for (const filename of filenames) { + const id = filename.replace(/\.eml$/, ''); + const cached = existing.get(id); + if (cached) { + entries.push(cached); + } else { + try { + const raw = readFileSync(join(emailsDir, filename), 'utf-8'); + entries.push(parseEmlToSummary(raw, id)); + } catch { + /* skip unreadable files */ + } + } + } + + entries.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + writeFileSync(indexPath, JSON.stringify({ v: INDEX_VERSION, entries })); + return entries; +} + type SyncProgress = { saved: number; skipped: number; errors: number; page: number }; type OnProgress = (progress: SyncProgress) => void; @@ -263,6 +387,9 @@ const gmailSyncHandler: JobHandler = { } console.log(`[gmail-sync] Saved ${totalSaved}, skipped ${totalSkipped}, errors ${totalErrors}`); + + rebuildIndex(outputDir); + console.log('[gmail-sync] Index rebuilt'); }, }, ], diff --git a/src/workspaces/types/email.ts b/src/workspaces/types/email.ts index f573a6ee..1b9a3480 100644 --- a/src/workspaces/types/email.ts +++ b/src/workspaces/types/email.ts @@ -5,6 +5,7 @@ export type EmailSummary = { subject: string; date: string; snippet: string; + attachmentCount?: number; read?: boolean; };