@@ -1721,65 +1721,84 @@ func TestHandleUnpinned_DMRoom_FansOutToBothMembers(t *testing.T) {
17211721
17221722func TestThreadFanOutAccounts (t * testing.T ) {
17231723 tests := []struct {
1724- name string
1725- sender string
1726- followers map [string ]struct {}
1727- extraAccounts []string
1728- want []string
1724+ name string
1725+ sender string
1726+ followers map [string ]struct {}
1727+ mentions []string
1728+ memberMentions map [string ]struct {}
1729+ want []string
17291730 }{
17301731 {
1731- name : "sender alone still notified (own devices, no other followers)" ,
1732- sender : "alice" ,
1733- followers : map [string ]struct {}{},
1734- extraAccounts : nil ,
1735- want : []string {"alice" },
1732+ name : "sender alone still notified (own devices, no other followers)" ,
1733+ sender : "alice" ,
1734+ followers : map [string ]struct {}{},
1735+ want : []string {"alice" },
17361736 },
17371737 {
1738- name : "sender included even when not yet in replyAccounts (race-free)" ,
1739- sender : "alice" ,
1740- followers : map [string ]struct {}{"bob" : {}},
1741- extraAccounts : nil ,
1742- want : []string {"alice" , "bob" },
1738+ name : "sender included even when not yet in replyAccounts (race-free)" ,
1739+ sender : "alice" ,
1740+ followers : map [string ]struct {}{"bob" : {}},
1741+ want : []string {"alice" , "bob" },
17431742 },
17441743 {
1745- name : "sender included when also a follower (multi-device support )" ,
1744+ name : "follower delivered without a membership check (replyAccounts trusted, #308 )" ,
17461745 sender : "alice" ,
1747- followers : map [string ]struct {}{"alice" : {}, "bob" : {}},
1748- want : []string {"alice" , "bob" },
1746+ followers : map [string ]struct {}{"bob" : {}, "carol" : {}},
1747+ want : []string {"alice" , "bob" , "carol" },
1748+ },
1749+ {
1750+ name : "member mention delivered" ,
1751+ sender : "alice" ,
1752+ followers : map [string ]struct {}{},
1753+ mentions : []string {"carol" },
1754+ memberMentions : map [string ]struct {}{"carol" : {}},
1755+ want : []string {"alice" , "carol" },
17491756 },
17501757 {
1751- name : "sender included when only in extra accounts" ,
1752- sender : "alice" ,
1753- followers : map [string ]struct {}{"bob" : {}},
1754- extraAccounts : []string {"alice" },
1755- want : []string {"bob" , "alice" },
1758+ name : "non-member mention filtered out (#309)" ,
1759+ sender : "alice" ,
1760+ followers : map [string ]struct {}{},
1761+ mentions : []string {"mallory" },
1762+ memberMentions : map [string ]struct {}{}, // mallory is not a member → dropped
1763+ want : []string {"alice" },
17561764 },
17571765 {
1758- name : "extra accounts merged deduped" ,
1759- sender : "alice" ,
1760- followers : map [string ]struct {}{"bob" : {}},
1761- extraAccounts : []string {"bob" , "carol" },
1762- want : []string {"alice" , "bob" , "carol" },
1766+ name : "followers trusted, mentions gated, mix" ,
1767+ sender : "alice" ,
1768+ followers : map [string ]struct {}{"bob" : {}},
1769+ mentions : []string {"carol" , "mallory" },
1770+ memberMentions : map [string ]struct {}{"carol" : {}},
1771+ want : []string {"alice" , "bob" , "carol" },
17631772 },
17641773 {
1765- name : "bot accounts skipped even if sender is bot" ,
1766- sender : "helper.bot" ,
1767- followers : map [string ]struct {}{"helper.bot" : {}, "bob" : {}},
1768- extraAccounts : []string {"other.bot" },
1769- want : []string {"bob" },
1774+ name : "bot accounts skipped even if sender is bot" ,
1775+ sender : "helper.bot" ,
1776+ followers : map [string ]struct {}{"helper.bot" : {}, "bob" : {}},
1777+ mentions : []string {"other.bot" },
1778+ memberMentions : map [string ]struct {}{"other.bot" : {}},
1779+ want : []string {"bob" },
17701780 },
17711781 {
1772- name : "sender not duplicated when in both followers and extras" ,
1773- sender : "alice" ,
1774- followers : map [string ]struct {}{"alice" : {}, "bob" : {}},
1775- extraAccounts : []string {"alice" , "carol" },
1776- want : []string {"alice" , "bob" , "carol" },
1782+ name : "sender not duplicated when also a follower and mention" ,
1783+ sender : "alice" ,
1784+ followers : map [string ]struct {}{"alice" : {}, "bob" : {}},
1785+ mentions : []string {"alice" , "carol" },
1786+ memberMentions : map [string ]struct {}{"alice" : {}, "carol" : {}},
1787+ want : []string {"alice" , "bob" , "carol" },
1788+ },
1789+ {
1790+ name : "mention that is also a follower is delivered via the trusted follower path" ,
1791+ sender : "alice" ,
1792+ followers : map [string ]struct {}{"bob" : {}},
1793+ mentions : []string {"bob" },
1794+ memberMentions : map [string ]struct {}{"bob" : {}},
1795+ want : []string {"alice" , "bob" },
17771796 },
17781797 }
17791798
17801799 for _ , tc := range tests {
17811800 t .Run (tc .name , func (t * testing.T ) {
1782- got := threadFanOutAccounts (tc .sender , tc .followers , tc .extraAccounts )
1801+ got := threadFanOutAccounts (tc .sender , tc .followers , tc .mentions , tc . memberMentions )
17831802 assert .ElementsMatch (t , tc .want , got )
17841803 })
17851804 }
@@ -2016,6 +2035,7 @@ func TestHandleThreadCreated_ChannelRoom_FansOutToFollowers(t *testing.T) {
20162035 followers := map [string ]struct {}{"bob" : {}, "carol" : {}}
20172036 store .EXPECT ().GetRoomMeta (gomock .Any (), roomID ).Return (metaOf (testChannelRoom ), nil )
20182037 store .EXPECT ().GetThreadFollowers (gomock .Any (), parentMsgID ).Return (followers , nil )
2038+ // No @-mentions in the reply → no membership query; followers are trusted (#308).
20192039 us .EXPECT ().FindUsersByAccounts (gomock .Any (), []string {"alice" }).Return ([]model.User {testUsers [0 ]}, nil )
20202040
20212041 evt := model.MessageEvent {
@@ -2054,6 +2074,60 @@ func TestHandleThreadCreated_ChannelRoom_FansOutToFollowers(t *testing.T) {
20542074 assert .True (t , subjects [subject .UserRoomEvent ("carol" )])
20552075}
20562076
2077+ func TestHandleThreadCreated_ChannelRoom_FiltersNonMemberMentions (t * testing.T ) {
2078+ ctrl := gomock .NewController (t )
2079+ store := NewMockStore (ctrl )
2080+ us := NewMockUserStore (ctrl )
2081+ pub := & mockPublisher {}
2082+ keyStore := NewMockRoomKeyProvider (ctrl )
2083+
2084+ msgTime := time .Date (2026 , 4 , 1 , 10 , 0 , 0 , 0 , time .UTC )
2085+ parentMsgID := "parent-1"
2086+ siteID := "site-a"
2087+ roomID := "r1"
2088+
2089+ // Followers bob + carol are delivered without a membership check (#308); of
2090+ // the mentions, member dave is delivered but non-member mallory is not (#309).
2091+ followers := map [string ]struct {}{"bob" : {}, "carol" : {}}
2092+ store .EXPECT ().GetRoomMeta (gomock .Any (), roomID ).Return (metaOf (testChannelRoom ), nil )
2093+ store .EXPECT ().GetThreadFollowers (gomock .Any (), parentMsgID ).Return (followers , nil )
2094+ // Only the mentioned accounts are queried — never the followers.
2095+ store .EXPECT ().FilterRoomMembers (gomock .Any (), "room-1" , gomock .InAnyOrder ([]string {"dave" , "mallory" })).
2096+ Return (map [string ]struct {}{"dave" : {}}, nil )
2097+ us .EXPECT ().FindUsersByAccounts (gomock .Any (), gomock .InAnyOrder ([]string {"alice" , "dave" , "mallory" })).Return ([]model.User {testUsers [0 ]}, nil )
2098+
2099+ evt := model.MessageEvent {
2100+ Event : model .EventCreated ,
2101+ SiteID : siteID ,
2102+ Timestamp : msgTime .UnixMilli (),
2103+ Message : model.Message {
2104+ ID : "reply-1" ,
2105+ RoomID : roomID ,
2106+ UserID : "u-alice" ,
2107+ UserAccount : "alice" ,
2108+ Content : "a thread reply @dave @mallory" ,
2109+ CreatedAt : msgTime ,
2110+ ThreadParentMessageID : parentMsgID ,
2111+ TShow : false ,
2112+ },
2113+ }
2114+ data , _ := json .Marshal (evt )
2115+
2116+ h := NewHandler (store , us , pub , keyStore , false )
2117+ require .NoError (t , h .HandleMessage (context .Background (), data ))
2118+
2119+ subjects := map [string ]bool {}
2120+ for _ , r := range pub .records {
2121+ subjects [r .subject ] = true
2122+ }
2123+ assert .True (t , subjects [subject .UserRoomEvent ("alice" )], "sender must receive their own echo" )
2124+ assert .True (t , subjects [subject .UserRoomEvent ("bob" )], "follower must receive the event" )
2125+ assert .True (t , subjects [subject .UserRoomEvent ("carol" )], "follower must receive the event" )
2126+ assert .True (t , subjects [subject .UserRoomEvent ("dave" )], "member mention must receive the event" )
2127+ assert .False (t , subjects [subject .UserRoomEvent ("mallory" )], "non-member mention must NOT receive the live thread event (#309)" )
2128+ require .Len (t , pub .records , 4 )
2129+ }
2130+
20572131func TestHandleThreadCreated_ChannelRoom_NoFollowers_SendsToSenderOnly (t * testing.T ) {
20582132 ctrl := gomock .NewController (t )
20592133 store := NewMockStore (ctrl )
@@ -2065,6 +2139,7 @@ func TestHandleThreadCreated_ChannelRoom_NoFollowers_SendsToSenderOnly(t *testin
20652139
20662140 store .EXPECT ().GetRoomMeta (gomock .Any (), "r1" ).Return (metaOf (testChannelRoom ), nil )
20672141 store .EXPECT ().GetThreadFollowers (gomock .Any (), "parent-1" ).Return (map [string ]struct {}{}, nil )
2142+ // No followers and no mentions → no membership query at all.
20682143 us .EXPECT ().FindUsersByAccounts (gomock .Any (), []string {"alice" }).Return ([]model.User {testUsers [0 ]}, nil )
20692144
20702145 evt := model.MessageEvent {
@@ -2186,6 +2261,7 @@ func TestHandleThreadUpdated_ChannelRoom_FansOutToFollowers(t *testing.T) {
21862261 followers := map [string ]struct {}{"bob" : {}, "carol" : {}}
21872262 store .EXPECT ().GetRoom (gomock .Any (), roomID ).Return (room , nil )
21882263 store .EXPECT ().GetThreadFollowers (gomock .Any (), parentMsgID ).Return (followers , nil )
2264+ // No @-mentions → no membership query; followers are trusted (#308).
21892265
21902266 evt := model.MessageEvent {
21912267 Event : model .EventUpdated ,
@@ -2334,6 +2410,7 @@ func TestHandleThreadDeleted_ChannelRoom_FansOutToFollowers(t *testing.T) {
23342410 followers := map [string ]struct {}{"bob" : {}, "carol" : {}}
23352411 store .EXPECT ().GetRoom (gomock .Any (), roomID ).Return (room , nil )
23362412 store .EXPECT ().GetThreadFollowers (gomock .Any (), parentMsgID ).Return (followers , nil )
2413+ // No @-mentions → no membership query; followers are trusted (#308).
23372414 // No NewTCount → no badge update.
23382415
23392416 evt := model.MessageEvent {
@@ -2387,6 +2464,7 @@ func TestHandleThreadDeleted_ChannelRoom_WithBadgeUpdate(t *testing.T) {
23872464 room := & model.Room {ID : "r1" , Type : model .RoomTypeChannel , SiteID : "site-a" }
23882465 store .EXPECT ().GetRoom (gomock .Any (), "r1" ).Return (room , nil )
23892466 store .EXPECT ().GetThreadFollowers (gomock .Any (), "parent-1" ).Return (map [string ]struct {}{"bob" : {}}, nil )
2467+ // No @-mentions → no membership query; follower bob is trusted (#308).
23902468
23912469 evt := model.MessageEvent {
23922470 Event : model .EventDeleted ,
0 commit comments