#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,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<AttachedFile[]>([]);
const [sending, setSending] = useState(false);
const [undoSeconds, setUndoSeconds] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const cancelled = useRef(false);
const [draftStatus, setDraftStatus] = useState<string>("");
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 {
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 (
<div className="flex-1 p-6 bg-slate-950 flex flex-col justify-center items-center">
<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">
<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-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">
<h1 className="text-lg font-bold text-white flex items-center gap-2">
<span>&#128221;</span> {t("mail.compose")}
</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>
{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">
{t("mail.undoSend").replace("{seconds}", String(undoSeconds))}
<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>
{t("mail.undoSend")} ({undoSeconds}s remaining)
</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"
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>
</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">
<form onSubmit={handleSend} className="space-y-3 text-xs">
<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
type="email"
type="text"
value={to}
onChange={(e) => 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"
/>
</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>
<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
type="text"
value={subject}
onChange={(e) => 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"
/>
</div>
{/* Rich Text Editor Formatting Toolbar */}
<div>
<label className="block text-xs font-medium text-slate-300 mb-1">{t("ui.messageBody")}</label>
<textarea
rows={8}
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Write your email here..."
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"
/>
<label className="font-medium text-slate-300 mb-1 block">{t("ui.messageBody")}</label>
<div className="border border-slate-800 rounded-lg overflow-hidden bg-slate-900">
<div className="flex items-center gap-1 p-2 bg-slate-950 border-b border-slate-800 flex-wrap text-slate-300">
<button
type="button"
onClick={() => executeFormat("bold")}
className="px-2 py-1 hover:bg-slate-800 rounded font-bold"
title="Bold"
>
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 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">
Discard Draft
<Link href="/user/mail/inbox" className="text-slate-400 hover:text-slate-200">
Discard
</Link>
<button
type="submit"
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>
</div>
</form>