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
+4 -1
View File
@@ -94,6 +94,7 @@ async def guarded_get(url: str) -> str | None:
class GuardedResponse:
status: int
headers: dict[str, str]
content: bytes
text: str
@@ -131,8 +132,10 @@ async def guarded_request(
if total > max_bytes:
raise SsrfError("response exceeds size cap")
chunks.append(chunk)
body = b"".join(chunks)
return GuardedResponse(
status=resp.status_code,
headers=dict(resp.headers),
text=b"".join(chunks).decode("utf-8", "replace"),
content=body,
text=body.decode("utf-8", "replace"),
)
+2
View File
@@ -13,6 +13,7 @@ def register_routers(app: FastAPI) -> None:
"""
from app.api.accounts import router as accounts_router
from app.api.actions import router as actions_router
from app.api.avatar import router as avatar_router
from app.api.compose import router as compose_router
from app.api.mailboxes import router as mailboxes_router
from app.api.messages import router as messages_router
@@ -37,6 +38,7 @@ def register_routers(app: FastAPI) -> None:
settings_router,
mailboxes_router,
saved_searches_router,
avatar_router,
plugins_router,
):
app.include_router(router, prefix="/api")
+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)
+8 -6
View File
@@ -98,23 +98,25 @@ async def list_threads(
raise_api_error(400, "bad_ids", "invalid thread id in ids")
conditions.append(Thread.id.in_(wanted or [uuid.uuid4()]))
elif view.startswith("account:"):
# A single account's inbox: threads with a message in THIS account's
# inbox mailbox. Keeps the per-account view consistent with the unified
# Inbox (archived/trashed/sent threads don't leak in).
# A single account's mail: threads with a message in ANY of THIS account's
# mailboxes except Trash/Spam (those are only seen via their own folders).
try:
account_id = uuid.UUID(view.split(":", 1)[1])
except ValueError:
raise_api_error(400, "bad_view", "invalid account id in view")
in_account_inbox = (
in_account = (
select(Message.thread_id)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(
Message.user_id == user.id,
Message.account_id == account_id,
Mailbox.special_use == "inbox",
or_(
Mailbox.special_use.is_(None),
Mailbox.special_use.notin_(_REMOVAL_FOLDERS),
),
)
)
conditions.append(Thread.id.in_(in_account_inbox))
conditions.append(Thread.id.in_(in_account))
elif view.startswith("folder:"):
# Threads with at least one message in a mailbox of this special_use,
# across all of the user's accounts (unified per-folder view).
+5
View File
@@ -62,6 +62,11 @@ class Settings(BaseSettings):
# Gates the "Sign in with Google instead" OAuth fallback in the wizard (SPEC
# §6.2.1). Off until an operator configures a verified Cloud project.
google_oauth_enabled: bool = Field(default=False, alias="GOOGLE_OAUTH_ENABLED")
# Server-side sender avatars (Gravatar + domain favicon). Fetched by the server
# (never the browser) so senders can't see the user's IP or read activity.
sender_avatars_enabled: bool = Field(default=True, alias="SENDER_AVATARS_ENABLED")
# Auto-purge Trash/Spam messages older than this many days (0 disables).
trash_retention_days: int = Field(default=30, alias="TRASH_RETENTION_DAYS")
@lru_cache(maxsize=1)
+21 -1
View File
@@ -12,6 +12,7 @@ import contextlib
import logging
import uuid
from datetime import UTC, datetime
from email.utils import getaddresses
from typing import Any
from sqlalchemy import select
@@ -37,6 +38,17 @@ def _is_gmail(account: Account) -> bool:
return "gmail.com" in host or "googlemail.com" in host
def _addr_dicts(values: object) -> list[dict[str, str]]:
"""Parse a draft payload's ``["Name <email>", …]`` list into name+email dicts."""
if not isinstance(values, list):
return []
return [
{"name": name, "email": email}
for name, email in getaddresses([str(v) for v in values])
if email
]
async def _harvest_recipients(session: AsyncSession, user_id: uuid.UUID, emails: list[str]) -> None:
now = datetime.now(UTC)
for email in {e.lower() for e in emails if e}:
@@ -190,7 +202,15 @@ async def send_job(ctx: dict[str, Any], outbox_id: str) -> None:
bus = get_event_bus()
await bus.publish(
"message.sent",
{"user_id": str(user_id), "outbox_id": str(oid), "thread_id": str(draft.thread_id)},
{
"user_id": str(user_id),
"outbox_id": str(oid),
"thread_id": str(draft.thread_id),
# Recipients (name+email) so consumers (e.g. contacts autosave) don't
# need to read the outbox row. payload to/cc are "Name <email>" strings.
"to": _addr_dicts(draft.payload.get("to")),
"cc": _addr_dicts(draft.payload.get("cc")),
},
)
await bus.publish(
"outbox.state_changed",
+89 -1
View File
@@ -10,12 +10,15 @@ from __future__ import annotations
import logging
import uuid
from collections import defaultdict
from collections.abc import Iterable
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy import func, select, update
from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.db import session_scope
from app.events import get_event_bus
from app.models import Account, Draft, Mailbox, Message, Thread
@@ -438,3 +441,88 @@ async def empty_folder_job(ctx: dict[str, Any], user_id: str, special_use: str)
await denormalize_thread(session, tid)
await get_event_bus().publish("message.deleted", {"user_id": str(uid)})
async def autopurge_trash_spam(ctx: dict[str, Any]) -> None:
"""Cron: permanently delete Trash/Spam messages older than the retention window.
Same mechanics as :func:`empty_folder_job` (server EXPUNGE, drop rows, re-denorm
threads) but scoped to messages whose internal date predates
``TRASH_RETENTION_DAYS`` — across every user/account. A no-op when retention is 0.
"""
days = get_settings().trash_retention_days
if days <= 0:
return
cutoff = datetime.now(UTC) - timedelta(days=days)
async with session_scope() as session:
stale = (
await session.execute(
select(
Message.id,
Message.user_id,
Message.account_id,
Message.uid,
Message.thread_id,
Mailbox.path,
)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(Mailbox.special_use.in_(_REMOVAL_FOLDERS), Message.internal_date < cutoff)
)
).all()
if not stale:
return
account_ids = {r.account_id for r in stale}
accounts = {
a.id: a
for a in await session.scalars(select(Account).where(Account.id.in_(account_ids)))
}
# Server-side EXPUNGE, grouped by (account, mailbox path).
by_box: dict[tuple[uuid.UUID, str], list[int]] = defaultdict(list)
for r in stale:
by_box[(r.account_id, r.path)].append(r.uid)
for account_id in account_ids:
account = accounts.get(account_id)
if account is None:
continue
provider = build_provider(account)
try:
for (acct, path), uids in by_box.items():
if acct != account_id:
continue
try:
await provider.delete_messages(path, uids)
except Exception:
logger.exception("autopurge: expunge failed for %s in %s", uids, path)
finally:
await provider.close()
# Drop the local rows and re-denormalize (or delete) affected threads.
msg_ids = [r.id for r in stale]
thread_ids = {r.thread_id for r in stale if r.thread_id is not None}
user_ids = {r.user_id for r in stale}
async with session_scope() as session:
await session.execute(
update(Draft).where(Draft.reply_to_message_id.in_(msg_ids)).values(
reply_to_message_id=None
)
)
await session.execute(delete(Message).where(Message.id.in_(msg_ids)))
await session.flush()
for tid in thread_ids:
remaining = await session.scalar(
select(func.count()).select_from(Message).where(Message.thread_id == tid)
)
if not remaining:
await session.execute(
update(Draft).where(Draft.thread_id == tid).values(thread_id=None)
)
await denormalize_thread(session, tid)
bus = get_event_bus()
for user_id in user_ids:
await bus.publish("message.deleted", {"user_id": str(user_id)})
logger.info(
"autopurge: removed %d Trash/Spam message(s) older than %dd", len(msg_ids), days
)
+19 -8
View File
@@ -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:
+7 -1
View File
@@ -21,13 +21,19 @@ import logging
import signal
from typing import Any
from arq import cron
from arq.worker import Worker
from app.config import get_settings
from app.queue import QUEUE_BULK, QUEUE_INTERACTIVE, redis_settings
from app.sync.actions import autopurge_trash_spam
from app.sync.coordinator import SyncCoordinator
from workers.worker import BULK_FUNCTIONS, INTERACTIVE_FUNCTIONS
# Core scheduled jobs. Daily Trash/Spam retention purge at 03:17 (off-peak, and a
# minute that won't collide with typical top-of-hour crons).
_CORE_CRON_JOBS = [cron(autopurge_trash_spam, hour=3, minute=17)]
logger = logging.getLogger(__name__)
_SHUTDOWN_DRAIN_S = 10.0 # §7: release leases + drain within this budget
@@ -67,7 +73,7 @@ def build_arq_workers(role: str) -> list[Worker]:
QUEUE_INTERACTIVE,
s.interactive_concurrency,
120,
cron_jobs=plugin_cron_jobs(),
cron_jobs=[*_CORE_CRON_JOBS, *plugin_cron_jobs()],
),
_worker(BULK_FUNCTIONS, QUEUE_BULK, s.bulk_concurrency, 3600),
]