diff --git a/JobTrackerApi.Tests/EmailControllerTests.cs b/JobTrackerApi.Tests/EmailControllerTests.cs new file mode 100644 index 0000000..28ba67b --- /dev/null +++ b/JobTrackerApi.Tests/EmailControllerTests.cs @@ -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(result.Result); + var statuses = Assert.IsAssignableFrom>(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(unknown.Result); + + var disconnected = await controller.Search("gmail", null, 25, CancellationToken.None); + Assert.IsType(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(result.Result); + 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); + } + + 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 GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken) + { + LastOwnerUserId = ownerUserId; + return Task.FromResult(connection); + } + + public Task> SearchAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken) + => Task.FromResult>(Array.Empty()); + + public Task> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken) + => Task.FromResult>(Array.Empty()); + + public Task 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", + "", + Array.Empty(), + Array.Empty())); + } + } +} diff --git a/JobTrackerApi/Controllers/EmailController.cs b/JobTrackerApi/Controllers/EmailController.cs new file mode 100644 index 0000000..65800ab --- /dev/null +++ b/JobTrackerApi/Controllers/EmailController.cs @@ -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 Labels, + IReadOnlyList Attachments); + + [HttpGet("providers")] + public async Task>> GetProviders(CancellationToken cancellationToken) + { + var ownerUserId = GetOwnerUserId(); + if (ownerUserId is null) return Unauthorized(); + + var statuses = new List(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>> 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>> 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> 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, + }; +} diff --git a/job-tracker-ui/src/correspondence-inbox-page.test.tsx b/job-tracker-ui/src/correspondence-inbox-page.test.tsx index 5671554..8288a55 100644 --- a/job-tracker-ui/src/correspondence-inbox-page.test.tsx +++ b/job-tracker-ui/src/correspondence-inbox-page.test.tsx @@ -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]); diff --git a/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx b/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx index 4e4fe7d..e379684 100644 --- a/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx +++ b/job-tracker-ui/src/views/CorrespondenceInboxPage.tsx @@ -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([]); + const [providers, setProviders] = useState([]); const [loading, setLoading] = useState(false); const [query, setQuery] = useState(""); const [direction, setDirection] = useState("all"); @@ -70,6 +80,12 @@ export default function CorrespondenceInboxPage() { if (view === "inbox") void load(); }, [load, view]); + useEffect(() => { + api.get("/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() { + + {providers.map((provider) => ( + + ))} + + {view === "review" ? : <> setQuery(e.target.value)} placeholder="Company, role, recruiter, subject" />