Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 |
Expand Down
31 changes: 31 additions & 0 deletions internal/handlers/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.)
Expand Down
41 changes: 41 additions & 0 deletions internal/handlers/messages_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading