diff --git a/src/HasParsedMessage.php b/src/HasParsedMessage.php index bed28f1..7b553a3 100644 --- a/src/HasParsedMessage.php +++ b/src/HasParsedMessage.php @@ -13,7 +13,6 @@ use ZBateson\MailMimeParser\Header\Part\ContainerPart; use ZBateson\MailMimeParser\Header\Part\NameValuePart; use ZBateson\MailMimeParser\Message as MailMimeMessage; -use ZBateson\MailMimeParser\Message\MimePart; trait HasParsedMessage { @@ -119,14 +118,27 @@ public function bcc(): array */ public function attachments(): array { - return array_map(function (MimePart $part) { - return new Attachment( - $part->getFilename(), - $part->getContentId(), - $part->getContentType(), - $part->getContentStream() ?? Utils::streamFor(''), - ); - }, $this->parse()->getAllAttachmentParts()); + $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') { + $message = new FileMessage($part->getContent()); + + $attachments = array_merge($attachments, $message->attachments()); + } else { + $attachments[] = new Attachment( + $part->getFilename(), + $part->getContentId(), + $part->getContentType(), + $part->getContentStream() ?? Utils::streamFor(''), + ); + } + } + + return $attachments; } /** diff --git a/tests/Unit/FileMessageTest.php b/tests/Unit/FileMessageTest.php index 919b145..e81db11 100644 --- a/tests/Unit/FileMessageTest.php +++ b/tests/Unit/FileMessageTest.php @@ -198,6 +198,160 @@ expect($attachments[0]->filename())->toBe('inline_image.png'); }); +test('it can extract attachments from forwarded messages', function () { + // Create a forwarded message that 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; + + // Create the main message that forwards the above message + $contents = << + To: "Final Recipient" + Subject: Fwd: Original Message with Attachment + 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 with its attachment. + + --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); + + // Should find attachments from both the main message and the forwarded message + $attachments = $message->attachments(); + + expect($attachments)->toHaveCount(2); + + // First attachment should be from the forwarded message + expect($attachments[0]->filename())->toBe('original-document.pdf'); + expect($attachments[0]->contentType())->toBe('application/pdf'); + + // Second attachment should be from the main message + expect($attachments[1]->filename())->toBe('additional-file.zip'); + expect($attachments[1]->contentType())->toBe('application/zip'); +}); + +test('it can handle multiple levels of forwarded messages with attachments', function () { + // Create the deepest nested message with an attachment + $deepestMessage = <<<'EOT' + From: "Deep Sender" + To: "Deep Recipient" + Subject: Deep Message + MIME-Version: 1.0 + Content-Type: multipart/mixed; boundary="DEEP_BOUNDARY" + + --DEEP_BOUNDARY + Content-Type: text/plain; charset="UTF-8" + + This is the deepest message. + + --DEEP_BOUNDARY + Content-Type: text/plain; name="deep-file.txt" + Content-Disposition: attachment; filename="deep-file.txt" + + Deep file content + --DEEP_BOUNDARY-- + EOT; + + // Create a middle forwarded message that forwards the deep message + $middleMessage = << + To: "Middle Recipient" + Subject: Fwd: Deep Message + MIME-Version: 1.0 + Content-Type: multipart/mixed; boundary="MIDDLE_BOUNDARY" + + --MIDDLE_BOUNDARY + Content-Type: text/plain; charset="UTF-8" + + Forwarding the deep message. + + --MIDDLE_BOUNDARY + Content-Type: message/rfc822 + + $deepestMessage + --MIDDLE_BOUNDARY + Content-Type: image/jpeg; name="middle-image.jpg" + Content-Disposition: attachment; filename="middle-image.jpg" + Content-Transfer-Encoding: base64 + + /9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEB + --MIDDLE_BOUNDARY-- + EOT; + + // Create the top-level message + $contents = << + To: "Top Recipient" + Subject: Fwd: Fwd: Deep Message + MIME-Version: 1.0 + Content-Type: multipart/mixed; boundary="TOP_BOUNDARY" + + --TOP_BOUNDARY + Content-Type: text/plain; charset="UTF-8" + + Multiple levels of forwarding. + + --TOP_BOUNDARY + Content-Type: message/rfc822 + + $middleMessage + --TOP_BOUNDARY-- + EOT; + + $message = new FileMessage($contents); + + $attachments = $message->attachments(); + + // Should find attachments from all levels: deep-file.txt and middle-image.jpg + expect($attachments)->toHaveCount(2); + + // Verify we get attachments from nested messages + $filenames = array_map(fn ($attachment) => $attachment->filename(), $attachments); + expect($filenames)->toContain('deep-file.txt'); + expect($filenames)->toContain('middle-image.jpg'); +}); + test('it can determine if two messages are the same', function () { $contents1 = <<<'EOT' From: "John Doe"