diff --git a/JobTrackerApi.Tests/AiWorkspaceNotePersistenceTests.cs b/JobTrackerApi.Tests/AiWorkspaceNotePersistenceTests.cs index b94f83e..ec3c432 100644 --- a/JobTrackerApi.Tests/AiWorkspaceNotePersistenceTests.cs +++ b/JobTrackerApi.Tests/AiWorkspaceNotePersistenceTests.cs @@ -125,9 +125,7 @@ public sealed class AiWorkspaceNotePersistenceTests var controller = new JobApplicationsController( db, summarizer, - Mock.Of(), TestHostFactory.CreateUserManager(user).Object, - NullLogger.Instance, Mock.Of(), Mock.Of()); controller.ControllerContext = new ControllerContext diff --git a/JobTrackerApi.Tests/InterviewPrepPersistenceTests.cs b/JobTrackerApi.Tests/InterviewPrepPersistenceTests.cs index 8c33bee..6aac232 100644 --- a/JobTrackerApi.Tests/InterviewPrepPersistenceTests.cs +++ b/JobTrackerApi.Tests/InterviewPrepPersistenceTests.cs @@ -114,9 +114,7 @@ public sealed class InterviewPrepPersistenceTests var controller = new JobApplicationsController( db, summarizer, - Mock.Of(), TestHostFactory.CreateUserManager(null).Object, - NullLogger.Instance, Mock.Of(), Mock.Of()); controller.ControllerContext = new ControllerContext diff --git a/JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs b/JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs index da5ec53..a07f02e 100644 --- a/JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsApplicationPackageTests.cs @@ -455,9 +455,7 @@ public sealed class JobApplicationsApplicationPackageTests var controller = new JobApplicationsController( db, summarizer, - Mock.Of(), CreateUserManager(user).Object, - NullLogger.Instance, renderer ?? new TestCvTemplateRenderer(), exporter ?? new TestCvPdfExporter()); controller.ControllerContext = new ControllerContext diff --git a/JobTrackerApi.Tests/JobApplicationsAppliedDateHistoryTests.cs b/JobTrackerApi.Tests/JobApplicationsAppliedDateHistoryTests.cs index 32d431e..14c5d67 100644 --- a/JobTrackerApi.Tests/JobApplicationsAppliedDateHistoryTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsAppliedDateHistoryTests.cs @@ -115,7 +115,7 @@ public sealed class JobApplicationsAppliedDateHistoryTests private static JobApplicationsController CreateController(JobTrackerContext db, string userId) { - var controller = new JobApplicationsController(db, Mock.Of(), Mock.Of(), TestHostFactory.CreateUserManager().Object, NullLogger.Instance); + var controller = new JobApplicationsController(db, Mock.Of(), TestHostFactory.CreateUserManager().Object); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext diff --git a/JobTrackerApi.Tests/JobApplicationsAuthorizationTests.cs b/JobTrackerApi.Tests/JobApplicationsAuthorizationTests.cs index 61ef49d..5d2c21c 100644 --- a/JobTrackerApi.Tests/JobApplicationsAuthorizationTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsAuthorizationTests.cs @@ -88,7 +88,7 @@ public sealed class JobApplicationsAuthorizationTests { var summarizer = new Mock(); var users = TestHostFactory.CreateUserManager(); - return new JobApplicationsController(db, summarizer.Object, Mock.Of(), users.Object, NullLogger.Instance) + return new JobApplicationsController(db, summarizer.Object, users.Object) { ControllerContext = new ControllerContext { diff --git a/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs b/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs index 0d00e5d..bcf3d37 100644 --- a/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs @@ -295,7 +295,7 @@ public sealed class JobApplicationsEndpointBehaviorTests summarizer.Setup(x => x.SummarizeSectionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync("generated text"); var users = CreateUserManager(); - var controller = new JobApplicationsController(db, summarizer.Object, Mock.Of(), users.Object, NullLogger.Instance); + var controller = new JobApplicationsController(db, summarizer.Object, users.Object); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext diff --git a/JobTrackerApi.Tests/JobApplicationsFollowUpDraftTests.cs b/JobTrackerApi.Tests/JobApplicationsFollowUpDraftTests.cs index 749e179..145b38f 100644 --- a/JobTrackerApi.Tests/JobApplicationsFollowUpDraftTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsFollowUpDraftTests.cs @@ -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(), "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(result); + Assert.Equal(StatusCodes.Status410Gone, gone.StatusCode); + Assert.Equal("Legacy email delivery retired", Assert.IsType(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(), CreateUserManager().Object, NullLogger.Instance); + var controller = new JobApplicationsController(db, summarizer, CreateUserManager().Object); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext diff --git a/JobTrackerApi.Tests/JobApplicationsMariaDraftTests.cs b/JobTrackerApi.Tests/JobApplicationsMariaDraftTests.cs index db01cb9..e1651b6 100644 --- a/JobTrackerApi.Tests/JobApplicationsMariaDraftTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsMariaDraftTests.cs @@ -41,7 +41,7 @@ public sealed class JobApplicationsMariaDraftTests summarizer.Setup(x => x.SummarizeSectionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync("generated text"); var users = CreateUserManager(); - var controller = new JobApplicationsController(db, summarizer.Object, Mock.Of(), users.Object, NullLogger.Instance); + var controller = new JobApplicationsController(db, summarizer.Object, users.Object); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext diff --git a/JobTrackerApi.Tests/JobApplicationsWorkflowSignalsTests.cs b/JobTrackerApi.Tests/JobApplicationsWorkflowSignalsTests.cs index 17891ea..6a21977 100644 --- a/JobTrackerApi.Tests/JobApplicationsWorkflowSignalsTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsWorkflowSignalsTests.cs @@ -110,7 +110,7 @@ public sealed class JobApplicationsWorkflowSignalsTests private static JobApplicationsController CreateController(JobTrackerContext db, string userId) { - var controller = new JobApplicationsController(db, Mock.Of(), Mock.Of(), CreateUserManager().Object, NullLogger.Instance); + var controller = new JobApplicationsController(db, Mock.Of(), CreateUserManager().Object); controller.ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index 12b4fda..c3d6145 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -25,9 +25,7 @@ namespace JobTrackerApi.Controllers { private readonly JobTrackerContext _db; private readonly ISummarizerService _summarizer; - private readonly IAppEmailSender _email; private readonly UserManager _users; - private readonly ILogger _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 users, ILogger 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 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 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")] diff --git a/job-tracker-ui/src/components/JobDetailsDialog.tsx b/job-tracker-ui/src/components/JobDetailsDialog.tsx index e4cac4f..263b190 100644 --- a/job-tracker-ui/src/components/JobDetailsDialog.tsx +++ b/job-tracker-ui/src/components/JobDetailsDialog.tsx @@ -178,7 +178,6 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, const [followUpDraft, setFollowUpDraft] = useState(null); const [loadingDraft, setLoadingDraft] = useState(false); - const [sendingDraft, setSendingDraft] = useState(false); const [refreshingAi, setRefreshingAi] = useState(false); const [candidateFit, setCandidateFit] = useState(null); const [matchScore, setMatchScore] = useState(null); @@ -1265,29 +1264,15 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0, Manual send boundary - 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. - setDraftRecipient(e.target.value)} helperText={`${t("jobDetailsRecipientHelp")} Manual send only — nothing is dispatched until you press send.`} /> + setDraftRecipient(e.target.value)} helperText={`${t("jobDetailsRecipientHelp")} This draft is not sent from the Follow up tab.`} /> setDraftSubject(e.target.value)} /> - setDraftBody(e.target.value)} helperText="You can edit this before sending. Sending stays manual and logs the sent note back to correspondence." /> + setDraftBody(e.target.value)} helperText="You can edit and copy this draft. Provider delivery is available only from Job email." /> - + ) : {t("jobDetailsNoDraftAvailable")}} diff --git a/job-tracker-ui/src/end-to-end-trust-loop.test.tsx b/job-tracker-ui/src/end-to-end-trust-loop.test.tsx index d2eda85..884f7d6 100644 --- a/job-tracker-ui/src/end-to-end-trust-loop.test.tsx +++ b/job-tracker-ui/src/end-to-end-trust-loop.test.tsx @@ -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); }); }); diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 080fdfc..f391a1d 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -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.", diff --git a/job-tracker-ui/src/job-details-followup-drafts.test.tsx b/job-tracker-ui/src/job-details-followup-drafts.test.tsx index 0639ca1..186382c 100644 --- a/job-tracker-ui/src/job-details-followup-drafts.test.tsx +++ b/job-tracker-ui/src/job-details-followup-drafts.test.tsx @@ -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()); });