diff --git a/backend/inbox/mailbox_sync_tasks.py b/backend/inbox/mailbox_sync_tasks.py index e4f90d9..d574df0 100644 --- a/backend/inbox/mailbox_sync_tasks.py +++ b/backend/inbox/mailbox_sync_tasks.py @@ -69,9 +69,26 @@ async def sync_mailbox(run_id:str) -> dict: service=Email(session=session) if not service.token: return await _fail(run_id,"EMAIL_API_TOKEN is not configured") + + async def on_progress(processed,expected,entries): + ingested=sum(1 for e in entries if e.get("status") in ("ingested","known")) + skipped=sum(1 for e in entries if e.get("status")=="skipped") + errors=sum(1 for e in entries if e.get("status")=="error") + await MailboxSyncRun.update_run(session,run_id,{ + "entries":list(entries), + "triage":{ + "expected":expected, + "processed":processed, + "ingested":ingested, + "skipped":skipped, + "errors":errors, + "total":expected, + }, + }) + try: summary=await service.run_mailbox_sync_page( - top=top,skip=skip,test_on=test_on, + top=top,skip=skip,test_on=test_on,on_progress=on_progress, ) except Exception as e: logger.exception("mailbox sync failed for run %s",run_id) diff --git a/backend/inbox/views.py b/backend/inbox/views.py index 956da03..c569990 100644 --- a/backend/inbox/views.py +++ b/backend/inbox/views.py @@ -356,13 +356,18 @@ class Email: raise HTTPException(status_code=404,detail="No sync runs yet") return serialize_mailbox_sync_run(row) - async def run_mailbox_sync_page(self,top=100,skip=0,test_on=True): + async def run_mailbox_sync_page(self,top=100,skip=0,test_on=True,on_progress=None): """Pull one Outlook page, triage, ingest, enqueue matching. Returns summary. Shared by the legacy synchronous /email/fetch and the background Taskiq worker. + `on_progress(processed, expected, entries)` is optional; the mailbox_sync + worker uses it so the Sync button can poll a live percentage. """ data=await self.service_email(top,skip) value=data.get("value") or [] + expected=len(value) + if on_progress: + await on_progress(0,expected,[]) decisions=await self.triage_round([item.get("id") for item in value]) entries=[] for item in value: @@ -398,6 +403,8 @@ class Email: "triage_status":"error", }) self.triage_errors.append(str(message_id)) + if on_progress: + await on_progress(len(entries),expected,entries) if self.pending_match_ids: await self.enqueue_matching(list(self.pending_match_ids),force=False) diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index e15ce4e..9657137 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -15,6 +15,7 @@ import Modal from '../ui/Modal' import EmailBody, { looksLikeHtml } from '../ui/EmailBody' import OpenResumeButton from '../ui/OpenResumeButton' import PageHeader from '../ui/PageHeader' +import SyncButton from '../ui/SyncButton' import { Tabs } from '../ui/Tabs' import { DEFAULT_PAGE_SIZE, Pagination, pageWindow } from '../ui/DataTable' import { Avatar, Badge, EmptyState, Icon, ScoreChip, SkeletonRows } from '../ui/primitives' @@ -38,6 +39,25 @@ const FORM_TABS = ['All Applications', 'Processed', 'Rejected', 'Duplicates'] /** Inbox GET `top` / sheet GET `limit` both cap at 500. */ const PAGE_SIZE_MAX = 500 +const SYNC_RUN_KEY = 'mailbox_sync_run_id' + +function readStoredSyncRunId() { + try { + return localStorage.getItem(SYNC_RUN_KEY) || null + } catch { + return null + } +} + +function storeSyncRunId(id) { + try { + if (id) localStorage.setItem(SYNC_RUN_KEY, id) + else localStorage.removeItem(SYNC_RUN_KEY) + } catch { + /* private mode / quota — polling still works in-session */ + } +} + /** Inbox channel: Outlook email queue vs imported Google Form rows. */ const CHANNELS = [ { key: 'email', label: 'Email', icon: 'mail' }, @@ -959,6 +979,63 @@ export default function Inbox() { markDuplicate.mutate({ id: item.id, isDuplicate: !item.duplicate, kind: item.kind }) } + const [syncRunId, setSyncRunId] = useState(() => readStoredSyncRunId()) + const syncToastShown = useRef(null) + + const sync = useMutation({ + mutationFn: () => inboxApi.startMailboxSync(), + onSuccess: (res) => { + const id = res?.data?.id + if (id) { + storeSyncRunId(id) + setSyncRunId(id) + } + toast('Mailbox sync started — safe to leave this page', 'info') + }, + onError: (err) => toast(friendlyAuthError(err, 'Sync failed'), 'error'), + }) + + const syncRun = useQuery({ + queryKey: qk.mailbox.sync(syncRunId), + queryFn: async () => { + const res = await inboxApi.getMailboxSync(syncRunId) + return res?.data ?? null + }, + enabled: Boolean(syncRunId), + refetchInterval: (q) => { + const status = q.state.data?.status + return status === 'queued' || status === 'running' ? 800 : false + }, + }) + + useEffect(() => { + const run = syncRun.data + if (!run?.id) return undefined + if (run.status === 'completed' && syncToastShown.current !== run.id) { + syncToastShown.current = run.id + const t = run.triage + toast( + t + ? `Mailbox synced — ${t.ingested ?? 0} imported, ${t.skipped ?? 0} filtered out` + : 'Mailbox synced', + 'success', + ) + qc.invalidateQueries({ queryKey: qk.mailbox.all() }) + const timer = setTimeout(() => { + storeSyncRunId(null) + setSyncRunId(null) + }, 1800) + return () => clearTimeout(timer) + } + if (run.status === 'failed' && syncToastShown.current !== run.id) { + syncToastShown.current = run.id + toast(run.error || 'Sync failed', 'error') + storeSyncRunId(null) + setSyncRunId(null) + } + return undefined + }, [qc, syncRun.data, toast]) + return (