Skip to content

Commit 504d330

Browse files
committed
imapclient: implement support for NOTIFY
Client–server tests run only with dovecot; the imapmemserver doesn't support NOTIFY.
1 parent 17771fb commit 504d330

File tree

7 files changed

+1189
-1
lines changed

7 files changed

+1189
-1
lines changed

imapclient/client.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -896,6 +896,11 @@ func (c *Client) readResponseData(typ string) error {
896896
}
897897
case "NOMODSEQ":
898898
// ignore
899+
case "NOTIFICATIONOVERFLOW":
900+
// Server has disabled NOTIFY due to overflow (RFC 5465 section 5.8)
901+
if cmd := findPendingCmdByType[*NotifyCommand](c); cmd != nil {
902+
cmd.handleOverflow()
903+
}
899904
default: // [SP 1*<any TEXT-CHAR except "]">]
900905
if c.dec.SP() {
901906
c.dec.DiscardUntilByte(']')
@@ -1179,14 +1184,24 @@ type UnilateralDataMailbox struct {
11791184
//
11801185
// The handler will be invoked in an arbitrary goroutine.
11811186
//
1187+
// These handlers are important when using the NOTIFY command , as the server
1188+
// will send unsolicited STATUS, FETCH, and EXPUNGE responses for mailbox
1189+
// events.
1190+
//
11821191
// See Options.UnilateralDataHandler.
11831192
type UnilateralDataHandler struct {
11841193
Expunge func(seqNum uint32)
11851194
Mailbox func(data *UnilateralDataMailbox)
11861195
Fetch func(msg *FetchMessageData)
11871196

1188-
// requires ENABLE METADATA or ENABLE SERVER-METADATA
1197+
// Requires ENABLE METADATA or ENABLE SERVER-METADATA.
11891198
Metadata func(mailbox string, entries []string)
1199+
1200+
// Called when the server sends an unsolicited STATUS response.
1201+
//
1202+
// Commonly used with NOTIFY to receive mailbox status updates
1203+
// for non-selected mailboxes (RFC 5465).
1204+
Status func(data *imap.StatusData)
11901205
}
11911206

11921207
// command is an interface for IMAP commands.

imapclient/connection_test.go

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
package imapclient_test
2+
3+
import (
4+
"io"
5+
"net"
6+
"testing"
7+
"time"
8+
9+
"github.com/emersion/go-imap/v2"
10+
"github.com/emersion/go-imap/v2/imapclient"
11+
)
12+
13+
type pipeConn struct {
14+
io.Reader
15+
io.Writer
16+
closer io.Closer
17+
}
18+
19+
func (c pipeConn) Close() error {
20+
return c.closer.Close()
21+
}
22+
23+
func (c pipeConn) LocalAddr() net.Addr {
24+
return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}
25+
}
26+
27+
func (c pipeConn) RemoteAddr() net.Addr {
28+
return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}
29+
}
30+
31+
func (c pipeConn) SetDeadline(t time.Time) error {
32+
return nil
33+
}
34+
35+
func (c pipeConn) SetReadDeadline(t time.Time) error {
36+
return nil
37+
}
38+
39+
func (c pipeConn) SetWriteDeadline(t time.Time) error {
40+
return nil
41+
}
42+
43+
var _ net.Conn = pipeConn{}
44+
45+
// TestNotifyCommand_Overflow_ConnectionFailure tests that the Overflow() channel
46+
// is properly closed when the network connection drops unexpectedly.
47+
func TestNotifyCommand_Overflow_ConnectionFailure(t *testing.T) {
48+
// Create a custom connection pair that we can forcefully close
49+
clientR, serverW := io.Pipe()
50+
serverR, clientW := io.Pipe()
51+
52+
clientConn := pipeConn{
53+
Reader: clientR,
54+
Writer: clientW,
55+
closer: clientW,
56+
}
57+
serverConn := pipeConn{
58+
Reader: serverR,
59+
Writer: serverW,
60+
closer: serverW,
61+
}
62+
63+
client := imapclient.New(clientConn, nil)
64+
defer client.Close()
65+
66+
// Hacky server which drops the connection after a NOTIFY command.
67+
go func() {
68+
serverW.Write([]byte("* OK [CAPABILITY IMAP4rev1 NOTIFY] IMAP server ready\r\n"))
69+
70+
buf := make([]byte, 4096)
71+
for {
72+
n, err := serverR.Read(buf)
73+
if err != nil {
74+
return
75+
}
76+
77+
cmd := string(buf[:n])
78+
if n > 0 {
79+
// Extract tag (e.g., "T1" from "T1 NOTIFY ...")
80+
tag := "T1"
81+
for i := 0; i < len(cmd) && cmd[i] != ' '; i++ {
82+
if i+1 < len(cmd) && cmd[i+1] == ' ' {
83+
tag = cmd[:i+1]
84+
}
85+
}
86+
87+
// Respond based on command
88+
serverW.Write([]byte(tag + " OK Command completed\r\n"))
89+
90+
// If this was the NOTIFY command, close connection after a delay
91+
if len(cmd) > 6 && cmd[3:9] == "NOTIFY" {
92+
time.Sleep(50 * time.Millisecond)
93+
serverConn.Close()
94+
return
95+
}
96+
}
97+
}
98+
}()
99+
100+
if err := client.WaitGreeting(); err != nil {
101+
t.Fatalf("WaitGreeting() = %v", err)
102+
}
103+
104+
// Send NOTIFY command
105+
notifyCmd, err := client.Notify(&imap.NotifyOptions{
106+
Items: []imap.NotifyItem{
107+
{
108+
MailboxSpec: imap.NotifyMailboxSpecSelected,
109+
Events: []imap.NotifyEvent{
110+
imap.NotifyEventMessageNew,
111+
},
112+
},
113+
},
114+
})
115+
if err != nil {
116+
t.Fatalf("NOTIFY failed immediately: %v", err)
117+
}
118+
119+
overflowCh := notifyCmd.Overflow()
120+
if overflowCh == nil {
121+
t.Fatal("Overflow() returned nil channel")
122+
}
123+
124+
select {
125+
case <-overflowCh:
126+
t.Log("Overflow() channel closed after connection failure")
127+
case <-time.After(2 * time.Second):
128+
t.Fatal("Overflow() channel did not close after connection failure")
129+
}
130+
131+
if err := client.Noop().Wait(); err == nil {
132+
t.Error("Expected error after connection failure, got nil")
133+
}
134+
}
135+
136+
// TestCommand_Wait_ConnectionFailure tests that Wait() returns an error instead
137+
// of hanging when the network connection drops unexpectedly.
138+
func TestCommand_Wait_ConnectionFailure(t *testing.T) {
139+
// Create a custom connection pair
140+
clientR, serverW := io.Pipe()
141+
serverR, clientW := io.Pipe()
142+
143+
clientConn := pipeConn{
144+
Reader: clientR,
145+
Writer: clientW,
146+
closer: clientW,
147+
}
148+
serverConn := pipeConn{
149+
Reader: serverR,
150+
Writer: serverW,
151+
closer: serverW,
152+
}
153+
154+
client := imapclient.New(clientConn, nil)
155+
defer client.Close()
156+
157+
// Hacky server which sends greeting then closes without responding to commands.
158+
go func() {
159+
serverW.Write([]byte("* OK IMAP server ready\r\n"))
160+
161+
buf := make([]byte, 1024)
162+
serverR.Read(buf)
163+
164+
time.Sleep(50 * time.Millisecond)
165+
serverConn.Close()
166+
}()
167+
168+
if err := client.WaitGreeting(); err != nil {
169+
t.Fatalf("WaitGreeting() = %v", err)
170+
}
171+
172+
noopCmd := client.Noop()
173+
174+
// Wait should return an error, not hang
175+
errCh := make(chan error, 1)
176+
go func() {
177+
errCh <- noopCmd.Wait()
178+
}()
179+
180+
select {
181+
case err := <-errCh:
182+
if err == nil {
183+
t.Error("Expected error after connection failure, got nil")
184+
} else {
185+
t.Logf("Wait() returned error as expected: %v", err)
186+
}
187+
case <-time.After(2 * time.Second):
188+
t.Fatal("Wait() hung after connection failure")
189+
}
190+
}
191+
192+
// TestMultipleCommands_ConnectionFailure tests that multiple pending commands
193+
// are properly unblocked when the connection drops.
194+
func TestMultipleCommands_ConnectionFailure(t *testing.T) {
195+
// Create a custom connection pair
196+
clientR, serverW := io.Pipe()
197+
serverR, clientW := io.Pipe()
198+
199+
clientConn := pipeConn{
200+
Reader: clientR,
201+
Writer: clientW,
202+
closer: clientW,
203+
}
204+
serverConn := pipeConn{
205+
Reader: serverR,
206+
Writer: serverW,
207+
closer: serverW,
208+
}
209+
210+
client := imapclient.New(clientConn, nil)
211+
defer client.Close()
212+
213+
// Hacky server which send greeting then closes without responding.
214+
go func() {
215+
serverW.Write([]byte("* OK IMAP server ready\r\n"))
216+
217+
buf := make([]byte, 4096)
218+
serverR.Read(buf)
219+
220+
time.Sleep(100 * time.Millisecond)
221+
serverConn.Close()
222+
}()
223+
224+
if err := client.WaitGreeting(); err != nil {
225+
t.Fatalf("WaitGreeting() = %v", err)
226+
}
227+
228+
cmd1 := client.Noop()
229+
cmd2 := client.Noop()
230+
cmd3 := client.Noop()
231+
232+
done := make(chan struct{})
233+
go func() {
234+
cmd1.Wait()
235+
cmd2.Wait()
236+
cmd3.Wait()
237+
close(done)
238+
}()
239+
240+
select {
241+
case <-done:
242+
t.Log("All commands completed after connection failure")
243+
case <-time.After(5 * time.Second):
244+
t.Fatal("Commands hung after connection failure")
245+
}
246+
}

0 commit comments

Comments
 (0)