feat(imap): resilient batched sync, stale-session cleanup, and live progress UI

- Add SyncFull trigger and configurable IMAP socket read timeout
  - Replace streaming UID FETCH with UID SEARCH ALL + batched fetch so per-message progress stays responsive and throttling servers can retry with reconnection
  - Finalize stale Running sessions on startup so interrupted syncs no longer show a phantom "syncing" state
  - Show a live syncing pill on the account row; add elapsed time, current folder, and slow-server warning styling in the dialog
This commit is contained in:
rustmailer
2026-08-04 17:47:42 +08:00
parent 9b1f6cae8c
commit 75d345b742
30 changed files with 831 additions and 438 deletions

View File

@@ -42,6 +42,7 @@ export enum DownloadStatus {
export enum TriggerType {
Manual = "Manual",
Scheduled = "Scheduled",
SyncFull = "SyncFull",
}
export enum FolderStatus {

View File

@@ -24,7 +24,9 @@ import { useTranslation } from 'react-i18next';
import { useCurrentUser } from '@/hooks/use-current-user';
import { toast } from '@/hooks/use-toast';
import { ToastAction } from '@/components/ui/toast';
import { AccountModel } from '@/api/account/api';
import { useQuery } from '@tanstack/react-query'
import { download_state, AccountModel, DownloadStatus } from '@/api/account/api';
import { Loader2 } from 'lucide-react'
interface Props {
row: Row<AccountModel>
@@ -44,29 +46,50 @@ export function RunningStateCellAction({ row }: Props) {
}
const hasPermission = require_any_permission(['system:root', 'account:read_details'], row.original.id)
// Live sync status pill. While a download session is running, poll every 5s
// so the list always reflects progress without needing the dialog open.
const { data: state } = useQuery({
queryKey: ['running-state', row.original.id],
queryFn: () => download_state(row.original.id),
refetchInterval: (query) => {
const s = query.state.data?.active_session
return s && s.status === DownloadStatus.Running ? 5000 : false
},
})
const running = state?.active_session
const isRunning = !!running && running.status === DownloadStatus.Running
return (
<Button variant='ghost' className="h-auto p-1" onClick={() => {
if (hasPermission) {
setCurrentRow(row.original)
setOpen('running-state')
} else {
toast({
variant: 'destructive',
title: 'Forbidden',
description: 'You do not have permission to view this account.',
action: (
<ToastAction altText="Close">
Close
</ToastAction>
),
})
}
}}>
<span
className="text-xs text-primary cursor-pointer underline underline-offset-2 hover:opacity-80 transition-opacity"
>
{t('accounts.viewDetails')}
</span>
</Button>
<div className="flex items-center justify-center gap-2">
{isRunning && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-blue-500/10 text-blue-600 border border-blue-500/20 px-2 py-0.5 text-[11px] font-medium shrink-0">
<Loader2 className="h-3 w-3 animate-spin" />
{t('accounts.runningState.syncing')}
</span>
)}
<Button variant='ghost' className="h-auto p-1" onClick={() => {
if (hasPermission) {
setCurrentRow(row.original)
setOpen('running-state')
} else {
toast({
variant: 'destructive',
title: 'Forbidden',
description: 'You do not have permission to view this account.',
action: (
<ToastAction altText="Close">
Close
</ToastAction>
),
})
}
}}>
<span
className="text-xs text-primary cursor-pointer underline underline-offset-2 hover:opacity-80 transition-opacity"
>
{t('accounts.viewDetails')}
</span>
</Button>
</div>
)
}

View File

@@ -26,7 +26,8 @@ import {
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { useQuery } from '@tanstack/react-query'
import { download_state, AccountModel, FolderProgress } from '@/api/account/api'
import { useEffect, useState } from 'react'
import { download_state, DownloadStatus, AccountModel, FolderProgress } from '@/api/account/api'
import { format } from 'date-fns'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Badge } from '@/components/ui/badge'
@@ -114,11 +115,19 @@ function FolderDetailItem({ f, t }: { f: FolderProgress, t: (key: string) => str
{f.message && (
<div className="px-4 pb-4">
<div className="bg-muted/50 border rounded-lg p-3 flex gap-3 items-start">
<Info className="w-4 h-4 text-muted-foreground mt-0.5 shrink-0" />
<div className={`border rounded-lg p-3 flex gap-3 items-start ${f.message.toLowerCase().includes('slow') || f.message.toLowerCase().includes('limiting')
? 'bg-amber-500/10 border-amber-500/30'
: 'bg-muted/50'}`}>
{f.message.toLowerCase().includes('slow') || f.message.toLowerCase().includes('limiting') ? (
<AlertTriangle className="w-4 h-4 text-amber-600 mt-0.5 shrink-0" />
) : (
<Info className="w-4 h-4 text-muted-foreground mt-0.5 shrink-0" />
)}
<div className="space-y-0.5">
<p className="text-[10px] font-bold text-foreground">{t('accounts.runningState.message')}:</p>
<p className="text-[10px] font-medium text-muted-foreground leading-relaxed">{f.message}</p>
<p className={`text-[10px] font-medium leading-relaxed ${f.message.toLowerCase().includes('slow') || f.message.toLowerCase().includes('limiting')
? 'text-amber-700'
: 'text-muted-foreground'}`}>{f.message}</p>
</div>
</div>
</div>
@@ -133,12 +142,37 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
const { data: state, isLoading } = useQuery({
queryKey: ['running-state', currentRow.id],
queryFn: () => download_state(currentRow.id),
refetchInterval: 5000,
refetchInterval: (query) => {
const s = query.state.data?.active_session
return s && s.status === DownloadStatus.Running ? 5000 : false
},
enabled: open && !!currentRow.id,
})
const session = state?.active_session
const history = state?.history || []
const isRunning = !!session && session.status === DownloadStatus.Running
// When the poll is active, tick once per second so the "elapsed" and
// "last updated Xs ago" readouts stay fresh between refetches.
const [, setTick] = useState(0)
useEffect(() => {
if (!isRunning) return
const id = setInterval(() => setTick((v) => v + 1), 1000)
return () => clearInterval(id)
}, [isRunning])
const elapsedSec = session && isRunning
? Math.max(0, Math.floor((Date.now() - new Date(session.start_time).getTime()) / 1000))
: 0
const formatDur = (s: number) => {
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const sec = s % 60
if (h > 0) return `${h}h ${m}m ${sec}s`
if (m > 0) return `${m}m ${sec}s`
return `${sec}s`
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -202,6 +236,23 @@ export function RunningStateDialog({ currentRow, open, onOpenChange }: Props) {
</div>
</div>
</div>
{isRunning && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="p-3 sm:p-4 rounded-xl border bg-card shadow-sm flex items-center justify-between sm:block">
<p className="text-[10px] font-bold text-muted-foreground uppercase mb-1">{t('accounts.runningState.session.elapsed')}</p>
<div className="text-sm font-bold font-mono text-blue-600 flex items-center gap-1.5">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{formatDur(elapsedSec)}
</div>
</div>
{session.current_folder && (
<div className="p-3 sm:p-4 rounded-xl border bg-card shadow-sm flex items-center justify-between sm:block">
<p className="text-[10px] font-bold text-muted-foreground uppercase mb-1">{t('accounts.runningState.session.current_folder')}</p>
<div className="text-sm font-bold text-foreground truncate">{session.current_folder}</div>
</div>
)}
</div>
)}
<Tabs defaultValue="folders" className="w-full">
<TabsList className="bg-muted mb-3 h-8">
<TabsTrigger value="folders" className="text-[11px] font-bold">

View File

@@ -293,10 +293,14 @@
},
"message": "رسالة",
"session": {
"current_folder": "مجلد البريد الحالي",
"elapsed": "الوقت المنقضي",
"last_update": "آخر تحديث",
"started_at": "وقت البدء",
"status": "الحالة",
"trigger": "المشغّل"
"trigger": "طريقة المشغّل"
},
"syncing": "جاري المزامنة",
"tabs": {
"active_session": "الجلسة النشطة",
"errors": "أخطاء",

View File

@@ -293,10 +293,14 @@
},
"message": "Besked",
"session": {
"current_folder": "Nuværende postmappe",
"elapsed": "Varighed",
"last_update": "Senest opdateret",
"started_at": "Starttid",
"status": "Status",
"trigger": "Trigger"
"trigger": "Udløsermetode"
},
"syncing": "Synkroniserer",
"tabs": {
"active_session": "Aktiv session",
"errors": "Fejl",

View File

@@ -293,10 +293,14 @@
},
"message": "Nachricht",
"session": {
"current_folder": "Aktueller Ordner",
"elapsed": "Dauer",
"last_update": "Zuletzt aktualisiert",
"started_at": "Startzeit",
"status": "Status",
"trigger": "Auslöser"
},
"syncing": "Synchronisieren...",
"tabs": {
"active_session": "Aktive Sitzung",
"errors": "Fehler",

View File

@@ -295,10 +295,14 @@
},
"message": "Message",
"session": {
"current_folder": "Current mail folder",
"elapsed": "Elapsed time",
"last_update": "Last updated",
"started_at": "Started At",
"status": "Status",
"trigger": "Trigger"
},
"syncing": "Syncing",
"tabs": {
"active_session": "Active Session",
"errors": "Errors",

View File

@@ -293,10 +293,14 @@
},
"message": "Mensaje",
"session": {
"current_folder": "Carpeta de correo actual",
"elapsed": "Tiempo transcurrido",
"last_update": "Última actualización",
"started_at": "Hora de inicio",
"status": "Estado",
"trigger": "Disparador"
},
"syncing": "Sincronizando",
"tabs": {
"active_session": "Sesión activa",
"errors": "Errores",

View File

@@ -293,10 +293,14 @@
},
"message": "Viesti",
"session": {
"current_folder": "Nykyinen postikansio",
"elapsed": "Kesto",
"last_update": "Viimeksi päivitetty",
"started_at": "Aloitusaika",
"status": "Tila",
"trigger": "Laukaisija"
"trigger": "Käynnistystapa"
},
"syncing": "Synkronoidaan",
"tabs": {
"active_session": "Aktiivinen istunto",
"errors": "Virheet",

View File

@@ -293,10 +293,14 @@
},
"message": "Message",
"session": {
"current_folder": "Dossier de courrier actuel",
"elapsed": "Temps écoulé",
"last_update": "Dernière mise à jour",
"started_at": "Heure de début",
"status": "Statut",
"trigger": "Déclencheur"
},
"syncing": "Synchronisation",
"tabs": {
"active_session": "Session active",
"errors": "Erreurs",

View File

@@ -293,10 +293,14 @@
},
"message": "Messaggio",
"session": {
"current_folder": "Cartella posta corrente",
"elapsed": "Tempo trascorso",
"last_update": "Ultimo aggiornamento",
"started_at": "Ora di inizio",
"status": "Stato",
"trigger": "Trigger"
"trigger": "Innesco"
},
"syncing": "Sincronizzazione in corso",
"tabs": {
"active_session": "Sessione attiva",
"errors": "Errori",

View File

@@ -293,10 +293,14 @@
},
"message": "メッセージ",
"session": {
"current_folder": "現在のメールフォルダー",
"elapsed": "経過時間",
"last_update": "最終更新",
"started_at": "開始時刻",
"status": "状態",
"trigger": "トリガー"
},
"syncing": "同期中",
"tabs": {
"active_session": "実行中タスク",
"errors": "エラー",

View File

@@ -293,10 +293,14 @@
},
"message": "메시지",
"session": {
"current_folder": "현재 메일함",
"elapsed": "경과 시간",
"last_update": "최근 업데이트",
"started_at": "시작 시간",
"status": "상태",
"trigger": "트리거"
},
"syncing": "동기화 중",
"tabs": {
"active_session": "현재 작업",
"errors": "오류",

View File

@@ -293,10 +293,14 @@
},
"message": "Bericht",
"session": {
"current_folder": "Huidige e-mailmap",
"elapsed": "Verstreken tijd",
"last_update": "Laatst bijgewerkt",
"started_at": "Starttijd",
"status": "Status",
"trigger": "Trigger"
"trigger": "Triggermethode"
},
"syncing": "Synchroniseren",
"tabs": {
"active_session": "Actieve sessie",
"errors": "Fouten",

View File

@@ -293,10 +293,14 @@
},
"message": "Melding",
"session": {
"current_folder": "Nåværende e-postmappe",
"elapsed": "Varighet",
"last_update": "Sist oppdatert",
"started_at": "Starttid",
"status": "Status",
"trigger": "Trigger"
"trigger": "Utløser"
},
"syncing": "Synkroniserer",
"tabs": {
"active_session": "Aktiv økt",
"errors": "Feil",

View File

@@ -293,10 +293,14 @@
},
"message": "Wiadomość",
"session": {
"current_folder": "Bieżący folder poczty",
"elapsed": "Czas trwania",
"last_update": "Ostatnia aktualizacja",
"started_at": "Czas rozpoczęcia",
"status": "Status",
"trigger": "Wyzwalacz"
},
"syncing": "Synchronizowanie",
"tabs": {
"active_session": "Aktywna sesja",
"errors": "Błędy",

View File

@@ -293,10 +293,14 @@
},
"message": "Mensagem",
"session": {
"current_folder": "Pasta de e-mail atual",
"elapsed": "Tempo decorrido",
"last_update": "Última atualização",
"started_at": "Hora de início",
"status": "Status",
"trigger": "Gatilho"
},
"syncing": "Sincronizando",
"tabs": {
"active_session": "Sessão ativa",
"errors": "Erros",

View File

@@ -293,10 +293,14 @@
},
"message": "Сообщение",
"session": {
"current_folder": "Текущая почтовая папка",
"elapsed": "Прошло времени",
"last_update": "Последнее обновление",
"started_at": "Время начала",
"status": "Статус",
"trigger": "Триггер"
},
"syncing": "Синхронизация",
"tabs": {
"active_session": "Активная сессия",
"errors": "Ошибки",

View File

@@ -293,10 +293,14 @@
},
"message": "Meddelande",
"session": {
"current_folder": "Aktuell e-postmapp",
"elapsed": "Tidsåtgång",
"last_update": "Senast uppdaterad",
"started_at": "Starttid",
"status": "Status",
"trigger": "Trigger"
"trigger": "Utlösare"
},
"syncing": "Synkroniserar",
"tabs": {
"active_session": "Aktiv session",
"errors": "Fel",

View File

@@ -293,10 +293,14 @@
},
"message": "訊息",
"session": {
"current_folder": "目前郵件資料夾",
"elapsed": "耗時",
"last_update": "最近更新",
"started_at": "開始時間",
"status": "狀態",
"trigger": "觸發方式"
},
"syncing": "同步中",
"tabs": {
"active_session": "目前任務",
"errors": "錯誤",

View File

@@ -295,10 +295,14 @@
},
"message": "消息",
"session": {
"current_folder": "当前邮件夹",
"elapsed": "耗时",
"last_update": "最近更新",
"started_at": "开始时间",
"status": "状态",
"trigger": "触发方式"
},
"syncing": "同步中",
"tabs": {
"active_session": "当前任务",
"errors": "错误",