diff --git a/src/Sender/MailToFileSender.php b/src/Sender/MailToFileSender.php index 536347b0..b05d93c8 100644 --- a/src/Sender/MailToFileSender.php +++ b/src/Sender/MailToFileSender.php @@ -47,15 +47,17 @@ public function send(iterable $frames): void /** * Get normalized email address for file or directory name. * - * @return non-empty-string + * @return non-empty-string|null */ - private static function normalizeEmail(string $email): string + private static function normalizeEmail(?string $email): ?string { - return \preg_replace( + $normalized = \preg_replace( ['/[^a-z0-9.\\- @]/i', '/\s+/'], ['!', '_'], - $email, + (string) $email, ); + + return $normalized === '' ? null : $normalized; } /** @@ -63,14 +65,14 @@ private static function normalizeEmail(string $email): string */ private static function fetchDirectories(Message\Smtp $message): array { - return - \array_filter( - \array_unique( - \array_map( - static fn(Contact $c) => self::normalizeEmail($c->email), - \array_merge($message->getBcc(), $message->getTo()), - ), + return \array_values(\array_filter( + \array_unique( + \array_map( + static fn(Contact $c): ?string => self::normalizeEmail($c->email), + \array_merge($message->getBcc(), $message->getTo()), ), - ); + ), + static fn(?string $dir): bool => $dir !== null, + )); } } diff --git a/src/Test/Mock/StreamClientMock.php b/src/Test/Mock/StreamClientMock.php index b1d7f341..b4b338a5 100644 --- a/src/Test/Mock/StreamClientMock.php +++ b/src/Test/Mock/StreamClientMock.php @@ -16,6 +16,7 @@ final class StreamClientMock implements StreamClient /** @var \SplQueue */ private \SplQueue $queue; + private string $sendBuffer = ''; private bool $disconnected = false; private function __construct( @@ -23,6 +24,9 @@ private function __construct( private readonly \DateTimeInterface $createdAt = new \DateTimeImmutable(), ) { $this->queue = new \SplQueue(); + // init the Generator + $value = (string) $this->generator->current(); + $value === '' or $this->queue->enqueue($value); } public static function createFromGenerator(\Generator $generator): StreamClient @@ -51,7 +55,7 @@ public function sendData(string $data): bool } $this->fetchFromGenerator(); - $this->generator->send($data); + $this->sendBuffer .= $data; return true; } @@ -141,12 +145,8 @@ private function fetchFromGenerator(): void if ($this->isFinished()) { return; } - $value = (string) $this->generator->current(); - - if ($value !== '') { - $this->queue->enqueue($value); - } - - $this->generator->next(); + [$toSend, $this->sendBuffer] = [$this->sendBuffer, '']; + $value = (string) $this->generator->send($toSend); + $value === '' or $this->queue->enqueue($value); } } diff --git a/src/Traffic/Dispatcher/Smtp.php b/src/Traffic/Dispatcher/Smtp.php index d3a6e816..85444105 100644 --- a/src/Traffic/Dispatcher/Smtp.php +++ b/src/Traffic/Dispatcher/Smtp.php @@ -27,16 +27,18 @@ public function __construct( $this->parser = new Parser\Smtp(); } + // todo support Auth https://www.rfc-editor.org/rfc/rfc4954 public function dispatch(StreamClient $stream): iterable { $stream->sendData($this->createResponse(self::READY, 'mailamie')); $protocol = []; - - $message = null; while (!$stream->isFinished()) { - $response = $stream->fetchLine(); - if (\preg_match('/^(?:EHLO|HELO)/', $response)) { + $response = \ltrim($stream->fetchLine(), ' '); + if (\str_starts_with($response, 'NOOP')) { + $stream->sendData($this->createResponse(self::OK)); + } elseif (\preg_match('/^(?:EHLO|HELO|RSET)/', $response)) { $stream->sendData($this->createResponse(self::OK)); + $protocol = []; } elseif (\preg_match('/^MAIL FROM:\s*<(.*)>/', $response, $matches)) { /** @var array{0: non-empty-string, 1: string} $matches */ $protocol['FROM'][] = $matches[1]; @@ -48,19 +50,16 @@ public function dispatch(StreamClient $stream): iterable } elseif (\str_starts_with($response, 'QUIT')) { $stream->sendData($this->createResponse(self::CLOSING)); $stream->disconnect(); + return; } elseif (\str_starts_with($response, 'DATA')) { $stream->sendData($this->createResponse(self::START_MAIL_INPUT)); - $message = $this->parser->parseStream($protocol, $stream); $stream->sendData($this->createResponse(self::OK)); + yield new Frame\Smtp($message, $stream->getCreatedAt()); + $protocol = []; + unset($message); } } - - if ($message === null) { - return; - } - - yield new Frame\Smtp($message, $stream->getCreatedAt()); } public function detect(string $data, \DateTimeImmutable $createdAt): ?bool diff --git a/src/Traffic/Message/Smtp.php b/src/Traffic/Message/Smtp.php index 0cd1659d..b6ebf3e3 100644 --- a/src/Traffic/Message/Smtp.php +++ b/src/Traffic/Message/Smtp.php @@ -133,7 +133,7 @@ public function getSender(): array { $addrs = \array_unique(\array_merge((array) ($this->protocol['FROM'] ?? []), $this->getHeader('From'))); - return \array_map([$this, 'parseContact'], $addrs); + return \array_map(self::parseContact(...), $addrs); } /** @@ -141,7 +141,7 @@ public function getSender(): array */ public function getTo(): array { - return $this->normalizeAddressList($this->getHeader('To')); + return self::normalizeAddressList($this->getHeader('To')); } /** @@ -149,7 +149,7 @@ public function getTo(): array */ public function getCc(): array { - return $this->normalizeAddressList($this->getHeader('Cc')); + return self::normalizeAddressList($this->getHeader('Cc')); } /** @@ -160,7 +160,7 @@ public function getCc(): array */ public function getBcc(): array { - return $this->normalizeAddressList($this->protocol['BCC'] ?? []); + return self::normalizeAddressList($this->protocol['BCC'] ?? []); } /** @@ -168,7 +168,7 @@ public function getBcc(): array */ public function getReplyTo(): array { - return $this->normalizeAddressList($this->getHeader('Reply-To')); + return self::normalizeAddressList($this->getHeader('Reply-To')); } public function getSubject(): string @@ -187,7 +187,7 @@ public function getMessage(MessageFormat $type): ?Field return null; } - private function parseContact(string $line): Contact + private static function parseContact(string $line): Contact { if (\preg_match('/^\s*+(?.*?)\s*<(?.*)>\s*$/', $line, $matches) === 1) { $name = match (true) { @@ -206,26 +206,27 @@ private function parseContact(string $line): Contact } /** - * @return array + * @return list */ - private function parseDestinationAddress(string $line): array + private static function parseDestinationAddress(string $line): array { // if this is a group recipient if (\preg_match('/^[^"]+:(.*);$/', $line, $matches) === 1) { $line = $matches[1]; } - $emailList = \array_map('trim', \explode(',', $line)); - return \array_map([$this, 'parseContact'], $emailList); + $emailList = \array_map(\trim(...), \explode(',', $line)); + return \array_map(self::parseContact(...), $emailList); } /** - * @return array + * @param list $param + * @return list */ - private function normalizeAddressList(array $param): array + private static function normalizeAddressList(array $param): array { return \array_merge( - ...\array_map([$this, 'parseDestinationAddress'], $param), + ...\array_map(self::parseDestinationAddress(...), $param), ); } } diff --git a/tests/Unit/Traffic/Dispatcher/SmtpTest.php b/tests/Unit/Traffic/Dispatcher/SmtpTest.php new file mode 100644 index 00000000..1ce1e9b2 --- /dev/null +++ b/tests/Unit/Traffic/Dispatcher/SmtpTest.php @@ -0,0 +1,110 @@ +mailMe(quit: true)); + + $this->runInFiber(static function () use ($stream): void { + $cnt = 0; + foreach ((new Smtp())->dispatch($stream) as $frame) { + self::assertInstanceOf(SmtpFrame::class, $frame); + self::assertSame('Test email', $frame->message->getSubject()); + ++$cnt; + } + + self::assertSame(1, $cnt, 'Only one frame should be yielded.'); + }); + } + + public function testDispatchOneMailWithRset(): void + { + $stream = StreamClientMock::createFromGenerator((function (\Generator ...$generators) { + yield "EHLO\r\n"; + yield "MAIL FROM: \r\n"; + yield "RCPT TO: \r\n"; + yield "RCPT TO: \r\n"; + yield "RSET\r\n"; + yield from $this->mailMe('Test email'); + })()); + + $this->runInFiber(static function () use ($stream): void { + $cnt = 0; + foreach ((new Smtp())->dispatch($stream) as $frame) { + self::assertInstanceOf(SmtpFrame::class, $frame); + self::assertSame("Test email", $frame->message->getSubject()); + self::assertSame( + ['user1@company.tld', 'user2@company.tld'], + $frame->message->getProtocol()['BCC'], + 'RSET should clear the buffered data.', + ); + ++$cnt; + } + + self::assertSame(1, $cnt, 'Only one frame should be yielded.'); + }); + } + + public function testDispatchMultipleMails(): void + { + $stream = StreamClientMock::createFromGenerator((static function (\Generator ...$generators) { + foreach ($generators as $generator) { + yield from $generator; + } + })( + $this->mailMe('Test email 1'), + $this->mailMe('Test email 2'), + $this->mailMe('Test email 3'), + )); + + $this->runInFiber(static function () use ($stream): void { + $i = 1; + foreach ((new Smtp())->dispatch($stream) as $frame) { + self::assertInstanceOf(SmtpFrame::class, $frame); + self::assertSame("Test email $i", $frame->message->getSubject()); + + if (++$i === 3) { + return; + } + } + + self::fail('No frame was yielded.'); + }); + } + + private function mailMe(string $subject = 'Test email', bool $quit = false): \Generator + { + yield "EHLO\r\n"; + yield "MAIL FROM: \r\n"; + yield "RCPT TO: \r\n"; + yield "RCPT TO: \r\n"; + yield "NOOP\r\n"; + yield "DATA\r\n"; + yield "From: sender@example.com\r\n"; + yield "To: recipient@example.com\r\n"; + yield "Subject: $subject\r\n"; + yield "Content-Type: text/plain\r\n"; + yield "\r\n"; + yield "Hello, this is a test email.\r\n"; + yield ".\r\n"; + yield "NOOP\r\n"; + if ($quit) { + yield "QUIT\r\n"; + } + } +} diff --git a/tests/Unit/Traffic/Parser/SmtpParserTest.php b/tests/Unit/Traffic/Parser/SmtpParserTest.php index 7dd2b8fb..d82ce523 100644 --- a/tests/Unit/Traffic/Parser/SmtpParserTest.php +++ b/tests/Unit/Traffic/Parser/SmtpParserTest.php @@ -8,8 +8,10 @@ use Buggregator\Trap\Tests\Unit\FiberTrait; use Buggregator\Trap\Traffic\Message; use Buggregator\Trap\Traffic\Parser; +use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +#[CoversClass(Parser\Smtp::class)] final class SmtpParserTest extends TestCase { use FiberTrait;