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 description:
Decode UTF-7 to UTF-8 characters when retrieving folder name
Closes #50 

Patch:
diff --git a/src/Folder.php b/src/Folder.php
index 341fc66..d46d745 100644
--- a/src/Folder.php
+++ b/src/Folder.php
@@ -7,6 +7,7 @@ use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse;
 use DirectoryTree\ImapEngine\Enums\ImapFetchIdentifier;
 use DirectoryTree\ImapEngine\Exceptions\Exception;
 use DirectoryTree\ImapEngine\Exceptions\ImapCapabilityException;
+use DirectoryTree\ImapEngine\Support\Str;
 use Illuminate\Contracts\Support\Arrayable;
 use Illuminate\Support\ItemNotFoundException;
 use JsonSerializable;
@@ -62,7 +63,9 @@ class Folder implements Arrayable, FolderInterface, JsonSerializable
      */
     public function name(): string
     {
-        return last(explode($this->delimiter, $this->path));
+        return Str::decodeUtf7Imap(
+            last(explode($this->delimiter, $this->path))
+        );
     }
 
     /**
diff --git a/src/Support/Str.php b/src/Support/Str.php
index 9d09076..b7daa9c 100644
--- a/src/Support/Str.php
+++ b/src/Support/Str.php
@@ -130,4 +130,73 @@ class Str
         // Escape backslashes first to avoid double-escaping and then escape double quotes.
         return str_replace(['\\', '"'], ['\\\\', '\\"'], $string);
     }
+
+    /**
+     * Decode a modified UTF-7 string (IMAP specific) to UTF-8.
+     */
+    public static function decodeUtf7Imap(string $string): string
+    {
+        // If the string doesn't contain any '&' character, it's not UTF-7 encoded.
+        if (! str_contains($string, '&')) {
+            return $string;
+        }
+
+        // Handle the special case of '&-' which represents '&' in UTF-7.
+        if ($string === '&-') {
+            return '&';
+        }
+
+        // Direct implementation of IMAP's modified UTF-7 decoding.
+        return preg_replace_callback('/&([^-]*)-?/', function ($matches) {
+            // If it's just an ampersand.
+            if ($matches[1] === '') {
+                return '&';
+            }
+
+            // If it's the special case for ampersand.
+            if ($matches[1] === '-') {
+                return '&';
+            }
+
+            // Convert modified base64 to standard base64.
+            $base64 = strtr($matches[1], ',', '/');
+
+            // Add padding if necessary.
+            switch (strlen($base64) % 4) {
+                case 1: $base64 .= '===';
+                    break;
+                case 2: $base64 .= '==';
+                    break;
+                case 3: $base64 .= '=';
+                    break;
+            }
+
+            // Decode base64 to binary.
+            $binary = base64_decode($base64, true);
+
+            if ($binary === false) {
+                // If decoding fails, return the original string.
+                return '&'.$matches[1].($matches[2] ?? '');
+            }
+
+            $result = '';
+
+            // Convert binary UTF-16BE to UTF-8.
+            for ($i = 0; $i < strlen($binary); $i += 2) {
+                if (isset($binary[$i + 1])) {
+                    $char = (ord($binary[$i]) << 8) | ord($binary[$i + 1]);
+
+                    if ($char < 0x80) {
+                        $result .= chr($char);
+                    } elseif ($char < 0x800) {
+                        $result .= chr(0xC0 | ($char >> 6)).chr(0x80 | ($char & 0x3F));
+                    } else {
+                        $result .= chr(0xE0 | ($char >> 12)).chr(0x80 | (($char >> 6) & 0x3F)).chr(0x80 | ($char & 0x3F));
+                    }
+                }
+            }
+
+            return $result;
+        }, $string);
+    }
 }


Test patch:
diff --git a/tests/Unit/FolderTest.php b/tests/Unit/FolderTest.php
new file mode 100644
index 0000000..81552bc
--- /dev/null
+++ b/tests/Unit/FolderTest.php
@@ -0,0 +1,52 @@
+<?php
+
+use DirectoryTree\ImapEngine\Folder;
+use DirectoryTree\ImapEngine\Mailbox;
+
+test('it properly decodes name from UTF-7', function () {
+    $mailbox = Mailbox::make();
+
+    // Create a folder with a UTF-7 encoded name.
+    $folder = new Folder(
+        mailbox: $mailbox,
+        path: '[Gmail]/&BBoEPgRABDcEOAQ9BDA-',
+        flags: ['\\HasNoChildren'],
+        delimiter: '/'
+    );
+
+    // The name should be decoded to UTF-8.
+    expect($folder->name())->toBe('Корзина');
+
+    // The path should remain as is (UTF-7 encoded).
+    expect($folder->path())->toBe('[Gmail]/&BBoEPgRABDcEOAQ9BDA-');
+});
+
+test('it preserves existing UTF-8 characters in folder names', function () {
+    $mailbox = Mailbox::make();
+
+    // Create a folder with a name that already contains UTF-8 characters.
+    $utf8FolderName = 'Привет';
+
+    $folder = new Folder(
+        mailbox: $mailbox,
+        path: '[Gmail]/'.$utf8FolderName,
+        flags: ['\\HasNoChildren'],
+        delimiter: '/'
+    );
+
+    // The name should remain unchanged
+    expect($folder->name())->toBe($utf8FolderName);
+
+    // Test with a mix of UTF-8 characters from different languages.
+    $mixedUtf8FolderName = 'Привет_你好_こんにちは';
+
+    $mixedFolder = new Folder(
+        mailbox: $mailbox,
+        path: '[Gmail]/'.$mixedUtf8FolderName,
+        flags: ['\\HasNoChildren'],
+        delimiter: '/'
+    );
+
+    // The name should remain unchanged.
+    expect($mixedFolder->name())->toBe($mixedUtf8FolderName);
+});
diff --git a/tests/Unit/Support/StrTest.php b/tests/Unit/Support/StrTest.php
index e4a1786..baa87b0 100644
--- a/tests/Unit/Support/StrTest.php
+++ b/tests/Unit/Support/StrTest.php
@@ -101,3 +101,45 @@ test('enums handles nested arrays containing backed enums and strings', function
 
     expect($result)->toEqual($expected);
 });
+
+test('decodeUtf7Imap decodes UTF-7 encoded folder names', function () {
+    // Russian Cyrillic example from the bug report.
+    $encoded = '&BBoEPgRABDcEOAQ9BDA-';
+    $decoded = 'Корзина';
+
+    expect(Str::decodeUtf7Imap($encoded))->toBe($decoded);
+});
+
+test('decodeUtf7Imap handles non-encoded strings', function () {
+    $plainString = 'INBOX';
+
+    expect(Str::decodeUtf7Imap($plainString))->toBe($plainString);
+});
+
+test('decodeUtf7Imap handles special characters', function () {
+    // Ampersand is represented as &- in UTF-7.
+    $encoded = '&-';
+    $decoded = '&';
+
+    expect(Str::decodeUtf7Imap($encoded))->toBe($decoded);
+});
+
+test('decodeUtf7Imap 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);
+});
+
+test('decodeUtf7Imap 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);
+
+    // Test with a mix of UTF-8 and regular ASCII.
+    $mixedString = 'Hello Привет 123';
+    expect(Str::decodeUtf7Imap($mixedString))->toBe($mixedString);
+});