@@ -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 )
0 commit comments