using System.Security.Claims; using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace JobTrackerApi.Controllers; [ApiController] [Route("api/gmail")] [Authorize] public sealed class GmailController : ControllerBase { private readonly IGmailOAuthService _gmail; private readonly JobTrackerContext _db; private readonly IConfiguration _cfg; public GmailController(IGmailOAuthService gmail, JobTrackerContext db, IConfiguration cfg) { _gmail = gmail; _db = db; _cfg = cfg; } [HttpGet("status")] public async Task Status(CancellationToken cancellationToken) { var ownerUserId = GetRequiredOwnerUserId(); var connection = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken); return Ok(new { connected = connection is not null, gmailAddress = connection?.GmailAddress, connectedAt = connection?.ConnectedAt, lastSyncedAt = connection?.LastSyncedAt, }); } [HttpGet("connect-url")] public IActionResult ConnectUrl() { var ownerUserId = GetRequiredOwnerUserId(); var url = _gmail.BuildAuthorizationUrl(ownerUserId, GetRedirectUri()); return Ok(new { url }); } [AllowAnonymous] [HttpGet("oauth/callback")] public async Task Callback([FromQuery] string? code, [FromQuery] string? state, [FromQuery] string? error, CancellationToken cancellationToken) { if (!string.IsNullOrWhiteSpace(error)) { return Content(BuildPopupHtml(false, $"Google returned an error: {error}"), "text/html"); } if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(state)) { return Content(BuildPopupHtml(false, "Missing Google OAuth code or state."), "text/html"); } var ownerUserId = _gmail.ConsumeState(state); if (string.IsNullOrWhiteSpace(ownerUserId)) { return Content(BuildPopupHtml(false, "This Gmail connection request is no longer valid. Start the connection again."), "text/html"); } try { var result = await _gmail.ExchangeCodeAsync(ownerUserId, code, GetRedirectUri(), cancellationToken); return Content(BuildPopupHtml(true, $"Connected Gmail: {result.GmailAddress}"), "text/html"); } catch (Exception ex) { return Content(BuildPopupHtml(false, ex.Message), "text/html"); } } [HttpDelete("connection")] public async Task Disconnect(CancellationToken cancellationToken) { var ownerUserId = GetRequiredOwnerUserId(); await _gmail.DisconnectAsync(ownerUserId, cancellationToken); return NoContent(); } [HttpGet("messages")] public async Task Messages([FromQuery] string? query, [FromQuery] int maxResults = 12, CancellationToken cancellationToken = default) { var ownerUserId = GetRequiredOwnerUserId(); var items = await _gmail.ListMessagesAsync(ownerUserId, query, maxResults, cancellationToken); return Ok(items); } public sealed record ImportGmailMessageRequest(int JobApplicationId, string MessageId); [HttpPost("import")] public async Task Import([FromBody] ImportGmailMessageRequest request, CancellationToken cancellationToken) { try { if (request.JobApplicationId <= 0) return BadRequest("Valid jobApplicationId is required."); if (string.IsNullOrWhiteSpace(request.MessageId)) return BadRequest("MessageId is required."); var ownerUserId = GetRequiredOwnerUserId(); var job = await _db.JobApplications.Include(x => x.Company).FirstOrDefaultAsync(x => x.Id == request.JobApplicationId, cancellationToken); if (job is null) return NotFound("Job application not found."); var existing = await _db.Correspondences.FirstOrDefaultAsync( x => x.JobApplicationId == request.JobApplicationId && x.ExternalMessageId == request.MessageId, cancellationToken); if (existing is not null) { return Ok(existing); } var detail = await _gmail.GetMessageAsync(ownerUserId, request.MessageId, cancellationToken); var me = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken); var gmailAddress = me?.GmailAddress ?? string.Empty; var isMe = detail.From.Contains(gmailAddress, StringComparison.OrdinalIgnoreCase); var messageDate = detail.Date?.LocalDateTime ?? DateTime.Now; var message = new Correspondence { JobApplicationId = request.JobApplicationId, From = isMe ? "Me" : "Company", Subject = string.IsNullOrWhiteSpace(detail.Subject) ? null : detail.Subject.Trim(), Channel = "Email", ExternalMessageId = detail.Id, Content = string.IsNullOrWhiteSpace(detail.BodyText) ? detail.Snippet : detail.BodyText, Date = messageDate, }; _db.Correspondences.Add(message); if (job.Company is not null) { job.Company.LastContactedAt = messageDate; } if (!isMe && (!job.ResponseReceived || job.ResponseDate is null || messageDate < job.ResponseDate.Value)) { var oldResponse = $"{job.ResponseReceived}:{job.ResponseDate?.ToString("o")}"; job.ResponseReceived = true; job.ResponseDate = messageDate; _db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "ReplyReceived", OldValue = oldResponse, NewValue = $"{job.ResponseReceived}:{job.ResponseDate?.ToString("o")}", Note = detail.Subject, At = messageDate }); } await _db.SaveChangesAsync(cancellationToken); return Ok(message); } catch (Exception ex) { return BadRequest(ex.Message); } } private string GetRequiredOwnerUserId() { return User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? throw new InvalidOperationException("Authenticated user id is missing."); } private string GetRedirectUri() { var configured = (_cfg["Google:GmailRedirectUri"] ?? _cfg["Google:RedirectUri"] ?? "").Trim(); if (!string.IsNullOrWhiteSpace(configured)) return configured; var publicBaseUrl = (_cfg["App:PublicBaseUrl"] ?? "").Trim().TrimEnd('/'); if (!string.IsNullOrWhiteSpace(publicBaseUrl)) { return $"{publicBaseUrl}/api/gmail/oauth/callback"; } return $"{Request.Scheme}://{Request.Host}/api/gmail/oauth/callback"; } private static string BuildPopupHtml(bool success, string message) { var escaped = System.Net.WebUtility.HtmlEncode(message); var status = success ? "connected" : "error"; var title = success ? "Gmail connected" : "Gmail connection failed"; var serializedMessage = System.Text.Json.JsonSerializer.Serialize(message); return $@" Gmail connection

{title}

{escaped}

You can close this window.

"; } }