diff --git a/services/webmail/messages/en.json b/services/webmail/messages/en.json index 2313671..dfb19f2 100644 --- a/services/webmail/messages/en.json +++ b/services/webmail/messages/en.json @@ -53,7 +53,10 @@ "recoveryCodesTitle": "Recovery Codes", "saveRecoveryCodes": "Save these recovery codes in a safe place. They will not be shown again.", "language": "Language", - "signOut": "Sign out" + "signOut": "Sign out", + "langEn": "English", + "langPtBR": "Portuguese (Brazil)", + "langEs": "Spanish" }, "admin": { "dashboardTitle": "System Dashboard", diff --git a/services/webmail/messages/es.json b/services/webmail/messages/es.json index 33bf425..83d3bdf 100644 --- a/services/webmail/messages/es.json +++ b/services/webmail/messages/es.json @@ -53,7 +53,10 @@ "recoveryCodesTitle": "Códigos de Recuperación", "saveRecoveryCodes": "Guarde estos códigos de recuperación en un lugar seguro. No se volverán a mostrar.", "language": "Idioma", - "signOut": "Cerrar sesión" + "signOut": "Cerrar sesión", + "langEn": "Inglés", + "langPtBR": "Portugués (Brasil)", + "langEs": "Español" }, "admin": { "dashboardTitle": "Panel del Sistema", diff --git a/services/webmail/messages/pt-BR.json b/services/webmail/messages/pt-BR.json index 00573da..1becf15 100644 --- a/services/webmail/messages/pt-BR.json +++ b/services/webmail/messages/pt-BR.json @@ -53,7 +53,10 @@ "recoveryCodesTitle": "Códigos de Recuperação", "saveRecoveryCodes": "Guarde estes códigos de recuperação em local seguro. Eles não serão exibidos novamente.", "language": "Idioma", - "signOut": "Sair" + "signOut": "Sair", + "langEn": "Inglês", + "langPtBR": "Português (Brasil)", + "langEs": "Espanhol" }, "admin": { "dashboardTitle": "Painel do Sistema", diff --git a/services/webmail/src/app/(admin)/admin/dashboard/page.tsx b/services/webmail/src/app/(admin)/admin/dashboard/page.tsx index 50db844..f601f18 100644 --- a/services/webmail/src/app/(admin)/admin/dashboard/page.tsx +++ b/services/webmail/src/app/(admin)/admin/dashboard/page.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useEffect, useState } from "react"; +import { useTranslations } from "../../../../i18n/provider"; interface DashboardMetrics { inbound_24h: number; @@ -15,6 +16,7 @@ interface DashboardMetrics { } export default function AdminDashboardPage() { + const t = useTranslations(); const [metrics, setMetrics] = useState(null); useEffect(() => { @@ -28,7 +30,7 @@ export default function AdminDashboardPage() {
-

System Overview & Performance

+

{t("admin.dashboardTitle")}

Live operational metrics for LambdaMail mail node.

diff --git a/services/webmail/src/app/(admin)/admin/dmarc/page.tsx b/services/webmail/src/app/(admin)/admin/dmarc/page.tsx index 8368bee..8950e5c 100644 --- a/services/webmail/src/app/(admin)/admin/dmarc/page.tsx +++ b/services/webmail/src/app/(admin)/admin/dmarc/page.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useEffect, useState } from "react"; +import { useTranslations } from "../../../../i18n/provider"; interface DmarcSource { ip: string; @@ -19,6 +20,7 @@ interface DmarcData { } export default function AdminDmarcPage() { + const t = useTranslations(); const [data, setData] = useState(null); useEffect(() => { @@ -31,7 +33,7 @@ export default function AdminDmarcPage() { return (
-

DMARC Aggregate Reports Analytics

+

{t("admin.dmarcTitle")}

Ingested XML aggregate reports breakdown (dmarc_reports table).

diff --git a/services/webmail/src/app/(admin)/admin/domains/page.tsx b/services/webmail/src/app/(admin)/admin/domains/page.tsx index 58f1e77..3bb5c98 100644 --- a/services/webmail/src/app/(admin)/admin/domains/page.tsx +++ b/services/webmail/src/app/(admin)/admin/domains/page.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useState } from "react"; +import { useTranslations } from "../../../../i18n/provider"; interface DnsRecord { type: string; @@ -27,6 +28,7 @@ const DNS_RECORDS_13: DnsRecord[] = [ ]; export default function AdminDomainsPage() { + const t = useTranslations(); const [reconciling, setReconciling] = useState(false); const [syncMessage, setSyncMessage] = useState(null); @@ -43,7 +45,7 @@ export default function AdminDomainsPage() {
-

Domain & 13-DNS Records Automation

+

{t("admin.domainsTitle")}

Live DNS diff reconciliation against Cloudflare API (PLAN.md Section 16 F3/F4 requirement).

diff --git a/services/webmail/src/app/(admin)/admin/mailboxes/page.tsx b/services/webmail/src/app/(admin)/admin/mailboxes/page.tsx index 44878cb..b65bf68 100644 --- a/services/webmail/src/app/(admin)/admin/mailboxes/page.tsx +++ b/services/webmail/src/app/(admin)/admin/mailboxes/page.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useState } from "react"; +import { useTranslations } from "../../../../i18n/provider"; interface Mailbox { id: string; @@ -19,6 +20,7 @@ const INITIAL_MAILBOXES: Mailbox[] = [ ]; export default function AdminMailboxesPage() { + const t = useTranslations(); const [mailboxes, setMailboxes] = useState(INITIAL_MAILBOXES); const [mfaPolicy, setMfaPolicy] = useState<"optional" | "required_admins" | "required_all">("required_admins"); const [newEmail, setNewEmail] = useState(""); @@ -44,7 +46,7 @@ export default function AdminMailboxesPage() {
-

Mailboxes & Account Management

+

{t("admin.mailboxesTitle")}

Configure mailboxes, quota allocation, and domain MFA policy.

diff --git a/services/webmail/src/app/(admin)/admin/queue/page.tsx b/services/webmail/src/app/(admin)/admin/queue/page.tsx index ce285b0..9defeed 100644 --- a/services/webmail/src/app/(admin)/admin/queue/page.tsx +++ b/services/webmail/src/app/(admin)/admin/queue/page.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useState } from "react"; +import { useTranslations } from "../../../../i18n/provider"; interface QueueJob { id: string; @@ -18,6 +19,7 @@ const INITIAL_JOBS: QueueJob[] = [ ]; export default function AdminQueuePage() { + const t = useTranslations(); const [jobs, setJobs] = useState(INITIAL_JOBS); const [message, setMessage] = useState(null); @@ -34,7 +36,7 @@ export default function AdminQueuePage() { return (
-

Outbound Delivery Queue Control

+

{t("admin.queueTitle")}

Inspect outbound queue, retry retriable jobs, or freeze problematic destinations.

diff --git a/services/webmail/src/app/(admin)/admin/security/page.tsx b/services/webmail/src/app/(admin)/admin/security/page.tsx index 5dbd973..5cc6678 100644 --- a/services/webmail/src/app/(admin)/admin/security/page.tsx +++ b/services/webmail/src/app/(admin)/admin/security/page.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useEffect, useState } from "react"; +import { useTranslations } from "../../../../i18n/provider"; interface AuditEntry { id: string; @@ -23,6 +24,7 @@ const MOCK_AUDIT_LOGS: AuditEntry[] = [ ]; export default function AdminSecurityPage() { + const t = useTranslations(); const [preflightChecks, setPreflightChecks] = useState([]); useEffect(() => { @@ -35,7 +37,7 @@ export default function AdminSecurityPage() { return (
-

Security, Audit Log & Preflight

+

{t("admin.securityTitle")}

Immutable security event logging and mail server preflight diagnostics.

diff --git a/services/webmail/src/app/(user)/user/compose/page.tsx b/services/webmail/src/app/(user)/user/compose/page.tsx index 5130c4d..383e7ff 100644 --- a/services/webmail/src/app/(user)/user/compose/page.tsx +++ b/services/webmail/src/app/(user)/user/compose/page.tsx @@ -1,34 +1,67 @@ "use client"; -import React, { useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import Link from "next/link"; +import { useTranslations } from "../../../../i18n/provider"; + +// The undo window holds the message in the browser before it is submitted. +// Nothing is queued server-side until it elapses, so "undo" really does mean +// the message was never sent, rather than trying to recall it afterwards. +const UNDO_WINDOW_SECONDS = 10; export default function ComposePage() { + const t = useTranslations(); const [to, setTo] = useState(""); const [subject, setSubject] = useState(""); const [body, setBody] = useState(""); const [sending, setSending] = useState(false); const [undoSeconds, setUndoSeconds] = useState(null); + const [error, setError] = useState(null); + const cancelled = useRef(false); + + const submit = async () => { + try { + const res = await fetch("/api/v1/mail/send", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ to: [to], subject, body }), + }); + if (res.status === 401) { + window.location.href = "/user/login"; + return; + } + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.message || t("errors.serverError")); + window.location.href = "/user/mail/sent"; + } catch (err) { + setError(err instanceof Error ? err.message : t("errors.serverError")); + setSending(false); + } + }; + + useEffect(() => { + if (undoSeconds === null) return; + if (undoSeconds <= 0) { + setUndoSeconds(null); + if (!cancelled.current) void submit(); + return; + } + const handle = setTimeout(() => setUndoSeconds((prev) => (prev === null ? null : prev - 1)), 1000); + return () => clearTimeout(handle); + // submit closes over the current fields, which is what should be sent. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [undoSeconds]); const handleSend = (e: React.FormEvent) => { e.preventDefault(); + setError(null); + cancelled.current = false; setSending(true); - setUndoSeconds(30); - - const interval = setInterval(() => { - setUndoSeconds((prev) => { - if (prev === null || prev <= 1) { - clearInterval(interval); - setSending(false); - window.location.href = "/user/mail/sent"; - return null; - } - return prev - 1; - }); - }, 1000); + setUndoSeconds(UNDO_WINDOW_SECONDS); }; const handleUndo = () => { + cancelled.current = true; setUndoSeconds(null); setSending(false); }; @@ -38,7 +71,7 @@ export default function ComposePage() {

- 📝 Compose Email Message + 📝 {t("mail.compose")}

Auto-draft active (3s)
@@ -46,17 +79,23 @@ export default function ComposePage() { {undoSeconds !== null && (
- Email queued! Sending in {undoSeconds}s... + {t("mail.undoSend").replace("{seconds}", String(undoSeconds))}
)} + {error && ( +
+ {error} +
+ )} +
diff --git a/services/webmail/src/app/(user)/user/layout.tsx b/services/webmail/src/app/(user)/user/layout.tsx index d15d45d..41bfce2 100644 --- a/services/webmail/src/app/(user)/user/layout.tsx +++ b/services/webmail/src/app/(user)/user/layout.tsx @@ -1,12 +1,31 @@ "use client"; -import React from "react"; +import React, { useEffect, useState } from "react"; import Link from "next/link"; import { useTranslations } from "../../../i18n/provider"; import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher"; +interface FolderCount { + special_use: string; + name: string; + unread_count: number; +} + export default function UserWebmailLayout({ children }: { children: React.ReactNode }) { const t = useTranslations(); + const [folders, setFolders] = useState([]); + + useEffect(() => { + // The badge counts used to be literals in the markup, so an empty inbox + // still advertised unread mail. + 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; return (
{/* Sidebar Navigation */} @@ -39,7 +58,11 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
📬 {t("mail.inbox")}
- 3 + {unreadFor("inbox") > 0 && ( + + {unreadFor("inbox")} + + )} 📝 {t("mail.drafts")}
- 1 + {unreadFor("drafts") > 0 && ( + {unreadFor("drafts")} + )} -

Welcome to LambdaMail v2.0

-

Your secure self-hosted email box is active.

-
    -
  • Surface Isolation: Cookie Path=/user enforced
  • -
  • 2FA TOTP: Active RFC 6238 support
  • -
  • Mail Security: 13 DNS records reconciled via Cloudflare
  • -
- Remote pixel test -
- `, - }, - { - id: "msg-2", - sender: "GitHub Notifications", - senderEmail: "notifications@github.com", - subject: "[LambdaMail/LambdaMail] Pull Request #20 Merged", - snippet: "Feature branch feature/20-f4-webmail-admin-mfa-i18n merged successfully.", - date: "Yesterday", - unread: false, - hasAttachment: true, - spf: "pass", - dkim: "pass", - dmarc: "pass", - htmlContent: ` -
-

Pull Request #20 Merged into main

-

Phase F4 (Webmail, Admin Console, 2FA TOTP, and i18n) is approved!

-
- `, - }, -]; +interface RenderedMessage { + uid: number; + subject: string; + from: string; + to: string[]; + cc: string[]; + date: string; + text: string; + html: string; + attachments: string[]; +} -export default function MailFolderPage() { - const [selectedMsg, setSelectedMsg] = useState(MOCK_MESSAGES[0]); +/** Strips remote references so a tracking pixel cannot phone home before the + * user asks for images. The message still renders; only external loads go. */ +function blockRemoteContent(html: string): string { + return html + .replace(/(]+)src\s*=\s*("|')https?:[^"']*\2/gi, "$1data-blocked-src=$2$2") + .replace(/(<[^>]+)background\s*=\s*("|')https?:[^"']*\2/gi, "$1") + .replace(/url\(\s*['"]?https?:[^)]*\)/gi, "none"); +} + +export default function MailFolderPage({ params }: { params: Promise<{ folder: string }> }) { + const { folder } = use(params); + const t = useTranslations(); + + const [messages, setMessages] = useState([]); + const [selected, setSelected] = useState(null); const [loadRemoteImages, setLoadRemoteImages] = useState(false); const [searchQuery, setSearchQuery] = useState(""); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + 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}`); + 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], + ); + + useEffect(() => { + void loadMessages(""); + }, [loadMessages]); + + // Debounced so typing in the search box does not issue a query per keystroke. + useEffect(() => { + const handle = setTimeout(() => void loadMessages(searchQuery), 250); + return () => clearTimeout(handle); + }, [searchQuery, loadMessages]); + + const openMessage = async (uid: number) => { + setLoadRemoteImages(false); + try { + const res = await fetch(`/api/v1/mail/message/${uid}?folder=${encodeURIComponent(folder)}`); + if (!res.ok) throw new Error(t("errors.serverError")); + setSelected(await res.json()); + // Reading marks it seen server-side; reflect that without a refetch. + setMessages((current) => current.map((m) => (m.uid === uid ? { ...m, seen: true } : m))); + } catch (err) { + setError(err instanceof Error ? err.message : t("errors.serverError")); + } + }; + + const bodyHtml = selected?.html + ? loadRemoteImages + ? selected.html + : blockRemoteContent(selected.html) + : null; return (
- {/* Messages List Column */}
- {/* Search Bar */}
setSearchQuery(e.target.value)} - placeholder="Search email (e.g. from:admin has:attachment)..." + placeholder={t("common.search")} className="w-full px-3 py-2 text-xs rounded-lg bg-slate-900 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500" />
- {/* Message Items List */}
- {MOCK_MESSAGES.map((msg) => ( - - ))} + {loading ? ( +
{t("common.loading")}
+ ) : error ? ( +
{error}
+ ) : messages.length === 0 ? ( +
{t("mail.noMessages")}
+ ) : ( + messages.map((msg) => ( + + )) + )}
- {/* Message Reader Column */}
- {selectedMsg ? ( + {selected ? (
- {/* Message Header */}
-
-

{selectedMsg.subject}

-
- - SPF:{selectedMsg.spf} - - - DKIM:{selectedMsg.dkim} - - - DMARC:{selectedMsg.dmarc} - -
-
- +

{selected.subject || "(no subject)"}

-
- {selectedMsg.sender} <{selectedMsg.senderEmail}> -
-
{selectedMsg.date}
+
{selected.from}
+
{selected.date ? new Date(selected.date).toLocaleString() : ""}
+ {selected.to.length > 0 && ( +
To: {selected.to.join(", ")}
+ )} + {selected.attachments.length > 0 && ( +
+ {"\u{1F4CE} "} + {selected.attachments.join(", ")} +
+ )}
- {/* Remote Images Banner */} -
- Remote images are blocked by default for security (anti-tracking pixel protection). - -
+ {selected.html && ( +
+ {t("mail.loadImages")} + +
+ )} - {/* Isolated HTML Sandboxed Frame Container */} -
-