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

+1 -1
View File
@@ -78,7 +78,7 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
- **Podcast** and **Podcast Admin**: launchers that open `PODCAST_URL` (a home-network address) and `VERSE_URL` in their own tabs. - **Podcast** and **Podcast Admin**: launchers that open `PODCAST_URL` (a home-network address) and `VERSE_URL` in their own tabs.
- **Office**: embeds onlyoffice.necloud.us in a frame, with an "open in new tab" fallback if it blocks framing. - **Office**: embeds onlyoffice.necloud.us in a frame, with an "open in new tab" fallback if it blocks framing.
- **Mail**: your **Outlook.com** (personal) mailbox inside the app: folders with unread counts, message list with search, reader, compose with attachments (up to about 3 MB), reply, reply all, forward, archive, delete, flag, mark unread, an unread badge on the Mail tab and a Home card. It uses Microsoft Graph, because Outlook.com no longer allows IMAP passwords. Each person connects their own mailbox (Mail tab -> Connect Outlook.com, sign-in is on Microsoft's own page); the hub keeps only the sign-in token, encrypted, in that person's folder, and everything is private to them. Message text is shown in a sandboxed frame with no scripts, and pictures from the internet stay hidden until you press Show pictures. Not included: drafts autosave, contacts, several mailboxes per person, Microsoft 365 work accounts, Gmail/iCloud (those can be added the same way later). - **Mail**: your **Outlook.com** (personal) mailbox inside the app: folders with unread counts, message list with search, reader, compose with attachments (up to about 3 MB), reply, reply all, forward, archive, delete, flag, mark unread, an unread badge on the Mail tab and a Home card. It uses Microsoft Graph, because Outlook.com no longer allows IMAP passwords. Each person connects their own mailbox (Mail tab -> Connect Outlook.com, sign-in is on Microsoft's own page); the hub keeps only the sign-in token, encrypted, in that person's folder, and everything is private to them. Message text is shown in a sandboxed frame with no scripts, and pictures from the internet stay hidden until you press Show pictures. Not included: drafts autosave, contacts, several mailboxes per person, Microsoft 365 work accounts, Gmail/iCloud (those can be added the same way later).
**One-time setup** (step by step, with troubleshooting, in [docs/mail-setup.md](docs/mail-setup.md); needs a free Microsoft account and takes five minutes): go to entra.microsoft.com -> App registrations -> New registration. Name it NE Hub; supported account types **Personal Microsoft accounts only**; Redirect URI: platform **Web**, value `<your hub address>/api/mail/callback` (for example `https://hub.necloud.us/api/mail/callback`; it must match `PUBLIC_URL` exactly). Then API permissions -> Add -> Microsoft Graph -> Delegated: `Mail.ReadWrite`, `Mail.Send`, `User.Read`, `offline_access`. Copy the **Application (client) ID** into `MS_CLIENT_ID`. If you registered it as a Web app, also make a client secret (Certificates & secrets) and put its value in `MS_CLIENT_SECRET`; if you used platform "Mobile and desktop applications" instead, no secret is needed. Leave `MS_CLIENT_ID` empty to switch Mail off. (The old bundled Cypht webmail and its `nehub-cypht` volume are gone; you can delete that volume.) Connecting from the installed iPhone/iPad app may open Microsoft's page in Safari; if the app does not notice, reopen it. **One-time setup** (step by step, with troubleshooting, in [docs/mail-setup.md](docs/mail-setup.md); needs a free Microsoft account and takes five minutes): go to entra.microsoft.com -> App registrations -> New registration. Name it NE Hub; supported account types **Personal Microsoft accounts only**; Redirect URI: platform **Web**, value `<your hub address>/api/mail/callback` (for example `https://hub.necloud.us/api/mail/callback`; it must match `PUBLIC_URL` exactly). Then API permissions -> Add -> Microsoft Graph -> Delegated: `Mail.ReadWrite`, `Mail.Send`, `User.Read`, `offline_access`. Copy the **Application (client) ID** and paste it, with the secret, into **Settings -> Microsoft app (admin)** (or set `MS_CLIENT_ID` / `MS_CLIENT_SECRET` in the environment, which win). If you registered it as a Web app, also make a client secret (Certificates & secrets) and put its value in `MS_CLIENT_SECRET`; if you used platform "Mobile and desktop applications" instead, no secret is needed. Leave `MS_CLIENT_ID` empty to switch Mail off. (The old bundled Cypht webmail and its `nehub-cypht` volume are gone; you can delete that volume.) Connecting from the installed iPhone/iPad app may open Microsoft's page in Safari; if the app does not notice, reopen it.
## Install on iPhone / iPad (PWA) ## Install on iPhone / iPad (PWA)
+6 -8
View File
@@ -40,7 +40,9 @@ On the app's **Overview** page, copy the **Application (client) ID** (a long GUI
## 6. Give the hub the values ## 6. Give the hub the values
Add these to your `.env` (or your stack's environment settings): **In the app (easiest):** sign in as an admin, open **Settings -> Microsoft app (admin)**. It shows the exact redirect address to register (with a Copy button), and has boxes for the Application (client) ID, the client secret Value and the date the secret expires. Press **Save**; Mail switches on straight away, no restart needed. The secret is stored encrypted on the server and is never shown again. NE Hub warns you in Settings, and with a push notification if you turned reminders on, in the month before the secret expires.
**Or in the environment** (these win over anything saved in the app, and then the Settings box is read-only):
``` ```
MS_CLIENT_ID=<the Application (client) ID> MS_CLIENT_ID=<the Application (client) ID>
@@ -48,16 +50,12 @@ MS_CLIENT_SECRET=<the secret Value>
PUBLIC_URL=https://hub.necloud.us PUBLIC_URL=https://hub.necloud.us
``` ```
Then redeploy the hub: then redeploy with `docker compose up -d`. Leave both empty (and nothing saved in the app) to switch Mail off.
```
docker compose up -d
```
Leave `MS_CLIENT_ID` empty to switch Mail off.
## 7. Connect ## 7. Connect
Microsoft cannot check an app registration without a real sign-in, so the first Connect is the test: it checks the ID, the secret and the redirect address, and if something is wrong it says what. After one good connection, Settings -> Microsoft app shows the date of the last successful connection.
1. Open the hub, go to **Mail**, and press **Connect Outlook.com**. 1. Open the hub, go to **Mail**, and press **Connect Outlook.com**.
2. Sign in with your Outlook.com account on Microsoft's page and approve the permissions. 2. Sign in with your Outlook.com account on Microsoft's page and approve the permissions.
3. You land back on the Mail tab with your inbox showing. 3. You land back on the Mail tab with your inbox showing.
+55 -6
View File
@@ -9,13 +9,28 @@ const users = require('./users');
const audit = require('./audit'); const audit = require('./audit');
const vault = require('./vault')('nehub-mail'); 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 TENANT = process.env.MS_TENANT || 'consumers'; // personal Microsoft accounts only
const LOGIN = (process.env.MS_AUTH_URL || 'https://login.microsoftonline.com').replace(/\/$/, ''); 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 GRAPH = (process.env.MS_GRAPH_URL || 'https://graph.microsoft.com/v1.0').replace(/\/$/, '');
const PUBLIC_URL = (process.env.PUBLIC_URL || '').replace(/\/$/, ''); const PUBLIC_URL = (process.env.PUBLIC_URL || '').replace(/\/$/, '');
const SCOPES = 'offline_access Mail.ReadWrite Mail.Send User.Read'; 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 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 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 state = crypto.randomBytes(24).toString('base64url'), verifier = crypto.randomBytes(48).toString('base64url');
const redirect = redirectUri(req); const redirect = redirectUri(req);
states.set(state, { uid: users.current().id, verifier, redirect, exp: Date.now() + 10 * 60e3 }); 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' }); code_challenge: crypto.createHash('sha256').update(verifier).digest('base64url'), code_challenge_method: 'S256', prompt: 'select_account' });
return `${LOGIN}/${TENANT}/oauth2/v2.0/authorize?${q}`; return `${LOGIN}/${TENANT}/oauth2/v2.0/authorize?${q}`;
} }
async function tokenCall(params) { 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 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(() => ({})); 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 }); 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 () => { 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 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 || '' }); 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); audit.log('mail connected', { who: user.name, detail: me.mail || me.userPrincipalName || '' }, req);
}); });
res.writeHead(302, { location: '/#/mail', 'cache-control': 'no-store' }); res.end(); 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) 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; 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) { async function handle(req, res) {
const u = new URL(req.url, 'http://x'), p = u.pathname, q = u.searchParams; const u = new URL(req.url, 'http://x'), p = u.pathname, q = u.searchParams;
try { try {
if (p === '/api/mail/admin' || p.startsWith('/api/mail/admin/')) return await adminHandle(req, res, p);
if (p === '/api/mail/status') { if (p === '/api/mail/status') {
const st = load(); const st = load();
if (!st) return json(res, 200, { connected: false }); 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 }); } } catch (e) { json(res, e.status || 400, { error: e.message }); }
} }
module.exports = { enabled, handle, callback }; module.exports = { enabled, handle, callback, secretStatus };
+44 -1
View File
@@ -38,13 +38,56 @@ async function renderMailCard(el) {
} }
function mailSettings() { function mailSettings() {
const box = $('#mailst'); if (!box) return; const box = $('#mailst'); if (!box) return;
jfetch('/api/mail/status').then(s => { if (allowed('mail') && cfg.mail) jfetch('/api/mail/status').then(s => {
if (!$('#mailst')) return; if (!$('#mailst')) return;
box.innerHTML = s.connected box.innerHTML = s.connected
? `<div class="row"><span class="grow"><b>Outlook.com</b><small>${esc(s.address || 'Connected')}</small></span><button class="btn danger" id="mdis" style="min-height:36px">Disconnect</button></div>` ? `<div class="row"><span class="grow"><b>Outlook.com</b><small>${esc(s.address || 'Connected')}</small></span><button class="btn danger" id="mdis" style="min-height:36px">Disconnect</button></div>`
: `<div class="row"><span class="grow"><b>Outlook.com</b><small>${s.expired ? 'Microsoft signed you out. ' : ''}Not connected</small></span><a class="btn ghost" href="#/mail" style="min-height:36px">Connect</a></div>`; : `<div class="row"><span class="grow"><b>Outlook.com</b><small>${s.expired ? 'Microsoft signed you out. ' : ''}Not connected</small></span><a class="btn ghost" href="#/mail" style="min-height:36px">Connect</a></div>`;
const d = $('#mdis'); if (d) d.onclick = () => confirmSheet('Disconnect mail?', 'NE Hub forgets your Outlook.com sign-in. Your mailbox is not touched.', 'Disconnect', async () => { try { await jfetch('/api/mail/disconnect', { method: 'POST', body: '{}' }); mail.st = null; mailUnread = 0; renderNav(); mailSettings(); } catch (e) { toast(e.message); } }); const d = $('#mdis'); if (d) d.onclick = () => confirmSheet('Disconnect mail?', 'NE Hub forgets your Outlook.com sign-in. Your mailbox is not touched.', 'Disconnect', async () => { try { await jfetch('/api/mail/disconnect', { method: 'POST', body: '{}' }); mail.st = null; mailUnread = 0; renderNav(); mailSettings(); } catch (e) { toast(e.message); } });
}).catch(() => { box.innerHTML = '<div class="row"><span class="grow"><small>Could not check</small></span></div>'; }); }).catch(() => { box.innerHTML = '<div class="row"><span class="grow"><small>Could not check</small></span></div>'; });
else box.innerHTML = '';
if (cfg.mailAdmin) mailAdminCard();
}
/** Admin: the Microsoft app behind Mail (client ID, secret, expiry), with the redirect address to register and a test button. */
async function mailAdminCard() {
let host = $('#mailadm');
if (!host) { $('#sec-mail').insertAdjacentHTML('beforeend', '<div id="mailadm"></div>'); host = $('#mailadm'); }
let a; try { a = await jfetch('/api/mail/admin'); } catch (e) { host.innerHTML = `<p class="err">${esc(e.message)}</p>`; return; }
if (!$('#mailadm')) return;
const warn = a.days === null ? '' : a.days < 0 ? `<p class="err" style="margin:8px 0 0">The client secret expired ${-a.days} day${a.days === -1 ? '' : 's'} ago. Mail cannot connect until you replace it.</p>`
: a.days <= 30 ? `<p class="err" style="margin:8px 0 0">The client secret expires in ${a.days} day${a.days === 1 ? '' : 's'} (${esc(a.secretExpires)}). Make a new one in Microsoft Entra and paste it below.</p>` : '';
host.innerHTML = `<h2>Microsoft app (admin)</h2>
<p class="sub">Mail uses a free Microsoft app registration. ${a.source === 'env' ? 'This server gets it from its environment settings (MS_CLIENT_ID), so change it there.' : a.source === 'app' ? 'Saved here, encrypted on your server.' : 'Not set up yet: follow the steps below, then paste the values here.'}</p>
${warn}
<p class="sub" style="margin:8px 0 0">${a.verifiedAt ? '✓ Last successful connection: ' + esc(new Date(a.verifiedAt).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })) : a.clientId ? 'Not proven yet. Open the Mail tab and press Connect: Microsoft checks everything then.' : ''}</p>
<div class="card" style="max-width:none">
<label>Redirect address to register with Microsoft</label>
<div style="display:flex;gap:8px"><input type="text" id="mredir" readonly value="${esc(a.redirect)}" style="flex:1"><button class="btn ghost" id="mcopy" type="button">Copy</button></div>
<p class="sub" style="margin:4px 0 0">It must match exactly, and be https (set PUBLIC_URL on the server if this shows the wrong address).</p>
${a.source === 'env' ? `<label>Application (client) ID</label><input type="text" readonly value="${esc(a.clientId)}"><p class="sub">Client secret: ${a.hasSecret ? 'set' : 'not set'}</p>` : `<form id="madm">
<label for="mcid">Application (client) ID</label><input type="text" id="mcid" value="${esc(a.clientId)}" autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="1a2b3c4d-1234-5678-9abc-def012345678">
<label for="mcsec">Client secret (the Value)</label><input type="password" id="mcsec" autocomplete="new-password" placeholder="${a.hasSecret ? 'Saved. Leave blank to keep it' : 'Leave blank if the app has no secret'}">
<label for="mcexp">Secret expires on</label><input type="date" id="mcexp" value="${esc(a.secretExpires)}"><p class="sub" style="margin:4px 0 0">Microsoft secrets last at most 24 months. NE Hub warns you a month before.</p>
<div class="bar"><button class="btn" style="flex:1;justify-content:center">Save</button></div>
${a.source === 'app' ? '<div class="bar" style="margin-top:0"><button class="btn danger" type="button" id="mclear" style="flex:1;justify-content:center">Remove these settings</button></div>' : ''}
<div id="mres" class="err" role="status"></div></form>`}
</div>
<details style="margin-top:10px"><summary style="cursor:pointer;color:var(--accent)">How to register the app (about 5 minutes)</summary>
<ol class="sub" style="line-height:1.55;padding-left:20px">
<li>Go to <b>entra.microsoft.com</b> and sign in with any Microsoft account. Accept the free "Default Directory" if asked.</li>
<li><b>Entra ID → App registrations → New registration</b>. Name it NE Hub, choose <b>Personal Microsoft accounts only</b>, set the redirect platform to <b>Web</b>, paste the redirect address above, press Register.</li>
<li>Copy the <b>Application (client) ID</b> from the Overview page into the first box above.</li>
<li><b>API permissions → Add a permission → Microsoft Graph → Delegated</b>: Mail.ReadWrite, Mail.Send, User.Read, offline_access.</li>
<li><b>Certificates &amp; secrets → New client secret</b> (24 months). Copy its <b>Value</b> now (shown once) into the secret box, and set the expiry date.</li>
<li>Press Save, then open the Mail tab and press <b>Connect</b>. Microsoft checks the ID, secret and redirect address at that moment; if something is wrong it says what.</li>
</ol></details>`;
$('#mcopy').onclick = async () => { try { await navigator.clipboard.writeText(a.redirect); toast('Copied'); } catch { $('#mredir').select(); toast('Press Ctrl/Cmd-C to copy'); } };
const f = $('#madm'); if (!f) return;
const res = (t, good) => { const el = $('#mres'); el.textContent = t; el.style.color = good ? 'var(--accent)' : ''; };
const save = async extra => jfetch('/api/mail/admin', { method: 'POST', body: JSON.stringify({ clientId: $('#mcid').value, clientSecret: $('#mcsec').value, secretExpires: $('#mcexp').value, ...(extra || {}) }) });
f.onsubmit = async e => { e.preventDefault(); res(''); try { await save(); const r = await jfetch('/api/config'); cfg = { ...cfg, ...r }; toast('Saved. Now open the Mail tab and press Connect.'); renderNav(); mailSettings(); } catch (err) { res(err.message); } };
if ($('#mclear')) $('#mclear').onclick = () => confirmSheet('Remove the Microsoft app settings?', 'Mail stops working for everyone until you set it up again. Connected mailboxes stay saved.', 'Remove', async () => { try { await jfetch('/api/mail/admin', { method: 'POST', body: JSON.stringify({ clientId: '', clearSecret: true, secretExpires: '' }) }); const c = await jfetch('/api/config'); cfg = { ...cfg, ...c }; renderNav(); mailSettings(); } catch (e) { toast(e.message); } });
} }
// ---- the tab // ---- the tab
+1 -1
View File
@@ -95,7 +95,7 @@ function viewSettings() {
remindersInit(); remindersInit();
// only show what this person may use // only show what this person may use
const hide = (cond, sel) => { if (cond) document.querySelectorAll(sel).forEach(e => e.remove()); }; const hide = (cond, sel) => { if (cond) document.querySelectorAll(sel).forEach(e => e.remove()); };
hide(!(allowed('mail') && cfg.mail), '#sec-mail'); hide(!allowed('audio'), '#sec-audio'); hide(!allowed('signal'), '#sec-signal'); hide(!(allowed('files') || allowed('office')), '#row-sf, #out'); hide(!((allowed('mail') && cfg.mail) || cfg.mailAdmin), '#sec-mail'); hide(!allowed('audio'), '#sec-audio'); hide(!allowed('signal'), '#sec-signal'); hide(!(allowed('files') || allowed('office')), '#row-sf, #out');
hide(!allowed('music'), '#row-plex'); hide(!allowed('office'), '#row-oo'); hide(!allowed('music'), '#row-plex'); hide(!allowed('office'), '#row-oo');
if (!document.querySelector('#sec-conn .list .row')) hide(true, '#sec-conn'); // nothing to show here for this person if (!document.querySelector('#sec-conn .list .row')) hide(true, '#sec-conn'); // nothing to show here for this person
if ($('#sga-s')) { if ($('#sga-s')) {
+11
View File
@@ -8,6 +8,7 @@ const atomicWrite = require('./atomic');
const users = require('./users'); const users = require('./users');
const tasks = require('./tasks'); const tasks = require('./tasks');
const cal = require('./cal'); const cal = require('./cal');
const mail = require('./mail');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'); const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
const readBody = req => new Promise((ok, no) => { const c = []; let n = 0; req.on('data', d => { n += d.length; if (n > 20000) { no(new Error('Too large')); req.destroy(); } else c.push(d); }); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); }); const readBody = req => new Promise((ok, no) => { const c = []; let n = 0; req.on('data', d => { n += d.length; if (n > 20000) { no(new Error('Too large')); req.destroy(); } else c.push(d); }); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); });
@@ -64,6 +65,16 @@ async function tick(now = new Date(), force = false) {
} }
} catch (e) { console.warn('[push] tasks check failed:', e.message); } } catch (e) { console.warn('[push] tasks check failed:', e.message); }
} }
if (u.role === 'admin' && checkEvents) { // the Microsoft client secret (for Mail) is about to expire
const ss = mail.secretStatus();
if (ss && ss.days <= 30) {
const bucket = ss.days <= 0 ? 0 : [1, 3, 7, 14, 30].find(t => ss.days <= t), key = `mailsecret:${ss.expires}:${bucket}`;
if (!st.sent.includes(key)) {
st.sent.push(key); dirty = true;
await send(dir, st, { title: ss.days <= 0 ? 'Mail sign-in has expired' : `Mail sign-in expires in ${ss.days} day${ss.days === 1 ? '' : 's'}`, body: 'Make a new client secret in Microsoft Entra and paste it in Settings -> Microsoft app.', url: '#/settings', tag: 'mailsecret' });
}
}
}
if (checkEvents && st.events && users.can(u, 'calendar')) { if (checkEvents && st.events && users.can(u, 'calendar')) {
try { try {
const from = now.getTime(), to = from + (st.eventMins + 5) * 60e3; const from = now.getTime(), to = from + (st.eventMins + 5) * 60e3;
+2 -2
View File
@@ -467,7 +467,7 @@ async function handleRequest(req, res) {
const c = t => users.can(user, t); const c = t => users.can(user, t);
res.writeHead(200, { 'content-type': 'application/json' }); res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ seafile: c('files') || c('office') ? SEAFILE_URL : '', onlyoffice: c('office') ? ONLYOFFICE_URL : '', audiobooks: c('audio') ? AUDIOBOOKS_URL : '', verse: c('verse') ? VERSE_URL : '', return res.end(JSON.stringify({ seafile: c('files') || c('office') ? SEAFILE_URL : '', onlyoffice: c('office') ? ONLYOFFICE_URL : '', audiobooks: c('audio') ? AUDIOBOOKS_URL : '', verse: c('verse') ? VERSE_URL : '',
podcast: c('podcast') ? PODCAST_URL : '', signal: c('signal') && signal.enabled() ? '1' : '', lyrics: c('music') && lyrics.enabled() ? '1' : '', mail: c('mail') && mail.enabled() ? '1' : '', editor: (c('office') || c('files')) && OO_OK, jwt: !!JWT_SECRET, ooReady: OO_OK, build: BUILD })); podcast: c('podcast') ? PODCAST_URL : '', signal: c('signal') && signal.enabled() ? '1' : '', lyrics: c('music') && lyrics.enabled() ? '1' : '', mail: c('mail') && mail.enabled() ? '1' : '', mailAdmin: user.role === 'admin' ? '1' : '', editor: (c('office') || c('files')) && OO_OK, jwt: !!JWT_SECRET, ooReady: OO_OK, build: BUILD }));
} }
if (req.method === 'POST' && req.url === '/api/oo/config') return OO_OK ? ooConfig(req, res) : forbidden(res, 'In-app editing needs ONLYOFFICE_JWT_SECRET to be set on the server'); if (req.method === 'POST' && req.url === '/api/oo/config') return OO_OK ? ooConfig(req, res) : forbidden(res, 'In-app editing needs ONLYOFFICE_JWT_SECRET to be set on the server');
if (pathname === '/api/backup') return user.role === 'admin' ? backup.handle(req, res, user.name) : forbidden(res, 'Only an admin can do this'); if (pathname === '/api/backup') return user.role === 'admin' ? backup.handle(req, res, user.name) : forbidden(res, 'Only an admin can do this');
@@ -485,7 +485,7 @@ async function handleRequest(req, res) {
} }
if (pathname === '/api/lyrics') return lyrics.enabled() ? lyrics.handle(req, res) : forbidden(res, 'Lyrics are not set up on this server'); if (pathname === '/api/lyrics') return lyrics.enabled() ? lyrics.handle(req, res) : forbidden(res, 'Lyrics are not set up on this server');
if (pathname.startsWith('/api/signal/')) return signal.enabled() ? signal.handle(req, res) : forbidden(res, 'Signal is not set up on this server'); if (pathname.startsWith('/api/signal/')) return signal.enabled() ? signal.handle(req, res) : forbidden(res, 'Signal is not set up on this server');
if (pathname.startsWith('/api/mail/')) return mail.enabled() ? mail.handle(req, res) : forbidden(res, 'Mail is not set up on this server'); if (pathname.startsWith('/api/mail/')) return mail.enabled() || pathname.startsWith('/api/mail/admin') ? mail.handle(req, res) : forbidden(res, 'Mail is not set up on this server');
if (req.url.startsWith('/api/plex/')) return plexApi(req, res); if (req.url.startsWith('/api/plex/')) return plexApi(req, res);
if (req.url.startsWith('/plex/') && (req.method === 'GET' || (['POST', 'PUT', 'DELETE'].includes(req.method) && PLEX_WRITABLE.test(req.url)))) return plexProxy(req, res); if (req.url.startsWith('/plex/') && (req.method === 'GET' || (['POST', 'PUT', 'DELETE'].includes(req.method) && PLEX_WRITABLE.test(req.url)))) return plexProxy(req, res);
if (req.url.startsWith('/sf/')) return proxySeafile(req, res); if (req.url.startsWith('/sf/')) return proxySeafile(req, res);
+44 -1
View File
@@ -25,7 +25,8 @@ const SECRET = 'mail-test-secret-'.repeat(3);
ms.calls.push(rq.method + ' ' + decodeURIComponent(p + u.search)); ms.calls.push(rq.method + ' ' + decodeURIComponent(p + u.search));
if (p === '/consumers/oauth2/v2.0/token' && rq.method === 'POST') { if (p === '/consumers/oauth2/v2.0/token' && rq.method === 'POST') {
const f = new URLSearchParams(b); const f = new URLSearchParams(b);
if (f.get('client_id') !== 'test-client') return out(rs, 400, { error: 'invalid_client' }); if (!['test-client', 'aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb'].includes(f.get('client_id'))) return out(rs, 400, { error: 'invalid_client', error_description: 'AADSTS700016: Application not found' });
if (ms.secret !== undefined && f.get('client_secret') !== ms.secret) return out(rs, 401, { error: 'invalid_client', error_description: ms.secret === 'EXPIRED' ? 'AADSTS7000222: expired' : 'AADSTS7000215: Invalid client secret provided' });
if (f.get('grant_type') === 'authorization_code') { if (f.get('grant_type') === 'authorization_code') {
if (f.get('code') !== 'GOODCODE') return out(rs, 400, { error: 'invalid_grant', error_description: 'bad code' }); if (f.get('code') !== 'GOODCODE') return out(rs, 400, { error: 'invalid_grant', error_description: 'bad code' });
if (crypto.createHash('sha256').update(f.get('code_verifier') || '').digest('base64url') !== ms.challenge) return out(rs, 400, { error: 'invalid_grant', error_description: 'PKCE failed' }); if (crypto.createHash('sha256').update(f.get('code_verifier') || '').digest('base64url') !== ms.challenge) return out(rs, 400, { error: 'invalid_grant', error_description: 'PKCE failed' });
@@ -144,8 +145,50 @@ const SECRET = 'mail-test-secret-'.repeat(3);
r = await post('unate', '/api/mail/disconnect'); r = await call('unate', '/api/mail/status'); ok('disconnect removes the sign-in', r.json.connected === false && !fs.existsSync(path.join(dir, 'users', 'unate', 'mail.json')), r.json); r = await post('unate', '/api/mail/disconnect'); r = await call('unate', '/api/mail/status'); ok('disconnect removes the sign-in', r.json.connected === false && !fs.existsSync(path.join(dir, 'users', 'unate', 'mail.json')), r.json);
r = await call('unate', '/api/audit'); const evs = (r.json.events || []).map(e => e.event); ok('connecting, sending and disconnecting are in the activity log (without any message text)', ['mail connected', 'mail sent', 'mail disconnected'].every(e => evs.includes(e)) && !JSON.stringify(r.json).includes('not bold'), evs); r = await call('unate', '/api/audit'); const evs = (r.json.events || []).map(e => e.event); ok('connecting, sending and disconnecting are in the activity log (without any message text)', ['mail connected', 'mail sent', 'mail disconnected'].every(e => evs.includes(e)) && !JSON.stringify(r.json).includes('not bold'), evs);
r = await call('unate', '/webmail/'); ok('the old /webmail/ pass-through is gone', r.status === 404 || r.status === 200 && !/Cypht/i.test(r.text), r.status); r = await call('unate', '/webmail/'); ok('the old /webmail/ pass-through is gone', r.status === 404 || r.status === 200 && !/Cypht/i.test(r.text), r.status);
// ================= admin: Microsoft app settings (environment version) =================
r = await call('unate', '/api/mail/admin'); ok('an admin sees the app settings: set from the environment, with the redirect address to register', r.status === 200 && r.json.source === 'env' && r.json.clientId === 'test-client' && r.json.redirect === base + '/api/mail/callback', r.json);
r = await call('uann', '/api/mail/admin'); ok('other people cannot see or change them', r.status === 403, r.status);
r = await post('unate', '/api/mail/admin', { clientId: '1a2b3c4d-1234-5678-9abc-def012345678' }); ok('with the environment in charge, saving is refused', r.status === 400 && /environment/.test(r.json.error), r.json);
r = await call('unate', '/api/config'); ok('the admin is told they can manage Mail setup', r.json.mailAdmin === '1', r.json);
ok('the server is still running', child.exitCode === null, log.slice(-300)); ok('the server is still running', child.exitCode === null, log.slice(-300));
// ================= a hub with no environment settings: set up inside the app =================
child.kill(); await wait(300);
const port2 = port + 1, base2 = `http://localhost:${port2}`, dir2 = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-mail2-'));
fs.copyFileSync(path.join(dir, 'users.json'), path.join(dir2, 'users.json'));
const env2 = { ...process.env, PORT: String(port2), DATA_DIR: dir2, COOKIE_SECRET: SECRET, MS_AUTH_URL: msBase, MS_GRAPH_URL: msBase + '/v1.0' }; delete env2.MS_CLIENT_ID; delete env2.MS_CLIENT_SECRET;
const child2 = spawn(process.execPath, ['server.js'], { cwd: root, env: env2 }); child2.stderr.on('data', () => {}); child2.stdout.on('data', () => {});
for (let i = 0; i < 60; i++) { try { await fetch(base2 + '/login'); break; } catch { await wait(100); } }
const call2 = async (uid, p, body, method) => { const rr = await fetch(base2 + p, { method: method || (body ? 'POST' : 'GET'), headers: { cookie: ck(uid), 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined }); const tx = await rr.text(); let j; try { j = JSON.parse(tx); } catch { j = tx; } return { status: rr.status, json: j }; };
r = await call2('unate', '/api/config'); ok('without settings: Mail is off for everyone, but the admin can reach the setup', r.json.mail === '' && r.json.mailAdmin === '1', r.json);
r = await call2('uann', '/api/mail/status'); ok('and ordinary mail requests say it is not set up', r.status === 403, r.status);
r = await call2('unate', '/api/mail/admin'); ok('the setup page reports nothing saved yet', r.json.source === 'none' && r.json.hasSecret === false, r.json);
r = await call2('unate', '/api/mail/admin', { clientId: 'not-a-guid' }); ok('a malformed client ID is refused with an explanation', r.status === 400 && /36-character/.test(r.json.error), r.json);
const soon = new Date(Date.now() + 10 * 864e5).toISOString().slice(0, 10);
r = await call2('unate', '/api/mail/admin', { clientId: 'aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb', clientSecret: 'S3CRET', secretExpires: soon }); ok('the admin saves the app settings', r.status === 200 && r.json.source === 'app' && r.json.clientId === 'aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb' && r.json.hasSecret === true, r.json);
ok('the secret is never sent back', !JSON.stringify(r.json).includes('S3CRET') && !JSON.stringify((await call2('unate', '/api/mail/admin')).json).includes('S3CRET'));
ok('and is stored encrypted', !fs.readFileSync(path.join(dir2, 'mail-config.json'), 'utf8').includes('S3CRET'));
r = await call2('unate', '/api/mail/admin'); ok('days until the secret expires are reported (about 10)', r.json.days >= 9 && r.json.days <= 11, r.json);
r = await call2('unate', '/api/config'); ok('saving switches Mail on at once, with no restart', r.json.mail === '1', r.json);
ms.secret = 'OTHER'; { const c1 = await call2('unate', '/api/mail/connect'); const q1 = new URL(c1.json.url); ms.challenge = q1.searchParams.get('code_challenge');
const rr = await fetch(base2 + '/api/mail/callback?code=GOODCODE&state=' + q1.searchParams.get('state'), { redirect: 'manual' }); const tx = await rr.text();
ok('a wrong secret shows up when connecting, explained in plain words', rr.status === 400 && /secret is wrong/i.test(tx) && /Value/.test(tx), [rr.status, tx.slice(-200)]);
r = await call2('unate', '/api/mail/admin'); ok('and nothing is marked as proven', !r.json.verifiedAt, r.json); }
ms.secret = 'EXPIRED'; { const c1 = await call2('unate', '/api/mail/connect'); const q1 = new URL(c1.json.url); const rr = await fetch(base2 + '/api/mail/callback?code=GOODCODE&state=' + q1.searchParams.get('state'), { redirect: 'manual' }); ok('an expired secret is explained when connecting', /expired/i.test(await rr.text()), rr.status); }
ms.secret = 'S3CRET'; { const c1 = await call2('unate', '/api/mail/connect'); const q1 = new URL(c1.json.url); ms.challenge = q1.searchParams.get('code_challenge');
const rr = await fetch(base2 + '/api/mail/callback?code=GOODCODE&state=' + q1.searchParams.get('state'), { redirect: 'manual' }); await rr.text();
ok('with the right secret, connecting works with the settings saved in the app', rr.status === 302, rr.status);
r = await call2('unate', '/api/mail/admin'); ok('and the setup page now shows the app as proven (date of last good connection)', r.json.verifiedAt > 0, r.json);
r = await call2('unate', '/api/mail/status'); ok('the mailbox is connected', r.json.connected === true, r.json);
r = await call2('unate', '/api/mail/admin', { secretExpires: soon }); ok('changing only the expiry keeps it proven', r.json.verifiedAt > 0, r.json);
r = await call2('unate', '/api/mail/admin', { clientSecret: 'NEWSECRET' }); ok('but a new secret has to be proven again', !r.json.verifiedAt, r.json); }
r = await call2('unate', '/api/mail/admin/test', {}); ok('(the old test button endpoint is gone: Microsoft cannot check an app without a real sign-in)', r.status === 404 || r.status === 400, r.status);
r = await call2('uann', '/api/mail/admin', { clientId: 'aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb' }); ok('only an admin can save', r.status === 403, r.status);
r = await call2('unate', '/api/mail/admin', { clientId: '' }); r = await call2('unate', '/api/mail/admin', { clearSecret: true }); r = await call2('unate', '/api/config'); ok('removing the settings switches Mail off again', r.json.mail === '' && !fs.existsSync(path.join(dir2, 'mail-config.json')), r.json);
r = await call2('unate', '/api/audit'); ok('changes to the setup are in the activity log', (r.json.events || []).some(e => e.event === 'mail setup changed'), r.json);
child2.kill(); fs.rmSync(dir2, { recursive: true, force: true });
child.kill(); server.close(); fs.rmSync(dir, { recursive: true, force: true }); child.kill(); server.close(); fs.rmSync(dir, { recursive: true, force: true });
console.log(failed ? `\n${failed} check(s) failed` : '\nAll mail checks passed'); process.exit(failed ? 1 : 0); console.log(failed ? `\n${failed} check(s) failed` : '\nAll mail checks passed'); process.exit(failed ? 1 : 0);
})().catch(e => { console.error('TEST CRASHED:', e); process.exit(1); }); })().catch(e => { console.error('TEST CRASHED:', e); process.exit(1); });
+11
View File
@@ -47,6 +47,17 @@ const users = require('../users'), push = require('../push');
await push.tick(new Date(Date.now() + 60e3), true); await push.tick(new Date(Date.now() + 60e3), true);
ok('and only once', sent.filter(s => s.title === 'Dentist').length === 1, sent); ok('and only once', sent.filter(s => s.title === 'Dentist').length === 1, sent);
// the Microsoft client secret for Mail is about to expire: admins are warned once per stage
const admin = users.create({ name: 'Boss', role: 'admin' }), ad = users.dirOf(admin.id);
fs.writeFileSync(path.join(ad, 'push.json'), JSON.stringify({ tz: TZ, tasks: false, events: false, subs: [{ sub: { endpoint: 'https://push.example/admin', keys: { p256dh: 'x', auth: 'y' } } }] }));
const in5 = new Date(Date.now() + 5 * 864e5).toISOString().slice(0, 10);
fs.writeFileSync(path.join(dir, 'mail-config.json'), JSON.stringify({ data: require('../vault')('nehub-mail').seal({ clientId: 'aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb', clientSecret: 's', secretExpires: in5 }) }));
sent.length = 0; await push.tick(new Date(), true);
ok('an admin is warned that the mail secret expires soon', sent.some(s => /Mail sign-in expires in [456] days/.test(s.title) && s.sub.endpoint.endsWith('/admin')), sent);
const n1 = sent.length; await push.tick(new Date(Date.now() + 60e3), true);
ok('and only once for that stage', sent.length === n1, sent.length);
ok('an ordinary person is not told about it', !sent.some(s => /Mail sign-in/.test(s.title) && !s.sub.endpoint.endsWith('/admin')), sent);
// a subscription the push service says is gone is dropped // a subscription the push service says is gone is dropped
dead = true; writePush({ tasks: true, tasksAt: '00:00', tasksDay: '' }); fs.writeFileSync(path.join(d, 'tasks.json'), JSON.stringify({ lists: [{ id: 'inbox', name: 'Tasks' }], tasks: [{ id: 't1', list: 'inbox', title: 'X', due: '2000-01-01', done: false }] })); dead = true; writePush({ tasks: true, tasksAt: '00:00', tasksDay: '' }); fs.writeFileSync(path.join(d, 'tasks.json'), JSON.stringify({ lists: [{ id: 'inbox', name: 'Tasks' }], tasks: [{ id: 't1', list: 'inbox', title: 'X', due: '2000-01-01', done: false }] }));
await push.tick(new Date()); await push.tick(new Date());