From 0f6de132f8a7a640090de34a54dc79de01514220 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Mon, 31 Aug 2026 17:03:21 +0500 Subject: [PATCH 1/2] make sync visible --- frontend/src/screens/Inbox.jsx | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index e15ce4e..2e3baf4 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -959,6 +959,24 @@ export default function Inbox() { markDuplicate.mutate({ id: item.id, isDuplicate: !item.duplicate, kind: item.kind }) } + const sync = useMutation({ + mutationFn: () => inboxApi.syncMailbox(), + onSuccess: async (res) => { + await qc.invalidateQueries({ queryKey: qk.mailbox.all() }) + // /email/fetch reports what the intake gate did with the page. The key is + // additive and absent when the gate is disabled, so fall back to the old + // message rather than rendering "undefined filtered out". + const t = res?.triage + toast( + t + ? `Mailbox synced — ${t.ingested} imported, ${t.skipped} filtered out` + : 'Mailbox synced', + 'success', + ) + }, + onError: (err) => toast(friendlyAuthError(err, 'Sync failed'), 'error'), + }) + return (
Google Sheets · Form data )} + {!isForms && ( + + )} From 42e590e71d6f30dab5919498e8be240cc7aa3eac Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Mon, 31 Aug 2026 17:35:34 +0500 Subject: [PATCH 2/2] . --- backend/inbox/mailbox_sync_tasks.py | 19 +++++- backend/inbox/views.py | 9 ++- frontend/src/screens/Inbox.jsx | 101 +++++++++++++++++++++------- frontend/src/styles/styles.css | 28 +++++++- frontend/src/ui/SyncButton.jsx | 52 ++++++++++++++ 5 files changed, 183 insertions(+), 26 deletions(-) create mode 100644 frontend/src/ui/SyncButton.jsx 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 2e3baf4..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,24 +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.syncMailbox(), - onSuccess: async (res) => { - await qc.invalidateQueries({ queryKey: qk.mailbox.all() }) - // /email/fetch reports what the intake gate did with the page. The key is - // additive and absent when the gate is disabled, so fall back to the old - // message rather than rendering "undefined filtered out". - const t = res?.triage - toast( - t - ? `Mailbox synced — ${t.ingested} imported, ${t.skipped} filtered out` - : 'Mailbox synced', - 'success', - ) + 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 (
Google Sheets · Form data )} {!isForms && ( - + sync.mutate()} + /> )} + ) +}