#23: show real data, unify the surfaces and make enrolment scannable

The interface was reporting things that were not true. Several screens were
mockups wired to nothing, and a reader with the console in Portuguese was
served a mixture of two languages.

Fabricated data, removed:

- The outbound queue was two invented jobs held in component state. Retry and
  Freeze only edited that local array, so the screen showed the same two rows
  on every deployment and reported success for actions that never happened.
  "Freeze" named an operation the backend has never implemented. It now reads
  /api/v1/admin/queue, which was already serving the real thing, and offers
  retry and cancel, which exist.
- Every dashboard KPI fell back to an invented constant (1420 received, 890
  sent, 3 queued, 18.4% disk), so an unreachable API produced a dashboard of
  plausible traffic for a server that might have delivered nothing. Two of
  those fields were never returned by the endpoint at all, so the fake disk
  figure was what everyone always saw. DMARC did the same with 1250/98%/97%.
- The domains screen showed thirteen DNS records for example.com, all marked
  VERIFIED, and posted {domain_id: "default-domain"} to endpoints expecting a
  real id. DKIM rotation sends "domain", not "domain_id", so its 400 was
  swallowed and a made-up TXT record displayed as though a key had been
  issued: rotation had never once worked from this screen.
- The TLS panel asserted a Let's Encrypt RSA 4096 certificate and verified
  DANE from static markup, on a deployment currently serving a self-signed
  one. It now reads the cert watcher via /api/v1/admin/tls.
- The readiness scorecard printed "10 / 10 all checks passed" regardless of
  what preflight reported, including failures.
- A message trace that found nothing invented a five-step delivery - TLS 1.3
  accepted, SPF pass, spam score 0.8 - for a message that does not exist.
- Both sidebars showed a fixed admin@lambdamail.local with a hardcoded
  "SUPER_ADMIN (2FA)", claiming a second factor whether or not one existed.
- Settings seeded a "[Boletim]" Sieve rule. Saving posts the whole list, so
  the first edit wrote that invented filter into the account's real script.

The reconcile endpoint itself answered {records_verified: 13, status:
"VERIFIED"} for any domain id, including one that does not exist, without
resolving a name. It now reports what it does, which is record the request.

Surfaces:

The root was a chooser listing webmail and the console side by side, which
asked visitors a question they should not have to answer and advertised cookie
names and audiences to anonymous readers. It now redirects into the mail
client. Accounts that may administer the server get a link to the console from
inside the app - a link to /admin/login, so crossing over still costs the
second factor the admin audience requires - and the console gets a plain link
back, since the webmail session was never given up to get there.

Enrolment:

Both enrolment screens printed the base32 secret as text and left the operator
to type 32 characters into their phone. The enrol endpoint already returned an
otpauth:// URI; it simply was not drawn. Added a shared component that renders
it as a QR locally - the seed is already in the page, and sending it to an
image service would put it in another request log for nothing.

The recovery-code step rendered its own "sign in" button while the form's
submit button stayed visible below it: two identical buttons, and no way to
copy the codes it insisted you save. It is now terminal, with copy and
continue.

Language:

translate() had no interpolation, so "Undo Send ({seconds}s remaining)" showed
the placeholder and a second hardcoded countdown was appended after it. It now
substitutes, leaving an unmatched token visible rather than blanking it.

The pt-BR bundle had lost its diacritics - "Destinatario", "Ultimo erro",
"Acoes" - from the ASCII-source rule being applied to the translations, which
are data. Restored, and the three bundles are back in key parity at 270 each,
with roughly 120 strings that were hardcoded in the source now keyed.

Healthchecks:

web had none, despite being the only service users reach directly: a wedged
Next process kept its container running and Traefik kept routing to it. Added,
using node's own fetch since the image ships no curl. rspamd and clamav gained
the timeouts and start_periods whose absence would fail them during startup.
migrate stays without one and says why: it is a one-shot job, and a probe
would report unhealthy exactly when it has succeeded.

Verified: 36 webmail tests and 41 auth tests against a real Postgres, both new
admin/me cases confirmed to fail without the route (404). lang-lint clean,
production build clean, 0 vulnerabilities, compose validates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-08-02 19:13:53 -03:00
1 parent f7e93275d5
commit ee240ae227
29 files changed
+2201 -1221

No files matched your search

@@ -12,7 +12,6 @@ import {
List,
ListOrdered,
Link as LinkIcon,
Trash2,
Undo2,
X,
Sparkles,
@@ -59,7 +58,7 @@ export default function ComposePage({
useEffect(() => {
if (!to && !subject) return;
const timer = setTimeout(() => {
setDraftStatus("Rascunho salvo automaticamente");
setDraftStatus(t("mail.draftSaved"));
}, 2500);
return () => clearTimeout(timer);
}, [to, subject]);
@@ -148,13 +147,16 @@ export default function ComposePage({
<div className="p-4 rounded-xl bg-indigo-600/20 border border-indigo-500/40 flex items-center justify-between text-indigo-300 text-xs">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-indigo-400 animate-spin" />
<span>
{t("mail.undoSend")} ({undoSeconds}s restantes)
</span>
{/* The countdown is a placeholder inside the message, not text
bolted on after it. It used to be both: the bundle's own
"{seconds}" was never substituted and a second, hardcoded
count was appended, so this read "Undo Send ({seconds}s
remaining) (9s remaining)". */}
<span>{t("mail.undoSend", { seconds: undoSeconds })}</span>
</div>
<Button variant="primary" size="sm" onClick={handleUndo}>
<Undo2 className="w-3.5 h-3.5" />
Desfazer Envio
{t("mail.undoSendAction")}
</Button>
</div>
)}
@@ -168,7 +170,7 @@ export default function ComposePage({
onClick={() => setShowCcBcc(!showCcBcc)}
className="text-indigo-400 hover:text-indigo-300 font-medium hover:underline"
>
{showCcBcc ? "- Ocultar Cc/Bcc" : "+ Adicionar Cc/Bcc"}
{showCcBcc ? t("mail.hideCcBcc") : t("mail.showCcBcc")}
</button>
</div>
<input
@@ -231,7 +233,7 @@ export default function ComposePage({
type="button"
onClick={() => executeFormat("bold")}
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
title="Negrito"
title={t("mail.bold")}
>
<Bold className="w-4 h-4" />
</button>
@@ -239,7 +241,7 @@ export default function ComposePage({
type="button"
onClick={() => executeFormat("italic")}
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
title="Italic"
title={t("mail.italic")}
>
<Italic className="w-4 h-4" />
</button>
@@ -247,7 +249,7 @@ export default function ComposePage({
type="button"
onClick={() => executeFormat("underline")}
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
title="Sublinhado"
title={t("mail.underline")}
>
<Underline className="w-4 h-4" />
</button>
@@ -255,7 +257,7 @@ export default function ComposePage({
type="button"
onClick={() => executeFormat("strikeThrough")}
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
title="Tachado"
title={t("mail.strikethrough")}
>
<Strikethrough className="w-4 h-4" />
</button>
@@ -264,7 +266,7 @@ export default function ComposePage({
type="button"
onClick={() => executeFormat("insertUnorderedList")}
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
title="Lista"
title={t("mail.bulletList")}
>
<List className="w-4 h-4" />
</button>
@@ -272,7 +274,7 @@ export default function ComposePage({
type="button"
onClick={() => executeFormat("insertOrderedList")}
className="p-1.5 hover:bg-slate-800 rounded-lg text-slate-300 hover:text-white"
title="Lista Numerada"
title={t("mail.numberedList")}
>
<ListOrdered className="w-4 h-4" />
</button>
@@ -280,11 +282,11 @@ export default function ComposePage({
<button
type="button"
onClick={() => {
const url = prompt("Insira a URL:");
const url = prompt(t("mail.insertUrl"));
if (url) executeFormat("createLink", url);
}}
className="p-1.5 hover:bg-slate-800 rounded-lg text-indigo-400 hover:text-indigo-300"
title="Inserir Link"
title={t("mail.insertLink")}
>
<LinkIcon className="w-4 h-4" />
</button>
@@ -302,10 +304,10 @@ export default function ComposePage({
{/* Attachment Selector & Display */}
<div className="pt-2">
<div className="flex items-center justify-between mb-2">
<span className="font-semibold text-slate-300">Anexos ({attachments.length})</span>
<span className="font-semibold text-slate-300">{t("mail.attachments", { count: attachments.length })}</span>
<label className="cursor-pointer text-indigo-400 hover:text-indigo-300 flex items-center gap-1.5 font-medium">
<Paperclip className="w-3.5 h-3.5" />
<span>Adicionar Arquivos...</span>
<span>{t("mail.addFiles")}</span>
<input
type="file"
multiple
@@ -315,6 +317,16 @@ export default function ComposePage({
</label>
</div>
{/* Says so plainly rather than letting someone attach a contract
and watch it silently not arrive: /mail/send is handed the file
names only, because there is no upload endpoint behind this
picker yet. */}
{attachments.length > 0 && (
<div className="mb-2 rounded-xl border border-amber-500/30 bg-amber-500/10 p-2.5 text-[11px] text-amber-300">
{t("mail.attachmentsUnsupported")}
</div>
)}
{attachments.length > 0 && (
<div className="flex flex-wrap gap-2">
{attachments.map((file) => (
@@ -343,13 +355,13 @@ export default function ComposePage({
<div className="flex items-center justify-between pt-4 border-t border-slate-800">
<Link href="/user/mail/inbox">
<Button variant="ghost" size="md">
Descartar
{t("mail.discard")}
</Button>
</Link>
<Button type="submit" variant="primary" size="md" disabled={sending}>
<Send className="w-4 h-4" />
<span>{sending ? "Sending..." : "Send message"}</span>
<span>{sending ? t("mail.sending") : t("mail.sendMessage")}</span>
</Button>
</div>
</form>
@@ -16,11 +16,14 @@ import {
LogOut,
Mail,
ShieldCheck,
ShieldAlert,
Sliders,
Menu,
X,
} from "lucide-react";
import { useTranslations } from "../../../i18n/provider";
import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher";
import { useAccount, isAdminRole } from "../../../lib/useAccount";
interface FolderCount {
special_use: string;
@@ -33,6 +36,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
const pathname = usePathname();
const [folders, setFolders] = useState<FolderCount[]>([]);
const [mobileOpen, setMobileOpen] = useState(false);
const account = useAccount("user");
useEffect(() => {
void fetch("/api/v1/mail/folders")
@@ -48,9 +52,9 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
{ href: "/user/mail/inbox", label: t("mail.inbox"), icon: Inbox, role: "inbox" },
{ href: "/user/mail/sent", label: t("mail.sent"), icon: Send, role: "sent" },
{ href: "/user/mail/drafts", label: t("mail.drafts"), icon: FileText, role: "drafts" },
{ href: "/user/mail/archive", label: "Archive", icon: Archive, role: "archive" },
{ href: "/user/mail/junk", label: "Spam", icon: AlertTriangle, role: "junk" },
{ href: "/user/mail/trash", label: "Trash", icon: Trash2, role: "trash" },
{ href: "/user/mail/archive", label: t("mail.archive"), icon: Archive, role: "archive" },
{ href: "/user/mail/junk", label: t("mail.junk"), icon: AlertTriangle, role: "junk" },
{ href: "/user/mail/trash", label: t("mail.trash"), icon: Trash2, role: "trash" },
];
const handleLogout = () => {
@@ -134,6 +138,21 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
</button>
</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. */}
{isAdminRole(account?.role) && (
<Link
href="/admin/login"
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" />
<span className="text-xs font-semibold truncate">{t("admin.openAdmin")}</span>
</Link>
)}
<Link
href="/user/settings"
className={`flex items-center justify-between p-2.5 rounded-xl border transition-all ${
@@ -143,13 +162,27 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
}`}
>
<div className="flex items-center gap-2.5 min-w-0">
<div className="w-8 h-8 rounded-full bg-gradient-to-tr from-indigo-500 to-cyan-500 text-white flex items-center justify-center font-bold text-xs flex-shrink-0 shadow-sm">
U
<div className="w-8 h-8 rounded-full bg-gradient-to-tr from-indigo-500 to-cyan-500 text-white flex items-center justify-center font-bold text-xs flex-shrink-0 shadow-sm uppercase">
{account?.email?.[0] ?? "?"}
</div>
<div className="truncate">
<div className="text-xs font-semibold text-slate-200 truncate">user@lambdamail.local</div>
<div className="text-xs font-semibold text-slate-200 truncate">
{account?.email ?? t("common.loading")}
</div>
{/* Reports what is actually configured. The old fixed "2FA
active" reassured accounts that had no second factor at all. */}
<div className="text-[10px] text-slate-400 flex items-center gap-1">
<ShieldCheck className="w-3 h-3 text-emerald-400 inline" /> 2FA Ativo
{account?.mfa_enrolled ? (
<>
<ShieldCheck className="w-3 h-3 text-emerald-400 inline" />
{t("settings.mfaEnabled")}
</>
) : (
<>
<ShieldAlert className="w-3 h-3 text-amber-400 inline" />
{t("settings.mfaDisabled")}
</>
)}
</div>
</div>
</div>
@@ -8,10 +8,8 @@ import {
Paperclip,
Reply,
Forward,
ShieldAlert,
Inbox,
Filter,
CheckCircle2,
Image as ImageIcon,
Mail,
Clock,
@@ -158,7 +156,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
: "bg-slate-800/60 text-slate-400 hover:text-slate-200"
}`}
>
Todos ({messages.length})
{t("mail.allMessages")} ({messages.length})
</button>
<button
onClick={() => setActiveFilter("unread")}
@@ -168,7 +166,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
: "bg-slate-800/60 text-slate-400 hover:text-slate-200"
}`}
>
Unread
{t("mail.unreadFilter")}
</button>
<button
onClick={() => setActiveFilter("attachment")}
@@ -178,7 +176,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
: "bg-slate-800/60 text-slate-400 hover:text-slate-200"
}`}
>
Anexos
{t("mail.attachmentsFilter")}
</button>
</div>
</div>
@@ -366,7 +364,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
{bodyHtml ? (
<div className="bg-white rounded-2xl overflow-hidden shadow-2xl border border-slate-800 min-h-[350px]">
<iframe
title="Message body"
title={t("ui.messageBody")}
srcDoc={bodyHtml}
sandbox=""
className="w-full h-[500px] border-0"
@@ -12,12 +12,12 @@ import {
Trash2,
Plus,
Lock,
Sparkles,
} from "lucide-react";
import { useTranslations } from "../../../../i18n/provider";
import { Card, CardHeader, CardTitle } from "../../../../components/ui/Card";
import { Card } from "../../../../components/ui/Card";
import { Badge } from "../../../../components/ui/Badge";
import { Button } from "../../../../components/ui/Button";
import { TotpEnrolment, RecoveryCodes } from "../../../../components/TotpEnrolment";
interface SieveRule {
id: string;
@@ -62,16 +62,18 @@ export default function UserSettingsPage() {
const [newPassword, setNewPassword] = useState("");
const [vacationEnabled, setVacationEnabled] = useState(false);
const [vacationSubject, setVacationSubject] = useState("Out of office auto-reply");
const [vacationBody, setVacationBody] = useState("Thank you for your message. I am currently away from my desk.");
const [vacationSubject, setVacationSubject] = useState(() => t("settings.vacationDefaultSubject"));
const [vacationBody, setVacationBody] = useState(() => t("settings.vacationDefaultBody"));
const [vacationSaved, setVacationSaved] = useState(false);
const [signature, setSignature] = useState("");
const [sigSaved, setSigSaved] = useState(false);
const [rules, setRules] = useState<SieveRule[]>([
{ id: "rule-1", field: "Subject", match: "contains", value: "[Boletim]", action: "move", targetFolder: "Archive" },
]);
// Starts empty rather than with a sample rule. A seeded "[Boletim]" filter
// used to sit here, and because saving posts the whole list, the first time
// anyone added or removed a rule that invented filter was written into their
// real Sieve script and began moving their mail.
const [rules, setRules] = useState<SieveRule[]>([]);
const [newField, setNewField] = useState<"From" | "Subject" | "Header">("Subject");
const [newMatch, setNewMatch] = useState<"contains" | "equals" | "matches">("contains");
const [newValue, setNewValue] = useState("");
@@ -107,7 +109,7 @@ export default function UserSettingsPage() {
setSigSaved(true);
setTimeout(() => setSigSaved(false), 2000);
} catch {
setMessage("Falha ao salvar assinatura");
setMessage(t("settings.signatureSaveFailed"));
}
};
@@ -122,7 +124,7 @@ export default function UserSettingsPage() {
setVacationSaved(true);
setTimeout(() => setVacationSaved(false), 2000);
} catch {
setMessage("Could not save the auto-reply");
setMessage(t("settings.autoReplySaveFailed"));
}
};
@@ -246,9 +248,7 @@ export default function UserSettingsPage() {
<h1 className="text-2xl md:text-3xl font-extrabold text-white tracking-tight">
{t("settings.title")}
</h1>
<p className="text-sm text-slate-400 mt-1">
Two-factor, Sieve rules, the auto-reply and your signature.
</p>
<p className="text-sm text-slate-400 mt-1">{t("settings.subtitle")}</p>
</div>
{message && (
@@ -266,9 +266,7 @@ export default function UserSettingsPage() {
<Shield className="w-5 h-5 text-indigo-400" />
{t("ui.twoFactorSection")}
</h2>
<p className="text-xs text-slate-400 mt-0.5">
Proteja sua conta com Google Authenticator, Aegis, Authy ou 1Password.
</p>
<p className="text-xs text-slate-400 mt-0.5">{t("settings.twoFactorIntro")}</p>
</div>
<Badge variant={mfaEnabled ? "success" : "warning"}>
{mfaEnabled ? t("settings.mfaEnabled") : t("settings.mfaDisabled")}
@@ -292,12 +290,7 @@ export default function UserSettingsPage() {
{qrSecret && (
<div className="p-4 rounded-xl bg-slate-900/90 border border-slate-800 space-y-4 text-xs">
<div className="text-slate-300">
Digitalize esta chave no seu aplicativo autenticador ou copie a chave secreta:
</div>
<div className="p-3 bg-slate-950 rounded-lg font-mono text-indigo-400 break-all border border-slate-800">
Secreto: {qrSecret}
</div>
<TotpEnrolment secret={qrSecret} uri={qrUri} />
<form
onSubmit={async (e) => {
e.preventDefault();
@@ -306,20 +299,25 @@ export default function UserSettingsPage() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ secret: qrSecret, code: totpCode }),
});
const data = await res.json();
const data = await res.json().catch(() => ({}));
if (res.ok) {
setMfaEnabled(true);
setRecoveryCodes(data.recovery_codes);
setRecoveryCodes(data.recovery_codes ?? []);
setQrSecret(null);
setTotpCode("");
} else {
setMessage(data.message ?? t("auth.verificationFailed"));
}
}}
className="pt-2 flex items-center gap-3"
>
<input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
value={totpCode}
onChange={(e) => setTotpCode(e.target.value)}
placeholder="Six-digit code"
placeholder={t("settings.sixDigitCode")}
maxLength={6}
required
className="px-4 py-2 rounded-xl bg-slate-950 border border-slate-800 text-white text-xs font-mono text-center focus:outline-none focus:border-indigo-500"
@@ -332,15 +330,11 @@ export default function UserSettingsPage() {
)}
{recoveryCodes && (
<div className="mt-4 p-4 rounded-xl bg-slate-900/90 border border-emerald-500/30">
<h3 className="text-xs font-bold text-white mb-1">{t("settings.recoveryCodesTitle")}</h3>
<p className="text-[11px] text-slate-400 mb-3">{t("settings.saveRecoveryCodes")}</p>
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2 text-center font-mono text-xs text-emerald-400 bg-slate-950 p-3 rounded-xl border border-slate-800">
{recoveryCodes.map((code, idx) => (
<div key={idx} className="p-1 border border-slate-800 rounded-lg">{code}</div>
))}
</div>
</div>
<RecoveryCodes
codes={recoveryCodes}
onContinue={() => setRecoveryCodes(null)}
continueLabel={t("common.continue")}
/>
)}
</Card>
@@ -349,9 +343,9 @@ export default function UserSettingsPage() {
<div>
<h2 className="text-base font-bold text-white flex items-center gap-2">
<Filter className="w-5 h-5 text-indigo-400" />
Filtros Visuais Sieve (RFC 5228)
{t("settings.sieveTitle")}
</h2>
<p className="text-xs text-slate-400 mt-0.5">Automatic sorting and filing rules.</p>
<p className="text-xs text-slate-400 mt-0.5">{t("settings.sieveIntro")}</p>
</div>
<form onSubmit={addSieveRule} className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-5 gap-3 text-xs bg-slate-900/60 p-4 rounded-xl border border-slate-800">
@@ -360,9 +354,9 @@ export default function UserSettingsPage() {
onChange={(e) => setNewField(e.target.value as "From" | "Subject" | "Header")}
className="px-3 py-2 rounded-xl bg-slate-900 border border-slate-800 text-white focus:outline-none focus:border-indigo-500"
>
<option value="Subject">Assunto</option>
<option value="From">De</option>
<option value="Header">Header</option>
<option value="Subject">{t("settings.fieldSubject")}</option>
<option value="From">{t("settings.fieldFrom")}</option>
<option value="Header">{t("settings.fieldHeader")}</option>
</select>
<select
@@ -370,16 +364,16 @@ export default function UserSettingsPage() {
onChange={(e) => setNewMatch(e.target.value as "contains" | "equals" | "matches")}
className="px-3 py-2 rounded-xl bg-slate-900 border border-slate-800 text-white focus:outline-none focus:border-indigo-500"
>
<option value="contains">contains</option>
<option value="equals">equals</option>
<option value="matches">corresponde a regex</option>
<option value="contains">{t("settings.matchContains")}</option>
<option value="equals">{t("settings.matchEquals")}</option>
<option value="matches">{t("settings.matchRegex")}</option>
</select>
<input
type="text"
value={newValue}
onChange={(e) => setNewValue(e.target.value)}
placeholder="Valor a corresponder..."
placeholder={t("settings.valuePlaceholder")}
required
className="px-3 py-2 rounded-xl bg-slate-900 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500"
/>
@@ -389,14 +383,14 @@ export default function UserSettingsPage() {
onChange={(e) => setNewAction(e.target.value as "move" | "flag" | "discard" | "redirect")}
className="px-3 py-2 rounded-xl bg-slate-900 border border-slate-800 text-white focus:outline-none focus:border-indigo-500"
>
<option value="move">Mover para Arquivo</option>
<option value="flag">Flag message</option>
<option value="discard">Descartar</option>
<option value="move">{t("settings.actionMove")}</option>
<option value="flag">{t("settings.actionFlag")}</option>
<option value="discard">{t("settings.actionDiscard")}</option>
</select>
<Button type="submit" variant="primary" size="sm" className="w-full">
<Plus className="w-4 h-4" />
<span>Adicionar Regra</span>
<span>{t("settings.addRule")}</span>
</Button>
</form>
@@ -404,7 +398,8 @@ export default function UserSettingsPage() {
{rules.map((r) => (
<div key={r.id} className="flex items-center justify-between p-3 rounded-xl bg-slate-900/80 border border-slate-800">
<span className="font-mono text-slate-200">
SE <strong>{r.field}</strong> {r.match} &quot;{r.value}&quot; &rarr; THEN {r.action.toUpperCase()}
{t("settings.ruleIf")} <strong>{r.field}</strong> {r.match} &quot;{r.value}&quot; &rarr;{" "}
{t("settings.ruleThen")} {r.action.toUpperCase()}
</span>
<button
onClick={() => removeSieveRule(r.id)}
@@ -423,9 +418,9 @@ export default function UserSettingsPage() {
<div>
<h2 className="text-base font-bold text-white flex items-center gap-2">
<Palmtree className="w-5 h-5 text-indigo-400" />
Out of office auto-reply
{t("settings.vacationTitle")}
</h2>
<p className="text-xs text-slate-400 mt-0.5">Reply automatically to incoming mail while you are awer ausente.</p>
<p className="text-xs text-slate-400 mt-0.5">{t("settings.vacationIntro")}</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
@@ -441,14 +436,14 @@ export default function UserSettingsPage() {
{vacationSaved && (
<div className="p-3.5 rounded-xl bg-emerald-500/15 border border-emerald-500/30 text-emerald-300 text-xs flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
<span>Settings saved.</span>
<span>{t("settings.settingsSaved")}</span>
</div>
)}
{vacationEnabled && (
<form onSubmit={saveVacationResponder} className="space-y-3 text-xs">
<div>
<label className="font-semibold text-slate-300 mb-1.5 block">Assunto da Resposta</label>
<label className="font-semibold text-slate-300 mb-1.5 block">{t("settings.vacationSubjectLabel")}</label>
<input
type="text"
value={vacationSubject}
@@ -458,7 +453,7 @@ export default function UserSettingsPage() {
</div>
<div>
<label className="font-semibold text-slate-300 mb-1.5 block">Message body</label>
<label className="font-semibold text-slate-300 mb-1.5 block">{t("settings.vacationBodyLabel")}</label>
<textarea
rows={4}
value={vacationBody}
@@ -468,7 +463,7 @@ export default function UserSettingsPage() {
</div>
<Button type="submit" variant="primary" size="sm">
Save auto-reply
{t("settings.saveAutoReply")}
</Button>
</form>
)}
@@ -479,15 +474,15 @@ export default function UserSettingsPage() {
<div>
<h2 className="text-base font-bold text-white flex items-center gap-2">
<FileText className="w-5 h-5 text-indigo-400" />
Assinatura de E-mail
{t("settings.signatureTitle")}
</h2>
<p className="text-slate-400 mt-0.5">Assinatura anexada automaticamente nas mensagens compostas.</p>
<p className="text-slate-400 mt-0.5">{t("settings.signatureIntro")}</p>
</div>
{sigSaved && (
<div className="p-3.5 rounded-xl bg-emerald-500/15 border border-emerald-500/30 text-emerald-300 flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
<span>Assinatura salva com sucesso!</span>
<span>{t("settings.signatureSaved")}</span>
</div>
)}
@@ -495,7 +490,7 @@ export default function UserSettingsPage() {
rows={4}
value={signature}
onChange={(e) => setSignature(e.target.value)}
placeholder="Atenciosamente,&#10;Seu Nome&#10;Seu Cargo"
placeholder={t("settings.signaturePlaceholder")}
className="w-full px-3.5 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"
/>
@@ -526,7 +521,7 @@ export default function UserSettingsPage() {
type="text"
value={newAppPassLabel}
onChange={(e) => setNewAppPassLabel(e.target.value)}
placeholder="Thunderbird / Outlook Mobile"
placeholder={t("settings.appPasswordPlaceholder")}
className="flex-1 px-3.5 py-2 text-xs rounded-xl bg-slate-900/90 border border-slate-800 text-white focus:outline-none focus:border-indigo-500"
/>
<Button type="submit" variant="primary" size="sm">