language dependencies and popup for external links
09e9dfb230b69d3a19217a4145950f4bc542181b
20 files changed
MailSharp.MailClient/Controllers/Api/MailApiController.csMailSharp.MailClient/Controllers/Api/MaintenanceApiController.csMailSharp.MailClient/Controllers/MailController.csMailSharp.MailClient/Localization/en.jsonMailSharp.MailClient/Localization/nl.jsonMailSharp.MailClient/Properties/PublishProfiles/aster - production.pubxmlMailSharp.MailClient/Services/ImageSanitizer.csMailSharp.MailClient/Services/ImapService.csMailSharp.MailClient/Services/Logging/BackgroundIndexingStatus.csMailSharp.MailClient/Views/Mail/Compose.cshtmlMailSharp.MailClient/Views/Mail/ExternalLink.cshtmlMailSharp.MailClient/Views/Shared/_Layout.cshtmlMailSharp.MailClient/wwwroot/css/site.cssMailSharp.MailClient/wwwroot/js/common.jsMailSharp.MailClient/wwwroot/js/compose.jsMailSharp.MailClient/wwwroot/js/contacts.jsMailSharp.MailClient/wwwroot/js/login.jsMailSharp.MailClient/wwwroot/js/mail.jsMailSharp.MailClient/wwwroot/js/maintenance.jsMailSharp.MailClient/wwwroot/js/settings.js
diff --git a/MailSharp.MailClient/Controllers/Api/MailApiController.cs b/MailSharp.MailClient/Controllers/Api/MailApiController.cs
index df28c81..4a44914 100644
--- a/MailSharp.MailClient/Controllers/Api/MailApiController.cs
+++ b/MailSharp.MailClient/Controllers/Api/MailApiController.cs
@@ -1,5 +1,6 @@
using MailSharp.MailClient.Models;
using MailSharp.MailClient.Services;
+using MailSharp.MailClient.Services.Logging;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.Options;
@@ -18,6 +19,7 @@ public class MailApiController(
IContactStore contactStore,
IOptions<MailSettings> mailSettings,
LocalizationService localizer,
+ IBackgroundIndexingStatus indexingStatus,
ILogger<MailApiController> logger) : ControllerBase
{
private readonly MailSettings _settings = mailSettings.Value;
@@ -33,6 +35,24 @@ public class MailApiController(
return (account, password);
}
+ // Lightweight, non-admin view of the same background-indexing state the Maintenance page shows
+ // (see BackgroundIndexingStatus) - polled by the topbar toast on every page so users notice
+ // their inbox is still being indexed without needing admin rights to open Maintenance.
+ [HttpGet("indexing-status")]
+ public IActionResult IndexingStatus()
+ {
+ return Ok(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 };
+ })
+ });
+ }
+
[HttpGet("folders")]
public async Task<IActionResult> Folders(CancellationToken ct)
{
diff --git a/MailSharp.MailClient/Controllers/Api/MaintenanceApiController.cs b/MailSharp.MailClient/Controllers/Api/MaintenanceApiController.cs
index a555fb6..28d0337 100644
--- a/MailSharp.MailClient/Controllers/Api/MaintenanceApiController.cs
+++ b/MailSharp.MailClient/Controllers/Api/MaintenanceApiController.cs
@@ -72,7 +72,11 @@ public class MaintenanceApiController(
{
maxConcurrentIndexing = indexingStatus.MaxConcurrentIndexing,
currentlyAvailableSlots = indexingStatus.CurrentlyAvailableSlots,
- inProgress = indexingStatus.InProgressKeys
+ inProgress = indexingStatus.InProgressKeys.Select(key =>
+ {
+ indexingStatus.TryGetProgress(key, out var processed, out var total);
+ return new { key, processed, total };
+ })
},
index = new
{
diff --git a/MailSharp.MailClient/Controllers/MailController.cs b/MailSharp.MailClient/Controllers/MailController.cs
index 3a3f190..0ccf5d8 100644
--- a/MailSharp.MailClient/Controllers/MailController.cs
+++ b/MailSharp.MailClient/Controllers/MailController.cs
@@ -16,4 +16,24 @@ public class MailController : Controller
[HttpGet]
public IActionResult Compose() => View();
+
+ // Interstitial every link inside a rendered message HTML body gets rewritten to point at (see
+ // ImageSanitizer.ApplyLinkPolicy) - lets the user confirm before a sender-controlled URL
+ // actually runs, and opens in a real top-level tab instead of the sandboxed message iframe so
+ // no destination site can refuse to be framed. 404s rather than open-redirecting on anything
+ // that isn't a well-formed http(s) URL, since url is attacker-influenced (it's copied straight
+ // out of an email).
+ [HttpGet]
+ public IActionResult ExternalLink(string url)
+ {
+ if (!Uri.TryCreate(url, UriKind.Absolute, out var parsed) ||
+ (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps))
+ {
+ return NotFound();
+ }
+
+ ViewData["ExternalUrl"] = parsed.AbsoluteUri;
+ ViewData["ExternalHost"] = parsed.Host;
+ return View();
+ }
}
diff --git a/MailSharp.MailClient/Localization/en.json b/MailSharp.MailClient/Localization/en.json
index 4744c57..f18b58c 100644
--- a/MailSharp.MailClient/Localization/en.json
+++ b/MailSharp.MailClient/Localization/en.json
@@ -169,5 +169,15 @@
"sync_all_headers": "All headers",
"sync_new_messages": "New messages",
"sync_all_messages": "All messages",
- "sync_direct": "Direct"
+ "sync_direct": "Direct",
+
+ "external_link_page_title": "External link",
+ "external_link_title": "You are leaving MailSharp",
+ "external_link_warning": "This link comes from an email message and goes to an external website:",
+ "external_link_confirm": "Are you sure you want to continue?",
+ "external_link_continue": "Continue",
+ "external_link_cancel": "Cancel",
+
+ "toast_indexing_active": "Background indexing: {0} / {1} active.",
+ "toast_indexing_working_on": " Working on: {0}"
}
diff --git a/MailSharp.MailClient/Localization/nl.json b/MailSharp.MailClient/Localization/nl.json
index 5551de5..c4365aa 100644
--- a/MailSharp.MailClient/Localization/nl.json
+++ b/MailSharp.MailClient/Localization/nl.json
@@ -169,5 +169,15 @@
"sync_all_headers": "Alle headers",
"sync_new_messages": "Nieuwe berichten",
"sync_all_messages": "Alle berichten",
- "sync_direct": "Direct"
+ "sync_direct": "Direct",
+
+ "external_link_page_title": "Externe link",
+ "external_link_title": "U verlaat MailSharp",
+ "external_link_warning": "Deze link komt uit een e-mailbericht en gaat naar een externe website:",
+ "external_link_confirm": "Weet u zeker dat u door wilt gaan?",
+ "external_link_continue": "Doorgaan",
+ "external_link_cancel": "Annuleren",
+
+ "toast_indexing_active": "Achtergrondindexering: {0} / {1} actief.",
+ "toast_indexing_working_on": " Bezig met: {0}"
}
diff --git a/MailSharp.MailClient/Properties/PublishProfiles/aster - production.pubxml b/MailSharp.MailClient/Properties/PublishProfiles/aster - production.pubxml
index c6b3134..35c5ad2 100644
--- a/MailSharp.MailClient/Properties/PublishProfiles/aster - production.pubxml
+++ b/MailSharp.MailClient/Properties/PublishProfiles/aster - production.pubxml
@@ -1,24 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
<Project>
- <PropertyGroup>
- <WebPublishMethod>MSDeploy</WebPublishMethod>
- <LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
- <LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
- <LastUsedPlatform>Any CPU</LastUsedPlatform>
- <SiteUrlToLaunchAfterPublish>https://heijden.com/meel2/</SiteUrlToLaunchAfterPublish>
- <ExcludeApp_Data>false</ExcludeApp_Data>
- <ProjectGuid>6b353405-77ee-4030-bc9f-77f35a5a7948</ProjectGuid>
- <SelfContained>false</SelfContained>
- <MSDeployServiceURL>192.168.74.200</MSDeployServiceURL>
- <DeployIisAppPath>www.heijden.com/meel2</DeployIisAppPath>
- <RemoteSitePhysicalPath />
- <SkipExtraFilesOnServer>true</SkipExtraFilesOnServer>
- <MSDeployPublishMethod>WMSVC</MSDeployPublishMethod>
- <EnableMSDeployBackup>true</EnableMSDeployBackup>
- <EnableMsDeployAppOffline>true</EnableMsDeployAppOffline>
- <UserName>Administrator</UserName>
- <_SavePWD>true</_SavePWD>
- <_TargetId>IISWebDeploy</_TargetId>
- </PropertyGroup>
+ <PropertyGroup>
+ <WebPublishMethod>MSDeploy</WebPublishMethod>
+ <LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
+ <LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
+ <LastUsedPlatform>Any CPU</LastUsedPlatform>
+ <SiteUrlToLaunchAfterPublish>https://heijden.com/meel2/</SiteUrlToLaunchAfterPublish>
+ <ExcludeApp_Data>false</ExcludeApp_Data>
+ <ProjectGuid>6b353405-77ee-4030-bc9f-77f35a5a7948</ProjectGuid>
+ <SelfContained>false</SelfContained>
+ <MSDeployServiceURL>192.168.74.200</MSDeployServiceURL>
+ <DeployIisAppPath>www.heijden.com/meel2</DeployIisAppPath>
+ <RemoteSitePhysicalPath />
+ <SkipExtraFilesOnServer>true</SkipExtraFilesOnServer>
+ <MSDeployPublishMethod>WMSVC</MSDeployPublishMethod>
+ <EnableMSDeployBackup>true</EnableMSDeployBackup>
+ <EnableMsDeployAppOffline>true</EnableMsDeployAppOffline>
+ <AllowUntrustedCertificate>True</AllowUntrustedCertificate>
+ <UserName>Administrator</UserName>
+ <_SavePWD>true</_SavePWD>
+ <_TargetId>IISWebDeploy</_TargetId>
+ </PropertyGroup>
</Project>
\ No newline at end of file
diff --git a/MailSharp.MailClient/Services/ImageSanitizer.cs b/MailSharp.MailClient/Services/ImageSanitizer.cs
index 72b2fb7..9617306 100644
--- a/MailSharp.MailClient/Services/ImageSanitizer.cs
+++ b/MailSharp.MailClient/Services/ImageSanitizer.cs
@@ -13,6 +13,30 @@ public partial class ImageSanitizer(IMailCacheStore cacheStore)
[GeneratedRegex(@"(<img\b[^>]*?\bsrc\s*=\s*)(['""])(https?://[^'""]+)\2", RegexOptions.IgnoreCase)]
private static partial Regex ImgSrcRegex();
+ [GeneratedRegex(@"(<a\b[^>]*?\bhref\s*=\s*)(['""])(https?://[^'""]+)\2", RegexOptions.IgnoreCase)]
+ private static partial Regex AnchorHrefRegex();
+
+ // Message HTML renders inside a sandboxed iframe (sandbox="allow-popups", no
+ // allow-top-navigation) - clicking a link as-is would just be silently blocked by that
+ // sandbox rather than doing anything the user would recognize as "not working" (and the
+ // remote site refusing to be framed makes it look like their fault instead). Rewriting every
+ // external href to our own interstitial (see MailController.ExternalLink) fixes both: it opens
+ // in a real top-level tab (never framed, so no site can refuse it), and gives the user an "you
+ // are leaving MailSharp" checkpoint before a sender-controlled link runs.
+ public string ApplyLinkPolicy(string html)
+ {
+ if (string.IsNullOrEmpty(html)) return html;
+
+ return AnchorHrefRegex().Replace(html, m =>
+ {
+ var prefix = m.Groups[1].Value;
+ var quote = m.Groups[2].Value;
+ var url = m.Groups[3].Value;
+ var interstitialHref = "Mail/ExternalLink?url=" + Uri.EscapeDataString(url);
+ return $"{prefix}{quote}{interstitialHref}{quote} target=\"_blank\" rel=\"noopener noreferrer\"";
+ });
+ }
+
public (string Html, bool HasExternalImages) ApplyPolicy(string html, bool allowExternalImages, string emailAddress, string messageId)
{
if (string.IsNullOrEmpty(html)) return (html, false);
@@ -36,7 +60,7 @@ public partial class ImageSanitizer(IMailCacheStore cacheStore)
var fileName = $"{index:D2}{extension}";
cacheStore.SaveImageSourceUrl(emailAddress, messageId, fileName, url);
- var src = $"/cache/images/{Uri.EscapeDataString(emailAddress)}/{Uri.EscapeDataString(messageId)}/{fileName}";
+ var src = $"cache/images/{Uri.EscapeDataString(emailAddress)}/{Uri.EscapeDataString(messageId)}/{fileName}";
return $"{prefix}{quote}{src}{quote} data-url={quote}{url}{quote}";
});
return (result, hasExternal);
diff --git a/MailSharp.MailClient/Services/ImapService.cs b/MailSharp.MailClient/Services/ImapService.cs
index b48780d..92a4446 100644
--- a/MailSharp.MailClient/Services/ImapService.cs
+++ b/MailSharp.MailClient/Services/ImapService.cs
@@ -56,6 +56,12 @@ public class ImapService(
// route indexing calls through a new service.
internal static readonly ConcurrentDictionary<string, byte> IndexingInProgress = new();
+ // Message-level progress for the same key as IndexingInProgress ("{accountId}:{folder}") -
+ // only populated during a full reindex (see EnsureFolderIndexedAsync), since that's the only
+ // path slow enough for a raw count/percentage to matter to a user watching the toast; the
+ // incremental sync paths finish fast enough that per-chunk progress wouldn't be visible anyway.
+ internal static readonly ConcurrentDictionary<string, (int Processed, int Total)> IndexingProgress = new();
+
// Caps how many background index builds run their IMAP connection at once, process-wide. Most
// IMAP servers cap concurrent connections per account - without this, clicking through several
// never-indexed folders in a row would fire off that many simultaneous background connections,
@@ -332,7 +338,28 @@ public class ImapService(
folderFullName, account.Id, state == null ? "first index" : "UIDVALIDITY changed");
await folder.OpenAsync(FolderAccess.ReadOnly, ct);
var allUids = await folder.SearchAsync(SearchQuery.All, ct);
- var messages = allUids.Count > 0 ? await FetchIndexedMessagesAsync(folder, allUids, ct) : [];
+
+ var progressKey = $"{account.Id}:{folderFullName}";
+ var messages = new List<IndexedMessage>(allUids.Count);
+ try
+ {
+ if (allUids.Count > 0) IndexingProgress[progressKey] = (0, allUids.Count);
+
+ // Fetched in chunks (rather than one FETCH for every UID) purely so IndexingProgress
+ // can be updated as we go - MailKit has no per-message progress callback for FETCH,
+ // only per-chunk granularity is achievable this way.
+ const int ChunkSize = 200;
+ for (var i = 0; i < allUids.Count; i += ChunkSize)
+ {
+ var chunk = allUids.Skip(i).Take(ChunkSize).ToList();
+ messages.AddRange(await FetchIndexedMessagesAsync(folder, chunk, ct));
+ IndexingProgress[progressKey] = (messages.Count, allUids.Count);
+ }
+ }
+ finally
+ {
+ IndexingProgress.TryRemove(progressKey, out _);
+ }
await folder.CloseAsync(false, ct);
indexStore.ReplaceFolder(account.Id, folderFullName, messages);
@@ -634,6 +661,7 @@ public class ImapService(
var allowImages = cacheStore.GetAllowExternalImages(account.Id, senderEmail);
var messageId = ResolveMessageId(message, folderFullName, uid);
var (transformedHtml, hasExternal) = imageSanitizer.ApplyPolicy(html, allowImages, account.EmailAddress, messageId);
+ transformedHtml = imageSanitizer.ApplyLinkPolicy(transformedHtml);
var detail = new MessageDetail
{
diff --git a/MailSharp.MailClient/Services/Logging/BackgroundIndexingStatus.cs b/MailSharp.MailClient/Services/Logging/BackgroundIndexingStatus.cs
index 4719f20..ef1a9fb 100644
--- a/MailSharp.MailClient/Services/Logging/BackgroundIndexingStatus.cs
+++ b/MailSharp.MailClient/Services/Logging/BackgroundIndexingStatus.cs
@@ -5,6 +5,11 @@ public interface IBackgroundIndexingStatus
int MaxConcurrentIndexing { get; }
int CurrentlyAvailableSlots { get; }
IReadOnlyCollection<string> InProgressKeys { get; }
+
+ // Only populated while a key's index build is doing a full reindex (see
+ // ImapService.EnsureFolderIndexedAsync) - returns false for keys still in InProgressKeys but
+ // not yet past the initial connect/search step, or doing a cheap incremental sync instead.
+ bool TryGetProgress(string key, out int processed, out int total);
}
// Thin read-only view over ImapService's process-wide static indexing state - that state is
@@ -16,4 +21,17 @@ public class BackgroundIndexingStatus : IBackgroundIndexingStatus
public int MaxConcurrentIndexing => ImapService.MaxConcurrentBackgroundIndexing;
public int CurrentlyAvailableSlots => ImapService.BackgroundIndexingThrottle.CurrentCount;
public IReadOnlyCollection<string> InProgressKeys => [.. ImapService.IndexingInProgress.Keys];
+
+ public bool TryGetProgress(string key, out int processed, out int total)
+ {
+ if (ImapService.IndexingProgress.TryGetValue(key, out var progress))
+ {
+ processed = progress.Processed;
+ total = progress.Total;
+ return true;
+ }
+ processed = 0;
+ total = 0;
+ return false;
+ }
}
diff --git a/MailSharp.MailClient/Views/Mail/Compose.cshtml b/MailSharp.MailClient/Views/Mail/Compose.cshtml
index ea376bc..3c8f8bb 100644
--- a/MailSharp.MailClient/Views/Mail/Compose.cshtml
+++ b/MailSharp.MailClient/Views/Mail/Compose.cshtml
@@ -13,6 +13,30 @@
<form id="composeForm">
<input type="hidden" name="AccountId" id="AccountId" />
+ <div class="compose-options">
+ <div>
+ <label data-i18n="compose_priority"></label>
+ <select name="Priority">
+ <option value="Low" data-i18n="compose_priority_low"></option>
+ <option value="Normal" selected data-i18n="compose_priority_normal"></option>
+ <option value="High" data-i18n="compose_priority_high"></option>
+ </select>
+ </div>
+ <div>
+ <label data-i18n="compose_sensitivity"></label>
+ <select name="Sensitivity">
+ <option value="Nothing" selected data-i18n="compose_sensitivity_nothing"></option>
+ <option value="Confidential" data-i18n="compose_sensitivity_confidential"></option>
+ <option value="Private" data-i18n="compose_sensitivity_private"></option>
+ <option value="Personal" data-i18n="compose_sensitivity_personal"></option>
+ </select>
+ </div>
+ </div>
+
+ <label class="checkbox-line">
+ <input type="checkbox" name="RequestReadReceipt" value="true" /> <span data-i18n="compose_request_read_receipt"></span>
+ </label>
+
<label data-i18n="compose_to"></label>
<input type="text" name="To" id="ToInput" required />
@@ -64,30 +88,6 @@
<div id="editor" contenteditable="true" spellcheck="true"></div>
<textarea id="HtmlBody" name="HtmlBody" style="display:none;"></textarea>
- <div class="compose-options">
- <div>
- <label data-i18n="compose_priority"></label>
- <select name="Priority">
- <option value="Low" data-i18n="compose_priority_low"></option>
- <option value="Normal" selected data-i18n="compose_priority_normal"></option>
- <option value="High" data-i18n="compose_priority_high"></option>
- </select>
- </div>
- <div>
- <label data-i18n="compose_sensitivity"></label>
- <select name="Sensitivity">
- <option value="Nothing" selected data-i18n="compose_sensitivity_nothing"></option>
- <option value="Confidential" data-i18n="compose_sensitivity_confidential"></option>
- <option value="Private" data-i18n="compose_sensitivity_private"></option>
- <option value="Personal" data-i18n="compose_sensitivity_personal"></option>
- </select>
- </div>
- </div>
-
- <label class="checkbox-line">
- <input type="checkbox" name="RequestReadReceipt" value="true" /> <span data-i18n="compose_request_read_receipt"></span>
- </label>
-
<label data-i18n="compose_attachments"></label>
<input type="file" id="attachmentsInput" name="files" multiple />
<div id="attachmentsList" class="attachments-list"></div>
diff --git a/MailSharp.MailClient/Views/Mail/ExternalLink.cshtml b/MailSharp.MailClient/Views/Mail/ExternalLink.cshtml
new file mode 100644
index 0000000..c70a812
--- /dev/null
+++ b/MailSharp.MailClient/Views/Mail/ExternalLink.cshtml
@@ -0,0 +1,34 @@
+@{
+ ViewData["Title"] = "Externe link";
+ var url = (string)ViewData["ExternalUrl"]!;
+ var host = (string)ViewData["ExternalHost"]!;
+}
+<div class="app">
+ <div class="app-body">
+ <div class="content">
+ <div class="settings-page">
+ <div class="settings-wrap">
+ <section class="settings-section">
+ <h2 data-i18n="external_link_title"></h2>
+ <p data-i18n="external_link_warning"></p>
+ <p style="word-break:break-all;"><strong>@host</strong><br /><em>@url</em></p>
+ <p data-i18n="external_link_confirm"></p>
+ <div class="form-actions">
+ <a class="btn btn-primary" href="@url" rel="noopener noreferrer" data-i18n="external_link_continue"></a>
+ <a class="btn" href="#" onclick="event.preventDefault(); window.close(); window.history.back();" data-i18n="external_link_cancel"></a>
+ </div>
+ </section>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
+@section Scripts {
+ <script>
+ window.MailSharp.apiFetch("api/language/state").then(function (langState) {
+ document.documentElement.lang = langState.current;
+ window.MailSharp.applyStrings(langState.strings);
+ document.title = langState.strings.external_link_page_title || document.title;
+ });
+ </script>
+}
diff --git a/MailSharp.MailClient/Views/Shared/_Layout.cshtml b/MailSharp.MailClient/Views/Shared/_Layout.cshtml
index 0b13e69..d053ac1 100644
--- a/MailSharp.MailClient/Views/Shared/_Layout.cshtml
+++ b/MailSharp.MailClient/Views/Shared/_Layout.cshtml
@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
+ <base href="@Url.Content("~/")" />
<title>MailSharp</title>
<link rel="stylesheet" href="~/css/site.css" />
</head>
@@ -11,6 +12,7 @@
<div id="navLoadingCancel">
<button type="button" id="cancelRequestBtn" data-i18n="common_cancel_request"></button>
</div>
+ <div id="indexingToast" class="toast" hidden></div>
<script src="~/js/common.js"></script>
@await RenderSectionAsync("Scripts", required: false)
</body>
diff --git a/MailSharp.MailClient/wwwroot/css/site.css b/MailSharp.MailClient/wwwroot/css/site.css
index 4fcbc98..792fbfd 100644
--- a/MailSharp.MailClient/wwwroot/css/site.css
+++ b/MailSharp.MailClient/wwwroot/css/site.css
@@ -46,6 +46,22 @@ body.nav-loading::after {
}
body.nav-loading #navLoadingCancel { display: block; }
+.toast {
+ position: fixed;
+ left: 50%;
+ bottom: 24px;
+ transform: translateX(-50%);
+ max-width: min(90vw, 480px);
+ background: var(--text);
+ color: #fff;
+ padding: 10px 16px;
+ border-radius: var(--radius);
+ box-shadow: var(--shadow);
+ font-size: 13px;
+ z-index: 9000;
+}
+.toast[hidden] { display: none; }
+
button, .btn {
font-family: inherit;
font-size: 14px;
diff --git a/MailSharp.MailClient/wwwroot/js/common.js b/MailSharp.MailClient/wwwroot/js/common.js
index a62f434..30aac65 100644
--- a/MailSharp.MailClient/wwwroot/js/common.js
+++ b/MailSharp.MailClient/wwwroot/js/common.js
@@ -134,7 +134,7 @@ window.MailSharp = (function ()
{
if (response.status === 401)
{
- window.location.href = "/Account/Login";
+ window.location.href = "Account/Login";
throw new Error("unauthorized");
}
return response.text().then(function (text)
@@ -243,17 +243,60 @@ window.MailSharp = (function ()
if (btn) btn.addEventListener("click", cancelActiveRequests);
});
+ var INDEXING_POLL_MS = 5000;
+ var indexingPollTimer = 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
+ // shown as a bare key rather than a misleading "0 / 0".
+ function formatProgressEntry(entry)
+ {
+ if (!entry.total) return entry.key;
+ var percent = Math.round((entry.processed / entry.total) * 100);
+ 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()
+ {
+ apiFetch("api/mail/indexing-status").then(function (indexing)
+ {
+ var toast = document.getElementById("indexingToast");
+ if (!toast) return;
+
+ 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)
+ {
+ 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 */ });
+ }
+
// Shared bootstrap for every page's topbar: auth check + redirect, account email text,
// language select + logout wiring. Elements are optional - pages without a #langSelect or
// #logoutBtn (there are none currently, but future page shells might trim the header) just
// skip that part instead of throwing.
function initHeader()
{
- return apiFetch("/api/auth/state").then(function (authState)
+ return apiFetch("api/auth/state").then(function (authState)
{
if (!authState.isLoggedIn)
{
- window.location.href = "/Account/Login";
+ window.location.href = "Account/Login";
throw new Error("redirecting to login");
}
@@ -264,7 +307,7 @@ window.MailSharp = (function ()
var maintenanceLink = document.getElementById("maintenanceLink");
if (maintenanceLink && authState.account.isAdmin) maintenanceLink.style.display = "";
- return apiFetch("/api/language/state").then(function (langState)
+ return apiFetch("api/language/state").then(function (langState)
{
document.documentElement.lang = langState.current;
applyStrings(langState.strings);
@@ -279,7 +322,7 @@ window.MailSharp = (function ()
}).join("");
langSel.addEventListener("change", function (e)
{
- apiFetch("/api/language/set", { method: "POST", body: { lang: e.target.value } })
+ apiFetch("api/language/set", { method: "POST", body: { lang: e.target.value } })
.then(function () { window.location.reload(); });
});
}
@@ -290,13 +333,16 @@ window.MailSharp = (function ()
logoutBtn.addEventListener("click", function (e)
{
e.preventDefault();
- apiFetch("/api/auth/logout", { method: "POST" }).then(function ()
+ clearTimeout(indexingPollTimer);
+ apiFetch("api/auth/logout", { method: "POST" }).then(function ()
{
- window.location.href = "/Account/Login";
+ window.location.href = "Account/Login";
});
});
}
+ pollIndexingStatus();
+
return { authState: authState, langState: langState };
});
});
diff --git a/MailSharp.MailClient/wwwroot/js/compose.js b/MailSharp.MailClient/wwwroot/js/compose.js
index a70c2ac..c943556 100644
--- a/MailSharp.MailClient/wwwroot/js/compose.js
+++ b/MailSharp.MailClient/wwwroot/js/compose.js
@@ -10,16 +10,16 @@
// of embedding the image twice.
var inlineImages = [];
- $.apiFetch("/api/auth/state").then(function (authState)
+ $.apiFetch("api/auth/state").then(function (authState)
{
if (!authState.isLoggedIn)
{
- window.location.href = "/Account/Login";
+ window.location.href = "Account/Login";
return;
}
document.getElementById("AccountId").value = authState.account.id;
- return $.apiFetch("/api/language/state").then(function (langState)
+ return $.apiFetch("api/language/state").then(function (langState)
{
$.applyStrings(langState.strings);
document.documentElement.lang = langState.current;
@@ -169,7 +169,7 @@
{
window.alert("Verzonden, maar niet opgeslagen in Verzonden items: " + result.sentError);
}
- window.location.href = "/";
+ window.location.href = "./";
})
.catch(function (err) { if (!err.cancelled) window.alert(err.message); });
}
@@ -177,11 +177,11 @@
document.getElementById("composeForm").addEventListener("submit", function (e)
{
e.preventDefault();
- submit("/api/mail/send");
+ submit("api/mail/send");
});
document.getElementById("saveDraftBtn").addEventListener("click", function ()
{
- submit("/api/mail/save-draft");
+ submit("api/mail/save-draft");
});
})();
diff --git a/MailSharp.MailClient/wwwroot/js/contacts.js b/MailSharp.MailClient/wwwroot/js/contacts.js
index 873a6ba..8f731b0 100644
--- a/MailSharp.MailClient/wwwroot/js/contacts.js
+++ b/MailSharp.MailClient/wwwroot/js/contacts.js
@@ -29,7 +29,7 @@
{
mailsPageSize = result.authState.account.contactsPerPage;
}
- return $.apiFetch("/api/contacts");
+ return $.apiFetch("api/contacts");
}).then(function (contacts)
{
state.contacts = contacts;
@@ -225,7 +225,7 @@
}
var bodyHtml = (m.htmlBody && m.htmlBody.trim() !== "")
- ? '<div class="body-html"><iframe srcdoc="' + $.esc(m.htmlBody) + '" sandbox=""></iframe></div>'
+ ? '<div class="body-html"><iframe srcdoc="' + $.esc(m.htmlBody) + '" sandbox="allow-popups allow-popups-to-escape-sandbox"></iframe></div>'
: '<div class="body-html"><pre style="white-space:pre-wrap;font-family:inherit;">' + $.esc(m.textBody) + "</pre></div>";
pane.innerHTML =
@@ -250,7 +250,7 @@
{
opts = opts || {};
var params = new URLSearchParams({ email: email, refresh: !!opts.refresh });
- return $.apiFetch("/api/contacts/mails?" + params.toString()).then(function (mails)
+ return $.apiFetch("api/contacts/mails?" + params.toString()).then(function (mails)
{
state.mails = mails;
state.mailsPage = 1;
@@ -265,7 +265,7 @@
updateSelectedMailRowClasses();
var params = new URLSearchParams({ folder: folder });
- $.apiFetch("/api/mail/messages/" + uid + "?" + params.toString()).then(function (detail)
+ $.apiFetch("api/mail/messages/" + uid + "?" + params.toString()).then(function (detail)
{
state.selectedMessage = detail;
renderMailDetail();
@@ -334,7 +334,7 @@
{
e.preventDefault();
sessionStorage.setItem("mailsharp_compose_prefill", JSON.stringify({ to: state.selectedEmail }));
- window.location.href = "/Mail/Compose";
+ window.location.href = "Mail/Compose";
return;
}
if (e.target.id === "editContactBtn")
@@ -355,7 +355,7 @@
{
e.preventDefault();
var newName = document.getElementById("editDisplayName").value.trim();
- $.apiFetch("/api/contacts/update", { method: "POST", body: { email: state.selectedEmail, displayName: newName } })
+ $.apiFetch("api/contacts/update", { method: "POST", body: { email: state.selectedEmail, displayName: newName } })
.then(function ()
{
var contact = selectedContact();
@@ -397,7 +397,7 @@
{
e.preventDefault();
var sender = state.selectedMessage.senderEmail;
- $.apiFetch("/api/mail/allow-images", { method: "POST", body: { sender: sender } }).then(function ()
+ $.apiFetch("api/mail/allow-images", { method: "POST", body: { sender: sender } }).then(function ()
{
return openMailRefresh();
}).catch(reportError);
@@ -412,7 +412,7 @@
{
var mail = state.selectedMail;
var params = new URLSearchParams({ folder: mail.folder });
- return $.apiFetch("/api/mail/messages/" + mail.uid + "?" + params.toString()).then(function (detail)
+ return $.apiFetch("api/mail/messages/" + mail.uid + "?" + params.toString()).then(function (detail)
{
state.selectedMessage = detail;
renderMailDetail();
@@ -427,7 +427,7 @@
var mode = btn.getAttribute("data-attachment");
var params = new URLSearchParams({ folder: mail.folder });
- fetch("/api/mail/attachments/" + mail.uid + "/" + part + "?" + params.toString(), { credentials: "same-origin" })
+ fetch("api/mail/attachments/" + mail.uid + "/" + part + "?" + params.toString(), { credentials: "same-origin" })
.then(function (response)
{
if (!response.ok) throw new Error("HTTP " + response.status);
diff --git a/MailSharp.MailClient/wwwroot/js/login.js b/MailSharp.MailClient/wwwroot/js/login.js
index ba6ddfb..cb55995 100644
--- a/MailSharp.MailClient/wwwroot/js/login.js
+++ b/MailSharp.MailClient/wwwroot/js/login.js
@@ -3,17 +3,17 @@
"use strict";
var $ = window.MailSharp;
- $.apiFetch("/api/auth/state").then(function (authState)
+ $.apiFetch("api/auth/state").then(function (authState)
{
if (authState.isLoggedIn)
{
- window.location.href = "/";
+ window.location.href = "./";
return;
}
return Promise.all([
- $.apiFetch("/api/language/state"),
- $.apiFetch("/api/auth/login-defaults")
+ $.apiFetch("api/language/state"),
+ $.apiFetch("api/auth/login-defaults")
]).then(function (results)
{
var langState = results[0];
@@ -60,8 +60,8 @@
var errorBox = document.getElementById("loginError");
errorBox.style.display = "none";
- $.apiFetch("/api/auth/login", { method: "POST", body: payload })
- .then(function () { window.location.href = "/"; })
+ $.apiFetch("api/auth/login", { method: "POST", body: payload })
+ .then(function () { window.location.href = "./"; })
.catch(function (err)
{
errorBox.textContent = err.message;
diff --git a/MailSharp.MailClient/wwwroot/js/mail.js b/MailSharp.MailClient/wwwroot/js/mail.js
index 0c81ac4..b19202b 100644
--- a/MailSharp.MailClient/wwwroot/js/mail.js
+++ b/MailSharp.MailClient/wwwroot/js/mail.js
@@ -101,7 +101,7 @@
function loadFolders()
{
- return $.apiFetch("/api/mail/folders").then(function (folders) { state.folders = folders; });
+ return $.apiFetch("api/mail/folders").then(function (folders) { state.folders = folders; });
}
function loadMessages(opts)
@@ -114,7 +114,7 @@
page: state.page,
refresh: !!opts.refresh
});
- return $.apiFetch("/api/mail/messages?" + params.toString()).then(function (result)
+ return $.apiFetch("api/mail/messages?" + params.toString()).then(function (result)
{
state.messages = result.messages;
state.page = result.page;
@@ -127,7 +127,7 @@
function loadMessage(uid)
{
var params = new URLSearchParams({ folder: state.currentFolder });
- return $.apiFetch("/api/mail/messages/" + uid + "?" + params.toString()).then(function (detail)
+ return $.apiFetch("api/mail/messages/" + uid + "?" + params.toString()).then(function (detail)
{
state.selectedMessage = detail;
});
@@ -243,7 +243,7 @@
}
var bodyHtml = (m.htmlBody && m.htmlBody.trim() !== "")
- ? '<div class="body-html"><iframe srcdoc="' + $.esc(m.htmlBody) + '" sandbox=""></iframe></div>'
+ ? '<div class="body-html"><iframe srcdoc="' + $.esc(m.htmlBody) + '" sandbox="allow-popups allow-popups-to-escape-sandbox"></iframe></div>'
: '<div class="body-html"><pre style="white-space:pre-wrap;font-family:inherit;">' + $.esc(m.textBody) + "</pre></div>";
pane.innerHTML =
@@ -356,7 +356,7 @@
if (action === "markAllRead" || action === "markAllUnread")
{
- $.apiFetch("/api/mail/" + (action === "markAllRead" ? "mark-all-read" : "mark-all-unread"), {
+ $.apiFetch("api/mail/" + (action === "markAllRead" ? "mark-all-read" : "mark-all-unread"), {
method: "POST", body: { folder: state.currentFolder }
}).then(refreshAfterAction).catch(reportError);
return;
@@ -366,7 +366,7 @@
if (uids.length === 0) return;
var endpoints = { markRead: "mark-read", markUnread: "mark-unread", flag: "flag", unflag: "unflag" };
- $.apiFetch("/api/mail/" + endpoints[action], {
+ $.apiFetch("api/mail/" + endpoints[action], {
method: "POST", body: { folder: state.currentFolder, uids: uids }
}).then(refreshAfterAction).catch(reportError);
});
@@ -379,7 +379,7 @@
var uids = getSelectedUids();
if (uids.length === 0) return;
- $.apiFetch("/api/mail/move", {
+ $.apiFetch("api/mail/move", {
method: "POST", body: { folder: state.currentFolder, uids: uids, targetFolder: target }
}).then(function ()
{
@@ -403,13 +403,13 @@
return f.fullName.toLowerCase() === folderName.toLowerCase() || f.displayName.toLowerCase() === folderName.toLowerCase();
});
if (!folder) return;
- $.apiFetch("/api/mail/empty-folder", { method: "POST", body: { folder: folder.fullName } }).then(refreshAfterAction).catch(reportError);
+ $.apiFetch("api/mail/empty-folder", { method: "POST", body: { folder: folder.fullName } }).then(refreshAfterAction).catch(reportError);
return;
}
var uids = getSelectedUids();
if (uids.length === 0) return;
- $.apiFetch("/api/mail/delete", { method: "POST", body: { folder: state.currentFolder, uids: uids } }).then(function ()
+ $.apiFetch("api/mail/delete", { method: "POST", body: { folder: state.currentFolder, uids: uids } }).then(function ()
{
state.selectedUid = null;
state.selectedMessage = null;
@@ -427,7 +427,7 @@
return f.fullName.toLowerCase() === "spam" || f.displayName.toLowerCase() === "spam" || f.fullName.toLowerCase() === "junk" || f.displayName.toLowerCase() === "junk";
});
if (!spamFolder) return;
- $.apiFetch("/api/mail/move", {
+ $.apiFetch("api/mail/move", {
method: "POST", body: { folder: state.currentFolder, uids: uids, targetFolder: spamFolder.fullName }
}).then(refreshAfterAction).catch(reportError);
});
@@ -510,7 +510,7 @@
item.isFlagged = newValue;
el.classList.toggle("flagged", newValue);
- $.apiFetch("/api/mail/" + (newValue ? "flag" : "unflag"), {
+ $.apiFetch("api/mail/" + (newValue ? "flag" : "unflag"), {
method: "POST", body: { folder: state.currentFolder, uids: [uid] }
}).catch(function (err)
{
@@ -554,7 +554,7 @@
prefill.subject = /^fwd?:/i.test(m.subject) ? m.subject : "Fwd: " + m.subject;
}
sessionStorage.setItem("mailsharp_compose_prefill", JSON.stringify(prefill));
- window.location.href = "/Mail/Compose";
+ window.location.href = "Mail/Compose";
}
document.getElementById("detailPane").addEventListener("change", function (e)
@@ -583,7 +583,7 @@
}
if (e.target.id === "allowImagesBtn")
{
- $.apiFetch("/api/mail/allow-images", {
+ $.apiFetch("api/mail/allow-images", {
method: "POST",
body: { folder: state.currentFolder, uid: state.selectedUid, sender: state.selectedMessage.senderEmail }
}).then(function ()
@@ -608,7 +608,7 @@
var mode = btn.getAttribute("data-attachment");
var part = btn.getAttribute("data-part");
var fileName = btn.getAttribute("data-filename");
- var url = "/api/mail/attachments/" + state.selectedUid + "/" + part + "?folder=" + encodeURIComponent(state.currentFolder);
+ var url = "api/mail/attachments/" + state.selectedUid + "/" + part + "?folder=" + encodeURIComponent(state.currentFolder);
fetch(url, { headers: { "X-Requested-With": "fetch" }, credentials: "same-origin" })
.then(function (response)
diff --git a/MailSharp.MailClient/wwwroot/js/maintenance.js b/MailSharp.MailClient/wwwroot/js/maintenance.js
index aa24c81..c4c63e7 100644
--- a/MailSharp.MailClient/wwwroot/js/maintenance.js
+++ b/MailSharp.MailClient/wwwroot/js/maintenance.js
@@ -36,7 +36,7 @@
function loadStatus()
{
state.statusLoaded = true;
- $.apiFetch("/api/maintenance/status").then(function (s)
+ $.apiFetch("api/maintenance/status").then(function (s)
{
renderIndexingStatus(s.indexing);
renderIndexTotals(s.index);
@@ -50,7 +50,12 @@
var text = "Achtergrondindexering: " + (indexing.maxConcurrentIndexing - indexing.currentlyAvailableSlots) + " / " + indexing.maxConcurrentIndexing + " actief.";
if (indexing.inProgress.length > 0)
{
- text += " Bezig met: " + indexing.inProgress.join(", ");
+ text += " Bezig met: " + indexing.inProgress.map(function (entry)
+ {
+ if (!entry.total) return entry.key;
+ var percent = Math.round((entry.processed / entry.total) * 100);
+ return entry.key + " (" + entry.processed + " / " + entry.total + ", " + percent + "%)";
+ }).join(", ");
}
else
{
@@ -107,7 +112,7 @@
{
state.metricsLoaded = true;
var accountId = document.getElementById("metricsAccountFilter").value;
- var url = "/api/maintenance/metrics" + (accountId ? "?accountId=" + encodeURIComponent(accountId) : "");
+ var url = "api/maintenance/metrics" + (accountId ? "?accountId=" + encodeURIComponent(accountId) : "");
$.apiFetch(url).then(function (m)
{
@@ -155,7 +160,7 @@
function loadLogs()
{
state.logsLoaded = true;
- $.apiFetch("/api/maintenance/logs?" + buildLogsQuery()).then(function (result)
+ $.apiFetch("api/maintenance/logs?" + buildLogsQuery()).then(function (result)
{
state.totalCount = result.totalCount;
var rows = result.items.map(function (e)
diff --git a/MailSharp.MailClient/wwwroot/js/settings.js b/MailSharp.MailClient/wwwroot/js/settings.js
index 7c9ec5d..1e25a64 100644
--- a/MailSharp.MailClient/wwwroot/js/settings.js
+++ b/MailSharp.MailClient/wwwroot/js/settings.js
@@ -46,7 +46,7 @@
state.strings = result.langState.strings;
document.getElementById("settingsAccountInfo").textContent = result.authState.account.emailAddress;
- return $.apiFetch("/api/settings");
+ return $.apiFetch("api/settings");
}).then(renderSettingsForm).catch(reportError);
}
@@ -84,7 +84,7 @@
language: document.getElementById("settingsLanguage").value
};
- $.apiFetch("/api/settings", { method: "POST", body: body }).then(function ()
+ $.apiFetch("api/settings", { method: "POST", body: body }).then(function ()
{
window.alert(state.strings.settings_saved);
window.location.reload();
@@ -114,7 +114,7 @@
function loadFolders(opts)
{
var refresh = !!(opts && opts.refresh);
- return $.apiFetch("/api/settings/folders?refresh=" + refresh).then(function (folders)
+ return $.apiFetch("api/settings/folders?refresh=" + refresh).then(function (folders)
{
state.folders = folders;
state.foldersLoaded = true;
@@ -255,7 +255,7 @@
});
});
- $.apiFetch("/api/settings/folders", { method: "POST", body: { folders: folders } }).then(function ()
+ $.apiFetch("api/settings/folders", { method: "POST", body: { folders: folders } }).then(function ()
{
window.alert(state.strings.settings_saved);
}).catch(reportError);
@@ -267,7 +267,7 @@
var name = window.prompt(state.strings.settings_new_folder_prompt);
if (!name) return;
- $.apiFetch("/api/settings/folders/add", { method: "POST", body: { name: name } })
+ $.apiFetch("api/settings/folders/add", { method: "POST", body: { name: name } })
.then(loadFolders)
.catch(reportError);
});
@@ -282,7 +282,7 @@
});
if (names.length === 0) return;
- $.apiFetch("/api/settings/folders/delete", { method: "POST", body: { folderNames: names } })
+ $.apiFetch("api/settings/folders/delete", { method: "POST", body: { folderNames: names } })
.then(function (result)
{
if (result.failed && result.failed.length > 0)