From 7757c5eb2e4310295990ddb1309236d7bc4daf2f Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Mon, 24 Aug 2026 10:45:50 -0400 Subject: [PATCH 1/2] Generate sequence ranges to keep IMAP command payloads small --- src/Support/Str.php | 51 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/src/Support/Str.php b/src/Support/Str.php index b33f62c..7813f3d 100644 --- a/src/Support/Str.php +++ b/src/Support/Str.php @@ -3,6 +3,7 @@ namespace DirectoryTree\ImapEngine\Support; use BackedEnum; +use Illuminate\Support\Collection; class Str { @@ -75,18 +76,12 @@ public static function enum(BackedEnum|string $enum): string } /** - * Make a range set for use in a search command. + * Make an IMAP sequence set. */ public static function set(int|string|array $from, int|float|string|null $to = null): string { - // If $from is an array with multiple elements, return them as a comma-separated list. - if (is_array($from) && count($from) > 1) { - return implode(',', $from); - } - - // If $from is an array with a single element, return that element. - if (is_array($from) && count($from) === 1) { - return (string) reset($from); + if (is_array($from)) { + return static::toSequenceSet($from); } // At this point, $from is an integer. No upper bound provided, return $from as a string. @@ -103,6 +98,44 @@ public static function set(int|string|array $from, int|float|string|null $to = n return $from.':'.$to; } + /** + * Convert the values into an IMAP sequence set. + * + * @param array $values + */ + protected static function toSequenceSet(array $values): string + { + return Collection::make(array_values($values)) + ->chunkWhile(function (int|string $value, int $key, Collection $range) { + $previous = $range->last(); + + if (! is_numeric($value) || ! is_numeric($previous)) { + return false; + } + + $difference = (int) $value - (int) $previous; + $direction = (int) $previous <=> (int) $range->first(); + + return in_array($difference, [-1, 1], true) + && ($range->count() === 1 || $difference === $direction); + }) + ->map(fn (Collection $range) => static::toSequenceRange( + $range->first(), + $range->last(), + )) + ->implode(','); + } + + /** + * Convert the values into an IMAP sequence range. + */ + protected static function toSequenceRange(int|string $start, int|string $end): string + { + return (string) $start === (string) $end + ? (string) $start + : $start.':'.$end; + } + /** * Make a credentials string for use in the AUTHENTICATE command. */ From e2b1d219f709a2d219b83d1e5b001b03de950086 Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Mon, 24 Aug 2026 10:45:54 -0400 Subject: [PATCH 2/2] Add and update tests --- tests/Unit/Connection/ImapConnectionTest.php | 2 +- .../Unit/Connection/ImapQueryBuilderTest.php | 2 +- tests/Unit/MessageQueryTest.php | 20 +++++++++---------- tests/Unit/Support/StrTest.php | 12 ++++++++++- 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/tests/Unit/Connection/ImapConnectionTest.php b/tests/Unit/Connection/ImapConnectionTest.php index 7e38ef1..d99f718 100644 --- a/tests/Unit/Connection/ImapConnectionTest.php +++ b/tests/Unit/Connection/ImapConnectionTest.php @@ -481,7 +481,7 @@ $connection->move('Archive', [1, 2, 3]); - $stream->assertWritten('TAG1 UID MOVE 1,2,3 "Archive"'); + $stream->assertWritten('TAG1 UID MOVE 1:3 "Archive"'); }); test('store flags', function () { diff --git a/tests/Unit/Connection/ImapQueryBuilderTest.php b/tests/Unit/Connection/ImapQueryBuilderTest.php index efad6fa..9c10c80 100644 --- a/tests/Unit/Connection/ImapQueryBuilderTest.php +++ b/tests/Unit/Connection/ImapQueryBuilderTest.php @@ -253,7 +253,7 @@ function (ImapQueryBuilder $q) { $builder->uid([2, 3, 5]); - expect($builder->toImap())->toBe('UID 2,3,5'); + expect($builder->toImap())->toBe('UID 2:3,5'); }); test('compiles UID range to infinity with from and to as a numeric upper bound', function () { diff --git a/tests/Unit/MessageQueryTest.php b/tests/Unit/MessageQueryTest.php index 4213c08..d9876b3 100644 --- a/tests/Unit/MessageQueryTest.php +++ b/tests/Unit/MessageQueryTest.php @@ -75,7 +75,7 @@ function query(?Mailbox $mailbox = null): MessageQuery query($mailbox)->destroy([1, 2, 3]); - $stream->assertWritten('TAG2 UID STORE 1,2,3 +FLAGS.SILENT (\Deleted)'); + $stream->assertWritten('TAG2 UID STORE 1:3 +FLAGS.SILENT (\Deleted)'); }); test('oldest sets fetch order to asc', function () { @@ -232,7 +232,7 @@ function query(?Mailbox $mailbox = null): MessageQuery $count = query($mailbox)->flag(ImapFlag::Seen, '+'); expect($count)->toBe(3); - $stream->assertWritten('TAG3 UID STORE 1,2,3 +FLAGS.SILENT (\Seen)'); + $stream->assertWritten('TAG3 UID STORE 1:3 +FLAGS.SILENT (\Seen)'); }); test('flag removes flag from all matching messages', function () { @@ -253,7 +253,7 @@ function query(?Mailbox $mailbox = null): MessageQuery $count = query($mailbox)->flag(ImapFlag::Flagged, '-'); expect($count)->toBe(2); - $stream->assertWritten('TAG3 UID STORE 4,5 -FLAGS.SILENT (\Flagged)'); + $stream->assertWritten('TAG3 UID STORE 4:5 -FLAGS.SILENT (\Flagged)'); }); test('flag returns zero when no messages match', function () { @@ -293,7 +293,7 @@ function query(?Mailbox $mailbox = null): MessageQuery $count = query($mailbox)->markRead(); expect($count)->toBe(3); - $stream->assertWritten('TAG3 UID STORE 1,2,3 +FLAGS.SILENT (\Seen)'); + $stream->assertWritten('TAG3 UID STORE 1:3 +FLAGS.SILENT (\Seen)'); }); test('markUnread marks all matching messages as unread', function () { @@ -314,7 +314,7 @@ function query(?Mailbox $mailbox = null): MessageQuery $count = query($mailbox)->markUnread(); expect($count)->toBe(2); - $stream->assertWritten('TAG3 UID STORE 1,2 -FLAGS.SILENT (\Seen)'); + $stream->assertWritten('TAG3 UID STORE 1:2 -FLAGS.SILENT (\Seen)'); }); test('markFlagged flags all matching messages', function () { @@ -335,7 +335,7 @@ function query(?Mailbox $mailbox = null): MessageQuery $count = query($mailbox)->markFlagged(); expect($count)->toBe(4); - $stream->assertWritten('TAG3 UID STORE 5,6,7,8 +FLAGS.SILENT (\Flagged)'); + $stream->assertWritten('TAG3 UID STORE 5:8 +FLAGS.SILENT (\Flagged)'); }); test('uid range to infinity searches with numeric upper bound', function () { @@ -397,7 +397,7 @@ function query(?Mailbox $mailbox = null): MessageQuery $count = query($mailbox)->delete(); expect($count)->toBe(3); - $stream->assertWritten('TAG3 UID STORE 1,2,3 +FLAGS.SILENT (\Deleted)'); + $stream->assertWritten('TAG3 UID STORE 1:3 +FLAGS.SILENT (\Deleted)'); }); test('delete with expunge also expunges folder', function () { @@ -422,7 +422,7 @@ function query(?Mailbox $mailbox = null): MessageQuery $count = $query->delete(expunge: true); expect($count)->toBe(2); - $stream->assertWritten('TAG3 UID STORE 1,2 +FLAGS.SILENT (\Deleted)'); + $stream->assertWritten('TAG3 UID STORE 1:2 +FLAGS.SILENT (\Deleted)'); $stream->assertWritten('TAG4 EXPUNGE'); }); @@ -444,7 +444,7 @@ function query(?Mailbox $mailbox = null): MessageQuery $count = query($mailbox)->move('Archive'); expect($count)->toBe(3); - $stream->assertWritten('TAG3 UID MOVE 1,2,3 "Archive"'); + $stream->assertWritten('TAG3 UID MOVE 1:3 "Archive"'); }); test('move returns zero when no messages match', function () { @@ -484,7 +484,7 @@ function query(?Mailbox $mailbox = null): MessageQuery $count = query($mailbox)->copy('Backup'); expect($count)->toBe(2); - $stream->assertWritten('TAG3 UID COPY 4,5 "Backup"'); + $stream->assertWritten('TAG3 UID COPY 4:5 "Backup"'); }); test('copy returns zero when no messages match', function () { diff --git a/tests/Unit/Support/StrTest.php b/tests/Unit/Support/StrTest.php index ba61b5d..630ccf2 100644 --- a/tests/Unit/Support/StrTest.php +++ b/tests/Unit/Support/StrTest.php @@ -13,6 +13,16 @@ expect(Str::set(5))->toBe('5'); }); +test('set converts consecutive values into sequence ranges', function () { + expect(Str::set([1, 2, 3, 5, 7, 8, 9]))->toBe('1:3,5,7:9'); + expect(Str::set([9, 8, 7, 5, 3, 2, 1]))->toBe('9:7,5,3:1'); + expect(Str::set(range(16902, 15146)))->toBe('16902:15146'); + expect(Str::set([1, 3, 5]))->toBe('1,3,5'); + expect(Str::set([1, 2, 3, 8, 7, 6]))->toBe('1:3,8:6'); + expect(Str::set(['1', '2', '3', '5']))->toBe('1:3,5'); + expect(Str::set([1, '*']))->toBe('1,*'); +}); + test('credentials', function () { expect(Str::credentials('foo', 'bar'))->toBe('dXNlcj1mb28BYXV0aD1CZWFyZXIgYmFyAQE='); }); @@ -22,7 +32,7 @@ }); test('set ignores $to when $from is a multi-element array', function () { - expect(Str::set([5, 6], 10))->toBe('5,6'); + expect(Str::set([5, 6], 10))->toBe('5:6'); }); test('escape removes newlines/control characters and escapes backslashes and double quotes', function () {