(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 (
'
"
);
}).join("");
renderMoveToFolderOptions();
}
function renderMessageList()
{
var t = state.strings;
var html;
if (state.messages.length === 0)
{
html = '
";
}
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 (
'
"
);
}
function renderDetailPane()
{
var t = state.strings;
var m = state.selectedMessage;
var pane = document.getElementById("detailPane");
var content = document.getElementById("mailContent");
if (!m)
{
pane.innerHTML = '
" : "";
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 =
'
' +
'' + $.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.
'
";
}
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();
})();