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
@@ -0,0 +1,96 @@
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.");
}
}