Skip to content

Commit 6f9544a

Browse files
authored
Merge pull request #7 from opcodesio/nested-multipart-fix
fix for nested parts
2 parents 8949a27 + 00837be commit 6f9544a

4 files changed

Lines changed: 183 additions & 0 deletions

File tree

src/Message.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,30 @@ protected function parse(): void
182182

183183
$this->addPart($rawPart);
184184
}
185+
186+
// Flatten any nested multipart parts into their leaf sub-parts
187+
$this->parts = $this->flattenParts($this->parts);
188+
}
189+
190+
/**
191+
* Recursively replace multipart parts with their leaf sub-parts.
192+
*
193+
* @param MessagePart[] $parts
194+
* @return MessagePart[]
195+
*/
196+
protected function flattenParts(array $parts): array
197+
{
198+
$result = [];
199+
200+
foreach ($parts as $part) {
201+
if ($part->isMultipart()) {
202+
$result = array_merge($result, $part->getSubParts());
203+
} else {
204+
$result[] = $part;
205+
}
206+
}
207+
208+
return $result;
185209
}
186210

187211
protected function addPart(string $rawMessage): MessagePart

src/MessagePart.php

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ class MessagePart implements \JsonSerializable
1010

1111
protected string $content;
1212

13+
/**
14+
* @var MessagePart[]
15+
*/
16+
protected array $subParts = [];
17+
1318
public function __construct(string $message)
1419
{
1520
$this->rawMessage = $message;
@@ -32,6 +37,59 @@ protected function parse(): void
3237
// No headers, just content
3338
$this->content = trim($this->rawMessage);
3439
}
40+
41+
// If this part is multipart/*, recursively parse sub-parts
42+
if ($this->isMultipart()) {
43+
$boundary = $this->extractBoundary();
44+
45+
if ($boundary !== null) {
46+
$parts = preg_split("/--" . preg_quote($boundary, '/') . "(?:--|(?:\r\n|$))/", $this->content);
47+
48+
foreach ($parts as $rawPart) {
49+
if (empty(trim($rawPart))) {
50+
continue;
51+
}
52+
53+
$this->subParts[] = new self($rawPart);
54+
}
55+
}
56+
}
57+
}
58+
59+
public function isMultipart(): bool
60+
{
61+
return str_starts_with(strtolower($this->getContentType()), 'multipart/');
62+
}
63+
64+
/**
65+
* Get all leaf (non-multipart) sub-parts, flattened recursively.
66+
*
67+
* @return MessagePart[]
68+
*/
69+
public function getSubParts(): array
70+
{
71+
$result = [];
72+
73+
foreach ($this->subParts as $subPart) {
74+
if ($subPart->isMultipart()) {
75+
$result = array_merge($result, $subPart->getSubParts());
76+
} else {
77+
$result[] = $subPart;
78+
}
79+
}
80+
81+
return $result;
82+
}
83+
84+
protected function extractBoundary(): ?string
85+
{
86+
$contentType = $this->getContentType();
87+
88+
if (preg_match('/boundary="?([^";\r\n]+)"?/', $contentType, $matches)) {
89+
return $matches[1];
90+
}
91+
92+
return null;
3593
}
3694

3795
public function getContentType(): string
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
From: Acme Corp <sender@acme.test>
2+
To: Jane Doe <jane@example.test>
3+
Reply-To: Jane Doe <jane@example.test>
4+
Subject: Order Confirmation for Jane Doe
5+
MIME-Version: 1.0
6+
Date: Tue, 15 Apr 2025 10:30:00 +0000
7+
Message-ID: <a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6@acme.test>
8+
Content-Type: multipart/mixed; boundary=Xq3mB9fZ
9+
10+
--Xq3mB9fZ
11+
Content-Type: multipart/alternative; boundary=Kp7jR2wL
12+
13+
--Kp7jR2wL
14+
Content-Type: text/plain; charset=utf-8
15+
Content-Transfer-Encoding: quoted-printable
16+
17+
Your order has been confirmed.
18+
19+
Thank you for shopping with Acme Corp!
20+
21+
--Kp7jR2wL
22+
Content-Type: text/html; charset=utf-8
23+
Content-Transfer-Encoding: quoted-printable
24+
25+
<html>
26+
<body>
27+
<h1>Your order has been confirmed.</h1>
28+
<p>Thank you for shopping with Acme Corp!</p>
29+
</body>
30+
</html>
31+
--Kp7jR2wL--
32+
33+
--Xq3mB9fZ
34+
Content-Type: application/pdf;
35+
name="receipt.pdf"
36+
Content-Transfer-Encoding: base64
37+
Content-Disposition: attachment;
38+
name="receipt.pdf";
39+
filename="receipt.pdf"
40+
41+
dGVzdCBwZGYgY29udGVudCAxMjM=
42+
--Xq3mB9fZ
43+
Content-Type: application/pdf;
44+
name="invoice.pdf"
45+
Content-Transfer-Encoding: base64
46+
Content-Disposition: attachment;
47+
name="invoice.pdf";
48+
filename="invoice.pdf"
49+
50+
dGVzdCBwZGYgY29udGVudCA0NTY=
51+
--Xq3mB9fZ
52+
Content-Type: application/pdf;
53+
name="terms.pdf"
54+
Content-Transfer-Encoding: base64
55+
Content-Disposition: attachment;
56+
name="terms.pdf";
57+
filename="terms.pdf"
58+
59+
dGVzdCBwZGYgY29udGVudCA3ODk=
60+
--Xq3mB9fZ--

tests/Unit/MessageTest.php

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,47 @@
348348
->and($message->getParts()[1]->getContent())->toBe('This is a test string');
349349
});
350350

351+
it('can parse a nested multipart email with alternatives and attachments', function () {
352+
$message = Message::fromFile(__DIR__ . '/../Fixtures/multiformat_email_2.eml');
353+
354+
expect($message->getFrom())->toBe('Acme Corp <sender@acme.test>')
355+
->and($message->getTo())->toBe('Jane Doe <jane@example.test>')
356+
->and($message->getReplyTo())->toBe('Jane Doe <jane@example.test>')
357+
->and($message->getSubject())->toBe('Order Confirmation for Jane Doe')
358+
->and($message->getId())->toBe('a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6@acme.test')
359+
->and($message->getDate()->format('Y-m-d H:i:s'))->toBe('2025-04-15 10:30:00')
360+
->and($message->getBoundary())->toBe('Xq3mB9fZ');
361+
362+
// The nested multipart/alternative should be flattened into its leaf parts,
363+
// so we expect: text/plain, text/html, and 3 PDF attachments = 5 parts
364+
$parts = $message->getParts();
365+
expect($parts)->toHaveCount(5);
366+
367+
// Text part from the nested multipart/alternative
368+
$textPart = $message->getTextPart();
369+
expect($textPart)->not->toBeNull()
370+
->and($textPart->getContentType())->toBe('text/plain; charset=utf-8')
371+
->and($textPart->getContent())->toContain('Your order has been confirmed.')
372+
->and($textPart->getContent())->toContain('Thank you for shopping with Acme Corp!');
373+
374+
// HTML part from the nested multipart/alternative
375+
$htmlPart = $message->getHtmlPart();
376+
expect($htmlPart)->not->toBeNull()
377+
->and($htmlPart->getContentType())->toBe('text/html; charset=utf-8')
378+
->and($htmlPart->getContent())->toContain('Your order has been confirmed.')
379+
->and($htmlPart->getContent())->toContain('</html>');
380+
381+
// 3 PDF attachments
382+
$attachments = $message->getAttachments();
383+
expect($attachments)->toHaveCount(3)
384+
->and($attachments[0]->getFilename())->toBe('receipt.pdf')
385+
->and($attachments[0]->isAttachment())->toBeTrue()
386+
->and($attachments[1]->getFilename())->toBe('invoice.pdf')
387+
->and($attachments[1]->isAttachment())->toBeTrue()
388+
->and($attachments[2]->getFilename())->toBe('terms.pdf')
389+
->and($attachments[2]->isAttachment())->toBeTrue();
390+
});
391+
351392
it('still parses with a broken boundary', function () {
352393
$messageString = <<<EOF
353394
From: sender@example.com

0 commit comments

Comments
 (0)