Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion imapclient/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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*<any TEXT-CHAR except "]">]
if c.dec.SP() {
c.dec.DiscardUntilByte(']')
Expand Down Expand Up @@ -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.
Expand Down
246 changes: 246 additions & 0 deletions imapclient/connection_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading