From 88321ebe1743a6677ce009322f52e53d3878bd8c Mon Sep 17 00:00:00 2001 From: Mark Mennell Date: Sat, 17 Oct 2026 18:19:13 +0800 Subject: [PATCH] Recipients see a message only once it is sent A draft's recipients could list it in GET /fmsg and read it, its data, attachments and thread, mark it read, react to it and add recipients to it. Clients that save drafts as their user types (fmsg.ai's web app does) showed recipients empty and half-written messages that could never be marked read. A draft is now its sender's alone: GET /fmsg lists sent messages only, and every recipient route answers 404 for someone else's draft. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 + internal/handlers/attachments.go | 7 +- internal/handlers/drafts_integration_test.go | 78 ++++++++++++++++++++ internal/handlers/messages.go | 35 +++++++-- internal/handlers/reactions.go | 4 + internal/handlers/thread.go | 26 +++++-- 6 files changed, 139 insertions(+), 15 deletions(-) create mode 100644 internal/handlers/drafts_integration_test.go diff --git a/README.md b/README.md index dff6762..efaf7cc 100644 --- a/README.md +++ b/README.md @@ -571,6 +571,10 @@ ws.onmessage = (e) => { Returns messages where the authenticated identity is a recipient (listed in `msg_to` or `msg_add_to`), ordered by message ID descending. +Only sent messages are listed. A draft is its sender's alone: until it is sent, +its recipients get `404` for it on every route (get, data, attachments, thread, +read, react and add-to), as if it did not exist. + Visibility is always scoped to exactly the one identity the caller authenticated as โ€” the owner, or (via a sub-account's own API-key token or the owner's `X-FMSG-Act-As` header) a single sub-account. A request never diff --git a/internal/handlers/attachments.go b/internal/handlers/attachments.go index 951645f..77ef36d 100644 --- a/internal/handlers/attachments.go +++ b/internal/handlers/attachments.go @@ -187,7 +187,8 @@ func (h *AttachmentHandler) Download(c *gin.Context) { // Check ownership or recipient access. var fromAddr string - err := h.q().QueryRow(ctx, "SELECT from_addr FROM msg WHERE id = $1", msgID).Scan(&fromAddr) + var timeSent *float64 + err := h.q().QueryRow(ctx, "SELECT from_addr, time_sent FROM msg WHERE id = $1", msgID).Scan(&fromAddr, &timeSent) if err != nil { if errors.Is(err, pgx.ErrNoRows) { c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) @@ -197,6 +198,10 @@ func (h *AttachmentHandler) Download(c *gin.Context) { } return } + if draftHidden([]string{identity}, fromAddr, timeSent) { + c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) + return + } // Check recipients (to or add_to) if not owner. if fromAddr != identity { diff --git a/internal/handlers/drafts_integration_test.go b/internal/handlers/drafts_integration_test.go new file mode 100644 index 0000000..e8e6c6c --- /dev/null +++ b/internal/handlers/drafts_integration_test.go @@ -0,0 +1,78 @@ +package handlers + +import ( + "encoding/json" + "testing" +) + +// A draft is its sender's alone: its recipients can neither list nor reach it +// by any route until it is sent. +func TestRecipientsCannotSeeDrafts(t *testing.T) { + a := newFinalizationAPI(t) + const alice, bob = "@alice@example.com", "@bob@example.com" + id := a.draft(t, alice, "not ready yet", nil) + a.upload(t, id) + + listed := func() bool { + t.Helper() + w := a.request(bob, "GET", "/fmsg", nil) + if w.Code != 200 { + t.Fatalf("list %d: %s", w.Code, w.Body) + } + var items []struct { + ID json.Number `json:"id"` + } + if err := json.Unmarshal(w.Body.Bytes(), &items); err != nil { + t.Fatal(err) + } + for _, it := range items { + if it.ID.String() == id { + return true + } + } + return false + } + if listed() { + t.Fatal("bob's inbox lists alice's draft") + } + + for _, r := range []struct{ method, path string }{ + {"GET", "/fmsg/" + id}, + {"GET", "/fmsg/" + id + "/data"}, + {"GET", "/fmsg/" + id + "/attach/note.txt"}, + {"GET", "/fmsg/" + id + "/thread"}, + {"GET", "/fmsg/" + id + "/thread/messages"}, + {"POST", "/fmsg/" + id + "/read"}, + } { + if w := a.request(bob, r.method, r.path, map[string]any{}); w.Code != 404 { + t.Errorf("bob %s %s: HTTP %d, want 404: %s", r.method, r.path, w.Code, w.Body) + } + } + if w := a.request(bob, "POST", "/fmsg/"+id+"/react", map[string]any{"emoji": "๐Ÿ‘"}); w.Code != 404 { + t.Errorf("bob react: HTTP %d, want 404: %s", w.Code, w.Body) + } + if w := a.request(bob, "POST", "/fmsg/"+id+"/add-to", map[string]any{"add_to": []string{"@carol@example.com"}}); w.Code != 404 { + t.Errorf("bob add-to: HTTP %d, want 404: %s", w.Code, w.Body) + } + + // The sender still has it. + for _, path := range []string{"/fmsg/" + id, "/fmsg/" + id + "/data", "/fmsg/" + id + "/attach/note.txt", "/fmsg/" + id + "/thread/messages"} { + if w := a.request(alice, "GET", path, nil); w.Code != 200 { + t.Errorf("alice GET %s: HTTP %d: %s", path, w.Code, w.Body) + } + } + + // Once sent, bob has it. + jsonResponse(t, a.request(alice, "POST", "/fmsg/"+id+"/send", map[string]any{}), 200) + if !listed() { + t.Fatal("bob's inbox does not list the sent message") + } + for _, path := range []string{"/fmsg/" + id, "/fmsg/" + id + "/data", "/fmsg/" + id + "/attach/note.txt", "/fmsg/" + id + "/thread/messages"} { + if w := a.request(bob, "GET", path, nil); w.Code != 200 { + t.Errorf("bob GET %s after send: HTTP %d: %s", path, w.Code, w.Body) + } + } + if w := a.request(bob, "POST", "/fmsg/"+id+"/read", map[string]any{}); w.Code != 200 { + t.Errorf("bob read after send: HTTP %d: %s", w.Code, w.Body) + } +} diff --git a/internal/handlers/messages.go b/internal/handlers/messages.go index 090e9e9..63cdaba 100644 --- a/internal/handlers/messages.go +++ b/internal/handlers/messages.go @@ -322,8 +322,9 @@ func (h *MessageHandler) List(c *gin.Context) { (SELECT mat.time_read FROM msg_add_to mat WHERE mat.msg_id = m.id AND lower(mat.addr) = ANY($1)) ) AS time_read FROM msg m - WHERE EXISTS (SELECT 1 FROM msg_to mt WHERE mt.msg_id = m.id AND lower(mt.addr) = ANY($1)) - OR EXISTS (SELECT 1 FROM msg_add_to mat WHERE mat.msg_id = m.id AND lower(mat.addr) = ANY($1)) + WHERE m.time_sent IS NOT NULL + AND (EXISTS (SELECT 1 FROM msg_to mt WHERE mt.msg_id = m.id AND lower(mt.addr) = ANY($1)) + OR EXISTS (SELECT 1 FROM msg_add_to mat WHERE mat.msg_id = m.id AND lower(mat.addr) = ANY($1))) ORDER BY m.id DESC LIMIT $2 OFFSET $3`, addrs, limit, offset, @@ -620,6 +621,11 @@ func (h *MessageHandler) Get(c *gin.Context) { return } + if draftHidden(addrs, msg.From, msg.Time) { + c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) + return + } + // Authorization: owner or recipient (to or add_to), across all visible addrs. isSender := isRecipient(addrs, msg.From) if !isSender && !anyRecipient(addrs, msg.To) && !anyAddToContains(addrs, msg.AddTo) { @@ -673,9 +679,10 @@ func (h *MessageHandler) DownloadData(c *gin.Context) { // Fetch message metadata for auth check and file path. var fromAddr string var dataPath string + var timeSent *float64 err = h.q().QueryRow(ctx, - "SELECT from_addr, filepath FROM msg WHERE id = $1", msgID, - ).Scan(&fromAddr, &dataPath) + "SELECT from_addr, filepath, time_sent FROM msg WHERE id = $1", msgID, + ).Scan(&fromAddr, &dataPath, &timeSent) if err != nil { if errors.Is(err, pgx.ErrNoRows) { c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) @@ -685,6 +692,10 @@ func (h *MessageHandler) DownloadData(c *gin.Context) { } return } + if draftHidden(addrs, fromAddr, timeSent) { + c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) + return + } // Authorize: must be owner or recipient (across all visible addrs). if !isRecipient(addrs, fromAddr) { @@ -1042,8 +1053,9 @@ func (h *MessageHandler) MarkRead(c *gin.Context) { (SELECT mt.time_read FROM msg_to mt WHERE mt.msg_id = $1 AND lower(mt.addr) = lower($2)), (SELECT mat.time_read FROM msg_add_to mat WHERE mat.msg_id = $1 AND lower(mat.addr) = lower($2)) ), - EXISTS (SELECT 1 FROM msg_to mt WHERE mt.msg_id = $1 AND lower(mt.addr) = lower($2)) - OR EXISTS (SELECT 1 FROM msg_add_to mat WHERE mat.msg_id = $1 AND lower(mat.addr) = lower($2))`, + (EXISTS (SELECT 1 FROM msg_to mt WHERE mt.msg_id = $1 AND lower(mt.addr) = lower($2)) + OR EXISTS (SELECT 1 FROM msg_add_to mat WHERE mat.msg_id = $1 AND lower(mat.addr) = lower($2))) + AND EXISTS (SELECT 1 FROM msg m WHERE m.id = $1 AND m.time_sent IS NOT NULL)`, msgID, identity, ).Scan(&existing, &recipient) if err != nil { @@ -1138,6 +1150,10 @@ func (h *MessageHandler) AddRecipients(c *gin.Context) { } return } + if draftHidden([]string{identity}, fromAddr, timeSent) { + c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) + return + } // A terminal message is a leaf: recipients cannot be added to it (fmsg // Specification v0.6.0 ยง12). if terminal { @@ -1603,6 +1619,13 @@ func sameAddr(a, b string) bool { return strings.EqualFold(a, b) } +// draftHidden reports whether a message is a draft that none of addrs sent. A +// draft is its sender's alone: until it is sent, its recipients get 404 for it +// on every route, as if it did not exist. +func draftHidden(addrs []string, from string, timeSent *float64) bool { + return timeSent == nil && !isRecipient(addrs, from) +} + func isRecipient(to []string, addr string) bool { for _, a := range to { if strings.EqualFold(a, addr) { diff --git a/internal/handlers/reactions.go b/internal/handlers/reactions.go index ea1b2d0..28bbde3 100644 --- a/internal/handlers/reactions.go +++ b/internal/handlers/reactions.go @@ -326,6 +326,10 @@ func (h *MessageHandler) React(c *gin.Context) { return } + if draftHidden([]string{identity}, subject.From, subject.Time) { + c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) + return + } // Only a participant of the subject may react to it (the host enforces // the same rule on the wire). if !strings.EqualFold(subject.From, identity) && !isRecipient(subject.To, identity) && !addToContains(subject.AddTo, identity) { diff --git a/internal/handlers/thread.go b/internal/handlers/thread.go index 3484585..bf7833f 100644 --- a/internal/handlers/thread.go +++ b/internal/handlers/thread.go @@ -289,8 +289,9 @@ func (h *MessageHandler) ThreadText(c *gin.Context) { ) SELECT c.id, c.from_addr, c.time_sent, c.type, c.size, c.filepath, (lower(c.from_addr) = ANY($2) - OR EXISTS (SELECT 1 FROM msg_to t WHERE t.msg_id = c.id AND lower(t.addr) = ANY($2)) - OR EXISTS (SELECT 1 FROM msg_add_to a WHERE a.msg_id = c.id AND lower(a.addr) = ANY($2))) AS readable + OR (c.time_sent IS NOT NULL + AND (EXISTS (SELECT 1 FROM msg_to t WHERE t.msg_id = c.id AND lower(t.addr) = ANY($2)) + OR EXISTS (SELECT 1 FROM msg_add_to a WHERE a.msg_id = c.id AND lower(a.addr) = ANY($2))))) AS readable FROM chain c ORDER BY c.depth DESC`, msgID, addrs, threadMaxHops, ) @@ -326,8 +327,12 @@ func (h *MessageHandler) ThreadText(c *gin.Context) { // Authorization gate is the requested message itself, same as GET // /fmsg/:id โ€” a non-participant learns nothing, not even chain shape. - if !entries[len(entries)-1].Readable { - c.JSON(http.StatusForbidden, gin.H{"error": "access denied"}) + if last := entries[len(entries)-1]; !last.Readable { + if last.TimeSent == nil { // someone else's draft + c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) + } else { + c.JSON(http.StatusForbidden, gin.H{"error": "access denied"}) + } return } @@ -395,8 +400,9 @@ func (h *MessageHandler) ThreadMessages(c *gin.Context) { c.is_terminal, c.time_sent, c.from_addr, c.topic, c.type, c.size, c.filepath, encode(c.sha256, 'hex'), encode(c.psha256, 'hex'), (lower(c.from_addr) = ANY($2) - OR EXISTS (SELECT 1 FROM msg_to t WHERE t.msg_id = c.id AND lower(t.addr) = ANY($2)) - OR EXISTS (SELECT 1 FROM msg_add_to a WHERE a.msg_id = c.id AND lower(a.addr) = ANY($2))) + OR (c.time_sent IS NOT NULL + AND (EXISTS (SELECT 1 FROM msg_to t WHERE t.msg_id = c.id AND lower(t.addr) = ANY($2)) + OR EXISTS (SELECT 1 FROM msg_add_to a WHERE a.msg_id = c.id AND lower(a.addr) = ANY($2))))) FROM chain c ORDER BY c.depth DESC`, msgID, addrs, threadMaxHops) if err != nil { log.Printf("thread messages: walk %d: %v", msgID, err) @@ -429,8 +435,12 @@ func (h *MessageHandler) ThreadMessages(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) return } - if !messages[len(messages)-1].Visible { - c.JSON(http.StatusForbidden, gin.H{"error": "access denied"}) + if last := messages[len(messages)-1]; !last.Visible { + if last.Time == nil { // someone else's draft + c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) + } else { + c.JSON(http.StatusForbidden, gin.H{"error": "access denied"}) + } return } if len(messages) == threadMaxHops && messages[0].PID != nil {