From 2569d18a86779a69807778dcc2c8e70289f0679a Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Thu, 16 Jul 2026 12:00:50 -0400 Subject: [PATCH 1/6] Decode RFC 2231 MIME parameters --- src/BodyStructureCollection.php | 3 +- src/BodyStructurePart.php | 7 +- src/ContentDisposition.php | 5 +- src/Support/MimeParameterParser.php | 69 +++++++++++++++++ tests/Unit/BodyStructureTest.php | 31 ++++++++ .../Unit/Support/MimeParameterParserTest.php | 77 +++++++++++++++++++ 6 files changed, 187 insertions(+), 5 deletions(-) create mode 100644 src/Support/MimeParameterParser.php create mode 100644 tests/Unit/Support/MimeParameterParserTest.php diff --git a/src/BodyStructureCollection.php b/src/BodyStructureCollection.php index 5cac88d..a607b13 100644 --- a/src/BodyStructureCollection.php +++ b/src/BodyStructureCollection.php @@ -6,6 +6,7 @@ use DirectoryTree\ImapEngine\Connection\Responses\Data\ListData; use DirectoryTree\ImapEngine\Connection\Tokens\Nil; use DirectoryTree\ImapEngine\Connection\Tokens\Token; +use DirectoryTree\ImapEngine\Support\MimeParameterParser; use Illuminate\Contracts\Support\Arrayable; use IteratorAggregate; use JsonSerializable; @@ -63,7 +64,7 @@ public static function fromListData(ListData $data, ?string $partNumber = null): if ($subtypeIndex) { foreach (array_slice($tokens, $subtypeIndex + 1) as $token) { if ($token instanceof ListData && ! static::isDispositionList($token)) { - $parameters = $token->toKeyValuePairs(); + $parameters = MimeParameterParser::parse($token->toKeyValuePairs()); break; } diff --git a/src/BodyStructurePart.php b/src/BodyStructurePart.php index ba7e660..0d9c067 100644 --- a/src/BodyStructurePart.php +++ b/src/BodyStructurePart.php @@ -5,6 +5,7 @@ use DirectoryTree\ImapEngine\Connection\Responses\Data\ListData; use DirectoryTree\ImapEngine\Connection\Tokens\Nil; use DirectoryTree\ImapEngine\Connection\Tokens\Token; +use DirectoryTree\ImapEngine\Support\MimeParameterParser; use Illuminate\Contracts\Support\Arrayable; use JsonSerializable; @@ -45,7 +46,9 @@ protected static function parse(array $tokens, string $partNumber): static partNumber: $partNumber, type: strtolower(static::tokenValueAt($tokens, 0) ?? 'text'), subtype: strtolower(static::tokenValueAt($tokens, 1) ?? 'plain'), - parameters: isset($tokens[2]) && $tokens[2] instanceof ListData ? $tokens[2]->toKeyValuePairs() : [], + parameters: isset($tokens[2]) && $tokens[2] instanceof ListData + ? MimeParameterParser::parse($tokens[2]->toKeyValuePairs()) + : [], id: static::tokenValueAt($tokens, 3), description: static::tokenValueAt($tokens, 4), encoding: static::tokenValueAt($tokens, 5), @@ -184,7 +187,7 @@ public function disposition(): ?ContentDisposition */ public function filename(): ?string { - return $this->disposition?->filename() ?? $this->parameters['name'] ?? null; + return $this->disposition?->filename() ?? $this->parameter('name'); } /** diff --git a/src/ContentDisposition.php b/src/ContentDisposition.php index e5fe820..241d4a8 100644 --- a/src/ContentDisposition.php +++ b/src/ContentDisposition.php @@ -5,6 +5,7 @@ use DirectoryTree\ImapEngine\Connection\Responses\Data\ListData; use DirectoryTree\ImapEngine\Connection\Tokens\Token; use DirectoryTree\ImapEngine\Enums\ContentDispositionType; +use DirectoryTree\ImapEngine\Support\MimeParameterParser; use Illuminate\Contracts\Support\Arrayable; use JsonSerializable; @@ -44,7 +45,7 @@ public static function parse(array $tokens): ?static } $parameters = isset($innerTokens[1]) && $innerTokens[1] instanceof ListData - ? $innerTokens[1]->toKeyValuePairs() + ? MimeParameterParser::parse($innerTokens[1]->toKeyValuePairs()) : []; return new self($type, $parameters); @@ -82,7 +83,7 @@ public function parameter(string $name): ?string */ public function filename(): ?string { - return $this->parameters['filename'] ?? null; + return $this->parameter('filename'); } /** diff --git a/src/Support/MimeParameterParser.php b/src/Support/MimeParameterParser.php new file mode 100644 index 0000000..ddc2b8b --- /dev/null +++ b/src/Support/MimeParameterParser.php @@ -0,0 +1,69 @@ + $parameters + * @return array + */ + public static function parse(array $parameters): array + { + if ($parameters === []) { + return []; + } + + $header = new ParameterHeader( + 'Content-Type', + 'application/octet-stream; '.implode('; ', static::stringify($parameters)) + ); + + $parsed = []; + + foreach (array_keys($parameters) as $name) { + $name = strtolower(explode('*', $name, 2)[0]); + + if (array_key_exists($name, $parsed)) { + continue; + } + + $value = $header->getValueFor($name); + + if ($value !== null) { + $parsed[$name] = $value; + } + } + + return $parsed; + } + + /** + * Convert parameter values into MIME parameter syntax. + * + * @param array $parameters + * @return string[] + */ + protected static function stringify(array $parameters): array + { + $values = []; + + foreach ($parameters as $name => $value) { + $values[] = sprintf('%s="%s"', $name, static::escape($value)); + } + + return $values; + } + + /** + * Escape a MIME quoted-string value. + */ + protected static function escape(string $value): string + { + return str_replace(['\\', '"'], ['\\\\', '\\"'], $value); + } +} diff --git a/tests/Unit/BodyStructureTest.php b/tests/Unit/BodyStructureTest.php index d2f8aea..dd1cadb 100644 --- a/tests/Unit/BodyStructureTest.php +++ b/tests/Unit/BodyStructureTest.php @@ -84,6 +84,37 @@ expect($attachments[0]->contentType())->toBe('application/pdf'); }); +test('it decodes continued attachment filenames', function () { + $listData = parseBodyStructureResponse( + '* 1 FETCH (BODYSTRUCTURE (("text" "plain" ("charset" "utf-8") NIL NIL "7bit" 100 5 NIL NIL NIL) ("application" "pdf" ("name*1" "attachment_name_part_1.pdf" "name*0" "attachment_name_part_0") NIL NIL "base64" 5000 NIL ("attachment" ("filename*1" "attachment_name_part_1.pdf" "filename*0" "attachment_name_part_0")) NIL NIL) "mixed" ("boundary" "abc") NIL NIL) UID 1)' + ); + + $collection = BodyStructureCollection::fromListData($listData); + $attachment = $collection->attachments()[0]; + + expect($attachment->filename())->toBe('attachment_name_part_0attachment_name_part_1.pdf'); + expect($attachment->parameters())->toBe([ + 'name' => 'attachment_name_part_0attachment_name_part_1.pdf', + ]); + expect($attachment->disposition()?->parameters())->toBe([ + 'filename' => 'attachment_name_part_0attachment_name_part_1.pdf', + ]); +}); + +test('it decodes extended content type names when no disposition is present', function () { + $listData = parseBodyStructureResponse( + '* 1 FETCH (BODYSTRUCTURE (("text" "plain" ("charset" "utf-8") NIL NIL "7bit" 100 5 NIL NIL NIL) ("application" "pdf" ("name*1" "2026.pdf" "name*0*" "utf-8\'\'invoice%20") NIL NIL "base64" 5000 NIL NIL NIL NIL) "mixed" ("boundary" "abc") NIL NIL) UID 1)' + ); + + $collection = BodyStructureCollection::fromListData($listData); + $attachment = $collection->attachments()[0]; + + expect($attachment->filename())->toBe('invoice 2026.pdf'); + expect($attachment->parameters())->toBe([ + 'name' => 'invoice 2026.pdf', + ]); +}); + test('it converts BodyStructurePart to array', function () { $listData = parseBodyStructureResponse( '* 1 FETCH (BODYSTRUCTURE ("text" "plain" ("charset" "utf-8") NIL NIL "7bit" 100 5 NIL NIL NIL) UID 1)' diff --git a/tests/Unit/Support/MimeParameterParserTest.php b/tests/Unit/Support/MimeParameterParserTest.php new file mode 100644 index 0000000..08f85e5 --- /dev/null +++ b/tests/Unit/Support/MimeParameterParserTest.php @@ -0,0 +1,77 @@ + 'utf-8', + 'name' => 'document.pdf', + ]); + + expect($parameters)->toBe([ + 'charset' => 'utf-8', + 'name' => 'document.pdf', + ]); +}); + +test('it combines RFC 2231 parameter continuations in numerical order', function () { + $parameters = MimeParameterParser::parse([ + 'filename*1' => 'attachment_name_part_1.pdf', + 'filename*0' => 'attachment_name_part_0', + ]); + + expect($parameters)->toBe([ + 'filename' => 'attachment_name_part_0attachment_name_part_1.pdf', + ]); +}); + +test('it decodes RFC 2231 extended parameter continuations', function () { + $parameters = MimeParameterParser::parse([ + 'filename*1*' => '2026.pdf', + 'filename*0*' => "utf-8''invoice%20", + ]); + + expect($parameters)->toBe([ + 'filename' => 'invoice 2026.pdf', + ]); +}); + +test('it decodes standalone RFC 2231 extended parameters', function () { + $parameters = MimeParameterParser::parse([ + 'filename*' => "utf-8''invoice%202026.pdf", + ]); + + expect($parameters)->toBe([ + 'filename' => 'invoice 2026.pdf', + ]); +}); + +test('it converts RFC 2231 parameter values to UTF-8', function () { + $parameters = MimeParameterParser::parse([ + 'filename*' => "iso-8859-1''f%FCr%20Zustellungen.pdf", + ]); + + expect($parameters)->toBe([ + 'filename' => 'für Zustellungen.pdf', + ]); +}); + +test('it decodes MIME encoded parameter values for compatibility', function () { + $parameters = MimeParameterParser::parse([ + 'filename' => '=?iso-8859-1?Q?f=FCr_Zustellungen.pdf?=', + ]); + + expect($parameters)->toBe([ + 'filename' => 'für Zustellungen.pdf', + ]); +}); + +test('it preserves MIME quoted-string characters', function () { + $parameters = MimeParameterParser::parse([ + 'filename' => 'invoice "draft"\\document.pdf', + ]); + + expect($parameters)->toBe([ + 'filename' => 'invoice "draft"\\document.pdf', + ]); +}); From 4cd344e95bf5d11848be94bf0e0c7009c9ac05ce Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Thu, 16 Jul 2026 13:02:02 -0400 Subject: [PATCH 2/6] Decode MIME content descriptions --- src/BodyStructurePart.php | 5 ++++- tests/Unit/BodyStructureTest.php | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/BodyStructurePart.php b/src/BodyStructurePart.php index 0d9c067..201763f 100644 --- a/src/BodyStructurePart.php +++ b/src/BodyStructurePart.php @@ -6,6 +6,7 @@ use DirectoryTree\ImapEngine\Connection\Tokens\Nil; use DirectoryTree\ImapEngine\Connection\Tokens\Token; use DirectoryTree\ImapEngine\Support\MimeParameterParser; +use DirectoryTree\ImapEngine\Support\Str; use Illuminate\Contracts\Support\Arrayable; use JsonSerializable; @@ -42,6 +43,8 @@ public static function fromListData(ListData $data, string $partNumber = '1'): s */ protected static function parse(array $tokens, string $partNumber): static { + $description = static::tokenValueAt($tokens, 4); + return new static( partNumber: $partNumber, type: strtolower(static::tokenValueAt($tokens, 0) ?? 'text'), @@ -50,7 +53,7 @@ protected static function parse(array $tokens, string $partNumber): static ? MimeParameterParser::parse($tokens[2]->toKeyValuePairs()) : [], id: static::tokenValueAt($tokens, 3), - description: static::tokenValueAt($tokens, 4), + description: $description === null ? null : Str::decodeMimeHeader($description), encoding: static::tokenValueAt($tokens, 5), size: static::tokenIntValueAt($tokens, 6), lines: static::tokenIntValueAt($tokens, 7), diff --git a/tests/Unit/BodyStructureTest.php b/tests/Unit/BodyStructureTest.php index dd1cadb..7ccc76d 100644 --- a/tests/Unit/BodyStructureTest.php +++ b/tests/Unit/BodyStructureTest.php @@ -17,6 +17,29 @@ expect($part->size())->toBe(100); expect($part->lines())->toBe(5); expect($part->partNumber())->toBe('1'); + expect($part->description())->toBeNull(); +}); + +test('it preserves plain content descriptions', function () { + $listData = parseBodyStructureResponse( + '* 1 FETCH (BODYSTRUCTURE ("application" "pdf" NIL NIL "A PDF invoice" "base64" 5000 NIL NIL NIL NIL) UID 1)' + ); + + $part = BodyStructurePart::fromListData($listData); + + expect($part->description())->toBe('A PDF invoice'); + expect($part->toArray()['description'])->toBe('A PDF invoice'); +}); + +test('it decodes MIME encoded content descriptions', function () { + $listData = parseBodyStructureResponse( + '* 1 FETCH (BODYSTRUCTURE ("application" "pdf" NIL NIL "=?iso-8859-1?Q?123456_-_von_Beispiel_GmbH_vom_01.01.2025_Pauschale?= =?iso-8859-1?Q?_f=FCr_Zustellungen=5F.pdf?=" "base64" 5000 NIL NIL NIL NIL) UID 1)' + ); + + $part = BodyStructurePart::fromListData($listData); + + expect($part->description())->toBe('123456 - von Beispiel GmbH vom 01.01.2025 Pauschale für Zustellungen_.pdf'); + expect($part->toArray()['description'])->toBe('123456 - von Beispiel GmbH vom 01.01.2025 Pauschale für Zustellungen_.pdf'); }); test('it parses a multipart/alternative message as BodyStructureCollection', function () { From c9b2b1ff27bd66be625ac98da4487181b7398598 Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Thu, 16 Jul 2026 13:05:36 -0400 Subject: [PATCH 3/6] Use Str support --- src/Support/MimeParameterParser.php | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/Support/MimeParameterParser.php b/src/Support/MimeParameterParser.php index ddc2b8b..a8315f7 100644 --- a/src/Support/MimeParameterParser.php +++ b/src/Support/MimeParameterParser.php @@ -53,17 +53,9 @@ protected static function stringify(array $parameters): array $values = []; foreach ($parameters as $name => $value) { - $values[] = sprintf('%s="%s"', $name, static::escape($value)); + $values[] = sprintf('%s="%s"', $name, Str::escape($value)); } return $values; } - - /** - * Escape a MIME quoted-string value. - */ - protected static function escape(string $value): string - { - return str_replace(['\\', '"'], ['\\\\', '\\"'], $value); - } } From 495ba367abf752537509b33d797d7772c4431580 Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Thu, 16 Jul 2026 13:06:43 -0400 Subject: [PATCH 4/6] Small clean up --- src/Support/MimeParameterParser.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Support/MimeParameterParser.php b/src/Support/MimeParameterParser.php index a8315f7..ca9c8b2 100644 --- a/src/Support/MimeParameterParser.php +++ b/src/Support/MimeParameterParser.php @@ -14,7 +14,7 @@ class MimeParameterParser */ public static function parse(array $parameters): array { - if ($parameters === []) { + if (empty($parameters)) { return []; } @@ -32,9 +32,7 @@ public static function parse(array $parameters): array continue; } - $value = $header->getValueFor($name); - - if ($value !== null) { + if (! is_null($value = $header->getValueFor($name))) { $parsed[$name] = $value; } } From a615ef97bc564d76a7f8a537a0ec9fa1099b57de Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Thu, 16 Jul 2026 13:12:10 -0400 Subject: [PATCH 5/6] Small cleanup --- src/BodyStructurePart.php | 4 +--- src/Support/Str.php | 4 ++++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/BodyStructurePart.php b/src/BodyStructurePart.php index 201763f..eb19c48 100644 --- a/src/BodyStructurePart.php +++ b/src/BodyStructurePart.php @@ -43,8 +43,6 @@ public static function fromListData(ListData $data, string $partNumber = '1'): s */ protected static function parse(array $tokens, string $partNumber): static { - $description = static::tokenValueAt($tokens, 4); - return new static( partNumber: $partNumber, type: strtolower(static::tokenValueAt($tokens, 0) ?? 'text'), @@ -53,7 +51,7 @@ protected static function parse(array $tokens, string $partNumber): static ? MimeParameterParser::parse($tokens[2]->toKeyValuePairs()) : [], id: static::tokenValueAt($tokens, 3), - description: $description === null ? null : Str::decodeMimeHeader($description), + description: Str::decodeMimeHeader(static::tokenValueAt($tokens, 4) ?? ''), encoding: static::tokenValueAt($tokens, 5), size: static::tokenIntValueAt($tokens, 6), lines: static::tokenIntValueAt($tokens, 7), diff --git a/src/Support/Str.php b/src/Support/Str.php index 1ef257c..b33f62c 100644 --- a/src/Support/Str.php +++ b/src/Support/Str.php @@ -300,6 +300,10 @@ public static function is(array|string $pattern, string $value, bool $ignoreCase */ public static function decodeMimeHeader(string $value): string { + if (empty($value)) { + return $value; + } + if (! str_contains($value, '=?')) { return $value; } From 395970f5d16c3a0bcdc636672bad729b77895e05 Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Thu, 16 Jul 2026 13:56:04 -0400 Subject: [PATCH 6/6] Update BodyStructurePart.php --- src/BodyStructurePart.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/BodyStructurePart.php b/src/BodyStructurePart.php index eb19c48..0b84e3c 100644 --- a/src/BodyStructurePart.php +++ b/src/BodyStructurePart.php @@ -51,7 +51,9 @@ protected static function parse(array $tokens, string $partNumber): static ? MimeParameterParser::parse($tokens[2]->toKeyValuePairs()) : [], id: static::tokenValueAt($tokens, 3), - description: Str::decodeMimeHeader(static::tokenValueAt($tokens, 4) ?? ''), + description: is_null($description = static::tokenValueAt($tokens, 4)) + ? null + : Str::decodeMimeHeader($description), encoding: static::tokenValueAt($tokens, 5), size: static::tokenIntValueAt($tokens, 6), lines: static::tokenIntValueAt($tokens, 7), @@ -66,7 +68,9 @@ protected static function parse(array $tokens, string $partNumber): static */ protected static function tokenValueAt(array $tokens, int $index): ?string { - $token = $tokens[$index] ?? null; + if (is_null($token = $tokens[$index] ?? null)) { + return null; + } if (! $token instanceof Token || $token instanceof Nil) { return null; @@ -84,7 +88,7 @@ protected static function tokenIntValueAt(array $tokens, int $index): ?int { $value = static::tokenValueAt($tokens, $index); - return $value === null ? null : (int) $value; + return is_null($value) ? null : (int) $value; } /**