// Package notify is a minimal in-process pub-sub for "a message landed in this // mailbox folder" events — the single relay point SMTP local delivery and IMAP APPEND // call into, and both IMAP IDLE push (internal/imapserver) and webmail SSE push // (internal/webui) subscribe to. Deliberately just a wakeup signal with no message // content: every subscriber already has its own authoritative way to re-fetch state // (a DB query), so there's no duplicate-source-of-truth risk in keeping the event // itself empty. package notify import "sync" type key struct { mailboxID int64 folder string } // Bus fans a Publish out to every current Subscribe-r of the same (mailboxID, // folder). Safe for concurrent use. type Bus struct { mu sync.Mutex subs map[key][]chan struct{} } func NewBus() *Bus { return &Bus{subs: make(map[key][]chan struct{})} } // Subscribe returns a channel that receives a value each time Publish(mailboxID, // folder) is called, and an unsubscribe func the caller must call when done listening // (typically via defer). The channel is buffered 1 and sent to non-blockingly — a // subscriber that's slow to drain it can miss a notification, which is fine, since the // next poll/idle cycle still reflects real DB state regardless of whether it was // notified about this specific change. A nil Bus is a valid receiver (see Publish): // the returned channel simply never fires, and unsubscribe is a no-op. func (b *Bus) Subscribe(mailboxID int64, folder string) (ch <-chan struct{}, unsubscribe func()) { if b == nil { return make(chan struct{}), func() {} } k := key{mailboxID, folder} c := make(chan struct{}, 1) b.mu.Lock() b.subs[k] = append(b.subs[k], c) b.mu.Unlock() return c, func() { b.mu.Lock() defer b.mu.Unlock() list := b.subs[k] for i, existing := range list { if existing == c { b.subs[k] = append(list[:i], list[i+1:]...) break } } if len(b.subs[k]) == 0 { delete(b.subs, k) } } } // Publish wakes every current subscriber of (mailboxID, folder). A nil Bus is a valid // no-op receiver — callers that hold an optionally-nil *Bus (e.g. in a test backend // that never wired one up) can call Publish unconditionally instead of nil-checking // at every call site. func (b *Bus) Publish(mailboxID int64, folder string) { if b == nil { return } b.mu.Lock() subs := append([]chan struct{}{}, b.subs[key{mailboxID, folder}]...) b.mu.Unlock() for _, c := range subs { select { case c <- struct{}{}: default: } } } // PublishAccountWide wakes every subscriber of mailboxID's account-wide sentinel key // (folder ""), which never collides with a real folder name. Used by JMAP's // EventSource push (internal/jmap), which pushes per-account rather than per-folder // like IMAP IDLE/webmail's SSE — call this alongside the normal per-folder Publish at // any call site that creates/mutates/deletes a message or folder, so a JMAP client's // EventSource connection sees it too. func (b *Bus) PublishAccountWide(mailboxID int64) { b.Publish(mailboxID, "") }