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
@@ -125,9 +125,7 @@ public sealed class AiWorkspaceNotePersistenceTests
var controller = new JobApplicationsController( var controller = new JobApplicationsController(
db, db,
summarizer, summarizer,
Mock.Of<IAppEmailSender>(),
TestHostFactory.CreateUserManager(user).Object, TestHostFactory.CreateUserManager(user).Object,
NullLogger<JobApplicationsController>.Instance,
Mock.Of<ICvTemplateRenderer>(), Mock.Of<ICvTemplateRenderer>(),
Mock.Of<ICvPdfExporter>()); Mock.Of<ICvPdfExporter>());
controller.ControllerContext = new ControllerContext controller.ControllerContext = new ControllerContext
@@ -114,9 +114,7 @@ public sealed class InterviewPrepPersistenceTests
var controller = new JobApplicationsController( var controller = new JobApplicationsController(
db, db,
summarizer, summarizer,
Mock.Of<IAppEmailSender>(),
TestHostFactory.CreateUserManager(null).Object, TestHostFactory.CreateUserManager(null).Object,
NullLogger<JobApplicationsController>.Instance,
Mock.Of<ICvTemplateRenderer>(), Mock.Of<ICvTemplateRenderer>(),
Mock.Of<ICvPdfExporter>()); Mock.Of<ICvPdfExporter>());
controller.ControllerContext = new ControllerContext controller.ControllerContext = new ControllerContext
@@ -455,9 +455,7 @@ public sealed class JobApplicationsApplicationPackageTests
var controller = new JobApplicationsController( var controller = new JobApplicationsController(
db, db,
summarizer, summarizer,
Mock.Of<IAppEmailSender>(),
CreateUserManager(user).Object, CreateUserManager(user).Object,
NullLogger<JobApplicationsController>.Instance,
renderer ?? new TestCvTemplateRenderer(), renderer ?? new TestCvTemplateRenderer(),
exporter ?? new TestCvPdfExporter()); exporter ?? new TestCvPdfExporter());
controller.ControllerContext = new ControllerContext controller.ControllerContext = new ControllerContext
@@ -115,7 +115,7 @@ public sealed class JobApplicationsAppliedDateHistoryTests
private static JobApplicationsController CreateController(JobTrackerContext db, string userId) private static JobApplicationsController CreateController(JobTrackerContext db, string userId)
{ {
var controller = new JobApplicationsController(db, Mock.Of<ISummarizerService>(), Mock.Of<IAppEmailSender>(), TestHostFactory.CreateUserManager().Object, NullLogger<JobApplicationsController>.Instance); var controller = new JobApplicationsController(db, Mock.Of<ISummarizerService>(), TestHostFactory.CreateUserManager().Object);
controller.ControllerContext = new ControllerContext controller.ControllerContext = new ControllerContext
{ {
HttpContext = new DefaultHttpContext HttpContext = new DefaultHttpContext
@@ -88,7 +88,7 @@ public sealed class JobApplicationsAuthorizationTests
{ {
var summarizer = new Mock<ISummarizerService>(); var summarizer = new Mock<ISummarizerService>();
var users = TestHostFactory.CreateUserManager(); var users = TestHostFactory.CreateUserManager();
return new JobApplicationsController(db, summarizer.Object, Mock.Of<IAppEmailSender>(), users.Object, NullLogger<JobApplicationsController>.Instance) return new JobApplicationsController(db, summarizer.Object, users.Object)
{ {
ControllerContext = new ControllerContext ControllerContext = new ControllerContext
{ {
@@ -295,7 +295,7 @@ public sealed class JobApplicationsEndpointBehaviorTests
summarizer.Setup(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>())).ReturnsAsync("generated text"); summarizer.Setup(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>())).ReturnsAsync("generated text");
var users = CreateUserManager(); var users = CreateUserManager();
var controller = new JobApplicationsController(db, summarizer.Object, Mock.Of<IAppEmailSender>(), users.Object, NullLogger<JobApplicationsController>.Instance); var controller = new JobApplicationsController(db, summarizer.Object, users.Object);
controller.ControllerContext = new ControllerContext controller.ControllerContext = new ControllerContext
{ {
HttpContext = new DefaultHttpContext HttpContext = new DefaultHttpContext
@@ -103,9 +103,29 @@ public sealed class JobApplicationsFollowUpDraftTests
Assert.Contains("Maria Recruiter", payload.LastCorrespondenceFrom ?? string.Empty); Assert.Contains("Maria Recruiter", payload.LastCorrespondenceFrom ?? string.Empty);
} }
[Fact]
public void Legacy_follow_up_delivery_is_gone_and_has_no_smtp_dependency()
{
using var db = CreateDb();
var controller = CreateController(db, Mock.Of<ISummarizerService>(), "user-1");
var result = controller.SendFollowUp(42, new SendFollowUpRequest(
"recruiter@example.test",
"Synthetic subject",
"Synthetic body",
DateTime.UtcNow.AddDays(3)), default);
var gone = Assert.IsType<ObjectResult>(result);
Assert.Equal(StatusCodes.Status410Gone, gone.StatusCode);
Assert.Equal("Legacy email delivery retired", Assert.IsType<ProblemDetails>(gone.Value).Title);
Assert.Empty(db.Correspondences);
Assert.DoesNotContain(typeof(JobApplicationsController).GetConstructors().Single().GetParameters(),
parameter => parameter.ParameterType == typeof(IAppEmailSender));
}
private static JobApplicationsController CreateController(JobTrackerContext db, ISummarizerService summarizer, string userId) private static JobApplicationsController CreateController(JobTrackerContext db, ISummarizerService summarizer, string userId)
{ {
var controller = new JobApplicationsController(db, summarizer, Mock.Of<IAppEmailSender>(), CreateUserManager().Object, NullLogger<JobApplicationsController>.Instance); var controller = new JobApplicationsController(db, summarizer, CreateUserManager().Object);
controller.ControllerContext = new ControllerContext controller.ControllerContext = new ControllerContext
{ {
HttpContext = new DefaultHttpContext HttpContext = new DefaultHttpContext
@@ -41,7 +41,7 @@ public sealed class JobApplicationsMariaDraftTests
summarizer.Setup(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>())).ReturnsAsync("generated text"); summarizer.Setup(x => x.SummarizeSectionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<int>())).ReturnsAsync("generated text");
var users = CreateUserManager(); var users = CreateUserManager();
var controller = new JobApplicationsController(db, summarizer.Object, Mock.Of<IAppEmailSender>(), users.Object, NullLogger<JobApplicationsController>.Instance); var controller = new JobApplicationsController(db, summarizer.Object, users.Object);
controller.ControllerContext = new ControllerContext controller.ControllerContext = new ControllerContext
{ {
HttpContext = new DefaultHttpContext HttpContext = new DefaultHttpContext
@@ -110,7 +110,7 @@ public sealed class JobApplicationsWorkflowSignalsTests
private static JobApplicationsController CreateController(JobTrackerContext db, string userId) private static JobApplicationsController CreateController(JobTrackerContext db, string userId)
{ {
var controller = new JobApplicationsController(db, Mock.Of<ISummarizerService>(), Mock.Of<IAppEmailSender>(), CreateUserManager().Object, NullLogger<JobApplicationsController>.Instance); var controller = new JobApplicationsController(db, Mock.Of<ISummarizerService>(), CreateUserManager().Object);
controller.ControllerContext = new ControllerContext controller.ControllerContext = new ControllerContext
{ {
HttpContext = new DefaultHttpContext HttpContext = new DefaultHttpContext
@@ -25,9 +25,7 @@ namespace JobTrackerApi.Controllers
{ {
private readonly JobTrackerContext _db; private readonly JobTrackerContext _db;
private readonly ISummarizerService _summarizer; private readonly ISummarizerService _summarizer;
private readonly IAppEmailSender _email;
private readonly UserManager<ApplicationUser> _users; private readonly UserManager<ApplicationUser> _users;
private readonly ILogger<JobApplicationsController> _logger;
private readonly ICvTemplateRenderer _cvTemplateRenderer; private readonly ICvTemplateRenderer _cvTemplateRenderer;
private readonly ICvPdfExporter _cvPdfExporter; private readonly ICvPdfExporter _cvPdfExporter;
private readonly AnalyticsService _analytics; private readonly AnalyticsService _analytics;
@@ -35,14 +33,12 @@ namespace JobTrackerApi.Controllers
private readonly IMemoryCache _cache; private readonly IMemoryCache _cache;
private readonly IApplicationChecklistService _checklist; private readonly IApplicationChecklistService _checklist;
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager<ApplicationUser> users, ILogger<JobApplicationsController> logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, AnalyticsService? analytics = null, IJobCvMatchService? matchService = null, IMemoryCache? cache = null, IApplicationChecklistService? checklist = null) public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, UserManager<ApplicationUser> users, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, AnalyticsService? analytics = null, IJobCvMatchService? matchService = null, IMemoryCache? cache = null, IApplicationChecklistService? checklist = null)
{ {
_checklist = checklist ?? new ApplicationChecklistService(db); _checklist = checklist ?? new ApplicationChecklistService(db);
_db = db; _db = db;
_summarizer = summarizer; _summarizer = summarizer;
_email = email;
_users = users; _users = users;
_logger = logger;
_cvTemplateRenderer = cvTemplateRenderer ?? new CvTemplateRenderer(); _cvTemplateRenderer = cvTemplateRenderer ?? new CvTemplateRenderer();
_cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter(); _cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter();
_analytics = analytics ?? new AnalyticsService(db); _analytics = analytics ?? new AnalyticsService(db);
@@ -2252,55 +2248,13 @@ Job description:
} }
[HttpPost("{id:int}/send-followup")] [HttpPost("{id:int}/send-followup")]
public async Task<IActionResult> SendFollowUp([FromRoute] int id, [FromBody] SendFollowUpRequest request, CancellationToken cancellationToken) public IActionResult SendFollowUp([FromRoute] int id, [FromBody] SendFollowUpRequest request, CancellationToken cancellationToken)
{ {
var job = await _db.JobApplications return StatusCode(StatusCodes.Status410Gone, new ProblemDetails
.Include(j => j.Company)
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
if (string.IsNullOrWhiteSpace(request.Subject)) return BadRequest("Subject is required.");
if (string.IsNullOrWhiteSpace(request.Body)) return BadRequest("Body is required.");
var toEmail = (request.ToEmail ?? job.Company?.RecruiterEmail ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(toEmail)) return BadRequest("Recipient email is required.");
try
{ {
await _email.SendAsync(toEmail, request.Subject.Trim(), request.Body.Trim(), cancellationToken); Title = "Legacy email delivery retired",
} Detail = "Use Job email to review the connected provider and explicitly confirm delivery.",
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send follow-up email for job {JobId} to {Email}", id, toEmail);
return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Email delivery unavailable", detail: "Follow-up email could not be sent right now. Please try again later.");
}
_db.Correspondences.Add(new Correspondence
{
JobApplicationId = id,
From = "Me",
Subject = request.Subject.Trim(),
Channel = "Email",
Content = request.Body.Trim(),
Date = DateTime.Now,
}); });
if (job.Company is not null)
{
job.Company.LastContactedAt = DateTime.Now;
if (request.NextFollowUpAt is not null)
{
job.Company.NextContactAt = request.NextFollowUpAt.Value;
}
}
if (request.NextFollowUpAt is not null)
{
job.FollowUpAt = request.NextFollowUpAt.Value;
}
await _db.SaveChangesAsync(cancellationToken);
return NoContent();
} }
[HttpGet("ai-metrics")] [HttpGet("ai-metrics")]
@@ -178,7 +178,6 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
const [followUpDraft, setFollowUpDraft] = useState<FollowUpDraft | null>(null); const [followUpDraft, setFollowUpDraft] = useState<FollowUpDraft | null>(null);
const [loadingDraft, setLoadingDraft] = useState(false); const [loadingDraft, setLoadingDraft] = useState(false);
const [sendingDraft, setSendingDraft] = useState(false);
const [refreshingAi, setRefreshingAi] = useState(false); const [refreshingAi, setRefreshingAi] = useState(false);
const [candidateFit, setCandidateFit] = useState<CandidateFit | null>(null); const [candidateFit, setCandidateFit] = useState<CandidateFit | null>(null);
const [matchScore, setMatchScore] = useState<MatchScore | 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" }}> <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="overline">Manual send boundary</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}> <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> </Typography>
</Box> </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("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" }}> <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="outlined" onClick={() => navigator.clipboard.writeText(`${draftSubject}\n\n${draftBody}`)}>{t("jobDetailsCopyDraft")}</Button>
<Button variant="contained" disabled={sendingDraft || !draftSubject.trim() || !draftBody.trim()} onClick={async () => { <Button variant="contained" href="/correspondence">{t("jobDetailsOpenJobEmail")}</Button>
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>
</Box> </Box>
</Box> </Box>
) : <Typography sx={{ color: "text.secondary" }}>{t("jobDetailsNoDraftAvailable")}</Typography>} ) : <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(/follow-up context/i)).toBeInTheDocument();
expect(await screen.findByText(/saved application package material is available for reuse/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(/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 })); 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 })); 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(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); 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", jobDetailsSubject: "Subject",
jobDetailsDraft: "Draft", jobDetailsDraft: "Draft",
jobDetailsCopyDraft: "Copy draft", jobDetailsCopyDraft: "Copy draft",
jobDetailsOpenJobEmail: "Open Job email",
jobDetailsSendAndLogEmail: "Send and log email", jobDetailsSendAndLogEmail: "Send and log email",
jobDetailsSending: "Sending...", jobDetailsSending: "Sending...",
jobDetailsFollowUpSent: "Follow-up sent and logged.", jobDetailsFollowUpSent: "Follow-up sent and logged.",
@@ -2183,6 +2184,7 @@ export const translations = {
jobDetailsSubject: "Emne", jobDetailsSubject: "Emne",
jobDetailsDraft: "Utkast", jobDetailsDraft: "Utkast",
jobDetailsCopyDraft: "Kopier utkast", jobDetailsCopyDraft: "Kopier utkast",
jobDetailsOpenJobEmail: "Åpne Job email",
jobDetailsSendAndLogEmail: "Send og loggfør e-post", jobDetailsSendAndLogEmail: "Send og loggfør e-post",
jobDetailsSending: "Sender...", jobDetailsSending: "Sender...",
jobDetailsFollowUpSent: "Oppfølging sendt og loggført.", jobDetailsFollowUpSent: "Oppfølging sendt og loggført.",
@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import '@testing-library/jest-dom'; 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 { ConfirmProvider } from './confirm';
import { PromptProvider } from './prompt'; import { PromptProvider } from './prompt';
import { ToastProvider } from './toast'; import { ToastProvider } from './toast';
@@ -92,7 +92,7 @@ afterEach(() => {
jest.clearAllMocks(); 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(); renderDialog();
fireEvent.click(await screen.findByRole('tab', { name: /follow up/i })); 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(/follow-up context/i)).toBeInTheDocument();
expect(await screen.findByText(/saved application package material is available for reuse/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(/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(); 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); 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.change(draft, { target: { value: 'Hi Maria,\n\nEdited follow-up.\n\nThanks,\nCasey' } });
fireEvent.click(screen.getByRole('button', { name: /send and log email/i })); expect(draft).toHaveValue('Hi Maria,\n\nEdited follow-up.\n\nThanks,\nCasey');
expect(screen.getByRole('link', { name: /open job email/i })).toHaveAttribute('href', '/correspondence');
await waitFor(() => { expect(mockedApi.post).not.toHaveBeenCalledWith('/jobapplications/42/send-followup', expect.anything());
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),
});
});
}); });