123fc5555a
Require tenant-owned jobs, explicit confirmation, canonical request IDs, and rate limiting before provider delivery. Persist sent correspondence with a content-free idempotency ledger, and never retry uncertain outcomes automatically.
288 lines
14 KiB
C#
288 lines
14 KiB
C#
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;
|
|
|
|
public sealed record EmailMessageDetailDto(
|
|
string Id,
|
|
string ThreadId,
|
|
string Subject,
|
|
string From,
|
|
string To,
|
|
DateTimeOffset? Date,
|
|
string Snippet,
|
|
string BodyText,
|
|
IReadOnlyList<string> Labels,
|
|
IReadOnlyList<EmailAttachmentRef> Attachments);
|
|
|
|
[ApiController]
|
|
[Route("api/email")]
|
|
[Authorize(AuthenticationSchemes = "local")]
|
|
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)
|
|
{
|
|
var ownerUserId = GetOwnerUserId();
|
|
if (ownerUserId is null) return Unauthorized();
|
|
|
|
var statuses = new List<ProviderStatus>(providers.All.Count);
|
|
foreach (var provider in providers.All)
|
|
{
|
|
var connection = await provider.GetConnectionAsync(ownerUserId, cancellationToken);
|
|
statuses.Add(new ProviderStatus(
|
|
provider.ProviderKey,
|
|
GetDisplayName(provider.ProviderKey),
|
|
connection is not null,
|
|
connection?.Address,
|
|
CanRead: connection is not null,
|
|
CanSend: connection?.CanSend ?? false));
|
|
}
|
|
|
|
return Ok(statuses);
|
|
}
|
|
|
|
[HttpGet("messages")]
|
|
public async Task<ActionResult<IReadOnlyList<EmailMessageSummary>>> Search(
|
|
[FromQuery] string provider,
|
|
[FromQuery] string? q,
|
|
[FromQuery] int limit = 25,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var resolved = providers.Get(provider);
|
|
if (resolved is null) return BadRequest("Unknown email provider.");
|
|
|
|
var ownerUserId = GetOwnerUserId();
|
|
if (ownerUserId is null) return Unauthorized();
|
|
if (await resolved.GetConnectionAsync(ownerUserId, cancellationToken) is null)
|
|
return Conflict(new ProblemDetails { Title = "Email provider is not connected." });
|
|
|
|
return Ok(await resolved.SearchAsync(ownerUserId, q, Math.Clamp(limit, 1, 100), cancellationToken));
|
|
}
|
|
|
|
[HttpGet("thread")]
|
|
public async Task<ActionResult<IReadOnlyList<EmailMessageSummary>>> GetThread(
|
|
[FromQuery] string provider,
|
|
[FromQuery] string threadId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(threadId)) return BadRequest("threadId is required.");
|
|
var resolved = providers.Get(provider);
|
|
if (resolved is null) return BadRequest("Unknown email provider.");
|
|
|
|
var ownerUserId = GetOwnerUserId();
|
|
if (ownerUserId is null) return Unauthorized();
|
|
if (await resolved.GetConnectionAsync(ownerUserId, cancellationToken) is null)
|
|
return Conflict(new ProblemDetails { Title = "Email provider is not connected." });
|
|
|
|
return Ok(await resolved.ListThreadMessagesAsync(ownerUserId, threadId.Trim(), cancellationToken));
|
|
}
|
|
|
|
[HttpGet("message")]
|
|
public async Task<ActionResult<EmailMessageDetailDto>> GetMessage(
|
|
[FromQuery] string provider,
|
|
[FromQuery] string messageId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(messageId)) return BadRequest("messageId is required.");
|
|
var resolved = providers.Get(provider);
|
|
if (resolved is null) return BadRequest("Unknown email provider.");
|
|
|
|
var ownerUserId = GetOwnerUserId();
|
|
if (ownerUserId is null) return Unauthorized();
|
|
if (await resolved.GetConnectionAsync(ownerUserId, cancellationToken) is null)
|
|
return Conflict(new ProblemDetails { Title = "Email provider is not connected." });
|
|
|
|
var detail = await resolved.GetMessageAsync(ownerUserId, messageId.Trim(), cancellationToken);
|
|
return Ok(new EmailMessageDetailDto(
|
|
detail.Id,
|
|
detail.ThreadId,
|
|
detail.Subject,
|
|
detail.From,
|
|
detail.To,
|
|
detail.Date,
|
|
detail.Snippet,
|
|
detail.BodyText,
|
|
detail.Labels,
|
|
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",
|
|
"microsoft" => "Outlook",
|
|
"imap" => "IMAP",
|
|
_ => provider,
|
|
};
|
|
}
|