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:
Lucas Rayan committed 2026-08-20 15:32:14 -03:00
1 parent 4f1b9c5217
commit 880dced9e4
20 files changed
+1053 -30

No files matched your search

+22
View File
@@ -16,6 +16,28 @@ const nextConfig: NextConfig = {
source: "/api/v1/events",
destination: `${PROTOCOLS_SERVICE_URL}/api/v1/events`,
},
// The well-known documents belong to the protocols service, but the
// hostnames senders ask for - mta-sts.<domain> above all - resolve to
// this app. Without these rewrites the Next 404 page was served in
// place of the MTA-STS policy: the _mta-sts TXT record advertised a
// policy that could not be fetched, so every sender fell back to
// "no-policy-found" and no TLS policy was enforced on inbound mail.
// Google's daily TLS-RPT report reports precisely that.
//
// A rewrite rather than a redirect, because RFC 8461 section 3.3 has
// the sender fetch that exact HTTPS path and it need not follow one.
{
source: "/.well-known/mta-sts.txt",
destination: `${PROTOCOLS_SERVICE_URL}/.well-known/mta-sts.txt`,
},
{
source: "/.well-known/security.txt",
destination: `${PROTOCOLS_SERVICE_URL}/.well-known/security.txt`,
},
{
source: "/.well-known/autoconfig/mail/config-v1.1.xml",
destination: `${PROTOCOLS_SERVICE_URL}/.well-known/autoconfig/mail/config-v1.1.xml`,
},
];
},
};
@@ -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"
@@ -0,0 +1,82 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
/**
* The composer's draft handling, and the delete action the mail list was
* missing entirely.
*
* Every check here is a defect the user hit: a message sent from the composer
* left its autosaved draft behind, that draft was empty because the autosave
* never saw the body, and no screen offered any way to remove it.
*/
const read = (path: string) => readFileSync(resolve(process.cwd(), path), "utf8");
const code = (path: string) =>
read(path)
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/\{\/\*[\s\S]*?\*\/\}/g, "")
.replace(/^\s*\/\/.*$/gm, "");
const COMPOSE = "src/app/(user)/user/compose/page.tsx";
const MAIL_PAGE = "src/app/(user)/user/mail/[folder]/page.tsx";
describe("the draft a sent message leaves behind", () => {
// Sending posted to /mail/send without ever naming the draft it came from,
// so the draft stayed in Drafts as a duplicate of mail already on its way.
it("tells the server which draft the sent message came from", () => {
const compose = code(COMPOSE);
expect(compose).toMatch(/draft_uid:\s*draftUidRef\.current/);
});
it("posts the draft UID on the send request, not only on the autosave", () => {
const compose = code(COMPOSE);
const sendCall = compose.slice(compose.indexOf('"/api/v1/mail/send"'));
expect(sendCall.slice(0, 600)).toContain("draft_uid");
});
});
describe("what the autosave actually stores", () => {
// The body lived in a contenteditable read through a ref, and the debounce
// depended only on the header fields. Typing a message therefore never
// re-triggered a save, so the stored draft held the recipient and nothing
// else - the empty draft the user could see and could not delete.
it("re-runs when the body changes, not only the recipient and subject", () => {
const compose = code(COMPOSE);
const effect = compose.slice(compose.indexOf("const timer = setTimeout(() => void saveDraft()"));
const deps = effect.slice(effect.indexOf("}, ["), effect.indexOf("]);") + 3);
expect(deps).toContain("body");
});
it("keeps the body in state so a change can be observed at all", () => {
const compose = code(COMPOSE);
expect(compose).toMatch(/useState.*\n?.*body|const \[body, setBody\]/);
expect(compose).toContain("setBody");
});
it("does not save a draft that has no content anywhere", () => {
const compose = code(COMPOSE);
expect(compose).toMatch(/if \(!to && !subject && !body/);
});
});
describe("deleting a message", () => {
// There was no delete anywhere in the webmail: no button, and no route
// behind one. Anything the user wanted rid of stayed forever.
it("offers a delete action on the message list", () => {
const page = code(MAIL_PAGE);
expect(page).toContain("/api/v1/mail/delete");
});
it("refreshes the counters after deleting, so the badges follow", () => {
const page = code(MAIL_PAGE);
const del = page.slice(page.indexOf('"/api/v1/mail/delete"'));
expect(del.slice(0, 900)).toContain("notifyMailStateChanged");
});
it("removes the deleted row from the list without a full reload", () => {
const page = code(MAIL_PAGE);
const del = page.slice(page.indexOf("const deleteMessage"));
expect(del.slice(0, 1200)).toMatch(/setMessages\(/);
});
});
@@ -0,0 +1,34 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
/**
* The well-known documents senders fetch over HTTPS.
*
* The protocols service serves /.well-known/mta-sts.txt, but the hostname a
* sender asks - mta-sts.<domain> - lands on this Next app, which had no route
* for it and answered its own 404 page. The DNS half of MTA-STS was published
* and the HTTPS half was not, so every sender saw "no-policy-found" and no
* TLS policy was ever enforced. Google's daily TLS-RPT report says exactly
* that.
*/
const config = readFileSync(resolve(process.cwd(), "next.config.ts"), "utf8");
describe("the MTA-STS policy senders fetch", () => {
it("is routed to the service that serves it", () => {
expect(config).toContain("/.well-known/mta-sts.txt");
expect(config).toMatch(/mta-sts\.txt[\s\S]{0,200}PROTOCOLS_SERVICE_URL/);
});
it("routes the other well-known documents the same way", () => {
expect(config).toContain("/.well-known/security.txt");
});
// A rewrite, not a redirect: RFC 8461 section 3.3 requires the policy to be
// fetched over HTTPS from that exact path, and a sender is not obliged to
// follow a redirect to get it.
it("rewrites rather than redirects", () => {
expect(config).not.toMatch(/redirects\(\)/);
});
});