Files
hub/public/mail.js
T

283 lines
30 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 };
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; 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 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; 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 &amp; 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; }
v.innerHTML = '<div class="wrap"><h1>Mail</h1><p class="empty">Loading…</p></div>';
let st; try { st = await jfetch('/api/mail/status'); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Mail</h1><p class="err">${esc(e.message)}</p></div>`; return; }
if (!location.hash.startsWith('#/mail')) return;
mail.st = st; mail.accts = st.accounts || [];
if (!st.connected) return mailConnectScreen(st);
if (typeof st.unread === 'number' && st.unread !== mailUnread) { mailUnread = st.unread; renderNav(); }
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) { try { mail.folders = await jfetch('/api/mail/folders?acct=' + encodeURIComponent(mail.acct)); } 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) {
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 ? `<select id="macct2" aria-label="Account" style="margin-bottom:8px">${mail.accts.map(a => `<option value="${esc(a.id)}" ${a.id === mail.acct ? 'selected' : ''}>${esc(a.address)}</option>`).join('')}</select>` : ''}${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 ? `<div class="mtool" style="margin-bottom:4px"><select id="macct" aria-label="Account" style="max-width:100%;flex:1">${mail.accts.map(a => `<option value="${esc(a.id)}" ${a.id === mail.acct ? 'selected' : ''}>${esc(a.address)}${a.unread ? ' (' + a.unread + ')' : ''}</option>`).join('')}</select></div>` : ''}
<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); };
if ($('#macct2')) $('#macct2').onchange = e => { mail.q = ''; location.hash = '#/mail/f/' + encodeURIComponent(e.target.value + '~inbox'); };
if ($('#macct')) $('#macct').onchange = e => { mail.q = ''; location.hash = '#/mail/f/' + encodeURIComponent(e.target.value + '~inbox'); };
$('#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) {
if (reset) { mail.items = []; mail.next = 0; mail.more = false; }
const rows = $('#mrows'); if (rows && reset) rows.innerHTML = '<p class="empty">Loading…</p>';
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 = `<p class="err" style="padding:12px">${esc(e.message)}</p>`; 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 => `<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);
}
async function mailOpen(id) {
const rd = $('#mread'); if (rd) rd.innerHTML = '<p class="empty" style="padding:16px">Opening…</p>';
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?acct=' + encodeURIComponent(mail.acct)).then(f => { mail.folders = f; }).catch(() => {});
}).catch(() => {});
}
} 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 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?acct=' + encodeURIComponent(mail.acct)).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 (/(^|~)deleteditems$/.test(mail.folder)) 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 : '', `<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);
}