'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, accts: [], acct: '', folders: [], folder: 'inbox', items: [], more: false, next: 0, q: '', msg: null, images: false, loading: false }; // The last mail you looked at is kept on this device (subjects, senders, dates; no message bodies) so Mail can show it the instant you open the tab // while the fresh copy loads behind it. Removed when you disconnect an account or sign out. const mcKey = k => 'mc:' + (ME ? ME.id : '') + ':' + k; const mcGet = k => { try { const o = JSON.parse(localStorage.getItem(mcKey(k))); return o && o.t ? o : null; } catch { return null; } }; function mcPut(k, v) { try { localStorage.setItem(mcKey(k), JSON.stringify({ t: Date.now(), ...v })); const ix = mcKey('index'), list = (JSON.parse(localStorage.getItem(ix) || '[]')).filter(x => x !== k); list.unshift(k); for (const old of list.splice(16)) localStorage.removeItem(mcKey(old)); localStorage.setItem(ix, JSON.stringify(list)); } catch { /* storage full or blocked: go without */ } } function mcClear() { if (typeof mbClear === 'function') mbClear(); try { for (const k of Object.keys(localStorage)) if (k.startsWith('mc:' + (ME ? ME.id : '') + ':')) localStorage.removeItem(k); } catch {} } 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 || (/~inbox$/.test(id) || 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; mail.accts = s.accounts || mail.accts; const n = s.connected && s.unread ? s.unread : 0; if (n !== mailUnread) { mailUnread = n; renderNav(); } if (location.hash.startsWith('#/mail') && typeof mailDrawChips === 'function') mailDrawChips(); } 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 lists = await Promise.all(s.accounts.slice(0, 4).map(a => jfetch('/api/mail/messages?folder=' + encodeURIComponent(a.id + '~inbox')).then(r => r.items.map(x => ({ ...x, acct: a.address }))).catch(() => []))); if (!document.body.contains(el)) return; const top = lists.flat().filter(m => !m.read).sort((a, b) => String(b.date).localeCompare(String(a.date))).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)}${s.accounts.length > 1 ? ' · ' + esc(m.acct) : ''}`).join('') : `${icon('mail')}Inbox is clear${esc(s.accounts.map(a => a.address).join(', '))}`}
`; } catch {} } const MAIL_LABEL = { outlook: 'Outlook.com', gmail: 'Gmail', icloud: 'iCloud Mail' }; 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.accounts.length ? s.accounts.map(a => `
${esc(MAIL_LABEL[a.type] || a.type)}${esc(a.address || 'Connected')}${a.error ? ' · ' + esc(a.error) : ''}
`).join('') : `
No mail account yet${s.expired ? 'Microsoft signed you out. ' : ''}Add Outlook.com, Gmail or iCloud Mail
`) + `
`; $('#madd').onclick = () => mailAddAccount(s); box.querySelectorAll('[data-dis]').forEach(d => d.onclick = () => { const a = s.accounts.find(x => x.id === d.dataset.dis); confirmSheet('Disconnect ' + (a ? a.address : 'this account') + '?', 'NE Hub forgets the sign-in. Your mailbox is not touched.', 'Disconnect', async () => { try { await jfetch('/api/mail/disconnect', { method: 'POST', body: JSON.stringify({ acct: d.dataset.dis }) }); mail.st = null; mcClear(); mail.items = []; mail.folders = []; mail.acct = ''; await mailPoll(); mailSettings(); } catch (e) { toast(e.message); } }); }); }).catch(() => { box.innerHTML = '
Could not check
'; }); 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'), '', `

${g ? 'Google needs an app password, not your normal password. Turn on 2-Step Verification, then make one at myaccount.google.com/apppasswords (a 16-letter code).' : 'Apple needs an app-specific password. Go to appleid.apple.com → Sign-In and Security → App-Specific Passwords, and make one for NE Hub.'}

It is checked with ${g ? 'Google' : 'Apple'} before saving, and kept encrypted on your server. NE Hub never sees your normal password.

`); 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 = `

${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 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 = '

Mail

Loading…

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

Mail

${esc(e.message)}

`; 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).

'}
`; $('#view').querySelectorAll('[data-p]').forEach(b => b.onclick = async () => { if (b.dataset.p === 'outlook') { try { const r = await jfetch('/api/mail/connect'); location.href = r.url; } catch (e) { $('#mce').textContent = e.message; } } else mailPasswordForm(b.dataset.p); }); } function mailShell(mode) { mail.sel = null; const fl = mail.folders.length ? mail.folders : [{ id: 'inbox', name: 'Inbox', unread: mailUnread }]; $('#view').innerHTML = `
${mail.accts.length > 1 ? mailChips() : ''}
`; $('#mfsel').onchange = e => { mail.q = ''; location.hash = '#/mail/f/' + encodeURIComponent(e.target.value); }; mailBindChips(); $('#mnew').onclick = () => mailCompose(); $('#msel').onclick = () => mailSelMode(!mail.sel); $('#mrf').onclick = () => { mailLoadList(true); jfetch('/api/mail/folders?acct=' + encodeURIComponent(mail.acct)).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) { const folder = mail.folder, first = reset && !mail.q; if (reset) { mail.items = []; mail.next = 0; mail.more = false; const saved = first && mcGet('l:' + folder); if (saved) { mail.items = saved.items; mail.more = saved.more; mail.next = saved.next; mailDrawList(); } // last time's list, right away else { const rows = $('#mrows'); if (rows) rows.innerHTML = '

Loading…

'; } } const showing = reset && mail.items.length; // a saved list is on screen: the fresh one replaces it instead of being added to it try { const r = await jfetch(`/api/mail/messages?folder=${encodeURIComponent(folder)}&skip=${showing ? 0 : mail.next}${mail.q ? '&q=' + encodeURIComponent(mail.q) : ''}`); if (!$('#mrows') || mail.folder !== folder) return; mail.items = showing ? mailApplyMine(r.items) : mail.items.concat(mailApplyMine(r.items)); mail.more = r.more; mail.next = r.next; if (first) mcPut('l:' + folder, { items: r.items, more: r.more, next: r.next }); mailDrawList(); mailPrefetch(); } catch (e) { if (showing) return; const el = $('#mrows'); if (el) el.innerHTML = `

${esc(e.message)}

`; if (/Connect again/.test(e.message)) { mail.st = null; mcClear(); setTimeout(route, 1200); } } } function mailMarkSel(id) { document.querySelectorAll('a.mrow').forEach(a => a.classList.toggle('sel', !!id && decodeURIComponent(a.getAttribute('href').slice(9)) === id)); } function mailDrawList() { const rows = $('#mrows'); if (!rows) return; mailSwipeInit(rows); const sel = (location.hash.match(/^#\/mail\/m\/([^/]+)/) || [])[1]; rows.innerHTML = mail.items.length ? mail.items.map(m => `${mail.sel ? `` : ''} ${esc(mailFrom(m.from))}${esc(m.subject)}${esc(m.preview)} ${esc(mailWhen(m.date))}${m.flagged ? `${icon('bookmark')}` : ''}${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); mailDrawSelBar(); } // ---- Swipe a message in the list: left deletes it, right archives it (touch screens). The row follows your finger and the colour under it shows what will happen; // let go past a third of the way (or flick it) and it leaves at once and finishes in the background, otherwise it springs back. function mailSwipeInit(rows) { if (rows.dataset.swipe) return; rows.dataset.swipe = '1'; let row = null, x0 = 0, y0 = 0, dx = 0, t0 = 0, mode = '', w = 0; const reset = (anim) => { if (!row) return; const r = row; r.style.transition = anim ? 'transform .18s ease-out' : ''; r.style.transform = ''; setTimeout(() => { r.classList.remove('swl', 'swr'); r.style.transition = ''; r.style.removeProperty('--sww'); }, anim ? 200 : 0); }; rows.addEventListener('touchstart', e => { const a = e.target.closest && e.target.closest('a.mrow'); if (!a || e.touches.length !== 1 || mail.sel) { row = null; return; } row = a; x0 = e.touches[0].clientX; y0 = e.touches[0].clientY; dx = 0; mode = ''; t0 = Date.now(); w = a.offsetWidth; }, { passive: true }); rows.addEventListener('touchmove', e => { if (!row) return; const t = e.touches[0], mx = t.clientX - x0, my = t.clientY - y0; if (!mode) { if (Math.abs(my) > 12 && Math.abs(my) > Math.abs(mx)) { row = null; return; } if (Math.abs(mx) > 12 && Math.abs(mx) > Math.abs(my) * 1.4) { mode = 'swipe'; row.style.setProperty('--sww', w + 'px'); } else return; } dx = mx; row.classList.toggle('swl', dx < 0); row.classList.toggle('swr', dx > 0); row.style.transition = ''; row.style.transform = `translateX(${dx}px)`; row.dataset.sw = dx < 0 ? 'Delete' : 'Archive'; row.classList.toggle('swgo', Math.abs(dx) > w / 3); }, { passive: true }); const end = () => { if (!row) return; const r = row; row = null; if (mode !== 'swipe') return; const fast = Math.abs(dx) > 60 && Date.now() - t0 < 250; r.dataset.swiped = '1'; setTimeout(() => delete r.dataset.swiped, 400); if (Math.abs(dx) > w / 3 || fast) { const id = decodeURIComponent(r.getAttribute('href').slice(9)), kind = dx < 0 ? 'delete' : 'archive'; if (kind === 'delete' && /(^|~)deleteditems$/.test(mail.folder)) { row = r; reset(true); row = null; const it = mail.items.find(x => x.id === id); return confirmSheet('Delete for good?', it ? it.subject : '', 'Delete', () => mailRowAway(id, kind)); } r.style.transition = 'transform .16s ease-in, opacity .16s'; r.style.transform = `translateX(${dx < 0 ? -w : w}px)`; r.style.opacity = '0.2'; setTimeout(() => mailRowAway(id, kind), 160); } else { row = r; reset(true); row = null; } }; rows.addEventListener('touchend', end); rows.addEventListener('touchcancel', () => { if (row) { reset(true); row = null; } }); rows.addEventListener('click', e => { const a = e.target.closest && e.target.closest('a.mrow'); if (a && a.dataset.swiped) { e.preventDefault(); e.stopPropagation(); } }, true); // the finger lifting after a swipe is not a tap // Selecting: while it is on, a tap picks or unpicks a message; a long press on a phone (or Ctrl/Cmd-click, Shift-click for a range, on a computer) starts it. let lp = 0, lpRow = null, lx = 0, ly = 0, lpFired = false; rows.addEventListener('touchstart', e => { const a = e.target.closest && e.target.closest('a.mrow'); if (!a || e.touches.length !== 1) return; lpRow = a; lx = e.touches[0].clientX; ly = e.touches[0].clientY; clearTimeout(lp); lpFired = false; lp = setTimeout(() => { if (!lpRow) return; const r = lpRow; lpRow = null; lpFired = true; if (navigator.vibrate) navigator.vibrate(15); mailSelMode(true, decodeURIComponent(r.getAttribute('href').slice(9))); }, 520); }, { passive: true }); rows.addEventListener('touchmove', e => { if (lpRow && (Math.abs(e.touches[0].clientX - lx) > 8 || Math.abs(e.touches[0].clientY - ly) > 8)) { clearTimeout(lp); lpRow = null; } }, { passive: true }); ['touchend', 'touchcancel'].forEach(t => rows.addEventListener(t, e => { clearTimeout(lp); lpRow = null; if (lpFired) { lpFired = false; mail.quiet = Date.now() + 250; if (e.cancelable) e.preventDefault(); } })); // lifting the finger after a long press must not count as a tap on anything rows.addEventListener('contextmenu', e => { if (e.target.closest && e.target.closest('a.mrow') && 'ontouchstart' in window) e.preventDefault(); }); rows.addEventListener('click', e => { const a = e.target.closest && e.target.closest('a.mrow'); if (!a || a.dataset.swiped) return; if (Date.now() < (mail.quiet || 0)) { e.preventDefault(); e.stopPropagation(); return; } const id = decodeURIComponent(a.getAttribute('href').slice(9)); if (!mail.sel && !(e.ctrlKey || e.metaKey || e.shiftKey)) return; e.preventDefault(); e.stopPropagation(); if (!mail.sel) mailSelMode(true, id); else if (e.shiftKey && mail.selLast) { const ids = mail.items.map(x => x.id), i = ids.indexOf(mail.selLast), j = ids.indexOf(id); if (i >= 0 && j >= 0) ids.slice(Math.min(i, j), Math.max(i, j) + 1).forEach(x => mail.sel.add(x)); mailSelPaint(); } else mailSelToggle(id); }, true); } function mailSelMode(on, first) { mail.sel = on ? new Set(first ? [first] : []) : null; mail.selLast = first || null; if (on && /^#\/mail\/m\//.test(location.hash) && matchMedia('(max-width: 899px)').matches) location.hash = '#/mail/f/' + encodeURIComponent(mail.folder); mailSelPaint(); } /** Show who is picked without rebuilding the list (the row under a finger must stay in the page, or the finger's release never reaches it). */ function mailSelPaint() { document.querySelectorAll('#mrows a.mrow').forEach(a => { const id = decodeURIComponent(a.getAttribute('href').slice(9)), on = !!mail.sel && mail.sel.has(id); let c = a.querySelector(':scope > .mchk'); if (mail.sel && !c) { c = document.createElement('span'); c.className = 'mchk'; a.insertBefore(c, a.firstChild); } else if (!mail.sel && c) c.remove(); if (c) c.classList.toggle('on', on); a.classList.toggle('picked', on); if (mail.sel) a.classList.remove('sel'); }); mailDrawSelBar(); } function mailSelToggle(id) { if (!mail.sel) return; if (mail.sel.has(id)) mail.sel.delete(id); else mail.sel.add(id); mail.selLast = id; mailSelPaint(); } function mailDrawSelBar() { const bar = $('#mselbar'); if (!bar) return; document.body.classList.toggle('mselecting', !!mail.sel); if (!mail.sel) { bar.hidden = true; bar.innerHTML = ''; return; } const picked = mail.items.filter(m => mail.sel.has(m.id)), n = picked.length, anyUnread = picked.some(m => !m.read), anyUnflag = picked.some(m => !m.flagged), all = n && n === mail.items.length; const b = (a, label, ic, cls = '') => ``; bar.hidden = false; bar.innerHTML = `
${n ? n + ' selected' : 'Select messages'}${b('all', all ? 'Select none' : 'Select all', '')}${b('done', 'Done', '', 'btn')}
${b(anyUnread ? 'read' : 'unread', anyUnread ? 'Mark read' : 'Mark unread', 'mail')}${b(anyUnflag ? 'flag' : 'unflag', anyUnflag ? 'Flag' : 'Unflag', 'check')}${b('archive', 'Archive', 'files')}${b('move', 'Move', 'newdir')}${b('delete', 'Delete', 'trash', 'danger')}
`; bar.querySelectorAll('[data-s]').forEach(x => x.onclick = () => mailSelAct(x.dataset.s)); } function mailSelAct(a) { if (Date.now() < (mail.quiet || 0)) return; // the finger-lift that ended a long press if (a === 'done') return mailSelMode(false); if (a === 'all') { if (mail.sel.size === mail.items.length) mail.sel.clear(); else mail.items.forEach(m => mail.sel.add(m.id)); return mailSelPaint(); } const ids = [...mail.sel].filter(id => mail.items.some(m => m.id === id)); if (!ids.length) return; if (a === 'move') { const fl = mail.folders.filter(f => f.id !== mail.folder); const el = openSheet('Move to', ids.length + (ids.length > 1 ? ' messages' : ' message'), `
${fl.map(f => ``).join('') || '

No other folders.

'}
`); return el.querySelectorAll('[data-f]').forEach(r => r.onclick = () => { closeSheet(); mailBatch(ids, 'move', r.dataset.f); }); } if (a === 'delete' && /(^|~)deleteditems$/.test(mail.folder)) return confirmSheet('Delete for good?', ids.length + (ids.length > 1 ? ' messages' : ' message') + ' will be deleted for good.', 'Delete', () => mailBatch(ids, 'delete', true)); mailBatch(ids, a); } /** Change one message in the open list and in the saved copy of it. */ function mailPatchItem(id, patch) { const it = mail.items.find(x => x.id === id); if (it) Object.assign(it, patch); const c = mcGet('l:' + mail.folder); if (c && c.items) { const x = c.items.find(y => y.id === id); if (x) { Object.assign(x, patch); mcPut('l:' + mail.folder, { items: c.items, more: c.more, next: c.next }); } } } /** Do the same thing to several messages. The list changes at once; each message is then sent to the server (a few at a time), and any the server refuses go back as they were. */ async function mailBatch(ids, a, extra) { const items = ids.map(id => mail.items.find(x => x.id === id)).filter(Boolean), before = new Map(items.map(m => [m.id, { ...m }])), at = new Map(items.map(m => [m.id, mail.items.indexOf(m)])); const removes = ['archive', 'move', 'delete'].includes(a), inbox = mailInInbox(); let dUnread = 0; mail.sel = null; mail.selLast = null; if (removes) { const gone = new Set(ids); mail.items = mail.items.filter(m => !gone.has(m.id)); dUnread = -items.filter(m => !m.read).length; const sv = mcGet('l:' + mail.folder); if (sv && sv.items) mcPut('l:' + mail.folder, { items: sv.items.filter(x => !gone.has(x.id)), more: sv.more, next: sv.next }); if (mail.msg && gone.has(mail.msg.id)) { mail.msg = null; location.hash = '#/mail/f/' + encodeURIComponent(mail.folder); } } else if (a === 'read' || a === 'unread') { const read = a === 'read'; dUnread = items.filter(m => m.read !== read).length * (read ? -1 : 1); items.forEach(m => { mailSetRead(m.id, read); }); } else if (a === 'flag' || a === 'unflag') items.forEach(m => mailPatchItem(m.id, { flagged: a === 'flag' })); if (inbox && dUnread) { mailUnread = Math.max(0, mailUnread + dUnread); renderNav(); } mailDrawList(); const call = m => a === 'move' ? jfetch('/api/mail/move', { method: 'POST', body: JSON.stringify({ id: m.id, to: extra }) }) : a === 'archive' ? jfetch('/api/mail/move', { method: 'POST', body: JSON.stringify({ id: m.id, to: 'archive' }) }) : a === 'delete' ? jfetch('/api/mail/delete', { method: 'POST', body: JSON.stringify({ id: m.id, ...(extra ? { permanent: true } : {}) }) }) : jfetch('/api/mail/update', { method: 'POST', body: JSON.stringify(a === 'read' || a === 'unread' ? { id: m.id, read: a === 'read' } : { id: m.id, flagged: a === 'flag' }) }); const failed = []; let next = 0, lastErr = ''; await Promise.all([0, 1, 2, 3].map(async () => { while (next < items.length) { const m = items[next++]; try { await call(m); } catch (e) { failed.push(m); lastErr = e.message; } } })); const word = { read: 'Marked read', unread: 'Marked unread', flag: 'Flagged', unflag: 'Unflagged', archive: 'Archived', move: 'Moved', delete: extra ? 'Deleted' : 'Moved to Deleted' }[a], ok = items.length - failed.length; if (failed.length) { for (const m of failed) { const o = before.get(m.id); if (removes) mail.items.splice(Math.min(at.get(m.id), mail.items.length), 0, o); else { if (a === 'read' || a === 'unread') mailSetRead(m.id, o.read); else mailPatchItem(m.id, { flagged: o.flagged }); } } if (inbox) { jfetch('/api/mail/status').catch(() => {}); } mailDrawList(); toast(`${ok ? word + ' ' + ok + ', ' : ''}${failed.length} failed: ${lastErr}`); } else toast(`${word}${items.length > 1 ? ' (' + items.length + ')' : ''}`); mailPoll(); jfetch('/api/mail/folders?acct=' + encodeURIComponent(mail.acct)).then(f => { mail.folders = f; mailRefreshFolders(); }).catch(() => {}); } function mailRowAway(id, kind) { const at = mail.items.findIndex(x => x.id === id), had = mail.items[at]; if (!had) return mailDrawList(); const permanent = kind === 'delete' && /(^|~)deleteditems$/.test(mail.folder); mail.items.splice(at, 1); const sv = mcGet('l:' + mail.folder); if (sv && sv.items) mcPut('l:' + mail.folder, { items: sv.items.filter(x => x.id !== id), more: sv.more, next: sv.next }); if (mail.msg && mail.msg.id === id) { mail.msg = null; location.hash = '#/mail/f/' + encodeURIComponent(mail.folder); } mailDrawList(); if (!had.read && mailInInbox() && mailUnread > 0) { mailUnread--; renderNav(); } toast(kind === 'archive' ? 'Archived' : permanent ? 'Deleted' : 'Moved to Deleted'); const refresh = () => { mailPoll(); jfetch('/api/mail/folders?acct=' + encodeURIComponent(mail.acct)).then(f => { mail.folders = f; mailRefreshFolders(); }).catch(() => {}); }; jfetch('/api/mail/' + (kind === 'archive' ? 'move' : 'delete'), { method: 'POST', body: JSON.stringify(kind === 'archive' ? { id, to: 'archive' } : { id, ...(permanent ? { permanent: true } : {}) }) }) .then(refresh, e => { toast(e.message); if (!had.read && mailInInbox()) { mailUnread++; renderNav(); } mail.items.splice(Math.min(at, mail.items.length), 0, had); mailDrawList(); refresh(); }); } // Opened messages are also kept on this device (IndexedDB, the newest 80), so one you have read opens at once, even offline. A message's text never // changes, so only its read/flagged state is taken fresh (from the list). Cleared with the rest of the saved mail when you disconnect or sign out. const mbDB = () => mbDB.p || (mbDB.p = new Promise(ok => { try { const r = indexedDB.open('hub-mail', 1); r.onupgradeneeded = () => r.result.createObjectStore('b'); r.onsuccess = () => ok(r.result); r.onerror = () => ok(null); } catch { ok(null); } })); const mbKey = id => (ME ? ME.id : '') + ':' + id; async function mbGet(id) { const db = await mbDB(); if (!db) return null; return new Promise(ok => { try { const r = db.transaction('b').objectStore('b').get(mbKey(id)); r.onsuccess = () => ok(r.result && r.result.m || null); r.onerror = () => ok(null); } catch { ok(null); } }); } async function mbPut(id, m) { const db = await mbDB(); if (!db || JSON.stringify(m).length > 1.5e6) return; try { const st = db.transaction('b', 'readwrite').objectStore('b'); st.put({ t: Date.now(), m }, mbKey(id)); const all = st.getAll(), keys = st.getAllKeys(); keys.onsuccess = () => { const rows = keys.result.map((k, i) => [k, all.result[i].t]).sort((x, y) => y[1] - x[1]); for (const [k] of rows.slice(80)) st.delete(k); }; // (all was asked first, so it is done when keys is) } catch {} } async function mbClear() { const db = await mbDB(); if (db) try { db.transaction('b', 'readwrite').objectStore('b').clear(); } catch {} } const mailCache = new Map(); // message id -> { t, p: promise }: a message you point at or tap is fetched once and kept for a minute const mailFetch = id => { const c = mailCache.get(id); if (c && Date.now() - c.t < 60e3) return c.p; const p = mbGet(id).then(saved => { if (saved) { const it = mail.items.find(x => x.id === id); return it ? { ...saved, read: it.read, flagged: it.flagged } : saved; } // (opens at once) return jfetch('/api/mail/message?id=' + encodeURIComponent(id)).then(m => { mbPut(id, m); return m; }); }); mailCache.set(id, { t: Date.now(), p }); p.catch(() => mailCache.delete(id)); if (mailCache.size > 30) mailCache.delete(mailCache.keys().next().value); return p; }; let mailPre = 0; // fetches the newest unread-or-recent messages of the open list one at a time, so they are already here when tapped async function mailPrefetch() { const run = ++mailPre, ids = mail.items.slice(0, 10).map(x => x.id); for (const id of ids) { if (run !== mailPre || !location.hash.startsWith('#/mail')) return; try { if (!(await mbGet(id))) await mailFetch(id); } catch {} } } document.addEventListener('pointerover', e => { const a = e.target.closest && e.target.closest('a.mrow'); if (a && a.getAttribute('href').startsWith('#/mail/m/')) mailFetch(decodeURIComponent(a.getAttribute('href').slice(9))).catch(() => {}); }, { passive: true }); document.addEventListener('touchstart', e => { const a = e.target.closest && e.target.closest('a.mrow'); if (a && a.getAttribute('href').startsWith('#/mail/m/')) mailFetch(decodeURIComponent(a.getAttribute('href').slice(9))).catch(() => {}); }, { passive: true }); // What this device has just done to a message (read, unread), kept for a minute: a list that was already on its way from the server // (it started before the change) must not put the old state back. const mailMine = new Map(); const mailInInbox = () => /(^|~)inbox$/.test(mail.folder); function mailSetRead(id, read) { mailMine.set(id, { read, t: Date.now() }); const it = mail.items.find(x => x.id === id); if (it) it.read = read; const c = mcGet('l:' + mail.folder); // the saved copy of this folder shows it too the next time Mail opens if (c && c.items) { const x = c.items.find(y => y.id === id); if (x) { x.read = read; mcPut('l:' + mail.folder, { items: c.items, more: c.more, next: c.next }); } } } const mailApplyMine = items => { const now = Date.now(); for (const [k, v] of mailMine) if (now - v.t > 60e3) mailMine.delete(k); for (const m of items) { const o = mailMine.get(m.id); if (o) m.read = o.read; } return items; }; async function mailOpen(id) { const rd = $('#mread'), it = mail.items.find(x => x.id === id); if (rd) rd.innerHTML = `
${icon('back')}${esc(mailFolderName(mail.folder))}${it ? `

${esc(it.subject)}

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

` : ''}

Opening…

`; // (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 = '

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 refresh = () => { mailPoll(); jfetch('/api/mail/folders?acct=' + encodeURIComponent(mail.acct)).then(f => { mail.folders = f; mailRefreshFolders(); }).catch(() => {}); }; const gone = () => { mail.items = mail.items.filter(x => x.id !== m.id); const sv = mcGet('l:' + mail.folder); if (sv && sv.items) mcPut('l:' + mail.folder, { items: sv.items.filter(x => x.id !== m.id), more: sv.more, next: sv.next }); }; const back = keep => { if (!keep) gone(); mail.msg = null; location.hash = '#/mail/f/' + encodeURIComponent(mail.folder); refresh(); }; // Archive and delete leave at once and finish in the background; if the server refuses, the message comes back const away = (p, body, said) => { const had = mail.items.find(x => x.id === m.id), at = mail.items.findIndex(x => x.id === m.id); toast(said); back(); post(p, body).catch(e => { toast(e.message); if (had) { mail.items.splice(Math.min(at < 0 ? 0 : at, mail.items.length), 0, had); mailDrawList(); } refresh(); }); }; try { if (a === 'reply' || a === 'replyAll' || a === 'forward') return mailCompose({ mode: a, msg: m }); if (a === 'archive') return away('move', { to: 'archive' }, 'Archived'); if (a === 'unread') { await post('update', { read: false }); m.read = false; mailSetRead(m.id, false); toast('Marked unread'); if (mailInInbox()) { mailUnread++; renderNav(); } return back(true); } 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 (/(^|~)deleteditems$/.test(mail.folder)) return confirmSheet('Delete for good?', m.subject, 'Delete', () => away('delete', { permanent: true }, 'Deleted')); return away('delete', {}, 'Moved to Deleted'); } } 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.

` : `${mail.accts.length > 1 ? `` : ''}`} ${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({ acct: el.querySelector('#mfrom') ? el.querySelector('#mfrom').value : mail.acct || undefined, 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); }