Complete S01 Gmail matching and import workflow

This commit is contained in:
2026-03-24 10:06:50 +01:00
parent 92ccf47446
commit 3e5f796326
13 changed files with 1043 additions and 105 deletions
+126 -70
View File
@@ -25,11 +25,19 @@ 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, GmailMessageSummary, GmailStatus } from "../types";
import {
CorrespondenceMessage,
GmailImportMessageResult,
GmailImportThreadResult,
GmailJobMatchesResponse,
GmailStatus,
JobApplication,
} from "../types";
import { useDialogActions } from "../dialogs";
import { useI18n } from "../i18n/I18nProvider";
@@ -63,22 +71,32 @@ function parseRawEmail(raw: string): { subject?: string; date?: string; from?: s
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;
function formatConfidence(value: string) {
return value ? `${value[0].toUpperCase()}${value.slice(1)} confidence` : "Confidence unknown";
}
export default function Correspondence({ jobId }: { jobId: number }) {
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;
}
}
export default function Correspondence({ jobId, job }: { jobId: number; job: JobApplication | null }) {
const theme = useTheme();
const { toast } = useToast();
const { t } = useI18n();
@@ -95,8 +113,8 @@ export default function Correspondence({ jobId }: { jobId: number }) {
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 [gmailMatches, setGmailMatches] = useState<GmailJobMatchesResponse | null>(null);
const [gmailMatchesLoading, setGmailMatchesLoading] = useState(false);
const [importingMessageId, setImportingMessageId] = useState<string | null>(null);
const [importingThreadId, setImportingThreadId] = useState<string | null>(null);
@@ -117,22 +135,23 @@ export default function Correspondence({ jobId }: { jobId: number }) {
}
}, []);
const loadGmailMessages = useCallback(async () => {
const loadGmailMatches = useCallback(async (queryOverride?: string) => {
try {
setGmailMessagesLoading(true);
const res = await api.get<GmailMessageSummary[]>("/gmail/messages", {
setGmailMatchesLoading(true);
const res = await api.get<GmailJobMatchesResponse>("/gmail/job-candidates", {
params: {
query: gmailQuery.trim() || undefined,
maxResults: 12,
jobApplicationId: jobId,
queryOverride: queryOverride?.trim() || undefined,
maxResultsPerQuery: 6,
},
});
setGmailMessages(res.data);
setGmailMatches(res.data);
} catch (error: any) {
toast(getApiErrorMessage(error, "Failed to load Gmail messages."), "error");
toast(getApiErrorMessage(error, "Failed to load Gmail suggestions."), "error");
} finally {
setGmailMessagesLoading(false);
setGmailMatchesLoading(false);
}
}, [gmailQuery, toast]);
}, [jobId, toast]);
useEffect(() => {
void load();
@@ -151,8 +170,8 @@ export default function Correspondence({ jobId }: { jobId: number }) {
useEffect(() => {
if (!importOpen || importTab !== 1 || !gmailStatus?.connected) return;
void loadGmailMessages();
}, [importOpen, importTab, gmailStatus?.connected, loadGmailMessages]);
void loadGmailMatches(gmailQuery);
}, [importOpen, importTab, gmailStatus?.connected, gmailQuery, loadGmailMatches]);
useEffect(() => {
const onMessage = (event: MessageEvent) => {
@@ -162,7 +181,7 @@ export default function Correspondence({ jobId }: { jobId: number }) {
toast(data.message || t("googleLinkedSuccess"), "success");
void loadGmailStatus();
setImportTab(1);
void loadGmailMessages();
void loadGmailMatches(gmailQuery);
} else {
toast(data.message || t("googleAuthFailed"), "error");
}
@@ -170,36 +189,24 @@ export default function Correspondence({ jobId }: { jobId: number }) {
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [loadGmailMessages, loadGmailStatus, t, toast]);
}, [gmailQuery, loadGmailMatches, 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);
const fromSubjects = messages.map((m) => m.subject).filter(Boolean) as string[];
const uniqueSubjects = Array.from(new Set(fromSubjects)).slice(0, 2);
const companyName = job?.company?.name?.trim();
const recruiterEmail = job?.company?.recruiterEmail?.trim();
const jobTitle = job?.jobTitle?.trim();
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]);
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 }>;
}, [job?.company?.name, job?.company?.recruiterEmail, job?.jobTitle, messages]);
const send = async () => {
if (!canSend) return;
@@ -227,6 +234,8 @@ export default function Correspondence({ jobId }: { jobId: number }) {
subject: parsed.subject ?? null,
content: parsed.body || rawEmail,
date: parsed.date ?? null,
externalFrom: parsed.from ?? null,
externalTo: parsed.to ?? null,
});
setImportOpen(false);
setRawEmail("");
@@ -251,7 +260,7 @@ export default function Correspondence({ jobId }: { jobId: number }) {
try {
await api.delete("/gmail/connection");
setGmailStatus({ connected: false });
setGmailMessages([]);
setGmailMatches(null);
toast(t("googleUnlinked"), "success");
} catch (error) {
toast(getApiErrorMessage(error, t("correspondenceDisconnectFailed")), "error");
@@ -272,9 +281,14 @@ export default function Correspondence({ jobId }: { jobId: number }) {
const importGmailMessage = async (messageId: string) => {
try {
setImportingMessageId(messageId);
await api.post("/gmail/import", { jobApplicationId: jobId, messageId });
const res = await api.post<GmailImportMessageResult>("/gmail/import", { jobApplicationId: jobId, messageId });
await load();
toast(t("correspondenceImportEmail"), "success");
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 {
@@ -285,8 +299,9 @@ export default function Correspondence({ jobId }: { jobId: number }) {
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 });
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");
@@ -310,6 +325,13 @@ export default function Correspondence({ jobId }: { jobId: number }) {
<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) ? (
<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}
</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()}` : ""}
@@ -360,11 +382,16 @@ export default function Correspondence({ jobId }: { jobId: number }) {
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{gmailLoading ? t("correspondenceCheckingConnection") : gmailStatus?.connected ? t("correspondenceConnectedAs", { email: gmailStatus.gmailAddress || "" }) : t("correspondenceConnectGmailHint")}
</Typography>
{job ? (
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>
Matching against {job.company?.name || "this company"} / {job.jobTitle}
</Typography>
) : null}
</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" onClick={() => void loadGmailMatches(gmailQuery)} disabled={gmailMatchesLoading}>{t("correspondenceRefresh")}</Button>
<Button variant="outlined" color="error" onClick={() => void disconnectGmail()}>{t("correspondenceDisconnect")}</Button>
</>
) : (
@@ -382,36 +409,65 @@ export default function Correspondence({ jobId }: { jobId: number }) {
</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>
<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}
<Paper variant="outlined" sx={{ maxHeight: 420, overflowY: "auto" }}>
{gmailMessagesLoading ? (
{gmailMatchesLoading ? (
<Box sx={{ py: 5, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box>
) : groupedByThread.length === 0 ? (
<Typography sx={{ color: "text.secondary", p: 2 }}>{t("correspondenceNoGmailMessages")}</Typography>
) : !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>
{groupedByThread.map(({ threadId, items }, threadIndex) => (
<React.Fragment key={threadId}>
{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 }}>{items[0]?.subject || t("correspondenceNoSubject")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("correspondenceMessagesInThread", { count: items.length })}</Typography>
<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 === threadId} onClick={() => void importGmailThread(threadId, items.map((x) => x.id))}>
{importingThreadId === threadId ? t("correspondenceImporting") : t("correspondenceImportThread")}
<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>
{items.map((message, index) => (
{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 sx={{ mt: 0.5 }}><Typography variant="body2" sx={{ color: "text.primary" }}>{t("correspondenceFromLabel", { value: message.from || t("correspondenceUnknown") })}</Typography><Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25 }}>{message.snippet}</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")}
@@ -357,7 +357,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
</Box>
)}
{tab === 1 && jobId && <Correspondence jobId={jobId} />}
{tab === 1 && jobId && <Correspondence jobId={jobId} job={job} />}
{tab === 2 && jobId && <Attachments jobId={jobId} />}
{tab === 3 && (
@@ -0,0 +1,230 @@
import React from "react";
import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { ConfirmProvider } from "./confirm";
import { PromptProvider } from "./prompt";
import { ToastProvider } from "./toast";
import { I18nProvider } from "./i18n/I18nProvider";
import JobDetailsDialog from "./components/JobDetailsDialog";
import { api } from "./api";
jest.setTimeout(15000);
jest.mock("./api", () => ({
api: {
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
patch: jest.fn(),
delete: jest.fn(),
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
},
}));
const mockedApi = api as jest.Mocked<typeof api>;
function renderDialog() {
return render(
<ToastProvider>
<I18nProvider>
<ConfirmProvider>
<PromptProvider>
<JobDetailsDialog open jobId={42} onClose={() => {}} initialTab={1} />
</PromptProvider>
</ConfirmProvider>
</I18nProvider>
</ToastProvider>,
);
}
describe("correspondence Gmail import", () => {
let correspondenceMessages: any[];
beforeEach(() => {
Object.assign(navigator, {
clipboard: {
writeText: jest.fn().mockResolvedValue(undefined),
},
});
window.open = jest.fn();
correspondenceMessages = [];
mockedApi.get.mockImplementation((url: string, config?: any) => {
if (url === "/jobapplications/42") {
return Promise.resolve({
data: {
id: 42,
jobTitle: "Backend Developer",
status: "Applied",
dateApplied: new Date().toISOString(),
daysSince: 3,
company: { name: "Acme", recruiterEmail: "maria@acme.test", recruiterName: "Maria Recruiter" },
tailoredCvText: "",
shortSummary: "summary",
},
} as any);
}
if (url === "/auth/me") {
return Promise.resolve({ data: { roles: [], profileCvText: "Master CV text" } } as any);
}
if (url === "/jobapplications/42/history") {
return Promise.resolve({ data: [] } as any);
}
if (url === "/attachments/42") {
return Promise.resolve({ data: [] } as any);
}
if (url === "/correspondence/42") {
return Promise.resolve({ data: correspondenceMessages } as any);
}
if (url === "/gmail/status") {
return Promise.resolve({ data: { connected: true, gmailAddress: "user@example.test", lastSyncedAt: new Date().toISOString() } } as any);
}
if (url === "/gmail/job-candidates") {
return Promise.resolve({
data: {
jobApplicationId: 42,
jobTitle: "Backend Developer",
companyName: "Acme",
recruiterName: "Maria Recruiter",
recruiterEmail: "maria@acme.test",
queries: [config?.params?.queryOverride || '"Acme" "Backend Developer" newer_than:365d'],
threads: [
{
threadId: "thread-1",
subject: "Backend Developer interview",
score: 42,
confidence: "high",
hasImportedMessages: false,
messageCount: 2,
latestDate: new Date().toISOString(),
matchReasons: [
{ label: "company", value: "Acme" },
{ label: "recruiterEmail", value: "maria@acme.test" },
],
messages: [
{
id: "msg-1",
threadId: "thread-1",
subject: "Backend Developer interview",
from: "Maria Recruiter <maria@acme.test>",
to: "user@example.test",
date: new Date().toISOString(),
snippet: "Acme wants to schedule a call.",
score: 42,
confidence: "high",
alreadyImported: false,
matchReasons: [
{ label: "company", value: "Acme" },
{ label: "jobTitle", value: "Developer" },
],
},
{
id: "msg-2",
threadId: "thread-1",
subject: "Backend Developer follow-up",
from: "user@example.test",
to: "Maria Recruiter <maria@acme.test>",
date: new Date().toISOString(),
snippet: "Following up on the role.",
score: 24,
confidence: "medium",
alreadyImported: false,
matchReasons: [{ label: "recency", value: "45d" }],
},
],
},
],
},
} as any);
}
return Promise.resolve({ data: [] } as any);
});
mockedApi.post.mockImplementation((url: string, body?: any) => {
if (url === "/gmail/import") {
correspondenceMessages = [
{
id: 700,
jobApplicationId: 42,
from: "Company",
content: "Acme wants to schedule a call.",
subject: "Backend Developer interview",
channel: "Email",
date: new Date().toISOString(),
externalMessageId: body.messageId,
externalThreadId: "thread-1",
externalFrom: "Maria Recruiter <maria@acme.test>",
externalTo: "user@example.test",
},
];
return Promise.resolve({
data: {
imported: 1,
skipped: 0,
messageId: body.messageId,
threadId: "thread-1",
message: correspondenceMessages[0],
},
} as any);
}
if (url === "/gmail/import-thread") {
return Promise.resolve({ data: { imported: 2, skipped: 0, threadId: "thread-1" } } as any);
}
if (url === "/correspondence") {
return Promise.resolve({ data: {} } as any);
}
return Promise.resolve({ data: {} } as any);
});
mockedApi.put.mockResolvedValue({ data: {} } as any);
mockedApi.delete.mockResolvedValue({ data: {} } as any);
});
afterEach(() => {
jest.clearAllMocks();
});
test("shows ranked Gmail suggestions with reasons and refreshes correspondence after message import", async () => {
renderDialog();
fireEvent.click(await screen.findByRole("button", { name: /import email/i }));
fireEvent.click(await screen.findByRole("tab", { name: /^google$/i }));
expect((await screen.findAllByText(/backend developer interview/i)).length).toBeGreaterThan(0);
expect((await screen.findAllByText(/high confidence · score 42/i)).length).toBeGreaterThan(0);
expect((await screen.findAllByText(/company: acme/i)).length).toBeGreaterThan(0);
expect(await screen.findByText(/recruiter email: maria@acme\.test/i)).toBeInTheDocument();
const importButtons = await screen.findAllByRole("button", { name: /^import email$/i });
fireEvent.click(importButtons[importButtons.length - 1]);
await waitFor(() => {
expect(mockedApi.post).toHaveBeenCalledWith("/gmail/import", { jobApplicationId: 42, messageId: "msg-2" });
});
expect(await screen.findByText(/thread thread-1/i)).toBeInTheDocument();
expect(await screen.findByText(/from maria recruiter <maria@acme\.test>/i)).toBeInTheDocument();
expect(await screen.findByText(/to user@example\.test/i)).toBeInTheDocument();
});
test("manual Gmail search override reloads job candidates with queryOverride", async () => {
renderDialog();
fireEvent.click(await screen.findByRole("button", { name: /import email/i }));
fireEvent.click(await screen.findByRole("tab", { name: /^google$/i }));
const search = await screen.findByLabelText(/search gmail/i);
fireEvent.change(search, { target: { value: 'subject:"Acme" newer_than:30d' } });
fireEvent.click(screen.getByRole("button", { name: /^search$/i }));
await waitFor(() => {
expect(mockedApi.get).toHaveBeenCalledWith("/gmail/job-candidates", expect.objectContaining({
params: expect.objectContaining({
jobApplicationId: 42,
queryOverride: 'subject:"Acme" newer_than:30d',
}),
}));
});
});
});
+58
View File
@@ -125,8 +125,66 @@ export interface CorrespondenceMessage {
subject?: string;
channel?: string;
date: string;
externalMessageId?: string | null;
externalThreadId?: string | null;
externalFrom?: string | null;
externalTo?: string | null;
}
export interface GmailJobMatchReason {
label: string;
value: string;
}
export interface GmailJobMatchedMessage {
id: string;
threadId: string;
subject: string;
from: string;
to: string;
date?: string;
snippet: string;
score: number;
confidence: string;
alreadyImported: boolean;
matchReasons: GmailJobMatchReason[];
}
export interface GmailJobMatchedThread {
threadId: string;
subject: string;
score: number;
confidence: string;
hasImportedMessages: boolean;
messageCount: number;
latestDate?: string;
matchReasons: GmailJobMatchReason[];
messages: GmailJobMatchedMessage[];
}
export interface GmailJobMatchesResponse {
jobApplicationId: number;
jobTitle: string;
companyName: string;
recruiterName?: string | null;
recruiterEmail?: string | null;
queries: string[];
threads: GmailJobMatchedThread[];
}
export interface GmailImportMessageResult {
imported: number;
skipped: number;
messageId: string;
threadId?: string | null;
message?: CorrespondenceMessage | null;
}
export interface GmailImportThreadResult {
imported: number;
skipped: number;
threadId?: string | null;
}
export interface GmailStatus {
connected: boolean;