mirror of
https://github.com/mjl-/mox.git
synced 2025-07-19 03:26:37 +03:00
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.
This commit is contained in:
329
imapserver/notify.go
Normal file
329
imapserver/notify.go
Normal file
@ -0,0 +1,329 @@
|
||||
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 = ¬ify{}
|
||||
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)
|
||||
}
|
Reference in New Issue
Block a user