feat(email): add ImapProvider (generic IMAP for unsupported providers)
CI and Deploy / test (pull_request) Successful in 2m2s
CI and Deploy / deploy (pull_request) Has been skipped

b3 of the multi-provider email roadmap. Adds ImapConnection model + table
(reconciler pattern, SQLite+MySQL), ImapService (MailKit-backed IMAP client),
ImapProvider implementing the existing IEmailProvider contract unchanged,
and ImapController for credential-based connect (no OAuth — user supplies
host/username/password directly, verified by a live connect before storage).

Scope, documented inline with ponytail: comments:
- INBOX only, no multi-folder support.
- Thread grouping approximates the References/In-Reply-To chain root rather
  than the IMAP THREAD extension, which not every server implements.
- External message ids are IMAP UIDs, scoped to the connection's current
  UIDVALIDITY.

Security: ran the security-audit skill against this diff (credential
handling + arbitrary-host connect is exactly the class of change the
standing security gate exists for). Found and fixed a real SSRF: the
connect endpoint let an authenticated user point the server at an
arbitrary host:port with no internal-range check, and connect-vs-auth
failure was distinguishable to the caller -- together a working oracle to
fingerprint internal services (loopback/RFC1918/link-local/cloud metadata)
from the server's network position. Fixed with EnsureHostIsExternalAsync
(DNS-resolve + reject internal ranges, re-checked on every reconnect to
close the DNS-rebinding gap) and a single generic failure message that no
longer distinguishes connect vs auth failure. 7 regression tests added.

Dependency: MailKit 4.17.0 (MIT license) on JobTrackerBackend.csproj --
stdlib has no IMAP client; hand-rolling IMAP4rev1 (TLS, SASL, MIME parsing)
would be a large, security-sensitive protocol implementation nobody asked
for, so this is the correct dependency, not a stdlib substitute.

168/168 green (161 existing + 7 new SSRF regression tests; the earlier
14 IMAP feature tests are included in the 161).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-11 18:53:16 +02:00
parent 8edbdceee9
commit a8e2f4dc4a
11 changed files with 849 additions and 1 deletions
+131
View File
@@ -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<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"))
}
}
};
}
}