build-container / build (push) Successful in 14m15s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
392 lines
40 KiB
JavaScript
392 lines
40 KiB
JavaScript
'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 `<!doctype html><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${csp}"><base target="_blank"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<style>body{margin:14px;font:15px/1.45 -apple-system,Segoe UI,Roboto,sans-serif;color:#111;background:#fff;overflow-wrap:anywhere}img{max-width:100%;height:auto}table{max-width:100%}a{color:#1a5fd1}blockquote{margin:0 0 0 6px;padding-left:10px;border-left:3px solid #ccc;color:#555}</style>${html}`;
|
|
}
|
|
const mailHasRemote = html => /<img[^>]+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 = `<h2>Mail${s.unread ? ' · ' + s.unread + ' unread' : ''}</h2><div class="list">${top.length ? top.map(m => `<a class="row" href="#/mail/m/${encodeURIComponent(m.id)}"><span class="ic" style="background:#4f7cff">${icon('mail')}</span><span class="grow"><b>${esc(mailFrom(m.from))}</b><small>${esc(m.subject)}${s.accounts.length > 1 ? ' · ' + esc(m.acct) : ''}</small></span></a>`).join('') : `<a class="row" href="#/mail"><span class="ic" style="background:#4f7cff">${icon('mail')}</span><span class="grow"><b>Inbox is clear</b><small>${esc(s.accounts.map(a => a.address).join(', '))}</small></span></a>`}</div>`;
|
|
} 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 => `<div class="row"><span class="grow"><b>${esc(MAIL_LABEL[a.type] || a.type)}</b><small>${esc(a.address || 'Connected')}${a.error ? ' · ' + esc(a.error) : ''}</small></span><button class="btn danger" data-dis="${esc(a.id)}" style="min-height:36px">Disconnect</button></div>`).join('') : `<div class="row"><span class="grow"><b>No mail account yet</b><small>${s.expired ? 'Microsoft signed you out. ' : ''}Add Outlook.com, Gmail or iCloud Mail</small></span></div>`)
|
|
+ `<div class="row"><span class="grow"></span><button class="btn ghost" id="madd" style="min-height:36px">Add an account</button></div>`;
|
|
$('#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 = '<div class="row"><span class="grow"><small>Could not check</small></span></div>'; });
|
|
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', '', `<div class="list">${choices.map(([k, l, s]) => `<button class="row" data-p="${k}"><span class="ic" style="background:${k === 'gmail' ? '#d9453a' : k === 'icloud' ? '#3b97e8' : '#0a64c8'}">${icon('mail')}</span><span class="grow"><b>${l}</b><small>${s}</small></span>${icon('chev', 'chev')}</button>`).join('')}</div>
|
|
<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Cancel</button></div>`);
|
|
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'), '', `<form id="paf">
|
|
<p class="sub" style="margin-top:0">${g ? 'Google needs an <b>app password</b>, not your normal password. Turn on 2-Step Verification, then make one at <b>myaccount.google.com/apppasswords</b> (a 16-letter code).' : 'Apple needs an <b>app-specific password</b>. Go to <b>appleid.apple.com</b> → Sign-In and Security → App-Specific Passwords, and make one for NE Hub.'}</p>
|
|
<label for="pae">Email address</label><input type="text" id="pae" inputmode="email" autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="${g ? 'you@gmail.com' : 'you@icloud.com'}" required>
|
|
<label for="pap">${g ? 'App password' : 'App-specific password'}</label><input type="password" id="pap" autocomplete="new-password" required>
|
|
<p class="sub" style="margin:6px 0 0">It is checked with ${g ? 'Google' : 'Apple'} before saving, and kept encrypted on your server. NE Hub never sees your normal password.</p>
|
|
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" id="pab" style="flex:1;justify-content:center">Connect</button></div><div class="err" id="pax" role="alert"></div></form>`);
|
|
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', '<div id="mailadm"></div>'); host = $('#mailadm'); }
|
|
let a; try { a = await jfetch('/api/mail/admin'); } catch (e) { host.innerHTML = `<p class="err">${esc(e.message)}</p>`; return; }
|
|
if (!$('#mailadm')) return;
|
|
const warn = a.days === null ? '' : a.days < 0 ? `<p class="err" style="margin:8px 0 0">The client secret expired ${-a.days} day${a.days === -1 ? '' : 's'} ago. Mail cannot connect until you replace it.</p>`
|
|
: a.days <= 30 ? `<p class="err" style="margin:8px 0 0">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.</p>` : '';
|
|
host.innerHTML = `<h2>Microsoft app (admin)</h2>
|
|
<p class="sub">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.'}</p>
|
|
${warn}
|
|
<p class="sub" style="margin:8px 0 0">${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.' : ''}</p>
|
|
<div class="card" style="max-width:none">
|
|
<label>Redirect address to register with Microsoft</label>
|
|
<div style="display:flex;gap:8px"><input type="text" id="mredir" readonly value="${esc(a.redirect)}" style="flex:1"><button class="btn ghost" id="mcopy" type="button">Copy</button></div>
|
|
<p class="sub" style="margin:4px 0 0">It must match exactly, and be https (set PUBLIC_URL on the server if this shows the wrong address).</p>
|
|
${a.source === 'env' ? `<label>Application (client) ID</label><input type="text" readonly value="${esc(a.clientId)}"><p class="sub">Client secret: ${a.hasSecret ? 'set' : 'not set'}</p>` : `<form id="madm">
|
|
<label for="mcid">Application (client) ID</label><input type="text" id="mcid" value="${esc(a.clientId)}" autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="1a2b3c4d-1234-5678-9abc-def012345678">
|
|
<label for="mcsec">Client secret (the Value)</label><input type="password" id="mcsec" autocomplete="new-password" placeholder="${a.hasSecret ? 'Saved. Leave blank to keep it' : 'Leave blank if the app has no secret'}">
|
|
<label for="mcexp">Secret expires on</label><input type="date" id="mcexp" value="${esc(a.secretExpires)}"><p class="sub" style="margin:4px 0 0">Microsoft secrets last at most 24 months. NE Hub warns you a month before.</p>
|
|
<div class="bar"><button class="btn" style="flex:1;justify-content:center">Save</button></div>
|
|
${a.source === 'app' ? '<div class="bar" style="margin-top:0"><button class="btn danger" type="button" id="mclear" style="flex:1;justify-content:center">Remove these settings</button></div>' : ''}
|
|
<div id="mres" class="err" role="status"></div></form>`}
|
|
</div>
|
|
<details style="margin-top:10px"><summary style="cursor:pointer;color:var(--accent)">How to register the app (about 5 minutes)</summary>
|
|
<ol class="sub" style="line-height:1.55;padding-left:20px">
|
|
<li>Go to <b>entra.microsoft.com</b> and sign in with any Microsoft account. Accept the free "Default Directory" if asked.</li>
|
|
<li><b>Entra ID → App registrations → New registration</b>. Name it NE Hub, choose <b>Personal Microsoft accounts only</b>, set the redirect platform to <b>Web</b>, paste the redirect address above, press Register.</li>
|
|
<li>Copy the <b>Application (client) ID</b> from the Overview page into the first box above.</li>
|
|
<li><b>API permissions → Add a permission → Microsoft Graph → Delegated</b>: Mail.ReadWrite, Mail.Send, User.Read, offline_access.</li>
|
|
<li><b>Certificates & secrets → New client secret</b> (24 months). Copy its <b>Value</b> now (shown once) into the secret box, and set the expiry date.</li>
|
|
<li>Press Save, then open the Mail tab and press <b>Connect</b>. Microsoft checks the ID, secret and redirect address at that moment; if something is wrong it says what.</li>
|
|
</ol></details>`;
|
|
$('#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 = '<div class="wrap"><h1>Mail</h1><p class="empty">Mail is not available to you.</p></div>'; 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 = '<div class="wrap"><h1>Mail</h1><p class="empty">Loading…</p></div>';
|
|
try { st = await jfetch('/api/mail/status?light=1'); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Mail</h1><p class="err">${esc(e.message)}</p></div>`; 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 = () => `<div class="maccts" role="tablist" aria-label="Accounts">${mail.accts.map(a => `<button type="button" role="tab" data-a="${esc(a.id)}" aria-selected="${a.id === mail.acct}" class="${a.id === mail.acct ? 'on' : ''}${a.unread ? ' new' : ''}"><span class="mal">${esc(a.name || a.address)}</span><small>${esc(a.name ? a.address : '')}</small>${a.unread ? `<i class="nbadge" style="position:static">${a.unread > 99 ? '99+' : a.unread}</i>` : ''}</button>`).join('')}</div>`;
|
|
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 => `<option value="${esc(x.id)}" ${x.id === mail.folder ? 'selected' : ''}>${esc(x.name)}${x.unread ? ' (' + x.unread + ')' : ''}</option>`).join('');
|
|
const side = document.querySelector('.mfold'); if (side) { const sel = side.querySelector('select'); side.innerHTML = (mail.accts.length > 1 ? mailChips() : '') + fl.map(x => `<a href="#/mail/f/${encodeURIComponent(x.id)}" class="${x.id === mail.folder ? 'on' : ''}"><span class="grow">${esc(x.name)}</span>${x.unread ? `<i class="nbadge" style="position:static">${x.unread}</i>` : ''}</a>`).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 = `<div class="wrap"><h1>Mail</h1>
|
|
<p class="sub">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.</p>
|
|
${st.expired ? '<p class="err">Microsoft signed you out of mail. Connect again to continue.</p>' : ''}
|
|
<div class="list">${choices.map(([k, l, s, c]) => `<button class="row" data-p="${k}"><span class="ic" style="background:${c}">${icon('mail')}</span><span class="grow"><b>${l}</b><small>${s}</small></span>${icon('chev', 'chev')}</button>`).join('')}</div><div class="err" id="mce"></div>
|
|
${st.outlook ? '' : '<p class="sub">Outlook.com needs a one-time Microsoft app set up by an admin (Settings → Microsoft app).</p>'}</div>`;
|
|
$('#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) {
|
|
const fl = mail.folders.length ? mail.folders : [{ id: 'inbox', name: 'Inbox', unread: mailUnread }];
|
|
$('#view').innerHTML = `<div class="mailapp" data-mode="${mode}">
|
|
<aside class="mfold" aria-label="Folders">${mail.accts.length > 1 ? mailChips() : ''}${fl.map(f => `<a href="#/mail/f/${encodeURIComponent(f.id)}" class="${f.id === mail.folder ? 'on' : ''}"><span class="grow">${esc(f.name)}</span>${f.unread ? `<i class="nbadge" style="position:static">${f.unread}</i>` : ''}</a>`).join('')}</aside>
|
|
<section class="mlist" aria-label="Messages">
|
|
${mail.accts.length > 1 ? mailChips() : ''}
|
|
<div class="mtool"><select id="mfsel" aria-label="Folder">${fl.map(f => `<option value="${esc(f.id)}" ${f.id === mail.folder ? 'selected' : ''}>${esc(f.name)}${f.unread ? ' (' + f.unread + ')' : ''}</option>`).join('')}</select>
|
|
<input type="search" id="mq" placeholder="Search mail" value="${esc(mail.q)}" autocomplete="off" aria-label="Search mail">
|
|
<button class="ibtn" id="mrf" aria-label="Refresh">${icon('refresh')}</button><button class="btn" id="mnew">${icon('edit')}New</button></div>
|
|
<div id="mrows" class="list"></div><div id="mmore"></div>
|
|
</section>
|
|
<section class="mread" id="mread" aria-label="Message"></section></div>`;
|
|
$('#mfsel').onchange = e => { mail.q = ''; location.hash = '#/mail/f/' + encodeURIComponent(e.target.value); };
|
|
mailBindChips();
|
|
$('#mnew').onclick = () => mailCompose();
|
|
$('#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 = '<p class="empty">Loading…</p>'; }
|
|
}
|
|
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 = `<p class="err" style="padding:12px">${esc(e.message)}</p>`; 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;
|
|
const sel = (location.hash.match(/^#\/mail\/m\/([^/]+)/) || [])[1];
|
|
rows.innerHTML = mail.items.length ? mail.items.map(m => `<a class="row mrow${m.read ? '' : ' unread'}${sel && decodeURIComponent(sel) === m.id ? ' sel' : ''}" href="#/mail/m/${encodeURIComponent(m.id)}">
|
|
<span class="grow"><b>${esc(mailFrom(m.from))}</b><span class="msub">${esc(m.subject)}</span><small>${esc(m.preview)}</small></span>
|
|
<span class="mmeta"><small>${esc(mailWhen(m.date))}</small>${m.flagged ? icon('check') : ''}${m.attach ? icon('clip') : ''}</span></a>`).join('') : `<p class="empty" style="padding:12px">${mail.q ? 'Nothing matches.' : 'No messages here.'}</p>`;
|
|
const more = $('#mmore'); more.innerHTML = mail.more ? '<div class="bar" style="justify-content:center"><button class="btn ghost" id="mmb">Load more</button></div>' : '';
|
|
if ($('#mmb')) $('#mmb').onclick = () => mailLoadList(false);
|
|
}
|
|
|
|
// 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 = `<div class="mrhead"><a class="btn ghost mback" href="#/mail/f/${encodeURIComponent(mail.folder)}">${icon('back')}${esc(mailFolderName(mail.folder))}</a>${it ? `<h2 class="msubj">${esc(it.subject)}</h2><p class="mfrom"><b>${esc(mailFrom(it.from))}</b> <small>${esc(it.from.address)}</small></p>` : ''}</div><p class="empty" style="padding:16px">Opening…</p>`; // (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 = `<p class="err" style="padding:16px">${esc(e.message)}</p>`; }
|
|
}
|
|
|
|
function mailDrawReader() {
|
|
const rd = $('#mread'); if (!rd) return;
|
|
const m = mail.msg;
|
|
if (!m) { rd.innerHTML = '<p class="empty" style="padding:24px">Choose a message to read it.</p>'; 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 = `<div class="mrhead"><a class="btn ghost mback" href="#/mail/f/${encodeURIComponent(mail.folder)}">${icon('back')}${esc(mailFolderName(mail.folder))}</a>
|
|
<h2 class="msubj">${esc(m.subject)}</h2>
|
|
<p class="mfrom"><b>${esc(mailFrom(m.from))}</b> <small>${esc(m.from.address)}</small></p>
|
|
<p class="mto"><small>To ${addr(m.to) || 'me'}${m.cc.length ? ' · Cc ' + addr(m.cc) : ''} · ${esc(new Date(m.date).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }))}</small></p>
|
|
<div class="mact">
|
|
<button class="btn" data-a="reply">Reply</button><button class="btn ghost" data-a="replyAll">Reply all</button><button class="btn ghost" data-a="forward">Forward</button>
|
|
<button class="btn ghost" data-a="archive">Archive</button><button class="btn ghost" data-a="unread">Mark unread</button><button class="btn ghost" data-a="flag">${m.flagged ? 'Unflag' : 'Flag'}</button><button class="btn danger" data-a="delete">Delete</button></div>
|
|
${m.attachments.length ? `<div class="matt">${m.attachments.map(a => `<a class="btn ghost" href="/api/mail/attachment?id=${encodeURIComponent(m.id)}&aid=${encodeURIComponent(a.id)}" download>${icon('clip')}${esc(a.name)} <small>${esc(fmtSize(a.size || 0))}</small></a>`).join('')}</div>` : ''}
|
|
${remote ? '<div class="mimg">Pictures are hidden to protect your privacy. <button class="btn ghost" id="mshow" style="min-height:32px">Show pictures</button></div>' : ''}</div>
|
|
<iframe class="mbody" title="Message" sandbox="allow-popups allow-popups-to-escape-sandbox" referrerpolicy="no-referrer" srcdoc="${esc(mailDoc(m.html, mail.images))}"></iframe>`;
|
|
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 : '', `<form id="mf">
|
|
${reply && reply !== 'forward' ? `<p class="sub" style="margin:0">To ${esc(reply === 'reply' ? mailFrom(m.from) : 'everyone on the message')}. The original is quoted below your text automatically.</p>` : `${mail.accts.length > 1 ? `<label for="mfrom">From</label><select id="mfrom">${mail.accts.map(a => `<option value="${esc(a.id)}" ${a.id === mail.acct ? 'selected' : ''}>${esc(a.address)}</option>`).join('')}</select>` : ''}<label for="mto">To</label><input type="text" id="mto" autocomplete="off" autocapitalize="off" inputmode="email" placeholder="name@example.com, another@example.com" required>`}
|
|
${reply ? '' : `<div id="mccw"><a href="#" id="mcc" style="color:var(--accent);font-size:14px">Add Cc / Bcc</a></div><div id="mcb" hidden><label for="mcc2">Cc</label><input type="text" id="mcc2" autocomplete="off" autocapitalize="off"><label for="mbcc">Bcc</label><input type="text" id="mbcc" autocomplete="off" autocapitalize="off"></div>
|
|
<label for="msb">Subject</label><input type="text" id="msb" autocomplete="off">`}
|
|
<label for="mbd">${reply ? 'Your message' : 'Message'}</label><textarea id="mbd" rows="${reply ? 6 : 9}"></textarea>
|
|
${reply ? '' : `<div class="bar" style="margin:8px 0 0;justify-content:flex-start"><label class="btn ghost" style="cursor:pointer;margin:0">${icon('clip')}Attach<input type="file" id="mfile" multiple hidden></label><small id="mfl" style="color:var(--muted)"></small></div>`}
|
|
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" id="msend" style="flex:1;justify-content:center">Send</button></div><div class="err" id="mer"></div></form>`);
|
|
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);
|
|
}
|