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 ? (
+
) : (
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 && (
+