import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { Box, Alert, Autocomplete, Chip, CircularProgress, FormControl, InputLabel, MenuItem, Pagination, 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 { useI18n } from "../i18n/I18nProvider"; 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 CorrespondenceInboxPage = { items: CorrespondenceInboxItem[]; page: number; pageSize: number; total: number; totalPages: number; }; type EmailProviderStatus = { provider: string; displayName: string; connected: boolean; address?: string | null; canRead: boolean; canSend: boolean; }; type JobChoice = { id: number; jobTitle: string; companyName: string; }; 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 { language, t } = useI18n(); const [items, setItems] = useState([]); const [inboxPage, setInboxPage] = useState(1); const [inboxTotal, setInboxTotal] = useState(0); const [inboxTotalPages, setInboxTotalPages] = useState(0); const [providers, setProviders] = useState([]); const [providerStatusLoaded, setProviderStatusLoaded] = useState(false); const [jobs, setJobs] = useState([]); const [jobsLoaded, setJobsLoaded] = useState(false); const [jobsLoading, setJobsLoading] = useState(false); const [jobQuery, setJobQuery] = useState(""); 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/page", { params: { q: query.trim() || undefined, direction: direction === "all" ? undefined : direction, linkState: linkState === "all" ? undefined : linkState, page: inboxPage, pageSize: 50, }, }); setItems(res.data?.items ?? []); setInboxTotal(res.data?.total ?? 0); setInboxTotalPages(res.data?.totalPages ?? 0); if (res.data?.page && res.data.page !== inboxPage) setInboxPage(res.data.page); setSelectedMessageId(null); setMessageDetail(null); } catch (error) { toast(getApiErrorMessage(error, t("emailInboxLoadFailed")), "error"); } finally { setLoading(false); } }, [direction, inboxPage, linkState, query, t, 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(() => { const timer = window.setTimeout(() => { setJobsLoading(true); api.get("/jobapplications/choices", { params: { q: jobQuery.trim() || undefined, limit: 20 } }) .then((response) => setJobs(Array.isArray(response.data) ? response.data : [])) .catch(() => setJobs([])) .finally(() => { setJobsLoaded(true); setJobsLoading(false); }); }, jobQuery.trim() ? 250 : 0); return () => window.clearTimeout(timer); }, [jobQuery]); const loadStoredDrafts = useCallback(async () => { try { const response = await api.get("/email/drafts"); setStoredDrafts(Array.isArray(response.data) ? response.data : []); } catch { setDraftError(t("emailInboxDraftsLoadFailed")); } }, [t]); 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(t("emailInboxProviderCopyUnavailable")); } } 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, t("emailInboxMessageLoadFailed"))); } 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: t("emailInboxReplaceDraftTitle"), message: t("emailInboxReplaceReplyMessage"), confirmLabel: t("emailInboxReplaceDraft"), destructive: true, }))) return; const subject = messageDetail.subject.trim(); setDraft({ jobApplicationId: item.jobApplicationId, companyName: item.companyName || t("emailInboxUnknownCompany"), jobTitle: item.jobTitle || t("emailInboxUnknownRole"), provider: item.provider, providerName: provider.displayName, fromAddress: provider.address || t("emailInboxConnectedAccount"), to: item.direction === "outbound" ? messageDetail.to : messageDetail.from, subject: /^re:/i.test(subject) ? subject : `Re: ${subject || t("emailInboxYourMessage")}`, 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: t("emailInboxReplaceDraftTitle"), message: draft.id ? t("emailInboxCurrentDraftSaved") : t("emailInboxUnsavedDraftLost"), confirmLabel: t("emailInboxReplaceDraft"), 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 || t("emailInboxSavedDraft"), providerName: provider?.displayName || stored.provider, fromAddress: provider?.address || t("emailInboxReconnectBeforeSending"), }); 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: t("emailInboxReplaceDraftTitle"), message: draft.id ? t("emailInboxCurrentDraftSaved") : t("emailInboxUnsavedDraftLost"), confirmLabel: t("emailInboxReplaceDraft"), destructive: !draft.id, }))) return; setDraft({ jobApplicationId: job.id, companyName: job.companyName || t("emailInboxUnknownCompany"), jobTitle: job.jobTitle || t("emailInboxUnknownRole"), provider: provider.provider, providerName: provider.displayName, fromAddress: provider.address || t("emailInboxConnectedAccount"), 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: t("emailInboxDiscardDraftTitle"), message: t("emailInboxDiscardDraftMessage"), confirmLabel: t("emailInboxDiscardDraft"), 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, t("emailInboxDraftChangedDiscard"))); 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(t("emailInboxDraftSaved"), "success"); } catch (error: any) { setDraftError(error?.response?.status === 409 ? t("emailInboxDraftChangedElsewhere") : getApiErrorMessage(error, t("emailInboxDraftSaveFailed"))); } finally { setSavingDraft(false); } }; const sendDraft = async () => { if (!draft || sending || !draft.to.trim() || !draft.subject.trim() || !draft.bodyText.trim()) return; const approved = await confirm({ title: t("emailInboxConfirmSendTitle"), message: t("emailInboxConfirmSendMessage", { provider: draft.providerName, from: draft.fromAddress, to: draft.to.trim(), subject: draft.subject.trim(), thread: draft.threadId || t("emailInboxNewMessage") }), confirmLabel: t("emailInboxSendEmail"), }); 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(t("emailInboxSentSaved"), "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(t("emailInboxSentNewerDraft"), "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, t("emailInboxSendFailed"))); } 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 ? t("emailInboxNewAttemptConflict") : getApiErrorMessage(error, t("emailInboxNewAttemptFailed"))); 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: t("emailInboxUnlinkTitle"), message: t("emailInboxUnlinkMessage", { job: item.companyName || t("emailInboxThisJob") }), confirmLabel: t("emailInboxUnlinkThread"), 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(t("emailInboxUnlinked"), "success"); } catch (error) { toast(getApiErrorMessage(error, t("emailInboxUnlinkFailed")), "error"); } finally { setUnlinkingThreadId(null); } }; const providerStatusLabel = (provider: EmailProviderStatus) => { if (!provider.connected) return t("emailInboxProviderNotConnected", { provider: provider.displayName }); const identity = provider.address || t("emailInboxConnectedAccount"); if (!provider.canRead) return t("emailInboxProviderReadUnavailable", { provider: provider.displayName, identity }); return t(provider.canSend ? "emailInboxProviderReadSend" : "emailInboxProviderReadOnly", { provider: provider.displayName, identity }); }; return ( {t("correspondenceInbox")} {t("emailInboxSubtitle")} {view === "inbox" ? } label={t("emailInboxItems", { count: inboxTotal })} variant="outlined" /> : null} {view === "inbox" ? 0 ? "success" : "default"} /> : null} {view === "inbox" ? : null} {providers.map((provider) => ( ))} {providerStatusError ? ( {t("emailInboxProviderStatusUnavailable")} ) : null} {jobsLoaded && jobs.length === 0 ? {t("emailInboxAddJobFirst")} : null} {providerStatusLoaded && !providerStatusError && sendCapableProviders.length === 0 ? {t("emailInboxReconnectToCompose")} : null} {draftError && !draft ? {draftError} : null} {storedDrafts.length > 0 ? ( {t(storedDrafts.length === 1 ? "emailInboxSavedDraftOne" : "emailInboxSavedDraftMany", { count: storedDrafts.length })} {storedDrafts.slice(0, 5).map((stored) => ( ))} ) : null} {composeSetupOpen ? ( {t("emailInboxCompose")} job.id === composeJobId) ?? null} onInputChange={(_, value, reason) => { if (reason === "input" || reason === "clear") setJobQuery(value); }} onChange={(_, value) => setComposeJobId(value?.id ?? "")} isOptionEqualToValue={(option, value) => option.id === value.id} getOptionLabel={(job) => `${job.companyName || t("emailInboxUnknownCompany")} · ${job.jobTitle}`} renderInput={(params) => } /> {t("emailInboxSendingProvider")} ) : null} {view === "review" ? : <> { setQuery(e.target.value); setInboxPage(1); }} placeholder={t("emailInboxSearchPlaceholder")} /> {t("emailInboxDirection")} {t("emailInboxLinkState")} {draft ? ( {t("emailInboxDraftTitle")} {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={t("emailInboxCharacters", { count: draft.bodyText.length })} sx={{ gridColumn: "1 / -1" }} /> {sendResult?.status === "sent" ? {t("emailInboxSentSuccess")} : null} {sendResult?.status === "uncertain" || sendResult?.status === "sending" || sendResult?.status === "pending" ? ( {t("emailInboxDeliveryUncertain")} ) : null} {sendResult?.status === "failed" ? ( void prepareNewAttempt()}>{t("emailInboxPrepareAttempt")}}> {t("emailInboxDeliveryFailed")} ) : null} {draftError ? {draftError} : null} {sendError && sendResult?.status !== "uncertain" ? {sendError} : null} ) : null} {loading ? : null} {!loading && items.length === 0 ? ( {t("emailInboxNoMatches")} ) : null} {items.map((item) => ( {item.companyName || t("emailInboxUnknownCompany")} • {item.jobTitle || t("emailInboxUnknownRole")} {item.subject || item.contentPreview} {item.externalFrom || item.from} {item.externalTo ? `→ ${item.externalTo}` : ""} · {new Date(item.date).toLocaleString(language === "nb" ? "nb-NO" : "en")} {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 ? {t("emailInboxLoadingMessage")} : null} {detailNotice ? {detailNotice} : null} {detailError ? {detailError} : null} {messageDetail ? <> {messageDetail.subject || t("emailInboxNoSubject")} {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" ? ( {t("emailInboxReconnectToReply")} ) : null} : null} ) : null} ))} {inboxTotalPages > 1 ? ( setInboxPage(value)} color="primary" aria-label={t("emailInboxPages")} /> ) : null} } ); }