using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services.EmailProviders; using System.Text.Json; namespace JobTrackerApi.Controllers { [ApiController] // Explicitly authorized. These endpoints are all tenant-scoped user data, so they must not // depend on the Auth:Require fallback policy being switched on: a deployment that lost that flag // would otherwise serve them anonymously. docs/production-readiness-review.md. [Route("api/correspondence")] [Authorize(AuthenticationSchemes = "local")] public class CorrespondenceController : ControllerBase { private readonly JobTrackerContext _db; public CorrespondenceController(JobTrackerContext db) { _db = db; } // Resolve correspondence through its parent job so the DbContext's user-scoped // job filter still protects raw id endpoints in multi-user deployments. private Task FindOwnedMessageAsync(int correspondenceId, CancellationToken cancellationToken) { return _db.Correspondences .Include(c => c.JobApplication) .FirstOrDefaultAsync(c => c.Id == correspondenceId, cancellationToken); } public sealed record CorrespondenceInboxItemDto( int Id, int JobApplicationId, string? CompanyName, string? JobTitle, string From, string? Direction, string? Subject, string? Channel, DateTime Date, string ContentPreview, string? ExternalThreadId, string? ExternalMessageId, string? Provider, string? ExternalFrom, string? ExternalTo, int LabelCount, int AttachmentCount); public sealed record CorrespondenceInboxPageDto( List Items, int Page, int PageSize, int Total, int TotalPages); [HttpGet] public async Task>> GetInbox( [FromQuery] string? q, [FromQuery] string? direction, [FromQuery] string? linkState, CancellationToken cancellationToken) { var query = BuildInboxQuery(q, direction, linkState); var items = await LoadInboxItemsAsync(query, 0, 200, cancellationToken); return Ok(items); } [HttpGet("page")] public async Task> GetInboxPage( [FromQuery] string? q, [FromQuery] string? direction, [FromQuery] string? linkState, [FromQuery] int page = 1, [FromQuery] int pageSize = 50, CancellationToken cancellationToken = default) { page = Math.Max(1, page); pageSize = Math.Clamp(pageSize, 1, 100); var query = BuildInboxQuery(q, direction, linkState); var total = await query.CountAsync(cancellationToken); var totalPages = total == 0 ? 0 : (int)Math.Ceiling(total / (double)pageSize); if (totalPages > 0) page = Math.Min(page, totalPages); var items = await LoadInboxItemsAsync(query, (page - 1) * pageSize, pageSize, cancellationToken); return Ok(new CorrespondenceInboxPageDto(items, page, pageSize, total, totalPages)); } private IQueryable BuildInboxQuery(string? q, string? direction, string? linkState) { var query = _db.Correspondences .AsNoTracking() .Include(c => c.JobApplication) .ThenInclude(j => j.Company) .AsQueryable(); if (!string.IsNullOrWhiteSpace(q)) { var needle = q.Trim(); query = query.Where(c => (c.Subject != null && EF.Functions.Like(c.Subject, $"%{needle}%")) || EF.Functions.Like(c.Content, $"%{needle}%") || (c.ExternalFrom != null && EF.Functions.Like(c.ExternalFrom, $"%{needle}%")) || (c.JobApplication.JobTitle != null && EF.Functions.Like(c.JobApplication.JobTitle, $"%{needle}%")) || (c.JobApplication.Company.Name != null && EF.Functions.Like(c.JobApplication.Company.Name, $"%{needle}%"))); } if (!string.IsNullOrWhiteSpace(direction) && !string.Equals(direction, "all", StringComparison.OrdinalIgnoreCase)) { query = query.Where(c => c.Direction == direction); } if (string.Equals(linkState, "linked", StringComparison.OrdinalIgnoreCase)) { query = query.Where(c => c.ExternalThreadId != null); } else if (string.Equals(linkState, "manual", StringComparison.OrdinalIgnoreCase)) { query = query.Where(c => c.ExternalThreadId == null); } return query; } private static async Task> LoadInboxItemsAsync( IQueryable query, int skip, int take, CancellationToken cancellationToken) { var rows = await query .OrderByDescending(c => c.Date) .ThenByDescending(c => c.Id) .Skip(skip) .Take(take) .Select(c => new { c.Id, c.JobApplicationId, CompanyName = c.JobApplication.Company != null ? c.JobApplication.Company.Name : null, JobTitle = c.JobApplication.JobTitle, c.From, c.Direction, c.Subject, c.Channel, c.Date, ContentPreview = c.Content.Length <= 220 ? c.Content : c.Content.Substring(0, 220), c.ExternalThreadId, c.ExternalMessageId, c.Provider, c.ExternalFrom, c.ExternalTo, c.ExternalLabelsJson, c.AttachmentMetadataJson, }) .ToListAsync(cancellationToken); var items = rows.Select(c => new CorrespondenceInboxItemDto( c.Id, c.JobApplicationId, c.CompanyName, c.JobTitle, c.From, c.Direction, c.Subject, c.Channel, c.Date, c.ContentPreview, c.ExternalThreadId, c.ExternalMessageId, c.Provider, c.ExternalFrom, c.ExternalTo, DeserializeLabels(c.ExternalLabelsJson).Count, DeserializeAttachments(c.AttachmentMetadataJson).Count)).ToList(); return items; } // GET all messages for a job [HttpGet("{jobId:int}")] public async Task>> GetForJob([FromRoute] int jobId, CancellationToken cancellationToken) { var jobOk = await _db.JobApplications.AnyAsync(j => j.Id == jobId, cancellationToken); if (!jobOk) return NotFound(); var messages = await _db.Correspondences .Where(c => c.JobApplicationId == jobId) .OrderBy(c => c.Date) .ToListAsync(cancellationToken); return Ok(messages); } [HttpGet("message/{id:int}")] public async Task> GetMessage([FromRoute] int id, CancellationToken cancellationToken) { var message = await FindOwnedMessageAsync(id, cancellationToken); if (message is null) return NotFound(); return Ok(new EmailMessageDetailDto( message.ExternalMessageId ?? $"correspondence-{message.Id}", message.ExternalThreadId ?? string.Empty, message.Subject ?? string.Empty, message.ExternalFrom ?? message.From, message.ExternalTo ?? string.Empty, new DateTimeOffset(DateTime.SpecifyKind(message.Date, DateTimeKind.Local)), message.Content.Length <= 220 ? message.Content : message.Content[..220], message.Content, DeserializeLabels(message.ExternalLabelsJson), DeserializeAttachments(message.AttachmentMetadataJson))); } public sealed record CreateCorrespondenceRequest(int JobApplicationId, string From, string Content); public sealed record CreateCorrespondenceRequestV2( int JobApplicationId, string From, string Content, string? Subject, string? Channel, DateTime? Date, string? Direction, string? ExternalMessageId, string? ExternalThreadId, string? ExternalFrom, string? ExternalTo, string? ExternalLabelsJson, string? AttachmentMetadataJson ); // POST new message [HttpPost] public async Task> Create([FromBody] CreateCorrespondenceRequestV2 request, CancellationToken cancellationToken) { if (request.JobApplicationId <= 0) return BadRequest("Valid jobApplicationId is required."); if (string.IsNullOrWhiteSpace(request.From)) return BadRequest("From is required."); if (string.IsNullOrWhiteSpace(request.Content)) return BadRequest("Content is required."); var exists = await _db.JobApplications.AnyAsync(j => j.Id == request.JobApplicationId, cancellationToken); if (!exists) return BadRequest("jobApplicationId does not exist."); var message = new Correspondence { JobApplicationId = request.JobApplicationId, From = request.From.Trim(), Subject = string.IsNullOrWhiteSpace(request.Subject) ? null : request.Subject.Trim(), Channel = string.IsNullOrWhiteSpace(request.Channel) ? null : request.Channel.Trim(), Direction = string.IsNullOrWhiteSpace(request.Direction) ? null : request.Direction.Trim(), ExternalMessageId = string.IsNullOrWhiteSpace(request.ExternalMessageId) ? null : request.ExternalMessageId.Trim(), ExternalThreadId = string.IsNullOrWhiteSpace(request.ExternalThreadId) ? null : request.ExternalThreadId.Trim(), ExternalFrom = string.IsNullOrWhiteSpace(request.ExternalFrom) ? null : request.ExternalFrom.Trim(), ExternalTo = string.IsNullOrWhiteSpace(request.ExternalTo) ? null : request.ExternalTo.Trim(), ExternalLabelsJson = string.IsNullOrWhiteSpace(request.ExternalLabelsJson) ? null : request.ExternalLabelsJson.Trim(), AttachmentMetadataJson = string.IsNullOrWhiteSpace(request.AttachmentMetadataJson) ? null : request.AttachmentMetadataJson.Trim(), Provider = "manual", Content = request.Content, Date = request.Date ?? DateTime.Now, }; _db.Correspondences.Add(message); await _db.SaveChangesAsync(cancellationToken); return CreatedAtAction(nameof(GetForJob), new { jobId = message.JobApplicationId }, message); } [HttpDelete("{id:int}")] public async Task Delete([FromRoute] int id, CancellationToken cancellationToken) { var message = await FindOwnedMessageAsync(id, cancellationToken); if (message is null) return NotFound(); _db.Correspondences.Remove(message); await _db.SaveChangesAsync(cancellationToken); return NoContent(); } private static IReadOnlyList DeserializeLabels(string? json) { if (string.IsNullOrWhiteSpace(json)) return Array.Empty(); try { return JsonSerializer.Deserialize>(json) ?? new List(); } catch (JsonException) { return Array.Empty(); } } private static IReadOnlyList DeserializeAttachments(string? json) { if (string.IsNullOrWhiteSpace(json)) return Array.Empty(); try { return (JsonSerializer.Deserialize>(json) ?? new List()) .Select(item => new EmailAttachmentRef(item.FileName, item.MimeType, item.SizeBytes, item.GmailAttachmentId, item.Inline)) .ToList(); } catch (JsonException) { return Array.Empty(); } } } }