22 Commits

Author SHA1 Message Date
cesnimda b8c91a22b6 Fix API startup by removing unused OpenAPI package 2026-04-04 16:43:26 +02:00
cesnimda 170f1390a9 Trigger deploy after relaxing AI gate 2026-04-02 14:53:38 +02:00
cesnimda a22ce08913 Do not block deploy on AI service health 2026-04-02 14:52:44 +02:00
cesnimda f7efad7337 Trigger rebuild after CI repro 2026-04-02 14:34:30 +02:00
cesnimda 947d4eeab9 Trigger redeploy after backend runtime fix 2026-04-02 14:06:52 +02:00
cesnimda f61da1869d Include JwtBearer in backend publish output 2026-04-02 14:06:48 +02:00
cesnimda 463d4277cd Trigger redeploy 2026-04-02 13:27:48 +02:00
cesnimda 7b9a97323e Fix backend Docker publish context 2026-04-02 12:49:49 +02:00
cesnimda 5cd34f17bb Complete Gmail correspondence workflow 2026-04-02 12:29:24 +02:00
cesnimda 1f34eb42d2 fix: include backend project in docker build context 2026-04-01 22:24:00 +02:00
cesnimda b87e673d38 feat: add gmail review actions 2026-04-01 21:54:05 +02:00
cesnimda 161ecb4b94 feat: add gmail review decisions 2026-04-01 21:45:01 +02:00
cesnimda a0e823facf test: opt gmail router tests into v7 future flags 2026-04-01 17:26:07 +02:00
cesnimda 5af2c66616 feat: add gmail review queue surface 2026-04-01 17:16:00 +02:00
cesnimda 69e78d8951 refactor: extract gmail matching service 2026-04-01 16:59:29 +02:00
cesnimda 61c12d3479 feat: add global correspondence inbox 2026-04-01 16:51:02 +02:00
cesnimda 3f04849fe6 feat: add correspondence inbox and gmail ingestion contract 2026-04-01 16:50:14 +02:00
cesnimda 289c2f47ad Merge feature branch feat/gmail-job-correspondence 2026-04-01 16:38:03 +02:00
cesnimda fd3527776a docs: update gmail workstream progress 2026-04-01 16:38:03 +02:00
cesnimda f48136f04c feat: enrich gmail correspondence metadata 2026-04-01 16:27:34 +02:00
cesnimda e5bcf9d5ea feat: harden gmail sync foundation 2026-04-01 16:09:29 +02:00
cesnimda 068ce447c0 Merge feature branch feat/cv-builder-parser-ollama 2026-04-01 15:54:00 +02:00
27 changed files with 2809 additions and 333 deletions
+2
View File
@@ -3,12 +3,14 @@
# everything first and then opt back into only the source folders it needs.
*
!JobTrackerApi/
!JobTrackerBackend/
!Data/
!Models/
!.dockerignore
# Include the source trees.
!JobTrackerApi/**
!JobTrackerBackend/**
!Data/**
!Models/**
+7 -10
View File
@@ -89,11 +89,8 @@ jobs:
docker compose ps
AI_CONTAINER_ID="$(docker compose ps -q ai-service)"
if [ -z "$AI_CONTAINER_ID" ]; then
echo "AI service container id could not be resolved after deploy."
docker compose ps
docker compose logs --tail=200 ai-service || true
exit 1
fi
echo "AI service container id could not be resolved after deploy. Continuing because AI is not a deploy gate for the core app."
else
ATTEMPTS=90
SLEEP_SECS=2
i=1
@@ -103,16 +100,16 @@ jobs:
break
fi
if [ "$HEALTH_STATUS" = "unhealthy" ]; then
echo "AI service became unhealthy during deploy readiness wait."
echo "AI service became unhealthy during deploy readiness wait. Continuing because AI is not a deploy gate for the core app."
docker compose logs --tail=200 ai-service || true
exit 1
break
fi
sleep "$SLEEP_SECS"
i=$((i + 1))
done
if [ "$HEALTH_STATUS" != "healthy" ]; then
echo "AI service did not become healthy within $((ATTEMPTS * SLEEP_SECS)) seconds. Final status: ${HEALTH_STATUS:-unknown}"
if [ "${HEALTH_STATUS:-unknown}" != "healthy" ]; then
echo "AI service did not become healthy within $((ATTEMPTS * SLEEP_SECS)) seconds. Final status: ${HEALTH_STATUS:-unknown}. Continuing because AI is not a deploy gate for the core app."
docker compose ps
docker compose logs --tail=200 ai-service || true
exit 1
fi
fi
+6
View File
@@ -17,6 +17,7 @@ namespace JobTrackerApi.Data
public DbSet<JobApplication> JobApplications => Set<JobApplication>();
public DbSet<Correspondence> Correspondences => Set<Correspondence>();
public DbSet<GmailConnection> GmailConnections => Set<GmailConnection>();
public DbSet<GmailReviewDecision> GmailReviewDecisions => Set<GmailReviewDecision>();
public DbSet<Attachment> Attachments => Set<Attachment>();
public DbSet<RuleSettings> RuleSettings => Set<RuleSettings>();
public DbSet<UserRuleSettings> UserRuleSettings => Set<UserRuleSettings>();
@@ -66,6 +67,11 @@ namespace JobTrackerApi.Data
modelBuilder.Entity<GmailConnection>()
.HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId);
modelBuilder.Entity<GmailReviewDecision>()
.HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId);
modelBuilder.Ignore<CorrespondenceAttachmentMetadata>();
modelBuilder.Entity<GmailConnection>()
.HasIndex(x => new { x.OwnerUserId, x.GmailAddress })
.IsUnique();
+405 -5
View File
@@ -15,6 +15,39 @@ namespace JobTrackerApi.Tests;
public sealed class GmailControllerTests
{
[Fact]
public async Task Status_returns_sync_state_fields_for_connected_account()
{
await using var db = CreateDb();
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",
ConnectedAt = DateTimeOffset.UtcNow.AddDays(-3),
LastSyncedAt = DateTimeOffset.UtcNow.AddMinutes(-10),
LastSyncAttemptedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
LastSyncSucceededAt = DateTimeOffset.UtcNow.AddMinutes(-10),
LastSyncMode = "list-messages",
LastSyncSource = "custom-query",
LastSyncStatus = "error",
LastSyncError = "Token refresh failed"
});
var controller = CreateController(db, gmail.Object, "user-1");
var result = await controller.Status(CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var payload = Assert.IsType<GmailController.GmailConnectionStatusDto>(ok.Value);
Assert.True(payload.Connected);
Assert.Equal("user@example.test", payload.GmailAddress);
Assert.Equal("list-messages", payload.LastSyncMode);
Assert.Equal("custom-query", payload.LastSyncSource);
Assert.Equal("error", payload.LastSyncStatus);
Assert.Equal("Token refresh failed", payload.LastSyncError);
}
[Fact]
public async Task Import_thread_rejects_missing_message_ids()
{
@@ -225,7 +258,9 @@ public sealed class GmailControllerTests
DateTimeOffset.UtcNow.AddDays(-1),
"Snippet",
"Body text",
null));
null,
new[] { "INBOX", "IMPORTANT" },
new[] { new GmailMessageAttachment("cv.pdf", "application/pdf", 2048, "att-1", false) }));
var controller = CreateController(db, gmail.Object, "user-1");
@@ -239,6 +274,10 @@ public sealed class GmailControllerTests
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);
Assert.Equal("inbound", firstPayload.Message.Direction);
Assert.Contains("IMPORTANT", firstPayload.Message.ExternalLabels);
Assert.Single(firstPayload.Message.AttachmentMetadata);
Assert.Equal("cv.pdf", firstPayload.Message.AttachmentMetadata[0].FileName);
var second = await controller.Import(new GmailController.ImportGmailMessageRequest(job.Id, "msg-1"), CancellationToken.None);
var secondOk = Assert.IsType<OkObjectResult>(second.Result);
@@ -282,7 +321,9 @@ public sealed class GmailControllerTests
DateTimeOffset.UtcNow.AddDays(-1),
"Snippet 1",
"Body text 1",
null));
null,
Array.Empty<string>(),
Array.Empty<GmailMessageAttachment>()));
gmail.Setup(service => service.GetMessageAsync("user-1", "msg-2", It.IsAny<CancellationToken>()))
.ReturnsAsync(new GmailMessageDetail(
"msg-2",
@@ -293,7 +334,9 @@ public sealed class GmailControllerTests
DateTimeOffset.UtcNow,
"Snippet 2",
"Body text 2",
null));
null,
Array.Empty<string>(),
Array.Empty<GmailMessageAttachment>()));
var controller = CreateController(db, gmail.Object, "user-1");
var request = new GmailController.ImportGmailThreadRequest(job.Id, "thread-1", new[] { "msg-1", "msg-2" });
@@ -365,7 +408,9 @@ public sealed class GmailControllerTests
DateTimeOffset.UtcNow,
"New reply",
"Reply body",
null));
null,
Array.Empty<string>(),
Array.Empty<GmailMessageAttachment>()));
var controller = CreateController(db, gmail.Object, "user-1");
var result = await controller.RefreshLinkedThreads(new GmailController.RefreshLinkedThreadsRequest(job.Id), CancellationToken.None);
@@ -435,6 +480,51 @@ public sealed class GmailControllerTests
gmail.Verify(service => service.ListThreadMessagesAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Review_candidates_returns_threads_grouped_with_routing_summary()
{
await using var db = CreateDb();
var company = new Company { Name = "Acme", 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();
var gmail = new Mock<IGmailOAuthService>();
gmail.Setup(service => service.ListJobCandidateMessagesAsync("user-1", It.IsAny<IEnumerable<string>>(), 6, It.IsAny<CancellationToken>()))
.ReturnsAsync(new[]
{
new GmailQueryMatchedMessage(
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[] { "\"Acme\" \"Backend Developer\" newer_than:365d" })
});
var controller = CreateController(db, gmail.Object, "user-1");
var result = await controller.ReviewCandidates(null, 6, CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var payload = Assert.IsType<GmailController.GmailReviewQueueResponseDto>(ok.Value);
Assert.Equal(1, payload.CandidateThreadCount);
Assert.Single(payload.Threads);
Assert.Equal("thread-top", payload.Threads[0].ThreadId);
Assert.True(payload.Threads[0].JobCandidates.Count > 0);
Assert.Contains(payload.Threads[0].Routing, new[] { "auto-link", "review", "unmatched" });
}
[Fact]
public async Task Refresh_linked_threads_rejects_invalid_job_id()
{
@@ -500,9 +590,319 @@ public sealed class GmailControllerTests
gmail.Verify(service => service.ListJobCandidateMessagesAsync(It.IsAny<string>(), It.IsAny<IEnumerable<string>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Save_review_decision_links_thread_and_imports_messages()
{
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.ListThreadMessagesAsync("user-1", "thread-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(new[]
{
new GmailMessageSummary("msg-1", "thread-1", "Backend Developer interview", "Maria Recruiter <maria@acme.test>", "user@example.test", DateTimeOffset.UtcNow.AddDays(-1), "Interview invite")
});
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",
"Backend Developer interview",
"Maria Recruiter <maria@acme.test>",
"user@example.test",
DateTimeOffset.UtcNow.AddDays(-1),
"Interview invite",
"Body text",
null,
new[] { "INBOX" },
Array.Empty<GmailMessageAttachment>()));
var controller = CreateController(db, gmail.Object, "user-1");
var result = await controller.SaveReviewDecision(new GmailController.SaveGmailReviewDecisionRequest("thread-1", "linked", job.Id, "Strong recruiter match"), CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result);
var decision = await db.GmailReviewDecisions.SingleAsync();
Assert.Equal("linked", decision.Decision);
Assert.Equal(job.Id, decision.JobApplicationId);
Assert.Equal("Strong recruiter match", decision.Note);
var imported = await db.Correspondences.SingleAsync();
Assert.Equal("thread-1", imported.ExternalThreadId);
Assert.Equal("msg-1", imported.ExternalMessageId);
Assert.NotNull(ok.Value);
}
[Fact]
public async Task Manual_sync_auto_links_high_confidence_thread()
{
await using var db = CreateDb();
var company = new Company
{
Name = "Acme",
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();
var gmail = new Mock<IGmailOAuthService>();
gmail.Setup(service => service.ListJobCandidateMessagesAsync(
"user-1",
It.Is<IEnumerable<string>>(queries => queries.Any(query => query.Contains("-in:spam")) && queries.Any(query => query.Contains("-in:trash")) && queries.All(query => query.Contains("newer_than:365d"))),
8,
It.IsAny<CancellationToken>()))
.ReturnsAsync(new[]
{
new GmailQueryMatchedMessage(
new GmailMessageSummary(
"msg-1",
"thread-1",
"Backend Developer interview",
"Maria Recruiter <maria@acme.test>",
"user@example.test",
DateTimeOffset.UtcNow.AddDays(-2),
"Acme wants to schedule a backend developer interview."),
new[]
{
"\"Acme\" \"Backend Developer\" newer_than:365d -in:spam -in:trash",
"(from:maria@acme.test OR to:maria@acme.test) newer_than:365d -in:spam -in:trash"
})
});
gmail.Setup(service => service.ListThreadMessagesAsync("user-1", "thread-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(new[]
{
new GmailMessageSummary("msg-1", "thread-1", "Backend Developer interview", "Maria Recruiter <maria@acme.test>", "user@example.test", DateTimeOffset.UtcNow.AddDays(-2), "Invite")
});
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",
"Backend Developer interview",
"Maria Recruiter <maria@acme.test>",
"user@example.test",
DateTimeOffset.UtcNow.AddDays(-2),
"Invite",
"Interview details",
null,
new[] { "INBOX" },
Array.Empty<GmailMessageAttachment>()));
var controller = CreateController(db, gmail.Object, "user-1");
var result = await controller.ManualSync(new GmailController.GmailManualSyncRequest(365, 8, true, false), CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var payload = Assert.IsType<GmailController.GmailManualSyncResultDto>(ok.Value);
Assert.Equal(1, payload.AutoLinkedThreadCount);
Assert.Equal(1, payload.ImportedThreads);
Assert.Equal(1, payload.ImportedMessages);
var decision = await db.GmailReviewDecisions.SingleAsync();
Assert.Equal("linked", decision.Decision);
Assert.Equal(job.Id, decision.JobApplicationId);
}
[Fact]
public async Task Suggested_jobs_and_create_suggested_job_create_job_and_link_thread()
{
await using var db = CreateDb();
var gmail = new Mock<IGmailOAuthService>();
gmail.Setup(service => service.ListJobCandidateMessagesAsync("user-1", It.IsAny<IEnumerable<string>>(), 6, It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<GmailQueryMatchedMessage>());
gmail.Setup(service => service.ListThreadMessagesAsync("user-1", "thread-suggested", It.IsAny<CancellationToken>()))
.ReturnsAsync(new[]
{
new GmailMessageSummary("msg-s1", "thread-suggested", "Platform Engineer interview", "Nina Recruiter <nina@beta.test>", "user@example.test", DateTimeOffset.UtcNow.AddDays(-1), "Let's talk about the role")
});
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-s1", It.IsAny<CancellationToken>()))
.ReturnsAsync(new GmailMessageDetail(
"msg-s1",
"thread-suggested",
"Platform Engineer interview",
"Nina Recruiter <nina@beta.test>",
"user@example.test",
DateTimeOffset.UtcNow.AddDays(-1),
"Let's talk about the role",
"Interview details",
null,
new[] { "INBOX" },
Array.Empty<GmailMessageAttachment>()));
db.GmailReviewDecisions.Add(new GmailReviewDecision
{
OwnerUserId = "user-1",
ThreadId = "thread-suggested",
Decision = "suggested",
UpdatedAt = DateTimeOffset.UtcNow
});
await db.SaveChangesAsync();
var controller = CreateController(db, gmail.Object, "user-1");
var reviewQueue = new GmailController.GmailReviewQueueResponseDto(
Array.Empty<string>(),
1,
0,
0,
1,
new[]
{
new GmailController.GmailReviewThreadDto(
"thread-suggested",
"Platform Engineer interview",
DateTimeOffset.UtcNow.AddDays(-1),
1,
"suggested",
false,
null,
Array.Empty<string>(),
Array.Empty<GmailController.GmailReviewJobCandidateDto>(),
new[]
{
new GmailController.GmailJobMatchedMessageDto(
"msg-s1",
"thread-suggested",
"Platform Engineer interview",
"Nina Recruiter <nina@beta.test>",
"user@example.test",
DateTimeOffset.UtcNow.AddDays(-1),
"Let's talk about the role",
0,
"low",
false,
Array.Empty<string>(),
Array.Empty<GmailController.GmailJobMatchReasonDto>())
})
});
var suggested = Assert.IsType<OkObjectResult>((await controller.SuggestedJobs(CancellationToken.None)).Result);
Assert.IsType<GmailController.GmailSuggestedJobsResponseDto>(suggested.Value);
var create = await controller.CreateSuggestedJob(new GmailController.CreateSuggestedGmailJobRequest("thread-suggested", "Beta", "Platform Engineer", "Nina Recruiter", "nina@beta.test", "Create from Gmail suggestion", "Applied"), CancellationToken.None);
var createOk = Assert.IsType<OkObjectResult>(create.Result);
var created = Assert.IsType<GmailController.CreatedSuggestedGmailJobDto>(createOk.Value);
Assert.True(created.JobApplicationId > 0);
Assert.Equal(1, created.Imported);
Assert.Equal("thread-suggested", created.ThreadId);
Assert.Equal(1, await db.JobApplications.CountAsync());
Assert.Equal(1, await db.Correspondences.CountAsync());
}
[Fact]
public async Task Unlink_thread_removes_messages_and_sets_review_decision()
{
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();
db.Correspondences.AddRange(
new Correspondence { JobApplicationId = job.Id, From = "Company", Content = "First", ExternalMessageId = "msg-1", ExternalThreadId = "thread-1" },
new Correspondence { JobApplicationId = job.Id, From = "Me", Content = "Second", ExternalMessageId = "msg-2", ExternalThreadId = "thread-1" });
await db.SaveChangesAsync();
var controller = CreateController(db, Mock.Of<IGmailOAuthService>(), "user-1");
var result = await controller.UnlinkThread(new GmailController.UnlinkGmailThreadRequest(job.Id, "thread-1", "Need manual review", "review"), CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var payload = Assert.IsType<GmailController.GmailUnlinkResultDto>(ok.Value);
Assert.Equal(2, payload.RemovedMessages);
Assert.Equal("review", payload.Decision);
Assert.Empty(await db.Correspondences.ToListAsync());
var decision = await db.GmailReviewDecisions.SingleAsync();
Assert.Equal("review", decision.Decision);
Assert.Equal("Need manual review", decision.Note);
}
[Fact]
public async Task Relink_thread_can_move_messages_from_other_jobs()
{
await using var db = CreateDb();
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
db.Companies.Add(company);
await db.SaveChangesAsync();
var sourceJob = new JobApplication { JobTitle = "Source", CompanyId = company.Id, OwnerUserId = "user-1" };
var targetJob = new JobApplication { JobTitle = "Target", CompanyId = company.Id, OwnerUserId = "user-1" };
db.JobApplications.AddRange(sourceJob, targetJob);
await db.SaveChangesAsync();
db.Correspondences.Add(new Correspondence
{
JobApplicationId = sourceJob.Id,
From = "Company",
Content = "Existing import",
ExternalMessageId = "msg-1",
ExternalThreadId = "thread-1"
});
await db.SaveChangesAsync();
var gmail = new Mock<IGmailOAuthService>();
gmail.Setup(service => service.ListThreadMessagesAsync("user-1", "thread-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(new[]
{
new GmailMessageSummary("msg-1", "thread-1", "Interview", "Maria <maria@acme.test>", "user@example.test", DateTimeOffset.UtcNow, "Snippet")
});
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",
"Maria <maria@acme.test>",
"user@example.test",
DateTimeOffset.UtcNow,
"Snippet",
"Body",
null,
Array.Empty<string>(),
Array.Empty<GmailMessageAttachment>()));
var controller = CreateController(db, gmail.Object, "user-1");
var result = await controller.RelinkThread(new GmailController.RelinkGmailThreadRequest(targetJob.Id, "thread-1", true, "Move to target"), CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var payload = Assert.IsType<GmailController.GmailRelinkResultDto>(ok.Value);
Assert.Equal(1, payload.UnlinkedMessages);
Assert.Equal(1, payload.Imported);
var stored = await db.Correspondences.SingleAsync();
Assert.Equal(targetJob.Id, stored.JobApplicationId);
Assert.Equal("thread-1", stored.ExternalThreadId);
var decision = await db.GmailReviewDecisions.SingleAsync();
Assert.Equal(targetJob.Id, decision.JobApplicationId);
Assert.Equal("linked", decision.Decision);
}
private static GmailController CreateController(JobTrackerContext db, IGmailOAuthService gmail, string userId)
{
var controller = new GmailController(gmail, db, BuildConfig())
var controller = new GmailController(gmail, new GmailJobMatchingService(), db, BuildConfig())
{
ControllerContext = new ControllerContext
{
@@ -25,6 +25,84 @@ namespace JobTrackerApi.Controllers
.FirstOrDefaultAsync(c => c.Id == correspondenceId, cancellationToken);
}
public sealed record CorrespondenceInboxItemDto(
int Id,
int JobApplicationId,
string? CompanyName,
string? JobTitle,
string From,
string? Direction,
string? Subject,
string? Channel,
DateTime Date,
string ContentPreview,
string? ExternalThreadId,
string? ExternalFrom,
string? ExternalTo,
int LabelCount,
int AttachmentCount);
[HttpGet]
public async Task<ActionResult<List<CorrespondenceInboxItemDto>>> GetInbox(
[FromQuery] string? q,
[FromQuery] string? direction,
[FromQuery] string? linkState,
CancellationToken cancellationToken)
{
var query = _db.Correspondences
.Include(c => c.JobApplication)
.ThenInclude(j => j.Company)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(q))
{
var needle = q.Trim();
query = query.Where(c =>
(c.Subject != null && EF.Functions.Like(c.Subject, $"%{needle}%")) ||
EF.Functions.Like(c.Content, $"%{needle}%") ||
(c.ExternalFrom != null && EF.Functions.Like(c.ExternalFrom, $"%{needle}%")) ||
(c.JobApplication.JobTitle != null && EF.Functions.Like(c.JobApplication.JobTitle, $"%{needle}%")) ||
(c.JobApplication.Company.Name != null && EF.Functions.Like(c.JobApplication.Company.Name, $"%{needle}%")));
}
if (!string.IsNullOrWhiteSpace(direction) && !string.Equals(direction, "all", StringComparison.OrdinalIgnoreCase))
{
query = query.Where(c => c.Direction == direction);
}
if (string.Equals(linkState, "linked", StringComparison.OrdinalIgnoreCase))
{
query = query.Where(c => c.ExternalThreadId != null);
}
else if (string.Equals(linkState, "manual", StringComparison.OrdinalIgnoreCase))
{
query = query.Where(c => c.ExternalThreadId == null);
}
var items = await query
.OrderByDescending(c => c.Date)
.Take(200)
.Select(c => new CorrespondenceInboxItemDto(
c.Id,
c.JobApplicationId,
c.JobApplication.Company != null ? c.JobApplication.Company.Name : null,
c.JobApplication.JobTitle,
c.From,
c.Direction,
c.Subject,
c.Channel,
c.Date,
c.Content.Length <= 220 ? c.Content : c.Content.Substring(0, 220),
c.ExternalThreadId,
c.ExternalFrom,
c.ExternalTo,
c.ExternalLabelsJson != null ? 1 : 0,
c.AttachmentMetadataJson != null ? 1 : 0))
.ToListAsync(cancellationToken);
return Ok(items);
}
// GET all messages for a job
[HttpGet("{jobId:int}")]
public async Task<ActionResult<List<Correspondence>>> GetForJob([FromRoute] int jobId, CancellationToken cancellationToken)
@@ -48,10 +126,13 @@ namespace JobTrackerApi.Controllers
string? Subject,
string? Channel,
DateTime? Date,
string? Direction,
string? ExternalMessageId,
string? ExternalThreadId,
string? ExternalFrom,
string? ExternalTo
string? ExternalTo,
string? ExternalLabelsJson,
string? AttachmentMetadataJson
);
// POST new message
@@ -71,10 +152,13 @@ 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(),
Direction = string.IsNullOrWhiteSpace(request.Direction) ? null : request.Direction.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(),
ExternalLabelsJson = string.IsNullOrWhiteSpace(request.ExternalLabelsJson) ? null : request.ExternalLabelsJson.Trim(),
AttachmentMetadataJson = string.IsNullOrWhiteSpace(request.AttachmentMetadataJson) ? null : request.AttachmentMetadataJson.Trim(),
Content = request.Content,
Date = request.Date ?? DateTime.Now,
};
+627 -163
View File
@@ -1,4 +1,5 @@
using System.Security.Claims;
using System.Text.Json;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
@@ -14,12 +15,14 @@ namespace JobTrackerApi.Controllers;
public sealed class GmailController : ControllerBase
{
private readonly IGmailOAuthService _gmail;
private readonly IGmailJobMatchingService _matching;
private readonly JobTrackerContext _db;
private readonly IConfiguration _cfg;
public GmailController(IGmailOAuthService gmail, JobTrackerContext db, IConfiguration cfg)
public GmailController(IGmailOAuthService gmail, IGmailJobMatchingService matching, JobTrackerContext db, IConfiguration cfg)
{
_gmail = gmail;
_matching = matching;
_db = db;
_cfg = cfg;
}
@@ -68,18 +71,49 @@ public sealed class GmailController : ControllerBase
int CandidateThreadCount,
IReadOnlyList<GmailJobMatchedThreadDto> Threads);
public sealed record GmailReviewJobCandidateDto(int JobApplicationId, string JobTitle, string CompanyName, int Score, string Confidence, IReadOnlyList<GmailJobMatchReasonDto> Reasons);
public sealed record GmailReviewThreadDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, int MessageCount, string Routing, bool HasImportedMessages, string? DecisionNote, IReadOnlyList<string> MatchedQueries, IReadOnlyList<GmailReviewJobCandidateDto> JobCandidates, IReadOnlyList<GmailJobMatchedMessageDto> Messages);
public sealed record GmailReviewQueueResponseDto(IReadOnlyList<string> Queries, int CandidateThreadCount, int AutoLinkThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, IReadOnlyList<GmailReviewThreadDto> Threads);
public sealed record SaveGmailReviewDecisionRequest(string ThreadId, string Decision, int? JobApplicationId, string? Note);
public sealed record GmailManualSyncRequest(int? LookbackDays, int? MaxResultsPerQuery, bool? AutoImportHighConfidence, bool? IncludeSpamTrash);
public sealed record GmailManualSyncResultDto(int QueriesRun, int CandidateThreadCount, int AutoLinkedThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, int ImportedMessages, int ImportedThreads, int SkippedMessages, int LookbackDays, bool IncludeSpamTrash, DateTimeOffset SyncedAt);
public sealed record GmailSuggestedJobCandidateDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, string? CompanyName, string? RecruiterName, string? RecruiterEmail, string? SuggestedJobTitle, string Routing, IReadOnlyList<string> MatchedQueries, string Preview);
public sealed record GmailSuggestedJobsResponseDto(int Count, IReadOnlyList<GmailSuggestedJobCandidateDto> Items);
public sealed record CreateSuggestedGmailJobRequest(string ThreadId, string CompanyName, string JobTitle, string? RecruiterName, string? RecruiterEmail, string? Notes, string? Status);
public sealed record CreatedSuggestedGmailJobDto(int JobApplicationId, int CompanyId, string ThreadId, int Imported, int Skipped);
public sealed record RelinkGmailThreadRequest(int JobApplicationId, string ThreadId, bool RemoveFromOtherJobs, string? Note);
public sealed record GmailRelinkResultDto(string ThreadId, int JobApplicationId, int Imported, int Skipped, int UnlinkedMessages);
public sealed record UnlinkGmailThreadRequest(int JobApplicationId, string ThreadId, string? Note, string? NextDecision);
public sealed record GmailUnlinkResultDto(string ThreadId, int JobApplicationId, int RemovedMessages, string Decision);
public sealed record GmailConnectionStatusDto(
bool Connected,
string? GmailAddress,
DateTimeOffset? ConnectedAt,
DateTimeOffset? LastSyncedAt,
DateTimeOffset? LastSyncAttemptedAt,
DateTimeOffset? LastSyncSucceededAt,
string? LastSyncMode,
string? LastSyncSource,
string? LastSyncStatus,
string? LastSyncError);
[HttpGet("status")]
public async Task<IActionResult> Status(CancellationToken cancellationToken)
public async Task<ActionResult<GmailConnectionStatusDto>> Status(CancellationToken cancellationToken)
{
var ownerUserId = GetRequiredOwnerUserId();
var connection = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken);
return Ok(new
{
connected = connection is not null,
gmailAddress = connection?.GmailAddress,
connectedAt = connection?.ConnectedAt,
lastSyncedAt = connection?.LastSyncedAt,
});
return Ok(new GmailConnectionStatusDto(
connection is not null,
connection?.GmailAddress,
connection?.ConnectedAt,
connection?.LastSyncedAt,
connection?.LastSyncAttemptedAt,
connection?.LastSyncSucceededAt,
connection?.LastSyncMode,
connection?.LastSyncSource,
connection?.LastSyncStatus,
connection?.LastSyncError));
}
[HttpGet("connect-url")]
@@ -119,7 +153,7 @@ public sealed class GmailController : ControllerBase
.ToHashSet(StringComparer.Ordinal);
var rankedMessages = candidateMessages
.Select(message => ScoreMessage(job, message, importedMessageIds.Contains(message.Message.Id), importedThreadIds.Contains(message.Message.ThreadId)))
.Select(message => _matching.ScoreMessage(job, message, importedMessageIds.Contains(message.Message.Id), importedThreadIds.Contains(message.Message.ThreadId)))
.Where(result => result.Score > 0 || result.AlreadyImported)
.OrderByDescending(result => result.Score)
.ThenByDescending(result => result.Message.Date ?? DateTimeOffset.MinValue)
@@ -145,6 +179,7 @@ public sealed class GmailController : ControllerBase
.ThenBy(reason => reason.Label, StringComparer.Ordinal)
.ThenBy(reason => reason.Value, StringComparer.Ordinal)
.Take(8)
.Select(reason => new GmailJobMatchReasonDto(reason.Label, reason.Value, reason.Points))
.ToList();
var matchedQueries = ordered
.SelectMany(item => item.MatchedQueries)
@@ -179,7 +214,7 @@ public sealed class GmailController : ControllerBase
ToConfidence(item.Score),
item.AlreadyImported,
item.MatchedQueries,
item.Reasons)).ToList());
item.Reasons.Select(reason => new GmailJobMatchReasonDto(reason.Label, reason.Value, reason.Points)).ToList())).ToList());
})
.OrderByDescending(thread => thread.Score)
.ThenByDescending(thread => thread.LatestDate ?? DateTimeOffset.MinValue)
@@ -197,6 +232,499 @@ public sealed class GmailController : ControllerBase
threads));
}
[HttpGet("review-candidates")]
public async Task<ActionResult<GmailReviewQueueResponseDto>> ReviewCandidates(
[FromQuery] string? queryOverride,
[FromQuery] int maxResultsPerQuery = 6,
CancellationToken cancellationToken = default)
{
var ownerUserId = GetRequiredOwnerUserId();
var jobs = await _db.JobApplications
.Where(x => x.OwnerUserId == ownerUserId && !x.IsDeleted)
.Include(x => x.Company)
.Include(x => x.Messages)
.OrderByDescending(x => x.DateApplied)
.Take(100)
.ToListAsync(cancellationToken);
if (jobs.Count == 0)
{
return Ok(new GmailReviewQueueResponseDto(Array.Empty<string>(), 0, 0, 0, 0, Array.Empty<GmailReviewThreadDto>()));
}
var querySet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var job in jobs)
{
foreach (var query in _matching.BuildJobQueries(job, queryOverride))
{
querySet.Add(query);
}
}
var queries = querySet.Take(18).ToList();
var candidateMessages = await _gmail.ListJobCandidateMessagesAsync(ownerUserId, queries, maxResultsPerQuery, cancellationToken);
var allImportedMessageIds = jobs.SelectMany(job => job.Messages)
.Where(message => !string.IsNullOrWhiteSpace(message.ExternalMessageId))
.Select(message => message.ExternalMessageId!)
.ToHashSet(StringComparer.Ordinal);
var allImportedThreadIds = jobs.SelectMany(job => job.Messages)
.Where(message => !string.IsNullOrWhiteSpace(message.ExternalThreadId))
.Select(message => message.ExternalThreadId!)
.ToHashSet(StringComparer.Ordinal);
var reviewDecisions = await _db.GmailReviewDecisions
.AsNoTracking()
.Where(decision => decision.OwnerUserId == ownerUserId)
.ToListAsync(cancellationToken);
var groupedThreads = candidateMessages
.GroupBy(message => string.IsNullOrWhiteSpace(message.Message.ThreadId) ? message.Message.Id : message.Message.ThreadId, StringComparer.Ordinal)
.Select(group =>
{
var existingDecision = reviewDecisions.FirstOrDefault(x => x.ThreadId == group.Key);
var orderedMessages = group.OrderByDescending(item => item.Message.Date ?? DateTimeOffset.MinValue).ToList();
var latestDate = orderedMessages.Max(item => item.Message.Date ?? DateTimeOffset.MinValue);
var subject = orderedMessages.FirstOrDefault(item => !string.IsNullOrWhiteSpace(item.Message.Subject))?.Message.Subject ?? "(no subject)";
var matchedQueries = orderedMessages.SelectMany(item => item.MatchedQueries).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList();
var hasImportedMessages = orderedMessages.Any(item => allImportedMessageIds.Contains(item.Message.Id) || allImportedThreadIds.Contains(item.Message.ThreadId));
var jobCandidates = jobs
.Select(job =>
{
var best = orderedMessages
.Select(item => _matching.ScoreMessage(job, item, allImportedMessageIds.Contains(item.Message.Id), allImportedThreadIds.Contains(item.Message.ThreadId)))
.OrderByDescending(score => score.Score)
.First();
return new GmailReviewJobCandidateDto(
job.Id,
job.JobTitle,
job.Company?.Name ?? string.Empty,
best.Score,
best.Confidence,
best.Reasons.Select(reason => new GmailJobMatchReasonDto(reason.Label, reason.Value, reason.Points)).ToList());
})
.Where(candidate => candidate.Score > 0)
.OrderByDescending(candidate => candidate.Score)
.Take(3)
.ToList();
var topScore = jobCandidates.FirstOrDefault()?.Score ?? 0;
var secondScore = jobCandidates.Skip(1).FirstOrDefault()?.Score ?? 0;
var routing = existingDecision?.Decision switch
{
"linked" => "linked",
"rejected" => "rejected",
"suggested" => "suggested",
_ => topScore >= 30 && topScore - secondScore >= 8
? "auto-link"
: topScore >= 16
? "review"
: "unmatched"
};
var messages = orderedMessages
.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.MatchedQueries.Count * 4,
item.MatchedQueries.Count >= 2 ? "medium" : "low",
allImportedMessageIds.Contains(item.Message.Id),
item.MatchedQueries,
Array.Empty<GmailJobMatchReasonDto>()))
.ToList();
return new GmailReviewThreadDto(group.Key, subject, latestDate, orderedMessages.Count, routing, hasImportedMessages, existingDecision?.Note, matchedQueries, jobCandidates, messages);
})
.OrderByDescending(thread => thread.LatestDate ?? DateTimeOffset.MinValue)
.Take(100)
.ToList();
return Ok(new GmailReviewQueueResponseDto(
queries,
groupedThreads.Count,
groupedThreads.Count(thread => thread.Routing == "auto-link"),
groupedThreads.Count(thread => thread.Routing == "review"),
groupedThreads.Count(thread => thread.Routing == "unmatched"),
groupedThreads));
}
[HttpPost("review-decision")]
public async Task<IActionResult> SaveReviewDecision([FromBody] SaveGmailReviewDecisionRequest request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.ThreadId)) return BadRequest("ThreadId is required.");
var decision = (request.Decision ?? string.Empty).Trim().ToLowerInvariant();
if (decision is not ("linked" or "rejected" or "review" or "suggested"))
{
return BadRequest("Decision must be linked, rejected, review, or suggested.");
}
var ownerUserId = GetRequiredOwnerUserId();
JobApplication? job = null;
if (decision == "linked")
{
if (request.JobApplicationId is null or <= 0) return BadRequest("jobApplicationId is required when linking a thread.");
job = await _db.JobApplications
.Where(x => x.OwnerUserId == ownerUserId)
.Include(x => x.Company)
.Include(x => x.Messages)
.FirstOrDefaultAsync(x => x.Id == request.JobApplicationId.Value, cancellationToken);
if (job is null) return NotFound("Job application not found.");
var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, request.ThreadId.Trim(), cancellationToken);
var distinctMessageIds = threadMessages
.Where(message => !string.IsNullOrWhiteSpace(message.Id))
.Select(message => message.Id)
.Distinct(StringComparer.Ordinal)
.ToList();
var existingMessageIds = await _db.Correspondences
.Where(message => message.JobApplicationId == job.Id && message.ExternalMessageId != null && distinctMessageIds.Contains(message.ExternalMessageId))
.Select(message => message.ExternalMessageId!)
.ToListAsync(cancellationToken);
foreach (var messageId in distinctMessageIds)
{
if (existingMessageIds.Contains(messageId, StringComparer.Ordinal)) continue;
await ImportSingleMessageAsync(ownerUserId, job, messageId, cancellationToken);
}
}
var existing = await _db.GmailReviewDecisions.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId && x.ThreadId == request.ThreadId.Trim(), cancellationToken);
if (existing is null)
{
existing = new GmailReviewDecision
{
OwnerUserId = ownerUserId,
ThreadId = request.ThreadId.Trim(),
};
_db.GmailReviewDecisions.Add(existing);
}
existing.Decision = decision switch
{
"review" => "review",
_ => decision,
};
existing.JobApplicationId = decision == "linked" ? request.JobApplicationId : null;
existing.Note = string.IsNullOrWhiteSpace(request.Note) ? null : request.Note.Trim();
existing.UpdatedAt = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
return Ok(new
{
existing.ThreadId,
existing.Decision,
existing.JobApplicationId,
existing.Note,
existing.UpdatedAt,
});
}
[HttpPost("manual-sync")]
public async Task<ActionResult<GmailManualSyncResultDto>> ManualSync([FromBody] GmailManualSyncRequest? request, CancellationToken cancellationToken)
{
var ownerUserId = GetRequiredOwnerUserId();
var jobs = await _db.JobApplications
.Where(x => x.OwnerUserId == ownerUserId && !x.IsDeleted)
.Include(x => x.Company)
.Include(x => x.Messages)
.OrderByDescending(x => x.DateApplied)
.Take(100)
.ToListAsync(cancellationToken);
if (jobs.Count == 0)
{
return Ok(new GmailManualSyncResultDto(0, 0, 0, 0, 0, 0, 0, 0, 365, false, DateTimeOffset.UtcNow));
}
var lookbackDays = Math.Clamp(request?.LookbackDays ?? 365, 30, 365);
var maxResultsPerQuery = Math.Clamp(request?.MaxResultsPerQuery ?? 8, 1, 25);
var includeSpamTrash = request?.IncludeSpamTrash ?? false;
var autoImportHighConfidence = request?.AutoImportHighConfidence ?? true;
var querySet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var jobItem in jobs)
{
foreach (var query in _matching.BuildJobQueries(jobItem, null))
{
var bounded = ApplySyncBoundary(query, lookbackDays, includeSpamTrash);
querySet.Add(bounded);
}
}
var queries = querySet.Take(24).ToList();
var candidateMessages = await _gmail.ListJobCandidateMessagesAsync(ownerUserId, queries, maxResultsPerQuery, cancellationToken);
var allImportedMessageIds = jobs.SelectMany(jobItem => jobItem.Messages)
.Where(message => !string.IsNullOrWhiteSpace(message.ExternalMessageId))
.Select(message => message.ExternalMessageId!)
.ToHashSet(StringComparer.Ordinal);
var allImportedThreadIds = jobs.SelectMany(jobItem => jobItem.Messages)
.Where(message => !string.IsNullOrWhiteSpace(message.ExternalThreadId))
.Select(message => message.ExternalThreadId!)
.ToHashSet(StringComparer.Ordinal);
var reviewDecisions = await _db.GmailReviewDecisions
.Where(decision => decision.OwnerUserId == ownerUserId)
.ToListAsync(cancellationToken);
var groupedThreads = candidateMessages
.GroupBy(message => string.IsNullOrWhiteSpace(message.Message.ThreadId) ? message.Message.Id : message.Message.ThreadId, StringComparer.Ordinal)
.ToList();
var autoLinked = 0;
var reviewCount = 0;
var unmatchedCount = 0;
var importedMessages = 0;
var importedThreads = 0;
var skippedMessages = 0;
foreach (var threadGroup in groupedThreads)
{
var threadId = threadGroup.Key;
var existingDecision = reviewDecisions.FirstOrDefault(x => x.ThreadId == threadId);
if (string.Equals(existingDecision?.Decision, "rejected", StringComparison.OrdinalIgnoreCase))
{
unmatchedCount++;
continue;
}
var orderedMessages = threadGroup.OrderByDescending(item => item.Message.Date ?? DateTimeOffset.MinValue).ToList();
var candidates = jobs
.Select(jobItem =>
{
var best = orderedMessages
.Select(item => _matching.ScoreMessage(jobItem, item, allImportedMessageIds.Contains(item.Message.Id), allImportedThreadIds.Contains(item.Message.ThreadId)))
.OrderByDescending(score => score.Score)
.First();
return new { Job = jobItem, Best = best };
})
.Where(x => x.Best.Score > 0)
.OrderByDescending(x => x.Best.Score)
.Take(3)
.ToList();
var top = candidates.FirstOrDefault();
var secondScore = candidates.Skip(1).FirstOrDefault()?.Best.Score ?? 0;
if (top is not null && autoImportHighConfidence && top.Best.Score >= 30 && top.Best.Score - secondScore >= 8)
{
var distinctMessageIds = orderedMessages.Select(item => item.Message.Id).Distinct(StringComparer.Ordinal).ToList();
var existingIds = await _db.Correspondences
.Where(message => message.JobApplicationId == top.Job.Id && message.ExternalMessageId != null && distinctMessageIds.Contains(message.ExternalMessageId))
.Select(message => message.ExternalMessageId!)
.ToListAsync(cancellationToken);
foreach (var messageId in distinctMessageIds)
{
if (existingIds.Contains(messageId, StringComparer.Ordinal))
{
skippedMessages++;
continue;
}
await ImportSingleMessageAsync(ownerUserId, top.Job, messageId, cancellationToken);
allImportedMessageIds.Add(messageId);
importedMessages++;
}
importedThreads++;
autoLinked++;
UpsertReviewDecision(reviewDecisions, ownerUserId, threadId, "linked", top.Job.Id, existingDecision?.Note);
continue;
}
if (top is not null && top.Best.Score >= 16)
{
reviewCount++;
UpsertReviewDecision(reviewDecisions, ownerUserId, threadId, existingDecision?.Decision == "suggested" ? "suggested" : "review", null, existingDecision?.Note);
continue;
}
unmatchedCount++;
if (LooksLikeJobRelatedThread(orderedMessages))
{
UpsertReviewDecision(reviewDecisions, ownerUserId, threadId, "suggested", null, existingDecision?.Note);
}
}
await _db.SaveChangesAsync(cancellationToken);
return Ok(new GmailManualSyncResultDto(queries.Count, groupedThreads.Count, autoLinked, reviewCount, unmatchedCount, importedMessages, importedThreads, skippedMessages, lookbackDays, includeSpamTrash, DateTimeOffset.UtcNow));
}
[HttpGet("suggested-jobs")]
public async Task<ActionResult<GmailSuggestedJobsResponseDto>> SuggestedJobs(CancellationToken cancellationToken)
{
var ownerUserId = GetRequiredOwnerUserId();
var reviewThreads = await ReviewCandidates(null, 6, cancellationToken);
if (reviewThreads.Result is not OkObjectResult ok || ok.Value is not GmailReviewQueueResponseDto payload)
{
return BadRequest("Unable to compute Gmail suggested jobs.");
}
var items = payload.Threads
.Where(thread => thread.Routing is "unmatched" or "suggested")
.Select(thread => new GmailSuggestedJobCandidateDto(
thread.ThreadId,
thread.Subject,
thread.LatestDate,
ExtractCompanyName(thread.Messages.FirstOrDefault()?.From, thread.Subject),
ExtractRecruiterName(thread.Messages.FirstOrDefault()?.From),
ExtractFirstEmail(thread.Messages.FirstOrDefault()?.From),
ExtractRoleFromSubject(thread.Subject),
thread.Routing,
thread.MatchedQueries,
thread.Messages.FirstOrDefault()?.Snippet ?? string.Empty))
.Where(item => !string.IsNullOrWhiteSpace(item.CompanyName) || !string.IsNullOrWhiteSpace(item.SuggestedJobTitle))
.Take(50)
.ToList();
return Ok(new GmailSuggestedJobsResponseDto(items.Count, items));
}
[HttpPost("create-suggested-job")]
public async Task<ActionResult<CreatedSuggestedGmailJobDto>> CreateSuggestedJob([FromBody] CreateSuggestedGmailJobRequest request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.ThreadId)) return BadRequest("ThreadId is required.");
if (string.IsNullOrWhiteSpace(request.CompanyName)) return BadRequest("CompanyName is required.");
if (string.IsNullOrWhiteSpace(request.JobTitle)) return BadRequest("JobTitle is required.");
var ownerUserId = GetRequiredOwnerUserId();
var companyName = request.CompanyName.Trim();
var jobTitle = request.JobTitle.Trim();
var company = await _db.Companies.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId && x.Name.ToLower() == companyName.ToLower(), cancellationToken);
if (company is null)
{
company = new Company
{
OwnerUserId = ownerUserId,
Name = companyName,
RecruiterName = string.IsNullOrWhiteSpace(request.RecruiterName) ? null : request.RecruiterName.Trim(),
RecruiterEmail = string.IsNullOrWhiteSpace(request.RecruiterEmail) ? null : request.RecruiterEmail.Trim(),
};
_db.Companies.Add(company);
await _db.SaveChangesAsync(cancellationToken);
}
else
{
if (string.IsNullOrWhiteSpace(company.RecruiterName) && !string.IsNullOrWhiteSpace(request.RecruiterName)) company.RecruiterName = request.RecruiterName.Trim();
if (string.IsNullOrWhiteSpace(company.RecruiterEmail) && !string.IsNullOrWhiteSpace(request.RecruiterEmail)) company.RecruiterEmail = request.RecruiterEmail.Trim();
}
var job = new JobApplication
{
OwnerUserId = ownerUserId,
CompanyId = company.Id,
JobTitle = jobTitle,
Status = string.IsNullOrWhiteSpace(request.Status) ? "Applied" : request.Status.Trim(),
Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(),
DateApplied = DateTime.UtcNow,
};
_db.JobApplications.Add(job);
await _db.SaveChangesAsync(cancellationToken);
var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, request.ThreadId.Trim(), cancellationToken);
var distinctMessageIds = threadMessages.Select(message => message.Id).Where(static id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).ToList();
var imported = 0;
var skipped = 0;
foreach (var messageId in distinctMessageIds)
{
var existing = await _db.Correspondences.AnyAsync(message => message.JobApplicationId == job.Id && message.ExternalMessageId == messageId, cancellationToken);
if (existing)
{
skipped++;
continue;
}
await ImportSingleMessageAsync(ownerUserId, job, messageId, cancellationToken);
imported++;
}
UpsertReviewDecision(await _db.GmailReviewDecisions.Where(x => x.OwnerUserId == ownerUserId).ToListAsync(cancellationToken), ownerUserId, request.ThreadId.Trim(), "linked", job.Id, request.Notes);
await _db.SaveChangesAsync(cancellationToken);
return Ok(new CreatedSuggestedGmailJobDto(job.Id, company.Id, request.ThreadId.Trim(), imported, skipped));
}
[HttpPost("relink-thread")]
public async Task<ActionResult<GmailRelinkResultDto>> RelinkThread([FromBody] RelinkGmailThreadRequest request, CancellationToken cancellationToken)
{
if (request.JobApplicationId <= 0) return BadRequest("Valid jobApplicationId is required.");
if (string.IsNullOrWhiteSpace(request.ThreadId)) return BadRequest("ThreadId is required.");
var ownerUserId = GetRequiredOwnerUserId();
var job = await _db.JobApplications
.Where(x => x.OwnerUserId == ownerUserId)
.Include(x => x.Company)
.Include(x => x.Messages)
.FirstOrDefaultAsync(x => x.Id == request.JobApplicationId, cancellationToken);
if (job is null) return NotFound("Job application not found.");
var threadId = request.ThreadId.Trim();
var unlinkedMessages = 0;
if (request.RemoveFromOtherJobs)
{
var otherMessages = await _db.Correspondences
.Include(message => message.JobApplication)
.Where(message => message.ExternalThreadId == threadId && message.JobApplicationId != job.Id && message.JobApplication.OwnerUserId == ownerUserId)
.ToListAsync(cancellationToken);
if (otherMessages.Count > 0)
{
_db.Correspondences.RemoveRange(otherMessages);
unlinkedMessages = otherMessages.Count;
}
}
var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
var distinctMessageIds = threadMessages.Select(message => message.Id).Where(static id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).ToList();
var existingMessageIds = await _db.Correspondences
.Where(message => message.JobApplicationId == job.Id && message.ExternalMessageId != null && distinctMessageIds.Contains(message.ExternalMessageId))
.Select(message => message.ExternalMessageId!)
.ToListAsync(cancellationToken);
var imported = 0;
var skipped = 0;
foreach (var messageId in distinctMessageIds)
{
if (existingMessageIds.Contains(messageId, StringComparer.Ordinal))
{
skipped++;
continue;
}
await ImportSingleMessageAsync(ownerUserId, job, messageId, cancellationToken);
imported++;
}
var reviewDecisions = await _db.GmailReviewDecisions.Where(x => x.OwnerUserId == ownerUserId).ToListAsync(cancellationToken);
UpsertReviewDecision(reviewDecisions, ownerUserId, threadId, "linked", job.Id, request.Note);
await _db.SaveChangesAsync(cancellationToken);
return Ok(new GmailRelinkResultDto(threadId, job.Id, imported, skipped, unlinkedMessages));
}
[HttpPost("unlink-thread")]
public async Task<ActionResult<GmailUnlinkResultDto>> UnlinkThread([FromBody] UnlinkGmailThreadRequest request, CancellationToken cancellationToken)
{
if (request.JobApplicationId <= 0) return BadRequest("Valid jobApplicationId is required.");
if (string.IsNullOrWhiteSpace(request.ThreadId)) return BadRequest("ThreadId is required.");
var ownerUserId = GetRequiredOwnerUserId();
var job = await _db.JobApplications
.Where(x => x.OwnerUserId == ownerUserId)
.FirstOrDefaultAsync(x => x.Id == request.JobApplicationId, cancellationToken);
if (job is null) return NotFound("Job application not found.");
var threadId = request.ThreadId.Trim();
var messages = await _db.Correspondences
.Where(message => message.JobApplicationId == job.Id && message.ExternalThreadId == threadId)
.ToListAsync(cancellationToken);
if (messages.Count > 0)
{
_db.Correspondences.RemoveRange(messages);
}
var reviewDecisions = await _db.GmailReviewDecisions.Where(x => x.OwnerUserId == ownerUserId).ToListAsync(cancellationToken);
var nextDecision = (request.NextDecision ?? "review").Trim().ToLowerInvariant();
if (nextDecision is not ("review" or "suggested" or "rejected")) nextDecision = "review";
UpsertReviewDecision(reviewDecisions, ownerUserId, threadId, nextDecision, null, request.Note);
await _db.SaveChangesAsync(cancellationToken);
return Ok(new GmailUnlinkResultDto(threadId, job.Id, messages.Count, nextDecision));
}
[AllowAnonymous]
[HttpGet("oauth/callback")]
public async Task<IActionResult> Callback([FromQuery] string? code, [FromQuery] string? state, [FromQuery] string? error, CancellationToken cancellationToken)
@@ -398,12 +926,22 @@ public sealed class GmailController : ControllerBase
{
JobApplicationId = job.Id,
From = isMe ? "Me" : "Company",
Direction = isMe ? "outbound" : "inbound",
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(),
ExternalLabelsJson = detail.Labels.Count == 0 ? null : JsonSerializer.Serialize(detail.Labels),
AttachmentMetadataJson = detail.Attachments.Count == 0 ? null : JsonSerializer.Serialize(detail.Attachments.Select(attachment => new CorrespondenceAttachmentMetadata
{
FileName = attachment.FileName,
MimeType = attachment.MimeType,
SizeBytes = attachment.SizeBytes,
GmailAttachmentId = attachment.GmailAttachmentId,
Inline = attachment.Inline,
})),
Content = string.IsNullOrWhiteSpace(detail.BodyText) ? detail.Snippet : detail.BodyText,
Date = messageDate,
};
@@ -433,168 +971,63 @@ public sealed class GmailController : ControllerBase
return message;
}
private static IReadOnlyList<string> BuildJobQueries(JobApplication job, string? queryOverride)
private IReadOnlyList<string> BuildJobQueries(JobApplication job, string? queryOverride)
{
var queries = new List<string>();
void Add(string? query)
{
if (!string.IsNullOrWhiteSpace(query))
{
queries.Add(query.Trim());
}
return _matching.BuildJobQueries(job, queryOverride);
}
Add(queryOverride);
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail))
private static string ApplySyncBoundary(string query, int lookbackDays, bool includeSpamTrash)
{
Add($"(from:{job.Company.RecruiterEmail.Trim()} OR to:{job.Company.RecruiterEmail.Trim()}) newer_than:365d");
var bounded = (query ?? string.Empty).Trim();
if (!bounded.Contains("newer_than:", StringComparison.OrdinalIgnoreCase))
{
bounded = string.IsNullOrWhiteSpace(bounded)
? $"newer_than:{lookbackDays}d"
: $"{bounded} newer_than:{lookbackDays}d";
}
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName))
if (!includeSpamTrash)
{
Add($"\"{job.Company.RecruiterName.Trim()}\" newer_than:365d");
if (!bounded.Contains("in:spam", StringComparison.OrdinalIgnoreCase)) bounded += " -in:spam";
if (!bounded.Contains("in:trash", StringComparison.OrdinalIgnoreCase)) bounded += " -in:trash";
}
if (!string.IsNullOrWhiteSpace(job.Company?.Name) && !string.IsNullOrWhiteSpace(job.JobTitle))
{
Add($"\"{job.Company.Name.Trim()}\" \"{job.JobTitle.Trim()}\" newer_than:365d");
return bounded.Trim();
}
if (!string.IsNullOrWhiteSpace(job.Company?.Name))
private static bool LooksLikeJobRelatedThread(IReadOnlyList<GmailQueryMatchedMessage> orderedMessages)
{
Add($"\"{job.Company.Name.Trim()}\" (application OR interview OR recruiter OR role OR position) newer_than:365d");
var sample = string.Join("\n", orderedMessages.Select(item => string.Join(" ", new[] { item.Message.Subject, item.Message.From, item.Message.Snippet }.Where(value => !string.IsNullOrWhiteSpace(value)))));
if (string.IsNullOrWhiteSpace(sample)) return false;
return sample.Contains("interview", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("application", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("recruit", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("role", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("position", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("offer", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("follow up", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("follow-up", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("rejection", StringComparison.OrdinalIgnoreCase);
}
if (!string.IsNullOrWhiteSpace(job.JobTitle))
private void UpsertReviewDecision(List<GmailReviewDecision> decisions, string ownerUserId, string threadId, string decision, int? jobApplicationId, string? note)
{
Add($"subject:\"{job.JobTitle.Trim()}\" newer_than:365d");
var existing = decisions.FirstOrDefault(x => x.ThreadId == threadId);
if (existing is null)
{
existing = new GmailReviewDecision
{
OwnerUserId = ownerUserId,
ThreadId = threadId,
};
decisions.Add(existing);
_db.GmailReviewDecisions.Add(existing);
}
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, GmailQueryMatchedMessage candidate, bool alreadyImported, bool threadAlreadyImported)
{
var reasons = new List<GmailJobMatchReasonDto>();
var score = 0;
var message = candidate.Message;
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 (candidate.MatchedQueries.Count > 0)
{
var queryHitPoints = Math.Min(12, candidate.MatchedQueries.Count * 4);
score += queryHitPoints;
reasons.Add(new GmailJobMatchReasonDto("queryHits", candidate.MatchedQueries.Count.ToString(), queryHitPoints));
}
if (!string.IsNullOrWhiteSpace(job.Company?.Name) && ContainsValue(haystack, job.Company.Name))
{
score += 18;
reasons.Add(new GmailJobMatchReasonDto("company", job.Company.Name.Trim(), 18));
}
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(), 20));
}
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName) && ContainsValue(haystack, job.Company.RecruiterName))
{
score += 12;
reasons.Add(new GmailJobMatchReasonDto("recruiter", job.Company.RecruiterName.Trim(), 12));
}
foreach (var token in SplitTerms(job.JobTitle).Take(4))
{
if (!ContainsValue(haystack, token)) continue;
score += 5;
reasons.Add(new GmailJobMatchReasonDto("jobTitle", token, 5));
}
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(), 8));
}
if (message.Date is { } messageDate)
{
var ageDays = Math.Abs((DateTimeOffset.UtcNow - messageDate).TotalDays);
if (ageDays <= 45)
{
score += 4;
reasons.Add(new GmailJobMatchReasonDto("recency", "45d", 4));
}
else if (ageDays <= 180)
{
score += 2;
reasons.Add(new GmailJobMatchReasonDto("recency", "180d", 2));
}
}
if (threadAlreadyImported && !alreadyImported)
{
reasons.Add(new GmailJobMatchReasonDto("status", "thread-already-imported", 0));
}
if (alreadyImported)
{
reasons.Add(new GmailJobMatchReasonDto("status", "already-imported", 0));
}
reasons = reasons
.GroupBy(reason => new { reason.Label, reason.Value, reason.Points })
.Select(group => group.First())
.OrderByDescending(reason => reason.Points)
.ThenBy(reason => reason.Label, StringComparer.Ordinal)
.ThenBy(reason => reason.Value, StringComparer.Ordinal)
.ToList();
return new GmailScoredMessage(message, alreadyImported, score, candidate.MatchedQueries, 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;
}
existing.Decision = decision;
existing.JobApplicationId = jobApplicationId;
if (!string.IsNullOrWhiteSpace(note)) existing.Note = note.Trim();
existing.UpdatedAt = DateTimeOffset.UtcNow;
}
private static string ToConfidence(int score)
@@ -607,6 +1040,44 @@ public sealed class GmailController : ControllerBase
};
}
private static string? ExtractFirstEmail(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var match = System.Text.RegularExpressions.Regex.Match(value, @"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
return match.Success ? match.Value : null;
}
private static string? ExtractRecruiterName(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var trimmed = value.Split('<')[0].Trim().Trim('"');
return string.IsNullOrWhiteSpace(trimmed) || trimmed.Contains('@') ? null : trimmed;
}
private static string? ExtractCompanyName(string? from, string? subject)
{
var subjectText = (subject ?? string.Empty).Trim();
if (!string.IsNullOrWhiteSpace(subjectText))
{
var parts = subjectText.Split(new[] { '-', '', '|' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length >= 2) return parts[0];
}
var recruiterName = ExtractRecruiterName(from);
return recruiterName is { Length: > 0 } && recruiterName.Contains(' ') ? recruiterName.Split(' ').Last() : null;
}
private static string? ExtractRoleFromSubject(string? subject)
{
if (string.IsNullOrWhiteSpace(subject)) return null;
var trimmed = subject.Trim();
if (trimmed.Contains("interview", StringComparison.OrdinalIgnoreCase))
{
return trimmed.Replace("interview", string.Empty, StringComparison.OrdinalIgnoreCase).Trim(' ', '-', ':');
}
return trimmed.Length <= 120 ? trimmed : trimmed[..120];
}
private string GetRequiredOwnerUserId()
{
return User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")
@@ -652,11 +1123,4 @@ public sealed class GmailController : ControllerBase
</body>
</html>";
}
private sealed record GmailScoredMessage(
GmailMessageSummary Message,
bool AlreadyImported,
int Score,
IReadOnlyList<string> MatchedQueries,
IReadOnlyList<GmailJobMatchReasonDto> Reasons);
}
+2
View File
@@ -3,9 +3,11 @@ FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY JobTrackerApi/JobTrackerApi.csproj JobTrackerApi/
COPY JobTrackerBackend/JobTrackerBackend.csproj JobTrackerBackend/
COPY Data/ Data/
COPY Models/ Models/
COPY JobTrackerApi/ JobTrackerApi/
COPY JobTrackerBackend/ JobTrackerBackend/
RUN dotnet publish JobTrackerApi/JobTrackerApi.csproj -c Release -o /app/publish /p:UseAppHost=false
+1 -6
View File
@@ -10,12 +10,7 @@
<ItemGroup>
<Compile Remove="Controllers\**\*.cs" />
<Compile Remove="Services\**\*.cs" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.14">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.14">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.14" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
</ItemGroup>
+82 -2
View File
@@ -135,6 +135,8 @@ builder.Services.AddSingleton<ISummarizerService, SummarizerService>();
builder.Services.AddSingleton<ICvAiClassifier, CvAiClassifier>();
builder.Services.AddSingleton<IGoogleTokenValidator, GoogleTokenValidator>();
builder.Services.AddScoped<IGmailOAuthService, GmailOAuthService>();
builder.Services.AddSingleton<IGmailJobMatchingService, GmailJobMatchingService>();
builder.Services.AddSingleton<IGmailCorrespondenceEnrichmentService, NoOpGmailCorrespondenceEnrichmentService>();
builder.Services.AddIdentityCore<ApplicationUser>(options =>
{
@@ -628,10 +630,35 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" (
"AccessTokenExpiresAt" TEXT NULL,
"Scope" TEXT NOT NULL,
"ConnectedAt" TEXT NOT NULL,
"LastSyncedAt" TEXT NULL
"LastSyncedAt" TEXT NULL,
"LastSyncAttemptedAt" TEXT NULL,
"LastSyncSucceededAt" TEXT NULL,
"LastSyncMode" TEXT NULL,
"LastSyncSource" TEXT NULL,
"LastSyncStatus" TEXT NULL,
"LastSyncError" TEXT NULL
);
""");
Exec(c, """
CREATE TABLE IF NOT EXISTS "GmailReviewDecisions" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_GmailReviewDecisions" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL,
"ThreadId" TEXT NOT NULL,
"JobApplicationId" INTEGER NULL,
"Decision" TEXT NOT NULL,
"Note" TEXT NULL,
"UpdatedAt" TEXT NOT NULL
);
""");
EnsureColumn(c, "GmailConnections", "LastSyncAttemptedAt", "ALTER TABLE GmailConnections ADD COLUMN LastSyncAttemptedAt TEXT NULL;");
EnsureColumn(c, "GmailConnections", "LastSyncSucceededAt", "ALTER TABLE GmailConnections ADD COLUMN LastSyncSucceededAt TEXT NULL;");
EnsureColumn(c, "GmailConnections", "LastSyncMode", "ALTER TABLE GmailConnections ADD COLUMN LastSyncMode TEXT NULL;");
EnsureColumn(c, "GmailConnections", "LastSyncSource", "ALTER TABLE GmailConnections ADD COLUMN LastSyncSource TEXT NULL;");
EnsureColumn(c, "GmailConnections", "LastSyncStatus", "ALTER TABLE GmailConnections ADD COLUMN LastSyncStatus TEXT NULL;");
EnsureColumn(c, "GmailConnections", "LastSyncError", "ALTER TABLE GmailConnections ADD COLUMN LastSyncError TEXT NULL;");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_GmailConnections_OwnerUserId" ON "GmailConnections" ("OwnerUserId");""");
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_GmailConnections_OwnerUserId_GmailAddress" ON "GmailConnections" ("OwnerUserId", "GmailAddress");""");
}
@@ -739,6 +766,9 @@ CREATE TABLE IF NOT EXISTS "TailoredCvDrafts" (
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, "Correspondences", "Direction", "ALTER TABLE Correspondences ADD COLUMN Direction TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalLabelsJson", "ALTER TABLE Correspondences ADD COLUMN ExternalLabelsJson TEXT NULL;");
EnsureColumn(conn, "Correspondences", "AttachmentMetadataJson", "ALTER TABLE Correspondences ADD COLUMN AttachmentMetadataJson TEXT NULL;");
}
// Record the migration as applied.
@@ -766,6 +796,9 @@ CREATE TABLE IF NOT EXISTS "TailoredCvDrafts" (
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, "Correspondences", "Direction", "ALTER TABLE Correspondences ADD COLUMN Direction TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalLabelsJson", "ALTER TABLE Correspondences ADD COLUMN ExternalLabelsJson TEXT NULL;");
EnsureColumn(conn, "Correspondences", "AttachmentMetadataJson", "ALTER TABLE Correspondences ADD COLUMN AttachmentMetadataJson 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;");
@@ -865,6 +898,9 @@ CREATE TABLE IF NOT EXISTS "TailoredCvDrafts" (
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, "Correspondences", "Direction", "ALTER TABLE `Correspondences` ADD COLUMN `Direction` varchar(100) NULL;");
EnsureMySqlColumn(conn, "Correspondences", "ExternalLabelsJson", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalLabelsJson` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "AttachmentMetadataJson", "ALTER TABLE `Correspondences` ADD COLUMN `AttachmentMetadataJson` 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", "ProfileCvText", "ALTER TABLE `AspNetUsers` ADD COLUMN `ProfileCvText` longtext NULL;");
@@ -939,6 +975,37 @@ CONSTRAINT `FK_CvExtractionRuns_CvUploadArtifacts_ArtifactId` FOREIGN KEY (`Arti
cmd.ExecuteNonQuery();
}
if (!HasMySqlTable(conn, "GmailConnections"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `GmailConnections` (
`Id` int NOT NULL AUTO_INCREMENT,
`OwnerUserId` varchar(255) NOT NULL,
`GmailAddress` varchar(512) NOT NULL,
`EncryptedRefreshToken` longtext NOT NULL,
`EncryptedAccessToken` longtext NULL,
`AccessTokenExpiresAt` datetime(6) NULL,
`Scope` longtext NOT NULL,
`ConnectedAt` datetime(6) NOT NULL,
`LastSyncedAt` datetime(6) NULL,
`LastSyncAttemptedAt` datetime(6) NULL,
`LastSyncSucceededAt` datetime(6) NULL,
`LastSyncMode` varchar(255) NULL,
`LastSyncSource` varchar(255) NULL,
`LastSyncStatus` varchar(255) NULL,
`LastSyncError` longtext NULL,
PRIMARY KEY (`Id`)
);";
cmd.ExecuteNonQuery();
}
EnsureMySqlColumn(conn, "GmailConnections", "LastSyncAttemptedAt", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncAttemptedAt` datetime(6) NULL;");
EnsureMySqlColumn(conn, "GmailConnections", "LastSyncSucceededAt", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncSucceededAt` datetime(6) NULL;");
EnsureMySqlColumn(conn, "GmailConnections", "LastSyncMode", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncMode` varchar(255) NULL;");
EnsureMySqlColumn(conn, "GmailConnections", "LastSyncSource", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncSource` varchar(255) NULL;");
EnsureMySqlColumn(conn, "GmailConnections", "LastSyncStatus", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncStatus` varchar(255) NULL;");
EnsureMySqlColumn(conn, "GmailConnections", "LastSyncError", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncError` longtext NULL;");
if (!HasMySqlTable(conn, "TailoredCvDrafts"))
{
using var cmd = conn.CreateCommand();
@@ -1000,6 +1067,20 @@ CONSTRAINT `FK_TailoredCvDrafts_JobApplications_JobApplicationId` FOREIGN KEY (`
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "GmailConnections", "IX_GmailConnections_OwnerUserId"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_GmailConnections_OwnerUserId` ON `GmailConnections` (`OwnerUserId`);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "GmailConnections", "IX_GmailConnections_OwnerUserId_GmailAddress"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE UNIQUE INDEX `IX_GmailConnections_OwnerUserId_GmailAddress` ON `GmailConnections` (`OwnerUserId`, `GmailAddress`);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "TailoredCvDrafts", "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId"))
{
using var cmd = conn.CreateCommand();
@@ -1123,4 +1204,3 @@ app.UseAuthorization();
app.MapControllers();
app.Run();
app.Run();
@@ -0,0 +1,21 @@
namespace JobTrackerApi.Services;
public sealed record GmailSemanticMatchCandidate(
int? JobApplicationId,
string? Confidence,
string? Reason,
IReadOnlyList<string>? ExtractedCompanies,
IReadOnlyList<string>? ExtractedRecruiters,
IReadOnlyList<string>? ExtractedRoles,
IReadOnlyList<string>? ExtractedHints);
public interface IGmailCorrespondenceEnrichmentService
{
Task<GmailSemanticMatchCandidate?> EnrichAsync(string threadSubject, string from, string to, string snippet, string? bodyText, CancellationToken cancellationToken = default);
}
public sealed class NoOpGmailCorrespondenceEnrichmentService : IGmailCorrespondenceEnrichmentService
{
public Task<GmailSemanticMatchCandidate?> EnrichAsync(string threadSubject, string from, string to, string snippet, string? bodyText, CancellationToken cancellationToken = default)
=> Task.FromResult<GmailSemanticMatchCandidate?>(null);
}
@@ -0,0 +1,167 @@
using JobTrackerApi.Controllers;
using JobTrackerApi.Models;
namespace JobTrackerApi.Services;
public sealed record GmailMatchReason(string Label, string Value, int Points);
public sealed record GmailScoredMessageResult(
GmailMessageSummary Message,
bool AlreadyImported,
int Score,
string Confidence,
IReadOnlyList<string> MatchedQueries,
IReadOnlyList<GmailMatchReason> Reasons);
public interface IGmailJobMatchingService
{
IReadOnlyList<string> BuildJobQueries(JobApplication job, string? queryOverride);
GmailScoredMessageResult ScoreMessage(JobApplication job, GmailQueryMatchedMessage candidate, bool alreadyImported, bool threadAlreadyImported);
}
public sealed class GmailJobMatchingService : IGmailJobMatchingService
{
public 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();
}
public GmailScoredMessageResult ScoreMessage(JobApplication job, GmailQueryMatchedMessage candidate, bool alreadyImported, bool threadAlreadyImported)
{
var reasons = new List<GmailMatchReason>();
var score = 0;
var message = candidate.Message;
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 (candidate.MatchedQueries.Count > 0)
{
var queryHitPoints = Math.Min(12, candidate.MatchedQueries.Count * 4);
score += queryHitPoints;
reasons.Add(new GmailMatchReason("queryHits", candidate.MatchedQueries.Count.ToString(), queryHitPoints));
}
if (!string.IsNullOrWhiteSpace(job.Company?.Name) && ContainsValue(haystack, job.Company.Name))
{
score += 18;
reasons.Add(new GmailMatchReason("company", job.Company.Name.Trim(), 18));
}
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail) && (ContainsValue(from, job.Company.RecruiterEmail) || ContainsValue(to, job.Company.RecruiterEmail)))
{
score += 20;
reasons.Add(new GmailMatchReason("recruiterEmail", job.Company.RecruiterEmail.Trim(), 20));
}
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName) && ContainsValue(haystack, job.Company.RecruiterName))
{
score += 12;
reasons.Add(new GmailMatchReason("recruiter", job.Company.RecruiterName.Trim(), 12));
}
foreach (var token in SplitTerms(job.JobTitle).Take(4))
{
if (!ContainsValue(haystack, token)) continue;
score += 5;
reasons.Add(new GmailMatchReason("jobTitle", token, 5));
}
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 GmailMatchReason("existingSubject", subjectLine!.Trim(), 8));
}
if (message.Date is { } messageDate)
{
var ageDays = Math.Abs((DateTimeOffset.UtcNow - messageDate).TotalDays);
if (ageDays <= 45)
{
score += 4;
reasons.Add(new GmailMatchReason("recency", "45d", 4));
}
else if (ageDays <= 180)
{
score += 2;
reasons.Add(new GmailMatchReason("recency", "180d", 2));
}
}
if (threadAlreadyImported && !alreadyImported)
reasons.Add(new GmailMatchReason("status", "thread-already-imported", 0));
if (alreadyImported)
reasons.Add(new GmailMatchReason("status", "already-imported", 0));
reasons = reasons
.GroupBy(reason => new { reason.Label, reason.Value, reason.Points })
.Select(group => group.First())
.OrderByDescending(reason => reason.Points)
.ThenBy(reason => reason.Label, StringComparer.Ordinal)
.ThenBy(reason => reason.Value, StringComparer.Ordinal)
.ToList();
return new GmailScoredMessageResult(message, alreadyImported, score, ToConfidence(score), candidate.MatchedQueries, reasons);
}
private static bool ContainsValue(string haystack, string? value)
=> !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) => score switch
{
>= 30 => "high",
>= 16 => "medium",
_ => "low"
};
}
+111 -5
View File
@@ -27,7 +27,8 @@ public interface IGmailOAuthService
public sealed record GmailOAuthExchangeResult(string GmailAddress);
public sealed record GmailMessageSummary(string Id, string ThreadId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet);
public sealed record GmailQueryMatchedMessage(GmailMessageSummary Message, IReadOnlyList<string> MatchedQueries);
public sealed record GmailMessageDetail(string Id, string ThreadId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet, string BodyText, string? BodyHtml);
public sealed record GmailMessageAttachment(string? FileName, string? MimeType, long? SizeBytes, string? GmailAttachmentId, bool Inline);
public sealed record GmailMessageDetail(string Id, string ThreadId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet, string BodyText, string? BodyHtml, IReadOnlyList<string> Labels, IReadOnlyList<GmailMessageAttachment> Attachments);
internal sealed class GmailTokenResponse
{
@@ -116,6 +117,12 @@ public sealed class GmailOAuthService : IGmailOAuthService
existing.AccessTokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(tokens.expires_in - 60, 60));
existing.Scope = tokens.scope?.Trim() ?? Scope;
existing.ConnectedAt = DateTimeOffset.UtcNow;
existing.LastSyncStatus = "connected";
existing.LastSyncSource = "oauth-callback";
existing.LastSyncMode = "connect";
existing.LastSyncError = null;
existing.LastSyncAttemptedAt = DateTimeOffset.UtcNow;
existing.LastSyncSucceededAt = existing.LastSyncAttemptedAt;
await _db.SaveChangesAsync(cancellationToken);
return new GmailOAuthExchangeResult(existing.GmailAddress);
@@ -148,6 +155,8 @@ public sealed class GmailOAuthService : IGmailOAuthService
public async Task<IReadOnlyList<GmailMessageSummary>> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken)
{
maxResults = Math.Clamp(maxResults, 1, 25);
try
{
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
@@ -164,6 +173,7 @@ public sealed class GmailOAuthService : IGmailOAuthService
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
if (!doc.RootElement.TryGetProperty("messages", out var messagesElement) || messagesElement.ValueKind != JsonValueKind.Array)
{
await TouchSyncStateAsync(ownerUserId, "list-messages", string.IsNullOrWhiteSpace(query) ? "default-query" : "custom-query", true, null, cancellationToken);
return Array.Empty<GmailMessageSummary>();
}
@@ -180,9 +190,15 @@ public sealed class GmailOAuthService : IGmailOAuthService
results.Add(new GmailMessageSummary(detail.Id, detail.ThreadId, detail.Subject, detail.From, detail.To, detail.Date, detail.Snippet));
}
await TouchSyncTimeAsync(ownerUserId, cancellationToken);
await TouchSyncStateAsync(ownerUserId, "list-messages", string.IsNullOrWhiteSpace(query) ? "default-query" : "custom-query", true, null, cancellationToken);
return results;
}
catch (Exception ex)
{
await TouchSyncStateAsync(ownerUserId, "list-messages", string.IsNullOrWhiteSpace(query) ? "default-query" : "custom-query", false, ex.Message, cancellationToken);
throw;
}
}
public async Task<IReadOnlyList<GmailMessageSummary>> ListMessagesForQueriesAsync(string ownerUserId, IEnumerable<string> queries, int maxResultsPerQuery, CancellationToken cancellationToken)
{
@@ -233,6 +249,8 @@ public sealed class GmailOAuthService : IGmailOAuthService
return Array.Empty<GmailMessageSummary>();
}
try
{
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
@@ -244,6 +262,7 @@ public sealed class GmailOAuthService : IGmailOAuthService
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
if (!doc.RootElement.TryGetProperty("messages", out var messagesElement) || messagesElement.ValueKind != JsonValueKind.Array)
{
await TouchSyncStateAsync(ownerUserId, "thread-refresh", "thread-metadata", true, null, cancellationToken);
return Array.Empty<GmailMessageSummary>();
}
@@ -276,11 +295,19 @@ public sealed class GmailOAuthService : IGmailOAuthService
snippet));
}
await TouchSyncTimeAsync(ownerUserId, cancellationToken);
await TouchSyncStateAsync(ownerUserId, "thread-refresh", "thread-metadata", true, null, cancellationToken);
return results;
}
catch (Exception ex)
{
await TouchSyncStateAsync(ownerUserId, "thread-refresh", "thread-metadata", false, ex.Message, cancellationToken);
throw;
}
}
public async Task<GmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken)
{
try
{
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
var client = _httpClientFactory.CreateClient();
@@ -295,9 +322,13 @@ public sealed class GmailOAuthService : IGmailOAuthService
var threadId = root.TryGetProperty("threadId", out var threadEl) ? threadEl.GetString() ?? "" : "";
var snippet = root.TryGetProperty("snippet", out var snippetEl) ? snippetEl.GetString() ?? "" : "";
var labels = root.TryGetProperty("labelIds", out var labelIdsEl) && labelIdsEl.ValueKind == JsonValueKind.Array
? labelIdsEl.EnumerateArray().Where(x => x.ValueKind == JsonValueKind.String).Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x)).Cast<string>().ToList()
: new List<string>();
var payload = root.GetProperty("payload");
var headers = ReadHeaders(payload);
var attachments = ReadAttachments(payload);
var bodyText = ExtractBody(payload, "text/plain");
var bodyHtml = ExtractBody(payload, "text/html");
if (string.IsNullOrWhiteSpace(bodyText) && !string.IsNullOrWhiteSpace(bodyHtml))
@@ -309,6 +340,7 @@ public sealed class GmailOAuthService : IGmailOAuthService
bodyText = StripHtml(bodyText);
}
await TouchSyncStateAsync(ownerUserId, "message-detail", "gmail-message", true, null, cancellationToken);
return new GmailMessageDetail(
messageId,
threadId,
@@ -318,9 +350,17 @@ public sealed class GmailOAuthService : IGmailOAuthService
headers.TryGetValue("date", out var dateRaw) && DateTimeOffset.TryParse(dateRaw, out var parsedDate) ? parsedDate : null,
snippet,
bodyText.Trim(),
string.IsNullOrWhiteSpace(bodyHtml) ? null : bodyHtml
string.IsNullOrWhiteSpace(bodyHtml) ? null : bodyHtml,
labels,
attachments
);
}
catch (Exception ex)
{
await TouchSyncStateAsync(ownerUserId, "message-detail", "gmail-message", false, ex.Message, cancellationToken);
throw;
}
}
private async Task<string> GetValidAccessTokenAsync(string ownerUserId, CancellationToken cancellationToken)
{
@@ -435,13 +475,37 @@ public sealed class GmailOAuthService : IGmailOAuthService
}
private async Task TouchSyncTimeAsync(string ownerUserId, CancellationToken cancellationToken)
{
await TouchSyncStateAsync(ownerUserId, "sync", "gmail", true, null, cancellationToken);
}
private async Task TouchSyncStateAsync(string ownerUserId, string mode, string source, bool succeeded, string? error, CancellationToken cancellationToken)
{
var connection = await _db.GmailConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (connection is null) return;
connection.LastSyncedAt = DateTimeOffset.UtcNow;
var now = DateTimeOffset.UtcNow;
connection.LastSyncAttemptedAt = now;
connection.LastSyncMode = mode;
connection.LastSyncSource = source;
connection.LastSyncStatus = succeeded ? "success" : "error";
connection.LastSyncError = succeeded ? null : TrimError(error);
if (succeeded)
{
connection.LastSyncedAt = now;
connection.LastSyncSucceededAt = now;
}
await _db.SaveChangesAsync(cancellationToken);
}
private static string? TrimError(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var trimmed = value.Trim();
return trimmed.Length <= 300 ? trimmed : trimmed[..300];
}
private string GetRequiredClientId()
{
return (_cfg["Google:ClientId"] ?? _cfg["Auth:GoogleClientId"] ?? "").Trim() switch
@@ -481,6 +545,48 @@ public sealed class GmailOAuthService : IGmailOAuthService
return result;
}
private static List<GmailMessageAttachment> ReadAttachments(JsonElement payload)
{
var results = new List<GmailMessageAttachment>();
ReadAttachmentsRecursive(payload, results);
return results;
}
private static void ReadAttachmentsRecursive(JsonElement payload, List<GmailMessageAttachment> results)
{
var body = payload.TryGetProperty("body", out var bodyEl) && bodyEl.ValueKind == JsonValueKind.Object
? bodyEl
: default;
var gmailAttachmentId = body.ValueKind == JsonValueKind.Object && body.TryGetProperty("attachmentId", out var attachmentIdEl) && attachmentIdEl.ValueKind == JsonValueKind.String
? attachmentIdEl.GetString()
: null;
var filename = payload.TryGetProperty("filename", out var filenameEl) ? filenameEl.GetString() : null;
var mimeType = payload.TryGetProperty("mimeType", out var mimeTypeEl) ? mimeTypeEl.GetString() : null;
var sizeBytes = body.ValueKind == JsonValueKind.Object && body.TryGetProperty("size", out var sizeEl) && sizeEl.ValueKind == JsonValueKind.Number
? sizeEl.GetInt64()
: (long?)null;
var disposition = payload.TryGetProperty("headers", out var headersEl) && headersEl.ValueKind == JsonValueKind.Array
? headersEl.EnumerateArray()
.Where(h => h.TryGetProperty("name", out var n) && string.Equals(n.GetString(), "Content-Disposition", StringComparison.OrdinalIgnoreCase))
.Select(h => h.TryGetProperty("value", out var v) ? v.GetString() : null)
.FirstOrDefault()
: null;
var isInline = !string.IsNullOrWhiteSpace(disposition) && disposition.Contains("inline", StringComparison.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(gmailAttachmentId) || !string.IsNullOrWhiteSpace(filename))
{
results.Add(new GmailMessageAttachment(filename, mimeType, sizeBytes, gmailAttachmentId, isInline));
}
if (payload.TryGetProperty("parts", out var partsEl) && partsEl.ValueKind == JsonValueKind.Array)
{
foreach (var part in partsEl.EnumerateArray())
{
ReadAttachmentsRecursive(part, results);
}
}
}
private static string ExtractBody(JsonElement payload, string mimeType)
{
if (payload.TryGetProperty("mimeType", out var mimeTypeEl) &&
+23
View File
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace JobTrackerApi.Models
@@ -11,13 +12,35 @@ namespace JobTrackerApi.Models
[JsonIgnore]
public JobApplication JobApplication { get; set; } = null!;
public string From { get; set; } = ""; // "Me" or "Company"
public string? Direction { get; set; } // inbound, outbound, internal, unknown
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? ExternalLabelsJson { get; set; }
public string? AttachmentMetadataJson { get; set; }
public string Content { get; set; } = "";
public DateTime Date { get; set; } = DateTime.Now;
[JsonIgnore]
public IReadOnlyList<string> ExternalLabels => string.IsNullOrWhiteSpace(ExternalLabelsJson)
? Array.Empty<string>()
: (System.Text.Json.JsonSerializer.Deserialize<List<string>>(ExternalLabelsJson) ?? new List<string>());
[JsonIgnore]
public IReadOnlyList<CorrespondenceAttachmentMetadata> AttachmentMetadata => string.IsNullOrWhiteSpace(AttachmentMetadataJson)
? Array.Empty<CorrespondenceAttachmentMetadata>()
: (System.Text.Json.JsonSerializer.Deserialize<List<CorrespondenceAttachmentMetadata>>(AttachmentMetadataJson) ?? new List<CorrespondenceAttachmentMetadata>());
}
public sealed class CorrespondenceAttachmentMetadata
{
public string? FileName { get; set; }
public string? MimeType { get; set; }
public long? SizeBytes { get; set; }
public string? GmailAttachmentId { get; set; }
public bool Inline { get; set; }
}
}
+6
View File
@@ -11,4 +11,10 @@ public sealed class GmailConnection
public string Scope { get; set; } = "";
public DateTimeOffset ConnectedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? LastSyncedAt { get; set; }
public DateTimeOffset? LastSyncAttemptedAt { get; set; }
public DateTimeOffset? LastSyncSucceededAt { get; set; }
public string? LastSyncMode { get; set; }
public string? LastSyncSource { get; set; }
public string? LastSyncStatus { get; set; }
public string? LastSyncError { get; set; }
}
+12
View File
@@ -0,0 +1,12 @@
namespace JobTrackerApi.Models;
public sealed class GmailReviewDecision
{
public int Id { get; set; }
public string OwnerUserId { get; set; } = "";
public string ThreadId { get; set; } = "";
public int? JobApplicationId { get; set; }
public string Decision { get; set; } = "review"; // review, linked, rejected, suggested
public string? Note { get; set; }
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}
+76
View File
@@ -0,0 +1,76 @@
# Smart Gmail Job Correspondence Integration Progress
## Branch
- main
## Status
- Core Phase 1 Gmail correspondence feature is now implemented in code.
- Remaining gap is deployment/runtime rollout on the live host, not missing product logic in this repo.
## Completed
### Foundation
- Gmail OAuth connect/disconnect/status flow preserved.
- Durable Gmail sync-state fields added and surfaced from `GET /api/gmail/status`.
- Per-job correspondence UI shows Gmail sync diagnostics.
### Ingestion and storage
- Imported Gmail correspondence stores:
- direction
- Gmail labels JSON
- attachment metadata JSON
- Gmail payload parsing extracts labels and attachment metadata.
- Message-level deduplication remains in place.
- Linked-thread refresh continues to import only new thread messages.
### Matching and routing
- Deterministic scoring extracted to `JobTrackerApi/Services/GmailJobMatchingService.cs`.
- Review queue backend exists at `GET /api/gmail/review-candidates`.
- Review decisions persist through `POST /api/gmail/review-decision`.
- Manual sync now exists at `POST /api/gmail/manual-sync`.
- Manual sync applies a bounded historical window and excludes spam/trash by default.
- High-confidence matches now auto-link during manual sync.
- Medium-confidence matches remain in review.
- Low-confidence job-like threads can be marked as suggested jobs.
- Suggested-job surfaces now exist via:
- `GET /api/gmail/suggested-jobs`
- `POST /api/gmail/create-suggested-job`
### Correspondence UX
- Global inbox exists at `/correspondence`.
- Gmail review page exists at `/correspondence/review`.
- Review page now supports:
- manual sync
- routing filters
- review notes
- link/review/reject/suggested actions
- create-job flow from suggested Gmail threads
- Per-job correspondence workspace now supports:
- linked-thread refresh
- unlink thread from current job
- move/relink thread to another existing job
- Backend relink/unlink endpoints now exist:
- `POST /api/gmail/relink-thread`
- `POST /api/gmail/unlink-thread`
### Phase 2 prep
- Future seam remains in place at `JobTrackerApi/Services/GmailCorrespondenceEnrichment.cs`.
- Design doc remains in place at `docs/gmail-correspondence-phase1.md`.
### Deployment hardening
- Added deploy smoke-check logic to `deploy/deploy.sh`.
- Deploy now fails if `${APP_PUBLIC_BASE_URL}/api/auth/config` returns HTML or non-JSON instead of backend auth config JSON.
## Verification completed
- `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter GmailControllerTests /p:DisableSourceControlManagerQueries=true`
- `cd job-tracker-ui && CI=true ./node_modules/.bin/react-scripts test --runInBand --watch=false src/correspondence-gmail-import.test.tsx src/gmail-review-page.test.tsx src/correspondence-inbox-page.test.tsx`
- `dotnet build './Job tracker.sln' -c Release`
## Runtime note
- Live host check shows `https://jobs.cesnimda.uk/api/auth/config` currently returns the frontend HTML shell (`x-powered-by: Express`) instead of backend JSON.
- That is a deployment/proxy mismatch outside the app code in this checkout.
- The new deploy smoke-check was added so future deploys fail fast on that condition.
## Resume notes
- If the live site still shows 404s for `/api/...`, the running service is not the repos Dockerized frontend+backend path.
- The CRA/Express-style live response and websocket attempts to `:3000/ws` suggest an old dev-style frontend process or wrong reverse-proxy target is still serving the domain.
+38 -1
View File
@@ -62,11 +62,48 @@ fi
ai_status="$(compose ps ai-service --format '{{.State}}' 2>/dev/null | head -n 1 | tr '[:upper:]' '[:lower:]')"
if [ "$ai_status" != "running" ]; then
echo "AI service is not healthy after deploy (state: ${ai_status:-unknown})."
echo "AI service is not healthy after deploy (state: ${ai_status:-unknown}). Continuing because AI is not a deploy gate for the core app."
compose logs --tail=200 ai-service || true
fi
if [ -n "${APP_PUBLIC_BASE_URL:-}" ]; then
public_base="${APP_PUBLIC_BASE_URL%/}"
auth_config_body_file="$(mktemp)"
auth_config_headers_file="$(mktemp)"
cleanup_public_check() {
rm -f "$auth_config_body_file" "$auth_config_headers_file"
}
trap cleanup_public_check EXIT
echo "Running public smoke check against ${public_base}"
if ! curl -fsS "${public_base}/" >/dev/null; then
echo "Public frontend check failed for ${public_base}/"
exit 1
fi
if ! curl -fsS -D "$auth_config_headers_file" -o "$auth_config_body_file" "${public_base}/api/auth/config"; then
echo "Public API smoke check failed for ${public_base}/api/auth/config"
exit 1
fi
content_type="$(awk 'BEGIN{IGNORECASE=1} /^content-type:/ {print $2}' "$auth_config_headers_file" | tr -d '\r' | tail -n 1)"
if [[ "$content_type" != application/json* ]]; then
echo "Public API smoke check returned unexpected content type: ${content_type:-missing}"
echo "First bytes of response:"
head -c 200 "$auth_config_body_file" || true
exit 1
fi
if ! grep -q 'requireAuth' "$auth_config_body_file"; then
echo "Public API smoke check returned JSON without requireAuth."
cat "$auth_config_body_file"
exit 1
fi
trap - EXIT
cleanup_public_check
fi
# Clean up old legacy container name if it still exists from pre-rename deployments.
docker rm -f app-summarizer-1 2>/dev/null || true
+55
View File
@@ -0,0 +1,55 @@
# Smart Gmail Job Correspondence Integration
## Phase split
### Phase 1
Deterministic, high-trust Gmail job correspondence integration:
- OAuth/account connection
- token refresh lifecycle
- sync-state tracking
- manual sync/backfill
- dedup by Gmail message id
- deterministic job-linking + confidence routing
- review queue for medium-confidence items
- unmatched thread suggestions
- global inbox + per-job timeline
### Phase 2
Prepared, not deeply implemented in this slice:
- semantic Gmail-to-job disambiguation
- richer recruiter/company/role extraction
- stage/status hinting
- interview/rejection/offer extraction
- follow-up/reply suggestion generation
## Foundation decisions
- Phase 1 remains useful without AI/Ollama.
- Deterministic evidence remains the primary truth source.
- Future AI enrichment attaches reasons/confidence alongside deterministic evidence rather than replacing it.
- Gmail sync state is now durable on the Gmail connection record:
- `LastSyncAttemptedAt`
- `LastSyncSucceededAt`
- `LastSyncMode`
- `LastSyncSource`
- `LastSyncStatus`
- `LastSyncError`
## Current code seams
- Gmail OAuth and token lifecycle: `JobTrackerApi/Services/GmailOAuthService.cs`
- Gmail endpoints: `JobTrackerApi/Controllers/GmailController.cs`
- Gmail connection persistence: `Models/GmailConnection.cs`
- Correspondence persistence: `Models/Correspondence.cs`
- Per-job correspondence UX: `job-tracker-ui/src/components/Correspondence.tsx`
- Future Phase 2 AI seam: `JobTrackerApi/Services/GmailCorrespondenceEnrichment.cs`
## What Phase 2 should plug into later
The `IGmailCorrespondenceEnrichmentService` seam is intended to accept normalized Gmail message/thread context and return optional semantic hints:
- probable job match
- richer confidence rationale
- extracted recruiter/company/role entities
- lightweight stage hints
Phase 1 should never require this service to return anything useful. The default runtime implementation remains a no-op.
+11
View File
@@ -14,6 +14,7 @@ import AlarmIcon from "@mui/icons-material/Alarm";
import AccountCircleIcon from "@mui/icons-material/AccountCircle";
import ShieldIcon from "@mui/icons-material/Shield";
import SearchIcon from "@mui/icons-material/Search";
import MailOutlineIcon from "@mui/icons-material/MailOutline";
import MemoryIcon from "@mui/icons-material/Memory";
import { Navigate, Route, Routes, useLocation, useNavigate, createBrowserRouter, RouterProvider } from "react-router-dom";
@@ -46,6 +47,8 @@ const ProfilePage = lazy(() => import("./pages/ProfilePage"));
const AdminAuditPage = lazy(() => import("./pages/AdminAuditPage"));
const AdminUsersPage = lazy(() => import("./pages/AdminUsersPage"));
const AdminSystemPage = lazy(() => import("./pages/AdminSystemPage"));
const CorrespondenceInboxPage = lazy(() => import("./pages/CorrespondenceInboxPage"));
const GmailReviewPage = lazy(() => import("./pages/GmailReviewPage"));
const NotFoundPage = lazy(() => import("./pages/NotFoundPage"));
type AuthConfig = { requireAuth: boolean };
@@ -67,6 +70,8 @@ function breadcrumbsFor(path: string, t: (k: any) => string): string[] {
if (path.startsWith("/reminders")) return [t("home"), t("reminders")];
if (path.startsWith("/kanban")) return [t("home"), t("kanbanBoard")];
if (path.startsWith("/companies")) return [t("home"), t("companies")];
if (path.startsWith("/correspondence/review")) return [t("home"), "Gmail review queue"];
if (path.startsWith("/correspondence")) return [t("home"), "Correspondence inbox"];
if (path.startsWith("/trash")) return [t("home"), t("trash")];
if (path.startsWith("/settings")) return [t("home"), t("settings")];
if (path.startsWith("/profile")) return [t("home"), t("account"), t("profile")];
@@ -82,6 +87,8 @@ function titleFor(path: string, t: (k: any) => string): string {
if (path.startsWith("/jobs")) return t("jobApplications");
if (path.startsWith("/kanban")) return t("kanbanBoard");
if (path.startsWith("/companies")) return t("companies");
if (path.startsWith("/correspondence/review")) return "Gmail review queue";
if (path.startsWith("/correspondence")) return "Correspondence inbox";
if (path.startsWith("/trash")) return t("trash");
if (path.startsWith("/settings")) return t("settings");
if (path.startsWith("/profile")) return t("profile");
@@ -154,6 +161,8 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
{ to: "/reminders", label: t("reminders"), icon: <AlarmIcon fontSize="small" />, badgeCount: notifCount, section: t("manage") },
{ to: "/kanban", label: t("kanbanBoard"), icon: <ViewKanbanIcon fontSize="small" />, section: t("manage") },
{ to: "/companies", label: t("companies"), icon: <BusinessIcon fontSize="small" />, section: t("manage") },
{ to: "/correspondence", label: "Correspondence", icon: <MailOutlineIcon fontSize="small" />, section: t("manage") },
{ to: "/correspondence/review", label: "Gmail review", icon: <MailOutlineIcon fontSize="small" />, section: t("manage") },
{ to: "/trash", label: t("trash"), icon: <DeleteOutlineIcon fontSize="small" />, section: t("manage") },
];
@@ -225,6 +234,8 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
<Route path="/reminders" element={<RemindersView />} />
<Route path="/kanban" element={<KanbanBoard />} />
<Route path="/companies" element={<CompaniesTable />} />
<Route path="/correspondence" element={<CorrespondenceInboxPage />} />
<Route path="/correspondence/review" element={<GmailReviewPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/admin/audit" element={<AdminAuditPage />} />
<Route path="/admin/users" element={<AdminUsersPage />} />
@@ -10,10 +10,14 @@ import {
DialogContent,
DialogTitle,
Divider,
FormControl,
InputLabel,
List,
ListItemButton,
ListItemText,
MenuItem,
Paper,
Select,
Tab,
Tabs,
TextField,
@@ -35,8 +39,10 @@ import {
GmailImportMessageResult,
GmailImportThreadResult,
GmailJobMatchesResponse,
GmailRelinkResult,
GmailStatus,
GmailThreadRefreshResult,
GmailUnlinkResult,
JobApplication,
} from "../types";
import { useDialogActions } from "../dialogs";
@@ -97,6 +103,10 @@ function formatReasonLabel(label: string) {
}
}
interface PagedResult<T> {
items: T[];
}
export default function Correspondence({ jobId, job }: { jobId: number; job: JobApplication | null }) {
const theme = useTheme();
const { toast } = useToast();
@@ -120,6 +130,11 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
const [linkedThreadRefreshLoading, setLinkedThreadRefreshLoading] = useState(false);
const [importingMessageId, setImportingMessageId] = useState<string | null>(null);
const [importingThreadId, setImportingThreadId] = useState<string | null>(null);
const [availableJobs, setAvailableJobs] = useState<JobApplication[]>([]);
const [manageThreadId, setManageThreadId] = useState<string | null>(null);
const [manageTargetJobId, setManageTargetJobId] = useState<number>(jobId);
const [manageNote, setManageNote] = useState("");
const [manageSaving, setManageSaving] = useState(false);
const autoRefreshKeyRef = useRef<string | null>(null);
const load = useCallback(async () => {
@@ -157,6 +172,15 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
}
}, [jobId, toast]);
const loadAvailableJobs = useCallback(async () => {
try {
const res = await api.get<PagedResult<JobApplication>>("/jobapplications", { params: { page: 1, pageSize: 100, sortBy: "dateApplied", sortDir: "desc" } });
setAvailableJobs((res.data?.items ?? []).filter((item) => item.id !== jobId));
} catch {
setAvailableJobs([]);
}
}, [jobId]);
const linkedThreadIds = useMemo(
() => Array.from(new Set(messages.map((message) => message.externalThreadId).filter(Boolean) as string[])).sort(),
[messages],
@@ -210,7 +234,8 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
useEffect(() => {
void loadGmailStatus();
}, [loadGmailStatus]);
void loadAvailableJobs();
}, [loadAvailableJobs, loadGmailStatus]);
useEffect(() => {
if (!gmailStatus?.connected || linkedThreadIds.length === 0) {
@@ -367,6 +392,55 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
}
};
const openManageThread = (threadId: string) => {
setManageThreadId(threadId);
setManageTargetJobId(jobId);
setManageNote("");
};
const unlinkThread = async () => {
if (!manageThreadId) return;
setManageSaving(true);
try {
const res = await api.post<GmailUnlinkResult>("/gmail/unlink-thread", {
jobApplicationId: jobId,
threadId: manageThreadId,
note: manageNote.trim() || null,
nextDecision: "review",
});
await load();
await loadGmailMatches(gmailQuery);
setManageThreadId(null);
toast(`Unlinked ${res.data.removedMessages} message${res.data.removedMessages === 1 ? "" : "s"} from this job.`, "success");
} catch (error: any) {
toast(getApiErrorMessage(error, "Failed to unlink the Gmail thread."), "error");
} finally {
setManageSaving(false);
}
};
const relinkThread = async () => {
if (!manageThreadId || manageTargetJobId <= 0 || manageTargetJobId === jobId) return;
setManageSaving(true);
try {
const res = await api.post<GmailRelinkResult>("/gmail/relink-thread", {
jobApplicationId: manageTargetJobId,
threadId: manageThreadId,
removeFromOtherJobs: true,
note: manageNote.trim() || null,
});
await load();
await loadGmailMatches(gmailQuery);
setManageThreadId(null);
const targetJob = availableJobs.find((item) => item.id === manageTargetJobId);
toast(`Moved thread to ${targetJob?.company?.name || targetJob?.jobTitle || `job ${res.data.jobApplicationId}`}.`, "success");
} catch (error: any) {
toast(getApiErrorMessage(error, "Failed to move the Gmail thread."), "error");
} finally {
setManageSaving(false);
}
};
return (
<Box>
<Paper ref={scrollRef} sx={{ p: 1.5, maxHeight: 360, overflowY: "auto", background: theme.palette.mode === "dark" ? "rgba(15,23,42,0.45)" : "rgba(255,255,255,0.75)", backdropFilter: "blur(8px)" }}>
@@ -382,11 +456,13 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
<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}
<Typography sx={{ whiteSpace: "pre-wrap", lineHeight: 1.35 }}>{m.content}</Typography>
{(m.externalThreadId || m.externalFrom || m.externalTo) ? (
{(m.externalThreadId || m.externalFrom || m.externalTo || m.externalLabelsJson || m.attachmentMetadataJson) ? (
<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}
{m.externalLabelsJson ? <Chip size="small" label={`${JSON.parse(m.externalLabelsJson).length} Gmail label${JSON.parse(m.externalLabelsJson).length === 1 ? "" : "s"}`} variant="outlined" /> : null}
{m.attachmentMetadataJson ? <Chip size="small" label={`${JSON.parse(m.attachmentMetadataJson).length} attachment${JSON.parse(m.attachmentMetadataJson).length === 1 ? "" : "s"}`} variant="outlined" /> : null}
</Box>
) : null}
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "flex-end", mt: 0.75 }}>
@@ -413,6 +489,21 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Chip size="small" color={gmailStatus?.connected ? "success" : "default"} variant="outlined" label={gmailStatus?.connected ? "Gmail connected" : "Gmail not connected"} />
<Chip size="small" color={linkedThreadIds.length > 0 ? "success" : "default"} variant="outlined" label={linkedThreadIds.length > 0 ? `Linked threads: ${linkedThreadIds.length}` : "No linked threads yet"} />
{linkedThreadIds.slice(0, 6).map((threadId) => (
<Button key={threadId} size="small" variant="text" onClick={() => openManageThread(threadId)}>
Manage {threadId}
</Button>
))}
{gmailStatus?.lastSyncStatus ? (
<Chip
size="small"
color={gmailStatus.lastSyncStatus === "success" ? "success" : "warning"}
variant="outlined"
label={gmailStatus.lastSyncStatus === "success"
? `Last Gmail sync ${gmailStatus.lastSyncMode || "sync"} ok`
: `Last Gmail sync ${gmailStatus.lastSyncMode || "sync"} failed`}
/>
) : null}
{linkedThreadRefresh ? (
<Chip
size="small"
@@ -425,6 +516,11 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
/>
) : null}
</Box>
{gmailStatus?.lastSyncError ? (
<Typography variant="body2" sx={{ color: "warning.main", mt: 1 }}>
Latest Gmail sync issue: {gmailStatus.lastSyncError}
</Typography>
) : null}
</Box>
<Box sx={{ display: "flex", gap: 1, alignItems: "flex-start", mt: 1.5, flexWrap: "wrap" }}>
@@ -440,6 +536,44 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
<Button variant="contained" onClick={send} disabled={!canSend}>{t("correspondenceAdd")}</Button>
</Box>
<Dialog open={Boolean(manageThreadId)} onClose={() => setManageThreadId(null)} fullWidth maxWidth="sm">
<DialogTitle>Manage linked Gmail thread</DialogTitle>
<DialogContent sx={{ display: "flex", flexDirection: "column", gap: 2, pt: 1 }}>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
Unlink this thread from the current job, or move it to another existing job.
</Typography>
{manageThreadId ? <Chip label={`Thread ${manageThreadId}`} variant="outlined" sx={{ width: "fit-content" }} /> : null}
<TextField
label="Review note"
value={manageNote}
onChange={(event) => setManageNote(event.target.value)}
multiline
minRows={2}
placeholder="Why this thread should stay in review or move to another job."
/>
<FormControl fullWidth>
<InputLabel>Move to job</InputLabel>
<Select
value={String(manageTargetJobId)}
label="Move to job"
onChange={(event) => setManageTargetJobId(Number(event.target.value))}
>
<MenuItem value={String(jobId)}>Keep on current job</MenuItem>
{availableJobs.map((item) => (
<MenuItem key={item.id} value={String(item.id)}>
{item.company?.name || "Unknown company"} {item.jobTitle}
</MenuItem>
))}
</Select>
</FormControl>
</DialogContent>
<DialogActions>
<Button onClick={() => setManageThreadId(null)} disabled={manageSaving}>Close</Button>
<Button color="warning" variant="outlined" onClick={() => void unlinkThread()} disabled={manageSaving || !manageThreadId}>Unlink from this job</Button>
<Button variant="contained" onClick={() => void relinkThread()} disabled={manageSaving || !manageThreadId || manageTargetJobId === jobId}>Move thread</Button>
</DialogActions>
</Dialog>
<Dialog open={importOpen} onClose={() => setImportOpen(false)} fullWidth maxWidth="md">
<DialogTitle>{t("correspondenceImportTitle")}</DialogTitle>
<DialogContent>
@@ -503,6 +637,8 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
</Box>
) : null}
{gmailStatus.lastSyncedAt ? <Chip label={t("correspondenceLastSynced", { date: new Date(gmailStatus.lastSyncedAt).toLocaleString() })} size="small" /> : null}
{gmailStatus.lastSyncAttemptedAt ? <Chip label={`Sync checked ${new Date(gmailStatus.lastSyncAttemptedAt).toLocaleString()}`} size="small" variant="outlined" /> : null}
{gmailStatus.lastSyncStatus === "error" && gmailStatus.lastSyncError ? <Chip label={`Sync issue: ${gmailStatus.lastSyncError}`} size="small" color="warning" variant="outlined" /> : null}
{linkedThreadIds.length > 0 ? (
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Chip size="small" color="success" variant="outlined" label={`Linked threads: ${linkedThreadIds.length}`} />
@@ -51,6 +51,30 @@ describe("correspondence Gmail import", () => {
correspondenceMessages = [];
mockedApi.get.mockImplementation((url: string, config?: any) => {
if (url === "/jobapplications") {
return Promise.resolve({
data: {
items: [
{
id: 42,
jobTitle: "Backend Developer",
status: "Applied",
dateApplied: new Date().toISOString(),
daysSince: 3,
company: { name: "Acme", recruiterEmail: "maria@acme.test", recruiterName: "Maria Recruiter" },
},
{
id: 77,
jobTitle: "Platform Engineer",
status: "Applied",
dateApplied: new Date().toISOString(),
daysSince: 1,
company: { name: "Beta" },
},
],
},
} as any);
}
if (url === "/jobapplications/42") {
return Promise.resolve({
data: {
@@ -78,7 +102,7 @@ describe("correspondence Gmail import", () => {
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);
return Promise.resolve({ data: { connected: true, gmailAddress: "user@example.test", lastSyncedAt: new Date().toISOString(), lastSyncAttemptedAt: new Date().toISOString(), lastSyncMode: "list-messages", lastSyncSource: "custom-query", lastSyncStatus: "error", lastSyncError: "Token refresh failed" } } as any);
}
if (url === "/gmail/job-candidates") {
return Promise.resolve({
@@ -142,6 +166,15 @@ describe("correspondence Gmail import", () => {
});
mockedApi.post.mockImplementation((url: string, body?: any) => {
if (url === "/gmail/relink-thread") {
correspondenceMessages = [];
return Promise.resolve({ data: { threadId: body.threadId, jobApplicationId: body.jobApplicationId, imported: 1, skipped: 0, unlinkedMessages: 1 } } as any);
}
if (url === "/gmail/unlink-thread") {
const removed = correspondenceMessages.filter((message) => message.externalThreadId === body.threadId).length;
correspondenceMessages = correspondenceMessages.filter((message) => message.externalThreadId !== body.threadId);
return Promise.resolve({ data: { threadId: body.threadId, jobApplicationId: body.jobApplicationId, removedMessages: removed, decision: body.nextDecision || 'review' } } as any);
}
if (url === "/gmail/refresh-linked-threads") {
const hasReply = correspondenceMessages.some((message) => message.externalMessageId === "msg-2");
if (!hasReply && correspondenceMessages.some((message) => message.externalThreadId === "thread-1")) {
@@ -291,6 +324,82 @@ describe("correspondence Gmail import", () => {
expect((await screen.findAllByText(/thread thread-1/i)).length).toBeGreaterThan(0);
});
test("lets the user unlink a linked Gmail thread", async () => {
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: "msg-1",
externalThreadId: "thread-1",
externalFrom: "Maria Recruiter <maria@acme.test>",
externalTo: "user@example.test",
},
];
renderDialog();
fireEvent.click(await screen.findByRole("button", { name: /manage thread-1/i }));
fireEvent.click(await screen.findByRole("button", { name: /unlink from this job/i }));
await waitFor(() => {
expect(mockedApi.post).toHaveBeenCalledWith("/gmail/unlink-thread", expect.objectContaining({
jobApplicationId: 42,
threadId: "thread-1",
nextDecision: "review",
}));
});
expect(await screen.findByText(/no messages yet/i)).toBeInTheDocument();
});
test("lets the user move a linked Gmail thread to another job", async () => {
correspondenceMessages = [
{
id: 702,
jobApplicationId: 42,
from: "Company",
content: "Second import.",
subject: "Backend Developer interview",
channel: "Email",
date: new Date().toISOString(),
externalMessageId: "msg-1",
externalThreadId: "thread-1",
externalFrom: "Maria Recruiter <maria@acme.test>",
externalTo: "user@example.test",
},
];
renderDialog();
fireEvent.click(await screen.findByRole("button", { name: /manage thread-1/i }));
fireEvent.mouseDown((await screen.findAllByRole("combobox")).slice(-1)[0]);
fireEvent.click(await screen.findByRole("option", { name: /beta • platform engineer/i }));
fireEvent.click(screen.getByRole("button", { name: /move thread/i }));
await waitFor(() => {
expect(mockedApi.post).toHaveBeenCalledWith("/gmail/relink-thread", expect.objectContaining({
jobApplicationId: 77,
threadId: "thread-1",
removeFromOtherJobs: true,
}));
});
});
test("shows Gmail sync state diagnostics alongside linked thread continuity", async () => {
renderDialog();
fireEvent.click(await screen.findByRole("button", { name: /import email/i }));
fireEvent.click(await screen.findByRole("tab", { name: /^google$/i }));
expect(await screen.findByText(/sync checked/i)).toBeInTheDocument();
expect((await screen.findAllByText(/token refresh failed/i)).length).toBeGreaterThan(0);
});
test("manual Gmail search override reloads job candidates with queryOverride", async () => {
renderDialog();
@@ -0,0 +1,88 @@
import React from 'react';
import '@testing-library/jest-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from './toast';
import { I18nProvider } from './i18n/I18nProvider';
import CorrespondenceInboxPage from './pages/CorrespondenceInboxPage';
import { api } from './api';
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() } },
},
getApiErrorMessage: (error: any, fallback?: string) => fallback || 'Request failed.',
}));
const mockedApi = api as jest.Mocked<typeof api>;
function renderPage() {
return render(
<ToastProvider>
<I18nProvider>
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<CorrespondenceInboxPage />
</MemoryRouter>
</I18nProvider>
</ToastProvider>,
);
}
describe('CorrespondenceInboxPage', () => {
beforeEach(() => {
mockedApi.get.mockResolvedValue({
data: [
{
id: 1,
jobApplicationId: 42,
companyName: 'Acme Systems',
jobTitle: 'Backend Engineer',
from: 'Company',
direction: 'inbound',
subject: 'Interview invite',
channel: 'Email',
date: new Date().toISOString(),
contentPreview: 'We would like to schedule an interview.',
externalThreadId: 'thread-1',
externalFrom: 'Maria Recruiter <maria@acme.test>',
externalTo: 'user@example.test',
labelCount: 2,
attachmentCount: 1,
},
],
} as any);
});
afterEach(() => {
jest.clearAllMocks();
});
test('renders correspondence inbox items and reloads with filters', async () => {
renderPage();
expect(await screen.findByText(/correspondence inbox/i)).toBeInTheDocument();
expect(await screen.findByText(/1 items/i)).toBeInTheDocument();
expect(await screen.findByText(/acme systems/i)).toBeInTheDocument();
expect(await screen.findByText(/backend engineer/i)).toBeInTheDocument();
expect(screen.getByText(/2 labels/i)).toBeInTheDocument();
expect(screen.getByText(/1 attachments/i)).toBeInTheDocument();
fireEvent.change(screen.getByLabelText(/search/i), { target: { value: 'Maria' } });
fireEvent.mouseDown(screen.getAllByRole('combobox')[0]);
fireEvent.click((await screen.findAllByRole('option', { name: /Inbound/i }))[0]);
await waitFor(() => {
expect(mockedApi.get).toHaveBeenLastCalledWith('/correspondence', expect.objectContaining({
params: expect.objectContaining({
q: 'Maria',
direction: 'inbound',
}),
}));
});
});
});
@@ -0,0 +1,105 @@
import React from 'react';
import '@testing-library/jest-dom';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from './toast';
import { I18nProvider } from './i18n/I18nProvider';
import GmailReviewPage from './pages/GmailReviewPage';
import { api } from './api';
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() } },
},
getApiErrorMessage: (error: any, fallback?: string) => fallback || 'Request failed.',
}));
const mockedApi = api as jest.Mocked<typeof api>;
function renderPage() {
return render(
<ToastProvider>
<I18nProvider>
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<GmailReviewPage />
</MemoryRouter>
</I18nProvider>
</ToastProvider>,
);
}
describe('GmailReviewPage', () => {
beforeEach(() => {
mockedApi.get.mockImplementation((url: string) => {
if (url === '/gmail/review-candidates') {
return Promise.resolve({
data: {
queries: ['"Acme" "Backend Developer" newer_than:365d'],
candidateThreadCount: 2,
autoLinkThreadCount: 1,
reviewThreadCount: 1,
unmatchedThreadCount: 0,
threads: [
{
threadId: 'thread-1',
subject: 'Backend Developer interview',
latestDate: new Date().toISOString(),
messageCount: 2,
routing: 'review',
hasImportedMessages: false,
matchedQueries: ['"Acme" "Backend Developer" newer_than:365d'],
jobCandidates: [
{ jobApplicationId: 42, jobTitle: 'Backend Developer', companyName: 'Acme', score: 24, confidence: 'medium', reasons: [{ label: 'company', value: 'Acme', points: 18 }] },
],
messages: [],
},
],
},
} as any);
}
if (url === '/gmail/suggested-jobs') {
return Promise.resolve({ data: { count: 0, items: [] } } as any);
}
return Promise.resolve({ data: {} } as any);
});
});
afterEach(() => {
jest.clearAllMocks();
});
test('renders Gmail review queue summary and candidate threads', async () => {
renderPage();
expect(await screen.findByText(/gmail review queue/i)).toBeInTheDocument();
expect(await screen.findByText(/2 candidate threads/i)).toBeInTheDocument();
expect(screen.getByText(/backend developer interview/i)).toBeInTheDocument();
expect(screen.getByText(/acme • backend developer \(24\)/i)).toBeInTheDocument();
await waitFor(() => {
expect(mockedApi.get).toHaveBeenCalledWith('/gmail/review-candidates');
});
});
test('persists a review decision for the top job', async () => {
renderPage();
fireEvent.click(await screen.findByRole('button', { name: /link top job/i }));
await waitFor(() => {
expect(mockedApi.post).toHaveBeenCalledWith('/gmail/review-decision', {
threadId: 'thread-1',
decision: 'linked',
jobApplicationId: 42,
note: null,
});
});
});
});
@@ -0,0 +1,144 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Box,
Chip,
CircularProgress,
FormControl,
InputLabel,
MenuItem,
Paper,
Select,
Stack,
TextField,
Typography,
Button,
} from "@mui/material";
import MailOutlineIcon from "@mui/icons-material/MailOutline";
import { api, getApiErrorMessage } from "../api";
import { useToast } from "../toast";
export type CorrespondenceInboxItem = {
id: number;
jobApplicationId: number;
companyName?: string | null;
jobTitle?: string | null;
from: string;
direction?: string | null;
subject?: string | null;
channel?: string | null;
date: string;
contentPreview: string;
externalThreadId?: string | null;
externalFrom?: string | null;
externalTo?: string | null;
labelCount: number;
attachmentCount: number;
};
export default function CorrespondenceInboxPage() {
const navigate = useNavigate();
const { toast } = useToast();
const [items, setItems] = useState<CorrespondenceInboxItem[]>([]);
const [loading, setLoading] = useState(false);
const [query, setQuery] = useState("");
const [direction, setDirection] = useState<string>("all");
const [linkState, setLinkState] = useState<string>("all");
const load = useCallback(async () => {
setLoading(true);
try {
const res = await api.get<CorrespondenceInboxItem[]>("/correspondence", {
params: {
q: query.trim() || undefined,
direction: direction === "all" ? undefined : direction,
linkState: linkState === "all" ? undefined : linkState,
},
});
setItems(res.data ?? []);
} catch (error) {
toast(getApiErrorMessage(error, "Failed to load correspondence inbox."), "error");
} finally {
setLoading(false);
}
}, [direction, linkState, query, toast]);
useEffect(() => {
void load();
}, [load]);
const filteredSummary = useMemo(() => {
const linked = items.filter((item) => item.externalThreadId).length;
const inbound = items.filter((item) => item.direction === "inbound").length;
return { linked, inbound };
}, [items]);
return (
<Paper sx={{ mt: 0, p: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap", mb: 2 }}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 900 }}>Correspondence inbox</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
Cross-job view of imported correspondence and Gmail-linked history.
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Chip icon={<MailOutlineIcon />} label={`${items.length} items`} variant="outlined" />
<Chip label={`${filteredSummary.linked} linked`} variant="outlined" color={filteredSummary.linked > 0 ? "success" : "default"} />
<Chip label={`${filteredSummary.inbound} inbound`} variant="outlined" />
</Box>
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "2fr 1fr 1fr auto" }, gap: 1.25, mb: 2 }}>
<TextField label="Search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Company, role, recruiter, subject" />
<FormControl fullWidth>
<InputLabel>Direction</InputLabel>
<Select value={direction} label="Direction" onChange={(e) => setDirection(String(e.target.value))}>
<MenuItem value="all">All</MenuItem>
<MenuItem value="inbound">Inbound</MenuItem>
<MenuItem value="outbound">Outbound</MenuItem>
<MenuItem value="internal">Internal</MenuItem>
</Select>
</FormControl>
<FormControl fullWidth>
<InputLabel>Link state</InputLabel>
<Select value={linkState} label="Link state" onChange={(e) => setLinkState(String(e.target.value))}>
<MenuItem value="all">All</MenuItem>
<MenuItem value="linked">Linked threads</MenuItem>
<MenuItem value="manual">Manual/internal only</MenuItem>
</Select>
</FormControl>
<Button variant="contained" onClick={() => void load()} disabled={loading}>{loading ? "Loading..." : "Refresh"}</Button>
</Box>
{loading ? <Box sx={{ py: 6, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : null}
{!loading && items.length === 0 ? (
<Typography sx={{ color: "text.secondary", py: 4, textAlign: "center" }}>No correspondence matches the current filters.</Typography>
) : null}
<Stack spacing={1.25}>
{items.map((item) => (
<Paper key={item.id} variant="outlined" sx={{ p: 1.5, borderRadius: 3 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
<Box sx={{ minWidth: 0 }}>
<Typography sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{item.companyName || "Unknown company"} {item.jobTitle || "Unknown role"}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{item.subject || item.contentPreview}</Typography>
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 0.5 }}>
{item.externalFrom || item.from} {item.externalTo ? `${item.externalTo}` : ""} · {new Date(item.date).toLocaleString()}
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", justifyContent: "flex-end" }}>
{item.direction ? <Chip size="small" label={item.direction} variant="outlined" /> : null}
{item.externalThreadId ? <Chip size="small" label={`Thread ${item.externalThreadId}`} color="success" variant="outlined" /> : <Chip size="small" label="Manual/internal" variant="outlined" />}
{item.labelCount > 0 ? <Chip size="small" label={`${item.labelCount} labels`} variant="outlined" /> : null}
{item.attachmentCount > 0 ? <Chip size="small" label={`${item.attachmentCount} attachments`} variant="outlined" /> : null}
<Button size="small" variant="text" onClick={() => navigate(`/jobs?open=${item.jobApplicationId}`)}>Open job</Button>
</Box>
</Box>
</Paper>
))}
</Stack>
</Paper>
);
}
@@ -0,0 +1,269 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Box, Button, Chip, CircularProgress, Paper, Stack, TextField, Typography } from "@mui/material";
import { api, getApiErrorMessage } from "../api";
import { CreatedSuggestedGmailJobResult, GmailManualSyncResult, GmailReviewQueueResponse, GmailSuggestedJobsResponse } from "../types";
import { useToast } from "../toast";
import { useNavigate } from "react-router-dom";
export default function GmailReviewPage() {
const { toast } = useToast();
const navigate = useNavigate();
const [data, setData] = useState<GmailReviewQueueResponse | null>(null);
const [suggestions, setSuggestions] = useState<GmailSuggestedJobsResponse | null>(null);
const [loading, setLoading] = useState(false);
const [syncing, setSyncing] = useState(false);
const [savingThreadId, setSavingThreadId] = useState<string | null>(null);
const [creatingThreadId, setCreatingThreadId] = useState<string | null>(null);
const [routingFilter, setRoutingFilter] = useState<"all" | "auto-link" | "review" | "unmatched" | "suggested" | "linked" | "rejected">("all");
const [notes, setNotes] = useState<Record<string, string>>({});
const load = useCallback(async () => {
setLoading(true);
try {
const [reviewRes, suggestedRes] = await Promise.all([
api.get<GmailReviewQueueResponse>("/gmail/review-candidates"),
api.get<GmailSuggestedJobsResponse>("/gmail/suggested-jobs"),
]);
setData(reviewRes.data);
setSuggestions(suggestedRes.data);
setNotes((prev) => {
const next = { ...prev };
for (const thread of reviewRes.data.threads) {
if (next[thread.threadId] === undefined) next[thread.threadId] = thread.decisionNote || "";
}
return next;
});
} catch (error) {
toast(getApiErrorMessage(error, "Failed to load Gmail review candidates."), "error");
} finally {
setLoading(false);
}
}, [toast]);
useEffect(() => {
void load();
}, [load]);
const saveDecision = useCallback(async (threadId: string, decision: "linked" | "rejected" | "review" | "suggested", jobApplicationId?: number) => {
setSavingThreadId(threadId);
try {
await api.post("/gmail/review-decision", {
threadId,
decision,
jobApplicationId: decision === "linked" ? jobApplicationId ?? null : null,
note: notes[threadId]?.trim() || null,
});
await load();
toast(
decision === "linked"
? "Thread linked and imported."
: decision === "rejected"
? "Thread rejected from review."
: decision === "suggested"
? "Thread kept as suggested job material."
: "Thread returned to review.",
"success",
);
} catch (error) {
toast(getApiErrorMessage(error, "Failed to save Gmail review decision."), "error");
} finally {
setSavingThreadId(null);
}
}, [load, notes, toast]);
const runManualSync = useCallback(async () => {
setSyncing(true);
try {
const res = await api.post<GmailManualSyncResult>("/gmail/manual-sync", {
lookbackDays: 365,
maxResultsPerQuery: 8,
autoImportHighConfidence: true,
includeSpamTrash: false,
});
await load();
toast(
`Manual Gmail sync finished: ${res.data.importedThreads} threads linked, ${res.data.reviewThreadCount} review, ${res.data.unmatchedThreadCount} unmatched.`,
"success",
);
} catch (error) {
toast(getApiErrorMessage(error, "Failed to run Gmail manual sync."), "error");
} finally {
setSyncing(false);
}
}, [load, toast]);
const createSuggestedJob = useCallback(async (threadId: string) => {
const suggestion = suggestions?.items.find((item) => item.threadId === threadId);
if (!suggestion) return;
setCreatingThreadId(threadId);
try {
const res = await api.post<CreatedSuggestedGmailJobResult>("/gmail/create-suggested-job", {
threadId,
companyName: suggestion.companyName || "Unknown company",
jobTitle: suggestion.suggestedJobTitle || suggestion.subject || "Suggested role",
recruiterName: suggestion.recruiterName || null,
recruiterEmail: suggestion.recruiterEmail || null,
notes: notes[threadId]?.trim() || suggestion.preview || null,
status: "Applied",
});
await load();
toast(`Created suggested job and imported ${res.data.imported} message${res.data.imported === 1 ? "" : "s"}.`, "success");
navigate(`/jobs?open=${res.data.jobApplicationId}`);
} catch (error) {
toast(getApiErrorMessage(error, "Failed to create the suggested job."), "error");
} finally {
setCreatingThreadId(null);
}
}, [load, navigate, notes, suggestions?.items, toast]);
const filteredThreads = useMemo(() => {
const threads = data?.threads ?? [];
return routingFilter === "all" ? threads : threads.filter((thread) => thread.routing === routingFilter);
}, [data?.threads, routingFilter]);
return (
<Paper sx={{ mt: 0, p: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap", mb: 2 }}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 900 }}>Gmail review queue</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
Manual sync, high-confidence auto-linking, medium-confidence review, and suggested jobs from unmatched Gmail threads.
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Button variant="contained" onClick={() => void runManualSync()} disabled={syncing}>
{syncing ? "Syncing..." : "Run manual sync"}
</Button>
<Button variant="outlined" onClick={() => void load()} disabled={loading || syncing}>
{loading ? "Loading..." : "Refresh"}
</Button>
</Box>
</Box>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 2 }}>
<Chip label={`Filter: ${routingFilter}`} variant="outlined" />
{(["all", "auto-link", "review", "unmatched", "suggested", "linked", "rejected"] as const).map((value) => (
<Button key={value} size="small" variant={routingFilter === value ? "contained" : "text"} onClick={() => setRoutingFilter(value)}>
{value}
</Button>
))}
</Box>
{data ? (
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 2 }}>
<Chip label={`${data.candidateThreadCount} candidate threads`} variant="outlined" />
<Chip label={`${data.autoLinkThreadCount} auto-link`} color="success" variant="outlined" />
<Chip label={`${data.reviewThreadCount} review`} color="warning" variant="outlined" />
<Chip label={`${data.unmatchedThreadCount} unmatched`} variant="outlined" />
{suggestions?.count ? <Chip label={`${suggestions.count} suggested jobs`} color="secondary" variant="outlined" /> : null}
</Box>
) : null}
{loading ? <Box sx={{ py: 6, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : null}
{!loading && data && filteredThreads.length === 0 ? <Typography sx={{ color: "text.secondary" }}>No Gmail review candidates match the current filter.</Typography> : null}
<Stack spacing={1.25}>
{filteredThreads.map((thread) => {
const suggestion = (suggestions?.items ?? []).find((item) => item.threadId === thread.threadId);
return (
<Paper key={thread.threadId} variant="outlined" sx={{ p: 1.5, borderRadius: 3 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
<Box sx={{ minWidth: 0, flex: "1 1 420px" }}>
<Typography sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{thread.subject}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{thread.messageCount} messages · {thread.routing}
</Typography>
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.75 }}>
{thread.matchedQueries.slice(0, 3).map((query) => (
<Chip key={query} size="small" label={query} variant="outlined" />
))}
{thread.hasImportedMessages ? <Chip size="small" label="Has imported messages" color="success" variant="outlined" /> : null}
</Box>
<TextField
label="Review notes"
value={notes[thread.threadId] ?? ""}
onChange={(event) => setNotes((prev) => ({ ...prev, [thread.threadId]: event.target.value }))}
size="small"
fullWidth
multiline
minRows={2}
sx={{ mt: 1.25 }}
placeholder="Why this should link, stay in review, or become a suggested job."
/>
{suggestion ? (
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>
Suggested job: {suggestion.companyName || "Unknown company"} · {suggestion.suggestedJobTitle || "Unknown role"}
</Typography>
) : null}
</Box>
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", justifyContent: "flex-end" }}>
{thread.jobCandidates.slice(0, 2).map((candidate) => (
<Chip
key={candidate.jobApplicationId}
label={`${candidate.companyName}${candidate.jobTitle} (${candidate.score})`}
variant="outlined"
color={candidate.confidence === "high" ? "success" : candidate.confidence === "medium" ? "warning" : "default"}
/>
))}
{thread.jobCandidates[0] ? (
<Button size="small" variant="text" onClick={() => navigate(`/jobs?open=${thread.jobCandidates[0].jobApplicationId}`)}>
Open top job
</Button>
) : null}
{thread.jobCandidates[0] ? (
<Button
size="small"
variant="contained"
disabled={savingThreadId === thread.threadId}
onClick={() => void saveDecision(thread.threadId, "linked", thread.jobCandidates[0].jobApplicationId)}
>
Link top job
</Button>
) : null}
<Button
size="small"
variant="outlined"
color="warning"
disabled={savingThreadId === thread.threadId}
onClick={() => void saveDecision(thread.threadId, "review")}
>
Keep in review
</Button>
<Button
size="small"
variant="outlined"
color="secondary"
disabled={savingThreadId === thread.threadId}
onClick={() => void saveDecision(thread.threadId, "suggested")}
>
Suggested job
</Button>
{suggestion ? (
<Button
size="small"
variant="outlined"
disabled={creatingThreadId === thread.threadId}
onClick={() => void createSuggestedJob(thread.threadId)}
>
{creatingThreadId === thread.threadId ? "Creating..." : "Create job"}
</Button>
) : null}
<Button
size="small"
variant="outlined"
color="error"
disabled={savingThreadId === thread.threadId}
onClick={() => void saveDecision(thread.threadId, "rejected")}
>
Reject
</Button>
</Box>
</Box>
</Paper>
);
})}
</Stack>
</Paper>
);
}
-22
View File
@@ -167,28 +167,6 @@ function initialsFrom(values: Array<string | undefined>) {
return (joined[0][0] + joined[1][0]).toUpperCase();
}
function replaceCvSection(source: string, sectionName: string, sectionDraft: string) {
const trimmedSource = source.trim();
const trimmedDraft = sectionDraft.trim();
if (!trimmedDraft) return source;
if (!trimmedSource) return `${sectionName}\n${trimmedDraft}`;
const headingPattern = /^([A-Z][A-Za-z &/]+):?\s*$/gm;
const matches = Array.from(trimmedSource.matchAll(headingPattern));
const normalizedTarget = sectionName.trim().toLowerCase();
const targetIndex = matches.findIndex((match) => match[1].trim().toLowerCase() === normalizedTarget);
if (targetIndex === -1) {
return `${trimmedSource}\n\n${sectionName}\n${trimmedDraft}`.trim();
}
const start = matches[targetIndex].index ?? 0;
const end = targetIndex + 1 < matches.length ? (matches[targetIndex + 1].index ?? trimmedSource.length) : trimmedSource.length;
const before = trimmedSource.slice(0, start).trimEnd();
const after = trimmedSource.slice(end).trimStart();
return [before, `${sectionName}\n${trimmedDraft}`, after].filter(Boolean).join("\n\n").trim();
}
function confidenceTone(confidence?: number) {
if (typeof confidence !== "number") return { label: "Review", color: "default" as const };
if (confidence >= 0.8) return { label: `High ${Math.round(confidence * 100)}%`, color: "success" as const };
+103
View File
@@ -200,10 +200,19 @@ export interface SaveApplicationDraftsRequest {
recruiterMessageDraft?: string | null;
}
export interface CorrespondenceAttachmentMetadata {
fileName?: string | null;
mimeType?: string | null;
sizeBytes?: number | null;
gmailAttachmentId?: string | null;
inline?: boolean;
}
export interface CorrespondenceMessage {
id: number;
jobApplicationId: number;
from: string;
direction?: string | null;
content: string;
subject?: string;
channel?: string;
@@ -212,6 +221,8 @@ export interface CorrespondenceMessage {
externalThreadId?: string | null;
externalFrom?: string | null;
externalTo?: string | null;
externalLabelsJson?: string | null;
attachmentMetadataJson?: string | null;
}
export interface GmailJobMatchReason {
@@ -288,11 +299,103 @@ export interface GmailThreadRefreshResult {
threads: GmailThreadRefreshThreadResult[];
}
export interface GmailReviewJobCandidate {
jobApplicationId: number;
jobTitle: string;
companyName: string;
score: number;
confidence: string;
reasons: GmailJobMatchReason[];
}
export interface GmailReviewThread {
threadId: string;
subject: string;
latestDate?: string;
messageCount: number;
routing: string;
hasImportedMessages: boolean;
decisionNote?: string | null;
matchedQueries: string[];
jobCandidates: GmailReviewJobCandidate[];
messages: GmailJobMatchedMessage[];
}
export interface GmailReviewQueueResponse {
queries: string[];
candidateThreadCount: number;
autoLinkThreadCount: number;
reviewThreadCount: number;
unmatchedThreadCount: number;
threads: GmailReviewThread[];
}
export interface GmailStatus {
connected: boolean;
gmailAddress?: string;
connectedAt?: string;
lastSyncedAt?: string;
lastSyncAttemptedAt?: string;
lastSyncSucceededAt?: string;
lastSyncMode?: string | null;
lastSyncSource?: string | null;
lastSyncStatus?: string | null;
lastSyncError?: string | null;
}
export interface GmailManualSyncResult {
queriesRun: number;
candidateThreadCount: number;
autoLinkedThreadCount: number;
reviewThreadCount: number;
unmatchedThreadCount: number;
importedMessages: number;
importedThreads: number;
skippedMessages: number;
lookbackDays: number;
includeSpamTrash: boolean;
syncedAt: string;
}
export interface GmailSuggestedJobCandidate {
threadId: string;
subject: string;
latestDate?: string | null;
companyName?: string | null;
recruiterName?: string | null;
recruiterEmail?: string | null;
suggestedJobTitle?: string | null;
routing: string;
matchedQueries: string[];
preview: string;
}
export interface GmailSuggestedJobsResponse {
count: number;
items: GmailSuggestedJobCandidate[];
}
export interface CreatedSuggestedGmailJobResult {
jobApplicationId: number;
companyId: number;
threadId: string;
imported: number;
skipped: number;
}
export interface GmailRelinkResult {
threadId: string;
jobApplicationId: number;
imported: number;
skipped: number;
unlinkedMessages: number;
}
export interface GmailUnlinkResult {
threadId: string;
jobApplicationId: number;
removedMessages: number;
decision: string;
}
export interface GmailMessageSummary {