Add OAth flow for Gmail and update tables and UI

This commit is contained in:
cesnimda
2026-03-21 14:02:19 +01:00
parent 51a539068f
commit ed68e44eaf
17 changed files with 1180 additions and 53 deletions
@@ -20,6 +20,29 @@ namespace JobTrackerApi.Controllers
private string? CurrentUserId =>
User?.FindFirstValue(ClaimTypes.NameIdentifier) ?? User?.FindFirstValue("sub");
private static string? NormalizeSource(string? source)
{
if (string.IsNullOrWhiteSpace(source)) return null;
var value = source.Trim();
if (Uri.TryCreate(value, UriKind.Absolute, out var uri) && !string.IsNullOrWhiteSpace(uri.Host))
{
value = uri.Host;
}
value = value.Replace("www.", "", StringComparison.OrdinalIgnoreCase).Trim().Trim('/');
var lower = value.ToLowerInvariant();
return lower switch
{
"linkedin" or "linkedin.com" => "LinkedIn",
"finn" or "finn.no" => "Finn",
"nav" or "nav.no" => "NAV",
"jobbnorge" or "jobbnorge.no" => "Jobbnorge",
_ => string.Join(" ", value.Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries).Select(part => char.ToUpperInvariant(part[0]) + part[1..].ToLowerInvariant()))
};
}
[HttpGet]
public async Task<ActionResult<List<Company>>> GetAll(CancellationToken cancellationToken)
{
@@ -86,7 +109,7 @@ namespace JobTrackerApi.Controllers
OwnerUserId = string.IsNullOrWhiteSpace(userId) ? null : userId,
Name = name,
Location = string.IsNullOrWhiteSpace(request.Location) ? null : request.Location.Trim(),
Source = string.IsNullOrWhiteSpace(request.Source) ? null : request.Source.Trim(),
Source = NormalizeSource(request.Source),
};
_db.Companies.Add(company);
@@ -111,7 +134,7 @@ namespace JobTrackerApi.Controllers
company.Name = name;
company.Location = string.IsNullOrWhiteSpace(request.Location) ? null : request.Location.Trim();
company.Source = string.IsNullOrWhiteSpace(request.Source) ? null : request.Source.Trim();
company.Source = NormalizeSource(request.Source);
company.RecruiterName = string.IsNullOrWhiteSpace(request.RecruiterName) ? null : request.RecruiterName.Trim();
company.RecruiterEmail = string.IsNullOrWhiteSpace(request.RecruiterEmail) ? null : request.RecruiterEmail.Trim();
@@ -0,0 +1,187 @@
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;
}
[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 });
}
[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);
}
public sealed record ImportGmailMessageRequest(int JobApplicationId, string MessageId);
[HttpPost("import")]
public async Task<IActionResult> Import([FromBody] ImportGmailMessageRequest request, CancellationToken cancellationToken)
{
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(existing);
}
var detail = await _gmail.GetMessageAsync(ownerUserId, request.MessageId, cancellationToken);
var me = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken);
var gmailAddress = me?.GmailAddress ?? string.Empty;
var isMe = detail.From.Contains(gmailAddress, StringComparison.OrdinalIgnoreCase);
var message = new Correspondence
{
JobApplicationId = request.JobApplicationId,
From = isMe ? "Me" : "Company",
Subject = string.IsNullOrWhiteSpace(detail.Subject) ? null : detail.Subject.Trim(),
Channel = "Email",
ExternalMessageId = detail.Id,
Content = string.IsNullOrWhiteSpace(detail.BodyText) ? detail.Snippet : detail.BodyText,
Date = detail.Date?.LocalDateTime ?? DateTime.Now,
};
_db.Correspondences.Add(message);
if (job.Company is not null)
{
job.Company.LastContactedAt = DateTime.UtcNow;
}
await _db.SaveChangesAsync(cancellationToken);
return Ok(message);
}
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>";
}
}
@@ -14,11 +14,13 @@ namespace JobTrackerApi.Controllers
{
private readonly JobTrackerContext _db;
private readonly ISummarizerService _summarizer;
private readonly IAppEmailSender _email;
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer)
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email)
{
_db = db;
_summarizer = summarizer;
_email = email;
}
private string? CurrentUserId =>
@@ -68,6 +70,30 @@ namespace JobTrackerApi.Controllers
return new string(value.Trim().ToLowerInvariant().Where(char.IsLetterOrDigit).ToArray());
}
private static string? NormalizeTags(string? raw)
{
var normalized = SplitTags(raw)
.Select(tag => tag.Trim())
.Where(tag => tag.Length > 0)
.GroupBy(tag => tag, StringComparer.OrdinalIgnoreCase)
.Select(group =>
{
var first = group.First();
return string.Join(" ", first.Split(' ', StringSplitOptions.RemoveEmptyEntries).Select(part => char.ToUpperInvariant(part[0]) + part[1..].ToLowerInvariant()));
})
.OrderBy(tag => tag, StringComparer.OrdinalIgnoreCase)
.ToList();
return normalized.Count == 0 ? null : JsonSerializer.Serialize(normalized);
}
private static string? NormalizeUrl(string? url)
{
if (string.IsNullOrWhiteSpace(url)) return null;
var value = url.Trim();
return Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri.ToString() : value;
}
public sealed record PagedResult<T>(List<T> Items, int Total, int Page, int PageSize);
@@ -538,10 +564,10 @@ namespace JobTrackerApi.Controllers
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description,
TranslatedDescription = string.IsNullOrWhiteSpace(request.TranslatedDescription) ? null : request.TranslatedDescription,
DescriptionLanguage = string.IsNullOrWhiteSpace(request.DescriptionLanguage) ? null : request.DescriptionLanguage.Trim(),
Tags = string.IsNullOrWhiteSpace(request.Tags) ? null : request.Tags,
Tags = NormalizeTags(request.Tags),
Deadline = request.Deadline,
CoverLetterText = string.IsNullOrWhiteSpace(request.CoverLetterText) ? null : request.CoverLetterText,
JobUrl = string.IsNullOrWhiteSpace(request.JobUrl) ? null : request.JobUrl,
JobUrl = NormalizeUrl(request.JobUrl),
DateApplied = request.DateApplied ?? DateTime.Now,
ResponseReceived = false,
ResponseDate = null,
@@ -635,10 +661,10 @@ namespace JobTrackerApi.Controllers
job.Description = request.Description;
job.TranslatedDescription = request.TranslatedDescription;
job.DescriptionLanguage = request.DescriptionLanguage;
job.Tags = request.Tags;
job.Tags = NormalizeTags(request.Tags);
job.Deadline = request.Deadline;
job.CoverLetterText = request.CoverLetterText;
job.JobUrl = request.JobUrl;
job.JobUrl = NormalizeUrl(request.JobUrl);
if (request.DateApplied is not null) job.DateApplied = request.DateApplied.Value;
if (oldResponseReceived != job.ResponseReceived || oldResponseDate != job.ResponseDate)
@@ -1076,6 +1102,7 @@ namespace JobTrackerApi.Controllers
public sealed record DuplicateCandidateDto(int Id, string JobTitle, string Company, string? JobUrl, string Status, DateTime DateApplied, string Reason);
public sealed record DuplicateCheckResult(bool HasDuplicates, List<DuplicateCandidateDto> Matches);
public sealed record FollowUpDraftDto(string Subject, string Body, string Reason, DateTime SuggestedSendOn);
public sealed record SendFollowUpRequest(string? ToEmail, string Subject, string Body, DateTime? NextFollowUpAt);
public sealed record TagTrendResponse(List<string> Months, List<TagTrendSeries> Series);
[HttpGet("analytics-overview")]
@@ -1310,6 +1337,50 @@ namespace JobTrackerApi.Controllers
return Ok(new FollowUpDraftDto(subject, body, reason, DateTime.Today));
}
[HttpPost("{id:int}/send-followup")]
public async Task<IActionResult> SendFollowUp([FromRoute] int id, [FromBody] SendFollowUpRequest request, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
.Include(j => j.Company)
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
if (string.IsNullOrWhiteSpace(request.Subject)) return BadRequest("Subject is required.");
if (string.IsNullOrWhiteSpace(request.Body)) return BadRequest("Body is required.");
var toEmail = (request.ToEmail ?? job.Company?.RecruiterEmail ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(toEmail)) return BadRequest("Recipient email is required.");
await _email.SendAsync(toEmail, request.Subject.Trim(), request.Body.Trim(), cancellationToken);
_db.Correspondences.Add(new Correspondence
{
JobApplicationId = id,
From = "Me",
Subject = request.Subject.Trim(),
Channel = "Email",
Content = request.Body.Trim(),
Date = DateTime.Now,
});
if (job.Company is not null)
{
job.Company.LastContactedAt = DateTime.Now;
if (request.NextFollowUpAt is not null)
{
job.Company.NextContactAt = request.NextFollowUpAt.Value;
}
}
if (request.NextFollowUpAt is not null)
{
job.FollowUpAt = request.NextFollowUpAt.Value;
}
await _db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpGet("summarizer-metrics")]
public async Task<ActionResult<SummarizerMetrics>> GetSummarizerMetrics(CancellationToken cancellationToken)
{
+26
View File
@@ -77,6 +77,7 @@ builder.Services.AddControllers();
builder.Services.AddDataProtection();
builder.Services.AddHostedService<RulesHostedService>();
builder.Services.AddHostedService<DailyExportHostedService>();
builder.Services.AddHostedService<JobEnrichmentHostedService>();
builder.Services.AddHttpClient("jobimport")
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
@@ -94,6 +95,7 @@ builder.Services.AddHttpClient("summarizer", client =>
builder.Services.AddMemoryCache();
builder.Services.AddSingleton<ISummarizerService, SummarizerService>();
builder.Services.AddScoped<IGmailOAuthService, GmailOAuthService>();
builder.Services.AddIdentityCore<ApplicationUser>(options =>
{
@@ -449,6 +451,28 @@ CREATE TABLE IF NOT EXISTS "UserRuleSettings" (
EnsureUserRuleSettingsTable(conn);
static void EnsureGmailConnectionsTable(DbConnection c)
{
Exec(c, """
CREATE TABLE IF NOT EXISTS "GmailConnections" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_GmailConnections" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL,
"GmailAddress" TEXT NOT NULL,
"EncryptedRefreshToken" TEXT NOT NULL,
"EncryptedAccessToken" TEXT NULL,
"AccessTokenExpiresAt" TEXT NULL,
"Scope" TEXT NOT NULL,
"ConnectedAt" TEXT NOT NULL,
"LastSyncedAt" TEXT NULL
);
""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_GmailConnections_OwnerUserId" ON "GmailConnections" ("OwnerUserId");""");
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_GmailConnections_OwnerUserId_GmailAddress" ON "GmailConnections" ("OwnerUserId", "GmailAddress");""");
}
EnsureGmailConnectionsTable(conn);
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
// and at least one of the new columns already exists.
var isLegacy =
@@ -478,6 +502,7 @@ CREATE TABLE IF NOT EXISTS "UserRuleSettings" (
{
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;");
}
// Record the migration as applied.
@@ -495,6 +520,7 @@ CREATE TABLE IF NOT EXISTS "UserRuleSettings" (
// Ensure ownership columns exist even on non-legacy DBs.
EnsureColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE Companies ADD COLUMN OwnerUserId TEXT NULL;");
EnsureColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE JobApplications ADD COLUMN OwnerUserId TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE Correspondences ADD COLUMN ExternalMessageId TEXT NULL;");
// Ensure data folder exists before creating/opening SQLite files.
Directory.CreateDirectory(paths.DataRoot);
+417
View File
@@ -0,0 +1,417 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
namespace JobTrackerApi.Services;
public interface IGmailOAuthService
{
string BuildAuthorizationUrl(string ownerUserId, string redirectUri);
string? ConsumeState(string state);
Task<GmailOAuthExchangeResult> ExchangeCodeAsync(string ownerUserId, string code, string redirectUri, CancellationToken cancellationToken);
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<GmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken);
}
public sealed record GmailOAuthExchangeResult(string GmailAddress);
public sealed record GmailMessageSummary(string Id, string ThreadId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet);
public sealed record GmailMessageDetail(string Id, string ThreadId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet, string BodyText, string? BodyHtml);
internal sealed class GmailTokenResponse
{
public string? access_token { get; set; }
public int expires_in { get; set; }
public string? refresh_token { get; set; }
public string? scope { get; set; }
public string? token_type { get; set; }
}
public sealed class GmailOAuthService : IGmailOAuthService
{
private const string Scope = "openid email profile https://www.googleapis.com/auth/gmail.readonly";
private readonly IConfiguration _cfg;
private readonly JobTrackerContext _db;
private readonly IDataProtector _protector;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMemoryCache _cache;
public GmailOAuthService(
IConfiguration cfg,
JobTrackerContext db,
IDataProtectionProvider protectionProvider,
IHttpClientFactory httpClientFactory,
IMemoryCache cache)
{
_cfg = cfg;
_db = db;
_protector = protectionProvider.CreateProtector("gmail-oauth-tokens-v1");
_httpClientFactory = httpClientFactory;
_cache = cache;
}
public string BuildAuthorizationUrl(string ownerUserId, string redirectUri)
{
var clientId = GetRequiredClientId();
var state = Convert.ToBase64String(Guid.NewGuid().ToByteArray())
.Replace("+", "-")
.Replace("/", "_")
.TrimEnd('=');
_cache.Set(GetStateCacheKey(state), ownerUserId, TimeSpan.FromMinutes(15));
var query = new Dictionary<string, string?>
{
["client_id"] = clientId,
["redirect_uri"] = redirectUri,
["response_type"] = "code",
["access_type"] = "offline",
["prompt"] = "consent",
["include_granted_scopes"] = "true",
["scope"] = Scope,
["state"] = state,
};
var encoded = string.Join("&", query.Select(kvp => $"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value ?? "")}"));
return $"https://accounts.google.com/o/oauth2/v2/auth?{encoded}";
}
public async Task<GmailOAuthExchangeResult> ExchangeCodeAsync(string ownerUserId, string code, string redirectUri, CancellationToken cancellationToken)
{
var tokens = await ExchangeCodeForTokensAsync(code, redirectUri, cancellationToken);
var accessToken = tokens.access_token?.Trim();
var refreshToken = tokens.refresh_token?.Trim();
if (string.IsNullOrWhiteSpace(accessToken))
throw new InvalidOperationException("Google did not return an access token.");
if (string.IsNullOrWhiteSpace(refreshToken))
throw new InvalidOperationException("Google did not return a refresh token. Reconnect Gmail and ensure consent is granted.");
var gmailAddress = await GetProfileAsync(accessToken, cancellationToken);
if (string.IsNullOrWhiteSpace(gmailAddress))
throw new InvalidOperationException("Google did not return a Gmail address.");
var existing = await _db.GmailConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (existing is null)
{
existing = new GmailConnection
{
OwnerUserId = ownerUserId,
};
_db.GmailConnections.Add(existing);
}
existing.GmailAddress = gmailAddress.Trim();
existing.EncryptedRefreshToken = _protector.Protect(refreshToken);
existing.EncryptedAccessToken = _protector.Protect(accessToken);
existing.AccessTokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(tokens.expires_in - 60, 60));
existing.Scope = tokens.scope?.Trim() ?? Scope;
existing.ConnectedAt = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
return new GmailOAuthExchangeResult(existing.GmailAddress);
}
public string? ConsumeState(string state)
{
if (!_cache.TryGetValue<string>(GetStateCacheKey(state), out var ownerUserId))
{
return null;
}
_cache.Remove(GetStateCacheKey(state));
return ownerUserId;
}
public Task<GmailConnection?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken)
{
return _db.GmailConnections.AsNoTracking().FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
}
public async Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken)
{
var existing = await _db.GmailConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (existing is null) return;
_db.GmailConnections.Remove(existing);
await _db.SaveChangesAsync(cancellationToken);
}
public async Task<IReadOnlyList<GmailMessageSummary>> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken)
{
maxResults = Math.Clamp(maxResults, 1, 25);
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var url = $"https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults={maxResults}";
if (!string.IsNullOrWhiteSpace(query))
{
url += $"&q={Uri.EscapeDataString(query.Trim())}";
}
using var response = await client.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
if (!doc.RootElement.TryGetProperty("messages", out var messagesElement) || messagesElement.ValueKind != JsonValueKind.Array)
{
return Array.Empty<GmailMessageSummary>();
}
var ids = messagesElement.EnumerateArray()
.Select(x => x.TryGetProperty("id", out var id) ? id.GetString() : null)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Cast<string>()
.ToList();
var results = new List<GmailMessageSummary>(ids.Count);
foreach (var id in ids)
{
var detail = await GetMessageAsync(ownerUserId, id, cancellationToken);
results.Add(new GmailMessageSummary(detail.Id, detail.ThreadId, detail.Subject, detail.From, detail.To, detail.Date, detail.Snippet));
}
await TouchSyncTimeAsync(ownerUserId, cancellationToken);
return results;
}
public async Task<GmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken)
{
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var url = $"https://gmail.googleapis.com/gmail/v1/users/me/messages/{Uri.EscapeDataString(messageId)}?format=full";
using var response = await client.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
var root = doc.RootElement;
var threadId = root.TryGetProperty("threadId", out var threadEl) ? threadEl.GetString() ?? "" : "";
var snippet = root.TryGetProperty("snippet", out var snippetEl) ? snippetEl.GetString() ?? "" : "";
var payload = root.GetProperty("payload");
var headers = ReadHeaders(payload);
var bodyText = ExtractBody(payload, "text/plain");
var bodyHtml = ExtractBody(payload, "text/html");
if (string.IsNullOrWhiteSpace(bodyText) && !string.IsNullOrWhiteSpace(bodyHtml))
{
bodyText = StripHtml(bodyHtml);
}
return new GmailMessageDetail(
messageId,
threadId,
headers.TryGetValue("subject", out var subject) ? subject : "",
headers.TryGetValue("from", out var from) ? from : "",
headers.TryGetValue("to", out var to) ? to : "",
headers.TryGetValue("date", out var dateRaw) && DateTimeOffset.TryParse(dateRaw, out var parsedDate) ? parsedDate : null,
snippet,
bodyText.Trim(),
string.IsNullOrWhiteSpace(bodyHtml) ? null : bodyHtml
);
}
private async Task<string> GetValidAccessTokenAsync(string ownerUserId, CancellationToken cancellationToken)
{
var connection = await _db.GmailConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (connection is null)
throw new InvalidOperationException("Gmail is not connected for this account.");
if (!string.IsNullOrWhiteSpace(connection.EncryptedAccessToken) &&
connection.AccessTokenExpiresAt is { } expiresAt &&
expiresAt > DateTimeOffset.UtcNow.AddMinutes(1))
{
return _protector.Unprotect(connection.EncryptedAccessToken);
}
var refreshToken = _protector.Unprotect(connection.EncryptedRefreshToken);
var refreshed = await RefreshAccessTokenAsync(refreshToken, cancellationToken);
var accessToken = refreshed.access_token?.Trim();
if (string.IsNullOrWhiteSpace(accessToken))
throw new InvalidOperationException("Failed to refresh Gmail access token.");
connection.EncryptedAccessToken = _protector.Protect(accessToken);
connection.AccessTokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(refreshed.expires_in - 60, 60));
if (!string.IsNullOrWhiteSpace(refreshed.scope))
{
connection.Scope = refreshed.scope.Trim();
}
await _db.SaveChangesAsync(cancellationToken);
return accessToken;
}
private async Task<GmailTokenResponse> ExchangeCodeForTokensAsync(string code, string redirectUri, CancellationToken cancellationToken)
{
var client = _httpClientFactory.CreateClient();
using var response = await client.PostAsync(
"https://oauth2.googleapis.com/token",
new FormUrlEncodedContent(new Dictionary<string, string>
{
["code"] = code,
["client_id"] = GetRequiredClientId(),
["client_secret"] = GetRequiredClientSecret(),
["redirect_uri"] = redirectUri,
["grant_type"] = "authorization_code",
}),
cancellationToken);
var payload = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException($"Google token exchange failed: {payload}");
}
return JsonSerializer.Deserialize<GmailTokenResponse>(payload)
?? throw new InvalidOperationException("Unable to parse Google token response.");
}
private async Task<GmailTokenResponse> RefreshAccessTokenAsync(string refreshToken, CancellationToken cancellationToken)
{
var client = _httpClientFactory.CreateClient();
using var response = await client.PostAsync(
"https://oauth2.googleapis.com/token",
new FormUrlEncodedContent(new Dictionary<string, string>
{
["refresh_token"] = refreshToken,
["client_id"] = GetRequiredClientId(),
["client_secret"] = GetRequiredClientSecret(),
["grant_type"] = "refresh_token",
}),
cancellationToken);
var payload = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException($"Google token refresh failed: {payload}");
}
return JsonSerializer.Deserialize<GmailTokenResponse>(payload)
?? throw new InvalidOperationException("Unable to parse Google refresh response.");
}
private async Task<string> GetProfileAsync(string accessToken, CancellationToken cancellationToken)
{
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
using var response = await client.GetAsync("https://gmail.googleapis.com/gmail/v1/users/me/profile", cancellationToken);
var payload = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException($"Failed to read Gmail profile: {payload}");
}
using var doc = JsonDocument.Parse(payload);
return doc.RootElement.TryGetProperty("emailAddress", out var emailEl)
? emailEl.GetString() ?? ""
: "";
}
private async Task TouchSyncTimeAsync(string ownerUserId, CancellationToken cancellationToken)
{
var connection = await _db.GmailConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (connection is null) return;
connection.LastSyncedAt = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
}
private string GetRequiredClientId()
{
return (_cfg["Google:ClientId"] ?? _cfg["Auth:GoogleClientId"] ?? "").Trim() switch
{
"" => throw new InvalidOperationException("Google Gmail client ID is not configured."),
var value => value,
};
}
private string GetRequiredClientSecret()
{
return (_cfg["Google:GmailClientSecret"] ?? _cfg["Google:ClientSecret"] ?? "").Trim() switch
{
"" => throw new InvalidOperationException("Google Gmail client secret is not configured."),
var value => value,
};
}
private static string GetStateCacheKey(string state) => $"gmail-oauth-state:{state}";
private static Dictionary<string, string> ReadHeaders(JsonElement payload)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (!payload.TryGetProperty("headers", out var headers) || headers.ValueKind != JsonValueKind.Array)
{
return result;
}
foreach (var item in headers.EnumerateArray())
{
var name = item.TryGetProperty("name", out var nameEl) ? nameEl.GetString() : null;
var value = item.TryGetProperty("value", out var valueEl) ? valueEl.GetString() : null;
if (string.IsNullOrWhiteSpace(name) || value is null) continue;
result[name.Trim()] = value;
}
return result;
}
private static string ExtractBody(JsonElement payload, string mimeType)
{
if (payload.TryGetProperty("mimeType", out var mimeTypeEl) &&
string.Equals(mimeTypeEl.GetString(), mimeType, StringComparison.OrdinalIgnoreCase) &&
payload.TryGetProperty("body", out var bodyEl) &&
bodyEl.TryGetProperty("data", out var dataEl))
{
return DecodeBody(dataEl.GetString());
}
if (payload.TryGetProperty("parts", out var partsEl) && partsEl.ValueKind == JsonValueKind.Array)
{
foreach (var part in partsEl.EnumerateArray())
{
var nested = ExtractBody(part, mimeType);
if (!string.IsNullOrWhiteSpace(nested))
{
return nested;
}
}
}
return "";
}
private static string DecodeBody(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return "";
var normalized = raw.Replace('-', '+').Replace('_', '/');
var padding = normalized.Length % 4;
if (padding > 0)
{
normalized = normalized.PadRight(normalized.Length + (4 - padding), '=');
}
try
{
var bytes = Convert.FromBase64String(normalized);
return Encoding.UTF8.GetString(bytes);
}
catch
{
return "";
}
}
private static string StripHtml(string html)
{
if (string.IsNullOrWhiteSpace(html)) return "";
return System.Text.RegularExpressions.Regex.Replace(html, "<.*?>", string.Empty)
.Replace("&nbsp;", " ")
.Replace("&amp;", "&")
.Trim();
}
}
@@ -0,0 +1,89 @@
using System.Text.Json;
using JobTrackerApi.Data;
using JobTrackerApi.Services.JobImport;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Services;
public sealed class JobEnrichmentHostedService : BackgroundService
{
private readonly IServiceProvider _services;
private readonly ILogger<JobEnrichmentHostedService> _logger;
public JobEnrichmentHostedService(IServiceProvider services, ILogger<JobEnrichmentHostedService> logger)
{
_services = services;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
var summarizer = scope.ServiceProvider.GetRequiredService<ISummarizerService>();
var jobs = await db.JobApplications
.Where(j => !j.IsDeleted)
.Where(j => string.IsNullOrWhiteSpace(j.ShortSummary) || string.IsNullOrWhiteSpace(j.Tags))
.OrderByDescending(j => j.DateApplied)
.Take(20)
.ToListAsync(stoppingToken);
var changed = 0;
foreach (var job in jobs)
{
var sourceText = string.IsNullOrWhiteSpace(job.Description) ? job.Notes : job.Description;
if (string.IsNullOrWhiteSpace(job.Tags) && !string.IsNullOrWhiteSpace(sourceText))
{
var tags = SkillTagger.Detect(sourceText)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
.ToList();
if (tags.Count > 0)
{
job.Tags = JsonSerializer.Serialize(tags);
changed++;
}
}
if (string.IsNullOrWhiteSpace(job.ShortSummary) && !string.IsNullOrWhiteSpace(sourceText))
{
try
{
var shortSummary = await summarizer.SummarizeAsync(sourceText, 80, 20);
if (!string.IsNullOrWhiteSpace(shortSummary))
{
job.ShortSummary = shortSummary;
changed++;
}
}
catch
{
// Best effort; leave for a later pass.
}
}
}
if (changed > 0)
{
await db.SaveChangesAsync(stoppingToken);
_logger.LogInformation("Backfilled tags/summaries for {Count} job fields.", changed);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Job enrichment background pass failed.");
}
await Task.Delay(TimeSpan.FromMinutes(10), stoppingToken);
}
}
}