From 2fa4e38ba9c53d2fd3ed0b1c0fb5a538cbce409b Mon Sep 17 00:00:00 2001 From: cesnimda Date: Mon, 10 Aug 2026 10:00:34 +0200 Subject: [PATCH] feat(email): include drafts in user exports Export readable private draft content only through existing owner-filtered encrypted and daily export boundaries, with cross-tenant regression coverage. --- .../BackgroundWorkerTenantTests.cs | 21 +++++++++++ JobTrackerApi.Tests/BackupControllerTests.cs | 35 ++++++++++++++++++- JobTrackerApi/Controllers/BackupController.cs | 16 +++++++++ JobTrackerApi/Models/EmailDraft.cs | 12 +++++++ .../Services/DailyExportHostedService.cs | 15 ++++++++ 5 files changed, 98 insertions(+), 1 deletion(-) diff --git a/JobTrackerApi.Tests/BackgroundWorkerTenantTests.cs b/JobTrackerApi.Tests/BackgroundWorkerTenantTests.cs index 4a57c70..8536514 100644 --- a/JobTrackerApi.Tests/BackgroundWorkerTenantTests.cs +++ b/JobTrackerApi.Tests/BackgroundWorkerTenantTests.cs @@ -121,6 +121,9 @@ public sealed class BackgroundWorkerTenantTests 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 }); + db.EmailDrafts.AddRange( + Draft("user-1", jobs[0].Id, "one@example.test", "Private draft one"), + Draft("user-2", jobs[1].Id, "two@example.test", "Private draft two")); await db.SaveChangesAsync(); } var environment = new Mock(); @@ -148,6 +151,10 @@ public sealed class BackgroundWorkerTenantTests 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 _)); + var draft = Assert.Single(rootElement.GetProperty("EmailDrafts").EnumerateArray()); + Assert.Equal(owner == "user-1" ? "one@example.test" : "two@example.test", draft.GetProperty("To").GetString()); + Assert.Equal(owner == "user-1" ? "Private draft one" : "Private draft two", draft.GetProperty("BodyText").GetString()); + Assert.Equal(1, draft.GetProperty("Revision").GetInt64()); } owners.Sort(StringComparer.Ordinal); Assert.Equal(new[] { "user-1", "user-2" }, owners); @@ -159,6 +166,20 @@ public sealed class BackgroundWorkerTenantTests } } + private static EmailDraft Draft(string ownerUserId, int jobApplicationId, string recipient, string body) => new() + { + Id = Guid.NewGuid(), + OwnerUserId = ownerUserId, + JobApplicationId = jobApplicationId, + Provider = ownerUserId == "user-1" ? "gmail" : "microsoft", + To = recipient, + Subject = "Synthetic export subject", + BodyText = body, + ThreadId = "synthetic-thread", + CreatedAtUtc = DateTime.UtcNow.AddMinutes(-5), + UpdatedAtUtc = DateTime.UtcNow, + }; + [Fact] public async Task Enrichment_processes_both_owners_only_through_fake_ai() { diff --git a/JobTrackerApi.Tests/BackupControllerTests.cs b/JobTrackerApi.Tests/BackupControllerTests.cs index cb73b4d..684f797 100644 --- a/JobTrackerApi.Tests/BackupControllerTests.cs +++ b/JobTrackerApi.Tests/BackupControllerTests.cs @@ -36,7 +36,8 @@ public sealed class BackupControllerTests await using var db = CreateDb(); db.Companies.Add(new Company { Name = "Acme", OwnerUserId = "user-1" }); var job = new JobApplication { JobTitle = "Backend Developer", OwnerUserId = "user-1" }; - db.JobApplications.Add(job); + var otherJob = new JobApplication { JobTitle = "Other tenant role", OwnerUserId = "user-2" }; + db.JobApplications.AddRange(job, otherJob); await db.SaveChangesAsync(); db.EmailSendAttempts.Add(new EmailSendAttempt { @@ -51,6 +52,32 @@ public sealed class BackupControllerTests CreatedAtUtc = DateTime.UtcNow, CompletedAtUtc = DateTime.UtcNow, }); + db.EmailDrafts.Add(new EmailDraft + { + Id = Guid.NewGuid(), + OwnerUserId = "user-1", + JobApplicationId = job.Id, + Provider = "gmail", + To = "recipient@example.test", + Subject = "Synthetic export subject", + BodyText = "Synthetic readable draft body.", + ThreadId = "synthetic-thread", + Revision = 3, + CreatedAtUtc = DateTime.UtcNow.AddMinutes(-5), + UpdatedAtUtc = DateTime.UtcNow, + }); + db.EmailDrafts.Add(new EmailDraft + { + Id = Guid.NewGuid(), + OwnerUserId = "user-2", + JobApplicationId = otherJob.Id, + Provider = "microsoft", + To = "other@example.test", + Subject = "Other tenant subject", + BodyText = "Other tenant private body.", + CreatedAtUtc = DateTime.UtcNow, + UpdatedAtUtc = DateTime.UtcNow, + }); await db.SaveChangesAsync(); var provider = DataProtectionProvider.Create(new DirectoryInfo(Path.Combine(Path.GetTempPath(), $"jobtracker-tests-{Guid.NewGuid():N}"))); @@ -70,6 +97,12 @@ public sealed class BackupControllerTests Assert.Equal("gmail", attempt.GetProperty("Provider").GetString()); Assert.Equal("sent", attempt.GetProperty("Status").GetString()); Assert.False(attempt.TryGetProperty("PayloadHash", out _)); + var draft = Assert.Single(document.RootElement.GetProperty("Data").GetProperty("EmailDrafts").EnumerateArray()); + Assert.Equal("recipient@example.test", draft.GetProperty("To").GetString()); + Assert.Equal("Synthetic export subject", draft.GetProperty("Subject").GetString()); + Assert.Equal("Synthetic readable draft body.", draft.GetProperty("BodyText").GetString()); + Assert.Equal("synthetic-thread", draft.GetProperty("ThreadId").GetString()); + Assert.Equal(3, draft.GetProperty("Revision").GetInt64()); } private static JobTrackerContext CreateDb() diff --git a/JobTrackerApi/Controllers/BackupController.cs b/JobTrackerApi/Controllers/BackupController.cs index 7dff19b..500d4dc 100644 --- a/JobTrackerApi/Controllers/BackupController.cs +++ b/JobTrackerApi/Controllers/BackupController.cs @@ -89,6 +89,21 @@ namespace JobTrackerApi.Controllers attempt.StartedAtUtc, attempt.CompletedAtUtc)) .ToListAsync(cancellationToken); + var emailDrafts = await _db.EmailDrafts.AsNoTracking() + .Where(draft => jobIds.Contains(draft.JobApplicationId)) + .OrderBy(draft => draft.UpdatedAtUtc) + .Select(draft => new EmailDraftExport( + draft.Id, + draft.JobApplicationId, + draft.Provider, + draft.To, + draft.Subject, + draft.BodyText, + draft.ThreadId, + draft.Revision, + draft.CreatedAtUtc, + draft.UpdatedAtUtc)) + .ToListAsync(cancellationToken); var rules = await _db.RuleSettings.AsNoTracking().FirstOrDefaultAsync(cancellationToken); return new @@ -99,6 +114,7 @@ namespace JobTrackerApi.Controllers Attachments = attachments, Events = events, EmailSendAttempts = emailSendAttempts, + EmailDrafts = emailDrafts, Rules = rules }; } diff --git a/JobTrackerApi/Models/EmailDraft.cs b/JobTrackerApi/Models/EmailDraft.cs index a58be3f..887be3d 100644 --- a/JobTrackerApi/Models/EmailDraft.cs +++ b/JobTrackerApi/Models/EmailDraft.cs @@ -15,3 +15,15 @@ public sealed class EmailDraft public DateTime CreatedAtUtc { get; set; } public DateTime UpdatedAtUtc { get; set; } } + +public sealed record EmailDraftExport( + Guid Id, + int JobApplicationId, + string Provider, + string To, + string Subject, + string BodyText, + string? ThreadId, + long Revision, + DateTime CreatedAtUtc, + DateTime UpdatedAtUtc); diff --git a/JobTrackerApi/Services/DailyExportHostedService.cs b/JobTrackerApi/Services/DailyExportHostedService.cs index 662c90c..1c28c79 100644 --- a/JobTrackerApi/Services/DailyExportHostedService.cs +++ b/JobTrackerApi/Services/DailyExportHostedService.cs @@ -77,6 +77,21 @@ public sealed class DailyExportHostedService( attempt.StartedAtUtc, attempt.CompletedAtUtc)) .ToListAsync(cancellationToken), + EmailDrafts = await db.EmailDrafts.AsNoTracking() + .Where(draft => jobIds.Contains(draft.JobApplicationId)) + .OrderBy(draft => draft.UpdatedAtUtc) + .Select(draft => new EmailDraftExport( + draft.Id, + draft.JobApplicationId, + draft.Provider, + draft.To, + draft.Subject, + draft.BodyText, + draft.ThreadId, + draft.Revision, + draft.CreatedAtUtc, + draft.UpdatedAtUtc)) + .ToListAsync(cancellationToken), Rules = await RulesEngine.GetSettings(db, cancellationToken), };