window.MailSharp = (function () { "use strict"; var LOADING_DELAY_MS = 500; var loadingTimer = null; var dragging = null; var activeControllers = []; var currentStrings = {}; var currentTimeZone = null; function esc(s) { return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) { return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; }); } function formatTemplate(template) { var args = Array.prototype.slice.call(arguments, 1); var result = template || ""; args.forEach(function (a, i) { result = result.replace("{" + i + "}", a); }); return result; } function formatDate(iso) { var d = new Date(iso); try { var parts = new Intl.DateTimeFormat("nl", { timeZone: currentTimeZone || undefined, day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit", hour12: false }).formatToParts(d).reduce(function (acc, p) { acc[p.type] = p.value; return acc; }, {}); return parts.day + "-" + parts.month + "-" + parts.year + " " + parts.hour + ":" + parts.minute; } catch (e) { var pad = function (n) { return n < 10 ? "0" + n : "" + n; }; return pad(d.getDate()) + "-" + pad(d.getMonth() + 1) + "-" + d.getFullYear() + " " + pad(d.getHours()) + ":" + pad(d.getMinutes()); } } function formatFullDate(iso, lang) { var d = new Date(iso); try { return new Intl.DateTimeFormat(lang || "nl", { timeZone: currentTimeZone || undefined, weekday: "long", day: "2-digit", month: "long", year: "numeric", hour: "2-digit", minute: "2-digit", hour12: false }).format(d); } catch (e) { return d.toString(); } } function showLoadingSoon() { clearTimeout(loadingTimer); loadingTimer = setTimeout(function () { document.body.classList.add("nav-loading"); }, LOADING_DELAY_MS); } function hideLoading() { clearTimeout(loadingTimer); loadingTimer = null; document.body.classList.remove("nav-loading"); } // ASP.NET's [ApiController] returns a ValidationProblemDetails body ({ errors: { Field: [...] } }) // for model-binding/validation failures, and our own controllers return { error: "..." } - fall // back to the ProblemDetails "title" or a bare status code if neither shape matches. function extractErrorMessage(data, status) { if (data && data.error) return data.error; if (data && data.errors) { var messages = []; Object.keys(data.errors).forEach(function (field) { (data.errors[field] || []).forEach(function (m) { messages.push(m); }); }); if (messages.length > 0) return messages.join(" "); } if (data && data.title) return data.title; return "HTTP " + status; } // Cancels every fetch currently in flight (started via apiFetch below) - wired to the // "Cancel" button that appears once a request has been slow enough to show the loading // overlay (see showLoadingSoon). The request reaches the server as a genuinely aborted HTTP // connection, which ASP.NET Core surfaces as a cancelled RequestAborted token; controller // actions that accept a CancellationToken and thread it into the IMAP calls (see // MailApiController/ContactsApiController) will actually stop the in-progress IMAP command // instead of just abandoning the response. function cancelActiveRequests() { activeControllers.slice().forEach(function (c) { c.abort(); }); } // Every page's data comes from these JSON endpoints via fetch - no data is ever // embedded server-side into the HTML itself. function apiFetch(url, options) { options = options || {}; var headers = Object.assign({ "X-Requested-With": "fetch" }, options.headers || {}); var fetchOpts = { method: options.method || "GET", headers: headers, credentials: "same-origin" }; if (options.body !== undefined) { if (options.body instanceof FormData) { fetchOpts.body = options.body; } else { headers["Content-Type"] = "application/json"; fetchOpts.body = JSON.stringify(options.body); } } var controller = new AbortController(); fetchOpts.signal = controller.signal; activeControllers.push(controller); showLoadingSoon(); return fetch(url, fetchOpts) .then(function (response) { if (response.status === 401) { window.location.href = "Account/Login"; throw new Error("unauthorized"); } return response.text().then(function (text) { var data = text ? JSON.parse(text) : null; if (!response.ok) { throw new Error(extractErrorMessage(data, response.status)); } return data; }); }) .catch(function (err) { if (err.name === "AbortError") { // Deliberate, user-initiated cancellation (see cancelActiveRequests) - not a // real failure, so callers shouldn't alert the user about it. Flagged rather // than silently swallowed here so callers can still skip their own follow-up // work (e.g. not re-rendering with stale state) if they check err.cancelled. var cancelledErr = new Error(currentStrings.common_request_cancelled || "Cancelled."); cancelledErr.cancelled = true; throw cancelledErr; } throw err; }) .finally(function () { var idx = activeControllers.indexOf(controller); if (idx !== -1) activeControllers.splice(idx, 1); hideLoading(); }); } // Fills every element marked data-i18n="key" with the matching localized string. function applyStrings(strings) { currentStrings = strings || {}; document.querySelectorAll("[data-i18n]").forEach(function (el) { var key = el.getAttribute("data-i18n"); if (strings[key] != null) el.textContent = strings[key]; }); document.querySelectorAll("[data-i18n-placeholder]").forEach(function (el) { var key = el.getAttribute("data-i18n-placeholder"); if (strings[key] != null) el.placeholder = strings[key]; }); } function applySavedColumnWidths() { document.querySelectorAll(".col-resizer").forEach(function (handle) { var key = "colwidth_" + handle.getAttribute("data-resize-key"); var target = document.querySelector(handle.getAttribute("data-resize-target")); if (!target) return; var saved = localStorage.getItem(key); if (saved) target.style.width = saved + "px"; }); } document.addEventListener("mousedown", function (e) { var handle = e.target.closest(".col-resizer"); if (!handle) return; var target = document.querySelector(handle.getAttribute("data-resize-target")); if (!target) return; dragging = { handle: handle, target: target, key: "colwidth_" + handle.getAttribute("data-resize-key"), min: parseInt(handle.getAttribute("data-min"), 10) || 150, max: parseInt(handle.getAttribute("data-max"), 10) || 800, startX: e.clientX, startWidth: target.getBoundingClientRect().width }; handle.classList.add("resizing"); document.body.classList.add("col-resizing"); e.preventDefault(); }); document.addEventListener("mousemove", function (e) { if (!dragging) return; var newWidth = dragging.startWidth + (e.clientX - dragging.startX); newWidth = Math.max(dragging.min, Math.min(dragging.max, newWidth)); dragging.target.style.width = newWidth + "px"; }); document.addEventListener("mouseup", function () { if (!dragging) return; dragging.handle.classList.remove("resizing"); document.body.classList.remove("col-resizing"); localStorage.setItem(dragging.key, Math.round(dragging.target.getBoundingClientRect().width)); dragging = null; }); document.addEventListener("DOMContentLoaded", applySavedColumnWidths); document.addEventListener("DOMContentLoaded", function () { var btn = document.getElementById("cancelRequestBtn"); if (btn) btn.addEventListener("click", cancelActiveRequests); }); // Mobile "⋮" toggle for the topbar's collapsed link panel (see _Topbar.cshtml/site.css) - shared // across every page's topbar, not just Mail's, since Settings/Maintenance have this same button // now too. Closes on an outside click or Escape, same as any other dropdown/menu. document.addEventListener("DOMContentLoaded", function () { var toggle = document.getElementById("topbarMenuToggle"); var links = document.getElementById("topbarLinks"); if (!toggle || !links) return; function closeMenu() { links.classList.remove("open"); toggle.setAttribute("aria-expanded", "false"); } toggle.addEventListener("click", function (e) { e.stopPropagation(); var willOpen = !links.classList.contains("open"); links.classList.toggle("open", willOpen); toggle.setAttribute("aria-expanded", willOpen ? "true" : "false"); }); document.addEventListener("click", function (e) { if (links.classList.contains("open") && !links.contains(e.target) && e.target !== toggle) closeMenu(); }); document.addEventListener("keydown", function (e) { if (e.key === "Escape") closeMenu(); }); }); var indexingEventSource = null; // entry.total is only known once a folder's full reindex has started scanning (see // ImapService.IndexingProgress) - a key that's still connecting/searching has total 0, so it's // 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 + "%)"; } function renderIndexingStatus(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; } } function stopIndexingStatusStream() { if (indexingEventSource) { indexingEventSource.close(); indexingEventSource = null; } } // Server-pushed replacement for the old 5s setInterval poll (see MailApiController. // IndexingStatusStream) - runs over its own connection outside apiFetch, so a background status // update can no longer call hideLoading() and dismiss the Cancel button/loading overlay for an // unrelated, still-in-flight foreground request. On a hard failure (e.g. the session expired - // 401 from RequireAuthorization()) EventSource fails the connection without retrying per spec, // so this just tears it down rather than nagging with reconnect attempts. // // One shared connection carries more than the indexing toast - see the "flags" listener below - // so pages other than the indexing toast's owner (mail.js) can react to their own named event // without common.js needing to know what they do with it; it just re-dispatches as a plain DOM // event so any page can listen without coupling to this module's internals. function startIndexingStatusStream() { stopIndexingStatusStream(); indexingEventSource = new EventSource("api/mail/indexing-status/stream"); indexingEventSource.addEventListener("indexing", function (e) { try { renderIndexingStatus(JSON.parse(e.data)); } catch (err) { /* ignore malformed frame */ } }); indexingEventSource.addEventListener("flags", function (e) { try { document.dispatchEvent(new CustomEvent("mailsharp:flags-refreshed", { detail: JSON.parse(e.data) })); } catch (err) { /* ignore malformed frame */ } }); indexingEventSource.onerror = function () { stopIndexingStatusStream(); }; } // Shared bootstrap for every page's topbar: auth check + redirect, account email text, // 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) { if (!authState.isLoggedIn) { window.location.href = "Account/Login"; throw new Error("redirecting to login"); } var emailEl = document.getElementById("accountEmail"); if (emailEl) emailEl.textContent = authState.account.emailAddress; currentTimeZone = authState.account.timeZoneId || null; var maintenanceLink = document.getElementById("maintenanceLink"); if (maintenanceLink && authState.account.isAdmin) maintenanceLink.style.display = ""; return apiFetch("api/language/state").then(function (langState) { document.documentElement.lang = langState.current; applyStrings(langState.strings); var langSel = document.getElementById("langSelect"); if (langSel) { langSel.title = langState.strings.topbar_language || ""; langSel.innerHTML = langState.languages.map(function (l) { return '"; }).join(""); langSel.addEventListener("change", function (e) { apiFetch("api/language/set", { method: "POST", body: { lang: e.target.value } }) .then(function () { window.location.reload(); }); }); } var logoutBtn = document.getElementById("logoutBtn"); if (logoutBtn) { logoutBtn.addEventListener("click", function (e) { e.preventDefault(); stopIndexingStatusStream(); apiFetch("api/auth/logout", { method: "POST" }).then(function () { window.location.href = "Account/Login"; }); }); } startIndexingStatusStream(); return { authState: authState, langState: langState }; }); }); } return { esc: esc, formatTemplate: formatTemplate, formatDate: formatDate, formatFullDate: formatFullDate, apiFetch: apiFetch, applyStrings: applyStrings, applySavedColumnWidths: applySavedColumnWidths, initHeader: initHeader }; })();