From a20775c24a13c771363613c4f1d3fc3a1386c6cb Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 9 Aug 2026 21:44:24 +0200 Subject: [PATCH] feat(email): add safe message detail --- .../CorrespondenceControllerTests.cs | 53 ++++++++++++ JobTrackerApi.Tests/EmailControllerTests.cs | 2 +- .../Controllers/CorrespondenceController.cs | 82 +++++++++++++++++-- JobTrackerApi/Controllers/EmailController.cs | 28 +++---- .../src/correspondence-inbox-page.test.tsx | 38 +++++++++ .../src/views/CorrespondenceInboxPage.tsx | 82 ++++++++++++++++++- 6 files changed, 262 insertions(+), 23 deletions(-) diff --git a/JobTrackerApi.Tests/CorrespondenceControllerTests.cs b/JobTrackerApi.Tests/CorrespondenceControllerTests.cs index e28f9e9..1cfdfa8 100644 --- a/JobTrackerApi.Tests/CorrespondenceControllerTests.cs +++ b/JobTrackerApi.Tests/CorrespondenceControllerTests.cs @@ -1,9 +1,11 @@ using JobTrackerApi.Controllers; using JobTrackerApi.Data; using JobTrackerApi.Models; +using JobTrackerApi.Services; using JobTrackerApi.Tests.TestSupport; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Moq; using Xunit; namespace JobTrackerApi.Tests; @@ -32,4 +34,55 @@ public sealed class CorrespondenceControllerTests var stored = await db.Correspondences.SingleAsync(); Assert.Equal("manual", stored.Provider); } + + [Fact] + public async Task Message_detail_is_owner_scoped_and_tolerates_bad_metadata() + { + var databaseName = Guid.NewGuid().ToString(); + var options = new DbContextOptionsBuilder().UseInMemoryDatabase(databaseName).Options; + var owner = new Mock(); + owner.SetupGet(service => service.UserId).Returns("user-1"); + + int messageId; + await using (var ownerDb = new JobTrackerContext(options, owner.Object)) + { + var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; + ownerDb.Companies.Add(company); + await ownerDb.SaveChangesAsync(); + var job = new JobApplication { JobTitle = "Backend Developer", CompanyId = company.Id, OwnerUserId = "user-1" }; + ownerDb.JobApplications.Add(job); + await ownerDb.SaveChangesAsync(); + var message = new Correspondence + { + JobApplicationId = job.Id, + From = "Company", + Subject = "Interview", + Content = "Safe saved copy", + ExternalLabelsJson = "not-json", + AttachmentMetadataJson = "also-not-json" + }; + ownerDb.Correspondences.Add(message); + await ownerDb.SaveChangesAsync(); + messageId = message.Id; + + var ownerResult = await new CorrespondenceController(ownerDb).GetMessage(messageId, CancellationToken.None); + var ok = Assert.IsType(ownerResult.Result); + var detail = Assert.IsType(ok.Value); + Assert.Equal("Safe saved copy", detail.BodyText); + Assert.Empty(detail.Labels); + Assert.Empty(detail.Attachments); + + var inboxResult = await new CorrespondenceController(ownerDb).GetInbox(null, null, null, CancellationToken.None); + var inbox = Assert.IsType(inboxResult.Result); + var item = Assert.Single(Assert.IsType>(inbox.Value)); + Assert.Equal(0, item.LabelCount); + Assert.Equal(0, item.AttachmentCount); + } + + var other = new Mock(); + other.SetupGet(service => service.UserId).Returns("user-2"); + await using var otherDb = new JobTrackerContext(options, other.Object); + var otherResult = await new CorrespondenceController(otherDb).GetMessage(messageId, CancellationToken.None); + Assert.IsType(otherResult.Result); + } } diff --git a/JobTrackerApi.Tests/EmailControllerTests.cs b/JobTrackerApi.Tests/EmailControllerTests.cs index 28ba67b..ab132bb 100644 --- a/JobTrackerApi.Tests/EmailControllerTests.cs +++ b/JobTrackerApi.Tests/EmailControllerTests.cs @@ -59,7 +59,7 @@ public sealed class EmailControllerTests var result = await controller.GetMessage("gmail", "message-1", CancellationToken.None); var ok = Assert.IsType(result.Result); - var detail = Assert.IsType(ok.Value); + var detail = Assert.IsType(ok.Value); Assert.Equal("Safe plain text", detail.BodyText); Assert.DoesNotContain("script", System.Text.Json.JsonSerializer.Serialize(detail), StringComparison.OrdinalIgnoreCase); Assert.Equal("user-1", provider.LastOwnerUserId); diff --git a/JobTrackerApi/Controllers/CorrespondenceController.cs b/JobTrackerApi/Controllers/CorrespondenceController.cs index 7db5e29..69c8da0 100644 --- a/JobTrackerApi/Controllers/CorrespondenceController.cs +++ b/JobTrackerApi/Controllers/CorrespondenceController.cs @@ -3,6 +3,8 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using JobTrackerApi.Data; using JobTrackerApi.Models; +using JobTrackerApi.Services.EmailProviders; +using System.Text.Json; namespace JobTrackerApi.Controllers { @@ -42,6 +44,8 @@ namespace JobTrackerApi.Controllers DateTime Date, string ContentPreview, string? ExternalThreadId, + string? ExternalMessageId, + string? Provider, string? ExternalFrom, string? ExternalTo, int LabelCount, @@ -84,27 +88,50 @@ namespace JobTrackerApi.Controllers query = query.Where(c => c.ExternalThreadId == null); } - var items = await query + var rows = await query .OrderByDescending(c => c.Date) .Take(200) - .Select(c => new CorrespondenceInboxItemDto( + .Select(c => new + { c.Id, c.JobApplicationId, - c.JobApplication.Company != null ? c.JobApplication.Company.Name : null, - c.JobApplication.JobTitle, + CompanyName = c.JobApplication.Company != null ? c.JobApplication.Company.Name : null, + JobTitle = c.JobApplication.JobTitle, c.From, c.Direction, c.Subject, c.Channel, c.Date, - c.Content.Length <= 220 ? c.Content : c.Content.Substring(0, 220), + ContentPreview = c.Content.Length <= 220 ? c.Content : c.Content.Substring(0, 220), c.ExternalThreadId, + c.ExternalMessageId, + c.Provider, c.ExternalFrom, c.ExternalTo, - c.ExternalLabelsJson != null ? 1 : 0, - c.AttachmentMetadataJson != null ? 1 : 0)) + c.ExternalLabelsJson, + c.AttachmentMetadataJson, + }) .ToListAsync(cancellationToken); + var items = rows.Select(c => new CorrespondenceInboxItemDto( + c.Id, + c.JobApplicationId, + c.CompanyName, + c.JobTitle, + c.From, + c.Direction, + c.Subject, + c.Channel, + c.Date, + c.ContentPreview, + c.ExternalThreadId, + c.ExternalMessageId, + c.Provider, + c.ExternalFrom, + c.ExternalTo, + DeserializeLabels(c.ExternalLabelsJson).Count, + DeserializeAttachments(c.AttachmentMetadataJson).Count)).ToList(); + return Ok(items); } @@ -123,6 +150,25 @@ namespace JobTrackerApi.Controllers return Ok(messages); } + [HttpGet("message/{id:int}")] + public async Task> GetMessage([FromRoute] int id, CancellationToken cancellationToken) + { + var message = await FindOwnedMessageAsync(id, cancellationToken); + if (message is null) return NotFound(); + + return Ok(new EmailMessageDetailDto( + message.ExternalMessageId ?? $"correspondence-{message.Id}", + message.ExternalThreadId ?? string.Empty, + message.Subject ?? string.Empty, + message.ExternalFrom ?? message.From, + message.ExternalTo ?? string.Empty, + new DateTimeOffset(DateTime.SpecifyKind(message.Date, DateTimeKind.Local)), + message.Content.Length <= 220 ? message.Content : message.Content[..220], + message.Content, + DeserializeLabels(message.ExternalLabelsJson), + DeserializeAttachments(message.AttachmentMetadataJson))); + } + public sealed record CreateCorrespondenceRequest(int JobApplicationId, string From, string Content); public sealed record CreateCorrespondenceRequestV2( int JobApplicationId, @@ -186,5 +232,27 @@ namespace JobTrackerApi.Controllers await _db.SaveChangesAsync(cancellationToken); return NoContent(); } + + private static IReadOnlyList DeserializeLabels(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return Array.Empty(); + try { return JsonSerializer.Deserialize>(json) ?? new List(); } + catch (JsonException) { return Array.Empty(); } + } + + private static IReadOnlyList DeserializeAttachments(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return Array.Empty(); + try + { + return (JsonSerializer.Deserialize>(json) ?? new List()) + .Select(item => new EmailAttachmentRef(item.FileName, item.MimeType, item.SizeBytes, item.GmailAttachmentId, item.Inline)) + .ToList(); + } + catch (JsonException) + { + return Array.Empty(); + } + } } } diff --git a/JobTrackerApi/Controllers/EmailController.cs b/JobTrackerApi/Controllers/EmailController.cs index 65800ab..22a1764 100644 --- a/JobTrackerApi/Controllers/EmailController.cs +++ b/JobTrackerApi/Controllers/EmailController.cs @@ -5,24 +5,24 @@ using Microsoft.AspNetCore.Mvc; namespace JobTrackerApi.Controllers; +public sealed record EmailMessageDetailDto( + string Id, + string ThreadId, + string Subject, + string From, + string To, + DateTimeOffset? Date, + string Snippet, + string BodyText, + IReadOnlyList Labels, + IReadOnlyList Attachments); + [ApiController] [Route("api/email")] [Authorize(AuthenticationSchemes = "local")] public sealed class EmailController(IEmailProviderRegistry providers) : ControllerBase { public sealed record ProviderStatus(string Provider, string DisplayName, bool Connected, string? Address, bool CanRead, bool CanSend); - public sealed record MessageDetail( - string Id, - string ThreadId, - string Subject, - string From, - string To, - DateTimeOffset? Date, - string Snippet, - string BodyText, - IReadOnlyList Labels, - IReadOnlyList Attachments); - [HttpGet("providers")] public async Task>> GetProviders(CancellationToken cancellationToken) { @@ -82,7 +82,7 @@ public sealed class EmailController(IEmailProviderRegistry providers) : Controll } [HttpGet("message")] - public async Task> GetMessage( + public async Task> GetMessage( [FromQuery] string provider, [FromQuery] string messageId, CancellationToken cancellationToken) @@ -97,7 +97,7 @@ public sealed class EmailController(IEmailProviderRegistry providers) : Controll return Conflict(new ProblemDetails { Title = "Email provider is not connected." }); var detail = await resolved.GetMessageAsync(ownerUserId, messageId.Trim(), cancellationToken); - return Ok(new MessageDetail( + return Ok(new EmailMessageDetailDto( detail.Id, detail.ThreadId, detail.Subject, diff --git a/job-tracker-ui/src/correspondence-inbox-page.test.tsx b/job-tracker-ui/src/correspondence-inbox-page.test.tsx index 8288a55..4f2985b 100644 --- a/job-tracker-ui/src/correspondence-inbox-page.test.tsx +++ b/job-tracker-ui/src/correspondence-inbox-page.test.tsx @@ -53,12 +53,18 @@ describe('CorrespondenceInboxPage', () => { date: new Date().toISOString(), contentPreview: 'We would like to schedule an interview.', externalThreadId: 'thread-1', + externalMessageId: 'message-1', + provider: 'gmail', externalFrom: 'Maria Recruiter ', externalTo: 'user@example.test', labelCount: 2, attachmentCount: 1, }, ] } as any); + if (url === '/email/message') return Promise.resolve({ data: { + id: 'message-1', threadId: 'thread-1', subject: 'Interview invite', from: 'Maria Recruiter ', to: 'user@example.test', + date: new Date().toISOString(), snippet: 'Interview', bodyText: 'Please choose an interview time.', labels: ['INBOX'], attachments: [{ fileName: 'agenda.pdf' }], + } } as any); if (url === '/gmail/review-candidates') return Promise.resolve({ data: { queries: [], candidateThreadCount: 0, autoLinkThreadCount: 0, reviewThreadCount: 0, unmatchedThreadCount: 0, threads: [], } } as any); @@ -97,6 +103,38 @@ describe('CorrespondenceInboxPage', () => { }); }); + test('opens provider-backed plain-text message detail in the hub', async () => { + renderPage(); + + fireEvent.click(await screen.findByRole('button', { name: /view message/i })); + + expect(await screen.findByText(/please choose an interview time/i)).toBeInTheDocument(); + expect(screen.getByText('INBOX')).toBeInTheDocument(); + expect(screen.getByText('agenda.pdf')).toBeInTheDocument(); + expect(mockedApi.get).toHaveBeenCalledWith('/email/message', { + params: { provider: 'gmail', messageId: 'message-1' }, + }); + }); + + test('falls back to the saved copy when provider detail is unavailable', async () => { + const original = mockedApi.get.getMockImplementation(); + mockedApi.get.mockImplementation((url: string, config?: any) => { + if (url === '/email/message') return Promise.reject(new Error('reauthorization required')); + if (url === '/correspondence/message/1') return Promise.resolve({ data: { + id: 'message-1', threadId: 'thread-1', subject: 'Interview invite', from: 'Maria Recruiter', to: 'user@example.test', + date: new Date().toISOString(), snippet: 'Saved', bodyText: 'Saved interview message.', labels: [], attachments: [], + } } as any); + return original!(url, config); + }); + + renderPage(); + fireEvent.click(await screen.findByRole('button', { name: /view message/i })); + + expect(await screen.findByText(/showing the saved jobtracker copy/i)).toBeInTheDocument(); + expect(await screen.findByText(/saved interview message/i)).toBeInTheDocument(); + expect(mockedApi.get).toHaveBeenCalledWith('/correspondence/message/1'); + }); + test('uses one hub for linked messages and recruitment suggestions', async () => { renderPage(); diff --git a/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx b/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx index e379684..fc5b26b 100644 --- a/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx +++ b/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx @@ -1,7 +1,8 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { Box, + Alert, Chip, CircularProgress, FormControl, @@ -31,6 +32,8 @@ export type CorrespondenceInboxItem = { date: string; contentPreview: string; externalThreadId?: string | null; + externalMessageId?: string | null; + provider?: string | null; externalFrom?: string | null; externalTo?: string | null; labelCount: number; @@ -46,6 +49,19 @@ type EmailProviderStatus = { canSend: boolean; }; +type EmailMessageDetail = { + id: string; + threadId: string; + subject: string; + from: string; + to: string; + date?: string | null; + snippet: string; + bodyText: string; + labels: string[]; + attachments: Array<{ fileName?: string | null; mimeType?: string | null; sizeBytes?: number | null }>; +}; + export default function CorrespondenceInboxPage() { const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); @@ -53,6 +69,12 @@ export default function CorrespondenceInboxPage() { const { toast } = useToast(); const [items, setItems] = useState([]); const [providers, setProviders] = useState([]); + const [selectedMessageId, setSelectedMessageId] = useState(null); + const [messageDetail, setMessageDetail] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const [detailError, setDetailError] = useState(null); + const [detailNotice, setDetailNotice] = useState(null); + const detailRequest = useRef(0); const [loading, setLoading] = useState(false); const [query, setQuery] = useState(""); const [direction, setDirection] = useState("all"); @@ -92,6 +114,46 @@ export default function CorrespondenceInboxPage() { return { linked, inbound }; }, [items]); + const showMessage = async (item: CorrespondenceInboxItem) => { + if (selectedMessageId === item.id) { + detailRequest.current += 1; + setSelectedMessageId(null); + setMessageDetail(null); + return; + } + + const request = ++detailRequest.current; + setSelectedMessageId(item.id); + setMessageDetail(null); + setDetailError(null); + setDetailNotice(null); + setDetailLoading(true); + try { + if (item.provider && item.provider !== "manual" && item.externalMessageId) { + try { + const live = await api.get("/email/message", { + params: { provider: item.provider, messageId: item.externalMessageId }, + }); + if (request !== detailRequest.current) return; + setMessageDetail(live.data); + return; + } catch { + if (request !== detailRequest.current) return; + setDetailNotice("The provider copy is unavailable. Showing the saved JobTracker copy."); + } + } + + const saved = await api.get(`/correspondence/message/${item.id}`); + if (request !== detailRequest.current) return; + setMessageDetail(saved.data); + } catch (error) { + if (request !== detailRequest.current) return; + setDetailError(getApiErrorMessage(error, "Failed to load this message.")); + } finally { + if (request === detailRequest.current) setDetailLoading(false); + } + }; + return ( {item.direction ? : null} + {item.provider ? : null} {item.externalThreadId ? : } {item.labelCount > 0 ? : null} {item.attachmentCount > 0 ? : null} + + {selectedMessageId === item.id ? ( + + {detailLoading ? Loading message… : null} + {detailNotice ? {detailNotice} : null} + {detailError ? {detailError} : null} + {messageDetail ? <> + {messageDetail.subject || "No subject"} + {messageDetail.from}{messageDetail.to ? ` → ${messageDetail.to}` : ""} + {messageDetail.bodyText} + {(messageDetail.labels.length > 0 || messageDetail.attachments.length > 0) ? + {messageDetail.labels.map((label, index) => )} + {messageDetail.attachments.map((attachment, index) => )} + : null} + : null} + + ) : null} ))}