Compare commits

..

1 Commits

Author SHA1 Message Date
cesnimda 6a19a9f70d feat(email): add Correspondence.Provider discriminator
CI and Deploy / test (pull_request) Successful in 1m59s
CI and Deploy / deploy (pull_request) Has been skipped
b4 of the multi-provider email roadmap. The manual/free-text correspondence
entry path already existed (CorrespondenceController.Create) -- this slice
was narrower than the roadmap wording suggests: tag every Correspondence row
with which provider it came from (gmail | manual today; microsoft | imap
once those providers grow an import-into-Correspondence path of their own),
not build a new endpoint.

- Correspondence.Provider (nullable string), reconciled via the existing
  EnsureColumn pattern (SQLite + MySQL).
- Idempotent backfill: rows with an ExternalThreadId (historically only
  ever written by Gmail import) get 'gmail'; everything else gets 'manual'.
- GmailController.ImportSingleMessageAsync now tags Provider = "gmail".
- CorrespondenceController.Create now tags Provider = "manual".
- Both write sites use a fixed literal, not request input -- no injection
  surface introduced. Backfill SQL is static, no interpolation.

148/148 green (147 existing + 1 new CorrespondenceControllerTests; the
GmailController import test gained a Provider assertion in place).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 19:03:42 +02:00
12 changed files with 2 additions and 855 deletions
+1 -6
View File
@@ -70,12 +70,7 @@ jobs:
CI: 'false' CI: 'false'
GENERATE_SOURCEMAP: 'false' GENERATE_SOURCEMAP: 'false'
NODE_OPTIONS: --max-old-space-size=4096 NODE_OPTIONS: --max-old-space-size=4096
# CRA's build (Terser minify + fork-ts-checker workers) has repeatedly died silently on run: npm run build
# this runner with no error output (OOM/SIGSEGV signature — same resource-starved-runner
# class as the npm ci and dotnet-install flakes elsewhere in this workflow). Retry once.
run: |
npm run build \
|| ( echo "Frontend build failed ($?) — retrying once..." && npm run build )
deploy: deploy:
needs: test needs: test
-1
View File
@@ -19,7 +19,6 @@ namespace JobTrackerApi.Data
public DbSet<GmailConnection> GmailConnections => Set<GmailConnection>(); public DbSet<GmailConnection> GmailConnections => Set<GmailConnection>();
public DbSet<GmailReviewDecision> GmailReviewDecisions => Set<GmailReviewDecision>(); public DbSet<GmailReviewDecision> GmailReviewDecisions => Set<GmailReviewDecision>();
public DbSet<MicrosoftGraphConnection> MicrosoftGraphConnections => Set<MicrosoftGraphConnection>(); public DbSet<MicrosoftGraphConnection> MicrosoftGraphConnections => Set<MicrosoftGraphConnection>();
public DbSet<ImapConnection> ImapConnections => Set<ImapConnection>();
public DbSet<Attachment> Attachments => Set<Attachment>(); public DbSet<Attachment> Attachments => Set<Attachment>();
public DbSet<RuleSettings> RuleSettings => Set<RuleSettings>(); public DbSet<RuleSettings> RuleSettings => Set<RuleSettings>();
public DbSet<UserRuleSettings> UserRuleSettings => Set<UserRuleSettings>(); public DbSet<UserRuleSettings> UserRuleSettings => Set<UserRuleSettings>();
-131
View File
@@ -1,131 +0,0 @@
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<IImapService>();
imap.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
.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<OkObjectResult>(result.Result);
var payload = Assert.IsType<ImapController.ImapConnectionStatusDto>(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<IImapService>();
imap.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
.ReturnsAsync((ImapConnection?)null);
var controller = CreateController(imap.Object, "user-1");
var result = await controller.Status(CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var payload = Assert.IsType<ImapController.ImapConnectionStatusDto>(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<IImapService>(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<BadRequestObjectResult>(result);
Assert.Equal(expectedError, badRequest.Value);
}
[Fact]
public async Task Connect_returns_bad_request_when_service_rejects_credentials()
{
var imap = new Mock<IImapService>();
imap.Setup(service => service.ConnectAsync("user-1", "imap.example.test", 993, true, "user", "wrong", It.IsAny<CancellationToken>()))
.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<BadRequestObjectResult>(result);
Assert.Contains("authentication failed", (string)badRequest.Value!);
}
[Fact]
public async Task Connect_succeeds_and_returns_username()
{
var imap = new Mock<IImapService>();
imap.Setup(service => service.ConnectAsync("user-1", "imap.example.test", 993, true, "user@example.test", "correct", It.IsAny<CancellationToken>()))
.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<OkObjectResult>(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<IImapService>();
imap.Setup(service => service.DisconnectAsync("user-1", It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
var controller = CreateController(imap.Object, "user-1");
var result = await controller.Disconnect(CancellationToken.None);
Assert.IsType<NoContentResult>(result);
imap.Verify(service => service.DisconnectAsync("user-1", It.IsAny<CancellationToken>()), 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"))
}
}
};
}
}
-82
View File
@@ -1,82 +0,0 @@
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<IImapService>());
Assert.Equal("imap", provider.ProviderKey);
}
[Fact]
public async Task GetConnectionAsync_maps_username_onto_neutral_shape()
{
var imap = new Mock<IImapService>();
imap.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
.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<IImapService>();
imap.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
.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<IImapService>();
imap.Setup(service => service.ListMessagesAsync("user-1", "recruiter", 10, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<ImapMessageSummary>
{
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<IImapService>();
imap.Setup(service => service.GetMessageAsync("user-1", "42", It.IsAny<CancellationToken>()))
.ReturnsAsync(new ImapMessageDetail(
"42", "root-msg-id@example.test", "Offer", "them@company.test", "me@example.test", DateTimeOffset.UtcNow, "snippet",
"body text", "<p>body</p>", new List<string>(),
new List<ImapMessageAttachment> { 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);
}
}
@@ -1,49 +0,0 @@
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<InvalidOperationException>(() =>
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<InvalidOperationException>(() =>
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);
}
}
@@ -1,96 +0,0 @@
using System.Security.Claims;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace JobTrackerApi.Controllers;
/// <summary>
/// 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,
/// <see cref="IImapService"/> verifies them by connecting, then encrypts and stores them.
/// </summary>
[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<ActionResult<ImapConnectionStatusDto>> 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<IActionResult> 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<IActionResult> 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.");
}
}
+1 -3
View File
@@ -166,12 +166,10 @@ builder.Services.AddScoped<IGmailOAuthService, GmailOAuthService>();
builder.Services.AddSingleton<IGmailJobMatchingService, GmailJobMatchingService>(); builder.Services.AddSingleton<IGmailJobMatchingService, GmailJobMatchingService>();
builder.Services.AddSingleton<IGmailCorrespondenceEnrichmentService, NoOpGmailCorrespondenceEnrichmentService>(); builder.Services.AddSingleton<IGmailCorrespondenceEnrichmentService, NoOpGmailCorrespondenceEnrichmentService>();
builder.Services.AddScoped<IMicrosoftGraphOAuthService, MicrosoftGraphOAuthService>(); builder.Services.AddScoped<IMicrosoftGraphOAuthService, MicrosoftGraphOAuthService>();
builder.Services.AddScoped<IImapService, ImapService>();
// Provider-neutral email seam (multi-provider: Gmail + Microsoft Graph + IMAP today; manual next). // Provider-neutral email seam (multi-provider: Gmail + Microsoft Graph today; IMAP / manual next).
builder.Services.AddScoped<JobTrackerApi.Services.EmailProviders.IEmailProvider, JobTrackerApi.Services.EmailProviders.GmailProvider>(); builder.Services.AddScoped<JobTrackerApi.Services.EmailProviders.IEmailProvider, JobTrackerApi.Services.EmailProviders.GmailProvider>();
builder.Services.AddScoped<JobTrackerApi.Services.EmailProviders.IEmailProvider, JobTrackerApi.Services.EmailProviders.MicrosoftGraphProvider>(); builder.Services.AddScoped<JobTrackerApi.Services.EmailProviders.IEmailProvider, JobTrackerApi.Services.EmailProviders.MicrosoftGraphProvider>();
builder.Services.AddScoped<JobTrackerApi.Services.EmailProviders.IEmailProvider, JobTrackerApi.Services.EmailProviders.ImapProvider>();
builder.Services.AddScoped<JobTrackerApi.Services.EmailProviders.IEmailProviderRegistry, JobTrackerApi.Services.EmailProviders.EmailProviderRegistry>(); builder.Services.AddScoped<JobTrackerApi.Services.EmailProviders.IEmailProviderRegistry, JobTrackerApi.Services.EmailProviders.EmailProviderRegistry>();
builder.Services.AddIdentityCore<ApplicationUser>(options => builder.Services.AddIdentityCore<ApplicationUser>(options =>
@@ -1,63 +0,0 @@
using JobTrackerApi.Services;
namespace JobTrackerApi.Services.EmailProviders
{
/// <summary>
/// Generic IMAP implementation of <see cref="IEmailProvider"/> for mailboxes with no
/// dedicated OAuth provider. Adapts <see cref="IImapService"/> to the provider-neutral
/// contract, mapping IMAP DTOs to the neutral shapes.
/// </summary>
public sealed class ImapProvider : IEmailProvider
{
private readonly IImapService _imap;
public ImapProvider(IImapService imap)
{
_imap = imap;
}
public string ProviderKey => "imap";
public async Task<EmailConnectionInfo?> 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<IReadOnlyList<EmailMessageSummary>> 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<IReadOnlyList<EmailMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken)
{
var messages = await _imap.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
return messages.Select(ToSummary).ToList();
}
public async Task<EmailMessageDetail> 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);
}
}
-345
View File
@@ -1,345 +0,0 @@
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<ImapConnectResult> ConnectAsync(string ownerUserId, string host, int port, bool useSsl, string username, string password, CancellationToken cancellationToken);
Task<ImapConnection?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken);
Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken);
Task<IReadOnlyList<ImapMessageSummary>> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken);
Task<IReadOnlyList<ImapMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadKey, CancellationToken cancellationToken);
Task<ImapMessageDetail> 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<string> Labels, IReadOnlyList<ImapMessageAttachment> Attachments);
/// <summary>
/// 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).
/// </summary>
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<ImapConnectResult> 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<ImapConnection?> 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<IReadOnlyList<ImapMessageSummary>> 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<IReadOnlyList<ImapMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadKey, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(threadKey))
{
return Array.Empty<ImapMessageSummary>();
}
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<ImapMessageDetail> 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<string>(),
attachments);
}
catch (Exception ex)
{
await TouchSyncStateAsync(ownerUserId, "message-detail", "imap-message", false, ex.Message, cancellationToken);
throw;
}
}
private static async Task<IReadOnlyList<ImapMessageSummary>> FetchSummariesAsync(ImapClient client, IList<UniqueId> uids, CancellationToken cancellationToken)
{
if (uids.Count == 0) return Array.Empty<ImapMessageSummary>();
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<ImapClient> 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);
}
}
@@ -386,31 +386,6 @@ public static class StartupInitializationExtensions
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_MicrosoftGraphConnections_OwnerUserId_MailAddress" ON "MicrosoftGraphConnections" ("OwnerUserId", "MailAddress");"""); 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) static void EnsureCvTables(DbConnection c)
{ {
Exec(c, """ Exec(c, """
@@ -480,7 +455,6 @@ public static class StartupInitializationExtensions
EnsureGmailConnectionsTable(conn); EnsureGmailConnectionsTable(conn);
EnsureMicrosoftGraphConnectionsTable(conn); EnsureMicrosoftGraphConnectionsTable(conn);
EnsureImapConnectionsTable(conn);
EnsureCvTables(conn); EnsureCvTables(conn);
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded, // Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
@@ -666,7 +640,6 @@ public static class StartupInitializationExtensions
EnsureMySqlAutoIncrementPrimaryKey(conn, "JobEvents", "Id"); EnsureMySqlAutoIncrementPrimaryKey(conn, "JobEvents", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "GmailConnections", "Id"); EnsureMySqlAutoIncrementPrimaryKey(conn, "GmailConnections", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "MicrosoftGraphConnections", "Id"); EnsureMySqlAutoIncrementPrimaryKey(conn, "MicrosoftGraphConnections", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "ImapConnections", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvUploadArtifacts", "Id"); EnsureMySqlAutoIncrementPrimaryKey(conn, "CvUploadArtifacts", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvExtractionRuns", "Id"); EnsureMySqlAutoIncrementPrimaryKey(conn, "CvExtractionRuns", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "TailoredCvDrafts", "Id"); EnsureMySqlAutoIncrementPrimaryKey(conn, "TailoredCvDrafts", "Id");
@@ -895,30 +868,6 @@ public static class StartupInitializationExtensions
cmd.ExecuteNonQuery(); 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")) if (!HasMySqlTable(conn, "TailoredCvDrafts"))
{ {
using var cmd = conn.CreateCommand(); using var cmd = conn.CreateCommand();
@@ -1024,13 +973,6 @@ public static class StartupInitializationExtensions
cmd.ExecuteNonQuery(); 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")) if (!MySqlIndexExists(conn, "TailoredCvDrafts", "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId"))
{ {
using var cmd = conn.CreateCommand(); using var cmd = conn.CreateCommand();
@@ -29,6 +29,5 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.14" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.14" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.14.0" /> <PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.14.0" />
<PackageReference Include="MailKit" Version="4.17.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>
-20
View File
@@ -1,20 +0,0 @@
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; }
}