#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,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>
|
||||
|
||||
Reference in new issue
Block a user