fix(ui): preserve compose draft identity and delivery safeguards
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user