import React, { useCallback, useEffect, useRef, useState } from "react"; import { Alert, Box, Button, Checkbox, Divider, FormControlLabel, Stack, TextField, Typography } from "@mui/material"; import { api, getApiErrorMessage } from "../api"; import { useI18n } from "../i18n/I18nProvider"; import { useToast } from "../toast"; import type { GmailStatus, ImapStatus, MicrosoftGraphStatus } from "../types"; import { emailOAuthCallbackOrigin, emailOAuthMessage, type EmailOAuthAttempt, type EmailOAuthProvider } from "../emailOAuth"; import { PageSection } from "../layout/PageLayout"; type Provider = EmailOAuthProvider | "imap"; type ConnectionStatus = GmailStatus & MicrosoftGraphStatus & ImapStatus; type ProviderState = { status: ConnectionStatus | null; loading: boolean; readFailed: boolean; refreshRequired: boolean; busy: "connect" | "disconnect" | null; error: { kind: "disconnect" | "imap"; cause: unknown } | null; }; const providerPaths = { gmail: "/gmail", microsoft: "/microsoft-graph", imap: "/imap" }; const initialProvider = (): ProviderState => ({ status: null, loading: true, readFailed: false, refreshRequired: false, busy: null, error: null }); const providerWork = () => ({ reading: false, writing: false, refreshRequired: false, sequence: 0, controller: null as AbortController | null, status: null as ConnectionStatus | null }); // 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 { t } = useI18n(); const [providers, setProviders] = useState({ gmail: initialProvider(), microsoft: initialProvider(), imap: initialProvider() }); const work = useRef({ gmail: providerWork(), microsoft: providerWork(), imap: providerWork() }); const headings = useRef>>({}); const panels = useRef>>({}); const pendingFocus = useRef<{ provider: Provider; element: HTMLElement } | null>(null); const [imapForm, setImapForm] = useState({ host: "", port: "993", useSsl: true, username: "", password: "" }); const [invalidPort, setInvalidPort] = useState(false); const oauth = useRef({ mounted: true, generation: 0, starting: new Set(), attempts: new Map() }); const startupTranslation = useRef(t); useEffect(() => { startupTranslation.current = t; }, [t]); const updateProvider = useCallback((provider: Provider, patch: Partial) => { setProviders(current => ({ ...current, [provider]: { ...current[provider], ...patch } })); }, []); const rememberFocus = (provider: Provider) => { const element = document.activeElement; if (element instanceof HTMLElement && panels.current[provider]?.contains(element)) pendingFocus.current = { provider, element }; }; useEffect(() => { const pending = pendingFocus.current; if (!pending) return; pendingFocus.current = null; const active = document.activeElement; if (active === document.body || active === pending.element && (pending.element.matches(":disabled") || !pending.element.isConnected)) headings.current[pending.provider]?.focus(); }, [providers]); useEffect(() => { const session = oauth.current; const providerSessions = work.current; session.mounted = true; return () => { session.mounted = false; session.generation += 1; session.starting.clear(); session.attempts.clear(); for (const provider of Object.values(providerSessions)) { provider.sequence += 1; provider.controller?.abort(); provider.reading = false; } }; }, []); const loadStatus = useCallback(async (provider: Provider, afterWrite = false): Promise => { const session = oauth.current; const current = work.current[provider]; if (!session.mounted || current.reading || current.writing && !afterWrite) return null; current.reading = true; const sequence = ++current.sequence; const generation = session.generation; const controller = new AbortController(); current.controller = controller; updateProvider(provider, { loading: true, readFailed: false }); const active = () => session.mounted && generation === session.generation && sequence === current.sequence; try { const { data } = await api.get(`${providerPaths[provider]}/status`, { signal: controller.signal }); if (!active()) return null; if (typeof data?.connected !== "boolean") throw new Error("Invalid connection status"); current.status = data; current.refreshRequired = false; updateProvider(provider, { status: data, readFailed: false, refreshRequired: false }); return data; } catch { if (active()) updateProvider(provider, { readFailed: true }); return null; } finally { if (active()) { current.reading = false; current.controller = null; updateProvider(provider, { loading: false }); } } }, [updateProvider]); const loadGmailStatus = useCallback(async () => { await loadStatus("gmail"); }, [loadStatus]); const loadMicrosoftStatus = useCallback(async () => { await loadStatus("microsoft"); }, [loadStatus]); const loadImapStatus = useCallback(async () => { await loadStatus("imap"); }, [loadStatus]); useEffect(() => { void loadGmailStatus(); void loadMicrosoftStatus(); void loadImapStatus(); }, [loadGmailStatus, loadMicrosoftStatus, loadImapStatus]); useEffect(() => { const onMessage = (event: MessageEvent) => { if (!oauth.current.mounted) return; for (const [provider, attempt] of oauth.current.attempts) { const data = emailOAuthMessage(event, attempt); if (!data) continue; oauth.current.attempts.delete(provider); if (data.status === "connected") { toast(data.message || t(provider === "gmail" ? "emailConnectionsGmailConnected" : "emailConnectionsOutlookConnected"), "success"); void (provider === "gmail" ? loadGmailStatus() : loadMicrosoftStatus()); } else { toast(data.message || t(provider === "gmail" ? "emailConnectionsGmailFailed" : "emailConnectionsOutlookFailed"), "error"); } return; } }; window.addEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage); }, [loadGmailStatus, loadMicrosoftStatus, t, toast]); const connectViaPopup = async (provider: EmailOAuthProvider, connectUrlPath: string, popupName: string, providerLabel: string) => { const session = oauth.current; const current = work.current[provider]; if (!session.mounted || session.starting.has(provider) || current.reading || current.writing || current.refreshRequired || !current.status || current.status.connected) return; const previous = session.attempts.get(provider); if (previous && !previous.popup.closed) { previous.popup.focus(); return; } session.attempts.delete(provider); session.starting.add(provider); rememberFocus(provider); updateProvider(provider, { busy: "connect", error: null }); const generation = session.generation; try { const res = await api.get<{ url: string }>(connectUrlPath); if (!session.mounted || generation !== session.generation) return; const origin = emailOAuthCallbackOrigin(res.data.url, provider); if (!origin) { toast(startupTranslation.current("emailConnectionsStartFailed", { provider: providerLabel }), "error"); return; } const popup = window.open(res.data.url, popupName, "width=620,height=760,resizable=yes,scrollbars=yes"); if (!popup) toast(startupTranslation.current("emailConnectionsPopupBlocked"), "error"); else session.attempts.set(provider, { provider, origin, popup }); } catch (error) { if (session.mounted && generation === session.generation) toast(getApiErrorMessage(error, startupTranslation.current("emailConnectionsStartFailed", { provider: providerLabel })), "error"); } finally { if (session.mounted && generation === session.generation) { session.starting.delete(provider); updateProvider(provider, { busy: null }); } } }; const disconnect = async (provider: Provider, providerLabel: string) => { const session = oauth.current; const current = work.current[provider]; if (!session.mounted || current.reading || current.writing || current.refreshRequired || !current.status?.connected || provider !== "imap" && session.starting.has(provider)) return; current.writing = true; const generation = session.generation; rememberFocus(provider); updateProvider(provider, { busy: "disconnect", error: null }); try { await api.delete(`${providerPaths[provider]}/connection`); if (!session.mounted || generation !== session.generation) return; current.status = null; current.refreshRequired = true; updateProvider(provider, { status: null, refreshRequired: true }); const status = await loadStatus(provider, true); if (session.mounted && generation === session.generation && status && !status.connected) toast(startupTranslation.current("emailConnectionsDisconnected", { provider: providerLabel }), "success"); } catch (error) { if (session.mounted && generation === session.generation) updateProvider(provider, { error: { kind: "disconnect", cause: error } }); } finally { if (session.mounted && generation === session.generation) { current.writing = false; updateProvider(provider, { busy: null }); } } }; const connectImap = async () => { const session = oauth.current; const current = work.current.imap; if (!session.mounted || current.reading || current.writing || current.refreshRequired || !current.status || current.status.connected) return; if (!imapForm.host.trim() || !imapForm.username.trim() || !imapForm.password) { toast(t("emailConnectionsRequired"), "error"); return; } const port = Number(imapForm.port); if (!imapForm.port.trim() || !Number.isInteger(port) || port < 1 || port > 65535) { setInvalidPort(true); return; } const submitted = { ...imapForm }; current.writing = true; const generation = session.generation; rememberFocus("imap"); updateProvider("imap", { busy: "connect", error: null }); try { await api.post("/imap/connect", { ...submitted, port }); if (!session.mounted || generation !== session.generation) return; rememberFocus("imap"); // A completed submission must not clear credentials edited for another attempt. setImapForm(previous => Object.keys(submitted).every(key => previous[key as keyof typeof previous] === submitted[key as keyof typeof submitted]) ? { ...previous, password: "" } : previous); current.status = null; current.refreshRequired = true; updateProvider("imap", { status: null, refreshRequired: true }); const status = await loadStatus("imap", true); if (session.mounted && generation === session.generation && status?.connected) toast(startupTranslation.current("emailConnectionsImapConnected"), "success"); } catch (error) { if (session.mounted && generation === session.generation) updateProvider("imap", { error: { kind: "imap", cause: error } }); } finally { if (session.mounted && generation === session.generation) { current.writing = false; updateProvider("imap", { busy: null }); } } }; const providerFeedback = (provider: Provider, providerLabel: string) => { const state = providers[provider]; return <> {state.loading ? {t("emailConnectionsLoading")} : null} {state.readFailed ? { rememberFocus(provider); void loadStatus(provider); }}>{t("retry")}}> {t(state.refreshRequired ? "emailConnectionsRefreshRequired" : "emailConnectionsStatusFailed")} : null} {state.error ? {getApiErrorMessage(state.error.cause, t(state.error.kind === "imap" ? "emailConnectionsImapFailed" : "emailConnectionsDisconnectFailed", { provider: providerLabel }))} : null} ; }; return ( { panels.current.gmail = node; }} headingRef={node => { headings.current.gmail = node; }} onConnect={() => void connectViaPopup("gmail", "/gmail/connect-url", "jobtracker-gmail-connect", "Gmail")} onDisconnect={() => void disconnect("gmail", "Gmail")} >{providerFeedback("gmail", "Gmail")} { panels.current.microsoft = node; }} headingRef={node => { headings.current.microsoft = node; }} onConnect={() => void connectViaPopup("microsoft", "/microsoft-graph/connect-url", "jobtracker-microsoft-connect", "Outlook")} onDisconnect={() => void disconnect("microsoft", "Outlook")} >{providerFeedback("microsoft", "Outlook")} { panels.current.imap = node; }} headingRef={node => { headings.current.imap = node; }} onDisconnect={() => void disconnect("imap", "IMAP")} > {providerFeedback("imap", "IMAP")} {providers.imap.status && !providers.imap.status.connected && !providers.imap.readFailed && !providers.imap.refreshRequired && ( setImapForm((prev) => ({ ...prev, host: e.target.value }))} /> { setImapForm((prev) => ({ ...prev, port: e.target.value })); setInvalidPort(false); }} /> 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={t("emailConnectionsUseSsl")} /> )} ); } function ProviderRow({ label, state, address, panelRef, headingRef, onConnect, onDisconnect, children, }: { label: string; state: ProviderState; address: string | null; panelRef: (node: HTMLElement | null) => void; headingRef: (node: HTMLHeadingElement | null) => void; onConnect?: () => void; onDisconnect: () => void; children: React.ReactNode; }) { const { t } = useI18n(); return ( {label} {state.status && !state.loading && !state.readFailed && !state.refreshRequired ? {state.status.connected ? address || t("emailConnectionsConnected") : t("emailConnectionsNotConnected")} : null} {state.status && !state.readFailed && !state.refreshRequired ? state.status.connected ? ( ) : onConnect ? : null : null} {children} ); }