Initial commit: webmail client (M0/M1)
Three-pane webmail client. - Backend: FastAPI + async SQLAlchemy + arq worker (Postgres/Redis), IMAP/SMTP sync, threading, search, drafts/outbox with undo, attachments, 2FA. - Frontend: React 18 + Vite + TypeScript + TanStack Query + Tailwind; three-pane UI with rich-text composer, light/dark themes, resizable panels. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Sync engine: bootstrap, backfill, live IDLE/poll loops (SPEC §6.3)."""
|
||||
@@ -0,0 +1,287 @@
|
||||
"""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. On provider failure the job retries (arq, ×5 exponential) and, on
|
||||
final failure, reverts the DB change and emits ``flags.changed`` with
|
||||
``reverted=true`` so the UI can roll back.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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.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"})
|
||||
_MAX_TRIES = 5
|
||||
|
||||
# Folder names to create when a target special-use folder is missing on the server.
|
||||
_DEFAULT_FOLDER_NAME = {"archive": "Archive", "trash": "Trash", "junk": "Spam"}
|
||||
|
||||
|
||||
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 thread_ids:
|
||||
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
|
||||
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:
|
||||
if ctx.get("job_try", 1) >= _MAX_TRIES:
|
||||
await _revert_flags(mid, user_id, add, remove)
|
||||
return
|
||||
raise # arq retries with backoff
|
||||
finally:
|
||||
await provider.close()
|
||||
|
||||
|
||||
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:
|
||||
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
|
||||
|
||||
provider = build_provider(account)
|
||||
try:
|
||||
new_uid = await provider.move(uid, src_path, dst_path)
|
||||
except Exception:
|
||||
if ctx.get("job_try", 1) >= _MAX_TRIES:
|
||||
logger.warning("move failed for message %s; leaving optimistic state", mid)
|
||||
return
|
||||
raise
|
||||
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",
|
||||
{"user_id": str(user_id), "message_id": str(mid), "mailbox_id": str(dst_id)},
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
for account_id in account_ids:
|
||||
provider = build_provider(accounts[account_id])
|
||||
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:
|
||||
logger.exception("move failed for message %s -> %s", mid, target_path)
|
||||
continue
|
||||
moves.append((mid, account_id, target_path, new_uid))
|
||||
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:
|
||||
message.mailbox_id = resolved
|
||||
message.uid = new_uid
|
||||
|
||||
await get_event_bus().publish(
|
||||
"thread.updated", {"user_id": str(user_id), "thread_id": str(tid)}
|
||||
)
|
||||
|
||||
|
||||
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:
|
||||
logger.exception("expunge failed for %s in %s", uids, path)
|
||||
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)
|
||||
)
|
||||
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)}
|
||||
)
|
||||
@@ -0,0 +1,276 @@
|
||||
"""Sync engine: bootstrap mailboxes, backfill, and the live IDLE/poll loop (SPEC §6.3)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.events import get_event_bus
|
||||
from app.indexer.indexer import PersistResult, persist_message
|
||||
from app.models import Account, Mailbox, Message
|
||||
from app.providers.base import MailboxDelta, ProviderNotSupported
|
||||
from app.providers.imap_smtp import ImapSmtpProvider
|
||||
from app.search.factory import get_search_backend
|
||||
from app.sync.folder_names import infer_special_use
|
||||
from app.sync.provider_factory import build_provider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Backfill priority: inbox, then sent/drafts, then everything else (SPEC §6.3).
|
||||
_PRIORITY = {"inbox": 0, "sent": 1, "drafts": 2}
|
||||
|
||||
|
||||
async def _load_account(session: AsyncSession, account_id: uuid.UUID) -> Account:
|
||||
account = await session.get(Account, account_id)
|
||||
if account is None:
|
||||
raise ValueError(f"account {account_id} not found")
|
||||
return account
|
||||
|
||||
|
||||
async def _set_state(account_id: uuid.UUID, state: str, detail: str = "") -> None:
|
||||
from app.db import session_scope
|
||||
|
||||
async with session_scope() as session:
|
||||
await session.execute(
|
||||
update(Account).where(Account.id == account_id).values(state=state, state_detail=detail)
|
||||
)
|
||||
bus = get_event_bus()
|
||||
account = None
|
||||
async with session_scope() as session:
|
||||
account = await session.get(Account, account_id)
|
||||
if account is not None:
|
||||
await bus.publish(
|
||||
"account.state_changed",
|
||||
{"user_id": str(account.user_id), "account_id": str(account_id), "state": state},
|
||||
)
|
||||
|
||||
|
||||
async def bootstrap_mailboxes(
|
||||
session: AsyncSession, provider: ImapSmtpProvider, account: Account
|
||||
) -> list[Mailbox]:
|
||||
"""List remote mailboxes and upsert local ``mailboxes`` rows (SPEC §6.3)."""
|
||||
remote = await provider.list_mailboxes()
|
||||
result: list[Mailbox] = []
|
||||
for rm in remote:
|
||||
special = rm.special_use or infer_special_use(rm.path)
|
||||
existing = await session.scalar(
|
||||
select(Mailbox).where(Mailbox.account_id == account.id, Mailbox.path == rm.path)
|
||||
)
|
||||
if existing is None:
|
||||
mailbox = Mailbox(
|
||||
account_id=account.id,
|
||||
user_id=account.user_id,
|
||||
path=rm.path,
|
||||
special_use=special,
|
||||
uidvalidity=rm.uidvalidity,
|
||||
)
|
||||
session.add(mailbox)
|
||||
else:
|
||||
existing.special_use = special
|
||||
existing.uidvalidity = rm.uidvalidity
|
||||
mailbox = existing
|
||||
result.append(mailbox)
|
||||
await session.flush()
|
||||
return result
|
||||
|
||||
|
||||
def _mailbox_order(mailboxes: list[Mailbox]) -> list[Mailbox]:
|
||||
return sorted(mailboxes, key=lambda m: _PRIORITY.get(m.special_use or "", 99))
|
||||
|
||||
|
||||
async def _emit_batch(
|
||||
results: list[PersistResult], account: Account, mailbox_path: str, done: int
|
||||
) -> None:
|
||||
backend = get_search_backend()
|
||||
bus = get_event_bus()
|
||||
for r in results:
|
||||
await backend.index(r.search_doc)
|
||||
await bus.publish(
|
||||
"message.new",
|
||||
{
|
||||
"user_id": str(account.user_id),
|
||||
"account_id": str(account.id),
|
||||
"message_id": str(r.message.id),
|
||||
"thread_id": str(r.message.thread_id),
|
||||
},
|
||||
)
|
||||
await bus.publish(
|
||||
"sync.progress",
|
||||
{
|
||||
"user_id": str(account.user_id),
|
||||
"account_id": str(account.id),
|
||||
"mailbox": mailbox_path,
|
||||
"done": done,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def backfill_mailbox(provider: ImapSmtpProvider, account: Account, mailbox: Mailbox) -> int:
|
||||
"""Backfill one mailbox newest-first, indexing inline per batch (FR-30)."""
|
||||
from app.db import session_scope
|
||||
|
||||
account_id, user_id, mailbox_id = account.id, account.user_id, mailbox.id
|
||||
special_use, path = mailbox.special_use, mailbox.path
|
||||
done = 0
|
||||
async for batch in provider.backfill(path, batch_size=200):
|
||||
results: list[PersistResult] = []
|
||||
max_uid = 0
|
||||
async with session_scope() as session:
|
||||
for remote in batch:
|
||||
r = await persist_message(
|
||||
session,
|
||||
user_id=user_id,
|
||||
account_id=account_id,
|
||||
mailbox_id=mailbox_id,
|
||||
mailbox_special_use=special_use,
|
||||
remote=remote,
|
||||
)
|
||||
if r is not None:
|
||||
results.append(r)
|
||||
max_uid = max(max_uid, remote.uid)
|
||||
await session.execute(
|
||||
update(Mailbox)
|
||||
.where(Mailbox.id == mailbox_id, Mailbox.last_seen_uid < max_uid)
|
||||
.values(last_seen_uid=max_uid)
|
||||
)
|
||||
done += len(results)
|
||||
await _emit_batch(results, account, path, done)
|
||||
return done
|
||||
|
||||
|
||||
async def bootstrap_and_backfill(account_id: uuid.UUID) -> int:
|
||||
"""Full initial sync for an account: mailboxes + backfill; sets state=live."""
|
||||
from app.db import session_scope
|
||||
|
||||
async with session_scope() as session:
|
||||
account = await _load_account(session, account_id)
|
||||
provider = build_provider(account)
|
||||
mailboxes = _mailbox_order(await bootstrap_mailboxes(session, provider, account))
|
||||
# Detach: reuse account/mailboxes values after the session closes.
|
||||
account_obj = account
|
||||
mailbox_list = list(mailboxes)
|
||||
|
||||
total = 0
|
||||
try:
|
||||
for mailbox in mailbox_list:
|
||||
total += await backfill_mailbox(provider, account_obj, mailbox)
|
||||
await _set_state(account_id, "live")
|
||||
except Exception as exc: # noqa: BLE001 - surface provider errors as account state
|
||||
logger.exception("backfill failed for account %s", account_id)
|
||||
await _set_state(account_id, "error", str(exc)[:500])
|
||||
raise
|
||||
finally:
|
||||
await provider.close()
|
||||
return total
|
||||
|
||||
|
||||
async def apply_delta(
|
||||
provider: ImapSmtpProvider, account: Account, mailbox: Mailbox, delta: MailboxDelta
|
||||
) -> None:
|
||||
"""Apply a poll delta: new messages, flag changes, uidvalidity resets (SPEC §6.3)."""
|
||||
from app.db import session_scope
|
||||
|
||||
bus = get_event_bus()
|
||||
|
||||
if delta.new_uidvalidity is not None:
|
||||
# UIDVALIDITY changed: wipe this mailbox's rows and re-backfill (SPEC §6.3).
|
||||
async with session_scope() as session:
|
||||
await session.execute(delete(Message).where(Message.mailbox_id == mailbox.id))
|
||||
await session.execute(
|
||||
update(Mailbox)
|
||||
.where(Mailbox.id == mailbox.id)
|
||||
.values(uidvalidity=delta.new_uidvalidity, last_seen_uid=0)
|
||||
)
|
||||
await backfill_mailbox(provider, account, mailbox)
|
||||
return
|
||||
|
||||
results: list[PersistResult] = []
|
||||
max_uid = mailbox.last_seen_uid
|
||||
async with session_scope() as session:
|
||||
for remote in delta.new:
|
||||
r = await persist_message(
|
||||
session,
|
||||
user_id=account.user_id,
|
||||
account_id=account.id,
|
||||
mailbox_id=mailbox.id,
|
||||
mailbox_special_use=mailbox.special_use,
|
||||
remote=remote,
|
||||
)
|
||||
if r is not None:
|
||||
results.append(r)
|
||||
max_uid = max(max_uid, remote.uid)
|
||||
for change in delta.flag_changes:
|
||||
await session.execute(
|
||||
update(Message)
|
||||
.where(Message.mailbox_id == mailbox.id, Message.uid == change.uid)
|
||||
.values(flags=sorted(change.flags))
|
||||
)
|
||||
if delta.expunged_uids:
|
||||
await session.execute(
|
||||
delete(Message).where(
|
||||
Message.mailbox_id == mailbox.id, Message.uid.in_(delta.expunged_uids)
|
||||
)
|
||||
)
|
||||
updates = {"last_seen_uid": max_uid}
|
||||
if delta.new_highestmodseq is not None:
|
||||
updates["highestmodseq"] = delta.new_highestmodseq
|
||||
await session.execute(update(Mailbox).where(Mailbox.id == mailbox.id).values(**updates))
|
||||
|
||||
await _emit_batch(results, account, mailbox.path, len(results))
|
||||
for change in delta.flag_changes:
|
||||
await bus.publish(
|
||||
"flags.changed",
|
||||
{
|
||||
"user_id": str(account.user_id),
|
||||
"account_id": str(account.id),
|
||||
"mailbox": mailbox.path,
|
||||
"uid": change.uid,
|
||||
"flags": sorted(change.flags),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def live_loop(
|
||||
account_id: uuid.UUID, mailbox_path: str = "INBOX", stop: asyncio.Event | None = None
|
||||
) -> None:
|
||||
"""Maintain a live IDLE/poll loop for one mailbox (SPEC §6.3)."""
|
||||
from app.db import session_scope
|
||||
|
||||
stop = stop or asyncio.Event()
|
||||
poll_interval = get_settings().poll_interval
|
||||
|
||||
async with session_scope() as session:
|
||||
account = await _load_account(session, account_id)
|
||||
account_obj = account
|
||||
|
||||
provider = build_provider(account_obj)
|
||||
try:
|
||||
while not stop.is_set():
|
||||
try:
|
||||
await provider.idle(mailbox_path)
|
||||
except ProviderNotSupported:
|
||||
await asyncio.sleep(poll_interval)
|
||||
if stop.is_set():
|
||||
break
|
||||
async with session_scope() as session:
|
||||
mailbox = await session.scalar(
|
||||
select(Mailbox).where(
|
||||
Mailbox.account_id == account_id, Mailbox.path == mailbox_path
|
||||
)
|
||||
)
|
||||
mailbox_obj = mailbox
|
||||
if mailbox_obj is None:
|
||||
break
|
||||
delta = await provider.poll(
|
||||
mailbox_path,
|
||||
last_seen_uid=mailbox_obj.last_seen_uid,
|
||||
highestmodseq=mailbox_obj.highestmodseq,
|
||||
)
|
||||
await apply_delta(provider, account_obj, mailbox_obj, delta)
|
||||
finally:
|
||||
await provider.close()
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Special-use inference from folder names (SPEC §6.3 fallback heuristics).
|
||||
|
||||
Used only when the server does not advertise SPECIAL-USE. Includes Polish names
|
||||
(Wysłane, Kosz, ...) alongside English/German, per the spec's i18n intent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
_NAME_MAP: dict[str, str] = {
|
||||
# sent
|
||||
"sent": "sent",
|
||||
"sent items": "sent",
|
||||
"sent mail": "sent",
|
||||
"wysłane": "sent",
|
||||
"wyslane": "sent",
|
||||
"elementy wysłane": "sent",
|
||||
"gesendet": "sent",
|
||||
# drafts
|
||||
"drafts": "drafts",
|
||||
"draft": "drafts",
|
||||
"robocze": "drafts",
|
||||
"kopie robocze": "drafts",
|
||||
"entwürfe": "drafts",
|
||||
# trash
|
||||
"trash": "trash",
|
||||
"deleted": "trash",
|
||||
"deleted items": "trash",
|
||||
"kosz": "trash",
|
||||
"papierkorb": "trash",
|
||||
# junk
|
||||
"junk": "junk",
|
||||
"spam": "junk",
|
||||
"bulk mail": "junk",
|
||||
"wiadomości-śmieci": "junk",
|
||||
# archive
|
||||
"archive": "archive",
|
||||
"archiwum": "archive",
|
||||
"all mail": "archive",
|
||||
"archiv": "archive",
|
||||
}
|
||||
|
||||
|
||||
def infer_special_use(path: str) -> str | None:
|
||||
"""Infer a special_use value from a mailbox path/name (case-insensitive)."""
|
||||
if path.upper() == "INBOX":
|
||||
return "inbox"
|
||||
leaf = path.replace("\\", "/").split("/")[-1].strip().lower()
|
||||
return _NAME_MAP.get(leaf) or _NAME_MAP.get(path.strip().lower())
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Build a MailProvider from an account row (SPEC §10.2 decryption boundary).
|
||||
|
||||
This module runs only inside the worker/sync context; ``decrypt_secret`` (the
|
||||
credential vault) is invoked here to obtain the plaintext mailbox password. It
|
||||
must never be imported by API request code.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.models import Account
|
||||
from app.providers.imap_smtp import ImapSmtpProvider, ProviderConfig
|
||||
from app.security.crypto import decrypt_secret
|
||||
|
||||
|
||||
def build_provider(account: Account) -> ImapSmtpProvider:
|
||||
"""Construct an :class:`ImapSmtpProvider` for ``account`` (password auth, M0)."""
|
||||
imap = account.imap_config
|
||||
smtp = account.smtp_config
|
||||
password = decrypt_secret(account.secret_ct).decode("utf-8")
|
||||
config = ProviderConfig(
|
||||
imap_host=str(imap["host"]),
|
||||
imap_port=int(str(imap["port"])),
|
||||
imap_tls=str(imap.get("tls", "ssl")),
|
||||
smtp_host=str(smtp["host"]),
|
||||
smtp_port=int(str(smtp["port"])),
|
||||
smtp_tls=str(smtp.get("tls", "ssl")),
|
||||
username=account.email,
|
||||
password=password,
|
||||
)
|
||||
return ImapSmtpProvider(config)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Live-sync supervisor (SPEC §6.3).
|
||||
|
||||
Runs inside the arq worker: on startup it launches one IDLE/poll loop per live
|
||||
account so new mail and flag changes flow to the UI over WebSocket. Loops
|
||||
self-heal (reconnect with backoff) and are cancelled on worker shutdown.
|
||||
|
||||
NOTE(spec): accounts that become live *after* worker startup are picked up on the
|
||||
next worker restart; reacting to ``account.state_changed`` live is a later refinement.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db import session_scope
|
||||
from app.models import Account
|
||||
from app.sync.engine import live_loop
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_RECONNECT_BACKOFF = 30.0
|
||||
|
||||
|
||||
async def _run_account(account_id: uuid.UUID, stop: asyncio.Event) -> None:
|
||||
while not stop.is_set():
|
||||
try:
|
||||
await live_loop(account_id, "INBOX", stop)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("live loop for account %s crashed; reconnecting", account_id)
|
||||
with contextlib.suppress(TimeoutError):
|
||||
await asyncio.wait_for(stop.wait(), timeout=_RECONNECT_BACKOFF)
|
||||
|
||||
|
||||
async def start_supervisor(ctx: dict[str, Any]) -> None:
|
||||
"""Launch a live loop per live account (called from the worker on_startup)."""
|
||||
async with session_scope() as session:
|
||||
account_ids = list(await session.scalars(select(Account.id).where(Account.state == "live")))
|
||||
stop = asyncio.Event()
|
||||
ctx["supervisor_stop"] = stop
|
||||
ctx["supervisor_tasks"] = [asyncio.create_task(_run_account(aid, stop)) for aid in account_ids]
|
||||
logger.info("live supervisor started for %d account(s)", len(account_ids))
|
||||
|
||||
|
||||
async def stop_supervisor(ctx: dict[str, Any]) -> None:
|
||||
"""Signal and cancel all live loops (called from the worker on_shutdown)."""
|
||||
stop = ctx.get("supervisor_stop")
|
||||
if stop is not None:
|
||||
stop.set()
|
||||
tasks = ctx.get("supervisor_tasks", [])
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
Reference in New Issue
Block a user