Skip to content

Commit

Permalink
fix(smtp): support multiline headers (RFC822 section 3.1.3) (#160)
Browse files Browse the repository at this point in the history
  • Loading branch information
roxblnfk authored Jan 23, 2025
1 parent 4359f6b commit 001c625
Show file tree
Hide file tree
Showing 3 changed files with 144 additions and 47 deletions.
2 changes: 2 additions & 0 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
failOnRisky="true"
executionOrder="random"
stderr="true"
displayDetailsOnTestsThatTriggerWarnings="true"
displayDetailsOnTestsThatTriggerDeprecations="true"
beStrictAboutOutputDuringTests="true"
>
<extensions>
Expand Down
44 changes: 41 additions & 3 deletions src/Traffic/Parser/Smtp.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,53 @@
*/
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) {
// Skip empty lines
if ($line === '') {
continue;
}

// Append to the previous header
if ($line[0] === ' ' || $line[0] === "\t") {
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");
Expand Down Expand Up @@ -54,8 +94,6 @@ public function parseStream(array $protocol, StreamClient $stream): Message\Smtp
? $this->processMultipartForm($message, $fileStream)
: $this->processSingleBody($message, $fileStream);



return $message;
}

Expand Down
145 changes: 101 additions & 44 deletions tests/Unit/Traffic/Parser/SmtpParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,26 +9,83 @@
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)]
final class SmtpParserTest extends TestCase
{
use FiberTrait;

public function testParseSimpleBody(): void
public static function provideHeaders()
{
yield [
"To: User1 <[email protected]>, User2 <[email protected]>, User3\r\n <[email protected]>\r\n",
['To' => ['User1 <[email protected]>, User2 <[email protected]>, User3 <[email protected]>']],
];
}

#[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 <[email protected]>\r
To: User1 <[email protected]>\r
Subject: Very important theme\r
Content-Type: text/plain\r
To: User1 <[email protected]>, User2 <[email protected]>, User3\r
<[email protected]>\r
Subject: ABCDEFGHIJKLMNOPQRSTUVWXYZ- ABCDEFGHIJKLMNOPQRSTUVWXYZ-\r
ABCDEFGHIJKLMNOPQRSTUVWXYZ- ABCDEFGHIJKLMNOPQRSTUVWXYZ-\r
ABCDEFGHIJKLMNOPQRSTUVWXYZ-\r
From: sandbox producer <[email protected]>\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 <[email protected]>'],
'To' => ['User1 <[email protected]>, User2 <[email protected]>, User3 <[email protected]>'],
'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 <[email protected]>\r
To: User1 <[email protected]>\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
Expand All @@ -45,43 +102,43 @@ public function testParseSimpleBody(): void
public function testParseMultipart(): void
{
$data = \str_split(<<<SMTP
From: [email protected]\r
To: [email protected]\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: [email protected]\r
To: [email protected]\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' => ['<[email protected]>'],
'BCC' => ['<[email protected]>', '<[email protected]>'],
Expand Down

0 comments on commit 001c625

Please sign in to comment.