Skip to content

Commit

Permalink
Merge pull request #153: Support multiple emails in one connection
Browse files Browse the repository at this point in the history
  • Loading branch information
roxblnfk authored Jan 19, 2025
2 parents f306fd0 + 7f6ad5b commit d3eb7a1
Show file tree
Hide file tree
Showing 6 changed files with 158 additions and 44 deletions.
26 changes: 14 additions & 12 deletions src/Sender/MailToFileSender.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,30 +47,32 @@ 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;
}

/**
* @return list<non-empty-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,
));
}
}
16 changes: 8 additions & 8 deletions src/Test/Mock/StreamClientMock.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,17 @@ final class StreamClientMock implements StreamClient
/** @var \SplQueue<string> */
private \SplQueue $queue;

private string $sendBuffer = '';
private bool $disconnected = false;

private function __construct(
private readonly \Generator $generator,
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
Expand Down Expand Up @@ -51,7 +55,7 @@ public function sendData(string $data): bool
}

$this->fetchFromGenerator();
$this->generator->send($data);
$this->sendBuffer .= $data;

return true;
}
Expand Down Expand Up @@ -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);
}
}
21 changes: 10 additions & 11 deletions src/Traffic/Dispatcher/Smtp.php
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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
Expand Down
27 changes: 14 additions & 13 deletions src/Traffic/Message/Smtp.php
Original file line number Diff line number Diff line change
Expand Up @@ -133,23 +133,23 @@ 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);
}

/**
* @return Contact[]
*/
public function getTo(): array
{
return $this->normalizeAddressList($this->getHeader('To'));
return self::normalizeAddressList($this->getHeader('To'));
}

/**
* @return Contact[]
*/
public function getCc(): array
{
return $this->normalizeAddressList($this->getHeader('Cc'));
return self::normalizeAddressList($this->getHeader('Cc'));
}

/**
Expand All @@ -160,15 +160,15 @@ public function getCc(): array
*/
public function getBcc(): array
{
return $this->normalizeAddressList($this->protocol['BCC'] ?? []);
return self::normalizeAddressList($this->protocol['BCC'] ?? []);
}

/**
* @return Contact[]
*/
public function getReplyTo(): array
{
return $this->normalizeAddressList($this->getHeader('Reply-To'));
return self::normalizeAddressList($this->getHeader('Reply-To'));
}

public function getSubject(): string
Expand All @@ -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*+(?<name>.*?)\s*<(?<email>.*)>\s*$/', $line, $matches) === 1) {
$name = match (true) {
Expand All @@ -206,26 +206,27 @@ private function parseContact(string $line): Contact
}

/**
* @return array<Contact>
* @return list<Contact>
*/
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<Contact>
* @param list<string> $param
* @return list<Contact>
*/
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),
);
}
}
110 changes: 110 additions & 0 deletions tests/Unit/Traffic/Dispatcher/SmtpTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?php

declare(strict_types=1);

namespace Buggregator\Trap\Tests\Unit\Traffic\Dispatcher;

use Buggregator\Trap\Proto\Frame\Smtp as SmtpFrame;
use Buggregator\Trap\Test\Mock\StreamClientMock;
use Buggregator\Trap\Tests\Unit\FiberTrait;
use Buggregator\Trap\Traffic\Dispatcher\Smtp;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;

#[CoversClass(Smtp::class)]
class SmtpTest extends TestCase
{
use FiberTrait;

public function testDispatchOneMail(): void
{
$stream = StreamClientMock::createFromGenerator($this->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: <[email protected]>\r\n";
yield "RCPT TO: <[email protected]>\r\n";
yield "RCPT TO: <[email protected]>\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(
['[email protected]', '[email protected]'],
$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: <[email protected]>\r\n";
yield "RCPT TO: <[email protected]>\r\n";
yield "RCPT TO: <[email protected]>\r\n";
yield "NOOP\r\n";
yield "DATA\r\n";
yield "From: [email protected]\r\n";
yield "To: [email protected]\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";
}
}
}
2 changes: 2 additions & 0 deletions tests/Unit/Traffic/Parser/SmtpParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down

0 comments on commit d3eb7a1

Please sign in to comment.