attachments preview overlay, docx/xlsx/odf rendering, composer drag-drop & undo-send UX

Frontend:
- Full-screen attachment preview overlay (AttachmentPreview) for image/pdf/txt
  plus office docs: .docx via docx-preview (rendered live into the sandboxed
  iframe so header/footer VML images survive), .xlsx via xlsx-preview, and
  .odt/.ods/.odp/.odg via a JSZip+DOMParser ODF walker (officeConvert).
- Type-aware attachment icons (green spreadsheet, blue document, red PDF,
  image/archive); new document/image/spreadsheet/print SVGs.
- Composer: drag-drop attachments (body drop inserts images inline, else
  attaches); inserted images are selectable/resizable/deletable (RichTextEditor).
- Undo-send: "Sending…" banner with live "N seconds to Undo" countdown.
- Search result list: pin/archive/delete update correctly; grey-out only for
  search hits; scoped "Search in Trash/Spam".

Backend:
- Search: prefix (search-as-you-type) matching, email-address search, and
  address-first result ordering; Trash/Junk excluded with scoped folder search.
- Attachments: RFC 6266 Content-Disposition for non-ASCII filenames; one-time
  inline image loading.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rcj3TpydMTzTSmU7kunzf8
This commit is contained in:
2026-07-26 16:15:49 +02:00
co-authored by Claude Opus 4.8
parent c023cfa15e
commit dd0b089049
22 changed files with 2726 additions and 82 deletions
+11 -1
View File
@@ -7,6 +7,7 @@ import io
import uuid
import zipfile
from typing import Annotated
from urllib.parse import quote
from fastapi import APIRouter, Depends, Query
from fastapi.responses import HTMLResponse, Response
@@ -146,6 +147,15 @@ async def message_cid(
return Response(content=data, media_type=media_type)
def _content_disposition(disposition: str, filename: str) -> str:
"""RFC 6266 Content-Disposition safe for non-ASCII names. HTTP header values
are Latin-1, so a raw Polish/CJK filename (ł, ń, …) crashes the response with
a 500; emit an ASCII fallback plus a UTF-8 ``filename*`` for modern clients."""
ascii_name = filename.encode("ascii", "ignore").decode("ascii").replace('"', "").strip()
fallback = ascii_name or "attachment"
return f"{disposition}; filename=\"{fallback}\"; filename*=UTF-8''{quote(filename)}"
async def _real_attachments(db: AsyncSession, message_id: uuid.UUID) -> list[Attachment]:
"""Downloadable (non-inline) attachments for a message, in a stable order."""
rows = await db.scalars(
@@ -234,5 +244,5 @@ async def message_attachment(
return Response(
content=data,
media_type=media_type,
headers={"Content-Disposition": f'{disposition}; filename="{att.filename}"'},
headers={"Content-Disposition": _content_disposition(disposition, att.filename)},
)
+28
View File
@@ -257,6 +257,17 @@ class PgSearchBackend(SearchBackend):
for cond in conditions:
stmt = stmt.where(cond)
# Exclude Trash/Junk from search by default (Gmail-style), so deleting a
# message from search results removes it. An explicit `in:trash`/`in:junk`
# overrides this to search there on purpose.
if not _targets_removal_folder(groups):
stmt = stmt.where(
or_(
Mailbox.special_use.is_(None),
Mailbox.special_use.notin_(_REMOVAL_FOLDERS),
)
)
decoded = _decode_cursor(cursor)
if decoded is not None:
cur_hit, cur_date, cur_id = decoded
@@ -291,6 +302,23 @@ class PgSearchBackend(SearchBackend):
return hits, next_cursor
_REMOVAL_FOLDERS = ("trash", "junk")
def _targets_removal_folder(groups: list[list[dict[str, object]]]) -> bool:
"""True if the query explicitly scopes to Trash/Junk via an ``in:`` filter, in
which case the default removal-folder exclusion must not apply."""
for group in groups:
for clause in group:
if (
clause.get("type") == "filter"
and clause.get("key") == "in"
and str(clause.get("value", "")).strip().lower() in _REMOVAL_FOLDERS
):
return True
return False
def _positive_text_terms(groups: list[list[dict[str, object]]]) -> list[str]:
terms: list[str] = []
for group in groups: