First sync still uses IMAP with app password. Subsequent syncs use Gmail API history.list + messages.get with OAuth for faster, more reliable incremental sync. Dispatch gmail-sync handler for gmail accounts instead of generic email-sync. Show sync button for synced accounts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
509 lines
20 KiB
TypeScript
509 lines
20 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { toast } from 'sonner';
|
|
import { Plus, Trash2, Loader2, Mail, Server, KeyRound, RefreshCw } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { useAuth } from 'hooks/useAuth';
|
|
import { useJobs } from 'hooks/useJobs';
|
|
|
|
type EmailAccountRow = {
|
|
id: number;
|
|
provider: string;
|
|
email: string;
|
|
displayName: string | null;
|
|
enabled: boolean;
|
|
status: string;
|
|
createdAt: string;
|
|
};
|
|
|
|
type ProviderChoice = 'gmail-oauth' | 'gmail-password' | 'imap';
|
|
|
|
type FormState = {
|
|
email: string;
|
|
displayName: string;
|
|
password: string;
|
|
imapHost: string;
|
|
imapPort: string;
|
|
imapSecure: boolean;
|
|
};
|
|
|
|
const INITIAL_FORM: FormState = {
|
|
email: '',
|
|
displayName: '',
|
|
password: '',
|
|
imapHost: '',
|
|
imapPort: '993',
|
|
imapSecure: true,
|
|
};
|
|
|
|
export const EmailAccounts = () => {
|
|
const client = useClient();
|
|
const { user } = useAuth();
|
|
const { jobs } = useJobs({ type: 'email-sync' });
|
|
const [accounts, setAccounts] = useState<EmailAccountRow[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [showPicker, setShowPicker] = useState(false);
|
|
const [activeForm, setActiveForm] = useState<ProviderChoice | null>(null);
|
|
const [form, setForm] = useState<FormState>(INITIAL_FORM);
|
|
const [saving, setSaving] = useState(false);
|
|
const [deleting, setDeleting] = useState<number | null>(null);
|
|
const [syncing, setSyncing] = useState<number | null>(null);
|
|
|
|
const fetchAccounts = () => {
|
|
client
|
|
.get<EmailAccountRow[]>('/email/accounts')
|
|
.then(setAccounts)
|
|
.catch(() => {})
|
|
.finally(() => setLoading(false));
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchAccounts();
|
|
}, []);
|
|
|
|
// Poll accounts while any are queued/syncing to pick up status changes
|
|
const hasSyncingAccount = accounts.some((a) => a.status === 'syncing' || a.status === 'queued');
|
|
useEffect(() => {
|
|
if (!hasSyncingAccount) return;
|
|
const interval = setInterval(fetchAccounts, 5000);
|
|
return () => clearInterval(interval);
|
|
}, [hasSyncingAccount]);
|
|
|
|
const resetForm = () => {
|
|
setActiveForm(null);
|
|
setShowPicker(false);
|
|
setForm(INITIAL_FORM);
|
|
};
|
|
|
|
const handlePickProvider = (choice: ProviderChoice) => {
|
|
setShowPicker(false);
|
|
setActiveForm(choice);
|
|
if (choice === 'gmail-oauth' || choice === 'gmail-password') {
|
|
setForm({ ...INITIAL_FORM, imapHost: 'imap.gmail.com', imapPort: '993', imapSecure: true });
|
|
} else {
|
|
setForm(INITIAL_FORM);
|
|
}
|
|
};
|
|
|
|
const handleGmailOAuth = async () => {
|
|
setSaving(true);
|
|
try {
|
|
const status = await client.get<{ connected: boolean; email: string | null }>('/integrations/google/status');
|
|
if (!status.connected) {
|
|
const params = new URLSearchParams({ token: client.token ?? '', origin: window.location.origin });
|
|
window.location.href = `/api/integrations/google/authorize?${params.toString()}`;
|
|
return;
|
|
}
|
|
|
|
const result = await client.post<{ id: number; provider: string; email: string }>('/email/accounts', {
|
|
provider: 'gmail',
|
|
email: status.email,
|
|
displayName: form.displayName || undefined,
|
|
imapHost: 'imap.gmail.com',
|
|
imapPort: 993,
|
|
imapSecure: true,
|
|
authType: 'oauth',
|
|
credentials: { userIntegrationId: true },
|
|
});
|
|
|
|
toast.success(`Added ${result.email}`);
|
|
resetForm();
|
|
fetchAccounts();
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Failed to add Gmail account');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handlePasswordSubmit = async () => {
|
|
if (!form.email.trim() || !form.password.trim()) return;
|
|
setSaving(true);
|
|
try {
|
|
const provider = form.imapHost === 'imap.gmail.com' ? 'gmail' : 'imap';
|
|
const result = await client.post<{ id: number; provider: string; email: string }>('/email/accounts', {
|
|
provider,
|
|
email: form.email.trim(),
|
|
displayName: form.displayName.trim() || undefined,
|
|
imapHost: form.imapHost,
|
|
imapPort: Number(form.imapPort),
|
|
imapSecure: form.imapSecure,
|
|
authType: 'password',
|
|
credentials: { password: form.password },
|
|
});
|
|
|
|
toast.success(`Added ${result.email}`);
|
|
resetForm();
|
|
fetchAccounts();
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Failed to add account');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (id: number) => {
|
|
setDeleting(id);
|
|
try {
|
|
await client.delete(`/email/accounts/${id}`);
|
|
setAccounts((prev) => prev.filter((a) => a.id !== id));
|
|
toast.success('Account removed');
|
|
} catch {
|
|
toast.error('Failed to remove account');
|
|
} finally {
|
|
setDeleting(null);
|
|
}
|
|
};
|
|
|
|
const handleSync = async (id: number) => {
|
|
setSyncing(id);
|
|
try {
|
|
await client.post(`/email/accounts/${id}/sync`, {});
|
|
toast.success('Sync started');
|
|
// Update local state immediately
|
|
setAccounts((prev) => prev.map((a) => (a.id === id ? { ...a, status: 'queued' } : a)));
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Failed to start sync');
|
|
} finally {
|
|
setSyncing(null);
|
|
}
|
|
};
|
|
|
|
if (loading) return null;
|
|
|
|
return (
|
|
<div className="grid gap-4">
|
|
<div>
|
|
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Email Accounts</p>
|
|
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 mt-1">
|
|
Connect email accounts for syncing your inbox. Supports Gmail and any IMAP provider.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Account list */}
|
|
{accounts.length > 0 && (
|
|
<div className="grid gap-2">
|
|
{accounts.map((account) => {
|
|
const accountJob = jobs.find(
|
|
(j) =>
|
|
(j.status === 'queued' || j.status === 'running') &&
|
|
(j.meta as Record<string, unknown> | undefined)?.emailAccountId === account.id,
|
|
);
|
|
const progress = accountJob?.steps[accountJob.currentStep]?.progress;
|
|
|
|
return (
|
|
<div
|
|
key={account.id}
|
|
className="rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<ProviderIcon provider={account.provider} />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm font-medium text-duck-dark dark:text-foreground truncate">
|
|
{account.displayName ?? account.email}
|
|
</p>
|
|
{account.displayName && (
|
|
<p className="text-xs text-duck-dark/50 dark:text-foreground/50 truncate">{account.email}</p>
|
|
)}
|
|
</div>
|
|
<StatusBadge status={account.status} />
|
|
{account.status === 'queued' && (
|
|
<span className="text-xs text-duck-dark/50 dark:text-foreground/50">Waiting...</span>
|
|
)}
|
|
{(account.status === 'connected' || account.status === 'synced') && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleSync(account.id)}
|
|
disabled={syncing === account.id}
|
|
className="h-7 text-xs cursor-pointer gap-1.5"
|
|
>
|
|
{syncing === account.id ? (
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
) : (
|
|
<RefreshCw className="h-3 w-3" />
|
|
)}
|
|
Sync
|
|
</Button>
|
|
)}
|
|
<button
|
|
onClick={() => handleDelete(account.id)}
|
|
disabled={deleting === account.id || account.status === 'syncing' || account.status === 'queued'}
|
|
className="text-duck-dark/40 dark:text-foreground/40 hover:text-red-500 transition-colors cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
|
|
>
|
|
{deleting === account.id ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Trash2 className="h-4 w-4" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
{/* Sync progress */}
|
|
{account.status === 'syncing' && progress && (
|
|
<div className="mt-2 flex items-center gap-2">
|
|
<Loader2 className="h-3 w-3 animate-spin text-blue-500 shrink-0" />
|
|
<p className="text-xs text-duck-dark/60 dark:text-foreground/60 truncate">
|
|
{progress.label ?? 'Syncing...'}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{account.status === 'syncing' && !progress && (
|
|
<div className="mt-2 flex items-center gap-2">
|
|
<Loader2 className="h-3 w-3 animate-spin text-blue-500 shrink-0" />
|
|
<p className="text-xs text-duck-dark/60 dark:text-foreground/60">Syncing...</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Provider picker */}
|
|
{showPicker && !activeForm && (
|
|
<div className="grid gap-2">
|
|
<button
|
|
onClick={() => handlePickProvider('gmail-oauth')}
|
|
className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors cursor-pointer text-left"
|
|
>
|
|
<Mail className="h-4 w-4 text-duck-dark/60 dark:text-foreground/60" />
|
|
<div>
|
|
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Gmail (OAuth)</p>
|
|
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">Uses your existing Google connection</p>
|
|
</div>
|
|
</button>
|
|
<button
|
|
onClick={() => handlePickProvider('gmail-password')}
|
|
className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors cursor-pointer text-left"
|
|
>
|
|
<KeyRound className="h-4 w-4 text-duck-dark/60 dark:text-foreground/60" />
|
|
<div>
|
|
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Gmail (App Password)</p>
|
|
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">
|
|
Direct IMAP with a Google App Password
|
|
</p>
|
|
</div>
|
|
</button>
|
|
<button
|
|
onClick={() => handlePickProvider('imap')}
|
|
className="flex items-center gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-3 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 transition-colors cursor-pointer text-left"
|
|
>
|
|
<Server className="h-4 w-4 text-duck-dark/60 dark:text-foreground/60" />
|
|
<div>
|
|
<p className="text-sm font-medium text-duck-dark dark:text-foreground">IMAP Account</p>
|
|
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">Any email provider with IMAP access</p>
|
|
</div>
|
|
</button>
|
|
<Button type="button" variant="ghost" onClick={resetForm} className="h-9 cursor-pointer">
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Gmail OAuth form */}
|
|
{activeForm === 'gmail-oauth' && (
|
|
<div className="grid gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
|
|
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Add Gmail (OAuth)</p>
|
|
<input
|
|
type="text"
|
|
value={form.displayName}
|
|
onChange={(ev) => setForm({ ...form, displayName: ev.target.value })}
|
|
placeholder="Display name (optional)"
|
|
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">
|
|
<Button type="button" variant="ghost" onClick={resetForm} className="h-9 cursor-pointer">
|
|
Cancel
|
|
</Button>
|
|
<Button type="button" onClick={handleGmailOAuth} disabled={saving} className="h-9 flex-1 cursor-pointer">
|
|
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Connect & Add'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Gmail App Password form */}
|
|
{activeForm === 'gmail-password' && (
|
|
<div className="grid gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
|
|
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Add Gmail (App Password)</p>
|
|
<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>
|
|
<input
|
|
type="email"
|
|
value={form.email}
|
|
onChange={(ev) => setForm({ ...form, email: 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"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={form.displayName}
|
|
onChange={(ev) => setForm({ ...form, displayName: ev.target.value })}
|
|
placeholder="Display name (optional)"
|
|
className="h-9 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-transparent px-3 text-sm"
|
|
/>
|
|
<input
|
|
type="password"
|
|
value={form.password}
|
|
onChange={(ev) => setForm({ ...form, password: ev.target.value })}
|
|
placeholder="xxxx xxxx xxxx xxxx"
|
|
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">
|
|
<Button type="button" variant="ghost" onClick={resetForm} className="h-9 cursor-pointer">
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
onClick={handlePasswordSubmit}
|
|
disabled={!form.email.trim() || !form.password.trim() || saving}
|
|
className="h-9 flex-1 cursor-pointer"
|
|
>
|
|
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Test & Save'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Generic IMAP form */}
|
|
{activeForm === 'imap' && (
|
|
<div className="grid gap-3 rounded-lg border border-duck-dark/10 dark:border-foreground/10 p-4">
|
|
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Add IMAP Account</p>
|
|
<input
|
|
type="email"
|
|
value={form.email}
|
|
onChange={(ev) => setForm({ ...form, email: ev.target.value })}
|
|
placeholder="you@example.com"
|
|
className="h-9 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-transparent px-3 text-sm"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={form.displayName}
|
|
onChange={(ev) => setForm({ ...form, displayName: ev.target.value })}
|
|
placeholder="Display name (optional)"
|
|
className="h-9 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-transparent px-3 text-sm"
|
|
/>
|
|
<div className="grid grid-cols-[1fr_auto_auto] gap-2">
|
|
<input
|
|
type="text"
|
|
value={form.imapHost}
|
|
onChange={(ev) => setForm({ ...form, imapHost: ev.target.value })}
|
|
placeholder="imap.example.com"
|
|
className="h-9 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-transparent px-3 text-sm"
|
|
/>
|
|
<input
|
|
type="number"
|
|
value={form.imapPort}
|
|
onChange={(ev) => setForm({ ...form, imapPort: ev.target.value })}
|
|
placeholder="993"
|
|
className="h-9 w-20 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-transparent px-3 text-sm"
|
|
/>
|
|
<label className="flex items-center gap-1.5 text-xs text-duck-dark/60 dark:text-foreground/60 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={form.imapSecure}
|
|
onChange={(ev) => setForm({ ...form, imapSecure: ev.target.checked })}
|
|
className="cursor-pointer"
|
|
/>
|
|
SSL
|
|
</label>
|
|
</div>
|
|
<input
|
|
type="password"
|
|
value={form.password}
|
|
onChange={(ev) => setForm({ ...form, password: ev.target.value })}
|
|
placeholder="Password"
|
|
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">
|
|
<Button type="button" variant="ghost" onClick={resetForm} className="h-9 cursor-pointer">
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
onClick={handlePasswordSubmit}
|
|
disabled={!form.email.trim() || !form.password.trim() || !form.imapHost.trim() || saving}
|
|
className="h-9 flex-1 cursor-pointer"
|
|
>
|
|
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Test & Save'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Add account button */}
|
|
{!showPicker && !activeForm && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => setShowPicker(true)}
|
|
className="w-full h-11 cursor-pointer gap-2"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Add Account
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const StatusBadge = ({ status }: { status: string }) => {
|
|
switch (status) {
|
|
case 'connected':
|
|
return (
|
|
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-yellow-500/10 text-yellow-600 dark:text-yellow-400">
|
|
Connected
|
|
</span>
|
|
);
|
|
case 'queued':
|
|
return (
|
|
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-orange-500/10 text-orange-600 dark:text-orange-400">
|
|
Queued
|
|
</span>
|
|
);
|
|
case 'syncing':
|
|
return (
|
|
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-blue-500/10 text-blue-600 dark:text-blue-400">
|
|
Syncing
|
|
</span>
|
|
);
|
|
case 'synced':
|
|
return (
|
|
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-green-500/10 text-green-600 dark:text-green-400">
|
|
Synced
|
|
</span>
|
|
);
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const ProviderIcon = ({ provider }: { provider: string }) => {
|
|
switch (provider) {
|
|
case 'gmail':
|
|
return <Mail className="h-4 w-4 text-red-500 shrink-0" />;
|
|
case 'outlook':
|
|
return <Mail className="h-4 w-4 text-blue-500 shrink-0" />;
|
|
default:
|
|
return <Server className="h-4 w-4 text-duck-dark/60 dark:text-foreground/60 shrink-0" />;
|
|
}
|
|
};
|