This commit is contained in:
@@ -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 <email>');
|
||||
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`);
|
||||
@@ -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<string | null>('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 (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-sm opacity-50">
|
||||
<Mail className="h-8 w-8" />
|
||||
@@ -47,14 +54,35 @@ export const EmailList = () => {
|
||||
<div className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<Mail className="h-4 w-4 opacity-60" />
|
||||
<span className="text-sm font-medium">Inbox</span>
|
||||
<span className="text-xs opacity-50">{data?.total ?? 0}</span>
|
||||
<span className="text-xs opacity-50">{total}</span>
|
||||
{totalPages > 1 && (
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
className="flex items-center text-xs disabled:opacity-30 cursor-pointer disabled:cursor-default"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<span className="text-xs opacity-60">
|
||||
{page}/{totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
className="flex items-center text-xs disabled:opacity-30 cursor-pointer disabled:cursor-default"
|
||||
>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col divide-y divide-white/10">
|
||||
{messages.map((msg: EmailSummary) => (
|
||||
<button
|
||||
key={msg.id}
|
||||
onClick={() => setSelectedId(msg.id)}
|
||||
className={`flex flex-col gap-0.5 border-b px-3 py-2.5 text-left transition-colors cursor-pointer ${
|
||||
className={`flex flex-col gap-0.5 px-3 py-2.5 text-left transition-colors cursor-pointer ${
|
||||
selectedId === msg.id ? 'bg-accent' : 'hover:bg-accent/50'
|
||||
}`}
|
||||
>
|
||||
@@ -63,7 +91,15 @@ export const EmailList = () => {
|
||||
<span className="shrink-0 text-xs opacity-50">{formatDate(msg.date)}</span>
|
||||
</div>
|
||||
<span className="truncate text-sm">{msg.subject}</span>
|
||||
<span className="truncate text-xs opacity-50">{msg.snippet}</span>
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
{!!msg.attachmentCount && (
|
||||
<span className="flex shrink-0 items-center gap-0.5 text-muted-foreground">
|
||||
<Paperclip className="h-3 w-3" />
|
||||
{msg.attachmentCount}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate opacity-50">{msg.snippet}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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<HTMLIFrameElement>(null);
|
||||
|
||||
@@ -36,6 +44,7 @@ const HtmlBody = ({ html }: { html: string }) => {
|
||||
export const EmailReader = () => {
|
||||
const client = useClient();
|
||||
const [selectedId] = useGlobal<string | null>('EMAIL_SELECTED', null);
|
||||
const [openAttachment, setOpenAttachment] = useState<OpenAttachment | null>(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<OpenAttachment>(`/email/messages/${selectedId}/attachments/${index}/extract`);
|
||||
setOpenAttachment(result);
|
||||
};
|
||||
|
||||
if (!selectedId) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-sm opacity-50">
|
||||
@@ -83,9 +98,13 @@ export const EmailReader = () => {
|
||||
{message.attachments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{message.attachments.map((att: { filename: string; size: number; contentType: string }, i: number) => (
|
||||
<span key={i} className="rounded bg-accent px-2 py-0.5 text-xs">
|
||||
<button
|
||||
key={i}
|
||||
className="cursor-pointer rounded bg-accent px-2 py-0.5 text-xs hover:bg-accent/80"
|
||||
onClick={() => extractAttachment(i)}
|
||||
>
|
||||
{att.filename} ({Math.round(att.size / 1024)}KB)
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -97,6 +116,26 @@ export const EmailReader = () => {
|
||||
<pre className="whitespace-pre-wrap p-4 text-sm">{message.text}</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={!!openAttachment} onOpenChange={(open) => !open && setOpenAttachment(null)}>
|
||||
<DialogContent className="flex h-[80vh] max-w-4xl flex-col gap-0 p-0">
|
||||
<DialogTitle className="sr-only">{openAttachment?.fileName}</DialogTitle>
|
||||
{openAttachment && (
|
||||
<FileViewerProvider
|
||||
filePath={openAttachment.filePath}
|
||||
fileName={openAttachment.fileName}
|
||||
root={openAttachment.root}
|
||||
>
|
||||
<div className="flex shrink-0 items-center gap-2 border-b px-4 py-1.5">
|
||||
<FileViewerHeader />
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<FileViewerBody />
|
||||
</div>
|
||||
</FileViewerProvider>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<string, CacheEntry>();
|
||||
|
||||
const parseHeadersOnly = async (filePath: string, id: string): Promise<EmailSummary | null> => {
|
||||
try {
|
||||
const file = Bun.file(filePath);
|
||||
@@ -20,6 +14,7 @@ const parseHeadersOnly = async (filePath: string, id: string): Promise<EmailSumm
|
||||
|
||||
const text = parsed.text ?? '';
|
||||
const snippet = text.slice(0, 120).replace(/\s+/g, ' ').trim();
|
||||
const attachmentCount = parsed.attachments?.length ?? 0;
|
||||
|
||||
return {
|
||||
id,
|
||||
@@ -28,6 +23,7 @@ const parseHeadersOnly = async (filePath: string, id: string): Promise<EmailSumm
|
||||
subject: parsed.subject ?? '(no subject)',
|
||||
date: (parsed.date ?? new Date()).toISOString(),
|
||||
snippet,
|
||||
...(attachmentCount > 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 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 */
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, EmailSummary>();
|
||||
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');
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -5,6 +5,7 @@ export type EmailSummary = {
|
||||
subject: string;
|
||||
date: string;
|
||||
snippet: string;
|
||||
attachmentCount?: number;
|
||||
read?: boolean;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user