'use strict'; // Mail tab: your Outlook.com mailbox (through the server, see mail.js there). Folders, message list, reader, compose, reply/forward, // search, unread badge. Message bodies are shown in a sandboxed frame with remote pictures blocked until you ask for them. let mailUnread = 0; const mail = { st: null, folders: [], folder: 'inbox', items: [], more: false, next: 0, q: '', msg: null, images: false, loading: false }; const mailFrom = f => (f && (f.name || f.address)) || ''; const mailWhen = iso => { const d = new Date(iso), now = new Date(); if (isNaN(d)) return ''; if (d.toDateString() === now.toDateString()) return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); if (now - d < 6 * 864e5) return d.toLocaleDateString([], { weekday: 'short' }); return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: d.getFullYear() === now.getFullYear() ? undefined : '2-digit' }); }; const mailFolderName = id => (mail.folders.find(f => f.id === id) || {}).name || (id === 'inbox' ? 'Inbox' : 'Folder'); /** The page a message is shown in: no scripts, nothing loaded from the internet unless the person allows pictures. */ function mailDoc(html, images) { const csp = `default-src 'none'; img-src data: ${images ? 'https: http:' : ''}; style-src 'unsafe-inline'; font-src data:`; return ` ${html}`; } const mailHasRemote = html => /]+src=["']?https?:|url\(\s*["']?https?:/i.test(html || ''); // ---- unread badge and Home card async function mailPoll() { if (!(allowed('mail') && cfg.mail)) return; try { const s = await jfetch('/api/mail/status'); mail.st = s; const n = s.connected && s.unread ? s.unread : 0; if (n !== mailUnread) { mailUnread = n; renderNav(); } } catch {} } async function renderMailCard(el) { if (!el) return; try { const s = mail.st || await jfetch('/api/mail/status'); mail.st = s; if (!s.connected || !document.body.contains(el)) return; const r = await jfetch('/api/mail/messages?folder=inbox'); if (!document.body.contains(el)) return; const top = r.items.filter(m => !m.read).slice(0, 3); el.innerHTML = `

Mail${s.unread ? ' · ' + s.unread + ' unread' : ''}

${top.length ? top.map(m => `${icon('mail')}${esc(mailFrom(m.from))}${esc(m.subject)}`).join('') : `${icon('mail')}Inbox is clear${esc(s.address || '')}`}
`; } catch {} } function mailSettings() { const box = $('#mailst'); if (!box) return; if (allowed('mail') && cfg.mail) jfetch('/api/mail/status').then(s => { if (!$('#mailst')) return; box.innerHTML = s.connected ? `
Outlook.com${esc(s.address || 'Connected')}
` : `
Outlook.com${s.expired ? 'Microsoft signed you out. ' : ''}Not connectedConnect
`; const d = $('#mdis'); if (d) d.onclick = () => confirmSheet('Disconnect mail?', 'NE Hub forgets your Outlook.com sign-in. Your mailbox is not touched.', 'Disconnect', async () => { try { await jfetch('/api/mail/disconnect', { method: 'POST', body: '{}' }); mail.st = null; mailUnread = 0; renderNav(); mailSettings(); } catch (e) { toast(e.message); } }); }).catch(() => { box.innerHTML = '
Could not check
'; }); else box.innerHTML = ''; if (cfg.mailAdmin) mailAdminCard(); } /** 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 = `

${esc(e.message)}

`; return; } if (!$('#mailadm')) return; const warn = a.days === null ? '' : a.days < 0 ? `

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'}

` : `

Microsoft secrets last at most 24 months. NE Hub warns you a month before.

${a.source === 'app' ? '
' : ''}
`}
How to register the app (about 5 minutes)
  1. Go to entra.microsoft.com and sign in with any Microsoft account. Accept the free "Default Directory" if asked.
  2. 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.
  3. Copy the Application (client) ID from the Overview page into the first box above.
  4. API permissions → Add a permission → Microsoft Graph → Delegated: Mail.ReadWrite, Mail.Send, User.Read, offline_access.
  5. Certificates & secrets → New client secret (24 months). Copy its Value now (shown once) into the secret box, and set the expiry date.
  6. 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 set up on this server. An admin can switch it on (see the README, Mail).

'; return; } v.innerHTML = '

Mail

Loading…

'; let st; try { st = await jfetch('/api/mail/status'); } catch (e) { v.innerHTML = `

Mail

${esc(e.message)}

`; return; } if (!location.hash.startsWith('#/mail')) return; mail.st = st; if (!st.connected) return mailConnectScreen(st); if (typeof st.unread === 'number' && st.unread !== mailUnread) { mailUnread = st.unread; renderNav(); } const [kind, key] = args; if (kind === 'f' && key) mail.folder = key; if (!mail.folders.length) { try { mail.folders = await jfetch('/api/mail/folders'); } catch {} } mailShell(kind === 'm' && key ? 'read' : 'list'); if (!mail.items.length || kind === 'f' || kind === undefined) mailLoadList(true); else mailDrawList(); if (kind === 'm' && key) mailOpen(key); else { mail.msg = null; mailDrawReader(); } } function mailConnectScreen(st) { $('#view').innerHTML = `

Mail

Read and send mail from your Outlook.com account right here. You sign in on Microsoft's own page; NE Hub never sees your password and only keeps a sign-in token on your server (encrypted).

${st.expired ? '

Microsoft signed you out of mail. Connect again to continue.

' : ''}

Personal Outlook.com, Hotmail and Live accounts only.

`; $('#mcon').onclick = async () => { try { const r = await jfetch('/api/mail/connect'); location.href = r.url; } catch (e) { $('#mce').textContent = e.message; } }; } function mailShell(mode) { const fl = mail.folders.length ? mail.folders : [{ id: 'inbox', name: 'Inbox', unread: mailUnread }]; $('#view').innerHTML = `
`; $('#mfsel').onchange = e => { mail.q = ''; location.hash = '#/mail/f/' + encodeURIComponent(e.target.value); }; $('#mnew').onclick = () => mailCompose(); $('#mrf').onclick = () => { mailLoadList(true); jfetch('/api/mail/folders').then(f => { mail.folders = f; mailPoll(); }).catch(() => {}); }; let t; $('#mq').oninput = e => { clearTimeout(t); t = setTimeout(() => { mail.q = e.target.value.trim(); mailLoadList(true); }, 400); }; } async function mailLoadList(reset) { if (reset) { mail.items = []; mail.next = 0; mail.more = false; } const rows = $('#mrows'); if (rows && reset) rows.innerHTML = '

Loading…

'; try { const r = await jfetch(`/api/mail/messages?folder=${encodeURIComponent(mail.folder)}&skip=${mail.next}${mail.q ? '&q=' + encodeURIComponent(mail.q) : ''}`); if (!$('#mrows')) return; mail.items = mail.items.concat(r.items); mail.more = r.more; mail.next = r.next; mailDrawList(); } catch (e) { const el = $('#mrows'); if (el) el.innerHTML = `

${esc(e.message)}

`; if (/Connect again/.test(e.message)) { mail.st = null; setTimeout(route, 1200); } } } function mailDrawList() { const rows = $('#mrows'); if (!rows) return; const sel = (location.hash.match(/^#\/mail\/m\/([^/]+)/) || [])[1]; rows.innerHTML = mail.items.length ? mail.items.map(m => ` ${esc(mailFrom(m.from))}${esc(m.subject)}${esc(m.preview)} ${esc(mailWhen(m.date))}${m.flagged ? icon('check') : ''}${m.attach ? icon('clip') : ''}`).join('') : `

${mail.q ? 'Nothing matches.' : 'No messages here.'}

`; const more = $('#mmore'); more.innerHTML = mail.more ? '
' : ''; if ($('#mmb')) $('#mmb').onclick = () => mailLoadList(false); } async function mailOpen(id) { const rd = $('#mread'); if (rd) rd.innerHTML = '

Opening…

'; mail.images = false; try { const m = await jfetch('/api/mail/message?id=' + encodeURIComponent(id)); if (!(location.hash.startsWith('#/mail/m/'))) return; mail.msg = m; mailDrawReader(); mailDrawList(); if (!m.read) { // opening a message marks it read jfetch('/api/mail/update', { method: 'POST', body: JSON.stringify({ id, read: true }) }).then(() => { const it = mail.items.find(x => x.id === id); if (it) it.read = true; m.read = true; mailDrawList(); mailUnread = Math.max(0, mailUnread - 1); renderNav(); jfetch('/api/mail/folders').then(f => { mail.folders = f; }).catch(() => {}); }).catch(() => {}); } } 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 = '

Choose a message to read it.

'; return; } const addr = l => l.map(a => esc(a.name ? `${a.name} <${a.address}>` : a.address)).join(', '); const remote = mailHasRemote(m.html) && !mail.images; rd.innerHTML = `
${icon('back')}${esc(mailFolderName(mail.folder))}

${esc(m.subject)}

${esc(mailFrom(m.from))} ${esc(m.from.address)}

To ${addr(m.to) || 'me'}${m.cc.length ? ' · Cc ' + addr(m.cc) : ''} · ${esc(new Date(m.date).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }))}

${m.attachments.length ? `` : ''} ${remote ? '
Pictures are hidden to protect your privacy.
' : ''}
`; if ($('#mshow')) $('#mshow').onclick = () => { mail.images = true; mailDrawReader(); }; rd.querySelectorAll('[data-a]').forEach(b => b.onclick = () => mailAction(b.dataset.a)); } async function mailAction(a) { const m = mail.msg; if (!m) return; const post = (p, body) => jfetch('/api/mail/' + p, { method: 'POST', body: JSON.stringify({ id: m.id, ...body }) }); const back = () => { mail.items = mail.items.filter(x => x.id !== m.id); mail.msg = null; location.hash = '#/mail/f/' + encodeURIComponent(mail.folder); mailPoll(); jfetch('/api/mail/folders').then(f => { mail.folders = f; }).catch(() => {}); }; try { if (a === 'reply' || a === 'replyAll' || a === 'forward') return mailCompose({ mode: a, msg: m }); if (a === 'archive') { await post('move', { to: 'archive' }); toast('Archived'); return back(); } if (a === 'unread') { await post('update', { read: false }); const it = mail.items.find(x => x.id === m.id); if (it) it.read = false; toast('Marked unread'); mailUnread++; renderNav(); return back(); } if (a === 'flag') { await post('update', { flagged: !m.flagged }); m.flagged = !m.flagged; const it = mail.items.find(x => x.id === m.id); if (it) it.flagged = m.flagged; mailDrawReader(); return mailDrawList(); } if (a === 'delete') { if (mail.folder === 'deleteditems') return confirmSheet('Delete for good?', m.subject, 'Delete', async () => { try { await post('delete', { permanent: true }); toast('Deleted'); back(); } catch (e) { toast(e.message); } }); await post('delete', {}); toast('Moved to Deleted'); return back(); } } catch (e) { toast(e.message); } } // ---- compose, reply, forward const MAIL_MAX = 3 * 1024 * 1024; function mailCompose(o = {}) { const reply = o.mode, m = o.msg, files = []; const title = reply === 'reply' ? 'Reply' : reply === 'replyAll' ? 'Reply all' : reply === 'forward' ? 'Forward' : 'New message'; const el = openSheet(title, reply ? m.subject : '', `
${reply && reply !== 'forward' ? `

To ${esc(reply === 'reply' ? mailFrom(m.from) : 'everyone on the message')}. The original is quoted below your text automatically.

` : ``} ${reply ? '' : ` `} ${reply ? '' : `
`}
`); el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet; const cc = el.querySelector('#mcc'); if (cc) cc.onclick = e => { e.preventDefault(); el.querySelector('#mcb').hidden = false; el.querySelector('#mccw').hidden = true; }; const fi = el.querySelector('#mfile'); const total = () => files.reduce((n, f) => n + f.size, 0); const drawFiles = () => { el.querySelector('#mfl').textContent = files.length ? files.map(f => f.name).join(', ') + ' (' + fmtSize(total()) + ')' : ''; }; if (fi) fi.onchange = () => { for (const f of fi.files) files.push(f); fi.value = ''; if (total() > MAIL_MAX) { files.pop(); el.querySelector('#mer').textContent = 'Attachments can total about 3 MB.'; } drawFiles(); }; const b64 = f => new Promise((ok, no) => { const r = new FileReader(); r.onload = () => ok(String(r.result).split(',')[1] || ''); r.onerror = () => no(new Error('Could not read ' + f.name)); r.readAsDataURL(f); }); el.querySelector('#mf').onsubmit = async e => { e.preventDefault(); const btn = el.querySelector('#msend'); btn.disabled = true; el.querySelector('#mer').textContent = ''; try { const body = el.querySelector('#mbd').value; if (reply) await jfetch('/api/mail/reply', { method: 'POST', body: JSON.stringify({ id: m.id, mode: reply, comment: body, to: reply === 'forward' ? el.querySelector('#mto').value : undefined }) }); else await jfetch('/api/mail/send', { method: 'POST', body: JSON.stringify({ to: el.querySelector('#mto').value, cc: el.querySelector('#mcc2').value, bcc: el.querySelector('#mbcc').value, subject: el.querySelector('#msb').value, body, attachments: await Promise.all(files.map(async f => ({ name: f.name, type: f.type, data: await b64(f) }))) }) }); closeSheet(); toast('Sent'); } catch (err) { btn.disabled = false; el.querySelector('#mer').textContent = err.message; } }; setTimeout(() => { const f = el.querySelector('#mto') || el.querySelector('#mbd'); if (f) f.focus(); }, 60); }