From 4bd02cefb0336198b35f40f35cbee9d990dde4c8 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Fri, 7 Aug 2026 20:16:54 +0500 Subject: [PATCH] email response handled --- backend/inbox/serializers.py | 1 + frontend/src/api/inbox.js | 12 ++++ frontend/src/lib/queryKeys.js | 1 + frontend/src/screens/Inbox.jsx | 119 +++++++++++++++++++++++++++++++-- 4 files changed, 129 insertions(+), 4 deletions(-) diff --git a/backend/inbox/serializers.py b/backend/inbox/serializers.py index 6893e10..f1c5963 100644 --- a/backend/inbox/serializers.py +++ b/backend/inbox/serializers.py @@ -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, diff --git a/frontend/src/api/inbox.js b/frontend/src/api/inbox.js index 4f3acd9..5450dea 100644 --- a/frontend/src/api/inbox.js +++ b/frontend/src/api/inbox.js @@ -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 } }) diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js index 1830e71..2a4d330 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -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 --- diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index de76a1c..b00f68e 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -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 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

a

b

would collapse to + // "ab". Turn breaks and closing block tags into newlines BEFORE parsing. + const withBreaks = raw + .replace(//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= -> 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. + ) : detailQuery.isError ? ( +
+ + {friendlyAuthError(detailQuery.error, 'Request failed')} + +
) : ( 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 {i.processing}{' '} {i.resumeStatus} - + {' '} + {loading && Loading details…} {i.atsScore != null && ( @@ -535,6 +624,13 @@ function ApplicationDetail({ item: i, onPreview, onImport, onParse, onAssign, on
Received
{i.received ? fmtDate(i.received) : '—'}
+ {/* Only present once GET /inbox/fetch?record_id= has resolved — the list + endpoint carries none of these. */} + {i.sentAt && ( +
Sent
{fmtDate(i.sentAt)}
+ )} + {i.cc &&
CC
{i.cc}
} + {i.bcc &&
BCC
{i.bcc}
} {i.atsScore != null && (
Match
@@ -543,11 +639,26 @@ function ApplicationDetail({ item: i, onPreview, onImport, onParse, onAssign, on )}
+ {/* 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 && ( +
+ {i.body || This email has no message body.} +
+ )} + {i.hasAttachment && (
-
{orDash(i.attachment)}
+
+ {orDash(i.attachment)} + {i.files?.[0]?.size != null && ( + · {Math.round(i.files[0].size / 1024)} KB + )} +
{/* The real extracted PDF text (inbox_messages.resume_text), written