feat(email): expose provider-neutral reads
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
using System.Security.Claims;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Services.EmailProviders;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class EmailControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Provider_status_is_owner_scoped_and_does_not_claim_send_support()
|
||||
{
|
||||
var gmail = new FakeProvider("gmail", new EmailConnectionInfo("gmail", "owner@gmail.test"));
|
||||
var outlook = new FakeProvider("microsoft", null);
|
||||
var controller = CreateController(gmail, outlook);
|
||||
|
||||
var result = await controller.GetProviders(CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var statuses = Assert.IsAssignableFrom<IReadOnlyList<EmailController.ProviderStatus>>(ok.Value);
|
||||
Assert.Equal("user-1", gmail.LastOwnerUserId);
|
||||
Assert.Collection(statuses,
|
||||
status =>
|
||||
{
|
||||
Assert.Equal("Gmail", status.DisplayName);
|
||||
Assert.True(status.Connected);
|
||||
Assert.True(status.CanRead);
|
||||
Assert.False(status.CanSend);
|
||||
},
|
||||
status =>
|
||||
{
|
||||
Assert.Equal("Outlook", status.DisplayName);
|
||||
Assert.False(status.Connected);
|
||||
Assert.False(status.CanRead);
|
||||
Assert.False(status.CanSend);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Search_rejects_unknown_or_disconnected_provider()
|
||||
{
|
||||
var controller = CreateController(new FakeProvider("gmail", null));
|
||||
|
||||
var unknown = await controller.Search("unknown", null, 25, CancellationToken.None);
|
||||
Assert.IsType<BadRequestObjectResult>(unknown.Result);
|
||||
|
||||
var disconnected = await controller.Search("gmail", null, 25, CancellationToken.None);
|
||||
Assert.IsType<ConflictObjectResult>(disconnected.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Message_detail_returns_plain_text_without_provider_html()
|
||||
{
|
||||
var provider = new FakeProvider("gmail", new EmailConnectionInfo("gmail", "owner@gmail.test"));
|
||||
var controller = CreateController(provider);
|
||||
|
||||
var result = await controller.GetMessage("gmail", "message-1", CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var detail = Assert.IsType<EmailController.MessageDetail>(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);
|
||||
}
|
||||
|
||||
private static EmailController CreateController(params IEmailProvider[] providers)
|
||||
{
|
||||
var controller = new EmailController(new EmailProviderRegistry(providers));
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(
|
||||
new[] { new Claim(ClaimTypes.NameIdentifier, "user-1") },
|
||||
"test"))
|
||||
}
|
||||
};
|
||||
return controller;
|
||||
}
|
||||
|
||||
private sealed class FakeProvider(string providerKey, EmailConnectionInfo? connection) : IEmailProvider
|
||||
{
|
||||
public string ProviderKey => providerKey;
|
||||
public string? LastOwnerUserId { get; private set; }
|
||||
|
||||
public Task<EmailConnectionInfo?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
LastOwnerUserId = ownerUserId;
|
||||
return Task.FromResult(connection);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<EmailMessageSummary>> SearchAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlyList<EmailMessageSummary>>(Array.Empty<EmailMessageSummary>());
|
||||
|
||||
public Task<IReadOnlyList<EmailMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlyList<EmailMessageSummary>>(Array.Empty<EmailMessageSummary>());
|
||||
|
||||
public Task<EmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken)
|
||||
{
|
||||
LastOwnerUserId = ownerUserId;
|
||||
return Task.FromResult(new EmailMessageDetail(
|
||||
messageId,
|
||||
"thread-1",
|
||||
"Interview",
|
||||
"recruiter@example.test",
|
||||
"owner@gmail.test",
|
||||
DateTimeOffset.UtcNow,
|
||||
"Snippet",
|
||||
"Safe plain text",
|
||||
"<script>unsafe()</script>",
|
||||
Array.Empty<string>(),
|
||||
Array.Empty<EmailAttachmentRef>()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Security.Claims;
|
||||
using JobTrackerApi.Services.EmailProviders;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace JobTrackerApi.Controllers;
|
||||
|
||||
[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<string> Labels,
|
||||
IReadOnlyList<EmailAttachmentRef> Attachments);
|
||||
|
||||
[HttpGet("providers")]
|
||||
public async Task<ActionResult<IReadOnlyList<ProviderStatus>>> GetProviders(CancellationToken cancellationToken)
|
||||
{
|
||||
var ownerUserId = GetOwnerUserId();
|
||||
if (ownerUserId is null) return Unauthorized();
|
||||
|
||||
var statuses = new List<ProviderStatus>(providers.All.Count);
|
||||
foreach (var provider in providers.All)
|
||||
{
|
||||
var connection = await provider.GetConnectionAsync(ownerUserId, cancellationToken);
|
||||
statuses.Add(new ProviderStatus(
|
||||
provider.ProviderKey,
|
||||
GetDisplayName(provider.ProviderKey),
|
||||
connection is not null,
|
||||
connection?.Address,
|
||||
CanRead: connection is not null,
|
||||
CanSend: false));
|
||||
}
|
||||
|
||||
return Ok(statuses);
|
||||
}
|
||||
|
||||
[HttpGet("messages")]
|
||||
public async Task<ActionResult<IReadOnlyList<EmailMessageSummary>>> Search(
|
||||
[FromQuery] string provider,
|
||||
[FromQuery] string? q,
|
||||
[FromQuery] int limit = 25,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var resolved = providers.Get(provider);
|
||||
if (resolved is null) return BadRequest("Unknown email provider.");
|
||||
|
||||
var ownerUserId = GetOwnerUserId();
|
||||
if (ownerUserId is null) return Unauthorized();
|
||||
if (await resolved.GetConnectionAsync(ownerUserId, cancellationToken) is null)
|
||||
return Conflict(new ProblemDetails { Title = "Email provider is not connected." });
|
||||
|
||||
return Ok(await resolved.SearchAsync(ownerUserId, q, Math.Clamp(limit, 1, 100), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("thread")]
|
||||
public async Task<ActionResult<IReadOnlyList<EmailMessageSummary>>> GetThread(
|
||||
[FromQuery] string provider,
|
||||
[FromQuery] string threadId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(threadId)) return BadRequest("threadId is required.");
|
||||
var resolved = providers.Get(provider);
|
||||
if (resolved is null) return BadRequest("Unknown email provider.");
|
||||
|
||||
var ownerUserId = GetOwnerUserId();
|
||||
if (ownerUserId is null) return Unauthorized();
|
||||
if (await resolved.GetConnectionAsync(ownerUserId, cancellationToken) is null)
|
||||
return Conflict(new ProblemDetails { Title = "Email provider is not connected." });
|
||||
|
||||
return Ok(await resolved.ListThreadMessagesAsync(ownerUserId, threadId.Trim(), cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("message")]
|
||||
public async Task<ActionResult<MessageDetail>> GetMessage(
|
||||
[FromQuery] string provider,
|
||||
[FromQuery] string messageId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(messageId)) return BadRequest("messageId is required.");
|
||||
var resolved = providers.Get(provider);
|
||||
if (resolved is null) return BadRequest("Unknown email provider.");
|
||||
|
||||
var ownerUserId = GetOwnerUserId();
|
||||
if (ownerUserId is null) return Unauthorized();
|
||||
if (await resolved.GetConnectionAsync(ownerUserId, cancellationToken) is null)
|
||||
return Conflict(new ProblemDetails { Title = "Email provider is not connected." });
|
||||
|
||||
var detail = await resolved.GetMessageAsync(ownerUserId, messageId.Trim(), cancellationToken);
|
||||
return Ok(new MessageDetail(
|
||||
detail.Id,
|
||||
detail.ThreadId,
|
||||
detail.Subject,
|
||||
detail.From,
|
||||
detail.To,
|
||||
detail.Date,
|
||||
detail.Snippet,
|
||||
detail.BodyText,
|
||||
detail.Labels,
|
||||
detail.Attachments));
|
||||
}
|
||||
|
||||
private string? GetOwnerUserId() =>
|
||||
User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
|
||||
|
||||
private static string GetDisplayName(string provider) => provider.ToLowerInvariant() switch
|
||||
{
|
||||
"gmail" => "Gmail",
|
||||
"microsoft" => "Outlook",
|
||||
"imap" => "IMAP",
|
||||
_ => provider,
|
||||
};
|
||||
}
|
||||
@@ -36,6 +36,10 @@ function renderPage() {
|
||||
describe('CorrespondenceInboxPage', () => {
|
||||
beforeEach(() => {
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url === '/email/providers') return Promise.resolve({ data: [
|
||||
{ provider: 'gmail', displayName: 'Gmail', connected: true, address: 'owner@gmail.test', canRead: true, canSend: false },
|
||||
{ provider: 'microsoft', displayName: 'Outlook', connected: false, address: null, canRead: false, canSend: false },
|
||||
] } as any);
|
||||
if (url === '/correspondence') return Promise.resolve({ data: [
|
||||
{
|
||||
id: 1,
|
||||
@@ -76,6 +80,8 @@ describe('CorrespondenceInboxPage', () => {
|
||||
expect(await screen.findByText(/backend engineer/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/2 labels/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 attachments/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Gmail: owner@gmail\.test · Read only/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Outlook: Not connected · Read only/i)).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/search/i), { target: { value: 'Maria' } });
|
||||
fireEvent.mouseDown(screen.getAllByRole('combobox')[0]);
|
||||
|
||||
@@ -37,12 +37,22 @@ export type CorrespondenceInboxItem = {
|
||||
attachmentCount: number;
|
||||
};
|
||||
|
||||
type EmailProviderStatus = {
|
||||
provider: string;
|
||||
displayName: string;
|
||||
connected: boolean;
|
||||
address?: string | null;
|
||||
canRead: boolean;
|
||||
canSend: boolean;
|
||||
};
|
||||
|
||||
export default function CorrespondenceInboxPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const view = searchParams.get("view") === "review" ? "review" : "inbox";
|
||||
const { toast } = useToast();
|
||||
const [items, setItems] = useState<CorrespondenceInboxItem[]>([]);
|
||||
const [providers, setProviders] = useState<EmailProviderStatus[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [direction, setDirection] = useState<string>("all");
|
||||
@@ -70,6 +80,12 @@ export default function CorrespondenceInboxPage() {
|
||||
if (view === "inbox") void load();
|
||||
}, [load, view]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<EmailProviderStatus[]>("/email/providers")
|
||||
.then((response) => setProviders(response.data ?? []))
|
||||
.catch(() => setProviders([]));
|
||||
}, []);
|
||||
|
||||
const filteredSummary = useMemo(() => {
|
||||
const linked = items.filter((item) => item.externalThreadId).length;
|
||||
const inbound = items.filter((item) => item.direction === "inbound").length;
|
||||
@@ -102,6 +118,18 @@ export default function CorrespondenceInboxPage() {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 2 }} aria-label="Email provider status">
|
||||
{providers.map((provider) => (
|
||||
<Chip
|
||||
key={provider.provider}
|
||||
size="small"
|
||||
color={provider.connected ? "success" : "default"}
|
||||
variant="outlined"
|
||||
label={`${provider.displayName}: ${provider.connected ? provider.address || "Connected" : "Not connected"}${provider.canSend ? " · Send enabled" : " · Read only"}`}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{view === "review" ? <GmailReviewPage embedded /> : <>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "2fr 1fr 1fr auto" }, gap: 1.25, mb: 2 }}>
|
||||
<TextField label="Search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Company, role, recruiter, subject" />
|
||||
|
||||
Reference in New Issue
Block a user