Mail: Outlook.com inside the app through Microsoft Graph (folders, reader, compose, reply, unread badge); Cypht webmail removed
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1564941ea5
commit
a9c277b497
23 files changed
+637
-405
No files matched your search
+4
-14
@@ -20,17 +20,7 @@ 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 Cypht (it must allow framing). Leave blank normally. See README (Mail).
|
||||
WEBMAIL_URL=
|
||||
|
||||
# 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=
|
||||
# Mail (Outlook.com personal accounts, through Microsoft Graph). Register an app at entra.microsoft.com (see README -> Mail),
|
||||
# then put its Application (client) ID here. The secret is only needed if you registered it as a "Web" app.
|
||||
MS_CLIENT_ID=
|
||||
MS_CLIENT_SECRET=
|
||||
@@ -25,8 +25,8 @@ jobs:
|
||||
- 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/lyrics-test.js # lyrics: formats, wrong matches refused, remembered
|
||||
- run: node tools/mail-test.js # Outlook.com mail through Microsoft Graph: connect, refresh, read, send, privacy
|
||||
- run: node tools/signal-test.js # Signal: linking, messages, sending, live updates
|
||||
- run: node tools/webmail-test.js # the /webmail/ pass-through to the bundled webmail (Cypht)
|
||||
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
@@ -59,24 +59,6 @@ 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 Cypht + the hub's settings)
|
||||
- id: meta_rc
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: git.necloud.us/nate/hub-cypht
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=sha,format=short
|
||||
type=ref,event=tag
|
||||
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./cypht
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.meta_rc.outputs.tags }}
|
||||
labels: ${{ steps.meta_rc.outputs.labels }}
|
||||
|
||||
# The lyrics container (Lyrica, pinned)
|
||||
- id: meta_ly
|
||||
uses: docker/metadata-action@v5
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# NE Hub
|
||||
|
||||
One installable web app (PWA) for Seafile, OnlyOffice and your four webmail accounts.
|
||||
One installable web app (PWA) for Seafile, OnlyOffice, Plex, Signal, Outlook.com mail and more.
|
||||
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
|
||||
@@ -77,25 +77,8 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
|
||||
- **Code layout**: `public/` holds one script per feature (`tasks.js`, `calendar.js`, `music.js`, `player.js`, `files.js`, `settings.js`, ...). They are plain scripts that share one global scope and load in the order listed in `index.html` (`boot.js` last); the versioned `?v=` addresses and the cache version are computed from every file in `public/`.
|
||||
- **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**: Cypht comes with the project (a second container in `docker-compose.yml`), so the Mail tab can open a full webmail inside the app (the shortcut cards are the default page; the **Open webmail** button there opens it, and the envelope button in
|
||||
the webmail goes back), 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 default Mail page is 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.
|
||||
- **Mail**: your **Outlook.com** (personal) mailbox inside the app: folders with unread counts, message list with search, reader, compose with attachments (up to about 3 MB), reply, reply all, forward, archive, delete, flag, mark unread, an unread badge on the Mail tab and a Home card. It uses Microsoft Graph, because Outlook.com no longer allows IMAP passwords. Each person connects their own mailbox (Mail tab -> Connect Outlook.com, sign-in is on Microsoft's own page); the hub keeps only the sign-in token, encrypted, in that person's folder, and everything is private to them. Message text is shown in a sandboxed frame with no scripts, and pictures from the internet stay hidden until you press Show pictures. Not included: drafts autosave, contacts, several mailboxes per person, Microsoft 365 work accounts, Gmail/iCloud (those can be added the same way later).
|
||||
**One-time setup** (needs a free Microsoft account; takes five minutes): go to entra.microsoft.com -> App registrations -> New registration. Name it NE Hub; supported account types **Personal Microsoft accounts only**; Redirect URI: platform **Web**, value `<your hub address>/api/mail/callback` (for example `https://hub.necloud.us/api/mail/callback`; it must match `PUBLIC_URL` exactly). Then API permissions -> Add -> Microsoft Graph -> Delegated: `Mail.ReadWrite`, `Mail.Send`, `User.Read`, `offline_access`. Copy the **Application (client) ID** into `MS_CLIENT_ID`. If you registered it as a Web app, also make a client secret (Certificates & secrets) and put its value in `MS_CLIENT_SECRET`; if you used platform "Mobile and desktop applications" instead, no secret is needed. Leave `MS_CLIENT_ID` empty to switch Mail off. (The old bundled Cypht webmail and its `nehub-cypht` volume are gone; you can delete that volume.) Connecting from the installed iPhone/iPad app may open Microsoft's page in Safari; if the app does not notice, reopen it.
|
||||
|
||||
## Install on iPhone / iPad (PWA)
|
||||
|
||||
@@ -107,14 +90,13 @@ 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), and the webmail container from `cypht/` (the official Cypht plus the hub's settings) to
|
||||
`git.necloud.us/nate/hub-cypht`. The hub image is amd64 and arm64; the webmail and lyrics images are amd64 only.
|
||||
`git.necloud.us/nate/hub` (tags: `latest`, short commit sha, git tag) and the lyrics container from `lyrica/` to `git.necloud.us/nate/hub-lyrica`. The hub image is amd64 and arm64; the lyrics image is amd64 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, CYPHT_PASSWORD (choose one), etc.
|
||||
cp .env.example .env # fill in secrets, PUBLIC_URL, MS_CLIENT_ID (for Mail), etc.
|
||||
docker compose pull && docker compose up -d
|
||||
docker compose logs hub | grep "Setup code" # first sign-in: create your passkey
|
||||
@@ -1,19 +0,0 @@
|
||||
# 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"]
|
||||
@@ -1,18 +0,0 @@
|
||||
#!/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 "$@"
|
||||
@@ -1,46 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-31
@@ -19,10 +19,8 @@ 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 Cypht below
|
||||
WEBMAIL_BACKEND_URL: http://127.0.0.1:80 # the bundled webmail (Cypht, below, shares this container's network); the hub shows it at /webmail/
|
||||
CYPHT_USER: ${CYPHT_USER:-hub} # the webmail's login; the Mail tab signs in with it for you
|
||||
CYPHT_PASSWORD: ${CYPHT_PASSWORD:-}
|
||||
MS_CLIENT_ID: ${MS_CLIENT_ID:-} # Outlook.com mail (Microsoft app registration, see README -> Mail); leave empty to switch Mail off
|
||||
MS_CLIENT_SECRET: ${MS_CLIENT_SECRET:-}
|
||||
LYRICS_API_URL: http://127.0.0.1:9999 # the bundled lyrics service (Lyrica, below, shares this container's network); remove this line and the service to skip it
|
||||
SIGNAL_API_URL: http://127.0.0.1:8085 # the bundled Signal bridge (below, shares this container's network); remove this line and the service to skip Signal
|
||||
PODCAST_URL: ${PODCAST_URL:-https://podcast.necloud.us/}
|
||||
@@ -34,31 +32,6 @@ services:
|
||||
volumes:
|
||||
- hub-data:/data # passkeys, calendar links and the session secret live here
|
||||
|
||||
# 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
|
||||
# Shares the hub's network, so the hub reaches it at 127.0.0.1 whatever network mode or DNS the host uses, and nothing else can reach it.
|
||||
network_mode: "service:hub"
|
||||
depends_on:
|
||||
- hub
|
||||
environment:
|
||||
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:
|
||||
- cypht-data:/var/lib/hm3 # the webmail's database: your accounts and settings
|
||||
|
||||
# Signal messages (the Signal tab). This links to your Signal account as one more linked device (like Signal Desktop) through the community
|
||||
# signal-cli-rest-api bridge. Not an official Signal product; its link to your account (keys) is kept in the signal-data volume. See README -> Signal.
|
||||
signal:
|
||||
@@ -88,7 +61,5 @@ services:
|
||||
volumes:
|
||||
hub-data:
|
||||
name: nehub-data # fixed name so the data survives redeploys and renamed stacks
|
||||
cypht-data:
|
||||
name: nehub-cypht
|
||||
signal-data:
|
||||
name: nehub-signal
|
||||
@@ -0,0 +1,207 @@
|
||||
// Mail for a personal Outlook.com account, through Microsoft Graph (the supported way now that Outlook.com has switched off IMAP passwords).
|
||||
// Each person connects their own account: they sign in on Microsoft's page, the hub keeps only the tokens (encrypted, in their own folder),
|
||||
// and every mail request runs as that person. Needs an app registration (MS_CLIENT_ID, see README -> Mail).
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const atomicWrite = require('./atomic');
|
||||
const users = require('./users');
|
||||
const audit = require('./audit');
|
||||
const vault = require('./vault')('nehub-mail');
|
||||
|
||||
const CLIENT_ID = process.env.MS_CLIENT_ID || '', CLIENT_SECRET = process.env.MS_CLIENT_SECRET || '';
|
||||
const TENANT = process.env.MS_TENANT || 'consumers'; // personal Microsoft accounts only
|
||||
const LOGIN = (process.env.MS_AUTH_URL || 'https://login.microsoftonline.com').replace(/\/$/, '');
|
||||
const GRAPH = (process.env.MS_GRAPH_URL || 'https://graph.microsoft.com/v1.0').replace(/\/$/, '');
|
||||
const PUBLIC_URL = (process.env.PUBLIC_URL || '').replace(/\/$/, '');
|
||||
const SCOPES = 'offline_access Mail.ReadWrite Mail.Send User.Read';
|
||||
const enabled = () => !!CLIENT_ID;
|
||||
|
||||
const MAX_BODY = 6 * 1024 * 1024, MAX_ATTACH = 3 * 1024 * 1024;
|
||||
const readBody = req => new Promise((ok, no) => { const c = []; let n = 0; req.on('data', d => { n += d.length; if (n > MAX_BODY) { no(new Error('That is too large to send (attachments can total about 3 MB)')); req.destroy(); } else c.push(d); }); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); });
|
||||
const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(obj)); };
|
||||
const fail = (status, message) => Object.assign(new Error(message), { status });
|
||||
|
||||
// ---- per-person token file (encrypted)
|
||||
const file = () => path.join(users.dir(), 'mail.json');
|
||||
const load = () => { try { return vault.unseal(JSON.parse(fs.readFileSync(file(), 'utf8')).data) || null; } catch { return null; } };
|
||||
const save = o => atomicWrite(file(), JSON.stringify({ data: vault.seal(o) }), { mode: 0o600 });
|
||||
const wipe = () => { try { fs.unlinkSync(file()); } catch {} };
|
||||
|
||||
// ---- sign-in with Microsoft (authorization code + PKCE)
|
||||
const states = new Map(); // state -> { uid, verifier, redirect, exp }
|
||||
const redirectUri = req => (PUBLIC_URL || `${req.headers['x-forwarded-proto'] || 'http'}://${req.headers['x-forwarded-host'] || req.headers.host}`) + '/api/mail/callback';
|
||||
function startUrl(req) {
|
||||
for (const [k, v] of states) if (v.exp < Date.now()) states.delete(k);
|
||||
const state = crypto.randomBytes(24).toString('base64url'), verifier = crypto.randomBytes(48).toString('base64url');
|
||||
const redirect = redirectUri(req);
|
||||
states.set(state, { uid: users.current().id, verifier, redirect, exp: Date.now() + 10 * 60e3 });
|
||||
const q = new URLSearchParams({ client_id: CLIENT_ID, response_type: 'code', redirect_uri: redirect, response_mode: 'query', scope: SCOPES, state,
|
||||
code_challenge: crypto.createHash('sha256').update(verifier).digest('base64url'), code_challenge_method: 'S256', prompt: 'select_account' });
|
||||
return `${LOGIN}/${TENANT}/oauth2/v2.0/authorize?${q}`;
|
||||
}
|
||||
async function tokenCall(params) {
|
||||
const body = new URLSearchParams({ client_id: CLIENT_ID, scope: SCOPES, ...params }); if (CLIENT_SECRET) body.set('client_secret', CLIENT_SECRET);
|
||||
const r = await fetch(`${LOGIN}/${TENANT}/oauth2/v2.0/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body, signal: AbortSignal.timeout(15000) });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw Object.assign(new Error(j.error_description || j.error || 'Microsoft refused the sign-in'), { status: r.status, code: j.error });
|
||||
return j;
|
||||
}
|
||||
const page = (title, msg) => `<!doctype html><meta charset=utf-8><meta name=viewport content="width=device-width"><title>${title}</title><body style="font:16px system-ui;padding:28px;max-width:520px;margin:auto"><h2>${title}</h2><p>${msg}</p><p><a href="/#/mail">Back to NE Hub</a></p>`;
|
||||
const escHtml = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
|
||||
/** Microsoft sends the person back here. Runs without the hub cookie (an installed app may sign in in another browser), so the one-time state says who it is. */
|
||||
async function callback(req, res) {
|
||||
const q = new URL(req.url, 'http://x').searchParams, rec = states.get(q.get('state') || '');
|
||||
const send = (code, html) => { res.writeHead(code, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' }); res.end(html); };
|
||||
if (!rec || rec.exp < Date.now()) return send(400, page('Sign-in expired', 'That sign-in link is no longer valid. Go back and press Connect again.'));
|
||||
states.delete(q.get('state'));
|
||||
if (q.get('error')) return send(400, page('Not connected', escHtml(q.get('error_description') || q.get('error'))));
|
||||
const user = users.get(rec.uid); if (!user || user.disabled) return send(403, page('Not allowed', 'That account is not available.'));
|
||||
try {
|
||||
const t = await tokenCall({ grant_type: 'authorization_code', code: q.get('code') || '', redirect_uri: rec.redirect, code_verifier: rec.verifier });
|
||||
if (!t.refresh_token) throw new Error('Microsoft did not allow ongoing access');
|
||||
await users.runAs(user, async () => {
|
||||
const me = await (await fetch(GRAPH + '/me?$select=displayName,mail,userPrincipalName', { headers: { authorization: 'Bearer ' + t.access_token } })).json().catch(() => ({}));
|
||||
save({ access: t.access_token, refresh: t.refresh_token, exp: Date.now() + (t.expires_in || 3600) * 1000, address: me.mail || me.userPrincipalName || '', name: me.displayName || '' });
|
||||
audit.log('mail connected', { who: user.name, detail: me.mail || me.userPrincipalName || '' }, req);
|
||||
});
|
||||
res.writeHead(302, { location: '/#/mail', 'cache-control': 'no-store' }); res.end();
|
||||
} catch (e) { console.error('[mail] sign-in failed:', e.message); send(400, page('Could not connect', escHtml(e.message))); }
|
||||
}
|
||||
|
||||
const refreshing = new Map(); // uid -> promise: parallel requests share one refresh (refresh tokens can rotate)
|
||||
/** A working access token for the current person, refreshed when needed. `stale` is the token that just failed. */
|
||||
async function token(force, stale) {
|
||||
const st = load(); if (!st) throw fail(409, 'Mail is not connected');
|
||||
if (!force && st.exp - 60e3 > Date.now()) return st.access;
|
||||
if (force && st.access !== stale) return st.access; // another request already refreshed it
|
||||
const uid = users.current().id;
|
||||
if (refreshing.has(uid)) return refreshing.get(uid);
|
||||
const job = (async () => {
|
||||
try {
|
||||
const t = await tokenCall({ grant_type: 'refresh_token', refresh_token: st.refresh });
|
||||
save({ ...st, access: t.access_token, refresh: t.refresh_token || st.refresh, exp: Date.now() + (t.expires_in || 3600) * 1000 });
|
||||
return t.access_token;
|
||||
} catch (e) {
|
||||
if (e.code === 'invalid_grant' || e.status === 400 || e.status === 401) { wipe(); throw fail(409, 'Microsoft signed you out of mail. Connect again.'); }
|
||||
throw fail(502, 'Could not reach Microsoft: ' + e.message);
|
||||
}
|
||||
})().finally(() => refreshing.delete(uid));
|
||||
refreshing.set(uid, job);
|
||||
return job;
|
||||
}
|
||||
async function graph(p, opts = {}, stale) {
|
||||
const used = await token(stale !== undefined, stale);
|
||||
const r = await fetch(GRAPH + p, { ...opts, headers: { authorization: 'Bearer ' + used, ...(opts.body && typeof opts.body === 'string' ? { 'content-type': 'application/json' } : {}), ...(opts.headers || {}) }, signal: AbortSignal.timeout(30000) });
|
||||
if (r.status === 401 && stale === undefined) return graph(p, opts, used);
|
||||
if (r.status === 204 || r.status === 202) return null;
|
||||
if (opts.raw && r.ok) return r;
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw fail(r.status === 404 ? 404 : r.status === 429 ? 429 : 502, (j.error && j.error.message) || 'Microsoft Graph error ' + r.status);
|
||||
return j;
|
||||
}
|
||||
|
||||
// ---- helpers
|
||||
const ID = /^[A-Za-z0-9_=\-]{1,400}$/;
|
||||
const WELL = { inbox: 'Inbox', drafts: 'Drafts', sentitems: 'Sent', archive: 'Archive', junkemail: 'Junk', deleteditems: 'Deleted' };
|
||||
const id = v => { if (!ID.test(String(v || ''))) throw fail(400, 'Bad id'); return encodeURIComponent(v); };
|
||||
const addr = a => a && a.emailAddress ? { name: a.emailAddress.name || '', address: a.emailAddress.address || '' } : { name: '', address: '' };
|
||||
const row = m => ({ id: m.id, subject: m.subject || '(No subject)', from: addr(m.from), date: m.receivedDateTime, read: !!m.isRead, attach: !!m.hasAttachments, preview: m.bodyPreview || '', flagged: !!(m.flag && m.flag.flagStatus === 'flagged') });
|
||||
const EMAIL = /^[^\s@<>,;]+@[^\s@<>,;]+\.[^\s@<>,;]+$/;
|
||||
const people = (list, label) => { const out = []; for (const x of Array.isArray(list) ? list : String(list || '').split(/[,;]/)) { const a = String(x).trim(); if (!a) continue; const m = /<([^>]+)>/.exec(a), e = (m ? m[1] : a).trim(); if (!EMAIL.test(e)) throw fail(400, `“${a}” is not an email address (${label})`); out.push({ emailAddress: { address: e } }); } return out; };
|
||||
const textToHtml = t => '<div style="white-space:pre-wrap;font-family:sans-serif">' + escHtml(t).replace(/\r?\n/g, '<br>') + '</div>';
|
||||
const safeName = n => String(n || 'attachment').replace(/[\r\n"\\/]+/g, '_').slice(0, 150);
|
||||
|
||||
let folderCache = new Map(); // uid -> { t, v }
|
||||
async function folders() {
|
||||
const uid = users.current().id, c = folderCache.get(uid);
|
||||
if (c && Date.now() - c.t < 15e3) return c.v;
|
||||
const sel = '?$select=id,displayName,unreadItemCount,totalItemCount';
|
||||
const wk = await Promise.all(Object.keys(WELL).map(k => graph(`/me/mailFolders/${k}${sel}`).then(f => ({ id: k, realId: f.id, name: WELL[k], unread: f.unreadItemCount || 0, total: f.totalItemCount || 0, well: true })).catch(() => null)));
|
||||
const known = new Set(wk.filter(Boolean).map(f => f.realId));
|
||||
const rest = (await graph(`/me/mailFolders${sel}&$top=100`).catch(() => ({ value: [] }))).value || [];
|
||||
const v = [...wk.filter(Boolean).map(({ realId, ...f }) => f), ...rest.filter(f => !known.has(f.id) && !['Outbox', 'Conversation History', 'Sync Issues', 'RSS Feeds'].includes(f.displayName)).map(f => ({ id: f.id, name: f.displayName, unread: f.unreadItemCount || 0, total: f.totalItemCount || 0 }))];
|
||||
folderCache.set(uid, { t: Date.now(), v }); return v;
|
||||
}
|
||||
const dropFolderCache = () => folderCache.delete(users.current().id);
|
||||
|
||||
async function withInline(html, attachments, mid) {
|
||||
const inl = (attachments || []).filter(a => a.isInline && a.contentId && /^image\//i.test(a.contentType || '') && (a.size || 0) < 1.5 * 1024 * 1024).slice(0, 15);
|
||||
for (const a of inl) {
|
||||
try {
|
||||
const full = await graph(`/me/messages/${id(mid)}/attachments/${id(a.id)}?$select=contentBytes,contentType`);
|
||||
if (full && full.contentBytes) html = html.split('cid:' + String(a.contentId).replace(/^<|>$/g, '')).join(`data:${full.contentType};base64,${full.contentBytes}`);
|
||||
} catch { /* leave that picture out */ }
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
async function handle(req, res) {
|
||||
const u = new URL(req.url, 'http://x'), p = u.pathname, q = u.searchParams;
|
||||
try {
|
||||
if (p === '/api/mail/status') {
|
||||
const st = load();
|
||||
if (!st) return json(res, 200, { connected: false });
|
||||
let unread = null; try { unread = (await graph('/me/mailFolders/inbox?$select=unreadItemCount')).unreadItemCount; } catch (e) { if (e.status === 409) return json(res, 200, { connected: false, expired: true }); }
|
||||
return json(res, 200, { connected: true, address: st.address, name: st.name, unread });
|
||||
}
|
||||
if (p === '/api/mail/connect' && req.method === 'GET') return json(res, 200, { url: startUrl(req), redirect: redirectUri(req) });
|
||||
if (p === '/api/mail/disconnect' && req.method === 'POST') { wipe(); dropFolderCache(); audit.log('mail disconnected', { who: users.current().name }, req); return json(res, 200, { ok: true }); }
|
||||
if (p === '/api/mail/folders') return json(res, 200, await folders());
|
||||
if (p === '/api/mail/messages') {
|
||||
const folder = id(q.get('folder') || 'inbox'), search = (q.get('q') || '').replace(/["\\]/g, ' ').trim().slice(0, 100), skip = Math.max(0, parseInt(q.get('skip'), 10) || 0);
|
||||
const sel = '$select=id,subject,from,receivedDateTime,isRead,hasAttachments,bodyPreview,flag';
|
||||
const r = await graph(search ? `/me/mailFolders/${folder}/messages?${sel}&$top=50&$search="${encodeURIComponent(search)}"` : `/me/mailFolders/${folder}/messages?${sel}&$top=30&$skip=${skip}&$orderby=receivedDateTime desc`.replace(/ /g, '%20'));
|
||||
const items = (r.value || []).map(row); if (search) items.sort((a, b) => String(b.date).localeCompare(String(a.date)));
|
||||
return json(res, 200, { items, more: !search && !!r['@odata.nextLink'], next: skip + items.length });
|
||||
}
|
||||
if (p === '/api/mail/message') {
|
||||
const mid = q.get('id');
|
||||
const m = await graph(`/me/messages/${id(mid)}?$select=id,subject,from,toRecipients,ccRecipients,receivedDateTime,isRead,body,hasAttachments,flag,parentFolderId&$expand=attachments($select=id,name,contentType,size,isInline,contentId)`);
|
||||
let html = m.body && m.body.contentType === 'html' ? m.body.content || '' : textToHtml((m.body && m.body.content) || '');
|
||||
html = await withInline(html, m.attachments, mid);
|
||||
return json(res, 200, { id: m.id, subject: m.subject || '(No subject)', from: addr(m.from), to: (m.toRecipients || []).map(addr), cc: (m.ccRecipients || []).map(addr), date: m.receivedDateTime, read: !!m.isRead, flagged: !!(m.flag && m.flag.flagStatus === 'flagged'),
|
||||
html, attachments: (m.attachments || []).filter(a => !(a.isInline && a.contentId)).map(a => ({ id: a.id, name: a.name, type: a.contentType, size: a.size })) });
|
||||
}
|
||||
if (p === '/api/mail/attachment') {
|
||||
const mid = q.get('id'), aid = q.get('aid');
|
||||
const meta = await graph(`/me/messages/${id(mid)}/attachments/${id(aid)}?$select=name,contentType,size`);
|
||||
const r = await graph(`/me/messages/${id(mid)}/attachments/${id(aid)}/$value`, { raw: true });
|
||||
res.writeHead(200, { 'content-type': 'application/octet-stream', 'content-disposition': `attachment; filename="${safeName(meta.name)}"; filename*=UTF-8''${encodeURIComponent(safeName(meta.name))}`, 'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff' });
|
||||
return res.end(Buffer.from(await r.arrayBuffer()));
|
||||
}
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' });
|
||||
const b = JSON.parse((await readBody(req)) || '{}');
|
||||
if (p === '/api/mail/update') {
|
||||
const patch = {}; if (typeof b.read === 'boolean') patch.isRead = b.read; if (typeof b.flagged === 'boolean') patch.flag = { flagStatus: b.flagged ? 'flagged' : 'notFlagged' };
|
||||
if (!Object.keys(patch).length) throw fail(400, 'Nothing to change');
|
||||
await graph(`/me/messages/${id(b.id)}`, { method: 'PATCH', body: JSON.stringify(patch) }); dropFolderCache(); return json(res, 200, { ok: true });
|
||||
}
|
||||
if (p === '/api/mail/move') { await graph(`/me/messages/${id(b.id)}/move`, { method: 'POST', body: JSON.stringify({ destinationId: decodeURIComponent(id(b.to)) }) }); dropFolderCache(); return json(res, 200, { ok: true }); }
|
||||
if (p === '/api/mail/delete') {
|
||||
if (b.permanent) await graph(`/me/messages/${id(b.id)}`, { method: 'DELETE' });
|
||||
else await graph(`/me/messages/${id(b.id)}/move`, { method: 'POST', body: JSON.stringify({ destinationId: 'deleteditems' }) });
|
||||
dropFolderCache(); return json(res, 200, { ok: true });
|
||||
}
|
||||
if (p === '/api/mail/send') {
|
||||
const to = people(b.to, 'To'), cc = people(b.cc, 'Cc'), bcc = people(b.bcc, 'Bcc');
|
||||
if (!to.length && !cc.length && !bcc.length) throw fail(400, 'Add at least one recipient');
|
||||
const subject = String(b.subject || '').slice(0, 255), text = String(b.body || ''); if (text.length > 200000) throw fail(400, 'That message is too long');
|
||||
let total = 0; const attachments = (Array.isArray(b.attachments) ? b.attachments : []).slice(0, 10).map(a => { const bytes = Buffer.from(String(a.data || ''), 'base64').length; total += bytes; return { '@odata.type': '#microsoft.graph.fileAttachment', name: safeName(a.name), contentType: String(a.type || 'application/octet-stream').slice(0, 100), contentBytes: String(a.data || '') }; });
|
||||
if (total > MAX_ATTACH) throw fail(400, 'Attachments can total about 3 MB');
|
||||
await graph('/me/sendMail', { method: 'POST', body: JSON.stringify({ message: { subject, body: { contentType: 'HTML', content: textToHtml(text) }, toRecipients: to, ccRecipients: cc, bccRecipients: bcc, attachments }, saveToSentItems: true }) });
|
||||
audit.log('mail sent', { who: users.current().name, detail: `${to.length + cc.length + bcc.length} recipient(s)` }, req); dropFolderCache(); return json(res, 200, { ok: true });
|
||||
}
|
||||
if (p === '/api/mail/reply') {
|
||||
const mode = { reply: 'reply', replyAll: 'replyAll', forward: 'forward' }[b.mode]; if (!mode) throw fail(400, 'Unknown action');
|
||||
const body = { comment: String(b.comment || '').slice(0, 200000) };
|
||||
if (mode === 'forward') { body.toRecipients = people(b.to, 'To'); if (!body.toRecipients.length) throw fail(400, 'Add at least one recipient'); }
|
||||
await graph(`/me/messages/${id(b.id)}/${mode}`, { method: 'POST', body: JSON.stringify(body) });
|
||||
audit.log('mail sent', { who: users.current().name, detail: mode }, req); dropFolderCache(); return json(res, 200, { ok: true });
|
||||
}
|
||||
json(res, 404, { error: 'Not found' });
|
||||
} catch (e) { json(res, e.status || 400, { error: e.message }); }
|
||||
}
|
||||
|
||||
module.exports = { enabled, handle, callback };
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"test": "for t in smoke tasks push users plex player lyrics signal webmail; do node tools/$t-test.js || exit 1; done",
|
||||
"test": "for t in smoke tasks push users plex player lyrics signal mail; do node tools/$t-test.js || exit 1; done",
|
||||
"audit": "npm audit --omit=dev --audit-level=high"
|
||||
},
|
||||
"keywords": [],
|
||||
|
||||
+8
-30
@@ -10,7 +10,7 @@ const $ = s => document.querySelector(s);
|
||||
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
// Preferences are kept in the browser for speed AND saved on the server, so they survive cleared browser data, a different
|
||||
// device, or the separate storage of the iOS home-screen app. Only the music queue stays local.
|
||||
const SYNCED = new Set(['mail_hints', 'mail_on', 'ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links']);
|
||||
const SYNCED = new Set(['ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links']);
|
||||
const outbox = new Map(); let outTimer = null, warned = false;
|
||||
function flushSettings() {
|
||||
outTimer = null;
|
||||
@@ -95,28 +95,7 @@ const ICONS = {
|
||||
};
|
||||
const icon = (n, cls = '') => `<svg class="${cls}" viewBox="0 0 24 24">${ICONS[n]}</svg>`;
|
||||
|
||||
// ---- Email: providers block iframes and need OAuth apps for API access, so we deep-link to webmail.
|
||||
const MAIL = [
|
||||
{ id: 'gmail', name: 'Gmail', color: '#d9453a', inbox: 'https://mail.google.com/mail/u/0/#inbox', compose: 'https://mail.google.com/mail/?view=cm&fs=1' },
|
||||
{ id: 'outlook', name: 'Outlook.com', color: '#0a64c8', inbox: 'https://outlook.live.com/mail/0/inbox', compose: 'https://outlook.live.com/mail/0/deeplink/compose' },
|
||||
{ id: 'm365', name: 'Microsoft 365', color: '#d83b01', inbox: 'https://outlook.office.com/mail/inbox', compose: 'https://outlook.office.com/mail/deeplink/compose' },
|
||||
{ id: 'icloud', name: 'iCloud Mail', color: '#3b97e8', inbox: 'https://www.icloud.com/mail', compose: 'https://www.icloud.com/mail' },
|
||||
];
|
||||
|
||||
// Saved account addresses (browser-only, no passwords) are passed as sign-in hints so the provider opens the right mailbox.
|
||||
const mailHints = () => store.get('mail_hints', {});
|
||||
// Which mail apps this person wants (all of them until they choose).
|
||||
const mailOn = () => { const v = store.get('mail_on', null); return Array.isArray(v) ? v : MAIL.map(m => m.id); };
|
||||
function mailUrl(m, kind) {
|
||||
const base = m[kind], hint = (mailHints()[m.id] || '').trim();
|
||||
if (!hint) return base;
|
||||
const u = new URL(base), q = encodeURIComponent(hint);
|
||||
if (m.id === 'gmail') { if (u.pathname.startsWith('/mail/u/')) u.pathname = '/mail/'; u.searchParams.set('authuser', hint); return u.toString(); }
|
||||
if (m.id === 'outlook' || m.id === 'm365') { u.searchParams.set('login_hint', hint); return u.toString(); }
|
||||
return base; // iCloud has no sign-in hint
|
||||
}
|
||||
|
||||
let cfg = { webmail: '', podcast: 'https://podcast.necloud.us/', verse: 'https://versebyversewithnate.us/admin', audiobooks: 'https://audiobooks.necloud.us', seafile: 'https://seafile.necloud.us', onlyoffice: 'https://onlyoffice.necloud.us' };
|
||||
let cfg = { mail: '', podcast: 'https://podcast.necloud.us/', verse: 'https://versebyversewithnate.us/admin', audiobooks: 'https://audiobooks.necloud.us', seafile: 'https://seafile.necloud.us', onlyoffice: 'https://onlyoffice.necloud.us' };
|
||||
|
||||
// ---- Seafile client (via same-origin proxy)
|
||||
const sf = {
|
||||
@@ -162,13 +141,13 @@ function renderNav(active = activeTab) {
|
||||
if (typeof sgDockBadge === 'function') sgDockBadge();
|
||||
if (typeof sgBadgeEverywhere === 'function') sgBadgeEverywhere();
|
||||
const badge = n => n ? `<i class="nbadge">${n > 99 ? '99+' : n}</i>` : '';
|
||||
const tab = ([id, label]) => `<a href="#/${id}" class="${id === active ? 'on' : ''}" ${id === active ? 'aria-current="page"' : ''}>${icon(TAB_ICON[id])}<span>${label}</span>${id === 'signal' ? badge(sg.unread) : ''}</a>`;
|
||||
const tab = ([id, label]) => `<a href="#/${id}" class="${id === active ? 'on' : ''}" ${id === active ? 'aria-current="page"' : ''}>${icon(TAB_ICON[id])}<span>${label}</span>${id === 'signal' ? badge(sg.unread) : id === 'mail' ? badge(mailUnread) : ''}</a>`;
|
||||
const brand = `<div class="brand"><img src="/icon.svg" alt=""><span>NE Hub</span></div>`;
|
||||
const mine = TABS.filter(t => allowed(t[0]));
|
||||
if (phone.matches) {
|
||||
const rest = mine.filter(t => !PRIMARY.includes(t[0])), more = !PRIMARY.includes(active);
|
||||
$('#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>${rest.some(t => t[0] === 'signal') ? badge(sg.unread) : ''}</a>` : '');
|
||||
(rest.length ? `<a href="#" id="more" class="${more ? 'on' : ''}" role="button">${icon('more')}<span>More</span>${rest.some(t => t[0] === 'signal') ? badge(sg.unread) : ''}${rest.some(t => t[0] === 'mail') ? badge(mailUnread) : ''}</a>` : '');
|
||||
const mo = $('#more'); if (mo) mo.onclick = e => { e.preventDefault(); openMore(); };
|
||||
} 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>`;
|
||||
@@ -179,7 +158,7 @@ phone.addEventListener('change', () => renderNav());
|
||||
|
||||
function openMore() {
|
||||
const el = openSheet('More', '', `<div class="list">${TABS.filter(t => !PRIMARY.includes(t[0]) && allowed(t[0])).map(([id, label]) => `
|
||||
<a class="row" href="#/${id}">${icon(TAB_ICON[id])}<span class="grow"><b>${label}</b></span>${id === 'signal' && sg.unread ? `<i class="nbadge" style="position:static">${sg.unread}</i>` : ''}${icon('chev', 'chev')}</a>`).join('')}</div>`);
|
||||
<a class="row" href="#/${id}">${icon(TAB_ICON[id])}<span class="grow"><b>${label}</b></span>${(id === 'signal' ? sg.unread : id === 'mail' ? mailUnread : 0) ? `<i class="nbadge" style="position:static">${id === 'signal' ? sg.unread : mailUnread}</i>` : ''}${icon('chev', 'chev')}</a>`).join('')}</div>`);
|
||||
el.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet));
|
||||
}
|
||||
|
||||
@@ -263,17 +242,16 @@ function viewHome() {
|
||||
${allowed('verse') && cfg.verse ? tile(cfg.verse, '#0f9d9a', 'book', 'Podcast Admin', 'Opens in a new tab', true) : ''}
|
||||
${allowed('signal') ? tile('#/signal', '#3a76f0', 'chat', 'Signal', sg.unread ? sg.unread + ' unread' : 'Messages') : ''}
|
||||
${allowed('links') ? tile('#/links', '#0f9d9a', 'link', 'Links', (store.get('links', []) || []).length + ' saved') : ''}
|
||||
${allowed('mail') ? tile('#/mail', '#4f7cff', 'mail', 'Mail', mailOn().length + (mailOn().length === 1 ? ' app' : ' apps')) : ''}
|
||||
${allowed('mail') ? tile('#/mail', '#4f7cff', 'mail', 'Mail', mailUnread ? mailUnread + ' unread' : 'Outlook.com') : ''}
|
||||
</div>
|
||||
${allowed('mail') ? `<h2>Mail</h2><div class="grid">
|
||||
${MAIL.map(m => tile(mailUrl(m, 'inbox'), m.color, 'mail', m.name, 'Open inbox', true)).join('')}
|
||||
</div>` : ''}</div>`;
|
||||
${allowed('mail') && cfg.mail ? '<div id="mcard"></div>' : ''}</div>`;
|
||||
renderNpCard(); // the live Now Playing card (it follows the song and has the controls)
|
||||
$('#view').querySelectorAll('button[data-r]').forEach(b => b.onclick = () => {
|
||||
const r = recents[b.dataset.r]; openFile(r.repo, r.path, r.name, r.path.slice(0, r.path.lastIndexOf('/')) || '/');
|
||||
});
|
||||
if ($('#qadd')) { $('#qadd').onclick = () => quickAdd(); $('#qpal').onclick = () => openPalette(); }
|
||||
if (allowed('tasks')) renderTasksCard($('#tcard'));
|
||||
if ($('#mcard')) renderMailCard($('#mcard'));
|
||||
if (allowed('calendar') && typeof renderAgenda === 'function') renderAgenda($('#agenda'), 'today');
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
addEventListener('hashchange', route);
|
||||
restorePlayer();
|
||||
route();
|
||||
mailPoll(); setInterval(mailPoll, 2 * 60e3);
|
||||
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
})();
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
<script src="/sheets.js"></script>
|
||||
<script src="/file-actions.js"></script>
|
||||
<script src="/signal-view.js"></script>
|
||||
<script src="/mail.js"></script>
|
||||
<script src="/boot.js"></script>
|
||||
<script src="/shell.js"></script>
|
||||
</body>
|
||||
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
'use strict';
|
||||
// Mail tab: your Outlook.com mailbox (through the server, see mail.js there). Folders, message list, reader, compose, reply/forward,
|
||||
// search, unread badge. Message bodies are shown in a sandboxed frame with remote pictures blocked until you ask for them.
|
||||
let mailUnread = 0;
|
||||
const mail = { st: null, folders: [], folder: 'inbox', items: [], more: false, next: 0, q: '', msg: null, images: false, loading: false };
|
||||
|
||||
const mailFrom = f => (f && (f.name || f.address)) || '';
|
||||
const mailWhen = iso => {
|
||||
const d = new Date(iso), now = new Date(); if (isNaN(d)) return '';
|
||||
if (d.toDateString() === now.toDateString()) return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||
if (now - d < 6 * 864e5) return d.toLocaleDateString([], { weekday: 'short' });
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: d.getFullYear() === now.getFullYear() ? undefined : '2-digit' });
|
||||
};
|
||||
const mailFolderName = id => (mail.folders.find(f => f.id === id) || {}).name || (id === 'inbox' ? 'Inbox' : 'Folder');
|
||||
|
||||
/** The page a message is shown in: no scripts, nothing loaded from the internet unless the person allows pictures. */
|
||||
function mailDoc(html, images) {
|
||||
const csp = `default-src 'none'; img-src data: ${images ? 'https: http:' : ''}; style-src 'unsafe-inline'; font-src data:`;
|
||||
return `<!doctype html><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${csp}"><base target="_blank"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style>body{margin:14px;font:15px/1.45 -apple-system,Segoe UI,Roboto,sans-serif;color:#111;background:#fff;overflow-wrap:anywhere}img{max-width:100%;height:auto}table{max-width:100%}a{color:#1a5fd1}blockquote{margin:0 0 0 6px;padding-left:10px;border-left:3px solid #ccc;color:#555}</style>${html}`;
|
||||
}
|
||||
const mailHasRemote = html => /<img[^>]+src=["']?https?:|url\(\s*["']?https?:/i.test(html || '');
|
||||
|
||||
// ---- unread badge and Home card
|
||||
async function mailPoll() {
|
||||
if (!(allowed('mail') && cfg.mail)) return;
|
||||
try { const s = await jfetch('/api/mail/status'); mail.st = s; const n = s.connected && s.unread ? s.unread : 0; if (n !== mailUnread) { mailUnread = n; renderNav(); } } catch {}
|
||||
}
|
||||
async function renderMailCard(el) {
|
||||
if (!el) return;
|
||||
try {
|
||||
const s = mail.st || await jfetch('/api/mail/status'); mail.st = s;
|
||||
if (!s.connected || !document.body.contains(el)) return;
|
||||
const r = await jfetch('/api/mail/messages?folder=inbox'); if (!document.body.contains(el)) return;
|
||||
const top = r.items.filter(m => !m.read).slice(0, 3);
|
||||
el.innerHTML = `<h2>Mail${s.unread ? ' · ' + s.unread + ' unread' : ''}</h2><div class="list">${top.length ? top.map(m => `<a class="row" href="#/mail/m/${encodeURIComponent(m.id)}"><span class="ic" style="background:#4f7cff">${icon('mail')}</span><span class="grow"><b>${esc(mailFrom(m.from))}</b><small>${esc(m.subject)}</small></span></a>`).join('') : `<a class="row" href="#/mail"><span class="ic" style="background:#4f7cff">${icon('mail')}</span><span class="grow"><b>Inbox is clear</b><small>${esc(s.address || '')}</small></span></a>`}</div>`;
|
||||
} catch {}
|
||||
}
|
||||
function mailSettings() {
|
||||
const box = $('#mailst'); if (!box) return;
|
||||
jfetch('/api/mail/status').then(s => {
|
||||
if (!$('#mailst')) return;
|
||||
box.innerHTML = s.connected
|
||||
? `<div class="row"><span class="grow"><b>Outlook.com</b><small>${esc(s.address || 'Connected')}</small></span><button class="btn danger" id="mdis" style="min-height:36px">Disconnect</button></div>`
|
||||
: `<div class="row"><span class="grow"><b>Outlook.com</b><small>${s.expired ? 'Microsoft signed you out. ' : ''}Not connected</small></span><a class="btn ghost" href="#/mail" style="min-height:36px">Connect</a></div>`;
|
||||
const d = $('#mdis'); if (d) d.onclick = () => confirmSheet('Disconnect mail?', 'NE Hub forgets your Outlook.com sign-in. Your mailbox is not touched.', 'Disconnect', async () => { try { await jfetch('/api/mail/disconnect', { method: 'POST', body: '{}' }); mail.st = null; mailUnread = 0; renderNav(); mailSettings(); } catch (e) { toast(e.message); } });
|
||||
}).catch(() => { box.innerHTML = '<div class="row"><span class="grow"><small>Could not check</small></span></div>'; });
|
||||
}
|
||||
|
||||
// ---- the tab
|
||||
async function viewMail(args = []) {
|
||||
const v = $('#view');
|
||||
if (!cfg.mail) { v.innerHTML = '<div class="wrap"><h1>Mail</h1><p class="empty">Mail is not set up on this server. An admin can switch it on (see the README, Mail).</p></div>'; return; }
|
||||
v.innerHTML = '<div class="wrap"><h1>Mail</h1><p class="empty">Loading…</p></div>';
|
||||
let st; try { st = await jfetch('/api/mail/status'); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Mail</h1><p class="err">${esc(e.message)}</p></div>`; return; }
|
||||
if (!location.hash.startsWith('#/mail')) return;
|
||||
mail.st = st;
|
||||
if (!st.connected) return mailConnectScreen(st);
|
||||
if (typeof st.unread === 'number' && st.unread !== mailUnread) { mailUnread = st.unread; renderNav(); }
|
||||
const [kind, key] = args;
|
||||
if (kind === 'f' && key) mail.folder = key;
|
||||
if (!mail.folders.length) { try { mail.folders = await jfetch('/api/mail/folders'); } catch {} }
|
||||
mailShell(kind === 'm' && key ? 'read' : 'list');
|
||||
if (!mail.items.length || kind === 'f' || kind === undefined) mailLoadList(true);
|
||||
else mailDrawList();
|
||||
if (kind === 'm' && key) mailOpen(key); else { mail.msg = null; mailDrawReader(); }
|
||||
}
|
||||
|
||||
function mailConnectScreen(st) {
|
||||
$('#view').innerHTML = `<div class="wrap"><h1>Mail</h1>
|
||||
<p class="sub">Read and send mail from your Outlook.com account right here. You sign in on Microsoft's own page; NE Hub never sees your password and only keeps a sign-in token on your server (encrypted).</p>
|
||||
${st.expired ? '<p class="err">Microsoft signed you out of mail. Connect again to continue.</p>' : ''}
|
||||
<div class="bar"><button class="btn" id="mcon">${icon('mail')}Connect Outlook.com</button></div><div class="err" id="mce"></div>
|
||||
<p class="sub">Personal Outlook.com, Hotmail and Live accounts only.</p></div>`;
|
||||
$('#mcon').onclick = async () => {
|
||||
try { const r = await jfetch('/api/mail/connect'); location.href = r.url; }
|
||||
catch (e) { $('#mce').textContent = e.message; }
|
||||
};
|
||||
}
|
||||
|
||||
function mailShell(mode) {
|
||||
const fl = mail.folders.length ? mail.folders : [{ id: 'inbox', name: 'Inbox', unread: mailUnread }];
|
||||
$('#view').innerHTML = `<div class="mailapp" data-mode="${mode}">
|
||||
<aside class="mfold" aria-label="Folders">${fl.map(f => `<a href="#/mail/f/${encodeURIComponent(f.id)}" class="${f.id === mail.folder ? 'on' : ''}"><span class="grow">${esc(f.name)}</span>${f.unread ? `<i class="nbadge" style="position:static">${f.unread}</i>` : ''}</a>`).join('')}</aside>
|
||||
<section class="mlist" aria-label="Messages">
|
||||
<div class="mtool"><select id="mfsel" aria-label="Folder">${fl.map(f => `<option value="${esc(f.id)}" ${f.id === mail.folder ? 'selected' : ''}>${esc(f.name)}${f.unread ? ' (' + f.unread + ')' : ''}</option>`).join('')}</select>
|
||||
<input type="search" id="mq" placeholder="Search mail" value="${esc(mail.q)}" autocomplete="off" aria-label="Search mail">
|
||||
<button class="ibtn" id="mrf" aria-label="Refresh">${icon('refresh')}</button><button class="btn" id="mnew">${icon('edit')}New</button></div>
|
||||
<div id="mrows" class="list"></div><div id="mmore"></div>
|
||||
</section>
|
||||
<section class="mread" id="mread" aria-label="Message"></section></div>`;
|
||||
$('#mfsel').onchange = e => { mail.q = ''; location.hash = '#/mail/f/' + encodeURIComponent(e.target.value); };
|
||||
$('#mnew').onclick = () => mailCompose();
|
||||
$('#mrf').onclick = () => { mailLoadList(true); jfetch('/api/mail/folders').then(f => { mail.folders = f; mailPoll(); }).catch(() => {}); };
|
||||
let t; $('#mq').oninput = e => { clearTimeout(t); t = setTimeout(() => { mail.q = e.target.value.trim(); mailLoadList(true); }, 400); };
|
||||
}
|
||||
|
||||
async function mailLoadList(reset) {
|
||||
if (reset) { mail.items = []; mail.next = 0; mail.more = false; }
|
||||
const rows = $('#mrows'); if (rows && reset) rows.innerHTML = '<p class="empty">Loading…</p>';
|
||||
try {
|
||||
const r = await jfetch(`/api/mail/messages?folder=${encodeURIComponent(mail.folder)}&skip=${mail.next}${mail.q ? '&q=' + encodeURIComponent(mail.q) : ''}`);
|
||||
if (!$('#mrows')) return;
|
||||
mail.items = mail.items.concat(r.items); mail.more = r.more; mail.next = r.next;
|
||||
mailDrawList();
|
||||
} catch (e) { const el = $('#mrows'); if (el) el.innerHTML = `<p class="err" style="padding:12px">${esc(e.message)}</p>`; if (/Connect again/.test(e.message)) { mail.st = null; setTimeout(route, 1200); } }
|
||||
}
|
||||
function mailDrawList() {
|
||||
const rows = $('#mrows'); if (!rows) return;
|
||||
const sel = (location.hash.match(/^#\/mail\/m\/([^/]+)/) || [])[1];
|
||||
rows.innerHTML = mail.items.length ? mail.items.map(m => `<a class="row mrow${m.read ? '' : ' unread'}${sel && decodeURIComponent(sel) === m.id ? ' sel' : ''}" href="#/mail/m/${encodeURIComponent(m.id)}">
|
||||
<span class="grow"><b>${esc(mailFrom(m.from))}</b><span class="msub">${esc(m.subject)}</span><small>${esc(m.preview)}</small></span>
|
||||
<span class="mmeta"><small>${esc(mailWhen(m.date))}</small>${m.flagged ? icon('check') : ''}${m.attach ? icon('clip') : ''}</span></a>`).join('') : `<p class="empty" style="padding:12px">${mail.q ? 'Nothing matches.' : 'No messages here.'}</p>`;
|
||||
const more = $('#mmore'); more.innerHTML = mail.more ? '<div class="bar" style="justify-content:center"><button class="btn ghost" id="mmb">Load more</button></div>' : '';
|
||||
if ($('#mmb')) $('#mmb').onclick = () => mailLoadList(false);
|
||||
}
|
||||
|
||||
async function mailOpen(id) {
|
||||
const rd = $('#mread'); if (rd) rd.innerHTML = '<p class="empty" style="padding:16px">Opening…</p>';
|
||||
mail.images = false;
|
||||
try {
|
||||
const m = await jfetch('/api/mail/message?id=' + encodeURIComponent(id));
|
||||
if (!(location.hash.startsWith('#/mail/m/'))) return;
|
||||
mail.msg = m; mailDrawReader(); mailDrawList();
|
||||
if (!m.read) { // opening a message marks it read
|
||||
jfetch('/api/mail/update', { method: 'POST', body: JSON.stringify({ id, read: true }) }).then(() => {
|
||||
const it = mail.items.find(x => x.id === id); if (it) it.read = true; m.read = true; mailDrawList();
|
||||
mailUnread = Math.max(0, mailUnread - 1); renderNav(); jfetch('/api/mail/folders').then(f => { mail.folders = f; }).catch(() => {});
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch (e) { if ($('#mread')) $('#mread').innerHTML = `<p class="err" style="padding:16px">${esc(e.message)}</p>`; }
|
||||
}
|
||||
|
||||
function mailDrawReader() {
|
||||
const rd = $('#mread'); if (!rd) return;
|
||||
const m = mail.msg;
|
||||
if (!m) { rd.innerHTML = '<p class="empty" style="padding:24px">Choose a message to read it.</p>'; return; }
|
||||
const addr = l => l.map(a => esc(a.name ? `${a.name} <${a.address}>` : a.address)).join(', ');
|
||||
const remote = mailHasRemote(m.html) && !mail.images;
|
||||
rd.innerHTML = `<div class="mrhead"><a class="btn ghost mback" href="#/mail/f/${encodeURIComponent(mail.folder)}">${icon('back')}${esc(mailFolderName(mail.folder))}</a>
|
||||
<h2 class="msubj">${esc(m.subject)}</h2>
|
||||
<p class="mfrom"><b>${esc(mailFrom(m.from))}</b> <small>${esc(m.from.address)}</small></p>
|
||||
<p class="mto"><small>To ${addr(m.to) || 'me'}${m.cc.length ? ' · Cc ' + addr(m.cc) : ''} · ${esc(new Date(m.date).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }))}</small></p>
|
||||
<div class="mact">
|
||||
<button class="btn" data-a="reply">Reply</button><button class="btn ghost" data-a="replyAll">Reply all</button><button class="btn ghost" data-a="forward">Forward</button>
|
||||
<button class="btn ghost" data-a="archive">Archive</button><button class="btn ghost" data-a="unread">Mark unread</button><button class="btn ghost" data-a="flag">${m.flagged ? 'Unflag' : 'Flag'}</button><button class="btn danger" data-a="delete">Delete</button></div>
|
||||
${m.attachments.length ? `<div class="matt">${m.attachments.map(a => `<a class="btn ghost" href="/api/mail/attachment?id=${encodeURIComponent(m.id)}&aid=${encodeURIComponent(a.id)}" download>${icon('clip')}${esc(a.name)} <small>${esc(fmtSize(a.size || 0))}</small></a>`).join('')}</div>` : ''}
|
||||
${remote ? '<div class="mimg">Pictures are hidden to protect your privacy. <button class="btn ghost" id="mshow" style="min-height:32px">Show pictures</button></div>' : ''}</div>
|
||||
<iframe class="mbody" title="Message" sandbox="allow-popups allow-popups-to-escape-sandbox" referrerpolicy="no-referrer" srcdoc="${esc(mailDoc(m.html, mail.images))}"></iframe>`;
|
||||
if ($('#mshow')) $('#mshow').onclick = () => { mail.images = true; mailDrawReader(); };
|
||||
rd.querySelectorAll('[data-a]').forEach(b => b.onclick = () => mailAction(b.dataset.a));
|
||||
}
|
||||
|
||||
async function mailAction(a) {
|
||||
const m = mail.msg; if (!m) return;
|
||||
const post = (p, body) => jfetch('/api/mail/' + p, { method: 'POST', body: JSON.stringify({ id: m.id, ...body }) });
|
||||
const back = () => { mail.items = mail.items.filter(x => x.id !== m.id); mail.msg = null; location.hash = '#/mail/f/' + encodeURIComponent(mail.folder); mailPoll(); jfetch('/api/mail/folders').then(f => { mail.folders = f; }).catch(() => {}); };
|
||||
try {
|
||||
if (a === 'reply' || a === 'replyAll' || a === 'forward') return mailCompose({ mode: a, msg: m });
|
||||
if (a === 'archive') { await post('move', { to: 'archive' }); toast('Archived'); return back(); }
|
||||
if (a === 'unread') { await post('update', { read: false }); const it = mail.items.find(x => x.id === m.id); if (it) it.read = false; toast('Marked unread'); mailUnread++; renderNav(); return back(); }
|
||||
if (a === 'flag') { await post('update', { flagged: !m.flagged }); m.flagged = !m.flagged; const it = mail.items.find(x => x.id === m.id); if (it) it.flagged = m.flagged; mailDrawReader(); return mailDrawList(); }
|
||||
if (a === 'delete') {
|
||||
if (mail.folder === 'deleteditems') return confirmSheet('Delete for good?', m.subject, 'Delete', async () => { try { await post('delete', { permanent: true }); toast('Deleted'); back(); } catch (e) { toast(e.message); } });
|
||||
await post('delete', {}); toast('Moved to Deleted'); return back();
|
||||
}
|
||||
} catch (e) { toast(e.message); }
|
||||
}
|
||||
|
||||
// ---- compose, reply, forward
|
||||
const MAIL_MAX = 3 * 1024 * 1024;
|
||||
function mailCompose(o = {}) {
|
||||
const reply = o.mode, m = o.msg, files = [];
|
||||
const title = reply === 'reply' ? 'Reply' : reply === 'replyAll' ? 'Reply all' : reply === 'forward' ? 'Forward' : 'New message';
|
||||
const el = openSheet(title, reply ? m.subject : '', `<form id="mf">
|
||||
${reply && reply !== 'forward' ? `<p class="sub" style="margin:0">To ${esc(reply === 'reply' ? mailFrom(m.from) : 'everyone on the message')}. The original is quoted below your text automatically.</p>` : `<label for="mto">To</label><input type="text" id="mto" autocomplete="off" autocapitalize="off" inputmode="email" placeholder="name@example.com, another@example.com" required>`}
|
||||
${reply ? '' : `<div id="mccw"><a href="#" id="mcc" style="color:var(--accent);font-size:14px">Add Cc / Bcc</a></div><div id="mcb" hidden><label for="mcc2">Cc</label><input type="text" id="mcc2" autocomplete="off" autocapitalize="off"><label for="mbcc">Bcc</label><input type="text" id="mbcc" autocomplete="off" autocapitalize="off"></div>
|
||||
<label for="msb">Subject</label><input type="text" id="msb" autocomplete="off">`}
|
||||
<label for="mbd">${reply ? 'Your message' : 'Message'}</label><textarea id="mbd" rows="${reply ? 6 : 9}"></textarea>
|
||||
${reply ? '' : `<div class="bar" style="margin:8px 0 0;justify-content:flex-start"><label class="btn ghost" style="cursor:pointer;margin:0">${icon('clip')}Attach<input type="file" id="mfile" multiple hidden></label><small id="mfl" style="color:var(--muted)"></small></div>`}
|
||||
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" id="msend" style="flex:1;justify-content:center">Send</button></div><div class="err" id="mer"></div></form>`);
|
||||
el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet;
|
||||
const cc = el.querySelector('#mcc'); if (cc) cc.onclick = e => { e.preventDefault(); el.querySelector('#mcb').hidden = false; el.querySelector('#mccw').hidden = true; };
|
||||
const fi = el.querySelector('#mfile');
|
||||
const total = () => files.reduce((n, f) => n + f.size, 0);
|
||||
const drawFiles = () => { el.querySelector('#mfl').textContent = files.length ? files.map(f => f.name).join(', ') + ' (' + fmtSize(total()) + ')' : ''; };
|
||||
if (fi) fi.onchange = () => { for (const f of fi.files) files.push(f); fi.value = ''; if (total() > MAIL_MAX) { files.pop(); el.querySelector('#mer').textContent = 'Attachments can total about 3 MB.'; } drawFiles(); };
|
||||
const b64 = f => new Promise((ok, no) => { const r = new FileReader(); r.onload = () => ok(String(r.result).split(',')[1] || ''); r.onerror = () => no(new Error('Could not read ' + f.name)); r.readAsDataURL(f); });
|
||||
el.querySelector('#mf').onsubmit = async e => {
|
||||
e.preventDefault(); const btn = el.querySelector('#msend'); btn.disabled = true; el.querySelector('#mer').textContent = '';
|
||||
try {
|
||||
const body = el.querySelector('#mbd').value;
|
||||
if (reply) await jfetch('/api/mail/reply', { method: 'POST', body: JSON.stringify({ id: m.id, mode: reply, comment: body, to: reply === 'forward' ? el.querySelector('#mto').value : undefined }) });
|
||||
else await jfetch('/api/mail/send', { method: 'POST', body: JSON.stringify({ to: el.querySelector('#mto').value, cc: el.querySelector('#mcc2').value, bcc: el.querySelector('#mbcc').value, subject: el.querySelector('#msb').value, body,
|
||||
attachments: await Promise.all(files.map(async f => ({ name: f.name, type: f.type, data: await b64(f) }))) }) });
|
||||
closeSheet(); toast('Sent');
|
||||
} catch (err) { btn.disabled = false; el.querySelector('#mer').textContent = err.message; }
|
||||
};
|
||||
setTimeout(() => { const f = el.querySelector('#mto') || el.querySelector('#mbd'); if (f) f.focus(); }, 60);
|
||||
}
|
||||
@@ -54,6 +54,7 @@ function openPalette() {
|
||||
const out = TABS.filter(t => allowed(t[0])).map(([id, label]) => ({ label: 'Go to ' + label, hint: 'Page', ic: TAB_ICON[id], run: go('#/' + id), kw: label }));
|
||||
if (allowed('tasks')) out.push({ label: 'New task', hint: 'Create', ic: 'plus', run: () => quickAdd(), kw: 'add todo' });
|
||||
if (allowed('calendar')) out.push({ label: 'New event', hint: 'Create', ic: 'plus', run: () => newEvent(ymd(new Date())), kw: 'add calendar' });
|
||||
if (allowed('mail') && cfg.mail) out.push({ label: 'New email', hint: 'Create', ic: 'mail', run: () => { location.hash = '#/mail'; setTimeout(() => mailCompose(), 300); }, kw: 'compose write message' });
|
||||
if (allowed('links')) out.push({ label: 'New link', hint: 'Create', ic: 'plus', run: () => linkSheet(), kw: 'add bookmark' });
|
||||
out.push({ label: 'Hide or show the menu', hint: 'View', ic: 'menu', run: () => setNav({ hidden: !navPref().hidden }), kw: 'sidebar' });
|
||||
out.push({ label: 'Refresh the app', hint: 'App', ic: 'refresh', run: () => location.reload(), kw: 'reload update' });
|
||||
|
||||
+3
-13
@@ -82,11 +82,7 @@ function viewSettings() {
|
||||
<div class="row" id="row-oo"><span class="grow"><b>OnlyOffice</b><small>${esc(cfg.onlyoffice)}</small></span></div>
|
||||
</div>
|
||||
${signed ? '<div class="bar"><button class="btn danger" id="out">Sign out of Seafile</button></div>' : ''}</div>
|
||||
<div id="sec-mail"><h2>Mail apps</h2><p class="sub">Choose the mail apps you use and add your address for each, so its inbox and compose buttons open the right mailbox. Saved on your server, so every device sees them. Passwords are never saved here.</p>
|
||||
<div class="card" style="max-width:none">${MAIL.map(m => `<div class="macct"><label class="chk" style="margin:${m.id === MAIL[0].id ? 0 : 14}px 0 0"><input type="checkbox" data-on="${m.id}" ${mailOn().includes(m.id) ? 'checked' : ''}><span>${esc(m.name)}</span></label>
|
||||
${m.id === 'icloud' ? '<small class="sub" style="display:block;margin:2px 0 0 32px">iCloud Mail can\'t take a hint; use Keychain autofill or stay signed in.</small>'
|
||||
: `<input type="email" id="h-${m.id}" data-id="${m.id}" placeholder="you@example.com" autocomplete="off" autocapitalize="off" value="${esc(mailHints()[m.id] || '')}" style="margin-top:6px" ${mailOn().includes(m.id) ? '' : 'hidden'}>`}</div>`).join('')}
|
||||
</div>
|
||||
<div id="sec-mail"><h2>Mail</h2><div class="list" id="mailst"><div class="row"><span class="grow"><small>Checking…</small></span></div></div></div>
|
||||
</div><div id="sec-audio"><h2>Audiobooks sign-in</h2><p class="sub">Saved on your server (encrypted) and shared with all your devices. Leave blank to skip. Use the copy button on the Audiobooks tab to paste them into the site's sign-in form.</p>
|
||||
<div class="card" style="max-width:none"><label for="ab-u">Username</label>
|
||||
<input type="text" id="ab-u" autocomplete="off" autocapitalize="off" value="${esc(abCreds().u)}">
|
||||
@@ -99,7 +95,7 @@ function viewSettings() {
|
||||
remindersInit();
|
||||
// only show what this person may use
|
||||
const hide = (cond, sel) => { if (cond) document.querySelectorAll(sel).forEach(e => e.remove()); };
|
||||
hide(!allowed('mail'), '#sec-mail'); hide(!allowed('audio'), '#sec-audio'); hide(!allowed('signal'), '#sec-signal'); hide(!(allowed('files') || allowed('office')), '#row-sf, #out');
|
||||
hide(!(allowed('mail') && cfg.mail), '#sec-mail'); hide(!allowed('audio'), '#sec-audio'); hide(!allowed('signal'), '#sec-signal'); hide(!(allowed('files') || allowed('office')), '#row-sf, #out');
|
||||
hide(!allowed('music'), '#row-plex'); hide(!allowed('office'), '#row-oo');
|
||||
if (!document.querySelector('#sec-conn .list .row')) hide(true, '#sec-conn'); // nothing to show here for this person
|
||||
if ($('#sga-s')) {
|
||||
@@ -115,13 +111,7 @@ function viewSettings() {
|
||||
};
|
||||
$('#sga-t').onclick = () => { sgUnlockAudio(); sgChime(); if (!sgAC || sgAC.state !== 'running') toast('Click anywhere on the page once, then try again (the browser allows sound only after that)'); };
|
||||
}
|
||||
document.querySelectorAll('input[data-on]').forEach(c => c.onchange = () => {
|
||||
const on = [...document.querySelectorAll('input[data-on]:checked')].map(x => x.dataset.on); store.set('mail_on', on);
|
||||
const h = document.getElementById('h-' + c.dataset.on); if (h) h.hidden = !c.checked;
|
||||
});
|
||||
document.querySelectorAll('input[data-id]').forEach(i => i.oninput = () => {
|
||||
const h = mailHints(); const v = i.value.trim(); if (v) h[i.dataset.id] = v; else delete h[i.dataset.id]; store.set('mail_hints', h);
|
||||
});
|
||||
if ($('#mailst')) mailSettings();
|
||||
const saveAb = () => store.set('ab_creds', { u: $('#ab-u').value, p: $('#ab-p').value });
|
||||
if ($('#ab-u')) { $('#ab-u').oninput = saveAb; $('#ab-p').oninput = saveAb; }
|
||||
if (signed) sf.req('/api2/account/info/').then(a => { const el = $('#sfst'); if (el) el.textContent = cfg.seafile + ' · Signed in' + (a && (a.email || a.name) ? ' as ' + (a.email || a.name) : ''); })
|
||||
|
||||
+42
-15
@@ -303,21 +303,6 @@ body:not(.editing) #view { padding-top: 8px; }
|
||||
@media (min-width: 768px) { #navbar { padding-left: 24px; } body:not(.editing) #view { padding-top: 12px; } }
|
||||
|
||||
/* Mail */
|
||||
.mailgrid { display: grid; gap: 14px; grid-template-columns: 1fr; margin-top: 16px; }
|
||||
@media (min-width: 768px) { .mailgrid { grid-template-columns: repeat(2, 1fr); } }
|
||||
.mcard { background: var(--panel); border: 1px solid var(--line); border-radius: 18px; padding: 16px; position: relative; overflow: hidden; display: flex; flex-direction: column; gap: 16px; }
|
||||
.mcard::before { content: ''; position: absolute; left: 0; top: 0; right: 0; height: 4px; background: var(--mc); }
|
||||
.mhd { display: flex; align-items: center; gap: 12px; }
|
||||
.mhd .grow { flex: 1; min-width: 0; }
|
||||
.mhd b, .mhd small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.mhd b { font-size: 17px; }
|
||||
.mhd small { color: var(--muted); }
|
||||
.mbadge { width: 48px; height: 48px; border-radius: 14px; background: var(--mc); color: #fff; display: grid; place-items: center; flex: none; }
|
||||
.mbadge svg { width: 26px; height: 26px; }
|
||||
.mact { display: flex; gap: 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; }
|
||||
@@ -526,3 +511,45 @@ body.lyropen { overflow: hidden; }
|
||||
.tgev b { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tgev small { color: var(--muted); font-size: 11px; }
|
||||
.tgnow { position: absolute; left: 0; right: 0; height: 2px; background: var(--danger); z-index: 2; pointer-events: none; }
|
||||
|
||||
/* Mail */
|
||||
.mailapp { display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; min-height: 70dvh; }
|
||||
.mlist, .mread { min-width: 0; }
|
||||
.mailapp .mfold { display: none; }
|
||||
.mailapp[data-mode="list"] .mread, .mailapp[data-mode="read"] .mlist { display: none; }
|
||||
.mtool { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; flex-wrap: wrap; }
|
||||
.mtool select { width: auto; max-width: 42%; }
|
||||
.mtool input[type=search] { flex: 1; min-width: 120px; margin: 0; }
|
||||
.mrow { align-items: flex-start; }
|
||||
.mrow .grow { min-width: 0; display: flex; flex-direction: column; }
|
||||
.mrow b, .mrow .msub, .mrow small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
|
||||
.mrow .msub { font-size: 14px; }
|
||||
.mrow:not(.unread) b { font-weight: 500; }
|
||||
.mrow:not(.unread) .msub { color: var(--muted); }
|
||||
.mrow.unread b::before { content: ''; display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); margin-right: 6px; }
|
||||
.mrow.sel { background: var(--panel2); }
|
||||
.mmeta { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; flex: none; color: var(--muted); }
|
||||
.mmeta svg { width: 14px; height: 14px; }
|
||||
.mread { min-width: 0; display: flex; flex-direction: column; }
|
||||
.mrhead { padding: 4px 0 8px; }
|
||||
.msubj { margin: 8px 0 4px; font-size: 20px; text-transform: none; letter-spacing: 0; color: var(--text); }
|
||||
.mfrom, .mto { margin: 0 0 2px; }
|
||||
.mact, .matt { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0; }
|
||||
.mact .btn, .matt .btn { min-height: 38px; }
|
||||
.mimg { background: var(--panel2); border-radius: 10px; padding: 8px 12px; font-size: 14px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
.mbody { width: 100%; flex: 1; min-height: 55dvh; border: 1px solid var(--line); border-radius: 12px; background: #fff; }
|
||||
@media (min-width: 1024px) {
|
||||
.mailapp { grid-template-columns: 340px minmax(0, 1fr); height: calc(100dvh - 40px); min-height: 0; }
|
||||
.mailapp[data-mode] .mlist, .mailapp[data-mode] .mread { display: block; }
|
||||
.mailapp[data-mode] .mread { display: flex; }
|
||||
.mlist, .mread { overflow-y: auto; min-height: 0; }
|
||||
.mback { display: none; }
|
||||
.mbody { min-height: 0; }
|
||||
}
|
||||
@media (min-width: 1500px) {
|
||||
.mailapp { grid-template-columns: 190px 340px minmax(0, 1fr); }
|
||||
.mailapp .mfold { display: flex; flex-direction: column; gap: 2px; }
|
||||
.mfold a { display: flex; align-items: center; gap: 8px; padding: 9px 12px; border-radius: 10px; color: var(--muted); }
|
||||
.mfold a.on { background: var(--panel2); color: var(--text); font-weight: 600; }
|
||||
.mtool select { display: none; }
|
||||
}
|
||||
+1
-1
@@ -23,7 +23,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('/webmail') || 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('/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;
|
||||
|
||||
@@ -109,64 +109,6 @@ async function renderTasksCard(el) {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Mail: with WEBMAIL_URL set, your own webmail (which you can allow to be framed) opens inside the app and keeps running between tabs.
|
||||
// The provider shortcuts (Gmail, Outlook, ...) stay one tap away, because those sites refuse to be framed.
|
||||
function viewMail(args = []) {
|
||||
if (cfg.webmail && args[0] === 'webmail') return viewWebmail(); // the shortcuts are the default; the webmail is one tap away
|
||||
return viewMailCards();
|
||||
}
|
||||
function viewWebmail() {
|
||||
const old = $('#audiowrap iframe[data-k="mail"]');
|
||||
$('#view').innerHTML = `<div class="abbar"><span class="grow"><b>Mail</b><small id="abst">${old ? '' : 'Opening…'}</small></span>
|
||||
<a class="ibtn" href="#/mail" aria-label="Mail shortcuts (Gmail, Outlook and others)" title="Mail shortcuts (Gmail, Outlook and others)">${icon('mail')}</a>
|
||||
<button class="ibtn" id="ar" aria-label="Reload" title="Reload">${icon('refresh')}</button>
|
||||
<button class="ibtn" id="af" aria-label="Full screen" title="Full screen">${icon('full')}</button>
|
||||
<a class="ibtn" href="${esc(cfg.webmail)}" target="_blank" rel="noopener" aria-label="Open in new tab" title="Open in new tab">${icon('ext')}</a></div>`;
|
||||
const setSt = t => { const e = $('#abst'); if (e) e.textContent = t; };
|
||||
const open = fresh => {
|
||||
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(''); 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"><h1>Mail</h1>
|
||||
<p class="sub">Each account opens in its own webmail tab. Nothing is stored here.</p>
|
||||
${cfg.webmail ? `<div class="bar" style="margin-bottom:0"><a class="btn" href="#/mail/webmail">${icon('mail')}Open webmail (all accounts in one place)</a></div>` : ''}
|
||||
${shown.length ? '' : '<p class="empty">No mail apps chosen. Pick the ones you use in <a href="#/settings" style="color:var(--accent)">Settings</a>.</p>'}<div class="mailgrid">${shown.map(m => `<div class="mcard" style="--mc:${m.color}">
|
||||
<div class="mhd"><span class="mbadge">${icon('mail')}</span><span class="grow"><b>${esc(m.name)}</b><small>${esc(hints[m.id] || (m.id === 'icloud' ? 'Opens iCloud Mail' : 'No address saved'))}</small></span></div>
|
||||
<div class="mact"><a class="btn" href="${esc(mailUrl(m, 'inbox'))}" target="_blank" rel="noopener">${icon('mail')}Inbox</a>
|
||||
<a class="btn ghost" href="${esc(mailUrl(m, 'compose'))}" target="_blank" rel="noopener">${icon('edit')}Compose</a></div></div>`).join('')}</div>
|
||||
${anyHint || !shown.length ? '' : '<p class="sub" style="margin-top:14px">Tip: save your address for each account in <a href="#/settings" style="color:var(--accent)">Settings</a> so the right mailbox opens.</p>'}</div>`;
|
||||
}
|
||||
|
||||
const officeHash = (repo, path) => '#/office/doc/' + encodeURIComponent(repo) + path.split('/').filter(Boolean).map(x => '/' + encodeURIComponent(x)).join('');
|
||||
|
||||
function viewOffice(args = []) {
|
||||
|
||||
@@ -11,12 +11,6 @@ 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 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
|
||||
@@ -38,6 +32,7 @@ const users = require('./users');
|
||||
const admin = require('./admin');
|
||||
const backup = require('./backup');
|
||||
const push = require('./push');
|
||||
const mail = require('./mail'); // Outlook.com mail, through Microsoft Graph (MS_CLIENT_ID)
|
||||
const lyrics = require('./lyrics'); // lyrics for the music player, through Lyrica (LYRICS_API_URL)
|
||||
const signal = require('./signal'); // Signal messages, through signal-cli-rest-api (SIGNAL_API_URL)
|
||||
const vault = require('./vault')('nehub-plex');
|
||||
@@ -354,46 +349,6 @@ async function plexProxy(req, res) {
|
||||
} catch (e) { if (!res.headersSent) { res.writeHead(502); res.end(); } else res.destroy(); }
|
||||
}
|
||||
|
||||
// ---- 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(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 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, 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, 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 (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/webmail', 'cache-control': 'no-store' }); r.resume(); return res.end();
|
||||
}
|
||||
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 the webmail')));
|
||||
up.on('error', e => {
|
||||
if (res.headersSent) return res.destroy();
|
||||
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>');
|
||||
});
|
||||
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) {
|
||||
@@ -455,7 +410,7 @@ const ACCESS = [
|
||||
[/^\/api\/audiobooks\//, ['audio']],
|
||||
[/^\/api\/signal\//, ['signal']],
|
||||
[/^\/api\/lyrics$/, ['music']],
|
||||
[/^\/(webmail(\/|$)|api\/webmail\/)/, ['mail']],
|
||||
[/^\/api\/mail\//, ['mail']],
|
||||
[/^\/api\/cal\//, ['calendar']],
|
||||
[/^\/api\/tasks/, ['tasks']],
|
||||
[/^\/api\/(search|ddg)$/, ['search']],
|
||||
@@ -486,6 +441,7 @@ async function handleRequest(req, res) {
|
||||
if (pathname === '/healthz') { res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end('{"ok":true}'); }
|
||||
if (crossSite(req) && pathname !== '/api/oo/callback') return forbidden(res, 'Cross-site request refused');
|
||||
if (pathname.startsWith('/api/auth/')) return auth.handle(req, res);
|
||||
if (pathname === '/api/mail/callback' && mail.enabled()) return mail.callback(req, res); // Microsoft sends the person back here; the one-time state says who they are
|
||||
if (pathname.startsWith('/cal/feed/')) return cal.feed(req, res); // subscribe feed: protected by its own secret token, not the session
|
||||
// Everything else needs a passkey session, except the sign-in page and its assets,
|
||||
// and OnlyOffice's save callback (which carries its own signed token).
|
||||
@@ -511,7 +467,7 @@ async function handleRequest(req, res) {
|
||||
const c = t => users.can(user, t);
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
return res.end(JSON.stringify({ seafile: c('files') || c('office') ? SEAFILE_URL : '', onlyoffice: c('office') ? ONLYOFFICE_URL : '', audiobooks: c('audio') ? AUDIOBOOKS_URL : '', verse: c('verse') ? VERSE_URL : '',
|
||||
podcast: c('podcast') ? PODCAST_URL : '', signal: c('signal') && signal.enabled() ? '1' : '', lyrics: c('music') && lyrics.enabled() ? '1' : '', webmail: c('mail') ? (WEBMAIL_URL || (WEBMAIL_BACKEND_URL ? '/webmail/' : '')) : '', editor: (c('office') || c('files')) && OO_OK, jwt: !!JWT_SECRET, ooReady: OO_OK, build: BUILD }));
|
||||
podcast: c('podcast') ? PODCAST_URL : '', signal: c('signal') && signal.enabled() ? '1' : '', lyrics: c('music') && lyrics.enabled() ? '1' : '', mail: c('mail') && mail.enabled() ? '1' : '', editor: (c('office') || c('files')) && OO_OK, jwt: !!JWT_SECRET, ooReady: OO_OK, build: BUILD }));
|
||||
}
|
||||
if (req.method === 'POST' && req.url === '/api/oo/config') return OO_OK ? ooConfig(req, res) : forbidden(res, 'In-app editing needs ONLYOFFICE_JWT_SECRET to be set on the server');
|
||||
if (pathname === '/api/backup') return user.role === 'admin' ? backup.handle(req, res, user.name) : forbidden(res, 'Only an admin can do this');
|
||||
@@ -527,10 +483,9 @@ 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 (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 (pathname === '/api/lyrics') return lyrics.enabled() ? lyrics.handle(req, res) : forbidden(res, 'Lyrics are not set up on this server');
|
||||
if (pathname.startsWith('/api/signal/')) return signal.enabled() ? signal.handle(req, res) : forbidden(res, 'Signal is not set up on this server');
|
||||
if (WEBMAIL_BACKEND_URL && isWebmailPath(pathname)) return proxyWebmail(req, res);
|
||||
if (pathname.startsWith('/api/mail/')) return mail.enabled() ? mail.handle(req, res) : forbidden(res, 'Mail is not set up on this server');
|
||||
if (req.url.startsWith('/api/plex/')) return plexApi(req, res);
|
||||
if (req.url.startsWith('/plex/') && (req.method === 'GET' || (['POST', 'PUT', 'DELETE'].includes(req.method) && PLEX_WRITABLE.test(req.url)))) return plexProxy(req, res);
|
||||
if (req.url.startsWith('/sf/')) return proxySeafile(req, res);
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ const users = require('./users');
|
||||
const file = () => path.join(users.dir(), 'settings.json'); // each person has their own
|
||||
|
||||
// Only these keys are accepted. (The music queue stays in the browser.)
|
||||
const KEYS = new Set(['mail_hints', 'mail_on', 'ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links']);
|
||||
const KEYS = new Set(['ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links']);
|
||||
const SECRET = new Set(['ab_creds', 'sf_token']);
|
||||
const MAX_VALUE = 20000; // bytes of JSON per key
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env node
|
||||
// Mail (Outlook.com through Microsoft Graph): connecting, token refresh, reading, sending, privacy between people.
|
||||
// Uses a fake Microsoft sign-in/token service and a fake Graph. Run: node tools/mail-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 = 'mail-test-secret-'.repeat(3);
|
||||
|
||||
(async () => {
|
||||
// ---- fake Microsoft
|
||||
const ms = { challenge: '', goodTokens: new Set(), refreshCalls: 0, revoked: false, tokenN: 0, calls: [], expiresIn: 3600 };
|
||||
const body = rq => new Promise(r => { const c = []; rq.on('data', d => c.push(d)); rq.on('end', () => r(Buffer.concat(c).toString())); });
|
||||
const out = (rs, code, o, h = {}) => { rs.writeHead(code, { 'content-type': 'application/json', ...h }); rs.end(o === undefined ? '' : JSON.stringify(o)); };
|
||||
const folder = (id, name, unread, total) => ({ id: 'real-' + id, displayName: name, unreadItemCount: unread, totalItemCount: total });
|
||||
const msgs = [
|
||||
{ id: 'M1', subject: 'Hello there', from: { emailAddress: { name: 'Ann', address: 'ann@example.com' } }, receivedDateTime: '2026-10-22T10:00:00Z', isRead: false, hasAttachments: true, bodyPreview: 'Hi…', flag: { flagStatus: 'notFlagged' } },
|
||||
{ id: 'M2', subject: 'Second', from: { emailAddress: { name: 'Bob', address: 'bob@example.com' } }, receivedDateTime: '2026-10-21T10:00:00Z', isRead: true, hasAttachments: false, bodyPreview: 'x', flag: { flagStatus: 'flagged' } },
|
||||
];
|
||||
const server = http.createServer(async (rq, rs) => {
|
||||
const u = new URL(rq.url, 'http://x'), p = u.pathname, b = await body(rq);
|
||||
ms.calls.push(rq.method + ' ' + decodeURIComponent(p + u.search));
|
||||
if (p === '/consumers/oauth2/v2.0/token' && rq.method === 'POST') {
|
||||
const f = new URLSearchParams(b);
|
||||
if (f.get('client_id') !== 'test-client') return out(rs, 400, { error: 'invalid_client' });
|
||||
if (f.get('grant_type') === 'authorization_code') {
|
||||
if (f.get('code') !== 'GOODCODE') return out(rs, 400, { error: 'invalid_grant', error_description: 'bad code' });
|
||||
if (crypto.createHash('sha256').update(f.get('code_verifier') || '').digest('base64url') !== ms.challenge) return out(rs, 400, { error: 'invalid_grant', error_description: 'PKCE failed' });
|
||||
const at = 'AT' + (++ms.tokenN); ms.goodTokens = new Set([at]); return out(rs, 200, { access_token: at, refresh_token: 'RT1', expires_in: ms.expiresIn });
|
||||
}
|
||||
if (f.get('grant_type') === 'refresh_token') {
|
||||
ms.refreshCalls++;
|
||||
if (ms.revoked || !/^RT/.test(f.get('refresh_token'))) return out(rs, 400, { error: 'invalid_grant', error_description: 'revoked' });
|
||||
const at = 'AT' + (++ms.tokenN); ms.goodTokens = new Set([at]); return out(rs, 200, { access_token: at, refresh_token: 'RT' + (ms.tokenN), expires_in: 3600 });
|
||||
}
|
||||
}
|
||||
if (!p.startsWith('/v1.0/')) { rs.writeHead(404); return rs.end(); }
|
||||
const tok = (rq.headers.authorization || '').replace('Bearer ', '');
|
||||
if (!ms.goodTokens.has(tok)) return out(rs, 401, { error: { code: 'InvalidAuthenticationToken', message: 'expired' } });
|
||||
const g = p.slice(5);
|
||||
if (g === '/me') return out(rs, 200, { displayName: 'Nate', mail: 'nate@outlook.com' });
|
||||
let m;
|
||||
if ((m = /^\/me\/mailFolders\/(\w+)$/.exec(g))) { const f = { inbox: folder('inbox', 'Inbox', 3, 10), drafts: folder('drafts', 'Drafts', 0, 1), sentitems: folder('sentitems', 'Sent Items', 0, 4), archive: folder('archive', 'Archive', 0, 0), junkemail: folder('junkemail', 'Junk Email', 0, 0), deleteditems: folder('deleteditems', 'Deleted Items', 0, 2) }[m[1]]; return f ? out(rs, 200, f) : out(rs, 404, { error: { message: 'no folder' } }); }
|
||||
if (g === '/me/mailFolders') return out(rs, 200, { value: [folder('inbox', 'Inbox', 3, 10), { id: 'CUSTOM1', displayName: 'Receipts', unreadItemCount: 1, totalItemCount: 5 }, { id: 'CUSTOM2', displayName: 'Outbox', unreadItemCount: 0, totalItemCount: 0 }] });
|
||||
if ((m = /^\/me\/mailFolders\/([\w=-]+)\/messages$/.exec(g))) { const all = u.searchParams.get('$search') ? msgs.filter(x => x.subject.toLowerCase().includes(u.searchParams.get('$search').replace(/"/g, '').toLowerCase())) : msgs; return out(rs, 200, { value: all, ...(u.searchParams.get('$skip') === '0' && all.length >= 2 ? { '@odata.nextLink': 'x' } : {}) }); }
|
||||
if ((m = /^\/me\/messages\/([\w=-]+)$/.exec(g))) {
|
||||
if (rq.method === 'PATCH') { ms.patched = JSON.parse(b); return out(rs, 200, {}); }
|
||||
if (rq.method === 'DELETE') { ms.deleted = m[1]; return out(rs, 204); }
|
||||
const x = msgs.find(y => y.id === m[1]); if (!x) return out(rs, 404, { error: { message: 'gone' } });
|
||||
return out(rs, 200, { ...x, toRecipients: [{ emailAddress: { name: 'Nate', address: 'nate@outlook.com' } }], ccRecipients: [], body: { contentType: 'html', content: '<p>Hi <img src="cid:logo1"></p><script>alert(1)</script>' },
|
||||
attachments: [{ id: 'A1', name: 'report.pdf', contentType: 'application/pdf', size: 5, isInline: false }, { id: 'A2', name: 'logo.png', contentType: 'image/png', size: 4, isInline: true, contentId: '<logo1>' }] });
|
||||
}
|
||||
if ((m = /^\/me\/messages\/([\w=-]+)\/attachments\/(\w+)$/.exec(g))) return out(rs, 200, m[2] === 'A2' ? { contentBytes: Buffer.from('PNG!').toString('base64'), contentType: 'image/png' } : { name: 'rep"ort\r\n.pdf', contentType: 'application/pdf', size: 5 });
|
||||
if ((m = /^\/me\/messages\/([\w=-]+)\/attachments\/(\w+)\/\$value$/.exec(g))) { rs.writeHead(200, { 'content-type': 'application/pdf' }); return rs.end('%PDF-'); }
|
||||
if ((m = /^\/me\/messages\/([\w=-]+)\/move$/.exec(g))) { ms.moved = [m[1], JSON.parse(b).destinationId]; return out(rs, 201, {}); }
|
||||
if (g === '/me/sendMail') { ms.sent = JSON.parse(b); return out(rs, 202); }
|
||||
if ((m = /^\/me\/messages\/([\w=-]+)\/(reply|replyAll|forward)$/.exec(g))) { ms.replied = [m[1], m[2], JSON.parse(b)]; return out(rs, 202); }
|
||||
rs.writeHead(404); rs.end();
|
||||
});
|
||||
await new Promise(r => server.listen(0, '127.0.0.1', r));
|
||||
const msBase = `http://127.0.0.1:${server.address().port}`;
|
||||
|
||||
// ---- the hub with two people: Nate (admin) and Sam (no Mail tab)
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-mail-')), port = 20000 + Math.floor(Math.random() * 20000), base = `http://localhost:${port}`;
|
||||
fs.writeFileSync(path.join(dir, 'users.json'), JSON.stringify({ users: [
|
||||
{ id: 'unate', name: 'Nate', role: 'admin', tabs: [], handle: 'aGFuZGxl', epoch: 1, disabled: false, created: 1 },
|
||||
{ id: 'usam', name: 'Sam', role: 'user', tabs: ['tasks'], handle: 'aGFuZGxl', epoch: 1, disabled: false, created: 1 },
|
||||
{ id: 'uann', name: 'Ann', role: 'user', tabs: ['mail'], handle: 'aGFuZGxl', epoch: 1, disabled: false, created: 1 }] }));
|
||||
const exp = Date.now() + 864e5, ck = uid => `hub_session=${uid}.1.${exp}.${crypto.createHmac('sha256', SECRET).update(`s${uid}.1.${exp}`).digest('base64url')}`;
|
||||
const child = spawn(process.execPath, ['server.js'], { cwd: root, env: { ...process.env, PORT: String(port), DATA_DIR: dir, COOKIE_SECRET: SECRET, MS_CLIENT_ID: 'test-client', MS_AUTH_URL: msBase, MS_GRAPH_URL: msBase + '/v1.0', PUBLIC_URL: '' } });
|
||||
let log = ''; child.stdout.on('data', d => log += d); child.stderr.on('data', d => log += d);
|
||||
for (let i = 0; i < 60; i++) { try { await fetch(base + '/login'); break; } catch { await wait(100); } }
|
||||
const call = async (uid, p, o = {}) => { const r = await fetch(base + p, { redirect: 'manual', ...o, headers: { ...(uid ? { cookie: ck(uid) } : {}), 'content-type': 'application/json', ...(o.headers || {}) }, body: o.body && typeof o.body !== 'string' ? JSON.stringify(o.body) : o.body }); const t = await r.text(); let j; try { j = JSON.parse(t); } catch { j = t; } return { status: r.status, json: j, headers: r.headers, text: t }; };
|
||||
const post = (uid, p, b) => call(uid, p, { method: 'POST', body: b || {} });
|
||||
|
||||
// ================= access =================
|
||||
let r = await call('uann', '/api/config'); ok('the app is told Mail is set up', r.json.mail === '1', r.json);
|
||||
r = await call('usam', '/api/config'); ok('a person without the Mail tab is not told', r.json.mail === '', r.json);
|
||||
r = await call('usam', '/api/mail/status'); ok('and cannot use mail at all', r.status === 403, r.status);
|
||||
r = await call(null, '/api/mail/status'); ok('nor can someone who is not signed in', r.status === 401, r.status);
|
||||
r = await call('unate', '/api/mail/status'); ok('before connecting: not connected', r.status === 200 && r.json.connected === false, r.json);
|
||||
r = await call('unate', '/api/mail/messages'); ok('reading mail before connecting says to connect (409)', r.status === 409, r);
|
||||
|
||||
// ================= connecting =================
|
||||
r = await call('unate', '/api/mail/connect'); const au = new URL(r.json.url);
|
||||
ok('connect gives a Microsoft sign-in link for personal accounts with PKCE and mail permissions', au.pathname === '/consumers/oauth2/v2.0/authorize' && au.searchParams.get('client_id') === 'test-client' && au.searchParams.get('code_challenge_method') === 'S256' && /Mail\.ReadWrite/.test(au.searchParams.get('scope')) && /Mail\.Send/.test(au.searchParams.get('scope')) && au.searchParams.get('redirect_uri') === base + '/api/mail/callback', r.json);
|
||||
ms.challenge = au.searchParams.get('code_challenge'); const state = au.searchParams.get('state');
|
||||
r = await call(null, `/api/mail/callback?code=GOODCODE&state=WRONG`); ok('a callback with an unknown state is refused', r.status === 400, r.status);
|
||||
r = await call(null, `/api/mail/callback?code=BAD&state=${state}`); ok('a rejected code does not connect anyone', r.status === 400 && /Could not connect/.test(r.text), r.status);
|
||||
r = await call('unate', '/api/mail/connect'); const au2 = new URL(r.json.url); ms.challenge = au2.searchParams.get('code_challenge');
|
||||
r = await call(null, `/api/mail/callback?code=GOODCODE&state=${au2.searchParams.get('state')}`); ok('the real callback connects and sends the person back to the Mail tab', r.status === 302 && r.headers.get('location') === '/#/mail', [r.status, r.headers.get('location')]);
|
||||
r = await call(null, `/api/mail/callback?code=GOODCODE&state=${au2.searchParams.get('state')}`); ok('the sign-in link works only once', r.status === 400, r.status);
|
||||
r = await call('unate', '/api/mail/status'); ok('now connected, with the address and the unread count', r.json.connected && r.json.address === 'nate@outlook.com' && r.json.unread === 3, r.json);
|
||||
const raw = fs.readFileSync(path.join(dir, 'users', 'unate', 'mail.json'), 'utf8'); ok('the tokens are saved encrypted', !/AT1|RT1/.test(raw), raw.slice(0, 80));
|
||||
r = await call('uann', '/api/mail/status'); ok("another person is not connected to Nate's mailbox", r.json.connected === false, r.json);
|
||||
r = await call('uann', '/api/mail/messages'); ok("and cannot read it", r.status === 409, r.status);
|
||||
|
||||
// ================= reading =================
|
||||
r = await call('unate', '/api/mail/folders'); const names = (r.json || []).map(f => f.name);
|
||||
ok('folders: the usual six first with unread counts, then your own; internal ones hidden', names.slice(0, 6).join() === 'Inbox,Drafts,Sent,Archive,Junk,Deleted' && names.includes('Receipts') && !names.includes('Outbox') && r.json[0].unread === 3 && names.filter(n => n === 'Inbox').length === 1, r.json);
|
||||
r = await call('unate', '/api/mail/messages?folder=inbox'); ok('the inbox lists messages, newest first, with who, read state and attachment flag', r.status === 200 && r.json.items.length === 2 && r.json.items[0].from.address === 'ann@example.com' && r.json.items[0].read === false && r.json.items[0].attach === true && r.json.items[1].flagged === true, r.json);
|
||||
ok('and says there may be more', r.json.more === true && r.json.next === 2, r.json);
|
||||
r = await call('unate', '/api/mail/messages?folder=inbox&q=second'); ok('search finds a message', r.json.items.length === 1 && r.json.items[0].id === 'M2', r.json);
|
||||
r = await call('unate', '/api/mail/messages?folder=../me'); ok('a folder name cannot break out of the mail path', r.status === 400, r.status);
|
||||
r = await call('unate', '/api/mail/message?id=M1'); const mm = r.json;
|
||||
ok('a message opens with sender, recipients and subject', r.status === 200 && mm.subject === 'Hello there' && mm.to[0].address === 'nate@outlook.com', mm);
|
||||
ok('pictures that are part of the message are put inline (cid -> data)', /data:image\/png;base64,/.test(mm.html) && !/cid:logo1/.test(mm.html), mm.html);
|
||||
ok('only real attachments are listed (not the inline picture)', mm.attachments.length === 1 && mm.attachments[0].name === 'report.pdf', mm.attachments);
|
||||
r = await call('unate', '/api/mail/attachment?id=M1&aid=A1'); ok('an attachment downloads as a file, with a safe name', r.status === 200 && r.text === '%PDF-' && /attachment; filename="rep_ort__?\.pdf"/.test(r.headers.get('content-disposition')) && !/[\r\n]/.test(r.headers.get('content-disposition')), [r.status, r.headers.get('content-disposition')]);
|
||||
|
||||
// ================= changing things =================
|
||||
await post('unate', '/api/mail/update', { id: 'M1', read: true }); ok('mark as read', ms.patched && ms.patched.isRead === true, ms.patched);
|
||||
await post('unate', '/api/mail/update', { id: 'M1', flagged: true }); ok('flag', ms.patched.flag.flagStatus === 'flagged', ms.patched);
|
||||
await post('unate', '/api/mail/move', { id: 'M1', to: 'archive' }); ok('archive moves to the Archive folder', ms.moved[0] === 'M1' && ms.moved[1] === 'archive', ms.moved);
|
||||
await post('unate', '/api/mail/delete', { id: 'M2' }); ok('delete moves to Deleted Items', ms.moved[0] === 'M2' && ms.moved[1] === 'deleteditems', ms.moved);
|
||||
await post('unate', '/api/mail/delete', { id: 'M2', permanent: true }); ok('and from Deleted Items it is removed for good', ms.deleted === 'M2', ms.deleted);
|
||||
|
||||
// ================= sending =================
|
||||
r = await post('unate', '/api/mail/send', { to: 'bob@example.com, Ann <ann@example.com>', cc: '', subject: 'Hi', body: 'Line 1\n<b>not bold</b>' });
|
||||
ok('send builds the message (several recipients, text turned into safe HTML)', r.status === 200 && ms.sent.message.toRecipients.length === 2 && ms.sent.message.toRecipients[1].emailAddress.address === 'ann@example.com' && /<b>not bold/.test(ms.sent.message.body.content) && ms.sent.saveToSentItems === true, ms.sent);
|
||||
r = await post('unate', '/api/mail/send', { to: 'nobody', subject: 'x', body: 'y' }); ok('a bad address is refused with its name in the message', r.status === 400 && /nobody/.test(r.json.error), r.json);
|
||||
r = await post('unate', '/api/mail/send', { to: '', subject: 'x', body: 'y' }); ok('no recipient is refused', r.status === 400, r.json);
|
||||
r = await post('unate', '/api/mail/send', { to: 'a@b.co', subject: 'x', body: 'y', attachments: [{ name: 'big.bin', type: 'x/y', data: Buffer.alloc(3.5 * 1024 * 1024).toString('base64') }] }); ok('attachments over about 3 MB are refused', r.status === 400 && /3 MB/.test(r.json.error), r.json);
|
||||
r = await post('unate', '/api/mail/reply', { id: 'M1', mode: 'replyAll', comment: 'Thanks' }); ok('reply all goes through the thread', r.status === 200 && ms.replied[1] === 'replyAll' && ms.replied[2].comment === 'Thanks', ms.replied);
|
||||
r = await post('unate', '/api/mail/reply', { id: 'M1', mode: 'forward', comment: 'FYI', to: 'x@y.org' }); ok('forward needs a recipient and sends', r.status === 200 && ms.replied[2].toRecipients[0].emailAddress.address === 'x@y.org', ms.replied);
|
||||
r = await post('unate', '/api/mail/reply', { id: 'M1', mode: 'forward', comment: 'FYI' }); ok('forward with no recipient is refused', r.status === 400, r.json);
|
||||
r = await post('unate', '/api/mail/reply', { id: 'M1', mode: 'bogus' }); ok('an unknown action is refused', r.status === 400, r.json);
|
||||
|
||||
// ================= tokens =================
|
||||
const refreshBefore = ms.refreshCalls;
|
||||
ms.goodTokens = new Set(); // Microsoft no longer accepts the access token (expired early)
|
||||
r = await call('unate', '/api/mail/folders'); ok('an expired access token is refreshed automatically and the request still works', r.status === 200 && ms.refreshCalls === refreshBefore + 1, [r.status, ms.refreshCalls]);
|
||||
ms.revoked = true; ms.goodTokens = new Set();
|
||||
r = await call('unate', '/api/mail/messages'); ok('if Microsoft revokes access, the person is told to connect again (409)', r.status === 409 && /Connect again/.test(r.json.error), r);
|
||||
r = await call('unate', '/api/mail/status'); ok('and shown as not connected', r.json.connected === false, r.json);
|
||||
ok('the dead sign-in is removed from disk', !fs.existsSync(path.join(dir, 'users', 'unate', 'mail.json')));
|
||||
|
||||
// reconnect, then disconnect
|
||||
ms.revoked = false; r = await call('unate', '/api/mail/connect'); const au3 = new URL(r.json.url); ms.challenge = au3.searchParams.get('code_challenge');
|
||||
await call(null, `/api/mail/callback?code=GOODCODE&state=${au3.searchParams.get('state')}`); r = await call('unate', '/api/mail/status'); ok('connecting again works', r.json.connected === true, r.json);
|
||||
r = await post('unate', '/api/mail/disconnect'); r = await call('unate', '/api/mail/status'); ok('disconnect removes the sign-in', r.json.connected === false && !fs.existsSync(path.join(dir, 'users', 'unate', 'mail.json')), r.json);
|
||||
r = await call('unate', '/api/audit'); const evs = (r.json.events || []).map(e => e.event); ok('connecting, sending and disconnecting are in the activity log (without any message text)', ['mail connected', 'mail sent', 'mail disconnected'].every(e => evs.includes(e)) && !JSON.stringify(r.json).includes('not bold'), evs);
|
||||
r = await call('unate', '/webmail/'); ok('the old /webmail/ pass-through is gone', r.status === 404 || r.status === 200 && !/Cypht/i.test(r.text), r.status);
|
||||
ok('the server is still running', child.exitCode === null, log.slice(-300));
|
||||
|
||||
child.kill(); server.close(); fs.rmSync(dir, { recursive: true, force: true });
|
||||
console.log(failed ? `\n${failed} check(s) failed` : '\nAll mail checks passed'); process.exit(failed ? 1 : 0);
|
||||
})().catch(e => { console.error('TEST CRASHED:', e); process.exit(1); });
|
||||
+3
-3
@@ -71,7 +71,7 @@ const T = (title, extra) => ({ title, notes: '', due: '', done: false, ...extra
|
||||
|
||||
await admin.call('/api/tasks', { list: 'local:inbox', fields: T('Admin private task') });
|
||||
await admin.call('/api/cal/event', { cal: 'local', fields: { title: 'Admin private event', location: '', notes: '', allDay: true, start: '2026-11-03', end: '2026-11-03', tz: 'UTC', repeat: 'none' } });
|
||||
await admin.call('/api/settings', { key: 'mail_hints', value: { gmail: 'admin@gmail.com' } }, 'PUT');
|
||||
await admin.call('/api/settings', { key: 'office_loc', value: { gmail: 'admin@gmail.com' } }, 'PUT');
|
||||
|
||||
// ---- add Sam with only Tasks and Calendar
|
||||
r = await admin.call('/api/users', { name: 'Sam', tabs: ['tasks', 'calendar', 'search'] }); const samUser = r.json.user, samCode = r.json.code;
|
||||
@@ -99,13 +99,13 @@ const T = (title, extra) => ({ title, notes: '', due: '', done: false, ...extra
|
||||
r = await sam.call('/api/tasks?list=local:inbox'); ok("Sam starts with an empty task list (the admin's task is not visible)", r.status === 200 && r.json.length === 0, r.json);
|
||||
await sam.call('/api/tasks', { list: 'local:inbox', fields: T('Sam private task') });
|
||||
await sam.call('/api/cal/event', { cal: 'local', fields: { title: 'Sam private event', location: '', notes: '', allDay: true, start: '2026-11-03', end: '2026-11-03', tz: 'UTC', repeat: 'none' } });
|
||||
await sam.call('/api/settings', { key: 'mail_hints', value: { gmail: 'sam@gmail.com' } }, 'PUT');
|
||||
await sam.call('/api/settings', { key: 'office_loc', value: { gmail: 'sam@gmail.com' } }, 'PUT');
|
||||
const q = '/api/cal/events?from=' + Date.parse('2026-11-01T00:00:00Z') + '&to=' + Date.parse('2026-11-07T00:00:00Z') + '&fd=2026-11-01&td=2026-11-07';
|
||||
const at = (await admin.call('/api/tasks?list=local:inbox')).json.map(t => t.title), st = (await sam.call('/api/tasks?list=local:inbox')).json.map(t => t.title);
|
||||
ok('tasks are private to each person', at.join() === 'Admin private task' && st.join() === 'Sam private task', [at, st]);
|
||||
const ae = (await admin.call(q)).json.events.map(e => e.title), se = (await sam.call(q)).json.events.map(e => e.title);
|
||||
ok('NE Hub calendar events are private to each person', ae.join() === 'Admin private event' && se.join() === 'Sam private event', [ae, se]);
|
||||
ok('settings are private to each person', (await admin.call('/api/settings')).json.values.mail_hints.gmail === 'admin@gmail.com' && (await sam.call('/api/settings')).json.values.mail_hints.gmail === 'sam@gmail.com');
|
||||
ok('settings are private to each person', (await admin.call('/api/settings')).json.values.office_loc.gmail === 'admin@gmail.com' && (await sam.call('/api/settings')).json.values.office_loc.gmail === 'sam@gmail.com');
|
||||
const fa = (await admin.call('/api/cal/feed')).json.url, fs_ = (await sam.call('/api/cal/feed')).json.url;
|
||||
const fetchFeed = async u => { const x = await fetch(u.replace(/^https?:\/\/[^/]+/, base)); return { status: x.status, text: await x.text() }; };
|
||||
const ta = await fetchFeed(fa), ts = await fetchFeed(fs_);
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// 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');
|
||||
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('/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, 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 + '; 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 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('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 (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/?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/webmail' && (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 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