Repository: DirectoryTree/ImapEngine (php)

Issue:
Folder names needs UTF-7 decoding
If you have folder with non ASCII symbols, you get bad-looking folder names:
`[Gmail]/&BBoEPgRABDcEOAQ9BDA-` instead of `[Gmail]/Корзина` in RU

Folder name is UTF-7 encoded and needs decoding to UTF-8.
This is done in Webklex lib [\Webklex\PHPIMAP\Folder::decodeName](https://github.com/Webklex/php-imap/blob/35bad8556032e07aa046b0036e398b43eae044d7/src/Folder.php#L236)

I prefer to use this ImapEnging package instead of PHPIMAP, so it would be nice to have same feature here.

Pull request:
Add `Str::toImapUtf7` method to encode UTF-8 back to UTF-7 in folder queries
Closes #50

Patch:
diff --git a/src/Folder.php b/src/Folder.php
index 4bbf523..bd30cc2 100644
--- a/src/Folder.php
+++ b/src/Folder.php
@@ -65,7 +65,7 @@ class Folder implements Arrayable, FolderInterface, JsonSerializable
      */
     public function name(): string
     {
-        return Str::decodeUtf7Imap(
+        return Str::fromImapUtf7(
             last(explode($this->delimiter, $this->path))
         );
     }
diff --git a/src/FolderRepository.php b/src/FolderRepository.php
index c0c5a8e..edd1a69 100644
--- a/src/FolderRepository.php
+++ b/src/FolderRepository.php
@@ -4,6 +4,7 @@ namespace DirectoryTree\ImapEngine;
 
 use DirectoryTree\ImapEngine\Collections\FolderCollection;
 use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse;
+use DirectoryTree\ImapEngine\Support\Str;
 
 class FolderRepository implements FolderRepositoryInterface
 {
@@ -19,7 +20,7 @@ class FolderRepository implements FolderRepositoryInterface
      */
     public function find(string $path): ?FolderInterface
     {
-        return $this->get($path)->first();
+        return $this->get(Str::toImapUtf7($path))->first();
     }
 
     /**
@@ -27,7 +28,7 @@ class FolderRepository implements FolderRepositoryInterface
      */
     public function findOrFail(string $path): FolderInterface
     {
-        return $this->get($path)->firstOrFail();
+        return $this->get(Str::toImapUtf7($path))->firstOrFail();
     }
 
     /**
@@ -35,7 +36,9 @@ class FolderRepository implements FolderRepositoryInterface
      */
     public function create(string $path): FolderInterface
     {
-        $this->mailbox->connection()->create($path);
+        $this->mailbox->connection()->create(
+            Str::toImapUtf7($path)
+        );
 
         return $this->find($path);
     }
@@ -53,7 +56,7 @@ class FolderRepository implements FolderRepositoryInterface
      */
     public function get(?string $match = '*', ?string $reference = ''): FolderCollection
     {
-        return $this->mailbox->connection()->list($reference, $match)->map(
+        return $this->mailbox->connection()->list($reference, Str::toImapUtf7($match))->map(
             fn (UntaggedResponse $response) => new Folder(
                 mailbox: $this->mailbox,
                 path: $response->tokenAt(4)->value,
diff --git a/src/Support/Str.php b/src/Support/Str.php
index bb6eab2..743f113 100644
--- a/src/Support/Str.php
+++ b/src/Support/Str.php
@@ -134,7 +134,7 @@ class Str
     /**
      * Decode a modified UTF-7 string (IMAP specific) to UTF-8.
      */
-    public static function decodeUtf7Imap(string $string): string
+    public static function fromImapUtf7(string $string): string
     {
         // If the string doesn't contain any '&' character, it's not UTF-7 encoded.
         if (! str_contains($string, '&')) {
@@ -200,6 +200,62 @@ class Str
         }, $string);
     }
 
+    /**
+     * Encode a UTF-8 string to modified UTF-7 (IMAP specific).
+     */
+    public static function toImapUtf7(string $string): string
+    {
+        $result = '';
+        $buffer = '';
+
+        // Iterate over each character in the UTF-8 string.
+        for ($i = 0; $i < mb_strlen($string, 'UTF-8'); $i++) {
+            $char = mb_substr($string, $i, 1, 'UTF-8');
+
+            // Convert character to its UTF-16BE code unit (for deciding if ASCII).
+            $ord = unpack('n', mb_convert_encoding($char, 'UTF-16BE', 'UTF-8'))[1];
+
+            // Handle printable ASCII characters (0x20 - 0x7E) except '&'
+            if ($ord >= 0x20 && $ord <= 0x7E && $char !== '&') {
+                // If there is any buffered non-ASCII content, flush it as a base64 section.
+                if ($buffer !== '') {
+                    // Encode the buffer to UTF-16BE, then to base64, swap '/' for ',', trim '=' padding, and wrap with '&' and '-'.
+                    $result .= '&'.rtrim(strtr(base64_encode(mb_convert_encoding($buffer, 'UTF-16BE', 'UTF-8')), '/', ','), '=').'-';
+                    $buffer = '';
+                }
+
+                // Append the ASCII character as-is.
+                $result .= $char;
+
+                continue;
+            }
+
+            // Special handling for literal '&' which becomes '&-'
+            if ($char === '&') {
+                // Flush any buffered non-ASCII content first.
+                if ($buffer !== '') {
+                    $result .= '&'.rtrim(strtr(base64_encode(mb_convert_encoding($buffer, 'UTF-16BE', 'UTF-8')), '/', ','), '=').'-';
+                    $buffer = '';
+                }
+
+                // '&' is encoded as '&-'
+                $result .= '&-';
+
+                continue;
+            }
+
+            // Buffer non-ASCII characters for later base64 encoding.
+            $buffer .= $char;
+        }
+
+        // After the loop, flush any remaining buffered non-ASCII content.
+        if ($buffer !== '') {
+            $result .= '&'.rtrim(strtr(base64_encode(mb_convert_encoding($buffer, 'UTF-16BE', 'UTF-8')), '/', ','), '=').'-';
+        }
+
+        return $result;
+    }
+
     /**
      * Determine if a given string matches a given pattern.
      */


Test patch:
diff --git a/src/Testing/FakeFolder.php b/src/Testing/FakeFolder.php
index 30b984b..aafb8a8 100644
--- a/src/Testing/FakeFolder.php
+++ b/src/Testing/FakeFolder.php
@@ -62,7 +62,7 @@ class FakeFolder implements FolderInterface
      */
     public function name(): string
     {
-        return Str::decodeUtf7Imap(
+        return Str::fromImapUtf7(
             last(explode($this->delimiter, $this->path))
         );
     }
diff --git a/tests/Unit/Support/StrTest.php b/tests/Unit/Support/StrTest.php
index baa87b0..cb80375 100644
--- a/tests/Unit/Support/StrTest.php
+++ b/tests/Unit/Support/StrTest.php
@@ -102,44 +102,72 @@ test('enums handles nested arrays containing backed enums and strings', function
     expect($result)->toEqual($expected);
 });
 
-test('decodeUtf7Imap decodes UTF-7 encoded folder names', function () {
+test('fromImapUtf7 decodes UTF-7 encoded folder names', function () {
     // Russian Cyrillic example from the bug report.
     $encoded = '&BBoEPgRABDcEOAQ9BDA-';
     $decoded = 'Корзина';
 
-    expect(Str::decodeUtf7Imap($encoded))->toBe($decoded);
+    expect(Str::fromImapUtf7($encoded))->toBe($decoded);
 });
 
-test('decodeUtf7Imap handles non-encoded strings', function () {
+test('fromImapUtf7 handles non-encoded strings', function () {
     $plainString = 'INBOX';
 
-    expect(Str::decodeUtf7Imap($plainString))->toBe($plainString);
+    expect(Str::fromImapUtf7($plainString))->toBe($plainString);
 });
 
-test('decodeUtf7Imap handles special characters', function () {
+test('fromImapUtf7 handles special characters', function () {
     // Ampersand is represented as &- in UTF-7.
     $encoded = '&-';
     $decoded = '&';
 
-    expect(Str::decodeUtf7Imap($encoded))->toBe($decoded);
+    expect(Str::fromImapUtf7($encoded))->toBe($decoded);
 });
 
-test('decodeUtf7Imap handles mixed content', function () {
+test('fromImapUtf7 handles mixed content', function () {
     // Test that the function doesn't modify the non-encoded part.
     $encoded = 'Hello &-';
     $decoded = 'Hello &';
 
-    expect(Str::decodeUtf7Imap($encoded))->toBe($decoded);
+    expect(Str::fromImapUtf7($encoded))->toBe($decoded);
 });
 
-test('decodeUtf7Imap preserves existing UTF-8 characters', function () {
+test('fromImapUtf7 preserves existing UTF-8 characters', function () {
     // Test with various UTF-8 characters that should remain unchanged.
     $utf8String = 'Привет мир 你好 こんにちは ñáéíóú';
 
     // The function should return the string unchanged since it's already UTF-8.
-    expect(Str::decodeUtf7Imap($utf8String))->toBe($utf8String);
+    expect(Str::fromImapUtf7($utf8String))->toBe($utf8String);
 
     // Test with a mix of UTF-8 and regular ASCII.
     $mixedString = 'Hello Привет 123';
-    expect(Str::decodeUtf7Imap($mixedString))->toBe($mixedString);
+    expect(Str::fromImapUtf7($mixedString))->toBe($mixedString);
+});
+
+test('toImapUtf7 encodes plain ASCII as-is', function () {
+    $input = 'Inbox';
+    $expected = 'Inbox';
+
+    expect(Str::toImapUtf7($input))->toBe($expected);
+});
+
+test('toImapUtf7 encodes ampersand correctly', function () {
+    $input = 'Inbox & Archive';
+    $expected = 'Inbox &- Archive';
+
+    expect(Str::toImapUtf7($input))->toBe($expected);
+});
+
+test('toImapUtf7 encodes non-ASCII characters', function () {
+    $input = 'Корзина'; // Russian for "Trash"
+    $expected = '&BBoEPgRABDcEOAQ9BDA-';
+
+    expect(Str::toImapUtf7($input))->toBe($expected);
+});
+
+test('toImapUtf7 encodes mixed content correctly', function () {
+    $input = 'Work Корзина & Stuff';
+    $expected = 'Work &BBoEPgRABDcEOAQ9BDA- &- Stuff';
+
+    expect(Str::toImapUtf7($input))->toBe($expected);
 });