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:
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 }}
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
ChevronDown,
|
||||
Download,
|
||||
FolderInput,
|
||||
Forward,
|
||||
Image as ImageIcon,
|
||||
MailOpen,
|
||||
Pencil,
|
||||
Reply,
|
||||
ReplyAll,
|
||||
Star,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useI18n } from "../i18n/provider";
|
||||
import { Avatar } from "./Avatar";
|
||||
import { offersReplyAll, replyAllRecipients, replyRecipients } from "../lib/recipients";
|
||||
|
||||
export interface ReaderMessage {
|
||||
uid: number;
|
||||
subject: string;
|
||||
from: string;
|
||||
to: string[];
|
||||
cc: string[];
|
||||
date: string;
|
||||
text: string;
|
||||
attachments: string[];
|
||||
message_id?: string;
|
||||
/** The sender asked for priority (Importance: high). */
|
||||
important?: boolean;
|
||||
}
|
||||
|
||||
export interface ReaderRow {
|
||||
sender_address: string;
|
||||
from_display_name: string;
|
||||
received_at: string;
|
||||
seen: boolean;
|
||||
flagged?: boolean;
|
||||
}
|
||||
|
||||
export interface ReaderActions {
|
||||
canEdit: boolean;
|
||||
canReply: boolean;
|
||||
canForward: boolean;
|
||||
canMarkUnread: boolean;
|
||||
deleteIsPermanent: boolean;
|
||||
}
|
||||
|
||||
interface MoveTarget {
|
||||
name: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
message: ReaderMessage;
|
||||
row?: ReaderRow;
|
||||
/** The signed-in address, left out of every reply-all. */
|
||||
ownAddress: string;
|
||||
folder: string;
|
||||
actions: ReaderActions;
|
||||
moveTargets: MoveTarget[];
|
||||
/** The document the body frame renders, or null for a text-only message. */
|
||||
document: string | null;
|
||||
frameHeight: number;
|
||||
/** Whether anything was held back, so the privacy bar is not always shown. */
|
||||
hasBlockedContent: boolean;
|
||||
imagesLoaded: boolean;
|
||||
senderRemembered: boolean;
|
||||
onClose: () => void;
|
||||
onToggleSeen: () => void;
|
||||
onToggleFlagged: () => void;
|
||||
onDelete: () => void;
|
||||
onMove: (target: string) => void;
|
||||
onToggleImages: () => void;
|
||||
onToggleRemember: () => void;
|
||||
}
|
||||
|
||||
/** The sender's name and address, from a header like `Jenny <jenny@x.test>`. */
|
||||
function splitSender(from: string): { name: string; address: string } {
|
||||
const match = from.match(/^\s*"?([^"<]*?)"?\s*<([^>]+)>\s*$/);
|
||||
if (match) return { name: match[1].trim(), address: match[2].trim() };
|
||||
return { name: "", address: from.trim() };
|
||||
}
|
||||
|
||||
/** The initials shown in the sender avatar, from a display name or an address. */
|
||||
function initialsOf(name: string, address: string): string {
|
||||
const source = (name || address || "?").trim();
|
||||
const parts = source.replace(/[<>"]/g, "").split(/[\s.@_-]+/).filter(Boolean);
|
||||
return (parts.slice(0, 2).map((p) => p[0]).join("") || "?").toUpperCase();
|
||||
}
|
||||
|
||||
/** A short type tag and colour for an attachment, from its extension. */
|
||||
function attachmentKind(filename: string): { tag: string; className: string } {
|
||||
const ext = (filename.split(".").pop() ?? "").toLowerCase();
|
||||
if (ext === "pdf") return { tag: "PDF", className: "bg-rose-700" };
|
||||
if (["png", "jpg", "jpeg", "gif", "webp", "bmp"].includes(ext)) return { tag: "IMG", className: "bg-sky-700" };
|
||||
if (["doc", "docx", "odt", "rtf", "txt"].includes(ext)) return { tag: "DOC", className: "bg-blue-700" };
|
||||
if (["xls", "xlsx", "ods", "csv"].includes(ext)) return { tag: "XLS", className: "bg-emerald-700" };
|
||||
if (["zip", "rar", "7z", "gz", "tar"].includes(ext)) return { tag: "ZIP", className: "bg-amber-700" };
|
||||
return { tag: (ext || "FILE").slice(0, 4).toUpperCase(), className: "bg-slate-600" };
|
||||
}
|
||||
|
||||
// One look for every control on the command bar, so the bar reads as a single
|
||||
// strip. The label hides on narrow screens and the icon stays, which is what
|
||||
// keeps six actions on one row at phone width.
|
||||
const commandClass =
|
||||
"inline-flex h-10 min-w-10 items-center justify-center gap-1.5 rounded-lg px-2.5 text-[13px] text-slate-200 transition-colors hover:bg-white/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 md:px-3";
|
||||
const infoLinkClass =
|
||||
"rounded-md px-1.5 py-1 text-[12.5px] font-medium text-indigo-300 transition-colors hover:bg-white/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400";
|
||||
|
||||
export function MessageReader(props: Props) {
|
||||
const { message, row, folder, actions } = props;
|
||||
const { t, locale } = useI18n();
|
||||
const [moveOpen, setMoveOpen] = useState(false);
|
||||
const [recipientsOpen, setRecipientsOpen] = useState(false);
|
||||
|
||||
const { name, address } = splitSender(message.from);
|
||||
const displayName = name || row?.from_display_name || address;
|
||||
const showAddress = displayName !== address;
|
||||
|
||||
const when = message.date || row?.received_at || "";
|
||||
const whenText = when
|
||||
? new Date(when).toLocaleString(locale, {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: "";
|
||||
|
||||
// Collapsed, the recipient line names the first two and counts the rest: a
|
||||
// newsletter sent to forty people pushed the message itself off the screen.
|
||||
const visibleTo = recipientsOpen ? message.to : message.to.slice(0, 2);
|
||||
const hiddenCount = message.to.length - visibleTo.length + (recipientsOpen ? 0 : message.cc.length);
|
||||
const showCc = recipientsOpen && message.cc.length > 0;
|
||||
|
||||
const composeHref = (recipients: { to: string[]; cc: string[] }, mode: "reply" | "replyAll") => {
|
||||
const query = new URLSearchParams({ mode, subject: message.subject });
|
||||
if (recipients.to.length > 0) query.set("replyTo", recipients.to.join(", "));
|
||||
if (recipients.cc.length > 0) query.set("cc", recipients.cc.join(", "));
|
||||
if (message.message_id) query.set("inReplyTo", message.message_id);
|
||||
return `/user/compose?${query.toString()}`;
|
||||
};
|
||||
const replyHref = composeHref(replyRecipients(message, props.ownAddress), "reply");
|
||||
const replyAllHref = composeHref(replyAllRecipients(message, props.ownAddress), "replyAll");
|
||||
// Offered only when it would reach someone a plain reply would not: with
|
||||
// one other person on the thread the two buttons would do the same thing.
|
||||
const canReplyAll = actions.canReply && offersReplyAll(message, props.ownAddress);
|
||||
// Forward carries the subject but no recipient: prefilling the original
|
||||
// sender would send the message straight back to whoever wrote it.
|
||||
const forwardHref = `/user/compose?mode=forward&subject=${encodeURIComponent(message.subject)}`;
|
||||
const flagged = Boolean(row?.flagged);
|
||||
const deleteLabel = actions.deleteIsPermanent ? t("mail.deleteForever") : t("common.delete");
|
||||
|
||||
return (
|
||||
<motion.article
|
||||
key={message.uid}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
{/* Command bar. It stays put while the message scrolls under it, so
|
||||
reply and delete are never a scroll away from a long newsletter. */}
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label={t("mail.messageActions")}
|
||||
className="flex flex-none flex-wrap items-center gap-0.5 bg-dark-bg px-2 py-1.5 shadow-[0_1px_0_0_rgba(233,233,237,0.09)] sm:px-4"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onClose}
|
||||
className={`${commandClass} md:hidden`}
|
||||
title={t("mail.backToList")}
|
||||
aria-label={t("mail.backToList")}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{actions.canEdit && (
|
||||
<Link href={`/user/compose?draft=${message.uid}`} className={`${commandClass} text-indigo-300`}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<span className="hidden xl:inline">{t("mail.continueDraft")}</span>
|
||||
</Link>
|
||||
)}
|
||||
{actions.canReply && (
|
||||
<Link href={replyHref} className={`${commandClass} text-indigo-300`} title={t("mail.reply")}>
|
||||
<Reply className="h-4 w-4" />
|
||||
<span className="hidden xl:inline">{t("mail.reply")}</span>
|
||||
</Link>
|
||||
)}
|
||||
{canReplyAll && (
|
||||
<Link href={replyAllHref} className={commandClass} title={t("mail.replyAll")}>
|
||||
<ReplyAll className="h-4 w-4" />
|
||||
<span className="hidden 2xl:inline">{t("mail.replyAll")}</span>
|
||||
</Link>
|
||||
)}
|
||||
{actions.canForward && (
|
||||
<Link href={forwardHref} className={commandClass} title={t("mail.forward")}>
|
||||
<Forward className="h-4 w-4" />
|
||||
<span className="hidden 2xl:inline">{t("mail.forward")}</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<span aria-hidden className="mx-1 h-5 w-px bg-white/[0.12]" />
|
||||
|
||||
<button type="button" onClick={props.onDelete} className={commandClass} title={deleteLabel}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="hidden 2xl:inline">{deleteLabel}</span>
|
||||
</button>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMoveOpen((open) => !open)}
|
||||
aria-expanded={moveOpen}
|
||||
aria-haspopup="menu"
|
||||
className={commandClass}
|
||||
title={t("mail.moveTo")}
|
||||
>
|
||||
<FolderInput className="h-4 w-4" />
|
||||
<span className="hidden 2xl:inline">{t("mail.moveTo")}</span>
|
||||
<ChevronDown className="hidden h-3 w-3 2xl:block" />
|
||||
</button>
|
||||
{moveOpen && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 top-full z-20 mt-1 min-w-[190px] overflow-hidden rounded-xl bg-dark-panel py-1 shadow-edge md:left-0 md:right-auto"
|
||||
>
|
||||
{props.moveTargets.map((target) => (
|
||||
<button
|
||||
key={target.name}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
setMoveOpen(false);
|
||||
props.onMove(target.name);
|
||||
}}
|
||||
className="block w-full px-3.5 py-2.5 text-left text-[13px] text-slate-200 transition-colors hover:bg-white/[0.07] focus-visible:bg-white/[0.07] focus-visible:outline-none"
|
||||
>
|
||||
{target.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Not offered in Sent or Drafts: nothing delivers to those folders,
|
||||
so an unread flag there says nothing. */}
|
||||
{actions.canMarkUnread && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onToggleSeen}
|
||||
className={commandClass}
|
||||
title={row?.seen === false ? t("mail.markRead") : t("mail.markUnread")}
|
||||
>
|
||||
<MailOpen className="h-4 w-4" />
|
||||
<span className="hidden 2xl:inline">
|
||||
{row?.seen === false ? t("mail.markRead") : t("mail.markUnread")}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* The importance mark, available in every folder: a message in Sent
|
||||
or Archive can be worth flagging too. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onToggleFlagged}
|
||||
aria-pressed={flagged}
|
||||
className={`${commandClass} ${flagged ? "text-amber-300" : ""}`}
|
||||
title={flagged ? t("mail.unmarkImportant") : t("mail.markImportant")}
|
||||
>
|
||||
<Star className="h-4 w-4" fill={flagged ? "currentColor" : "none"} />
|
||||
<span className="hidden 2xl:inline">{flagged ? t("mail.unmarkImportant") : t("mail.markImportant")}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onClose}
|
||||
className={`${commandClass} ml-auto hidden md:inline-flex`}
|
||||
title={t("mail.backToList")}
|
||||
aria-label={t("mail.backToList")}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto flex w-full max-w-[1000px] flex-col gap-4 px-4 pb-10 pt-5 sm:px-7 2xl:max-w-[1140px]">
|
||||
{message.important && (
|
||||
<span className="inline-flex w-fit items-center gap-1.5 rounded-full bg-rose-900/60 px-2.5 py-0.5 text-xs font-medium text-rose-200 shadow-[inset_0_0_0_1px_rgba(244,63,94,0.45)]">
|
||||
<AlertCircle className="h-3.5 w-3.5" aria-hidden />
|
||||
{t("mail.highImportanceBadge")}
|
||||
</span>
|
||||
)}
|
||||
<h1 className="text-[19px] font-semibold leading-snug tracking-[-0.01em] text-slate-100 [overflow-wrap:anywhere] [text-wrap:pretty] sm:text-[22px]">
|
||||
{message.subject || t("mail.noSubject")}
|
||||
</h1>
|
||||
|
||||
{/* The sender block: name first and the address second, because the
|
||||
name is what a reader recognises and a long address used to be
|
||||
the loudest thing in the header. */}
|
||||
<header className="flex items-start gap-3">
|
||||
<Avatar
|
||||
address={row?.sender_address ?? address}
|
||||
initials={initialsOf(displayName, address)}
|
||||
size={44}
|
||||
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-0.5">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<span className="text-[15px] font-semibold text-slate-100 [overflow-wrap:anywhere]">{displayName}</span>
|
||||
{showAddress && (
|
||||
<span className="text-[13px] text-slate-400 [overflow-wrap:anywhere]"><{address}></span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[12.5px] leading-relaxed text-slate-400 [overflow-wrap:anywhere]">
|
||||
{visibleTo.length > 0 && (
|
||||
<>
|
||||
<span className="text-slate-500">{t("ui.toRecipients")}: </span>
|
||||
{visibleTo.join(", ")}
|
||||
</>
|
||||
)}
|
||||
{showCc && (
|
||||
<div>
|
||||
<span className="text-slate-500">{t("ui.ccRecipients")}: </span>
|
||||
{message.cc.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
{(hiddenCount > 0 || recipientsOpen) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRecipientsOpen((open) => !open)}
|
||||
aria-expanded={recipientsOpen}
|
||||
className="ml-1 inline-flex items-center gap-0.5 rounded px-1 text-indigo-300 hover:bg-white/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400"
|
||||
>
|
||||
{recipientsOpen ? t("mail.showFewerRecipients") : `+${hiddenCount}`}
|
||||
<ChevronDown className={`h-3 w-3 transition-transform ${recipientsOpen ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* On a phone the date drops under the recipients rather than
|
||||
squeezing the name. */}
|
||||
<span className="text-xs tabular-nums text-slate-500 sm:hidden">{whenText}</span>
|
||||
</div>
|
||||
<span className="hidden flex-none pt-0.5 text-xs tabular-nums text-slate-400 sm:block">{whenText}</span>
|
||||
</header>
|
||||
|
||||
{/* One quiet line, shown only when something was actually held back.
|
||||
The wording is the reassurance, not the colour: this is not an
|
||||
error. Two separate decisions - look now, or stop asking about
|
||||
this sender - so a reader can see one message without granting
|
||||
anything permanently. */}
|
||||
{(props.hasBlockedContent || props.senderRemembered) && (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-lg bg-dark-card px-3 py-2 shadow-edge">
|
||||
<ImageIcon className="h-4 w-4 flex-none text-indigo-400" aria-hidden />
|
||||
<span className="min-w-[180px] flex-1 text-[12.5px] leading-relaxed text-slate-300">
|
||||
{props.imagesLoaded ? t("mail.imagesEnabled") : t("mail.remoteContentBlocked")}
|
||||
</span>
|
||||
<button type="button" onClick={props.onToggleImages} className={infoLinkClass}>
|
||||
{props.imagesLoaded ? t("mail.hideImages") : t("mail.loadImages")}
|
||||
</button>
|
||||
<button type="button" onClick={props.onToggleRemember} className={infoLinkClass}>
|
||||
{props.senderRemembered ? t("mail.forgetSender") : t("mail.alwaysFromSender")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.attachments.length > 0 && (
|
||||
<section aria-label={t("mail.attachments", { count: message.attachments.length })}>
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{message.attachments.map((filename, idx) => {
|
||||
const kind = attachmentKind(filename);
|
||||
return (
|
||||
<a
|
||||
key={`${filename}-${idx}`}
|
||||
href={`/api/v1/mail/message/${message.uid}/attachment/${encodeURIComponent(filename)}?folder=${encodeURIComponent(folder)}`}
|
||||
download={filename}
|
||||
className="group flex h-10 max-w-[260px] flex-none items-center gap-2 rounded-lg bg-dark-card px-2.5 text-slate-200 shadow-edge transition-shadow hover:shadow-edge-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400"
|
||||
>
|
||||
<span className={`rounded px-1.5 py-0.5 text-[10px] font-bold text-white ${kind.className}`}>
|
||||
{kind.tag}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-[12.5px]">{filename}</span>
|
||||
<Download className="h-3.5 w-3.5 flex-none text-slate-500 group-hover:text-indigo-300" />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{props.document ? (
|
||||
<div className="overflow-hidden rounded-xl bg-white shadow-edge">
|
||||
<iframe
|
||||
title={t("ui.messageBody")}
|
||||
srcDoc={props.document}
|
||||
/* 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: `${props.frameHeight}px` }}
|
||||
className="block w-full border-0"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl bg-dark-panel px-5 py-5 shadow-edge sm:px-6">
|
||||
<pre className="whitespace-pre-wrap break-words font-sans text-[14.5px] leading-relaxed text-slate-200">
|
||||
{message.text || t("mail.emptyBody")}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef, useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { useTranslations } from "../i18n/provider";
|
||||
import { dedupe, isValidAddress, parseAddress, parseAddressList } from "../lib/recipients";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
/** Entries as they will be sent: `Name <addr>` or a bare address. */
|
||||
value: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
/** Outlines the field when something in it cannot be sent. */
|
||||
invalid?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* An address field that turns each recipient into a chip.
|
||||
*
|
||||
* Commit points are the ones people expect from a mail client: a comma, a
|
||||
* semicolon, Enter, a space after something that is already an address, and
|
||||
* leaving the field. A pasted list becomes several chips at once. A chip that
|
||||
* is not an address stays visible and red rather than being dropped, so a
|
||||
* typo is something the sender sees instead of mail that quietly goes nowhere.
|
||||
*/
|
||||
export function RecipientInput({ id, value, onChange, placeholder, disabled, invalid }: Props) {
|
||||
const t = useTranslations();
|
||||
const [draft, setDraft] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const commit = (text: string) => {
|
||||
const parsed = parseAddressList(text);
|
||||
if (parsed.length === 0) return;
|
||||
onChange(dedupe([...value, ...parsed.map((p) => p.entry)]));
|
||||
};
|
||||
|
||||
const flush = () => {
|
||||
if (draft.trim()) commit(draft);
|
||||
setDraft("");
|
||||
};
|
||||
|
||||
const remove = (index: number) => {
|
||||
onChange(value.filter((_, i) => i !== index));
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const text = e.target.value;
|
||||
// Only a separator that is not inside a quoted name or an <address> ends a
|
||||
// recipient; parseAddressList knows the difference, so it is asked rather
|
||||
// than splitting here.
|
||||
if (/[,;]$/.test(text)) {
|
||||
const body = text.slice(0, -1);
|
||||
const unbalanced = (body.match(/"/g)?.length ?? 0) % 2 === 1 || (body.includes("<") && !body.includes(">"));
|
||||
if (!unbalanced) {
|
||||
commit(body);
|
||||
setDraft("");
|
||||
return;
|
||||
}
|
||||
}
|
||||
setDraft(text);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
// Enter in this field must never submit the message.
|
||||
e.preventDefault();
|
||||
flush();
|
||||
} else if (e.key === " " && isValidAddress(draft.trim())) {
|
||||
e.preventDefault();
|
||||
flush();
|
||||
} else if (e.key === "Backspace" && draft === "" && value.length > 0) {
|
||||
onChange(value.slice(0, -1));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
const text = e.clipboardData.getData("text");
|
||||
// A single-line input swallows line breaks on paste, so a column of
|
||||
// addresses from a spreadsheet would arrive glued together.
|
||||
if (/[\n\r,;\s]/.test(text.trim())) {
|
||||
e.preventDefault();
|
||||
commit(draft + text);
|
||||
setDraft("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
// Clicking anywhere in the row focuses the input, like a text field.
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
className={`flex min-h-[34px] min-w-[160px] flex-1 flex-wrap items-center gap-1.5 rounded-lg px-1 py-0.5 ${
|
||||
invalid ? "ring-1 ring-rose-500/70" : ""
|
||||
}`}
|
||||
>
|
||||
{value.map((entry, index) => {
|
||||
const parsed = parseAddress(entry);
|
||||
return (
|
||||
<span
|
||||
key={`${entry}-${index}`}
|
||||
title={parsed.valid ? parsed.address : t("mail.invalidAddress")}
|
||||
className={`inline-flex max-w-full items-center gap-1 rounded-full py-0.5 pl-2.5 pr-1 text-[12.5px] ${
|
||||
parsed.valid
|
||||
? "bg-indigo-900/70 text-indigo-100 shadow-[inset_0_0_0_1px_#5d5294]"
|
||||
: "bg-rose-900/60 text-rose-100 shadow-[inset_0_0_0_1px_rgba(244,63,94,0.5)]"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{parsed.label}</span>
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
remove(index);
|
||||
}}
|
||||
aria-label={t("mail.removeRecipient", { name: parsed.label })}
|
||||
className="flex h-5 w-5 flex-none items-center justify-center rounded-full hover:bg-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-300"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
type="text"
|
||||
inputMode="email"
|
||||
autoComplete="off"
|
||||
value={draft}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onBlur={flush}
|
||||
placeholder={value.length === 0 ? placeholder : undefined}
|
||||
disabled={disabled}
|
||||
aria-invalid={invalid || undefined}
|
||||
className="min-h-[28px] min-w-[140px] flex-1 border-0 bg-transparent px-1 text-sm text-slate-100 placeholder-slate-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -56,7 +56,7 @@ describe("what the autosave actually stores", () => {
|
||||
|
||||
it("does not save a draft that has no content anywhere", () => {
|
||||
const compose = code(COMPOSE);
|
||||
expect(compose).toMatch(/if \(!to && !subject && !body/);
|
||||
expect(compose).toMatch(/if \(to\.length === 0 && !subject && !body/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface MessageSummary {
|
||||
uid: number;
|
||||
seen: boolean;
|
||||
has_attachments: boolean;
|
||||
/** Marked important (\\Flagged). */
|
||||
flagged?: boolean;
|
||||
}
|
||||
|
||||
export interface FolderMetrics {
|
||||
@@ -28,7 +30,7 @@ export interface FolderMetrics {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export type ListFilter = "unread" | "attachment" | null;
|
||||
export type ListFilter = "unread" | "attachment" | "important" | null;
|
||||
|
||||
/** Clamps a counter to something displayable: no negatives, no NaN. */
|
||||
function counter(value: unknown): number {
|
||||
@@ -73,17 +75,20 @@ export function messageCounts(messages: MessageSummary[]): {
|
||||
loaded: number;
|
||||
unread: number;
|
||||
withAttachments: number;
|
||||
important: number;
|
||||
} {
|
||||
return {
|
||||
loaded: messages.length,
|
||||
unread: messages.filter((m) => !m.seen).length,
|
||||
withAttachments: messages.filter((m) => m.has_attachments).length,
|
||||
important: messages.filter((m) => m.flagged).length,
|
||||
};
|
||||
}
|
||||
|
||||
export function filterMessages<T extends MessageSummary>(messages: T[], filter: ListFilter): T[] {
|
||||
if (filter === "unread") return messages.filter((m) => !m.seen);
|
||||
if (filter === "attachment") return messages.filter((m) => m.has_attachments);
|
||||
if (filter === "important") return messages.filter((m) => m.flagged);
|
||||
return messages;
|
||||
}
|
||||
|
||||
@@ -121,6 +126,10 @@ export function applySeen<T extends MessageSummary>(messages: T[], uid: number,
|
||||
return messages.map((m) => (m.uid === uid ? { ...m, seen } : m));
|
||||
}
|
||||
|
||||
export function applyFlagged<T extends MessageSummary>(messages: T[], uid: number, flagged: boolean): T[] {
|
||||
return messages.map((m) => (m.uid === uid ? { ...m, flagged } : m));
|
||||
}
|
||||
|
||||
/** What the sidebar shows beside one folder. */
|
||||
export interface FolderBadge {
|
||||
/** How many unread messages the folder holds. */
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* The width of the message list, which the reader can drag to change.
|
||||
*
|
||||
* Both panes share one row, so a width has to leave the other pane enough room
|
||||
* to be usable: a list dragged to the full window would leave no reader, and
|
||||
* one dragged to nothing would leave subjects unreadable. The limits live here
|
||||
* so they are tested rather than scattered through the drag handler.
|
||||
*/
|
||||
|
||||
export const PANE_KEY = "lm_list_width";
|
||||
export const DEFAULT_LIST_WIDTH = 420;
|
||||
export const MIN_LIST_WIDTH = 300;
|
||||
/** What the reader keeps whatever the list is dragged to. */
|
||||
export const MIN_READER_WIDTH = 520;
|
||||
|
||||
export function clampListWidth(width: number, containerWidth: number): number {
|
||||
if (Number.isNaN(width)) return DEFAULT_LIST_WIDTH;
|
||||
const max = Math.max(MIN_LIST_WIDTH, containerWidth - MIN_READER_WIDTH);
|
||||
return Math.round(Math.min(max, Math.max(MIN_LIST_WIDTH, width)));
|
||||
}
|
||||
|
||||
/** The stored width, or null when there is none or it is not a number. */
|
||||
export function readStoredWidth(): number | null {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(PANE_KEY);
|
||||
const value = raw === null ? NaN : Number(raw);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
} catch {
|
||||
// Storage can throw (private window, blocked site data); the default holds.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function storeWidth(width: number): void {
|
||||
try {
|
||||
window.localStorage.setItem(PANE_KEY, String(Math.round(width)));
|
||||
} catch {
|
||||
// Not remembered this session; the divider still works.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Reading and answering address lists.
|
||||
*
|
||||
* The compose field used to split on every comma, which cuts a display name
|
||||
* like `"Silva, João" <joao@x.test>` in half and sends to two broken
|
||||
* addresses. Reply-all had no home at all: the composer took one `replyTo`
|
||||
* and nothing else, so answering a thread meant retyping everyone on it.
|
||||
*/
|
||||
|
||||
export interface ParsedAddress {
|
||||
/** The entry as it will be sent: `Name <addr>` or a bare address. */
|
||||
entry: string;
|
||||
/** The mailbox itself, lowercased for comparison. */
|
||||
address: string;
|
||||
/** What a chip shows: the name when there is one, else the address. */
|
||||
label: string;
|
||||
valid: boolean;
|
||||
}
|
||||
|
||||
const MAILBOX = /^[^\s@<>(),;:"\\[\]]+@[^\s@<>(),;:"\\[\]]+\.[^\s@<>(),;:"\\[\]]+$/;
|
||||
|
||||
export function isValidAddress(address: string): boolean {
|
||||
return address.length <= 254 && MAILBOX.test(address.trim());
|
||||
}
|
||||
|
||||
/** The mailbox inside `Name <addr>`, or the text itself when it is bare. */
|
||||
export function addressOf(entry: string): string {
|
||||
const angled = entry.match(/<([^>]*)>\s*$/);
|
||||
return (angled ? angled[1] : entry).trim().toLowerCase();
|
||||
}
|
||||
|
||||
function nameOf(entry: string): string {
|
||||
const angled = entry.match(/^\s*"?(.*?)"?\s*<[^>]*>\s*$/);
|
||||
return angled ? angled[1].replace(/\\(.)/g, "$1").trim() : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits text on commas, semicolons and line breaks that are not inside a
|
||||
* quoted name or an angle-bracketed address.
|
||||
*/
|
||||
function splitOutsideQuotes(text: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let current = "";
|
||||
let quoted = false;
|
||||
let angled = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (quoted && ch === "\\" && i + 1 < text.length) {
|
||||
current += ch + text[++i];
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') quoted = !quoted;
|
||||
else if (!quoted && ch === "<") angled = true;
|
||||
else if (!quoted && ch === ">") angled = false;
|
||||
|
||||
if (!quoted && !angled && (ch === "," || ch === ";" || ch === "\n" || ch === "\r")) {
|
||||
parts.push(current);
|
||||
current = "";
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
parts.push(current);
|
||||
return parts.map((p) => p.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
export function parseAddress(raw: string): ParsedAddress {
|
||||
const entry = raw.trim();
|
||||
const address = addressOf(entry);
|
||||
const name = nameOf(entry);
|
||||
return { entry, address, label: name || address, valid: isValidAddress(address) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses pasted or typed text into addresses.
|
||||
*
|
||||
* A run of bare addresses separated only by spaces - what copying from a mail
|
||||
* header or a spreadsheet produces - is split as well, but only when every
|
||||
* piece is itself an address, so a display name with spaces is never broken.
|
||||
*/
|
||||
export function parseAddressList(text: string): ParsedAddress[] {
|
||||
const out: ParsedAddress[] = [];
|
||||
for (const part of splitOutsideQuotes(text)) {
|
||||
const pieces = part.split(/\s+/);
|
||||
if (pieces.length > 1 && !/[<>"]/.test(part) && pieces.every((p) => isValidAddress(p))) {
|
||||
for (const p of pieces) out.push(parseAddress(p));
|
||||
} else {
|
||||
out.push(parseAddress(part));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The list without repeats, comparing mailboxes and keeping the first entry. */
|
||||
export function dedupe(entries: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
return entries.filter((entry) => {
|
||||
const key = addressOf(entry);
|
||||
if (!key || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
interface Thread {
|
||||
from: string;
|
||||
to: string[];
|
||||
cc: string[];
|
||||
}
|
||||
|
||||
export interface Recipients {
|
||||
to: string[];
|
||||
cc: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Who a plain reply goes to: the sender. Answering a message the user wrote
|
||||
* themselves - from Sent - means following up with its recipients, not
|
||||
* writing to oneself.
|
||||
*/
|
||||
export function replyRecipients(message: Thread, own: string): Recipients {
|
||||
const me = own.trim().toLowerCase();
|
||||
if (me && addressOf(message.from) === me) {
|
||||
return { to: dedupe(message.to), cc: [] };
|
||||
}
|
||||
return { to: [message.from], cc: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Who reply-all goes to: the sender and everyone else who was addressed.
|
||||
*
|
||||
* The user's own address is dropped everywhere - mailing oneself on every
|
||||
* reply is the classic reply-all mistake - and nobody is listed twice, in
|
||||
* To and again in Cc.
|
||||
*/
|
||||
export function replyAllRecipients(message: Thread, own: string): Recipients {
|
||||
const me = own.trim().toLowerCase();
|
||||
const notMe = (entry: string) => addressOf(entry) !== me;
|
||||
|
||||
const base = replyRecipients(message, own);
|
||||
const to = dedupe([...base.to, ...message.to].filter(notMe));
|
||||
const taken = new Set(to.map(addressOf));
|
||||
const cc = dedupe(message.cc.filter(notMe)).filter((entry) => !taken.has(addressOf(entry)));
|
||||
return { to, cc };
|
||||
}
|
||||
|
||||
/** Whether reply-all would reach anyone a plain reply would not. */
|
||||
export function offersReplyAll(message: Thread, own: string): boolean {
|
||||
const single = replyRecipients(message, own);
|
||||
const all = replyAllRecipients(message, own);
|
||||
const reached = new Set(single.to.map(addressOf));
|
||||
return [...all.to, ...all.cc].some((entry) => !reached.has(addressOf(entry)));
|
||||
}
|
||||
|
||||
/** `Re:` or `Fwd:` once, however many times the subject has been through. */
|
||||
export function prefixSubject(subject: string, mode: "reply" | "forward"): string {
|
||||
if (!subject) return "";
|
||||
if (mode === "forward") return `Fwd: ${subject.replace(/^((Fwd?|Enc|Rv):\s*)+/i, "")}`;
|
||||
return `Re: ${subject.replace(/^((Re|Res):\s*)+/i, "")}`;
|
||||
}
|
||||
@@ -53,7 +53,7 @@ export function sanitizeEmailHtml(rawHtml: string): string {
|
||||
],
|
||||
ALLOWED_ATTR: [
|
||||
"align", "alt", "bgcolor", "border", "cellpadding", "cellspacing", "cite",
|
||||
"class", "color", "colspan", "dir", "height", "href", "lang", "rowspan",
|
||||
"background", "class", "color", "colspan", "dir", "height", "href", "lang", "rowspan",
|
||||
"src", "style", "title", "width", "target", "rel", "data-blocked-src",
|
||||
],
|
||||
// style, link, meta and base are gone: a stylesheet in a message can cover
|
||||
@@ -67,24 +67,44 @@ export function sanitizeEmailHtml(rawHtml: string): string {
|
||||
// message's own inline parts, and rejecting them stripped the src from
|
||||
// every embedded picture. image/svg+xml is deliberately absent: an SVG is
|
||||
// a document that carries script.
|
||||
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|cid|tel):|data:image\/(?:png|gif|jpe?g|webp|bmp|x-icon|vnd\.microsoft\.icon);)/i,
|
||||
//
|
||||
// The last two alternatives are DOMPurify's own default for values that are
|
||||
// not a URL at all ("600", "50%", "center", "#fff"). A custom pattern
|
||||
// replaces that default, and without them every attribute whose value is
|
||||
// not a link - width, height, colspan, align, bgcolor, cellpadding - was
|
||||
// stripped, which is what flattened table layouts. A value with an
|
||||
// unlisted scheme (javascript:, vbscript:, data:text/html) matches neither:
|
||||
// the scheme name has to be followed by something other than a colon.
|
||||
ALLOWED_URI_REGEXP:
|
||||
/^(?:(?:https?|mailto|cid|tel):|data:image\/(?:png|gif|jpe?g|webp|bmp|x-icon|vnd\.microsoft\.icon);|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i,
|
||||
});
|
||||
}
|
||||
|
||||
/** Marks a CSS url() the browser cannot fetch, and that unblocking can undo. */
|
||||
const BLOCKED_SCHEME = "lm-blocked-";
|
||||
|
||||
/**
|
||||
* Replaces remote image sources with data-blocked-src so a tracking pixel
|
||||
* cannot fire before the reader asks for images.
|
||||
* Holds remote images back so a tracking pixel cannot fire before the reader
|
||||
* asks for images.
|
||||
*
|
||||
* Every rewrite is reversible. Backgrounds used to be deleted outright, so
|
||||
* choosing "show images" restored the <img> tags and never the banner behind
|
||||
* them - the part of a marketing message that carries its look.
|
||||
*/
|
||||
export function blockRemoteImages(html: string): string {
|
||||
if (!html) return "";
|
||||
return html
|
||||
.replace(/(<img\b[^>]*?\b)src\s*=\s*("|')(https?:[^"']*)\2/gi, "$1data-blocked-src=$2$3$2")
|
||||
.replace(/(<[^>]+)background\s*=\s*("|')https?:[^"']*\2/gi, "$1")
|
||||
.replace(/url\(\s*['"]?https?:[^)]*\)/gi, "none");
|
||||
.replace(/(<[^>]+?\s)background\s*=\s*("|')(https?:[^"']*)\2/gi, "$1data-blocked-background=$2$3$2")
|
||||
// An unknown scheme is never fetched, and is not a valid image either.
|
||||
.replace(/url\(\s*(['"]?)(https?:)/gi, `url($1${BLOCKED_SCHEME}$2`);
|
||||
}
|
||||
|
||||
/** Restores the blocked sources once the reader opts in. */
|
||||
export function unblockRemoteImages(html: string): string {
|
||||
if (!html) return "";
|
||||
return html.replace(/(<img[^>]+)data-blocked-src\s*=\s*("|')([^"']+)\2/gi, "$1src=$2$3$2");
|
||||
return html
|
||||
.replace(/(<img[^>]+)data-blocked-src\s*=\s*("|')([^"']+)\2/gi, "$1src=$2$3$2")
|
||||
.replace(/(<[^>]+?\s)data-blocked-background\s*=\s*("|')([^"']+)\2/gi, "$1background=$2$3$2")
|
||||
.replace(new RegExp(`${BLOCKED_SCHEME}(https?:)`, "gi"), "$1");
|
||||
}
|
||||
@@ -121,8 +121,12 @@ describe("the reader pane", () => {
|
||||
const page = code(MAIL_PAGE);
|
||||
expect(page).not.toContain("h-[500px]");
|
||||
expect(page).toContain("lm:reader-height");
|
||||
// The height report needs script inside the frame, which sandbox="" forbids.
|
||||
expect(page).toContain("allow-scripts");
|
||||
// The frame itself lives in the reader component. Its height report needs
|
||||
// script inside it, which sandbox="" forbids - and it must never be given
|
||||
// the app's own origin.
|
||||
const reader = code(resolve(process.cwd(), "src/components/MessageReader.tsx"));
|
||||
expect(reader).toContain("allow-scripts");
|
||||
expect(reader).not.toContain("allow-same-origin");
|
||||
});
|
||||
|
||||
// Every one of these was a Portuguese string typed into a component that had
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
badgeCount,
|
||||
messageCounts,
|
||||
filterMessages,
|
||||
applyFlagged,
|
||||
listHeaderCount,
|
||||
applySeen,
|
||||
type FolderSummary,
|
||||
@@ -80,16 +81,28 @@ describe("the number on a sidebar folder", () => {
|
||||
const messages: MessageSummary[] = [
|
||||
{ uid: 1, seen: false, has_attachments: true },
|
||||
{ uid: 2, seen: true, has_attachments: false },
|
||||
{ uid: 3, seen: false, has_attachments: false },
|
||||
{ uid: 3, seen: false, has_attachments: false, flagged: true },
|
||||
];
|
||||
|
||||
describe("important messages", () => {
|
||||
it("filters to the flagged ones", () => {
|
||||
expect(filterMessages(messages, "important").map((m) => m.uid)).toEqual([3]);
|
||||
});
|
||||
|
||||
it("sets and clears the flag on one row only", () => {
|
||||
const on = applyFlagged(messages, 1, true);
|
||||
expect(on.map((m) => !!m.flagged)).toEqual([true, false, true]);
|
||||
expect(applyFlagged(on, 1, false).map((m) => !!m.flagged)).toEqual([false, false, true]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("counting the loaded page", () => {
|
||||
it("counts what is on it by unread and by attachment", () => {
|
||||
expect(messageCounts(messages)).toEqual({ loaded: 3, unread: 2, withAttachments: 1 });
|
||||
expect(messageCounts(messages)).toEqual({ loaded: 3, unread: 2, withAttachments: 1, important: 1 });
|
||||
});
|
||||
|
||||
it("counts an empty page as zeros", () => {
|
||||
expect(messageCounts([])).toEqual({ loaded: 0, unread: 0, withAttachments: 0 });
|
||||
expect(messageCounts([])).toEqual({ loaded: 0, unread: 0, withAttachments: 0, important: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { clampListWidth, DEFAULT_LIST_WIDTH, MIN_LIST_WIDTH, MIN_READER_WIDTH } from "./lib/paneWidth";
|
||||
|
||||
describe("clampListWidth", () => {
|
||||
it("keeps a width that fits", () => {
|
||||
expect(clampListWidth(500, 1400)).toBe(500);
|
||||
});
|
||||
|
||||
it("never lets the list shrink below its minimum", () => {
|
||||
expect(clampListWidth(50, 1400)).toBe(MIN_LIST_WIDTH);
|
||||
});
|
||||
|
||||
it("always leaves the reader its minimum width", () => {
|
||||
expect(clampListWidth(5000, 1400)).toBe(1400 - MIN_READER_WIDTH);
|
||||
});
|
||||
|
||||
it("holds the minimum on a window too narrow for both", () => {
|
||||
expect(clampListWidth(900, 600)).toBe(MIN_LIST_WIDTH);
|
||||
});
|
||||
|
||||
it("treats infinity as the widest allowed", () => {
|
||||
expect(clampListWidth(Infinity, 1400)).toBe(1400 - MIN_READER_WIDTH);
|
||||
});
|
||||
|
||||
it("falls back to the default for a value that is not a number", () => {
|
||||
expect(clampListWidth(NaN, 1400)).toBe(DEFAULT_LIST_WIDTH);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
parseAddressList,
|
||||
isValidAddress,
|
||||
addressOf,
|
||||
dedupe,
|
||||
replyRecipients,
|
||||
replyAllRecipients,
|
||||
offersReplyAll,
|
||||
prefixSubject,
|
||||
} from "./lib/recipients";
|
||||
|
||||
describe("parseAddressList", () => {
|
||||
it("splits on commas, semicolons and line breaks", () => {
|
||||
const list = parseAddressList("a@x.test, b@x.test;c@x.test\nd@x.test");
|
||||
expect(list.map((p) => p.address)).toEqual(["a@x.test", "b@x.test", "c@x.test", "d@x.test"]);
|
||||
});
|
||||
|
||||
it("does not cut a quoted name that contains a comma", () => {
|
||||
const list = parseAddressList('"Silva, João" <joao@x.test>, ana@x.test');
|
||||
expect(list).toHaveLength(2);
|
||||
expect(list[0].address).toBe("joao@x.test");
|
||||
expect(list[0].label).toBe("Silva, João");
|
||||
expect(list[0].valid).toBe(true);
|
||||
});
|
||||
|
||||
it("splits bare addresses separated only by spaces", () => {
|
||||
expect(parseAddressList("a@x.test b@x.test").map((p) => p.address)).toEqual(["a@x.test", "b@x.test"]);
|
||||
});
|
||||
|
||||
it("keeps a display name with spaces whole", () => {
|
||||
const list = parseAddressList("Maria da Silva <maria@x.test>");
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].label).toBe("Maria da Silva");
|
||||
});
|
||||
|
||||
it("flags what is not an address", () => {
|
||||
const list = parseAddressList("not-an-address, ok@x.test");
|
||||
expect(list.map((p) => p.valid)).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it("ignores empty segments", () => {
|
||||
expect(parseAddressList(" , ;; ")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidAddress / addressOf", () => {
|
||||
it("accepts ordinary addresses and rejects malformed ones", () => {
|
||||
expect(isValidAddress("a.b+c@sub.example.test")).toBe(true);
|
||||
for (const bad of ["a@", "@x.test", "a b@x.test", "a@x", "a@@x.test", "<a@x.test>"]) {
|
||||
expect(isValidAddress(bad)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("reads the mailbox out of a display form, lowercased", () => {
|
||||
expect(addressOf('Jenny <Jenny@X.test>')).toBe("jenny@x.test");
|
||||
expect(addressOf("BARE@x.test")).toBe("bare@x.test");
|
||||
});
|
||||
});
|
||||
|
||||
const thread = {
|
||||
from: "Alice <alice@x.test>",
|
||||
to: ["me@mail.test", "Bob <bob@x.test>"],
|
||||
cc: ["carol@x.test", "ME@mail.test"],
|
||||
};
|
||||
|
||||
describe("replyRecipients", () => {
|
||||
it("answers the sender", () => {
|
||||
expect(replyRecipients(thread, "me@mail.test")).toEqual({ to: ["Alice <alice@x.test>"], cc: [] });
|
||||
});
|
||||
|
||||
it("answers the original recipients when the user wrote the message", () => {
|
||||
const sent = { from: "Me <me@mail.test>", to: ["bob@x.test"], cc: [] };
|
||||
expect(replyRecipients(sent, "me@mail.test").to).toEqual(["bob@x.test"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("replyAllRecipients", () => {
|
||||
it("reaches everyone but the user, with no repeats", () => {
|
||||
const got = replyAllRecipients(thread, "me@mail.test");
|
||||
expect(got.to).toEqual(["Alice <alice@x.test>", "Bob <bob@x.test>"]);
|
||||
expect(got.cc).toEqual(["carol@x.test"]);
|
||||
});
|
||||
|
||||
it("never lists someone in both To and Cc", () => {
|
||||
const got = replyAllRecipients(
|
||||
{ from: "a@x.test", to: ["me@mail.test"], cc: ["A@x.test", "d@x.test"] },
|
||||
"me@mail.test",
|
||||
);
|
||||
expect(got.to).toEqual(["a@x.test"]);
|
||||
expect(got.cc).toEqual(["d@x.test"]);
|
||||
});
|
||||
|
||||
it("drops the user even with different casing", () => {
|
||||
const got = replyAllRecipients(thread, "Me@Mail.Test");
|
||||
expect([...got.to, ...got.cc].some((e) => addressOf(e) === "me@mail.test")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("offersReplyAll", () => {
|
||||
it("is false when only the sender and the user are on the thread", () => {
|
||||
expect(offersReplyAll({ from: "a@x.test", to: ["me@mail.test"], cc: [] }, "me@mail.test")).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when anyone else was addressed", () => {
|
||||
expect(offersReplyAll(thread, "me@mail.test")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dedupe / prefixSubject", () => {
|
||||
it("keeps the first of a repeated mailbox", () => {
|
||||
expect(dedupe(["A <a@x.test>", "a@x.test", "b@x.test"])).toEqual(["A <a@x.test>", "b@x.test"]);
|
||||
});
|
||||
|
||||
it("prefixes once", () => {
|
||||
expect(prefixSubject("Re: Re: hello", "reply")).toBe("Re: hello");
|
||||
expect(prefixSubject("hello", "forward")).toBe("Fwd: hello");
|
||||
expect(prefixSubject("Fwd: hello", "forward")).toBe("Fwd: hello");
|
||||
expect(prefixSubject("", "reply")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -68,7 +68,29 @@ describe("remote content blocking", () => {
|
||||
});
|
||||
|
||||
it("neutralises CSS url() references and background attributes", () => {
|
||||
expect(blockRemoteImages(`<div style="background:url(https://t.test/p.png)">x</div>`)).not.toContain("t.test");
|
||||
expect(blockRemoteImages(`<table background="https://t.test/p.png"><tr><td>x</td></tr></table>`)).not.toContain("t.test");
|
||||
// Nothing may be left that a browser would fetch: no http(s) url() and no
|
||||
// live background attribute.
|
||||
const css = blockRemoteImages(`<div style="background:url(https://t.test/p.png)">x</div>`);
|
||||
expect(css).not.toMatch(/url\(\s*['"]?https?:/i);
|
||||
const attr = blockRemoteImages(`<table background="https://t.test/p.png"><tr><td>x</td></tr></table>`);
|
||||
expect(attr).not.toMatch(/\sbackground=/i);
|
||||
});
|
||||
|
||||
it("restores backgrounds when the reader opts in", () => {
|
||||
const html = `<table background="https://t.test/a.png"><tr><td style="background-image:url('https://t.test/b.png')">x</td></tr></table>`;
|
||||
expect(unblockRemoteImages(blockRemoteImages(html))).toBe(html);
|
||||
});
|
||||
|
||||
it("keeps layout attributes that are not URLs", () => {
|
||||
const out = sanitizeEmailHtml(`<table width="600" cellpadding="0" bgcolor="#fff"><tr><td colspan="2" align="center"><img src="https://t.test/a.png" width="600" height="20"></td></tr></table>`);
|
||||
for (const attr of ['width="600"', 'cellpadding="0"', 'bgcolor="#fff"', 'colspan="2"', 'align="center"', 'height="20"']) {
|
||||
expect(out).toContain(attr);
|
||||
}
|
||||
});
|
||||
|
||||
it("still refuses scripting schemes in attributes", () => {
|
||||
const out = sanitizeEmailHtml(`<a href="javascript:alert(1)">x</a><a href="data:text/html,<script>1</script>">y</a>`);
|
||||
expect(out).not.toMatch(/javascript:/i);
|
||||
expect(out).not.toMatch(/data:text/i);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user