email response handled
parent
40e40a3864
commit
4bd02cefb0
|
|
@ -44,6 +44,7 @@ def serialize_message(message: Inbox_Messages) -> dict:
|
|||
return {
|
||||
"id": str(message.id),
|
||||
"message_id": str(message.message_id) if message.message_id else None,
|
||||
"full_email_response": message.full_email_response,
|
||||
"sender_name": sender_name,
|
||||
"fromEmail": message.message_from,
|
||||
"subject": message.message_subject,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,18 @@ export function listApplications({ search, top, skip, recordId, isread } = {}) {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* One persisted message by id — the detail behind an inbox row.
|
||||
*
|
||||
* `record_id` is the inbox_messages PRIMARY KEY, not the Graph message_id:
|
||||
* get_inbox_message_by_id runs uuid.UUID(record_id) and matches on `id`, so the
|
||||
* external string id would fail the parse and 404. The `id` field on both
|
||||
* /inbox/fetch and /inbox/all-applications rows is already that primary key.
|
||||
*/
|
||||
export function getMessage(recordId) {
|
||||
return request('/inbox/fetch', { params: { record_id: recordId } })
|
||||
}
|
||||
|
||||
/** Triggers the Graph proxy to pull new mail and persist it. */
|
||||
export function syncMailbox({ token, top, skip } = {}) {
|
||||
return request('/email/fetch', { params: { token, top, skip } })
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export const qk = {
|
|||
all: () => ['mailbox'],
|
||||
messages: () => ['mailbox', 'messages'],
|
||||
applications: (p = {}) => ['mailbox', 'applications', p],
|
||||
message: (id) => ['mailbox', 'message', id],
|
||||
},
|
||||
|
||||
// --- seed-backed buckets ---
|
||||
|
|
|
|||
|
|
@ -79,6 +79,75 @@ function SourceChip({ item }) {
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Graph delivers the body as text/html, so rendering it verbatim as text — which
|
||||
* is what keeps it XSS-safe — prints the raw markup at the user.
|
||||
*
|
||||
* DOMParser builds a DETACHED document: it is never adopted into the live DOM, so
|
||||
* scripts do not run and <img onerror> never fires. Reading textContent off it is
|
||||
* therefore both safe and readable, and needs no dangerouslySetInnerHTML.
|
||||
*/
|
||||
function htmlToText(value) {
|
||||
const raw = (value || '').trim()
|
||||
if (!raw) return ''
|
||||
if (!/<[a-z!/]/i.test(raw)) return raw // already plain text
|
||||
// textContent ignores block boundaries, so <p>a</p><p>b</p> would collapse to
|
||||
// "ab". Turn breaks and closing block tags into newlines BEFORE parsing.
|
||||
const withBreaks = raw
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<\/(p|div|li|tr|h[1-6]|blockquote|table)\s*>/gi, '\n')
|
||||
const doc = new DOMParser().parseFromString(withBreaks, 'text/html')
|
||||
doc.querySelectorAll('script, style, head').forEach((n) => n.remove())
|
||||
return (doc.body?.textContent || '').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
|
||||
}
|
||||
|
||||
/** match_status -> the resume badge, mirroring _RESUME_STATUS in inbox/serializers.py. */
|
||||
const RESUME_STATUS = {
|
||||
processing: 'Parsing', matched: 'Parsed', no_text: 'Failed',
|
||||
failed: 'Failed', dlq: 'Failed', skipped: 'Pending',
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /inbox/fetch?record_id=<pk> -> the detail behind one application row.
|
||||
*
|
||||
* Returns serialize_message, a different shape from serialize_application, so it
|
||||
* is remapped onto the row shape here and OVERLAID on the list row rather than
|
||||
* replacing it: serialize_message carries the body and the real decoded
|
||||
* attachments, but omits resume_text, so the list row keeps supplying that.
|
||||
* suggested_job_post_ids is dropped, same as everywhere else on this page.
|
||||
*/
|
||||
async function fetchMessageDetail(recordId) {
|
||||
const res = await inboxApi.getMessage(recordId)
|
||||
const row = res?.data
|
||||
if (!row) return null
|
||||
const name = row.sender_name || row.fromEmail || 'Unknown'
|
||||
return {
|
||||
id: String(row.id),
|
||||
name,
|
||||
initials: initialsOf(name),
|
||||
color: avatarColor(name),
|
||||
email: row.fromEmail || '',
|
||||
position: row.subject || '(no subject)',
|
||||
...sourceFrom(row.message_to),
|
||||
received: parseDate(row.when) ?? parseDate(row.message_sent_time),
|
||||
unread: Boolean(row.unread),
|
||||
processing: row.unread ? 'Unread' : 'Read',
|
||||
resumeStatus: RESUME_STATUS[row.match_status] ?? 'Pending',
|
||||
attachment: row.attachment_name,
|
||||
hasAttachment: Boolean(row.attachment),
|
||||
body: htmlToText(row.body),
|
||||
cc: row.message_cc || '',
|
||||
bcc: row.message_bcc || '',
|
||||
sentAt: parseDate(row.message_sent_time),
|
||||
files: Array.isArray(row.files) ? row.files : [],
|
||||
matchStatus: row.match_status || null,
|
||||
matchSummary: row.match_summary || '',
|
||||
matchReasoning: row.match_reasoning || '',
|
||||
matchError: row.match_error || '',
|
||||
matchedAt: parseDate(row.matched_at),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /inbox/all-applications -> the shape the application tabs render.
|
||||
*
|
||||
|
|
@ -252,7 +321,19 @@ export default function Inbox() {
|
|||
return l
|
||||
}, [inbox, tab, q])
|
||||
|
||||
const selected = inbox.find((i) => i.id === selectedId)
|
||||
// Clicking a row fetches that one record from /inbox/fetch. The list row is
|
||||
// kept as the base and the detail is overlaid, so the pane paints instantly
|
||||
// from cached list data and fills in body/attachments when the fetch lands.
|
||||
const detailQuery = useQuery({
|
||||
queryKey: qk.mailbox.message(selectedId),
|
||||
queryFn: () => fetchMessageDetail(selectedId),
|
||||
enabled: tab !== 'Email' && Boolean(selectedId),
|
||||
})
|
||||
|
||||
const selectedRow = inbox.find((i) => i.id === selectedId)
|
||||
const selected = selectedRow || detailQuery.data
|
||||
? { ...selectedRow, ...(detailQuery.data ?? {}) }
|
||||
: null
|
||||
|
||||
// The one mutation these tabs CAN persist — everything else on them is
|
||||
// disabled until the endpoints exist.
|
||||
|
|
@ -410,9 +491,16 @@ export default function Inbox() {
|
|||
Choose an item from the list to view details and take action.
|
||||
</EmptyState>
|
||||
</div>
|
||||
) : detailQuery.isError ? (
|
||||
<div style={{ padding: '100px 20px' }}>
|
||||
<EmptyState icon="inbox" title="Couldn’t load this application">
|
||||
{friendlyAuthError(detailQuery.error, 'Request failed')}
|
||||
</EmptyState>
|
||||
</div>
|
||||
) : (
|
||||
<ApplicationDetail
|
||||
item={selected}
|
||||
loading={detailQuery.isPending}
|
||||
onPreview={() => setPreviewing(selected)}
|
||||
onImport={() => importItem(selected)}
|
||||
onParse={() => parseResume(selected)}
|
||||
|
|
@ -495,7 +583,7 @@ function orDash(value, suffix = '') {
|
|||
return value == null || value === '' ? '—' : `${value}${suffix}`
|
||||
}
|
||||
|
||||
function ApplicationDetail({ item: i, onPreview, onImport, onParse, onAssign, onMove, onNote, onReject }) {
|
||||
function ApplicationDetail({ item: i, loading, 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
|
||||
|
|
@ -513,7 +601,8 @@ function ApplicationDetail({ item: i, onPreview, onImport, onParse, onAssign, on
|
|||
<SourceChip item={i} /> <Badge>{i.processing}</Badge>{' '}
|
||||
<Badge className={i.resumeStatus === 'Parsed' ? 'b-green' : i.resumeStatus === 'Failed' ? 'b-red' : 'b-amber'}>
|
||||
{i.resumeStatus}
|
||||
</Badge>
|
||||
</Badge>{' '}
|
||||
{loading && <span className="cell-sub">Loading details…</span>}
|
||||
</div>
|
||||
</div>
|
||||
{i.atsScore != null && (
|
||||
|
|
@ -535,6 +624,13 @@ function ApplicationDetail({ item: i, onPreview, onImport, onParse, onAssign, on
|
|||
<div className="il">Received</div>
|
||||
<div className="iv">{i.received ? fmtDate(i.received) : '—'}</div>
|
||||
</div>
|
||||
{/* Only present once GET /inbox/fetch?record_id= has resolved — the list
|
||||
endpoint carries none of these. */}
|
||||
{i.sentAt && (
|
||||
<div className="info-item"><div className="il">Sent</div><div className="iv">{fmtDate(i.sentAt)}</div></div>
|
||||
)}
|
||||
{i.cc && <div className="info-item"><div className="il">CC</div><div className="iv">{i.cc}</div></div>}
|
||||
{i.bcc && <div className="info-item"><div className="il">BCC</div><div className="iv">{i.bcc}</div></div>}
|
||||
{i.atsScore != null && (
|
||||
<div className="info-item">
|
||||
<div className="il">Match</div>
|
||||
|
|
@ -543,11 +639,26 @@ function ApplicationDetail({ item: i, onPreview, onImport, onParse, onAssign, on
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Body arrives only from GET /inbox/fetch?record_id= — the list endpoint
|
||||
does not carry it. Already run through htmlToText, and still rendered as
|
||||
TEXT: inbound mail is attacker-supplied. A body that is only an empty
|
||||
HTML skeleton flattens to '' and the block is skipped entirely. */}
|
||||
{!loading && (
|
||||
<div className="email-preview" style={{ marginBottom: 20 }}>
|
||||
{i.body || <span className="text-muted">This email has no message body.</span>}
|
||||
</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>
|
||||
<div className="fw-600">
|
||||
<Icon name="paperclip" /> {orDash(i.attachment)}
|
||||
{i.files?.[0]?.size != null && (
|
||||
<span className="cell-sub"> · {Math.round(i.files[0].size / 1024)} KB</span>
|
||||
)}
|
||||
</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
|
||||
|
|
|
|||
Loading…
Reference in New Issue