wip: email sync via imap with status tracking and auto cron

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 04:57:30 +00:00
co-authored by Claude Opus 4.6
parent 5925ac49a1
commit 170bd6d41b
20 changed files with 1929 additions and 373 deletions
@@ -1,64 +1,32 @@
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { RefreshCw, Loader2, CheckCircle2, XCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth';
import { useQueryClient } from '@tanstack/react-query';
import { useJobs } from 'hooks/useJobs';
type GoogleStatus = {
connected: boolean;
email: string | null;
picture: string | null;
configured: boolean;
hasAppPassword: boolean;
};
const formatTime = (ts: number | string) => {
const d = new Date(ts);
return d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
};
export const GoogleAccount = () => {
const client = useClient();
const { user } = useAuth();
const queryClient = useQueryClient();
const [isLoading, setIsLoading] = useState(true);
const [status, setStatus] = useState<GoogleStatus>({
connected: false,
email: null,
picture: null,
configured: false,
hasAppPassword: false,
});
const [gmailEmail, setGmailEmail] = useState('');
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' });
const activeJob = jobs.find((j) => j.status === 'queued' || j.status === 'running');
const lastJob = jobs[0];
const fetchStatus = () => {
client
.get<GoogleStatus>('/integrations/google/status')
.then((s) => {
setStatus(s);
setGmailEmail(s.email ?? user?.email ?? '');
})
.catch(() => {})
.finally(() => setIsLoading(false));
client
.get<{ lastSyncAt: string | null }>('/email/sync-status')
.then((res) => setLastSyncAt(res.lastSyncAt))
.catch(() => {});
};
useEffect(() => {
fetchStatus();
client
.get<GoogleStatus>('/integrations/google/status')
.then(setStatus)
.catch(() => {})
.finally(() => setIsLoading(false));
const params = new URLSearchParams(window.location.search);
const result = params.get('google');
if (result === 'success') {
@@ -71,27 +39,6 @@ export const GoogleAccount = () => {
}
}, []);
// Refresh sync status and dock when a job finishes
useEffect(() => {
if (!activeJob && lastJob?.status === 'completed') {
client
.get<{ lastSyncAt: string | null }>('/email/sync-status')
.then((res) => setLastSyncAt(res.lastSyncAt))
.catch(() => {});
queryClient.invalidateQueries({ queryKey: ['DOCK'] });
}
}, [activeJob, lastJob?.status]);
const handleSync = async (year?: number) => {
try {
await createJob({ lane: 'google-api', type: 'gmail-sync', meta: year ? { year } : undefined });
await refetch();
toast.success("Gmail sync started — you'll receive an email when it's done");
} catch {
toast.error('Failed to start Gmail sync');
}
};
const handleConnect = () => {
const params = new URLSearchParams({
token: client.token ?? '',
@@ -110,201 +57,41 @@ export const GoogleAccount = () => {
}
};
const handleSaveAppPassword = async () => {
if (!appPassword.trim() || !gmailEmail.trim()) return;
setSavingPassword(true);
try {
await client.put('/integrations/google/app-password', {
appPassword: appPassword.trim(),
email: gmailEmail.trim(),
});
setStatus({ ...status, hasAppPassword: true, email: gmailEmail.trim() });
setAppPassword('');
setShowPasswordInput(false);
toast.success('App password saved');
} catch {
toast.error('Failed to save app password');
} finally {
setSavingPassword(false);
}
};
if (isLoading) return null;
const currentStep = activeJob?.steps[activeJob.currentStep];
const progress = currentStep?.progress;
if (!status.configured) return null;
return (
<div className="grid gap-6">
{/* Gmail Sync — independent of OAuth */}
<div className="grid gap-4">
<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>
{status.email && (
<span className="text-xs text-duck-dark/50 dark:text-foreground/50">({status.email})</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="grid gap-2">
<input
type="email"
value={gmailEmail}
onChange={(ev) => setGmailEmail(ev.target.value)}
placeholder="your@gmail.com"
className="h-9 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-transparent px-3 text-sm"
/>
<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() || !gmailEmail.trim() || savingPassword}
onClick={handleSaveAppPassword}
className="h-9 cursor-pointer"
>
{savingPassword ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Save'}
</Button>
</div>
</div>
</>
)}
</div>
{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" />
<div className="grid gap-4">
<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.
</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">
{activeJob.status === 'queued' ? 'Queued' : 'Syncing'}
{progress?.label ? ` — ${progress.label}` : ''}
</p>
{progress && progress.total > 0 && (
<div className="mt-1.5 h-1.5 rounded-full bg-duck-dark/10 dark:bg-foreground/10 overflow-hidden">
<div
className="h-full rounded-full bg-duck-yellow transition-all duration-300"
style={{ width: `${Math.round((progress.current / progress.total) * 100)}%` }}
/>
</div>
)}
{progress && progress.total === 0 && progress.current > 0 && (
<p className="mt-1 text-xs text-duck-dark/50 dark:text-foreground/50">
{progress.current.toLocaleString()} emails processed
</p>
)}
<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>
)}
{!activeJob && lastJob?.status === 'failed' && !dismissedError && (
<button
onClick={() => setDismissedError(true)}
className="flex items-center gap-2 text-xs text-red-500 hover:opacity-70 transition-opacity cursor-pointer text-left"
title="Click to dismiss"
>
<XCircle className="h-3.5 w-3.5 shrink-0" />
Last sync failed{lastJob.error ? `: ${lastJob.error}` : ''}
</button>
)}
{!activeJob && lastSyncAt && (
<div className="flex items-center gap-2 text-xs text-duck-dark/50 dark:text-foreground/50">
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />
Last sync completed {formatTime(lastSyncAt)}
</div>
)}
<Button type="button" variant="outline" onClick={handleDisconnect} className="w-full h-11 cursor-pointer">
Disconnect
</Button>
</>
) : (
<Button
type="button"
variant="outline"
disabled={!!activeJob || !status.hasAppPassword}
onClick={() => handleSync()}
className="w-full h-11 cursor-pointer gap-2"
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"
>
<RefreshCw className="h-4 w-4" />
Sync Gmail Inbox
Connect Google Account
</Button>
</div>
{/* 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>
);