Phase 8: push and notifications

Implements PLAN.md Phase 8. internal/pushrules is the rule language: the
spec's default ruleset, all six condition kinds, glob and word-boundary
matching, intentional mentions switching off the legacy body rules, and
rule ordering as Synapse has it. internal/pushserver keeps the rules,
pushers and notifications and talks to push gateways.

Push rules are the m.push_rules account data, written only by the push
server; reads keep the user's own rules and the enabled/actions of each
default and take the rest from the current defaults, so new default rules
reach every user without a migration. PUT account_data/m.push_rules is
refused with 405.

The push server listens on the roomserver's emit stage and evaluates each
event on its own worker for every local member and an invite's target.
Notifications and per-room unread counts are stored in migration 0010;
counts are a seventh sync token stream, so /sync reports
unread_notifications and picks up count changes made after the event or by
a receipt from another device. m.read and m.read.private receipts clear
the counts; leaving a room clears it.

Pushers (http kind only) push through a bounded sender pool with retries,
honour event_id_only, drop push keys the gateway rejects, and go away with
their device. GET /notifications pages the history. New config section
push: enabled, include_content, gateway_timeout, notification_retention.

Account data writes now wake the user's blocked syncs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-17 11:43:36 +02:00
1 parent 74f7bc8805
commit b507753172
47 files changed
+5136 -50

No files matched your search

+51
View File
@@ -0,0 +1,51 @@
package syncapi
import (
"context"
"slices"
)
// pushRulesType is the account data push rules travel as. The push server
// renders it rather than the account data store, because a user who never
// changed a rule has none stored and still has the server defaults.
const pushRulesType = "m.push_rules"
// UnreadNotifications is a joined room's unread counts for the user
// syncing.
type UnreadNotifications struct {
NotificationCount int `json:"notification_count"`
HighlightCount int `json:"highlight_count"`
}
// unreadNotifications fills in the counts of every joined room in the
// response, in one read.
//
// Every joined room reported gets its counts, not only the ones whose
// counts moved: a room is in a response because something in it changed,
// and a client showing a new message wants the count beside it whether or
// not this particular sync is the one that moved it.
func (s *API) unreadNotifications(ctx context.Context, userID string, resp *Response) error {
if s.push == nil || len(resp.Rooms.Join) == 0 {
return nil
}
roomIDs := make([]string, 0, len(resp.Rooms.Join))
for roomID := range resp.Rooms.Join {
roomIDs = append(roomIDs, roomID)
}
slices.Sort(roomIDs)
counts, err := s.push.NotificationCounts(ctx, userID, roomIDs)
if err != nil {
return err
}
for _, roomID := range roomIDs {
room := resp.Rooms.Join[roomID]
c := counts[roomID]
room.UnreadNotifications = &UnreadNotifications{
NotificationCount: c.Notifications,
HighlightCount: c.Highlights,
}
resp.Rooms.Join[roomID] = room
}
return nil
}
+5
View File
@@ -97,6 +97,11 @@ func (s *API) SetReceipt(ctx context.Context, req ReceiptRequest) error {
if !moved {
return nil
}
// A receipt is also the user saying how far they have read, which is
// what clears their unread notifications.
if s.push != nil && s.isLocal(req.User) {
s.push.ReceiptSet(ctx, req.User, req.RoomID, req.ReceiptType, req.ThreadID, req.EventID)
}
// A private receipt concerns only its sender; a public one is part of
// the room's ephemeral stream and everybody in it wants it.
if req.ReceiptType == ReceiptReadPrivate {
+48 -7
View File
@@ -97,6 +97,12 @@ type JoinedRoom struct {
Timeline Timeline `json:"timeline"`
Ephemeral EphemeralEventList `json:"ephemeral"`
AccountData AccountDataList `json:"account_data"`
// UnreadNotifications is the user's unread notification counts in the
// room. It is a pointer so that a server without a push server leaves
// it out rather than claiming zero: an absent count says nothing,
// where a zero says "you have read everything".
UnreadNotifications *UnreadNotifications `json:"unread_notifications,omitempty"`
}
// InvitedRoom is a room the user has been invited to. It carries no
@@ -327,8 +333,11 @@ func (s *API) build(ctx context.Context, req SyncRequest, filter Filter, since T
if err := s.buildRooms(ctx, req, filter, since, to, rooms, &resp); err != nil {
return Response{}, false, err
}
if err := s.unreadNotifications(ctx, req.User, &resp); err != nil {
return Response{}, false, err
}
accountData, err := s.globalAccountData(ctx, req.Localpart, since.AccountData, to.AccountData, filter)
accountData, err := s.globalAccountData(ctx, req, since.AccountData, to.AccountData, filter)
if err != nil {
return Response{}, false, err
}
@@ -537,6 +546,22 @@ func (s *API) changedRooms(ctx context.Context, req SyncRequest, filter Filter,
}
}
// A room whose unread counts moved without an event in this window —
// read on another device, or counted after the event was already sent
// to this client — is reported so that the client hears the new
// counts.
if s.push != nil && to.Notification > since.Notification {
changed, err := s.push.NotificationCountChanges(ctx, req.User, since.Notification, to.Notification)
if err != nil {
return nil, err
}
for _, roomID := range changed {
if slices.Contains(joined, roomID) {
out[roomID] = struct{}{}
}
}
}
for _, room := range rooms {
if room.Position > since.PDU && room.Position <= to.PDU {
out[room.RoomID] = struct{}{}
@@ -640,12 +665,20 @@ func (s *API) roomEphemeral(ctx context.Context, userID, roomID string, window r
}
// globalAccountData reads the account data that is not about a room.
func (s *API) globalAccountData(ctx context.Context, localpart string, from, to int64, filter Filter) ([]AccountDataEvent, error) {
entries, err := s.accountDataEntries(ctx, localpart, from, to)
func (s *API) globalAccountData(ctx context.Context, req SyncRequest, from, to int64, filter Filter) ([]AccountDataEvent, error) {
entries, err := s.accountDataEntries(ctx, req.Localpart, from, to)
if err != nil {
return nil, err
}
return s.accountDataEvents(ctx, localpart, entries, "", filter.AccountDataFilter())
// Every user has push rules, written or not: a user who has never
// changed one has the server defaults, and a client reads its rules
// from the account data of its first sync whichever it is.
if from == 0 && s.push != nil && !slices.ContainsFunc(entries, func(e storage.AccountDataEntry) bool {
return e.RoomID == "" && e.DataType == pushRulesType
}) {
entries = append(entries, storage.AccountDataEntry{DataType: pushRulesType})
}
return s.accountDataEvents(ctx, req, entries, "", filter.AccountDataFilter())
}
// roomAccountData reads one room's account data.
@@ -655,7 +688,7 @@ func (s *API) roomAccountData(ctx context.Context, localpart, roomID string, win
if err != nil {
return nil, err
}
return s.accountDataEvents(ctx, localpart, entries, roomID, filter.RoomAccountDataFilter())
return s.accountDataEvents(ctx, SyncRequest{Localpart: localpart}, entries, roomID, filter.RoomAccountDataFilter())
}
// accountDataEntries lists which of a user's account data changed in a
@@ -669,7 +702,7 @@ func (s *API) accountDataEntries(ctx context.Context, localpart string, from, to
// accountDataEvents turns the entries of one scope — global, or one room —
// into the events a sync reports.
func (s *API) accountDataEvents(ctx context.Context, localpart string, entries []storage.AccountDataEntry, roomID string, filter storage.TimelineFilter) ([]AccountDataEvent, error) {
func (s *API) accountDataEvents(ctx context.Context, req SyncRequest, entries []storage.AccountDataEntry, roomID string, filter storage.TimelineFilter) ([]AccountDataEvent, error) {
out := []AccountDataEvent{}
for _, entry := range entries {
if entry.RoomID != roomID {
@@ -678,7 +711,15 @@ func (s *API) accountDataEvents(ctx context.Context, localpart string, entries [
if !matchesEventFilter(entry.DataType, "", filter) {
continue
}
content, err := s.accounts.AccountData(ctx, localpart, entry.RoomID, entry.DataType)
if entry.RoomID == "" && entry.DataType == pushRulesType && s.push != nil {
content, err := s.push.Document(ctx, req.Localpart, req.User)
if err != nil {
return nil, err
}
out = append(out, AccountDataEvent{Type: entry.DataType, Content: content})
continue
}
content, err := s.accounts.AccountData(ctx, req.Localpart, entry.RoomID, entry.DataType)
switch {
case errors.Is(err, storage.ErrNoSuchAccountData):
// Deleted between the two reads: nothing to report.
+20 -2
View File
@@ -28,8 +28,9 @@
//
// Phase 4 of PLAN.md, with device lists from Phase 7: the keyserver writes
// the device list stream, and this package works out which of its changes
// concern whom (devicelists.go). What it does not do yet is notification
// counts, which need push rules (Phase 8); docs/sync.md names that gap.
// concern whom (devicelists.go). Notification counts, from Phase 8, are
// the push server's: it writes them and their stream, and a sync reports
// them per joined room (notifications.go).
package syncapi
import (
@@ -82,12 +83,24 @@ type Keys interface {
DeviceListChanges(ctx context.Context, userIDs []string, from, to int64) ([]storage.DeviceListChange, error)
}
// Push is the part of the push server a sync reads: the push rules it
// reports as account data, the unread counts of each room, and which
// rooms' counts moved. It is also told about read receipts, which are what
// clear the counts. *pushserver.API satisfies it.
type Push interface {
Document(ctx context.Context, localpart, userID string) ([]byte, error)
NotificationCounts(ctx context.Context, userID string, roomIDs []string) (map[string]storage.NotificationCounts, error)
NotificationCountChanges(ctx context.Context, userID string, from, to int64) ([]string, error)
ReceiptSet(ctx context.Context, userID, roomID, receiptType, threadID, eventID string)
}
// API is the syncapi component.
type API struct {
serverName string
rooms *roomserver.API
accounts Accounts
keys Keys
push Push
store storage.SyncStore
notifier *Notifier
typing *typingTracker
@@ -126,6 +139,10 @@ type Options struct {
// both device lists empty.
Keys Keys
// Push is the push server. Nil leaves the notification counts out of
// a sync, and m.push_rules is then whatever account data holds.
Push Push
// Store is the sync half of storage.
Store storage.SyncStore
@@ -206,6 +223,7 @@ func New(opts Options) (*API, error) {
rooms: opts.Rooms,
accounts: opts.Accounts,
keys: opts.Keys,
push: opts.Push,
store: opts.Store,
notifier: opts.Notifier,
typing: newTypingTracker(opts.TypingTimeout, opts.Notifier, opts.Now),
+14 -6
View File
@@ -18,12 +18,13 @@ import (
//
// The wire form is
//
// s{pdu}_{receipt}_{to_device}_{account_data}_{presence}_{device_list}
// s{pdu}_{receipt}_{to_device}_{account_data}_{presence}_{device_list}_{notification}
//
// with trailing zeroes left off, so a client that has only ever seen room
// events carries "s12" rather than "s12_0_0_0_0_0". The device list
// position came with Phase 7 and was added at the end, which is what lets
// every token handed out before it go on parsing. The leading "s" is there
// events carries "s12" rather than "s12_0_0_0_0_0_0". The device list
// position came with Phase 7 and the notification count position with
// Phase 8, each added at the end, which is what lets every token handed
// out before them go on parsing. The leading "s" is there
// to make a token recognisable in a log and to leave room for a second
// token shape later — Simplified Sliding Sync (PLAN.md phase 10) has its
// own — without any ambiguity about which is which.
@@ -38,10 +39,13 @@ type Token struct {
AccountData int64
Presence int64
DeviceList int64
// Notification is the push server's notification count stream.
Notification int64
}
// tokenPositions is how many positions a token carries.
const tokenPositions = 6
const tokenPositions = 7
// tokenPrefix marks a classic /sync token.
const tokenPrefix = "s"
@@ -52,7 +56,7 @@ func (t Token) IsInitial() bool { return t == Token{} }
// String renders the wire form.
func (t Token) String() string {
parts := []int64{t.PDU, t.Receipt, t.ToDevice, t.AccountData, t.Presence, t.DeviceList}
parts := []int64{t.PDU, t.Receipt, t.ToDevice, t.AccountData, t.Presence, t.DeviceList, t.Notification}
// Trailing zeroes carry no information, and leaving them off keeps the
// common token short.
last := 0
@@ -117,6 +121,8 @@ func ParseToken(raw string) (Token, error) {
AccountData: positions[3],
Presence: positions[4],
DeviceList: positions[5],
Notification: positions[6],
}, nil
}
@@ -130,6 +136,8 @@ func tokenAt(positions storage.StreamPositions) Token {
AccountData: positions.AccountData,
Presence: positions.Presence,
DeviceList: positions.DeviceList,
Notification: positions.Notification,
}
}
+13 -7
View File
@@ -17,6 +17,12 @@ func TestTokenRoundTrips(t *testing.T) {
{"events and receipts", syncapi.Token{PDU: 12, Receipt: 3}, "s12_3"},
{
"every stream",
syncapi.Token{PDU: 1, Receipt: 2, ToDevice: 3, AccountData: 4, Presence: 5, DeviceList: 6, Notification: 7},
"s1_2_3_4_5_6_7",
},
{
// A Phase 7 token, from before the notification stream.
"six streams",
syncapi.Token{PDU: 1, Receipt: 2, ToDevice: 3, AccountData: 4, Presence: 5, DeviceList: 6},
"s1_2_3_4_5_6",
},
@@ -81,13 +87,13 @@ func TestParseTokenAcceptsAShortToken(t *testing.T) {
func TestParseTokenRejectsNonsense(t *testing.T) {
for _, raw := range []string{
"nonsense", // no prefix
"t1_2", // a prefix this build does not write
"s", // a prefix and nothing else
"s1_-1", // a negative position outside the event stream
"sx", // not a number
"s1_", // a trailing separator
"s1_2_3_4_5_6_7", // more positions than there are streams
"nonsense", // no prefix
"t1_2", // a prefix this build does not write
"s", // a prefix and nothing else
"s1_-1", // a negative position outside the event stream
"sx", // not a number
"s1_", // a trailing separator
"s1_2_3_4_5_6_7_8", // more positions than there are streams
"s1.5",
} {
if _, err := syncapi.ParseToken(raw); err == nil {