Files
mailgoserver/internal/notify/notify.go
T
2026-08-22 06:45:05 +01:00

88 lines
3.0 KiB
Go

// 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, "")
}