import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { 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 { IconButton } from "@mui/material"; import { api, getApiErrorMessage } from "../api"; import { useToast } from "../toast"; import { CorrespondenceMessage, GmailMessageSummary, GmailStatus } 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 scoreMessage(message: GmailMessageSummary, query: string, messages: CorrespondenceMessage[]) { const hay = `${message.subject} ${message.from} ${message.to} ${message.snippet}`.toLowerCase(); const q = query.toLowerCase(); let score = 0; if (q && hay.includes(q)) score += 8; if (/interview|application|recruit|follow up|follow-up|position|role/.test(hay)) score += 2; if (messages.some((m) => m.subject && message.subject && m.subject.toLowerCase() === message.subject.toLowerCase())) score -= 4; if (message.date) { const ageDays = Math.abs((Date.now() - new Date(message.date).getTime()) / 86400000); if (ageDays <= 30) score += 3; else if (ageDays <= 120) score += 1; } return score; } export default function Correspondence({ jobId }: { jobId: number }) { 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 [gmailMessages, setGmailMessages] = useState([]); const [gmailMessagesLoading, setGmailMessagesLoading] = useState(false); const [importingMessageId, setImportingMessageId] = useState(null); const [importingThreadId, setImportingThreadId] = useState(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 loadGmailMessages = useCallback(async () => { try { setGmailMessagesLoading(true); const res = await api.get("/gmail/messages", { params: { query: gmailQuery.trim() || undefined, maxResults: 12, }, }); setGmailMessages(res.data); } catch (error: any) { toast(getApiErrorMessage(error, "Failed to load Gmail messages."), "error"); } finally { setGmailMessagesLoading(false); } }, [gmailQuery, toast]); useEffect(() => { void load(); }, [load]); useEffect(() => { const el = scrollRef.current; if (!el) return; el.scrollTop = el.scrollHeight; }, [messages.length]); useEffect(() => { if (!importOpen) return; void loadGmailStatus(); }, [importOpen, loadGmailStatus]); useEffect(() => { if (!importOpen || importTab !== 1 || !gmailStatus?.connected) return; void loadGmailMessages(); }, [importOpen, importTab, gmailStatus?.connected, loadGmailMessages]); 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 loadGmailMessages(); } else { toast(data.message || t("googleAuthFailed"), "error"); } }; window.addEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage); }, [loadGmailMessages, loadGmailStatus, t, toast]); const canSend = useMemo(() => text.trim().length > 0, [text]); const suggestedQueries = useMemo(() => { const subjectTerms = messages.map((m) => m.subject).filter(Boolean) as string[]; const uniqueSubjects = Array.from(new Set(subjectTerms)).slice(0, 2); const companyTerms = Array.from(new Set(messages.filter((m) => m.from === "Company").map((m) => m.subject).filter(Boolean) as string[])).slice(0, 2); return [ { label: "Recent recruiting mail", value: "newer_than:180d (interview OR recruiter OR application OR position)" }, { label: "Inbox only", value: "label:inbox newer_than:120d" }, { label: "Sent follow-ups", value: "in:sent newer_than:180d follow up" }, ...uniqueSubjects.map((s) => ({ label: `Subject: ${s}`, value: `subject:"${s}"` })), ...companyTerms.map((s) => ({ label: `Related topic: ${s}`, value: `"${s}" newer_than:180d` })), ].slice(0, 6); }, [messages]); const rankedMessages = useMemo(() => { return [...gmailMessages].sort((a, b) => scoreMessage(b, gmailQuery, messages) - scoreMessage(a, gmailQuery, messages)); }, [gmailMessages, gmailQuery, messages]); const groupedByThread = useMemo(() => { const map = new Map(); for (const message of rankedMessages) { const key = message.threadId || message.id; map.set(key, [...(map.get(key) ?? []), message]); } return Array.from(map.entries()).map(([threadId, items]) => ({ threadId, items })); }, [rankedMessages]); 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, }); setImportOpen(false); setRawEmail(""); await load(); toast(t("correspondenceLogEmail"), "success"); } catch (error) { toast(getApiErrorMessage(error, "Failed to import email."), "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("Your browser blocked the Gmail popup.", "error"); } catch (error) { toast(getApiErrorMessage(error, "Failed to start Gmail connection."), "error"); } }; const disconnectGmail = async () => { try { await api.delete("/gmail/connection"); setGmailStatus({ connected: false }); setGmailMessages([]); toast(t("googleUnlinked"), "success"); } catch (error) { toast(getApiErrorMessage(error, "Failed to disconnect Gmail."), "error"); } }; const deleteMessage = async (messageId: number) => { if (!(await confirmAction("Remove this correspondence message?", { title: "Delete message", confirmLabel: t("jobTableDeleteSelected"), destructive: true }))) return; try { await api.delete(`/correspondence/${messageId}`); await load(); toast("Message removed.", "success"); } catch (error) { toast(getApiErrorMessage(error, "Failed to remove message."), "error"); } }; const importGmailMessage = async (messageId: string) => { try { setImportingMessageId(messageId); await api.post("/gmail/import", { jobApplicationId: jobId, messageId }); await load(); toast(t("correspondenceImportEmail"), "success"); } catch (error: any) { toast(getApiErrorMessage(error, "Failed to import Gmail message."), "error"); } finally { setImportingMessageId(null); } }; const importGmailThread = async (threadId: string, messageIds: string[]) => { try { setImportingThreadId(threadId); const res = await api.post<{ imported: number; skipped: number; threadId?: string }>("/gmail/import-thread", { jobApplicationId: jobId, threadId, messageIds }); await load(); toast(`Imported ${res.data.imported} messages${res.data.skipped ? `, skipped ${res.data.skipped} duplicates` : ""}.`, "success"); } catch (error: any) { toast(getApiErrorMessage(error, "Failed to import Gmail thread."), "error"); } finally { setImportingThreadId(null); } }; return ( {messages.length === 0 ? ( No messages yet. ) : ( {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} {isMe ? t("correspondenceMe") : t("correspondenceCompany")}{m.channel ? ` - ${m.channel}` : ""}{m.date ? ` - ${new Date(m.date).toLocaleString()}` : ""} void deleteMessage(m.id)} sx={{ color: "text.secondary" }}> ); })} )} 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 })} /> 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")} {gmailStatus?.connected ? ( <> ) : ( )} {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 /> {gmailStatus.lastSyncedAt ? : null} {gmailMessagesLoading ? ( ) : groupedByThread.length === 0 ? ( No Gmail messages found. ) : ( {groupedByThread.map(({ threadId, items }, threadIndex) => ( {threadIndex > 0 ? : null} {items[0]?.subject || "(No subject)"} {items.length} message{items.length === 1 ? "" : "s"} in thread {items.map((message, index) => ( {index > 0 ? : null} {message.subject || "(No subject)"}{message.date ? new Date(message.date).toLocaleString() : ""}} secondary={From: {message.from || t("correspondenceUnknown")}{message.snippet}} /> ))} ))} )} ) : null} )} {importTab === 0 ? : null} ); }