import type { JobListItem, PostApplicationInboxItem, PostApplicationProvider, PostApplicationSyncRun, } from "@shared/types"; import { POST_APPLICATION_PROVIDERS } from "@shared/types"; import { useQuery } from "@tanstack/react-query"; import { CheckCircle, Inbox, Link2, Loader2, RefreshCcw, Unplug, Upload, XCircle, } from "lucide-react"; import type React from "react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { useQueryErrorToast } from "@/client/hooks/useQueryErrorToast"; import { queryKeys } from "@/client/lib/queryKeys"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { formatDateTime } from "@/lib/utils"; import * as api from "../api"; import { EmptyState, PageHeader, PageMain } from "../components"; import { EmailViewerList } from "./tracking-inbox/EmailViewerList"; const PROVIDER_OPTIONS: PostApplicationProvider[] = [ ...POST_APPLICATION_PROVIDERS, ]; const GMAIL_OAUTH_RESULT_TYPE = "gmail-oauth-result"; const GMAIL_OAUTH_TIMEOUT_MS = 3 * 60 * 1000; const EMPTY_INBOX_ITEMS: PostApplicationInboxItem[] = []; const EMPTY_SYNC_RUNS: PostApplicationSyncRun[] = []; type GmailOauthResultMessage = { type: string; state?: string; code?: string; error?: string; }; function formatEpochMs(value?: number | null): string { if (!value) return "n/a"; return formatDateTime(new Date(value).toISOString()) ?? "n/a"; } export const TrackingInboxPage: React.FC = () => { const [provider, setProvider] = useState("gmail"); const [accountKey, setAccountKey] = useState("default"); const [maxMessages, setMaxMessages] = useState("100"); const [searchDays, setSearchDays] = useState("90"); const [isRefreshing, setIsRefreshing] = useState(false); const [isActionLoading, setIsActionLoading] = useState(false); const [activeAction, setActiveAction] = useState< "connect" | "sync" | "disconnect" | null >(null); const [isRunModalOpen, setIsRunModalOpen] = useState(false); const [selectedRun, setSelectedRun] = useState( null, ); const [appliedJobByMessageId, setAppliedJobByMessageId] = useState< Record >({}); const statusQuery = useQuery({ queryKey: queryKeys.postApplication.providerStatus(provider, accountKey), queryFn: () => api.postApplicationProviderStatus({ provider, accountKey }), enabled: Boolean(provider && accountKey), }); const inboxQuery = useQuery({ queryKey: queryKeys.postApplication.inbox(provider, accountKey, 100), queryFn: () => api.getPostApplicationInbox({ provider, accountKey, limit: 100 }), enabled: Boolean(provider && accountKey), }); const runsQuery = useQuery({ queryKey: queryKeys.postApplication.runs(provider, accountKey, 20), queryFn: () => api.getPostApplicationRuns({ provider, accountKey, limit: 20 }), enabled: Boolean(provider && accountKey), }); const status = statusQuery.data?.status ?? null; const inbox = inboxQuery.data?.items ?? EMPTY_INBOX_ITEMS; const runs = runsQuery.data?.runs ?? EMPTY_SYNC_RUNS; const runMessagesQuery = useQuery({ queryKey: queryKeys.postApplication.runMessages( selectedRun?.id ?? "", provider, accountKey, ), queryFn: () => api.getPostApplicationRunMessages({ runId: selectedRun?.id ?? "", provider, accountKey, }), enabled: Boolean( isRunModalOpen && selectedRun?.id && provider && accountKey, ), }); const selectedRunItems = runMessagesQuery.data?.items ?? EMPTY_INBOX_ITEMS; const isRunMessagesLoading = runMessagesQuery.isPending || runMessagesQuery.isFetching; const hasReviewItems = useMemo( () => inbox.length > 0 || selectedRunItems.length > 0, [inbox.length, selectedRunItems.length], ); const appliedJobsQuery = useQuery({ queryKey: queryKeys.jobs.list({ statuses: ["applied", "in_progress"], view: "list", }), queryFn: () => api.getJobs({ statuses: ["applied", "in_progress"], view: "list", }), enabled: hasReviewItems, }); const appliedJobs = useMemo( () => (appliedJobsQuery.data?.jobs ?? []).filter( (job) => job.status === "applied" || job.status === "in_progress", ), [appliedJobsQuery.data?.jobs], ); const isAppliedJobsLoading = appliedJobsQuery.isPending || appliedJobsQuery.isFetching; const [inboxActionDialog, setInboxActionDialog] = useState<{ isOpen: boolean; action: "approve" | "deny" | null; itemCount: number; }>({ isOpen: false, action: null, itemCount: 0 }); const isLoading = statusQuery.isPending || inboxQuery.isPending || runsQuery.isPending; const refresh = useCallback(async () => { setIsRefreshing(true); try { await Promise.all([ statusQuery.refetch(), inboxQuery.refetch(), runsQuery.refetch(), hasReviewItems ? appliedJobsQuery.refetch() : Promise.resolve(), ]); } catch (error) { const message = error instanceof Error ? error.message : "Failed to refresh tracking inbox"; toast.error(message); } finally { setIsRefreshing(false); } }, [appliedJobsQuery, hasReviewItems, inboxQuery, runsQuery, statusQuery]); useEffect(() => { if (!provider || !accountKey) return; setAppliedJobByMessageId({}); }, [provider, accountKey]); useEffect(() => { const defaultAppliedJobId = appliedJobs[0]?.id ?? ""; setAppliedJobByMessageId((previous) => { const next = { ...previous }; let didChange = false; for (const item of [...inbox, ...selectedRunItems]) { const selectedJobId = next[item.message.id]; const hasValidSelection = appliedJobs.some( (appliedJob) => appliedJob.id === selectedJobId, ); if (!selectedJobId || !hasValidSelection) { const matchedJobId = item.message.matchedJobId ?? ""; const hasValidMatchedJob = appliedJobs.some( (appliedJob) => appliedJob.id === matchedJobId, ); const nextJobId = hasValidMatchedJob ? matchedJobId : defaultAppliedJobId; if (next[item.message.id] !== nextJobId) { next[item.message.id] = nextJobId; didChange = true; } } } return didChange ? next : previous; }); }, [appliedJobs, inbox, selectedRunItems]); const waitForGmailOauthResult = useCallback( ( expectedState: string, popup: Window, ): Promise<{ code?: string; error?: string }> => { return new Promise((resolve, reject) => { let settled = false; const close = () => { window.clearTimeout(timeoutId); window.clearInterval(closedCheckId); window.removeEventListener("message", onMessage); }; const finishResolve = (value: { code?: string; error?: string }) => { if (settled) return; settled = true; close(); try { popup.close(); } catch { // Ignore cross-window close errors. } resolve(value); }; const finishReject = (message: string) => { if (settled) return; settled = true; close(); reject(new Error(message)); }; const onMessage = (event: MessageEvent) => { if (event.origin !== window.location.origin) return; const data = event.data as GmailOauthResultMessage | undefined; if (!data || data.type !== GMAIL_OAUTH_RESULT_TYPE) return; if (data.state !== expectedState) return; finishResolve({ ...(data.code ? { code: data.code } : {}), ...(data.error ? { error: data.error } : {}), }); }; const timeoutId = window.setTimeout(() => { finishReject("Timed out waiting for Gmail OAuth response."); }, GMAIL_OAUTH_TIMEOUT_MS); const closedCheckId = window.setInterval(() => { if (!popup.closed) return; finishReject("Gmail OAuth window was closed before completion."); }, 250); window.addEventListener("message", onMessage); }); }, [], ); const runProviderAction = useCallback( async (action: "connect" | "sync" | "disconnect") => { setIsActionLoading(true); setActiveAction(action); let syncToastId: string | number | null = null; try { if (action === "connect") { if (provider !== "gmail") { toast.error( `${provider} connect is not implemented yet. Use Gmail for now.`, ); return; } const oauthStart = await api.postApplicationGmailOauthStart({ accountKey, }); const popup = window.open( oauthStart.authorizationUrl, "gmail-oauth-connect", "popup,width=520,height=720", ); if (!popup) { toast.error( "Browser blocked the Gmail OAuth popup. Allow popups and retry.", ); return; } const oauthResult = await waitForGmailOauthResult( oauthStart.state, popup, ); if (oauthResult.error) { throw new Error(`Gmail OAuth failed: ${oauthResult.error}`); } if (!oauthResult.code) { throw new Error( "Gmail OAuth did not return an authorization code.", ); } await api.postApplicationGmailOauthExchange({ accountKey, state: oauthStart.state, code: oauthResult.code, }); toast.success("Provider connected"); } else if (action === "sync") { const parsedMaxMessages = Number.parseInt(maxMessages, 10); const parsedSearchDays = Number.parseInt(searchDays, 10); if ( !Number.isFinite(parsedMaxMessages) || parsedMaxMessages < 1 || parsedMaxMessages > 500 || !Number.isFinite(parsedSearchDays) || parsedSearchDays < 1 || parsedSearchDays > 365 ) { toast.error( "Max messages must be 1-500 and search days must be 1-365 before syncing.", ); return; } syncToastId = toast.loading( "Sync in progress. This may take up to a couple of minutes.", ); await api.postApplicationProviderSync({ provider, accountKey, maxMessages: parsedMaxMessages, searchDays: parsedSearchDays, }); toast.success("Sync completed", { ...(syncToastId ? { id: syncToastId } : {}), }); } else { await api.postApplicationProviderDisconnect({ provider, accountKey }); toast.success("Provider disconnected"); } await refresh(); } catch (error) { const message = error instanceof Error ? error.message : `Failed to ${action} provider connection`; if (syncToastId) { toast.error(message, { id: syncToastId }); } else { toast.error(message); } } finally { setActiveAction(null); setIsActionLoading(false); } }, [ accountKey, maxMessages, provider, refresh, searchDays, waitForGmailOauthResult, ], ); const handleDecision = useCallback( async (item: PostApplicationInboxItem, decision: "approve" | "deny") => { const selectedJobId = appliedJobByMessageId[item.message.id] || item.message.matchedJobId; if (decision === "approve" && !selectedJobId) { toast.error("Select an applied job before making a decision."); return; } setIsActionLoading(true); try { if (decision === "approve") { await api.approvePostApplicationInboxItem({ messageId: item.message.id, provider, accountKey, jobId: selectedJobId ?? undefined, stageTarget: item.message.stageTarget ?? undefined, }); toast.success("Message linked"); } else { await api.denyPostApplicationInboxItem({ messageId: item.message.id, provider, accountKey, }); toast.success("Message ignored"); } await refresh(); } catch (error) { const message = error instanceof Error ? error.message : `Failed to ${decision} message`; toast.error(message); } finally { setIsActionLoading(false); } }, [accountKey, appliedJobByMessageId, provider, refresh], ); const handleInboxAction = useCallback( async (action: "approve" | "deny") => { if (inbox.length === 0) return; setIsActionLoading(true); setInboxActionDialog({ isOpen: false, action: null, itemCount: 0 }); try { const result = await api.runPostApplicationInboxAction({ action, provider, accountKey, }); const { succeeded, failed, skipped } = result; const actionLabel = action === "approve" ? "approved" : "ignored"; if (failed === 0 && skipped === 0) { toast.success(`All ${succeeded} messages ${actionLabel}`); } else if (failed === 0) { toast.success( `${succeeded} messages ${actionLabel}, ${skipped} skipped (no suggested match)`, ); } else { toast.error( `${succeeded} ${actionLabel}, ${failed} failed, ${skipped} skipped`, ); } await refresh(); } catch (error) { const message = error instanceof Error ? error.message : `Failed to ${action} messages`; toast.error(message); } finally { setIsActionLoading(false); } }, [accountKey, inbox.length, provider, refresh], ); const openInboxActionDialog = useCallback( (action: "approve" | "deny") => { const eligibleCount = action === "approve" ? inbox.filter((item) => item.matchedJob).length : inbox.length; if (eligibleCount === 0) { toast.error( action === "approve" ? "No messages with suggested job matches to approve" : "No messages to ignore", ); return; } setInboxActionDialog({ isOpen: true, action, itemCount: eligibleCount, }); }, [inbox], ); const handleOpenRunMessages = useCallback((run: PostApplicationSyncRun) => { setSelectedRun(run); setIsRunModalOpen(true); }, []); useQueryErrorToast( statusQuery.error, "Failed to load provider connection status", ); useQueryErrorToast(inboxQuery.error, "Failed to load inbox"); useQueryErrorToast(runsQuery.error, "Failed to load sync runs"); useQueryErrorToast( appliedJobsQuery.error, "Failed to load jobs for inbox matching", ); useQueryErrorToast( runMessagesQuery.error, "Failed to load messages for selected sync run", ); const pendingCount = inbox.length; const isConnected = Boolean(status?.connected); const connectionLabel = useMemo(() => { if (!status) return "Unknown"; if (!status.connected) return "Disconnected"; if (status.integration?.status === "error") return "Error"; return "Connected"; }, [status]); const handleAppliedJobChange = useCallback( (messageId: string, value: string) => { setAppliedJobByMessageId((previous) => ({ ...previous, [messageId]: value, })); }, [], ); return ( <> void refresh()} disabled={isRefreshing || isLoading} className="gap-2" > {isRefreshing ? ( ) : ( )} Refresh } />

Application Inbox Matching

Connect your inbox to ingest related emails, review the suggested job matches, and approve or deny to automatically update your tracking timeline.

Provider Controls
setAccountKey(event.target.value)} />

Gmail connect uses Google OAuth popup and stores credentials server-side. No manual refresh token paste is needed.

setMaxMessages(event.target.value)} />
setSearchDays(event.target.value)} />
{!isConnected ? ( ) : null} {isConnected ? ( ) : null}
{connectionLabel} Pending review:{" "} {pendingCount} {status?.integration?.lastSyncedAt ? ( Last synced: {formatEpochMs(status.integration.lastSyncedAt)} ) : null}
Pending Review Queue {inbox.length > 0 && (
)}
{isLoading ? (
Loading inbox...
) : inbox.length === 0 ? ( ) : ( void handleDecision(item, decision) } isActionLoading={isActionLoading} isAppliedJobsLoading={isAppliedJobsLoading} /> )}
Recent Sync Runs {runs.length === 0 ? (

No sync runs yet.

) : (
{runs.map((run) => ( ))}
)}
{ setIsRunModalOpen(open); if (!open) { setSelectedRun(null); } }} > Run Messages {selectedRun ? `Run ${selectedRun.id} • discovered ${selectedRun.messagesDiscovered} • relevant ${selectedRun.messagesRelevant} • matched ${selectedRun.messagesMatched}` : "Review all messages captured in this sync run, including partial matches."}
{isRunMessagesLoading ? (
Loading run messages...
) : selectedRunItems.length === 0 ? (

No messages found for this run.

) : ( void handleDecision(item, decision) } isActionLoading={isActionLoading} isAppliedJobsLoading={isAppliedJobsLoading} /> )}
setInboxActionDialog((previous) => ({ ...previous, isOpen: open })) } > {inboxActionDialog.action === "approve" ? "Approve All Messages?" : "Ignore All Messages?"} {inboxActionDialog.action === "approve" ? `This will approve ${inboxActionDialog.itemCount} message${inboxActionDialog.itemCount === 1 ? "" : "s"} with suggested job matches. Messages without matches will be skipped.` : `This will ignore all ${inboxActionDialog.itemCount} pending message${inboxActionDialog.itemCount === 1 ? "" : "s"}.`} Cancel { if (inboxActionDialog.action) { void handleInboxAction(inboxActionDialog.action); } }} > {inboxActionDialog.action === "approve" ? "Approve All" : "Ignore All"} ); };