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
@@ -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) &&