Skip to content

Commit d76ff18

Browse files
committed
Add toImapUtf7 method to handle encoding UTF-8 back to UTF-7
1 parent ae67c9f commit d76ff18

5 files changed

Lines changed: 105 additions & 18 deletions

File tree

src/Folder.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ public function delimiter(): string
6565
*/
6666
public function name(): string
6767
{
68-
return Str::decodeUtf7Imap(
68+
return Str::fromImapUtf7(
6969
last(explode($this->delimiter, $this->path))
7070
);
7171
}

src/FolderRepository.php

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use DirectoryTree\ImapEngine\Collections\FolderCollection;
66
use DirectoryTree\ImapEngine\Connection\Responses\UntaggedResponse;
7+
use DirectoryTree\ImapEngine\Support\Str;
78

89
class FolderRepository implements FolderRepositoryInterface
910
{
@@ -19,23 +20,25 @@ public function __construct(
1920
*/
2021
public function find(string $path): ?FolderInterface
2122
{
22-
return $this->get($path)->first();
23+
return $this->get(Str::toImapUtf7($path))->first();
2324
}
2425

2526
/**
2627
* {@inheritDoc}
2728
*/
2829
public function findOrFail(string $path): FolderInterface
2930
{
30-
return $this->get($path)->firstOrFail();
31+
return $this->get(Str::toImapUtf7($path))->firstOrFail();
3132
}
3233

3334
/**
3435
* {@inheritDoc}
3536
*/
3637
public function create(string $path): FolderInterface
3738
{
38-
$this->mailbox->connection()->create($path);
39+
$this->mailbox->connection()->create(
40+
Str::toImapUtf7($path)
41+
);
3942

4043
return $this->find($path);
4144
}
@@ -53,7 +56,7 @@ public function firstOrCreate(string $path): FolderInterface
5356
*/
5457
public function get(?string $match = '*', ?string $reference = ''): FolderCollection
5558
{
56-
return $this->mailbox->connection()->list($reference, $match)->map(
59+
return $this->mailbox->connection()->list($reference, Str::toImapUtf7($match))->map(
5760
fn (UntaggedResponse $response) => new Folder(
5861
mailbox: $this->mailbox,
5962
path: $response->tokenAt(4)->value,

src/Support/Str.php

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ public static function escape(string $string): string
134134
/**
135135
* Decode a modified UTF-7 string (IMAP specific) to UTF-8.
136136
*/
137-
public static function decodeUtf7Imap(string $string): string
137+
public static function fromImapUtf7(string $string): string
138138
{
139139
// If the string doesn't contain any '&' character, it's not UTF-7 encoded.
140140
if (! str_contains($string, '&')) {
@@ -200,6 +200,62 @@ public static function decodeUtf7Imap(string $string): string
200200
}, $string);
201201
}
202202

203+
/**
204+
* Encode a UTF-8 string to modified UTF-7 (IMAP specific).
205+
*/
206+
public static function toImapUtf7(string $string): string
207+
{
208+
$result = '';
209+
$buffer = '';
210+
211+
// Iterate over each character in the UTF-8 string.
212+
for ($i = 0; $i < mb_strlen($string, 'UTF-8'); $i++) {
213+
$char = mb_substr($string, $i, 1, 'UTF-8');
214+
215+
// Convert character to its UTF-16BE code unit (for deciding if ASCII).
216+
$ord = unpack('n', mb_convert_encoding($char, 'UTF-16BE', 'UTF-8'))[1];
217+
218+
// Handle printable ASCII characters (0x20 - 0x7E) except '&'
219+
if ($ord >= 0x20 && $ord <= 0x7E && $char !== '&') {
220+
// If there is any buffered non-ASCII content, flush it as a base64 section.
221+
if ($buffer !== '') {
222+
// Encode the buffer to UTF-16BE, then to base64, swap '/' for ',', trim '=' padding, and wrap with '&' and '-'.
223+
$result .= '&'.rtrim(strtr(base64_encode(mb_convert_encoding($buffer, 'UTF-16BE', 'UTF-8')), '/', ','), '=').'-';
224+
$buffer = '';
225+
}
226+
227+
// Append the ASCII character as-is.
228+
$result .= $char;
229+
230+
continue;
231+
}
232+
233+
// Special handling for literal '&' which becomes '&-'
234+
if ($char === '&') {
235+
// Flush any buffered non-ASCII content first.
236+
if ($buffer !== '') {
237+
$result .= '&'.rtrim(strtr(base64_encode(mb_convert_encoding($buffer, 'UTF-16BE', 'UTF-8')), '/', ','), '=').'-';
238+
$buffer = '';
239+
}
240+
241+
// '&' is encoded as '&-'
242+
$result .= '&-';
243+
244+
continue;
245+
}
246+
247+
// Buffer non-ASCII characters for later base64 encoding.
248+
$buffer .= $char;
249+
}
250+
251+
// After the loop, flush any remaining buffered non-ASCII content.
252+
if ($buffer !== '') {
253+
$result .= '&'.rtrim(strtr(base64_encode(mb_convert_encoding($buffer, 'UTF-16BE', 'UTF-8')), '/', ','), '=').'-';
254+
}
255+
256+
return $result;
257+
}
258+
203259
/**
204260
* Determine if a given string matches a given pattern.
205261
*/

src/Testing/FakeFolder.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ public function delimiter(): string
6262
*/
6363
public function name(): string
6464
{
65-
return Str::decodeUtf7Imap(
65+
return Str::fromImapUtf7(
6666
last(explode($this->delimiter, $this->path))
6767
);
6868
}

tests/Unit/Support/StrTest.php

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -102,44 +102,72 @@
102102
expect($result)->toEqual($expected);
103103
});
104104

105-
test('decodeUtf7Imap decodes UTF-7 encoded folder names', function () {
105+
test('fromImapUtf7 decodes UTF-7 encoded folder names', function () {
106106
// Russian Cyrillic example from the bug report.
107107
$encoded = '&BBoEPgRABDcEOAQ9BDA-';
108108
$decoded = 'Корзина';
109109

110-
expect(Str::decodeUtf7Imap($encoded))->toBe($decoded);
110+
expect(Str::fromImapUtf7($encoded))->toBe($decoded);
111111
});
112112

113-
test('decodeUtf7Imap handles non-encoded strings', function () {
113+
test('fromImapUtf7 handles non-encoded strings', function () {
114114
$plainString = 'INBOX';
115115

116-
expect(Str::decodeUtf7Imap($plainString))->toBe($plainString);
116+
expect(Str::fromImapUtf7($plainString))->toBe($plainString);
117117
});
118118

119-
test('decodeUtf7Imap handles special characters', function () {
119+
test('fromImapUtf7 handles special characters', function () {
120120
// Ampersand is represented as &- in UTF-7.
121121
$encoded = '&-';
122122
$decoded = '&';
123123

124-
expect(Str::decodeUtf7Imap($encoded))->toBe($decoded);
124+
expect(Str::fromImapUtf7($encoded))->toBe($decoded);
125125
});
126126

127-
test('decodeUtf7Imap handles mixed content', function () {
127+
test('fromImapUtf7 handles mixed content', function () {
128128
// Test that the function doesn't modify the non-encoded part.
129129
$encoded = 'Hello &-';
130130
$decoded = 'Hello &';
131131

132-
expect(Str::decodeUtf7Imap($encoded))->toBe($decoded);
132+
expect(Str::fromImapUtf7($encoded))->toBe($decoded);
133133
});
134134

135-
test('decodeUtf7Imap preserves existing UTF-8 characters', function () {
135+
test('fromImapUtf7 preserves existing UTF-8 characters', function () {
136136
// Test with various UTF-8 characters that should remain unchanged.
137137
$utf8String = 'Привет мир 你好 こんにちは ñáéíóú';
138138

139139
// The function should return the string unchanged since it's already UTF-8.
140-
expect(Str::decodeUtf7Imap($utf8String))->toBe($utf8String);
140+
expect(Str::fromImapUtf7($utf8String))->toBe($utf8String);
141141

142142
// Test with a mix of UTF-8 and regular ASCII.
143143
$mixedString = 'Hello Привет 123';
144-
expect(Str::decodeUtf7Imap($mixedString))->toBe($mixedString);
144+
expect(Str::fromImapUtf7($mixedString))->toBe($mixedString);
145+
});
146+
147+
test('toImapUtf7 encodes plain ASCII as-is', function () {
148+
$input = 'Inbox';
149+
$expected = 'Inbox';
150+
151+
expect(Str::toImapUtf7($input))->toBe($expected);
152+
});
153+
154+
test('toImapUtf7 encodes ampersand correctly', function () {
155+
$input = 'Inbox & Archive';
156+
$expected = 'Inbox &- Archive';
157+
158+
expect(Str::toImapUtf7($input))->toBe($expected);
159+
});
160+
161+
test('toImapUtf7 encodes non-ASCII characters', function () {
162+
$input = 'Корзина'; // Russian for "Trash"
163+
$expected = '&BBoEPgRABDcEOAQ9BDA-';
164+
165+
expect(Str::toImapUtf7($input))->toBe($expected);
166+
});
167+
168+
test('toImapUtf7 encodes mixed content correctly', function () {
169+
$input = 'Work Корзина & Stuff';
170+
$expected = 'Work &BBoEPgRABDcEOAQ9BDA- &- Stuff';
171+
172+
expect(Str::toImapUtf7($input))->toBe($expected);
145173
});

0 commit comments

Comments
 (0)