This commit is contained in:
2026-02-25 01:34:09 +00:00
parent 6e8ee69311
commit 0226608d8f
8 changed files with 296 additions and 43 deletions
+55 -27
View File
@@ -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 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);
}
});
+9 -4
View File
@@ -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 {
+2
View File
@@ -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);
+128 -1
View File
@@ -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');
},
},
],