a8e2f4dc4a
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>
97 lines
3.5 KiB
C#
97 lines
3.5 KiB
C#
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.");
|
|
}
|
|
}
|