Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/BodyStructureCollection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
18 changes: 13 additions & 5 deletions src/BodyStructurePart.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
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 DirectoryTree\ImapEngine\Support\Str;
use Illuminate\Contracts\Support\Arrayable;
use JsonSerializable;

Expand Down Expand Up @@ -45,9 +47,13 @@ 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),
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),
Expand All @@ -62,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;
Expand All @@ -80,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;
}

/**
Expand Down Expand Up @@ -184,7 +192,7 @@ public function disposition(): ?ContentDisposition
*/
public function filename(): ?string
{
return $this->disposition?->filename() ?? $this->parameters['name'] ?? null;
return $this->disposition?->filename() ?? $this->parameter('name');
}

/**
Expand Down
5 changes: 3 additions & 2 deletions src/ContentDisposition.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -82,7 +83,7 @@ public function parameter(string $name): ?string
*/
public function filename(): ?string
{
return $this->parameters['filename'] ?? null;
return $this->parameter('filename');
}

/**
Expand Down
59 changes: 59 additions & 0 deletions src/Support/MimeParameterParser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

namespace DirectoryTree\ImapEngine\Support;

use ZBateson\MailMimeParser\Header\ParameterHeader;

class MimeParameterParser
{
/**
* Parse and normalize MIME parameters.
*
* @param array<string, string> $parameters
* @return array<string, string>
*/
public static function parse(array $parameters): array
{
if (empty($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;
}

if (! is_null($value = $header->getValueFor($name))) {
$parsed[$name] = $value;
}
}

return $parsed;
}

/**
* Convert parameter values into MIME parameter syntax.
*
* @param array<string, string> $parameters
* @return string[]
*/
protected static function stringify(array $parameters): array
{
$values = [];

foreach ($parameters as $name => $value) {
$values[] = sprintf('%s="%s"', $name, Str::escape($value));
}

return $values;
}
}
4 changes: 4 additions & 0 deletions src/Support/Str.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
54 changes: 54 additions & 0 deletions tests/Unit/BodyStructureTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down Expand Up @@ -84,6 +107,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)'
Expand Down
77 changes: 77 additions & 0 deletions tests/Unit/Support/MimeParameterParserTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php

use DirectoryTree\ImapEngine\Support\MimeParameterParser;

test('it parses regular parameters', function () {
$parameters = MimeParameterParser::parse([
'charset' => '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',
]);
});
Loading