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:
2026-03-04 23:13:55 +00:00
co-authored by Claude Opus 4.6
parent 8737681464
commit aa0207436c
7 changed files with 146 additions and 43 deletions
@@ -3,6 +3,8 @@ 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 = {
@@ -20,8 +22,17 @@ const formatTime = (ts: number | string) => {
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 [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);
@@ -34,7 +45,10 @@ export const GoogleAccount = () => {
const fetchStatus = () => {
client
.get<GoogleStatus>('/integrations/google/status')
.then(setStatus)
.then((s) => {
setStatus(s);
setGmailEmail(s.email ?? user?.email ?? '');
})
.catch(() => {})
.finally(() => setIsLoading(false));
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(() => {
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]);
@@ -93,11 +111,14 @@ export const GoogleAccount = () => {
};
const handleSaveAppPassword = async () => {
if (!appPassword.trim()) return;
if (!appPassword.trim() || !gmailEmail.trim()) return;
setSavingPassword(true);
try {
await client.put('/integrations/google/app-password', { appPassword: appPassword.trim() });
setStatus({ ...status, hasAppPassword: true });
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');
@@ -130,6 +151,9 @@ export const GoogleAccount = () => {
{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"
@@ -158,23 +182,32 @@ export const GoogleAccount = () => {
<li>Copy the 16-character password and paste it below</li>
</ol>
</div>
<div className="flex gap-2">
<div className="grid 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"
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"
/>
<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 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>
</>
)}
@@ -250,7 +283,12 @@ export const GoogleAccount = () => {
<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" />
<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">