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
+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
)