update for a better client

alphons <alphons@heijden.com> 5 Aug 2026, 13:10
a8cfb59ef02150878c8649e6fb3a759f244f41f6
10 files changed
  • MailSharp.MailClient/Controllers/Api/MailApiController.cs
  • MailSharp.MailClient/Models/MailModels.cs
  • MailSharp.MailClient/Services/ImapService.cs
  • MailSharp.MailClient/Services/MessageIndexStore.cs
  • MailSharp.MailClient/Views/Maintenance/Index.cshtml
  • MailSharp.MailClient/appsettings.json
  • MailSharp.MailClient/wwwroot/css/site.css
  • MailSharp.MailClient/wwwroot/js/common.js
  • MailSharp.MailClient/wwwroot/js/mail.js
  • MailSharp.MailClient/wwwroot/js/maintenance.js
diff --git a/MailSharp.MailClient/Controllers/Api/MailApiController.cs b/MailSharp.MailClient/Controllers/Api/MailApiController.cs
index 4a44914..73f9135 100644
--- a/MailSharp.MailClient/Controllers/Api/MailApiController.cs
+++ b/MailSharp.MailClient/Controllers/Api/MailApiController.cs
@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.Options;
using MimeKit;
+using System.Text.Json;
namespace MailSharp.MailClient.Controllers.Api;
@@ -53,6 +54,90 @@ public class MailApiController(
});
}
+ private object BuildIndexingStatusPayload() => new
+ {
+ maxConcurrentIndexing = indexingStatus.MaxConcurrentIndexing,
+ currentlyAvailableSlots = indexingStatus.CurrentlyAvailableSlots,
+ inProgress = indexingStatus.InProgressKeys.Select(key =>
+ {
+ indexingStatus.TryGetProgress(key, out var processed, out var total);
+ return new { key, processed, total };
+ })
+ };
+
+ private static object BuildFlagsRefreshPayload(RecentFlagsRefreshResult r) => new
+ {
+ folder = r.Folder,
+ checkedCount = r.CheckedCount,
+ changedCount = r.ChangedCount,
+ elapsedMs = r.ElapsedMs,
+ timestamp = r.TimestampUtc
+ };
+
+ private async Task WriteSseEventAsync(string eventName, string data, CancellationToken ct)
+ {
+ await Response.WriteAsync($"event: {eventName}\ndata: {data}\n\n", ct);
+ await Response.Body.FlushAsync(ct);
+ }
+
+ // Server-Sent Events push channel for the topbar - started once per page load and kept open,
+ // replacing what used to be a client-side setInterval poll (see common.js). An EventSource just
+ // sits and receives pushes, so it no longer shares apiFetch's activeControllers/loading-overlay
+ // plumbing - a background status refresh could previously call hideLoading() and dismiss the
+ // Cancel button/overlay for a real, unrelated, still-in-flight request.
+ //
+ // Multiplexes more than one kind of push over this one connection rather than opening a stream
+ // per feature: "indexing" (background-indexing toast, global - not account-scoped, matching the
+ // old polled endpoint's behaviour) and "flags" (ImapService.RecentFlagsRefreshResults - results
+ // of the lightweight recent-messages flag recheck, scoped to the caller's own account so one
+ // user's folder names/activity never reach another). The push side is still a poll internally
+ // (in-memory static state has no change notification), but it only writes to the client when a
+ // given snapshot actually changes, and the client opens exactly one long-lived connection instead
+ // of a new HTTP request every few seconds.
+ [HttpGet("indexing-status/stream")]
+ public async Task IndexingStatusStream(CancellationToken ct)
+ {
+ var active = GetActive();
+ if (active == null) { Response.StatusCode = StatusCodes.Status401Unauthorized; return; }
+ var accountId = active.Value.account.Id;
+
+ Response.Headers.ContentType = "text/event-stream";
+ Response.Headers.CacheControl = "no-cache";
+ Response.Headers["X-Accel-Buffering"] = "no";
+
+ string? lastIndexingPayload = null;
+ var lastFlagsPayloads = new Dictionary<string, string>();
+ try
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ var indexingPayload = JsonSerializer.Serialize(BuildIndexingStatusPayload());
+ if (indexingPayload != lastIndexingPayload)
+ {
+ await WriteSseEventAsync("indexing", indexingPayload, ct);
+ lastIndexingPayload = indexingPayload;
+ }
+
+ foreach (var kvp in ImapService.RecentFlagsRefreshResults)
+ {
+ if (kvp.Value.AccountId != accountId) continue;
+ var flagsPayload = JsonSerializer.Serialize(BuildFlagsRefreshPayload(kvp.Value));
+ if (!lastFlagsPayloads.TryGetValue(kvp.Key, out var prev) || prev != flagsPayload)
+ {
+ await WriteSseEventAsync("flags", flagsPayload, ct);
+ lastFlagsPayloads[kvp.Key] = flagsPayload;
+ }
+ }
+
+ await Task.Delay(1000, ct);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Client navigated away or closed the EventSource - not an error.
+ }
+ }
+
[HttpGet("folders")]
public async Task<IActionResult> Folders(CancellationToken ct)
{
diff --git a/MailSharp.MailClient/Models/MailModels.cs b/MailSharp.MailClient/Models/MailModels.cs
index 724129d..db1f091 100644
--- a/MailSharp.MailClient/Models/MailModels.cs
+++ b/MailSharp.MailClient/Models/MailModels.cs
@@ -85,6 +85,13 @@ public class MessageDetail
public bool ImagesAllowed { get; set; }
public MessagePriority Priority { get; set; } = MessagePriority.Normal;
public MessageSensitivity Sensitivity { get; set; } = MessageSensitivity.Nothing;
+
+ // The folder's unread count immediately after this message got marked read (from the local
+ // index, which GetMessageAsync already just updated) - lets the client patch its folder sidebar
+ // badge for free instead of re-fetching every folder's live IMAP STATUS just to refresh one
+ // number. Null for folders that aren't indexed (NotSynchronized), where there's no local count
+ // to report and the client should leave the badge alone.
+ public int? FolderUnreadCount { get; set; }
}
public class AttachmentInfo
diff --git a/MailSharp.MailClient/Services/ImapService.cs b/MailSharp.MailClient/Services/ImapService.cs
index 92a4446..399dc04 100644
--- a/MailSharp.MailClient/Services/ImapService.cs
+++ b/MailSharp.MailClient/Services/ImapService.cs
@@ -39,6 +39,8 @@ public interface IImapService
Task<List<MessageSearchResult>> 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,
@@ -69,6 +71,37 @@ public class ImapService(
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<string, RecentFlagsRefreshResult> RecentFlagsRefreshResults = new();
+ private static readonly ConcurrentDictionary<string, byte> 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<string, SemaphoreSlim> 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
@@ -120,6 +153,103 @@ public class ImapService(
});
}
+ // 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;
+ try
+ {
+ var currentLocal = indexStore.GetMessages(account.Id, folderFullName).ToDictionary(m => m.Uid);
+
+ var toMarkRead = new List<uint>();
+ var toMarkUnread = new List<uint>();
+ var toMarkFlagged = new List<uint>();
+ var toMarkUnflagged = new List<uint>();
+ 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 (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<uint>([.. 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);
+ }
+ 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 _);
+ }
+ });
+ }
+
// 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
@@ -179,37 +309,91 @@ public class ImapService(
private static Task<IMailFolder?> 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<ImapClient> ConnectAsync(Account account, string password, CancellationToken ct)
{
- var client = new ImapClient();
var secureSocket = account.ImapSecurity switch
{
SecurityMode.SslTls => MailKit.Security.SecureSocketOptions.SslOnConnect,
SecurityMode.StartTls => MailKit.Security.SecureSocketOptions.StartTls,
_ => MailKit.Security.SecureSocketOptions.None
};
- try
- {
- await client.ConnectAsync(account.ImapHost, account.ImapPort, secureSocket, ct);
- await client.AuthenticateAsync(account.Username, password, ct);
- return client;
- }
- catch (Exception ex)
+
+ for (var attempt = 0; ; attempt++)
{
- logger.LogError(ex, "IMAP connect/authenticate failed for account {AccountId} against {Host}:{Port}", account.Id, account.ImapHost, account.ImapPort);
- throw;
+ 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<List<MailFolder>> GetFoldersAsync(Account account, string password, bool includeSizes = false, bool forceRefreshSizes = false, CancellationToken ct = default)
{
using var scope = logger.BeginScope(new Dictionary<string, object?> { ["accountId"] = account.Id });
+ var sw = System.Diagnostics.Stopwatch.StartNew();
using var client = await ConnectAsync(account, password, ct);
var result = new List<MailFolder>();
var personal = client.GetFolder(client.PersonalNamespaces[0]);
var folders = await personal.GetSubfoldersAsync(true, ct);
var settings = folderSettingsStore.GetAll(account.Id);
+ 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;
@@ -217,6 +401,7 @@ public class ImapService(
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
{
@@ -234,9 +419,15 @@ public class ImapService(
}
var (sizeBytes, isSizeEstimated) = includeSizes
- ? await ResolveFolderSizeAsync(account, password, f, setting?.SyncMode ?? FolderSyncMode.Direct, forceRefreshSizes, ct)
+ ? 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);
+ }
+
result.Add(new MailFolder
{
FullName = f.FullName,
@@ -252,8 +443,10 @@ public class ImapService(
IsProtected = isProtected
});
}
- catch
+ 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,
@@ -267,6 +460,8 @@ public class ImapService(
}
}
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));
}
@@ -278,7 +473,13 @@ public class ImapService(
// 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.
- private async Task<(long SizeBytes, bool IsEstimated)> ResolveFolderSizeAsync(Account account, string password, IMailFolder folder, FolderSyncMode syncMode, bool forceRebuild, CancellationToken ct)
+ // 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);
@@ -298,7 +499,7 @@ public class ImapService(
try
{
- await EnsureFolderIndexedAsync(account, password, folder.FullName, ct);
+ await EnsureFolderIndexedAsync(account, password, client, folder.FullName, ct);
return (indexStore.GetTotalSize(account.Id, folder.FullName), false);
}
catch
@@ -326,11 +527,26 @@ public class ImapService(
{
using var scope = logger.BeginScope(new Dictionary<string, object?> { ["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<string, object?> { ["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)
{
@@ -364,7 +580,8 @@ public class ImapService(
indexStore.ReplaceFolder(account.Id, folderFullName, messages);
indexStore.SaveSyncState(account.Id, folderFullName, folder.UidValidity, uidNext, folder.Count);
- await client.DisconnectAsync(true, ct);
+ 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;
}
@@ -391,28 +608,11 @@ public class ImapService(
if (missing.Count > 0) indexStore.RemoveMessages(account.Id, folderFullName, missing);
}
- // None of the checks above catch flag changes made to already-indexed messages by another
- // IMAP client (or the server itself) - UIDVALIDITY/UIDNEXT/count are all unaffected by a
- // Seen flag flipping on an existing UID, so a message marked unread elsewhere would
- // otherwise stay "read" in our index forever. The live Unread count from the STATUS call
- // above is free to compare against the index's own count; a mismatch means flags drifted,
- // so re-fetch Envelope+Flags for every current UID and upsert (corrects IsRead/IsFlagged/etc
- // for the whole folder) rather than trying to figure out which specific UIDs changed.
- if (indexStore.GetUnreadCount(account.Id, folderFullName) != folder.Unread)
- {
- logger.LogInformation("Flag drift detected in folder {Folder} for account {AccountId}: index unread={IndexUnread}, server unread={ServerUnread} - refreshing flags",
- folderFullName, account.Id, indexStore.GetUnreadCount(account.Id, folderFullName), folder.Unread);
-
- await folder.OpenAsync(FolderAccess.ReadOnly, ct);
- var allUids = await folder.SearchAsync(SearchQuery.All, ct);
- var refreshed = allUids.Count > 0 ? await FetchIndexedMessagesAsync(folder, allUids, ct) : [];
- await folder.CloseAsync(false, ct);
-
- if (refreshed.Count > 0) indexStore.UpsertMessages(account.Id, folderFullName, refreshed);
- }
-
indexStore.SaveSyncState(account.Id, folderFullName, folder.UidValidity, uidNext, folder.Count);
- await client.DisconnectAsync(true, ct);
+ 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
@@ -640,7 +840,13 @@ public class ImapService(
// 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.
- indexStore.MarkRead(account.Id, folderFullName, [uid], true);
+ // 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(); }
var html = message.HtmlBody ?? "";
if (!string.IsNullOrEmpty(html))
@@ -682,7 +888,10 @@ public class ImapService(
MimeKit.MessagePriority.Urgent => ModelPriority.High,
_ => ModelPriority.Normal
},
- Sensitivity = ParseSensitivity(message.Headers["Sensitivity"])
+ Sensitivity = ParseSensitivity(message.Headers["Sensitivity"]),
+ FolderUnreadCount = indexStore.GetSyncState(account.Id, folderFullName) != null
+ ? indexStore.GetUnreadCount(account.Id, folderFullName)
+ : null
};
int idx = 0;
@@ -727,6 +936,20 @@ public class ImapService(
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);
}
@@ -844,7 +1067,14 @@ public class ImapService(
var applied = await ApplySeenFlagWithVerificationAsync(folder, uidList, seen, ct);
await client.DisconnectAsync(true, ct);
- indexStore.MarkRead(account.Id, folderFullName, applied, seen);
+ // 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<uint> uids, bool flagged, CancellationToken ct = default)
@@ -857,7 +1087,11 @@ public class ImapService(
if (!flagged) await folder.RemoveFlagsAsync(uidList, MessageFlags.Flagged, true, ct);
await client.DisconnectAsync(true, ct);
- indexStore.MarkFlagged(account.Id, folderFullName, uidList.Select(u => u.Id), flagged);
+ // 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
@@ -884,7 +1118,11 @@ public class ImapService(
logger.LogInformation("Marked {Applied}/{Requested} messages as {SeenState} in folder {Folder} for account {AccountId}",
applied.Count, uids.Count, seen ? "read" : "unread", folderFullName, account.Id);
- indexStore.MarkRead(account.Id, folderFullName, applied, seen);
+ // 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
diff --git a/MailSharp.MailClient/Services/MessageIndexStore.cs b/MailSharp.MailClient/Services/MessageIndexStore.cs
index 13ab604..dc9cebd 100644
--- a/MailSharp.MailClient/Services/MessageIndexStore.cs
+++ b/MailSharp.MailClient/Services/MessageIndexStore.cs
@@ -9,6 +9,7 @@ public interface IMessageIndexStore
void SaveSyncState(int accountId, string folder, uint uidValidity, uint uidNext, int messageCount);
List<IndexedMessage> GetMessages(int accountId, string folder);
+ IndexedMessage? GetMessage(int accountId, string folder, uint uid);
int GetMessageCount(int accountId, string folder);
// Full rebuild: replaces every row for this folder in one go (used on UIDVALIDITY change, i.e.
@@ -79,6 +80,13 @@ public class LiteDbMessageIndexStore : IMessageIndexStore, IDisposable
public List<IndexedMessage> GetMessages(int accountId, string folder) =>
[.. Messages.Find(x => x.Key == Key(accountId, folder))];
+ // O(1) primary-key lookup (unlike GetMessages, which scans/returns every row for the folder) -
+ // for callers that only need one message's current state, e.g. checking whether it's still
+ // unread before deciding whether a cache-hit read needs an IMAP round trip after all (see
+ // ImapService.LoadMimeMessageAsync).
+ public IndexedMessage? GetMessage(int accountId, string folder, uint uid) =>
+ Messages.FindById(MessageId(accountId, folder, uid));
+
public int GetMessageCount(int accountId, string folder) =>
Messages.Count(x => x.Key == Key(accountId, folder));
diff --git a/MailSharp.MailClient/Views/Maintenance/Index.cshtml b/MailSharp.MailClient/Views/Maintenance/Index.cshtml
index 4f5b5d7..d3492c3 100644
--- a/MailSharp.MailClient/Views/Maintenance/Index.cshtml
+++ b/MailSharp.MailClient/Views/Maintenance/Index.cshtml
@@ -103,20 +103,7 @@
<input type="text" id="logSearchFilter" placeholder="Zoeken in bericht..." />
<a class="btn" href="#" id="logFilterBtn">Filteren</a>
</div>
- <div class="folder-manage-table-wrap">
- <table class="folder-manage-table">
- <thead>
- <tr>
- <th>Tijd</th>
- <th>Niveau</th>
- <th>Categorie</th>
- <th>Account</th>
- <th>Bericht</th>
- </tr>
- </thead>
- <tbody id="logsBody"></tbody>
- </table>
- </div>
+ <div id="logsBody" class="logs-list"></div>
<div class="folder-manage-toolbar">
<a class="btn" href="#" id="logsPrevBtn">Vorige</a>
<span id="logsPageInfo"></span>
diff --git a/MailSharp.MailClient/appsettings.json b/MailSharp.MailClient/appsettings.json
index 0075935..bcbda82 100644
--- a/MailSharp.MailClient/appsettings.json
+++ b/MailSharp.MailClient/appsettings.json
@@ -2,7 +2,8 @@
"Logging": {
"LogLevel": {
"Default": "Information",
- "Microsoft.AspNetCore": "Warning"
+ "Microsoft.AspNetCore": "Warning",
+ "MailSharp.MailClient.Services.ImapService": "Debug"
}
},
"AllowedHosts": "*",
diff --git a/MailSharp.MailClient/wwwroot/css/site.css b/MailSharp.MailClient/wwwroot/css/site.css
index 792fbfd..f543490 100644
--- a/MailSharp.MailClient/wwwroot/css/site.css
+++ b/MailSharp.MailClient/wwwroot/css/site.css
@@ -293,6 +293,20 @@ label { font-size: 13px; color: var(--text-muted); display: block; margin-bottom
.folder-manage-table th:nth-child(4), .folder-manage-table td:nth-child(4) { text-align: right; }
.folder-manage-table select { width: auto; padding: 6px 8px; font-size: 13px; }
.folder-manage-table tfoot td { border-top: 2px solid var(--border); border-bottom: none; }
+
+/* Log viewer: the message is the thing being read, so it gets full width and readable size; time/
+ level/category/account are just orientation and stay small and out of the way. A table forces
+ every column to the same importance - this is a plain list instead, one entry per log line. */
+.logs-list { display: flex; flex-direction: column; gap: 2px; }
+.log-entry { padding: 8px 10px; border-bottom: 1px solid var(--border); }
+.log-entry-meta { font-size: 12px; color: var(--text-muted); margin-bottom: 3px; }
+.log-entry-level { display: inline-block; min-width: 62px; font-weight: 600; }
+.log-entry-message { font-size: 14px; white-space: pre-wrap; word-break: break-word; }
+.log-level-Warning .log-entry-level, .log-level-Warning .log-entry-message { color: #b8860b; }
+.log-level-Error .log-entry-level, .log-level-Error .log-entry-message,
+.log-level-Critical .log-entry-level, .log-level-Critical .log-entry-message { color: #d9534f; }
+.log-level-Warning { background: rgba(184, 134, 11, 0.06); }
+.log-level-Error, .log-level-Critical { background: rgba(217, 83, 79, 0.06); }
.settings-hint { color: var(--text-muted); font-size: 13px; margin-top: 12px; }
.folder-manage-table .move-handle { cursor: grab; color: var(--text-muted); display: flex; gap: 2px; align-items: center; }
diff --git a/MailSharp.MailClient/wwwroot/js/common.js b/MailSharp.MailClient/wwwroot/js/common.js
index 30aac65..5aa64c4 100644
--- a/MailSharp.MailClient/wwwroot/js/common.js
+++ b/MailSharp.MailClient/wwwroot/js/common.js
@@ -243,8 +243,7 @@ window.MailSharp = (function ()
if (btn) btn.addEventListener("click", cancelActiveRequests);
});
- var INDEXING_POLL_MS = 5000;
- var indexingPollTimer = null;
+ var indexingEventSource = null;
// entry.total is only known once a folder's full reindex has started scanning (see
// ImapService.IndexingProgress) - a key that's still connecting/searching has total 0, so it's
@@ -256,34 +255,60 @@ window.MailSharp = (function ()
return entry.key + " (" + entry.processed + " / " + entry.total + ", " + percent + "%)";
}
- // Polls the same background-indexing state the Maintenance page shows (see
- // MailApiController.IndexingStatus) and surfaces it as a bottom toast on every page, so users
- // notice their inbox is still being indexed without needing admin rights. Silently stops
- // polling on error (e.g. logged out) rather than nagging with repeated failures.
- function pollIndexingStatus()
+ function renderIndexingStatus(indexing)
{
- apiFetch("api/mail/indexing-status").then(function (indexing)
- {
- var toast = document.getElementById("indexingToast");
- if (!toast) return;
+ var toast = document.getElementById("indexingToast");
+ if (!toast) return;
- var activeCount = indexing.maxConcurrentIndexing - indexing.currentlyAvailableSlots;
- if (activeCount <= 0)
- {
- toast.hidden = true;
- } else
+ var activeCount = indexing.maxConcurrentIndexing - indexing.currentlyAvailableSlots;
+ if (activeCount <= 0)
+ {
+ toast.hidden = true;
+ } else
+ {
+ var text = formatTemplate(currentStrings.toast_indexing_active || "Achtergrondindexering: {0} / {1} actief.", activeCount, indexing.maxConcurrentIndexing);
+ if (indexing.inProgress.length > 0)
{
- var text = formatTemplate(currentStrings.toast_indexing_active || "Achtergrondindexering: {0} / {1} actief.", activeCount, indexing.maxConcurrentIndexing);
- if (indexing.inProgress.length > 0)
- {
- text += formatTemplate(currentStrings.toast_indexing_working_on || " Bezig met: {0}", indexing.inProgress.map(formatProgressEntry).join(", "));
- }
- toast.textContent = text;
- toast.hidden = false;
+ text += formatTemplate(currentStrings.toast_indexing_working_on || " Bezig met: {0}", indexing.inProgress.map(formatProgressEntry).join(", "));
}
+ toast.textContent = text;
+ toast.hidden = false;
+ }
+ }
- indexingPollTimer = setTimeout(pollIndexingStatus, INDEXING_POLL_MS);
- }).catch(function () { /* logged out or transient error - stop polling until next page load */ });
+ function stopIndexingStatusStream()
+ {
+ if (indexingEventSource)
+ {
+ indexingEventSource.close();
+ indexingEventSource = null;
+ }
+ }
+
+ // Server-pushed replacement for the old 5s setInterval poll (see MailApiController.
+ // IndexingStatusStream) - runs over its own connection outside apiFetch, so a background status
+ // update can no longer call hideLoading() and dismiss the Cancel button/loading overlay for an
+ // unrelated, still-in-flight foreground request. On a hard failure (e.g. the session expired -
+ // 401 from RequireAuthorization()) EventSource fails the connection without retrying per spec,
+ // so this just tears it down rather than nagging with reconnect attempts.
+ //
+ // One shared connection carries more than the indexing toast - see the "flags" listener below -
+ // so pages other than the indexing toast's owner (mail.js) can react to their own named event
+ // without common.js needing to know what they do with it; it just re-dispatches as a plain DOM
+ // event so any page can listen without coupling to this module's internals.
+ function startIndexingStatusStream()
+ {
+ stopIndexingStatusStream();
+ indexingEventSource = new EventSource("api/mail/indexing-status/stream");
+ indexingEventSource.addEventListener("indexing", function (e)
+ {
+ try { renderIndexingStatus(JSON.parse(e.data)); } catch (err) { /* ignore malformed frame */ }
+ });
+ indexingEventSource.addEventListener("flags", function (e)
+ {
+ try { document.dispatchEvent(new CustomEvent("mailsharp:flags-refreshed", { detail: JSON.parse(e.data) })); } catch (err) { /* ignore malformed frame */ }
+ });
+ indexingEventSource.onerror = function () { stopIndexingStatusStream(); };
}
// Shared bootstrap for every page's topbar: auth check + redirect, account email text,
@@ -333,7 +358,7 @@ window.MailSharp = (function ()
logoutBtn.addEventListener("click", function (e)
{
e.preventDefault();
- clearTimeout(indexingPollTimer);
+ stopIndexingStatusStream();
apiFetch("api/auth/logout", { method: "POST" }).then(function ()
{
window.location.href = "Account/Login";
@@ -341,7 +366,7 @@ window.MailSharp = (function ()
});
}
- pollIndexingStatus();
+ startIndexingStatusStream();
return { authState: authState, langState: langState };
});
diff --git a/MailSharp.MailClient/wwwroot/js/mail.js b/MailSharp.MailClient/wwwroot/js/mail.js
index b19202b..17b3ddd 100644
--- a/MailSharp.MailClient/wwwroot/js/mail.js
+++ b/MailSharp.MailClient/wwwroot/js/mail.js
@@ -42,14 +42,22 @@
state.strings = result.langState.strings;
updateSortLinksActive();
- return Promise.all([loadFolders(), loadMessages()]);
+ // Rendered independently rather than via Promise.all: folders and messages are two
+ // unrelated IMAP round-trips, and a slow/failing messages fetch (e.g. a large NotSynchronized
+ // folder, or a transient server hiccup) shouldn't leave the folder sidebar blank while it's
+ // pending - the user should at least be able to see and navigate the folder list.
+ var foldersReady = loadFolders().then(renderFolderList);
+ var messagesReady = loadMessages().then(function ()
+ {
+ renderMessageList();
+ renderDetailPane();
+ if (pendingOpen && pendingOpen.uid != null) openMessage(pendingOpen.uid);
+ });
+
+ return Promise.all([foldersReady, messagesReady]);
}).then(function ()
{
- renderFolderList();
- renderMessageList();
- renderDetailPane();
$.applySavedColumnWidths();
- if (pendingOpen && pendingOpen.uid != null) openMessage(pendingOpen.uid);
setupAutoCheck();
});
}
@@ -64,6 +72,16 @@
setInterval(function () { refreshAfterAction().catch(function () { }); }, minutes * 60 * 1000);
}
+ // Pushed by the server (see common.js's SSE stream / MailApiController.IndexingStatusStream)
+ // whenever the background recent-messages flag recheck finds a read/flag change made from
+ // another client (a phone, another tab) - only re-renders when it's the folder currently open,
+ // and the refresh itself is cheap (a local index read, not an IMAP round trip).
+ document.addEventListener("mailsharp:flags-refreshed", function (e)
+ {
+ if (!e.detail || e.detail.folder !== state.currentFolder || e.detail.changedCount <= 0) return;
+ refreshAfterAction().catch(function () { });
+ });
+
function renderMoveToFolderOptions()
{
var sel = document.getElementById("moveToFolder");
@@ -301,13 +319,26 @@
state.selectedUid = uid;
updateSelectedRowClasses();
- Promise.all([loadMessage(uid), loadFolders()]).then(function ()
+ // loadMessage() marking the message Seen (server-side and in the local index - see
+ // ImapService.GetMessageAsync) already returns the folder's updated unread count for free
+ // (MessageDetail.folderUnreadCount, read straight from that same local index) - patching just
+ // that one badge is enough, and avoids a full loadFolders() (a live IMAP STATUS on every
+ // folder) on every single message opened just to refresh one number.
+ loadMessage(uid).then(function ()
{
var item = state.messages.find(function (x) { return x.uid === uid; });
if (item) item.isRead = true;
+
+ var count = state.selectedMessage.folderUnreadCount;
+ if (count != null)
+ {
+ var folder = state.folders.find(function (f) { return f.fullName === state.currentFolder; });
+ if (folder) folder.unreadCount = count;
+ }
+
renderDetailPane();
- renderFolderList();
updateSelectedRowClasses();
+ renderFolderList();
});
}
diff --git a/MailSharp.MailClient/wwwroot/js/maintenance.js b/MailSharp.MailClient/wwwroot/js/maintenance.js
index c4c63e7..e7920c2 100644
--- a/MailSharp.MailClient/wwwroot/js/maintenance.js
+++ b/MailSharp.MailClient/wwwroot/js/maintenance.js
@@ -165,7 +165,12 @@
state.totalCount = result.totalCount;
var rows = result.items.map(function (e)
{
- return "<tr><td>" + $.esc($.formatDate(e.timestamp)) + "</td><td>" + $.esc(e.level) + "</td><td>" + $.esc(e.category) + "</td><td>" + (e.accountId != null ? e.accountId : "") + "</td><td>" + $.esc(e.message) + (e.exception ? " — " + $.esc(e.exception) : "") + "</td></tr>";
+ var meta = $.formatDate(e.timestamp) + " · " + e.category + (e.accountId != null ? " · account " + e.accountId : "");
+ var body = $.esc(e.message) + (e.exception ? "\n" + $.esc(e.exception) : "");
+ return '<div class="log-entry log-level-' + $.esc(e.level) + '">' +
+ '<div class="log-entry-meta"><span class="log-entry-level">' + $.esc(e.level) + "</span> " + $.esc(meta) + "</div>" +
+ '<div class="log-entry-message">' + body + "</div>" +
+ "</div>";
});
document.getElementById("logsBody").innerHTML = rows.join("");