Sender avatars, PDF password preview, contacts autosave, account-view + Trash/Spam auto-purge, UI polish
Sender avatars (privacy-safe, server-fetched, cached): - New GET /api/avatar resolves Gravatar (d=404) then the sender domain's favicon under the SSRF guard, Redis-cached (hit+miss); gated by SENDER_AVATARS_ENABLED. GuardedResponse gains raw bytes. Shown in the mail list, reading pane, People panel, and Contacts (overlay that falls back to initials on a miss). PDF preview: - Password-protected PDFs now prompt in-place and retry (pdf.js PasswordException), with a clear error for other load failures instead of a blank overlay. The password is remembered per attachment until the thread is left; clicking the field no longer dismisses the overlay. Contacts: - Autosave reply recipients: message.sent now carries to/cc (name+email); the contacts plugin subscribes and adds any non-contact recipients to the Autosaved group. Contact avatars show the gravatar/favicon via /api/avatar. Mail views / retention: - Clicking an account shows all its mail except Trash/Spam (was inbox-only). - Daily cron autopurge_trash_spam permanently deletes Trash/Spam older than TRASH_RETENTION_DAYS (default 30; 0 disables) — server EXPUNGE + local rows. UI polish: - Ultimo signet logo in the Mail/Calendar/Contacts sidebar header and collapsed rail; consistent 11px top margin and vertically-aligned header row across states (fixed-size flex icon buttons); SVG favicon on the browser tab. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
@@ -170,30 +170,41 @@ async def test_folder_view(api: ApiCtx) -> None:
|
||||
assert sent.json()["threads"] == []
|
||||
|
||||
|
||||
async def test_account_view_shows_only_that_accounts_inbox(api: ApiCtx) -> None:
|
||||
async def test_account_view_shows_all_but_trash_spam(api: ApiCtx) -> None:
|
||||
assert api.session is not None
|
||||
# The account view starts equal to the inbox (all seeded mail is in inbox).
|
||||
# The account view shows the account's mail (both seeded threads are in inbox).
|
||||
resp = await api.client.get("/api/threads", params={"view": f"account:{api.account_id}"})
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()["threads"]) == 2
|
||||
|
||||
# Move the whole "Lunch" thread out of the inbox into Archive.
|
||||
lunch_id = next(t["id"] for t in resp.json()["threads"] if t["subject"] == "Lunch")
|
||||
msgs = list(
|
||||
await api.session.scalars(select(Message).where(Message.thread_id == uuid.UUID(lunch_id)))
|
||||
)
|
||||
|
||||
# Moving "Lunch" to Archive drops it from the inbox but KEEPS it in the account
|
||||
# view — only Trash/Spam are excluded there.
|
||||
for m in msgs:
|
||||
m.mailbox_id = api.archive_id
|
||||
await api.session.commit()
|
||||
|
||||
# It must drop out of both the unified inbox and the account view...
|
||||
inbox = await api.client.get("/api/threads", params={"view": "folder:inbox"})
|
||||
assert [t["subject"] for t in inbox.json()["threads"]] == ["Project kickoff"]
|
||||
account = await api.client.get("/api/threads", params={"view": f"account:{api.account_id}"})
|
||||
assert sorted(t["subject"] for t in account.json()["threads"]) == ["Lunch", "Project kickoff"]
|
||||
|
||||
# Moving it to Trash removes it from the account view (still reachable via Trash).
|
||||
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()
|
||||
for m in msgs:
|
||||
m.mailbox_id = trash.id
|
||||
await api.session.commit()
|
||||
account = await api.client.get("/api/threads", params={"view": f"account:{api.account_id}"})
|
||||
assert [t["subject"] for t in account.json()["threads"]] == ["Project kickoff"]
|
||||
# ...but still be reachable under the Archive folder.
|
||||
archive = await api.client.get("/api/threads", params={"view": "folder:archive"})
|
||||
assert [t["subject"] for t in archive.json()["threads"]] == ["Lunch"]
|
||||
trash_view = await api.client.get("/api/threads", params={"view": "folder:trash"})
|
||||
assert [t["subject"] for t in trash_view.json()["threads"]] == ["Lunch"]
|
||||
|
||||
|
||||
async def test_attachments_list_and_zip(api: ApiCtx) -> None:
|
||||
|
||||
Reference in New Issue
Block a user