diff --git a/Data/JobTrackerContext.cs b/Data/JobTrackerContext.cs index 8e4025f..9f699e4 100644 --- a/Data/JobTrackerContext.cs +++ b/Data/JobTrackerContext.cs @@ -19,6 +19,7 @@ namespace JobTrackerApi.Data public DbSet GmailConnections => Set(); public DbSet GmailReviewDecisions => Set(); public DbSet MicrosoftGraphConnections => Set(); + public DbSet ImapConnections => Set(); public DbSet Attachments => Set(); public DbSet RuleSettings => Set(); public DbSet UserRuleSettings => Set(); diff --git a/JobTrackerApi.Tests/ImapControllerTests.cs b/JobTrackerApi.Tests/ImapControllerTests.cs new file mode 100644 index 0000000..3cf5c34 --- /dev/null +++ b/JobTrackerApi.Tests/ImapControllerTests.cs @@ -0,0 +1,131 @@ +using System.Security.Claims; +using JobTrackerApi.Controllers; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class ImapControllerTests +{ + [Fact] + public async Task Status_returns_connection_fields_for_connected_account() + { + var imap = new Mock(); + imap.Setup(service => service.GetConnectionAsync("user-1", It.IsAny())) + .ReturnsAsync(new ImapConnection + { + OwnerUserId = "user-1", + Host = "imap.example.test", + Port = 993, + UseSsl = true, + Username = "user@example.test", + ConnectedAt = DateTimeOffset.UtcNow.AddDays(-1), + LastSyncStatus = "ok" + }); + + var controller = CreateController(imap.Object, "user-1"); + var result = await controller.Status(CancellationToken.None); + + var ok = Assert.IsType(result.Result); + var payload = Assert.IsType(ok.Value); + Assert.True(payload.Connected); + Assert.Equal("imap.example.test", payload.Host); + Assert.Equal(993, payload.Port); + Assert.Equal("user@example.test", payload.Username); + Assert.Equal("ok", payload.LastSyncStatus); + } + + [Fact] + public async Task Status_reports_not_connected_when_no_connection_exists() + { + var imap = new Mock(); + imap.Setup(service => service.GetConnectionAsync("user-1", It.IsAny())) + .ReturnsAsync((ImapConnection?)null); + + var controller = CreateController(imap.Object, "user-1"); + var result = await controller.Status(CancellationToken.None); + + var ok = Assert.IsType(result.Result); + var payload = Assert.IsType(ok.Value); + Assert.False(payload.Connected); + } + + [Theory] + [InlineData("", 993, "user", "pass", "Host is required.")] + [InlineData("imap.example.test", 0, "user", "pass", "Valid port is required.")] + [InlineData("imap.example.test", 993, "", "pass", "Username is required.")] + [InlineData("imap.example.test", 993, "user", "", "Password is required.")] + public async Task Connect_rejects_missing_fields(string host, int port, string username, string password, string expectedError) + { + var imap = new Mock(MockBehavior.Strict); + var controller = CreateController(imap.Object, "user-1"); + + var result = await controller.Connect(new ImapController.ImapConnectRequest(host, port, true, username, password), CancellationToken.None); + + var badRequest = Assert.IsType(result); + Assert.Equal(expectedError, badRequest.Value); + } + + [Fact] + public async Task Connect_returns_bad_request_when_service_rejects_credentials() + { + var imap = new Mock(); + imap.Setup(service => service.ConnectAsync("user-1", "imap.example.test", 993, true, "user", "wrong", It.IsAny())) + .ThrowsAsync(new InvalidOperationException("IMAP authentication failed: bad credentials")); + + var controller = CreateController(imap.Object, "user-1"); + var result = await controller.Connect(new ImapController.ImapConnectRequest("imap.example.test", 993, true, "user", "wrong"), CancellationToken.None); + + var badRequest = Assert.IsType(result); + Assert.Contains("authentication failed", (string)badRequest.Value!); + } + + [Fact] + public async Task Connect_succeeds_and_returns_username() + { + var imap = new Mock(); + imap.Setup(service => service.ConnectAsync("user-1", "imap.example.test", 993, true, "user@example.test", "correct", It.IsAny())) + .ReturnsAsync(new ImapConnectResult("user@example.test")); + + var controller = CreateController(imap.Object, "user-1"); + var result = await controller.Connect(new ImapController.ImapConnectRequest("imap.example.test", 993, true, "user@example.test", "correct"), CancellationToken.None); + + var ok = Assert.IsType(result); + var username = ok.Value!.GetType().GetProperty("username")!.GetValue(ok.Value) as string; + Assert.Equal("user@example.test", username); + } + + [Fact] + public async Task Disconnect_calls_service_for_authenticated_user() + { + var imap = new Mock(); + imap.Setup(service => service.DisconnectAsync("user-1", It.IsAny())).Returns(Task.CompletedTask); + + var controller = CreateController(imap.Object, "user-1"); + var result = await controller.Disconnect(CancellationToken.None); + + Assert.IsType(result); + imap.Verify(service => service.DisconnectAsync("user-1", It.IsAny()), Times.Once); + } + + private static ImapController CreateController(IImapService imap, string userId) + { + return new ImapController(imap) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, userId) + }, "test")) + } + } + }; + } +} diff --git a/JobTrackerApi.Tests/ImapProviderTests.cs b/JobTrackerApi.Tests/ImapProviderTests.cs new file mode 100644 index 0000000..29d4075 --- /dev/null +++ b/JobTrackerApi.Tests/ImapProviderTests.cs @@ -0,0 +1,82 @@ +using JobTrackerApi.Services; +using JobTrackerApi.Services.EmailProviders; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class ImapProviderTests +{ + [Fact] + public void ProviderKey_is_imap() + { + var provider = new ImapProvider(Mock.Of()); + Assert.Equal("imap", provider.ProviderKey); + } + + [Fact] + public async Task GetConnectionAsync_maps_username_onto_neutral_shape() + { + var imap = new Mock(); + imap.Setup(service => service.GetConnectionAsync("user-1", It.IsAny())) + .ReturnsAsync(new JobTrackerApi.Models.ImapConnection { OwnerUserId = "user-1", Username = "user@example.test" }); + + var provider = new ImapProvider(imap.Object); + var connection = await provider.GetConnectionAsync("user-1", CancellationToken.None); + + Assert.NotNull(connection); + Assert.Equal("imap", connection!.ProviderKey); + Assert.Equal("user@example.test", connection.Address); + } + + [Fact] + public async Task GetConnectionAsync_returns_null_when_not_connected() + { + var imap = new Mock(); + imap.Setup(service => service.GetConnectionAsync("user-1", It.IsAny())) + .ReturnsAsync((JobTrackerApi.Models.ImapConnection?)null); + + var provider = new ImapProvider(imap.Object); + var connection = await provider.GetConnectionAsync("user-1", CancellationToken.None); + + Assert.Null(connection); + } + + [Fact] + public async Task SearchAsync_maps_thread_key_onto_neutral_thread_id() + { + var imap = new Mock(); + imap.Setup(service => service.ListMessagesAsync("user-1", "recruiter", 10, It.IsAny())) + .ReturnsAsync(new List + { + new("42", "root-msg-id@example.test", "Interview", "them@company.test", "me@example.test", DateTimeOffset.UtcNow, "snippet") + }); + + var provider = new ImapProvider(imap.Object); + var results = await provider.SearchAsync("user-1", "recruiter", 10, CancellationToken.None); + + var summary = Assert.Single(results); + Assert.Equal("42", summary.Id); + Assert.Equal("root-msg-id@example.test", summary.ThreadId); + Assert.Equal("Interview", summary.Subject); + } + + [Fact] + public async Task GetMessageAsync_maps_content_id_onto_neutral_external_attachment_id() + { + var imap = new Mock(); + imap.Setup(service => service.GetMessageAsync("user-1", "42", It.IsAny())) + .ReturnsAsync(new ImapMessageDetail( + "42", "root-msg-id@example.test", "Offer", "them@company.test", "me@example.test", DateTimeOffset.UtcNow, "snippet", + "body text", "

body

", new List(), + new List { new("resume.pdf", "application/pdf", 1024, "cid-1", false) })); + + var provider = new ImapProvider(imap.Object); + var detail = await provider.GetMessageAsync("user-1", "42", CancellationToken.None); + + Assert.Equal("root-msg-id@example.test", detail.ThreadId); + var attachment = Assert.Single(detail.Attachments); + Assert.Equal("resume.pdf", attachment.FileName); + Assert.Equal("cid-1", attachment.ExternalAttachmentId); + } +} diff --git a/JobTrackerApi.Tests/ImapServiceSsrfGuardTests.cs b/JobTrackerApi.Tests/ImapServiceSsrfGuardTests.cs new file mode 100644 index 0000000..00a120d --- /dev/null +++ b/JobTrackerApi.Tests/ImapServiceSsrfGuardTests.cs @@ -0,0 +1,49 @@ +using System.IO; +using JobTrackerApi.Services; +using JobTrackerApi.Tests.TestSupport; +using Microsoft.AspNetCore.DataProtection; +using Xunit; + +namespace JobTrackerApi.Tests; + +// Regression coverage for the SSRF guard in ImapService: an authenticated user's IMAP "connect" +// target must not be usable to probe loopback/RFC1918/link-local/cloud-metadata addresses. +public sealed class ImapServiceSsrfGuardTests +{ + [Theory] + [InlineData("127.0.0.1")] + [InlineData("localhost")] + [InlineData("10.0.0.5")] + [InlineData("172.16.0.5")] + [InlineData("192.168.1.5")] + [InlineData("169.254.169.254")] // cloud metadata endpoint + public async Task ConnectAsync_rejects_internal_and_metadata_hosts(string host) + { + var service = CreateService(); + + var ex = await Assert.ThrowsAsync(() => + service.ConnectAsync("user-1", host, 993, true, "user", "password", CancellationToken.None)); + + // Message must not leak connect-vs-auth distinction (that's the oracle this guard closes). + Assert.DoesNotContain("resolve", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("reachable", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ConnectAsync_rejects_unresolvable_host_without_leaking_dns_detail() + { + var service = CreateService(); + + var ex = await Assert.ThrowsAsync(() => + service.ConnectAsync("user-1", "this-host-does-not-exist.invalid", 993, true, "user", "password", CancellationToken.None)); + + Assert.Equal("Could not connect to that IMAP server with the given credentials. Check host, port, and password.", ex.Message); + } + + private static ImapService CreateService() + { + var db = TestHostFactory.CreateInMemoryDb(); + var protectionProvider = DataProtectionProvider.Create(new DirectoryInfo(Path.Combine(Path.GetTempPath(), $"jobtracker-tests-{Guid.NewGuid():N}"))); + return new ImapService(db, protectionProvider); + } +} diff --git a/JobTrackerApi/Controllers/ImapController.cs b/JobTrackerApi/Controllers/ImapController.cs new file mode 100644 index 0000000..00c9504 --- /dev/null +++ b/JobTrackerApi/Controllers/ImapController.cs @@ -0,0 +1,96 @@ +using System.Security.Claims; +using JobTrackerApi.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace JobTrackerApi.Controllers; + +/// +/// Generic IMAP connection lifecycle for mailboxes with no dedicated OAuth provider. Unlike +/// Gmail/Microsoft, there's no OAuth redirect — the caller submits host/username/password once, +/// verifies them by connecting, then encrypts and stores them. +/// +[ApiController] +[Route("api/imap")] +[Authorize] +public sealed class ImapController : ControllerBase +{ + private readonly IImapService _imap; + + public ImapController(IImapService imap) + { + _imap = imap; + } + + public sealed record ImapConnectRequest(string Host, int Port, bool UseSsl, string Username, string Password); + + public sealed record ImapConnectionStatusDto( + bool Connected, + string? Host, + int? Port, + bool? UseSsl, + string? Username, + DateTimeOffset? ConnectedAt, + DateTimeOffset? LastSyncedAt, + DateTimeOffset? LastSyncAttemptedAt, + DateTimeOffset? LastSyncSucceededAt, + string? LastSyncMode, + string? LastSyncSource, + string? LastSyncStatus, + string? LastSyncError); + + [HttpGet("status")] + public async Task> Status(CancellationToken cancellationToken) + { + var ownerUserId = GetRequiredOwnerUserId(); + var connection = await _imap.GetConnectionAsync(ownerUserId, cancellationToken); + return Ok(new ImapConnectionStatusDto( + connection is not null, + connection?.Host, + connection?.Port, + connection?.UseSsl, + connection?.Username, + connection?.ConnectedAt, + connection?.LastSyncedAt, + connection?.LastSyncAttemptedAt, + connection?.LastSyncSucceededAt, + connection?.LastSyncMode, + connection?.LastSyncSource, + connection?.LastSyncStatus, + connection?.LastSyncError)); + } + + [HttpPost("connect")] + public async Task Connect([FromBody] ImapConnectRequest request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.Host)) return BadRequest("Host is required."); + if (request.Port <= 0 || request.Port > 65535) return BadRequest("Valid port is required."); + if (string.IsNullOrWhiteSpace(request.Username)) return BadRequest("Username is required."); + if (string.IsNullOrWhiteSpace(request.Password)) return BadRequest("Password is required."); + + var ownerUserId = GetRequiredOwnerUserId(); + try + { + var result = await _imap.ConnectAsync(ownerUserId, request.Host, request.Port, request.UseSsl, request.Username, request.Password, cancellationToken); + return Ok(new { username = result.Username }); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + [HttpDelete("connection")] + public async Task Disconnect(CancellationToken cancellationToken) + { + var ownerUserId = GetRequiredOwnerUserId(); + await _imap.DisconnectAsync(ownerUserId, cancellationToken); + return NoContent(); + } + + private string GetRequiredOwnerUserId() + { + return User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") + ?? throw new InvalidOperationException("Authenticated user id is missing."); + } +} diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index 0cbfd61..eb2ae39 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -166,10 +166,12 @@ builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); +builder.Services.AddScoped(); -// Provider-neutral email seam (multi-provider: Gmail + Microsoft Graph today; IMAP / manual next). +// Provider-neutral email seam (multi-provider: Gmail + Microsoft Graph + IMAP today; manual next). builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddIdentityCore(options => diff --git a/JobTrackerApi/Services/EmailProviders/ImapProvider.cs b/JobTrackerApi/Services/EmailProviders/ImapProvider.cs new file mode 100644 index 0000000..b2c264e --- /dev/null +++ b/JobTrackerApi/Services/EmailProviders/ImapProvider.cs @@ -0,0 +1,63 @@ +using JobTrackerApi.Services; + +namespace JobTrackerApi.Services.EmailProviders +{ + /// + /// Generic IMAP implementation of for mailboxes with no + /// dedicated OAuth provider. Adapts to the provider-neutral + /// contract, mapping IMAP DTOs to the neutral shapes. + /// + public sealed class ImapProvider : IEmailProvider + { + private readonly IImapService _imap; + + public ImapProvider(IImapService imap) + { + _imap = imap; + } + + public string ProviderKey => "imap"; + + public async Task GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken) + { + var connection = await _imap.GetConnectionAsync(ownerUserId, cancellationToken); + return connection is null ? null : new EmailConnectionInfo("imap", connection.Username ?? ""); + } + + public async Task> SearchAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken) + { + var messages = await _imap.ListMessagesAsync(ownerUserId, query, maxResults, cancellationToken); + return messages.Select(ToSummary).ToList(); + } + + public async Task> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken) + { + var messages = await _imap.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken); + return messages.Select(ToSummary).ToList(); + } + + public async Task GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken) + { + var detail = await _imap.GetMessageAsync(ownerUserId, messageId, cancellationToken); + var attachments = detail.Attachments + .Select(a => new EmailAttachmentRef(a.FileName, a.MimeType, a.SizeBytes, a.ContentId, a.Inline)) + .ToList(); + + return new EmailMessageDetail( + detail.Id, + detail.ThreadKey, + detail.Subject, + detail.From, + detail.To, + detail.Date, + detail.Snippet, + detail.BodyText, + detail.BodyHtml, + detail.Labels, + attachments); + } + + private static EmailMessageSummary ToSummary(ImapMessageSummary m) + => new(m.Id, m.ThreadKey, m.Subject, m.From, m.To, m.Date, m.Snippet); + } +} diff --git a/JobTrackerApi/Services/ImapService.cs b/JobTrackerApi/Services/ImapService.cs new file mode 100644 index 0000000..bc0bd1f --- /dev/null +++ b/JobTrackerApi/Services/ImapService.cs @@ -0,0 +1,345 @@ +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using MailKit; +using MailKit.Net.Imap; +using MailKit.Search; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.EntityFrameworkCore; +using MimeKit; +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; + +namespace JobTrackerApi.Services; + +public interface IImapService +{ + Task ConnectAsync(string ownerUserId, string host, int port, bool useSsl, string username, string password, CancellationToken cancellationToken); + Task GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken); + Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken); + Task> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken); + Task> ListThreadMessagesAsync(string ownerUserId, string threadKey, CancellationToken cancellationToken); + Task GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken); +} + +public sealed record ImapConnectResult(string Username); +public sealed record ImapMessageSummary(string Id, string ThreadKey, string Subject, string From, string To, DateTimeOffset? Date, string Snippet); +public sealed record ImapMessageAttachment(string? FileName, string? MimeType, long? SizeBytes, string? ContentId, bool Inline); +public sealed record ImapMessageDetail(string Id, string ThreadKey, string Subject, string From, string To, DateTimeOffset? Date, string Snippet, string BodyText, string? BodyHtml, IReadOnlyList Labels, IReadOnlyList Attachments); + +/// +/// Generic IMAP mail access for "any provider not explicitly supported" (Gmail/Microsoft have +/// their own OAuth-based providers). Auth is direct host/username/password rather than OAuth — +/// there's no connect-url/callback dance, the caller submits credentials once and they're +/// encrypted at rest the same way Gmail/Microsoft's refresh tokens are. +/// +/// ponytail: scoped to INBOX only, and "thread" is approximated from the References/In-Reply-To +/// headers (the root Message-Id) rather than the server-side IMAP THREAD extension, which not +/// every provider implements. Good enough for "show me the other messages in this conversation"; +/// upgrade to THREAD/SORT if a target provider needs cross-folder or extension-grade threading. +/// External message ids are "{UID}" scoped to INBOX under the connection's current UIDVALIDITY — +/// they are not stable across a UIDVALIDITY change (rare: a full mailbox reset on the server). +/// +public sealed class ImapService : IImapService +{ + private const int ThreadScanWindow = 200; + private readonly JobTrackerContext _db; + private readonly IDataProtector _protector; + + public ImapService(JobTrackerContext db, IDataProtectionProvider protectionProvider) + { + _db = db; + _protector = protectionProvider.CreateProtector("imap-credentials-v1"); + } + + public async Task ConnectAsync(string ownerUserId, string host, int port, bool useSsl, string username, string password, CancellationToken cancellationToken) + { + host = host.Trim(); + username = username.Trim(); + if (host.Length == 0) throw new InvalidOperationException("IMAP host is required."); + if (username.Length == 0) throw new InvalidOperationException("IMAP username is required."); + if (string.IsNullOrEmpty(password)) throw new InvalidOperationException("IMAP password is required."); + + // Verify the credentials actually work before persisting them. Failure detail is + // intentionally generic (not the raw MailKit exception) so a "connect" attempt can't be + // used as a distinguishable oracle to fingerprint what's listening on a given host:port. + using (var client = new ImapClient()) + { + try + { + await EnsureHostIsExternalAsync(host, cancellationToken); + await client.ConnectAsync(host, port, useSsl, cancellationToken); + await client.AuthenticateAsync(username, password, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + throw new InvalidOperationException("Could not connect to that IMAP server with the given credentials. Check host, port, and password."); + } + finally + { + if (client.IsConnected) + { + await client.DisconnectAsync(true, cancellationToken); + } + } + } + + var existing = await _db.ImapConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken); + if (existing is null) + { + existing = new ImapConnection { OwnerUserId = ownerUserId }; + _db.ImapConnections.Add(existing); + } + + existing.Host = host; + existing.Port = port; + existing.UseSsl = useSsl; + existing.Username = username; + existing.EncryptedPassword = _protector.Protect(password); + existing.ConnectedAt = DateTimeOffset.UtcNow; + existing.LastSyncStatus = "connected"; + existing.LastSyncSource = "connect"; + existing.LastSyncMode = "connect"; + existing.LastSyncError = null; + existing.LastSyncAttemptedAt = DateTimeOffset.UtcNow; + existing.LastSyncSucceededAt = existing.LastSyncAttemptedAt; + + await _db.SaveChangesAsync(cancellationToken); + return new ImapConnectResult(existing.Username); + } + + public Task GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken) + => _db.ImapConnections.AsNoTracking().FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken); + + public async Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken) + { + var existing = await _db.ImapConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken); + if (existing is null) return; + _db.ImapConnections.Remove(existing); + await _db.SaveChangesAsync(cancellationToken); + } + + public async Task> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken) + { + maxResults = Math.Clamp(maxResults, 1, 25); + try + { + using var client = await OpenInboxAsync(ownerUserId, false, cancellationToken); + var searchQuery = string.IsNullOrWhiteSpace(query) + ? SearchQuery.All + : SearchQuery.SubjectContains(query.Trim()).Or(SearchQuery.FromContains(query.Trim())).Or(SearchQuery.BodyContains(query.Trim())); + + var uids = await client.Inbox.SearchAsync(searchQuery, cancellationToken); + var window = uids.OrderByDescending(u => u.Id).Take(maxResults).ToList(); + var summaries = await FetchSummariesAsync(client, window, cancellationToken); + + await TouchSyncStateAsync(ownerUserId, "list-messages", string.IsNullOrWhiteSpace(query) ? "default-query" : "custom-query", true, null, cancellationToken); + return summaries; + } + catch (Exception ex) + { + await TouchSyncStateAsync(ownerUserId, "list-messages", string.IsNullOrWhiteSpace(query) ? "default-query" : "custom-query", false, ex.Message, cancellationToken); + throw; + } + } + + public async Task> ListThreadMessagesAsync(string ownerUserId, string threadKey, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(threadKey)) + { + return Array.Empty(); + } + + try + { + using var client = await OpenInboxAsync(ownerUserId, false, cancellationToken); + var recentUids = (await client.Inbox.SearchAsync(SearchQuery.All, cancellationToken)) + .OrderByDescending(u => u.Id) + .Take(ThreadScanWindow) + .ToList(); + + var items = await client.Inbox.FetchAsync(recentUids, MessageSummaryItems.Envelope | MessageSummaryItems.References | MessageSummaryItems.UniqueId, cancellationToken); + var matches = items.Where(item => ComputeThreadKey(item) == threadKey.Trim()).ToList(); + var summaries = matches.Select(ToSummary).OrderBy(s => s.Date).ToList(); + + await TouchSyncStateAsync(ownerUserId, "thread-refresh", "reference-scan", true, null, cancellationToken); + return summaries; + } + catch (Exception ex) + { + await TouchSyncStateAsync(ownerUserId, "thread-refresh", "reference-scan", false, ex.Message, cancellationToken); + throw; + } + } + + public async Task GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken) + { + try + { + using var client = await OpenInboxAsync(ownerUserId, false, cancellationToken); + var uid = ParseUid(messageId); + + var summaryItems = await client.Inbox.FetchAsync(new[] { uid }, MessageSummaryItems.Envelope | MessageSummaryItems.References | MessageSummaryItems.UniqueId, cancellationToken); + var summary = summaryItems.FirstOrDefault() ?? throw new InvalidOperationException($"IMAP message {messageId} was not found."); + + var mime = await client.Inbox.GetMessageAsync(uid, cancellationToken); + var bodyText = mime.TextBody ?? (mime.HtmlBody is null ? "" : StripHtml(mime.HtmlBody)); + var attachments = mime.Attachments.Select(a => new ImapMessageAttachment( + a.ContentDisposition?.FileName ?? a.ContentType?.Name, + a.ContentType?.MimeType, + a is MimePart part ? part.Content?.Stream?.Length : null, + a.ContentId, + a.IsAttachment == false + )).ToList(); + + await TouchSyncStateAsync(ownerUserId, "message-detail", "imap-message", true, null, cancellationToken); + return new ImapMessageDetail( + messageId, + ComputeThreadKey(summary), + summary.Envelope?.Subject ?? "", + FormatAddresses(summary.Envelope?.From), + FormatAddresses(summary.Envelope?.To), + summary.Envelope?.Date, + bodyText.Length > 200 ? bodyText[..200] : bodyText, + bodyText.Trim(), + mime.HtmlBody, + Array.Empty(), + attachments); + } + catch (Exception ex) + { + await TouchSyncStateAsync(ownerUserId, "message-detail", "imap-message", false, ex.Message, cancellationToken); + throw; + } + } + + private static async Task> FetchSummariesAsync(ImapClient client, IList uids, CancellationToken cancellationToken) + { + if (uids.Count == 0) return Array.Empty(); + var items = await client.Inbox.FetchAsync(uids, MessageSummaryItems.Envelope | MessageSummaryItems.References | MessageSummaryItems.UniqueId, cancellationToken); + return items.Select(ToSummary).ToList(); + } + + private static ImapMessageSummary ToSummary(IMessageSummary item) => new( + item.UniqueId.Id.ToString(), + ComputeThreadKey(item), + item.Envelope?.Subject ?? "", + FormatAddresses(item.Envelope?.From), + FormatAddresses(item.Envelope?.To), + item.Envelope?.Date, + ""); + + // The root Message-Id of the References chain, or this message's own Message-Id if it + // starts no chain — a stand-in "thread id" that works without the IMAP THREAD extension. + private static string ComputeThreadKey(IMessageSummary item) + { + if (item.References is { Count: > 0 }) + { + return item.References[0]; + } + return item.Envelope?.MessageId ?? item.UniqueId.Id.ToString(); + } + + private static string FormatAddresses(InternetAddressList? list) + => list is null ? "" : string.Join(", ", list.Mailboxes.Select(m => m.Address)); + + private static string StripHtml(string html) + => System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", " ").Trim(); + + private static UniqueId ParseUid(string messageId) + => uint.TryParse(messageId, out var id) ? new UniqueId(id) : throw new InvalidOperationException($"Invalid IMAP message id: {messageId}"); + + private async Task OpenInboxAsync(string ownerUserId, bool writable, CancellationToken cancellationToken) + { + var connection = await _db.ImapConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken) + ?? throw new InvalidOperationException("IMAP is not connected for this account."); + + string password; + try + { + password = _protector.Unprotect(connection.EncryptedPassword); + } + catch (CryptographicException) + { + throw new InvalidOperationException("Your stored IMAP connection can no longer be decrypted after a server key change. Disconnect and reconnect IMAP."); + } + + await EnsureHostIsExternalAsync(connection.Host, cancellationToken); + var client = new ImapClient(); + await client.ConnectAsync(connection.Host, connection.Port, connection.UseSsl, cancellationToken); + await client.AuthenticateAsync(connection.Username, password, cancellationToken); + await client.Inbox.OpenAsync(writable ? FolderAccess.ReadWrite : FolderAccess.ReadOnly, cancellationToken); + return client; + } + + // SSRF guard: a user-supplied IMAP host resolves to an IP the server then opens a socket to. + // Without this check an authenticated user could point "their mailbox" at loopback, RFC1918/ + // link-local ranges, or the cloud metadata address to probe internal infrastructure. Re-run on + // every connect (not just the initial one) so a DNS record that resolves externally at connect + // time can't be rebound internally for a later reconnect. + private static async Task EnsureHostIsExternalAsync(string host, CancellationToken cancellationToken) + { + IPAddress[] addresses; + try + { + addresses = await Dns.GetHostAddressesAsync(host, cancellationToken); + } + catch (SocketException) + { + throw new InvalidOperationException("Could not resolve that IMAP host."); + } + + if (addresses.Length == 0 || addresses.Any(IsInternalAddress)) + { + throw new InvalidOperationException("That IMAP host is not reachable."); + } + } + + private static bool IsInternalAddress(IPAddress address) + { + if (address.IsIPv4MappedToIPv6) address = address.MapToIPv4(); + + if (IPAddress.IsLoopback(address)) return true; + if (address.Equals(IPAddress.Any) || address.Equals(IPAddress.IPv6Any)) return true; + + if (address.AddressFamily == AddressFamily.InterNetwork) + { + var bytes = address.GetAddressBytes(); + if (bytes[0] == 10) return true; // 10.0.0.0/8 + if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return true; // 172.16.0.0/12 + if (bytes[0] == 192 && bytes[1] == 168) return true; // 192.168.0.0/16 + if (bytes[0] == 169 && bytes[1] == 254) return true; // 169.254.0.0/16 (incl. cloud metadata) + if (bytes[0] == 127) return true; // 127.0.0.0/8 + return false; + } + + if (address.AddressFamily == AddressFamily.InterNetworkV6) + { + if (address.IsIPv6LinkLocal || address.IsIPv6SiteLocal) return true; + var bytes = address.GetAddressBytes(); + if ((bytes[0] & 0xFE) == 0xFC) return true; // fc00::/7 (unique local) + return false; + } + + return true; // unknown address family: fail closed + } + + private async Task TouchSyncStateAsync(string ownerUserId, string mode, string source, bool succeeded, string? error, CancellationToken cancellationToken) + { + var connection = await _db.ImapConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken); + if (connection is null) return; + + connection.LastSyncAttemptedAt = DateTimeOffset.UtcNow; + connection.LastSyncMode = mode; + connection.LastSyncSource = source; + connection.LastSyncStatus = succeeded ? "ok" : "error"; + connection.LastSyncError = succeeded ? null : error; + if (succeeded) + { + connection.LastSyncedAt = DateTimeOffset.UtcNow; + connection.LastSyncSucceededAt = connection.LastSyncedAt; + } + + await _db.SaveChangesAsync(cancellationToken); + } +} diff --git a/JobTrackerApi/Services/StartupInitializationExtensions.cs b/JobTrackerApi/Services/StartupInitializationExtensions.cs index 72b81ea..1f5ccd6 100644 --- a/JobTrackerApi/Services/StartupInitializationExtensions.cs +++ b/JobTrackerApi/Services/StartupInitializationExtensions.cs @@ -386,6 +386,31 @@ public static class StartupInitializationExtensions Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_MicrosoftGraphConnections_OwnerUserId_MailAddress" ON "MicrosoftGraphConnections" ("OwnerUserId", "MailAddress");"""); } + static void EnsureImapConnectionsTable(DbConnection c) + { + Exec(c, """ + CREATE TABLE IF NOT EXISTS "ImapConnections" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_ImapConnections" PRIMARY KEY AUTOINCREMENT, + "OwnerUserId" TEXT NOT NULL, + "Host" TEXT NOT NULL, + "Port" INTEGER NOT NULL, + "UseSsl" INTEGER NOT NULL, + "Username" TEXT NOT NULL, + "EncryptedPassword" TEXT NOT NULL, + "ConnectedAt" TEXT NOT NULL, + "LastSyncedAt" TEXT NULL, + "LastSyncAttemptedAt" TEXT NULL, + "LastSyncSucceededAt" TEXT NULL, + "LastSyncMode" TEXT NULL, + "LastSyncSource" TEXT NULL, + "LastSyncStatus" TEXT NULL, + "LastSyncError" TEXT NULL + ); + """); + + Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_ImapConnections_OwnerUserId" ON "ImapConnections" ("OwnerUserId");"""); + } + static void EnsureCvTables(DbConnection c) { Exec(c, """ @@ -455,6 +480,7 @@ public static class StartupInitializationExtensions EnsureGmailConnectionsTable(conn); EnsureMicrosoftGraphConnectionsTable(conn); + EnsureImapConnectionsTable(conn); EnsureCvTables(conn); // Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded, @@ -634,6 +660,7 @@ public static class StartupInitializationExtensions EnsureMySqlAutoIncrementPrimaryKey(conn, "JobEvents", "Id"); EnsureMySqlAutoIncrementPrimaryKey(conn, "GmailConnections", "Id"); EnsureMySqlAutoIncrementPrimaryKey(conn, "MicrosoftGraphConnections", "Id"); + EnsureMySqlAutoIncrementPrimaryKey(conn, "ImapConnections", "Id"); EnsureMySqlAutoIncrementPrimaryKey(conn, "CvUploadArtifacts", "Id"); EnsureMySqlAutoIncrementPrimaryKey(conn, "CvExtractionRuns", "Id"); EnsureMySqlAutoIncrementPrimaryKey(conn, "TailoredCvDrafts", "Id"); @@ -851,6 +878,30 @@ public static class StartupInitializationExtensions cmd.ExecuteNonQuery(); } + if (!HasMySqlTable(conn, "ImapConnections")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `ImapConnections` ( + `Id` int NOT NULL AUTO_INCREMENT, + `OwnerUserId` varchar(255) NOT NULL, + `Host` varchar(255) NOT NULL, + `Port` int NOT NULL, + `UseSsl` tinyint(1) NOT NULL, + `Username` varchar(255) NOT NULL, + `EncryptedPassword` longtext NOT NULL, + `ConnectedAt` datetime(6) NOT NULL, + `LastSyncedAt` datetime(6) NULL, + `LastSyncAttemptedAt` datetime(6) NULL, + `LastSyncSucceededAt` datetime(6) NULL, + `LastSyncMode` varchar(255) NULL, + `LastSyncSource` varchar(255) NULL, + `LastSyncStatus` varchar(255) NULL, + `LastSyncError` longtext NULL, + PRIMARY KEY (`Id`) + );"; + cmd.ExecuteNonQuery(); + } + if (!HasMySqlTable(conn, "TailoredCvDrafts")) { using var cmd = conn.CreateCommand(); @@ -956,6 +1007,13 @@ public static class StartupInitializationExtensions cmd.ExecuteNonQuery(); } + if (!MySqlIndexExists(conn, "ImapConnections", "IX_ImapConnections_OwnerUserId")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "CREATE UNIQUE INDEX `IX_ImapConnections_OwnerUserId` ON `ImapConnections` (`OwnerUserId`);"; + cmd.ExecuteNonQuery(); + } + if (!MySqlIndexExists(conn, "TailoredCvDrafts", "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId")) { using var cmd = conn.CreateCommand(); diff --git a/JobTrackerBackend/JobTrackerBackend.csproj b/JobTrackerBackend/JobTrackerBackend.csproj index 77b0598..767977f 100644 --- a/JobTrackerBackend/JobTrackerBackend.csproj +++ b/JobTrackerBackend/JobTrackerBackend.csproj @@ -29,5 +29,6 @@ + diff --git a/Models/ImapConnection.cs b/Models/ImapConnection.cs new file mode 100644 index 0000000..3943808 --- /dev/null +++ b/Models/ImapConnection.cs @@ -0,0 +1,20 @@ +namespace JobTrackerApi.Models; + +public sealed class ImapConnection +{ + public int Id { get; set; } + public string OwnerUserId { get; set; } = ""; + public string Host { get; set; } = ""; + public int Port { get; set; } = 993; + public bool UseSsl { get; set; } = true; + public string Username { get; set; } = ""; + public string EncryptedPassword { get; set; } = ""; + public DateTimeOffset ConnectedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? LastSyncedAt { get; set; } + public DateTimeOffset? LastSyncAttemptedAt { get; set; } + public DateTimeOffset? LastSyncSucceededAt { get; set; } + public string? LastSyncMode { get; set; } + public string? LastSyncSource { get; set; } + public string? LastSyncStatus { get; set; } + public string? LastSyncError { get; set; } +}