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
+36
View File
@@ -234,6 +234,42 @@ async def test_attachments_list_and_zip(api: ApiCtx) -> None:
assert archive.read("notes.txt") == b"hello attachment"
async def test_attachment_download_non_ascii_filename(api: ApiCtx) -> None:
# A Polish filename (ł, ń) is not Latin-1, which would crash the response with
# a 500 if put raw in Content-Disposition. It must serve 200 with RFC 6266.
from app.indexer import blobstore
from app.models import Attachment
assert api.session is not None
mid = api.message_ids[-1]
payload = b"PK\x03\x04 zip-ish bytes"
sha = blobstore.store(api.user_id, payload)
name = "oferta - składowe ceny.ods"
api.session.add(
Attachment(
message_id=mid,
user_id=api.user_id,
filename=name,
mime_type="application/vnd.oasis.opendocument.spreadsheet",
size_bytes=len(payload),
content_id=None,
blob_sha256=sha,
)
)
await api.session.commit()
aid = next(
a["id"] for a in (await api.client.get(f"/api/messages/{mid}/attachments")).json()
if a["filename"] == name
)
resp = await api.client.get(f"/api/messages/{mid}/attachments/{aid}")
assert resp.status_code == 200 # not 500
assert resp.content == payload
cd = resp.headers["content-disposition"]
assert "filename*=UTF-8''" in cd # UTF-8 encoded name for modern clients
assert "sk%C5%82adowe" in cd # ł percent-encoded
async def test_quota_reports_usage_and_default(api: ApiCtx) -> None:
resp = await api.client.get("/api/settings/quota")
assert resp.status_code == 200
+23 -2
View File
@@ -86,6 +86,8 @@ async def _ingest(
body: str,
flags: frozenset[str] = frozenset(),
attach: bool = False,
mailbox_id: uuid.UUID | None = None,
special: str = "inbox",
) -> None:
ctx.uid += 1
m = EmailMessage()
@@ -101,8 +103,8 @@ async def _ingest(
ctx.session,
user_id=ctx.user_id,
account_id=ctx.account_id,
mailbox_id=ctx.mailbox_id,
mailbox_special_use="inbox",
mailbox_id=mailbox_id or ctx.mailbox_id,
mailbox_special_use=special,
remote=RemoteMessage(
uid=ctx.uid, raw=m.as_bytes(), flags=flags, internal_date=datetime.now(UTC)
),
@@ -145,6 +147,25 @@ async def seeded(ctx: Ctx) -> Ctx:
return ctx
async def test_search_excludes_trash_by_default(ctx: Ctx) -> None:
# Deleting a message from search results (it moves to Trash) removes it from the
# results — search excludes Trash/Junk unless in:trash/in:junk asks for them.
from app.models import Mailbox
await _ingest(ctx, frm="Bob <bob@corp.com>", subject="quarterly zephyr", body="hi")
trash = Mailbox(
account_id=ctx.account_id, user_id=ctx.user_id, path="Trash", special_use="trash"
)
ctx.session.add(trash)
await ctx.session.flush()
await _ingest(
ctx, frm="X <x@corp.com>", subject="zephyr in trash", body="bye",
mailbox_id=trash.id, special="trash",
)
assert await _search(ctx, "zephyr") == 1 # inbox only; the trashed one is hidden
assert await _search(ctx, "in:trash zephyr") == 1 # explicit -> searches Trash
async def test_free_text(seeded: Ctx) -> None:
assert await _search(seeded, "revenue") == 1
assert await _search(seeded, "lunch") == 1