import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Box, Button, Chip, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, Divider, FormControl, InputLabel, List, ListItemButton, ListItemText, MenuItem, Paper, Select, 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, JobApplication, } from "../types"; import { useDialogActions } from "../dialogs"; import { useI18n } from "../i18n/I18nProvider"; 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 }; } function formatConfidence(value: string) { return value ? `${value[0].toUpperCase()}${value.slice(1)} confidence` : "Confidence unknown"; } function formatReasonLabel(label: string) { switch (label) { case "company": return "Company"; case "recruiterEmail": return "Recruiter email"; case "recruiter": return "Recruiter"; case "jobTitle": return "Job title"; case "existingSubject": return "Existing subject"; case "recency": return "Recent"; case "status": return "Status"; default: return label; } } interface PagedResult { items: T[]; } export type CorrespondenceJobContext = { companyName?: string | null; recruiterEmail?: string | null; jobTitle?: string | null; }; export function buildSuggestedCorrespondenceQueries( context: CorrespondenceJobContext, subjects: Array, ) { 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: "Recruiter mailbox", value: `(from:${recruiterEmail} OR to:${recruiterEmail}) newer_than:365d` } : null, companyName && jobTitle ? { label: "Company + role", value: `"${companyName}" "${jobTitle}" newer_than:365d` } : null, companyName ? { label: "Company mail", value: `"${companyName}" (application OR interview OR recruiter) newer_than:365d` } : null, ...uniqueSubjects.map((subject) => ({ label: `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 { t } = useI18n(); const { confirmAction } = useDialogActions(); const [messages, setMessages] = useState([]); 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 [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 () => { const res = await api.get(`/correspondence/${jobId}`); setMessages(res.data); }, [jobId]); 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, "Failed to load Gmail suggestions."), "error"); } finally { setGmailMatchesLoading(false); } }, [jobId, toast]); const loadAvailableJobs = useCallback(async () => { try { const res = await api.get>("/jobapplications", { params: { page: 1, pageSize: 100, sortBy: "dateApplied", sortDir: "desc" } }); setAvailableJobs((res.data?.items ?? []).filter((item) => item.id !== jobId)); } catch { setAvailableJobs([]); } }, [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(`Imported ${res.data.imported} new Gmail message${res.data.imported === 1 ? "" : "s"}.`, "success"); } else if (res.data.hasLinkedThreads) { toast("Linked Gmail threads are already current.", "success"); } else { toast("This job does not have any linked Gmail threads yet.", "success"); } } else if (res.data.imported > 0) { toast(`Linked Gmail threads imported ${res.data.imported} new message${res.data.imported === 1 ? "" : "s"}.`, "success"); } return res.data; } catch (error: any) { if (mode === "manual") { toast(getApiErrorMessage(error, "Failed to refresh linked Gmail threads."), "error"); } return null; } finally { setLinkedThreadRefreshLoading(false); } }, [gmailQuery, gmailStatus?.connected, importOpen, importTab, jobId, linkedThreadIds.length, load, loadGmailMatches, loadGmailStatus, toast]); useEffect(() => { void load(); }, [load]); useEffect(() => { const el = scrollRef.current; if (!el) return; el.scrollTop = el.scrollHeight; }, [messages.length]); useEffect(() => { void loadGmailStatus(); void loadAvailableJobs(); }, [loadAvailableJobs, loadGmailStatus]); 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)); const send = async () => { if (!canSend) return; try { await api.post("/correspondence", { jobApplicationId: jobId, from, content: text }); setText(""); await load(); } catch (error) { toast(getApiErrorMessage(error, "Failed to add message."), "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("This Gmail message is already linked to the job.", "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); 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(`Unlinked ${res.data.removedMessages} message${res.data.removedMessages === 1 ? "" : "s"} from this job.`, "success"); } catch (error: any) { toast(getApiErrorMessage(error, "Failed to unlink the Gmail thread."), "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(`Moved thread to ${targetJob?.company?.name || targetJob?.jobTitle || `job ${res.data.jobApplicationId}`}.`, "success"); } catch (error: any) { toast(getApiErrorMessage(error, "Failed to move the Gmail thread."), "error"); } finally { setManageSaving(false); } }; return ( {messages.length === 0 ? ( {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()}` : ""} void deleteMessage(m.id)} sx={{ color: "text.secondary" }}> ); })} )} Linked Gmail thread continuity Linked Gmail refresh only checks threads that are already tied to this job, so new correspondence can appear here without re-importing the whole thread. 0 ? "success" : "default"} variant="outlined" label={linkedThreadIds.length > 0 ? `Linked threads: ${linkedThreadIds.length}` : "No linked threads yet"} /> {linkedThreadIds.slice(0, 6).map((threadId) => ( ))} {gmailStatus?.lastSyncStatus ? ( ) : null} {linkedThreadRefresh ? ( 0 ? `Last linked refresh imported ${linkedThreadRefresh.imported} new message${linkedThreadRefresh.imported === 1 ? "" : "s"}` : linkedThreadRefresh.hasLinkedThreads ? `Last linked refresh checked ${linkedThreadRefresh.threadsChecked} linked thread${linkedThreadRefresh.threadsChecked === 1 ? "" : "s"}` : "No linked Gmail refresh history yet"} /> ) : null} {gmailStatus?.lastSyncError ? ( Latest Gmail sync issue: {gmailStatus.lastSyncError} ) : null} v && setFrom(v)} size="small"> {t("correspondenceMe")} {t("correspondenceCompany")} setText(e.target.value)} multiline minRows={3} sx={{ flex: "1 1 320px" }} helperText={t("correspondenceCharacters", { count: text.length })} /> setManageThreadId(null)} fullWidth maxWidth="sm"> Manage linked Gmail thread Unlink this thread from the current job, or move it to another existing job. {manageThreadId ? : null} setManageNote(event.target.value)} multiline minRows={2} placeholder="Why this thread should stay in review or move to another job." /> Move to job setImportOpen(false)} fullWidth maxWidth="md"> {t("correspondenceImportTitle")} setImportTab(v)} sx={{ mb: 2 }}> {importTab === 0 ? ( <> {t("correspondencePasteEmailHelp")} setRawEmail(e.target.value)} placeholder={"Subject: ...\nDate: ...\nFrom: ...\nTo: ...\n\nBody..."} /> ) : ( {t("correspondenceGoogleGmail")} {gmailLoading ? t("correspondenceCheckingConnection") : gmailStatus?.connected ? t("correspondenceConnectedAs", { email: gmailStatus.gmailAddress || "" }) : t("correspondenceConnectGmailHint")} {jobContext.companyName || jobContext.jobTitle ? ( Matching against {jobContext.companyName || "this company"} / {jobContext.jobTitle || "this role"} ) : 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 ? `Last refresh imported ${linkedThreadRefresh.imported} new message${linkedThreadRefresh.imported === 1 ? "" : "s"}` : linkedThreadRefresh.hasLinkedThreads ? `Last refresh checked ${linkedThreadRefresh.threadsChecked} linked thread${linkedThreadRefresh.threadsChecked === 1 ? "" : "s"}` : "No linked Gmail threads yet"} /> ) : null} ) : null} {gmailMatchesLoading ? ( ) : !gmailMatches || gmailMatches.threads.length === 0 ? ( {gmailQuery.trim() ? "No Gmail matches for this job and search override yet." : 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() : ""}} secondary={ {t("correspondenceFromLabel", { value: message.from || t("correspondenceUnknown") })} {message.snippet} {message.alreadyImported ? : null} {message.matchReasons.map((reason) => ( ))} } /> ))} ))} )} ) : null} )} {importTab === 0 ? : null} ); }