Complete S01 Gmail matching and import workflow

This commit is contained in:
2026-03-24 10:06:50 +01:00
parent 92ccf47446
commit 3e5f796326
13 changed files with 1043 additions and 105 deletions
@@ -47,7 +47,11 @@ namespace JobTrackerApi.Controllers
string Content,
string? Subject,
string? Channel,
DateTime? Date
DateTime? Date,
string? ExternalMessageId,
string? ExternalThreadId,
string? ExternalFrom,
string? ExternalTo
);
// POST new message
@@ -67,6 +71,10 @@ namespace JobTrackerApi.Controllers
From = request.From.Trim(),
Subject = string.IsNullOrWhiteSpace(request.Subject) ? null : request.Subject.Trim(),
Channel = string.IsNullOrWhiteSpace(request.Channel) ? null : request.Channel.Trim(),
ExternalMessageId = string.IsNullOrWhiteSpace(request.ExternalMessageId) ? null : request.ExternalMessageId.Trim(),
ExternalThreadId = string.IsNullOrWhiteSpace(request.ExternalThreadId) ? null : request.ExternalThreadId.Trim(),
ExternalFrom = string.IsNullOrWhiteSpace(request.ExternalFrom) ? null : request.ExternalFrom.Trim(),
ExternalTo = string.IsNullOrWhiteSpace(request.ExternalTo) ? null : request.ExternalTo.Trim(),
Content = request.Content,
Date = request.Date ?? DateTime.Now,
};
+290 -3
View File
@@ -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);
}
+9
View File
@@ -653,6 +653,9 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" (
EnsureColumn(conn, "Correspondences", "Subject", "ALTER TABLE Correspondences ADD COLUMN Subject TEXT NULL;");
EnsureColumn(conn, "Correspondences", "Channel", "ALTER TABLE Correspondences ADD COLUMN Channel TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE Correspondences ADD COLUMN ExternalMessageId TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE Correspondences ADD COLUMN ExternalThreadId TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE Correspondences ADD COLUMN ExternalFrom TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE Correspondences ADD COLUMN ExternalTo TEXT NULL;");
}
// Record the migration as applied.
@@ -677,6 +680,9 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" (
EnsureColumn(conn, "Correspondences", "Subject", "ALTER TABLE Correspondences ADD COLUMN Subject TEXT NULL;");
EnsureColumn(conn, "Correspondences", "Channel", "ALTER TABLE Correspondences ADD COLUMN Channel TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE Correspondences ADD COLUMN ExternalMessageId TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE Correspondences ADD COLUMN ExternalThreadId TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE Correspondences ADD COLUMN ExternalFrom TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE Correspondences ADD COLUMN ExternalTo TEXT NULL;");
EnsureColumn(conn, "Attachments", "Purpose", "ALTER TABLE Attachments ADD COLUMN Purpose TEXT NULL;");
EnsureColumn(conn, "Attachments", "UseForAi", "ALTER TABLE Attachments ADD COLUMN UseForAi INTEGER NOT NULL DEFAULT 1;");
@@ -733,6 +739,9 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" (
EnsureMySqlColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE `Companies` ADD COLUMN `OwnerUserId` varchar(255) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE `JobApplications` ADD COLUMN `OwnerUserId` varchar(255) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE `JobApplications` ADD COLUMN `LastReminderEmailSentAt` datetime NULL;");
EnsureMySqlColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalThreadId` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalFrom` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalTo` longtext NULL;");
EnsureMySqlColumn(conn, "Attachments", "Purpose", "ALTER TABLE `Attachments` ADD COLUMN `Purpose` varchar(100) NULL;");
EnsureMySqlColumn(conn, "Attachments", "UseForAi", "ALTER TABLE `Attachments` ADD COLUMN `UseForAi` tinyint(1) NOT NULL DEFAULT 1;");
EnsureMySqlColumn(conn, "AspNetUsers", "ProfileCvStructureJson", "ALTER TABLE `AspNetUsers` ADD COLUMN `ProfileCvStructureJson` longtext NULL;");
@@ -18,6 +18,7 @@ public interface IGmailOAuthService
Task<GmailConnection?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken);
Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken);
Task<IReadOnlyList<GmailMessageSummary>> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken);
Task<IReadOnlyList<GmailMessageSummary>> ListMessagesForQueriesAsync(string ownerUserId, IEnumerable<string> queries, int maxResultsPerQuery, CancellationToken cancellationToken);
Task<GmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken);
}
@@ -180,6 +181,33 @@ public sealed class GmailOAuthService : IGmailOAuthService
return results;
}
public async Task<IReadOnlyList<GmailMessageSummary>> ListMessagesForQueriesAsync(string ownerUserId, IEnumerable<string> queries, int maxResultsPerQuery, CancellationToken cancellationToken)
{
maxResultsPerQuery = Math.Clamp(maxResultsPerQuery, 1, 25);
var normalizedQueries = queries
.Where(static query => !string.IsNullOrWhiteSpace(query))
.Select(static query => query.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (normalizedQueries.Count == 0)
{
return Array.Empty<GmailMessageSummary>();
}
var combined = new List<GmailMessageSummary>();
foreach (var query in normalizedQueries)
{
var items = await ListMessagesAsync(ownerUserId, query, maxResultsPerQuery, cancellationToken);
combined.AddRange(items);
}
return combined
.GroupBy(message => message.Id, StringComparer.Ordinal)
.Select(group => group.First())
.ToList();
}
public async Task<GmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken)
{
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);