Files
webmail-client/backend/app/sync/actions.py
T

605 lines
25 KiB
Python
Raw Normal View History

2026-07-17 23:20:34 +02:00
"""Outbound user actions: flag changes and moves (SPEC §6.3).
The API writes the change to the DB first, then enqueues a job here to push it to
the mail server. While the server cannot be reached the job is retried (arq
``Retry``) with growing gaps for about 24 hours (``ACTION_MAX_TRIES``); a flag
change that still cannot be pushed is then reverted in the DB and announced with
``flags.changed`` / ``reverted=true`` so the UI rolls back. A plain exception does
NOT make arq retry — only ``Retry`` does.
2026-07-17 23:20:34 +02:00
"""
from __future__ import annotations
import logging
import uuid
from collections import defaultdict
2026-07-17 23:20:34 +02:00
from collections.abc import Iterable
from datetime import UTC, datetime, timedelta
2026-07-17 23:20:34 +02:00
from typing import Any
from arq import Retry
from sqlalchemy import delete, func, select, update
2026-07-17 23:20:34 +02:00
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
2026-07-17 23:20:34 +02:00
from app.db import session_scope
from app.events import get_event_bus
from app.models import Account, Draft, Mailbox, Message, Thread
from app.sync.health import is_temporary
2026-07-17 23:20:34 +02:00
from app.sync.provider_factory import build_provider
from app.threading_.threader import denormalize_thread
logger = logging.getLogger(__name__)
# User-settable IMAP flags (SPEC §8.2 is:read/starred/answered). \Deleted/\Draft
# are managed by move/expunge and drafts, not this surface.
ALLOWED_FLAGS = frozenset({"\\Seen", "\\Flagged", "\\Answered"})
# Gaps between tries while the server is unreachable: ~24 hours in all.
_RETRY_DEFERS_S = (30, 60, 120, 300, 600, 1800, 3600, 7200, 14400, 28800, 28800)
ACTION_MAX_TRIES = len(_RETRY_DEFERS_S) + 1
def _retry_later(ctx: dict[str, Any], exc: BaseException) -> Retry | None:
"""A ``Retry`` for a temporary failure while tries remain, else None (give up)."""
if not is_temporary(exc):
return None
attempt = int(ctx.get("job_try", 1))
if attempt >= ACTION_MAX_TRIES:
return None
logger.info(
"server unreachable (try %d), retrying in %ds: %s",
attempt,
_RETRY_DEFERS_S[attempt - 1],
exc,
)
return Retry(defer=_RETRY_DEFERS_S[attempt - 1])
2026-07-17 23:20:34 +02:00
# Folder names to create when a target special-use folder is missing on the server.
_DEFAULT_FOLDER_NAME = {"archive": "Archive", "trash": "Trash", "junk": "Spam"}
# Moves into these folders record where the message came from (restore_mailbox_id)
# so the Restore action can return it to its original folder.
_REMOVAL_FOLDERS = frozenset({"archive", "trash", "junk"})
2026-07-17 23:20:34 +02:00
def sanitize_flags(flags: Iterable[str]) -> frozenset[str]:
return frozenset(f for f in flags if f in ALLOWED_FLAGS)
async def apply_flags_to_messages(
session: AsyncSession, messages: list[Message], add: frozenset[str], remove: frozenset[str]
) -> set[uuid.UUID]:
"""Apply a flag delta to messages in the DB and re-denormalize their threads."""
thread_ids: set[uuid.UUID] = set()
for message in messages:
message.flags = sorted((set(message.flags) | add) - remove)
if message.thread_id is not None:
thread_ids.add(message.thread_id)
await session.flush()
for tid in sorted(thread_ids): # one lock order across transactions
2026-07-17 23:20:34 +02:00
await denormalize_thread(session, tid)
return thread_ids
# --- worker jobs -------------------------------------------------------------
async def push_flags_job(
ctx: dict[str, Any], message_id: str, add: list[str], remove: list[str]
) -> None:
"""Push a flag change to the mail server; revert the DB on final failure."""
mid = uuid.UUID(message_id)
async with session_scope() as session:
message = await session.get(Message, mid)
if message is None:
return
if _superseded(message.flags, add, remove):
return # changed again since: that later change's own job pushes it
2026-07-17 23:20:34 +02:00
account = await session.get(Account, message.account_id)
mailbox = await session.get(Mailbox, message.mailbox_id)
if account is None or mailbox is None:
return
user_id, uid, path = message.user_id, message.uid, mailbox.path
provider = build_provider(account)
try:
await provider.push_flags(path, uid, frozenset(add), frozenset(remove))
except Exception as exc:
retry = _retry_later(ctx, exc)
if retry is not None:
raise retry from exc
logger.warning("flag change for message %s could not be pushed: %s", mid, exc)
await _revert_flags(mid, user_id, add, remove)
2026-07-17 23:20:34 +02:00
finally:
await provider.close()
def _superseded(flags: list[str], add: list[str], remove: list[str]) -> bool:
"""The message's flags no longer show this change: a later one replaced it
(e.g. read, then unread again while the server was unreachable)."""
now = set(flags)
return not set(add) <= now or bool(set(remove) & now)
2026-07-17 23:20:34 +02:00
async def _revert_flags(
message_id: uuid.UUID, user_id: uuid.UUID, add: list[str], remove: list[str]
) -> None:
async with session_scope() as session:
message = await session.get(Message, message_id)
if message is None or _superseded(message.flags, add, remove):
2026-07-17 23:20:34 +02:00
return
# Undo the change: remove what we added, add back what we removed.
message.flags = sorted((set(message.flags) - set(add)) | set(remove))
await session.flush()
if message.thread_id is not None:
await denormalize_thread(session, message.thread_id)
flags = list(message.flags)
await get_event_bus().publish(
"flags.changed",
{"user_id": str(user_id), "message_id": str(message_id), "flags": flags, "reverted": True},
)
logger.warning("reverted flag change for message %s after push failures", message_id)
async def move_job(
ctx: dict[str, Any], message_id: str, dst_mailbox_id: str, src_mailbox_id: str
) -> None:
"""Move a message on the server; update its uid, or revert the DB on failure."""
mid = uuid.UUID(message_id)
dst_id = uuid.UUID(dst_mailbox_id)
async with session_scope() as session:
message = await session.get(Message, mid)
if message is None:
return
account = await session.get(Account, message.account_id)
dst = await session.get(Mailbox, dst_id)
src = await session.get(Mailbox, uuid.UUID(src_mailbox_id))
if account is None or dst is None or src is None:
return
src_path = src.path
uid, dst_path, user_id = message.uid, dst.path, message.user_id
2026-09-17 08:36:51 +02:00
# Carried into the event below: a consumer usually cares about the
# transition, not the destination. "Now in Spam" and "moved out of Spam"
# are different facts, and only the pair distinguishes them.
src_special, dst_special = src.special_use, dst.special_use
# The open reading panel is keyed by thread: without this the UI
# invalidates the message list but never refetches the thread the user is
# looking at, so a moved message stays on screen.
thread_id = message.thread_id
2026-07-17 23:20:34 +02:00
provider = build_provider(account)
try:
new_uid = await provider.move(uid, src_path, dst_path)
except Exception as exc:
retry = _retry_later(ctx, exc)
if retry is not None:
raise retry from exc
logger.warning("move failed for message %s; leaving optimistic state: %s", mid, exc)
return
2026-07-17 23:20:34 +02:00
finally:
await provider.close()
async with session_scope() as session:
message = await session.get(Message, mid)
if message is not None:
message.uid = new_uid
message.mailbox_id = dst_id
await get_event_bus().publish(
"message.changed",
2026-09-17 08:36:51 +02:00
{
"user_id": str(user_id),
"message_id": str(mid),
"thread_id": str(thread_id) if thread_id else None,
2026-09-17 08:36:51 +02:00
"mailbox_id": str(dst_id),
"previous_mailbox_id": src_mailbox_id,
"special_use": dst_special,
"previous_special_use": src_special,
},
2026-07-17 23:20:34 +02:00
)
async def move_thread_to_folder_job(ctx: dict[str, Any], thread_id: str, special_use: str) -> None:
"""Move every message in a thread to each account's special-use folder.
Creates the target folder (e.g. Archive) if the account doesn't have one.
Used by the Archive and Delete (-> Trash) thread actions.
"""
tid = uuid.UUID(thread_id)
async with session_scope() as session:
messages = list(await session.scalars(select(Message).where(Message.thread_id == tid)))
if not messages:
return
user_id = messages[0].user_id
account_ids = list({m.account_id for m in messages})
accounts = {
a.id: a
for a in await session.scalars(select(Account).where(Account.id.in_(account_ids)))
}
mailboxes = list(
await session.scalars(select(Mailbox).where(Mailbox.account_id.in_(account_ids)))
)
path_by_id = {mb.id: mb.path for mb in mailboxes}
target_by_account = {mb.account_id: mb for mb in mailboxes if mb.special_use == special_use}
target_id_path = {mb.account_id: (mb.id, mb.path) for mb in target_by_account.values()}
snapshot = [(m.id, m.account_id, m.mailbox_id, m.uid) for m in messages]
# (message_id, account_id, target_path, new_uid) applied after the network moves.
moves: list[tuple[uuid.UUID, uuid.UUID, str, int]] = []
to_create: list[tuple[uuid.UUID, str, int]] = [] # account_id, path, uidvalidity
unreachable: BaseException | None = None # set: retry the moves that did not happen
2026-07-17 23:20:34 +02:00
for account_id in account_ids:
provider = build_provider(accounts[account_id])
try:
try:
if account_id in target_id_path:
target_id, target_path = target_id_path[account_id]
else:
target_id, target_path = (
None,
_DEFAULT_FOLDER_NAME.get(special_use, special_use),
)
await provider.create_mailbox(target_path)
remote = await provider.list_mailboxes()
uidv = next((r.uidvalidity for r in remote if r.path == target_path), 0)
to_create.append((account_id, target_path, uidv))
for mid, m_account, m_mailbox, m_uid in snapshot:
if m_account != account_id or m_mailbox == target_id:
continue
src_path = path_by_id.get(m_mailbox, "INBOX")
try:
new_uid = await provider.move(m_uid, src_path, target_path)
except Exception as exc:
if is_temporary(exc):
raise # the server is gone: stop this account, retry it later
logger.exception("move failed for message %s -> %s", mid, target_path)
continue
moves.append((mid, account_id, target_path, new_uid))
except Exception as exc:
if not is_temporary(exc):
raise
unreachable = exc
2026-07-17 23:20:34 +02:00
finally:
await provider.close()
async with session_scope() as session:
created: dict[tuple[uuid.UUID, str], uuid.UUID] = {}
for account_id, path, uidv in to_create:
mailbox = Mailbox(
account_id=account_id,
user_id=user_id,
path=path,
special_use=special_use,
uidvalidity=uidv,
)
session.add(mailbox)
await session.flush()
created[(account_id, path)] = mailbox.id
for mid, account_id, target_path, new_uid in moves:
resolved = target_id_path.get(account_id, (None, ""))[0] or created.get(
(account_id, target_path)
)
message = await session.get(Message, mid)
if message is not None and resolved is not None:
# Remember where it came from so Restore returns it there, not the Inbox.
if special_use in _REMOVAL_FOLDERS and message.mailbox_id != resolved:
message.restore_mailbox_id = message.mailbox_id
2026-07-17 23:20:34 +02:00
message.mailbox_id = resolved
message.uid = new_uid
await get_event_bus().publish(
"thread.updated", {"user_id": str(user_id), "thread_id": str(tid)}
)
# What moved is recorded; the rest goes again once the server is back (the
# re-run skips messages already in the target folder).
if unreachable is not None and (retry := _retry_later(ctx, unreachable)) is not None:
raise retry from unreachable
2026-07-17 23:20:34 +02:00
async def restore_thread_job(ctx: dict[str, Any], thread_id: str) -> None:
"""Move every message in a thread back to its original folder (Restore action).
Each message returns to ``restore_mailbox_id`` (the folder it was in before it
was archived/trashed) — e.g. a deleted Sent message goes back to Sent. Messages
with no recorded origin fall back to the account's Inbox. Only messages in
Archive, Trash or Spam move: the thread's others (your reply in Sent) stay.
"""
tid = uuid.UUID(thread_id)
async with session_scope() as session:
messages = list(await session.scalars(select(Message).where(Message.thread_id == tid)))
if not messages:
return
user_id = messages[0].user_id
account_ids = list({m.account_id for m in messages})
accounts = {
a.id: a
for a in await session.scalars(select(Account).where(Account.id.in_(account_ids)))
}
mailboxes = list(
await session.scalars(select(Mailbox).where(Mailbox.account_id.in_(account_ids)))
)
path_by_id = {mb.id: mb.path for mb in mailboxes}
removal_ids = {mb.id for mb in mailboxes if mb.special_use in _REMOVAL_FOLDERS}
inbox_by_account = {mb.account_id: mb.id for mb in mailboxes if mb.special_use == "inbox"}
# (message_id, account_id, source_path, target_id) — resolve the target now.
plan: list[tuple[uuid.UUID, uuid.UUID, str, uuid.UUID, int]] = []
for m in messages:
if m.mailbox_id not in removal_ids:
continue # never removed: nothing to restore
target_id = m.restore_mailbox_id
if target_id is None or target_id not in path_by_id:
target_id = inbox_by_account.get(m.account_id)
if target_id is None or target_id == m.mailbox_id:
continue # nowhere to restore to, or already there
plan.append(
(m.id, m.account_id, path_by_id.get(m.mailbox_id, "INBOX"), target_id, m.uid)
)
# (message_id, target_id, new_uid) applied after the network moves.
moves: list[tuple[uuid.UUID, uuid.UUID, int]] = []
unreachable: BaseException | None = None
for account_id in account_ids:
provider = build_provider(accounts[account_id])
try:
for mid, m_account, src_path, target_id, uid in plan:
if m_account != account_id:
continue
target_path = path_by_id[target_id]
try:
new_uid = await provider.move(uid, src_path, target_path)
except Exception as exc:
if is_temporary(exc):
unreachable = exc # the server is gone: stop this account
break
logger.exception("restore move failed for message %s -> %s", mid, target_path)
continue
moves.append((mid, target_id, new_uid))
finally:
await provider.close()
async with session_scope() as session:
for mid, target_id, new_uid in moves:
message = await session.get(Message, mid)
if message is not None:
message.mailbox_id = target_id
message.uid = new_uid
message.restore_mailbox_id = None
await get_event_bus().publish(
"thread.updated", {"user_id": str(user_id), "thread_id": str(tid)}
)
if unreachable is not None and (retry := _retry_later(ctx, unreachable)) is not None:
raise retry from unreachable
2026-07-17 23:20:34 +02:00
async def delete_thread_job(ctx: dict[str, Any], thread_id: str) -> None:
"""Permanently delete every message in a thread (server EXPUNGE + DB rows).
Used by the Trash folder's Delete action. Best-effort per message on the
server; the DB rows (and cascaded attachments/body parts) are always removed.
"""
tid = uuid.UUID(thread_id)
async with session_scope() as session:
messages = list(await session.scalars(select(Message).where(Message.thread_id == tid)))
if not messages:
return
user_id = messages[0].user_id
account_ids = list({m.account_id for m in messages})
accounts = {
a.id: a
for a in await session.scalars(select(Account).where(Account.id.in_(account_ids)))
}
mailboxes = list(
await session.scalars(select(Mailbox).where(Mailbox.account_id.in_(account_ids)))
)
path_by_id = {mb.id: mb.path for mb in mailboxes}
# (account_id, mailbox_path) -> [uids] to expunge on the server.
by_box: dict[tuple[uuid.UUID, str], list[int]] = {}
for m in messages:
by_box.setdefault((m.account_id, path_by_id.get(m.mailbox_id, "INBOX")), []).append(
m.uid
)
for account_id in account_ids:
provider = build_provider(accounts[account_id])
try:
for (acct, path), uids in by_box.items():
if acct != account_id:
continue
try:
await provider.delete_messages(path, uids)
except Exception as exc:
# Keep the rows while the server is away: deleted here but
# not there, the next sync would bring the messages back.
if (retry := _retry_later(ctx, exc)) is not None:
raise retry from exc
logger.warning("expunge failed for %s in %s: %s", uids, path, exc)
2026-07-17 23:20:34 +02:00
finally:
await provider.close()
async with session_scope() as session:
msgs = list(await session.scalars(select(Message).where(Message.thread_id == tid)))
msg_ids = [m.id for m in msgs]
# Drop draft references to the thread/messages (FKs lack ON DELETE CASCADE).
if msg_ids:
await session.execute(
update(Draft)
.where(Draft.reply_to_message_id.in_(msg_ids))
.values(reply_to_message_id=None)
)
await session.execute(update(Draft).where(Draft.thread_id == tid).values(thread_id=None))
2026-07-17 23:20:34 +02:00
for m in msgs:
await session.delete(m)
thread = await session.get(Thread, tid)
if thread is not None:
await session.delete(thread)
await get_event_bus().publish(
"thread.updated", {"user_id": str(user_id), "thread_id": str(tid)}
)
async def empty_folder_job(ctx: dict[str, Any], user_id: str, special_use: str) -> None:
"""Permanently delete every message in the user's folder of this special-use.
Backs the Trash/Spam/Archive "Empty" action: EXPUNGE on the server, drop the DB
rows, and re-denormalize (or delete) affected threads.
"""
uid = uuid.UUID(user_id)
async with session_scope() as session:
mailboxes = list(
await session.scalars(
select(Mailbox).where(Mailbox.user_id == uid, Mailbox.special_use == special_use)
)
)
if not mailboxes:
return
mb_ids = [mb.id for mb in mailboxes]
path_by_id = {mb.id: mb.path for mb in mailboxes}
messages = list(
await session.scalars(
select(Message).where(Message.user_id == uid, Message.mailbox_id.in_(mb_ids))
)
)
if not messages:
return
account_ids = list({m.account_id for m in messages})
accounts = {
a.id: a
for a in await session.scalars(select(Account).where(Account.id.in_(account_ids)))
}
thread_ids = list({m.thread_id for m in messages if m.thread_id is not None})
by_box: dict[tuple[uuid.UUID, str], list[int]] = {}
for m in messages:
by_box.setdefault((m.account_id, path_by_id[m.mailbox_id]), []).append(m.uid)
for account_id in account_ids:
provider = build_provider(accounts[account_id])
try:
for (acct, path), uids in by_box.items():
if acct != account_id:
continue
try:
await provider.delete_messages(path, uids)
except Exception as exc:
# As for a thread: keep the rows until the server has them gone.
if (retry := _retry_later(ctx, exc)) is not None:
raise retry from exc
logger.warning("empty folder: expunge failed for %s in %s: %s", uids, path, exc)
finally:
await provider.close()
async with session_scope() as session:
msgs = list(
await session.scalars(
select(Message).where(Message.user_id == uid, Message.mailbox_id.in_(mb_ids))
)
)
msg_ids = [m.id for m in msgs]
if msg_ids:
await session.execute(
update(Draft)
.where(Draft.reply_to_message_id.in_(msg_ids))
.values(reply_to_message_id=None)
)
for m in msgs:
await session.delete(m)
await session.flush()
# Threads with no messages left get dropped (drafts un-linked first); the
# rest are re-denormalized so their counts/snippet reflect the removal.
for tid in sorted(thread_ids):
remaining = await session.scalar(
select(func.count()).select_from(Message).where(Message.thread_id == tid)
)
if not remaining:
await session.execute(
update(Draft).where(Draft.thread_id == tid).values(thread_id=None)
)
await denormalize_thread(session, tid)
await get_event_bus().publish("message.deleted", {"user_id": str(uid)})
async def autopurge_trash_spam(ctx: dict[str, Any]) -> None:
"""Cron: permanently delete Trash/Spam messages older than the retention window.
Same mechanics as :func:`empty_folder_job` (server EXPUNGE, drop rows, re-denorm
threads) but scoped to messages whose internal date predates
``TRASH_RETENTION_DAYS`` — across every user/account. A no-op when retention is 0.
"""
days = get_settings().trash_retention_days
if days <= 0:
return
cutoff = datetime.now(UTC) - timedelta(days=days)
async with session_scope() as session:
stale = (
await session.execute(
select(
Message.id,
Message.user_id,
Message.account_id,
Message.uid,
Message.thread_id,
Mailbox.path,
)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(Mailbox.special_use.in_(_REMOVAL_FOLDERS), Message.internal_date < cutoff)
)
).all()
if not stale:
return
account_ids = {r.account_id for r in stale}
accounts = {
a.id: a
for a in await session.scalars(select(Account).where(Account.id.in_(account_ids)))
}
# Server-side EXPUNGE, grouped by (account, mailbox path).
by_box: dict[tuple[uuid.UUID, str], list[int]] = defaultdict(list)
for r in stale:
by_box[(r.account_id, r.path)].append(r.uid)
for account_id in account_ids:
account = accounts.get(account_id)
if account is None:
continue
provider = build_provider(account)
try:
for (acct, path), uids in by_box.items():
if acct != account_id:
continue
try:
await provider.delete_messages(path, uids)
except Exception:
logger.exception("autopurge: expunge failed for %s in %s", uids, path)
finally:
await provider.close()
# Drop the local rows and re-denormalize (or delete) affected threads.
msg_ids = [r.id for r in stale]
thread_ids = {r.thread_id for r in stale if r.thread_id is not None}
user_ids = {r.user_id for r in stale}
async with session_scope() as session:
await session.execute(
update(Draft)
.where(Draft.reply_to_message_id.in_(msg_ids))
.values(reply_to_message_id=None)
)
await session.execute(delete(Message).where(Message.id.in_(msg_ids)))
await session.flush()
for tid in sorted(thread_ids):
remaining = await session.scalar(
select(func.count()).select_from(Message).where(Message.thread_id == tid)
)
if not remaining:
await session.execute(
update(Draft).where(Draft.thread_id == tid).values(thread_id=None)
)
await denormalize_thread(session, tid)
bus = get_event_bus()
for user_id in user_ids:
await bus.publish("message.deleted", {"user_id": str(user_id)})
logger.info("autopurge: removed %d Trash/Spam message(s) older than %dd", len(msg_ids), days)