// 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: "~". 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 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 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'; // ---- Microsoft app credentials: from the environment if set there, otherwise saved by an admin in Settings (encrypted, in the data folder). const CONFIG_FILE = path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'mail-config.json'); let cfgCache; const loadCfg = () => { if (cfgCache !== undefined) return cfgCache; try { cfgCache = vault.unseal(JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')).data) || {}; } catch { cfgCache = {}; } return cfgCache; }; 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 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; const days = Math.ceil((Date.parse(e + 'T23:59:59') - Date.now()) / 864e5); return { expires: e, days }; } 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) => { const body = Buffer.from(JSON.stringify(obj)), h = { 'content-type': 'application/json', 'cache-control': 'no-store' }; if (body.length > 2048 && /\bgzip\b/.test((res.req && res.req.headers['accept-encoding']) || '')) { // message bodies are mostly HTML: gzip makes them several times smaller const z = require('zlib').gzipSync(body); res.writeHead(code, { ...h, 'content-encoding': 'gzip', vary: 'Accept-Encoding' }); return res.end(z); } res.writeHead(code, h); res.end(body); }; const fail = (status, message) => Object.assign(new Error(message), { status }); // ---- 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]. 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 } 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: clientId(), 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: clientId(), scope: SCOPES, ...params }); if (clientSecret()) body.set('client_secret', clientSecret()); 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) => `${title}

${title}

${msg}

Back to NE Hub

`; 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(() => ({})); 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); }); 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(friendly(e)))); } } 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 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 }); 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) { 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(st.id)); refreshing.set(st.id, job); return job; } 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(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(() => ({})); 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@<>,;]+$/; /** "a@b.co, Ann " -> [{ 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 => '
' + escHtml(t).replace(/\r?\n/g, '
') + '
'; 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(); // account id -> { t, v } const dropFolderCache = id => { if (id) { folderCache.delete(id); unreadCache.delete(id); } else { folderCache.clear(); unreadCache.clear(); } imap.dropFolders(id); }; // ---- 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', keys = Object.keys(WELL); // the usual six and the full list in ONE round trip (Graph's $batch), instead of seven const b = await graph(a, '/$batch', { method: 'POST', body: JSON.stringify({ requests: [...keys.map((k, i) => ({ id: String(i), method: 'GET', url: `/me/mailFolders/${k}${sel}` })), { id: 'all', method: 'GET', url: `/me/mailFolders${sel}&$top=100` }] }) }); const by = Object.fromEntries((b.responses || []).map(r => [r.id, r])); const wk = keys.map((k, i) => { const r = by[String(i)]; return r && r.status === 200 ? { id: k, realId: r.body.id, name: WELL[k], unread: r.body.unreadItemCount || 0, total: r.body.totalItemCount || 0, well: true } : null; }); const known = new Set(wk.filter(Boolean).map(f => f.realId)); const rest = by.all && by.all.status === 200 ? by.all.body.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(x => x.isInline && /^image\//i.test(x.contentType || '') && (x.size || 0) < 1.5 * 1024 * 1024).slice(0, 15); const fulls = await Promise.all(inl.map(x => graph(a, `/me/messages/${id(mid)}/attachments/${id(x.id)}`).catch(() => null))); // all at once inl.forEach((x, i) => { const full = fulls[i]; if (full && full.contentBytes && full.contentId) html = html.split('cid:' + String(full.contentId).replace(/^<|>$/g, '')).join(`data:${full.contentType || x.contentType};base64,${full.contentBytes}`); }); return html; } /** Plain-language reason for a Microsoft sign-in error. */ function friendly(e) { const t = (e.message || '') + ' ' + (e.code || ''); if (/AADSTS700016/.test(t)) return 'Microsoft does not know that Application (client) ID. Check you copied the whole ID, and that the app is set to "Personal Microsoft accounts only".'; if (/AADSTS7000215/.test(t)) return 'The client secret is wrong. Use the secret Value (shown once when you made it), not the Secret ID.'; if (/AADSTS7000222/.test(t)) return 'The client secret has expired. Make a new one in Microsoft Entra (Certificates & secrets) and paste its Value here.'; if (/AADSTS7000218|AADSTS70002/.test(t)) return 'Microsoft wants a client secret for this app. Add one here, or register the redirect under "Mobile and desktop applications" instead of "Web".'; if (/AADSTS700025/.test(t)) return 'This app is registered as a public client, so remove the client secret here (or register the redirect under "Web").'; if (/AADSTS50194|AADSTS90002|AADSTS700038/.test(t)) return 'The app registration is not set to personal Microsoft accounts only.'; return e.message || 'Microsoft refused the request'; } async function adminHandle(req, res, p) { const me = users.current(); if (me.role !== 'admin') return json(res, 403, { error: 'Only an admin can do this' }); const c = loadCfg(); const view = () => ({ source: ENV_ID ? 'env' : c.clientId ? 'app' : 'none', clientId: clientId(), hasSecret: !!clientSecret(), secretExpires: ENV_ID ? '' : c.secretExpires || '', days: (secretStatus() || {}).days ?? null, redirect: redirectUri(req), tenant: TENANT, verifiedAt: c.verifiedAt || 0 }); if (p === '/api/mail/admin' && req.method === 'GET') return json(res, 200, view()); if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' }); if (ENV_ID) return json(res, 400, { error: 'The Microsoft app is set in the server environment (MS_CLIENT_ID). Change it there.' }); if (p === '/api/mail/admin') { const b = JSON.parse((await readBody(req)) || '{}'), next = { ...c }; if (typeof b.clientId === 'string') { const id = b.clientId.trim(); if (id && !/^[0-9a-fA-F-]{36}$/.test(id)) throw fail(400, 'The Application (client) ID is a 36-character code like 1a2b3c4d-1234-5678-9abc-def012345678'); next.clientId = id; } if (b.clearSecret) delete next.clientSecret; else if (typeof b.clientSecret === 'string' && b.clientSecret.trim()) next.clientSecret = b.clientSecret.trim(); if (typeof b.secretExpires === 'string') { if (b.secretExpires && !/^\d{4}-\d{2}-\d{2}$/.test(b.secretExpires)) throw fail(400, 'Enter the expiry as a date'); next.secretExpires = b.secretExpires; } if (next.clientId !== c.clientId || next.clientSecret !== c.clientSecret) delete next.verifiedAt; // changed, so it is unproven again if (!next.clientId) { fs.rmSync(CONFIG_FILE, { force: true }); cfgCache = {}; } else saveCfg(next); audit.log('mail setup changed', { who: me.name, detail: next.clientId ? 'saved' : 'cleared' }, req); return json(res, 200, { ok: true, ...(() => { const cc = loadCfg(); return { source: cc.clientId ? 'app' : 'none', clientId: cc.clientId || '', hasSecret: !!cc.clientSecret, secretExpires: cc.secretExpires || '', days: (secretStatus() || {}).days ?? null, redirect: redirectUri(req), verifiedAt: cc.verifiedAt || 0 }; })() }); } json(res, 404, { error: 'Not found' }); } const unreadCache = new Map(); // account id -> { t, n }: counts are shown at once from here and refreshed when older than 30 s const withTimeout = (p, ms) => Promise.race([p, new Promise((_, no) => setTimeout(() => no(Object.assign(new Error('That account did not answer in time'), { status: 504 })), ms))]); /** The accounts, with unread counts. `light` makes no network calls at all (what the Mail tab needs to draw itself); otherwise counts older than * 30 seconds are refreshed, each account on its own 6-second limit so one slow or unreachable account cannot hold up the others. */ async function overview(light) { const list = accounts(); const rows = await Promise.all(list.map(async a => { const c = unreadCache.get(a.id); if (light || (c && Date.now() - c.t < 30e3)) return { ...publicAcct(a), unread: c ? c.n : null }; try { const n = await withTimeout(providerOf(a).unread(a), 6000); unreadCache.set(a.id, { t: Date.now(), n }); return { ...publicAcct(a), unread: n }; } catch (e) { return { ...publicAcct(a), unread: c ? c.n : 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') return json(res, 200, { ...(await overview(q.get('light') === '1')), 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/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) })) }); } if (p === '/api/mail/message') { 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 [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)) || '{}'), 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/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 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), 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 [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, outlookOn, handle, callback, secretStatus, recipients, textToHtml };