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