Two-way draft sync, signatures, sent/bcc fixes, and mail-list UI
Backend - Sent copies: persist locally on send + periodic multi-folder CONDSTORE poll (Sent/Archive/Trash/Junk) so they thread and show without an IDLE-only INBOX. - Bcc: transmitted copy omits Bcc, Sent copy keeps it; store/display bcc_addr. - Drafts: mirror local drafts to the IMAP Drafts folder (append/replace/expunge); inbound sync of foreign drafts (un-threaded) with adopt-to-edit + dedup by UID. - Per-user storage quota (5 GiB default) and manual "sync all folders" action. - Account editing (display/color/server/password) and per-account HTML signature. - Account view scoped to that account's inbox; new migrations 0006–0009. Frontend - Mail-list pagination, folder filters (All/Unread/Pinned), sort toggle, refresh. - Aligned + widened mail-list margins; reading-pane bars fixed height. - Rich-text composer: color palette, highlight, image; attachments; expand/pop-out. - Signatures: auto-insert for new mail, replace on From change, reply insert. - Restore/Delete actions per folder; drafts view merges local + remote, date-sorted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
+122
-2
@@ -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"}
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
+75
-39
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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": "<p>Best,<br>Me</p>"}, headers=_hdr()
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["signature"] == "<p>Best,<br>Me</p>"
|
||||
# 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"] == "<p>Best,<br>Me</p>"
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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": "<p>hi</p>", "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"] = "<d1@x.com>"
|
||||
m["In-Reply-To"] = "<orig@x.com>" # 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"
|
||||
@@ -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 <me@example.com>"
|
||||
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"] == "<orig@x.com>"
|
||||
assert "<root@x.com>" in msg["References"] and "<orig@x.com>" 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": "<p>hi</p>", "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="<p>hello</p>"))
|
||||
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"] == "<p>hello</p>"
|
||||
|
||||
# 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"
|
||||
|
||||
@@ -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", "<s1@x.com>"),
|
||||
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()
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user