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:
+113
-39
@@ -10,6 +10,7 @@ type GoogleStatus = {
|
|||||||
email: string | null;
|
email: string | null;
|
||||||
picture: string | null;
|
picture: string | null;
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
|
hasAppPassword: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTime = (ts: number | string) => {
|
const formatTime = (ts: number | string) => {
|
||||||
@@ -20,7 +21,10 @@ const formatTime = (ts: number | string) => {
|
|||||||
export const GoogleAccount = () => {
|
export const GoogleAccount = () => {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
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 [lastSyncAt, setLastSyncAt] = useState<string | null>(null);
|
||||||
const [dismissedError, setDismissedError] = useState(false);
|
const [dismissedError, setDismissedError] = useState(false);
|
||||||
const { jobs, createJob, refetch } = useJobs({ type: 'gmail-sync' });
|
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 (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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status.connected) {
|
|
||||||
const currentStep = activeJob?.steps[activeJob.currentStep];
|
const currentStep = activeJob?.steps[activeJob.currentStep];
|
||||||
const progress = currentStep?.progress;
|
const progress = currentStep?.progress;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="grid gap-6">
|
||||||
|
{/* Gmail Sync — independent of OAuth */}
|
||||||
<div className="grid gap-4">
|
<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>
|
||||||
<div className="h-2.5 w-2.5 rounded-full bg-green-500 shrink-0" />
|
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Gmail Sync</p>
|
||||||
<div className="min-w-0 flex-1">
|
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 mt-1">
|
||||||
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Connected</p>
|
Import and sync your Gmail emails with your Officer inbox. This uses a Google App Password for a direct IMAP
|
||||||
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 truncate">{status.email}</p>
|
connection — the only thing it can do is download your emails. It cannot send, delete, or modify anything in
|
||||||
|
your account.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{status.picture && (
|
<div className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4 grid gap-2">
|
||||||
<img src={status.picture} alt="" className="h-9 w-9 rounded-full shrink-0" referrerPolicy="no-referrer" />
|
<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>
|
</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 && (
|
{activeJob && (
|
||||||
<div className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3">
|
<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" />
|
<Loader2 className="h-4 w-4 animate-spin text-duck-dark/60 dark:text-foreground/60 shrink-0" />
|
||||||
@@ -163,30 +222,42 @@ export const GoogleAccount = () => {
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={!!activeJob}
|
disabled={!!activeJob || !status.hasAppPassword}
|
||||||
onClick={() => handleSync()}
|
onClick={() => handleSync()}
|
||||||
className="w-full h-11 cursor-pointer gap-2"
|
className="w-full h-11 cursor-pointer gap-2"
|
||||||
>
|
>
|
||||||
<RefreshCw className="h-4 w-4" />
|
<RefreshCw className="h-4 w-4" />
|
||||||
Sync Gmail Inbox
|
Sync Gmail Inbox
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
</div>
|
||||||
type="button"
|
|
||||||
variant="outline"
|
{/* OAuth — for Calendar and other Google services */}
|
||||||
onClick={handleDisconnect}
|
{status.configured && (
|
||||||
className="w-full h-11 cursor-pointer"
|
<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
|
Disconnect
|
||||||
</Button>
|
</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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleConnect}
|
onClick={handleConnect}
|
||||||
@@ -194,6 +265,9 @@ export const GoogleAccount = () => {
|
|||||||
>
|
>
|
||||||
Connect Google Account
|
Connect Google Account
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -126,9 +126,28 @@ integrationsRouter.get('/google/status', async (ctx) => {
|
|||||||
connected: !!connConfig?.accessToken,
|
connected: !!connConfig?.accessToken,
|
||||||
email: connConfig?.email ?? null,
|
email: connConfig?.email ?? null,
|
||||||
picture: connConfig?.picture ?? 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) => {
|
integrationsRouter.delete('/google/connection', async (ctx) => {
|
||||||
const user = ctx.get('user');
|
const user = ctx.get('user');
|
||||||
const connection = await getUserIntegration(user.id, 'google');
|
const connection = await getUserIntegration(user.id, 'google');
|
||||||
|
|||||||
@@ -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 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. */
|
/** Derive a valid Linux username from a display username or email. */
|
||||||
export const toShellUsername = (username: string, email: string): string => {
|
export const toShellUsername = (username: string, email: string): string => {
|
||||||
const raw = username || email.split('@')[0]!;
|
const raw = username || email.split('@')[0]!;
|
||||||
|
|||||||
@@ -81,11 +81,16 @@ function kickLane(lane: string) {
|
|||||||
processNextInLane(lane);
|
processNextInLane(lane);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scheduleRetry(lane: string, delayMs: number) {
|
||||||
|
setTimeout(() => kickLane(lane), delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
async function processNextInLane(lane: string) {
|
async function processNextInLane(lane: string) {
|
||||||
try {
|
try {
|
||||||
const jobs = await listAllJobs();
|
const jobs = await listAllJobs();
|
||||||
|
const now = Date.now();
|
||||||
const next = jobs
|
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];
|
.sort((a, b) => a.createdAt - b.createdAt)[0];
|
||||||
|
|
||||||
if (!next) {
|
if (!next) {
|
||||||
@@ -98,7 +103,7 @@ async function processNextInLane(lane: string) {
|
|||||||
console.error(`[queue] Lane ${lane} processing error:`, err);
|
console.error(`[queue] Lane ${lane} processing error:`, err);
|
||||||
} finally {
|
} finally {
|
||||||
const jobs = await listAllJobs();
|
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) {
|
if (hasMore) {
|
||||||
processNextInLane(lane);
|
processNextInLane(lane);
|
||||||
} else {
|
} else {
|
||||||
@@ -119,8 +124,10 @@ async function runJob(job: Job) {
|
|||||||
|
|
||||||
job.status = 'running';
|
job.status = 'running';
|
||||||
job.startedAt = Date.now();
|
job.startedAt = Date.now();
|
||||||
|
job.retryAt = undefined;
|
||||||
await writeJob(job);
|
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 ?? {}) };
|
const sharedMeta: Record<string, unknown> = { ...(job.meta ?? {}) };
|
||||||
|
|
||||||
@@ -134,6 +141,9 @@ async function runJob(job: Job) {
|
|||||||
const handlerStep = handler.steps[i]!;
|
const handlerStep = handler.steps[i]!;
|
||||||
const step = fresh.steps[i]!;
|
const step = fresh.steps[i]!;
|
||||||
|
|
||||||
|
// Skip already-completed steps on retry
|
||||||
|
if (step.status === 'completed') continue;
|
||||||
|
|
||||||
fresh.currentStep = i;
|
fresh.currentStep = i;
|
||||||
step.status = 'running';
|
step.status = 'running';
|
||||||
step.startedAt = Date.now();
|
step.startedAt = Date.now();
|
||||||
@@ -171,9 +181,32 @@ async function runJob(job: Job) {
|
|||||||
step.status = 'failed';
|
step.status = 'failed';
|
||||||
step.error = errorMessage;
|
step.error = errorMessage;
|
||||||
step.completedAt = Date.now();
|
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.status = 'failed';
|
||||||
fresh.error = `Step "${step.name}" failed: ${errorMessage}`;
|
fresh.error = `Step "${step.name}" failed: ${errorMessage}`;
|
||||||
fresh.completedAt = Date.now();
|
fresh.completedAt = Date.now();
|
||||||
|
fresh.meta = { ...fresh.meta, ...sharedMeta };
|
||||||
await writeJob(fresh);
|
await writeJob(fresh);
|
||||||
console.error(`[queue] Job ${fresh.id} failed at step "${step.name}":`, errorMessage);
|
console.error(`[queue] Job ${fresh.id} failed at step "${step.name}":`, errorMessage);
|
||||||
if (fresh.notify !== false) await notifyFailure(fresh);
|
if (fresh.notify !== false) await notifyFailure(fresh);
|
||||||
|
|||||||
@@ -1,270 +1,311 @@
|
|||||||
import type { Database } from 'bun:sqlite';
|
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 type { JobHandler } from '../types';
|
||||||
import { registerHandler } from '../handler-registry';
|
import { registerHandler } from '../handler-registry';
|
||||||
import { openEmailDb, upsertFromRawEml, getSyncMeta, setSyncMeta } from '../../api/email/email-db';
|
import { openEmailDb, upsertFromRawEml, setSyncMeta } from '../../api/email/email-db';
|
||||||
import { getServerIntegration, getUserByEmail, getUserIntegration } from 'officerdb';
|
import { getUserByEmail, getUserIntegration } from 'officerdb';
|
||||||
|
import { getMaildirPath } from '@@/data-path';
|
||||||
|
|
||||||
type GoogleCredentials = {
|
// ── Credentials ──
|
||||||
accessToken: string;
|
|
||||||
refreshToken: string;
|
|
||||||
expiresAt: number;
|
|
||||||
clientId: string;
|
|
||||||
clientSecret: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function loadCredentials(email: string): Promise<GoogleCredentials> {
|
type ImapCredentials = { email: string; appPassword: string };
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
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');
|
if (!dbUser) throw new Error('User not found');
|
||||||
|
|
||||||
const userGoogle = await getUserIntegration(dbUser.id, 'google');
|
const userGoogle = await getUserIntegration(dbUser.id, 'google');
|
||||||
const userConfig = userGoogle?.config as Record<string, unknown> | undefined;
|
const config = userGoogle?.config as Record<string, unknown> | undefined;
|
||||||
if (!userConfig?.accessToken) {
|
if (!config?.imapAppPassword) {
|
||||||
throw new Error('Google account not connected — connect in Settings → Integrations');
|
throw new Error('Gmail App Password not configured — set it in Settings → Integrations');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const gmailEmail = (config.email as string) ?? userEmail;
|
||||||
accessToken: userConfig.accessToken as string,
|
return { email: gmailEmail, appPassword: config.imapAppPassword as string };
|
||||||
refreshToken: (userConfig.refreshToken as string) ?? '',
|
|
||||||
expiresAt: (userConfig.expiresAt as number) ?? 0,
|
|
||||||
clientId: serverConfig.clientId as string,
|
|
||||||
clientSecret: serverConfig.clientSecret as string,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let cachedAccessToken: string | null = null;
|
// ── mbsync config ──
|
||||||
let cachedExpiresAt = 0;
|
|
||||||
|
|
||||||
async function getValidAccessToken(creds: GoogleCredentials): Promise<string> {
|
function buildMbsyncConfig(email: string, appPassword: string, maildirPath: string): string {
|
||||||
if (cachedAccessToken && cachedExpiresAt > Date.now() + 5 * 60 * 1000) {
|
return `IMAPAccount gmail
|
||||||
return cachedAccessToken;
|
Host imap.gmail.com
|
||||||
|
Port 993
|
||||||
|
User ${email}
|
||||||
|
Pass "${appPassword}"
|
||||||
|
SSLType IMAPS
|
||||||
|
AuthMechs LOGIN
|
||||||
|
|
||||||
|
IMAPStore gmail-remote
|
||||||
|
Account gmail
|
||||||
|
|
||||||
|
MaildirStore gmail-local
|
||||||
|
Path ${maildirPath}/
|
||||||
|
Inbox ${maildirPath}/INBOX
|
||||||
|
SubFolders Verbatim
|
||||||
|
|
||||||
|
Channel gmail
|
||||||
|
Far :gmail-remote:
|
||||||
|
Near :gmail-local:
|
||||||
|
Patterns * ![Gmail]/Trash ![Gmail]/Spam
|
||||||
|
Create Near
|
||||||
|
Expunge None
|
||||||
|
SyncState *
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (creds.expiresAt > Date.now() + 5 * 60 * 1000) {
|
// ── Folder → label mapping ──
|
||||||
cachedAccessToken = creds.accessToken;
|
|
||||||
cachedExpiresAt = creds.expiresAt;
|
|
||||||
return creds.accessToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!creds.refreshToken) throw new Error('Token expired and no refresh token available');
|
const FOLDER_LABEL_MAP: Record<string, string> = {
|
||||||
|
INBOX: 'inbox',
|
||||||
const res = await fetch('https://oauth2.googleapis.com/token', {
|
'[Gmail]/Sent Mail': 'sent',
|
||||||
method: 'POST',
|
'[Gmail]/Drafts': 'draft',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
'[Gmail]/Starred': 'starred',
|
||||||
body: new URLSearchParams({
|
'[Gmail]/Important': 'important',
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── IMAP label mapping ──
|
|
||||||
|
|
||||||
const SYSTEM_LABEL_MAP: Record<string, string> = {
|
|
||||||
'\\Inbox': 'inbox',
|
|
||||||
'\\Sent': 'sent',
|
|
||||||
'\\Trash': 'trash',
|
|
||||||
'\\Spam': 'spam',
|
|
||||||
'\\Draft': 'draft',
|
|
||||||
'\\Starred': 'starred',
|
|
||||||
'\\Important': 'important',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function mapImapLabels(labels: Set<string> | undefined): string[] {
|
const SKIP_FOLDERS = new Set(['[Gmail]/All Mail', '[Gmail]/Trash', '[Gmail]/Spam']);
|
||||||
if (!labels) return [];
|
|
||||||
const mapped: string[] = [];
|
function folderToLabel(folder: string): string | null {
|
||||||
for (const label of labels) {
|
if (SKIP_FOLDERS.has(folder)) return null;
|
||||||
const system = SYSTEM_LABEL_MAP[label];
|
if (FOLDER_LABEL_MAP[folder]) return FOLDER_LABEL_MAP[folder]!;
|
||||||
if (system) {
|
// Custom labels / other folders: lowercase the folder name
|
||||||
mapped.push(system);
|
return folder.replace(/^\[Gmail\]\//, '').toLowerCase();
|
||||||
} else {
|
|
||||||
mapped.push(label.toLowerCase());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return mapped;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 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
|
// ── Maildir import ──
|
||||||
const SYNC_SPECIAL_USE = ['\\All', '\\Trash', '\\Junk'];
|
|
||||||
|
|
||||||
async function syncViaImap(
|
type ImportResult = { saved: number; skipped: number; errors: number };
|
||||||
accessToken: string,
|
|
||||||
|
async function importMaildir(
|
||||||
|
maildirPath: string,
|
||||||
emailAccount: string,
|
emailAccount: string,
|
||||||
db: Database,
|
db: Database,
|
||||||
since: Date | null,
|
|
||||||
onProgress?: (saved: number, skipped: number) => void,
|
onProgress?: (saved: number, skipped: number) => void,
|
||||||
): Promise<ImapSyncResult> {
|
): Promise<ImportResult> {
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
let saved = 0;
|
let saved = 0;
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
let errors = 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 existingIds = new Set<string>();
|
||||||
const rows = db.query('SELECT id FROM emails').all() as Array<{ id: string }>;
|
const rows = db.query('SELECT id FROM emails').all() as Array<{ id: string }>;
|
||||||
for (const row of rows) existingIds.add(row.id);
|
for (const row of rows) existingIds.add(row.id);
|
||||||
|
|
||||||
try {
|
// First pass: collect all message files with their folders to build label map
|
||||||
// Find mailboxes by specialUse flag (locale-independent)
|
const messageIdLabels = new Map<string, Set<string>>();
|
||||||
const allMailboxes = await client.list();
|
const messageFiles = new Map<string, string>(); // id → first file path
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toSync.length === 0) {
|
let folders: string[];
|
||||||
console.error('[gmail-sync] No mailboxes found to sync');
|
try {
|
||||||
|
folders = await readdir(maildirPath);
|
||||||
|
} catch {
|
||||||
|
console.log('[gmail-sync] No Maildir folders found');
|
||||||
return { saved, skipped, errors };
|
return { saved, skipped, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const mailbox of toSync) {
|
for (const folder of folders) {
|
||||||
console.log(`[gmail-sync] Opening ${mailbox.path} (${mailbox.specialUse})...`);
|
const label = folderToLabel(folder);
|
||||||
const lock = await client.getMailboxLock(mailbox.path);
|
if (label === null) continue;
|
||||||
try {
|
|
||||||
const searchCriteria = since ? { since } : { all: true };
|
|
||||||
const uids = await client.search(searchCriteria, { uid: true });
|
|
||||||
|
|
||||||
if (!uids || uids.length === 0) {
|
for (const subdir of ['cur', 'new']) {
|
||||||
console.log(`[gmail-sync] ${mailbox.path}: no messages`);
|
const dirPath = join(maildirPath, folder, subdir);
|
||||||
|
let files: string[];
|
||||||
|
try {
|
||||||
|
files = await readdir(dirPath);
|
||||||
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[gmail-sync] ${mailbox.path}: ${uids.length} messages`);
|
for (const file of files) {
|
||||||
|
const filePath = join(dirPath, file);
|
||||||
const uidRange = uids.join(',');
|
|
||||||
const messages = client.fetch(uidRange, {
|
|
||||||
source: true,
|
|
||||||
labels: true,
|
|
||||||
}, { uid: true });
|
|
||||||
|
|
||||||
for await (const msg of messages) {
|
|
||||||
try {
|
try {
|
||||||
if (!msg.emailId || !msg.source) continue;
|
const raw = await readFile(filePath, 'utf-8');
|
||||||
|
const id = messageIdToStableId(raw);
|
||||||
|
if (!id) {
|
||||||
|
errors++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const gmailId = BigInt(msg.emailId).toString(16);
|
// Track labels
|
||||||
|
const labels = messageIdLabels.get(id) ?? new Set<string>();
|
||||||
|
labels.add(label);
|
||||||
|
messageIdLabels.set(id, labels);
|
||||||
|
|
||||||
if (existingIds.has(gmailId)) {
|
// Keep first file path for importing
|
||||||
|
if (!messageFiles.has(id)) {
|
||||||
|
messageFiles.set(id, filePath);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: import messages that aren't already in DB
|
||||||
|
for (const [id, filePath] of messageFiles) {
|
||||||
|
if (existingIds.has(id)) {
|
||||||
skipped++;
|
skipped++;
|
||||||
if ((saved + skipped) % 500 === 0) onProgress?.(saved, skipped);
|
if ((saved + skipped) % 500 === 0) onProgress?.(saved, skipped);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawEmail = msg.source.toString('utf-8');
|
try {
|
||||||
const labels = mapImapLabels(msg.labels);
|
const raw = await readFile(filePath, 'utf-8');
|
||||||
|
const labels = Array.from(messageIdLabels.get(id) ?? []);
|
||||||
|
|
||||||
upsertFromRawEml({ db, id: gmailId, raw: rawEmail, integration: 'gmail', emailAccount, labels });
|
upsertFromRawEml({ db, id, raw, integration: 'gmail', emailAccount, labels });
|
||||||
existingIds.add(gmailId);
|
existingIds.add(id);
|
||||||
saved++;
|
saved++;
|
||||||
|
|
||||||
if ((saved + skipped) % 100 === 0) {
|
if ((saved + skipped) % 100 === 0) {
|
||||||
console.log(`[gmail-sync] Progress: saved ${saved}, skipped ${skipped}, errors ${errors}`);
|
console.log(`[gmail-sync] Import progress: saved ${saved}, skipped ${skipped}, errors ${errors}`);
|
||||||
onProgress?.(saved, skipped);
|
onProgress?.(saved, skipped);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
errors++;
|
errors++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
lock.release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
await client.logout();
|
|
||||||
}
|
|
||||||
|
|
||||||
return { saved, skipped, 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 ──
|
// ── Handler ──
|
||||||
|
|
||||||
const gmailSyncHandler: JobHandler = {
|
const gmailSyncHandler: JobHandler = {
|
||||||
type: 'gmail-sync',
|
type: 'gmail-sync',
|
||||||
|
retry: { delayMs: 15 * 60 * 1000, maxRetries: 10 },
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
name: 'Verify connection',
|
name: 'Verify credentials',
|
||||||
run: async (ctx) => {
|
run: async (ctx) => {
|
||||||
const creds = await loadCredentials(ctx.job.userId);
|
const creds = await loadImapCredentials(ctx.job.userId);
|
||||||
const token = await getValidAccessToken(creds);
|
ctx.meta.email = creds.email;
|
||||||
ctx.meta.accessToken = token;
|
ctx.meta.appPassword = creds.appPassword;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Sync emails',
|
name: 'Sync via mbsync',
|
||||||
run: async (ctx) => {
|
run: async (ctx) => {
|
||||||
const token = ctx.meta.accessToken as string;
|
const email = ctx.meta.email as string;
|
||||||
const year = ctx.meta.year as number | undefined;
|
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);
|
const db = openEmailDb(ctx.job.userId);
|
||||||
try {
|
try {
|
||||||
// Bootstrap sync_meta from existing emails if DB was imported without metadata
|
const result = await importMaildir(maildirPath, emailAccount, db, (saved, skipped) => {
|
||||||
if (!getSyncMeta(db, 'last_sync_date')) {
|
ctx.updateProgress({
|
||||||
const newest = db.query('SELECT date FROM emails ORDER BY date DESC LIMIT 1').get() as { date: string } | null;
|
current: saved + skipped,
|
||||||
if (newest?.date) {
|
total: 0,
|
||||||
console.log(`[gmail-sync] Bootstrapping last_sync_date from existing DB: ${newest.date}`);
|
label: `Saved ${saved.toLocaleString()}, skipped ${skipped.toLocaleString()}`,
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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_date', new Date().toISOString().split('T')[0]!);
|
||||||
setSyncMeta(db, 'last_sync_at', new Date().toISOString());
|
setSyncMeta(db, 'last_sync_at', new Date().toISOString());
|
||||||
|
|||||||
@@ -1,17 +1,64 @@
|
|||||||
import Layout from 'emailer/emails/layouts/MainLayout.jsx';
|
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 = {
|
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 Email = ({ job }: JobEmailData) => {
|
||||||
|
const isRecoverableGmail = job?.type === 'gmail-sync' && job.meta?.gmailSyncRecoverable;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<Container>
|
<Container>
|
||||||
<Text className="pt-4 text-2xl">Job Failed</Text>
|
<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>
|
<Text>Your {job?.type ?? 'unknown'} job has failed.</Text>
|
||||||
{job?.error ? <Text className="text-sm text-gray-600">{job.error}</Text> : null}
|
{job?.error ? <Text className="text-sm text-gray-600">{job.error}</Text> : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Container>
|
</Container>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user