#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 <noreply@anthropic.com>
This commit is contained in:
1 parent
48fe3c68f3
commit
3e60852551
15 files changed
+299
-166
No files matched your search
@@ -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<DashboardMetrics | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -28,7 +30,7 @@ export default function AdminDashboardPage() {
|
||||
<div className="p-8 space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white mb-1">System Overview & Performance</h1>
|
||||
<h1 className="text-2xl font-bold text-white mb-1">{t("admin.dashboardTitle")}</h1>
|
||||
<p className="text-xs text-slate-400">Live operational metrics for LambdaMail mail node.</p>
|
||||
</div>
|
||||
<span className="badge-verified px-3 py-1 rounded-full text-xs font-bold font-mono">
|
||||
|
||||
@@ -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<DmarcData | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -31,7 +33,7 @@ export default function AdminDmarcPage() {
|
||||
return (
|
||||
<div className="p-8 space-y-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white mb-1">DMARC Aggregate Reports Analytics</h1>
|
||||
<h1 className="text-2xl font-bold text-white mb-1">{t("admin.dmarcTitle")}</h1>
|
||||
<p className="text-xs text-slate-400">Ingested XML aggregate reports breakdown (dmarc_reports table).</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
|
||||
@@ -43,7 +45,7 @@ export default function AdminDomainsPage() {
|
||||
<div className="p-8 space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white mb-1">Domain & 13-DNS Records Automation</h1>
|
||||
<h1 className="text-2xl font-bold text-white mb-1">{t("admin.domainsTitle")}</h1>
|
||||
<p className="text-xs text-slate-400">Live DNS diff reconciliation against Cloudflare API (PLAN.md Section 16 F3/F4 requirement).</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<Mailbox[]>(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() {
|
||||
<div className="p-8 space-y-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white mb-1">Mailboxes & Account Management</h1>
|
||||
<h1 className="text-2xl font-bold text-white mb-1">{t("admin.mailboxesTitle")}</h1>
|
||||
<p className="text-xs text-slate-400">Configure mailboxes, quota allocation, and domain MFA policy.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<QueueJob[]>(INITIAL_JOBS);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
@@ -34,7 +36,7 @@ export default function AdminQueuePage() {
|
||||
return (
|
||||
<div className="p-8 space-y-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white mb-1">Outbound Delivery Queue Control</h1>
|
||||
<h1 className="text-2xl font-bold text-white mb-1">{t("admin.queueTitle")}</h1>
|
||||
<p className="text-xs text-slate-400">Inspect outbound queue, retry retriable jobs, or freeze problematic destinations.</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<PreflightCheck[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -35,7 +37,7 @@ export default function AdminSecurityPage() {
|
||||
return (
|
||||
<div className="p-8 space-y-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white mb-1">Security, Audit Log & Preflight</h1>
|
||||
<h1 className="text-2xl font-bold text-white mb-1">{t("admin.securityTitle")}</h1>
|
||||
<p className="text-xs text-slate-400">Immutable security event logging and mail server preflight diagnostics.</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(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() {
|
||||
<div className="glass-panel p-6 rounded-2xl max-w-2xl w-full border border-slate-800 shadow-2xl">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 pb-4 mb-6">
|
||||
<h1 className="text-lg font-bold text-white flex items-center gap-2">
|
||||
<span>📝</span> Compose Email Message
|
||||
<span>📝</span> {t("mail.compose")}
|
||||
</h1>
|
||||
<span className="text-xs text-slate-400 font-mono">Auto-draft active (3s)</span>
|
||||
</div>
|
||||
@@ -46,17 +79,23 @@ export default function ComposePage() {
|
||||
{undoSeconds !== null && (
|
||||
<div className="mb-6 p-4 rounded-xl bg-indigo-600/20 border border-indigo-500/40 flex items-center justify-between text-indigo-300">
|
||||
<span className="text-sm font-medium">
|
||||
Email queued! Sending in <strong>{undoSeconds}s</strong>...
|
||||
{t("mail.undoSend").replace("{seconds}", String(undoSeconds))}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleUndo}
|
||||
className="px-4 py-1.5 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-bold transition-colors"
|
||||
>
|
||||
Undo Send
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSend} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-300 mb-1">To (Recipients)</label>
|
||||
|
||||
@@ -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<FolderCount[]>([]);
|
||||
|
||||
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 (
|
||||
<div className="flex h-screen overflow-hidden bg-slate-950 text-slate-100">
|
||||
{/* Sidebar Navigation */}
|
||||
@@ -39,7 +58,11 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span>📬</span> {t("mail.inbox")}
|
||||
</div>
|
||||
<span className="text-xs bg-indigo-500/30 px-2 py-0.5 rounded-full font-bold">3</span>
|
||||
{unreadFor("inbox") > 0 && (
|
||||
<span className="text-xs bg-indigo-500/30 px-2 py-0.5 rounded-full font-bold">
|
||||
{unreadFor("inbox")}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
@@ -58,7 +81,9 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span>📝</span> {t("mail.drafts")}
|
||||
</div>
|
||||
<span className="text-xs bg-slate-800 px-2 py-0.5 rounded-full">1</span>
|
||||
{unreadFor("drafts") > 0 && (
|
||||
<span className="text-xs bg-slate-800 px-2 py-0.5 rounded-full">{unreadFor("drafts")}</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
|
||||
@@ -1,170 +1,207 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import React, { use, useCallback, useEffect, useState } from "react";
|
||||
import { useTranslations } from "../../../../../i18n/provider";
|
||||
|
||||
interface SampleMessage {
|
||||
id: string;
|
||||
sender: string;
|
||||
senderEmail: string;
|
||||
interface MessageSummary {
|
||||
uid: number;
|
||||
subject: string;
|
||||
sender_address: string;
|
||||
from_display_name: string;
|
||||
snippet: string;
|
||||
date: string;
|
||||
unread: boolean;
|
||||
hasAttachment: boolean;
|
||||
htmlContent: string;
|
||||
spf: string;
|
||||
dkim: string;
|
||||
dmarc: string;
|
||||
size_bytes: number;
|
||||
received_at: string;
|
||||
has_attachments: boolean;
|
||||
seen: boolean;
|
||||
spam_verdict: string;
|
||||
dmarc_result: string;
|
||||
}
|
||||
|
||||
const MOCK_MESSAGES: SampleMessage[] = [
|
||||
{
|
||||
id: "msg-1",
|
||||
sender: "Security Operations",
|
||||
senderEmail: "security@lambdamail.org",
|
||||
subject: "Welcome to LambdaMail v2.0 - Account Provisioned",
|
||||
snippet: "Your LambdaMail account is active with strict SPF, DKIM, and DMARC verification.",
|
||||
date: "10:42 AM",
|
||||
unread: true,
|
||||
hasAttachment: false,
|
||||
spf: "pass",
|
||||
dkim: "pass",
|
||||
dmarc: "pass",
|
||||
htmlContent: `
|
||||
<div style="font-family: sans-serif; padding: 20px; color: #333;">
|
||||
<h2>Welcome to LambdaMail v2.0</h2>
|
||||
<p>Your secure self-hosted email box is active.</p>
|
||||
<ul>
|
||||
<li><strong>Surface Isolation:</strong> Cookie Path=/user enforced</li>
|
||||
<li><strong>2FA TOTP:</strong> Active RFC 6238 support</li>
|
||||
<li><strong>Mail Security:</strong> 13 DNS records reconciled via Cloudflare</li>
|
||||
</ul>
|
||||
<img src="https://example.com/pixel.png" alt="Remote pixel test" style="display:none;" />
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
{
|
||||
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: `
|
||||
<div style="font-family: sans-serif; padding: 20px; color: #333;">
|
||||
<h3>Pull Request #20 Merged into main</h3>
|
||||
<p>Phase F4 (Webmail, Admin Console, 2FA TOTP, and i18n) is approved!</p>
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
];
|
||||
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<SampleMessage | null>(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(/(<img[^>]+)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<MessageSummary[]>([]);
|
||||
const [selected, setSelected] = useState<RenderedMessage | null>(null);
|
||||
const [loadRemoteImages, setLoadRemoteImages] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Messages List Column */}
|
||||
<div className="w-96 border-r border-slate-800 flex flex-col bg-slate-900/30">
|
||||
{/* Search Bar */}
|
||||
<div className="p-3 border-b border-slate-800">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Message Items List */}
|
||||
<div className="flex-1 overflow-y-auto divide-y divide-slate-800/60">
|
||||
{MOCK_MESSAGES.map((msg) => (
|
||||
<button
|
||||
key={msg.id}
|
||||
onClick={() => {
|
||||
setSelectedMsg(msg);
|
||||
setLoadRemoteImages(false);
|
||||
}}
|
||||
className={`w-full text-left p-4 transition-colors block ${
|
||||
selectedMsg?.id === msg.id ? "bg-indigo-600/10 border-l-2 border-indigo-500" : "hover:bg-slate-800/40"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className={`text-xs font-semibold ${msg.unread ? "text-white" : "text-slate-300"}`}>
|
||||
{msg.sender}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-500">{msg.date}</span>
|
||||
</div>
|
||||
<div className={`text-xs truncate mb-1 ${msg.unread ? "font-bold text-slate-100" : "text-slate-300"}`}>
|
||||
{msg.subject}
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-400 truncate">{msg.snippet}</div>
|
||||
</button>
|
||||
))}
|
||||
{loading ? (
|
||||
<div className="p-4 text-xs text-slate-500">{t("common.loading")}</div>
|
||||
) : error ? (
|
||||
<div className="p-4 text-xs text-red-400">{error}</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="p-4 text-xs text-slate-500 italic">{t("mail.noMessages")}</div>
|
||||
) : (
|
||||
messages.map((msg) => (
|
||||
<button
|
||||
key={msg.uid}
|
||||
onClick={() => void openMessage(msg.uid)}
|
||||
className={`w-full text-left p-4 transition-colors block ${
|
||||
selected?.uid === msg.uid
|
||||
? "bg-indigo-600/10 border-l-2 border-indigo-500"
|
||||
: "hover:bg-slate-800/40"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className={`text-xs font-semibold ${msg.seen ? "text-slate-300" : "text-white"}`}>
|
||||
{msg.from_display_name || msg.sender_address}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-500">
|
||||
{new Date(msg.received_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`text-xs truncate mb-1 ${msg.seen ? "text-slate-300" : "font-bold text-slate-100"}`}>
|
||||
{msg.subject || "(no subject)"}
|
||||
{msg.has_attachments ? " \u{1F4CE}" : ""}
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-400 truncate">{msg.snippet}</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message Reader Column */}
|
||||
<div className="flex-1 flex flex-col bg-slate-950 overflow-y-auto p-6">
|
||||
{selectedMsg ? (
|
||||
{selected ? (
|
||||
<div className="max-w-3xl w-full mx-auto">
|
||||
{/* Message Header */}
|
||||
<div className="border-b border-slate-800 pb-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-xl font-bold text-white">{selectedMsg.subject}</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="badge-verified px-2 py-0.5 rounded text-[10px] font-mono">
|
||||
SPF:{selectedMsg.spf}
|
||||
</span>
|
||||
<span className="badge-verified px-2 py-0.5 rounded text-[10px] font-mono">
|
||||
DKIM:{selectedMsg.dkim}
|
||||
</span>
|
||||
<span className="badge-verified px-2 py-0.5 rounded text-[10px] font-mono">
|
||||
DMARC:{selectedMsg.dmarc}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-xl font-bold text-white mb-4">{selected.subject || "(no subject)"}</h1>
|
||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||
<div>
|
||||
<span className="font-semibold text-slate-200">{selectedMsg.sender}</span> <{selectedMsg.senderEmail}>
|
||||
</div>
|
||||
<div>{selectedMsg.date}</div>
|
||||
<div className="font-semibold text-slate-200">{selected.from}</div>
|
||||
<div>{selected.date ? new Date(selected.date).toLocaleString() : ""}</div>
|
||||
</div>
|
||||
{selected.to.length > 0 && (
|
||||
<div className="mt-1 text-[11px] text-slate-500">To: {selected.to.join(", ")}</div>
|
||||
)}
|
||||
{selected.attachments.length > 0 && (
|
||||
<div className="mt-2 text-[11px] text-slate-400">
|
||||
{"\u{1F4CE} "}
|
||||
{selected.attachments.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Remote Images Banner */}
|
||||
<div className="mb-4 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20 flex items-center justify-between text-xs text-amber-300">
|
||||
<span>Remote images are blocked by default for security (anti-tracking pixel protection).</span>
|
||||
<button
|
||||
onClick={() => setLoadRemoteImages(!loadRemoteImages)}
|
||||
className="px-3 py-1 rounded bg-amber-500/20 hover:bg-amber-500/30 text-amber-200 font-medium transition-colors"
|
||||
>
|
||||
{loadRemoteImages ? "Images Loaded" : "Load Remote Images"}
|
||||
</button>
|
||||
</div>
|
||||
{selected.html && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20 flex items-center justify-between text-xs text-amber-300">
|
||||
<span>{t("mail.loadImages")}</span>
|
||||
<button
|
||||
onClick={() => setLoadRemoteImages(!loadRemoteImages)}
|
||||
className="px-3 py-1 rounded bg-amber-500/20 hover:bg-amber-500/30 text-amber-200 font-medium transition-colors"
|
||||
>
|
||||
{loadRemoteImages ? t("common.confirm") : t("mail.loadImages")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Isolated HTML Sandboxed Frame Container */}
|
||||
<div className="bg-white rounded-xl overflow-hidden shadow-xl border border-slate-800 min-h-[300px]">
|
||||
<iframe
|
||||
title="Message Reader Body"
|
||||
srcDoc={selectedMsg.htmlContent}
|
||||
sandbox="allow-popups allow-popups-to-escape-sandbox"
|
||||
className="w-full h-96 border-0"
|
||||
/>
|
||||
</div>
|
||||
{bodyHtml ? (
|
||||
<div className="bg-white rounded-xl overflow-hidden shadow-xl border border-slate-800 min-h-[300px]">
|
||||
{/* Sandboxed with no allow-scripts and no same-origin: hostile
|
||||
markup in a message cannot run or reach this session. */}
|
||||
<iframe
|
||||
title="Message body"
|
||||
srcDoc={bodyHtml}
|
||||
sandbox=""
|
||||
className="w-full h-96 border-0"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap break-words text-sm text-slate-200 font-sans">
|
||||
{selected.text}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-slate-500 text-sm">
|
||||
Select a message to display contents
|
||||
{t("mail.noMessages")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,14 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
*/
|
||||
|
||||
const AUTH_SERVICE_URL = process.env.AUTH_SERVICE_URL ?? "http://auth:3001";
|
||||
// The mail screens are served by the protocols service, which owns the folder,
|
||||
// UID, flag and blob logic. Both verify the same session token, so the browser
|
||||
// sees one origin and one cookie either way.
|
||||
const PROTOCOLS_SERVICE_URL = process.env.PROTOCOLS_SERVICE_URL ?? "http://protocols:8080";
|
||||
|
||||
function upstreamFor(path: string[]): string {
|
||||
return path[0] === "mail" ? PROTOCOLS_SERVICE_URL : AUTH_SERVICE_URL;
|
||||
}
|
||||
|
||||
// Hop-by-hop headers must not be forwarded (RFC 9110 section 7.6.1), and the
|
||||
// upstream sets its own content-length.
|
||||
@@ -36,7 +44,7 @@ function filterHeaders(source: Headers): Headers {
|
||||
}
|
||||
|
||||
async function forward(req: NextRequest, path: string[]): Promise<Response> {
|
||||
const target = `${AUTH_SERVICE_URL}/api/v1/${path.join("/")}${req.nextUrl.search}`;
|
||||
const target = `${upstreamFor(path)}/api/v1/${path.join("/")}${req.nextUrl.search}`;
|
||||
const headers = filterHeaders(req.headers);
|
||||
|
||||
// The client's address is what the auth service records on sessions and
|
||||
@@ -60,7 +68,7 @@ async function forward(req: NextRequest, path: string[]): Promise<Response> {
|
||||
// A dead upstream is a gateway problem, not a 404 - the difference decides
|
||||
// whether an operator looks at the auth service or at this route.
|
||||
return NextResponse.json(
|
||||
{ error: "UPSTREAM_UNAVAILABLE", message: "Authentication service is unreachable" },
|
||||
{ error: "UPSTREAM_UNAVAILABLE", message: "Upstream service is unreachable" },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { LOCALES, LOCALE_LABELS, type Locale } from "./config";
|
||||
import { LOCALES, LOCALE_LABEL_KEYS, type Locale } from "./config";
|
||||
import { useI18n } from "./provider";
|
||||
|
||||
/** Language selector, rendered in both surface layouts (PLAN.md section 21.2). */
|
||||
@@ -18,7 +18,7 @@ export function LanguageSwitcher({ className = "" }: { className?: string }) {
|
||||
>
|
||||
{LOCALES.map((l) => (
|
||||
<option key={l} value={l}>
|
||||
{LOCALE_LABELS[l]}
|
||||
{t(LOCALE_LABEL_KEYS[l])}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -8,10 +8,13 @@ export type Locale = (typeof LOCALES)[number];
|
||||
export const DEFAULT_LOCALE: Locale = "en";
|
||||
export const LOCALE_COOKIE = "lm_locale";
|
||||
|
||||
export const LOCALE_LABELS: Record<Locale, string> = {
|
||||
en: "English",
|
||||
"pt-BR": "Português (Brasil)",
|
||||
es: "Español",
|
||||
// The display name of each language lives in the message bundles: PLAN.md R9
|
||||
// keeps non-ASCII out of the source, and a switcher should name each language
|
||||
// the way that language does.
|
||||
export const LOCALE_LABEL_KEYS: Record<Locale, string> = {
|
||||
en: "settings.langEn",
|
||||
"pt-BR": "settings.langPtBR",
|
||||
es: "settings.langEs",
|
||||
};
|
||||
|
||||
// Bundled at build time rather than fetched: these are small, needed on first
|
||||
|
||||
Reference in new issue
Block a user