fix(ui): preserve compose draft identity and delivery safeguards

This commit is contained in:
cesnimda
2026-10-14 13:03:05 +02:00
parent a791e353b0
commit 223dcf57d2
18 changed files with 4947 additions and 267 deletions
@@ -66,6 +66,42 @@ function HistoryBackControl() {
return <button onClick={() => navigate(-1)}>History Back</button>;
}
const composeUuid = '00000000-0000-4000-8000-000000000101';
const replacementUuid = '00000000-0000-4000-8000-000000000202';
function savedComposeDraft(overrides: Record<string, unknown> = {}) {
return { id: 'compose-owned', revision: 4, jobApplicationId: 42, provider: 'gmail', to: 'maria@acme.test', subject: 'Owned compose draft', bodyText: 'Owned saved body', threadId: 'thread-1', clientRequestId: 'server-owned-attempt', createdAtUtc: '2026-02-13T11:30:00Z', updatedAtUtc: '2026-02-13T11:30:00Z', ...overrides };
}
function composeReads(stored: ReturnType<typeof savedComposeDraft>[] = []) {
const original = mockedApi.get.getMockImplementation();
mockedApi.get.mockImplementation((url: string, config?: any) => {
if (url === '/email/providers') return Promise.resolve({ data: [{ provider: 'gmail', displayName: 'Gmail', connected: true, address: 'owner@gmail.test', canRead: true, canSend: true }] } as any);
if (url === '/email/drafts') return Promise.resolve({ data: stored } as any);
if (url === '/jobapplications/choices') return Promise.resolve({ data: [{ id: 42, jobTitle: 'Backend Engineer', companyName: 'Acme Systems' }] } as any);
return original!(url, config);
});
const uuid = globalThis.crypto.randomUUID as jest.Mock;
uuid.mockReset().mockReturnValueOnce(composeUuid).mockReturnValue(replacementUuid);
return uuid;
}
function composeField(key: string) {
return within(screen.getByRole('region', { name: copy('emailInboxDraftTitle') })).getByRole('textbox', { name: copy(key) });
}
async function openReply(bodyText = '') {
fireEvent.click(await screen.findByRole('button', { name: copy('emailInboxViewMessage') }));
fireEvent.click(await screen.findByRole('button', { name: copy('emailInboxReply') }));
if (bodyText) fireEvent.change(composeField('emailInboxMessage'), { target: { value: bodyText } });
}
async function closeConfirmation(actionKey: string) {
const dialog = await screen.findByRole('dialog');
fireEvent.click(within(dialog).getByRole('button', { name: copy(actionKey) }));
await waitFor(() => expect(screen.queryByRole('dialog', { hidden: true })).not.toBeInTheDocument());
}
function renderPage(initialPath = '/') {
return render(
<ToastProvider>
@@ -834,4 +870,299 @@ describe('CorrespondenceInboxPage', () => {
expect(mockedApi.patch).not.toHaveBeenCalled();
expect(mockedApi.delete).not.toHaveBeenCalled();
});
test('held draft Save captures one write and locks edits, Send, Discard and every draft replacement entry', async () => {
composeReads([savedComposeDraft()]);
const save = deferred<any>();
mockedApi.post.mockReturnValue(save.promise);
renderPage();
await openReply('Captured save body');
await waitFor(() => expect(screen.getByRole('button', { name: copy('emailInboxCompose') })).toBeEnabled());
const button = screen.getByRole('button', { name: copy('emailInboxSaveDraft') });
act(() => { fireEvent.click(button); fireEvent.click(button); });
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(mockedApi.post).toHaveBeenCalledWith('/email/drafts', { jobApplicationId: 42, provider: 'gmail', to: 'Maria Recruiter <maria@acme.test>', subject: 'Re: Interview invite', bodyText: 'Captured save body', threadId: 'thread-1' });
for (const key of ['emailInboxRecipient', 'emailInboxSubject', 'emailInboxMessage']) expect(composeField(key)).toBeDisabled();
for (const key of ['emailInboxReviewSend', 'emailInboxDiscardDraft', 'emailInboxCompose', 'emailInboxReply']) expect(screen.getByRole('button', { name: copy(key) })).toBeDisabled();
expect(screen.getByRole('button', { name: copy('emailInboxResumeDraft', { name: 'Owned compose draft' }) })).toBeDisabled();
await act(async () => { save.resolve({ data: savedComposeDraft({ to: 'Maria Recruiter <maria@acme.test>', subject: 'Re: Interview invite', bodyText: 'Captured save body' }) }); await save.promise; });
await waitFor(() => expect(screen.getByRole('button', { name: copy('profileSaveChanges') })).toBeEnabled());
expect(composeField('emailInboxMessage')).toHaveValue('Captured save body');
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(mockedApi.delete).not.toHaveBeenCalled();
});
test('double Review creates one confirmation, Cancel sends nothing and explicit double Send owns one captured attempt', async () => {
const uuid = composeReads();
const send = deferred<any>();
mockedApi.post.mockReturnValue(send.promise);
renderPage();
await openReply('Captured confirmed reply');
const review = screen.getByRole('button', { name: copy('emailInboxReviewSend') });
review.focus();
act(() => { fireEvent.click(review); fireEvent.click(review); });
expect(await screen.findAllByRole('dialog')).toHaveLength(1);
expect(mockedApi.post).not.toHaveBeenCalled();
await closeConfirmation('cancel');
expect(mockedApi.post).not.toHaveBeenCalled();
await waitFor(() => expect(review).toHaveFocus());
expect(composeField('emailInboxMessage')).toHaveValue('Captured confirmed reply');
fireEvent.click(review);
const dialog = await screen.findByRole('dialog');
const confirm = within(dialog).getByRole('button', { name: copy('emailInboxSendEmail') });
act(() => { fireEvent.click(confirm); fireEvent.click(confirm); });
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(1));
expect(mockedApi.post).toHaveBeenCalledWith('/email/send', { jobApplicationId: 42, provider: 'gmail', clientRequestId: composeUuid, to: 'Maria Recruiter <maria@acme.test>', subject: 'Re: Interview invite', bodyText: 'Captured confirmed reply', threadId: 'thread-1', confirmed: true });
await waitFor(() => expect(screen.queryByRole('dialog', { hidden: true })).not.toBeInTheDocument());
expect(screen.getByRole('button', { name: copy('emailInboxSending') })).toBeDisabled();
expect(uuid).toHaveBeenCalledTimes(1);
await act(async () => { send.resolve({ data: { attemptId: 'server-pending', status: 'pending', duplicate: false } }); await send.promise; });
expect(await screen.findByText(copy('emailInboxDeliveryUncertain'))).toBeInTheDocument();
expect(screen.getByRole('button', { name: copy('emailInboxReviewSend') })).toBeDisabled();
expect(mockedApi.post).toHaveBeenCalledTimes(1);
});
test('recipient, subject and body edits cannot erase any recorded delivery state or rotate its attempt identity', async () => {
for (const status of ['uncertain', 'pending', 'sending', 'sent', 'failed'] as const) {
const uuid = composeReads();
mockedApi.post.mockReset();
const result = { attemptId: `recorded-${status}`, status, duplicate: false, failureCategory: status === 'failed' ? 'provider_rejected' : null };
mockedApi.post.mockImplementation(() => status === 'failed' ? Promise.reject({ response: { status: 502, data: result } }) : Promise.resolve({ data: result } as any));
const mounted = renderPage();
await openReply('Recorded attempt body');
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxReviewSend') }));
await closeConfirmation('emailInboxSendEmail');
const stateKey = status === 'sent' ? 'emailInboxSentSuccess' : status === 'failed' ? 'emailInboxDeliveryFailed' : 'emailInboxDeliveryUncertain';
await screen.findByText(copy(stateKey));
for (const [key, value] of [['emailInboxRecipient', 'changed@acme.test'], ['emailInboxSubject', 'Changed subject'], ['emailInboxMessage', 'Changed body']]) {
fireEvent.change(composeField(key), { target: { value } });
expect(screen.getByText(copy(stateKey))).toBeInTheDocument();
expect(screen.getByRole('button', { name: copy('emailInboxReviewSend') })).toBeDisabled();
expect(uuid).toHaveBeenCalledTimes(1);
expect(mockedApi.post).toHaveBeenCalledTimes(1);
}
if (status === 'failed') expect(screen.getByRole('button', { name: copy('emailInboxPrepareAttempt') })).toBeInTheDocument();
else expect(screen.queryByRole('button', { name: copy('emailInboxPrepareAttempt') })).not.toBeInTheDocument();
mounted.unmount();
}
});
test('generic 409 cannot prepare a new attempt while an authoritative failed result requires one explicit server rotation', async () => {
let uuid = composeReads([savedComposeDraft()]);
mockedApi.post.mockRejectedValue({ response: { status: 409, data: { detail: 'Draft or delivery conflict' } } });
let mounted = renderPage();
fireEvent.click(await screen.findByRole('button', { name: copy('emailInboxResumeDraft', { name: 'Owned compose draft' }) }));
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxReviewSend') }));
await closeConfirmation('emailInboxSendEmail');
await screen.findByText(copy('emailInboxDeliveryUncertain'));
expect(screen.queryByRole('button', { name: copy('emailInboxPrepareAttempt') })).not.toBeInTheDocument();
expect(uuid).not.toHaveBeenCalled();
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(mockedApi.post).toHaveBeenCalledWith('/email/send', expect.objectContaining({ clientRequestId: 'server-owned-attempt' }));
mounted.unmount();
uuid = composeReads([savedComposeDraft()]);
mockedApi.post.mockReset();
const rotate = deferred<any>();
let sends = 0;
mockedApi.post.mockImplementation((url: string) => {
if (url === '/email/send') return ++sends === 1 ? Promise.reject({ response: { status: 502, data: { attemptId: 'failed-server-attempt', status: 'failed', duplicate: false, failureCategory: 'provider_rejected' } } }) : Promise.resolve({ data: { attemptId: 'new-server-attempt', status: 'pending', duplicate: false } } as any);
if (url === '/email/drafts/compose-owned/new-attempt') return rotate.promise;
return Promise.reject(new Error(`Unexpected POST ${url}`));
});
mounted = renderPage();
fireEvent.click(await screen.findByRole('button', { name: copy('emailInboxResumeDraft', { name: 'Owned compose draft' }) }));
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxReviewSend') }));
await closeConfirmation('emailInboxSendEmail');
const prepare = await screen.findByRole('button', { name: copy('emailInboxPrepareAttempt') });
expect(mockedApi.post).toHaveBeenCalledTimes(1);
act(() => { fireEvent.click(prepare); fireEvent.click(prepare); });
expect(mockedApi.post).toHaveBeenCalledTimes(2);
expect(mockedApi.post).toHaveBeenLastCalledWith('/email/drafts/compose-owned/new-attempt', { revision: 4 });
expect(uuid).not.toHaveBeenCalled();
await act(async () => { rotate.resolve({ data: savedComposeDraft({ revision: 5, clientRequestId: 'server-rotated-attempt' }) }); await rotate.promise; });
await waitFor(() => expect(screen.getByRole('button', { name: copy('emailInboxReviewSend') })).toBeEnabled());
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxReviewSend') }));
await closeConfirmation('emailInboxSendEmail');
expect(mockedApi.post).toHaveBeenLastCalledWith('/email/send', expect.objectContaining({ clientRequestId: 'server-rotated-attempt', confirmed: true }));
expect(uuid).not.toHaveBeenCalled();
mounted.unmount();
});
test('recipient-only and subject-only unsaved changes require replacement and discard confirmation whose Cancel retains the draft', async () => {
for (const [key, value] of [['emailInboxRecipient', 'edited-recipient@acme.test'], ['emailInboxSubject', 'Edited subject only']]) {
composeReads([savedComposeDraft()]);
const mounted = renderPage();
await openReply();
fireEvent.change(composeField(key), { target: { value } });
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxResumeDraft', { name: 'Owned compose draft' }) }));
expect(await screen.findByRole('dialog', { name: copy('emailInboxReplaceDraftTitle') })).toBeInTheDocument();
await closeConfirmation('cancel');
expect(composeField(key)).toHaveValue(value);
expect(composeField('emailInboxMessage')).toHaveValue('');
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxDiscardDraft') }));
expect(await screen.findByRole('dialog', { name: copy('emailInboxDiscardDraftTitle') })).toBeInTheDocument();
await closeConfirmation('cancel');
expect(composeField(key)).toHaveValue(value);
expect(composeField('emailInboxMessage')).toHaveValue('');
expect(mockedApi.post).not.toHaveBeenCalled();
expect(mockedApi.put).not.toHaveBeenCalled();
expect(mockedApi.delete).not.toHaveBeenCalled();
mounted.unmount();
}
});
test('saved-draft initial read and post-save refresh failures recover with GET-only Retry without replaying the successful Save', async () => {
composeReads();
const original = mockedApi.get.getMockImplementation();
const retry = deferred<any>();
let draftReads = 0;
mockedApi.get.mockImplementation((url: string, config?: any) => {
if (url === '/email/drafts') {
draftReads += 1;
if (draftReads === 1 || draftReads === 3) return Promise.reject({ response: { status: 503 } });
if (draftReads === 2) return retry.promise;
return Promise.resolve({ data: [savedComposeDraft({ to: 'Maria Recruiter <maria@acme.test>', subject: 'Re: Interview invite', bodyText: 'New captured save' })] } as any);
}
return original!(url, config);
});
mockedApi.post.mockResolvedValue({ data: savedComposeDraft({ to: 'Maria Recruiter <maria@acme.test>', subject: 'Re: Interview invite', bodyText: 'New captured save' }) } as any);
renderPage();
await screen.findByText(copy('emailInboxDraftsLoadFailed'));
const button = retryNotice('emailInboxDraftsLoadFailed').getByRole('button', { name: copy('retry') });
act(() => { fireEvent.click(button); fireEvent.click(button); });
expect(draftReads).toBe(2);
expect(mockedApi.post).not.toHaveBeenCalled();
await act(async () => { retry.resolve({ data: [] }); await retry.promise; });
await openReply('New captured save');
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxSaveDraft') }));
await waitFor(() => expect(draftReads).toBe(3));
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(composeField('emailInboxMessage')).toHaveValue('New captured save');
expect(screen.getByRole('button', { name: copy('profileSaveChanges') })).toBeInTheDocument();
fireEvent.click(retryNotice('emailInboxDraftsLoadFailed').getByRole('button', { name: copy('retry') }));
await waitFor(() => expect(draftReads).toBe(4));
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(mockedApi.put).not.toHaveBeenCalled();
expect(mockedApi.delete).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: copy('emailInboxResumeDraft', { name: 'Re: Interview invite' }) })).toBeInTheDocument();
});
test('a late Save after inbox unmount cannot refresh drafts or attach its result to another page', async () => {
composeReads();
const save = deferred<any>();
mockedApi.post.mockReturnValue(save.promise);
const mounted = renderPage();
await openReply('Old owner save');
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxSaveDraft') }));
expect(mockedApi.post).toHaveBeenCalledTimes(1);
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith('/jobapplications/choices', expect.anything()));
const reads = mockedApi.get.mock.calls.length;
mounted.unmount();
await act(async () => { save.resolve({ data: savedComposeDraft({ bodyText: 'Old owner save' }) }); await save.promise; });
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
expect(screen.queryByRole('region', { name: copy('emailInboxDraftTitle') })).not.toBeInTheDocument();
expect(screen.queryByText(copy('emailInboxDraftSaved'))).not.toBeInTheDocument();
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(mockedApi.put).not.toHaveBeenCalled();
expect(mockedApi.delete).not.toHaveBeenCalled();
});
test('a held managed-draft 409 retains recipient, subject, body and revision with current-language original Save recovery', async () => {
const uuid = composeReads([savedComposeDraft()]);
const save = deferred<any>();
mockedApi.put.mockReturnValueOnce(save.promise).mockRejectedValueOnce({ response: { status: 409 } });
renderPage();
fireEvent.click(await screen.findByRole('button', { name: copy('emailInboxResumeDraft', { name: 'Owned compose draft' }) }));
fireEvent.change(composeField('emailInboxRecipient'), { target: { value: 'local@acme.test' } });
fireEvent.change(composeField('emailInboxSubject'), { target: { value: 'Local conflict subject' } });
fireEvent.change(composeField('emailInboxMessage'), { target: { value: 'Local conflict body' } });
fireEvent.click(screen.getByRole('button', { name: copy('profileSaveChanges') }));
expect(mockedApi.put).toHaveBeenCalledWith('/email/drafts/compose-owned', { revision: 4, to: 'local@acme.test', subject: 'Local conflict subject', bodyText: 'Local conflict body' });
await act(async () => { save.reject({ response: { status: 409 } }); await save.promise.catch(() => undefined); });
await screen.findByText(copy('emailInboxDraftChangedElsewhere'));
const reads = mockedApi.get.mock.calls.length;
await changeLanguage('nb');
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
expect(screen.getByText(copy('emailInboxDraftChangedElsewhere'))).toBeInTheDocument();
expect(composeField('emailInboxRecipient')).toHaveValue('local@acme.test');
expect(composeField('emailInboxSubject')).toHaveValue('Local conflict subject');
expect(composeField('emailInboxMessage')).toHaveValue('Local conflict body');
expect(uuid).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: copy('profileSaveChanges') }));
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledTimes(2));
expect(mockedApi.put).toHaveBeenLastCalledWith('/email/drafts/compose-owned', { revision: 4, to: 'local@acme.test', subject: 'Local conflict subject', bodyText: 'Local conflict body' });
expect(mockedApi.post).not.toHaveBeenCalled();
expect(mockedApi.delete).not.toHaveBeenCalled();
});
test('server attempt rotation preserves three unsaved fields and their dirty state while the next send uses the new identity', async () => {
const uuid = composeReads([savedComposeDraft()]);
const original = mockedApi.get.getMockImplementation();
let serverDraft = savedComposeDraft();
mockedApi.get.mockImplementation((url: string, config?: any) => url === '/email/drafts' ? Promise.resolve({ data: [serverDraft] } as any) : original!(url, config));
let sends = 0;
mockedApi.post.mockImplementation((url: string) => {
if (url === '/email/send') return ++sends === 1
? Promise.reject({ response: { status: 502, data: { attemptId: 'failed-owned-attempt', status: 'failed', duplicate: false, failureCategory: 'provider_rejected' } } })
: Promise.resolve({ data: { attemptId: 'rotated-owned-attempt', status: 'pending', duplicate: false } } as any);
if (url === '/email/drafts/compose-owned/new-attempt') {
serverDraft = savedComposeDraft({ revision: 5, clientRequestId: 'server-rotated-owned-key' });
return Promise.resolve({ data: serverDraft } as any);
}
return Promise.reject(new Error(`Unexpected POST ${url}`));
});
renderPage();
fireEvent.click(await screen.findByRole('button', { name: copy('emailInboxResumeDraft', { name: 'Owned compose draft' }) }));
fireEvent.change(composeField('emailInboxRecipient'), { target: { value: 'edited-recipient@acme.test' } });
fireEvent.change(composeField('emailInboxSubject'), { target: { value: 'Unsaved edited subject' } });
fireEvent.change(composeField('emailInboxMessage'), { target: { value: 'Unsaved edited message' } });
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxReviewSend') }));
await closeConfirmation('emailInboxSendEmail');
fireEvent.click(await screen.findByRole('button', { name: copy('emailInboxPrepareAttempt') }));
await waitFor(() => expect(screen.getByRole('button', { name: copy('emailInboxReviewSend') })).toBeEnabled());
expect(mockedApi.post).toHaveBeenCalledWith('/email/drafts/compose-owned/new-attempt', { revision: 4 });
expect(composeField('emailInboxRecipient')).toHaveValue('edited-recipient@acme.test');
expect(composeField('emailInboxSubject')).toHaveValue('Unsaved edited subject');
expect(composeField('emailInboxMessage')).toHaveValue('Unsaved edited message');
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxResumeDraft', { name: 'Owned compose draft' }) }));
const dialog = await screen.findByRole('dialog', { name: copy('emailInboxReplaceDraftTitle') });
expect(within(dialog).getByText(copy('emailInboxUnsavedDraftLost'))).toBeInTheDocument();
await closeConfirmation('cancel');
expect(composeField('emailInboxMessage')).toHaveValue('Unsaved edited message');
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxReviewSend') }));
await closeConfirmation('emailInboxSendEmail');
expect(mockedApi.post).toHaveBeenLastCalledWith('/email/send', { jobApplicationId: 42, provider: 'gmail', clientRequestId: 'server-rotated-owned-key', to: 'edited-recipient@acme.test', subject: 'Unsaved edited subject', bodyText: 'Unsaved edited message', threadId: 'thread-1', confirmed: true });
expect(mockedApi.post).toHaveBeenCalledTimes(3);
expect(uuid).not.toHaveBeenCalled();
expect(mockedApi.put).not.toHaveBeenCalled();
expect(mockedApi.delete).not.toHaveBeenCalled();
});
test('a committed Save starts a fresh draft-list read despite an older pending GET and rejects the late old snapshot', async () => {
composeReads();
const original = mockedApi.get.getMockImplementation();
const oldRead = deferred<any>();
const freshRead = deferred<any>();
let reads = 0;
const created = savedComposeDraft({ id: 'newly-saved-owned', revision: 1, clientRequestId: 'newly-saved-server-key', to: 'Maria Recruiter <maria@acme.test>', subject: 'Re: Interview invite', bodyText: 'Freshly saved message' });
mockedApi.get.mockImplementation((url: string, config?: any) => url === '/email/drafts' ? ++reads === 1 ? oldRead.promise : freshRead.promise : original!(url, config));
mockedApi.post.mockResolvedValue({ data: created } as any);
renderPage();
await openReply('Freshly saved message');
expect(reads).toBe(1);
fireEvent.click(screen.getByRole('button', { name: copy('emailInboxSaveDraft') }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(1));
await waitFor(() => expect(reads).toBe(2));
expect(mockedApi.get).toHaveBeenLastCalledWith('/email/drafts');
await act(async () => { freshRead.resolve({ data: [created] }); await freshRead.promise; });
expect(await screen.findByRole('button', { name: copy('emailInboxResumeDraft', { name: 'Re: Interview invite' }) })).toBeInTheDocument();
await act(async () => { oldRead.resolve({ data: [savedComposeDraft({ id: 'outdated-owned', subject: 'Stale old draft' })] }); await oldRead.promise; });
expect(screen.getByRole('button', { name: copy('emailInboxResumeDraft', { name: 'Re: Interview invite' }) })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: copy('emailInboxResumeDraft', { name: 'Stale old draft' }) })).not.toBeInTheDocument();
expect(composeField('emailInboxMessage')).toHaveValue('Freshly saved message');
expect(mockedApi.post).toHaveBeenCalledTimes(1);
expect(mockedApi.put).not.toHaveBeenCalled();
expect(mockedApi.delete).not.toHaveBeenCalled();
});
});
+8 -2
View File
@@ -70,6 +70,9 @@ export const translations = {
emailInboxJobNumber: "job",
emailInboxJob: "Job",
emailInboxSearchJobs: "Search every job",
emailInboxJobsLoadFailed: "Could not load jobs for this draft.",
emailInboxNoJobMatches: "No matching jobs",
emailInboxDraftDiscardFailed: "Could not discard the draft. Your draft is still open.",
emailInboxSendingProvider: "Sending provider",
emailInboxStartDraft: "Start draft",
emailInboxSearchPlaceholder: "Company, role, recruiter, subject",
@@ -124,7 +127,7 @@ export const translations = {
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.",
emailInboxUnsavedDraftLost: "Your unsaved changes to this draft will be lost.",
emailInboxSavedDraft: "Saved email draft",
emailInboxReconnectBeforeSending: "Reconnect before sending",
emailInboxDiscardDraftTitle: "Discard email draft?",
@@ -2727,6 +2730,9 @@ export const translations = {
emailInboxJobNumber: "jobb",
emailInboxJob: "Jobb",
emailInboxSearchJobs: "Søk i alle jobber",
emailInboxJobsLoadFailed: "Kunne ikke laste inn jobber for dette utkastet.",
emailInboxNoJobMatches: "Ingen jobber samsvarer med søket",
emailInboxDraftDiscardFailed: "Kunne ikke forkaste utkastet. Utkastet er fortsatt åpent.",
emailInboxSendingProvider: "Avsenderkonto",
emailInboxStartDraft: "Start utkast",
emailInboxSearchPlaceholder: "Bedrift, rolle, rekrutterer, emne",
@@ -2781,7 +2787,7 @@ export const translations = {
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.",
emailInboxUnsavedDraftLost: "Ulagrede endringer i dette utkastet vil gå tapt.",
emailInboxSavedDraft: "Lagret e-postutkast",
emailInboxReconnectBeforeSending: "Koble til på nytt før sending",
emailInboxDiscardDraftTitle: "Forkast e-postutkast?",
@@ -114,6 +114,8 @@ type EmailSendResult = {
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;
@@ -138,6 +140,11 @@ export default function CorrespondenceInboxPage() {
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 | "">("");
@@ -171,13 +178,25 @@ export default function CorrespondenceInboxPage() {
const hasCurrentInbox = loadedListKey === currentListKey && loadError?.kind !== "unauthorized";
const [reviewVisited, setReviewVisited] = useState(view === "review");
const mounted = useRef(true);
const [draftsReadError, setDraftsReadError] = useState(false);
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<string | null>(null);
const [draftError, setDraftError] = useState<DraftMutationError | null>(null);
const [sendResult, setSendResult] = useState<EmailSendResult | null>(null);
const [sendError, setSendError] = useState<string | 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);
@@ -196,7 +215,7 @@ export default function CorrespondenceInboxPage() {
useEffect(() => {
mounted.current = true;
return () => { mounted.current = false; listRequest.current += 1; detailRequest.current += 1; };
return () => { mounted.current = false; listRequest.current += 1; detailRequest.current += 1; jobsRequest.current += 1; draftsRequest.current += 1; draftsPending.current = false; };
}, []);
const load = useCallback(async () => {
@@ -275,26 +294,47 @@ export default function CorrespondenceInboxPage() {
}, []);
useEffect(() => { void loadProviders(); }, [loadProviders]);
useEffect(() => {
const timer = window.setTimeout(() => {
setJobsLoading(true);
api.get<JobChoice[]>("/jobapplications/choices", { params: { q: jobQuery.trim() || undefined, limit: 20 } })
.then((response) => setJobs(Array.isArray(response.data) ? response.data : []))
.catch(() => setJobs([]))
.finally(() => {
setJobsLoaded(true);
setJobsLoading(false);
});
}, jobQuery.trim() ? 250 : 0);
return () => window.clearTimeout(timer);
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 () => {
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) { setStoredDrafts(Array.isArray(response.data) ? response.data : []); setDraftsReadError(false); }
} catch {
if (mounted.current) setDraftsReadError(true);
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); }
}
}, []);
@@ -356,225 +396,238 @@ export default function CorrespondenceInboxPage() {
setDetailLoading(false);
};
const startReply = async (item: CorrespondenceInboxItem) => {
if (!messageDetail || !item.provider) return;
const provider = providers.find((candidate) => candidate.provider === item.provider);
if (!provider?.canSend) return;
if (draft?.bodyText.trim() && !(await confirm({
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: t("emailInboxReplaceReplyMessage"),
message: (unresolved ? t("emailInboxDeliveryUncertain") + "\n\n" : "") + t(saved ? "emailInboxCurrentDraftSaved" : "emailInboxUnsavedDraftLost"),
confirmLabel: t("emailInboxReplaceDraft"),
destructive: true,
}))) return;
const subject = messageDetail.subject.trim();
setDraft({
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" ? messageDetail.to : messageDetail.from,
subject: /^re:/i.test(subject) ? subject : `Re: ${subject || t("emailInboxYourMessage")}`,
bodyText: "",
threadId: messageDetail.threadId || item.externalThreadId || "",
clientRequestId: globalThis.crypto.randomUUID(),
destructive: !saved,
});
setSendResult(null);
setSendError(null);
setDraftError(null);
};
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 (draft?.bodyText.trim() && !(await confirm({
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);
const item = items.find((candidate) => candidate.jobApplicationId === stored.jobApplicationId);
setDraft({
...stored,
companyName: item?.companyName || `Job #${stored.jobApplicationId}`,
jobTitle: item?.jobTitle || t("emailInboxSavedDraft"),
providerName: provider?.displayName || stored.provider,
fromAddress: provider?.address || t("emailInboxReconnectBeforeSending"),
});
setSendResult(null);
setSendError(null);
setDraftError(null);
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 = jobs.find((candidate) => candidate.id === composeJobId);
const job = jobsCurrent ? jobs.find((candidate) => candidate.id === composeJobId) : undefined;
const provider = sendCapableProviders.find((candidate) => candidate.provider === composeProvider);
if (!job || !provider) return;
if (draft?.bodyText.trim() && !(await confirm({
title: t("emailInboxReplaceDraftTitle"),
message: draft.id ? t("emailInboxCurrentDraftSaved") : t("emailInboxUnsavedDraftLost"),
confirmLabel: t("emailInboxReplaceDraft"),
destructive: !draft.id,
}))) return;
setDraft({
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);
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">>) => {
setDraft((current) => current ? {
...current,
...changes,
clientRequestId: sendResult ? globalThis.crypto.randomUUID() : current.clientRequestId,
} : null);
if (sendResult) setSendResult(null);
const current = draftRef.current;
if (!current || composeOperation.current || sendResult) return;
const next = { ...current, ...changes };
draftRef.current = next;
setDraft(next);
setSendError(null);
};
const discardDraft = async () => {
if (draft?.bodyText.trim() && !(await confirm({
title: t("emailInboxDiscardDraftTitle"),
message: t("emailInboxDiscardDraftMessage"),
confirmLabel: t("emailInboxDiscardDraft"),
destructive: true,
}))) return;
if (draft?.id && draft.revision) {
try {
await api.delete(`/email/drafts/${draft.id}`, { params: { revision: draft.revision } });
await loadStoredDrafts();
} catch (error) {
setDraftError(getApiErrorMessage(error, t("emailInboxDraftChangedDiscard")));
return;
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);
}
}
setDraft(null);
setSendResult(null);
setSendError(null);
setDraftError(null);
} catch (error) {
if (isCurrentDraft(captured)) setDraftError({ ...readError(error), action: "discard" });
} finally { finishComposeOperation(); }
};
const saveDraft = async () => {
if (!draft || savingDraft || sendResult) return;
setSavingDraft(true);
setDraftError(null);
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 = draft.id && draft.revision
? await api.put<StoredEmailDraft>(`/email/drafts/${draft.id}`, {
revision: draft.revision,
to: draft.to,
subject: draft.subject,
bodyText: draft.bodyText,
})
: await api.post<StoredEmailDraft>("/email/drafts", {
jobApplicationId: draft.jobApplicationId,
provider: draft.provider,
to: draft.to,
subject: draft.subject,
bodyText: draft.bodyText,
threadId: draft.threadId || null,
});
setDraft((current) => current ? { ...current, ...response.data } : null);
await loadStoredDrafts();
toast(t("emailInboxDraftSaved"), "success");
} catch (error: any) {
setDraftError(error?.response?.status === 409
? t("emailInboxDraftChangedElsewhere")
: getApiErrorMessage(error, t("emailInboxDraftSaveFailed")));
} finally {
setSavingDraft(false);
}
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 () => {
if (!draft || sending || !draft.to.trim() || !draft.subject.trim() || !draft.bodyText.trim()) return;
const approved = await confirm({
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;
setSending(true);
setSendError(null);
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: draft.jobApplicationId,
provider: draft.provider,
clientRequestId: draft.clientRequestId,
to: draft.to.trim(),
subject: draft.subject.trim(),
bodyText: draft.bodyText,
threadId: draft.threadId || null,
confirmed: true,
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(t("emailInboxSentSaved"), "success");
if (draft.id && draft.revision) {
toast(latestT.current("emailInboxSentSaved"), "success");
if (captured.id && captured.revision) {
try {
await api.delete(`/email/drafts/${draft.id}`, { params: { revision: draft.revision } });
setDraft((current) => current ? { ...current, id: undefined, revision: undefined } : null);
await loadStoredDrafts();
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 {
toast(t("emailInboxSentNewerDraft"), "warning");
if (!mounted.current) return;
toast(latestT.current("emailInboxSentNewerDraft"), "warning");
}
if (mounted.current) await loadStoredDrafts(true);
}
await load();
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) {
setSendResult({ attemptId: draft.clientRequestId, status: "uncertain", duplicate: false, failureCategory: "client_connection_lost" });
} else {
setSendResult({ attemptId: draft.clientRequestId, status: "failed", duplicate: false });
}
setSendError(getApiErrorMessage(error, t("emailInboxSendFailed")));
} finally {
setSending(false);
}
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 () => {
if (!draft || sendResult?.status !== "failed") return;
setDraftError(null);
if (draft.id && draft.revision) {
try {
const response = await api.post<StoredEmailDraft>(`/email/drafts/${draft.id}/new-attempt`, { revision: draft.revision });
setDraft((current) => current ? { ...current, ...response.data } : null);
await loadStoredDrafts();
} catch (error: any) {
setDraftError(error?.response?.status === 409
? t("emailInboxNewAttemptConflict")
: getApiErrorMessage(error, t("emailInboxNewAttemptFailed")));
return;
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);
}
} else {
setDraft((current) => current ? { ...current, clientRequestId: globalThis.crypto.randomUUID() } : null);
}
setSendResult(null);
setSendError(null);
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) => {
@@ -626,9 +679,9 @@ export default function CorrespondenceInboxPage() {
{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} size="small" onClick={() => updateFilters({ view: "inbox" })}>{t("emailInboxLinkedMessages")}</Button>
<Button variant={view === "review" ? "outlined" : "text"} aria-pressed={view === "review"} disabled={!!unlinkingThreadId} size="small" onClick={() => updateFilters({ view: "review" })}>{t("emailInboxReviewSuggestions")}</Button>
<Button variant="outlined" size="small" onClick={openComposeSetup} disabled={jobs.length === 0 || sendCapableProviders.length === 0}>{t("emailInboxCompose")}</Button>
<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>
@@ -646,17 +699,21 @@ export default function CorrespondenceInboxPage() {
</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>
{jobsLoaded && jobs.length === 0 ? <Alert severity="info" sx={{ mb: 2 }}>{t("emailInboxAddJobFirst")}</Alert> : null}
<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}
{draftError && !draft ? <Alert severity="warning" sx={{ mb: 2 }}>{draftError}</Alert> : null}
{draftsReadError && !draft ? <Alert severity="warning" sx={{ mb: 2 }}>{t("emailInboxDraftsLoadFailed")}</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" onClick={() => void resumeDraft(stored)}>
<Button key={stored.id} size="small" disabled={composeBusy} onClick={() => void resumeDraft(stored)}>
{t("emailInboxResumeDraft", { name: stored.subject || `${t("emailInboxJobNumber")} #${stored.jobApplicationId}` })}
</Button>
))}
@@ -665,12 +722,14 @@ export default function CorrespondenceInboxPage() {
) : null}
{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 }}>{t("emailInboxCompose")}</Typography>
<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={jobs}
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);
@@ -682,13 +741,13 @@ export default function CorrespondenceInboxPage() {
/>
<FormControl fullWidth>
<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))}>
<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 onClick={() => setComposeSetupOpen(false)}>{t("cancel")}</Button>
<Button variant="contained" onClick={() => void startNewMessage()} disabled={!composeJobId || !composeProvider}>{t("emailInboxStartDraft")}</Button>
<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>
@@ -719,37 +778,38 @@ export default function CorrespondenceInboxPage() {
</Box>
{draft ? (
<Paper component="section" aria-labelledby="email-draft-title" variant="outlined" sx={{ p: 2, borderRadius: 3, mb: 2 }}>
<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="h2" variant="h6" sx={{ fontWeight: 900 }}>{t("emailInboxDraftTitle")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{draft.companyName} · {draft.jobTitle}</Typography>
<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={sending}>{t("emailInboxDiscardDraft")}</Button>
<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={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" }} />
<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" onClick={() => void prepareNewAttempt()}>{t("emailInboxPrepareAttempt")}</Button>}>
<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 }}>{draftError}</Alert> : null}
{sendError && sendResult?.status !== "uncertain" ? <Typography variant="body2" sx={{ color: "error.main", mt: 1 }}>{sendError}</Typography> : 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={savingDraft || sending || !!sendResult}>
<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={sending || !!sendResult || !draft.to.trim() || !draft.subject.trim() || !draft.bodyText.trim()}>
<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>
@@ -802,7 +862,7 @@ export default function CorrespondenceInboxPage() {
{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)}>{t("emailInboxReply")}</Button>
<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}