Mail setup inside the app: admins save the Microsoft app ID, secret and expiry in Settings, with the redirect address to copy, an expiry warning, and plain-language Microsoft errors
build-container / build (push) Successful in 20m34s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-22 08:17:40 -04:00
1 parent 5c224521d3
commit 2d8d4f4d82
9 files changed
+175 -20

No files matched your search

+55 -6
View File
@@ -9,13 +9,28 @@ 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 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';
const enabled = () => !!CLIENT_ID;
// ---- 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 enabled = () => !!clientId();
/** 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); });
@@ -36,12 +51,12 @@ function startUrl(req) {
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,
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: CLIENT_ID, scope: SCOPES, ...params }); if (CLIENT_SECRET) body.set('client_secret', CLIENT_SECRET);
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 });
@@ -64,10 +79,11 @@ async function callback(req, res) {
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 || '' });
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(e.message))); }
} 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)
@@ -137,9 +153,42 @@ async function withInline(html, attachments, mid) {
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' });
}
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 });
@@ -204,4 +253,4 @@ async function handle(req, res) {
} catch (e) { json(res, e.status || 400, { error: e.message }); }
}
module.exports = { enabled, handle, callback };
module.exports = { enabled, handle, callback, secretStatus };