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

+1
View File
@@ -33,6 +33,7 @@ jobs:
- run: node tools/backup-test.js # backup restore (unsafe archives refused, old data kept) and personal export - run: node tools/backup-test.js # backup restore (unsafe archives refused, old data kept) and personal export
- run: node tools/listening-test.js # listening stats: real listening counted, seeks not, streak and finished books - run: node tools/listening-test.js # listening stats: real listening counted, seeks not, streak and finished books
- run: node tools/moviefam-test.js # Movies & TV: favorites and the family movie night vote - run: node tools/moviefam-test.js # Movies & TV: favorites and the family movie night vote
- run: node tools/signal-test.js # Signal: linking, messages, sending, live updates - run: node tools/signal-test.js # Signal: linking, messages, sending, live updates
- run: node tools/podcast-test.js # Podgrab podcasts: only allowed calls pass, password stays on the server - run: node tools/podcast-test.js # Podgrab podcasts: only allowed calls pass, password stays on the server
- run: node tools/abapi-test.js # audiobooks: signed in as the person, audio ranges, progress, blocked calls - run: node tools/abapi-test.js # audiobooks: signed in as the person, audio ranges, progress, blocked calls
+37 -2
View File
@@ -269,6 +269,30 @@ async function overview(light) {
return { connected: shown.length > 0, accounts: shown, expired: expired && !shown.length, address: (shown[0] || {}).address, name: (shown[0] || {}).name, unread: shown.reduce((n, r) => n + (r.unread || 0), 0) }; return { connected: shown.length > 0, accounts: shown, expired: expired && !shown.length, address: (shown[0] || {}).address, name: (shown[0] || {}).name, unread: shown.reduce((n, r) => n + (r.unread || 0), 0) };
} }
// ---- Mail 2.0: snooze, templates and a unified inbox. Snoozed messages and templates are kept by the hub for each person (the mail providers know nothing of them).
// Snooze hides a message from the inbox lists until a time, then it is back and the person is told.
const snoozeFile = () => path.join(users.dir(), 'mail-snoozed.json'), tplFile = () => path.join(users.dir(), 'mail-templates.json');
const readArr = f => { try { const a = JSON.parse(fs.readFileSync(f, 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; } };
const snoozed = () => readArr(snoozeFile()).filter(x => x && typeof x.id === 'string' && x.until > Date.now());
const isInbox = f => /(^|~)inbox$/i.test(String(f || 'inbox'));
const hideSnoozed = (items, folder) => { if (!isInbox(folder)) return items; const hide = new Set(snoozed().map(x => x.id)); return hide.size ? items.filter(m => !hide.has(m.id)) : items; };
/** Snoozed messages whose time has come, for one person (and forgets them), so the caller can tell the person. */
function snoozeDue(uid) {
return users.runAs(users.get(uid), () => {
const all = readArr(snoozeFile()), now = Date.now(), due = all.filter(x => x.until <= now);
if (due.length) atomicWrite(snoozeFile(), JSON.stringify(all.filter(x => x.until > now)), { mode: 0o600 });
return due;
});
}
const tplClean = b => { const name = String(b.name || '').replace(/[\u0000-\u001f]/g, '').trim().slice(0, 60); if (!name) throw fail(400, 'Give the template a name'); return { name, subject: String(b.subject || '').replace(/[\r\n]+/g, ' ').trim().slice(0, 200), body: String(b.body || '').slice(0, 20000) }; };
/** The newest inbox messages of every connected account, together: one list, newest first (the first page of each account). */
async function unifiedInbox(search) {
const accs = accounts(), parts = await Promise.all(accs.map(async a => { try { const r = await withTimeout(providerOf(a).list(a, { folder: 'inbox', skip: 0, q: search }), 12000); return r.items.map(m => ({ ...m, id: pack(a, m.id), account: a.name || a.address, accountId: a.id })); } catch { return []; } }));
return parts.flat().sort((x, y) => String(y.date || '').localeCompare(String(x.date || ''))).slice(0, 40);
}
/** An attachment's name and bytes (for saving into Files). `id` is the packed message id. */
async function attachmentOf(id, aid) { const [a, nid] = unpack(id), f = await providerOf(a).attachment(a, nid, aid); return { name: safeName(f.name), buffer: f.buffer }; }
async function handle(req, res) { async function handle(req, res) {
const u = new URL(req.url, 'http://x'), p = u.pathname, q = u.searchParams; const u = new URL(req.url, 'http://x'), p = u.pathname, q = u.searchParams;
try { try {
@@ -279,10 +303,13 @@ async function handle(req, res) {
const a = findAccount(q.get('acct') || undefined); const a = findAccount(q.get('acct') || undefined);
return json(res, 200, (await providerOf(a).folders(a)).map(f => ({ ...f, id: pack(a, f.id) }))); return json(res, 200, (await providerOf(a).folders(a)).map(f => ({ ...f, id: pack(a, f.id) })));
} }
if (p === '/api/mail/messages' && q.get('folder') === 'unified') { const items = hideSnoozed(await unifiedInbox((q.get('q') || '').replace(/["\\]/g, ' ').trim().slice(0, 100)), 'inbox'); return json(res, 200, { items, more: false, next: items.length }); }
if (p === '/api/mail/snoozed' && req.method === 'GET') return json(res, 200, snoozed());
if (p === '/api/mail/templates' && req.method === 'GET') return json(res, 200, readArr(tplFile()));
if (p === '/api/mail/messages') { if (p === '/api/mail/messages') {
const [a, fid] = unpack(q.get('folder') || 'inbox'), search = (q.get('q') || '').replace(/["\\]/g, ' ').trim().slice(0, 100), skip = Math.max(0, parseInt(q.get('skip'), 10) || 0); const [a, fid] = unpack(q.get('folder') || 'inbox'), search = (q.get('q') || '').replace(/["\\]/g, ' ').trim().slice(0, 100), skip = Math.max(0, parseInt(q.get('skip'), 10) || 0);
const r = await providerOf(a).list(a, { folder: fid || 'inbox', skip, q: search }); const r = await providerOf(a).list(a, { folder: fid || 'inbox', skip, q: search });
return json(res, 200, { ...r, items: r.items.map(m => ({ ...m, id: pack(a, m.id) })) }); return json(res, 200, { ...r, items: hideSnoozed(r.items.map(m => ({ ...m, id: pack(a, m.id) })), fid || 'inbox') });
} }
if (p === '/api/mail/message') { if (p === '/api/mail/message') {
const [a, nid] = unpack(q.get('id')), m = await providerOf(a).message(a, nid); const [a, nid] = unpack(q.get('id')), m = await providerOf(a).message(a, nid);
@@ -295,6 +322,14 @@ async function handle(req, res) {
} }
if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' }); if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' });
const b = JSON.parse((await readBody(req)) || '{}'), me = users.current(); const b = JSON.parse((await readBody(req)) || '{}'), me = users.current();
if (p === '/api/mail/snooze') { // hide a message from the inbox until a time (a message that cannot be found is refused)
const until = Math.round(+b.until); if (!(until > Date.now() && until < Date.now() + 366 * 864e5)) throw fail(400, 'Pick a time in the future (within a year)');
const [a, nid] = unpack(b.id), id = pack(a, nid), l = snoozed().filter(x => x.id !== id); l.push({ id, until, subject: String(b.subject || '').slice(0, 150), from: String(b.from || '').slice(0, 100) });
atomicWrite(snoozeFile(), JSON.stringify(l.slice(-500)), { mode: 0o600 }); return json(res, 200, { ok: true, until });
}
if (p === '/api/mail/unsnooze') { atomicWrite(snoozeFile(), JSON.stringify(snoozed().filter(x => x.id !== String(b.id))), { mode: 0o600 }); return json(res, 200, { ok: true }); }
if (p === '/api/mail/templates') { const t = tplClean(b), l = readArr(tplFile()), i = b.id ? l.findIndex(x => x.id === b.id) : -1; if (i >= 0) l[i] = { ...l[i], ...t }; else { if (l.length >= 50) throw fail(400, 'That is the most templates (50)'); l.push({ id: crypto.randomBytes(4).toString('hex'), ...t }); } atomicWrite(tplFile(), JSON.stringify(l), { mode: 0o600 }); return json(res, 200, l); }
if (p === '/api/mail/templates/delete') { const l = readArr(tplFile()).filter(x => x.id !== b.id); atomicWrite(tplFile(), JSON.stringify(l), { mode: 0o600 }); return json(res, 200, l); }
if (p === '/api/mail/accounts') { // add a Gmail or iCloud account (an app password; checked against the server before it is saved) if (p === '/api/mail/accounts') { // add a Gmail or iCloud account (an app password; checked against the server before it is saved)
const a = await imap.connectNew({ type: b.type, address: b.address, password: b.password }); const a = await imap.connectNew({ type: b.type, address: b.address, password: b.password });
const same = accounts().find(x => x.type === a.type && x.address.toLowerCase() === a.address.toLowerCase()); const same = accounts().find(x => x.type === a.type && x.address.toLowerCase() === a.address.toLowerCase());
@@ -343,4 +378,4 @@ async function markRead(refs) {
async function health() { async function health() {
return Promise.all(accounts().map(async a => { const t = Date.now(); try { await withTimeout(providerOf(a).unread(a), 8000); return { address: a.address, type: a.type, ok: true, ms: Date.now() - t }; } catch (e) { return { address: a.address, type: a.type, ok: false, error: e.message }; } })); return Promise.all(accounts().map(async a => { const t = Date.now(); try { await withTimeout(providerOf(a).unread(a), 8000); return { address: a.address, type: a.type, ok: true, ms: Date.now() - t }; } catch (e) { return { address: a.address, type: a.type, ok: false, error: e.message }; } }));
} }
module.exports = { markRead, health, newest, enabled, outlookOn, handle, callback, secretStatus, recipients, textToHtml }; module.exports = { snoozeDue, attachmentOf, markRead, health, newest, enabled, outlookOn, handle, callback, secretStatus, recipients, textToHtml };
+40 -10
View File
@@ -25,7 +25,7 @@ const mailWhen = iso => {
if (now - d < 6 * 864e5) return d.toLocaleDateString([], { weekday: 'short' }); 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' }); 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. */ /** The page a message is shown in: no scripts, nothing loaded from the internet unless the person allows pictures. */
function mailDoc(html, images) { function mailDoc(html, images) {
@@ -176,9 +176,10 @@ async function viewMail(args = []) {
const [kind, key] = args; const [kind, key] = args;
const idAcct = x => String(x || '').includes('~') ? String(x).split('~')[0] : ''; 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); 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'; } const keepUni = mail.folder === 'unified' && kind === 'm'; // reading a message from the all-inboxes list keeps that list behind it
if (kind === 'f' && key) mail.folder = idAcct(key) ? key : mail.acct + '~' + key; if (want !== mail.acct) { mail.acct = want; mail.folders = []; if (!keepUni) { mail.items = []; if (!(kind === 'f' && idAcct(key))) mail.folder = want + '~inbox'; } }
else if (!String(mail.folder).startsWith(mail.acct + '~')) mail.folder = mail.acct + '~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; } if (!mail.folders.length) { const f = mcGet('f:' + mail.acct); if (f) mail.folders = f.folders; }
mailShell(kind === 'm' && key ? 'read' : 'list'); mailShell(kind === 'm' && key ? 'read' : 'list');
const acct = mail.acct; const acct = mail.acct;
@@ -189,8 +190,8 @@ async function viewMail(args = []) {
mailPoll().then(() => { if (mail.st && location.hash.startsWith('#/mail')) mailRefreshFolders(); }); 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. */ /** 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>`; 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 === mail.acct) return; mail.q = ''; location.hash = '#/mail/f/' + encodeURIComponent(b.dataset.a + '~inbox'); }); } 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(); } 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. */ /** Redraw the folder pickers (names and unread counts) without touching the list or the open message. */
function mailRefreshFolders() { 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 // 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. // (it started before the change) must not put the old state back.
const mailMine = new Map(); const mailMine = new Map();
const mailInInbox = () => /(^|~)inbox$/.test(mail.folder); const mailInInbox = () => /(^|~)inbox$|^unified$/.test(mail.folder);
function mailSetRead(id, read) { function mailSetRead(id, read) {
mailMine.set(id, { read, t: Date.now() }); mailMine.set(id, { read, t: Date.now() });
const it = mail.items.find(x => x.id === id); if (it) it.read = read; 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> <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"> <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" 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> <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>`).join('')}</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> ${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>`; <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(); }; if ($('#mshow')) $('#mshow').onclick = () => { mail.images = true; mailDrawReader(); };
mailFit(rd.querySelector('.mbody')); mailFit(rd.querySelector('.mbody'));
if (typeof ctMailHook === 'function') ctMailHook(rd, m); if (typeof ctMailHook === 'function') ctMailHook(rd, m);
rd.querySelectorAll('[data-a]').forEach(b => b.onclick = () => mailAction(b.dataset.a)); 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) { async function mailAction(a) {
@@ -492,6 +494,7 @@ async function mailAction(a) {
try { try {
if (a === 'reply' || a === 'replyAll' || a === 'forward') return mailCompose({ mode: a, msg: m }); if (a === 'reply' || a === 'replyAll' || a === 'forward') return mailCompose({ mode: a, msg: m });
if (a === 'archive') return away('move', { to: 'archive' }, 'Archived'); 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 === '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 === '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 (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 && 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> ${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="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>`} ${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>`); <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; el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet;
if (o.to && el.querySelector('#mto')) el.querySelector('#mto').value = o.to; 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)); 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 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 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); 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. // 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 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 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']; const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
+4
View File
@@ -89,6 +89,10 @@ async function tick(now = new Date(), force = false) {
} }
} catch (e) { console.warn('[push] task times failed:', e.message); } } catch (e) { console.warn('[push] task times failed:', e.message); }
} }
if (checkEvents && users.can(u, 'mail')) { // snoozed mail whose time has come is back in the inbox: say so
try { const due = mail.snoozeDue(u.id); if (due.length) await send(dir, st, u.id, { title: due.length === 1 ? 'Back in your inbox: ' + (due[0].subject || 'a message') : due.length + ' snoozed messages are back', body: due.length === 1 ? due[0].from || '' : due.slice(0, 3).map(x => x.subject).filter(Boolean).join(', '), url: '#/mail', tag: 'snooze' }); }
catch (e) { console.warn('[push] snooze check failed:', e.message); }
}
if (st.tasks && users.can(u, 'contacts') && st.datesDay !== lt.day && lt.hm >= st.tasksAt) { // birthdays and anniversaries from Contacts, with the gift ideas, with the morning summary if (st.tasks && users.can(u, 'contacts') && st.datesDay !== lt.day && lt.hm >= st.tasksAt) { // birthdays and anniversaries from Contacts, with the gift ideas, with the morning summary
st.datesDay = lt.day; dirty = true; st.datesDay = lt.day; dirty = true;
try { try {
+5 -1
View File
@@ -6,7 +6,7 @@ const settings = require('./settings'), users = require('./users'), books = requ
const SEAFILE = () => (process.env.SEAFILE_URL || 'https://seafile.necloud.us').replace(/\/$/, ''); const SEAFILE = () => (process.env.SEAFILE_URL || 'https://seafile.necloud.us').replace(/\/$/, '');
const MAX_FILE = 500 * 1024 * 1024; const MAX_FILE = 500 * 1024 * 1024;
const TABS = { 'vbv-notes': 'podcast', 'vbv-lesson': 'podcast', 'vbv-study': 'podcast', book: 'read', 'librivox-book': 'audio', 'librivox-chapter': 'audio' }; const TABS = { 'vbv-notes': 'podcast', 'vbv-lesson': 'podcast', 'vbv-study': 'podcast', book: 'read', 'mail-attachment': 'mail', 'librivox-book': 'audio', 'librivox-chapter': 'audio' };
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); }; const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
const fail = (code, message) => Object.assign(new Error(message), { code }); const fail = (code, message) => Object.assign(new Error(message), { code });
const safe = (s, max = 100) => String(s || '').replace(/[\u0000-\u001f\\/:*?"<>|]+/g, ' ').replace(/\s+/g, ' ').replace(/^[. ]+|[. ]+$/g, '').slice(0, max).trim() || 'Untitled'; const safe = (s, max = 100) => String(s || '').replace(/[\u0000-\u001f\\/:*?"<>|]+/g, ' ').replace(/\s+/g, ' ').replace(/^[. ]+|[. ]+$/g, '').slice(0, max).trim() || 'Untitled';
@@ -44,6 +44,10 @@ async function plan(me, b) {
out.push({ folder: ['Books'], name: safe(f.author ? `${f.author} - ${f.title}` : f.title, 120) + '.' + f.ext, load: async () => fs.promises.readFile(f.path) }); out.push({ folder: ['Books'], name: safe(f.author ? `${f.author} - ${f.title}` : f.title, 120) + '.' + f.ext, load: async () => fs.promises.readFile(f.path) });
return { label: f.title, out }; return { label: f.title, out };
} }
if (kind === 'mail-attachment') {
const f = await require('./mail').attachmentOf(String(b.id || ''), String(b.aid || '')); if (f.buffer.length > MAX_FILE) throw fail(413, 'That attachment is too large to save');
out.push({ folder: ['Mail attachments'], name: safe(f.name, 120), load: async () => f.buffer }); return { label: f.name, out };
}
if (kind === 'librivox-book' || kind === 'librivox-chapter') { if (kind === 'librivox-book' || kind === 'librivox-chapter') {
const bk = await librivox.book(String(b.id || '')), only = kind === 'librivox-chapter' ? +b.n : -1; const bk = await librivox.book(String(b.id || '')), only = kind === 'librivox-chapter' ? +b.n : -1;
bk.chapters.forEach(c => { if (only >= 0 && c.n !== only) return; out.push({ folder: ['Audiobooks', safe(bk.title)], name: `${num(c.n, bk.chapters.length)} - ${safe(c.title, 80)}.mp3`, load: () => download(c.url, librivox.allowed, 'NE-Hub/1.0') }); }); bk.chapters.forEach(c => { if (only >= 0 && c.n !== only) return; out.push({ folder: ['Audiobooks', safe(bk.title)], name: `${num(c.n, bk.chapters.length)} - ${safe(c.title, 80)}.mp3`, load: () => download(c.url, librivox.allowed, 'NE-Hub/1.0') }); });
+13
View File
@@ -109,6 +109,19 @@ const SECRET = 'mail-test-secret-'.repeat(3);
ok('and says there may be more', r.json.more === true && r.json.next === 2, r.json); ok('and says there may be more', r.json.more === true && r.json.next === 2, r.json);
r = await call('unate', '/api/mail/messages?folder=inbox&q=second'); ok('search finds a message', r.json.items.length === 1 && /~M2$/.test(r.json.items[0].id), r.json); r = await call('unate', '/api/mail/messages?folder=inbox&q=second'); ok('search finds a message', r.json.items.length === 1 && /~M2$/.test(r.json.items[0].id), r.json);
r = await call('unate', '/api/mail/messages?folder=../me'); ok('a folder name cannot break out of the mail path', r.status === 400, r.status); r = await call('unate', '/api/mail/messages?folder=../me'); ok('a folder name cannot break out of the mail path', r.status === 400, r.status);
// unified inbox, snooze and templates
r = await call('unate', '/api/mail/messages?folder=unified'); ok('the unified inbox lists the newest of every account together', r.status === 200 && r.json.items.length === 2 && r.json.items.every(m => m.account && m.accountId) && r.json.more === false, r.json);
const sid = r.json.items[0].id; r = await post('unate', '/api/mail/snooze', { id: sid, until: Date.now() - 1000 }); ok('snoozing needs a future time', r.status === 400, r.status);
r = await post('unate', '/api/mail/snooze', { id: sid, until: Date.now() + 3600e3, subject: 'Hello', from: 'Ann' }); ok('a message can be snoozed', r.status === 200, r.json);
r = await call('unate', '/api/mail/messages?folder=inbox'); ok('a snoozed message leaves the inbox', r.json.items.length === 1 && !r.json.items.some(m => m.id === sid), r.json);
r = await call('unate', '/api/mail/messages?folder=unified'); ok('and the unified inbox', r.json.items.length === 1, r.json);
r = await call('unate', '/api/mail/snoozed'); ok('the snoozed list says what and until when', r.json.length === 1 && r.json[0].id === sid && r.json[0].subject === 'Hello', r.json);
r = await post('unate', '/api/mail/unsnooze', { id: sid }); r = await call('unate', '/api/mail/messages?folder=inbox'); ok('unsnoozing brings it back', r.json.items.length === 2, r.json);
r = await post('unate', '/api/mail/templates', { name: 'Thanks', subject: 'Thank you', body: 'Thanks so much!\nNate' }); ok('a template can be saved', r.status === 200 && r.json.length === 1 && r.json[0].name === 'Thanks' && r.json[0].id, r.json);
const tid = r.json[0].id; r = await post('unate', '/api/mail/templates', { id: tid, name: 'Thanks!', subject: 'Thank you', body: 'Hi' }); ok('and changed', r.json.length === 1 && r.json[0].name === 'Thanks!' && r.json[0].body === 'Hi', r.json);
r = await post('unate', '/api/mail/templates', { name: ' ' }); ok('a template needs a name', r.status === 400, r.status);
r = await call('uann', '/api/mail/templates'); ok('templates are private to a person', r.status === 200 || r.status === 409 ? !(r.json || []).length : false, r);
r = await post('unate', '/api/mail/templates/delete', { id: tid }); ok('and deleted', r.json.length === 0, r.json);
r = await call('unate', '/api/mail/message?id=M1'); const mm = r.json; r = await call('unate', '/api/mail/message?id=M1'); const mm = r.json;
ok('a message opens with sender, recipients and subject', r.status === 200 && mm.subject === 'Hello there' && mm.to[0].address === 'nate@outlook.com', mm); ok('a message opens with sender, recipients and subject', r.status === 200 && mm.subject === 'Hello there' && mm.to[0].address === 'nate@outlook.com', mm);
ok('pictures that are part of the message are put inline (cid -> data)', /data:image\/png;base64,/.test(mm.html) && !/cid:logo1/.test(mm.html), mm.html); ok('pictures that are part of the message are put inline (cid -> data)', /data:image\/png;base64,/.test(mm.html) && !/cid:logo1/.test(mm.html), mm.html);