diff --git a/README.md b/README.md index c369651..6a02388 100644 --- a/README.md +++ b/README.md @@ -611,6 +611,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 @@ -631,6 +637,7 @@ 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 f32e789..a15c32a 100644 --- a/internal/handlers/messages.go +++ b/internal/handlers/messages.go @@ -884,6 +884,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 @@ -1583,6 +1590,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 a7ac464..1c5b7e1 100644 --- a/internal/handlers/messages_test.go +++ b/internal/handlers/messages_test.go @@ -334,3 +334,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) + } +}