#34: fix the mail counters, the reader pane, and the state between surfaces
The counts were computed inline in JSX where nothing could test them. The
sidebar matched a folder by a special-use role folders do not always carry, its
badges were fetched once and then froze - new mail did not raise them and
reading a message did not lower it - and the list header printed the size of
the loaded page, capped at 50, as the size of the folder. The rules now live in
mailCounts.ts behind tests, useFolders keeps them current from the server's
push and from this tab's own changes, and each filter chip carries its number.
Inline images never left the server: RenderMessage dropped every part a body
references as cid:, so mail written by any normal client rendered with broken
icons. They now come back as bounded data URIs, refusing image/svg+xml, and the
reader resolves them, sizes its frame from the message instead of a fixed
500px, and renders on the light surface mail assumes.
Reading mail no longer asks for a second factor - the mailbox is behind the
account's own password - while the console still requires one unconditionally,
now collected by a step-up that does not ask again for the password the session
in hand just proved. An admin sign-in issues the webmail session too, so
leaving the console is a link rather than another login.
Two defects found while testing the above:
- The admin sign-in checked the password and the second factor and never the
role, so any account that had enrolled a factor from its own settings could
obtain an admin-audience session.
- Session tokens carried nothing that varied below one second, so signing in
twice within the same second minted the identical token and collided with
the unique index on web_sessions.refresh_token_hash. A correct password
answered 500, and a double-clicked sign-in was enough to do it. Tokens now
carry a jti.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e2ea58a4c9
commit
8b69521c76
25 files changed
+2276
-380
No files matched your search
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { KeyRound, ShieldCheck, AlertCircle, Sliders } from "lucide-react";
|
||||
import { useTranslations } from "../../../../i18n/provider";
|
||||
import { useAccount } from "../../../../lib/useAccount";
|
||||
import { TotpEnrolment, RecoveryCodes } from "../../../../components/TotpEnrolment";
|
||||
|
||||
/**
|
||||
* Crossing from the webmail into the console.
|
||||
*
|
||||
* The password is not asked for again - the session that got here proved it -
|
||||
* so this collects the one thing the console adds over the webmail: the second
|
||||
* factor. Sending an operator who was already signed in to a full sign-in form
|
||||
* was the app forgetting what it had already been told.
|
||||
*
|
||||
* An account with no factor yet is walked through enrolling one here rather
|
||||
* than being sent off to find the settings screen.
|
||||
*/
|
||||
export default function AdminStepUpPage() {
|
||||
const t = useTranslations();
|
||||
const account = useAccount("user");
|
||||
|
||||
const [code, setCode] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [enrolmentToken, setEnrolmentToken] = useState<string | null>(null);
|
||||
const [enrolSecret, setEnrolSecret] = useState<string | null>(null);
|
||||
const [enrolUri, setEnrolUri] = useState<string | null>(null);
|
||||
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
|
||||
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Confirming a fresh enrolment, using the grant the step-up handed back.
|
||||
if (enrolmentToken) {
|
||||
const res = await fetch("/api/v1/user/mfa/totp/confirm", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${enrolmentToken}` },
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.message || t("auth.verificationFailed"));
|
||||
setRecoveryCodes(data.recovery_codes ?? []);
|
||||
setEnrolmentToken(null);
|
||||
setEnrolSecret(null);
|
||||
setCode("");
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch("/api/v1/auth/admin/step-up", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
// No factor enrolled. The grant that comes back permits enrolling one and
|
||||
// nothing else, so the operator can do it without leaving this screen.
|
||||
if (res.status === 403 && data.error === "MFA_ENROLLMENT_REQUIRED" && data.enrollment_token) {
|
||||
const enroll = await fetch("/api/v1/user/mfa/totp/enroll", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${data.enrollment_token}` },
|
||||
});
|
||||
const enrolData = await enroll.json();
|
||||
if (!enroll.ok) throw new Error(enrolData.message || t("auth.enrolmentFailed"));
|
||||
setEnrolmentToken(data.enrollment_token);
|
||||
setEnrolSecret(enrolData.secret);
|
||||
setEnrolUri(enrolData.uri);
|
||||
setCode("");
|
||||
return;
|
||||
}
|
||||
|
||||
// A session with no webmail session behind it cannot step up; the sign-in
|
||||
// is where that starts.
|
||||
if (res.status === 401) {
|
||||
window.location.href = "/user/login?next=/admin/step-up";
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error(data.message || t("auth.verificationFailed"));
|
||||
|
||||
window.location.href = "/admin/dashboard";
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : t("auth.genericError"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center px-4 bg-slate-950">
|
||||
<div className="glass-panel p-8 rounded-2xl max-w-md w-full border border-emerald-900/40 shadow-2xl">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-emerald-600/20 border border-emerald-500/30 flex items-center justify-center text-emerald-400">
|
||||
<Sliders className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-bold text-white">{t("auth.stepUpTitle")}</h1>
|
||||
<p className="text-xs text-slate-400 truncate">
|
||||
{account?.email ? t("auth.stepUpSignedInAs", { email: account.email }) : t("common.loading")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-rose-500/10 border border-rose-500/30 text-rose-300 text-xs flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recoveryCodes ? (
|
||||
<RecoveryCodes
|
||||
codes={recoveryCodes}
|
||||
onContinue={() => setRecoveryCodes(null)}
|
||||
continueLabel={t("common.continue")}
|
||||
/>
|
||||
) : (
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
{enrolSecret ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-indigo-500/30 bg-indigo-500/10 p-3 text-xs text-indigo-300">
|
||||
{t("auth.enrolmentIntro")}
|
||||
</div>
|
||||
<TotpEnrolment secret={enrolSecret} uri={enrolUri} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-3 text-xs text-emerald-300 flex items-start gap-2">
|
||||
<ShieldCheck className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||
<span>{t("auth.stepUpIntro")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="step-up-code" className="mb-1 block text-xs font-medium text-slate-300">
|
||||
{t("auth.totpCodeLabel")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<KeyRound className="w-4 h-4 absolute left-3.5 top-3.5 text-slate-500" />
|
||||
<input
|
||||
id="step-up-code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\s/g, ""))}
|
||||
placeholder="123456"
|
||||
required
|
||||
className="w-full rounded-xl border border-slate-800 bg-slate-900 pl-10 pr-4 py-3 text-center font-mono text-lg tracking-widest text-white focus:border-emerald-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
{/* A recovery code is the way in when the phone is gone, and
|
||||
nothing on the old screens said so. */}
|
||||
<p className="mt-1.5 text-[11px] text-slate-500">{t("auth.orRecoveryCode")}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-medium transition-colors shadow-lg shadow-emerald-600/20 disabled:opacity-50"
|
||||
>
|
||||
{loading ? t("common.loading") : t("auth.verifyCodeButton")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-slate-800 text-center">
|
||||
<Link href="/user/mail/inbox" className="text-xs text-slate-400 hover:text-slate-200 transition-colors">
|
||||
{t("admin.backToWebmail")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Mail,
|
||||
Lock,
|
||||
ShieldCheck,
|
||||
KeyRound,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { Mail, Lock, AlertCircle } from "lucide-react";
|
||||
import { useTranslations } from "../../../../i18n/provider";
|
||||
import { Card } from "../../../../components/ui/Card";
|
||||
import { Button } from "../../../../components/ui/Button";
|
||||
@@ -17,46 +10,40 @@ export default function UserLoginPage() {
|
||||
const t = useTranslations();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [mfaCode, setMfaCode] = useState("");
|
||||
const [challengeToken, setChallengeToken] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
/**
|
||||
* One step, because the mailbox is one step.
|
||||
*
|
||||
* Reading your own mail is behind your own password; the second factor guards
|
||||
* the console, and is collected on the way in there. This screen used to have
|
||||
* a code step as well, so an account that had enrolled a factor could not open
|
||||
* its inbox without its phone.
|
||||
*/
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (challengeToken) {
|
||||
// Step 2: Verify TOTP code
|
||||
const res = await fetch("/api/v1/auth/mfa/verify", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ challenge_token: challengeToken, code: mfaCode }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.message || "Two-factor verification failed");
|
||||
window.location.href = "/user/mail/inbox";
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 1: Password authentication
|
||||
const res = await fetch("/api/v1/auth/user/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.message || "Invalid credentials");
|
||||
if (!res.ok) throw new Error(data.message || t("auth.invalidCredentials"));
|
||||
|
||||
if (data.mfa_required) {
|
||||
setChallengeToken(data.challenge_token);
|
||||
} else {
|
||||
window.location.href = "/user/mail/inbox";
|
||||
}
|
||||
// The destination the middleware attached when it sent an unauthenticated
|
||||
// visitor here, so a deep link survives the sign-in.
|
||||
// A path on this origin only: "//evil.test" also begins with a slash, and
|
||||
// following it would turn the sign-in into an open redirect.
|
||||
const next = new URLSearchParams(window.location.search).get("next");
|
||||
const safe = next && next.startsWith("/") && !next.startsWith("//");
|
||||
window.location.href = safe ? next : "/user/mail/inbox";
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Ocorreu um erro ao autenticar");
|
||||
setError(err instanceof Error ? err.message : t("auth.genericError"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -87,68 +74,46 @@ export default function UserLoginPage() {
|
||||
)}
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-4 text-xs">
|
||||
{!challengeToken ? (
|
||||
<>
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-300 mb-1.5">{t("auth.emailLabel")}</label>
|
||||
<div className="relative">
|
||||
<Mail className="w-4 h-4 absolute left-3.5 top-3 text-slate-500" />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="user@domain.com"
|
||||
required
|
||||
className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-semibold text-slate-300 mb-1.5">{t("auth.passwordLabel")}</label>
|
||||
<div className="relative">
|
||||
<Lock className="w-4 h-4 absolute left-3.5 top-3 text-slate-500" />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="************"
|
||||
required
|
||||
className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<div className="p-3.5 mb-4 rounded-xl bg-indigo-500/15 border border-indigo-500/30 text-indigo-300 text-xs flex items-center gap-2">
|
||||
<ShieldCheck className="w-4 h-4 text-indigo-400 flex-shrink-0" />
|
||||
<span>{t("auth.mfaRequired")}</span>
|
||||
</div>
|
||||
<label className="block font-semibold text-slate-300 mb-1.5">{t("auth.totpCodeLabel")}</label>
|
||||
<div className="relative">
|
||||
<KeyRound className="w-4 h-4 absolute left-3.5 top-3.5 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={mfaCode}
|
||||
onChange={(e) => setMfaCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||
placeholder="123456"
|
||||
maxLength={7}
|
||||
required
|
||||
className="w-full pl-10 pr-4 py-3 rounded-xl bg-slate-900/90 border border-slate-800 text-white text-center tracking-widest text-lg font-mono focus:outline-none focus:border-indigo-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="login-email" className="block font-semibold text-slate-300 mb-1.5">
|
||||
{t("auth.emailLabel")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="w-4 h-4 absolute left-3.5 top-3 text-slate-500" />
|
||||
<input
|
||||
id="login-email"
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="user@domain.com"
|
||||
required
|
||||
className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full mt-2"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? t("common.loading") : challengeToken ? t("auth.verifyCodeButton") : t("auth.signInButton")}
|
||||
<div>
|
||||
<label htmlFor="login-password" className="block font-semibold text-slate-300 mb-1.5">
|
||||
{t("auth.passwordLabel")}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="w-4 h-4 absolute left-3.5 top-3 text-slate-500" />
|
||||
<input
|
||||
id="login-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="************"
|
||||
required
|
||||
className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-slate-900/90 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" variant="primary" size="lg" className="w-full mt-2" disabled={loading}>
|
||||
{loading ? t("common.loading") : t("auth.signInButton")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
@@ -24,29 +24,17 @@ import {
|
||||
import { useTranslations } from "../../../i18n/provider";
|
||||
import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher";
|
||||
import { useAccount, isAdminRole } from "../../../lib/useAccount";
|
||||
|
||||
interface FolderCount {
|
||||
special_use: string;
|
||||
name: string;
|
||||
unread_count: number;
|
||||
}
|
||||
import { useFolders } from "../../../lib/useFolders";
|
||||
import { badgeCount, folderMetrics } from "../../../lib/mailCounts";
|
||||
|
||||
export default function UserWebmailLayout({ children }: { children: React.ReactNode }) {
|
||||
const t = useTranslations();
|
||||
const pathname = usePathname();
|
||||
const [folders, setFolders] = useState<FolderCount[]>([]);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const account = useAccount("user");
|
||||
|
||||
useEffect(() => {
|
||||
void fetch("/api/v1/mail/folders")
|
||||
.then((res) => (res.ok ? res.json() : []))
|
||||
.then(setFolders)
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
const unreadFor = (role: string) =>
|
||||
folders.find((f) => f.special_use === role || f.name.toLowerCase() === role)?.unread_count ?? 0;
|
||||
// Live, not fetched once: these badges used to freeze at the value they had
|
||||
// when the tab was opened.
|
||||
const { folders } = useFolders();
|
||||
|
||||
const navItems = [
|
||||
{ href: "/user/mail/inbox", label: t("mail.inbox"), icon: Inbox, role: "inbox" },
|
||||
@@ -93,31 +81,43 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = pathname === item.href || (item.role === "inbox" && pathname === "/user/mail");
|
||||
const unread = unreadFor(item.role);
|
||||
// Drafts counts what is waiting rather than what is unread; see
|
||||
// badgeCount, which is where that rule is tested.
|
||||
const badge = badgeCount(folders, item.role);
|
||||
const { total } = folderMetrics(folders, item.role);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
title={total > 0 ? t("mail.messagesInFolder", { count: total }) : item.label}
|
||||
className={`flex items-center justify-between px-3.5 py-2.5 rounded-xl transition-all duration-150 ${
|
||||
isActive
|
||||
? "bg-indigo-600/20 text-indigo-300 border border-indigo-500/30 shadow-sm"
|
||||
: "text-slate-400 hover:text-slate-200 hover:bg-slate-800/40 border border-transparent"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon className={`w-4 h-4 ${isActive ? "text-indigo-400" : "text-slate-400"}`} />
|
||||
<span>{item.label}</span>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Icon className={`w-4 h-4 flex-shrink-0 ${isActive ? "text-indigo-400" : "text-slate-400"}`} />
|
||||
<span className="truncate">{item.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
{/* The folder's size, shown quietly beside the badge: the
|
||||
sidebar reported unread only, so there was nowhere in the
|
||||
interface that said how much mail a folder holds. */}
|
||||
{total > 0 && (
|
||||
<span className="text-[10px] font-mono text-slate-500 tabular-nums">{total}</span>
|
||||
)}
|
||||
{badge > 0 && (
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full font-semibold tabular-nums ${
|
||||
isActive ? "bg-indigo-500/40 text-indigo-200" : "bg-indigo-500/20 text-indigo-300"
|
||||
}`}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{unread > 0 && (
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full font-semibold ${
|
||||
isActive ? "bg-indigo-500/40 text-indigo-200" : "bg-slate-800 text-slate-300"
|
||||
}`}
|
||||
>
|
||||
{unread}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
@@ -139,13 +139,13 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
||||
</div>
|
||||
|
||||
{/* The console is reachable from inside the app for the accounts that
|
||||
may open it, which is why there is no longer a surface chooser in
|
||||
front of the sign-in. It is a plain link to the admin sign-in, not a
|
||||
direct jump: the admin audience is a separate token, so crossing
|
||||
over means proving the second factor again. */}
|
||||
may open it. It goes to the step-up rather than to the admin sign-in:
|
||||
the admin audience is a separate token, so crossing over costs a
|
||||
second factor - but not the password, which the session in hand
|
||||
proved already. */}
|
||||
{isAdminRole(account?.role) && (
|
||||
<Link
|
||||
href="/admin/login"
|
||||
href="/admin/step-up"
|
||||
className="flex items-center gap-2.5 p-2.5 rounded-xl border border-emerald-500/25 bg-emerald-500/10 text-emerald-300 hover:border-emerald-500/50 hover:bg-emerald-500/15 transition-all"
|
||||
>
|
||||
<Sliders className="w-4 h-4 flex-shrink-0" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { use, useCallback, useEffect, useState } from "react";
|
||||
import React, { use, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
@@ -9,17 +9,26 @@ import {
|
||||
Reply,
|
||||
Forward,
|
||||
Inbox,
|
||||
Filter,
|
||||
Image as ImageIcon,
|
||||
Mail,
|
||||
Clock,
|
||||
User,
|
||||
ExternalLink,
|
||||
MailOpen,
|
||||
Download,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "../../../../../i18n/provider";
|
||||
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 { Badge } from "../../../../../components/ui/Badge";
|
||||
import { resolveInlineImages, buildReaderDocument, type InlineImages } from "../../../../../lib/emailBody";
|
||||
import { Button } from "../../../../../components/ui/Button";
|
||||
|
||||
interface MessageSummary {
|
||||
@@ -46,19 +55,69 @@ interface RenderedMessage {
|
||||
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);
|
||||
const t = useTranslations();
|
||||
// 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<MessageSummary[]>([]);
|
||||
const [selected, setSelected] = useState<RenderedMessage | null>(null);
|
||||
const [loadRemoteImages, setLoadRemoteImages] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [activeFilter, setActiveFilter] = useState<string | null>(null);
|
||||
const [activeFilter, setActiveFilter] = useState<ListFilter>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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) => {
|
||||
@@ -66,7 +125,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
setError(null);
|
||||
try {
|
||||
const query = new URLSearchParams({ folder, ...(search ? { q: search } : {}) });
|
||||
const res = await fetch(`/api/v1/mail/messages?${query}`);
|
||||
const res = await fetch(`/api/v1/mail/messages?${query}`, { cache: "no-store" });
|
||||
if (res.status === 401) {
|
||||
window.location.href = "/user/login";
|
||||
return;
|
||||
@@ -82,10 +141,6 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
[folder, t]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void loadMessages("");
|
||||
}, [loadMessages]);
|
||||
|
||||
useMailEvents(
|
||||
useCallback(
|
||||
(event) => {
|
||||
@@ -97,87 +152,168 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
)
|
||||
);
|
||||
|
||||
// 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), 250);
|
||||
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)}`);
|
||||
const res = await fetch(`/api/v1/mail/message/${uid}?folder=${encodeURIComponent(folder)}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!res.ok) throw new Error(t("errors.serverError"));
|
||||
const data = await res.json();
|
||||
setSelected(data);
|
||||
setMessages((current) => current.map((m) => (m.uid === uid ? { ...m, seen: true } : m)));
|
||||
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 = messages.filter((msg) => {
|
||||
if (activeFilter === "unread") return !msg.seen;
|
||||
if (activeFilter === "attachment") return msg.has_attachments;
|
||||
return true;
|
||||
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);
|
||||
|
||||
const sanitizedHtml = selected?.html ? sanitizeEmailHtml(selected.html) : null;
|
||||
const bodyHtml = sanitizedHtml
|
||||
? loadRemoteImages
|
||||
? unblockRemoteImages(sanitizedHtml)
|
||||
: blockRemoteImages(sanitizedHtml)
|
||||
: null;
|
||||
/**
|
||||
* 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 (
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Messages List Column */}
|
||||
<div
|
||||
className={`${selected ? "hidden md:flex" : "flex"} w-full md:w-96 border-r border-slate-800/80 flex-col bg-slate-900/40 backdrop-blur-md md:flex-shrink-0`}
|
||||
className={`${selected ? "hidden md:flex" : "flex"} w-full md:w-[22rem] lg:w-96 border-r border-slate-800/80 flex-col bg-slate-900/40 backdrop-blur-md md:flex-shrink-0`}
|
||||
>
|
||||
{/* Folder heading, with the counts that belong to the folder rather
|
||||
than to the page of it that happens to be loaded. */}
|
||||
<div className="px-3.5 pt-3.5 flex items-baseline justify-between gap-2">
|
||||
<h2 className="text-sm font-bold text-slate-100 capitalize truncate">{folder}</h2>
|
||||
<span className="text-[11px] text-slate-400 font-mono tabular-nums flex-shrink-0">
|
||||
{header.isFolderTotal
|
||||
? t("mail.messagesInFolder", { count: header.count })
|
||||
: t("mail.searchMatches", { count: header.count })}
|
||||
{metrics.unread > 0 && (
|
||||
<span className="text-indigo-300"> · {t("mail.unreadCount", { count: metrics.unread })}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Search Bar & Filter Chips */}
|
||||
<div className="p-3.5 border-b border-slate-800 space-y-2.5">
|
||||
<div className="relative">
|
||||
<Search className="w-4 h-4 absolute left-3 top-3 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
type="search"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={`${t("common.search")} (from:, subject:)...`}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 text-[11px] overflow-x-auto pb-0.5">
|
||||
<button
|
||||
onClick={() => setActiveFilter(null)}
|
||||
className={`px-2.5 py-1 rounded-lg font-medium transition-all ${
|
||||
activeFilter === null
|
||||
? "bg-indigo-600 text-white shadow-sm"
|
||||
: "bg-slate-800/60 text-slate-400 hover:text-slate-200"
|
||||
}`}
|
||||
>
|
||||
{t("mail.allMessages")} ({messages.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveFilter("unread")}
|
||||
className={`px-2.5 py-1 rounded-lg font-medium transition-all ${
|
||||
activeFilter === "unread"
|
||||
? "bg-indigo-600 text-white shadow-sm"
|
||||
: "bg-slate-800/60 text-slate-400 hover:text-slate-200"
|
||||
}`}
|
||||
>
|
||||
{t("mail.unreadFilter")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveFilter("attachment")}
|
||||
className={`px-2.5 py-1 rounded-lg font-medium transition-all ${
|
||||
activeFilter === "attachment"
|
||||
? "bg-indigo-600 text-white shadow-sm"
|
||||
: "bg-slate-800/60 text-slate-400 hover:text-slate-200"
|
||||
}`}
|
||||
>
|
||||
{t("mail.attachmentsFilter")}
|
||||
</button>
|
||||
{chips.map((chip) => (
|
||||
<button
|
||||
key={chip.label}
|
||||
type="button"
|
||||
aria-pressed={activeFilter === chip.key}
|
||||
onClick={() => setActiveFilter(chip.key)}
|
||||
className={`px-2.5 py-1 rounded-lg font-medium transition-all whitespace-nowrap flex items-center gap-1.5 ${
|
||||
activeFilter === chip.key
|
||||
? "bg-indigo-600 text-white shadow-sm"
|
||||
: "bg-slate-800/60 text-slate-400 hover:text-slate-200"
|
||||
}`}
|
||||
>
|
||||
<span>{chip.label}</span>
|
||||
{/* Every chip carries its own number: two of the three used to
|
||||
filter to a count nothing on screen disclosed. */}
|
||||
<span className="tabular-nums opacity-80">{chip.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -193,9 +329,9 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
{error}
|
||||
</div>
|
||||
) : filteredMessages.length === 0 ? (
|
||||
<div className="p-8 text-center text-xs text-slate-500 italic flex flex-col items-center gap-2">
|
||||
<div className="p-8 text-center text-xs text-slate-500 flex flex-col items-center gap-2">
|
||||
<Inbox className="w-8 h-8 text-slate-600" />
|
||||
<span>{t("mail.noMessages")}</span>
|
||||
<span>{searchQuery ? t("mail.noMatches") : t("mail.noMessages")}</span>
|
||||
</div>
|
||||
) : (
|
||||
filteredMessages.map((msg) => {
|
||||
@@ -204,43 +340,54 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
<button
|
||||
key={msg.uid}
|
||||
onClick={() => void openMessage(msg.uid)}
|
||||
className={`w-full text-left p-4 transition-all duration-150 block relative ${
|
||||
isSelected
|
||||
? "bg-indigo-600/15 border-l-4 border-indigo-500"
|
||||
: "hover:bg-slate-800/30"
|
||||
className={`w-full text-left p-3.5 transition-all duration-150 flex gap-3 relative ${
|
||||
isSelected ? "bg-indigo-600/15" : "hover:bg-slate-800/30"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
{isSelected && <span className="absolute left-0 top-0 bottom-0 w-[3px] bg-indigo-500" />}
|
||||
{/* An unread row is marked by a dot in a reserved column, so
|
||||
the subject does not shift sideways when it is read. */}
|
||||
<span className="mt-1.5 w-2 flex-shrink-0">
|
||||
{!msg.seen && <span className="block w-2 h-2 rounded-full bg-indigo-400" />}
|
||||
</span>
|
||||
<span
|
||||
className={`mt-0.5 w-8 h-8 rounded-full flex-shrink-0 flex items-center justify-center text-[10px] font-bold ${
|
||||
msg.seen
|
||||
? "bg-slate-800 text-slate-400"
|
||||
: "bg-indigo-500/25 text-indigo-200 border border-indigo-500/30"
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{initials(msg.from_display_name, msg.sender_address)}
|
||||
</span>
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center justify-between gap-2 mb-0.5">
|
||||
<span
|
||||
className={`text-xs truncate ${
|
||||
msg.seen ? "text-slate-300 font-medium" : "text-white font-bold"
|
||||
}`}
|
||||
>
|
||||
{msg.from_display_name || msg.sender_address}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-500 font-mono flex-shrink-0 tabular-nums">
|
||||
{listTimestamp(msg.received_at, locale)}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-semibold truncate max-w-[170px] ${
|
||||
msg.seen ? "text-slate-300" : "text-white font-bold"
|
||||
className={`text-xs truncate flex items-center gap-1.5 mb-0.5 ${
|
||||
msg.seen ? "text-slate-300" : "font-semibold text-slate-100"
|
||||
}`}
|
||||
>
|
||||
{msg.from_display_name || msg.sender_address}
|
||||
<span className="truncate">{msg.subject || t("mail.noSubject")}</span>
|
||||
{msg.has_attachments && (
|
||||
<Paperclip className="w-3 h-3 text-slate-400 flex-shrink-0" />
|
||||
)}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-500 font-mono">
|
||||
{new Date(msg.received_at).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
<span className="block text-[11px] text-slate-500 truncate leading-relaxed">
|
||||
{msg.snippet}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`text-xs truncate mb-1 flex items-center gap-1.5 ${
|
||||
msg.seen ? "text-slate-300" : "font-bold text-slate-100"
|
||||
}`}
|
||||
>
|
||||
{!msg.seen && (
|
||||
<span className="w-2 h-2 rounded-full bg-indigo-500 flex-shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{msg.subject || "(Sem assunto)"}</span>
|
||||
{msg.has_attachments && (
|
||||
<Paperclip className="w-3.5 h-3.5 text-slate-400 flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-400 truncate leading-relaxed">
|
||||
{msg.snippet}
|
||||
</div>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
@@ -250,142 +397,179 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
|
||||
{/* Message Reader Column */}
|
||||
<div
|
||||
className={`${selected ? "flex" : "hidden md:flex"} flex-1 flex-col bg-dark-bg overflow-y-auto p-4 sm:p-6 md:p-8`}
|
||||
className={`${selected ? "flex" : "hidden md:flex"} flex-1 flex-col bg-dark-bg overflow-y-auto`}
|
||||
>
|
||||
{selected && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected(null)}
|
||||
className="md:hidden mb-4 self-start rounded-lg border border-slate-700 px-3 py-1.5 text-xs text-slate-300"
|
||||
>
|
||||
← {t("mail.inbox")}
|
||||
</button>
|
||||
)}
|
||||
<AnimatePresence mode="wait">
|
||||
{selected ? (
|
||||
<motion.div
|
||||
<motion.article
|
||||
key={selected.uid}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="max-w-4xl w-full mx-auto space-y-6"
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="w-full max-w-3xl mx-auto px-4 sm:px-6 py-5 space-y-5"
|
||||
>
|
||||
{/* Header & Reply/Forward Actions */}
|
||||
<div className="glass-panel p-6 rounded-2xl border border-slate-800 space-y-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-slate-800/80 pb-4">
|
||||
<h1 className="text-xl md:text-2xl font-extrabold text-white tracking-tight">
|
||||
{selected.subject || "(Sem assunto)"}
|
||||
</h1>
|
||||
{/* 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 items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected(null)}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-slate-800 bg-slate-900/70 px-2.5 py-1.5 text-xs text-slate-300 hover:text-white hover:border-slate-700 transition-colors"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
<span>{t("mail.backToList")}</span>
|
||||
</button>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Link
|
||||
href={`/user/compose?replyTo=${encodeURIComponent(
|
||||
selected.from
|
||||
)}&subject=${encodeURIComponent(selected.subject)}`}
|
||||
>
|
||||
<Button variant="primary" size="sm">
|
||||
<Reply className="w-3.5 h-3.5" />
|
||||
<span>{t("mail.reply")}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={`/user/compose?replyTo=${encodeURIComponent(
|
||||
selected.from
|
||||
)}&subject=${encodeURIComponent(selected.subject)}`}
|
||||
>
|
||||
<Button variant="secondary" size="sm">
|
||||
<Forward className="w-3.5 h-3.5" />
|
||||
<span>{t("mail.forward")}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const nowSeen = !(selectedRow?.seen ?? true);
|
||||
void setSeen(selected.uid, nowSeen, { persist: true });
|
||||
}}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-slate-800 bg-slate-900/70 px-2.5 py-1.5 text-slate-300 hover:text-white hover:border-slate-700 transition-colors"
|
||||
>
|
||||
<MailOpen className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">
|
||||
{selectedRow?.seen === false ? t("mail.markRead") : t("mail.markUnread")}
|
||||
</span>
|
||||
</button>
|
||||
<Link
|
||||
href={`/user/compose?replyTo=${encodeURIComponent(selected.from)}&subject=${encodeURIComponent(selected.subject)}`}
|
||||
>
|
||||
<Button variant="primary" size="sm">
|
||||
<Reply className="w-3.5 h-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. */}
|
||||
<Link href={`/user/compose?subject=${encodeURIComponent(selected.subject)}`}>
|
||||
<Button variant="secondary" size="sm">
|
||||
<Forward className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">{t("mail.forward")}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-indigo-500/20 border border-indigo-500/30 text-indigo-400 flex items-center justify-center font-bold">
|
||||
<User className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-slate-100 text-xs">{selected.from}</div>
|
||||
{selected.to.length > 0 && (
|
||||
<div className="text-[10px] text-slate-400">
|
||||
Para: {selected.to.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<header className="space-y-4">
|
||||
<h1 className="text-lg md:text-xl font-bold text-white tracking-tight leading-snug break-words">
|
||||
{selected.subject || t("mail.noSubject")}
|
||||
</h1>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-full bg-gradient-to-tr from-indigo-500/30 to-cyan-500/20 border border-indigo-500/30 text-indigo-200 flex items-center justify-center text-[11px] font-bold flex-shrink-0">
|
||||
{initials(selectedRow?.from_display_name ?? "", selected.from)}
|
||||
</div>
|
||||
<div className="font-mono text-slate-400 text-[11px]">
|
||||
{selected.date ? new Date(selected.date).toLocaleString() : ""}
|
||||
<div className="min-w-0 flex-1 text-xs">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<span className="font-semibold text-slate-100 break-all">{selected.from}</span>
|
||||
<span className="text-[11px] text-slate-500 font-mono">
|
||||
{selected.date
|
||||
? new Date(selected.date).toLocaleString(locale)
|
||||
: selectedRow
|
||||
? new Date(selectedRow.received_at).toLocaleString(locale)
|
||||
: ""}
|
||||
</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="text-[11px] text-slate-400 break-all">
|
||||
<span className="text-slate-500">{t("ui.toRecipients")}: </span>
|
||||
{selected.to.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
{selected.cc.length > 0 && (
|
||||
<div className="text-[11px] text-slate-400 break-all">
|
||||
<span className="text-slate-500">{t("ui.ccRecipients")}: </span>
|
||||
{selected.cc.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
{selectedRow && selectedRow.size_bytes > 0 && (
|
||||
<div className="text-[11px] text-slate-500 font-mono">
|
||||
{formatBytes(selectedRow.size_bytes)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Attachment Download Chips */}
|
||||
{selected.attachments.length > 0 && (
|
||||
<div className="pt-3 border-t border-slate-800/80 flex flex-wrap gap-2 text-xs">
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
{selected.attachments.map((filename, idx) => (
|
||||
<a
|
||||
key={idx}
|
||||
href={`/api/v1/mail/message/${selected.uid}/attachment/${encodeURIComponent(
|
||||
filename
|
||||
)}`}
|
||||
key={`${filename}-${idx}`}
|
||||
href={`/api/v1/mail/message/${selected.uid}/attachment/${encodeURIComponent(filename)}?folder=${encodeURIComponent(folder)}`}
|
||||
download={filename}
|
||||
className="px-3 py-1.5 rounded-xl bg-slate-900/90 border border-slate-800 hover:border-indigo-500 text-indigo-300 text-xs font-mono flex items-center gap-2 transition-all shadow-sm"
|
||||
className="group max-w-full px-3 py-2 rounded-xl bg-slate-900/80 border border-slate-800 hover:border-indigo-500/60 hover:bg-slate-900 text-slate-200 flex items-center gap-2 transition-all"
|
||||
>
|
||||
<Paperclip className="w-3.5 h-3.5 text-indigo-400" />
|
||||
<span>{filename}</span>
|
||||
<ExternalLink className="w-3 h-3 text-slate-500" />
|
||||
<Paperclip className="w-3.5 h-3.5 text-indigo-400 flex-shrink-0" />
|
||||
<span className="truncate font-mono text-[11px]">{filename}</span>
|
||||
<Download className="w-3.5 h-3.5 text-slate-500 group-hover:text-indigo-300 flex-shrink-0" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Remote Images Privacy Banner */}
|
||||
{selected.html && (
|
||||
<div className="p-3.5 rounded-xl bg-amber-500/10 border border-amber-500/25 flex items-center justify-between text-xs text-amber-300">
|
||||
<div className="flex items-center gap-2">
|
||||
<ImageIcon className="w-4 h-4 text-amber-400" />
|
||||
<span>{t("mail.loadImages")} (sanitised)</span>
|
||||
</div>
|
||||
{/* Remote content banner, shown only when something was actually
|
||||
held back rather than over every message. */}
|
||||
{hasBlockedContent && (
|
||||
<div className="p-3 rounded-xl bg-amber-500/10 border border-amber-500/25 flex flex-wrap items-center justify-between gap-2 text-xs text-amber-200">
|
||||
<span className="flex items-center gap-2">
|
||||
<ImageIcon className="w-4 h-4 text-amber-400 flex-shrink-0" />
|
||||
<span>{t("mail.remoteContentBlocked")}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLoadRemoteImages(!loadRemoteImages)}
|
||||
className="px-3 py-1 rounded-lg bg-amber-500/20 hover:bg-amber-500/30 text-amber-200 font-medium transition-colors"
|
||||
className="px-3 py-1 rounded-lg bg-amber-500/20 hover:bg-amber-500/30 text-amber-100 font-medium transition-colors"
|
||||
>
|
||||
{loadRemoteImages ? "Imagens Habilitadas" : t("mail.loadImages")}
|
||||
{loadRemoteImages ? t("mail.imagesEnabled") : t("mail.loadImages")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message Body Container */}
|
||||
{bodyHtml ? (
|
||||
<div className="bg-white rounded-2xl overflow-hidden shadow-2xl border border-slate-800 min-h-[350px]">
|
||||
{/* Message Body */}
|
||||
{readerDocument ? (
|
||||
<div className="bg-white rounded-2xl overflow-hidden border border-slate-800 shadow-xl">
|
||||
<iframe
|
||||
title={t("ui.messageBody")}
|
||||
srcDoc={bodyHtml}
|
||||
sandbox=""
|
||||
className="w-full h-[500px] border-0"
|
||||
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="w-full border-0 block"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="glass-panel p-6 rounded-2xl border border-slate-800">
|
||||
<div className="rounded-2xl border border-slate-800 bg-slate-900/60 p-5">
|
||||
<pre className="whitespace-pre-wrap break-words text-sm text-slate-200 font-sans leading-relaxed">
|
||||
{selected.text}
|
||||
{selected.text || t("mail.emptyBody")}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.article>
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex-1 flex flex-col items-center justify-center text-slate-500 text-sm gap-3 py-20"
|
||||
className="flex-1 flex flex-col items-center justify-center text-slate-500 text-sm gap-3 py-20 px-6 text-center"
|
||||
>
|
||||
<Mail className="w-12 h-12 text-slate-700 stroke-1" />
|
||||
<span>{t("mail.noMessages")}</span>
|
||||
{/* This pane used to say "No messages" even with a full list
|
||||
beside it: the empty reader is not an empty folder. */}
|
||||
<span>{t("mail.selectAMessage")}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
Reference in new issue
Block a user