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:
2026-08-06 19:23:27 +02:00
co-authored by Claude Opus 4.8
parent 4c200ab94a
commit 12a67825cc
23 changed files with 530 additions and 49 deletions
+91
View File
@@ -0,0 +1,91 @@
"""Sender avatars — Gravatar then the sender domain's favicon (SPEC §9.1).
The browser only ever calls ``GET /api/avatar`` (same origin), so a sender can never
see the user's IP or read activity: the server does the outbound fetch under the
§10.6 SSRF guard and caches the result (both hits and misses) in Redis. A miss
returns 404, which the UI renders as its initials avatar. Disable with
``SENDER_AVATARS_ENABLED=false``.
"""
from __future__ import annotations
import hashlib
import httpx
from fastapi import APIRouter, Query, Response
from app.accounts.ssrf import SsrfError, guarded_request
from app.config import get_settings
from app.identity.deps import CurrentUser
from app.redis_client import get_redis
router = APIRouter(prefix="/avatar", tags=["mail"])
_CACHE_TTL_HIT = 7 * 24 * 3600 # remember a found avatar for a week
_CACHE_TTL_MISS = 24 * 3600 # re-check "no avatar" daily
_MAX_BYTES = 200 * 1024
_NONE = b"none"
def _email_md5(email: str) -> str:
# Gravatar keys on md5(lowercased email); reused as our cache key. Not security.
return hashlib.md5(email.encode()).hexdigest() # noqa: S324
async def _fetch_image(url: str) -> tuple[str, bytes] | None:
"""GET ``url`` under the SSRF guard; return (content_type, bytes) for a 200 image."""
try:
resp = await guarded_request("GET", url, timeout=5.0, max_bytes=_MAX_BYTES)
except (SsrfError, httpx.HTTPError, OSError):
return None
if resp.status != 200 or not resp.content:
return None
ctype = resp.headers.get("content-type", "").split(";")[0].strip().lower()
if not ctype.startswith("image/"):
return None
return ctype, resp.content
async def _resolve(email: str, md5: str) -> tuple[str, bytes] | None:
"""Gravatar first (``d=404`` so it 404s when the user has none), then the sender
domain's ``/favicon.ico`` as a brand-ish fallback."""
gravatar = await _fetch_image(f"https://www.gravatar.com/avatar/{md5}?d=404&s=80")
if gravatar is not None:
return gravatar
domain = email.rsplit("@", 1)[-1]
if domain:
favicon = await _fetch_image(f"https://{domain}/favicon.ico")
if favicon is not None:
return favicon
return None
@router.get("")
async def sender_avatar(_user: CurrentUser, email: str = Query(...)) -> Response:
email = email.strip().lower()
if not get_settings().sender_avatars_enabled or "@" not in email:
return Response(status_code=404)
redis = get_redis()
md5 = _email_md5(email)
key = f"avatar:{md5}"
headers = {"Cache-Control": "private, max-age=86400"}
cached: bytes | None = await redis.get(key)
if cached == _NONE:
return Response(status_code=404)
if cached:
cached_ctype, _, cached_body = cached.partition(b"\n")
return Response(
content=cached_body,
media_type=cached_ctype.decode("ascii", "ignore"),
headers=headers,
)
result = await _resolve(email, md5)
if result is None:
await redis.set(key, _NONE, ex=_CACHE_TTL_MISS)
return Response(status_code=404)
ctype, body = result
await redis.set(key, ctype.encode("ascii", "ignore") + b"\n" + body, ex=_CACHE_TTL_HIT)
return Response(content=body, media_type=ctype, headers=headers)