ab79072e52
Backlog item 3 (Wave 2), GmailController slice. Pure mechanical extraction, no behaviour change: - GmailDtos.cs: the 26 inline record DTOs, moved to a partial-class file so every existing GmailController.XyzDto reference (tests included) keeps working unchanged. - GmailParsing.cs: the 8 pure static helpers (ApplySyncBoundary, LooksLikeJobRelatedThread, ToConfidence, ExtractFirstEmail/RecruiterName/ CompanyName/RoleFromSubject, BuildPopupHtml), same partial-class approach. GmailController.cs: 1200 -> 1022 lines. 169/169 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
117 lines
4.8 KiB
C#
117 lines
4.8 KiB
C#
using JobTrackerApi.Services;
|
||
|
||
namespace JobTrackerApi.Controllers;
|
||
|
||
// Pure parsing/formatting helpers for GmailController, split out for readability (Wave 2 safe
|
||
// refactor -- no behaviour change). All are static and side-effect free.
|
||
public sealed partial class GmailController
|
||
{
|
||
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 static string ToConfidence(int score)
|
||
{
|
||
return score switch
|
||
{
|
||
>= 30 => "high",
|
||
>= 16 => "medium",
|
||
_ => "low"
|
||
};
|
||
}
|
||
|
||
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 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>";
|
||
}
|
||
}
|