Complete Gmail correspondence workflow

This commit is contained in:
2026-04-02 12:29:24 +02:00
parent 1f34eb42d2
commit 5cd34f17bb
10 changed files with 1390 additions and 145 deletions
+479 -2
View File
@@ -72,8 +72,19 @@ public sealed class GmailController : ControllerBase
IReadOnlyList<GmailJobMatchedThreadDto> Threads);
public sealed record GmailReviewJobCandidateDto(int JobApplicationId, string JobTitle, string CompanyName, int Score, string Confidence, IReadOnlyList<GmailJobMatchReasonDto> Reasons);
public sealed record GmailReviewThreadDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, int MessageCount, string Routing, bool HasImportedMessages, IReadOnlyList<string> MatchedQueries, IReadOnlyList<GmailReviewJobCandidateDto> JobCandidates, IReadOnlyList<GmailJobMatchedMessageDto> Messages);
public sealed record GmailReviewThreadDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, int MessageCount, string Routing, bool HasImportedMessages, string? DecisionNote, IReadOnlyList<string> MatchedQueries, IReadOnlyList<GmailReviewJobCandidateDto> JobCandidates, IReadOnlyList<GmailJobMatchedMessageDto> Messages);
public sealed record GmailReviewQueueResponseDto(IReadOnlyList<string> Queries, int CandidateThreadCount, int AutoLinkThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, IReadOnlyList<GmailReviewThreadDto> 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<string> MatchedQueries, string Preview);
public sealed record GmailSuggestedJobsResponseDto(int Count, IReadOnlyList<GmailSuggestedJobCandidateDto> 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,
@@ -325,7 +336,7 @@ public sealed class GmailController : ControllerBase
Array.Empty<GmailJobMatchReasonDto>()))
.ToList();
return new GmailReviewThreadDto(group.Key, subject, latestDate, orderedMessages.Count, routing, hasImportedMessages, matchedQueries, jobCandidates, messages);
return new GmailReviewThreadDto(group.Key, subject, latestDate, orderedMessages.Count, routing, hasImportedMessages, existingDecision?.Note, matchedQueries, jobCandidates, messages);
})
.OrderByDescending(thread => thread.LatestDate ?? DateTimeOffset.MinValue)
.Take(100)
@@ -340,6 +351,380 @@ public sealed class GmailController : ControllerBase
groupedThreads));
}
[HttpPost("review-decision")]
public async Task<IActionResult> 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<ActionResult<GmailManualSyncResultDto>> ManualSync([FromBody] GmailManualSyncRequest? request, CancellationToken cancellationToken)
{
var ownerUserId = GetRequiredOwnerUserId();
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<string>(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<ActionResult<GmailSuggestedJobsResponseDto>> SuggestedJobs(CancellationToken cancellationToken)
{
var ownerUserId = GetRequiredOwnerUserId();
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<ActionResult<CreatedSuggestedGmailJobDto>> 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<ActionResult<GmailRelinkResultDto>> 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<ActionResult<GmailUnlinkResultDto>> 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<IActionResult> Callback([FromQuery] string? code, [FromQuery] string? state, [FromQuery] string? error, CancellationToken cancellationToken)
@@ -591,6 +976,60 @@ public sealed class GmailController : ControllerBase
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<GmailQueryMatchedMessage> 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<GmailReviewDecision> 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
@@ -601,6 +1040,44 @@ public sealed class GmailController : ControllerBase
};
}
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")