Webmail is now Cypht (several accounts in one place) instead of Roundcube
build-container / build (push) Successful in 6m58s
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:
1 parent
a7a5b155e3
commit
7823bbcb03
12 files changed
+211
-135
No files matched your search
+11
-4
@@ -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=
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
Executable
+18
@@ -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 "$@"
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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));
|
||||
@@ -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, '&').replace(/"/g, '"');
|
||||
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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user