improve gmail sync: email input, permanent errors, auto-dock
- add isync to setup.sh - ask for gmail address alongside app password in integrations - add PermanentError to job queue (skips retries for non-recoverable failures) - use PermanentError for missing credentials, missing executable, auth failures - auto-add /email to dock after successful gmail sync - invalidate dock cache on sync completion for seamless UI update Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -135,6 +135,15 @@ if has sqlite3; then skip "sqlite3"; else
|
|||||||
esac
|
esac
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# isync (provides mbsync for Gmail IMAP sync)
|
||||||
|
if has mbsync; then skip "isync (mbsync)"; else
|
||||||
|
case $PM in
|
||||||
|
apt) CORE_PKGS+=(isync) ;;
|
||||||
|
pacman) CORE_PKGS+=(isync) ;;
|
||||||
|
brew) CORE_PKGS+=(isync) ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
# ripgrep
|
# ripgrep
|
||||||
if has rg; then skip "ripgrep"; else
|
if has rg; then skip "ripgrep"; else
|
||||||
case $PM in
|
case $PM in
|
||||||
|
|||||||
+61
-23
@@ -3,6 +3,8 @@ import { toast } from 'sonner';
|
|||||||
import { RefreshCw, Loader2, CheckCircle2, XCircle } from 'lucide-react';
|
import { RefreshCw, Loader2, CheckCircle2, XCircle } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { useAuth } from 'hooks/useAuth';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useJobs } from 'hooks/useJobs';
|
import { useJobs } from 'hooks/useJobs';
|
||||||
|
|
||||||
type GoogleStatus = {
|
type GoogleStatus = {
|
||||||
@@ -20,8 +22,17 @@ const formatTime = (ts: number | string) => {
|
|||||||
|
|
||||||
export const GoogleAccount = () => {
|
export const GoogleAccount = () => {
|
||||||
const client = useClient();
|
const client = useClient();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [status, setStatus] = useState<GoogleStatus>({ connected: false, email: null, picture: null, configured: false, hasAppPassword: false });
|
const [status, setStatus] = useState<GoogleStatus>({
|
||||||
|
connected: false,
|
||||||
|
email: null,
|
||||||
|
picture: null,
|
||||||
|
configured: false,
|
||||||
|
hasAppPassword: false,
|
||||||
|
});
|
||||||
|
const [gmailEmail, setGmailEmail] = useState('');
|
||||||
const [appPassword, setAppPassword] = useState('');
|
const [appPassword, setAppPassword] = useState('');
|
||||||
const [showPasswordInput, setShowPasswordInput] = useState(false);
|
const [showPasswordInput, setShowPasswordInput] = useState(false);
|
||||||
const [savingPassword, setSavingPassword] = useState(false);
|
const [savingPassword, setSavingPassword] = useState(false);
|
||||||
@@ -34,7 +45,10 @@ export const GoogleAccount = () => {
|
|||||||
const fetchStatus = () => {
|
const fetchStatus = () => {
|
||||||
client
|
client
|
||||||
.get<GoogleStatus>('/integrations/google/status')
|
.get<GoogleStatus>('/integrations/google/status')
|
||||||
.then(setStatus)
|
.then((s) => {
|
||||||
|
setStatus(s);
|
||||||
|
setGmailEmail(s.email ?? user?.email ?? '');
|
||||||
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => setIsLoading(false));
|
.finally(() => setIsLoading(false));
|
||||||
client
|
client
|
||||||
@@ -57,10 +71,14 @@ export const GoogleAccount = () => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Refresh sync status from DB when a job finishes
|
// Refresh sync status and dock when a job finishes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeJob && lastJob?.status === 'completed') {
|
if (!activeJob && lastJob?.status === 'completed') {
|
||||||
client.get<{ lastSyncAt: string | null }>('/email/sync-status').then((res) => setLastSyncAt(res.lastSyncAt)).catch(() => {});
|
client
|
||||||
|
.get<{ lastSyncAt: string | null }>('/email/sync-status')
|
||||||
|
.then((res) => setLastSyncAt(res.lastSyncAt))
|
||||||
|
.catch(() => {});
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['DOCK'] });
|
||||||
}
|
}
|
||||||
}, [activeJob, lastJob?.status]);
|
}, [activeJob, lastJob?.status]);
|
||||||
|
|
||||||
@@ -93,11 +111,14 @@ export const GoogleAccount = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveAppPassword = async () => {
|
const handleSaveAppPassword = async () => {
|
||||||
if (!appPassword.trim()) return;
|
if (!appPassword.trim() || !gmailEmail.trim()) return;
|
||||||
setSavingPassword(true);
|
setSavingPassword(true);
|
||||||
try {
|
try {
|
||||||
await client.put('/integrations/google/app-password', { appPassword: appPassword.trim() });
|
await client.put('/integrations/google/app-password', {
|
||||||
setStatus({ ...status, hasAppPassword: true });
|
appPassword: appPassword.trim(),
|
||||||
|
email: gmailEmail.trim(),
|
||||||
|
});
|
||||||
|
setStatus({ ...status, hasAppPassword: true, email: gmailEmail.trim() });
|
||||||
setAppPassword('');
|
setAppPassword('');
|
||||||
setShowPasswordInput(false);
|
setShowPasswordInput(false);
|
||||||
toast.success('App password saved');
|
toast.success('App password saved');
|
||||||
@@ -130,6 +151,9 @@ export const GoogleAccount = () => {
|
|||||||
{status.hasAppPassword && !showPasswordInput ? (
|
{status.hasAppPassword && !showPasswordInput ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-green-600 dark:text-green-400">Configured</span>
|
<span className="text-xs text-green-600 dark:text-green-400">Configured</span>
|
||||||
|
{status.email && (
|
||||||
|
<span className="text-xs text-duck-dark/50 dark:text-foreground/50">({status.email})</span>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowPasswordInput(true)}
|
onClick={() => setShowPasswordInput(true)}
|
||||||
className="text-xs text-duck-dark/50 dark:text-foreground/50 underline hover:opacity-70 cursor-pointer"
|
className="text-xs text-duck-dark/50 dark:text-foreground/50 underline hover:opacity-70 cursor-pointer"
|
||||||
@@ -158,23 +182,32 @@ export const GoogleAccount = () => {
|
|||||||
<li>Copy the 16-character password and paste it below</li>
|
<li>Copy the 16-character password and paste it below</li>
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="grid gap-2">
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="email"
|
||||||
value={appPassword}
|
value={gmailEmail}
|
||||||
onChange={(ev) => setAppPassword(ev.target.value)}
|
onChange={(ev) => setGmailEmail(ev.target.value)}
|
||||||
placeholder="xxxx xxxx xxxx xxxx"
|
placeholder="your@gmail.com"
|
||||||
className="flex-1 h-9 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-transparent px-3 text-sm"
|
className="h-9 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-transparent px-3 text-sm"
|
||||||
/>
|
/>
|
||||||
<Button
|
<div className="flex gap-2">
|
||||||
type="button"
|
<input
|
||||||
variant="outline"
|
type="password"
|
||||||
disabled={!appPassword.trim() || savingPassword}
|
value={appPassword}
|
||||||
onClick={handleSaveAppPassword}
|
onChange={(ev) => setAppPassword(ev.target.value)}
|
||||||
className="h-9 cursor-pointer"
|
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"
|
||||||
{savingPassword ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Save'}
|
/>
|
||||||
</Button>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!appPassword.trim() || !gmailEmail.trim() || savingPassword}
|
||||||
|
onClick={handleSaveAppPassword}
|
||||||
|
className="h-9 cursor-pointer"
|
||||||
|
>
|
||||||
|
{savingPassword ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -250,7 +283,12 @@ export const GoogleAccount = () => {
|
|||||||
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 truncate">{status.email}</p>
|
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 truncate">{status.email}</p>
|
||||||
</div>
|
</div>
|
||||||
{status.picture && (
|
{status.picture && (
|
||||||
<img src={status.picture} alt="" className="h-9 w-9 rounded-full shrink-0" referrerPolicy="no-referrer" />
|
<img
|
||||||
|
src={status.picture}
|
||||||
|
alt=""
|
||||||
|
className="h-9 w-9 rounded-full shrink-0"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button type="button" variant="outline" onClick={handleDisconnect} className="w-full h-11 cursor-pointer">
|
<Button type="button" variant="outline" onClick={handleDisconnect} className="w-full h-11 cursor-pointer">
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ integrationsRouter.get('/google/verify', async (ctx) => {
|
|||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
const valid = body.error === 'invalid_grant' || body.error === 'redirect_uri_mismatch';
|
const valid = body.error === 'invalid_grant' || body.error === 'redirect_uri_mismatch';
|
||||||
|
|
||||||
return ctx.json({ valid, error: valid ? null : body.error_description ?? body.error });
|
return ctx.json({ valid, error: valid ? null : (body.error_description ?? body.error) });
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Personal: Google account connection status ---
|
// --- Personal: Google account connection status ---
|
||||||
@@ -132,17 +132,20 @@ integrationsRouter.get('/google/status', async (ctx) => {
|
|||||||
|
|
||||||
integrationsRouter.put('/google/app-password', async (ctx) => {
|
integrationsRouter.put('/google/app-password', async (ctx) => {
|
||||||
const user = ctx.get('user');
|
const user = ctx.get('user');
|
||||||
const body = ctx.get('body') as { appPassword?: string };
|
const body = ctx.get('body') as { appPassword?: string; email?: string };
|
||||||
if (!body.appPassword) throw BAD_REQUEST('Missing appPassword');
|
if (!body.appPassword) throw BAD_REQUEST('Missing appPassword');
|
||||||
|
|
||||||
const connection = await getUserIntegration(user.id, 'google');
|
const connection = await getUserIntegration(user.id, 'google');
|
||||||
const connConfig = (connection?.config as Record<string, unknown>) ?? {};
|
const connConfig = (connection?.config as Record<string, unknown>) ?? {};
|
||||||
|
|
||||||
|
const updated: Record<string, unknown> = { ...connConfig, imapAppPassword: body.appPassword };
|
||||||
|
if (body.email) updated.email = body.email;
|
||||||
|
|
||||||
await upsertUserIntegration({
|
await upsertUserIntegration({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
provider: 'google',
|
provider: 'google',
|
||||||
serverIntegrationId: connection?.serverIntegrationId ?? undefined,
|
serverIntegrationId: connection?.serverIntegrationId ?? undefined,
|
||||||
config: { ...connConfig, imapAppPassword: body.appPassword },
|
config: updated,
|
||||||
});
|
});
|
||||||
|
|
||||||
return ctx.json({ ok: true });
|
return ctx.json({ ok: true });
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Job, JobProgress, EnqueueParams, StepContext } from './types';
|
import { type Job, type JobProgress, type EnqueueParams, type StepContext, PermanentError } from './types';
|
||||||
import { readJob, writeJob, listAllJobs } from './storage';
|
import { readJob, writeJob, listAllJobs } from './storage';
|
||||||
import { getHandler } from './handler-registry';
|
import { getHandler } from './handler-registry';
|
||||||
import { sendMail } from 'emailer';
|
import { sendMail } from 'emailer';
|
||||||
@@ -103,7 +103,9 @@ 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' && (!j.retryAt || j.retryAt <= Date.now()));
|
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 {
|
||||||
@@ -127,7 +129,9 @@ async function runJob(job: Job) {
|
|||||||
job.retryAt = undefined;
|
job.retryAt = undefined;
|
||||||
await writeJob(job);
|
await writeJob(job);
|
||||||
const isRetry = (job.retries ?? 0) > 0;
|
const isRetry = (job.retries ?? 0) > 0;
|
||||||
console.log(`[queue] ${isRetry ? 'Resuming' : 'Running'} job ${job.id} (${job.type})${isRetry ? ` retry ${job.retries}` : ''}`);
|
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 ?? {}) };
|
||||||
|
|
||||||
@@ -182,9 +186,10 @@ async function runJob(job: Job) {
|
|||||||
step.error = errorMessage;
|
step.error = errorMessage;
|
||||||
step.completedAt = Date.now();
|
step.completedAt = Date.now();
|
||||||
|
|
||||||
// Check if handler supports retry
|
// Check if handler supports retry (PermanentError skips retries)
|
||||||
|
const isPermanent = err instanceof PermanentError;
|
||||||
const retries = (fresh.retries ?? 0) + 1;
|
const retries = (fresh.retries ?? 0) + 1;
|
||||||
if (handler.retry && retries <= handler.retry.maxRetries) {
|
if (!isPermanent && handler.retry && retries <= handler.retry.maxRetries) {
|
||||||
// Schedule retry: reset failed step to pending, re-queue
|
// Schedule retry: reset failed step to pending, re-queue
|
||||||
step.status = 'pending';
|
step.status = 'pending';
|
||||||
step.error = undefined;
|
step.error = undefined;
|
||||||
@@ -198,7 +203,9 @@ async function runJob(job: Job) {
|
|||||||
fresh.retries = retries;
|
fresh.retries = retries;
|
||||||
fresh.retryAt = Date.now() + handler.retry.delayMs;
|
fresh.retryAt = Date.now() + handler.retry.delayMs;
|
||||||
await writeJob(fresh);
|
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}`);
|
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);
|
scheduleRetry(fresh.lane, handler.retry.delayMs);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import type { Database } from 'bun:sqlite';
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { readdir, readFile, writeFile, unlink, mkdir, stat } from 'node:fs/promises';
|
import { readdir, readFile, writeFile, unlink, mkdir, stat } from 'node:fs/promises';
|
||||||
import type { JobHandler } from '../types';
|
import { type JobHandler, PermanentError } from '../types';
|
||||||
import { registerHandler } from '../handler-registry';
|
import { registerHandler } from '../handler-registry';
|
||||||
import { openEmailDb, upsertFromRawEml, setSyncMeta } from '../../api/email/email-db';
|
import { openEmailDb, upsertFromRawEml, setSyncMeta } from '../../api/email/email-db';
|
||||||
import { getUserByEmail, getUserIntegration } from 'officerdb';
|
import { getUserByEmail, getUserIntegration, getDockPaths, setDockPaths } from 'officerdb';
|
||||||
import { getMaildirPath } from '@@/data-path';
|
import { getMaildirPath } from '@@/data-path';
|
||||||
|
|
||||||
// ── Credentials ──
|
// ── Credentials ──
|
||||||
@@ -14,12 +14,12 @@ type ImapCredentials = { email: string; appPassword: string };
|
|||||||
|
|
||||||
async function loadImapCredentials(userEmail: string): Promise<ImapCredentials> {
|
async function loadImapCredentials(userEmail: string): Promise<ImapCredentials> {
|
||||||
const dbUser = await getUserByEmail(userEmail);
|
const dbUser = await getUserByEmail(userEmail);
|
||||||
if (!dbUser) throw new Error('User not found');
|
if (!dbUser) throw new PermanentError('User not found');
|
||||||
|
|
||||||
const userGoogle = await getUserIntegration(dbUser.id, 'google');
|
const userGoogle = await getUserIntegration(dbUser.id, 'google');
|
||||||
const config = userGoogle?.config as Record<string, unknown> | undefined;
|
const config = userGoogle?.config as Record<string, unknown> | undefined;
|
||||||
if (!config?.imapAppPassword) {
|
if (!config?.imapAppPassword) {
|
||||||
throw new Error('Gmail App Password not configured — set it in Settings → Integrations');
|
throw new PermanentError('Gmail App Password not configured — set it in Settings → Integrations');
|
||||||
}
|
}
|
||||||
|
|
||||||
const gmailEmail = (config.email as string) ?? userEmail;
|
const gmailEmail = (config.email as string) ?? userEmail;
|
||||||
@@ -235,15 +235,21 @@ const gmailSyncHandler: JobHandler = {
|
|||||||
await ctx.updateProgress({ current: 0, total: 0, label: 'Running mbsync...' });
|
await ctx.updateProgress({ current: 0, total: 0, label: 'Running mbsync...' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const proc = Bun.spawn(['mbsync', '-c', configPath, '-a', '-V'], {
|
let proc: ReturnType<typeof Bun.spawn>;
|
||||||
stdout: 'pipe',
|
try {
|
||||||
stderr: 'pipe',
|
proc = Bun.spawn(['mbsync', '-c', configPath, '-a', '-V'], {
|
||||||
});
|
stdout: 'pipe',
|
||||||
|
stderr: 'pipe',
|
||||||
|
});
|
||||||
|
} catch (spawnErr) {
|
||||||
|
const msg = spawnErr instanceof Error ? spawnErr.message : String(spawnErr);
|
||||||
|
throw new PermanentError(`Failed to start mbsync: ${msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Stream stderr for live progress
|
// Stream stderr for live progress
|
||||||
let lastLine = '';
|
let lastLine = '';
|
||||||
let stderrBuf = '';
|
let stderrBuf = '';
|
||||||
const reader = proc.stderr.getReader();
|
const reader = (proc.stderr as ReadableStream<Uint8Array>).getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
const readLoop = (async () => {
|
const readLoop = (async () => {
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -285,6 +291,7 @@ const gmailSyncHandler: JobHandler = {
|
|||||||
ctx.meta.gmailSyncRecoverable = true;
|
ctx.meta.gmailSyncRecoverable = true;
|
||||||
ctx.meta.gmailSyncEmailCount = emailCount;
|
ctx.meta.gmailSyncEmailCount = emailCount;
|
||||||
ctx.meta.gmailSyncIsAuthFail = true;
|
ctx.meta.gmailSyncIsAuthFail = true;
|
||||||
|
throw new PermanentError(`Authentication failed — check your App Password`);
|
||||||
}
|
}
|
||||||
throw new Error(`mbsync exited with code ${exitCode}: ${stderrBuf.slice(-500)}`);
|
throw new Error(`mbsync exited with code ${exitCode}: ${stderrBuf.slice(-500)}`);
|
||||||
}
|
}
|
||||||
@@ -315,11 +322,32 @@ const gmailSyncHandler: JobHandler = {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[gmail-sync] Import 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());
|
||||||
|
|
||||||
|
// Auto-add /email to dock if not already present
|
||||||
|
if (result.saved > 0) {
|
||||||
|
try {
|
||||||
|
const dbUser = await getUserByEmail(ctx.job.userId);
|
||||||
|
if (dbUser) {
|
||||||
|
const paths = await getDockPaths(dbUser.id);
|
||||||
|
if (!paths) {
|
||||||
|
// User hasn't customized dock — initialize with defaults + /email
|
||||||
|
const defaults = ['/', '/files', '/automation', '/projects', '/dashboards', '/chat'];
|
||||||
|
await setDockPaths(dbUser.id, [...defaults, '/email']);
|
||||||
|
} else if (!paths.includes('/email')) {
|
||||||
|
await setDockPaths(dbUser.id, [...paths, '/email']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-fatal — dock update is best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await ctx.updateProgress({ current: 1, total: 1, label: `Done — ${result.saved} new emails` });
|
await ctx.updateProgress({ current: 1, total: 1, label: `Done — ${result.saved} new emails` });
|
||||||
} finally {
|
} finally {
|
||||||
db.close();
|
db.close();
|
||||||
|
|||||||
@@ -5,7 +5,17 @@ import './handlers';
|
|||||||
export { enqueue, cancelJob } from './engine';
|
export { enqueue, cancelJob } from './engine';
|
||||||
export { readJob, listAllJobs } from './storage';
|
export { readJob, listAllJobs } from './storage';
|
||||||
export { registerHandler } from './handler-registry';
|
export { registerHandler } from './handler-registry';
|
||||||
export type { Job, JobStep, JobStatus, JobProgress, StepContext, JobHandler, JobHandlerStep, EnqueueParams } from './types';
|
export { PermanentError } from './types';
|
||||||
|
export type {
|
||||||
|
Job,
|
||||||
|
JobStep,
|
||||||
|
JobStatus,
|
||||||
|
JobProgress,
|
||||||
|
StepContext,
|
||||||
|
JobHandler,
|
||||||
|
JobHandlerStep,
|
||||||
|
EnqueueParams,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
export async function initQueue() {
|
export async function initQueue() {
|
||||||
await ensureQueueDir();
|
await ensureQueueDir();
|
||||||
|
|||||||
@@ -65,3 +65,11 @@ export type EnqueueParams = {
|
|||||||
meta?: Record<string, unknown>;
|
meta?: Record<string, unknown>;
|
||||||
notify?: boolean;
|
notify?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Throw this from a step to fail the job immediately without retrying. */
|
||||||
|
export class PermanentError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'PermanentError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user