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:
@@ -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();
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user