Files
jobtrackingapp/JobTrackerApi/Services/ImapService.cs
T
cesnimda a8e2f4dc4a
CI and Deploy / test (pull_request) Successful in 2m2s
CI and Deploy / deploy (pull_request) Has been skipped
feat(email): add ImapProvider (generic IMAP for unsupported providers)
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>
2026-07-11 19:40:50 +02:00

346 lines
17 KiB
C#

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);
}
}