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
build-container / build (push) Successful in 20m34s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5c224521d3
commit
2d8d4f4d82
9 files changed
+175
-20
No files matched your search
@@ -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.
|
||||
- **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).
|
||||
**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)
|
||||
|
||||
|
||||
+6
-8
@@ -40,7 +40,9 @@ On the app's **Overview** page, copy the **Application (client) ID** (a long GUI
|
||||
|
||||
## 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>
|
||||
@@ -48,16 +50,12 @@ MS_CLIENT_SECRET=<the secret Value>
|
||||
PUBLIC_URL=https://hub.necloud.us
|
||||
```
|
||||
|
||||
Then redeploy the hub:
|
||||
|
||||
```
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Leave `MS_CLIENT_ID` empty to switch Mail off.
|
||||
then redeploy with `docker compose up -d`. Leave both empty (and nothing saved in the app) to switch Mail off.
|
||||
|
||||
## 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**.
|
||||
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.
|
||||
|
||||
@@ -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 };
|
||||
+44
-1
@@ -38,13 +38,56 @@ async function renderMailCard(el) {
|
||||
}
|
||||
function mailSettings() {
|
||||
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;
|
||||
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>${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); } });
|
||||
}).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 & 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
|
||||
|
||||
+1
-1
@@ -95,7 +95,7 @@ function viewSettings() {
|
||||
remindersInit();
|
||||
// only show what this person may use
|
||||
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');
|
||||
if (!document.querySelector('#sec-conn .list .row')) hide(true, '#sec-conn'); // nothing to show here for this person
|
||||
if ($('#sga-s')) {
|
||||
|
||||
@@ -8,6 +8,7 @@ const atomicWrite = require('./atomic');
|
||||
const users = require('./users');
|
||||
const tasks = require('./tasks');
|
||||
const cal = require('./cal');
|
||||
const mail = require('./mail');
|
||||
|
||||
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); });
|
||||
@@ -64,6 +65,16 @@ async function tick(now = new Date(), force = false) {
|
||||
}
|
||||
} 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')) {
|
||||
try {
|
||||
const from = now.getTime(), to = from + (st.eventMins + 5) * 60e3;
|
||||
|
||||
@@ -467,7 +467,7 @@ async function handleRequest(req, res) {
|
||||
const c = t => users.can(user, t);
|
||||
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 : '',
|
||||
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 (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.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('/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);
|
||||
|
||||
+44
-1
@@ -25,7 +25,8 @@ const SECRET = 'mail-test-secret-'.repeat(3);
|
||||
ms.calls.push(rq.method + ' ' + decodeURIComponent(p + u.search));
|
||||
if (p === '/consumers/oauth2/v2.0/token' && rq.method === 'POST') {
|
||||
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('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' });
|
||||
@@ -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 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);
|
||||
// ================= 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));
|
||||
|
||||
// ================= 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 });
|
||||
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); });
|
||||
@@ -47,6 +47,17 @@ const users = require('../users'), push = require('../push');
|
||||
await push.tick(new Date(Date.now() + 60e3), true);
|
||||
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
|
||||
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());
|
||||
|
||||
Reference in new issue
Block a user