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
16 changes: 12 additions & 4 deletions src/HasParsedMessage.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use ZBateson\MailMimeParser\Header\Part\ContainerPart;
use ZBateson\MailMimeParser\Header\Part\NameValuePart;
use ZBateson\MailMimeParser\Message as MailMimeMessage;
use ZBateson\MailMimeParser\Message\IMessagePart;

trait HasParsedMessage
{
Expand Down Expand Up @@ -121,10 +122,7 @@ public function attachments(): array
$attachments = [];

foreach ($this->parse()->getAllAttachmentParts() as $part) {
// If the attachment's content type is message/rfc822, we're
// working with a forwarded message. We will parse the
// forwarded message and merge in its attachments.
if (strtolower($part->getContentType()) === 'message/rfc822') {
if ($this->isForwardedMessage($part)) {
$message = new FileMessage($part->getContent());

$attachments = array_merge($attachments, $message->attachments());
Expand Down Expand Up @@ -157,6 +155,16 @@ public function attachmentCount(): int
return $this->parse()->getAttachmentCount();
}

/**
* Determine if the attachment should be treated as an embedded forwarded message.
*/
protected function isForwardedMessage(IMessagePart $part): bool
{
return empty($part->getFilename())
&& strtolower((string) $part->getContentType()) === 'message/rfc822'
&& strtolower((string) $part->getContentDisposition()) !== 'attachment';
}

/**
* Get addresses from the given header.
*
Expand Down
80 changes: 76 additions & 4 deletions tests/Unit/FileMessageTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@
expect($attachments[0]->filename())->toBe('inline_image.png');
});

test('it can extract attachments from forwarded messages', function () {
test('it merges attachments from an inline forwarded message', function () {
// Create a forwarded message that contains an attachment
$forwardedMessage = <<<'EOT'
From: "Original Sender" <[email protected]>
Expand All @@ -243,7 +243,7 @@
--ORIGINAL_BOUNDARY--
EOT;

// Create the main message that forwards the above message
// Create the main message that forwards the above message inline (no filename/disposition on message/rfc822)
$contents = <<<EOT
From: "Forwarder" <[email protected]>
To: "Final Recipient" <[email protected]>
Expand All @@ -259,8 +259,7 @@
Here is the forwarded message with its attachment.

--FORWARD_BOUNDARY
Content-Type: message/rfc822; name="forwarded-message.eml"
Content-Disposition: attachment; filename="forwarded-message.eml"
Content-Type: message/rfc822

$forwardedMessage
--FORWARD_BOUNDARY
Expand Down Expand Up @@ -290,6 +289,79 @@
expect($attachments[1]->contentType())->toBe('application/zip');
});

test('it does not merge attached .eml files', function () {
// Forwarded message that itself contains an attachment
$forwardedMessage = <<<'EOT'
From: "Original Sender" <[email protected]>
To: "Original Recipient" <[email protected]>
Subject: Original Message with Attachment
Date: Tue, 18 Feb 2025 10:00:00 -0500
Message-ID: <[email protected]>
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="ORIGINAL_BOUNDARY"

--ORIGINAL_BOUNDARY
Content-Type: text/plain; charset="UTF-8"

This is the original message with an attachment.

--ORIGINAL_BOUNDARY
Content-Type: application/pdf; name="original-document.pdf"
Content-Disposition: attachment; filename="original-document.pdf"
Content-Transfer-Encoding: base64

JVBERi0xLjUKJeLjz9MKMyAwIG9iago8PC9MZW5ndGggNCAgIC9GaWx0ZXIvQXNjaWlIYXgg
ICAgPj5zdHJlYW0Kc3R1ZmYKZW5kc3RyZWFtCmVuZG9iajAK
--ORIGINAL_BOUNDARY--
EOT;

// Top-level message that attaches the forwarded message as an .eml (should not merge)
$contents = <<<EOT
From: "Forwarder" <[email protected]>
To: "Final Recipient" <[email protected]>
Subject: Fwd: Original Message with Attachment (as .eml)
Date: Wed, 19 Feb 2025 12:34:56 -0500
Message-ID: <[email protected]>
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="FORWARD_BOUNDARY"

--FORWARD_BOUNDARY
Content-Type: text/plain; charset="UTF-8"

Here is the forwarded message attached as an .eml file.

--FORWARD_BOUNDARY
Content-Type: message/rfc822; name="forwarded-message.eml"
Content-Disposition: attachment; filename="forwarded-message.eml"

$forwardedMessage
--FORWARD_BOUNDARY
Content-Type: application/zip; name="additional-file.zip"
Content-Disposition: attachment; filename="additional-file.zip"
Content-Transfer-Encoding: base64

UEsDBAoAAAAAAKxVVVMAAAAAAAAAAAAAAAAJAAAAdGVzdC50eHRQSwECFAAKAAAAAACs
VVVTAAAAAAAAAAAAAAAACQAAAAAAAAAAAAAAAAAAAHRlc3QudHh0UEsFBgAAAAABAAEA
NwAAAB8AAAAAAA==
--FORWARD_BOUNDARY--
EOT;

$message = new FileMessage($contents);

$attachments = $message->attachments();

// Expect exactly two top-level attachments: the attached .eml itself and the zip
expect($attachments)->toHaveCount(2);

// .eml is preserved as-is (no merging of its inner PDF)
expect($attachments[0]->contentType())->toBe('message/rfc822');
expect($attachments[0]->filename())->toBe('forwarded-message.eml');

// The top-level zip attachment remains
expect($attachments[1]->contentType())->toBe('application/zip');
expect($attachments[1]->filename())->toBe('additional-file.zip');
});

test('it can handle multiple levels of forwarded messages with attachments', function () {
// Create the deepest nested message with an attachment
$deepestMessage = <<<'EOT'
Expand Down