gmail sync: locale-agnostic folder mapping and import all mail

[Google Mail] locale variant was not matched by hardcoded [Gmail] paths,
so Sent/Starred/Important/Drafts were never labeled. All Mail was skipped
entirely, losing ~9k archived emails. Now normalizes the prefix, imports
everything with proper labels, and processes All Mail last so specific
folder labels take priority.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 09:29:22 +00:00
co-authored by Claude Opus 4.6
parent 5e86fcff88
commit 28f5cefb5b
2 changed files with 86 additions and 18 deletions
+33
View File
@@ -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 <email>');
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();
+53 -18
View File
@@ -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<string, string> = {
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<string, string> = {
'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<string, Set<string>>();
const messageFiles = new Map<string, string>(); // 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<string>();
labels.add(label);
if (label !== 'archive' || labels.size === 0) {
labels.add(label);
}
messageIdLabels.set(id, labels);
// Keep first file path for importing