From 56a176d1f02227467bccfa15b6ca88c1a8fec3fa Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Sun, 2 Aug 2026 10:37:03 -0300 Subject: [PATCH] #21: enhance webmail with rich-text editor, DOMPurify sanitizer, attachments, sieve builder, and vacation responder --- services/auth/src/auth.test.ts | 1 + services/webmail/package-lock.json | 28 ++ services/webmail/package.json | 4 +- .../src/app/(user)/user/compose/page.tsx | 349 ++++++++++++---- .../app/(user)/user/mail/[folder]/page.tsx | 122 ++++-- .../src/app/(user)/user/settings/page.tsx | 383 +++++++++++------- services/webmail/src/lib/sanitizer.ts | 50 +++ 7 files changed, 677 insertions(+), 260 deletions(-) create mode 100644 services/webmail/src/lib/sanitizer.ts diff --git a/services/auth/src/auth.test.ts b/services/auth/src/auth.test.ts index df3cd7b..ee22530 100644 --- a/services/auth/src/auth.test.ts +++ b/services/auth/src/auth.test.ts @@ -38,6 +38,7 @@ describe("crypto module", () => { }); it("encrypts and decrypts secrets using AES-256-GCM", () => { + process.env.LAMBDAMAIL_MASTER_KEY = "test-master-key-32-chars-long!!"; const secret = "JBSWY3DPEHPK3PXP"; const encrypted = encryptSecret(secret); const decrypted = decryptSecret(encrypted.encrypted, encrypted.nonce, encrypted.keyVersion); diff --git a/services/webmail/package-lock.json b/services/webmail/package-lock.json index 3ea9ed1..9146b7a 100644 --- a/services/webmail/package-lock.json +++ b/services/webmail/package-lock.json @@ -8,11 +8,13 @@ "name": "@lambdamail/webmail", "version": "0.1.0", "dependencies": { + "dompurify": "^3.4.12", "next": "15.5.21", "react": "^18.3.1", "react-dom": "^18.3.1" }, "devDependencies": { + "@types/dompurify": "^3.0.5", "@types/node": "^20.14.0", "@types/react": "^18.3.3", "typescript": "^5.5.4", @@ -1438,6 +1440,16 @@ "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": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1473,6 +1485,13 @@ "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": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", @@ -1704,6 +1723,15 @@ "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": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", diff --git a/services/webmail/package.json b/services/webmail/package.json index 5c84532..08d5e21 100644 --- a/services/webmail/package.json +++ b/services/webmail/package.json @@ -10,14 +10,16 @@ "lint": "tsc --noEmit" }, "dependencies": { + "dompurify": "^3.4.12", "next": "15.5.21", "react": "^18.3.1", "react-dom": "^18.3.1" }, "devDependencies": { - "typescript": "^5.5.4", + "@types/dompurify": "^3.0.5", "@types/node": "^20.14.0", "@types/react": "^18.3.3", + "typescript": "^5.5.4", "vitest": "^2.0.5" }, "overrides": { diff --git a/services/webmail/src/app/(user)/user/compose/page.tsx b/services/webmail/src/app/(user)/user/compose/page.tsx index b610adf..0c2062e 100644 --- a/services/webmail/src/app/(user)/user/compose/page.tsx +++ b/services/webmail/src/app/(user)/user/compose/page.tsx @@ -1,152 +1,341 @@ "use client"; -import React, { useEffect, useRef, useState } from "react"; +import React, { useEffect, useRef, useState, use } 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; +interface AttachedFile { + id: string; + name: string; + 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 [to, setTo] = useState(""); - const [subject, setSubject] = useState(""); - const [body, setBody] = useState(""); + const params = searchParams ? use(searchParams) : {}; + + 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([]); const [sending, setSending] = useState(false); const [undoSeconds, setUndoSeconds] = useState(null); - const [error, setError] = useState(null); - const cancelled = useRef(false); + const [draftStatus, setDraftStatus] = useState(""); - const submit = async () => { + const editorRef = useRef(null); + + // Load signature from localStorage + useEffect(() => { + const savedSig = localStorage.getItem("lm_user_signature"); + if (editorRef.current && savedSig) { + editorRef.current.innerHTML = `

--
${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) => { + 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 { - const res = await fetch("/api/v1/mail/send", { + await fetch("/api/v1/mail/send", { method: "POST", 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"; - } catch (err) { - setError(err instanceof Error ? err.message : t("errors.serverError")); + } catch { 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 = () => { - cancelled.current = true; setUndoSeconds(null); setSending(false); }; return ( -
-
-
+
+
+

📝 {t("mail.compose")}

- Auto-draft active (3s) + {draftStatus}
{undoSeconds !== null && ( -
- - {t("mail.undoSend").replace("{seconds}", String(undoSeconds))} +
+ + {t("mail.undoSend")} ({undoSeconds}s remaining)
)} - {error && ( -
- {error} -
- )} - -
+
- +
+ + +
setTo(e.target.value)} placeholder="recipient@domain.com" required 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" />
+ {showCcBcc && ( +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ )} +
- + setSubject(e.target.value)} - placeholder="Message Subject..." + placeholder={t("ui.subject")} required 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" />
+ {/* Rich Text Editor Formatting Toolbar */}
- -