From d650f16febf34bd1c2b47a108d25728bb846002c Mon Sep 17 00:00:00 2001 From: Lukas Remis Date: Mon, 20 Jul 2026 23:12:25 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA --- backend/app/api/actions.py | 9 +- backend/app/api/compose.py | 40 +- backend/app/api/threads.py | 64 +- backend/app/models.py | 5 + backend/app/outbox/jobs.py | 7 + backend/app/outbox/service.py | 25 +- backend/app/providers/base.py | 4 + backend/app/providers/imap_smtp/provider.py | 12 + backend/app/sync/actions.py | 75 ++ backend/app/sync/engine.py | 45 +- .../versions/0010_message_restore_mailbox.py | 32 + backend/tests/api/test_api.py | 44 + backend/tests/mail/test_actions.py | 10 +- backend/tests/outbox/test_outbox.py | 67 ++ backend/tests/sync/test_secondary_poll.py | 72 +- backend/workers/worker.py | 2 + frontend/icons/hide.svg | 1 + frontend/icons/show.svg | 1 + frontend/src/components/Icon.tsx | 8 + frontend/src/components/compose/Composer.tsx | 129 ++- frontend/src/components/thread/ThreadView.tsx | 88 +- frontend/src/i18n/en.ts | 4 + frontend/src/lib/api.ts | 4 +- frontend/src/lib/print.ts | 200 +++++ frontend/src/routes/MailLayout.tsx | 170 +++- screenshots/UI_screenshot.jpg~ | Bin 214293 -> 0 bytes screenshots/mail_list_alignement.png | Bin 173390 -> 0 bytes screenshots/palette.png | Bin 1877 -> 0 bytes screenshots/thread-panel-all-expanded.jpg | Bin 0 -> 97999 bytes screenshots/thread-panel-collapsed.jpg | Bin 0 -> 78556 bytes screenshots/thread-panel-one-expanded.jpg | Bin 0 -> 84101 bytes webmail-client-spec_v3.md | 811 ++++++++++++++++++ worker-description.txt | 103 +++ 33 files changed, 1927 insertions(+), 105 deletions(-) create mode 100644 backend/migrations/versions/0010_message_restore_mailbox.py create mode 100644 frontend/icons/hide.svg create mode 100644 frontend/icons/show.svg create mode 100644 frontend/src/lib/print.ts delete mode 100644 screenshots/UI_screenshot.jpg~ delete mode 100644 screenshots/mail_list_alignement.png delete mode 100644 screenshots/palette.png create mode 100644 screenshots/thread-panel-all-expanded.jpg create mode 100644 screenshots/thread-panel-collapsed.jpg create mode 100644 screenshots/thread-panel-one-expanded.jpg create mode 100644 webmail-client-spec_v3.md create mode 100644 worker-description.txt diff --git a/backend/app/api/actions.py b/backend/app/api/actions.py index e0cc20c..acf7667 100644 --- a/backend/app/api/actions.py +++ b/backend/app/api/actions.py @@ -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"} diff --git a/backend/app/api/compose.py b/backend/app/api/compose.py index 622a0c3..8006690 100644 --- a/backend/app/api/compose.py +++ b/backend/app/api/compose.py @@ -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: diff --git a/backend/app/api/threads.py b/backend/app/api/threads.py index a2da8ed..88a158d 100644 --- a/backend/app/api/threads.py +++ b/backend/app/api/threads.py @@ -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, diff --git a/backend/app/models.py b/backend/app/models.py index ad021be..83b5b88 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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") diff --git a/backend/app/outbox/jobs.py b/backend/app/outbox/jobs.py index 6ac1655..b20fe5c 100644 --- a/backend/app/outbox/jobs.py +++ b/backend/app/outbox/jobs.py @@ -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() diff --git a/backend/app/outbox/service.py b/backend/app/outbox/service.py index 326bb65..6f2ea5d 100644 --- a/backend/app/outbox/service.py +++ b/backend/app/outbox/service.py @@ -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: diff --git a/backend/app/providers/base.py b/backend/app/providers/base.py index 883e4d4..1f7ffc4 100644 --- a/backend/app/providers/base.py +++ b/backend/app/providers/base.py @@ -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). diff --git a/backend/app/providers/imap_smtp/provider.py b/backend/app/providers/imap_smtp/provider.py index 0c937b3..ba1660b 100644 --- a/backend/app/providers/imap_smtp/provider.py +++ b/backend/app/providers/imap_smtp/provider.py @@ -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 [] diff --git a/backend/app/sync/actions.py b/backend/app/sync/actions.py index 0d327b1..de14d70 100644 --- a/backend/app/sync/actions.py +++ b/backend/app/sync/actions.py @@ -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). diff --git a/backend/app/sync/engine.py b/backend/app/sync/engine.py index 5c99cd3..d0e6135 100644 --- a/backend/app/sync/engine.py +++ b/backend/app/sync/engine.py @@ -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 diff --git a/backend/migrations/versions/0010_message_restore_mailbox.py b/backend/migrations/versions/0010_message_restore_mailbox.py new file mode 100644 index 0000000..7acc084 --- /dev/null +++ b/backend/migrations/versions/0010_message_restore_mailbox.py @@ -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") diff --git a/backend/tests/api/test_api.py b/backend/tests/api/test_api.py index 3092eeb..ed3a10f 100644 --- a/backend/tests/api/test_api.py +++ b/backend/tests/api/test_api.py @@ -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") diff --git a/backend/tests/mail/test_actions.py b/backend/tests/mail/test_actions.py index 7bda4fb..5ff00f0 100644 --- a/backend/tests/mail/test_actions.py +++ b/backend/tests/mail/test_actions.py @@ -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 diff --git a/backend/tests/outbox/test_outbox.py b/backend/tests/outbox/test_outbox.py index 0d6f153..4607f7b 100644 --- a/backend/tests/outbox/test_outbox.py +++ b/backend/tests/outbox/test_outbox.py @@ -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() diff --git a/backend/tests/sync/test_secondary_poll.py b/backend/tests/sync/test_secondary_poll.py index 5a389a4..1c2b28c 100644 --- a/backend/tests/sync/test_secondary_poll.py +++ b/backend/tests/sync/test_secondary_poll.py @@ -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() diff --git a/backend/workers/worker.py b/backend/workers/worker.py index c354964..1c4f5e5 100644 --- a/backend/workers/worker.py +++ b/backend/workers/worker.py @@ -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, diff --git a/frontend/icons/hide.svg b/frontend/icons/hide.svg new file mode 100644 index 0000000..895a5c0 --- /dev/null +++ b/frontend/icons/hide.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/icons/show.svg b/frontend/icons/show.svg new file mode 100644 index 0000000..e9bac9a --- /dev/null +++ b/frontend/icons/show.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/Icon.tsx b/frontend/src/components/Icon.tsx index 30df719..eddf2cc 100644 --- a/frontend/src/components/Icon.tsx +++ b/frontend/src/components/Icon.tsx @@ -8,12 +8,16 @@ import compose from "../../icons/compose.svg?raw"; import drafts from "../../icons/drafts-28-regular.svg?raw"; import expandAll from "../../icons/expand-all.svg?raw"; import forward from "../../icons/forward.svg?raw"; +import hide from "../../icons/hide.svg?raw"; import inbox from "../../icons/inbox.svg?raw"; +import logOut from "../../icons/log-out.svg?raw"; import pin from "../../icons/pin.svg?raw"; import print from "../../icons/print.svg?raw"; import replyAll from "../../icons/reply-all-line.svg?raw"; import reply from "../../icons/reply.svg?raw"; import sent from "../../icons/sent-02.svg?raw"; +import settings from "../../icons/settings.svg?raw"; +import show from "../../icons/show.svg?raw"; import spam from "../../icons/spam.svg?raw"; import trash from "../../icons/trash.svg?raw"; @@ -24,12 +28,16 @@ const RAW: Record = { drafts, "expand-all": expandAll, forward, + hide, inbox, + "log-out": logOut, pin, print, reply, "reply-all": replyAll, sent, + settings, + show, spam, trash, }; diff --git a/frontend/src/components/compose/Composer.tsx b/frontend/src/components/compose/Composer.tsx index f09512b..4eca598 100644 --- a/frontend/src/components/compose/Composer.tsx +++ b/frontend/src/components/compose/Composer.tsx @@ -137,16 +137,37 @@ export function Composer({ const [showToolbar, setShowToolbar] = useState(true); const [fromOpen, setFromOpen] = useState(false); const [moreOpen, setMoreOpen] = useState(false); + const moreRef = useRef(null); + const fromRef = useRef(null); const [attachments, setAttachments] = useState([]); const [attachError, setAttachError] = useState(""); const [status, setStatus] = useState<"" | "saving" | "saved" | "sending" | "error">(""); const [undo, setUndo] = useState<{ outboxId: string } | null>(null); + const [showClosePrompt, setShowClosePrompt] = useState(false); const draftId = useRef(init.draftId ?? null); const sentRef = useRef(false); // send initiated -> don't mirror to Drafts on close + // Whether the user actually edited anything. Merely opening a draft to view it + // must not re-save/re-mirror it (that spawned duplicate IMAP copies). + const dirtyRef = useRef(false); + const markDirty = () => { + dirtyRef.current = true; + }; const undoTimer = useRef(null); const fileInputRef = useRef(null); const editorRef = useRef(null); + // Close the From / More dropdowns on any click outside them. + useEffect(() => { + if (!moreOpen && !fromOpen) return; + const onDown = (e: MouseEvent) => { + const target = e.target as Node; + if (moreOpen && moreRef.current && !moreRef.current.contains(target)) setMoreOpen(false); + if (fromOpen && fromRef.current && !fromRef.current.contains(target)) setFromOpen(false); + }; + document.addEventListener("mousedown", onDown); + return () => document.removeEventListener("mousedown", onDown); + }, [moreOpen, fromOpen]); + const account = accounts.find((a) => a.id === accountId); const fromName = account?.display_name || ""; const fromEmail = account?.email ?? ""; @@ -204,6 +225,7 @@ export function Composer({ }, []); const addFiles = async (files: FileList) => { + markDirty(); setAttachError(""); for (const file of Array.from(files)) { if (file.size > MAX_ATTACHMENT_BYTES) { @@ -219,8 +241,10 @@ export function Composer({ } }; - const removeAttachment = (id: string) => + const removeAttachment = (id: string) => { + markDirty(); setAttachments((prev) => prev.filter((a) => a.id !== id)); + }; // The editor's seed content. New messages get the account signature (2 blank // lines above it); replies keep the quoted original in a marked block so the @@ -263,6 +287,7 @@ export function Composer({ useEffect(() => { if (undo) return; const handle = window.setTimeout(async () => { + if (!dirtyRef.current) return; // nothing the user changed -> don't touch the draft if (!to && !subject && !bodyHtml) return; setStatus("saving"); try { @@ -284,8 +309,9 @@ export function Composer({ () => () => { if (undoTimer.current) window.clearTimeout(undoTimer.current); // On close, mirror the draft to the IMAP Drafts folder so it can be - // continued on any mail client (skip if it was sent or discarded). - if (draftId.current && !sentRef.current) { + // continued on any mail client — but only if the user actually edited it + // (skip if it was sent, discarded, or merely opened to view). + if (draftId.current && !sentRef.current && dirtyRef.current) { void api.publishDraft(draftId.current).catch(() => {}); } }, @@ -333,6 +359,26 @@ export function Composer({ onClose(); }; + // X button: if there's a draft worth keeping, ask before closing; otherwise + // (nothing typed, no draft) just close. + const requestClose = () => { + if (dirtyRef.current || draftId.current) setShowClosePrompt(true); + else onClose(); + }; + + const saveDraftAndClose = async () => { + setShowClosePrompt(false); + try { + if (dirtyRef.current) { + if (draftId.current) await api.updateDraft(draftId.current, payload()); + else draftId.current = (await api.createDraft(payload())).id; + } + } catch { + /* the close-time IMAP mirror is best-effort too */ + } + onClose(); // the unmount effect mirrors the (dirty) draft to the IMAP Drafts folder + }; + const popOut = async () => { // Persist first so the new window can load this exact draft. try { @@ -367,12 +413,44 @@ export function Composer({ return (
+ {showClosePrompt && ( +
setShowClosePrompt(false)} + > +
e.stopPropagation()} + > +

{t("compose.closePrompt")}

+
+ + +
+
+
+ )} {/* Header: Send · Attach · formatting toggle · More · window controls */}
{open && (
- {item(t("mail.reply"), () => onReply("reply"))} - {item(t("mail.replyAll"), () => onReply("reply_all"))} - {item(t("mail.forward"), () => onReply("forward"))} - {item(t("mail.forwardAsAttachment"), () => onReply("forward"))} + {item(t("mail.reply"), () => onReply("reply"), replyDisabled)} + {item(t("mail.replyAll"), () => onReply("reply_all"), replyDisabled)} + {item(t("mail.forward"), () => onReply("forward"), replyDisabled)} + {item(t("mail.forwardAsAttachment"), () => onReply("forward"), replyDisabled)} {item(t("mail.print"), onPrint)}
{item(t("mail.delete"), onDelete)} @@ -199,6 +214,7 @@ function MessageItem({ boxClassName, zIndex, showReplyBar, + canReply, onToggle, onReply, onToggleRead, @@ -214,6 +230,7 @@ function MessageItem({ boxClassName: string; zIndex: number; showReplyBar: boolean; + canReply: boolean; onToggle: () => void; onReply: (kind: string) => void; onToggleRead: () => void; @@ -321,14 +338,21 @@ function MessageItem({ }`} /> - - + {canReply && ( + + )} +
{showDetails && ( @@ -356,7 +380,7 @@ function MessageItem({
- {showReplyBar && ( + {showReplyBar && canReply && (
{replyBtn("reply", t("mail.reply"), "reply")} {replyBtn("reply_all", t("mail.replyAll"), "reply-all")} @@ -440,6 +464,7 @@ export function ThreadView({ }; const openReply = (msg: MessageEnvelope, kind: string) => { + if (specialUse.get(msg.mailbox_id) === "trash") return; // no reply/forward from Trash setExpanded((prev) => new Set(prev).add(msg.id)); setComposing({ anchorId: msg.id, init: buildReplyInit(subject, msg, kind) }); }; @@ -471,9 +496,15 @@ export function ThreadView({ allExpanded ? new Set(newest ? [newest.id] : []) : new Set(messages.map((m) => m.id)), ); + const composerRef = useRef(null); + // Bring the inline composer into view when it opens (reply/reply-all/forward). + useEffect(() => { + if (composing) composerRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); + }, [composing]); + const composerFor = (id: string) => composing?.anchorId === id ? ( -
+
setComposing(null)} />
) : null; @@ -487,7 +518,7 @@ export function ThreadView({ className="rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg" title={t("mail.print")} aria-label={t("mail.print")} - onClick={() => window.print()} + onClick={() => void printMessages(subject, messages)} > @@ -507,17 +538,23 @@ export function ThreadView({ const isExpanded = expanded.has(message.id); const prev = i > 0 ? visual[i - 1] : undefined; const prevCollapsed = prev ? !expanded.has(prev.id) : false; - const prevIsNewest = Boolean(prev && newest && prev.id === newest.id); - // Overlap onto a preceding collapsed card, and also onto the always-open - // newest message — so the newest stacks with its neighbour in both sort - // orders (bottom card in oldest-first, top card in newest-first). z-index - // keeps the newer/"current" edge on top so text stays visible. - const overlap = prevCollapsed || prevIsNewest; + // Cards overlap by a negative top margin (tuck up under the card above), + // with the newer card kept on top by z-index. The card above is OLDER in + // oldest-first but NEWER in newest-first, so the rule differs by order: + // - oldest-first: an expanded card tucks up over the older card above it + // (its own top stays clear). Only overlap a collapsed card above. + // - newest-first: the card above is newer, so tucking an expanded card up + // would hide its header under that newer card. Only collapsed cards tuck + // up; an expanded card keeps its gap, so its top stays clear of the newer + // message and the older card BELOW tucks under it instead. + const overlap = order === "desc" ? !isExpanded : prevCollapsed; const margin = i === 0 ? "" : overlap ? "-mt-[20px]" : "mt-2"; const z = order === "asc" ? i + 1 : visual.length - i; const boxClassName = `${margin} rounded-lg border border-app-border bg-app-surface ${ isExpanded ? "" : "shadow-sm" }`; + // Reply/Reply-all/Forward make no sense on a trashed message. + const canReply = specialUse.get(message.mailbox_id) !== "trash"; return ( toggle(message.id)} onReply={(kind) => openReply(message, kind)} onToggleRead={() => toggleRead(message)} - onPrint={() => window.print()} + onPrint={() => void printMessages(subject, [message])} onDelete={() => deleteMessage(message)} innerRef={isNewest ? (el) => (newestRef.current = el) : undefined} /> diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index 265afc5..388e789 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -38,6 +38,8 @@ export const en = { "nav.mail": "Mail", "nav.settings": "Settings", "nav.compose": "Compose", + "nav.collapseSidebar": "Collapse sidebar", + "nav.expandSidebar": "Expand sidebar", "nav.allAccounts": "All accounts", "nav.inbox": "Inbox", "nav.archive": "Archive", @@ -103,6 +105,8 @@ export const en = { "compose.send": "Send", "compose.sending": "Sending…", "compose.discard": "Discard", + "compose.saveDraft": "Save draft", + "compose.closePrompt": "Save this message as a draft?", "compose.sent": "Message sent.", "compose.undo": "Undo", "compose.sendFailed": "Send failed.", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 6c10a44..fdb7c66 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -94,8 +94,8 @@ export const api = { if (cursor) params.set("cursor", cursor); return apiFetch(`/threads?${params.toString()}`); }, - thread: (id: string, sort: "asc" | "desc" = "asc") => - apiFetch(`/threads/${id}?sort=${sort}`), + thread: (id: string, sort: "asc" | "desc" = "asc", view = "unified") => + apiFetch(`/threads/${id}?sort=${sort}&view=${encodeURIComponent(view)}`), search: (q: string, cursor?: string) => { const params = new URLSearchParams({ q }); diff --git a/frontend/src/lib/print.ts b/frontend/src/lib/print.ts new file mode 100644 index 0000000..3c63a42 --- /dev/null +++ b/frontend/src/lib/print.ts @@ -0,0 +1,200 @@ +// Mail-style printing: assemble a clean, print-ready document (message headers + +// sanitized body, one section per message for a thread) in a new window and invoke +// the browser's print dialog. Reuses the same sanitized HTML the reader shows. + +import { api } from "./api"; +import type { Address, AttachmentOut, MessageEnvelope } from "./types"; + +function esc(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function fmtAddr(a: Address): string { + if (!a.email) return a.name || ""; + return a.name ? `${a.name} <${a.email}>` : a.email; +} + +function fmtList(addrs: Address[]): string { + return addrs.map(fmtAddr).filter(Boolean).join(", "); +} + +function fmtDate(iso: string): string { + const d = new Date(iso); + return d.toLocaleString(undefined, { + weekday: "short", + year: "numeric", + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +function fmtBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +// Fetch a message's sanitized HTML (a full document) and return just its . +async function bodyHtml(messageId: string): Promise { + try { + const resp = await fetch(api.messageHtmlUrl(messageId), { credentials: "include" }); + if (!resp.ok) throw new Error(String(resp.status)); + const doc = new DOMParser().parseFromString(await resp.text(), "text/html"); + return doc.body?.innerHTML ?? ""; + } catch { + return `

[message content unavailable]

`; + } +} + +async function attachmentsFor(msg: MessageEnvelope): Promise { + if (!msg.has_attachments) return []; + try { + return await api.messageAttachments(msg.id); + } catch { + return []; + } +} + +function metaRow(label: string, value: string): string { + return value + ? `${esc(label)}${esc(value)}` + : ""; +} + +function section(msg: MessageEnvelope, subject: string, body: string, atts: AttachmentOut[]): string { + const attachLine = atts.length + ? `Attachments${atts + .map((a) => `${esc(a.filename)} (${fmtBytes(a.size_bytes)})`) + .join(", ")}` + : ""; + return `
+
+
${esc(subject || "(no subject)")}
+ + ${metaRow("From", fmtList(msg.from_addr))} + ${metaRow("To", fmtList(msg.to_addr))} + ${metaRow("Cc", fmtList(msg.cc_addr))} + ${metaRow("Date", fmtDate(msg.date))} + ${attachLine} +
+
+
${body}
+
`; +} + +const MARGIN = "16mm"; + +const PRINT_CSS = ` + /* Let the sheet be the full page; the margin is content padding so it always + prints, regardless of the browser's "Margins" print-dialog setting. */ + @page { margin: 0; } + * { box-sizing: border-box; } + html, body { margin: 0; padding: 0; } + body { + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + color: #111; background: #e9e9ec; font-size: 12pt; line-height: 1.5; + -webkit-print-color-adjust: exact; print-color-adjust: exact; + } + /* On screen, present it like a padded sheet of paper. */ + @media screen { + body { + max-width: 210mm; margin: 24px auto; padding: ${MARGIN}; + background: #fff; box-shadow: 0 1px 8px rgba(0,0,0,.15); + } + } + /* In print the padding IS the page margin (survives a "None" margin setting). */ + @media print { + html, body { background: #fff; } + body { padding: ${MARGIN}; } + } + .wmc-print-msg { padding-bottom: 18px; } + .wmc-print-msg + .wmc-print-msg { border-top: 2px solid #ddd; padding-top: 18px; } + .wmc-print-head { break-inside: avoid; margin-bottom: 12px; } + .wmc-print-subject { font-size: 16pt; font-weight: 600; margin: 0 0 8px; } + .wmc-print-meta { width: 100%; border-collapse: collapse; font-size: 10.5pt; } + .wmc-print-meta th { + text-align: left; vertical-align: top; color: #666; font-weight: 500; + width: 72px; padding: 1px 8px 1px 0; white-space: nowrap; + } + .wmc-print-meta td { vertical-align: top; padding: 1px 0; color: #111; } + .wmc-print-body { color: #1a1a1a; word-break: break-word; } + .wmc-print-body img { max-width: 100%; height: auto; } + .wmc-print-body table { max-width: 100%; border-collapse: collapse; } + .wmc-print-body blockquote { + margin: 0 0 0 12px; padding-left: 12px; border-left: 3px solid #e0e0e0; color: #555; + } + .wmc-print-body a { color: #2563eb; } + .wmc-print-body pre { white-space: pre-wrap; word-wrap: break-word; } +`; + +// Wait for images to finish (so they're in the print), capped so a slow/broken +// image can never block printing indefinitely. +async function waitForImages(win: Window, timeoutMs = 3000): Promise { + const pending = Array.from(win.document.images).filter((img) => !img.complete); + if (pending.length === 0) return; + await Promise.race([ + Promise.all( + pending.map( + (img) => + new Promise((res) => { + img.addEventListener("load", () => res(), { once: true }); + img.addEventListener("error", () => res(), { once: true }); + }), + ), + ), + new Promise((res) => setTimeout(res, timeoutMs)), + ]); +} + +/** + * Open a print-ready window for one or more messages and invoke the print dialog. + * Pass a single message to print just that email, or all thread messages (ascending) + * to print the conversation. + */ +export async function printMessages(subject: string, messages: MessageEnvelope[]): Promise { + if (messages.length === 0) return; + + // Open the window synchronously (inside the click) so it isn't blocked as a popup. + const win = window.open("", "_blank", "width=820,height=1000"); + if (!win) { + window.alert("Please allow pop-ups for this site to print."); + return; + } + const title = esc(subject || "(no subject)"); + win.document.write( + `${title}` + + `

Preparing…

`, + ); + win.document.close(); + + const [bodies, atts] = await Promise.all([ + Promise.all(messages.map((m) => bodyHtml(m.id))), + Promise.all(messages.map((m) => attachmentsFor(m))), + ]); + if (win.closed) return; // the user dismissed the window while it was preparing + + const sections = messages + .map((m, i) => section(m, m.subject || subject, bodies[i], atts[i])) + .join(""); + // Write the whole document in one pass so the print stylesheet parses cleanly. + win.document.open(); + win.document.write( + `` + + `` + + `${title}` + + `${sections}`, + ); + win.document.close(); + + await waitForImages(win); + if (win.closed) return; + win.focus(); + win.onafterprint = () => win.close(); + win.print(); +} diff --git a/frontend/src/routes/MailLayout.tsx b/frontend/src/routes/MailLayout.tsx index f606dac..9ea3922 100644 --- a/frontend/src/routes/MailLayout.tsx +++ b/frontend/src/routes/MailLayout.tsx @@ -26,6 +26,9 @@ function quotaLabel(q: QuotaOut): string { // Threads shown per page in the mail list. const PAGE_SIZE = 50; +// Collapsed sidebar width: just wide enough for the icons. +const COLLAPSED_W = 60; + // Unified per-folder views. "Spam" maps to the IMAP special-use "junk". const FOLDERS: { view: string; icon: string; label: MessageKey }[] = [ { view: "folder:inbox", icon: "inbox", label: "nav.inbox" }, @@ -52,17 +55,23 @@ export function MailLayout() { const [refreshing, setRefreshing] = useState(false); const [composing, setComposing] = useState(null); const [composerExpanded, setComposerExpanded] = useState(false); + // Bumped each time a composer is opened so the remounts (fresh state) + // when switching between drafts instead of keeping the previous draft's editor. + const [composeKey, setComposeKey] = useState(0); const [replySignal, setReplySignal] = useState<{ kind: string; seq: number }>(); const searchRef = useRef(null); // Resizable panel widths (persisted). Drag the dividers to adjust. const [sidebarW, setSidebarW] = useState(() => Number(localStorage.getItem("panel.sidebar")) || 240); const [listW, setListW] = useState(() => Number(localStorage.getItem("panel.list")) || 384); + // Collapsed sidebar shows icons only (persisted). + const [collapsed, setCollapsed] = useState(() => localStorage.getItem("panel.collapsed") === "1"); const clampSidebar = (w: number) => Math.min(Math.max(w, 180), 420); // The list can be dragged up to 75% of the window width. const clampList = (w: number) => Math.min(Math.max(w, 260), window.innerWidth * 0.75); useEffect(() => localStorage.setItem("panel.sidebar", String(sidebarW)), [sidebarW]); useEffect(() => localStorage.setItem("panel.list", String(listW)), [listW]); + useEffect(() => localStorage.setItem("panel.collapsed", collapsed ? "1" : "0"), [collapsed]); useEffect(() => localStorage.setItem("mail.listOrder", listOrder), [listOrder]); const isDrafts = view === "folder:drafts"; @@ -117,8 +126,8 @@ export function MailLayout() { threadsRef.current = threads; const detailQuery = useQuery({ - queryKey: ["thread", threadId, threadOrder], - queryFn: () => api.thread(threadId!, threadOrder), + queryKey: ["thread", threadId, threadOrder, view], + queryFn: () => api.thread(threadId!, threadOrder, view), enabled: Boolean(threadId), }); @@ -132,6 +141,33 @@ export function MailLayout() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [detailQuery.data?.id]); + // Open the composer with fresh state, remounting it if one is already open + // (e.g. clicking a different draft replaces the current editor). + const openComposer = (init: ComposerInit, expand = false) => { + setComposing(init); + setComposerExpanded(expand); + setComposeKey((k) => k + 1); + }; + + const closeComposer = () => { + setComposing(null); + setComposerExpanded(false); + queryClient.invalidateQueries({ queryKey: ["drafts"] }); + queryClient.invalidateQueries({ queryKey: ["drafts-remote"] }); + }; + + // Leaving a folder closes any open composer/draft editor: the reading pane + // should reflect the folder you navigated to, not a leftover draft editor. + const firstView = useRef(true); + useEffect(() => { + if (firstView.current) { + firstView.current = false; + return; + } + closeComposer(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [view]); + const afterMove = () => { queryClient.invalidateQueries({ queryKey: ["threads"] }); queryClient.invalidateQueries({ queryKey: ["quota"] }); @@ -201,7 +237,7 @@ export function MailLayout() { useKeyboardShortcuts({ onSearch: () => searchRef.current?.focus(), - onCompose: () => accountsQuery.data?.[0] && setComposing({ accountId: accountsQuery.data[0].id, kind: "new" }), + onCompose: () => accountsQuery.data?.[0] && openComposer({ accountId: accountsQuery.data[0].id, kind: "new" }), onReply: () => setReplySignal({ kind: "reply", seq: Date.now() }), onReplyAll: () => setReplySignal({ kind: "reply_all", seq: Date.now() }), onForward: () => setReplySignal({ kind: "forward", seq: Date.now() }), @@ -221,22 +257,39 @@ export function MailLayout() {
{/* Sidebar */} - setSidebarW((w) => clampSidebar(w + dx))} /> + {!collapsed && setSidebarW((w) => clampSidebar(w + dx))} />} {/* Message list */}
{ - setComposing(draftToInit(draft)); - setComposerExpanded(true); - }} + onOpen={(draft) => openComposer(draftToInit(draft), true)} onOpenRemote={async (remote) => { // Adopt the server draft into an editable local draft, then open it. const draft = await api.adoptDraft(remote.message_id); queryClient.invalidateQueries({ queryKey: ["drafts"] }); queryClient.invalidateQueries({ queryKey: ["drafts-remote"] }); - setComposing(draftToInit(draft)); - setComposerExpanded(true); + openComposer(draftToInit(draft), true); }} /> ) : ( @@ -466,15 +533,11 @@ export function MailLayout() { } > setComposerExpanded((v) => !v)} - onClose={() => { - setComposing(null); - setComposerExpanded(false); - queryClient.invalidateQueries({ queryKey: ["drafts"] }); - queryClient.invalidateQueries({ queryKey: ["drafts-remote"] }); - }} + onClose={closeComposer} />
)} @@ -570,11 +633,26 @@ function SortIcon() { ); } -function SidebarItem({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) { +function SidebarItem({ + active, + onClick, + collapsed = false, + title, + children, +}: { + active: boolean; + onClick: () => void; + collapsed?: boolean; + title?: string; + children: React.ReactNode; +}) { return (