diff --git a/services/protocols/internal/application/usecase/webmail_compose_test.go b/services/protocols/internal/application/usecase/webmail_compose_test.go index 0b7dffd..87854da 100644 --- a/services/protocols/internal/application/usecase/webmail_compose_test.go +++ b/services/protocols/internal/application/usecase/webmail_compose_test.go @@ -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 diff --git a/services/protocols/internal/application/usecase/webmail_mail.go b/services/protocols/internal/application/usecase/webmail_mail.go index f67c0af..81aa880 100644 --- a/services/protocols/internal/application/usecase/webmail_mail.go +++ b/services/protocols/internal/application/usecase/webmail_mail.go @@ -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" diff --git a/services/protocols/internal/presentation/http/mail_api.go b/services/protocols/internal/presentation/http/mail_api.go index c6b6d89..cf45825 100644 --- a/services/protocols/internal/presentation/http/mail_api.go +++ b/services/protocols/internal/presentation/http/mail_api.go @@ -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 diff --git a/services/webmail/messages/en.json b/services/webmail/messages/en.json index f96f547..42b9f42 100644 --- a/services/webmail/messages/en.json +++ b/services/webmail/messages/en.json @@ -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", diff --git a/services/webmail/messages/es.json b/services/webmail/messages/es.json index 2e48d66..6e66db1 100644 --- a/services/webmail/messages/es.json +++ b/services/webmail/messages/es.json @@ -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", diff --git a/services/webmail/messages/pt-BR.json b/services/webmail/messages/pt-BR.json index 04e55e0..c03ef86 100644 --- a/services/webmail/messages/pt-BR.json +++ b/services/webmail/messages/pt-BR.json @@ -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", diff --git a/services/webmail/src/app/(user)/user/compose/page.tsx b/services/webmail/src/app/(user)/user/compose/page.tsx index 935c7c6..4da1ef4 100644 --- a/services/webmail/src/app/(user)/user/compose/page.tsx +++ b/services/webmail/src/app/(user)/user/compose/page.tsx @@ -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(null); const [draftStatus, setDraftStatus] = useState(""); + const [discarding, setDiscarding] = useState(false); const [sendError, setSendError] = useState(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(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(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 => { + 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({ {t("mail.highImportance")} - - - +