Skip to content

Commit b2884a3

Browse files
committed
imapclient: implement support for NOTIFY
Client–server tests run only with dovecot; the imapmemserver doesn't support NOTIFY.
1 parent 5c47ad1 commit b2884a3

File tree

7 files changed

+1084
-1
lines changed

7 files changed

+1084
-1
lines changed

imapclient/client.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -896,6 +896,10 @@ func (c *Client) readResponseData(typ string) error {
896896
}
897897
case "NOMODSEQ":
898898
// ignore
899+
case "NOTIFICATIONOVERFLOW":
900+
if c.options.UnilateralDataHandler.NotificationOverflow != nil {
901+
c.options.UnilateralDataHandler.NotificationOverflow()
902+
}
899903
default: // [SP 1*<any TEXT-CHAR except "]">]
900904
if c.dec.SP() {
901905
c.dec.DiscardUntilByte(']')
@@ -1179,14 +1183,29 @@ type UnilateralDataMailbox struct {
11791183
//
11801184
// The handler will be invoked in an arbitrary goroutine.
11811185
//
1186+
// These handlers are important when using the IDLE or NOTIFY commands, as the
1187+
// server will send unsolicited STATUS, FETCH, and EXPUNGE responses for
1188+
// mailbox events.
1189+
//
11821190
// See Options.UnilateralDataHandler.
11831191
type UnilateralDataHandler struct {
11841192
Expunge func(seqNum uint32)
11851193
Mailbox func(data *UnilateralDataMailbox)
11861194
Fetch func(msg *FetchMessageData)
11871195

1188-
// requires ENABLE METADATA or ENABLE SERVER-METADATA
1196+
// Requires ENABLE METADATA or ENABLE SERVER-METADATA.
11891197
Metadata func(mailbox string, entries []string)
1198+
1199+
// Called when the server sends an unsolicited STATUS response.
1200+
//
1201+
// Commonly used with NOTIFY to receive mailbox status updates
1202+
// for non-selected mailboxes (RFC 5465).
1203+
Status func(data *imap.StatusData)
1204+
1205+
// Called when the server sends NOTIFICATIONOVERFLOW (RFC 5465).
1206+
//
1207+
// Indicates the server has disabled all NOTIFY notifications.
1208+
NotificationOverflow func()
11901209
}
11911210

11921211
// command is an interface for IMAP commands.

imapclient/connection_test.go

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
package imapclient_test
2+
3+
import (
4+
"io"
5+
"net"
6+
"testing"
7+
"time"
8+
9+
"github.com/emersion/go-imap/v2/imapclient"
10+
)
11+
12+
type pipeConn struct {
13+
io.Reader
14+
io.Writer
15+
closer io.Closer
16+
}
17+
18+
func (c pipeConn) Close() error {
19+
return c.closer.Close()
20+
}
21+
22+
func (c pipeConn) LocalAddr() net.Addr {
23+
return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}
24+
}
25+
26+
func (c pipeConn) RemoteAddr() net.Addr {
27+
return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}
28+
}
29+
30+
func (c pipeConn) SetDeadline(t time.Time) error {
31+
return nil
32+
}
33+
34+
func (c pipeConn) SetReadDeadline(t time.Time) error {
35+
return nil
36+
}
37+
38+
func (c pipeConn) SetWriteDeadline(t time.Time) error {
39+
return nil
40+
}
41+
42+
var _ net.Conn = pipeConn{}
43+
44+
// TestCommand_Wait_ConnectionFailure tests that Wait() returns an error instead
45+
// of hanging when the network connection drops unexpectedly.
46+
func TestCommand_Wait_ConnectionFailure(t *testing.T) {
47+
// Create a custom connection pair
48+
clientR, serverW := io.Pipe()
49+
serverR, clientW := io.Pipe()
50+
51+
clientConn := pipeConn{
52+
Reader: clientR,
53+
Writer: clientW,
54+
closer: clientW,
55+
}
56+
serverConn := pipeConn{
57+
Reader: serverR,
58+
Writer: serverW,
59+
closer: serverW,
60+
}
61+
62+
client := imapclient.New(clientConn, nil)
63+
defer client.Close()
64+
65+
// Hacky server which sends greeting then closes without responding to commands.
66+
go func() {
67+
serverW.Write([]byte("* OK IMAP server ready\r\n"))
68+
69+
buf := make([]byte, 1024)
70+
serverR.Read(buf)
71+
72+
time.Sleep(50 * time.Millisecond)
73+
serverConn.Close()
74+
}()
75+
76+
if err := client.WaitGreeting(); err != nil {
77+
t.Fatalf("WaitGreeting() = %v", err)
78+
}
79+
80+
noopCmd := client.Noop()
81+
82+
// Wait should return an error, not hang
83+
errCh := make(chan error, 1)
84+
go func() {
85+
errCh <- noopCmd.Wait()
86+
}()
87+
88+
select {
89+
case err := <-errCh:
90+
if err == nil {
91+
t.Error("Expected error after connection failure, got nil")
92+
} else {
93+
t.Logf("Wait() returned error as expected: %v", err)
94+
}
95+
case <-time.After(2 * time.Second):
96+
t.Fatal("Wait() hung after connection failure")
97+
}
98+
}
99+
100+
// TestMultipleCommands_ConnectionFailure tests that multiple pending commands
101+
// are properly unblocked when the connection drops.
102+
func TestMultipleCommands_ConnectionFailure(t *testing.T) {
103+
// Create a custom connection pair
104+
clientR, serverW := io.Pipe()
105+
serverR, clientW := io.Pipe()
106+
107+
clientConn := pipeConn{
108+
Reader: clientR,
109+
Writer: clientW,
110+
closer: clientW,
111+
}
112+
serverConn := pipeConn{
113+
Reader: serverR,
114+
Writer: serverW,
115+
closer: serverW,
116+
}
117+
118+
client := imapclient.New(clientConn, nil)
119+
defer client.Close()
120+
121+
// Hacky server which send greeting then closes without responding.
122+
go func() {
123+
serverW.Write([]byte("* OK IMAP server ready\r\n"))
124+
125+
buf := make([]byte, 4096)
126+
serverR.Read(buf)
127+
128+
time.Sleep(100 * time.Millisecond)
129+
serverConn.Close()
130+
}()
131+
132+
if err := client.WaitGreeting(); err != nil {
133+
t.Fatalf("WaitGreeting() = %v", err)
134+
}
135+
136+
cmd1 := client.Noop()
137+
cmd2 := client.Noop()
138+
cmd3 := client.Noop()
139+
140+
done := make(chan struct{})
141+
go func() {
142+
cmd1.Wait()
143+
cmd2.Wait()
144+
cmd3.Wait()
145+
close(done)
146+
}()
147+
148+
select {
149+
case <-done:
150+
t.Log("All commands completed after connection failure")
151+
case <-time.After(5 * time.Second):
152+
t.Fatal("Commands hung after connection failure")
153+
}
154+
}

imapclient/notify.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package imapclient
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/emersion/go-imap/v2"
7+
"github.com/emersion/go-imap/v2/internal/imapwire"
8+
)
9+
10+
// Notify sends a NOTIFY command (RFC 5465).
11+
//
12+
// The NOTIFY command allows clients to request server-push notifications
13+
// for mailbox events like new messages, expunges, flag changes, etc.
14+
//
15+
// When NOTIFY SET is active, the server may send unsolicited responses at any
16+
// time (STATUS, FETCH, EXPUNGE, LIST responses). These unsolicited responses
17+
// are delivered via the UnilateralDataHandler callbacks set in
18+
// imapclient.Options.
19+
//
20+
// When the server sends NOTIFICATIONOVERFLOW, the NotificationOverflow callback
21+
// in UnilateralDataHandler will be called (if set).
22+
func (c *Client) Notify(options *imap.NotifyOptions) (*NotifyCommand, error) {
23+
cmd := &NotifyCommand{}
24+
enc := c.beginCommand("NOTIFY", cmd)
25+
if err := encodeNotifyOptions(enc.Encoder, options); err != nil {
26+
enc.end()
27+
return nil, err
28+
}
29+
enc.end()
30+
31+
if err := cmd.Wait(); err != nil {
32+
return nil, err
33+
}
34+
35+
return cmd, nil
36+
}
37+
38+
// encodeNotifyOptions encodes NOTIFY command options to the encoder.
39+
func encodeNotifyOptions(enc *imapwire.Encoder, options *imap.NotifyOptions) error {
40+
if options == nil || len(options.Items) == 0 {
41+
// NOTIFY NONE: disable all notifications.
42+
enc.SP().Atom("NONE")
43+
return nil
44+
}
45+
46+
enc.SP().Atom("SET")
47+
48+
if options.Status {
49+
enc.SP().List(1, func(i int) {
50+
enc.Atom("STATUS")
51+
})
52+
}
53+
54+
for _, item := range options.Items {
55+
if item.MailboxSpec == "" && len(item.Mailboxes) == 0 {
56+
return fmt.Errorf("invalid NOTIFY item: must specify either MailboxSpec or Mailboxes")
57+
}
58+
59+
enc.SP().List(1, func(_ int) {
60+
if item.MailboxSpec != "" {
61+
enc.Atom(string(item.MailboxSpec))
62+
}
63+
// Else: len(item.Mailboxes) > 0, as per the check above.
64+
if item.Subtree {
65+
enc.Atom("SUBTREE").SP()
66+
}
67+
enc.List(len(item.Mailboxes), func(j int) {
68+
enc.Mailbox(item.Mailboxes[j])
69+
})
70+
71+
if len(item.Events) > 0 {
72+
enc.SP().List(len(item.Events), func(j int) {
73+
enc.Atom(string(item.Events[j]))
74+
})
75+
}
76+
})
77+
78+
}
79+
80+
return nil
81+
}
82+
83+
// NotifyCommand is a NOTIFY command.
84+
//
85+
// When NOTIFY SET is active, the server may send unsolicited responses at any
86+
// time. These responses are delivered via UnilateralDataHandler
87+
// (see Options.UnilateralDataHandler).
88+
//
89+
// If the server sends NOTIFICATIONOVERFLOW, the NotificationOverflow callback
90+
// in UnilateralDataHandler will be called (if set).
91+
type NotifyCommand struct {
92+
commandBase
93+
}
94+
95+
// Wait blocks until the NOTIFY command has completed.
96+
func (cmd *NotifyCommand) Wait() error {
97+
return cmd.wait()
98+
}

0 commit comments

Comments
 (0)