Complete S01 Gmail matching and import workflow

This commit is contained in:
2026-03-24 10:06:50 +01:00
parent 92ccf47446
commit 3e5f796326
13 changed files with 1043 additions and 105 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ The product must let one person run a real job search without losing the thread:
## Current State ## 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 ## Architecture / Key Patterns
+1 -1
View File
@@ -50,7 +50,7 @@ This milestone is complete only when all are true:
## Slices ## 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. > 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]` - [ ] **S02: Stronger AI application package drafting** `risk:high` `depends:[S01]`
+3 -3
View File
@@ -38,19 +38,19 @@ S01 directly advances active requirements **R001** and **R002**, and it also lay
## Tasks ## 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. - 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` - 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`. - 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` - 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. - 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. - 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` - 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. - 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` - 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. - 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. - 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` - 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. - 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.
+272 -13
View File
@@ -1,7 +1,12 @@
using System.Security.Claims;
using JobTrackerApi.Controllers; using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using JobTrackerApi.Models; using JobTrackerApi.Models;
using JobTrackerApi.Services; using JobTrackerApi.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Moq; using Moq;
using Xunit; using Xunit;
@@ -12,19 +17,8 @@ public sealed class GmailControllerTests
[Fact] [Fact]
public async Task Import_thread_rejects_missing_message_ids() public async Task Import_thread_rejects_missing_message_ids()
{ {
var controller = new GmailController(Mock.Of<IGmailOAuthService>(), null!, BuildConfig()) await using var db = CreateDb();
{ var controller = CreateController(db, Mock.Of<IGmailOAuthService>(), "user-1");
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"))
}
}
};
var result = await controller.ImportThread(new GmailController.ImportGmailThreadRequest(1, "thread-1", Array.Empty<string>()), CancellationToken.None); var result = await controller.ImportThread(new GmailController.ImportGmailThreadRequest(1, "thread-1", Array.Empty<string>()), CancellationToken.None);
@@ -32,6 +26,271 @@ public sealed class GmailControllerTests
Assert.Equal("At least one messageId is required.", badRequest.Value); 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<IGmailOAuthService>();
gmail.Setup(service => service.ListMessagesForQueriesAsync("user-1", It.IsAny<IEnumerable<string>>(), 6, It.IsAny<CancellationToken>()))
.ReturnsAsync(new[]
{
new GmailMessageSummary(
"msg-top",
"thread-top",
"Backend Developer interview",
"Maria Recruiter <maria@acme.test>",
"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 <maria@acme.test>",
"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<OkObjectResult>(result.Result);
var payload = Assert.IsType<GmailController.GmailJobMatchesResponseDto>(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<IGmailOAuthService>();
gmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
.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<CancellationToken>()))
.ReturnsAsync(new GmailMessageDetail(
"msg-1",
"thread-1",
"Interview update",
"Maria Recruiter <maria@acme.test>",
"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<OkObjectResult>(first.Result);
var firstPayload = Assert.IsType<GmailController.GmailImportMessageResultDto>(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 <maria@acme.test>", 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<OkObjectResult>(second.Result);
var secondPayload = Assert.IsType<GmailController.GmailImportMessageResultDto>(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<CancellationToken>()), 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<IGmailOAuthService>();
gmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
.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<CancellationToken>()))
.ReturnsAsync(new GmailMessageDetail(
"msg-1",
"thread-1",
"Interview update",
"Maria Recruiter <maria@acme.test>",
"user@example.test",
DateTimeOffset.UtcNow.AddDays(-1),
"Snippet 1",
"Body text 1",
null));
gmail.Setup(service => service.GetMessageAsync("user-1", "msg-2", It.IsAny<CancellationToken>()))
.ReturnsAsync(new GmailMessageDetail(
"msg-2",
"thread-1",
"Interview follow-up",
"user@example.test",
"Maria Recruiter <maria@acme.test>",
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<OkObjectResult>(first.Result);
var firstPayload = Assert.IsType<GmailController.GmailImportResultDto>(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<OkObjectResult>(second.Result);
var secondPayload = Assert.IsType<GmailController.GmailImportResultDto>(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<IGmailOAuthService>(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<NotFoundObjectResult>(result.Result);
Assert.Equal("Job application not found.", notFound.Value);
gmail.Verify(service => service.ListMessagesForQueriesAsync(It.IsAny<string>(), It.IsAny<IEnumerable<string>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), 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<JobTrackerContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var currentUser = new Mock<ICurrentUserService>();
currentUser.SetupGet(service => service.UserId).Returns("user-1");
return new JobTrackerContext(options, currentUser.Object);
}
private static Microsoft.Extensions.Configuration.IConfiguration BuildConfig() private static Microsoft.Extensions.Configuration.IConfiguration BuildConfig()
{ {
return new Microsoft.Extensions.Configuration.ConfigurationBuilder() return new Microsoft.Extensions.Configuration.ConfigurationBuilder()
@@ -47,7 +47,11 @@ namespace JobTrackerApi.Controllers
string Content, string Content,
string? Subject, string? Subject,
string? Channel, string? Channel,
DateTime? Date DateTime? Date,
string? ExternalMessageId,
string? ExternalThreadId,
string? ExternalFrom,
string? ExternalTo
); );
// POST new message // POST new message
@@ -67,6 +71,10 @@ namespace JobTrackerApi.Controllers
From = request.From.Trim(), From = request.From.Trim(),
Subject = string.IsNullOrWhiteSpace(request.Subject) ? null : request.Subject.Trim(), Subject = string.IsNullOrWhiteSpace(request.Subject) ? null : request.Subject.Trim(),
Channel = string.IsNullOrWhiteSpace(request.Channel) ? null : request.Channel.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, Content = request.Content,
Date = request.Date ?? DateTime.Now, Date = request.Date ?? DateTime.Now,
}; };
+290 -3
View File
@@ -25,8 +25,40 @@ public sealed class GmailController : ControllerBase
} }
public sealed record GmailImportResultDto(int Imported, int Skipped, string? ThreadId); 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 ImportGmailMessageRequest(int JobApplicationId, string MessageId);
public sealed record ImportGmailThreadRequest(int JobApplicationId, string ThreadId, string[] MessageIds); 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<GmailJobMatchReasonDto> MatchReasons);
public sealed record GmailJobMatchedThreadDto(
string ThreadId,
string Subject,
int Score,
string Confidence,
bool HasImportedMessages,
int MessageCount,
DateTimeOffset? LatestDate,
IReadOnlyList<GmailJobMatchReasonDto> MatchReasons,
IReadOnlyList<GmailJobMatchedMessageDto> Messages);
public sealed record GmailJobMatchesResponseDto(
int JobApplicationId,
string JobTitle,
string CompanyName,
string? RecruiterName,
string? RecruiterEmail,
IReadOnlyList<string> Queries,
IReadOnlyList<GmailJobMatchedThreadDto> Threads);
[HttpGet("status")] [HttpGet("status")]
public async Task<IActionResult> Status(CancellationToken cancellationToken) public async Task<IActionResult> Status(CancellationToken cancellationToken)
@@ -50,6 +82,94 @@ public sealed class GmailController : ControllerBase
return Ok(new { url }); return Ok(new { url });
} }
[HttpGet("job-candidates")]
public async Task<ActionResult<GmailJobMatchesResponseDto>> 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] [AllowAnonymous]
[HttpGet("oauth/callback")] [HttpGet("oauth/callback")]
public async Task<IActionResult> Callback([FromQuery] string? code, [FromQuery] string? state, [FromQuery] string? error, CancellationToken cancellationToken) public async Task<IActionResult> Callback([FromQuery] string? code, [FromQuery] string? state, [FromQuery] string? error, CancellationToken cancellationToken)
@@ -98,7 +218,7 @@ public sealed class GmailController : ControllerBase
} }
[HttpPost("import")] [HttpPost("import")]
public async Task<IActionResult> Import([FromBody] ImportGmailMessageRequest request, CancellationToken cancellationToken) public async Task<ActionResult<GmailImportMessageResultDto>> Import([FromBody] ImportGmailMessageRequest request, CancellationToken cancellationToken)
{ {
try try
{ {
@@ -114,12 +234,12 @@ public sealed class GmailController : ControllerBase
cancellationToken); cancellationToken);
if (existing is not null) 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); var created = await ImportSingleMessageAsync(ownerUserId, job, request.MessageId, cancellationToken);
await _db.SaveChangesAsync(cancellationToken); await _db.SaveChangesAsync(cancellationToken);
return Ok(created); return Ok(new GmailImportMessageResultDto(1, 0, request.MessageId, created.ExternalThreadId, created));
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -172,6 +292,9 @@ public sealed class GmailController : ControllerBase
Subject = string.IsNullOrWhiteSpace(detail.Subject) ? null : detail.Subject.Trim(), Subject = string.IsNullOrWhiteSpace(detail.Subject) ? null : detail.Subject.Trim(),
Channel = "Email", Channel = "Email",
ExternalMessageId = detail.Id, 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, Content = string.IsNullOrWhiteSpace(detail.BodyText) ? detail.Snippet : detail.BodyText,
Date = messageDate, Date = messageDate,
}; };
@@ -201,6 +324,164 @@ public sealed class GmailController : ControllerBase
return message; return message;
} }
private static IReadOnlyList<string> BuildJobQueries(JobApplication job, string? queryOverride)
{
var queries = new List<string>();
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<GmailJobMatchReasonDto>();
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<string> 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() private string GetRequiredOwnerUserId()
{ {
return User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") return User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")
@@ -246,4 +527,10 @@ public sealed class GmailController : ControllerBase
</body> </body>
</html>"; </html>";
} }
private sealed record GmailScoredMessage(
GmailMessageSummary Message,
bool AlreadyImported,
int Score,
IReadOnlyList<GmailJobMatchReasonDto> Reasons);
} }
+9
View File
@@ -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", "Subject", "ALTER TABLE Correspondences ADD COLUMN Subject TEXT NULL;");
EnsureColumn(conn, "Correspondences", "Channel", "ALTER TABLE Correspondences ADD COLUMN Channel 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", "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. // 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", "Subject", "ALTER TABLE Correspondences ADD COLUMN Subject TEXT NULL;");
EnsureColumn(conn, "Correspondences", "Channel", "ALTER TABLE Correspondences ADD COLUMN Channel 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", "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", "Purpose", "ALTER TABLE Attachments ADD COLUMN Purpose TEXT NULL;");
EnsureColumn(conn, "Attachments", "UseForAi", "ALTER TABLE Attachments ADD COLUMN UseForAi INTEGER NOT NULL DEFAULT 1;"); 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, "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", "OwnerUserId", "ALTER TABLE `JobApplications` ADD COLUMN `OwnerUserId` varchar(255) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE `JobApplications` ADD COLUMN `LastReminderEmailSentAt` datetime 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", "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, "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;"); EnsureMySqlColumn(conn, "AspNetUsers", "ProfileCvStructureJson", "ALTER TABLE `AspNetUsers` ADD COLUMN `ProfileCvStructureJson` longtext NULL;");
@@ -18,6 +18,7 @@ public interface IGmailOAuthService
Task<GmailConnection?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken); Task<GmailConnection?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken);
Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken); Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken);
Task<IReadOnlyList<GmailMessageSummary>> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken); Task<IReadOnlyList<GmailMessageSummary>> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken);
Task<IReadOnlyList<GmailMessageSummary>> ListMessagesForQueriesAsync(string ownerUserId, IEnumerable<string> queries, int maxResultsPerQuery, CancellationToken cancellationToken);
Task<GmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken); Task<GmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken);
} }
@@ -180,6 +181,33 @@ public sealed class GmailOAuthService : IGmailOAuthService
return results; return results;
} }
public async Task<IReadOnlyList<GmailMessageSummary>> ListMessagesForQueriesAsync(string ownerUserId, IEnumerable<string> 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<GmailMessageSummary>();
}
var combined = new List<GmailMessageSummary>();
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<GmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken) public async Task<GmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken)
{ {
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken); var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
+15 -12
View File
@@ -4,17 +4,20 @@ using System.Text.Json.Serialization;
namespace JobTrackerApi.Models namespace JobTrackerApi.Models
{ {
public class Correspondence public class Correspondence
{ {
public int Id { get; set; } public int Id { get; set; }
public int JobApplicationId { get; set; } public int JobApplicationId { get; set; }
[JsonIgnore] [JsonIgnore]
public JobApplication JobApplication { get; set; } = null!; public JobApplication JobApplication { get; set; } = null!;
public string From { get; set; } = ""; // "Me" or "Company" public string From { get; set; } = ""; // "Me" or "Company"
public string? Subject { get; set; } public string? Subject { get; set; }
public string? Channel { get; set; } // e.g. Email, Call, Note public string? Channel { get; set; } // e.g. Email, Call, Note
public string? ExternalMessageId { get; set; } public string? ExternalMessageId { get; set; }
public string Content { get; set; } = ""; public string? ExternalThreadId { get; set; }
public DateTime Date { get; set; } = DateTime.Now; public string? ExternalFrom { get; set; }
} public string? ExternalTo { get; set; }
public string Content { get; set; } = "";
public DateTime Date { get; set; } = DateTime.Now;
}
} }
+126 -70
View File
@@ -25,11 +25,19 @@ import { alpha, useTheme } from "@mui/material/styles";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome"; import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome";
import MailOutlineIcon from "@mui/icons-material/MailOutline"; import MailOutlineIcon from "@mui/icons-material/MailOutline";
import SearchIcon from "@mui/icons-material/Search";
import { IconButton } from "@mui/material"; import { IconButton } from "@mui/material";
import { api, getApiErrorMessage } from "../api"; import { api, getApiErrorMessage } from "../api";
import { useToast } from "../toast"; import { useToast } from "../toast";
import { CorrespondenceMessage, GmailMessageSummary, GmailStatus } from "../types"; import {
CorrespondenceMessage,
GmailImportMessageResult,
GmailImportThreadResult,
GmailJobMatchesResponse,
GmailStatus,
JobApplication,
} from "../types";
import { useDialogActions } from "../dialogs"; import { useDialogActions } from "../dialogs";
import { useI18n } from "../i18n/I18nProvider"; 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 }; return { subject: headers["subject"], from: headers["from"], to: headers["to"], date: iso, body };
} }
function scoreMessage(message: GmailMessageSummary, query: string, messages: CorrespondenceMessage[]) { function formatConfidence(value: string) {
const hay = `${message.subject} ${message.from} ${message.to} ${message.snippet}`.toLowerCase(); return value ? `${value[0].toUpperCase()}${value.slice(1)} confidence` : "Confidence unknown";
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;
} }
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 theme = useTheme();
const { toast } = useToast(); const { toast } = useToast();
const { t } = useI18n(); const { t } = useI18n();
@@ -95,8 +113,8 @@ export default function Correspondence({ jobId }: { jobId: number }) {
const [gmailStatus, setGmailStatus] = useState<GmailStatus | null>(null); const [gmailStatus, setGmailStatus] = useState<GmailStatus | null>(null);
const [gmailLoading, setGmailLoading] = useState(false); const [gmailLoading, setGmailLoading] = useState(false);
const [gmailQuery, setGmailQuery] = useState(""); const [gmailQuery, setGmailQuery] = useState("");
const [gmailMessages, setGmailMessages] = useState<GmailMessageSummary[]>([]); const [gmailMatches, setGmailMatches] = useState<GmailJobMatchesResponse | null>(null);
const [gmailMessagesLoading, setGmailMessagesLoading] = useState(false); const [gmailMatchesLoading, setGmailMatchesLoading] = useState(false);
const [importingMessageId, setImportingMessageId] = useState<string | null>(null); const [importingMessageId, setImportingMessageId] = useState<string | null>(null);
const [importingThreadId, setImportingThreadId] = useState<string | null>(null); const [importingThreadId, setImportingThreadId] = useState<string | null>(null);
@@ -117,22 +135,23 @@ export default function Correspondence({ jobId }: { jobId: number }) {
} }
}, []); }, []);
const loadGmailMessages = useCallback(async () => { const loadGmailMatches = useCallback(async (queryOverride?: string) => {
try { try {
setGmailMessagesLoading(true); setGmailMatchesLoading(true);
const res = await api.get<GmailMessageSummary[]>("/gmail/messages", { const res = await api.get<GmailJobMatchesResponse>("/gmail/job-candidates", {
params: { params: {
query: gmailQuery.trim() || undefined, jobApplicationId: jobId,
maxResults: 12, queryOverride: queryOverride?.trim() || undefined,
maxResultsPerQuery: 6,
}, },
}); });
setGmailMessages(res.data); setGmailMatches(res.data);
} catch (error: any) { } catch (error: any) {
toast(getApiErrorMessage(error, "Failed to load Gmail messages."), "error"); toast(getApiErrorMessage(error, "Failed to load Gmail suggestions."), "error");
} finally { } finally {
setGmailMessagesLoading(false); setGmailMatchesLoading(false);
} }
}, [gmailQuery, toast]); }, [jobId, toast]);
useEffect(() => { useEffect(() => {
void load(); void load();
@@ -151,8 +170,8 @@ export default function Correspondence({ jobId }: { jobId: number }) {
useEffect(() => { useEffect(() => {
if (!importOpen || importTab !== 1 || !gmailStatus?.connected) return; if (!importOpen || importTab !== 1 || !gmailStatus?.connected) return;
void loadGmailMessages(); void loadGmailMatches(gmailQuery);
}, [importOpen, importTab, gmailStatus?.connected, loadGmailMessages]); }, [importOpen, importTab, gmailStatus?.connected, gmailQuery, loadGmailMatches]);
useEffect(() => { useEffect(() => {
const onMessage = (event: MessageEvent) => { const onMessage = (event: MessageEvent) => {
@@ -162,7 +181,7 @@ export default function Correspondence({ jobId }: { jobId: number }) {
toast(data.message || t("googleLinkedSuccess"), "success"); toast(data.message || t("googleLinkedSuccess"), "success");
void loadGmailStatus(); void loadGmailStatus();
setImportTab(1); setImportTab(1);
void loadGmailMessages(); void loadGmailMatches(gmailQuery);
} else { } else {
toast(data.message || t("googleAuthFailed"), "error"); toast(data.message || t("googleAuthFailed"), "error");
} }
@@ -170,36 +189,24 @@ export default function Correspondence({ jobId }: { jobId: number }) {
window.addEventListener("message", onMessage); window.addEventListener("message", onMessage);
return () => window.removeEventListener("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 canSend = useMemo(() => text.trim().length > 0, [text]);
const suggestedQueries = useMemo(() => { const suggestedQueries = useMemo(() => {
const subjectTerms = messages.map((m) => m.subject).filter(Boolean) as string[]; const fromSubjects = messages.map((m) => m.subject).filter(Boolean) as string[];
const uniqueSubjects = Array.from(new Set(subjectTerms)).slice(0, 2); const uniqueSubjects = Array.from(new Set(fromSubjects)).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 companyName = job?.company?.name?.trim();
const recruiterEmail = job?.company?.recruiterEmail?.trim();
const jobTitle = job?.jobTitle?.trim();
return [ return [
{ label: "Recent recruiting mail", value: "newer_than:180d (interview OR recruiter OR application OR position)" }, recruiterEmail ? { label: "Recruiter mailbox", value: `(from:${recruiterEmail} OR to:${recruiterEmail}) newer_than:365d` } : null,
{ label: "Inbox only", value: "label:inbox newer_than:120d" }, companyName && jobTitle ? { label: "Company + role", value: `"${companyName}" "${jobTitle}" newer_than:365d` } : null,
{ label: "Sent follow-ups", value: "in:sent newer_than:180d follow up" }, companyName ? { label: "Company mail", value: `"${companyName}" (application OR interview OR recruiter) newer_than:365d` } : null,
...uniqueSubjects.map((s) => ({ label: `Subject: ${s}`, value: `subject:"${s}"` })), ...uniqueSubjects.map((subject) => ({ label: `Subject: ${subject}`, value: `subject:"${subject}" newer_than:365d` })),
...companyTerms.map((s) => ({ label: `Related topic: ${s}`, value: `"${s}" newer_than:180d` })), ].filter(Boolean).slice(0, 6) as Array<{ label: string; value: string }>;
].slice(0, 6); }, [job?.company?.name, job?.company?.recruiterEmail, job?.jobTitle, messages]);
}, [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<string, GmailMessageSummary[]>();
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]);
const send = async () => { const send = async () => {
if (!canSend) return; if (!canSend) return;
@@ -227,6 +234,8 @@ export default function Correspondence({ jobId }: { jobId: number }) {
subject: parsed.subject ?? null, subject: parsed.subject ?? null,
content: parsed.body || rawEmail, content: parsed.body || rawEmail,
date: parsed.date ?? null, date: parsed.date ?? null,
externalFrom: parsed.from ?? null,
externalTo: parsed.to ?? null,
}); });
setImportOpen(false); setImportOpen(false);
setRawEmail(""); setRawEmail("");
@@ -251,7 +260,7 @@ export default function Correspondence({ jobId }: { jobId: number }) {
try { try {
await api.delete("/gmail/connection"); await api.delete("/gmail/connection");
setGmailStatus({ connected: false }); setGmailStatus({ connected: false });
setGmailMessages([]); setGmailMatches(null);
toast(t("googleUnlinked"), "success"); toast(t("googleUnlinked"), "success");
} catch (error) { } catch (error) {
toast(getApiErrorMessage(error, t("correspondenceDisconnectFailed")), "error"); toast(getApiErrorMessage(error, t("correspondenceDisconnectFailed")), "error");
@@ -272,9 +281,14 @@ export default function Correspondence({ jobId }: { jobId: number }) {
const importGmailMessage = async (messageId: string) => { const importGmailMessage = async (messageId: string) => {
try { try {
setImportingMessageId(messageId); setImportingMessageId(messageId);
await api.post("/gmail/import", { jobApplicationId: jobId, messageId }); const res = await api.post<GmailImportMessageResult>("/gmail/import", { jobApplicationId: jobId, messageId });
await load(); 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) { } catch (error: any) {
toast(getApiErrorMessage(error, t("correspondenceImportGmailFailed")), "error"); toast(getApiErrorMessage(error, t("correspondenceImportGmailFailed")), "error");
} finally { } finally {
@@ -285,8 +299,9 @@ export default function Correspondence({ jobId }: { jobId: number }) {
const importGmailThread = async (threadId: string, messageIds: string[]) => { const importGmailThread = async (threadId: string, messageIds: string[]) => {
try { try {
setImportingThreadId(threadId); 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<GmailImportThreadResult>("/gmail/import-thread", { jobApplicationId: jobId, threadId, messageIds });
await load(); await load();
await loadGmailMatches(gmailQuery);
toast(t("correspondenceImportThreadResult", { imported: res.data.imported, skippedText: res.data.skipped ? t("correspondenceImportThreadSkipped", { count: res.data.skipped }) : "" }), "success"); toast(t("correspondenceImportThreadResult", { imported: res.data.imported, skippedText: res.data.skipped ? t("correspondenceImportThreadSkipped", { count: res.data.skipped }) : "" }), "success");
} catch (error: any) { } catch (error: any) {
toast(getApiErrorMessage(error, t("correspondenceImportThreadFailed")), "error"); toast(getApiErrorMessage(error, t("correspondenceImportThreadFailed")), "error");
@@ -310,6 +325,13 @@ export default function Correspondence({ jobId }: { jobId: number }) {
<Box sx={{ maxWidth: "80%", borderRadius: 3, p: 1.25, border: `1px solid ${alpha(accent, theme.palette.mode === "dark" ? 0.32 : 0.22)}`, background: alpha(accent, theme.palette.mode === "dark" ? 0.14 : 0.1), color: "text.primary" }}> <Box sx={{ maxWidth: "80%", borderRadius: 3, p: 1.25, border: `1px solid ${alpha(accent, theme.palette.mode === "dark" ? 0.32 : 0.22)}`, background: alpha(accent, theme.palette.mode === "dark" ? 0.14 : 0.1), color: "text.primary" }}>
{m.subject ? <Typography sx={{ fontWeight: 800, mb: 0.5 }}>{m.subject}</Typography> : null} {m.subject ? <Typography sx={{ fontWeight: 800, mb: 0.5 }}>{m.subject}</Typography> : null}
<Typography sx={{ whiteSpace: "pre-wrap", lineHeight: 1.35 }}>{m.content}</Typography> <Typography sx={{ whiteSpace: "pre-wrap", lineHeight: 1.35 }}>{m.content}</Typography>
{(m.externalThreadId || m.externalFrom || m.externalTo) ? (
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 1 }}>
{m.externalThreadId ? <Chip size="small" label={`Thread ${m.externalThreadId}`} variant="outlined" /> : null}
{m.externalFrom ? <Chip size="small" label={`From ${m.externalFrom}`} variant="outlined" /> : null}
{m.externalTo ? <Chip size="small" label={`To ${m.externalTo}`} variant="outlined" /> : null}
</Box>
) : null}
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "flex-end", mt: 0.75 }}> <Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "flex-end", mt: 0.75 }}>
<Typography variant="caption" sx={{ color: "text.secondary" }}> <Typography variant="caption" sx={{ color: "text.secondary" }}>
{isMe ? t("correspondenceMe") : t("correspondenceCompany")}{m.channel ? ` - ${m.channel}` : ""}{m.date ? ` - ${new Date(m.date).toLocaleString()}` : ""} {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 }) {
<Typography variant="body2" sx={{ color: "text.secondary" }}> <Typography variant="body2" sx={{ color: "text.secondary" }}>
{gmailLoading ? t("correspondenceCheckingConnection") : gmailStatus?.connected ? t("correspondenceConnectedAs", { email: gmailStatus.gmailAddress || "" }) : t("correspondenceConnectGmailHint")} {gmailLoading ? t("correspondenceCheckingConnection") : gmailStatus?.connected ? t("correspondenceConnectedAs", { email: gmailStatus.gmailAddress || "" }) : t("correspondenceConnectGmailHint")}
</Typography> </Typography>
{job ? (
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>
Matching against {job.company?.name || "this company"} / {job.jobTitle}
</Typography>
) : null}
</Box> </Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}> <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
{gmailStatus?.connected ? ( {gmailStatus?.connected ? (
<> <>
<Button variant="outlined" onClick={() => void loadGmailMessages()} disabled={gmailMessagesLoading}>{t("correspondenceRefresh")}</Button> <Button variant="outlined" onClick={() => void loadGmailMatches(gmailQuery)} disabled={gmailMatchesLoading}>{t("correspondenceRefresh")}</Button>
<Button variant="outlined" color="error" onClick={() => void disconnectGmail()}>{t("correspondenceDisconnect")}</Button> <Button variant="outlined" color="error" onClick={() => void disconnectGmail()}>{t("correspondenceDisconnect")}</Button>
</> </>
) : ( ) : (
@@ -382,36 +409,65 @@ export default function Correspondence({ jobId }: { jobId: number }) {
</Box> </Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}> <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<TextField label={t("correspondenceSearchGmail")} value={gmailQuery} onChange={(e) => setGmailQuery(e.target.value)} placeholder={t("correspondenceSearchGmailPlaceholder")} size="small" fullWidth /> <TextField label={t("correspondenceSearchGmail")} value={gmailQuery} onChange={(e) => setGmailQuery(e.target.value)} placeholder={t("correspondenceSearchGmailPlaceholder")} size="small" fullWidth />
<Button variant="outlined" onClick={() => void loadGmailMessages()} disabled={gmailMessagesLoading}>{t("correspondenceSearch")}</Button> <Button variant="outlined" startIcon={<SearchIcon />} onClick={() => void loadGmailMatches(gmailQuery)} disabled={gmailMatchesLoading}>{t("correspondenceSearch")}</Button>
</Box> </Box>
{gmailMatches?.queries?.length ? (
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
{gmailMatches.queries.slice(0, 4).map((query) => (
<Chip key={query} size="small" label={query} variant="outlined" />
))}
</Box>
) : null}
{gmailStatus.lastSyncedAt ? <Chip label={t("correspondenceLastSynced", { date: new Date(gmailStatus.lastSyncedAt).toLocaleString() })} size="small" /> : null} {gmailStatus.lastSyncedAt ? <Chip label={t("correspondenceLastSynced", { date: new Date(gmailStatus.lastSyncedAt).toLocaleString() })} size="small" /> : null}
<Paper variant="outlined" sx={{ maxHeight: 420, overflowY: "auto" }}> <Paper variant="outlined" sx={{ maxHeight: 420, overflowY: "auto" }}>
{gmailMessagesLoading ? ( {gmailMatchesLoading ? (
<Box sx={{ py: 5, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> <Box sx={{ py: 5, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box>
) : groupedByThread.length === 0 ? ( ) : !gmailMatches || gmailMatches.threads.length === 0 ? (
<Typography sx={{ color: "text.secondary", p: 2 }}>{t("correspondenceNoGmailMessages")}</Typography> <Typography sx={{ color: "text.secondary", p: 2 }}>
{gmailQuery.trim() ? "No Gmail matches for this job and search override yet." : t("correspondenceNoGmailMessages")}
</Typography>
) : ( ) : (
<List disablePadding> <List disablePadding>
{groupedByThread.map(({ threadId, items }, threadIndex) => ( {gmailMatches.threads.map((thread, threadIndex) => (
<React.Fragment key={threadId}> <React.Fragment key={thread.threadId}>
{threadIndex > 0 ? <Divider /> : null} {threadIndex > 0 ? <Divider /> : null}
<Box sx={{ p: 1.5, backgroundColor: alpha(theme.palette.primary.main, 0.04) }}> <Box sx={{ p: 1.5, backgroundColor: alpha(theme.palette.primary.main, 0.04) }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1 }}> <Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1 }}>
<Box> <Box>
<Typography sx={{ fontWeight: 800 }}>{items[0]?.subject || t("correspondenceNoSubject")}</Typography> <Typography sx={{ fontWeight: 800 }}>{thread.subject || t("correspondenceNoSubject")}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("correspondenceMessagesInThread", { count: items.length })}</Typography> <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.75 }}>
<Chip size="small" label={`${formatConfidence(thread.confidence)} · score ${thread.score}`} color={thread.confidence === "high" ? "success" : thread.confidence === "medium" ? "warning" : "default"} />
<Chip size="small" label={`${thread.messageCount} message${thread.messageCount === 1 ? "" : "s"}`} variant="outlined" />
{thread.hasImportedMessages ? <Chip size="small" label="Already linked" variant="outlined" color="success" /> : null}
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 1 }}>
{thread.matchReasons.map((reason) => (
<Chip key={`${thread.threadId}-${reason.label}-${reason.value}`} size="small" label={`${formatReasonLabel(reason.label)}: ${reason.value}`} variant="outlined" />
))}
</Box>
</Box> </Box>
<Button startIcon={<MailOutlineIcon />} variant="outlined" size="small" disabled={importingThreadId === threadId} onClick={() => void importGmailThread(threadId, items.map((x) => x.id))}> <Button startIcon={<MailOutlineIcon />} variant="outlined" size="small" disabled={importingThreadId === thread.threadId} onClick={() => void importGmailThread(thread.threadId, thread.messages.map((x) => x.id))}>
{importingThreadId === threadId ? t("correspondenceImporting") : t("correspondenceImportThread")} {importingThreadId === thread.threadId ? t("correspondenceImporting") : t("correspondenceImportThread")}
</Button> </Button>
</Box> </Box>
{items.map((message, index) => ( {thread.messages.map((message, index) => (
<React.Fragment key={message.id}> <React.Fragment key={message.id}>
{index > 0 ? <Divider sx={{ my: 1 }} /> : null} {index > 0 ? <Divider sx={{ my: 1 }} /> : null}
<ListItemButton sx={{ alignItems: "flex-start", px: 0, py: 1 }}> <ListItemButton sx={{ alignItems: "flex-start", px: 0, py: 1 }}>
<ListItemText <ListItemText
secondaryTypographyProps={{ component: "div" }}
primary={<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}><Typography sx={{ fontWeight: 700 }}>{message.subject || t("correspondenceNoSubject")}</Typography><Typography variant="caption" sx={{ color: "text.secondary" }}>{message.date ? new Date(message.date).toLocaleString() : ""}</Typography></Box>} primary={<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}><Typography sx={{ fontWeight: 700 }}>{message.subject || t("correspondenceNoSubject")}</Typography><Typography variant="caption" sx={{ color: "text.secondary" }}>{message.date ? new Date(message.date).toLocaleString() : ""}</Typography></Box>}
secondary={<Box sx={{ mt: 0.5 }}><Typography variant="body2" sx={{ color: "text.primary" }}>{t("correspondenceFromLabel", { value: message.from || t("correspondenceUnknown") })}</Typography><Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25 }}>{message.snippet}</Typography></Box>} secondary={<Box component="span" sx={{ mt: 0.5, display: "block" }}>
<Typography component="span" variant="body2" sx={{ color: "text.primary", display: "block" }}>{t("correspondenceFromLabel", { value: message.from || t("correspondenceUnknown") })}</Typography>
<Typography component="span" variant="body2" sx={{ color: "text.secondary", mt: 0.25, display: "block" }}>{message.snippet}</Typography>
<Box component="span" sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 1 }}>
<Chip size="small" label={`${formatConfidence(message.confidence)} · score ${message.score}`} variant="outlined" />
{message.alreadyImported ? <Chip size="small" label="Already linked" color="success" variant="outlined" /> : null}
{message.matchReasons.map((reason) => (
<Chip key={`${message.id}-${reason.label}-${reason.value}`} size="small" label={`${formatReasonLabel(reason.label)}: ${reason.value}`} variant="outlined" />
))}
</Box>
</Box>}
/> />
<Button variant="contained" size="small" disabled={importingMessageId === message.id} onClick={() => void importGmailMessage(message.id)}> <Button variant="contained" size="small" disabled={importingMessageId === message.id} onClick={() => void importGmailMessage(message.id)}>
{importingMessageId === message.id ? t("correspondenceImporting") : t("correspondenceImportEmail")} {importingMessageId === message.id ? t("correspondenceImporting") : t("correspondenceImportEmail")}
@@ -357,7 +357,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
</Box> </Box>
)} )}
{tab === 1 && jobId && <Correspondence jobId={jobId} />} {tab === 1 && jobId && <Correspondence jobId={jobId} job={job} />}
{tab === 2 && jobId && <Attachments jobId={jobId} />} {tab === 2 && jobId && <Attachments jobId={jobId} />}
{tab === 3 && ( {tab === 3 && (
@@ -0,0 +1,230 @@
import React from "react";
import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { ConfirmProvider } from "./confirm";
import { PromptProvider } from "./prompt";
import { ToastProvider } from "./toast";
import { I18nProvider } from "./i18n/I18nProvider";
import JobDetailsDialog from "./components/JobDetailsDialog";
import { api } from "./api";
jest.setTimeout(15000);
jest.mock("./api", () => ({
api: {
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
patch: jest.fn(),
delete: jest.fn(),
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
},
}));
const mockedApi = api as jest.Mocked<typeof api>;
function renderDialog() {
return render(
<ToastProvider>
<I18nProvider>
<ConfirmProvider>
<PromptProvider>
<JobDetailsDialog open jobId={42} onClose={() => {}} initialTab={1} />
</PromptProvider>
</ConfirmProvider>
</I18nProvider>
</ToastProvider>,
);
}
describe("correspondence Gmail import", () => {
let correspondenceMessages: any[];
beforeEach(() => {
Object.assign(navigator, {
clipboard: {
writeText: jest.fn().mockResolvedValue(undefined),
},
});
window.open = jest.fn();
correspondenceMessages = [];
mockedApi.get.mockImplementation((url: string, config?: any) => {
if (url === "/jobapplications/42") {
return Promise.resolve({
data: {
id: 42,
jobTitle: "Backend Developer",
status: "Applied",
dateApplied: new Date().toISOString(),
daysSince: 3,
company: { name: "Acme", recruiterEmail: "maria@acme.test", recruiterName: "Maria Recruiter" },
tailoredCvText: "",
shortSummary: "summary",
},
} as any);
}
if (url === "/auth/me") {
return Promise.resolve({ data: { roles: [], profileCvText: "Master CV text" } } as any);
}
if (url === "/jobapplications/42/history") {
return Promise.resolve({ data: [] } as any);
}
if (url === "/attachments/42") {
return Promise.resolve({ data: [] } as any);
}
if (url === "/correspondence/42") {
return Promise.resolve({ data: correspondenceMessages } as any);
}
if (url === "/gmail/status") {
return Promise.resolve({ data: { connected: true, gmailAddress: "user@example.test", lastSyncedAt: new Date().toISOString() } } as any);
}
if (url === "/gmail/job-candidates") {
return Promise.resolve({
data: {
jobApplicationId: 42,
jobTitle: "Backend Developer",
companyName: "Acme",
recruiterName: "Maria Recruiter",
recruiterEmail: "maria@acme.test",
queries: [config?.params?.queryOverride || '"Acme" "Backend Developer" newer_than:365d'],
threads: [
{
threadId: "thread-1",
subject: "Backend Developer interview",
score: 42,
confidence: "high",
hasImportedMessages: false,
messageCount: 2,
latestDate: new Date().toISOString(),
matchReasons: [
{ label: "company", value: "Acme" },
{ label: "recruiterEmail", value: "maria@acme.test" },
],
messages: [
{
id: "msg-1",
threadId: "thread-1",
subject: "Backend Developer interview",
from: "Maria Recruiter <maria@acme.test>",
to: "user@example.test",
date: new Date().toISOString(),
snippet: "Acme wants to schedule a call.",
score: 42,
confidence: "high",
alreadyImported: false,
matchReasons: [
{ label: "company", value: "Acme" },
{ label: "jobTitle", value: "Developer" },
],
},
{
id: "msg-2",
threadId: "thread-1",
subject: "Backend Developer follow-up",
from: "user@example.test",
to: "Maria Recruiter <maria@acme.test>",
date: new Date().toISOString(),
snippet: "Following up on the role.",
score: 24,
confidence: "medium",
alreadyImported: false,
matchReasons: [{ label: "recency", value: "45d" }],
},
],
},
],
},
} as any);
}
return Promise.resolve({ data: [] } as any);
});
mockedApi.post.mockImplementation((url: string, body?: any) => {
if (url === "/gmail/import") {
correspondenceMessages = [
{
id: 700,
jobApplicationId: 42,
from: "Company",
content: "Acme wants to schedule a call.",
subject: "Backend Developer interview",
channel: "Email",
date: new Date().toISOString(),
externalMessageId: body.messageId,
externalThreadId: "thread-1",
externalFrom: "Maria Recruiter <maria@acme.test>",
externalTo: "user@example.test",
},
];
return Promise.resolve({
data: {
imported: 1,
skipped: 0,
messageId: body.messageId,
threadId: "thread-1",
message: correspondenceMessages[0],
},
} as any);
}
if (url === "/gmail/import-thread") {
return Promise.resolve({ data: { imported: 2, skipped: 0, threadId: "thread-1" } } as any);
}
if (url === "/correspondence") {
return Promise.resolve({ data: {} } as any);
}
return Promise.resolve({ data: {} } as any);
});
mockedApi.put.mockResolvedValue({ data: {} } as any);
mockedApi.delete.mockResolvedValue({ data: {} } as any);
});
afterEach(() => {
jest.clearAllMocks();
});
test("shows ranked Gmail suggestions with reasons and refreshes correspondence after message import", async () => {
renderDialog();
fireEvent.click(await screen.findByRole("button", { name: /import email/i }));
fireEvent.click(await screen.findByRole("tab", { name: /^google$/i }));
expect((await screen.findAllByText(/backend developer interview/i)).length).toBeGreaterThan(0);
expect((await screen.findAllByText(/high confidence · score 42/i)).length).toBeGreaterThan(0);
expect((await screen.findAllByText(/company: acme/i)).length).toBeGreaterThan(0);
expect(await screen.findByText(/recruiter email: maria@acme\.test/i)).toBeInTheDocument();
const importButtons = await screen.findAllByRole("button", { name: /^import email$/i });
fireEvent.click(importButtons[importButtons.length - 1]);
await waitFor(() => {
expect(mockedApi.post).toHaveBeenCalledWith("/gmail/import", { jobApplicationId: 42, messageId: "msg-2" });
});
expect(await screen.findByText(/thread thread-1/i)).toBeInTheDocument();
expect(await screen.findByText(/from maria recruiter <maria@acme\.test>/i)).toBeInTheDocument();
expect(await screen.findByText(/to user@example\.test/i)).toBeInTheDocument();
});
test("manual Gmail search override reloads job candidates with queryOverride", async () => {
renderDialog();
fireEvent.click(await screen.findByRole("button", { name: /import email/i }));
fireEvent.click(await screen.findByRole("tab", { name: /^google$/i }));
const search = await screen.findByLabelText(/search gmail/i);
fireEvent.change(search, { target: { value: 'subject:"Acme" newer_than:30d' } });
fireEvent.click(screen.getByRole("button", { name: /^search$/i }));
await waitFor(() => {
expect(mockedApi.get).toHaveBeenCalledWith("/gmail/job-candidates", expect.objectContaining({
params: expect.objectContaining({
jobApplicationId: 42,
queryOverride: 'subject:"Acme" newer_than:30d',
}),
}));
});
});
});
+58
View File
@@ -125,8 +125,66 @@ export interface CorrespondenceMessage {
subject?: string; subject?: string;
channel?: string; channel?: string;
date: string; date: string;
externalMessageId?: string | null;
externalThreadId?: string | null;
externalFrom?: string | null;
externalTo?: string | null;
} }
export interface GmailJobMatchReason {
label: string;
value: string;
}
export interface GmailJobMatchedMessage {
id: string;
threadId: string;
subject: string;
from: string;
to: string;
date?: string;
snippet: string;
score: number;
confidence: string;
alreadyImported: boolean;
matchReasons: GmailJobMatchReason[];
}
export interface GmailJobMatchedThread {
threadId: string;
subject: string;
score: number;
confidence: string;
hasImportedMessages: boolean;
messageCount: number;
latestDate?: string;
matchReasons: GmailJobMatchReason[];
messages: GmailJobMatchedMessage[];
}
export interface GmailJobMatchesResponse {
jobApplicationId: number;
jobTitle: string;
companyName: string;
recruiterName?: string | null;
recruiterEmail?: string | null;
queries: string[];
threads: GmailJobMatchedThread[];
}
export interface GmailImportMessageResult {
imported: number;
skipped: number;
messageId: string;
threadId?: string | null;
message?: CorrespondenceMessage | null;
}
export interface GmailImportThreadResult {
imported: number;
skipped: number;
threadId?: string | null;
}
export interface GmailStatus { export interface GmailStatus {
connected: boolean; connected: boolean;