Merge branch 'dev' into review/pewds-pr6738-terra-20261027

Integrate PR #6739 foundation (backend security, bridge hardening, Python 3.11 test fixture correction) with PR #6738 remediations.
- Reconcile routes/email/email_routes.py harmonizing _LIST_LOCK and _LIST_EPOCH with #6738 cache invalidation and thread-safe mutation.
- Preserve editor-session validation, legacy-browser tool restrictions, image-tool authorization, and email cache concurrency invariants.
This commit is contained in:
Alexandre Teixeira
2026-10-27 18:12:05 +00:00
19 changed files with 974 additions and 169 deletions
+76 -22
View File
@@ -1827,21 +1827,25 @@ def setup_email_routes():
# a cached message body.
return (account_id or "", folder, str(uid), owner)
def _list_cache_get(key):
v = _LIST_CACHE.get(key)
if not v: return None
if v[0] < _time.monotonic():
_LIST_CACHE.pop(key, None)
return None
return v[1]
_LIST_STALE = {}
_LIST_CACHE_LOCK = _threading.RLock()
_LIST_LOCK = _threading.RLock()
_LIST_CACHE_LOCK = _LIST_LOCK
# Invalidation fences reads already in flight, including background refresh.
_LIST_CACHE_GENERATION = [0]
_LIST_EPOCH = [0]
_LIST_CACHE_GENERATION = _LIST_EPOCH
def _list_cache_get(key):
with _LIST_LOCK:
v = _LIST_CACHE.get(key)
if not v: return None
if v[0] < _time.monotonic():
_LIST_CACHE.pop(key, None)
return None
return v[1]
def _list_stale_get(key):
return _LIST_STALE.get(key)
with _LIST_LOCK:
return _LIST_STALE.get(key)
def _list_stale_put(key, value):
_LIST_STALE[key] = value
@@ -1852,8 +1856,9 @@ def setup_email_routes():
_LIST_REFRESHING = set()
def _list_cache_put(key, value):
with _LIST_CACHE_LOCK:
with _LIST_LOCK:
_LIST_CACHE[key] = (_time.monotonic() + _LIST_TTL, value)
# Also snapshot the last-good response for stale-while-revalidate.
_list_stale_put(key, value)
if len(_LIST_CACHE) > 64:
for k in list(_LIST_CACHE.keys())[:-32]:
@@ -1865,18 +1870,18 @@ def setup_email_routes():
Runs the blocking IMAP fetch in a daemon thread, then repopulates both
the TTL cache and the stale snapshot so the next list view is warm.
"""
with _LIST_CACHE_LOCK:
with _LIST_LOCK:
if cache_key in _LIST_REFRESHING:
return
_LIST_REFRESHING.add(cache_key)
generation = _LIST_CACHE_GENERATION[0]
epoch = _LIST_EPOCH[0]
def _run():
try:
refreshed = _list_emails_sync(*sync_args)
if refreshed and not refreshed.get("error"):
with _LIST_CACHE_LOCK:
if generation != _LIST_CACHE_GENERATION[0]:
with _LIST_LOCK:
if epoch != _LIST_EPOCH[0]:
return
_list_cache_put(cache_key, refreshed)
try:
@@ -1887,7 +1892,7 @@ def setup_email_routes():
except Exception as exc: # pragma: no cover - background best effort
logger.debug("email list background refresh failed: %s", exc)
finally:
with _LIST_CACHE_LOCK:
with _LIST_LOCK:
_LIST_REFRESHING.discard(cache_key)
_threading.Thread(target=_run, daemon=True, name="email-list-refresh").start()
@@ -1925,10 +1930,14 @@ def setup_email_routes():
hook(account_id, folder)
except Exception:
pass
with _LIST_CACHE_LOCK:
_LIST_CACHE_GENERATION[0] += 1
with _LIST_LOCK:
_LIST_EPOCH[0] += 1
if account_id is None and folder is None:
_LIST_CACHE.clear()
_LIST_STALE.clear()
return
for cache in (_LIST_CACHE, _LIST_STALE):
for k in list(cache):
for k in list(cache.keys()):
k_acct = k[0] if len(k) > 0 else ""
k_folder = k[1] if len(k) > 1 else ""
if (account_id is None or k_acct == (account_id or "")) and \
@@ -1936,15 +1945,60 @@ def setup_email_routes():
cache.pop(k, None)
def _index_update_flags(owner, account_id, folder, uid, flag, add):
with _LIST_CACHE_LOCK:
with _LIST_LOCK:
_email_index_update_flags(owner, account_id, folder, uid, flag, add)
_invalidate_list_cache(account_id, folder)
def _index_delete(owner, account_id, folder, uid):
with _LIST_CACHE_LOCK:
with _LIST_LOCK:
_email_index_delete(owner, account_id, folder, uid)
_invalidate_list_cache(account_id, folder)
def _update_list_cache_seen(account_id, folder, uid, seen: bool):
uid_s = str(uid)
with _LIST_LOCK:
for key, (expires_at, value) in list(_LIST_CACHE.items()):
if key[0] != (account_id or "") or key[1] != folder:
continue
emails = list((value or {}).get("emails") or [])
changed = False
if seen and key[2] == "unread":
kept = [e for e in emails if str((e or {}).get("uid") or "") != uid_s]
if len(kept) != len(emails):
value = dict(value)
value["emails"] = kept
value["total"] = max(0, int(value.get("total") or 0) - (len(emails) - len(kept)))
changed = True
else:
for e in emails:
if str((e or {}).get("uid") or "") == uid_s:
e["is_read"] = bool(seen)
changed = True
if changed:
_LIST_CACHE[key] = (expires_at, value)
if key in _LIST_STALE:
_LIST_STALE[key] = value
for key, value in list(_LIST_STALE.items()):
if key[0] != (account_id or "") or key[1] != folder:
continue
emails = list((value or {}).get("emails") or [])
changed = False
if seen and key[2] == "unread":
kept = [e for e in emails if str((e or {}).get("uid") or "") != uid_s]
if len(kept) != len(emails):
value = dict(value)
value["emails"] = kept
value["total"] = max(0, int(value.get("total") or 0) - (len(emails) - len(kept)))
changed = True
else:
for e in emails:
if str((e or {}).get("uid") or "") == uid_s:
e["is_read"] = bool(seen)
changed = True
if changed:
_LIST_STALE[key] = value
def _read_cache_get(key):
v = _READ_CACHE.get(key)
if not v: return None