'; });
else box.innerHTML = '';
if (cfg.mailAdmin) mailAdminCard();
}
/** Choose a provider; Outlook.com signs in on Microsoft's page, Gmail and iCloud take an app password. */
function mailAddAccount(st) {
const choices = [['gmail', 'Gmail', 'App password'], ['icloud', 'iCloud Mail', 'App-specific password'], ...(st && st.outlook ? [['outlook', 'Outlook.com', 'Sign in with Microsoft']] : [])];
const el = openSheet('Add a mail account', '', `
${choices.map(([k, l, s]) => ``).join('')}
`);
el.querySelector('#sx').onclick = closeSheet;
el.querySelectorAll('[data-p]').forEach(b => b.onclick = async () => {
const k = b.dataset.p;
if (k === 'outlook') { try { const r = await jfetch('/api/mail/connect'); location.href = r.url; } catch (e) { toast(e.message); } return; }
mailPasswordForm(k);
});
}
function mailPasswordForm(type) {
const g = type === 'gmail';
const el = openSheet('Add ' + (g ? 'Gmail' : 'iCloud Mail'), '', ``);
el.querySelector('#sx').onclick = closeSheet;
el.querySelector('#paf').onsubmit = async e => {
e.preventDefault(); const btn = el.querySelector('#pab'); btn.disabled = true; btn.textContent = 'Checking…'; el.querySelector('#pax').textContent = '';
try { await jfetch('/api/mail/accounts', { method: 'POST', body: JSON.stringify({ type, address: el.querySelector('#pae').value, password: el.querySelector('#pap').value }) }); closeSheet(); toast('Connected'); mail.st = null; mail.items = []; mail.folders = []; mail.acct = ''; await mailPoll(); if (location.hash.startsWith('#/mail')) route(); else if ($('#mailst')) mailSettings(); }
catch (err) { btn.disabled = false; btn.textContent = 'Connect'; el.querySelector('#pax').textContent = err.message; }
};
setTimeout(() => el.querySelector('#pae').focus(), 60);
}
/** Admin: the Microsoft app behind Mail (client ID, secret, expiry), with the redirect address to register and a test button. */
async function mailAdminCard() {
let host = $('#mailadm');
if (!host) { $('#sec-mail').insertAdjacentHTML('beforeend', ''); host = $('#mailadm'); }
let a; try { a = await jfetch('/api/mail/admin'); } catch (e) { host.innerHTML = `
The client secret expired ${-a.days} day${a.days === -1 ? '' : 's'} ago. Mail cannot connect until you replace it.
`
: a.days <= 30 ? `
The client secret expires in ${a.days} day${a.days === 1 ? '' : 's'} (${esc(a.secretExpires)}). Make a new one in Microsoft Entra and paste it below.
` : '';
host.innerHTML = `
Microsoft app (admin)
Mail uses a free Microsoft app registration. ${a.source === 'env' ? 'This server gets it from its environment settings (MS_CLIENT_ID), so change it there.' : a.source === 'app' ? 'Saved here, encrypted on your server.' : 'Not set up yet: follow the steps below, then paste the values here.'}
${warn}
${a.verifiedAt ? '✓ Last successful connection: ' + esc(new Date(a.verifiedAt).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })) : a.clientId ? 'Not proven yet. Open the Mail tab and press Connect: Microsoft checks everything then.' : ''}
It must match exactly, and be https (set PUBLIC_URL on the server if this shows the wrong address).
${a.source === 'env' ? `
Client secret: ${a.hasSecret ? 'set' : 'not set'}
` : ``}
How to register the app (about 5 minutes)
Go to entra.microsoft.com and sign in with any Microsoft account. Accept the free "Default Directory" if asked.
Entra ID → App registrations → New registration. Name it NE Hub, choose Personal Microsoft accounts only, set the redirect platform to Web, paste the redirect address above, press Register.
Copy the Application (client) ID from the Overview page into the first box above.
API permissions → Add a permission → Microsoft Graph → Delegated: Mail.ReadWrite, Mail.Send, User.Read, offline_access.
Certificates & secrets → New client secret (24 months). Copy its Value now (shown once) into the secret box, and set the expiry date.
Press Save, then open the Mail tab and press Connect. Microsoft checks the ID, secret and redirect address at that moment; if something is wrong it says what.
`;
$('#mcopy').onclick = async () => { try { await navigator.clipboard.writeText(a.redirect); toast('Copied'); } catch { $('#mredir').select(); toast('Press Ctrl/Cmd-C to copy'); } };
const f = $('#madm'); if (!f) return;
const res = (t, good) => { const el = $('#mres'); el.textContent = t; el.style.color = good ? 'var(--accent)' : ''; };
const save = async extra => jfetch('/api/mail/admin', { method: 'POST', body: JSON.stringify({ clientId: $('#mcid').value, clientSecret: $('#mcsec').value, secretExpires: $('#mcexp').value, ...(extra || {}) }) });
f.onsubmit = async e => { e.preventDefault(); res(''); try { await save(); const r = await jfetch('/api/config'); cfg = { ...cfg, ...r }; toast('Saved. Now open the Mail tab and press Connect.'); renderNav(); mailSettings(); } catch (err) { res(err.message); } };
if ($('#mclear')) $('#mclear').onclick = () => confirmSheet('Remove the Microsoft app settings?', 'Mail stops working for everyone until you set it up again. Connected mailboxes stay saved.', 'Remove', async () => { try { await jfetch('/api/mail/admin', { method: 'POST', body: JSON.stringify({ clientId: '', clearSecret: true, secretExpires: '' }) }); const c = await jfetch('/api/config'); cfg = { ...cfg, ...c }; renderNav(); mailSettings(); } catch (e) { toast(e.message); } });
}
// ---- the tab
async function viewMail(args = []) {
const v = $('#view');
if (!cfg.mail) { v.innerHTML = '
Mail
Mail is not available to you.
'; return; }
// Moving between messages (or back to the list) in the same folder changes only the reader and the highlighted row: the page is not rebuilt,
// so there is no flash, and the list keeps its place.
const shell = document.querySelector('.mailapp'), k0 = args[0], key0 = args[1];
if (shell && v.contains(shell) && mail.st && mail.st.connected && mail.accts.length && (k0 === 'm' || k0 === undefined) && (k0 !== 'm' || !String(key0).includes('~') || String(key0).split('~')[0] === mail.acct)) {
const view = $('#view');
if (k0 === 'm') { if (shell.dataset.mode !== 'read') mail.listScroll = view.scrollTop; shell.dataset.mode = 'read'; mailMarkSel(key0); mailOpen(key0); }
else { shell.dataset.mode = 'list'; mail.msg = null; mailDrawReader(); mailMarkSel(''); requestAnimationFrame(() => { if (mail.listScroll) view.scrollTop = mail.listScroll; }); }
return;
}
// The accounts come from the server without any network call to the mail providers ("light"), so the page can draw at once; the folders, the message
// list and the real unread counts then load side by side instead of one after another.
let st = mail.st;
if (!st) {
const saved = mcGet('st'); // the accounts as of last time: draw from them now and check with the server behind it
if (saved && saved.connected) {
st = saved; mail.st = saved;
jfetch('/api/mail/status?light=1').then(fresh => { if (!fresh.connected || fresh.accounts.map(a => a.id).join() !== saved.accounts.map(a => a.id).join()) { mail.st = null; mcClear(); if (location.hash.startsWith('#/mail')) route(); } else { mail.st = { ...fresh, accounts: fresh.accounts.map(a => ({ ...a, unread: (mail.st.accounts.find(x => x.id === a.id) || {}).unread ?? a.unread })) }; mcPut('st', mail.st); } }).catch(() => {});
} else {
v.innerHTML = '
`; return; }
if (st.connected) mcPut('st', { connected: true, accounts: st.accounts, outlook: st.outlook, providers: st.providers });
}
}
if (!location.hash.startsWith('#/mail')) return;
mail.st = st; mail.accts = st.accounts || [];
if (!st.connected) { mail.st = null; mcClear(); return mailConnectScreen(st); }
const [kind, key] = args;
const idAcct = x => String(x || '').includes('~') ? String(x).split('~')[0] : '';
const want = idAcct(kind === 'f' ? key : kind === 'm' ? key : '') || (mail.accts.some(a => a.id === mail.acct) ? mail.acct : mail.accts[0].id);
if (want !== mail.acct) { mail.acct = want; mail.folders = []; mail.items = []; if (!(kind === 'f' && idAcct(key))) mail.folder = want + '~inbox'; }
if (kind === 'f' && key) mail.folder = idAcct(key) ? key : mail.acct + '~' + key;
else if (!String(mail.folder).startsWith(mail.acct + '~')) mail.folder = mail.acct + '~inbox';
if (!mail.folders.length) { const f = mcGet('f:' + mail.acct); if (f) mail.folders = f.folders; }
mailShell(kind === 'm' && key ? 'read' : 'list');
const acct = mail.acct;
if (!mail.items.length || kind === 'f' || kind === undefined) mailLoadList(true); else mailDrawList();
if (kind === 'm' && key) mailOpen(key); else { mail.msg = null; mailDrawReader(); }
// folders (with counts) and the real unread numbers arrive when they arrive
jfetch('/api/mail/folders?acct=' + encodeURIComponent(acct)).then(f => { if (mail.acct !== acct) return; mail.folders = f; mcPut('f:' + acct, { folders: f }); mailRefreshFolders(); }).catch(() => {});
mailPoll().then(() => { if (mail.st && location.hash.startsWith('#/mail')) mailRefreshFolders(); });
}
/** One button per account with its own unread count, so you can see which one has new mail without switching. */
const mailChips = () => `
${mail.accts.map(a => ``).join('')}
`;
function mailBindChips() { document.querySelectorAll('.maccts button').forEach(b => b.onclick = () => { if (b.dataset.a === mail.acct) return; mail.q = ''; location.hash = '#/mail/f/' + encodeURIComponent(b.dataset.a + '~inbox'); }); }
function mailDrawChips() { if (mail.accts.length < 2) return; document.querySelectorAll('.maccts').forEach(el => el.outerHTML = mailChips()); mailBindChips(); }
/** Redraw the folder pickers (names and unread counts) without touching the list or the open message. */
function mailRefreshFolders() {
mailDrawChips();
const fl = mail.folders; if (!fl.length) return;
const f = $('#mfsel'); if (f) f.innerHTML = fl.map(x => ``).join('');
const side = document.querySelector('.mfold'); if (side) { const sel = side.querySelector('select'); side.innerHTML = (mail.accts.length > 1 ? mailChips() : '') + fl.map(x => `${esc(x.name)}${x.unread ? `${x.unread}` : ''}`).join(''); mailBindChips(); }
}
function mailConnectScreen(st) {
const choices = [['gmail', 'Gmail', 'Uses an app password', '#d9453a'], ['icloud', 'iCloud Mail', 'Uses an app-specific password', '#3b97e8'], ...(st.outlook ? [['outlook', 'Outlook.com', 'Sign in with Microsoft', '#0a64c8']] : [])];
$('#view').innerHTML = `
Mail
Read and send mail from your own accounts right here, all in one place. Passwords are checked with the provider and kept encrypted on your server; Outlook.com signs in on Microsoft's own page.
${st.expired ? '
Microsoft signed you out of mail. Connect again to continue.
' : ''}
${choices.map(([k, l, s, c]) => ``).join('')}
${st.outlook ? '' : '
Outlook.com needs a one-time Microsoft app set up by an admin (Settings → Microsoft app).
`; // (what the list already knows, at once)
mail.images = false;
try {
const m = await mailFetch(id);
if (decodeURIComponent((location.hash.match(/^#\/mail\/m\/([^/]+)/) || [])[1] || '') !== id) return; // you moved on to another message while this one loaded
mail.msg = m; mailDrawReader(); mailDrawList();
if (!m.read) { // opening a message marks it read
const inbox = mailInInbox();
jfetch('/api/mail/update', { method: 'POST', body: JSON.stringify({ id, read: true }) }).then(() => {
m.read = true; mailSetRead(id, true); mailDrawList();
if (inbox) { mailUnread = Math.max(0, mailUnread - 1); renderNav(); }
mailPoll(); // the real count (the server drops its saved one when a message changes)
jfetch('/api/mail/folders?acct=' + encodeURIComponent(mail.acct)).then(f => { mail.folders = f; mailRefreshFolders(); }).catch(() => {});
}).catch(e => toast('Could not mark it as read: ' + e.message));
}
} catch (e) { if ($('#mread')) $('#mread').innerHTML = `
${esc(e.message)}
`; }
}
function mailDrawReader() {
const rd = $('#mread'); if (!rd) return;
const m = mail.msg;
if (!m) { rd.innerHTML = '