fix(email): retire legacy SMTP follow-up
CI and Deploy / test (pull_request) Failing after 55s
CI and Deploy / deploy (pull_request) Has been skipped

Keep follow-up draft generation but remove the direct application SMTP delivery boundary. Route users to the provider-aware Job email flow and return 410 for legacy API callers.
This commit is contained in:
cesnimda
2026-08-10 00:43:52 +02:00
parent d7b556e494
commit 8fe39031ea
14 changed files with 46 additions and 96 deletions
@@ -178,7 +178,6 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
const [followUpDraft, setFollowUpDraft] = useState<FollowUpDraft | null>(null);
const [loadingDraft, setLoadingDraft] = useState(false);
const [sendingDraft, setSendingDraft] = useState(false);
const [refreshingAi, setRefreshingAi] = useState(false);
const [candidateFit, setCandidateFit] = useState<CandidateFit | null>(null);
const [matchScore, setMatchScore] = useState<MatchScore | null>(null);
@@ -1265,29 +1264,15 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "warning.main", backgroundColor: "background.default" }}>
<Typography variant="overline">Manual send boundary</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
Generating or regenerating this grounded draft never sends recruiter email. The only outbound step is the explicit Send and log email action below.
Generating or regenerating this grounded draft never sends recruiter email. Copy it or open Job email, where the connected provider and final confirmation are shown before delivery.
</Typography>
</Box>
<TextField label={t("jobDetailsRecipient")} value={draftRecipient} onChange={(e) => setDraftRecipient(e.target.value)} helperText={`${t("jobDetailsRecipientHelp")} Manual send only — nothing is dispatched until you press send.`} />
<TextField label={t("jobDetailsRecipient")} value={draftRecipient} onChange={(e) => setDraftRecipient(e.target.value)} helperText={`${t("jobDetailsRecipientHelp")} This draft is not sent from the Follow up tab.`} />
<TextField label={t("jobDetailsSubject")} value={draftSubject} onChange={(e) => setDraftSubject(e.target.value)} />
<TextField label={t("jobDetailsDraft")} multiline minRows={8} value={draftBody} onChange={(e) => setDraftBody(e.target.value)} helperText="You can edit this before sending. Sending stays manual and logs the sent note back to correspondence." />
<TextField label={t("jobDetailsDraft")} multiline minRows={8} value={draftBody} onChange={(e) => setDraftBody(e.target.value)} helperText="You can edit and copy this draft. Provider delivery is available only from Job email." />
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}>
<Button variant="outlined" onClick={() => navigator.clipboard.writeText(`${draftSubject}\n\n${draftBody}`)}>{t("jobDetailsCopyDraft")}</Button>
<Button variant="contained" disabled={sendingDraft || !draftSubject.trim() || !draftBody.trim()} onClick={async () => {
if (!jobId) return;
setSendingDraft(true);
try {
await api.post(`/jobapplications/${jobId}/send-followup`, { toEmail: draftRecipient || null, subject: draftSubject, body: draftBody, nextFollowUpAt: followUpDraft.suggestedSendOn || null });
setJob((prev) => prev ? { ...prev, followUpAt: followUpDraft.suggestedSendOn } : prev);
readinessCache.clearCached();
setReadiness(null);
toast(t("jobDetailsFollowUpSent"), "success");
} catch (error: any) {
toast(getApiErrorMessage(error, t("jobDetailsFollowUpSendFailed")), "error");
} finally {
setSendingDraft(false);
}
}}>{sendingDraft ? t("jobDetailsSending") : t("jobDetailsSendAndLogEmail")}</Button>
<Button variant="contained" href="/correspondence">{t("jobDetailsOpenJobEmail")}</Button>
</Box>
</Box>
) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNoDraftAvailable")}</Typography>}
@@ -217,7 +217,7 @@ describe('end-to-end trust loop', () => {
expect(await screen.findByText(/follow-up context/i)).toBeInTheDocument();
expect(await screen.findByText(/saved application package material is available for reuse/i)).toBeInTheDocument();
expect(await screen.findByText(/manual send boundary/i)).toBeInTheDocument();
expect(await screen.findByText(/the only outbound step is the explicit “send and log email” action below/i)).toBeInTheDocument();
expect(await screen.findByText(/open job email.*connected provider.*final confirmation/i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: /tailored cv/i }));
@@ -243,6 +243,7 @@ describe('end-to-end trust loop', () => {
fireEvent.click(screen.getByRole('tab', { name: /follow up/i }));
expect(await screen.findByDisplayValue(/i wanted to follow up on the backend developer thread/i)).toBeInTheDocument();
expect(screen.getByRole('link', { name: /open job email/i })).toHaveAttribute('href', '/correspondence');
expect(mockedApi.post.mock.calls.some(([url]) => url === '/jobapplications/42/send-followup')).toBe(false);
});
});
+2
View File
@@ -1053,6 +1053,7 @@ export const translations = {
jobDetailsSubject: "Subject",
jobDetailsDraft: "Draft",
jobDetailsCopyDraft: "Copy draft",
jobDetailsOpenJobEmail: "Open Job email",
jobDetailsSendAndLogEmail: "Send and log email",
jobDetailsSending: "Sending...",
jobDetailsFollowUpSent: "Follow-up sent and logged.",
@@ -2183,6 +2184,7 @@ export const translations = {
jobDetailsSubject: "Emne",
jobDetailsDraft: "Utkast",
jobDetailsCopyDraft: "Kopier utkast",
jobDetailsOpenJobEmail: "Åpne Job email",
jobDetailsSendAndLogEmail: "Send og loggfør e-post",
jobDetailsSending: "Sender...",
jobDetailsFollowUpSent: "Oppfølging sendt og loggført.",
@@ -1,6 +1,6 @@
import React from 'react';
import '@testing-library/jest-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import { ConfirmProvider } from './confirm';
import { PromptProvider } from './prompt';
import { ToastProvider } from './toast';
@@ -92,7 +92,7 @@ afterEach(() => {
jest.clearAllMocks();
});
test('follow-up workspace shows thread grounding and keeps sending manual', async () => {
test('follow-up workspace keeps the draft editable and routes delivery to Job email', async () => {
renderDialog();
fireEvent.click(await screen.findByRole('tab', { name: /follow up/i }));
@@ -100,20 +100,14 @@ test('follow-up workspace shows thread grounding and keeps sending manual', asyn
expect(await screen.findByText(/follow-up context/i)).toBeInTheDocument();
expect(await screen.findByText(/saved application package material is available for reuse/i)).toBeInTheDocument();
expect(await screen.findByText(/saved cover letter available/i)).toBeInTheDocument();
expect(await screen.findByText(/manual send only/i)).toBeInTheDocument();
expect(await screen.findByText(/manual send boundary/i)).toBeInTheDocument();
expect(await screen.findByText(/open job email.*connected provider.*final confirmation/i)).toBeInTheDocument();
expect(await screen.findByDisplayValue(/i wanted to follow up on the backend developer thread/i)).toBeInTheDocument();
const draft = screen.getByDisplayValue(/i wanted to follow up on the backend developer thread/i);
fireEvent.change(draft, { target: { value: 'Hi Maria,\n\nEdited follow-up.\n\nThanks,\nCasey' } });
fireEvent.click(screen.getByRole('button', { name: /send and log email/i }));
await waitFor(() => {
expect(mockedApi.post).toHaveBeenCalledWith('/jobapplications/42/send-followup', {
toEmail: 'recruiter@acme.test',
subject: 'Re: Backend Developer application update',
body: 'Hi Maria,\n\nEdited follow-up.\n\nThanks,\nCasey',
nextFollowUpAt: expect.any(String),
});
});
expect(draft).toHaveValue('Hi Maria,\n\nEdited follow-up.\n\nThanks,\nCasey');
expect(screen.getByRole('link', { name: /open job email/i })).toHaveAttribute('href', '/correspondence');
expect(mockedApi.post).not.toHaveBeenCalledWith('/jobapplications/42/send-followup', expect.anything());
});