email response handled
parent
40e40a3864
commit
4bd02cefb0
|
|
@ -44,6 +44,7 @@ def serialize_message(message: Inbox_Messages) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": str(message.id),
|
"id": str(message.id),
|
||||||
"message_id": str(message.message_id) if message.message_id else None,
|
"message_id": str(message.message_id) if message.message_id else None,
|
||||||
|
"full_email_response": message.full_email_response,
|
||||||
"sender_name": sender_name,
|
"sender_name": sender_name,
|
||||||
"fromEmail": message.message_from,
|
"fromEmail": message.message_from,
|
||||||
"subject": message.message_subject,
|
"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. */
|
/** Triggers the Graph proxy to pull new mail and persist it. */
|
||||||
export function syncMailbox({ token, top, skip } = {}) {
|
export function syncMailbox({ token, top, skip } = {}) {
|
||||||
return request('/email/fetch', { params: { token, top, skip } })
|
return request('/email/fetch', { params: { token, top, skip } })
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ export const qk = {
|
||||||
all: () => ['mailbox'],
|
all: () => ['mailbox'],
|
||||||
messages: () => ['mailbox', 'messages'],
|
messages: () => ['mailbox', 'messages'],
|
||||||
applications: (p = {}) => ['mailbox', 'applications', p],
|
applications: (p = {}) => ['mailbox', 'applications', p],
|
||||||
|
message: (id) => ['mailbox', 'message', id],
|
||||||
},
|
},
|
||||||
|
|
||||||
// --- seed-backed buckets ---
|
// --- 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.
|
* GET /inbox/all-applications -> the shape the application tabs render.
|
||||||
*
|
*
|
||||||
|
|
@ -252,7 +321,19 @@ export default function Inbox() {
|
||||||
return l
|
return l
|
||||||
}, [inbox, tab, q])
|
}, [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
|
// The one mutation these tabs CAN persist — everything else on them is
|
||||||
// disabled until the endpoints exist.
|
// 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.
|
Choose an item from the list to view details and take action.
|
||||||
</EmptyState>
|
</EmptyState>
|
||||||
</div>
|
</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
|
<ApplicationDetail
|
||||||
item={selected}
|
item={selected}
|
||||||
|
loading={detailQuery.isPending}
|
||||||
onPreview={() => setPreviewing(selected)}
|
onPreview={() => setPreviewing(selected)}
|
||||||
onImport={() => importItem(selected)}
|
onImport={() => importItem(selected)}
|
||||||
onParse={() => parseResume(selected)}
|
onParse={() => parseResume(selected)}
|
||||||
|
|
@ -495,7 +583,7 @@ function orDash(value, suffix = '') {
|
||||||
return value == null || value === '' ? '—' : `${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 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)'
|
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
|
// 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>{' '}
|
<SourceChip item={i} /> <Badge>{i.processing}</Badge>{' '}
|
||||||
<Badge className={i.resumeStatus === 'Parsed' ? 'b-green' : i.resumeStatus === 'Failed' ? 'b-red' : 'b-amber'}>
|
<Badge className={i.resumeStatus === 'Parsed' ? 'b-green' : i.resumeStatus === 'Failed' ? 'b-red' : 'b-amber'}>
|
||||||
{i.resumeStatus}
|
{i.resumeStatus}
|
||||||
</Badge>
|
</Badge>{' '}
|
||||||
|
{loading && <span className="cell-sub">Loading details…</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{i.atsScore != null && (
|
{i.atsScore != null && (
|
||||||
|
|
@ -535,6 +624,13 @@ function ApplicationDetail({ item: i, onPreview, onImport, onParse, onAssign, on
|
||||||
<div className="il">Received</div>
|
<div className="il">Received</div>
|
||||||
<div className="iv">{i.received ? fmtDate(i.received) : '—'}</div>
|
<div className="iv">{i.received ? fmtDate(i.received) : '—'}</div>
|
||||||
</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 && (
|
{i.atsScore != null && (
|
||||||
<div className="info-item">
|
<div className="info-item">
|
||||||
<div className="il">Match</div>
|
<div className="il">Match</div>
|
||||||
|
|
@ -543,11 +639,26 @@ function ApplicationDetail({ item: i, onPreview, onImport, onParse, onAssign, on
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 && (
|
{i.hasAttachment && (
|
||||||
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginBottom: 20 }}>
|
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginBottom: 20 }}>
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<div className="flex items-center gap-12" style={{ justifyContent: 'space-between', marginBottom: 12 }}>
|
<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>
|
<button className="btn btn-secondary btn-sm" onClick={onPreview}><Icon name="eye" /> Preview</button>
|
||||||
</div>
|
</div>
|
||||||
{/* The real extracted PDF text (inbox_messages.resume_text), written
|
{/* The real extracted PDF text (inbox_messages.resume_text), written
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue