Webmail is now Cypht (several accounts in one place) instead of Roundcube
build-container / build (push) Successful in 6m58s

- cypht/: official Cypht 2.13.0 on a SQLite file, served under /webmail/; workflow builds git.necloud.us/nate/hub-cypht
- the hub passes /webmail/ through behind the passkey sign-in and signs in to Cypht for you (CYPHT_PASSWORD)
- provider sign-in pages (Google, Microsoft) open in the whole window and return to the Mail tab; other links open in a new tab
- Gmail / Outlook.com / Office365 OAuth settings (Microsoft permissions patched to the current ones)
- Roundcube removed; README, compose and webmail tests updated

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-21 09:28:50 -04:00
1 parent a7a5b155e3
commit 7823bbcb03
12 files changed
+211 -135

No files matched your search

+11 -4
View File
@@ -20,10 +20,17 @@ AUDIOBOOKS_LOGIN_LINK=
AUDIOBOOKS_SSO_SECRET=
#AUDIOBOOKS_SSO_URL=https://audiobooks.necloud.us/sso
# 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).
# Optional: a webmail you run elsewhere, to show in the Mail tab instead of the bundled Cypht (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=
# Webmail (Cypht) login. Choose a password; the Mail tab signs in with it for you. Required.
CYPHT_PASSWORD=
#CYPHT_USER=hub
# Sign in with Google / Microsoft instead of app passwords (optional; see README -> Mail)
GMAIL_CLIENT_ID=
GMAIL_CLIENT_SECRET=
OUTLOOK_CLIENT_ID=
OUTLOOK_CLIENT_SECRET=
OFFICE365_CLIENT_ID=
OFFICE365_CLIENT_SECRET=
M365_TENANT=
+4 -4
View File
@@ -21,7 +21,7 @@ jobs:
- run: node tools/users-test.js # accounts: private data, tab limits, invites, migration
- run: node tools/plex-test.js # Plex sign-in stays signed in and repairs itself
- run: node tools/player-test.js # music player: queue, shuffle/repeat, stall recovery
- run: node tools/webmail-test.js # the /webmail/ pass-through to Roundcube
- run: node tools/webmail-test.js # the /webmail/ pass-through to the bundled webmail (Cypht)
- uses: docker/setup-buildx-action@v3
@@ -53,11 +53,11 @@ jobs:
cache-from: type=registry,ref=git.necloud.us/nate/hub:buildcache
cache-to: type=registry,ref=git.necloud.us/nate/hub:buildcache,mode=max
# The webmail container (official Roundcube + the hub's settings)
# The webmail container (official Cypht + the hub's settings)
- id: meta_rc
uses: docker/metadata-action@v5
with:
images: git.necloud.us/nate/hub-roundcube
images: git.necloud.us/nate/hub-cypht
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha,format=short
@@ -65,7 +65,7 @@ jobs:
- uses: docker/build-push-action@v6
with:
context: ./roundcube
context: ./cypht
platforms: linux/amd64
push: true
tags: ${{ steps.meta_rc.outputs.tags }}
+20 -32
View File
@@ -55,38 +55,25 @@ 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**: 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. The webmail is its own image (`git.necloud.us/nate/hub-roundcube`, built by the Gitea workflow from `roundcube/`: the official Roundcube plus
`roundcube/hub.php`), so any compose tool can run it. To change the presets, edit `roundcube/hub.php`. To use a webmail you run elsewhere instead, set `WEBMAIL_URL` (it must allow being framed:
- **Mail**: Cypht comes with the project (a second container in `docker-compose.yml`), so the Mail tab is a full webmail inside the app, built for **several
accounts at once** (iCloud, Gmail, Outlook.com, Office365, Yahoo, Fastmail and others are presets, plus any IMAP server) with combined views of them all.
The hub passes `/webmail/` through to it, so it 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). Set `CYPHT_PASSWORD` (any password you choose; required) in `.env` or your stack; the hub uses it to sign in to Cypht for you, so you
never see a second login. Then, once, in the Mail tab: **Add an E-mail Account**, pick the provider, and follow the steps (iCloud, Yahoo and most others: your
address and an **app password**, e.g. appleid.apple.com -> App-Specific Passwords; Gmail needs 2-step verification, then Google account -> App passwords).
Everything is kept in the `nehub-cypht` volume. Everyone who has the Mail tab shares this one Cypht account; for separate mailboxes give only the people who
should share it the tab, or run `docker exec <cypht container> php /usr/local/share/cypht/scripts/create_account.php <name> <password>` and sign in as that person in Cypht
(the automatic sign-in is then skipped by logging out of Cypht once). The envelope button in the Mail toolbar still opens the Gmail / Outlook.com / Microsoft 365 /
iCloud web shortcuts, and Settings -> Mail apps chooses which of those cards show.
**Sign in with Google or Microsoft instead of app passwords** (optional): create an OAuth app and put its ID and secret in the environment of the `cypht` service
(`GMAIL_CLIENT_ID` / `GMAIL_CLIENT_SECRET` from console.cloud.google.com, with `<your hub address>/webmail/?page=home` as the authorized redirect URI;
`OUTLOOK_CLIENT_ID` / `OUTLOOK_CLIENT_SECRET` for a personal Outlook.com account and `OFFICE365_CLIENT_ID` / `OFFICE365_CLIENT_SECRET` plus `M365_TENANT` for a work
or school account, from a Microsoft Entra app registration with the same redirect URI and the delegated permissions `IMAP.AccessAsUser.All`, `SMTP.Send`, `offline_access`).
Set `PUBLIC_URL` (your hub address) so the redirect address is right. The provider's sign-in page cannot be shown inside a frame, so that step opens in the whole
window and returns you to the Mail tab. The Gmail and Microsoft sign-ins have not been tried against the real providers; the Microsoft ones use a patched permission
list because Cypht's own preset asks for a retired one. 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
environment:
ROUNDCUBEMAIL_DEFAULT_HOST: ssl://imap.gmail.com # or imap.mail.me.com, etc.
ROUNDCUBEMAIL_DEFAULT_PORT: 993
ROUNDCUBEMAIL_SMTP_SERVER: ssl://smtp.gmail.com
ROUNDCUBEMAIL_SMTP_PORT: 465
ROUNDCUBEMAIL_PLUGINS: archive,zipdownload
volumes: [ ./roundcube:/var/roundcube/config ] # a file here can add the lines below
Put this in a config file there: `$config['x_frame_options'] = false;` `$config['imap_host'] = ['ssl://imap.gmail.com:993' => 'Gmail', 'ssl://imap.mail.me.com:993' => 'iCloud'];`
and have the reverse proxy for your webmail send `Content-Security-Policy: frame-ancestors https://<your hub address>` (and no `X-Frame-Options`), so only the hub can frame it.
Keep the webmail on the same site as the hub (for example mail.necloud.us and hub.necloud.us) so its sign-in cookie works inside the frame.
## Install on iPhone / iPad (PWA)
Open the site in **Safari** (it must be the `https://` address), tap **Share -> Add to Home Screen**. The home-screen app has proper
@@ -97,13 +84,14 @@ iOS keeps a separate sign-in for the home-screen app, so sign in once inside it
## Build and deploy with Gitea
`.gitea/workflows/build.yml` builds the container on every push to `main` (and `v*` tags) and pushes it to
`git.necloud.us/nate/hub` (tags: `latest`, short commit sha, git tag). amd64 (x86) only.
`git.necloud.us/nate/hub` (tags: `latest`, short commit sha, git tag), and the webmail container from `cypht/` (the official Cypht plus the hub's settings) to
`git.necloud.us/nate/hub-cypht`. amd64 (x86) only.
One-time setup in Gitea: create an access token with the **write:package** scope, then add it to the repo under
Settings -> Actions -> Secrets as `REGISTRY_TOKEN` (optionally `REGISTRY_USER`). The runner needs Docker.
On the server:
cp .env.example .env # fill in secrets, PUBLIC_URL, etc.
cp .env.example .env # fill in secrets, PUBLIC_URL, CYPHT_PASSWORD (choose one), etc.
docker compose pull && docker compose up -d
docker compose logs hub | grep "Setup code" # first sign-in: create your passkey
+19
View File
@@ -0,0 +1,19 @@
# Cypht webmail for NE Hub: the official image, set up to run on its own SQLite file and to live under /webmail/ behind the hub.
FROM cypht/cypht:2.13.0
COPY nginx.conf /etc/nginx/nginx.conf
# Cypht's Outlook.com / Microsoft 365 presets still ask for the retired 'wl.imap' permission; ask for the current IMAP and SMTP ones instead.
RUN sed -i "s#'scope' => 'wl.imap'#'scope' => 'https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send offline_access'#" /usr/local/share/cypht/modules/nux/services.php \
&& [ "$(grep -c 'IMAP.AccessAsUser.All' /usr/local/share/cypht/modules/nux/services.php)" = 2 ]
COPY entrypoint.sh /hub-entrypoint.sh
RUN mkdir -p /var/lib/hm3
ENV DB_CONNECTION_TYPE=host \
DB_DRIVER=sqlite \
DB_NAME=/var/lib/hm3/cypht.sqlite \
SESSION_TYPE=PHP \
AUTH_TYPE=DB \
USER_CONFIG_TYPE=DB \
COOKIE_PATH=/webmail/ \
ALLOW_LONG_SESSION=true \
LONG_SESSION_LIFETIME=30
VOLUME /var/lib/hm3
ENTRYPOINT ["/hub-entrypoint.sh"]
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
# Cypht's own start-up creates its SQLite file as root, but the web part runs as www-data: make the file and folder www-data's first.
set -e
DB="${DB_NAME:-/var/lib/hm3/cypht.sqlite}"; DIR=$(dirname "$DB")
mkdir -p "$DIR"; [ -f "$DB" ] || touch "$DB"
chown www-data:www-data "$DIR" "$DB"
# ...and again once its set-up has run (it may have added files beside it).
( i=0; while [ $i -lt 60 ]; do sleep 1; chown www-data:www-data "$DIR" "$DB" 2>/dev/null; chown www-data:www-data "$DB"-* 2>/dev/null; i=$((i+1)); done ) &
# OAuth sign-in (Gmail, Outlook.com, Microsoft 365): Cypht needs the address Google / Microsoft send you back to, which is the hub's /webmail/ page.
HUB="${PUBLIC_URL:-http://localhost:8080}"; HUB="${HUB%/}"
: "${GMAIL_CLIENT_URI:=$HUB/webmail/?page=home}"; : "${OUTLOOK_CLIENT_URI:=$HUB/webmail/?page=home}"; : "${OFFICE365_CLIENT_URI:=$HUB/webmail/?page=home}"
# Microsoft's current (v2) endpoints: personal accounts use 'consumers', work or school accounts use your tenant.
MS=https://login.microsoftonline.com; T="${M365_TENANT:-organizations}"
: "${OUTLOOK_AUTH_URI:=$MS/consumers/oauth2/v2.0/authorize}"; : "${OUTLOOK_TOKEN_URI:=$MS/consumers/oauth2/v2.0/token}"; : "${OUTLOOK_REFRESH_URI:=$MS/consumers/oauth2/v2.0/token}"
: "${OFFICE365_AUTH_URI:=$MS/$T/oauth2/v2.0/authorize}"; : "${OFFICE365_TOKEN_URI:=$MS/$T/oauth2/v2.0/token}"; : "${OFFICE365_REFRESH_URI:=$MS/$T/oauth2/v2.0/token}"
export GMAIL_CLIENT_URI OUTLOOK_CLIENT_URI OFFICE365_CLIENT_URI OUTLOOK_AUTH_URI OUTLOOK_TOKEN_URI OUTLOOK_REFRESH_URI OFFICE365_AUTH_URI OFFICE365_TOKEN_URI OFFICE365_REFRESH_URI
exec docker-entrypoint.sh "$@"
+46
View File
@@ -0,0 +1,46 @@
user www-data;
worker_processes 4;
pid /run/nginx.pid;
events {
worker_connections 768;
}
http {
sendfile off;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
include /etc/nginx/mime.types;
default_type application/octet-stream;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2; # Dropping SSLv3, ref: POODLE
ssl_prefer_server_ciphers on;
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
gzip on;
gzip_disable "msie6";
server {
listen 80;
server_name localhost;
index index.php;
root /var/www;
client_max_body_size 60M;
# The hub shows this at /webmail/. Cypht builds its links from the address it was asked for ($request_uri, which stays as asked),
# so it is handed the /webmail/ address and the files are found here without the prefix.
location = /webmail { return 302 /webmail/; }
location /webmail/ {
rewrite ^/webmail/(.*)$ /$1 last;
}
location / {
try_files $uri /index.php$is_args$args;
}
location ~ \.php {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param SCRIPT_NAME $fastcgi_script_name;
fastcgi_index index.php;
fastcgi_pass 127.0.0.1:9000;
}
}
}
+23 -15
View File
@@ -19,8 +19,10 @@ 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:-} # 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)
WEBMAIL_URL: ${WEBMAIL_URL:-} # optional: a webmail elsewhere; leave empty to use the bundled Cypht below
WEBMAIL_BACKEND_URL: http://cypht:80 # the bundled webmail (Cypht); the hub shows it at /webmail/ (it is not exposed to the internet)
CYPHT_USER: ${CYPHT_USER:-hub} # the webmail's login; the Mail tab signs in with it for you
CYPHT_PASSWORD: ${CYPHT_PASSWORD:-}
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)
@@ -28,27 +30,33 @@ services:
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
- cypht
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 roundcube/hub.php).
roundcube:
image: git.necloud.us/nate/hub-roundcube:latest # built by the Gitea workflow from ./roundcube; `docker compose build` builds locally instead
build: ./roundcube
# Webmail shown inside the Mail tab (Cypht: several mail accounts in one place). Only the hub can reach it (no published port),
# so it sits behind the passkey sign-in. Add your accounts once inside it (Gmail, iCloud, Outlook.com, Office365, Yahoo and others are presets).
cypht:
image: git.necloud.us/nate/hub-cypht:latest # built by the Gitea workflow from ./cypht; `docker compose build` builds locally instead
build: ./cypht
restart: unless-stopped
environment:
# 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:-}
AUTH_USERNAME: ${CYPHT_USER:-hub}
AUTH_PASSWORD: ${CYPHT_PASSWORD:?Choose a password for the webmail and set CYPHT_PASSWORD} # the hub also uses it to sign you in
PUBLIC_URL: ${PUBLIC_URL:-}
# Sign in with Google / Microsoft instead of an app password (optional, see README -> Mail). Leave empty to skip.
GMAIL_CLIENT_ID: ${GMAIL_CLIENT_ID:-}
GMAIL_CLIENT_SECRET: ${GMAIL_CLIENT_SECRET:-}
OUTLOOK_CLIENT_ID: ${OUTLOOK_CLIENT_ID:-} # personal Outlook.com / Hotmail
OUTLOOK_CLIENT_SECRET: ${OUTLOOK_CLIENT_SECRET:-}
OFFICE365_CLIENT_ID: ${OFFICE365_CLIENT_ID:-} # work or school Microsoft 365
OFFICE365_CLIENT_SECRET: ${OFFICE365_CLIENT_SECRET:-}
M365_TENANT: ${M365_TENANT:-}
volumes:
- roundcube-data:/var/roundcube/db # address book, settings and the encryption key
- cypht-data:/var/lib/hm3 # the webmail's database: your accounts and settings
volumes:
hub-data:
name: nehub-data # fixed name so the data survives redeploys and renamed stacks
roundcube-data:
name: nehub-roundcube
cypht-data:
name: nehub-cypht
+22 -1
View File
@@ -733,13 +733,34 @@ function viewWebmail() {
setSt('Opening…'); let f = $('#audiowrap iframe[data-k="mail"]'); if (f) f.remove();
showFrame('mail', cfg.webmail + (fresh ? (cfg.webmail.includes('?') ? '&' : '?') + '_=' + Date.now() : ''), 'Mail', 'fullscreen; clipboard-write; clipboard-read');
f = $('#audiowrap iframe[data-k="mail"]'); let done = false;
f.addEventListener('load', () => { done = true; setSt(''); });
f.addEventListener('load', () => { done = true; setSt(''); webmailHooks(f); });
setTimeout(() => { if (!done) setSt('Still loading… if it says it refused to connect, the webmail must allow framing (see the README)'); }, 10000);
};
if (!old) open(false); else { showFrame('mail', '', 'Mail', ''); setSt(''); }
$('#ar').onclick = () => open(true);
$('#af').onclick = () => { const f = $('#audiowrap iframe[data-k="mail"]'); if (f && f.requestFullscreen) f.requestFullscreen().catch(() => toast('Full screen is not available here')); else toast('Full screen is not available here'); };
}
// The webmail page shares our address, so we can help it from outside: sign in for you, and send links that cannot be framed to the right place.
const SIGNIN_HOSTS = /^(accounts\.google\.com|login\.microsoftonline\.com|login\.live.com|appleid\.apple\.com|github\.com)$/;
async function webmailHooks(f) {
let d; try { d = f.contentDocument; if (!d || !d.body) return; } catch { return; }
if (!d.__hubHooks) {
d.__hubHooks = true;
d.addEventListener('click', e => {
const a = e.target.closest && e.target.closest('a[href]'); if (!a || e.defaultPrevented) return;
let u; try { u = new URL(a.href, d.baseURI); } catch { return; }
if (!/^https?:$/.test(u.protocol) || u.origin === location.origin) return;
e.preventDefault(); e.stopPropagation();
if (SIGNIN_HOSTS.test(u.hostname)) location.href = u.href; // a provider's sign-in page refuses to be framed: use the whole window
else window.open(u.href, '_blank', 'noopener');
}, true);
}
const u = d.querySelector('input[name=username]'), p = d.querySelector('input[name=password]'), form = u && u.form;
if (form && p && !d.querySelector('.error, .danger, .alert-danger') && !f.dataset.tried) { // the webmail's own login box: fill it in (once, so a wrong password is not retried)
f.dataset.tried = 1;
try { const c = await jfetch('/api/webmail/login'); if (c.user && c.pass) { u.value = c.user; p.value = c.pass; form.submit(); } } catch {}
}
}
function viewMailCards() {
const hints = mailHints(), on = mailOn(), shown = MAIL.filter(m => on.includes(m.id)), anyHint = shown.some(m => hints[m.id]);
$('#view').innerHTML = `<div class="wrap">${cfg.webmail ? `<div class="bar" style="margin-top:0"><a class="btn ghost" href="#/mail">${icon('back')}Webmail</a></div>` : ''}<h1>Mail</h1>
-7
View File
@@ -1,7 +0,0 @@
# Roundcube webmail for NE Hub: the official image plus the hub's settings (so no config file has to be mounted).
FROM roundcube/roundcubemail:latest-apache
COPY hub.php /var/roundcube/config/hub.php
ENV ROUNDCUBEMAIL_DB_TYPE=sqlite \
ROUNDCUBEMAIL_REQUEST_PATH=/webmail/ \
ROUNDCUBEMAIL_PLUGINS=archive,zipdownload \
ROUNDCUBEMAIL_UPLOAD_MAX_FILESIZE=30M
-35
View File
@@ -1,35 +0,0 @@
<?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));
+26 -19
View File
@@ -10,9 +10,12 @@ 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
// Bundled webmail (Cypht, 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 WEBMAIL_BACKEND_URL = (process.env.WEBMAIL_BACKEND_URL || '').replace(/\/$/, '');
// The webmail's own login (one shared account, see README). When the hub knows it, the Mail tab signs in for you.
const CYPHT_USER = process.env.CYPHT_USER || 'hub', CYPHT_PASSWORD = process.env.CYPHT_PASSWORD || '';
const isWebmailPath = p => p === '/webmail' || p.startsWith('/webmail/');
const WEBMAIL_URL = (process.env.WEBMAIL_URL || '').replace(/\/$/, ''); // your own webmail 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 || '';
// Back-door sign-in for the audiobooks site. AUDIOBOOKS_LOGIN_LINK is the site's own secret link (the key is part of it); anyone who has it
@@ -344,36 +347,39 @@ 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.
// ---- Webmail: pass /webmail/* to the webmail container. It is only reachable through the hub,
// so it sits behind the passkey sign-in. Plain http.request rather than fetch: fetch retries a 401 reply 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',
const base = new URL(WEBMAIL_BACKEND_URL);
const headers = { ...req.headers, '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
headers.cookie = (req.headers.cookie || '').split(/;\s*/).filter(c => c && !c.startsWith('hub_session=')).join('; '); // the hub's own sign-in never goes to the webmail
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 => {
path: req.url, headers }, r => { // (the /webmail/ address is kept: the webmail builds its links from it)
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 loc = r.headers.location, dest = req.headers['sec-fetch-dest'] || '';
if (loc && /^https?:\/\//i.test(loc) && new URL(loc).origin !== base.origin && new URL(loc).origin !== 'http://' + (req.headers.host || '') && new URL(loc).origin !== 'https://' + (req.headers.host || '') && (dest === 'iframe' || dest === 'frame')) {
// A sign-in page elsewhere 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
if (loc && dest === 'document' && /[?&]state=nux_authorization/.test(req.url)) { // back from Google / Microsoft in the whole window: carry on inside the 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; }
if (loc) { // keep redirects on the hub, under /webmail/ (the webmail thinks it lives at the root)
const u = new URL(loc, 'http://' + (req.headers.host || 'hub') + '/');
if (u.origin === base.origin || u.host === req.headers.host) out.location = (u.pathname.startsWith('/webmail/') ? '' : '/webmail') + u.pathname + u.search; // (the webmail may answer as if it lived at the root)
}
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.setTimeout(30000, () => up.destroy(new Error('timed out waiting for the webmail')));
up.on('error', e => {
if (res.headersSent) return res.destroy();
console.error('[webmail] cannot reach Roundcube:', e.code || e.message);
console.error('[webmail] cannot reach the webmail:', 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>');
});
@@ -436,7 +442,7 @@ const ACCESS = [
[/^\/api\/oo\/config$/, ['office', 'files']],
[/^\/(api\/plex\/|plex\/)/, ['music']],
[/^\/api\/audiobooks\//, ['audio']],
[/^\/webmail(\/|$)/, ['mail']],
[/^\/(webmail(\/|$)|api\/webmail\/)/, ['mail']],
[/^\/api\/cal\//, ['calendar']],
[/^\/api\/tasks/, ['tasks']],
[/^\/api\/(search|ddg)$/, ['search']],
@@ -471,7 +477,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 || (ROUNDCUBE_URL ? '/webmail/' : '')) : '', editor: c('office') || c('files'), jwt: !!JWT_SECRET, build: BUILD }));
podcast: c('podcast') ? PODCAST_URL : '', webmail: c('mail') ? (WEBMAIL_URL || (WEBMAIL_BACKEND_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);
@@ -484,7 +490,8 @@ 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 (pathname === '/api/webmail/login') { res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(WEBMAIL_BACKEND_URL && CYPHT_PASSWORD ? { user: CYPHT_USER, pass: CYPHT_PASSWORD } : {})); }
if (WEBMAIL_BACKEND_URL && isWebmailPath(pathname)) 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);
+22 -18
View File
@@ -1,6 +1,6 @@
#!/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
// The /webmail/ pass-through to the webmail (Cypht): sign-in required, paths, a 401 reply with a streamed body, redirects, cookies, the sign-in helper.
// Uses a fake webmail. 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');
@@ -15,44 +15,48 @@ const SECRET = 'webmail-test-secret-'.repeat(2);
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>');
if (rq.url.startsWith('/webmail/?_task=login') && rq.method === 'POST') { rs.writeHead(401, { 'content-type': 'text/html' }); return rs.end('<p>Login failed.</p>'); }
if (rq.url.startsWith('/webmail/?_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('/webmail/?page=home&state=nux_authorization')) { rs.writeHead(303, { location: '/?page=servers', 'set-cookie': 'hm_session=oauth; path=/webmail/' }); return rs.end(); }
if (rq.url === '/webmail/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': ['hm_session=abc; path=/webmail/; HttpOnly', 'hm_auth=def; path=/webmail/'] }); rs.end('<h1>the webmail</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' } });
const child = spawn(process.execPath, ['server.js'], { cwd: root, env: { ...process.env, PORT: String(port), DATA_DIR: dataDir, COOKIE_SECRET: SECRET, WEBMAIL_BACKEND_URL: 'http://127.0.0.1:' + rc.address().port, CYPHT_USER: 'hub', CYPHT_PASSWORD: 'secret-pass', 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 || {}) } });
const call = (p, o = {}) => fetch(base + p, { redirect: 'manual', ...o, headers: { cookie: cookie + '; hm_session=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]);
ok('without the hub sign-in, webmail goes to the sign-in page and the webmail 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());
ok('signed in: the page comes through, with the prefix removed', r.status === 200 && (await r.text()).includes('the webmail') && seen[0].url === '/webmail/?_task=login', seen[0]);
ok("the hub's own sign-in cookie is not passed on, but the webmail's is", !seen[0].cookie.includes('hub_session') && seen[0].cookie.includes('hm_session=keepme'), seen[0].cookie);
ok("the webmail'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]);
ok('a wrong password (the webmail 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 the webmail', 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/?page=home&state=nux_authorization&code=1', { 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('hm_session=oauth')), [r.status, r.headers.get('location'), r.headers.getSetCookie()]);
r = await call('/api/webmail/login'); const lg = await r.json();
ok('the Mail tab can sign in to the webmail for you', lg.user === 'hub' && lg.pass === 'secret-pass', lg);
r = await fetch(base + '/api/webmail/login', { redirect: 'manual' });
ok('but only someone signed in to the hub', r.status === 302 || r.status === 401, r.status);
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);
ok('with the webmail 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);