import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { Box, Alert, Chip, CircularProgress, FormControl, InputLabel, MenuItem, Paper, Select, Stack, TextField, Typography, Button, } from "@mui/material"; import MailOutlineIcon from "@mui/icons-material/MailOutline"; import { api, getApiErrorMessage } from "../api"; import { useConfirm } from "../confirm"; import { useToast } from "../toast"; import GmailReviewPage from "./GmailReviewPage"; export type CorrespondenceInboxItem = { id: number; jobApplicationId: number; companyName?: string | null; jobTitle?: string | null; from: string; direction?: string | null; subject?: string | null; channel?: string | null; date: string; contentPreview: string; externalThreadId?: string | null; externalMessageId?: string | null; provider?: string | null; externalFrom?: string | null; externalTo?: string | null; labelCount: number; attachmentCount: number; }; type EmailProviderStatus = { provider: string; displayName: string; connected: boolean; address?: string | null; canRead: boolean; canSend: boolean; }; type JobChoice = { id: number; jobTitle: string; company?: { name?: string | null } | null; }; export function emailProviderStatusLabel(provider: EmailProviderStatus) { if (!provider.connected) return `${provider.displayName}: Not connected`; const identity = provider.address || "Connected account"; if (!provider.canRead) return `${provider.displayName}: ${identity} · Mailbox access unavailable`; return `${provider.displayName}: ${identity}${provider.canSend ? " · Read + send" : " · Read only; reconnect to enable send"}`; } 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 }>; }; type EmailDraft = { id?: string; revision?: number; jobApplicationId: number; companyName: string; jobTitle: string; provider: string; providerName: string; fromAddress: string; to: string; subject: string; bodyText: string; threadId: string; clientRequestId: string; }; type StoredEmailDraft = Pick & { id: string; revision: number; createdAtUtc: string; updatedAtUtc: string; }; type EmailSendResult = { attemptId: string; status: "pending" | "sending" | "sent" | "failed" | "uncertain"; duplicate: boolean; externalMessageId?: string | null; externalThreadId?: string | null; failureCategory?: string | null; }; export default function CorrespondenceInboxPage() { const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const view = searchParams.get("view") === "review" ? "review" : "inbox"; const { toast } = useToast(); const { confirm } = useConfirm(); const [items, setItems] = useState([]); const [providers, setProviders] = useState([]); const [providerStatusLoaded, setProviderStatusLoaded] = useState(false); const [jobs, setJobs] = useState([]); const [jobsLoaded, setJobsLoaded] = useState(false); const [composeSetupOpen, setComposeSetupOpen] = useState(false); const [composeJobId, setComposeJobId] = useState(""); const [composeProvider, setComposeProvider] = useState(""); const [providerStatusError, setProviderStatusError] = useState(false); const [selectedMessageId, setSelectedMessageId] = useState(null); const [messageDetail, setMessageDetail] = useState(null); const [detailLoading, setDetailLoading] = useState(false); const [detailError, setDetailError] = useState(null); const [detailNotice, setDetailNotice] = useState(null); const detailRequest = useRef(0); const [loading, setLoading] = useState(false); const [query, setQuery] = useState(""); const [direction, setDirection] = useState("all"); const [linkState, setLinkState] = useState("all"); const [draft, setDraft] = useState(null); const [storedDrafts, setStoredDrafts] = useState([]); const [savingDraft, setSavingDraft] = useState(false); const [draftError, setDraftError] = useState(null); const [sendResult, setSendResult] = useState(null); const [sendError, setSendError] = useState(null); const [sending, setSending] = useState(false); const [unlinkingThreadId, setUnlinkingThreadId] = useState(null); const load = useCallback(async () => { setLoading(true); try { const res = await api.get("/correspondence", { params: { q: query.trim() || undefined, direction: direction === "all" ? undefined : direction, linkState: linkState === "all" ? undefined : linkState, }, }); setItems(res.data ?? []); } catch (error) { toast(getApiErrorMessage(error, "Failed to load correspondence inbox."), "error"); } finally { setLoading(false); } }, [direction, linkState, query, toast]); useEffect(() => { if (view === "inbox") void load(); }, [load, view]); useEffect(() => { api.get("/email/providers") .then((response) => { setProviders(response.data ?? []); setProviderStatusError(false); }) .catch(() => { setProviders([]); setProviderStatusError(true); }) .finally(() => setProviderStatusLoaded(true)); }, []); useEffect(() => { api.get<{ items?: JobChoice[] }>("/jobapplications", { params: { page: 1, pageSize: 100, sortBy: "dateApplied", sortDir: "desc" } }) .then((response) => setJobs(Array.isArray(response.data?.items) ? response.data.items : [])) .catch(() => setJobs([])) .finally(() => setJobsLoaded(true)); }, []); const loadStoredDrafts = useCallback(async () => { try { const response = await api.get("/email/drafts"); setStoredDrafts(Array.isArray(response.data) ? response.data : []); } catch { setDraftError("Saved drafts could not be loaded. New email can still be reviewed before sending."); } }, []); useEffect(() => { void loadStoredDrafts(); }, [loadStoredDrafts]); const filteredSummary = useMemo(() => { const linked = items.filter((item) => item.externalThreadId).length; const inbound = items.filter((item) => item.direction === "inbound").length; return { linked, inbound }; }, [items]); const sendCapableProviders = useMemo(() => providers.filter((provider) => provider.connected && provider.canSend), [providers]); 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("/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(`/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); } }; const startReply = async (item: CorrespondenceInboxItem) => { if (!messageDetail || !item.provider) return; const provider = providers.find((candidate) => candidate.provider === item.provider); if (!provider?.canSend) return; if (draft?.bodyText.trim() && !(await confirm({ title: "Replace email draft?", message: "Starting this reply will discard the draft you are currently editing.", confirmLabel: "Replace draft", destructive: true, }))) return; const subject = messageDetail.subject.trim(); setDraft({ jobApplicationId: item.jobApplicationId, companyName: item.companyName || "Unknown company", jobTitle: item.jobTitle || "Unknown role", provider: item.provider, providerName: provider.displayName, fromAddress: provider.address || "Connected account", to: item.direction === "outbound" ? messageDetail.to : messageDetail.from, subject: /^re:/i.test(subject) ? subject : `Re: ${subject || "Your message"}`, bodyText: "", threadId: messageDetail.threadId || item.externalThreadId || "", clientRequestId: globalThis.crypto.randomUUID(), }); setSendResult(null); setSendError(null); setDraftError(null); }; const resumeDraft = async (stored: StoredEmailDraft) => { if (draft?.bodyText.trim() && !(await confirm({ title: "Replace email draft?", message: draft.id ? "Your current draft is already saved and can be resumed later." : "Your current unsaved draft text will be lost.", confirmLabel: "Replace draft", destructive: !draft.id, }))) return; const provider = providers.find((candidate) => candidate.provider === stored.provider); const item = items.find((candidate) => candidate.jobApplicationId === stored.jobApplicationId); setDraft({ ...stored, companyName: item?.companyName || `Job #${stored.jobApplicationId}`, jobTitle: item?.jobTitle || "Saved email draft", providerName: provider?.displayName || stored.provider, fromAddress: provider?.address || "Reconnect before sending", }); setSendResult(null); setSendError(null); setDraftError(null); }; const openComposeSetup = () => { setComposeJobId((current) => current || jobs[0]?.id || ""); setComposeProvider((current) => current || sendCapableProviders[0]?.provider || ""); setComposeSetupOpen(true); }; const startNewMessage = async () => { const job = jobs.find((candidate) => candidate.id === composeJobId); const provider = sendCapableProviders.find((candidate) => candidate.provider === composeProvider); if (!job || !provider) return; if (draft?.bodyText.trim() && !(await confirm({ title: "Replace email draft?", message: draft.id ? "Your current draft is already saved and can be resumed later." : "Your current unsaved draft text will be lost.", confirmLabel: "Replace draft", destructive: !draft.id, }))) return; setDraft({ jobApplicationId: job.id, companyName: job.company?.name || "Unknown company", jobTitle: job.jobTitle || "Unknown role", provider: provider.provider, providerName: provider.displayName, fromAddress: provider.address || "Connected account", to: "", subject: "", bodyText: "", threadId: "", clientRequestId: globalThis.crypto.randomUUID(), }); setComposeSetupOpen(false); setSendResult(null); setSendError(null); setDraftError(null); }; const updateDraft = (changes: Partial>) => { setDraft((current) => current ? { ...current, ...changes, clientRequestId: sendResult ? globalThis.crypto.randomUUID() : current.clientRequestId, } : null); if (sendResult) setSendResult(null); setSendError(null); }; const discardDraft = async () => { if (draft?.bodyText.trim() && !(await confirm({ title: "Discard email draft?", message: "Your unsent draft text will be lost.", confirmLabel: "Discard draft", destructive: true, }))) return; if (draft?.id && draft.revision) { try { await api.delete(`/email/drafts/${draft.id}`, { params: { revision: draft.revision } }); await loadStoredDrafts(); } catch (error) { setDraftError(getApiErrorMessage(error, "The saved draft changed elsewhere. Reload it before discarding.")); return; } } setDraft(null); setSendResult(null); setSendError(null); setDraftError(null); }; const saveDraft = async () => { if (!draft || savingDraft || sendResult) return; setSavingDraft(true); setDraftError(null); try { const response = draft.id && draft.revision ? await api.put(`/email/drafts/${draft.id}`, { revision: draft.revision, to: draft.to, subject: draft.subject, bodyText: draft.bodyText, }) : await api.post("/email/drafts", { jobApplicationId: draft.jobApplicationId, provider: draft.provider, to: draft.to, subject: draft.subject, bodyText: draft.bodyText, threadId: draft.threadId || null, }); setDraft((current) => current ? { ...current, ...response.data } : null); await loadStoredDrafts(); toast("Email draft saved.", "success"); } catch (error: any) { setDraftError(error?.response?.status === 409 ? "This draft changed in another tab. Resume the latest saved version before editing again." : getApiErrorMessage(error, "Failed to save the email draft.")); } finally { setSavingDraft(false); } }; const sendDraft = async () => { if (!draft || sending || !draft.to.trim() || !draft.subject.trim() || !draft.bodyText.trim()) return; const approved = await confirm({ title: "Confirm email send", message: `Send with ${draft.providerName} (${draft.fromAddress}) to ${draft.to.trim()}? Subject: ${draft.subject.trim()}. Thread: ${draft.threadId || "New message"}.`, confirmLabel: "Send email", }); if (!approved) return; setSending(true); setSendError(null); try { const response = await api.post("/email/send", { jobApplicationId: draft.jobApplicationId, provider: draft.provider, clientRequestId: draft.clientRequestId, to: draft.to.trim(), subject: draft.subject.trim(), bodyText: draft.bodyText, threadId: draft.threadId || null, confirmed: true, }); setSendResult(response.data); if (response.data.status === "sent") { toast("Email sent and saved to this job.", "success"); if (draft.id && draft.revision) { try { await api.delete(`/email/drafts/${draft.id}`, { params: { revision: draft.revision } }); setDraft((current) => current ? { ...current, id: undefined, revision: undefined } : null); await loadStoredDrafts(); } catch { toast("Email was sent, but a newer saved draft still exists. Review it before taking another action.", "warning"); } } await load(); } } catch (error: any) { const result = error?.response?.data; if (["failed", "uncertain", "sent", "pending", "sending"].includes(result?.status)) { setSendResult(result as EmailSendResult); } else if (!error?.response || error.response.status >= 500) { setSendResult({ attemptId: draft.clientRequestId, status: "uncertain", duplicate: false, failureCategory: "client_connection_lost" }); } else { setSendResult({ attemptId: draft.clientRequestId, status: "failed", duplicate: false }); } setSendError(getApiErrorMessage(error, "The email could not be sent.")); } finally { setSending(false); } }; const prepareNewAttempt = async () => { if (!draft || sendResult?.status !== "failed") return; setDraftError(null); if (draft.id && draft.revision) { try { const response = await api.post(`/email/drafts/${draft.id}/new-attempt`, { revision: draft.revision }); setDraft((current) => current ? { ...current, ...response.data } : null); await loadStoredDrafts(); } catch (error: any) { setDraftError(error?.response?.status === 409 ? "A new attempt is allowed only after the latest saved draft has a definitive failed delivery. Reload the draft before continuing." : getApiErrorMessage(error, "Failed to prepare a new delivery attempt.")); return; } } else { setDraft((current) => current ? { ...current, clientRequestId: globalThis.crypto.randomUUID() } : null); } setSendResult(null); setSendError(null); }; const unlinkGmailThread = async (item: CorrespondenceInboxItem) => { if (item.provider !== "gmail" || !item.externalThreadId || unlinkingThreadId) return; if (!(await confirm({ title: "Unlink Gmail thread?", message: `Remove this Gmail thread from ${item.companyName || "this job"} and return it to recruitment review? The provider copy is not deleted.`, confirmLabel: "Unlink thread", destructive: true, }))) return; setUnlinkingThreadId(item.externalThreadId); try { await api.post("/gmail/unlink-thread", { jobApplicationId: item.jobApplicationId, threadId: item.externalThreadId, note: "Unlinked from Job email hub", nextDecision: "review", }); if (selectedMessageId === item.id) { detailRequest.current += 1; setSelectedMessageId(null); setMessageDetail(null); } await load(); toast("Gmail thread returned to recruitment review.", "success"); } catch (error) { toast(getApiErrorMessage(error, "Failed to unlink the Gmail thread."), "error"); } finally { setUnlinkingThreadId(null); } }; return ( Job email Review linked correspondence and suggested recruitment messages in one place. {view === "inbox" ? } label={`${items.length} items`} variant="outlined" /> : null} {view === "inbox" ? 0 ? "success" : "default"} /> : null} {view === "inbox" ? : null} {providers.map((provider) => ( ))} {providerStatusError ? ( Email provider status is temporarily unavailable. Saved JobTracker correspondence remains available. ) : null} {jobsLoaded && jobs.length === 0 ? Add a job before composing a new email. : null} {providerStatusLoaded && !providerStatusError && sendCapableProviders.length === 0 ? Reconnect Gmail or Outlook with send access before composing a new email. : null} {draftError && !draft ? {draftError} : null} {storedDrafts.length > 0 ? ( {storedDrafts.length} saved email draft{storedDrafts.length === 1 ? "" : "s"} available. {storedDrafts.slice(0, 5).map((stored) => ( ))} ) : null} {composeSetupOpen ? ( Compose new email Job Sending provider ) : null} {view === "review" ? : <> setQuery(e.target.value)} placeholder="Company, role, recruiter, subject" /> Direction Link state {draft ? ( Email draft {draft.companyName} · {draft.jobTitle} updateDraft({ to: event.target.value })} disabled={sending} inputProps={{ maxLength: 320 }} /> updateDraft({ subject: event.target.value })} disabled={sending} inputProps={{ maxLength: 998 }} /> updateDraft({ bodyText: event.target.value })} disabled={sending} multiline minRows={6} inputProps={{ maxLength: 200000 }} helperText={`${draft.bodyText.length} / 200000 characters`} sx={{ gridColumn: "1 / -1" }} /> {sendResult?.status === "sent" ? Sent successfully. This exact attempt cannot be sent again. : null} {sendResult?.status === "uncertain" || sendResult?.status === "sending" || sendResult?.status === "pending" ? ( Delivery status is uncertain. Do not retry this draft. Check the provider Sent folder before taking any further action. ) : null} {sendResult?.status === "failed" ? ( void prepareNewAttempt()}>Prepare new attempt}> The provider confirmed that this attempt did not complete. Review the connection and draft before creating a new attempt. ) : null} {draftError ? {draftError} : null} {sendError && sendResult?.status !== "uncertain" ? {sendError} : null} ) : null} {loading ? : null} {!loading && items.length === 0 ? ( No correspondence matches the current filters. ) : null} {items.map((item) => ( {item.companyName || "Unknown company"} • {item.jobTitle || "Unknown role"} {item.subject || item.contentPreview} {item.externalFrom || item.from} {item.externalTo ? `→ ${item.externalTo}` : ""} · {new Date(item.date).toLocaleString()} {item.direction ? : null} {item.provider ? : null} {item.externalThreadId ? : } {item.labelCount > 0 ? : null} {item.attachmentCount > 0 ? : null} {item.provider === "gmail" && item.externalThreadId ? ( ) : null} {selectedMessageId === item.id ? ( {detailLoading ? Loading message… : null} {detailNotice ? {detailNotice} : null} {detailError ? {detailError} : null} {messageDetail ? <> {messageDetail.subject || "No subject"} {messageDetail.from}{messageDetail.to ? ` → ${messageDetail.to}` : ""} {messageDetail.bodyText} {(messageDetail.labels.length > 0 || messageDetail.attachments.length > 0) ? {messageDetail.labels.map((label, index) => )} {messageDetail.attachments.map((attachment, index) => )} : null} {item.provider && providers.find((provider) => provider.provider === item.provider)?.canSend ? ( ) : item.provider && item.provider !== "manual" ? ( Reconnect this provider with send access to reply from JobTracker. ) : null} : null} ) : null} ))} } ); }