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
@@ -14,6 +14,7 @@ from __future__ import annotations
from pathlib import Path
from app.plugins.sdk import Page, PageNav, Plugin, UiManifest
from webmail_plugin_contacts.hook import on_message_sent
from webmail_plugin_contacts.router import router
_HERE = Path(__file__).resolve().parent
@@ -22,6 +23,7 @@ plugin = Plugin(
name="contacts",
version="0.1.0",
builtin=True,
hooks={"message.sent": on_message_sent},
router=router,
ui_manifest=UiManifest(
pages=[
@@ -0,0 +1,99 @@
"""message.sent hook: autosave reply recipients as contacts (SPEC App. B).
When the user sends a reply, its recipients (To/Cc) are people they've now
corresponded with, so any who aren't already contacts are added to the default
book's "Autosaved" group. Runs in the sync worker wherever message.sent fires;
uses the RLS-scoped SDK session, so it can only ever touch this user's rows.
"""
from __future__ import annotations
import json
import logging
import uuid
from typing import Any
from sqlalchemy import text
from app.plugins.sdk import sdk
from webmail_plugin_contacts.router import _ensure_book
logger = logging.getLogger("plg.contacts")
_SDK = sdk("contacts")
def _emails_value(email: str) -> str:
return json.dumps([{"value": email, "type": "", "pref": True}])
async def on_message_sent(payload: dict[str, Any]) -> None:
user_raw = payload.get("user_id")
if not isinstance(user_raw, str):
return
# De-dupe within this send; keep the first display name seen for each address.
recipients: dict[str, str] = {}
for field in ("to", "cc"):
for addr in payload.get(field) or []:
if not isinstance(addr, dict):
continue
email = str(addr.get("email") or "").strip().lower()
if "@" in email and email not in recipients:
recipients[email] = str(addr.get("name") or "").strip()
if not recipients:
return
user_id = uuid.UUID(user_raw)
async with _SDK.session(user_id) as session:
book_id = await _ensure_book(session, user_id) # also ensures the Autosaved group
group_row = (
await session.execute(
text(
"SELECT id FROM plg_contacts_groups "
"WHERE user_id = :u AND name = 'Autosaved' LIMIT 1"
),
{"u": str(user_id)},
)
).first()
autosaved_gid = str(group_row[0]) if group_row is not None else None
for email, name in recipients.items():
already = (
await session.execute(
text(
"SELECT 1 FROM plg_contacts_contacts "
"WHERE user_id = :u "
"AND (lower(default_email) = :e OR emails @> cast(:match as jsonb)) "
"LIMIT 1"
),
{"u": str(user_id), "e": email, "match": json.dumps([{"value": email}])},
)
).first()
if already is not None:
continue
contact_id = (
await session.execute(
text(
"INSERT INTO plg_contacts_contacts "
"(user_id, book_id, vcard_uid, name_display, emails, default_email) "
"VALUES (:u, :book, :uid, :name, :emails, :defemail) RETURNING id"
),
{
"u": str(user_id),
"book": book_id,
"uid": str(uuid.uuid4()),
"name": name or email,
"emails": _emails_value(email),
"defemail": email,
},
)
).scalar_one()
if autosaved_gid is not None:
await session.execute(
text(
"INSERT INTO plg_contacts_group_members (user_id, group_id, contact_id) "
"VALUES (:u, :g, :c) ON CONFLICT DO NOTHING"
),
{"u": str(user_id), "g": autosaved_gid, "c": str(contact_id)},
)
logger.info("contacts: autosaved %s from a sent message", email)
@@ -50,7 +50,12 @@ function initials(name) {
}
function avatarHtml(name, email, size) {
const s = size || 40;
return `<span class="ct-av" style="width:${s}px;height:${s}px;background:${avatarColor(email || name)};font-size:${Math.round(s * 0.38)}px">${esc(initials(name || email))}</span>`;
// Overlay the server-resolved gravatar/favicon (if any) on the initials circle;
// a miss (404) is removed by the error handler wired in wire(), leaving initials.
const img = email && email.includes("@")
? `<img class="ct-avimg" data-cav src="/api/avatar?email=${encodeURIComponent(email)}" alt="" aria-hidden="true" loading="lazy"/>`
: "";
return `<span class="ct-av" style="width:${s}px;height:${s}px;background:${avatarColor(email || name)};font-size:${Math.round(s * 0.38)}px">${esc(initials(name || email))}${img}</span>`;
}
function fmtBirthday(s) {
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(s));
@@ -81,7 +86,8 @@ const CSS = `
.ct-railbtn.active{background:rgb(var(--app-active));color:rgb(var(--app-fg))}
.ct-railnew{display:flex;align-items:center;justify-content:center;width:40px;height:40px;border-radius:11px;background:rgb(var(--app-accent));color:rgb(var(--app-accent-fg));cursor:pointer;border:0}
.ct-railnew:hover{opacity:.9}
.ct-av{display:inline-flex;align-items:center;justify-content:center;border-radius:50%;color:#fff;font-weight:600;flex:0 0 auto;overflow:hidden}
.ct-av{position:relative;display:inline-flex;align-items:center;justify-content:center;border-radius:50%;color:#fff;font-weight:600;flex:0 0 auto;overflow:hidden}
.ct-avimg{position:absolute;inset:0;width:100%;height:100%;border-radius:50%;object-fit:cover;background:#fff}
.ct-listcol{flex:0 0 auto;min-width:0;display:flex;flex-direction:column;border-right:1px solid rgb(var(--app-border))}
.ct-detailcol{flex:1;min-width:0;display:flex;flex-direction:column}
.ct-resizer{flex:0 0 auto;width:5px;margin:0 -2px;cursor:col-resize;position:relative;z-index:10}
@@ -700,6 +706,11 @@ export default function mount(el, ctx) {
function wire() {
const on = (sel, ev, fn) => el.querySelectorAll(sel).forEach((n) => n.addEventListener(ev, fn));
// Drop avatar images that fail to resolve (no gravatar/favicon) so the initials show.
el.querySelectorAll("img[data-cav]").forEach((img) => {
img.addEventListener("error", () => img.remove());
if (img.complete && img.naturalWidth === 0) img.remove();
});
const act = (name, fn) => on(`[data-act="${name}"]`, "click", fn);
const setFilter = (f) => { state.filter = f; state.group = ""; state.menuOpen = false; state.checked.clear(); state.page = 0; reloadList(false); };