#21: enhance webmail with rich-text editor, DOMPurify sanitizer, attachments, sieve builder, and vacation responder

This commit is contained in:
Lucas Rayan committed 2026-08-02 10:37:03 -03:00
1 parent b57854107e
commit 56a176d1f0
7 files changed
+677 -260

No files matched your search

+1
View File
@@ -38,6 +38,7 @@ describe("crypto module", () => {
}); });
it("encrypts and decrypts secrets using AES-256-GCM", () => { it("encrypts and decrypts secrets using AES-256-GCM", () => {
process.env.LAMBDAMAIL_MASTER_KEY = "test-master-key-32-chars-long!!";
const secret = "JBSWY3DPEHPK3PXP"; const secret = "JBSWY3DPEHPK3PXP";
const encrypted = encryptSecret(secret); const encrypted = encryptSecret(secret);
const decrypted = decryptSecret(encrypted.encrypted, encrypted.nonce, encrypted.keyVersion); const decrypted = decryptSecret(encrypted.encrypted, encrypted.nonce, encrypted.keyVersion);
+28
View File
@@ -8,11 +8,13 @@
"name": "@lambdamail/webmail", "name": "@lambdamail/webmail",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"dompurify": "^3.4.12",
"next": "15.5.21", "next": "15.5.21",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },
"devDependencies": { "devDependencies": {
"@types/dompurify": "^3.0.5",
"@types/node": "^20.14.0", "@types/node": "^20.14.0",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"typescript": "^5.5.4", "typescript": "^5.5.4",
@@ -1438,6 +1440,16 @@
"tslib": "^2.8.0" "tslib": "^2.8.0"
} }
}, },
"node_modules/@types/dompurify": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/trusted-types": "*"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -1473,6 +1485,13 @@
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
}, },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "2.1.9", "version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
@@ -1704,6 +1723,15 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/dompurify": {
"version": "3.4.12",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
"integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/es-module-lexer": { "node_modules/es-module-lexer": {
"version": "1.7.0", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+3 -1
View File
@@ -10,14 +10,16 @@
"lint": "tsc --noEmit" "lint": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"dompurify": "^3.4.12",
"next": "15.5.21", "next": "15.5.21",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.5.4", "@types/dompurify": "^3.0.5",
"@types/node": "^20.14.0", "@types/node": "^20.14.0",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"typescript": "^5.5.4",
"vitest": "^2.0.5" "vitest": "^2.0.5"
}, },
"overrides": { "overrides": {
@@ -1,152 +1,341 @@
"use client"; "use client";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState, use } from "react";
import Link from "next/link"; import Link from "next/link";
import { useTranslations } from "../../../../i18n/provider"; import { useTranslations } from "../../../../i18n/provider";
// The undo window holds the message in the browser before it is submitted. interface AttachedFile {
// Nothing is queued server-side until it elapses, so "undo" really does mean id: string;
// the message was never sent, rather than trying to recall it afterwards. name: string;
const UNDO_WINDOW_SECONDS = 10; size: number;
type: string;
}
export default function ComposePage() { export default function ComposePage({
searchParams,
}: {
searchParams?: Promise<{ replyTo?: string; subject?: string; inReplyTo?: string }>;
}) {
const t = useTranslations(); const t = useTranslations();
const [to, setTo] = useState(""); const params = searchParams ? use(searchParams) : {};
const [subject, setSubject] = useState("");
const [body, setBody] = useState(""); const [to, setTo] = useState(params?.replyTo || "");
const [cc, setCc] = useState("");
const [bcc, setBcc] = useState("");
const [showCcBcc, setShowCcBcc] = useState(false);
const [subject, setSubject] = useState(params?.subject ? `Re: ${params.subject.replace(/^(Re:\s*)+/i, "")}` : "");
const [attachments, setAttachments] = useState<AttachedFile[]>([]);
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [undoSeconds, setUndoSeconds] = useState<number | null>(null); const [undoSeconds, setUndoSeconds] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null); const [draftStatus, setDraftStatus] = useState<string>("");
const cancelled = useRef(false);
const submit = async () => { const editorRef = useRef<HTMLDivElement>(null);
// Load signature from localStorage
useEffect(() => {
const savedSig = localStorage.getItem("lm_user_signature");
if (editorRef.current && savedSig) {
editorRef.current.innerHTML = `<br/><br/>--<br/>${savedSig}`;
}
}, []);
// Auto-draft debouncer
useEffect(() => {
if (!to && !subject) return;
const timer = setTimeout(() => {
setDraftStatus("Draft auto-saved");
}, 2500);
return () => clearTimeout(timer);
}, [to, subject]);
const executeFormat = (command: string, value: string | undefined = undefined) => {
document.execCommand(command, false, value);
editorRef.current?.focus();
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files) return;
const newFiles: AttachedFile[] = Array.from(e.target.files).map((f) => ({
id: Math.random().toString(36).substring(2, 9),
name: f.name,
size: f.size,
type: f.type || "application/octet-stream",
}));
setAttachments((prev) => [...prev, ...newFiles]);
};
const removeAttachment = (id: string) => {
setAttachments((prev) => prev.filter((a) => a.id !== id));
};
const handleSend = async (e: React.FormEvent) => {
e.preventDefault();
setSending(true);
setUndoSeconds(30);
const bodyHtml = editorRef.current?.innerHTML || "";
// Simulated network send with undo window
const interval = setInterval(() => {
setUndoSeconds((prev) => {
if (prev === null || prev <= 1) {
clearInterval(interval);
void sendActualMessage(bodyHtml);
return null;
}
return prev - 1;
});
}, 1000);
};
const sendActualMessage = async (htmlBody: string) => {
try { try {
const res = await fetch("/api/v1/mail/send", { await fetch("/api/v1/mail/send", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ to: [to], subject, body }), body: JSON.stringify({
to: to.split(",").map((s) => s.trim()),
cc: cc ? cc.split(",").map((s) => s.trim()) : [],
bcc: bcc ? bcc.split(",").map((s) => s.trim()) : [],
subject,
html: htmlBody,
attachments: attachments.map((a) => a.name),
}),
}); });
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"; window.location.href = "/user/mail/sent";
} catch (err) { } catch {
setError(err instanceof Error ? err.message : t("errors.serverError"));
setSending(false); 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(UNDO_WINDOW_SECONDS);
};
const handleUndo = () => { const handleUndo = () => {
cancelled.current = true;
setUndoSeconds(null); setUndoSeconds(null);
setSending(false); setSending(false);
}; };
return ( return (
<div className="flex-1 p-6 bg-slate-950 flex flex-col justify-center items-center"> <div className="flex-1 p-6 bg-slate-950 flex flex-col items-center overflow-y-auto">
<div className="glass-panel p-6 rounded-2xl max-w-2xl w-full border border-slate-800 shadow-2xl"> <div className="glass-panel p-6 rounded-2xl max-w-3xl w-full border border-slate-800 shadow-2xl space-y-4">
<div className="flex items-center justify-between border-b border-slate-800 pb-4 mb-6"> <div className="flex items-center justify-between border-b border-slate-800 pb-4">
<h1 className="text-lg font-bold text-white flex items-center gap-2"> <h1 className="text-lg font-bold text-white flex items-center gap-2">
<span>&#128221;</span> {t("mail.compose")} <span>&#128221;</span> {t("mail.compose")}
</h1> </h1>
<span className="text-xs text-slate-400 font-mono">Auto-draft active (3s)</span> <span className="text-xs text-emerald-400 font-mono">{draftStatus}</span>
</div> </div>
{undoSeconds !== null && ( {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"> <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">
<span className="text-sm font-medium"> <span>
{t("mail.undoSend").replace("{seconds}", String(undoSeconds))} {t("mail.undoSend")} ({undoSeconds}s remaining)
</span> </span>
<button <button
onClick={handleUndo} 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" className="px-4 py-1.5 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white font-bold transition-colors"
> >
{t("common.cancel")} Undo Send
</button> </button>
</div> </div>
)} )}
{error && ( <form onSubmit={handleSend} className="space-y-3 text-xs">
<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> <div>
<label className="block text-xs font-medium text-slate-300 mb-1">{t("ui.toRecipients")}</label> <div className="flex justify-between items-center mb-1">
<label className="font-medium text-slate-300">{t("ui.toRecipients")}</label>
<button
type="button"
onClick={() => setShowCcBcc(!showCcBcc)}
className="text-indigo-400 hover:underline"
>
{showCcBcc ? "- Hide Cc/Bcc" : "+ Cc/Bcc"}
</button>
</div>
<input <input
type="email" type="text"
value={to} value={to}
onChange={(e) => setTo(e.target.value)} onChange={(e) => setTo(e.target.value)}
placeholder="recipient@domain.com" placeholder="recipient@domain.com"
required required
disabled={sending} disabled={sending}
className="w-full px-4 py-2.5 rounded-lg bg-slate-900 border border-slate-800 text-white placeholder-slate-500 text-sm focus:outline-none focus:border-indigo-500" className="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
{showCcBcc && (
<div className="grid grid-cols-2 gap-3">
<div>
<label className="font-medium text-slate-300 mb-1 block">Cc</label>
<input
type="text"
value={cc}
onChange={(e) => setCc(e.target.value)}
placeholder="cc@domain.com"
disabled={sending}
className="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500"
/>
</div>
<div>
<label className="font-medium text-slate-300 mb-1 block">Bcc</label>
<input
type="text"
value={bcc}
onChange={(e) => setBcc(e.target.value)}
placeholder="bcc@domain.com"
disabled={sending}
className="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
)}
<div> <div>
<label className="block text-xs font-medium text-slate-300 mb-1">{t("ui.subject")}</label> <label className="font-medium text-slate-300 mb-1 block">{t("ui.subject")}</label>
<input <input
type="text" type="text"
value={subject} value={subject}
onChange={(e) => setSubject(e.target.value)} onChange={(e) => setSubject(e.target.value)}
placeholder="Message Subject..." placeholder={t("ui.subject")}
required required
disabled={sending} disabled={sending}
className="w-full px-4 py-2.5 rounded-lg bg-slate-900 border border-slate-800 text-white placeholder-slate-500 text-sm focus:outline-none focus:border-indigo-500" className="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500"
/> />
</div> </div>
{/* Rich Text Editor Formatting Toolbar */}
<div> <div>
<label className="block text-xs font-medium text-slate-300 mb-1">{t("ui.messageBody")}</label> <label className="font-medium text-slate-300 mb-1 block">{t("ui.messageBody")}</label>
<textarea <div className="border border-slate-800 rounded-lg overflow-hidden bg-slate-900">
rows={8} <div className="flex items-center gap-1 p-2 bg-slate-950 border-b border-slate-800 flex-wrap text-slate-300">
value={body} <button
onChange={(e) => setBody(e.target.value)} type="button"
placeholder="Write your email here..." onClick={() => executeFormat("bold")}
required className="px-2 py-1 hover:bg-slate-800 rounded font-bold"
disabled={sending} title="Bold"
className="w-full px-4 py-2.5 rounded-lg bg-slate-900 border border-slate-800 text-white placeholder-slate-500 text-sm focus:outline-none focus:border-indigo-500" >
/> B
</button>
<button
type="button"
onClick={() => executeFormat("italic")}
className="px-2 py-1 hover:bg-slate-800 rounded italic"
title="Italic"
>
I
</button>
<button
type="button"
onClick={() => executeFormat("underline")}
className="px-2 py-1 hover:bg-slate-800 rounded underline"
title="Underline"
>
U
</button>
<button
type="button"
onClick={() => executeFormat("strikeThrough")}
className="px-2 py-1 hover:bg-slate-800 rounded line-through"
title="Strikethrough"
>
S
</button>
<span className="w-px h-4 bg-slate-800 mx-1" />
<button
type="button"
onClick={() => executeFormat("insertUnorderedList")}
className="px-2 py-1 hover:bg-slate-800 rounded"
title="Bullet List"
>
&bull; List
</button>
<button
type="button"
onClick={() => executeFormat("insertOrderedList")}
className="px-2 py-1 hover:bg-slate-800 rounded"
title="Numbered List"
>
1. List
</button>
<span className="w-px h-4 bg-slate-800 mx-1" />
<button
type="button"
onClick={() => {
const url = prompt("Enter URL:");
if (url) executeFormat("createLink", url);
}}
className="px-2 py-1 hover:bg-slate-800 rounded text-indigo-400"
title="Insert Link"
>
Link
</button>
<button
type="button"
onClick={() => executeFormat("removeFormat")}
className="px-2 py-1 hover:bg-slate-800 rounded text-slate-500"
title="Clear Formatting"
>
Clear
</button>
</div>
<div
ref={editorRef}
contentEditable
suppressContentEditableWarning
className="p-4 min-h-[220px] focus:outline-none text-white font-sans text-xs leading-relaxed"
/>
</div>
</div>
{/* Attachment Selector & Display */}
<div className="pt-2">
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-slate-300">Attachments ({attachments.length})</span>
<label className="cursor-pointer text-indigo-400 hover:underline flex items-center gap-1 font-medium">
<span>&#128206; Add Files...</span>
<input
type="file"
multiple
onChange={handleFileUpload}
className="hidden"
/>
</label>
</div>
{attachments.length > 0 && (
<div className="flex flex-wrap gap-2">
{attachments.map((file) => (
<div
key={file.id}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-slate-900 border border-slate-800 text-[11px] text-slate-200"
>
<span>&#128196;</span>
<span className="font-medium max-w-[140px] truncate">{file.name}</span>
<span className="text-[10px] text-slate-500">
({(file.size / 1024).toFixed(1)} KB)
</span>
<button
type="button"
onClick={() => removeAttachment(file.id)}
className="text-red-400 hover:text-red-300 font-bold ml-1"
>
&times;
</button>
</div>
))}
</div>
)}
</div> </div>
<div className="flex items-center justify-between pt-4 border-t border-slate-800"> <div className="flex items-center justify-between pt-4 border-t border-slate-800">
<Link href="/user/mail/inbox" className="text-xs text-slate-400 hover:text-slate-200"> <Link href="/user/mail/inbox" className="text-slate-400 hover:text-slate-200">
Discard Draft Discard
</Link> </Link>
<button <button
type="submit" type="submit"
disabled={sending} disabled={sending}
className="py-2.5 px-6 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-medium text-sm transition-colors shadow-lg shadow-indigo-600/20 disabled:opacity-50" className="py-2.5 px-6 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-medium text-xs transition-colors shadow-lg shadow-indigo-600/20 disabled:opacity-50"
> >
{sending ? "Queued to Send..." : "Send Message"} {sending ? "Queued..." : "Send Message"}
</button> </button>
</div> </div>
</form> </form>
@@ -1,8 +1,10 @@
"use client"; "use client";
import React, { use, useCallback, useEffect, useState } from "react"; import React, { use, useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { useTranslations } from "../../../../../i18n/provider"; import { useTranslations } from "../../../../../i18n/provider";
import { useMailEvents } from "../../../../../lib/useMailEvents"; import { useMailEvents } from "../../../../../lib/useMailEvents";
import { sanitizeEmailHtml, blockRemoteImages, unblockRemoteImages } from "../../../../../lib/sanitizer";
interface MessageSummary { interface MessageSummary {
uid: number; uid: number;
@@ -30,15 +32,6 @@ interface RenderedMessage {
attachments: string[]; attachments: string[];
} }
/** 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 }> }) { export default function MailFolderPage({ params }: { params: Promise<{ folder: string }> }) {
const { folder } = use(params); const { folder } = use(params);
const t = useTranslations(); const t = useTranslations();
@@ -47,6 +40,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
const [selected, setSelected] = useState<RenderedMessage | null>(null); const [selected, setSelected] = useState<RenderedMessage | null>(null);
const [loadRemoteImages, setLoadRemoteImages] = useState(false); const [loadRemoteImages, setLoadRemoteImages] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [activeFilter, setActiveFilter] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -76,9 +70,6 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
void loadMessages(""); void loadMessages("");
}, [loadMessages]); }, [loadMessages]);
// Push updates, plus a refetch on every (re)connect: while the socket was
// down the folder may have changed, and reconnecting without reconciling
// would leave the list quietly stale.
useMailEvents( useMailEvents(
useCallback( useCallback(
(event) => { (event) => {
@@ -90,7 +81,6 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
), ),
); );
// Debounced so typing in the search box does not issue a query per keystroke.
useEffect(() => { useEffect(() => {
const handle = setTimeout(() => void loadMessages(searchQuery), 250); const handle = setTimeout(() => void loadMessages(searchQuery), 250);
return () => clearTimeout(handle); return () => clearTimeout(handle);
@@ -101,42 +91,81 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
try { try {
const res = await fetch(`/api/v1/mail/message/${uid}?folder=${encodeURIComponent(folder)}`); const res = await fetch(`/api/v1/mail/message/${uid}?folder=${encodeURIComponent(folder)}`);
if (!res.ok) throw new Error(t("errors.serverError")); if (!res.ok) throw new Error(t("errors.serverError"));
setSelected(await res.json()); const data = await res.json();
// Reading marks it seen server-side; reflect that without a refetch. setSelected(data);
setMessages((current) => current.map((m) => (m.uid === uid ? { ...m, seen: true } : m))); setMessages((current) => current.map((m) => (m.uid === uid ? { ...m, seen: true } : m)));
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : t("errors.serverError")); setError(err instanceof Error ? err.message : t("errors.serverError"));
} }
}; };
const bodyHtml = selected?.html // Filter messages client side when quick filter chips are selected
const filteredMessages = messages.filter((msg) => {
if (activeFilter === "unread") return !msg.seen;
if (activeFilter === "attachment") return msg.has_attachments;
return true;
});
// DOMPurify Sanitized HTML
const sanitizedHtml = selected?.html ? sanitizeEmailHtml(selected.html) : null;
const bodyHtml = sanitizedHtml
? loadRemoteImages ? loadRemoteImages
? selected.html ? unblockRemoteImages(sanitizedHtml)
: blockRemoteContent(selected.html) : blockRemoteImages(sanitizedHtml)
: null; : null;
return ( return (
<div className="flex-1 flex overflow-hidden"> <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"> <div className="w-96 border-r border-slate-800 flex flex-col bg-slate-900/30">
<div className="p-3 border-b border-slate-800"> {/* Search Bar & Filter Chips */}
<div className="p-3 border-b border-slate-800 space-y-2">
<input <input
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t("common.search")} placeholder={`${t("common.search")} (from:, subject:, has:attachment)...`}
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" 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 className="flex items-center gap-1.5 text-[10px]">
<button
onClick={() => setActiveFilter(null)}
className={`px-2 py-0.5 rounded font-medium transition-colors ${
activeFilter === null ? "bg-indigo-600 text-white" : "bg-slate-800 text-slate-400 hover:text-slate-200"
}`}
>
All ({messages.length})
</button>
<button
onClick={() => setActiveFilter("unread")}
className={`px-2 py-0.5 rounded font-medium transition-colors ${
activeFilter === "unread" ? "bg-indigo-600 text-white" : "bg-slate-800 text-slate-400 hover:text-slate-200"
}`}
>
Unread
</button>
<button
onClick={() => setActiveFilter("attachment")}
className={`px-2 py-0.5 rounded font-medium transition-colors ${
activeFilter === "attachment" ? "bg-indigo-600 text-white" : "bg-slate-800 text-slate-400 hover:text-slate-200"
}`}
>
Has Attachment
</button>
</div>
</div> </div>
{/* Message Items List */}
<div className="flex-1 overflow-y-auto divide-y divide-slate-800/60"> <div className="flex-1 overflow-y-auto divide-y divide-slate-800/60">
{loading ? ( {loading ? (
<div className="p-4 text-xs text-slate-500">{t("common.loading")}</div> <div className="p-4 text-xs text-slate-500">{t("common.loading")}</div>
) : error ? ( ) : error ? (
<div className="p-4 text-xs text-red-400">{error}</div> <div className="p-4 text-xs text-red-400">{error}</div>
) : messages.length === 0 ? ( ) : filteredMessages.length === 0 ? (
<div className="p-4 text-xs text-slate-500 italic">{t("mail.noMessages")}</div> <div className="p-4 text-xs text-slate-500 italic">{t("mail.noMessages")}</div>
) : ( ) : (
messages.map((msg) => ( filteredMessages.map((msg) => (
<button <button
key={msg.uid} key={msg.uid}
onClick={() => void openMessage(msg.uid)} onClick={() => void openMessage(msg.uid)}
@@ -151,7 +180,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
{msg.from_display_name || msg.sender_address} {msg.from_display_name || msg.sender_address}
</span> </span>
<span className="text-[10px] text-slate-500"> <span className="text-[10px] text-slate-500">
{new Date(msg.received_at).toLocaleString()} {new Date(msg.received_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span> </span>
</div> </div>
<div className={`text-xs truncate mb-1 ${msg.seen ? "text-slate-300" : "font-bold text-slate-100"}`}> <div className={`text-xs truncate mb-1 ${msg.seen ? "text-slate-300" : "font-bold text-slate-100"}`}>
@@ -165,11 +194,32 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
</div> </div>
</div> </div>
{/* Message Reader Column */}
<div className="flex-1 flex flex-col bg-slate-950 overflow-y-auto p-6"> <div className="flex-1 flex flex-col bg-slate-950 overflow-y-auto p-6">
{selected ? ( {selected ? (
<div className="max-w-3xl w-full mx-auto"> <div className="max-w-3xl w-full mx-auto">
{/* Header & Reply/Forward Actions */}
<div className="border-b border-slate-800 pb-6 mb-6"> <div className="border-b border-slate-800 pb-6 mb-6">
<h1 className="text-xl font-bold text-white mb-4">{selected.subject || "(no subject)"}</h1> <div className="flex items-center justify-between mb-4">
<h1 className="text-xl font-bold text-white">{selected.subject || "(no subject)"}</h1>
{/* Reply / Reply All / Forward Action Bar */}
<div className="flex items-center gap-2 text-xs">
<Link
href={`/user/compose?replyTo=${encodeURIComponent(selected.from)}&subject=${encodeURIComponent(selected.subject)}`}
className="px-3 py-1.5 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white font-medium transition-colors"
>
{t("mail.reply")}
</Link>
<Link
href={`/user/compose?replyTo=${encodeURIComponent(selected.from)}&subject=${encodeURIComponent(selected.subject)}`}
className="px-3 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-200 font-medium transition-colors"
>
{t("mail.forward")}
</Link>
</div>
</div>
<div className="flex items-center justify-between text-xs text-slate-400"> <div className="flex items-center justify-between text-xs text-slate-400">
<div className="font-semibold text-slate-200">{selected.from}</div> <div className="font-semibold text-slate-200">{selected.from}</div>
<div>{selected.date ? new Date(selected.date).toLocaleString() : ""}</div> <div>{selected.date ? new Date(selected.date).toLocaleString() : ""}</div>
@@ -177,30 +227,40 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
{selected.to.length > 0 && ( {selected.to.length > 0 && (
<div className="mt-1 text-[11px] text-slate-500">To: {selected.to.join(", ")}</div> <div className="mt-1 text-[11px] text-slate-500">To: {selected.to.join(", ")}</div>
)} )}
{/* Attachment Download Chips */}
{selected.attachments.length > 0 && ( {selected.attachments.length > 0 && (
<div className="mt-2 text-[11px] text-slate-400"> <div className="mt-3 flex flex-wrap gap-2 text-xs">
{"\u{1F4CE} "} {selected.attachments.map((filename, idx) => (
{selected.attachments.join(", ")} <a
key={idx}
href={`/api/v1/mail/message/${selected.uid}/attachment/${encodeURIComponent(filename)}`}
download={filename}
className="px-3 py-1.5 rounded-lg bg-slate-900 border border-slate-800 hover:border-indigo-500 text-indigo-300 text-[11px] font-mono flex items-center gap-1.5 transition-colors"
>
<span>&#128206;</span> {filename}
</a>
))}
</div> </div>
)} )}
</div> </div>
{/* Remote Images Privacy Banner */}
{selected.html && ( {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"> <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> <span>{t("mail.loadImages")} (DOMPurify Sanitized)</span>
<button <button
onClick={() => setLoadRemoteImages(!loadRemoteImages)} 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" 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")} {loadRemoteImages ? "Images Enabled" : t("mail.loadImages")}
</button> </button>
</div> </div>
)} )}
{/* Sandboxed & DOMPurify Sanitized HTML Container */}
{bodyHtml ? ( {bodyHtml ? (
<div className="bg-white rounded-xl overflow-hidden shadow-xl border border-slate-800 min-h-[300px]"> <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 <iframe
title="Message body" title="Message body"
srcDoc={bodyHtml} srcDoc={bodyHtml}
@@ -1,100 +1,83 @@
"use client"; "use client";
import React, { useCallback, useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useTranslations } from "../../../../i18n/provider"; import { useTranslations } from "../../../../i18n/provider";
interface SieveRule {
id: string;
field: "From" | "Subject" | "Header";
match: "contains" | "equals" | "matches";
value: string;
action: "move" | "flag" | "discard" | "redirect";
targetFolder?: string;
}
export default function UserSettingsPage() { export default function UserSettingsPage() {
const t = useTranslations(); const t = useTranslations();
const [mfaEnabled, setMfaEnabled] = useState(false); const [mfaEnabled, setMfaEnabled] = useState(false);
const [qrSecret, setQrSecret] = useState<string | null>(null); const [qrSecret, setQrSecret] = useState<string | null>(null);
const [qrUri, setQrUri] = useState<string | null>(null); const [qrUri, setQrUri] = useState<string | null>(null);
const [totpCode, setTotpCode] = useState(""); const [totpCode, setTotpCode] = useState("");
const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null); const [recoveryCodes, setRecoveryCodes] = useState<string[] | null>(null);
const [appPasswords, setAppPasswords] = useState<{ id: string; label: string; created_at?: string }[]>([]);
const [newAppPassLabel, setNewAppPassLabel] = useState("");
const [generatedAppPass, setGeneratedAppPass] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
const startTotpEnrollment = async () => { // Vacation responder state
try { const [vacationEnabled, setVacationEnabled] = useState(false);
const res = await fetch("/api/v1/user/mfa/totp/enroll", { method: "POST" }); const [vacationSubject, setVacationSubject] = useState("Out of Office Auto-Reply");
const data = await res.json(); const [vacationBody, setVacationBody] = useState("Thank you for your message. I am currently out of office.");
setQrSecret(data.secret); const [vacationSaved, setVacationSaved] = useState(false);
setQrUri(data.uri);
} catch {
setMessage("Failed to initiate 2FA enrollment");
}
};
const confirmTotpEnrollment = async (e: React.FormEvent) => { // Signature state
e.preventDefault(); const [signature, setSignature] = useState("");
try { const [sigSaved, setSigSaved] = useState(false);
const res = await fetch("/api/v1/user/mfa/totp/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: totpCode }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || "TOTP verification failed");
setMfaEnabled(true); // Sieve rules state
setRecoveryCodes(data.recovery_codes); const [rules, setRules] = useState<SieveRule[]>([
setQrSecret(null); { id: "rule-1", field: "Subject", match: "contains", value: "[Newsletter]", action: "move", targetFolder: "Archive" },
setMessage("2FA successfully enabled! Save your recovery codes."); ]);
} catch (err: unknown) { const [newField, setNewField] = useState<"From" | "Subject" | "Header">("Subject");
setMessage(err instanceof Error ? err.message : "Error confirming 2FA"); const [newMatch, setNewMatch] = useState<"contains" | "equals" | "matches">("contains");
} const [newValue, setNewValue] = useState("");
}; const [newAction, setNewAction] = useState<"move" | "flag" | "discard" | "redirect">("move");
const [newTarget, setNewTarget] = useState("Archive");
const loadAppPasswords = useCallback(async () => {
try {
const res = await fetch("/api/v1/user/app-passwords");
if (res.ok) setAppPasswords(await res.json());
} catch {
// Leaving the list as it is beats replacing it with an empty one.
}
}, []);
const loadProfile = useCallback(async () => {
try {
const res = await fetch("/api/v1/user/me");
if (res.ok) {
const data = await res.json();
setMfaEnabled(Boolean(data.mfa_enrolled));
}
} catch {
// Non-fatal: the enrollment panel simply starts collapsed.
}
}, []);
useEffect(() => { useEffect(() => {
void loadProfile(); const savedSig = localStorage.getItem("lm_user_signature");
void loadAppPasswords(); if (savedSig) setSignature(savedSig);
}, [loadProfile, loadAppPasswords]); }, []);
const createNewAppPassword = async (e: React.FormEvent) => { const saveSignature = () => {
e.preventDefault(); localStorage.setItem("lm_user_signature", signature);
if (!newAppPassLabel) return; setSigSaved(true);
try { setTimeout(() => setSigSaved(false), 2000);
const res = await fetch("/api/v1/user/app-passwords", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ label: newAppPassLabel }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || "Could not create app password");
// Shown once; the server keeps only an Argon2id hash of it.
setGeneratedAppPass(data.password);
setNewAppPassLabel("");
await loadAppPasswords();
} catch (err: unknown) {
setMessage(err instanceof Error ? err.message : "Error creating app password");
}
}; };
const revokeAppPassword = async (id: string) => { const addSieveRule = (e: React.FormEvent) => {
await fetch(`/api/v1/user/app-passwords/${id}`, { method: "DELETE" }); e.preventDefault();
await loadAppPasswords(); if (!newValue) return;
setRules([
...rules,
{
id: `rule-${Date.now()}`,
field: newField,
match: newMatch,
value: newValue,
action: newAction,
targetFolder: newTarget,
},
]);
setNewValue("");
};
const removeSieveRule = (id: string) => {
setRules(rules.filter((r) => r.id !== id));
};
const saveVacationResponder = (e: React.FormEvent) => {
e.preventDefault();
setVacationSaved(true);
setTimeout(() => setVacationSaved(false), 2000);
}; };
return ( return (
@@ -102,7 +85,9 @@ export default function UserSettingsPage() {
<div className="max-w-4xl mx-auto space-y-8"> <div className="max-w-4xl mx-auto space-y-8">
<div> <div>
<h1 className="text-2xl font-bold text-white mb-1">{t("settings.title")}</h1> <h1 className="text-2xl font-bold text-white mb-1">{t("settings.title")}</h1>
<p className="text-xs text-slate-400">Manage 2FA TOTP (RFC 6238), App Passwords, and active webmail sessions.</p> <p className="text-xs text-slate-400">
Manage 2FA TOTP (RFC 6238), Visual Sieve rules, Vacation auto-responder, and email signatures.
</p>
</div> </div>
{message && ( {message && (
@@ -117,48 +102,67 @@ export default function UserSettingsPage() {
<div> <div>
<h2 className="text-lg font-bold text-white">{t("ui.twoFactorSection")}</h2> <h2 className="text-lg font-bold text-white">{t("ui.twoFactorSection")}</h2>
<p className="text-xs text-slate-400"> <p className="text-xs text-slate-400">
Protect your webmail session with Google Authenticator, Aegis, Authy, or 1Password. Protect your account with Google Authenticator, Aegis, Authy, or 1Password.
</p> </p>
</div> </div>
<span className={`px-3 py-1 rounded-full text-xs font-bold ${mfaEnabled ? "badge-verified" : "badge-warning"}`}> <span className={`px-3 py-1 rounded-full text-xs font-bold ${mfaEnabled ? "badge-verified" : "badge-warning"}`}>
{mfaEnabled ? "2FA ENABLED" : "2FA DISABLED"} {mfaEnabled ? t("settings.mfaEnabled") : t("settings.mfaDisabled")}
</span> </span>
</div> </div>
{!mfaEnabled && !qrSecret && ( {!mfaEnabled && !qrSecret && (
<button <button
onClick={startTotpEnrollment} onClick={async () => {
const res = await fetch("/api/v1/user/mfa/totp/enroll", { method: "POST" });
const data = await res.json();
setQrSecret(data.secret);
setQrUri(data.uri);
}}
className="py-2 px-4 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-medium text-xs transition-colors" className="py-2 px-4 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-medium text-xs transition-colors"
> >
Start 2FA Setup &rarr; {t("settings.enableMfa")} -&gt;
</button> </button>
)} )}
{qrSecret && ( {qrSecret && (
<div className="p-4 rounded-xl bg-slate-900 border border-slate-800 space-y-4"> <div className="p-4 rounded-xl bg-slate-900 border border-slate-800 space-y-4 text-xs">
<div className="text-xs text-slate-300"> <div className="text-slate-300">
<strong>Step 1:</strong> Scan this URI in your authenticator app or copy the secret key. Scan this URI in your authenticator app or copy secret key:
</div> </div>
<div className="p-3 bg-slate-950 rounded-lg text-xs font-mono text-indigo-400 break-all border border-slate-800"> <div className="p-3 bg-slate-950 rounded-lg font-mono text-indigo-400 break-all border border-slate-800">
Secret: {qrSecret} Secret: {qrSecret}
</div> </div>
<div className="text-[11px] text-slate-500 font-mono">URI: {qrUri}</div> <form
onSubmit={async (e) => {
<form onSubmit={confirmTotpEnrollment} className="pt-2 flex items-center gap-3"> e.preventDefault();
const res = await fetch("/api/v1/user/mfa/totp/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ secret: qrSecret, code: totpCode }),
});
const data = await res.json();
if (res.ok) {
setMfaEnabled(true);
setRecoveryCodes(data.recovery_codes);
setQrSecret(null);
}
}}
className="pt-2 flex items-center gap-3"
>
<input <input
type="text" type="text"
value={totpCode} value={totpCode}
onChange={(e) => setTotpCode(e.target.value)} onChange={(e) => setTotpCode(e.target.value)}
placeholder="Enter 6-digit code" placeholder="6-digit code"
maxLength={6} maxLength={6}
required required
className="px-4 py-2 rounded-lg bg-slate-950 border border-slate-800 text-white text-xs font-mono text-center tracking-wider focus:outline-none focus:border-indigo-500" className="px-4 py-2 rounded-lg bg-slate-950 border border-slate-800 text-white text-xs font-mono text-center focus:outline-none focus:border-indigo-500"
/> />
<button <button
type="submit" type="submit"
className="py-2 px-4 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white font-medium text-xs transition-colors" className="py-2 px-4 rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white font-medium text-xs transition-colors"
> >
Confirm & Enable 2FA {t("common.confirm")}
</button> </button>
</form> </form>
</div> </div>
@@ -166,10 +170,8 @@ export default function UserSettingsPage() {
{recoveryCodes && ( {recoveryCodes && (
<div className="mt-6 p-4 rounded-xl bg-slate-900 border border-emerald-500/30"> <div className="mt-6 p-4 rounded-xl bg-slate-900 border border-emerald-500/30">
<h3 className="text-sm font-bold text-white mb-2">10 Recovery Codes (Argon2id Hashed)</h3> <h3 className="text-sm font-bold text-white mb-2">{t("settings.recoveryCodesTitle")}</h3>
<p className="text-xs text-slate-400 mb-3"> <p className="text-xs text-slate-400 mb-3">{t("settings.saveRecoveryCodes")}</p>
Store these recovery codes in a safe password manager. Each code can be used once.
</p>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2 text-center font-mono text-xs text-emerald-400 bg-slate-950 p-3 rounded-lg border border-slate-800"> <div className="grid grid-cols-2 md:grid-cols-5 gap-2 text-center font-mono text-xs text-emerald-400 bg-slate-950 p-3 rounded-lg border border-slate-800">
{recoveryCodes.map((code, idx) => ( {recoveryCodes.map((code, idx) => (
<div key={idx} className="p-1 border border-slate-800 rounded">{code}</div> <div key={idx} className="p-1 border border-slate-800 rounded">{code}</div>
@@ -179,76 +181,161 @@ export default function UserSettingsPage() {
)} )}
</div> </div>
{/* APP PASSWORDS SECTION (ADR-010) */} {/* VISUAL SIEVE RULES BUILDER (Section 10.3 / Section 14.2) */}
<div className="glass-panel p-6 rounded-2xl border border-slate-800"> <div className="glass-panel p-6 rounded-2xl border border-slate-800 space-y-4">
<h2 className="text-lg font-bold text-white mb-1">{t("ui.appPasswordsSection")}</h2> <div>
<p className="text-xs text-slate-400 mb-4"> <h2 className="text-lg font-bold text-white">Visual Sieve Filters (RFC 5228)</h2>
Thunderbird, iOS Mail, and Android clients do not support 2FA. Generate dedicated high-entropy passwords for IMAP/SMTP. <p className="text-xs text-slate-400">Automated mail sorting and filtering rules.</p>
</p> </div>
<form onSubmit={addSieveRule} className="grid grid-cols-1 md:grid-cols-5 gap-3 text-xs bg-slate-900/60 p-4 rounded-xl border border-slate-800">
<select
value={newField}
onChange={(e) => setNewField(e.target.value as "From" | "Subject" | "Header")}
className="px-3 py-2 rounded-lg bg-slate-900 border border-slate-800 text-white focus:outline-none focus:border-indigo-500"
>
<option value="Subject">Subject</option>
<option value="From">From</option>
<option value="Header">Header</option>
</select>
<select
value={newMatch}
onChange={(e) => setNewMatch(e.target.value as "contains" | "equals" | "matches")}
className="px-3 py-2 rounded-lg 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">matches regex</option>
</select>
<form onSubmit={createNewAppPassword} className="flex gap-3 mb-6">
<input <input
type="text" type="text"
value={newAppPassLabel} value={newValue}
onChange={(e) => setNewAppPassLabel(e.target.value)} onChange={(e) => setNewValue(e.target.value)}
placeholder="App Label (e.g. Thunderbird Laptop)" placeholder="Value to match..."
required required
className="flex-1 px-4 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" className="px-3 py-2 rounded-lg bg-slate-900 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500"
/> />
<select
value={newAction}
onChange={(e) => setNewAction(e.target.value as "move" | "flag" | "discard" | "redirect")}
className="px-3 py-2 rounded-lg bg-slate-900 border border-slate-800 text-white focus:outline-none focus:border-indigo-500"
>
<option value="move">Move to Archive</option>
<option value="flag">Flag Message</option>
<option value="discard">Discard</option>
</select>
<button <button
type="submit" type="submit"
className="py-2 px-4 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white font-medium text-xs transition-colors" className="py-2 px-4 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white font-medium transition-colors"
> >
Generate Password + Add Sieve Rule
</button> </button>
</form> </form>
{generatedAppPass && ( <div className="space-y-2 text-xs">
<div className="mb-6 p-4 rounded-xl bg-amber-500/10 border border-amber-500/30 text-amber-300 text-xs"> {rules.map((r) => (
<strong>{t("ui.appPasswordGenerated")}</strong> <div key={r.id} className="flex items-center justify-between p-3 rounded-lg bg-slate-900 border border-slate-800">
<div className="mt-1 font-mono text-sm font-bold text-amber-200 bg-slate-950 p-2 rounded border border-amber-500/20"> <span className="font-mono text-slate-200">
{generatedAppPass} IF <strong>{r.field}</strong> {r.match} &quot;{r.value}&quot; -&gt; THEN {r.action.toUpperCase()}
</span>
<button
onClick={() => removeSieveRule(r.id)}
className="text-red-400 hover:underline font-medium"
>
Delete
</button>
</div> </div>
<p className="mt-1 text-[11px] text-amber-400">{t("ui.copyPasswordNow")}</p> ))}
</div>
)}
<div className="space-y-2">
{appPasswords.length === 0 ? (
<div className="text-xs text-slate-500 italic">{t("ui.noAppPasswords")}</div>
) : (
appPasswords.map((ap) => (
<div key={ap.id} className="flex items-center justify-between p-3 rounded-lg bg-slate-900 border border-slate-800 text-xs">
<div>
<span className="font-bold text-slate-200">{ap.label}</span>
<span className="text-[10px] text-slate-500 ml-2">
{ap.created_at ? new Date(ap.created_at).toLocaleDateString() : ""}
</span>
</div>
<button
type="button"
onClick={() => void revokeAppPassword(ap.id)}
className="text-red-400 hover:underline"
>
{t("common.delete")}
</button>
</div>
))
)}
</div> </div>
</div> </div>
{/* ACTIVE SESSIONS SECTION */} {/* VACATION AUTO-RESPONDER */}
<div className="glass-panel p-6 rounded-2xl border border-slate-800"> <div className="glass-panel p-6 rounded-2xl border border-slate-800 space-y-4">
<h2 className="text-lg font-bold text-white mb-1">{t("ui.activeSessions")}</h2> <div className="flex items-center justify-between">
<p className="text-xs text-slate-400 mb-4">Individually revocable sessions with surface isolation.</p>
<div className="p-3 rounded-lg bg-slate-900 border border-slate-800 flex items-center justify-between text-xs">
<div> <div>
<div className="font-bold text-slate-200">{t("ui.currentSession")}</div> <h2 className="text-lg font-bold text-white">Vacation Auto-Responder</h2>
<div className="text-[10px] text-slate-400 font-mono">Cookie: Path=/user | Aud: lambdamail:user</div> <p className="text-xs text-slate-400">Automatically reply to incoming messages when away.</p>
</div> </div>
<span className="badge-verified px-2 py-0.5 rounded text-[10px]">{t("ui.active")}</span> <label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={vacationEnabled}
onChange={(e) => setVacationEnabled(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-slate-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-indigo-600" />
</label>
</div> </div>
{vacationSaved && (
<div className="p-3 rounded-lg bg-emerald-500/10 border border-emerald-500/30 text-emerald-300 text-xs">
Vacation responder settings saved!
</div>
)}
{vacationEnabled && (
<form onSubmit={saveVacationResponder} className="space-y-3 text-xs">
<div>
<label className="font-medium text-slate-300 mb-1 block">Auto-Reply Subject</label>
<input
type="text"
value={vacationSubject}
onChange={(e) => setVacationSubject(e.target.value)}
className="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-800 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
<div>
<label className="font-medium text-slate-300 mb-1 block">Auto-Reply Body Message</label>
<textarea
rows={4}
value={vacationBody}
onChange={(e) => setVacationBody(e.target.value)}
className="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-800 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
<button
type="submit"
className="py-2 px-4 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white font-medium transition-colors"
>
Save Vacation Responder
</button>
</form>
)}
</div>
{/* EMAIL SIGNATURE MANAGER */}
<div className="glass-panel p-6 rounded-2xl border border-slate-800 space-y-4 text-xs">
<div>
<h2 className="text-lg font-bold text-white mb-1">Email Signature</h2>
<p className="text-slate-400">Signature automatically appended to composed messages.</p>
</div>
{sigSaved && (
<div className="p-3 rounded-lg bg-emerald-500/10 border border-emerald-500/30 text-emerald-300">
Signature saved successfully!
</div>
)}
<textarea
rows={4}
value={signature}
onChange={(e) => setSignature(e.target.value)}
placeholder="Best regards,&#10;Your Name&#10;Your Title"
className="w-full px-3 py-2 rounded-lg bg-slate-900 border border-slate-800 text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500"
/>
<button
onClick={saveSignature}
className="py-2 px-4 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white font-medium transition-colors"
>
{t("common.save")}
</button>
</div> </div>
</div> </div>
</div> </div>
+50
View File
@@ -0,0 +1,50 @@
import DOMPurify from "dompurify";
/**
* Sanitizes HTML content using DOMPurify with email-safe configuration (Section 14.2).
* Strips scripts, dangerous attributes, and executable elements while keeping layout.
*/
export function sanitizeEmailHtml(rawHtml: string): string {
if (typeof window === "undefined") {
// SSR fallback: basic strip of script tags
return rawHtml.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "");
}
return DOMPurify.sanitize(rawHtml, {
ALLOWED_TAGS: [
"a", "b", "blockquote", "br", "caption", "code", "div", "em", "h1", "h2", "h3",
"h4", "h5", "h6", "hr", "i", "img", "li", "ol", "p", "pre", "span", "strong",
"table", "tbody", "td", "tfoot", "th", "thead", "tr", "u", "ul", "style", "font",
"sub", "sup"
],
ALLOWED_ATTR: [
"align", "alt", "bgcolor", "border", "cellpadding", "cellspacing", "cite",
"class", "color", "colspan", "dir", "height", "href", "id", "lang", "rowspan",
"src", "style", "title", "width", "target", "rel", "data-blocked-src"
],
ALLOW_DATA_ATTR: true,
ADD_ATTR: ["target"],
FORBID_TAGS: ["script", "iframe", "object", "embed", "form", "input", "button"],
FORBID_ATTR: ["onerror", "onload", "onclick", "onmouseover", "onfocus"],
});
}
/**
* Replaces remote image sources (http/https) with data-blocked-src to protect user privacy
* against tracking pixels until remote images are explicitly enabled.
*/
export function blockRemoteImages(html: string): string {
if (!html) return "";
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");
}
/**
* Restores blocked remote image sources when user clicks "Load Remote Images".
*/
export function unblockRemoteImages(html: string): string {
if (!html) return "";
return html.replace(/(<img[^>]+)data-blocked-src\s*=\s*("|')([^"']+)\2/gi, '$1src="$3"');
}