#21: enhance webmail with rich-text editor, DOMPurify sanitizer, attachments, sieve builder, and vacation responder
This commit is contained in:
1 parent
b57854107e
commit
56a176d1f0
7 files changed
+677
-260
No files matched your search
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import React, { use, useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "../../../../../i18n/provider";
|
||||
import { useMailEvents } from "../../../../../lib/useMailEvents";
|
||||
import { sanitizeEmailHtml, blockRemoteImages, unblockRemoteImages } from "../../../../../lib/sanitizer";
|
||||
|
||||
interface MessageSummary {
|
||||
uid: number;
|
||||
@@ -30,15 +32,6 @@ interface RenderedMessage {
|
||||
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 }> }) {
|
||||
const { folder } = use(params);
|
||||
const t = useTranslations();
|
||||
@@ -47,6 +40,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
const [selected, setSelected] = useState<RenderedMessage | null>(null);
|
||||
const [loadRemoteImages, setLoadRemoteImages] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [activeFilter, setActiveFilter] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -76,9 +70,6 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
void 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(
|
||||
useCallback(
|
||||
(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(() => {
|
||||
const handle = setTimeout(() => void loadMessages(searchQuery), 250);
|
||||
return () => clearTimeout(handle);
|
||||
@@ -101,42 +91,81 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
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.
|
||||
const data = await res.json();
|
||||
setSelected(data);
|
||||
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
|
||||
// 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
|
||||
? selected.html
|
||||
: blockRemoteContent(selected.html)
|
||||
? unblockRemoteImages(sanitizedHtml)
|
||||
: blockRemoteImages(sanitizedHtml)
|
||||
: 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">
|
||||
<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
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
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"
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
{/* Message Items List */}
|
||||
<div className="flex-1 overflow-y-auto divide-y divide-slate-800/60">
|
||||
{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 ? (
|
||||
) : filteredMessages.length === 0 ? (
|
||||
<div className="p-4 text-xs text-slate-500 italic">{t("mail.noMessages")}</div>
|
||||
) : (
|
||||
messages.map((msg) => (
|
||||
filteredMessages.map((msg) => (
|
||||
<button
|
||||
key={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}
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* Message Reader Column */}
|
||||
<div className="flex-1 flex flex-col bg-slate-950 overflow-y-auto p-6">
|
||||
{selected ? (
|
||||
<div className="max-w-3xl w-full mx-auto">
|
||||
{/* Header & Reply/Forward Actions */}
|
||||
<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="font-semibold text-slate-200">{selected.from}</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 && (
|
||||
<div className="mt-1 text-[11px] text-slate-500">To: {selected.to.join(", ")}</div>
|
||||
)}
|
||||
|
||||
{/* Attachment Download Chips */}
|
||||
{selected.attachments.length > 0 && (
|
||||
<div className="mt-2 text-[11px] text-slate-400">
|
||||
{"\u{1F4CE} "}
|
||||
{selected.attachments.join(", ")}
|
||||
<div className="mt-3 flex flex-wrap gap-2 text-xs">
|
||||
{selected.attachments.map((filename, idx) => (
|
||||
<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>📎</span> {filename}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Remote Images Privacy Banner */}
|
||||
{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>
|
||||
<span>{t("mail.loadImages")} (DOMPurify Sanitized)</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")}
|
||||
{loadRemoteImages ? "Images Enabled" : t("mail.loadImages")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sandboxed & DOMPurify Sanitized HTML Container */}
|
||||
{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}
|
||||
|
||||
Reference in new issue
Block a user