feat: implement message deletion functionality and improve draft handling
- Added `MoveToTrash` method in `WebmailRepository` to move messages to the Trash folder instead of deleting them permanently. - Introduced `handleDelete` method in `mailAPI` to handle message deletion requests from the client. - Updated router to include a new route for message deletion. - Enhanced the compose page to manage draft integrity, ensuring drafts are correctly associated with sent messages. - Implemented UI changes to allow users to delete messages from the mail folder view. - Added tests to verify the correct behavior of message deletion and draft handling. - Updated database migration scripts to reconcile folder counters and ensure accurate unread counts. - Added tests for TLS-RPT report parsing and webmail repository functionalities.
This commit is contained in:
1 parent
4f1b9c5217
commit
880dced9e4
20 files changed
+1053
-30
No files matched your search
@@ -58,11 +58,21 @@ export default function ComposePage({
|
||||
const draftUidRef = useRef<number>(0);
|
||||
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
// The body, mirrored into React state.
|
||||
//
|
||||
// It used to live only inside the contenteditable, read through the ref at
|
||||
// save time. Nothing observed it, so the autosave effect - which depends on
|
||||
// what it reads - never re-ran when the message itself was typed. The draft
|
||||
// was written once, moments after the address was entered, and never again:
|
||||
// that is why a draft left behind by a sent message was empty.
|
||||
const [body, setBody] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const savedSig = localStorage.getItem("lm_user_signature");
|
||||
if (editorRef.current && savedSig) {
|
||||
editorRef.current.innerHTML = `<br/><br/>--<br/>${savedSig}`;
|
||||
const initial = `<br/><br/>--<br/>${savedSig}`;
|
||||
editorRef.current.innerHTML = initial;
|
||||
setBody(initial);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -70,8 +80,8 @@ export default function ComposePage({
|
||||
// "draft saved automatically" and nothing else - no request, no storage - so
|
||||
// closing the tab lost the message while the screen had just said otherwise.
|
||||
const saveDraft = useCallback(async () => {
|
||||
const html = editorRef.current?.innerHTML ?? "";
|
||||
if (!to && !subject && !html.trim()) return;
|
||||
const html = editorRef.current?.innerHTML ?? body;
|
||||
if (!to && !subject && !body.trim() && !html.trim()) return;
|
||||
setDraftStatus(t("mail.draftSaving"));
|
||||
try {
|
||||
const res = await fetch("/api/v1/mail/draft", {
|
||||
@@ -94,20 +104,26 @@ export default function ComposePage({
|
||||
} catch {
|
||||
setDraftStatus(t("mail.draftSaveFailed"));
|
||||
}
|
||||
}, [to, cc, bcc, subject, t]);
|
||||
}, [to, cc, bcc, subject, body, t]);
|
||||
|
||||
// Debounced so a draft is written after a pause in typing rather than on
|
||||
// every keystroke. Skipped once the message is on its way out.
|
||||
useEffect(() => {
|
||||
if (sending) return;
|
||||
if (!to && !subject) return;
|
||||
// The body alone is enough: a message typed before its recipient is still
|
||||
// worth keeping, and requiring an address here is half of why drafts were
|
||||
// being stored with nothing in them.
|
||||
if (!to && !subject && !body.trim()) return;
|
||||
const timer = setTimeout(() => void saveDraft(), 2500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [to, subject, cc, bcc, sending, saveDraft]);
|
||||
}, [to, subject, cc, bcc, body, sending, saveDraft]);
|
||||
|
||||
const executeFormat = (command: string, value: string | undefined = undefined) => {
|
||||
document.execCommand(command, false, value);
|
||||
editorRef.current?.focus();
|
||||
// execCommand edits the DOM directly, so the mirrored body is re-read
|
||||
// here rather than relying on an input event that may not be dispatched.
|
||||
setBody(editorRef.current?.innerHTML ?? "");
|
||||
};
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -130,7 +146,7 @@ export default function ComposePage({
|
||||
setSending(true);
|
||||
setUndoSeconds(10);
|
||||
|
||||
const bodyHtml = editorRef.current?.innerHTML || "";
|
||||
const bodyHtml = editorRef.current?.innerHTML || body;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setUndoSeconds((prev) => {
|
||||
@@ -155,6 +171,10 @@ export default function ComposePage({
|
||||
bcc: splitAddresses(bcc),
|
||||
subject,
|
||||
html: htmlBody,
|
||||
// The draft this message grew out of, so the server can discard it.
|
||||
// Without this the draft outlived the message it became and sat in
|
||||
// Drafts as a duplicate of mail already sent.
|
||||
draft_uid: draftUidRef.current,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
@@ -369,6 +389,8 @@ export default function ComposePage({
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
onInput={(e) => setBody((e.target as HTMLDivElement).innerHTML)}
|
||||
onBlur={(e) => setBody((e.target as HTMLDivElement).innerHTML)}
|
||||
aria-label={t("ui.messageBody")}
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
MailOpen,
|
||||
Download,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useI18n } from "../../../../../i18n/provider";
|
||||
@@ -202,6 +203,39 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
[folder]
|
||||
);
|
||||
|
||||
/**
|
||||
* Deletes one message: to Trash from an ordinary folder, and for good from
|
||||
* Trash itself, which is what the server does with the same call.
|
||||
*
|
||||
* The webmail had no delete at all - no button and no route behind one - so
|
||||
* anything the user wanted rid of stayed. The draft a sent message left
|
||||
* behind was the case that made it impossible to ignore.
|
||||
*/
|
||||
const deleteMessage = useCallback(
|
||||
async (uid: number) => {
|
||||
// Removed from the list first so the row goes at the moment of the
|
||||
// click; the reload below is what makes the server's answer final.
|
||||
setMessages((current) => current.filter((m) => m.uid !== uid));
|
||||
setSelected((current) => (current?.uid === uid ? null : current));
|
||||
try {
|
||||
const res = await fetch("/api/v1/mail/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ folder, uid }),
|
||||
});
|
||||
if (!res.ok) throw new Error(t("errors.serverError"));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("errors.serverError"));
|
||||
// The optimistic removal was wrong, so put the folder back as it is.
|
||||
void loadMessages(searchQuery);
|
||||
} finally {
|
||||
// Both the source folder and Trash moved, so the badges must re-read.
|
||||
notifyMailStateChanged();
|
||||
}
|
||||
},
|
||||
[folder, t, loadMessages, searchQuery]
|
||||
);
|
||||
|
||||
const openMessage = async (uid: number) => {
|
||||
setLoadRemoteImages(false);
|
||||
setReaderHeight(420);
|
||||
@@ -359,8 +393,12 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
filteredMessages.map((msg) => {
|
||||
const isSelected = selected?.uid === msg.uid;
|
||||
return (
|
||||
/* The row and its delete control are siblings inside a
|
||||
positioned wrapper rather than nested: a button inside a
|
||||
button is invalid markup, and the browser drops the inner
|
||||
one, so the delete would never have been clickable. */
|
||||
<div key={msg.uid} className="group relative">
|
||||
<button
|
||||
key={msg.uid}
|
||||
onClick={() => void openMessage(msg.uid)}
|
||||
data-active={isSelected}
|
||||
className="lm-row w-full text-left"
|
||||
@@ -417,6 +455,19 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
{!msg.seen && <span className="block h-[7px] w-[7px] rounded-full bg-indigo-500" />}
|
||||
</span>
|
||||
</button>
|
||||
{/* Reachable without opening the message first, which is what
|
||||
the leftover empty draft needed: there was nothing worth
|
||||
reading in it, and no other way to get rid of it. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void deleteMessage(msg.uid)}
|
||||
title={t("common.delete")}
|
||||
aria-label={t("common.delete")}
|
||||
className="absolute right-2 top-2 hidden h-7 w-7 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-white/[0.09] hover:text-slate-100 focus:flex focus-visible:flex group-hover:flex"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
@@ -466,6 +517,15 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
|
||||
<MailOpen className="h-3.5 w-3.5" />
|
||||
<span>{selectedRow?.seen === false ? t("mail.markRead") : t("mail.markUnread")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => void deleteMessage(selected.uid)}
|
||||
title={t("common.delete")}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<span>{t("common.delete")}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
|
||||
Reference in new issue
Block a user