"use client"; import React, { use, useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { motion, AnimatePresence } from "framer-motion"; import { Search, Paperclip, Reply, Forward, Inbox, Image as ImageIcon, Mail, Clock, MailOpen, Download, X, } from "lucide-react"; import { useI18n } from "../../../../../i18n/provider"; import { useMailEvents } from "../../../../../lib/useMailEvents"; import { useFolders, notifyMailStateChanged } from "../../../../../lib/useFolders"; import { folderMetrics, filterMessages, messageCounts, listHeaderCount, applySeen, type ListFilter, } from "../../../../../lib/mailCounts"; import { sanitizeEmailHtml, blockRemoteImages, unblockRemoteImages } from "../../../../../lib/sanitizer"; import { resolveInlineImages, buildReaderDocument, type InlineImages } from "../../../../../lib/emailBody"; import { Button } from "../../../../../components/ui/Button"; interface MessageSummary { uid: number; subject: string; sender_address: string; from_display_name: string; snippet: string; size_bytes: number; received_at: string; has_attachments: boolean; seen: boolean; spam_verdict: string; dmarc_result: string; } interface RenderedMessage { uid: number; subject: string; from: string; to: string[]; cc: string[]; date: string; text: string; html: string; attachments: string[]; // 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; } /** The initials shown in the sender avatar, from a display name or an address. */ function initials(name: string, address: string): string { const source = (name || address || "?").trim(); const parts = source.replace(/[<>"]/g, "").split(/[\s.@_-]+/).filter(Boolean); const letters = parts.slice(0, 2).map((p) => p[0]); return (letters.join("") || "?").toUpperCase(); } /** * A timestamp as a mail client shows one: the time for today, the date for * anything older. A fixed time-only format made every message in the list look * like it arrived today. */ function listTimestamp(iso: string, locale: string): string { const date = new Date(iso); if (Number.isNaN(date.getTime())) return ""; const now = new Date(); const sameDay = date.getDate() === now.getDate() && date.getMonth() === now.getMonth() && date.getFullYear() === now.getFullYear(); return sameDay ? date.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" }) : date.toLocaleDateString(locale, { day: "2-digit", month: "short" }); } function formatBytes(bytes: number): string { if (!bytes || bytes < 0) return ""; const units = ["B", "KB", "MB", "GB"]; let value = bytes; let unit = 0; while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit += 1; } return `${value < 10 && unit > 0 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`; } export default function MailFolderPage({ params }: { params: Promise<{ folder: string }> }) { const { folder } = use(params); // The locale is needed alongside the translator: timestamps are formatted // against it, and a fixed format is why the list rendered every date the same // way whatever language the interface was in. const { t, locale } = useI18n(); const [messages, setMessages] = useState([]); const [selected, setSelected] = useState(null); const [loadRemoteImages, setLoadRemoteImages] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [activeFilter, setActiveFilter] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // The frame is sized from the message rather than pinned to a guess: 500px // clipped every long message and left white space under every short one. const [readerHeight, setReaderHeight] = useState(420); const { folders } = useFolders(); const metrics = folderMetrics(folders, folder); const loadMessages = useCallback( async (search: string) => { setLoading(true); setError(null); try { const query = new URLSearchParams({ folder, ...(search ? { q: search } : {}) }); const res = await fetch(`/api/v1/mail/messages?${query}`, { cache: "no-store" }); if (res.status === 401) { window.location.href = "/user/login"; return; } if (!res.ok) throw new Error(t("errors.serverError")); setMessages(await res.json()); } catch (err) { setError(err instanceof Error ? err.message : t("errors.serverError")); } finally { setLoading(false); } }, [folder, t] ); useMailEvents( useCallback( (event) => { if (event.type === "resync" || event.type === "EmailReceived") { void loadMessages(searchQuery); } }, [loadMessages, searchQuery] ) ); // One effect drives both the first load and the search, debounced. Having a // separate mount effect as well meant every folder change fired two requests // and the later one could be the stale answer. useEffect(() => { const handle = setTimeout(() => void loadMessages(searchQuery), searchQuery ? 250 : 0); return () => clearTimeout(handle); }, [searchQuery, loadMessages]); // The reader frame reports its own height; nothing outside a sandboxed frame // can measure it. useEffect(() => { const onMessage = (event: MessageEvent) => { const data = event.data as { type?: string; height?: number } | null; if (data?.type !== "lm:reader-height" || typeof data.height !== "number") return; // Clamped so a message that reports something absurd cannot produce a // frame kilometres tall or one collapsed to nothing. setReaderHeight(Math.min(20000, Math.max(200, Math.ceil(data.height)))); }; window.addEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage); }, []); /** * Records a read-state change on the server and everywhere it is displayed. * * Opening a message already marked it read server-side, but the list held its * own optimistic copy and the sidebar badge held another, so the three * disagreed until the page was reloaded. */ const setSeen = useCallback( async (uid: number, seen: boolean, { persist }: { persist: boolean }) => { setMessages((current) => applySeen(current, uid, seen)); notifyMailStateChanged(); if (!persist) return; try { await fetch("/api/v1/mail/seen", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ folder, uid, seen }), }); } catch { // The server is the authority; the next push or reload corrects this. } finally { notifyMailStateChanged(); } }, [folder] ); const openMessage = async (uid: number) => { setLoadRemoteImages(false); setReaderHeight(420); try { const res = await fetch(`/api/v1/mail/message/${uid}?folder=${encodeURIComponent(folder)}`, { cache: "no-store", }); if (!res.ok) throw new Error(t("errors.serverError")); setSelected(await res.json()); // Fetching the message is what marks it read on the server, so this only // brings the list and the badges into line with what already happened. void setSeen(uid, true, { persist: false }); } catch (err) { setError(err instanceof Error ? err.message : t("errors.serverError")); } }; const filteredMessages = filterMessages(messages, activeFilter); const counts = messageCounts(messages); const header = listHeaderCount({ folder: metrics, loaded: counts.loaded, searching: searchQuery.trim().length > 0, }); const selectedRow = messages.find((m) => m.uid === selected?.uid); /** * The document the reader frame renders. * * Order matters: sanitise first so nothing downstream handles untrusted * markup, then resolve the message's own inline parts, then block or restore * remote sources - which is the only part the reader chooses. */ const readerDocument = useMemo(() => { if (!selected) return null; if (!selected.html) return null; const sanitized = sanitizeEmailHtml(selected.html); if (!sanitized) return null; const withInline = resolveInlineImages(sanitized, selected.inline_images ?? {}); const body = loadRemoteImages ? unblockRemoteImages(withInline) : blockRemoteImages(withInline); return buildReaderDocument(body); }, [selected, loadRemoteImages]); // Whether anything was actually held back, so the privacy banner is not shown // over a message that has no remote content at all. const hasBlockedContent = useMemo(() => { if (!selected?.html) return false; const sanitized = resolveInlineImages( sanitizeEmailHtml(selected.html), selected.inline_images ?? {} ); return blockRemoteImages(sanitized) !== sanitized; }, [selected]); const chips: { key: ListFilter; label: string; count: number }[] = [ { 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 }, ]; return (
{/* Messages List Column */}
{/* Folder heading, with the counts that belong to the folder rather than to the page of it that happens to be loaded. */}

{folder}

{header.isFolderTotal ? t("mail.messagesInFolder", { count: header.count }) : t("mail.searchMatches", { count: header.count })} {metrics.unread > 0 && ( · {t("mail.unreadCount", { count: metrics.unread })} )}
{/* Search Bar & Filter Chips */}
setSearchQuery(e.target.value)} placeholder={t("mail.searchPlaceholder")} aria-label={t("common.search")} className="w-full pl-9 pr-3 py-2 text-xs rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 transition-colors" />
{chips.map((chip) => ( ))}
{/* Message Items List */}
{loading ? (
{t("common.loading")}
) : error ? (
{error}
) : filteredMessages.length === 0 ? (
{searchQuery ? t("mail.noMatches") : t("mail.noMessages")}
) : ( filteredMessages.map((msg) => { const isSelected = selected?.uid === msg.uid; return ( ); }) )}
{/* Message Reader Column */}
{selected ? ( {/* Toolbar: closing, replying and read state all in one row, so the reader is not the only pane with no way out of itself. */}
{/* Forward carries the subject but no recipient: it used to prefill the original sender, which sends the message straight back to whoever wrote it. */}

{selected.subject || t("mail.noSubject")}

{initials(selectedRow?.from_display_name ?? "", selected.from)}
{selected.from} {selected.date ? new Date(selected.date).toLocaleString(locale) : selectedRow ? new Date(selectedRow.received_at).toLocaleString(locale) : ""}
{/* Recipients are labelled with translated headings; they used to be introduced by a Portuguese word regardless of the interface language. */} {selected.to.length > 0 && (
{t("ui.toRecipients")}: {selected.to.join(", ")}
)} {selected.cc.length > 0 && (
{t("ui.ccRecipients")}: {selected.cc.join(", ")}
)} {selectedRow && selectedRow.size_bytes > 0 && (
{formatBytes(selectedRow.size_bytes)}
)}
{/* Attachment Download Chips */} {selected.attachments.length > 0 && (
{selected.attachments.map((filename, idx) => ( {filename} ))}
)}
{/* Remote content banner, shown only when something was actually held back rather than over every message. */} {hasBlockedContent && (
{t("mail.remoteContentBlocked")}
)} {/* Message Body */} {readerDocument ? (