From a3010299d02104eecab9255dd12277bc5be667b3 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:19:45 -0400 Subject: [PATCH] Mail: folder unread counts refresh at once after a change, read state survives a late list, no wrong message after quick clicks, mark unread keeps the message in the list Co-Authored-By: Claude Sonnet 5.5 --- mail-imap.js | 4 +++- mail.js | 2 +- public/mail.js | 29 ++++++++++++++++++++++------- tools/mail-imap-test.js | 3 +++ 4 files changed, 29 insertions(+), 9 deletions(-) diff --git a/mail-imap.js b/mail-imap.js index 7778ed0..7875cf4 100644 --- a/mail-imap.js +++ b/mail-imap.js @@ -191,6 +191,8 @@ async function del(a, mid, permanent) { const [path, uid] = dec(mid); return withClient(a, async client => { const lock = await client.getMailboxLock(path); try { await client.messageDelete(String(uid), { uid: true }); } finally { lock.release(); } }); } +/** 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); } // ---- sending @@ -251,4 +253,4 @@ async function connectNew({ type, address, password }) { return { type, address: addr, pass, name: '' }; } -module.exports = { PRESETS: Object.fromEntries(Object.entries(PRESETS).map(([k, v]) => [k, { name: v.name }])), folders, list, message, attachment, update, move, del, unread, send, reply, connectNew, forget }; +module.exports = { dropFolders, PRESETS: Object.fromEntries(Object.entries(PRESETS).map(([k, v]) => [k, { name: v.name }])), folders, list, message, attachment, update, move, del, unread, send, reply, connectNew, forget }; diff --git a/mail.js b/mail.js index b4c25c2..c8abc60 100644 --- a/mail.js +++ b/mail.js @@ -155,7 +155,7 @@ const safeName = n => String(n || 'attachment').replace(/[\r\n"\\/]+/g, '_').sli const cleanAttachments = list => { let total = 0; const out = (Array.isArray(list) ? list : []).slice(0, 10).map(a => { const data = String(a.data || ''); total += Buffer.from(data, 'base64').length; return { name: safeName(a.name), type: String(a.type || 'application/octet-stream').slice(0, 100), data }; }); if (total > MAX_ATTACH) throw fail(400, 'Attachments can total about 3 MB'); return out; }; let folderCache = new Map(); // account id -> { t, v } -const dropFolderCache = id => { if (id) { folderCache.delete(id); unreadCache.delete(id); } else { folderCache.clear(); unreadCache.clear(); } }; +const dropFolderCache = id => { if (id) { folderCache.delete(id); unreadCache.delete(id); } else { folderCache.clear(); unreadCache.clear(); } imap.dropFolders(id); }; // ---- Outlook.com through Microsoft Graph. Every function takes the account and Graph's own ids. const outlook = { diff --git a/public/mail.js b/public/mail.js index 692656a..75739d6 100644 --- a/public/mail.js +++ b/public/mail.js @@ -232,7 +232,7 @@ async function mailLoadList(reset) { try { const r = await jfetch(`/api/mail/messages?folder=${encodeURIComponent(folder)}&skip=${showing ? 0 : mail.next}${mail.q ? '&q=' + encodeURIComponent(mail.q) : ''}`); if (!$('#mrows') || mail.folder !== folder) return; - mail.items = showing ? r.items : mail.items.concat(r.items); mail.more = r.more; mail.next = r.next; + 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(); } 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); } } @@ -252,19 +252,34 @@ const mailCache = new Map(); // message id -> { t, p: promise }: a message you p 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; }; 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 +// (it started before the change) must not put the old state back. +const mailMine = new Map(); +const mailInInbox = () => /(^|~)inbox$/.test(mail.folder); +function mailSetRead(id, read) { + mailMine.set(id, { read, t: Date.now() }); + const it = mail.items.find(x => x.id === id); if (it) it.read = read; + const c = mcGet('l:' + mail.folder); // the saved copy of this folder shows it too the next time Mail opens + if (c && c.items) { const x = c.items.find(y => y.id === id); if (x) { x.read = read; mcPut('l:' + mail.folder, { items: c.items, more: c.more, next: c.next }); } } +} +const mailApplyMine = items => { const now = Date.now(); for (const [k, v] of mailMine) if (now - v.t > 60e3) mailMine.delete(k); for (const m of items) { const o = mailMine.get(m.id); if (o) m.read = o.read; } return items; }; + async function mailOpen(id) { const rd = $('#mread'), it = mail.items.find(x => x.id === id); if (rd) rd.innerHTML = `
${icon('back')}${esc(mailFolderName(mail.folder))}${it ? `

${esc(it.subject)}

${esc(mailFrom(it.from))} ${esc(it.from.address)}

` : ''}

Opening…

`; // (what the list already knows, at once) mail.images = false; try { const m = await mailFetch(id); - if (!(location.hash.startsWith('#/mail/m/'))) return; + if (decodeURIComponent((location.hash.match(/^#\/mail\/m\/([^/]+)/) || [])[1] || '') !== id) return; // you moved on to another message while this one loaded mail.msg = m; mailDrawReader(); mailDrawList(); if (!m.read) { // opening a message marks it read + const inbox = mailInInbox(); 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?acct=' + encodeURIComponent(mail.acct)).then(f => { mail.folders = f; }).catch(() => {}); - }).catch(() => {}); + m.read = true; mailSetRead(id, true); mailDrawList(); + if (inbox) { mailUnread = Math.max(0, mailUnread - 1); renderNav(); } + mailPoll(); // the real count (the server drops its saved one when a message changes) + jfetch('/api/mail/folders?acct=' + encodeURIComponent(mail.acct)).then(f => { mail.folders = f; mailRefreshFolders(); }).catch(() => {}); + }).catch(e => toast('Could not mark it as read: ' + e.message)); } } catch (e) { if ($('#mread')) $('#mread').innerHTML = `

${esc(e.message)}

`; } } @@ -292,11 +307,11 @@ 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 = () => { 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?acct=' + encodeURIComponent(mail.acct)).then(f => { mail.folders = f; }).catch(() => {}); }; + 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(() => {}); }; 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 === '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); } }); diff --git a/tools/mail-imap-test.js b/tools/mail-imap-test.js index c48e7e8..bfbd754 100644 --- a/tools/mail-imap-test.js +++ b/tools/mail-imap-test.js @@ -80,6 +80,9 @@ const free = () => 21000 + Math.floor(Math.random() * 20000); // ================= flags, moving, deleting ================= await A('/api/mail/update', { id: helloId, read: true }); r = await A('/api/mail/messages?folder=' + gm.id + '~inbox'); ok('mark as read', r.json.items[0].read === true, r.json.items[0]); + r = await A('/api/mail/folders?acct=' + gm.id); const unreadBefore = r.json[0].unread; + await A('/api/mail/update', { id: helloId, read: false }); r = await A('/api/mail/folders?acct=' + gm.id); ok('the folder unread count follows a change at once (not the 30 s copy)', r.json[0].unread === unreadBefore + 1, r.json[0]); + await A('/api/mail/update', { id: helloId, read: true }); r = await A('/api/mail/folders?acct=' + gm.id); await A('/api/mail/update', { id: helloId, read: false, flagged: true }); r = await A('/api/mail/messages?folder=' + gm.id + '~inbox'); ok('mark unread and flag', r.json.items[0].read === false && r.json.items[0].flagged === true, r.json.items[0]); r = await A('/api/mail/update', { id: helloId }); ok('an update with nothing to change is refused', r.status === 400, r.status); const archBefore = (await peek('[Gmail]/All Mail')).messages;