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