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 Labels, IReadOnlyList Attachments); [ApiController] [Route("api/email")] [Authorize(AuthenticationSchemes = "local")] public sealed class EmailController( IEmailProviderRegistry providers, JobTrackerContext db, EmailSendAttemptStore attempts, ILogger 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>> GetProviders(CancellationToken cancellationToken) { var ownerUserId = GetOwnerUserId(); if (ownerUserId is null) return Unauthorized(); var statuses = new List(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>> 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>> 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> 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> 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, }; }