From 449faeb5cecbcbaf711b658a222c6dc46812e0f1 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Mon, 10 Aug 2026 00:09:42 +0200 Subject: [PATCH] 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. --- .../src/correspondence-inbox-page.test.tsx | 106 +++++++++-- .../src/views/CorrespondenceInboxPage.tsx | 166 ++++++++++++++++++ 2 files changed, 259 insertions(+), 13 deletions(-) diff --git a/job-tracker-ui/src/correspondence-inbox-page.test.tsx b/job-tracker-ui/src/correspondence-inbox-page.test.tsx index 4f2985b..5c80181 100644 --- a/job-tracker-ui/src/correspondence-inbox-page.test.tsx +++ b/job-tracker-ui/src/correspondence-inbox-page.test.tsx @@ -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; function renderPage() { return render( - - - - - + + + + + + + , ); } 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 '); + 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 ', + 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( - - - - } /> - } /> - - - + + + + + } /> + } /> + + + + , ); diff --git a/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx b/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx index 2c4f963..10363aa 100644 --- a/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx +++ b/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx @@ -17,6 +17,7 @@ import { } from "@mui/material"; import MailOutlineIcon from "@mui/icons-material/MailOutline"; import { api, getApiErrorMessage } from "../api"; +import { useConfirm } from "../confirm"; import { useToast } from "../toast"; import GmailReviewPage from "./GmailReviewPage"; @@ -62,11 +63,35 @@ type EmailMessageDetail = { attachments: Array<{ fileName?: string | null; mimeType?: string | null; sizeBytes?: number | null }>; }; +type EmailDraft = { + jobApplicationId: number; + companyName: string; + jobTitle: string; + provider: string; + providerName: string; + fromAddress: string; + to: string; + subject: string; + bodyText: string; + threadId: string; + clientRequestId: string; +}; + +type EmailSendResult = { + attemptId: string; + status: "pending" | "sending" | "sent" | "failed" | "uncertain"; + duplicate: boolean; + externalMessageId?: string | null; + externalThreadId?: string | null; + failureCategory?: string | null; +}; + export default function CorrespondenceInboxPage() { const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const view = searchParams.get("view") === "review" ? "review" : "inbox"; const { toast } = useToast(); + const { confirm } = useConfirm(); const [items, setItems] = useState([]); const [providers, setProviders] = useState([]); const [selectedMessageId, setSelectedMessageId] = useState(null); @@ -79,6 +104,10 @@ export default function CorrespondenceInboxPage() { const [query, setQuery] = useState(""); const [direction, setDirection] = useState("all"); const [linkState, setLinkState] = useState("all"); + const [draft, setDraft] = useState(null); + const [sendResult, setSendResult] = useState(null); + const [sendError, setSendError] = useState(null); + const [sending, setSending] = useState(false); const load = useCallback(async () => { setLoading(true); @@ -154,6 +183,104 @@ export default function CorrespondenceInboxPage() { } }; + 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({ + title: "Replace email draft?", + message: "Starting this reply will discard the draft you are currently editing.", + confirmLabel: "Replace draft", + destructive: true, + }))) return; + const subject = messageDetail.subject.trim(); + setDraft({ + jobApplicationId: item.jobApplicationId, + companyName: item.companyName || "Unknown company", + jobTitle: item.jobTitle || "Unknown role", + provider: item.provider, + providerName: provider.displayName, + fromAddress: provider.address || "Connected account", + to: item.direction === "outbound" ? messageDetail.to : messageDetail.from, + subject: /^re:/i.test(subject) ? subject : `Re: ${subject || "Your message"}`, + bodyText: "", + threadId: messageDetail.threadId || item.externalThreadId || "", + clientRequestId: globalThis.crypto.randomUUID(), + }); + setSendResult(null); + setSendError(null); + }; + + const updateDraft = (changes: Partial>) => { + setDraft((current) => current ? { + ...current, + ...changes, + clientRequestId: sendResult ? globalThis.crypto.randomUUID() : current.clientRequestId, + } : null); + if (sendResult) setSendResult(null); + setSendError(null); + }; + + const discardDraft = async () => { + if (draft?.bodyText.trim() && !(await confirm({ + title: "Discard email draft?", + message: "Your unsent draft text will be lost.", + confirmLabel: "Discard draft", + destructive: true, + }))) return; + setDraft(null); + setSendResult(null); + setSendError(null); + }; + + const sendDraft = async () => { + if (!draft || sending || !draft.to.trim() || !draft.subject.trim() || !draft.bodyText.trim()) return; + const approved = await confirm({ + title: "Confirm email send", + message: `Send with ${draft.providerName} (${draft.fromAddress}) to ${draft.to.trim()}? Subject: ${draft.subject.trim()}. Thread: ${draft.threadId || "New message"}.`, + confirmLabel: "Send email", + }); + if (!approved) return; + + setSending(true); + setSendError(null); + try { + const response = await api.post("/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, + }); + setSendResult(response.data); + if (response.data.status === "sent") { + toast("Email sent and saved to this job.", "success"); + await load(); + } + } catch (error: any) { + 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, "The email could not be sent.")); + } finally { + setSending(false); + } + }; + + const prepareNewAttempt = () => { + setDraft((current) => current ? { ...current, clientRequestId: globalThis.crypto.randomUUID() } : null); + setSendResult(null); + setSendError(null); + }; + return ( void load()} disabled={loading}>{loading ? "Loading..." : "Refresh"} + {draft ? ( + + + + Email draft + {draft.companyName} · {draft.jobTitle} + + + + + + + updateDraft({ to: event.target.value })} disabled={sending} inputProps={{ maxLength: 320 }} /> + updateDraft({ subject: event.target.value })} disabled={sending} inputProps={{ maxLength: 998 }} /> + updateDraft({ bodyText: event.target.value })} disabled={sending} multiline minRows={6} inputProps={{ maxLength: 200000 }} helperText={`${draft.bodyText.length} / 200000 characters`} sx={{ gridColumn: "1 / -1" }} /> + + {sendResult?.status === "sent" ? Sent successfully. This exact attempt cannot be sent again. : null} + {sendResult?.status === "uncertain" || sendResult?.status === "sending" || sendResult?.status === "pending" ? ( + Delivery status is uncertain. Do not retry this draft. Check the provider Sent folder before taking any further action. + ) : null} + {sendResult?.status === "failed" ? ( + Prepare new attempt}> + The provider confirmed that this attempt did not complete. Review the connection and draft before creating a new attempt. + + ) : null} + {sendError && sendResult?.status !== "uncertain" ? {sendError} : null} + + + + + ) : null} + {loading ? : null} {!loading && items.length === 0 ? ( @@ -255,6 +416,11 @@ export default function CorrespondenceInboxPage() { {messageDetail.labels.map((label, index) => )} {messageDetail.attachments.map((attachment, index) => )} : null} + {item.provider && providers.find((provider) => provider.provider === item.provider)?.canSend ? ( + + ) : item.provider && item.provider !== "manual" ? ( + Reconnect this provider with send access to reply from JobTracker. + ) : null} : null} ) : null}