Files
Brad ArantandClaude Sonnet 5 b8f7793773 Add global music player, invoicing/payment system, account subpages, mail account admin, and the qualityiron.vegas Jet 2.0 port
Jet interpreter (Tag.cpp, __call.cpp, __imap*.cpp/h, __smtpsend.cpp/h,
__imapemptyfolder.cpp/h - new): assorted parser/tag fixes found across
this work - a stray literal-tag-in-comment and backslash-quote parser
corruption class, a dangling-pointer fix, a __call.cpp fd leak, IMAP
folder-management tags, multi-account/per-host IMAP support, and a
smtpsend Message-ID fix.

barant.com site (jetc/barant-site/):
- A site-wide persistent music player (header band, playlist/queue,
  stereo FFT EQ, session-persisted state) fed by new library/
  ianslibrary/music song-list subpages.
- /account split into info/phones/invoices subpages; a full invoicing
  and Square payment system (create/edit/copy/pay/email invoice);
  admin invoice management and mail-account management subpages.
- /services split into overview/hosting/siptrunking/documents
  subpages, retiring the old standalone pages.
- SIPPHONE.tag: a Jet 2.0 WebRTC softphone tag.
- main.css/spa.js/email.js: the SPA loading indicator, the music
  player band styling, and assorted fixes accumulated alongside this
  work.

qualityiron-site/: the qualityiron.vegas job/contractor/customer/
document management app, ported from its original Jet 1.4 codebase to
Jet 2.0 as barant.coms APIServers second multi-tenant site.

wcwc/wcwc.jet: the WCWC site Jet source (wcwc.jtc build output not
included).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-06 17:42:01 +00:00

882 lines
38 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(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>',
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>',
pagePrev:
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' +
'<path d="M15 18l-6-6 6-6"/>' +
'</svg>',
pageNext:
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' +
'<path d="M9 18l6-6-6-6"/>' +
'</svg>',
emptyFolder:
'<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="M9.5 12.5l5 5M14.5 12.5l-5 5"/>' +
'</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;
// How many newest-first messages /email/list has already skipped -
// 0 is the newest page. Must match LIST_LIMIT server-side (email.jet
// defaults "limit" to 50 too) so a page is exactly one "limit"
// worth of messages. Reset to 0 everywhere currentFolder/currentUid
// already get reset (folder switch, search, account switch) - a
// stale offset from one folder/account applied to another would
// silently skip or duplicate messages.
var currentOffset = 0;
var LIST_LIMIT = 50;
// Total message count the server reported for the CURRENT page's
// folder/query, used only to enable/disable the Next button -
// updated every time updateListCount() runs.
var currentTotal = 0;
// 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;
currentOffset = 0;
// 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);
// Only Spam/Trash ever offer this - emptying Inbox (or any future
// folder) as a single destructive click isn't something any real
// mail client exposes, and the server enforces the exact same
// Spam/Trash-only allowlist independently (see email.jet) - this is
// just keeping the button from ever being visible somewhere it
// would 400 anyway, not the actual safety boundary.
var emptyBtn = document.getElementById('email-empty-folder-btn');
if (emptyBtn)
emptyBtn.hidden = (currentFolder !== 'Spam' && currentFolder !== 'Trash');
}
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-pagination"></span>' +
'<span id="email-toolbar-actions">' +
'<span id="email-search-box">' +
'<input type="text" id="email-search-input" placeholder="Search mail&hellip;">' +
'<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-empty-folder-btn" class="icon-btn" title="Permanently empty this folder" hidden>' + ICONS.emptyFolder + '</button>' +
'<button type="button" id="email-refresh-btn" class="icon-btn" title="Refresh">' + ICONS.refresh + '</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-empty-folder-btn').addEventListener('click', emptyCurrentFolder);
// 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;
currentOffset = 0;
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 + ')';
}
// total === null means "not paginated" (search results, or nothing
// loaded yet) - hides the whole control rather than showing a
// meaningless "1-0 of null". Otherwise renders the current page's
// 1-based range and enables Prev/Next based on currentOffset/total,
// matching how far a click on either button would actually move.
function updatePaginationControls(total) {
var el = document.getElementById('email-pagination');
if (!el)
return;
currentTotal = total || 0;
if (total === null || total === 0) {
el.innerHTML = '';
return;
}
var rangeStart = total === 0 ? 0 : currentOffset + 1;
var rangeEnd = Math.min(currentOffset + LIST_LIMIT, total);
var atStart = currentOffset <= 0;
var atEnd = currentOffset + LIST_LIMIT >= total;
el.innerHTML =
'<button type="button" id="email-page-prev" class="icon-btn"' + (atStart ? ' disabled' : '') + ' title="Newer messages">' + ICONS.pagePrev + '</button>' +
'<span id="email-page-range">' + rangeStart + '–' + rangeEnd + ' of ' + total + '</span>' +
'<button type="button" id="email-page-next" class="icon-btn"' + (atEnd ? ' disabled' : '') + ' title="Older messages">' + ICONS.pageNext + '</button>';
var prevBtn = document.getElementById('email-page-prev');
var nextBtn = document.getElementById('email-page-next');
if (prevBtn) prevBtn.addEventListener('click', function() {
if (currentOffset <= 0)
return;
currentOffset = Math.max(0, currentOffset - LIST_LIMIT);
currentUid = null;
loadMessageList();
});
if (nextBtn) nextBtn.addEventListener('click', function() {
if (currentOffset + LIST_LIMIT >= currentTotal)
return;
currentOffset += LIST_LIMIT;
currentUid = null;
loadMessageList();
});
}
function renderMessageList(messages, total) {
var list = document.getElementById('email-list');
currentMessages = messages;
updateListCount(messages);
updatePaginationControls(total === undefined ? null : total);
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>';
});
});
}
// Permanently deletes every message in the CURRENT folder (only ever
// reachable while viewing Spam or Trash - see syncActiveFolder()'s own
// button-visibility gate, and email.jet's independent server-side
// allowlist, which is the real enforcement). Unlike trashMessage()
// there's no undo, no "move somewhere" - a real, human-readable confirm()
// is the whole safety net here, matching how Gmail/Outlook both gate
// "Empty Trash now" behind exactly one extra click and nothing fancier.
function emptyCurrentFolder() {
if (currentFolder !== 'Spam' && currentFolder !== 'Trash')
return;
if (!window.confirm('Permanently delete every message in ' + currentFolder + '? This cannot be undone.'))
return;
var btn = document.getElementById('email-empty-folder-btn');
if (btn) btn.disabled = true;
post('/email/emptyfolder', { folder: currentFolder })
.then(function(res) {
if (btn) btn.disabled = false;
if (!res.ok) {
window.alert(res.error || 'Could not empty the folder.');
return;
}
currentUid = null;
document.getElementById('email-reading-pane').innerHTML =
'<div id="email-empty-state">Select a message to read it.</div>';
loadMessageList();
})
.catch(function() {
if (btn) btn.disabled = false;
window.alert('Empty-folder request failed.');
});
}
// 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. Search isn't paginated (offset/limit only apply
// to the plain listing) - a search result set is returned in full.
var request = currentSearchQuery
? post('/email/search', { folder: currentFolder, query: currentSearchQuery })
: post('/email/list', { folder: currentFolder, offset: currentOffset, limit: LIST_LIMIT });
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 if (currentSearchQuery)
renderMessageList(Array.isArray(res) ? res : [], null);
else
renderMessageList(Array.isArray(res.messages) ? res.messages : [], res.total || 0);
// 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(err) {
console.error('loadMessageList failed:', err);
list.innerHTML = '<div id="email-empty-state">Could not load messages' +
(err && err.message ? ': ' + escapeHtml(err.message) : '.') + '</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) {
console.error('loadFolderCounts server error:', 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(err) {
console.error('loadFolderCounts failed:', err);
});
}
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;
currentOffset = 0;
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(err) {
console.error('openMessage failed:', err);
pane.innerHTML = '<div id="email-empty-state">Could not load message' +
(err && err.message ? ': ' + escapeHtml(err.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();
}
})();