diff --git a/src/HasParsedMessage.php b/src/HasParsedMessage.php index 8c00de9..b253df1 100644 --- a/src/HasParsedMessage.php +++ b/src/HasParsedMessage.php @@ -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 { @@ -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()); @@ -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. * diff --git a/tests/Unit/FileMessageTest.php b/tests/Unit/FileMessageTest.php index c1b1890..d37cab0 100644 --- a/tests/Unit/FileMessageTest.php +++ b/tests/Unit/FileMessageTest.php @@ -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" @@ -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 = << To: "Final Recipient" @@ -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 @@ -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" + To: "Original Recipient" + Subject: Original Message with Attachment + Date: Tue, 18 Feb 2025 10:00:00 -0500 + Message-ID: + 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 = << + To: "Final Recipient" + Subject: Fwd: Original Message with Attachment (as .eml) + Date: Wed, 19 Feb 2025 12:34:56 -0500 + Message-ID: + 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'