diff --git a/src/Traffic/Parser/Smtp.php b/src/Traffic/Parser/Smtp.php index 2cfd6e8..ebad5a5 100644 --- a/src/Traffic/Parser/Smtp.php +++ b/src/Traffic/Parser/Smtp.php @@ -17,13 +17,48 @@ */ final class Smtp { + /** + * @return array<array-key, non-empty-list<non-empty-string>> + */ + public static function parseHeaders(string $headersBlock): array + { + $result = []; + $name = null; + $value = ''; + foreach (\explode("\r\n", $headersBlock) as $line) { + if ($line[0] === ' ' || $line[0] === "\t") { + // Append to the previous header + if ($name === null) { + continue; + } + + $value .= $line; + continue; + } + + // Store previous header + $name === null or $value === '' or $result[$name][] = $value; + + // New header + [$name, $value] = \explode(':', $line, 2) + [1 => '']; + $name = \trim($name); + $value = \ltrim($value); + $name === '' || $value === '' and $name = null; + } + + // Store last header + $name === null or $value === '' or $result[$name][] = $value; + + return $result; + } + /** * @param array<non-empty-string, list<string>> $protocol */ public function parseStream(array $protocol, StreamClient $stream): Message\Smtp { $headerBlock = Http::getBlock($stream); - $headers = Http::parseHeaders($headerBlock); + $headers = self::parseHeaders($headerBlock); $fileStream = StreamHelper::createFileStream(); // Store read headers to the file stream. $fileStream->write($headerBlock . "\r\n\r\n"); diff --git a/tests/Unit/Traffic/Parser/SmtpParserTest.php b/tests/Unit/Traffic/Parser/SmtpParserTest.php index d82ce52..af721be 100644 --- a/tests/Unit/Traffic/Parser/SmtpParserTest.php +++ b/tests/Unit/Traffic/Parser/SmtpParserTest.php @@ -9,6 +9,7 @@ use Buggregator\Trap\Traffic\Message; use Buggregator\Trap\Traffic\Parser; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; #[CoversClass(Parser\Smtp::class)] @@ -16,19 +17,75 @@ final class SmtpParserTest extends TestCase { use FiberTrait; - public function testParseSimpleBody(): void + public static function provideHeaders() + { + yield [ + "To: User1 <gam6itko@mail.ru>, User2 <gam6itko@gmail.com>, User3\r\n <gam6itko@yandex.ru>\r\n", + ['To' => ['User1 <gam6itko@mail.ru>, User2 <gam6itko@gmail.com>, User3 <gam6itko@yandex.ru>']], + ]; + } + + #[DataProvider('provideHeaders')] + public function testParseHeader(string $headerString, array $expected): void + { + self::assertSame($expected, Parser\Smtp::parseHeaders($headerString)); + } + + public function testMultilineHeaders(): void { $data = \str_split(<<<SMTP - From: Some User <someusername@somecompany.ru>\r - To: User1 <user1@company.tld>\r - Subject: Very important theme\r - Content-Type: text/plain\r + To: User1 <gam6itko@mail.ru>, User2 <gam6itko@gmail.com>, User3\r + <gam6itko@yandex.ru>\r + Subject: ABCDEFGHIJKLMNOPQRSTUVWXYZ- ABCDEFGHIJKLMNOPQRSTUVWXYZ-\r + ABCDEFGHIJKLMNOPQRSTUVWXYZ- ABCDEFGHIJKLMNOPQRSTUVWXYZ-\r + ABCDEFGHIJKLMNOPQRSTUVWXYZ-\r + From: sandbox producer <test@producer.sandbox>\r + Content-Type: multipart/alternative; boundary=_ZwZ4SBw\r + \r + --_ZwZ4SBw\r + Content-Type: text/plain; charset=utf-8\r + Content-Transfer-Encoding: quoted-printable\r + \r + Hello from Producer!\r \r - Hi!\r - .\r\n + --_ZwZ4SBw\r + Content-Type: text/html; charset=utf-8\r + Content-Transfer-Encoding: quoted-printable\r + \r + <h1>Hello from Producer!</h1>\r + \r + --_ZwZ4SBw--\r + \r\n.\r\n SMTP, 10); $message = $this->parse($data); + self::assertSame(\implode('', $data), (string) $message->getBody()); + self::assertCount(2, $message->getMessages()); + // Check headers + self::assertEquals([ + 'From' => ['sandbox producer <test@producer.sandbox>'], + 'To' => ['User1 <gam6itko@mail.ru>, User2 <gam6itko@gmail.com>, User3 <gam6itko@yandex.ru>'], + 'Subject' => ['ABCDEFGHIJKLMNOPQRSTUVWXYZ- ABCDEFGHIJKLMNOPQRSTUVWXYZ- ABCDEFGHIJKLMNOPQRSTUVWXYZ- ABCDEFGHIJKLMNOPQRSTUVWXYZ- ABCDEFGHIJKLMNOPQRSTUVWXYZ-'], + 'Content-Type' => ['multipart/alternative; boundary=_ZwZ4SBw'], + ], $message->getHeaders()); + // Check body + self::assertSame("Hello from Producer!\r\n", $message->getMessages()[0]->getValue()); + self::assertSame(" <h1>Hello from Producer!</h1>\r\n", $message->getMessages()[1]->getValue()); + } + + public function testParseSimpleBody(): void + { + $data = \str_split(<<<SMTP + From: Some User <someusername@somecompany.ru>\r + To: User1 <user1@company.tld>\r + Subject: Very important theme\r + Content-Type: text/plain\r + \r + Hi!\r + .\r\n + SMTP, 10); + $message = $this->parse($data); + self::assertSame(\implode('', $data), (string) $message->getBody()); self::assertCount(1, $message->getMessages()); // Check headers @@ -45,43 +102,43 @@ public function testParseSimpleBody(): void public function testParseMultipart(): void { $data = \str_split(<<<SMTP - From: sender@example.com\r - To: recipient@example.com\r - Subject: Multipart Email Example\r - Content-Type: multipart/alternative; boundary="boundary-string"\r - \r - --boundary-string\r - Content-Type: text/plain; charset="utf-8"\r - Content-Transfer-Encoding: quoted-printable\r - Content-Disposition: inline\r - \r - Plain text email goes here!\r - This is the fallback if email client does not support HTML\r - \r - --boundary-string\r - Content-Type: text/html; charset="utf-8"\r - Content-Transfer-Encoding: quoted-printable\r - Content-Disposition: inline\r - \r - <h1>This is the HTML Section!</h1>\r - <p>This is what displays in most modern email clients</p>\r - \r - --boundary-string--\r - Content-Type: image/x-icon\r - Content-Transfer-Encoding: base64\r - Content-Disposition: attachment;filename=logo.ico\r - \r - 123456789098765432123456789\r - \r - --boundary-string--\r - Content-Type: text/watch-html; charset="utf-8"\r - Content-Transfer-Encoding: quoted-printable\r - Content-Disposition: inline\r - \r - <b>Apple Watch formatted content</b>\r - \r - --boundary-string--\r\n\r\n - SMTP, 10); + From: sender@example.com\r + To: recipient@example.com\r + Subject: Multipart Email Example\r + Content-Type: multipart/alternative; boundary="boundary-string"\r + \r + --boundary-string\r + Content-Type: text/plain; charset="utf-8"\r + Content-Transfer-Encoding: quoted-printable\r + Content-Disposition: inline\r + \r + Plain text email goes here!\r + This is the fallback if email client does not support HTML\r + \r + --boundary-string\r + Content-Type: text/html; charset="utf-8"\r + Content-Transfer-Encoding: quoted-printable\r + Content-Disposition: inline\r + \r + <h1>This is the HTML Section!</h1>\r + <p>This is what displays in most modern email clients</p>\r + \r + --boundary-string--\r + Content-Type: image/x-icon\r + Content-Transfer-Encoding: base64\r + Content-Disposition: attachment;filename=logo.ico\r + \r + 123456789098765432123456789\r + \r + --boundary-string--\r + Content-Type: text/watch-html; charset="utf-8"\r + Content-Transfer-Encoding: quoted-printable\r + Content-Disposition: inline\r + \r + <b>Apple Watch formatted content</b>\r + \r + --boundary-string--\r\n\r\n + SMTP, 10); $message = $this->parse($data, [ 'FROM' => ['<someusername@foo.bar>'], 'BCC' => ['<user1@company.tld>', '<user2@company.tld>'],