Files
jobtrackingapp/job-tracker-ui/src/components/Correspondence.tsx
T
2026-08-10 09:36:11 +02:00

743 lines
36 KiB
TypeScript

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<string, string> = {};
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<T> {
items: T[];
}
export type CorrespondenceJobContext = {
companyName?: string | null;
recruiterEmail?: string | null;
jobTitle?: string | null;
};
export function buildSuggestedCorrespondenceQueries(
context: CorrespondenceJobContext,
subjects: Array<string | null | undefined>,
) {
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<CorrespondenceMessage[]>([]);
const [from, setFrom] = useState<"Me" | "Company">("Me");
const [text, setText] = useState("");
const scrollRef = useRef<HTMLDivElement | null>(null);
const [importOpen, setImportOpen] = useState(false);
const [importTab, setImportTab] = useState(0);
const [rawEmail, setRawEmail] = useState("");
const [gmailStatus, setGmailStatus] = useState<GmailStatus | null>(null);
const [gmailLoading, setGmailLoading] = useState(false);
const [gmailQuery, setGmailQuery] = useState("");
const [gmailMatches, setGmailMatches] = useState<GmailJobMatchesResponse | null>(null);
const [gmailMatchesLoading, setGmailMatchesLoading] = useState(false);
const [linkedThreadRefresh, setLinkedThreadRefresh] = useState<GmailThreadRefreshResult | null>(null);
const [linkedThreadRefreshLoading, setLinkedThreadRefreshLoading] = useState(false);
const [importingMessageId, setImportingMessageId] = useState<string | null>(null);
const [importingThreadId, setImportingThreadId] = useState<string | null>(null);
const [availableJobs, setAvailableJobs] = useState<JobApplication[]>([]);
const [manageThreadId, setManageThreadId] = useState<string | null>(null);
const [manageTargetJobId, setManageTargetJobId] = useState<number>(jobId);
const [manageNote, setManageNote] = useState("");
const [manageSaving, setManageSaving] = useState(false);
const autoRefreshKeyRef = useRef<string | null>(null);
const load = useCallback(async () => {
const res = await api.get<CorrespondenceMessage[]>(`/correspondence/${jobId}`);
setMessages(res.data);
}, [jobId]);
const loadGmailStatus = useCallback(async () => {
try {
setGmailLoading(true);
const res = await api.get<GmailStatus>("/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<GmailJobMatchesResponse>("/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<PagedResult<JobApplication>>("/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<GmailThreadRefreshResult>("/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<GmailImportMessageResult>("/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<GmailImportThreadResult>("/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<GmailUnlinkResult>("/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<GmailRelinkResult>("/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 (
<Box>
<Paper ref={scrollRef} sx={{ p: 1.5, maxHeight: 360, overflowY: "auto", background: theme.palette.mode === "dark" ? "rgba(15,23,42,0.45)" : "rgba(255,255,255,0.75)", backdropFilter: "blur(8px)" }}>
{messages.length === 0 ? (
<Typography sx={{ color: "text.secondary", py: 2, textAlign: "center" }}>{t("correspondenceNoMessages")}</Typography>
) : (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{messages.map((m) => {
const isMe = (m.from || "").toLowerCase() === "me";
const accent = isMe ? theme.palette.primary.main : theme.palette.warning.main;
return (
<Box key={m.id} sx={{ display: "flex", justifyContent: isMe ? "flex-end" : "flex-start" }}>
<Box sx={{ maxWidth: "80%", borderRadius: 3, p: 1.25, border: `1px solid ${alpha(accent, theme.palette.mode === "dark" ? 0.32 : 0.22)}`, background: alpha(accent, theme.palette.mode === "dark" ? 0.14 : 0.1), color: "text.primary" }}>
{m.subject ? <Typography sx={{ fontWeight: 800, mb: 0.5 }}>{m.subject}</Typography> : null}
<Typography sx={{ whiteSpace: "pre-wrap", lineHeight: 1.35 }}>{m.content}</Typography>
{(m.externalThreadId || m.externalFrom || m.externalTo || m.externalLabelsJson || m.attachmentMetadataJson) ? (
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 1 }}>
{m.externalThreadId ? <Chip size="small" label={`Thread ${m.externalThreadId}`} variant="outlined" /> : null}
{m.externalFrom ? <Chip size="small" label={`From ${m.externalFrom}`} variant="outlined" /> : null}
{m.externalTo ? <Chip size="small" label={`To ${m.externalTo}`} variant="outlined" /> : null}
{m.externalLabelsJson ? <Chip size="small" label={`${JSON.parse(m.externalLabelsJson).length} Gmail label${JSON.parse(m.externalLabelsJson).length === 1 ? "" : "s"}`} variant="outlined" /> : null}
{m.attachmentMetadataJson ? <Chip size="small" label={`${JSON.parse(m.attachmentMetadataJson).length} attachment${JSON.parse(m.attachmentMetadataJson).length === 1 ? "" : "s"}`} variant="outlined" /> : null}
</Box>
) : null}
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "flex-end", mt: 0.75 }}>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{isMe ? t("correspondenceMe") : t("correspondenceCompany")}{m.channel ? ` - ${m.channel}` : ""}{m.date ? ` - ${new Date(m.date).toLocaleString()}` : ""}
</Typography>
<IconButton size="small" onClick={() => void deleteMessage(m.id)} sx={{ color: "text.secondary" }}>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Box>
</Box>
</Box>
);
})}
</Box>
)}
</Paper>
<Box sx={{ mt: 1.5, p: 1.5, borderRadius: 3, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", backgroundColor: "background.default" }}>
<Typography variant="overline">Linked Gmail thread continuity</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.25 }}>
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.
</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Chip size="small" color={gmailStatus?.connected ? "success" : "default"} variant="outlined" label={gmailStatus?.connected ? "Gmail connected" : "Gmail not connected"} />
<Chip size="small" color={linkedThreadIds.length > 0 ? "success" : "default"} variant="outlined" label={linkedThreadIds.length > 0 ? `Linked threads: ${linkedThreadIds.length}` : "No linked threads yet"} />
{linkedThreadIds.slice(0, 6).map((threadId) => (
<Button key={threadId} size="small" variant="text" onClick={() => openManageThread(threadId)}>
Manage {threadId}
</Button>
))}
{gmailStatus?.lastSyncStatus ? (
<Chip
size="small"
color={gmailStatus.lastSyncStatus === "success" ? "success" : "warning"}
variant="outlined"
label={gmailStatus.lastSyncStatus === "success"
? `Last Gmail sync ${gmailStatus.lastSyncMode || "sync"} ok`
: `Last Gmail sync ${gmailStatus.lastSyncMode || "sync"} failed`}
/>
) : null}
{linkedThreadRefresh ? (
<Chip
size="small"
variant="outlined"
label={linkedThreadRefresh.imported > 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}
</Box>
{gmailStatus?.lastSyncError ? (
<Typography variant="body2" sx={{ color: "warning.main", mt: 1 }}>
Latest Gmail sync issue: {gmailStatus.lastSyncError}
</Typography>
) : null}
</Box>
<Box sx={{ display: "flex", gap: 1, alignItems: "flex-start", mt: 1.5, flexWrap: "wrap" }}>
<ToggleButtonGroup exclusive value={from} onChange={(_, v) => v && setFrom(v)} size="small">
<ToggleButton value="Me">{t("correspondenceMe")}</ToggleButton>
<ToggleButton value="Company">{t("correspondenceCompany")}</ToggleButton>
</ToggleButtonGroup>
<Button variant="outlined" size="small" onClick={() => setImportOpen(true)}>{t("correspondenceImportEmail")}</Button>
<TextField label={t("correspondenceLogNoteOrMessage")} value={text} onChange={(e) => setText(e.target.value)} multiline minRows={3} sx={{ flex: "1 1 320px" }} helperText={t("correspondenceCharacters", { count: text.length })} />
<Button variant="contained" onClick={send} disabled={!canSend}>{t("correspondenceAdd")}</Button>
</Box>
<Dialog open={Boolean(manageThreadId)} onClose={() => setManageThreadId(null)} fullWidth maxWidth="sm">
<DialogTitle>Manage linked Gmail thread</DialogTitle>
<DialogContent sx={{ display: "flex", flexDirection: "column", gap: 2, pt: 1 }}>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
Unlink this thread from the current job, or move it to another existing job.
</Typography>
{manageThreadId ? <Chip label={`Thread ${manageThreadId}`} variant="outlined" sx={{ width: "fit-content" }} /> : null}
<TextField
label="Review note"
value={manageNote}
onChange={(event) => setManageNote(event.target.value)}
multiline
minRows={2}
placeholder="Why this thread should stay in review or move to another job."
/>
<FormControl fullWidth>
<InputLabel>Move to job</InputLabel>
<Select
value={String(manageTargetJobId)}
label="Move to job"
onChange={(event) => setManageTargetJobId(Number(event.target.value))}
>
<MenuItem value={String(jobId)}>Keep on current job</MenuItem>
{availableJobs.map((item) => (
<MenuItem key={item.id} value={String(item.id)}>
{item.company?.name || "Unknown company"} {item.jobTitle}
</MenuItem>
))}
</Select>
</FormControl>
</DialogContent>
<DialogActions>
<Button onClick={() => setManageThreadId(null)} disabled={manageSaving}>Close</Button>
<Button color="warning" variant="outlined" onClick={() => void unlinkThread()} disabled={manageSaving || !manageThreadId}>Unlink from this job</Button>
<Button variant="contained" onClick={() => void relinkThread()} disabled={manageSaving || !manageThreadId || manageTargetJobId === jobId}>Move thread</Button>
</DialogActions>
</Dialog>
<Dialog open={importOpen} onClose={() => setImportOpen(false)} fullWidth maxWidth="md">
<DialogTitle>{t("correspondenceImportTitle")}</DialogTitle>
<DialogContent>
<Tabs value={importTab} onChange={(_, v) => setImportTab(v)} sx={{ mb: 2 }}>
<Tab label={t("correspondencePasteEmail")} />
<Tab label={t("google")} />
</Tabs>
{importTab === 0 ? (
<>
<Typography sx={{ color: "text.secondary", mb: 1 }}>{t("correspondencePasteEmailHelp")}</Typography>
<TextField multiline minRows={10} fullWidth value={rawEmail} onChange={(e) => setRawEmail(e.target.value)} placeholder={"Subject: ...\nDate: ...\nFrom: ...\nTo: ...\n\nBody..."} />
</>
) : (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<Box>
<Typography sx={{ fontWeight: 800 }}>{t("correspondenceGoogleGmail")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{gmailLoading ? t("correspondenceCheckingConnection") : gmailStatus?.connected ? t("correspondenceConnectedAs", { email: gmailStatus.gmailAddress || "" }) : t("correspondenceConnectGmailHint")}
</Typography>
{jobContext.companyName || jobContext.jobTitle ? (
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>
Matching against {jobContext.companyName || "this company"} / {jobContext.jobTitle || "this role"}
</Typography>
) : null}
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
{gmailStatus?.connected ? (
<>
{linkedThreadIds.length > 0 ? (
<Button variant="outlined" onClick={() => void refreshLinkedThreads("manual")} disabled={linkedThreadRefreshLoading}>
{linkedThreadRefreshLoading ? "Refreshing linked threads..." : "Refresh linked threads"}
</Button>
) : null}
<Button variant="outlined" onClick={() => void loadGmailMatches(gmailQuery)} disabled={gmailMatchesLoading}>{t("correspondenceRefresh")}</Button>
<Button variant="outlined" color="error" onClick={() => void disconnectGmail()}>{t("correspondenceDisconnect")}</Button>
</>
) : (
<Button variant="contained" onClick={() => void connectGmail()}>{t("correspondenceConnectGmail")}</Button>
)}
</Box>
</Box>
{gmailStatus?.connected ? (
<>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
{suggestedQueries.map((item) => (
<Chip key={item.label} icon={<AutoAwesomeIcon />} label={item.label} clickable variant="outlined" onClick={() => setGmailQuery(item.value)} />
))}
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<TextField label={t("correspondenceSearchGmail")} value={gmailQuery} onChange={(e) => setGmailQuery(e.target.value)} placeholder={t("correspondenceSearchGmailPlaceholder")} size="small" fullWidth />
<Button variant="outlined" startIcon={<SearchIcon />} onClick={() => void loadGmailMatches(gmailQuery)} disabled={gmailMatchesLoading}>{t("correspondenceSearch")}</Button>
</Box>
{gmailMatches?.queries?.length ? (
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
{gmailMatches.queries.slice(0, 4).map((query) => (
<Chip key={query} size="small" label={query} variant="outlined" />
))}
</Box>
) : null}
{gmailStatus.lastSyncedAt ? <Chip label={t("correspondenceLastSynced", { date: new Date(gmailStatus.lastSyncedAt).toLocaleString() })} size="small" /> : null}
{gmailStatus.lastSyncAttemptedAt ? <Chip label={`Sync checked ${new Date(gmailStatus.lastSyncAttemptedAt).toLocaleString()}`} size="small" variant="outlined" /> : null}
{gmailStatus.lastSyncStatus === "error" && gmailStatus.lastSyncError ? <Chip label={`Sync issue: ${gmailStatus.lastSyncError}`} size="small" color="warning" variant="outlined" /> : null}
{linkedThreadIds.length > 0 ? (
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Chip size="small" color="success" variant="outlined" label={`Linked threads: ${linkedThreadIds.length}`} />
{linkedThreadRefresh ? (
<Chip
size="small"
variant="outlined"
label={linkedThreadRefresh.imported > 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}
</Box>
) : null}
<Paper variant="outlined" sx={{ maxHeight: 420, overflowY: "auto" }}>
{gmailMatchesLoading ? (
<Box sx={{ py: 5, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box>
) : !gmailMatches || gmailMatches.threads.length === 0 ? (
<Typography sx={{ color: "text.secondary", p: 2 }}>
{gmailQuery.trim() ? "No Gmail matches for this job and search override yet." : t("correspondenceNoGmailMessages")}
</Typography>
) : (
<List disablePadding>
{gmailMatches.threads.map((thread, threadIndex) => (
<React.Fragment key={thread.threadId}>
{threadIndex > 0 ? <Divider /> : null}
<Box sx={{ p: 1.5, backgroundColor: alpha(theme.palette.primary.main, 0.04) }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1 }}>
<Box>
<Typography sx={{ fontWeight: 800 }}>{thread.subject || t("correspondenceNoSubject")}</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.75 }}>
<Chip size="small" label={`${formatConfidence(thread.confidence)} · score ${thread.score}`} color={thread.confidence === "high" ? "success" : thread.confidence === "medium" ? "warning" : "default"} />
<Chip size="small" label={`${thread.messageCount} message${thread.messageCount === 1 ? "" : "s"}`} variant="outlined" />
{thread.hasImportedMessages ? <Chip size="small" label="Already linked" variant="outlined" color="success" /> : null}
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 1 }}>
{thread.matchReasons.map((reason) => (
<Chip key={`${thread.threadId}-${reason.label}-${reason.value}`} size="small" label={`${formatReasonLabel(reason.label)}: ${reason.value}`} variant="outlined" />
))}
</Box>
</Box>
<Button startIcon={<MailOutlineIcon />} variant="outlined" size="small" disabled={importingThreadId === thread.threadId} onClick={() => void importGmailThread(thread.threadId, thread.messages.map((x) => x.id))}>
{importingThreadId === thread.threadId ? t("correspondenceImporting") : t("correspondenceImportThread")}
</Button>
</Box>
{thread.messages.map((message, index) => (
<React.Fragment key={message.id}>
{index > 0 ? <Divider sx={{ my: 1 }} /> : null}
<ListItemButton sx={{ alignItems: "flex-start", px: 0, py: 1 }}>
<ListItemText
secondaryTypographyProps={{ component: "div" }}
primary={<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}><Typography sx={{ fontWeight: 700 }}>{message.subject || t("correspondenceNoSubject")}</Typography><Typography variant="caption" sx={{ color: "text.secondary" }}>{message.date ? new Date(message.date).toLocaleString() : ""}</Typography></Box>}
secondary={<Box component="span" sx={{ mt: 0.5, display: "block" }}>
<Typography component="span" variant="body2" sx={{ color: "text.primary", display: "block" }}>{t("correspondenceFromLabel", { value: message.from || t("correspondenceUnknown") })}</Typography>
<Typography component="span" variant="body2" sx={{ color: "text.secondary", mt: 0.25, display: "block" }}>{message.snippet}</Typography>
<Box component="span" sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 1 }}>
<Chip size="small" label={`${formatConfidence(message.confidence)} · score ${message.score}`} variant="outlined" />
{message.alreadyImported ? <Chip size="small" label="Already linked" color="success" variant="outlined" /> : null}
{message.matchReasons.map((reason) => (
<Chip key={`${message.id}-${reason.label}-${reason.value}`} size="small" label={`${formatReasonLabel(reason.label)}: ${reason.value}`} variant="outlined" />
))}
</Box>
</Box>}
/>
<Button variant="contained" size="small" disabled={importingMessageId === message.id} onClick={() => void importGmailMessage(message.id)}>
{importingMessageId === message.id ? t("correspondenceImporting") : t("correspondenceImportEmail")}
</Button>
</ListItemButton>
</React.Fragment>
))}
</Box>
</React.Fragment>
))}
</List>
)}
</Paper>
</>
) : null}
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setImportOpen(false)}>{t("correspondenceClose")}</Button>
{importTab === 0 ? <Button variant="contained" onClick={importEmail}>{t("correspondenceLogEmail")}</Button> : null}
</DialogActions>
</Dialog>
</Box>
);
}