feat(email): add confirmed reply composer

Keep provider, recipient, subject, thread, and body editable until an app-owned confirmation is accepted. Reuse one request UUID per attempt and block automatic retry when delivery is uncertain.
This commit is contained in:
cesnimda
2026-08-10 00:09:42 +02:00
parent 90f9bf576c
commit 449faeb5ce
2 changed files with 259 additions and 13 deletions
@@ -3,6 +3,7 @@ import '@testing-library/jest-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter, Navigate, Route, Routes } from 'react-router-dom';
import { ToastProvider } from './toast';
import { ConfirmProvider } from './confirm';
import { I18nProvider } from './i18n/I18nProvider';
import CorrespondenceInboxPage from './views/CorrespondenceInboxPage';
import { api } from './api';
@@ -24,17 +25,20 @@ const mockedApi = api as jest.Mocked<typeof api>;
function renderPage() {
return render(
<ToastProvider>
<I18nProvider>
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<CorrespondenceInboxPage />
</MemoryRouter>
</I18nProvider>
<ConfirmProvider>
<I18nProvider>
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<CorrespondenceInboxPage />
</MemoryRouter>
</I18nProvider>
</ConfirmProvider>
</ToastProvider>,
);
}
describe('CorrespondenceInboxPage', () => {
beforeEach(() => {
Object.defineProperty(globalThis.crypto, 'randomUUID', { configurable: true, value: jest.fn(() => '00000000-0000-4000-8000-000000000123') });
mockedApi.get.mockImplementation((url: string) => {
if (url === '/email/providers') return Promise.resolve({ data: [
{ provider: 'gmail', displayName: 'Gmail', connected: true, address: 'owner@gmail.test', canRead: true, canSend: false },
@@ -135,6 +139,80 @@ describe('CorrespondenceInboxPage', () => {
expect(mockedApi.get).toHaveBeenCalledWith('/correspondence/message/1');
});
test('keeps replies editable and requires final send confirmation', async () => {
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);
return original!(url, config);
});
mockedApi.post.mockResolvedValue({ data: {
attemptId: 'attempt-1', status: 'sent', duplicate: false, externalMessageId: 'sent-1', externalThreadId: 'thread-1', failureCategory: null,
} } as any);
renderPage();
fireEvent.click(await screen.findByRole('button', { name: /view message/i }));
fireEvent.click(await screen.findByRole('button', { name: /reply with connected provider/i }));
expect(screen.getByRole('textbox', { name: /^provider$/i })).toHaveValue('Gmail · owner@gmail.test');
expect(screen.getByLabelText(/recipient/i)).toHaveValue('Maria Recruiter <maria@acme.test>');
expect(screen.getByLabelText(/subject/i)).toHaveValue('Re: Interview invite');
expect(screen.getByLabelText(/thread/i)).toHaveValue('thread-1');
fireEvent.change(screen.getByLabelText(/message/i), { target: { value: 'Thank you. Tuesday works well.' } });
fireEvent.click(screen.getByRole('button', { name: /review and send/i }));
expect(await screen.findByRole('heading', { name: /confirm email send/i })).toBeInTheDocument();
expect(screen.getByText(/owner@gmail\.test.*maria@acme\.test/i)).toBeInTheDocument();
expect(mockedApi.post).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: /^cancel$/i }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(screen.getByLabelText(/message/i)).toHaveValue('Thank you. Tuesday works well.');
expect(mockedApi.post).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: /review and send/i }));
fireEvent.click(screen.getByRole('button', { name: /^send email$/i }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/email/send', {
jobApplicationId: 42,
provider: 'gmail',
clientRequestId: '00000000-0000-4000-8000-000000000123',
to: 'Maria Recruiter <maria@acme.test>',
subject: 'Re: Interview invite',
bodyText: 'Thank you. Tuesday works well.',
threadId: 'thread-1',
confirmed: true,
}));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(await screen.findByText(/sent successfully/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /review and send/i })).toBeDisabled();
});
test('does not offer retry when delivery is uncertain', async () => {
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);
return original!(url, config);
});
mockedApi.post.mockRejectedValue({ response: { status: 409, data: {
attemptId: 'attempt-1', status: 'uncertain', duplicate: false, failureCategory: 'transport_interrupted',
} } });
renderPage();
fireEvent.click(await screen.findByRole('button', { name: /view message/i }));
fireEvent.click(await screen.findByRole('button', { name: /reply with connected provider/i }));
fireEvent.change(screen.getByLabelText(/message/i), { target: { value: 'Synthetic reply.' } });
fireEvent.click(screen.getByRole('button', { name: /review and send/i }));
fireEvent.click(await screen.findByRole('button', { name: /^send email$/i }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(await screen.findByText(/delivery status is uncertain/i)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /prepare new attempt/i })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /review and send/i })).toBeDisabled();
expect(mockedApi.post).toHaveBeenCalledTimes(1);
});
test('uses one hub for linked messages and recruitment suggestions', async () => {
renderPage();
@@ -147,14 +225,16 @@ describe('CorrespondenceInboxPage', () => {
test('legacy Gmail review route redirects into the consolidated hub filter', async () => {
render(
<ToastProvider>
<I18nProvider>
<MemoryRouter initialEntries={['/correspondence/review']} future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<Routes>
<Route path="/correspondence/review" element={<Navigate to="/correspondence?view=review" replace />} />
<Route path="/correspondence" element={<CorrespondenceInboxPage />} />
</Routes>
</MemoryRouter>
</I18nProvider>
<ConfirmProvider>
<I18nProvider>
<MemoryRouter initialEntries={['/correspondence/review']} future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<Routes>
<Route path="/correspondence/review" element={<Navigate to="/correspondence?view=review" replace />} />
<Route path="/correspondence" element={<CorrespondenceInboxPage />} />
</Routes>
</MemoryRouter>
</I18nProvider>
</ConfirmProvider>
</ToastProvider>,
);