'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' : ''} `;
} catch {}
}
function mailSettings() {
const box = $('#mailst'); if (!box) return;
jfetch('/api/mail/status').then(s => {
if (!$('#mailst')) return;
box.innerHTML = s.connected
? `Outlook.com ${esc(s.address || 'Connected')} Disconnect
`
: `Outlook.com ${s.expired ? 'Microsoft signed you out. ' : ''}Not connected Connect `;
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
'; });
}
// ---- 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 = '';
let st; try { st = await jfetch('/api/mail/status'); } catch (e) { v.innerHTML = ``; 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.
' : ''}
${icon('mail')}Connect Outlook.com
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 = `
${fl.map(f => `${esc(f.name)}${f.unread ? ' (' + f.unread + ')' : ''} `).join('')}
${icon('refresh')} ${icon('edit')}New
`;
$('#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 ? 'Load 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' }))}
Reply Reply all Forward
Archive Mark unread ${m.flagged ? 'Unflag' : 'Flag'} Delete
${m.attachments.length ? `
` : ''}
${remote ? '
Pictures are hidden to protect your privacy. Show pictures
' : ''}
`;
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 : '', ``);
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);
}