#24: make draft autosave replace the previous draft

The autosave I had just added appended a new message on every debounce, so a
few minutes of typing would have left the Drafts folder holding a dozen copies
of the same unfinished mail. Caught before deploying.

The composer now returns the stored UID and sends it back as replace_uid on
the next save; the use case expunges the superseded draft, but only after the
replacement has committed, so a failure leaves the older draft in place rather
than losing both. Expunge is a soft delete matching the expunged_at column the
schema already uses, and it locates the message by mailbox as well as UID so a
caller cannot delete someone else's mail by guessing a number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Opus 5 committed 2026-08-02 20:01:12 -03:00
1 parent 707e6ae80e
commit 0fcdd15b58
6 files changed
+218 -6

No files matched your search

@@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"errors"
"io"
"mime"
"net/http"
"strconv"
@@ -264,12 +265,13 @@ func (m *mailAPI) handleSaveDraft(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
input, ok := m.decodeCompose(w, r, session.Email)
// The body is read once, so replace_uid has to come out of the same decode.
input, replaceUID, ok := m.decodeDraft(w, r, session.Email)
if !ok {
return
}
uid, err := m.useCase.SaveDraft(r.Context(), input)
uid, err := m.useCase.SaveDraft(r.Context(), input, replaceUID)
if err != nil {
m.fail(w, err)
return
@@ -339,3 +341,29 @@ func (m *mailAPI) fail(w http.ResponseWriter, err error) {
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Request could not be processed")
}
}
// decodeDraft reads a composed message plus the draft it supersedes.
//
// replace_uid carries the UID of the previous autosave so the new one takes
// its place. Without it each keystroke pause would leave another copy behind
// and the Drafts folder would fill with the same unfinished message.
func (m *mailAPI) decodeDraft(
w http.ResponseWriter, r *http.Request, sender string,
) (usecase.ComposeInput, uint32, bool) {
raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxComposeBytes))
if err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Could not read the request")
return usecase.ComposeInput{}, 0, false
}
var meta struct {
ReplaceUID uint32 `json:"replace_uid"`
}
// A missing or malformed replace_uid is not fatal: the draft is still
// worth saving, it just will not supersede anything.
_ = json.Unmarshal(raw, &meta)
r.Body = io.NopCloser(bytes.NewReader(raw))
input, ok := m.decodeCompose(w, r, sender)
return input, meta.ReplaceUID, ok
}