Files
hub/mail.js
T

208 lines
17 KiB
JavaScript

// 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).
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const atomicWrite = require('./atomic');
const users = require('./users');
const audit = require('./audit');
const vault = require('./vault')('nehub-mail');
const CLIENT_ID = process.env.MS_CLIENT_ID || '', CLIENT_SECRET = process.env.MS_CLIENT_SECRET || '';
const TENANT = process.env.MS_TENANT || 'consumers'; // personal Microsoft accounts only
const LOGIN = (process.env.MS_AUTH_URL || 'https://login.microsoftonline.com').replace(/\/$/, '');
const GRAPH = (process.env.MS_GRAPH_URL || 'https://graph.microsoft.com/v1.0').replace(/\/$/, '');
const PUBLIC_URL = (process.env.PUBLIC_URL || '').replace(/\/$/, '');
const SCOPES = 'offline_access Mail.ReadWrite Mail.Send User.Read';
const enabled = () => !!CLIENT_ID;
const MAX_BODY = 6 * 1024 * 1024, MAX_ATTACH = 3 * 1024 * 1024;
const readBody = req => new Promise((ok, no) => { const c = []; let n = 0; req.on('data', d => { n += d.length; if (n > MAX_BODY) { no(new Error('That is too large to send (attachments can total about 3 MB)')); req.destroy(); } else c.push(d); }); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); });
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 {} };
// ---- sign-in with Microsoft (authorization code + PKCE)
const states = new Map(); // state -> { uid, verifier, redirect, exp }
const redirectUri = req => (PUBLIC_URL || `${req.headers['x-forwarded-proto'] || 'http'}://${req.headers['x-forwarded-host'] || req.headers.host}`) + '/api/mail/callback';
function startUrl(req) {
for (const [k, v] of states) if (v.exp < Date.now()) states.delete(k);
const state = crypto.randomBytes(24).toString('base64url'), verifier = crypto.randomBytes(48).toString('base64url');
const redirect = redirectUri(req);
states.set(state, { uid: users.current().id, verifier, redirect, exp: Date.now() + 10 * 60e3 });
const q = new URLSearchParams({ client_id: CLIENT_ID, response_type: 'code', redirect_uri: redirect, response_mode: 'query', scope: SCOPES, state,
code_challenge: crypto.createHash('sha256').update(verifier).digest('base64url'), code_challenge_method: 'S256', prompt: 'select_account' });
return `${LOGIN}/${TENANT}/oauth2/v2.0/authorize?${q}`;
}
async function tokenCall(params) {
const body = new URLSearchParams({ client_id: CLIENT_ID, scope: SCOPES, ...params }); if (CLIENT_SECRET) body.set('client_secret', CLIENT_SECRET);
const r = await fetch(`${LOGIN}/${TENANT}/oauth2/v2.0/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body, signal: AbortSignal.timeout(15000) });
const j = await r.json().catch(() => ({}));
if (!r.ok) throw Object.assign(new Error(j.error_description || j.error || 'Microsoft refused the sign-in'), { status: r.status, code: j.error });
return j;
}
const page = (title, msg) => `<!doctype html><meta charset=utf-8><meta name=viewport content="width=device-width"><title>${title}</title><body style="font:16px system-ui;padding:28px;max-width:520px;margin:auto"><h2>${title}</h2><p>${msg}</p><p><a href="/#/mail">Back to NE Hub</a></p>`;
const escHtml = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
/** Microsoft sends the person back here. Runs without the hub cookie (an installed app may sign in in another browser), so the one-time state says who it is. */
async function callback(req, res) {
const q = new URL(req.url, 'http://x').searchParams, rec = states.get(q.get('state') || '');
const send = (code, html) => { res.writeHead(code, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' }); res.end(html); };
if (!rec || rec.exp < Date.now()) return send(400, page('Sign-in expired', 'That sign-in link is no longer valid. Go back and press Connect again.'));
states.delete(q.get('state'));
if (q.get('error')) return send(400, page('Not connected', escHtml(q.get('error_description') || q.get('error'))));
const user = users.get(rec.uid); if (!user || user.disabled) return send(403, page('Not allowed', 'That account is not available.'));
try {
const t = await tokenCall({ grant_type: 'authorization_code', code: q.get('code') || '', redirect_uri: rec.redirect, code_verifier: rec.verifier });
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 || '' });
audit.log('mail connected', { who: user.name, detail: me.mail || me.userPrincipalName || '' }, req);
});
res.writeHead(302, { location: '/#/mail', 'cache-control': 'no-store' }); res.end();
} catch (e) { console.error('[mail] sign-in failed:', e.message); send(400, page('Could not connect', escHtml(e.message))); }
}
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');
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);
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 });
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.'); }
throw fail(502, 'Could not reach Microsoft: ' + e.message);
}
})().finally(() => refreshing.delete(uid));
refreshing.set(uid, job);
return job;
}
async function graph(p, opts = {}, stale) {
const used = await token(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 === 204 || r.status === 202) return null;
if (opts.raw && r.ok) return r;
const j = await r.json().catch(() => ({}));
if (!r.ok) throw fail(r.status === 404 ? 404 : r.status === 429 ? 429 : 502, (j.error && j.error.message) || 'Microsoft Graph error ' + r.status);
return j;
}
// ---- helpers
const ID = /^[A-Za-z0-9_=\-]{1,400}$/;
const WELL = { inbox: 'Inbox', drafts: 'Drafts', sentitems: 'Sent', archive: 'Archive', junkemail: 'Junk', deleteditems: 'Deleted' };
const id = v => { if (!ID.test(String(v || ''))) throw fail(400, 'Bad id'); return encodeURIComponent(v); };
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; };
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);
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);
async function withInline(html, attachments, mid) {
const inl = (attachments || []).filter(a => a.isInline && a.contentId && /^image\//i.test(a.contentType || '') && (a.size || 0) < 1.5 * 1024 * 1024).slice(0, 15);
for (const a of inl) {
try {
const full = await graph(`/me/messages/${id(mid)}/attachments/${id(a.id)}?$select=contentBytes,contentType`);
if (full && full.contentBytes) html = html.split('cid:' + String(a.contentId).replace(/^<|>$/g, '')).join(`data:${full.contentType};base64,${full.contentBytes}`);
} catch { /* leave that picture out */ }
}
return html;
}
async function handle(req, res) {
const u = new URL(req.url, 'http://x'), p = u.pathname, q = u.searchParams;
try {
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/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 });
}
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,contentId)`);
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 && a.contentId)).map(a => ({ id: a.id, name: a.name, type: a.contentType, size: a.size })) });
}
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()));
}
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 });
}
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/send') {
const to = people(b.to, 'To'), cc = people(b.cc, 'Cc'), bcc = people(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 });
}
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 });
}
json(res, 404, { error: 'Not found' });
} catch (e) { json(res, e.status || 400, { error: e.message }); }
}
module.exports = { enabled, handle, callback };