From a9bb22e530caf5ff25e9591c6e9250bfd72d9b8f Mon Sep 17 00:00:00 2001 From: cesnimda Date: Mon, 10 Aug 2026 10:05:39 +0200 Subject: [PATCH] feat(email): add tenant-safe draft API Expose bounded draft CRUD with owned-job validation and revision conflicts. Saving drafts never contacts providers or sends email. --- .../EmailDraftsControllerTests.cs | 197 +++++++++++++++++ .../Controllers/EmailDraftsController.cs | 199 ++++++++++++++++++ 2 files changed, 396 insertions(+) create mode 100644 JobTrackerApi.Tests/EmailDraftsControllerTests.cs create mode 100644 JobTrackerApi/Controllers/EmailDraftsController.cs diff --git a/JobTrackerApi.Tests/EmailDraftsControllerTests.cs b/JobTrackerApi.Tests/EmailDraftsControllerTests.cs new file mode 100644 index 0000000..1124699 --- /dev/null +++ b/JobTrackerApi.Tests/EmailDraftsControllerTests.cs @@ -0,0 +1,197 @@ +using System.Reflection; +using System.Security.Claims; +using JobTrackerApi.Controllers; +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using JobTrackerApi.Services.EmailProviders; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class EmailDraftsControllerTests +{ + [Fact] + public void Draft_routes_require_local_authentication() + { + var authorization = Assert.Single(typeof(EmailDraftsController) + .GetCustomAttributes()); + Assert.Equal("local", authorization.AuthenticationSchemes); + Assert.True(string.IsNullOrWhiteSpace(authorization.Policy)); + } + + [Fact] + public async Task Create_accepts_incomplete_owned_drafts_but_rejects_invalid_or_foreign_inputs() + { + await using var fixture = await Fixture.CreateAsync(); + await using var db = fixture.Context("user-1"); + var controller = Controller(db, "user-1"); + + var createdResult = await controller.Create( + new EmailDraftsController.CreateDraftRequest(fixture.UserOneJobId, "GMAIL", "", "", "", null), default); + var created = Assert.IsType( + Assert.IsType(createdResult.Result).Value); + Assert.Equal("gmail", created.Provider); + Assert.Equal(1, created.Revision); + Assert.Empty(created.To); + + Assert.IsType((await controller.Create( + new EmailDraftsController.CreateDraftRequest(fixture.UserOneJobId, "unknown", "", "", "", null), default)).Result); + Assert.IsType((await controller.Create( + new EmailDraftsController.CreateDraftRequest(fixture.UserOneJobId, "gmail", "not-an-address", "", "", null), default)).Result); + Assert.IsType((await controller.Create( + new EmailDraftsController.CreateDraftRequest(fixture.UserTwoJobId, "gmail", "", "", "", null), default)).Result); + } + + [Fact] + public async Task Direct_ids_and_job_lists_do_not_cross_tenants() + { + await using var fixture = await Fixture.CreateAsync(seedDrafts: true); + await using var db = fixture.Context("user-1"); + var controller = Controller(db, "user-1"); + + Assert.IsType((await controller.Get(fixture.UserTwoDraftId, default)).Result); + var foreignList = Assert.IsAssignableFrom>( + Assert.IsType((await controller.List(fixture.UserTwoJobId, default)).Result).Value); + Assert.Empty(foreignList); + var ownList = Assert.IsAssignableFrom>( + Assert.IsType((await controller.List(fixture.UserOneJobId, default)).Result).Value); + Assert.Equal(fixture.UserOneDraftId, Assert.Single(ownList).Id); + } + + [Fact] + public async Task Revision_conflicts_prevent_lost_updates_and_cross_tenant_deletes() + { + await using var fixture = await Fixture.CreateAsync(seedDrafts: true); + await using (var userOneDb = fixture.Context("user-1")) + { + var userOne = Controller(userOneDb, "user-1"); + var updatedResult = await userOne.Update(fixture.UserOneDraftId, + new EmailDraftsController.UpdateDraftRequest(1, "new@example.test", "Updated", "Updated body"), default); + var updated = Assert.IsType(Assert.IsType(updatedResult.Result).Value); + Assert.Equal(2, updated.Revision); + Assert.Equal("Updated body", updated.BodyText); + + Assert.IsType((await userOne.Update(fixture.UserOneDraftId, + new EmailDraftsController.UpdateDraftRequest(1, "stale@example.test", "Stale", "Stale body"), default)).Result); + Assert.IsType(await userOne.Delete(fixture.UserOneDraftId, 1, default)); + } + + await using (var userTwoDb = fixture.Context("user-2")) + { + var userTwo = Controller(userTwoDb, "user-2"); + Assert.IsType((await userTwo.Update(fixture.UserOneDraftId, + new EmailDraftsController.UpdateDraftRequest(2, "other@example.test", "Other", "Other body"), default)).Result); + Assert.IsType(await userTwo.Delete(fixture.UserOneDraftId, 2, default)); + } + + await using (var userOneDb = fixture.Context("user-1")) + Assert.IsType(await Controller(userOneDb, "user-1").Delete(fixture.UserOneDraftId, 2, default)); + + await using var verify = fixture.Context(null); + var remaining = Assert.Single(await verify.EmailDrafts.IgnoreQueryFilters().AsNoTracking().ToListAsync()); + Assert.Equal("user-2", remaining.OwnerUserId); + } + + private static EmailDraftsController Controller(JobTrackerContext db, string userId) + { + var controller = new EmailDraftsController( + db, + new EmailProviderRegistry(new[] { new FakeProvider() }), + TimeProvider.System); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity( + new[] { new Claim(ClaimTypes.NameIdentifier, userId) }, "test")), + }, + }; + return controller; + } + + private sealed class FakeProvider : IEmailProvider + { + public string ProviderKey => "gmail"; + public Task GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken) => Task.FromResult(null); + public Task> SearchAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken) => throw new NotSupportedException(); + public Task SendAsync(string ownerUserId, EmailDeliveryRequest request, CancellationToken cancellationToken) => throw new NotSupportedException(); + } + + private sealed class Fixture : IAsyncDisposable + { + private readonly SqliteConnection _connection; + private readonly DbContextOptions _options; + + private Fixture(SqliteConnection connection, DbContextOptions options) + { + _connection = connection; + _options = options; + } + + public int UserOneJobId { get; private set; } + public int UserTwoJobId { get; private set; } + public Guid UserOneDraftId { get; private set; } + public Guid UserTwoDraftId { get; private set; } + + public static async Task CreateAsync(bool seedDrafts = false) + { + var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder().UseSqlite(connection).Options; + var fixture = new Fixture(connection, options); + await using var db = fixture.Context(null); + await db.Database.EnsureCreatedAsync(); + var companyOne = new Company { Name = "One", OwnerUserId = "user-1" }; + var companyTwo = new Company { Name = "Two", OwnerUserId = "user-2" }; + db.Companies.AddRange(companyOne, companyTwo); + await db.SaveChangesAsync(); + var jobOne = new JobApplication { JobTitle = "One", CompanyId = companyOne.Id, OwnerUserId = "user-1" }; + var jobTwo = new JobApplication { JobTitle = "Two", CompanyId = companyTwo.Id, OwnerUserId = "user-2" }; + db.JobApplications.AddRange(jobOne, jobTwo); + await db.SaveChangesAsync(); + fixture.UserOneJobId = jobOne.Id; + fixture.UserTwoJobId = jobTwo.Id; + if (seedDrafts) + { + var one = Draft("user-1", jobOne.Id, "one@example.test"); + var two = Draft("user-2", jobTwo.Id, "two@example.test"); + db.EmailDrafts.AddRange(one, two); + await db.SaveChangesAsync(); + fixture.UserOneDraftId = one.Id; + fixture.UserTwoDraftId = two.Id; + } + return fixture; + } + + public JobTrackerContext Context(string? userId) + { + var currentUser = new Mock(); + currentUser.SetupGet(service => service.UserId).Returns(userId); + return new JobTrackerContext(_options, currentUser.Object); + } + + public ValueTask DisposeAsync() => _connection.DisposeAsync(); + + private static EmailDraft Draft(string ownerUserId, int jobApplicationId, string recipient) => new() + { + Id = Guid.NewGuid(), + OwnerUserId = ownerUserId, + JobApplicationId = jobApplicationId, + Provider = "gmail", + To = recipient, + Subject = "Synthetic", + BodyText = "Synthetic private draft.", + CreatedAtUtc = DateTime.UtcNow, + UpdatedAtUtc = DateTime.UtcNow, + }; + } +} diff --git a/JobTrackerApi/Controllers/EmailDraftsController.cs b/JobTrackerApi/Controllers/EmailDraftsController.cs new file mode 100644 index 0000000..8d41df9 --- /dev/null +++ b/JobTrackerApi/Controllers/EmailDraftsController.cs @@ -0,0 +1,199 @@ +using System.Security.Claims; +using System.Net.Mail; +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services.EmailProviders; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace JobTrackerApi.Controllers; + +[ApiController] +[Route("api/email/drafts")] +[Authorize(AuthenticationSchemes = "local")] +public sealed class EmailDraftsController( + JobTrackerContext db, + IEmailProviderRegistry providers, + TimeProvider timeProvider) : ControllerBase +{ + public sealed record DraftDto( + Guid Id, + int JobApplicationId, + string Provider, + string To, + string Subject, + string BodyText, + string? ThreadId, + long Revision, + DateTime CreatedAtUtc, + DateTime UpdatedAtUtc); + + public sealed record CreateDraftRequest( + int JobApplicationId, + string? Provider, + string? To, + string? Subject, + string? BodyText, + string? ThreadId); + + public sealed record UpdateDraftRequest(long Revision, string? To, string? Subject, string? BodyText); + + [HttpGet] + public async Task>> List( + [FromQuery] int jobApplicationId, + CancellationToken cancellationToken) + { + var ownerUserId = GetOwnerUserId(); + if (ownerUserId is null) return Unauthorized(); + if (jobApplicationId <= 0) return BadRequest("A valid job application is required."); + + return Ok(await db.EmailDrafts.AsNoTracking() + .Where(draft => draft.OwnerUserId == ownerUserId && draft.JobApplicationId == jobApplicationId) + .OrderByDescending(draft => draft.UpdatedAtUtc) + .Select(draft => new DraftDto( + draft.Id, + draft.JobApplicationId, + draft.Provider, + draft.To, + draft.Subject, + draft.BodyText, + draft.ThreadId, + draft.Revision, + draft.CreatedAtUtc, + draft.UpdatedAtUtc)) + .ToListAsync(cancellationToken)); + } + + [HttpGet("{id:guid}")] + public async Task> Get(Guid id, CancellationToken cancellationToken) + { + var ownerUserId = GetOwnerUserId(); + if (ownerUserId is null) return Unauthorized(); + var draft = await db.EmailDrafts.AsNoTracking() + .FirstOrDefaultAsync(item => item.Id == id && item.OwnerUserId == ownerUserId, cancellationToken); + return draft is null ? NotFound() : Ok(ToDto(draft)); + } + + [HttpPost] + public async Task> Create(CreateDraftRequest request, CancellationToken cancellationToken) + { + var ownerUserId = GetOwnerUserId(); + if (ownerUserId is null) return Unauthorized(); + if (request.JobApplicationId <= 0) return BadRequest("A valid job application is required."); + var provider = providers.Get(request.Provider); + if (provider is null) return BadRequest("Unknown email provider."); + if (!TryNormalizeContent(request.To, request.Subject, request.BodyText, out var recipient, out var subject, out var bodyText, out var error)) + return BadRequest(error); + var threadId = string.IsNullOrWhiteSpace(request.ThreadId) ? null : request.ThreadId.Trim(); + if (threadId?.Length > 512) return BadRequest("Thread ID must be at most 512 characters."); + + var ownsJob = await db.JobApplications.AsNoTracking() + .AnyAsync(job => job.Id == request.JobApplicationId && job.OwnerUserId == ownerUserId, cancellationToken); + if (!ownsJob) return NotFound(); + + var now = timeProvider.GetUtcNow().UtcDateTime; + var draft = new EmailDraft + { + Id = Guid.NewGuid(), + OwnerUserId = ownerUserId, + JobApplicationId = request.JobApplicationId, + Provider = provider.ProviderKey.ToLowerInvariant(), + To = recipient, + Subject = subject, + BodyText = bodyText, + ThreadId = threadId, + Revision = 1, + CreatedAtUtc = now, + UpdatedAtUtc = now, + }; + db.EmailDrafts.Add(draft); + await db.SaveChangesAsync(cancellationToken); + return CreatedAtAction(nameof(Get), new { id = draft.Id }, ToDto(draft)); + } + + [HttpPut("{id:guid}")] + public async Task> Update(Guid id, UpdateDraftRequest request, CancellationToken cancellationToken) + { + var ownerUserId = GetOwnerUserId(); + if (ownerUserId is null) return Unauthorized(); + if (request.Revision <= 0) return BadRequest("A positive revision is required."); + if (!TryNormalizeContent(request.To, request.Subject, request.BodyText, out var recipient, out var subject, out var bodyText, out var error)) + return BadRequest(error); + + var now = timeProvider.GetUtcNow().UtcDateTime; + var affected = await db.EmailDrafts + .Where(draft => draft.Id == id && draft.OwnerUserId == ownerUserId && draft.Revision == request.Revision) + .ExecuteUpdateAsync(setters => setters + .SetProperty(draft => draft.To, recipient) + .SetProperty(draft => draft.Subject, subject) + .SetProperty(draft => draft.BodyText, bodyText) + .SetProperty(draft => draft.Revision, draft => draft.Revision + 1) + .SetProperty(draft => draft.UpdatedAtUtc, now), cancellationToken); + if (affected == 0) + { + var exists = await db.EmailDrafts.AsNoTracking() + .AnyAsync(draft => draft.Id == id && draft.OwnerUserId == ownerUserId, cancellationToken); + return exists + ? Conflict(new ProblemDetails { Title = "Draft revision conflict", Detail = "Reload the latest draft before saving again." }) + : NotFound(); + } + + return await Get(id, cancellationToken); + } + + [HttpDelete("{id:guid}")] + public async Task Delete(Guid id, [FromQuery] long revision, CancellationToken cancellationToken) + { + var ownerUserId = GetOwnerUserId(); + if (ownerUserId is null) return Unauthorized(); + if (revision <= 0) return BadRequest("A positive revision is required."); + + var affected = await db.EmailDrafts + .Where(draft => draft.Id == id && draft.OwnerUserId == ownerUserId && draft.Revision == revision) + .ExecuteDeleteAsync(cancellationToken); + if (affected == 1) return NoContent(); + var exists = await db.EmailDrafts.AsNoTracking() + .AnyAsync(draft => draft.Id == id && draft.OwnerUserId == ownerUserId, cancellationToken); + return exists + ? Conflict(new ProblemDetails { Title = "Draft revision conflict", Detail = "Reload the latest draft before deleting it." }) + : NotFound(); + } + + private string? GetOwnerUserId() => + User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"); + + private static bool TryNormalizeContent( + string? to, + string? subject, + string? bodyText, + out string recipient, + out string normalizedSubject, + out string normalizedBody, + out string error) + { + recipient = to?.Trim() ?? string.Empty; + normalizedSubject = subject?.Trim() ?? string.Empty; + normalizedBody = bodyText ?? string.Empty; + error = string.Empty; + if (recipient.Length > 320 || (recipient.Length > 0 && !MailAddress.TryCreate(recipient, out _))) + error = "Recipient must be empty or a valid address of at most 320 characters."; + else if (normalizedSubject.Length > 998) + error = "Subject must be at most 998 characters."; + else if (normalizedBody.Length > 200_000) + error = "Body must be at most 200000 characters."; + return error.Length == 0; + } + + private static DraftDto ToDto(EmailDraft draft) => new( + draft.Id, + draft.JobApplicationId, + draft.Provider, + draft.To, + draft.Subject, + draft.BodyText, + draft.ThreadId, + draft.Revision, + draft.CreatedAtUtc, + draft.UpdatedAtUtc); +}