955cae6d4b
- JobTrackerApi/Controllers/GmailController.cs - JobTrackerApi/Services/GmailOAuthService.cs - JobTrackerApi.Tests/GmailControllerTests.cs - .gsd/milestones/M001/slices/S01/S01-PLAN.md - .gsd/KNOWLEDGE.md
578 lines
24 KiB
C#
578 lines
24 KiB
C#
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 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<string> MatchedQueries,
|
|
IReadOnlyList<GmailJobMatchReasonDto> MatchReasons);
|
|
public sealed record GmailJobMatchedThreadDto(
|
|
string ThreadId,
|
|
string Subject,
|
|
int Score,
|
|
string Confidence,
|
|
bool HasImportedMessages,
|
|
int ImportedMessageCount,
|
|
int MessageCount,
|
|
DateTimeOffset? LatestDate,
|
|
IReadOnlyList<string> MatchedQueries,
|
|
IReadOnlyList<GmailJobMatchReasonDto> MatchReasons,
|
|
IReadOnlyList<GmailJobMatchedMessageDto> Messages);
|
|
public sealed record GmailJobMatchesResponseDto(
|
|
int JobApplicationId,
|
|
string JobTitle,
|
|
string CompanyName,
|
|
string? RecruiterName,
|
|
string? RecruiterEmail,
|
|
IReadOnlyList<string> Queries,
|
|
int CandidateMessageCount,
|
|
int CandidateThreadCount,
|
|
IReadOnlyList<GmailJobMatchedThreadDto> Threads);
|
|
|
|
[HttpGet("status")]
|
|
public async Task<IActionResult> 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<ActionResult<GmailJobMatchesResponseDto>> 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<IActionResult> 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<IActionResult> Disconnect(CancellationToken cancellationToken)
|
|
{
|
|
var ownerUserId = GetRequiredOwnerUserId();
|
|
await _gmail.DisconnectAsync(ownerUserId, cancellationToken);
|
|
return NoContent();
|
|
}
|
|
|
|
[HttpGet("messages")]
|
|
public async Task<IActionResult> 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<ActionResult<GmailImportMessageResultDto>> 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<ActionResult<GmailImportResultDto>> 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));
|
|
}
|
|
|
|
private async Task<Correspondence> 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<string> BuildJobQueries(JobApplication job, string? queryOverride)
|
|
{
|
|
var queries = new List<string>();
|
|
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<GmailJobMatchReasonDto>();
|
|
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<string> 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 $@"<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset=""utf-8"" />
|
|
<title>Gmail connection</title>
|
|
</head>
|
|
<body style=""font-family:Segoe UI,Arial,sans-serif;padding:24px;line-height:1.5;"">
|
|
<h2>{title}</h2>
|
|
<p>{escaped}</p>
|
|
<p>You can close this window.</p>
|
|
<script>
|
|
if (window.opener) {{
|
|
window.opener.postMessage({{ source: 'jobtracker-gmail-oauth', status: '{status}', message: {serializedMessage} }}, '*');
|
|
}}
|
|
window.close();
|
|
</script>
|
|
</body>
|
|
</html>";
|
|
}
|
|
|
|
private sealed record GmailScoredMessage(
|
|
GmailMessageSummary Message,
|
|
bool AlreadyImported,
|
|
int Score,
|
|
IReadOnlyList<string> MatchedQueries,
|
|
IReadOnlyList<GmailJobMatchReasonDto> Reasons);
|
|
}
|