891 lines
49 KiB
TypeScript
891 lines
49 KiB
TypeScript
import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
|
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
|
import {
|
|
Box,
|
|
Alert,
|
|
Autocomplete,
|
|
Chip,
|
|
FormControl,
|
|
InputLabel,
|
|
MenuItem,
|
|
Pagination,
|
|
Paper,
|
|
Select,
|
|
Stack,
|
|
TextField,
|
|
Typography,
|
|
Button,
|
|
} from "@mui/material";
|
|
import MailOutlineIcon from "@mui/icons-material/MailOutline";
|
|
import { api, getApiErrorMessage } from "../api";
|
|
import { useConfirm } from "../confirm";
|
|
import { useToast } from "../toast";
|
|
import { useI18n } from "../i18n/I18nProvider";
|
|
import GmailReviewPage from "./GmailReviewPage";
|
|
import ViewStateNotice from "../components/ViewStateNotice";
|
|
import { PageSection } from "../layout/PageLayout";
|
|
import type { ViewResourceError } from "../hooks/useViewResource";
|
|
|
|
export type CorrespondenceInboxItem = {
|
|
id: number;
|
|
jobApplicationId: number;
|
|
companyName?: string | null;
|
|
jobTitle?: string | null;
|
|
from: string;
|
|
direction?: string | null;
|
|
subject?: string | null;
|
|
channel?: string | null;
|
|
date: string;
|
|
contentPreview: string;
|
|
externalThreadId?: string | null;
|
|
externalMessageId?: string | null;
|
|
provider?: string | null;
|
|
externalFrom?: string | null;
|
|
externalTo?: string | null;
|
|
labelCount: number;
|
|
attachmentCount: number;
|
|
};
|
|
|
|
type CorrespondenceInboxPage = {
|
|
items: CorrespondenceInboxItem[];
|
|
page: number;
|
|
pageSize: number;
|
|
total: number;
|
|
totalPages: number;
|
|
};
|
|
|
|
type EmailProviderStatus = {
|
|
provider: string;
|
|
displayName: string;
|
|
connected: boolean;
|
|
address?: string | null;
|
|
canRead: boolean;
|
|
canSend: boolean;
|
|
};
|
|
|
|
type JobChoice = {
|
|
id: number;
|
|
jobTitle: string;
|
|
companyName: string;
|
|
};
|
|
|
|
type EmailMessageDetail = {
|
|
id: string;
|
|
threadId: string;
|
|
subject: string;
|
|
from: string;
|
|
to: string;
|
|
date?: string | null;
|
|
snippet: string;
|
|
bodyText: string;
|
|
labels: string[];
|
|
attachments: Array<{ fileName?: string | null; mimeType?: string | null; sizeBytes?: number | null }>;
|
|
};
|
|
|
|
type EmailDraft = {
|
|
id?: string;
|
|
revision?: number;
|
|
jobApplicationId: number;
|
|
companyName: string;
|
|
jobTitle: string;
|
|
provider: string;
|
|
providerName: string;
|
|
fromAddress: string;
|
|
to: string;
|
|
subject: string;
|
|
bodyText: string;
|
|
threadId: string;
|
|
clientRequestId: string;
|
|
};
|
|
|
|
type StoredEmailDraft = Pick<EmailDraft, "id" | "revision" | "jobApplicationId" | "provider" | "to" | "subject" | "bodyText" | "threadId" | "clientRequestId"> & {
|
|
id: string;
|
|
revision: number;
|
|
createdAtUtc: string;
|
|
updatedAtUtc: string;
|
|
};
|
|
|
|
type EmailSendResult = {
|
|
attemptId: string;
|
|
status: "pending" | "sending" | "sent" | "failed" | "uncertain";
|
|
duplicate: boolean;
|
|
externalMessageId?: string | null;
|
|
externalThreadId?: string | null;
|
|
failureCategory?: string | null;
|
|
};
|
|
|
|
type DraftMutationError = ViewResourceError & { action: "save" | "discard" | "prepare" };
|
|
|
|
function readError(error: any): ViewResourceError {
|
|
const status = error?.response?.status as number | undefined;
|
|
const unauthorized = status === 401 || status === 403;
|
|
return { kind: unauthorized ? "unauthorized" : "error", status, retryable: !unauthorized, message: getApiErrorMessage(error, "") };
|
|
}
|
|
|
|
export default function CorrespondenceInboxPage() {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const [searchParams] = useSearchParams();
|
|
const view = searchParams.get("view") === "review" ? "review" : "inbox";
|
|
const { toast } = useToast();
|
|
const { confirm } = useConfirm();
|
|
const { language, t } = useI18n();
|
|
const [items, setItems] = useState<CorrespondenceInboxItem[]>([]);
|
|
const page = Number(searchParams.get("page"));
|
|
const inboxPage = Number.isInteger(page) && page > 0 && page <= 2147483647 ? page : 1;
|
|
const [inboxTotal, setInboxTotal] = useState(0);
|
|
const [inboxTotalPages, setInboxTotalPages] = useState(0);
|
|
const [providers, setProviders] = useState<EmailProviderStatus[]>([]);
|
|
const [providerStatusLoaded, setProviderStatusLoaded] = useState(false);
|
|
const [jobs, setJobs] = useState<JobChoice[]>([]);
|
|
const [jobsLoaded, setJobsLoaded] = useState(false);
|
|
const [jobsLoading, setJobsLoading] = useState(false);
|
|
const [jobsError, setJobsError] = useState<ViewResourceError | null>(null);
|
|
const [loadedJobQuery, setLoadedJobQuery] = useState<string | null>(null);
|
|
const jobsRequest = useRef(0);
|
|
const pendingJobs = useRef<string | null>(null);
|
|
const jobsPanel = useRef<HTMLDivElement>(null);
|
|
const [jobQuery, setJobQuery] = useState("");
|
|
const [composeSetupOpen, setComposeSetupOpen] = useState(false);
|
|
const [composeJobId, setComposeJobId] = useState<number | "">("");
|
|
const [composeProvider, setComposeProvider] = useState("");
|
|
const [providerStatusError, setProviderStatusError] = useState(false);
|
|
const [providerError, setProviderError] = useState<ViewResourceError | null>(null);
|
|
const [providersLoading, setProvidersLoading] = useState(false);
|
|
const providerPending = useRef(false);
|
|
const [selectedMessageId, setSelectedMessageId] = useState<number | null>(null);
|
|
const [messageDetail, setMessageDetail] = useState<EmailMessageDetail | null>(null);
|
|
const [detailLoading, setDetailLoading] = useState(false);
|
|
const [detailError, setDetailError] = useState<ViewResourceError | null>(null);
|
|
const [detailNotice, setDetailNotice] = useState(false);
|
|
const detailRequest = useRef(0);
|
|
const detailItem = useRef<CorrespondenceInboxItem | null>(null);
|
|
const detailPending = useRef(false);
|
|
const detailPanel = useRef<HTMLDivElement>(null);
|
|
const inboxPanel = useRef<HTMLDivElement>(null);
|
|
const providerPanel = useRef<HTMLDivElement>(null);
|
|
const rowId = useId();
|
|
const [loading, setLoading] = useState(false);
|
|
const [loadError, setLoadError] = useState<ViewResourceError | null>(null);
|
|
const [hasLoaded, setHasLoaded] = useState(false);
|
|
const listRequest = useRef(0);
|
|
const pendingList = useRef<string | null>(null);
|
|
const [loadedListKey, setLoadedListKey] = useState<string | null>(null);
|
|
const query = searchParams.get("q") ?? "";
|
|
const direction = ["inbound", "outbound", "internal"].includes(searchParams.get("direction") ?? "") ? searchParams.get("direction")! : "all";
|
|
const linkState = ["linked", "manual"].includes(searchParams.get("linkState") ?? "") ? searchParams.get("linkState")! : "all";
|
|
const currentListKey = JSON.stringify([query.trim(), direction, linkState, inboxPage]);
|
|
const hasCurrentInbox = loadedListKey === currentListKey && loadError?.kind !== "unauthorized";
|
|
const [reviewVisited, setReviewVisited] = useState(view === "review");
|
|
const mounted = useRef(true);
|
|
const [draftsReadError, setDraftsReadError] = useState<ViewResourceError | null>(null);
|
|
const [draftsLoading, setDraftsLoading] = useState(false);
|
|
const draftsPending = useRef(false);
|
|
const draftsRequest = useRef(0);
|
|
const draftsPanel = useRef<HTMLDivElement>(null);
|
|
const [draft, setDraft] = useState<EmailDraft | null>(null);
|
|
const draftRef = useRef(draft);
|
|
draftRef.current = draft;
|
|
const draftBaseline = useRef<EmailDraft | null>(null);
|
|
const draftPanel = useRef<HTMLDivElement>(null);
|
|
const composeOperation = useRef(false);
|
|
const [composeBusy, setComposeBusy] = useState(false);
|
|
const latestT = useRef(t);
|
|
latestT.current = t;
|
|
const [storedDrafts, setStoredDrafts] = useState<StoredEmailDraft[]>([]);
|
|
const [savingDraft, setSavingDraft] = useState(false);
|
|
const [draftError, setDraftError] = useState<DraftMutationError | null>(null);
|
|
const [sendResult, setSendResult] = useState<EmailSendResult | null>(null);
|
|
const [sendError, setSendError] = useState<ViewResourceError | null>(null);
|
|
const [sending, setSending] = useState(false);
|
|
const [unlinkingThreadId, setUnlinkingThreadId] = useState<string | null>(null);
|
|
const unlinkPending = useRef(false);
|
|
const [unlinkError, setUnlinkError] = useState<ViewResourceError | null>(null);
|
|
|
|
const updateFilters = useCallback((changes: Record<string, string>) => {
|
|
const next = new URLSearchParams(searchParams);
|
|
for (const [key, value] of Object.entries(changes)) {
|
|
if (!value || (value === "all" && (key === "direction" || key === "linkState")) || (key === "page" && value === "1") || (key === "view" && value === "inbox")) next.delete(key);
|
|
else next.set(key, value);
|
|
}
|
|
navigate({ pathname: location.pathname, search: `?${next}`, hash: location.hash }, { state: location.state, replace: Object.hasOwn(changes, "q") });
|
|
}, [location.hash, location.pathname, location.state, navigate, searchParams]);
|
|
const latestFilters = useRef(updateFilters);
|
|
latestFilters.current = updateFilters;
|
|
|
|
useEffect(() => {
|
|
mounted.current = true;
|
|
return () => { mounted.current = false; listRequest.current += 1; detailRequest.current += 1; jobsRequest.current += 1; draftsRequest.current += 1; draftsPending.current = false; };
|
|
}, []);
|
|
|
|
const load = useCallback(async () => {
|
|
if (view !== "inbox") return;
|
|
const key = JSON.stringify([query.trim(), direction, linkState, inboxPage]);
|
|
if (pendingList.current === key) return;
|
|
const request = ++listRequest.current;
|
|
pendingList.current = key;
|
|
setLoading(true);
|
|
setLoadError(null);
|
|
try {
|
|
const res = await api.get<CorrespondenceInboxPage>("/correspondence/page", {
|
|
params: {
|
|
q: query.trim() || undefined,
|
|
direction: direction === "all" ? undefined : direction,
|
|
linkState: linkState === "all" ? undefined : linkState,
|
|
page: inboxPage,
|
|
pageSize: 50,
|
|
},
|
|
});
|
|
if (!mounted.current || request !== listRequest.current) return;
|
|
setItems(res.data?.items ?? []);
|
|
setInboxTotal(res.data?.total ?? 0);
|
|
setInboxTotalPages(res.data?.totalPages ?? 0);
|
|
setLoadedListKey(key);
|
|
if (res.data?.page && res.data.page !== inboxPage) latestFilters.current({ page: String(res.data.page) });
|
|
setHasLoaded(true);
|
|
detailRequest.current += 1;
|
|
detailItem.current = null;
|
|
detailPending.current = false;
|
|
setSelectedMessageId(null);
|
|
setMessageDetail(null);
|
|
} catch (error) {
|
|
if (mounted.current && request === listRequest.current) setLoadError(readError(error));
|
|
} finally {
|
|
if (mounted.current && request === listRequest.current) { pendingList.current = null; setLoading(false); }
|
|
}
|
|
}, [direction, inboxPage, linkState, query, view]);
|
|
const latestLoad = useRef(load);
|
|
latestLoad.current = load;
|
|
|
|
useEffect(() => {
|
|
if (view === "inbox") void load();
|
|
else setReviewVisited(true);
|
|
return () => {
|
|
listRequest.current += 1;
|
|
pendingList.current = null;
|
|
detailRequest.current += 1;
|
|
detailItem.current = null;
|
|
detailPending.current = false;
|
|
setSelectedMessageId(null);
|
|
setMessageDetail(null);
|
|
setDetailLoading(false);
|
|
};
|
|
}, [load, view]);
|
|
|
|
const loadProviders = useCallback(async () => {
|
|
if (providerPending.current) return;
|
|
providerPending.current = true;
|
|
setProvidersLoading(true);
|
|
try {
|
|
const response = await api.get<EmailProviderStatus[]>("/email/providers");
|
|
if (!mounted.current) return;
|
|
setProviders(response.data ?? []);
|
|
setProviderStatusError(false);
|
|
setProviderError(null);
|
|
} catch (error) {
|
|
if (!mounted.current) return;
|
|
setProviders([]);
|
|
setProviderStatusError(true);
|
|
setProviderError(readError(error));
|
|
} finally {
|
|
providerPending.current = false;
|
|
if (mounted.current) { setProviderStatusLoaded(true); setProvidersLoading(false); }
|
|
}
|
|
}, []);
|
|
useEffect(() => { void loadProviders(); }, [loadProviders]);
|
|
|
|
const loadJobs = useCallback(async () => {
|
|
const key = jobQuery.trim();
|
|
if (pendingJobs.current === key) return;
|
|
const request = ++jobsRequest.current;
|
|
pendingJobs.current = key;
|
|
setJobsLoading(true);
|
|
setJobsError(null);
|
|
try {
|
|
const response = await api.get<JobChoice[]>("/jobapplications/choices", { params: { q: key || undefined, limit: 20 } });
|
|
if (!mounted.current || request !== jobsRequest.current) return;
|
|
setJobs(Array.isArray(response.data) ? response.data : []);
|
|
setLoadedJobQuery(key);
|
|
setJobsLoaded(true);
|
|
} catch (error) {
|
|
if (!mounted.current || request !== jobsRequest.current) return;
|
|
setJobsError(readError(error));
|
|
} finally {
|
|
if (request === jobsRequest.current) { pendingJobs.current = null; if (mounted.current) setJobsLoading(false); }
|
|
}
|
|
}, [jobQuery]);
|
|
useEffect(() => {
|
|
const timer = window.setTimeout(() => void loadJobs(), jobQuery.trim() ? 250 : 0);
|
|
return () => { window.clearTimeout(timer); jobsRequest.current += 1; pendingJobs.current = null; };
|
|
}, [jobQuery, loadJobs]);
|
|
|
|
const loadStoredDrafts = useCallback(async (afterWrite = false) => {
|
|
if (!mounted.current || (draftsPending.current && !afterWrite)) return;
|
|
draftsPending.current = true;
|
|
const request = ++draftsRequest.current;
|
|
setDraftsLoading(true);
|
|
try {
|
|
const response = await api.get<StoredEmailDraft[]>("/email/drafts");
|
|
if (mounted.current && request === draftsRequest.current) { setStoredDrafts(Array.isArray(response.data) ? response.data : []); setDraftsReadError(null); }
|
|
} catch (error) {
|
|
if (mounted.current && request === draftsRequest.current) {
|
|
const failure = readError(error);
|
|
setDraftsReadError(failure);
|
|
if (failure.kind === "unauthorized") setStoredDrafts([]);
|
|
}
|
|
} finally {
|
|
if (request === draftsRequest.current) { draftsPending.current = false; if (mounted.current) setDraftsLoading(false); }
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void loadStoredDrafts();
|
|
}, [loadStoredDrafts]);
|
|
|
|
const filteredSummary = useMemo(() => {
|
|
if (!hasCurrentInbox) return { linked: 0, inbound: 0 };
|
|
const linked = items.filter((item) => item.externalThreadId).length;
|
|
const inbound = items.filter((item) => item.direction === "inbound").length;
|
|
return { linked, inbound };
|
|
}, [hasCurrentInbox, items]);
|
|
const sendCapableProviders = useMemo(() => providers.filter((provider) => provider.connected && provider.canSend), [providers]);
|
|
|
|
const loadMessage = async (item: CorrespondenceInboxItem) => {
|
|
if (detailPending.current && detailItem.current?.id === item.id) return;
|
|
const request = ++detailRequest.current;
|
|
detailPending.current = true;
|
|
detailItem.current = item;
|
|
setSelectedMessageId(item.id);
|
|
setMessageDetail(null);
|
|
setDetailError(null);
|
|
setDetailNotice(false);
|
|
setDetailLoading(true);
|
|
try {
|
|
if (item.provider && item.provider !== "manual" && item.externalMessageId) {
|
|
try {
|
|
const live = await api.get<EmailMessageDetail>("/email/message", {
|
|
params: { provider: item.provider, messageId: item.externalMessageId },
|
|
});
|
|
if (request !== detailRequest.current) return;
|
|
setMessageDetail(live.data);
|
|
return;
|
|
} catch {
|
|
if (request !== detailRequest.current) return;
|
|
setDetailNotice(true);
|
|
}
|
|
}
|
|
|
|
const saved = await api.get<EmailMessageDetail>(`/correspondence/message/${item.id}`);
|
|
if (request !== detailRequest.current) return;
|
|
setMessageDetail(saved.data);
|
|
} catch (error) {
|
|
if (request !== detailRequest.current) return;
|
|
setDetailError(readError(error));
|
|
} finally {
|
|
if (request === detailRequest.current) { detailPending.current = false; setDetailLoading(false); }
|
|
}
|
|
};
|
|
|
|
const showMessage = (item: CorrespondenceInboxItem) => {
|
|
if (selectedMessageId !== item.id) return loadMessage(item);
|
|
detailRequest.current += 1;
|
|
detailItem.current = null;
|
|
detailPending.current = false;
|
|
setSelectedMessageId(null);
|
|
setMessageDetail(null);
|
|
setDetailLoading(false);
|
|
};
|
|
|
|
const hasDraftContent = (current: EmailDraft | null) => !!current && !!(current.to.trim() || current.subject.trim() || current.bodyText.trim());
|
|
const draftDirty = !!draft && (!draftBaseline.current || draft.to !== draftBaseline.current.to || draft.subject !== draftBaseline.current.subject || draft.bodyText !== draftBaseline.current.bodyText);
|
|
const jobsCurrent = loadedJobQuery === jobQuery.trim() && jobsError?.kind !== "unauthorized";
|
|
const draftErrorMessage = draftError ? (draftError.status === 409 ? "" : draftError.message) || t(draftError.action === "discard"
|
|
? draftError.status === 409 ? "emailInboxDraftChangedDiscard" : "emailInboxDraftDiscardFailed"
|
|
: draftError.action === "prepare" ? draftError.status === 409 ? "emailInboxNewAttemptConflict" : "emailInboxNewAttemptFailed"
|
|
: draftError.status === 409 ? "emailInboxDraftChangedElsewhere" : "emailInboxDraftSaveFailed") : "";
|
|
const openDraft = (next: EmailDraft | null) => {
|
|
draftRef.current = next;
|
|
draftBaseline.current = next;
|
|
setDraft(next);
|
|
};
|
|
const isCurrentDraft = (captured: EmailDraft) => mounted.current && draftRef.current?.clientRequestId === captured.clientRequestId
|
|
&& draftRef.current?.id === captured.id && draftRef.current?.jobApplicationId === captured.jobApplicationId && draftRef.current?.provider === captured.provider;
|
|
const finishComposeOperation = () => {
|
|
composeOperation.current = false;
|
|
if (mounted.current) { setComposeBusy(false); setSavingDraft(false); setSending(false); }
|
|
};
|
|
const replaceDraftApproved = () => {
|
|
const current = draftRef.current;
|
|
if (!hasDraftContent(current) && !sendResult) return null;
|
|
const saved = !!current?.id && !draftDirty;
|
|
const unresolved = sendResult && ["uncertain", "pending", "sending"].includes(sendResult.status);
|
|
return confirm({
|
|
title: t("emailInboxReplaceDraftTitle"),
|
|
message: (unresolved ? t("emailInboxDeliveryUncertain") + "\n\n" : "") + t(saved ? "emailInboxCurrentDraftSaved" : "emailInboxUnsavedDraftLost"),
|
|
confirmLabel: t("emailInboxReplaceDraft"),
|
|
destructive: !saved,
|
|
});
|
|
};
|
|
|
|
const startReply = async (item: CorrespondenceInboxItem) => {
|
|
const detail = messageDetail;
|
|
const provider = providers.find((candidate) => candidate.provider === item.provider);
|
|
if (!detail || !item.provider || !provider?.canSend || composeOperation.current) return;
|
|
composeOperation.current = true;
|
|
try {
|
|
const approval = replaceDraftApproved();
|
|
if ((approval && !(await approval)) || !mounted.current) return;
|
|
const subject = detail.subject.trim();
|
|
openDraft({
|
|
jobApplicationId: item.jobApplicationId,
|
|
companyName: item.companyName || t("emailInboxUnknownCompany"),
|
|
jobTitle: item.jobTitle || t("emailInboxUnknownRole"),
|
|
provider: item.provider,
|
|
providerName: provider.displayName,
|
|
fromAddress: provider.address || t("emailInboxConnectedAccount"),
|
|
to: item.direction === "outbound" ? detail.to : detail.from,
|
|
subject: /^re:/i.test(subject) ? subject : `Re: ${subject || t("emailInboxYourMessage")}`,
|
|
bodyText: "",
|
|
threadId: detail.threadId || item.externalThreadId || "",
|
|
clientRequestId: globalThis.crypto.randomUUID(),
|
|
});
|
|
setSendResult(null); setSendError(null); setDraftError(null);
|
|
} finally { finishComposeOperation(); }
|
|
};
|
|
|
|
const resumeDraft = async (stored: StoredEmailDraft) => {
|
|
if (composeOperation.current) return;
|
|
composeOperation.current = true;
|
|
try {
|
|
const approval = replaceDraftApproved();
|
|
if ((approval && !(await approval)) || !mounted.current) return;
|
|
const provider = providers.find((candidate) => candidate.provider === stored.provider);
|
|
const item = items.find((candidate) => candidate.jobApplicationId === stored.jobApplicationId);
|
|
openDraft({
|
|
...stored,
|
|
companyName: item?.companyName || `${t("emailInboxJobNumber")} #${stored.jobApplicationId}`,
|
|
jobTitle: item?.jobTitle || t("emailInboxSavedDraft"),
|
|
providerName: provider?.displayName || stored.provider,
|
|
fromAddress: provider?.address || t("emailInboxReconnectBeforeSending"),
|
|
});
|
|
setSendResult(null); setSendError(null); setDraftError(null);
|
|
} finally { finishComposeOperation(); }
|
|
};
|
|
|
|
const openComposeSetup = () => {
|
|
if (composeOperation.current) return;
|
|
setComposeJobId((current) => current || jobs[0]?.id || "");
|
|
setComposeProvider((current) => current || sendCapableProviders[0]?.provider || "");
|
|
setComposeSetupOpen(true);
|
|
};
|
|
|
|
const startNewMessage = async () => {
|
|
const job = jobsCurrent ? jobs.find((candidate) => candidate.id === composeJobId) : undefined;
|
|
const provider = sendCapableProviders.find((candidate) => candidate.provider === composeProvider);
|
|
if (!job || !provider || jobsLoading || jobsError || composeOperation.current) return;
|
|
composeOperation.current = true;
|
|
try {
|
|
const approval = replaceDraftApproved();
|
|
if ((approval && !(await approval)) || !mounted.current) return;
|
|
openDraft({
|
|
jobApplicationId: job.id,
|
|
companyName: job.companyName || t("emailInboxUnknownCompany"),
|
|
jobTitle: job.jobTitle || t("emailInboxUnknownRole"),
|
|
provider: provider.provider,
|
|
providerName: provider.displayName,
|
|
fromAddress: provider.address || t("emailInboxConnectedAccount"),
|
|
to: "", subject: "", bodyText: "", threadId: "",
|
|
clientRequestId: globalThis.crypto.randomUUID(),
|
|
});
|
|
setComposeSetupOpen(false);
|
|
setSendResult(null); setSendError(null); setDraftError(null);
|
|
} finally { finishComposeOperation(); }
|
|
};
|
|
|
|
const updateDraft = (changes: Partial<Pick<EmailDraft, "to" | "subject" | "bodyText">>) => {
|
|
const current = draftRef.current;
|
|
if (!current || composeOperation.current || sendResult) return;
|
|
const next = { ...current, ...changes };
|
|
draftRef.current = next;
|
|
setDraft(next);
|
|
setSendError(null);
|
|
};
|
|
|
|
const discardDraft = async () => {
|
|
const captured = draftRef.current;
|
|
if (!captured || composeOperation.current) return;
|
|
composeOperation.current = true;
|
|
try {
|
|
if ((captured.id || hasDraftContent(captured) || sendResult) && !(await confirm({
|
|
title: t("emailInboxDiscardDraftTitle"),
|
|
message: (sendResult && ["uncertain", "pending", "sending"].includes(sendResult.status) ? t("emailInboxDeliveryUncertain") + "\n\n" : "") + t("emailInboxDiscardDraftMessage"),
|
|
confirmLabel: t("emailInboxDiscardDraft"), destructive: true,
|
|
}))) return;
|
|
if (!isCurrentDraft(captured)) return;
|
|
setComposeBusy(true);
|
|
draftPanel.current?.focus({ preventScroll: true });
|
|
setDraftError(null);
|
|
if (captured.id && captured.revision) await api.delete(`/email/drafts/${captured.id}`, { params: { revision: captured.revision } });
|
|
if (!isCurrentDraft(captured)) return;
|
|
inboxPanel.current?.focus({ preventScroll: true });
|
|
openDraft(null);
|
|
setSendResult(null); setSendError(null);
|
|
if (captured.id) {
|
|
setStoredDrafts((current) => current.filter((stored) => stored.id !== captured.id));
|
|
await loadStoredDrafts(true);
|
|
}
|
|
} catch (error) {
|
|
if (isCurrentDraft(captured)) setDraftError({ ...readError(error), action: "discard" });
|
|
} finally { finishComposeOperation(); }
|
|
};
|
|
|
|
const saveDraft = async () => {
|
|
const captured = draftRef.current;
|
|
if (!captured || composeOperation.current || sendResult) return;
|
|
composeOperation.current = true;
|
|
setComposeBusy(true); setSavingDraft(true); setDraftError(null);
|
|
draftPanel.current?.focus({ preventScroll: true });
|
|
try {
|
|
const response = captured.id && captured.revision
|
|
? await api.put<StoredEmailDraft>(`/email/drafts/${captured.id}`, { revision: captured.revision, to: captured.to, subject: captured.subject, bodyText: captured.bodyText })
|
|
: await api.post<StoredEmailDraft>("/email/drafts", { jobApplicationId: captured.jobApplicationId, provider: captured.provider, to: captured.to, subject: captured.subject, bodyText: captured.bodyText, threadId: captured.threadId || null });
|
|
if (!isCurrentDraft(captured)) return;
|
|
openDraft({ ...captured, ...response.data });
|
|
setStoredDrafts((current) => [response.data, ...current.filter((stored) => stored.id !== response.data.id)]);
|
|
await loadStoredDrafts(true);
|
|
if (mounted.current) toast(latestT.current("emailInboxDraftSaved"), "success");
|
|
} catch (error) {
|
|
if (isCurrentDraft(captured)) setDraftError({ ...readError(error), action: "save" });
|
|
} finally { finishComposeOperation(); }
|
|
};
|
|
|
|
const sendDraft = async () => {
|
|
const captured = draftRef.current;
|
|
if (!captured || composeOperation.current || sendResult || !captured.to.trim() || !captured.subject.trim() || !captured.bodyText.trim()) return;
|
|
composeOperation.current = true;
|
|
try {
|
|
if (!(await confirm({
|
|
title: t("emailInboxConfirmSendTitle"),
|
|
message: t("emailInboxConfirmSendMessage", { provider: captured.providerName, from: captured.fromAddress, to: captured.to.trim(), subject: captured.subject.trim(), thread: captured.threadId || t("emailInboxNewMessage") }),
|
|
confirmLabel: t("emailInboxSendEmail"),
|
|
})) || !isCurrentDraft(captured)) return;
|
|
setComposeBusy(true); setSending(true); setSendError(null);
|
|
draftPanel.current?.focus({ preventScroll: true });
|
|
const response = await api.post<EmailSendResult>("/email/send", {
|
|
jobApplicationId: captured.jobApplicationId, provider: captured.provider, clientRequestId: captured.clientRequestId,
|
|
to: captured.to.trim(), subject: captured.subject.trim(), bodyText: captured.bodyText, threadId: captured.threadId || null, confirmed: true,
|
|
});
|
|
if (!isCurrentDraft(captured)) return;
|
|
setSendResult(response.data);
|
|
if (response.data.status === "sent") {
|
|
toast(latestT.current("emailInboxSentSaved"), "success");
|
|
if (captured.id && captured.revision) {
|
|
try {
|
|
await api.delete(`/email/drafts/${captured.id}`, { params: { revision: captured.revision } });
|
|
if (!isCurrentDraft(captured)) return;
|
|
openDraft({ ...captured, id: undefined, revision: undefined });
|
|
setStoredDrafts((current) => current.filter((stored) => stored.id !== captured.id));
|
|
} catch {
|
|
if (!mounted.current) return;
|
|
toast(latestT.current("emailInboxSentNewerDraft"), "warning");
|
|
}
|
|
if (mounted.current) await loadStoredDrafts(true);
|
|
}
|
|
if (mounted.current) await latestLoad.current();
|
|
}
|
|
} catch (error: any) {
|
|
if (!isCurrentDraft(captured)) return;
|
|
const result = error?.response?.data;
|
|
if (["failed", "uncertain", "sent", "pending", "sending"].includes(result?.status)) setSendResult(result as EmailSendResult);
|
|
else if (!error?.response || error.response.status >= 500 || error.response.status === 409)
|
|
setSendResult({ attemptId: captured.clientRequestId, status: "uncertain", duplicate: false, failureCategory: "client_connection_lost" });
|
|
setSendError(readError(error));
|
|
} finally { finishComposeOperation(); }
|
|
};
|
|
|
|
const prepareNewAttempt = async () => {
|
|
const captured = draftRef.current;
|
|
if (!captured || composeOperation.current || sendResult?.status !== "failed") return;
|
|
composeOperation.current = true;
|
|
setComposeBusy(true); setDraftError(null);
|
|
draftPanel.current?.focus({ preventScroll: true });
|
|
try {
|
|
if (captured.id && captured.revision) {
|
|
const response = await api.post<StoredEmailDraft>(`/email/drafts/${captured.id}/new-attempt`, { revision: captured.revision });
|
|
if (!isCurrentDraft(captured)) return;
|
|
const stored = { ...captured, ...response.data };
|
|
openDraft({ ...stored, to: captured.to, subject: captured.subject, bodyText: captured.bodyText });
|
|
draftBaseline.current = stored;
|
|
setStoredDrafts((current) => [response.data, ...current.filter((item) => item.id !== response.data.id)]);
|
|
} else {
|
|
if (!isCurrentDraft(captured)) return;
|
|
const next = { ...captured, clientRequestId: globalThis.crypto.randomUUID() };
|
|
draftRef.current = next;
|
|
setDraft(next);
|
|
}
|
|
setSendResult(null); setSendError(null);
|
|
if (captured.id) await loadStoredDrafts(true);
|
|
} catch (error) {
|
|
if (isCurrentDraft(captured)) setDraftError({ ...readError(error), action: "prepare" });
|
|
} finally { finishComposeOperation(); }
|
|
};
|
|
|
|
const unlinkGmailThread = async (item: CorrespondenceInboxItem) => {
|
|
if (item.provider !== "gmail" || !item.externalThreadId || unlinkPending.current) return;
|
|
unlinkPending.current = true;
|
|
try {
|
|
if (!(await confirm({
|
|
title: t("emailInboxUnlinkTitle"),
|
|
message: t("emailInboxUnlinkMessage", { job: item.companyName || t("emailInboxThisJob") }),
|
|
confirmLabel: t("emailInboxUnlinkThread"),
|
|
destructive: true,
|
|
})) || !mounted.current) return;
|
|
setUnlinkingThreadId(item.externalThreadId);
|
|
inboxPanel.current?.focus({ preventScroll: true });
|
|
setUnlinkError(null);
|
|
await api.post("/gmail/unlink-thread", {
|
|
jobApplicationId: item.jobApplicationId,
|
|
threadId: item.externalThreadId,
|
|
note: "Unlinked from Job email hub",
|
|
nextDecision: "review",
|
|
});
|
|
if (!mounted.current) return;
|
|
if (selectedMessageId === item.id) {
|
|
detailRequest.current += 1;
|
|
setSelectedMessageId(null);
|
|
setMessageDetail(null);
|
|
}
|
|
await latestLoad.current();
|
|
toast(t("emailInboxUnlinked"), "success");
|
|
} catch (error) {
|
|
if (mounted.current) setUnlinkError({ ...readError(error), retryable: false });
|
|
} finally {
|
|
unlinkPending.current = false;
|
|
if (mounted.current) setUnlinkingThreadId(null);
|
|
}
|
|
};
|
|
|
|
const providerStatusLabel = (provider: EmailProviderStatus) => {
|
|
if (!provider.connected) return t("emailInboxProviderNotConnected", { provider: provider.displayName });
|
|
const identity = provider.address || t("emailInboxConnectedAccount");
|
|
if (!provider.canRead) return t("emailInboxProviderReadUnavailable", { provider: provider.displayName, identity });
|
|
return t(provider.canSend ? "emailInboxProviderReadSend" : "emailInboxProviderReadOnly", { provider: provider.displayName, identity });
|
|
};
|
|
|
|
return (
|
|
<PageSection surface title={t("correspondenceInbox")} description={t("emailInboxSubtitle")}>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap", mb: 2 }}>
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
|
{view === "inbox" && hasCurrentInbox ? <Chip icon={<MailOutlineIcon />} label={t("emailInboxItems", { count: inboxTotal })} variant="outlined" /> : null}
|
|
{view === "inbox" && hasCurrentInbox ? <Chip label={t("emailInboxLinkedOnPage", { count: filteredSummary.linked })} variant="outlined" color={filteredSummary.linked > 0 ? "success" : "default"} /> : null}
|
|
{view === "inbox" && hasCurrentInbox ? <Chip label={t("emailInboxInboundOnPage", { count: filteredSummary.inbound })} variant="outlined" /> : null}
|
|
<Button variant={view === "inbox" ? "outlined" : "text"} aria-pressed={view === "inbox"} disabled={!!unlinkingThreadId || composeBusy} size="small" onClick={() => updateFilters({ view: "inbox" })}>{t("emailInboxLinkedMessages")}</Button>
|
|
<Button variant={view === "review" ? "outlined" : "text"} aria-pressed={view === "review"} disabled={!!unlinkingThreadId || composeBusy} size="small" onClick={() => updateFilters({ view: "review" })}>{t("emailInboxReviewSuggestions")}</Button>
|
|
<Button variant="outlined" size="small" onClick={openComposeSetup} disabled={composeBusy || jobsLoading || !jobsCurrent || jobs.length === 0 || sendCapableProviders.length === 0}>{t("emailInboxCompose")}</Button>
|
|
</Box>
|
|
</Box>
|
|
|
|
<Box ref={providerPanel} tabIndex={-1} role="group" aria-label={t("emailInboxProviderStatus")} sx={{ minWidth: 0, mb: 2 }}>
|
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", "& .MuiChip-root": { maxWidth: "100%", height: "auto" }, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere" } }}>
|
|
{providers.map((provider) => (
|
|
<Chip
|
|
key={provider.provider}
|
|
size="small"
|
|
color={provider.connected ? "success" : "default"}
|
|
variant="outlined"
|
|
label={providerStatusLabel(provider)}
|
|
/>
|
|
))}
|
|
</Box>
|
|
<ViewStateNotice compact loading={providersLoading} title={t("emailInboxProviderStatusUnavailable")} error={providerError ? { ...providerError, message: providerError.message || t("emailInboxProviderStatusUnavailable") } : null} onRetry={() => { providerPanel.current?.focus({ preventScroll: true }); return loadProviders(); }} />
|
|
</Box>
|
|
<Box ref={jobsPanel} role="group" aria-label={t("emailInboxJob")} tabIndex={-1}>
|
|
<ViewStateNotice compact loading={jobsLoading} title={t("emailInboxJobsLoadFailed")} error={jobsError ? { ...jobsError, message: jobsError.message || t("emailInboxJobsLoadFailed") } : null} onRetry={() => { jobsPanel.current?.focus({ preventScroll: true }); return loadJobs(); }} />
|
|
</Box>
|
|
{jobsLoaded && jobsCurrent && !jobsLoading && !jobsError && !jobQuery.trim() && jobs.length === 0 ? <Alert severity="info" sx={{ mb: 2 }}>{t("emailInboxAddJobFirst")}</Alert> : null}
|
|
{providerStatusLoaded && !providerStatusError && sendCapableProviders.length === 0 ? <Alert severity="info" sx={{ mb: 2 }}>{t("emailInboxReconnectToCompose")}</Alert> : null}
|
|
<Box ref={draftsPanel} role="group" aria-label={t("emailInboxSavedDraft")} tabIndex={-1}>
|
|
<ViewStateNotice compact loading={draftsLoading} title={t("emailInboxDraftsLoadFailed")} error={draftsReadError ? { ...draftsReadError, message: draftsReadError.message || t("emailInboxDraftsLoadFailed") } : null} onRetry={() => { draftsPanel.current?.focus({ preventScroll: true }); return loadStoredDrafts(); }} />
|
|
</Box>
|
|
|
|
{storedDrafts.length > 0 ? (
|
|
<Alert severity="info" sx={{ mb: 2 }}>
|
|
<Stack direction="row" spacing={1} useFlexGap flexWrap="wrap" alignItems="center">
|
|
<Typography variant="body2">{t(storedDrafts.length === 1 ? "emailInboxSavedDraftOne" : "emailInboxSavedDraftMany", { count: storedDrafts.length })}</Typography>
|
|
{storedDrafts.slice(0, 5).map((stored) => (
|
|
<Button key={stored.id} size="small" disabled={composeBusy} onClick={() => void resumeDraft(stored)}>
|
|
{t("emailInboxResumeDraft", { name: stored.subject || `${t("emailInboxJobNumber")} #${stored.jobApplicationId}` })}
|
|
</Button>
|
|
))}
|
|
</Stack>
|
|
</Alert>
|
|
) : null}
|
|
|
|
{composeSetupOpen ? (
|
|
<Paper component="section" aria-labelledby="compose-new-title" variant="outlined" sx={{ p: "var(--app-card-padding)", borderRadius: 1, minWidth: 0, mb: 2 }}>
|
|
<Typography id="compose-new-title" component="h3" variant="h6" sx={{ mb: 1.5 }}>{t("emailInboxCompose")}</Typography>
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr auto" }, gap: 1.25, alignItems: "center" }}>
|
|
<Autocomplete
|
|
options={jobsCurrent ? jobs : []}
|
|
loading={jobsLoading}
|
|
disabled={composeBusy}
|
|
noOptionsText={t("emailInboxNoJobMatches")}
|
|
value={jobs.find((job) => job.id === composeJobId) ?? null}
|
|
onInputChange={(_, value, reason) => {
|
|
if (reason === "input" || reason === "clear") setJobQuery(value);
|
|
}}
|
|
onChange={(_, value) => setComposeJobId(value?.id ?? "")}
|
|
isOptionEqualToValue={(option, value) => option.id === value.id}
|
|
getOptionLabel={(job) => `${job.companyName || t("emailInboxUnknownCompany")} · ${job.jobTitle}`}
|
|
renderInput={(params) => <TextField {...params} label={t("emailInboxJob")} placeholder={t("emailInboxSearchJobs")} />}
|
|
/>
|
|
<FormControl fullWidth>
|
|
<InputLabel id="compose-provider-label">{t("emailInboxSendingProvider")}</InputLabel>
|
|
<Select labelId="compose-provider-label" value={composeProvider} disabled={composeBusy} label={t("emailInboxSendingProvider")} onChange={(event) => setComposeProvider(String(event.target.value))}>
|
|
{sendCapableProviders.map((provider) => <MenuItem key={provider.provider} value={provider.provider}>{provider.displayName} · {provider.address || t("emailInboxConnectedAccount")}</MenuItem>)}
|
|
</Select>
|
|
</FormControl>
|
|
<Box sx={{ display: "flex", gap: 1 }}>
|
|
<Button disabled={composeBusy} onClick={() => setComposeSetupOpen(false)}>{t("cancel")}</Button>
|
|
<Button variant="contained" onClick={() => void startNewMessage()} disabled={composeBusy || jobsLoading || !!jobsError || !jobsCurrent || !jobs.some((job) => job.id === composeJobId) || !composeProvider}>{t("emailInboxStartDraft")}</Button>
|
|
</Box>
|
|
</Box>
|
|
</Paper>
|
|
) : null}
|
|
|
|
{reviewVisited ? <Box hidden={view !== "review"}><GmailReviewPage embedded active={view === "review"} /></Box> : null}
|
|
<Box ref={inboxPanel} role="group" tabIndex={-1} aria-label={t("emailInboxLinkedMessages")} hidden={view !== "inbox"}>
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "2fr 1fr 1fr auto" }, gap: 1.25, mb: 2 }}>
|
|
<TextField label={t("correspondenceSearch")} value={query} disabled={!!unlinkingThreadId} onChange={(e) => updateFilters({ q: e.target.value, page: "1" })} placeholder={t("emailInboxSearchPlaceholder")} />
|
|
<FormControl fullWidth>
|
|
<InputLabel id="email-direction-label">{t("emailInboxDirection")}</InputLabel>
|
|
<Select labelId="email-direction-label" value={direction} disabled={!!unlinkingThreadId} label={t("emailInboxDirection")} onChange={(e) => updateFilters({ direction: String(e.target.value), page: "1" })}>
|
|
<MenuItem value="all">{t("emailInboxAll")}</MenuItem>
|
|
<MenuItem value="inbound">{t("emailInboxInbound")}</MenuItem>
|
|
<MenuItem value="outbound">{t("emailInboxOutbound")}</MenuItem>
|
|
<MenuItem value="internal">{t("emailInboxInternal")}</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
<FormControl fullWidth>
|
|
<InputLabel id="email-link-state-label">{t("emailInboxLinkState")}</InputLabel>
|
|
<Select labelId="email-link-state-label" value={linkState} disabled={!!unlinkingThreadId} label={t("emailInboxLinkState")} onChange={(e) => updateFilters({ linkState: String(e.target.value), page: "1" })}>
|
|
<MenuItem value="all">{t("emailInboxAll")}</MenuItem>
|
|
<MenuItem value="linked">{t("emailInboxLinkedThreads")}</MenuItem>
|
|
<MenuItem value="manual">{t("emailInboxManualOnly")}</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
<Button variant="outlined" onClick={() => { inboxPanel.current?.focus({ preventScroll: true }); void load(); }} disabled={loading || !!unlinkingThreadId}>{loading ? t("loading") : t("refresh")}</Button>
|
|
</Box>
|
|
|
|
{draft ? (
|
|
<Paper ref={draftPanel} tabIndex={-1} component="section" aria-labelledby="email-draft-title" variant="outlined" sx={{ p: "var(--app-card-padding)", borderRadius: 1, minWidth: 0, mb: 2 }}>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", mb: 1.5 }}>
|
|
<Box>
|
|
<Typography id="email-draft-title" component="h3" variant="h6">{t("emailInboxDraftTitle")}</Typography>
|
|
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{draft.companyName} · {draft.jobTitle}</Typography>
|
|
{draftDirty ? <Chip size="small" variant="outlined" color="warning" label={t("assetsUnsavedChanges")} sx={{ mt: 1 }} /> : null}
|
|
</Box>
|
|
<Button onClick={() => void discardDraft()} disabled={composeBusy}>{t("emailInboxDiscardDraft")}</Button>
|
|
</Box>
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.25 }}>
|
|
<TextField label={t("emailInboxProvider")} value={`${draft.providerName} · ${draft.fromAddress}`} slotProps={{ input: { readOnly: true } }} />
|
|
<TextField label={t("emailInboxThread")} value={draft.threadId || t("emailInboxNewMessage")} slotProps={{ input: { readOnly: true } }} />
|
|
<TextField label={t("emailInboxRecipient")} type="email" value={draft.to} onChange={(event) => updateDraft({ to: event.target.value })} disabled={composeBusy || !!sendResult} inputProps={{ maxLength: 320 }} />
|
|
<TextField label={t("emailInboxSubject")} value={draft.subject} onChange={(event) => updateDraft({ subject: event.target.value })} disabled={composeBusy || !!sendResult} inputProps={{ maxLength: 998 }} />
|
|
<TextField label={t("emailInboxMessage")} value={draft.bodyText} onChange={(event) => updateDraft({ bodyText: event.target.value })} disabled={composeBusy || !!sendResult} multiline minRows={6} inputProps={{ maxLength: 200000 }} helperText={t("emailInboxCharacters", { count: draft.bodyText.length })} sx={{ gridColumn: "1 / -1", "& .MuiFormHelperText-root.Mui-disabled": { color: "text.secondary" } }} />
|
|
</Box>
|
|
{sendResult?.status === "sent" ? <Alert severity="success" sx={{ mt: 1.5 }}>{t("emailInboxSentSuccess")}</Alert> : null}
|
|
{sendResult?.status === "uncertain" || sendResult?.status === "sending" || sendResult?.status === "pending" ? (
|
|
<Alert severity="warning" sx={{ mt: 1.5 }}>{t("emailInboxDeliveryUncertain")}</Alert>
|
|
) : null}
|
|
{sendResult?.status === "failed" ? (
|
|
<Alert severity="error" sx={{ mt: 1.5 }} action={<Button color="inherit" size="small" disabled={composeBusy} onClick={() => void prepareNewAttempt()}>{t("emailInboxPrepareAttempt")}</Button>}>
|
|
{t("emailInboxDeliveryFailed")}
|
|
</Alert>
|
|
) : null}
|
|
{draftError ? <Alert severity="warning" sx={{ mt: 1.5 }}>{draftErrorMessage}</Alert> : null}
|
|
{sendError && sendResult?.status !== "uncertain" ? <Typography role="alert" variant="body2" sx={{ color: "error.main", mt: 1 }}>{sendError.message || t("emailInboxSendFailed")}</Typography> : null}
|
|
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1, mt: 1.5, flexWrap: "wrap" }}>
|
|
<Button variant="outlined" onClick={() => void saveDraft()} disabled={composeBusy || !!sendResult}>
|
|
{savingDraft ? t("saving") : draft.id ? t("profileSaveChanges") : t("emailInboxSaveDraft")}
|
|
</Button>
|
|
<Button variant="contained" onClick={() => void sendDraft()} disabled={composeBusy || !!sendResult || !draft.to.trim() || !draft.subject.trim() || !draft.bodyText.trim()}>
|
|
{sending ? t("emailInboxSending") : t("emailInboxReviewSend")}
|
|
</Button>
|
|
</Box>
|
|
</Paper>
|
|
) : null}
|
|
|
|
<ViewStateNotice compact loading={loading} title={t("emailInboxLoadFailed")} error={loadError ? { ...loadError, message: loadError.message || t("emailInboxLoadFailed") } : null} onRetry={() => { inboxPanel.current?.focus({ preventScroll: true }); return load(); }} />
|
|
<ViewStateNotice compact loading={!!unlinkingThreadId} title={t("emailInboxUnlinkFailed")} error={unlinkError ? { ...unlinkError, message: unlinkError.message || t("emailInboxUnlinkFailed") } : null} />
|
|
|
|
{hasLoaded && hasCurrentInbox && !loadError && !loading && items.length === 0 ? (
|
|
<Typography sx={{ color: "text.secondary", py: 4, textAlign: "center" }}>{t("emailInboxNoMatches")}</Typography>
|
|
) : null}
|
|
|
|
<Stack spacing={1.25}>
|
|
{(hasCurrentInbox ? items : []).map((item) => (
|
|
<Paper component="article" aria-labelledby={`${rowId}-${item.id}`} key={item.id} variant="outlined" sx={{ p: "var(--app-card-padding)", borderRadius: 1, minWidth: 0, "& .MuiChip-root": { maxWidth: "100%", height: "auto" }, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere" } }}>
|
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
|
|
<Box sx={{ minWidth: 0 }}>
|
|
<Typography sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{item.companyName || t("emailInboxUnknownCompany")} • {item.jobTitle || t("emailInboxUnknownRole")}</Typography>
|
|
<Typography id={`${rowId}-${item.id}`} component="h3" variant="subtitle2" sx={{ overflowWrap: "anywhere" }}>{item.subject || item.contentPreview || t("emailInboxNoSubject")}</Typography>
|
|
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 0.5 }}>
|
|
{item.externalFrom || item.from} {item.externalTo ? `→ ${item.externalTo}` : ""} · {new Date(item.date).toLocaleString(language === "nb" ? "nb-NO" : "en")}
|
|
</Typography>
|
|
</Box>
|
|
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", justifyContent: "flex-end" }}>
|
|
{item.direction ? <Chip size="small" label={t(item.direction === "inbound" ? "emailInboxInbound" : item.direction === "outbound" ? "emailInboxOutbound" : "emailInboxInternal")} variant="outlined" /> : null}
|
|
{item.provider ? <Chip size="small" label={item.provider === "microsoft" ? "Outlook" : item.provider === "gmail" ? "Gmail" : item.provider.toUpperCase()} variant="outlined" /> : null}
|
|
{item.externalThreadId ? <Chip size="small" label={t("emailInboxThreadId", { id: item.externalThreadId })} color="success" variant="outlined" /> : <Chip size="small" label={t("emailInboxManualInternal")} variant="outlined" />}
|
|
{item.labelCount > 0 ? <Chip size="small" label={t("emailInboxLabels", { count: item.labelCount })} variant="outlined" /> : null}
|
|
{item.attachmentCount > 0 ? <Chip size="small" label={t("emailInboxAttachments", { count: item.attachmentCount })} variant="outlined" /> : null}
|
|
<Button size="small" variant="text" disabled={!!unlinkingThreadId} aria-expanded={selectedMessageId === item.id} aria-controls={selectedMessageId === item.id ? `${rowId}-${item.id}-message` : undefined} onClick={() => void showMessage(item)}>{selectedMessageId === item.id ? t("emailInboxHideMessage") : t("emailInboxViewMessage")}</Button>
|
|
<Button size="small" variant="text" disabled={!!unlinkingThreadId} onClick={() => navigate(`/jobs/${item.jobApplicationId}?section=communication`, { state: { from: location.pathname + location.search + location.hash } })}>{t("emailInboxOpenJob")}</Button>
|
|
{item.provider === "gmail" && item.externalThreadId ? (
|
|
<Button size="small" color="warning" variant="text" disabled={!!unlinkingThreadId} onClick={() => void unlinkGmailThread(item)}>
|
|
{unlinkingThreadId === item.externalThreadId ? t("emailInboxUnlinking") : t("emailInboxUnlinkThread")}
|
|
</Button>
|
|
) : null}
|
|
</Box>
|
|
</Box>
|
|
{selectedMessageId === item.id ? (
|
|
<Box ref={detailPanel} tabIndex={-1} role="group" id={`${rowId}-${item.id}-message`} aria-label={t("emailInboxMessage")} sx={{ mt: 1.5, pt: 1.5, borderTop: "1px solid", borderColor: "divider" }}>
|
|
<ViewStateNotice compact loading={detailLoading} title={t("emailInboxMessageLoadFailed")} error={detailError ? { ...detailError, message: detailError.message || t("emailInboxMessageLoadFailed") } : null} onRetry={() => { detailPanel.current?.focus({ preventScroll: true }); return detailItem.current ? loadMessage(detailItem.current) : undefined; }} />
|
|
{detailNotice ? <Alert severity="warning" sx={{ mb: 1 }}>{t("emailInboxProviderCopyUnavailable")}</Alert> : null}
|
|
{messageDetail ? <>
|
|
<Typography sx={{ fontWeight: 800 }}>{messageDetail.subject || t("emailInboxNoSubject")}</Typography>
|
|
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mb: 1 }}>{messageDetail.from}{messageDetail.to ? ` → ${messageDetail.to}` : ""}</Typography>
|
|
<Typography sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{messageDetail.bodyText}</Typography>
|
|
{(messageDetail.labels.length > 0 || messageDetail.attachments.length > 0) ? <Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 1 }}>
|
|
{messageDetail.labels.map((label, index) => <Chip key={`${label}-${index}`} size="small" label={label} variant="outlined" />)}
|
|
{messageDetail.attachments.map((attachment, index) => <Chip key={`${attachment.fileName || "attachment"}-${index}`} size="small" label={attachment.fileName || t("emailInboxAttachment")} variant="outlined" />)}
|
|
</Box> : null}
|
|
{item.provider && providers.find((provider) => provider.provider === item.provider)?.canSend ? (
|
|
<Button sx={{ mt: 1.5 }} variant="outlined" disabled={composeBusy} onClick={() => void startReply(item)}>{t("emailInboxReply")}</Button>
|
|
) : item.provider && item.provider !== "manual" ? (
|
|
<Alert severity="info" sx={{ mt: 1.5 }}>{t("emailInboxReconnectToReply")}</Alert>
|
|
) : null}
|
|
</> : null}
|
|
</Box>
|
|
) : null}
|
|
</Paper>
|
|
))}
|
|
</Stack>
|
|
{hasCurrentInbox && inboxTotalPages > 1 ? (
|
|
<Box sx={{ display: "flex", justifyContent: "center", mt: 2 }}>
|
|
<Pagination
|
|
page={inboxPage}
|
|
count={inboxTotalPages}
|
|
onChange={(_, value) => updateFilters({ page: String(value) })}
|
|
disabled={!!unlinkingThreadId}
|
|
color="primary"
|
|
aria-label={t("emailInboxPages")}
|
|
/>
|
|
</Box>
|
|
) : null}
|
|
</Box>
|
|
</PageSection>
|
|
);
|
|
}
|