From b73596394e314be0195a22c13f520cdd8669b7bb Mon Sep 17 00:00:00 2001 From: cesnimda Date: Mon, 10 Aug 2026 10:28:41 +0200 Subject: [PATCH] feat(email): add new-message drafting --- .../src/correspondence-inbox-page.test.tsx | 50 +++++++++++ .../src/views/CorrespondenceInboxPage.tsx | 85 ++++++++++++++++++- 2 files changed, 134 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 f9ddfb7..d319b57 100644 --- a/job-tracker-ui/src/correspondence-inbox-page.test.tsx +++ b/job-tracker-ui/src/correspondence-inbox-page.test.tsx @@ -321,6 +321,56 @@ describe('CorrespondenceInboxPage', () => { expect(screen.getByRole('button', { name: /review and send/i })).toBeEnabled(); }); + test('starts a new-message draft only from an owned job and send-capable provider choice', 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 }, + { provider: 'microsoft', displayName: 'Outlook', connected: true, address: 'owner@outlook.test', canRead: true, canSend: false }, + ] } as any); + if (url === '/jobapplications') return Promise.resolve({ data: { items: [ + { id: 42, jobTitle: 'Backend Engineer', company: { name: 'Acme Systems' } }, + ] } } as any); + return original!(url, config); + }); + mockedApi.post.mockImplementation((url: string) => { + if (url === '/email/drafts') return Promise.resolve({ data: { + id: 'draft-new', jobApplicationId: 42, provider: 'gmail', to: 'recruiter@acme.test', subject: 'Application question', bodyText: 'Hello from a synthetic draft.', + threadId: null, 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(); + const compose = await screen.findByRole('button', { name: /compose new email/i }); + await waitFor(() => expect(compose).toBeEnabled()); + fireEvent.click(compose); + expect(await screen.findByRole('heading', { name: /compose new email/i })).toBeInTheDocument(); + const providerSelect = screen.getByRole('combobox', { name: /sending provider/i }); + expect(providerSelect).toHaveTextContent(/Gmail/i); + fireEvent.mouseDown(providerSelect); + const gmailOption = await screen.findByRole('option', { name: /Gmail/i }); + expect(screen.queryByRole('option', { name: /Outlook/i })).not.toBeInTheDocument(); + fireEvent.click(gmailOption); + fireEvent.click(screen.getByRole('button', { name: /start draft/i })); + + expect(screen.getByRole('textbox', { name: /^provider$/i })).toHaveValue('Gmail · owner@gmail.test'); + expect(screen.getByLabelText(/thread/i)).toHaveValue('New message'); + fireEvent.change(screen.getByLabelText(/recipient/i), { target: { value: 'recruiter@acme.test' } }); + fireEvent.change(screen.getByLabelText(/subject/i), { target: { value: 'Application question' } }); + fireEvent.change(screen.getByLabelText(/message/i), { target: { value: 'Hello from a synthetic draft.' } }); + fireEvent.click(screen.getByRole('button', { name: /^save draft$/i })); + + await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/email/drafts', { + jobApplicationId: 42, + provider: 'gmail', + to: 'recruiter@acme.test', + subject: 'Application question', + bodyText: 'Hello from a synthetic draft.', + threadId: null, + })); + }); + 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 0021838..2a61d0a 100644 --- a/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx +++ b/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx @@ -50,6 +50,12 @@ type EmailProviderStatus = { canSend: boolean; }; +type JobChoice = { + id: number; + jobTitle: string; + company?: { name?: string | null } | null; +}; + export function emailProviderStatusLabel(provider: EmailProviderStatus) { if (!provider.connected) return `${provider.displayName}: Not connected`; const identity = provider.address || "Connected account"; @@ -110,6 +116,12 @@ export default function CorrespondenceInboxPage() { const { confirm } = useConfirm(); const [items, setItems] = useState([]); const [providers, setProviders] = useState([]); + const [providerStatusLoaded, setProviderStatusLoaded] = useState(false); + const [jobs, setJobs] = useState([]); + const [jobsLoaded, setJobsLoaded] = useState(false); + const [composeSetupOpen, setComposeSetupOpen] = useState(false); + const [composeJobId, setComposeJobId] = useState(""); + const [composeProvider, setComposeProvider] = useState(""); const [providerStatusError, setProviderStatusError] = useState(false); const [selectedMessageId, setSelectedMessageId] = useState(null); const [messageDetail, setMessageDetail] = useState(null); @@ -161,7 +173,15 @@ export default function CorrespondenceInboxPage() { .catch(() => { setProviders([]); setProviderStatusError(true); - }); + }) + .finally(() => setProviderStatusLoaded(true)); + }, []); + + useEffect(() => { + api.get<{ items?: JobChoice[] }>("/jobapplications", { params: { page: 1, pageSize: 100, sortBy: "dateApplied", sortDir: "desc" } }) + .then((response) => setJobs(Array.isArray(response.data?.items) ? response.data.items : [])) + .catch(() => setJobs([])) + .finally(() => setJobsLoaded(true)); }, []); const loadStoredDrafts = useCallback(async () => { @@ -182,6 +202,7 @@ export default function CorrespondenceInboxPage() { const inbound = items.filter((item) => item.direction === "inbound").length; return { linked, inbound }; }, [items]); + const sendCapableProviders = useMemo(() => providers.filter((provider) => provider.connected && provider.canSend), [providers]); const showMessage = async (item: CorrespondenceInboxItem) => { if (selectedMessageId === item.id) { @@ -273,6 +294,41 @@ export default function CorrespondenceInboxPage() { setDraftError(null); }; + const openComposeSetup = () => { + 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 provider = sendCapableProviders.find((candidate) => candidate.provider === composeProvider); + if (!job || !provider) return; + 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; + setDraft({ + jobApplicationId: job.id, + companyName: job.company?.name || "Unknown company", + jobTitle: job.jobTitle || "Unknown role", + provider: provider.provider, + providerName: provider.displayName, + fromAddress: provider.address || "Connected account", + to: "", + subject: "", + bodyText: "", + threadId: "", + clientRequestId: globalThis.crypto.randomUUID(), + }); + setComposeSetupOpen(false); + setSendResult(null); + setSendError(null); + setDraftError(null); + }; + const updateDraft = (changes: Partial>) => { setDraft((current) => current ? { ...current, @@ -463,6 +519,7 @@ export default function CorrespondenceInboxPage() { {view === "inbox" ? : null} + @@ -480,6 +537,8 @@ export default function CorrespondenceInboxPage() { {providerStatusError ? ( Email provider status is temporarily unavailable. Saved JobTracker correspondence remains available. ) : null} + {jobsLoaded && jobs.length === 0 ? Add a job before composing a new email. : null} + {providerStatusLoaded && !providerStatusError && sendCapableProviders.length === 0 ? Reconnect Gmail or Outlook with send access before composing a new email. : null} {draftError && !draft ? {draftError} : null} {storedDrafts.length > 0 ? ( @@ -495,6 +554,30 @@ export default function CorrespondenceInboxPage() { ) : null} + {composeSetupOpen ? ( + + Compose new email + + + Job + + + + Sending provider + + + + + + + + + ) : null} + {view === "review" ? : <> setQuery(e.target.value)} placeholder="Company, role, recruiter, subject" />