diff --git a/.env.example b/.env.example index 4c9873b..4441d5f 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,9 @@ AUTH_JWT_KEY=CHANGE_ME_LONG_RANDOM_SECRET AUTH_ADMIN_EMAIL=admin@example.com AUTH_ADMIN_PASSWORD=CHANGE_ME_STRONG_PASSWORD AUTH_GOOGLE_CLIENT_ID=723556162227-llqucvpog2esn1dutmtvuul1lv374or6.apps.googleusercontent.com +GOOGLE_GMAIL_CLIENT_SECRET=CHANGE_ME_GOOGLE_OAUTH_CLIENT_SECRET +# Optional. If omitted, the backend uses https:///api/gmail/oauth/callback +GOOGLE_GMAIL_REDIRECT_URI= # Optional: only needed if you want the UI to call a non-default API base URL. # In production the UI defaults to `/api`. diff --git a/Data/JobTrackerContext.cs b/Data/JobTrackerContext.cs index ed2567c..69d18e3 100644 --- a/Data/JobTrackerContext.cs +++ b/Data/JobTrackerContext.cs @@ -16,6 +16,7 @@ namespace JobTrackerApi.Data public DbSet Companies => Set(); public DbSet JobApplications => Set(); public DbSet Correspondences => Set(); + public DbSet GmailConnections => Set(); public DbSet Attachments => Set(); public DbSet RuleSettings => Set(); public DbSet UserRuleSettings => Set(); @@ -58,6 +59,16 @@ namespace JobTrackerApi.Data .HasForeignKey(c => c.JobApplicationId) .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId); + + modelBuilder.Entity() + .HasIndex(x => new { x.OwnerUserId, x.GmailAddress }) + .IsUnique(); + + modelBuilder.Entity() + .HasIndex(x => x.OwnerUserId); + modelBuilder.Entity() .HasOne(a => a.JobApplication) .WithMany(j => j.Attachments) diff --git a/JobTrackerApi/Controllers/CompaniesController.cs b/JobTrackerApi/Controllers/CompaniesController.cs index 9ee4676..b48e6e8 100644 --- a/JobTrackerApi/Controllers/CompaniesController.cs +++ b/JobTrackerApi/Controllers/CompaniesController.cs @@ -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>> 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(); diff --git a/JobTrackerApi/Controllers/GmailController.cs b/JobTrackerApi/Controllers/GmailController.cs new file mode 100644 index 0000000..6d8c72f --- /dev/null +++ b/JobTrackerApi/Controllers/GmailController.cs @@ -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 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 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 Disconnect(CancellationToken cancellationToken) + { + var ownerUserId = GetRequiredOwnerUserId(); + await _gmail.DisconnectAsync(ownerUserId, cancellationToken); + return NoContent(); + } + + [HttpGet("messages")] + public async Task 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 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 $@" + + + + Gmail connection + + +

{title}

+

{escaped}

+

You can close this window.

+ + +"; + } +} diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index 807912b..efc3b54 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -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(List 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 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 Months, List 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 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> GetSummarizerMetrics(CancellationToken cancellationToken) { diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index 1fa4e73..0985f2b 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -77,6 +77,7 @@ builder.Services.AddControllers(); builder.Services.AddDataProtection(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); +builder.Services.AddHostedService(); builder.Services.AddHttpClient("jobimport") .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler @@ -94,6 +95,7 @@ builder.Services.AddHttpClient("summarizer", client => builder.Services.AddMemoryCache(); builder.Services.AddSingleton(); +builder.Services.AddScoped(); builder.Services.AddIdentityCore(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); diff --git a/JobTrackerApi/Services/GmailOAuthService.cs b/JobTrackerApi/Services/GmailOAuthService.cs new file mode 100644 index 0000000..172ec44 --- /dev/null +++ b/JobTrackerApi/Services/GmailOAuthService.cs @@ -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 ExchangeCodeAsync(string ownerUserId, string code, string redirectUri, CancellationToken cancellationToken); + Task GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken); + Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken); + Task> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken); + Task 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 + { + ["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 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(GetStateCacheKey(state), out var ownerUserId)) + { + return null; + } + + _cache.Remove(GetStateCacheKey(state)); + return ownerUserId; + } + + public Task 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> 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(); + } + + var ids = messagesElement.EnumerateArray() + .Select(x => x.TryGetProperty("id", out var id) ? id.GetString() : null) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Cast() + .ToList(); + + var results = new List(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 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 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 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 + { + ["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(payload) + ?? throw new InvalidOperationException("Unable to parse Google token response."); + } + + private async Task RefreshAccessTokenAsync(string refreshToken, CancellationToken cancellationToken) + { + var client = _httpClientFactory.CreateClient(); + using var response = await client.PostAsync( + "https://oauth2.googleapis.com/token", + new FormUrlEncodedContent(new Dictionary + { + ["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(payload) + ?? throw new InvalidOperationException("Unable to parse Google refresh response."); + } + + private async Task 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 ReadHeaders(JsonElement payload) + { + var result = new Dictionary(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(" ", " ") + .Replace("&", "&") + .Trim(); + } +} diff --git a/JobTrackerApi/Services/JobEnrichmentHostedService.cs b/JobTrackerApi/Services/JobEnrichmentHostedService.cs new file mode 100644 index 0000000..be549c1 --- /dev/null +++ b/JobTrackerApi/Services/JobEnrichmentHostedService.cs @@ -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 _logger; + + public JobEnrichmentHostedService(IServiceProvider services, ILogger 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(); + var summarizer = scope.ServiceProvider.GetRequiredService(); + + 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); + } + } +} diff --git a/Models/Correspondence.cs b/Models/Correspondence.cs index af925be..3f6864c 100644 --- a/Models/Correspondence.cs +++ b/Models/Correspondence.cs @@ -13,6 +13,7 @@ namespace JobTrackerApi.Models public string From { get; set; } = ""; // "Me" or "Company" public string? Subject { get; set; } public string? Channel { get; set; } // e.g. Email, Call, Note + public string? ExternalMessageId { get; set; } public string Content { get; set; } = ""; public DateTime Date { get; set; } = DateTime.Now; } diff --git a/Models/GmailConnection.cs b/Models/GmailConnection.cs new file mode 100644 index 0000000..024671a --- /dev/null +++ b/Models/GmailConnection.cs @@ -0,0 +1,14 @@ +namespace JobTrackerApi.Models; + +public sealed class GmailConnection +{ + public int Id { get; set; } + public string OwnerUserId { get; set; } = ""; + public string GmailAddress { get; set; } = ""; + public string EncryptedRefreshToken { get; set; } = ""; + public string? EncryptedAccessToken { get; set; } + public DateTimeOffset? AccessTokenExpiresAt { get; set; } + public string Scope { get; set; } = ""; + public DateTimeOffset ConnectedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? LastSyncedAt { get; set; } +} diff --git a/docker-compose.yml b/docker-compose.yml index 9299d58..d6f5897 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,8 @@ services: - Auth__AdminPassword=${AUTH_ADMIN_PASSWORD} # Optional: allow Google ID-token bearer auth - Auth__GoogleClientId=${AUTH_GOOGLE_CLIENT_ID} + - Google__GmailClientSecret=${GOOGLE_GMAIL_CLIENT_SECRET} + - Google__GmailRedirectUri=${GOOGLE_GMAIL_REDIRECT_URI} # Email (SMTP) - App__PublicBaseUrl=${APP_PUBLIC_BASE_URL} - Email__Enabled=${EMAIL_ENABLED} diff --git a/job-tracker-ui/src/components/CompaniesTable.tsx b/job-tracker-ui/src/components/CompaniesTable.tsx index 7c17b99..48b2eb1 100644 --- a/job-tracker-ui/src/components/CompaniesTable.tsx +++ b/job-tracker-ui/src/components/CompaniesTable.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useMemo, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; import { Box, @@ -25,6 +26,8 @@ import { useToast } from "../toast"; export default function CompaniesTable() { const { toast } = useToast(); + const location = useLocation(); + const navigate = useNavigate(); const [companies, setCompanies] = useState([]); const [editOpen, setEditOpen] = useState(false); const [editing, setEditing] = useState(null); @@ -40,6 +43,17 @@ export default function CompaniesTable() { api.get("/companies").then((r) => setCompanies(r.data)); }, []); + useEffect(() => { + const params = new URLSearchParams(location.search); + const editId = Number(params.get("edit") || 0); + if (!editId || companies.length === 0) return; + const company = companies.find((c) => c.id === editId); + if (!company) return; + openEdit(company); + params.delete("edit"); + navigate({ pathname: location.pathname, search: params.toString() ? `?${params.toString()}` : "" }, { replace: true }); + }, [companies, location.pathname, location.search, navigate]); + const openEdit = (c: Company) => { setEditing(c); setRecruiterName(c.recruiterName ?? ""); diff --git a/job-tracker-ui/src/components/Correspondence.tsx b/job-tracker-ui/src/components/Correspondence.tsx index e4caa10..8088f0b 100644 --- a/job-tracker-ui/src/components/Correspondence.tsx +++ b/job-tracker-ui/src/components/Correspondence.tsx @@ -3,11 +3,20 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" import { Box, Button, + Chip, + CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, + Divider, + List, + ListItemButton, + ListItemText, Paper, + Tab, + Tabs, + TextField, ToggleButton, ToggleButtonGroup, Typography, @@ -16,7 +25,7 @@ import { alpha, useTheme } from "@mui/material/styles"; import { api } from "../api"; import { useToast } from "../toast"; -import { CorrespondenceMessage } from "../types"; +import { CorrespondenceMessage, GmailMessageSummary, GmailStatus } from "../types"; function parseRawEmail(raw: string): { subject?: string; @@ -68,13 +77,50 @@ export default function Correspondence({ jobId }: { jobId: number }) { const scrollRef = useRef(null); const [importOpen, setImportOpen] = useState(false); + const [importTab, setImportTab] = useState(0); const [rawEmail, setRawEmail] = useState(""); + const [gmailStatus, setGmailStatus] = useState(null); + const [gmailLoading, setGmailLoading] = useState(false); + const [gmailQuery, setGmailQuery] = useState(""); + const [gmailMessages, setGmailMessages] = useState([]); + const [gmailMessagesLoading, setGmailMessagesLoading] = useState(false); + const [importingMessageId, setImportingMessageId] = useState(null); + const load = useCallback(async () => { const res = await api.get(`/correspondence/${jobId}`); setMessages(res.data); }, [jobId]); + const loadGmailStatus = useCallback(async () => { + try { + setGmailLoading(true); + const res = await api.get("/gmail/status"); + setGmailStatus(res.data); + } catch { + setGmailStatus({ connected: false }); + } finally { + setGmailLoading(false); + } + }, []); + + const loadGmailMessages = useCallback(async () => { + try { + setGmailMessagesLoading(true); + const res = await api.get("/gmail/messages", { + params: { + query: gmailQuery.trim() || undefined, + maxResults: 12, + }, + }); + setGmailMessages(res.data); + } catch { + toast("Failed to load Gmail messages.", "error"); + } finally { + setGmailMessagesLoading(false); + } + }, [gmailQuery, toast]); + useEffect(() => { void load(); }, [load]); @@ -85,6 +131,34 @@ export default function Correspondence({ jobId }: { jobId: number }) { el.scrollTop = el.scrollHeight; }, [messages.length]); + useEffect(() => { + if (!importOpen) return; + void loadGmailStatus(); + }, [importOpen, loadGmailStatus]); + + useEffect(() => { + if (!importOpen || importTab !== 1 || !gmailStatus?.connected) return; + void loadGmailMessages(); + }, [importOpen, importTab, gmailStatus?.connected, loadGmailMessages]); + + useEffect(() => { + const onMessage = (event: MessageEvent) => { + const data = event.data as { source?: string; status?: string; message?: string }; + if (data?.source !== "jobtracker-gmail-oauth") return; + if (data.status === "connected") { + toast(data.message || "Gmail connected.", "success"); + void loadGmailStatus(); + setImportTab(1); + void loadGmailMessages(); + } else { + toast(data.message || "Gmail connection failed.", "error"); + } + }; + + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, [loadGmailMessages, loadGmailStatus, toast]); + const canSend = useMemo(() => text.trim().length > 0, [text]); const send = async () => { @@ -130,6 +204,45 @@ export default function Correspondence({ jobId }: { jobId: number }) { } }; + const connectGmail = async () => { + try { + const res = await api.get<{ url: string }>("/gmail/connect-url"); + const popup = window.open(res.data.url, "jobtracker-gmail-connect", "width=620,height=760,resizable=yes,scrollbars=yes"); + if (!popup) { + toast("Your browser blocked the Gmail popup.", "error"); + } + } catch { + toast("Failed to start Gmail connection.", "error"); + } + }; + + const disconnectGmail = async () => { + try { + await api.delete("/gmail/connection"); + setGmailStatus({ connected: false }); + setGmailMessages([]); + toast("Gmail disconnected.", "success"); + } catch { + toast("Failed to disconnect Gmail.", "error"); + } + }; + + const importGmailMessage = async (messageId: string) => { + try { + setImportingMessageId(messageId); + await api.post("/gmail/import", { + jobApplicationId: jobId, + messageId, + }); + await load(); + toast("Email imported from Gmail.", "success"); + } catch { + toast("Failed to import Gmail message.", "error"); + } finally { + setImportingMessageId(null); + } + }; + return ( {isMe ? "Me" : "Company"} - {m.channel ? ` · ${m.channel}` : ""} - {m.date ? ` · ${new Date(m.date).toLocaleString()}` : ""} + {m.channel ? ` · ${m.channel}` : ""} + {m.date ? ` · ${new Date(m.date).toLocaleString()}` : ""} @@ -249,38 +362,143 @@ export default function Correspondence({ jobId }: { jobId: number }) { setImportOpen(false)} fullWidth maxWidth="md"> Import Email - - Paste raw email text (headers optional). We parse Subject and Date when present. - -