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; } public sealed record GmailImportResultDto(int Imported, int Skipped, string? ThreadId); public sealed record GmailImportMessageResultDto(int Imported, int Skipped, string MessageId, string? ThreadId, Correspondence? Message); public sealed record ImportGmailMessageRequest(int JobApplicationId, string MessageId); public sealed record ImportGmailThreadRequest(int JobApplicationId, string ThreadId, string[] MessageIds); public sealed record RefreshLinkedThreadsRequest(int JobApplicationId); public sealed record GmailThreadRefreshThreadDto(string ThreadId, int Imported, int Skipped, int TotalMessages, string Status, DateTimeOffset? LatestMessageDate); public sealed record GmailThreadRefreshResultDto(int JobApplicationId, int ThreadsChecked, int Imported, int Skipped, bool HasLinkedThreads, DateTimeOffset RefreshedAt, IReadOnlyList Threads); public sealed record GmailJobMatchReasonDto(string Label, string Value, int Points); public sealed record GmailJobMatchedMessageDto( string Id, string ThreadId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet, int Score, string Confidence, bool AlreadyImported, IReadOnlyList MatchedQueries, IReadOnlyList MatchReasons); public sealed record GmailJobMatchedThreadDto( string ThreadId, string Subject, int Score, string Confidence, bool HasImportedMessages, int ImportedMessageCount, int MessageCount, DateTimeOffset? LatestDate, IReadOnlyList MatchedQueries, IReadOnlyList MatchReasons, IReadOnlyList Messages); public sealed record GmailJobMatchesResponseDto( int JobApplicationId, string JobTitle, string CompanyName, string? RecruiterName, string? RecruiterEmail, IReadOnlyList Queries, int CandidateMessageCount, int CandidateThreadCount, IReadOnlyList Threads); [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 }); } [HttpGet("job-candidates")] public async Task> JobCandidates( [FromQuery] int jobApplicationId, [FromQuery] string? queryOverride, [FromQuery] int maxResultsPerQuery = 6, CancellationToken cancellationToken = default) { if (jobApplicationId <= 0) return BadRequest("Valid jobApplicationId is required."); var ownerUserId = GetRequiredOwnerUserId(); var job = await _db.JobApplications .Where(x => x.OwnerUserId == ownerUserId) .Include(x => x.Company) .Include(x => x.Messages) .FirstOrDefaultAsync(x => x.Id == jobApplicationId, cancellationToken); if (job is null) return NotFound("Job application not found."); var queries = BuildJobQueries(job, queryOverride); var candidateMessages = await _gmail.ListJobCandidateMessagesAsync(ownerUserId, queries, maxResultsPerQuery, cancellationToken); var importedMessageIds = job.Messages .Where(message => !string.IsNullOrWhiteSpace(message.ExternalMessageId)) .Select(message => message.ExternalMessageId!) .ToHashSet(StringComparer.Ordinal); var importedThreadIds = job.Messages .Where(message => !string.IsNullOrWhiteSpace(message.ExternalThreadId)) .Select(message => message.ExternalThreadId!) .ToHashSet(StringComparer.Ordinal); var rankedMessages = candidateMessages .Select(message => ScoreMessage(job, message, importedMessageIds.Contains(message.Message.Id), importedThreadIds.Contains(message.Message.ThreadId))) .Where(result => result.Score > 0 || result.AlreadyImported) .OrderByDescending(result => result.Score) .ThenByDescending(result => result.Message.Date ?? DateTimeOffset.MinValue) .ToList(); var threads = rankedMessages .GroupBy(result => string.IsNullOrWhiteSpace(result.Message.ThreadId) ? result.Message.Id : result.Message.ThreadId, StringComparer.Ordinal) .Select(group => { var ordered = group .OrderByDescending(item => item.Score) .ThenByDescending(item => item.Message.Date ?? DateTimeOffset.MinValue) .ToList(); var latestDate = ordered .Select(item => item.Message.Date) .OrderByDescending(item => item ?? DateTimeOffset.MinValue) .FirstOrDefault(); var combinedReasons = ordered .SelectMany(item => item.Reasons) .GroupBy(reason => new { reason.Label, reason.Value, reason.Points }) .Select(reason => reason.First()) .OrderByDescending(reason => reason.Points) .ThenBy(reason => reason.Label, StringComparer.Ordinal) .ThenBy(reason => reason.Value, StringComparer.Ordinal) .Take(8) .ToList(); var matchedQueries = ordered .SelectMany(item => item.MatchedQueries) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(query => query, StringComparer.OrdinalIgnoreCase) .ToList(); var hasImportedMessages = ordered.Any(item => item.AlreadyImported); var importedMessageCount = ordered.Count(item => item.AlreadyImported); var threadScore = ordered.Max(item => item.Score) + Math.Min(ordered.Count - 1, 2); var representative = ordered[0].Message; return new GmailJobMatchedThreadDto( group.Key, string.IsNullOrWhiteSpace(representative.Subject) ? "(no subject)" : representative.Subject, threadScore, ToConfidence(threadScore), hasImportedMessages, importedMessageCount, ordered.Count, latestDate, matchedQueries, combinedReasons, ordered.Select(item => new GmailJobMatchedMessageDto( item.Message.Id, item.Message.ThreadId, item.Message.Subject, item.Message.From, item.Message.To, item.Message.Date, item.Message.Snippet, item.Score, ToConfidence(item.Score), item.AlreadyImported, item.MatchedQueries, item.Reasons)).ToList()); }) .OrderByDescending(thread => thread.Score) .ThenByDescending(thread => thread.LatestDate ?? DateTimeOffset.MinValue) .ToList(); return Ok(new GmailJobMatchesResponseDto( job.Id, job.JobTitle, job.Company?.Name ?? string.Empty, job.Company?.RecruiterName, job.Company?.RecruiterEmail, queries, rankedMessages.Count, threads.Count, threads)); } [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); } [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(new GmailImportMessageResultDto(0, 1, request.MessageId, existing.ExternalThreadId, existing)); } var created = await ImportSingleMessageAsync(ownerUserId, job, request.MessageId, cancellationToken); await _db.SaveChangesAsync(cancellationToken); return Ok(new GmailImportMessageResultDto(1, 0, request.MessageId, created.ExternalThreadId, created)); } catch (Exception ex) { return BadRequest(ex.Message); } } [HttpPost("import-thread")] public async Task> ImportThread([FromBody] ImportGmailThreadRequest request, CancellationToken cancellationToken) { if (request.JobApplicationId <= 0) return BadRequest("Valid jobApplicationId is required."); if (string.IsNullOrWhiteSpace(request.ThreadId)) return BadRequest("ThreadId is required."); if (request.MessageIds is null || request.MessageIds.Length == 0) return BadRequest("At least one 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 distinctIds = request.MessageIds.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()).Distinct(StringComparer.Ordinal).ToList(); var existingIds = await _db.Correspondences .Where(x => x.JobApplicationId == request.JobApplicationId && x.ExternalMessageId != null && distinctIds.Contains(x.ExternalMessageId)) .Select(x => x.ExternalMessageId!) .ToListAsync(cancellationToken); var skipped = existingIds.Count; var imported = 0; foreach (var messageId in distinctIds) { if (existingIds.Contains(messageId, StringComparer.Ordinal)) continue; await ImportSingleMessageAsync(ownerUserId, job, messageId, cancellationToken); imported++; } await _db.SaveChangesAsync(cancellationToken); return Ok(new GmailImportResultDto(imported, skipped, request.ThreadId)); } [HttpPost("refresh-linked-threads")] public async Task> RefreshLinkedThreads([FromBody] RefreshLinkedThreadsRequest request, CancellationToken cancellationToken) { try { if (request.JobApplicationId <= 0) return BadRequest("Valid jobApplicationId is required."); var ownerUserId = GetRequiredOwnerUserId(); var job = await _db.JobApplications .Where(x => x.OwnerUserId == ownerUserId) .Include(x => x.Company) .Include(x => x.Messages) .FirstOrDefaultAsync(x => x.Id == request.JobApplicationId, cancellationToken); if (job is null) return NotFound("Job application not found."); var connection = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken); if (connection is null) return Conflict("Connect Gmail before refreshing linked threads."); var linkedThreadIds = job.Messages .Where(message => !string.IsNullOrWhiteSpace(message.ExternalThreadId)) .Select(message => message.ExternalThreadId!.Trim()) .Distinct(StringComparer.Ordinal) .ToList(); if (linkedThreadIds.Count == 0) { return Ok(new GmailThreadRefreshResultDto(job.Id, 0, 0, 0, false, DateTimeOffset.UtcNow, Array.Empty())); } var existingMessageIds = job.Messages .Where(message => !string.IsNullOrWhiteSpace(message.ExternalMessageId)) .Select(message => message.ExternalMessageId!) .ToHashSet(StringComparer.Ordinal); var refreshedThreads = new List(linkedThreadIds.Count); var imported = 0; var skipped = 0; foreach (var threadId in linkedThreadIds) { var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken); var distinctThreadMessages = threadMessages .Where(message => !string.IsNullOrWhiteSpace(message.Id)) .GroupBy(message => message.Id, StringComparer.Ordinal) .Select(group => group.First()) .OrderBy(message => message.Date ?? DateTimeOffset.MinValue) .ToList(); var threadImported = 0; var threadSkipped = 0; foreach (var message in distinctThreadMessages) { if (existingMessageIds.Contains(message.Id)) { threadSkipped++; skipped++; continue; } var created = await ImportSingleMessageAsync(ownerUserId, job, message.Id, cancellationToken); existingMessageIds.Add(created.ExternalMessageId ?? message.Id); threadImported++; imported++; } var latestMessageDate = distinctThreadMessages .Select(message => message.Date) .OrderByDescending(message => message ?? DateTimeOffset.MinValue) .FirstOrDefault(); var status = threadImported > 0 ? "imported-new-messages" : "already-current"; refreshedThreads.Add(new GmailThreadRefreshThreadDto(threadId, threadImported, threadSkipped, distinctThreadMessages.Count, status, latestMessageDate)); } await _db.SaveChangesAsync(cancellationToken); return Ok(new GmailThreadRefreshResultDto(job.Id, refreshedThreads.Count, imported, skipped, true, DateTimeOffset.UtcNow, refreshedThreads)); } catch (Exception ex) { return BadRequest(ex.Message); } } private async Task ImportSingleMessageAsync(string ownerUserId, JobApplication job, string messageId, CancellationToken cancellationToken) { var detail = await _gmail.GetMessageAsync(ownerUserId, 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 = job.Id, From = isMe ? "Me" : "Company", Subject = string.IsNullOrWhiteSpace(detail.Subject) ? null : detail.Subject.Trim(), Channel = "Email", ExternalMessageId = detail.Id, ExternalThreadId = string.IsNullOrWhiteSpace(detail.ThreadId) ? null : detail.ThreadId.Trim(), ExternalFrom = string.IsNullOrWhiteSpace(detail.From) ? null : detail.From.Trim(), ExternalTo = string.IsNullOrWhiteSpace(detail.To) ? null : detail.To.Trim(), 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 }); } return message; } private static IReadOnlyList BuildJobQueries(JobApplication job, string? queryOverride) { var queries = new List(); void Add(string? query) { if (!string.IsNullOrWhiteSpace(query)) { queries.Add(query.Trim()); } } Add(queryOverride); if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail)) { Add($"(from:{job.Company.RecruiterEmail.Trim()} OR to:{job.Company.RecruiterEmail.Trim()}) newer_than:365d"); } if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName)) { Add($"\"{job.Company.RecruiterName.Trim()}\" newer_than:365d"); } if (!string.IsNullOrWhiteSpace(job.Company?.Name) && !string.IsNullOrWhiteSpace(job.JobTitle)) { Add($"\"{job.Company.Name.Trim()}\" \"{job.JobTitle.Trim()}\" newer_than:365d"); } if (!string.IsNullOrWhiteSpace(job.Company?.Name)) { Add($"\"{job.Company.Name.Trim()}\" (application OR interview OR recruiter OR role OR position) newer_than:365d"); } if (!string.IsNullOrWhiteSpace(job.JobTitle)) { Add($"subject:\"{job.JobTitle.Trim()}\" newer_than:365d"); } foreach (var subject in job.Messages .Select(message => message.Subject) .Where(subject => !string.IsNullOrWhiteSpace(subject)) .Distinct(StringComparer.OrdinalIgnoreCase) .Take(2)) { Add($"subject:\"{subject!.Trim()}\" newer_than:365d"); } if (queries.Count == 0) { Add("newer_than:365d (application OR interview OR recruiter OR role OR position)"); } return queries.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); } private static GmailScoredMessage ScoreMessage(JobApplication job, GmailQueryMatchedMessage candidate, bool alreadyImported, bool threadAlreadyImported) { var reasons = new List(); var score = 0; var message = candidate.Message; var subject = message.Subject ?? string.Empty; var from = message.From ?? string.Empty; var to = message.To ?? string.Empty; var snippet = message.Snippet ?? string.Empty; var haystack = $"{subject} {from} {to} {snippet}"; if (candidate.MatchedQueries.Count > 0) { var queryHitPoints = Math.Min(12, candidate.MatchedQueries.Count * 4); score += queryHitPoints; reasons.Add(new GmailJobMatchReasonDto("queryHits", candidate.MatchedQueries.Count.ToString(), queryHitPoints)); } if (!string.IsNullOrWhiteSpace(job.Company?.Name) && ContainsValue(haystack, job.Company.Name)) { score += 18; reasons.Add(new GmailJobMatchReasonDto("company", job.Company.Name.Trim(), 18)); } if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail) && (ContainsValue(from, job.Company.RecruiterEmail) || ContainsValue(to, job.Company.RecruiterEmail))) { score += 20; reasons.Add(new GmailJobMatchReasonDto("recruiterEmail", job.Company.RecruiterEmail.Trim(), 20)); } if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName) && ContainsValue(haystack, job.Company.RecruiterName)) { score += 12; reasons.Add(new GmailJobMatchReasonDto("recruiter", job.Company.RecruiterName.Trim(), 12)); } foreach (var token in SplitTerms(job.JobTitle).Take(4)) { if (!ContainsValue(haystack, token)) continue; score += 5; reasons.Add(new GmailJobMatchReasonDto("jobTitle", token, 5)); } foreach (var subjectLine in job.Messages .Select(existing => existing.Subject) .Where(existing => !string.IsNullOrWhiteSpace(existing)) .Distinct(StringComparer.OrdinalIgnoreCase) .Take(2)) { if (!ContainsValue(subject, subjectLine!)) continue; score += 8; reasons.Add(new GmailJobMatchReasonDto("existingSubject", subjectLine!.Trim(), 8)); } if (message.Date is { } messageDate) { var ageDays = Math.Abs((DateTimeOffset.UtcNow - messageDate).TotalDays); if (ageDays <= 45) { score += 4; reasons.Add(new GmailJobMatchReasonDto("recency", "45d", 4)); } else if (ageDays <= 180) { score += 2; reasons.Add(new GmailJobMatchReasonDto("recency", "180d", 2)); } } if (threadAlreadyImported && !alreadyImported) { reasons.Add(new GmailJobMatchReasonDto("status", "thread-already-imported", 0)); } if (alreadyImported) { reasons.Add(new GmailJobMatchReasonDto("status", "already-imported", 0)); } reasons = reasons .GroupBy(reason => new { reason.Label, reason.Value, reason.Points }) .Select(group => group.First()) .OrderByDescending(reason => reason.Points) .ThenBy(reason => reason.Label, StringComparer.Ordinal) .ThenBy(reason => reason.Value, StringComparer.Ordinal) .ToList(); return new GmailScoredMessage(message, alreadyImported, score, candidate.MatchedQueries, reasons); } private static bool ContainsValue(string haystack, string? value) { return !string.IsNullOrWhiteSpace(value) && haystack.Contains(value.Trim(), StringComparison.OrdinalIgnoreCase); } private static IEnumerable SplitTerms(string? value) { if (string.IsNullOrWhiteSpace(value)) yield break; foreach (var token in value .Split(new[] { ' ', '/', '-', ',', '.', '(', ')', ':' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Where(token => token.Length >= 3) .Distinct(StringComparer.OrdinalIgnoreCase)) { yield return token; } } private static string ToConfidence(int score) { return score switch { >= 30 => "high", >= 16 => "medium", _ => "low" }; } 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.

"; } private sealed record GmailScoredMessage( GmailMessageSummary Message, bool AlreadyImported, int Score, IReadOnlyList MatchedQueries, IReadOnlyList Reasons); }