Skip to content

Commit abedb06

Browse files
committed
feat: implement Pusher capabilities in IMAP client; update lifecycle tests and documentation for F3 integration
1 parent 895f599 commit abedb06

6 files changed

Lines changed: 270 additions & 7 deletions

File tree

pkg/mail/imap/client.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
// plus SMTP submission for Send.
33
//
44
// Framework F1 provides the base mail.Client (slice A). F2 adds Searcher.
5-
// Push and threading remain for later phases. Consumer Gmail/Outlook OAuth
6-
// is out of scope.
5+
// F3 adds Pusher via a dedicated IDLE connection. Threading remains for F4.
6+
// Consumer Gmail/Outlook OAuth is out of scope.
77
package imap
88

99
import (
@@ -52,6 +52,9 @@ type Options struct {
5252
// DialSMTP overrides implicit-TLS SMTP dialing. It is primarily useful for
5353
// tests and may return a plaintext connection.
5454
DialSMTP func(ctx context.Context) (net.Conn, error)
55+
// WatchMailbox is SELECT'd on the dedicated IDLE connection used by Watch.
56+
// Empty defaults to "INBOX".
57+
WatchMailbox string
5558
}
5659

5760
func (o Options) validate() error {
@@ -93,6 +96,7 @@ type Client struct {
9396
var (
9497
_ mail.Client = (*Client)(nil)
9598
_ mail.Searcher = (*Client)(nil)
99+
_ mail.Pusher = (*Client)(nil)
96100
)
97101

98102
// New validates options, connects to IMAP, and authenticates the account.
@@ -139,14 +143,14 @@ func New(ctx context.Context, opts Options) (*Client, error) {
139143
}
140144

141145
// Fetch capabilities now so go-imap can select MOVE/UIDPLUS behavior.
142-
_ = ic.Caps()
146+
serverCaps := ic.Caps()
143147
return &Client{
144148
opts: opts,
145149
imap: ic,
146150
caps: mail.Capabilities{
147-
// IMAP SEARCH is available on all IMAP4 servers we target.
148151
Search: true,
149-
// Threads/Push land in F3/F4.
152+
Push: supportsIdle(serverCaps),
153+
// Threads land in F4.
150154
},
151155
}, nil
152156
}

pkg/mail/imap/client_integration_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,8 @@ func TestClientIMAPLifecycle(t *testing.T) {
5151
})
5252

5353
caps := client.Capabilities()
54-
if !caps.Search || caps.Threads || caps.Push || caps.MaxUploadSize != 0 || caps.MaxMessageSize != 0 {
55-
t.Fatalf("unexpected F2 capabilities: %+v", caps)
54+
if !caps.Search || !caps.Push || caps.Threads || caps.MaxUploadSize != 0 || caps.MaxMessageSize != 0 {
55+
t.Fatalf("unexpected F3 capabilities: %+v", caps)
5656
}
5757
boxes, err := client.Mailboxes(context.Background())
5858
if err != nil {

pkg/mail/imap/harness_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ func newIMAPHarness(t *testing.T) *imapHarness {
3939
Caps: goimap.CapSet{
4040
goimap.CapIMAP4rev1: {},
4141
goimap.CapIMAP4rev2: {},
42+
goimap.CapIdle: {},
4243
},
4344
InsecureAuth: true,
4445
})

pkg/mail/imap/leak_test.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package imap
2+
3+
import (
4+
"testing"
5+
6+
"go.uber.org/goleak"
7+
)
8+
9+
func TestMain(m *testing.M) {
10+
goleak.VerifyTestMain(m)
11+
}

pkg/mail/imap/push.go

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
package imap
2+
3+
import (
4+
"context"
5+
"crypto/tls"
6+
"fmt"
7+
"log/slog"
8+
"net"
9+
"strconv"
10+
"time"
11+
12+
goimap "github.com/emersion/go-imap/v2"
13+
"github.com/emersion/go-imap/v2/imapclient"
14+
"github.com/fastygo/framework/pkg/mail"
15+
)
16+
17+
const (
18+
idleReconnectDelay = 5 * time.Second
19+
defaultWatchMbox = "INBOX"
20+
)
21+
22+
// Watch implements mail.Pusher with a dedicated IMAP connection running IDLE.
23+
// The primary Client connection stays free for ordinary commands. The returned
24+
// channel closes when ctx is cancelled or the watcher exits permanently.
25+
//
26+
// ChangeEvent.MailboxID is the mailbox SELECT'd for IDLE (Options.WatchMailbox
27+
// or INBOX). Events are mailbox-level only — callers re-query Messages.
28+
func (c *Client) Watch(ctx context.Context) (<-chan mail.ChangeEvent, error) {
29+
const op = "imap: IDLE"
30+
if !c.Capabilities().Push {
31+
return nil, &mail.Error{Op: op, Code: mail.CodeUnsupported, Err: fmt.Errorf("server does not advertise IDLE")}
32+
}
33+
if err := ctx.Err(); err != nil {
34+
return nil, wrapError(op, mail.CodeUnavailable, err)
35+
}
36+
37+
mailbox := c.opts.WatchMailbox
38+
if mailbox == "" {
39+
mailbox = defaultWatchMbox
40+
}
41+
events := make(chan mail.ChangeEvent)
42+
go func() {
43+
defer close(events)
44+
auditEvent(ctx, slog.LevelInfo, "push_started",
45+
slog.String("op", op),
46+
slog.String("mailbox", mailbox))
47+
defer auditEvent(ctx, slog.LevelInfo, "push_stopped",
48+
slog.String("op", op),
49+
slog.String("mailbox", mailbox))
50+
51+
for {
52+
if err := c.idleOnce(ctx, mailbox, events); err != nil {
53+
if ctx.Err() != nil {
54+
return
55+
}
56+
select {
57+
case <-ctx.Done():
58+
return
59+
case <-time.After(idleReconnectDelay):
60+
}
61+
continue
62+
}
63+
if ctx.Err() != nil {
64+
return
65+
}
66+
}
67+
}()
68+
return events, nil
69+
}
70+
71+
func (c *Client) idleOnce(ctx context.Context, mailbox string, events chan<- mail.ChangeEvent) error {
72+
const op = "imap: IDLE"
73+
ic, err := c.openIdleClient(ctx, mailbox, events)
74+
if err != nil {
75+
return err
76+
}
77+
defer func() {
78+
_ = ic.Logout().Wait()
79+
_ = ic.Close()
80+
}()
81+
82+
idleCmd, err := ic.Idle()
83+
if err != nil {
84+
return wrapError(op, mail.CodeUnavailable, err)
85+
}
86+
87+
waitDone := make(chan error, 1)
88+
go func() {
89+
waitDone <- idleCmd.Wait()
90+
}()
91+
92+
select {
93+
case <-ctx.Done():
94+
_ = idleCmd.Close()
95+
<-waitDone
96+
return ctx.Err()
97+
case err := <-waitDone:
98+
_ = idleCmd.Close()
99+
if err != nil {
100+
return wrapError(op, mail.CodeUnavailable, err)
101+
}
102+
return nil
103+
}
104+
}
105+
106+
func (c *Client) openIdleClient(ctx context.Context, mailbox string, events chan<- mail.ChangeEvent) (*imapclient.Client, error) {
107+
const op = "imap: IDLE connect"
108+
emit := func(ev mail.ChangeEvent) {
109+
select {
110+
case events <- ev:
111+
case <-ctx.Done():
112+
}
113+
}
114+
handler := &imapclient.UnilateralDataHandler{
115+
Expunge: func(uint32) {
116+
emit(mail.ChangeEvent{MailboxID: mailbox})
117+
},
118+
Mailbox: func(data *imapclient.UnilateralDataMailbox) {
119+
if data == nil {
120+
return
121+
}
122+
emit(mail.ChangeEvent{MailboxID: mailbox})
123+
},
124+
Fetch: func(*imapclient.FetchMessageData) {
125+
emit(mail.ChangeEvent{MailboxID: mailbox})
126+
},
127+
}
128+
129+
tlsConfig := &tls.Config{
130+
ServerName: c.opts.IMAPHost,
131+
InsecureSkipVerify: c.opts.InsecureTLS, //nolint:gosec // explicit development-only option
132+
}
133+
imapOpts := &imapclient.Options{
134+
TLSConfig: tlsConfig,
135+
UnilateralDataHandler: handler,
136+
}
137+
138+
var (
139+
ic *imapclient.Client
140+
err error
141+
)
142+
if c.opts.DialIMAP != nil {
143+
var conn net.Conn
144+
conn, err = c.opts.DialIMAP(ctx)
145+
if err == nil {
146+
ic = imapclient.New(conn, imapOpts)
147+
}
148+
} else {
149+
ic, err = imapclient.DialTLS(net.JoinHostPort(c.opts.IMAPHost, strconv.Itoa(c.opts.IMAPPort)), imapOpts)
150+
}
151+
if err != nil {
152+
return nil, wrapError(op, mail.CodeUnavailable, err)
153+
}
154+
if err := ctx.Err(); err != nil {
155+
_ = ic.Close()
156+
return nil, wrapError(op, mail.CodeUnavailable, err)
157+
}
158+
if err := ic.Login(c.opts.Username, c.opts.Password).Wait(); err != nil {
159+
_ = ic.Close()
160+
return nil, wrapError(op, mail.CodeAuth, err)
161+
}
162+
if _, err := ic.Select(mailbox, nil).Wait(); err != nil {
163+
_ = ic.Logout().Wait()
164+
_ = ic.Close()
165+
return nil, wrapError(op, mail.CodeNotFound, err)
166+
}
167+
return ic, nil
168+
}
169+
170+
func supportsIdle(caps goimap.CapSet) bool {
171+
return caps.Has(goimap.CapIdle) || caps.Has(goimap.CapIMAP4rev2)
172+
}

pkg/mail/imap/push_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package imap_test
2+
3+
import (
4+
"context"
5+
"strings"
6+
"testing"
7+
"time"
8+
9+
"github.com/fastygo/framework/pkg/mail"
10+
frameworkimap "github.com/fastygo/framework/pkg/mail/imap"
11+
)
12+
13+
func TestClientWatchIDLE(t *testing.T) {
14+
harness := newIMAPHarness(t)
15+
client, err := frameworkimap.New(context.Background(), harness.options)
16+
if err != nil {
17+
t.Fatalf("New: %v", err)
18+
}
19+
t.Cleanup(func() { _ = client.Close() })
20+
21+
if !client.Capabilities().Push {
22+
t.Fatal("expected Capabilities.Push when IDLE is available")
23+
}
24+
var _ mail.Pusher = client
25+
26+
ctx, cancel := context.WithCancel(context.Background())
27+
defer cancel()
28+
29+
events, err := client.Watch(ctx)
30+
if err != nil {
31+
t.Fatalf("Watch: %v", err)
32+
}
33+
34+
// Give IDLE a moment to start before appending.
35+
time.Sleep(50 * time.Millisecond)
36+
harness.append(t, "INBOX", []byte(strings.Join([]string{
37+
"Date: Tue, 28 Jul 2026 11:00:00 +0000",
38+
"From: Ada <ada@example.com>",
39+
"To: Bob <bob@example.com>",
40+
"Subject: idle notify",
41+
"Message-ID: <idle@example.com>",
42+
"MIME-Version: 1.0",
43+
"Content-Type: text/plain; charset=utf-8",
44+
"",
45+
"ping",
46+
"",
47+
}, "\r\n")))
48+
49+
select {
50+
case ev := <-events:
51+
if ev.MailboxID != "INBOX" {
52+
t.Fatalf("MailboxID: got %q", ev.MailboxID)
53+
}
54+
case <-time.After(3 * time.Second):
55+
t.Fatal("timed out waiting for IDLE ChangeEvent")
56+
}
57+
58+
cancel()
59+
select {
60+
case _, ok := <-events:
61+
if ok {
62+
// drain optional trailing event then wait for close
63+
select {
64+
case _, ok = <-events:
65+
if ok {
66+
t.Fatal("events channel still open after cancel")
67+
}
68+
case <-time.After(2 * time.Second):
69+
t.Fatal("events channel did not close after cancel")
70+
}
71+
}
72+
case <-time.After(2 * time.Second):
73+
t.Fatal("events channel did not close after cancel")
74+
}
75+
}

0 commit comments

Comments
 (0)