feat(email): add safe message detail
CI and Deploy / test (pull_request) Failing after 1m29s
CI and Deploy / deploy (pull_request) Has been skipped

This commit is contained in:
cesnimda
2026-08-09 21:44:24 +02:00
parent 6f0e9bb516
commit a20775c24a
6 changed files with 262 additions and 23 deletions
@@ -53,12 +53,18 @@ describe('CorrespondenceInboxPage', () => {
date: new Date().toISOString(),
contentPreview: 'We would like to schedule an interview.',
externalThreadId: 'thread-1',
externalMessageId: 'message-1',
provider: 'gmail',
externalFrom: 'Maria Recruiter <maria@acme.test>',
externalTo: 'user@example.test',
labelCount: 2,
attachmentCount: 1,
},
] } as any);
if (url === '/email/message') return Promise.resolve({ data: {
id: 'message-1', threadId: 'thread-1', subject: 'Interview invite', from: 'Maria Recruiter <maria@acme.test>', to: 'user@example.test',
date: new Date().toISOString(), snippet: 'Interview', bodyText: 'Please choose an interview time.', labels: ['INBOX'], attachments: [{ fileName: 'agenda.pdf' }],
} } as any);
if (url === '/gmail/review-candidates') return Promise.resolve({ data: {
queries: [], candidateThreadCount: 0, autoLinkThreadCount: 0, reviewThreadCount: 0, unmatchedThreadCount: 0, threads: [],
} } as any);
@@ -97,6 +103,38 @@ describe('CorrespondenceInboxPage', () => {
});
});
test('opens provider-backed plain-text message detail in the hub', async () => {
renderPage();
fireEvent.click(await screen.findByRole('button', { name: /view message/i }));
expect(await screen.findByText(/please choose an interview time/i)).toBeInTheDocument();
expect(screen.getByText('INBOX')).toBeInTheDocument();
expect(screen.getByText('agenda.pdf')).toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledWith('/email/message', {
params: { provider: 'gmail', messageId: 'message-1' },
});
});
test('falls back to the saved copy when provider detail is unavailable', async () => {
const original = mockedApi.get.getMockImplementation();
mockedApi.get.mockImplementation((url: string, config?: any) => {
if (url === '/email/message') return Promise.reject(new Error('reauthorization required'));
if (url === '/correspondence/message/1') return Promise.resolve({ data: {
id: 'message-1', threadId: 'thread-1', subject: 'Interview invite', from: 'Maria Recruiter', to: 'user@example.test',
date: new Date().toISOString(), snippet: 'Saved', bodyText: 'Saved interview message.', labels: [], attachments: [],
} } as any);
return original!(url, config);
});
renderPage();
fireEvent.click(await screen.findByRole('button', { name: /view message/i }));
expect(await screen.findByText(/showing the saved jobtracker copy/i)).toBeInTheDocument();
expect(await screen.findByText(/saved interview message/i)).toBeInTheDocument();
expect(mockedApi.get).toHaveBeenCalledWith('/correspondence/message/1');
});
test('uses one hub for linked messages and recruitment suggestions', async () => {
renderPage();
@@ -1,7 +1,8 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import {
Box,
Alert,
Chip,
CircularProgress,
FormControl,
@@ -31,6 +32,8 @@ export type CorrespondenceInboxItem = {
date: string;
contentPreview: string;
externalThreadId?: string | null;
externalMessageId?: string | null;
provider?: string | null;
externalFrom?: string | null;
externalTo?: string | null;
labelCount: number;
@@ -46,6 +49,19 @@ type EmailProviderStatus = {
canSend: boolean;
};
type EmailMessageDetail = {
id: string;
threadId: string;
subject: string;
from: string;
to: string;
date?: string | null;
snippet: string;
bodyText: string;
labels: string[];
attachments: Array<{ fileName?: string | null; mimeType?: string | null; sizeBytes?: number | null }>;
};
export default function CorrespondenceInboxPage() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
@@ -53,6 +69,12 @@ export default function CorrespondenceInboxPage() {
const { toast } = useToast();
const [items, setItems] = useState<CorrespondenceInboxItem[]>([]);
const [providers, setProviders] = useState<EmailProviderStatus[]>([]);
const [selectedMessageId, setSelectedMessageId] = useState<number | null>(null);
const [messageDetail, setMessageDetail] = useState<EmailMessageDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [detailError, setDetailError] = useState<string | null>(null);
const [detailNotice, setDetailNotice] = useState<string | null>(null);
const detailRequest = useRef(0);
const [loading, setLoading] = useState(false);
const [query, setQuery] = useState("");
const [direction, setDirection] = useState<string>("all");
@@ -92,6 +114,46 @@ export default function CorrespondenceInboxPage() {
return { linked, inbound };
}, [items]);
const showMessage = async (item: CorrespondenceInboxItem) => {
if (selectedMessageId === item.id) {
detailRequest.current += 1;
setSelectedMessageId(null);
setMessageDetail(null);
return;
}
const request = ++detailRequest.current;
setSelectedMessageId(item.id);
setMessageDetail(null);
setDetailError(null);
setDetailNotice(null);
setDetailLoading(true);
try {
if (item.provider && item.provider !== "manual" && item.externalMessageId) {
try {
const live = await api.get<EmailMessageDetail>("/email/message", {
params: { provider: item.provider, messageId: item.externalMessageId },
});
if (request !== detailRequest.current) return;
setMessageDetail(live.data);
return;
} catch {
if (request !== detailRequest.current) return;
setDetailNotice("The provider copy is unavailable. Showing the saved JobTracker copy.");
}
}
const saved = await api.get<EmailMessageDetail>(`/correspondence/message/${item.id}`);
if (request !== detailRequest.current) return;
setMessageDetail(saved.data);
} catch (error) {
if (request !== detailRequest.current) return;
setDetailError(getApiErrorMessage(error, "Failed to load this message."));
} finally {
if (request === detailRequest.current) setDetailLoading(false);
}
};
return (
<Paper
sx={{
@@ -172,12 +234,30 @@ export default function CorrespondenceInboxPage() {
</Box>
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", justifyContent: "flex-end" }}>
{item.direction ? <Chip size="small" label={item.direction} variant="outlined" /> : null}
{item.provider ? <Chip size="small" label={item.provider === "microsoft" ? "Outlook" : item.provider === "gmail" ? "Gmail" : item.provider.toUpperCase()} variant="outlined" /> : null}
{item.externalThreadId ? <Chip size="small" label={`Thread ${item.externalThreadId}`} color="success" variant="outlined" /> : <Chip size="small" label="Manual/internal" variant="outlined" />}
{item.labelCount > 0 ? <Chip size="small" label={`${item.labelCount} labels`} variant="outlined" /> : null}
{item.attachmentCount > 0 ? <Chip size="small" label={`${item.attachmentCount} attachments`} variant="outlined" /> : null}
<Button size="small" variant="text" onClick={() => void showMessage(item)}>{selectedMessageId === item.id ? "Hide message" : "View message"}</Button>
<Button size="small" variant="text" onClick={() => navigate(`/jobs?open=${item.jobApplicationId}`)}>Open job</Button>
</Box>
</Box>
{selectedMessageId === item.id ? (
<Box sx={{ mt: 1.5, pt: 1.5, borderTop: "1px solid", borderColor: "divider" }}>
{detailLoading ? <Box sx={{ display: "flex", gap: 1, alignItems: "center" }}><CircularProgress size={18} /><Typography variant="body2">Loading message</Typography></Box> : null}
{detailNotice ? <Alert severity="warning" sx={{ mb: 1 }}>{detailNotice}</Alert> : null}
{detailError ? <Alert severity="error">{detailError}</Alert> : null}
{messageDetail ? <>
<Typography sx={{ fontWeight: 800 }}>{messageDetail.subject || "No subject"}</Typography>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mb: 1 }}>{messageDetail.from}{messageDetail.to ? `${messageDetail.to}` : ""}</Typography>
<Typography sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{messageDetail.bodyText}</Typography>
{(messageDetail.labels.length > 0 || messageDetail.attachments.length > 0) ? <Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 1 }}>
{messageDetail.labels.map((label, index) => <Chip key={`${label}-${index}`} size="small" label={label} variant="outlined" />)}
{messageDetail.attachments.map((attachment, index) => <Chip key={`${attachment.fileName || "attachment"}-${index}`} size="small" label={attachment.fileName || "Attachment"} variant="outlined" />)}
</Box> : null}
</> : null}
</Box>
) : null}
</Paper>
))}
</Stack>