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
@@ -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\(\)/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user