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:
@@ -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"}
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user