Bundled Roundcube webmail in the Mail tab, optional Microsoft 365 OAuth sign-in, collapsible menu
build-container / build (push) Successful in 2m42s

- 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>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-21 08:56:13 -04:00
1 parent 318006dba3
commit cdf862427e
8 files changed
+230 -11

No files matched your search

+6 -1
View File
@@ -20,5 +20,10 @@ AUDIOBOOKS_LOGIN_LINK=
AUDIOBOOKS_SSO_SECRET=
#AUDIOBOOKS_SSO_URL=https://audiobooks.necloud.us/sso
# Your own webmail to show inside the Mail tab (Roundcube, SnappyMail...). Leave blank to keep the provider shortcuts. See README (Mail).
# Optional: a webmail you run elsewhere, to show in the Mail tab instead of the bundled Roundcube (it must allow framing). Leave blank normally. See README (Mail).
WEBMAIL_URL=
# Microsoft 365 sign-in for the bundled webmail (optional; see README -> Mail for the Entra app registration)
M365_CLIENT_ID=
M365_CLIENT_SECRET=
M365_TENANT=
+18 -5
View File
@@ -1,7 +1,7 @@
# NE Hub
One installable web app (PWA) for Seafile, OnlyOffice and your four webmail accounts.
Mobile-first; bottom tabs on phones, side rail on iPad and desktop (tuned for 12.9" at 1024 and 1366 wide).
Mobile-first; bottom tabs on phones, side rail on iPad and desktop (tuned for 12.9" at 1024 and 1366 wide). On a tablet or computer the menu collapses to icons (button at the bottom of the rail) or hides completely (the menu button at the top, or Ctrl/Cmd+B); the choice is remembered on that device.
## Configure
@@ -55,10 +55,23 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
`{u, id, exp, n}` and `sig` is the base64url HMAC-SHA256 of `body`. Without either, the plain site opens and the saved username can be copied.
- **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**: with `WEBMAIL_URL` set, your own webmail opens inside the app (a frame that keeps running between tabs, with reload, full screen and open-in-new-tab);
a button there opens the Gmail / Outlook.com / Microsoft 365 / iCloud shortcuts. Without it, Mail is those shortcuts (inbox + compose, only for the apps you tick
in Settings -> Mail apps). Gmail, iCloud and Outlook refuse to be framed, so to read all your accounts in the app, run a webmail you control and connect it to them
over IMAP (Gmail and iCloud with an app-specific password). Roundcube example:
- **Mail**: Roundcube comes with the project (a second container in `docker-compose.yml`), so the Mail tab is a full inbox inside the app with nothing to set up.
The hub passes `/webmail/` through to it, so Roundcube is not exposed to the internet, sits behind your passkey sign-in, and shares the hub's address (which
is what lets it be framed). Sign in to it once with your address and an **app password** (Gmail: turn on 2-step verification, then Google account -> App
passwords; iCloud: appleid.apple.com -> App-Specific Passwords; Yahoo: Account security -> App password) and pick the provider in the list; it stays signed in
for 30 days. Gmail, iCloud and Yahoo are preset. The envelope button in the Mail toolbar opens the Gmail / Outlook.com / Microsoft 365 / iCloud web
shortcuts, and Settings -> Mail apps chooses which cards show.
**Microsoft 365** can't use app passwords, but Roundcube can sign in with OAuth 2.0. One-time setup in the Microsoft Entra admin center
(entra.microsoft.com): App registrations -> New registration (this directory only; Redirect URI type *Web*:
`https://<your hub address>/webmail/index.php/login/oauth`); copy the *Application (client) ID* and *Directory (tenant) ID*; Certificates & secrets -> New client
secret (copy its *Value*); API permissions -> Add -> *APIs my organization uses* -> Office 365 Exchange Online -> Delegated -> `IMAP.AccessAsUser.All` and
`SMTP.Send` (plus Microsoft Graph delegated `offline_access`, `openid`, `email`, `profile`), then grant admin consent if your tenant asks. In Exchange, IMAP and
authenticated SMTP must be allowed for the mailbox. Then put `M365_CLIENT_ID`, `M365_CLIENT_SECRET`, `M365_TENANT` (the tenant ID) and `PUBLIC_URL`
(your hub address, used for the redirect) in `.env` and `docker compose up -d`. The Roundcube login then shows **Login with Microsoft 365**. Microsoft's sign-in
page cannot be shown inside a frame, so that one step opens in the whole window and returns you to the Mail tab. Personal Outlook.com accounts are not covered.
Roundcube's data (settings, address book, its encryption key) is in the `nehub-roundcube` volume. To change the presets, edit the `roundcube_hub` config in
`docker-compose.yml` (needs Docker Compose 2.23.1 or newer). To use a webmail you run elsewhere instead, set `WEBMAIL_URL` (it must allow being framed:
no `X-Frame-Options`, and `Content-Security-Policy: frame-ancestors https://<your hub address>`). With neither, Mail is just the shortcut cards.
roundcube:
image: roundcube/roundcubemail:latest-apache
+66 -1
View File
@@ -19,15 +19,80 @@ services:
AUDIOBOOKS_SSO_SECRET: ${AUDIOBOOKS_SSO_SECRET:-}
AUDIOBOOKS_SSO_URL: ${AUDIOBOOKS_SSO_URL:-}
VERSE_URL: ${VERSE_URL:-https://versebyversewithnate.us/admin}
WEBMAIL_URL: ${WEBMAIL_URL:-}
WEBMAIL_URL: ${WEBMAIL_URL:-} # optional: a webmail elsewhere; leave empty to use the bundled Roundcube below
ROUNDCUBE_URL: http://roundcube:80 # the bundled Roundcube; the hub shows it at /webmail/ (it is not exposed to the internet)
PODCAST_URL: ${PODCAST_URL:-https://podcast.necloud.us/}
ONLYOFFICE_JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:-} # same value as the OnlyOffice server's JWT secret
PUBLIC_URL: ${PUBLIC_URL:-} # e.g. https://hub.necloud.us (OnlyOffice posts saved edits here)
HUB_SETUP_CODE: ${HUB_SETUP_CODE:-} # first passkey; if empty, a code is printed in the log
SESSION_DAYS: ${SESSION_DAYS:-180} # how long you stay signed in when idle; using the app renews it
COOKIE_SECRET: ${COOKIE_SECRET:-} # optional; auto-generated and kept in /data if empty
depends_on:
- roundcube
volumes:
- hub-data:/data # passkeys, calendar links and the session secret live here
# Webmail shown inside the Mail tab. Only the hub can reach it (no published port), so it sits behind the passkey sign-in.
# Sign in to it with your mail address and an app password (Gmail, iCloud and Yahoo are preset in the config below).
roundcube:
image: roundcube/roundcubemail:latest-apache
restart: unless-stopped
environment:
ROUNDCUBEMAIL_DB_TYPE: sqlite
ROUNDCUBEMAIL_REQUEST_PATH: /webmail/
ROUNDCUBEMAIL_PLUGINS: archive,zipdownload
ROUNDCUBEMAIL_UPLOAD_MAX_FILESIZE: 30M
# Microsoft 365 sign-in (optional, see README -> Mail). Leave empty to skip.
M365_CLIENT_ID: ${M365_CLIENT_ID:-}
M365_CLIENT_SECRET: ${M365_CLIENT_SECRET:-}
M365_TENANT: ${M365_TENANT:-}
PUBLIC_URL: ${PUBLIC_URL:-}
configs:
- source: roundcube_hub
target: /var/roundcube/config/hub.php
volumes:
- roundcube-data:/var/roundcube/db # address book, settings and the encryption key
configs:
roundcube_hub:
content: |
<?php
// Settings for the Roundcube that comes with NE Hub.
$$config['product_name'] = 'Mail';
$$config['x_frame_options'] = false; // the hub shows this page inside its Mail tab (same address, so only the hub can frame it)
$$hosts = [
'ssl://imap.gmail.com:993' => 'Gmail',
'ssl://imap.mail.me.com:993' => 'iCloud Mail',
'ssl://imap.mail.yahoo.com:993' => 'Yahoo Mail',
];
// Microsoft 365 through OAuth 2.0 (needs an app registration, see README). It goes first because that is where OAuth sign-ins are sent.
if (getenv('M365_CLIENT_ID')) {
$$hosts = ['ssl://outlook.office365.com:993' => 'Microsoft 365 (use the Microsoft button)'] + $$hosts;
$$tenant = getenv('M365_TENANT') ?: 'organizations';
$$config['oauth_provider'] = 'generic';
$$config['oauth_provider_name'] = 'Microsoft 365';
$$config['oauth_client_id'] = getenv('M365_CLIENT_ID');
$$config['oauth_client_secret'] = getenv('M365_CLIENT_SECRET');
$$config['oauth_auth_uri'] = "https://login.microsoftonline.com/$${tenant}/oauth2/v2.0/authorize";
$$config['oauth_token_uri'] = "https://login.microsoftonline.com/$${tenant}/oauth2/v2.0/token";
$$config['oauth_identity_uri'] = 'https://graph.microsoft.com/oidc/userinfo';
$$config['oauth_identity_fields'] = ['email', 'preferred_username'];
$$config['oauth_scope'] = 'email openid profile offline_access https://outlook.office365.com/IMAP.AccessAsUser.All https://outlook.office365.com/SMTP.Send';
$$config['oauth_login_redirect'] = false;
if (getenv('PUBLIC_URL')) $$config['oauth_redirect_uri'] = rtrim(getenv('PUBLIC_URL'), '/') . '/webmail/index.php/login/oauth';
}
$$config['imap_host'] = $$hosts;
$$config['smtp_host'] = 'tls://smtp.%z:587'; // smtp.gmail.com, smtp.mail.me.com, smtp.mail.yahoo.com
$$config['session_lifetime'] = 43200; // minutes (30 days); the hub's own sign-in is what protects access
// Behind the hub: believe its forwarded address and https.
if (($$_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https') $$_SERVER['HTTPS'] = 'on';
if (!empty($$_SERVER['HTTP_X_FORWARDED_HOST'])) $$_SERVER['HTTP_HOST'] = $$_SERVER['HTTP_X_FORWARDED_HOST'];
// A fixed encryption key kept with the data, so saved settings and sessions survive restarts and updates.
$$kf = '/var/roundcube/db/des_key';
if (!is_file($$kf)) @file_put_contents($$kf, bin2hex(random_bytes(12)));
if (is_file($$kf)) $$config['des_key'] = trim(file_get_contents($$kf));
volumes:
hub-data:
name: nehub-data # fixed name so the data survives redeploys and renamed stacks
roundcube-data:
name: nehub-roundcube
+13 -2
View File
@@ -49,6 +49,7 @@ async function loadSettings() {
}
const ICONS = {
menu: '<path d="M4 7h16M4 12h16M4 17h16"/>',
home: '<path d="M3 11l9-8 9 8M5 10v10h14V10"/>',
mail: '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 7l9 6 9-6"/>',
files: '<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/>',
@@ -157,7 +158,10 @@ function renderNav(active = activeTab) {
$('#nav').innerHTML = mine.filter(t => PRIMARY.includes(t[0])).map(tab).join('') +
(rest.length ? `<a href="#" id="more" class="${more ? 'on' : ''}" role="button">${icon('more')}<span>More</span></a>` : '');
const mo = $('#more'); if (mo) mo.onclick = e => { e.preventDefault(); openMore(); };
} else $('#nav').innerHTML = brand + mine.map(tab).join('');
} else {
$('#nav').innerHTML = brand + mine.map(tab).join('') + `<div class="spacer"></div><button id="navcol" class="navcol" aria-label="Collapse the menu" title="Collapse or expand the menu">${icon('back')}<span>Collapse</span></button>`;
$('#navcol').onclick = () => setNav({ mini: !navPref().mini });
}
}
phone.addEventListener('change', () => renderNav());
@@ -169,6 +173,12 @@ function openMore() {
const routes = { home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: viewAudio, verse: viewVerse, podcast: viewPodcast, music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, settings: viewSettings, edit: viewEdit };
let editor;
// Menu on a tablet or computer: collapse it to icons (wide screens), or hide it completely. Remembered on this device.
const navPref = () => { try { return JSON.parse(localStorage.getItem('nav_pref')) || {}; } catch { return {}; } };
function applyNav() { const p = navPref(); document.body.classList.toggle('nav-mini', !!p.mini); document.body.classList.toggle('nav-hidden', !!p.hidden); }
function setNav(patch) { try { localStorage.setItem('nav_pref', JSON.stringify({ ...navPref(), ...patch })); } catch {} applyNav(); }
applyNav();
addEventListener('keydown', e => { if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'b' && !/^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName || '')) { e.preventDefault(); setNav({ hidden: !navPref().hidden }); } });
// In-app Back / Forward (an installed app has no browser buttons). Each history entry remembers its position so we know what is possible.
let navIdx = 0, navMax = 0, navStarted = false;
const parentHash = () => { const p = location.hash.split('/'); return p.length > 3 ? p.slice(0, -1).join('/') : ''; }; // e.g. #/files/lib/a/b -> #/files/lib/a
@@ -183,7 +193,8 @@ function drawNav() {
const canBack = navIdx > 0 || !!parentHash(), canFwd = navIdx < navMax;
if (!el.dataset.on) {
el.dataset.on = 1;
el.innerHTML = `<button class="ibtn" id="nvb" aria-label="Back" title="Back">${icon('back')}</button><button class="ibtn" id="nvf" aria-label="Forward" title="Forward">${icon('chev')}</button><span id="nvt"></span>`;
el.innerHTML = `<button class="ibtn navtoggle" id="nvm" aria-label="Show or hide the menu" title="Show or hide the menu (Ctrl/Cmd+B)">${icon('menu')}</button><button class="ibtn" id="nvb" aria-label="Back" title="Back">${icon('back')}</button><button class="ibtn" id="nvf" aria-label="Forward" title="Forward">${icon('chev')}</button><span id="nvt"></span>`;
$('#nvm').onclick = () => setNav({ hidden: !navPref().hidden });
$('#nvb').onclick = () => { if (navIdx > 0) history.back(); else if (parentHash()) location.hash = parentHash(); };
$('#nvf').onclick = () => history.forward();
}
+25
View File
@@ -318,3 +318,28 @@ body:not(.editing) #view { padding-top: 8px; }
.mact .btn { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 46px; }
.mact .btn svg { width: 20px; height: 20px; }
.mact .btn:not(.ghost) { background: var(--mc); color: #fff; }
/* Collapsible menu (tablet / computer) */
.navtoggle { display: none; }
.navcol { display: none; }
@media (min-width: 768px) {
#nav { overflow-y: auto; scrollbar-width: none; }
.navtoggle { display: grid; }
body.nav-hidden #nav { display: none; }
body.nav-hidden #navbar { padding-left: 12px; }
}
@media (min-width: 1024px) {
.navcol { display: flex; align-items: center; gap: 12px; min-height: 44px; padding: 0 14px; border: 0; background: none; border-radius: 12px; color: var(--muted); font-size: 14px; }
.navcol:hover { background: var(--panel2); color: var(--text); }
.navcol svg { width: 20px; height: 20px; }
body.nav-mini #nav { width: 76px; padding: calc(var(--sat) + 12px) 0 12px var(--sal); }
body.nav-mini #nav a { flex-direction: column; justify-content: center; gap: 2px; padding: 0; font-size: 10px; min-height: 50px; border-radius: 0; flex: none; }
body.nav-mini #nav a svg { width: 22px; height: 22px; }
body.nav-mini #nav a.on { background: none; }
body.nav-mini .brand { display: block; width: 36px; height: 36px; margin: 4px auto 12px; }
body.nav-mini .brand img { width: 36px; height: 36px; border-radius: 10px; }
body.nav-mini .brand span { display: none; }
body.nav-mini .navcol { flex-direction: column; justify-content: center; gap: 2px; padding: 0; min-height: 56px; font-size: 11px; }
body.nav-mini .navcol svg { transform: rotate(180deg); }
body.nav-mini .navcol span { display: none; }
}
+1 -1
View File
@@ -12,7 +12,7 @@ self.addEventListener('activate', e => {
});
self.addEventListener('fetch', e => {
const u = new URL(e.request.url);
if (e.request.method !== 'GET' || u.origin !== location.origin || u.pathname.startsWith('/sf/') || u.pathname.startsWith('/plex/') || u.pathname.startsWith('/api/')) return;
if (e.request.method !== 'GET' || u.origin !== location.origin || u.pathname.startsWith('/sf/') || u.pathname.startsWith('/plex/') || u.pathname.startsWith('/webmail') || u.pathname.startsWith('/api/')) return;
e.respondWith(fetch(e.request).then(r => {
if (r.ok) { const copy = r.clone(); caches.open(CACHE).then(c => c.put(e.request, copy)); }
return r;
+42 -1
View File
@@ -10,6 +10,8 @@ const PORT = process.env.PORT || 8080;
const SEAFILE_URL = (process.env.SEAFILE_URL || 'https://seafile.necloud.us').replace(/\/$/, '');
const ONLYOFFICE_URL = (process.env.ONLYOFFICE_URL || 'https://onlyoffice.necloud.us').replace(/\/$/, '');
const AUDIOBOOKS_URL = (process.env.AUDIOBOOKS_URL || 'https://audiobooks.necloud.us').replace(/\/$/, '');
// Bundled Roundcube (docker-compose): the hub sits in front of it at /webmail/, so it needs no public address, shares the hub's sign-in and can be framed.
const ROUNDCUBE_URL = (process.env.ROUNDCUBE_URL || '').replace(/\/$/, '');
const WEBMAIL_URL = (process.env.WEBMAIL_URL || '').replace(/\/$/, ''); // your own webmail (Roundcube, SnappyMail...) shown inside the Mail tab
const AUDIOBOOKS_SSO_URL = process.env.AUDIOBOOKS_SSO_URL || AUDIOBOOKS_URL + '/sso';
const AUDIOBOOKS_SSO_SECRET = process.env.AUDIOBOOKS_SSO_SECRET || '';
@@ -342,6 +344,43 @@ async function plexProxy(req, res) {
} catch (e) { if (!res.headersSent) { res.writeHead(502); res.end(); } else res.destroy(); }
}
// ---- Webmail: pass /webmail/* to the Roundcube container (only reachable through the hub, so it is behind the passkey sign-in).
// Plain http.request rather than fetch: fetch retries a 401 reply (Roundcube answers a wrong password with 401) and cannot resend a streamed body.
function proxyWebmail(req, res) {
if (req.url === '/webmail') { res.writeHead(302, { location: '/webmail/' }); return res.end(); }
const base = new URL(ROUNDCUBE_URL), headers = { ...req.headers, host: base.host, 'accept-encoding': 'identity',
'x-forwarded-proto': req.headers['x-forwarded-proto'] || (req.socket.encrypted ? 'https' : 'http'), 'x-forwarded-host': req.headers['x-forwarded-host'] || req.headers.host || '', 'x-forwarded-for': req.socket.remoteAddress || '' };
headers.cookie = (req.headers.cookie || '').split(/;\s*/).filter(c => c && !c.startsWith('hub_session=')).join('; '); // the hub's own sign-in never goes to Roundcube
if (!headers.cookie) delete headers.cookie;
delete headers.authorization;
const up = (base.protocol === 'https:' ? require('https') : http).request({ hostname: base.hostname, port: base.port || (base.protocol === 'https:' ? 443 : 80), method: req.method,
path: req.url.slice('/webmail'.length), headers }, r => {
const out = { ...r.headers }; delete out.connection; delete out['keep-alive'];
const loc = r.headers.location;
const dest = req.headers['sec-fetch-dest'] || '';
if (loc && /^https?:\/\//i.test(loc) && new URL(loc).origin !== base.origin && (dest === 'iframe' || dest === 'frame')) {
// A sign-in page elsewhere (Microsoft 365) refuses to be framed: continue in the whole window instead.
const j = JSON.stringify(loc).replace(/</g, '\\u003c'), a = loc.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
r.resume(); return res.end(`<!doctype html><meta charset=utf-8><meta name=viewport content="width=device-width"><body style="font:16px system-ui;padding:24px;color:#888"><p>Opening the sign-in page… <a href="${a}" target="_top">Continue</a></p><script>try{top.location.href=${j}}catch(e){}</script>`);
}
if (req.url.startsWith('/webmail/index.php/login/oauth') && loc) { // back from the sign-in page: carry on inside the app's Mail tab
res.writeHead(302, { 'set-cookie': r.headers['set-cookie'] || [], location: '/#/mail', 'cache-control': 'no-store' }); r.resume(); return res.end();
}
if (loc) { const u = new URL(loc, ROUNDCUBE_URL + '/'); out.location = u.origin === base.origin ? '/webmail' + (u.pathname.startsWith('/webmail/') ? u.pathname.slice('/webmail'.length) : u.pathname) + u.search : loc; }
res.writeHead(r.statusCode, out); r.on('error', () => res.destroy()); r.pipe(res);
});
up.setTimeout(30000, () => up.destroy(new Error('timed out waiting for Roundcube')));
up.on('error', e => {
if (res.headersSent) return res.destroy();
console.error('[webmail] cannot reach Roundcube:', e.code || e.message);
res.writeHead(502, { 'content-type': 'text/html; charset=utf-8' });
res.end('<!doctype html><meta name=viewport content="width=device-width"><body style="font:16px system-ui;padding:24px;color:#888"><p>Webmail is not reachable right now. It may still be starting; try reload in a moment.</p>');
});
res.on('close', () => up.destroy());
req.pipe(up);
}
// ---- Storage health: is /data a real mounted volume, and what is in it? (so a wiped volume is obvious)
/** What is mounted at the data folder? Docker's own anonymous volumes look "mounted" but are lost when the container is recreated. */
function classifyMount(mountinfoLine) {
@@ -397,6 +436,7 @@ const ACCESS = [
[/^\/api\/oo\/config$/, ['office', 'files']],
[/^\/(api\/plex\/|plex\/)/, ['music']],
[/^\/api\/audiobooks\//, ['audio']],
[/^\/webmail(\/|$)/, ['mail']],
[/^\/api\/cal\//, ['calendar']],
[/^\/api\/tasks/, ['tasks']],
[/^\/api\/(search|ddg)$/, ['search']],
@@ -431,7 +471,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 : '', webmail: c('mail') ? WEBMAIL_URL : '', editor: c('office') || c('files'), jwt: !!JWT_SECRET, build: BUILD }));
podcast: c('podcast') ? PODCAST_URL : '', webmail: c('mail') ? (WEBMAIL_URL || (ROUNDCUBE_URL ? '/webmail/' : '')) : '', editor: c('office') || c('files'), jwt: !!JWT_SECRET, build: BUILD }));
}
if (req.method === 'POST' && req.url === '/api/oo/config') return ooConfig(req, res);
if (pathname === '/api/ddg') return ddgAnswer(req, res);
@@ -444,6 +484,7 @@ async function handleRequest(req, res) {
const to = audiobooksLogin(user);
res.writeHead(302, { location: to || AUDIOBOOKS_URL, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' }); return res.end();
}
if (ROUNDCUBE_URL && (pathname === '/webmail' || pathname.startsWith('/webmail/'))) return proxyWebmail(req, res);
if (req.url.startsWith('/api/plex/')) return plexApi(req, res);
if (req.url.startsWith('/plex/') && req.method === 'GET') return plexProxy(req, res);
if (req.url.startsWith('/sf/')) return proxySeafile(req, res);
+59
View File
@@ -0,0 +1,59 @@
#!/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);
})();