Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add MailToFile Sender #151

Merged
merged 16 commits into from
Jan 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,9 +214,13 @@ Environment variables can also be used to set endpoints:
Buggregator Trap provides a variety of "senders" that dictate where the dumps will be sent. Currently, the available
sender options include:

- `console`: This option displays dumps directly in the console.
- `server`: With this choice, dumps are sent to a remote Buggregator server.
- `file`: This allows for dumps to be stored in a file for future reference.
- `console`: Shows dumps directly in the console.
- `server`: Sends dumps to a remote Buggregator server.
- `file`: Saves dumps in a file for later use.
- `mail-to-file`: Creates a folder for each recipient and saves each message as a JSON file. Useful for testing mails.
If you send a mail `To: [email protected], [email protected]`, the following folders will be created:
- `runtime/mail/[email protected]`
- `runtime/mail/[email protected]`

By default, the Trap server is set to display dumps in the console. However, you can easily select your preferred
senders using the `-s` option.
Expand Down
1 change: 1 addition & 0 deletions src/Command/Run.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<?php

Check failure on line 1 in src/Command/Run.php

View workflow job for this annotation

GitHub Actions / phpstan (ubuntu-latest, 8.2, locked)

Ignored error pattern #^Short ternary operator is not allowed\. Use null coalesce operator if applicable or consider using long ternary\.$# in path /home/runner/work/trap/trap/src/Command/Run.php was not matched in reported errors.

declare(strict_types=1);

Expand Down Expand Up @@ -83,6 +83,7 @@
$registry->register('console', Sender\ConsoleSender::create($output));
$registry->register('file', new Sender\EventsToFileSender());
$registry->register('file-body', new Sender\BodyToFileSender());
$registry->register('mail-to-file', new Sender\MailToFileSender());
$registry->register(
'server',
new Sender\RemoteSender(
Expand Down
2 changes: 1 addition & 1 deletion src/Proto/Frame/Http.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public function __construct(

public static function fromString(string $payload, \DateTimeImmutable $time): static
{
$payload = \json_decode($payload, true, \JSON_THROW_ON_ERROR);
$payload = \json_decode($payload, true, 64, \JSON_THROW_ON_ERROR);

$request = new ServerRequest(
$payload['method'] ?? 'GET',
Expand Down
6 changes: 3 additions & 3 deletions src/Proto/Frame/Smtp.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ public function __construct(

public static function fromString(string $payload, \DateTimeImmutable $time): static
{
/** @var TArrayData $payload */
$payload = \json_decode($payload, true, \JSON_THROW_ON_ERROR);
$message = Message\Smtp::fromArray($payload);
/** @var TArrayData $data */
$data = \json_decode($payload, true, 64, \JSON_THROW_ON_ERROR);
$message = Message\Smtp::fromArray($data);

return new self($message, $time);
}
Expand Down
8 changes: 4 additions & 4 deletions src/Sender/BodyToFileSender.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
namespace Buggregator\Trap\Sender;

use Buggregator\Trap\Proto\Frame;
use Buggregator\Trap\Proto\StreamCarrier;
use Buggregator\Trap\Sender;
use Buggregator\Trap\Support\FileSystem;
use Buggregator\Trap\Support\StreamHelper;
use Nyholm\Psr7\Stream;

Expand All @@ -23,9 +25,7 @@ public function __construct(
string $path = 'runtime/body',
) {
$this->path = \rtrim($path, '/\\');
if (!\is_dir($path) && !\mkdir($path, 0o777, true) && !\is_dir($path)) {
throw new \RuntimeException(\sprintf('Directory "%s" was not created', $path));
}
FileSystem::mkdir($path);
}

public function send(iterable $frames): void
Expand All @@ -35,7 +35,7 @@ public function send(iterable $frames): void

/** @var Frame $frame */
foreach ($frames as $frame) {
if (!$frame instanceof \Buggregator\Trap\Proto\StreamCarrier) {
if (!$frame instanceof StreamCarrier) {
continue;
}

Expand Down
5 changes: 2 additions & 3 deletions src/Sender/EventsToFileSender.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Buggregator\Trap\Proto\Frame;
use Buggregator\Trap\Sender;
use Buggregator\Trap\Support\FileSystem;

/**
* Store event groups to files.
Expand All @@ -21,9 +22,7 @@ public function __construct(
string $path = 'runtime',
) {
$this->path = \rtrim($path, '/\\');
if (!\is_dir($path) && !\mkdir($path, 0o777, true) && !\is_dir($path)) {
throw new \RuntimeException(\sprintf('Directory "%s" was not created', $path));
}
FileSystem::mkdir($path);
}

public function send(iterable $frames): void
Expand Down
76 changes: 76 additions & 0 deletions src/Sender/MailToFileSender.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

declare(strict_types=1);

namespace Buggregator\Trap\Sender;

use Buggregator\Trap\Proto\Frame;
use Buggregator\Trap\Proto\Frame\Smtp;
use Buggregator\Trap\Sender;
use Buggregator\Trap\Support\FileSystem;
use Buggregator\Trap\Traffic\Message;
use Buggregator\Trap\Traffic\Message\Smtp\Contact;

/**
* @internal
*/
class MailToFileSender implements Sender
{
private readonly string $path;

public function __construct(
string $path = 'runtime/mail',
) {
$this->path = \rtrim($path, '/\\');
FileSystem::mkdir($path);
}

public function send(iterable $frames): void
{
/** @var Frame $frame */
foreach ($frames as $frame) {
if (!$frame instanceof Smtp) {
continue;
}

foreach (self::fetchDirectories($frame->message) as $dirName) {
$path = $this->path . DIRECTORY_SEPARATOR . $dirName;
FileSystem::mkdir($path);
$filepath = \sprintf("%s/%s.json", $path, $frame->time->format('Y-m-d-H-i-s-v'));

\assert(!\file_exists($filepath));
\file_put_contents($filepath, \json_encode($frame->message, \JSON_PRETTY_PRINT | \JSON_THROW_ON_ERROR));
}
}
}

/**
* Get normalized email address for file or directory name.
*
* @return non-empty-string

Check failure on line 50 in src/Sender/MailToFileSender.php

View workflow job for this annotation

GitHub Actions / psalm (ubuntu-latest, 8.2, locked)

MoreSpecificReturnType

src/Sender/MailToFileSender.php:50:16: MoreSpecificReturnType: The declared return type 'non-empty-string' for Buggregator\Trap\Sender\MailToFileSender::normalizeEmail is more specific than the inferred return type 'null|string' (see https://psalm.dev/070)
*/
private static function normalizeEmail(string $email): string
{
return \preg_replace(

Check failure on line 54 in src/Sender/MailToFileSender.php

View workflow job for this annotation

GitHub Actions / psalm (ubuntu-latest, 8.2, locked)

LessSpecificReturnStatement

src/Sender/MailToFileSender.php:54:16: LessSpecificReturnStatement: The type 'null|string' is more general than the declared return type 'non-empty-string' for Buggregator\Trap\Sender\MailToFileSender::normalizeEmail (see https://psalm.dev/129)
['/[^a-z0-9.\\- @]/i', '/\s+/'],
['!', '_'],
$email,
);
}

/**
* @return list<non-empty-string>

Check failure on line 62 in src/Sender/MailToFileSender.php

View workflow job for this annotation

GitHub Actions / psalm (ubuntu-latest, 8.2, locked)

MoreSpecificReturnType

src/Sender/MailToFileSender.php:62:16: MoreSpecificReturnType: The declared return type 'list<non-empty-string>' for Buggregator\Trap\Sender\MailToFileSender::fetchDirectories is more specific than the inferred return type 'array<array-key, non-falsy-string>' (see https://psalm.dev/070)
*/
private static function fetchDirectories(Message\Smtp $message): array
{
return
\array_filter(

Check failure on line 67 in src/Sender/MailToFileSender.php

View workflow job for this annotation

GitHub Actions / psalm (ubuntu-latest, 8.2, locked)

LessSpecificReturnStatement

src/Sender/MailToFileSender.php:67:13: LessSpecificReturnStatement: The type 'array<array-key, non-falsy-string>' is more general than the declared return type 'list<non-empty-string>' for Buggregator\Trap\Sender\MailToFileSender::fetchDirectories (see https://psalm.dev/129)
\array_unique(
\array_map(
static fn(Contact $c) => self::normalizeEmail($c->email),

Check failure on line 70 in src/Sender/MailToFileSender.php

View workflow job for this annotation

GitHub Actions / psalm (ubuntu-latest, 8.2, locked)

PossiblyNullArgument

src/Sender/MailToFileSender.php:70:71: PossiblyNullArgument: Argument 1 of Buggregator\Trap\Sender\MailToFileSender::normalizeEmail cannot be null, possibly null value provided (see https://psalm.dev/078)
\array_merge($message->getBcc(), $message->getTo()),
),
),
);
}
}
19 changes: 19 additions & 0 deletions src/Support/FileSystem.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace Buggregator\Trap\Support;

/**
* @internal
* @psalm-internal Buggregator\Trap
*/
final class FileSystem
{
public static function mkdir(string $path, int $mode = 0777, bool $recursive = true): void
{
\is_dir($path) or \mkdir($path, $mode, $recursive) or \is_dir($path) or throw new \RuntimeException(
\sprintf('Directory "%s" was not created.', $path),
);
}
}
45 changes: 39 additions & 6 deletions src/Traffic/Message/Smtp.php
Original file line number Diff line number Diff line change
Expand Up @@ -141,15 +141,15 @@
*/
public function getTo(): array
{
return \array_map([$this, 'parseContact'], $this->getHeader('To'));
return $this->normalizeAddressList($this->getHeader('To'));
}

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

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

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

public function getSubject(): string
Expand All @@ -189,10 +189,43 @@

private function parseContact(string $line): Contact
{
if (\preg_match('/^\s*(?<name>.*)\s*<(?<email>.*)>\s*$/', $line, $matches) === 1) {
return new Contact($matches['name'] ?: null, $matches['email'] ?: null);
if (\preg_match('/^\s*+(?<name>.*?)\s*<(?<email>.*)>\s*$/', $line, $matches) === 1) {
$name = match (true) {
\preg_match('/^".*?"$/', $matches['name']) === 1 => \str_replace('\\"', '"', \substr($matches['name'], 1, -1)),
$matches['name'] === '' => null,
default => $matches['name'],
};

return new Contact(
$name,
$matches['email'] === '' ? null : \trim($matches['email']),
);
}

return new Contact(null, $line);
}

/**
* @return array<Contact>
*/
private 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);
}

/**
* @return array<Contact>
*/
private function normalizeAddressList(array $param): array
{
return \array_merge(
...\array_map([$this, 'parseDestinationAddress'], $param),

Check failure on line 228 in src/Traffic/Message/Smtp.php

View workflow job for this annotation

GitHub Actions / psalm (ubuntu-latest, 8.2, locked)

NamedArgumentNotAllowed

src/Traffic/Message/Smtp.php:228:16: NamedArgumentNotAllowed: Method array_merge called with named unpacked array array<array-key, array<array-key, Buggregator\Trap\Traffic\Message\Smtp\Contact>> (array with string keys) (see https://psalm.dev/268)

Check failure on line 228 in src/Traffic/Message/Smtp.php

View workflow job for this annotation

GitHub Actions / psalm (ubuntu-latest, 8.2, locked)

MixedArgument

src/Traffic/Message/Smtp.php:228:63: MixedArgument: Argument 1 of Buggregator\Trap\Traffic\Message\Smtp::parseDestinationAddress cannot be mixed, expecting string (see https://psalm.dev/030)
);
}
}
76 changes: 76 additions & 0 deletions tests/Unit/Sender/MailToFileSenderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

declare(strict_types=1);

namespace Buggregator\Trap\Tests\Unit\Sender;

use Buggregator\Trap\Info;
use Buggregator\Trap\Proto\Frame\Smtp as SmtpFrame;
use Buggregator\Trap\Sender\MailToFileSender;
use Buggregator\Trap\Traffic\Message\Smtp as SmtpMessage;
use PHPUnit\Framework\TestCase;

/**
* @coversDefaultClass \Buggregator\Trap\Sender\MailToFileSender
*/
final class MailToFileSenderTest extends TestCase
{
/** @var list<non-empty-string> */
private array $cleanupFolders = [];

public function testForSmtp(): void
{
$this->cleanupFolders[] = $root = Info::TRAP_ROOT . '/runtime/tests/mail-to-file-sender';

$message = SmtpMessage::create(
protocol: [
'FROM' => ['<[email protected]>'],
'BCC' => [
'<[email protected]>',
'<[email protected]>',
],
],
headers: [
'From' => ['Some User <[email protected]>'],
'To' => [
'User1 <[email protected]>',
'[email protected]',
'User without email', // no email
'User3 <[email protected]>, User4 <[email protected]>, [email protected]',
],
'Subject' => ['Very important theme'],
'Content-Type' => ['text/plain'],
],
);
$frame = new SmtpFrame($message);
$sender = new MailToFileSender($root);
$sender->send([$frame]);

$this->assertRecipient("$root/[email protected]");
$this->assertRecipient("$root/[email protected]");
$this->assertRecipient("$root/[email protected]");
$this->assertRecipient("$root/[email protected]");
$this->assertRecipient("$root/[email protected]");
}

protected function tearDown(): void
{
foreach ($this->cleanupFolders as $folder) {
\array_map('unlink', \glob("$folder/*/*.*"));
\array_map('rmdir', \glob("$folder/*"));
\rmdir($folder);
}
}

private function assertRecipient(string $folder): void
{
self::assertDirectoryExists($folder);
$files = \glob(\str_replace('[', '[[]', "$folder/*.json"));
self::assertCount(1, $files);
$arr = \json_decode(\file_get_contents($files[0]), true, \JSON_THROW_ON_ERROR);
self::assertArrayHasKey('protocol', $arr);
self::assertArrayHasKey('headers', $arr);
self::assertArrayHasKey('messages', $arr);
self::assertArrayHasKey('attachments', $arr);
}
}
Loading
Loading