├── .gitignore ├── tests ├── Pest.php ├── TestCase.php ├── Fixtures │ ├── multiformat_email.eml │ ├── complex_email.eml │ └── complex_email_2.eml └── Unit │ └── MessageTest.php ├── .editorconfig ├── phpunit.xml ├── src ├── HasHeaders.php ├── Utils.php ├── MessagePart.php └── Message.php ├── LICENSE.md ├── composer.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | composer.lock 3 | .idea 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /tests/Pest.php: -------------------------------------------------------------------------------- 1 | in('Feature'); 4 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./tests 6 | 7 | 8 | 9 | 10 | 11 | ./app 12 | ./src 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /tests/Fixtures/multiformat_email.eml: -------------------------------------------------------------------------------- 1 | From: Arunas Practice 2 | To: Arunas arukomp 3 | Reply-To: Arunas Practice 4 | Subject: Appointment confirmation 5 | Message-ID: 6 | MIME-Version: 1.0 7 | Date: Thu, 24 Aug 2023 14:51:14 +0100 8 | Content-Type: multipart/alternative; boundary=s1NCDW_3 9 | 10 | --s1NCDW_3 11 | Content-Type: text/plain; charset=utf-8 12 | Content-Transfer-Encoding: quoted-printable 13 | 14 | Hi Arunas Skirius, 15 | This is a confirmation of your appointment. 16 | --s1NCDW_3 17 | Content-Type: text/html; charset=utf-8 18 | Content-Transfer-Encoding: quoted-printable 19 | 20 | 21 | 22 | 23 | 24 | Title 25 | 26 | 27 | --s1NCDW_3-- 28 | -------------------------------------------------------------------------------- /src/HasHeaders.php: -------------------------------------------------------------------------------- 1 | headers; 12 | } 13 | 14 | public function getHeader(string $header, $default = null): mixed 15 | { 16 | $header = strtolower($header); 17 | 18 | foreach ($this->headers as $key => $value) { 19 | if (strtolower($key) === $header) { 20 | return $value; 21 | } 22 | } 23 | 24 | return $default; 25 | } 26 | 27 | public function setHeader(string $header, $value): void 28 | { 29 | $this->headers[$header] = Utils::decodeHeader($value); 30 | } 31 | 32 | public function removeHeader(string $header): void 33 | { 34 | $header = strtolower($header); 35 | 36 | foreach ($this->headers as $key => $value) { 37 | if (strtolower($key) === $header) { 38 | unset($this->headers[$key]); 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) arukompas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "opcodesio/mail-parser", 3 | "description": "Parse emails without the mailparse extension", 4 | "keywords": [ 5 | "arukompas", 6 | "opcodesio", 7 | "php", 8 | "mail", 9 | "email", 10 | "email parser" 11 | ], 12 | "license": "MIT", 13 | "authors": [ 14 | { 15 | "name": "Arunas Skirius", 16 | "email": "arukomp@gmail.com", 17 | "role": "Developer" 18 | } 19 | ], 20 | "scripts": { 21 | "test": "vendor/bin/pest" 22 | }, 23 | "autoload": { 24 | "psr-4": { 25 | "Opcodes\\MailParser\\": "src/" 26 | } 27 | }, 28 | "autoload-dev": { 29 | "psr-4": { 30 | "Opcodes\\MailParser\\Tests\\": "tests" 31 | } 32 | }, 33 | "require": { 34 | "php": "^8.0" 35 | }, 36 | "config": { 37 | "sort-packages": true, 38 | "allow-plugins": { 39 | "pestphp/pest-plugin": true 40 | } 41 | }, 42 | "require-dev": { 43 | "pestphp/pest": "^2.16", 44 | "symfony/var-dumper": "^6.3" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/Fixtures/complex_email.eml: -------------------------------------------------------------------------------- 1 | From: Arunas Practice 2 | To: Arunas arukomp 3 | Reply-To: Arunas Practice 4 | Subject: Appointment confirmation 5 | Message-ID: 6 | MIME-Version: 1.0 7 | Date: Thu, 24 Aug 2023 14:51:14 +0100 8 | Content-Type: multipart/mixed; boundary=lGiKDww4 9 | 10 | --lGiKDww4 11 | Content-Type: text/html; charset=utf-8 12 | Content-Transfer-Encoding: quoted-printable 13 | 14 | 15 | 16 | 17 | 18 | 19 | --lGiKDww4 20 | Content-Type: text/calendar; name=Appointment.ics 21 | Content-Transfer-Encoding: base64 22 | Content-Disposition: attachment; name=Appointment.ics; 23 | filename=Appointment.ics 24 | 25 | QkVHSU46VkNBTEVOREFSDQpWRVJTSU9OOjIuMA0KUFJPRElEOi0vL2hhY2tzdy9oYW5kY2FsLy9O 26 | T05TR01MIHYxLjAvL0VODQpCRUdJTjpWVElNRVpPTkUNClRaSUQ6RXVyb3BlL0xvbmRvbg0KWC1M 27 | SUMtTE9DQVRJT046RXVyb3BlL0xvbmRvbg0KQkVHSU46REFZTElHSFQNClRaT0ZGU0VURlJPTTor 28 | MDEwMA0KVFpPRkZTRVRUTzorMDIwMA0KVFpOQU1FOkNFU1QN 29 | --lGiKDww4-- 30 | -------------------------------------------------------------------------------- /src/Utils.php: -------------------------------------------------------------------------------- 1 | $value) { 68 | if (str_starts_with($value, '=?')) { 69 | $decodedHeaders[$key] = self::decodeHeader($value); 70 | } else { 71 | $decodedHeaders[$key] = $value; 72 | } 73 | } 74 | 75 | return $decodedHeaders; 76 | } 77 | 78 | public static function decodeHeader(string $header): string 79 | { 80 | if (preg_match_all('/=\?([^?]+)\?([BQ])\?([^?]+)\?=/i', $header, $matches, PREG_SET_ORDER)) { 81 | foreach ($matches as $match) { 82 | $encoding = $match[1]; 83 | $type = $match[2]; 84 | $data = $match[3]; 85 | 86 | if ($type === 'B') { 87 | $decoded = base64_decode($data); 88 | } else { 89 | $decoded = quoted_printable_decode($data); 90 | } 91 | 92 | $header = str_replace($match[0], $decoded, $header); 93 | } 94 | } 95 | 96 | return $header; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/MessagePart.php: -------------------------------------------------------------------------------- 1 | rawMessage = $message; 16 | 17 | $this->parse(); 18 | } 19 | 20 | protected function parse(): void 21 | { 22 | // Split part into headers and content 23 | if (strpos($this->rawMessage, "\r\n\r\n") !== false) { 24 | [$headers, $content] = explode("\r\n\r\n", $this->rawMessage, 2); 25 | 26 | // Parse part headers 27 | $this->headers = Utils::parseHeaders($headers); 28 | $this->headers = Utils::decodeHeaders($this->headers); 29 | 30 | $this->content = trim($content); 31 | } else { 32 | // No headers, just content 33 | $this->content = trim($this->rawMessage); 34 | } 35 | } 36 | 37 | public function getContentType(): string 38 | { 39 | return $this->getHeader('Content-Type', ''); 40 | } 41 | 42 | public function getContent(): string 43 | { 44 | if (strtolower($this->getHeader('Content-Transfer-Encoding', '')) === 'base64') { 45 | return Utils::normaliseLineEndings(base64_decode($this->content)); 46 | } 47 | 48 | return Utils::normaliseLineEndings($this->content); 49 | } 50 | 51 | public function isHtml(): bool 52 | { 53 | return str_starts_with(strtolower($this->getContentType()), 'text/html'); 54 | } 55 | 56 | public function isText(): bool 57 | { 58 | return str_starts_with(strtolower($this->getContentType()), 'text/plain'); 59 | } 60 | 61 | public function isImage(): bool 62 | { 63 | return str_starts_with(strtolower($this->getContentType()), 'image/'); 64 | } 65 | 66 | public function isAttachment(): bool 67 | { 68 | return str_starts_with($this->getHeader('Content-Disposition', ''), 'attachment'); 69 | } 70 | 71 | public function getFilename(): string 72 | { 73 | if (preg_match('/filename=([^;]+)/', $this->getHeader('Content-Disposition'), $matches)) { 74 | return trim($matches[1], '"'); 75 | } 76 | 77 | if (preg_match('/name=([^;]+)/', $this->getContentType(), $matches)) { 78 | return trim($matches[1], '"'); 79 | } 80 | 81 | return ''; 82 | } 83 | 84 | public function getSize(): int 85 | { 86 | return strlen($this->rawMessage); 87 | } 88 | 89 | public function toArray(): array 90 | { 91 | return [ 92 | 'headers' => $this->getHeaders(), 93 | 'content' => $this->getContent(), 94 | 'filename' => $this->getFilename(), 95 | 'size' => $this->getSize(), 96 | ]; 97 | } 98 | 99 | public function jsonSerialize(): mixed 100 | { 101 | return $this->toArray(); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

3 |

Mail Parser for PHP
Simple, fast, no extensions required

4 |

5 |
6 | 7 |

8 | Features | 9 | Installation | 10 | Credits 11 |

12 | 13 |

14 | Packagist 15 | Packagist 16 | PHP from Packagist 17 |

18 | 19 | ## Features 20 | 21 | [OPcodes's](https://www.opcodes.io/) **Mail Parser** has a very simple API to parse emails and their MIME contents. Unlike many other parsers out there, this package does not require the [mailparse](https://www.php.net/manual/en/book.mailparse.php) PHP extension. 22 | 23 | Has not been fully tested against RFC 5322. 24 | 25 | ## Get Started 26 | 27 | ### Requirements 28 | 29 | - **PHP 8.0+** 30 | 31 | ### Installation 32 | 33 | To install the package via composer, Run: 34 | 35 | ```bash 36 | composer require opcodesio/mail-parser 37 | ``` 38 | 39 | ### Usage 40 | 41 | ```php 42 | use Opcodes\MailParser\Message; 43 | 44 | // Parse a message from a string 45 | $message = Message::fromString('...'); 46 | // Or from a file location (accessible with file_get_contents()) 47 | $message = Message::fromFile('/path/to/email.eml'); 48 | 49 | $message->getHeaders(); // get all headers 50 | $message->getHeader('Content-Type'); // 'multipart/mixed; boundary="----=_Part_1_1234567890"' 51 | $message->getFrom(); // 'Arunas 52 | $message->getTo(); // 'John Doe 53 | $message->getSubject(); // 'Subject line' 54 | $message->getDate(); // DateTime object when the email was sent 55 | $message->getSize(); // Email size in bytes 56 | 57 | $message->getParts(); // Returns an array of \Opcodes\MailParser\MessagePart, which can be html parts, text parts, attachments, etc. 58 | $message->getHtmlPart(); // Returns the \Opcodes\MailParser\MessagePart containing the HTML body 59 | $message->getTextPart(); // Returns the \Opcodes\MailParser\MessagePart containing the Text body 60 | $message->getAttachments(); // Returns an array of \Opcodes\MailParser\MessagePart that represent attachments 61 | 62 | $messagePart = $message->getParts()[0]; 63 | 64 | $messagePart->getHeaders(); // array of all headers for this message part 65 | $messagePart->getHeader('Content-Type'); // value of a particular header 66 | $messagePart->getContentType(); // 'text/html; charset="utf-8"' 67 | $messagePart->getContent(); // '....' 68 | $messagePart->getSize(); // 312 69 | $messagePart->getFilename(); // name of the file, in case this is an attachment part 70 | ``` 71 | 72 | ## Contributing 73 | 74 | A guide for contributing is in progress... 75 | 76 | ## Security Vulnerabilities 77 | 78 | Please review [our security policy](../../security/policy) on how to report security vulnerabilities. 79 | 80 | ## Credits 81 | 82 | - [Arunas Skirius](https://github.com/arukompas) 83 | - [All Contributors](../../contributors) 84 | 85 | ## License 86 | 87 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 88 | -------------------------------------------------------------------------------- /tests/Fixtures/complex_email_2.eml: -------------------------------------------------------------------------------- 1 | Delivered-To: receiver@example.com 2 | Received: by 2002:a05:6a20:6a27:b0:1ce:d986:86ca with SMTP id asdf; 3 | Tue, 12 Nov 2024 07:01:18 -0800 (PST) 4 | X-Google-Smtp-Source: asdfasdfasdfasdfasdfasdfasdfasdf 5 | X-Received: by 2002:a05:6512:4025:b0:533:3fc8:43ee with SMTP id asdf-asdf.13.1731423677759; 6 | Tue, 12 Nov 2024 07:01:17 -0800 (PST) 7 | ARC-Seal: i=1; a=rsa-sha256; t=1731423677; cv=none; 8 | d=google.com; s=arc-20240605; 9 | b=asdf/asdf/asdf 10 | asdf/asdf 11 | asdf 12 | asdf/asdf+asdf 13 | asdf+asdf/sadf/OP5 14 | 8b+g== 15 | ARC-Message-Signature: i=1; a=rsa-sha256; c=relaxed/relaxed; d=google.com; s=arc-20240605; 16 | h=list-id:list-unsubscribe-post:list-unsubscribe:from:subject 17 | :reply-to:mime-version:date:message-id:to:content-transfer-encoding 18 | :dkim-signature; 19 | bh=asdf=; 20 | fh=asdf=; 21 | b=asdf/f/asdf/asdf/oECL+asdf 22 | asdf+nfQu/asdf/+asdf 23 | asdf/asdf 24 | asdf+1ig+asdf 25 | asdf/asdf 26 | XGyw==; 27 | dara=google.com 28 | ARC-Authentication-Results: i=1; mx.google.com; 29 | dkim=pass header.i=@test34345345435.com header.s=scph0924 header.b="ChJ/3uaY"; 30 | spf=pass (google.com: domain of msprvs1=asdf=bounces-12345@sparkpost.bounce.edrone.me designates 123.123.123.123 as permitted sender) smtp.mailfrom="asdf=asdf=bounces-12345@sparkpost.bounce.edrone.me"; 31 | dmarc=pass (p=QUARANTINE sp=NONE dis=NONE) header.from=test34345345435.com 32 | Return-Path: 33 | Received: from mta-12-228-142.sparkpostmail.com (mta-12-228-142.sparkpostmail.com. [123.123.123.123]) 34 | by mx.google.com with ESMTPS id asdf-asdf.299.2024.11.12.07.01.17 35 | for 36 | (version=TLS1_2 cipher=ECDHE-ECDSA-AES128-GCM-SHA256 bits=128/128); 37 | Tue, 12 Nov 2024 07:01:17 -0800 (PST) 38 | Received-SPF: pass (google.com: domain of msprvs1=asdf=bounces-12345@sparkpost.bounce.edrone.me designates 123.123.123.123 as permitted sender) client-ip=123.123.123.123; 39 | Authentication-Results: mx.google.com; 40 | dkim=pass header.i=@test34345345435.com header.s=scph0924 header.b="ChJ/3uaY"; 41 | spf=pass (google.com: domain of msprvs1=asdf=bounces-12345@sparkpost.bounce.edrone.me designates 123.123.123.123 as permitted sender) smtp.mailfrom="msprvs1=asdf=bounces-12345@sparkpost.bounce.edrone.me"; 42 | dmarc=pass (p=QUARANTINE sp=NONE dis=NONE) header.from=test34345345435.com 43 | X-MSFBL: asdf+asdf=|asdf 44 | asdf 45 | asdf 46 | asdf 47 | DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=test34345345435.com; 48 | s=scph0924; t=1731423675; i=@test34345345435.com; 49 | bh=asdf=; 50 | h=Content-Type:To:Message-ID:Date:Subject:From:List-Unsubscribe: 51 | List-Unsubscribe-Post:From:To:Cc:Subject; 52 | b=ChJ/asdf+asdf 53 | 3Y+asdf/asdf 54 | asdf/asdf/asdf/asdf= 55 | Content-Transfer-Encoding: quoted-printable 56 | Content-Type: text/html; charset="UTF-8" 57 | To: receiver@example.com 58 | Message-ID: <01.A1.00000.ABC000000@gt.mta3vrest.cc.prd.sparkpost> 59 | Date: Tue, 12 Nov 2024 15:01:15 +0000 60 | MIME-Version: 1.0 61 | Reply-To: test@test34345345435.com 62 | Subject: =?utf-8?B?VGVzdCBzdWJqZWN0?= 63 | From: "Test Center" 64 | List-Unsubscribe: , 65 | List-Unsubscribe-Post: List-Unsubscribe=One-Click 66 | List-Id: 67 | 68 | =0A=0A=0A=0A =0A =0A = 77 | =0A =0A 81 | -------------------------------------------------------------------------------- /src/Message.php: -------------------------------------------------------------------------------- 1 | rawMessage = Utils::cleanUntilFirstHeader($message); 21 | $this->rawMessage = Utils::normaliseLineEndings($this->rawMessage, true); 22 | 23 | $this->parse(); 24 | } 25 | 26 | public static function fromString($message): self 27 | { 28 | return new self($message); 29 | } 30 | 31 | public static function fromFile($path): self 32 | { 33 | return new self(file_get_contents($path)); 34 | } 35 | 36 | public function getBoundary(): ?string 37 | { 38 | return $this->boundary ?? null; 39 | } 40 | 41 | public function getContentType(): string 42 | { 43 | return $this->getHeader('Content-Type', ''); 44 | } 45 | 46 | public function getId(): string 47 | { 48 | $header = $this->getHeader('Message-ID', ''); 49 | 50 | return trim($header, '<>'); 51 | } 52 | 53 | public function getSubject(): string 54 | { 55 | return $this->getHeader('Subject', ''); 56 | } 57 | 58 | public function getFrom(): string 59 | { 60 | return $this->getHeader('From', ''); 61 | } 62 | 63 | public function getTo(): string 64 | { 65 | return $this->getHeader('To', ''); 66 | } 67 | 68 | public function getReplyTo(): string 69 | { 70 | return $this->getHeader('Reply-To', ''); 71 | } 72 | 73 | public function getDate(): ?\DateTime 74 | { 75 | return \DateTime::createFromFormat( 76 | 'D, d M Y H:i:s O', 77 | $this->getHeader('Date') 78 | ) ?: null; 79 | } 80 | 81 | public function getParts(): array 82 | { 83 | return $this->parts; 84 | } 85 | 86 | public function getHtmlPart(): ?MessagePart 87 | { 88 | foreach ($this->getParts() as $part) { 89 | if ($part->isHtml()) { 90 | return $part; 91 | } 92 | } 93 | 94 | return null; 95 | } 96 | 97 | public function getTextPart(): ?MessagePart 98 | { 99 | foreach ($this->getParts() as $part) { 100 | if ($part->isText()) { 101 | return $part; 102 | } 103 | } 104 | 105 | return null; 106 | } 107 | 108 | /** 109 | * @return MessagePart[] 110 | */ 111 | public function getAttachments(): array 112 | { 113 | return array_values(array_filter($this->parts, fn ($part) => $part->isAttachment())); 114 | } 115 | 116 | public function getSize(): int 117 | { 118 | return strlen($this->rawMessage); 119 | } 120 | 121 | public function toArray(): array 122 | { 123 | return [ 124 | 'id' => $this->getId(), 125 | 'subject' => $this->getSubject(), 126 | 'from' => $this->getFrom(), 127 | 'to' => $this->getTo(), 128 | 'reply_to' => $this->getReplyTo(), 129 | 'date' => $this->getDate() ? $this->getDate()->format('c') : null, 130 | 'headers' => $this->getHeaders(), 131 | 'parts' => array_map(fn ($part) => $part->toArray(), $this->getParts()), 132 | ]; 133 | } 134 | 135 | public function jsonSerialize(): mixed 136 | { 137 | return $this->toArray(); 138 | } 139 | 140 | protected function parse(): void 141 | { 142 | // Split email into headers and body 143 | [$rawHeaders, $body] = explode("\r\n\r\n", $this->rawMessage, 2); 144 | 145 | // Parse top-level headers 146 | $this->headers = Utils::parseHeaders($rawHeaders); 147 | $this->headers = Utils::decodeHeaders($this->headers); 148 | 149 | // Get boundary if this is a multipart email 150 | $contentType = $this->getHeader('Content-Type'); 151 | if ($contentType && preg_match('/boundary="?([^";\r\n]+)"?/', $contentType, $matches)) { 152 | $this->boundary = $matches[1]; 153 | } 154 | 155 | if (!isset($this->boundary) && str_contains($contentType ?? '', 'multipart/')) { 156 | // multipart email, perhaps the boundary is corrupted in the header. 157 | // Let's attempt to find a boundary in the body. 158 | if (preg_match("~^--(?[0-9A-Za-z'()+_,-./:=?]{0,68}[0-9A-Za-z'()+_,-./=?])~", $body, $matches)) { 159 | $this->boundary = trim($matches['boundary']); 160 | } 161 | } 162 | 163 | // If no boundary, treat the entire body as a single part 164 | if (!isset($this->boundary)) { 165 | $part = $this->addPart($body ?? ''); 166 | if ($contentType = $this->getHeader('Content-Type')) { 167 | $part->setHeader('Content-Type', $contentType); 168 | } 169 | if ($contentTransferEncoding = $this->getHeader('Content-Transfer-Encoding')) { 170 | $part->setHeader('Content-Transfer-Encoding', $contentTransferEncoding); 171 | $this->removeHeader('Content-Transfer-Encoding'); 172 | } 173 | return; 174 | } 175 | 176 | // Split body into parts using boundary 177 | $parts = preg_split("/--" . preg_quote($this->boundary) . "(?:--|(?:\r\n|$))/", $body); 178 | 179 | // Process each part 180 | foreach ($parts as $rawPart) { 181 | if (empty(trim($rawPart))) continue; 182 | 183 | $this->addPart($rawPart); 184 | } 185 | } 186 | 187 | protected function addPart(string $rawMessage): MessagePart 188 | { 189 | $this->parts[] = $part = new MessagePart($rawMessage); 190 | 191 | return $part; 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /tests/Unit/MessageTest.php: -------------------------------------------------------------------------------- 1 | 10 | To: Receiver 11 | Subject: Test Subject 12 | Message-ID: <6e30b164904cf01158c7cc58f144b9ca@example.com> 13 | MIME-Version: 1.0 14 | Date: Fri, 25 Aug 2023 15:36:13 +0200 15 | Content-Type: text/html; charset=utf-8 16 | Content-Transfer-Encoding: quoted-printable 17 | 18 | Email content goes here. 19 | EOF; 20 | 21 | $message = Message::fromString($messageString); 22 | 23 | expect($message->getFrom())->toBe('Sender ') 24 | ->and($message->getTo())->toBe('Receiver ') 25 | ->and($message->getSubject())->toBe('Test Subject') 26 | ->and($message->getId())->toBe('6e30b164904cf01158c7cc58f144b9ca@example.com') 27 | ->and($message->getDate()?->format('Y-m-d H:i:s'))->toBe('2023-08-25 15:36:13') 28 | ->and($message->getContentType())->toBe('text/html; charset=utf-8') 29 | ->and($message->getHtmlPart()?->getContent())->toBe('Email content goes here.') 30 | ->and($message->getHtmlPart()?->getHeaders())->toBe([ 31 | 'Content-Type' => 'text/html; charset=utf-8', 32 | 'Content-Transfer-Encoding' => 'quoted-printable', 33 | ]); 34 | }); 35 | 36 | it('can parse lowercase headers', function () { 37 | $messageString = << 39 | to: Receiver 40 | subject: Test Subject 41 | message-id: <6e30b164904cf01158c7cc58f144b9ca@example.com> 42 | mime-version: 1.0 43 | date: Fri, 25 Aug 2023 15:36:13 +0200 44 | content-type: text/html; charset=utf-8 45 | content-transfer-encoding: quoted-printable 46 | 47 | Email content goes here. 48 | EOF; 49 | 50 | $message = Message::fromString($messageString); 51 | 52 | expect($message->getHeaders())->toBe([ 53 | 'from' => 'Sender ', 54 | 'to' => 'Receiver ', 55 | 'subject' => 'Test Subject', 56 | 'message-id' => '<6e30b164904cf01158c7cc58f144b9ca@example.com>', 57 | 'mime-version' => '1.0', 58 | 'date' => 'Fri, 25 Aug 2023 15:36:13 +0200', 59 | 'content-type' => 'text/html; charset=utf-8', 60 | ]) 61 | ->and($message->getFrom())->toBe('Sender ') 62 | ->and($message->getHeader('Content-Type'))->toBe('text/html; charset=utf-8'); 63 | }); 64 | 65 | it('can parse a mail message with boundaries', function () { 66 | date_default_timezone_set('UTC'); 67 | $messageString = << 86 | 87 | This is an HTML email 88 | 89 | 90 |

This is the HTML version of the email

91 | 92 | 93 | 94 | ------=_Part_1_1234567890-- 95 | EOF; 96 | 97 | $message = new Message($messageString); 98 | 99 | expect($message->getHeaders())->toBe([ 100 | 'From' => 'sender@example.com', 101 | 'To' => 'recipient@example.com', 102 | 'Cc' => 'cc@example.com', 103 | 'Bcc' => 'bcc@example.com', 104 | 'Subject' => 'This is an email with common headers', 105 | 'Date' => 'Thu, 24 Aug 2023 21:15:01 PST', 106 | 'MIME-Version' => '1.0', 107 | 'Content-Type' => 'multipart/mixed; boundary="----=_Part_1_1234567890"', 108 | ]) 109 | ->and($message->getSubject())->toBe('This is an email with common headers') 110 | ->and($message->getFrom())->toBe('sender@example.com') 111 | ->and($message->getTo())->toBe('recipient@example.com') 112 | ->and($message->getDate())->toBeInstanceOf(\DateTime::class) 113 | ->and($message->getDate()->format('Y-m-d H:i:s'))->toBe('2023-08-24 21:15:01'); 114 | 115 | $parts = $message->getParts(); 116 | 117 | expect($parts)->toHaveCount(2) 118 | ->and($parts[0]->getContentType())->toBe('text/plain; charset="utf-8"') 119 | ->and($parts[0]->getContent())->toBe('This is the text version of the email.') 120 | ->and($parts[1]->getContentType())->toBe('text/html; charset="utf-8"') 121 | ->and($parts[1]->getContent())->toBe(<< 123 | 124 | This is an HTML email 125 | 126 | 127 |

This is the HTML version of the email

128 | 129 | 130 | EOF); 131 | 132 | }); 133 | 134 | it('can parse a complex mail message', function () { 135 | $message = Message::fromFile(__DIR__ . '/../Fixtures/complex_email.eml'); 136 | 137 | expect($message->getFrom())->toBe('Arunas Practice ') 138 | ->and($message->getTo())->toBe('Arunas arukomp ') 139 | ->and($message->getReplyTo())->toBe('Arunas Practice ') 140 | ->and($message->getSubject())->toBe('Appointment confirmation') 141 | ->and($message->getId())->toBe('fddff4779513441c3f0c1811193f5b12@example.com') 142 | ->and($message->getDate()->format('Y-m-d H:i:s'))->toBe('2023-08-24 14:51:14') 143 | ->and($message->getBoundary())->toBe('lGiKDww4'); 144 | 145 | $parts = $message->getParts(); 146 | 147 | expect($parts)->toHaveCount(2) 148 | ->and($parts[0]->getContentType())->toBe('text/html; charset=utf-8') 149 | ->and($parts[0]->getHeaders())->toBe([ 150 | 'Content-Type' => 'text/html; charset=utf-8', 151 | 'Content-Transfer-Encoding' => 'quoted-printable', 152 | ]) 153 | ->and($parts[1]->getContentType())->toBe('text/calendar; name=Appointment.ics') 154 | ->and($parts[1]->getHeaders())->toBe([ 155 | 'Content-Type' => 'text/calendar; name=Appointment.ics', 156 | 'Content-Transfer-Encoding' => 'base64', 157 | 'Content-Disposition' => 'attachment; name=Appointment.ics; filename=Appointment.ics', 158 | ]); 159 | }); 160 | 161 | it('can parse a complex mail message 2', function () { 162 | $message = Message::fromFile(__DIR__ . '/../Fixtures/complex_email_2.eml'); 163 | 164 | expect($message->getFrom())->toBe('"Test Center" ') 165 | ->and($message->getTo())->toBe('receiver@example.com') 166 | ->and($message->getReplyTo())->toBe('test@test34345345435.com') 167 | ->and($message->getSubject())->toBe('Test subject') 168 | ->and($message->getId())->toBe('01.A1.00000.ABC000000@gt.mta3vrest.cc.prd.sparkpost') 169 | ->and($message->getDate()->format('Y-m-d H:i:s'))->toBe('2024-11-12 15:01:15') 170 | ->and($message->getHeader('Delivered-To'))->toBe('receiver@example.com') 171 | ->and($message->getBoundary())->toBeNull(); 172 | 173 | $parts = $message->getParts(); 174 | 175 | expect($parts)->toHaveCount(1); 176 | $part = $parts[0]; 177 | 178 | expect($part->isHtml())->toBeTrue() 179 | ->and($part->getContent())->toStartWith('and($part->getContent())->toEndWith(''); 181 | }); 182 | 183 | it('can parse a multi-format mail message', function () { 184 | $message = Message::fromFile(__DIR__ . '/../Fixtures/multiformat_email.eml'); 185 | 186 | expect($message->getFrom())->toBe('Arunas Practice ') 187 | ->and($message->getTo())->toBe('Arunas arukomp ') 188 | ->and($message->getReplyTo())->toBe('Arunas Practice ') 189 | ->and($message->getSubject())->toBe('Appointment confirmation') 190 | ->and($message->getId())->toBe('fddff4779513441c3f0c1811193f5b12@example.com') 191 | ->and($message->getDate()->format('Y-m-d H:i:s'))->toBe('2023-08-24 14:51:14') 192 | ->and($message->getBoundary())->toBe('s1NCDW_3'); 193 | 194 | $parts = $message->getParts(); 195 | 196 | expect($parts)->toHaveCount(2) 197 | ->and($parts[0]->getContentType())->toBe('text/plain; charset=utf-8') 198 | ->and($parts[0]->getHeaders())->toBe([ 199 | 'Content-Type' => 'text/plain; charset=utf-8', 200 | 'Content-Transfer-Encoding' => 'quoted-printable', 201 | ]) 202 | ->and($parts[1]->getContentType())->toBe('text/html; charset=utf-8') 203 | ->and($parts[1]->getHeaders())->toBe([ 204 | 'Content-Type' => 'text/html; charset=utf-8', 205 | 'Content-Transfer-Encoding' => 'quoted-printable', 206 | ])->and($message->getTextPart()?->getContent())->toBe(<< 226 | 227 | This is an HTML email 228 | 229 | 230 |

This is the HTML version of the email

231 | 232 | 233 | 234 | ------=_Part_1_1234567890 235 | Content-Type: text/plain; name=test.txt 236 | Content-Transfer-Encoding: base64 237 | Content-Disposition: attachment; name=test.txt; 238 | filename="test.txt"; name="test.txt" 239 | 240 | VGhpcyBpcyBhIHRlc3Qgc3RyaW5n 241 | ------=_Part_1_1234567890-- 242 | EOF; 243 | 244 | $message = new Message($messageString); 245 | 246 | $parts = $message->getParts(); 247 | 248 | expect($parts)->toHaveCount(2); 249 | 250 | $htmlPart = $parts[0]; 251 | 252 | expect($htmlPart->getContentType())->toBe('text/html; charset="utf-8"') 253 | ->and($htmlPart->isHtml())->toBe(true); 254 | 255 | $attachmentPart = $parts[1]; 256 | 257 | expect($attachmentPart->getContent())->toBe('This is a test string') 258 | ->and($attachmentPart->isAttachment())->toBe(true) 259 | ->and($attachmentPart->getFilename())->toBe('test.txt'); 260 | 261 | $attachments = $message->getAttachments(); 262 | expect($attachments)->toHaveCount(1) 263 | ->and($attachments)->toHaveKey(0); 264 | }); 265 | 266 | it('skips initial content that is not part of the message', function () { 267 | $messageString = << 281 | 282 | This is an HTML email 283 | 284 | 285 |

This is the HTML version of the email

286 | 287 | 288 | 289 | ------=_Part_1_1234567890-- 290 | EOF; 291 | 292 | $message = Message::fromString($messageString); 293 | 294 | expect($message->getFrom())->toBe('sender@example.com') 295 | ->and($message->getHtmlPart()?->getContent())->toBe(<< 297 | 298 | This is an HTML email 299 | 300 | 301 |

This is the HTML version of the email

302 | 303 | 304 | EOF); 305 | }); 306 | 307 | it('catches boundaries on the same line', function () { 308 | $messageString = << 320 | 321 | This is an HTML email 322 | 323 | 324 |

This is the HTML version of the email

325 | 326 | --b552as-tfy 327 | Content-Type: text/plain; name=test.txt 328 | Content-Transfer-Encoding: base64 329 | Content-Disposition: attachment; name=test.txt; 330 | filename="test.txt"; name="test.txt" 331 | 332 | VGhpcyBpcyBhIHRlc3Qgc3RyaW5n--b552as-tfy-- 333 | EOF; 334 | 335 | $message = Message::fromString($messageString); 336 | 337 | expect($message->getParts())->toHaveCount(2) 338 | ->and($message->getParts()[0]->getContent())->toBe(<< 340 | 341 | This is an HTML email 342 | 343 | 344 |

This is the HTML version of the email

345 | 346 | 347 | EOF) 348 | ->and($message->getParts()[1]->getContent())->toBe('This is a test string'); 349 | }); 350 | 351 | it('still parses with a broken boundary', function () { 352 | $messageString = << 364 | 365 | This is an HTML email 366 | 367 | 368 |

This is the HTML version of the email

369 | 370 | --a8cQXEYh 371 | Content-Type: text/plain; name=test.txt 372 | Content-Transfer-Encoding: base64 373 | Content-Disposition: attachment; name=test.txt; 374 | filename="test.txt"; name="test.txt" 375 | 376 | 377 | --a8cQXEYh-- 378 | EOF; 379 | $messageString = str_replace("\n", "\r\n", $messageString); 380 | 381 | $message = Message::fromString($messageString); 382 | 383 | expect($message->getParts())->toHaveCount(2) 384 | ->and($message->getParts()[1]->isAttachment())->toBe(true) 385 | ->and($message->getParts()[1]->getContent())->toBeEmpty(); 386 | }); 387 | --------------------------------------------------------------------------------