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 ConnectAsync(string ownerUserId, string host, int port, bool useSsl, string username, string password, CancellationToken cancellationToken); Task GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken); Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken); Task> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken); Task> ListThreadMessagesAsync(string ownerUserId, string threadKey, CancellationToken cancellationToken); Task 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 Labels, IReadOnlyList Attachments); /// /// 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). /// 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 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 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> 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> ListThreadMessagesAsync(string ownerUserId, string threadKey, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(threadKey)) { return Array.Empty(); } 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 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(), attachments); } catch (Exception ex) { await TouchSyncStateAsync(ownerUserId, "message-detail", "imap-message", false, ex.Message, cancellationToken); throw; } } private static async Task> FetchSummariesAsync(ImapClient client, IList uids, CancellationToken cancellationToken) { if (uids.Count == 0) return Array.Empty(); 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 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); } }