From ee35138e37cc579341e38c074c75751a57fc4e21 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sat, 11 Jul 2026 17:30:20 +0200 Subject: [PATCH] feat: fix the generic IMAP client so it can actually connect and sync - assign the client before auth (was nil and would panic), check CONDSTORE after login where servers advertise it, request LIST-STATUS fields (folders were silently empty), and fetch 1:* instead of an empty SeqSet that panics inside go-imap --- internal/client/smtpimap/imap/client.go | 34 ++++++++++++++++++------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/internal/client/smtpimap/imap/client.go b/internal/client/smtpimap/imap/client.go index 81daa477..8f1a22f1 100644 --- a/internal/client/smtpimap/imap/client.go +++ b/internal/client/smtpimap/imap/client.go @@ -66,12 +66,7 @@ func (c *Client) Connect() *errx.MailError { return errx.ErrMailServerUnreachable } - client := imapclient.New(conn, nil) - - caps := client.Caps() - if !caps.Has(imap.CapCondStore) { - return errx.ErrMailCondStoreNotSupported - } + c.client = imapclient.New(conn, nil) var xerr *errx.MailError @@ -81,8 +76,18 @@ func (c *Client) Connect() *errx.MailError { case models.AuthOAuth2: xerr = c.oauth2Auth() } + if xerr != nil { + return xerr + } - return xerr + // CONDSTORE backs the ChangedSince incremental sync. Servers (Gmail, + // Dovecot, ...) typically advertise it only after authentication, so the + // check must run post-auth. + if !c.client.Caps().Has(imap.CapCondStore) { + return errx.ErrMailCondStoreNotSupported + } + + return nil } func (c *Client) Close() error { @@ -126,7 +131,14 @@ func (c *Client) oauth2Auth() *errx.MailError { func (c *Client) Folders() ([]models.Mailbox, *errx.MailError) { var resp []models.Mailbox - cmd := c.client.List("", "%", nil) + // LIST-STATUS: without requesting these, f.Status is nil for every + // folder and the sync loop sees an empty account. + cmd := c.client.List("", "%", &imap.ListOptions{ + ReturnStatus: &imap.StatusOptions{ + UIDValidity: true, + HighestModSeq: true, + }, + }) for f := cmd.Next(); f != nil; f = cmd.Next() { if len(resp) >= config.MaxEmailFolders { @@ -167,7 +179,11 @@ func (c *Client) Mailbox(mailbox string, uidvali, opts *imap.SelectOptions) erro } func (c *Client) FetchChanges(ctx context.Context, lastModSeq uint64) *errx.MailError { - cmd := c.client.Fetch(&imap.SeqSet{}, &imap.FetchOptions{ + // 1:* — an empty SeqSet has no encodable ranges and panics inside + // go-imap; ChangedSince narrows the result server-side. + var allMessages imap.SeqSet + allMessages.AddRange(1, 0) + cmd := c.client.Fetch(allMessages, &imap.FetchOptions{ UID: true, Envelope: true, BodyStructure: &imap.FetchItemBodyStructure{