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:
nmemmertandClaude Sonnet 5.5 committed 2026-10-22 08:09:11 -04:00
1 parent 1564941ea5
commit a9c277b497
23 files changed
+637 -405

No files matched your search

+5 -50
View File
@@ -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, '&amp;').replace(/"/g, '&quot;');
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
r.resume(); return res.end(`<!doctype html><meta charset=utf-8><meta name=viewport content="width=device-width"><body style="font:16px system-ui;padding:24px;color:#888"><p>Opening the sign-in page… <a href="${a}" target="_top">Continue</a></p><script>try{top.location.href=${j}}catch(e){}</script>`);
}
if (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);