using MailKit; using MailKit.Net.Imap; using MailKit.Search; using MailSharp.MailClient.Models; using Microsoft.Extensions.Options; using MimeKit; using System.Collections.Concurrent; using MailFolder = MailSharp.MailClient.Models.MailFolder; using ModelPriority = MailSharp.MailClient.Models.MessagePriority; namespace MailSharp.MailClient.Services; public interface IImapService { // includeSizes is false by default on purpose: computing folder sizes can mean a full // per-message header scan (see ResolveFolderSizeAsync) when the server doesn't support IMAP's // STATUS SIZE extension, so only the Settings > Mappen beheren page (the only place sizes are // ever shown) should ask for it - the Mail app's folder sidebar and the login connectivity // check never display sizes and shouldn't pay for computing them. // // includeUnsubscribed is false by default (LSUB - subscribed only) for the same reason: the Mail // app's sidebar should only ever show what the user has actually subscribed to. Mappen beheren // is the exception - its whole "Geabonneerd" checkbox column only makes sense if it can show a // folder that ISN'T subscribed yet (e.g. one just created, which doesn't auto-subscribe - see // CreateFolderAsync) so the user can toggle it on. Task> GetFoldersAsync(Account account, string password, bool includeSizes = false, bool forceRefreshSizes = false, bool includeUnsubscribed = false, CancellationToken ct = default); Task CreateFolderAsync(Account account, string password, string folderName, CancellationToken ct = default); // Real IMAP SUBSCRIBE/UNSUBSCRIBE, not folderSettingsStore's separate (and, until now, entirely // decorative) Subscribed field - see GetFoldersAsync's includeUnsubscribed. One connection for // the whole batch, since Settings > Mappen beheren saves every folder's checkbox state at once. Task SetFolderSubscriptionsAsync(Account account, string password, Dictionary subscriptions, CancellationToken ct = default); // Physically re-parents a folder on the server (IMAP RENAME to a new parent, same leaf name) - // unlike GetOrder/SaveOrder (Settings > Mappen beheren's existing display-only reordering), this // changes the folder's actual FullName/hierarchy. newParentFullName null/empty moves it to the // top level. Task MoveFolderAsync(Account account, string password, string folderFullName, string? newParentFullName, CancellationToken ct = default); // IMAP RENAME with the same parent, new leaf name - MoveFolderAsync's counterpart for changing // what a folder is called rather than where it sits in the tree. Task RenameFolderAsync(Account account, string password, string folderFullName, string newName, CancellationToken ct = default); // Refuses to delete non-empty folders (checked via IMAP message count, not the cached // UnreadCount) rather than silently expunging their contents - returns the subset of the // requested names that were skipped for that reason, so the caller can report it. Task> DeleteFoldersAsync(Account account, string password, IEnumerable folderFullNames, CancellationToken ct = default); Task> GetMessagesAsync(Account account, string password, string folderFullName, bool forceRefresh = false, CancellationToken ct = default); Task GetMessageAsync(Account account, string password, string folderFullName, uint uid, CancellationToken ct = default); Task GetAttachmentAsync(Account account, string password, string folderFullName, uint uid, int partIndex, CancellationToken ct = default); Task SaveDraftAsync(Account account, string password, ComposeModel compose, IEnumerable<(string FileName, Stream Content)> attachments, IEnumerable<(string Cid, string FileName, Stream Content)> inlineImages, CancellationToken ct = default); Task AppendSentAsync(Account account, string password, MimeMessage message, CancellationToken ct = default); Task SetSeenAsync(Account account, string password, string folderFullName, IEnumerable uids, bool seen, CancellationToken ct = default); Task SetFlaggedAsync(Account account, string password, string folderFullName, IEnumerable uids, bool flagged, CancellationToken ct = default); Task MarkAllAsync(Account account, string password, string folderFullName, bool seen, CancellationToken ct = default); Task MoveAsync(Account account, string password, string folderFullName, IEnumerable uids, string targetFolderFullName, CancellationToken ct = default); Task DeleteAsync(Account account, string password, string folderFullName, IEnumerable uids, CancellationToken ct = default); Task EmptyFolderAsync(Account account, string password, string folderFullName, CancellationToken ct = default); Task> GetSentContactsAsync(Account account, string password, CancellationToken ct = default); Task> SearchByAddressAsync(Account account, string password, string address, bool forceRefresh = false, CancellationToken ct = default); } public sealed record RecentFlagsRefreshResult(int AccountId, string Folder, int CheckedCount, int ChangedCount, long ElapsedMs, DateTime TimestampUtc); public class ImapService( IMailCacheStore cacheStore, IFolderSettingsStore folderSettingsStore, IMessageIndexStore indexStore, IOptions mailSettings, ImageSanitizer imageSanitizer, ILogger logger) : IImapService { private readonly MailSettings _settings = mailSettings.Value; // De-dup guard so a folder never gets indexed by two overlapping background builds at once // (e.g. two browser tabs, or a page reload while the first build is still running). Process- // wide (static) rather than per-instance since ImapService is scoped per request. Internal (not // private) so BackgroundIndexingStatus can read it for the Maintenance page without needing to // route indexing calls through a new service. internal static readonly ConcurrentDictionary IndexingInProgress = new(); // Message-level progress for the same key as IndexingInProgress ("{accountId}:{folder}") - // only populated during a full reindex (see EnsureFolderIndexedAsync), since that's the only // path slow enough for a raw count/percentage to matter to a user watching the toast; the // incremental sync paths finish fast enough that per-chunk progress wouldn't be visible anyway. internal static readonly ConcurrentDictionary IndexingProgress = new(); // Caps how many background index builds run their IMAP connection at once, process-wide. Most // IMAP servers cap concurrent connections per account - without this, clicking through several // never-indexed folders in a row would fire off that many simultaneous background connections, // competing with (and slowing down) whatever the user is actually waiting on in the foreground. internal const int MaxConcurrentBackgroundIndexing = 2; internal static readonly SemaphoreSlim BackgroundIndexingThrottle = new(MaxConcurrentBackgroundIndexing, MaxConcurrentBackgroundIndexing); // Most read/flag changes made from another client (a phone, another tab) land on recently // received mail - a user who checks mail daily has their unread messages concentrated in // roughly the last day or so, not scattered across a 74,000-message mailbox. So instead of the // old approach (compare the whole folder's unread count and, on any mismatch, FETCH FLAGS for // every UID - see EnsureFolderIndexedAsync's history), this only ever re-checks the flags of the // most recent RecentFlagsRefreshCount messages, and only running here in the background so it // never blocks GetMessagesAsync. Results (and how long the FETCH actually took, so it's easy to // see from the logs/SSE stream whether this approach is actually cheap in practice) are kept // per "{accountId}:{folder}" key and pushed to clients over the same SSE channel as the // background-indexing toast (see MailApiController.IndexingStatusStream). internal const int RecentFlagsRefreshCount = 200; internal static readonly ConcurrentDictionary RecentFlagsRefreshResults = new(); private static readonly ConcurrentDictionary RecentFlagsRefreshInProgress = new(); // Cheap cooldown so rapid repeated folder opens (a user bouncing between tabs, or a slow // connection retrying) don't each kick off their own IMAP connection - only useful information // once per interval anyway, since nothing changes flags that fast. private static readonly TimeSpan RecentFlagsRefreshCooldown = TimeSpan.FromSeconds(20); // Serializes StartRecentFlagsRefresh against SetSeenAsync/SetFlaggedAsync for the same // "{accountId}:{folder}" key - without this, a background refresh that read a message's flags // from the server just before the user explicitly marked it read/unread/flagged could still be // mid-flight when that explicit action's own indexStore write lands, and then overwrite it with // its now-stale snapshot a moment later (indistinguishable from the user's own change silently // reverting). Whichever of the two starts first now runs to completion - including its local // index write - before the other's fetch begins, so a background refresh either sees the // explicit change already applied (server-side too, so it agrees) or the explicit action // naturally comes after and simply wins. private static readonly ConcurrentDictionary FolderMutationLocks = new(); private static SemaphoreSlim GetFolderMutationLock(string key) => FolderMutationLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); // Building a folder's index for the first time means a full IMAP scan (see // EnsureFolderIndexedAsync) - too slow to do inline within the request that triggered it (the // user would stare at a blank screen). Instead this runs it on a background Task using its own // fresh IMAP connection, while the caller falls back to the old capped-live-fetch path for this // one request; once the background build finishes, the next request finds the index ready and // takes the fast path. Deliberately uses no request-scoped state (only the static ConnectAsync // and the singleton-backed index/settings stores captured on `this`), so it's safe to keep // running after the HTTP request that started it has already completed. private void StartBackgroundIndexBuild(Account account, string password, string folderFullName) { var key = $"{account.Id}:{folderFullName}"; if (!IndexingInProgress.TryAdd(key, 0)) { logger.LogInformation("Background index build for account {AccountId} folder {Folder} already in progress, skipping duplicate trigger", account.Id, folderFullName); return; } logger.LogInformation("Background index build queued for account {AccountId} folder {Folder}", account.Id, folderFullName); var sw = System.Diagnostics.Stopwatch.StartNew(); _ = Task.Run(async () => { try { // Let the page load that triggered this finish its own IMAP connections first // (folder list, live message fallback) instead of immediately competing for the same // account's connection slots. await Task.Delay(TimeSpan.FromSeconds(3)); await BackgroundIndexingThrottle.WaitAsync(); try { await EnsureFolderIndexedAsync(account, password, folderFullName, CancellationToken.None); logger.LogInformation("Background index build completed for account {AccountId} folder {Folder} in {DurationMs}ms, {Count} messages", account.Id, folderFullName, sw.ElapsedMilliseconds, indexStore.GetMessageCount(account.Id, folderFullName)); } finally { BackgroundIndexingThrottle.Release(); } } catch (Exception ex) { logger.LogWarning(ex, "Background index build failed for account {AccountId} folder {Folder} after {DurationMs}ms", account.Id, folderFullName, sw.ElapsedMilliseconds); } finally { IndexingInProgress.TryRemove(key, out _); } }); } // See RecentFlagsRefreshResults - re-checks only the most recent RecentFlagsRefreshCount // messages' flags (a cheap FETCH FLAGS, not the full Envelope/Size fetch a real index rebuild // needs) so read/flag changes made from another client show up without ever touching the rest // of a large mailbox. Always backgrounded (called from EnsureFolderIndexedAsync, which must not // block on it) and skipped entirely if a full reindex is already in flight for this folder - // that reindex will already pick up current flags for free. private void StartRecentFlagsRefresh(Account account, string password, string folderFullName) { var key = $"{account.Id}:{folderFullName}"; if (IndexingInProgress.ContainsKey(key)) return; if (RecentFlagsRefreshResults.TryGetValue(key, out var last) && DateTime.UtcNow - last.TimestampUtc < RecentFlagsRefreshCooldown) return; if (!RecentFlagsRefreshInProgress.TryAdd(key, 0)) return; var sw = System.Diagnostics.Stopwatch.StartNew(); _ = Task.Run(async () => { try { await BackgroundIndexingThrottle.WaitAsync(); try { // IMAP fetch happens before the lock is taken (no need to serialize the network // round trip against explicit actions, only the read-compare-write against the // local index) - but the local snapshot has to be re-taken *inside* the lock, // otherwise an explicit SetSeenAsync/SetFlaggedAsync that lands between the first // snapshot and the fetch completing would get diffed against stale local data here. var candidateUids = indexStore.GetMessages(account.Id, folderFullName) .OrderByDescending(m => m.Uid) .Take(RecentFlagsRefreshCount) .Select(m => m.Uid) .ToList(); if (candidateUids.Count == 0) return; using var client = await ConnectAsync(account, password, CancellationToken.None); var folder = await client.GetFolderAsync(folderFullName, CancellationToken.None); await folder.OpenAsync(FolderAccess.ReadOnly, CancellationToken.None); var summaries = await folder.FetchAsync( [.. candidateUids.Select(uid => new UniqueId(uid))], MessageSummaryItems.Flags, CancellationToken.None); await folder.CloseAsync(false, CancellationToken.None); await client.DisconnectAsync(true, CancellationToken.None); var folderLock = GetFolderMutationLock(key); await folderLock.WaitAsync(); int changedCount; List stillUnreadUids; try { var currentLocal = indexStore.GetMessages(account.Id, folderFullName).ToDictionary(m => m.Uid); var toMarkRead = new List(); var toMarkUnread = new List(); var toMarkFlagged = new List(); var toMarkUnflagged = new List(); stillUnreadUids = []; foreach (var summary in summaries) { if (!currentLocal.TryGetValue(summary.UniqueId.Id, out var local)) continue; var isRead = summary.Flags?.HasFlag(MessageFlags.Seen) ?? local.IsRead; var isFlagged = summary.Flags?.HasFlag(MessageFlags.Flagged) ?? local.IsFlagged; if (isRead != local.IsRead) (isRead ? toMarkRead : toMarkUnread).Add(local.Uid); if (isFlagged != local.IsFlagged) (isFlagged ? toMarkFlagged : toMarkUnflagged).Add(local.Uid); if (!isRead) stillUnreadUids.Add(local.Uid); } if (toMarkRead.Count > 0) indexStore.MarkRead(account.Id, folderFullName, toMarkRead, true); if (toMarkUnread.Count > 0) indexStore.MarkRead(account.Id, folderFullName, toMarkUnread, false); if (toMarkFlagged.Count > 0) indexStore.MarkFlagged(account.Id, folderFullName, toMarkFlagged, true); if (toMarkUnflagged.Count > 0) indexStore.MarkFlagged(account.Id, folderFullName, toMarkUnflagged, false); changedCount = new HashSet([.. toMarkRead, .. toMarkUnread, .. toMarkFlagged, .. toMarkUnflagged]).Count; } finally { folderLock.Release(); } var result = new RecentFlagsRefreshResult(account.Id, folderFullName, candidateUids.Count, changedCount, sw.ElapsedMilliseconds, DateTime.UtcNow); RecentFlagsRefreshResults[key] = result; logger.LogInformation("Recent flags refresh for folder {Folder} account {AccountId}: checked {CheckedCount} of the most recent messages, {ChangedCount} changed, took {ElapsedMs}ms", folderFullName, account.Id, result.CheckedCount, result.ChangedCount, result.ElapsedMs); // Directly answers "which message is stuck unread" - per the server's own live // Flags response just fetched above, not a guess derived from the local index. if (stillUnreadUids.Count > 0) { logger.LogInformation("Recent flags refresh for folder {Folder} account {AccountId}: {Count} still unread among the checked messages (server-confirmed): {Uids}", folderFullName, account.Id, stillUnreadUids.Count, string.Join(",", stillUnreadUids)); } } finally { BackgroundIndexingThrottle.Release(); } } catch (Exception ex) { logger.LogWarning(ex, "Recent flags refresh failed for account {AccountId} folder {Folder} after {ElapsedMs}ms", account.Id, folderFullName, sw.ElapsedMilliseconds); } finally { RecentFlagsRefreshInProgress.TryRemove(key, out _); } }); } // IMailFolder.GetSubfoldersAsync only returns the immediate children of the folder it's called // on - it is NOT recursive. Called on just the personal namespace root (as every call site here // used to do), that means any folder nested two or more levels deep (e.g. "Archief/AliExpress", // "Willy/okkerse") never showed up anywhere: not in the folder list, not in special-folder // lookups, not in cross-folder address search. Walking every folder's own children in turn is // the only way MailKit exposes the full tree. private static async Task> GetAllSubfoldersRecursiveAsync(IMailFolder root, bool subscribedOnly, CancellationToken ct) { var result = new List(); foreach (var folder in await root.GetSubfoldersAsync(subscribedOnly, ct)) { result.Add(folder); result.AddRange(await GetAllSubfoldersRecursiveAsync(folder, subscribedOnly, ct)); } return result; } // SpecialFolder.* lookups rely on the server advertising IMAP SPECIAL-USE/XLIST - servers that // don't (like this one) make MailKit throw NotSupportedException rather than returning null. // Falls back to matching common folder names (English/Dutch) among the account's actual // folders, then to a couple of hardcoded full-name guesses; returns null (rather than throwing) // if nothing matches, since some callers (e.g. "delete" falling back to expunge when there's no // Trash) treat "no such folder" as a valid, handleable outcome. private static async Task ResolveFolderOrNullAsync(ImapClient client, SpecialFolder special, string[] nameHints, string[] nameGuesses, CancellationToken ct) { IMailFolder? folder = null; try { folder = client.GetFolder(special); } catch (NotSupportedException) { /* server doesn't advertise SPECIAL-USE/XLIST - fall through */ } if (folder != null) return folder; var personal = client.GetFolder(client.PersonalNamespaces[0]); var subfolders = await GetAllSubfoldersRecursiveAsync(personal, true, ct); var byName = subfolders.FirstOrDefault(f => nameHints.Any(h => f.Name.Contains(h, StringComparison.OrdinalIgnoreCase))); if (byName != null) return byName; foreach (var guess in nameGuesses) { try { return await client.GetFolderAsync(guess, ct); } catch { /* try next guess */ } } return null; } private static readonly string[] SentNameHints = ["sent", "verzonden"]; private static readonly string[] SentNameGuesses = ["Sent", "Sent Items", "Sent Messages", "INBOX.Sent", "INBOX/Sent"]; private static readonly string[] DraftsNameHints = ["draft", "concept"]; private static readonly string[] DraftsNameGuesses = ["Drafts", "INBOX.Drafts", "INBOX/Drafts"]; private static readonly string[] TrashNameHints = ["trash", "deleted", "prullenbak"]; private static readonly string[] TrashNameGuesses = ["Trash", "Deleted Items", "INBOX.Trash", "INBOX/Trash"]; private static readonly string[] JunkNameHints = ["spam", "junk", "unwanted", "ongewenst"]; // INBOX (by definition) and whatever folder is filling Sent/Trash/Drafts/Junk's role can't be // deleted (see DeleteFoldersAsync) since core features assume they exist - detected the same // way as the Resolve*FolderAsync helpers above: SPECIAL-USE attributes first, common name // hints as a fallback for servers that don't advertise SPECIAL-USE. private static bool IsProtectedFolder(string fullName, string name, FolderAttributes attributes) { if (string.Equals(fullName, "INBOX", StringComparison.OrdinalIgnoreCase)) return true; if ((attributes & (FolderAttributes.Inbox | FolderAttributes.Sent | FolderAttributes.Trash | FolderAttributes.Drafts | FolderAttributes.Junk)) != 0) return true; return SentNameHints.Concat(TrashNameHints).Concat(DraftsNameHints).Concat(JunkNameHints) .Any(h => name.Contains(h, StringComparison.OrdinalIgnoreCase)); } // External images are how spam/phishing senders confirm a mailbox is live and being read (a // tracking pixel that loads means "human opened this") - the per-sender "always show" preference // (see IMailCacheStore.GetAllowExternalImages) exists for legitimate senders a user trusts, but // that trust decision shouldn't apply inside the folder that exists specifically to hold mail the // user (or the server's own spam filter) has flagged as untrustworthy. Only the leaf folder name // is checked (not the account's other folders sharing a hint substring, e.g. a folder named // "Sponsors" containing "spo" wouldn't match "spam" as a whole segment). // Internal (not private) so MailApiController can reject the "always show images from this // sender" action outright when it's being requested from within the Spam folder - see AllowImages. internal static bool IsSpamFolder(string folderFullName) { var leaf = folderFullName.Split('/', '.') is { Length: > 0 } segments ? segments[^1] : folderFullName; return JunkNameHints.Any(h => leaf.Contains(h, StringComparison.OrdinalIgnoreCase)); } private static async Task ResolveSentFolderAsync(ImapClient client, CancellationToken ct) => await ResolveFolderOrNullAsync(client, SpecialFolder.Sent, SentNameHints, SentNameGuesses, ct) ?? throw new InvalidOperationException("Could not locate a Sent folder on this account."); private static async Task ResolveDraftsFolderAsync(ImapClient client, CancellationToken ct) => await ResolveFolderOrNullAsync(client, SpecialFolder.Drafts, DraftsNameHints, DraftsNameGuesses, ct) ?? throw new InvalidOperationException("Could not locate a Drafts folder on this account."); private static Task ResolveTrashFolderAsync(ImapClient client, CancellationToken ct) => ResolveFolderOrNullAsync(client, SpecialFolder.Trash, TrashNameHints, TrashNameGuesses, ct); // Bounds every socket read/write MailKit does on a client - connect, auth, LIST, STATUS, // FETCH, disconnect, everywhere this client gets used - not just the initial connect. Without // this a server that accepts the connection/auth but then stalls mid-protocol (e.g. a firewall // that lets the handshake through but silently drops later packets) hangs the calling request // forever with nothing to log; MailKit's own default (120s) is too long to notice quickly and, // being per-client rather than something we set, easy to forget applies to every code path that // creates a client, not just this one. A single retry with a fresh client/socket rides out the // transient case (e.g. one bad connection attempt against a loaded mail server) without // surfacing an error to the user. private const int ImapTimeoutSeconds = 20; private const int ConnectRetryCount = 1; // Process-wide count of currently-open IMAP connections (across every account/request) - the // one piece of visibility that was missing while diagnosing the "mappen beheren never comes // back" issue: nothing before this logged how many connections were actually in flight at once, // so a pile-up (e.g. one new connection per folder) was invisible until it made the page hang. // Decremented via MailKit's Disconnected event rather than at the call site, so it stays // accurate however the client goes away - explicit DisconnectAsync, Dispose() on a `using`, or // the socket dying under it. private static int _openConnectionCount; private async Task ConnectAsync(Account account, string password, CancellationToken ct) { var secureSocket = account.ImapSecurity switch { SecurityMode.SslTls => MailKit.Security.SecureSocketOptions.SslOnConnect, SecurityMode.StartTls => MailKit.Security.SecureSocketOptions.StartTls, _ => MailKit.Security.SecureSocketOptions.None }; for (var attempt = 0; ; attempt++) { var client = new ImapClient { Timeout = ImapTimeoutSeconds * 1000 }; var sw = System.Diagnostics.Stopwatch.StartNew(); try { logger.LogDebug("IMAP connecting: account {AccountId} to {Host}:{Port}, attempt {Attempt}, {OpenConnections} connection(s) currently open", account.Id, account.ImapHost, account.ImapPort, attempt + 1, _openConnectionCount); await client.ConnectAsync(account.ImapHost, account.ImapPort, secureSocket, ct); await client.AuthenticateAsync(account.Username, password, ct); var opened = Interlocked.Increment(ref _openConnectionCount); client.Disconnected += (_, _) => { var remaining = Interlocked.Decrement(ref _openConnectionCount); logger.LogDebug("IMAP disconnected: account {AccountId} from {Host}:{Port}, {OpenConnections} connection(s) still open", account.Id, account.ImapHost, account.ImapPort, remaining); }; logger.LogDebug("IMAP connected: account {AccountId} to {Host}:{Port} in {ElapsedMs}ms, {OpenConnections} connection(s) now open", account.Id, account.ImapHost, account.ImapPort, sw.ElapsedMilliseconds, opened); return client; } catch (Exception ex) when (!ct.IsCancellationRequested) { client.Dispose(); if (attempt < ConnectRetryCount) { logger.LogWarning(ex, "IMAP connect/authenticate attempt {Attempt} failed for account {AccountId} against {Host}:{Port} after {ElapsedMs}ms, retrying", attempt + 1, account.Id, account.ImapHost, account.ImapPort, sw.ElapsedMilliseconds); continue; } logger.LogError(ex, "IMAP connect/authenticate failed for account {AccountId} against {Host}:{Port} after {Attempts} attempt(s), {ElapsedMs}ms", account.Id, account.ImapHost, account.ImapPort, attempt + 1, sw.ElapsedMilliseconds); throw; } } } public async Task> GetFoldersAsync(Account account, string password, bool includeSizes = false, bool forceRefreshSizes = false, bool includeUnsubscribed = false, CancellationToken ct = default) { using var scope = logger.BeginScope(new Dictionary { ["accountId"] = account.Id }); var sw = System.Diagnostics.Stopwatch.StartNew(); using var client = await ConnectAsync(account, password, ct); var result = new List(); var personal = client.GetFolder(client.PersonalNamespaces[0]); var folders = await GetAllSubfoldersRecursiveAsync(personal, !includeUnsubscribed, ct); var settings = folderSettingsStore.GetAll(account.Id); // "Subscribed" reported to the client is the real IMAP subscription state, not a locally // stored preference (folderSettingsStore's own Subscribed field is unrelated - see // SetFolderSubscriptionAsync) - a folder is subscribed exactly when it appears in the LSUB // listing. When this call already used LSUB-only (the Mail sidebar's normal path), every // folder here already IS subscribed by definition; only Mappen beheren's includeUnsubscribed // pass needs the extra LSUB walk to know which of the (now larger) full LIST is subscribed. HashSet? subscribedFullNames = includeUnsubscribed ? [.. (await GetAllSubfoldersRecursiveAsync(personal, true, ct)).Select(sf => sf.FullName)] : null; logger.LogInformation("GetFoldersAsync: account {AccountId}, includeSizes={IncludeSizes}, forceRefreshSizes={ForceRefreshSizes}, {FolderCount} folder(s) listed after {ElapsedMs}ms", account.Id, includeSizes, forceRefreshSizes, folders.Count + 1, sw.ElapsedMilliseconds); foreach (var f in new[] { client.Inbox }.Concat(folders)) { if (result.Any(x => x.FullName == f.FullName)) continue; var setting = settings.GetValueOrDefault(f.FullName); var depth = FolderDepth(f, personal); var isProtected = IsProtectedFolder(f.FullName, f.Name, f.Attributes); var folderSw = System.Diagnostics.Stopwatch.StartNew(); try { // STATUS rather than SELECT/Open - cheaper (no mailbox actually gets opened). Only // asks for SIZE (RFC 8438) when the caller actually wants sizes - not every server // supports that extension, so retry without it rather than losing Count/Unread too. if (includeSizes) { try { await f.StatusAsync(StatusItems.Count | StatusItems.Unread | StatusItems.Size, ct); } catch { await f.StatusAsync(StatusItems.Count | StatusItems.Unread, ct); } } else { await f.StatusAsync(StatusItems.Count | StatusItems.Unread, ct); } var (sizeBytes, isSizeEstimated) = includeSizes ? await ResolveFolderSizeAsync(account, password, client, f, setting?.SyncMode ?? FolderSyncMode.Direct, forceRefreshSizes, ct) : (0, false); if (folderSw.ElapsedMilliseconds > 500) { logger.LogInformation("GetFoldersAsync: folder {Folder} for account {AccountId} took {ElapsedMs}ms", f.FullName, account.Id, folderSw.ElapsedMilliseconds); } // Recent-scoped unread, not the folder's raw live total (see GetRecentUnreadCount) - // a message from years ago, or the tens of thousands sitting untouched in Trash, // shouldn't move this badge. Falls back to the live IMAP count for NotSynchronized // folders (which are deliberately never indexed) or a folder not indexed yet. var syncMode = setting?.SyncMode ?? FolderSyncMode.Direct; var isIndexed = syncMode != FolderSyncMode.NotSynchronized && indexStore.GetSyncState(account.Id, f.FullName) != null; var unreadCount = isIndexed ? indexStore.GetRecentUnreadCount(account.Id, f.FullName, RecentFlagsRefreshCount) : f.Unread; if (isIndexed && unreadCount > 0) { var uids = indexStore.GetRecentUnreadUids(account.Id, f.FullName, RecentFlagsRefreshCount); logger.LogInformation("GetFoldersAsync: folder {Folder} for account {AccountId} shows {UnreadCount} unread among the most recent {RecentCount} - uids: {Uids}", f.FullName, account.Id, unreadCount, RecentFlagsRefreshCount, string.Join(",", uids)); } result.Add(new MailFolder { FullName = f.FullName, DisplayName = f.Name, UnreadCount = unreadCount, MessageCount = f.Count, IsSelectable = true, Depth = depth, SyncMode = setting?.SyncMode ?? FolderSyncMode.Direct, Subscribed = subscribedFullNames?.Contains(f.FullName) ?? true, SizeBytes = sizeBytes, IsSizeEstimated = isSizeEstimated, IsProtected = isProtected }); } catch (Exception ex) { logger.LogWarning(ex, "GetFoldersAsync: folder {Folder} for account {AccountId} failed STATUS/size resolution after {ElapsedMs}ms, marking unselectable", f.FullName, account.Id, folderSw.ElapsedMilliseconds); result.Add(new MailFolder { FullName = f.FullName, DisplayName = f.Name, IsSelectable = false, Depth = depth, SyncMode = setting?.SyncMode ?? FolderSyncMode.Direct, Subscribed = subscribedFullNames?.Contains(f.FullName) ?? true, IsProtected = isProtected }); } } await client.DisconnectAsync(true, ct); logger.LogInformation("GetFoldersAsync: account {AccountId} completed in {ElapsedMs}ms, {FolderCount} folder(s)", account.Id, sw.ElapsedMilliseconds, result.Count); return ApplyDisplayOrder(result, folderSettingsStore.GetOrder(account.Id)); } // Real total size: IMAP STATUS SIZE (RFC 8438) where the server supports it - already checked // by the caller via f.Size. Otherwise this is the local message index's own total (see // EnsureFolderIndexedAsync) - exact, not an estimate, and free once the folder is indexed. // NotSynchronized folders are deliberately never indexed, so their size is simply unknown (0) // rather than triggering the very full-folder scan that setting exists to avoid. A folder // that's never been indexed yet also shows 0 for now - its first build runs in the background // (see StartBackgroundIndexBuild) rather than blocking this page load; a later reload picks up // the real total once that finishes. // Takes the already-connected client from GetFoldersAsync's own loop rather than reconnecting - // resolving sizes for every already-indexed folder on the Settings > Mappen beheren page used to // open a brand new IMAP connection per folder (via EnsureFolderIndexedAsync's own ConnectAsync), // which for an account with many folders meant that many sequential connect+auth round trips // (plus any per-folder flag-drift reconciliation) serialized within one request - individually // fast enough that nothing ever hit a socket timeout, but the page never came back in practice. private async Task<(long SizeBytes, bool IsEstimated)> ResolveFolderSizeAsync(Account account, string password, ImapClient client, IMailFolder folder, FolderSyncMode syncMode, bool forceRebuild, CancellationToken ct) { if (folder.Size.HasValue) return ((long)folder.Size.Value, false); if (syncMode == FolderSyncMode.NotSynchronized) return (0, false); var state = indexStore.GetSyncState(account.Id, folder.FullName); if (forceRebuild && state != null) { indexStore.RemoveFolder(account.Id, folder.FullName); state = null; } if (state == null) { StartBackgroundIndexBuild(account, password, folder.FullName); return (0, false); } try { await EnsureFolderIndexedAsync(account, password, client, folder.FullName, ct); return (indexStore.GetTotalSize(account.Id, folder.FullName), false); } catch { return (0, false); } } // Keeps a local header index (Envelope/Flags/Size per message) up to date without re-scanning // everything on every access - the same headers GetMessagesAsync/GetFoldersAsync/ // SearchByAddressAsync/GetSentContactsAsync already needed, just fetched once and kept in sync // instead of re-fetched (fully or partially) every time. No CONDSTORE required: UIDVALIDITY // changing means the server considers the mailbox rebuilt (full reindex); UIDNEXT advancing // means new mail arrived (cheap incremental fetch of just the new UID range); and the message // count not matching the index's own row count is the cheap signal - already free from the // STATUS call below - that something was removed (by this app or another IMAP client), which is // the only case expensive enough (a UID-only SEARCH ALL) to want to avoid doing blindly. // // Deliberately doesn't fetch BODYSTRUCTURE: some servers send a slightly non-conformant one for // certain (often malformed/spam) messages, and MailKit doesn't just skip that one message - a // parse error mid-response desyncs the whole stream, disconnecting the client outright. The only // thing BODYSTRUCTURE would have bought here is the message list's HasAttachments paperclip icon, // not worth risking the entire index build over. private async Task EnsureFolderIndexedAsync(Account account, string password, string folderFullName, CancellationToken ct) { using var scope = logger.BeginScope(new Dictionary { ["accountId"] = account.Id, ["folder"] = folderFullName }); using var client = await ConnectAsync(account, password, ct); await EnsureFolderIndexedAsync(account, password, client, folderFullName, ct); await client.DisconnectAsync(true, ct); } // See ResolveFolderSizeAsync - lets a caller that already has an open, authenticated client // (GetFoldersAsync's per-folder size resolution) reuse it instead of paying for a fresh // connect+auth round trip per folder. Never disconnects the client itself; that's the caller's // connection to manage. Takes password (unlike the client itself) purely to hand off to // StartRecentFlagsRefresh below, which needs its own separate background connection. private async Task EnsureFolderIndexedAsync(Account account, string password, ImapClient client, string folderFullName, CancellationToken ct) { using var scope = logger.BeginScope(new Dictionary { ["accountId"] = account.Id, ["folder"] = folderFullName }); var sw = System.Diagnostics.Stopwatch.StartNew(); var folder = await client.GetFolderAsync(folderFullName, ct); await folder.StatusAsync(StatusItems.Count | StatusItems.UidValidity | StatusItems.UidNext | StatusItems.Unread, ct); var uidNext = folder.UidNext?.Id ?? 0; var state = indexStore.GetSyncState(account.Id, folderFullName); logger.LogDebug("EnsureFolderIndexedAsync: folder {Folder} for account {AccountId}, indexState={IndexState}, serverCount={ServerCount}, serverUidValidity={UidValidity}, serverUidNext={UidNext}", folderFullName, account.Id, state == null ? "none" : "present", folder.Count, folder.UidValidity, uidNext); if (state == null || state.UidValidity != folder.UidValidity) { logger.LogInformation("Full reindex of folder {Folder} for account {AccountId} ({Reason})", folderFullName, account.Id, state == null ? "first index" : "UIDVALIDITY changed"); await folder.OpenAsync(FolderAccess.ReadOnly, ct); var allUids = await folder.SearchAsync(SearchQuery.All, ct); var progressKey = $"{account.Id}:{folderFullName}"; var messages = new List(allUids.Count); try { if (allUids.Count > 0) IndexingProgress[progressKey] = (0, allUids.Count); // Fetched in chunks (rather than one FETCH for every UID) purely so IndexingProgress // can be updated as we go - MailKit has no per-message progress callback for FETCH, // only per-chunk granularity is achievable this way. const int ChunkSize = 200; for (var i = 0; i < allUids.Count; i += ChunkSize) { var chunk = allUids.Skip(i).Take(ChunkSize).ToList(); messages.AddRange(await FetchIndexedMessagesAsync(folder, chunk, ct)); IndexingProgress[progressKey] = (messages.Count, allUids.Count); } } finally { IndexingProgress.TryRemove(progressKey, out _); } await folder.CloseAsync(false, ct); indexStore.ReplaceFolder(account.Id, folderFullName, messages); indexStore.SaveSyncState(account.Id, folderFullName, folder.UidValidity, uidNext, folder.Count); logger.LogInformation("EnsureFolderIndexedAsync: folder {Folder} for account {AccountId} full reindex of {MessageCount} message(s) took {ElapsedMs}ms", folderFullName, account.Id, messages.Count, sw.ElapsedMilliseconds); return; } if (uidNext > state.UidNext) { await folder.OpenAsync(FolderAccess.ReadOnly, ct); var range = new UniqueIdRange(new UniqueId(state.UidNext), UniqueId.MaxValue); var newMessages = await FetchIndexedMessagesAsync(folder, range, ct); await folder.CloseAsync(false, ct); if (newMessages.Count > 0) indexStore.UpsertMessages(account.Id, folderFullName, newMessages); } if (indexStore.GetMessageCount(account.Id, folderFullName) != folder.Count) { await folder.OpenAsync(FolderAccess.ReadOnly, ct); var currentUids = (await folder.SearchAsync(SearchQuery.All, ct)).Select(u => u.Id).ToHashSet(); await folder.CloseAsync(false, ct); var missing = indexStore.GetMessages(account.Id, folderFullName) .Select(m => m.Uid) .Where(uid => !currentUids.Contains(uid)) .ToList(); if (missing.Count > 0) indexStore.RemoveMessages(account.Id, folderFullName, missing); } indexStore.SaveSyncState(account.Id, folderFullName, folder.UidValidity, uidNext, folder.Count); logger.LogDebug("EnsureFolderIndexedAsync: folder {Folder} for account {AccountId} incremental sync took {ElapsedMs}ms", folderFullName, account.Id, sw.ElapsedMilliseconds); StartRecentFlagsRefresh(account, password, folderFullName); } // Priority isn't covered by MessageSummaryItems' normal flags (Envelope/Flags/Size) - MailKit // only exposes it by explicitly requesting the two headers senders actually use for it // (Importance and the older X-Priority), fetched here rather than the whole header block to // keep this as cheap as the rest of the index build. private static readonly HeaderId[] PriorityHeaderFields = [HeaderId.Importance, HeaderId.XPriority]; // A message with no Date header (seen in the wild from at least one spam sender) makes both // MimeKit and, apparently, some IMAP servers' own ENVELOPE response fall back to an implausible // default (e.g. year 1) rather than something usable - which then sorts that message to the very // bottom of a folder with tens of thousands of messages, making it effectively unfindable by // scrolling/paging (see the INBOX uid 95868 investigation). INTERNALDATE - when the server itself // received the message - is always present and a much more useful fallback than an arbitrary // sentinel, so anything implausibly old uses that instead. private static readonly DateTimeOffset PlausibleDateFloor = new(1990, 1, 1, 0, 0, 0, TimeSpan.Zero); private static DateTimeOffset ResolveMessageDate(IMessageSummary s) => s.Envelope?.Date is { } envelopeDate && envelopeDate >= PlausibleDateFloor ? envelopeDate : s.InternalDate ?? DateTimeOffset.MinValue; // See GetMessageAsync's date self-heal - a single lightweight FETCH just for INTERNALDATE, its // own short-lived connection since this only runs for the rare already-broken index entry. private async Task FetchInternalDateAsync(Account account, string password, string folderFullName, uint uid, CancellationToken ct) { using var client = await ConnectAsync(account, password, ct); var folder = await client.GetFolderAsync(folderFullName, ct); var summaries = await folder.FetchAsync([new UniqueId(uid)], MessageSummaryItems.InternalDate, ct); await client.DisconnectAsync(true, ct); return summaries.FirstOrDefault()?.InternalDate; } private static async Task> FetchIndexedMessagesAsync(IMailFolder folder, IList uids, CancellationToken ct) { var summaries = await folder.FetchAsync(uids, MessageSummaryItems.Envelope | MessageSummaryItems.Flags | MessageSummaryItems.Size | MessageSummaryItems.InternalDate, PriorityHeaderFields, ct); return [.. summaries.Select(s => new IndexedMessage { Uid = s.UniqueId.Id, Subject = s.Envelope?.Subject ?? "(no subject)", From = s.Envelope?.From?.ToString() ?? "", To = s.Envelope?.To?.ToString() ?? "", Cc = s.Envelope?.Cc?.ToString() ?? "", Date = ResolveMessageDate(s), SizeBytes = s.Size ?? 0, IsRead = s.Flags?.HasFlag(MessageFlags.Seen) ?? false, IsFlagged = s.Flags?.HasFlag(MessageFlags.Flagged) ?? false, HasAttachments = false, Priority = ParsePriorityHeaders(s.Headers) })]; } private static ModelPriority ParsePriorityHeaders(HeaderList? headers) { var importance = headers?[HeaderId.Importance]?.Trim(); if (string.Equals(importance, "high", StringComparison.OrdinalIgnoreCase)) return ModelPriority.High; if (string.Equals(importance, "low", StringComparison.OrdinalIgnoreCase)) return ModelPriority.Low; // X-Priority: 1-2 = High, 3 = Normal, 4-5 = Low (the de-facto convention since Outlook Express). var xPriority = headers?[HeaderId.XPriority]?.Trim(); if (xPriority != null && xPriority.Length > 0 && char.IsDigit(xPriority[0])) { return xPriority[0] switch { '1' or '2' => ModelPriority.High, '4' or '5' => ModelPriority.Low, _ => ModelPriority.Normal }; } return ModelPriority.Normal; } // Presentation-only reordering (Settings > Mappen beheren) - never touches the real IMAP // folder structure. Folders mentioned in savedOrder come first, in that sequence; anything // else (new folders discovered since the order was last saved) is appended afterwards in its // original IMAP discovery order. private static List ApplyDisplayOrder(List folders, List savedOrder) { if (savedOrder.Count == 0) return folders; var position = savedOrder.Select((name, i) => (name, i)).ToDictionary(x => x.name, x => x.i, StringComparer.Ordinal); // A folder not in savedOrder (e.g. a subfolder GetFoldersAsync's now-recursive walk finds // that didn't exist - or wasn't visible - when the order was last saved) used to sort via // int.MaxValue, dumping it at the very end regardless of where it actually belongs in the // tree - every newly-discovered nested folder clumped together at the bottom, detached from // its parent. Instead it inherits the position of the nearest PRECEDING ordered folder: since // `folders` arrives here in parent-before-children discovery order, that's always its own // ancestor (or an ancestor's already-ordered sibling), so it stays visually attached to where // it belongs instead of migrating to the end of the whole list. var inheritedPosition = new int[folders.Count]; var lastKnownPosition = 0; for (var i = 0; i < folders.Count; i++) { if (position.TryGetValue(folders[i].FullName, out var p)) lastKnownPosition = p; inheritedPosition[i] = lastKnownPosition; } return [.. folders .Select((f, naturalIndex) => (f, naturalIndex)) .OrderBy(x => inheritedPosition[x.naturalIndex]) .ThenBy(x => x.naturalIndex) .Select(x => x.f)]; } private static int FolderDepth(IMailFolder folder, IMailFolder root) { var depth = 0; var parent = folder.ParentFolder; while (parent != null && parent != root) { depth++; parent = parent.ParentFolder; } return depth; } // Looks up folderName under the personal namespace without creating anything. GetFolderAsync // throws FolderNotFoundException when it isn't there, which is the normal "go ahead and create // it" case, so it's swallowed; the subfolder scan is the fallback for servers whose GetFolder // path building doesn't match their own LIST output (differing separator, namespace prefix). private static async Task ResolveExistingFolderOrNullAsync(ImapClient client, IMailFolder personal, string folderName, CancellationToken ct) { var prefix = string.IsNullOrEmpty(personal.FullName) ? string.Empty : personal.FullName + personal.DirectorySeparator; try { return await client.GetFolderAsync(prefix + folderName, ct); } catch (FolderNotFoundException) { } try { var existing = await personal.GetSubfoldersAsync(false, ct); return existing.FirstOrDefault(f => string.Equals(f.Name, folderName, StringComparison.OrdinalIgnoreCase)); } catch (Exception) { return null; } } public async Task CreateFolderAsync(Account account, string password, string folderName, CancellationToken ct = default) { using var scope = logger.BeginScope(new Dictionary { ["accountId"] = account.Id, ["folder"] = folderName }); using var client = await ConnectAsync(account, password, ct); var personal = client.GetFolder(client.PersonalNamespaces[0]); // Creating a folder that already exists makes the server answer NO [ALREADYEXISTS], which // MailKit throws as ImapCommandException - and that used to surface as a 500. It's a very // easy state to reach: a folder created before the SubscribeAsync below existed (or created // by another client) exists on the server but is invisible in the sidebar's LSUB-only // listing, so the user simply creates it again. Treat "already there" as success and fall // through to the subscribe, which is exactly what such a folder is missing. var folder = await ResolveExistingFolderOrNullAsync(client, personal, folderName, ct); if (folder == null) { try { folder = await personal.CreateAsync(folderName, true, ct); } catch (ImapCommandException ex) when (ex.Response == ImapCommandResponse.No) { folder = await ResolveExistingFolderOrNullAsync(client, personal, folderName, ct); if (folder == null) throw; logger.LogInformation("Folder {Folder} already existed for account {AccountId}; subscribing to it instead", folderName, account.Id); } } // CreateAsync only creates the folder - it doesn't subscribe to it, and every folder listing // in this app (GetFoldersAsync, ResolveFolderOrNullAsync, SearchByAddressAsync) asks IMAP for // subscribed folders only (LSUB, via GetSubfoldersAsync(true, ...)). Without this, a newly // created folder exists on the server (you can move messages into it, as confirmed by its own // log line) but is invisible everywhere in the UI - nothing was wrong with the move, the // folder just never showed up in any list that led back to it. if (folder != null) await folder.SubscribeAsync(ct); await client.DisconnectAsync(true, ct); logger.LogInformation("Created and subscribed to folder {Folder} for account {AccountId}", folderName, account.Id); } public async Task SetFolderSubscriptionsAsync(Account account, string password, Dictionary subscriptions, CancellationToken ct = default) { using var scope = logger.BeginScope(new Dictionary { ["accountId"] = account.Id }); using var client = await ConnectAsync(account, password, ct); foreach (var (folderFullName, subscribed) in subscriptions) { try { var folder = await client.GetFolderAsync(folderFullName, ct); if (subscribed) await folder.SubscribeAsync(ct); else await folder.UnsubscribeAsync(ct); } catch (Exception ex) { logger.LogWarning(ex, "Failed to {Action} folder {Folder} for account {AccountId}", subscribed ? "subscribe to" : "unsubscribe from", folderFullName, account.Id); } } await client.DisconnectAsync(true, ct); } public async Task MoveFolderAsync(Account account, string password, string folderFullName, string? newParentFullName, CancellationToken ct = default) { using var scope = logger.BeginScope(new Dictionary { ["accountId"] = account.Id, ["folder"] = folderFullName }); using var client = await ConnectAsync(account, password, ct); var folder = await client.GetFolderAsync(folderFullName, ct); if (IsProtectedFolder(folder.FullName, folder.Name, folder.Attributes)) throw new InvalidOperationException($"Refusing to move protected folder '{folderFullName}'."); // A folder can't become its own (grand)child - IMAP has no cycle detection of its own, the // server would just accept a nonsensical RENAME and leave the folder unreachable. var separator = folder.DirectorySeparator; if (!string.IsNullOrEmpty(newParentFullName) && (newParentFullName == folderFullName || newParentFullName.StartsWith(folderFullName + separator, StringComparison.Ordinal))) { throw new InvalidOperationException("Cannot move a folder into itself or one of its own subfolders."); } var destination = string.IsNullOrEmpty(newParentFullName) ? client.GetFolder(client.PersonalNamespaces[0]) : await client.GetFolderAsync(newParentFullName, ct); await folder.RenameAsync(destination, folder.Name, ct); await client.DisconnectAsync(true, ct); // The folder's FullName - and therefore every store keyed by it (local index, sync-mode/ // subscription settings) - changes the moment it moves. Rather than migrating those rows to // the new name, just drop them; the folder re-indexes itself automatically the next time it's // opened (see EnsureFolderIndexedAsync), same as any folder seen for the first time. indexStore.RemoveFolder(account.Id, folderFullName); folderSettingsStore.Remove(account.Id, folderFullName); logger.LogInformation("Moved folder {Folder} to parent {NewParent} for account {AccountId}", folderFullName, string.IsNullOrEmpty(newParentFullName) ? "(root)" : newParentFullName, account.Id); } public async Task RenameFolderAsync(Account account, string password, string folderFullName, string newName, CancellationToken ct = default) { using var scope = logger.BeginScope(new Dictionary { ["accountId"] = account.Id, ["folder"] = folderFullName }); if (string.IsNullOrWhiteSpace(newName)) throw new InvalidOperationException("New folder name cannot be empty."); using var client = await ConnectAsync(account, password, ct); var folder = await client.GetFolderAsync(folderFullName, ct); if (IsProtectedFolder(folder.FullName, folder.Name, folder.Attributes)) throw new InvalidOperationException($"Refusing to rename protected folder '{folderFullName}'."); // Same parent as before, so only the leaf name changes - the destination for RenameAsync is // deliberately the folder's own current parent, not the personal namespace root. var destination = folder.ParentFolder ?? client.GetFolder(client.PersonalNamespaces[0]); await folder.RenameAsync(destination, newName, ct); await client.DisconnectAsync(true, ct); // See MoveFolderAsync - the FullName changes, so stores keyed by the old one are dropped // rather than migrated; the folder re-indexes itself the next time it's opened. indexStore.RemoveFolder(account.Id, folderFullName); folderSettingsStore.Remove(account.Id, folderFullName); logger.LogInformation("Renamed folder {Folder} to {NewName} for account {AccountId}", folderFullName, newName, account.Id); } public async Task> DeleteFoldersAsync(Account account, string password, IEnumerable folderFullNames, CancellationToken ct = default) { using var scope = logger.BeginScope(new Dictionary { ["accountId"] = account.Id }); using var client = await ConnectAsync(account, password, ct); var failed = new List(); foreach (var fullName in folderFullNames) { try { var folder = await client.GetFolderAsync(fullName, ct); if (IsProtectedFolder(folder.FullName, folder.Name, folder.Attributes)) { logger.LogWarning("Refused to delete protected folder {Folder} for account {AccountId}", fullName, account.Id); failed.Add(fullName); continue; } await folder.OpenAsync(FolderAccess.ReadOnly, ct); var count = folder.Count; await folder.CloseAsync(false, ct); if (count > 0) { logger.LogWarning("Refused to delete non-empty folder {Folder} for account {AccountId} ({Count} messages)", fullName, account.Id, count); failed.Add(fullName); continue; } await folder.DeleteAsync(ct); indexStore.RemoveFolder(account.Id, fullName); logger.LogInformation("Deleted folder {Folder} for account {AccountId}", fullName, account.Id); } catch (Exception ex) { logger.LogError(ex, "Failed to delete folder {Folder} for account {AccountId}", fullName, account.Id); failed.Add(fullName); } } await client.DisconnectAsync(true, ct); return failed; } public async Task> GetMessagesAsync(Account account, string password, string folderFullName, bool forceRefresh = false, CancellationToken ct = default) { var syncMode = folderSettingsStore.GetAll(account.Id).GetValueOrDefault(folderFullName)?.SyncMode ?? FolderSyncMode.Direct; // Indexed path: covers every folder except NotSynchronized - the local header index (see // EnsureFolderIndexedAsync) holds every message, not just the last MessageListFetchLimit, and // is kept in sync cheaply instead of being re-fetched (fully or partially) on every request. if (syncMode != FolderSyncMode.NotSynchronized) { var state = indexStore.GetSyncState(account.Id, folderFullName); // forceRefresh is deliberately a no-op here (unlike the NotSynchronized fallback below, // which uses it to bypass a short TTL cache): EnsureFolderIndexedAsync already re-verifies // against the live server on every call - cheaply, via STATUS - and mark-read/flag/move/ // delete already update the index immediately for their own uids. There's nothing a // "refresh" needs to force here; treating it as "wipe and rebuild the whole folder" would // only throw away an accurate index (and re-trigger the full-folder header scan) on every // single mark-read/flag/move/delete, since the UI always requests a refresh after those. if (state == null) { // First time this folder is seen (or just reset above): a full rebuild means scanning // every message, far too slow to do inline - the user would stare at a blank screen. // Kick it off in the background and fall through to the fast capped-live-fetch path // below for this one request; once the background build lands, later requests take // the fast indexed path above instead. StartBackgroundIndexBuild(account, password, folderFullName); } else { await EnsureFolderIndexedAsync(account, password, folderFullName, ct); return [.. indexStore.GetMessages(account.Id, folderFullName) .OrderByDescending(m => m.Date) .Select(m => new MessageListItem { Uid = m.Uid, Subject = m.Subject, From = m.From, Date = m.Date, SizeBytes = m.SizeBytes, IsRead = m.IsRead, IsFlagged = m.IsFlagged, HasAttachments = m.HasAttachments, Priority = m.Priority })]; } } // Fallback path: used for NotSynchronized folders (unchanged, permanent behaviour for that // mode) and for indexed folders whose very first index build is still running in the // background above - capped live fetch (last MessageListFetchLimit messages) behind a short // TTL cache, same as before the index existed. if (!forceRefresh) { var cached = cacheStore.GetMessageList(account.Id, folderFullName, TimeSpan.FromMinutes(_settings.MessageListCacheTtlMinutes)); if (cached != null) return cached; } var indexed = await FetchLiveMessagesAsync(account, password, folderFullName, ct); var items = indexed.Select(m => new MessageListItem { Uid = m.Uid, Subject = m.Subject, From = m.From, Date = m.Date, SizeBytes = m.SizeBytes, IsRead = m.IsRead, IsFlagged = m.IsFlagged, HasAttachments = m.HasAttachments, Priority = m.Priority }); var result = new List([.. items.OrderByDescending(x => x.Date)]); cacheStore.SaveMessageList(account.Id, folderFullName, result); return result; } // Used for the NotSynchronized capped-live-fetch path. private async Task> FetchLiveMessagesAsync(Account account, string password, string folderFullName, CancellationToken ct) { using var client = await ConnectAsync(account, password, ct); var folder = await client.GetFolderAsync(folderFullName, ct); await folder.OpenAsync(FolderAccess.ReadOnly, ct); var uids = await folder.SearchAsync(SearchQuery.All, ct); var take = uids.Skip(Math.Max(0, uids.Count - _settings.MessageListFetchLimit)).ToList(); var messages = take.Count > 0 ? await FetchIndexedMessagesAsync(folder, take, ct) : []; await client.DisconnectAsync(true, ct); return messages; } public async Task GetMessageAsync(Account account, string password, string folderFullName, uint uid, CancellationToken ct = default) { var message = await LoadMimeMessageAsync(account, password, folderFullName, uid, ct); // LoadMimeMessageAsync only sets the Seen flag on IMAP (and only on the very first open, // before the .eml is cached - a cache hit skips even that) and marks the short-TTL live // message-list cache - it never touches the persistent index (see IMessageIndexStore), // which is what indexed folders actually read their read/unread state from (see // GetMessagesAsync). Without this, an opened message's "read" state only ever showed up as // a client-side-only flag that reverted to unread on the next list reload. No-ops harmlessly // for folders that aren't indexed (message not present in the index) or already marked read. // Serialized against StartRecentFlagsRefresh (see FolderMutationLocks) for the same reason as // SetSeenAsync below - without it, a background refresh reading stale flags could revert this // right after it lands. var openMutationLock = GetFolderMutationLock($"{account.Id}:{folderFullName}"); await openMutationLock.WaitAsync(ct); try { indexStore.MarkRead(account.Id, folderFullName, [uid], true); } finally { openMutationLock.Release(); } // Self-heal for a message indexed before ResolveMessageDate's INTERNALDATE fallback existed - // a normal reindex only happens on UIDVALIDITY change, so an already-broken date otherwise // never gets corrected on its own (see the uid 95868/46823/46821 investigation). Opening the // message is the one moment it's cheap to fix: one extra lightweight FETCH, only when the // stored date actually looks wrong, and it never needs fixing again after this. var indexedEntry = indexStore.GetMessage(account.Id, folderFullName, uid); if (indexedEntry != null && indexedEntry.Date < PlausibleDateFloor) { var internalDate = await FetchInternalDateAsync(account, password, folderFullName, uid, ct); if (internalDate is { } resolvedDate) { indexStore.UpdateDate(account.Id, folderFullName, uid, resolvedDate); } } // MimeMessage.HtmlBody/TextBody only match the exact "text/html"/"text/plain" subtypes - a // message whose body part uses a non-standard Content-Type (e.g. "text/text", seen in the // wild from at least one spam sender - see the uid 95868 investigation) is a TextPart by // MimeKit's own classification (MediaType "text", any subtype) but neither convenience // property picks it up, so the message rendered as completely empty even though it has // content. Falling back to the first TextPart at all covers that case without needing to // special-case every possible malformed subtype. var fallbackText = message.HtmlBody == null && message.TextBody == null ? message.BodyParts.OfType().FirstOrDefault()?.Text : null; var html = message.HtmlBody ?? (fallbackText != null ? System.Net.WebUtility.HtmlEncode(fallbackText).Replace("\n", "
") : ""); if (!string.IsNullOrEmpty(html)) { foreach (var part in message.BodyParts.OfType()) { if (string.IsNullOrEmpty(part.ContentId) || part.Content == null) continue; if (!html.Contains($"cid:{part.ContentId}", StringComparison.OrdinalIgnoreCase)) continue; using var ms = new MemoryStream(); part.Content.DecodeTo(ms, ct); var dataUri = $"data:{part.ContentType.MimeType};base64,{Convert.ToBase64String(ms.ToArray())}"; html = html.Replace($"cid:{part.ContentId}", dataUri, StringComparison.OrdinalIgnoreCase); } } var senderEmail = message.From.Mailboxes.FirstOrDefault()?.Address ?? ""; // Never in the Spam folder, regardless of any saved per-sender preference - see IsSpamFolder. var allowImages = !IsSpamFolder(folderFullName) && cacheStore.GetAllowExternalImages(account.Id, senderEmail); var messageId = ResolveMessageId(message, folderFullName, uid); var (transformedHtml, hasExternal) = imageSanitizer.ApplyPolicy(html, allowImages, account.EmailAddress, messageId); transformedHtml = imageSanitizer.ApplyLinkPolicy(transformedHtml); var detail = new MessageDetail { Uid = uid, Subject = message.Subject ?? "(no subject)", From = message.From.ToString(), SenderEmail = senderEmail, To = message.To.ToString(), Cc = message.Cc.ToString(), // Prefer the index's already-resolved date (see ResolveMessageDate/FetchIndexedMessagesAsync) // over the raw MimeMessage.Date - a message with no Date header parses to an implausible // default here too, and showing a different (wrong) date in the detail pane than what the // message list/sort already fell back to would just be a fresh source of confusion. Date = indexStore.GetMessage(account.Id, folderFullName, uid)?.Date ?? message.Date, HtmlBody = transformedHtml, TextBody = message.TextBody ?? fallbackText ?? "", HasExternalImages = hasExternal, ImagesAllowed = allowImages, Priority = message.Priority switch { MimeKit.MessagePriority.NonUrgent => ModelPriority.Low, MimeKit.MessagePriority.Urgent => ModelPriority.High, _ => ModelPriority.Normal }, Sensitivity = ParseSensitivity(message.Headers["Sensitivity"]), // Recent-scoped, not the folder's raw unread total (see GetRecentUnreadCount) - matches // what StartRecentFlagsRefresh actually keeps accurate, and is the number that's actually // useful (a message from years ago sitting unread, or Trash's tens of thousands of // untouched messages, shouldn't move this badge). FolderUnreadCount = indexStore.GetSyncState(account.Id, folderFullName) != null ? indexStore.GetRecentUnreadCount(account.Id, folderFullName, RecentFlagsRefreshCount) : null }; int idx = 0; foreach (var att in message.Attachments) { var fileName = att.ContentDisposition?.FileName ?? att.ContentType.Name ?? $"attachment{idx}"; long size = 0; if (att is MimePart part && part.Content != null) { using var ms = new MemoryStream(); part.Content.DecodeTo(ms, ct); size = ms.Length; } detail.Attachments.Add(new AttachmentInfo { FileName = fileName, ContentType = att.ContentType.MimeType, SizeBytes = size, PartIndex = idx }); idx++; } return detail; } // Reads the cached .eml if we already have one; otherwise fetches the message's exact raw // bytes from IMAP (not a MimeMessage re-serialized via WriteToAsync, which would only be a // logical reproduction - original header order/whitespace could differ, which matters e.g. // for DKIM verification), marks it Seen, and writes those raw bytes to disk as-is - keyed by // the message's own Message-ID rather than its (folder, uid), since a uid is only unique // within one folder and changes when a message is moved. (folder, uid) is all we know before // contacting IMAP though, so a small mapping to the Message-ID is kept for that lookup; a // fresh IMAP round trip is still needed the first time a given (folder, uid) pair is seen // (e.g. right after a move), but every later read - here and in GetAttachmentAsync - is then // a local file read instead. private async Task LoadMimeMessageAsync(Account account, string password, string folderFullName, uint uid, CancellationToken ct) { var messageId = cacheStore.GetMessageIdForUid(account.Id, folderFullName, uid); if (messageId != null) { var cachedEml = cacheStore.GetMessageEml(account.EmailAddress, messageId); if (cachedEml != null) { // A cached .eml skips the IMAP round trip entirely for speed, but that means the // Seen-flag AddFlagsAsync below (the cache-miss path) never runs either. That's fine // the first time a message is read (Seen already got set then, which is what put it // in the cache), but if it's since been marked unread again - by this app or another // client - and reopened, the local index gets marked read again (see GetMessageAsync) // while the server's flag silently stays unset, so the live unread count this app's // own folder sidebar shows (straight from IMAP STATUS UNSEEN) never reflects it. Only // worth the extra connection for that specific case; the common case (reopening an // already-read message) stays free of any IMAP round trip. if (indexStore.GetMessage(account.Id, folderFullName, uid) is { IsRead: false }) { await SetSeenAsync(account, password, folderFullName, [uid], true, ct); } using var cachedStream = new MemoryStream(cachedEml); return await MimeMessage.LoadAsync(cachedStream, ct); } } using var client = await ConnectAsync(account, password, ct); var folder = await client.GetFolderAsync(folderFullName, ct); await folder.OpenAsync(FolderAccess.ReadWrite, ct); byte[] rawBytes; using (var rawStream = await folder.GetStreamAsync(new UniqueId(uid), ct)) using (var buffer = new MemoryStream()) { await rawStream.CopyToAsync(buffer, ct); rawBytes = buffer.ToArray(); } await folder.AddFlagsAsync(new UniqueId(uid), MessageFlags.Seen, true, ct); await client.DisconnectAsync(true, ct); MimeMessage message; using (var parseStream = new MemoryStream(rawBytes)) { message = await MimeMessage.LoadAsync(parseStream, ct); } var resolvedMessageId = ResolveMessageId(message, folderFullName, uid); cacheStore.SaveMessageEml(account.EmailAddress, resolvedMessageId, rawBytes); cacheStore.SaveMessageIdForUid(account.Id, folderFullName, uid, resolvedMessageId); cacheStore.MarkMessageRead(account.Id, folderFullName, uid); return message; } // Shared by the .eml cache key and the image cache directory name, so both always agree on // the same identity for a given message even when its Message-ID header is missing. private static string ResolveMessageId(MimeMessage message, string folderFullName, uint uid) => string.IsNullOrWhiteSpace(message.MessageId) ? $"no-message-id-{folderFullName}-{uid}" : message.MessageId; // "Sensitivity" isn't a MimeKit first-class property (unlike Priority/X-Priority) - it's a // plain header this app itself writes on send (see MimeMessageFactory.Build) using the same // values Outlook/OWA use, so parsing it back here just has to match that same vocabulary. private static MessageSensitivity ParseSensitivity(string? headerValue) => headerValue?.Trim() switch { "Personal" => MessageSensitivity.Personal, "Private" => MessageSensitivity.Private, "Company-Confidential" => MessageSensitivity.Confidential, _ => MessageSensitivity.Nothing }; public async Task GetAttachmentAsync(Account account, string password, string folderFullName, uint uid, int partIndex, CancellationToken ct = default) { // The uid -> Message-ID mapping alone (cheap LiteDB lookup) is enough to check the disk // cache, so a cached attachment never requires loading/parsing the .eml at all. var knownMessageId = cacheStore.GetMessageIdForUid(account.Id, folderFullName, uid); if (knownMessageId != null) { var cached = cacheStore.GetAttachmentContent(account.EmailAddress, knownMessageId, partIndex); if (cached != null) return new MemoryStream(cached); } var message = await LoadMimeMessageAsync(account, password, folderFullName, uid, ct); var messageId = ResolveMessageId(message, folderFullName, uid); var att = message.Attachments.ElementAt(partIndex); var result = new MemoryStream(); if (att is MimePart part && part.Content != null) { part.Content.DecodeTo(result, ct); result.Position = 0; var fileName = att.ContentDisposition?.FileName ?? att.ContentType.Name ?? $"attachment{partIndex}"; cacheStore.SaveAttachmentContent(account.EmailAddress, messageId, partIndex, fileName, result.ToArray()); result.Position = 0; } return result; } public async Task SaveDraftAsync(Account account, string password, ComposeModel compose, IEnumerable<(string FileName, Stream Content)> attachments, IEnumerable<(string Cid, string FileName, Stream Content)> inlineImages, CancellationToken ct = default) { var message = MimeMessageFactory.Build(account, compose, attachments, inlineImages); using var client = await ConnectAsync(account, password, ct); var drafts = await ResolveDraftsFolderAsync(client, ct); await drafts.AppendAsync(message, MessageFlags.Draft | MessageFlags.Seen, ct); await client.DisconnectAsync(true, ct); } // Most SMTP servers never store a copy of what they relay - the client is expected to save its // own copy to the Sent folder over IMAP, the same way any other webmail/desktop mail client does. public async Task AppendSentAsync(Account account, string password, MimeMessage message, CancellationToken ct = default) { using var client = await ConnectAsync(account, password, ct); try { var sent = await ResolveSentFolderAsync(client, ct); await sent.AppendAsync(message, MessageFlags.Seen, ct); } catch (Exception ex) { logger.LogWarning(ex, "Failed to save sent copy to the Sent folder for account {AccountId}", account.Id); throw; } finally { await client.DisconnectAsync(true, ct); } } public async Task SetSeenAsync(Account account, string password, string folderFullName, IEnumerable uids, bool seen, CancellationToken ct = default) { using var scope = logger.BeginScope(new Dictionary { ["accountId"] = account.Id, ["folder"] = folderFullName }); using var client = await ConnectAsync(account, password, ct); var folder = await client.GetFolderAsync(folderFullName, ct); await folder.OpenAsync(FolderAccess.ReadWrite, ct); var uidList = uids.Select(u => new UniqueId(u)).ToList(); var applied = await ApplySeenFlagWithVerificationAsync(folder, uidList, seen, ct); await client.DisconnectAsync(true, ct); // See FolderMutationLocks / StartRecentFlagsRefresh - the server-side flag is already set by // this point, but the local index write still has to be serialized against a concurrent // background flag refresh for this folder, otherwise that refresh's own (now-stale) snapshot // could land right after this and silently revert what the user just explicitly did. var mutationLock = GetFolderMutationLock($"{account.Id}:{folderFullName}"); await mutationLock.WaitAsync(ct); try { indexStore.MarkRead(account.Id, folderFullName, applied, seen); } finally { mutationLock.Release(); } } public async Task SetFlaggedAsync(Account account, string password, string folderFullName, IEnumerable uids, bool flagged, CancellationToken ct = default) { using var client = await ConnectAsync(account, password, ct); var folder = await client.GetFolderAsync(folderFullName, ct); await folder.OpenAsync(FolderAccess.ReadWrite, ct); var uidList = uids.Select(u => new UniqueId(u)).ToList(); await folder.AddFlagsAsync(uidList, MessageFlags.Flagged, flagged, ct); if (!flagged) await folder.RemoveFlagsAsync(uidList, MessageFlags.Flagged, true, ct); await client.DisconnectAsync(true, ct); // See SetSeenAsync above for why this is serialized against StartRecentFlagsRefresh. var mutationLock = GetFolderMutationLock($"{account.Id}:{folderFullName}"); await mutationLock.WaitAsync(ct); try { indexStore.MarkFlagged(account.Id, folderFullName, uidList.Select(u => u.Id), flagged); } finally { mutationLock.Release(); } } // One giant STORE command covering every UID in the folder has been observed (see the Vmtux // investigation) to silently not apply to a handful of UIDs on at least one real server, even // though the same command against a single UID at a time works fine for those exact messages - // no exception, no server error, just a subset quietly not taking effect. Root cause is outside // this app (server-side UID-set/command-length handling), so instead of trusting one big batch // this chunks the request and then verifies + individually retries whatever didn't stick, // mirroring the code path that's confirmed to work. Only UIDs verified to actually have the // target flag afterward get written into the local index - never assumed from the request. private const int FlagUpdateBatchSize = 50; public async Task MarkAllAsync(Account account, string password, string folderFullName, bool seen, CancellationToken ct = default) { using var scope = logger.BeginScope(new Dictionary { ["accountId"] = account.Id, ["folder"] = folderFullName }); using var client = await ConnectAsync(account, password, ct); var folder = await client.GetFolderAsync(folderFullName, ct); await folder.OpenAsync(FolderAccess.ReadWrite, ct); var uids = await folder.SearchAsync(SearchQuery.All, ct); var applied = await ApplySeenFlagWithVerificationAsync(folder, uids, seen, ct); await client.DisconnectAsync(true, ct); logger.LogInformation("Marked {Applied}/{Requested} messages as {SeenState} in folder {Folder} for account {AccountId}", applied.Count, uids.Count, seen ? "read" : "unread", folderFullName, account.Id); // See SetSeenAsync for why this is serialized against StartRecentFlagsRefresh. var mutationLock = GetFolderMutationLock($"{account.Id}:{folderFullName}"); await mutationLock.WaitAsync(ct); try { indexStore.MarkRead(account.Id, folderFullName, applied, seen); } finally { mutationLock.Release(); } } // Applies the Seen flag in batches, then verifies against a fresh SEARCH and individually // retries any UID that didn't stick - see MarkAllAsync's remarks. Returns the UIDs confirmed to // actually be in the target state afterward (a strict subset of the input on a flaky server). private async Task> ApplySeenFlagWithVerificationAsync(IMailFolder folder, IList uids, bool seen, CancellationToken ct) { if (uids.Count == 0) return []; for (var offset = 0; offset < uids.Count; offset += FlagUpdateBatchSize) { var batch = uids.Skip(offset).Take(FlagUpdateBatchSize).ToList(); try { if (seen) await folder.AddFlagsAsync(batch, MessageFlags.Seen, true, ct); else await folder.RemoveFlagsAsync(batch, MessageFlags.Seen, true, ct); } catch (Exception ex) { logger.LogWarning(ex, "Batch flag update failed for {Count} messages in folder {Folder}, will retry individually", batch.Count, folder.FullName); } } var mismatchQuery = seen ? SearchQuery.NotSeen : SearchQuery.Seen; var stillMismatched = (await folder.SearchAsync(mismatchQuery, ct)).Where(uids.Contains).ToList(); var confirmed = uids.Except(stillMismatched).Select(u => u.Id).ToList(); if (stillMismatched.Count == 0) return confirmed; logger.LogWarning("{Count} messages did not pick up the bulk flag update in folder {Folder}, retrying individually: {Uids}", stillMismatched.Count, folder.FullName, string.Join(",", stillMismatched.Select(u => u.Id))); foreach (var uid in stillMismatched) { try { if (seen) await folder.AddFlagsAsync(uid, MessageFlags.Seen, true, ct); else await folder.RemoveFlagsAsync(uid, MessageFlags.Seen, true, ct); } catch (Exception ex) { logger.LogError(ex, "Individual flag update also failed for uid {Uid} in folder {Folder} - leaving as-is", uid.Id, folder.FullName); } } // The command not throwing doesn't mean the server actually applied it - that's the exact // silent-no-op behaviour this whole verify/retry path exists to work around in the first // place (see the Vmtux investigation), so trusting "no exception" here would just reintroduce // the same bug one level down: the caller would mark these read/flagged locally, only for the // next background flags refresh to discover the server disagrees and quietly revert it - // which looked like "marking as read does nothing" no matter how many times it was retried. // A final SEARCH is the only way to know it actually stuck. var stillMismatchedAfterRetry = (await folder.SearchAsync(mismatchQuery, ct)).Where(stillMismatched.Contains).ToHashSet(); confirmed.AddRange(stillMismatched.Where(u => !stillMismatchedAfterRetry.Contains(u)).Select(u => u.Id)); if (stillMismatchedAfterRetry.Count > 0) { logger.LogError("{Count} message(s) in folder {Folder} still did not pick up the flag update after individual retry - leaving as-is: {Uids}", stillMismatchedAfterRetry.Count, folder.FullName, string.Join(",", stillMismatchedAfterRetry.Select(u => u.Id))); } return confirmed; } public async Task MoveAsync(Account account, string password, string folderFullName, IEnumerable uids, string targetFolderFullName, CancellationToken ct = default) { using var scope = logger.BeginScope(new Dictionary { ["accountId"] = account.Id, ["folder"] = folderFullName }); using var client = await ConnectAsync(account, password, ct); var folder = await client.GetFolderAsync(folderFullName, ct); await folder.OpenAsync(FolderAccess.ReadWrite, ct); var target = await client.GetFolderAsync(targetFolderFullName, ct); var uidList = uids.Select(u => new UniqueId(u)).ToList(); await folder.MoveToAsync(uidList, target, ct); await client.DisconnectAsync(true, ct); logger.LogInformation("Moved {Count} messages from {Folder} to {TargetFolder} for account {AccountId}", uidList.Count, folderFullName, targetFolderFullName, account.Id); // The moved messages get new UIDs in targetFolderFullName - rather than guess at those, just // drop them from the source folder's index now; the target folder picks them up as "new" // UIDs (with flags intact, since IMAP MOVE preserves them) next time it's indexed. indexStore.RemoveMessages(account.Id, folderFullName, uidList.Select(u => u.Id)); } public async Task DeleteAsync(Account account, string password, string folderFullName, IEnumerable uids, CancellationToken ct = default) { using var scope = logger.BeginScope(new Dictionary { ["accountId"] = account.Id, ["folder"] = folderFullName }); using var client = await ConnectAsync(account, password, ct); var folder = await client.GetFolderAsync(folderFullName, ct); await folder.OpenAsync(FolderAccess.ReadWrite, ct); var uidList = uids.Select(u => new UniqueId(u)).ToList(); var trash = await ResolveTrashFolderAsync(client, ct); if (trash != null && folder.FullName != trash.FullName) { await folder.MoveToAsync(uidList, trash, ct); logger.LogInformation("Moved {Count} messages from {Folder} to Trash for account {AccountId}", uidList.Count, folderFullName, account.Id); } else { await folder.AddFlagsAsync(uidList, MessageFlags.Deleted, true, ct); await folder.ExpungeAsync(ct); logger.LogInformation("Expunged {Count} messages from {Folder} for account {AccountId}", uidList.Count, folderFullName, account.Id); } await client.DisconnectAsync(true, ct); // Same reasoning as MoveAsync above: moved to Trash (new UIDs there, picked up on next // index) or actually expunged (gone for good either way) - either way, gone from here now. indexStore.RemoveMessages(account.Id, folderFullName, uidList.Select(u => u.Id)); } public async Task EmptyFolderAsync(Account account, string password, string folderFullName, CancellationToken ct = default) { using var scope = logger.BeginScope(new Dictionary { ["accountId"] = account.Id, ["folder"] = folderFullName }); using var client = await ConnectAsync(account, password, ct); var folder = await client.GetFolderAsync(folderFullName, ct); await folder.OpenAsync(FolderAccess.ReadWrite, ct); var uids = await folder.SearchAsync(SearchQuery.All, ct); if (uids.Count > 0) { await folder.AddFlagsAsync(uids, MessageFlags.Deleted, true, ct); await folder.ExpungeAsync(ct); } await client.DisconnectAsync(true, ct); indexStore.RemoveFolder(account.Id, folderFullName); logger.LogInformation("Emptied folder {Folder} for account {AccountId} ({Count} messages)", folderFullName, account.Id, uids.Count); } public async Task> GetSentContactsAsync(Account account, string password, CancellationToken ct = default) { string sentFullName; using (var client = await ConnectAsync(account, password, ct)) { var sent = await ResolveSentFolderAsync(client, ct); sentFullName = sent.FullName; await client.DisconnectAsync(true, ct); } var syncMode = folderSettingsStore.GetAll(account.Id).GetValueOrDefault(sentFullName)?.SyncMode ?? FolderSyncMode.Direct; var state = syncMode != FolderSyncMode.NotSynchronized ? indexStore.GetSyncState(account.Id, sentFullName) : null; var result = new Dictionary(StringComparer.OrdinalIgnoreCase); if (state != null) { // Already indexed: no IMAP fetch needed at all beyond the cheap sync check - To/Cc are // stored as the same InternetAddressList.ToString() form MimeKit itself produces, so they // parse straight back into MailboxAddress for extraction, same as the live path below. await EnsureFolderIndexedAsync(account, password, sentFullName, ct); foreach (var m in indexStore.GetMessages(account.Id, sentFullName)) AddContactRecipients(result, ParseMailboxes(m.To).Concat(ParseMailboxes(m.Cc))); return [.. result.Values.OrderBy(c => c.Email)]; } // NotSynchronized, or simply never indexed yet: do the (correct, if slower) live scan this // once, same as before the index existed - and if it's the latter case, also kick off a // background build so the next call takes the fast indexed path above instead. if (syncMode != FolderSyncMode.NotSynchronized) StartBackgroundIndexBuild(account, password, sentFullName); using var liveClient = await ConnectAsync(account, password, ct); var liveSent = await liveClient.GetFolderAsync(sentFullName, ct); await liveSent.OpenAsync(FolderAccess.ReadOnly, ct); var uids = await liveSent.SearchAsync(SearchQuery.All, ct); var summaries = await liveSent.FetchAsync(uids, MessageSummaryItems.Envelope, ct); await liveClient.DisconnectAsync(true, ct); foreach (var s in summaries) AddContactRecipients(result, (s.Envelope?.To ?? []).Concat(s.Envelope?.Cc ?? []).OfType()); return [.. result.Values.OrderBy(c => c.Email)]; } private static void AddContactRecipients(Dictionary result, IEnumerable recipients) { foreach (var mb in recipients) { if (string.IsNullOrWhiteSpace(mb.Address)) continue; if (!result.ContainsKey(mb.Address)) { result[mb.Address] = new ContactAddress { Email = mb.Address, Name = mb.Name ?? "" }; } } } private static IEnumerable ParseMailboxes(string addressListString) { if (string.IsNullOrWhiteSpace(addressListString)) return []; try { return InternetAddressList.Parse(addressListString).OfType(); } catch { return []; } } public async Task> SearchByAddressAsync(Account account, string password, string address, bool forceRefresh = false, CancellationToken ct = default) { if (!forceRefresh) { var cached = cacheStore.GetContactMailSearch(account.Id, address, TimeSpan.FromMinutes(_settings.ContactMailSearchCacheTtlMinutes)); if (cached != null) return cached; } using var client = await ConnectAsync(account, password, ct); var personal = client.GetFolder(client.PersonalNamespaces[0]); var folders = await GetAllSubfoldersRecursiveAsync(personal, true, ct); var settings = folderSettingsStore.GetAll(account.Id); var result = new List(); var query = SearchQuery.FromContains(address).Or(SearchQuery.ToContains(address)); foreach (var f in new[] { client.Inbox }.Concat(folders).DistinctBy(f => f.FullName)) { var syncMode = settings.GetValueOrDefault(f.FullName)?.SyncMode ?? FolderSyncMode.Direct; var alreadyIndexed = syncMode != FolderSyncMode.NotSynchronized && indexStore.GetSyncState(account.Id, f.FullName) != null; // Already indexed: just keep it current and let the bulk SearchByAddress query below // pick up this folder's matches - no per-folder IMAP search/fetch needed at all. if (alreadyIndexed) { try { await EnsureFolderIndexedAsync(account, password, f.FullName, ct); } catch { /* couldn't refresh right now - fall through using whatever's already indexed */ } continue; } // Never indexed yet (or NotSynchronized): do the live per-folder scan this once, same as // before the index existed, so this search isn't missing results while the background // build (kicked off below, for the non-NotSynchronized case) is still running. if (syncMode != FolderSyncMode.NotSynchronized) StartBackgroundIndexBuild(account, password, f.FullName); try { await f.OpenAsync(FolderAccess.ReadOnly, ct); var uids = await f.SearchAsync(query, ct); if (uids.Count == 0) { await f.CloseAsync(false, ct); continue; } var summaries = await f.FetchAsync(uids, MessageSummaryItems.Envelope, ct); foreach (var s in summaries) { result.Add(new MessageSearchResult { Folder = f.FullName, Uid = s.UniqueId.Id, Subject = s.Envelope?.Subject ?? "(no subject)", From = s.Envelope?.From?.ToString() ?? "", Date = s.Envelope?.Date ?? DateTimeOffset.MinValue }); } await f.CloseAsync(false, ct); } catch { /* non-selectable folder */ } } await client.DisconnectAsync(true, ct); result.AddRange(indexStore.SearchByAddress(account.Id, address).Select(m => new MessageSearchResult { Folder = m.Folder, Uid = m.Uid, Subject = m.Subject, From = m.From, Date = m.Date })); var ordered = new List([.. result.OrderByDescending(r => r.Date)]); cacheStore.SaveContactMailSearch(account.Id, address, ordered); return ordered; } }