feat(email): add safe message detail
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user