feat: enrich gmail correspondence metadata

This commit is contained in:
2026-04-01 16:27:34 +02:00
parent e5bcf9d5ea
commit f48136f04c
9 changed files with 133 additions and 8 deletions
+2
View File
@@ -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();
+16 -4
View File
@@ -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,
};
+9
View File
@@ -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;");
+51 -2
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
{
@@ -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) &&
+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; }
}
}
@@ -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 }}>
+11
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 {