feat(webmail,auth): leitor estilo Outlook, responder a todos, importantes e isolamento do admin de dominio

Webmail
- Leitor de mensagem em componente proprio, com barra de comandos fixa,
  cabecalho compacto, faixa de imagens bloqueadas e chips de anexo.
- Divisor arrastavel entre lista e leitor (mouse, setas, duplo clique),
  largura persistida e limites que preservam os dois paineis.
- Destinatarios em chips (Para, Cc, Cco): aceita nomes com virgula,
  colagem de listas, validacao e bloqueio de envio com endereco invalido.
- Responder a todos, sem o proprio endereco e sem repetidos; In-Reply-To
  e References nas respostas; prefixo Re:/Fwd: sem repeticao.
- Marcar como importante (estrela, filtro, linha da lista) e enviar com
  alta importancia (Importance/X-Priority), com etiqueta no leitor.
- Sanitizador: a regexp de URI customizada descartava todo atributo que nao
  fosse link (width, colspan, bgcolor...), o que desmontava tabelas de
  e-mail; imagens de fundo agora sao bloqueadas de forma reversivel.

Protocols
- POST /api/v1/mail/flagged (\Flagged) com bump de modseq para IMAP.
- Renderizacao expoe message_id, in_reply_to e important.
- Cabecalhos de resposta validados contra injecao.

Auth
- As views do admin de dominio eram globais: dashboard, fila de saida,
  DMARC, preflight e diagnostico mostravam dados de todos os dominios.
  Agora todas respeitam o escopo da sessao; super admin segue vendo tudo.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-10-13 10:09:55 -03:00
1 parent b5baf33a63
commit 50a67531ba
30 files changed
+1810 -348

No files matched your search

@@ -16,17 +16,17 @@ import {
X,
Sparkles,
CheckCircle2,
Flag,
} from "lucide-react";
import { useTranslations } from "../../../../i18n/provider";
import { Button } from "../../../../components/ui/Button";
import { signatureToHtml } from "../../../../lib/signature";
import { RecipientInput } from "../../../../components/RecipientInput";
import { parseAddress, parseAddressList, prefixSubject } from "../../../../lib/recipients";
/** Splits a comma or semicolon separated field, dropping empty entries. */
function splitAddresses(value: string): string[] {
return value
.split(/[,;]/)
.map((entry) => entry.trim())
.filter(Boolean);
/** Entries from a query-string list, which may be several addresses. */
function entriesFrom(value: string | undefined): string[] {
return value ? parseAddressList(value).map((p) => p.entry) : [];
}
interface AttachedFile {
@@ -39,16 +39,30 @@ interface AttachedFile {
export default function ComposePage({
searchParams,
}: {
searchParams?: Promise<{ replyTo?: string; subject?: string; inReplyTo?: string; draft?: string }>;
searchParams?: Promise<{
replyTo?: string;
cc?: string;
subject?: string;
inReplyTo?: string;
mode?: string;
draft?: string;
}>;
}) {
const t = useTranslations();
const params = searchParams ? use(searchParams) : {};
const [to, setTo] = useState(params?.replyTo || "");
const [cc, setCc] = useState("");
const [bcc, setBcc] = useState("");
const [showCcBcc, setShowCcBcc] = useState(false);
const [subject, setSubject] = useState(params?.subject ? `Re: ${params.subject.replace(/^(Re:\s*)+/i, "")}` : "");
// Recipients are lists of entries, not one string to be split later: a
// reply-all arrives with several people already in To and Cc.
const [to, setTo] = useState<string[]>(() => entriesFrom(params?.replyTo));
const [cc, setCc] = useState<string[]>(() => entriesFrom(params?.cc));
const [bcc, setBcc] = useState<string[]>([]);
const [showCcBcc, setShowCcBcc] = useState(() => entriesFrom(params?.cc).length > 0);
const [subject, setSubject] = useState(
params?.subject ? prefixSubject(params.subject, params.mode === "forward" ? "forward" : "reply") : "",
);
// The Message-ID being answered, so the reply threads under it.
const [inReplyTo, setInReplyTo] = useState(params?.mode === "forward" ? "" : params?.inReplyTo || "");
const [important, setImportant] = useState(false);
const [attachments, setAttachments] = useState<AttachedFile[]>([]);
const [sending, setSending] = useState(false);
const [undoSeconds, setUndoSeconds] = useState<number | null>(null);
@@ -101,8 +115,10 @@ export default function ComposePage({
if (cancelled) return;
draftUidRef.current = uid;
setTo((message.to ?? []).join(", "));
setCc((message.cc ?? []).join(", "));
setTo(message.to ?? []);
setCc(message.cc ?? []);
setImportant(Boolean(message.important));
setInReplyTo(message.in_reply_to ?? "");
if ((message.cc ?? []).length > 0) setShowCcBcc(true);
setSubject(message.subject ?? "");
const html = message.html || message.text || "";
@@ -123,18 +139,20 @@ export default function ComposePage({
// closing the tab lost the message while the screen had just said otherwise.
const saveDraft = useCallback(async () => {
const html = editorRef.current?.innerHTML ?? body;
if (!to && !subject && !body.trim() && !html.trim()) return;
if (to.length === 0 && !subject && !body.trim() && !html.trim()) return;
setDraftStatus(t("mail.draftSaving"));
try {
const res = await fetch("/api/v1/mail/draft", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
to: splitAddresses(to),
cc: splitAddresses(cc),
bcc: splitAddresses(bcc),
to,
cc,
bcc,
subject,
html,
important,
in_reply_to: inReplyTo,
replace_uid: draftUidRef.current,
}),
});
@@ -146,7 +164,7 @@ export default function ComposePage({
} catch {
setDraftStatus(t("mail.draftSaveFailed"));
}
}, [to, cc, bcc, subject, body, t]);
}, [to, cc, bcc, subject, body, important, inReplyTo, t]);
// Debounced so a draft is written after a pause in typing rather than on
// every keystroke. Skipped once the message is on its way out.
@@ -155,10 +173,10 @@ export default function ComposePage({
// The body alone is enough: a message typed before its recipient is still
// worth keeping, and requiring an address here is half of why drafts were
// being stored with nothing in them.
if (!to && !subject && !body.trim()) return;
if (to.length === 0 && !subject && !body.trim()) return;
const timer = setTimeout(() => void saveDraft(), 2500);
return () => clearTimeout(timer);
}, [to, subject, cc, bcc, body, sending, saveDraft]);
}, [to, subject, cc, bcc, body, important, sending, saveDraft]);
const executeFormat = (command: string, value: string | undefined = undefined) => {
document.execCommand(command, false, value);
@@ -183,8 +201,24 @@ export default function ComposePage({
setAttachments((prev) => prev.filter((a) => a.id !== id));
};
// Anything in a recipient field that is not an address. These stay on screen
// as red chips; sending with one would queue mail to a destination that
// cannot exist and report success.
const invalidRecipients = [...to, ...cc, ...bcc].filter((entry) => !parseAddress(entry).valid);
const hasRecipient = to.length + cc.length + bcc.length > 0;
const handleSend = async (e: React.FormEvent) => {
e.preventDefault();
if (!hasRecipient) {
setSendError(t("mail.noRecipients"));
return;
}
if (invalidRecipients.length > 0) {
setSendError(t("mail.fixRecipients"));
setShowCcBcc(true);
return;
}
setSendError(null);
setSending(true);
setUndoSeconds(10);
@@ -208,11 +242,13 @@ export default function ComposePage({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
to: splitAddresses(to),
cc: splitAddresses(cc),
bcc: splitAddresses(bcc),
to,
cc,
bcc,
subject,
html: htmlBody,
important,
in_reply_to: inReplyTo,
// The draft this message grew out of, so the server can discard it.
// Without this the draft outlived the message it became and sat in
// Drafts as a duplicate of mail already sent.
@@ -261,6 +297,19 @@ export default function ComposePage({
)}
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setImportant((v) => !v)}
aria-pressed={important}
disabled={sending}
title={t("mail.highImportanceHint")}
className={`inline-flex h-10 items-center gap-2 rounded-xl px-3.5 text-sm shadow-edge transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 ${
important ? "bg-rose-900/70 text-rose-100" : "bg-dark-card text-slate-200 hover:bg-white/[0.07]"
}`}
>
<Flag className="h-4 w-4" fill={important ? "currentColor" : "none"} />
<span>{t("mail.highImportance")}</span>
</button>
<Link href="/user/mail/inbox">
<Button variant="secondary" size="md">
{t("mail.discard")}
@@ -304,15 +353,13 @@ export default function ComposePage({
<label htmlFor="compose-to" className={fieldLabel}>
{t("ui.toRecipients")}
</label>
<input
<RecipientInput
id="compose-to"
type="text"
value={to}
onChange={(e) => setTo(e.target.value)}
onChange={setTo}
placeholder="recipient@example.com"
required
disabled={sending}
className={bareInput}
invalid={to.some((e) => !parseAddress(e).valid)}
/>
<button
type="button"
@@ -329,28 +376,26 @@ export default function ComposePage({
<label htmlFor="compose-cc" className={fieldLabel}>
{t("mail.ccLabel")}
</label>
<input
<RecipientInput
id="compose-cc"
type="text"
value={cc}
onChange={(e) => setCc(e.target.value)}
onChange={setCc}
placeholder="cc@domain.com"
disabled={sending}
className={bareInput}
invalid={cc.some((e) => !parseAddress(e).valid)}
/>
</div>
<div className={fieldRow}>
<label htmlFor="compose-bcc" className={fieldLabel}>
{t("mail.bccLabel")}
</label>
<input
<RecipientInput
id="compose-bcc"
type="text"
value={bcc}
onChange={(e) => setBcc(e.target.value)}
onChange={setBcc}
placeholder="bcc@domain.com"
disabled={sending}
className={bareInput}
invalid={bcc.some((e) => !parseAddress(e).valid)}
/>
</div>
</>
@@ -1,25 +1,17 @@
"use client";
import React, { use, useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import React, { use, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion";
import {
Search,
Star,
Paperclip,
Reply,
Forward,
Inbox,
Image as ImageIcon,
Mail,
Clock,
MailOpen,
Download,
FolderInput,
Pencil,
RefreshCw,
Trash2,
X,
} from "lucide-react";
import { useI18n } from "../../../../../i18n/provider";
import { useMailEvents } from "../../../../../lib/useMailEvents";
@@ -30,6 +22,7 @@ import {
messageCounts,
listHeaderCount,
applySeen,
applyFlagged,
readerActions,
moveTargets,
type ListFilter,
@@ -38,6 +31,14 @@ import { sanitizeEmailHtml, blockRemoteImages, unblockRemoteImages } from "../..
import { isSenderTrusted, trustSender, revokeSender } from "../../../../../lib/remoteImages";
import { resolveInlineImages, buildReaderDocument, type InlineImages } from "../../../../../lib/emailBody";
import { Button } from "../../../../../components/ui/Button";
import {
clampListWidth,
DEFAULT_LIST_WIDTH,
readStoredWidth,
storeWidth,
} from "../../../../../lib/paneWidth";
import { useAccount } from "../../../../../lib/useAccount";
import { MessageReader } from "../../../../../components/MessageReader";
import { Avatar } from "../../../../../components/Avatar";
interface MessageSummary {
@@ -50,6 +51,7 @@ interface MessageSummary {
received_at: string;
has_attachments: boolean;
seen: boolean;
flagged: boolean;
spam_verdict: string;
dmarc_result: string;
}
@@ -64,6 +66,8 @@ interface RenderedMessage {
text: string;
html: string;
attachments: string[];
message_id?: string;
important?: boolean;
// The message's own pictures, keyed by Content-ID. Without these every
// cid: reference in the body is unresolvable and renders as a broken icon.
inline_images: InlineImages;
@@ -120,8 +124,6 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
// Whether this sender has a standing decision, so the banner can offer to
// take it back rather than only to grant it.
const [rememberSender, setRememberSender] = useState(false);
// Whether the "move to" menu is open.
const [moveOpen, setMoveOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [activeFilter, setActiveFilter] = useState<ListFilter>(null);
const [loading, setLoading] = useState(true);
@@ -130,6 +132,26 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
// clipped every long message and left white space under every short one.
const [readerHeight, setReaderHeight] = useState(420);
const account = useAccount("user");
// The list's width, draggable on a wide screen like the Outlook divider.
// Remembered between visits; below md the panes stack and it does not apply.
const [listWidth, setListWidth] = useState(DEFAULT_LIST_WIDTH);
const [dragging, setDragging] = useState(false);
const paneRowRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const stored = readStoredWidth();
if (stored !== null) setListWidth(stored);
}, []);
/** Applies a width within the limits the current window allows. */
const resizeList = useCallback((width: number, persist: boolean) => {
const container = paneRowRef.current?.getBoundingClientRect().width ?? window.innerWidth;
const next = clampListWidth(width, container);
setListWidth(next);
if (persist) storeWidth(next);
}, []);
const { folders } = useFolders();
const metrics = folderMetrics(folders, folder);
const router = useRouter();
@@ -221,6 +243,31 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
[folder]
);
/**
* Marks a message important, or takes the mark off.
*
* Optimistic like read state: the star changes at the click and goes back if
* the server refuses, so a failed request is never left looking like a
* success.
*/
const setFlagged = useCallback(
async (uid: number, flagged: boolean) => {
setMessages((current) => applyFlagged(current, uid, flagged));
try {
const res = await fetch("/api/v1/mail/flagged", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ folder, uid, flagged }),
});
if (!res.ok) throw new Error(t("errors.serverError"));
} catch (err) {
setMessages((current) => applyFlagged(current, uid, !flagged));
setError(err instanceof Error ? err.message : t("errors.serverError"));
}
},
[folder, t]
);
/**
* Deletes one message: to Trash from an ordinary folder, and for good from
* Trash itself, which is what the server does with the same call.
@@ -259,7 +306,6 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
async (uid: number, target: string) => {
setMessages((current) => current.filter((m) => m.uid !== uid));
setSelected((current) => (current?.uid === uid ? null : current));
setMoveOpen(false);
try {
const res = await fetch("/api/v1/mail/move", {
method: "POST",
@@ -348,6 +394,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
{ key: null, label: t("mail.allMessages"), count: header.count },
{ key: "unread", label: t("mail.unreadFilter"), count: counts.unread },
{ key: "attachment", label: t("mail.attachmentsFilter"), count: counts.withAttachments },
{ key: "important", label: t("mail.importantFilter"), count: counts.important },
];
@@ -365,10 +412,11 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
const folderTitle = FOLDER_LABELS[folder.toLowerCase()] ?? folder;
return (
<div className="flex flex-1 overflow-hidden">
<div ref={paneRowRef} className="flex flex-1 overflow-hidden">
{/* Messages List Column */}
<div
className={`${selected ? "hidden md:flex" : "flex"} w-full flex-col bg-dark-bg shadow-[inset_-1px_0_0_0_rgba(233,233,237,0.09)] md:w-[380px] md:flex-none lg:w-[420px] xl:w-[460px] 2xl:w-[500px]`}
style={{ "--list-w": `${listWidth}px` } as React.CSSProperties}
className={`${selected ? "hidden md:flex" : "flex"} w-full flex-col bg-dark-bg md:w-[var(--list-w)] md:flex-none`}
>
{/* Folder heading, with the counts that belong to the folder rather
than to the page of it that happens to be loaded. */}
@@ -514,6 +562,13 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
{/* An unread row is marked by a dot in a reserved column, so
the subject does not shift sideways when it is read. */}
{msg.flagged && (
<Star
className="mt-1 h-3.5 w-3.5 flex-none text-amber-300"
fill="currentColor"
aria-label={t("mail.markedImportant")}
/>
)}
<span className="mt-3 w-[7px] flex-none">
{!msg.seen && <span className="block h-[7px] w-[7px] rounded-full bg-indigo-500" />}
</span>
@@ -537,250 +592,84 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
</div>
</div>
{/* The divider between the panes. Wide screens only: below md they stack
and there is nothing to divide. A pointer drag, the arrow keys and a
double-click to restore all move it, so it is not mouse-only. */}
<div
role="separator"
aria-orientation="vertical"
aria-label={t("mail.resizeList")}
aria-valuenow={listWidth}
tabIndex={0}
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
setDragging(true);
}}
onPointerMove={(e) => {
if (!dragging) return;
const left = paneRowRef.current?.getBoundingClientRect().left ?? 0;
resizeList(e.clientX - left, false);
}}
onPointerUp={(e) => {
e.currentTarget.releasePointerCapture(e.pointerId);
setDragging(false);
storeWidth(listWidth);
}}
onDoubleClick={() => resizeList(DEFAULT_LIST_WIDTH, true)}
onKeyDown={(e) => {
if (e.key === "ArrowLeft") resizeList(listWidth - 24, true);
else if (e.key === "ArrowRight") resizeList(listWidth + 24, true);
else if (e.key === "Home") resizeList(0, true);
else if (e.key === "End") resizeList(Infinity, true);
else return;
e.preventDefault();
}}
className={`group relative hidden w-1.5 flex-none cursor-col-resize touch-none select-none md:block ${
dragging ? "bg-indigo-500/40" : "bg-white/[0.07] hover:bg-indigo-500/30"
} focus-visible:bg-indigo-500/50 focus-visible:outline-none`}
>
{/* A wider invisible target, so the thin line is easy to grab. */}
<span className="absolute inset-y-0 -left-1.5 -right-1.5" />
</div>
{/* Message Reader Column */}
<div className={`${selected ? "flex" : "hidden md:flex"} flex-1 flex-col overflow-y-auto bg-dark-bg`}>
<div className={`${selected ? "flex" : "hidden md:flex"} min-w-0 flex-1 flex-col overflow-hidden bg-dark-bg`}>
<AnimatePresence mode="wait">
{selected ? (
<motion.article
<MessageReader
key={selected.uid}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.15 }}
className="mx-auto flex w-full max-w-[860px] flex-col gap-5 px-5 pb-10 pt-6 sm:px-7 xl:max-w-[1000px] 2xl:max-w-[1140px]"
>
{/* Toolbar: closing, replying and read state all in one row, so
the reader is not the only pane with no way out of itself. */}
<div className="flex flex-wrap items-center gap-2">
{/* A draft is unfinished, so the thing to do with it is carry
on writing. It used to offer Reply and Forward on the
user's own half-written message and no way to edit it. */}
{actions.canEdit && (
<Link href={`/user/compose?draft=${selected.uid}`}>
<Button variant="primary" size="sm">
<Pencil className="h-3.5 w-3.5" />
<span>{t("mail.continueDraft")}</span>
</Button>
</Link>
)}
{actions.canReply && (
<Link
href={`/user/compose?replyTo=${encodeURIComponent(selected.from)}&subject=${encodeURIComponent(selected.subject)}`}
>
<Button variant="primary" size="sm">
<Reply className="h-3.5 w-3.5" />
<span>{t("mail.reply")}</span>
</Button>
</Link>
)}
{/* Forward carries the subject but no recipient: it used to
prefill the original sender, which sends the message
straight back to whoever wrote it. */}
{actions.canForward && (
<Link href={`/user/compose?subject=${encodeURIComponent(selected.subject)}`}>
<Button variant="secondary" size="sm">
<Forward className="h-3.5 w-3.5" />
<span>{t("mail.forward")}</span>
</Button>
</Link>
)}
{/* Not offered in Sent or Drafts: nothing delivers to those
folders, so an unread flag there says nothing. */}
{actions.canMarkUnread && (
<Button
variant="secondary"
size="sm"
onClick={() => {
const nowSeen = !(selectedRow?.seen ?? true);
void setSeen(selected.uid, nowSeen, { persist: true });
}}
>
<MailOpen className="h-3.5 w-3.5" />
<span>{selectedRow?.seen === false ? t("mail.markRead") : t("mail.markUnread")}</span>
</Button>
)}
{/* Filing a message elsewhere: the one ordinary action the
reader still had no button for. */}
<div className="relative">
<Button
variant="secondary"
size="sm"
onClick={() => setMoveOpen((open) => !open)}
title={t("mail.moveTo")}
>
<FolderInput className="h-3.5 w-3.5" />
<span>{t("mail.moveTo")}</span>
</Button>
{moveOpen && (
<div className="absolute left-0 top-full z-20 mt-1 min-w-[190px] overflow-hidden rounded-xl bg-dark-panel py-1 shadow-edge">
{moveTargets(folders, folder).map((target) => (
<button
key={target.name}
type="button"
onClick={() => void moveMessage(selected.uid, target.name)}
className="block w-full px-3.5 py-2 text-left text-[13px] text-slate-200 transition-colors hover:bg-white/[0.07]"
>
{FOLDER_LABELS[(target.special_use || target.name).toLowerCase()] ?? target.name}
</button>
))}
</div>
)}
</div>
<Button
variant="secondary"
size="sm"
onClick={() => void deleteMessage(selected.uid)}
title={actions.deleteIsPermanent ? t("mail.deleteForever") : t("common.delete")}
>
<Trash2 className="h-3.5 w-3.5" />
<span>{actions.deleteIsPermanent ? t("mail.deleteForever") : t("common.delete")}</span>
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setSelected(null)}
className="ml-auto h-9 w-9 flex-none p-0"
title={t("mail.backToList")}
aria-label={t("mail.backToList")}
>
<X className="h-4 w-4" />
</Button>
</div>
<header className="flex flex-col gap-3.5">
<h1 className="text-[25px] font-medium leading-tight tracking-[-0.015em] text-slate-100 [text-wrap:pretty]">
{selected.subject || t("mail.noSubject")}
</h1>
<div className="flex items-start gap-3">
<Avatar
address={selectedRow?.sender_address ?? selected.from}
initials={initials(selectedRow?.from_display_name ?? "", selected.from)}
size={38}
className="bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]"
/>
<div className="flex min-w-0 flex-1 flex-col gap-[3px]">
<div className="flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
<span className="break-all text-sm text-slate-100">{selected.from}</span>
{selectedRow && selectedRow.size_bytes > 0 && (
<span className="text-[11px] tabular-nums text-slate-500">
{formatBytes(selectedRow.size_bytes)}
</span>
)}
</div>
{/* Recipients are labelled with translated headings; they
used to be introduced by a Portuguese word regardless of
the interface language. */}
{selected.to.length > 0 && (
<div className="break-words text-[12.5px] leading-relaxed text-slate-400">
<span className="text-slate-500">{t("ui.toRecipients")} </span>
{selected.to.join(", ")}
</div>
)}
{selected.cc.length > 0 && (
<div className="break-words text-[12.5px] leading-relaxed text-slate-400">
<span className="text-slate-500">{t("ui.ccRecipients")} </span>
{selected.cc.join(", ")}
</div>
)}
</div>
<span className="flex-none text-xs tabular-nums text-slate-400">
{selected.date
? new Date(selected.date).toLocaleString(locale)
: selectedRow
? new Date(selectedRow.received_at).toLocaleString(locale)
: ""}
</span>
</div>
</header>
{/* Remote content banner, shown only when something was actually
held back rather than over every message. The wording is the
reassurance, not the colour: this is not an error. */}
{(hasBlockedContent || rememberSender) && (
<div className="flex flex-wrap items-center gap-2 rounded-xl bg-dark-card p-3 shadow-edge">
<ImageIcon className="h-4 w-4 flex-none text-indigo-400" />
<span className="min-w-[200px] flex-1 text-[12.5px] leading-relaxed text-slate-300">
{t("mail.remoteContentBlocked")}
</span>
{/* Two separate decisions: show them now, and stop asking
about this sender. Keeping them apart lets a reader look
at one message without granting anything permanently. */}
<Button
variant="ghost"
size="sm"
onClick={() => setLoadRemoteImages(!loadRemoteImages)}
>
{loadRemoteImages ? t("mail.hideImages") : t("mail.loadImages")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
if (rememberSender) {
revokeSender(selected.from);
setRememberSender(false);
setLoadRemoteImages(false);
} else {
trustSender(selected.from);
setRememberSender(true);
setLoadRemoteImages(true);
}
}}
>
{rememberSender ? t("mail.forgetSender") : t("mail.alwaysFromSender")}
</Button>
</div>
)}
{/* Attachment Download Chips */}
{selected.attachments.length > 0 && (
<div className="flex flex-col gap-2">
<div className="text-[11px] uppercase tracking-[0.08em] text-slate-400">
{t("mail.attachments", { count: selected?.attachments.length ?? 1 })}
</div>
<div className="flex flex-wrap gap-2">
{selected.attachments.map((filename, idx) => (
<a
key={`${filename}-${idx}`}
href={`/api/v1/mail/message/${selected.uid}/attachment/${encodeURIComponent(filename)}?folder=${encodeURIComponent(folder)}`}
download={filename}
className="group flex max-w-full items-center gap-2.5 rounded-xl bg-dark-card px-3 py-2.5 text-slate-200 shadow-edge transition-shadow hover:shadow-edge-accent"
>
<Paperclip className="h-[18px] w-[18px] flex-none text-indigo-400" />
<span className="min-w-0 break-all text-[13px]">{filename}</span>
<Download className="h-4 w-4 flex-none text-slate-500 group-hover:text-indigo-300" />
</a>
))}
</div>
</div>
)}
{/* Message Body */}
{readerDocument ? (
<div className="overflow-hidden rounded-2xl bg-white shadow-edge">
<iframe
title={t("ui.messageBody")}
srcDoc={readerDocument}
/* allow-scripts, and deliberately not allow-same-origin: the
frame stays on an opaque origin - it cannot read a cookie
or touch this document - while still running the few lines
that report its height and open links outward. Message
markup is sanitised before it gets here. */
sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox"
referrerPolicy="no-referrer"
style={{ height: `${readerHeight}px` }}
className="block w-full border-0"
/>
</div>
) : (
<div className="rounded-2xl bg-dark-panel px-6 py-6 shadow-edge">
<pre className="whitespace-pre-wrap break-words font-sans text-[14.5px] leading-relaxed text-slate-200">
{selected.text || t("mail.emptyBody")}
</pre>
</div>
)}
</motion.article>
message={selected}
row={selectedRow}
ownAddress={account?.email ?? ""}
folder={folder}
actions={actions}
moveTargets={moveTargets(folders, folder).map((target) => ({
name: target.name,
label: FOLDER_LABELS[(target.special_use || target.name).toLowerCase()] ?? target.name,
}))}
document={readerDocument}
frameHeight={readerHeight}
hasBlockedContent={hasBlockedContent}
imagesLoaded={loadRemoteImages}
senderRemembered={rememberSender}
onClose={() => setSelected(null)}
onToggleFlagged={() => void setFlagged(selected.uid, !(selectedRow?.flagged ?? false))}
onToggleSeen={() => void setSeen(selected.uid, !(selectedRow?.seen ?? true), { persist: true })}
onDelete={() => void deleteMessage(selected.uid)}
onMove={(target) => void moveMessage(selected.uid, target)}
onToggleImages={() => setLoadRemoteImages(!loadRemoteImages)}
onToggleRemember={() => {
if (rememberSender) {
revokeSender(selected.from);
setRememberSender(false);
setLoadRemoteImages(false);
} else {
trustSender(selected.from);
setRememberSender(true);
setLoadRemoteImages(true);
}
}}
/>
) : (
<motion.div
initial={{ opacity: 0 }}