From e9f8653d19e9b004e64850c75ad52267e9816c56 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 29 Aug 2026 22:10:53 +0200 Subject: [PATCH] fix(i18n): localize job email hub --- docs/work-programmes/master-progress.md | 5 + .../src/correspondence-inbox-page.test.tsx | 12 + job-tracker-ui/src/i18n/translations.ts | 198 +++++++++++++++ .../src/views/CorrespondenceInboxPage.tsx | 234 +++++++++--------- 4 files changed, 333 insertions(+), 116 deletions(-) diff --git a/docs/work-programmes/master-progress.md b/docs/work-programmes/master-progress.md index 2d88c3b..29c8ce4 100644 --- a/docs/work-programmes/master-progress.md +++ b/docs/work-programmes/master-progress.md @@ -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. diff --git a/job-tracker-ui/src/correspondence-inbox-page.test.tsx b/job-tracker-ui/src/correspondence-inbox-page.test.tsx index acbc360..8e147b5 100644 --- a/job-tracker-ui/src/correspondence-inbox-page.test.tsx +++ b/job-tracker-ui/src/correspondence-inbox-page.test.tsx @@ -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( diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index f2cc217..4343a20 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -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", diff --git a/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx b/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx index 75ac44d..8baf468 100644 --- a/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx +++ b/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx @@ -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([]); 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("/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 ( - Job email + {t("correspondenceInbox")} - Review linked correspondence and suggested recruitment messages in one place. + {t("emailInboxSubtitle")} - {view === "inbox" ? } label={`${inboxTotal} items`} variant="outlined" /> : null} - {view === "inbox" ? 0 ? "success" : "default"} /> : null} - {view === "inbox" ? : null} - - - + {view === "inbox" ? } label={t("emailInboxItems", { count: inboxTotal })} variant="outlined" /> : null} + {view === "inbox" ? 0 ? "success" : "default"} /> : null} + {view === "inbox" ? : null} + + + - + {providers.map((provider) => ( ))} {providerStatusError ? ( - Email provider status is temporarily unavailable. Saved JobTracker correspondence remains available. + {t("emailInboxProviderStatusUnavailable")} ) : null} - {jobsLoaded && jobs.length === 0 ? Add a job before composing a new email. : null} - {providerStatusLoaded && !providerStatusError && sendCapableProviders.length === 0 ? Reconnect Gmail or Outlook with send access before composing a new email. : null} + {jobsLoaded && jobs.length === 0 ? {t("emailInboxAddJobFirst")} : null} + {providerStatusLoaded && !providerStatusError && sendCapableProviders.length === 0 ? {t("emailInboxReconnectToCompose")} : null} {draftError && !draft ? {draftError} : null} {storedDrafts.length > 0 ? ( - {storedDrafts.length} saved email draft{storedDrafts.length === 1 ? "" : "s"} available. + {t(storedDrafts.length === 1 ? "emailInboxSavedDraftOne" : "emailInboxSavedDraftMany", { count: storedDrafts.length })} {storedDrafts.slice(0, 5).map((stored) => ( ))} @@ -585,7 +587,7 @@ export default function CorrespondenceInboxPage() { {composeSetupOpen ? ( - Compose new email + {t("emailInboxCompose")} setComposeJobId(value?.id ?? "")} isOptionEqualToValue={(option, value) => option.id === value.id} - getOptionLabel={(job) => `${job.companyName || "Unknown company"} · ${job.jobTitle}`} - renderInput={(params) => } + getOptionLabel={(job) => `${job.companyName || t("emailInboxUnknownCompany")} · ${job.jobTitle}`} + renderInput={(params) => } /> - Sending provider - setComposeProvider(String(event.target.value))}> + {sendCapableProviders.map((provider) => {provider.displayName} · {provider.address || t("emailInboxConnectedAccount")})} - - + + @@ -615,60 +617,60 @@ export default function CorrespondenceInboxPage() { {view === "review" ? : <> - { setQuery(e.target.value); setInboxPage(1); }} placeholder="Company, role, recruiter, subject" /> + { setQuery(e.target.value); setInboxPage(1); }} placeholder={t("emailInboxSearchPlaceholder")} /> - Direction - { setDirection(String(e.target.value)); setInboxPage(1); }}> + {t("emailInboxAll")} + {t("emailInboxInbound")} + {t("emailInboxOutbound")} + {t("emailInboxInternal")} - Link state - { setLinkState(String(e.target.value)); setInboxPage(1); }}> + {t("emailInboxAll")} + {t("emailInboxLinkedThreads")} + {t("emailInboxManualOnly")} - + {draft ? ( - Email draft + {t("emailInboxDraftTitle")} {draft.companyName} · {draft.jobTitle} - + - - - updateDraft({ to: event.target.value })} disabled={sending} inputProps={{ maxLength: 320 }} /> - updateDraft({ subject: event.target.value })} disabled={sending} inputProps={{ maxLength: 998 }} /> - updateDraft({ bodyText: event.target.value })} disabled={sending} multiline minRows={6} inputProps={{ maxLength: 200000 }} helperText={`${draft.bodyText.length} / 200000 characters`} sx={{ gridColumn: "1 / -1" }} /> + + + updateDraft({ to: event.target.value })} disabled={sending} inputProps={{ maxLength: 320 }} /> + updateDraft({ subject: event.target.value })} disabled={sending} inputProps={{ maxLength: 998 }} /> + updateDraft({ bodyText: event.target.value })} disabled={sending} multiline minRows={6} inputProps={{ maxLength: 200000 }} helperText={t("emailInboxCharacters", { count: draft.bodyText.length })} sx={{ gridColumn: "1 / -1" }} /> - {sendResult?.status === "sent" ? Sent successfully. This exact attempt cannot be sent again. : null} + {sendResult?.status === "sent" ? {t("emailInboxSentSuccess")} : null} {sendResult?.status === "uncertain" || sendResult?.status === "sending" || sendResult?.status === "pending" ? ( - Delivery status is uncertain. Do not retry this draft. Check the provider Sent folder before taking any further action. + {t("emailInboxDeliveryUncertain")} ) : null} {sendResult?.status === "failed" ? ( - void prepareNewAttempt()}>Prepare new attempt}> - The provider confirmed that this attempt did not complete. Review the connection and draft before creating a new attempt. + void prepareNewAttempt()}>{t("emailInboxPrepareAttempt")}}> + {t("emailInboxDeliveryFailed")} ) : null} {draftError ? {draftError} : null} {sendError && sendResult?.status !== "uncertain" ? {sendError} : null} @@ -677,7 +679,7 @@ export default function CorrespondenceInboxPage() { {loading ? : null} {!loading && items.length === 0 ? ( - No correspondence matches the current filters. + {t("emailInboxNoMatches")} ) : null} @@ -685,44 +687,44 @@ export default function CorrespondenceInboxPage() { - {item.companyName || "Unknown company"} • {item.jobTitle || "Unknown role"} + {item.companyName || t("emailInboxUnknownCompany")} • {item.jobTitle || t("emailInboxUnknownRole")} {item.subject || item.contentPreview} - {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")} - {item.direction ? : null} + {item.direction ? : null} {item.provider ? : null} - {item.externalThreadId ? : } - {item.labelCount > 0 ? : null} - {item.attachmentCount > 0 ? : null} - - + {item.externalThreadId ? : } + {item.labelCount > 0 ? : null} + {item.attachmentCount > 0 ? : null} + + {item.provider === "gmail" && item.externalThreadId ? ( ) : null} {selectedMessageId === item.id ? ( - {detailLoading ? Loading message… : null} + {detailLoading ? {t("emailInboxLoadingMessage")} : null} {detailNotice ? {detailNotice} : null} {detailError ? {detailError} : null} {messageDetail ? <> - {messageDetail.subject || "No subject"} + {messageDetail.subject || t("emailInboxNoSubject")} {messageDetail.from}{messageDetail.to ? ` → ${messageDetail.to}` : ""} {messageDetail.bodyText} {(messageDetail.labels.length > 0 || messageDetail.attachments.length > 0) ? {messageDetail.labels.map((label, index) => )} - {messageDetail.attachments.map((attachment, index) => )} + {messageDetail.attachments.map((attachment, index) => )} : null} {item.provider && providers.find((provider) => provider.provider === item.provider)?.canSend ? ( - + ) : item.provider && item.provider !== "manual" ? ( - Reconnect this provider with send access to reply from JobTracker. + {t("emailInboxReconnectToReply")} ) : null} : null} @@ -737,7 +739,7 @@ export default function CorrespondenceInboxPage() { count={inboxTotalPages} onChange={(_, value) => setInboxPage(value)} color="primary" - aria-label="Correspondence pages" + aria-label={t("emailInboxPages")} /> ) : null}