diff --git a/fetch.go b/fetch.go index f146c897..405f6236 100644 --- a/fetch.go +++ b/fetch.go @@ -21,6 +21,7 @@ type FetchOptions struct { ModSeq bool // requires CONDSTORE ChangedSince uint64 // requires CONDSTORE + Vanished bool // requires QRESYNC, only for UID FETCH with ChangedSince } // FetchItemBodyStructure contains FETCH options for the body structure. diff --git a/imapclient/client.go b/imapclient/client.go index 620bce36..26f3c6c3 100644 --- a/imapclient/client.go +++ b/imapclient/client.go @@ -896,6 +896,11 @@ func (c *Client) readResponseData(typ string) error { } case "NOMODSEQ": // ignore + case "NOTIFICATIONOVERFLOW": + // Server has disabled NOTIFY due to overflow (RFC 5465 section 5.8) + if cmd := findPendingCmdByType[*NotifyCommand](c); cmd != nil { + cmd.handleOverflow() + } default: // [SP 1*] if c.dec.SP() { c.dec.DiscardUntilByte(']') @@ -973,6 +978,11 @@ func (c *Client) readResponseData(typ string) error { return c.handleFetch(num) case "EXPUNGE": return c.handleExpunge(num) + case "VANISHED": + if !c.dec.ExpectSP() { + return c.dec.Err() + } + return c.handleVanished() case "SEARCH": return c.handleSearch() case "ESEARCH": @@ -1179,14 +1189,31 @@ type UnilateralDataMailbox struct { // // The handler will be invoked in an arbitrary goroutine. // +// These handlers are important when using the NOTIFY command , as the server +// will send unsolicited STATUS, FETCH, and EXPUNGE responses for mailbox +// events. +// // See Options.UnilateralDataHandler. type UnilateralDataHandler struct { Expunge func(seqNum uint32) Mailbox func(data *UnilateralDataMailbox) Fetch func(msg *FetchMessageData) - // requires ENABLE METADATA or ENABLE SERVER-METADATA + // Requires ENABLE METADATA or ENABLE SERVER-METADATA. Metadata func(mailbox string, entries []string) + + // Called when the server sends an unsolicited STATUS response. + // + // Commonly used with NOTIFY to receive mailbox status updates + // for non-selected mailboxes (RFC 5465). + Status func(data *imap.StatusData) + + // Called when the server sends an untagged VANISHED response. + // + // Requires QRESYNC extension (RFC 4551/7162). The parameter earlier + // indicates whether this response covers earlier expunges (true for + // SELECT QRESYNC responses, false for UID FETCH VANISHED responses). + Vanished func(uids imap.UIDSet, earlier bool) } // command is an interface for IMAP commands. diff --git a/imapclient/client_test.go b/imapclient/client_test.go index 9e5c206f..d82488af 100644 --- a/imapclient/client_test.go +++ b/imapclient/client_test.go @@ -102,6 +102,7 @@ func newMemClientServerPair(t *testing.T) (net.Conn, io.Closer) { Caps: imap.CapSet{ imap.CapIMAP4rev1: {}, imap.CapIMAP4rev2: {}, + imap.CapNotify: {}, }, }) diff --git a/imapclient/enable.go b/imapclient/enable.go index 89576664..20aaa0c9 100644 --- a/imapclient/enable.go +++ b/imapclient/enable.go @@ -14,7 +14,7 @@ func (c *Client) Enable(caps ...imap.Cap) *EnableCommand { // extensions we support here for _, name := range caps { switch name { - case imap.CapIMAP4rev2, imap.CapUTF8Accept, imap.CapMetadata, imap.CapMetadataServer: + case imap.CapIMAP4rev2, imap.CapUTF8Accept, imap.CapMetadata, imap.CapMetadataServer, imap.CapQResync, imap.CapCondStore: // ok default: done := make(chan error) diff --git a/imapclient/fetch.go b/imapclient/fetch.go index f60256fc..82a67d0f 100644 --- a/imapclient/fetch.go +++ b/imapclient/fetch.go @@ -34,7 +34,11 @@ func (c *Client) Fetch(numSet imap.NumSet, options *imap.FetchOptions) *FetchCom enc.SP().NumSet(numSet).SP() writeFetchItems(enc.Encoder, numKind, options) if options.ChangedSince != 0 { - enc.SP().Special('(').Atom("CHANGEDSINCE").SP().ModSeq(options.ChangedSince).Special(')') + enc.SP().Special('(').Atom("CHANGEDSINCE").SP().ModSeq(options.ChangedSince) + if options.Vanished { + enc.SP().Atom("VANISHED") + } + enc.Special(')') } enc.end() return cmd diff --git a/imapclient/notify.go b/imapclient/notify.go new file mode 100644 index 00000000..f13144c8 --- /dev/null +++ b/imapclient/notify.go @@ -0,0 +1,141 @@ +package imapclient + +import ( + "sync/atomic" + + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/internal/imapwire" +) + +// Notify sends a NOTIFY command (RFC 5465). +// +// The NOTIFY command allows clients to request server-push notifications +// for mailbox events like new messages, expunges, flag changes, etc. +// +// When NOTIFY SET is active, the server may send unsolicited responses at any +// time (STATUS, FETCH, EXPUNGE, LIST responses). These unsolicited responses +// are delivered via the UnilateralDataHandler callbacks set in +// imapclient.Options. +// +// When the server sends an untagged OK [NOTIFICATIONOVERFLOW] response, the +// Overflow() channel on the returned NotifyCommand will be closed. This +// indicates the server has disabled all notifications and the client should +// re-issue the NOTIFY command if needed. +// +// This requires support for the NOTIFY extension. +func (c *Client) Notify(options *imap.NotifyOptions) (*NotifyCommand, error) { + cmd := &NotifyCommand{ + options: options, + overflow: make(chan struct{}), + } + enc := c.beginCommand("NOTIFY", cmd) + encodeNotifyOptions(enc.Encoder, options) + enc.end() + + if err := cmd.Wait(); err != nil { + return nil, err + } + + return cmd, nil +} + +// encodeNotifyOptions encodes NOTIFY command options to the encoder. +func encodeNotifyOptions(enc *imapwire.Encoder, options *imap.NotifyOptions) { + if options == nil || len(options.Items) == 0 { + // NOTIFY NONE - disable all notifications + enc.SP().Atom("NONE") + } else { + // NOTIFY SET + enc.SP().Atom("SET") + + if options.STATUS { + enc.SP().List(1, func(i int) { + enc.Atom("STATUS") + }) + } + + // Encode each notify item + for _, item := range options.Items { + // Validate the item before encoding + if item.MailboxSpec == "" && len(item.Mailboxes) == 0 { + // Skip invalid items - this shouldn't happen with properly constructed NotifyOptions + continue + } + + enc.SP().List(1, func(i int) { + // Encode mailbox specification + if item.MailboxSpec != "" { + enc.Atom(string(item.MailboxSpec)) + } else if len(item.Mailboxes) > 0 { + if item.Subtree { + enc.Atom("SUBTREE").SP() + } + // Encode mailbox list + enc.List(len(item.Mailboxes), func(j int) { + enc.Mailbox(item.Mailboxes[j]) + }) + } + + // Encode events + if len(item.Events) > 0 { + enc.SP().List(len(item.Events), func(j int) { + enc.Atom(string(item.Events[j])) + }) + } + }) + } + } +} + +// NotifyNone sends a NOTIFY NONE command to disable all notifications. +func (c *Client) NotifyNone() error { + _, err := c.Notify(nil) + return err +} + +// NotifyCommand is a NOTIFY command. +// +// When NOTIFY SET is active (options != nil), the server may send unsolicited +// responses at any time. These responses are delivered via UnilateralDataHandler +// (see Options.UnilateralDataHandler). +// +// The Overflow() channel can be monitored to detect when the server sends an +// untagged OK [NOTIFICATIONOVERFLOW] response, indicating that notifications +// shall no longer be delivered. +type NotifyCommand struct { + commandBase + + options *imap.NotifyOptions + overflow chan struct{} + closed atomic.Bool +} + +// Wait blocks until the NOTIFY command has completed. +func (cmd *NotifyCommand) Wait() error { + return cmd.wait() +} + +// Overflow returns a channel that is closed when the server sends a +// NOTIFICATIONOVERFLOW response code. This indicates the server has disabled +// notifications and the client should re-issue the NOTIFY command if needed. +// +// The channel is nil if NOTIFY NONE was sent (no notifications active). +func (cmd *NotifyCommand) Overflow() <-chan struct{} { + if cmd.options == nil || len(cmd.options.Items) == 0 { + return nil + } + return cmd.overflow +} + +// Close disables the NOTIFY monitoring by calling it an internal close. +// This is called internally when NOTIFICATIONOVERFLOW is received. +func (cmd *NotifyCommand) close() { + if cmd.closed.Swap(true) { + return + } + close(cmd.overflow) +} + +func (cmd *NotifyCommand) handleOverflow() { + cmd.close() +} diff --git a/imapclient/notify_encode_test.go b/imapclient/notify_encode_test.go new file mode 100644 index 00000000..87efbf52 --- /dev/null +++ b/imapclient/notify_encode_test.go @@ -0,0 +1,346 @@ +package imapclient + +import ( + "bufio" + "bytes" + "testing" + + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/internal/imapwire" +) + +func encodeToString(options *imap.NotifyOptions) string { + buf := &bytes.Buffer{} + bw := bufio.NewWriter(buf) + enc := imapwire.NewEncoder(bw, imapwire.ConnSideClient) + + encodeNotifyOptions(enc, options) + + enc.CRLF() + bw.Flush() + + return buf.String() +} + +func TestEncodeNotifyOptions_None(t *testing.T) { + result := encodeToString(nil) + expected := " NONE\r\n" + if result != expected { + t.Errorf("Expected %q, got %q", expected, result) + } +} + +func TestEncodeNotifyOptions_EmptyItems(t *testing.T) { + options := &imap.NotifyOptions{ + Items: []imap.NotifyItem{}, + } + result := encodeToString(options) + expected := " NONE\r\n" + if result != expected { + t.Errorf("Expected %q, got %q", expected, result) + } +} + +func TestEncodeNotifyOptions_Selected(t *testing.T) { + options := &imap.NotifyOptions{ + Items: []imap.NotifyItem{ + { + MailboxSpec: imap.NotifyMailboxSpecSelected, + Events: []imap.NotifyEvent{ + imap.NotifyEventMessageNew, + imap.NotifyEventMessageExpunge, + }, + }, + }, + } + result := encodeToString(options) + expected := " SET (SELECTED (MessageNew MessageExpunge))\r\n" + if result != expected { + t.Errorf("Expected %q, got %q", expected, result) + } +} + +func TestEncodeNotifyOptions_SelectedDelayed(t *testing.T) { + options := &imap.NotifyOptions{ + Items: []imap.NotifyItem{ + { + MailboxSpec: imap.NotifyMailboxSpecSelectedDelayed, + Events: []imap.NotifyEvent{ + imap.NotifyEventMessageNew, + imap.NotifyEventMessageExpunge, + }, + }, + }, + } + result := encodeToString(options) + expected := " SET (SELECTED-DELAYED (MessageNew MessageExpunge))\r\n" + if result != expected { + t.Errorf("Expected %q, got %q", expected, result) + } +} + +func TestEncodeNotifyOptions_Personal(t *testing.T) { + options := &imap.NotifyOptions{ + Items: []imap.NotifyItem{ + { + MailboxSpec: imap.NotifyMailboxSpecPersonal, + Events: []imap.NotifyEvent{ + imap.NotifyEventMailboxName, + imap.NotifyEventSubscriptionChange, + }, + }, + }, + } + result := encodeToString(options) + expected := " SET (PERSONAL (MailboxName SubscriptionChange))\r\n" + if result != expected { + t.Errorf("Expected %q, got %q", expected, result) + } +} + +func TestEncodeNotifyOptions_Inboxes(t *testing.T) { + options := &imap.NotifyOptions{ + Items: []imap.NotifyItem{ + { + MailboxSpec: imap.NotifyMailboxSpecInboxes, + Events: []imap.NotifyEvent{ + imap.NotifyEventMessageNew, + }, + }, + }, + } + result := encodeToString(options) + expected := " SET (INBOXES (MessageNew))\r\n" + if result != expected { + t.Errorf("Expected %q, got %q", expected, result) + } +} + +func TestEncodeNotifyOptions_Subscribed(t *testing.T) { + options := &imap.NotifyOptions{ + Items: []imap.NotifyItem{ + { + MailboxSpec: imap.NotifyMailboxSpecSubscribed, + Events: []imap.NotifyEvent{ + imap.NotifyEventMessageNew, + imap.NotifyEventMailboxName, + }, + }, + }, + } + result := encodeToString(options) + expected := " SET (SUBSCRIBED (MessageNew MailboxName))\r\n" + if result != expected { + t.Errorf("Expected %q, got %q", expected, result) + } +} + +func TestEncodeNotifyOptions_Subtree(t *testing.T) { + options := &imap.NotifyOptions{ + Items: []imap.NotifyItem{ + { + Subtree: true, + Mailboxes: []string{"INBOX", "Lists"}, + Events: []imap.NotifyEvent{ + imap.NotifyEventMessageNew, + }, + }, + }, + } + result := encodeToString(options) + expected := " SET (SUBTREE (INBOX \"Lists\") (MessageNew))\r\n" + if result != expected { + t.Errorf("Expected %q, got %q", expected, result) + } +} + +func TestEncodeNotifyOptions_MailboxList(t *testing.T) { + options := &imap.NotifyOptions{ + Items: []imap.NotifyItem{ + { + Mailboxes: []string{"INBOX", "Sent"}, + Events: []imap.NotifyEvent{ + imap.NotifyEventMessageNew, + imap.NotifyEventMessageExpunge, + imap.NotifyEventFlagChange, + }, + }, + }, + } + result := encodeToString(options) + expected := " SET ((INBOX \"Sent\") (MessageNew MessageExpunge FlagChange))\r\n" + if result != expected { + t.Errorf("Expected %q, got %q", expected, result) + } +} + +func TestEncodeNotifyOptions_StatusIndicator(t *testing.T) { + options := &imap.NotifyOptions{ + STATUS: true, + Items: []imap.NotifyItem{ + { + MailboxSpec: imap.NotifyMailboxSpecSelected, + Events: []imap.NotifyEvent{ + imap.NotifyEventMessageNew, + imap.NotifyEventMessageExpunge, + }, + }, + }, + } + result := encodeToString(options) + expected := " SET (STATUS) (SELECTED (MessageNew MessageExpunge))\r\n" + if result != expected { + t.Errorf("Expected %q, got %q", expected, result) + } +} + +func TestEncodeNotifyOptions_MultipleItems(t *testing.T) { + options := &imap.NotifyOptions{ + Items: []imap.NotifyItem{ + { + MailboxSpec: imap.NotifyMailboxSpecSelected, + Events: []imap.NotifyEvent{ + imap.NotifyEventMessageNew, + imap.NotifyEventMessageExpunge, + }, + }, + { + MailboxSpec: imap.NotifyMailboxSpecPersonal, + Events: []imap.NotifyEvent{ + imap.NotifyEventMailboxName, + imap.NotifyEventSubscriptionChange, + }, + }, + { + MailboxSpec: imap.NotifyMailboxSpecInboxes, + Events: []imap.NotifyEvent{ + imap.NotifyEventMessageNew, + }, + }, + }, + } + result := encodeToString(options) + expected := " SET (SELECTED (MessageNew MessageExpunge)) (PERSONAL (MailboxName SubscriptionChange)) (INBOXES (MessageNew))\r\n" + if result != expected { + t.Errorf("Expected %q, got %q", expected, result) + } +} + +func TestEncodeNotifyOptions_AllEvents(t *testing.T) { + options := &imap.NotifyOptions{ + Items: []imap.NotifyItem{ + { + MailboxSpec: imap.NotifyMailboxSpecSelected, + Events: []imap.NotifyEvent{ + imap.NotifyEventMessageNew, + imap.NotifyEventMessageExpunge, + imap.NotifyEventFlagChange, + imap.NotifyEventAnnotationChange, + imap.NotifyEventMailboxName, + imap.NotifyEventSubscriptionChange, + imap.NotifyEventMailboxMetadataChange, + imap.NotifyEventServerMetadataChange, + }, + }, + }, + } + result := encodeToString(options) + expected := " SET (SELECTED (MessageNew MessageExpunge FlagChange AnnotationChange MailboxName SubscriptionChange MailboxMetadataChange ServerMetadataChange))\r\n" + if result != expected { + t.Errorf("Expected %q, got %q", expected, result) + } +} + +func TestEncodeNotifyOptions_NoEvents(t *testing.T) { + options := &imap.NotifyOptions{ + Items: []imap.NotifyItem{ + { + MailboxSpec: imap.NotifyMailboxSpecSelected, + Events: []imap.NotifyEvent{}, + }, + }, + } + result := encodeToString(options) + expected := " SET (SELECTED)\r\n" + if result != expected { + t.Errorf("Expected %q, got %q", expected, result) + } +} + +func TestEncodeNotifyOptions_InvalidItemSkipped(t *testing.T) { + // Items with neither MailboxSpec nor Mailboxes should be skipped + // XXX: should we warn about these? + options := &imap.NotifyOptions{ + Items: []imap.NotifyItem{ + { + // Invalid: no mailbox spec or mailboxes + Events: []imap.NotifyEvent{ + imap.NotifyEventMessageNew, + }, + }, + { + MailboxSpec: imap.NotifyMailboxSpecSelected, + Events: []imap.NotifyEvent{ + imap.NotifyEventMessageNew, + }, + }, + }, + } + result := encodeToString(options) + expected := " SET (SELECTED (MessageNew))\r\n" + if result != expected { + t.Errorf("Expected %q, got %q", expected, result) + } +} + +func TestEncodeNotifyOptions_ComplexMixed(t *testing.T) { + options := &imap.NotifyOptions{ + STATUS: true, + Items: []imap.NotifyItem{ + { + MailboxSpec: imap.NotifyMailboxSpecSelected, + Events: []imap.NotifyEvent{ + imap.NotifyEventMessageNew, + imap.NotifyEventMessageExpunge, + }, + }, + { + Subtree: true, + Mailboxes: []string{"INBOX"}, + Events: []imap.NotifyEvent{ + imap.NotifyEventMessageNew, + }, + }, + { + Mailboxes: []string{"Drafts", "Sent"}, + Events: []imap.NotifyEvent{ + imap.NotifyEventFlagChange, + }, + }, + }, + } + result := encodeToString(options) + expected := " SET (STATUS) (SELECTED (MessageNew MessageExpunge)) (SUBTREE (INBOX) (MessageNew)) ((\"Drafts\" \"Sent\") (FlagChange))\r\n" + if result != expected { + t.Errorf("Expected %q, got %q", expected, result) + } +} + +func TestEncodeNotifyOptions_MailboxWithSpecialChars(t *testing.T) { + // Test mailbox names that require quoting + options := &imap.NotifyOptions{ + Items: []imap.NotifyItem{ + { + Mailboxes: []string{"INBOX", "Foo Bar", "Test&Mailbox"}, + Events: []imap.NotifyEvent{ + imap.NotifyEventMessageNew, + }, + }, + }, + } + result := encodeToString(options) + expected := " SET ((INBOX \"Foo Bar\" \"Test&-Mailbox\") (MessageNew))\r\n" + if result != expected { + t.Errorf("Expected %q, got %q", expected, result) + } +} diff --git a/imapclient/notify_test.go b/imapclient/notify_test.go new file mode 100644 index 00000000..c3b84d4d --- /dev/null +++ b/imapclient/notify_test.go @@ -0,0 +1,112 @@ +package imapclient_test + +import ( + "testing" + + "github.com/emersion/go-imap/v2" +) + +func TestClient_Notify(t *testing.T) { + client, server := newClientServerPair(t, imap.ConnStateAuthenticated) + defer client.Close() + defer server.Close() + + // Test NOTIFY with SELECTED mailbox + options := &imap.NotifyOptions{ + Items: []imap.NotifyItem{ + { + MailboxSpec: imap.NotifyMailboxSpecSelected, + Events: []imap.NotifyEvent{ + imap.NotifyEventMessageNew, + imap.NotifyEventMessageExpunge, + }, + }, + }, + } + + // Note: The test server's stub implementation refuses NOTIFY with + // NO [NOTIFICATIONOVERFLOW], which is RFC-compliant per RFC 5465 Section 3.1. + _, err := client.Notify(options) + if err == nil { + t.Fatal("Expected error from stub implementation") + } + + imapErr, ok := err.(*imap.Error) + if !ok { + t.Fatalf("Expected *imap.Error, got %T", err) + } + if imapErr.Type != imap.StatusResponseTypeNo { + t.Errorf("Expected NO response, got %v", imapErr.Type) + } + if imapErr.Code != imap.ResponseCodeNotificationOverflow { + t.Errorf("Expected NOTIFICATIONOVERFLOW code, got %v", imapErr.Code) + } +} + +func TestClient_NotifyNone(t *testing.T) { + client, server := newClientServerPair(t, imap.ConnStateAuthenticated) + defer client.Close() + defer server.Close() + + // Note: The test server's stub implementation refuses NOTIFY with + // NO [NOTIFICATIONOVERFLOW] + err := client.NotifyNone() + if err == nil { + t.Fatal("Expected error from stub implementation") + } + imapErr, ok := err.(*imap.Error) + if !ok { + t.Fatalf("Expected *imap.Error, got %T", err) + } + if imapErr.Type != imap.StatusResponseTypeNo { + t.Errorf("Expected NO response, got %v", imapErr.Type) + } + if imapErr.Code != imap.ResponseCodeNotificationOverflow { + t.Errorf("Expected NOTIFICATIONOVERFLOW code, got %v", imapErr.Code) + } +} + +func TestClient_NotifyMultiple(t *testing.T) { + client, server := newClientServerPair(t, imap.ConnStateAuthenticated) + defer client.Close() + defer server.Close() + + // Test NOTIFY with multiple items + options := &imap.NotifyOptions{ + STATUS: true, + Items: []imap.NotifyItem{ + { + MailboxSpec: imap.NotifyMailboxSpecSelected, + Events: []imap.NotifyEvent{ + imap.NotifyEventMessageNew, + imap.NotifyEventMessageExpunge, + }, + }, + { + MailboxSpec: imap.NotifyMailboxSpecPersonal, + Events: []imap.NotifyEvent{ + imap.NotifyEventMailboxName, + imap.NotifyEventSubscriptionChange, + }, + }, + }, + } + + // Note: The test server's stub implementation refuses NOTIFY with + // NO [NOTIFICATIONOVERFLOW] + _, err := client.Notify(options) + if err == nil { + t.Fatal("Expected error from stub implementation") + } + + imapErr, ok := err.(*imap.Error) + if !ok { + t.Fatalf("Expected *imap.Error, got %T", err) + } + if imapErr.Type != imap.StatusResponseTypeNo { + t.Errorf("Expected NO response, got %v", imapErr.Type) + } + if imapErr.Code != imap.ResponseCodeNotificationOverflow { + t.Errorf("Expected NOTIFICATIONOVERFLOW code, got %v", imapErr.Code) + } +} diff --git a/imapclient/select.go b/imapclient/select.go index c325ff04..1b18b9de 100644 --- a/imapclient/select.go +++ b/imapclient/select.go @@ -17,8 +17,25 @@ func (c *Client) Select(mailbox string, options *imap.SelectOptions) *SelectComm cmd := &SelectCommand{mailbox: mailbox} enc := c.beginCommand(cmdName, cmd) enc.SP().Mailbox(mailbox) - if options != nil && options.CondStore { - enc.SP().Special('(').Atom("CONDSTORE").Special(')') + if options != nil { + if options.QResync != nil { + // QRESYNC implies CONDSTORE + enc.SP().Special('(').Atom("QRESYNC").SP() + enc.Special('(') + enc.Number(options.QResync.UIDValidity).SP().ModSeq(options.QResync.ModSeq) + if options.QResync.KnownUIDs != nil { + enc.SP().NumSet(*options.QResync.KnownUIDs) + if options.QResync.SeqMatchData != nil { + enc.SP().Special('(') + enc.NumSet(options.QResync.SeqMatchData.KnownSeqSet).SP() + enc.NumSet(options.QResync.SeqMatchData.KnownUIDSet) + enc.Special(')') + } + } + enc.Special(')').Special(')') + } else if options.CondStore { + enc.SP().Special('(').Atom("CONDSTORE").Special(')') + } } enc.end() return cmd diff --git a/imapclient/status.go b/imapclient/status.go index 973345bc..b97b5cb9 100644 --- a/imapclient/status.go +++ b/imapclient/status.go @@ -75,6 +75,11 @@ func (c *Client) handleStatus() error { cmd.pendingData.Status = data cmd.mailboxes <- cmd.pendingData cmd.pendingData = nil + default: + // Unsolicited STATUS response (e.g., from NOTIFY) + if handler := c.options.unilateralDataHandler().Status; handler != nil { + handler(data) + } } return nil diff --git a/imapclient/vanished.go b/imapclient/vanished.go new file mode 100644 index 00000000..e5e61817 --- /dev/null +++ b/imapclient/vanished.go @@ -0,0 +1,34 @@ +package imapclient + +import ( + "github.com/emersion/go-imap/v2" +) + +func (c *Client) handleVanished() error { + var earlier bool + if c.dec.Special('(') { + var atom string + if !c.dec.ExpectAtom(&atom) || atom != "EARLIER" || !c.dec.ExpectSpecial(')') { + return c.dec.Err() + } + earlier = true + if !c.dec.ExpectSP() { + return c.dec.Err() + } + } + + var uids imap.UIDSet + if !c.dec.ExpectUIDSet(&uids) { + return c.dec.Err() + } + + // Check if this is part of a SELECT command response + cmd := findPendingCmdByType[*SelectCommand](c) + if cmd != nil { + cmd.data.VanishedUIDs = uids + } else if handler := c.options.unilateralDataHandler().Vanished; handler != nil { + handler(uids, earlier) + } + + return nil +} diff --git a/imapserver/capability.go b/imapserver/capability.go index 37da104b..8f0b52d9 100644 --- a/imapserver/capability.go +++ b/imapserver/capability.go @@ -93,6 +93,7 @@ func (c *Conn) availableCaps() []imap.Cap { imap.CapCreateSpecialUse, imap.CapLiteralPlus, imap.CapUnauthenticate, + imap.CapNotify, }) if appendLimitSession, ok := c.session.(SessionAppendLimit); ok { diff --git a/imapserver/conn.go b/imapserver/conn.go index 291f37ec..e355180b 100644 --- a/imapserver/conn.go +++ b/imapserver/conn.go @@ -153,6 +153,9 @@ func (c *Conn) serve() { if _, ok := c.session.(SessionUnauthenticate); !ok && caps.Has(imap.CapUnauthenticate) { panic("imapserver: server advertises UNAUTHENTICATE but session doesn't support it") } + if _, ok := c.session.(SessionNotify); !ok && caps.Has(imap.CapNotify) { + panic("imapserver: server advertises NOTIFY but session doesn't support it") + } c.state = imap.ConnStateNotAuthenticated statusType := imap.StatusResponseTypeOK @@ -253,6 +256,8 @@ func (c *Conn) readCommand(dec *imapwire.Decoder) error { err = c.handleNamespace(dec) case "IDLE": err = c.handleIdle(dec) + case "NOTIFY": + err = c.handleNotify(dec) case "SELECT", "EXAMINE": err = c.handleSelect(tag, dec, name == "EXAMINE") sendOK = false diff --git a/imapserver/imapmemserver/session.go b/imapserver/imapmemserver/session.go index 70e9d2f8..a64eb9a7 100644 --- a/imapserver/imapmemserver/session.go +++ b/imapserver/imapmemserver/session.go @@ -138,3 +138,20 @@ func (sess *UserSession) Idle(w *imapserver.UpdateWriter, stop <-chan struct{}) } return sess.mailbox.Idle(w, stop) } + +func (sess *UserSession) Notify(w *imapserver.UpdateWriter, options *imap.NotifyOptions) error { + // Refuse the NOTIFY request with NO [NOTIFICATIONOVERFLOW] to indicate + // the server is unable/unwilling to deliver notifications. + // + // Per RFC 5465 Section 3.1 (lines 327-330): + // "If the notification would be prohibitively expensive for the server + // (e.g., "notify me of all flag changes in all mailboxes"), the server + // MAY refuse the command with a tagged NO [NOTIFICATIONOVERFLOW] response." + // + // This is a simple RFC-compliant stub. Implementing full NOTIFY support is pending. + return &imap.Error{ + Type: imap.StatusResponseTypeNo, + Code: imap.ResponseCodeNotificationOverflow, + Text: "Request not implemented", + } +} diff --git a/imapserver/notify.go b/imapserver/notify.go new file mode 100644 index 00000000..b6335f3d --- /dev/null +++ b/imapserver/notify.go @@ -0,0 +1,248 @@ +package imapserver + +import ( + "strings" + + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/internal/imapwire" +) + +func (c *Conn) handleNotify(dec *imapwire.Decoder) error { + options, err := readNotifyOptions(dec) + if err != nil { + return err + } + + if err := c.checkState(imap.ConnStateAuthenticated); err != nil { + return err + } + + session, ok := c.session.(SessionNotify) + if !ok { + return &imap.Error{ + Type: imap.StatusResponseTypeBad, + Text: "NOTIFY not supported", + } + } + + w := &UpdateWriter{conn: c, allowExpunge: true} + return session.Notify(w, options) +} + +// readNotifyOptions parses the NOTIFY command arguments from the decoder. +// Returns nil options for NOTIFY NONE, or populated options for NOTIFY SET. +func readNotifyOptions(dec *imapwire.Decoder) (*imap.NotifyOptions, error) { + if !dec.ExpectSP() { + return nil, dec.Err() + } + + // Check for NONE or SET + var atom string + if !dec.ExpectAtom(&atom) { + return nil, dec.Err() + } + + atom = strings.ToUpper(atom) + if atom == "NONE" { + // NOTIFY NONE - disable all notifications + if !dec.ExpectCRLF() { + return nil, dec.Err() + } + return nil, nil + } else if atom == "SET" { + // NOTIFY SET - set notifications + options := &imap.NotifyOptions{} + + // Parse items until we hit CRLF + for { + // We need at least a space before each item + if !dec.SP() { + return nil, &imap.Error{ + Type: imap.StatusResponseTypeBad, + Text: "Expected SP after SET or between items", + } + } + + // Parse a list + isList, err := dec.List(func() error { + // First element in the list: check if it's STATUS or a mailbox spec + var firstAtom string + if dec.Atom(&firstAtom) { + firstAtom = strings.ToUpper(firstAtom) + if firstAtom == "STATUS" { + // This is the STATUS parameter + options.STATUS = true + return nil + } + + // It's a mailbox spec or SUBTREE, parse as a notify item + item, err := parseNotifyItemFromAtom(dec, firstAtom) + if err != nil { + return err + } + options.Items = append(options.Items, *item) + return nil + } + + // Not an atom, try to parse as mailbox list + item, err := parseNotifyItemMailboxList(dec) + if err != nil { + return err + } + options.Items = append(options.Items, *item) + return nil + }) + if err != nil { + return nil, err + } + if !isList { + return nil, &imap.Error{ + Type: imap.StatusResponseTypeBad, + Text: "Expected list", + } + } + + // Check if we're done (CRLF) + if dec.CRLF() { + break + } + } + + if len(options.Items) == 0 && !options.STATUS { + return nil, &imap.Error{ + Type: imap.StatusResponseTypeBad, + Text: "NOTIFY SET requires at least one mailbox specification", + } + } + + return options, nil + } else { + return nil, &imap.Error{ + Type: imap.StatusResponseTypeBad, + Text: "Expected NONE or SET", + } + } +} + +// parseNotifyItemFromAtom parses a notify item that starts with an atom (mailbox spec or SUBTREE) +func parseNotifyItemFromAtom(dec *imapwire.Decoder, firstAtom string) (*imap.NotifyItem, error) { + item := &imap.NotifyItem{} + + switch firstAtom { + case "SELECTED", "SELECTED-DELAYED", "PERSONAL", "INBOXES", "SUBSCRIBED": + item.MailboxSpec = imap.NotifyMailboxSpec(firstAtom) + + // Check for optional events list + if dec.SP() { + err := dec.ExpectList(func() error { + return readNotifyEvent(dec, item) + }) + if err != nil { + return nil, err + } + } + return item, nil + + case "SUBTREE": + // SUBTREE mailbox-list [event-list] + item.Subtree = true + + if !dec.ExpectSP() { + return nil, dec.Err() + } + + // Read mailbox list + err := dec.ExpectList(func() error { + var mailbox string + if !dec.ExpectMailbox(&mailbox) { + return dec.Err() + } + item.Mailboxes = append(item.Mailboxes, mailbox) + return nil + }) + if err != nil { + return nil, err + } + + // Check for optional events list + if dec.SP() { + err := dec.ExpectList(func() error { + return readNotifyEvent(dec, item) + }) + if err != nil { + return nil, err + } + } + return item, nil + + default: + return nil, &imap.Error{ + Type: imap.StatusResponseTypeBad, + Text: "Invalid mailbox specification: " + firstAtom, + } + } +} + +// parseNotifyItemMailboxList parses a notify item that starts with a mailbox list +func parseNotifyItemMailboxList(dec *imapwire.Decoder) (*imap.NotifyItem, error) { + item := &imap.NotifyItem{} + + // We're already inside a list, so we need to see if the first element is a mailbox or a list + // The decoder is positioned at the start of the list content + // Try to parse as a nested mailbox list + isList, err := dec.List(func() error { + var mailbox string + if !dec.ExpectMailbox(&mailbox) { + return dec.Err() + } + item.Mailboxes = append(item.Mailboxes, mailbox) + return nil + }) + if err != nil { + return nil, err + } + if !isList { + return nil, &imap.Error{ + Type: imap.StatusResponseTypeBad, + Text: "Expected mailbox list", + } + } + + // Check for optional events list + if dec.SP() { + err := dec.ExpectList(func() error { + return readNotifyEvent(dec, item) + }) + if err != nil { + return nil, err + } + } + + return item, nil +} + +func readNotifyEvent(dec *imapwire.Decoder, item *imap.NotifyItem) error { + var event string + if !dec.ExpectAtom(&event) { + return dec.Err() + } + + // Validate event name + switch imap.NotifyEvent(event) { + case imap.NotifyEventMessageNew, + imap.NotifyEventMessageExpunge, + imap.NotifyEventFlagChange, + imap.NotifyEventAnnotationChange, + imap.NotifyEventMailboxName, + imap.NotifyEventSubscriptionChange, + imap.NotifyEventMailboxMetadataChange, + imap.NotifyEventServerMetadataChange: + item.Events = append(item.Events, imap.NotifyEvent(event)) + return nil + default: + return &imap.Error{ + Type: imap.StatusResponseTypeBad, + Text: "Unknown NOTIFY event: " + event, + } + } +} diff --git a/imapserver/notify_parse_test.go b/imapserver/notify_parse_test.go new file mode 100644 index 00000000..ad1074b9 --- /dev/null +++ b/imapserver/notify_parse_test.go @@ -0,0 +1,446 @@ +package imapserver + +import ( + "bufio" + "strings" + "testing" + + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/internal/imapwire" +) + +// Helper to create a decoder from a command string (without tag and command name) +func newTestDecoder(s string) *imapwire.Decoder { + br := bufio.NewReader(strings.NewReader(s)) + return imapwire.NewDecoder(br, imapwire.ConnSideServer) +} + +func TestReadNotifyOptions_None(t *testing.T) { + dec := newTestDecoder(" NONE\r\n") + options, err := readNotifyOptions(dec) + if err != nil { + t.Fatalf("readNotifyOptions() error = %v", err) + } + if options != nil { + t.Errorf("Expected nil options for NOTIFY NONE, got %+v", options) + } +} + +func TestReadNotifyOptions_Selected(t *testing.T) { + dec := newTestDecoder(" SET (SELECTED (MessageNew MessageExpunge))\r\n") + options, err := readNotifyOptions(dec) + if err != nil { + t.Fatalf("readNotifyOptions() error = %v", err) + } + if options == nil { + t.Fatal("Expected non-nil options") + } + if len(options.Items) != 1 { + t.Fatalf("Expected 1 item, got %d", len(options.Items)) + } + item := options.Items[0] + if item.MailboxSpec != imap.NotifyMailboxSpecSelected { + t.Errorf("Expected SELECTED, got %v", item.MailboxSpec) + } + if len(item.Events) != 2 { + t.Fatalf("Expected 2 events, got %d", len(item.Events)) + } + if item.Events[0] != imap.NotifyEventMessageNew { + t.Errorf("Expected MessageNew, got %v", item.Events[0]) + } + if item.Events[1] != imap.NotifyEventMessageExpunge { + t.Errorf("Expected MessageExpunge, got %v", item.Events[1]) + } +} + +func TestReadNotifyOptions_SelectedDelayed(t *testing.T) { + dec := newTestDecoder(" SET (SELECTED-DELAYED (MessageNew MessageExpunge))\r\n") + options, err := readNotifyOptions(dec) + if err != nil { + t.Fatalf("readNotifyOptions() error = %v", err) + } + if options == nil { + t.Fatal("Expected non-nil options") + } + if len(options.Items) != 1 { + t.Fatalf("Expected 1 item, got %d", len(options.Items)) + } + if options.Items[0].MailboxSpec != imap.NotifyMailboxSpecSelectedDelayed { + t.Errorf("Expected SELECTED-DELAYED, got %v", options.Items[0].MailboxSpec) + } +} + +func TestReadNotifyOptions_Personal(t *testing.T) { + dec := newTestDecoder(" SET (PERSONAL (MailboxName SubscriptionChange))\r\n") + options, err := readNotifyOptions(dec) + if err != nil { + t.Fatalf("readNotifyOptions() error = %v", err) + } + if options == nil { + t.Fatal("Expected non-nil options") + } + if len(options.Items) != 1 { + t.Fatalf("Expected 1 item, got %d", len(options.Items)) + } + item := options.Items[0] + if item.MailboxSpec != imap.NotifyMailboxSpecPersonal { + t.Errorf("Expected PERSONAL, got %v", item.MailboxSpec) + } + if len(item.Events) != 2 { + t.Fatalf("Expected 2 events, got %d", len(item.Events)) + } +} + +func TestReadNotifyOptions_Inboxes(t *testing.T) { + dec := newTestDecoder(" SET (INBOXES (MessageNew))\r\n") + options, err := readNotifyOptions(dec) + if err != nil { + t.Fatalf("readNotifyOptions() error = %v", err) + } + if options == nil { + t.Fatal("Expected non-nil options") + } + if len(options.Items) != 1 { + t.Fatalf("Expected 1 item, got %d", len(options.Items)) + } + if options.Items[0].MailboxSpec != imap.NotifyMailboxSpecInboxes { + t.Errorf("Expected INBOXES, got %v", options.Items[0].MailboxSpec) + } +} + +func TestReadNotifyOptions_Subscribed(t *testing.T) { + dec := newTestDecoder(" SET (SUBSCRIBED (MessageNew MailboxName))\r\n") + options, err := readNotifyOptions(dec) + if err != nil { + t.Fatalf("readNotifyOptions() error = %v", err) + } + if options == nil { + t.Fatal("Expected non-nil options") + } + if len(options.Items) != 1 { + t.Fatalf("Expected 1 item, got %d", len(options.Items)) + } + if options.Items[0].MailboxSpec != imap.NotifyMailboxSpecSubscribed { + t.Errorf("Expected SUBSCRIBED, got %v", options.Items[0].MailboxSpec) + } +} + +func TestReadNotifyOptions_Subtree(t *testing.T) { + dec := newTestDecoder(" SET (SUBTREE (INBOX Lists) (MessageNew))\r\n") + options, err := readNotifyOptions(dec) + if err != nil { + t.Fatalf("readNotifyOptions() error = %v", err) + } + if options == nil { + t.Fatal("Expected non-nil options") + } + if len(options.Items) != 1 { + t.Fatalf("Expected 1 item, got %d", len(options.Items)) + } + item := options.Items[0] + if !item.Subtree { + t.Error("Expected Subtree=true") + } + if len(item.Mailboxes) != 2 { + t.Fatalf("Expected 2 mailboxes, got %d", len(item.Mailboxes)) + } + if item.Mailboxes[0] != "INBOX" { + t.Errorf("Expected INBOX, got %v", item.Mailboxes[0]) + } + if item.Mailboxes[1] != "Lists" { + t.Errorf("Expected Lists, got %v", item.Mailboxes[1]) + } + if len(item.Events) != 1 { + t.Fatalf("Expected 1 event, got %d", len(item.Events)) + } + if item.Events[0] != imap.NotifyEventMessageNew { + t.Errorf("Expected MessageNew, got %v", item.Events[0]) + } +} + +func TestReadNotifyOptions_MailboxList(t *testing.T) { + dec := newTestDecoder(" SET ((INBOX Sent) (MessageNew MessageExpunge FlagChange))\r\n") + options, err := readNotifyOptions(dec) + if err != nil { + t.Fatalf("readNotifyOptions() error = %v", err) + } + if options == nil { + t.Fatal("Expected non-nil options") + } + if len(options.Items) != 1 { + t.Fatalf("Expected 1 item, got %d", len(options.Items)) + } + item := options.Items[0] + if item.Subtree { + t.Error("Expected Subtree=false") + } + if len(item.Mailboxes) != 2 { + t.Fatalf("Expected 2 mailboxes, got %d", len(item.Mailboxes)) + } + if item.Mailboxes[0] != "INBOX" { + t.Errorf("Expected INBOX, got %v", item.Mailboxes[0]) + } + if item.Mailboxes[1] != "Sent" { + t.Errorf("Expected Sent, got %v", item.Mailboxes[1]) + } + if len(item.Events) != 3 { + t.Fatalf("Expected 3 events, got %d", len(item.Events)) + } +} + +func TestReadNotifyOptions_StatusIndicator(t *testing.T) { + dec := newTestDecoder(" SET (STATUS) (SELECTED (MessageNew MessageExpunge))\r\n") + options, err := readNotifyOptions(dec) + if err != nil { + t.Fatalf("readNotifyOptions() error = %v", err) + } + if options == nil { + t.Fatal("Expected non-nil options") + } + if !options.STATUS { + t.Error("Expected STATUS=true") + } + if len(options.Items) != 1 { + t.Fatalf("Expected 1 item, got %d", len(options.Items)) + } +} + +func TestReadNotifyOptions_MultipleItems(t *testing.T) { + dec := newTestDecoder(" SET (SELECTED (MessageNew MessageExpunge)) (PERSONAL (MailboxName SubscriptionChange)) (INBOXES (MessageNew))\r\n") + options, err := readNotifyOptions(dec) + if err != nil { + t.Fatalf("readNotifyOptions() error = %v", err) + } + if options == nil { + t.Fatal("Expected non-nil options") + } + if len(options.Items) != 3 { + t.Fatalf("Expected 3 items, got %d", len(options.Items)) + } + if options.Items[0].MailboxSpec != imap.NotifyMailboxSpecSelected { + t.Errorf("Expected SELECTED, got %v", options.Items[0].MailboxSpec) + } + if options.Items[1].MailboxSpec != imap.NotifyMailboxSpecPersonal { + t.Errorf("Expected PERSONAL, got %v", options.Items[1].MailboxSpec) + } + if options.Items[2].MailboxSpec != imap.NotifyMailboxSpecInboxes { + t.Errorf("Expected INBOXES, got %v", options.Items[2].MailboxSpec) + } +} + +func TestReadNotifyOptions_AllEvents(t *testing.T) { + dec := newTestDecoder(" SET (SELECTED (MessageNew MessageExpunge FlagChange AnnotationChange MailboxName SubscriptionChange MailboxMetadataChange ServerMetadataChange))\r\n") + options, err := readNotifyOptions(dec) + if err != nil { + t.Fatalf("readNotifyOptions() error = %v", err) + } + if options == nil { + t.Fatal("Expected non-nil options") + } + if len(options.Items) != 1 { + t.Fatalf("Expected 1 item, got %d", len(options.Items)) + } + if len(options.Items[0].Events) != 8 { + t.Fatalf("Expected 8 events, got %d", len(options.Items[0].Events)) + } + expectedEvents := []imap.NotifyEvent{ + imap.NotifyEventMessageNew, + imap.NotifyEventMessageExpunge, + imap.NotifyEventFlagChange, + imap.NotifyEventAnnotationChange, + imap.NotifyEventMailboxName, + imap.NotifyEventSubscriptionChange, + imap.NotifyEventMailboxMetadataChange, + imap.NotifyEventServerMetadataChange, + } + for i, expected := range expectedEvents { + if options.Items[0].Events[i] != expected { + t.Errorf("Event %d: expected %v, got %v", i, expected, options.Items[0].Events[i]) + } + } +} + +func TestReadNotifyOptions_NoEventsSpecified(t *testing.T) { + // Mailbox specifiers without event list should be valid + dec := newTestDecoder(" SET (SELECTED)\r\n") + options, err := readNotifyOptions(dec) + if err != nil { + t.Fatalf("readNotifyOptions() error = %v", err) + } + if options == nil { + t.Fatal("Expected non-nil options") + } + if len(options.Items) != 1 { + t.Fatalf("Expected 1 item, got %d", len(options.Items)) + } + if len(options.Items[0].Events) != 0 { + t.Errorf("Expected 0 events, got %d", len(options.Items[0].Events)) + } +} + +// Error cases + +func TestReadNotifyOptions_MissingSpace(t *testing.T) { + dec := newTestDecoder("NONE\r\n") + _, err := readNotifyOptions(dec) + if err == nil { + t.Fatal("Expected error for missing space after NOTIFY") + } +} + +func TestReadNotifyOptions_InvalidCommand(t *testing.T) { + dec := newTestDecoder(" INVALID\r\n") + _, err := readNotifyOptions(dec) + if err == nil { + t.Fatal("Expected error for invalid command") + } + imapErr, ok := err.(*imap.Error) + if !ok { + t.Fatalf("Expected *imap.Error, got %T", err) + } + if imapErr.Type != imap.StatusResponseTypeBad { + t.Errorf("Expected BAD response, got %v", imapErr.Type) + } +} + +func TestReadNotifyOptions_SetWithoutItems(t *testing.T) { + dec := newTestDecoder(" SET\r\n") + _, err := readNotifyOptions(dec) + if err == nil { + t.Fatal("Expected error for SET without items") + } + imapErr, ok := err.(*imap.Error) + if !ok { + t.Fatalf("Expected *imap.Error, got %T", err) + } + if imapErr.Type != imap.StatusResponseTypeBad { + t.Errorf("Expected BAD response, got %v", imapErr.Type) + } +} + +func TestReadNotifyOptions_SetWithEmptyList(t *testing.T) { + dec := newTestDecoder(" SET ()\r\n") + _, err := readNotifyOptions(dec) + if err == nil { + t.Fatal("Expected error for SET with empty list") + } +} + +func TestReadNotifyOptions_InvalidMailboxSpec(t *testing.T) { + dec := newTestDecoder(" SET (INVALID (MessageNew))\r\n") + _, err := readNotifyOptions(dec) + if err == nil { + t.Fatal("Expected error for invalid mailbox specifier") + } + imapErr, ok := err.(*imap.Error) + if !ok { + t.Fatalf("Expected *imap.Error, got %T", err) + } + if imapErr.Type != imap.StatusResponseTypeBad { + t.Errorf("Expected BAD response, got %v", imapErr.Type) + } +} + +func TestReadNotifyOptions_InvalidEvent(t *testing.T) { + dec := newTestDecoder(" SET (SELECTED (InvalidEvent))\r\n") + _, err := readNotifyOptions(dec) + if err == nil { + t.Fatal("Expected error for invalid event") + } + imapErr, ok := err.(*imap.Error) + if !ok { + t.Fatalf("Expected *imap.Error, got %T", err) + } + if imapErr.Type != imap.StatusResponseTypeBad { + t.Errorf("Expected BAD response, got %v", imapErr.Type) + } +} + +func TestReadNotifyOptions_SubtreeWithoutMailboxList(t *testing.T) { + dec := newTestDecoder(" SET (SUBTREE)\r\n") + _, err := readNotifyOptions(dec) + if err == nil { + t.Fatal("Expected error for SUBTREE without mailbox list") + } +} + +func TestReadNotifyOptions_MissingCRLF(t *testing.T) { + dec := newTestDecoder(" SET (SELECTED (MessageNew))") + _, err := readNotifyOptions(dec) + if err == nil { + t.Fatal("Expected error for missing CRLF") + } +} + +func TestReadNotifyOptions_StatusOnly(t *testing.T) { + // STATUS alone is technically valid per the parser, though not very useful + // The validation only requires at least one item when STATUS is false + dec := newTestDecoder(" SET (STATUS)\r\n") + options, err := readNotifyOptions(dec) + if err != nil { + t.Fatalf("readNotifyOptions() error = %v", err) + } + if options == nil { + t.Fatal("Expected non-nil options") + } + if !options.STATUS { + t.Error("Expected STATUS=true") + } + if len(options.Items) != 0 { + t.Errorf("Expected 0 items, got %d", len(options.Items)) + } +} + +func TestReadNotifyOptions_CaseInsensitive(t *testing.T) { + // Test that commands and keywords are case-insensitive + dec := newTestDecoder(" set (selected (MessageNew)) (personal (MailboxName))\r\n") + options, err := readNotifyOptions(dec) + if err != nil { + t.Fatalf("readNotifyOptions() error = %v", err) + } + if options == nil { + t.Fatal("Expected non-nil options") + } + if len(options.Items) != 2 { + t.Fatalf("Expected 2 items, got %d", len(options.Items)) + } +} + +func TestReadNotifyOptions_ComplexMixed(t *testing.T) { + // Complex example with STATUS, multiple mailbox specs, and various events + dec := newTestDecoder(" SET (STATUS) (SELECTED (MessageNew MessageExpunge)) (SUBTREE (INBOX) (MessageNew)) ((Drafts Sent) (FlagChange))\r\n") + options, err := readNotifyOptions(dec) + if err != nil { + t.Fatalf("readNotifyOptions() error = %v", err) + } + if options == nil { + t.Fatal("Expected non-nil options") + } + if !options.STATUS { + t.Error("Expected STATUS=true") + } + if len(options.Items) != 3 { + t.Fatalf("Expected 3 items, got %d", len(options.Items)) + } + + // Check first item (SELECTED) + if options.Items[0].MailboxSpec != imap.NotifyMailboxSpecSelected { + t.Errorf("Item 0: Expected SELECTED, got %v", options.Items[0].MailboxSpec) + } + + // Check second item (SUBTREE) + if !options.Items[1].Subtree { + t.Error("Item 1: Expected Subtree=true") + } + if len(options.Items[1].Mailboxes) != 1 || options.Items[1].Mailboxes[0] != "INBOX" { + t.Errorf("Item 1: Expected mailboxes [INBOX], got %v", options.Items[1].Mailboxes) + } + + // Check third item (mailbox list) + if options.Items[2].Subtree { + t.Error("Item 2: Expected Subtree=false") + } + if len(options.Items[2].Mailboxes) != 2 { + t.Errorf("Item 2: Expected 2 mailboxes, got %d", len(options.Items[2].Mailboxes)) + } +} diff --git a/imapserver/notify_test.go b/imapserver/notify_test.go new file mode 100644 index 00000000..e4de774e --- /dev/null +++ b/imapserver/notify_test.go @@ -0,0 +1,276 @@ +package imapserver_test + +import ( + "bufio" + "net" + "strings" + "testing" + + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/imapserver" + "github.com/emersion/go-imap/v2/imapserver/imapmemserver" +) + +func TestServer_Notify(t *testing.T) { + conn, bw, scanner := newTestClient(t, true) + defer conn.Close() + + // Test NOTIFY with SELECTED mailbox spec + // Note: The test implementation refuses NOTIFY with NO [NOTIFICATIONOVERFLOW] + bw.Write([]byte("a1 NOTIFY SET (SELECTED (MessageNew MessageExpunge))\r\n")) + bw.Flush() + + expectNOWithCode(t, scanner, "a1", "NOTIFICATIONOVERFLOW") +} + +func TestServer_NotifyNone(t *testing.T) { + conn, bw, scanner := newTestClient(t, true) + defer conn.Close() + + // Test NOTIFY NONE + // Note: The test implementation refuses NOTIFY with NO [NOTIFICATIONOVERFLOW] + bw.Write([]byte("a1 NOTIFY NONE\r\n")) + bw.Flush() + + expectNOWithCode(t, scanner, "a1", "NOTIFICATIONOVERFLOW") +} + +func TestServer_NotifyMultipleItems(t *testing.T) { + conn, bw, scanner := newTestClient(t, true) + defer conn.Close() + + // Test NOTIFY with multiple items and STATUS + // Note: The test implementation refuses NOTIFY with NO [NOTIFICATIONOVERFLOW] + bw.Write([]byte("a1 NOTIFY SET (STATUS) (SELECTED (MessageNew MessageExpunge)) (PERSONAL (MailboxName SubscriptionChange))\r\n")) + bw.Flush() + + expectNOWithCode(t, scanner, "a1", "NOTIFICATIONOVERFLOW") +} + +func TestServer_NotifySubtree(t *testing.T) { + conn, bw, scanner := newTestClient(t, true) + defer conn.Close() + + // Test NOTIFY with SUBTREE + // Note: The test implementation refuses NOTIFY with NO [NOTIFICATIONOVERFLOW] + bw.Write([]byte("a1 NOTIFY SET (SUBTREE (INBOX) (MessageNew))\r\n")) + bw.Flush() + + expectNOWithCode(t, scanner, "a1", "NOTIFICATIONOVERFLOW") +} + +func TestServer_NotifyMailboxList(t *testing.T) { + conn, bw, scanner := newTestClient(t, true) + defer conn.Close() + + // Test NOTIFY with explicit mailbox list + // Note: The test implementation refuses NOTIFY with NO [NOTIFICATIONOVERFLOW] + bw.Write([]byte("a1 NOTIFY SET ((INBOX) (MessageNew MessageExpunge FlagChange))\r\n")) + bw.Flush() + + expectNOWithCode(t, scanner, "a1", "NOTIFICATIONOVERFLOW") +} + +func TestServer_NotifySelectedDelayed(t *testing.T) { + conn, bw, scanner := newTestClient(t, true) + defer conn.Close() + + // Test NOTIFY with SELECTED-DELAYED + // Note: The test implementation refuses NOTIFY with NO [NOTIFICATIONOVERFLOW] + bw.Write([]byte("a1 NOTIFY SET (SELECTED-DELAYED (MessageNew MessageExpunge))\r\n")) + bw.Flush() + + expectNOWithCode(t, scanner, "a1", "NOTIFICATIONOVERFLOW") +} + +func TestServer_NotifyInboxes(t *testing.T) { + conn, bw, scanner := newTestClient(t, true) + defer conn.Close() + + // Test NOTIFY with INBOXES + // Note: The test implementation refuses NOTIFY with NO [NOTIFICATIONOVERFLOW] + bw.Write([]byte("a1 NOTIFY SET (INBOXES (MessageNew))\r\n")) + bw.Flush() + + expectNOWithCode(t, scanner, "a1", "NOTIFICATIONOVERFLOW") +} + +func TestServer_NotifySubscribed(t *testing.T) { + conn, bw, scanner := newTestClient(t, true) + defer conn.Close() + + // Test NOTIFY with SUBSCRIBED + // Note: The test implementation refuses NOTIFY with NO [NOTIFICATIONOVERFLOW] + bw.Write([]byte("a1 NOTIFY SET (SUBSCRIBED (MessageNew MailboxName))\r\n")) + bw.Flush() + + expectNOWithCode(t, scanner, "a1", "NOTIFICATIONOVERFLOW") +} + +func TestServer_NotifyAllEvents(t *testing.T) { + conn, bw, scanner := newTestClient(t, true) + defer conn.Close() + + // Test NOTIFY with all event types + // Note: The test implementation refuses NOTIFY with NO [NOTIFICATIONOVERFLOW] + bw.Write([]byte("a1 NOTIFY SET (SELECTED (MessageNew MessageExpunge FlagChange AnnotationChange MailboxName SubscriptionChange MailboxMetadataChange ServerMetadataChange))\r\n")) + bw.Flush() + + expectNOWithCode(t, scanner, "a1", "NOTIFICATIONOVERFLOW") +} + +func TestServer_NotifyNotAuthenticated(t *testing.T) { + conn, bw, scanner := newTestClient(t, false) + defer conn.Close() + + // Test NOTIFY before authentication should fail + bw.Write([]byte("a1 NOTIFY NONE\r\n")) + bw.Flush() + + expectBAD(t, scanner, "a1") +} + +func TestServer_NotifyInvalidSyntax(t *testing.T) { + conn, bw, scanner := newTestClient(t, true) + defer conn.Close() + + // Test NOTIFY with invalid syntax (missing SET/NONE) + bw.Write([]byte("a1 NOTIFY (SELECTED (MessageNew))\r\n")) + bw.Flush() + + expectBAD(t, scanner, "a1") +} + +func TestServer_NotifySetNoItems(t *testing.T) { + conn, bw, scanner := newTestClient(t, true) + defer conn.Close() + + // Test NOTIFY SET without any items should fail + bw.Write([]byte("a1 NOTIFY SET\r\n")) + bw.Flush() + + expectBAD(t, scanner, "a1") +} + +func TestServer_NotifyInvalidEvent(t *testing.T) { + conn, bw, scanner := newTestClient(t, true) + defer conn.Close() + + // Test NOTIFY with invalid event name + bw.Write([]byte("a1 NOTIFY SET (SELECTED (InvalidEvent))\r\n")) + bw.Flush() + + expectBAD(t, scanner, "a1") +} + +// Helper functions + +func newTestClient(t *testing.T, authenticate bool) (net.Conn, *bufio.Writer, *bufio.Scanner) { + memServer := imapmemserver.New() + + user := imapmemserver.NewUser("testuser", "testpass") + user.Create("INBOX", nil) + memServer.AddUser(user) + + server := imapserver.New(&imapserver.Options{ + NewSession: func(conn *imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) { + return memServer.NewSession(), nil, nil + }, + InsecureAuth: true, + Caps: imap.CapSet{ + imap.CapIMAP4rev1: {}, + imap.CapNotify: {}, + }, + }) + + ln, err := net.Listen("tcp", "localhost:0") + if err != nil { + t.Fatalf("net.Listen() = %v", err) + } + + go func() { + if err := server.Serve(ln); err != nil { + // Server errors are expected when closing + } + }() + t.Cleanup(func() { + server.Close() + ln.Close() + }) + + conn, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatalf("net.Dial() = %v", err) + } + + reader := bufio.NewReader(conn) + writer := bufio.NewWriter(conn) + scanner := bufio.NewScanner(reader) + + // Read greeting + if !scanner.Scan() { + t.Fatalf("Failed to read greeting: %v", scanner.Err()) + } + greeting := scanner.Text() + if !strings.HasPrefix(greeting, "* OK") { + t.Fatalf("Unexpected greeting: %v", greeting) + } + + if authenticate { + // Login + writer.Write([]byte("a0 LOGIN testuser testpass\r\n")) + writer.Flush() + + expectOK(t, scanner, "a0") + } + + return conn, writer, scanner +} + +func expectOK(t *testing.T, scanner *bufio.Scanner, tag string) { + t.Helper() + if !scanner.Scan() { + t.Fatalf("Failed to read response: %v", scanner.Err()) + } + line := scanner.Text() + expected := tag + " OK" + if !strings.HasPrefix(line, expected) { + t.Fatalf("Expected OK response with tag %v, got: %v", tag, line) + } +} + +func expectNOWithCode(t *testing.T, scanner *bufio.Scanner, tag string, code string) { + t.Helper() + if !scanner.Scan() { + t.Fatalf("Failed to read response: %v", scanner.Err()) + } + line := scanner.Text() + expected := tag + " NO [" + code + "]" + if !strings.HasPrefix(line, expected) { + t.Fatalf("Expected NO [%v] response with tag %v, got: %v", code, tag, line) + } +} + +func expectNO(t *testing.T, scanner *bufio.Scanner, tag string) { + t.Helper() + if !scanner.Scan() { + t.Fatalf("Failed to read response: %v", scanner.Err()) + } + line := scanner.Text() + expected := tag + " NO" + if !strings.HasPrefix(line, expected) { + t.Fatalf("Expected NO response with tag %v, got: %v", tag, line) + } +} + +func expectBAD(t *testing.T, scanner *bufio.Scanner, tag string) { + t.Helper() + if !scanner.Scan() { + t.Fatalf("Failed to read response: %v", scanner.Err()) + } + line := scanner.Text() + expected := tag + " BAD" + if !strings.HasPrefix(line, expected) { + t.Fatalf("Expected BAD response with tag %v, got: %v", tag, line) + } +} diff --git a/imapserver/session.go b/imapserver/session.go index 35b40e8d..a2ea71bf 100644 --- a/imapserver/session.go +++ b/imapserver/session.go @@ -124,3 +124,11 @@ type SessionAppendLimit interface { // this server in an APPEND command. AppendLimit() uint32 } + +// SessionNotify is an IMAP session which supports NOTIFY. +type SessionNotify interface { + Session + + // Authenticated state + Notify(w *UpdateWriter, options *imap.NotifyOptions) error +} diff --git a/notify.go b/notify.go new file mode 100644 index 00000000..90900d0f --- /dev/null +++ b/notify.go @@ -0,0 +1,64 @@ +package imap + +const ( + // ResponseCodeNotificationOverflow is returned when the server cannot + // handle the requested notifications (RFC 5465). + ResponseCodeNotificationOverflow ResponseCode = "NOTIFICATIONOVERFLOW" +) + +// NotifyEvent represents an event type for the NOTIFY command (RFC 5465). +type NotifyEvent string + +const ( + // Message events + NotifyEventFlagChange NotifyEvent = "FlagChange" + NotifyEventAnnotationChange NotifyEvent = "AnnotationChange" + NotifyEventMessageNew NotifyEvent = "MessageNew" + NotifyEventMessageExpunge NotifyEvent = "MessageExpunge" + + // Mailbox events + NotifyEventMailboxName NotifyEvent = "MailboxName" + NotifyEventSubscriptionChange NotifyEvent = "SubscriptionChange" + NotifyEventMailboxMetadataChange NotifyEvent = "MailboxMetadataChange" + NotifyEventServerMetadataChange NotifyEvent = "ServerMetadataChange" +) + +// NotifyMailboxSpec represents a mailbox specifier (rfc5465#section-6) for the NOTIFY command. +type NotifyMailboxSpec string + +const ( + NotifyMailboxSpecSelected NotifyMailboxSpec = "SELECTED" + NotifyMailboxSpecSelectedDelayed NotifyMailboxSpec = "SELECTED-DELAYED" + NotifyMailboxSpecPersonal NotifyMailboxSpec = "PERSONAL" + NotifyMailboxSpecInboxes NotifyMailboxSpec = "INBOXES" + NotifyMailboxSpecSubscribed NotifyMailboxSpec = "SUBSCRIBED" +) + +// NotifyOptions contains options for the NOTIFY command. +// XXX: how does this encode `NOTIFY NONE`? +type NotifyOptions struct { + // STATUS indicates that a STATUS response should be sent for new mailboxes. + // Only valid with Personal, Inboxes, or Subscribed mailbox specs. + STATUS bool + + // Items represents the mailbox and events to monitor. + Items []NotifyItem +} + +// NotifyItem represents a mailbox or mailbox set and its events. +type NotifyItem struct { + // MailboxSpec is a special mailbox specifier (Selected, Personal, etc.) + // If empty, Mailboxes must be non-empty. + MailboxSpec NotifyMailboxSpec + + // Mailboxes is a list of specific mailboxes to monitor. + // Can include wildcards (*). + Mailboxes []string + + // Subtree indicates that all mailboxes under the specified mailboxes + // should be monitored (recursive). + Subtree bool + + // Events is the list of events to monitor for these mailboxes. + Events []NotifyEvent +} diff --git a/select.go b/select.go index f307ff34..a91141d3 100644 --- a/select.go +++ b/select.go @@ -4,6 +4,23 @@ package imap type SelectOptions struct { ReadOnly bool CondStore bool // requires CONDSTORE + + // QRESYNC parameters (requires QRESYNC extension, RFC 5162) + QResync *SelectQResyncOptions +} + +// SelectQResyncOptions contains QRESYNC parameters for SELECT. +type SelectQResyncOptions struct { + UIDValidity uint32 + ModSeq uint64 + KnownUIDs *UIDSet // optional + SeqMatchData *SelectSeqMatchData // optional +} + +// SelectSeqMatchData contains sequence match data for QRESYNC. +type SelectSeqMatchData struct { + KnownSeqSet SeqSet + KnownUIDSet UIDSet } // SelectData is the data returned by a SELECT command. @@ -28,4 +45,8 @@ type SelectData struct { List *ListData // requires IMAP4rev2 HighestModSeq uint64 // requires CONDSTORE + + // UIDs of messages that were expunged. + // Requires QRESYNC extension (RFC 4551/7162). + VanishedUIDs UIDSet // requires QRESYNC }