diff --git a/backend/app/accounts/schemas.py b/backend/app/accounts/schemas.py index c4a9fc6..cd282f2 100644 --- a/backend/app/accounts/schemas.py +++ b/backend/app/accounts/schemas.py @@ -61,6 +61,11 @@ class CreateAccountRequest(BaseModel): class UpdateAccountRequest(BaseModel): display_name: str | None = None color: str | None = None + signature: str | None = None + # Connection/credential changes require the password (re-validated + re-encrypted). + imap: ServerConfigIn | None = None + smtp: ServerConfigIn | None = None + password: str | None = None class AccountDetailOut(BaseModel): @@ -70,5 +75,6 @@ class AccountDetailOut(BaseModel): color: str state: str state_detail: str + signature: str imap_config: dict[str, object] smtp_config: dict[str, object] diff --git a/backend/app/api/accounts.py b/backend/app/api/accounts.py index f8304f2..e9206f3 100644 --- a/backend/app/api/accounts.py +++ b/backend/app/api/accounts.py @@ -32,6 +32,7 @@ from app.identity.deps import CurrentUser, require_csrf from app.models import Account from app.queue import get_arq_pool from app.redis_client import get_redis +from app.security.crypto import encrypt_secret router = APIRouter(prefix="/accounts", tags=["accounts"]) @@ -75,12 +76,27 @@ async def list_accounts(user: CurrentUser, db: DbSession) -> list[AccountOut]: ) return [ AccountOut( - id=a.id, email=a.email, display_name=a.display_name, color=a.color, state=a.state + id=a.id, + email=a.email, + display_name=a.display_name, + color=a.color, + state=a.state, + signature=a.signature, ) for a in accounts ] +@router.post("/sync", dependencies=[Depends(require_csrf)]) +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)) + return {"status": "queued"} + + @router.post("/discover", response_model=DiscoverResponse, dependencies=[Depends(require_csrf)]) async def discover_account(body: DiscoverRequest, user: CurrentUser) -> DiscoverResponse: await _rate_limit_discover(user.id) @@ -135,6 +151,25 @@ async def create_new_account( return _detail(account) +@router.get("/{account_id}", response_model=AccountDetailOut) +async def get_account(account_id: uuid.UUID, user: CurrentUser, db: DbSession) -> AccountDetailOut: + account = await db.scalar( + select(Account).where(Account.id == account_id, Account.user_id == user.id) + ) + if account is None: + raise_api_error(404, "not_found", "account not found") + return _detail(account) + + +def _server_from_config(cfg: dict[str, object]) -> ServerConfig: + return ServerConfig( + str(cfg["host"]), + int(str(cfg["port"])), + str(cfg.get("tls", "ssl")), + str(cfg.get("auth", "password")), + ) + + @router.patch( "/{account_id}", response_model=AccountDetailOut, dependencies=[Depends(require_csrf)] ) @@ -150,7 +185,51 @@ async def update_account( account.display_name = body.display_name if body.color is not None: account.color = body.color + if body.signature is not None: + account.signature = body.signature + + # Connection settings and/or password: re-validate against the servers, then + # re-encrypt the secret and re-sync. The password must be supplied (§10.2: the + # stored secret is only decrypted in the worker, never here). + conn_change = body.imap is not None or body.smtp is not None or body.password is not None + if conn_change: + if not body.password: + raise_api_error( + 400, + "password_required", + "the mailbox password is required to change server settings", + ) + imap = ( + _to_server_config(body.imap) if body.imap else _server_from_config(account.imap_config) + ) + smtp = ( + _to_server_config(body.smtp) if body.smtp else _server_from_config(account.smtp_config) + ) + try: + await test_account(imap=imap, smtp=smtp, username=account.email, password=body.password) + except AccountTestError as exc: + raise_api_error(400, exc.code, exc.detail) + account.imap_config = { + "host": imap.host, + "port": imap.port, + "tls": imap.tls, + "auth": imap.auth, + } + account.smtp_config = { + "host": smtp.host, + "port": smtp.port, + "tls": smtp.tls, + "auth": smtp.auth, + } + account.secret_ct = encrypt_secret(body.password.encode("utf-8")) + account.state = "backfilling" + await db.commit() + + if conn_change: + pool = await get_arq_pool() + await pool.enqueue_job("sync_bootstrap_account", str(account.id)) + return _detail(account) @@ -171,6 +250,7 @@ def _detail(account: Account) -> AccountDetailOut: color=account.color, state=account.state, state_detail=account.state_detail, + signature=account.signature, imap_config=account.imap_config, smtp_config=account.smtp_config, ) diff --git a/backend/app/api/compose.py b/backend/app/api/compose.py index dbcbdef..7145129 100644 --- a/backend/app/api/compose.py +++ b/backend/app/api/compose.py @@ -16,8 +16,14 @@ from app.config import get_settings from app.db import get_session from app.identity.deps import CurrentUser, require_csrf from app.indexer import blobstore -from app.models import ComposeAttachment, Draft -from app.outbox.schemas import DraftIn, DraftOut, SendRequest, SendResponse +from app.models import BodyPart, ComposeAttachment, Draft, Mailbox, Message +from app.outbox.schemas import ( + DraftIn, + DraftOut, + RemoteDraftOut, + SendRequest, + SendResponse, +) from app.outbox.service import DraftError, cancel_outbox, create_outbox, upsert_draft from app.queue import get_arq_pool @@ -102,6 +108,104 @@ async def list_drafts(user: CurrentUser, db: DbSession) -> list[DraftOut]: return [_draft_out(d) for d in drafts] +def _fmt_addr(addr: dict[str, str]) -> str: + email = addr.get("email", "") + name = addr.get("name", "") + return f"{name} <{email}>" if name else email + + +async def _own_draft_uids(db: AsyncSession, user_id: uuid.UUID) -> set[tuple[uuid.UUID, int]]: + """(account_id, imap_uid) of drafts we mirrored — excluded from the remote list.""" + rows = await db.execute( + select(Draft.account_id, Draft.imap_uid).where( + Draft.user_id == user_id, Draft.imap_uid.isnot(None) + ) + ) + return {(r.account_id, r.imap_uid) for r in rows} + + +@router.get("/drafts/remote", response_model=list[RemoteDraftOut]) +async def list_remote_drafts(user: CurrentUser, db: DbSession) -> list[RemoteDraftOut]: + """Drafts sitting in the IMAP Drafts folder that we haven't already adopted.""" + own = await _own_draft_uids(db, user.id) + messages = await db.scalars( + select(Message) + .join(Mailbox, Mailbox.id == Message.mailbox_id) + .where(Message.user_id == user.id, Mailbox.special_use == "drafts") + .order_by(Message.internal_date.desc()) + ) + out: list[RemoteDraftOut] = [] + for m in messages: + if (m.account_id, m.uid) in own: + continue + out.append( + RemoteDraftOut( + message_id=m.id, + account_id=m.account_id, + imap_uid=m.uid, + subject=m.subject, + to=[_fmt_addr(a) for a in m.to_addr], + cc=[_fmt_addr(a) for a in m.cc_addr], + bcc=[_fmt_addr(a) for a in m.bcc_addr], + snippet=m.snippet, + updated_at=(m.date_hdr or m.internal_date).isoformat(), + ) + ) + return out + + +@router.post("/drafts/adopt/{message_id}", response_model=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) + .join(Mailbox, Mailbox.id == Message.mailbox_id) + .where( + Message.id == message_id, + Message.user_id == user.id, + Mailbox.special_use == "drafts", + ) + ) + if msg is None: + raise_api_error(404, "not_found", "draft message not found") + + # Idempotent: if already adopted (a local draft links this UID), return it. + existing = await db.scalar( + select(Draft).where( + Draft.user_id == user.id, + Draft.account_id == msg.account_id, + Draft.imap_uid == msg.uid, + ) + ) + if existing is not None: + return _draft_out(existing) + + parts = list(await db.scalars(select(BodyPart).where(BodyPart.message_id == msg.id))) + html = next((p.content for p in parts if p.kind == "html_sanitized"), "") + text = next((p.content for p in parts if p.kind == "text"), "") + payload = { + "to": [_fmt_addr(a) for a in msg.to_addr], + "cc": [_fmt_addr(a) for a in msg.cc_addr], + "bcc": [_fmt_addr(a) for a in msg.bcc_addr], + "subject": msg.subject, + "html": html, + "text": text, + "attachment_ids": [], + } + draft = Draft( + user_id=user.id, + account_id=msg.account_id, + kind="new", + payload=payload, + imap_uid=msg.uid, + ) + db.add(draft) + await db.commit() + return _draft_out(draft) + + @router.post("/drafts", response_model=DraftOut) async def create_draft(body: DraftIn, user: CurrentUser, db: DbSession) -> DraftOut: try: @@ -125,13 +229,29 @@ async def update_draft( return _draft_out(draft) +@router.post("/drafts/{draft_id}/publish") +async def publish_draft(draft_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]: + """Mirror the draft to the account's IMAP Drafts folder (continue on any client).""" + 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)) + return {"status": "queued"} + + @router.delete("/drafts/{draft_id}") async def delete_draft(draft_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]: 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") + account_id, imap_uid = draft.account_id, draft.imap_uid await db.delete(draft) 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) return {"status": "deleted"} diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index 1e90cd7..43b6fe7 100644 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -47,6 +47,7 @@ class MessageEnvelope(BaseModel): from_addr: list[Address] to_addr: list[Address] cc_addr: list[Address] + bcc_addr: list[Address] date: dt.datetime flags: list[str] is_unread: bool @@ -79,6 +80,7 @@ class AccountOut(BaseModel): display_name: str color: str state: str + signature: str = "" class SearchThreadHit(BaseModel): diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 62383e9..49f618f 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -6,11 +6,12 @@ from typing import Annotated from fastapi import APIRouter, Depends from pydantic import BaseModel +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.db import get_session from app.identity.deps import CurrentUser, require_csrf -from app.models import User +from app.models import Message, User router = APIRouter(prefix="/settings", tags=["settings"]) @@ -30,6 +31,11 @@ class SettingsPatch(BaseModel): tz: str | None = None +class QuotaOut(BaseModel): + used_bytes: int + quota_bytes: int + + def allowed_image_senders(user: User) -> set[str]: value = user.settings.get("allowed_image_senders", []) if isinstance(value, list): @@ -59,6 +65,15 @@ async def get_settings_endpoint(user: CurrentUser) -> SettingsOut: return _settings_out(user) +@router.get("/quota", response_model=QuotaOut) +async def get_quota(user: CurrentUser, db: DbSession) -> QuotaOut: + """Storage quota and current usage (sum of stored message sizes).""" + used = await db.scalar( + select(func.coalesce(func.sum(Message.size_bytes), 0)).where(Message.user_id == user.id) + ) + return QuotaOut(used_bytes=int(used or 0), quota_bytes=user.quota_bytes) + + @router.patch("", response_model=SettingsOut, dependencies=[Depends(require_csrf)]) async def patch_settings(body: SettingsPatch, user: CurrentUser, db: DbSession) -> SettingsOut: updates = dict(user.settings) diff --git a/backend/app/api/threads.py b/backend/app/api/threads.py index b4c1c6b..684d929 100644 --- a/backend/app/api/threads.py +++ b/backend/app/api/threads.py @@ -56,6 +56,7 @@ def _envelope(msg: Message) -> MessageEnvelope: from_addr=_addresses(msg.from_addr), to_addr=_addresses(msg.to_addr), cc_addr=_addresses(msg.cc_addr), + bcc_addr=_addresses(msg.bcc_addr), date=msg.date_hdr or msg.internal_date, flags=list(msg.flags), is_unread="\\Seen" not in msg.flags, diff --git a/backend/app/indexer/indexer.py b/backend/app/indexer/indexer.py index d66e0aa..9698588 100644 --- a/backend/app/indexer/indexer.py +++ b/backend/app/indexer/indexer.py @@ -150,6 +150,7 @@ async def persist_message( from_addr=parsed.from_addr, to_addr=parsed.to_addr, cc_addr=parsed.cc_addr, + bcc_addr=parsed.bcc_addr, date_hdr=parsed.date, internal_date=remote.internal_date or datetime.now(UTC), flags=sorted(remote.flags), @@ -192,7 +193,10 @@ async def persist_message( ) message.has_remote_images = has_remote_images - await thread_message(session, message) + # Drafts are not conversation messages: keep them out of threads so they don't + # pollute the reading pane or thread list. They surface only via the drafts UI. + if mailbox_special_use != "drafts": + await thread_message(session, message) if mailbox_special_use not in _NO_RECIPIENT_HARVEST: await _harvest_recipient(session, message, parsed) diff --git a/backend/app/indexer/mime.py b/backend/app/indexer/mime.py index f7030a6..63e0178 100644 --- a/backend/app/indexer/mime.py +++ b/backend/app/indexer/mime.py @@ -37,6 +37,7 @@ class ParsedEmail: from_addr: list[dict[str, str]] to_addr: list[dict[str, str]] cc_addr: list[dict[str, str]] + bcc_addr: list[dict[str, str]] date: datetime | None text_body: str html_body: str | None @@ -140,6 +141,7 @@ def parse_message(raw: bytes) -> ParsedEmail: from_addr=_addresses(msg, "From"), to_addr=_addresses(msg, "To"), cc_addr=_addresses(msg, "Cc"), + bcc_addr=_addresses(msg, "Bcc"), date=date, text_body=text_body or "", html_body=html_body, diff --git a/backend/app/models.py b/backend/app/models.py index 2081091..ad021be 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -10,6 +10,7 @@ import datetime as dt import uuid from sqlalchemy import ( + BigInteger, ForeignKey, Index, Integer, @@ -54,6 +55,11 @@ class User(Base): settings: Mapped[dict[str, object]] = mapped_column( JSONB, nullable=False, server_default=text("'{}'") ) + # Storage quota in bytes; per-user so an admin can override it later. + # Default 5 GiB (5 * 1024^3). + quota_bytes: Mapped[int] = mapped_column( + BigInteger, nullable=False, server_default=text("5368709120") + ) created_at: Mapped[dt.datetime] = _now() @@ -68,6 +74,8 @@ class Account(Base): email: Mapped[str] = mapped_column(CITEXT, nullable=False) display_name: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) color: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'#888888'")) + # Per-account HTML signature, inserted into new messages (and via compose menu). + signature: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) imap_config: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False) smtp_config: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False) secret_ct: Mapped[bytes] = mapped_column(BYTEA, nullable=False) @@ -158,6 +166,9 @@ class Message(Base): cc_addr: Mapped[list[dict[str, str]]] = mapped_column( JSONB, nullable=False, server_default=text("'[]'") ) + bcc_addr: Mapped[list[dict[str, str]]] = mapped_column( + JSONB, nullable=False, server_default=text("'[]'") + ) date_hdr: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True)) internal_date: Mapped[dt.datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False) flags: Mapped[list[str]] = mapped_column( @@ -233,6 +244,9 @@ class Draft(Base): ) kind: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("'reply'")) payload: Mapped[dict[str, object]] = mapped_column(JSONB, nullable=False) + # UID of the mirrored copy in the account's IMAP Drafts folder (so the draft + # is visible/continuable on other mail clients). Replaced on each edit. + imap_uid: Mapped[int | None] = mapped_column(Integer) updated_at: Mapped[dt.datetime] = _now() diff --git a/backend/app/outbox/attachments.py b/backend/app/outbox/attachments.py new file mode 100644 index 0000000..3ae1f09 --- /dev/null +++ b/backend/app/outbox/attachments.py @@ -0,0 +1,40 @@ +"""Resolve a draft's compose-attachment ids into raw (filename, mime, data) tuples.""" + +from __future__ import annotations + +import uuid + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.indexer import blobstore +from app.models import ComposeAttachment, Draft + + +async def load_attachments( + session: AsyncSession, user_id: uuid.UUID, draft: Draft +) -> list[tuple[str, str, bytes]]: + """Return each of the draft's compose attachments as ``(filename, mime, data)``.""" + raw_ids = draft.payload.get("attachment_ids") or [] + if not isinstance(raw_ids, list) or not raw_ids: + return [] + ids: list[uuid.UUID] = [] + for value in raw_ids: + try: + ids.append(uuid.UUID(str(value))) + except ValueError: + continue + if not ids: + return [] + rows = await session.scalars( + select(ComposeAttachment).where( + ComposeAttachment.id.in_(ids), ComposeAttachment.user_id == user_id + ) + ) + by_id = {r.id: r for r in rows} + out: list[tuple[str, str, bytes]] = [] + for i in ids: + att = by_id.get(i) + if att is not None: + out.append((att.filename, att.mime_type, blobstore.load(user_id, att.blob_sha256))) + return out diff --git a/backend/app/outbox/draft_sync.py b/backend/app/outbox/draft_sync.py new file mode 100644 index 0000000..7e7763a --- /dev/null +++ b/backend/app/outbox/draft_sync.py @@ -0,0 +1,120 @@ +"""Mirror local drafts to the account's IMAP Drafts folder (SPEC §6.5 extension). + +Drafts are stored server-side as ``\\Draft`` messages so they can be continued on +any standard mail client. IMAP messages are immutable, so each edit APPENDs a new +copy and expunges the previous one (tracked by ``drafts.imap_uid``). Runs in the +worker (credential decryption is worker-only, §10.2). +""" + +from __future__ import annotations + +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, Draft, Mailbox, Message +from app.outbox.attachments import load_attachments +from app.outbox.mime_build import build_mime +from app.sync.provider_factory import build_provider + +logger = logging.getLogger(__name__) + +_DRAFT_FLAGS = frozenset({"\\Draft", "\\Seen"}) + + +async def _ensure_drafts_folder(provider: Any, account: Account, user_id: uuid.UUID) -> str: + """Return the account's Drafts folder path, creating one if it is missing.""" + path = "Drafts" + await provider.create_mailbox(path) + remote = await provider.list_mailboxes() + 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" + ) + ) + if existing is not None: + return existing.path + session.add( + Mailbox( + account_id=account.id, + user_id=user_id, + path=path, + special_use="drafts", + uidvalidity=uidvalidity, + ) + ) + return path + + +async def push_draft_to_imap_job(ctx: dict[str, Any], draft_id: str) -> None: + """APPEND the draft to the Drafts folder and expunge the previous copy.""" + did = uuid.UUID(draft_id) + async with session_scope() as session: + draft = await session.get(Draft, did) + if draft is None: + return + account = await session.get(Account, draft.account_id) + if account is None: + return + user_id = draft.user_id + old_uid = draft.imap_uid + reply_to = ( + await session.get(Message, draft.reply_to_message_id) + if draft.reply_to_message_id + else 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" + ) + ) + drafts_path = drafts_mb.path if drafts_mb else None + account_obj = account + + provider = build_provider(account_obj) + new_uid = 0 + try: + if drafts_path is None: + drafts_path = await _ensure_drafts_folder(provider, account_obj, user_id) + new_uid = await provider.append(drafts_path, sent_raw, _DRAFT_FLAGS) + if old_uid: + with contextlib.suppress(Exception): + await provider.delete_messages(drafts_path, [old_uid]) + finally: + await provider.close() + + if new_uid: + async with session_scope() as session: + draft = await session.get(Draft, did) + if draft is not None: + draft.imap_uid = new_uid + + +async def remove_imap_draft_job(ctx: dict[str, Any], account_id: str, uid: int) -> None: + """Expunge a draft copy from the account's Drafts folder (on discard).""" + aid = uuid.UUID(account_id) + async with session_scope() as session: + account = await session.get(Account, aid) + if account is None: + return + drafts_mb = await session.scalar( + select(Mailbox).where(Mailbox.account_id == aid, Mailbox.special_use == "drafts") + ) + drafts_path = drafts_mb.path if drafts_mb else None + account_obj = account + if drafts_path is None: + return + provider = build_provider(account_obj) + try: + with contextlib.suppress(Exception): + await provider.delete_messages(drafts_path, [int(uid)]) + finally: + await provider.close() diff --git a/backend/app/outbox/jobs.py b/backend/app/outbox/jobs.py index e9504d0..48cd58c 100644 --- a/backend/app/outbox/jobs.py +++ b/backend/app/outbox/jobs.py @@ -8,6 +8,7 @@ to Sent (skipped for Gmail, which self-appends), harvest recipients into from __future__ import annotations +import contextlib import logging import uuid from datetime import UTC, datetime @@ -19,17 +20,12 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.db import session_scope from app.events import get_event_bus -from app.indexer import blobstore -from app.models import ( - Account, - ComposeAttachment, - Draft, - KnownRecipient, - Mailbox, - Message, - Outbox, -) +from app.indexer.indexer import persist_message +from app.models import Account, Draft, KnownRecipient, Mailbox, Message, Outbox +from app.outbox.attachments import load_attachments from app.outbox.mime_build import build_mime +from app.providers.base import RemoteMessage +from app.search.factory import get_search_backend from app.sync.provider_factory import build_provider logger = logging.getLogger(__name__) @@ -56,33 +52,56 @@ async def _harvest_recipients(session: AsyncSession, user_id: uuid.UUID, emails: await session.execute(stmt) -async def _load_attachments( - session: AsyncSession, user_id: uuid.UUID, draft: Draft -) -> list[tuple[str, str, bytes]]: - """Resolve a draft's compose-attachment ids into (filename, mime, data) tuples.""" - raw_ids = draft.payload.get("attachment_ids") or [] - if not isinstance(raw_ids, list) or not raw_ids: - return [] - ids: list[uuid.UUID] = [] - for value in raw_ids: - try: - ids.append(uuid.UUID(str(value))) - except ValueError: - continue - if not ids: - return [] - rows = await session.scalars( - select(ComposeAttachment).where( - ComposeAttachment.id.in_(ids), ComposeAttachment.user_id == user_id +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 + would pick it up. persist_message dedupes on (mailbox_id, uid). + """ + if uid <= 0: + return + async with session_scope() as session: + sent_mb = await session.scalar( + select(Mailbox).where( + Mailbox.account_id == account.id, Mailbox.special_use == "sent" + ) ) + if sent_mb is None: + return + remote = RemoteMessage( + uid=uid, raw=raw, flags=frozenset({"\\Seen"}), internal_date=datetime.now(UTC) + ) + result = await persist_message( + session, + user_id=user_id, + account_id=account.id, + mailbox_id=sent_mb.id, + mailbox_special_use="sent", + remote=remote, + ) + if result is None: + return + message_id = result.message.id + thread_id = result.message.thread_id + search_doc = result.search_doc + + await get_search_backend().index(search_doc) + bus = get_event_bus() + await bus.publish( + "message.new", + { + "user_id": str(user_id), + "account_id": str(account.id), + "message_id": str(message_id), + "thread_id": str(thread_id) if thread_id else None, + }, ) - by_id = {r.id: r for r in rows} - out: list[tuple[str, str, bytes]] = [] - for i in ids: - att = by_id.get(i) - if att is not None: - out.append((att.filename, att.mime_type, blobstore.load(user_id, att.blob_sha256))) - return out + if thread_id is not None: + 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: @@ -109,21 +128,34 @@ async def send_job(ctx: dict[str, Any], outbox_id: str) -> None: outbox.error = "account missing" return user_id = outbox.user_id - attachments = await _load_attachments(session, user_id, draft) - raw, envelope_from, envelope_to = build_mime(account, draft, reply_to, attachments) + attachments = await load_attachments(session, user_id, draft) + transmit_raw, sent_raw, envelope_from, envelope_to = build_mime( + account, draft, reply_to, attachments + ) sent_path = await session.scalar( select(Mailbox.path).where( Mailbox.account_id == account.id, Mailbox.special_use == "sent" ) ) + drafts_path = await session.scalar( + select(Mailbox.path).where( + Mailbox.account_id == account.id, Mailbox.special_use == "drafts" + ) + ) + draft_imap_uid = draft.imap_uid gmail = _is_gmail(account) account_obj = account provider = build_provider(account_obj) + appended_uid = 0 try: - await provider.send(raw, envelope_from, envelope_to) + await provider.send(transmit_raw, envelope_from, envelope_to) if sent_path and not gmail: - await provider.append(sent_path, raw, frozenset({"\\Seen"})) + appended_uid = await provider.append(sent_path, sent_raw, frozenset({"\\Seen"})) + # Remove the mirrored Drafts-folder copy now that it has been sent. + if drafts_path and draft_imap_uid: + with contextlib.suppress(Exception): + await provider.delete_messages(drafts_path, [draft_imap_uid]) except Exception as exc: # noqa: BLE001 - record and notify, keep the draft async with session_scope() as session: ob = await session.get(Outbox, oid) @@ -139,6 +171,10 @@ async def send_job(ctx: dict[str, Any], outbox_id: str) -> None: finally: await provider.close() + # Persist the sent copy (with Bcc) ourselves so it shows in Sent and threads + # immediately; the periodic Sent poll would also catch it (dedup by UID). + await _persist_sent_copy(user_id, account_obj, sent_raw, appended_uid) + async with session_scope() as session: ob = await session.get(Outbox, oid) if ob is not None: diff --git a/backend/app/outbox/mime_build.py b/backend/app/outbox/mime_build.py index 942cde9..1684c64 100644 --- a/backend/app/outbox/mime_build.py +++ b/backend/app/outbox/mime_build.py @@ -49,8 +49,13 @@ def build_mime( draft: Draft, reply_to: Message | None, attachments: list[tuple[str, str, bytes]] | None = None, -) -> tuple[bytes, str, list[str]]: - """Return ``(raw_rfc822, envelope_from, envelope_to)`` for ``draft``. +) -> tuple[bytes, bytes, str, list[str]]: + """Return ``(transmit_raw, sent_raw, envelope_from, envelope_to)`` for ``draft``. + + ``transmit_raw`` is sent to the SMTP recipients and carries NO ``Bcc`` header. + ``sent_raw`` is the copy filed in Sent; it additionally includes a ``Bcc`` + header so the sender keeps a record of blind recipients (standard client + behavior). They are identical when there is no Bcc. ``attachments`` is a list of ``(filename, mime_type, data)`` uploaded via the compose Attach action; each is added as a file attachment part. @@ -99,4 +104,10 @@ def build_mime( ) envelope_to = [_email_only(a) for a in (to + cc + bcc)] - return msg.as_bytes(), account.email, envelope_to + transmit_raw = msg.as_bytes() # no Bcc header -> safe to transmit + if bcc: + msg["Bcc"] = ", ".join(bcc) + sent_raw = msg.as_bytes() + else: + sent_raw = transmit_raw + return transmit_raw, sent_raw, account.email, envelope_to diff --git a/backend/app/outbox/schemas.py b/backend/app/outbox/schemas.py index 144f8a3..0ed58a9 100644 --- a/backend/app/outbox/schemas.py +++ b/backend/app/outbox/schemas.py @@ -35,6 +35,20 @@ class DraftOut(BaseModel): updated_at: str +class RemoteDraftOut(BaseModel): + """A draft found in the account's IMAP Drafts folder (created on another client).""" + + message_id: uuid.UUID + account_id: uuid.UUID + imap_uid: int + subject: str + to: list[str] + cc: list[str] + bcc: list[str] + snippet: str + updated_at: str + + class SendRequest(BaseModel): draft_id: uuid.UUID diff --git a/backend/app/sync/engine.py b/backend/app/sync/engine.py index e81578b..09a1fbc 100644 --- a/backend/app/sync/engine.py +++ b/backend/app/sync/engine.py @@ -3,8 +3,10 @@ from __future__ import annotations import asyncio +import contextlib import logging import uuid +from typing import Any from sqlalchemy import delete, select, update from sqlalchemy.ext.asyncio import AsyncSession @@ -274,3 +276,73 @@ async def live_loop( await apply_delta(provider, account_obj, mailbox_obj, delta) finally: await provider.close() + + +async def poll_folders_once(account_id: uuid.UUID, *, include_inbox: bool) -> None: + """One CONDSTORE poll pass over an account's special-use folders. + + ``include_inbox`` also polls INBOX (used by the manual Refresh); the periodic + secondary loop leaves INBOX to its dedicated IDLE loop. Custom (non special-use) + folders are not polled. Drafts are polled so drafts made in other clients appear + (persisted un-threaded). + """ + from app.db import session_scope + + async with session_scope() as session: + account = await _load_account(session, account_id) + account_obj = account + stmt = select(Mailbox.id).where( + Mailbox.account_id == account_id, Mailbox.special_use.isnot(None) + ) + if not include_inbox: + stmt = stmt.where(Mailbox.special_use != "inbox") + mailbox_ids = list(await session.scalars(stmt)) + if not mailbox_ids: + return + + provider = build_provider(account_obj) + try: + for mailbox_id in mailbox_ids: + async with session_scope() as session: + mailbox = await session.get(Mailbox, mailbox_id) + mailbox_obj = mailbox + if mailbox_obj is None: + continue + try: + delta = await provider.poll( + mailbox_obj.path, + last_seen_uid=mailbox_obj.last_seen_uid, + highestmodseq=mailbox_obj.highestmodseq, + ) + await apply_delta(provider, account_obj, mailbox_obj, delta) + except Exception: + logger.exception("poll failed: account=%s mailbox=%s", account_id, mailbox_obj.path) + finally: + await provider.close() + + +async def _poll_secondary_once(account_id: uuid.UUID) -> None: + await poll_folders_once(account_id, include_inbox=False) + + +async def refresh_account_job(ctx: dict[str, Any], account_id: str) -> None: + """Poll every special-use folder (incl. INBOX) once — the manual Refresh action.""" + 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: + """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. + """ + stop = stop or asyncio.Event() + interval = get_settings().poll_interval + while not stop.is_set(): + # Wait first: the initial state was already covered by the backfill. + with contextlib.suppress(TimeoutError): + await asyncio.wait_for(stop.wait(), timeout=interval) + if stop.is_set(): + break + await _poll_secondary_once(account_id) diff --git a/backend/app/sync/supervisor.py b/backend/app/sync/supervisor.py index b4ba0ec..93bbeec 100644 --- a/backend/app/sync/supervisor.py +++ b/backend/app/sync/supervisor.py @@ -20,7 +20,7 @@ from sqlalchemy import select from app.db import session_scope from app.models import Account -from app.sync.engine import live_loop +from app.sync.engine import live_loop, secondary_poll_loop logger = logging.getLogger(__name__) @@ -39,13 +39,29 @@ async def _run_account(account_id: uuid.UUID, stop: asyncio.Event) -> None: 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 - ctx["supervisor_tasks"] = [asyncio.create_task(_run_account(aid, stop)) for aid in account_ids] + 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)) diff --git a/backend/migrations/versions/0006_user_quota.py b/backend/migrations/versions/0006_user_quota.py new file mode 100644 index 0000000..ec75e72 --- /dev/null +++ b/backend/migrations/versions/0006_user_quota.py @@ -0,0 +1,30 @@ +"""add users.quota_bytes + +Per-user storage quota (bytes) so an administrator can define/override it later. +Defaults to 5 GiB for existing and new users. + +Revision ID: 0006 +Revises: 0005 +Create Date: 2026-07-18 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op + +revision: str = "0006" +down_revision: str | None = "0005" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute( + "ALTER TABLE users ADD COLUMN quota_bytes bigint NOT NULL DEFAULT 5368709120" + ) + + +def downgrade() -> None: + op.execute("ALTER TABLE users DROP COLUMN IF EXISTS quota_bytes") diff --git a/backend/migrations/versions/0007_message_bcc.py b/backend/migrations/versions/0007_message_bcc.py new file mode 100644 index 0000000..901d472 --- /dev/null +++ b/backend/migrations/versions/0007_message_bcc.py @@ -0,0 +1,29 @@ +"""add messages.bcc_addr + +The Sent copy of an outgoing message includes a Bcc header (standard client +behavior) so the sender keeps a record of blind recipients; store it structured +alongside to/cc for display. + +Revision ID: 0007 +Revises: 0006 +Create Date: 2026-07-18 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op + +revision: str = "0007" +down_revision: str | None = "0006" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute("ALTER TABLE messages ADD COLUMN bcc_addr jsonb NOT NULL DEFAULT '[]'") + + +def downgrade() -> None: + op.execute("ALTER TABLE messages DROP COLUMN IF EXISTS bcc_addr") diff --git a/backend/migrations/versions/0008_draft_imap_uid.py b/backend/migrations/versions/0008_draft_imap_uid.py new file mode 100644 index 0000000..5169af5 --- /dev/null +++ b/backend/migrations/versions/0008_draft_imap_uid.py @@ -0,0 +1,29 @@ +"""add drafts.imap_uid + +Drafts are mirrored to the account's IMAP Drafts folder (as \\Draft messages) so +they can be continued on any standard mail client. This stores the UID of the +current server copy so it can be replaced/removed. + +Revision ID: 0008 +Revises: 0007 +Create Date: 2026-07-18 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op + +revision: str = "0008" +down_revision: str | None = "0007" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute("ALTER TABLE drafts ADD COLUMN imap_uid integer") + + +def downgrade() -> None: + op.execute("ALTER TABLE drafts DROP COLUMN IF EXISTS imap_uid") diff --git a/backend/migrations/versions/0009_account_signature.py b/backend/migrations/versions/0009_account_signature.py new file mode 100644 index 0000000..b1e3733 --- /dev/null +++ b/backend/migrations/versions/0009_account_signature.py @@ -0,0 +1,28 @@ +"""add accounts.signature + +Per-account HTML signature, configured in Settings → Accounts and inserted into +outgoing messages by the composer. + +Revision ID: 0009 +Revises: 0008 +Create Date: 2026-07-18 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op + +revision: str = "0009" +down_revision: str | None = "0008" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute("ALTER TABLE accounts ADD COLUMN signature text NOT NULL DEFAULT ''") + + +def downgrade() -> None: + op.execute("ALTER TABLE accounts DROP COLUMN IF EXISTS signature") diff --git a/backend/tests/accounts/test_account_api.py b/backend/tests/accounts/test_account_api.py index aa3963b..94e32b9 100644 --- a/backend/tests/accounts/test_account_api.py +++ b/backend/tests/accounts/test_account_api.py @@ -146,6 +146,114 @@ async def test_create_account_enqueues_sync(ctx: Ctx, monkeypatch) -> None: # t assert enqueued == [("sync_bootstrap_account", body["id"])] +async def _make_account(ctx: Ctx, monkeypatch) -> str: # type: ignore[no-untyped-def] + async def fake_test(**_kw: object) -> None: + return None + + class FakePool: + async def enqueue_job(self, name: str, arg: str) -> 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) + resp = await ctx.client.post( + "/api/accounts", + json={**GOOD_CFG, "email": "me@example.com", "display_name": "Me", "password": "secret"}, + headers=_hdr(), + ) + assert resp.status_code == 200, resp.text + return str(resp.json()["id"]) + + +async def test_sync_all_enqueues_refresh_per_account(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def] + aid = await _make_account(ctx, monkeypatch) + + enqueued: list[tuple[str, str]] = [] + + class FakePool: + async def enqueue_job(self, name: str, arg: str) -> None: + enqueued.append((name, arg)) + + async def fake_pool() -> FakePool: + return FakePool() + + monkeypatch.setattr(accounts_api, "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)] + + +async def test_update_display_and_color_no_resync(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def] + aid = await _make_account(ctx, monkeypatch) + resp = await ctx.client.patch( + f"/api/accounts/{aid}", + json={"display_name": "New Name", "color": "#00ff00"}, + headers=_hdr(), + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["display_name"] == "New Name" and body["color"] == "#00ff00" + + +async def test_update_signature(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def] + aid = await _make_account(ctx, monkeypatch) + resp = await ctx.client.patch( + f"/api/accounts/{aid}", json={"signature": "
Best,
Me
Best,
Me
Best,
Me
hi
", "text": ""}, + ) + ctx.session.add(draft) + await ctx.session.commit() + return draft.id + + +async def test_push_draft_appends_with_draft_flag_and_replaces(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def] + did = await _make_draft(ctx) + fake = FakeProvider() + monkeypatch.setattr(draft_sync, "build_provider", lambda _a: fake) + + await draft_sync.push_draft_to_imap_job({}, str(did)) + + # appended once to DRAFTS with the \Draft flag; UID recorded on the draft + assert len(fake.appended) == 1 + assert fake.appended[0][0] == "DRAFTS" + assert "\\Draft" in fake.appended[0][1] + assert fake.deleted == [] # nothing to replace yet + async with session_scope() as s: + assert (await s.get(Draft, did)).imap_uid == 42 # type: ignore[union-attr] + + # A second push (edit) appends a new copy and expunges the previous UID. + await draft_sync.push_draft_to_imap_job({}, str(did)) + assert len(fake.appended) == 2 + assert fake.deleted == [("DRAFTS", [42])] + async with session_scope() as s: + assert (await s.get(Draft, did)).imap_uid == 43 # type: ignore[union-attr] + + +async def test_draft_messages_are_not_threaded(ctx: Ctx) -> None: + from datetime import UTC, datetime + from email.message import EmailMessage + + from app.indexer.indexer import persist_message + from app.providers.base import RemoteMessage + + m = EmailMessage() + m["Subject"] = "A draft reply" + m["From"] = "me@example.com" + m["To"] = "bob@example.com" + m["Message-ID"] = "hi
", "text": ""}, ) - raw, _from, _to = build_mime( + raw, _sent, _from, _to = build_mime( account, draft, None, [("report.pdf", "application/pdf", b"%PDF-1.4 data")] ) msg = email.message_from_bytes(raw, policy=default_policy) @@ -219,6 +223,79 @@ async def test_draft_crud(ctx: Ctx) -> None: assert deleted.status_code == 200 +async def test_remote_drafts_listed_and_adopted(ctx: Ctx) -> None: + from datetime import UTC, datetime + + from app.models import BodyPart + + # A "foreign" draft (as if created in another client) in the Drafts folder. + drafts_mb = Mailbox( + account_id=ctx.account_id, user_id=ctx.user_id, path="DRAFTS", special_use="drafts" + ) + ctx.session.add(drafts_mb) + await ctx.session.flush() + msg = Message( + user_id=ctx.user_id, + account_id=ctx.account_id, + mailbox_id=drafts_mb.id, + uid=94, + subject="Home draft", + to_addr=[{"name": "", "email": "bob@example.com"}], + cc_addr=[], + bcc_addr=[], + internal_date=datetime.now(UTC), + blob_sha256="x" * 64, + snippet="hello there", + ) + ctx.session.add(msg) + await ctx.session.flush() + ctx.session.add(BodyPart(message_id=msg.id, kind="html_sanitized", content="hello
")) + ctx.session.add(BodyPart(message_id=msg.id, kind="text", content="hello there")) + await ctx.session.commit() + + # It shows in the remote drafts list. + listing = await ctx.client.get("/api/drafts/remote") + assert listing.status_code == 200 + assert any(i["message_id"] == str(msg.id) and i["imap_uid"] == 94 for i in listing.json()) + + # Adopting turns it into an editable local draft (linked by UID). + adopted = await ctx.client.post(f"/api/drafts/adopt/{msg.id}", headers=_hdr()) + assert adopted.status_code == 200, adopted.text + draft = adopted.json() + assert draft["payload"]["subject"] == "Home draft" + assert draft["payload"]["to"] == ["bob@example.com"] + assert draft["payload"]["html"] == "hello
" + + # Now it's excluded from the remote list (we own that UID)... + listing2 = await ctx.client.get("/api/drafts/remote") + assert all(i["message_id"] != str(msg.id) for i in listing2.json()) + # ...and adopting again returns the same local draft (idempotent). + again = await ctx.client.post(f"/api/drafts/adopt/{msg.id}", headers=_hdr()) + assert again.json()["id"] == draft["id"] + + +async def test_publish_draft_enqueues_imap_mirror(ctx: Ctx) -> None: + did = (await ctx.client.post("/api/drafts", json=_draft_body(ctx), headers=_hdr())).json()["id"] + ctx.enqueued.clear() + resp = await ctx.client.post(f"/api/drafts/{did}/publish", headers=_hdr()) + assert resp.status_code == 200 + assert ctx.enqueued[-1][0] == ("push_draft_to_imap_job", did) + + +async def test_delete_draft_expunges_mirror_when_present(ctx: Ctx) -> None: + did = (await ctx.client.post("/api/drafts", json=_draft_body(ctx), headers=_hdr())).json()["id"] + # Simulate a previously mirrored copy (imap_uid set). + draft = await ctx.session.get(Draft, uuid.UUID(did)) + assert draft is not None + draft.imap_uid = 88 + await ctx.session.commit() + + ctx.enqueued.clear() + resp = await ctx.client.request("DELETE", f"/api/drafts/{did}", headers=_hdr()) + assert resp.status_code == 200 + assert ctx.enqueued[-1][0] == ("remove_imap_draft_job", str(ctx.account_id), 88) + + async def test_upload_and_list_compose_attachment(ctx: Ctx) -> None: resp = await ctx.client.post( "/api/attachments", @@ -332,6 +409,17 @@ async def test_send_job_success(ctx: Ctx, monkeypatch) -> None: # type: ignore[ ctx.session.expunge_all() async with session_scope() as verify: assert await verify.get(Draft, draft_id) is None + # The sent copy is persisted into the Sent mailbox (append UID 99). + sent_mb = await verify.scalar( + select(Mailbox).where( + Mailbox.account_id == ctx.account_id, Mailbox.special_use == "sent" + ) + ) + assert sent_mb is not None + sent_msg = await verify.scalar( + select(Message).where(Message.mailbox_id == sent_mb.id, Message.uid == 99) + ) + assert sent_msg is not None and sent_msg.subject == "Re: Hi" kr = await ctx.session.scalar( select(KnownRecipient).where( KnownRecipient.user_id == ctx.user_id, KnownRecipient.email == "bob@example.com" diff --git a/backend/tests/sync/test_secondary_poll.py b/backend/tests/sync/test_secondary_poll.py new file mode 100644 index 0000000..5a389a4 --- /dev/null +++ b/backend/tests/sync/test_secondary_poll.py @@ -0,0 +1,99 @@ +"""Secondary (non-INBOX) folder polling — multi-folder CONDSTORE sync (SPEC §6.3).""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime +from email.message import EmailMessage + +from sqlalchemy import delete, func, 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.identity.passwords import hash_password +from app.models import Account, Mailbox, Message, User +from app.providers.base import MailboxDelta, RemoteMessage +from app.sync import engine as sync_engine + + +def _raw(subject: str, mid: str) -> bytes: + m = EmailMessage() + m["Subject"] = subject + m["From"] = "me@example.com" + m["To"] = "bob@example.com" + m["Message-ID"] = mid + m["Date"] = "Mon, 09 Mar 2026 08:36:37 +0100" + m.set_content("body") + return m.as_bytes() + + +async def test_secondary_poll_syncs_special_folders_not_inbox(monkeypatch) -> None: # type: ignore[no-untyped-def] + engine = create_async_engine(get_settings().database_url, poolclass=NullPool) + sm = async_sessionmaker(engine, expire_on_commit=False) + session = sm() + user = User(handle=f"sec{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw")) + session.add(user) + await session.flush() + account = Account( + user_id=user.id, + email="me@example.com", + imap_config={}, + smtp_config={}, + secret_ct=b"", + discovery_src="manual", + ) + session.add(account) + await session.flush() + inbox = Mailbox(account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox") + sent = Mailbox(account_id=account.id, user_id=user.id, path="SENT", special_use="sent") + custom = Mailbox(account_id=account.id, user_id=user.id, path="Notes", special_use=None) + session.add_all([inbox, sent, custom]) + await session.commit() + + polled: list[str] = [] + + class FakeProvider: + async def poll(self, mailbox: str, *, last_seen_uid: int, highestmodseq: int | None): # type: ignore[no-untyped-def] + polled.append(mailbox) + if mailbox == "SENT": + return MailboxDelta( + [ + RemoteMessage( + uid=5, + raw=_raw("Sent one", "${quoteHtml}` + `
${quoteHtml}