From 3e60852551075e94f7936b3b2541d8cb778b929c Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Sun, 2 Aug 2026 10:04:05 -0300 Subject: [PATCH] #21: put real mail on the webmail screens The mail list rendered two hardcoded messages and the reader showed their canned HTML; compose ran a countdown and then navigated away without ever submitting anything. Both now use the API, including search, the unread badges in the sidebar (which were the literals "3" and "1", so an empty inbox still advertised unread mail), and marking a message read on open. The undo window now means what it says: the message is held in the browser and only submitted when the countdown elapses, so cancelling it means nothing was ever queued rather than trying to recall it afterwards. Message bodies render in an iframe with an empty sandbox - no scripts, no same-origin - and remote references are stripped until the reader asks for images, so a tracking pixel cannot fire on open. The previous sandbox allowed popups. The admin page titles and the language switcher labels now come from the bundles. The labels live there rather than in the source because PLAN.md R9 keeps non-ASCII out of code, and each language should be named the way that language names it. ADR-001 assigned the mail API to the TypeScript storage service; the reasoning for putting it in Go instead is recorded in PLAN.md, which .gitignore deliberately keeps out of the repository, so that revision lives only in the working copy. Co-Authored-By: Claude Opus 5 --- services/webmail/messages/en.json | 5 +- services/webmail/messages/es.json | 5 +- services/webmail/messages/pt-BR.json | 5 +- .../src/app/(admin)/admin/dashboard/page.tsx | 4 +- .../src/app/(admin)/admin/dmarc/page.tsx | 4 +- .../src/app/(admin)/admin/domains/page.tsx | 4 +- .../src/app/(admin)/admin/mailboxes/page.tsx | 4 +- .../src/app/(admin)/admin/queue/page.tsx | 4 +- .../src/app/(admin)/admin/security/page.tsx | 4 +- .../src/app/(user)/user/compose/page.tsx | 73 ++++- .../webmail/src/app/(user)/user/layout.tsx | 31 +- .../app/(user)/user/mail/[folder]/page.tsx | 295 ++++++++++-------- .../webmail/src/app/api/v1/[...path]/route.ts | 12 +- .../webmail/src/i18n/LanguageSwitcher.tsx | 4 +- services/webmail/src/i18n/config.ts | 11 +- 15 files changed, 299 insertions(+), 166 deletions(-) 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 */} -
-