better formatting header

alphons <alphons@heijden.com> 5 Aug 2026, 20:43
c272f022d6a2b6cb518239001b9945383de345b9
10 files changed
  • MailSharp.MailClient/Controllers/Api/Dtos.cs
  • MailSharp.MailClient/Controllers/Api/MailApiController.cs
  • MailSharp.MailClient/Controllers/Api/SettingsApiController.cs
  • MailSharp.MailClient/Localization/en.json
  • MailSharp.MailClient/Localization/nl.json
  • MailSharp.MailClient/Services/ImapService.cs
  • MailSharp.MailClient/Views/Mail/Index.cshtml
  • MailSharp.MailClient/wwwroot/css/site.css
  • MailSharp.MailClient/wwwroot/js/mail.js
  • MailSharp.MailClient/wwwroot/js/settings.js
diff --git a/MailSharp.MailClient/Controllers/Api/Dtos.cs b/MailSharp.MailClient/Controllers/Api/Dtos.cs
index dd6282c..5e7b52d 100644
--- a/MailSharp.MailClient/Controllers/Api/Dtos.cs
+++ b/MailSharp.MailClient/Controllers/Api/Dtos.cs
@@ -171,3 +171,5 @@ 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; } }
+
+public class RenameFolderRequest { public string Folder { get; set; } = ""; public string NewName { get; set; } = ""; }
diff --git a/MailSharp.MailClient/Controllers/Api/MailApiController.cs b/MailSharp.MailClient/Controllers/Api/MailApiController.cs
index 2eb5479..b04408e 100644
--- a/MailSharp.MailClient/Controllers/Api/MailApiController.cs
+++ b/MailSharp.MailClient/Controllers/Api/MailApiController.cs
@@ -196,6 +196,11 @@ public class MailApiController(
var active = GetActive();
if (active == null) return Unauthorized();
+ // Defense in depth: the client already hides this action inside Spam (see ImapService.
+ // GetMessageAsync/IsSpamFolder), but a direct API call should be refused too, not just
+ // silently accepted and then ignored on the next render.
+ if (ImapService.IsSpamFolder(request.Folder)) return BadRequest(new { error = "Externe afbeeldingen kunnen niet worden toegestaan voor berichten in de Spam-map." });
+
if (!string.IsNullOrWhiteSpace(request.Sender))
mailCacheStore.SetAllowExternalImages(active.Value.account.Id, request.Sender, true);
diff --git a/MailSharp.MailClient/Controllers/Api/SettingsApiController.cs b/MailSharp.MailClient/Controllers/Api/SettingsApiController.cs
index 2713177..2a21902 100644
--- a/MailSharp.MailClient/Controllers/Api/SettingsApiController.cs
+++ b/MailSharp.MailClient/Controllers/Api/SettingsApiController.cs
@@ -91,18 +91,23 @@ public class SettingsApiController(
var active = GetActive();
if (active == null) return Unauthorized();
var (account, password) = active.Value;
- return Ok(await imapService.GetFoldersAsync(account, password, includeSizes: true, forceRefreshSizes: refresh, ct: ct));
+ // Unlike the Mail app's folder sidebar, Mappen beheren's whole point is to let the user see
+ // and toggle folders they haven't subscribed to yet (e.g. one just created - see
+ // CreateFolderAsync) - so this is the one caller that needs every folder, not just LSUB's
+ // subscribed-only view.
+ return Ok(await imapService.GetFoldersAsync(account, password, includeSizes: true, forceRefreshSizes: refresh, includeUnsubscribed: true, ct: ct));
}
// The array's own sequence doubles as the new display order (see IFolderSettingsStore.
// SaveOrder) - the client always submits every row it currently shows, in its current
// (possibly drag-reordered) on-screen order, so there's no separate "save order" step.
[HttpPost("folders")]
- public IActionResult SaveFolders(SaveFolderSettingsRequest request)
+ public async Task<IActionResult> SaveFolders(SaveFolderSettingsRequest request, CancellationToken ct)
{
var active = GetActive();
if (active == null) return Unauthorized();
- var accountId = active.Value.account.Id;
+ var (account, password) = active.Value;
+ var accountId = account.Id;
foreach (var f in request.Folders)
{
@@ -110,6 +115,15 @@ public class SettingsApiController(
folderSettingsStore.Save(accountId, f.FullName, f.SyncMode, f.Subscribed);
}
+ // The checkbox drives real IMAP subscription state (see GetFoldersAsync's includeUnsubscribed/
+ // SetFolderSubscriptionsAsync) - folderSettingsStore.Save above only records the app's own
+ // per-folder sync-mode preference now, Subscribed there is vestigial but left alone rather
+ // than migrating/removing it for a handful of leftover reads elsewhere.
+ var subscriptions = request.Folders
+ .Where(f => !string.IsNullOrWhiteSpace(f.FullName))
+ .ToDictionary(f => f.FullName, f => f.Subscribed);
+ if (subscriptions.Count > 0) await imapService.SetFolderSubscriptionsAsync(account, password, subscriptions, ct);
+
folderSettingsStore.SaveOrder(accountId, [.. request.Folders.Select(f => f.FullName).Where(n => !string.IsNullOrWhiteSpace(n))]);
return Ok();
@@ -127,6 +141,25 @@ public class SettingsApiController(
return Ok();
}
+ [HttpPost("folders/rename")]
+ public async Task<IActionResult> RenameFolder(RenameFolderRequest request, CancellationToken ct)
+ {
+ var active = GetActive();
+ if (active == null) return Unauthorized();
+ if (string.IsNullOrWhiteSpace(request.Folder) || string.IsNullOrWhiteSpace(request.NewName)) return BadRequest();
+
+ var (account, password) = active.Value;
+ try
+ {
+ await imapService.RenameFolderAsync(account, password, request.Folder, request.NewName, ct);
+ return Ok();
+ }
+ catch (InvalidOperationException ex)
+ {
+ return BadRequest(new { error = ex.Message });
+ }
+ }
+
[HttpPost("folders/move")]
public async Task<IActionResult> MoveFolder(MoveFolderRequest request, CancellationToken ct)
{
diff --git a/MailSharp.MailClient/Localization/en.json b/MailSharp.MailClient/Localization/en.json
index 0fb5abe..3bd660d 100644
--- a/MailSharp.MailClient/Localization/en.json
+++ b/MailSharp.MailClient/Localization/en.json
@@ -95,8 +95,8 @@
"action_mark_unread": "Mark as unread",
"action_flag": "Flag",
"action_unflag": "Unflag",
- "action_mark_all_read": "Mark all as read",
- "action_mark_all_unread": "Mark all as unread",
+ "action_mark_all_read": "All as Read",
+ "action_mark_all_unread": "All as Unread",
"mail_move_to": "Move to...",
"mail_delete_menu": "Delete...",
"action_delete": "Delete",
@@ -115,6 +115,7 @@
"detail_cc": "Cc:",
"detail_date": "Date:",
"detail_images_blocked": "External images are blocked for this sender.",
+ "detail_images_blocked_spam": "External images are blocked and cannot be allowed for messages in the Spam folder.",
"detail_always_show_for": "Always show for {0}",
"detail_view": "View",
"detail_download": "Download",
@@ -163,6 +164,8 @@
"settings_reload_folders": "Reload folders",
"settings_delete_non_empty_warning": "Non-empty folders cannot be deleted. Please empty them first.",
"settings_new_folder_prompt": "Name of the new folder:",
+ "settings_rename_hint": "Double-click to rename",
+ "settings_rename_prompt": "New name:",
"settings_folder_order_hint": "Drag a folder (or use the arrows) to change its order in the main menu. This only changes the display, not the actual folder structure.",
"sync_not_synchronized": "Do not synchronize",
diff --git a/MailSharp.MailClient/Localization/nl.json b/MailSharp.MailClient/Localization/nl.json
index 825037a..393913e 100644
--- a/MailSharp.MailClient/Localization/nl.json
+++ b/MailSharp.MailClient/Localization/nl.json
@@ -95,8 +95,8 @@
"action_mark_unread": "Als ongelezen markeren",
"action_flag": "Vlaggen",
"action_unflag": "Uitvlaggen",
- "action_mark_all_read": "Allemaal als gelezen markeren",
- "action_mark_all_unread": "Allemaal als ongelezen markeren",
+ "action_mark_all_read": "Allemaal als Gelezen",
+ "action_mark_all_unread": "Allemaal als Ongelezen",
"mail_move_to": "Verplaatsen naar...",
"mail_delete_menu": "Verwijderen...",
"action_delete": "Verwijderen",
@@ -115,6 +115,7 @@
"detail_cc": "Cc:",
"detail_date": "Datum:",
"detail_images_blocked": "Externe afbeeldingen zijn geblokkeerd voor deze afzender.",
+ "detail_images_blocked_spam": "Externe afbeeldingen zijn geblokkeerd en kunnen niet worden toegestaan voor berichten in de Spam-map.",
"detail_always_show_for": "Altijd tonen voor {0}",
"detail_view": "Bekijken",
"detail_download": "Downloaden",
@@ -163,6 +164,8 @@
"settings_reload_folders": "Mappen opnieuw laden",
"settings_delete_non_empty_warning": "Niet-lege mappen verwijderen is niet mogelijk. Gelieve eerst de inhoud van deze mappen te verwijderen.",
"settings_new_folder_prompt": "Naam van de nieuwe map:",
+ "settings_rename_hint": "Dubbelklik om te hernoemen",
+ "settings_rename_prompt": "Nieuwe naam:",
"settings_folder_order_hint": "Versleep een map (of gebruik de pijltjes) om de volgorde in het hoofdmenu te wijzigen. Dit past alleen de weergave aan, niet de echte mappenstructuur.",
"sync_not_synchronized": "Niet synchroniseren",
diff --git a/MailSharp.MailClient/Services/ImapService.cs b/MailSharp.MailClient/Services/ImapService.cs
index d69a445..f45cab3 100644
--- a/MailSharp.MailClient/Services/ImapService.cs
+++ b/MailSharp.MailClient/Services/ImapService.cs
@@ -17,15 +17,30 @@ public interface IImapService
// 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.
- Task<List<MailFolder>> GetFoldersAsync(Account account, string password, bool includeSizes = false, bool forceRefreshSizes = false, CancellationToken ct = default);
+ //
+ // 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<List<MailFolder>> 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<string, bool> 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.
@@ -332,6 +347,21 @@ public class ImapService(
.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<IMailFolder> 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.");
@@ -415,16 +445,26 @@ public class ImapService(
}
}
- public async Task<List<MailFolder>> GetFoldersAsync(Account account, string password, bool includeSizes = false, bool forceRefreshSizes = false, CancellationToken ct = default)
+ public async Task<List<MailFolder>> GetFoldersAsync(Account account, string password, bool includeSizes = false, bool forceRefreshSizes = false, bool includeUnsubscribed = 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 GetAllSubfoldersRecursiveAsync(personal, true, ct);
+ 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<string>? 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);
@@ -488,7 +528,7 @@ public class ImapService(
IsSelectable = true,
Depth = depth,
SyncMode = setting?.SyncMode ?? FolderSyncMode.Direct,
- Subscribed = setting?.Subscribed ?? true,
+ Subscribed = subscribedFullNames?.Contains(f.FullName) ?? true,
SizeBytes = sizeBytes,
IsSizeEstimated = isSizeEstimated,
IsProtected = isProtected
@@ -505,7 +545,7 @@ public class ImapService(
IsSelectable = false,
Depth = depth,
SyncMode = setting?.SyncMode ?? FolderSyncMode.Direct,
- Subscribed = setting?.Subscribed ?? true,
+ Subscribed = subscribedFullNames?.Contains(f.FullName) ?? true,
IsProtected = isProtected
});
}
@@ -741,9 +781,26 @@ public class ImapService(
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 => position.TryGetValue(x.f.FullName, out var idx) ? idx : int.MaxValue)
+ .OrderBy(x => inheritedPosition[x.naturalIndex])
.ThenBy(x => x.naturalIndex)
.Select(x => x.f)];
}
@@ -765,9 +822,41 @@ public class ImapService(
using var scope = logger.BeginScope(new Dictionary<string, object?> { ["accountId"] = account.Id, ["folder"] = folderName });
using var client = await ConnectAsync(account, password, ct);
var personal = client.GetFolder(client.PersonalNamespaces[0]);
- await personal.CreateAsync(folderName, true, ct);
+ var folder = await personal.CreateAsync(folderName, true, ct);
+
+ // 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<string, bool> subscriptions, CancellationToken ct = default)
+ {
+ using var scope = logger.BeginScope(new Dictionary<string, object?> { ["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);
- 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)
@@ -806,6 +895,32 @@ public class ImapService(
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<string, object?> { ["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<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 });
@@ -1002,7 +1117,8 @@ public class ImapService(
}
var senderEmail = message.From.Mailboxes.FirstOrDefault()?.Address ?? "";
- var allowImages = cacheStore.GetAllowExternalImages(account.Id, senderEmail);
+ // 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);
diff --git a/MailSharp.MailClient/Views/Mail/Index.cshtml b/MailSharp.MailClient/Views/Mail/Index.cshtml
index 9ee7489..013b88c 100644
--- a/MailSharp.MailClient/Views/Mail/Index.cshtml
+++ b/MailSharp.MailClient/Views/Mail/Index.cshtml
@@ -22,41 +22,45 @@
<div class="content" id="mailContent">
<div class="message-list-pane">
<div class="list-toolbar">
- <a class="btn" href="#" id="refreshBtn" data-i18n="mail_refresh"></a>
- <input type="checkbox" data-select-all />
- <select id="markActions">
- <option value="" data-i18n="mail_actions" selected></option>
- <option value="markRead" data-i18n="action_mark_read"></option>
- <option value="markUnread" data-i18n="action_mark_unread"></option>
- <option value="flag" data-i18n="action_flag"></option>
- <option value="unflag" data-i18n="action_unflag"></option>
- <option value="markAllRead" data-i18n="action_mark_all_read"></option>
- <option value="markAllUnread" data-i18n="action_mark_all_unread"></option>
- </select>
- <select id="moveToFolder">
- <option value="" data-i18n="mail_move_to" selected></option>
- </select>
- <select id="deleteActions">
- <option value="" data-i18n="mail_delete_menu" selected></option>
- <option value="delete" data-i18n="action_delete"></option>
- <option value="emptyTrash" data-i18n="action_empty_trash"></option>
- <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>
- <a href="#" data-sort="subject" data-i18n="mail_sort_subject"></a>
- <a href="#" data-sort="from" data-i18n="mail_sort_from"></a>
- <a href="#" data-sort="size" data-i18n="mail_sort_size"></a>
+ <div class="list-toolbar-row list-toolbar-row-wrap">
+ <select id="markActions">
+ <option value="" data-i18n="mail_actions" selected></option>
+ <option value="markRead" data-i18n="action_mark_read"></option>
+ <option value="markUnread" data-i18n="action_mark_unread"></option>
+ <option value="flag" data-i18n="action_flag"></option>
+ <option value="unflag" data-i18n="action_unflag"></option>
+ <option value="markAllRead" data-i18n="action_mark_all_read"></option>
+ <option value="markAllUnread" data-i18n="action_mark_all_unread"></option>
+ </select>
+ <select id="moveToFolder">
+ <option value="" data-i18n="mail_move_to" selected></option>
+ </select>
+ <select id="deleteActions">
+ <option value="" data-i18n="mail_delete_menu" selected></option>
+ <option value="delete" data-i18n="action_delete"></option>
+ <option value="emptyTrash" data-i18n="action_empty_trash"></option>
+ <option value="emptySpam" data-i18n="action_empty_spam"></option>
+ </select>
+ </div>
+ <div class="list-toolbar-row">
+ <div class="sort-links">
+ <span data-i18n="mail_sort"></span>
+ <a href="#" data-sort="date" data-i18n="mail_sort_date"></a>
+ <a href="#" data-sort="subject" data-i18n="mail_sort_subject"></a>
+ <a href="#" data-sort="from" data-i18n="mail_sort_from"></a>
+ <a href="#" data-sort="size" data-i18n="mail_sort_size"></a>
+ </div>
+ </div>
+ <div class="list-toolbar-row checkbox-grid-row">
+ <span class="flag-col"></span>
+ <input type="checkbox" data-select-all />
+ <div class="toolbar-row-rest">
+ <label class="unread-only-toggle">
+ <input type="checkbox" id="unreadOnlyToggle" />
+ <span data-i18n="mail_unread_only"></span>
+ </label>
+ <a class="btn" href="#" id="spamBtn" data-i18n="mail_spam"></a>
+ </div>
</div>
</div>
<div id="messageListBody"></div>
diff --git a/MailSharp.MailClient/wwwroot/css/site.css b/MailSharp.MailClient/wwwroot/css/site.css
index f0a7f75..2cc246a 100644
--- a/MailSharp.MailClient/wwwroot/css/site.css
+++ b/MailSharp.MailClient/wwwroot/css/site.css
@@ -207,6 +207,9 @@ label { font-size: 13px; color: var(--text-muted); display: block; margin-bottom
.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; }
+.folder-row-right { display: flex; align-items: center; gap: 6px; }
+.folder-refresh { color: var(--text-muted); font-size: 14px; line-height: 1; padding: 2px; border-radius: 4px; }
+.folder-refresh:hover { color: var(--primary-dark); background: var(--panel); }
.message-row { cursor: grab; }
.message-row:active { cursor: grabbing; }
@@ -221,32 +224,50 @@ label { font-size: 13px; color: var(--text-muted); display: block; margin-bottom
/* message list */
.list-toolbar {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 10px 14px;
+ padding: 10px 0;
border-bottom: 1px solid var(--border);
background: var(--panel);
- flex-wrap: wrap;
}
-.list-toolbar .sort-links { display: flex; gap: 4px; font-size: 13px; margin-left: auto; }
+/* Horizontal padding lives on the row, not the container - every .list-toolbar-row and
+ .message-row (see further down) uses this exact same 14px, so whichever one sets the checkbox
+ column's left edge, they all agree. */
+.list-toolbar-row { display: flex; align-items: center; gap: 10px; flex-wrap: nowrap; overflow-x: auto; padding: 0 14px 2px; }
+.list-toolbar-row + .list-toolbar-row { margin-top: 8px; }
+/* The Acties/Verplaatsen/Verwijderen selects are the one row allowed to actually wrap onto a
+ second line on a narrow pane, rather than scrolling sideways - there's no checkbox-column
+ alignment to preserve here, unlike the bottom row. */
+.list-toolbar-row-wrap { flex-wrap: wrap; overflow-x: visible; }
+/* Single shared source of truth for the checkbox column's geometry, used by BOTH this row and
+ .message-row below (they carry this exact same class) - grid-template-columns/column-gap can
+ only be defined once, here, so the two can never drift apart the way matching flex gaps by hand
+ kept doing. Only the fixed-width leading columns (flag icon, checkbox) need to line up; the rest
+ of each row's content differs entirely, hence 1fr swallowing everything after that. */
+.checkbox-grid-row { display: grid; grid-template-columns: 20px 20px 1fr; column-gap: 10px; align-items: center; }
+.toolbar-row-rest { display: flex; align-items: center; gap: 10px; }
+.list-toolbar .sort-links { display: flex; align-items: center; gap: 4px; font-size: 13px; }
.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 { display: flex; align-items: center; gap: 6px; font-size: 13px; line-height: 1; 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; }
+/* Normalizes checkbox box-model across browsers - without this, native checkbox rendering height
+ varies just enough from the "Alleen ongelezen" text's own line-height/the Spam button's padding
+ that align-items:center on the row centers three slightly different box heights instead of
+ visually lining up their actual glyphs/baselines. */
+.list-toolbar-row input[type=checkbox] { width: 15px; height: 15px; margin: 0; flex-shrink: 0; }
-.detail-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
-.detail-toolbar select { width: auto; padding: 8px 10px; font-size: 13px; }
+.detail-header-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 12px; }
+.detail-header-main { flex: 1; min-width: 0; }
+.detail-toolbar { display: flex; flex-direction: column; align-items: stretch; gap: 6px; flex-shrink: 0; }
+.detail-toolbar select, .detail-toolbar .btn { width: 100%; padding: 8px 10px; font-size: 13px; }
#messageListBody, #contactMailsBody { display: flex; flex-direction: column; flex: 1; min-height: 0; }
.message-list { list-style: none; margin: 0; padding: 0; overflow-y: auto; flex: 1; background: var(--panel); }
+/* Extends .checkbox-grid-row's shared 20px/20px leading columns with a 4th (the right-aligned meta
+ block) - a more specific selector than .checkbox-grid-row alone, so it only overrides
+ grid-template-columns, not the shared display/column-gap/align-items. */
+.message-row.checkbox-grid-row { grid-template-columns: 20px 20px 1fr auto; }
.message-row {
- display: flex;
- align-items: center;
- gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid var(--border);
cursor: pointer;
@@ -263,7 +284,7 @@ label { font-size: 13px; color: var(--text-muted); display: block; margin-bottom
.message-row .meta .date { white-space: nowrap; }
.message-row .meta .size { margin-top: 2px; }
.priority-high { color: var(--danger); font-weight: 700; margin-right: 4px; }
-.flag-toggle { flex-shrink: 0; color: var(--border); font-size: 16px; line-height: 1; cursor: pointer; }
+.flag-toggle { text-align: center; color: var(--border); font-size: 16px; line-height: 1; cursor: pointer; }
.flag-toggle:hover { color: var(--text-muted); }
.flag-toggle.flagged { color: var(--danger); }
@@ -297,8 +318,8 @@ label { font-size: 13px; color: var(--text-muted); display: block; margin-bottom
.folder-manage-table-wrap { overflow-x: auto; }
.folder-manage-table { width: 100%; border-collapse: collapse; font-size: 14px; }
.folder-manage-table th, .folder-manage-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); text-align: left; white-space: nowrap; }
-.folder-manage-table th:nth-child(3), .folder-manage-table td:nth-child(3),
-.folder-manage-table th:nth-child(4), .folder-manage-table td:nth-child(4) { text-align: right; }
+.folder-manage-table th:nth-child(4), .folder-manage-table td:nth-child(4),
+.folder-manage-table th:nth-child(5), .folder-manage-table td:nth-child(5) { 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; }
diff --git a/MailSharp.MailClient/wwwroot/js/mail.js b/MailSharp.MailClient/wwwroot/js/mail.js
index dc35074..03913ff 100644
--- a/MailSharp.MailClient/wwwroot/js/mail.js
+++ b/MailSharp.MailClient/wwwroot/js/mail.js
@@ -23,6 +23,16 @@
selectedMessage: null
};
+ // Mirrors ImapService.IsSpamFolder's leaf-name hint matching - purely to decide whether to show
+ // the "always show images" button at all; the server enforces the actual restriction regardless
+ // (see MailApiController.AllowImages), this only avoids offering an action that would just fail.
+ var SPAM_NAME_HINTS = ["spam", "junk", "unwanted", "ongewenst"];
+ function isSpamFolder(folderFullName)
+ {
+ var leaf = folderFullName.split(/[/.]/).pop().toLowerCase();
+ return SPAM_NAME_HINTS.some(function (h) { return leaf.indexOf(h) !== -1; });
+ }
+
function bootstrap()
{
var pendingOpen = null;
@@ -165,10 +175,17 @@
// 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"' : "";
+ // The old standalone "Vernieuwen" toolbar button always refreshed whichever folder was
+ // currently open anyway, so it only ever needed to exist next to that one folder - moving
+ // it into the sidebar (only shown on the active row) removes an entire toolbar row's
+ // worth of vertical space without losing anything.
return (
'<li><a href="#" class="' + (active ? "active" : "") + '" data-folder="' + $.esc(f.fullName) + '"' + indentStyle + '>' +
"<span>" + $.esc(f.displayName) + "</span>" +
+ '<span class="folder-row-right">' +
+ (active ? '<span class="folder-refresh" data-refresh-folder title="' + $.esc(state.strings.mail_refresh || "Vernieuwen") + '">&#8635;</span>' : "") +
(f.unreadCount > 0 ? '<span class="badge">' + f.unreadCount + "</span>" : "") +
+ "</span>" +
"</a></li>"
);
}).join("");
@@ -207,7 +224,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 + '" draggable="true">' +
+ '<li class="message-row checkbox-grid-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">' +
@@ -240,13 +257,19 @@
var imagesBanner = "";
if (m.hasExternalImages && !m.imagesAllowed)
{
- imagesBanner =
- '<div class="alert alert-info image-block-banner">' +
- $.esc(t.detail_images_blocked) + " " +
- '<button type="button" class="btn-link" id="allowImagesBtn">' +
- $.esc($.formatTemplate(t.detail_always_show_for, m.senderEmail)) +
- "</button>" +
- "</div>";
+ // Spam messages never get an "always show" button - external images are how a spam/
+ // phishing sender confirms the mailbox is live (a loaded tracking pixel means "a human
+ // opened this"), so that trust decision doesn't make sense inside the folder that exists
+ // specifically to hold mail flagged as untrustworthy. Server refuses the action too either
+ // way (see MailApiController.AllowImages) - this just avoids offering it in the first place.
+ imagesBanner = isSpamFolder(state.currentFolder)
+ ? '<div class="alert alert-info image-block-banner">' + $.esc(t.detail_images_blocked_spam) + "</div>"
+ : '<div class="alert alert-info image-block-banner">' +
+ $.esc(t.detail_images_blocked) + " " +
+ '<button type="button" class="btn-link" id="allowImagesBtn">' +
+ $.esc($.formatTemplate(t.detail_always_show_for, m.senderEmail)) +
+ "</button>" +
+ "</div>";
}
var attachmentsHtml = "";
@@ -274,14 +297,10 @@
pane.innerHTML =
'<div class="message-detail">' +
'<a class="btn mobile-back" href="#" id="closeDetailBtn" style="margin-bottom:12px;">' + $.esc(t.detail_back_to_list) + "</a>" +
- '<div class="detail-toolbar">' +
- '<select id="replyActions">' +
- '<option value="" selected>' + $.esc(t.detail_reply_menu) + "</option>" +
- '<option value="reply">' + $.esc(t.action_reply) + "</option>" +
- '<option value="replyAll">' + $.esc(t.action_reply_all) + "</option>" +
- "</select>" +
- '<a class="btn" href="#" id="forwardBtn">' + $.esc(t.detail_forward) + "</a>" +
- "</div>" +
+ // Reply/Forward stacked to the right of the subject/headers, not their own full-width row
+ // above them - saves a whole row of vertical space, pushing the message body up.
+ '<div class="detail-header-row">' +
+ '<div class="detail-header-main">' +
'<div class="subject-line">' +
(m.priority === "High" ? '<span class="priority-high" title="' + $.esc(t.detail_priority_high || "") + '">!</span>' : "") +
$.esc(m.subject) + "</div>" +
@@ -291,6 +310,16 @@
(m.cc ? "<div><b>" + $.esc(t.detail_cc) + "</b> " + $.esc(m.cc) + "</div>" : "") +
"<div><b>" + $.esc(t.detail_date) + "</b> " + $.esc($.formatFullDate(m.date, state.lang)) + "</div>" +
"</div>" +
+ "</div>" +
+ '<div class="detail-toolbar">' +
+ '<select id="replyActions">' +
+ '<option value="" selected>' + $.esc(t.detail_reply_menu) + "</option>" +
+ '<option value="reply">' + $.esc(t.action_reply) + "</option>" +
+ '<option value="replyAll">' + $.esc(t.action_reply_all) + "</option>" +
+ "</select>" +
+ '<a class="btn" href="#" id="forwardBtn">' + $.esc(t.detail_forward) + "</a>" +
+ "</div>" +
+ "</div>" +
sensitivityBanner +
imagesBanner +
attachmentsHtml +
@@ -365,11 +394,6 @@
document.getElementById("sidebar").classList.toggle("open");
});
- document.getElementById("refreshBtn").addEventListener("click", function (e)
- {
- e.preventDefault();
- loadMessages({ refresh: true }).then(renderMessageList);
- });
function refreshAfterAction()
{
@@ -568,19 +592,6 @@
}).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;
@@ -603,6 +614,13 @@
document.getElementById("folderList").addEventListener("click", function (e)
{
+ if (e.target.closest("[data-refresh-folder]"))
+ {
+ e.preventDefault();
+ loadMessages({ refresh: true }).then(renderMessageList);
+ return;
+ }
+
var a = e.target.closest("a[data-folder]");
if (!a) return;
e.preventDefault();
diff --git a/MailSharp.MailClient/wwwroot/js/settings.js b/MailSharp.MailClient/wwwroot/js/settings.js
index e617aac..c8ff992 100644
--- a/MailSharp.MailClient/wwwroot/js/settings.js
+++ b/MailSharp.MailClient/wwwroot/js/settings.js
@@ -9,9 +9,23 @@
strings: {},
folders: [],
foldersLoaded: false,
- dragFullName: null
+ dragFullName: null,
+ foldersDirty: false
};
+ // Sync mode/subscribed/display order are batched - unlike Add/Delete/Move (which hit the server
+ // immediately), those only take effect once "Opslaan" is clicked. Leaving the page any other way
+ // (closing the tab, navigating elsewhere) would otherwise silently drop them with no indication
+ // anything was even pending.
+ function markFoldersDirty() { state.foldersDirty = true; }
+
+ window.addEventListener("beforeunload", function (e)
+ {
+ if (!state.foldersDirty) return;
+ e.preventDefault();
+ e.returnValue = "";
+ });
+
function reportError(err)
{
if (err.cancelled) return;
@@ -118,6 +132,7 @@
{
state.folders = folders;
state.foldersLoaded = true;
+ state.foldersDirty = false;
renderFolderTable();
});
}
@@ -141,7 +156,9 @@
'<button type="button" class="move-down" data-dir="1"' + (index === state.folders.length - 1 ? " disabled" : "") + ">▼</button>" +
"</span></td>" +
'<td><input type="checkbox" class="folder-row-select"' + (f.isProtected ? " disabled" : "") + " /></td>" +
- '<td style="padding-left:' + (f.depth * 20) + 'px;">' + $.esc(f.displayName) + "</td>" +
+ '<td class="folder-name-cell" style="padding-left:' + (f.depth * 20) + 'px;"' +
+ (f.isProtected ? "" : ' title="' + $.esc(t.settings_rename_hint || "Dubbelklik om te hernoemen") + '"') + '>' +
+ $.esc(f.displayName) + "</td>" +
'<td>' + f.messageCount + "</td>" +
'<td>' + formatSize(f.sizeBytes, f.isSizeEstimated) + "</td>" +
'<td><select class="folder-sync-mode">' + syncOptions + "</select></td>" +
@@ -175,8 +192,14 @@
state.folders.forEach(function (f) { byName[f.fullName] = f; });
state.folders = newFullNameOrder.map(function (name) { return byName[name]; }).filter(Boolean);
renderFolderTable();
+ markFoldersDirty();
}
+ document.getElementById("folderManageBody").addEventListener("change", function (e)
+ {
+ if (e.target.matches(".folder-sync-mode, .folder-subscribed")) markFoldersDirty();
+ });
+
document.getElementById("folderSelectAll").addEventListener("change", function (e)
{
document.querySelectorAll(".folder-row-select:not(:disabled)").forEach(function (cb) { cb.checked = e.target.checked; });
@@ -297,10 +320,28 @@
$.apiFetch("api/settings/folders", { method: "POST", body: { folders: folders } }).then(function ()
{
+ state.foldersDirty = false;
window.alert(state.strings.settings_saved);
}).catch(reportError);
});
+ document.getElementById("folderManageBody").addEventListener("dblclick", function (e)
+ {
+ var cell = e.target.closest(".folder-name-cell");
+ if (!cell) return;
+ var row = cell.closest("tr[data-folder]");
+ if (!row || row.getAttribute("data-protected") === "true") return;
+
+ var fullName = row.getAttribute("data-folder");
+ var currentName = state.folders.find(function (f) { return f.fullName === fullName; }).displayName;
+ var newName = window.prompt(state.strings.settings_rename_prompt || "Nieuwe naam:", currentName);
+ if (!newName || newName === currentName) return;
+
+ $.apiFetch("api/settings/folders/rename", { method: "POST", body: { folder: fullName, newName: newName } })
+ .then(loadFolders)
+ .catch(reportError);
+ });
+
document.getElementById("addFolderBtn").addEventListener("click", function (e)
{
e.preventDefault();