This commit is contained in:
@@ -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');
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user