Files
jobtrackingapp/job-tracker-ui/src/components/Correspondence.tsx
T

441 lines
20 KiB
TypeScript

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<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 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<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 [gmailMessages, setGmailMessages] = useState<GmailMessageSummary[]>([]);
const [gmailMessagesLoading, setGmailMessagesLoading] = useState(false);
const [importingMessageId, setImportingMessageId] = useState<string | null>(null);
const [importingThreadId, setImportingThreadId] = useState<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 loadGmailMessages = useCallback(async () => {
try {
setGmailMessagesLoading(true);
const res = await api.get<GmailMessageSummary[]>("/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<string, GmailMessageSummary[]>();
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 (
<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" }}>No messages yet.</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>
<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={{ 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={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>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
{gmailStatus?.connected ? (
<>
<Button variant="outlined" onClick={() => void loadGmailMessages()} disabled={gmailMessagesLoading}>{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" onClick={() => void loadGmailMessages()} disabled={gmailMessagesLoading}>{t("correspondenceSearch")}</Button>
</Box>
{gmailStatus.lastSyncedAt ? <Chip label={`Last synced ${new Date(gmailStatus.lastSyncedAt).toLocaleString()}`} size="small" /> : null}
<Paper variant="outlined" sx={{ maxHeight: 420, overflowY: "auto" }}>
{gmailMessagesLoading ? (
<Box sx={{ py: 5, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box>
) : groupedByThread.length === 0 ? (
<Typography sx={{ color: "text.secondary", p: 2 }}>No Gmail messages found.</Typography>
) : (
<List disablePadding>
{groupedByThread.map(({ threadId, items }, threadIndex) => (
<React.Fragment key={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 }}>{items[0]?.subject || "(No subject)"}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{items.length} message{items.length === 1 ? "" : "s"} in thread</Typography>
</Box>
<Button startIcon={<MailOutlineIcon />} variant="outlined" size="small" disabled={importingThreadId === threadId} onClick={() => void importGmailThread(threadId, items.map((x) => x.id))}>
{importingThreadId === threadId ? "Importing..." : "Import thread"}
</Button>
</Box>
{items.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
primary={<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}><Typography sx={{ fontWeight: 700 }}>{message.subject || "(No subject)"}</Typography><Typography variant="caption" sx={{ color: "text.secondary" }}>{message.date ? new Date(message.date).toLocaleString() : ""}</Typography></Box>}
secondary={<Box sx={{ mt: 0.5 }}><Typography variant="body2" sx={{ color: "text.primary" }}>From: {message.from || t("correspondenceUnknown")}</Typography><Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25 }}>{message.snippet}</Typography></Box>}
/>
<Button variant="contained" size="small" disabled={importingMessageId === message.id} onClick={() => void importGmailMessage(message.id)}>
{importingMessageId === message.id ? "Importing..." : "Import"}
</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>
);
}