From 6db3bffb2f8e67ea4feda5dbac462dc9b295100f Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 11 Jul 2026 19:20:33 +0200 Subject: [PATCH] feat(email): add provider picker to Settings > Account b5 of the multi-provider email roadmap (frontend). Adds EmailProviderConnections -- one card per provider (Gmail, Outlook/Microsoft 365, generic IMAP) showing connect status and connect/disconnect actions, mounted in SettingsView's Account tab alongside the existing app-login GoogleAuthCard (a separate concern: that card is sign-in identity, this is mailbox linking). Gmail and Microsoft reuse the OAuth-popup + postMessage handshake already built server-side (mirrors Correspondence.tsx's existing Gmail-connect flow). IMAP has no OAuth step, so it's a plain host/port/ssl/username/password form posting to /api/imap/connect, which verifies the connection server-side before storing it. Deliberately NOT touched: the Gmail-specific job-candidate-matching/review UI in Correspondence.tsx and GmailReviewPage.tsx. That backend pipeline (ListJobCandidateMessagesAsync, GmailReviewDecisions) is still Gmail-only by design -- generalising it now would mean building fake UI for capabilities Microsoft/IMAP don't have yet. This is scoped to the piece that's actually provider-neutral: connect/disconnect status. Verified live (backend + frontend dev servers): logged in, confirmed all three /status calls return 200, Gmail connect-url fetch succeeds, IMAP form submit hits /api/imap/connect and surfaces the expected 400 on a bad host. Frontend suite: 25 suites / 57 tests green (2 new). Co-Authored-By: Claude Opus 4.8 --- .../components/EmailProviderConnections.tsx | 243 ++++++++++++++++++ .../src/components/SettingsView.tsx | 4 + .../src/email-provider-connections.test.tsx | 72 ++++++ job-tracker-ui/src/types.ts | 29 +++ 4 files changed, 348 insertions(+) create mode 100644 job-tracker-ui/src/components/EmailProviderConnections.tsx create mode 100644 job-tracker-ui/src/email-provider-connections.test.tsx diff --git a/job-tracker-ui/src/components/EmailProviderConnections.tsx b/job-tracker-ui/src/components/EmailProviderConnections.tsx new file mode 100644 index 0000000..75e3437 --- /dev/null +++ b/job-tracker-ui/src/components/EmailProviderConnections.tsx @@ -0,0 +1,243 @@ +import React, { useCallback, useEffect, useState } from "react"; + +import { Box, Button, Checkbox, Chip, Divider, FormControlLabel, Paper, Stack, TextField, Typography } from "@mui/material"; +import CheckCircleIcon from "@mui/icons-material/CheckCircle"; + +import { api, getApiErrorMessage } from "../api"; +import { useToast } from "../toast"; +import type { GmailStatus, ImapStatus, MicrosoftGraphStatus } from "../types"; + +// Settings > Account: connect/disconnect each linked-mailbox provider. Gmail and Microsoft use +// the same OAuth-popup + postMessage handshake (mirrored server-side in GmailController / +// MicrosoftGraphController's BuildPopupHtml); IMAP has no OAuth step, so it's a plain credential +// form submitted to POST /api/imap/connect, which verifies the connection before storing it. +export default function EmailProviderConnections() { + const { toast } = useToast(); + + const [gmailStatus, setGmailStatus] = useState(null); + const [microsoftStatus, setMicrosoftStatus] = useState(null); + const [imapStatus, setImapStatus] = useState(null); + + const [imapForm, setImapForm] = useState({ host: "", port: 993, useSsl: true, username: "", password: "" }); + const [imapConnecting, setImapConnecting] = useState(false); + + const loadGmailStatus = useCallback(async () => { + try { + const res = await api.get("/gmail/status"); + setGmailStatus(res.data); + } catch { + setGmailStatus({ connected: false }); + } + }, []); + + const loadMicrosoftStatus = useCallback(async () => { + try { + const res = await api.get("/microsoft-graph/status"); + setMicrosoftStatus(res.data); + } catch { + setMicrosoftStatus({ connected: false }); + } + }, []); + + const loadImapStatus = useCallback(async () => { + try { + const res = await api.get("/imap/status"); + setImapStatus(res.data); + } catch { + setImapStatus({ connected: false }); + } + }, []); + + useEffect(() => { + void loadGmailStatus(); + void loadMicrosoftStatus(); + void loadImapStatus(); + }, [loadGmailStatus, loadMicrosoftStatus, loadImapStatus]); + + useEffect(() => { + const onMessage = (event: MessageEvent) => { + const data = event.data as { source?: string; status?: string; message?: string }; + if (data?.source === "jobtracker-gmail-oauth") { + if (data.status === "connected") { + toast(data.message || "Gmail connected.", "success"); + void loadGmailStatus(); + } else { + toast(data.message || "Gmail connection failed.", "error"); + } + } else if (data?.source === "jobtracker-microsoft-oauth") { + if (data.status === "connected") { + toast(data.message || "Outlook connected.", "success"); + void loadMicrosoftStatus(); + } else { + toast(data.message || "Outlook connection failed.", "error"); + } + } + }; + + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, [loadGmailStatus, loadMicrosoftStatus, toast]); + + const connectViaPopup = async (connectUrlPath: string, popupName: string, providerLabel: string) => { + try { + const res = await api.get<{ url: string }>(connectUrlPath); + const popup = window.open(res.data.url, popupName, "width=620,height=760,resizable=yes,scrollbars=yes"); + if (!popup) toast("Your browser blocked the connect popup. Allow popups and try again.", "error"); + } catch (error) { + toast(getApiErrorMessage(error, `Failed to start ${providerLabel} connection.`), "error"); + } + }; + + const disconnect = async (path: string, reload: () => Promise, providerLabel: string) => { + try { + await api.delete(path); + await reload(); + toast(`${providerLabel} disconnected.`, "success"); + } catch (error) { + toast(getApiErrorMessage(error, `Failed to disconnect ${providerLabel}.`), "error"); + } + }; + + const connectImap = async () => { + if (!imapForm.host.trim() || !imapForm.username.trim() || !imapForm.password) { + toast("Host, username, and password are required.", "error"); + return; + } + setImapConnecting(true); + try { + await api.post("/imap/connect", imapForm); + setImapForm((prev) => ({ ...prev, password: "" })); + await loadImapStatus(); + toast("IMAP account connected.", "success"); + } catch (error) { + toast(getApiErrorMessage(error, "Failed to connect IMAP account."), "error"); + } finally { + setImapConnecting(false); + } + }; + + return ( + + Linked email accounts + + Connect a mailbox so recruiter correspondence can be linked to jobs automatically. + + + + void connectViaPopup("/gmail/connect-url", "jobtracker-gmail-connect", "Gmail")} + onDisconnect={() => void disconnect("/gmail/connection", loadGmailStatus, "Gmail")} + /> + + + + void connectViaPopup("/microsoft-graph/connect-url", "jobtracker-microsoft-connect", "Outlook")} + onDisconnect={() => void disconnect("/microsoft-graph/connection", loadMicrosoftStatus, "Outlook")} + /> + + + + + void disconnect("/imap/connection", loadImapStatus, "IMAP")} + /> + {!imapStatus?.connected && ( + + setImapForm((prev) => ({ ...prev, host: e.target.value }))} + /> + setImapForm((prev) => ({ ...prev, port: Number(e.target.value) || 993 }))} + /> + setImapForm((prev) => ({ ...prev, username: e.target.value }))} + sx={{ gridColumn: "1 / -1" }} + /> + setImapForm((prev) => ({ ...prev, password: e.target.value }))} + sx={{ gridColumn: "1 / -1" }} + /> + setImapForm((prev) => ({ ...prev, useSsl: e.target.checked }))} />} + label="Use SSL/TLS" + /> + + + )} + + + + ); +} + +function ProviderRow({ + label, + connected, + address, + onConnect, + onDisconnect, +}: { + label: string; + connected: boolean; + address: string | null; + onConnect?: () => void; + onDisconnect: () => void; +}) { + return ( + + + {label} + {connected ? ( + } + color="success" + variant="outlined" + label={address || "Connected"} + sx={{ mt: 0.5 }} + /> + ) : ( + Not connected + )} + + {connected ? ( + + ) : ( + onConnect && + )} + + ); +} diff --git a/job-tracker-ui/src/components/SettingsView.tsx b/job-tracker-ui/src/components/SettingsView.tsx index a5d89a2..b9187cc 100644 --- a/job-tracker-ui/src/components/SettingsView.tsx +++ b/job-tracker-ui/src/components/SettingsView.tsx @@ -22,6 +22,7 @@ import { useNavigate } from "react-router-dom"; import { JobTableColumns } from "./JobTable"; import ImportExportJobs from "./ImportExportJobs"; import GoogleAuthCard from "./GoogleAuthCard"; +import EmailProviderConnections from "./EmailProviderConnections"; import RulesSettingsCard from "./RulesSettingsCard"; import BackupCard from "./BackupCard"; import QuickCaptureCard from "./QuickCaptureCard"; @@ -338,6 +339,9 @@ export default function SettingsView({ + + + diff --git a/job-tracker-ui/src/email-provider-connections.test.tsx b/job-tracker-ui/src/email-provider-connections.test.tsx new file mode 100644 index 0000000..bad4e76 --- /dev/null +++ b/job-tracker-ui/src/email-provider-connections.test.tsx @@ -0,0 +1,72 @@ +import React from "react"; +import "@testing-library/jest-dom"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ToastProvider } from "./toast"; +import { api } from "./api"; +import EmailProviderConnections from "./components/EmailProviderConnections"; + +jest.mock("./api", () => ({ + api: { + get: jest.fn(), + post: jest.fn(), + delete: jest.fn(), + interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } }, + }, + getApiErrorMessage: (_err: unknown, fallback: string) => fallback, +})); + +const mockedApi = api as jest.Mocked; + +function renderComponent() { + return render( + + + , + ); +} + +describe("EmailProviderConnections", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("renders connected state for Gmail and Outlook, disconnected form for IMAP", async () => { + mockedApi.get.mockImplementation((path: string) => { + if (path === "/gmail/status") return Promise.resolve({ data: { connected: true, gmailAddress: "me@gmail.test" } }); + if (path === "/microsoft-graph/status") return Promise.resolve({ data: { connected: false } }); + if (path === "/imap/status") return Promise.resolve({ data: { connected: false } }); + return Promise.reject(new Error("unexpected path")); + }); + + renderComponent(); + + expect(await screen.findByText("me@gmail.test")).toBeInTheDocument(); + expect(screen.getByLabelText("IMAP host")).toBeInTheDocument(); + expect(screen.getAllByText("Not connected").length).toBeGreaterThan(0); + }); + + it("submits IMAP connect form and reloads status on success", async () => { + mockedApi.get.mockImplementation((path: string) => { + if (path === "/gmail/status") return Promise.resolve({ data: { connected: false } }); + if (path === "/microsoft-graph/status") return Promise.resolve({ data: { connected: false } }); + if (path === "/imap/status") return Promise.resolve({ data: { connected: false } }); + return Promise.reject(new Error("unexpected path")); + }); + mockedApi.post.mockResolvedValueOnce({ data: { username: "user@example.test" } }); + + renderComponent(); + await screen.findByLabelText("IMAP host"); + + await userEvent.type(screen.getByLabelText("IMAP host"), "imap.example.test"); + await userEvent.type(screen.getByLabelText("Username"), "user@example.test"); + await userEvent.type(screen.getByLabelText("Password"), "secret"); + await userEvent.click(screen.getByRole("button", { name: /connect imap account/i })); + + await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith("/imap/connect", expect.objectContaining({ + host: "imap.example.test", + username: "user@example.test", + password: "secret", + }))); + }); +}); diff --git a/job-tracker-ui/src/types.ts b/job-tracker-ui/src/types.ts index 9efd0d6..9143db6 100644 --- a/job-tracker-ui/src/types.ts +++ b/job-tracker-ui/src/types.ts @@ -384,6 +384,35 @@ export interface GmailStatus { lastSyncError?: string | null; } +export interface MicrosoftGraphStatus { + connected: boolean; + mailAddress?: string | null; + connectedAt?: string; + lastSyncedAt?: string; + lastSyncAttemptedAt?: string; + lastSyncSucceededAt?: string; + lastSyncMode?: string | null; + lastSyncSource?: string | null; + lastSyncStatus?: string | null; + lastSyncError?: string | null; +} + +export interface ImapStatus { + connected: boolean; + host?: string | null; + port?: number | null; + useSsl?: boolean | null; + username?: string | null; + connectedAt?: string; + lastSyncedAt?: string; + lastSyncAttemptedAt?: string; + lastSyncSucceededAt?: string; + lastSyncMode?: string | null; + lastSyncSource?: string | null; + lastSyncStatus?: string | null; + lastSyncError?: string | null; +} + export interface GmailManualSyncResult { queriesRun: number; candidateThreadCount: number;