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:
1 parent
54d18594a3
commit
808274b4fe
7 files changed
+101
-14
No files matched your search
@@ -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 };
|
||||
Reference in new issue
Block a user