'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; 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
'; }); } // ---- 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); }