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),
]
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/ultimo-logo-signet.svg" />
<title>Webmail</title>
</head>
<body>
+21
View File
@@ -0,0 +1,21 @@
import { useState } from "react";
// Overlay for a server-fetched sender avatar (Gravatar / domain favicon). Drop it
// inside an existing initials circle: on success the image covers the initials; on a
// miss (404/error) it renders nothing so the initials show through. The parent must
// be `position: relative` and round. Fetched via /api/avatar (same origin), so the
// sender never sees the user's IP or read activity.
export function AvatarImage({ email }: { email?: string | null }) {
const [failedFor, setFailedFor] = useState<string | null>(null);
if (!email || !email.includes("@") || failedFor === email) return null;
return (
<img
src={`/api/avatar?email=${encodeURIComponent(email)}`}
alt=""
aria-hidden
loading="lazy"
onError={() => setFailedFor(email)}
className="absolute inset-0 h-full w-full rounded-full bg-white object-cover"
/>
);
}
+1 -1
View File
@@ -7,7 +7,7 @@ export function ThemeToggle() {
const { resolved, setTheme } = useTheme();
return (
<button
className="rounded p-2 text-app-muted hover:bg-app-hover hover:text-app-fg"
className="inline-flex h-7 w-7 items-center justify-center rounded text-app-muted hover:bg-app-hover hover:text-app-fg"
title={t("theme.toggle")}
aria-label={t("theme.toggle")}
onClick={() => setTheme(resolved === "dark" ? "light" : "dark")}
+3 -1
View File
@@ -7,6 +7,7 @@ import { api } from "../../lib/api";
import { formatClock, getDateLocale } from "../../lib/timeFormat";
import type { Address, ThreadSummary } from "../../lib/types";
import { Icon } from "../Icon";
import { AvatarImage } from "../SenderAvatar";
import { LabelChips } from "../mail/LabelChips";
function timeLabel(iso: string): string {
@@ -165,10 +166,11 @@ export function MessageList({
)}
<div className={checkMode ? "hidden" : "contents group-hover/av:hidden"}>
<span
className="flex h-[33px] w-[33px] items-center justify-center rounded-full text-[13px] font-semibold text-white"
className="relative flex h-[33px] w-[33px] items-center justify-center overflow-hidden rounded-full text-[13px] font-semibold text-white"
style={{ backgroundColor: avatarBg }}
>
{initials(primary)}
<AvatarImage email={primary?.email} />
</span>
{extra > 0 && (
<span className="absolute -bottom-1 -left-1.5 rounded-full bg-app-hover px-1 text-[10px] font-medium text-app-muted ring-2 ring-app-surface">
+3 -1
View File
@@ -11,6 +11,7 @@ import { useNavigate } from "react-router-dom";
import { useT } from "../../i18n";
import { api, apiFetch } from "../../lib/api";
import type { Address, AttachmentOut, MessageEnvelope, ThreadDetail } from "../../lib/types";
import { AvatarImage } from "../SenderAvatar";
import { loadBundle } from "../../plugins/bundleLoader";
import { usePluginContext } from "../../plugins/context";
import { isPreviewable } from "../thread/previewKind";
@@ -39,10 +40,11 @@ function initials(name: string) {
function Avatar({ name, email, size = 36 }: { name: string; email: string; size?: number }) {
return (
<span
className="inline-flex shrink-0 items-center justify-center rounded-full font-semibold text-white"
className="relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-full font-semibold text-white"
style={{ width: size, height: size, background: avatarColor(email || name), fontSize: Math.round(size * 0.38) }}
>
{initials(name || email)}
<AvatarImage email={email} />
</span>
);
}
@@ -4,7 +4,7 @@
import * as pdfjs from "pdfjs-dist";
import PdfWorker from "pdfjs-dist/build/pdf.worker.min.mjs?worker";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useT } from "../../i18n";
@@ -16,6 +16,14 @@ import { extOf, kindOf } from "./previewKind";
pdfjs.GlobalWorkerOptions.workerPort = new PdfWorker();
// Remembers a protected PDF's password (in memory) per attachment id, so re-opening
// the same file doesn't re-prompt. Cleared when leaving the thread (ThreadView calls
// clearPdfPasswordCache on thread change) — never persisted.
const pdfPasswordCache = new Map<string, string>();
export function clearPdfPasswordCache(): void {
pdfPasswordCache.clear();
}
export function AttachmentPreview({
messageId,
attachments,
@@ -166,7 +174,8 @@ export function AttachmentPreview({
<div
className="relative min-h-0 flex-1"
onClick={(e) => {
if (!(e.target as Element).closest("img, iframe, canvas, pre, button, a")) onClose();
if (!(e.target as Element).closest("img, iframe, canvas, pre, button, a, form, input"))
onClose();
}}
>
{kind === "image" && <ImageView url={url} zoom={zoom} alt={att.filename} />}
@@ -174,7 +183,12 @@ export function AttachmentPreview({
<TextView url={url} zoom={zoom} onLoaded={(tx) => (textRef.current = tx)} />
)}
{kind === "pdf" && (
<PdfView url={url} zoom={zoom} onPage={(current, total) => setPage({ current, total })} />
<PdfView
url={url}
cacheKey={att.id}
zoom={zoom}
onPage={(current, total) => setPage({ current, total })}
/>
)}
{kind === "office" && (
<OfficeView
@@ -449,28 +463,55 @@ function OfficeView({
function PdfView({
url,
cacheKey,
zoom,
onPage,
}: {
url: string;
cacheKey: string;
zoom: number;
onPage: (current: number, total: number) => void;
}) {
const t = useT();
const scrollRef = useRef<HTMLDivElement>(null);
const docRef = useRef<pdfjs.PDFDocumentProxy | null>(null);
const [numPages, setNumPages] = useState(0);
// Password-protected PDFs: pdf.js rejects with a PasswordException until the right
// password is supplied. We prompt in-place and retry; the file never leaves the
// browser. `password` undefined = "not tried yet"; changing it re-runs the load.
// Seed from the per-attachment cache so re-opening within a thread skips the prompt.
const [password, setPassword] = useState<string | undefined>(() => pdfPasswordCache.get(cacheKey));
const [needPassword, setNeedPassword] = useState(false);
const [wrongPassword, setWrongPassword] = useState(false);
const [failed, setFailed] = useState(false);
const [pwInput, setPwInput] = useState("");
// Load the document once.
// Load the document; re-runs when the file (url) or a submitted password changes.
useEffect(() => {
let cancelled = false;
(async () => {
const buf = await (await fetch(url, { credentials: "include" })).arrayBuffer();
if (cancelled) return;
const doc = await pdfjs.getDocument({ data: buf }).promise;
if (cancelled) return;
docRef.current = doc;
setNumPages(doc.numPages);
onPage(1, doc.numPages);
setFailed(false);
try {
const buf = await (await fetch(url, { credentials: "include" })).arrayBuffer();
if (cancelled) return;
const doc = await pdfjs.getDocument({ data: buf, password }).promise;
if (cancelled) return;
docRef.current = doc;
setNeedPassword(false);
setWrongPassword(false);
if (password !== undefined) pdfPasswordCache.set(cacheKey, password);
setNumPages(doc.numPages);
onPage(1, doc.numPages);
} catch (e) {
if (cancelled) return;
const ex = e as { name?: string; code?: number };
if (ex?.name === "PasswordException") {
setNeedPassword(true);
setWrongPassword(ex.code === 2); // PasswordResponses.INCORRECT_PASSWORD
} else {
setFailed(true);
}
}
})();
return () => {
cancelled = true;
@@ -478,7 +519,7 @@ function PdfView({
docRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url]);
}, [url, password]);
// (Re)render all pages to canvases when the doc or zoom changes.
useEffect(() => {
@@ -524,5 +565,65 @@ function PdfView({
onPage(current, docRef.current.numPages);
};
const submitPassword = (e: FormEvent) => {
e.preventDefault();
setPassword(pwInput);
};
if (failed) {
return (
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-white/70">
{t("preview.pdfError")}
</div>
);
}
if (needPassword) {
return (
<div className="flex h-full items-center justify-center px-6">
<form
onSubmit={submitPassword}
className="w-full max-w-xs rounded-xl border border-app-border bg-app-elevated p-5 text-center shadow-2xl"
>
<div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-full bg-app-hover text-app-muted">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<rect x="4" y="11" width="16" height="10" rx="2" />
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
</svg>
</div>
<p className="text-sm font-medium text-app-fg">{t("preview.pdfProtected")}</p>
<input
type="password"
autoFocus
value={pwInput}
onChange={(ev) => setPwInput(ev.target.value)}
placeholder={t("preview.password")}
className="mt-3 w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-sm text-app-fg outline-none focus:border-app-accent"
/>
{wrongPassword && (
<p className="mt-2 text-xs text-red-400">{t("preview.pdfWrongPassword")}</p>
)}
<button
type="submit"
disabled={!pwInput}
className="mt-3 w-full rounded-lg bg-app-accent py-2 text-sm font-medium text-app-accent-fg disabled:opacity-50"
>
{t("preview.unlock")}
</button>
</form>
</div>
);
}
return <div ref={scrollRef} onScroll={onScroll} className="h-full overflow-auto py-4" />;
}
@@ -9,6 +9,8 @@ import type { AccountOut, MailboxOut, MessageEnvelope } from "../../lib/types";
import { PluginSlot } from "../../plugins/PluginSlot";
import { Composer, type ComposerInit } from "../compose/Composer";
import { Icon } from "../Icon";
import { AvatarImage } from "../SenderAvatar";
import { clearPdfPasswordCache } from "./AttachmentPreview";
import { LabelChips } from "../mail/LabelChips";
import { MessageBody } from "./MessageBody";
@@ -345,10 +347,11 @@ function MessageItem({
const nameColor = accentColor ?? `hsl(${h} 55% 62%)`;
const avatar = () => (
<span
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-semibold text-white"
className="relative flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-full text-sm font-semibold text-white"
style={{ backgroundColor: avatarBg }}
>
{initials(message)}
<AvatarImage email={message.from_addr[0]?.email} />
</span>
);
const senderName = (
@@ -534,6 +537,8 @@ export function ThreadView({
// --- labels: per-message storage + a thread union for display (chips + list) ---
const messageIds = useMemo(() => messages.map((m) => m.id), [messages]);
const threadId = messages[0]?.thread_id ?? null;
// Forget remembered protected-PDF passwords when the open thread changes or closes.
useEffect(() => () => clearPdfPasswordCache(), [threadId]);
const allLabelsQuery = useQuery({ queryKey: ["labels-all"], queryFn: api.labelsAll });
const msgLabelsQuery = useQuery({
queryKey: ["message-labels", messageIds],
+5
View File
@@ -69,6 +69,11 @@ export const en = {
"mail.previewResetZoom": "Reset zoom",
"mail.previewPrev": "Previous",
"mail.previewNext": "Next",
"preview.pdfProtected": "This PDF is password-protected",
"preview.password": "Password",
"preview.pdfWrongPassword": "Incorrect password — try again",
"preview.unlock": "Unlock",
"preview.pdfError": "Couldn't open this PDF.",
"mail.orderOldest": "Oldest first",
"mail.orderNewest": "Newest first",
"filter.all": "All mail",
+14 -10
View File
@@ -145,28 +145,30 @@
line-height: 1.5;
}
.wm-side-head {
@apply flex items-center justify-between pt-3 px-3 pb-1;
@apply flex items-center justify-between pt-[11px] px-3 pb-1;
}
.wm-side-switch {
@apply relative flex items-center gap-2;
}
/* Ultimo signet in front of the app-switcher menu (Calendar / Contacts headers). */
/* Ultimo signet in front of the app-switcher menu (Calendar / Contacts headers).
`auto 28px` renders it at a true 28px tall (matching Mail's <img h-7>) rather
than letting `contain` shrink it to fit the box's smaller dimension. */
.wm-side-switch::before {
content: "";
flex: 0 0 auto;
width: 28px;
width: 30px;
height: 28px;
background: url("/ultimo-logo-signet.svg") center / contain no-repeat;
background: url("/ultimo-logo-signet.svg") center / auto 28px no-repeat;
}
/* And at the top of the collapsed rail, above the expand/theme buttons. */
.cal-rail::before,
.ct-rail::before {
content: "";
flex: 0 0 auto;
width: 30px;
height: 30px;
margin: 10px auto 6px;
background: url("/ultimo-logo-signet.svg") center / contain no-repeat;
width: 32px;
height: 28px;
margin: 11px auto 6px;
background: url("/ultimo-logo-signet.svg") center / auto 28px no-repeat;
}
.wm-side-switch > button {
@apply flex items-center gap-1.5 rounded px-1.5 py-1 text-[15px] font-semibold text-app-fg hover:bg-app-hover;
@@ -193,10 +195,12 @@
cursor: pointer;
}
.wm-side-themebtn {
@apply p-2;
/* Fixed 28px box (matching the logo/other icons) so the header row height stays
28px and everything centers on one line — no drift vs the collapsed rail. */
@apply h-7 w-7 items-center justify-center p-0;
}
.wm-side-primary {
@apply px-3 pb-2;
@apply px-3 pb-2 pt-2.5;
}
.wm-side-primary > button {
@apply flex w-full items-center justify-center gap-2 rounded-lg bg-app-accent py-2 text-[15px] font-medium text-app-accent-fg shadow-sm hover:opacity-90;
+3 -3
View File
@@ -509,7 +509,7 @@ export function MailLayout() {
className="flex shrink-0 flex-col border-r border-app-border bg-app-bg"
>
<div
className={`flex pb-1 pt-3 ${
className={`flex pb-1 pt-[11px] ${
collapsed ? "flex-col items-center gap-1 px-2" : "items-center justify-between px-3"
}`}
>
@@ -526,14 +526,14 @@ export function MailLayout() {
onClick={() => setCollapsed((v) => !v)}
title={collapsed ? t("nav.expandSidebar") : t("nav.collapseSidebar")}
aria-label={collapsed ? t("nav.expandSidebar") : t("nav.collapseSidebar")}
className="rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
className="inline-flex h-7 w-7 items-center justify-center rounded text-app-muted hover:bg-app-hover hover:text-app-fg"
>
<Icon name={collapsed ? "show" : "hide"} size={16} />
</button>
<ThemeToggle />
</div>
</div>
<div className={`pb-2 ${collapsed ? "px-2" : "px-3"}`}>
<div className={`pb-2 pt-2.5 ${collapsed ? "px-2" : "px-3"}`}>
<button
className="flex w-full items-center justify-center gap-2 rounded-lg bg-app-accent py-2 text-[15px] font-medium text-app-accent-fg shadow-sm hover:opacity-90"
title={collapsed ? t("nav.compose") : undefined}
@@ -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); };
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB