From 3e5f796326757a3f61ac720b85ca0c3660a133c9 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Tue, 24 Mar 2026 10:06:50 +0100 Subject: [PATCH] Complete S01 Gmail matching and import workflow --- .gsd/PROJECT.md | 2 +- .gsd/milestones/M001/M001-ROADMAP.md | 2 +- .gsd/milestones/M001/slices/S01/S01-PLAN.md | 6 +- JobTrackerApi.Tests/GmailControllerTests.cs | 285 ++++++++++++++++- .../Controllers/CorrespondenceController.cs | 10 +- JobTrackerApi/Controllers/GmailController.cs | 293 +++++++++++++++++- JobTrackerApi/Program.cs | 9 + JobTrackerApi/Services/GmailOAuthService.cs | 28 ++ Models/Correspondence.cs | 27 +- .../src/components/Correspondence.tsx | 196 +++++++----- .../src/components/JobDetailsDialog.tsx | 2 +- .../src/correspondence-gmail-import.test.tsx | 230 ++++++++++++++ job-tracker-ui/src/types.ts | 58 ++++ 13 files changed, 1043 insertions(+), 105 deletions(-) create mode 100644 job-tracker-ui/src/correspondence-gmail-import.test.tsx diff --git a/.gsd/PROJECT.md b/.gsd/PROJECT.md index 5d689c7..ac43c2b 100644 --- a/.gsd/PROJECT.md +++ b/.gsd/PROJECT.md @@ -10,7 +10,7 @@ The product must let one person run a real job search without losing the thread: ## Current State -A substantial brownfield app already exists. The repo has a React frontend, an ASP.NET Core API, and a local FastAPI AI service. Current capabilities already include job tracking, companies, attachments, correspondence, reminders, job import preview, Gmail connection/import, profile CV upload/parsing/rewrite flows, AI-assisted tailored CV and cover-letter generation, candidate-fit/focus-plan/interview-prep/readiness endpoints, and dashboard/system surfaces. The next phase is not greenfield feature invention; it is turning existing capability into a more coherent, more trustworthy daily workflow. +A substantial brownfield app already exists. The repo has a React frontend, an ASP.NET Core API, and a local FastAPI AI service. Current capabilities already include job tracking, companies, attachments, correspondence, reminders, job import preview, Gmail connection/import, profile CV upload/parsing/rewrite flows, AI-assisted tailored CV and cover-letter generation, candidate-fit/focus-plan/interview-prep/readiness endpoints, and dashboard/system surfaces. S01 has now moved Gmail import from a generic search surface to a job-aware workspace flow: the backend ranks likely Gmail threads/messages for a specific job, imports report duplicate state explicitly, correspondence persists Gmail thread and sender/recipient metadata, and the job workspace renders those ranked suggestions directly. The next phase is not greenfield feature invention; it is continuing to turn existing capability into a more coherent, more trustworthy daily workflow. ## Architecture / Key Patterns diff --git a/.gsd/milestones/M001/M001-ROADMAP.md b/.gsd/milestones/M001/M001-ROADMAP.md index 70c539e..d9f4288 100644 --- a/.gsd/milestones/M001/M001-ROADMAP.md +++ b/.gsd/milestones/M001/M001-ROADMAP.md @@ -50,7 +50,7 @@ This milestone is complete only when all are true: ## Slices -- [ ] **S01: Smarter Gmail import and matching** `risk:high` `depends:[]` +- [x] **S01: Smarter Gmail import and matching** `risk:high` `depends:[]` > After this: User can connect Gmail, review likely messages or threads for a job, and import correspondence with much better matching confidence and less manual cleanup. - [ ] **S02: Stronger AI application package drafting** `risk:high` `depends:[S01]` diff --git a/.gsd/milestones/M001/slices/S01/S01-PLAN.md b/.gsd/milestones/M001/slices/S01/S01-PLAN.md index 7e96e64..06dc87b 100644 --- a/.gsd/milestones/M001/slices/S01/S01-PLAN.md +++ b/.gsd/milestones/M001/slices/S01/S01-PLAN.md @@ -38,19 +38,19 @@ S01 directly advances active requirements **R001** and **R002**, and it also lay ## Tasks -- [ ] **T01: Add a job-aware Gmail matching contract and backend ranking tests** `est:3h` +- [x] **T01: Add a job-aware Gmail matching contract and backend ranking tests** `est:3h` - Why: The slice risk sits in matching quality, so the backend needs to own candidate discovery, ranking, and duplicate awareness before the UI can present trustworthy suggestions. - Files: `JobTrackerApi/Controllers/GmailController.cs`, `JobTrackerApi/Services/GmailOAuthService.cs`, `JobTrackerApi.Tests/GmailControllerTests.cs` - Do: Add a job-scoped Gmail candidate endpoint that loads the owned job plus company context, builds multiple Gmail queries from recruiter/company/title/correspondence signals, dedupes messages and groups them by thread, and returns ranked suggestions with match reasons, confidence inputs, and already-imported flags; cover the contract with focused controller/service tests instead of leaving ranking in `Correspondence.tsx`. - Verify: `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter GmailControllerTests` - Done when: the API can return ranked Gmail candidates for one job with explicit reasons and duplicate state, and backend tests prove owned-job lookup plus ranking/dedupe behavior. -- [ ] **T02: Persist Gmail thread metadata and harden import continuity** `est:3h` +- [x] **T02: Persist Gmail thread metadata and harden import continuity** `est:3h` - Why: Smarter matching is not enough if imported correspondence still loses thread identity or forces downstream slices to re-derive sender/thread information later. - Files: `Models/Correspondence.cs`, `JobTrackerApi/Controllers/GmailController.cs`, `JobTrackerApi/Controllers/CorrespondenceController.cs`, `JobTrackerApi/Program.cs`, `JobTrackerApi.Tests/GmailControllerTests.cs` - Do: Extend correspondence persistence and import logic so Gmail imports store thread/message identity plus sender/recipient metadata, update import responses and duplicate handling to reflect imported vs skipped outcomes clearly, and update startup schema guards so older SQLite/MySQL dev databases remain bootable when the new fields land. - Verify: `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter GmailControllerTests` - Done when: imported correspondence keeps enough Gmail metadata for thread continuity and dedupe, compatibility guards are planned with the schema change, and tests prove single-message/thread imports behave correctly on repeat imports. -- [ ] **T03: Wire ranked Gmail suggestions into the job workspace UI** `est:3h` +- [x] **T03: Wire ranked Gmail suggestions into the job workspace UI** `est:3h` - Why: The slice is only complete when the user can act on the smarter backend contract inside the actual job workspace rather than through a generic inbox search UI. - Files: `job-tracker-ui/src/components/JobDetailsDialog.tsx`, `job-tracker-ui/src/components/Correspondence.tsx`, `job-tracker-ui/src/types.ts`, `job-tracker-ui/src/correspondence-gmail-import.test.tsx` - Do: Pass job context from `JobDetailsDialog` into `Correspondence`, replace client-side primary ranking with the server-provided candidate contract, show match reasons/confidence/import state and thread actions, keep manual query/search as a fallback override, and add a React test that proves ranked suggestions and import refresh behavior in the dialog. diff --git a/JobTrackerApi.Tests/GmailControllerTests.cs b/JobTrackerApi.Tests/GmailControllerTests.cs index 871f6b0..f0ab4cd 100644 --- a/JobTrackerApi.Tests/GmailControllerTests.cs +++ b/JobTrackerApi.Tests/GmailControllerTests.cs @@ -1,7 +1,12 @@ +using System.Security.Claims; using JobTrackerApi.Controllers; +using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; using Moq; using Xunit; @@ -12,19 +17,8 @@ public sealed class GmailControllerTests [Fact] public async Task Import_thread_rejects_missing_message_ids() { - var controller = new GmailController(Mock.Of(), null!, BuildConfig()) - { - ControllerContext = new Microsoft.AspNetCore.Mvc.ControllerContext - { - HttpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext - { - User = new System.Security.Claims.ClaimsPrincipal(new System.Security.Claims.ClaimsIdentity(new[] - { - new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, "user-1") - }, "test")) - } - } - }; + await using var db = CreateDb(); + var controller = CreateController(db, Mock.Of(), "user-1"); var result = await controller.ImportThread(new GmailController.ImportGmailThreadRequest(1, "thread-1", Array.Empty()), CancellationToken.None); @@ -32,6 +26,271 @@ public sealed class GmailControllerTests Assert.Equal("At least one messageId is required.", badRequest.Value); } + [Fact] + public async Task Job_candidates_returns_ranked_threads_with_match_reasons_and_import_flags() + { + await using var db = CreateDb(); + var company = new Company + { + Name = "Acme", + RecruiterName = "Maria Recruiter", + RecruiterEmail = "maria@acme.test", + OwnerUserId = "user-1" + }; + db.Companies.Add(company); + await db.SaveChangesAsync(); + + var job = new JobApplication + { + JobTitle = "Backend Developer", + CompanyId = company.Id, + OwnerUserId = "user-1" + }; + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + + db.Correspondences.Add(new Correspondence + { + JobApplicationId = job.Id, + From = "Company", + Subject = "Backend Developer interview", + ExternalMessageId = "msg-imported", + Content = "Earlier imported thread" + }); + await db.SaveChangesAsync(); + + var gmail = new Mock(); + gmail.Setup(service => service.ListMessagesForQueriesAsync("user-1", It.IsAny>(), 6, It.IsAny())) + .ReturnsAsync(new[] + { + new GmailMessageSummary( + "msg-top", + "thread-top", + "Backend Developer interview", + "Maria Recruiter ", + "user@example.test", + DateTimeOffset.UtcNow.AddDays(-2), + "Acme wants to schedule a backend developer interview."), + new GmailMessageSummary( + "msg-imported", + "thread-top", + "Backend Developer interview", + "Maria Recruiter ", + "user@example.test", + DateTimeOffset.UtcNow.AddDays(-4), + "Already imported message."), + new GmailMessageSummary( + "msg-low", + "thread-low", + "Backend update", + "alerts@example.test", + "user@example.test", + DateTimeOffset.UtcNow.AddDays(-90), + "A generic backend role update without recruiter context.") + }); + + var controller = CreateController(db, gmail.Object, "user-1"); + var result = await controller.JobCandidates(job.Id, null, 6, CancellationToken.None); + + var ok = Assert.IsType(result.Result); + var payload = Assert.IsType(ok.Value); + + Assert.Equal(job.Id, payload.JobApplicationId); + Assert.NotEmpty(payload.Queries); + Assert.Equal(2, payload.Threads.Count); + + var topThread = payload.Threads[0]; + Assert.Equal("thread-top", topThread.ThreadId); + Assert.Equal("high", topThread.Confidence); + Assert.True(topThread.HasImportedMessages); + Assert.Equal(2, topThread.MessageCount); + Assert.Contains(topThread.MatchReasons, reason => reason.Label == "company" && reason.Value == "Acme"); + Assert.Contains(topThread.MatchReasons, reason => reason.Label == "recruiterEmail" && reason.Value == "maria@acme.test"); + Assert.Contains(topThread.Messages, message => message.Id == "msg-imported" && message.AlreadyImported); + Assert.Contains(topThread.Messages, message => message.Id == "msg-top" && !message.AlreadyImported); + + var lowThread = payload.Threads[1]; + Assert.Equal("thread-low", lowThread.ThreadId); + Assert.Equal("low", lowThread.Confidence); + } + + [Fact] + public async Task Import_returns_message_metadata_and_skips_repeat_message_imports() + { + await using var db = CreateDb(); + var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; + db.Companies.Add(company); + await db.SaveChangesAsync(); + + var job = new JobApplication + { + JobTitle = "Backend Developer", + CompanyId = company.Id, + OwnerUserId = "user-1" + }; + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + + var gmail = new Mock(); + gmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny())) + .ReturnsAsync(new GmailConnection { OwnerUserId = "user-1", GmailAddress = "user@example.test", EncryptedRefreshToken = "ignored", Scope = "scope", ConnectedAt = DateTimeOffset.UtcNow }); + gmail.Setup(service => service.GetMessageAsync("user-1", "msg-1", It.IsAny())) + .ReturnsAsync(new GmailMessageDetail( + "msg-1", + "thread-1", + "Interview update", + "Maria Recruiter ", + "user@example.test", + DateTimeOffset.UtcNow.AddDays(-1), + "Snippet", + "Body text", + null)); + + var controller = CreateController(db, gmail.Object, "user-1"); + + var first = await controller.Import(new GmailController.ImportGmailMessageRequest(job.Id, "msg-1"), CancellationToken.None); + var firstOk = Assert.IsType(first.Result); + var firstPayload = Assert.IsType(firstOk.Value); + Assert.Equal(1, firstPayload.Imported); + Assert.Equal(0, firstPayload.Skipped); + Assert.Equal("thread-1", firstPayload.ThreadId); + Assert.NotNull(firstPayload.Message); + Assert.Equal("thread-1", firstPayload.Message!.ExternalThreadId); + Assert.Equal("Maria Recruiter ", firstPayload.Message.ExternalFrom); + Assert.Equal("user@example.test", firstPayload.Message.ExternalTo); + + var second = await controller.Import(new GmailController.ImportGmailMessageRequest(job.Id, "msg-1"), CancellationToken.None); + var secondOk = Assert.IsType(second.Result); + var secondPayload = Assert.IsType(secondOk.Value); + Assert.Equal(0, secondPayload.Imported); + Assert.Equal(1, secondPayload.Skipped); + Assert.Equal("thread-1", secondPayload.ThreadId); + + var storedMessages = await db.Correspondences.Where(message => message.JobApplicationId == job.Id).ToListAsync(); + Assert.Single(storedMessages); + gmail.Verify(service => service.GetMessageAsync("user-1", "msg-1", It.IsAny()), Times.Once); + } + + [Fact] + public async Task Import_thread_reports_imported_and_skipped_counts_for_repeat_calls() + { + await using var db = CreateDb(); + var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; + db.Companies.Add(company); + await db.SaveChangesAsync(); + + var job = new JobApplication + { + JobTitle = "Backend Developer", + CompanyId = company.Id, + OwnerUserId = "user-1" + }; + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + + var gmail = new Mock(); + gmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny())) + .ReturnsAsync(new GmailConnection { OwnerUserId = "user-1", GmailAddress = "user@example.test", EncryptedRefreshToken = "ignored", Scope = "scope", ConnectedAt = DateTimeOffset.UtcNow }); + gmail.Setup(service => service.GetMessageAsync("user-1", "msg-1", It.IsAny())) + .ReturnsAsync(new GmailMessageDetail( + "msg-1", + "thread-1", + "Interview update", + "Maria Recruiter ", + "user@example.test", + DateTimeOffset.UtcNow.AddDays(-1), + "Snippet 1", + "Body text 1", + null)); + gmail.Setup(service => service.GetMessageAsync("user-1", "msg-2", It.IsAny())) + .ReturnsAsync(new GmailMessageDetail( + "msg-2", + "thread-1", + "Interview follow-up", + "user@example.test", + "Maria Recruiter ", + DateTimeOffset.UtcNow, + "Snippet 2", + "Body text 2", + null)); + + var controller = CreateController(db, gmail.Object, "user-1"); + var request = new GmailController.ImportGmailThreadRequest(job.Id, "thread-1", new[] { "msg-1", "msg-2" }); + + var first = await controller.ImportThread(request, CancellationToken.None); + var firstOk = Assert.IsType(first.Result); + var firstPayload = Assert.IsType(firstOk.Value); + Assert.Equal(2, firstPayload.Imported); + Assert.Equal(0, firstPayload.Skipped); + Assert.Equal("thread-1", firstPayload.ThreadId); + + var second = await controller.ImportThread(request, CancellationToken.None); + var secondOk = Assert.IsType(second.Result); + var secondPayload = Assert.IsType(secondOk.Value); + Assert.Equal(0, secondPayload.Imported); + Assert.Equal(2, secondPayload.Skipped); + + var storedMessages = await db.Correspondences.Where(message => message.JobApplicationId == job.Id).OrderBy(message => message.ExternalMessageId).ToListAsync(); + Assert.Equal(2, storedMessages.Count); + Assert.All(storedMessages, message => Assert.Equal("thread-1", message.ExternalThreadId)); + } + + + [Fact] + public async Task Job_candidates_respects_owned_job_scope() + { + await using var db = CreateDb(); + var company = new Company { Name = "OtherCo", OwnerUserId = "user-2" }; + db.Companies.Add(company); + await db.SaveChangesAsync(); + + var foreignJob = new JobApplication + { + JobTitle = "Hidden job", + CompanyId = company.Id, + OwnerUserId = "user-2" + }; + db.JobApplications.Add(foreignJob); + await db.SaveChangesAsync(); + + var gmail = new Mock(MockBehavior.Strict); + var controller = CreateController(db, gmail.Object, "user-1"); + var result = await controller.JobCandidates(foreignJob.Id, null, 6, CancellationToken.None); + + var notFound = Assert.IsType(result.Result); + Assert.Equal("Job application not found.", notFound.Value); + gmail.Verify(service => service.ListMessagesForQueriesAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny()), Times.Never); + } + + private static GmailController CreateController(JobTrackerContext db, IGmailOAuthService gmail, string userId) + { + var controller = new GmailController(gmail, db, BuildConfig()) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, userId) + }, "test")) + } + } + }; + + return controller; + } + + private static JobTrackerContext CreateDb() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var currentUser = new Mock(); + currentUser.SetupGet(service => service.UserId).Returns("user-1"); + return new JobTrackerContext(options, currentUser.Object); + } + private static Microsoft.Extensions.Configuration.IConfiguration BuildConfig() { return new Microsoft.Extensions.Configuration.ConfigurationBuilder() diff --git a/JobTrackerApi/Controllers/CorrespondenceController.cs b/JobTrackerApi/Controllers/CorrespondenceController.cs index 2a256e0..c82c50d 100644 --- a/JobTrackerApi/Controllers/CorrespondenceController.cs +++ b/JobTrackerApi/Controllers/CorrespondenceController.cs @@ -47,7 +47,11 @@ namespace JobTrackerApi.Controllers string Content, string? Subject, string? Channel, - DateTime? Date + DateTime? Date, + string? ExternalMessageId, + string? ExternalThreadId, + string? ExternalFrom, + string? ExternalTo ); // POST new message @@ -67,6 +71,10 @@ namespace JobTrackerApi.Controllers From = request.From.Trim(), Subject = string.IsNullOrWhiteSpace(request.Subject) ? null : request.Subject.Trim(), Channel = string.IsNullOrWhiteSpace(request.Channel) ? null : request.Channel.Trim(), + ExternalMessageId = string.IsNullOrWhiteSpace(request.ExternalMessageId) ? null : request.ExternalMessageId.Trim(), + ExternalThreadId = string.IsNullOrWhiteSpace(request.ExternalThreadId) ? null : request.ExternalThreadId.Trim(), + ExternalFrom = string.IsNullOrWhiteSpace(request.ExternalFrom) ? null : request.ExternalFrom.Trim(), + ExternalTo = string.IsNullOrWhiteSpace(request.ExternalTo) ? null : request.ExternalTo.Trim(), Content = request.Content, Date = request.Date ?? DateTime.Now, }; diff --git a/JobTrackerApi/Controllers/GmailController.cs b/JobTrackerApi/Controllers/GmailController.cs index c4d5ebe..5134d92 100644 --- a/JobTrackerApi/Controllers/GmailController.cs +++ b/JobTrackerApi/Controllers/GmailController.cs @@ -25,8 +25,40 @@ public sealed class GmailController : ControllerBase } public sealed record GmailImportResultDto(int Imported, int Skipped, string? ThreadId); + public sealed record GmailImportMessageResultDto(int Imported, int Skipped, string MessageId, string? ThreadId, Correspondence? Message); public sealed record ImportGmailMessageRequest(int JobApplicationId, string MessageId); public sealed record ImportGmailThreadRequest(int JobApplicationId, string ThreadId, string[] MessageIds); + public sealed record GmailJobMatchReasonDto(string Label, string Value); + public sealed record GmailJobMatchedMessageDto( + string Id, + string ThreadId, + string Subject, + string From, + string To, + DateTimeOffset? Date, + string Snippet, + int Score, + string Confidence, + bool AlreadyImported, + IReadOnlyList MatchReasons); + public sealed record GmailJobMatchedThreadDto( + string ThreadId, + string Subject, + int Score, + string Confidence, + bool HasImportedMessages, + int MessageCount, + DateTimeOffset? LatestDate, + IReadOnlyList MatchReasons, + IReadOnlyList Messages); + public sealed record GmailJobMatchesResponseDto( + int JobApplicationId, + string JobTitle, + string CompanyName, + string? RecruiterName, + string? RecruiterEmail, + IReadOnlyList Queries, + IReadOnlyList Threads); [HttpGet("status")] public async Task Status(CancellationToken cancellationToken) @@ -50,6 +82,94 @@ public sealed class GmailController : ControllerBase return Ok(new { url }); } + [HttpGet("job-candidates")] + public async Task> JobCandidates( + [FromQuery] int jobApplicationId, + [FromQuery] string? queryOverride, + [FromQuery] int maxResultsPerQuery = 6, + CancellationToken cancellationToken = default) + { + if (jobApplicationId <= 0) return BadRequest("Valid jobApplicationId is required."); + + var ownerUserId = GetRequiredOwnerUserId(); + var job = await _db.JobApplications + .Include(x => x.Company) + .Include(x => x.Messages) + .FirstOrDefaultAsync(x => x.Id == jobApplicationId, cancellationToken); + if (job is null) return NotFound("Job application not found."); + + var queries = BuildJobQueries(job, queryOverride); + var messages = await _gmail.ListMessagesForQueriesAsync(ownerUserId, queries, maxResultsPerQuery, cancellationToken); + var importedMessageIds = job.Messages + .Where(message => !string.IsNullOrWhiteSpace(message.ExternalMessageId)) + .Select(message => message.ExternalMessageId!) + .ToHashSet(StringComparer.Ordinal); + + var rankedMessages = messages + .Select(message => ScoreMessage(job, message, importedMessageIds.Contains(message.Id))) + .Where(result => result.Score > 0) + .OrderByDescending(result => result.Score) + .ThenByDescending(result => result.Message.Date ?? DateTimeOffset.MinValue) + .ToList(); + + var threads = rankedMessages + .GroupBy(result => string.IsNullOrWhiteSpace(result.Message.ThreadId) ? result.Message.Id : result.Message.ThreadId, StringComparer.Ordinal) + .Select(group => + { + var ordered = group + .OrderByDescending(item => item.Score) + .ThenByDescending(item => item.Message.Date ?? DateTimeOffset.MinValue) + .ToList(); + var latestDate = ordered + .Select(item => item.Message.Date) + .OrderByDescending(item => item ?? DateTimeOffset.MinValue) + .FirstOrDefault(); + var combinedReasons = ordered + .SelectMany(item => item.Reasons) + .GroupBy(reason => new { reason.Label, reason.Value }) + .Select(reason => reason.First()) + .Take(6) + .ToList(); + var threadScore = ordered.Max(item => item.Score) + Math.Min(ordered.Count - 1, 2); + var hasImportedMessages = ordered.Any(item => item.AlreadyImported); + var representative = ordered[0].Message; + + return new GmailJobMatchedThreadDto( + group.Key, + string.IsNullOrWhiteSpace(representative.Subject) ? "(no subject)" : representative.Subject, + threadScore, + ToConfidence(threadScore), + hasImportedMessages, + ordered.Count, + latestDate, + combinedReasons, + ordered.Select(item => new GmailJobMatchedMessageDto( + item.Message.Id, + item.Message.ThreadId, + item.Message.Subject, + item.Message.From, + item.Message.To, + item.Message.Date, + item.Message.Snippet, + item.Score, + ToConfidence(item.Score), + item.AlreadyImported, + item.Reasons)).ToList()); + }) + .OrderByDescending(thread => thread.Score) + .ThenByDescending(thread => thread.LatestDate ?? DateTimeOffset.MinValue) + .ToList(); + + return Ok(new GmailJobMatchesResponseDto( + job.Id, + job.JobTitle, + job.Company?.Name ?? string.Empty, + job.Company?.RecruiterName, + job.Company?.RecruiterEmail, + queries, + threads)); + } + [AllowAnonymous] [HttpGet("oauth/callback")] public async Task Callback([FromQuery] string? code, [FromQuery] string? state, [FromQuery] string? error, CancellationToken cancellationToken) @@ -98,7 +218,7 @@ public sealed class GmailController : ControllerBase } [HttpPost("import")] - public async Task Import([FromBody] ImportGmailMessageRequest request, CancellationToken cancellationToken) + public async Task> Import([FromBody] ImportGmailMessageRequest request, CancellationToken cancellationToken) { try { @@ -114,12 +234,12 @@ public sealed class GmailController : ControllerBase cancellationToken); if (existing is not null) { - return Ok(existing); + return Ok(new GmailImportMessageResultDto(0, 1, request.MessageId, existing.ExternalThreadId, existing)); } var created = await ImportSingleMessageAsync(ownerUserId, job, request.MessageId, cancellationToken); await _db.SaveChangesAsync(cancellationToken); - return Ok(created); + return Ok(new GmailImportMessageResultDto(1, 0, request.MessageId, created.ExternalThreadId, created)); } catch (Exception ex) { @@ -172,6 +292,9 @@ public sealed class GmailController : ControllerBase Subject = string.IsNullOrWhiteSpace(detail.Subject) ? null : detail.Subject.Trim(), Channel = "Email", ExternalMessageId = detail.Id, + ExternalThreadId = string.IsNullOrWhiteSpace(detail.ThreadId) ? null : detail.ThreadId.Trim(), + ExternalFrom = string.IsNullOrWhiteSpace(detail.From) ? null : detail.From.Trim(), + ExternalTo = string.IsNullOrWhiteSpace(detail.To) ? null : detail.To.Trim(), Content = string.IsNullOrWhiteSpace(detail.BodyText) ? detail.Snippet : detail.BodyText, Date = messageDate, }; @@ -201,6 +324,164 @@ public sealed class GmailController : ControllerBase return message; } + private static IReadOnlyList BuildJobQueries(JobApplication job, string? queryOverride) + { + var queries = new List(); + void Add(string? query) + { + if (!string.IsNullOrWhiteSpace(query)) + { + queries.Add(query.Trim()); + } + } + + Add(queryOverride); + + if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail)) + { + Add($"(from:{job.Company.RecruiterEmail.Trim()} OR to:{job.Company.RecruiterEmail.Trim()}) newer_than:365d"); + } + + if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName)) + { + Add($"\"{job.Company.RecruiterName.Trim()}\" newer_than:365d"); + } + + if (!string.IsNullOrWhiteSpace(job.Company?.Name) && !string.IsNullOrWhiteSpace(job.JobTitle)) + { + Add($"\"{job.Company.Name.Trim()}\" \"{job.JobTitle.Trim()}\" newer_than:365d"); + } + + if (!string.IsNullOrWhiteSpace(job.Company?.Name)) + { + Add($"\"{job.Company.Name.Trim()}\" (application OR interview OR recruiter OR role OR position) newer_than:365d"); + } + + if (!string.IsNullOrWhiteSpace(job.JobTitle)) + { + Add($"subject:\"{job.JobTitle.Trim()}\" newer_than:365d"); + } + + foreach (var subject in job.Messages + .Select(message => message.Subject) + .Where(subject => !string.IsNullOrWhiteSpace(subject)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(2)) + { + Add($"subject:\"{subject!.Trim()}\" newer_than:365d"); + } + + if (queries.Count == 0) + { + Add("newer_than:365d (application OR interview OR recruiter OR role OR position)"); + } + + return queries.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + } + + private static GmailScoredMessage ScoreMessage(JobApplication job, GmailMessageSummary message, bool alreadyImported) + { + var reasons = new List(); + var score = 0; + var subject = message.Subject ?? string.Empty; + var from = message.From ?? string.Empty; + var to = message.To ?? string.Empty; + var snippet = message.Snippet ?? string.Empty; + var haystack = $"{subject} {from} {to} {snippet}"; + + if (!string.IsNullOrWhiteSpace(job.Company?.Name) && ContainsValue(haystack, job.Company.Name)) + { + score += 18; + reasons.Add(new GmailJobMatchReasonDto("company", job.Company.Name.Trim())); + } + + if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail) && (ContainsValue(from, job.Company.RecruiterEmail) || ContainsValue(to, job.Company.RecruiterEmail))) + { + score += 20; + reasons.Add(new GmailJobMatchReasonDto("recruiterEmail", job.Company.RecruiterEmail.Trim())); + } + + if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName) && ContainsValue(haystack, job.Company.RecruiterName)) + { + score += 12; + reasons.Add(new GmailJobMatchReasonDto("recruiter", job.Company.RecruiterName.Trim())); + } + + foreach (var token in SplitTerms(job.JobTitle).Take(4)) + { + if (!ContainsValue(haystack, token)) continue; + score += 5; + reasons.Add(new GmailJobMatchReasonDto("jobTitle", token)); + } + + foreach (var subjectLine in job.Messages + .Select(existing => existing.Subject) + .Where(existing => !string.IsNullOrWhiteSpace(existing)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(2)) + { + if (!ContainsValue(subject, subjectLine!)) continue; + score += 8; + reasons.Add(new GmailJobMatchReasonDto("existingSubject", subjectLine!.Trim())); + } + + if (message.Date is { } messageDate) + { + var ageDays = Math.Abs((DateTimeOffset.UtcNow - messageDate).TotalDays); + if (ageDays <= 45) + { + score += 4; + reasons.Add(new GmailJobMatchReasonDto("recency", "45d")); + } + else if (ageDays <= 180) + { + score += 2; + reasons.Add(new GmailJobMatchReasonDto("recency", "180d")); + } + } + + if (alreadyImported) + { + reasons.Add(new GmailJobMatchReasonDto("status", "already-imported")); + } + + reasons = reasons + .GroupBy(reason => new { reason.Label, reason.Value }) + .Select(group => group.First()) + .ToList(); + + return new GmailScoredMessage(message, alreadyImported, score, reasons); + } + + private static bool ContainsValue(string haystack, string? value) + { + return !string.IsNullOrWhiteSpace(value) + && haystack.Contains(value.Trim(), StringComparison.OrdinalIgnoreCase); + } + + private static IEnumerable SplitTerms(string? value) + { + if (string.IsNullOrWhiteSpace(value)) yield break; + + foreach (var token in value + .Split(new[] { ' ', '/', '-', ',', '.', '(', ')', ':' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(token => token.Length >= 3) + .Distinct(StringComparer.OrdinalIgnoreCase)) + { + yield return token; + } + } + + private static string ToConfidence(int score) + { + return score switch + { + >= 30 => "high", + >= 16 => "medium", + _ => "low" + }; + } + private string GetRequiredOwnerUserId() { return User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") @@ -246,4 +527,10 @@ public sealed class GmailController : ControllerBase "; } + + private sealed record GmailScoredMessage( + GmailMessageSummary Message, + bool AlreadyImported, + int Score, + IReadOnlyList Reasons); } diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index 790a1be..cae2eb9 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -653,6 +653,9 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" ( EnsureColumn(conn, "Correspondences", "Subject", "ALTER TABLE Correspondences ADD COLUMN Subject TEXT NULL;"); EnsureColumn(conn, "Correspondences", "Channel", "ALTER TABLE Correspondences ADD COLUMN Channel TEXT NULL;"); EnsureColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE Correspondences ADD COLUMN ExternalMessageId TEXT NULL;"); + EnsureColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE Correspondences ADD COLUMN ExternalThreadId TEXT NULL;"); + EnsureColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE Correspondences ADD COLUMN ExternalFrom TEXT NULL;"); + EnsureColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE Correspondences ADD COLUMN ExternalTo TEXT NULL;"); } // Record the migration as applied. @@ -677,6 +680,9 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" ( EnsureColumn(conn, "Correspondences", "Subject", "ALTER TABLE Correspondences ADD COLUMN Subject TEXT NULL;"); EnsureColumn(conn, "Correspondences", "Channel", "ALTER TABLE Correspondences ADD COLUMN Channel TEXT NULL;"); EnsureColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE Correspondences ADD COLUMN ExternalMessageId TEXT NULL;"); + EnsureColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE Correspondences ADD COLUMN ExternalThreadId TEXT NULL;"); + EnsureColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE Correspondences ADD COLUMN ExternalFrom TEXT NULL;"); + EnsureColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE Correspondences ADD COLUMN ExternalTo TEXT NULL;"); EnsureColumn(conn, "Attachments", "Purpose", "ALTER TABLE Attachments ADD COLUMN Purpose TEXT NULL;"); EnsureColumn(conn, "Attachments", "UseForAi", "ALTER TABLE Attachments ADD COLUMN UseForAi INTEGER NOT NULL DEFAULT 1;"); @@ -733,6 +739,9 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" ( EnsureMySqlColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE `Companies` ADD COLUMN `OwnerUserId` varchar(255) NULL;"); EnsureMySqlColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE `JobApplications` ADD COLUMN `OwnerUserId` varchar(255) NULL;"); EnsureMySqlColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE `JobApplications` ADD COLUMN `LastReminderEmailSentAt` datetime NULL;"); + EnsureMySqlColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalThreadId` longtext NULL;"); + EnsureMySqlColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalFrom` longtext NULL;"); + EnsureMySqlColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalTo` longtext NULL;"); EnsureMySqlColumn(conn, "Attachments", "Purpose", "ALTER TABLE `Attachments` ADD COLUMN `Purpose` varchar(100) NULL;"); EnsureMySqlColumn(conn, "Attachments", "UseForAi", "ALTER TABLE `Attachments` ADD COLUMN `UseForAi` tinyint(1) NOT NULL DEFAULT 1;"); EnsureMySqlColumn(conn, "AspNetUsers", "ProfileCvStructureJson", "ALTER TABLE `AspNetUsers` ADD COLUMN `ProfileCvStructureJson` longtext NULL;"); diff --git a/JobTrackerApi/Services/GmailOAuthService.cs b/JobTrackerApi/Services/GmailOAuthService.cs index 9bfc77c..5e869ee 100644 --- a/JobTrackerApi/Services/GmailOAuthService.cs +++ b/JobTrackerApi/Services/GmailOAuthService.cs @@ -18,6 +18,7 @@ public interface IGmailOAuthService Task GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken); Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken); Task> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken); + Task> ListMessagesForQueriesAsync(string ownerUserId, IEnumerable queries, int maxResultsPerQuery, CancellationToken cancellationToken); Task GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken); } @@ -180,6 +181,33 @@ public sealed class GmailOAuthService : IGmailOAuthService return results; } + public async Task> ListMessagesForQueriesAsync(string ownerUserId, IEnumerable queries, int maxResultsPerQuery, CancellationToken cancellationToken) + { + maxResultsPerQuery = Math.Clamp(maxResultsPerQuery, 1, 25); + var normalizedQueries = queries + .Where(static query => !string.IsNullOrWhiteSpace(query)) + .Select(static query => query.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (normalizedQueries.Count == 0) + { + return Array.Empty(); + } + + var combined = new List(); + foreach (var query in normalizedQueries) + { + var items = await ListMessagesAsync(ownerUserId, query, maxResultsPerQuery, cancellationToken); + combined.AddRange(items); + } + + return combined + .GroupBy(message => message.Id, StringComparer.Ordinal) + .Select(group => group.First()) + .ToList(); + } + public async Task GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken) { var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken); diff --git a/Models/Correspondence.cs b/Models/Correspondence.cs index 3f6864c..002be6d 100644 --- a/Models/Correspondence.cs +++ b/Models/Correspondence.cs @@ -4,17 +4,20 @@ using System.Text.Json.Serialization; namespace JobTrackerApi.Models { public class Correspondence -{ - public int Id { get; set; } - public int JobApplicationId { get; set; } + { + public int Id { get; set; } + public int JobApplicationId { get; set; } - [JsonIgnore] - public JobApplication JobApplication { get; set; } = null!; - 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; -} + [JsonIgnore] + public JobApplication JobApplication { get; set; } = null!; + 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? ExternalThreadId { get; set; } + public string? ExternalFrom { get; set; } + public string? ExternalTo { get; set; } + public string Content { get; set; } = ""; + public DateTime Date { get; set; } = DateTime.Now; + } } diff --git a/job-tracker-ui/src/components/Correspondence.tsx b/job-tracker-ui/src/components/Correspondence.tsx index c756d34..3c736f9 100644 --- a/job-tracker-ui/src/components/Correspondence.tsx +++ b/job-tracker-ui/src/components/Correspondence.tsx @@ -25,11 +25,19 @@ import { alpha, useTheme } from "@mui/material/styles"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome"; import MailOutlineIcon from "@mui/icons-material/MailOutline"; +import SearchIcon from "@mui/icons-material/Search"; import { IconButton } from "@mui/material"; import { api, getApiErrorMessage } from "../api"; import { useToast } from "../toast"; -import { CorrespondenceMessage, GmailMessageSummary, GmailStatus } from "../types"; +import { + CorrespondenceMessage, + GmailImportMessageResult, + GmailImportThreadResult, + GmailJobMatchesResponse, + GmailStatus, + JobApplication, +} from "../types"; import { useDialogActions } from "../dialogs"; import { useI18n } from "../i18n/I18nProvider"; @@ -63,22 +71,32 @@ function parseRawEmail(raw: string): { subject?: string; date?: string; from?: s return { subject: headers["subject"], from: headers["from"], to: headers["to"], date: iso, body }; } -function scoreMessage(message: GmailMessageSummary, query: string, messages: CorrespondenceMessage[]) { - const hay = `${message.subject} ${message.from} ${message.to} ${message.snippet}`.toLowerCase(); - const q = query.toLowerCase(); - let score = 0; - if (q && hay.includes(q)) score += 8; - if (/interview|application|recruit|follow up|follow-up|position|role/.test(hay)) score += 2; - if (messages.some((m) => m.subject && message.subject && m.subject.toLowerCase() === message.subject.toLowerCase())) score -= 4; - if (message.date) { - const ageDays = Math.abs((Date.now() - new Date(message.date).getTime()) / 86400000); - if (ageDays <= 30) score += 3; - else if (ageDays <= 120) score += 1; - } - return score; +function formatConfidence(value: string) { + return value ? `${value[0].toUpperCase()}${value.slice(1)} confidence` : "Confidence unknown"; } -export default function Correspondence({ jobId }: { jobId: number }) { +function formatReasonLabel(label: string) { + switch (label) { + case "company": + return "Company"; + case "recruiterEmail": + return "Recruiter email"; + case "recruiter": + return "Recruiter"; + case "jobTitle": + return "Job title"; + case "existingSubject": + return "Existing subject"; + case "recency": + return "Recent"; + case "status": + return "Status"; + default: + return label; + } +} + +export default function Correspondence({ jobId, job }: { jobId: number; job: JobApplication | null }) { const theme = useTheme(); const { toast } = useToast(); const { t } = useI18n(); @@ -95,8 +113,8 @@ export default function Correspondence({ jobId }: { jobId: number }) { const [gmailStatus, setGmailStatus] = useState(null); const [gmailLoading, setGmailLoading] = useState(false); const [gmailQuery, setGmailQuery] = useState(""); - const [gmailMessages, setGmailMessages] = useState([]); - const [gmailMessagesLoading, setGmailMessagesLoading] = useState(false); + const [gmailMatches, setGmailMatches] = useState(null); + const [gmailMatchesLoading, setGmailMatchesLoading] = useState(false); const [importingMessageId, setImportingMessageId] = useState(null); const [importingThreadId, setImportingThreadId] = useState(null); @@ -117,22 +135,23 @@ export default function Correspondence({ jobId }: { jobId: number }) { } }, []); - const loadGmailMessages = useCallback(async () => { + const loadGmailMatches = useCallback(async (queryOverride?: string) => { try { - setGmailMessagesLoading(true); - const res = await api.get("/gmail/messages", { + setGmailMatchesLoading(true); + const res = await api.get("/gmail/job-candidates", { params: { - query: gmailQuery.trim() || undefined, - maxResults: 12, + jobApplicationId: jobId, + queryOverride: queryOverride?.trim() || undefined, + maxResultsPerQuery: 6, }, }); - setGmailMessages(res.data); + setGmailMatches(res.data); } catch (error: any) { - toast(getApiErrorMessage(error, "Failed to load Gmail messages."), "error"); + toast(getApiErrorMessage(error, "Failed to load Gmail suggestions."), "error"); } finally { - setGmailMessagesLoading(false); + setGmailMatchesLoading(false); } - }, [gmailQuery, toast]); + }, [jobId, toast]); useEffect(() => { void load(); @@ -151,8 +170,8 @@ export default function Correspondence({ jobId }: { jobId: number }) { useEffect(() => { if (!importOpen || importTab !== 1 || !gmailStatus?.connected) return; - void loadGmailMessages(); - }, [importOpen, importTab, gmailStatus?.connected, loadGmailMessages]); + void loadGmailMatches(gmailQuery); + }, [importOpen, importTab, gmailStatus?.connected, gmailQuery, loadGmailMatches]); useEffect(() => { const onMessage = (event: MessageEvent) => { @@ -162,7 +181,7 @@ export default function Correspondence({ jobId }: { jobId: number }) { toast(data.message || t("googleLinkedSuccess"), "success"); void loadGmailStatus(); setImportTab(1); - void loadGmailMessages(); + void loadGmailMatches(gmailQuery); } else { toast(data.message || t("googleAuthFailed"), "error"); } @@ -170,36 +189,24 @@ export default function Correspondence({ jobId }: { jobId: number }) { window.addEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage); - }, [loadGmailMessages, loadGmailStatus, t, toast]); + }, [gmailQuery, loadGmailMatches, loadGmailStatus, t, toast]); const canSend = useMemo(() => text.trim().length > 0, [text]); const suggestedQueries = useMemo(() => { - const subjectTerms = messages.map((m) => m.subject).filter(Boolean) as string[]; - const uniqueSubjects = Array.from(new Set(subjectTerms)).slice(0, 2); - const companyTerms = Array.from(new Set(messages.filter((m) => m.from === "Company").map((m) => m.subject).filter(Boolean) as string[])).slice(0, 2); + const fromSubjects = messages.map((m) => m.subject).filter(Boolean) as string[]; + const uniqueSubjects = Array.from(new Set(fromSubjects)).slice(0, 2); + const companyName = job?.company?.name?.trim(); + const recruiterEmail = job?.company?.recruiterEmail?.trim(); + const jobTitle = job?.jobTitle?.trim(); return [ - { label: "Recent recruiting mail", value: "newer_than:180d (interview OR recruiter OR application OR position)" }, - { label: "Inbox only", value: "label:inbox newer_than:120d" }, - { label: "Sent follow-ups", value: "in:sent newer_than:180d follow up" }, - ...uniqueSubjects.map((s) => ({ label: `Subject: ${s}`, value: `subject:"${s}"` })), - ...companyTerms.map((s) => ({ label: `Related topic: ${s}`, value: `"${s}" newer_than:180d` })), - ].slice(0, 6); - }, [messages]); - - const rankedMessages = useMemo(() => { - return [...gmailMessages].sort((a, b) => scoreMessage(b, gmailQuery, messages) - scoreMessage(a, gmailQuery, messages)); - }, [gmailMessages, gmailQuery, messages]); - - const groupedByThread = useMemo(() => { - const map = new Map(); - for (const message of rankedMessages) { - const key = message.threadId || message.id; - map.set(key, [...(map.get(key) ?? []), message]); - } - return Array.from(map.entries()).map(([threadId, items]) => ({ threadId, items })); - }, [rankedMessages]); + recruiterEmail ? { label: "Recruiter mailbox", value: `(from:${recruiterEmail} OR to:${recruiterEmail}) newer_than:365d` } : null, + companyName && jobTitle ? { label: "Company + role", value: `"${companyName}" "${jobTitle}" newer_than:365d` } : null, + companyName ? { label: "Company mail", value: `"${companyName}" (application OR interview OR recruiter) newer_than:365d` } : null, + ...uniqueSubjects.map((subject) => ({ label: `Subject: ${subject}`, value: `subject:"${subject}" newer_than:365d` })), + ].filter(Boolean).slice(0, 6) as Array<{ label: string; value: string }>; + }, [job?.company?.name, job?.company?.recruiterEmail, job?.jobTitle, messages]); const send = async () => { if (!canSend) return; @@ -227,6 +234,8 @@ export default function Correspondence({ jobId }: { jobId: number }) { subject: parsed.subject ?? null, content: parsed.body || rawEmail, date: parsed.date ?? null, + externalFrom: parsed.from ?? null, + externalTo: parsed.to ?? null, }); setImportOpen(false); setRawEmail(""); @@ -251,7 +260,7 @@ export default function Correspondence({ jobId }: { jobId: number }) { try { await api.delete("/gmail/connection"); setGmailStatus({ connected: false }); - setGmailMessages([]); + setGmailMatches(null); toast(t("googleUnlinked"), "success"); } catch (error) { toast(getApiErrorMessage(error, t("correspondenceDisconnectFailed")), "error"); @@ -272,9 +281,14 @@ export default function Correspondence({ jobId }: { jobId: number }) { const importGmailMessage = async (messageId: string) => { try { setImportingMessageId(messageId); - await api.post("/gmail/import", { jobApplicationId: jobId, messageId }); + const res = await api.post("/gmail/import", { jobApplicationId: jobId, messageId }); await load(); - toast(t("correspondenceImportEmail"), "success"); + await loadGmailMatches(gmailQuery); + if (res.data.imported > 0) { + toast(t("correspondenceImportEmail"), "success"); + } else { + toast("This Gmail message is already linked to the job.", "success"); + } } catch (error: any) { toast(getApiErrorMessage(error, t("correspondenceImportGmailFailed")), "error"); } finally { @@ -285,8 +299,9 @@ export default function Correspondence({ jobId }: { jobId: number }) { const importGmailThread = async (threadId: string, messageIds: string[]) => { try { setImportingThreadId(threadId); - const res = await api.post<{ imported: number; skipped: number; threadId?: string }>("/gmail/import-thread", { jobApplicationId: jobId, threadId, messageIds }); + const res = await api.post("/gmail/import-thread", { jobApplicationId: jobId, threadId, messageIds }); await load(); + await loadGmailMatches(gmailQuery); toast(t("correspondenceImportThreadResult", { imported: res.data.imported, skippedText: res.data.skipped ? t("correspondenceImportThreadSkipped", { count: res.data.skipped }) : "" }), "success"); } catch (error: any) { toast(getApiErrorMessage(error, t("correspondenceImportThreadFailed")), "error"); @@ -310,6 +325,13 @@ export default function Correspondence({ jobId }: { jobId: number }) { {m.subject ? {m.subject} : null} {m.content} + {(m.externalThreadId || m.externalFrom || m.externalTo) ? ( + + {m.externalThreadId ? : null} + {m.externalFrom ? : null} + {m.externalTo ? : null} + + ) : null} {isMe ? t("correspondenceMe") : t("correspondenceCompany")}{m.channel ? ` - ${m.channel}` : ""}{m.date ? ` - ${new Date(m.date).toLocaleString()}` : ""} @@ -360,11 +382,16 @@ export default function Correspondence({ jobId }: { jobId: number }) { {gmailLoading ? t("correspondenceCheckingConnection") : gmailStatus?.connected ? t("correspondenceConnectedAs", { email: gmailStatus.gmailAddress || "" }) : t("correspondenceConnectGmailHint")} + {job ? ( + + Matching against {job.company?.name || "this company"} / {job.jobTitle} + + ) : null} {gmailStatus?.connected ? ( <> - + ) : ( @@ -382,36 +409,65 @@ export default function Correspondence({ jobId }: { jobId: number }) { setGmailQuery(e.target.value)} placeholder={t("correspondenceSearchGmailPlaceholder")} size="small" fullWidth /> - + + {gmailMatches?.queries?.length ? ( + + {gmailMatches.queries.slice(0, 4).map((query) => ( + + ))} + + ) : null} {gmailStatus.lastSyncedAt ? : null} - {gmailMessagesLoading ? ( + {gmailMatchesLoading ? ( - ) : groupedByThread.length === 0 ? ( - {t("correspondenceNoGmailMessages")} + ) : !gmailMatches || gmailMatches.threads.length === 0 ? ( + + {gmailQuery.trim() ? "No Gmail matches for this job and search override yet." : t("correspondenceNoGmailMessages")} + ) : ( - {groupedByThread.map(({ threadId, items }, threadIndex) => ( - + {gmailMatches.threads.map((thread, threadIndex) => ( + {threadIndex > 0 ? : null} - {items[0]?.subject || t("correspondenceNoSubject")} - {t("correspondenceMessagesInThread", { count: items.length })} + {thread.subject || t("correspondenceNoSubject")} + + + + {thread.hasImportedMessages ? : null} + + + {thread.matchReasons.map((reason) => ( + + ))} + - - {items.map((message, index) => ( + {thread.messages.map((message, index) => ( {index > 0 ? : null} {message.subject || t("correspondenceNoSubject")}{message.date ? new Date(message.date).toLocaleString() : ""}} - secondary={{t("correspondenceFromLabel", { value: message.from || t("correspondenceUnknown") })}{message.snippet}} + secondary={ + {t("correspondenceFromLabel", { value: message.from || t("correspondenceUnknown") })} + {message.snippet} + + + {message.alreadyImported ? : null} + {message.matchReasons.map((reason) => ( + + ))} + + } />