fix(i18n): localize job email hub

This commit is contained in:
cesnimda
2026-08-29 22:10:53 +02:00
parent f89e37bb4f
commit e9f8653d19
4 changed files with 333 additions and 116 deletions
+5
View File
@@ -30,6 +30,8 @@ Updated: 2026-08-29
- Localized the Operations and Notifications activity page controls, dates, progress accessibility labels, empty/error/loading states, cancellation feedback, and retry/dismiss actions in English and Norwegian Bokmål.
- Localized Job Discovery across search, source guidance, retry/empty/loading states, result sorting, vacancy metadata, locale-aware dates, and save/view actions.
- Localized Connected Accounts and the shared email-provider connection controls, validation, OAuth/IMAP feedback, connection states, and accessible form labels without translating provider names or user email addresses.
- Localized the consolidated Job Email hub across provider status, search/filter controls, saved drafts, compose/reply flows, destructive confirmations, send safety states, message metadata, pagination, and failure handling. Persisted subjects, bodies, company names, roles, addresses, and provider names remain unchanged.
- Corrected the Job Email Direction and Link State controls so their visible labels are programmatically associated with the selects, and removed the remaining mojibake separators/loading text from the workflow.
### In progress
@@ -77,6 +79,9 @@ Updated: 2026-08-29
- Full frontend after the Career/Operations localization batch: 62 suites, 260/260 tests passed.
- Next optimized production build and its integrated TypeScript check passed after the Career/Operations localization batch.
- Job Discovery and email-provider connections focused verification: 2 suites, 8/8 passed, including Bokmål workflow regressions; TypeScript passed.
- Job Email focused verification: 16/16 passed, including a Bokmål regression that preserves correspondence content; TypeScript passed.
- Full frontend after the Job Email localization batch: 62 suites, 263/263 tests passed.
- Next optimized production build and integrated TypeScript check passed after the Job Email localization batch.
- Backend matcher/intelligence focused verification: 35/35 passed, including detection of a manually created Norwegian advert with no saved translation.
- Full backend: 712/712 tests passed on .NET 9.
- Next optimized production build and TypeScript: passed after the Job Workspace/checklist batch.
@@ -38,6 +38,7 @@ function renderPage() {
describe('CorrespondenceInboxPage', () => {
beforeEach(() => {
window.localStorage.clear();
Object.defineProperty(globalThis.crypto, 'randomUUID', { configurable: true, value: jest.fn(() => '00000000-0000-4000-8000-000000000123') });
mockedApi.get.mockImplementation((url: string) => {
if (url === '/email/providers') return Promise.resolve({ data: [
@@ -475,6 +476,17 @@ describe('CorrespondenceInboxPage', () => {
expect(screen.getByRole('button', { name: /linked messages/i })).toBeInTheDocument();
});
test('localizes inbox controls while preserving correspondence content', async () => {
window.localStorage.setItem('uiLanguage', 'nb');
renderPage();
expect(await screen.findByRole('heading', { name: 'Jobb-e-post' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Tilknyttede meldinger' })).toBeInTheDocument();
expect(screen.getByLabelText('Retning')).toBeInTheDocument();
expect(screen.getByText('Interview invite')).toBeInTheDocument();
expect(screen.getByText(/Gmail: owner@gmail\.test · Kun lesing/)).toBeInTheDocument();
});
test('legacy Gmail review route redirects into the consolidated hub filter', async () => {
render(
<ToastProvider>
+198
View File
@@ -27,6 +27,105 @@ export const translations = {
remindersPageSubtitle: "Everything due for follow-up, soonest first.",
correspondencePageSubtitle: "Every message linked to a job, in one inbox.",
correspondenceInbox: "Job email",
emailInboxSubtitle: "Review linked correspondence and suggested recruitment messages in one place.",
emailInboxItems: "{count} items",
emailInboxLinked: "{count} linked",
emailInboxInboundCount: "{count} inbound",
emailInboxLinkedMessages: "Linked messages",
emailInboxReviewSuggestions: "Review suggestions",
emailInboxCompose: "Compose new email",
emailInboxProviderStatus: "Email provider status",
emailInboxProviderNotConnected: "{provider}: Not connected",
emailInboxConnectedAccount: "Connected account",
emailInboxProviderReadUnavailable: "{provider}: {identity} · Mailbox access unavailable",
emailInboxProviderReadSend: "{provider}: {identity} · Read + send",
emailInboxProviderReadOnly: "{provider}: {identity} · Read only; reconnect to enable send",
emailInboxProviderStatusUnavailable: "Email provider status is temporarily unavailable. Saved JobTracker correspondence remains available.",
emailInboxAddJobFirst: "Add a job before composing a new email.",
emailInboxReconnectToCompose: "Reconnect Gmail or Outlook with send access before composing a new email.",
emailInboxSavedDraftOne: "{count} saved email draft available.",
emailInboxSavedDraftMany: "{count} saved email drafts available.",
emailInboxResumeDraft: "Resume {name}",
emailInboxJobNumber: "job",
emailInboxJob: "Job",
emailInboxSearchJobs: "Search every job",
emailInboxSendingProvider: "Sending provider",
emailInboxStartDraft: "Start draft",
emailInboxSearchPlaceholder: "Company, role, recruiter, subject",
emailInboxDirection: "Direction",
emailInboxAll: "All",
emailInboxInbound: "Inbound",
emailInboxOutbound: "Outbound",
emailInboxInternal: "Internal",
emailInboxLinkState: "Link state",
emailInboxLinkedThreads: "Linked threads",
emailInboxManualOnly: "Manual/internal only",
emailInboxDraftTitle: "Email draft",
emailInboxDiscardDraft: "Discard draft",
emailInboxProvider: "Provider",
emailInboxThread: "Thread",
emailInboxNewMessage: "New message",
emailInboxRecipient: "Recipient",
emailInboxSubject: "Subject",
emailInboxMessage: "Message",
emailInboxCharacters: "{count} / 200000 characters",
emailInboxSentSuccess: "Sent successfully. This exact attempt cannot be sent again.",
emailInboxDeliveryUncertain: "Delivery status is uncertain. Do not retry this draft. Check the provider Sent folder before taking any further action.",
emailInboxPrepareAttempt: "Prepare new attempt",
emailInboxDeliveryFailed: "The provider confirmed that this attempt did not complete. Review the connection and draft before creating a new attempt.",
emailInboxSaveDraft: "Save draft",
emailInboxSending: "Sending…",
emailInboxReviewSend: "Review and send",
emailInboxNoMatches: "No correspondence matches the current filters.",
emailInboxUnknownCompany: "Unknown company",
emailInboxUnknownRole: "Unknown role",
emailInboxThreadId: "Thread {id}",
emailInboxManualInternal: "Manual/internal",
emailInboxLabels: "{count} labels",
emailInboxAttachments: "{count} attachments",
emailInboxHideMessage: "Hide message",
emailInboxViewMessage: "View message",
emailInboxOpenJob: "Open job",
emailInboxUnlinking: "Unlinking…",
emailInboxUnlinkThread: "Unlink thread",
emailInboxLoadingMessage: "Loading message…",
emailInboxNoSubject: "No subject",
emailInboxAttachment: "Attachment",
emailInboxReply: "Reply with connected provider",
emailInboxReconnectToReply: "Reconnect this provider with send access to reply from JobTracker.",
emailInboxPages: "Correspondence pages",
emailInboxLoadFailed: "Failed to load correspondence inbox.",
emailInboxDraftsLoadFailed: "Saved drafts could not be loaded. New email can still be reviewed before sending.",
emailInboxProviderCopyUnavailable: "The provider copy is unavailable. Showing the saved JobTracker copy.",
emailInboxMessageLoadFailed: "Failed to load this message.",
emailInboxReplaceDraftTitle: "Replace email draft?",
emailInboxReplaceReplyMessage: "Starting this reply will discard the draft you are currently editing.",
emailInboxReplaceDraft: "Replace draft",
emailInboxYourMessage: "Your message",
emailInboxCurrentDraftSaved: "Your current draft is already saved and can be resumed later.",
emailInboxUnsavedDraftLost: "Your current unsaved draft text will be lost.",
emailInboxSavedDraft: "Saved email draft",
emailInboxReconnectBeforeSending: "Reconnect before sending",
emailInboxDiscardDraftTitle: "Discard email draft?",
emailInboxDiscardDraftMessage: "Your unsent draft text will be lost.",
emailInboxDraftChangedDiscard: "The saved draft changed elsewhere. Reload it before discarding.",
emailInboxDraftSaved: "Email draft saved.",
emailInboxDraftChangedElsewhere: "This draft changed in another tab. Resume the latest saved version before editing again.",
emailInboxDraftSaveFailed: "Failed to save the email draft.",
emailInboxConfirmSendTitle: "Confirm email send",
emailInboxConfirmSendMessage: "Send with {provider} ({from}) to {to}? Subject: {subject}. Thread: {thread}.",
emailInboxSendEmail: "Send email",
emailInboxSentSaved: "Email sent and saved to this job.",
emailInboxSentNewerDraft: "Email was sent, but a newer saved draft still exists. Review it before taking another action.",
emailInboxSendFailed: "The email could not be sent.",
emailInboxNewAttemptConflict: "A new attempt is allowed only after the latest saved draft has a definitive failed delivery. Reload the draft before continuing.",
emailInboxNewAttemptFailed: "Failed to prepare a new delivery attempt.",
emailInboxUnlinkTitle: "Unlink Gmail thread?",
emailInboxUnlinkMessage: "Remove this Gmail thread from {job} and return it to recruitment review? The provider copy is not deleted.",
emailInboxThisJob: "this job",
emailInboxUnlinked: "Gmail thread returned to recruitment review.",
emailInboxUnlinkFailed: "Failed to unlink the Gmail thread.",
saving: "Saving…",
gmailReviewPageSubtitle: "Review Gmail threads before linking them to a job.",
account: "Account",
profile: "Profile",
@@ -1945,6 +2044,105 @@ export const translations = {
remindersPageSubtitle: "Alt som trenger oppfølging, snarest først.",
correspondencePageSubtitle: "Alle meldinger koblet til en jobb, i én innboks.",
correspondenceInbox: "Jobb-e-post",
emailInboxSubtitle: "Se tilknyttet korrespondanse og foreslåtte rekrutteringsmeldinger på ett sted.",
emailInboxItems: "{count} elementer",
emailInboxLinked: "{count} tilknyttet",
emailInboxInboundCount: "{count} innkommende",
emailInboxLinkedMessages: "Tilknyttede meldinger",
emailInboxReviewSuggestions: "Gjennomgå forslag",
emailInboxCompose: "Skriv ny e-post",
emailInboxProviderStatus: "Status for e-postleverandører",
emailInboxProviderNotConnected: "{provider}: Ikke tilkoblet",
emailInboxConnectedAccount: "Tilkoblet konto",
emailInboxProviderReadUnavailable: "{provider}: {identity} · Tilgang til innboksen er utilgjengelig",
emailInboxProviderReadSend: "{provider}: {identity} · Lese og sende",
emailInboxProviderReadOnly: "{provider}: {identity} · Kun lesing; koble til på nytt for å sende",
emailInboxProviderStatusUnavailable: "Status for e-postleverandører er midlertidig utilgjengelig. Lagret korrespondanse i JobTracker er fortsatt tilgjengelig.",
emailInboxAddJobFirst: "Legg til en jobb før du skriver en ny e-post.",
emailInboxReconnectToCompose: "Koble Gmail eller Outlook til på nytt med sendetilgang før du skriver en ny e-post.",
emailInboxSavedDraftOne: "{count} lagret e-postutkast er tilgjengelig.",
emailInboxSavedDraftMany: "{count} lagrede e-postutkast er tilgjengelige.",
emailInboxResumeDraft: "Fortsett {name}",
emailInboxJobNumber: "jobb",
emailInboxJob: "Jobb",
emailInboxSearchJobs: "Søk i alle jobber",
emailInboxSendingProvider: "Avsenderkonto",
emailInboxStartDraft: "Start utkast",
emailInboxSearchPlaceholder: "Bedrift, rolle, rekrutterer, emne",
emailInboxDirection: "Retning",
emailInboxAll: "Alle",
emailInboxInbound: "Innkommende",
emailInboxOutbound: "Utgående",
emailInboxInternal: "Intern",
emailInboxLinkState: "Tilknytningsstatus",
emailInboxLinkedThreads: "Tilknyttede tråder",
emailInboxManualOnly: "Kun manuell/intern",
emailInboxDraftTitle: "E-postutkast",
emailInboxDiscardDraft: "Forkast utkast",
emailInboxProvider: "Leverandør",
emailInboxThread: "Tråd",
emailInboxNewMessage: "Ny melding",
emailInboxRecipient: "Mottaker",
emailInboxSubject: "Emne",
emailInboxMessage: "Melding",
emailInboxCharacters: "{count} / 200000 tegn",
emailInboxSentSuccess: "Sendt. Dette nøyaktige forsøket kan ikke sendes på nytt.",
emailInboxDeliveryUncertain: "Leveringsstatusen er usikker. Ikke prøv å sende dette utkastet på nytt. Kontroller Sendt-mappen hos leverandøren før du gjør noe mer.",
emailInboxPrepareAttempt: "Forbered nytt forsøk",
emailInboxDeliveryFailed: "Leverandøren bekreftet at forsøket ikke ble fullført. Kontroller tilkoblingen og utkastet før du oppretter et nytt forsøk.",
emailInboxSaveDraft: "Lagre utkast",
emailInboxSending: "Sender…",
emailInboxReviewSend: "Gjennomgå og send",
emailInboxNoMatches: "Ingen korrespondanse samsvarer med de gjeldende filtrene.",
emailInboxUnknownCompany: "Ukjent bedrift",
emailInboxUnknownRole: "Ukjent rolle",
emailInboxThreadId: "Tråd {id}",
emailInboxManualInternal: "Manuell/intern",
emailInboxLabels: "{count} etiketter",
emailInboxAttachments: "{count} vedlegg",
emailInboxHideMessage: "Skjul melding",
emailInboxViewMessage: "Vis melding",
emailInboxOpenJob: "Åpne jobb",
emailInboxUnlinking: "Fjerner tilknytning…",
emailInboxUnlinkThread: "Fjern trådtilknytning",
emailInboxLoadingMessage: "Laster melding…",
emailInboxNoSubject: "Uten emne",
emailInboxAttachment: "Vedlegg",
emailInboxReply: "Svar med tilkoblet leverandør",
emailInboxReconnectToReply: "Koble denne leverandøren til på nytt med sendetilgang for å svare fra JobTracker.",
emailInboxPages: "Korrespondansesider",
emailInboxLoadFailed: "Kunne ikke laste korrespondanseinnboksen.",
emailInboxDraftsLoadFailed: "Lagrede utkast kunne ikke lastes. Ny e-post kan fortsatt gjennomgås før sending.",
emailInboxProviderCopyUnavailable: "Kopien hos leverandøren er utilgjengelig. Viser den lagrede JobTracker-kopien.",
emailInboxMessageLoadFailed: "Kunne ikke laste denne meldingen.",
emailInboxReplaceDraftTitle: "Erstatt e-postutkast?",
emailInboxReplaceReplyMessage: "Når du starter dette svaret, forkastes utkastet du redigerer nå.",
emailInboxReplaceDraft: "Erstatt utkast",
emailInboxYourMessage: "Meldingen din",
emailInboxCurrentDraftSaved: "Det gjeldende utkastet er lagret og kan fortsettes senere.",
emailInboxUnsavedDraftLost: "Teksten i det ulagrede utkastet går tapt.",
emailInboxSavedDraft: "Lagret e-postutkast",
emailInboxReconnectBeforeSending: "Koble til på nytt før sending",
emailInboxDiscardDraftTitle: "Forkast e-postutkast?",
emailInboxDiscardDraftMessage: "Teksten i det usendte utkastet går tapt.",
emailInboxDraftChangedDiscard: "Det lagrede utkastet ble endret et annet sted. Last det inn på nytt før du forkaster.",
emailInboxDraftSaved: "E-postutkast lagret.",
emailInboxDraftChangedElsewhere: "Utkastet ble endret i en annen fane. Fortsett den nyeste lagrede versjonen før du redigerer igjen.",
emailInboxDraftSaveFailed: "Kunne ikke lagre e-postutkastet.",
emailInboxConfirmSendTitle: "Bekreft sending av e-post",
emailInboxConfirmSendMessage: "Send med {provider} ({from}) til {to}? Emne: {subject}. Tråd: {thread}.",
emailInboxSendEmail: "Send e-post",
emailInboxSentSaved: "E-posten ble sendt og lagret på denne jobben.",
emailInboxSentNewerDraft: "E-posten ble sendt, men det finnes fortsatt et nyere lagret utkast. Kontroller det før du gjør noe mer.",
emailInboxSendFailed: "E-posten kunne ikke sendes.",
emailInboxNewAttemptConflict: "Et nytt forsøk er bare tillatt etter at det nyeste lagrede utkastet har en endelig mislykket levering. Last utkastet inn på nytt før du fortsetter.",
emailInboxNewAttemptFailed: "Kunne ikke forberede et nytt leveringsforsøk.",
emailInboxUnlinkTitle: "Fjern tilknytningen til Gmail-tråden?",
emailInboxUnlinkMessage: "Fjern denne Gmail-tråden fra {job} og send den tilbake til rekrutteringsgjennomgang? Kopien hos leverandøren slettes ikke.",
emailInboxThisJob: "denne jobben",
emailInboxUnlinked: "Gmail-tråden ble sendt tilbake til rekrutteringsgjennomgang.",
emailInboxUnlinkFailed: "Kunne ikke fjerne tilknytningen til Gmail-tråden.",
saving: "Lagrer…",
gmailReviewPageSubtitle: "Se gjennom Gmail-tråder før du kobler dem til en jobb.",
account: "Konto",
profile: "Profil",
@@ -21,6 +21,7 @@ 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";
export type CorrespondenceInboxItem = {
@@ -66,13 +67,6 @@ type JobChoice = {
companyName: string;
};
export function emailProviderStatusLabel(provider: EmailProviderStatus) {
if (!provider.connected) return `${provider.displayName}: Not connected`;
const identity = provider.address || "Connected account";
if (!provider.canRead) return `${provider.displayName}: ${identity} · Mailbox access unavailable`;
return `${provider.displayName}: ${identity}${provider.canSend ? " · Read + send" : " · Read only; reconnect to enable send"}`;
}
type EmailMessageDetail = {
id: string;
threadId: string;
@@ -124,6 +118,7 @@ export default function CorrespondenceInboxPage() {
const view = searchParams.get("view") === "review" ? "review" : "inbox";
const { toast } = useToast();
const { confirm } = useConfirm();
const { language, t } = useI18n();
const [items, setItems] = useState<CorrespondenceInboxItem[]>([]);
const [inboxPage, setInboxPage] = useState(1);
const [inboxTotal, setInboxTotal] = useState(0);
@@ -176,11 +171,11 @@ export default function CorrespondenceInboxPage() {
setSelectedMessageId(null);
setMessageDetail(null);
} catch (error) {
toast(getApiErrorMessage(error, "Failed to load correspondence inbox."), "error");
toast(getApiErrorMessage(error, t("emailInboxLoadFailed")), "error");
} finally {
setLoading(false);
}
}, [direction, inboxPage, linkState, query, toast]);
}, [direction, inboxPage, language, linkState, query, toast]);
useEffect(() => {
if (view === "inbox") void load();
@@ -218,9 +213,9 @@ export default function CorrespondenceInboxPage() {
const response = await api.get<StoredEmailDraft[]>("/email/drafts");
setStoredDrafts(Array.isArray(response.data) ? response.data : []);
} catch {
setDraftError("Saved drafts could not be loaded. New email can still be reviewed before sending.");
setDraftError(t("emailInboxDraftsLoadFailed"));
}
}, []);
}, [language]);
useEffect(() => {
void loadStoredDrafts();
@@ -258,7 +253,7 @@ export default function CorrespondenceInboxPage() {
return;
} catch {
if (request !== detailRequest.current) return;
setDetailNotice("The provider copy is unavailable. Showing the saved JobTracker copy.");
setDetailNotice(t("emailInboxProviderCopyUnavailable"));
}
}
@@ -267,7 +262,7 @@ export default function CorrespondenceInboxPage() {
setMessageDetail(saved.data);
} catch (error) {
if (request !== detailRequest.current) return;
setDetailError(getApiErrorMessage(error, "Failed to load this message."));
setDetailError(getApiErrorMessage(error, t("emailInboxMessageLoadFailed")));
} finally {
if (request === detailRequest.current) setDetailLoading(false);
}
@@ -278,21 +273,21 @@ export default function CorrespondenceInboxPage() {
const provider = providers.find((candidate) => candidate.provider === item.provider);
if (!provider?.canSend) return;
if (draft?.bodyText.trim() && !(await confirm({
title: "Replace email draft?",
message: "Starting this reply will discard the draft you are currently editing.",
confirmLabel: "Replace draft",
title: t("emailInboxReplaceDraftTitle"),
message: t("emailInboxReplaceReplyMessage"),
confirmLabel: t("emailInboxReplaceDraft"),
destructive: true,
}))) return;
const subject = messageDetail.subject.trim();
setDraft({
jobApplicationId: item.jobApplicationId,
companyName: item.companyName || "Unknown company",
jobTitle: item.jobTitle || "Unknown role",
companyName: item.companyName || t("emailInboxUnknownCompany"),
jobTitle: item.jobTitle || t("emailInboxUnknownRole"),
provider: item.provider,
providerName: provider.displayName,
fromAddress: provider.address || "Connected account",
fromAddress: provider.address || t("emailInboxConnectedAccount"),
to: item.direction === "outbound" ? messageDetail.to : messageDetail.from,
subject: /^re:/i.test(subject) ? subject : `Re: ${subject || "Your message"}`,
subject: /^re:/i.test(subject) ? subject : `Re: ${subject || t("emailInboxYourMessage")}`,
bodyText: "",
threadId: messageDetail.threadId || item.externalThreadId || "",
clientRequestId: globalThis.crypto.randomUUID(),
@@ -304,9 +299,9 @@ export default function CorrespondenceInboxPage() {
const resumeDraft = async (stored: StoredEmailDraft) => {
if (draft?.bodyText.trim() && !(await confirm({
title: "Replace email draft?",
message: draft.id ? "Your current draft is already saved and can be resumed later." : "Your current unsaved draft text will be lost.",
confirmLabel: "Replace draft",
title: t("emailInboxReplaceDraftTitle"),
message: draft.id ? t("emailInboxCurrentDraftSaved") : t("emailInboxUnsavedDraftLost"),
confirmLabel: t("emailInboxReplaceDraft"),
destructive: !draft.id,
}))) return;
const provider = providers.find((candidate) => candidate.provider === stored.provider);
@@ -314,9 +309,9 @@ export default function CorrespondenceInboxPage() {
setDraft({
...stored,
companyName: item?.companyName || `Job #${stored.jobApplicationId}`,
jobTitle: item?.jobTitle || "Saved email draft",
jobTitle: item?.jobTitle || t("emailInboxSavedDraft"),
providerName: provider?.displayName || stored.provider,
fromAddress: provider?.address || "Reconnect before sending",
fromAddress: provider?.address || t("emailInboxReconnectBeforeSending"),
});
setSendResult(null);
setSendError(null);
@@ -334,18 +329,18 @@ export default function CorrespondenceInboxPage() {
const provider = sendCapableProviders.find((candidate) => candidate.provider === composeProvider);
if (!job || !provider) return;
if (draft?.bodyText.trim() && !(await confirm({
title: "Replace email draft?",
message: draft.id ? "Your current draft is already saved and can be resumed later." : "Your current unsaved draft text will be lost.",
confirmLabel: "Replace draft",
title: t("emailInboxReplaceDraftTitle"),
message: draft.id ? t("emailInboxCurrentDraftSaved") : t("emailInboxUnsavedDraftLost"),
confirmLabel: t("emailInboxReplaceDraft"),
destructive: !draft.id,
}))) return;
setDraft({
jobApplicationId: job.id,
companyName: job.companyName || "Unknown company",
jobTitle: job.jobTitle || "Unknown role",
companyName: job.companyName || t("emailInboxUnknownCompany"),
jobTitle: job.jobTitle || t("emailInboxUnknownRole"),
provider: provider.provider,
providerName: provider.displayName,
fromAddress: provider.address || "Connected account",
fromAddress: provider.address || t("emailInboxConnectedAccount"),
to: "",
subject: "",
bodyText: "",
@@ -370,9 +365,9 @@ export default function CorrespondenceInboxPage() {
const discardDraft = async () => {
if (draft?.bodyText.trim() && !(await confirm({
title: "Discard email draft?",
message: "Your unsent draft text will be lost.",
confirmLabel: "Discard draft",
title: t("emailInboxDiscardDraftTitle"),
message: t("emailInboxDiscardDraftMessage"),
confirmLabel: t("emailInboxDiscardDraft"),
destructive: true,
}))) return;
if (draft?.id && draft.revision) {
@@ -380,7 +375,7 @@ export default function CorrespondenceInboxPage() {
await api.delete(`/email/drafts/${draft.id}`, { params: { revision: draft.revision } });
await loadStoredDrafts();
} catch (error) {
setDraftError(getApiErrorMessage(error, "The saved draft changed elsewhere. Reload it before discarding."));
setDraftError(getApiErrorMessage(error, t("emailInboxDraftChangedDiscard")));
return;
}
}
@@ -412,11 +407,11 @@ export default function CorrespondenceInboxPage() {
});
setDraft((current) => current ? { ...current, ...response.data } : null);
await loadStoredDrafts();
toast("Email draft saved.", "success");
toast(t("emailInboxDraftSaved"), "success");
} catch (error: any) {
setDraftError(error?.response?.status === 409
? "This draft changed in another tab. Resume the latest saved version before editing again."
: getApiErrorMessage(error, "Failed to save the email draft."));
? t("emailInboxDraftChangedElsewhere")
: getApiErrorMessage(error, t("emailInboxDraftSaveFailed")));
} finally {
setSavingDraft(false);
}
@@ -425,9 +420,9 @@ export default function CorrespondenceInboxPage() {
const sendDraft = async () => {
if (!draft || sending || !draft.to.trim() || !draft.subject.trim() || !draft.bodyText.trim()) return;
const approved = await confirm({
title: "Confirm email send",
message: `Send with ${draft.providerName} (${draft.fromAddress}) to ${draft.to.trim()}? Subject: ${draft.subject.trim()}. Thread: ${draft.threadId || "New message"}.`,
confirmLabel: "Send email",
title: t("emailInboxConfirmSendTitle"),
message: t("emailInboxConfirmSendMessage", { provider: draft.providerName, from: draft.fromAddress, to: draft.to.trim(), subject: draft.subject.trim(), thread: draft.threadId || t("emailInboxNewMessage") }),
confirmLabel: t("emailInboxSendEmail"),
});
if (!approved) return;
@@ -446,14 +441,14 @@ export default function CorrespondenceInboxPage() {
});
setSendResult(response.data);
if (response.data.status === "sent") {
toast("Email sent and saved to this job.", "success");
toast(t("emailInboxSentSaved"), "success");
if (draft.id && draft.revision) {
try {
await api.delete(`/email/drafts/${draft.id}`, { params: { revision: draft.revision } });
setDraft((current) => current ? { ...current, id: undefined, revision: undefined } : null);
await loadStoredDrafts();
} catch {
toast("Email was sent, but a newer saved draft still exists. Review it before taking another action.", "warning");
toast(t("emailInboxSentNewerDraft"), "warning");
}
}
await load();
@@ -467,7 +462,7 @@ export default function CorrespondenceInboxPage() {
} else {
setSendResult({ attemptId: draft.clientRequestId, status: "failed", duplicate: false });
}
setSendError(getApiErrorMessage(error, "The email could not be sent."));
setSendError(getApiErrorMessage(error, t("emailInboxSendFailed")));
} finally {
setSending(false);
}
@@ -483,8 +478,8 @@ export default function CorrespondenceInboxPage() {
await loadStoredDrafts();
} catch (error: any) {
setDraftError(error?.response?.status === 409
? "A new attempt is allowed only after the latest saved draft has a definitive failed delivery. Reload the draft before continuing."
: getApiErrorMessage(error, "Failed to prepare a new delivery attempt."));
? t("emailInboxNewAttemptConflict")
: getApiErrorMessage(error, t("emailInboxNewAttemptFailed")));
return;
}
} else {
@@ -497,9 +492,9 @@ export default function CorrespondenceInboxPage() {
const unlinkGmailThread = async (item: CorrespondenceInboxItem) => {
if (item.provider !== "gmail" || !item.externalThreadId || unlinkingThreadId) return;
if (!(await confirm({
title: "Unlink Gmail thread?",
message: `Remove this Gmail thread from ${item.companyName || "this job"} and return it to recruitment review? The provider copy is not deleted.`,
confirmLabel: "Unlink thread",
title: t("emailInboxUnlinkTitle"),
message: t("emailInboxUnlinkMessage", { job: item.companyName || t("emailInboxThisJob") }),
confirmLabel: t("emailInboxUnlinkThread"),
destructive: true,
}))) return;
@@ -517,14 +512,21 @@ export default function CorrespondenceInboxPage() {
setMessageDetail(null);
}
await load();
toast("Gmail thread returned to recruitment review.", "success");
toast(t("emailInboxUnlinked"), "success");
} catch (error) {
toast(getApiErrorMessage(error, "Failed to unlink the Gmail thread."), "error");
toast(getApiErrorMessage(error, t("emailInboxUnlinkFailed")), "error");
} finally {
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 (
<Paper
sx={{
@@ -537,46 +539,46 @@ export default function CorrespondenceInboxPage() {
>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap", mb: 2 }}>
<Box>
<Typography component="h1" variant="h5" sx={{ fontWeight: 900 }}>Job email</Typography>
<Typography component="h1" variant="h5" sx={{ fontWeight: 900 }}>{t("correspondenceInbox")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
Review linked correspondence and suggested recruitment messages in one place.
{t("emailInboxSubtitle")}
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
{view === "inbox" ? <Chip icon={<MailOutlineIcon />} label={`${inboxTotal} items`} variant="outlined" /> : null}
{view === "inbox" ? <Chip label={`${filteredSummary.linked} linked`} variant="outlined" color={filteredSummary.linked > 0 ? "success" : "default"} /> : null}
{view === "inbox" ? <Chip label={`${filteredSummary.inbound} inbound`} variant="outlined" /> : null}
<Button variant={view === "inbox" ? "contained" : "text"} size="small" onClick={() => setSearchParams({})}>Linked messages</Button>
<Button variant={view === "review" ? "contained" : "text"} size="small" onClick={() => setSearchParams({ view: "review" })}>Review suggestions</Button>
<Button variant="outlined" size="small" onClick={openComposeSetup} disabled={jobs.length === 0 || sendCapableProviders.length === 0}>Compose new email</Button>
{view === "inbox" ? <Chip icon={<MailOutlineIcon />} label={t("emailInboxItems", { count: inboxTotal })} variant="outlined" /> : null}
{view === "inbox" ? <Chip label={t("emailInboxLinked", { count: filteredSummary.linked })} variant="outlined" color={filteredSummary.linked > 0 ? "success" : "default"} /> : null}
{view === "inbox" ? <Chip label={t("emailInboxInboundCount", { count: filteredSummary.inbound })} variant="outlined" /> : null}
<Button variant={view === "inbox" ? "contained" : "text"} size="small" onClick={() => setSearchParams({})}>{t("emailInboxLinkedMessages")}</Button>
<Button variant={view === "review" ? "contained" : "text"} size="small" onClick={() => setSearchParams({ view: "review" })}>{t("emailInboxReviewSuggestions")}</Button>
<Button variant="outlined" size="small" onClick={openComposeSetup} disabled={jobs.length === 0 || sendCapableProviders.length === 0}>{t("emailInboxCompose")}</Button>
</Box>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 2 }} aria-label="Email provider status">
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 2 }} aria-label={t("emailInboxProviderStatus")}>
{providers.map((provider) => (
<Chip
key={provider.provider}
size="small"
color={provider.connected ? "success" : "default"}
variant="outlined"
label={emailProviderStatusLabel(provider)}
label={providerStatusLabel(provider)}
/>
))}
</Box>
{providerStatusError ? (
<Alert severity="warning" sx={{ mb: 2 }}>Email provider status is temporarily unavailable. Saved JobTracker correspondence remains available.</Alert>
<Alert severity="warning" sx={{ mb: 2 }}>{t("emailInboxProviderStatusUnavailable")}</Alert>
) : null}
{jobsLoaded && jobs.length === 0 ? <Alert severity="info" sx={{ mb: 2 }}>Add a job before composing a new email.</Alert> : null}
{providerStatusLoaded && !providerStatusError && sendCapableProviders.length === 0 ? <Alert severity="info" sx={{ mb: 2 }}>Reconnect Gmail or Outlook with send access before composing a new email.</Alert> : null}
{jobsLoaded && 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}
{draftError && !draft ? <Alert severity="warning" sx={{ mb: 2 }}>{draftError}</Alert> : null}
{storedDrafts.length > 0 ? (
<Alert severity="info" sx={{ mb: 2 }}>
<Stack direction="row" spacing={1} useFlexGap flexWrap="wrap" alignItems="center">
<Typography variant="body2">{storedDrafts.length} saved email draft{storedDrafts.length === 1 ? "" : "s"} available.</Typography>
<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" onClick={() => void resumeDraft(stored)}>
Resume {stored.subject || `job #${stored.jobApplicationId}`}
{t("emailInboxResumeDraft", { name: stored.subject || `${t("emailInboxJobNumber")} #${stored.jobApplicationId}` })}
</Button>
))}
</Stack>
@@ -585,7 +587,7 @@ export default function CorrespondenceInboxPage() {
{composeSetupOpen ? (
<Paper component="section" aria-labelledby="compose-new-title" variant="outlined" sx={{ p: 2, borderRadius: 3, mb: 2 }}>
<Typography id="compose-new-title" component="h2" variant="h6" sx={{ fontWeight: 900, mb: 1.5 }}>Compose new email</Typography>
<Typography id="compose-new-title" component="h2" variant="h6" sx={{ fontWeight: 900, mb: 1.5 }}>{t("emailInboxCompose")}</Typography>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr auto" }, gap: 1.25, alignItems: "center" }}>
<Autocomplete
options={jobs}
@@ -596,18 +598,18 @@ export default function CorrespondenceInboxPage() {
}}
onChange={(_, value) => setComposeJobId(value?.id ?? "")}
isOptionEqualToValue={(option, value) => option.id === value.id}
getOptionLabel={(job) => `${job.companyName || "Unknown company"} · ${job.jobTitle}`}
renderInput={(params) => <TextField {...params} label="Job" placeholder="Search every job" />}
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">Sending provider</InputLabel>
<Select labelId="compose-provider-label" value={composeProvider} label="Sending provider" onChange={(event) => setComposeProvider(String(event.target.value))}>
{sendCapableProviders.map((provider) => <MenuItem key={provider.provider} value={provider.provider}>{provider.displayName} · {provider.address || "Connected account"}</MenuItem>)}
<InputLabel id="compose-provider-label">{t("emailInboxSendingProvider")}</InputLabel>
<Select labelId="compose-provider-label" value={composeProvider} 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 onClick={() => setComposeSetupOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={() => void startNewMessage()} disabled={!composeJobId || !composeProvider}>Start draft</Button>
<Button onClick={() => setComposeSetupOpen(false)}>{t("cancel")}</Button>
<Button variant="contained" onClick={() => void startNewMessage()} disabled={!composeJobId || !composeProvider}>{t("emailInboxStartDraft")}</Button>
</Box>
</Box>
</Paper>
@@ -615,60 +617,60 @@ export default function CorrespondenceInboxPage() {
{view === "review" ? <GmailReviewPage embedded /> : <>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "2fr 1fr 1fr auto" }, gap: 1.25, mb: 2 }}>
<TextField label="Search" value={query} onChange={(e) => { setQuery(e.target.value); setInboxPage(1); }} placeholder="Company, role, recruiter, subject" />
<TextField label={t("correspondenceSearch")} value={query} onChange={(e) => { setQuery(e.target.value); setInboxPage(1); }} placeholder={t("emailInboxSearchPlaceholder")} />
<FormControl fullWidth>
<InputLabel>Direction</InputLabel>
<Select value={direction} label="Direction" onChange={(e) => { setDirection(String(e.target.value)); setInboxPage(1); }}>
<MenuItem value="all">All</MenuItem>
<MenuItem value="inbound">Inbound</MenuItem>
<MenuItem value="outbound">Outbound</MenuItem>
<MenuItem value="internal">Internal</MenuItem>
<InputLabel id="email-direction-label">{t("emailInboxDirection")}</InputLabel>
<Select labelId="email-direction-label" value={direction} label={t("emailInboxDirection")} onChange={(e) => { setDirection(String(e.target.value)); setInboxPage(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>Link state</InputLabel>
<Select value={linkState} label="Link state" onChange={(e) => { setLinkState(String(e.target.value)); setInboxPage(1); }}>
<MenuItem value="all">All</MenuItem>
<MenuItem value="linked">Linked threads</MenuItem>
<MenuItem value="manual">Manual/internal only</MenuItem>
<InputLabel id="email-link-state-label">{t("emailInboxLinkState")}</InputLabel>
<Select labelId="email-link-state-label" value={linkState} label={t("emailInboxLinkState")} onChange={(e) => { setLinkState(String(e.target.value)); setInboxPage(1); }}>
<MenuItem value="all">{t("emailInboxAll")}</MenuItem>
<MenuItem value="linked">{t("emailInboxLinkedThreads")}</MenuItem>
<MenuItem value="manual">{t("emailInboxManualOnly")}</MenuItem>
</Select>
</FormControl>
<Button variant="contained" onClick={() => void load()} disabled={loading}>{loading ? "Loading..." : "Refresh"}</Button>
<Button variant="contained" onClick={() => void load()} disabled={loading}>{loading ? t("loading") : t("refresh")}</Button>
</Box>
{draft ? (
<Paper component="section" aria-labelledby="email-draft-title" variant="outlined" sx={{ p: 2, borderRadius: 3, mb: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", mb: 1.5 }}>
<Box>
<Typography id="email-draft-title" component="h2" variant="h6" sx={{ fontWeight: 900 }}>Email draft</Typography>
<Typography id="email-draft-title" component="h2" variant="h6" sx={{ fontWeight: 900 }}>{t("emailInboxDraftTitle")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{draft.companyName} · {draft.jobTitle}</Typography>
</Box>
<Button onClick={() => void discardDraft()} disabled={sending}>Discard draft</Button>
<Button onClick={() => void discardDraft()} disabled={sending}>{t("emailInboxDiscardDraft")}</Button>
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.25 }}>
<TextField label="Provider" value={`${draft.providerName} · ${draft.fromAddress}`} slotProps={{ input: { readOnly: true } }} />
<TextField label="Thread" value={draft.threadId || "New message"} slotProps={{ input: { readOnly: true } }} />
<TextField label="Recipient" type="email" value={draft.to} onChange={(event) => updateDraft({ to: event.target.value })} disabled={sending} inputProps={{ maxLength: 320 }} />
<TextField label="Subject" value={draft.subject} onChange={(event) => updateDraft({ subject: event.target.value })} disabled={sending} inputProps={{ maxLength: 998 }} />
<TextField label="Message" value={draft.bodyText} onChange={(event) => updateDraft({ bodyText: event.target.value })} disabled={sending} multiline minRows={6} inputProps={{ maxLength: 200000 }} helperText={`${draft.bodyText.length} / 200000 characters`} sx={{ gridColumn: "1 / -1" }} />
<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={sending} inputProps={{ maxLength: 320 }} />
<TextField label={t("emailInboxSubject")} value={draft.subject} onChange={(event) => updateDraft({ subject: event.target.value })} disabled={sending} inputProps={{ maxLength: 998 }} />
<TextField label={t("emailInboxMessage")} value={draft.bodyText} onChange={(event) => updateDraft({ bodyText: event.target.value })} disabled={sending} multiline minRows={6} inputProps={{ maxLength: 200000 }} helperText={t("emailInboxCharacters", { count: draft.bodyText.length })} sx={{ gridColumn: "1 / -1" }} />
</Box>
{sendResult?.status === "sent" ? <Alert severity="success" sx={{ mt: 1.5 }}>Sent successfully. This exact attempt cannot be sent again.</Alert> : null}
{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 }}>Delivery status is uncertain. Do not retry this draft. Check the provider Sent folder before taking any further action.</Alert>
<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" onClick={() => void prepareNewAttempt()}>Prepare new attempt</Button>}>
The provider confirmed that this attempt did not complete. Review the connection and draft before creating a new attempt.
<Alert severity="error" sx={{ mt: 1.5 }} action={<Button color="inherit" size="small" onClick={() => void prepareNewAttempt()}>{t("emailInboxPrepareAttempt")}</Button>}>
{t("emailInboxDeliveryFailed")}
</Alert>
) : null}
{draftError ? <Alert severity="warning" sx={{ mt: 1.5 }}>{draftError}</Alert> : null}
{sendError && sendResult?.status !== "uncertain" ? <Typography variant="body2" sx={{ color: "error.main", mt: 1 }}>{sendError}</Typography> : null}
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1, mt: 1.5, flexWrap: "wrap" }}>
<Button variant="outlined" onClick={() => void saveDraft()} disabled={savingDraft || sending || !!sendResult}>
{savingDraft ? "Saving…" : draft.id ? "Save changes" : "Save draft"}
{savingDraft ? t("saving") : draft.id ? t("profileSaveChanges") : t("emailInboxSaveDraft")}
</Button>
<Button variant="contained" onClick={() => void sendDraft()} disabled={sending || !!sendResult || !draft.to.trim() || !draft.subject.trim() || !draft.bodyText.trim()}>
{sending ? "Sending" : "Review and send"}
{sending ? t("emailInboxSending") : t("emailInboxReviewSend")}
</Button>
</Box>
</Paper>
@@ -677,7 +679,7 @@ export default function CorrespondenceInboxPage() {
{loading ? <Box sx={{ py: 6, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : null}
{!loading && items.length === 0 ? (
<Typography sx={{ color: "text.secondary", py: 4, textAlign: "center" }}>No correspondence matches the current filters.</Typography>
<Typography sx={{ color: "text.secondary", py: 4, textAlign: "center" }}>{t("emailInboxNoMatches")}</Typography>
) : null}
<Stack spacing={1.25}>
@@ -685,44 +687,44 @@ export default function CorrespondenceInboxPage() {
<Paper key={item.id} variant="outlined" sx={{ p: 1.5, borderRadius: 3 }}>
<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 || "Unknown company"} {item.jobTitle || "Unknown role"}</Typography>
<Typography sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{item.companyName || t("emailInboxUnknownCompany")} {item.jobTitle || t("emailInboxUnknownRole")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{item.subject || item.contentPreview}</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()}
{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={item.direction} variant="outlined" /> : null}
{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={`Thread ${item.externalThreadId}`} color="success" variant="outlined" /> : <Chip size="small" label="Manual/internal" variant="outlined" />}
{item.labelCount > 0 ? <Chip size="small" label={`${item.labelCount} labels`} variant="outlined" /> : null}
{item.attachmentCount > 0 ? <Chip size="small" label={`${item.attachmentCount} attachments`} variant="outlined" /> : null}
<Button size="small" variant="text" onClick={() => void showMessage(item)}>{selectedMessageId === item.id ? "Hide message" : "View message"}</Button>
<Button size="small" variant="text" onClick={() => navigate(`/jobs/${item.jobApplicationId}?section=communication`)}>Open job</Button>
{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" onClick={() => void showMessage(item)}>{selectedMessageId === item.id ? t("emailInboxHideMessage") : t("emailInboxViewMessage")}</Button>
<Button size="small" variant="text" onClick={() => navigate(`/jobs/${item.jobApplicationId}?section=communication`)}>{t("emailInboxOpenJob")}</Button>
{item.provider === "gmail" && item.externalThreadId ? (
<Button size="small" color="warning" variant="text" disabled={unlinkingThreadId === item.externalThreadId} onClick={() => void unlinkGmailThread(item)}>
{unlinkingThreadId === item.externalThreadId ? "Unlinking…" : "Unlink thread"}
{unlinkingThreadId === item.externalThreadId ? t("emailInboxUnlinking") : t("emailInboxUnlinkThread")}
</Button>
) : null}
</Box>
</Box>
{selectedMessageId === item.id ? (
<Box sx={{ mt: 1.5, pt: 1.5, borderTop: "1px solid", borderColor: "divider" }}>
{detailLoading ? <Box sx={{ display: "flex", gap: 1, alignItems: "center" }}><CircularProgress size={18} /><Typography variant="body2">Loading message</Typography></Box> : null}
{detailLoading ? <Box sx={{ display: "flex", gap: 1, alignItems: "center" }}><CircularProgress size={18} /><Typography variant="body2">{t("emailInboxLoadingMessage")}</Typography></Box> : null}
{detailNotice ? <Alert severity="warning" sx={{ mb: 1 }}>{detailNotice}</Alert> : null}
{detailError ? <Alert severity="error">{detailError}</Alert> : null}
{messageDetail ? <>
<Typography sx={{ fontWeight: 800 }}>{messageDetail.subject || "No subject"}</Typography>
<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 || "Attachment"} 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" onClick={() => void startReply(item)}>Reply with connected provider</Button>
<Button sx={{ mt: 1.5 }} variant="outlined" onClick={() => void startReply(item)}>{t("emailInboxReply")}</Button>
) : item.provider && item.provider !== "manual" ? (
<Alert severity="info" sx={{ mt: 1.5 }}>Reconnect this provider with send access to reply from JobTracker.</Alert>
<Alert severity="info" sx={{ mt: 1.5 }}>{t("emailInboxReconnectToReply")}</Alert>
) : null}
</> : null}
</Box>
@@ -737,7 +739,7 @@ export default function CorrespondenceInboxPage() {
count={inboxTotalPages}
onChange={(_, value) => setInboxPage(value)}
color="primary"
aria-label="Correspondence pages"
aria-label={t("emailInboxPages")}
/>
</Box>
) : null}