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
|
Channel gmail
|
||||||
Far :gmail-remote:
|
Far :gmail-remote:
|
||||||
Near :gmail-local:
|
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
|
Create Near
|
||||||
Expunge None
|
Expunge None
|
||||||
SyncState *
|
SyncState *
|
||||||
@@ -57,21 +57,34 @@ SyncState *
|
|||||||
|
|
||||||
// ── Folder → label mapping ──
|
// ── Folder → label mapping ──
|
||||||
|
|
||||||
const FOLDER_LABEL_MAP: Record<string, string> = {
|
// Strips [Gmail]/ or [Google Mail]/ prefix, returns the suffix (e.g. "Sent Mail")
|
||||||
INBOX: 'inbox',
|
const GMAIL_PREFIX_RE = /^\[(?:Gmail|Google Mail)\]\//;
|
||||||
'[Gmail]/Sent Mail': 'sent',
|
|
||||||
'[Gmail]/Drafts': 'draft',
|
function normalizeGmailFolder(folder: string): string {
|
||||||
'[Gmail]/Starred': 'starred',
|
return folder.replace(GMAIL_PREFIX_RE, '');
|
||||||
'[Gmail]/Important': 'important',
|
}
|
||||||
|
|
||||||
|
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 {
|
function folderToLabel(folder: string): string | null {
|
||||||
if (SKIP_FOLDERS.has(folder)) return null;
|
const suffix = normalizeGmailFolder(folder);
|
||||||
if (FOLDER_LABEL_MAP[folder]) return FOLDER_LABEL_MAP[folder]!;
|
// If it had a Gmail prefix, check against known suffixes
|
||||||
// Custom labels / other folders: lowercase the folder name
|
if (suffix !== folder) {
|
||||||
return folder.replace(/^\[Gmail\]\//, '').toLowerCase();
|
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 ──
|
// ── Stable ID from Message-Id header ──
|
||||||
@@ -86,7 +99,7 @@ function messageIdToStableId(raw: string): string | null {
|
|||||||
|
|
||||||
type ImportResult = { saved: number; skipped: number; errors: number };
|
type ImportResult = { saved: number; skipped: number; errors: number };
|
||||||
|
|
||||||
async function importMaildir(
|
export async function importMaildir(
|
||||||
maildirPath: string,
|
maildirPath: string,
|
||||||
emailAccount: string,
|
emailAccount: string,
|
||||||
db: Database,
|
db: Database,
|
||||||
@@ -105,14 +118,34 @@ async function importMaildir(
|
|||||||
const messageIdLabels = new Map<string, Set<string>>();
|
const messageIdLabels = new Map<string, Set<string>>();
|
||||||
const messageFiles = new Map<string, string>(); // id → first file path
|
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 {
|
try {
|
||||||
folders = await readdir(maildirPath);
|
topEntries = await readdir(maildirPath);
|
||||||
} catch {
|
} catch {
|
||||||
console.log('[gmail-sync] No Maildir folders found');
|
console.log('[gmail-sync] No Maildir folders found');
|
||||||
return { saved, skipped, errors };
|
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) {
|
for (const folder of folders) {
|
||||||
const label = folderToLabel(folder);
|
const label = folderToLabel(folder);
|
||||||
if (label === null) continue;
|
if (label === null) continue;
|
||||||
@@ -136,9 +169,11 @@ async function importMaildir(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track labels
|
// Track labels — only add "archive" for messages not seen in any specific folder
|
||||||
const labels = messageIdLabels.get(id) ?? new Set<string>();
|
const labels = messageIdLabels.get(id) ?? new Set<string>();
|
||||||
|
if (label !== 'archive' || labels.size === 0) {
|
||||||
labels.add(label);
|
labels.add(label);
|
||||||
|
}
|
||||||
messageIdLabels.set(id, labels);
|
messageIdLabels.set(id, labels);
|
||||||
|
|
||||||
// Keep first file path for importing
|
// Keep first file path for importing
|
||||||
|
|||||||
Reference in New Issue
Block a user