Merge feature branch feat/gmail-job-correspondence
This commit is contained in:
@@ -66,6 +66,8 @@ namespace JobTrackerApi.Data
|
|||||||
modelBuilder.Entity<GmailConnection>()
|
modelBuilder.Entity<GmailConnection>()
|
||||||
.HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId);
|
.HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId);
|
||||||
|
|
||||||
|
modelBuilder.Ignore<CorrespondenceAttachmentMetadata>();
|
||||||
|
|
||||||
modelBuilder.Entity<GmailConnection>()
|
modelBuilder.Entity<GmailConnection>()
|
||||||
.HasIndex(x => new { x.OwnerUserId, x.GmailAddress })
|
.HasIndex(x => new { x.OwnerUserId, x.GmailAddress })
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|||||||
@@ -15,6 +15,39 @@ namespace JobTrackerApi.Tests;
|
|||||||
|
|
||||||
public sealed class GmailControllerTests
|
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]
|
[Fact]
|
||||||
public async Task Import_thread_rejects_missing_message_ids()
|
public async Task Import_thread_rejects_missing_message_ids()
|
||||||
{
|
{
|
||||||
@@ -225,7 +258,9 @@ public sealed class GmailControllerTests
|
|||||||
DateTimeOffset.UtcNow.AddDays(-1),
|
DateTimeOffset.UtcNow.AddDays(-1),
|
||||||
"Snippet",
|
"Snippet",
|
||||||
"Body text",
|
"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");
|
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("thread-1", firstPayload.Message!.ExternalThreadId);
|
||||||
Assert.Equal("Maria Recruiter <maria@acme.test>", firstPayload.Message.ExternalFrom);
|
Assert.Equal("Maria Recruiter <maria@acme.test>", firstPayload.Message.ExternalFrom);
|
||||||
Assert.Equal("user@example.test", firstPayload.Message.ExternalTo);
|
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 second = await controller.Import(new GmailController.ImportGmailMessageRequest(job.Id, "msg-1"), CancellationToken.None);
|
||||||
var secondOk = Assert.IsType<OkObjectResult>(second.Result);
|
var secondOk = Assert.IsType<OkObjectResult>(second.Result);
|
||||||
@@ -282,7 +321,9 @@ public sealed class GmailControllerTests
|
|||||||
DateTimeOffset.UtcNow.AddDays(-1),
|
DateTimeOffset.UtcNow.AddDays(-1),
|
||||||
"Snippet 1",
|
"Snippet 1",
|
||||||
"Body text 1",
|
"Body text 1",
|
||||||
null));
|
null,
|
||||||
|
Array.Empty<string>(),
|
||||||
|
Array.Empty<GmailMessageAttachment>()));
|
||||||
gmail.Setup(service => service.GetMessageAsync("user-1", "msg-2", It.IsAny<CancellationToken>()))
|
gmail.Setup(service => service.GetMessageAsync("user-1", "msg-2", It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(new GmailMessageDetail(
|
.ReturnsAsync(new GmailMessageDetail(
|
||||||
"msg-2",
|
"msg-2",
|
||||||
@@ -293,7 +334,9 @@ public sealed class GmailControllerTests
|
|||||||
DateTimeOffset.UtcNow,
|
DateTimeOffset.UtcNow,
|
||||||
"Snippet 2",
|
"Snippet 2",
|
||||||
"Body text 2",
|
"Body text 2",
|
||||||
null));
|
null,
|
||||||
|
Array.Empty<string>(),
|
||||||
|
Array.Empty<GmailMessageAttachment>()));
|
||||||
|
|
||||||
var controller = CreateController(db, gmail.Object, "user-1");
|
var controller = CreateController(db, gmail.Object, "user-1");
|
||||||
var request = new GmailController.ImportGmailThreadRequest(job.Id, "thread-1", new[] { "msg-1", "msg-2" });
|
var request = new GmailController.ImportGmailThreadRequest(job.Id, "thread-1", new[] { "msg-1", "msg-2" });
|
||||||
@@ -365,7 +408,9 @@ public sealed class GmailControllerTests
|
|||||||
DateTimeOffset.UtcNow,
|
DateTimeOffset.UtcNow,
|
||||||
"New reply",
|
"New reply",
|
||||||
"Reply body",
|
"Reply body",
|
||||||
null));
|
null,
|
||||||
|
Array.Empty<string>(),
|
||||||
|
Array.Empty<GmailMessageAttachment>()));
|
||||||
|
|
||||||
var controller = CreateController(db, gmail.Object, "user-1");
|
var controller = CreateController(db, gmail.Object, "user-1");
|
||||||
var result = await controller.RefreshLinkedThreads(new GmailController.RefreshLinkedThreadsRequest(job.Id), CancellationToken.None);
|
var result = await controller.RefreshLinkedThreads(new GmailController.RefreshLinkedThreadsRequest(job.Id), CancellationToken.None);
|
||||||
|
|||||||
@@ -48,10 +48,13 @@ namespace JobTrackerApi.Controllers
|
|||||||
string? Subject,
|
string? Subject,
|
||||||
string? Channel,
|
string? Channel,
|
||||||
DateTime? Date,
|
DateTime? Date,
|
||||||
|
string? Direction,
|
||||||
string? ExternalMessageId,
|
string? ExternalMessageId,
|
||||||
string? ExternalThreadId,
|
string? ExternalThreadId,
|
||||||
string? ExternalFrom,
|
string? ExternalFrom,
|
||||||
string? ExternalTo
|
string? ExternalTo,
|
||||||
|
string? ExternalLabelsJson,
|
||||||
|
string? AttachmentMetadataJson
|
||||||
);
|
);
|
||||||
|
|
||||||
// POST new message
|
// POST new message
|
||||||
@@ -71,10 +74,13 @@ namespace JobTrackerApi.Controllers
|
|||||||
From = request.From.Trim(),
|
From = request.From.Trim(),
|
||||||
Subject = string.IsNullOrWhiteSpace(request.Subject) ? null : request.Subject.Trim(),
|
Subject = string.IsNullOrWhiteSpace(request.Subject) ? null : request.Subject.Trim(),
|
||||||
Channel = string.IsNullOrWhiteSpace(request.Channel) ? null : request.Channel.Trim(),
|
Channel = string.IsNullOrWhiteSpace(request.Channel) ? null : request.Channel.Trim(),
|
||||||
|
Direction = string.IsNullOrWhiteSpace(request.Direction) ? null : request.Direction.Trim(),
|
||||||
ExternalMessageId = string.IsNullOrWhiteSpace(request.ExternalMessageId) ? null : request.ExternalMessageId.Trim(),
|
ExternalMessageId = string.IsNullOrWhiteSpace(request.ExternalMessageId) ? null : request.ExternalMessageId.Trim(),
|
||||||
ExternalThreadId = string.IsNullOrWhiteSpace(request.ExternalThreadId) ? null : request.ExternalThreadId.Trim(),
|
ExternalThreadId = string.IsNullOrWhiteSpace(request.ExternalThreadId) ? null : request.ExternalThreadId.Trim(),
|
||||||
ExternalFrom = string.IsNullOrWhiteSpace(request.ExternalFrom) ? null : request.ExternalFrom.Trim(),
|
ExternalFrom = string.IsNullOrWhiteSpace(request.ExternalFrom) ? null : request.ExternalFrom.Trim(),
|
||||||
ExternalTo = string.IsNullOrWhiteSpace(request.ExternalTo) ? null : request.ExternalTo.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,
|
Content = request.Content,
|
||||||
Date = request.Date ?? DateTime.Now,
|
Date = request.Date ?? DateTime.Now,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using System.Text.Json;
|
||||||
using JobTrackerApi.Data;
|
using JobTrackerApi.Data;
|
||||||
using JobTrackerApi.Models;
|
using JobTrackerApi.Models;
|
||||||
using JobTrackerApi.Services;
|
using JobTrackerApi.Services;
|
||||||
@@ -68,18 +69,34 @@ public sealed class GmailController : ControllerBase
|
|||||||
int CandidateThreadCount,
|
int CandidateThreadCount,
|
||||||
IReadOnlyList<GmailJobMatchedThreadDto> Threads);
|
IReadOnlyList<GmailJobMatchedThreadDto> Threads);
|
||||||
|
|
||||||
|
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")]
|
[HttpGet("status")]
|
||||||
public async Task<IActionResult> Status(CancellationToken cancellationToken)
|
public async Task<ActionResult<GmailConnectionStatusDto>> Status(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var ownerUserId = GetRequiredOwnerUserId();
|
var ownerUserId = GetRequiredOwnerUserId();
|
||||||
var connection = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken);
|
var connection = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken);
|
||||||
return Ok(new
|
return Ok(new GmailConnectionStatusDto(
|
||||||
{
|
connection is not null,
|
||||||
connected = connection is not null,
|
connection?.GmailAddress,
|
||||||
gmailAddress = connection?.GmailAddress,
|
connection?.ConnectedAt,
|
||||||
connectedAt = connection?.ConnectedAt,
|
connection?.LastSyncedAt,
|
||||||
lastSyncedAt = connection?.LastSyncedAt,
|
connection?.LastSyncAttemptedAt,
|
||||||
});
|
connection?.LastSyncSucceededAt,
|
||||||
|
connection?.LastSyncMode,
|
||||||
|
connection?.LastSyncSource,
|
||||||
|
connection?.LastSyncStatus,
|
||||||
|
connection?.LastSyncError));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("connect-url")]
|
[HttpGet("connect-url")]
|
||||||
@@ -398,12 +415,22 @@ public sealed class GmailController : ControllerBase
|
|||||||
{
|
{
|
||||||
JobApplicationId = job.Id,
|
JobApplicationId = job.Id,
|
||||||
From = isMe ? "Me" : "Company",
|
From = isMe ? "Me" : "Company",
|
||||||
|
Direction = isMe ? "outbound" : "inbound",
|
||||||
Subject = string.IsNullOrWhiteSpace(detail.Subject) ? null : detail.Subject.Trim(),
|
Subject = string.IsNullOrWhiteSpace(detail.Subject) ? null : detail.Subject.Trim(),
|
||||||
Channel = "Email",
|
Channel = "Email",
|
||||||
ExternalMessageId = detail.Id,
|
ExternalMessageId = detail.Id,
|
||||||
ExternalThreadId = string.IsNullOrWhiteSpace(detail.ThreadId) ? null : detail.ThreadId.Trim(),
|
ExternalThreadId = string.IsNullOrWhiteSpace(detail.ThreadId) ? null : detail.ThreadId.Trim(),
|
||||||
ExternalFrom = string.IsNullOrWhiteSpace(detail.From) ? null : detail.From.Trim(),
|
ExternalFrom = string.IsNullOrWhiteSpace(detail.From) ? null : detail.From.Trim(),
|
||||||
ExternalTo = string.IsNullOrWhiteSpace(detail.To) ? null : detail.To.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,
|
Content = string.IsNullOrWhiteSpace(detail.BodyText) ? detail.Snippet : detail.BodyText,
|
||||||
Date = messageDate,
|
Date = messageDate,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ builder.Services.AddSingleton<ISummarizerService, SummarizerService>();
|
|||||||
builder.Services.AddSingleton<ICvAiClassifier, CvAiClassifier>();
|
builder.Services.AddSingleton<ICvAiClassifier, CvAiClassifier>();
|
||||||
builder.Services.AddSingleton<IGoogleTokenValidator, GoogleTokenValidator>();
|
builder.Services.AddSingleton<IGoogleTokenValidator, GoogleTokenValidator>();
|
||||||
builder.Services.AddScoped<IGmailOAuthService, GmailOAuthService>();
|
builder.Services.AddScoped<IGmailOAuthService, GmailOAuthService>();
|
||||||
|
builder.Services.AddSingleton<IGmailCorrespondenceEnrichmentService, NoOpGmailCorrespondenceEnrichmentService>();
|
||||||
|
|
||||||
builder.Services.AddIdentityCore<ApplicationUser>(options =>
|
builder.Services.AddIdentityCore<ApplicationUser>(options =>
|
||||||
{
|
{
|
||||||
@@ -628,10 +629,23 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" (
|
|||||||
"AccessTokenExpiresAt" TEXT NULL,
|
"AccessTokenExpiresAt" TEXT NULL,
|
||||||
"Scope" TEXT NOT NULL,
|
"Scope" TEXT NOT NULL,
|
||||||
"ConnectedAt" 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
|
||||||
);
|
);
|
||||||
""");
|
""");
|
||||||
|
|
||||||
|
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 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");""");
|
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_GmailConnections_OwnerUserId_GmailAddress" ON "GmailConnections" ("OwnerUserId", "GmailAddress");""");
|
||||||
}
|
}
|
||||||
@@ -739,6 +753,9 @@ CREATE TABLE IF NOT EXISTS "TailoredCvDrafts" (
|
|||||||
EnsureColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE Correspondences ADD COLUMN ExternalThreadId TEXT NULL;");
|
EnsureColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE Correspondences ADD COLUMN ExternalThreadId TEXT NULL;");
|
||||||
EnsureColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE Correspondences ADD COLUMN ExternalFrom TEXT NULL;");
|
EnsureColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE Correspondences ADD COLUMN ExternalFrom TEXT NULL;");
|
||||||
EnsureColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE Correspondences ADD COLUMN ExternalTo 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.
|
// Record the migration as applied.
|
||||||
@@ -766,6 +783,9 @@ CREATE TABLE IF NOT EXISTS "TailoredCvDrafts" (
|
|||||||
EnsureColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE Correspondences ADD COLUMN ExternalThreadId TEXT NULL;");
|
EnsureColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE Correspondences ADD COLUMN ExternalThreadId TEXT NULL;");
|
||||||
EnsureColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE Correspondences ADD COLUMN ExternalFrom TEXT NULL;");
|
EnsureColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE Correspondences ADD COLUMN ExternalFrom TEXT NULL;");
|
||||||
EnsureColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE Correspondences ADD COLUMN ExternalTo 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", "Purpose", "ALTER TABLE Attachments ADD COLUMN Purpose TEXT NULL;");
|
||||||
EnsureColumn(conn, "Attachments", "UseForAi", "ALTER TABLE Attachments ADD COLUMN UseForAi INTEGER NOT NULL DEFAULT 1;");
|
EnsureColumn(conn, "Attachments", "UseForAi", "ALTER TABLE Attachments ADD COLUMN UseForAi INTEGER NOT NULL DEFAULT 1;");
|
||||||
|
|
||||||
@@ -865,6 +885,9 @@ CREATE TABLE IF NOT EXISTS "TailoredCvDrafts" (
|
|||||||
EnsureMySqlColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalThreadId` longtext NULL;");
|
EnsureMySqlColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalThreadId` longtext NULL;");
|
||||||
EnsureMySqlColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalFrom` longtext NULL;");
|
EnsureMySqlColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalFrom` longtext NULL;");
|
||||||
EnsureMySqlColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalTo` 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", "Purpose", "ALTER TABLE `Attachments` ADD COLUMN `Purpose` varchar(100) NULL;");
|
||||||
EnsureMySqlColumn(conn, "Attachments", "UseForAi", "ALTER TABLE `Attachments` ADD COLUMN `UseForAi` tinyint(1) NOT NULL DEFAULT 1;");
|
EnsureMySqlColumn(conn, "Attachments", "UseForAi", "ALTER TABLE `Attachments` ADD COLUMN `UseForAi` tinyint(1) NOT NULL DEFAULT 1;");
|
||||||
EnsureMySqlColumn(conn, "AspNetUsers", "ProfileCvText", "ALTER TABLE `AspNetUsers` ADD COLUMN `ProfileCvText` longtext NULL;");
|
EnsureMySqlColumn(conn, "AspNetUsers", "ProfileCvText", "ALTER TABLE `AspNetUsers` ADD COLUMN `ProfileCvText` longtext NULL;");
|
||||||
@@ -939,6 +962,37 @@ CONSTRAINT `FK_CvExtractionRuns_CvUploadArtifacts_ArtifactId` FOREIGN KEY (`Arti
|
|||||||
cmd.ExecuteNonQuery();
|
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"))
|
if (!HasMySqlTable(conn, "TailoredCvDrafts"))
|
||||||
{
|
{
|
||||||
using var cmd = conn.CreateCommand();
|
using var cmd = conn.CreateCommand();
|
||||||
@@ -1000,6 +1054,20 @@ CONSTRAINT `FK_TailoredCvDrafts_JobApplications_JobApplicationId` FOREIGN KEY (`
|
|||||||
cmd.ExecuteNonQuery();
|
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"))
|
if (!MySqlIndexExists(conn, "TailoredCvDrafts", "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId"))
|
||||||
{
|
{
|
||||||
using var cmd = conn.CreateCommand();
|
using var cmd = conn.CreateCommand();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -27,7 +27,8 @@ public interface IGmailOAuthService
|
|||||||
public sealed record GmailOAuthExchangeResult(string GmailAddress);
|
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 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 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
|
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.AccessTokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(tokens.expires_in - 60, 60));
|
||||||
existing.Scope = tokens.scope?.Trim() ?? Scope;
|
existing.Scope = tokens.scope?.Trim() ?? Scope;
|
||||||
existing.ConnectedAt = DateTimeOffset.UtcNow;
|
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);
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
return new GmailOAuthExchangeResult(existing.GmailAddress);
|
return new GmailOAuthExchangeResult(existing.GmailAddress);
|
||||||
@@ -148,40 +155,49 @@ public sealed class GmailOAuthService : IGmailOAuthService
|
|||||||
public async Task<IReadOnlyList<GmailMessageSummary>> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken)
|
public async Task<IReadOnlyList<GmailMessageSummary>> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
maxResults = Math.Clamp(maxResults, 1, 25);
|
maxResults = Math.Clamp(maxResults, 1, 25);
|
||||||
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
|
try
|
||||||
var client = _httpClientFactory.CreateClient();
|
|
||||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
|
||||||
|
|
||||||
var url = $"https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults={maxResults}";
|
|
||||||
if (!string.IsNullOrWhiteSpace(query))
|
|
||||||
{
|
{
|
||||||
url += $"&q={Uri.EscapeDataString(query.Trim())}";
|
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
|
||||||
|
var client = _httpClientFactory.CreateClient();
|
||||||
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||||
|
|
||||||
|
var url = $"https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults={maxResults}";
|
||||||
|
if (!string.IsNullOrWhiteSpace(query))
|
||||||
|
{
|
||||||
|
url += $"&q={Uri.EscapeDataString(query.Trim())}";
|
||||||
|
}
|
||||||
|
|
||||||
|
using var response = await client.GetAsync(url, cancellationToken);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
|
||||||
|
if (!doc.RootElement.TryGetProperty("messages", out var messagesElement) || messagesElement.ValueKind != JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
await TouchSyncStateAsync(ownerUserId, "list-messages", string.IsNullOrWhiteSpace(query) ? "default-query" : "custom-query", true, null, cancellationToken);
|
||||||
|
return Array.Empty<GmailMessageSummary>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var ids = messagesElement.EnumerateArray()
|
||||||
|
.Select(x => x.TryGetProperty("id", out var id) ? id.GetString() : null)
|
||||||
|
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||||
|
.Cast<string>()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var results = new List<GmailMessageSummary>(ids.Count);
|
||||||
|
foreach (var id in ids)
|
||||||
|
{
|
||||||
|
var detail = await GetMessageAsync(ownerUserId, id, cancellationToken);
|
||||||
|
results.Add(new GmailMessageSummary(detail.Id, detail.ThreadId, detail.Subject, detail.From, detail.To, detail.Date, detail.Snippet));
|
||||||
|
}
|
||||||
|
|
||||||
|
await TouchSyncStateAsync(ownerUserId, "list-messages", string.IsNullOrWhiteSpace(query) ? "default-query" : "custom-query", true, null, cancellationToken);
|
||||||
|
return results;
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
using var response = await client.GetAsync(url, cancellationToken);
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
|
|
||||||
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
|
|
||||||
if (!doc.RootElement.TryGetProperty("messages", out var messagesElement) || messagesElement.ValueKind != JsonValueKind.Array)
|
|
||||||
{
|
{
|
||||||
return Array.Empty<GmailMessageSummary>();
|
await TouchSyncStateAsync(ownerUserId, "list-messages", string.IsNullOrWhiteSpace(query) ? "default-query" : "custom-query", false, ex.Message, cancellationToken);
|
||||||
|
throw;
|
||||||
}
|
}
|
||||||
|
|
||||||
var ids = messagesElement.EnumerateArray()
|
|
||||||
.Select(x => x.TryGetProperty("id", out var id) ? id.GetString() : null)
|
|
||||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
|
||||||
.Cast<string>()
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var results = new List<GmailMessageSummary>(ids.Count);
|
|
||||||
foreach (var id in ids)
|
|
||||||
{
|
|
||||||
var detail = await GetMessageAsync(ownerUserId, id, cancellationToken);
|
|
||||||
results.Add(new GmailMessageSummary(detail.Id, detail.ThreadId, detail.Subject, detail.From, detail.To, detail.Date, detail.Snippet));
|
|
||||||
}
|
|
||||||
|
|
||||||
await TouchSyncTimeAsync(ownerUserId, cancellationToken);
|
|
||||||
return results;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<GmailMessageSummary>> ListMessagesForQueriesAsync(string ownerUserId, IEnumerable<string> queries, int maxResultsPerQuery, CancellationToken cancellationToken)
|
public async Task<IReadOnlyList<GmailMessageSummary>> ListMessagesForQueriesAsync(string ownerUserId, IEnumerable<string> queries, int maxResultsPerQuery, CancellationToken cancellationToken)
|
||||||
@@ -233,93 +249,117 @@ public sealed class GmailOAuthService : IGmailOAuthService
|
|||||||
return Array.Empty<GmailMessageSummary>();
|
return Array.Empty<GmailMessageSummary>();
|
||||||
}
|
}
|
||||||
|
|
||||||
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
|
try
|
||||||
var client = _httpClientFactory.CreateClient();
|
|
||||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
|
||||||
|
|
||||||
var url = $"https://gmail.googleapis.com/gmail/v1/users/me/threads/{Uri.EscapeDataString(threadId.Trim())}?format=metadata&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=To&metadataHeaders=Date";
|
|
||||||
using var response = await client.GetAsync(url, cancellationToken);
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
|
|
||||||
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
|
|
||||||
if (!doc.RootElement.TryGetProperty("messages", out var messagesElement) || messagesElement.ValueKind != JsonValueKind.Array)
|
|
||||||
{
|
{
|
||||||
return Array.Empty<GmailMessageSummary>();
|
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
|
||||||
}
|
var client = _httpClientFactory.CreateClient();
|
||||||
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||||
|
|
||||||
var results = new List<GmailMessageSummary>();
|
var url = $"https://gmail.googleapis.com/gmail/v1/users/me/threads/{Uri.EscapeDataString(threadId.Trim())}?format=metadata&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=To&metadataHeaders=Date";
|
||||||
foreach (var messageElement in messagesElement.EnumerateArray())
|
using var response = await client.GetAsync(url, cancellationToken);
|
||||||
{
|
response.EnsureSuccessStatusCode();
|
||||||
var id = messageElement.TryGetProperty("id", out var idEl) ? idEl.GetString() : null;
|
|
||||||
if (string.IsNullOrWhiteSpace(id)) continue;
|
|
||||||
|
|
||||||
var messageThreadId = messageElement.TryGetProperty("threadId", out var messageThreadIdEl)
|
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
|
||||||
? messageThreadIdEl.GetString() ?? threadId.Trim()
|
if (!doc.RootElement.TryGetProperty("messages", out var messagesElement) || messagesElement.ValueKind != JsonValueKind.Array)
|
||||||
: threadId.Trim();
|
|
||||||
var snippet = messageElement.TryGetProperty("snippet", out var snippetEl) ? snippetEl.GetString() ?? string.Empty : string.Empty;
|
|
||||||
var payload = messageElement.TryGetProperty("payload", out var payloadEl) ? payloadEl : default;
|
|
||||||
var headers = payload.ValueKind == JsonValueKind.Object ? ReadHeaders(payload) : new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
DateTimeOffset? date = null;
|
|
||||||
if (headers.TryGetValue("date", out var dateHeader) && DateTimeOffset.TryParse(dateHeader, out var parsedDate))
|
|
||||||
{
|
{
|
||||||
date = parsedDate;
|
await TouchSyncStateAsync(ownerUserId, "thread-refresh", "thread-metadata", true, null, cancellationToken);
|
||||||
|
return Array.Empty<GmailMessageSummary>();
|
||||||
}
|
}
|
||||||
|
|
||||||
results.Add(new GmailMessageSummary(
|
var results = new List<GmailMessageSummary>();
|
||||||
id.Trim(),
|
foreach (var messageElement in messagesElement.EnumerateArray())
|
||||||
messageThreadId,
|
{
|
||||||
headers.TryGetValue("subject", out var subject) ? subject : string.Empty,
|
var id = messageElement.TryGetProperty("id", out var idEl) ? idEl.GetString() : null;
|
||||||
headers.TryGetValue("from", out var from) ? from : string.Empty,
|
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||||
headers.TryGetValue("to", out var to) ? to : string.Empty,
|
|
||||||
date,
|
|
||||||
snippet));
|
|
||||||
}
|
|
||||||
|
|
||||||
await TouchSyncTimeAsync(ownerUserId, cancellationToken);
|
var messageThreadId = messageElement.TryGetProperty("threadId", out var messageThreadIdEl)
|
||||||
return results;
|
? messageThreadIdEl.GetString() ?? threadId.Trim()
|
||||||
|
: threadId.Trim();
|
||||||
|
var snippet = messageElement.TryGetProperty("snippet", out var snippetEl) ? snippetEl.GetString() ?? string.Empty : string.Empty;
|
||||||
|
var payload = messageElement.TryGetProperty("payload", out var payloadEl) ? payloadEl : default;
|
||||||
|
var headers = payload.ValueKind == JsonValueKind.Object ? ReadHeaders(payload) : new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
DateTimeOffset? date = null;
|
||||||
|
if (headers.TryGetValue("date", out var dateHeader) && DateTimeOffset.TryParse(dateHeader, out var parsedDate))
|
||||||
|
{
|
||||||
|
date = parsedDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
results.Add(new GmailMessageSummary(
|
||||||
|
id.Trim(),
|
||||||
|
messageThreadId,
|
||||||
|
headers.TryGetValue("subject", out var subject) ? subject : string.Empty,
|
||||||
|
headers.TryGetValue("from", out var from) ? from : string.Empty,
|
||||||
|
headers.TryGetValue("to", out var to) ? to : string.Empty,
|
||||||
|
date,
|
||||||
|
snippet));
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
public async Task<GmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
|
try
|
||||||
var client = _httpClientFactory.CreateClient();
|
|
||||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
|
||||||
|
|
||||||
var url = $"https://gmail.googleapis.com/gmail/v1/users/me/messages/{Uri.EscapeDataString(messageId)}?format=full";
|
|
||||||
using var response = await client.GetAsync(url, cancellationToken);
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
|
|
||||||
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
|
|
||||||
var root = doc.RootElement;
|
|
||||||
|
|
||||||
var threadId = root.TryGetProperty("threadId", out var threadEl) ? threadEl.GetString() ?? "" : "";
|
|
||||||
var snippet = root.TryGetProperty("snippet", out var snippetEl) ? snippetEl.GetString() ?? "" : "";
|
|
||||||
|
|
||||||
var payload = root.GetProperty("payload");
|
|
||||||
var headers = ReadHeaders(payload);
|
|
||||||
var bodyText = ExtractBody(payload, "text/plain");
|
|
||||||
var bodyHtml = ExtractBody(payload, "text/html");
|
|
||||||
if (string.IsNullOrWhiteSpace(bodyText) && !string.IsNullOrWhiteSpace(bodyHtml))
|
|
||||||
{
|
{
|
||||||
bodyText = StripHtml(bodyHtml);
|
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
|
||||||
}
|
var client = _httpClientFactory.CreateClient();
|
||||||
else if (LooksLikeHtml(bodyText))
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||||
{
|
|
||||||
bodyText = StripHtml(bodyText);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new GmailMessageDetail(
|
var url = $"https://gmail.googleapis.com/gmail/v1/users/me/messages/{Uri.EscapeDataString(messageId)}?format=full";
|
||||||
messageId,
|
using var response = await client.GetAsync(url, cancellationToken);
|
||||||
threadId,
|
response.EnsureSuccessStatusCode();
|
||||||
headers.TryGetValue("subject", out var subject) ? subject : "",
|
|
||||||
headers.TryGetValue("from", out var from) ? from : "",
|
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
|
||||||
headers.TryGetValue("to", out var to) ? to : "",
|
var root = doc.RootElement;
|
||||||
headers.TryGetValue("date", out var dateRaw) && DateTimeOffset.TryParse(dateRaw, out var parsedDate) ? parsedDate : null,
|
|
||||||
snippet,
|
var threadId = root.TryGetProperty("threadId", out var threadEl) ? threadEl.GetString() ?? "" : "";
|
||||||
bodyText.Trim(),
|
var snippet = root.TryGetProperty("snippet", out var snippetEl) ? snippetEl.GetString() ?? "" : "";
|
||||||
string.IsNullOrWhiteSpace(bodyHtml) ? null : bodyHtml
|
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))
|
||||||
|
{
|
||||||
|
bodyText = StripHtml(bodyHtml);
|
||||||
|
}
|
||||||
|
else if (LooksLikeHtml(bodyText))
|
||||||
|
{
|
||||||
|
bodyText = StripHtml(bodyText);
|
||||||
|
}
|
||||||
|
|
||||||
|
await TouchSyncStateAsync(ownerUserId, "message-detail", "gmail-message", true, null, cancellationToken);
|
||||||
|
return new GmailMessageDetail(
|
||||||
|
messageId,
|
||||||
|
threadId,
|
||||||
|
headers.TryGetValue("subject", out var subject) ? subject : "",
|
||||||
|
headers.TryGetValue("from", out var from) ? from : "",
|
||||||
|
headers.TryGetValue("to", out var to) ? to : "",
|
||||||
|
headers.TryGetValue("date", out var dateRaw) && DateTimeOffset.TryParse(dateRaw, out var parsedDate) ? parsedDate : null,
|
||||||
|
snippet,
|
||||||
|
bodyText.Trim(),
|
||||||
|
string.IsNullOrWhiteSpace(bodyHtml) ? null : bodyHtml,
|
||||||
|
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)
|
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)
|
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);
|
var connection = await _db.GmailConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
||||||
if (connection is null) return;
|
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);
|
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()
|
private string GetRequiredClientId()
|
||||||
{
|
{
|
||||||
return (_cfg["Google:ClientId"] ?? _cfg["Auth:GoogleClientId"] ?? "").Trim() switch
|
return (_cfg["Google:ClientId"] ?? _cfg["Auth:GoogleClientId"] ?? "").Trim() switch
|
||||||
@@ -481,6 +545,48 @@ public sealed class GmailOAuthService : IGmailOAuthService
|
|||||||
return result;
|
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)
|
private static string ExtractBody(JsonElement payload, string mimeType)
|
||||||
{
|
{
|
||||||
if (payload.TryGetProperty("mimeType", out var mimeTypeEl) &&
|
if (payload.TryGetProperty("mimeType", out var mimeTypeEl) &&
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace JobTrackerApi.Models
|
namespace JobTrackerApi.Models
|
||||||
@@ -11,13 +12,35 @@ namespace JobTrackerApi.Models
|
|||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public JobApplication JobApplication { get; set; } = null!;
|
public JobApplication JobApplication { get; set; } = null!;
|
||||||
public string From { get; set; } = ""; // "Me" or "Company"
|
public string From { get; set; } = ""; // "Me" or "Company"
|
||||||
|
public string? Direction { get; set; } // inbound, outbound, internal, unknown
|
||||||
public string? Subject { get; set; }
|
public string? Subject { get; set; }
|
||||||
public string? Channel { get; set; } // e.g. Email, Call, Note
|
public string? Channel { get; set; } // e.g. Email, Call, Note
|
||||||
public string? ExternalMessageId { get; set; }
|
public string? ExternalMessageId { get; set; }
|
||||||
public string? ExternalThreadId { get; set; }
|
public string? ExternalThreadId { get; set; }
|
||||||
public string? ExternalFrom { get; set; }
|
public string? ExternalFrom { get; set; }
|
||||||
public string? ExternalTo { get; set; }
|
public string? ExternalTo { get; set; }
|
||||||
|
public string? ExternalLabelsJson { get; set; }
|
||||||
|
public string? AttachmentMetadataJson { get; set; }
|
||||||
public string Content { get; set; } = "";
|
public string Content { get; set; } = "";
|
||||||
public DateTime Date { get; set; } = DateTime.Now;
|
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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,4 +11,10 @@ public sealed class GmailConnection
|
|||||||
public string Scope { get; set; } = "";
|
public string Scope { get; set; } = "";
|
||||||
public DateTimeOffset ConnectedAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset ConnectedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
public DateTimeOffset? LastSyncedAt { get; set; }
|
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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Smart Gmail Job Correspondence Integration Progress
|
||||||
|
|
||||||
|
## Branch
|
||||||
|
- feat/gmail-job-correspondence
|
||||||
|
|
||||||
|
## Status
|
||||||
|
- Workstream initialized.
|
||||||
|
- Milestones planned: M006-M010.
|
||||||
|
- Current focus: M006 / S01 foundation work.
|
||||||
|
|
||||||
|
## Completed so far
|
||||||
|
- Created separate Gmail feature branch.
|
||||||
|
- Captured foundation context in `.gsd/milestones/M006/M006-CONTEXT.md`.
|
||||||
|
- Planned milestones M006-M010 for the Gmail workstream.
|
||||||
|
- Planned slice M006/S01.
|
||||||
|
- Confirmed existing architecture seams:
|
||||||
|
- Gmail OAuth/token flow already exists.
|
||||||
|
- Per-job Gmail candidate search/import/thread refresh already exists.
|
||||||
|
- Correspondence persistence already stores Gmail thread/message metadata.
|
||||||
|
- Current implementation is job-local, not global-inbox/review oriented.
|
||||||
|
- Implemented M006/S01 foundation changes:
|
||||||
|
- durable Gmail sync-state fields on `GmailConnection`
|
||||||
|
- SQLite/MySQL bootstrap support for new Gmail sync-state columns
|
||||||
|
- richer `GET /api/gmail/status` response
|
||||||
|
- per-job correspondence UI now shows sync diagnostics
|
||||||
|
- focused backend/frontend Gmail tests added and passing
|
||||||
|
- Phase 2 extension seam scaffolded with a no-op enrichment service
|
||||||
|
- Phase 1/2 design doc added at `docs/gmail-correspondence-phase1.md`
|
||||||
|
- Started M007 ingestion/storage work:
|
||||||
|
- imported correspondence now stores direction, Gmail labels JSON, and attachment metadata JSON
|
||||||
|
- Gmail message detail extraction now reads labels and attachment metadata from Gmail payloads
|
||||||
|
- focused Gmail backend/frontend tests pass against the richer import contract
|
||||||
|
|
||||||
|
## Next tasks
|
||||||
|
1. Implement M006/S01/T01: refactor Gmail connection foundation and sync-state model.
|
||||||
|
2. Implement M006/S01/T02: expose sync-state surfaces in UI without breaking current correspondence workflow.
|
||||||
|
3. Implement M006/S01/T03: prepare Phase 2 extension seam/docs.
|
||||||
|
4. Verify backend + frontend Gmail focused tests.
|
||||||
|
5. Commit and push incremental progress.
|
||||||
|
|
||||||
|
## Resume notes
|
||||||
|
- Previous CV/parsing branch work is separate and already pushed.
|
||||||
|
- Local dev SQLite runtime still has missing-table drift in some unrelated surfaces (`RuleSettings`, `Companies`, etc.); avoid conflating that with the Gmail feature work.
|
||||||
|
- Existing per-job Gmail tests live in `JobTrackerApi.Tests/GmailControllerTests.cs` and `job-tracker-ui/src/correspondence-gmail-import.test.tsx`.
|
||||||
@@ -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.
|
||||||
@@ -382,11 +382,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" }}>
|
<Box sx={{ maxWidth: "80%", borderRadius: 3, p: 1.25, border: `1px solid ${alpha(accent, theme.palette.mode === "dark" ? 0.32 : 0.22)}`, background: alpha(accent, theme.palette.mode === "dark" ? 0.14 : 0.1), color: "text.primary" }}>
|
||||||
{m.subject ? <Typography sx={{ fontWeight: 800, mb: 0.5 }}>{m.subject}</Typography> : null}
|
{m.subject ? <Typography sx={{ fontWeight: 800, mb: 0.5 }}>{m.subject}</Typography> : null}
|
||||||
<Typography sx={{ whiteSpace: "pre-wrap", lineHeight: 1.35 }}>{m.content}</Typography>
|
<Typography sx={{ whiteSpace: "pre-wrap", lineHeight: 1.35 }}>{m.content}</Typography>
|
||||||
{(m.externalThreadId || m.externalFrom || m.externalTo) ? (
|
{(m.externalThreadId || m.externalFrom || m.externalTo || m.externalLabelsJson || m.attachmentMetadataJson) ? (
|
||||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 1 }}>
|
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 1 }}>
|
||||||
{m.externalThreadId ? <Chip size="small" label={`Thread ${m.externalThreadId}`} variant="outlined" /> : null}
|
{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.externalFrom ? <Chip size="small" label={`From ${m.externalFrom}`} variant="outlined" /> : null}
|
||||||
{m.externalTo ? <Chip size="small" label={`To ${m.externalTo}`} 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>
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "flex-end", mt: 0.75 }}>
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "flex-end", mt: 0.75 }}>
|
||||||
@@ -413,6 +415,16 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
|
|||||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
<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={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"} />
|
<Chip size="small" color={linkedThreadIds.length > 0 ? "success" : "default"} variant="outlined" label={linkedThreadIds.length > 0 ? `Linked threads: ${linkedThreadIds.length}` : "No linked threads yet"} />
|
||||||
|
{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 ? (
|
{linkedThreadRefresh ? (
|
||||||
<Chip
|
<Chip
|
||||||
size="small"
|
size="small"
|
||||||
@@ -425,6 +437,11 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
|
{gmailStatus?.lastSyncError ? (
|
||||||
|
<Typography variant="body2" sx={{ color: "warning.main", mt: 1 }}>
|
||||||
|
Latest Gmail sync issue: {gmailStatus.lastSyncError}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ display: "flex", gap: 1, alignItems: "flex-start", mt: 1.5, flexWrap: "wrap" }}>
|
<Box sx={{ display: "flex", gap: 1, alignItems: "flex-start", mt: 1.5, flexWrap: "wrap" }}>
|
||||||
@@ -503,6 +520,8 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
|
|||||||
</Box>
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
{gmailStatus.lastSyncedAt ? <Chip label={t("correspondenceLastSynced", { date: new Date(gmailStatus.lastSyncedAt).toLocaleString() })} size="small" /> : null}
|
{gmailStatus.lastSyncedAt ? <Chip label={t("correspondenceLastSynced", { date: new Date(gmailStatus.lastSyncedAt).toLocaleString() })} size="small" /> : null}
|
||||||
|
{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 ? (
|
{linkedThreadIds.length > 0 ? (
|
||||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||||
<Chip size="small" color="success" variant="outlined" label={`Linked threads: ${linkedThreadIds.length}`} />
|
<Chip size="small" color="success" variant="outlined" label={`Linked threads: ${linkedThreadIds.length}`} />
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ describe("correspondence Gmail import", () => {
|
|||||||
return Promise.resolve({ data: correspondenceMessages } as any);
|
return Promise.resolve({ data: correspondenceMessages } as any);
|
||||||
}
|
}
|
||||||
if (url === "/gmail/status") {
|
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") {
|
if (url === "/gmail/job-candidates") {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
@@ -291,6 +291,16 @@ describe("correspondence Gmail import", () => {
|
|||||||
expect((await screen.findAllByText(/thread thread-1/i)).length).toBeGreaterThan(0);
|
expect((await screen.findAllByText(/thread thread-1/i)).length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 () => {
|
test("manual Gmail search override reloads job candidates with queryOverride", async () => {
|
||||||
renderDialog();
|
renderDialog();
|
||||||
|
|
||||||
|
|||||||
@@ -200,10 +200,19 @@ export interface SaveApplicationDraftsRequest {
|
|||||||
recruiterMessageDraft?: string | null;
|
recruiterMessageDraft?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CorrespondenceAttachmentMetadata {
|
||||||
|
fileName?: string | null;
|
||||||
|
mimeType?: string | null;
|
||||||
|
sizeBytes?: number | null;
|
||||||
|
gmailAttachmentId?: string | null;
|
||||||
|
inline?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CorrespondenceMessage {
|
export interface CorrespondenceMessage {
|
||||||
id: number;
|
id: number;
|
||||||
jobApplicationId: number;
|
jobApplicationId: number;
|
||||||
from: string;
|
from: string;
|
||||||
|
direction?: string | null;
|
||||||
content: string;
|
content: string;
|
||||||
subject?: string;
|
subject?: string;
|
||||||
channel?: string;
|
channel?: string;
|
||||||
@@ -212,6 +221,8 @@ export interface CorrespondenceMessage {
|
|||||||
externalThreadId?: string | null;
|
externalThreadId?: string | null;
|
||||||
externalFrom?: string | null;
|
externalFrom?: string | null;
|
||||||
externalTo?: string | null;
|
externalTo?: string | null;
|
||||||
|
externalLabelsJson?: string | null;
|
||||||
|
attachmentMetadataJson?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GmailJobMatchReason {
|
export interface GmailJobMatchReason {
|
||||||
@@ -293,6 +304,12 @@ export interface GmailStatus {
|
|||||||
gmailAddress?: string;
|
gmailAddress?: string;
|
||||||
connectedAt?: string;
|
connectedAt?: string;
|
||||||
lastSyncedAt?: string;
|
lastSyncedAt?: string;
|
||||||
|
lastSyncAttemptedAt?: string;
|
||||||
|
lastSyncSucceededAt?: string;
|
||||||
|
lastSyncMode?: string | null;
|
||||||
|
lastSyncSource?: string | null;
|
||||||
|
lastSyncStatus?: string | null;
|
||||||
|
lastSyncError?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GmailMessageSummary {
|
export interface GmailMessageSummary {
|
||||||
|
|||||||
Reference in New Issue
Block a user