diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml
index 8219d61..b26d192 100644
--- a/.gitea/workflows/build.yml
+++ b/.gitea/workflows/build.yml
@@ -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/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/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/abapi-test.js # audiobooks: signed in as the person, audio ranges, progress, blocked calls
diff --git a/mail.js b/mail.js
index bfee382..7868a8b 100644
--- a/mail.js
+++ b/mail.js
@@ -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) };
}
+// ---- 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) {
const u = new URL(req.url, 'http://x'), p = u.pathname, q = u.searchParams;
try {
@@ -279,10 +303,13 @@ async function handle(req, res) {
const a = findAccount(q.get('acct') || undefined);
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') {
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 });
- 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') {
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' });
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)
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());
@@ -343,4 +378,4 @@ async function markRead(refs) {
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 }; } }));
}
-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 };
diff --git a/public/mail.js b/public/mail.js
index 6b0e0ac..a8b1206 100644
--- a/public/mail.js
+++ b/public/mail.js
@@ -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 = () => `
${mail.accts.map(a => `${esc(a.name || a.address)} ${esc(a.name ? a.address : '')} ${a.unread ? `${a.unread > 99 ? '99+' : a.unread} ` : ''} `).join('')}
`;
-function mailBindChips() { document.querySelectorAll('.maccts button').forEach(b => b.onclick = () => { if (b.dataset.a === mail.acct) return; mail.q = ''; location.hash = '#/mail/f/' + encodeURIComponent(b.dataset.a + '~inbox'); }); }
+const mailChips = () => `${mail.accts.length > 1 ? `All inboxes every account ` : ''}${mail.accts.map(a => `${esc(a.name || a.address)} ${esc(a.name ? a.address : '')} ${a.unread ? `${a.unread > 99 ? '99+' : a.unread} ` : ''} `).join('')}
`;
+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() {
To ${addr(m.to) || 'me'}${m.cc.length ? ' · Cc ' + addr(m.cc) : ''} · ${esc(new Date(m.date).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }))}
Reply Reply all Forward
- Archive Mark unread ${m.flagged ? 'Unflag' : 'Flag'} Delete
- ${m.attachments.length ? `` : ''}
+ Archive Snooze Mark unread ${m.flagged ? 'Unflag' : 'Flag'} Delete
+ ${m.attachments.length ? `` : ''}
${remote ? 'Pictures are hidden to protect your privacy. Show pictures
' : ''}
`;
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' ? `To ${esc(reply === 'reply' ? mailFrom(m.from) : 'everyone on the message')}. The original is quoted below your text automatically.
` : `${mail.accts.length > 1 ? `From ${mail.accts.map(a => `${esc(a.address)} `).join('')} ` : ''}To `}
${reply ? '' : `Cc Bcc
Subject `}
- ${reply ? 'Your message' : 'Message'}
+ ${reply ? 'Your message' : 'Message'}
${reply ? '' : `${icon('clip')}Attach
`}
Cancel Send
`);
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, `
+
`);
+ 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 = `Templates${list.length ? ' (' + list.length + ')' : ''} Save as template `;
+ 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', `${list.map(t => `
`).join('')}
`);
+ 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', '', ``);
+ 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(() => {});
+}
diff --git a/public/sw.js b/public/sw.js
index a0bd3f2..009dcbb 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -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'];
diff --git a/push.js b/push.js
index ec37017..d81f73e 100644
--- a/push.js
+++ b/push.js
@@ -89,6 +89,10 @@ async function tick(now = new Date(), force = false) {
}
} 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
st.datesDay = lt.day; dirty = true;
try {
diff --git a/savefiles.js b/savefiles.js
index 380926a..fb74a46 100644
--- a/savefiles.js
+++ b/savefiles.js
@@ -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 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 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';
@@ -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) });
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') {
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') }); });
diff --git a/tools/mail-test.js b/tools/mail-test.js
index d915362..f82145e 100644
--- a/tools/mail-test.js
+++ b/tools/mail-test.js
@@ -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);
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);
+ // 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;
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);