diff --git a/Data/JobTrackerContext.cs b/Data/JobTrackerContext.cs index e1f8a9e..c621e56 100644 --- a/Data/JobTrackerContext.cs +++ b/Data/JobTrackerContext.cs @@ -66,6 +66,8 @@ namespace JobTrackerApi.Data modelBuilder.Entity() .HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId); + modelBuilder.Ignore(); + modelBuilder.Entity() .HasIndex(x => new { x.OwnerUserId, x.GmailAddress }) .IsUnique(); diff --git a/JobTrackerApi.Tests/GmailControllerTests.cs b/JobTrackerApi.Tests/GmailControllerTests.cs index 2515e07..35779a5 100644 --- a/JobTrackerApi.Tests/GmailControllerTests.cs +++ b/JobTrackerApi.Tests/GmailControllerTests.cs @@ -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 ", 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(second.Result); @@ -315,7 +321,9 @@ public sealed class GmailControllerTests DateTimeOffset.UtcNow.AddDays(-1), "Snippet 1", "Body text 1", - null)); + null, + Array.Empty(), + Array.Empty())); gmail.Setup(service => service.GetMessageAsync("user-1", "msg-2", It.IsAny())) .ReturnsAsync(new GmailMessageDetail( "msg-2", @@ -326,7 +334,9 @@ public sealed class GmailControllerTests DateTimeOffset.UtcNow, "Snippet 2", "Body text 2", - null)); + null, + Array.Empty(), + Array.Empty())); 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(), + Array.Empty())); var controller = CreateController(db, gmail.Object, "user-1"); var result = await controller.RefreshLinkedThreads(new GmailController.RefreshLinkedThreadsRequest(job.Id), CancellationToken.None); diff --git a/JobTrackerApi/Controllers/CorrespondenceController.cs b/JobTrackerApi/Controllers/CorrespondenceController.cs index c82c50d..a97ab20 100644 --- a/JobTrackerApi/Controllers/CorrespondenceController.cs +++ b/JobTrackerApi/Controllers/CorrespondenceController.cs @@ -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, }; diff --git a/JobTrackerApi/Controllers/GmailController.cs b/JobTrackerApi/Controllers/GmailController.cs index c9f89f1..bc01aac 100644 --- a/JobTrackerApi/Controllers/GmailController.cs +++ b/JobTrackerApi/Controllers/GmailController.cs @@ -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, }; diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index 74c08ad..974aa87 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -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;"); diff --git a/JobTrackerApi/Services/GmailOAuthService.cs b/JobTrackerApi/Services/GmailOAuthService.cs index 01dae06..c6ef0a4 100644 --- a/JobTrackerApi/Services/GmailOAuthService.cs +++ b/JobTrackerApi/Services/GmailOAuthService.cs @@ -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 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 Labels, IReadOnlyList 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().ToList() + : new List(); 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 ReadAttachments(JsonElement payload) + { + var results = new List(); + ReadAttachmentsRecursive(payload, results); + return results; + } + + private static void ReadAttachmentsRecursive(JsonElement payload, List 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) && diff --git a/Models/Correspondence.cs b/Models/Correspondence.cs index 002be6d..7ddc96d 100644 --- a/Models/Correspondence.cs +++ b/Models/Correspondence.cs @@ -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 ExternalLabels => string.IsNullOrWhiteSpace(ExternalLabelsJson) + ? Array.Empty() + : (System.Text.Json.JsonSerializer.Deserialize>(ExternalLabelsJson) ?? new List()); + + [JsonIgnore] + public IReadOnlyList AttachmentMetadata => string.IsNullOrWhiteSpace(AttachmentMetadataJson) + ? Array.Empty() + : (System.Text.Json.JsonSerializer.Deserialize>(AttachmentMetadataJson) ?? new List()); + } + + 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; } } } diff --git a/job-tracker-ui/src/components/Correspondence.tsx b/job-tracker-ui/src/components/Correspondence.tsx index 6ea5066..c51c795 100644 --- a/job-tracker-ui/src/components/Correspondence.tsx +++ b/job-tracker-ui/src/components/Correspondence.tsx @@ -382,11 +382,13 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job {m.subject ? {m.subject} : null} {m.content} - {(m.externalThreadId || m.externalFrom || m.externalTo) ? ( + {(m.externalThreadId || m.externalFrom || m.externalTo || m.externalLabelsJson || m.attachmentMetadataJson) ? ( {m.externalThreadId ? : null} {m.externalFrom ? : null} {m.externalTo ? : null} + {m.externalLabelsJson ? : null} + {m.attachmentMetadataJson ? : null} ) : null} diff --git a/job-tracker-ui/src/types.ts b/job-tracker-ui/src/types.ts index 1166b1b..9c4dad8 100644 --- a/job-tracker-ui/src/types.ts +++ b/job-tracker-ui/src/types.ts @@ -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 {