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
+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 = []) {
|
||||
|
||||
Reference in new issue
Block a user