diff --git a/mail-imap.js b/mail-imap.js index 7875cf4..dad0ad8 100644 --- a/mail-imap.js +++ b/mail-imap.js @@ -41,27 +41,28 @@ function friendly(a, e) { // ---- connections: one per account, kept for a minute after the last use const IDLE = 5 * 60e3; // a connection is kept this long after its last use: opening one (TLS and login) is the slowest part of a request const pool = new Map(); // account id -> { client, timer, key } -const forget = id => { folderCache.delete(id); boxCache.delete(id); const e = pool.get(id); if (e) { clearTimeout(e.timer); try { e.client.close(); } catch {} pool.delete(id); } }; +const drop = id => { const e = pool.get(id); if (e) { clearTimeout(e.timer); try { e.client.close(); } catch {} pool.delete(id); } }; +const forget = id => { folderCache.delete(id); boxCache.delete(id); drop(id); drop(id + ':bg'); }; const keyOf = a => a.address + '\n' + a.pass; -async function open(a) { - const e = pool.get(a.id); - if (e && e.key === keyOf(a) && e.client.usable) { clearTimeout(e.timer); e.timer = setTimeout(() => forget(a.id), IDLE).unref(); return e.client; } - forget(a.id); +async function open(a, slot) { // slot 'bg': a second connection for counts and folder lists, so they never queue in front of opening or deleting a message + const pid = slot ? a.id + ':' + slot : a.id, e = pool.get(pid); + if (e && e.key === keyOf(a) && e.client.usable) { clearTimeout(e.timer); e.timer = setTimeout(() => drop(pid), IDLE).unref(); return e.client; } + drop(pid); const c = cfgOf(a); const client = new ImapFlow({ host: c.host, port: c.port, secure: c.secure, ...(c.test ? { doSTARTTLS: false, tls: { rejectUnauthorized: false } } : {}), auth: { user: a.address, pass: a.pass }, logger: false, emitLogs: false, socketTimeout: 60e3, greetingTimeout: 20e3, connectionTimeout: 20e3 }); - client.on('error', () => {}); client.on('close', () => { const cur = pool.get(a.id); if (cur && cur.client === client) pool.delete(a.id); }); + client.on('error', () => {}); client.on('close', () => { const cur = pool.get(pid); if (cur && cur.client === client) pool.delete(a.id); }); try { await client.connect(); } catch (err) { throw fail(err.authenticationFailed ? 401 : 502, friendly(a, err)); } - pool.set(a.id, { client, key: keyOf(a), timer: setTimeout(() => forget(a.id), IDLE).unref() }); + pool.set(pid, { client, key: keyOf(a), timer: setTimeout(() => drop(pid), IDLE).unref() }); return client; } /** Run `fn(client)`; if the connection had gone stale, reconnect once. */ -async function withClient(a, fn) { +async function withClient(a, fn, slot) { for (let attempt = 0; ; attempt++) { - const client = await open(a); + const client = await open(a, slot); try { return await fn(client); } catch (err) { if (err.status) throw err; - if (attempt === 0 && (!client.usable || /closed|ECONNRESET|socket|EPIPE|Connection not available/i.test(err.message || ''))) { forget(a.id); continue; } + if (attempt === 0 && (!client.usable || /closed|ECONNRESET|socket|EPIPE|Connection not available/i.test(err.message || ''))) { drop(slot ? a.id + ':' + slot : a.id); continue; } throw fail(502, friendly(a, err)); } } @@ -105,7 +106,7 @@ async function folders(a) { const rows = keep.map(b => { const k = byPath[b.path], st = counts.get(b.path) || {}; return { id: k || 'p-' + Buffer.from(b.path).toString('base64url'), name: k ? NAMES[k] : b.path.replace(/^\[.*?\]\//, ''), unread: st.unseen || 0, total: st.messages || 0, well: !!k }; }); const order = Object.keys(NAMES); return rows.sort((x, y) => (x.well ? order.indexOf(x.id) : 99) - (y.well ? order.indexOf(y.id) : 99) || x.name.localeCompare(y.name)); - }); + }, 'bg'); folderCache.set(a.id, { t: Date.now(), v }); return v; } @@ -141,12 +142,10 @@ async function source(a, mid) { return withClient(a, async client => { const lock = await client.getMailboxLock(path); try { - const dl = await client.download(String(uid), undefined, { uid: true }); - if (!dl || !dl.content) throw fail(404, 'That message is gone'); - const chunks = []; let n = 0; - for await (const ch of dl.content) { n += ch.length; if (n > MAX_SOURCE) throw fail(400, 'That message is too large to open here'); chunks.push(ch); } - const meta = await client.fetchOne(String(uid), { flags: true }, { uid: true }).catch(() => null); - return { parsed: await simpleParser(Buffer.concat(chunks)), path, uid, flags: meta ? meta.flags : new Set() }; + const m = await client.fetchOne(String(uid), { source: { maxLength: MAX_SOURCE + 1 }, flags: true }, { uid: true }); // (one round trip for the body and the flags) + if (!m || !m.source) throw fail(404, 'That message is gone'); + if (m.source.length > MAX_SOURCE) throw fail(400, 'That message is too large to open here'); + return { parsed: await simpleParser(m.source), path, uid, flags: m.flags || new Set() }; } finally { lock.release(); } }); } @@ -193,7 +192,7 @@ async function del(a, mid, permanent) { } /** Counts shown in the folder list are kept 30 s; reading, moving, deleting or sending changes them, so the next request must ask again. */ const dropFolders = id => { if (id) folderCache.delete(id); else folderCache.clear(); }; -async function unread(a) { return withClient(a, async client => (await client.status('INBOX', { unseen: true })).unseen || 0); } +async function unread(a) { return withClient(a, async client => (await client.status('INBOX', { unseen: true })).unseen || 0, 'bg'); } // ---- sending function transport(a) { diff --git a/public/mail.js b/public/mail.js index 75739d6..e704150 100644 --- a/public/mail.js +++ b/public/mail.js @@ -16,7 +16,8 @@ function mcPut(k, v) { localStorage.setItem(ix, JSON.stringify(list)); } catch { /* storage full or blocked: go without */ } } -function mcClear() { try { for (const k of Object.keys(localStorage)) if (k.startsWith('mc:' + (ME ? ME.id : '') + ':')) localStorage.removeItem(k); } catch {} } +function mcClear() { if (typeof mbClear === 'function') mbClear(); + try { for (const k of Object.keys(localStorage)) if (k.startsWith('mc:' + (ME ? ME.id : '') + ':')) localStorage.removeItem(k); } catch {} } const mailFrom = f => (f && (f.name || f.address)) || ''; const mailWhen = iso => { const d = new Date(iso), now = new Date(); if (isNaN(d)) return ''; @@ -234,7 +235,7 @@ async function mailLoadList(reset) { if (!$('#mrows') || mail.folder !== folder) return; mail.items = showing ? mailApplyMine(r.items) : mail.items.concat(mailApplyMine(r.items)); mail.more = r.more; mail.next = r.next; if (first) mcPut('l:' + folder, { items: r.items, more: r.more, next: r.next }); - mailDrawList(); + mailDrawList(); mailPrefetch(); } catch (e) { if (showing) return; const el = $('#mrows'); if (el) el.innerHTML = `

${esc(e.message)}

`; if (/Connect again/.test(e.message)) { mail.st = null; mcClear(); setTimeout(route, 1200); } } } function mailMarkSel(id) { document.querySelectorAll('a.mrow').forEach(a => a.classList.toggle('sel', !!id && decodeURIComponent(a.getAttribute('href').slice(9)) === id)); } @@ -248,8 +249,34 @@ function mailDrawList() { if ($('#mmb')) $('#mmb').onclick = () => mailLoadList(false); } +// Opened messages are also kept on this device (IndexedDB, the newest 80), so one you have read opens at once, even offline. A message's text never +// changes, so only its read/flagged state is taken fresh (from the list). Cleared with the rest of the saved mail when you disconnect or sign out. +const mbDB = () => mbDB.p || (mbDB.p = new Promise(ok => { try { const r = indexedDB.open('hub-mail', 1); r.onupgradeneeded = () => r.result.createObjectStore('b'); r.onsuccess = () => ok(r.result); r.onerror = () => ok(null); } catch { ok(null); } })); +const mbKey = id => (ME ? ME.id : '') + ':' + id; +async function mbGet(id) { const db = await mbDB(); if (!db) return null; return new Promise(ok => { try { const r = db.transaction('b').objectStore('b').get(mbKey(id)); r.onsuccess = () => ok(r.result && r.result.m || null); r.onerror = () => ok(null); } catch { ok(null); } }); } +async function mbPut(id, m) { + const db = await mbDB(); if (!db || JSON.stringify(m).length > 1.5e6) return; + try { + const st = db.transaction('b', 'readwrite').objectStore('b'); st.put({ t: Date.now(), m }, mbKey(id)); + const all = st.getAll(), keys = st.getAllKeys(); + keys.onsuccess = () => { const rows = keys.result.map((k, i) => [k, all.result[i].t]).sort((x, y) => y[1] - x[1]); for (const [k] of rows.slice(80)) st.delete(k); }; // (all was asked first, so it is done when keys is) + } catch {} +} +async function mbClear() { const db = await mbDB(); if (db) try { db.transaction('b', 'readwrite').objectStore('b').clear(); } catch {} } const mailCache = new Map(); // message id -> { t, p: promise }: a message you point at or tap is fetched once and kept for a minute -const mailFetch = id => { const c = mailCache.get(id); if (c && Date.now() - c.t < 60e3) return c.p; const p = jfetch('/api/mail/message?id=' + encodeURIComponent(id)); mailCache.set(id, { t: Date.now(), p }); p.catch(() => mailCache.delete(id)); if (mailCache.size > 30) mailCache.delete(mailCache.keys().next().value); return p; }; +const mailFetch = id => { + const c = mailCache.get(id); if (c && Date.now() - c.t < 60e3) return c.p; + const p = mbGet(id).then(saved => { + if (saved) { const it = mail.items.find(x => x.id === id); return it ? { ...saved, read: it.read, flagged: it.flagged } : saved; } // (opens at once) + return jfetch('/api/mail/message?id=' + encodeURIComponent(id)).then(m => { mbPut(id, m); return m; }); + }); + mailCache.set(id, { t: Date.now(), p }); p.catch(() => mailCache.delete(id)); if (mailCache.size > 30) mailCache.delete(mailCache.keys().next().value); return p; +}; +let mailPre = 0; // fetches the newest unread-or-recent messages of the open list one at a time, so they are already here when tapped +async function mailPrefetch() { + const run = ++mailPre, ids = mail.items.slice(0, 10).map(x => x.id); + for (const id of ids) { if (run !== mailPre || !location.hash.startsWith('#/mail')) return; try { if (!(await mbGet(id))) await mailFetch(id); } catch {} } +} document.addEventListener('pointerover', e => { const a = e.target.closest && e.target.closest('a.mrow'); if (a && a.getAttribute('href').startsWith('#/mail/m/')) mailFetch(decodeURIComponent(a.getAttribute('href').slice(9))).catch(() => {}); }, { passive: true }); document.addEventListener('touchstart', e => { const a = e.target.closest && e.target.closest('a.mrow'); if (a && a.getAttribute('href').startsWith('#/mail/m/')) mailFetch(decodeURIComponent(a.getAttribute('href').slice(9))).catch(() => {}); }, { passive: true }); // What this device has just done to a message (read, unread), kept for a minute: a list that was already on its way from the server @@ -307,15 +334,23 @@ function mailDrawReader() { 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 = keep => { if (!keep) { mail.items = mail.items.filter(x => x.id !== m.id); const sv = mcGet('l:' + mail.folder); if (sv && sv.items) mcPut('l:' + mail.folder, { items: sv.items.filter(x => x.id !== m.id), more: sv.more, next: sv.next }); } mail.msg = null; location.hash = '#/mail/f/' + encodeURIComponent(mail.folder); mailPoll(); jfetch('/api/mail/folders?acct=' + encodeURIComponent(mail.acct)).then(f => { mail.folders = f; }).catch(() => {}); }; + const refresh = () => { mailPoll(); jfetch('/api/mail/folders?acct=' + encodeURIComponent(mail.acct)).then(f => { mail.folders = f; mailRefreshFolders(); }).catch(() => {}); }; + const gone = () => { mail.items = mail.items.filter(x => x.id !== m.id); const sv = mcGet('l:' + mail.folder); if (sv && sv.items) mcPut('l:' + mail.folder, { items: sv.items.filter(x => x.id !== m.id), more: sv.more, next: sv.next }); }; + const back = keep => { if (!keep) gone(); mail.msg = null; location.hash = '#/mail/f/' + encodeURIComponent(mail.folder); refresh(); }; + // Archive and delete leave at once and finish in the background; if the server refuses, the message comes back + const away = (p, body, said) => { + const had = mail.items.find(x => x.id === m.id), at = mail.items.findIndex(x => x.id === m.id); + toast(said); back(); + post(p, body).catch(e => { toast(e.message); if (had) { mail.items.splice(Math.min(at < 0 ? 0 : at, mail.items.length), 0, had); mailDrawList(); } refresh(); }); + }; 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 === 'archive') return away('move', { to: 'archive' }, 'Archived'); if (a === 'unread') { await post('update', { read: false }); m.read = false; mailSetRead(m.id, false); toast('Marked unread'); if (mailInInbox()) { mailUnread++; renderNav(); } return back(true); } 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 (/(^|~)deleteditems$/.test(mail.folder)) 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(); + if (/(^|~)deleteditems$/.test(mail.folder)) return confirmSheet('Delete for good?', m.subject, 'Delete', () => away('delete', { permanent: true }, 'Deleted')); + return away('delete', {}, 'Moved to Deleted'); } } catch (e) { toast(e.message); } } diff --git a/public/style.css b/public/style.css index d6bfb5b..8ec4244 100644 --- a/public/style.css +++ b/public/style.css @@ -452,8 +452,8 @@ body.lyropen { overflow: hidden; } .ll.on { opacity: 1; transform: scale(1.04); } .lgap { height: 24px; } .ll .lt { color: inherit; } -.ll.on .lt { background: linear-gradient(90deg, var(--accent) calc(var(--p, 0) * 100%), var(--text) calc(var(--p, 0) * 100%)); -webkit-background-clip: text; background-clip: text; color: transparent; } -.ll.on .lt:has(.w) { background: none; color: inherit; } +.ll.on .lt { color: var(--accent); } /* (a line-synced song shows the whole current line; only real word timing sweeps across words) */ +.ll.on .lt:has(.w) { color: inherit; } .ll .w { color: inherit; } .ll.on .w { background: linear-gradient(90deg, var(--accent) calc(var(--p, 0) * 100%), var(--text) calc(var(--p, 0) * 100%)); -webkit-background-clip: text; background-clip: text; color: transparent; white-space: pre-wrap; } .ll.on .w.sung { background: none; color: var(--accent); } diff --git a/public/sw.js b/public/sw.js index 5272b6f..1d279db 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ // App-shell cache only; Seafile/API traffic always goes to the network. -const CACHE = 'hub-v14'; +const CACHE = 'hub-v16'; const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];