/* ============================================================ Recruitment Inbox — six seed-backed tabs plus the Email tab, which is the app's oldest real network call (GET /inbox/fetch, previously the only fetch in the entire prototype). The email body used to be interpolated raw into markup at js/inbox.js:292 — the single widest XSS sink in the repository, and the one that mattered most because inbound mail is attacker-supplied by definition. It renders as text now, which is the structural fix. ============================================================ */ import { useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import Modal from '../ui/Modal' import { Tabs } from '../ui/Tabs' import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives' import { useToast } from '../ui/Toast' import { seedQuery, useSeedMutation } from '../data/seedQueries' import { qk } from '../lib/queryKeys' import { friendlyAuthError } from '../lib/errors' import * as inboxApi from '../api/inbox' import { atsRecommendationClass, avatarColor, companies, fmtDate, fmtShort, getJob, initials as initialsOf, inboxSources, int, locations, pick, relTime, sourceMeta, TODAY, } from '../data/seed' const TABS = ['All Applications', 'Unread', 'Imported', 'Processed', 'Rejected', 'Duplicates', 'Email'] /** The prototype computed "time ago" against a fixed 2026-07-09T20:00. */ const NOW = new Date('2026-07-09T20:00') /** * The seed candidate record importEmail() writes needs a number. The agent * returns a verdict, not a score, so there is nothing on the wire to use — * named here so the fabricated value is visible at its point of use instead of * arriving disguised as a server field on every message. */ const SEED_ATS_SCORE = 70 /** * message_received_time / message_sent_time are plain string columns * (backend/inbox/models.py:54-56), not timestamps. An unparseable value yields * an Invalid Date that every fmt* helper renders as the literal "Invalid Date", * so return null instead and let the call sites decide what to show. */ function parseDate(value) { if (!value) return null const d = new Date(value) return Number.isNaN(d.getTime()) ? null : d } /** * `source` arrives as the raw To address, because that is where the board tag * lands — careers-rozee@, employee-referral@, mustakbil@ and so on. Strip * everything but letters from both sides so "Employee Referral" still matches * "employee-referral@", and keep the brand colour SourceChip paints from. * Nothing matches -> show the first recipient verbatim rather than guess. */ function sourceFrom(messageTo) { const raw = (messageTo || '').trim() if (!raw) return { source: 'Unknown', sourceMeta: null } const flat = raw.toLowerCase().replace(/[^a-z]/g, '') const hit = inboxSources.find((s) => flat.includes(s.toLowerCase().replace(/[^a-z]/g, ''))) if (hit) return { source: hit, sourceMeta: sourceMeta[hit] } return { source: raw.split(',')[0].trim(), sourceMeta: null } } function SourceChip({ item }) { // The dot carries the partner's brand colour; the label uses theme text — // 11px labels in the partner colour failed AA in both themes. return ( {item.source} ) } /** * GET /inbox/all-applications -> the shape the application tabs render. * * READ-ONLY: inbox_messages has no columns for processing state, duplicates, * recruiter, phone, experience or an ATS score, so those arrive null and every * mutating action on these tabs is disabled until the endpoints exist. * `processing` is derived from message_read alone, which is why the Imported / * Processed / Rejected / Duplicates tabs read empty. */ async function fetchApplications(params) { const res = await inboxApi.listApplications(params) const rows = Array.isArray(res?.data) ? res.data : [] return rows.map((row) => { const name = row.name || row.email || 'Unknown' return { id: String(row.id), name, initials: initialsOf(name), color: avatarColor(name), email: row.email || '', position: row.position || '(no subject)', ...sourceFrom(row.source), received: parseDate(row.received), unread: Boolean(row.unread), processing: row.processing || 'Unread', resumeStatus: row.resume_status || 'Pending', attachment: row.attachment, hasAttachment: Boolean(row.has_attachment), resumeText: row.resume_text || '', atsScore: row.ats_score, phone: row.phone, experience: row.experience, recruiter: row.recruiter, duplicate: Boolean(row.duplicate), } }) } /** * POST /inbox/{record_id}/read — flips message_read false -> true for one row. * * Optimistic, so the row un-bolds on click instead of after the round trip, and * rolls back if the server rejects. Both mailbox caches hold {id, unread} rows, * so one setQueriesData over qk.mailbox.all() covers the Email tab and the * application tabs at once; `processing` is derived from the same column, so it * moves with it. * * NOTE: the route requires INBOX_EDIT while the lists only require INBOX_VIEW, * so a view-only user gets a 403 here and the row snaps back to unread. */ function useMarkRead(toast) { const qc = useQueryClient() return useMutation({ mutationFn: (recordId) => inboxApi.markRead(recordId), onMutate: async (recordId) => { await qc.cancelQueries({ queryKey: qk.mailbox.all() }) const previous = qc.getQueriesData({ queryKey: qk.mailbox.all() }) qc.setQueriesData({ queryKey: qk.mailbox.all() }, (rows) => ( Array.isArray(rows) ? rows.map((r) => (r.id === recordId ? { ...r, unread: false, processing: r.processing === 'Unread' ? 'Read' : r.processing } : r)) : rows )) return { previous } }, onError: (err, _recordId, ctx) => { for (const [key, data] of ctx?.previous ?? []) qc.setQueryData(key, data) toast(friendlyAuthError(err, 'Could not mark as read.'), 'error') }, onSettled: () => qc.invalidateQueries({ queryKey: qk.mailbox.all() }), }) } export default function Inbox() { const { toast } = useToast() const navigate = useNavigate() const qc = useQueryClient() const { data: jobs = [] } = useQuery(seedQuery('jobs')) const { data: recruiters = [] } = useQuery(seedQuery('recruiters')) const updateInbox = useSeedMutation('inbox') const updateCandidates = useSeedMutation('candidates') const [tab, setTab] = useState('All Applications') const [selectedId, setSelectedId] = useState(null) const [q, setQ] = useState('') const [previewing, setPreviewing] = useState(null) const [assigning, setAssigning] = useState(null) const [noting, setNoting] = useState(null) // Only the Unread tab filters server-side; every other tab omits the param and // the backend's default (true) means "no filter". const isread = tab === 'Unread' ? false : undefined const applicationsQuery = useQuery({ queryKey: qk.mailbox.applications({ isread }), queryFn: () => fetchApplications({ isread }), enabled: tab !== 'Email', }) /** * The tab badges need whole-table counts, which a server-filtered response * cannot give — and there is no counts endpoint. So the unfiltered set stays * loaded for them. On every tab except Unread this resolves to the SAME query * key as the list above, so React Query serves both from one request. */ const countsQuery = useQuery({ queryKey: qk.mailbox.applications({ isread: undefined }), queryFn: () => fetchApplications({}), enabled: tab !== 'Email', }) const inbox = applicationsQuery.data ?? [] const allApplications = countsQuery.data ?? [] const emailsQuery = useQuery({ queryKey: qk.mailbox.messages(), queryFn: async () => { const res = await inboxApi.listMessages() const rows = Array.isArray(res?.data) ? res.data : [] return rows.map((row) => ({ id: String(row.id), from: row.sender_name || row.fromEmail || 'Unknown', fromEmail: row.fromEmail || '', subject: row.subject || '', body: row.body || '', when: parseDate(row.when) ?? parseDate(row.message_sent_time), unread: Boolean(row.unread), attachment: row.attachment_name || 'Resume.pdf', attachmentSize: '—', // The agent's verdict, straight off backend/inbox/serializers.py:44-48. // suggested_job_post_ids is deliberately NOT carried: job posts stay // dark to the inbox. matchStatus: row.match_status || null, matchSummary: row.match_summary || '', matchReasoning: row.match_reasoning || '', matchError: row.match_error || '', matchedAt: parseDate(row.matched_at), imported: false, })) }, enabled: tab === 'Email', }) const counts = useMemo( // Counted off the UNFILTERED set — `inbox` is server-filtered on the Unread // tab, so counting it there would report the unread total for every badge. () => ({ 'All Applications': allApplications.length, Unread: allApplications.filter((i) => i.processing === 'Unread').length, Imported: allApplications.filter((i) => i.processing === 'Imported').length, Processed: allApplications.filter((i) => i.processing === 'Processed').length, Rejected: allApplications.filter((i) => i.processing === 'Rejected').length, Duplicates: allApplications.filter((i) => i.duplicate).length, Email: (emailsQuery.data ?? []).filter((e) => e.unread).length, }), [allApplications, emailsQuery.data], ) const list = useMemo(() => { let l = inbox // Unread is already filtered server-side; re-applying it client-side is what // makes the optimistic mark-read drop the row from the list immediately // instead of leaving it until the refetch lands. if (tab === 'Unread') l = l.filter((i) => i.processing === 'Unread') else if (tab === 'Imported') l = l.filter((i) => i.processing === 'Imported') else if (tab === 'Processed') l = l.filter((i) => i.processing === 'Processed') else if (tab === 'Rejected') l = l.filter((i) => i.processing === 'Rejected') else if (tab === 'Duplicates') l = l.filter((i) => i.duplicate) if (q) l = l.filter((i) => (i.name + i.position + i.source).toLowerCase().includes(q.toLowerCase())) return l }, [inbox, tab, q]) const selected = inbox.find((i) => i.id === selectedId) // The one mutation these tabs CAN persist — everything else on them is // disabled until the endpoints exist. const markRead = useMarkRead(toast) function select(id) { setSelectedId(id) const item = inbox.find((i) => i.id === id) if (item?.unread) markRead.mutate(id) } function makeCandidate(item, job, cs) { return { id: `CAN-${5001 + cs.length}`, name: item.name, initials: item.initials, color: item.color, email: item.email, phone: item.phone, jobId: job.id, jobTitle: job.title, department: job.department, experience: item.experience, currentCompany: pick(companies), currentTitle: job.title, location: pick(locations), stage: 'Applied', status: 'Applied', aiScore: item.atsScore, source: item.source, recruiter: item.recruiter, recruiterId: '', applied: new Date(TODAY), education: "Bachelor's Degree", skills: job.skills.slice(0, 4), rating: '4.0', salary: int(90, 180) * 1000, matchedSkills: job.skills.slice(0, 3), missingSkills: job.skills.slice(3), recommendation: item.atsScore >= 82 ? 'Strong Match' : 'Potential Match', subScores: { skills: item.atsScore, experience: item.atsScore, education: 80, keywords: item.atsScore, location: 100, salary: 90 }, noticePeriod: '1 month', availability: '2 weeks', certifications: [], favorite: false, interviewStatus: 'Not Scheduled', } } function importItem(item) { const job = getJob(item.jobId) || jobs[0] updateCandidates((cs) => [makeCandidate(item, job, cs), ...cs]) updateInbox((items) => items.map((i) => (i.id === item.id ? { ...i, processing: 'Imported', unread: false } : i))) toast(`${item.name} imported → Applied stage of ${job.title}`, 'success') } function parseResume(item) { updateInbox((items) => items.map((i) => (i.id === item.id ? { ...i, resumeStatus: 'Parsing' } : i))) toast('Parsing resume with AI…', 'info') setTimeout(() => { updateInbox((items) => items.map((i) => (i.id === item.id ? { ...i, resumeStatus: 'Parsed', atsScore: int(60, 96) } : i)), ) toast('Resume parsed — profile fields extracted', 'success') }, 1100) } function moveToPipeline(item) { if (item.processing !== 'Imported' && item.processing !== 'Processed') importItem(item) updateInbox((items) => items.map((i) => (i.id === item.id ? { ...i, processing: 'Processed' } : i))) toast(`${item.name} moved to pipeline`, 'success') setTimeout(() => navigate('/pipeline'), 700) } function reject(item) { updateInbox((items) => items.map((i) => (i.id === item.id ? { ...i, processing: 'Rejected', unread: false } : i))) toast(`${item.name} rejected`, 'warning') } return (

Recruitment Inbox

Every candidate, every source — one unified queue

Microsoft Graph API · Connected
{ setTab(t); setSelectedId(null) }} tabs={TABS.map((t) => ({ key: t, label: t, count: counts[t] }))} />
{tab === 'Email' ? ( ) : (
setQ(e.target.value)} placeholder="Search applications…" />
{applicationsQuery.isPending && ( Fetching applications from the server. )} {applicationsQuery.isError && ( {friendlyAuthError(applicationsQuery.error, 'Request failed')} )} {applicationsQuery.isSuccess && list.length === 0 ? ( No applications in this view. ) : ( list.map((i) => (
select(i.id)} >
{i.name}{' '} {i.duplicate && ( DUP )}
{i.position}
{i.processing}
{i.received ? relTime(Math.round((NOW - i.received) / 60000)) : '—'}
{/* No ATS score exists server-side — the agent returns a verdict, not a number. The chip stays off rather than rendering a placeholder that reads as a real score. */} {i.atsScore != null && (
)}
)) )}
{!selected ? (
Choose an item from the list to view details and take action.
) : ( setPreviewing(selected)} onImport={() => importItem(selected)} onParse={() => parseResume(selected)} onAssign={() => setAssigning(selected)} onMove={() => moveToPipeline(selected)} onNote={() => setNoting(selected)} onReject={() => reject(selected)} /> )}
)}
{previewing && ( setPreviewing(null)} footer={ <> } >
            {previewing.resumeText || 'Resume text not extracted yet — the matching task has not run for this application.'}
          
)} {assigning && ( setAssigning(null)} onSave={(name) => { updateInbox((items) => items.map((i) => (i.id === assigning.id ? { ...i, recruiter: name } : i))) setAssigning(null) toast(`Recruiter assigned to ${assigning.name}`, 'success') }} /> )} {noting && ( setNoting(null)} footer={ <> } >