mail UX: restore-to-folder, draft/attachment fixes, printing, sidebar collapse

Backend:
- Restore returns each message to its original folder (e.g. Sent/Archive) instead
  of always the Inbox: new messages.restore_mailbox_id (migration 0010) +
  restore_thread_job.
- Drafts: expunge reconciliation for the Drafts poll (provider.list_uids /
  _reconcile_drafts) clears phantom "remote" drafts; send + discard drop the
  mirrored DB row; adopting a remote draft carries its real attachments into the
  compose flow.
- Thread detail/list are folder-aware: individually trashed/spam messages are
  hidden outside their folder, Trash/Spam show only their own messages, and the
  mail-list message count matches the view.

Frontend:
- Thread reading panel: fix newest-first card overlap/spacing (expand-all and
  single expand), hide Reply/Forward on trashed messages, close the Actions menu
  on message-body (iframe) clicks, scroll to the inline composer, and prompt
  Save draft / Discard on the X close.
- Composer: remount when switching drafts, dirty-tracking to stop duplicate IMAP
  mirrors, and close the From/More dropdowns on outside click.
- Sidebar: collapse/expand toggle (icons-only ~60px rail) and a log-out icon on
  Sign out.
- Mail-standard printing in a new window with reliable page margins (lib/print.ts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
2026-07-20 23:12:25 +02:00
co-authored by Claude Opus 4.8
parent 1a83e0fb9b
commit d650f16feb
33 changed files with 1927 additions and 105 deletions
+7 -2
View File
@@ -111,8 +111,13 @@ async def trash_thread(thread_id: uuid.UUID, user: CurrentUser, db: DbSession) -
@router.post("/threads/{thread_id}/restore")
async def restore_thread(thread_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]:
"""Move a thread back to the Inbox (from Archive or Trash)."""
await _move_thread(user.id, thread_id, db, "inbox")
"""Restore a thread (from Archive or Trash) to each message's original folder."""
thread = await db.scalar(
select(Thread).where(Thread.id == thread_id, Thread.user_id == user.id)
)
if thread is None:
raise_api_error(404, "not_found", "thread not found")
await enqueue_job("restore_thread_job", str(thread_id))
return {"status": "queued"}
+37 -3
View File
@@ -16,7 +16,7 @@ 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 BodyPart, ComposeAttachment, Draft, Mailbox, Message
from app.models import Attachment, BodyPart, ComposeAttachment, Draft, Mailbox, Message
from app.outbox.schemas import (
DraftIn,
DraftOut,
@@ -24,7 +24,13 @@ from app.outbox.schemas import (
SendRequest,
SendResponse,
)
from app.outbox.service import DraftError, cancel_outbox, create_outbox, upsert_draft
from app.outbox.service import (
DraftError,
cancel_outbox,
create_outbox,
delete_mirrored_draft_row,
upsert_draft,
)
from app.queue import enqueue_job
router = APIRouter(tags=["compose"], dependencies=[Depends(require_csrf)])
@@ -183,6 +189,30 @@ async def adopt_remote_draft(message_id: uuid.UUID, user: CurrentUser, db: DbSes
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"), "")
# Carry the draft's real (non-inline) attachments over as compose attachments,
# so they show in the editor and are re-attached on save/send. The underlying
# blobs are shared by sha256 — we only add new ComposeAttachment references.
att_rows = list(
await db.scalars(
select(Attachment)
.where(Attachment.message_id == msg.id, Attachment.content_id.is_(None))
.order_by(Attachment.filename)
)
)
attachment_ids: list[str] = []
for a in att_rows:
ca = ComposeAttachment(
user_id=user.id,
filename=a.filename,
mime_type=a.mime_type,
size_bytes=a.size_bytes,
blob_sha256=a.blob_sha256,
)
db.add(ca)
await db.flush()
attachment_ids.append(str(ca.id))
payload = {
"to": [_fmt_addr(a) for a in msg.to_addr],
"cc": [_fmt_addr(a) for a in msg.cc_addr],
@@ -190,7 +220,7 @@ async def adopt_remote_draft(message_id: uuid.UUID, user: CurrentUser, db: DbSes
"subject": msg.subject,
"html": html,
"text": text,
"attachment_ids": [],
"attachment_ids": attachment_ids,
}
draft = Draft(
user_id=user.id,
@@ -244,6 +274,10 @@ async def delete_draft(draft_id: uuid.UUID, user: CurrentUser, db: DbSession) ->
raise_api_error(404, "not_found", "draft not found")
account_id, imap_uid = draft.account_id, draft.imap_uid
await db.delete(draft)
# Drop the mirrored Drafts-folder message row now so the draft doesn't
# resurface as an "other client" remote draft after the local row is gone.
if imap_uid:
await delete_mirrored_draft_row(db, account_id=account_id, imap_uid=imap_uid)
await db.commit()
# Remove the mirrored copy from the server Drafts folder too.
if imap_uid:
+54 -10
View File
@@ -6,7 +6,7 @@ import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from sqlalchemy import literal, select, tuple_
from sqlalchemy import func, literal, or_, select, tuple_
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.cursors import decode_cursor, encode_cursor
@@ -31,14 +31,16 @@ def _addresses(raw: list[dict[str, str]]) -> list[Address]:
return [Address(name=a.get("name", ""), email=a.get("email", "")) for a in raw]
def _summary(thread: Thread) -> ThreadSummary:
def _summary(thread: Thread, message_count: int | None = None) -> ThreadSummary:
return ThreadSummary(
id=thread.id,
subject=thread.subject_norm or "(no subject)",
snippet=thread.snippet,
participants=_addresses(thread.participants),
account_ids=list(thread.account_ids),
message_count=thread.message_count,
# Count only the messages this view actually shows (deleted/spam excluded
# elsewhere); falls back to the denormalized total.
message_count=message_count if message_count is not None else thread.message_count,
unread_count=thread.unread_count,
has_attachments=thread.has_attachments,
pinned=thread.pinned,
@@ -123,7 +125,32 @@ async def list_threads(
next_cursor = (
encode_cursor(threads[-1].last_message_at, threads[-1].id) if has_more and threads else None
)
return ThreadListOut(threads=[_summary(t) for t in threads], cursor=next_cursor)
# Per-thread visible message count matching what get_thread shows for this view:
# Trash/Spam count only their own messages; every other view excludes them. This
# keeps the list count in step with a single deleted message.
counts: dict[uuid.UUID, int] = {}
if threads:
count_stmt = (
select(Message.thread_id, func.count())
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(Message.user_id == user.id, Message.thread_id.in_([t.id for t in threads]))
.group_by(Message.thread_id)
)
current_special = view.split(":", 1)[1] if view.startswith("folder:") else None
if current_special in _REMOVAL_FOLDERS:
count_stmt = count_stmt.where(Mailbox.special_use == current_special)
else:
count_stmt = count_stmt.where(
or_(Mailbox.special_use.is_(None), Mailbox.special_use.notin_(_REMOVAL_FOLDERS))
)
counts = {
r[0]: r[1] for r in (await db.execute(count_stmt)).all() if r[0] is not None
}
return ThreadListOut(
threads=[_summary(t, counts.get(t.id)) for t in threads], cursor=next_cursor
)
async def _resolve_thread(db: AsyncSession, user: User, thread_id: uuid.UUID) -> Thread:
@@ -135,23 +162,40 @@ async def _resolve_thread(db: AsyncSession, user: User, thread_id: uuid.UUID) ->
return thread
# Trash/Spam are message-scoped folders: an individually deleted message lives
# there on its own. Viewing a thread from Trash/Spam shows ONLY the messages filed
# there; every other view shows the conversation with trashed/spam messages hidden.
_REMOVAL_FOLDERS = frozenset({"trash", "junk"})
@router.get("/{thread_id}", response_model=ThreadDetail)
async def get_thread(
thread_id: uuid.UUID,
user: CurrentUser,
db: DbSession,
sort: str = Query("asc"),
view: str = Query("unified"),
) -> ThreadDetail:
thread = await _resolve_thread(db, user, thread_id)
current_special = view.split(":", 1)[1] if view.startswith("folder:") else None
# Messages are always returned ASC (oldest first); the client reverses for
# display per the requested order (invariant I-5 / §9.4).
messages = list(
await db.scalars(
select(Message)
.where(Message.thread_id == thread_id, Message.user_id == user.id)
.order_by(Message.internal_date.asc(), Message.id.asc())
)
stmt = (
select(Message)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(Message.thread_id == thread_id, Message.user_id == user.id)
.order_by(Message.internal_date.asc(), Message.id.asc())
)
if current_special in _REMOVAL_FOLDERS:
# Show only the messages actually in this Trash/Spam folder.
stmt = stmt.where(Mailbox.special_use == current_special)
else:
# Conversation view: hide messages that were individually trashed or spammed
# (keep custom folders, special_use IS NULL, and every non-removal folder).
stmt = stmt.where(
or_(Mailbox.special_use.is_(None), Mailbox.special_use.notin_(_REMOVAL_FOLDERS))
)
messages = list(await db.scalars(stmt))
order = "desc" if sort == "desc" else "asc"
return ThreadDetail(
id=thread.id,
+5
View File
@@ -147,6 +147,11 @@ class Message(Base):
mailbox_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("mailboxes.id", ondelete="CASCADE"), nullable=False
)
# The folder this message was in before being moved to Archive/Trash/Junk, so
# Restore can return it there (e.g. Sent) instead of the Inbox. NULL otherwise.
restore_mailbox_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("mailboxes.id", ondelete="SET NULL")
)
uid: Mapped[int] = mapped_column(Integer, nullable=False)
thread_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("threads.id")
+7
View File
@@ -24,6 +24,7 @@ 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.outbox.service import delete_mirrored_draft_row
from app.providers.base import RemoteMessage
from app.search.factory import get_search_backend
from app.sync.provider_factory import build_provider
@@ -178,6 +179,12 @@ async def send_job(ctx: dict[str, Any], outbox_id: str) -> None:
draft_row = await session.get(Draft, draft.id)
if draft_row is not None:
await session.delete(draft_row)
# Also drop the mirrored Drafts-folder copy's DB row (we expunged it on the
# server above) so it doesn't resurface as a "remote" draft.
if draft_imap_uid:
await delete_mirrored_draft_row(
session, account_id=account_obj.id, imap_uid=draft_imap_uid
)
await _harvest_recipients(session, user_id, envelope_to)
bus = get_event_bus()
+24 -1
View File
@@ -9,7 +9,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.models import Account, Draft, Outbox
from app.models import Account, Draft, Mailbox, Message, Outbox
from app.outbox.schemas import DraftIn
@@ -17,6 +17,29 @@ class DraftError(Exception):
"""Draft could not be created/updated (e.g. account not owned)."""
async def delete_mirrored_draft_row(
session: AsyncSession, *, account_id: uuid.UUID, imap_uid: int
) -> None:
"""Delete the DB row for a draft that was mirrored into the IMAP Drafts folder.
The server copy is expunged separately (send_job / remove_imap_draft_job).
Without also dropping this row the draft resurfaces in ``list_remote_drafts``
as an "other client" draft once its local ``drafts`` row is gone — until the
next Drafts poll would catch the expunge. Used on both send and discard.
"""
mirror = await session.scalar(
select(Message)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(
Message.account_id == account_id,
Message.uid == imap_uid,
Mailbox.special_use == "drafts",
)
)
if mirror is not None:
await session.delete(mirror)
async def upsert_draft(
session: AsyncSession, *, user_id: uuid.UUID, body: DraftIn, draft_id: uuid.UUID | None
) -> Draft:
+4
View File
@@ -83,6 +83,10 @@ class MailProvider(Protocol):
otherwise UID SEARCH + FETCH FLAGS diff."""
...
async def list_uids(self, mailbox: str) -> set[int]:
"""Full set of UIDs currently present in the mailbox (expunge reconciliation)."""
...
async def idle(self, mailbox: str) -> None:
"""Blocks until the server signals change or 25 min elapse (re-issue IDLE). May raise
ProviderNotSupported => sync loop falls back to poll() every POLL_WARM (default 180 s).
@@ -198,6 +198,18 @@ class ImapSmtpProvider(MailProvider):
if messages:
yield messages
async def list_uids(self, mailbox: str) -> set[int]:
"""Full set of UIDs currently present in the mailbox (expunge reconciliation).
Cheap — a single ``UID SEARCH ALL``. The CONDSTORE poll only reports new and
changed messages, not vanished ones (that needs QRESYNC/VANISHED); callers use
this to detect server-side expunges for small folders like Drafts.
"""
await self._select(mailbox)
imap = await self._connect()
resp = await imap.uid_search("ALL")
return set(parse_search_uids(resp.lines))
async def _fetch_messages(self, imap: aioimaplib.IMAP4, uids: list[int]) -> list[RemoteMessage]:
if not uids:
return []
+75
View File
@@ -32,6 +32,10 @@ _MAX_TRIES = 5
# Folder names to create when a target special-use folder is missing on the server.
_DEFAULT_FOLDER_NAME = {"archive": "Archive", "trash": "Trash", "junk": "Spam"}
# Moves into these folders record where the message came from (restore_mailbox_id)
# so the Restore action can return it to its original folder.
_REMOVAL_FOLDERS = frozenset({"archive", "trash", "junk"})
def sanitize_flags(flags: Iterable[str]) -> frozenset[str]:
return frozenset(f for f in flags if f in ALLOWED_FLAGS)
@@ -214,6 +218,9 @@ async def move_thread_to_folder_job(ctx: dict[str, Any], thread_id: str, special
)
message = await session.get(Message, mid)
if message is not None and resolved is not None:
# Remember where it came from so Restore returns it there, not the Inbox.
if special_use in _REMOVAL_FOLDERS and message.mailbox_id != resolved:
message.restore_mailbox_id = message.mailbox_id
message.mailbox_id = resolved
message.uid = new_uid
@@ -222,6 +229,74 @@ async def move_thread_to_folder_job(ctx: dict[str, Any], thread_id: str, special
)
async def restore_thread_job(ctx: dict[str, Any], thread_id: str) -> None:
"""Move every message in a thread back to its original folder (Restore action).
Each message returns to ``restore_mailbox_id`` (the folder it was in before it
was archived/trashed) — e.g. a deleted Sent message goes back to Sent. Messages
with no recorded origin fall back to the account's Inbox.
"""
tid = uuid.UUID(thread_id)
async with session_scope() as session:
messages = list(await session.scalars(select(Message).where(Message.thread_id == tid)))
if not messages:
return
user_id = messages[0].user_id
account_ids = list({m.account_id for m in messages})
accounts = {
a.id: a
for a in await session.scalars(select(Account).where(Account.id.in_(account_ids)))
}
mailboxes = list(
await session.scalars(select(Mailbox).where(Mailbox.account_id.in_(account_ids)))
)
path_by_id = {mb.id: mb.path for mb in mailboxes}
inbox_by_account = {
mb.account_id: mb.id for mb in mailboxes if mb.special_use == "inbox"
}
# (message_id, account_id, source_path, target_id) — resolve the target now.
plan: list[tuple[uuid.UUID, uuid.UUID, str, uuid.UUID, int]] = []
for m in messages:
target_id = m.restore_mailbox_id
if target_id is None or target_id not in path_by_id:
target_id = inbox_by_account.get(m.account_id)
if target_id is None or target_id == m.mailbox_id:
continue # nowhere to restore to, or already there
plan.append(
(m.id, m.account_id, path_by_id.get(m.mailbox_id, "INBOX"), target_id, m.uid)
)
# (message_id, target_id, new_uid) applied after the network moves.
moves: list[tuple[uuid.UUID, uuid.UUID, int]] = []
for account_id in account_ids:
provider = build_provider(accounts[account_id])
try:
for mid, m_account, src_path, target_id, uid in plan:
if m_account != account_id:
continue
target_path = path_by_id[target_id]
try:
new_uid = await provider.move(uid, src_path, target_path)
except Exception:
logger.exception("restore move failed for message %s -> %s", mid, target_path)
continue
moves.append((mid, target_id, new_uid))
finally:
await provider.close()
async with session_scope() as session:
for mid, target_id, new_uid in moves:
message = await session.get(Message, mid)
if message is not None:
message.mailbox_id = target_id
message.uid = new_uid
message.restore_mailbox_id = None
await get_event_bus().publish(
"thread.updated", {"user_id": str(user_id), "thread_id": str(tid)}
)
async def delete_thread_job(ctx: dict[str, Any], thread_id: str) -> None:
"""Permanently delete every message in a thread (server EXPUNGE + DB rows).
+44 -1
View File
@@ -15,7 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.events import get_event_bus
from app.indexer.indexer import PersistResult, persist_message
from app.models import Account, Mailbox, Message
from app.models import Account, Draft, Mailbox, Message
from app.providers.base import MailboxDelta, ProviderNotSupported
from app.providers.imap_smtp import ImapSmtpProvider
from app.search.factory import get_search_backend
@@ -248,6 +248,49 @@ async def apply_delta(
},
)
# Expunge reconciliation for Drafts. The mirror mechanism (and other clients)
# expunge server copies, but a CONDSTORE poll doesn't report vanished UIDs, so
# expunged drafts would linger in the DB and resurface as phantom "remote"
# drafts. Drafts folders are small, so a full UID reconcile per poll is cheap.
if mailbox.special_use == "drafts":
await _reconcile_drafts(provider, account, mailbox)
async def _reconcile_drafts(
provider: ImapSmtpProvider, account: Account, mailbox: Mailbox
) -> None:
"""Drop DB rows for drafts that no longer exist on the server (were expunged)."""
from app.db import session_scope
try:
server_uids = await provider.list_uids(mailbox.path)
except Exception:
logger.exception("drafts reconcile: list_uids failed for %s", mailbox.path)
return
async with session_scope() as session:
db_uids = set(
await session.scalars(select(Message.uid).where(Message.mailbox_id == mailbox.id))
)
gone = db_uids - server_uids
if not gone:
return
await session.execute(
delete(Message).where(Message.mailbox_id == mailbox.id, Message.uid.in_(gone))
)
# A local draft whose mirrored copy vanished becomes local-only again.
await session.execute(
update(Draft)
.where(Draft.account_id == account.id, Draft.imap_uid.in_(gone))
.values(imap_uid=None)
)
logger.info("drafts reconcile: removed %d expunged row(s) in %s", len(gone), mailbox.path)
await get_event_bus().publish(
"message.deleted",
{"user_id": str(account.user_id), "account_id": str(account.id), "mailbox": mailbox.path},
)
async def live_loop(
account_id: uuid.UUID, mailbox_path: str = "INBOX", stop: asyncio.Event | None = None
@@ -0,0 +1,32 @@
"""add messages.restore_mailbox_id
Records the folder a message was in before it was moved to Archive/Trash/Junk, so
the Restore action can return it to where it came from (e.g. Sent) instead of
always dumping it in the Inbox.
Revision ID: 0010
Revises: 0009
Create Date: 2026-07-20
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "0010"
down_revision: str | None = "0009"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"ALTER TABLE messages ADD COLUMN restore_mailbox_id uuid "
"REFERENCES mailboxes(id) ON DELETE SET NULL"
)
def downgrade() -> None:
op.execute("ALTER TABLE messages DROP COLUMN IF EXISTS restore_mailbox_id")
+44
View File
@@ -254,6 +254,50 @@ async def test_thread_detail_messages_ascending(api: ApiCtx) -> None:
assert dates == sorted(dates) # ascending
async def test_thread_detail_hides_trashed_message_unless_viewing_trash(api: ApiCtx) -> None:
assert api.session is not None
# Give the account a Trash folder and delete a single message of the 2-message
# kickoff thread (move it to Trash) — as the per-message Delete action does.
trash = Mailbox(
account_id=api.account_id, user_id=api.user_id, path="Trash", special_use="trash"
)
api.session.add(trash)
await api.session.flush()
resp = await api.client.get("/api/threads")
kickoff = next(t for t in resp.json()["threads"] if t["message_count"] == 2)
detail = (await api.client.get(f"/api/threads/{kickoff['id']}")).json()
victim = uuid.UUID(detail["messages"][-1]["id"])
msg = await api.session.get(Message, victim)
assert msg is not None
msg.mailbox_id = trash.id
await api.session.commit()
# Viewing the thread from the Inbox: the trashed message is gone.
inbox_view = await api.client.get(
f"/api/threads/{kickoff['id']}", params={"view": "folder:inbox"}
)
ids = [m["id"] for m in inbox_view.json()["messages"]]
assert str(victim) not in ids
assert len(ids) == 1
# Viewing from the Trash folder: only the trashed message is shown (Trash is
# message-scoped), not the rest of the conversation.
trash_view = await api.client.get(
f"/api/threads/{kickoff['id']}", params={"view": "folder:trash"}
)
ids_trash = [m["id"] for m in trash_view.json()["messages"]]
assert ids_trash == [str(victim)]
# The Inbox LIST count drops the trashed message (was 2, now 1); the Trash list
# counts only the one trashed message.
inbox_list = await api.client.get("/api/threads", params={"view": "folder:inbox"})
row = next(t for t in inbox_list.json()["threads"] if t["id"] == kickoff["id"])
assert row["message_count"] == 1
trash_list = await api.client.get("/api/threads", params={"view": "folder:trash"})
trow = next(t for t in trash_list.json()["threads"] if t["id"] == kickoff["id"])
assert trow["message_count"] == 1
async def test_message_html_is_sanitized(api: ApiCtx) -> None:
# the lunch message has HTML with a script
resp = await api.client.get("/api/threads")
+8 -2
View File
@@ -225,14 +225,20 @@ async def test_archive_unknown_thread_404(ctx: Ctx) -> None:
assert ctx.enqueued == []
async def test_restore_thread_enqueues_move_to_inbox(ctx: Ctx) -> None:
async def test_restore_thread_enqueues_restore_job(ctx: Ctx) -> None:
resp = await ctx.client.post(f"/api/threads/{ctx.thread_id}/restore", headers=_hdr())
assert resp.status_code == 200
assert ctx.enqueued == [
("move_thread_to_folder_job", str(ctx.thread_id), "inbox"),
("restore_thread_job", str(ctx.thread_id)),
]
async def test_restore_unknown_thread_404(ctx: Ctx) -> None:
resp = await ctx.client.post(f"/api/threads/{uuid.uuid4()}/restore", headers=_hdr())
assert resp.status_code == 404
assert ctx.enqueued == []
async def test_delete_thread_enqueues_permanent_delete(ctx: Ctx) -> None:
resp = await ctx.client.post(f"/api/threads/{ctx.thread_id}/delete", headers=_hdr())
assert resp.status_code == 200
+67
View File
@@ -274,6 +274,73 @@ async def test_remote_drafts_listed_and_adopted(ctx: Ctx) -> None:
assert again.json()["id"] == draft["id"]
async def test_adopt_remote_draft_carries_attachments(ctx: Ctx) -> None:
from datetime import UTC, datetime
from app.indexer import blobstore
from app.models import Attachment
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=95,
subject="Home draft with file",
to_addr=[{"name": "", "email": "bob@example.com"}],
cc_addr=[],
bcc_addr=[],
internal_date=datetime.now(UTC),
blob_sha256="y" * 64,
snippet="",
has_attachments=True,
)
ctx.session.add(msg)
await ctx.session.flush()
# A real (downloadable) attachment plus an inline cid image, which must NOT be carried.
sha = blobstore.store(ctx.user_id, b"file contents")
ctx.session.add(
Attachment(
message_id=msg.id,
user_id=ctx.user_id,
filename="report.pdf",
mime_type="application/pdf",
size_bytes=13,
content_id=None,
blob_sha256=sha,
)
)
ctx.session.add(
Attachment(
message_id=msg.id,
user_id=ctx.user_id,
filename="logo.png",
mime_type="image/png",
size_bytes=3,
content_id="logo@x",
blob_sha256=blobstore.store(ctx.user_id, b"img"),
)
)
await ctx.session.commit()
adopted = await ctx.client.post(f"/api/drafts/adopt/{msg.id}", headers=_hdr())
assert adopted.status_code == 200, adopted.text
ids = adopted.json()["payload"]["attachment_ids"]
assert len(ids) == 1 # only the real attachment, not the inline image
# The compose-attachment metadata resolves — this is what the editor renders and
# what load_attachments re-attaches on send.
meta = await ctx.client.get("/api/attachments", params={"ids": ",".join(ids)})
assert meta.status_code == 200
body = meta.json()
assert [a["filename"] for a in body] == ["report.pdf"]
assert body[0]["size_bytes"] == 13
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()
+71 -1
View File
@@ -13,7 +13,7 @@ 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.models import Account, Draft, Mailbox, Message, User
from app.providers.base import MailboxDelta, RemoteMessage
from app.sync import engine as sync_engine
@@ -97,3 +97,73 @@ async def test_secondary_poll_syncs_special_folders_not_inbox(monkeypatch) -> No
await cleanup.execute(delete(User).where(User.id == user.id))
await session.close()
await engine.dispose()
async def test_drafts_poll_reconciles_expunged_rows(monkeypatch) -> None: # type: ignore[no-untyped-def]
"""A Drafts poll drops DB rows whose server copy was expunged (phantoms) and
un-links a local draft whose mirrored copy vanished."""
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
sm = async_sessionmaker(engine, expire_on_commit=False)
session = sm()
user = User(handle=f"rec{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()
drafts = Mailbox(account_id=account.id, user_id=user.id, path="DRAFTS", special_use="drafts")
session.add(drafts)
await session.flush()
# uid 2 survives on the server; uids 1 and 3 were expunged (phantom DB rows).
for uid in (1, 2, 3):
session.add(
Message(
user_id=user.id,
account_id=account.id,
mailbox_id=drafts.id,
uid=uid,
internal_date=datetime.now(UTC),
blob_sha256="",
)
)
# A local draft still linking the vanished uid 3 must be un-linked (imap_uid -> NULL).
linked = Draft(user_id=user.id, account_id=account.id, kind="new", payload={}, imap_uid=3)
kept = Draft(user_id=user.id, account_id=account.id, kind="new", payload={}, imap_uid=2)
session.add_all([linked, kept])
await session.commit()
class FakeProvider:
async def poll(self, mailbox: str, *, last_seen_uid: int, highestmodseq: int | None): # type: ignore[no-untyped-def]
return MailboxDelta([], [], [], None, None)
async def list_uids(self, mailbox: str) -> set[int]:
return {2} # only uid 2 remains on the server
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)
session.expunge_all()
remaining = set(
await session.scalars(select(Message.uid).where(Message.mailbox_id == drafts.id))
)
assert remaining == {2} # phantoms 1 and 3 removed
assert (await session.get(Draft, linked.id)).imap_uid is None # un-linked
assert (await session.get(Draft, kept.id)).imap_uid == 2 # untouched
finally:
async with session_scope() as cleanup:
await cleanup.execute(delete(User).where(User.id == user.id))
await session.close()
await engine.dispose()
+2
View File
@@ -19,6 +19,7 @@ from app.sync.actions import (
move_job,
move_thread_to_folder_job,
push_flags_job,
restore_thread_job,
)
from app.sync.engine import bootstrap_and_backfill, refresh_account_job
@@ -38,6 +39,7 @@ INTERACTIVE_FUNCTIONS = [
push_flags_job,
move_job,
move_thread_to_folder_job,
restore_thread_job,
delete_thread_job,
send_job,
push_draft_to_imap_job,