From 808274b4fec82db26753e831610ee0b7c84c2122 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:25:48 -0400 Subject: [PATCH] Mail: unified inbox, snooze, templates, and saving attachments to Files Co-Authored-By: Claude Sonnet 5.5 --- .gitea/workflows/build.yml | 1 + mail.js | 39 +++++++++++++++++++++++++++-- public/mail.js | 50 ++++++++++++++++++++++++++++++-------- public/sw.js | 2 +- push.js | 4 +++ savefiles.js | 6 ++++- tools/mail-test.js | 13 ++++++++++ 7 files changed, 101 insertions(+), 14 deletions(-) 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 => ``).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 ? `` : ''}${mail.accts.map(a => ``).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' }))}

-
- ${m.attachments.length ? `
${m.attachments.map(a => `${icon('clip')}${esc(a.name)} ${esc(fmtSize(a.size || 0))}`).join('')}
` : ''} + + ${m.attachments.length ? `
${m.attachments.map(a => `${icon('clip')}${esc(a.name)} ${esc(fmtSize(a.size || 0))}${allowed('files') ? `` : ''}`).join('')}
` : ''} ${remote ? '
Pictures are hidden to protect your privacy.
' : ''} `; 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 ? `` : ''}`} ${reply ? '' : `
Add Cc / Bcc
`} - +
${reply ? '' : `
`}
`); 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, `
${opts.map(([l, t], i) => `${l}${new Date(t).toLocaleString([], { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}`).join('')}
+
`); + 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 = ``; + 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);