email response handled

pull/5/head
ahmed.mujtaba 2026-08-07 20:16:54 +05:00
parent 40e40a3864
commit 4bd02cefb0
4 changed files with 129 additions and 4 deletions

View File

@ -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,

View File

@ -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 } })

View File

@ -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 ---

View File

@ -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="Couldnt 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