Mail: unified inbox, snooze, templates, and saving attachments to Files

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-27 10:25:48 -04:00
1 parent 54d18594a3
commit 808274b4fe
7 files changed
+101 -14

No files matched your search

+40 -10
View File
@@ -25,7 +25,7 @@ const mailWhen = iso => {
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');
const mailFolderName = id => id === 'unified' ? 'All inboxes' : (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) {
@@ -176,9 +176,10 @@ async function viewMail(args = []) {
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';
const keepUni = mail.folder === 'unified' && kind === 'm'; // reading a message from the all-inboxes list keeps that list behind it
if (want !== mail.acct) { mail.acct = want; mail.folders = []; if (!keepUni) { mail.items = []; if (!(kind === 'f' && idAcct(key))) mail.folder = want + '~inbox'; } }
if (kind === 'f' && key) mail.folder = key === 'unified' ? 'unified' : idAcct(key) ? key : mail.acct + '~' + key;
else if (mail.folder !== 'unified' && !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;
@@ -189,8 +190,8 @@ async function viewMail(args = []) {
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'); }); }
const mailChips = () => `<div class="maccts" role="tablist" aria-label="Accounts">${mail.accts.length > 1 ? `<button type="button" role="tab" data-a="unified" aria-selected="${mail.folder === 'unified'}" class="${mail.folder === 'unified' ? 'on' : ''}"><span class="mal">All inboxes</span><small>every account</small></button>` : ''}${mail.accts.map(a => `<button type="button" role="tab" data-a="${esc(a.id)}" aria-selected="${a.id === mail.acct && mail.folder !== 'unified'}" class="${a.id === mail.acct && mail.folder !== 'unified' ? '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 === 'unified') { mail.q = ''; location.hash = '#/mail/f/unified'; return; } if (b.dataset.a === mail.acct && mail.folder !== 'unified') 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() {
@@ -426,7 +427,7 @@ document.addEventListener('touchstart', e => { const a = e.target.closest && e.t
// 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);
const mailInInbox = () => /(^|~)inbox$|^unified$/.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;
@@ -467,14 +468,15 @@ function mailDrawReader() {
<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>` : ''}
<button class="btn ghost" data-a="archive">Archive</button><button class="btn ghost" data-a="snooze">Snooze</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>${allowed('files') ? `<button class="btn ghost" data-att="${esc(a.id)}" aria-label="Save ${esc(a.name)} to Files" title="Save to Files">${icon('files')}</button>` : ''}`).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-same-origin 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(); };
mailFit(rd.querySelector('.mbody'));
if (typeof ctMailHook === 'function') ctMailHook(rd, m);
rd.querySelectorAll('[data-a]').forEach(b => b.onclick = () => mailAction(b.dataset.a));
rd.querySelectorAll('[data-att]').forEach(b => b.onclick = () => { const at = m.attachments.find(x => x.id === b.dataset.att); if (at) saveToFiles(at.name, { kind: 'mail-attachment', id: m.id, aid: at.id }); });
}
async function mailAction(a) {
@@ -492,6 +494,7 @@ async function mailAction(a) {
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 === 'snooze') return mailSnoozeSheet(m, async until => { await post('snooze', { until, subject: m.subject, from: mailFrom(m.from) }); toast('Snoozed until ' + new Date(until).toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' })); back(); });
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') {
@@ -510,11 +513,12 @@ function mailCompose(o = {}) {
${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>
<label for="mbd">${reply ? 'Your message' : 'Message'}</label><div id="mtpl" class="bar" style="margin:0 0 6px;justify-content:flex-start"></div><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;
if (o.to && el.querySelector('#mto')) el.querySelector('#mto').value = o.to;
mailTemplates(el);
if (typeof ctSuggest === 'function') for (const id of ['#mto', '#mcc2', '#mbcc']) ctSuggest(el.querySelector(id));
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');
@@ -534,3 +538,29 @@ function mailCompose(o = {}) {
};
setTimeout(() => { const f = el.querySelector('#mto') || el.querySelector('#mbd'); if (f) f.focus(); }, 60);
}
/** Pick when a message comes back to the inbox. */
function mailSnoozeSheet(m, go) {
const at = (days, h) => { const d = new Date(); d.setDate(d.getDate() + days); d.setHours(h, 0, 0, 0); return d.getTime(); }, now = new Date(), sat = (6 - now.getDay() + 7) % 7 || 7, mon = (8 - now.getDay()) % 7 || 7;
const opts = [...(now.getHours() < 17 ? [['Later today', at(0, 17)]] : []), ['Tomorrow morning', at(1, 8)], ['This weekend', at(sat, 9)], ['Next week', at(mon, 8)]];
const el = openSheet('Snooze', m.subject, `<div class="list">${opts.map(([l, t], i) => `<a href="#" class="row" data-i="${i}"><span class="grow"><b>${l}</b><small>${new Date(t).toLocaleString([], { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}</small></span></a>`).join('')}</div>
<form id="msf" class="bar" style="flex-wrap:nowrap;margin-top:12px"><input type="datetime-local" id="mst" style="flex:1" aria-label="Pick a date and time"><button class="btn ghost">Pick</button></form><div class="err" id="mse"></div>`);
const run = async t => { try { closeSheet(); await go(t); } catch (e) { toast(e.message); } };
el.querySelectorAll('[data-i]').forEach(a => a.onclick = e => { e.preventDefault(); run(opts[+a.dataset.i][1]); });
el.querySelector('#msf').onsubmit = e => { e.preventDefault(); const t = Date.parse(el.querySelector('#mst').value); if (!(t > Date.now())) { el.querySelector('#mse').textContent = 'Pick a time in the future.'; return; } run(t); };
}
/** Templates in a new message: insert one, or save what is typed as a template. */
function mailTemplates(el) {
const bar = el.querySelector('#mtpl'); if (!bar) return;
const draw = list => {
bar.innerHTML = `<button type="button" class="btn ghost" id="mtp">Templates${list.length ? ' (' + list.length + ')' : ''}</button><button type="button" class="btn ghost" id="mts">Save as template</button>`;
bar.querySelector('#mtp').onclick = () => { if (!list.length) return toast('No templates yet. Write a message, then Save as template.');
const sh = openSheet('Templates', 'Tap one to use it', `<div class="list">${list.map(t => `<div class="row"><a href="#" class="grow" data-use="${esc(t.id)}"><b>${esc(t.name)}</b><small>${esc(t.subject || t.body.slice(0, 60))}</small></a><button class="ibtn" data-del="${esc(t.id)}" aria-label="Delete ${esc(t.name)}">${icon('trash')}</button></div>`).join('')}</div>`);
sh.querySelectorAll('[data-use]').forEach(a => a.onclick = e => { e.preventDefault(); const t = list.find(x => x.id === a.dataset.use); closeSheet(); const sb = el.querySelector('#msb'), bd = el.querySelector('#mbd'); if (sb && t.subject && !sb.value) sb.value = t.subject; bd.value = bd.value ? bd.value + '\n' + t.body : t.body; bd.focus(); });
sh.querySelectorAll('[data-del]').forEach(b => b.onclick = async () => { try { const l = await jfetch('/api/mail/templates/delete', { method: 'POST', body: JSON.stringify({ id: b.dataset.del }) }); closeSheet(); draw(l); toast('Template deleted'); } catch (e) { toast(e.message); } }); };
bar.querySelector('#mts').onclick = () => { const body = el.querySelector('#mbd').value.trim(); if (!body) return toast('Write the message first');
const sh = openSheet('Save as template', '', `<form id="mtf"><label for="mtn">Name</label><input type="text" id="mtn" autocomplete="off" required><div class="bar"><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`);
sh.querySelector('#mtf').onsubmit = async e => { e.preventDefault(); try { const l = await jfetch('/api/mail/templates', { method: 'POST', body: JSON.stringify({ name: sh.querySelector('#mtn').value, subject: (el.querySelector('#msb') || {}).value || '', body }) }); closeSheet(); draw(l); toast('Template saved'); } catch (er) { toast(er.message); } }; };
};
jfetch('/api/mail/templates').then(draw).catch(() => {});
}
+1 -1
View File
@@ -1,5 +1,5 @@
// App-shell cache only; Seafile/API traffic always goes to the network.
const CACHE = 'hub-v111';
const CACHE = 'hub-v112';
const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];