Files
hub/tools/webmail-test.js
T
nmemmertandClaude Sonnet 5.5 cdf862427e
build-container / build (push) Successful in 2m42s
Bundled Roundcube webmail in the Mail tab, optional Microsoft 365 OAuth sign-in, collapsible menu
- docker-compose: roundcube service with inline config (Gmail/iCloud/Yahoo presets, framing allowed, persistent key); only reachable through the hub
- hub passes /webmail/ through to Roundcube behind the passkey sign-in; Microsoft's sign-in page opens in the whole window and returns to the Mail tab
- M365_* settings add 'Login with Microsoft 365' (README has the Entra app registration steps)
- Side menu collapses to icons or hides completely (remembered per device, Ctrl/Cmd+B)
- tools/webmail-test.js

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 08:56:13 -04:00

60 lines
6.2 KiB
JavaScript

#!/usr/bin/env node
// The /webmail/ pass-through to Roundcube: sign-in required, paths, a 401 reply with a streamed body, redirects, cookies.
// Uses a fake Roundcube. Run: node tools/webmail-test.js
const { spawn } = require('child_process');
const http = require('http');
const fs = require('fs'), os = require('os'), path = require('path'), crypto = require('crypto');
const root = path.join(__dirname, '..');
let failed = 0;
const ok = (n, c, x) => { console.log((c ? 'PASS ' : 'FAIL ') + n + (c ? '' : ' -> ' + JSON.stringify(x))); if (!c) failed++; };
const wait = ms => new Promise(r => setTimeout(r, ms));
const SECRET = 'webmail-test-secret-'.repeat(2);
(async () => {
const seen = [];
const rc = http.createServer((rq, rs) => {
const body = []; rq.on('data', d => body.push(d)); rq.on('end', () => {
seen.push({ method: rq.method, url: rq.url, cookie: rq.headers.cookie || '', host: rq.headers.host, body: Buffer.concat(body).toString(), fproto: rq.headers['x-forwarded-proto'] });
if (rq.url.startsWith('/?_task=login') && rq.method === 'POST') { rs.writeHead(401, { 'content-type': 'text/html' }); return rs.end('<p>Login failed.</p>'); }
if (rq.url.startsWith('/?_task=login&_action=oauth')) { rs.writeHead(302, { location: 'https://login.example.test/authorize?client_id=1&state=2' }); return rs.end(); }
if (rq.url.startsWith('/index.php/login/oauth')) { rs.writeHead(302, { location: '/webmail/?_task=mail', 'set-cookie': 'roundcube_sessid=oauth; path=/webmail/' }); return rs.end(); }
if (rq.url === '/go') { rs.writeHead(302, { location: 'http://127.0.0.1:' + rc.address().port + '/webmail/?_task=mail' }); return rs.end(); }
rs.writeHead(200, { 'content-type': 'text/html', 'set-cookie': ['roundcube_sessid=abc; path=/webmail/; HttpOnly', 'roundcube_sessauth=def; path=/webmail/'] }); rs.end('<h1>Roundcube</h1>');
});
});
await new Promise(r => rc.listen(0, '127.0.0.1', r));
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webmail-test-')), port = 20000 + Math.floor(Math.random() * 20000);
fs.writeFileSync(path.join(dataDir, 'users.json'), JSON.stringify({ users: [{ id: 'uadmin1', name: 'Admin', role: 'admin', tabs: ['mail'], handle: 'h', epoch: 1, disabled: false, created: 1 }] }));
const child = spawn(process.execPath, ['server.js'], { cwd: root, env: { ...process.env, PORT: String(port), DATA_DIR: dataDir, COOKIE_SECRET: SECRET, ROUNDCUBE_URL: 'http://127.0.0.1:' + rc.address().port, SEAFILE_URL: 'http://127.0.0.1:9' } });
let log = ''; child.stdout.on('data', d => log += d); child.stderr.on('data', d => log += d);
await wait(1500);
const base = 'http://127.0.0.1:' + port, exp = Date.now() + 864e5, cookie = `hub_session=${exp}.${crypto.createHmac('sha256', SECRET).update('s' + exp).digest('base64url')}`;
const call = (p, o = {}) => fetch(base + p, { redirect: 'manual', ...o, headers: { cookie: cookie + '; roundcube_sessid=keepme', ...(o.headers || {}) } });
let r = await fetch(base + '/webmail/', { redirect: 'manual' });
ok('without the hub sign-in, webmail goes to the sign-in page and Roundcube is never contacted', r.status === 302 && r.headers.get('location') === '/login' && seen.length === 0, [r.status, seen.length]);
r = await call('/webmail/?_task=login');
ok('signed in: the page comes through, with the prefix removed', r.status === 200 && (await r.text()).includes('Roundcube') && seen[0].url === '/?_task=login', seen[0]);
ok("the hub's own sign-in cookie is not passed on, but Roundcube's is", !seen[0].cookie.includes('hub_session') && seen[0].cookie.includes('roundcube_sessid=keepme'), seen[0].cookie);
ok("Roundcube's cookies come back (both of them)", (r.headers.getSetCookie() || []).length === 2, r.headers.getSetCookie());
r = await call('/webmail/?_task=login&_action=login', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: '_user=a&_pass=b' });
ok('a wrong password (Roundcube answers 401) shows its message instead of an error', r.status === 401 && (await r.text()).includes('Login failed'), r.status);
ok('the form data reached Roundcube', seen[seen.length - 1].method === 'POST' && seen[seen.length - 1].body === '_user=a&_pass=b', seen[seen.length - 1]);
r = await call('/webmail/go');
ok('redirects stay under /webmail/', r.status === 302 && r.headers.get('location') === '/webmail/?_task=mail', r.headers.get('location'));
r = await call('/webmail/?_task=login&_action=oauth', { headers: { 'sec-fetch-dest': 'iframe' } }); let t0 = await r.text();
ok('a sign-in page elsewhere (Microsoft) is opened in the whole window, not inside the frame', r.status === 200 && t0.includes('top.location.href=\"https://login.example.test/authorize?client_id=1&state=2\"'), [r.status, t0.slice(0, 200)]);
r = await call('/webmail/?_task=login&_action=oauth', { headers: { 'sec-fetch-dest': 'document' } });
ok('opened on its own (new tab), the redirect to the sign-in page is passed on as it is', r.status === 302 && r.headers.get('location').startsWith('https://login.example.test/'), [r.status, r.headers.get('location')]);
r = await call('/webmail/index.php/login/oauth?code=1&state=2', { headers: { 'sec-fetch-dest': 'document' } });
ok('coming back from the sign-in page keeps the cookies and returns to the Mail tab', r.status === 302 && r.headers.get('location') === '/#/mail' && (r.headers.getSetCookie() || []).some(c => c.startsWith('roundcube_sessid=oauth')), [r.status, r.headers.get('location'), r.headers.getSetCookie()]);
r = await call('/webmail'); ok('/webmail goes to /webmail/', r.status === 302 && r.headers.get('location') === '/webmail/', r.status);
r = await call('/api/config'); ok('the app is told where the webmail is', (await r.json()).webmail === '/webmail/');
rc.close(); await new Promise(r2 => setTimeout(r2, 100));
r = await call('/webmail/'); const t = await r.text();
ok('with Roundcube down, a friendly page (not a crash)', r.status === 502 && t.includes('not reachable'), r.status);
r = await call('/api/config'); ok('and the server is still running', r.status === 200);
child.kill(); fs.rmSync(dataDir, { recursive: true, force: true });
console.log(failed ? '\n' + failed + ' check(s) failed' : '\nAll webmail checks passed'); process.exit(failed ? 1 : 0);
})();