mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-12 08:28:40 -04:00
fix(email): make unread opens one authoritative IMAP operation (#5923)
* fix(email): mark opened messages seen in one IMAP operation
* fix(email): collapse unread opens and ignore stale responses
* fix(email): send seen flags as an IMAP flag list
Wrap the authoritative \\Seen STORE operand in parentheses so strict IMAP servers such as GreenMail accept both cache-miss and cached-open transitions. Tighten the focused fake IMAP contract to reject the previously emitted bare flag atom.
* fix(email): guard stale authoritative opens
* fix(email): report a failed \Seen instead of withholding the message
The authoritative-open contract made a failed STORE fatal to the read: the
cold path raised after the body was already fetched and parsed, and the
cached path discarded an in-memory message to return
{"error": "Failed to mark email read"}. A transient IMAP failure therefore
turned a readable message into one that could not be opened at all.
Being authoritative should mean the reported flag state is truthful, not
that the body is withheld. The read now always returns the message and
carries mark_seen_failed so the client can roll its optimistic unread
marker back:
- _read_email_sync logs and reports a rejected STORE rather than raising,
and only writes the local index/list-cache transition when the provider
accepted it, so local state cannot drift ahead of the mailbox.
- A mailbox that refuses a read-write SELECT (shared archives, some
provider folders) falls back to a read-only selection and reports the
flag failure instead of failing the open.
- The route strips mark_seen_failed before caching, so a one-off failure is
never replayed to later readers.
- mark_seen now defaults to False on _read_email_sync. It was inert before
this branch and now mutates provider state; the one caller that wants it
off already passes it explicitly.
emailInbox and emailLibrary keep the message rendered when mark_seen_failed
is set and restore the unread state, rather than showing a failed reader.
---------
Co-authored-by: Léo <leograndcontact@gmail.com>
This commit is contained in:
+123
-21
@@ -30,6 +30,10 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
|
||||
const API_BASE = window.location.origin;
|
||||
let _emailUnreadChipClickWired = false;
|
||||
let _libLoadSeq = 0;
|
||||
let _emailMailboxGeneration = 0;
|
||||
let _emailCardOpenSeq = 0;
|
||||
let _emailReadMutationSeq = 0;
|
||||
const _emailReadMutations = new Map();
|
||||
let _libFolderSeq = 0;
|
||||
let _libSearchSeq = 0;
|
||||
let _libSearchHadResults = false;
|
||||
@@ -837,14 +841,41 @@ document.addEventListener('keydown', (e) => {
|
||||
e.stopImmediatePropagation?.();
|
||||
}, true);
|
||||
|
||||
function _syncEmailReadState(uid, isRead = true) {
|
||||
function _emailReadContextKey(context) {
|
||||
return [context.accountId, context.folder, context.uid].map(value => String(value || '')).join('\u0000');
|
||||
}
|
||||
|
||||
function _emailReadContextIsCurrent(context) {
|
||||
if (!context) return true;
|
||||
return (
|
||||
String(state._libAccountId || '') === context.accountId &&
|
||||
String(state._libFolder || 'INBOX') === context.libraryFolder &&
|
||||
_emailMailboxGeneration === context.mailboxGeneration
|
||||
);
|
||||
}
|
||||
|
||||
function _emailMatchesReadContext(email, context) {
|
||||
if (String(email?.uid || '') !== context.uid) return false;
|
||||
const accountId = String(email?.account_id || context.accountId);
|
||||
const folder = String(email?.folder || context.folder);
|
||||
return accountId === context.accountId && folder === context.folder;
|
||||
}
|
||||
|
||||
function _syncEmailReadState(uid, isRead = true, context = null) {
|
||||
if (uid == null) return;
|
||||
const uidStr = String(uid);
|
||||
const read = !!isRead;
|
||||
const match = (state._libEmails || []).find(x => String(x.uid) === uidStr);
|
||||
if (context && (!_emailReadContextIsCurrent(context) || uidStr !== context.uid)) return;
|
||||
const match = (state._libEmails || []).find(x => (
|
||||
context ? _emailMatchesReadContext(x, context) : String(x.uid) === uidStr
|
||||
));
|
||||
if (match) match.is_read = read;
|
||||
|
||||
document.querySelectorAll('.doclib-card[data-uid="' + CSS.escape(uidStr) + '"]').forEach(card => {
|
||||
if (context && (
|
||||
String(card.dataset.emailAccount || '') !== context.accountId ||
|
||||
String(card.dataset.emailFolder || '') !== context.folder
|
||||
)) return;
|
||||
card.classList.toggle('email-card-unread', !read);
|
||||
const titleRow = card.querySelector('.email-card-titlerow');
|
||||
if (read) {
|
||||
@@ -1908,6 +1939,7 @@ function _resetEmailListForFreshLoad({ useCache = true } = {}) {
|
||||
_exitEmailReaderModeForList();
|
||||
_resetBulkSelectionForContextChange();
|
||||
state._libOffset = 0;
|
||||
_emailMailboxGeneration += 1;
|
||||
_libLoadSeq += 1;
|
||||
const ck = _libCacheKey();
|
||||
const cached = useCache ? _libCacheGet(ck) : null;
|
||||
@@ -2286,7 +2318,25 @@ function _publishActiveAccount() {
|
||||
|
||||
export function initEmailLibrary(config) {
|
||||
state._docModule = config.documentModule;
|
||||
state._onEmailClick = config.onEmailClick;
|
||||
const onEmailClick = config.onEmailClick;
|
||||
state._onEmailClick = typeof onEmailClick === 'function' ? (options = {}) => {
|
||||
const accountId = String(state._libAccountId || '');
|
||||
const libraryFolder = String(state._libFolder || 'INBOX');
|
||||
const messageFolder = String(options.email?.folder || libraryFolder);
|
||||
const mailboxGeneration = _emailMailboxGeneration;
|
||||
const mailboxContext = Object.freeze({
|
||||
accountId,
|
||||
libraryFolder,
|
||||
messageFolder,
|
||||
mailboxGeneration,
|
||||
isCurrent: () => (
|
||||
String(state._libAccountId || '') === accountId &&
|
||||
String(state._libFolder || 'INBOX') === libraryFolder &&
|
||||
_emailMailboxGeneration === mailboxGeneration
|
||||
),
|
||||
});
|
||||
return onEmailClick({ ...options, mailboxContext });
|
||||
} : null;
|
||||
}
|
||||
|
||||
export function isOpen() { return state._libOpen; }
|
||||
@@ -2303,6 +2353,7 @@ export function openEmailLibrary(opts = {}) {
|
||||
document.removeEventListener('keydown', state._libEscHandler, true);
|
||||
state._libEscHandler = null;
|
||||
}
|
||||
_emailMailboxGeneration += 1;
|
||||
state._libOpen = true;
|
||||
// On mobile the sidebar overlays content — close it so the email view isn't
|
||||
// opened behind it (same pattern as session-switch/delete).
|
||||
@@ -4836,6 +4887,8 @@ function _createCard(em) {
|
||||
else if (!em.is_read) cls += ' email-card-unread';
|
||||
card.className = cls;
|
||||
card.dataset.uid = String(em.uid);
|
||||
card.dataset.emailAccount = String(em.account_id || state._libAccountId || '');
|
||||
card.dataset.emailFolder = String(em.folder || state._libFolder || 'INBOX');
|
||||
if (state._selectMode && state._selectedUids.has(em.uid)) card.classList.add('selected');
|
||||
|
||||
// Checkbox in select mode
|
||||
@@ -5162,6 +5215,25 @@ async function _toggleCardPreview(card, em) {
|
||||
// currently-selected folder for normal inbox cards.
|
||||
const folderAtStart = (em && em.folder) || libraryFolderAtStart;
|
||||
const uidAtStart = String(em?.uid || card?.dataset?.uid || '');
|
||||
const wasReadAtStart = !!em?.is_read;
|
||||
const openGeneration = ++_emailCardOpenSeq;
|
||||
const readContext = Object.freeze({
|
||||
accountId: String(accountAtStart),
|
||||
libraryFolder: String(libraryFolderAtStart),
|
||||
folder: String(folderAtStart),
|
||||
uid: uidAtStart,
|
||||
mailboxGeneration: _emailMailboxGeneration,
|
||||
});
|
||||
const readContextKey = _emailReadContextKey(readContext);
|
||||
const isCurrentOpen = () => (
|
||||
openGeneration === _emailCardOpenSeq &&
|
||||
_emailReadContextIsCurrent(readContext) &&
|
||||
accountAtStart === (state._libAccountId || '') &&
|
||||
libraryFolderAtStart === (state._libFolder || 'INBOX') &&
|
||||
uidAtStart === String(card?.dataset?.uid || '') &&
|
||||
card.isConnected &&
|
||||
card.classList.contains('email-card-expanded')
|
||||
);
|
||||
const grid = card.closest('.doclib-grid');
|
||||
const gridRect = grid?.getBoundingClientRect?.();
|
||||
const modal = document.getElementById('email-lib-modal');
|
||||
@@ -5186,6 +5258,30 @@ async function _toggleCardPreview(card, em) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Every authoritative open supersedes any older optimistic mutation for the
|
||||
// same immutable mailbox identity. Carry the original unread state forward
|
||||
// so a close/reopen followed by failure still rolls back exactly once, while
|
||||
// a late failure from the superseded request cannot undo a newer success.
|
||||
const previousMutation = _emailReadMutations.get(readContextKey);
|
||||
const readMutation = {
|
||||
generation: ++_emailReadMutationSeq,
|
||||
rollbackUnread: !wasReadAtStart || !!previousMutation?.rollbackUnread,
|
||||
};
|
||||
_emailReadMutations.set(readContextKey, readMutation);
|
||||
const restoreUnreadState = () => {
|
||||
if (_emailReadMutations.get(readContextKey)?.generation !== readMutation.generation) return;
|
||||
_emailReadMutations.delete(readContextKey);
|
||||
if (readMutation.rollbackUnread) _syncEmailReadState(uidAtStart, false, readContext);
|
||||
};
|
||||
const commitReadState = () => {
|
||||
// A successful STORE/mark_seen is authoritative for this immutable
|
||||
// mailbox identity even when a newer open is still pending. Retire that
|
||||
// newer rollback token too, otherwise its later failure could restore an
|
||||
// unread state that no longer exists at the provider.
|
||||
_emailReadMutations.delete(readContextKey);
|
||||
_syncEmailReadState(uidAtStart, true, readContext);
|
||||
};
|
||||
|
||||
// Collapse any other expanded card
|
||||
if (grid) {
|
||||
grid.querySelectorAll('.email-card-expanded').forEach(c => {
|
||||
@@ -5207,10 +5303,10 @@ async function _toggleCardPreview(card, em) {
|
||||
requestAnimationFrame(() => {
|
||||
try { card.scrollIntoView({ behavior: 'smooth', block: 'start' }); } catch (_) {}
|
||||
});
|
||||
if (!em.is_read) {
|
||||
_syncEmailReadState(em.uid, true);
|
||||
fetch(`${API_BASE}/api/email/mark-read/${em.uid}?folder=${encodeURIComponent(folderAtStart)}${_acct()}`, { method: 'POST' })
|
||||
.catch(err => console.error('Failed to mark email read:', err));
|
||||
if (!wasReadAtStart) {
|
||||
// Keep the current optimistic visual update, but let the read request below
|
||||
// own the provider-side \Seen transition. A failure restores unread state.
|
||||
_syncEmailReadState(uidAtStart, true, readContext);
|
||||
}
|
||||
// Class hook on the modal so the header-hide / padding rules work on
|
||||
// browsers without :has() support (Firefox mobile) — the :has() versions
|
||||
@@ -5239,25 +5335,28 @@ async function _toggleCardPreview(card, em) {
|
||||
} catch (_) {}
|
||||
};
|
||||
|
||||
let authoritativeReadSucceeded = false;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/email/read/${em.uid}?folder=${encodeURIComponent(folderAtStart)}${_acct()}`);
|
||||
const accountQueryAtStart = accountAtStart ? `&account_id=${encodeURIComponent(accountAtStart)}` : '';
|
||||
const res = await fetch(`${API_BASE}/api/email/read/${encodeURIComponent(uidAtStart)}?folder=${encodeURIComponent(folderAtStart)}${accountQueryAtStart}&mark_seen=true`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
if (
|
||||
accountAtStart !== (state._libAccountId || '') ||
|
||||
libraryFolderAtStart !== (state._libFolder || 'INBOX') ||
|
||||
uidAtStart !== String(card?.dataset?.uid || '') ||
|
||||
!card.isConnected ||
|
||||
!card.classList.contains('email-card-expanded')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (data.error) {
|
||||
showFailedReader(`Failed to load email: ${data.error}`);
|
||||
restoreUnreadState();
|
||||
if (isCurrentOpen()) showFailedReader(`Failed to load email: ${data.error}`);
|
||||
return;
|
||||
}
|
||||
// Mark as read locally
|
||||
_syncEmailReadState(em.uid, true);
|
||||
if (data.mark_seen_failed) {
|
||||
// The body is authoritative even when the provider refused the \Seen
|
||||
// transition. Render the message and roll the unread marker back so the
|
||||
// list keeps telling the truth, rather than refusing to open a message
|
||||
// we successfully read.
|
||||
restoreUnreadState();
|
||||
} else {
|
||||
authoritativeReadSucceeded = true;
|
||||
commitReadState();
|
||||
}
|
||||
if (!isCurrentOpen()) return;
|
||||
_stampReaderContext(reader, { ...em, ...data }, state._libFolder, state._libAccountId);
|
||||
|
||||
// Build the attachments wrap using the shared helper so the signature-
|
||||
@@ -5439,7 +5538,10 @@ async function _toggleCardPreview(card, em) {
|
||||
// Always stop bubbling so the card's click doesn't fire while reading.
|
||||
reader.addEventListener('click', (ev) => { ev.stopPropagation(); });
|
||||
} catch (e) {
|
||||
showFailedReader(e?.message ? `Failed to load email: ${e.message}` : 'Failed to load email');
|
||||
if (!authoritativeReadSucceeded) restoreUnreadState();
|
||||
if (isCurrentOpen()) {
|
||||
showFailedReader(e?.message ? `Failed to load email: ${e.message}` : 'Failed to load email');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user