replace imap gmail sync with mbsync + maildir import, app password UI

- rewrite gmail-sync handler: mbsync downloads to local Maildir, then import to sqlite
- add app password field to google integration config and API
- gmail sync section independent from oauth in settings UI
- live mbsync progress streaming to job status
- recoverable failure email with instructions for overquota/auth errors
- sync meta persisted to job on failure for richer notifications

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 20:00:45 +00:00
co-authored by Claude Opus 4.6
parent 927267e041
commit 583580eb10
6 changed files with 472 additions and 256 deletions
@@ -10,6 +10,7 @@ type GoogleStatus = {
email: string | null;
picture: string | null;
configured: boolean;
hasAppPassword: boolean;
};
const formatTime = (ts: number | string) => {
@@ -20,7 +21,10 @@ const formatTime = (ts: number | string) => {
export const GoogleAccount = () => {
const client = useClient();
const [isLoading, setIsLoading] = useState(true);
const [status, setStatus] = useState<GoogleStatus>({ connected: false, email: null, picture: null, configured: false });
const [status, setStatus] = useState<GoogleStatus>({ connected: false, email: null, picture: null, configured: false, hasAppPassword: false });
const [appPassword, setAppPassword] = useState('');
const [showPasswordInput, setShowPasswordInput] = useState(false);
const [savingPassword, setSavingPassword] = useState(false);
const [lastSyncAt, setLastSyncAt] = useState<string | null>(null);
const [dismissedError, setDismissedError] = useState(false);
const { jobs, createJob, refetch } = useJobs({ type: 'gmail-sync' });
@@ -88,38 +92,93 @@ export const GoogleAccount = () => {
}
};
const handleSaveAppPassword = async () => {
if (!appPassword.trim()) return;
setSavingPassword(true);
try {
await client.put('/integrations/google/app-password', { appPassword: appPassword.trim() });
setStatus({ ...status, hasAppPassword: true });
setAppPassword('');
setShowPasswordInput(false);
toast.success('App password saved');
} catch {
toast.error('Failed to save app password');
} finally {
setSavingPassword(false);
}
};
if (isLoading) return null;
if (!status.configured) {
return (
<div className="grid gap-4">
<p className="text-sm text-duck-dark/60 dark:text-foreground/60">
Google integration has not been configured yet. Ask your administrator to set up Google OAuth credentials in
the Enterprise settings.
</p>
</div>
);
}
const currentStep = activeJob?.steps[activeJob.currentStep];
const progress = currentStep?.progress;
if (status.connected) {
const currentStep = activeJob?.steps[activeJob.currentStep];
const progress = currentStep?.progress;
return (
return (
<div className="grid gap-6">
{/* Gmail Sync — independent of OAuth */}
<div className="grid gap-4">
<div className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
<div className="h-2.5 w-2.5 rounded-full bg-green-500 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Connected</p>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 truncate">{status.email}</p>
</div>
{status.picture && (
<img src={status.picture} alt="" className="h-9 w-9 rounded-full shrink-0" referrerPolicy="no-referrer" />
<div>
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Gmail Sync</p>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 mt-1">
Import and sync your Gmail emails with your Officer inbox. This uses a Google App Password for a direct IMAP
connection the only thing it can do is download your emails. It cannot send, delete, or modify anything in
your account.
</p>
</div>
<div className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4 grid gap-2">
<p className="text-sm font-medium text-duck-dark dark:text-foreground">App Password</p>
{status.hasAppPassword && !showPasswordInput ? (
<div className="flex items-center gap-2">
<span className="text-xs text-green-600 dark:text-green-400">Configured</span>
<button
onClick={() => setShowPasswordInput(true)}
className="text-xs text-duck-dark/50 dark:text-foreground/50 underline hover:opacity-70 cursor-pointer"
>
Replace
</button>
</div>
) : (
<>
<div className="text-xs text-duck-dark/50 dark:text-foreground/50 grid gap-1.5">
<p>To create an App Password:</p>
<ol className="list-decimal ml-4 grid gap-0.5">
<li>
Go to{' '}
<a
href="https://myaccount.google.com/apppasswords"
target="_blank"
rel="noopener noreferrer"
className="underline hover:opacity-70"
>
Google App Passwords
</a>
</li>
<li>You may need to enable 2-Step Verification first</li>
<li>Enter a name (e.g. "Officer") and click Create</li>
<li>Copy the 16-character password and paste it below</li>
</ol>
</div>
<div className="flex gap-2">
<input
type="password"
value={appPassword}
onChange={(ev) => setAppPassword(ev.target.value)}
placeholder="xxxx xxxx xxxx xxxx"
className="flex-1 h-9 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-transparent px-3 text-sm"
/>
<Button
type="button"
variant="outline"
disabled={!appPassword.trim() || savingPassword}
onClick={handleSaveAppPassword}
className="h-9 cursor-pointer"
>
{savingPassword ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Save'}
</Button>
</div>
</>
)}
</div>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
Officer has access to your Google Calendar, Gmail, and other enabled services.
</p>
{activeJob && (
<div className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
<Loader2 className="h-4 w-4 animate-spin text-duck-dark/60 dark:text-foreground/60 shrink-0" />
@@ -163,37 +222,52 @@ export const GoogleAccount = () => {
<Button
type="button"
variant="outline"
disabled={!!activeJob}
disabled={!!activeJob || !status.hasAppPassword}
onClick={() => handleSync()}
className="w-full h-11 cursor-pointer gap-2"
>
<RefreshCw className="h-4 w-4" />
Sync Gmail Inbox
</Button>
<Button
type="button"
variant="outline"
onClick={handleDisconnect}
className="w-full h-11 cursor-pointer"
>
Disconnect
</Button>
</div>
);
}
return (
<div className="grid gap-4">
<p className="text-sm text-duck-dark/60 dark:text-foreground/60">
Connect your Google account to give Officer access to your Calendar, Gmail, and other Google services.
</p>
<Button
type="button"
onClick={handleConnect}
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer"
>
Connect Google Account
</Button>
{/* OAuth — for Calendar and other Google services */}
{status.configured && (
<div className="grid gap-4 border-t border-duck-dark/10 dark:border-foreground/10 pt-6">
<div>
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Google Account</p>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 mt-1">
Connect your Google account for Calendar, Contacts, and other Google services. This is separate from Gmail
sync above.
</p>
</div>
{status.connected ? (
<>
<div className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
<div className="h-2.5 w-2.5 rounded-full bg-green-500 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Connected</p>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 truncate">{status.email}</p>
</div>
{status.picture && (
<img src={status.picture} alt="" className="h-9 w-9 rounded-full shrink-0" referrerPolicy="no-referrer" />
)}
</div>
<Button type="button" variant="outline" onClick={handleDisconnect} className="w-full h-11 cursor-pointer">
Disconnect
</Button>
</>
) : (
<Button
type="button"
onClick={handleConnect}
className="w-full h-11 bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold transition-all duration-200 hover:scale-105 cursor-pointer"
>
Connect Google Account
</Button>
)}
</div>
)}
</div>
);
};
@@ -126,9 +126,28 @@ integrationsRouter.get('/google/status', async (ctx) => {
connected: !!connConfig?.accessToken,
email: connConfig?.email ?? null,
picture: connConfig?.picture ?? null,
hasAppPassword: !!connConfig?.imapAppPassword,
});
});
integrationsRouter.put('/google/app-password', async (ctx) => {
const user = ctx.get('user');
const body = ctx.get('body') as { appPassword?: string };
if (!body.appPassword) throw BAD_REQUEST('Missing appPassword');
const connection = await getUserIntegration(user.id, 'google');
const connConfig = (connection?.config as Record<string, unknown>) ?? {};
await upsertUserIntegration({
userId: user.id,
provider: 'google',
serverIntegrationId: connection?.serverIntegrationId ?? undefined,
config: { ...connConfig, imapAppPassword: body.appPassword },
});
return ctx.json({ ok: true });
});
integrationsRouter.delete('/google/connection', async (ctx) => {
const user = ctx.get('user');
const connection = await getUserIntegration(user.id, 'google');
+2
View File
@@ -93,6 +93,8 @@ export const getAttachmentsDir = (email: string, provider: 'claude' | 'opencode'
export const getUserEmailDir = (email: string) => join(DATA_PATH, email, 'Gmail', 'emails');
export const getMaildirPath = (email: string) => join(DATA_PATH, email, 'Gmail', 'Maildir');
/** Derive a valid Linux username from a display username or email. */
export const toShellUsername = (username: string, email: string): string => {
const raw = username || email.split('@')[0]!;
+36 -3
View File
@@ -81,11 +81,16 @@ function kickLane(lane: string) {
processNextInLane(lane);
}
function scheduleRetry(lane: string, delayMs: number) {
setTimeout(() => kickLane(lane), delayMs);
}
async function processNextInLane(lane: string) {
try {
const jobs = await listAllJobs();
const now = Date.now();
const next = jobs
.filter((j) => j.lane === lane && j.status === 'queued')
.filter((j) => j.lane === lane && j.status === 'queued' && (!j.retryAt || j.retryAt <= now))
.sort((a, b) => a.createdAt - b.createdAt)[0];
if (!next) {
@@ -98,7 +103,7 @@ async function processNextInLane(lane: string) {
console.error(`[queue] Lane ${lane} processing error:`, err);
} finally {
const jobs = await listAllJobs();
const hasMore = jobs.some((j) => j.lane === lane && j.status === 'queued');
const hasMore = jobs.some((j) => j.lane === lane && j.status === 'queued' && (!j.retryAt || j.retryAt <= Date.now()));
if (hasMore) {
processNextInLane(lane);
} else {
@@ -119,8 +124,10 @@ async function runJob(job: Job) {
job.status = 'running';
job.startedAt = Date.now();
job.retryAt = undefined;
await writeJob(job);
console.log(`[queue] Running job ${job.id} (${job.type})`);
const isRetry = (job.retries ?? 0) > 0;
console.log(`[queue] ${isRetry ? 'Resuming' : 'Running'} job ${job.id} (${job.type})${isRetry ? ` retry ${job.retries}` : ''}`);
const sharedMeta: Record<string, unknown> = { ...(job.meta ?? {}) };
@@ -134,6 +141,9 @@ async function runJob(job: Job) {
const handlerStep = handler.steps[i]!;
const step = fresh.steps[i]!;
// Skip already-completed steps on retry
if (step.status === 'completed') continue;
fresh.currentStep = i;
step.status = 'running';
step.startedAt = Date.now();
@@ -171,9 +181,32 @@ async function runJob(job: Job) {
step.status = 'failed';
step.error = errorMessage;
step.completedAt = Date.now();
// Check if handler supports retry
const retries = (fresh.retries ?? 0) + 1;
if (handler.retry && retries <= handler.retry.maxRetries) {
// Schedule retry: reset failed step to pending, re-queue
step.status = 'pending';
step.error = undefined;
step.startedAt = undefined;
step.completedAt = undefined;
step.progress = undefined;
fresh.status = 'queued';
fresh.error = undefined;
fresh.completedAt = undefined;
fresh.startedAt = undefined;
fresh.retries = retries;
fresh.retryAt = Date.now() + handler.retry.delayMs;
await writeJob(fresh);
console.log(`[queue] Job ${fresh.id} will retry (${retries}/${handler.retry.maxRetries}) in ${handler.retry.delayMs / 1000}s — failed at step "${step.name}": ${errorMessage}`);
scheduleRetry(fresh.lane, handler.retry.delayMs);
return;
}
fresh.status = 'failed';
fresh.error = `Step "${step.name}" failed: ${errorMessage}`;
fresh.completedAt = Date.now();
fresh.meta = { ...fresh.meta, ...sharedMeta };
await writeJob(fresh);
console.error(`[queue] Job ${fresh.id} failed at step "${step.name}":`, errorMessage);
if (fresh.notify !== false) await notifyFailure(fresh);
+239 -198
View File
@@ -1,270 +1,311 @@
import type { Database } from 'bun:sqlite';
import { ImapFlow } from 'imapflow';
import { createHash } from 'node:crypto';
import { join } from 'node:path';
import { readdir, readFile, writeFile, unlink, mkdir, stat } from 'node:fs/promises';
import type { JobHandler } from '../types';
import { registerHandler } from '../handler-registry';
import { openEmailDb, upsertFromRawEml, getSyncMeta, setSyncMeta } from '../../api/email/email-db';
import { getServerIntegration, getUserByEmail, getUserIntegration } from 'officerdb';
import { openEmailDb, upsertFromRawEml, setSyncMeta } from '../../api/email/email-db';
import { getUserByEmail, getUserIntegration } from 'officerdb';
import { getMaildirPath } from '@@/data-path';
type GoogleCredentials = {
accessToken: string;
refreshToken: string;
expiresAt: number;
clientId: string;
clientSecret: string;
};
// ── Credentials ──
async function loadCredentials(email: string): Promise<GoogleCredentials> {
const serverGoogle = await getServerIntegration('google');
const serverConfig = serverGoogle?.config as Record<string, unknown> | undefined;
if (!serverConfig?.clientId || !serverConfig?.clientSecret) {
throw new Error('Google OAuth not configured — ask your admin to set up credentials');
}
type ImapCredentials = { email: string; appPassword: string };
const dbUser = await getUserByEmail(email);
async function loadImapCredentials(userEmail: string): Promise<ImapCredentials> {
const dbUser = await getUserByEmail(userEmail);
if (!dbUser) throw new Error('User not found');
const userGoogle = await getUserIntegration(dbUser.id, 'google');
const userConfig = userGoogle?.config as Record<string, unknown> | undefined;
if (!userConfig?.accessToken) {
throw new Error('Google account not connected — connect in Settings → Integrations');
const config = userGoogle?.config as Record<string, unknown> | undefined;
if (!config?.imapAppPassword) {
throw new Error('Gmail App Password not configured — set it in Settings → Integrations');
}
return {
accessToken: userConfig.accessToken as string,
refreshToken: (userConfig.refreshToken as string) ?? '',
expiresAt: (userConfig.expiresAt as number) ?? 0,
clientId: serverConfig.clientId as string,
clientSecret: serverConfig.clientSecret as string,
};
const gmailEmail = (config.email as string) ?? userEmail;
return { email: gmailEmail, appPassword: config.imapAppPassword as string };
}
let cachedAccessToken: string | null = null;
let cachedExpiresAt = 0;
// ── mbsync config ──
async function getValidAccessToken(creds: GoogleCredentials): Promise<string> {
if (cachedAccessToken && cachedExpiresAt > Date.now() + 5 * 60 * 1000) {
return cachedAccessToken;
}
function buildMbsyncConfig(email: string, appPassword: string, maildirPath: string): string {
return `IMAPAccount gmail
Host imap.gmail.com
Port 993
User ${email}
Pass "${appPassword}"
SSLType IMAPS
AuthMechs LOGIN
if (creds.expiresAt > Date.now() + 5 * 60 * 1000) {
cachedAccessToken = creds.accessToken;
cachedExpiresAt = creds.expiresAt;
return creds.accessToken;
}
IMAPStore gmail-remote
Account gmail
if (!creds.refreshToken) throw new Error('Token expired and no refresh token available');
MaildirStore gmail-local
Path ${maildirPath}/
Inbox ${maildirPath}/INBOX
SubFolders Verbatim
const res = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: creds.clientId,
client_secret: creds.clientSecret,
refresh_token: creds.refreshToken,
grant_type: 'refresh_token',
}),
});
if (!res.ok) {
const error = await res.text().catch(() => '');
throw new Error(`Token refresh failed (${res.status}): ${error}`);
}
const data = (await res.json()) as { access_token: string; expires_in?: number };
cachedAccessToken = data.access_token;
cachedExpiresAt = Date.now() + (data.expires_in ?? 3600) * 1000;
return data.access_token;
Channel gmail
Far :gmail-remote:
Near :gmail-local:
Patterns * ![Gmail]/Trash ![Gmail]/Spam
Create Near
Expunge None
SyncState *
`;
}
// ── IMAP label mapping ──
// ── Folder → label mapping ──
const SYSTEM_LABEL_MAP: Record<string, string> = {
'\\Inbox': 'inbox',
'\\Sent': 'sent',
'\\Trash': 'trash',
'\\Spam': 'spam',
'\\Draft': 'draft',
'\\Starred': 'starred',
'\\Important': 'important',
const FOLDER_LABEL_MAP: Record<string, string> = {
INBOX: 'inbox',
'[Gmail]/Sent Mail': 'sent',
'[Gmail]/Drafts': 'draft',
'[Gmail]/Starred': 'starred',
'[Gmail]/Important': 'important',
};
function mapImapLabels(labels: Set<string> | undefined): string[] {
if (!labels) return [];
const mapped: string[] = [];
for (const label of labels) {
const system = SYSTEM_LABEL_MAP[label];
if (system) {
mapped.push(system);
} else {
mapped.push(label.toLowerCase());
}
}
return mapped;
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();
}
// ── IMAP sync ──
// ── Stable ID from Message-Id header ──
type ImapSyncResult = { saved: number; skipped: number; errors: number };
function messageIdToStableId(raw: string): string | null {
const match = raw.match(/^Message-Id:\s*<?([^>\s]+)>?/im);
if (!match?.[1]) return null;
return createHash('sha1').update(match[1]).digest('hex').slice(0, 16);
}
// Mailboxes to sync: All Mail has everything except Trash and Spam
const SYNC_SPECIAL_USE = ['\\All', '\\Trash', '\\Junk'];
// ── Maildir import ──
async function syncViaImap(
accessToken: string,
type ImportResult = { saved: number; skipped: number; errors: number };
async function importMaildir(
maildirPath: string,
emailAccount: string,
db: Database,
since: Date | null,
onProgress?: (saved: number, skipped: number) => void,
): Promise<ImapSyncResult> {
const client = new ImapFlow({
host: 'imap.gmail.com',
port: 993,
secure: true,
auth: { user: emailAccount, accessToken },
logger: false,
});
try {
await client.connect();
} catch (err) {
console.error('[gmail-sync] IMAP connect failed:', err);
throw err;
}
): Promise<ImportResult> {
let saved = 0;
let skipped = 0;
let errors = 0;
// Load existing IDs for dedup (once, shared across mailboxes)
// Load existing IDs for fast dedup
const existingIds = new Set<string>();
const rows = db.query('SELECT id FROM emails').all() as Array<{ id: string }>;
for (const row of rows) existingIds.add(row.id);
// First pass: collect all message files with their folders to build label map
const messageIdLabels = new Map<string, Set<string>>();
const messageFiles = new Map<string, string>(); // id → first file path
let folders: string[];
try {
// Find mailboxes by specialUse flag (locale-independent)
const allMailboxes = await client.list();
const toSync: Array<{ path: string; specialUse: string }> = [];
for (const mailbox of allMailboxes) {
if (mailbox.specialUse && SYNC_SPECIAL_USE.includes(mailbox.specialUse)) {
toSync.push({ path: mailbox.path, specialUse: mailbox.specialUse });
}
}
folders = await readdir(maildirPath);
} catch {
console.log('[gmail-sync] No Maildir folders found');
return { saved, skipped, errors };
}
if (toSync.length === 0) {
console.error('[gmail-sync] No mailboxes found to sync');
return { saved, skipped, errors };
}
for (const folder of folders) {
const label = folderToLabel(folder);
if (label === null) continue;
for (const mailbox of toSync) {
console.log(`[gmail-sync] Opening ${mailbox.path} (${mailbox.specialUse})...`);
const lock = await client.getMailboxLock(mailbox.path);
for (const subdir of ['cur', 'new']) {
const dirPath = join(maildirPath, folder, subdir);
let files: string[];
try {
const searchCriteria = since ? { since } : { all: true };
const uids = await client.search(searchCriteria, { uid: true });
files = await readdir(dirPath);
} catch {
continue;
}
if (!uids || uids.length === 0) {
console.log(`[gmail-sync] ${mailbox.path}: no messages`);
continue;
}
console.log(`[gmail-sync] ${mailbox.path}: ${uids.length} messages`);
const uidRange = uids.join(',');
const messages = client.fetch(uidRange, {
source: true,
labels: true,
}, { uid: true });
for await (const msg of messages) {
try {
if (!msg.emailId || !msg.source) continue;
const gmailId = BigInt(msg.emailId).toString(16);
if (existingIds.has(gmailId)) {
skipped++;
if ((saved + skipped) % 500 === 0) onProgress?.(saved, skipped);
continue;
}
const rawEmail = msg.source.toString('utf-8');
const labels = mapImapLabels(msg.labels);
upsertFromRawEml({ db, id: gmailId, raw: rawEmail, integration: 'gmail', emailAccount, labels });
existingIds.add(gmailId);
saved++;
if ((saved + skipped) % 100 === 0) {
console.log(`[gmail-sync] Progress: saved ${saved}, skipped ${skipped}, errors ${errors}`);
onProgress?.(saved, skipped);
}
} catch {
for (const file of files) {
const filePath = join(dirPath, file);
try {
const raw = await readFile(filePath, 'utf-8');
const id = messageIdToStableId(raw);
if (!id) {
errors++;
continue;
}
// Track labels
const labels = messageIdLabels.get(id) ?? new Set<string>();
labels.add(label);
messageIdLabels.set(id, labels);
// Keep first file path for importing
if (!messageFiles.has(id)) {
messageFiles.set(id, filePath);
}
} catch {
errors++;
}
} finally {
lock.release();
}
}
} finally {
await client.logout();
}
// Second pass: import messages that aren't already in DB
for (const [id, filePath] of messageFiles) {
if (existingIds.has(id)) {
skipped++;
if ((saved + skipped) % 500 === 0) onProgress?.(saved, skipped);
continue;
}
try {
const raw = await readFile(filePath, 'utf-8');
const labels = Array.from(messageIdLabels.get(id) ?? []);
upsertFromRawEml({ db, id, raw, integration: 'gmail', emailAccount, labels });
existingIds.add(id);
saved++;
if ((saved + skipped) % 100 === 0) {
console.log(`[gmail-sync] Import progress: saved ${saved}, skipped ${skipped}, errors ${errors}`);
onProgress?.(saved, skipped);
}
} catch {
errors++;
}
}
return { saved, skipped, errors };
}
// ── Maildir stats ──
async function countMaildirFiles(maildirPath: string): Promise<number> {
let count = 0;
let folders: string[];
try {
folders = await readdir(maildirPath);
} catch {
return 0;
}
for (const folder of folders) {
for (const subdir of ['cur', 'new']) {
try {
const files = await readdir(join(maildirPath, folder, subdir));
count += files.length;
} catch {
continue;
}
}
}
return count;
}
// ── Handler ──
const gmailSyncHandler: JobHandler = {
type: 'gmail-sync',
retry: { delayMs: 15 * 60 * 1000, maxRetries: 10 },
steps: [
{
name: 'Verify connection',
name: 'Verify credentials',
run: async (ctx) => {
const creds = await loadCredentials(ctx.job.userId);
const token = await getValidAccessToken(creds);
ctx.meta.accessToken = token;
const creds = await loadImapCredentials(ctx.job.userId);
ctx.meta.email = creds.email;
ctx.meta.appPassword = creds.appPassword;
},
},
{
name: 'Sync emails',
name: 'Sync via mbsync',
run: async (ctx) => {
const token = ctx.meta.accessToken as string;
const year = ctx.meta.year as number | undefined;
const email = ctx.meta.email as string;
const appPassword = ctx.meta.appPassword as string;
const maildirPath = getMaildirPath(ctx.job.userId);
// Ensure Maildir root exists
await mkdir(maildirPath, { recursive: true });
// Write temp config
const configPath = join(maildirPath, '.mbsyncrc');
const config = buildMbsyncConfig(email, appPassword, maildirPath);
await writeFile(configPath, config, { mode: 0o600 });
await ctx.updateProgress({ current: 0, total: 0, label: 'Running mbsync...' });
try {
const proc = Bun.spawn(['mbsync', '-c', configPath, '-a', '-V'], {
stdout: 'pipe',
stderr: 'pipe',
});
// Stream stderr for live progress
let lastLine = '';
let stderrBuf = '';
const reader = proc.stderr.getReader();
const decoder = new TextDecoder();
const readLoop = (async () => {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
stderrBuf += chunk;
const lines = chunk.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
lastLine = trimmed;
console.log(`[gmail-sync] mbsync: ${trimmed}`);
}
if (lastLine) {
ctx.updateProgress({ current: 0, total: 0, label: `mbsync: ${lastLine.slice(0, 80)}` });
}
}
})();
const exitCode = await proc.exited;
await readLoop;
if (exitCode !== 0) {
console.error(`[gmail-sync] mbsync failed`);
const isOverquota = stderrBuf.includes('OVERQUOTA');
const isAuthFail = stderrBuf.includes('AUTHENTICATIONFAILED') || stderrBuf.includes('Invalid credentials');
if (isOverquota || isAuthFail) {
const emailCount = await countMaildirFiles(maildirPath);
ctx.meta.gmailSyncRecoverable = true;
ctx.meta.gmailSyncEmailCount = emailCount;
ctx.meta.gmailSyncIsAuthFail = isAuthFail;
}
throw new Error(`mbsync exited with code ${exitCode}: ${stderrBuf.slice(-500)}`);
}
console.log('[gmail-sync] mbsync completed successfully');
} finally {
// Always clean up config (contains password)
await unlink(configPath).catch(() => {});
}
},
},
{
name: 'Import to database',
run: async (ctx) => {
const emailAccount = ctx.meta.email as string;
const maildirPath = getMaildirPath(ctx.job.userId);
await ctx.updateProgress({ current: 0, total: 0, label: 'Importing emails...' });
const db = openEmailDb(ctx.job.userId);
try {
// Bootstrap sync_meta from existing emails if DB was imported without metadata
if (!getSyncMeta(db, 'last_sync_date')) {
const newest = db.query('SELECT date FROM emails ORDER BY date DESC LIMIT 1').get() as { date: string } | null;
if (newest?.date) {
console.log(`[gmail-sync] Bootstrapping last_sync_date from existing DB: ${newest.date}`);
setSyncMeta(db, 'last_sync_date', newest.date);
}
}
// Determine since date
let since: Date | null = null;
if (year) {
since = new Date(year, 0, 1);
} else {
const lastSyncDate = getSyncMeta(db, 'last_sync_date');
if (lastSyncDate) {
since = new Date(lastSyncDate);
console.log(`[gmail-sync] Syncing since ${since.toISOString()}`);
}
}
await ctx.updateProgress({ current: 0, total: 0, label: 'Connecting via IMAP...' });
const result = await syncViaImap(token, ctx.job.userId, db, since, (saved, skipped) => {
const label = year
? `${year} — Saved ${saved.toLocaleString()}, skipped ${skipped.toLocaleString()}`
: `Saved ${saved.toLocaleString()}, skipped ${skipped.toLocaleString()}`;
ctx.updateProgress({ current: saved + skipped, total: 0, label });
const result = await importMaildir(maildirPath, emailAccount, db, (saved, skipped) => {
ctx.updateProgress({
current: saved + skipped,
total: 0,
label: `Saved ${saved.toLocaleString()}, skipped ${skipped.toLocaleString()}`,
});
});
console.log(`[gmail-sync] Done: saved ${result.saved}, skipped ${result.skipped}, errors ${result.errors}`);
console.log(`[gmail-sync] Import done: saved ${result.saved}, skipped ${result.skipped}, errors ${result.errors}`);
setSyncMeta(db, 'last_sync_date', new Date().toISOString().split('T')[0]!);
setSyncMeta(db, 'last_sync_at', new Date().toISOString());
+52 -5
View File
@@ -1,17 +1,64 @@
import Layout from 'emailer/emails/layouts/MainLayout.jsx';
import { Container, Text } from '@react-email/components';
import { Container, Text, Link } from '@react-email/components';
type JobEmailData = {
job: { type: string; error?: string };
job: {
type: string;
error?: string;
meta?: {
gmailSyncRecoverable?: boolean;
gmailSyncEmailCount?: number;
gmailSyncIsAuthFail?: boolean;
};
};
};
const GmailSyncRecovery = ({ job }: JobEmailData) => {
const emailCount = job.meta?.gmailSyncEmailCount ?? 0;
const isAuthFail = job.meta?.gmailSyncIsAuthFail;
return (
<>
<Text className="text-base font-semibold text-green-700">Don't worry — your progress is saved.</Text>
{emailCount > 0 && (
<Text className="text-sm text-gray-700">
Your mailbox already has {emailCount.toLocaleString()} emails downloaded. The sync will resume from where it
stopped — it won't re-download emails you already have.
</Text>
)}
<Text className="text-sm text-gray-700">
{isAuthFail
? 'Google temporarily blocked sign-in after hitting bandwidth limits. This usually clears up within a few hours.'
: 'Gmail throttled the connection after downloading too much data at once. This is normal for large mailboxes.'}
</Text>
<Text className="text-sm font-semibold text-gray-800">To resume syncing:</Text>
<Text className="text-sm text-gray-700" style={{ marginTop: 0 }}>
1. Wait a few hours for Google to lift the block{'\n'}
2. Generate a new App Password at{' '}
<Link href="https://myaccount.google.com/apppasswords">myaccount.google.com/apppasswords</Link> (Google revokes
them after repeated auth failures){'\n'}
3. Save the new password in Settings Integrations Google Account{'\n'}
4. Click "Sync Gmail Inbox" to resume only new emails will be downloaded
</Text>
</>
);
};
const Email = ({ job }: JobEmailData) => {
const isRecoverableGmail = job?.type === 'gmail-sync' && job.meta?.gmailSyncRecoverable;
return (
<Layout>
<Container>
<Text className="pt-4 text-2xl">Job Failed</Text>
<Text>Your {job?.type ?? 'unknown'} job has failed.</Text>
{job?.error ? <Text className="text-sm text-gray-600">{job.error}</Text> : null}
<Text className="pt-4 text-2xl">{isRecoverableGmail ? 'Gmail Sync Paused' : 'Job Failed'}</Text>
{isRecoverableGmail ? (
<GmailSyncRecovery job={job} />
) : (
<>
<Text>Your {job?.type ?? 'unknown'} job has failed.</Text>
{job?.error ? <Text className="text-sm text-gray-600">{job.error}</Text> : null}
</>
)}
</Container>
</Layout>
);