fix(webmail): Discard really discards the draft

The button was a link to the inbox, so the autosaved draft stayed in Drafts.
It now removes the stored draft for good (not into Trash), stops the autosave,
and cleans up a save that was already in flight.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-10-22 16:02:27 -03:00
1 parent d283ff6f8d
commit 657a43d612
8 files changed
+132 -8

No files matched your search

@@ -20,6 +20,7 @@ import {
} from "lucide-react";
import { useTranslations } from "../../../../i18n/provider";
import { Button } from "../../../../components/ui/Button";
import { notifyMailStateChanged } from "../../../../lib/useFolders";
import { signatureToHtml } from "../../../../lib/signature";
import { RecipientInput } from "../../../../components/RecipientInput";
import { parseAddress, parseAddressList, prefixSubject } from "../../../../lib/recipients";
@@ -67,10 +68,15 @@ export default function ComposePage({
const [sending, setSending] = useState(false);
const [undoSeconds, setUndoSeconds] = useState<number | null>(null);
const [draftStatus, setDraftStatus] = useState<string>("");
const [discarding, setDiscarding] = useState(false);
const [sendError, setSendError] = useState<string | null>(null);
// The UID of the draft already stored for this message. Sent back on each
// autosave so the server replaces it instead of leaving another copy.
const draftUidRef = useRef<number>(0);
// Set once the writer has discarded the message. An autosave already on its
// way when that happens would otherwise land afterwards and bring the draft
// back, so every save checks this before and after it talks to the server.
const discardedRef = useRef(false);
const editorRef = useRef<HTMLDivElement>(null);
// The body, mirrored into React state.
@@ -134,10 +140,27 @@ export default function ComposePage({
};
}, [draftParam]);
// Removes a stored draft for good. discard_draft is what keeps it out of
// Trash: an ordinary delete moves it there, and a draft sitting in Trash is
// not discarded.
const removeDraft = async (uid: number): Promise<boolean> => {
try {
const res = await fetch("/api/v1/mail/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ folder: "drafts", uid, discard_draft: true }),
});
return res.ok;
} catch {
return false;
}
};
// Saves the draft for real. This used to be a setTimeout that set the words
// "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 () => {
if (discardedRef.current) return;
const html = editorRef.current?.innerHTML ?? body;
if (to.length === 0 && !subject && !body.trim() && !html.trim()) return;
setDraftStatus(t("mail.draftSaving"));
@@ -159,6 +182,12 @@ export default function ComposePage({
if (res.ok) {
const data = await res.json().catch(() => ({}));
if (typeof data.uid === "number") draftUidRef.current = data.uid;
// Discarded while this save was in flight: what it just stored is a
// draft nobody wants, so it goes too.
if (discardedRef.current && draftUidRef.current > 0) {
void removeDraft(draftUidRef.current);
return;
}
}
setDraftStatus(res.ok ? t("mail.draftSaved") : t("mail.draftSaveFailed"));
} catch {
@@ -178,6 +207,28 @@ export default function ComposePage({
return () => clearTimeout(timer);
}, [to, subject, cc, bcc, body, important, sending, saveDraft]);
// Throws the message away: stops the autosave, removes the draft already
// stored for it, and only then leaves. Leaving first was the old behaviour -
// a link to the inbox - and left the draft in Drafts, as if nothing had
// been discarded.
const discardMessage = async () => {
discardedRef.current = true;
setDiscarding(true);
setSendError(null);
const uid = draftUidRef.current;
if (uid > 0 && !(await removeDraft(uid))) {
// Still there, so say so and let the autosave carry on rather than
// leaving the page with a draft the writer thinks is gone.
discardedRef.current = false;
setDiscarding(false);
setSendError(t("mail.discardFailed"));
return;
}
draftUidRef.current = 0;
notifyMailStateChanged();
window.location.href = "/user/mail/inbox";
};
const executeFormat = (command: string, value: string | undefined = undefined) => {
document.execCommand(command, false, value);
editorRef.current?.focus();
@@ -310,11 +361,15 @@ export default function ComposePage({
<Flag className="h-4 w-4" fill={important ? "currentColor" : "none"} />
<span>{t("mail.highImportance")}</span>
</button>
<Link href="/user/mail/inbox">
<Button variant="secondary" size="md">
{t("mail.discard")}
</Button>
</Link>
<Button
type="button"
variant="secondary"
size="md"
disabled={sending || discarding}
onClick={() => void discardMessage()}
>
{t("mail.discard")}
</Button>
<Button type="submit" variant="primary" size="md" disabled={sending}>
<Send className="h-4 w-4" />
<span>{sending ? t("mail.sending") : t("mail.sendMessage")}</span>
@@ -100,3 +100,22 @@ describe("the Reports folder", () => {
expect(page).toMatch(/reports:\s*t\("mail\.reports"\)/);
});
});
describe("discarding a draft", () => {
// "Discard" was a link to the inbox. The autosaved draft stayed in Drafts, so
// a message the writer had thrown away came back the next time they looked.
it("is a button that removes the stored draft, not a link away", () => {
const compose = code(COMPOSE);
expect(compose).not.toMatch(/<Link href="\/user\/mail\/inbox">\s*<Button[^>]*>\s*\{t\("mail\.discard"\)\}/);
expect(compose).toMatch(/discard_draft:\s*true/);
expect(compose).toMatch(/onClick=\{\(\) => void discardMessage\(\)\}/);
});
// An autosave already in flight lands after the discard and puts the draft
// back, so the save has to know it was discarded.
it("stops the autosave and cleans up a save that was already on its way", () => {
const compose = code(COMPOSE);
expect(compose).toMatch(/if \(discardedRef\.current\) return;/);
expect(compose).toMatch(/discardedRef\.current && draftUidRef\.current > 0/);
});
});