HR-ATS-Portal/frontend/src/screens/Inbox.jsx

792 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/* ============================================================
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 (
<span className="source-chip" style={{ '--chip': item.sourceMeta?.color }}>
<span className="source-dot" />
{item.source}
</span>
)
}
/**
* 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 (
<div className="page">
<div className="page-head">
<div>
<h1 className="page-title">Recruitment Inbox</h1>
<p className="page-sub">Every candidate, every source one unified queue</p>
</div>
<div className="page-head-actions">
<span className="integration-status"><span className="pulse" />Microsoft Graph API · Connected</span>
<button
className="btn btn-secondary"
onClick={() => {
toast('Syncing all sources…', 'info')
setTimeout(() => toast('Inbox synced', 'success'), 900)
}}
>
<Icon name="refresh" /> Sync
</button>
<button className="btn btn-primary" onClick={() => navigate('/import')}>
<Icon name="upload" /> Upload CVs
</button>
</div>
</div>
<div className="card">
<div style={{ margin: '0 16px', paddingTop: 8 }}>
<Tabs
value={tab}
onChange={(t) => { setTab(t); setSelectedId(null) }}
tabs={TABS.map((t) => ({ key: t, label: t, count: counts[t] }))}
/>
</div>
{tab === 'Email' ? (
<EmailTab query={emailsQuery} jobs={jobs} updateCandidates={updateCandidates} toast={toast} />
) : (
<div className="split">
<div className="split-list">
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)' }}>
<div className="toolbar-search" style={{ maxWidth: 'none' }}>
<Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search applications…" />
</div>
</div>
<div>
{applicationsQuery.isPending && (
<EmptyState icon="inbox" title="Loading…">Fetching applications from the server.</EmptyState>
)}
{applicationsQuery.isError && (
<EmptyState icon="inbox" title="Couldnt load applications">
{friendlyAuthError(applicationsQuery.error, 'Request failed')}
</EmptyState>
)}
{applicationsQuery.isSuccess && list.length === 0 ? (
<EmptyState icon="inbox" title="Nothing here">No applications in this view.</EmptyState>
) : (
list.map((i) => (
<div
key={i.id}
className={`inbox-item${i.unread ? ' unread' : ''}${selectedId === i.id ? ' active' : ''}`}
onClick={() => select(i.id)}
>
<Avatar name={i.name} initials={i.initials} color={i.color} />
<div className="ii-main">
<div className="ii-name">
{i.name}{' '}
{i.duplicate && (
<span className="badge b-red badge-plain" style={{ padding: '1px 6px', fontSize: 10 }}>DUP</span>
)}
</div>
<div className="ii-pos">{i.position}</div>
<div className="ii-meta"><SourceChip item={i} /> <Badge>{i.processing}</Badge></div>
</div>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
<div className="ii-time">
{i.received ? relTime(Math.round((NOW - i.received) / 60000)) : '—'}
</div>
{/* 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 && (
<div style={{ marginTop: 6 }}><ScoreChip score={i.atsScore} /></div>
)}
</div>
</div>
))
)}
</div>
</div>
<div className="split-detail">
{!selected ? (
<div style={{ padding: '100px 20px' }}>
<EmptyState icon="inbox" title="Select an application">
Choose an item from the list to view details and take action.
</EmptyState>
</div>
) : (
<ApplicationDetail
item={selected}
onPreview={() => setPreviewing(selected)}
onImport={() => importItem(selected)}
onParse={() => parseResume(selected)}
onAssign={() => setAssigning(selected)}
onMove={() => moveToPipeline(selected)}
onNote={() => setNoting(selected)}
onReject={() => reject(selected)}
/>
)}
</div>
</div>
)}
</div>
{previewing && (
<Modal
title={previewing.attachment}
subtitle={`Resume preview · ${previewing.name}`}
size="modal-lg"
onClose={() => setPreviewing(null)}
footer={
<>
<button className="btn btn-secondary" onClick={() => setPreviewing(null)}>Close</button>
<button
className="btn btn-primary"
onClick={() => { const it = previewing; setPreviewing(null); importItem(it) }}
disabled
title="Needs a backend endpoint — not implemented yet"
>
<Icon name="user-plus" /> Import Candidate
</button>
</>
}
>
<pre className="resume-thumb" style={{ maxHeight: 'none', fontSize: 12 }}>
{previewing.resumeText || 'Resume text not extracted yet — the matching task has not run for this application.'}
</pre>
</Modal>
)}
{assigning && (
<AssignRecruiter
item={assigning}
recruiters={recruiters}
onClose={() => 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 && (
<Modal
title="Add Note"
subtitle={noting.name}
onClose={() => setNoting(null)}
footer={
<>
<button className="btn btn-secondary" onClick={() => setNoting(null)}>Cancel</button>
<button className="btn btn-primary" onClick={() => { setNoting(null); toast('Note added', 'success') }}>
<Icon name="check" /> Save Note
</button>
</>
}
>
<div className="form-field">
<label>Note</label>
<textarea placeholder="Add a note about this application…" />
</div>
</Modal>
)}
</div>
)
}
/** Fields inbox_messages has no column for come back null; show a dash, not "null". */
function orDash(value, suffix = '') {
return value == null || value === '' ? '—' : `${value}${suffix}`
}
function ApplicationDetail({ item: i, onPreview, onImport, onParse, onAssign, onMove, onNote, onReject }) {
const recLabel = i.atsScore >= 82 ? 'Strong Match' : i.atsScore >= 65 ? 'Potential Match' : 'Weak Match'
const ringColor = i.atsScore >= 82 ? 'var(--success)' : i.atsScore >= 65 ? 'var(--warning)' : 'var(--danger)'
// Every action below writes to a table column or an endpoint that does not
// exist yet, so they are disabled rather than silently dropping the click.
const noBackend = 'Needs a backend endpoint — not implemented yet'
return (
<div style={{ padding: 24 }}>
<div className="flex items-center gap-16" style={{ marginBottom: 20 }}>
<Avatar name={i.name} initials={i.initials} color={i.color} className="avatar-lg" />
<div style={{ flex: 1 }}>
<div className="ph-name" style={{ fontSize: 19 }}>{i.name}</div>
<div className="ph-role">{i.position}</div>
<div className="ph-tags" style={{ marginTop: 8 }}>
<SourceChip item={i} /> <Badge>{i.processing}</Badge>{' '}
<Badge className={i.resumeStatus === 'Parsed' ? 'b-green' : i.resumeStatus === 'Failed' ? 'b-red' : 'b-amber'}>
{i.resumeStatus}
</Badge>
</div>
</div>
{i.atsScore != null && (
<div style={{ textAlign: 'center' }}>
<div className="ats-ring" style={{ width: 84, height: 84, '--pct': i.atsScore, '--c': ringColor }}>
<div className="ats-val"><div className="ats-num" style={{ fontSize: 22 }}>{i.atsScore}</div></div>
</div>
<div className="cell-sub" style={{ marginTop: 4 }}>ATS Score</div>
</div>
)}
</div>
<div className="info-grid" style={{ marginBottom: 20 }}>
<div className="info-item"><div className="il">Email</div><div className="iv">{orDash(i.email)}</div></div>
<div className="info-item"><div className="il">Phone</div><div className="iv">{orDash(i.phone)}</div></div>
<div className="info-item"><div className="il">Experience</div><div className="iv">{orDash(i.experience, ' years')}</div></div>
<div className="info-item"><div className="il">Assigned Recruiter</div><div className="iv">{orDash(i.recruiter)}</div></div>
<div className="info-item">
<div className="il">Received</div>
<div className="iv">{i.received ? fmtDate(i.received) : '—'}</div>
</div>
{i.atsScore != null && (
<div className="info-item">
<div className="il">Match</div>
<div className="iv"><Badge className={atsRecommendationClass(recLabel)}>{recLabel}</Badge></div>
</div>
)}
</div>
{i.hasAttachment && (
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginBottom: 20 }}>
<div className="card-body">
<div className="flex items-center gap-12" style={{ justifyContent: 'space-between', marginBottom: 12 }}>
<div className="fw-600"><Icon name="paperclip" /> {orDash(i.attachment)}</div>
<button className="btn btn-secondary btn-sm" onClick={onPreview}><Icon name="eye" /> Preview</button>
</div>
{/* The real extracted PDF text (inbox_messages.resume_text), written
by the matching task. Empty until that task has run. */}
<pre className="resume-thumb">
{i.resumeText || 'Resume text not extracted yet — the matching task has not run for this application.'}
</pre>
</div>
</div>
)}
<div className="flex gap-8" style={{ flexWrap: 'wrap' }}>
<button className="btn btn-primary" onClick={onImport} disabled title={noBackend}>
<Icon name="user-plus" /> Import Candidate
</button>
<button className="btn btn-secondary" onClick={onParse} disabled title={noBackend}>
<Icon name="sparkles" /> Parse Resume
</button>
<button className="btn btn-secondary" onClick={onAssign} disabled title={noBackend}>
<Icon name="users" /> Assign Recruiter
</button>
<button className="btn btn-secondary" onClick={onMove} disabled title={noBackend}>
<Icon name="layers" /> Move to Pipeline
</button>
<button className="btn btn-secondary" onClick={onNote} disabled title={noBackend}>
<Icon name="edit" /> Add Note
</button>
<button
className="btn btn-ghost"
style={{ color: 'var(--danger)' }}
onClick={onReject}
disabled
title={noBackend}
>
<Icon name="x" /> Reject
</button>
</div>
</div>
)
}
function AssignRecruiter({ item, recruiters, onClose, onSave }) {
const [name, setName] = useState(item.recruiter)
const current = recruiters.find((r) => r.name === item.recruiter)
return (
<Modal
title="Assign Recruiter"
subtitle={item.name}
onClose={onClose}
footer={
<>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={() => onSave(name)}>Assign</button>
</>
}
>
<div className="form-field">
<label>Recruiter</label>
<select value={name} onChange={(e) => setName(e.target.value)}>
{recruiters.map((r) => <option key={r.id}>{r.name}</option>)}
</select>
</div>
<p className="text-muted text-sm" style={{ marginTop: 10 }}>
Current workload is factored automatically. This recruiter has {current?.openReqs ?? 5} open reqs.
</p>
</Modal>
)
}
/** The live tab: real fetch, real loading state, real error state. */
function EmailTab({ query, jobs, updateCandidates, toast }) {
const qc = useQueryClient()
const [selectedId, setSelectedId] = useState(null)
const [imported, setImported] = useState(() => new Set())
const emails = query.data ?? []
const selected = emails.find((e) => e.id === selectedId)
const unread = emails.filter((e) => e.unread).length
const markRead = useMarkRead(toast)
// Refetching the list alone only re-reads rows already in our DB. GET
// /email/fetch is the Graph proxy pull that inserts new mail and enqueues the
// matching agent, so it has to run FIRST — then the list is invalidated to
// pick up whatever it wrote.
const sync = useMutation({
mutationFn: () => inboxApi.syncMailbox(),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: qk.mailbox.all() })
toast('Mailbox synced', 'success')
},
onError: (err) => toast(friendlyAuthError(err, 'Sync failed'), 'error'),
})
function importEmail(e) {
const job = jobs[0]
if (!job) return
updateCandidates((cs) => [
{
id: `CAN-${5001 + cs.length}`,
name: e.from, initials: initialsOf(e.from), color: avatarColor(e.from),
email: e.fromEmail, phone: '+1 (555) 000-0000',
jobId: job.id, jobTitle: job.title, department: job.department,
experience: int(2, 10), currentCompany: pick(companies), currentTitle: job.title,
location: pick(locations), stage: 'Applied', status: 'Applied',
aiScore: SEED_ATS_SCORE, source: 'Microsoft Outlook', recruiter: job.recruiter, recruiterId: '',
applied: new Date(TODAY), education: "Bachelor's Degree",
skills: job.skills.slice(0, 4), rating: '4.0', salary: 130000,
matchedSkills: job.skills.slice(0, 3), missingSkills: job.skills.slice(3),
recommendation: 'Potential Match',
subScores: { skills: SEED_ATS_SCORE, experience: 80, education: 80, keywords: SEED_ATS_SCORE, location: 100, salary: 90 },
noticePeriod: '1 month', availability: '2 weeks', certifications: [],
favorite: false, interviewStatus: 'Not Scheduled',
},
...cs,
])
setImported((s) => new Set(s).add(e.id))
toast(`${e.from} imported from Outlook → ${job.title}`, 'success')
}
const isImported = (e) => imported.has(e.id)
function selectEmail(e) {
setSelectedId(e.id)
if (e.unread) markRead.mutate(e.id)
}
return (
<>
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: 12 }}>
<span className="integration-status"><span className="pulse" />Outlook · Microsoft Graph API</span>
<span className="text-muted text-sm">
{query.isPending ? 'Loading…' : query.isError ? 'Sync failed' : `${emails.length} messages · ${unread} unread`}
</span>
<button
className="btn btn-secondary btn-sm"
style={{ marginLeft: 'auto' }}
disabled={sync.isPending}
onClick={() => { toast('Fetching from Outlook…', 'info'); sync.mutate() }}
>
<Icon name="refresh" /> {sync.isPending ? 'Syncing…' : 'Sync Mailbox'}
</button>
</div>
<div className="split">
<div className="split-list">
{query.isPending && <EmptyState icon="mail" title="Loading…">Fetching mailbox from the server.</EmptyState>}
{query.isError && (
<EmptyState icon="mail" title="Couldnt load mailbox">
{friendlyAuthError(query.error, 'Request failed')}
</EmptyState>
)}
{query.isSuccess && emails.length === 0 && (
<EmptyState icon="mail" title="Nothing here">No emails in the mailbox.</EmptyState>
)}
{query.isSuccess && emails.map((e) => (
<div
key={e.id}
className={`inbox-item${e.unread ? ' unread' : ''}${selectedId === e.id ? ' active' : ''}`}
onClick={() => selectEmail(e)}
>
<Avatar name={e.from} />
<div className="ii-main">
<div className="ii-name">{e.from}</div>
<div className="ii-pos">{e.subject}</div>
<div className="ii-meta">
<span className="source-chip" style={{ '--chip': '#0078d4' }}>
<Icon name="mail" />Outlook
</span>
{isImported(e) && <Badge className="b-green">Imported</Badge>}
</div>
</div>
<div className="ii-time">{e.when ? fmtShort(e.when) : '—'}</div>
</div>
))}
</div>
<div className="split-detail">
{!selected ? (
<div style={{ padding: '100px 20px' }}>
<EmptyState icon="mail" title="Select an email">
Preview email body and resume attachments here.
</EmptyState>
</div>
) : (
<div style={{ padding: 24 }}>
<div className="flex items-center gap-12" style={{ marginBottom: 6 }}>
<h2 style={{ fontSize: 18, flex: 1 }}>{selected.subject}</h2>
{isImported(selected) ? <Badge className="b-green">Imported</Badge> : <Badge className="b-blue">New</Badge>}
</div>
<div className="flex items-center gap-12" style={{ marginBottom: 20 }}>
<Avatar name={selected.from} />
<div>
<div className="fw-600">{selected.from}</div>
<div className="cell-sub">
{selected.fromEmail} · {selected.when ? fmtDate(selected.when) : 'Date unavailable'}
</div>
</div>
</div>
{/* Rendered as TEXT. This is js/inbox.js:292, the widest XSS sink
in the prototype, and inbound mail is attacker-supplied. */}
<div className="email-preview" style={{ marginBottom: 18, whiteSpace: 'pre-wrap' }}>
{selected.body}
</div>
<div className="attach-card" style={{ marginBottom: 18 }}>
<span className="attach-icn"><Icon name="file" /></span>
<div style={{ flex: 1 }}>
<div className="fw-600">{selected.attachment}</div>
<div className="cell-sub">{selected.attachmentSize} · PDF</div>
</div>
<div className="flex items-center gap-8">
<button className="btn btn-secondary btn-sm" onClick={() => toast('Opening attachment preview', 'info')}>
<Icon name="eye" /> Preview
</button>
</div>
</div>
<div className="flex gap-8">
{isImported(selected) ? (
<button className="btn btn-secondary" disabled><Icon name="check" /> Already Imported</button>
) : (
<button className="btn btn-primary" onClick={() => importEmail(selected)}>
<Icon name="user-plus" /> Import Candidate
</button>
)}
<button className="btn btn-secondary" onClick={() => toast('Reply drafted', 'info')}>
<Icon name="mail" /> Reply
</button>
<button className="btn btn-ghost" style={{ color: 'var(--danger)' }} onClick={() => toast('Email archived', 'info')}>
<Icon name="trash" /> Archive
</button>
</div>
</div>
)}
</div>
</div>
</>
)
}