using System.Security.Claims; using System.Net.Mail; using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services.EmailProviders; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace JobTrackerApi.Controllers; [ApiController] [Route("api/email/drafts")] [Authorize(AuthenticationSchemes = "local")] public sealed class EmailDraftsController( JobTrackerContext db, IEmailProviderRegistry providers, TimeProvider timeProvider) : ControllerBase { public sealed record DraftDto( Guid Id, int JobApplicationId, string Provider, string To, string Subject, string BodyText, string? ThreadId, string ClientRequestId, long Revision, DateTime CreatedAtUtc, DateTime UpdatedAtUtc); public sealed record CreateDraftRequest( int JobApplicationId, string? Provider, string? To, string? Subject, string? BodyText, string? ThreadId); public sealed record UpdateDraftRequest(long Revision, string? To, string? Subject, string? BodyText); public sealed record NewAttemptRequest(long Revision); [HttpGet] public async Task>> List( [FromQuery] int? jobApplicationId, CancellationToken cancellationToken) { var ownerUserId = GetOwnerUserId(); if (ownerUserId is null) return Unauthorized(); if (jobApplicationId is <= 0) return BadRequest("A valid job application is required."); var query = db.EmailDrafts.AsNoTracking().Where(draft => draft.OwnerUserId == ownerUserId); if (jobApplicationId.HasValue) query = query.Where(draft => draft.JobApplicationId == jobApplicationId.Value); return Ok(await query .OrderByDescending(draft => draft.UpdatedAtUtc) .Select(draft => new DraftDto( draft.Id, draft.JobApplicationId, draft.Provider, draft.To, draft.Subject, draft.BodyText, draft.ThreadId, draft.ClientRequestId, draft.Revision, draft.CreatedAtUtc, draft.UpdatedAtUtc)) .ToListAsync(cancellationToken)); } [HttpGet("{id:guid}")] public async Task> Get(Guid id, CancellationToken cancellationToken) { var ownerUserId = GetOwnerUserId(); if (ownerUserId is null) return Unauthorized(); var draft = await db.EmailDrafts.AsNoTracking() .FirstOrDefaultAsync(item => item.Id == id && item.OwnerUserId == ownerUserId, cancellationToken); return draft is null ? NotFound() : Ok(ToDto(draft)); } [HttpPost] public async Task> Create(CreateDraftRequest request, CancellationToken cancellationToken) { var ownerUserId = GetOwnerUserId(); if (ownerUserId is null) return Unauthorized(); 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 (!TryNormalizeContent(request.To, request.Subject, request.BodyText, out var recipient, out var subject, out var bodyText, out var error)) return BadRequest(error); var threadId = string.IsNullOrWhiteSpace(request.ThreadId) ? null : request.ThreadId.Trim(); if (threadId?.Length > 512) return BadRequest("Thread ID must be at most 512 characters."); var ownsJob = await db.JobApplications.AsNoTracking() .AnyAsync(job => job.Id == request.JobApplicationId && job.OwnerUserId == ownerUserId, cancellationToken); if (!ownsJob) return NotFound(); var now = timeProvider.GetUtcNow().UtcDateTime; var draft = new EmailDraft { Id = Guid.NewGuid(), OwnerUserId = ownerUserId, JobApplicationId = request.JobApplicationId, Provider = provider.ProviderKey.ToLowerInvariant(), To = recipient, Subject = subject, BodyText = bodyText, ThreadId = threadId, ClientRequestId = Guid.NewGuid().ToString("D"), Revision = 1, CreatedAtUtc = now, UpdatedAtUtc = now, }; db.EmailDrafts.Add(draft); await db.SaveChangesAsync(cancellationToken); return CreatedAtAction(nameof(Get), new { id = draft.Id }, ToDto(draft)); } [HttpPut("{id:guid}")] public async Task> Update(Guid id, UpdateDraftRequest request, CancellationToken cancellationToken) { var ownerUserId = GetOwnerUserId(); if (ownerUserId is null) return Unauthorized(); if (request.Revision <= 0) return BadRequest("A positive revision is required."); if (!TryNormalizeContent(request.To, request.Subject, request.BodyText, out var recipient, out var subject, out var bodyText, out var error)) return BadRequest(error); var now = timeProvider.GetUtcNow().UtcDateTime; var affected = await db.EmailDrafts .Where(draft => draft.Id == id && draft.OwnerUserId == ownerUserId && draft.Revision == request.Revision) .ExecuteUpdateAsync(setters => setters .SetProperty(draft => draft.To, recipient) .SetProperty(draft => draft.Subject, subject) .SetProperty(draft => draft.BodyText, bodyText) .SetProperty(draft => draft.Revision, draft => draft.Revision + 1) .SetProperty(draft => draft.UpdatedAtUtc, now), cancellationToken); if (affected == 0) { var exists = await db.EmailDrafts.AsNoTracking() .AnyAsync(draft => draft.Id == id && draft.OwnerUserId == ownerUserId, cancellationToken); return exists ? Conflict(new ProblemDetails { Title = "Draft revision conflict", Detail = "Reload the latest draft before saving again." }) : NotFound(); } return await Get(id, cancellationToken); } [HttpDelete("{id:guid}")] public async Task Delete(Guid id, [FromQuery] long revision, CancellationToken cancellationToken) { var ownerUserId = GetOwnerUserId(); if (ownerUserId is null) return Unauthorized(); if (revision <= 0) return BadRequest("A positive revision is required."); var affected = await db.EmailDrafts .Where(draft => draft.Id == id && draft.OwnerUserId == ownerUserId && draft.Revision == revision) .ExecuteDeleteAsync(cancellationToken); if (affected == 1) return NoContent(); var exists = await db.EmailDrafts.AsNoTracking() .AnyAsync(draft => draft.Id == id && draft.OwnerUserId == ownerUserId, cancellationToken); return exists ? Conflict(new ProblemDetails { Title = "Draft revision conflict", Detail = "Reload the latest draft before deleting it." }) : NotFound(); } [HttpPost("{id:guid}/new-attempt")] public async Task> NewAttempt(Guid id, NewAttemptRequest request, CancellationToken cancellationToken) { var ownerUserId = GetOwnerUserId(); if (ownerUserId is null) return Unauthorized(); if (request.Revision <= 0) return BadRequest("A positive revision is required."); var draft = await db.EmailDrafts.AsNoTracking() .FirstOrDefaultAsync(item => item.Id == id && item.OwnerUserId == ownerUserId, cancellationToken); if (draft is null) return NotFound(); if (draft.Revision != request.Revision) return Conflict(new ProblemDetails { Title = "Draft revision conflict", Detail = "Reload the latest draft before creating a new attempt." }); var failedAttempt = await db.EmailSendAttempts.AsNoTracking().AnyAsync(attempt => attempt.OwnerUserId == ownerUserId && attempt.ClientRequestId == draft.ClientRequestId && attempt.Status == EmailSendStatuses.Failed, cancellationToken); if (!failedAttempt) return Conflict(new ProblemDetails { Title = "A new attempt is not allowed", Detail = "Only a definitively failed delivery can receive a new attempt identity." }); var newClientRequestId = Guid.NewGuid().ToString("D"); var now = timeProvider.GetUtcNow().UtcDateTime; var affected = await db.EmailDrafts .Where(item => item.Id == id && item.OwnerUserId == ownerUserId && item.Revision == request.Revision && item.ClientRequestId == draft.ClientRequestId) .ExecuteUpdateAsync(setters => setters .SetProperty(item => item.ClientRequestId, newClientRequestId) .SetProperty(item => item.Revision, item => item.Revision + 1) .SetProperty(item => item.UpdatedAtUtc, now), cancellationToken); if (affected != 1) return Conflict(new ProblemDetails { Title = "Draft revision conflict", Detail = "Reload the latest draft before creating a new attempt." }); return await Get(id, cancellationToken); } private string? GetOwnerUserId() => User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"); private static bool TryNormalizeContent( string? to, string? subject, string? bodyText, out string recipient, out string normalizedSubject, out string normalizedBody, out string error) { recipient = to?.Trim() ?? string.Empty; normalizedSubject = subject?.Trim() ?? string.Empty; normalizedBody = bodyText ?? string.Empty; error = string.Empty; if (recipient.Length > 320 || (recipient.Length > 0 && !MailAddress.TryCreate(recipient, out _))) error = "Recipient must be empty or a valid address of at most 320 characters."; else if (normalizedSubject.Length > 998) error = "Subject must be at most 998 characters."; else if (normalizedBody.Length > 200_000) error = "Body must be at most 200000 characters."; return error.Length == 0; } private static DraftDto ToDto(EmailDraft draft) => new( draft.Id, draft.JobApplicationId, draft.Provider, draft.To, draft.Subject, draft.BodyText, draft.ThreadId, draft.ClientRequestId, draft.Revision, draft.CreatedAtUtc, draft.UpdatedAtUtc); }