From 5d2461ad94fc86737334a3d48ba7e8d66f114d18 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Mon, 22 Jun 2026 07:51:45 +0100 Subject: [PATCH] fix(mail): drain UidFetch channel on early error; clarify ParseHeaderOnly doc If header/body parsing errored mid-fetch we returned without draining the message channel, so the UidFetch goroutine could block on a full channel. Both fetch paths now break, drain remaining messages, then read the done error. Verified with the race detector. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/mail/imap.go | 39 ++++++++++++++++++++++++++++++--------- internal/mail/message.go | 4 +++- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/internal/mail/imap.go b/internal/mail/imap.go index fb0f266..bf1ac07 100644 --- a/internal/mail/imap.go +++ b/internal/mail/imap.go @@ -103,7 +103,10 @@ func (c *Client) fetchHeadersByUIDSet(folder string, set *imap.SeqSet) ([]Header done := make(chan error, 1) go func() { done <- c.c.UidFetch(set, items, msgCh) }() - var out []Header + var ( + out []Header + ferr error + ) for m := range msgCh { r := m.GetBody(section) if r == nil { @@ -111,17 +114,26 @@ func (c *Client) fetchHeadersByUIDSet(folder string, set *imap.SeqSet) ([]Header } raw, err := io.ReadAll(r) if err != nil { - return nil, fmt.Errorf("uid %d: read header: %w", m.Uid, err) + ferr = fmt.Errorf("uid %d: read header: %w", m.Uid, err) + break } h, err := ParseHeaderBytes(m.Uid, raw) if err != nil { - return nil, err + ferr = err + break } h.HasAttachments = hasAttachment(m.BodyStructure) out = append(out, h) } - if err := <-done; err != nil { - return nil, err + // Drain any remaining messages so the UidFetch goroutine can finish and + // never blocks on a full channel after an early break. + for range msgCh { + } + if err := <-done; err != nil && ferr == nil { + ferr = err + } + if ferr != nil { + return nil, ferr } sort.Slice(out, func(i, j int) bool { return out[i].UID > out[j].UID }) return out, nil @@ -144,6 +156,7 @@ func (c *Client) fetchFullByUID(folder string, uid uint32) (Message, error) { var ( msg Message found bool + ferr error ) for m := range msgCh { r := m.GetBody(section) @@ -152,16 +165,24 @@ func (c *Client) fetchFullByUID(folder string, uid uint32) (Message, error) { } raw, err := io.ReadAll(r) if err != nil { - return Message{}, fmt.Errorf("uid %d: read body: %w", m.Uid, err) + ferr = fmt.Errorf("uid %d: read body: %w", m.Uid, err) + break } parsed, err := ParseMessage(m.Uid, raw) if err != nil { - return Message{}, err + ferr = err + break } msg, found = parsed, true } - if err := <-done; err != nil { - return Message{}, err + // Drain so the UidFetch goroutine can finish even after an early break. + for range msgCh { + } + if err := <-done; err != nil && ferr == nil { + ferr = err + } + if ferr != nil { + return Message{}, ferr } if !found { return Message{}, fmt.Errorf("uid %d not found in %s", uid, folder) diff --git a/internal/mail/message.go b/internal/mail/message.go index 0ed73d4..364069f 100644 --- a/internal/mail/message.go +++ b/internal/mail/message.go @@ -95,7 +95,9 @@ func ParseMessage(uid uint32, raw []byte) (Message, error) { return m, nil } -// ParseHeaderOnly decodes headers and detects attachments without keeping bodies. +// ParseHeaderOnly parses a full RFC822 message (raw bytes) and returns only the +// Header, with HasAttachments set. For IMAP BODY[HEADER]-only bytes (no body), +// use ParseHeaderBytes instead, which avoids downloading the body entirely. func ParseHeaderOnly(uid uint32, raw []byte) (Header, error) { m, err := ParseMessage(uid, raw) if err != nil {