IMAP and Gmail sync into a per-account SQLite store, with the mail read and composed in the browser. moved whole from src/servers/sidecar/email (14 files), src/servers/api/email, officer_db/src/email, and the Screens/Dashboard/Email tree. what changed on the way out: - credentials are encrypted. the column was plaintext jsonb holding an IMAP app password that the SMTP transport read straight out of the row; it is now AES-256-GCM under this plugin's own 'email' secret-store purpose, sealed and opened in queries.ts alone so nothing above it can hold ciphertext by accident. no plaintext fallback on read - a value that does not verify is an error. - the router derives its prefix from mountPrefix() instead of the literal '/api/email', which was only correct for a first-party publisher. - the frontend became panels.ts + layout.ts. the two panel appTypes that were null are now declared, because the platform screen used to override them by panel id and a plugin has no such hook. - account management became a modal. it was a section in the platform's Settings, which a plugin cannot contribute to, and a panel does not work either: PluginScreen renders the workspace locked, so a panel outside the default layout is unreachable. it follows Compose, which had already solved this. - 17 palette classes replaced with semantic tokens. not verified: this has never run. it was switched off in the platform on 2026-08-13 and its table was never created, so nothing has exercised sync, sending or the UI since. the first install is the first test.
510 lines
20 KiB
TypeScript
510 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 {
|
|
const result = await client.post<{ ok: boolean; saved?: number }>(`/email/accounts/${id}/sync`, {});
|
|
if (result.saved !== undefined) {
|
|
toast.success(result.saved > 0 ? `${result.saved} new emails` : 'No new emails');
|
|
} else {
|
|
toast.success('Sync started');
|
|
setAccounts((prev) => prev.map((a) => (a.id === id ? { ...a, status: 'queued' } : a)));
|
|
}
|
|
fetchAccounts();
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Failed to 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-destructive 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-info 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-info 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-warning/10 text-warning dark:text-warning">
|
|
Connected
|
|
</span>
|
|
);
|
|
case 'queued':
|
|
return (
|
|
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-warning/10 text-warning dark:text-warning">
|
|
Queued
|
|
</span>
|
|
);
|
|
case 'syncing':
|
|
return (
|
|
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-info/10 text-info dark:text-info">
|
|
Syncing
|
|
</span>
|
|
);
|
|
case 'synced':
|
|
return (
|
|
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-success/10 text-success dark:text-success">
|
|
Synced
|
|
</span>
|
|
);
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const ProviderIcon = ({ provider }: { provider: string }) => {
|
|
switch (provider) {
|
|
case 'gmail':
|
|
return <Mail className="h-4 w-4 text-destructive shrink-0" />;
|
|
case 'outlook':
|
|
return <Mail className="h-4 w-4 text-info shrink-0" />;
|
|
default:
|
|
return <Server className="h-4 w-4 text-duck-dark/60 dark:text-foreground/60 shrink-0" />;
|
|
}
|
|
};
|