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 @@ public function delimiter(): string */ 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 @@ use DirectoryTree\ImapEngine\Collections\FolderCollection; use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse; +use DirectoryTree\ImapEngine\Support\Str; class FolderRepository implements FolderRepositoryInterface { @@ -19,7 +20,7 @@ public function __construct( */ public function find(string $path): ?FolderInterface { - return $this->get($path)->first(); + return $this->get(Str::toImapUtf7($path))->first(); } /** @@ -27,7 +28,7 @@ public function find(string $path): ?FolderInterface */ public function findOrFail(string $path): FolderInterface { - return $this->get($path)->firstOrFail(); + return $this->get(Str::toImapUtf7($path))->firstOrFail(); } /** @@ -35,7 +36,9 @@ public function findOrFail(string $path): FolderInterface */ 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 @@ public function firstOrCreate(string $path): FolderInterface */ 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 @@ public static function escape(string $string): string /** * 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 @@ public static function decodeUtf7Imap(string $string): string }, $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. */ 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 @@ public function delimiter(): string */ 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 @@ 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); });