diff --git a/static/js/emailLibrary.js b/static/js/emailLibrary.js index 781e3a5..806d652 100644 --- a/static/js/emailLibrary.js +++ b/static/js/emailLibrary.js @@ -4640,6 +4640,7 @@ function _updateBulkBar() { async function _bulkAction(action) { const uids = Array.from(state._selectedUids); if (uids.length === 0) return; + let failedReadSync = 0; if (action === 'delete') { const ok = await styledConfirm( `Delete ${uids.length} selected email${uids.length === 1 ? '' : 's'}?`, @@ -4655,11 +4656,19 @@ async function _bulkAction(action) { } else if (action === 'delete') { await fetch(`${API_BASE}/api/email/delete/${uid}?folder=${encodeURIComponent(state._libFolder)}${_acct()}`, { method: 'DELETE' }); } else if (action === 'read' || action === 'unread') { - // Local toggle for now (no backend endpoint yet) - const em = state._libEmails.find(e => e.uid === uid); - if (em) em.is_read = (action === 'read'); + 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) { console.error(`Failed to ${action} ${uid}:`, e); } + } catch (e) { + if (action === 'read' || action === 'unread') failedReadSync += 1; + console.error(`Failed to ${action} ${uid}:`, e); + } } if (action === 'archive' || action === 'delete') { @@ -4671,8 +4680,10 @@ async function _bulkAction(action) { state._selectMode = false; _updateBulkBar(); _renderGrid(); - // Sync the local mutation (delete/archive, or in-place read/unread - // flag flips on email objects) into the SWR cache so reopen doesn't + if (failedReadSync > 0) { + showToast(`Failed to update ${failedReadSync} email${failedReadSync === 1 ? '' : 's'}`); + } + // Sync successful local mutations into the SWR cache so reopen doesn't // briefly show the pre-bulk state. _libCacheWriteBack(); } diff --git a/tests/test_email_library_bulk_actions.py b/tests/test_email_library_bulk_actions.py new file mode 100644 index 0000000..900e0a6 --- /dev/null +++ b/tests/test_email_library_bulk_actions.py @@ -0,0 +1,36 @@ +from pathlib import Path + + +_REPO = Path(__file__).resolve().parents[1] +_EMAIL_LIBRARY = _REPO / "static" / "js" / "emailLibrary.js" + + +def _bulk_action_source() -> str: + text = _EMAIL_LIBRARY.read_text(encoding="utf-8") + start = text.index("async function _bulkAction(action)") + end = text.index("\n}\n\n// _extractName", start) + 3 + return text[start:end] + + +def test_email_bulk_read_unread_calls_provider_write_routes(): + """Bulk read/unread must persist to IMAP/provider, not only mutate UI state. + + Regression for issue #800's email follow-up: list select -> Actions -> + Mark Read used to update `em.is_read` locally and cache that fake state, + then refresh from the provider made the message unread again. + """ + src = _bulk_action_source() + + assert "Local toggle for now" not in src + assert "mark-read" in src + assert "mark-unread" in src + assert "method: 'POST'" in src + assert "_syncEmailReadState(uid, action === 'read')" in src + + +def test_email_bulk_read_unread_checks_backend_success_before_syncing_cache(): + src = _bulk_action_source() + + assert "data?.success === false" in src + assert "throw new Error(data?.error" in src + assert "_libCacheWriteBack()" in src