Mail: Gmail and iCloud (IMAP/SMTP with app passwords) alongside Outlook.com, several accounts per person with an account switcher, From choice and combined unread
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4173d2c058
commit
004209ed4f
10 files changed
+1274
-127
No files matched your search
@@ -1,6 +1,6 @@
|
||||
// Mail for a personal Outlook.com account, through Microsoft Graph (the supported way now that Outlook.com has switched off IMAP passwords).
|
||||
// Each person connects their own account: they sign in on Microsoft's page, the hub keeps only the tokens (encrypted, in their own folder),
|
||||
// and every mail request runs as that person. Needs an app registration (MS_CLIENT_ID, see README -> Mail).
|
||||
// Mail: several accounts per person. Outlook.com (personal) goes through Microsoft Graph (sign in on Microsoft's page, needs an app registration,
|
||||
// see README -> Mail); Gmail and iCloud go through IMAP/SMTP with an app password (mail-imap.js). Every account's credentials are kept encrypted in
|
||||
// that person's own folder, and every request runs as that person. Message and folder ids carry the account: "<account>~<id>".
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
@@ -8,6 +8,7 @@ const atomicWrite = require('./atomic');
|
||||
const users = require('./users');
|
||||
const audit = require('./audit');
|
||||
const vault = require('./vault')('nehub-mail');
|
||||
const imap = require('./mail-imap');
|
||||
|
||||
const ENV_ID = process.env.MS_CLIENT_ID || '', ENV_SECRET = process.env.MS_CLIENT_SECRET || '';
|
||||
const TENANT = process.env.MS_TENANT || 'consumers'; // personal Microsoft accounts only
|
||||
@@ -23,7 +24,8 @@ const loadCfg = () => { if (cfgCache !== undefined) return cfgCache; try { cfgCa
|
||||
const saveCfg = o => { atomicWrite(CONFIG_FILE, JSON.stringify({ data: vault.seal(o) }), { mode: 0o600 }); cfgCache = o; };
|
||||
const clientId = () => ENV_ID || loadCfg().clientId || '';
|
||||
const clientSecret = () => ENV_ID ? ENV_SECRET : loadCfg().clientSecret || '';
|
||||
const enabled = () => !!clientId();
|
||||
const outlookOn = () => !!clientId();
|
||||
const enabled = () => true; // Gmail and iCloud need no server setup; only the Outlook.com button needs the Microsoft app
|
||||
/** How long the saved client secret lasts: { expires, days } or null. */
|
||||
function secretStatus() {
|
||||
const e = loadCfg().secretExpires; if (!e || ENV_ID) return null;
|
||||
@@ -37,11 +39,25 @@ const readBody = req => new Promise((ok, no) => { const c = []; let n = 0; req.o
|
||||
const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(obj)); };
|
||||
const fail = (status, message) => Object.assign(new Error(message), { status });
|
||||
|
||||
// ---- per-person token file (encrypted)
|
||||
const file = () => path.join(users.dir(), 'mail.json');
|
||||
const load = () => { try { return vault.unseal(JSON.parse(fs.readFileSync(file(), 'utf8')).data) || null; } catch { return null; } };
|
||||
const save = o => atomicWrite(file(), JSON.stringify({ data: vault.seal(o) }), { mode: 0o600 });
|
||||
const wipe = () => { try { fs.unlinkSync(file()); } catch {} };
|
||||
// ---- each person's accounts (encrypted): [{ id, type: 'outlook' | 'gmail' | 'icloud', address, name, ...credentials }]
|
||||
const accFile = () => path.join(users.dir(), 'mail-accounts.json');
|
||||
const legacyFile = () => path.join(users.dir(), 'mail.json'); // one Outlook.com account, from before there were several
|
||||
const accounts = () => {
|
||||
try { return (vault.unseal(JSON.parse(fs.readFileSync(accFile(), 'utf8')).data) || {}).accounts || []; } catch {}
|
||||
try { // migrate the old single-account file
|
||||
const old = vault.unseal(JSON.parse(fs.readFileSync(legacyFile(), 'utf8')).data);
|
||||
if (old) { const list = [{ id: crypto.randomBytes(3).toString('hex'), type: 'outlook', ...old }]; saveAccounts(list); fs.rmSync(legacyFile(), { force: true }); return list; }
|
||||
} catch {}
|
||||
return [];
|
||||
};
|
||||
const saveAccounts = list => atomicWrite(accFile(), JSON.stringify({ data: vault.seal({ accounts: list }) }), { mode: 0o600 });
|
||||
const putAccount = a => { const list = accounts(), i = list.findIndex(x => x.id === a.id); if (i >= 0) list[i] = a; else list.push(a); saveAccounts(list); return a; };
|
||||
const dropAccount = id => { saveAccounts(accounts().filter(x => x.id !== id)); imap.forget(id); folderCache.clear(); };
|
||||
const findAccount = id => { const list = accounts(); const a = id ? list.find(x => x.id === id) : list[0]; if (!a) throw fail(id ? 404 : 409, id ? 'That account is not connected' : 'Mail is not connected'); return a; };
|
||||
const publicAcct = a => ({ id: a.id, type: a.type, address: a.address || '', name: a.name || '' });
|
||||
/** "<account>~<native id>" -> [account, native id]. An id with no account (older links) belongs to the first account. */
|
||||
const unpack = v => { const s = String(v || ''), i = s.indexOf('~'); return i > 0 ? [findAccount(s.slice(0, i)), s.slice(i + 1)] : [findAccount(), s]; };
|
||||
const pack = (a, nid) => a.id + '~' + nid;
|
||||
|
||||
// ---- sign-in with Microsoft (authorization code + PKCE)
|
||||
const states = new Map(); // state -> { uid, verifier, redirect, exp }
|
||||
@@ -78,7 +94,8 @@ async function callback(req, res) {
|
||||
if (!t.refresh_token) throw new Error('Microsoft did not allow ongoing access');
|
||||
await users.runAs(user, async () => {
|
||||
const me = await (await fetch(GRAPH + '/me?$select=displayName,mail,userPrincipalName', { headers: { authorization: 'Bearer ' + t.access_token } })).json().catch(() => ({}));
|
||||
save({ access: t.access_token, refresh: t.refresh_token, exp: Date.now() + (t.expires_in || 3600) * 1000, address: me.mail || me.userPrincipalName || '', name: me.displayName || '' });
|
||||
const address = me.mail || me.userPrincipalName || '', same = accounts().find(x => x.type === 'outlook' && x.address && x.address.toLowerCase() === address.toLowerCase());
|
||||
putAccount({ id: same ? same.id : crypto.randomBytes(3).toString('hex'), type: 'outlook', access: t.access_token, refresh: t.refresh_token, exp: Date.now() + (t.expires_in || 3600) * 1000, address, name: me.displayName || '' });
|
||||
saveCfg({ ...loadCfg(), verifiedAt: Date.now() }); // proof that the app registration works
|
||||
audit.log('mail connected', { who: user.name, detail: me.mail || me.userPrincipalName || '' }, req);
|
||||
});
|
||||
@@ -86,31 +103,30 @@ async function callback(req, res) {
|
||||
} catch (e) { console.error('[mail] sign-in failed:', e.message); send(400, page('Could not connect', escHtml(friendly(e)))); }
|
||||
}
|
||||
|
||||
const refreshing = new Map(); // uid -> promise: parallel requests share one refresh (refresh tokens can rotate)
|
||||
/** A working access token for the current person, refreshed when needed. `stale` is the token that just failed. */
|
||||
async function token(force, stale) {
|
||||
const st = load(); if (!st) throw fail(409, 'Mail is not connected');
|
||||
const refreshing = new Map(); // account id -> promise: parallel requests share one refresh (refresh tokens can rotate)
|
||||
/** A working access token for an Outlook account, refreshed when needed. `stale` is the token that just failed. */
|
||||
async function token(acct, force, stale) {
|
||||
const st = accounts().find(x => x.id === acct.id); if (!st) throw fail(409, 'Mail is not connected');
|
||||
if (!force && st.exp - 60e3 > Date.now()) return st.access;
|
||||
if (force && st.access !== stale) return st.access; // another request already refreshed it
|
||||
const uid = users.current().id;
|
||||
if (refreshing.has(uid)) return refreshing.get(uid);
|
||||
if (refreshing.has(st.id)) return refreshing.get(st.id);
|
||||
const job = (async () => {
|
||||
try {
|
||||
const t = await tokenCall({ grant_type: 'refresh_token', refresh_token: st.refresh });
|
||||
save({ ...st, access: t.access_token, refresh: t.refresh_token || st.refresh, exp: Date.now() + (t.expires_in || 3600) * 1000 });
|
||||
putAccount({ ...st, access: t.access_token, refresh: t.refresh_token || st.refresh, exp: Date.now() + (t.expires_in || 3600) * 1000 });
|
||||
return t.access_token;
|
||||
} catch (e) {
|
||||
if (e.code === 'invalid_grant' || e.status === 400 || e.status === 401) { wipe(); throw fail(409, 'Microsoft signed you out of mail. Connect again.'); }
|
||||
if (e.code === 'invalid_grant' || e.status === 400 || e.status === 401) { dropAccount(st.id); throw fail(409, 'Microsoft signed you out of mail. Connect again.'); }
|
||||
throw fail(502, 'Could not reach Microsoft: ' + e.message);
|
||||
}
|
||||
})().finally(() => refreshing.delete(uid));
|
||||
refreshing.set(uid, job);
|
||||
})().finally(() => refreshing.delete(st.id));
|
||||
refreshing.set(st.id, job);
|
||||
return job;
|
||||
}
|
||||
async function graph(p, opts = {}, stale) {
|
||||
const used = await token(stale !== undefined, stale);
|
||||
async function graph(acct, p, opts = {}, stale) {
|
||||
const used = await token(acct, stale !== undefined, stale);
|
||||
const r = await fetch(GRAPH + p, { ...opts, headers: { authorization: 'Bearer ' + used, ...(opts.body && typeof opts.body === 'string' ? { 'content-type': 'application/json' } : {}), ...(opts.headers || {}) }, signal: AbortSignal.timeout(30000) });
|
||||
if (r.status === 401 && stale === undefined) return graph(p, opts, used);
|
||||
if (r.status === 401 && stale === undefined) return graph(acct, p, opts, used);
|
||||
if (r.status === 204 || r.status === 202) return null;
|
||||
if (opts.raw && r.ok) return r;
|
||||
const j = await r.json().catch(() => ({}));
|
||||
@@ -125,30 +141,74 @@ const id = v => { if (!ID.test(String(v || ''))) throw fail(400, 'Bad id'); retu
|
||||
const addr = a => a && a.emailAddress ? { name: a.emailAddress.name || '', address: a.emailAddress.address || '' } : { name: '', address: '' };
|
||||
const row = m => ({ id: m.id, subject: m.subject || '(No subject)', from: addr(m.from), date: m.receivedDateTime, read: !!m.isRead, attach: !!m.hasAttachments, preview: m.bodyPreview || '', flagged: !!(m.flag && m.flag.flagStatus === 'flagged') });
|
||||
const EMAIL = /^[^\s@<>,;]+@[^\s@<>,;]+\.[^\s@<>,;]+$/;
|
||||
const people = (list, label) => { const out = []; for (const x of Array.isArray(list) ? list : String(list || '').split(/[,;]/)) { const a = String(x).trim(); if (!a) continue; const m = /<([^>]+)>/.exec(a), e = (m ? m[1] : a).trim(); if (!EMAIL.test(e)) throw fail(400, `“${a}” is not an email address (${label})`); out.push({ emailAddress: { address: e } }); } return out; };
|
||||
/** "a@b.co, Ann <ann@x.org>" -> [{ name, address }], refusing anything that is not an address. */
|
||||
const recipients = (list, label) => { const out = []; for (const x of Array.isArray(list) ? list : String(list || '').split(/[,;]/)) { const a = String(x).trim(); if (!a) continue; const m = /^(.*?)\s*<([^>]+)>$/.exec(a), e = (m ? m[2] : a).trim(); if (!EMAIL.test(e)) throw fail(400, `“${a}” is not an email address (${label})`); out.push({ name: m ? m[1].replace(/^"|"$/g, '') : '', address: e }); } return out; };
|
||||
const toGraph = list => list.map(p => ({ emailAddress: { address: p.address } }));
|
||||
const textToHtml = t => '<div style="white-space:pre-wrap;font-family:sans-serif">' + escHtml(t).replace(/\r?\n/g, '<br>') + '</div>';
|
||||
const safeName = n => String(n || 'attachment').replace(/[\r\n"\\/]+/g, '_').slice(0, 150);
|
||||
const cleanAttachments = list => { let total = 0; const out = (Array.isArray(list) ? list : []).slice(0, 10).map(a => { const data = String(a.data || ''); total += Buffer.from(data, 'base64').length; return { name: safeName(a.name), type: String(a.type || 'application/octet-stream').slice(0, 100), data }; }); if (total > MAX_ATTACH) throw fail(400, 'Attachments can total about 3 MB'); return out; };
|
||||
|
||||
let folderCache = new Map(); // uid -> { t, v }
|
||||
async function folders() {
|
||||
const uid = users.current().id, c = folderCache.get(uid);
|
||||
if (c && Date.now() - c.t < 15e3) return c.v;
|
||||
const sel = '?$select=id,displayName,unreadItemCount,totalItemCount';
|
||||
const wk = await Promise.all(Object.keys(WELL).map(k => graph(`/me/mailFolders/${k}${sel}`).then(f => ({ id: k, realId: f.id, name: WELL[k], unread: f.unreadItemCount || 0, total: f.totalItemCount || 0, well: true })).catch(() => null)));
|
||||
const known = new Set(wk.filter(Boolean).map(f => f.realId));
|
||||
const rest = (await graph(`/me/mailFolders${sel}&$top=100`).catch(() => ({ value: [] }))).value || [];
|
||||
const v = [...wk.filter(Boolean).map(({ realId, ...f }) => f), ...rest.filter(f => !known.has(f.id) && !['Outbox', 'Conversation History', 'Sync Issues', 'RSS Feeds'].includes(f.displayName)).map(f => ({ id: f.id, name: f.displayName, unread: f.unreadItemCount || 0, total: f.totalItemCount || 0 }))];
|
||||
folderCache.set(uid, { t: Date.now(), v }); return v;
|
||||
}
|
||||
const dropFolderCache = () => folderCache.delete(users.current().id);
|
||||
let folderCache = new Map(); // account id -> { t, v }
|
||||
const dropFolderCache = id => id ? folderCache.delete(id) : folderCache.clear();
|
||||
|
||||
async function withInline(html, attachments, mid) {
|
||||
// ---- Outlook.com through Microsoft Graph. Every function takes the account and Graph's own ids.
|
||||
const outlook = {
|
||||
async folders(a) {
|
||||
const c = folderCache.get(a.id); if (c && Date.now() - c.t < 15e3) return c.v;
|
||||
const sel = '?$select=id,displayName,unreadItemCount,totalItemCount';
|
||||
const wk = await Promise.all(Object.keys(WELL).map(k => graph(a, `/me/mailFolders/${k}${sel}`).then(f => ({ id: k, realId: f.id, name: WELL[k], unread: f.unreadItemCount || 0, total: f.totalItemCount || 0, well: true })).catch(() => null)));
|
||||
const known = new Set(wk.filter(Boolean).map(f => f.realId));
|
||||
const rest = (await graph(a, `/me/mailFolders${sel}&$top=100`).catch(() => ({ value: [] }))).value || [];
|
||||
const v = [...wk.filter(Boolean).map(({ realId, ...f }) => f), ...rest.filter(f => !known.has(f.id) && !['Outbox', 'Conversation History', 'Sync Issues', 'RSS Feeds'].includes(f.displayName)).map(f => ({ id: f.id, name: f.displayName, unread: f.unreadItemCount || 0, total: f.totalItemCount || 0 }))];
|
||||
folderCache.set(a.id, { t: Date.now(), v }); return v;
|
||||
},
|
||||
async list(a, { folder, skip, q }) {
|
||||
const fid = id(folder || 'inbox'), sel = '$select=id,subject,from,receivedDateTime,isRead,hasAttachments,bodyPreview,flag';
|
||||
const r = await graph(a, q ? `/me/mailFolders/${fid}/messages?${sel}&$top=50&$search="${encodeURIComponent(q)}"` : `/me/mailFolders/${fid}/messages?${sel}&$top=30&$skip=${skip}&$orderby=receivedDateTime desc`.replace(/ /g, '%20'));
|
||||
const items = (r.value || []).map(row); if (q) items.sort((x, y) => String(y.date).localeCompare(String(x.date)));
|
||||
return { items, more: !q && !!r['@odata.nextLink'], next: skip + items.length };
|
||||
},
|
||||
async message(a, mid) {
|
||||
const m = await graph(a, `/me/messages/${id(mid)}?$select=id,subject,from,toRecipients,ccRecipients,receivedDateTime,isRead,body,hasAttachments,flag,parentFolderId&$expand=attachments($select=id,name,contentType,size,isInline)`);
|
||||
let html = m.body && m.body.contentType === 'html' ? m.body.content || '' : textToHtml((m.body && m.body.content) || '');
|
||||
html = await withInline(a, html, m.attachments, mid);
|
||||
return { id: m.id, subject: m.subject || '(No subject)', from: addr(m.from), to: (m.toRecipients || []).map(addr), cc: (m.ccRecipients || []).map(addr), date: m.receivedDateTime, read: !!m.isRead, flagged: !!(m.flag && m.flag.flagStatus === 'flagged'),
|
||||
html, attachments: (m.attachments || []).filter(x => !x.isInline).map(x => ({ id: x.id, name: x.name, type: x.contentType, size: x.size })) };
|
||||
},
|
||||
async attachment(a, mid, aid) {
|
||||
const meta = await graph(a, `/me/messages/${id(mid)}/attachments/${id(aid)}?$select=name,contentType,size`);
|
||||
const r = await graph(a, `/me/messages/${id(mid)}/attachments/${id(aid)}/$value`, { raw: true });
|
||||
return { name: meta.name, type: meta.contentType, buffer: Buffer.from(await r.arrayBuffer()) };
|
||||
},
|
||||
async update(a, mid, { read, flagged }) {
|
||||
const patch = {}; if (typeof read === 'boolean') patch.isRead = read; if (typeof flagged === 'boolean') patch.flag = { flagStatus: flagged ? 'flagged' : 'notFlagged' };
|
||||
if (!Object.keys(patch).length) throw fail(400, 'Nothing to change');
|
||||
await graph(a, `/me/messages/${id(mid)}`, { method: 'PATCH', body: JSON.stringify(patch) });
|
||||
},
|
||||
async move(a, mid, to) { await graph(a, `/me/messages/${id(mid)}/move`, { method: 'POST', body: JSON.stringify({ destinationId: decodeURIComponent(id(to)) }) }); },
|
||||
async del(a, mid, permanent) {
|
||||
if (permanent) await graph(a, `/me/messages/${id(mid)}`, { method: 'DELETE' });
|
||||
else await graph(a, `/me/messages/${id(mid)}/move`, { method: 'POST', body: JSON.stringify({ destinationId: 'deleteditems' }) });
|
||||
},
|
||||
async send(a, { to, cc, bcc, subject, body, attachments }) {
|
||||
await graph(a, '/me/sendMail', { method: 'POST', body: JSON.stringify({ message: { subject, body: { contentType: 'HTML', content: textToHtml(body) }, toRecipients: toGraph(to), ccRecipients: toGraph(cc), bccRecipients: toGraph(bcc),
|
||||
attachments: attachments.map(x => ({ '@odata.type': '#microsoft.graph.fileAttachment', name: x.name, contentType: x.type, contentBytes: x.data })) }, saveToSentItems: true }) });
|
||||
},
|
||||
async reply(a, { id: mid, mode, comment, to }) {
|
||||
const body = { comment }; if (mode === 'forward') body.toRecipients = toGraph(to);
|
||||
await graph(a, `/me/messages/${id(mid)}/${mode}`, { method: 'POST', body: JSON.stringify(body) });
|
||||
},
|
||||
async unread(a) { return (await graph(a, '/me/mailFolders/inbox?$select=unreadItemCount')).unreadItemCount || 0; },
|
||||
};
|
||||
const providerOf = a => a.type === 'outlook' ? outlook : imap;
|
||||
|
||||
async function withInline(a, html, attachments, mid) {
|
||||
// (contentId only exists on file attachments, so it cannot be selected in the list: fetch each small inline picture whole)
|
||||
const inl = (attachments || []).filter(a => a.isInline && /^image\//i.test(a.contentType || '') && (a.size || 0) < 1.5 * 1024 * 1024).slice(0, 15);
|
||||
for (const a of inl) {
|
||||
const inl = (attachments || []).filter(x => x.isInline && /^image\//i.test(x.contentType || '') && (x.size || 0) < 1.5 * 1024 * 1024).slice(0, 15);
|
||||
for (const x of inl) {
|
||||
try {
|
||||
const full = await graph(`/me/messages/${id(mid)}/attachments/${id(a.id)}`);
|
||||
if (full && full.contentBytes && full.contentId) html = html.split('cid:' + String(full.contentId).replace(/^<|>$/g, '')).join(`data:${full.contentType || a.contentType};base64,${full.contentBytes}`);
|
||||
const full = await graph(a, `/me/messages/${id(mid)}/attachments/${id(x.id)}`);
|
||||
if (full && full.contentBytes && full.contentId) html = html.split('cid:' + String(full.contentId).replace(/^<|>$/g, '')).join(`data:${full.contentType || x.contentType};base64,${full.contentBytes}`);
|
||||
} catch { /* leave that picture out */ }
|
||||
}
|
||||
return html;
|
||||
@@ -186,72 +246,77 @@ async function adminHandle(req, res, p) {
|
||||
json(res, 404, { error: 'Not found' });
|
||||
}
|
||||
|
||||
/** Every unread count in one go (an account that cannot be reached is just left out of the total). */
|
||||
async function overview() {
|
||||
const list = accounts();
|
||||
const rows = await Promise.all(list.map(async a => {
|
||||
try { return { ...publicAcct(a), unread: await providerOf(a).unread(a) }; }
|
||||
catch (e) { return { ...publicAcct(a), unread: null, error: e.message, expired: e.status === 409 }; }
|
||||
}));
|
||||
const live = accounts(); // (an account whose sign-in Microsoft revoked was dropped meanwhile)
|
||||
const shown = rows.filter(r => live.some(x => x.id === r.id) || !r.expired);
|
||||
const expired = rows.some(r => r.expired);
|
||||
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) };
|
||||
}
|
||||
|
||||
async function handle(req, res) {
|
||||
const u = new URL(req.url, 'http://x'), p = u.pathname, q = u.searchParams;
|
||||
try {
|
||||
if (p === '/api/mail/admin' || p.startsWith('/api/mail/admin/')) return await adminHandle(req, res, p);
|
||||
if (p === '/api/mail/status') {
|
||||
const st = load();
|
||||
if (!st) return json(res, 200, { connected: false });
|
||||
let unread = null; try { unread = (await graph('/me/mailFolders/inbox?$select=unreadItemCount')).unreadItemCount; } catch (e) { if (e.status === 409) return json(res, 200, { connected: false, expired: true }); }
|
||||
return json(res, 200, { connected: true, address: st.address, name: st.name, unread });
|
||||
if (p === '/api/mail/status') return json(res, 200, { ...(await overview()), outlook: outlookOn(), providers: imap.PRESETS });
|
||||
if (p === '/api/mail/connect' && req.method === 'GET') { if (!outlookOn()) throw fail(403, 'Outlook.com is not set up on this server (an admin adds the Microsoft app in Settings)'); return json(res, 200, { url: startUrl(req), redirect: redirectUri(req) }); }
|
||||
if (p === '/api/mail/folders') {
|
||||
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/connect' && req.method === 'GET') return json(res, 200, { url: startUrl(req), redirect: redirectUri(req) });
|
||||
if (p === '/api/mail/disconnect' && req.method === 'POST') { wipe(); dropFolderCache(); audit.log('mail disconnected', { who: users.current().name }, req); return json(res, 200, { ok: true }); }
|
||||
if (p === '/api/mail/folders') return json(res, 200, await folders());
|
||||
if (p === '/api/mail/messages') {
|
||||
const folder = id(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 sel = '$select=id,subject,from,receivedDateTime,isRead,hasAttachments,bodyPreview,flag';
|
||||
const r = await graph(search ? `/me/mailFolders/${folder}/messages?${sel}&$top=50&$search="${encodeURIComponent(search)}"` : `/me/mailFolders/${folder}/messages?${sel}&$top=30&$skip=${skip}&$orderby=receivedDateTime desc`.replace(/ /g, '%20'));
|
||||
const items = (r.value || []).map(row); if (search) items.sort((a, b) => String(b.date).localeCompare(String(a.date)));
|
||||
return json(res, 200, { items, more: !search && !!r['@odata.nextLink'], next: skip + items.length });
|
||||
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) })) });
|
||||
}
|
||||
if (p === '/api/mail/message') {
|
||||
const mid = q.get('id');
|
||||
const m = await graph(`/me/messages/${id(mid)}?$select=id,subject,from,toRecipients,ccRecipients,receivedDateTime,isRead,body,hasAttachments,flag,parentFolderId&$expand=attachments($select=id,name,contentType,size,isInline)`);
|
||||
let html = m.body && m.body.contentType === 'html' ? m.body.content || '' : textToHtml((m.body && m.body.content) || '');
|
||||
html = await withInline(html, m.attachments, mid);
|
||||
return json(res, 200, { id: m.id, subject: m.subject || '(No subject)', from: addr(m.from), to: (m.toRecipients || []).map(addr), cc: (m.ccRecipients || []).map(addr), date: m.receivedDateTime, read: !!m.isRead, flagged: !!(m.flag && m.flag.flagStatus === 'flagged'),
|
||||
html, attachments: (m.attachments || []).filter(a => !a.isInline).map(a => ({ id: a.id, name: a.name, type: a.contentType, size: a.size })) });
|
||||
const [a, nid] = unpack(q.get('id')), m = await providerOf(a).message(a, nid);
|
||||
return json(res, 200, { ...m, id: pack(a, m.id), account: publicAcct(a) });
|
||||
}
|
||||
if (p === '/api/mail/attachment') {
|
||||
const mid = q.get('id'), aid = q.get('aid');
|
||||
const meta = await graph(`/me/messages/${id(mid)}/attachments/${id(aid)}?$select=name,contentType,size`);
|
||||
const r = await graph(`/me/messages/${id(mid)}/attachments/${id(aid)}/$value`, { raw: true });
|
||||
res.writeHead(200, { 'content-type': 'application/octet-stream', 'content-disposition': `attachment; filename="${safeName(meta.name)}"; filename*=UTF-8''${encodeURIComponent(safeName(meta.name))}`, 'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff' });
|
||||
return res.end(Buffer.from(await r.arrayBuffer()));
|
||||
const [a, nid] = unpack(q.get('id')), f = await providerOf(a).attachment(a, nid, q.get('aid'));
|
||||
res.writeHead(200, { 'content-type': 'application/octet-stream', 'content-disposition': `attachment; filename="${safeName(f.name)}"; filename*=UTF-8''${encodeURIComponent(safeName(f.name))}`, 'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff' });
|
||||
return res.end(f.buffer);
|
||||
}
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' });
|
||||
const b = JSON.parse((await readBody(req)) || '{}');
|
||||
if (p === '/api/mail/update') {
|
||||
const patch = {}; if (typeof b.read === 'boolean') patch.isRead = b.read; if (typeof b.flagged === 'boolean') patch.flag = { flagStatus: b.flagged ? 'flagged' : 'notFlagged' };
|
||||
if (!Object.keys(patch).length) throw fail(400, 'Nothing to change');
|
||||
await graph(`/me/messages/${id(b.id)}`, { method: 'PATCH', body: JSON.stringify(patch) }); dropFolderCache(); return json(res, 200, { ok: true });
|
||||
const b = JSON.parse((await readBody(req)) || '{}'), me = users.current();
|
||||
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());
|
||||
const saved = putAccount({ ...a, id: same ? same.id : crypto.randomBytes(3).toString('hex') }); dropFolderCache();
|
||||
audit.log('mail connected', { who: me.name, detail: a.address }, req);
|
||||
return json(res, 200, { ok: true, account: publicAcct(saved) });
|
||||
}
|
||||
if (p === '/api/mail/move') { await graph(`/me/messages/${id(b.id)}/move`, { method: 'POST', body: JSON.stringify({ destinationId: decodeURIComponent(id(b.to)) }) }); dropFolderCache(); return json(res, 200, { ok: true }); }
|
||||
if (p === '/api/mail/delete') {
|
||||
if (b.permanent) await graph(`/me/messages/${id(b.id)}`, { method: 'DELETE' });
|
||||
else await graph(`/me/messages/${id(b.id)}/move`, { method: 'POST', body: JSON.stringify({ destinationId: 'deleteditems' }) });
|
||||
dropFolderCache(); return json(res, 200, { ok: true });
|
||||
if (p === '/api/mail/disconnect') {
|
||||
const list = accounts(), gone = b.acct ? list.filter(x => x.id === b.acct) : list;
|
||||
if (b.acct && !gone.length) throw fail(404, 'That account is not connected');
|
||||
for (const x of gone) dropAccount(x.id);
|
||||
audit.log('mail disconnected', { who: me.name, detail: gone.map(x => x.address).join(', ') }, req); return json(res, 200, { ok: true });
|
||||
}
|
||||
if (p === '/api/mail/update') { const [a, nid] = unpack(b.id); await providerOf(a).update(a, nid, { read: b.read, flagged: b.flagged }); dropFolderCache(a.id); return json(res, 200, { ok: true }); }
|
||||
if (p === '/api/mail/move') { const [a, nid] = unpack(b.id), dest = String(b.to || ''); await providerOf(a).move(a, nid, dest.includes('~') ? dest.slice(dest.indexOf('~') + 1) : dest); dropFolderCache(a.id); return json(res, 200, { ok: true }); }
|
||||
if (p === '/api/mail/delete') { const [a, nid] = unpack(b.id); await providerOf(a).del(a, nid, !!b.permanent); dropFolderCache(a.id); return json(res, 200, { ok: true }); }
|
||||
if (p === '/api/mail/send') {
|
||||
const to = people(b.to, 'To'), cc = people(b.cc, 'Cc'), bcc = people(b.bcc, 'Bcc');
|
||||
const a = findAccount(b.acct || undefined), to = recipients(b.to, 'To'), cc = recipients(b.cc, 'Cc'), bcc = recipients(b.bcc, 'Bcc');
|
||||
if (!to.length && !cc.length && !bcc.length) throw fail(400, 'Add at least one recipient');
|
||||
const subject = String(b.subject || '').slice(0, 255), text = String(b.body || ''); if (text.length > 200000) throw fail(400, 'That message is too long');
|
||||
let total = 0; const attachments = (Array.isArray(b.attachments) ? b.attachments : []).slice(0, 10).map(a => { const bytes = Buffer.from(String(a.data || ''), 'base64').length; total += bytes; return { '@odata.type': '#microsoft.graph.fileAttachment', name: safeName(a.name), contentType: String(a.type || 'application/octet-stream').slice(0, 100), contentBytes: String(a.data || '') }; });
|
||||
if (total > MAX_ATTACH) throw fail(400, 'Attachments can total about 3 MB');
|
||||
await graph('/me/sendMail', { method: 'POST', body: JSON.stringify({ message: { subject, body: { contentType: 'HTML', content: textToHtml(text) }, toRecipients: to, ccRecipients: cc, bccRecipients: bcc, attachments }, saveToSentItems: true }) });
|
||||
audit.log('mail sent', { who: users.current().name, detail: `${to.length + cc.length + bcc.length} recipient(s)` }, req); dropFolderCache(); return json(res, 200, { ok: true });
|
||||
const subject = String(b.subject || '').slice(0, 255), body = String(b.body || ''); if (body.length > 200000) throw fail(400, 'That message is too long');
|
||||
await providerOf(a).send(a, { to, cc, bcc, subject, body, attachments: cleanAttachments(b.attachments) });
|
||||
audit.log('mail sent', { who: me.name, detail: `${to.length + cc.length + bcc.length} recipient(s)` }, req); dropFolderCache(a.id); return json(res, 200, { ok: true });
|
||||
}
|
||||
if (p === '/api/mail/reply') {
|
||||
const mode = { reply: 'reply', replyAll: 'replyAll', forward: 'forward' }[b.mode]; if (!mode) throw fail(400, 'Unknown action');
|
||||
const body = { comment: String(b.comment || '').slice(0, 200000) };
|
||||
if (mode === 'forward') { body.toRecipients = people(b.to, 'To'); if (!body.toRecipients.length) throw fail(400, 'Add at least one recipient'); }
|
||||
await graph(`/me/messages/${id(b.id)}/${mode}`, { method: 'POST', body: JSON.stringify(body) });
|
||||
audit.log('mail sent', { who: users.current().name, detail: mode }, req); dropFolderCache(); return json(res, 200, { ok: true });
|
||||
const [a, nid] = unpack(b.id), to = mode === 'forward' ? recipients(b.to, 'To') : [];
|
||||
if (mode === 'forward' && !to.length) throw fail(400, 'Add at least one recipient');
|
||||
await providerOf(a).reply(a, { id: nid, mode, comment: String(b.comment || '').slice(0, 200000), to });
|
||||
audit.log('mail sent', { who: me.name, detail: mode }, req); dropFolderCache(a.id); return json(res, 200, { ok: true });
|
||||
}
|
||||
json(res, 404, { error: 'Not found' });
|
||||
} catch (e) { json(res, e.status || 400, { error: e.message }); }
|
||||
}
|
||||
|
||||
module.exports = { enabled, handle, callback, secretStatus };
|
||||
module.exports = { enabled, outlookOn, handle, callback, secretStatus, recipients, textToHtml };
|
||||
Reference in new issue
Block a user