Files
cloudflare_temp_email/smtp_proxy_server/flag_store.py
Josh Tsai 4c1e593d07 fix(imap-proxy): persist IMAP flags and mark mail as read (#1090)
Fix IMAP flag persistence so read/unread state survives reconnects, and align SEARCH/FETCH behavior with persisted flags.

Co-authored-by: bounce12340 <bounce12340@users.noreply.github.com>
2026-07-27 20:15:25 +08:00

179 lines
6.2 KiB
Python

import json
import os
import sqlite3
import threading
def _encode_flags(flags: set[str]) -> str:
# JSON, not a comma-joined string: commas are legal inside IMAP keywords,
# so "foo,bar" as a single keyword must not be split into two on read.
return json.dumps(sorted(flags))
def _decode_flags(text: str) -> set[str]:
if not text:
return set()
try:
parsed = json.loads(text)
if isinstance(parsed, list):
return {str(flag) for flag in parsed}
except ValueError:
pass
# Rows written before the switch to JSON were comma-joined.
return set(text.split(","))
class FlagStore:
"""Persists IMAP message flags (e.g. \\Seen) to a local SQLite file.
SimpleMailbox previously kept flags only in an in-memory dict that was
recreated from scratch for every IMAP connection (see SimpleRealm.requestAvatar
in imap_server.py), so a client's STORE command (e.g. marking a message as
read) was silently lost as soon as the session ended. This store gives
flags a durable home keyed by (address, mailbox, uid) so they survive
reconnects.
"""
_UPSERT_SQL = """
INSERT INTO imap_flags (address, mailbox, uid, flags)
VALUES (?, ?, ?, ?)
ON CONFLICT(address, mailbox, uid) DO UPDATE SET flags = excluded.flags
"""
def __init__(self, db_path: str):
self._db_path = db_path
self._lock = threading.Lock()
self._init_db()
def _connect(self) -> sqlite3.Connection:
# isolation_level=None: transactions are managed explicitly below, so
# sqlite3 must not inject its own implicit BEGIN.
return sqlite3.connect(self._db_path, timeout=10, isolation_level=None)
def _init_db(self):
dirname = os.path.dirname(self._db_path)
if dirname:
os.makedirs(dirname, exist_ok=True)
with self._lock:
conn = self._connect()
try:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS imap_flags (
address TEXT NOT NULL,
mailbox TEXT NOT NULL,
uid INTEGER NOT NULL,
flags TEXT NOT NULL,
PRIMARY KEY (address, mailbox, uid)
)
"""
)
finally:
conn.close()
def get_all(self, address: str, mailbox: str) -> dict[int, set[str]]:
"""Return {uid: flags} for every UID with stored flags in a mailbox."""
with self._lock:
conn = self._connect()
try:
rows = conn.execute(
"SELECT uid, flags FROM imap_flags WHERE address = ? AND mailbox = ?",
(address, mailbox),
).fetchall()
finally:
conn.close()
return {uid: _decode_flags(flags) for uid, flags in rows}
def update_flags(
self,
address: str,
mailbox: str,
uids: list[int],
flags: set[str],
mode: int,
) -> dict[int, set[str]]:
"""Atomically apply an IMAP STORE to the given UIDs.
mode follows twisted's convention: 1 adds (+FLAGS), -1 removes
(-FLAGS), 0 replaces (FLAGS). Returns {uid: resulting flags}.
The read-modify-write runs inside one BEGIN IMMEDIATE transaction:
two concurrent sessions that loaded the same stale in-memory flags
cannot overwrite each other's STORE, because each session's update is
recomputed from the row current at its own commit.
"""
if not uids:
return {}
flags = set(flags)
result: dict[int, set[str]] = {}
with self._lock:
conn = self._connect()
try:
conn.execute("BEGIN IMMEDIATE")
try:
placeholders = ",".join("?" for _ in uids)
rows = conn.execute(
"SELECT uid, flags FROM imap_flags"
" WHERE address = ? AND mailbox = ?"
f" AND uid IN ({placeholders})",
(address, mailbox, *uids),
).fetchall()
current = {uid: _decode_flags(text) for uid, text in rows}
upserts = []
for uid in uids:
old = current.get(uid, set())
if mode == 1:
new = old | flags
elif mode == -1:
new = old - flags
else:
new = set(flags)
result[uid] = new
upserts.append((address, mailbox, uid, _encode_flags(new)))
conn.executemany(self._UPSERT_SQL, upserts)
conn.execute("COMMIT")
except BaseException:
conn.execute("ROLLBACK")
raise
finally:
conn.close()
return result
def set_flags_bulk(
self, address: str, mailbox: str, uid_flags: dict[int, set[str]]
) -> None:
"""Upsert flags for multiple UIDs in a single transaction."""
if not uid_flags:
return
rows = [
(address, mailbox, uid, _encode_flags(flags))
for uid, flags in uid_flags.items()
]
with self._lock:
conn = self._connect()
try:
conn.execute("BEGIN IMMEDIATE")
try:
conn.executemany(self._UPSERT_SQL, rows)
conn.execute("COMMIT")
except BaseException:
conn.execute("ROLLBACK")
raise
finally:
conn.close()
_default_store: FlagStore | None = None
_default_store_lock = threading.Lock()
def get_flag_store() -> FlagStore:
"""Return the process-wide FlagStore, created lazily from settings."""
global _default_store
if _default_store is None:
with _default_store_lock:
if _default_store is None:
from config import settings
_default_store = FlagStore(settings.imap_flag_db_path)
return _default_store