mox/imapserver/notify.go
Mechiel Lukkien 8bab38eac4
imapserver: implement NOTIFY extension from RFC 5465
NOTIFY is like IDLE, but where IDLE watches just the selected mailbox, NOTIFY
can watch all mailboxes. With NOTIFY, a client can also ask a server to
immediately return configurable fetch attributes for new messages, e.g. a
message preview, certain header fields, or simply the entire message.

Mild testing with evolution and fairemail.
2025-04-11 10:06:34 +02:00

330 lines
10 KiB
Go

package imapserver
import (
"fmt"
"slices"
"strings"
"github.com/mjl-/bstore"
"github.com/mjl-/mox/store"
)
// Max number of pending changes for selected-delayed mailbox before we write a
// NOTIFICATIONOVERFLOW message, flush changes and stop gathering more changes.
// Changed during tests.
var selectedDelayedChangesMax = 1000
// notify represents a configuration as passed to the notify command.
type notify struct {
// "NOTIFY NONE" results in an empty list, matching no events.
EventGroups []eventGroup
// Changes for the selected mailbox in case of SELECTED-DELAYED, when we don't send
// events asynchrously. These must still be processed later on for their
// ChangeRemoveUIDs, to erase expunged message files. At the end of a command (e.g.
// NOOP) or immediately upon IDLE we will send untagged responses for these
// changes. If the connection breaks, we still process the ChangeRemoveUIDs.
Delayed []store.Change
}
// match checks if an event for a mailbox id/name (optional depending on type)
// should be turned into a notification to the client.
func (n notify) match(c *conn, xtxfn func() *bstore.Tx, mailboxID int64, mailbox string, kind eventKind) (mailboxSpecifier, notifyEvent, bool) {
// We look through the event groups, and won't stop looking until we've found a
// confirmation the event should be notified. ../rfc/5465:756
// Non-message-related events are only matched by non-"selected" mailbox
// specifiers. ../rfc/5465:268
// If you read the mailboxes matching paragraph in isolation, you would think only
// "SELECTED" and "SELECTED-DELAYED" can match events for the selected mailbox. But
// a few other places hint that that only applies to message events, not to mailbox
// events, such as subscriptions and mailbox metadata changes. With a strict
// interpretation, clients couldn't request notifications for such events for the
// selection mailbox. ../rfc/5465:752
for _, eg := range n.EventGroups {
switch eg.MailboxSpecifier.Kind {
case mbspecSelected, mbspecSelectedDelayed: // ../rfc/5465:800
if mailboxID != c.mailboxID || !slices.Contains(messageEventKinds, kind) {
continue
}
for _, ev := range eg.Events {
if eventKind(ev.Kind) == kind {
return eg.MailboxSpecifier, ev, true
}
}
// We can only have a single selected for notify, so no point in continuing the search.
return mailboxSpecifier{}, notifyEvent{}, false
default:
// The selected mailbox can only match for non-message events for specifiers other
// than "selected"/"selected-delayed".
if c.mailboxID == mailboxID && slices.Contains(messageEventKinds, kind) {
continue
}
}
var match bool
Match:
switch eg.MailboxSpecifier.Kind {
case mbspecPersonal: // ../rfc/5465:817
match = true
case mbspecInboxes: // ../rfc/5465:822
if mailbox == "Inbox" || strings.HasPrefix(mailbox, "Inbox/") {
match = true
break Match
}
if mailbox == "" {
break Match
}
// Include mailboxes we may deliver to based on destinations, or based on rulesets,
// not including deliveries for mailing lists.
conf, _ := c.account.Conf()
for _, dest := range conf.Destinations {
if dest.Mailbox == mailbox {
match = true
break Match
}
for _, rs := range dest.Rulesets {
if rs.ListAllowDomain == "" && rs.Mailbox == mailbox {
match = true
break Match
}
}
}
case mbspecSubscribed: // ../rfc/5465:831
sub := store.Subscription{Name: mailbox}
err := xtxfn().Get(&sub)
if err != bstore.ErrAbsent {
xcheckf(err, "lookup subscription")
}
match = err == nil
case mbspecSubtree: // ../rfc/5465:847
for _, name := range eg.MailboxSpecifier.Mailboxes {
if mailbox == name || strings.HasPrefix(mailbox, name+"/") {
match = true
break
}
}
case mbspecSubtreeOne: // ../rfc/7377:274
ntoken := len(strings.Split(mailbox, "/"))
for _, name := range eg.MailboxSpecifier.Mailboxes {
if mailbox == name || (strings.HasPrefix(mailbox, name+"/") && len(strings.Split(name, "/"))+1 == ntoken) {
match = true
break
}
}
case mbspecMailboxes: // ../rfc/5465:853
match = slices.Contains(eg.MailboxSpecifier.Mailboxes, mailbox)
default:
panic("missing case for " + string(eg.MailboxSpecifier.Kind))
}
if !match {
continue
}
// NONE is the signal we shouldn't return events for this mailbox. ../rfc/5465:455
if len(eg.Events) == 0 {
break
}
// If event kind matches, we will be notifying about this change. If not, we'll
// look again at next mailbox specifiers.
for _, ev := range eg.Events {
if eventKind(ev.Kind) == kind {
return eg.MailboxSpecifier, ev, true
}
}
}
return mailboxSpecifier{}, notifyEvent{}, false
}
// Notify enables continuous notifications from the server to the client, without
// the client issuing an IDLE command. The mailboxes and events to notify about are
// specified in the account. When notify is enabled, instead of being blocked
// waiting for a command from the client, we also wait for events from the account,
// and send events about it.
//
// State: Authenticated and selected.
func (c *conn) cmdNotify(tag, cmd string, p *parser) {
// Command: ../rfc/5465:203
// Request syntax: ../rfc/5465:923
p.xspace()
// NONE indicates client doesn't want any events, also not the "normal" events
// without notify. ../rfc/5465:234
// ../rfc/5465:930
if p.take("NONE") {
p.xempty()
// If we have delayed changes for the selected mailbox, we are no longer going to
// notify about them. The client can't know anymore whether messages still exist,
// and trying to read them can cause errors if the messages have been expunged and
// erased.
var changes []store.Change
if c.notify != nil {
changes = c.notify.Delayed
}
c.notify = &notify{}
c.flushChanges(changes)
c.ok(tag, cmd)
return
}
var n notify
var status bool
// ../rfc/5465:926
p.xtake("SET")
p.xspace()
if p.take("STATUS") {
status = true
p.xspace()
}
for {
eg := p.xeventGroup()
n.EventGroups = append(n.EventGroups, eg)
if !p.space() {
break
}
}
p.xempty()
for _, eg := range n.EventGroups {
var hasNew, hasExpunge, hasFlag, hasAnnotation bool
for _, ev := range eg.Events {
switch eventKind(ev.Kind) {
case eventMessageNew:
hasNew = true
case eventMessageExpunge:
hasExpunge = true
case eventFlagChange:
hasFlag = true
case eventMailboxName, eventSubscriptionChange, eventMailboxMetadataChange, eventServerMetadataChange:
// Nothing special.
default: // Including eventAnnotationChange.
hasAnnotation = true // Ineffective, we don't implement message annotations yet.
// Result must be NO instead of BAD, and we must include BADEVENT and the events we
// support. ../rfc/5465:343
// ../rfc/5465:1033
xusercodeErrorf("BADEVENT (MessageNew MessageExpunge FlagChange MailboxName SubscriptionChange MailboxMetadataChange ServerMetadataChange)", "unimplemented event %s", ev.Kind)
}
}
if hasNew != hasExpunge {
// ../rfc/5465:443 ../rfc/5465:987
xsyntaxErrorf("MessageNew and MessageExpunge must be specified together")
}
if (hasFlag || hasAnnotation) && !hasNew {
// ../rfc/5465:439
xsyntaxErrorf("FlagChange and/or AnnotationChange requires MessageNew and MessageExpunge")
}
}
for _, eg := range n.EventGroups {
for i, name := range eg.MailboxSpecifier.Mailboxes {
eg.MailboxSpecifier.Mailboxes[i] = xcheckmailboxname(name, true)
}
}
// Only one selected/selected-delay mailbox filter is allowed. ../rfc/5465:779
// Only message events are allowed for selected/selected-delayed. ../rfc/5465:796
var haveSelected bool
for _, eg := range n.EventGroups {
switch eg.MailboxSpecifier.Kind {
case mbspecSelected, mbspecSelectedDelayed:
if haveSelected {
xsyntaxErrorf("cannot have multiple selected/selected-delayed mailbox filters")
}
haveSelected = true
// Only events from message-event are allowed with selected mailbox specifiers.
// ../rfc/5465:977
for _, ev := range eg.Events {
if !slices.Contains(messageEventKinds, eventKind(ev.Kind)) {
xsyntaxErrorf("selected/selected-delayed is only allowed with message events, not %s", ev.Kind)
}
}
}
}
// We must apply any changes for delayed select. ../rfc/5465:248
if c.notify != nil {
delayed := c.notify.Delayed
c.notify.Delayed = nil
c.xapplyChangesNotify(delayed, true)
}
if status {
var statuses []string
// Flush new pending changes before we read the current state from the database.
// Don't allow any concurrent changes for a consistent snapshot.
c.account.WithRLock(func() {
select {
case <-c.comm.Pending:
overflow, changes := c.comm.Get()
c.xapplyChanges(overflow, changes, true, true)
default:
}
c.xdbread(func(tx *bstore.Tx) {
// Send STATUS responses for all matching mailboxes. ../rfc/5465:271
q := bstore.QueryTx[store.Mailbox](tx)
q.FilterEqual("Expunged", false)
q.SortAsc("Name")
for mb, err := range q.All() {
xcheckf(err, "list mailboxes for status")
if mb.ID == c.mailboxID {
continue
}
_, _, ok := n.match(c, func() *bstore.Tx { return tx }, mb.ID, mb.Name, eventMessageNew)
if !ok {
continue
}
list := listspace{
bare("MESSAGES"), number(mb.MessageCountIMAP()),
bare("UIDNEXT"), number(mb.UIDNext),
bare("UIDVALIDITY"), number(mb.UIDValidity),
// Unseen is not mentioned for STATUS, but clients are able to parse it due to
// FlagChange, and it will be useful to have.
bare("UNSEEN"), number(mb.MailboxCounts.Unseen),
}
if c.enabled[capCondstore] || c.enabled[capQresync] {
list = append(list, bare("HIGHESTMODSEQ"), number(mb.ModSeq))
}
status := fmt.Sprintf("* STATUS %s %s", mailboxt(mb.Name).pack(c), list.pack(c))
statuses = append(statuses, status)
}
})
})
// Write outside of db transaction and lock.
for _, s := range statuses {
c.xbwritelinef("%s", s)
}
}
// We replace the previous notify config. ../rfc/5465:245
c.notify = &n
// Writing OK will flush any other pending changes for the account according to the
// new filters.
c.ok(tag, cmd)
}