Compare commits

...
13 Commits
Author SHA1 Message Date
lukasandClaude Opus 4.8 293945480b sync-v2 §10.7: fix +- sign in RSS-growth output (use :+.1f)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 11:59:35 +02:00
lukasandClaude Opus 4.8 3d962a183c sync-v2 §10.7: mail-injecting mode for the soak harness
Optional SOAK_MAIL=1 makes the mock deliver synthetic messages (unique UIDs,
well-formed RFC822) on INBOX polls, so the full parse -> blob -> DB -> index path
actually runs and CPU/RSS move under real throughput. Tunables: SOAK_MAIL_PER_POLL,
SOAK_MSG_KB, SOAK_BIG_MSG_PCT (percent above STREAM_PARSE_MB -> streaming/§8 path).
Blob store + search backend are isolated (temp BLOB_ROOT, SEARCH_BACKEND=pg) and
torn down. Output gains msgs + msg/s columns and a delivered/peak-RSS summary.

RSS check is now a trend (early-third vs late-third mean, not min-to-max) so it
detects a real leak without flaking on transient large-message parse spikes.

Validated: mail run delivers ~4.2 msg/s (incl. 5% 10MB messages); RSS oscillates
123-267MB but its mean is flat (+3.7% over the run) -> no leak, parse memory
bound holds (§8); tier re-promotion on new mail exercised; all §10.7 checks pass.
Full gate green (238 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 09:28:48 +02:00
lukasandClaude Opus 4.8 ee4dd99ad1 sync-v2 §10.7: soak stability — even per-worker cap + zombie diagnostic
- Size MAX_ACCOUNTS_PER_WORKER to ~1.2x the fair share so the fleet fills each
  worker evenly at startup and the rebalancer barely sheds; without this, three
  coordinators racing for the whole fleet shed/reclaim in a thrash that reset
  reclaimed accounts' first-poll clocks and dragged the sync-lag ramp.
- Sample a "dead" count (owned runners whose work loops have stopped) to
  distinguish a real zombie-runner bug from a slow ramp. Validated: dead=0
  throughout; 400-account run holds lag ~48s under a 52s budget across 13 steady
  samples, persistent sockets == hot, RSS flat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 08:25:03 +02:00
lukasandClaude Opus 4.8 887ba7ade6 sync-v2 §10.7: mock-provider soak/load harness + poll jitter
- loadtest/soak_sync.py + mock_provider.py: fully isolated soak for acceptance
  criterion 7. Creates a throwaway webmail_soak DB + uses Redis DB 14 (both torn
  down), simulates N accounts (default 1000, 20% active sessions) across 3
  in-process coordinators with a mock MailProvider (no real IMAP). Asserts:
  all accounts owned, persistent sockets <= hot*1.05, oldest lag < POLL_COLD*1.15,
  RSS stable. Steady state = after every account has completed its first sync.
  Configurable via SOAK_* / POLL_* env for fast smoke runs.
- engine: poll_all_loop and secondary_poll_loop now jitter their waits (first at a
  random point in the interval, later +/-10%) so a fleet claimed together doesn't
  poll in lockstep — anti-thundering-herd, a real improvement surfaced by the soak.

Validated: 60-account smoke passes all four criteria; full gate green (238 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 08:09:44 +02:00
lukasandClaude Opus 4.8 8405e2f9b7 sync-v2: queue-isolation routing test (acceptance §10.5)
Assert bulk backfills route to the bulk queue and interactive user actions to
the interactive queue, and that enqueue_job stamps _queue_name accordingly — the
mechanism that keeps a backfill burst from starving sends/flag changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 07:20:59 +02:00
lukasandClaude Opus 4.8 6d83f7cde8 sync-v2 step 7: observability + graceful shutdown (§7)
- Heartbeat JSON now carries {owned, sockets_open, tiers:{hot,warm,cold}, ts} per
  §2 so peers and operators can see each worker's load and socket footprint.
- run.py runs the sync coordinator directly (not via a placeholder arq worker) so
  SIGINT/SIGTERM can stop the claimer, cancel runners, and release every lease
  explicitly within a 10s drain budget — peers adopt the accounts immediately
  instead of waiting out the lease TTL. Removed the sync:control-queue hack and
  the supervisor indirection (start/stop_supervisor, sync_on_startup/shutdown).
- Acceptance: graceful-shutdown test proves a peer adopts released accounts within
  seconds under a 30s TTL (i.e. via release, not expiry).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 07:18:19 +02:00
lukasandClaude Opus 4.8 9dbc5ed998 sync-v2 step 6: memory + connection bounds (§7, §4)
- concurrency.loop_semaphore: per-event-loop semaphores (safe across pytest loops
  and process restarts via a WeakKeyDictionary keyed by the running loop).
- provider: holds a per-account (≤4) and per-host (PER_HOST_CONN_CAP) slot for the
  whole lifetime of an IMAP socket, released on close; SMTP send takes a per-host
  slot for its duration. A worker can no longer open hundreds of sockets to one
  host/account.
- indexer: parsing goes through a PARSE_CONCURRENCY semaphore, and a message over
  STREAM_PARSE_MB is parsed from its blob file (parse_message_file) instead of an
  in-memory tree, so peak RAM ~= PARSE_CONCURRENCY x STREAM_PARSE_MB regardless of
  mailbox sizes. Backfill parallelism stays bounded by the bulk queue only.
- config: PER_HOST_CONN_CAP, PARSE_CONCURRENCY, STREAM_PARSE_MB.
- tests: per-account cap blocks the 5th connection / distinct accounts independent;
  file-vs-bytes parse equivalence + large-message streaming routing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 07:13:03 +02:00
lukasandClaude Opus 4.8 19335116da sync-v2 step 5: activity tiers (hot/warm/cold)
Each owned account is tiered by the coordinator; the runner shapes its work to
match — correctness identical, only latency/socket cost differ.

- hot: persistent INBOX IDLE + secondary poll every POLL_WARM.
- warm: no persistent socket; transient INBOX+special poll every POLL_WARM.
- cold: same transient poll every POLL_COLD.
- engine: poll_all_loop (transient full poll); secondary_poll_loop interval is
  now a parameter; POLL_INTERVAL removed in favor of POLL_WARM/POLL_COLD.
- coordinator: tier eval every 120s from session heat (sess:active:{user}) +
  recent mail (max internal_date within TIER_HOT_ACTIVITY_H), with the
  HOT_BUDGET_PER_WORKER guard shedding least-recently-active session-less hot
  accounts; user.active event promotes a user's accounts to hot at once (≤5s).
- API: login + WS connect/ping publish user.active and refresh sess:active
  (TTL TIER_SESSION_GRACE_MIN). New sync:tier:{id} key, default warm on claim.
- config: POLL_WARM/POLL_COLD/TIER_*/HOT_BUDGET_PER_WORKER.
- tests: 5 tier tests (session/recent-mail promotion, cold, user.active,
  budget demotion) alongside the lease/lifecycle acceptance tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 07:03:54 +02:00
lukasandClaude Opus 4.8 245672d3f2 sync-v2 step 4: event-driven account lifecycle (§3)
- Coordinator subscribes to account.state_changed over Redis pub/sub: going
  live marks the account claimable at once; disabled/deleted/error tears the
  owning runner down immediately (≤30s) instead of waiting for the lease TTL.
- delete_account now publishes account.state_changed:deleted after dropping the
  account from sync:live, so teardown is event-driven, not just fencing.
- AccountRunner gains a verify loop (defense in depth): it periodically confirms
  the account is still live and flags itself dead if not; the coordinator reaps
  dead runners and releases their lease. Zombie loops are structurally impossible.
- New core event user.active (SPEC §8.3), published on login and on every WS
  connect; consumed for tier promotion in step 5.
- Acceptance: added event-driven teardown test (isolated from fencing/verify).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 06:49:48 +02:00
lukasandClaude Opus 4.8 bcf11fcecb sync-v2 step 3: lease-owned account claimer (replaces startup supervisor)
Ownership of an account now moves through a Redis lease so any number of sync
workers can run against the same account set with no double-IDLE.

- leases.py: sync:live registry + sync:lease:{id} (SET NX EX) with owner-checked
  Lua renew/release; mark_live / mark_not_live helpers.
- coordinator.py: SyncCoordinator claims from sync:live up to MAX_ACCOUNTS_PER_
  WORKER, renews every LEASE_RENEW (fences the runner on lease loss), reconciles
  sync:live against Postgres, heartbeats a worker key, and rebalances when it
  holds well above a fair share.
- supervisor start/stop now drive a coordinator; engine._set_state and
  delete_account maintain sync:live membership (+ lease drop = fencing teardown).
- config: MAX_ACCOUNTS_PER_WORKER, LEASE_TTL, LEASE_RENEW.
- tests: acceptance 1-3 (single ownership, crash failover, create/delete
  lifecycle) driving the real coordinator + Redis with faked IMAP loops.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 20:34:22 +02:00
lukasandClaude Opus 4.8 23704f323c sync-v2 step 2: extract AccountRunner
Move the per-account INBOX-IDLE + secondary-poll loops out of the supervisor
into a self-contained AccountRunner (start/stop/teardown). The startup supervisor
now launches one runner per live account. Pure refactor, no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 19:56:06 +02:00
lukasandClaude Opus 4.8 e0a45c5a46 sync-v2 step 1: worker roles + interactive/bulk queue split
- config: WORKER_ROLE (sync|jobs|all), INTERACTIVE_CONCURRENCY, BULK_CONCURRENCY.
- queue: named queues (interactive/bulk) + enqueue_job() router; sync_bootstrap
  goes to bulk, all other jobs to interactive.
- workers.run: single entrypoint; role=jobs runs both arq consumers (per-queue
  timeout/concurrency), role=sync runs the supervisor, all=both (dev default).
- docker-compose: worker-sync + worker-jobs services (shared image, nofile
  ulimit); backend/Dockerfile; Makefile worker/worker-sync/worker-jobs targets.
- tests repointed to app.queue.get_arq_pool; supervisor behavior unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 19:53:31 +02:00
lukasandClaude Opus 4.8 9cb946f75d docs: add sync-engine v2 refactor spec and spec_v2
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 19:42:05 +02:00
41 changed files with 3153 additions and 229 deletions
+6 -2
View File
@@ -9,8 +9,12 @@ reset-db: ## DESTRUCTIVE: wipe db + search + blobs, re-migrate
cd backend && alembic upgrade head
api:
uvicorn app.main:create_app --factory --reload --port 8000 --app-dir backend
worker:
cd backend && arq workers.worker.WorkerSettings
worker: ## run both sync + jobs in one process (dev)
cd backend && WORKER_ROLE=all python -m workers.run
worker-sync: ## run only the account-sync supervisor
cd backend && WORKER_ROLE=sync python -m workers.run
worker-jobs: ## run only the arq job consumers (interactive + bulk)
cd backend && WORKER_ROLE=jobs python -m workers.run
web:
cd frontend && npm run dev
test:
+16
View File
@@ -0,0 +1,16 @@
# Shared image for the worker roles (refactor §1). Built from backend/.
FROM python:3.11-slim
WORKDIR /app
# Install deps first for layer caching.
COPY pyproject.toml ./
COPY app ./app
COPY workers ./workers
COPY migrations ./migrations
COPY alembic.ini ./alembic.ini
RUN pip install --no-cache-dir .
# Default to the combined role; docker-compose overrides WORKER_ROLE per service.
ENV WORKER_ROLE=all
CMD ["python", "-m", "workers.run"]
+14
View File
@@ -80,6 +80,20 @@ async def delete_account(
await session.execute(delete(Account).where(Account.id == account_id))
await session.flush()
# Deregister from sync:live and drop any lease so the owning sync worker
# stops the account's runner on its next renewal (refactor §2 fencing).
from app.events import get_event_bus
from app.redis_client import get_redis
from app.sync import leases
await leases.mark_not_live(get_redis(), account_id)
# Sync v2 §3: signal the owning worker to tear the runner down now (≤30s),
# rather than waiting for the lease to lapse.
await get_event_bus().publish(
"account.state_changed",
{"user_id": str(user_id), "account_id": str(account_id), "state": "deleted"},
)
# Content-addressed blobs are shared across the user's accounts (dedup): only
# remove a blob when no remaining message/attachment references it.
for sha in candidate_shas:
+4 -7
View File
@@ -30,7 +30,7 @@ from app.api.schemas import AccountOut
from app.db import get_session
from app.identity.deps import CurrentUser, require_csrf
from app.models import Account
from app.queue import get_arq_pool
from app.queue import enqueue_job
from app.redis_client import get_redis
from app.security.crypto import encrypt_secret
@@ -91,9 +91,8 @@ async def list_accounts(user: CurrentUser, db: DbSession) -> list[AccountOut]:
async def sync_all_accounts(user: CurrentUser, db: DbSession) -> dict[str, str]:
"""Poll every folder of each of the user's accounts (manual Refresh)."""
account_ids = list(await db.scalars(select(Account.id).where(Account.user_id == user.id)))
pool = await get_arq_pool()
for account_id in account_ids:
await pool.enqueue_job("refresh_account_job", str(account_id))
await enqueue_job("refresh_account_job", str(account_id))
return {"status": "queued"}
@@ -145,8 +144,7 @@ async def create_new_account(
raise_api_error(409, "account_exists", "an account for this email already exists")
await db.commit()
pool = await get_arq_pool()
await pool.enqueue_job("sync_bootstrap_account", str(account.id))
await enqueue_job("sync_bootstrap_account", str(account.id))
return _detail(account)
@@ -227,8 +225,7 @@ async def update_account(
await db.commit()
if conn_change:
pool = await get_arq_pool()
await pool.enqueue_job("sync_bootstrap_account", str(account.id))
await enqueue_job("sync_bootstrap_account", str(account.id))
return _detail(account)
+5 -9
View File
@@ -15,7 +15,7 @@ from app.db import get_session
from app.events import get_event_bus
from app.identity.deps import CurrentUser, require_csrf
from app.models import Mailbox, Message, Thread
from app.queue import get_arq_pool
from app.queue import enqueue_job
from app.sync.actions import apply_flags_to_messages, sanitize_flags
router = APIRouter(tags=["mail"], dependencies=[Depends(require_csrf)])
@@ -26,10 +26,9 @@ DbSession = Annotated[AsyncSession, Depends(get_session)]
async def _push_and_publish(
user_id: uuid.UUID, changed: list[Message], add: frozenset[str], remove: frozenset[str]
) -> None:
pool = await get_arq_pool()
bus = get_event_bus()
for message in changed:
await pool.enqueue_job("push_flags_job", str(message.id), sorted(add), sorted(remove))
await enqueue_job("push_flags_job", str(message.id), sorted(add), sorted(remove))
await bus.publish(
"flags.changed",
{
@@ -95,8 +94,7 @@ async def _move_thread(
)
if thread is None:
raise_api_error(404, "not_found", "thread not found")
pool = await get_arq_pool()
await pool.enqueue_job("move_thread_to_folder_job", str(thread_id), special)
await enqueue_job("move_thread_to_folder_job", str(thread_id), special)
@router.post("/threads/{thread_id}/archive")
@@ -126,8 +124,7 @@ async def delete_thread(thread_id: uuid.UUID, user: CurrentUser, db: DbSession)
)
if thread is None:
raise_api_error(404, "not_found", "thread not found")
pool = await get_arq_pool()
await pool.enqueue_job("delete_thread_job", str(thread_id))
await enqueue_job("delete_thread_job", str(thread_id))
return {"status": "queued"}
@@ -154,6 +151,5 @@ async def move_message(
message.mailbox_id = dst.id # optimistic
await db.commit()
pool = await get_arq_pool()
await pool.enqueue_job("move_job", str(message.id), str(dst.id), str(src_mailbox_id))
await enqueue_job("move_job", str(message.id), str(dst.id), str(src_mailbox_id))
return {"id": str(message.id), "mailbox_id": str(dst.id)}
+5 -10
View File
@@ -25,7 +25,7 @@ from app.outbox.schemas import (
SendResponse,
)
from app.outbox.service import DraftError, cancel_outbox, create_outbox, upsert_draft
from app.queue import get_arq_pool
from app.queue import enqueue_job
router = APIRouter(tags=["compose"], dependencies=[Depends(require_csrf)])
@@ -155,9 +155,7 @@ async def list_remote_drafts(user: CurrentUser, db: DbSession) -> list[RemoteDra
@router.post("/drafts/adopt/{message_id}", response_model=DraftOut)
async def adopt_remote_draft(
message_id: uuid.UUID, user: CurrentUser, db: DbSession
) -> DraftOut:
async def adopt_remote_draft(message_id: uuid.UUID, user: CurrentUser, db: DbSession) -> DraftOut:
"""Turn a Drafts-folder message into an editable local draft (linked by UID)."""
msg = await db.scalar(
select(Message)
@@ -235,8 +233,7 @@ async def publish_draft(draft_id: uuid.UUID, user: CurrentUser, db: DbSession) -
draft = await db.scalar(select(Draft).where(Draft.id == draft_id, Draft.user_id == user.id))
if draft is None:
raise_api_error(404, "not_found", "draft not found")
pool = await get_arq_pool()
await pool.enqueue_job("push_draft_to_imap_job", str(draft_id))
await enqueue_job("push_draft_to_imap_job", str(draft_id))
return {"status": "queued"}
@@ -250,8 +247,7 @@ async def delete_draft(draft_id: uuid.UUID, user: CurrentUser, db: DbSession) ->
await db.commit()
# Remove the mirrored copy from the server Drafts folder too.
if imap_uid:
pool = await get_arq_pool()
await pool.enqueue_job("remove_imap_draft_job", str(account_id), imap_uid)
await enqueue_job("remove_imap_draft_job", str(account_id), imap_uid)
return {"status": "deleted"}
@@ -265,8 +261,7 @@ async def send(body: SendRequest, user: CurrentUser, db: DbSession) -> SendRespo
outbox = await create_outbox(db, user_id=user.id, draft=draft)
await db.commit()
pool = await get_arq_pool()
await pool.enqueue_job(
await enqueue_job(
"send_job",
str(outbox.id),
_defer_by=timedelta(seconds=get_settings().undo_seconds),
+32
View File
@@ -0,0 +1,32 @@
"""Per-event-loop semaphores for bounded concurrency (refactor §4, §7).
Connection and parse caps are process-local but must be bound to the running
event loop: an :class:`asyncio.Semaphore` created on one loop cannot be awaited
on another (a real hazard under pytest, which uses a fresh loop per test). These
helpers hand back a semaphore keyed by ``(group, key)`` for the current loop,
creating it on first use and dropping the whole set when the loop is collected.
"""
from __future__ import annotations
import asyncio
import weakref
# group -> {loop: {key: Semaphore}}. WeakKeyDictionary drops a loop's semaphores
# once the loop is gone, so no state leaks across tests or restarts.
_registry: dict[
str, weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, dict[str, asyncio.Semaphore]]
] = {}
def loop_semaphore(group: str, key: str, cap: int) -> asyncio.Semaphore:
"""Return the current loop's semaphore for ``(group, key)`` (cap on first use)."""
loop = asyncio.get_running_loop()
per_group = _registry.setdefault(group, weakref.WeakKeyDictionary())
per_loop = per_group.get(loop)
if per_loop is None:
per_loop = per_group[loop] = {}
sem = per_loop.get(key)
if sem is None:
sem = per_loop[key] = asyncio.Semaphore(cap)
return sem
+22 -1
View File
@@ -29,11 +29,32 @@ class Settings(BaseSettings):
meili_key: str = Field(default="", alias="MEILI_KEY")
undo_seconds: int = Field(default=10, alias="UNDO_SECONDS")
poll_interval: int = Field(default=120, alias="POLL_INTERVAL")
session_ttl_days: int = Field(default=30, alias="SESSION_TTL_DAYS")
max_attachment_mb: int = Field(default=25, alias="MAX_ATTACHMENT_MB")
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
# --- Sync engine v2 (workers layer) --------------------------------------
# Worker role: "sync" (lease-owned account runners) | "jobs" (arq consumer)
# | "all" (both, single-process dev default).
worker_role: str = Field(default="all", alias="WORKER_ROLE")
interactive_concurrency: int = Field(default=20, alias="INTERACTIVE_CONCURRENCY")
bulk_concurrency: int = Field(default=2, alias="BULK_CONCURRENCY")
# Lease protocol (§2): account ownership across sync workers.
max_accounts_per_worker: int = Field(default=400, alias="MAX_ACCOUNTS_PER_WORKER")
lease_ttl: int = Field(default=60, alias="LEASE_TTL")
lease_renew: int = Field(default=20, alias="LEASE_RENEW")
# Activity tiers (§5): hot = persistent IDLE, warm/cold = transient polls.
poll_warm: int = Field(default=180, alias="POLL_WARM")
poll_cold: int = Field(default=900, alias="POLL_COLD")
tier_hot_activity_h: int = Field(default=24, alias="TIER_HOT_ACTIVITY_H")
tier_session_grace_min: int = Field(default=30, alias="TIER_SESSION_GRACE_MIN")
tier_cold_after_d: int = Field(default=3, alias="TIER_COLD_AFTER_D")
hot_budget_per_worker: int = Field(default=250, alias="HOT_BUDGET_PER_WORKER")
# Memory/connection bounds (§7, §4): peak RAM ~= PARSE_CONCURRENCY x STREAM_PARSE_MB.
per_host_conn_cap: int = Field(default=50, alias="PER_HOST_CONN_CAP")
parse_concurrency: int = Field(default=4, alias="PARSE_CONCURRENCY")
stream_parse_mb: int = Field(default=8, alias="STREAM_PARSE_MB")
oauth_google_client_id: str = Field(default="", alias="OAUTH_GOOGLE_CLIENT_ID")
oauth_google_client_secret: str = Field(default="", alias="OAUTH_GOOGLE_CLIENT_SECRET")
oauth_ms_client_id: str = Field(default="", alias="OAUTH_MS_CLIENT_ID")
+3
View File
@@ -38,6 +38,9 @@ ALLOWED_EVENTS: frozenset[str] = frozenset(
"account.state_changed",
"draft.saved",
"outbox.state_changed",
# Sync v2 §3: published on login and every WS connect; drives tier
# promotion in the sync workers (§5). Payload: {user_id}.
"user.active",
}
)
+8
View File
@@ -12,6 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import raise_api_error
from app.config import get_settings
from app.db import get_session
from app.events import get_event_bus
from app.identity import totp as totp_mod
from app.identity.cookies import (
clear_auth_cookies,
@@ -50,6 +51,8 @@ from app.identity.sessions import (
revoke_session,
)
from app.models import User
from app.redis_client import get_redis
from app.sync import leases
router = APIRouter(prefix="/auth", tags=["auth"])
@@ -116,6 +119,11 @@ async def login(body: LoginRequest, request: Request, response: Response, db: Db
if user is None:
raise_api_error(401, "invalid_credentials", "incorrect handle or password")
await _establish_session(request, response, user)
# Sync v2 §3/§5: a login marks the user active (session heat) and promotes
# their accounts' tier to hot.
settings = get_settings()
await leases.mark_user_active(get_redis(), user.id, settings.tier_session_grace_min * 60)
await get_event_bus().publish("user.active", {"user_id": str(user.id)})
return _user_out(user)
+19 -2
View File
@@ -17,8 +17,10 @@ from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.concurrency import loop_semaphore
from app.config import get_settings
from app.indexer import blobstore
from app.indexer.mime import ParsedEmail, parse_message
from app.indexer.mime import ParsedEmail, parse_message, parse_message_file
from app.indexer.sanitize import sanitize_html
from app.models import Attachment, BodyPart, KnownRecipient, Message
from app.providers.base import RemoteMessage
@@ -115,6 +117,21 @@ async def _resolve_message_id(
return parsed_id
async def _parse_bounded(user_id: uuid.UUID, sha: str, raw: bytes) -> ParsedEmail:
"""Parse under the PARSE_CONCURRENCY cap; stream large messages from disk (§7).
Peak RAM stays ~PARSE_CONCURRENCY x STREAM_PARSE_MB: only a few parses run at
once, and a message above the threshold is parsed from its (already written)
blob file instead of being held as an in-memory tree beside its raw bytes.
"""
settings = get_settings()
threshold = settings.stream_parse_mb * 1024 * 1024
async with loop_semaphore("mime_parse", "parse", settings.parse_concurrency):
if len(raw) > threshold:
return parse_message_file(blobstore.blob_path(user_id, sha))
return parse_message(raw)
async def persist_message(
session: AsyncSession,
*,
@@ -132,7 +149,7 @@ async def persist_message(
return None
sha = blobstore.store(user_id, remote.raw)
parsed = parse_message(remote.raw)
parsed = await _parse_bounded(user_id, sha, remote.raw)
message_id_hdr = await _resolve_message_id(
session, user_id=user_id, account_id=account_id, parsed_id=parsed.message_id, raw=remote.raw
+13 -1
View File
@@ -7,6 +7,7 @@ Header RFC2047 decoding is automatic under ``policy.default``.
from __future__ import annotations
import email
import os
from dataclasses import dataclass, field
from datetime import datetime
from email.headerregistry import AddressHeader
@@ -104,10 +105,21 @@ def _extract_attachments(msg: EmailMessage) -> list[ParsedAttachment]:
def parse_message(raw: bytes) -> ParsedEmail:
"""Parse a raw RFC822 message into a :class:`ParsedEmail`."""
"""Parse a raw RFC822 message (in memory) into a :class:`ParsedEmail`."""
msg = email.message_from_bytes(raw, policy=default_policy)
assert isinstance(msg, EmailMessage)
return _parse_email(msg)
def parse_message_file(path: str | os.PathLike[str]) -> ParsedEmail:
"""Parse a message straight from a blob file handle (large-message path, §7)."""
with open(path, "rb") as handle:
msg = email.message_from_binary_file(handle, policy=default_policy)
assert isinstance(msg, EmailMessage)
return _parse_email(msg)
def _parse_email(msg: EmailMessage) -> ParsedEmail:
message_id = normalize_message_id(msg.get("Message-ID"))
in_reply_to_ids = normalize_reference_list(msg.get("In-Reply-To"))
in_reply_to = in_reply_to_ids[0] if in_reply_to_ids else None
+2 -6
View File
@@ -34,9 +34,7 @@ async def _ensure_drafts_folder(provider: Any, account: Account, user_id: uuid.U
uidvalidity = next((r.uidvalidity for r in remote if r.path == path), 0)
async with session_scope() as session:
existing = await session.scalar(
select(Mailbox).where(
Mailbox.account_id == account.id, Mailbox.special_use == "drafts"
)
select(Mailbox).where(Mailbox.account_id == account.id, Mailbox.special_use == "drafts")
)
if existing is not None:
return existing.path
@@ -72,9 +70,7 @@ async def push_draft_to_imap_job(ctx: dict[str, Any], draft_id: str) -> None:
attachments = await load_attachments(session, user_id, draft)
_, sent_raw, _, _ = build_mime(account, draft, reply_to, attachments)
drafts_mb = await session.scalar(
select(Mailbox).where(
Mailbox.account_id == account.id, Mailbox.special_use == "drafts"
)
select(Mailbox).where(Mailbox.account_id == account.id, Mailbox.special_use == "drafts")
)
drafts_path = drafts_mb.path if drafts_mb else None
account_obj = account
+3 -9
View File
@@ -52,9 +52,7 @@ async def _harvest_recipients(session: AsyncSession, user_id: uuid.UUID, emails:
await session.execute(stmt)
async def _persist_sent_copy(
user_id: uuid.UUID, account: Account, raw: bytes, uid: int
) -> None:
async def _persist_sent_copy(user_id: uuid.UUID, account: Account, raw: bytes, uid: int) -> None:
"""Store the just-sent message in the DB (Sent mailbox) and notify the UI.
No-op without a UIDPLUS append UID (uid <= 0); in that case a future Sent sync
@@ -64,9 +62,7 @@ async def _persist_sent_copy(
return
async with session_scope() as session:
sent_mb = await session.scalar(
select(Mailbox).where(
Mailbox.account_id == account.id, Mailbox.special_use == "sent"
)
select(Mailbox).where(Mailbox.account_id == account.id, Mailbox.special_use == "sent")
)
if sent_mb is None:
return
@@ -99,9 +95,7 @@ async def _persist_sent_copy(
},
)
if thread_id is not None:
await bus.publish(
"thread.updated", {"user_id": str(user_id), "thread_id": str(thread_id)}
)
await bus.publish("thread.updated", {"user_id": str(user_id), "thread_id": str(thread_id)})
async def send_job(ctx: dict[str, Any], outbox_id: str) -> None:
+1 -1
View File
@@ -85,7 +85,7 @@ class MailProvider(Protocol):
async def idle(self, mailbox: str) -> None:
"""Blocks until the server signals change or 25 min elapse (re-issue IDLE). May raise
ProviderNotSupported => sync loop falls back to poll() every POLL_INTERVAL (default 120 s).
ProviderNotSupported => sync loop falls back to poll() every POLL_WARM (default 180 s).
"""
...
+68 -31
View File
@@ -17,6 +17,8 @@ from dataclasses import dataclass
import aiosmtplib
from aioimaplib import aioimaplib
from app.concurrency import loop_semaphore
from app.config import get_settings
from app.providers.base import (
FlagChange,
MailboxDelta,
@@ -37,6 +39,7 @@ from app.providers.imap_smtp.parsing import (
_FETCH_ITEMS = "(UID FLAGS INTERNALDATE RFC822.SIZE BODY.PEEK[])"
_IDLE_TIMEOUT = 25 * 60 # SPEC §5.1: re-issue IDLE at least every 25 min
_CONNECT_TIMEOUT = 20
_PER_ACCOUNT_CONN_CAP = 4 # SPEC §6.3 / §4: IDLE + transient + short-lived jobs
_APPENDUID_RE = re.compile(rb"APPENDUID\s+\d+\s+(\d+)")
_COPYUID_RE = re.compile(rb"COPYUID\s+\d+\s+\S+\s+(\d+)")
@@ -59,34 +62,65 @@ class ImapSmtpProvider(MailProvider):
self._imap: aioimaplib.IMAP4 | None = None
self._selected: str | None = None
self._uidvalidity_seen: dict[str, int] = {}
self._conn_slots: list[asyncio.Semaphore] = []
# --- connection lifecycle ------------------------------------------------
async def _acquire_conn_slots(self) -> None:
"""Bound simultaneous open sockets: ≤4 per account and ≤cap per host (§4).
Held for the connection's whole lifetime and released in :meth:`close`.
"""
cfg = self._cfg
account = loop_semaphore(
"imap_account", f"{cfg.username}@{cfg.imap_host}", _PER_ACCOUNT_CONN_CAP
)
host = loop_semaphore("imap_host", cfg.imap_host, get_settings().per_host_conn_cap)
await account.acquire() # consistent order (account, then host) avoids deadlock
try:
await host.acquire()
except BaseException:
account.release()
raise
self._conn_slots = [host, account]
def _release_conn_slots(self) -> None:
for sem in self._conn_slots:
sem.release()
self._conn_slots = []
async def _connect(self) -> aioimaplib.IMAP4:
if self._imap is not None:
return self._imap
cfg = self._cfg
await self._acquire_conn_slots()
try:
if cfg.imap_tls == "ssl":
imap = aioimaplib.IMAP4_SSL(
host=cfg.imap_host, port=cfg.imap_port, timeout=_CONNECT_TIMEOUT
)
else:
imap = aioimaplib.IMAP4(
host=cfg.imap_host, port=cfg.imap_port, timeout=_CONNECT_TIMEOUT
)
await imap.wait_hello_from_server()
if cfg.imap_tls == "starttls":
await imap.starttls()
except (OSError, TimeoutError) as exc:
raise ProviderConnectError(f"cannot connect to {cfg.imap_host}: {exc}") from exc
try:
if cfg.imap_tls == "ssl":
imap = aioimaplib.IMAP4_SSL(
host=cfg.imap_host, port=cfg.imap_port, timeout=_CONNECT_TIMEOUT
)
else:
imap = aioimaplib.IMAP4(
host=cfg.imap_host, port=cfg.imap_port, timeout=_CONNECT_TIMEOUT
)
await imap.wait_hello_from_server()
if cfg.imap_tls == "starttls":
await imap.starttls()
except (OSError, TimeoutError) as exc:
raise ProviderConnectError(f"cannot connect to {cfg.imap_host}: {exc}") from exc
resp = await imap.login(cfg.username, cfg.password)
if resp.result != "OK":
detail = (
b" ".join(resp.lines).decode("utf-8", "ignore") if resp.lines else "login failed"
)
raise ProviderAuthError(detail)
resp = await imap.login(cfg.username, cfg.password)
if resp.result != "OK":
detail = (
b" ".join(resp.lines).decode("utf-8", "ignore")
if resp.lines
else "login failed"
)
raise ProviderAuthError(detail)
except BaseException:
self._release_conn_slots() # never hold a slot without an open socket
raise
self._imap = imap
return imap
@@ -104,6 +138,7 @@ class ImapSmtpProvider(MailProvider):
await self._imap.logout()
self._imap = None
self._selected = None
self._release_conn_slots() # free the per-account / per-host slots
# --- MailProvider protocol ----------------------------------------------
@@ -293,19 +328,21 @@ class ImapSmtpProvider(MailProvider):
cfg = self._cfg
use_tls = cfg.smtp_tls == "ssl"
start_tls = cfg.smtp_tls == "starttls"
host_slot = loop_semaphore("smtp_host", cfg.smtp_host, get_settings().per_host_conn_cap)
try:
await aiosmtplib.send(
raw,
sender=envelope_from,
recipients=envelope_to,
hostname=cfg.smtp_host,
port=cfg.smtp_port,
username=cfg.username,
password=cfg.password,
use_tls=use_tls,
start_tls=start_tls,
timeout=_CONNECT_TIMEOUT,
)
async with host_slot: # bound simultaneous sockets to the SMTP host (§4)
await aiosmtplib.send(
raw,
sender=envelope_from,
recipients=envelope_to,
hostname=cfg.smtp_host,
port=cfg.smtp_port,
username=cfg.username,
password=cfg.password,
use_tls=use_tls,
start_tls=start_tls,
timeout=_CONNECT_TIMEOUT,
)
except aiosmtplib.SMTPAuthenticationError as exc:
raise ProviderAuthError(f"SMTP auth failed: {exc}") from exc
except aiosmtplib.SMTPException as exc:
+27 -1
View File
@@ -1,14 +1,33 @@
"""arq queue helpers (enqueue side, used by the API)."""
"""arq queue helpers (enqueue side, used by the API).
Two named queues (refactor §6): ``interactive`` (fast user actions) and ``bulk``
(backfills). Enqueue via :func:`enqueue_job`, which routes each job to its queue —
the API never needs to know queue names.
"""
from __future__ import annotations
from typing import Any
from arq import create_pool
from arq.connections import ArqRedis, RedisSettings
from arq.jobs import Job
from app.config import get_settings
_pool: ArqRedis | None = None
QUEUE_INTERACTIVE = "interactive"
QUEUE_BULK = "bulk"
# Jobs default to the interactive queue; only bulk backfills go to the bulk queue.
_BULK_JOBS = frozenset({"sync_bootstrap_account"})
def queue_for(job_name: str) -> str:
"""Return the queue a job should run on."""
return QUEUE_BULK if job_name in _BULK_JOBS else QUEUE_INTERACTIVE
def redis_settings() -> RedisSettings:
"""Return arq RedisSettings derived from ``REDIS_URL``."""
@@ -23,6 +42,13 @@ async def get_arq_pool() -> ArqRedis:
return _pool
async def enqueue_job(name: str, *args: Any, **kwargs: Any) -> Job | None:
"""Enqueue ``name`` onto its routed queue (interactive/bulk)."""
pool = await get_arq_pool()
kwargs.setdefault("_queue_name", queue_for(name))
return await pool.enqueue_job(name, *args, **kwargs)
async def close_arq_pool() -> None:
"""Close the arq pool."""
global _pool
+1 -3
View File
@@ -273,9 +273,7 @@ async def delete_thread_job(ctx: dict[str, Any], thread_id: str) -> None:
.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)
)
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)
+362
View File
@@ -0,0 +1,362 @@
"""Sync coordinator: lease-owned account claimer (refactor §2, step 3).
Replaces the startup-only supervisor. Each sync worker runs one coordinator that:
* claims accounts from ``sync:live`` (up to ``MAX_ACCOUNTS_PER_WORKER``),
starting an :class:`AccountRunner` for each lease it wins;
* renews its leases every ``LEASE_RENEW`` seconds and, on losing one (a renewal
that returns 0), stops that account's runner immediately (fencing);
* reconciles ``sync:live`` against Postgres periodically as a safety net;
* heartbeats a worker key so peers can compute a fair share and rebalance.
All ownership is via :mod:`app.sync.leases`; two workers can never run the same
account because claims are ``SET NX`` and renew/release are owner-checked.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import logging
import os
import random
import secrets
import socket
import time
import uuid
from datetime import UTC, datetime, timedelta
from sqlalchemy import func, select
from app.config import get_settings
from app.db import session_scope
from app.events import channel_for
from app.models import Account, Message
from app.redis_client import aw, get_redis
from app.sync import leases
from app.sync.runner import AccountRunner
logger = logging.getLogger(__name__)
def _new_worker_id() -> str:
return f"{socket.gethostname()}:{os.getpid()}:{secrets.token_hex(4)}"
class SyncCoordinator:
"""Owns the leased account runners for a single sync worker."""
def __init__(self, worker_id: str | None = None) -> None:
self.worker_id = worker_id or _new_worker_id()
self._redis = get_redis()
self._scripts = leases.LeaseScripts(self._redis)
self._runners: dict[uuid.UUID, AccountRunner] = {}
self._stop = asyncio.Event()
self._tasks: list[asyncio.Task[None]] = []
s = get_settings()
self.lease_ttl = s.lease_ttl
self.lease_renew = s.lease_renew
self.max_accounts = s.max_accounts_per_worker
# Operational cadences (not operator-tunable; §2 protocol timings).
self.claim_interval = 10.0
self.claim_jitter = 5.0
self.reconcile_interval = 300.0
self.heartbeat_ttl = 30
self.heartbeat_refresh = 10.0
self.rebalance_interval = 60.0
self.tier_eval_interval = 120.0
self.session_grace_s = s.tier_session_grace_min * 60
self.hot_activity = timedelta(hours=s.tier_hot_activity_h)
self.cold_after = timedelta(days=s.tier_cold_after_d)
self.hot_budget = s.hot_budget_per_worker
# --- lifecycle -----------------------------------------------------------
async def start(self) -> None:
self._stop.clear()
await self._reconcile_once() # seed sync:live so the first claim isn't delayed
await self._heartbeat_once()
self._tasks = [
asyncio.create_task(
self._loop(self._claim_once, self.claim_interval, self.claim_jitter)
),
asyncio.create_task(self._loop(self._renew_once, self.lease_renew)),
asyncio.create_task(self._loop(self._reconcile_once, self.reconcile_interval)),
asyncio.create_task(self._loop(self._heartbeat_once, self.heartbeat_refresh)),
asyncio.create_task(self._loop(self._rebalance_once, self.rebalance_interval)),
asyncio.create_task(self._loop(self._evaluate_tiers_once, self.tier_eval_interval)),
asyncio.create_task(self._event_subscriber()),
]
logger.info("sync coordinator %s started", self.worker_id)
async def stop(self) -> None:
self._stop.set()
for task in self._tasks:
task.cancel()
if self._tasks:
await asyncio.gather(*self._tasks, return_exceptions=True)
self._tasks = []
# Release every lease so peers can pick the accounts up without waiting for TTL.
await asyncio.gather(
*(self._release(aid) for aid in list(self._runners)), return_exceptions=True
)
await self._redis.delete(leases.worker_key(self.worker_id))
logger.info("sync coordinator %s stopped", self.worker_id)
@property
def owned(self) -> set[uuid.UUID]:
return set(self._runners)
# --- periodic loop driver ------------------------------------------------
async def _loop(self, step: object, interval: float, jitter: float = 0.0) -> None:
while not self._stop.is_set():
delay = interval + random.uniform(0.0, jitter)
try:
await asyncio.wait_for(self._stop.wait(), timeout=delay)
return # stop requested
except TimeoutError:
pass
try:
await step() # type: ignore[operator]
except asyncio.CancelledError:
raise
except Exception:
logger.exception("coordinator step failed on worker %s", self.worker_id)
# --- claim / renew / release ---------------------------------------------
async def _claim_once(self) -> None:
if len(self._runners) >= self.max_accounts:
return
async for raw in self._redis.sscan_iter(leases.LIVE_SET):
if self._stop.is_set() or len(self._runners) >= self.max_accounts:
break
account_id = uuid.UUID(raw)
if account_id in self._runners:
continue
if await leases.claim(self._redis, account_id, self.worker_id, self.lease_ttl):
await self._start_runner(account_id)
async def _renew_once(self) -> None:
for account_id in list(self._runners):
runner = self._runners.get(account_id)
if runner is None:
continue
# Reap a runner that self-verified it is no longer live (§3).
if runner.dead.is_set():
logger.info("reaping dead runner for account %s", account_id)
await self._release(account_id)
continue
if not await self._scripts.renew(account_id, self.worker_id, self.lease_ttl):
logger.warning("lost lease for account %s; fencing runner", account_id)
await self._drop(account_id)
async def _release(self, account_id: uuid.UUID) -> None:
"""Graceful hand-off: stop the runner, then delete the lease if still ours."""
runner = self._runners.pop(account_id, None)
if runner is not None:
await runner.stop()
await self._scripts.release(account_id, self.worker_id)
async def _drop(self, account_id: uuid.UUID) -> None:
"""Fencing: stop the runner without touching the lease (we no longer own it)."""
runner = self._runners.pop(account_id, None)
if runner is not None:
await runner.stop()
async def _start_runner(self, account_id: uuid.UUID) -> None:
# Inherit any tier a previous owner recorded; default warm on first claim.
tier = await aw(self._redis.get(leases.tier_key(account_id))) or "warm"
await aw(self._redis.set(leases.tier_key(account_id), tier))
runner = AccountRunner(account_id, tier)
runner.start()
self._runners[account_id] = runner
# --- reconcile / heartbeat / rebalance -----------------------------------
async def _reconcile_once(self) -> None:
"""Make ``sync:live`` match the set of live accounts in Postgres.
Never touches leases — only set membership. Accounts that disappear here
stop being claimed; their runners are dropped when their lease is removed.
"""
async with session_scope() as session:
live_db = {
str(a)
for a in await session.scalars(select(Account.id).where(Account.state == "live"))
}
live_redis = set(await aw(self._redis.smembers(leases.LIVE_SET)))
to_add = live_db - live_redis
to_remove = live_redis - live_db
if to_add:
await aw(self._redis.sadd(leases.LIVE_SET, *to_add))
if to_remove:
await aw(self._redis.srem(leases.LIVE_SET, *to_remove))
# Stop any runners for accounts we own that are no longer live.
for raw in to_remove:
account_id = uuid.UUID(raw)
if account_id in self._runners:
await self._release(account_id)
async def _heartbeat_once(self) -> None:
"""Publish the worker's load for peer rebalancing + observability (§2)."""
tiers = {"hot": 0, "warm": 0, "cold": 0}
sockets_open = 0
for runner in self._runners.values():
tiers[runner.tier] = tiers.get(runner.tier, 0) + 1
if runner.tier == "hot":
sockets_open += 1 # one persistent IDLE connection per hot account
payload = json.dumps(
{
"owned": len(self._runners),
"sockets_open": sockets_open,
"tiers": tiers,
"ts": time.time(),
}
)
await self._redis.set(leases.worker_key(self.worker_id), payload, ex=self.heartbeat_ttl)
async def _alive_worker_count(self) -> int:
count = 0
async for _ in self._redis.scan_iter(match=f"{leases.WORKERS_PREFIX}*"):
count += 1
return max(count, 1)
async def _rebalance_once(self) -> None:
"""Shed accounts if we hold well above a fair share of the live total."""
total = int(await aw(self._redis.scard(leases.LIVE_SET)))
workers = await self._alive_worker_count()
target = total / workers
threshold = int(target * 1.2) + 5
excess = len(self._runners) - threshold
if excess <= 0:
return
# Release the newest claims first (least likely to be mid-backfill on us).
for account_id in list(self._runners)[-excess:]:
await self._release(account_id)
logger.info("worker %s shed %d account(s) to rebalance", self.worker_id, excess)
# --- event-driven lifecycle (§3) -----------------------------------------
async def _event_subscriber(self) -> None:
"""React to ``account.state_changed`` across processes (Redis pub/sub).
Going live makes an account claimable at once; going away tears the
runner down immediately (≤30s from the API call) instead of waiting for
the lease to lapse.
"""
pubsub = self._redis.pubsub()
state_ch = channel_for("account.state_changed")
active_ch = channel_for("user.active")
await pubsub.subscribe(state_ch, active_ch)
try:
async for message in pubsub.listen():
if self._stop.is_set():
break
if message.get("type") != "message":
continue
try:
payload = json.loads(message["data"]).get("payload", {})
except (ValueError, TypeError):
continue
if message.get("channel") == active_ch:
await self._on_user_active(payload)
else:
await self._on_state_changed(payload)
finally:
with contextlib.suppress(Exception):
await pubsub.aclose() # type: ignore[no-untyped-call]
async def _on_state_changed(self, payload: dict[str, object]) -> None:
raw = payload.get("account_id")
if not isinstance(raw, str):
return
account_id = uuid.UUID(raw)
if payload.get("state") == "live":
await leases.mark_live(self._redis, account_id) # claimable next cycle
elif account_id in self._runners:
await self._release(account_id) # disabled/deleted/error -> stop now
async def _on_user_active(self, payload: dict[str, object]) -> None:
"""Promote all this user's owned accounts to hot at once (≤5s, §5)."""
raw = payload.get("user_id")
if not isinstance(raw, str):
return
user_id = uuid.UUID(raw)
owned = list(self._runners)
if not owned:
return
async with session_scope() as session:
aids = list(
await session.scalars(
select(Account.id).where(Account.id.in_(owned), Account.user_id == user_id)
)
)
for account_id in aids:
await self._set_tier(account_id, "hot")
# --- tier evaluation (§5) ------------------------------------------------
async def _set_tier(self, account_id: uuid.UUID, tier: str) -> None:
runner = self._runners.get(account_id)
if runner is None:
return
await aw(self._redis.set(leases.tier_key(account_id), tier))
await runner.apply_tier(tier)
async def _account_activity(
self, ids: list[uuid.UUID]
) -> dict[uuid.UUID, tuple[uuid.UUID, datetime | None]]:
"""Return {account_id: (user_id, last_mail_at)} for the owned accounts."""
async with session_scope() as session:
rows = (
await session.execute(
select(Account.id, Account.user_id, func.max(Message.internal_date))
.select_from(Account)
.outerjoin(Message, Message.account_id == Account.id)
.where(Account.id.in_(ids))
.group_by(Account.id, Account.user_id)
)
).all()
return {row[0]: (row[1], row[2]) for row in rows}
async def _evaluate_tiers_once(self) -> None:
"""Recompute each owned account's tier from session heat + recent mail."""
owned = list(self._runners)
if not owned:
return
activity = await self._account_activity(owned)
now = datetime.now(UTC)
sessions: dict[uuid.UUID, bool] = {}
desired: dict[uuid.UUID, str] = {}
demotable: list[tuple[datetime, uuid.UUID]] = [] # activity-hot, no session
hot_count = 0
for account_id in owned:
user_id, last_mail = activity.get(account_id, (None, None))
session_active = False
if user_id is not None:
if user_id not in sessions:
sessions[user_id] = bool(
await aw(self._redis.exists(leases.session_key(user_id)))
)
session_active = sessions[user_id]
recent = last_mail is not None and (now - last_mail) < self.hot_activity
if session_active or recent:
desired[account_id] = "hot"
hot_count += 1
if not session_active and last_mail is not None:
demotable.append((last_mail, account_id))
elif last_mail is not None and (now - last_mail) < self.cold_after:
desired[account_id] = "warm"
else:
desired[account_id] = "cold"
# Global hot budget: shed the least-recently-active session-less hot accounts.
if hot_count > self.hot_budget:
demotable.sort(key=lambda item: item[0]) # oldest activity first
for _, account_id in demotable[: hot_count - self.hot_budget]:
desired[account_id] = "warm"
for account_id, tier in desired.items():
await self._set_tier(account_id, tier)
+49 -7
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
import contextlib
import logging
import random
import uuid
from typing import Any
@@ -41,6 +42,17 @@ async def _set_state(account_id: uuid.UUID, state: str, detail: str = "") -> Non
await session.execute(
update(Account).where(Account.id == account_id).values(state=state, state_detail=detail)
)
# Keep the sync:live registry in step with the account's state so the sync
# coordinator claims it once live and drops it otherwise (refactor §2).
from app.redis_client import get_redis
from app.sync import leases
redis = get_redis()
if state == "live":
await leases.mark_live(redis, account_id)
else:
await leases.mark_not_live(redis, account_id)
bus = get_event_bus()
account = None
async with session_scope() as session:
@@ -244,7 +256,7 @@ async def live_loop(
from app.db import session_scope
stop = stop or asyncio.Event()
poll_interval = get_settings().poll_interval
idle_fallback = get_settings().poll_warm
async with session_scope() as session:
account = await _load_account(session, account_id)
@@ -256,7 +268,7 @@ async def live_loop(
try:
await provider.idle(mailbox_path)
except ProviderNotSupported:
await asyncio.sleep(poll_interval)
await asyncio.sleep(idle_fallback)
if stop.is_set():
break
async with session_scope() as session:
@@ -330,19 +342,49 @@ async def refresh_account_job(ctx: dict[str, Any], account_id: str) -> None:
await poll_folders_once(uuid.UUID(account_id), include_inbox=True)
async def secondary_poll_loop(account_id: uuid.UUID, stop: asyncio.Event | None = None) -> None:
async def secondary_poll_loop(
account_id: uuid.UUID, stop: asyncio.Event | None = None, interval: float | None = None
) -> None:
"""Periodically CONDSTORE-poll non-INBOX folders (Sent/Drafts/Archive/Trash/Junk).
INBOX has its own IDLE loop (real-time). This keeps the other folders in step
with server-side changes (e.g. Gmail-filed sent mail) and changes made by other
clients. CONDSTORE/QRESYNC makes each pass a cheap MODSEQ delta.
clients. CONDSTORE/QRESYNC makes each pass a cheap MODSEQ delta. Used by hot-tier
runners alongside the INBOX IDLE loop.
"""
stop = stop or asyncio.Event()
interval = get_settings().poll_interval
period = interval if interval is not None else get_settings().poll_warm
first = True
while not stop.is_set():
# Wait first: the initial state was already covered by the backfill.
# Wait first (initial state was covered by backfill). Spread the fleet: the
# first wait is a random point in the period, later waits jitter ±10%, so
# accounts claimed together don't poll in lockstep (thundering herd).
wait = random.uniform(0, period) if first else period * random.uniform(0.9, 1.1)
first = False
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(stop.wait(), timeout=interval)
await asyncio.wait_for(stop.wait(), timeout=wait)
if stop.is_set():
break
await _poll_secondary_once(account_id)
async def poll_all_loop(
account_id: uuid.UUID, interval: float, stop: asyncio.Event | None = None
) -> None:
"""Transient CONDSTORE poll of INBOX + special folders every ``interval`` seconds.
The warm/cold tiers use this instead of a persistent IDLE connection: no socket
is held between passes. Correctness matches the hot tier — only latency differs.
"""
stop = stop or asyncio.Event()
first = True
while not stop.is_set():
# Spread the fleet: first poll at a random point in the interval, then
# jitter ±10% so a batch claimed together doesn't poll in lockstep.
wait = random.uniform(0, interval) if first else interval * random.uniform(0.9, 1.1)
first = False
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(stop.wait(), timeout=wait)
if stop.is_set():
break
await poll_folders_once(account_id, include_inbox=True)
+99
View File
@@ -0,0 +1,99 @@
"""Redis lease primitives for account ownership (refactor §2).
A sync worker must hold the lease ``sync:lease:{account_id}`` before it runs an
account's sync loops, and no two workers may hold the same lease. Claims are
atomic (``SET NX EX``); renewal and release are owner-checked Lua so a worker can
never renew or delete a lease that has been taken over by another worker.
The set ``sync:live`` is the registry of accounts that *should* be synced; the
claimer only ever claims ids drawn from it.
"""
from __future__ import annotations
import uuid
import redis.asyncio as aioredis
from app.redis_client import aw
LIVE_SET = "sync:live"
_LEASE_PREFIX = "sync:lease:"
WORKERS_PREFIX = "sync:workers:"
_TIER_PREFIX = "sync:tier:"
_SESSION_PREFIX = "sess:active:"
def lease_key(account_id: uuid.UUID) -> str:
return f"{_LEASE_PREFIX}{account_id}"
def worker_key(worker_id: str) -> str:
return f"{WORKERS_PREFIX}{worker_id}"
def tier_key(account_id: uuid.UUID) -> str:
return f"{_TIER_PREFIX}{account_id}"
def session_key(user_id: uuid.UUID | str) -> str:
"""Presence key the API refreshes while a user has an active session (§5)."""
return f"{_SESSION_PREFIX}{user_id}"
async def mark_user_active(redis: aioredis.Redis, user_id: uuid.UUID | str, grace_s: int) -> None:
"""Refresh the user's session-heat key; workers read it for tier promotion."""
await aw(redis.set(session_key(user_id), "1", ex=grace_s))
# Renew only if we still own the lease (guards against a stale worker extending a
# lease another worker has already claimed after expiry). Returns 1 on success.
_RENEW_LUA = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('EXPIRE', KEYS[1], ARGV[2])
else
return 0
end
"""
# Delete only if we still own the lease. Returns 1 if we released it, else 0.
_RELEASE_LUA = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
"""
class LeaseScripts:
"""Server-registered Lua for owner-checked renew/release."""
def __init__(self, redis: aioredis.Redis) -> None:
self._renew = redis.register_script(_RENEW_LUA)
self._release = redis.register_script(_RELEASE_LUA)
async def renew(self, account_id: uuid.UUID, worker_id: str, ttl: int) -> bool:
result = await self._renew(keys=[lease_key(account_id)], args=[worker_id, ttl])
return bool(result)
async def release(self, account_id: uuid.UUID, worker_id: str) -> bool:
result = await self._release(keys=[lease_key(account_id)], args=[worker_id])
return bool(result)
async def claim(redis: aioredis.Redis, account_id: uuid.UUID, worker_id: str, ttl: int) -> bool:
"""Atomically claim the lease for ``account_id``; True iff we now own it."""
ok = await redis.set(lease_key(account_id), worker_id, nx=True, ex=ttl)
return bool(ok)
async def mark_live(redis: aioredis.Redis, account_id: uuid.UUID) -> None:
"""Register an account as one that should be synced (idempotent)."""
await aw(redis.sadd(LIVE_SET, str(account_id)))
async def mark_not_live(redis: aioredis.Redis, account_id: uuid.UUID) -> None:
"""Deregister an account and drop any lease so its owner stops it (fencing)."""
await aw(redis.srem(LIVE_SET, str(account_id)))
await aw(redis.delete(lease_key(account_id)))
+141
View File
@@ -0,0 +1,141 @@
"""Per-account sync runner (refactor §4, §5).
An ``AccountRunner`` owns the long-lived sync work for a single account and
exposes a ``start`` / ``stop`` / ``apply_tier`` lifecycle. The task set is shaped
by the account's activity tier (§5):
* **hot** — a persistent INBOX IDLE loop plus a secondary CONDSTORE poll of the
special folders every ``POLL_WARM`` seconds.
* **warm** — no persistent socket; a single transient poll of INBOX + special
folders every ``POLL_WARM`` seconds.
* **cold** — the same single transient poll, every ``POLL_COLD`` seconds.
Correctness is identical across tiers — only latency and socket cost differ.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import uuid
from collections.abc import Awaitable, Callable
from sqlalchemy import select
from app.config import get_settings
from app.db import session_scope
from app.models import Account
from app.sync.engine import live_loop, poll_all_loop, secondary_poll_loop
logger = logging.getLogger(__name__)
_RECONNECT_BACKOFF = 30.0
_VERIFY_INTERVAL = 60.0
Tier = str # "hot" | "warm" | "cold"
class AccountRunner:
"""Owns the tier-shaped sync work for one account until stopped."""
def __init__(self, account_id: uuid.UUID, tier: Tier = "warm") -> None:
self.account_id = account_id
self.tier = tier
self._stop = asyncio.Event()
self._tasks: list[asyncio.Task[None]] = [] # tier-independent (verify)
self._work: list[asyncio.Task[None]] = [] # tier-shaped loops
# Set when the account is found no longer live (§3 defense in depth); the
# coordinator reaps such runners and releases their lease.
self.dead = asyncio.Event()
self.verify_interval = _VERIFY_INTERVAL
def start(self) -> None:
"""Launch the verify task and the current tier's work loops."""
self._stop.clear()
self._tasks = [asyncio.create_task(self._verify_loop())]
self._start_work()
def running(self) -> bool:
return any(not t.done() for t in (*self._tasks, *self._work))
async def stop(self) -> None:
"""Signal and cancel every loop, waiting for teardown."""
self._stop.set()
await self._cancel(self._work)
await self._cancel(self._tasks)
self._work = []
self._tasks = []
async def apply_tier(self, tier: Tier) -> None:
"""Reshape the work loops for a new tier (no-op if unchanged)."""
if tier == self.tier and self._work:
return
logger.info("account %s tier %s -> %s", self.account_id, self.tier, tier)
self.tier = tier
await self._cancel(self._work)
self._start_work()
# --- internals -----------------------------------------------------------
def _start_work(self) -> None:
settings = get_settings()
if self.tier == "hot":
self._work = [
asyncio.create_task(self._supervise(self._run_live, "live")),
asyncio.create_task(self._supervise(self._run_secondary, "secondary")),
]
else:
interval = float(settings.poll_warm if self.tier == "warm" else settings.poll_cold)
self._work = [
asyncio.create_task(self._supervise(lambda: self._run_poll(interval), "poll"))
]
async def _cancel(self, tasks: list[asyncio.Task[None]]) -> None:
for task in tasks:
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
async def _run_live(self) -> None:
await live_loop(self.account_id, "INBOX", self._stop)
async def _run_secondary(self) -> None:
await secondary_poll_loop(self.account_id, self._stop, get_settings().poll_warm)
async def _run_poll(self, interval: float) -> None:
await poll_all_loop(self.account_id, interval, self._stop)
async def _verify_loop(self) -> None:
"""Periodically confirm the account is still live; flag ``dead`` if not.
This makes zombie loops structurally impossible even if a lifecycle event
is lost: an account that was disabled/deleted stops being synced within a
verify cycle regardless of the lease/event paths.
"""
while not self._stop.is_set():
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(self._stop.wait(), timeout=self.verify_interval)
if self._stop.is_set():
return
if not await self._account_is_live():
logger.info("account %s no longer live; flagging runner dead", self.account_id)
self.dead.set()
return
async def _account_is_live(self) -> bool:
async with session_scope() as session:
state = await session.scalar(select(Account.state).where(Account.id == self.account_id))
return state == "live"
async def _supervise(self, run: Callable[[], Awaitable[None]], label: str) -> None:
"""Run a loop, restarting it (with backoff) until stopped or cancelled."""
while not self._stop.is_set():
try:
await run()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("%s loop for account %s crashed; retrying", label, self.account_id)
with contextlib.suppress(TimeoutError):
await asyncio.wait_for(self._stop.wait(), timeout=_RECONNECT_BACKOFF)
-77
View File
@@ -1,77 +0,0 @@
"""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, secondary_poll_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 _run_secondary(account_id: uuid.UUID, stop: asyncio.Event) -> None:
while not stop.is_set():
try:
await secondary_poll_loop(account_id, stop)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("secondary poll loop for %s crashed; retrying", 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
tasks: list[asyncio.Task[None]] = []
for aid in account_ids:
tasks.append(asyncio.create_task(_run_account(aid, stop))) # INBOX IDLE
tasks.append(asyncio.create_task(_run_secondary(aid, stop))) # other folders poll
ctx["supervisor_tasks"] = tasks
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)
+9
View File
@@ -13,9 +13,12 @@ import logging
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.config import get_settings
from app.events import get_event_bus
from app.identity.cookies import SESSION_COOKIE_INSECURE, SESSION_COOKIE_SECURE
from app.identity.sessions import resolve_session
from app.redis_client import get_redis
from app.sync import leases
logger = logging.getLogger(__name__)
@@ -34,6 +37,10 @@ async def ws_endpoint(websocket: WebSocket) -> None:
await websocket.accept()
user_id = str(session.user_id)
grace_s = get_settings().tier_session_grace_min * 60
# Sync v2 §3/§5: an open WS means the user is active -> session heat + promote.
await leases.mark_user_active(get_redis(), user_id, grace_s)
await get_event_bus().publish("user.active", {"user_id": user_id})
pubsub = get_redis().pubsub()
await pubsub.psubscribe("evt:*")
@@ -54,6 +61,8 @@ async def ws_endpoint(websocket: WebSocket) -> None:
raw = await websocket.receive_text()
try:
if json.loads(raw).get("type") == "ping":
# A ping keeps the user's session heat alive (§5).
await leases.mark_user_active(get_redis(), user_id, grace_s)
await websocket.send_json({"type": "pong"})
except (ValueError, TypeError):
continue
View File
+158
View File
@@ -0,0 +1,158 @@
"""In-process mock ``MailProvider`` for the sync soak (acceptance §10.7).
No sockets, no network: ``idle`` sleeps and returns "no change", ``poll`` returns
an empty delta. Two process-global counters make the soak's assertions meaningful
without real file descriptors:
* ``persistent_open`` — providers currently open that have issued an IDLE, i.e. the
hot-tier persistent connections. Warm/cold runners never call ``idle`` so they
must not contribute here.
* ``open`` — every currently-open provider (persistent + transient poll passes).
``last_sync[account_id]`` records the wall-clock of the account's most recent poll,
so the harness can compute the oldest sync lag across all accounts.
"""
from __future__ import annotations
import asyncio
import random
import time
import uuid
from collections.abc import AsyncIterator
from datetime import UTC, datetime
from email.message import EmailMessage
from email.utils import format_datetime
from app.providers.base import MailboxDelta, RemoteMailbox, RemoteMessage
class MockStats:
open = 0
persistent_open = 0
last_sync: dict[uuid.UUID, float] = {}
# --- optional mail injection (exercises parse/blob/DB/index under load) ----
mail_enabled = False
mail_per_poll = 0.0 # expected new INBOX messages per poll (fractional => prob)
msg_bytes = 10_000
big_pct = 0 # percent of messages that exceed STREAM_PARSE_MB (streaming path)
big_bytes = 10_000_000
delivered = 0 # total synthetic messages handed to the sync engine
_uid: dict[uuid.UUID, int] = {}
@classmethod
def reset(cls) -> None:
cls.open = 0
cls.persistent_open = 0
cls.last_sync = {}
cls.delivered = 0
cls._uid = {}
def _make_raw(size_bytes: int) -> bytes:
"""A unique, well-formed RFC822 message padded to roughly ``size_bytes``."""
msg = EmailMessage()
msg["Subject"] = f"soak {uuid.uuid4().hex[:12]}"
msg["From"] = "sender@load.local"
msg["To"] = "rcpt@load.local"
msg["Message-ID"] = f"<{uuid.uuid4().hex}@load.local>"
msg["Date"] = format_datetime(datetime.now(UTC))
msg.set_content("x" * max(0, size_bytes - 256))
return msg.as_bytes()
def _draw_count() -> int:
n = int(MockStats.mail_per_poll)
frac = MockStats.mail_per_poll - n
return n + (1 if random.random() < frac else 0)
class MockProvider:
"""A MailProvider that simulates a quiet mailbox with no real I/O."""
def __init__(self, account_id: uuid.UUID, idle_s: float) -> None:
self.account_id = account_id
self.idle_s = idle_s
self._open = False
self._persistent = False
def _open_socket(self) -> None:
if not self._open:
self._open = True
MockStats.open += 1
async def idle(self, mailbox: str) -> None:
self._open_socket()
if not self._persistent: # first IDLE marks this a persistent (hot) connection
self._persistent = True
MockStats.persistent_open += 1
await asyncio.sleep(self.idle_s) # simulate the IDLE wait / 25-min re-issue
async def poll(
self, mailbox: str, *, last_seen_uid: int, highestmodseq: int | None
) -> MailboxDelta:
self._open_socket()
MockStats.last_sync[self.account_id] = time.time()
new: list[RemoteMessage] = []
if MockStats.mail_enabled and mailbox == "INBOX":
for _ in range(_draw_count()):
uid = MockStats._uid.get(self.account_id, 0) + 1
MockStats._uid[self.account_id] = uid
big = random.random() < MockStats.big_pct / 100.0
size = MockStats.big_bytes if big else MockStats.msg_bytes
new.append(
RemoteMessage(
uid=uid,
raw=_make_raw(size),
flags=frozenset(),
internal_date=datetime.now(UTC),
)
)
MockStats.delivered += len(new)
return MailboxDelta(
new=new,
flag_changes=[],
expunged_uids=[],
new_uidvalidity=None,
new_highestmodseq=highestmodseq,
)
async def close(self) -> None:
if self._open:
self._open = False
MockStats.open -= 1
if self._persistent:
self._persistent = False
MockStats.persistent_open -= 1
# --- unused in the steady-state soak, defined for interface completeness ----
async def verify(self) -> None:
return None
async def list_mailboxes(self) -> list[RemoteMailbox]:
return []
async def backfill(
self, mailbox: str, *, newest_first: bool = True, batch_size: int = 200
) -> AsyncIterator[list[RemoteMessage]]:
return
yield # pragma: no cover - makes this an async generator
async def push_flags(
self, mailbox: str, uid: int, add: frozenset[str], remove: frozenset[str]
) -> None:
return None
async def move(self, uid: int, src: str, dst: str) -> int:
return uid
async def delete_messages(self, mailbox: str, uids: list[int]) -> None:
return None
async def append(self, mailbox: str, raw: bytes, flags: frozenset[str]) -> int:
return 0
async def send(self, raw: bytes, envelope_from: str, envelope_to: list[str]) -> None:
return None
+471
View File
@@ -0,0 +1,471 @@
"""Sync-engine soak / load test — acceptance criterion §10.7 (mock provider).
Simulates ``SOAK_ACCOUNTS`` accounts (default 1000), a fraction with active
sessions, sharded across ``SOAK_WORKERS`` in-process ``SyncCoordinator`` instances
using a mock ``MailProvider`` (no real IMAP). It samples, over the run:
* persistent sockets vs hot-account count (target: persistent ≤ hot, total ≤ hot×1.05)
* process RSS (target: stable — no per-account leak)
* oldest sync lag across all accounts (target: < POLL_COLD)
**Fully isolated**: it creates its own Postgres database (``webmail_soak``) and uses
a separate Redis DB, so it never touches the dev database, the dev Redis, or a
running worker. Both are torn down at the end.
Run (defaults = the §10.7 spec, ~1h):
cd backend && python -m loadtest.soak_sync
Fast smoke run (a couple of minutes, shortened cadences):
cd backend && SOAK_ACCOUNTS=200 SOAK_DURATION_S=150 POLL_COLD=30 POLL_WARM=15 \
SOAK_TIER_EVAL_S=5 SOAK_IDLE_S=10 python -m loadtest.soak_sync
Mail-injecting mode (SOAK_MAIL=1) delivers synthetic messages so the parse /
blob / DB / index path actually runs and RSS + CPU move under throughput. Tunables:
SOAK_MAIL_PER_POLL (expected new INBOX msgs per poll), SOAK_MSG_KB, SOAK_BIG_MSG_PCT
(percent above STREAM_PARSE_MB, exercising the streaming/§8 path). Example:
cd backend && SOAK_MAIL=1 SOAK_MAIL_PER_POLL=0.5 SOAK_BIG_MSG_PCT=5 \
SOAK_ACCOUNTS=300 SOAK_DURATION_S=300 POLL_COLD=60 POLL_WARM=30 \
SOAK_TIER_EVAL_S=15 SOAK_IDLE_S=20 python -m loadtest.soak_sync
Exit code 0 = all targets met, 1 = a target missed (usable in CI-nightly).
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import sys
import tempfile
# --- isolation: point DB + Redis + blobs at throwaway locations BEFORE app import
_DEV_DB = os.environ.get(
"DATABASE_URL", "postgresql+asyncpg://webmail:webmail_dev@127.0.0.1:5433/webmail_dev"
)
_SOAK_DB_NAME = os.environ.get("SOAK_DB_NAME", "webmail_soak")
_SOAK_DB_URL = re.sub(r"/[^/?]+(\?|$)", f"/{_SOAK_DB_NAME}\\1", _DEV_DB)
os.environ["DATABASE_URL"] = _SOAK_DB_URL
os.environ.setdefault("SOAK_REDIS_URL", "redis://127.0.0.1:6380/14")
os.environ["REDIS_URL"] = os.environ["SOAK_REDIS_URL"]
# Isolate the blob store (mail injection writes real blobs) and force the in-DB
# search backend so nothing leaks to the dev blob dir or an external Meilisearch.
_SOAK_BLOBS = tempfile.mkdtemp(prefix="soak-blobs-")
os.environ["BLOB_ROOT"] = _SOAK_BLOBS
os.environ["SEARCH_BACKEND"] = "pg"
import asyncio # noqa: E402
import logging # noqa: E402
import time # noqa: E402
import uuid # noqa: E402
from dataclasses import dataclass, field # noqa: E402
import asyncpg # noqa: E402
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine # noqa: E402
from sqlalchemy.pool import NullPool # noqa: E402
from app.config import get_settings # noqa: E402
from app.identity.passwords import hash_password # noqa: E402
from app.models import Account, Mailbox, User # noqa: E402
from app.redis_client import close_redis, get_redis # noqa: E402
from app.sync import leases # noqa: E402
from app.sync.coordinator import SyncCoordinator # noqa: E402
from loadtest.mock_provider import MockProvider, MockStats # noqa: E402
# --- config ------------------------------------------------------------------
def _int(name: str, default: int) -> int:
return int(os.environ.get(name, default))
@dataclass
class Config:
accounts: int = field(default_factory=lambda: _int("SOAK_ACCOUNTS", 1000))
session_pct: int = field(default_factory=lambda: _int("SOAK_SESSION_PCT", 20))
duration_s: int = field(default_factory=lambda: _int("SOAK_DURATION_S", 3600))
workers: int = field(default_factory=lambda: _int("SOAK_WORKERS", 3))
sample_s: int = field(default_factory=lambda: _int("SOAK_SAMPLE_S", 30))
idle_s: int = field(default_factory=lambda: _int("SOAK_IDLE_S", 60))
claim_s: int = field(default_factory=lambda: _int("SOAK_CLAIM_S", 5))
tier_eval_s: int = field(default_factory=lambda: _int("SOAK_TIER_EVAL_S", 120))
# Mail injection (off by default): synthetic messages exercise the
# parse/blob/DB/index path so RSS + CPU move under real throughput.
mail: bool = field(default_factory=lambda: bool(_int("SOAK_MAIL", 0)))
mail_per_poll: float = field(
default_factory=lambda: float(os.environ.get("SOAK_MAIL_PER_POLL", 0.3))
)
msg_kb: int = field(default_factory=lambda: _int("SOAK_MSG_KB", 12))
big_pct: int = field(default_factory=lambda: _int("SOAK_BIG_MSG_PCT", 0))
def __post_init__(self) -> None:
s = get_settings()
self.poll_cold = s.poll_cold
self.poll_warm = s.poll_warm
self.session_grace_s = s.tier_session_grace_min * 60
# Warm-up must outlast one cold poll so cold accounts have synced once, but
# never eat the whole run on a short smoke test.
default_warmup = min(self.poll_cold + 90, int(self.duration_s * 0.4))
self.warmup_s = _int("SOAK_WARMUP_S", default_warmup)
# --- database lifecycle ------------------------------------------------------
def _admin_dsn() -> str:
# asyncpg DSN for the maintenance DB (strip the +asyncpg driver + swap db name).
dsn = _DEV_DB.replace("postgresql+asyncpg://", "postgresql://")
return re.sub(r"/[^/?]+(\?|$)", "/postgres\\1", dsn)
async def _create_database() -> None:
conn = await asyncpg.connect(_admin_dsn())
try:
await conn.execute(f'DROP DATABASE IF EXISTS "{_SOAK_DB_NAME}" WITH (FORCE)')
await conn.execute(f'CREATE DATABASE "{_SOAK_DB_NAME}"')
finally:
await conn.close()
async def _drop_database() -> None:
conn = await asyncpg.connect(_admin_dsn())
try:
await conn.execute(f'DROP DATABASE IF EXISTS "{_SOAK_DB_NAME}" WITH (FORCE)')
finally:
await conn.close()
def _migrate() -> None:
env = {**os.environ, "DATABASE_URL": _SOAK_DB_URL}
backend_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
subprocess.run(
[sys.executable, "-c", "from alembic.config import main; main(argv=['upgrade', 'head'])"],
cwd=backend_dir,
env=env,
check=True,
)
_MAILBOXES = [
("INBOX", "inbox"),
("Sent", "sent"),
("Drafts", "drafts"),
("Archive", "archive"),
("Trash", "trash"),
]
async def _seed(cfg: Config) -> list[uuid.UUID]:
"""Create N users, each with one live account and a handful of mailboxes."""
engine = create_async_engine(_SOAK_DB_URL, poolclass=NullPool)
sm = async_sessionmaker(engine, expire_on_commit=False)
user_ids: list[uuid.UUID] = []
pw = hash_password("soak")
async with sm() as session:
for i in range(cfg.accounts):
user = User(handle=f"soak-{i:05d}@load.local", password_hash=pw)
session.add(user)
await session.flush()
user_ids.append(user.id)
account = Account(
user_id=user.id,
email=f"soak-{i:05d}@load.local",
display_name="Soak",
imap_config={"host": "mock", "port": 993, "tls": "ssl"},
smtp_config={"host": "mock", "port": 465, "tls": "ssl"},
secret_ct=b"",
discovery_src="manual",
state="live",
)
session.add(account)
await session.flush()
for path, special in _MAILBOXES:
session.add(
Mailbox(account_id=account.id, user_id=user.id, path=path, special_use=special)
)
if i % 200 == 0:
await session.flush()
await session.commit()
await engine.dispose()
return user_ids
# --- metrics -----------------------------------------------------------------
def _rss_mb() -> float:
with open("/proc/self/status") as fh:
for line in fh:
if line.startswith("VmRSS:"):
return int(line.split()[1]) / 1024.0
return 0.0
@dataclass
class Sample:
t: float
owned: int
hot: int
warm: int
cold: int
persistent: int
total_open: int
max_lag: float
rss_mb: float
synced: int # accounts that have completed >=1 poll (steady-state gate)
dead: int # owned runners whose work loops have died (zombies)
delivered: int # cumulative synthetic messages persisted (mail mode)
async def _tier_counts(coords: list[SyncCoordinator]) -> tuple[int, int, int, int, int]:
owned = hot = warm = cold = dead = 0
for c in coords:
for runner in list(c._runners.values()):
owned += 1
if runner.tier == "hot":
hot += 1
elif runner.tier == "warm":
warm += 1
else:
cold += 1
if not runner.running(): # owned but its work loops have died = zombie
dead += 1
return owned, hot, warm, cold, dead
def _max_lag(now: float, seeded: int, start: float) -> float:
"""Oldest sync lag across accounts. Never-synced accounts count from run start."""
worst = 0.0
synced = MockStats.last_sync
for ts in synced.values():
worst = max(worst, now - ts)
if len(synced) < seeded: # some accounts have not completed a first poll yet
worst = max(worst, now - start)
return worst
# --- run ---------------------------------------------------------------------
def _percentile(values: list[float], pct: float) -> float:
if not values:
return 0.0
ordered = sorted(values)
idx = min(len(ordered) - 1, int(pct / 100.0 * len(ordered)))
return ordered[idx]
async def _soak(cfg: Config) -> int:
import app.sync.engine as engine_mod
# Route every provider build to the mock (no real IMAP for the whole run).
engine_mod.build_provider = lambda account: MockProvider(account.id, cfg.idle_s) # type: ignore[assignment]
MockStats.reset()
if cfg.mail:
MockStats.mail_enabled = True
MockStats.mail_per_poll = cfg.mail_per_poll
MockStats.msg_bytes = cfg.msg_kb * 1024
MockStats.big_pct = cfg.big_pct
MockStats.big_bytes = (get_settings().stream_parse_mb + 2) * 1024 * 1024
redis = get_redis()
await redis.flushdb()
user_ids = await _seed(cfg)
active_users = user_ids[: cfg.accounts * cfg.session_pct // 100]
async def refresh_sessions() -> None:
for uid in active_users:
await leases.mark_user_active(redis, uid, cfg.session_grace_s)
await refresh_sessions()
# Size the per-worker cap so total capacity is just above the fleet: claims
# fill each worker roughly evenly and the rebalancer barely has to shed
# (mirrors how you'd tune MAX_ACCOUNTS_PER_WORKER in a real deployment).
import math
cap = max(50, math.ceil(cfg.accounts / cfg.workers * 1.2))
coords = [SyncCoordinator(f"soak-w{i}") for i in range(cfg.workers)]
for c in coords:
c.max_accounts = cap
c.claim_interval = cfg.claim_s
c.claim_jitter = 1.0
c.tier_eval_interval = cfg.tier_eval_s
c.heartbeat_refresh = 5.0
c.rebalance_interval = 20.0
c.reconcile_interval = 60.0
await asyncio.gather(*(c.start() for c in coords))
start = time.time()
deadline = start + cfg.duration_s
next_session_refresh = start + cfg.session_grace_s / 2
samples: list[Sample] = []
mail_desc = (
f"mail ON ~{cfg.mail_per_poll}/poll {cfg.msg_kb}KB big={cfg.big_pct}%"
if cfg.mail
else "mail OFF"
)
print(
f"soak: {cfg.accounts} accounts, {len(active_users)} active sessions, "
f"{cfg.workers} workers, duration {cfg.duration_s}s, warmup {cfg.warmup_s}s, "
f"POLL_COLD={cfg.poll_cold}s POLL_WARM={cfg.poll_warm}s, {mail_desc}",
flush=True,
)
print(
f"{'t':>6} {'owned':>6} {'hot':>5} {'warm':>5} {'cold':>5} "
f"{'persist':>8} {'open':>5} {'lag_s':>7} {'rss_mb':>8} {'dead':>5} "
f"{'msgs':>8} {'msg/s':>7}",
flush=True,
)
try:
while time.time() < deadline:
await asyncio.sleep(cfg.sample_s)
now = time.time()
if now >= next_session_refresh:
await refresh_sessions()
next_session_refresh = now + cfg.session_grace_s / 2
owned, hot, warm, cold, dead = await _tier_counts(coords)
s = Sample(
t=now - start,
owned=owned,
hot=hot,
warm=warm,
cold=cold,
persistent=MockStats.persistent_open,
total_open=MockStats.open,
max_lag=_max_lag(now, cfg.accounts, start),
rss_mb=_rss_mb(),
synced=len(MockStats.last_sync),
dead=dead,
delivered=MockStats.delivered,
)
samples.append(s)
prev = samples[-2] if len(samples) > 1 else None
rate = (s.delivered - prev.delivered) / cfg.sample_s if prev else 0.0
print(
f"{s.t:6.0f} {s.owned:6d} {s.hot:5d} {s.warm:5d} {s.cold:5d} "
f"{s.persistent:8d} {s.total_open:5d} {s.max_lag:7.0f} {s.rss_mb:8.1f} "
f"{s.dead:5d} {s.delivered:8d} {rate:7.1f}",
flush=True,
)
finally:
await asyncio.gather(*(c.stop() for c in coords), return_exceptions=True)
return _report(cfg, samples)
def _report(cfg: Config, samples: list[Sample]) -> int:
# Steady state begins once every account has completed its first sync (and at
# least the configured warm-up has elapsed) — before that, lag legitimately
# reflects accounts still ramping up, not a starved steady state.
all_synced_t = next((s.t for s in samples if s.synced >= cfg.accounts), None)
if all_synced_t is None:
worst = max((s.synced for s in samples), default=0)
print(
f"\nFAIL: not all accounts synced (best {worst}/{cfg.accounts}); "
"increase SOAK_DURATION_S",
flush=True,
)
return 1
floor = max(cfg.warmup_s, all_synced_t)
steady = [s for s in samples if s.t >= floor]
if not steady:
print("\nFAIL: no steady-state samples (increase SOAK_DURATION_S)", flush=True)
return 1
owned = max(s.owned for s in steady)
hot = max(s.hot for s in steady)
persistent_p95 = _percentile([s.persistent for s in steady], 95)
open_p95 = _percentile([float(s.total_open) for s in steady], 95)
lag_max = max(s.max_lag for s in steady)
# RSS trend: compare the mean of the first third of steady samples to the last
# third. This detects a genuine upward drift (leak) while ignoring transient
# parse spikes from large messages (min-to-max would flake in mail mode).
third = max(1, len(steady) // 3)
rss_early = sum(s.rss_mb for s in steady[:third]) / third
rss_late = sum(s.rss_mb for s in steady[-third:]) / third
rss_max = max(s.rss_mb for s in steady)
rss_growth = (rss_late - rss_early) / rss_early * 100 if rss_early else 0.0
socket_budget = hot * 1.05 # §10.7: persistent sockets <= hot count + 5%
lag_budget = cfg.poll_cold * 1.15
rss_budget_pct = 15.0
# §10.7 hard criteria.
checks = [
(
"all accounts owned",
owned >= cfg.accounts,
f"owned={owned}/{cfg.accounts}",
),
(
"persistent sockets <= hot+5%",
persistent_p95 <= socket_budget,
f"persist_p95={persistent_p95:.0f} budget={socket_budget:.0f} (hot={hot})",
),
(
"oldest lag < POLL_COLD*1.15",
lag_max < lag_budget,
f"lag_max={lag_max:.0f}s budget={lag_budget:.0f}s",
),
(
"RSS trend stable (<15%)",
rss_growth <= rss_budget_pct,
f"rss early {rss_early:.0f}MB -> late {rss_late:.0f}MB "
f"({rss_growth:+.1f}%), peak {rss_max:.0f}MB",
),
]
print("\n=== soak result (steady-state) ===", flush=True)
ok = True
for name, passed, detail in checks:
ok = ok and passed
print(f" [{'PASS' if passed else 'FAIL'}] {name:30} {detail}", flush=True)
# Observational (transient poll connections, not a §10.7 gate).
print(
f" [info] total open incl. transient open_p95={open_p95:.0f}",
flush=True,
)
if cfg.mail:
delivered = samples[-1].delivered
span = samples[-1].t - samples[0].t or 1.0
rss_peak = max(s.rss_mb for s in samples)
print(
f" [info] mail delivered={delivered} (~{delivered / span:.1f} msg/s), "
f"peak RSS={rss_peak:.0f}MB",
flush=True,
)
print(f"\nsteady samples: {len(steady)} hot~{hot} owned~{owned}", flush=True)
print("RESULT:", "PASS" if ok else "FAIL", flush=True)
return 0 if ok else 1
async def _main() -> int:
if not os.environ.get("SOAK_DEBUG"):
logging.disable(logging.WARNING) # quiet the per-account loops; keep ERROR+
else:
logging.basicConfig(level=logging.INFO)
print(f"provisioning isolated soak DB '{_SOAK_DB_NAME}' + Redis {os.environ['REDIS_URL']} ...")
await _create_database()
_migrate()
try:
rc = await _soak(Config())
finally:
await get_redis().flushdb()
await close_redis()
from app.db import dispose_engine
await dispose_engine()
await _drop_database()
shutil.rmtree(_SOAK_BLOBS, ignore_errors=True)
print(f"torn down soak DB '{_SOAK_DB_NAME}' + blob dir", flush=True)
return rc
if __name__ == "__main__":
raise SystemExit(asyncio.run(_main()))
@@ -21,9 +21,7 @@ depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"ALTER TABLE users ADD COLUMN quota_bytes bigint NOT NULL DEFAULT 5368709120"
)
op.execute("ALTER TABLE users ADD COLUMN quota_bytes bigint NOT NULL DEFAULT 5368709120")
def downgrade() -> None:
+11 -10
View File
@@ -13,6 +13,7 @@ from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app import queue as app_queue
from app.api import accounts as accounts_api
from app.config import get_settings
from app.identity.passwords import hash_password
@@ -114,14 +115,14 @@ async def test_create_account_enqueues_sync(ctx: Ctx, monkeypatch) -> None: # t
enqueued: list[tuple[str, str]] = []
class FakePool:
async def enqueue_job(self, name: str, arg: str) -> None:
async def enqueue_job(self, name: str, arg: str, **kwargs: object) -> None:
enqueued.append((name, arg))
async def fake_pool() -> FakePool:
return FakePool()
monkeypatch.setattr(accounts_api, "test_account", fake_test)
monkeypatch.setattr(accounts_api, "get_arq_pool", fake_pool)
monkeypatch.setattr(app_queue, "get_arq_pool", fake_pool)
resp = await ctx.client.post(
"/api/accounts",
@@ -151,14 +152,14 @@ async def _make_account(ctx: Ctx, monkeypatch) -> str: # type: ignore[no-untype
return None
class FakePool:
async def enqueue_job(self, name: str, arg: str) -> None:
async def enqueue_job(self, name: str, arg: str, **kwargs: object) -> None:
return None
async def fake_pool() -> FakePool:
return FakePool()
monkeypatch.setattr(accounts_api, "test_account", fake_test)
monkeypatch.setattr(accounts_api, "get_arq_pool", fake_pool)
monkeypatch.setattr(app_queue, "get_arq_pool", fake_pool)
resp = await ctx.client.post(
"/api/accounts",
json={**GOOD_CFG, "email": "me@example.com", "display_name": "Me", "password": "secret"},
@@ -174,13 +175,13 @@ async def test_sync_all_enqueues_refresh_per_account(ctx: Ctx, monkeypatch) -> N
enqueued: list[tuple[str, str]] = []
class FakePool:
async def enqueue_job(self, name: str, arg: str) -> None:
async def enqueue_job(self, name: str, arg: str, **kwargs: object) -> None:
enqueued.append((name, arg))
async def fake_pool() -> FakePool:
return FakePool()
monkeypatch.setattr(accounts_api, "get_arq_pool", fake_pool)
monkeypatch.setattr(app_queue, "get_arq_pool", fake_pool)
resp = await ctx.client.post("/api/accounts/sync", headers=_hdr())
assert resp.status_code == 200
assert enqueued == [("refresh_account_job", aid)]
@@ -221,7 +222,7 @@ async def test_update_password_reencrypts_and_resyncs(ctx: Ctx, monkeypatch) ->
enqueued: list[tuple[str, str]] = []
class FakePool:
async def enqueue_job(self, name: str, arg: str) -> None:
async def enqueue_job(self, name: str, arg: str, **kwargs: object) -> None:
enqueued.append((name, arg))
async def fake_pool() -> FakePool:
@@ -231,7 +232,7 @@ async def test_update_password_reencrypts_and_resyncs(ctx: Ctx, monkeypatch) ->
return None
monkeypatch.setattr(accounts_api, "test_account", fake_test)
monkeypatch.setattr(accounts_api, "get_arq_pool", fake_pool)
monkeypatch.setattr(app_queue, "get_arq_pool", fake_pool)
resp = await ctx.client.patch(
f"/api/accounts/{aid}", json={"password": "newsecret"}, headers=_hdr()
@@ -259,14 +260,14 @@ async def test_delete_account(ctx: Ctx, monkeypatch) -> None: # type: ignore[no
return None
class FakePool:
async def enqueue_job(self, name: str, arg: str) -> None:
async def enqueue_job(self, name: str, arg: str, **kwargs: object) -> None:
return None
async def fake_pool() -> FakePool:
return FakePool()
monkeypatch.setattr(accounts_api, "test_account", fake_test)
monkeypatch.setattr(accounts_api, "get_arq_pool", fake_pool)
monkeypatch.setattr(app_queue, "get_arq_pool", fake_pool)
created = await ctx.client.post(
"/api/accounts",
@@ -0,0 +1,60 @@
"""Bounded / streaming MIME parse (refactor §7).
Large messages are parsed from their blob file rather than an in-memory tree, and
all parsing passes through the PARSE_CONCURRENCY semaphore.
"""
from __future__ import annotations
import uuid
from email.message import EmailMessage
from app.indexer import indexer
from app.indexer.mime import parse_message, parse_message_file
def _raw(body: str = "hello") -> bytes:
msg = EmailMessage()
msg["Subject"] = "Streamed"
msg["From"] = "a@example.com"
msg["To"] = "b@example.com"
msg["Message-ID"] = "<s1@example.com>"
msg.set_content(body)
msg.add_attachment(
b"x" * 1024, maintype="application", subtype="octet-stream", filename="a.bin"
)
return msg.as_bytes()
def test_parse_file_matches_bytes(tmp_path) -> None: # type: ignore[no-untyped-def]
raw = _raw()
path = tmp_path / "msg.eml"
path.write_bytes(raw)
# Parsing from the file handle yields exactly the same ParsedEmail as from bytes.
assert parse_message_file(path) == parse_message(raw)
async def test_parse_bounded_streams_large_messages(monkeypatch) -> None: # type: ignore[no-untyped-def]
from app.config import get_settings
threshold = get_settings().stream_parse_mb * 1024 * 1024
calls: list[str] = []
def fake_file(path: object) -> str:
calls.append("file")
return "file"
def fake_bytes(raw: bytes) -> str:
calls.append("bytes")
return "bytes"
monkeypatch.setattr(indexer, "parse_message_file", fake_file)
monkeypatch.setattr(indexer, "parse_message", fake_bytes)
user_id = uuid.uuid4()
small = await indexer._parse_bounded(user_id, "sha", b"x" * 16)
big = await indexer._parse_bounded(user_id, "sha", b"x" * (threshold + 1))
assert small == "bytes" # small stays in memory
assert big == "file" # over threshold => parsed from the blob file
assert calls == ["bytes", "file"]
+3 -3
View File
@@ -15,7 +15,7 @@ from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.api import actions as actions_api
from app import queue as app_queue
from app.config import get_settings
from app.db import session_scope
from app.identity.passwords import hash_password
@@ -112,13 +112,13 @@ async def ctx(monkeypatch) -> AsyncIterator[Ctx]: # type: ignore[no-untyped-def
# capture enqueues instead of hitting a real arq pool
class FakePool:
async def enqueue_job(self, *args: object) -> None:
async def enqueue_job(self, *args: object, **kwargs: object) -> None:
c.enqueued.append(args)
async def fake_pool() -> FakePool:
return FakePool()
monkeypatch.setattr(actions_api, "get_arq_pool", fake_pool)
monkeypatch.setattr(app_queue, "get_arq_pool", fake_pool)
token = await create_session(user.id, "pytest")
c.client = httpx.AsyncClient(
+1 -3
View File
@@ -127,9 +127,7 @@ async def test_draft_messages_are_not_threaded(ctx: Ctx) -> None:
m.set_content("body")
drafts_mb = await ctx.session.scalar(
select(Mailbox).where(
Mailbox.account_id == ctx.account_id, Mailbox.special_use == "drafts"
)
select(Mailbox).where(Mailbox.account_id == ctx.account_id, Mailbox.special_use == "drafts")
)
assert drafts_mb is not None
remote = RemoteMessage(
+2 -2
View File
@@ -16,7 +16,7 @@ from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.api import compose as compose_api
from app import queue as app_queue
from app.config import get_settings
from app.db import session_scope
from app.identity.passwords import hash_password
@@ -181,7 +181,7 @@ async def ctx(monkeypatch) -> AsyncIterator[Ctx]: # type: ignore[no-untyped-def
async def fake_pool() -> FakePool:
return FakePool()
monkeypatch.setattr(compose_api, "get_arq_pool", fake_pool)
monkeypatch.setattr(app_queue, "get_arq_pool", fake_pool)
token = await create_session(user.id, "pytest")
c.client = httpx.AsyncClient(
@@ -0,0 +1,58 @@
"""Per-account / per-host connection caps (refactor §4).
The provider holds a per-account (≤4) and per-host semaphore slot for the whole
lifetime of an open socket, so a worker can never open hundreds of simultaneous
connections to one account or host.
"""
from __future__ import annotations
import asyncio
import pytest
from app.providers.imap_smtp.provider import _PER_ACCOUNT_CONN_CAP, ImapSmtpProvider, ProviderConfig
def _cfg(username: str, host: str) -> ProviderConfig:
return ProviderConfig(
imap_host=host,
imap_port=993,
imap_tls="ssl",
smtp_host=host,
smtp_port=465,
smtp_tls="ssl",
username=username,
password="pw",
)
async def test_per_account_connection_cap() -> None:
cfg = _cfg("user@example.com", "imap.example.com")
providers = [ImapSmtpProvider(cfg) for _ in range(_PER_ACCOUNT_CONN_CAP + 1)]
# Fill the account's slots (host cap defaults to 50, so account is the limiter).
for provider in providers[:_PER_ACCOUNT_CONN_CAP]:
await provider._acquire_conn_slots()
# The next acquire blocks until a slot frees.
with pytest.raises(TimeoutError):
await asyncio.wait_for(providers[-1]._acquire_conn_slots(), timeout=0.2)
providers[0]._release_conn_slots()
await asyncio.wait_for(providers[-1]._acquire_conn_slots(), timeout=0.5) # now succeeds
for provider in providers[1:]:
provider._release_conn_slots()
async def test_distinct_accounts_do_not_share_slots() -> None:
# Saturating account A must not block account B (independent semaphores).
a = [ImapSmtpProvider(_cfg("a@example.com", "imap.example.com")) for _ in range(4)]
for provider in a:
await provider._acquire_conn_slots()
b = ImapSmtpProvider(_cfg("b@example.com", "imap.example.com"))
await asyncio.wait_for(b._acquire_conn_slots(), timeout=0.5)
b._release_conn_slots()
for provider in a:
provider._release_conn_slots()
+415
View File
@@ -0,0 +1,415 @@
"""Sync-engine v2 acceptance tests 1-3 (refactor §10).
These drive the real :class:`SyncCoordinator` against the test Redis with the
per-account IMAP loops replaced by fakes, so the lease protocol — single
ownership, crash failover, and create/delete lifecycle — is exercised without a
live mail server. Intervals are shortened so the tests run in seconds.
1. Two workers, N accounts: every account owned by exactly one worker, exactly
one live (IDLE) connection each.
2. Kill a worker: its orphaned accounts are re-owned by the survivor once the
leases expire, still one connection each (no double ownership).
3. A newly-live account is claimed without a restart; a removed account's
connection is torn down (lease fencing), with no further ownership.
"""
from __future__ import annotations
import asyncio
import uuid
from collections import defaultdict
from collections.abc import AsyncIterator, Callable
from datetime import UTC, datetime, timedelta
import pytest_asyncio
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import get_settings
from app.db import session_scope
from app.events import get_event_bus
from app.identity.passwords import hash_password
from app.models import Account, Mailbox, Message, User
from app.redis_client import get_redis
from app.sync import coordinator as coordinator_mod
from app.sync import leases, runner
from app.sync.coordinator import SyncCoordinator
# --- fakes: replace the real IMAP loops with connection-counting stand-ins ----
@pytest_asyncio.fixture
async def connections(monkeypatch) -> AsyncIterator[dict[uuid.UUID, int]]: # type: ignore[no-untyped-def]
"""Patch the runner's loops; yield a live (hot-tier IDLE) connection count."""
conns: dict[uuid.UUID, int] = defaultdict(int)
async def fake_live_loop(account_id: uuid.UUID, folder: str, stop: asyncio.Event) -> None:
conns[account_id] += 1 # one persistent IDLE connection while alive (hot)
try:
await stop.wait()
finally:
conns[account_id] -= 1
async def fake_secondary(
account_id: uuid.UUID, stop: asyncio.Event | None = None, interval: float | None = None
) -> None:
assert stop is not None
await stop.wait()
async def fake_poll_all(
account_id: uuid.UUID, interval: float, stop: asyncio.Event | None = None
) -> None:
assert stop is not None # warm/cold: no persistent connection, just wait
await stop.wait()
monkeypatch.setattr(runner, "live_loop", fake_live_loop)
monkeypatch.setattr(runner, "secondary_poll_loop", fake_secondary)
monkeypatch.setattr(runner, "poll_all_loop", fake_poll_all)
yield conns
@pytest_asyncio.fixture
async def make_accounts() -> AsyncIterator[Callable[[int, str], object]]:
"""Factory creating N live accounts; cleans up its users afterwards."""
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
sm = async_sessionmaker(engine, expire_on_commit=False)
user_ids: list[uuid.UUID] = []
async def _make(n: int, state: str = "live") -> list[uuid.UUID]:
async with sm() as session:
user = User(
handle=f"acc{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw")
)
session.add(user)
await session.flush()
user_ids.append(user.id)
ids: list[uuid.UUID] = []
for _ in range(n):
account = Account(
user_id=user.id,
email=f"{uuid.uuid4().hex[:10]}@example.com",
display_name="Acct",
imap_config={},
smtp_config={},
secret_ct=b"",
discovery_src="manual",
state=state,
)
session.add(account)
await session.flush()
ids.append(account.id)
await session.commit()
return ids
try:
yield _make
finally:
async with sm() as session:
for uid in user_ids:
await session.execute(delete(User).where(User.id == uid))
await session.commit()
await engine.dispose()
async def _no_reconcile() -> None:
"""Stub: keep the shared dev DB's real live accounts out of the test."""
return None
def _fast(c: SyncCoordinator) -> SyncCoordinator:
"""Compress cadences and drive sync:live explicitly (no DB reconcile).
The tests share the dev Postgres, which holds real live accounts; reconciling
from it would pull them into the test. We seed ``sync:live`` ourselves instead.
"""
c.claim_interval = 0.05
c.claim_jitter = 0.0
c.lease_renew = 0.3
c.lease_ttl = 2 # short so a crashed worker's leases expire quickly
c.reconcile_interval = 1000.0
c.heartbeat_refresh = 1000.0
c.rebalance_interval = 1000.0
c.tier_eval_interval = 1000.0 # lease tests pin the tier; no eval churn
c._reconcile_once = _no_reconcile # type: ignore[method-assign]
return c
async def _seed(ids: set[uuid.UUID], tier: str = "hot") -> None:
"""Register accounts as live and pin their tier (hot => persistent IDLE)."""
redis = get_redis()
for account_id in ids:
await leases.mark_live(redis, account_id)
await redis.set(leases.tier_key(account_id), tier)
async def _user_of(account_id: uuid.UUID) -> uuid.UUID:
async with session_scope() as session:
user_id = await session.scalar(select(Account.user_id).where(Account.id == account_id))
assert user_id is not None
return user_id
async def _add_recent_mail(account_id: uuid.UUID, when: datetime) -> None:
"""Insert one INBOX message dated ``when`` so the account reads as active."""
user_id = await _user_of(account_id)
async with session_scope() as session:
mailbox = Mailbox(account_id=account_id, user_id=user_id, path="INBOX", special_use="inbox")
session.add(mailbox)
await session.flush()
session.add(
Message(
user_id=user_id,
account_id=account_id,
mailbox_id=mailbox.id,
uid=1,
internal_date=when,
blob_sha256="0" * 64,
)
)
await session.commit()
async def _until(pred: Callable[[], bool], timeout_s: float = 6.0) -> None:
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout_s
while loop.time() < deadline:
if pred():
return
await asyncio.sleep(0.05)
assert pred(), "condition not met within timeout"
# --- acceptance 1: single ownership, one connection per account ---------------
async def test_acceptance_1_single_ownership(connections, make_accounts) -> None: # type: ignore[no-untyped-def]
ids = set(await make_accounts(20))
await _seed(ids)
c1 = _fast(SyncCoordinator("w1"))
c2 = _fast(SyncCoordinator("w2"))
c1.rebalance_interval = c2.rebalance_interval = 0.3 # let the load even out
# Start concurrently so the two workers race for the leases.
await asyncio.gather(c1.start(), c2.start())
try:
# Both workers end up holding accounts (via the initial race + rebalance),
# together covering all 20 with no account owned twice.
await _until(lambda: c1.owned | c2.owned == ids and bool(c1.owned and c2.owned))
assert c1.owned & c2.owned == set()
# At steady state each account has exactly one live IDLE connection.
await _until(lambda: all(connections[a] == 1 for a in ids))
finally:
await c1.stop()
await c2.stop()
await _until(lambda: all(connections[a] == 0 for a in ids))
# --- acceptance 2: crash failover --------------------------------------------
async def test_acceptance_2_failover(connections, make_accounts) -> None: # type: ignore[no-untyped-def]
ids = set(await make_accounts(10))
await _seed(ids)
c1 = _fast(SyncCoordinator("w1"))
await c1.start()
try:
await _until(lambda: c1.owned == ids) # sole worker owns everything
c2 = _fast(SyncCoordinator("w2"))
await c2.start()
# Simulate `kill -9` on w1: connections drop and renewals stop, but the
# leases are NOT released — they must expire before w2 can take over.
for task in c1._tasks:
task.cancel()
await asyncio.gather(*c1._tasks, return_exceptions=True)
c1._tasks = []
for r in list(c1._runners.values()):
await r.stop()
c1._runners.clear()
try:
# w2 re-owns every orphan once the (short) leases expire.
await _until(lambda: c2.owned == ids, timeout_s=8.0)
assert all(connections[a] == 1 for a in ids) # one connection each, no dupes
finally:
await c2.stop()
finally:
# w1 already crashed; clear any residual leases it left behind.
await get_redis().flushdb()
await _until(lambda: all(connections[a] == 0 for a in ids))
# --- acceptance 3: create / delete lifecycle without restart ------------------
async def test_acceptance_3_create_delete(connections, make_accounts) -> None: # type: ignore[no-untyped-def]
c = _fast(SyncCoordinator("w1"))
await c.start() # starts with no live accounts
try:
assert c.owned == set()
# A newly-live account (as the bootstrap job's _set_state('live') would do):
# register it in sync:live; the running coordinator must claim it, no restart.
(aid,) = await make_accounts(1)
await get_redis().set(leases.tier_key(aid), "hot") # persistent IDLE
await leases.mark_live(get_redis(), aid)
await _until(lambda: aid in c.owned)
assert connections[aid] == 1
# Removing it (as delete_account does) fences the runner: lease gone ->
# next renewal fails -> connection torn down, no further ownership.
await leases.mark_not_live(get_redis(), aid)
await _until(lambda: aid not in c.owned)
await _until(lambda: connections[aid] == 0)
finally:
await c.stop()
async def test_event_driven_teardown(connections, make_accounts) -> None: # type: ignore[no-untyped-def]
"""§3: an account.state_changed event tears the runner down at once.
Leases are kept long (no fencing) and the verify loop slow, so the only path
that can stop the runner in time is the cross-process event subscriber.
"""
(aid,) = await make_accounts(1)
user_id = uuid.uuid4()
await _seed({aid})
c = _fast(SyncCoordinator("w1"))
c.lease_ttl = 60 # lease stays valid: fencing cannot explain the teardown
c.lease_renew = 30
await c.start()
try:
await _until(lambda: aid in c.owned)
assert connections[aid] == 1
# As delete_account does: drop it from sync:live (so it isn't re-claimed),
# then emit the event. The lease is left intact, so only the event — not
# fencing — can explain the teardown.
await get_redis().srem(leases.LIVE_SET, str(aid))
await get_event_bus().publish(
"account.state_changed",
{"user_id": str(user_id), "account_id": str(aid), "state": "deleted"},
)
await _until(lambda: aid not in c.owned, timeout_s=3.0)
await _until(lambda: connections[aid] == 0, timeout_s=3.0)
finally:
await c.stop()
# --- acceptance 6: graceful shutdown -----------------------------------------
async def test_acceptance_6_graceful_shutdown(connections, make_accounts) -> None: # type: ignore[no-untyped-def]
"""A graceful stop releases every lease so a peer adopts the accounts fast.
Leases are long-lived, so adoption within a couple of seconds proves the
accounts were handed over by explicit release — not by waiting out the TTL.
"""
ids = set(await make_accounts(6))
await _seed(ids) # hot => persistent IDLE
c1 = _fast(SyncCoordinator("w1"))
c2 = _fast(SyncCoordinator("w2"))
c1.lease_ttl = c2.lease_ttl = 30 # adoption must come from release, not expiry
await c1.start()
await _until(lambda: c1.owned == ids) # sole owner
await c2.start() # nothing to claim yet — all leased by w1
try:
await c1.stop() # graceful: stops claimer, cancels runners, releases leases
assert c1.owned == set()
assert await get_redis().exists(leases.worker_key("w1")) == 0 # heartbeat gone
# w2 adopts every account well within the 30s TTL, one connection each.
await _until(lambda: c2.owned == ids, timeout_s=4.0)
await _until(lambda: all(connections[a] == 1 for a in ids))
finally:
await c2.stop()
await _until(lambda: all(connections[a] == 0 for a in ids))
# --- §5 activity tiers -------------------------------------------------------
async def test_tier_session_promotes_to_hot(connections, make_accounts) -> None: # type: ignore[no-untyped-def]
(aid,) = await make_accounts(1)
user_id = await _user_of(aid)
await leases.mark_live(get_redis(), aid) # claimed as warm by default
await leases.mark_user_active(get_redis(), user_id, 300) # active session -> hot
c = _fast(SyncCoordinator("w1"))
c.tier_eval_interval = 0.1
await c.start()
try:
await _until(lambda: aid in c.owned)
await _until(lambda: connections[aid] == 1) # hot => persistent IDLE
assert c._runners[aid].tier == "hot"
finally:
await c.stop()
async def test_tier_no_session_no_mail_goes_cold(connections, make_accounts) -> None: # type: ignore[no-untyped-def]
(aid,) = await make_accounts(1)
await leases.mark_live(get_redis(), aid) # no session, no mail
c = _fast(SyncCoordinator("w1"))
c.tier_eval_interval = 0.1
await c.start()
try:
await _until(lambda: aid in c.owned)
await _until(lambda: c._runners[aid].tier == "cold")
assert connections[aid] == 0 # cold holds no persistent connection
finally:
await c.stop()
async def test_tier_recent_mail_promotes_to_hot(connections, make_accounts) -> None: # type: ignore[no-untyped-def]
(aid,) = await make_accounts(1)
await _add_recent_mail(aid, datetime.now(UTC)) # activity within TIER_HOT_ACTIVITY_H
await leases.mark_live(get_redis(), aid)
c = _fast(SyncCoordinator("w1"))
c.tier_eval_interval = 0.1
await c.start()
try:
await _until(lambda: aid in c.owned)
await _until(lambda: connections[aid] == 1) # recent mail => hot
assert c._runners[aid].tier == "hot"
finally:
await c.stop()
async def test_user_active_event_promotes_immediately(connections, make_accounts) -> None: # type: ignore[no-untyped-def]
aid0, aid1 = await make_accounts(2) # both under one user
user_id = await _user_of(aid0)
await leases.mark_live(get_redis(), aid0)
await leases.mark_live(get_redis(), aid1)
c = _fast(SyncCoordinator("w1")) # tier eval disabled: only the event can promote
await c.start()
try:
await _until(lambda: {aid0, aid1} <= c.owned)
assert connections[aid0] == 0 and connections[aid1] == 0 # warm to start
await get_event_bus().publish("user.active", {"user_id": str(user_id)})
await _until(lambda: connections[aid0] == 1 and connections[aid1] == 1, timeout_s=3.0)
finally:
await c.stop()
async def test_hot_budget_demotes_least_active(connections, make_accounts) -> None: # type: ignore[no-untyped-def]
aid_old, aid_new = await make_accounts(2) # activity-hot, same user, NO session
now = datetime.now(UTC)
await _add_recent_mail(aid_old, now - timedelta(hours=1)) # least recently active
await _add_recent_mail(aid_new, now)
await leases.mark_live(get_redis(), aid_old)
await leases.mark_live(get_redis(), aid_new)
c = _fast(SyncCoordinator("w1"))
c.tier_eval_interval = 0.1
c.hot_budget = 1 # only one may be hot
await c.start()
try:
await _until(lambda: {aid_old, aid_new} <= c.owned)
# The newer account stays hot; the older is shed to warm by the budget.
await _until(lambda: c._runners[aid_new].tier == "hot")
await _until(lambda: c._runners[aid_old].tier == "warm")
assert connections[aid_new] == 1 and connections[aid_old] == 0
finally:
await c.stop()
def test_worker_id_is_unique() -> None:
assert coordinator_mod._new_worker_id() != coordinator_mod._new_worker_id()
+53
View File
@@ -0,0 +1,53 @@
"""Interactive/bulk queue isolation (refactor §1, acceptance §10.5).
Bulk backfills and interactive user actions land on separate arq queues (run by
separate consumers with their own concurrency), so a burst of backfills can never
starve sends/flag changes. Here we assert the routing that guarantees it.
"""
from __future__ import annotations
from app import queue as app_queue
from app.queue import QUEUE_BULK, QUEUE_INTERACTIVE, queue_for
_INTERACTIVE_JOBS = [
"push_flags_job",
"move_job",
"move_thread_to_folder_job",
"delete_thread_job",
"send_job",
"push_draft_to_imap_job",
"remove_imap_draft_job",
"refresh_account_job",
]
def test_bulk_backfill_routes_to_bulk_queue() -> None:
assert queue_for("sync_bootstrap_account") == QUEUE_BULK
def test_interactive_jobs_route_to_interactive_queue() -> None:
for name in _INTERACTIVE_JOBS:
assert queue_for(name) == QUEUE_INTERACTIVE
async def test_enqueue_job_sets_queue_name(monkeypatch) -> None: # type: ignore[no-untyped-def]
captured: list[tuple[str, str | None]] = []
class FakePool:
async def enqueue_job(self, name: str, *args: object, **kwargs: object) -> None:
captured.append((name, kwargs.get("_queue_name"))) # type: ignore[arg-type]
return None
async def fake_pool() -> FakePool:
return FakePool()
monkeypatch.setattr(app_queue, "get_arq_pool", fake_pool)
await app_queue.enqueue_job("sync_bootstrap_account", "a")
await app_queue.enqueue_job("send_job", "b")
assert captured == [
("sync_bootstrap_account", QUEUE_BULK),
("send_job", QUEUE_INTERACTIVE),
]
+99
View File
@@ -0,0 +1,99 @@
"""Worker entrypoint (refactor §1, §7).
One image, two roles selected by ``WORKER_ROLE``:
- ``jobs`` — arq consumer for the ``interactive`` and ``bulk`` queues (both in one
process, per-queue concurrency/timeout).
- ``sync`` — runs the lease-owned account-sync coordinator (§2) directly.
- ``all`` — both, for single-process development (the default).
Graceful shutdown (SIGINT/SIGTERM): stop the coordinator first — it stops the
claimer, cancels the runners, and releases every lease so a peer adopts the
accounts immediately — then drain the arq consumers. Target < 10 s.
Run with: ``python -m workers.run``.
"""
from __future__ import annotations
import asyncio
import logging
import signal
from typing import Any
from arq.worker import Worker
from app.config import get_settings
from app.queue import QUEUE_BULK, QUEUE_INTERACTIVE, redis_settings
from app.sync.coordinator import SyncCoordinator
from workers.worker import BULK_FUNCTIONS, INTERACTIVE_FUNCTIONS
logger = logging.getLogger(__name__)
_SHUTDOWN_DRAIN_S = 10.0 # §7: release leases + drain within this budget
def _worker(functions: list[Any], queue_name: str, max_jobs: int, job_timeout: int) -> Worker:
return Worker(
functions=functions,
queue_name=queue_name,
redis_settings=redis_settings(),
max_jobs=max_jobs,
job_timeout=job_timeout,
handle_signals=False, # the runner owns signal handling (graceful shutdown)
)
def build_arq_workers(role: str) -> list[Worker]:
"""Construct the arq Worker instances for the given role (jobs only)."""
s = get_settings()
if role not in ("jobs", "all"):
return []
return [
_worker(INTERACTIVE_FUNCTIONS, QUEUE_INTERACTIVE, s.interactive_concurrency, 120),
_worker(BULK_FUNCTIONS, QUEUE_BULK, s.bulk_concurrency, 3600),
]
async def _run() -> None:
logging.basicConfig(level=get_settings().log_level)
role = get_settings().worker_role
coordinator = SyncCoordinator() if role in ("sync", "all") else None
if coordinator is not None:
await coordinator.start()
arq_tasks = [asyncio.create_task(w.async_run()) for w in build_arq_workers(role)]
logger.info("worker starting: role=%s (%d arq worker(s))", role, len(arq_tasks))
stop = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, stop.set)
await stop.wait()
logger.info("worker stopping: releasing leases + draining")
if coordinator is not None:
try:
await asyncio.wait_for(coordinator.stop(), timeout=_SHUTDOWN_DRAIN_S)
except TimeoutError:
logger.warning("coordinator stop exceeded %.0fs drain budget", _SHUTDOWN_DRAIN_S)
for task in arq_tasks:
task.cancel()
await asyncio.gather(*arq_tasks, return_exceptions=True)
from app.db import dispose_engine
from app.queue import close_arq_pool
from app.redis_client import close_redis
await dispose_engine()
await close_redis()
await close_arq_pool()
logger.info("worker stopped")
def main() -> None:
asyncio.run(_run())
if __name__ == "__main__":
main()
+18 -41
View File
@@ -1,6 +1,9 @@
"""arq worker entrypoint (SPEC §2 ``workers/worker.py``).
"""Worker job callables and queue function-sets (refactor §1, §6).
Run with: ``arq workers.worker.WorkerSettings`` (from ``backend/``).
The process is launched via ``workers.run`` (``WORKER_ROLE`` decides which arq
workers run). This module only defines the job callables and the two queue
function-sets; the runner wires them onto the ``interactive`` / ``bulk`` queues.
The sync role runs the coordinator directly (see ``workers.run``), not via arq.
"""
from __future__ import annotations
@@ -9,10 +12,8 @@ import logging
import uuid
from typing import Any
from app.config import get_settings
from app.outbox.draft_sync import push_draft_to_imap_job, remove_imap_draft_job
from app.outbox.jobs import send_job
from app.queue import redis_settings as arq_redis_settings
from app.sync.actions import (
delete_thread_job,
move_job,
@@ -32,41 +33,17 @@ async def sync_bootstrap_account(ctx: dict[str, Any], account_id: str) -> int:
return count
async def on_startup(ctx: dict[str, Any]) -> None:
logging.basicConfig(level=get_settings().log_level)
from app.sync.supervisor import start_supervisor
# Fast, interactive user actions (§6): short timeout, high concurrency.
INTERACTIVE_FUNCTIONS = [
push_flags_job,
move_job,
move_thread_to_folder_job,
delete_thread_job,
send_job,
push_draft_to_imap_job,
remove_imap_draft_job,
refresh_account_job,
]
await start_supervisor(ctx)
logger.info("webmail worker started")
async def on_shutdown(ctx: dict[str, Any]) -> None:
from app.db import dispose_engine
from app.redis_client import close_redis
from app.sync.supervisor import stop_supervisor
await stop_supervisor(ctx)
await dispose_engine()
await close_redis()
logger.info("webmail worker stopped")
class WorkerSettings:
"""arq worker configuration."""
functions = [
sync_bootstrap_account,
push_flags_job,
move_job,
move_thread_to_folder_job,
delete_thread_job,
send_job,
push_draft_to_imap_job,
remove_imap_draft_job,
refresh_account_job,
]
on_startup = on_startup
on_shutdown = on_shutdown
max_jobs = 10 # global worker cap (SPEC §6.3)
job_timeout = 3600 # backfill of a large account can be long
redis_settings = arq_redis_settings()
# Bulk work (§6): long timeout, low concurrency, isolated from interactive jobs.
BULK_FUNCTIONS = [sync_bootstrap_account]
+31
View File
@@ -36,6 +36,36 @@ services:
timeout: 3s
retries: 10
# Sync-engine v2 workers (refactor §1): two roles sharing one image. Scale the
# sync role with `--scale worker-sync=N` (lease protocol prevents double-owning).
worker-sync:
build: { context: ../backend }
command: ["python", "-m", "workers.run"]
environment: &worker-env
WORKER_ROLE: sync
APP_DOMAIN: dev.webmail.local
DATABASE_URL: postgresql+asyncpg://webmail:webmail_dev@postgres:5432/webmail_dev
REDIS_URL: redis://redis:6379/0
MASTER_KEY: CHANGE_ME_base64_32_bytes
BLOB_ROOT: /var/blobs
volumes: [blobs:/var/blobs]
ulimits: { nofile: 65536 }
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_healthy }
worker-jobs:
build: { context: ../backend }
command: ["python", "-m", "workers.run"]
environment:
<<: *worker-env
WORKER_ROLE: jobs
volumes: [blobs:/var/blobs]
ulimits: { nofile: 65536 }
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_healthy }
# Test mail servers (integration tests + manual dev against a throwaway mailbox)
greenmail:
image: greenmail/standalone:2.0.1
@@ -51,3 +81,4 @@ services:
volumes:
pgdata:
meilidata:
blobs:
+119
View File
@@ -0,0 +1,119 @@
# Sync Engine v2 — Refactor Instructions (replaces SPEC §6.3)
This document is a self-contained repair/rebuild task for the implementing agent. It replaces the current single-supervisor sync design with a sharded, lease-owned, tiered sync engine targeting **1000+ accounts**. Read it fully, then execute the migration steps in §9 **in order**.
## 0. Ground rules
- This is a **workers-layer refactor only**. Frozen and untouchable: the `MailProvider` interface (SPEC §5.1), the database schema (no migration is needed or allowed by this task), the REST/WS API surface, invariant **I-1** (API never opens IMAP connections), and the idempotency discipline (`UNIQUE (mailbox_id, uid)` upserts).
- Existing tests must stay green after every migration step. Each step ends with its own acceptance checks (§10 maps them).
- All new tunables go through `config.py` (pydantic-settings) — no literals in code. New env vars are listed in §8.
- Redis is already the queue and event bus; it now also carries leases, the live-account set, tier state, and worker heartbeats. No new infrastructure.
## 1. Process model: two roles
The single worker process splits into two entrypoints (same codebase, `WORKER_ROLE` env):
- **`role=jobs`** — pure arq consumer for the discrete jobs (send, flags, move, drafts, backfill). Stateless, horizontally scalable; run N instances freely.
- **`role=sync`** — runs the lease claimer + per-account runners (long-lived loops). Scalable via the lease protocol in §2; run 1..N instances.
Default dev/prod-small topology: 2× sync + 2× jobs. docker-compose gets two services (`worker-sync`, `worker-jobs`) sharing the image, each with `ulimits: { nofile: 65536 }`.
## 2. Lease protocol (account ownership)
Exactly one sync worker owns each live account at any moment. Ownership is a Redis lease:
- **Registry:** `sync:live` — a Redis SET of account ids with `state='live'`. Maintained by the API on every account state change AND reconciled against Postgres by each sync worker every 300 s (belt and braces; reconcile only adds/removes ids, never touches leases).
- **Lease key:** `sync:lease:{account_id}` = `{worker_id}`, TTL `LEASE_TTL` (60 s). `worker_id` = `{hostname}:{pid}:{random8}` generated at startup.
- **Claim:** `SET sync:lease:{id} {worker_id} NX EX {LEASE_TTL}` — success starts the account runner (§4).
- **Claimer loop:** every 10 s + random jitter 0–5 s, while `owned < MAX_ACCOUNTS_PER_WORKER`: iterate `sync:live` (SSCAN), attempt claims on ids with no lease. Jitter prevents thundering-herd on mass restart.
- **Renewal:** every `LEASE_RENEW` (20 s), for each owned account run this Lua atomically (never a plain EXPIRE):
```lua
if redis.call('GET', KEYS[1]) == ARGV[1]
then return redis.call('EXPIRE', KEYS[1], ARGV[2]) else return 0 end
```
- **Fencing (hard rule):** if renewal returns 0 for an account (lease lost or taken), the worker cancels that account's runner tasks **immediately, before any further provider or DB operation for it**. In-flight operations are tolerable because all writes are idempotent upserts.
- **Release:** on graceful stop or account disable, cancel the runner, then delete the lease with the owner-checked Lua (`GET==me → DEL`).
- **Worker registry & rebalance:** each sync worker heartbeats `sync:workers:{worker_id}` = JSON `{owned, sockets_open, tiers:{hot,warm,cold}, ts}` (TTL 30 s, refresh 10 s). Every 60 s: `target = |sync:live| / |alive workers|`; if `owned > target*1.2 + 5`, release the excess, choosing least-recently-active accounts first — released accounts are claimed by under-loaded workers within one claimer cycle.
**Failover story (emergent, no extra code):** worker crash → leases expire ≤ `LEASE_TTL` → survivors claim orphans on the next claimer tick. Total re-ownership within ~90 s.
## 3. Event-driven account lifecycle (kills startup-only discovery + zombie loops)
Sync workers subscribe to the event bus:
- `account.state_changed → live` (new account finished backfill, or re-enabled): API adds the id to `sync:live`; some worker claims it within one claimer cycle. **No restart, ever.**
- `account.state_changed → disabled|deleted`: API removes the id from `sync:live`; the owning worker (it can check `GET sync:lease:{id}==me` cheaply, or simply receives the event and checks ownership) stops the runner, closes connections, releases the lease — target ≤ 30 s from the API call.
- Defense in depth: every runner poll cycle re-verifies the account row exists with `state='live'`; if not, self-terminate and release. This makes zombie loops structurally impossible even if an event is lost.
- **New core event `user.active`** (add to SPEC §8.3 catalog): published by the API on successful login and on every WS connect, payload `{user_id}`. Consumed for tier promotion (§5).
## 4. Per-account runner (replaces the fixed 2-task model)
One `AccountRunner` per owned account, whose task set depends on the current tier:
- **hot:** task A = INBOX IDLE loop (one persistent connection; re-issue IDLE ≤ 25 min; on wake → `poll()` INBOX). Task B = secondary CONDSTORE poll of Sent/Drafts/Archive/Trash/Junk every `POLL_WARM` s on a transient connection.
- **warm:** no persistent connection. Single task: transient CONDSTORE poll of INBOX + special folders every `POLL_WARM` s.
- **cold:** same single task, every `POLL_COLD` s.
Delta application, UIDVALIDITY handling, expunge soft-handling, and outbound flag write-then-enqueue semantics are **unchanged** from the current implementation.
**Connection budgets:**
- ≤ 2 concurrent IMAP connections per account from the sync side (IDLE + one transient); jobs may add short-lived connections but must acquire the same per-account semaphore (cap 4 total, as today).
- Per-destination-host semaphore (process-local), cap `PER_HOST_CONN_CAP` (default 50): acquired around every IMAP/SMTP connect, so a worker never opens hundreds of simultaneous sockets to one provider. Sharding by account makes process-local sufficient.
## 5. Activity tiers
Tier state: `sync:tier:{account_id}` = `hot|warm|cold` (TTL none; default `warm` on claim). Evaluated by the owning worker every 120 s and on events:
- **→ hot** when: `user.active` received for the owning user (all their accounts promote within ≤ 5 s, IDLE established), OR the account received new mail in the last `TIER_HOT_ACTIVITY_H` (24 h). Session-based heat persists while WS pings continue + `TIER_SESSION_GRACE_MIN` (30 min) after the last one (API refreshes `sess:active:{user_id}` TTL; workers just read it).
- **→ warm** when neither hot condition holds and last activity < `TIER_COLD_AFTER_D` (3 d).
- **→ cold** when no user session and no new mail for ≥ `TIER_COLD_AFTER_D` days. Any new mail found by a cold poll immediately re-promotes to hot (mail arriving = activity).
- Optional global guard: if a worker's hot count exceeds `HOT_BUDGET_PER_WORKER` (default 250), the least-recently-active hot accounts without live sessions degrade to warm. Session-hot accounts are never degraded by the budget.
Expected steady state at ~3000 accounts: roughly 15–25 % hot → 450–750 persistent sockets across the pool, the rest on cheap transient polls. Correctness is identical in all tiers — only latency differs (seconds / ≤ `POLL_WARM` / ≤ `POLL_COLD`).
## 6. Queue split (interactive vs bulk)
Two arq queues, consumed by `role=jobs` workers:
| Queue | Jobs | `job_timeout` | Concurrency per worker |
|---|---|---|---|
| `interactive` | send_job, push_flags_job, move_job, move_thread_to_folder_job, delete_thread_job, push_draft_to_imap_job, remove_imap_draft_job, refresh_account_job | 120 s | 20 |
| `bulk` | sync_bootstrap_account (and future re-backfills) | 3600 s | `BULK_CONCURRENCY` (default 2) |
Every jobs worker consumes both queues (arq supports it) with the above per-queue caps — a storm of backfills can no longer delay a send. Retry/backoff and failure-revert semantics per job are unchanged.
## 7. Backfill memory bounds
- Global backfill parallelism is bounded by the `bulk` queue concurrency (above) — remove any other ad-hoc caps.
- Per-process parse semaphore `PARSE_CONCURRENCY` (default 4) around MIME parsing.
- Streaming threshold: a raw message larger than `STREAM_PARSE_MB` (default 8) is written to the blob store first and parsed from the file handle; smaller messages may parse in memory. Result: peak RAM ≈ `PARSE_CONCURRENCY × STREAM_PARSE_MB` per process, independent of mailbox sizes.
- `sync.progress` publication cadence unchanged (per batch).
## 8. New configuration (add to SPEC §14; all via `config.py`)
`WORKER_ROLE=jobs|sync` · `MAX_ACCOUNTS_PER_WORKER=400` · `LEASE_TTL=60` · `LEASE_RENEW=20` · `POLL_WARM=180` · `POLL_COLD=900` · `TIER_HOT_ACTIVITY_H=24` · `TIER_SESSION_GRACE_MIN=30` · `TIER_COLD_AFTER_D=3` · `HOT_BUDGET_PER_WORKER=250` · `PER_HOST_CONN_CAP=50` · `BULK_CONCURRENCY=2` · `PARSE_CONCURRENCY=4` · `STREAM_PARSE_MB=8`. Remove `POLL_INTERVAL` (superseded by POLL_WARM/POLL_COLD).
## 9. Migration steps (execute in order; suite green after each)
1. **Roles + queues.** Add `WORKER_ROLE`; move job registration to the two named queues with the §6 routing/timeouts; compose gets `worker-sync` / `worker-jobs` services with `nofile` ulimits. Supervisor behavior untouched.
2. **Extract `AccountRunner`.** Refactor the current per-account loops into a self-contained runner class (start/stop/teardown API), still launched by the old startup supervisor. Pure refactor, no behavior change.
3. **Lease manager + claimer.** Implement §2 (worker_id, claim, Lua renewal, fencing, release, heartbeats, rebalance). Replace the startup supervisor with the claimer. Verify single-worker behavior is equivalent, then run two sync workers against the same accounts.
4. **Event-driven lifecycle.** Implement §3 including the new `user.active` event in the API and the per-cycle state re-verification. Delete the startup-only discovery code path.
5. **Tiers.** Implement §5 tier storage, evaluation, promotions/demotions, and the tier-shaped task sets in `AccountRunner`.
6. **Memory bounds.** Implement §7 streaming threshold and semaphores.
7. **Observability + graceful shutdown.** Structured log fields (`worker_id`, `account_id`, `tier`), heartbeat JSON per §2, SIGTERM handler: stop claimer → cancel runners → release leases → drain (target < 10 s).
## 10. Acceptance criteria (all must pass; add as automated tests where marked)
1. **[test]** Two sync workers, 20 mock accounts: at no point do two workers hold the same lease; each account has exactly one IDLE connection (assert via mock provider connection counter).
2. **[test]** `kill -9` one of two sync workers: every orphaned account is re-owned and syncing within 90 s; message table has zero duplicate `(mailbox_id, uid)` rows afterwards.
3. **[test]** Create an account via the API: its live loop starts within 30 s **without any worker restart**. Delete an account: all its connections closed and no further provider calls within 30 s (zombie regression test — mock provider records post-deletion calls, must be zero after the 30 s cutoff).
4. **[test]** Publish `user.active`: all of that user's accounts reach `hot` (IDLE established on mock) within 5 s. After session grace expiry with no activity, they demote to `warm` on the next evaluation cycle.
5. **[test]** Queue isolation: enqueue 10 bulk backfills + 20 interactive sends; all sends complete in < 5 s p95 while backfills run.
6. **[test]** SIGTERM a sync worker: leases released and adopted by the peer within 15 s; no fencing violations logged.
7. **[load, manual/CI-nightly]** 1000 simulated accounts (mock provider), 20 % with active sessions: steady-state persistent sockets ≤ hot count + 5 %; worker RSS stable over 1 h; oldest-sync-lag < `POLL_COLD` for every account.
8. Backfilling a mailbox containing a 40 MB message never raises process RSS by more than ~`PARSE_CONCURRENCY × STREAM_PARSE_MB` above baseline.
## 11. Out of scope for this task
Cross-worker (Redis-based) per-host connection accounting, pgbouncer deployment, JMAP provider, multi-region operation, per-user rate plans. Note them in `docs/open-questions.md` if the need is observed.
+744
View File
@@ -0,0 +1,744 @@
# Webmail Client — Implementation Specification (v1.3, agent-ready)
This document is a complete, self-contained build specification. It is written to be handed to an AI coding agent. Read it fully before writing code. The companion document `webmail-client-concept.md` gives background; where they differ, **this spec wins**.
---
## 0. Instructions to the implementing agent
1. **Build milestone by milestone** (§12), in order. Do not start a milestone before the previous one's acceptance criteria pass.
2. **Do not add features beyond this spec.** Anything not specified here is out of scope; leave `TODO(spec)` comments instead of inventing behavior.
3. **Definition of done** for every task: code + type hints + unit tests + passing `ruff check`, `ruff format --check`, `mypy --strict` (backend) / `tsc --noEmit`, `eslint` (frontend) + updated docs in `docs/` if an interface changed.
4. **Stability over novelty:** use exactly the pinned dependencies in §3. Do not swap libraries or add dependencies without a `# DEPENDENCY:` comment explaining why, and never for something the stdlib does adequately.
5. **All interfaces in §5 are frozen contracts.** Implementations may evolve; signatures may not (without bumping the version comment at the top of the interface file).
6. **Security rules in §10 are hard requirements**, not suggestions.
7. Every module publishing or consuming events must use the event names in §8 verbatim.
8. Commit in small, working increments; every commit must leave the test suite green.
## 1. Architecture invariants (never violate)
- **I-1 Sync-first:** the web UI and REST API never open IMAP connections. Only sync workers talk to mail servers. The API reads exclusively from Postgres/Meilisearch.
- **I-2 Independent identity:** login uses app accounts (`localpart@our-web-client.example`) with Argon2id hashes. Mailbox credentials are payload data, encrypted at rest, decrypted only inside sync workers.
- **I-3 One index, many views:** unified inbox, per-account views, virtual mailboxes, and search results are all parameterized queries over the same index. No duplicated storage per view.
- **I-4 Threading is ours:** JWZ in our backend (§7). Never use the IMAP `THREAD` extension.
- **I-5 Direction-agnostic thread UI:** thread ordering (oldest-first/newest-first) is a data-ordering concern. One layout, one composer-anchor slot (§9.4).
- **I-6 Plugins extend, core stays closed:** new features beyond this spec must be expressible through the plugin surfaces (§11).
- **I-7 Multi-user isolation:** every query (SQL and search) carries a mandatory `user_id` filter. No exceptions, including admin tooling.
- **I-8 Mail HTML is hostile input:** sanitize with nh3 server-side at index time AND render in a sandboxed iframe. Both, always.
## 2. Repository layout (monorepo)
```
webmail/
├── backend/
│ ├── pyproject.toml
│ ├── app/
│ │ ├── main.py # FastAPI app factory
│ │ ├── config.py # pydantic-settings; all config via env
│ │ ├── db.py # SQLAlchemy async engine/session
│ │ ├── events.py # EventBus (Redis pub/sub + arq enqueue)
│ │ ├── identity/ # §6.1 auth, sessions, handles, TOTP
│ │ ├── accounts/ # §6.2 wizard, autodiscovery, credential vault
│ │ ├── providers/
│ │ │ ├── base.py # MailProvider interface (§5.1)
│ │ │ └── imap_smtp/ # first implementation
│ │ ├── sync/ # §6.3 backfill, IDLE loops, reconciliation
│ │ ├── indexer/ # §6.4 MIME parsing → DB rows + blobs
│ │ ├── threading_/ # §7 JWZ (trailing _ avoids stdlib clash)
│ │ ├── search/
│ │ │ ├── base.py # SearchBackend interface (§5.2)
│ │ │ ├── queryparser.py # operator grammar (§8.2)
│ │ │ ├── pg.py # Postgres FTS backend (M0–M1)
│ │ │ └── meili.py # Meilisearch backend (M2)
│ │ ├── outbox/ # §6.5 drafts, MIME assembly, send queue, undo
│ │ ├── api/ # routers, one file per §9.1 group
│ │ ├── ws.py # WebSocket hub (§9.2)
│ │ └── plugins/ # §11 loader + plugin packages
│ ├── workers/worker.py # arq worker entrypoint
│ ├── migrations/ # alembic
│ └── tests/
├── frontend/
│ ├── package.json
│ └── src/
│ ├── api/ # generated client from OpenAPI + WS layer
│ ├── routes/ # §9.3
│ ├── components/
│ │ ├── thread/ThreadView.tsx # §9.4 direction-agnostic
│ │ ├── thread/Composer.tsx
│ │ ├── list/MessageList.tsx # virtualized
│ │ └── slots/ # plugin UI slots (§11)
│ └── lib/
├── docker/
│ ├── docker-compose.yml # app, worker, postgres:16, redis:7, meilisearch:v1.8, dovecot (test)
│ └── docker-compose.ci.yml # + greenmail for integration tests
└── docs/
```
## 3. Pinned stack
**Backend (Python 3.11):** fastapi ~0.115, uvicorn ~0.30, sqlalchemy ~2.0 (async, asyncpg), alembic ~1.13, pydantic ~2.8, pydantic-settings, arq ~0.26, redis ~5.0, aioimaplib ~1.1, imapclient ~3.0 (utility/fallback), aiosmtplib ~3.0, argon2-cffi ~23.1, pyotp ~2.9, dnspython ~2.6, httpx ~0.27, nh3 ~0.2, cryptography ~43.0, mail-parser ~4.0 (helpers only; primary parsing via stdlib `email.message.EmailMessage` with `policy.default`).
**Frontend:** TypeScript ~5.5, React ~18.3, Vite ~5, TanStack Query ~5, TanStack Virtual ~3, Radix UI, Tailwind ~3.4, zod (WS payload validation). API client generated from OpenAPI (`openapi-typescript`).
**Infra:** PostgreSQL 16 (with `citext` extension), Redis 7, Meilisearch v1.8 (M2+), MinIO or plain filesystem for the blob store (config-switchable; filesystem default).
## 4. Database schema (Postgres 16, authoritative DDL)
All PKs are `uuid DEFAULT gen_random_uuid()`. All tables carrying user data include `user_id` with an index. Timestamps are `timestamptz`.
```sql
CREATE EXTENSION IF NOT EXISTS citext;
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
handle citext UNIQUE NOT NULL, -- full form: localpart@<APP_DOMAIN>
password_hash text NOT NULL, -- argon2id
totp_secret bytea, -- encrypted; NULL = 2FA off
recovery_email citext,
settings jsonb NOT NULL DEFAULT '{}', -- incl. {"thread_order":"asc"|"desc"}
created_at timestamptz NOT NULL DEFAULT now()
);
-- Handle rules (enforce in code, mail-grade from day one):
-- localpart: 3–30 chars, [a-z0-9._-], no leading/trailing/double dot,
-- case-insensitive, reserved list (postmaster, abuse, admin, root, noreply, ...).
CREATE TABLE accounts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
email citext NOT NULL,
display_name text NOT NULL DEFAULT '',
color text NOT NULL DEFAULT '#888888',
imap_config jsonb NOT NULL, -- {host,port,tls:"ssl"|"starttls",auth:"password"|"oauth2"}
smtp_config jsonb NOT NULL, -- same shape
secret_ct bytea NOT NULL, -- envelope-encrypted password or OAuth token bundle (§10.2)
discovery_src text NOT NULL, -- preset|autoconfig|ispdb|srv|probe|manual
state text NOT NULL DEFAULT 'backfilling', -- backfilling|live|error|disabled
state_detail text NOT NULL DEFAULT '',
last_sync_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, email)
);
CREATE TABLE mailboxes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
account_id uuid NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
path text NOT NULL, -- IMAP mailbox name, UTF-7 decoded
special_use text, -- inbox|sent|drafts|trash|junk|archive|NULL
uidvalidity bigint NOT NULL DEFAULT 0,
highestmodseq bigint, -- NULL if no CONDSTORE
last_seen_uid bigint NOT NULL DEFAULT 0,
UNIQUE (account_id, path)
);
CREATE TABLE threads (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
subject_norm text NOT NULL, -- normalized subject (§7.3)
snippet text NOT NULL DEFAULT '',
participants jsonb NOT NULL DEFAULT '[]', -- [{name,email}], deduped, ordered by recency
account_ids uuid[] NOT NULL DEFAULT '{}',
message_count int NOT NULL DEFAULT 0,
unread_count int NOT NULL DEFAULT 0,
has_attachments boolean NOT NULL DEFAULT false,
last_message_at timestamptz NOT NULL
);
CREATE INDEX ON threads (user_id, last_message_at DESC);
CREATE TABLE messages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
account_id uuid NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
mailbox_id uuid NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
uid bigint NOT NULL, -- IMAP UID within mailbox
thread_id uuid REFERENCES threads(id),
message_id_hdr text, -- Message-ID, angle brackets stripped
in_reply_to text,
references_hdr text[] NOT NULL DEFAULT '{}',
subject text NOT NULL DEFAULT '',
from_addr jsonb NOT NULL DEFAULT '[]',
to_addr jsonb NOT NULL DEFAULT '[]',
cc_addr jsonb NOT NULL DEFAULT '[]',
date_hdr timestamptz,
internal_date timestamptz NOT NULL,
flags text[] NOT NULL DEFAULT '{}', -- \Seen,\Flagged,\Answered,\Draft,\Deleted
size_bytes int NOT NULL DEFAULT 0,
has_attachments boolean NOT NULL DEFAULT false,
snippet text NOT NULL DEFAULT '', -- first ~160 chars of text body
blob_sha256 text NOT NULL, -- raw RFC822 in blob store (§6.4)
body_indexed tsvector, -- Postgres FTS (M0–M1 SearchBackend)
UNIQUE (mailbox_id, uid)
);
CREATE INDEX ON messages (user_id, thread_id);
CREATE INDEX ON messages (account_id, message_id_hdr);
CREATE INDEX ON messages USING gin (body_indexed);
CREATE TABLE body_parts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
message_id uuid NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
kind text NOT NULL, -- text|html_sanitized
content text NOT NULL
);
CREATE TABLE attachments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
message_id uuid NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
user_id uuid NOT NULL,
filename text NOT NULL,
mime_type text NOT NULL,
size_bytes int NOT NULL,
content_id text, -- for inline cid: images
blob_sha256 text NOT NULL
);
CREATE TABLE drafts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
account_id uuid NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
thread_id uuid REFERENCES threads(id), -- NULL = new mail
reply_to_message_id uuid REFERENCES messages(id),
kind text NOT NULL DEFAULT 'reply', -- reply|reply_all|forward|new
payload jsonb NOT NULL, -- {to,cc,bcc,subject,html,text,attachment_ids}
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE outbox (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL,
draft_id uuid NOT NULL,
state text NOT NULL DEFAULT 'undo_window', -- undo_window|sending|sent|failed|cancelled
send_after timestamptz NOT NULL, -- now() + undo window
error text
);
CREATE TABLE saved_searches (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name text NOT NULL,
query text NOT NULL, -- raw operator-syntax string (§8.2)
pinned boolean NOT NULL DEFAULT true,
UNIQUE (user_id, name)
);
-- Core recipient autocomplete (Gmail-style, derived from mail history).
-- The Contacts plugin (Appendix B) ENRICHES suggestions; this table works without it.
CREATE TABLE known_recipients (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
email citext NOT NULL,
name text NOT NULL DEFAULT '', -- last seen display name
times_used int NOT NULL DEFAULT 0, -- weighted: sent-to counts 3, received-from counts 1
last_used_at timestamptz NOT NULL,
PRIMARY KEY (user_id, email)
);
CREATE INDEX ON known_recipients (user_id, email text_pattern_ops);
```
**Blob store:** raw RFC822 messages and attachment payloads are stored content-addressed at `${BLOB_ROOT}/{user_id}/{sha256[:2]}/{sha256}` (filesystem) or the equivalent object key (MinIO). Postgres holds metadata only. Deleting a user removes their blob subtree.
**Sessions** live in Redis (`sess:{token}` → JSON `{user_id, created, ua}`, TTL 30 days sliding), not Postgres.
## 5. Frozen core interfaces (Python)
### 5.1 `providers/base.py` — MailProvider
```python
# INTERFACE VERSION: 1
from dataclasses import dataclass
from typing import AsyncIterator, Protocol
from datetime import datetime
@dataclass(frozen=True)
class RemoteMailbox:
path: str
special_use: str | None # "inbox"|"sent"|"drafts"|"trash"|"junk"|"archive"|None
uidvalidity: int
@dataclass(frozen=True)
class RemoteMessage:
uid: int
raw: bytes # full RFC822
flags: frozenset[str]
internal_date: datetime
@dataclass(frozen=True)
class FlagChange:
uid: int
flags: frozenset[str]
@dataclass(frozen=True)
class MailboxDelta:
new: list[RemoteMessage]
flag_changes: list[FlagChange]
expunged_uids: list[int]
new_uidvalidity: int | None # non-None => UIDVALIDITY changed, caller must full-resync
new_highestmodseq: int | None
class MailProvider(Protocol):
async def verify(self) -> None: ...
# Raises ProviderAuthError / ProviderConnectError with a human-readable .detail
async def list_mailboxes(self) -> list[RemoteMailbox]: ...
def backfill(self, mailbox: str, *, newest_first: bool = True,
batch_size: int = 200) -> AsyncIterator[list[RemoteMessage]]: ...
# Yields batches until the mailbox is exhausted. Headers+body in one pass.
async def poll(self, mailbox: str, *, last_seen_uid: int,
highestmodseq: int | None) -> MailboxDelta: ...
# One reconciliation pass. Uses QRESYNC/CONDSTORE when highestmodseq is not None,
# otherwise UID SEARCH + FETCH FLAGS diff.
async def idle(self, mailbox: str) -> None: ...
# Blocks until the server signals change or 25 min elapse (re-issue IDLE). May raise
# ProviderNotSupported => sync loop falls back to poll() every POLL_INTERVAL (default 120 s).
async def push_flags(self, mailbox: str, uid: int,
add: frozenset[str], remove: frozenset[str]) -> None: ...
async def move(self, uid: int, src: str, dst: str) -> int: ... # returns new UID
async def append(self, mailbox: str, raw: bytes,
flags: frozenset[str]) -> int: ... # Sent/Drafts upload
async def send(self, raw: bytes, envelope_from: str,
envelope_to: list[str]) -> None: ... # SMTP submission
```
`ImapSmtpProvider` is the only M0–M2 implementation. A future `NativeProvider` (hosted mailboxes) or `JmapProvider` implements the same protocol — nothing above this interface may import IMAP libraries.
### 5.2 `search/base.py` — SearchBackend
```python
# INTERFACE VERSION: 1
@dataclass(frozen=True)
class SearchQuery:
text: str | None # free-text remainder after operator extraction
filters: dict[str, object] # parsed operators, keys per §8.2
user_id: UUID # ALWAYS set (invariant I-7)
@dataclass(frozen=True)
class SearchHit:
message_id: UUID
thread_id: UUID
score: float
highlights: dict[str, str] # field -> HTML-escaped snippet with <mark>
class SearchBackend(Protocol):
async def index(self, doc: SearchDoc) -> None: ...
async def delete(self, message_id: UUID) -> None: ...
async def delete_user(self, user_id: UUID) -> None: ...
async def query(self, q: SearchQuery, *, limit: int, cursor: str | None
) -> tuple[list[SearchHit], str | None]: ...
```
`SearchDoc` fields: `message_id, thread_id, user_id, account_id, mailbox_special_use, from_ (name+email), to, cc, subject, body_text, attachment_names[], has_attachments, is_unread, is_starred, date (unix), size_bytes`. Both backends (pg.py, meili.py) index the same doc. Meilisearch: one index `messages`, filterable = every non-body field incl. `user_id`, searchable = `subject, body_text, from_, to, cc, attachment_names`, sortable = `date`; ranking rules default + `date:desc` tiebreak.
### 5.3 `events.py` — EventBus
```python
# INTERFACE VERSION: 1
class EventBus(Protocol):
async def publish(self, event: str, payload: dict) -> None:
# Fan-out to Redis pub/sub channel "evt:{event}" AND enqueue arq job
# "on_{event}" for durable consumers (indexer, plugins).
def subscribe(self, event: str, handler: Handler) -> None: ...
```
Payloads always include `user_id`. Event names are listed in §8.3 and are the only ones allowed.
## 6. Backend module specifications
### 6.1 Identity (`app/identity/`)
- Registration: validate localpart per §4 rules against reserved list in `identity/reserved.py`; store full handle `localpart@{APP_DOMAIN}` (env `APP_DOMAIN`).
- Login: constant-time verify (argon2id, `time_cost=3, memory_cost=64MiB, parallelism=4`); if `totp_secret` set, require valid TOTP (±1 window). Rate limit: 10 attempts / 15 min per handle AND per IP (Redis counters); lockout returns 429 with `Retry-After`.
- Session cookie: `__Host-session`, httpOnly, Secure, SameSite=Lax. CSRF: double-submit token header `X-CSRF` for all mutating requests.
- TOTP setup: `POST /auth/totp/setup` returns provisioning URI + QR payload; enable requires one valid code; secret stored envelope-encrypted.
- Password reset: only if `recovery_email` verified; single-use token, 30 min TTL, Redis-backed.
### 6.2 Accounts & autodiscovery (`app/accounts/`)
`discover(email) -> list[CandidateConfig]` runs the chain, **returning on first success** but recording every step's outcome in the response (`steps: [{method, status, detail}]`) for wizard UX:
1. **Presets** — `accounts/presets.yaml` (config, not code): match by domain; ships with gmail.com, googlemail.com, outlook.com/hotmail/live, office365, yahoo, icloud/me.com, gmx, wp.pl, onet.pl, o2.pl, interia.pl. Each entry: imap/smtp host+port+tls, auth kind, oauth provider id if applicable.
2. **Autoconfig** — GET `https://autoconfig.{domain}/mail/config-v1.1.xml?emailaddress={email}` then `https://{domain}/.well-known/autoconfig/mail/config-v1.1.xml`; parse Thunderbird XML (incomingServer type=imap, outgoingServer type=smtp; `%EMAILADDRESS%`/`%EMAILLOCALPART%` substitution).
3. **ISPDB** — GET `https://autoconfig.thunderbird.net/v1.1/{domain}`; same XML format.
4. **DNS SRV (RFC 6186)** — `_imaps._tcp.{domain}` (SSL 993), `_imap._tcp` (STARTTLS 143), `_submissions._tcp` (465), `_submission._tcp` (587); lowest priority wins.
5. **Probe** — try `{imap,mail}.{domain}` × ports 993(ssl)/143(starttls) and `{smtp,mail}.{domain}` × 465(ssl)/587(starttls); TCP connect + TLS handshake + IMAP/SMTP greeting, 5 s timeout each, all concurrently.
All HTTP/DNS lookups: 5 s timeout, no redirects off-domain, HTTPS only. `test(config, credentials)`: full IMAP LOGIN + `LIST`, and SMTP EHLO+AUTH (no message sent); errors mapped to precise user-facing strings (`auth_failed`, `tls_failed`, `connect_timeout`, `unknown_host`). On save: encrypt credentials (§10.2), create account row (`state=backfilling`), enqueue `sync.bootstrap_account`.
### 6.3 Sync engine v2 (`app/sync/`) — sharded, lease-owned, tiered
(Authoritative detail: `sync-engine-v2-refactor.md`; this section summarizes the binding rules.)
- **Two worker roles** (`WORKER_ROLE`): `jobs` = stateless arq consumer, N instances; `sync` = lease claimer + per-account runners, 1..N instances. Compose services `worker-jobs` / `worker-sync`, `nofile` ulimit 65536.
- **Lease ownership:** exactly one sync worker owns each live account. Redis SET `sync:live` (API-maintained, DB-reconciled every 300 s); lease `sync:lease:{account_id}` claimed `SET NX EX LEASE_TTL`, renewed every `LEASE_RENEW` via owner-checked Lua; **fencing** — on renewal failure, cancel that account's runner before any further provider/DB op. Worker heartbeats in `sync:workers:{id}`; rebalance releases excess above `target*1.2+5`. Crash failover = lease expiry + reclaim (≤ ~90 s), no coordinator.
- **Event-driven lifecycle:** `account.state_changed` adds/removes ids from `sync:live` at runtime — accounts go live ≤ 30 s after creation and stop ≤ 30 s after deletion with no restarts; runners re-verify `state='live'` each cycle (zombie-proof). New core event **`user.active`** (API publishes on login/WS connect) drives tier promotion.
- **Bootstrap (unchanged from v1, now a `bulk` job):** `list_mailboxes()` → upsert `mailboxes` (SPECIAL-USE mapping with name heuristics — Sent/Wysłane, Trash/Kosz, etc. in `sync/folder_names.py`); backfill newest-first per mailbox (INBOX first, then sent, then rest); `sync.progress` published per batch; on completion set `state='live'` → the API adds the id to `sync:live` and a sync worker claims it.
- **AccountRunner per tier:** *hot* = persistent INBOX IDLE + transient CONDSTORE poll of special folders every `POLL_WARM`; *warm* = transient poll of INBOX+specials every `POLL_WARM`; *cold* = every `POLL_COLD`. Promotion to hot on `user.active` (≤ 5 s) or new mail within `TIER_HOT_ACTIVITY_H`; demotion by inactivity; `HOT_BUDGET_PER_WORKER` degrades least-active non-session accounts. Delta application, UIDVALIDITY wipe-and-rebackfill, expunge soft-handling, and write-then-enqueue outbound flags are unchanged from v1.
- **Connection budgets:** ≤ 2 sync-side connections per account (4 total incl. jobs, shared semaphore); process-local per-destination-host semaphore cap `PER_HOST_CONN_CAP`.
- **Queues:** `interactive` (send/flags/move/draft jobs, timeout 120 s, concurrency 20) and `bulk` (backfills, timeout 3600 s, concurrency `BULK_CONCURRENCY`) — bulk work can never delay a send.
- **Memory bounds:** parse semaphore `PARSE_CONCURRENCY`; raw messages > `STREAM_PARSE_MB` stream to blob and parse from file — peak RAM ≈ `PARSE_CONCURRENCY × STREAM_PARSE_MB` per process regardless of mailbox size.
- **Shutdown:** SIGTERM → stop claimer → cancel runners → release leases → drain in < 10 s.
### 6.4 Indexer (`app/indexer/`)
Input: `RemoteMessage`. Steps: store raw blob (sha256, dedupe) → parse with `email` stdlib (`policy.default`) → extract: headers (decode RFC2047), `references_hdr` = parsed `References` + `In-Reply-To` message-ids in order; best text body and best HTML body (multipart/alternative preference: html for display, text for indexing; if only HTML, derive text via nh3 strip); sanitize HTML with nh3 (allowlist: structural tags, inline styles filtered, `cid:` images rewritten to `/api/messages/{id}/cid/{content_id}`, remote `src` rewritten to blocked-placeholder attributes for FR-23) → write `messages`, `body_parts` (kinds `text` and `html_sanitized`), `attachments` → hand to Threader (§7) → `bus.publish("message.new", ...)` (search indexing + thread denormalization are consumers). For non-junk received messages, upsert the `From` address into `known_recipients` (weight 1).
### 6.5 Outbox (`app/outbox/`)
- Drafts: autosave endpoint upserts `drafts` (client debounces 2 s); a background job mirrors drafts to the account's Drafts folder via `append()` at most every 60 s per draft (replace previous UID).
- Send: `POST /send {draft_id}` → build MIME (html + auto-generated text alternative via html-to-text; `In-Reply-To`/`References` from `reply_to_message_id`; attachments from blob store) → create `outbox` row with `send_after = now()+UNDO_SECONDS` (env, default 10) → schedule arq job at `send_after`. Undo endpoint flips state to `cancelled` if still in window. The job: SMTP `send()`, then `append()` to Sent (skip for Gmail preset — Gmail self-appends), mark `sent`, publish `message.sent`, and upsert every To/Cc/Bcc address into `known_recipients` (weight 3). Failure → `failed` + WS notification, draft preserved.
## 7. Threading specification (JWZ, `app/threading_/`)
### 7.1 Normalization
- `message_id_hdr`, `in_reply_to`, each `References` entry: strip angle brackets and whitespace, lowercase the domain part only. Discard obviously invalid ids (no `@`).
- Missing/duplicate `Message-ID`: synthesize `sha256(raw)[:32]@synthetic.local` and mark it non-authoritative (never used to join other messages to it).
### 7.2 Incremental algorithm (per new message)
1. Candidate set = all normalized ids in `references_hdr` + `in_reply_to` + own `message_id_hdr`.
2. `SELECT DISTINCT thread_id FROM messages WHERE user_id=$1 AND message_id_hdr = ANY($2) AND thread_id IS NOT NULL`.
3. 0 threads → subject fallback (§7.3); still none → create new thread.
4. 1 thread → attach.
5. ≥2 threads → **merge**: keep the thread with the oldest `last_message_at` as canonical, repoint messages of the others, delete emptied thread rows, publish `thread.updated` for all involved.
6. Recompute denormalized thread fields (count, unread, participants, snippet = newest message's snippet, has_attachments, account_ids, last_message_at) in one UPDATE.
Threads are cross-account by design: the candidate lookup is scoped by `user_id`, not `account_id` (invariant I-3). Sent-folder messages join threads exactly like received ones (FR-15).
### 7.3 Subject fallback
Only when reference joining fails: normalize subject (strip repeated `Re:/Fw:/Fwd:/Odp:/PD:` prefixes case-insensitively, collapse whitespace); join to a thread with the same `subject_norm` that has activity within 7 days AND shares ≥1 participant email; never join two messages that both lack references AND have empty subjects.
### 7.4 Test vectors (must pass in `tests/threading/`)
Linear chain; chain arriving out of order; reply missing `In-Reply-To` but carrying `References`; thread merge on late-arriving parent; broken client (subject-only) joins; distinct-topic same-subject ("hi") does NOT join; cross-account merge (message in account A references message in account B); duplicate Message-ID; synthetic-ID isolation.
## 8. Search specification
### 8.1 Pipeline
Consumer of `message.new` / `message.changed` / `message.deleted` → builds `SearchDoc` → active `SearchBackend` (env `SEARCH_BACKEND=pg|meili`). Backfill indexes inline with sync (per batch), never as a separate pass the user waits on (FR-30).
### 8.2 Query grammar (`search/queryparser.py`, engine-independent)
Tokenization: whitespace-split, respecting `"quoted phrases"`. A token matching `key:value` (or `-key:value`) with a known key becomes a filter; everything else joins free text. `-` prefix negates a term or filter. `OR` (uppercase) groups adjacent terms/filters into a disjunction; default combinator is AND.
| Operator | Filter semantics |
|---|---|
| `from:` `to:` `cc:` | substring match on name or email of that field |
| `subject:` | match constrained to subject |
| `account:` | account by `display_name` or email substring |
| `in:` | `inbox\|sent\|drafts\|trash\|junk\|archive` (special_use) or folder path |
| `is:` | `unread\|read\|starred\|answered` |
| `has:attachment` | boolean |
| `filename:` | attachment name substring |
| `before:` `after:` | `YYYY-MM-DD` |
| `newer_than:` `older_than:` | `Nd\|Nw\|Nm\|Ny` |
| `larger:` `smaller:` | `NK\|NM` bytes |
Parser output = `SearchQuery` (§5.2). Unknown `key:` tokens are treated as free text (forward compatibility). Unit tests must cover every operator, negation, quoting, `OR`, and garbage input (parser never raises).
### 8.3 Event catalog (complete)
`message.new`, `message.changed`, `message.deleted`, `message.sent`, `flags.changed`, `thread.updated`, `sync.progress`, `account.state_changed`, `draft.saved`, `outbox.state_changed`, `user.active` (published by the API on login and WS connect; consumed by sync workers for tier promotion — not forwarded to browsers). Payloads: `{user_id, ...ids, minimal-diff fields}` — documented in `docs/events.md` (agent: generate it from code).
Plugins may additionally publish events namespaced `plugin.{name}.*` (e.g. `plugin.calendar.event_updated`); core code never subscribes to plugin events, but the WS hub forwards them to the owning user like any other event.
## 9. API, realtime, frontend
### 9.1 REST API (prefix `/api`, session-cookie auth, JSON; errors: `{error:{code,message}}`)
**Auth:** `POST /auth/register` · `POST /auth/login` · `POST /auth/logout` · `GET /auth/me` · `GET /auth/sessions` · `DELETE /auth/sessions/{id}` · `POST /auth/totp/setup` · `POST /auth/totp/enable` · `POST /auth/password/reset-request` · `POST /auth/password/reset`
**Accounts:** `POST /accounts/discover {email}` → `{candidates:[{imap,smtp,source}], steps:[...]}` · `POST /accounts/test {config, credentials}` → `{ok}|{error:{code,detail}}` · `POST /accounts` · `GET /accounts` (incl. `state`, sync progress) · `PATCH /accounts/{id}` · `DELETE /accounts/{id}` (cascades rows, blobs, search docs)
**Mail:** `GET /threads?view=unified|account:{id}|mailbox:{id}|search&q=&cursor=&limit=50` → thread summaries (denormalized fields only; cursor = `last_message_at,id` keyset) · `GET /threads/{id}?sort=asc|desc` → ordered full messages (bodies of the N newest expanded per client hint `expand=`; older = envelope only) · `POST /threads/{id}/flags {add,remove}` (whole thread) · `POST /messages/{id}/flags` · `POST /messages/{id}/move {mailbox_id}` · `GET /messages/{id}/html` (sanitized, for iframe `srcdoc`) · `GET /messages/{id}/cid/{content_id}` · `GET /attachments/{id}` (Content-Disposition attachment; images/PDF also inline for preview) · `POST /messages/{id}/allow-remote-images` (per-sender allowlist in user settings)
**Compose:** `POST /drafts` · `PUT /drafts/{id}` · `DELETE /drafts/{id}` · `POST /send {draft_id}` → `{outbox_id, undo_until}` · `POST /send/{outbox_id}/undo` · `GET /recipients/suggest?q=` → top 10 from `known_recipients` (prefix match on email or name, ranked by `times_used` decayed by recency); the composer merges these with any plugin capability providers (§11.3)
**Search:** `GET /search?q=&cursor=` → `{threads:[...], hits_by_thread, highlights}` (grouped by thread, FR-28; `&group=message` for flat) · `GET/POST/DELETE /saved-searches`
**Settings:** `GET/PATCH /settings` (incl. `thread_order: "asc"|"desc"`, `tz` IANA timezone — default from browser at registration, undo window, per-sender image allowlist)
### 9.2 WebSocket `/api/ws`
Auth via session cookie. Server → client frames: `{event, payload}` for every §8.3 event belonging to the user (that's the whole contract — the UI refetches via TanStack Query invalidation keyed on ids in the payload). Client → server: `{type:"ping"}` only. Zod-validate all frames.
### 9.3 Frontend routes
`/login`, `/register`, `/mail` (unified inbox), `/mail/a/:accountId`, `/mail/a/:accountId/f/:mailboxId`, `/mail/t/:threadId` (thread pane; on desktop rendered beside the list), `/mail/search?q=`, `/settings/{profile|security|accounts|appearance}`; `/settings/accounts/new` = wizard.
**Account wizard flow:** step 1 email (+ password field revealed unless preset says oauth) → calls `discover` while typing pauses; step 2 shows resolved config with source badge ("found via ISPDB") and a Test button (auto-run); failure expands the manual form pre-filled with the best guess and the precise error; step 3 = created, live backfill progress bar (WS `sync.progress`) with "start reading now" (list fills as backfill proceeds).
### 9.4 ThreadView contract (invariant I-5)
```
ThreadView(props: { messages: Message[] /* ASC always */, order: 'asc'|'desc',
composerState, ... })
```
- Rendering order = `order==='asc' ? messages : [...messages].reverse()`. No other branching on `order` is allowed in layout code.
- **Composer anchor** = a slot component rendered adjacent to the newest message: after it when `asc` (bottom of thread), directly after it when `desc` (top of thread, above older messages). Same component, one placement rule: "composer is always attached to the newest message."
- Initial scroll: to the newest message (bottom for asc, top for desc). Collapse: all read messages except the newest are collapsed; "Expand all" toggles. Unread messages are never collapsed.
- Keyboard: `j`/`k` move in *visual* order; `o`/`Enter` expand; `r` reply, `a` reply-all, `f` forward (open composer at anchor); `e` archive; `/` focus search; `gi` go to unified inbox; `esc` close composer (draft persists). Toolbar has the per-thread order toggle; it updates local state only, and long-presses/menu offers "make default" (PATCH /settings).
- Message HTML renders in `<iframe sandbox="" srcdoc={...}>` sized via ResizeObserver postMessage shim; remote images show a "load images / always from this sender" bar when the sanitizer flagged any.
## 10. Security requirements (hard)
1. Argon2id params per §6.1; no other password hashing anywhere.
2. **Credential vault:** mailbox secrets encrypted with AES-256-GCM under per-account data keys, wrapped by a master key from env `MASTER_KEY` (32 B, base64) — `cryptography` library, key id embedded for future rotation. Decryption code path exists only in `workers/` and `accounts/test`. Secrets never appear in logs, API responses, or exceptions (custom repr).
3. CSRF double-submit on all mutating routes; `__Host-` cookie prefix; CSP: `default-src 'self'; frame-src 'self'; img-src 'self' data:` (remote mail images are proxied? **No** — v1 loads allowed remote images inside the sandboxed iframe only, which has its own relaxed CSP via srcdoc; document this in code).
4. nh3 sanitization at index time; re-sanitize on render if sanitizer version changed (store `sanitizer_v` on body_parts).
5. Rate limits: login (§6.1), `POST /accounts/discover` 10/min/user, `POST /send` 30/h/user (env-tunable).
6. All outbound autodiscovery HTTP: HTTPS only, 5 s timeout, response size cap 256 KB, no off-domain redirects (SSRF guard: resolve + reject private IP ranges).
7. Every SQL query and search query includes `user_id` (enforced by repository-layer helpers; raw session use in API code is a review error).
8. Uploads (attachments to drafts): 25 MB/file, 40 MB/message total, content-type sniffed server-side, stored to blob store, never executed/served with original content type unless in safe list (images/pdf/text) — others force `application/octet-stream` + attachment disposition.
## 11. Plugin API (freeze in M3 — including the heavy-plugin surfaces below)
A plugin = Python package `webmail_plugin_<name>` exposing:
```python
plugin = Plugin(
name, version,
hooks = {event: handler}, # subscribe to §8.3 events
router = APIRouter | None, # mounted at /api/plugins/{name}
ui_manifest = UiManifest | None, # slots + pages, see below
migrations = "migrations/" | None, # 11.1
jobs = [JobSpec, ...] | None, # 11.2
)
```
Loader discovers plugins via entry points group `webmail.plugins`, forwards declared events, and serves `GET /api/plugins/ui-manifest` for the frontend loader. Plugins may not import from `app.*` internals other than `app.plugins.sdk` (which exposes: DB session factory scoped to plugin tables, EventBus publish under `plugin.{name}.*`, blob store, outbox draft/send helpers, settings accessor, HTTP client with the §10.6 SSRF guard built in).
Every plugin gets the namespaced KV store (`plugin_kv(plugin, user_id, key)`) for light state. Heavy plugins additionally get:
### 11.1 Plugin-owned tables
A plugin may ship Alembic migrations in its own version branch (`alembic -n plugin_{name}`). All tables MUST be prefixed `plg_{name}_` and every user-data table MUST carry `user_id` (invariant I-7 applies to plugins). Core migrations never reference plugin tables and vice versa. Uninstalling a plugin offers `--drop-data` to run its downgrade chain.
### 11.2 Plugin background jobs
`JobSpec(func, schedule: cron_str | None, singleton: bool)` registers tasks on the shared arq worker: cron-scheduled (e.g. a sync loop trigger) or enqueued on demand from hooks/routes. `singleton=True` guarantees at most one running instance per (plugin, user) via a Redis lock. Plugin jobs run in the same worker process pool with the same retry semantics as core jobs and are visible in worker logs under `plg.{name}.*`.
### 11.3 Frontend surfaces
- **Slots** (lazy-loaded ESM injected into mail UI): `thread.toolbar`, `message.actions`, `sidebar.section`, `settings.page`.
- **Pages** (`app.page`): a plugin may declare full views: `{route: "/calendar", nav: {icon, label, order}, bundle}`. The shell adds the nav entry, and the route (including sub-routes under it) lazy-loads the bundle into the **entire content area** — no mail list/thread components are rendered. Page bundles receive the SDK context (API client, WS event stream filtered to `plugin.{name}.*` + requested core events, design-system components) and are sandboxed to their route: importing mail-component internals is a review error. Removing the plugin removes the nav entry and route cleanly.
- **Capabilities**: the manifest may declare named capabilities mapping to plugin endpoints, e.g. `capabilities: {"recipients.suggest": "/api/plugins/contacts/suggest"}`. Core UI features query all providers of a capability they support and merge results client-side (defined capabilities in v1: `recipients.suggest` — used by the composer To/Cc/Bcc fields and by any other plugin needing person-picking, such as calendar attendees). This keeps core backend code plugin-unaware.
Acceptance test for the API: implement **filters** (rules on `message.new`: match → move/flag) and **labels** entirely as plugins using only slots + KV; then implement the **calendar** (Appendix A) using tables + jobs + a page — all with zero core diffs.
## 12. Milestones with acceptance criteria
**M0 — Architecture spike**
Login/register (no 2FA), one hardcoded-config IMAP account, backfill+IDLE for INBOX, JWZ, read-only unified list + thread view with working asc/desc toggle and correct composer-anchor placeholder, Postgres FTS free-text search.
✅ Accept: sync a real 10k-message Gmail account via app password; threads match Gmail's grouping on a 50-thread sample ≥ 90%; toggle order and the newest message + anchor swap ends correctly; search returns in < 500 ms.
**M1 — MVP**
Full identity (2FA, sessions UI, reset), account wizard with full discovery chain, multi-account + unified inbox with account chips, flags/move/archive both directions, inline reply + reply-all/forward + SMTP send + Sent append, recipient autocomplete from `known_recipients`, drafts autosave, sanitized HTML + blocked remote images, operator search (full §8.2 grammar) on PG FTS, WS live updates.
✅ Accept: add gmail.com, outlook.com, and one autoconfig-only domain through the wizard typing only address+password; reply from a thread lands correctly threaded in the recipient's client; all §8.2 operators pass integration tests; `docker-compose up` from clean checkout works.
**M2 — Hardening / v1.0**
OAuth2 (Google + Microsoft, XOAUTH2 for IMAP/SMTP, server-side refresh), QRESYNC fast resync, drafts mirrored to server, undo-send, attachment upload/preview, Meilisearch backend + instant search-as-you-type UI + saved searches as virtual mailboxes, session revocation, backfill progress UX, folder-name i18n heuristics.
✅ Accept: typo query (`invocie`) finds "invoice" mail < 200 ms on a 100k-message corpus; kill -9 the worker mid-backfill and restart resumes without duplicates (idempotency via `(mailbox_id, uid)` unique key); undo-send within window results in no SMTP traffic.
**M3 — Plugin platform**
Loader, full SDK per §11 **including** plugin migrations (11.1), plugin jobs (11.2), and `app.page` (11.3); filters + labels shipped as plugins; plugin docs.
✅ Accept: both plugins install/uninstall cleanly with zero core diffs; a third trivial plugin ("Unsubscribe header button" on `message.actions`) can be written from `docs/plugins.md` alone; a skeleton page-plugin (route + nav entry + one plugin table + one cron job) round-trips install → run → uninstall `--drop-data` leaving no residue.
**M4 — Calendar plugin (Appendix A)**
Phases A1–A2: local calendars + full page views + iMIP invite handling; then A3: CalDAV client sync + ICS subscriptions.
✅ Accept: criteria in Appendix A §A.7.
**M5 — Contacts plugin (Appendix B)**
Phases B1–B2: contact books, full page, groups, add-from-mail, `recipients.suggest` capability, vCard import/export; then B3: CardDAV client sync. May be built in parallel with M4 — both depend only on the M3 SDK.
✅ Accept: criteria in Appendix B §B.7.
**M6 (optional) — Hosted mailboxes**
Inbound MTA (Stalwart) delivering to a `NativeProvider`; handle becomes receiving address. Specified separately when reached.
## 13. Testing & CI
- **Unit:** pytest + pytest-asyncio; threading vectors (§7.4); query parser (§8.2); autodiscovery resolver with `respx`-mocked HTTP + `dnspython` stub; MIME corpus in `tests/fixtures/mime/` (agent: include ≥ 25 nasty real-world-style fixtures — encoded headers incl. RFC2047 Polish diacritics, nested multiparts, missing Message-ID, malformed dates, HTML-only, inline cid images, 8-bit subjects, winmail.dat placeholder).
- **Integration:** docker-compose.ci.yml with Greenmail (IMAP+SMTP) and Dovecot; full loop test: append message to Greenmail → appears in API < 10 s → reply via API → arrives in Greenmail SMTP sink with correct References.
- **Frontend:** vitest + React Testing Library; ThreadView ordering/anchor tests for both directions; Playwright smoke: login → add Greenmail account via wizard → read → inline reply → search.
- **CI gates:** ruff, mypy --strict, pytest, tsc, eslint, vitest, Playwright smoke; coverage floor 80% on `threading_`, `search/queryparser`, `accounts/autodiscovery`, `indexer`.
## 14. Configuration (env, complete list)
`APP_DOMAIN`, `DATABASE_URL`, `REDIS_URL`, `MASTER_KEY`, `BLOB_ROOT` or `S3_*`, `SEARCH_BACKEND=pg|meili`, `MEILI_URL`, `MEILI_KEY`, `UNDO_SECONDS=10`, `SESSION_TTL_DAYS=30`, `OAUTH_GOOGLE_CLIENT_ID/SECRET`, `OAUTH_MS_CLIENT_ID/SECRET`, `MAX_ATTACHMENT_MB=25`, `LOG_LEVEL`; sync engine v2 (§6.3): `WORKER_ROLE=jobs|sync`, `MAX_ACCOUNTS_PER_WORKER=400`, `LEASE_TTL=60`, `LEASE_RENEW=20`, `POLL_WARM=180`, `POLL_COLD=900`, `TIER_HOT_ACTIVITY_H=24`, `TIER_SESSION_GRACE_MIN=30`, `TIER_COLD_AFTER_D=3`, `HOT_BUDGET_PER_WORKER=250`, `PER_HOST_CONN_CAP=50`, `BULK_CONCURRENCY=2`, `PARSE_CONCURRENCY=4`, `STREAM_PARSE_MB=8`. All read once in `config.py` (pydantic-settings); no `os.environ` elsewhere.
---
## Appendix A — Calendar plugin (`webmail_plugin_calendar`)
The calendar is the reference "heavy plugin": it lives in the same app and repo, uses the §11 SDK only, and must build with **zero core diffs**. It ships as a page (`/calendar`) beside Mail — one product, one login.
### A.1 Scope by phase
- **A1 — Local calendar:** create/edit/delete events, multiple calendars per user, month/week/day/agenda views, recurrence, reminders (WS notification), timezone-correct display.
- **A2 — Invites over mail (iMIP):** detect `text/calendar` parts on `message.new`; render an invite card in the thread (`message.actions` + inline summary); Accept/Tentative/Decline sends an iTIP REPLY through the outbox; organizer flow sends REQUEST/CANCEL to attendees; updates (SEQUENCE bumps) modify the stored event.
- **A3 — External sources:** `CalendarProvider` interface with **CalDAV client** (first implementation; covers iCloud, Fastmail, Nextcloud) and read-only **ICS URL subscriptions** (holidays, team feeds); sync loop as a plugin job.
- **A4 (deferred, spec later):** Google/Microsoft API providers reusing M2 OAuth machinery; secret **ICS export feed** `GET /api/plugins/calendar/feed/{token}.ics`; CalDAV **server** endpoint for native phone sync — architecturally anticipated, not built now.
### A.2 Dependencies
`icalendar ~5.0` (parse/serialize RFC 5545), `python-dateutil` (RRULE expansion), `caldav ~1.3` (A3), `tzdata`. No other additions.
### A.3 Data model (plugin migrations, prefix `plg_calendar_`)
```sql
CREATE TABLE plg_calendar_calendars (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL,
name text NOT NULL,
color text NOT NULL DEFAULT '#3b82f6',
kind text NOT NULL DEFAULT 'local', -- local|caldav|ics_subscription
source_config jsonb NOT NULL DEFAULT '{}', -- caldav url/collection, ics url
secret_ct bytea, -- caldav credentials, §10.2 vault scheme
sync_token text, -- CalDAV sync-token / ICS etag
read_only boolean NOT NULL DEFAULT false,
UNIQUE (user_id, name)
);
CREATE TABLE plg_calendar_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL,
calendar_id uuid NOT NULL REFERENCES plg_calendar_calendars(id) ON DELETE CASCADE,
ical_uid text NOT NULL, -- UID from iCalendar; join key for iMIP updates
sequence int NOT NULL DEFAULT 0,
summary text NOT NULL DEFAULT '',
description text NOT NULL DEFAULT '',
location text NOT NULL DEFAULT '',
dtstart timestamptz NOT NULL,
dtend timestamptz,
all_day boolean NOT NULL DEFAULT false,
tz text NOT NULL DEFAULT 'UTC', -- original TZID; render in user tz, store UTC
rrule text, -- raw RRULE string, NULL = single
exdates timestamptz[] NOT NULL DEFAULT '{}',
recurrence_id timestamptz, -- non-NULL = detached exception instance
organizer jsonb, -- {name,email}
attendees jsonb NOT NULL DEFAULT '[]', -- [{name,email,partstat,role}]
status text NOT NULL DEFAULT 'confirmed', -- confirmed|tentative|cancelled
my_partstat text, -- needs-action|accepted|declined|tentative
reminders int[] NOT NULL DEFAULT '{}', -- minutes before start
origin_message_id uuid, -- link back to the invite mail thread
raw_ical text, -- round-trip fidelity for iMIP/CalDAV
UNIQUE (calendar_id, ical_uid, recurrence_id)
);
CREATE INDEX ON plg_calendar_events (user_id, dtstart);
```
**Recurrence rule:** store the RRULE string; expand instances at query time with `dateutil.rrule` over the requested view range only (no materialization table). Exceptions: `exdates` remove instances; a row with `recurrence_id` overrides one instance. Range queries must account for events starting before the window but recurring into it (expand from `dtstart`, cap iterations at 1000 per event).
### A.4 CalendarProvider interface (inside the plugin, mirrors §5.1)
```python
# PLUGIN INTERFACE VERSION: 1
class CalendarProvider(Protocol):
async def verify(self) -> None: ...
async def list_collections(self) -> list[RemoteCalendar]: ...
async def pull(self, collection: str, *, sync_token: str | None
) -> tuple[list[RemoteEvent], list[str], str]: ...
# returns (changed_events_as_ical, deleted_uids, new_sync_token)
async def push(self, collection: str, ical: str, *, uid: str,
etag: str | None) -> str: ... # returns new etag
async def delete(self, collection: str, uid: str, etag: str | None) -> None: ...
```
Implementations in A3: `CaldavProvider`, `IcsSubscriptionProvider` (pull-only, `push`/`delete` raise `ReadOnlyCalendar`). Sync loop = plugin cron job (`schedule="*/5 * * * *"`, `singleton=True` per user) doing pull → upsert by `(calendar_id, ical_uid, recurrence_id)` → local-dirty push with etag conflict handling (conflict ⇒ server wins, local copy saved as a duplicate marked `status='tentative'`, user notified).
### A.5 iMIP flow (A2)
- **Inbound:** hook on `message.new`; if a `text/calendar` part exists, parse METHOD:
`REQUEST` → upsert event (match `ical_uid`; apply only if `SEQUENCE` ≥ stored) into the user's default calendar with `my_partstat='needs-action'`, set `origin_message_id`; `CANCEL` → `status='cancelled'`; `REPLY` → update that attendee's `partstat` on the organizer's copy.
- **RSVP:** invite card buttons call `POST /api/plugins/calendar/events/{id}/rsvp {partstat}` → update row → build iTIP REPLY ics → send via SDK outbox helper to the organizer (subject `Accepted: {summary}` etc.) → publish `plugin.calendar.event_updated`.
- **Organizer:** creating an event with attendees sends METHOD:REQUEST to each; edits bump `SEQUENCE` and re-send; deletion sends CANCEL. All outgoing invite mail uses the account whose address matches the organizer identity (picker if ambiguous).
### A.6 UI (page `/calendar`, nav icon between Mail and Settings)
Routes: `/calendar` (month default), `/calendar/{month|week|day|agenda}/:date`, `/calendar/event/:id`, `/calendar/settings` (calendars CRUD, CalDAV/ICS wizard reusing the account-wizard UX pattern: URL+credentials → test → collection picker). Interactions: click-drag create, drag-move/resize (writes optimistically, reverts on API error), event popover with edit/RSVP, mini-month navigator, calendar visibility toggles (client state), `t` = today, arrows = prev/next period. Invite linkage both ways: invite card in thread links to `/calendar/event/:id`; event view links back to the mail thread when `origin_message_id` is set. Live updates via WS `plugin.calendar.*` events. Timezone: render in user setting `settings.tz` (add to §9.1 settings; default from browser at registration).
### A.7 Acceptance criteria
1. Plugin installs/uninstalls (`--drop-data`) with zero diffs in `app/` and `frontend/src/` outside `plugins/`/`slots/` loader code.
2. A weekly-recurring event with two EXDATEs and one moved instance renders correctly in month and week views across a DST boundary (Europe/Warsaw fixtures).
3. Round-trip with Google Calendar **via iMIP mail only**: receive a Google invite → Accept in our UI → organizer's Google Calendar shows attendee accepted; our organizer invite → Google attendee accepts → our event shows `partstat=accepted`.
4. CalDAV sync against a Nextcloud/Radicale test container: create/edit/delete propagate both directions in ≤ 5 min (one cron cycle); conflict case produces the A.4 tentative-duplicate, never silent loss.
5. ICS subscription to a public holiday feed renders read-only events; editing is blocked in UI and API (403 `read_only_calendar`).
6. Killing the worker mid-sync and restarting causes no duplicate events (upsert key discipline).
---
## Appendix B — Contacts plugin (`webmail_plugin_contacts`)
The second heavy plugin: same rules as the calendar — §11 SDK only, zero core diffs, own page. Division of labor with core: core owns *implicit* recipient history (`known_recipients`, §4/§9.1) so autocomplete works even without this plugin; the Contacts plugin owns *explicit*, user-curated contact records and enriches suggestions via the `recipients.suggest` capability (§11.3).
### B.1 Scope by phase
- **B1 — Local contacts:** address books, contact CRUD with structured fields, groups, photo, search, dedicated page `/contacts`; "Add sender to contacts" from the thread view; suggestion capability wired into the composer.
- **B2 — Portability:** vCard 3.0/4.0 import (file, multi-card) and export (single contact / book), duplicate detection + merge tool.
- **B3 — External sources:** `ContactProvider` interface with **CardDAV client** sync (iCloud, Fastmail, Nextcloud, Radicale); per-book sync job.
- **B4 (deferred, spec later):** Google People / Microsoft Graph providers (reusing M2 OAuth), CardDAV **server** endpoint for native phone sync, book sharing.
### B.2 Dependencies
`vobject ~0.9` (vCard parse/serialize). Photos stored via the SDK blob store (JPEG/PNG, re-encoded server-side, ≤ 512×512). Nothing else.
### B.3 Data model (plugin migrations, prefix `plg_contacts_`)
```sql
CREATE TABLE plg_contacts_books (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL,
name text NOT NULL,
kind text NOT NULL DEFAULT 'local', -- local|carddav
source_config jsonb NOT NULL DEFAULT '{}',-- carddav url/collection
secret_ct bytea, -- carddav credentials, §10.2 vault scheme
sync_token text,
read_only boolean NOT NULL DEFAULT false,
UNIQUE (user_id, name)
);
CREATE TABLE plg_contacts_contacts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL,
book_id uuid NOT NULL REFERENCES plg_contacts_books(id) ON DELETE CASCADE,
vcard_uid text NOT NULL, -- UID property; join key for CardDAV
etag text, -- server etag (carddav books)
name_display text NOT NULL DEFAULT '', -- FN
name_parts jsonb NOT NULL DEFAULT '{}', -- {given,family,middle,prefix,suffix} (N)
emails jsonb NOT NULL DEFAULT '[]', -- [{value,type,pref}]
phones jsonb NOT NULL DEFAULT '[]',
addresses jsonb NOT NULL DEFAULT '[]', -- structured ADR
org text NOT NULL DEFAULT '',
title text NOT NULL DEFAULT '',
birthday date,
urls jsonb NOT NULL DEFAULT '[]',
notes text NOT NULL DEFAULT '',
photo_sha256 text,
raw_vcard text, -- round-trip fidelity: preserve unknown props
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (book_id, vcard_uid)
);
CREATE INDEX ON plg_contacts_contacts (user_id, name_display);
CREATE INDEX ON plg_contacts_contacts USING gin (emails jsonb_path_ops);
CREATE TABLE plg_contacts_groups (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL,
name text NOT NULL,
UNIQUE (user_id, name)
);
CREATE TABLE plg_contacts_group_members (
group_id uuid NOT NULL REFERENCES plg_contacts_groups(id) ON DELETE CASCADE,
contact_id uuid NOT NULL REFERENCES plg_contacts_contacts(id) ON DELETE CASCADE,
PRIMARY KEY (group_id, contact_id)
);
```
vCard mapping discipline: parse known properties into columns, keep the full source in `raw_vcard`, and on export/push regenerate from columns **merged over** `raw_vcard` so properties we don't model (e.g. `X-` extensions) survive round-trips.
### B.4 ContactProvider interface (inside the plugin, mirrors A.4)
```python
# PLUGIN INTERFACE VERSION: 1
class ContactProvider(Protocol):
async def verify(self) -> None: ...
async def list_books(self) -> list[RemoteBook]: ...
async def pull(self, book: str, *, sync_token: str | None
) -> tuple[list[RemoteVcard], list[str], str]: ...
# (changed vcards, deleted uids, new sync_token)
async def push(self, book: str, vcard: str, *, uid: str,
etag: str | None) -> str: ...
async def delete(self, book: str, uid: str, etag: str | None) -> None: ...
```
Sync loop = plugin cron job (`*/5 * * * *`, singleton per user), upsert by `(book_id, vcard_uid)`, etag conflict ⇒ server wins + local copy kept as a duplicate flagged for the merge tool (never silent loss) — same policy as A.4.
### B.5 API & capability (`/api/plugins/contacts/`)
`GET /contacts?book=&group=&q=&cursor=` · `POST /contacts` · `GET/PUT/DELETE /contacts/{id}` · `POST /contacts/from-message {message_id}` (prefill from From header) · `GET/POST/DELETE /books`, `/groups`, group membership ops · `POST /import` (vCard file → report `{created, updated, skipped}`) · `GET /export?book=|contact=` (`.vcf`) · `GET /duplicates` (candidate pairs: same email, or same normalized name + fuzzy) · `POST /merge {keep_id, remove_id}` (field union, keep wins conflicts, group memberships merged) · **`GET /suggest?q=`** — the `recipients.suggest` capability endpoint: matches name/email prefix and substring, returns `[{name, email, source:"contact", contact_id, photo_url?}]`. Composer merge rule (client-side, defined here so behavior is deterministic): contact hits rank above `known_recipients` hits at equal prefix quality; dedupe by email, contact entry wins and carries the photo/display name.
### B.6 UI
Page `/contacts` (nav entry after Calendar): three-pane layout — books/groups sidebar, alphabetically grouped virtualized list with search-as-you-type, detail pane with edit-in-place form; import/export and merge review under `/contacts/tools`; CardDAV book wizard reusing the account-wizard UX pattern (URL + credentials → test → book picker). Mail integration via `message.actions` slot: "Add to Contacts" (hidden when the sender already matches a contact; then it becomes "View contact" linking to `/contacts/{id}`). Group compose: selecting a group in the composer expands to its members' preferred emails.
### B.7 Acceptance criteria
1. Zero core diffs; install/uninstall `--drop-data` round-trip clean.
2. Import a 500-card vCard file mixing v3.0/v4.0, quoted-printable encoding, and Polish diacritics in names (`Grzegorz Brzęczyszczykiewicz`) — all names, emails, and photos correct; re-export → re-import is lossless including `X-` properties (raw_vcard merge discipline).
3. Composer typing a 3-char prefix returns merged suggestions in < 100 ms p95 (contacts ranked above history, deduped by email, photos shown for contacts).
4. CardDAV sync against Radicale: create/edit/delete propagate both directions within one cron cycle; concurrent edit conflict produces a flagged duplicate, never silent loss; worker kill mid-sync ⇒ no duplicate contacts on restart.
5. "Add sender to contacts" from a thread creates a prefilled contact; the same sender's messages immediately render the contact's display name, and the action flips to "View contact".
6. Merge tool: merging two contacts unions emails/phones/groups, keeps the chosen primary's conflicting fields, and repoints nothing else in the system (calendar attendees and mail remain keyed by email, by design).
---
*End of specification. Ambiguities: prefer the simplest behavior consistent with the invariants in §1, leave a `TODO(spec)` comment, and list open questions in `docs/open-questions.md` rather than guessing silently.*