Skip to content

Commit bd19e0e

Browse files
markmnlclaude
andauthored
send: refuse replies no remote recipient host can accept (#36)
Per SPEC §10.3 a host rejects a reply whose parent it has not stored (code 6). Found live: chain replies to a domain whose copy of the parent never arrived bounce there with no feedback to the sender. On send of a draft with pid, verify each remote recipient domain against the parent's recorded delivery: refuse with 409 (naming the domains and the add-to / new-thread remedy) when the parent was never addressed to the domain or every delivery attempt there concluded in rejection. Parents still in flight pass — sequencing in-flight chains is the host's outbound concern (fmsgd, tracked separately) — as does the parent's originating domain, which retains its own outgoing messages. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent dc6977b commit bd19e0e

3 files changed

Lines changed: 184 additions & 0 deletions

File tree

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,16 @@ Deletes a draft message and all its attachments from the database and disk. Only
609609

610610
Marks a draft message as sent by setting `time_sent` to the current timestamp. Only the owner may send.
611611

612+
For a reply (a draft with `pid`), the route first verifies that every remote
613+
recipient domain can actually accept it: per the fmsg spec a host rejects a
614+
reply whose parent it has not stored (response code 6), so if the parent was
615+
never addressed to a recipient's domain — or every delivery attempt of the
616+
parent to that domain concluded in rejection — the send is refused with `409`
617+
naming the domain(s) and the remedy (add the recipients to the parent via
618+
add-to, or start a new thread). Domains where the parent's delivery is still
619+
in flight are allowed; the reply's parent's own originating domain always
620+
passes (it retains its outgoing messages). Local recipients are unaffected.
621+
612622
**Response:** `200 OK` with `{"id": <int>, "time": <float64>}`.
613623

614624
**Errors:**
@@ -618,6 +628,7 @@ Marks a draft message as sent by setting `time_sent` to the current timestamp. O
618628
| `403` | Not the owner |
619629
| `404` | Message not found |
620630
| `409` | Message already sent |
631+
| `409` | Reply can never be accepted by one or more remote recipient domains (parent never addressed there, or its delivery there failed) |
621632

622633
### POST `/fmsg/:id/read`
623634

internal/handlers/messages.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,109 @@ func (h *MessageHandler) resolveLocalDelivery(ctx context.Context, table string,
137137
}
138138
}
139139

140+
// parentDomainDelivery summarizes the parent message's recorded delivery for
141+
// one recipient domain.
142+
type parentDomainDelivery struct {
143+
delivered bool // at least one recipient there was delivered (parent is stored)
144+
pending bool // at least one recipient there has no outcome recorded yet
145+
codes []int // failure response codes recorded for the domain
146+
}
147+
148+
// undeliverableReplyDomains returns, for each remote recipient domain of a
149+
// reply, the reason the reply can never be accepted there: the parent message
150+
// was never addressed to the domain, or every delivery attempt there
151+
// concluded in rejection. Per the fmsg spec a host rejects a reply whose
152+
// parent it has not stored (code 6), so sending such a reply is a client
153+
// error worth immediate feedback — the remedy is add-to on the parent or a
154+
// resend, not a retry. Domains where the parent is merely still in flight are
155+
// allowed: sequencing in-flight chains is the host's outbound concern, not
156+
// the client's.
157+
func undeliverableReplyDomains(replyDomains []string, parentFromDomain string, byDomain map[string]parentDomainDelivery) []string {
158+
var blocked []string
159+
for _, d := range replyDomains {
160+
if strings.EqualFold(d, parentFromDomain) {
161+
continue // the originating host retains its own outgoing messages
162+
}
163+
s, ok := byDomain[strings.ToLower(d)]
164+
switch {
165+
case !ok:
166+
blocked = append(blocked, fmt.Sprintf("%s: the message being replied to was never addressed to this domain", d))
167+
case s.delivered || s.pending:
168+
// stored there, or still in flight
169+
default:
170+
blocked = append(blocked, fmt.Sprintf("%s: delivery of the message being replied to failed there (response code(s) %v)", d, s.codes))
171+
}
172+
}
173+
return blocked
174+
}
175+
176+
// remoteRecipientDomains returns the reply's recipient domains excluding the
177+
// local domain, deduplicated case-insensitively.
178+
func remoteRecipientDomains(msg *models.Message, localDomain string) []string {
179+
seen := map[string]bool{}
180+
var out []string
181+
add := func(addr string) {
182+
_, domain := parseAddr(addr)
183+
key := strings.ToLower(domain)
184+
if domain == "" || strings.EqualFold(domain, localDomain) || seen[key] {
185+
return
186+
}
187+
seen[key] = true
188+
out = append(out, domain)
189+
}
190+
for _, a := range msg.To {
191+
add(a)
192+
}
193+
for _, b := range msg.AddTo {
194+
for _, a := range b.To {
195+
add(a)
196+
}
197+
}
198+
return out
199+
}
200+
201+
// parentDeliveryByDomain loads the parent's sender domain and a per-domain
202+
// summary of its recorded recipient delivery outcomes.
203+
func (h *MessageHandler) parentDeliveryByDomain(ctx context.Context, parentID int64) (string, map[string]parentDomainDelivery, error) {
204+
var fromAddr string
205+
if err := h.DB.Pool.QueryRow(ctx, "SELECT from_addr FROM msg WHERE id = $1", parentID).Scan(&fromAddr); err != nil {
206+
return "", nil, err
207+
}
208+
rows, err := h.DB.Pool.Query(ctx, `
209+
SELECT addr, time_delivered IS NOT NULL, response_code FROM (
210+
SELECT addr, time_delivered, response_code FROM msg_to WHERE msg_id = $1
211+
UNION ALL
212+
SELECT addr, time_delivered, response_code FROM msg_add_to WHERE msg_id = $1
213+
) r`, parentID)
214+
if err != nil {
215+
return "", nil, err
216+
}
217+
defer rows.Close()
218+
byDomain := map[string]parentDomainDelivery{}
219+
for rows.Next() {
220+
var addr string
221+
var delivered bool
222+
var code *int
223+
if err := rows.Scan(&addr, &delivered, &code); err != nil {
224+
return "", nil, err
225+
}
226+
_, domain := parseAddr(addr)
227+
key := strings.ToLower(domain)
228+
s := byDomain[key]
229+
switch {
230+
case delivered:
231+
s.delivered = true
232+
case code != nil:
233+
s.codes = append(s.codes, *code)
234+
default:
235+
s.pending = true
236+
}
237+
byDomain[key] = s
238+
}
239+
_, fromDomain := parseAddr(fromAddr)
240+
return fromDomain, byDomain, rows.Err()
241+
}
242+
140243
// messageInput is used for JSON binding on Create/Update — includes Data for the message body.
141244
// The outer AddTo field shadows models.Message.AddTo (same JSON name, shallower
142245
// depth wins), capturing any add_to in the body into an ignored value of any
@@ -763,6 +866,28 @@ func (h *MessageHandler) Send(c *gin.Context) {
763866
return
764867
}
765868

869+
// A reply can only be accepted by hosts that store its parent (SPEC
870+
// §10.3, reject code 6). Refuse now — with the reason — when a remote
871+
// recipient domain can never accept it, rather than letting the reply
872+
// bounce there later. Domains where the parent is still in flight pass:
873+
// sequencing those is the host's outbound concern.
874+
if existing.PID != nil {
875+
if replyDomains := remoteRecipientDomains(existing, h.LocalDomain); len(replyDomains) > 0 {
876+
parentFromDomain, byDomain, derr := h.parentDeliveryByDomain(ctx, *existing.PID)
877+
if derr != nil {
878+
log.Printf("send message %d: verify parent %d delivery: %v", msgID, *existing.PID, derr)
879+
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify parent delivery"})
880+
return
881+
}
882+
if blocked := undeliverableReplyDomains(replyDomains, parentFromDomain, byDomain); len(blocked) > 0 {
883+
c.JSON(http.StatusConflict, gin.H{"error": "reply cannot be accepted by recipient host(s): " +
884+
strings.Join(blocked, "; ") +
885+
" — add the recipients to the parent message (add-to) or start a new thread with them"})
886+
return
887+
}
888+
}
889+
}
890+
766891
now := float64(time.Now().UnixMicro()) / 1e6
767892
if _, err = h.DB.Pool.Exec(ctx, "UPDATE msg SET time_sent = $1 WHERE id = $2", now, msgID); err != nil {
768893
log.Printf("send message %d: %v", msgID, err)

internal/handlers/messages_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package handlers
22

33
import (
4+
"github.com/markmnl/fmsg-webapi/internal/models"
45
"os"
56
"path/filepath"
67
"strings"
@@ -282,3 +283,50 @@ func TestExtractShortText(t *testing.T) {
282283
}
283284
})
284285
}
286+
287+
func TestUndeliverableReplyDomains(t *testing.T) {
288+
byDomain := map[string]parentDomainDelivery{
289+
"delivered.example": {delivered: true},
290+
"pending.example": {pending: true},
291+
"failed.example": {codes: []int{6}},
292+
"mixed.example": {delivered: true, codes: []int{100}},
293+
"partfail.example": {pending: true, codes: []int{6}},
294+
}
295+
cases := []struct {
296+
name string
297+
replyDomains []string
298+
fromDomain string
299+
wantBlocked int
300+
}{
301+
{"delivered parent passes", []string{"delivered.example"}, "origin.example", 0},
302+
{"in-flight parent passes", []string{"pending.example"}, "origin.example", 0},
303+
{"partially failed but still pending passes", []string{"partfail.example"}, "origin.example", 0},
304+
{"delivered outweighs a failed sibling", []string{"mixed.example"}, "origin.example", 0},
305+
{"originating domain always passes", []string{"origin.example"}, "origin.example", 0},
306+
{"originating domain passes case-insensitively", []string{"Origin.Example"}, "origin.example", 0},
307+
{"never-addressed domain blocked", []string{"stranger.example"}, "origin.example", 1},
308+
{"permanently failed domain blocked", []string{"failed.example"}, "origin.example", 1},
309+
{"mixed reply blocks only the bad domains", []string{"delivered.example", "failed.example", "stranger.example"}, "origin.example", 2},
310+
}
311+
for _, tc := range cases {
312+
t.Run(tc.name, func(t *testing.T) {
313+
got := undeliverableReplyDomains(tc.replyDomains, tc.fromDomain, byDomain)
314+
if len(got) != tc.wantBlocked {
315+
t.Fatalf("blocked = %v, want %d entries", got, tc.wantBlocked)
316+
}
317+
})
318+
}
319+
}
320+
321+
func TestRemoteRecipientDomains(t *testing.T) {
322+
msg := &models.Message{
323+
To: []string{"@a@remote.example", "@b@Remote.Example", "@c@local.example"},
324+
AddTo: []models.AddToBatch{
325+
{To: []string{"@d@other.example", "@e@local.example"}},
326+
},
327+
}
328+
got := remoteRecipientDomains(msg, "local.example")
329+
if len(got) != 2 || !strings.EqualFold(got[0], "remote.example") || !strings.EqualFold(got[1], "other.example") {
330+
t.Fatalf("domains = %v", got)
331+
}
332+
}

0 commit comments

Comments
 (0)