using JobTrackerApi.Controllers; using JobTrackerApi.Models; namespace JobTrackerApi.Services; public sealed record GmailMatchReason(string Label, string Value, int Points); public sealed record GmailScoredMessageResult( GmailMessageSummary Message, bool AlreadyImported, int Score, string Confidence, IReadOnlyList MatchedQueries, IReadOnlyList Reasons); public interface IGmailJobMatchingService { IReadOnlyList BuildJobQueries(JobApplication job, string? queryOverride); GmailScoredMessageResult ScoreMessage(JobApplication job, GmailQueryMatchedMessage candidate, bool alreadyImported, bool threadAlreadyImported); } public sealed class GmailJobMatchingService : IGmailJobMatchingService { public 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(); } public GmailScoredMessageResult 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 GmailMatchReason("queryHits", candidate.MatchedQueries.Count.ToString(), queryHitPoints)); } if (!string.IsNullOrWhiteSpace(job.Company?.Name) && ContainsValue(haystack, job.Company.Name)) { score += 18; reasons.Add(new GmailMatchReason("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 GmailMatchReason("recruiterEmail", job.Company.RecruiterEmail.Trim(), 20)); } if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName) && ContainsValue(haystack, job.Company.RecruiterName)) { score += 12; reasons.Add(new GmailMatchReason("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 GmailMatchReason("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 GmailMatchReason("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 GmailMatchReason("recency", "45d", 4)); } else if (ageDays <= 180) { score += 2; reasons.Add(new GmailMatchReason("recency", "180d", 2)); } } if (threadAlreadyImported && !alreadyImported) reasons.Add(new GmailMatchReason("status", "thread-already-imported", 0)); if (alreadyImported) reasons.Add(new GmailMatchReason("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 GmailScoredMessageResult(message, alreadyImported, score, ToConfidence(score), candidate.MatchedQueries, reasons); } private static bool ContainsValue(string haystack, string? value) => !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) => score switch { >= 30 => "high", >= 16 => "medium", _ => "low" }; }