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:
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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")}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user