diff --git a/README.md b/README.md index d855dca..87fb2ca 100644 --- a/README.md +++ b/README.md @@ -609,6 +609,12 @@ Deletes a draft message and all its attachments from the database and disk. Only Marks a draft message as sent by setting `time_sent` to the current timestamp. Only the owner may send. +**Send is the validation gate.** Drafts are a workspace: create/update accept +incomplete messages (no recipients, no type) by design, but a draft may only +be *sent* when it is a complete, valid fmsg message — at least one +well-formed recipient, a `type`, and a supported `version`. An incomplete +draft is refused with `400` listing every problem. + For a reply (a draft with `pid`), the route first verifies that every remote recipient domain can actually accept it: per the fmsg spec a host rejects a reply whose parent it has not stored (response code 6), so if the parent was @@ -625,6 +631,7 @@ passes (it retains its outgoing messages). Local recipients are unaffected. | Status | Condition | | ------ | --------- | +| `400` | Message is not sendable (no recipients, invalid recipient address, no type, unsupported version) | | `403` | Not the owner | | `404` | Message not found | | `409` | Message already sent | diff --git a/internal/handlers/messages.go b/internal/handlers/messages.go index aca07df..27796a9 100644 --- a/internal/handlers/messages.go +++ b/internal/handlers/messages.go @@ -866,6 +866,13 @@ func (h *MessageHandler) Send(c *gin.Context) { return } + // Drafts are a workspace — create/update accept incomplete messages by + // design. Send is the gate: only a complete, valid message may leave. + if problems := sendableProblems(existing); len(problems) > 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "message is not sendable: " + strings.Join(problems, "; ")}) + return + } + // A reply can only be accepted by hosts that store its parent (SPEC // §10.3, reject code 6). Refuse now — with the reason — when a remote // recipient domain can never accept it, rather than letting the reply @@ -1565,6 +1572,30 @@ func (h *MessageHandler) extractShortText(dataPath, mimeType string) string { return string(buf) } +// sendableProblems lists everything preventing a draft from being sent as a +// valid fmsg message. Create/update deliberately accept incomplete drafts +// (a draft is a workspace); completeness is enforced only here, at send. +// Address *form* is still validated on create/update, but old rows predate +// that, so it is re-checked defensively. +func sendableProblems(m *models.Message) []string { + var problems []string + if m.Version != 1 { + problems = append(problems, fmt.Sprintf("unsupported version %d", m.Version)) + } + if len(m.To) == 0 { + problems = append(problems, "no recipients") + } + for _, addr := range m.To { + if !middleware.IsValidAddr(addr) { + problems = append(problems, fmt.Sprintf("invalid recipient address %q", addr)) + } + } + if m.Type == "" { + problems = append(problems, "no type") + } + return problems +} + // validateAddresses returns an error if the from address or any to address is // not a valid "@user@domain" address. (add_to recipients are validated by the // add-to route, not on create/update.) diff --git a/internal/handlers/messages_test.go b/internal/handlers/messages_test.go index da7afcb..b5ed888 100644 --- a/internal/handlers/messages_test.go +++ b/internal/handlers/messages_test.go @@ -330,3 +330,44 @@ func TestRemoteRecipientDomains(t *testing.T) { t.Fatalf("domains = %v", got) } } + +func TestSendableProblems(t *testing.T) { + valid := func() *models.Message { + return &models.Message{ + Version: 1, + To: []string{"@bob@remote.example"}, + Type: "text/markdown", + } + } + + if got := sendableProblems(valid()); len(got) != 0 { + t.Fatalf("valid draft flagged: %v", got) + } + + tests := []struct { + name string + mutate func(*models.Message) + want string + }{ + {"no recipients", func(m *models.Message) { m.To = nil }, "no recipients"}, + {"invalid recipient", func(m *models.Message) { m.To = []string{"bob@remote.example"} }, "invalid recipient"}, + {"no type", func(m *models.Message) { m.Type = "" }, "no type"}, + {"bad version", func(m *models.Message) { m.Version = 0 }, "unsupported version"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + m := valid() + tc.mutate(m) + got := sendableProblems(m) + if len(got) != 1 || !strings.Contains(got[0], tc.want) { + t.Fatalf("problems = %v, want one containing %q", got, tc.want) + } + }) + } + + // Everything wrong at once: every problem reported, not just the first. + m := &models.Message{Version: 0} + if got := sendableProblems(m); len(got) != 3 { + t.Fatalf("problems = %v, want 3", got) + } +}