diff --git a/static/js/emailLibrary.js b/static/js/emailLibrary.js index a52e541d4..f7104dbd3 100644 --- a/static/js/emailLibrary.js +++ b/static/js/emailLibrary.js @@ -4998,58 +4998,96 @@ async function _bulkAction(action) { const originalDeleteHtml = deleteBtn?.innerHTML || ''; const originalCountText = countEl?.textContent || ''; let busySpinner = null; - if (action === 'delete') { - if (deleteBtn) { - deleteBtn.disabled = true; - deleteBtn.classList.add('email-bulk-loading'); - deleteBtn.innerHTML = 'Deleting'; - busySpinner = spinnerModule.create('', 'clean', 'whirlpool'); - const spEl = busySpinner.createElement(); - spEl.classList.add('email-bulk-whirlpool'); - deleteBtn.appendChild(spEl); - busySpinner.start(); - } - if (actionsBtn) actionsBtn.disabled = true; - if (cancelBtn) cancelBtn.disabled = true; - if (selectAll) selectAll.disabled = true; - if (countEl) countEl.textContent = `Deleting ${uids.length}...`; + // Loading state for every bulk action, not just delete — large + // selections (e.g. 90+ Dones) used to silently hammer the server + // with sequential requests and the user got zero feedback. Now the + // Actions button (or Delete button) shows a whirlpool + verb-ing + // label, and the count surfaces progress. + const verbing = { + delete: 'Deleting', + archive: 'Archiving', + done: 'Marking done', + read: 'Marking read', + unread: 'Marking unread', + }[action] || 'Updating'; + const targetBtn = action === 'delete' ? deleteBtn : actionsBtn; + let originalTargetHtml = ''; + if (targetBtn) { + originalTargetHtml = targetBtn.innerHTML; + targetBtn.disabled = true; + targetBtn.classList.add('email-bulk-loading'); + targetBtn.innerHTML = `${verbing}`; + busySpinner = spinnerModule.create('', 'clean', 'whirlpool'); + const spEl = busySpinner.createElement(); + spEl.classList.add('email-bulk-whirlpool'); + targetBtn.appendChild(spEl); + busySpinner.start(); } + if (action !== 'delete' && deleteBtn) deleteBtn.disabled = true; + if (action === 'delete' && actionsBtn) actionsBtn.disabled = true; + if (cancelBtn) cancelBtn.disabled = true; + if (selectAll) selectAll.disabled = true; + if (countEl) countEl.textContent = `${verbing} ${uids.length}…`; + + // Single-uid worker. + const handleOne = async (uid) => { + try { + if (action === 'archive') { + await fetch(`${API_BASE}/api/email/archive/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' }); + } else if (action === 'delete') { + await fetch(`${API_BASE}/api/email/delete/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' }); + } else if (action === 'done') { + // uid may come back from the Set as a string while em.uid is + // numeric (or vice versa) — coerce both sides so the in-memory + // state actually flips and the post-loop re-render shows the + // done checkmark. + const em = state._libEmails.find(e => String(e.uid) === String(uid)); + if (em) { em.is_answered = true; em.is_read = true; } + const ansRes = await fetch(`${API_BASE}/api/email/mark-answered/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' }); + const readRes = await fetch(`${API_BASE}/api/email/mark-read/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' }); + if (!ansRes.ok || !readRes.ok) throw new Error(`mark-done HTTP ${ansRes.status}/${readRes.status}`); + } else if (action === 'read' || action === 'unread') { + const endpoint = action === 'read' ? 'mark-read' : 'mark-unread'; + const res = await fetch(`${API_BASE}/api/email/${endpoint}/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' }); + let data = null; + try { data = await res.json(); } catch (_) {} + if (!res.ok || data?.success === false) { + throw new Error(data?.error || `HTTP ${res.status}`); + } + _syncEmailReadState(uid, action === 'read'); + } + } catch (e) { + if (action === 'read' || action === 'unread') failedReadSync += 1; + console.error(`Failed to ${action} ${uid}:`, e); + } + }; try { - for (const uid of uids) { - try { - if (action === 'archive') { - await fetch(`${API_BASE}/api/email/archive/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' }); - } else if (action === 'delete') { - await fetch(`${API_BASE}/api/email/delete/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' }); - } else if (action === 'done') { - // uid may come back from the Set as a string while em.uid is - // numeric (or vice versa) — coerce both sides so the in-memory - // state actually flips and the post-loop re-render shows the - // done checkmark. - const em = state._libEmails.find(e => String(e.uid) === String(uid)); - if (em) { - em.is_answered = true; - em.is_read = true; - } - const ansRes = await fetch(`${API_BASE}/api/email/mark-answered/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' }); - const readRes = await fetch(`${API_BASE}/api/email/mark-read/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' }); - if (!ansRes.ok || !readRes.ok) throw new Error(`mark-done HTTP ${ansRes.status}/${readRes.status}`); - } else if (action === 'read' || action === 'unread') { - const endpoint = action === 'read' ? 'mark-read' : 'mark-unread'; - const res = await fetch(`${API_BASE}/api/email/${endpoint}/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'POST' }); - let data = null; - try { data = await res.json(); } catch (_) {} - if (!res.ok || data?.success === false) { - throw new Error(data?.error || `HTTP ${res.status}`); - } - _syncEmailReadState(uid, action === 'read'); + // Run in parallel with a concurrency cap so 92 emails don't take + // 30 seconds sequentially but we also don't open 92 simultaneous + // connections. + const CONCURRENCY = 6; + const queue = uids.slice(); + let inFlight = 0; + let nextSlot = 0; + let finishedCount = 0; + await new Promise((resolve) => { + const launch = () => { + while (inFlight < CONCURRENCY && nextSlot < queue.length) { + const uid = queue[nextSlot++]; + inFlight++; + handleOne(uid).finally(() => { + inFlight--; + finishedCount++; + if (countEl) countEl.textContent = `${verbing} ${finishedCount}/${queue.length}…`; + if (nextSlot >= queue.length && inFlight === 0) resolve(); + else launch(); + }); } - } catch (e) { - if (action === 'read' || action === 'unread') failedReadSync += 1; - console.error(`Failed to ${action} ${uid}:`, e); - } - } + if (queue.length === 0) resolve(); + }; + launch(); + }); if (action === 'archive' || action === 'delete') { await _animateEmailCardRemoval(uids); @@ -5066,12 +5104,17 @@ async function _bulkAction(action) { } } finally { if (busySpinner) busySpinner.destroy(); - if (deleteBtn) { - deleteBtn.disabled = false; - deleteBtn.classList.remove('email-bulk-loading'); - deleteBtn.innerHTML = originalDeleteHtml; + // Restore whichever button we hijacked (delete vs actions). + if (targetBtn) { + targetBtn.disabled = false; + targetBtn.classList.remove('email-bulk-loading'); + targetBtn.innerHTML = originalTargetHtml || targetBtn.innerHTML; } - if (actionsBtn) actionsBtn.disabled = false; + if (deleteBtn && deleteBtn !== targetBtn) { + deleteBtn.disabled = false; + deleteBtn.innerHTML = originalDeleteHtml || deleteBtn.innerHTML; + } + if (actionsBtn && actionsBtn !== targetBtn) actionsBtn.disabled = false; if (cancelBtn) cancelBtn.disabled = false; if (selectAll) selectAll.disabled = false; if (countEl) countEl.textContent = originalCountText;