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(
db,
summarizer,
Mock.Of<IAppEmailSender>(),
TestHostFactory.CreateUserManager(user).Object,
NullLogger<JobApplicationsController>.Instance,
Mock.Of<ICvTemplateRenderer>(),
Mock.Of<ICvPdfExporter>());
controller.ControllerContext = new ControllerContext
@@ -114,9 +114,7 @@ public sealed class InterviewPrepPersistenceTests
var controller = new JobApplicationsController(
db,
summarizer,
Mock.Of<IAppEmailSender>(),
TestHostFactory.CreateUserManager(null).Object,
NullLogger<JobApplicationsController>.Instance,
Mock.Of<ICvTemplateRenderer>(),
Mock.Of<ICvPdfExporter>());
controller.ControllerContext = new ControllerContext
@@ -455,9 +455,7 @@ public sealed class JobApplicationsApplicationPackageTests
var controller = new JobApplicationsController(
db,
summarizer,
Mock.Of<IAppEmailSender>(),
CreateUserManager(user).Object,
NullLogger<JobApplicationsController>.Instance,
renderer ?? new TestCvTemplateRenderer(),
exporter ?? new TestCvPdfExporter());
controller.ControllerContext = new ControllerContext
@@ -115,7 +115,7 @@ public sealed class JobApplicationsAppliedDateHistoryTests
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
{
HttpContext = new DefaultHttpContext
@@ -88,7 +88,7 @@ public sealed class JobApplicationsAuthorizationTests
{
var summarizer = new Mock<ISummarizerService>();
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
{
@@ -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");
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
{
HttpContext = new DefaultHttpContext
@@ -103,9 +103,29 @@ public sealed class JobApplicationsFollowUpDraftTests
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)
{
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
{
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");
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
{
HttpContext = new DefaultHttpContext
@@ -110,7 +110,7 @@ public sealed class JobApplicationsWorkflowSignalsTests
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
{
HttpContext = new DefaultHttpContext
@@ -25,9 +25,7 @@ namespace JobTrackerApi.Controllers
{
private readonly JobTrackerContext _db;
private readonly ISummarizerService _summarizer;
private readonly IAppEmailSender _email;
private readonly UserManager<ApplicationUser> _users;
private readonly ILogger<JobApplicationsController> _logger;
private readonly ICvTemplateRenderer _cvTemplateRenderer;
private readonly ICvPdfExporter _cvPdfExporter;
private readonly AnalyticsService _analytics;
@@ -35,14 +33,12 @@ namespace JobTrackerApi.Controllers
private readonly IMemoryCache _cache;
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);
_db = db;
_summarizer = summarizer;
_email = email;
_users = users;
_logger = logger;
_cvTemplateRenderer = cvTemplateRenderer ?? new CvTemplateRenderer();
_cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter();
_analytics = analytics ?? new AnalyticsService(db);
@@ -2252,55 +2248,13 @@ Job description:
}
[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
.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
return StatusCode(StatusCodes.Status410Gone, new ProblemDetails
{
await _email.SendAsync(toEmail, request.Subject.Trim(), request.Body.Trim(), cancellationToken);
}
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,
Title = "Legacy email delivery retired",
Detail = "Use Job email to review the connected provider and explicitly confirm delivery.",
});
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")]
@@ -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());
});