Compare commits
2 Commits
d44c8ceb08
...
90f9bf576c
| Author | SHA1 | Date | |
|---|---|---|---|
| 90f9bf576c | |||
| 123fc5555a |
@@ -3,6 +3,9 @@ using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Services.EmailProviders;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using JobTrackerApi.Services;
|
||||
using JobTrackerApi.Tests.TestSupport;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
@@ -67,7 +70,8 @@ public sealed class EmailControllerTests
|
||||
|
||||
private static EmailController CreateController(params IEmailProvider[] providers)
|
||||
{
|
||||
var controller = new EmailController(new EmailProviderRegistry(providers));
|
||||
var db = TestHostFactory.CreateInMemoryDb();
|
||||
var controller = new EmailController(new EmailProviderRegistry(providers), db, new EmailSendAttemptStore(db, TimeProvider.System), NullLogger<EmailController>.Instance);
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
using System.Security.Claims;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using JobTrackerApi.Services.EmailProviders;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class EmailSendControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Confirmed_send_is_recorded_once_and_duplicate_returns_existing_result()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
var provider = new FakeProvider();
|
||||
var controller = fixture.Controller(provider, "user-1");
|
||||
var request = Request(fixture.JobId, confirmed: true);
|
||||
|
||||
var first = await controller.Send(request, default);
|
||||
var duplicate = await controller.Send(request, default);
|
||||
|
||||
Assert.Equal(EmailSendStatuses.Sent, Assert.IsType<EmailController.SendResult>(Assert.IsType<OkObjectResult>(first.Result).Value).Status);
|
||||
Assert.True(Assert.IsType<EmailController.SendResult>(Assert.IsType<OkObjectResult>(duplicate.Result).Value).Duplicate);
|
||||
Assert.Equal(1, provider.SendCount);
|
||||
Assert.Equal(1, await fixture.Db.Correspondences.CountAsync());
|
||||
Assert.Equal(1, await fixture.Db.JobEvents.CountAsync(item => item.Type == "EmailSent"));
|
||||
Assert.DoesNotContain("Body", (await fixture.Db.JobEvents.SingleAsync()).Note ?? string.Empty);
|
||||
Assert.Equal(EmailSendStatuses.Sent, (await fixture.Db.EmailSendAttempts.AsNoTracking().SingleAsync()).Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Missing_confirmation_or_cross_tenant_job_never_reserves_or_sends()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
var provider = new FakeProvider();
|
||||
|
||||
var unconfirmed = await fixture.Controller(provider, "user-1").Send(Request(fixture.JobId, confirmed: false), default);
|
||||
Assert.IsType<BadRequestObjectResult>(unconfirmed.Result);
|
||||
|
||||
var other = await fixture.Controller(provider, "user-2").Send(Request(fixture.JobId, confirmed: true), default);
|
||||
Assert.IsType<NotFoundResult>(other.Result);
|
||||
Assert.Equal(0, provider.SendCount);
|
||||
Assert.Equal(0, await fixture.Db.EmailSendAttempts.IgnoreQueryFilters().CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Known_rejection_is_failed_and_requires_a_new_request_id()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
var provider = new FakeProvider(_ => throw new EmailProviderDeliveryException("provider_rejected", false, "Rejected"));
|
||||
var controller = fixture.Controller(provider, "user-1");
|
||||
var request = Request(fixture.JobId, confirmed: true);
|
||||
|
||||
var failed = Assert.IsType<ObjectResult>((await controller.Send(request, default)).Result);
|
||||
var duplicate = Assert.IsType<ConflictObjectResult>((await controller.Send(request, default)).Result);
|
||||
|
||||
Assert.Equal(StatusCodes.Status502BadGateway, failed.StatusCode);
|
||||
Assert.Equal(EmailSendStatuses.Failed, Assert.IsType<EmailController.SendResult>(failed.Value).Status);
|
||||
Assert.Equal(EmailSendStatuses.Failed, Assert.IsType<EmailController.SendResult>(duplicate.Value).Status);
|
||||
Assert.Equal(1, provider.SendCount);
|
||||
Assert.Empty(await fixture.Db.Correspondences.ToListAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Transport_interruption_is_uncertain_and_is_never_retried()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
var provider = new FakeProvider(_ => throw new EmailProviderDeliveryException("transport_interrupted", true, "Uncertain"));
|
||||
var controller = fixture.Controller(provider, "user-1");
|
||||
var request = Request(fixture.JobId, confirmed: true);
|
||||
|
||||
var uncertain = Assert.IsType<ObjectResult>((await controller.Send(request, default)).Result);
|
||||
var duplicate = Assert.IsType<ConflictObjectResult>((await controller.Send(request, default)).Result);
|
||||
|
||||
Assert.Equal(StatusCodes.Status409Conflict, uncertain.StatusCode);
|
||||
Assert.Equal(EmailSendStatuses.Uncertain, Assert.IsType<EmailController.SendResult>(uncertain.Value).Status);
|
||||
Assert.Equal(EmailSendStatuses.Uncertain, Assert.IsType<EmailController.SendResult>(duplicate.Value).Status);
|
||||
Assert.Equal(1, provider.SendCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Invalid_or_missing_fields_never_reserve_or_send()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
var provider = new FakeProvider();
|
||||
var controller = fixture.Controller(provider, "user-1");
|
||||
|
||||
var invalid = new[]
|
||||
{
|
||||
Request(fixture.JobId, confirmed: true) with { Provider = null },
|
||||
Request(fixture.JobId, confirmed: true) with { ClientRequestId = null },
|
||||
Request(fixture.JobId, confirmed: true) with { To = null },
|
||||
Request(fixture.JobId, confirmed: true) with { Subject = null },
|
||||
Request(fixture.JobId, confirmed: true) with { BodyText = null },
|
||||
Request(fixture.JobId, confirmed: true) with { ThreadId = new string('x', 513) },
|
||||
};
|
||||
|
||||
foreach (var request in invalid)
|
||||
Assert.IsType<BadRequestObjectResult>((await controller.Send(request, default)).Result);
|
||||
|
||||
Assert.Equal(0, provider.SendCount);
|
||||
Assert.Equal(0, await fixture.Db.EmailSendAttempts.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connection_check_failure_is_definite_and_never_calls_send()
|
||||
{
|
||||
await using var fixture = await Fixture.CreateAsync();
|
||||
var provider = new FakeProvider(connectionError: new HttpRequestException("Synthetic connection failure"));
|
||||
var controller = fixture.Controller(provider, "user-1");
|
||||
|
||||
var failed = Assert.IsType<ObjectResult>((await controller.Send(Request(fixture.JobId, confirmed: true), default)).Result);
|
||||
|
||||
Assert.Equal(StatusCodes.Status502BadGateway, failed.StatusCode);
|
||||
Assert.Equal(EmailSendStatuses.Failed, Assert.IsType<EmailController.SendResult>(failed.Value).Status);
|
||||
Assert.Equal(0, provider.SendCount);
|
||||
Assert.Equal(EmailSendStatuses.Failed, (await fixture.Db.EmailSendAttempts.AsNoTracking().SingleAsync()).Status);
|
||||
}
|
||||
|
||||
private static EmailController.SendRequest Request(int jobId, bool confirmed) => new(
|
||||
jobId,
|
||||
"gmail",
|
||||
"00000000-0000-0000-0000-000000000123",
|
||||
"recruiter@example.test",
|
||||
"Interview follow-up",
|
||||
"Synthetic body",
|
||||
"thread-1",
|
||||
confirmed);
|
||||
|
||||
private sealed class FakeProvider(Func<EmailDeliveryRequest, EmailDeliveryResult>? send = null, Exception? connectionError = null) : IEmailProvider
|
||||
{
|
||||
public int SendCount { get; private set; }
|
||||
public string ProviderKey => "gmail";
|
||||
public Task<EmailConnectionInfo?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken) =>
|
||||
connectionError is null
|
||||
? Task.FromResult<EmailConnectionInfo?>(new("gmail", "owner@gmail.test", true))
|
||||
: Task.FromException<EmailConnectionInfo?>(connectionError);
|
||||
public Task<IReadOnlyList<EmailMessageSummary>> SearchAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<IReadOnlyList<EmailMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<EmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
public Task<EmailDeliveryResult> SendAsync(string ownerUserId, EmailDeliveryRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
SendCount++;
|
||||
return Task.FromResult(send?.Invoke(request) ?? new EmailDeliveryResult("message-1", request.ThreadId));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Fixture(SqliteConnection connection, DbContextOptions<JobTrackerContext> options, JobTrackerContext db, int jobId) : IAsyncDisposable
|
||||
{
|
||||
private readonly List<JobTrackerContext> contexts = new();
|
||||
public JobTrackerContext Db { get; } = db;
|
||||
public int JobId { get; } = jobId;
|
||||
|
||||
public static async Task<Fixture> CreateAsync()
|
||||
{
|
||||
var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
var options = new DbContextOptionsBuilder<JobTrackerContext>().UseSqlite(connection).Options;
|
||||
var db = CreateDb(options, "user-1");
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
|
||||
db.Companies.Add(company);
|
||||
await db.SaveChangesAsync();
|
||||
var job = new JobApplication { JobTitle = "Backend", CompanyId = company.Id, OwnerUserId = "user-1" };
|
||||
db.JobApplications.Add(job);
|
||||
await db.SaveChangesAsync();
|
||||
return new Fixture(connection, options, db, job.Id);
|
||||
}
|
||||
|
||||
public EmailController Controller(IEmailProvider provider, string userId)
|
||||
{
|
||||
var context = userId == "user-1" ? Db : CreateDb(options, userId);
|
||||
if (!ReferenceEquals(context, Db)) contexts.Add(context);
|
||||
return new EmailController(new EmailProviderRegistry(new[] { provider }), context, new EmailSendAttemptStore(context, TimeProvider.System), NullLogger<EmailController>.Instance)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) }, "test"))
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static JobTrackerContext CreateDb(DbContextOptions<JobTrackerContext> options, string userId)
|
||||
{
|
||||
var currentUser = new Mock<ICurrentUserService>();
|
||||
currentUser.SetupGet(service => service.UserId).Returns(userId);
|
||||
return new JobTrackerContext(options, currentUser.Object);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
foreach (var context in contexts) await context.DisposeAsync();
|
||||
await Db.DisposeAsync();
|
||||
await connection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,16 @@
|
||||
using System.Security.Claims;
|
||||
using JobTrackerApi.Services.EmailProviders;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Net.Mail;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace JobTrackerApi.Controllers;
|
||||
|
||||
@@ -20,9 +29,15 @@ public sealed record EmailMessageDetailDto(
|
||||
[ApiController]
|
||||
[Route("api/email")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public sealed class EmailController(IEmailProviderRegistry providers) : ControllerBase
|
||||
public sealed class EmailController(
|
||||
IEmailProviderRegistry providers,
|
||||
JobTrackerContext db,
|
||||
EmailSendAttemptStore attempts,
|
||||
ILogger<EmailController> logger) : ControllerBase
|
||||
{
|
||||
public sealed record ProviderStatus(string Provider, string DisplayName, bool Connected, string? Address, bool CanRead, bool CanSend);
|
||||
public sealed record SendRequest(int JobApplicationId, string? Provider, string? ClientRequestId, string? To, string? Subject, string? BodyText, string? ThreadId, bool Confirmed);
|
||||
public sealed record SendResult(Guid AttemptId, string Status, bool Duplicate, string? ExternalMessageId, string? ExternalThreadId, string? FailureCategory);
|
||||
[HttpGet("providers")]
|
||||
public async Task<ActionResult<IReadOnlyList<ProviderStatus>>> GetProviders(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -110,9 +125,158 @@ public sealed class EmailController(IEmailProviderRegistry providers) : Controll
|
||||
detail.Attachments));
|
||||
}
|
||||
|
||||
[HttpPost("send")]
|
||||
[EnableRateLimiting("email-send")]
|
||||
public async Task<ActionResult<SendResult>> Send([FromBody] SendRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var ownerUserId = GetOwnerUserId();
|
||||
if (ownerUserId is null) return Unauthorized();
|
||||
if (!request.Confirmed) return BadRequest("Explicit send confirmation is required.");
|
||||
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 (!Guid.TryParse(request.ClientRequestId, out var requestId)) return BadRequest("clientRequestId must be a UUID.");
|
||||
var recipient = request.To?.Trim();
|
||||
var subject = request.Subject?.Trim();
|
||||
var bodyText = request.BodyText;
|
||||
var threadId = string.IsNullOrWhiteSpace(request.ThreadId) ? null : request.ThreadId.Trim();
|
||||
if (!MailAddress.TryCreate(recipient, out _) || recipient.Length > 320) return BadRequest("A valid recipient is required.");
|
||||
if (string.IsNullOrWhiteSpace(subject) || subject.Length > 998) return BadRequest("Subject is required and must be at most 998 characters.");
|
||||
if (string.IsNullOrWhiteSpace(bodyText) || bodyText.Length > 200_000) return BadRequest("Body is required and must be at most 200000 characters.");
|
||||
if (threadId?.Length > 512) return BadRequest("Thread ID must be at most 512 characters.");
|
||||
|
||||
var job = await db.JobApplications.Include(item => item.Company)
|
||||
.FirstOrDefaultAsync(item => item.Id == request.JobApplicationId, cancellationToken);
|
||||
if (job is null) return NotFound();
|
||||
|
||||
var normalizedProvider = provider.ProviderKey.ToLowerInvariant();
|
||||
var clientRequestId = requestId.ToString("D");
|
||||
var payloadHash = ComputePayloadHash(job.Id, normalizedProvider, recipient, subject, bodyText, threadId);
|
||||
EmailSendAttemptCreation reservation;
|
||||
try
|
||||
{
|
||||
reservation = await attempts.CreateAsync(new CreateEmailSendAttempt(job.Id, normalizedProvider, clientRequestId, payloadHash), cancellationToken);
|
||||
}
|
||||
catch (EmailSendConflictException ex)
|
||||
{
|
||||
return Conflict(new ProblemDetails { Title = "Idempotency conflict", Detail = ex.Message });
|
||||
}
|
||||
|
||||
if (!reservation.Created)
|
||||
{
|
||||
var existing = reservation.Attempt;
|
||||
if (existing.Status == EmailSendStatuses.Sent)
|
||||
return Ok(new SendResult(existing.Id, existing.Status, true, existing.ProviderMessageId, null, existing.FailureCategory));
|
||||
return Conflict(new SendResult(existing.Id, existing.Status, true, existing.ProviderMessageId, null, existing.FailureCategory));
|
||||
}
|
||||
|
||||
if (await attempts.BeginAsync(reservation.Attempt.Id, cancellationToken) != 1)
|
||||
return Conflict(new SendResult(reservation.Attempt.Id, EmailSendStatuses.Pending, true, null, null, null));
|
||||
|
||||
EmailConnectionInfo? connection;
|
||||
try
|
||||
{
|
||||
connection = await provider.GetConnectionAsync(ownerUserId, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
await attempts.MarkFailedAsync(reservation.Attempt.Id, "cancelled_before_delivery", CancellationToken.None);
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning("Email connection check failed for attempt {AttemptId} ({ExceptionType})", reservation.Attempt.Id, ex.GetType().Name);
|
||||
await attempts.MarkFailedAsync(reservation.Attempt.Id, "connection_check_failed", CancellationToken.None);
|
||||
return StatusCode(StatusCodes.Status502BadGateway,
|
||||
new SendResult(reservation.Attempt.Id, EmailSendStatuses.Failed, false, null, null, "connection_check_failed"));
|
||||
}
|
||||
if (connection is null || !connection.CanSend)
|
||||
{
|
||||
await attempts.MarkFailedAsync(reservation.Attempt.Id, "reauthorization_required", CancellationToken.None);
|
||||
return Conflict(new SendResult(reservation.Attempt.Id, EmailSendStatuses.Failed, false, null, null, "reauthorization_required"));
|
||||
}
|
||||
|
||||
EmailDeliveryResult delivery;
|
||||
try
|
||||
{
|
||||
delivery = await provider.SendAsync(ownerUserId, new EmailDeliveryRequest(recipient, subject, bodyText, threadId), cancellationToken);
|
||||
}
|
||||
catch (EmailProviderDeliveryException ex)
|
||||
{
|
||||
if (ex.Uncertain) await attempts.MarkUncertainAsync(reservation.Attempt.Id, ex.Category, CancellationToken.None);
|
||||
else await attempts.MarkFailedAsync(reservation.Attempt.Id, ex.Category, CancellationToken.None);
|
||||
var status = ex.Uncertain ? EmailSendStatuses.Uncertain : EmailSendStatuses.Failed;
|
||||
return StatusCode(ex.Uncertain ? StatusCodes.Status409Conflict : StatusCodes.Status502BadGateway,
|
||||
new SendResult(reservation.Attempt.Id, status, false, null, null, ex.Category));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Unexpected email delivery failure for attempt {AttemptId}", reservation.Attempt.Id);
|
||||
await attempts.MarkUncertainAsync(reservation.Attempt.Id, "unexpected_delivery_error", CancellationToken.None);
|
||||
return StatusCode(StatusCodes.Status409Conflict,
|
||||
new SendResult(reservation.Attempt.Id, EmailSendStatuses.Uncertain, false, null, null, "unexpected_delivery_error"));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var transaction = db.Database.IsRelational() ? await db.Database.BeginTransactionAsync(CancellationToken.None) : null;
|
||||
db.Correspondences.Add(new Correspondence
|
||||
{
|
||||
JobApplicationId = job.Id,
|
||||
From = "Me",
|
||||
Direction = "outbound",
|
||||
Subject = subject,
|
||||
Channel = "Email",
|
||||
ExternalMessageId = delivery.ExternalMessageId,
|
||||
ExternalThreadId = delivery.ExternalThreadId ?? threadId,
|
||||
ExternalFrom = connection.Address,
|
||||
ExternalTo = recipient,
|
||||
Provider = normalizedProvider,
|
||||
Content = bodyText,
|
||||
Date = DateTime.UtcNow,
|
||||
});
|
||||
db.JobEvents.Add(new JobEvent
|
||||
{
|
||||
JobApplicationId = job.Id,
|
||||
Type = "EmailSent",
|
||||
NewValue = normalizedProvider,
|
||||
Note = $"attempt:{reservation.Attempt.Id}",
|
||||
At = DateTime.UtcNow,
|
||||
});
|
||||
if (await attempts.MarkSentAsync(reservation.Attempt.Id, delivery.ExternalMessageId, CancellationToken.None) != 1)
|
||||
throw new InvalidOperationException("The email attempt could not be finalized.");
|
||||
await db.SaveChangesAsync(CancellationToken.None);
|
||||
if (transaction is not null) await transaction.CommitAsync(CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Email was accepted but local persistence failed for attempt {AttemptId}", reservation.Attempt.Id);
|
||||
db.ChangeTracker.Clear();
|
||||
await attempts.MarkUncertainAsync(reservation.Attempt.Id, "local_persistence_failed", CancellationToken.None);
|
||||
return StatusCode(StatusCodes.Status409Conflict,
|
||||
new SendResult(reservation.Attempt.Id, EmailSendStatuses.Uncertain, false, delivery.ExternalMessageId, delivery.ExternalThreadId, "local_persistence_failed"));
|
||||
}
|
||||
|
||||
return Ok(new SendResult(reservation.Attempt.Id, EmailSendStatuses.Sent, false, delivery.ExternalMessageId, delivery.ExternalThreadId, null));
|
||||
}
|
||||
|
||||
private string? GetOwnerUserId() =>
|
||||
User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
|
||||
|
||||
private static string ComputePayloadHash(int jobApplicationId, string provider, string recipient, string subject, string bodyText, string? threadId)
|
||||
{
|
||||
var canonical = JsonSerializer.Serialize(new
|
||||
{
|
||||
JobApplicationId = jobApplicationId,
|
||||
Provider = provider,
|
||||
To = recipient.ToLowerInvariant(),
|
||||
Subject = subject,
|
||||
Body = bodyText,
|
||||
ThreadId = threadId ?? string.Empty,
|
||||
});
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonical))).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string GetDisplayName(string provider) => provider.ToLowerInvariant() switch
|
||||
{
|
||||
"gmail" => "Gmail",
|
||||
|
||||
@@ -429,6 +429,17 @@ builder.Services.AddRateLimiter(options =>
|
||||
QueueLimit = 0,
|
||||
}));
|
||||
|
||||
options.AddPolicy("email-send", context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(
|
||||
partitionKey: $"email-send:{context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? context.User.FindFirst("sub")?.Value ?? context.Connection.RemoteIpAddress?.ToString() ?? "unknown"}",
|
||||
factory: _ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = 10,
|
||||
Window = TimeSpan.FromMinutes(1),
|
||||
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
|
||||
QueueLimit = 0,
|
||||
}));
|
||||
|
||||
options.AddPolicy("public-pdf", context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(
|
||||
partitionKey: $"public-pdf:{context.Request.RouteValues["slug"]?.ToString() ?? "unknown"}",
|
||||
|
||||
@@ -26,7 +26,7 @@ public sealed class EmailSendAttemptStore(JobTrackerContext db, TimeProvider tim
|
||||
if (!await db.JobApplications.AnyAsync(job => job.Id == request.JobApplicationId, cancellationToken))
|
||||
throw new InvalidOperationException("The job application does not exist in the current owner scope.");
|
||||
|
||||
var existing = await db.EmailSendAttempts.FirstOrDefaultAsync(item => item.ClientRequestId == request.ClientRequestId, cancellationToken);
|
||||
var existing = await db.EmailSendAttempts.AsNoTracking().FirstOrDefaultAsync(item => item.ClientRequestId == request.ClientRequestId, cancellationToken);
|
||||
if (existing is not null) return Existing(existing, request.PayloadHash);
|
||||
|
||||
var attempt = new EmailSendAttempt
|
||||
@@ -48,7 +48,7 @@ public sealed class EmailSendAttemptStore(JobTrackerContext db, TimeProvider tim
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
db.Entry(attempt).State = EntityState.Detached;
|
||||
existing = await db.EmailSendAttempts.FirstOrDefaultAsync(item => item.ClientRequestId == request.ClientRequestId, cancellationToken);
|
||||
existing = await db.EmailSendAttempts.AsNoTracking().FirstOrDefaultAsync(item => item.ClientRequestId == request.ClientRequestId, cancellationToken);
|
||||
if (existing is not null) return Existing(existing, request.PayloadHash);
|
||||
throw;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
# MAIL-001 evidence index
|
||||
|
||||
Updated: 2026-08-09
|
||||
Updated: 2026-08-10
|
||||
|
||||
- Progress report: `docs/verification/mail-001-job-email-hub.md`
|
||||
- Commands/results: `docs/audits/verification-log.md` V-126–V-133
|
||||
- Commands/results: `docs/audits/verification-log.md` V-126–V-134
|
||||
- Hub/legacy-route tests: `job-tracker-ui/src/correspondence-inbox-page.test.tsx`
|
||||
- Reused review decision tests: `job-tracker-ui/src/gmail-review-page.test.tsx`
|
||||
- Provider-neutral API tests: `JobTrackerApi.Tests/EmailControllerTests.cs`
|
||||
- Saved-copy/tenant tests: `JobTrackerApi.Tests/CorrespondenceControllerTests.cs`
|
||||
- Send-ledger tests: `JobTrackerApi.Tests/EmailSendAttemptStoreTests.cs`
|
||||
- Delivery adapter tests: `JobTrackerApi.Tests/EmailProviderDeliveryTests.cs`
|
||||
- Implementation commits: `6008b4a`, `536d403`, `a20775c`, `653f011`, `e9937ac`
|
||||
- Explicit-send/tenant/idempotency tests: `JobTrackerApi.Tests/EmailSendControllerTests.cs`
|
||||
- Implementation commits: `6008b4a`, `536d403`, `a20775c`, `653f011`, `e9937ac`, `123fc55`
|
||||
- Mocked provider data only; no real email, provider connection, private content, production service or external request was used.
|
||||
|
||||
@@ -165,3 +165,4 @@ Output was reduced to filenames and commit counts. The token artifact appears un
|
||||
| V-131 | Focused `EmailControllerTests|CorrespondenceControllerTests`; focused correspondence-inbox Jest; full backend/frontend; `npm.cmd run build`; `git diff --check` | Repository root / `job-tracker-ui` | Verify plain-text provider detail, owner-scoped saved fallback, malformed metadata tolerance, stale-request guard, full regressions and TypeScript | PASS — backend focused 5/5 and full 605/605; frontend focused 5/5 and full 49/49 suites, 188/188 tests; production build and patch check pass | Provider response is mocked and saved data synthetic. Full Jest took 81.434s and retains the existing force-exit/open-handle notice | Browser/real-provider/production verification remains |
|
||||
| V-132 | `EmailSendAttemptStoreTests`; backend build/full tests; `dotnet ef migrations has-pending-model-changes`; SQLite/MariaDB up/down scripts; disposable SQLite upgrade/insert/rollback; cleanup verification | Repository root | Verify inert tenant send ledger, idempotency/state safety, additive provider migration and rollback | PASS — focused 3/3; backend 608/608; model current; bounded provider SQL; SQLite unique index/FK/sample row/rollback pass; temp files removed | First MariaDB script exposed SQLite-scaffolded types and was rejected; explicit provider branch corrected it. One parallel test/build attempt contended on compiler output; serial test passed. MariaDB SQL generated only, not executed | Provider/runtime limitation and corrected verification setup |
|
||||
| V-133 | Focused delivery/provider/controller tests; full backend; focused inbox Jest; frontend production build; `git diff --check` | Repository root / `job-tracker-ui` | Verify explicit consent scopes, capability reporting, Gmail/Graph payloads, IMAP read-only, rejected/reauth/uncertain classification and regressions | PASS — focused backend 18/18; full backend 613/613; inbox 5/5; production build and patch check pass | Ephemeral encrypted tokens, synthetic recipients/content and mocked HTTP only. No OAuth/provider/email call or real account. Browser/production not run | External provider/production limitation |
|
||||
| V-134 | Focused `EmailSendControllerTests|EmailSendAttemptStoreTests|EmailControllerTests`; full backend; staged diff/secret-name/whitespace review | Repository root | Verify explicit confirmation, bounded input, owner isolation, canonical idempotency, rate-limited provider admission, correspondence/audit transaction and failed/uncertain behavior | PASS — focused 12/12; full backend 619/619; no whitespace errors or secret values. Provider called once across duplicate requests; cross-tenant/unconfirmed/malformed requests never reserve or deliver | SQLite and fake provider only. No real provider/email/network/browser/production execution. Crash-abandoned `sending` reconciliation remains | External provider/runtime limitation |
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# MAIL-001 consolidated job-email hub
|
||||
|
||||
Updated: 2026-08-09
|
||||
Updated: 2026-08-10
|
||||
|
||||
Status: `IN PROGRESS`. Canonical hub routing and provider-neutral read capability discovery are implemented and locally verified; provider actions and draft/send work remain.
|
||||
Status: `IN PROGRESS`. Canonical hub routing, provider-neutral reads and the explicit-confirmed send API are implemented and locally verified; the editable UI and remaining provider actions remain.
|
||||
|
||||
## Revalidated current boundaries
|
||||
|
||||
@@ -53,22 +53,33 @@ Status: `IN PROGRESS`. Canonical hub routing and provider-neutral read capabilit
|
||||
- Gmail builds an RFC MIME plain-text message, supports the existing Gmail thread ID, and uses the documented send endpoint. Graph sends plain-text JSON through `sendMail`.
|
||||
- HTTP rejection is a known failed-before-delivery category; 401/403 requires reauthorization. Network interruption/cancellation is marked uncertain because acceptance cannot be disproved.
|
||||
- Provider response bodies and transport exception details are not returned to callers. Recipient/body fixtures and HTTP transport are synthetic/mocked; no provider was contacted.
|
||||
- No application send route exists yet. Consent and adapters cannot be triggered by a JobTracker send button until ledger integration lands.
|
||||
- The adapters are reachable only through the later explicit-confirmed API; no JobTracker send button exists yet.
|
||||
|
||||
## Implemented explicit-send API increment
|
||||
|
||||
- Added one authenticated, rate-limited `POST /api/email/send` route. It requires an owned job, a send-capable connected provider, an explicit `confirmed=true`, and valid bounded recipient/subject/body/thread fields.
|
||||
- Client UUIDs are canonicalized before the tenant ledger reservation. Reusing a UUID with different content is rejected; sent duplicates return the original result; pending, failed or uncertain attempts are never redelivered automatically.
|
||||
- The ledger is reserved and moved to sending before provider I/O. Provider rejection is failed, transport ambiguity is uncertain, and connection failure before delivery is failed.
|
||||
- Successful delivery writes the outbound correspondence, a content-free job event and the ledger terminal state in one local database transaction. Provider acceptance followed by local persistence failure is surfaced as uncertain.
|
||||
- The audit event and ledger omit recipient, subject and body. Full content exists only in the intended job correspondence record.
|
||||
- Tests use owner-isolated SQLite and a fake provider; no email, OAuth flow, provider service or external network was invoked.
|
||||
|
||||
## Verification
|
||||
|
||||
- Focused delivery/provider/capability: 18/18; send ledger: 3/3; provider/correspondence controllers: 5/5; hub detail: 5/5.
|
||||
- Full backend: 613/613; full frontend: 49/49 suites, 188/188 tests.
|
||||
- Explicit-send controller/store/read focused tests: 12/12.
|
||||
- Full backend: 619/619; full frontend: 49/49 suites, 188/188 tests.
|
||||
- Production build/TypeScript and `git diff --check`: pass.
|
||||
- Implementation commits: `6008b4a`, `536d403`, `a20775c`, `653f011`, `e9937ac`.
|
||||
- Implementation commits: `6008b4a`, `536d403`, `a20775c`, `653f011`, `e9937ac`, `123fc55`.
|
||||
|
||||
## Remaining MAIL-001 work
|
||||
|
||||
- Extend shared provider-neutral thread navigation and application embedding while preserving provider capability differences.
|
||||
- Surface provider identity, reauthorization, read/unread, pin/read-later/archive/spam/trash only where the provider supports it.
|
||||
- Share thread detail and link/unlink actions between hub and job workspace.
|
||||
- Design editable provider drafts with recipient/subject/thread/provider review and explicit confirmed, idempotent send; uncertain failures must not be retried blindly.
|
||||
- Include non-sensitive send-attempt metadata in user export and verify account/job deletion coverage before enabling the send API.
|
||||
- Add editable provider drafts with recipient/subject/thread/provider review and a final confirmation dialog; reuse one client UUID per reviewed draft and show uncertain results without blind retry.
|
||||
- Reconcile abandoned `sending` attempts to an explicit uncertain/manual-review state after process failure; never auto-resend them.
|
||||
- Include non-sensitive send-attempt metadata in user export and verify account/job deletion coverage before production rollout.
|
||||
- Preserve minimal audit metadata without sensitive body logging; verify Free non-AI access and Pro-only AI assistance.
|
||||
- Complete link/unlink/dismiss/draft/send/failure/two-user/application-embed tests plus browser/production provider gates. No real email may be sent during repository verification.
|
||||
|
||||
@@ -78,4 +89,4 @@ The first focused Jest invocation exhibited the repository's open-handle delay.
|
||||
|
||||
## Rollback
|
||||
|
||||
Revert `e9937ac` to remove send consent/adapters, then `653f011` (after migration downgrade) for the ledger, followed by the earlier read/routing commits. Existing provider grants are not revoked by a code rollback; disconnect/reconnect is an explicit user action.
|
||||
Disable the UI/admission path before rollback. Revert `123fc55` to remove the send route, then `e9937ac` for send consent/adapters and `653f011` (after migration downgrade) for the ledger, followed by earlier read/routing commits. Existing provider grants are not revoked by a code rollback; disconnect/reconnect is an explicit user action.
|
||||
|
||||
@@ -419,3 +419,13 @@
|
||||
- **Consequences:** existing connections show read-only until explicit reconnect consent. Gmail supports its provider thread ID; Graph currently sends a new message and does not claim reply-thread semantics. The later API must reserve the ledger before calling either adapter and surface uncertain state for manual reconciliation.
|
||||
- **User approval required:** No; this repository-side programme requirement used fake transports only. Real account consent/send still requires an explicitly authorized synthetic provider account.
|
||||
- **Reversible:** Yes. Revert `e9937ac` to stop requesting/using send permission. Already granted provider permission is managed by the provider/user connection and is not automatically revoked by a code rollback.
|
||||
|
||||
## DEC-043 — Send admission reserves before delivery and never retries ambiguity
|
||||
|
||||
- **Date:** 2026-08-10
|
||||
- **Decision:** Admit provider delivery only through an authenticated, user-rate-limited route that requires an owned job, explicit confirmation and a canonical client UUID. Reserve and begin the content-free ledger before provider I/O; persist sent correspondence, a content-free job event and the sent state in one local transaction. Return existing sent results but reject every other duplicate, especially uncertain attempts.
|
||||
- **Reason/evidence:** provider acceptance cannot share the database transaction. Owner-scoped SQLite tests prove malformed, unconfirmed and cross-tenant requests do not reserve or send; duplicate, rejected and interrupted attempts call the fake provider at most once. Canonical UUID formatting closes a simple deduplication bypass.
|
||||
- **Alternatives considered:** call the provider before reserving; retry timeouts; rely on a disabled button; store message content in the ledger/event; use legacy application SMTP; mark local persistence failure as sent. These can duplicate delivery, lose audit state, expose content or bypass connected-provider consent.
|
||||
- **Consequences:** a successful provider call with failed local persistence is intentionally uncertain and requires manual reconciliation. A process stop after admission can leave a `sending` row; a later repository increment must age it into an explicit uncertain/manual-review state without redelivery. Existing read-only connections cannot send until re-consented.
|
||||
- **User approval required:** No; MAIL-001 explicitly authorizes local implementation and fake verification. Real provider consent/send remains gated.
|
||||
- **Reversible:** Yes. Disable admission/UI, then revert `123fc55`. No schema rollback is needed for this route-only increment.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# JobTracker master programme progress
|
||||
|
||||
Updated: 2026-08-09
|
||||
Updated: 2026-08-10
|
||||
|
||||
- **Overall programme status:** Active. Six packages are locally verified; nineteen packages through CAREER-002 are implemented with automated/runtime evidence but blocked from applicable browser/provider/production gates; MAIL-001 is in progress with five pushed increments.
|
||||
- **Current work package:** `MAIL-001` — consolidated job-email hub and explicit sending (`IN PROGRESS`); routing/reads/detail, durable ledger and fake-verified Gmail/Graph delivery/re-consent are pushed. Explicit-send API/UI remains unavailable until ledger integration.
|
||||
- **Overall programme status:** Active. Six packages are locally verified; nineteen packages through CAREER-002 are implemented with automated/runtime evidence but blocked from applicable browser/provider/production gates; MAIL-001 is in progress with six implementation increments.
|
||||
- **Current work package:** `MAIL-001` — consolidated job-email hub and explicit sending (`IN PROGRESS`); routing/reads/detail, durable ledger, Gmail/Graph delivery/re-consent and the explicit-confirmed tenant-safe send API are committed. Editable hub send UI and abandoned-attempt reconciliation remain.
|
||||
- **Completed work packages:** None are `DONE`; all repository security/AI packages still have applicable browser, provider and/or production gates.
|
||||
- **Locally verified work:** SEC-001, SEC-002, SEC-003, SEC-005A, CORE-001 and PROD-002 (`VERIFIED LOCALLY`).
|
||||
- **Implemented, verification incomplete:** SEC-004, SEC-005B, SEC-008, CORE-002, BG-001, OPS-001A/B/C, POL-001/002, AI-001/002/003/004, UX-001/002, QA-001, CAREER-001 and CAREER-002 (`IMPLEMENTED — NOT VERIFIED`). CAREER-002 now protects unsaved edits and provides tested structured custom-entry, profile-override and preview-error interactions; all automated/build gates pass.
|
||||
@@ -12,10 +12,10 @@ Updated: 2026-08-09
|
||||
- **Deferred work:** None. Conditional multi-replica coordination, model deletion, realtime operation delivery and unrelated production changes remain outside current packages.
|
||||
- **Next five work packages:** MAIL-001 consolidated email experience; JOBS-001 job-search/application redesign; HOME-001 homepage/Pro promotion; UX-003 authentication/profile polish; PRODUCT-001 homepage/Pro claims. SEC-006/007 resume after package-index permission.
|
||||
- **Status counts:** 6 `VERIFIED LOCALLY`; 19 `IMPLEMENTED — NOT VERIFIED`; 1 `IN PROGRESS`; 8 `NOT STARTED`; 5 `BLOCKED`; 0 `DONE`; 0 `DEFERRED`.
|
||||
- **Test status:** backend 613/613; frontend 49/49 suites and 188/188 tests; MAIL-001 delivery/capability 18/18, ledger 3/3, provider/correspondence 5/5 and hub detail 5/5; migration rehearsal and production build pass. Jest open-handle/slow-run behavior is recorded in V-127/V-128/V-130/V-131.
|
||||
- **Test status:** backend 619/619; frontend 49/49 suites and 188/188 tests; MAIL-001 send/read/store focused 12/12, delivery/capability 18/18, provider/correspondence 5/5 and hub detail 5/5; migration rehearsal and production build pass. Jest open-handle/slow-run behavior is recorded in V-127/V-128/V-130/V-131.
|
||||
- **Deployment status:** No deployment performed. No production migrations were run. AI operation worker remains disabled by default.
|
||||
- **Production status:** Unchanged and unverified. No provider/model call, model pull, external request or paid API occurred.
|
||||
- **Known regressions:** None found by automated suites. Jest still needs `--forceExit` and reports its existing open-handle notice. Email-provider tests are fake/local only; no send capability is claimed. Direct clean EF-only SQLite migration still hits the pre-existing historical blank-chain defect before later migrations; normal startup owns reconciliation. Cross-feature monthly AI usage accounting remains a rollout gap.
|
||||
- **Known regressions:** None found by automated suites. Jest still needs `--forceExit` and reports its existing open-handle notice. Email-provider/send tests are fake/local only; real delivery is not claimed. A process stop after ledger admission can leave `sending` for later reconciliation, but duplicate delivery remains fail-closed. Direct clean EF-only SQLite migration still hits the pre-existing historical blank-chain defect before later migrations; normal startup owns reconciliation. Cross-feature monthly AI usage accounting remains a rollout gap.
|
||||
- **Outstanding security findings:** JT-001 repository ownership remains High deployment risk until migration/inventory/provider checks; production portion of JT-002; JT-006/JT-009 and associated JT-011/JT-012/JT-022 prerequisites. JT-005 foundations are implemented; AI worker activation awaits controlled rollout. JT-007/JT-008/JT-010 lack browser/provider/production verification.
|
||||
|
||||
## Current evidence
|
||||
|
||||
@@ -651,9 +651,9 @@ Ordering differences from the suggested list:
|
||||
- **Required production verification:** provider read/draft/send requires explicit authorized synthetic account; never real unsolicited email.
|
||||
- **Status:** `IN PROGRESS`.
|
||||
- **Blocker:** real provider verification external; mocked/local implementation not blocked after dependencies.
|
||||
- **Evidence:** `docs/verification/mail-001-job-email-hub.md`; V-126–V-133. Delivery/capability 18/18; ledger 3/3; provider/correspondence 5/5; hub detail 5/5; backend 613/613; frontend 49/49 suites and 188/188 tests plus build.
|
||||
- **Commit:** `6008b4a` (hub), `536d403` (neutral reads), `a20775c` (safe detail), `653f011` (ledger), `e9937ac` (Gmail/Graph delivery adapters and consent).
|
||||
- **Remaining work:** explicit confirmed API/UI using ledger and adapters; correspondence/audit transaction; export/deletion coverage; shared thread/application actions; tenant/free/pro/duplicate/uncertain tests; browser/production. Existing connections need explicit re-consent; IMAP remains read-only. Legacy follow-up SMTP must not be called provider send. No real email; uncertain sends need manual reconciliation.
|
||||
- **Evidence:** `docs/verification/mail-001-job-email-hub.md`; V-126–V-134. Send/read/store focused 12/12; delivery/capability 18/18; provider/correspondence 5/5; hub detail 5/5; backend 619/619; frontend 49/49 suites and 188/188 tests plus build.
|
||||
- **Commit:** `6008b4a` (hub), `536d403` (neutral reads), `a20775c` (safe detail), `653f011` (ledger), `e9937ac` (Gmail/Graph delivery adapters and consent), `123fc55` (explicit-confirmed send API).
|
||||
- **Remaining work:** editable confirmed UI; abandoned-sending reconciliation; export/deletion coverage; shared thread/application actions; free/pro and browser/production verification. Existing connections need explicit re-consent; IMAP remains read-only. Legacy follow-up SMTP must be retired or routed through the same safety boundary before MAIL-001 completion. No real email; uncertain sends need manual reconciliation.
|
||||
|
||||
### JOBS-001 — Job-search source and assessment redesign
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
# JobTracker session handoff
|
||||
|
||||
Updated: 2026-08-09
|
||||
Updated: 2026-08-10
|
||||
|
||||
- **Exact current task:** MAIL-001 — connect one explicit-confirmed owner-scoped API to the durable ledger and provider adapters, persist correspondence/audit safely, then add the editable hub UI without real email.
|
||||
- **Last completed step:** Gmail/Graph delivery adapters, explicit send consent, honest per-connection capability and rejected-versus-uncertain classification were committed/pushed as `e9937ac`. No application send route exists.
|
||||
- **Files currently modified:** MAIL-001 verification/log/master tracking documents only. Application code/tests are committed and pushed.
|
||||
- **Commands already run:** complete provider/send trace; read/detail/ledger/delivery focused and full tests; TypeScript build; EF provider migration rehearsal; five implementation commits/pushes. See V-126–V-133.
|
||||
- **Test results:** MAIL-001 delivery/capability 18/18, ledger 3/3, provider/correspondence 5/5, hub detail 5/5; backend 613/613; frontend 49/49 suites and 188/188 tests; migration/model/build gates pass.
|
||||
- **Exact current task:** MAIL-001 — add the editable Job email send UI over the explicit-confirmed API, without invoking a real provider, then address abandoned-attempt and legacy-send boundaries.
|
||||
- **Last completed step:** tenant-owned, UUID-idempotent, rate-limited explicit send admission and transactional correspondence/audit persistence were committed as `123fc55`; focused tests prove confirmation, validation, isolation, duplicate suppression and failed/uncertain handling.
|
||||
- **Files currently modified:** MAIL-001 verification/log/master tracking documents only. Application code/tests are committed.
|
||||
- **Commands already run:** complete provider/send trace; read/detail/ledger/delivery/send focused tests; full backend; TypeScript build; EF provider migration rehearsal; six implementation commits. See V-126–V-134.
|
||||
- **Test results:** MAIL-001 send/read/store 12/12, delivery/capability 18/18, provider/correspondence 5/5, hub detail 5/5; backend 619/619; frontend 49/49 suites and 188/188 tests; migration/model/build gates pass.
|
||||
- **Services currently running:** none started intentionally. Exact task-owned hung Jest/build Node processes were stopped; Codex browser runtimes were not touched. Pre-existing Docker services were not changed.
|
||||
- **Temporary files or processes:** none. No provider account, private email, external model, paid service or production service was accessed.
|
||||
- **Production changes currently active:** none. No deployment, migration, provider connection/sync/send or production payload occurred.
|
||||
- **Rollback status:** revert `e9937ac` for consent/adapters; downgrade `20260809195014_AddEmailSendAttempts` then revert `653f011`; earlier MAIL commits remain independently reversible. No production migration/deploy occurred. Provider grants, if later obtained, require user disconnect/reconnect to revoke; none were obtained here.
|
||||
- **Rollback status:** keep admission/UI disabled, revert `123fc55`, then `e9937ac`; downgrade `20260809195014_AddEmailSendAttempts` before reverting `653f011`. Earlier MAIL commits remain independently reversible. No production migration/deploy/provider grant occurred.
|
||||
- **Uncommitted changes:** MAIL-001 evidence/tracking documents only; commit/push before the next application increment.
|
||||
- **Known failures:** explicit-send API/UI, correspondence/audit completion, export coverage, full thread actions and non-Gmail review remain; existing accounts need re-consent and IMAP stays read-only. Legacy follow-up SMTP remains unsafe/mislabeled for this purpose. Real provider/SMTP/MariaDB/production unavailable; Jest open handles; SEC-006 needs internet/package-index permission; parser isolation remains SEC-007.
|
||||
- **Exact next action:** commit/push this evidence, then add a POST API that requires `confirmed=true`, provider, UUID, recipient/subject/body/thread and an owned job; hash/reserve/begin the ledger before adapter I/O; map known rejection to failed and transport interruption to uncertain; never retry an existing uncertain attempt.
|
||||
- **Known failures:** editable send UI, abandoned `sending` reconciliation, export coverage, full thread actions and non-Gmail review remain; existing accounts need re-consent and IMAP stays read-only. Legacy follow-up SMTP bypasses the new boundary. Real provider/SMTP/MariaDB/production unavailable; Jest open handles; SEC-006 needs internet/package-index permission; parser isolation remains SEC-007.
|
||||
- **Exact next action:** commit/push this evidence, then add a minimal editable hub composer that displays provider/to/subject/thread/body, regenerates its UUID whenever reviewed content changes after an attempt, requires a final confirmation dialog, and never retries an uncertain result.
|
||||
- **Work that can continue independently:** remaining MAIL-001 repository work and later UX packages. SEC-006/007 await package-index permission; PROD-001/003/004 await production access.
|
||||
- **Decisions still required from the user:** none for synthetic/code-inspected repository work. Any provider connection or send test, internet/package upgrades, private data, external/paid providers and production actions retain explicit approval/safety gates; SEC-009 retention/legal policy remains unresolved.
|
||||
|
||||
Reference in New Issue
Block a user