diff --git a/scripts/reimport-gmail.ts b/scripts/reimport-gmail.ts new file mode 100644 index 00000000..b10a3539 --- /dev/null +++ b/scripts/reimport-gmail.ts @@ -0,0 +1,33 @@ +import { join } from 'node:path'; +import { unlinkSync } from 'node:fs'; +import { openEmailDb } from '../src/servers/api/email/email-db'; +import { importMaildir } from '../src/servers/queue/handlers/gmail-sync'; + +const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data'); +const email = process.argv[2]; + +if (!email) { + console.error('Usage: bun scripts/reimport-gmail.ts '); + process.exit(1); +} + +const maildirPath = join(DATA_PATH, email, 'Gmail', 'Maildir'); +const dbPath = join(DATA_PATH, email, 'emails.db'); + +// Delete existing DB for a fresh import +try { + unlinkSync(dbPath); + console.log(`Deleted ${dbPath}`); +} catch { + console.log('No existing DB to delete'); +} + +const db = openEmailDb(email); + +console.log(`Importing from ${maildirPath}...`); +const result = await importMaildir(maildirPath, email, db, (saved, skipped) => { + process.stdout.write(`\r saved ${saved}, skipped ${skipped}`); +}); + +console.log(`\nDone: saved ${result.saved}, skipped ${result.skipped}, errors ${result.errors}`); +db.close(); diff --git a/src/servers/queue/handlers/gmail-sync.ts b/src/servers/queue/handlers/gmail-sync.ts index e6e18a7f..316eb2db 100644 --- a/src/servers/queue/handlers/gmail-sync.ts +++ b/src/servers/queue/handlers/gmail-sync.ts @@ -48,7 +48,7 @@ SubFolders Verbatim Channel gmail Far :gmail-remote: Near :gmail-local: -Patterns * ![Gmail]/Trash ![Gmail]/Spam +Patterns * ![Gmail]/Trash ![Gmail]/Spam ![Gmail]/Bin ![Google Mail]/Trash ![Google Mail]/Spam ![Google Mail]/Bin Create Near Expunge None SyncState * @@ -57,21 +57,34 @@ SyncState * // ── Folder → label mapping ── -const FOLDER_LABEL_MAP: Record = { - INBOX: 'inbox', - '[Gmail]/Sent Mail': 'sent', - '[Gmail]/Drafts': 'draft', - '[Gmail]/Starred': 'starred', - '[Gmail]/Important': 'important', +// Strips [Gmail]/ or [Google Mail]/ prefix, returns the suffix (e.g. "Sent Mail") +const GMAIL_PREFIX_RE = /^\[(?:Gmail|Google Mail)\]\//; + +function normalizeGmailFolder(folder: string): string { + return folder.replace(GMAIL_PREFIX_RE, ''); +} + +const SUFFIX_LABEL_MAP: Record = { + 'Sent Mail': 'sent', + Drafts: 'draft', + Starred: 'starred', + Important: 'important', + 'All Mail': 'archive', + Trash: 'trash', + Bin: 'trash', + Spam: 'spam', }; -const SKIP_FOLDERS = new Set(['[Gmail]/All Mail', '[Gmail]/Trash', '[Gmail]/Spam']); - function folderToLabel(folder: string): string | null { - if (SKIP_FOLDERS.has(folder)) return null; - if (FOLDER_LABEL_MAP[folder]) return FOLDER_LABEL_MAP[folder]!; - // Custom labels / other folders: lowercase the folder name - return folder.replace(/^\[Gmail\]\//, '').toLowerCase(); + const suffix = normalizeGmailFolder(folder); + // If it had a Gmail prefix, check against known suffixes + if (suffix !== folder) { + if (SUFFIX_LABEL_MAP[suffix]) return SUFFIX_LABEL_MAP[suffix]!; + return suffix.toLowerCase(); + } + // Non-Gmail folders (INBOX, custom labels) + if (folder === 'INBOX') return 'inbox'; + return folder.toLowerCase(); } // ── Stable ID from Message-Id header ── @@ -86,7 +99,7 @@ function messageIdToStableId(raw: string): string | null { type ImportResult = { saved: number; skipped: number; errors: number }; -async function importMaildir( +export async function importMaildir( maildirPath: string, emailAccount: string, db: Database, @@ -105,14 +118,34 @@ async function importMaildir( const messageIdLabels = new Map>(); const messageFiles = new Map(); // id → first file path - let folders: string[]; + // Discover Maildir folders — handles nested [Gmail]/ and [Google Mail]/ structures + let topEntries: string[]; try { - folders = await readdir(maildirPath); + topEntries = await readdir(maildirPath); } catch { console.log('[gmail-sync] No Maildir folders found'); return { saved, skipped, errors }; } + const folders: string[] = []; + for (const entry of topEntries) { + if (entry === '[Gmail]' || entry === '[Google Mail]') { + // This is [Gmail] or [Google Mail] — subfolders are one level deeper + try { + const subs = await readdir(join(maildirPath, entry)); + for (const sub of subs) folders.push(`${entry}/${sub}`); + } catch { + // empty + } + } else { + folders.push(entry); + } + } + + // Process All Mail last so specific-folder labels take priority + const isAllMail = (f: string) => normalizeGmailFolder(f) === 'All Mail' && GMAIL_PREFIX_RE.test(f); + folders.sort((a, b) => (isAllMail(a) ? 1 : 0) - (isAllMail(b) ? 1 : 0)); + for (const folder of folders) { const label = folderToLabel(folder); if (label === null) continue; @@ -136,9 +169,11 @@ async function importMaildir( continue; } - // Track labels + // Track labels — only add "archive" for messages not seen in any specific folder const labels = messageIdLabels.get(id) ?? new Set(); - labels.add(label); + if (label !== 'archive' || labels.size === 0) { + labels.add(label); + } messageIdLabels.set(id, labels); // Keep first file path for importing