feat: enrich gmail correspondence metadata
This commit is contained in:
@@ -66,6 +66,8 @@ namespace JobTrackerApi.Data
|
||||
modelBuilder.Entity<GmailConnection>()
|
||||
.HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId);
|
||||
|
||||
modelBuilder.Ignore<CorrespondenceAttachmentMetadata>();
|
||||
|
||||
modelBuilder.Entity<GmailConnection>()
|
||||
.HasIndex(x => new { x.OwnerUserId, x.GmailAddress })
|
||||
.IsUnique();
|
||||
|
||||
@@ -258,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");
|
||||
|
||||
@@ -272,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);
|
||||
@@ -315,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",
|
||||
@@ -326,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" });
|
||||
@@ -398,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);
|
||||
|
||||
@@ -48,10 +48,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 +74,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,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
@@ -414,12 +415,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,
|
||||
};
|
||||
|
||||
@@ -753,6 +753,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.
|
||||
@@ -780,6 +783,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;");
|
||||
|
||||
@@ -879,6 +885,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;");
|
||||
|
||||
@@ -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
|
||||
{
|
||||
@@ -321,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))
|
||||
@@ -345,7 +350,9 @@ 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)
|
||||
@@ -538,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) &&
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }}>
|
||||
{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 }}>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user