(function () { "use strict"; var $ = window.MailSharp; var state = { lang: "nl", strings: {}, account: null, accounts: [], languages: [], folders: [], currentFolder: "INBOX", sort: "date", desc: true, unreadOnly: false, page: 1, pageSize: 20, totalCount: 0, totalPages: 1, messages: [], selectedUid: null, 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; var pendingRaw = sessionStorage.getItem("mailsharp_open_message"); if (pendingRaw) { sessionStorage.removeItem("mailsharp_open_message"); try { pendingOpen = JSON.parse(pendingRaw); } catch (e) { pendingOpen = null; } } if (pendingOpen && pendingOpen.folder) state.currentFolder = pendingOpen.folder; $.initHeader().then(function (result) { state.account = result.authState.account; state.accounts = result.authState.accounts; state.lang = result.langState.current; state.languages = result.langState.languages; state.strings = result.langState.strings; updateSortLinksActive(); // Rendered independently rather than via Promise.all: folders and messages are two // unrelated IMAP round-trips, and a slow/failing messages fetch (e.g. a large NotSynchronized // folder, or a transient server hiccup) shouldn't leave the folder sidebar blank while it's // pending - the user should at least be able to see and navigate the folder list. var foldersReady = loadFolders().then(renderFolderList); var messagesReady = loadMessages().then(function () { renderMessageList(); renderDetailPane(); if (pendingOpen && pendingOpen.uid != null) openMessage(pendingOpen.uid); }); return Promise.all([foldersReady, messagesReady]); }).then(function () { $.applySavedColumnWidths(); setupAutoCheck(); }); } // Silently re-polls the current folder at the account's configured interval (Settings > // Autocheck interval) - a plain setInterval rather than something restartable, since the // interval only changes via the Settings page, which does a full page reload after saving. function setupAutoCheck() { var minutes = state.account && state.account.autoCheckIntervalMinutes; if (!minutes || minutes <= 0) return; setInterval(function () { refreshAfterAction().catch(function () { }); }, minutes * 60 * 1000); } // Pushed by the server (see common.js's SSE stream / MailApiController.IndexingStatusStream) // whenever the background recent-messages flag recheck finds a read/flag change made from // another client (a phone, another tab) - only re-renders when it's the folder currently open, // and the refresh itself is cheap (a local index read, not an IMAP round trip). document.addEventListener("mailsharp:flags-refreshed", function (e) { if (!e.detail || e.detail.folder !== state.currentFolder || e.detail.changedCount <= 0) return; refreshAfterAction().catch(function () { }); }); function renderMoveToFolderOptions() { var sel = document.getElementById("moveToFolder"); var placeholder = sel.options[0]; sel.innerHTML = ""; sel.appendChild(placeholder); state.folders.forEach(function (f) { if (!f.isSelectable || f.fullName === state.currentFolder) return; var opt = document.createElement("option"); opt.value = f.fullName; opt.textContent = f.displayName; sel.appendChild(opt); }); } function getSelectedUids() { var uids = []; document.querySelectorAll(".message-row input[type=checkbox]:checked").forEach(function (cb) { uids.push(parseInt(cb.closest(".message-row").getAttribute("data-uid"), 10)); }); // Only fall back to the currently open message when nothing is explicitly ticked - opening // a message counts as an implicit selection (without visibly checking its checkbox), but // that implicit selection steps aside the moment the user starts explicitly picking // checkboxes, so a bulk action then only applies to what's actually ticked. if (uids.length === 0 && state.selectedUid != null) uids.push(state.selectedUid); return uids; } // ---------- data loading ---------- function loadFolders() { return $.apiFetch("api/mail/folders").then(function (folders) { state.folders = folders; }); } function loadMessages(opts) { opts = opts || {}; var params = new URLSearchParams({ folder: state.currentFolder, sort: state.sort, desc: state.desc, page: state.page, refresh: !!opts.refresh, unreadOnly: state.unreadOnly }); return $.apiFetch("api/mail/messages?" + params.toString()).then(function (result) { state.messages = result.messages; state.page = result.page; state.pageSize = result.pageSize; state.totalCount = result.totalCount; state.totalPages = result.totalPages; }); } function loadMessage(uid) { var params = new URLSearchParams({ folder: state.currentFolder }); return $.apiFetch("api/mail/messages/" + uid + "?" + params.toString()).then(function (detail) { state.selectedMessage = detail; }); } // ---------- rendering ---------- function renderFolderList() { document.getElementById("folderList").innerHTML = state.folders.map(function (f) { var active = f.fullName === state.currentFolder; // IMAP folders can be nested (server-defined hierarchy delimiter, e.g. "INBOX.Spam" or // "Werk/Projecten") - GetFoldersAsync already computes this via FolderDepth, just wasn't // used here before. Indent per level so a subfolder reads as belonging under its parent // instead of as another unrelated top-level entry. var indentStyle = f.depth > 0 ? ' style="padding-left:' + (10 + f.depth * 16) + 'px"' : ""; // 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 ( '
  • ' + "" + $.esc(f.displayName) + "" + '' + (active ? '' : "") + (f.unreadCount > 0 ? '' + f.unreadCount + "" : "") + "" + "
  • " ); }).join(""); renderMoveToFolderOptions(); } function renderMessageList() { var t = state.strings; var html; if (state.messages.length === 0) { html = '
    ' + $.esc(t.mail_empty) + "
    "; } else { html = '" + '
    ' + '' + $.esc(t.mail_previous) + "" + '' + $.esc($.formatTemplate(t.mail_pager_info, state.page, state.totalPages, state.totalCount)) + "" + '' + $.esc(t.mail_next) + "" + "
    "; } document.getElementById("messageListBody").innerHTML = html; // The rendered rows always start unchecked, but the select-all checkbox lives outside this // re-rendered container (see its own listener below) and would otherwise keep showing // "checked" across a page/folder/sort change even though none of the new rows are selected. var selectAll = document.querySelector("[data-select-all]"); if (selectAll) selectAll.checked = false; } function renderMessageRow(m) { var unread = !m.isRead ? "unread" : ""; var selected = m.uid === state.selectedUid ? "selected" : ""; var flagTitle = m.isFlagged ? state.strings.action_unflag : state.strings.action_flag; return ( '
  • ' + '' + '' + '
    ' + (m.priority === "High" ? '!' : "") + $.esc(m.subject) + (m.hasAttachments ? "📎" : "") + "
    " + $.esc(m.from) + "
    " + '
    ' + $.formatDate(m.date) + '
    ' + m.sizeKb.toFixed(1) + " kB
    " + "
  • " ); } function renderDetailPane() { var t = state.strings; var m = state.selectedMessage; var pane = document.getElementById("detailPane"); var content = document.getElementById("mailContent"); if (!m) { pane.innerHTML = '
    ' + $.esc(t.detail_empty) + "
    "; content.classList.remove("has-detail"); return; } content.classList.add("has-detail"); var sensitivityKey = { Confidential: "detail_sensitivity_confidential", Private: "detail_sensitivity_private", Personal: "detail_sensitivity_personal" }[m.sensitivity]; var sensitivityBanner = sensitivityKey ? '
    ' + $.esc(t[sensitivityKey] || m.sensitivity) + "
    " : ""; var imagesBanner = ""; if (m.hasExternalImages && !m.imagesAllowed) { // 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) ? '
    ' + $.esc(t.detail_images_blocked_spam) + "
    " : '
    ' + $.esc(t.detail_images_blocked) + " " + '" + "
    "; } var attachmentsHtml = ""; if (m.attachments && m.attachments.length > 0) { attachmentsHtml = '
    ' + m.attachments.map(function (att) { return ( '
    ' + "📎 " + $.esc(att.fileName) + " (" + (Math.round(att.sizeBytes / 1024 * 10) / 10) + " kB)" + '" + '" + "
    " ); }).join("") + "
    "; } var bodyHtml = (m.htmlBody && m.htmlBody.trim() !== "") ? '
    ' : '
    ' + $.esc(m.textBody) + "
    "; pane.innerHTML = '
    ' + '' + $.esc(t.detail_back_to_list) + "" + // 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. '
    ' + '
    ' + '
    ' + (m.priority === "High" ? '!' : "") + $.esc(m.subject) + "
    " + '
    ' + "
    " + $.esc(t.detail_from) + " " + $.esc(m.from) + "
    " + "
    " + $.esc(t.detail_to) + " " + $.esc(m.to) + "
    " + (m.cc ? "
    " + $.esc(t.detail_cc) + " " + $.esc(m.cc) + "
    " : "") + "
    " + $.esc(t.detail_date) + " " + $.esc($.formatFullDate(m.date, state.lang)) + "
    " + "
    " + "
    " + '
    ' + '" + '' + $.esc(t.detail_forward) + "" + "
    " + "
    " + sensitivityBanner + imagesBanner + attachmentsHtml + bodyHtml + "
    "; } function updateSortLinksActive() { document.querySelectorAll(".sort-links a").forEach(function (a) { a.classList.toggle("active", a.getAttribute("data-sort") === state.sort); }); } function updateSelectedRowClasses() { document.querySelectorAll(".message-row").forEach(function (row) { var uid = parseInt(row.getAttribute("data-uid"), 10); row.classList.toggle("selected", uid === state.selectedUid); if (uid === state.selectedUid) row.classList.remove("unread"); }); } // ---------- message selection (no-op if the same message is clicked again; // exactly one detail render once the data has actually loaded, to avoid flicker) ---------- function openMessage(uid) { if (uid === state.selectedUid) return; state.selectedUid = uid; updateSelectedRowClasses(); // loadMessage() marking the message Seen (server-side and in the local index - see // ImapService.GetMessageAsync) already returns the folder's updated unread count for free // (MessageDetail.folderUnreadCount, read straight from that same local index) - patching just // that one badge is enough, and avoids a full loadFolders() (a live IMAP STATUS on every // folder) on every single message opened just to refresh one number. loadMessage(uid).then(function () { var item = state.messages.find(function (x) { return x.uid === uid; }); if (item) item.isRead = true; var count = state.selectedMessage.folderUnreadCount; if (count != null) { var folder = state.folders.find(function (f) { return f.fullName === state.currentFolder; }); if (folder) folder.unreadCount = count; } renderDetailPane(); updateSelectedRowClasses(); renderFolderList(); }); } function closeDetail() { if (state.selectedUid == null) return; state.selectedUid = null; state.selectedMessage = null; renderDetailPane(); updateSelectedRowClasses(); } // ---------- events ---------- document.getElementById("sidebarToggle").addEventListener("click", function () { document.getElementById("sidebar").classList.toggle("open"); }); function refreshAfterAction() { return Promise.all([loadFolders(), loadMessages({ refresh: true })]).then(function () { renderFolderList(); renderMessageList(); }); } function reportError(err) { if (err.cancelled) return; window.alert(err.message); } document.getElementById("markActions").addEventListener("change", function (e) { var action = e.target.value; e.target.value = ""; if (!action) return; if (action === "markAllRead" || action === "markAllUnread") { $.apiFetch("api/mail/" + (action === "markAllRead" ? "mark-all-read" : "mark-all-unread"), { method: "POST", body: { folder: state.currentFolder } }).then(refreshAfterAction).catch(reportError); return; } var uids = getSelectedUids(); if (uids.length === 0) return; var endpoints = { markRead: "mark-read", markUnread: "mark-unread", flag: "flag", unflag: "unflag" }; $.apiFetch("api/mail/" + endpoints[action], { method: "POST", body: { folder: state.currentFolder, uids: uids } }).then(refreshAfterAction).catch(reportError); }); document.getElementById("moveToFolder").addEventListener("change", function (e) { var target = e.target.value; e.target.value = ""; if (!target) return; var uids = getSelectedUids(); if (uids.length === 0) return; $.apiFetch("api/mail/move", { method: "POST", body: { folder: state.currentFolder, uids: uids, targetFolder: target } }).then(function () { state.selectedUid = null; state.selectedMessage = null; refreshAfterAction().then(renderDetailPane); }).catch(reportError); }); document.getElementById("deleteActions").addEventListener("change", function (e) { var action = e.target.value; e.target.value = ""; if (!action) return; if (action === "emptyTrash" || action === "emptySpam") { var folderName = action === "emptyTrash" ? "Trash" : "Spam"; var folder = state.folders.find(function (f) { 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); return; } var uids = getSelectedUids(); if (uids.length === 0) return; $.apiFetch("api/mail/delete", { method: "POST", body: { folder: state.currentFolder, uids: uids } }).then(function () { state.selectedUid = null; state.selectedMessage = null; refreshAfterAction().then(renderDetailPane); }).catch(reportError); }); function findSpamFolder() { return state.folders.find(function (f) { return f.fullName.toLowerCase() === "spam" || f.displayName.toLowerCase() === "spam" || f.fullName.toLowerCase() === "junk" || f.displayName.toLowerCase() === "junk"; }); } function moveToSpam(uids, spamFolderFullName) { return $.apiFetch("api/mail/move", { method: "POST", body: { folder: state.currentFolder, uids: uids, targetFolder: spamFolderFullName } }).then(function () { // The message is gone from this folder, so the detail pane can't keep showing it - it // would be a view of a message that no longer exists here, and acting on it (reply, // delete, images) would hit a UID the server no longer has. Same clearing the regular // move/delete handlers already do; only when the open message is actually one of the // moved ones, so spamming a checkbox selection doesn't close an unrelated open message. if (state.selectedUid !== null && uids.indexOf(state.selectedUid) !== -1) { state.selectedUid = null; state.selectedMessage = null; return refreshAfterAction().then(renderDetailPane); } return refreshAfterAction(); }); } document.getElementById("spamBtn").addEventListener("click", function (e) { e.preventDefault(); var uids = getSelectedUids(); if (uids.length === 0) return; var spamFolder = findSpamFolder(); if (spamFolder) { moveToSpam(uids, spamFolder.fullName).catch(reportError); return; } // No Spam/Junk folder exists yet on this account - create one on the fly rather than making // the user go set it up in Mappen beheren first just to use a button they already clicked. $.apiFetch("api/settings/folders/add", { method: "POST", body: { name: "Spam" } }) .then(function () { return loadFolders(); }) .then(function () { renderFolderList(); // Re-lookup rather than assuming the new folder's fullName is exactly "Spam" - some // IMAP servers prefix personal-namespace folders (e.g. "INBOX.Spam"). var created = findSpamFolder(); return moveToSpam(uids, created ? created.fullName : "Spam"); }) .catch(reportError); }); // ---------- drag and drop: message row(s) -> folder in the sidebar ---------- document.getElementById("messageListBody").addEventListener("dragstart", function (e) { var row = e.target.closest(".message-row"); if (!row) { e.preventDefault(); return; } var uid = parseInt(row.getAttribute("data-uid"), 10); // Dragging a row that's part of the current checkbox selection drags the whole selection; // dragging an unrelated row (nothing checked, or checked rows elsewhere) only drags that one // message - matches how most file managers treat a drag started outside the selection. var selectedUids = getSelectedUids(); var uids = selectedUids.indexOf(uid) !== -1 ? selectedUids : [uid]; e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("application/json", JSON.stringify(uids)); }); function isValidDropTarget(folderFullName) { if (folderFullName === state.currentFolder) return false; var f = state.folders.find(function (x) { return x.fullName === folderFullName; }); return !!f && f.isSelectable; } document.getElementById("folderList").addEventListener("dragover", function (e) { var a = e.target.closest("a[data-folder]"); document.querySelectorAll("#folderList a.drop-target").forEach(function (el) { el.classList.remove("drop-target"); }); if (!a || !isValidDropTarget(a.getAttribute("data-folder"))) return; e.preventDefault(); e.dataTransfer.dropEffect = "move"; a.classList.add("drop-target"); }); document.getElementById("folderList").addEventListener("dragleave", function (e) { var a = e.target.closest("a[data-folder]"); if (a) a.classList.remove("drop-target"); }); document.getElementById("folderList").addEventListener("drop", function (e) { var a = e.target.closest("a[data-folder]"); document.querySelectorAll("#folderList a.drop-target").forEach(function (el) { el.classList.remove("drop-target"); }); if (!a) return; e.preventDefault(); var target = a.getAttribute("data-folder"); if (!isValidDropTarget(target)) return; var uids; try { uids = JSON.parse(e.dataTransfer.getData("application/json")); } catch (err) { return; } if (!uids || uids.length === 0) return; $.apiFetch("api/mail/move", { method: "POST", body: { folder: state.currentFolder, uids: uids, targetFolder: target } }).then(function () { if (uids.indexOf(state.selectedUid) !== -1) { state.selectedUid = null; state.selectedMessage = null; } refreshAfterAction().then(renderDetailPane); }).catch(reportError); }); document.getElementById("unreadOnlyToggle").addEventListener("change", function (e) { state.unreadOnly = e.target.checked; state.page = 1; loadMessages().then(renderMessageList); }); document.querySelectorAll(".sort-links a").forEach(function (a) { a.addEventListener("click", function (e) { e.preventDefault(); var key = a.getAttribute("data-sort"); state.desc = state.sort === key ? !state.desc : true; state.sort = key; updateSortLinksActive(); loadMessages().then(renderMessageList); }); }); 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(); var folder = a.getAttribute("data-folder"); if (folder === state.currentFolder) return; state.currentFolder = folder; state.page = 1; state.selectedUid = null; state.selectedMessage = null; document.getElementById("sidebar").classList.remove("open"); loadMessages().then(function () { renderFolderList(); renderMessageList(); renderDetailPane(); }); }); document.getElementById("messageListBody").addEventListener("click", function (e) { var pagerLink = e.target.closest("a[data-page]"); if (pagerLink) { e.preventDefault(); var page = parseInt(pagerLink.getAttribute("data-page"), 10) || 1; if (page < 1 || page > state.totalPages || page === state.page) return; state.page = page; loadMessages().then(renderMessageList); return; } var flagToggle = e.target.closest("[data-flag-toggle]"); if (flagToggle) { e.preventDefault(); e.stopPropagation(); toggleFlag(parseInt(flagToggle.closest(".message-row").getAttribute("data-uid"), 10), flagToggle); return; } var row = e.target.closest(".message-row"); if (row) { if (e.target.tagName === "INPUT") return; openMessage(parseInt(row.getAttribute("data-uid"), 10)); } }); // Toggles a single message's flag directly from the list, without opening it or requiring a // selection first (unlike the bulk "Acties" flag/unflag entries) - optimistic UI update, rolled // back on failure so the icon never lies about the request's outcome. function toggleFlag(uid, el) { var item = state.messages.find(function (x) { return x.uid === uid; }); if (!item) return; var newValue = !item.isFlagged; item.isFlagged = newValue; el.classList.toggle("flagged", newValue); $.apiFetch("api/mail/" + (newValue ? "flag" : "unflag"), { method: "POST", body: { folder: state.currentFolder, uids: [uid] } }).catch(function (err) { item.isFlagged = !newValue; el.classList.toggle("flagged", !newValue); if (!err.cancelled) window.alert(err.message); }); } // The checkbox itself lives in .list-toolbar (a static sibling of #messageListBody, not a // descendant), so it needs its own listener rather than being caught by #messageListBody's // delegated one - it never re-renders, so binding it once here directly is safe. document.querySelector("[data-select-all]").addEventListener("change", function (e) { var checked = e.target.checked; document.querySelectorAll(".message-row input[type=checkbox]").forEach(function (cb) { cb.checked = checked; }); }); function buildQuoteHtml(m) { var header = $.esc(m.from) + " - " + $.esc($.formatFullDate(m.date, state.lang)) + ":"; var body = (m.htmlBody && m.htmlBody.trim() !== "") ? m.htmlBody : "
    " + $.esc(m.textBody) + "
    "; return ( "

    " + header + '
    ' + body + "
    " ); } function goToCompose(mode) { var m = state.selectedMessage; if (!m) return; var prefill = { mode: mode, quoteHtml: buildQuoteHtml(m) }; if (mode === "reply" || mode === "replyAll") { prefill.to = m.senderEmail; prefill.subject = /^re:/i.test(m.subject) ? m.subject : "Re: " + m.subject; if (mode === "replyAll") prefill.cc = m.cc || ""; } else if (mode === "forward") { 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"; } document.getElementById("detailPane").addEventListener("change", function (e) { if (e.target.id === "replyActions") { var mode = e.target.value; e.target.value = ""; if (mode) goToCompose(mode); } }); document.getElementById("detailPane").addEventListener("click", function (e) { if (e.target.id === "closeDetailBtn") { e.preventDefault(); closeDetail(); return; } if (e.target.id === "forwardBtn") { e.preventDefault(); goToCompose("forward"); return; } if (e.target.id === "allowImagesBtn") { $.apiFetch("api/mail/allow-images", { method: "POST", body: { folder: state.currentFolder, uid: state.selectedUid, sender: state.selectedMessage.senderEmail } }).then(function () { return loadMessage(state.selectedUid); }).then(renderDetailPane); return; } var attachmentBtn = e.target.closest("[data-attachment]"); if (attachmentBtn) { openAttachment(attachmentBtn); } }); // Fetched as a blob via an authenticated fetch() call (never a plain navigation), so no // attachment URL is ever visible in the address bar, a new tab, or browser history - the // content is handed to the browser via a local, temporary blob: URL instead. function openAttachment(btn) { 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); fetch(url, { headers: { "X-Requested-With": "fetch" }, credentials: "same-origin" }) .then(function (response) { if (!response.ok) throw new Error("HTTP " + response.status); return response.blob(); }) .then(function (blob) { var blobUrl = URL.createObjectURL(blob); if (mode === "view") { window.open(blobUrl, "_blank"); } else { var a = document.createElement("a"); a.href = blobUrl; a.download = fileName; document.body.appendChild(a); a.click(); document.body.removeChild(a); } setTimeout(function () { URL.revokeObjectURL(blobUrl); }, 60000); }) .catch(reportError); } bootstrap(); })();