From aff34cc6452abc8b0feda1ce8b4b62e801930401 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Mon, 10 Aug 2026 00:48:32 +0200 Subject: [PATCH] feat(email): export send attempt metadata Add content-free provider delivery history to encrypted and daily owner exports, exclude payload hashes, and lock in tenant-safe hard-delete cascades. --- .../BackgroundWorkerTenantTests.cs | 22 +++++++++++++- JobTrackerApi.Tests/BackupControllerTests.cs | 25 +++++++++++++++- .../EmailSendAttemptStoreTests.cs | 30 +++++++++++++++++++ JobTrackerApi/Controllers/BackupController.cs | 17 +++++++++++ JobTrackerApi/Models/EmailSendAttempt.cs | 12 ++++++++ .../Services/DailyExportHostedService.cs | 16 ++++++++++ 6 files changed, 120 insertions(+), 2 deletions(-) diff --git a/JobTrackerApi.Tests/BackgroundWorkerTenantTests.cs b/JobTrackerApi.Tests/BackgroundWorkerTenantTests.cs index c128d32..4a57c70 100644 --- a/JobTrackerApi.Tests/BackgroundWorkerTenantTests.cs +++ b/JobTrackerApi.Tests/BackgroundWorkerTenantTests.cs @@ -114,6 +114,15 @@ public sealed class BackgroundWorkerTenantTests ["Data:Root"] = root, }); await fixture.SeedJobsAsync(); + await using (var seedScope = fixture.Provider.CreateAsyncScope()) + { + var db = seedScope.ServiceProvider.GetRequiredService(); + var jobs = await db.JobApplications.IgnoreQueryFilters().OrderBy(job => job.OwnerUserId).ToListAsync(); + db.EmailSendAttempts.AddRange( + new EmailSendAttempt { Id = Guid.NewGuid(), OwnerUserId = "user-1", JobApplicationId = jobs[0].Id, Provider = "gmail", ClientRequestId = Guid.NewGuid().ToString(), PayloadHash = new string('a', 64), Status = EmailSendStatuses.Sent, CreatedAtUtc = DateTime.UtcNow }, + new EmailSendAttempt { Id = Guid.NewGuid(), OwnerUserId = "user-2", JobApplicationId = jobs[1].Id, Provider = "microsoft", ClientRequestId = Guid.NewGuid().ToString(), PayloadHash = new string('b', 64), Status = EmailSendStatuses.Failed, FailureCategory = "rejected", CreatedAtUtc = DateTime.UtcNow }); + await db.SaveChangesAsync(); + } var environment = new Mock(); environment.SetupGet(x => x.ContentRootPath).Returns(root); try @@ -129,7 +138,18 @@ public sealed class BackgroundWorkerTenantTests var files = Directory.GetFiles(Path.Combine(root, "exports"), "*.json"); Assert.Equal(2, files.Length); Assert.DoesNotContain(files, path => path.Contains("user-1", StringComparison.Ordinal) || path.Contains("user-2", StringComparison.Ordinal)); - var owners = files.Select(path => JsonDocument.Parse(System.IO.File.ReadAllText(path)).RootElement.GetProperty("OwnerUserId").GetString()).Order().ToArray(); + var owners = new List(); + foreach (var path in files) + { + using var document = JsonDocument.Parse(System.IO.File.ReadAllText(path)); + var rootElement = document.RootElement; + var owner = rootElement.GetProperty("OwnerUserId").GetString(); + owners.Add(owner); + var attempt = Assert.Single(rootElement.GetProperty("EmailSendAttempts").EnumerateArray()); + Assert.Equal(owner == "user-1" ? "gmail" : "microsoft", attempt.GetProperty("Provider").GetString()); + Assert.False(attempt.TryGetProperty("PayloadHash", out _)); + } + owners.Sort(StringComparer.Ordinal); Assert.Equal(new[] { "user-1", "user-2" }, owners); Assert.Empty(Directory.GetFiles(Path.Combine(root, "exports"), "*.tmp")); } diff --git a/JobTrackerApi.Tests/BackupControllerTests.cs b/JobTrackerApi.Tests/BackupControllerTests.cs index 39f60a9..cb73b4d 100644 --- a/JobTrackerApi.Tests/BackupControllerTests.cs +++ b/JobTrackerApi.Tests/BackupControllerTests.cs @@ -35,7 +35,22 @@ public sealed class BackupControllerTests { await using var db = CreateDb(); db.Companies.Add(new Company { Name = "Acme", OwnerUserId = "user-1" }); - db.JobApplications.Add(new JobApplication { JobTitle = "Backend Developer", OwnerUserId = "user-1" }); + var job = new JobApplication { JobTitle = "Backend Developer", OwnerUserId = "user-1" }; + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + db.EmailSendAttempts.Add(new EmailSendAttempt + { + Id = Guid.NewGuid(), + OwnerUserId = "user-1", + JobApplicationId = job.Id, + Provider = "gmail", + ClientRequestId = Guid.NewGuid().ToString(), + PayloadHash = new string('a', 64), + Status = EmailSendStatuses.Sent, + ProviderMessageId = "synthetic-message-id", + CreatedAtUtc = DateTime.UtcNow, + CompletedAtUtc = DateTime.UtcNow, + }); await db.SaveChangesAsync(); var provider = DataProtectionProvider.Create(new DirectoryInfo(Path.Combine(Path.GetTempPath(), $"jobtracker-tests-{Guid.NewGuid():N}"))); @@ -47,6 +62,14 @@ public sealed class BackupControllerTests Assert.Equal("application/octet-stream", file.ContentType); Assert.EndsWith(".jtbackup", file.FileDownloadName); Assert.NotEmpty(file.FileContents); + + var protectedText = System.Text.Encoding.UTF8.GetString(file.FileContents); + var json = Convert.FromBase64String(provider.CreateProtector("JobTrackerApi.Backup.v1").Unprotect(protectedText)); + using var document = System.Text.Json.JsonDocument.Parse(json); + var attempt = Assert.Single(document.RootElement.GetProperty("Data").GetProperty("EmailSendAttempts").EnumerateArray()); + Assert.Equal("gmail", attempt.GetProperty("Provider").GetString()); + Assert.Equal("sent", attempt.GetProperty("Status").GetString()); + Assert.False(attempt.TryGetProperty("PayloadHash", out _)); } private static JobTrackerContext CreateDb() diff --git a/JobTrackerApi.Tests/EmailSendAttemptStoreTests.cs b/JobTrackerApi.Tests/EmailSendAttemptStoreTests.cs index b75a155..725dd02 100644 --- a/JobTrackerApi.Tests/EmailSendAttemptStoreTests.cs +++ b/JobTrackerApi.Tests/EmailSendAttemptStoreTests.cs @@ -58,6 +58,22 @@ public sealed class EmailSendAttemptStoreTests property.Name.Contains("Recipient", StringComparison.OrdinalIgnoreCase)); } + [Fact] + public async Task Hard_job_deletion_cascades_only_that_jobs_attempts() + { + await using var fixture = await Fixture.CreateAsync(); + var ownerOne = fixture.Store("user-1"); + var ownerTwo = fixture.Store("user-2"); + var mine = await ownerOne.CreateAsync(new(fixture.JobId, "gmail", Guid.NewGuid().ToString(), Hash('3')), default); + var theirs = await ownerTwo.CreateAsync(new(fixture.OtherJobId, "microsoft", Guid.NewGuid().ToString(), Hash('4')), default); + + await fixture.HardDeleteJobAsync("user-1", fixture.JobId); + + Assert.Null(await ownerOne.GetAsync(mine.Attempt.Id, default)); + Assert.NotNull(await ownerTwo.GetAsync(theirs.Attempt.Id, default)); + Assert.Equal(1, await fixture.AttemptCountIgnoringFiltersAsync()); + } + [Fact] public async Task Restart_recovery_is_tenant_visible_idempotent_and_never_requeues_delivery() { @@ -139,6 +155,20 @@ public sealed class EmailSendAttemptStoreTests return new UserNotificationStore(db, Time); } + public async Task HardDeleteJobAsync(string userId, int jobId) + { + await using var db = CreateDb(options, userId); + var job = await db.JobApplications.SingleAsync(item => item.Id == jobId); + db.JobApplications.Remove(job); + await db.SaveChangesAsync(); + } + + public async Task AttemptCountIgnoringFiltersAsync() + { + await using var db = CreateDb(options, null); + return await db.EmailSendAttempts.IgnoreQueryFilters().CountAsync(); + } + private static JobTrackerContext CreateDb(DbContextOptions options, string? userId) { var currentUser = new Mock(); diff --git a/JobTrackerApi/Controllers/BackupController.cs b/JobTrackerApi/Controllers/BackupController.cs index d1d820e..7dff19b 100644 --- a/JobTrackerApi/Controllers/BackupController.cs +++ b/JobTrackerApi/Controllers/BackupController.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using JobTrackerApi.Data; +using JobTrackerApi.Models; namespace JobTrackerApi.Controllers { @@ -73,6 +74,21 @@ namespace JobTrackerApi.Controllers .Where(e => jobIds.Contains(e.JobApplicationId)) .OrderBy(e => e.At) .ToListAsync(cancellationToken); + var emailSendAttempts = await _db.EmailSendAttempts.AsNoTracking() + .Where(attempt => jobIds.Contains(attempt.JobApplicationId)) + .OrderBy(attempt => attempt.CreatedAtUtc) + .Select(attempt => new EmailSendAttemptExport( + attempt.Id, + attempt.JobApplicationId, + attempt.Provider, + attempt.ClientRequestId, + attempt.Status, + attempt.ProviderMessageId, + attempt.FailureCategory, + attempt.CreatedAtUtc, + attempt.StartedAtUtc, + attempt.CompletedAtUtc)) + .ToListAsync(cancellationToken); var rules = await _db.RuleSettings.AsNoTracking().FirstOrDefaultAsync(cancellationToken); return new @@ -82,6 +98,7 @@ namespace JobTrackerApi.Controllers Correspondence = correspondence, Attachments = attachments, Events = events, + EmailSendAttempts = emailSendAttempts, Rules = rules }; } diff --git a/JobTrackerApi/Models/EmailSendAttempt.cs b/JobTrackerApi/Models/EmailSendAttempt.cs index 623537b..699142b 100644 --- a/JobTrackerApi/Models/EmailSendAttempt.cs +++ b/JobTrackerApi/Models/EmailSendAttempt.cs @@ -25,3 +25,15 @@ public sealed class EmailSendAttempt public DateTime? StartedAtUtc { get; set; } public DateTime? CompletedAtUtc { get; set; } } + +public sealed record EmailSendAttemptExport( + Guid Id, + int JobApplicationId, + string Provider, + string ClientRequestId, + string Status, + string? ProviderMessageId, + string? FailureCategory, + DateTime CreatedAtUtc, + DateTime? StartedAtUtc, + DateTime? CompletedAtUtc); diff --git a/JobTrackerApi/Services/DailyExportHostedService.cs b/JobTrackerApi/Services/DailyExportHostedService.cs index b9c0a15..662c90c 100644 --- a/JobTrackerApi/Services/DailyExportHostedService.cs +++ b/JobTrackerApi/Services/DailyExportHostedService.cs @@ -2,6 +2,7 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; using JobTrackerApi.Data; +using JobTrackerApi.Models; using Microsoft.EntityFrameworkCore; namespace JobTrackerApi.Services; @@ -61,6 +62,21 @@ public sealed class DailyExportHostedService( Correspondence = await db.Correspondences.AsNoTracking().Where(message => jobIds.Contains(message.JobApplicationId)).OrderBy(message => message.Date).ToListAsync(cancellationToken), Attachments = await db.Attachments.AsNoTracking().Where(attachment => jobIds.Contains(attachment.JobApplicationId)).OrderBy(attachment => attachment.UploadDate).ToListAsync(cancellationToken), Events = await db.JobEvents.AsNoTracking().Where(jobEvent => jobIds.Contains(jobEvent.JobApplicationId)).OrderBy(jobEvent => jobEvent.At).ToListAsync(cancellationToken), + EmailSendAttempts = await db.EmailSendAttempts.AsNoTracking() + .Where(attempt => jobIds.Contains(attempt.JobApplicationId)) + .OrderBy(attempt => attempt.CreatedAtUtc) + .Select(attempt => new EmailSendAttemptExport( + attempt.Id, + attempt.JobApplicationId, + attempt.Provider, + attempt.ClientRequestId, + attempt.Status, + attempt.ProviderMessageId, + attempt.FailureCategory, + attempt.CreatedAtUtc, + attempt.StartedAtUtc, + attempt.CompletedAtUtc)) + .ToListAsync(cancellationToken), Rules = await RulesEngine.GetSettings(db, cancellationToken), };