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

@@ -402,6 +402,28 @@ func TestDeleteFromTrashExpunges(t *testing.T) {
}
}
// "Discard" in the composer promises the draft is gone. Delete would leave it
// in Trash, so discarding removes it outright - and only ever from Drafts.
func TestDiscardingADraftRemovesItForGood(t *testing.T) {
mailboxID := uuid.New()
repo := &stubWebmailRepo{mailboxID: mailboxID.String()}
uc := &WebmailUseCase{
repo: repo,
auth: &stubAuthRepo{account: &port.MailboxAuth{ID: mailboxID, EmailAddress: "me@example.test"}},
localHost: "mail.example.test",
}
if err := uc.DiscardDraft(context.Background(), "me@example.test", 9); err != nil {
t.Fatalf("discard: %v", err)
}
if len(repo.expunged) != 1 || repo.expunged[0] != 9 {
t.Errorf("the draft was not expunged: %v", repo.expunged)
}
if len(repo.moved) != 0 {
t.Errorf("a discarded draft was moved instead of removed: %v", repo.moved)
}
}
// --- moving between folders ---------------------------------------------
// Filing a message somewhere else is the one ordinary mail action the webmail
@@ -283,6 +283,20 @@ func (uc *WebmailUseCase) Delete(ctx context.Context, address, folder string, ui
}
}
// DiscardDraft removes a draft for good.
//
// Delete would move it to Trash, which is right for a message someone might
// want back and wrong for "Discard": the composer's button promises the draft
// is gone, and one waiting in Trash is a draft that is not. Only Drafts is
// reachable from here, so this cannot be used to expunge anything else.
func (uc *WebmailUseCase) DiscardDraft(ctx context.Context, address string, uid uint32) error {
mailboxID, err := uc.mailboxID(ctx, address)
if err != nil {
return err
}
return uc.repo.Expunge(ctx, mailboxID, "Drafts", uid)
}
// SaveDraft files the message being composed into the Drafts folder.
//
// Nothing did this before: the composer displayed "draft saved automatically"
@@ -300,6 +300,8 @@ func (m *mailAPI) handleDelete(w http.ResponseWriter, r *http.Request) {
var body struct {
Folder string `json:"folder"`
UID uint32 `json:"uid"`
// DiscardDraft removes a draft for good instead of moving it to Trash.
DiscardDraft bool `json:"discard_draft"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid JSON")
@@ -313,6 +315,15 @@ func (m *mailAPI) handleDelete(w http.ResponseWriter, r *http.Request) {
return
}
if body.DiscardDraft {
if err := m.useCase.DiscardDraft(r.Context(), session.Email, body.UID); err != nil {
m.fail(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
return
}
if err := m.useCase.Delete(r.Context(), session.Email, body.Folder, body.UID); err != nil {
m.fail(w, err)
return
+2 -1
View File
@@ -329,7 +329,8 @@
"trustSender": "Trust sender",
"trustedSender": "Trusted sender",
"trustSenderHint": "Never file this sender's mail as spam, and always show their images",
"trustedSenderHint": "Not filed as spam, images always shown. Click to stop trusting."
"trustedSenderHint": "Not filed as spam, images always shown. Click to stop trusting.",
"discardFailed": "The draft could not be discarded. Try again."
},
"settings": {
"actionDiscard": "Discard",
+2 -1
View File
@@ -329,7 +329,8 @@
"trustSender": "Confiar en el remitente",
"trustedSender": "Remitente de confianza",
"trustSenderHint": "No enviar nunca el correo de este remitente a spam y mostrar siempre sus imágenes",
"trustedSenderHint": "No va a spam y sus imágenes siempre se muestran. Haz clic para dejar de confiar."
"trustedSenderHint": "No va a spam y sus imágenes siempre se muestran. Haz clic para dejar de confiar.",
"discardFailed": "No se pudo descartar el borrador. Inténtalo de nuevo."
},
"settings": {
"actionDiscard": "Descartar",
+2 -1
View File
@@ -329,7 +329,8 @@
"trustSender": "Confiar no remetente",
"trustedSender": "Remetente confiável",
"trustSenderHint": "Nunca mandar o e-mail deste remetente para o spam e sempre mostrar as imagens dele",
"trustedSenderHint": "Não vai para o spam e as imagens sempre aparecem. Clique para deixar de confiar."
"trustedSenderHint": "Não vai para o spam e as imagens sempre aparecem. Clique para deixar de confiar.",
"discardFailed": "Não foi possível descartar o rascunho. Tente novamente."
},
"settings": {
"actionDiscard": "Descartar",
@@ -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/);
});
});