Skip to content

Commit 5012796

Browse files
BrianLeishmanMartinBrugnaragemini-code-assist[bot]claude
authored
Add Dialer.GetMaxUID using RFC-4731 (from #83 + coverage fixes) (#85)
* Add Dialer.GetMaxUID as alt method using RFC-4731 * Use t.Skip() instread of if (false) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * strings.Split -> strings.Fields; camelCase instead of snake Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> * fix: fix indentation and add tests for parseMaxUIDSearchResponse coverage Fix indentation issues from applying review suggestions and add unit tests covering error paths (no ESEARCH line, empty response, non-numeric MAX value) to satisfy code coverage requirements. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address review feedback for parseMaxUIDSearchResponse - Rename searchMaxUIDre to searchMaxUIDRE for naming consistency - Use case-insensitive regex with capturing group to extract MAX value - Handles MAX not being the last token (RFC 4731 allows MIN/MAX/COUNT) - Add tests for case-insensitive matching and MAX-not-last scenarios Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address code review feedback for GetMaxUID - Handle empty mailbox: ESEARCH without MAX returns 0, nil (RFC 4731) - Detect malformed ESEARCH MAX values and return error - Add RFC-4731 server requirement caveat to README and example - Example now handles GetMaxUID error instead of silently ignoring it Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: simplify ESEARCH detection in parseMaxUIDSearchResponse - Replace redundant strings.EqualFold on "* " (no letters) with == - Move strings.ToUpper inside prefix check to avoid uppercasing every non-ESEARCH line Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Martin Brugnara <mb@martinbrugnara.me> Co-authored-by: Martin Brugnara <martin@0x6d62.eu> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2266c4c commit 5012796

7 files changed

Lines changed: 180 additions & 0 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ imap.exe
33
.devcontainer
44
.vscode/settings.json
55
coverage.out
6+
*.sw?

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,10 @@ rangeUIDs, _ := m.GetUIDs("1:10") // UIDs 1 through 10
356356
// Get the N most recent messages (recommended for "last N" queries)
357357
last10UIDs, _ := m.GetLastNUIDs(10) // Last 10 messages by UID
358358

359+
// Cheaper method to retrieve the latest UID (requires RFC-4731;
360+
// not all servers support this — check the error).
361+
maxUID, _ := m.GetMaxUID() // Highest UID only
362+
359363
// Size-based searches
360364
largeUIDs, _ := m.GetUIDs("LARGER 10485760") // Emails larger than 10MB
361365
smallUIDs, _ := m.GetUIDs("SMALLER 1024") // Emails smaller than 1KB

examples/search/main.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,11 +98,17 @@ func main() {
9898

9999
firstUID, _ := m.GetUIDs("1") // First email
100100
lastUID, _ := m.GetUIDs("*") // Last email
101+
maxUID, err := m.GetMaxUID() // Last email (cheaper, requires RFC-4731)
101102
first10UIDs, _ := m.GetUIDs("1:10") // First 10 emails
102103
last10UIDs, _ := m.GetUIDs("*:10") // Last 10 emails (reverse)
103104

104105
fmt.Printf("First email UID: %v\n", firstUID)
105106
fmt.Printf("Last email UID: %v\n", lastUID)
107+
if err != nil {
108+
fmt.Printf("Last email UID (alt): not supported (%v)\n", err)
109+
} else {
110+
fmt.Printf("Last email UID (alt): %v\n", maxUID)
111+
}
106112
fmt.Printf("First 10 email UIDs: %v\n", first10UIDs)
107113
if len(last10UIDs) <= 10 {
108114
fmt.Printf("Last 10 email UIDs: %v\n", last10UIDs)

integration_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,29 @@ func TestIntegration_GetLastNUIDs(t *testing.T) {
204204
}
205205
}
206206
})
207+
208+
// TODO: enable once test-imap server supports RFC-4731.
209+
// Currently fails with: "Search command not supported".
210+
t.Run("GetMaxUID returns highest UID", func(t *testing.T) {
211+
t.Skip("enable once test-imap server supports RFC-4731. Currently fails with: 'Search command not supported'.")
212+
213+
last1, err := conn.GetLastNUIDs(1)
214+
if err != nil {
215+
t.Fatalf("GetLastNUIDs(1) failed: %v", err)
216+
}
217+
218+
maxUID, err := conn.GetMaxUID()
219+
if err != nil {
220+
t.Fatalf("GetMaxUID() failed: %v", err)
221+
}
222+
223+
// last1 and maxUID must be identical.
224+
// NOTE: depends on GetLastNUIDs correctness,
225+
// which is already tested above.
226+
if maxUID != last1[0] {
227+
t.Errorf("Mismatch on max uid: got %d, want %d", maxUID, last1[0])
228+
}
229+
})
207230
}
208231

209232
func TestIntegration_GetUIDs_Ranges(t *testing.T) {

message.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,18 @@ func (d *Dialer) GetLastNUIDs(n int) ([]int, error) {
183183
return allUIDs[len(allUIDs)-n:], nil
184184
}
185185

186+
// Get max UID in the current folder using RFC-4731.
187+
//
188+
// The folder of interest must be already selected in either read-only mode,
189+
// ExamineFolder, or in read-write mode, SelectFolder.
190+
func (d *Dialer) GetMaxUID() (uid int, err error) {
191+
r, err := d.Exec("UID SEARCH RETURN (MAX) 1:*", true, RetryCount, nil)
192+
if err != nil {
193+
return 0, err
194+
}
195+
return parseMaxUIDSearchResponse(r)
196+
}
197+
186198
// MoveEmail moves an email to a different folder
187199
func (d *Dialer) MoveEmail(uid int, folder string) (err error) {
188200
// if we are currently read-only, switch to SELECT for the move-operation

message_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,92 @@ func TestGetLastNUIDs_EdgeCases(t *testing.T) {
133133
})
134134
}
135135

136+
func TestParseMaxUIDSearchResponse(t *testing.T) {
137+
t.Run("valid ESEARCH response", func(t *testing.T) {
138+
response := `COFFEE 121
139+
some more random UID 122
140+
some more random UID MAX 123
141+
UID MAX 124
142+
* ESEARCH (TAG "D6OVA9F5SOCMJIK7FEH0") UID MAX 387992
143+
not me UID MAX 125
144+
`
145+
maxUID, err := parseMaxUIDSearchResponse(response)
146+
if err != nil {
147+
t.Fatalf("unexpected error: %v", err)
148+
}
149+
if maxUID != 387992 {
150+
t.Errorf("unexpected maxuid: got %d, want 387992", maxUID)
151+
}
152+
})
153+
154+
t.Run("minimal ESEARCH response", func(t *testing.T) {
155+
response := "* ESEARCH (TAG \"A285\") UID MAX 3800\r\nA285 OK SEARCH completed\r\n"
156+
maxUID, err := parseMaxUIDSearchResponse(response)
157+
if err != nil {
158+
t.Fatalf("unexpected error: %v", err)
159+
}
160+
if maxUID != 3800 {
161+
t.Errorf("unexpected maxuid: got %d, want 3800", maxUID)
162+
}
163+
})
164+
165+
t.Run("no ESEARCH line returns error", func(t *testing.T) {
166+
response := "A285 OK SEARCH completed\r\n"
167+
_, err := parseMaxUIDSearchResponse(response)
168+
if err == nil {
169+
t.Fatal("expected error for response without ESEARCH line")
170+
}
171+
})
172+
173+
t.Run("empty response returns error", func(t *testing.T) {
174+
_, err := parseMaxUIDSearchResponse("")
175+
if err == nil {
176+
t.Fatal("expected error for empty response")
177+
}
178+
})
179+
180+
t.Run("ESEARCH with non-numeric MAX returns error", func(t *testing.T) {
181+
response := "* ESEARCH (TAG \"A285\") UID MAX notanumber\r\n"
182+
_, err := parseMaxUIDSearchResponse(response)
183+
if err == nil {
184+
t.Fatal("expected error for non-numeric MAX value")
185+
}
186+
})
187+
188+
t.Run("MAX not last token in ESEARCH", func(t *testing.T) {
189+
response := "* ESEARCH (TAG \"A285\") UID MIN 1 MAX 3800 COUNT 50\r\n"
190+
maxUID, err := parseMaxUIDSearchResponse(response)
191+
if err != nil {
192+
t.Fatalf("unexpected error: %v", err)
193+
}
194+
if maxUID != 3800 {
195+
t.Errorf("unexpected maxuid: got %d, want 3800", maxUID)
196+
}
197+
})
198+
199+
t.Run("case insensitive ESEARCH", func(t *testing.T) {
200+
response := "* esearch (TAG \"A285\") uid max 4200\r\n"
201+
maxUID, err := parseMaxUIDSearchResponse(response)
202+
if err != nil {
203+
t.Fatalf("unexpected error: %v", err)
204+
}
205+
if maxUID != 4200 {
206+
t.Errorf("unexpected maxuid: got %d, want 4200", maxUID)
207+
}
208+
})
209+
210+
t.Run("empty mailbox ESEARCH without MAX returns zero", func(t *testing.T) {
211+
response := "* ESEARCH (TAG \"A285\") UID\r\nA285 OK SEARCH completed\r\n"
212+
maxUID, err := parseMaxUIDSearchResponse(response)
213+
if err != nil {
214+
t.Fatalf("unexpected error: %v", err)
215+
}
216+
if maxUID != 0 {
217+
t.Errorf("expected 0 for empty mailbox, got %d", maxUID)
218+
}
219+
})
220+
}
221+
136222
func TestEnvelopeAtomAddress(t *testing.T) {
137223
name := "CBJ SAP SUPPORT INSIGHT"
138224
env := &Token{Type: TContainer, Tokens: []*Token{

parse.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const (
1717
var (
1818
atom = regexp.MustCompile(`{\d+\+?}$`)
1919
fetchLineStartRE = regexp.MustCompile(`(?m)^\* \d+ FETCH`)
20+
searchMaxUIDRE = regexp.MustCompile(`(?i)\* ESEARCH .* MAX (\d+)`)
2021
)
2122

2223
// Token represents a parsed IMAP token
@@ -456,6 +457,53 @@ func parseUIDSearchResponse(r string) ([]int, error) {
456457
return nil, fmt.Errorf("invalid response: %q", strings.TrimSpace(r))
457458
}
458459

460+
// Parse SEARCH RETURN(MAX) command response
461+
//
462+
// Expected response format (RFC 4731)
463+
//
464+
// C: A285 UID SEARCH RETURN (MAX) 1:5000
465+
// S: * ESEARCH (TAG "A285") UID MAX 3800
466+
// S: A285 OK SEARCH completed
467+
//
468+
// When the mailbox is empty, RFC 4731 omits MAX from the ESEARCH line:
469+
//
470+
// S: * ESEARCH (TAG "A285") UID
471+
//
472+
// In that case this function returns 0, nil.
473+
// ref https://www.rfc-editor.org/rfc/rfc4731.html#page-2
474+
func parseMaxUIDSearchResponse(r string) (int, error) {
475+
normalized := strings.ReplaceAll(r, nl, "\n")
476+
for rawLine := range strings.SplitSeq(normalized, "\n") {
477+
line := strings.TrimSpace(rawLine)
478+
if line == "" {
479+
continue
480+
}
481+
482+
if matches := searchMaxUIDRE.FindStringSubmatch(line); len(matches) > 1 {
483+
maxUID, err := strconv.Atoi(matches[1])
484+
if err != nil {
485+
return 0, fmt.Errorf("parse max uid %q: %w", matches[1], err)
486+
}
487+
return maxUID, nil
488+
}
489+
490+
// Check for ESEARCH line without a valid MAX capture
491+
if len(line) > 2 && line[:2] == "* " {
492+
upper := strings.ToUpper(line)
493+
if strings.Contains(upper, "ESEARCH") {
494+
// If MAX keyword is present but didn't match \d+, that's malformed
495+
if strings.Contains(upper, " MAX ") {
496+
return 0, fmt.Errorf("malformed ESEARCH MAX value in: %q", line)
497+
}
498+
// ESEARCH present without MAX means empty result set (RFC 4731)
499+
return 0, nil
500+
}
501+
}
502+
}
503+
504+
return 0, fmt.Errorf("no ESEARCH line. rfc4731 not supported?")
505+
}
506+
459507
// IsLiteral checks if a rune is valid for a literal token
460508
func IsLiteral(b rune) bool {
461509
switch {

0 commit comments

Comments
 (0)