tussentijdse commit

alphons <alphons@heijden.com> 5 Aug 2026, 16:19
0364124e23407c9fe697397e4e7691f92d217e7b
15 files changed
  • MailSharp.MailClient/Controllers/Api/Dtos.cs
  • MailSharp.MailClient/Controllers/Api/MailApiController.cs
  • MailSharp.MailClient/Controllers/Api/MaintenanceApiController.cs
  • MailSharp.MailClient/Controllers/Api/SettingsApiController.cs
  • MailSharp.MailClient/Localization/en.json
  • MailSharp.MailClient/Localization/nl.json
  • MailSharp.MailClient/Services/ImapService.cs
  • MailSharp.MailClient/Services/Logging/LogStore.cs
  • MailSharp.MailClient/Services/MessageIndexStore.cs
  • MailSharp.MailClient/Views/Mail/Index.cshtml
  • MailSharp.MailClient/Views/Maintenance/Index.cshtml
  • MailSharp.MailClient/wwwroot/css/site.css
  • MailSharp.MailClient/wwwroot/js/mail.js
  • MailSharp.MailClient/wwwroot/js/maintenance.js
  • MailSharp.MailClient/wwwroot/js/settings.js
diff --git a/MailSharp.MailClient/Controllers/Api/Dtos.cs b/MailSharp.MailClient/Controllers/Api/Dtos.cs
index 137ca06..dd6282c 100644
--- a/MailSharp.MailClient/Controllers/Api/Dtos.cs
+++ b/MailSharp.MailClient/Controllers/Api/Dtos.cs
@@ -169,3 +169,5 @@ public class SaveFolderSettingsRequest
public class AddFolderRequest { public string Name { get; set; } = ""; }
public class DeleteFoldersRequest { public List<string> FolderNames { get; set; } = []; }
+
+public class MoveFolderRequest { public string Folder { get; set; } = ""; public string? NewParent { get; set; } }
diff --git a/MailSharp.MailClient/Controllers/Api/MailApiController.cs b/MailSharp.MailClient/Controllers/Api/MailApiController.cs
index 73f9135..2eb5479 100644
--- a/MailSharp.MailClient/Controllers/Api/MailApiController.cs
+++ b/MailSharp.MailClient/Controllers/Api/MailApiController.cs
@@ -148,7 +148,7 @@ public class MailApiController(
}
[HttpGet("messages")]
- public async Task<IActionResult> Messages(string folder = "INBOX", string sort = "date", bool desc = true, int page = 1, bool refresh = false, CancellationToken ct = default)
+ public async Task<IActionResult> Messages(string folder = "INBOX", string sort = "date", bool desc = true, int page = 1, bool refresh = false, bool unreadOnly = false, CancellationToken ct = default)
{
var active = GetActive();
if (active == null) return Unauthorized();
@@ -157,6 +157,8 @@ public class MailApiController(
var pageSize = account.MessagesPerPage > 0 ? account.MessagesPerPage : _settings.MessageListPageSize;
var messages = await imapService.GetMessagesAsync(account, password, folder, refresh, ct);
+ if (unreadOnly) messages = [.. messages.Where(m => !m.IsRead)];
+
messages = sort switch
{
"subject" => desc ? [.. messages.OrderByDescending(m => m.Subject)] : [.. messages.OrderBy(m => m.Subject)],
diff --git a/MailSharp.MailClient/Controllers/Api/MaintenanceApiController.cs b/MailSharp.MailClient/Controllers/Api/MaintenanceApiController.cs
index 28d0337..4c2e609 100644
--- a/MailSharp.MailClient/Controllers/Api/MaintenanceApiController.cs
+++ b/MailSharp.MailClient/Controllers/Api/MaintenanceApiController.cs
@@ -43,6 +43,13 @@ public class MaintenanceApiController(
return Ok(new { items = result.Items, totalCount = result.TotalCount, page, pageSize });
}
+ [HttpPost("logs/clear")]
+ public IActionResult ClearLogs()
+ {
+ logStore.Clear();
+ return Ok();
+ }
+
[HttpGet("status")]
public IActionResult Status()
{
diff --git a/MailSharp.MailClient/Controllers/Api/SettingsApiController.cs b/MailSharp.MailClient/Controllers/Api/SettingsApiController.cs
index 3ce8353..2713177 100644
--- a/MailSharp.MailClient/Controllers/Api/SettingsApiController.cs
+++ b/MailSharp.MailClient/Controllers/Api/SettingsApiController.cs
@@ -127,6 +127,25 @@ public class SettingsApiController(
return Ok();
}
+ [HttpPost("folders/move")]
+ public async Task<IActionResult> MoveFolder(MoveFolderRequest request, CancellationToken ct)
+ {
+ var active = GetActive();
+ if (active == null) return Unauthorized();
+ if (string.IsNullOrWhiteSpace(request.Folder)) return BadRequest();
+
+ var (account, password) = active.Value;
+ try
+ {
+ await imapService.MoveFolderAsync(account, password, request.Folder, request.NewParent, ct);
+ return Ok();
+ }
+ catch (InvalidOperationException ex)
+ {
+ return BadRequest(new { error = ex.Message });
+ }
+ }
+
[HttpPost("folders/delete")]
public async Task<IActionResult> DeleteFolders(DeleteFoldersRequest request, CancellationToken ct)
{
diff --git a/MailSharp.MailClient/Localization/en.json b/MailSharp.MailClient/Localization/en.json
index f18b58c..0fb5abe 100644
--- a/MailSharp.MailClient/Localization/en.json
+++ b/MailSharp.MailClient/Localization/en.json
@@ -84,6 +84,7 @@
"mail_sort_from": "From",
"mail_sort_size": "Size",
"mail_refresh": "Refresh",
+ "mail_unread_only": "Unread only",
"mail_empty": "No messages in this folder.",
"mail_previous": "« Previous",
"mail_next": "Next »",
diff --git a/MailSharp.MailClient/Localization/nl.json b/MailSharp.MailClient/Localization/nl.json
index c4365aa..825037a 100644
--- a/MailSharp.MailClient/Localization/nl.json
+++ b/MailSharp.MailClient/Localization/nl.json
@@ -84,6 +84,7 @@
"mail_sort_from": "Afzender",
"mail_sort_size": "Grootte",
"mail_refresh": "Vernieuwen",
+ "mail_unread_only": "Alleen ongelezen",
"mail_empty": "Geen berichten in deze map.",
"mail_previous": "« Vorige",
"mail_next": "Volgende »",
diff --git a/MailSharp.MailClient/Services/ImapService.cs b/MailSharp.MailClient/Services/ImapService.cs
index 399dc04..d69a445 100644
--- a/MailSharp.MailClient/Services/ImapService.cs
+++ b/MailSharp.MailClient/Services/ImapService.cs
@@ -20,6 +20,12 @@ public interface IImapService
Task<List<MailFolder>> GetFoldersAsync(Account account, string password, bool includeSizes = false, bool forceRefreshSizes = false, CancellationToken ct = default);
Task CreateFolderAsync(Account account, string password, string folderName, 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);
+
// 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.
@@ -200,6 +206,7 @@ public class ImapService(
var folderLock = GetFolderMutationLock(key);
await folderLock.WaitAsync();
int changedCount;
+ List<uint> stillUnreadUids;
try
{
var currentLocal = indexStore.GetMessages(account.Id, folderFullName).ToDictionary(m => m.Uid);
@@ -208,6 +215,7 @@ public class ImapService(
var toMarkUnread = new List<uint>();
var toMarkFlagged = new List<uint>();
var toMarkUnflagged = new List<uint>();
+ stillUnreadUids = [];
foreach (var summary in summaries)
{
if (!currentLocal.TryGetValue(summary.UniqueId.Id, out var local)) continue;
@@ -215,6 +223,7 @@ public class ImapService(
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);
@@ -233,6 +242,14 @@ public class ImapService(
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
{
@@ -250,6 +267,23 @@ public class ImapService(
});
}
+ // 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<List<IMailFolder>> GetAllSubfoldersRecursiveAsync(IMailFolder root, bool subscribedOnly, CancellationToken ct)
+ {
+ var result = new List<IMailFolder>();
+ 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
@@ -264,7 +298,7 @@ public class ImapService(
if (folder != null) return folder;
var personal = client.GetFolder(client.PersonalNamespaces[0]);
- var subfolders = await personal.GetSubfoldersAsync(true, ct);
+ 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;
@@ -388,7 +422,7 @@ public class ImapService(
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 folders = await GetAllSubfoldersRecursiveAsync(personal, true, ct);
var settings = folderSettingsStore.GetAll(account.Id);
logger.LogInformation("GetFoldersAsync: account {AccountId}, includeSizes={IncludeSizes}, forceRefreshSizes={ForceRefreshSizes}, {FolderCount} folder(s) listed after {ElapsedMs}ms",
@@ -428,11 +462,28 @@ public class ImapService(
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 = f.Unread,
+ UnreadCount = unreadCount,
MessageCount = f.Count,
IsSelectable = true,
Depth = depth,
@@ -621,9 +672,34 @@ public class ImapService(
// 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<DateTimeOffset?> 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<List<IndexedMessage>> FetchIndexedMessagesAsync(IMailFolder folder, IList<UniqueId> uids, CancellationToken ct)
{
- var summaries = await folder.FetchAsync(uids, MessageSummaryItems.Envelope | MessageSummaryItems.Flags | MessageSummaryItems.Size, PriorityHeaderFields, 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,
@@ -631,7 +707,7 @@ public class ImapService(
From = s.Envelope?.From?.ToString() ?? "",
To = s.Envelope?.To?.ToString() ?? "",
Cc = s.Envelope?.Cc?.ToString() ?? "",
- Date = s.Envelope?.Date ?? DateTimeOffset.MinValue,
+ Date = ResolveMessageDate(s),
SizeBytes = s.Size ?? 0,
IsRead = s.Flags?.HasFlag(MessageFlags.Seen) ?? false,
IsFlagged = s.Flags?.HasFlag(MessageFlags.Flagged) ?? false,
@@ -694,6 +770,42 @@ public class ImapService(
logger.LogInformation("Created folder {Folder} for account {AccountId}", folderName, account.Id);
}
+ public async Task MoveFolderAsync(Account account, string password, string folderFullName, string? newParentFullName, CancellationToken ct = default)
+ {
+ using var scope = logger.BeginScope(new Dictionary<string, object?> { ["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<List<string>> DeleteFoldersAsync(Account account, string password, IEnumerable<string> folderFullNames, CancellationToken ct = default)
{
using var scope = logger.BeginScope(new Dictionary<string, object?> { ["accountId"] = account.Id });
@@ -848,7 +960,33 @@ public class ImapService(
try { indexStore.MarkRead(account.Id, folderFullName, [uid], true); }
finally { openMutationLock.Release(); }
- var html = message.HtmlBody ?? "";
+ // 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<TextPart>().FirstOrDefault()?.Text
+ : null;
+
+ var html = message.HtmlBody ?? (fallbackText != null ? System.Net.WebUtility.HtmlEncode(fallbackText).Replace("\n", "<br>") : "");
if (!string.IsNullOrEmpty(html))
{
foreach (var part in message.BodyParts.OfType<MimePart>())
@@ -877,9 +1015,13 @@ public class ImapService(
SenderEmail = senderEmail,
To = message.To.ToString(),
Cc = message.Cc.ToString(),
- Date = message.Date,
+ // 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 ?? "",
+ TextBody = message.TextBody ?? fallbackText ?? "",
HasExternalImages = hasExternal,
ImagesAllowed = allowImages,
Priority = message.Priority switch
@@ -889,8 +1031,12 @@ public class ImapService(
_ => 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.GetUnreadCount(account.Id, folderFullName)
+ ? indexStore.GetRecentUnreadCount(account.Id, folderFullName, RecentFlagsRefreshCount)
: null
};
@@ -1161,7 +1307,6 @@ public class ImapService(
{
if (seen) await folder.AddFlagsAsync(uid, MessageFlags.Seen, true, ct);
else await folder.RemoveFlagsAsync(uid, MessageFlags.Seen, true, ct);
- confirmed.Add(uid.Id);
}
catch (Exception ex)
{
@@ -1169,6 +1314,22 @@ public class ImapService(
}
}
+ // 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;
}
@@ -1310,7 +1471,7 @@ public class ImapService(
using var client = await ConnectAsync(account, password, ct);
var personal = client.GetFolder(client.PersonalNamespaces[0]);
- var folders = await personal.GetSubfoldersAsync(true, ct);
+ var folders = await GetAllSubfoldersRecursiveAsync(personal, true, ct);
var settings = folderSettingsStore.GetAll(account.Id);
var result = new List<MessageSearchResult>();
var query = SearchQuery.FromContains(address).Or(SearchQuery.ToContains(address));
diff --git a/MailSharp.MailClient/Services/Logging/LogStore.cs b/MailSharp.MailClient/Services/Logging/LogStore.cs
index 336e8c8..e4f3727 100644
--- a/MailSharp.MailClient/Services/Logging/LogStore.cs
+++ b/MailSharp.MailClient/Services/Logging/LogStore.cs
@@ -29,6 +29,7 @@ public interface ILogStore
void Add(LogEntry entry);
LogQueryResult Query(LogQuery query);
void PruneOlderThan(DateTime cutoffUtc);
+ void Clear();
}
// Holds a single long-lived LiteDatabase connection for the app's lifetime (registered as a
@@ -79,5 +80,7 @@ public class LiteDbLogStore : ILogStore, IDisposable
public void PruneOlderThan(DateTime cutoffUtc) => Logs.DeleteMany(x => x.Timestamp < cutoffUtc);
+ public void Clear() => Logs.DeleteAll();
+
public void Dispose() => _db.Dispose();
}
diff --git a/MailSharp.MailClient/Services/MessageIndexStore.cs b/MailSharp.MailClient/Services/MessageIndexStore.cs
index dc9cebd..09b888d 100644
--- a/MailSharp.MailClient/Services/MessageIndexStore.cs
+++ b/MailSharp.MailClient/Services/MessageIndexStore.cs
@@ -29,9 +29,28 @@ public interface IMessageIndexStore
void MarkRead(int accountId, string folder, IEnumerable<uint> uids, bool read);
void MarkFlagged(int accountId, string folder, IEnumerable<uint> uids, bool flagged);
+ // Self-heal for a message indexed before ResolveMessageDate's INTERNALDATE fallback existed (see
+ // ImapService.GetMessageAsync) - a normal index rebuild only happens on UIDVALIDITY change, so an
+ // already-broken date otherwise never gets corrected on its own. No-ops if the uid isn't indexed.
+ void UpdateDate(int accountId, string folder, uint uid, DateTimeOffset date);
+
long GetTotalSize(int accountId, string folder);
int GetUnreadCount(int accountId, string folder);
+ // Unread count among only the most recent `recentCount` messages (by UID) - what's actually
+ // shown to the user (see ImapService.GetFoldersAsync/GetMessageAsync). A raw folder-wide unread
+ // total isn't a useful number in practice: it counts a message from ten years ago, or 20,000
+ // untouched messages sitting in Trash, exactly the same as something that arrived five minutes
+ // ago - numbers nobody's actually trying to act on. Ordered/limited at the query level (not
+ // GetMessages().Take()) so this stays cheap even for a folder with tens of thousands of rows.
+ int GetRecentUnreadCount(int accountId, string folder, int recentCount);
+
+ // Same scope as GetRecentUnreadCount, but the actual UIDs rather than just the count - purely
+ // for diagnostics (see ImapService.GetFoldersAsync), so "the badge says 1" can be answered with
+ // "which one" directly from the same query the badge itself uses, instead of cross-referencing a
+ // differently-timed background job's own view of things.
+ List<uint> GetRecentUnreadUids(int accountId, string folder, int recentCount);
+
// Every folder this account currently has any indexed messages/sync state for - used by the
// Maintenance page's message-count metrics to aggregate across folders without needing the
// caller to already know the account's folder names.
@@ -139,6 +158,14 @@ public class LiteDbMessageIndexStore : IMessageIndexStore, IDisposable
}
}
+ public void UpdateDate(int accountId, string folder, uint uid, DateTimeOffset date)
+ {
+ var m = Messages.FindById(MessageId(accountId, folder, uid));
+ if (m == null || m.Date == date) return;
+ m.Date = date;
+ Messages.Update(m);
+ }
+
public void MarkFlagged(int accountId, string folder, IEnumerable<uint> uids, bool flagged)
{
foreach (var uid in uids)
@@ -159,6 +186,23 @@ public class LiteDbMessageIndexStore : IMessageIndexStore, IDisposable
public int GetUnreadCount(int accountId, string folder) =>
Messages.Count(x => x.Key == Key(accountId, folder) && !x.IsRead);
+ public int GetRecentUnreadCount(int accountId, string folder, int recentCount) =>
+ Messages.Query()
+ .Where(x => x.Key == Key(accountId, folder))
+ .OrderByDescending(x => x.Uid)
+ .Limit(recentCount)
+ .ToEnumerable()
+ .Count(x => !x.IsRead);
+
+ public List<uint> GetRecentUnreadUids(int accountId, string folder, int recentCount) =>
+ [.. Messages.Query()
+ .Where(x => x.Key == Key(accountId, folder))
+ .OrderByDescending(x => x.Uid)
+ .Limit(recentCount)
+ .ToEnumerable()
+ .Where(x => !x.IsRead)
+ .Select(x => x.Uid)];
+
public List<string> GetIndexedFolders(int accountId) =>
[.. SyncStates.Find(x => x.Id.StartsWith(accountId + ":"))
.Select(x => x.Id[(accountId.ToString().Length + 1)..])];
diff --git a/MailSharp.MailClient/Views/Mail/Index.cshtml b/MailSharp.MailClient/Views/Mail/Index.cshtml
index 44a6017..9ee7489 100644
--- a/MailSharp.MailClient/Views/Mail/Index.cshtml
+++ b/MailSharp.MailClient/Views/Mail/Index.cshtml
@@ -43,6 +43,14 @@
<option value="emptySpam" data-i18n="action_empty_spam"></option>
</select>
<a class="btn" href="#" id="spamBtn" data-i18n="mail_spam"></a>
+ <label class="unread-only-toggle">
+ <input type="checkbox" id="unreadOnlyToggle" />
+ <span data-i18n="mail_unread_only"></span>
+ </label>
+ <form id="gotoUidForm" class="goto-uid">
+ <input type="number" id="gotoUidInput" placeholder="UID..." style="width:90px" />
+ <button type="submit" class="btn">Ga naar UID</button>
+ </form>
<div class="sort-links">
<span data-i18n="mail_sort"></span>
<a href="#" data-sort="date" data-i18n="mail_sort_date"></a>
diff --git a/MailSharp.MailClient/Views/Maintenance/Index.cshtml b/MailSharp.MailClient/Views/Maintenance/Index.cshtml
index d3492c3..b67d721 100644
--- a/MailSharp.MailClient/Views/Maintenance/Index.cshtml
+++ b/MailSharp.MailClient/Views/Maintenance/Index.cshtml
@@ -102,6 +102,7 @@
<input type="number" id="logAccountFilter" placeholder="Account ID" style="width:110px" />
<input type="text" id="logSearchFilter" placeholder="Zoeken in bericht..." />
<a class="btn" href="#" id="logFilterBtn">Filteren</a>
+ <a class="btn" href="#" id="logClearBtn" style="margin-left:auto">Log wissen</a>
</div>
<div id="logsBody" class="logs-list"></div>
<div class="folder-manage-toolbar">
diff --git a/MailSharp.MailClient/wwwroot/css/site.css b/MailSharp.MailClient/wwwroot/css/site.css
index f543490..f0a7f75 100644
--- a/MailSharp.MailClient/wwwroot/css/site.css
+++ b/MailSharp.MailClient/wwwroot/css/site.css
@@ -206,6 +206,10 @@ label { font-size: 13px; color: var(--text-muted); display: block; margin-bottom
.folder-list li a:hover { background: var(--accent-bg); text-decoration: none; }
.folder-list li a.active { background: var(--accent-bg); color: var(--primary-dark); font-weight: 600; }
.folder-list .badge { color: var(--text-muted); font-size: 12px; }
+.folder-list li a.drop-target { background: var(--primary); color: #fff; outline: 2px dashed var(--primary-dark); outline-offset: -2px; }
+
+.message-row { cursor: grab; }
+.message-row:active { cursor: grabbing; }
.content {
flex: 1;
@@ -229,6 +233,10 @@ label { font-size: 13px; color: var(--text-muted); display: block; margin-bottom
.list-toolbar .sort-links a { padding: 6px 8px; border-radius: 6px; color: var(--text-muted); }
.list-toolbar .sort-links a.active { color: var(--primary-dark); font-weight: 600; background: var(--accent-bg); }
.list-toolbar select { width: auto; padding: 8px 10px; font-size: 13px; flex-shrink: 0; }
+.unread-only-toggle { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-muted); cursor: pointer; flex-shrink: 0; }
+.unread-only-toggle input { cursor: pointer; }
+.goto-uid { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
+.goto-uid input { padding: 8px 10px; font-size: 13px; }
.detail-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
.detail-toolbar select { width: auto; padding: 8px 10px; font-size: 13px; }
@@ -317,6 +325,7 @@ label { font-size: 13px; color: var(--text-muted); display: block; margin-bottom
.folder-manage-table .move-handle button:disabled { opacity: 0.25; cursor: default; }
.folder-manage-table tr.dragging { opacity: 0.4; }
.folder-manage-table tr.drag-over { box-shadow: inset 0 2px 0 var(--primary); }
+.folder-manage-table tr.nest-target { background: var(--accent-bg); outline: 2px dashed var(--primary); outline-offset: -2px; }
.folder-manage-table tr[data-protected="true"] .folder-row-select { visibility: hidden; }
/* three-column mail layout: sidebar (folders) | message-list-pane | detail-pane */
diff --git a/MailSharp.MailClient/wwwroot/js/mail.js b/MailSharp.MailClient/wwwroot/js/mail.js
index 17b3ddd..dc35074 100644
--- a/MailSharp.MailClient/wwwroot/js/mail.js
+++ b/MailSharp.MailClient/wwwroot/js/mail.js
@@ -13,6 +13,7 @@
currentFolder: "INBOX",
sort: "date",
desc: true,
+ unreadOnly: false,
page: 1,
pageSize: 20,
totalCount: 0,
@@ -130,7 +131,8 @@
sort: state.sort,
desc: state.desc,
page: state.page,
- refresh: !!opts.refresh
+ refresh: !!opts.refresh,
+ unreadOnly: state.unreadOnly
});
return $.apiFetch("api/mail/messages?" + params.toString()).then(function (result)
{
@@ -158,8 +160,13 @@
document.getElementById("folderList").innerHTML = state.folders.map(function (f)
{
var active = f.fullName === state.currentFolder;
+ // IMAP folders can be nested (server-defined hierarchy delimiter, e.g. "INBOX.Spam" or
+ // "Werk/Projecten") - GetFoldersAsync already computes this via FolderDepth, just wasn't
+ // used here before. Indent per level so a subfolder reads as belonging under its parent
+ // instead of as another unrelated top-level entry.
+ var indentStyle = f.depth > 0 ? ' style="padding-left:' + (10 + f.depth * 16) + 'px"' : "";
return (
- '<li><a href="#" class="' + (active ? "active" : "") + '" data-folder="' + $.esc(f.fullName) + '">' +
+ '<li><a href="#" class="' + (active ? "active" : "") + '" data-folder="' + $.esc(f.fullName) + '"' + indentStyle + '>' +
"<span>" + $.esc(f.displayName) + "</span>" +
(f.unreadCount > 0 ? '<span class="badge">' + f.unreadCount + "</span>" : "") +
"</a></li>"
@@ -200,7 +207,7 @@
var selected = m.uid === state.selectedUid ? "selected" : "";
var flagTitle = m.isFlagged ? state.strings.action_unflag : state.strings.action_flag;
return (
- '<li class="message-row ' + unread + " " + selected + '" data-uid="' + m.uid + '">' +
+ '<li class="message-row ' + unread + " " + selected + '" data-uid="' + m.uid + '" draggable="true">' +
'<span class="flag-toggle' + (m.isFlagged ? " flagged" : "") + '" data-flag-toggle title="' + $.esc(flagTitle || "") + '">&#9873;</span>' +
'<input type="checkbox" />' +
'<div class="main"><div class="subject">' +
@@ -448,21 +455,138 @@
}).catch(reportError);
});
+ function findSpamFolder()
+ {
+ return state.folders.find(function (f)
+ {
+ return f.fullName.toLowerCase() === "spam" || f.displayName.toLowerCase() === "spam" || f.fullName.toLowerCase() === "junk" || f.displayName.toLowerCase() === "junk";
+ });
+ }
+
+ function moveToSpam(uids, spamFolderFullName)
+ {
+ return $.apiFetch("api/mail/move", {
+ method: "POST", body: { folder: state.currentFolder, uids: uids, targetFolder: spamFolderFullName }
+ }).then(refreshAfterAction);
+ }
+
document.getElementById("spamBtn").addEventListener("click", function (e)
{
e.preventDefault();
var uids = getSelectedUids();
if (uids.length === 0) return;
- var spamFolder = state.folders.find(function (f)
+
+ var spamFolder = findSpamFolder();
+ if (spamFolder)
{
- return f.fullName.toLowerCase() === "spam" || f.displayName.toLowerCase() === "spam" || f.fullName.toLowerCase() === "junk" || f.displayName.toLowerCase() === "junk";
- });
- if (!spamFolder) return;
+ moveToSpam(uids, spamFolder.fullName).catch(reportError);
+ return;
+ }
+
+ // No Spam/Junk folder exists yet on this account - create one on the fly rather than making
+ // the user go set it up in Mappen beheren first just to use a button they already clicked.
+ $.apiFetch("api/settings/folders/add", { method: "POST", body: { name: "Spam" } })
+ .then(function () { return loadFolders(); })
+ .then(function ()
+ {
+ renderFolderList();
+ // Re-lookup rather than assuming the new folder's fullName is exactly "Spam" - some
+ // IMAP servers prefix personal-namespace folders (e.g. "INBOX.Spam").
+ var created = findSpamFolder();
+ return moveToSpam(uids, created ? created.fullName : "Spam");
+ })
+ .catch(reportError);
+ });
+
+
+ // ---------- drag and drop: message row(s) -> folder in the sidebar ----------
+
+ document.getElementById("messageListBody").addEventListener("dragstart", function (e)
+ {
+ var row = e.target.closest(".message-row");
+ if (!row) { e.preventDefault(); return; }
+
+ var uid = parseInt(row.getAttribute("data-uid"), 10);
+ // Dragging a row that's part of the current checkbox selection drags the whole selection;
+ // dragging an unrelated row (nothing checked, or checked rows elsewhere) only drags that one
+ // message - matches how most file managers treat a drag started outside the selection.
+ var selectedUids = getSelectedUids();
+ var uids = selectedUids.indexOf(uid) !== -1 ? selectedUids : [uid];
+
+ e.dataTransfer.effectAllowed = "move";
+ e.dataTransfer.setData("application/json", JSON.stringify(uids));
+ });
+
+ function isValidDropTarget(folderFullName)
+ {
+ if (folderFullName === state.currentFolder) return false;
+ var f = state.folders.find(function (x) { return x.fullName === folderFullName; });
+ return !!f && f.isSelectable;
+ }
+
+ document.getElementById("folderList").addEventListener("dragover", function (e)
+ {
+ var a = e.target.closest("a[data-folder]");
+ document.querySelectorAll("#folderList a.drop-target").forEach(function (el) { el.classList.remove("drop-target"); });
+ if (!a || !isValidDropTarget(a.getAttribute("data-folder"))) return;
+
+ e.preventDefault();
+ e.dataTransfer.dropEffect = "move";
+ a.classList.add("drop-target");
+ });
+
+ document.getElementById("folderList").addEventListener("dragleave", function (e)
+ {
+ var a = e.target.closest("a[data-folder]");
+ if (a) a.classList.remove("drop-target");
+ });
+
+ document.getElementById("folderList").addEventListener("drop", function (e)
+ {
+ var a = e.target.closest("a[data-folder]");
+ document.querySelectorAll("#folderList a.drop-target").forEach(function (el) { el.classList.remove("drop-target"); });
+ if (!a) return;
+ e.preventDefault();
+
+ var target = a.getAttribute("data-folder");
+ if (!isValidDropTarget(target)) return;
+
+ var uids;
+ try { uids = JSON.parse(e.dataTransfer.getData("application/json")); } catch (err) { return; }
+ if (!uids || uids.length === 0) return;
+
$.apiFetch("api/mail/move", {
- method: "POST", body: { folder: state.currentFolder, uids: uids, targetFolder: spamFolder.fullName }
- }).then(refreshAfterAction).catch(reportError);
+ method: "POST", body: { folder: state.currentFolder, uids: uids, targetFolder: target }
+ }).then(function ()
+ {
+ if (uids.indexOf(state.selectedUid) !== -1)
+ {
+ state.selectedUid = null;
+ state.selectedMessage = null;
+ }
+ refreshAfterAction().then(renderDetailPane);
+ }).catch(reportError);
});
+ // Debug/diagnostic tool: open a message by UID directly, regardless of which page/sort/filter
+ // the list is currently on - the UID is all a server log line (e.g. "still unread ... uids: X")
+ // ever gives you, and paging through hundreds of rows to find one row by eye doesn't scale.
+ document.getElementById("gotoUidForm").addEventListener("submit", function (e)
+ {
+ e.preventDefault();
+ var input = document.getElementById("gotoUidInput");
+ var uid = parseInt(input.value, 10);
+ if (!uid) return;
+ state.selectedUid = null;
+ openMessage(uid);
+ });
+
+ document.getElementById("unreadOnlyToggle").addEventListener("change", function (e)
+ {
+ state.unreadOnly = e.target.checked;
+ state.page = 1;
+ loadMessages().then(renderMessageList);
+ });
document.querySelectorAll(".sort-links a").forEach(function (a)
{
diff --git a/MailSharp.MailClient/wwwroot/js/maintenance.js b/MailSharp.MailClient/wwwroot/js/maintenance.js
index e7920c2..d029e5a 100644
--- a/MailSharp.MailClient/wwwroot/js/maintenance.js
+++ b/MailSharp.MailClient/wwwroot/js/maintenance.js
@@ -186,6 +186,17 @@
loadLogs();
});
+ document.getElementById("logClearBtn").addEventListener("click", function (e)
+ {
+ e.preventDefault();
+ if (!window.confirm("Alle logregels definitief verwijderen?")) return;
+ $.apiFetch("api/maintenance/logs/clear", { method: "POST" }).then(function ()
+ {
+ state.page = 1;
+ loadLogs();
+ }).catch(reportError);
+ });
+
// The level select filters immediately on change, unlike the free-text/account inputs (which
// wait for the Filteren button so every keystroke doesn't fire a request) - a dropdown change
// is already a single deliberate action, so there's no "still typing" state to debounce.
diff --git a/MailSharp.MailClient/wwwroot/js/settings.js b/MailSharp.MailClient/wwwroot/js/settings.js
index 1e25a64..e617aac 100644
--- a/MailSharp.MailClient/wwwroot/js/settings.js
+++ b/MailSharp.MailClient/wwwroot/js/settings.js
@@ -216,14 +216,44 @@
state.dragFullName = null;
});
+ // The middle band of a row means "drop onto this folder" (nest as a subfolder - a real IMAP
+ // RENAME, not just cosmetic); the top/bottom edges mean "drop between rows" (the existing
+ // display-only reorder). Same drag, two outcomes depending on where within the row it lands.
+ function isNestZone(row, clientY)
+ {
+ var rect = row.getBoundingClientRect();
+ var relative = (clientY - rect.top) / rect.height;
+ return relative > 0.25 && relative < 0.75;
+ }
+
document.getElementById("folderManageBody").addEventListener("dragover", function (e)
{
var row = e.target.closest("tr[data-folder]");
if (!row || !state.dragFullName) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
- document.querySelectorAll("#folderManageBody tr.drag-over").forEach(function (r) { if (r !== row) r.classList.remove("drag-over"); });
- row.classList.add("drag-over");
+
+ document.querySelectorAll("#folderManageBody tr.drag-over, #folderManageBody tr.nest-target").forEach(function (r)
+ {
+ if (r !== row) { r.classList.remove("drag-over"); r.classList.remove("nest-target"); }
+ });
+
+ if (row.getAttribute("data-folder") === state.dragFullName)
+ {
+ row.classList.remove("drag-over");
+ row.classList.remove("nest-target");
+ return;
+ }
+
+ if (isNestZone(row, e.clientY))
+ {
+ row.classList.add("nest-target");
+ row.classList.remove("drag-over");
+ } else
+ {
+ row.classList.add("drag-over");
+ row.classList.remove("nest-target");
+ }
});
document.getElementById("folderManageBody").addEventListener("drop", function (e)
@@ -231,12 +261,22 @@
var row = e.target.closest("tr[data-folder]");
if (!row || !state.dragFullName) return;
e.preventDefault();
+ var wasNest = row.classList.contains("nest-target");
row.classList.remove("drag-over");
+ row.classList.remove("nest-target");
var draggedName = state.dragFullName;
var targetName = row.getAttribute("data-folder");
if (draggedName === targetName) return;
+ if (wasNest)
+ {
+ $.apiFetch("api/settings/folders/move", { method: "POST", body: { folder: draggedName, newParent: targetName } })
+ .then(loadFolders)
+ .catch(reportError);
+ return;
+ }
+
var order = currentRowOrder().filter(function (name) { return name !== draggedName; });
var targetIndex = order.indexOf(targetName);
order.splice(targetIndex, 0, draggedName);