import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Autocomplete, Box, Button, Chip, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, Divider, List, ListItemButton, ListItemText, Paper, Tab, Tabs, TextField, ToggleButton, ToggleButtonGroup, Typography, } from "@mui/material"; import { alpha, useTheme } from "@mui/material/styles"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome"; import MailOutlineIcon from "@mui/icons-material/MailOutline"; import SearchIcon from "@mui/icons-material/Search"; import { IconButton } from "@mui/material"; import { api, getApiErrorMessage } from "../api"; import { useToast } from "../toast"; import { CorrespondenceMessage, GmailImportMessageResult, GmailImportThreadResult, GmailJobMatchesResponse, GmailRelinkResult, GmailStatus, GmailThreadRefreshResult, GmailUnlinkResult, } from "../types"; import { useDialogActions } from "../dialogs"; import { useI18n } from "../i18n/I18nProvider"; import ViewStateNotice from "./ViewStateNotice"; function parseRawEmail(raw: string): { subject?: string; date?: string; from?: string; to?: string; body: string } { const lines = raw.replace(/\r\n/g, "\n").split("\n"); const headers: Record = {}; let i = 0; for (; i < lines.length; i++) { const line = lines[i]; if (line.trim() === "") { i++; break; } const idx = line.indexOf(":"); if (idx <= 0) continue; const k = line.slice(0, idx).trim().toLowerCase(); const v = line.slice(idx + 1).trim(); headers[k] = headers[k] ? `${headers[k]} ${v}` : v; } const body = lines.slice(i).join("\n").trim(); const dateRaw = headers["date"]; let iso: string | undefined; if (dateRaw) { const d = new Date(dateRaw); if (!Number.isNaN(+d)) iso = d.toISOString(); } return { subject: headers["subject"], from: headers["from"], to: headers["to"], date: iso, body }; } type JobChoice = { id: number; jobTitle: string; companyName: string }; export type CorrespondenceJobContext = { companyName?: string | null; recruiterEmail?: string | null; jobTitle?: string | null; }; export function buildSuggestedCorrespondenceQueries( context: CorrespondenceJobContext, subjects: Array, labels: { recruiterMailbox: string; companyAndRole: string; companyMail: string; subject: string } = { recruiterMailbox: "Recruiter mailbox", companyAndRole: "Company + role", companyMail: "Company mail", subject: "Subject", }, ) { const uniqueSubjects = Array.from(new Set(subjects.filter(Boolean) as string[])).slice(0, 2); const companyName = context.companyName?.trim(); const recruiterEmail = context.recruiterEmail?.trim(); const jobTitle = context.jobTitle?.trim(); return [ recruiterEmail ? { label: labels.recruiterMailbox, value: `(from:${recruiterEmail} OR to:${recruiterEmail}) newer_than:365d` } : null, companyName && jobTitle ? { label: labels.companyAndRole, value: `"${companyName}" "${jobTitle}" newer_than:365d` } : null, companyName ? { label: labels.companyMail, value: `"${companyName}" (application OR interview OR recruiter) newer_than:365d` } : null, ...uniqueSubjects.map((subject) => ({ label: `${labels.subject}: ${subject}`, value: `subject:"${subject}" newer_than:365d` })), ].filter(Boolean).slice(0, 6) as Array<{ label: string; value: string }>; } export default function Correspondence({ jobId, jobContext }: { jobId: number; jobContext: CorrespondenceJobContext }) { const theme = useTheme(); const { toast } = useToast(); const { language, t } = useI18n(); const { confirmAction } = useDialogActions(); const formatConfidence = (value: string) => { if (value === "high") return t("correspondenceConfidenceHigh"); if (value === "medium") return t("correspondenceConfidenceMedium"); if (value === "low") return t("correspondenceConfidenceLow"); return t("correspondenceConfidenceUnknown"); }; const formatReasonLabel = (label: string) => { if (label === "company") return t("company"); if (label === "recruiterEmail") return t("correspondenceRecruiterEmail"); if (label === "recruiter") return t("correspondenceRecruiter"); if (label === "jobTitle") return t("correspondenceJobTitle"); if (label === "existingSubject") return t("correspondenceExistingSubject"); if (label === "recency") return t("correspondenceRecent"); if (label === "status") return t("correspondenceStatus"); return label; }; const [messages, setMessages] = useState([]); const [messagesLoading, setMessagesLoading] = useState(true); const [messagesError, setMessagesError] = useState(null); const [from, setFrom] = useState<"Me" | "Company">("Me"); const [text, setText] = useState(""); const scrollRef = useRef(null); const [importOpen, setImportOpen] = useState(false); const [importTab, setImportTab] = useState(0); const [rawEmail, setRawEmail] = useState(""); const [gmailStatus, setGmailStatus] = useState(null); const [gmailLoading, setGmailLoading] = useState(false); const [gmailQuery, setGmailQuery] = useState(""); const [gmailMatches, setGmailMatches] = useState(null); const [gmailMatchesLoading, setGmailMatchesLoading] = useState(false); const [linkedThreadRefresh, setLinkedThreadRefresh] = useState(null); const [linkedThreadRefreshLoading, setLinkedThreadRefreshLoading] = useState(false); const [importingMessageId, setImportingMessageId] = useState(null); const [importingThreadId, setImportingThreadId] = useState(null); const [availableJobs, setAvailableJobs] = useState([]); const [jobChoiceQuery, setJobChoiceQuery] = useState(""); const [jobChoicesLoading, setJobChoicesLoading] = useState(false); const [manageThreadId, setManageThreadId] = useState(null); const [manageTargetJobId, setManageTargetJobId] = useState(jobId); const [manageNote, setManageNote] = useState(""); const [manageSaving, setManageSaving] = useState(false); const autoRefreshKeyRef = useRef(null); const load = useCallback(async () => { setMessagesLoading(true); try { const res = await api.get(`/correspondence/${jobId}`); setMessages(res.data); setMessagesError(null); } catch (error) { setMessagesError(getApiErrorMessage(error, t("correspondenceLoadFailed"))); throw error; // Existing mutation callers still handle their own operation failure. } finally { setMessagesLoading(false); } }, [jobId, t]); const loadGmailStatus = useCallback(async () => { try { setGmailLoading(true); const res = await api.get("/gmail/status"); setGmailStatus(res.data); } catch { setGmailStatus({ connected: false }); } finally { setGmailLoading(false); } }, []); const loadGmailMatches = useCallback(async (queryOverride?: string) => { try { setGmailMatchesLoading(true); const res = await api.get("/gmail/job-candidates", { params: { jobApplicationId: jobId, queryOverride: queryOverride?.trim() || undefined, maxResultsPerQuery: 6, }, }); setGmailMatches(res.data); } catch (error: any) { toast(getApiErrorMessage(error, t("correspondenceLoadSuggestionsFailed")), "error"); } finally { setGmailMatchesLoading(false); } }, [jobId, t, toast]); const loadAvailableJobs = useCallback(async (query?: string) => { try { setJobChoicesLoading(true); const res = await api.get("/jobapplications/choices", { params: { q: query?.trim() || undefined, limit: 50 } }); setAvailableJobs((res.data ?? []).filter((item) => item.id !== jobId)); } catch { setAvailableJobs([]); } finally { setJobChoicesLoading(false); } }, [jobId]); const linkedThreadIds = useMemo( () => Array.from(new Set(messages.map((message) => message.externalThreadId).filter(Boolean) as string[])).sort(), [messages], ); const refreshLinkedThreads = useCallback(async (mode: "auto" | "manual" = "manual") => { if (!gmailStatus?.connected || linkedThreadIds.length === 0) return null; try { setLinkedThreadRefreshLoading(true); const res = await api.post("/gmail/refresh-linked-threads", { jobApplicationId: jobId }); setLinkedThreadRefresh(res.data); await load(); await loadGmailStatus(); if (importOpen && importTab === 1) { await loadGmailMatches(gmailQuery); } if (mode === "manual") { if (res.data.imported > 0) { toast(t(res.data.imported === 1 ? "correspondenceImportedNewMessage" : "correspondenceImportedNewMessages", { count: res.data.imported }), "success"); } else if (res.data.hasLinkedThreads) { toast(t("correspondenceLinkedThreadsCurrent"), "success"); } else { toast(t("correspondenceNoLinkedThreadsForJob"), "success"); } } else if (res.data.imported > 0) { toast(t(res.data.imported === 1 ? "correspondenceLinkedImportedMessage" : "correspondenceLinkedImportedMessages", { count: res.data.imported }), "success"); } return res.data; } catch (error: any) { if (mode === "manual") { toast(getApiErrorMessage(error, t("correspondenceRefreshLinkedFailed")), "error"); } return null; } finally { setLinkedThreadRefreshLoading(false); } }, [gmailQuery, gmailStatus?.connected, importOpen, importTab, jobId, linkedThreadIds.length, load, loadGmailMatches, loadGmailStatus, t, toast]); useEffect(() => { void load().catch(() => undefined); }, [load]); useEffect(() => { const el = scrollRef.current; if (!el) return; el.scrollTop = el.scrollHeight; }, [messages.length]); useEffect(() => { void loadGmailStatus(); }, [loadGmailStatus]); useEffect(() => { if (!manageThreadId) return; const timer = window.setTimeout(() => void loadAvailableJobs(jobChoiceQuery), jobChoiceQuery.trim() ? 250 : 0); return () => window.clearTimeout(timer); }, [jobChoiceQuery, loadAvailableJobs, manageThreadId]); useEffect(() => { if (!gmailStatus?.connected || linkedThreadIds.length === 0) { autoRefreshKeyRef.current = null; return; } const refreshKey = `${jobId}:${linkedThreadIds.join("|")}`; if (autoRefreshKeyRef.current === refreshKey) return; autoRefreshKeyRef.current = refreshKey; void refreshLinkedThreads("auto"); }, [gmailStatus?.connected, jobId, linkedThreadIds, refreshLinkedThreads]); useEffect(() => { if (!importOpen || importTab !== 1 || !gmailStatus?.connected) return; void loadGmailMatches(gmailQuery); }, [importOpen, importTab, gmailStatus?.connected, gmailQuery, loadGmailMatches]); useEffect(() => { const onMessage = (event: MessageEvent) => { const data = event.data as { source?: string; status?: string; message?: string }; if (data?.source !== "jobtracker-gmail-oauth") return; if (data.status === "connected") { toast(data.message || t("googleLinkedSuccess"), "success"); void loadGmailStatus(); setImportTab(1); void loadGmailMatches(gmailQuery); } else { toast(data.message || t("googleAuthFailed"), "error"); } }; window.addEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage); }, [gmailQuery, loadGmailMatches, loadGmailStatus, t, toast]); const canSend = useMemo(() => text.trim().length > 0, [text]); const suggestedQueries = buildSuggestedCorrespondenceQueries(jobContext, messages.map((message) => message.subject), { recruiterMailbox: t("correspondenceRecruiterMailbox"), companyAndRole: t("correspondenceCompanyAndRole"), companyMail: t("correspondenceCompanyMail"), subject: t("correspondenceSubject"), }); const send = async () => { if (!canSend) return; try { await api.post("/correspondence", { jobApplicationId: jobId, from, content: text }); setText(""); await load(); } catch (error) { toast(getApiErrorMessage(error, t("correspondenceAddFailed")), "error"); } }; const importEmail = async () => { const parsed = parseRawEmail(rawEmail); if (!parsed.body && !parsed.subject && !rawEmail.trim()) { toast(t("addJobModalPasteUrlFirst"), "error"); return; } try { await api.post("/correspondence", { jobApplicationId: jobId, from, channel: "Email", subject: parsed.subject ?? null, content: parsed.body || rawEmail, date: parsed.date ?? null, externalFrom: parsed.from ?? null, externalTo: parsed.to ?? null, }); setImportOpen(false); setRawEmail(""); await load(); toast(t("correspondenceLogEmail"), "success"); } catch (error) { toast(getApiErrorMessage(error, t("addJobModalImportFailed")), "error"); } }; const connectGmail = async () => { try { const res = await api.get<{ url: string }>("/gmail/connect-url"); const popup = window.open(res.data.url, "jobtracker-gmail-connect", "width=620,height=760,resizable=yes,scrollbars=yes"); if (!popup) toast(t("correspondenceBlockedPopup"), "error"); } catch (error) { toast(getApiErrorMessage(error, t("correspondenceStartGmailFailed")), "error"); } }; const disconnectGmail = async () => { try { await api.delete("/gmail/connection"); setGmailStatus({ connected: false }); setGmailMatches(null); setLinkedThreadRefresh(null); toast(t("googleUnlinked"), "success"); } catch (error) { toast(getApiErrorMessage(error, t("correspondenceDisconnectFailed")), "error"); } }; const deleteMessage = async (messageId: number) => { if (!(await confirmAction(t("correspondenceDeleteConfirm"), { title: t("correspondenceDeleteTitle"), confirmLabel: t("adminUsersDelete"), destructive: true }))) return; try { await api.delete(`/correspondence/${messageId}`); await load(); toast(t("correspondenceDeleted"), "success"); } catch (error) { toast(getApiErrorMessage(error, t("correspondenceDeleteFailed")), "error"); } }; const importGmailMessage = async (messageId: string) => { try { setImportingMessageId(messageId); const res = await api.post("/gmail/import", { jobApplicationId: jobId, messageId }); await load(); await loadGmailMatches(gmailQuery); if (res.data.imported > 0) { toast(t("correspondenceImportEmail"), "success"); } else { toast(t("correspondenceAlreadyLinkedToJob"), "success"); } } catch (error: any) { toast(getApiErrorMessage(error, t("correspondenceImportGmailFailed")), "error"); } finally { setImportingMessageId(null); } }; const importGmailThread = async (threadId: string, messageIds: string[]) => { try { setImportingThreadId(threadId); const res = await api.post("/gmail/import-thread", { jobApplicationId: jobId, threadId, messageIds }); await load(); await loadGmailMatches(gmailQuery); toast(t("correspondenceImportThreadResult", { imported: res.data.imported, skippedText: res.data.skipped ? t("correspondenceImportThreadSkipped", { count: res.data.skipped }) : "" }), "success"); } catch (error: any) { toast(getApiErrorMessage(error, t("correspondenceImportThreadFailed")), "error"); } finally { setImportingThreadId(null); } }; const openManageThread = (threadId: string) => { setManageThreadId(threadId); setManageTargetJobId(jobId); setJobChoiceQuery(""); setManageNote(""); }; const unlinkThread = async () => { if (!manageThreadId) return; setManageSaving(true); try { const res = await api.post("/gmail/unlink-thread", { jobApplicationId: jobId, threadId: manageThreadId, note: manageNote.trim() || null, nextDecision: "review", }); await load(); await loadGmailMatches(gmailQuery); setManageThreadId(null); toast(t(res.data.removedMessages === 1 ? "correspondenceUnlinkedMessage" : "correspondenceUnlinkedMessages", { count: res.data.removedMessages }), "success"); } catch (error: any) { toast(getApiErrorMessage(error, t("correspondenceUnlinkThreadFailed")), "error"); } finally { setManageSaving(false); } }; const relinkThread = async () => { if (!manageThreadId || manageTargetJobId <= 0 || manageTargetJobId === jobId) return; setManageSaving(true); try { const res = await api.post("/gmail/relink-thread", { jobApplicationId: manageTargetJobId, threadId: manageThreadId, removeFromOtherJobs: true, note: manageNote.trim() || null, }); await load(); await loadGmailMatches(gmailQuery); setManageThreadId(null); const targetJob = availableJobs.find((item) => item.id === manageTargetJobId); toast(t("correspondenceMovedThread", { job: targetJob?.companyName || targetJob?.jobTitle || t("correspondenceJobNumber", { id: res.data.jobApplicationId }) }), "success"); } catch (error: any) { toast(getApiErrorMessage(error, t("correspondenceMoveThreadFailed")), "error"); } finally { setManageSaving(false); } }; return ( load().catch(() => undefined)} compact /> {messages.length === 0 && !messagesLoading && !messagesError ? ( {t("correspondenceNoMessages")} ) : ( {messages.map((m) => { const isMe = (m.from || "").toLowerCase() === "me"; const accent = isMe ? theme.palette.primary.main : theme.palette.warning.main; return ( {m.subject ? {m.subject} : null} {m.content} {(m.externalThreadId || m.externalFrom || m.externalTo || m.externalLabelsJson || m.attachmentMetadataJson) ? ( {m.externalThreadId ? : null} {m.externalFrom ? : null} {m.externalTo ? : null} {m.externalLabelsJson ? : null} {m.attachmentMetadataJson ? : null} ) : null} {isMe ? t("correspondenceMe") : t("correspondenceCompany")}{m.channel ? ` - ${m.channel}` : ""}{m.date ? ` - ${new Date(m.date).toLocaleString(language === "nb" ? "nb-NO" : "en")}` : ""} void deleteMessage(m.id)} sx={{ color: "text.secondary" }}> ); })} )} {t("correspondenceLinkedContinuity")} {t("correspondenceLinkedContinuityHelp")} 0 ? "success" : "default"} variant="outlined" label={linkedThreadIds.length > 0 ? t("correspondenceLinkedThreads", { count: linkedThreadIds.length }) : t("correspondenceNoLinkedThreads")} /> {linkedThreadIds.slice(0, 6).map((threadId) => ( ))} {gmailStatus?.lastSyncStatus ? ( ) : null} {linkedThreadRefresh ? ( 0 ? t(linkedThreadRefresh.imported === 1 ? "correspondenceLastRefreshImportedMessage" : "correspondenceLastRefreshImportedMessages", { count: linkedThreadRefresh.imported }) : linkedThreadRefresh.hasLinkedThreads ? t(linkedThreadRefresh.threadsChecked === 1 ? "correspondenceLastRefreshCheckedThread" : "correspondenceLastRefreshCheckedThreads", { count: linkedThreadRefresh.threadsChecked }) : t("correspondenceNoRefreshHistory")} /> ) : null} {gmailStatus?.lastSyncError ? ( {t("correspondenceLatestSyncIssue", { error: gmailStatus.lastSyncError })} ) : null} v && setFrom(v)} size="small"> {t("correspondenceMe")} {t("correspondenceCompany")} setText(e.target.value)} multiline minRows={3} sx={{ flex: { xs: "1 1 100%", sm: "1 1 320px" }, minWidth: 0 }} helperText={t("correspondenceCharacters", { count: text.length })} /> setManageThreadId(null)} fullWidth maxWidth="sm"> {t("correspondenceManageLinkedThread")} {t("correspondenceManageLinkedThreadHelp")} {manageThreadId ? : null} setManageNote(event.target.value)} multiline minRows={2} placeholder={t("correspondenceReviewNotePlaceholder")} /> item.id === manageTargetJobId) ?? null} onInputChange={(_, value, reason) => { if (reason === "input" || reason === "clear") setJobChoiceQuery(value); }} onChange={(_, value) => setManageTargetJobId(value?.id ?? jobId)} isOptionEqualToValue={(option, value) => option.id === value.id} getOptionLabel={(item) => `${item.companyName || t("correspondenceUnknownCompany")} • ${item.jobTitle}`} renderInput={(params) => } /> setImportOpen(false)} fullWidth maxWidth="md"> {t("correspondenceImportTitle")} setImportTab(v)} sx={{ mb: 2 }}> {importTab === 0 ? ( <> {t("correspondencePasteEmailHelp")} setRawEmail(e.target.value)} placeholder={t("correspondencePasteEmailPlaceholder")} /> ) : ( {t("correspondenceGoogleGmail")} {gmailLoading ? t("correspondenceCheckingConnection") : gmailStatus?.connected ? t("correspondenceConnectedAs", { email: gmailStatus.gmailAddress || "" }) : t("correspondenceConnectGmailHint")} {jobContext.companyName || jobContext.jobTitle ? ( {t("correspondenceMatchingAgainst", { company: jobContext.companyName || t("correspondenceThisCompany"), role: jobContext.jobTitle || t("correspondenceThisRole") })} ) : null} {gmailStatus?.connected ? ( <> {linkedThreadIds.length > 0 ? ( ) : null} ) : ( )} {gmailStatus?.connected ? ( <> {suggestedQueries.map((item) => ( } label={item.label} clickable variant="outlined" onClick={() => setGmailQuery(item.value)} /> ))} setGmailQuery(e.target.value)} placeholder={t("correspondenceSearchGmailPlaceholder")} size="small" fullWidth /> {gmailMatches?.queries?.length ? ( {gmailMatches.queries.slice(0, 4).map((query) => ( ))} ) : null} {gmailStatus.lastSyncedAt ? : null} {gmailStatus.lastSyncAttemptedAt ? : null} {gmailStatus.lastSyncStatus === "error" && gmailStatus.lastSyncError ? : null} {linkedThreadIds.length > 0 ? ( {linkedThreadRefresh ? ( 0 ? t(linkedThreadRefresh.imported === 1 ? "correspondenceLastRefreshImportedMessage" : "correspondenceLastRefreshImportedMessages", { count: linkedThreadRefresh.imported }) : linkedThreadRefresh.hasLinkedThreads ? t(linkedThreadRefresh.threadsChecked === 1 ? "correspondenceLastRefreshCheckedThread" : "correspondenceLastRefreshCheckedThreads", { count: linkedThreadRefresh.threadsChecked }) : t("correspondenceNoLinkedThreadsForJob")} /> ) : null} ) : null} {gmailMatchesLoading ? ( ) : !gmailMatches || gmailMatches.threads.length === 0 ? ( {gmailQuery.trim() ? t("correspondenceNoMatchesForOverride") : t("correspondenceNoGmailMessages")} ) : ( {gmailMatches.threads.map((thread, threadIndex) => ( {threadIndex > 0 ? : null} {thread.subject || t("correspondenceNoSubject")} {thread.hasImportedMessages ? : null} {thread.matchReasons.map((reason) => ( ))} {thread.messages.map((message, index) => ( {index > 0 ? : null} {message.subject || t("correspondenceNoSubject")}{message.date ? new Date(message.date).toLocaleString(language === "nb" ? "nb-NO" : "en") : ""}} secondary={ {t("correspondenceFromLabel", { value: message.from || t("correspondenceUnknown") })} {message.snippet} {message.alreadyImported ? : null} {message.matchReasons.map((reason) => ( ))} } /> ))} ))} )} ) : null} )} {importTab === 0 ? : null} ); }