Complete S01 Gmail matching and import workflow
This commit is contained in:
@@ -25,8 +25,40 @@ public sealed class GmailController : ControllerBase
|
||||
}
|
||||
|
||||
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);
|
||||
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<GmailJobMatchReasonDto> MatchReasons);
|
||||
public sealed record GmailJobMatchedThreadDto(
|
||||
string ThreadId,
|
||||
string Subject,
|
||||
int Score,
|
||||
string Confidence,
|
||||
bool HasImportedMessages,
|
||||
int MessageCount,
|
||||
DateTimeOffset? LatestDate,
|
||||
IReadOnlyList<GmailJobMatchReasonDto> MatchReasons,
|
||||
IReadOnlyList<GmailJobMatchedMessageDto> Messages);
|
||||
public sealed record GmailJobMatchesResponseDto(
|
||||
int JobApplicationId,
|
||||
string JobTitle,
|
||||
string CompanyName,
|
||||
string? RecruiterName,
|
||||
string? RecruiterEmail,
|
||||
IReadOnlyList<string> Queries,
|
||||
IReadOnlyList<GmailJobMatchedThreadDto> Threads);
|
||||
|
||||
[HttpGet("status")]
|
||||
public async Task<IActionResult> Status(CancellationToken cancellationToken)
|
||||
@@ -50,6 +82,94 @@ public sealed class GmailController : ControllerBase
|
||||
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
|
||||
.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 messages = await _gmail.ListMessagesForQueriesAsync(ownerUserId, queries, maxResultsPerQuery, cancellationToken);
|
||||
var importedMessageIds = job.Messages
|
||||
.Where(message => !string.IsNullOrWhiteSpace(message.ExternalMessageId))
|
||||
.Select(message => message.ExternalMessageId!)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var rankedMessages = messages
|
||||
.Select(message => ScoreMessage(job, message, importedMessageIds.Contains(message.Id)))
|
||||
.Where(result => result.Score > 0)
|
||||
.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 })
|
||||
.Select(reason => reason.First())
|
||||
.Take(6)
|
||||
.ToList();
|
||||
var threadScore = ordered.Max(item => item.Score) + Math.Min(ordered.Count - 1, 2);
|
||||
var hasImportedMessages = ordered.Any(item => item.AlreadyImported);
|
||||
var representative = ordered[0].Message;
|
||||
|
||||
return new GmailJobMatchedThreadDto(
|
||||
group.Key,
|
||||
string.IsNullOrWhiteSpace(representative.Subject) ? "(no subject)" : representative.Subject,
|
||||
threadScore,
|
||||
ToConfidence(threadScore),
|
||||
hasImportedMessages,
|
||||
ordered.Count,
|
||||
latestDate,
|
||||
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.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,
|
||||
threads));
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("oauth/callback")]
|
||||
public async Task<IActionResult> Callback([FromQuery] string? code, [FromQuery] string? state, [FromQuery] string? error, CancellationToken cancellationToken)
|
||||
@@ -98,7 +218,7 @@ public sealed class GmailController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("import")]
|
||||
public async Task<IActionResult> Import([FromBody] ImportGmailMessageRequest request, CancellationToken cancellationToken)
|
||||
public async Task<ActionResult<GmailImportMessageResultDto>> Import([FromBody] ImportGmailMessageRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -114,12 +234,12 @@ public sealed class GmailController : ControllerBase
|
||||
cancellationToken);
|
||||
if (existing is not null)
|
||||
{
|
||||
return Ok(existing);
|
||||
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(created);
|
||||
return Ok(new GmailImportMessageResultDto(1, 0, request.MessageId, created.ExternalThreadId, created));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -172,6 +292,9 @@ public sealed class GmailController : ControllerBase
|
||||
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,
|
||||
};
|
||||
@@ -201,6 +324,164 @@ public sealed class GmailController : ControllerBase
|
||||
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, GmailMessageSummary message, bool alreadyImported)
|
||||
{
|
||||
var reasons = new List<GmailJobMatchReasonDto>();
|
||||
var score = 0;
|
||||
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 (!string.IsNullOrWhiteSpace(job.Company?.Name) && ContainsValue(haystack, job.Company.Name))
|
||||
{
|
||||
score += 18;
|
||||
reasons.Add(new GmailJobMatchReasonDto("company", job.Company.Name.Trim()));
|
||||
}
|
||||
|
||||
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()));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName) && ContainsValue(haystack, job.Company.RecruiterName))
|
||||
{
|
||||
score += 12;
|
||||
reasons.Add(new GmailJobMatchReasonDto("recruiter", job.Company.RecruiterName.Trim()));
|
||||
}
|
||||
|
||||
foreach (var token in SplitTerms(job.JobTitle).Take(4))
|
||||
{
|
||||
if (!ContainsValue(haystack, token)) continue;
|
||||
score += 5;
|
||||
reasons.Add(new GmailJobMatchReasonDto("jobTitle", token));
|
||||
}
|
||||
|
||||
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()));
|
||||
}
|
||||
|
||||
if (message.Date is { } messageDate)
|
||||
{
|
||||
var ageDays = Math.Abs((DateTimeOffset.UtcNow - messageDate).TotalDays);
|
||||
if (ageDays <= 45)
|
||||
{
|
||||
score += 4;
|
||||
reasons.Add(new GmailJobMatchReasonDto("recency", "45d"));
|
||||
}
|
||||
else if (ageDays <= 180)
|
||||
{
|
||||
score += 2;
|
||||
reasons.Add(new GmailJobMatchReasonDto("recency", "180d"));
|
||||
}
|
||||
}
|
||||
|
||||
if (alreadyImported)
|
||||
{
|
||||
reasons.Add(new GmailJobMatchReasonDto("status", "already-imported"));
|
||||
}
|
||||
|
||||
reasons = reasons
|
||||
.GroupBy(reason => new { reason.Label, reason.Value })
|
||||
.Select(group => group.First())
|
||||
.ToList();
|
||||
|
||||
return new GmailScoredMessage(message, alreadyImported, score, 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")
|
||||
@@ -246,4 +527,10 @@ public sealed class GmailController : ControllerBase
|
||||
</body>
|
||||
</html>";
|
||||
}
|
||||
|
||||
private sealed record GmailScoredMessage(
|
||||
GmailMessageSummary Message,
|
||||
bool AlreadyImported,
|
||||
int Score,
|
||||
IReadOnlyList<GmailJobMatchReasonDto> Reasons);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user