From d3d2b6783a7dc89e065144aead105ec399235655 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Mon, 10 Aug 2026 10:16:40 +0200 Subject: [PATCH] feat(email): persist and resume reply drafts --- .../src/correspondence-inbox-page.test.tsx | 64 ++++++++++ .../src/views/CorrespondenceInboxPage.tsx | 118 +++++++++++++++++- 2 files changed, 181 insertions(+), 1 deletion(-) diff --git a/job-tracker-ui/src/correspondence-inbox-page.test.tsx b/job-tracker-ui/src/correspondence-inbox-page.test.tsx index 284e702..7504ce7 100644 --- a/job-tracker-ui/src/correspondence-inbox-page.test.tsx +++ b/job-tracker-ui/src/correspondence-inbox-page.test.tsx @@ -65,6 +65,7 @@ describe('CorrespondenceInboxPage', () => { attachmentCount: 1, }, ] } as any); + if (url === '/email/drafts') return Promise.resolve({ data: [] } as any); if (url === '/email/message') return Promise.resolve({ data: { id: 'message-1', threadId: 'thread-1', subject: 'Interview invite', from: 'Maria Recruiter ', to: 'user@example.test', date: new Date().toISOString(), snippet: 'Interview', bodyText: 'Please choose an interview time.', labels: ['INBOX'], attachments: [{ fileName: 'agenda.pdf' }], @@ -223,6 +224,69 @@ describe('CorrespondenceInboxPage', () => { expect(screen.getByRole('button', { name: /review and send/i })).toBeDisabled(); }); + test('saves an incomplete reply with its server delivery identity', 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.mockImplementation((url: string) => { + if (url === '/email/drafts') return Promise.resolve({ data: { + id: 'draft-1', jobApplicationId: 42, provider: 'gmail', to: 'Maria Recruiter ', subject: 'Re: Interview invite', bodyText: '', + threadId: 'thread-1', clientRequestId: 'server-request-id', revision: 1, createdAtUtc: new Date().toISOString(), updatedAtUtc: new Date().toISOString(), + } } as any); + return Promise.reject(new Error(`Unexpected POST ${url}`)); + }); + + renderPage(); + fireEvent.click(await screen.findByRole('button', { name: /view message/i })); + fireEvent.click(await screen.findByRole('button', { name: /reply with connected provider/i })); + fireEvent.click(screen.getByRole('button', { name: /^save draft$/i })); + + await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/email/drafts', { + jobApplicationId: 42, + provider: 'gmail', + to: 'Maria Recruiter ', + subject: 'Re: Interview invite', + bodyText: '', + threadId: 'thread-1', + })); + expect(await screen.findByText(/email draft saved/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^save changes$/i })).toBeInTheDocument(); + }); + + test('resumes a saved draft and surfaces revision conflicts without overwriting it', async () => { + const original = mockedApi.get.getMockImplementation(); + mockedApi.get.mockImplementation((url: string, config?: any) => { + if (url === '/email/drafts') return Promise.resolve({ data: [{ + id: 'draft-1', jobApplicationId: 42, provider: 'gmail', to: 'maria@acme.test', subject: 'Saved subject', bodyText: 'Saved private draft.', + threadId: 'thread-1', clientRequestId: 'server-request-id', revision: 4, createdAtUtc: new Date().toISOString(), updatedAtUtc: new Date().toISOString(), + }] } as 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.put.mockRejectedValue({ response: { status: 409 } }); + + renderPage(); + fireEvent.click(await screen.findByRole('button', { name: /resume saved subject/i })); + expect(screen.getByLabelText(/message/i)).toHaveValue('Saved private draft.'); + fireEvent.change(screen.getByLabelText(/message/i), { target: { value: 'Conflicting local edit.' } }); + fireEvent.click(screen.getByRole('button', { name: /^save changes$/i })); + + await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith('/email/drafts/draft-1', { + revision: 4, + to: 'maria@acme.test', + subject: 'Saved subject', + bodyText: 'Conflicting local edit.', + })); + expect(await screen.findByText(/changed in another tab/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/message/i)).toHaveValue('Conflicting local edit.'); + }); + test('does not offer retry when delivery is uncertain', async () => { const original = mockedApi.get.getMockImplementation(); mockedApi.get.mockImplementation((url: string, config?: any) => { diff --git a/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx b/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx index 4f1e584..197d778 100644 --- a/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx +++ b/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx @@ -71,6 +71,8 @@ type EmailMessageDetail = { }; type EmailDraft = { + id?: string; + revision?: number; jobApplicationId: number; companyName: string; jobTitle: string; @@ -84,6 +86,13 @@ type EmailDraft = { clientRequestId: string; }; +type StoredEmailDraft = Pick & { + id: string; + revision: number; + createdAtUtc: string; + updatedAtUtc: string; +}; + type EmailSendResult = { attemptId: string; status: "pending" | "sending" | "sent" | "failed" | "uncertain"; @@ -113,6 +122,9 @@ export default function CorrespondenceInboxPage() { const [direction, setDirection] = useState("all"); const [linkState, setLinkState] = useState("all"); const [draft, setDraft] = useState(null); + const [storedDrafts, setStoredDrafts] = useState([]); + const [savingDraft, setSavingDraft] = useState(false); + const [draftError, setDraftError] = useState(null); const [sendResult, setSendResult] = useState(null); const [sendError, setSendError] = useState(null); const [sending, setSending] = useState(false); @@ -152,6 +164,19 @@ export default function CorrespondenceInboxPage() { }); }, []); + const loadStoredDrafts = useCallback(async () => { + try { + const response = await api.get("/email/drafts"); + setStoredDrafts(Array.isArray(response.data) ? response.data : []); + } catch { + setDraftError("Saved drafts could not be loaded. New email can still be reviewed before sending."); + } + }, []); + + useEffect(() => { + void loadStoredDrafts(); + }, [loadStoredDrafts]); + const filteredSummary = useMemo(() => { const linked = items.filter((item) => item.externalThreadId).length; const inbound = items.filter((item) => item.direction === "inbound").length; @@ -224,6 +249,28 @@ export default function CorrespondenceInboxPage() { }); setSendResult(null); setSendError(null); + setDraftError(null); + }; + + const resumeDraft = async (stored: StoredEmailDraft) => { + if (draft?.bodyText.trim() && !(await confirm({ + title: "Replace email draft?", + message: draft.id ? "Your current draft is already saved and can be resumed later." : "Your current unsaved draft text will be lost.", + confirmLabel: "Replace draft", + 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 || "Saved email draft", + providerName: provider?.displayName || stored.provider, + fromAddress: provider?.address || "Reconnect before sending", + }); + setSendResult(null); + setSendError(null); + setDraftError(null); }; const updateDraft = (changes: Partial>) => { @@ -243,9 +290,51 @@ export default function CorrespondenceInboxPage() { confirmLabel: "Discard draft", 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, "The saved draft changed elsewhere. Reload it before discarding.")); + return; + } + } setDraft(null); setSendResult(null); setSendError(null); + setDraftError(null); + }; + + const saveDraft = async () => { + if (!draft || savingDraft || sendResult) return; + setSavingDraft(true); + setDraftError(null); + try { + const response = draft.id && draft.revision + ? await api.put(`/email/drafts/${draft.id}`, { + revision: draft.revision, + to: draft.to, + subject: draft.subject, + bodyText: draft.bodyText, + }) + : await api.post("/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("Email draft saved.", "success"); + } catch (error: any) { + setDraftError(error?.response?.status === 409 + ? "This draft changed in another tab. Resume the latest saved version before editing again." + : getApiErrorMessage(error, "Failed to save the email draft.")); + } finally { + setSavingDraft(false); + } }; const sendDraft = async () => { @@ -273,6 +362,15 @@ export default function CorrespondenceInboxPage() { setSendResult(response.data); if (response.data.status === "sent") { toast("Email sent and saved to this job.", "success"); + if (draft.id && draft.revision) { + try { + await api.delete(`/email/drafts/${draft.id}`, { params: { revision: draft.revision } }); + setDraft((current) => current ? { ...current, id: undefined, revision: undefined } : null); + await loadStoredDrafts(); + } catch { + toast("Email was sent, but a newer saved draft still exists. Review it before taking another action.", "warning"); + } + } await load(); } } catch (error: any) { @@ -367,6 +465,20 @@ export default function CorrespondenceInboxPage() { {providerStatusError ? ( Email provider status is temporarily unavailable. Saved JobTracker correspondence remains available. ) : null} + {draftError && !draft ? {draftError} : null} + + {storedDrafts.length > 0 ? ( + + + {storedDrafts.length} saved email draft{storedDrafts.length === 1 ? "" : "s"} available. + {storedDrafts.slice(0, 5).map((stored) => ( + + ))} + + + ) : null} {view === "review" ? : <> @@ -416,8 +528,12 @@ export default function CorrespondenceInboxPage() { The provider confirmed that this attempt did not complete. Review the connection and draft before creating a new attempt. ) : null} + {draftError ? {draftError} : null} {sendError && sendResult?.status !== "uncertain" ? {sendError} : null} - + +