using System.Security.Claims; using System.Text.Json; 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 IGmailJobMatchingService _matching; private readonly JobTrackerContext _db; private readonly IConfiguration _cfg; public GmailController(IGmailOAuthService gmail, IGmailJobMatchingService matching, JobTrackerContext db, IConfiguration cfg) { _gmail = gmail; _matching = matching; _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); public sealed record GmailReviewJobCandidateDto(int JobApplicationId, string JobTitle, string CompanyName, int Score, string Confidence, IReadOnlyList Reasons); public sealed record GmailReviewThreadDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, int MessageCount, string Routing, bool HasImportedMessages, string? DecisionNote, IReadOnlyList MatchedQueries, IReadOnlyList JobCandidates, IReadOnlyList Messages); public sealed record GmailReviewQueueResponseDto(IReadOnlyList Queries, int CandidateThreadCount, int AutoLinkThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, IReadOnlyList Threads); public sealed record SaveGmailReviewDecisionRequest(string ThreadId, string Decision, int? JobApplicationId, string? Note); public sealed record GmailManualSyncRequest(int? LookbackDays, int? MaxResultsPerQuery, bool? AutoImportHighConfidence, bool? IncludeSpamTrash); public sealed record GmailManualSyncResultDto(int QueriesRun, int CandidateThreadCount, int AutoLinkedThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, int ImportedMessages, int ImportedThreads, int SkippedMessages, int LookbackDays, bool IncludeSpamTrash, DateTimeOffset SyncedAt); public sealed record GmailSuggestedJobCandidateDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, string? CompanyName, string? RecruiterName, string? RecruiterEmail, string? SuggestedJobTitle, string Routing, IReadOnlyList MatchedQueries, string Preview); public sealed record GmailSuggestedJobsResponseDto(int Count, IReadOnlyList Items); public sealed record CreateSuggestedGmailJobRequest(string ThreadId, string CompanyName, string JobTitle, string? RecruiterName, string? RecruiterEmail, string? Notes, string? Status); public sealed record CreatedSuggestedGmailJobDto(int JobApplicationId, int CompanyId, string ThreadId, int Imported, int Skipped); public sealed record RelinkGmailThreadRequest(int JobApplicationId, string ThreadId, bool RemoveFromOtherJobs, string? Note); public sealed record GmailRelinkResultDto(string ThreadId, int JobApplicationId, int Imported, int Skipped, int UnlinkedMessages); public sealed record UnlinkGmailThreadRequest(int JobApplicationId, string ThreadId, string? Note, string? NextDecision); public sealed record GmailUnlinkResultDto(string ThreadId, int JobApplicationId, int RemovedMessages, string Decision); public sealed record GmailConnectionStatusDto( bool Connected, string? GmailAddress, DateTimeOffset? ConnectedAt, DateTimeOffset? LastSyncedAt, DateTimeOffset? LastSyncAttemptedAt, DateTimeOffset? LastSyncSucceededAt, string? LastSyncMode, string? LastSyncSource, string? LastSyncStatus, string? LastSyncError); [HttpGet("status")] public async Task> Status(CancellationToken cancellationToken) { var ownerUserId = GetRequiredOwnerUserId(); var connection = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken); return Ok(new GmailConnectionStatusDto( connection is not null, connection?.GmailAddress, connection?.ConnectedAt, connection?.LastSyncedAt, connection?.LastSyncAttemptedAt, connection?.LastSyncSucceededAt, connection?.LastSyncMode, connection?.LastSyncSource, connection?.LastSyncStatus, connection?.LastSyncError)); } [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 => _matching.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) .Select(reason => new GmailJobMatchReasonDto(reason.Label, reason.Value, reason.Points)) .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.Select(reason => new GmailJobMatchReasonDto(reason.Label, reason.Value, reason.Points)).ToList())).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)); } [HttpGet("review-candidates")] public async Task> ReviewCandidates( [FromQuery] string? queryOverride, [FromQuery] int maxResultsPerQuery = 6, CancellationToken cancellationToken = default) { var ownerUserId = GetRequiredOwnerUserId(); if (await GetOwnerGmailConnectionAsync(ownerUserId, cancellationToken) is null) { return GmailNotConnectedResult(); } var jobs = await _db.JobApplications .Where(x => x.OwnerUserId == ownerUserId && !x.IsDeleted) .Include(x => x.Company) .Include(x => x.Messages) .OrderByDescending(x => x.DateApplied) .Take(100) .ToListAsync(cancellationToken); if (jobs.Count == 0) { return Ok(new GmailReviewQueueResponseDto(Array.Empty(), 0, 0, 0, 0, Array.Empty())); } var querySet = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var job in jobs) { foreach (var query in _matching.BuildJobQueries(job, queryOverride)) { querySet.Add(query); } } var queries = querySet.Take(18).ToList(); var candidateMessages = await _gmail.ListJobCandidateMessagesAsync(ownerUserId, queries, maxResultsPerQuery, cancellationToken); var allImportedMessageIds = jobs.SelectMany(job => job.Messages) .Where(message => !string.IsNullOrWhiteSpace(message.ExternalMessageId)) .Select(message => message.ExternalMessageId!) .ToHashSet(StringComparer.Ordinal); var allImportedThreadIds = jobs.SelectMany(job => job.Messages) .Where(message => !string.IsNullOrWhiteSpace(message.ExternalThreadId)) .Select(message => message.ExternalThreadId!) .ToHashSet(StringComparer.Ordinal); var reviewDecisions = await _db.GmailReviewDecisions .AsNoTracking() .Where(decision => decision.OwnerUserId == ownerUserId) .ToListAsync(cancellationToken); var groupedThreads = candidateMessages .GroupBy(message => string.IsNullOrWhiteSpace(message.Message.ThreadId) ? message.Message.Id : message.Message.ThreadId, StringComparer.Ordinal) .Select(group => { var existingDecision = reviewDecisions.FirstOrDefault(x => x.ThreadId == group.Key); var orderedMessages = group.OrderByDescending(item => item.Message.Date ?? DateTimeOffset.MinValue).ToList(); var latestDate = orderedMessages.Max(item => item.Message.Date ?? DateTimeOffset.MinValue); var subject = orderedMessages.FirstOrDefault(item => !string.IsNullOrWhiteSpace(item.Message.Subject))?.Message.Subject ?? "(no subject)"; var matchedQueries = orderedMessages.SelectMany(item => item.MatchedQueries).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList(); var hasImportedMessages = orderedMessages.Any(item => allImportedMessageIds.Contains(item.Message.Id) || allImportedThreadIds.Contains(item.Message.ThreadId)); var jobCandidates = jobs .Select(job => { var best = orderedMessages .Select(item => _matching.ScoreMessage(job, item, allImportedMessageIds.Contains(item.Message.Id), allImportedThreadIds.Contains(item.Message.ThreadId))) .OrderByDescending(score => score.Score) .First(); return new GmailReviewJobCandidateDto( job.Id, job.JobTitle, job.Company?.Name ?? string.Empty, best.Score, best.Confidence, best.Reasons.Select(reason => new GmailJobMatchReasonDto(reason.Label, reason.Value, reason.Points)).ToList()); }) .Where(candidate => candidate.Score > 0) .OrderByDescending(candidate => candidate.Score) .Take(3) .ToList(); var topScore = jobCandidates.FirstOrDefault()?.Score ?? 0; var secondScore = jobCandidates.Skip(1).FirstOrDefault()?.Score ?? 0; var routing = existingDecision?.Decision switch { "linked" => "linked", "rejected" => "rejected", "suggested" => "suggested", _ => topScore >= 30 && topScore - secondScore >= 8 ? "auto-link" : topScore >= 16 ? "review" : "unmatched" }; var messages = orderedMessages .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.MatchedQueries.Count * 4, item.MatchedQueries.Count >= 2 ? "medium" : "low", allImportedMessageIds.Contains(item.Message.Id), item.MatchedQueries, Array.Empty())) .ToList(); return new GmailReviewThreadDto(group.Key, subject, latestDate, orderedMessages.Count, routing, hasImportedMessages, existingDecision?.Note, matchedQueries, jobCandidates, messages); }) .OrderByDescending(thread => thread.LatestDate ?? DateTimeOffset.MinValue) .Take(100) .ToList(); return Ok(new GmailReviewQueueResponseDto( queries, groupedThreads.Count, groupedThreads.Count(thread => thread.Routing == "auto-link"), groupedThreads.Count(thread => thread.Routing == "review"), groupedThreads.Count(thread => thread.Routing == "unmatched"), groupedThreads)); } [HttpPost("review-decision")] public async Task SaveReviewDecision([FromBody] SaveGmailReviewDecisionRequest request, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(request.ThreadId)) return BadRequest("ThreadId is required."); var decision = (request.Decision ?? string.Empty).Trim().ToLowerInvariant(); if (decision is not ("linked" or "rejected" or "review" or "suggested")) { return BadRequest("Decision must be linked, rejected, review, or suggested."); } var ownerUserId = GetRequiredOwnerUserId(); JobApplication? job = null; if (decision == "linked") { if (request.JobApplicationId is null or <= 0) return BadRequest("jobApplicationId is required when linking a thread."); job = await _db.JobApplications .Where(x => x.OwnerUserId == ownerUserId) .Include(x => x.Company) .Include(x => x.Messages) .FirstOrDefaultAsync(x => x.Id == request.JobApplicationId.Value, cancellationToken); if (job is null) return NotFound("Job application not found."); var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, request.ThreadId.Trim(), cancellationToken); var distinctMessageIds = threadMessages .Where(message => !string.IsNullOrWhiteSpace(message.Id)) .Select(message => message.Id) .Distinct(StringComparer.Ordinal) .ToList(); var existingMessageIds = await _db.Correspondences .Where(message => message.JobApplicationId == job.Id && message.ExternalMessageId != null && distinctMessageIds.Contains(message.ExternalMessageId)) .Select(message => message.ExternalMessageId!) .ToListAsync(cancellationToken); foreach (var messageId in distinctMessageIds) { if (existingMessageIds.Contains(messageId, StringComparer.Ordinal)) continue; await ImportSingleMessageAsync(ownerUserId, job, messageId, cancellationToken); } } var existing = await _db.GmailReviewDecisions.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId && x.ThreadId == request.ThreadId.Trim(), cancellationToken); if (existing is null) { existing = new GmailReviewDecision { OwnerUserId = ownerUserId, ThreadId = request.ThreadId.Trim(), }; _db.GmailReviewDecisions.Add(existing); } existing.Decision = decision switch { "review" => "review", _ => decision, }; existing.JobApplicationId = decision == "linked" ? request.JobApplicationId : null; existing.Note = string.IsNullOrWhiteSpace(request.Note) ? null : request.Note.Trim(); existing.UpdatedAt = DateTimeOffset.UtcNow; await _db.SaveChangesAsync(cancellationToken); return Ok(new { existing.ThreadId, existing.Decision, existing.JobApplicationId, existing.Note, existing.UpdatedAt, }); } [HttpPost("manual-sync")] public async Task> ManualSync([FromBody] GmailManualSyncRequest? request, CancellationToken cancellationToken) { var ownerUserId = GetRequiredOwnerUserId(); if (await GetOwnerGmailConnectionAsync(ownerUserId, cancellationToken) is null) { return GmailNotConnectedResult(); } var jobs = await _db.JobApplications .Where(x => x.OwnerUserId == ownerUserId && !x.IsDeleted) .Include(x => x.Company) .Include(x => x.Messages) .OrderByDescending(x => x.DateApplied) .Take(100) .ToListAsync(cancellationToken); if (jobs.Count == 0) { return Ok(new GmailManualSyncResultDto(0, 0, 0, 0, 0, 0, 0, 0, 365, false, DateTimeOffset.UtcNow)); } var lookbackDays = Math.Clamp(request?.LookbackDays ?? 365, 30, 365); var maxResultsPerQuery = Math.Clamp(request?.MaxResultsPerQuery ?? 8, 1, 25); var includeSpamTrash = request?.IncludeSpamTrash ?? false; var autoImportHighConfidence = request?.AutoImportHighConfidence ?? true; var querySet = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var jobItem in jobs) { foreach (var query in _matching.BuildJobQueries(jobItem, null)) { var bounded = ApplySyncBoundary(query, lookbackDays, includeSpamTrash); querySet.Add(bounded); } } var queries = querySet.Take(24).ToList(); var candidateMessages = await _gmail.ListJobCandidateMessagesAsync(ownerUserId, queries, maxResultsPerQuery, cancellationToken); var allImportedMessageIds = jobs.SelectMany(jobItem => jobItem.Messages) .Where(message => !string.IsNullOrWhiteSpace(message.ExternalMessageId)) .Select(message => message.ExternalMessageId!) .ToHashSet(StringComparer.Ordinal); var allImportedThreadIds = jobs.SelectMany(jobItem => jobItem.Messages) .Where(message => !string.IsNullOrWhiteSpace(message.ExternalThreadId)) .Select(message => message.ExternalThreadId!) .ToHashSet(StringComparer.Ordinal); var reviewDecisions = await _db.GmailReviewDecisions .Where(decision => decision.OwnerUserId == ownerUserId) .ToListAsync(cancellationToken); var groupedThreads = candidateMessages .GroupBy(message => string.IsNullOrWhiteSpace(message.Message.ThreadId) ? message.Message.Id : message.Message.ThreadId, StringComparer.Ordinal) .ToList(); var autoLinked = 0; var reviewCount = 0; var unmatchedCount = 0; var importedMessages = 0; var importedThreads = 0; var skippedMessages = 0; foreach (var threadGroup in groupedThreads) { var threadId = threadGroup.Key; var existingDecision = reviewDecisions.FirstOrDefault(x => x.ThreadId == threadId); if (string.Equals(existingDecision?.Decision, "rejected", StringComparison.OrdinalIgnoreCase)) { unmatchedCount++; continue; } var orderedMessages = threadGroup.OrderByDescending(item => item.Message.Date ?? DateTimeOffset.MinValue).ToList(); var candidates = jobs .Select(jobItem => { var best = orderedMessages .Select(item => _matching.ScoreMessage(jobItem, item, allImportedMessageIds.Contains(item.Message.Id), allImportedThreadIds.Contains(item.Message.ThreadId))) .OrderByDescending(score => score.Score) .First(); return new { Job = jobItem, Best = best }; }) .Where(x => x.Best.Score > 0) .OrderByDescending(x => x.Best.Score) .Take(3) .ToList(); var top = candidates.FirstOrDefault(); var secondScore = candidates.Skip(1).FirstOrDefault()?.Best.Score ?? 0; if (top is not null && autoImportHighConfidence && top.Best.Score >= 30 && top.Best.Score - secondScore >= 8) { var distinctMessageIds = orderedMessages.Select(item => item.Message.Id).Distinct(StringComparer.Ordinal).ToList(); var existingIds = await _db.Correspondences .Where(message => message.JobApplicationId == top.Job.Id && message.ExternalMessageId != null && distinctMessageIds.Contains(message.ExternalMessageId)) .Select(message => message.ExternalMessageId!) .ToListAsync(cancellationToken); foreach (var messageId in distinctMessageIds) { if (existingIds.Contains(messageId, StringComparer.Ordinal)) { skippedMessages++; continue; } await ImportSingleMessageAsync(ownerUserId, top.Job, messageId, cancellationToken); allImportedMessageIds.Add(messageId); importedMessages++; } importedThreads++; autoLinked++; UpsertReviewDecision(reviewDecisions, ownerUserId, threadId, "linked", top.Job.Id, existingDecision?.Note); continue; } if (top is not null && top.Best.Score >= 16) { reviewCount++; UpsertReviewDecision(reviewDecisions, ownerUserId, threadId, existingDecision?.Decision == "suggested" ? "suggested" : "review", null, existingDecision?.Note); continue; } unmatchedCount++; if (LooksLikeJobRelatedThread(orderedMessages)) { UpsertReviewDecision(reviewDecisions, ownerUserId, threadId, "suggested", null, existingDecision?.Note); } } await _db.SaveChangesAsync(cancellationToken); return Ok(new GmailManualSyncResultDto(queries.Count, groupedThreads.Count, autoLinked, reviewCount, unmatchedCount, importedMessages, importedThreads, skippedMessages, lookbackDays, includeSpamTrash, DateTimeOffset.UtcNow)); } [HttpGet("suggested-jobs")] public async Task> SuggestedJobs(CancellationToken cancellationToken) { var ownerUserId = GetRequiredOwnerUserId(); if (await GetOwnerGmailConnectionAsync(ownerUserId, cancellationToken) is null) { return GmailNotConnectedResult(); } var reviewThreads = await ReviewCandidates(null, 6, cancellationToken); if (reviewThreads.Result is not OkObjectResult ok || ok.Value is not GmailReviewQueueResponseDto payload) { return BadRequest("Unable to compute Gmail suggested jobs."); } var items = payload.Threads .Where(thread => thread.Routing is "unmatched" or "suggested") .Select(thread => new GmailSuggestedJobCandidateDto( thread.ThreadId, thread.Subject, thread.LatestDate, ExtractCompanyName(thread.Messages.FirstOrDefault()?.From, thread.Subject), ExtractRecruiterName(thread.Messages.FirstOrDefault()?.From), ExtractFirstEmail(thread.Messages.FirstOrDefault()?.From), ExtractRoleFromSubject(thread.Subject), thread.Routing, thread.MatchedQueries, thread.Messages.FirstOrDefault()?.Snippet ?? string.Empty)) .Where(item => !string.IsNullOrWhiteSpace(item.CompanyName) || !string.IsNullOrWhiteSpace(item.SuggestedJobTitle)) .Take(50) .ToList(); return Ok(new GmailSuggestedJobsResponseDto(items.Count, items)); } [HttpPost("create-suggested-job")] public async Task> CreateSuggestedJob([FromBody] CreateSuggestedGmailJobRequest request, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(request.ThreadId)) return BadRequest("ThreadId is required."); if (string.IsNullOrWhiteSpace(request.CompanyName)) return BadRequest("CompanyName is required."); if (string.IsNullOrWhiteSpace(request.JobTitle)) return BadRequest("JobTitle is required."); var ownerUserId = GetRequiredOwnerUserId(); var companyName = request.CompanyName.Trim(); var jobTitle = request.JobTitle.Trim(); var company = await _db.Companies.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId && x.Name.ToLower() == companyName.ToLower(), cancellationToken); if (company is null) { company = new Company { OwnerUserId = ownerUserId, Name = companyName, RecruiterName = string.IsNullOrWhiteSpace(request.RecruiterName) ? null : request.RecruiterName.Trim(), RecruiterEmail = string.IsNullOrWhiteSpace(request.RecruiterEmail) ? null : request.RecruiterEmail.Trim(), }; _db.Companies.Add(company); await _db.SaveChangesAsync(cancellationToken); } else { if (string.IsNullOrWhiteSpace(company.RecruiterName) && !string.IsNullOrWhiteSpace(request.RecruiterName)) company.RecruiterName = request.RecruiterName.Trim(); if (string.IsNullOrWhiteSpace(company.RecruiterEmail) && !string.IsNullOrWhiteSpace(request.RecruiterEmail)) company.RecruiterEmail = request.RecruiterEmail.Trim(); } var job = new JobApplication { OwnerUserId = ownerUserId, CompanyId = company.Id, JobTitle = jobTitle, Status = string.IsNullOrWhiteSpace(request.Status) ? "Applied" : request.Status.Trim(), Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(), DateApplied = DateTime.UtcNow, }; _db.JobApplications.Add(job); await _db.SaveChangesAsync(cancellationToken); var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, request.ThreadId.Trim(), cancellationToken); var distinctMessageIds = threadMessages.Select(message => message.Id).Where(static id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).ToList(); var imported = 0; var skipped = 0; foreach (var messageId in distinctMessageIds) { var existing = await _db.Correspondences.AnyAsync(message => message.JobApplicationId == job.Id && message.ExternalMessageId == messageId, cancellationToken); if (existing) { skipped++; continue; } await ImportSingleMessageAsync(ownerUserId, job, messageId, cancellationToken); imported++; } UpsertReviewDecision(await _db.GmailReviewDecisions.Where(x => x.OwnerUserId == ownerUserId).ToListAsync(cancellationToken), ownerUserId, request.ThreadId.Trim(), "linked", job.Id, request.Notes); await _db.SaveChangesAsync(cancellationToken); return Ok(new CreatedSuggestedGmailJobDto(job.Id, company.Id, request.ThreadId.Trim(), imported, skipped)); } [HttpPost("relink-thread")] public async Task> RelinkThread([FromBody] RelinkGmailThreadRequest request, CancellationToken cancellationToken) { if (request.JobApplicationId <= 0) return BadRequest("Valid jobApplicationId is required."); if (string.IsNullOrWhiteSpace(request.ThreadId)) return BadRequest("ThreadId 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 threadId = request.ThreadId.Trim(); var unlinkedMessages = 0; if (request.RemoveFromOtherJobs) { var otherMessages = await _db.Correspondences .Include(message => message.JobApplication) .Where(message => message.ExternalThreadId == threadId && message.JobApplicationId != job.Id && message.JobApplication.OwnerUserId == ownerUserId) .ToListAsync(cancellationToken); if (otherMessages.Count > 0) { _db.Correspondences.RemoveRange(otherMessages); unlinkedMessages = otherMessages.Count; } } var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken); var distinctMessageIds = threadMessages.Select(message => message.Id).Where(static id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).ToList(); var existingMessageIds = await _db.Correspondences .Where(message => message.JobApplicationId == job.Id && message.ExternalMessageId != null && distinctMessageIds.Contains(message.ExternalMessageId)) .Select(message => message.ExternalMessageId!) .ToListAsync(cancellationToken); var imported = 0; var skipped = 0; foreach (var messageId in distinctMessageIds) { if (existingMessageIds.Contains(messageId, StringComparer.Ordinal)) { skipped++; continue; } await ImportSingleMessageAsync(ownerUserId, job, messageId, cancellationToken); imported++; } var reviewDecisions = await _db.GmailReviewDecisions.Where(x => x.OwnerUserId == ownerUserId).ToListAsync(cancellationToken); UpsertReviewDecision(reviewDecisions, ownerUserId, threadId, "linked", job.Id, request.Note); await _db.SaveChangesAsync(cancellationToken); return Ok(new GmailRelinkResultDto(threadId, job.Id, imported, skipped, unlinkedMessages)); } [HttpPost("unlink-thread")] public async Task> UnlinkThread([FromBody] UnlinkGmailThreadRequest request, CancellationToken cancellationToken) { if (request.JobApplicationId <= 0) return BadRequest("Valid jobApplicationId is required."); if (string.IsNullOrWhiteSpace(request.ThreadId)) return BadRequest("ThreadId is required."); var ownerUserId = GetRequiredOwnerUserId(); var job = await _db.JobApplications .Where(x => x.OwnerUserId == ownerUserId) .FirstOrDefaultAsync(x => x.Id == request.JobApplicationId, cancellationToken); if (job is null) return NotFound("Job application not found."); var threadId = request.ThreadId.Trim(); var messages = await _db.Correspondences .Where(message => message.JobApplicationId == job.Id && message.ExternalThreadId == threadId) .ToListAsync(cancellationToken); if (messages.Count > 0) { _db.Correspondences.RemoveRange(messages); } var reviewDecisions = await _db.GmailReviewDecisions.Where(x => x.OwnerUserId == ownerUserId).ToListAsync(cancellationToken); var nextDecision = (request.NextDecision ?? "review").Trim().ToLowerInvariant(); if (nextDecision is not ("review" or "suggested" or "rejected")) nextDecision = "review"; UpsertReviewDecision(reviewDecisions, ownerUserId, threadId, nextDecision, null, request.Note); await _db.SaveChangesAsync(cancellationToken); return Ok(new GmailUnlinkResultDto(threadId, job.Id, messages.Count, nextDecision)); } [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", Direction = isMe ? "outbound" : "inbound", 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(), ExternalLabelsJson = detail.Labels.Count == 0 ? null : JsonSerializer.Serialize(detail.Labels), AttachmentMetadataJson = detail.Attachments.Count == 0 ? null : JsonSerializer.Serialize(detail.Attachments.Select(attachment => new CorrespondenceAttachmentMetadata { FileName = attachment.FileName, MimeType = attachment.MimeType, SizeBytes = attachment.SizeBytes, GmailAttachmentId = attachment.GmailAttachmentId, Inline = attachment.Inline, })), 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 IReadOnlyList BuildJobQueries(JobApplication job, string? queryOverride) { return _matching.BuildJobQueries(job, queryOverride); } private static string ApplySyncBoundary(string query, int lookbackDays, bool includeSpamTrash) { var bounded = (query ?? string.Empty).Trim(); if (!bounded.Contains("newer_than:", StringComparison.OrdinalIgnoreCase)) { bounded = string.IsNullOrWhiteSpace(bounded) ? $"newer_than:{lookbackDays}d" : $"{bounded} newer_than:{lookbackDays}d"; } if (!includeSpamTrash) { if (!bounded.Contains("in:spam", StringComparison.OrdinalIgnoreCase)) bounded += " -in:spam"; if (!bounded.Contains("in:trash", StringComparison.OrdinalIgnoreCase)) bounded += " -in:trash"; } return bounded.Trim(); } private static bool LooksLikeJobRelatedThread(IReadOnlyList orderedMessages) { var sample = string.Join("\n", orderedMessages.Select(item => string.Join(" ", new[] { item.Message.Subject, item.Message.From, item.Message.Snippet }.Where(value => !string.IsNullOrWhiteSpace(value))))); if (string.IsNullOrWhiteSpace(sample)) return false; return sample.Contains("interview", StringComparison.OrdinalIgnoreCase) || sample.Contains("application", StringComparison.OrdinalIgnoreCase) || sample.Contains("recruit", StringComparison.OrdinalIgnoreCase) || sample.Contains("role", StringComparison.OrdinalIgnoreCase) || sample.Contains("position", StringComparison.OrdinalIgnoreCase) || sample.Contains("offer", StringComparison.OrdinalIgnoreCase) || sample.Contains("follow up", StringComparison.OrdinalIgnoreCase) || sample.Contains("follow-up", StringComparison.OrdinalIgnoreCase) || sample.Contains("rejection", StringComparison.OrdinalIgnoreCase); } private void UpsertReviewDecision(List decisions, string ownerUserId, string threadId, string decision, int? jobApplicationId, string? note) { var existing = decisions.FirstOrDefault(x => x.ThreadId == threadId); if (existing is null) { existing = new GmailReviewDecision { OwnerUserId = ownerUserId, ThreadId = threadId, }; decisions.Add(existing); _db.GmailReviewDecisions.Add(existing); } existing.Decision = decision; existing.JobApplicationId = jobApplicationId; if (!string.IsNullOrWhiteSpace(note)) existing.Note = note.Trim(); existing.UpdatedAt = DateTimeOffset.UtcNow; } private static string ToConfidence(int score) { return score switch { >= 30 => "high", >= 16 => "medium", _ => "low" }; } private static string? ExtractFirstEmail(string? value) { if (string.IsNullOrWhiteSpace(value)) return null; var match = System.Text.RegularExpressions.Regex.Match(value, @"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", System.Text.RegularExpressions.RegexOptions.IgnoreCase); return match.Success ? match.Value : null; } private static string? ExtractRecruiterName(string? value) { if (string.IsNullOrWhiteSpace(value)) return null; var trimmed = value.Split('<')[0].Trim().Trim('"'); return string.IsNullOrWhiteSpace(trimmed) || trimmed.Contains('@') ? null : trimmed; } private static string? ExtractCompanyName(string? from, string? subject) { var subjectText = (subject ?? string.Empty).Trim(); if (!string.IsNullOrWhiteSpace(subjectText)) { var parts = subjectText.Split(new[] { '-', '–', '|' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (parts.Length >= 2) return parts[0]; } var recruiterName = ExtractRecruiterName(from); return recruiterName is { Length: > 0 } && recruiterName.Contains(' ') ? recruiterName.Split(' ').Last() : null; } private static string? ExtractRoleFromSubject(string? subject) { if (string.IsNullOrWhiteSpace(subject)) return null; var trimmed = subject.Trim(); if (trimmed.Contains("interview", StringComparison.OrdinalIgnoreCase)) { return trimmed.Replace("interview", string.Empty, StringComparison.OrdinalIgnoreCase).Trim(' ', '-', ':'); } return trimmed.Length <= 120 ? trimmed : trimmed[..120]; } private string GetRequiredOwnerUserId() { return User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") ?? throw new InvalidOperationException("Authenticated user id is missing."); } private async Task GetOwnerGmailConnectionAsync(string ownerUserId, CancellationToken cancellationToken) { return await _gmail.GetConnectionAsync(ownerUserId, cancellationToken); } private ActionResult GmailNotConnectedResult() { return Conflict(new { code = "gmail_not_connected", message = "Connect Gmail before using the Gmail review queue.", }); } 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.

"; } }