feat(email): include drafts in user exports
CI and Deploy / test (pull_request) Successful in 4m14s
CI and Deploy / deploy (pull_request) Has been skipped

Export readable private draft content only through existing owner-filtered encrypted and daily export boundaries, with cross-tenant regression coverage.
This commit is contained in:
cesnimda
2026-08-10 10:00:34 +02:00
parent 10740fd8c3
commit 2fa4e38ba9
5 changed files with 98 additions and 1 deletions
@@ -121,6 +121,9 @@ public sealed class BackgroundWorkerTenantTests
db.EmailSendAttempts.AddRange( 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-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 }); 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(); await db.SaveChangesAsync();
} }
var environment = new Mock<IHostEnvironment>(); var environment = new Mock<IHostEnvironment>();
@@ -148,6 +151,10 @@ public sealed class BackgroundWorkerTenantTests
var attempt = Assert.Single(rootElement.GetProperty("EmailSendAttempts").EnumerateArray()); var attempt = Assert.Single(rootElement.GetProperty("EmailSendAttempts").EnumerateArray());
Assert.Equal(owner == "user-1" ? "gmail" : "microsoft", attempt.GetProperty("Provider").GetString()); Assert.Equal(owner == "user-1" ? "gmail" : "microsoft", attempt.GetProperty("Provider").GetString());
Assert.False(attempt.TryGetProperty("PayloadHash", out _)); 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); owners.Sort(StringComparer.Ordinal);
Assert.Equal(new[] { "user-1", "user-2" }, owners); 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] [Fact]
public async Task Enrichment_processes_both_owners_only_through_fake_ai() public async Task Enrichment_processes_both_owners_only_through_fake_ai()
{ {
+34 -1
View File
@@ -36,7 +36,8 @@ public sealed class BackupControllerTests
await using var db = CreateDb(); await using var db = CreateDb();
db.Companies.Add(new Company { Name = "Acme", OwnerUserId = "user-1" }); db.Companies.Add(new Company { Name = "Acme", OwnerUserId = "user-1" });
var job = new JobApplication { JobTitle = "Backend Developer", 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(); await db.SaveChangesAsync();
db.EmailSendAttempts.Add(new EmailSendAttempt db.EmailSendAttempts.Add(new EmailSendAttempt
{ {
@@ -51,6 +52,32 @@ public sealed class BackupControllerTests
CreatedAtUtc = DateTime.UtcNow, CreatedAtUtc = DateTime.UtcNow,
CompletedAtUtc = 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(); await db.SaveChangesAsync();
var provider = DataProtectionProvider.Create(new DirectoryInfo(Path.Combine(Path.GetTempPath(), $"jobtracker-tests-{Guid.NewGuid():N}"))); 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("gmail", attempt.GetProperty("Provider").GetString());
Assert.Equal("sent", attempt.GetProperty("Status").GetString()); Assert.Equal("sent", attempt.GetProperty("Status").GetString());
Assert.False(attempt.TryGetProperty("PayloadHash", out _)); 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() private static JobTrackerContext CreateDb()
@@ -89,6 +89,21 @@ namespace JobTrackerApi.Controllers
attempt.StartedAtUtc, attempt.StartedAtUtc,
attempt.CompletedAtUtc)) attempt.CompletedAtUtc))
.ToListAsync(cancellationToken); .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); var rules = await _db.RuleSettings.AsNoTracking().FirstOrDefaultAsync(cancellationToken);
return new return new
@@ -99,6 +114,7 @@ namespace JobTrackerApi.Controllers
Attachments = attachments, Attachments = attachments,
Events = events, Events = events,
EmailSendAttempts = emailSendAttempts, EmailSendAttempts = emailSendAttempts,
EmailDrafts = emailDrafts,
Rules = rules Rules = rules
}; };
} }
+12
View File
@@ -15,3 +15,15 @@ public sealed class EmailDraft
public DateTime CreatedAtUtc { get; set; } public DateTime CreatedAtUtc { get; set; }
public DateTime UpdatedAtUtc { 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);
@@ -77,6 +77,21 @@ public sealed class DailyExportHostedService(
attempt.StartedAtUtc, attempt.StartedAtUtc,
attempt.CompletedAtUtc)) attempt.CompletedAtUtc))
.ToListAsync(cancellationToken), .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), Rules = await RulesEngine.GetSettings(db, cancellationToken),
}; };