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

"}, headers=_hdr() + ) + assert resp.status_code == 200 + assert resp.json()["signature"] == "

Best,
Me

" + # The accounts list also carries the signature (used by the composer). + listing = await ctx.client.get("/api/accounts") + acct = next(a for a in listing.json() if a["id"] == aid) + assert acct["signature"] == "

Best,
Me

" + + +async def test_update_password_reencrypts_and_resyncs(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def] + aid = await _make_account(ctx, monkeypatch) + acct = await ctx.session.scalar(select(Account).where(Account.id == uuid.UUID(aid))) + assert acct is not None + await ctx.session.refresh(acct) + old_secret = bytes(acct.secret_ct) + + 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() + + async def fake_test(**_kw: object) -> None: + return None + + monkeypatch.setattr(accounts_api, "test_account", fake_test) + monkeypatch.setattr(accounts_api, "get_arq_pool", fake_pool) + + resp = await ctx.client.patch( + f"/api/accounts/{aid}", json={"password": "newsecret"}, headers=_hdr() + ) + assert resp.status_code == 200, resp.text + assert resp.json()["state"] == "backfilling" + await ctx.session.refresh(acct) + assert bytes(acct.secret_ct) != old_secret # re-encrypted + assert enqueued == [("sync_bootstrap_account", aid)] + + +async def test_update_server_change_requires_password(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={"imap": {"host": "imap2.example.com", "port": 993, "tls": "ssl"}}, + headers=_hdr(), + ) + assert resp.status_code == 400 + assert resp.json()["error"]["code"] == "password_required" + + async def test_delete_account(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def] async def fake_test(**_kw: object) -> None: return None diff --git a/backend/tests/api/test_api.py b/backend/tests/api/test_api.py index b6b3a4e..3092eeb 100644 --- a/backend/tests/api/test_api.py +++ b/backend/tests/api/test_api.py @@ -234,6 +234,14 @@ async def test_attachments_list_and_zip(api: ApiCtx) -> None: assert archive.read("notes.txt") == b"hello attachment" +async def test_quota_reports_usage_and_default(api: ApiCtx) -> None: + resp = await api.client.get("/api/settings/quota") + assert resp.status_code == 200 + body = resp.json() + assert body["quota_bytes"] == 5 * 1024**3 # default 5 GiB + assert body["used_bytes"] > 0 # seeded messages have a size + + async def test_thread_detail_messages_ascending(api: ApiCtx) -> None: # find the kickoff thread (2 messages) resp = await api.client.get("/api/threads") diff --git a/backend/tests/outbox/test_draft_sync.py b/backend/tests/outbox/test_draft_sync.py new file mode 100644 index 0000000..9fd38bf --- /dev/null +++ b/backend/tests/outbox/test_draft_sync.py @@ -0,0 +1,188 @@ +"""Mirroring local drafts to the IMAP Drafts folder (continue on any client).""" + +from __future__ import annotations + +import uuid +from collections.abc import AsyncIterator +from dataclasses import dataclass + +import pytest_asyncio +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession, 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, Draft, Mailbox, User +from app.outbox import draft_sync + + +@dataclass +class Ctx: + session: AsyncSession + user_id: uuid.UUID + account_id: uuid.UUID + + +@pytest_asyncio.fixture +async def ctx() -> AsyncIterator[Ctx]: + engine = create_async_engine(get_settings().database_url, poolclass=NullPool) + sm = async_sessionmaker(engine, expire_on_commit=False) + session = sm() + user = User(handle=f"drf{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", + display_name="Me", + imap_config={}, + smtp_config={}, + secret_ct=b"", + discovery_src="manual", + ) + session.add(account) + await session.flush() + session.add( + Mailbox(account_id=account.id, user_id=user.id, path="DRAFTS", special_use="drafts") + ) + await session.commit() + try: + yield Ctx(session=session, user_id=user.id, account_id=account.id) + finally: + await session.execute(delete(User).where(User.id == user.id)) + await session.commit() + await session.close() + await engine.dispose() + + +class FakeProvider: + def __init__(self) -> None: + self.appended: list[tuple[str, frozenset[str]]] = [] + self.deleted: list[tuple[str, list[int]]] = [] + self._uid = 41 + + async def append(self, mailbox: str, raw: bytes, flags: frozenset[str]) -> int: + self.appended.append((mailbox, flags)) + self._uid += 1 + return self._uid + + async def delete_messages(self, mailbox: str, uids: list[int]) -> None: + self.deleted.append((mailbox, list(uids))) + + async def close(self) -> None: + return None + + +async def _make_draft(ctx: Ctx) -> uuid.UUID: + draft = Draft( + user_id=ctx.user_id, + account_id=ctx.account_id, + kind="new", + payload={"to": ["bob@example.com"], "subject": "Hi", "html": "

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"] = "" + m["In-Reply-To"] = "" # would normally thread it + m["Date"] = "Mon, 09 Mar 2026 08:36:37 +0100" + m.set_content("body") + + drafts_mb = await ctx.session.scalar( + select(Mailbox).where( + Mailbox.account_id == ctx.account_id, Mailbox.special_use == "drafts" + ) + ) + assert drafts_mb is not None + remote = RemoteMessage( + uid=71, raw=m.as_bytes(), flags=frozenset({"\\Draft"}), internal_date=datetime.now(UTC) + ) + result = await persist_message( + ctx.session, + user_id=ctx.user_id, + account_id=ctx.account_id, + mailbox_id=drafts_mb.id, + mailbox_special_use="drafts", + remote=remote, + ) + await ctx.session.commit() + assert result is not None + assert result.message.thread_id is None # drafts stay out of conversations + + +async def test_remove_imap_draft_expunges(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def] + fake = FakeProvider() + monkeypatch.setattr(draft_sync, "build_provider", lambda _a: fake) + await draft_sync.remove_imap_draft_job({}, str(ctx.account_id), 7) + assert fake.deleted == [("DRAFTS", [7])] + + +async def test_no_drafts_mailbox_creates_one(ctx: Ctx, monkeypatch) -> None: # type: ignore[no-untyped-def] + # Drop the drafts mailbox so the job has to create it. + await ctx.session.execute( + delete(Mailbox).where(Mailbox.account_id == ctx.account_id, Mailbox.special_use == "drafts") + ) + await ctx.session.commit() + did = await _make_draft(ctx) + + created: list[str] = [] + + class CreatingProvider(FakeProvider): + async def create_mailbox(self, path: str) -> None: + created.append(path) + + async def list_mailboxes(self): # type: ignore[no-untyped-def] + from app.providers.base import RemoteMailbox + + return [RemoteMailbox(path="Drafts", special_use="drafts", uidvalidity=1)] + + fake = CreatingProvider() + monkeypatch.setattr(draft_sync, "build_provider", lambda _a: fake) + + await draft_sync.push_draft_to_imap_job({}, str(did)) + assert created == ["Drafts"] + async with session_scope() as s: + mb = await s.scalar( + select(Mailbox).where( + Mailbox.account_id == ctx.account_id, Mailbox.special_use == "drafts" + ) + ) + assert mb is not None and mb.path == "Drafts" diff --git a/backend/tests/outbox/test_outbox.py b/backend/tests/outbox/test_outbox.py index 0cd4a7a..c90444e 100644 --- a/backend/tests/outbox/test_outbox.py +++ b/backend/tests/outbox/test_outbox.py @@ -65,14 +65,18 @@ def test_build_mime_sets_threading_and_alternative() -> None: "text": "", }, ) - raw, env_from, env_to = build_mime(account, draft, parent) + transmit_raw, sent_raw, env_from, env_to = build_mime(account, draft, parent) assert env_from == "me@example.com" assert set(env_to) == {"bob@example.com", "c@example.com", "secret@example.com"} - msg = email.message_from_bytes(raw, policy=default_policy) + msg = email.message_from_bytes(transmit_raw, policy=default_policy) assert msg["From"] == "Me " assert msg["To"] == "bob@example.com" - assert "secret@example.com" not in (raw.decode()) # bcc not in headers + assert msg["Bcc"] is None # transmitted copy carries no Bcc + assert "secret@example.com" not in transmit_raw.decode() # bcc not transmitted + # ...but the Sent copy records the Bcc so the sender keeps the blind recipient. + sent_msg = email.message_from_bytes(sent_raw, policy=default_policy) + assert sent_msg["Bcc"] == "secret@example.com" assert msg["In-Reply-To"] == "" assert "" in msg["References"] and "" in msg["References"] # multipart/alternative with an auto-generated text part @@ -96,7 +100,7 @@ def test_build_mime_includes_attachments() -> None: kind="new", payload={"to": ["bob@example.com"], "subject": "Files", "html": "

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", ""), + flags=frozenset({"\\Seen"}), + internal_date=datetime.now(UTC), + ) + ], + [], + [], + None, + None, + ) + return MailboxDelta([], [], [], None, None) + + async def close(self) -> None: + return None + + monkeypatch.setattr(sync_engine, "build_provider", lambda _a: FakeProvider()) + + try: + await sync_engine._poll_secondary_once(account.id) + + # Only the non-INBOX special-use folder is polled (SENT); INBOX and the + # custom folder (no special_use) are skipped. + assert polled == ["SENT"] + + # The delta was applied: the sent message is now in the DB under SENT. + session.expunge_all() + n = await session.scalar( + select(func.count()).select_from(Message).where(Message.mailbox_id == sent.id) + ) + assert n == 1 + finally: + async with session_scope() as cleanup: + await cleanup.execute(delete(User).where(User.id == user.id)) + await session.close() + await engine.dispose() diff --git a/backend/workers/worker.py b/backend/workers/worker.py index b13ef58..01199e1 100644 --- a/backend/workers/worker.py +++ b/backend/workers/worker.py @@ -10,6 +10,7 @@ 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 ( @@ -18,7 +19,7 @@ from app.sync.actions import ( move_thread_to_folder_job, push_flags_job, ) -from app.sync.engine import bootstrap_and_backfill +from app.sync.engine import bootstrap_and_backfill, refresh_account_job logger = logging.getLogger(__name__) @@ -60,6 +61,9 @@ class WorkerSettings: 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 diff --git a/frontend/src/components/compose/Composer.tsx b/frontend/src/components/compose/Composer.tsx index d2545a6..f09512b 100644 --- a/frontend/src/components/compose/Composer.tsx +++ b/frontend/src/components/compose/Composer.tsx @@ -1,12 +1,12 @@ import { useQuery } from "@tanstack/react-query"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useT } from "../../i18n"; import { api, ApiError } from "../../lib/api"; import type { AccountOut, AttachmentOut, DraftInput, DraftOut } from "../../lib/types"; import { Icon } from "../Icon"; import { RecipientChips } from "./RecipientChips"; -import { RichTextEditor } from "./RichTextEditor"; +import { RichTextEditor, type RichTextEditorHandle } from "./RichTextEditor"; const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024; // 25 MB per file @@ -142,12 +142,26 @@ export function Composer({ const [status, setStatus] = useState<"" | "saving" | "saved" | "sending" | "error">(""); const [undo, setUndo] = useState<{ outboxId: string } | null>(null); const draftId = useRef(init.draftId ?? null); + const sentRef = useRef(false); // send initiated -> don't mirror to Drafts on close const undoTimer = useRef(null); const fileInputRef = useRef(null); + const editorRef = useRef(null); const account = accounts.find((a) => a.id === accountId); const fromName = account?.display_name || ""; const fromEmail = account?.email ?? ""; + const signature = account?.signature ?? ""; + + // When the From account changes, swap the signature block for the new one. + const firstAccount = useRef(true); + useEffect(() => { + if (firstAccount.current) { + firstAccount.current = false; + return; + } + editorRef.current?.replaceSignature(accounts.find((a) => a.id === accountId)?.signature ?? ""); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [accountId]); // Fetch the original message's sanitized body to quote in a reply/forward. useEffect(() => { @@ -208,24 +222,24 @@ export function Composer({ const removeAttachment = (id: string) => setAttachments((prev) => prev.filter((a) => a.id !== id)); - // The editor's seed content: an empty line to type into, then (for replies) - // the attribution line and the quoted original inside a blockquote. - const initialHtml = useMemo(() => { + // The editor's seed content. New messages get the account signature (2 blank + // lines above it); replies keep the quoted original in a marked block so the + // signature can later be inserted above it. + const initialHtml = (() => { if (init.html) return init.html; if (init.text) return textToHtml(init.text); if (init.quote) { return ( `

` + + `
` + `
${escapeHtml(init.quote.attribution)}
` + - `
${quoteHtml}
` + `
${quoteHtml}
` ); } - return `

`; - // Only the quote resolving should (re)compute this; it mounts the editor once. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [quoteResolved]); + return `


${signature}
`; + })(); - const bodyReady = quoteResolved; + const bodyReady = quoteResolved && accountsQuery.data !== undefined; const payload = (): DraftInput => { const html = bodyHtml || initialHtml; @@ -269,12 +283,18 @@ export function Composer({ useEffect( () => () => { if (undoTimer.current) window.clearTimeout(undoTimer.current); + // On close, mirror the draft to the IMAP Drafts folder so it can be + // continued on any mail client (skip if it was sent or discarded). + if (draftId.current && !sentRef.current) { + void api.publishDraft(draftId.current).catch(() => {}); + } }, [], ); const send = async () => { setStatus("sending"); + sentRef.current = true; // don't also mirror to Drafts on close try { if (!draftId.current) draftId.current = (await api.createDraft(payload())).id; else await api.updateDraft(draftId.current, payload()); @@ -282,6 +302,7 @@ export function Composer({ setUndo({ outboxId: res.outbox_id }); undoTimer.current = window.setTimeout(onClose, 10000); } catch (e) { + sentRef.current = false; setStatus("error"); if (!(e instanceof ApiError)) throw e; } @@ -295,6 +316,7 @@ export function Composer({ /* too late */ } if (undoTimer.current) window.clearTimeout(undoTimer.current); + sentRef.current = false; // back to a draft; mirror it on close setUndo(null); setStatus(""); }; @@ -403,6 +425,16 @@ export function Composer({ > {t("compose.cc")} / {t("compose.bcc")} + + ); +} + +// The Drafts folder lists local drafts (the `drafts` table) plus drafts found in +// the account's IMAP Drafts folder that were created on another client. export function DraftsList({ drafts, + remoteDrafts, onOpen, + onOpenRemote, }: { drafts: DraftOut[]; + remoteDrafts: RemoteDraftOut[]; onOpen: (draft: DraftOut) => void; + onOpenRemote: (draft: RemoteDraftOut) => void; }) { const t = useT(); - if (drafts.length === 0) { + if (drafts.length === 0 && remoteDrafts.length === 0) { return
{t("compose.noDrafts")}
; } + + // Merge local + remote drafts into one list sorted by date (newest first). + type Row = { + key: string; + date: string; + recipients: string; + subject: string; + preview: string; + badge?: string; + onClick: () => void; + }; + const rows: Row[] = [ + ...drafts.map((d) => ({ + key: `l-${d.id}`, + date: d.updated_at, + recipients: d.payload.to.join(", ") || t("compose.noRecipient"), + subject: d.payload.subject || t("compose.noSubject"), + preview: d.payload.text.trim(), + onClick: () => onOpen(d), + })), + ...remoteDrafts.map((d) => ({ + key: `r-${d.message_id}`, + date: d.updated_at, + recipients: d.to.join(", ") || t("compose.noRecipient"), + subject: d.subject || t("compose.noSubject"), + preview: d.snippet.trim(), + badge: t("compose.otherClient"), + onClick: () => onOpenRemote(d), + })), + ].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + return (
- {drafts.map((draft) => { - const recipients = draft.payload.to.join(", ") || t("compose.noRecipient"); - const preview = draft.payload.text.trim(); - return ( - - ); - })} + {rows.map((row) => ( + + ))}
); } diff --git a/frontend/src/components/list/MessageList.tsx b/frontend/src/components/list/MessageList.tsx index 8fb4f22..5276630 100644 --- a/frontend/src/components/list/MessageList.tsx +++ b/frontend/src/components/list/MessageList.tsx @@ -61,18 +61,13 @@ export function MessageList({ height: item.size, transform: `translateY(${item.start}px)`, }} - className={`flex flex-col gap-1 border-b border-app-border px-3 py-2.5 text-left ${ + className={`flex flex-col gap-1 border-b border-app-border px-6 py-2.5 text-left ${ selected ? "bg-app-active" : "hover:bg-app-hover" }`} >
- {unread ? ( - - ) : ( - - )} {thread.account_ids.map((id) => ( {timeLabel(thread.last_message_at)}
-
+
{thread.subject} {thread.has_attachments && 📎}
-
{thread.snippet}
+
{thread.snippet}
); })} diff --git a/frontend/src/components/thread/ThreadView.tsx b/frontend/src/components/thread/ThreadView.tsx index d35778f..dcfc986 100644 --- a/frontend/src/components/thread/ThreadView.tsx +++ b/frontend/src/components/thread/ThreadView.tsx @@ -120,6 +120,9 @@ function MessageItem({ {message.cc_addr.length > 0 && ( · cc: {formatList(message.cc_addr)} )} + {message.bcc_addr.length > 0 && ( + · bcc: {formatList(message.bcc_addr)} + )} {new Date(message.date).toLocaleString()} @@ -193,7 +196,7 @@ export function ThreadView({ subject, messages, order, replyKindSignal }: Thread return (
-
+

{subject || "(no subject)"}

+ {quotaQuery.data && ( +
+ {quotaLabel(quotaQuery.data)} +
+ )}
@@ -240,7 +298,7 @@ export function MailLayout() { style={{ width: listW }} className="flex shrink-0 flex-col border-r border-app-border bg-app-surface" > -
+
{!isDrafts && ( -
+
setFilter("all")}> {t("filter.all")} @@ -261,7 +319,7 @@ export function MailLayout() { {t("filter.pinned")}
+
+ + {!isDrafts && total > 0 ? ( +
+ + {pageStart + 1}–{pageEnd} of {total} + +
+ setPage(curPage - 1)} + label={t("pager.prev")} + dir="left" + /> + = total} + onClick={() => setPage(curPage + 1)} + label={t("pager.next")} + dir="right" + /> +
+
+ ) : ( + )}
@@ -291,7 +392,7 @@ export function MailLayout() {
{threadId && detailQuery.data ? (
-
+
{currentFolder === "archive" ? ( <> @@ -369,6 +470,7 @@ export function MailLayout() { setComposing(null); setComposerExpanded(false); queryClient.invalidateQueries({ queryKey: ["drafts"] }); + queryClient.invalidateQueries({ queryKey: ["drafts-remote"] }); }} />
@@ -401,6 +503,52 @@ function FilterTab({ ); } +function PagerButton({ + disabled, + onClick, + label, + dir, +}: { + disabled: boolean; + onClick: () => void; + label: string; + dir: "left" | "right"; +}) { + return ( + + ); +} + +function RefreshIcon({ spinning }: { spinning: boolean }) { + return ( + + + + + ); +} + function SortIcon() { return ( +

{t("settings.profile")}

@@ -114,7 +115,7 @@ function SecuritySection() { }); return ( -
+

{t("settings.twoFactor")}

{enabled ? ( @@ -217,13 +218,21 @@ function AccountsSection() { const navigate = useNavigate(); const queryClient = useQueryClient(); const accounts = useQuery({ queryKey: ["accounts"], queryFn: api.accounts }); + const [editing, setEditing] = useState<{ id: string; mode: "account" | "signature" } | null>( + null, + ); const remove = useMutation({ mutationFn: (id: string) => api.deleteAccount(id), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["accounts"] }), }); + const close = () => { + setEditing(null); + queryClient.invalidateQueries({ queryKey: ["accounts"] }); + }; + return ( -
+

{t("settings.accounts")}

+
  • + {editing?.id === a.id && editing.mode === "account" ? ( + setEditing(null)} onSaved={close} /> + ) : editing?.id === a.id && editing.mode === "signature" ? ( + setEditing(null)} onSaved={close} /> + ) : ( +
    + + + {a.email} + + {t("settings.syncState", { state: a.state })} + + + + + + + +
    + )}
  • ))} @@ -256,6 +289,243 @@ function AccountsSection() { ); } +function AccountEditor({ + accountId, + onCancel, + onSaved, +}: { + accountId: string; + onCancel: () => void; + onSaved: () => void; +}) { + const t = useT(); + const detail = useQuery({ queryKey: ["account", accountId], queryFn: () => api.accountDetail(accountId) }); + const [displayName, setDisplayName] = useState(""); + const [color, setColor] = useState("#888888"); + const [showServer, setShowServer] = useState(false); + const [imapHost, setImapHost] = useState(""); + const [imapPort, setImapPort] = useState("993"); + const [imapTls, setImapTls] = useState("ssl"); + const [smtpHost, setSmtpHost] = useState(""); + const [smtpPort, setSmtpPort] = useState("465"); + const [smtpTls, setSmtpTls] = useState("ssl"); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + + useEffect(() => { + const d = detail.data; + if (!d) return; + setDisplayName(d.display_name); + setColor(d.color); + setImapHost(String(d.imap_config.host ?? "")); + setImapPort(String(d.imap_config.port ?? "993")); + setImapTls(String(d.imap_config.tls ?? "ssl")); + setSmtpHost(String(d.smtp_config.host ?? "")); + setSmtpPort(String(d.smtp_config.port ?? "465")); + setSmtpTls(String(d.smtp_config.tls ?? "ssl")); + }, [detail.data]); + + const save = useMutation({ + mutationFn: () => { + const patch: Parameters[1] = { + display_name: displayName, + color, + }; + if (showServer) { + patch.imap = { host: imapHost, port: Number(imapPort), tls: imapTls, auth: "password" }; + patch.smtp = { host: smtpHost, port: Number(smtpPort), tls: smtpTls, auth: "password" }; + patch.password = password; + } + return api.updateAccount(accountId, patch); + }, + onSuccess: onSaved, + onError: (e) => setError(e instanceof ApiError ? e.message : "Save failed"), + }); + + if (!detail.data) return
    {t("common.loading")}
    ; + + const canSave = !save.isPending && (!showServer || password.length > 0); + + return ( +
    +
    + setColor(e.target.value)} + aria-label={t("settings.color")} + /> + setDisplayName(e.target.value)} + /> +
    + + {!showServer ? ( + + ) : ( +
    +

    {t("settings.serverSettingsHint")}

    + + + setPassword(e.target.value)} + /> +
    + )} + + {error &&

    {error}

    } +
    + + +
    +
    + ); +} + +function SignatureEditor({ + accountId, + onCancel, + onSaved, +}: { + accountId: string; + onCancel: () => void; + onSaved: () => void; +}) { + const t = useT(); + const detail = useQuery({ + queryKey: ["account", accountId], + queryFn: () => api.accountDetail(accountId), + }); + const [signature, setSignature] = useState(""); + const [error, setError] = useState(""); + + useEffect(() => { + if (detail.data) setSignature(detail.data.signature); + }, [detail.data]); + + const save = useMutation({ + mutationFn: () => api.updateAccount(accountId, { signature }), + onSuccess: onSaved, + onError: (e) => setError(e instanceof ApiError ? e.message : "Save failed"), + }); + + if (!detail.data) return
    {t("common.loading")}
    ; + + return ( +
    +
    + {t("settings.signature")} — {detail.data.email} +
    +
    + +
    + {error &&

    {error}

    } +
    + + +
    +
    + ); +} + +function ServerRow({ + label, + host, + port, + tls, + onHost, + onPort, + onTls, +}: { + label: string; + host: string; + port: string; + tls: string; + onHost: (v: string) => void; + onPort: (v: string) => void; + onTls: (v: string) => void; +}) { + const t = useT(); + return ( +
    + {label} + onHost(e.target.value)} + /> + onPort(e.target.value)} + /> + +
    + ); +} + function AppearanceSection() { const t = useT(); const queryClient = useQueryClient(); @@ -267,7 +537,7 @@ function AppearanceSection() { }); return ( -
    +

    {t("settings.appearance")}

    {t("settings.theme")} diff --git a/frontend/src/test/ThreadView.test.tsx b/frontend/src/test/ThreadView.test.tsx index c9fa5a2..d50963a 100644 --- a/frontend/src/test/ThreadView.test.tsx +++ b/frontend/src/test/ThreadView.test.tsx @@ -17,6 +17,7 @@ function msg(id: string, minutes: number, unread = false): MessageEnvelope { from_addr: [{ name: `Sender ${id}`, email: `${id}@x.com` }], to_addr: [], cc_addr: [], + bcc_addr: [], date: new Date(2026, 0, 1, 12, minutes).toISOString(), flags: unread ? [] : ["\\Seen"], is_unread: unread, diff --git a/screenshots/mail_list_alignement.png b/screenshots/mail_list_alignement.png new file mode 100644 index 0000000..0f9938d Binary files /dev/null and b/screenshots/mail_list_alignement.png differ