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 <noreply@anthropic.com>
This commit is contained in:
1 parent
de1430928a
commit
318006dba3
7 files changed
+89
-21
No files matched your search
@@ -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=
|
||||
@@ -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://<your hub address>` (and no `X-Frame-Options`), so only the hub can frame it.
|
||||
Keep the webmail on the same site as the hub (for example mail.necloud.us and hub.necloud.us) so its sign-in cookie works inside the frame.
|
||||
|
||||
## Install on iPhone / iPad (PWA)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
+47
-17
@@ -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 = `<div class="wrap"><h1>Mail</h1>
|
||||
<p class="sub">Opens each provider's own webmail in a new tab. Nothing is stored here.</p>
|
||||
<h2>Inboxes</h2><div class="list">${MAIL.map(m => `
|
||||
<a class="row" href="${esc(mailUrl(m, 'inbox'))}" target="_blank" rel="noopener">
|
||||
<span class="ic" style="background:${m.color}">${icon('mail')}</span>
|
||||
<span class="grow"><b>${esc(m.name)}</b><small>Inbox</small></span>${icon('chev', 'chev')}</a>`).join('')}</div>
|
||||
<h2>Compose</h2><div class="list">${MAIL.map(m => `
|
||||
<a class="row" href="${esc(mailUrl(m, 'compose'))}" target="_blank" rel="noopener">
|
||||
<span class="ic" style="background:${m.color}">${icon('edit')}</span>
|
||||
<span class="grow"><b>New message</b><small>from ${esc(m.name)}</small></span>${icon('chev', 'chev')}</a>`).join('')}</div></div>`;
|
||||
// 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 = `<div class="abbar"><span class="grow"><b>Mail</b><small id="abst">${old ? '' : 'Opening…'}</small></span>
|
||||
<a class="ibtn" href="#/mail/accounts" aria-label="Gmail, Outlook and other shortcuts" title="Gmail, Outlook and other shortcuts">${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(''); });
|
||||
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 = `<div class="wrap">${cfg.webmail ? `<div class="bar" style="margin-top:0"><a class="btn ghost" href="#/mail">${icon('back')}Webmail</a></div>` : ''}<h1>Mail</h1>
|
||||
<p class="sub">Each account opens in its own webmail tab. Nothing is stored here.</p>
|
||||
${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('');
|
||||
@@ -1390,10 +1415,11 @@ 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 accounts</h2><p class="sub">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.</p>
|
||||
<div class="card" style="max-width:none">${MAIL.filter(m => m.id !== 'icloud').map(m => `<label for="h-${m.id}">${esc(m.name)}</label>
|
||||
<input type="email" id="h-${m.id}" data-id="${m.id}" placeholder="you@example.com" autocomplete="off" autocapitalize="off" value="${esc(mailHints()[m.id] || '')}">`).join('')}
|
||||
<p class="sub" style="margin-top:12px">iCloud Mail can't take a hint; use Keychain autofill or stay signed in.</p></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><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)}">
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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; }
|
||||
@@ -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);
|
||||
|
||||
+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', '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
|
||||
|
||||
|
||||
Reference in new issue
Block a user