// 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) => `
${msg}
`; const escHtml = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 => '