From 318006dba3d9ab51244e5b5f27baa071fecfdc3f Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:43:12 -0400 Subject: [PATCH] Mail: account cards, choose which mail apps to show, optional framed webmail - Mail page redesigned as one card per account (inbox + compose) - Settings -> Mail apps: tick the apps you use; saved on the server per person - WEBMAIL_URL shows your own webmail (e.g. Roundcube) inside the Mail tab; provider shortcuts one tap away - README: Roundcube setup and framing headers Co-Authored-By: Claude Sonnet 5.5 --- .env.example | 3 +++ README.md | 20 +++++++++++++-- docker-compose.yml | 1 + public/app.js | 64 ++++++++++++++++++++++++++++++++++------------ public/style.css | 17 ++++++++++++ server.js | 3 ++- settings.js | 2 +- 7 files changed, 89 insertions(+), 21 deletions(-) diff --git a/.env.example b/.env.example index b989e1f..e8508ce 100644 --- a/.env.example +++ b/.env.example @@ -19,3 +19,6 @@ AUDIOBOOKS_LOGIN_LINK= # or per-person signed links (optional): shared secret the audiobooks site uses to verify signed links AUDIOBOOKS_SSO_SECRET= #AUDIOBOOKS_SSO_URL=https://audiobooks.necloud.us/sso + +# Your own webmail to show inside the Mail tab (Roundcube, SnappyMail...). Leave blank to keep the provider shortcuts. See README (Mail). +WEBMAIL_URL= diff --git a/README.md b/README.md index a1a0820..15f9d13 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,24 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke `{u, id, exp, n}` and `sig` is the base64url HMAC-SHA256 of `body`. Without either, the plain site opens and the saved username can be copied. - **Podcast** and **Podcast Admin**: launchers that open `PODCAST_URL` (a home-network address) and `VERSE_URL` in their own tabs. - **Office**: embeds onlyoffice.necloud.us in a frame, with an "open in new tab" fallback if it blocks framing. -- **Mail**: deep links to Gmail, Outlook.com, Microsoft 365 and iCloud webmail (inbox + compose). - Providers forbid framing, and a real unified inbox would need Google/Azure app registrations. +- **Mail**: with `WEBMAIL_URL` set, your own webmail opens inside the app (a frame that keeps running between tabs, with reload, full screen and open-in-new-tab); + a button there opens the Gmail / Outlook.com / Microsoft 365 / iCloud shortcuts. Without it, Mail is those shortcuts (inbox + compose, only for the apps you tick + in Settings -> Mail apps). Gmail, iCloud and Outlook refuse to be framed, so to read all your accounts in the app, run a webmail you control and connect it to them + over IMAP (Gmail and iCloud with an app-specific password). Roundcube example: + + roundcube: + image: roundcube/roundcubemail:latest-apache + environment: + ROUNDCUBEMAIL_DEFAULT_HOST: ssl://imap.gmail.com # or imap.mail.me.com, etc. + ROUNDCUBEMAIL_DEFAULT_PORT: 993 + ROUNDCUBEMAIL_SMTP_SERVER: ssl://smtp.gmail.com + ROUNDCUBEMAIL_SMTP_PORT: 465 + ROUNDCUBEMAIL_PLUGINS: archive,zipdownload + volumes: [ ./roundcube:/var/roundcube/config ] # a file here can add the lines below + + Put this in a config file there: `$config['x_frame_options'] = false;` `$config['imap_host'] = ['ssl://imap.gmail.com:993' => 'Gmail', 'ssl://imap.mail.me.com:993' => 'iCloud'];` + and have the reverse proxy for your webmail send `Content-Security-Policy: frame-ancestors https://` (and no `X-Frame-Options`), so only the hub can frame it. + Keep the webmail on the same site as the hub (for example mail.necloud.us and hub.necloud.us) so its sign-in cookie works inside the frame. ## Install on iPhone / iPad (PWA) diff --git a/docker-compose.yml b/docker-compose.yml index ad5e108..54de8b4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,7 @@ 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:-} PODCAST_URL: ${PODCAST_URL:-https://podcast.necloud.us/} ONLYOFFICE_JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:-} # same value as the OnlyOffice server's JWT secret PUBLIC_URL: ${PUBLIC_URL:-} # e.g. https://hub.necloud.us (OnlyOffice posts saved edits here) diff --git a/public/app.js b/public/app.js index 47d7d14..3ddabd5 100644 --- a/public/app.js +++ b/public/app.js @@ -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', 'ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents']); +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']); const outbox = new Map(); let outTimer = null, warned = false; function flushSettings() { outTimer = null; @@ -96,6 +96,8 @@ const 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; @@ -105,7 +107,7 @@ function mailUrl(m, kind) { return base; // iCloud has no sign-in hint } -let cfg = { 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 = { 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' }; // ---- Seafile client (via same-origin proxy) const sf = { @@ -702,17 +704,40 @@ async function renderTasksCard(el) { } catch {} } -function viewMail() { - $('#view').innerHTML = `

Mail

-

Opens each provider's own webmail in a new tab. Nothing is stored here.

-

Inboxes

-

Compose

`; +// 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] !== 'accounts') return viewWebmail(); + return viewMailCards(); +} +function viewWebmail() { + const old = $('#audiowrap iframe[data-k="mail"]'); + $('#view').innerHTML = `
Mail${old ? '' : 'Opening…'} + ${icon('mail')} + + + ${icon('ext')}
`; + 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(''); }); + 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'); }; +} +function viewMailCards() { + const hints = mailHints(), on = mailOn(), shown = MAIL.filter(m => on.includes(m.id)), anyHint = shown.some(m => hints[m.id]); + $('#view').innerHTML = `
${cfg.webmail ? `` : ''}

Mail

+

Each account opens in its own webmail tab. Nothing is stored here.

+ ${shown.length ? '' : '

No mail apps chosen. Pick the ones you use in Settings.

'}
${shown.map(m => `
+
${icon('mail')}${esc(m.name)}${esc(hints[m.id] || (m.id === 'icloud' ? 'Opens iCloud Mail' : 'No address saved'))}
+
`).join('')}
+ ${anyHint || !shown.length ? '' : '

Tip: save your address for each account in Settings so the right mailbox opens.

'}
`; } const officeHash = (repo, path) => '#/office/doc/' + encodeURIComponent(repo) + path.split('/').filter(Boolean).map(x => '/' + encodeURIComponent(x)).join(''); @@ -1390,10 +1415,11 @@ function viewSettings() {
OnlyOffice${esc(cfg.onlyoffice)}
${signed ? '
' : ''} -

Mail accounts

Your address for each account, so its inbox and new-message buttons open the right mailbox. Saved on your server, so every device sees them. Passwords are never saved here.

-
${MAIL.filter(m => m.id !== 'icloud').map(m => ` - `).join('')} -

iCloud Mail can't take a hint; use Keychain autofill or stay signed in.

+

Mail apps

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.

+
${MAIL.map(m => `
+ ${m.id === 'icloud' ? 'iCloud Mail can\'t take a hint; use Keychain autofill or stay signed in.' + : ``}
`).join('')} +

Audiobooks sign-in

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.

@@ -1405,6 +1431,10 @@ function viewSettings() { hide(!allowed('mail'), '#sec-mail'); hide(!allowed('audio'), '#sec-audio'); 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 + 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); }); diff --git a/public/style.css b/public/style.css index 337a94e..b6be187 100644 --- a/public/style.css +++ b/public/style.css @@ -301,3 +301,20 @@ input, textarea, [contenteditable], .sheet p, .card p, #view .sub { -webkit-user #navbar #nvt { color: var(--muted); font-size: 13px; margin-left: 6px; } 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; } diff --git a/server.js b/server.js index c6eb312..4ad0382 100644 --- a/server.js +++ b/server.js @@ -10,6 +10,7 @@ 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(/\/$/, ''); +const WEBMAIL_URL = (process.env.WEBMAIL_URL || '').replace(/\/$/, ''); // your own webmail (Roundcube, SnappyMail...) shown inside the Mail tab const AUDIOBOOKS_SSO_URL = process.env.AUDIOBOOKS_SSO_URL || AUDIOBOOKS_URL + '/sso'; const AUDIOBOOKS_SSO_SECRET = process.env.AUDIOBOOKS_SSO_SECRET || ''; // 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 @@ -430,7 +431,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 : '', editor: c('office') || c('files'), jwt: !!JWT_SECRET, build: BUILD })); + podcast: c('podcast') ? PODCAST_URL : '', webmail: c('mail') ? WEBMAIL_URL : '', editor: c('office') || c('files'), jwt: !!JWT_SECRET, build: BUILD })); } if (req.method === 'POST' && req.url === '/api/oo/config') return ooConfig(req, res); if (pathname === '/api/ddg') return ddgAnswer(req, res); diff --git a/settings.js b/settings.js index 5c31f0d..df76cfb 100644 --- a/settings.js +++ b/settings.js @@ -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', 'ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents']); +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']); const SECRET = new Set(['ab_creds', 'sf_token']); const MAX_VALUE = 20000; // bytes of JSON per key