feat(email): add safe message detail
This commit is contained in:
@@ -1,9 +1,11 @@
|
|||||||
using JobTrackerApi.Controllers;
|
using JobTrackerApi.Controllers;
|
||||||
using JobTrackerApi.Data;
|
using JobTrackerApi.Data;
|
||||||
using JobTrackerApi.Models;
|
using JobTrackerApi.Models;
|
||||||
|
using JobTrackerApi.Services;
|
||||||
using JobTrackerApi.Tests.TestSupport;
|
using JobTrackerApi.Tests.TestSupport;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Moq;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace JobTrackerApi.Tests;
|
namespace JobTrackerApi.Tests;
|
||||||
@@ -32,4 +34,55 @@ public sealed class CorrespondenceControllerTests
|
|||||||
var stored = await db.Correspondences.SingleAsync();
|
var stored = await db.Correspondences.SingleAsync();
|
||||||
Assert.Equal("manual", stored.Provider);
|
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<JobTrackerContext>().UseInMemoryDatabase(databaseName).Options;
|
||||||
|
var owner = new Mock<ICurrentUserService>();
|
||||||
|
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<OkObjectResult>(ownerResult.Result);
|
||||||
|
var detail = Assert.IsType<EmailMessageDetailDto>(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<OkObjectResult>(inboxResult.Result);
|
||||||
|
var item = Assert.Single(Assert.IsType<List<CorrespondenceController.CorrespondenceInboxItemDto>>(inbox.Value));
|
||||||
|
Assert.Equal(0, item.LabelCount);
|
||||||
|
Assert.Equal(0, item.AttachmentCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
var other = new Mock<ICurrentUserService>();
|
||||||
|
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<NotFoundResult>(otherResult.Result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ public sealed class EmailControllerTests
|
|||||||
var result = await controller.GetMessage("gmail", "message-1", CancellationToken.None);
|
var result = await controller.GetMessage("gmail", "message-1", CancellationToken.None);
|
||||||
|
|
||||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||||
var detail = Assert.IsType<EmailController.MessageDetail>(ok.Value);
|
var detail = Assert.IsType<EmailMessageDetailDto>(ok.Value);
|
||||||
Assert.Equal("Safe plain text", detail.BodyText);
|
Assert.Equal("Safe plain text", detail.BodyText);
|
||||||
Assert.DoesNotContain("script", System.Text.Json.JsonSerializer.Serialize(detail), StringComparison.OrdinalIgnoreCase);
|
Assert.DoesNotContain("script", System.Text.Json.JsonSerializer.Serialize(detail), StringComparison.OrdinalIgnoreCase);
|
||||||
Assert.Equal("user-1", provider.LastOwnerUserId);
|
Assert.Equal("user-1", provider.LastOwnerUserId);
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using JobTrackerApi.Data;
|
using JobTrackerApi.Data;
|
||||||
using JobTrackerApi.Models;
|
using JobTrackerApi.Models;
|
||||||
|
using JobTrackerApi.Services.EmailProviders;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace JobTrackerApi.Controllers
|
namespace JobTrackerApi.Controllers
|
||||||
{
|
{
|
||||||
@@ -42,6 +44,8 @@ namespace JobTrackerApi.Controllers
|
|||||||
DateTime Date,
|
DateTime Date,
|
||||||
string ContentPreview,
|
string ContentPreview,
|
||||||
string? ExternalThreadId,
|
string? ExternalThreadId,
|
||||||
|
string? ExternalMessageId,
|
||||||
|
string? Provider,
|
||||||
string? ExternalFrom,
|
string? ExternalFrom,
|
||||||
string? ExternalTo,
|
string? ExternalTo,
|
||||||
int LabelCount,
|
int LabelCount,
|
||||||
@@ -84,27 +88,50 @@ namespace JobTrackerApi.Controllers
|
|||||||
query = query.Where(c => c.ExternalThreadId == null);
|
query = query.Where(c => c.ExternalThreadId == null);
|
||||||
}
|
}
|
||||||
|
|
||||||
var items = await query
|
var rows = await query
|
||||||
.OrderByDescending(c => c.Date)
|
.OrderByDescending(c => c.Date)
|
||||||
.Take(200)
|
.Take(200)
|
||||||
.Select(c => new CorrespondenceInboxItemDto(
|
.Select(c => new
|
||||||
|
{
|
||||||
c.Id,
|
c.Id,
|
||||||
c.JobApplicationId,
|
c.JobApplicationId,
|
||||||
c.JobApplication.Company != null ? c.JobApplication.Company.Name : null,
|
CompanyName = c.JobApplication.Company != null ? c.JobApplication.Company.Name : null,
|
||||||
c.JobApplication.JobTitle,
|
JobTitle = c.JobApplication.JobTitle,
|
||||||
c.From,
|
c.From,
|
||||||
c.Direction,
|
c.Direction,
|
||||||
c.Subject,
|
c.Subject,
|
||||||
c.Channel,
|
c.Channel,
|
||||||
c.Date,
|
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.ExternalThreadId,
|
||||||
|
c.ExternalMessageId,
|
||||||
|
c.Provider,
|
||||||
c.ExternalFrom,
|
c.ExternalFrom,
|
||||||
c.ExternalTo,
|
c.ExternalTo,
|
||||||
c.ExternalLabelsJson != null ? 1 : 0,
|
c.ExternalLabelsJson,
|
||||||
c.AttachmentMetadataJson != null ? 1 : 0))
|
c.AttachmentMetadataJson,
|
||||||
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.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);
|
return Ok(items);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +150,25 @@ namespace JobTrackerApi.Controllers
|
|||||||
return Ok(messages);
|
return Ok(messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("message/{id:int}")]
|
||||||
|
public async Task<ActionResult<EmailMessageDetailDto>> 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 CreateCorrespondenceRequest(int JobApplicationId, string From, string Content);
|
||||||
public sealed record CreateCorrespondenceRequestV2(
|
public sealed record CreateCorrespondenceRequestV2(
|
||||||
int JobApplicationId,
|
int JobApplicationId,
|
||||||
@@ -186,5 +232,27 @@ namespace JobTrackerApi.Controllers
|
|||||||
await _db.SaveChangesAsync(cancellationToken);
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> DeserializeLabels(string? json)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json)) return Array.Empty<string>();
|
||||||
|
try { return JsonSerializer.Deserialize<List<string>>(json) ?? new List<string>(); }
|
||||||
|
catch (JsonException) { return Array.Empty<string>(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<EmailAttachmentRef> DeserializeAttachments(string? json)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json)) return Array.Empty<EmailAttachmentRef>();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return (JsonSerializer.Deserialize<List<CorrespondenceAttachmentMetadata>>(json) ?? new List<CorrespondenceAttachmentMetadata>())
|
||||||
|
.Select(item => new EmailAttachmentRef(item.FileName, item.MimeType, item.SizeBytes, item.GmailAttachmentId, item.Inline))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return Array.Empty<EmailAttachmentRef>();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,24 +5,24 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
|
|
||||||
namespace JobTrackerApi.Controllers;
|
namespace JobTrackerApi.Controllers;
|
||||||
|
|
||||||
|
public sealed record EmailMessageDetailDto(
|
||||||
|
string Id,
|
||||||
|
string ThreadId,
|
||||||
|
string Subject,
|
||||||
|
string From,
|
||||||
|
string To,
|
||||||
|
DateTimeOffset? Date,
|
||||||
|
string Snippet,
|
||||||
|
string BodyText,
|
||||||
|
IReadOnlyList<string> Labels,
|
||||||
|
IReadOnlyList<EmailAttachmentRef> Attachments);
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/email")]
|
[Route("api/email")]
|
||||||
[Authorize(AuthenticationSchemes = "local")]
|
[Authorize(AuthenticationSchemes = "local")]
|
||||||
public sealed class EmailController(IEmailProviderRegistry providers) : ControllerBase
|
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 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<string> Labels,
|
|
||||||
IReadOnlyList<EmailAttachmentRef> Attachments);
|
|
||||||
|
|
||||||
[HttpGet("providers")]
|
[HttpGet("providers")]
|
||||||
public async Task<ActionResult<IReadOnlyList<ProviderStatus>>> GetProviders(CancellationToken cancellationToken)
|
public async Task<ActionResult<IReadOnlyList<ProviderStatus>>> GetProviders(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -82,7 +82,7 @@ public sealed class EmailController(IEmailProviderRegistry providers) : Controll
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("message")]
|
[HttpGet("message")]
|
||||||
public async Task<ActionResult<MessageDetail>> GetMessage(
|
public async Task<ActionResult<EmailMessageDetailDto>> GetMessage(
|
||||||
[FromQuery] string provider,
|
[FromQuery] string provider,
|
||||||
[FromQuery] string messageId,
|
[FromQuery] string messageId,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@@ -97,7 +97,7 @@ public sealed class EmailController(IEmailProviderRegistry providers) : Controll
|
|||||||
return Conflict(new ProblemDetails { Title = "Email provider is not connected." });
|
return Conflict(new ProblemDetails { Title = "Email provider is not connected." });
|
||||||
|
|
||||||
var detail = await resolved.GetMessageAsync(ownerUserId, messageId.Trim(), cancellationToken);
|
var detail = await resolved.GetMessageAsync(ownerUserId, messageId.Trim(), cancellationToken);
|
||||||
return Ok(new MessageDetail(
|
return Ok(new EmailMessageDetailDto(
|
||||||
detail.Id,
|
detail.Id,
|
||||||
detail.ThreadId,
|
detail.ThreadId,
|
||||||
detail.Subject,
|
detail.Subject,
|
||||||
|
|||||||
@@ -53,12 +53,18 @@ describe('CorrespondenceInboxPage', () => {
|
|||||||
date: new Date().toISOString(),
|
date: new Date().toISOString(),
|
||||||
contentPreview: 'We would like to schedule an interview.',
|
contentPreview: 'We would like to schedule an interview.',
|
||||||
externalThreadId: 'thread-1',
|
externalThreadId: 'thread-1',
|
||||||
|
externalMessageId: 'message-1',
|
||||||
|
provider: 'gmail',
|
||||||
externalFrom: 'Maria Recruiter <maria@acme.test>',
|
externalFrom: 'Maria Recruiter <maria@acme.test>',
|
||||||
externalTo: 'user@example.test',
|
externalTo: 'user@example.test',
|
||||||
labelCount: 2,
|
labelCount: 2,
|
||||||
attachmentCount: 1,
|
attachmentCount: 1,
|
||||||
},
|
},
|
||||||
] } as any);
|
] } as any);
|
||||||
|
if (url === '/email/message') return Promise.resolve({ data: {
|
||||||
|
id: 'message-1', threadId: 'thread-1', subject: 'Interview invite', from: 'Maria Recruiter <maria@acme.test>', 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: {
|
if (url === '/gmail/review-candidates') return Promise.resolve({ data: {
|
||||||
queries: [], candidateThreadCount: 0, autoLinkThreadCount: 0, reviewThreadCount: 0, unmatchedThreadCount: 0, threads: [],
|
queries: [], candidateThreadCount: 0, autoLinkThreadCount: 0, reviewThreadCount: 0, unmatchedThreadCount: 0, threads: [],
|
||||||
} } as any);
|
} } 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 () => {
|
test('uses one hub for linked messages and recruitment suggestions', async () => {
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
|
|||||||
@@ -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 { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
|
Alert,
|
||||||
Chip,
|
Chip,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -31,6 +32,8 @@ export type CorrespondenceInboxItem = {
|
|||||||
date: string;
|
date: string;
|
||||||
contentPreview: string;
|
contentPreview: string;
|
||||||
externalThreadId?: string | null;
|
externalThreadId?: string | null;
|
||||||
|
externalMessageId?: string | null;
|
||||||
|
provider?: string | null;
|
||||||
externalFrom?: string | null;
|
externalFrom?: string | null;
|
||||||
externalTo?: string | null;
|
externalTo?: string | null;
|
||||||
labelCount: number;
|
labelCount: number;
|
||||||
@@ -46,6 +49,19 @@ type EmailProviderStatus = {
|
|||||||
canSend: boolean;
|
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() {
|
export default function CorrespondenceInboxPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
@@ -53,6 +69,12 @@ export default function CorrespondenceInboxPage() {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [items, setItems] = useState<CorrespondenceInboxItem[]>([]);
|
const [items, setItems] = useState<CorrespondenceInboxItem[]>([]);
|
||||||
const [providers, setProviders] = useState<EmailProviderStatus[]>([]);
|
const [providers, setProviders] = useState<EmailProviderStatus[]>([]);
|
||||||
|
const [selectedMessageId, setSelectedMessageId] = useState<number | null>(null);
|
||||||
|
const [messageDetail, setMessageDetail] = useState<EmailMessageDetail | null>(null);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
const [detailError, setDetailError] = useState<string | null>(null);
|
||||||
|
const [detailNotice, setDetailNotice] = useState<string | null>(null);
|
||||||
|
const detailRequest = useRef(0);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [direction, setDirection] = useState<string>("all");
|
const [direction, setDirection] = useState<string>("all");
|
||||||
@@ -92,6 +114,46 @@ export default function CorrespondenceInboxPage() {
|
|||||||
return { linked, inbound };
|
return { linked, inbound };
|
||||||
}, [items]);
|
}, [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<EmailMessageDetail>("/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<EmailMessageDetail>(`/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 (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
sx={{
|
sx={{
|
||||||
@@ -172,12 +234,30 @@ export default function CorrespondenceInboxPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", justifyContent: "flex-end" }}>
|
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", justifyContent: "flex-end" }}>
|
||||||
{item.direction ? <Chip size="small" label={item.direction} variant="outlined" /> : null}
|
{item.direction ? <Chip size="small" label={item.direction} variant="outlined" /> : null}
|
||||||
|
{item.provider ? <Chip size="small" label={item.provider === "microsoft" ? "Outlook" : item.provider === "gmail" ? "Gmail" : item.provider.toUpperCase()} variant="outlined" /> : null}
|
||||||
{item.externalThreadId ? <Chip size="small" label={`Thread ${item.externalThreadId}`} color="success" variant="outlined" /> : <Chip size="small" label="Manual/internal" variant="outlined" />}
|
{item.externalThreadId ? <Chip size="small" label={`Thread ${item.externalThreadId}`} color="success" variant="outlined" /> : <Chip size="small" label="Manual/internal" variant="outlined" />}
|
||||||
{item.labelCount > 0 ? <Chip size="small" label={`${item.labelCount} labels`} variant="outlined" /> : null}
|
{item.labelCount > 0 ? <Chip size="small" label={`${item.labelCount} labels`} variant="outlined" /> : null}
|
||||||
{item.attachmentCount > 0 ? <Chip size="small" label={`${item.attachmentCount} attachments`} variant="outlined" /> : null}
|
{item.attachmentCount > 0 ? <Chip size="small" label={`${item.attachmentCount} attachments`} variant="outlined" /> : null}
|
||||||
|
<Button size="small" variant="text" onClick={() => void showMessage(item)}>{selectedMessageId === item.id ? "Hide message" : "View message"}</Button>
|
||||||
<Button size="small" variant="text" onClick={() => navigate(`/jobs?open=${item.jobApplicationId}`)}>Open job</Button>
|
<Button size="small" variant="text" onClick={() => navigate(`/jobs?open=${item.jobApplicationId}`)}>Open job</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
{selectedMessageId === item.id ? (
|
||||||
|
<Box sx={{ mt: 1.5, pt: 1.5, borderTop: "1px solid", borderColor: "divider" }}>
|
||||||
|
{detailLoading ? <Box sx={{ display: "flex", gap: 1, alignItems: "center" }}><CircularProgress size={18} /><Typography variant="body2">Loading message…</Typography></Box> : null}
|
||||||
|
{detailNotice ? <Alert severity="warning" sx={{ mb: 1 }}>{detailNotice}</Alert> : null}
|
||||||
|
{detailError ? <Alert severity="error">{detailError}</Alert> : null}
|
||||||
|
{messageDetail ? <>
|
||||||
|
<Typography sx={{ fontWeight: 800 }}>{messageDetail.subject || "No subject"}</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mb: 1 }}>{messageDetail.from}{messageDetail.to ? ` → ${messageDetail.to}` : ""}</Typography>
|
||||||
|
<Typography sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{messageDetail.bodyText}</Typography>
|
||||||
|
{(messageDetail.labels.length > 0 || messageDetail.attachments.length > 0) ? <Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 1 }}>
|
||||||
|
{messageDetail.labels.map((label, index) => <Chip key={`${label}-${index}`} size="small" label={label} variant="outlined" />)}
|
||||||
|
{messageDetail.attachments.map((attachment, index) => <Chip key={`${attachment.fileName || "attachment"}-${index}`} size="small" label={attachment.fileName || "Attachment"} variant="outlined" />)}
|
||||||
|
</Box> : null}
|
||||||
|
</> : null}
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
</Paper>
|
</Paper>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
Reference in New Issue
Block a user