Add IMAP webmail tag, admin/opensource subpage tiers, store/cart, htmleditor, uploads
New __imap*/__smtpsend/__eval Jet tags give templates real IMAP (list/message/flag/delete/move/search/folders/accounts) and SMTP-send capability, backing the new /email webmail client (email.jet/ email.menu, assets/images/email.js + newemail.js) and its mainpagetemplate.jet-based page shell. Most of this tag family's source was already being built into the live site (Tag.cpp already registered __imap/__imaplist/__imapmessage/__imapflag/__imapdelete/ __eval) but had never actually been committed; this also registers and commits the remaining five (__imapmove/__imapfolders/ __imapsearch/__smtpsend/__imapaccounts). admin.page/admin.menu gain a real subpage tier (admin-email, admin-newemail, admin-invoices, admin-products, admin-users) replacing several previously top-level pages, mirrored by a similar opensource.page subpage tier (opensource-overview/asm1802/barantmail/ jet/serverframework/synthmax) - old top-level pages retired via git rename (asm1802.page, jet.page, serverframework.page, admin_invoices.page); barantmail.page is dropped outright, folded into opensource-barantmail.subpage. New storefront (store.page, store-createstore.subpage, barantstore.page) and cart flow (cart.jet, updatecartqty.proc, removecartitem.proc, editproduct.page/selectproduct.proc/ update_product.proc for inventory maintenance against the barant database). New document upload/download manager (documents.jet, documents_api.jet, documents_upload.proc, documents_delete.proc, assets/images/documents.js + documents-page.js) with a persistent floating panel, and a rich-text page editor (htmleditor.jet/.page, createhtmleditor.proc, savehtmleditor.proc, assets/images/htmleditor.js + its icon/logo assets). SESSIONCTL.tag: login now redirects back to the originally-requested page instead of always landing on /account ($[start:0] vs $[start] variable-name mismatch). main.css/menu.bar/invoices.menu/jet_overview.section/login.menu: styling and navigation updates for the above; new site assets (fonts, images, audio) referenced by them. jetc/build-assets.sh, list_jet_files.sh, lowercase_jet.py, strip_fragments.py: small dev tooling used while building the above.
This commit is contained in:
1 parent
b0cadc3f08
commit
c1491b044b
115 files changed
+6174
-137
No files matched your search
@@ -0,0 +1,778 @@
|
||||
(function() {
|
||||
var app = document.getElementById('email-app');
|
||||
if (!app) return;
|
||||
|
||||
// Small inline-SVG icon set for the toolbar/action buttons below -
|
||||
// stroke="currentColor" so each one just inherits its <button>'s own
|
||||
// text color (the site's gold theme, see .icon-btn in email.page's
|
||||
// own <style> block), no separate image assets or color variants to
|
||||
// keep in sync with the theme.
|
||||
var ICONS = {
|
||||
search:
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' +
|
||||
'<circle cx="11" cy="11" r="7"/>' +
|
||||
'<path d="M21 21l-4.35-4.35"/>' +
|
||||
'</svg>',
|
||||
compose:
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' +
|
||||
'<path d="M4 6h13a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2z"/>' +
|
||||
'<path d="M2.5 7.5 12 14l9.5-6.5"/>' +
|
||||
'<path d="M18.5 3.5v5M16 6h5"/>' +
|
||||
'</svg>',
|
||||
refresh:
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' +
|
||||
'<path d="M20 11a8 8 0 0 0-14.6-4.4M4 4v5h5"/>' +
|
||||
'<path d="M4 13a8 8 0 0 0 14.6 4.4M20 20v-5h-5"/>' +
|
||||
'</svg>',
|
||||
disconnect:
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' +
|
||||
'<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>' +
|
||||
'<path d="M16 17l5-5-5-5"/>' +
|
||||
'<path d="M21 12H9"/>' +
|
||||
'</svg>',
|
||||
flag:
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' +
|
||||
'<path d="M5 3v18"/>' +
|
||||
'<path d="M5 4h12l-3 4.5L17 13H5"/>' +
|
||||
'</svg>',
|
||||
trash:
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' +
|
||||
'<path d="M4 7h16"/>' +
|
||||
'<path d="M9 7V4h6v3"/>' +
|
||||
'<path d="M6 7l1 13a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-13"/>' +
|
||||
'<path d="M10 11v6M14 11v6"/>' +
|
||||
'</svg>'
|
||||
};
|
||||
|
||||
// Matches the exact, case-sensitive names the mailbox itself reports
|
||||
// via IMAP LIST (confirmed live: "Inbox", "Spam", "Trash" - only
|
||||
// INBOX is specially case-insensitive per RFC 3501, the other two
|
||||
// are not, so this has to be exact) - see email.menu's own folder
|
||||
// links, which pass these same three names to emailSelectFolder().
|
||||
var currentFolder = 'Inbox';
|
||||
var currentUid = null;
|
||||
// null = showing the normal folder listing; a non-empty string =
|
||||
// showing search results instead (see performSearch()/clearSearch()
|
||||
// and loadMessageList()'s own branch on this).
|
||||
var currentSearchQuery = null;
|
||||
// The array last handed to renderMessageList() - kept so openMessage()
|
||||
// can flip a single message's .seen flag locally and recompute the
|
||||
// toolbar's unread/total count without a round trip.
|
||||
var currentMessages = [];
|
||||
|
||||
// email.menu (the site's own left-side #panelmenu, a static include
|
||||
// living outside #email-app/#content entirely - see mainpagetemplate
|
||||
// .jet's own panelmenu wiring) renders its folder links against this
|
||||
// global rather than anything scoped to this closure, since it has
|
||||
// no other way to reach into a same-page IMAP client without a full
|
||||
// page navigation (which the rest of this client deliberately avoids
|
||||
// for every other interaction too - see the module doc comment on
|
||||
// post() below... [see loadMessageList/openMessage]). Guarded with
|
||||
// `window.emailSelectFolder &&` at each call site in email.menu so a
|
||||
// click before email.js has loaded (or before a mailbox is even
|
||||
// connected) is a harmless no-op instead of a broken link.
|
||||
window.emailSelectFolder = function(folder) {
|
||||
if (!document.getElementById('email-client') || (folder === currentFolder && !currentSearchQuery))
|
||||
return;
|
||||
currentFolder = folder;
|
||||
currentUid = null;
|
||||
// A folder click always means "show me this folder, plain" - drops
|
||||
// out of search mode even if the folder didn't actually change,
|
||||
// which is exactly how a user would expect to back out of a
|
||||
// search result view.
|
||||
currentSearchQuery = null;
|
||||
var searchInput = document.getElementById('email-search-input');
|
||||
if (searchInput) searchInput.value = '';
|
||||
syncActiveFolder();
|
||||
var pane = document.getElementById('email-reading-pane');
|
||||
if (pane) pane.innerHTML = '<div id="email-empty-state">Select a message to read it.</div>';
|
||||
loadMessageList();
|
||||
};
|
||||
|
||||
function syncActiveFolder() {
|
||||
var links = document.querySelectorAll('.email-folder-link');
|
||||
for (var i = 0; i < links.length; i++)
|
||||
links[i].classList.toggle('email-folder-selected', links[i].getAttribute('data-folder') === currentFolder);
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
var div = document.createElement('div');
|
||||
div.textContent = s == null ? '' : String(s);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function post(path, params) {
|
||||
return fetch(path, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams(params || {}).toString()
|
||||
}).then(function(r) { return r.json(); });
|
||||
}
|
||||
|
||||
// Matches spa.js's own delay() exactly - a separate closure here
|
||||
// since this file has no dependency on spa.js at all, not worth
|
||||
// sharing just for this.
|
||||
function delay(ms) {
|
||||
return new Promise(function(resolve) { setTimeout(resolve, ms); });
|
||||
}
|
||||
|
||||
function showConnectForm(errorText) {
|
||||
app.innerHTML =
|
||||
'<div id="email-connect-form">' +
|
||||
'<h3>Connect to Mailbox</h3>' +
|
||||
'<input type="text" id="email-user-input" placeholder="you@barant.com" autocomplete="username">' +
|
||||
'<input type="password" id="email-password-input" placeholder="Password" autocomplete="current-password">' +
|
||||
'<button type="button" id="email-connect-btn">Connect</button>' +
|
||||
'<div id="email-connect-error">' + (errorText ? escapeHtml(errorText) : '') + '</div>' +
|
||||
'</div>';
|
||||
wireConnectForm();
|
||||
}
|
||||
|
||||
function showClientView(userEmail) {
|
||||
app.innerHTML =
|
||||
'<div id="email-client">' +
|
||||
'<div id="email-toolbar">' +
|
||||
'<span id="email-connected-as">' + escapeHtml(userEmail) + '</span>' +
|
||||
'<span id="email-list-count"></span>' +
|
||||
'<span id="email-toolbar-actions">' +
|
||||
'<span id="email-search-box">' +
|
||||
'<input type="text" id="email-search-input" placeholder="Search mail…">' +
|
||||
'<button type="button" id="email-search-btn" class="icon-btn" title="Search">' + ICONS.search + '</button>' +
|
||||
'</span>' +
|
||||
'<button type="button" id="email-compose-btn" class="icon-btn" title="New Mail">' + ICONS.compose + '</button>' +
|
||||
'<button type="button" id="email-refresh-btn" class="icon-btn" title="Refresh">' + ICONS.refresh + '</button>' +
|
||||
'<button type="button" id="email-disconnect-btn" class="icon-btn" title="Disconnect">' + ICONS.disconnect + '</button>' +
|
||||
'</span>' +
|
||||
'</div>' +
|
||||
'<div id="email-layout">' +
|
||||
'<div id="email-list"></div>' +
|
||||
'<div id="email-reading-pane"><div id="email-empty-state">Select a message to read it.</div></div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
wireClientView();
|
||||
wireAddAccountForm();
|
||||
syncActiveFolder();
|
||||
loadMessageList();
|
||||
loadAccounts();
|
||||
}
|
||||
|
||||
function wireConnectForm() {
|
||||
var btn = document.getElementById('email-connect-btn');
|
||||
var userInput = document.getElementById('email-user-input');
|
||||
var passInput = document.getElementById('email-password-input');
|
||||
var errorDiv = document.getElementById('email-connect-error');
|
||||
|
||||
function submit() {
|
||||
errorDiv.textContent = '';
|
||||
btn.disabled = true;
|
||||
post('/email/connect', { user: userInput.value, password: passInput.value })
|
||||
.then(function(res) {
|
||||
btn.disabled = false;
|
||||
if (res.ok) {
|
||||
showClientView(userInput.value);
|
||||
} else {
|
||||
errorDiv.textContent = res.error || 'Could not connect.';
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
btn.disabled = false;
|
||||
errorDiv.textContent = 'Connection request failed.';
|
||||
});
|
||||
}
|
||||
|
||||
btn.addEventListener('click', submit);
|
||||
passInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') submit();
|
||||
});
|
||||
}
|
||||
|
||||
function wireClientView() {
|
||||
// Navigates via a synthetic <a> click rather than
|
||||
// window.location.href so spa.js's own document-level click
|
||||
// listener intercepts it and does its normal fade transition,
|
||||
// exactly as if a real link had been clicked.
|
||||
document.getElementById('email-compose-btn').addEventListener('click', function() {
|
||||
var a = document.createElement('a');
|
||||
a.href = '/admin/newemail';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
});
|
||||
document.getElementById('email-refresh-btn').addEventListener('click', function() {
|
||||
loadMessageList();
|
||||
});
|
||||
document.getElementById('email-disconnect-btn').addEventListener('click', function() {
|
||||
// Disconnecting only ever drops the ACTIVE account now (see
|
||||
// /email/disconnect's own doc comment in email.jet) - if another
|
||||
// already-connected account remains, res.active names it and the
|
||||
// view just switches there instead of falling back to the
|
||||
// connect form.
|
||||
post('/email/disconnect', {}).then(function(res) {
|
||||
if (res.active) {
|
||||
resetViewForActiveAccount(res.active);
|
||||
} else {
|
||||
currentFolder = 'Inbox';
|
||||
currentUid = null;
|
||||
syncActiveFolder();
|
||||
showConnectForm();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Matches sender, subject, or body content in whatever folder is
|
||||
// currently open (see __imap.cpp's searchMessages()) - an empty
|
||||
// query is treated as "leave search mode" rather than sent to the
|
||||
// server as a real search, since an empty needle would otherwise
|
||||
// just match everything anyway (see __IMAP_SEARCH.cpp's own
|
||||
// containsCI()) - clearer to just fall back to the plain listing.
|
||||
function runSearch() {
|
||||
var input = document.getElementById('email-search-input');
|
||||
var query = input.value.trim();
|
||||
currentSearchQuery = query === '' ? null : query;
|
||||
currentUid = null;
|
||||
document.getElementById('email-reading-pane').innerHTML =
|
||||
'<div id="email-empty-state">Select a message to read it.</div>';
|
||||
loadMessageList();
|
||||
}
|
||||
document.getElementById('email-search-btn').addEventListener('click', runSearch);
|
||||
document.getElementById('email-search-input').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter')
|
||||
runSearch();
|
||||
});
|
||||
}
|
||||
|
||||
function updateListCount(messages) {
|
||||
var countEl = document.getElementById('email-list-count');
|
||||
if (!countEl)
|
||||
return;
|
||||
if (!messages.length) {
|
||||
countEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
var unread = 0;
|
||||
for (var i = 0; i < messages.length; i++) {
|
||||
if (!messages[i].seen)
|
||||
unread++;
|
||||
}
|
||||
countEl.textContent = '(' + unread + '/' + messages.length + ')';
|
||||
}
|
||||
|
||||
function renderMessageList(messages) {
|
||||
var list = document.getElementById('email-list');
|
||||
currentMessages = messages;
|
||||
updateListCount(messages);
|
||||
if (!messages.length) {
|
||||
list.innerHTML = '<div id="email-empty-state">No messages.</div>';
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
for (var i = 0; i < messages.length; i++) {
|
||||
var m = messages[i];
|
||||
html += '<div class="email-row' + (m.seen ? '' : ' unread') +
|
||||
(m.uid === currentUid ? ' selected' : '') + '" data-uid="' + m.uid + '">' +
|
||||
'<div class="email-row-info">' +
|
||||
'<div class="email-from">' + escapeHtml(m.from) + '</div>' +
|
||||
'<div class="email-subject">' + escapeHtml(m.subject || '(no subject)') + '</div>' +
|
||||
'<div class="email-date">' + escapeHtml(m.date) + '</div>' +
|
||||
'</div>' +
|
||||
'<button type="button" class="icon-btn email-row-trash-btn" data-uid="' + m.uid + '" title="Move to Trash">' + ICONS.trash + '</button>' +
|
||||
'</div>';
|
||||
}
|
||||
list.innerHTML = html;
|
||||
|
||||
var rows = list.querySelectorAll('.email-row');
|
||||
for (var j = 0; j < rows.length; j++) {
|
||||
rows[j].addEventListener('click', function() {
|
||||
openMessage(parseInt(this.getAttribute('data-uid'), 10));
|
||||
});
|
||||
}
|
||||
// Each row's own trash button - stopPropagation so it doesn't also
|
||||
// trigger the row's own click-to-open handler just above.
|
||||
var trashBtns = list.querySelectorAll('.email-row-trash-btn');
|
||||
for (var k = 0; k < trashBtns.length; k++) {
|
||||
trashBtns[k].addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
trashMessage(parseInt(this.getAttribute('data-uid'), 10));
|
||||
});
|
||||
}
|
||||
|
||||
// Whatever caused this particular render (initial connect, a
|
||||
// folder switch, the refresh button with nothing open) left
|
||||
// currentUid null - open the top message rather than leave the
|
||||
// reading pane sitting empty. trashMessage() deliberately sets
|
||||
// currentUid to the row it's already decided to select BEFORE
|
||||
// reloading the list (see its own comment), specifically so this
|
||||
// check sees a real, already-correct uid and stays out of the way
|
||||
// instead of racing it to open something different.
|
||||
if (currentUid === null)
|
||||
openMessage(messages[0].uid);
|
||||
}
|
||||
|
||||
// Moves a message into the Trash folder (server-side: COPY there,
|
||||
// then \Deleted+EXPUNGE from the original folder - see __imap.cpp's
|
||||
// moveMessage()) rather than permanently removing it, the same
|
||||
// "delete means trash it" convention the reading pane's own Delete
|
||||
// button (a real, permanent expunge) deliberately does NOT follow -
|
||||
// that one stays as-is; this is a separate, softer action reachable
|
||||
// straight from the list.
|
||||
// Total time the .email-row-removing CSS animation takes to finish
|
||||
// (see email.page's own <style> block) - the opacity fade runs with
|
||||
// no delay for FADE_MS, then the height/padding/margin/border
|
||||
// collapse (what actually slides the rows below it up) runs for
|
||||
// COLLAPSE_MS starting COLLAPSE_DELAY_MS in. Kept here as the single
|
||||
// source of truth for how long trashMessage() waits before swapping
|
||||
// in the server's real, post-trash list - duplicated as literal
|
||||
// numbers in the CSS transition properties themselves, since CSS has
|
||||
// no way to reference a JS constant, so keep those two in sync by
|
||||
// hand if either ever changes.
|
||||
var REMOVE_FADE_MS = 200;
|
||||
var REMOVE_COLLAPSE_DELAY_MS = 200;
|
||||
var REMOVE_COLLAPSE_MS = 220;
|
||||
|
||||
// Plays the dissolve-then-collapse removal animation on one row in
|
||||
// place, resolving once it's fully finished (safe to call with a
|
||||
// null row - resolves immediately, for when the trashed uid's row
|
||||
// isn't actually in the currently-rendered list for some reason).
|
||||
function animateRowRemoval(row) {
|
||||
if (!row)
|
||||
return Promise.resolve();
|
||||
var height = row.getBoundingClientRect().height;
|
||||
row.style.height = height + 'px';
|
||||
// Forces the browser to register the explicit pixel height above
|
||||
// as a real, already-applied style before the next line changes
|
||||
// it again - without this the two height changes collapse into a
|
||||
// single layout pass and no transition plays at all.
|
||||
row.offsetHeight;
|
||||
row.classList.add('email-row-removing');
|
||||
return new Promise(function(resolve) {
|
||||
setTimeout(resolve, REMOVE_FADE_MS + REMOVE_COLLAPSE_DELAY_MS + REMOVE_COLLAPSE_MS);
|
||||
});
|
||||
}
|
||||
|
||||
// Only the trashed row itself animates - every other row just stays
|
||||
// exactly where it is and shifts up as an ordinary side effect of
|
||||
// the trashed row's own height collapsing to 0 (real document-flow
|
||||
// reflow, not a separate animation applied to them). The list is
|
||||
// still reloaded from the server afterward (flags/counts could have
|
||||
// changed for reasons unrelated to this one trash action), but with
|
||||
// loadMessageList(true) - skipping ITS OWN whole-list fade, since by
|
||||
// the time it swaps in fresh markup the trashed row has already
|
||||
// visually shrunk to nothing and every other row is already sitting
|
||||
// in its final position; fading the whole list on top of that would
|
||||
// just be a second, redundant flash.
|
||||
function trashMessage(uid) {
|
||||
var rows = document.querySelectorAll('.email-row');
|
||||
var index = -1;
|
||||
var trashedRow = null;
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
if (parseInt(rows[i].getAttribute('data-uid'), 10) === uid) {
|
||||
index = i;
|
||||
trashedRow = rows[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
var nextUid = null;
|
||||
if (index !== -1) {
|
||||
if (index + 1 < rows.length)
|
||||
nextUid = parseInt(rows[index + 1].getAttribute('data-uid'), 10);
|
||||
else if (index - 1 >= 0)
|
||||
nextUid = parseInt(rows[index - 1].getAttribute('data-uid'), 10);
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
animateRowRemoval(trashedRow),
|
||||
post('/email/trash', { folder: currentFolder, uid: uid })
|
||||
]).then(function() {
|
||||
// Set BEFORE the reload, not after - renderMessageList()'s own
|
||||
// auto-select-first-when-nothing's-selected check runs partway
|
||||
// through loadMessageList(true) below, and needs to already see
|
||||
// the real target here (or genuinely null, if the list is about
|
||||
// to be empty) rather than a stale null that would make it pick
|
||||
// the top row instead and race this function's own openMessage()
|
||||
// call just below.
|
||||
if (currentUid === uid)
|
||||
currentUid = nextUid;
|
||||
loadMessageList(true).then(function() {
|
||||
if (nextUid !== null)
|
||||
openMessage(nextUid);
|
||||
else
|
||||
document.getElementById('email-reading-pane').innerHTML =
|
||||
'<div id="email-empty-state">Select a message to read it.</div>';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Same fade-out/fetch-concurrently/fade-in shape as openMessage() and
|
||||
// spa.js's own navigate() - every list refresh (folder switch,
|
||||
// refresh button) gets the same smooth transition instead of a flash
|
||||
// of "Loading..." text followed by an abrupt content swap. Pass
|
||||
// skipFade=true to skip that whole-list transition and just quietly
|
||||
// re-render in place - used only right after trashMessage()'s own
|
||||
// per-row removal animation has already provided the visual
|
||||
// transition, so this reload doesn't also blink the entire list.
|
||||
function loadMessageList(skipFade) {
|
||||
var list = document.getElementById('email-list');
|
||||
if (!list)
|
||||
return Promise.resolve();
|
||||
if (!skipFade)
|
||||
list.classList.add('email-fading');
|
||||
// Search results use the identical JSON shape as a normal list
|
||||
// (see __imap.cpp's shared parseSummaryLines()), so this is the
|
||||
// only place that needs to know search mode even exists -
|
||||
// renderMessageList() below has no idea which one produced what
|
||||
// it's rendering.
|
||||
var request = currentSearchQuery
|
||||
? post('/email/search', { folder: currentFolder, query: currentSearchQuery })
|
||||
: post('/email/list', { folder: currentFolder });
|
||||
return Promise.all([
|
||||
request,
|
||||
skipFade ? true : delay(180)
|
||||
])
|
||||
.then(function(results) {
|
||||
var res = results[0];
|
||||
if (!res.ok && res.error)
|
||||
list.innerHTML = '<div id="email-empty-state">' + escapeHtml(res.error) + '</div>';
|
||||
else
|
||||
renderMessageList(Array.isArray(res) ? res : []);
|
||||
// Every place that reloads the list (connect, folder switch,
|
||||
// refresh, post-trash) is a place the unseen counts next to
|
||||
// each folder name could plausibly have changed too - one
|
||||
// hook here covers all of them rather than needing a separate
|
||||
// call at each individual call site.
|
||||
loadFolderCounts();
|
||||
if (!skipFade)
|
||||
list.classList.remove('email-fading');
|
||||
})
|
||||
.catch(function() {
|
||||
list.innerHTML = '<div id="email-empty-state">Could not load messages.</div>';
|
||||
if (!skipFade)
|
||||
list.classList.remove('email-fading');
|
||||
});
|
||||
}
|
||||
|
||||
// Populates the unread-count badge next to each folder name in
|
||||
// email.menu's own static markup (a real DOM lookup by
|
||||
// data-folder-count, not anything rendered by this file - email.menu
|
||||
// is a separate include living in #panelmenu, outside #content
|
||||
// entirely). Uses IMAP STATUS server-side (see __imap.cpp's
|
||||
// folderCounts()), not a real FETCH, so this is cheap enough to call
|
||||
// on every list reload without it being a heavy extra round trip.
|
||||
// Silently does nothing on error - a stale/missing count badge is a
|
||||
// cosmetic problem, not worth surfacing as a user-visible failure.
|
||||
function loadFolderCounts() {
|
||||
post('/email/folders', {}).then(function(res) {
|
||||
if (res.error)
|
||||
return;
|
||||
var folders = ['Inbox', 'Spam', 'Trash'];
|
||||
for (var i = 0; i < folders.length; i++) {
|
||||
var f = folders[i];
|
||||
var span = document.querySelector('.email-folder-count[data-folder-count="' + f + '"]');
|
||||
if (span && res[f])
|
||||
span.textContent = res[f].messages > 0 ? (res[f].unseen + '/' + res[f].messages) : '';
|
||||
}
|
||||
}).catch(function() {});
|
||||
}
|
||||
|
||||
function loadAccounts() {
|
||||
post('/email/accounts', {}).then(function(res) {
|
||||
var list = document.getElementById('email-account-list');
|
||||
if (!list || !res.accounts)
|
||||
return;
|
||||
var html = '';
|
||||
for (var i = 0; i < res.accounts.length; i++) {
|
||||
var user = res.accounts[i];
|
||||
html += '<a href="javascript:void(0);" class="email-account-link' +
|
||||
(user === res.active ? ' email-account-selected' : '') + '" data-user="' +
|
||||
escapeHtml(user) + '">' + escapeHtml(user) + '</a>';
|
||||
}
|
||||
list.innerHTML = html;
|
||||
var links = list.querySelectorAll('.email-account-link');
|
||||
for (var j = 0; j < links.length; j++) {
|
||||
links[j].addEventListener('click', function() {
|
||||
var user = this.getAttribute('data-user');
|
||||
if (!this.classList.contains('email-account-selected'))
|
||||
switchAccount(user);
|
||||
});
|
||||
}
|
||||
}).catch(function() {});
|
||||
}
|
||||
|
||||
// Shared by switchAccount() and the add-account form's own success
|
||||
// path (which is already connected/active server-side by the time
|
||||
// /email/connect returns ok - no reason to also round-trip through
|
||||
// switchaccount right after) - resets the view to a fresh Inbox for
|
||||
// whichever account just became active, same as a brand new connect.
|
||||
function resetViewForActiveAccount(user) {
|
||||
currentFolder = 'Inbox';
|
||||
currentUid = null;
|
||||
currentSearchQuery = null;
|
||||
var searchInput = document.getElementById('email-search-input');
|
||||
if (searchInput) searchInput.value = '';
|
||||
var connectedAs = document.getElementById('email-connected-as');
|
||||
if (connectedAs) connectedAs.textContent = user;
|
||||
syncActiveFolder();
|
||||
var pane = document.getElementById('email-reading-pane');
|
||||
if (pane) pane.innerHTML = '<div id="email-empty-state">Select a message to read it.</div>';
|
||||
loadMessageList();
|
||||
loadAccounts();
|
||||
}
|
||||
|
||||
function switchAccount(user) {
|
||||
post('/email/switchaccount', { user: user }).then(function(res) {
|
||||
if (res.ok)
|
||||
resetViewForActiveAccount(user);
|
||||
}).catch(function() {});
|
||||
}
|
||||
|
||||
window.emailShowAddAccount = function() {
|
||||
var form = document.getElementById('email-add-account-form');
|
||||
var link = document.getElementById('email-add-account-link');
|
||||
if (!form)
|
||||
return;
|
||||
form.style.display = 'flex';
|
||||
if (link) link.style.display = 'none';
|
||||
var userInput = document.getElementById('email-add-account-user');
|
||||
if (userInput) userInput.focus();
|
||||
};
|
||||
|
||||
function wireAddAccountForm() {
|
||||
var btn = document.getElementById('email-add-account-btn');
|
||||
var userInput = document.getElementById('email-add-account-user');
|
||||
var passInput = document.getElementById('email-add-account-password');
|
||||
var errorDiv = document.getElementById('email-add-account-error');
|
||||
if (!btn)
|
||||
return;
|
||||
|
||||
function submit() {
|
||||
errorDiv.textContent = '';
|
||||
btn.disabled = true;
|
||||
post('/email/connect', { user: userInput.value, password: passInput.value })
|
||||
.then(function(res) {
|
||||
btn.disabled = false;
|
||||
if (!res.ok) {
|
||||
errorDiv.textContent = res.error || 'Could not connect.';
|
||||
return;
|
||||
}
|
||||
var newUser = userInput.value;
|
||||
userInput.value = '';
|
||||
passInput.value = '';
|
||||
document.getElementById('email-add-account-form').style.display = 'none';
|
||||
document.getElementById('email-add-account-link').style.display = 'block';
|
||||
resetViewForActiveAccount(newUser);
|
||||
})
|
||||
.catch(function() {
|
||||
btn.disabled = false;
|
||||
errorDiv.textContent = 'Connection request failed.';
|
||||
});
|
||||
}
|
||||
|
||||
btn.addEventListener('click', submit);
|
||||
passInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') submit();
|
||||
});
|
||||
}
|
||||
|
||||
function renderMessage(msg) {
|
||||
var pane = document.getElementById('email-reading-pane');
|
||||
pane.innerHTML =
|
||||
'<div class="email-header">' +
|
||||
'<div><b>From:</b> ' + escapeHtml(msg.from) + '</div>' +
|
||||
'<div><b>To:</b> ' + escapeHtml(msg.to) + '</div>' +
|
||||
'<div><b>Subject:</b> ' + escapeHtml(msg.subject || '(no subject)') + '</div>' +
|
||||
'<div><b>Date:</b> ' + escapeHtml(msg.date) + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="email-body-container"></div>' +
|
||||
'<div class="email-actions">' +
|
||||
'<button type="button" id="email-flag-btn" class="icon-btn' + (msg.flagged ? ' icon-btn-active' : '') +
|
||||
'" title="' + (msg.flagged ? 'Unflag' : 'Flag') + '">' + ICONS.flag + '</button>' +
|
||||
'<button type="button" id="email-delete-btn" class="icon-btn" title="Delete">' + ICONS.trash + '</button>' +
|
||||
'</div>';
|
||||
|
||||
// HTML-formatted mail renders in a sandboxed iframe, never via
|
||||
// innerHTML into this page directly - the body content is whatever
|
||||
// a third party sent, and this page runs as the logged-in user
|
||||
// with real session cookies. `allow-same-origin` alone (no
|
||||
// `allow-scripts`, no `allow-forms`, no `allow-popups`, no
|
||||
// `allow-top-navigation`) still blocks every dangerous thing
|
||||
// untrusted HTML could do - scripts simply do not run without
|
||||
// allow-scripts, full stop, regardless of same-origin access - it
|
||||
// ONLY additionally lets THIS PAGE read the iframe's own rendered
|
||||
// content height back out (contentDocument), which the very first,
|
||||
// fully-opaque `sandbox=""` version couldn't do at all. That's
|
||||
// needed to size the iframe to its actual content below, rather
|
||||
// than a fixed height with its own internal scrollbar sitting
|
||||
// inside the reading pane's own scrollbar - two nested scrollbars
|
||||
// for one piece of content, confusing regardless of how tall the
|
||||
// fixed box happened to be. Building it via createElement + the
|
||||
// .srcdoc PROPERTY (not a string-concatenated HTML attribute)
|
||||
// sidesteps any need to hand-escape the content for attribute-
|
||||
// safety - the DOM API does that correctly on its own. Plain-text
|
||||
// mail keeps using .textContent, same reasoning.
|
||||
var bodyContainer = pane.querySelector('.email-body-container');
|
||||
if (msg.isHtml) {
|
||||
var frame = document.createElement('iframe');
|
||||
frame.className = 'email-body-frame';
|
||||
frame.setAttribute('sandbox', 'allow-same-origin');
|
||||
frame.setAttribute('scrolling', 'no');
|
||||
frame.addEventListener('load', function() {
|
||||
try {
|
||||
var doc = frame.contentDocument;
|
||||
if (doc && doc.documentElement)
|
||||
frame.style.height = Math.max(doc.documentElement.scrollHeight, doc.body.scrollHeight) + 'px';
|
||||
} catch (e) {
|
||||
// Left at its CSS fallback height - still shows the content,
|
||||
// just with its own scrollbar if that fallback runs short.
|
||||
}
|
||||
});
|
||||
frame.srcdoc = msg.body;
|
||||
bodyContainer.appendChild(frame);
|
||||
} else {
|
||||
var textDiv = document.createElement('div');
|
||||
textDiv.className = 'email-body';
|
||||
textDiv.textContent = msg.body;
|
||||
bodyContainer.appendChild(textDiv);
|
||||
}
|
||||
|
||||
document.getElementById('email-flag-btn').addEventListener('click', function() {
|
||||
post('/email/flag', {
|
||||
folder: currentFolder,
|
||||
uid: msg.uid,
|
||||
flag: '\\Flagged',
|
||||
set: msg.flagged ? 'no' : 'yes'
|
||||
}).then(function() {
|
||||
loadMessageList();
|
||||
openMessage(msg.uid);
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('email-delete-btn').addEventListener('click', function() {
|
||||
if (!window.confirm('Delete this message?')) return;
|
||||
post('/email/delete', { folder: currentFolder, uid: msg.uid }).then(function() {
|
||||
currentUid = null;
|
||||
document.getElementById('email-reading-pane').innerHTML =
|
||||
'<div id="email-empty-state">Select a message to read it.</div>';
|
||||
loadMessageList();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Opening a message is the reading pane's own concern - it fetches
|
||||
// and renders itself via /email/message and nothing else. It used to
|
||||
// also call loadMessageList() afterward (re-fetching and
|
||||
// re-rendering the ENTIRE list, every single time a message was
|
||||
// opened) just to pick up the \Seen flag the server already set as a
|
||||
// side effect of fetching the body - that's a locally-known update
|
||||
// (we know which uid was just opened), applied straight to the
|
||||
// already-rendered row below instead of paying for a second full
|
||||
// round trip and list re-render on every click.
|
||||
function openMessage(uid) {
|
||||
currentUid = uid;
|
||||
var pane = document.getElementById('email-reading-pane');
|
||||
var rows = document.querySelectorAll('.email-row');
|
||||
var wasUnread = false;
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
var isTarget = parseInt(rows[i].getAttribute('data-uid'), 10) === uid;
|
||||
rows[i].classList.toggle('selected', isTarget);
|
||||
if (isTarget) {
|
||||
wasUnread = rows[i].classList.contains('unread');
|
||||
// Marked read the instant it's selected, not after the fetch
|
||||
// resolves - fetchMessage() marks it \Seen server-side anyway
|
||||
// (BODY[] rather than BODY.PEEK[]), so there's nothing
|
||||
// optimistic about this beyond removing the visual lag.
|
||||
rows[i].classList.remove('unread');
|
||||
}
|
||||
}
|
||||
if (wasUnread) {
|
||||
for (var k = 0; k < currentMessages.length; k++) {
|
||||
if (currentMessages[k].uid === uid)
|
||||
currentMessages[k].seen = true;
|
||||
}
|
||||
updateListCount(currentMessages);
|
||||
loadFolderCounts();
|
||||
}
|
||||
|
||||
// Same fade-out/fetch-concurrently/fade-in shape as spa.js's own
|
||||
// navigate() - a minimum-duration fade (via delay()) runs alongside
|
||||
// the fetch rather than after it, so a fast response still shows
|
||||
// the full transition instead of a flash, and a slow one doesn't
|
||||
// wait any longer than the fetch itself already takes.
|
||||
pane.classList.add('email-fading');
|
||||
Promise.all([
|
||||
post('/email/message', { folder: currentFolder, uid: uid }),
|
||||
delay(180)
|
||||
])
|
||||
.then(function(results) {
|
||||
var res = results[0];
|
||||
if (!res.ok && res.error) {
|
||||
pane.innerHTML = '<div id="email-empty-state">' + escapeHtml(res.error) + '</div>';
|
||||
} else {
|
||||
renderMessage(res);
|
||||
}
|
||||
pane.classList.remove('email-fading');
|
||||
})
|
||||
.catch(function() {
|
||||
pane.innerHTML = '<div id="email-empty-state">Could not load message.</div>';
|
||||
pane.classList.remove('email-fading');
|
||||
});
|
||||
}
|
||||
|
||||
// Up/Down moves through the message list and opens whatever it
|
||||
// lands on - registered once, unconditionally, at load time (same
|
||||
// always-on-but-internally-guarded shape as spa.js's own document-
|
||||
// level click listener) rather than added/removed each time the
|
||||
// client view is (re)shown, so reconnecting never stacks up
|
||||
// duplicate handlers. Bails out immediately whenever the client
|
||||
// view isn't actually showing (the connect form has no .email-row
|
||||
// elements at all) or the keystroke is really meant for a form
|
||||
// field (a future compose/search input, say) rather than list
|
||||
// navigation.
|
||||
document.addEventListener('keydown', function(e) {
|
||||
var active = document.activeElement;
|
||||
if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA'))
|
||||
return;
|
||||
|
||||
// Delete trashes whichever message is currently open, the same
|
||||
// action (and the same dissolve-and-slide-up animation) as
|
||||
// clicking that row's own trash button - see trashMessage().
|
||||
if (e.key === 'Delete') {
|
||||
if (!document.getElementById('email-client') || currentUid === null)
|
||||
return;
|
||||
e.preventDefault();
|
||||
trashMessage(currentUid);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp')
|
||||
return;
|
||||
var rows = document.querySelectorAll('.email-row');
|
||||
if (!rows.length)
|
||||
return;
|
||||
e.preventDefault();
|
||||
|
||||
var index = -1;
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
if (parseInt(rows[i].getAttribute('data-uid'), 10) === currentUid) {
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (e.key === 'ArrowDown')
|
||||
index = (index === -1) ? 0 : Math.min(index + 1, rows.length - 1);
|
||||
else
|
||||
index = (index === -1) ? 0 : Math.max(index - 1, 0);
|
||||
|
||||
rows[index].scrollIntoView({ block: 'nearest' });
|
||||
openMessage(parseInt(rows[index].getAttribute('data-uid'), 10));
|
||||
});
|
||||
|
||||
if (document.getElementById('email-connect-form')) {
|
||||
wireConnectForm();
|
||||
} else if (document.getElementById('email-client')) {
|
||||
wireClientView();
|
||||
wireAddAccountForm();
|
||||
syncActiveFolder();
|
||||
loadMessageList();
|
||||
loadAccounts();
|
||||
}
|
||||
})();
|
||||
Reference in new issue
Block a user