diff --git a/imapclient/client.go b/imapclient/client.go index 620bce36..75832ce1 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(']') @@ -1179,14 +1184,24 @@ 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) } // command is an interface for IMAP commands. diff --git a/imapclient/connection_test.go b/imapclient/connection_test.go new file mode 100644 index 00000000..17b93071 --- /dev/null +++ b/imapclient/connection_test.go @@ -0,0 +1,246 @@ +package imapclient_test + +import ( + "io" + "net" + "testing" + "time" + + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/imapclient" +) + +type pipeConn struct { + io.Reader + io.Writer + closer io.Closer +} + +func (c pipeConn) Close() error { + return c.closer.Close() +} + +func (c pipeConn) LocalAddr() net.Addr { + return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0} +} + +func (c pipeConn) RemoteAddr() net.Addr { + return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0} +} + +func (c pipeConn) SetDeadline(t time.Time) error { + return nil +} + +func (c pipeConn) SetReadDeadline(t time.Time) error { + return nil +} + +func (c pipeConn) SetWriteDeadline(t time.Time) error { + return nil +} + +var _ net.Conn = pipeConn{} + +// TestNotifyCommand_Overflow_ConnectionFailure tests that the Overflow() channel +// is properly closed when the network connection drops unexpectedly. +func TestNotifyCommand_Overflow_ConnectionFailure(t *testing.T) { + // Create a custom connection pair that we can forcefully close + clientR, serverW := io.Pipe() + serverR, clientW := io.Pipe() + + clientConn := pipeConn{ + Reader: clientR, + Writer: clientW, + closer: clientW, + } + serverConn := pipeConn{ + Reader: serverR, + Writer: serverW, + closer: serverW, + } + + client := imapclient.New(clientConn, nil) + defer client.Close() + + // Hacky server which drops the connection after a NOTIFY command. + go func() { + serverW.Write([]byte("* OK [CAPABILITY IMAP4rev1 NOTIFY] IMAP server ready\r\n")) + + buf := make([]byte, 4096) + for { + n, err := serverR.Read(buf) + if err != nil { + return + } + + cmd := string(buf[:n]) + if n > 0 { + // Extract tag (e.g., "T1" from "T1 NOTIFY ...") + tag := "T1" + for i := 0; i < len(cmd) && cmd[i] != ' '; i++ { + if i+1 < len(cmd) && cmd[i+1] == ' ' { + tag = cmd[:i+1] + } + } + + // Respond based on command + serverW.Write([]byte(tag + " OK Command completed\r\n")) + + // If this was the NOTIFY command, close connection after a delay + if len(cmd) > 6 && cmd[3:9] == "NOTIFY" { + time.Sleep(50 * time.Millisecond) + serverConn.Close() + return + } + } + } + }() + + if err := client.WaitGreeting(); err != nil { + t.Fatalf("WaitGreeting() = %v", err) + } + + // Send NOTIFY command + notifyCmd, err := client.Notify(&imap.NotifyOptions{ + Items: []imap.NotifyItem{ + { + MailboxSpec: imap.NotifyMailboxSpecSelected, + Events: []imap.NotifyEvent{ + imap.NotifyEventMessageNew, + }, + }, + }, + }) + if err != nil { + t.Fatalf("NOTIFY failed immediately: %v", err) + } + + overflowCh := notifyCmd.Overflow() + if overflowCh == nil { + t.Fatal("Overflow() returned nil channel") + } + + select { + case <-overflowCh: + t.Log("Overflow() channel closed after connection failure") + case <-time.After(2 * time.Second): + t.Fatal("Overflow() channel did not close after connection failure") + } + + if err := client.Noop().Wait(); err == nil { + t.Error("Expected error after connection failure, got nil") + } +} + +// TestCommand_Wait_ConnectionFailure tests that Wait() returns an error instead +// of hanging when the network connection drops unexpectedly. +func TestCommand_Wait_ConnectionFailure(t *testing.T) { + // Create a custom connection pair + clientR, serverW := io.Pipe() + serverR, clientW := io.Pipe() + + clientConn := pipeConn{ + Reader: clientR, + Writer: clientW, + closer: clientW, + } + serverConn := pipeConn{ + Reader: serverR, + Writer: serverW, + closer: serverW, + } + + client := imapclient.New(clientConn, nil) + defer client.Close() + + // Hacky server which sends greeting then closes without responding to commands. + go func() { + serverW.Write([]byte("* OK IMAP server ready\r\n")) + + buf := make([]byte, 1024) + serverR.Read(buf) + + time.Sleep(50 * time.Millisecond) + serverConn.Close() + }() + + if err := client.WaitGreeting(); err != nil { + t.Fatalf("WaitGreeting() = %v", err) + } + + noopCmd := client.Noop() + + // Wait should return an error, not hang + errCh := make(chan error, 1) + go func() { + errCh <- noopCmd.Wait() + }() + + select { + case err := <-errCh: + if err == nil { + t.Error("Expected error after connection failure, got nil") + } else { + t.Logf("Wait() returned error as expected: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("Wait() hung after connection failure") + } +} + +// TestMultipleCommands_ConnectionFailure tests that multiple pending commands +// are properly unblocked when the connection drops. +func TestMultipleCommands_ConnectionFailure(t *testing.T) { + // Create a custom connection pair + clientR, serverW := io.Pipe() + serverR, clientW := io.Pipe() + + clientConn := pipeConn{ + Reader: clientR, + Writer: clientW, + closer: clientW, + } + serverConn := pipeConn{ + Reader: serverR, + Writer: serverW, + closer: serverW, + } + + client := imapclient.New(clientConn, nil) + defer client.Close() + + // Hacky server which send greeting then closes without responding. + go func() { + serverW.Write([]byte("* OK IMAP server ready\r\n")) + + buf := make([]byte, 4096) + serverR.Read(buf) + + time.Sleep(100 * time.Millisecond) + serverConn.Close() + }() + + if err := client.WaitGreeting(); err != nil { + t.Fatalf("WaitGreeting() = %v", err) + } + + cmd1 := client.Noop() + cmd2 := client.Noop() + cmd3 := client.Noop() + + done := make(chan struct{}) + go func() { + cmd1.Wait() + cmd2.Wait() + cmd3.Wait() + close(done) + }() + + select { + case <-done: + t.Log("All commands completed after connection failure") + case <-time.After(5 * time.Second): + t.Fatal("Commands hung after connection failure") + } +} diff --git a/imapclient/notify.go b/imapclient/notify.go new file mode 100644 index 00000000..55268d07 --- /dev/null +++ b/imapclient/notify.go @@ -0,0 +1,154 @@ +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 + } + + // Start monitoring for connection failures + // Only needed if NOTIFY SET was sent (not NOTIFY NONE) + if options != nil && len(options.Items) > 0 { + go cmd.monitorConnection(c) + } + + 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() +} + +// monitorConnection watches for connection failures and closes the overflow +// channel when the connection is lost to unblock all waiter.. +func (cmd *NotifyCommand) monitorConnection(c *Client) { + <-c.decCh + 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..afe05843 --- /dev/null +++ b/imapclient/notify_test.go @@ -0,0 +1,359 @@ +package imapclient_test + +import ( + "os" + "testing" + "time" + + "github.com/emersion/go-imap/v2" + "github.com/emersion/go-imap/v2/imapclient" +) + +func TestClient_Notify(t *testing.T) { + if os.Getenv("GOIMAP_TEST_DOVECOT") != "1" { + t.Skip("NOTIFY test requires GOIMAP_TEST_DOVECOT=1") + } + + existsCh := make(chan uint32, 1) + + // Clients needs a UnilateralDataHandler configured, so set it up manually. + conn, server := newDovecotClientServerPair(t) + defer server.Close() + + options := &imapclient.Options{ + UnilateralDataHandler: &imapclient.UnilateralDataHandler{ + Expunge: func(seqNum uint32) { + // Not testing expunge in this test + }, + Mailbox: func(data *imapclient.UnilateralDataMailbox) { + if data.NumMessages != nil { + select { + case existsCh <- *data.NumMessages: + default: + } + } + }, + }, + } + if testing.Verbose() { + options.DebugWriter = os.Stderr + } + + client := imapclient.New(conn, options) + defer client.Close() + + // (Dovecot connections are pre-authenticated) + if err := client.WaitGreeting(); err != nil { + t.Fatalf("WaitGreeting() = %v", err) + } + + // Append initial message to INBOX. + appendCmd := client.Append("INBOX", int64(len(simpleRawMessage)), nil) + appendCmd.Write([]byte(simpleRawMessage)) + appendCmd.Close() + if _, err := appendCmd.Wait(); err != nil { + t.Fatalf("Initial Append() = %v", err) + } + + selectData, err := client.Select("INBOX", nil).Wait() + if err != nil { + t.Fatalf("Select() = %v", err) + } + initialExists := selectData.NumMessages + + notifyOptions := &imap.NotifyOptions{ + Items: []imap.NotifyItem{ + { + MailboxSpec: imap.NotifyMailboxSpecSelected, + Events: []imap.NotifyEvent{ + imap.NotifyEventMessageNew, + imap.NotifyEventMessageExpunge, + }, + }, + }, + } + cmd, err := client.Notify(notifyOptions) + if err != nil { + t.Fatalf("Notify() = %v", err) + } + if cmd == nil { + t.Fatal("Expected non-nil NotifyCommand") + } + + // Append a new message to INBOX (we should get a NOTIFY event for it). + testMessage := `From: sender@example.com +To: recipient@example.com +Subject: Test NOTIFY + +This is a test message for NOTIFY. +` + appendCmd = client.Append("INBOX", int64(len(testMessage)), nil) + appendCmd.Write([]byte(testMessage)) + appendCmd.Close() + if _, err := appendCmd.Wait(); err != nil { + t.Fatalf("Append() = %v", err) + } + + // Wait for the EXISTS notification (with timeout) + select { + case count := <-existsCh: + if count <= initialExists { + t.Errorf("Expected EXISTS count > %d, got %d", initialExists, count) + } + t.Logf("Received EXISTS notification: %d messages (was %d)", count, initialExists) + case <-cmd.Overflow(): + t.Fatal("Received NOTIFICATIONOVERFLOW") + case <-time.After(5 * time.Second): + t.Fatal("Timeout waiting for EXISTS notification") + } +} + +func TestClient_NotifyNone(t *testing.T) { + if os.Getenv("GOIMAP_TEST_DOVECOT") != "1" { + t.Skip("Skipping NOTIFY test - requires GOIMAP_TEST_DOVECOT=1") + } + + client, server := newClientServerPair(t, imap.ConnStateAuthenticated) + defer client.Close() + defer server.Close() + + err := client.NotifyNone() + if err != nil { + t.Fatalf("NotifyNone() = %v", err) + } +} + +func TestClient_NotifyMultiple(t *testing.T) { + if os.Getenv("GOIMAP_TEST_DOVECOT") != "1" { + t.Skip("Skipping NOTIFY test - requires GOIMAP_TEST_DOVECOT=1") + } + + client, server := newClientServerPair(t, imap.ConnStateAuthenticated) + defer client.Close() + defer server.Close() + + // Test NOTIFY with multiple items + // Note: Dovecot doesn't support STATUS with message events + 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, + }, + }, + }, + } + + cmd, err := client.Notify(options) + if err != nil { + t.Fatalf("Notify() = %v", err) + } + + if cmd == nil { + t.Fatal("Expected non-nil NotifyCommand") + } +} + +// TestClient_NotifyPersonalMailboxes tests NOTIFY for personal mailboxes +func TestClient_NotifyPersonalMailboxes(t *testing.T) { + if os.Getenv("GOIMAP_TEST_DOVECOT") != "1" { + t.Skip("Skipping NOTIFY test - requires GOIMAP_TEST_DOVECOT=1") + } + + client, server := newClientServerPair(t, imap.ConnStateAuthenticated) + defer client.Close() + defer server.Close() + + // Note: Dovecot doesn't support message events with PERSONAL spec + // Only mailbox events (MailboxName, SubscriptionChange) seem to work. + options := &imap.NotifyOptions{ + Items: []imap.NotifyItem{ + { + MailboxSpec: imap.NotifyMailboxSpecPersonal, + Events: []imap.NotifyEvent{ + imap.NotifyEventMailboxName, + imap.NotifyEventSubscriptionChange, + }, + }, + }, + } + + cmd, err := client.Notify(options) + if err != nil { + t.Fatalf("Notify() = %v", err) + } + + if cmd == nil { + t.Fatal("Expected non-nil NotifyCommand") + } +} + +// TestClient_NotifySubtree tests NOTIFY for mailbox subtrees +func TestClient_NotifySubtree(t *testing.T) { + if os.Getenv("GOIMAP_TEST_DOVECOT") != "1" { + t.Skip("Skipping NOTIFY test - requires GOIMAP_TEST_DOVECOT=1") + } + + client, server := newClientServerPair(t, imap.ConnStateAuthenticated) + defer client.Close() + defer server.Close() + + // Request notifications for INBOX subtree + options := &imap.NotifyOptions{ + Items: []imap.NotifyItem{ + { + Mailboxes: []string{"INBOX"}, + Subtree: true, + Events: []imap.NotifyEvent{ + imap.NotifyEventMailboxName, + imap.NotifyEventSubscriptionChange, + }, + }, + }, + } + + cmd, err := client.Notify(options) + if err != nil { + t.Fatalf("Notify() = %v", err) + } + + if cmd == nil { + t.Fatal("Expected non-nil NotifyCommand") + } +} + +// TestClient_NotifyMailboxes tests NOTIFY for specific mailboxes with message events +func TestClient_NotifyMailboxes(t *testing.T) { + if os.Getenv("GOIMAP_TEST_DOVECOT") != "1" { + t.Skip("Skipping NOTIFY test - requires GOIMAP_TEST_DOVECOT=1") + } + + client, server := newClientServerPair(t, imap.ConnStateAuthenticated) + defer client.Close() + defer server.Close() + + // Request notifications for specific mailboxes with SUBTREE + // Note: Dovecot requires SUBTREE for explicit mailbox specifications + options := &imap.NotifyOptions{ + Items: []imap.NotifyItem{ + { + Mailboxes: []string{"INBOX"}, + Subtree: true, + Events: []imap.NotifyEvent{ + imap.NotifyEventMailboxName, + imap.NotifyEventSubscriptionChange, + }, + }, + }, + } + + cmd, err := client.Notify(options) + if err != nil { + t.Fatalf("Notify() = %v", err) + } + + if cmd == nil { + t.Fatal("Expected non-nil NotifyCommand") + } +} + +// TestClient_NotifySelectedDelayed tests NOTIFY with SELECTED-DELAYED for safe MSN usage +func TestClient_NotifySelectedDelayed(t *testing.T) { + if os.Getenv("GOIMAP_TEST_DOVECOT") != "1" { + t.Skip("Skipping NOTIFY test - requires GOIMAP_TEST_DOVECOT=1") + } + + client, server := newClientServerPair(t, imap.ConnStateSelected) + defer client.Close() + defer server.Close() + + // Request notifications with SELECTED-DELAYED to defer expunge notifications + options := &imap.NotifyOptions{ + Items: []imap.NotifyItem{ + { + MailboxSpec: imap.NotifyMailboxSpecSelectedDelayed, + Events: []imap.NotifyEvent{ + imap.NotifyEventMessageNew, + imap.NotifyEventMessageExpunge, + }, + }, + }, + } + + cmd, err := client.Notify(options) + if err != nil { + t.Fatalf("Notify() = %v", err) + } + + if cmd == nil { + t.Fatal("Expected non-nil NotifyCommand") + } +} + +// TestClient_NotifySequence tests a sequence of NOTIFY commands +func TestClient_NotifySequence(t *testing.T) { + if os.Getenv("GOIMAP_TEST_DOVECOT") != "1" { + t.Skip("Skipping NOTIFY test - requires GOIMAP_TEST_DOVECOT=1") + } + + client, server := newClientServerPair(t, imap.ConnStateAuthenticated) + defer client.Close() + defer server.Close() + + // First NOTIFY command + options1 := &imap.NotifyOptions{ + Items: []imap.NotifyItem{ + { + MailboxSpec: imap.NotifyMailboxSpecSelected, + Events: []imap.NotifyEvent{ + imap.NotifyEventMessageNew, + imap.NotifyEventMessageExpunge, + }, + }, + }, + } + + cmd1, err := client.Notify(options1) + if err != nil { + t.Fatalf("First Notify() = %v", err) + } + if cmd1 == nil { + t.Fatal("Expected non-nil NotifyCommand from first call") + } + + // Replace with different NOTIFY settings + options2 := &imap.NotifyOptions{ + Items: []imap.NotifyItem{ + { + MailboxSpec: imap.NotifyMailboxSpecPersonal, + Events: []imap.NotifyEvent{ + imap.NotifyEventMailboxName, + imap.NotifyEventSubscriptionChange, + }, + }, + }, + } + + cmd2, err := client.Notify(options2) + if err != nil { + t.Fatalf("Second Notify() = %v", err) + } + if cmd2 == nil { + t.Fatal("Expected non-nil NotifyCommand from second call") + } + + // Disable all notifications + err = client.NotifyNone() + if err != nil { + t.Fatalf("NotifyNone() = %v", err) + } +} 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/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/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/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..90391c0d --- /dev/null +++ b/notify.go @@ -0,0 +1,63 @@ +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. +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 +}