From f31c17150314e9d2fc23327dbcbd7e3839b699c2 Mon Sep 17 00:00:00 2001 From: bidi Date: Thu, 29 Oct 2026 17:10:12 +0200 Subject: [PATCH 1/5] code refactor - medium risk Signed-off-by: bidi --- .github/workflows/codecov.yml | 2 +- .github/workflows/static-analysis.yml | 2 +- src/Email.php | 85 +------------- src/Event/MailEvent.php | 4 +- src/Factory/AbstractMailFactory.php | 2 +- src/Factory/MailServiceAbstractFactory.php | 17 +-- src/Options/MailOptions.php | 8 +- src/Options/MessageOptions.php | 6 + src/Options/SmtpOptions.php | 2 +- src/Service/LogService.php | 9 +- src/Service/MailService.php | 12 +- test/EmailTest.php | 32 +++++ test/Event/MailEventTest.php | 11 ++ test/Factory/AbstractMailFactoryTest.php | 14 +++ .../MailServiceAbstractFactoryTest.php | 111 ++++++++++++++++++ test/Options/MailOptionsTest.php | 10 ++ test/Options/MessageOptionsTest.php | 10 ++ test/Options/SmtpOptionsTest.php | 5 + test/Service/LogServiceTest.php | 20 ++++ test/Service/MailServiceTest.php | 53 +++++++++ 20 files changed, 314 insertions(+), 101 deletions(-) diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index c0fbc53..e60152a 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -15,10 +15,10 @@ jobs: - ubuntu-latest php: - - "8.2" - "8.3" - "8.4" - "8.5" + - "8.6" steps: - name: Checkout diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 9976515..1bf077c 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -15,10 +15,10 @@ jobs: - ubuntu-latest php: - - "8.2" - "8.3" - "8.4" - "8.5" + - "8.6" steps: - name: Checkout diff --git a/src/Email.php b/src/Email.php index 7640ce7..0d605e6 100644 --- a/src/Email.php +++ b/src/Email.php @@ -12,20 +12,12 @@ use Symfony\Component\Mime\Message; use Symfony\Component\Mime\Part\AbstractPart; use Symfony\Component\Mime\Part\Multipart\AlternativePart; -use Symfony\Component\Mime\Part\Multipart\MixedPart; -use Symfony\Component\Mime\Part\Multipart\RelatedPart; use Symfony\Component\Mime\Part\TextPart; -use function array_filter; -use function array_merge; -use function array_unique; -use function array_values; use function is_array; use function is_string; -use function preg_match_all; use function sprintf; use function sscanf; -use function str_replace; class Email extends Message { @@ -48,7 +40,6 @@ class Email extends Message private ?string $html = null; private ?string $htmlCharset = null; - private array $attachments = []; public function subject(string $subject): static { @@ -265,8 +256,8 @@ public function ensureValidity(): void private function ensureBodyValid(): void { - if (null === $this->text && null === $this->html && ! $this->attachments) { - throw new LogicException('A message must have a text or an HTML part or attachments.'); + if (null === $this->text && null === $this->html) { + throw new LogicException('A message must have a text or an HTML part.'); } } @@ -274,81 +265,15 @@ private function generateBody(): AbstractPart { $this->ensureBodyValid(); - [$htmlPart, $otherParts, $relatedParts] = $this->prepareParts(); - $part = null === $this->text ? null : new TextPart($this->text, $this->textCharset); - if (null !== $htmlPart) { - if (null !== $part) { - $part = new AlternativePart($part, $htmlPart); - } else { - $part = $htmlPart; - } - } - - if ($relatedParts) { - $part = new RelatedPart($part, ...$relatedParts); - } - - if ($otherParts) { - if ($part) { - $part = new MixedPart($part, ...$otherParts); - } else { - $part = new MixedPart(...$otherParts); - } + if (null !== $this->html) { + $htmlPart = new TextPart($this->html, $this->htmlCharset, 'html'); + $part = null !== $part ? new AlternativePart($part, $htmlPart) : $htmlPart; } return $part ?? new TextPart((string) $this->text, $this->textCharset); } - private function prepareParts(): array - { - $names = []; - $htmlPart = null; - $html = $this->html; - if (null !== $html) { - $htmlPart = new TextPart($html, $this->htmlCharset, 'html'); - $html = $htmlPart->getBody(); - - $regexes = [ - ']*src\s*=\s*(?:([\'"])cid:(.+?)\\1|cid:([^>\s]+))', - '<\w+\s+[^>]*background\s*=\s*(?:([\'"])cid:(.+?)\\1|cid:([^>\s]+))', - ]; - $tmpMatches = []; - foreach ($regexes as $regex) { - preg_match_all('/' . $regex . '/i', $html, $tmpMatches); - $names = array_merge($names, $tmpMatches[2], $tmpMatches[3]); - } - $names = array_filter(array_unique($names)); - } - - $otherParts = $relatedParts = []; - foreach ($this->attachments as $part) { - foreach ($names as $name) { - if ($name !== $part->getName() && (! $part->hasContentId() || $name !== $part->getContentId())) { - continue; - } - if (isset($relatedParts[$name])) { - continue 2; - } - - if ($name !== $part->getContentId()) { - $html = str_replace('cid:' . $name, 'cid:' . $part->getContentId(), $html, $count); - } - $relatedParts[$name] = $part; - $part->setName($part->getContentId())->asInline(); - - continue 2; - } - - $otherParts[] = $part; - } - if (null !== $htmlPart) { - $htmlPart = new TextPart($html, $this->htmlCharset, 'html'); - } - - return [$htmlPart, $otherParts, array_values($relatedParts)]; - } - private function setHeaderBody(string $type, string $name, mixed $body): static { $this->getHeaders()->setHeaderBody($type, $name, $body); diff --git a/src/Event/MailEvent.php b/src/Event/MailEvent.php index 5ad801f..a835825 100644 --- a/src/Event/MailEvent.php +++ b/src/Event/MailEvent.php @@ -21,7 +21,7 @@ class MailEvent extends Event public const EVENT_MAIL_SEND_ERROR = 'event.mail.send.error'; protected MailServiceInterface $mailService; - protected ResultInterface $result; + protected ?ResultInterface $result = null; public function __construct(MailServiceInterface $mailService, string $name = self::EVENT_MAIL_PRE_SEND) { @@ -39,7 +39,7 @@ public function setMailService(MailServiceInterface $mailService): void $this->mailService = $mailService; } - public function getResult(): ResultInterface + public function getResult(): ?ResultInterface { return $this->result; } diff --git a/src/Factory/AbstractMailFactory.php b/src/Factory/AbstractMailFactory.php index 6ada22a..2756b33 100644 --- a/src/Factory/AbstractMailFactory.php +++ b/src/Factory/AbstractMailFactory.php @@ -37,7 +37,7 @@ public function canCreate(ContainerInterface $container, $requestedName): bool $specificServiceName = $parts[2]; $config = $this->getConfig($container); - return array_key_exists($specificServiceName, $config); + return array_key_exists($specificServiceName, $config) && $specificServiceName !== 'log'; } /** diff --git a/src/Factory/MailServiceAbstractFactory.php b/src/Factory/MailServiceAbstractFactory.php index d104550..621ef3b 100644 --- a/src/Factory/MailServiceAbstractFactory.php +++ b/src/Factory/MailServiceAbstractFactory.php @@ -72,7 +72,9 @@ public function __invoke( $body = $this->mailOptions->getMessageOptions()->getBody(); - $mailService->setBody($body->getContent(), $body->getCharset()); + if ($body->getContent() !== '') { + $mailService->setBody($body->getContent(), $body->getCharset()); + } //attach files $files = $this->mailOptions->getMessageOptions()->getAttachments()->getFiles(); @@ -179,12 +181,13 @@ protected function createTransport(ContainerInterface $container): TransportInte protected function setupTransportConfig(TransportInterface $transport): TransportInterface { if ($transport instanceof EsmtpTransport) { - $user = $this->mailOptions->getSmtpOptions()->getConnectionConfig()['username']; - $pass = rawurlencode($this->mailOptions->getSmtpOptions()->getConnectionConfig()['password']); - $tls = $this->mailOptions->getSmtpOptions()->getConnectionConfig()['tls'] === false ? 'false' : null; - $port = $this->mailOptions->getSmtpOptions()->getPort(); - $host = $this->mailOptions->getSmtpOptions()->getHost(); - $transport = Transport::fromDsn('smtp://' . $user . ':' . $pass . '@' . $host . ':' . $port + $connection = $this->mailOptions->getSmtpOptions()->getConnectionConfig(); + $user = rawurlencode((string) ($connection['username'] ?? '')); + $pass = rawurlencode((string) ($connection['password'] ?? '')); + $tls = ($connection['tls'] ?? null) === false ? 'false' : null; + $port = $this->mailOptions->getSmtpOptions()->getPort(); + $host = $this->mailOptions->getSmtpOptions()->getHost(); + $transport = Transport::fromDsn('smtp://' . $user . ':' . $pass . '@' . $host . ':' . $port . '?auto_tls=' . $tls); } diff --git a/src/Options/MailOptions.php b/src/Options/MailOptions.php index 7da067c..171107b 100644 --- a/src/Options/MailOptions.php +++ b/src/Options/MailOptions.php @@ -21,7 +21,7 @@ class MailOptions extends AbstractOptions { protected array $eventListeners = []; - protected TransportInterface|string $transport = EsmtpTransport::class; + protected TransportInterface|string $transport = SendmailTransport::class; protected array $transportMap = [ 'esmtp' => [EsmtpTransport::class], 'sendmail' => [SendmailTransport::class], @@ -61,6 +61,9 @@ public function setTransport(string|TransportInterface $transport): void public function getMessageOptions(): MessageOptions { + if (! isset($this->messageOptions)) { + $this->messageOptions = new MessageOptions(); + } return $this->messageOptions; } @@ -71,6 +74,9 @@ public function setMessageOptions(array $messageOptions): void public function getSmtpOptions(): SmtpOptions { + if (! isset($this->smtpOptions)) { + $this->smtpOptions = new SmtpOptions(); + } return $this->smtpOptions; } diff --git a/src/Options/MessageOptions.php b/src/Options/MessageOptions.php index 4076bad..ad1454f 100644 --- a/src/Options/MessageOptions.php +++ b/src/Options/MessageOptions.php @@ -105,6 +105,9 @@ public function setSubject(string $subject): void public function getBody(): BodyOptions { + if (! isset($this->body)) { + $this->body = new BodyOptions(); + } return $this->body; } @@ -115,6 +118,9 @@ public function setBody(array $body): void public function getAttachments(): AttachmentsOptions { + if (! isset($this->attachments)) { + $this->attachments = new AttachmentsOptions(); + } return $this->attachments; } diff --git a/src/Options/SmtpOptions.php b/src/Options/SmtpOptions.php index be8ccf0..46e942e 100644 --- a/src/Options/SmtpOptions.php +++ b/src/Options/SmtpOptions.php @@ -19,7 +19,7 @@ class SmtpOptions extends AbstractOptions protected string $host = '127.0.0.1'; protected int $port = 25; - protected ?int $connectionTimeLimit; + protected ?int $connectionTimeLimit = null; public function getName(): string { diff --git a/src/Service/LogService.php b/src/Service/LogService.php index 02e4290..890b80d 100644 --- a/src/Service/LogService.php +++ b/src/Service/LogService.php @@ -8,14 +8,15 @@ use Dot\Mail\Email; use function dirname; -use function file_exists; use function file_put_contents; +use function is_dir; use function json_encode; use function mkdir; use function sprintf; use function trim; use const FILE_APPEND; +use const LOCK_EX; use const PHP_EOL; class LogService implements LogServiceInterface @@ -43,8 +44,8 @@ public function sent(Email $message): false|int|null */ $dirname = dirname($target); - if (! file_exists($dirname)) { - mkdir($dirname, 0755, true); + if (! is_dir($dirname) && ! @mkdir($dirname, 0755, true) && ! is_dir($dirname)) { + return false; } /** @@ -63,7 +64,7 @@ public function sent(Email $message): false|int|null /** * Write the log data and return the result */ - return file_put_contents($target, $data, FILE_APPEND); + return file_put_contents($target, $data, FILE_APPEND | LOCK_EX); } public function extractAddresses(array $addressList): array diff --git a/src/Service/MailService.php b/src/Service/MailService.php index da943a0..d64b0a4 100644 --- a/src/Service/MailService.php +++ b/src/Service/MailService.php @@ -18,6 +18,7 @@ use Symfony\Component\Mime\Part\AbstractPart; use Symfony\Component\Mime\Part\DataPart; use Symfony\Component\Mime\Part\Multipart\MixedPart; +use Throwable; use function array_merge; use function basename; @@ -55,7 +56,10 @@ public function send(): ResultInterface { $result = new MailResult(); - $this->message->setEncoding('utf-8'); + if ($this->message->getEncoding() === null) { + $this->message->setEncoding(self::DEFAULT_CHARSET); + } + try { $this->getEventManager()->triggerEvent($this->createMailEvent()); @@ -78,8 +82,10 @@ public function send(): ResultInterface $this->getMessage()->setBody(null); } - if ($result->isValid()) { + try { $this->logService->sent($this->getMessage()); + } catch (Throwable) { + // a failed log write must never turn a delivered email into an error } return $result; @@ -123,7 +129,7 @@ public function attachFiles(): false|Email public function setBody(string|AbstractPart $body, ?string $charset = null): void { if (is_string($body)) { - $this->message->html($body); + $this->message->html($body, $charset ?? self::DEFAULT_CHARSET); } else { $this->message->setBody($body); } diff --git a/test/EmailTest.php b/test/EmailTest.php index 3d4f871..5af708c 100644 --- a/test/EmailTest.php +++ b/test/EmailTest.php @@ -9,6 +9,8 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Mime\Address; use Symfony\Component\Mime\Exception\LogicException; +use Symfony\Component\Mime\Part\Multipart\AlternativePart; +use Symfony\Component\Mime\Part\TextPart; class EmailTest extends TestCase { @@ -85,4 +87,34 @@ public function testAddressObjectsInArraysAreKept(): void $this->assertCount(1, $cc); $this->assertSame('Copy', $cc[0]->getName()); } + + public function testTextOnlyMessageHasOnlyTextPart(): void + { + $subject = new Email(); + $subject->text('plain text'); + + $body = $subject->getBody(); + $this->assertSame(TextPart::class, $body::class); + $this->assertSame('plain text', $body->getBody()); + } + + public function testTextAndHtmlMessageIsAlternative(): void + { + $subject = new Email(); + $subject->text('plain text'); + $subject->html('

html

'); + + $this->assertInstanceOf(AlternativePart::class, $subject->getBody()); + } + + public function testMessageWithoutBodyIsInvalid(): void + { + $subject = new Email(); + $subject->setFrom('from@example.com'); + $subject->setTo('to@example.com'); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('A message must have a text or an HTML part.'); + $subject->ensureValidity(); + } } diff --git a/test/Event/MailEventTest.php b/test/Event/MailEventTest.php index d6323be..171191b 100644 --- a/test/Event/MailEventTest.php +++ b/test/Event/MailEventTest.php @@ -5,6 +5,7 @@ namespace DotTest\Mail\Event; use Dot\Mail\Event\MailEvent; +use Dot\Mail\Result\MailResult; use Dot\Mail\Service\MailServiceInterface; use PHPUnit\Framework\MockObject\Exception; use PHPUnit\Framework\MockObject\MockObject; @@ -48,4 +49,14 @@ public function testEventPropagation(): void $event->stopPropagation(); $this->assertTrue($event->propagationIsStopped()); } + + public function testResultIsNullUntilSet(): void + { + $event = new MailEvent($this->mailService); + $this->assertNull($event->getResult()); + + $result = new MailResult(); + $event->setResult($result); + $this->assertSame($result, $event->getResult()); + } } diff --git a/test/Factory/AbstractMailFactoryTest.php b/test/Factory/AbstractMailFactoryTest.php index 319a6b5..3792dc3 100644 --- a/test/Factory/AbstractMailFactoryTest.php +++ b/test/Factory/AbstractMailFactoryTest.php @@ -102,4 +102,18 @@ public function testWillNotCreateRequestedNameWithoutParts(): void restore_error_handler(); } } + + /** + * @throws Exception + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testLogKeyIsNotACreatableService(): void + { + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturn($this->config); + + $this->assertTrue($this->subject->canCreate($container, 'dot-mail.testPart.default')); + $this->assertFalse($this->subject->canCreate($container, 'dot-mail.testPart.log')); + } } diff --git a/test/Factory/MailServiceAbstractFactoryTest.php b/test/Factory/MailServiceAbstractFactoryTest.php index 0519efb..c72bc9a 100644 --- a/test/Factory/MailServiceAbstractFactoryTest.php +++ b/test/Factory/MailServiceAbstractFactoryTest.php @@ -16,6 +16,7 @@ use Dot\Mail\Service\LogServiceInterface; use Dot\Mail\Service\MailService; use DotTest\Mail\CommonTrait; +use ErrorException; use org\bovigo\vfs\vfsStream; use PHPUnit\Framework\MockObject\Exception; use PHPUnit\Framework\MockObject\MockObject; @@ -23,8 +24,12 @@ use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface; use Psr\Container\NotFoundExceptionInterface; +use Symfony\Component\Mailer\Transport\SendmailTransport; use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; +use function restore_error_handler; +use function set_error_handler; + class MailServiceAbstractFactoryTest extends TestCase { use CommonTrait; @@ -262,4 +267,110 @@ public function testGenerateServiceThrowException(): void $this->expectException(InvalidArgumentException::class); ($this->subject)($this->container, $requestedName); } + + /** + * @throws ContainerExceptionInterface + * @throws Exception + * @throws NotFoundExceptionInterface + */ + public function testGenerateServiceFromMinimalConfiguration(): void + { + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturnMap([ + ['dot-mail.options.default', new MailOptions([])], + [LogServiceInterface::class, $this->createMock(LogService::class)], + ]); + + $mailService = ($this->subject)($container, 'dot-mail.service.default'); + + $this->assertInstanceOf(MailService::class, $mailService); + $this->assertInstanceOf(SendmailTransport::class, $mailService->getTransport()); + $this->assertNull($mailService->getMessage()->getHtmlBody()); + $this->assertSame([], $mailService->getAttachments()); + } + + /** + * @throws ContainerExceptionInterface + * @throws Exception + * @throws NotFoundExceptionInterface + */ + public function testGenerateServiceWithBodyContent(): void + { + $options = new MailOptions([ + 'transport' => 'sendmail', + 'message_options' => ['body' => ['content' => '

Hello

', 'charset' => 'iso-8859-1']], + ]); + + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturnMap([ + ['dot-mail.options.default', $options], + [LogServiceInterface::class, $this->createMock(LogService::class)], + ]); + + $mailService = ($this->subject)($container, 'dot-mail.service.default'); + + $this->assertSame('

Hello

', $mailService->getMessage()->getHtmlBody()); + $this->assertSame('iso-8859-1', $mailService->getMessage()->getHtmlCharset()); + } + + /** + * @throws ContainerExceptionInterface + * @throws Exception + * @throws NotFoundExceptionInterface + */ + public function testEsmtpTransportWithoutConnectionConfigKeys(): void + { + $options = new MailOptions([ + 'transport' => 'esmtp', + 'smtp_options' => ['host' => 'localhost', 'port' => 587], + ]); + + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturnMap([ + ['dot-mail.options.default', $options], + [LogServiceInterface::class, $this->createMock(LogService::class)], + ]); + + set_error_handler(static function (int $severity, string $message): never { + throw new ErrorException($message, 0, $severity); + }); + + try { + $transport = ($this->subject)($container, 'dot-mail.service.default')->getTransport(); + } finally { + restore_error_handler(); + } + + $this->assertInstanceOf(EsmtpTransport::class, $transport); + $this->assertSame('', $transport->getUsername()); + } + + /** + * @throws ContainerExceptionInterface + * @throws Exception + * @throws NotFoundExceptionInterface + */ + public function testEsmtpCredentialsAreUrlEncoded(): void + { + $options = new MailOptions([ + 'transport' => 'esmtp', + 'smtp_options' => [ + 'host' => 'localhost', + 'port' => 587, + 'connection_config' => ['username' => 'user@example.com', 'password' => 'p:ss/w@rd'], + ], + ]); + + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturnMap([ + ['dot-mail.options.default', $options], + [LogServiceInterface::class, $this->createMock(LogService::class)], + ]); + + $transport = ($this->subject)($container, 'dot-mail.service.default')->getTransport(); + + $this->assertInstanceOf(EsmtpTransport::class, $transport); + $this->assertSame('user@example.com', $transport->getUsername()); + $this->assertSame('p:ss/w@rd', $transport->getPassword()); + } } diff --git a/test/Options/MailOptionsTest.php b/test/Options/MailOptionsTest.php index ab427c7..29de436 100644 --- a/test/Options/MailOptionsTest.php +++ b/test/Options/MailOptionsTest.php @@ -50,4 +50,14 @@ public function testTransportAliasIsCaseInsensitive(): void $subject->setTransport('Custom\\Transport'); $this->assertSame('Custom\\Transport', $subject->getTransport()); } + + public function testDefaultsWithoutConfiguration(): void + { + $subject = new MailOptions(); + + $this->assertSame(SendmailTransport::class, $subject->getTransport()); + $this->assertContainsOnlyInstancesOf(MessageOptions::class, [$subject->getMessageOptions()]); + $this->assertContainsOnlyInstancesOf(SmtpOptions::class, [$subject->getSmtpOptions()]); + $this->assertSame($subject->getMessageOptions(), $subject->getMessageOptions()); + } } diff --git a/test/Options/MessageOptionsTest.php b/test/Options/MessageOptionsTest.php index 7a68400..42d31a5 100644 --- a/test/Options/MessageOptionsTest.php +++ b/test/Options/MessageOptionsTest.php @@ -48,4 +48,14 @@ public function testGettersAndSetters(): void $this->assertContainsOnlyInstancesOf(BodyOptions::class, [$subject->getBody()]); $this->assertContainsOnlyInstancesOf(AttachmentsOptions::class, [$subject->getAttachments()]); } + + public function testDefaultsWithoutConfiguration(): void + { + $subject = new MessageOptions(); + + $this->assertContainsOnlyInstancesOf(BodyOptions::class, [$subject->getBody()]); + $this->assertSame('', $subject->getBody()->getContent()); + $this->assertContainsOnlyInstancesOf(AttachmentsOptions::class, [$subject->getAttachments()]); + $this->assertSame([], $subject->getAttachments()->getFiles()); + } } diff --git a/test/Options/SmtpOptionsTest.php b/test/Options/SmtpOptionsTest.php index 81cdc08..d24e155 100644 --- a/test/Options/SmtpOptionsTest.php +++ b/test/Options/SmtpOptionsTest.php @@ -41,4 +41,9 @@ public function testPortBoundaries(): void $this->expectExceptionMessage('Port must be greater than 0; received "0"'); $subject->setPort(0); } + + public function testConnectionTimeLimitDefaultsToNull(): void + { + $this->assertNull((new SmtpOptions())->getConnectionTimeLimit()); + } } diff --git a/test/Service/LogServiceTest.php b/test/Service/LogServiceTest.php index e2bfe79..8692d20 100644 --- a/test/Service/LogServiceTest.php +++ b/test/Service/LogServiceTest.php @@ -83,4 +83,24 @@ public function testSentMailIsLogged(): void file_get_contents($this->fileSystem->url() . '/log/mail/sent.log') ); } + + /** + * @throws Exception + */ + public function testSentReturnsFalseWhenLogDirectoryCannotBeCreated(): void + { + // the "directory" of the target is an existing file, so it can be neither used nor created + $target = $this->fileSystem->url() . '/data/mail/attachments/testPdfAttachment.pdf/sent.log'; + $logService = new LogService(['log' => ['sent' => $target]]); + $message = $this->createMock(Email::class); + + $this->assertFalse($logService->sent($message)); + } + + public function testSentIsDisabledWithoutTarget(): void + { + $logService = new LogService(['log' => ['sent' => null]]); + + $this->assertNull($logService->sent(new Email())); + } } diff --git a/test/Service/MailServiceTest.php b/test/Service/MailServiceTest.php index 685711f..ad5027f 100644 --- a/test/Service/MailServiceTest.php +++ b/test/Service/MailServiceTest.php @@ -427,4 +427,57 @@ private function bodyContainsDataPart(AbstractPart $part): bool return false; } + + public function testSetBodyUsesProvidedCharset(): void + { + $this->mailService->setBody('

Body

', 'iso-8859-1'); + + $this->assertSame('iso-8859-1', $this->message->getHtmlCharset()); + } + + public function testSetBodyDefaultsToUtf8(): void + { + $this->mailService->setBody('

Body

'); + + $this->assertSame('utf-8', $this->message->getHtmlCharset()); + } + + public function testSendKeepsConfiguredEncoding(): void + { + $this->mailService->setBody('

Body

', 'iso-8859-1'); + $this->mailService->send(); + + $this->assertSame('iso-8859-1', $this->message->getEncoding()); + } + + public function testSendSetsDefaultEncodingWhenNoneIsSet(): void + { + $this->assertNull($this->message->getEncoding()); + + $this->mailService->send(); + + $this->assertSame('utf-8', $this->message->getEncoding()); + } + + /** + * @throws Exception + */ + public function testLogFailureDoesNotBreakSuccessfulSend(): void + { + $logService = $this->createMock(LogServiceInterface::class); + $logService->expects($this->once()) + ->method('sent') + ->willThrowException(new RuntimeException('Log failure')); + + $mailService = new MailService( + $logService, + new Email(), + $this->transportInterface, + $this->createMock(MailOptions::class) + ); + + $result = $mailService->send(); + + $this->assertTrue($result->isValid()); + } } From 62f1074e84eae40f3bff0fec627c29cad9099e3a Mon Sep 17 00:00:00 2001 From: bidi Date: Thu, 29 Oct 2026 17:55:04 +0200 Subject: [PATCH 2/5] code refactor - high risk Signed-off-by: bidi --- config/mail.global.php.dist | 139 +++++----- src/ConfigProvider.php | 4 +- src/Factory/AbstractMailFactory.php | 30 ++- src/Factory/MailServiceAbstractFactory.php | 119 ++++---- src/Options/SmtpOptions.php | 8 +- src/Result/MailResult.php | 10 +- src/Result/ResultInterface.php | 4 +- src/Service/MailService.php | 141 +++++++--- src/Service/MailServiceInterface.php | 6 + test/CommonTrait.php | 90 ++++--- test/ConfigProviderTest.php | 5 + test/Factory/AbstractMailFactoryTest.php | 55 ++++ .../MailServiceAbstractFactoryTest.php | 130 ++++++++- test/Result/MailResultTest.php | 11 + test/Service/MailServiceTest.php | 255 ++++++++++++++++-- 15 files changed, 745 insertions(+), 262 deletions(-) diff --git a/config/mail.global.php.dist b/config/mail.global.php.dist index ad0711d..cec53ff 100644 --- a/config/mail.global.php.dist +++ b/config/mail.global.php.dist @@ -9,78 +9,81 @@ return [ * what you'll usually do, these configs provide just defaults and options that remain the same for all mails */ 'dot_mail' => [ - //the key is the mail service name, this is the default one, which does not extend any configuration - 'default' => [ - //message configuration - 'message_options' => [ - //from email address of the email - 'from' => '', - //from name to be displayed instead of from address - 'from_name' => '', - //reply-to email address of the email - 'reply_to' => '', - //replyTo name to be displayed instead of the address - 'reply_to_name' => '', - //destination email address as string or a list of email addresses - 'to' => [], - //copy destination addresses - 'cc' => [], - //hidden copy destination addresses - 'bcc' => [], - //email subject - 'subject' => '', - //body options - content can be plain text, HTML - 'body' => [ - 'content' => '', - 'charset' => 'utf-8', - ], - //attachments config - 'attachments' => [ - 'files' => [], - 'dir' => [ - 'iterate' => false, - 'path' => 'data/mail/attachments', - 'recursive' => false, + // option to log the SENT emails + 'log' => [ + 'sent' => getcwd() . '/log/mail/sent.log', + ], + //the mail services, indexed by service name + 'services' => [ + //the key is the mail service name, this is the default one + 'default' => [ + //message configuration + 'message_options' => [ + //from email address of the email + 'from' => '', + //from name to be displayed instead of from address + 'from_name' => '', + //reply-to email address of the email + 'reply_to' => '', + //replyTo name to be displayed instead of the address + 'reply_to_name' => '', + //destination email address as string or a list of email addresses + 'to' => [], + //copy destination addresses + 'cc' => [], + //hidden copy destination addresses + 'bcc' => [], + //email subject + 'subject' => '', + //body options - content can be plain text, HTML + 'body' => [ + 'content' => '', + 'charset' => 'utf-8', + ], + //attachments config + 'attachments' => [ + 'files' => [], + 'dir' => [ + 'iterate' => false, + 'path' => 'data/mail/attachments', + 'recursive' => false, + ], ], ], - ], - /** - * the mail transport to use can be any class implementing - * Symfony\Component\Mailer\Transport\TransportInterface - * - * for standard mail transports, you can use these aliases: - * - sendmail => Symfony\Component\Mailer\Transport\SendmailTransport - * - esmtp => Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport - * - * defaults to sendmail - **/ - 'transport' => 'sendmail', - //options that will be used only if esmtp adapter is used - 'smtp_options' => [ - //hostname or IP address of the mail server - 'host' => '', - //port of the mail server - 587 or 465 for secure connections - 'port' => 587, - 'connection_config' => [ - //the smtp authentication identity - 'username' => '', - //the smtp authentication credential - 'password' => '', - /** - * tls will run by default on this component, use: - * - * null - to avoid interfering with automatic encryption - * false - to disable automatic encryption - * - * It's not recommended to disable TLS while connecting to an SMTP server over the Internet - **/ - 'tls' => null, + /** + * the mail transport to use can be any class implementing + * Symfony\Component\Mailer\Transport\TransportInterface + * + * for standard mail transports, you can use these aliases: + * - sendmail => Symfony\Component\Mailer\Transport\SendmailTransport + * - esmtp => Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport + * + * defaults to sendmail + **/ + 'transport' => 'sendmail', + //options that will be used only if esmtp adapter is used + 'smtp_options' => [ + //hostname or IP address of the mail server + 'host' => '', + //port of the mail server - 587 or 465 for secure connections + 'port' => 587, + 'connection_config' => [ + //the smtp authentication identity + 'username' => '', + //the smtp authentication credential + 'password' => '', + /** + * tls will run by default on this component, use: + * + * null - to avoid interfering with automatic encryption + * false - to disable automatic encryption + * + * It's not recommended to disable TLS while connecting to an SMTP server over the Internet + **/ + 'tls' => null, + ], ], ], ], - // option to log the SENT emails - 'log' => [ - 'sent' => getcwd() . '/log/mail/sent.log', - ], ], ]; diff --git a/src/ConfigProvider.php b/src/ConfigProvider.php index 6f6ae8e..53ec221 100644 --- a/src/ConfigProvider.php +++ b/src/ConfigProvider.php @@ -9,6 +9,7 @@ use Dot\Mail\Factory\MailServiceAbstractFactory; use Dot\Mail\Service\LogService; use Dot\Mail\Service\LogServiceInterface; +use Dot\Mail\Service\MailServiceInterface; class ConfigProvider { @@ -26,7 +27,8 @@ public function getDependencyConfig(): array LogService::class => LogServiceFactory::class, ], 'aliases' => [ - LogServiceInterface::class => LogService::class, + LogServiceInterface::class => LogService::class, + MailServiceInterface::class => 'dot-mail.service.default', ], 'abstract_factories' => [ MailServiceAbstractFactory::class, diff --git a/src/Factory/AbstractMailFactory.php b/src/Factory/AbstractMailFactory.php index 2756b33..1794f46 100644 --- a/src/Factory/AbstractMailFactory.php +++ b/src/Factory/AbstractMailFactory.php @@ -4,6 +4,7 @@ namespace Dot\Mail\Factory; +use Dot\Mail\Exception\InvalidArgumentException; use Laminas\ServiceManager\Factory\AbstractFactoryInterface; use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface; @@ -13,11 +14,14 @@ use function count; use function explode; use function is_array; +use function sprintf; abstract class AbstractMailFactory implements AbstractFactoryInterface { public const DOT_MAIL_PART = 'dot-mail'; public const SPECIFIC_PART = ''; + public const SERVICES_KEY = 'services'; + public const LOG_KEY = 'log'; protected string $configKey = 'dot_mail'; protected array $config = []; @@ -37,18 +41,38 @@ public function canCreate(ContainerInterface $container, $requestedName): bool $specificServiceName = $parts[2]; $config = $this->getConfig($container); - return array_key_exists($specificServiceName, $config) && $specificServiceName !== 'log'; + return array_key_exists($specificServiceName, $config); } /** + * Returns the configured mail services, indexed by name (the `dot_mail.services` key). + * * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface + * @throws InvalidArgumentException When the configuration uses the pre-6.0 layout. */ public function getConfig(ContainerInterface $container): array { $config = $container->get('config'); - if (isset($config[$this->configKey]) && is_array($config[$this->configKey])) { - $this->config = $config[$this->configKey]; + if (! isset($config[$this->configKey]) || ! is_array($config[$this->configKey])) { + return $this->config; + } + + $mailConfig = $config[$this->configKey]; + if (isset($mailConfig[self::SERVICES_KEY]) && is_array($mailConfig[self::SERVICES_KEY])) { + $this->config = $mailConfig[self::SERVICES_KEY]; + return $this->config; + } + + foreach ($mailConfig as $key => $value) { + if ($key !== self::LOG_KEY && is_array($value)) { + throw new InvalidArgumentException(sprintf( + 'Mail service "%s" must be configured under "%s.%s"; see the upgrade guide to version 6', + $key, + $this->configKey, + self::SERVICES_KEY + )); + } } return $this->config; diff --git a/src/Factory/MailServiceAbstractFactory.php b/src/Factory/MailServiceAbstractFactory.php index 621ef3b..136566d 100644 --- a/src/Factory/MailServiceAbstractFactory.php +++ b/src/Factory/MailServiceAbstractFactory.php @@ -4,7 +4,6 @@ namespace Dot\Mail\Factory; -use DirectoryIterator; use Dot\Mail\Email; use Dot\Mail\Event\MailEventListenerAwareInterface; use Dot\Mail\Event\MailEventListenerInterface; @@ -14,24 +13,19 @@ use Dot\Mail\Service\LogServiceInterface; use Dot\Mail\Service\MailService; use Dot\Mail\Service\MailServiceInterface; -use FilesystemIterator; use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface; use Psr\Container\NotFoundExceptionInterface; -use RecursiveDirectoryIterator; -use RecursiveIteratorIterator; -use Symfony\Component\Mailer\Transport; use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; +use Symfony\Component\Mailer\Transport\Smtp\Stream\SocketStream; use Symfony\Component\Mailer\Transport\TransportInterface; use function explode; use function gettype; use function is_array; -use function is_dir; use function is_object; use function is_string; use function is_subclass_of; -use function rawurlencode; use function sprintf; class MailServiceAbstractFactory extends AbstractMailFactory @@ -61,47 +55,12 @@ public function __invoke( ) ); - $logService = $container->get(LogServiceInterface::class); - $message = $this->createMessage(); - $transport = $this->createTransport($container); + $logService = $container->get(LogServiceInterface::class); + $message = $this->createMessage(); + $transport = $this->createTransport($container); + $attachments = $this->mailOptions->getMessageOptions()->getAttachments()->getFiles(); - $mailService = new MailService($logService, $message, $transport, $this->mailOptions); - - //set subject - $mailService->setSubject($this->mailOptions->getMessageOptions()->getSubject()); - - $body = $this->mailOptions->getMessageOptions()->getBody(); - - if ($body->getContent() !== '') { - $mailService->setBody($body->getContent(), $body->getCharset()); - } - - //attach files - $files = $this->mailOptions->getMessageOptions()->getAttachments()->getFiles(); - $mailService->addAttachments($files); - - //attach files from dir - $dir = $this->mailOptions->getMessageOptions()->getAttachments()->getDir(); - - if ($dir['iterate'] === true && is_string($dir['path']) && is_dir($dir['path'])) { - $files = $dir['recursive'] === true - ? new RecursiveIteratorIterator( - new RecursiveDirectoryIterator( - $dir['path'], - FilesystemIterator::SKIP_DOTS - ), - RecursiveIteratorIterator::CHILD_FIRST - ) - : new DirectoryIterator($dir['path']); - - foreach ($files as $fileInfo) { - if ($fileInfo->isDir()) { - continue; - } - - $mailService->addAttachment($fileInfo->getPathname()); - } - } + $mailService = new MailService($logService, $message, $transport, $this->mailOptions, $attachments); $this->attachMailListeners($mailService, $container); return $mailService; @@ -137,6 +96,13 @@ protected function createMessage(): Email $message->setBcc($bcc); } + $message->subject($options->getSubject()); + + $body = $options->getBody(); + if ($body->getContent() !== '') { + $message->html($body->getContent(), $body->getCharset()); + } + return $message; } @@ -147,25 +113,32 @@ protected function createMessage(): Email protected function createTransport(ContainerInterface $container): TransportInterface { $adapter = $this->mailOptions->getTransport(); + + //a transport object provided by the user is used as it is if ($adapter instanceof TransportInterface) { - return $this->setupTransportConfig($adapter); + return $adapter; } //check is adapter is a service if ($container->has($adapter)) { $transport = $container->get($adapter); if ($transport instanceof TransportInterface) { - return $this->setupTransportConfig($transport); - } else { - throw new InvalidArgumentException( - 'Provided transport service does not return a ' . TransportInterface::class . ' instance' - ); + return $transport; } + + throw new InvalidArgumentException( + 'Provided transport service does not return a ' . TransportInterface::class . ' instance' + ); + } + + //the esmtp alias is configured from the smtp options + if ($adapter === EsmtpTransport::class) { + return $this->createEsmtpTransport(); } //check is the adapter is Symfony default adapter if (is_subclass_of($adapter, TransportInterface::class)) { - return $this->setupTransportConfig(new $adapter()); + return new $adapter(); } //the adapter is not valid - throw exception @@ -178,17 +151,35 @@ protected function createTransport(ContainerInterface $container): TransportInte ); } - protected function setupTransportConfig(TransportInterface $transport): TransportInterface + protected function createEsmtpTransport(): EsmtpTransport { - if ($transport instanceof EsmtpTransport) { - $connection = $this->mailOptions->getSmtpOptions()->getConnectionConfig(); - $user = rawurlencode((string) ($connection['username'] ?? '')); - $pass = rawurlencode((string) ($connection['password'] ?? '')); - $tls = ($connection['tls'] ?? null) === false ? 'false' : null; - $port = $this->mailOptions->getSmtpOptions()->getPort(); - $host = $this->mailOptions->getSmtpOptions()->getHost(); - $transport = Transport::fromDsn('smtp://' . $user . ':' . $pass . '@' . $host . ':' . $port - . '?auto_tls=' . $tls); + $smtp = $this->mailOptions->getSmtpOptions(); + $connection = $smtp->getConnectionConfig(); + $tls = $connection['tls'] ?? null; + + //null leaves the encryption to the transport (automatic), false disables it + $transport = new EsmtpTransport($smtp->getHost(), $smtp->getPort(), $tls === false ? false : null); + $transport->setAutoTls($tls !== false); + + $username = (string) ($connection['username'] ?? ''); + if ($username !== '') { + $transport->setUsername($username); + } + + $password = (string) ($connection['password'] ?? ''); + if ($password !== '') { + $transport->setPassword($password); + } + + $name = $smtp->getName(); + if ($name !== null) { + $transport->setLocalDomain($name); + } + + $timeLimit = $smtp->getConnectionTimeLimit(); + $stream = $transport->getStream(); + if ($timeLimit !== null && $stream instanceof SocketStream) { + $stream->setTimeout((float) $timeLimit); } return $transport; diff --git a/src/Options/SmtpOptions.php b/src/Options/SmtpOptions.php index 46e942e..947e74c 100644 --- a/src/Options/SmtpOptions.php +++ b/src/Options/SmtpOptions.php @@ -14,22 +14,22 @@ */ class SmtpOptions extends AbstractOptions { - protected string $name = 'localhost'; + protected ?string $name = null; protected array $connectionConfig = []; protected string $host = '127.0.0.1'; protected int $port = 25; protected ?int $connectionTimeLimit = null; - public function getName(): string + public function getName(): ?string { return $this->name; } /** - * Set the local client hostname or IP + * Set the local client hostname or IP; when null, the transport's own default is kept */ - public function setName(string $name): static + public function setName(?string $name): static { $this->name = $name; return $this; diff --git a/src/Result/MailResult.php b/src/Result/MailResult.php index 07a6028..5de6e9a 100644 --- a/src/Result/MailResult.php +++ b/src/Result/MailResult.php @@ -4,7 +4,7 @@ namespace Dot\Mail\Result; -use Exception; +use Throwable; class MailResult implements ResultInterface { @@ -12,12 +12,12 @@ class MailResult implements ResultInterface protected bool $valid; protected string $message; - protected Exception|null $exception; + protected Throwable|null $exception; public function __construct( bool $valid = true, string $message = self::DEFAULT_MESSAGE, - ?Exception $exception = null + ?Throwable $exception = null ) { $this->valid = $valid; $this->message = $message; @@ -36,10 +36,10 @@ public function isValid(): bool public function hasException(): bool { - return $this->exception instanceof Exception; + return $this->exception instanceof Throwable; } - public function getException(): ?Exception + public function getException(): ?Throwable { return $this->exception; } diff --git a/src/Result/ResultInterface.php b/src/Result/ResultInterface.php index 9e84413..3d56ebe 100644 --- a/src/Result/ResultInterface.php +++ b/src/Result/ResultInterface.php @@ -4,7 +4,7 @@ namespace Dot\Mail\Result; -use Exception; +use Throwable; interface ResultInterface { @@ -14,5 +14,5 @@ public function isValid(): bool; public function hasException(): bool; - public function getException(): ?Exception; + public function getException(): ?Throwable; } diff --git a/src/Service/MailService.php b/src/Service/MailService.php index d64b0a4..524b971 100644 --- a/src/Service/MailService.php +++ b/src/Service/MailService.php @@ -4,6 +4,7 @@ namespace Dot\Mail\Service; +use DirectoryIterator; use Dot\Mail\Email; use Dot\Mail\Event\MailEvent; use Dot\Mail\Event\MailEventListenerAwareInterface; @@ -12,8 +13,9 @@ use Dot\Mail\Options\MailOptions; use Dot\Mail\Result\MailResult; use Dot\Mail\Result\ResultInterface; -use Exception; -use Symfony\Component\Mailer\Exception\TransportExceptionInterface; +use FilesystemIterator; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; use Symfony\Component\Mailer\Transport\TransportInterface; use Symfony\Component\Mime\Part\AbstractPart; use Symfony\Component\Mime\Part\DataPart; @@ -23,9 +25,12 @@ use function array_merge; use function basename; use function count; -use function fopen; +use function is_dir; use function is_file; +use function is_readable; use function is_string; +use function sprintf; +use function str_starts_with; class MailService implements MailServiceInterface, MailEventListenerAwareInterface { @@ -33,24 +38,37 @@ class MailService implements MailServiceInterface, MailEventListenerAwareInterfa protected LogServiceInterface $logService; protected Email $message; + protected Email $prototype; protected TransportInterface $transport; protected MailOptions $mailOptions; - protected array $attachments = []; + protected array $attachments = []; + protected array $defaultAttachments = []; + /** + * @param array $attachments The default attachments, restored by reset() after every send + */ public function __construct( LogServiceInterface $logService, Email $message, TransportInterface $transport, - MailOptions $mailOptions + MailOptions $mailOptions, + array $attachments = [] ) { - $this->logService = $logService; - $this->message = $message; - $this->transport = $transport; - $this->mailOptions = $mailOptions; + $this->logService = $logService; + $this->message = $message; + $this->prototype = clone $message; + $this->transport = $transport; + $this->mailOptions = $mailOptions; + $this->attachments = $attachments; + $this->defaultAttachments = $attachments; } /** - * @throws MailException|TransportExceptionInterface + * Sends the current message, then restores the configured defaults (see reset()). + * Recipients, subject, body and attachments must be set again for every message. + * An exception thrown by a post-send listener propagates unchanged: the message was already delivered. + * + * @throws MailException */ public function send(): ResultInterface { @@ -66,31 +84,35 @@ public function send(): ResultInterface //attach files before sending $this->attachFiles(); $this->getTransport()->send($this->getMessage()); - $this->getMessage()->setBody(null); - - $this->getEventManager()->triggerEvent($this->createMailEvent(MailEvent::EVENT_MAIL_POST_SEND, $result)); - } catch (Exception $e) { + } catch (Throwable $e) { $result = $this->createMailResultFromException($e); - //trigger error event - $this->getEventManager()->triggerEvent($this->createMailEvent(MailEvent::EVENT_MAIL_SEND_ERROR, $result)); - throw new MailException($result->getMessage()); - } finally { - // Reset the Symfony Message body after every send attempt (success or failure). - // attachFiles() embeds DataParts into Message::$body; without this reset those - // DataParts survive a failed send and get wrapped into the next email's body, - // causing attachments from a failed send to leak into subsequent emails. - $this->getMessage()->setBody(null); + try { + //trigger error event + $this->getEventManager()->triggerEvent( + $this->createMailEvent(MailEvent::EVENT_MAIL_SEND_ERROR, $result) + ); + } finally { + $this->reset(); + } + throw new MailException($result->getMessage(), (int) $e->getCode(), $e); } try { - $this->logService->sent($this->getMessage()); - } catch (Throwable) { - // a failed log write must never turn a delivered email into an error + $this->logSent(); + $this->getEventManager()->triggerEvent($this->createMailEvent(MailEvent::EVENT_MAIL_POST_SEND, $result)); + } finally { + $this->reset(); } return $result; } + public function reset(): void + { + $this->message = clone $this->prototype; + $this->attachments = $this->defaultAttachments; + } + public function createMailEvent( string $name = MailEvent::EVENT_MAIL_PRE_SEND, ?ResultInterface $result = null @@ -102,26 +124,30 @@ public function createMailEvent( return $event; } + /** + * @throws MailException When an attachment does not exist or is not readable. + */ public function attachFiles(): false|Email { - if (count($this->attachments) === 0) { + $attachments = array_merge($this->attachments, $this->getDirectoryAttachments()); + if (count($attachments) === 0) { return false; } $body = $this->message->getBody(); $parts = []; - foreach ($this->attachments as $key => $attachment) { - if (! is_file($attachment)) { - continue; + foreach ($attachments as $key => $attachment) { + if (! is_file($attachment) || ! is_readable($attachment)) { + throw new MailException( + sprintf('Attachment "%s" does not exist or is not readable', $attachment) + ); } $basename = is_string($key) ? $key : basename($attachment); - $parts[] = new DataPart(fopen($attachment, 'r'), $basename); + $parts[] = DataPart::fromPath($attachment, $basename); } - if (count($parts) > 0) { - $this->message->setBody(new MixedPart($body, ...$parts)); - } + $this->message->setBody(new MixedPart($body, ...$parts)); return $this->message; } @@ -135,7 +161,7 @@ public function setBody(string|AbstractPart $body, ?string $charset = null): voi } } - public function createMailResultFromException(Exception $e): ResultInterface + public function createMailResultFromException(Throwable $e): ResultInterface { return new MailResult(false, $e->getMessage(), $e); } @@ -183,4 +209,49 @@ public function setTransport(TransportInterface $transport): void { $this->transport = $transport; } + + /** + * A failed log write must never turn a delivered email into an error + */ + protected function logSent(): void + { + try { + $this->logService->sent($this->getMessage()); + } catch (Throwable) { + // intentionally ignored + } + } + + /** + * Collects the files of the configured attachments directory at send time. + * Relative paths are resolved against the current working directory; dotfiles are skipped. + * + * @return string[] + */ + protected function getDirectoryAttachments(): array + { + $dir = $this->mailOptions->getMessageOptions()->getAttachments()->getDir(); + + if (($dir['iterate'] ?? false) !== true || ! is_string($dir['path'] ?? null) || ! is_dir($dir['path'])) { + return []; + } + + $files = ($dir['recursive'] ?? false) === true + ? new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($dir['path'], FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ) + : new DirectoryIterator($dir['path']); + + $paths = []; + foreach ($files as $fileInfo) { + if ($fileInfo->isDir() || str_starts_with($fileInfo->getFilename(), '.')) { + continue; + } + + $paths[] = $fileInfo->getPathname(); + } + + return $paths; + } } diff --git a/src/Service/MailServiceInterface.php b/src/Service/MailServiceInterface.php index 5ebf83e..749579f 100644 --- a/src/Service/MailServiceInterface.php +++ b/src/Service/MailServiceInterface.php @@ -31,4 +31,10 @@ public function getAttachments(): array; public function setAttachments(array $paths): void; public function setTransport(TransportInterface $transport): void; + + /** + * Restores the message and attachments to the configured defaults. + * Called automatically after every send() attempt. + */ + public function reset(): void; } diff --git a/test/CommonTrait.php b/test/CommonTrait.php index e42f8f7..26b831f 100644 --- a/test/CommonTrait.php +++ b/test/CommonTrait.php @@ -44,55 +44,57 @@ private function generateConfig(): array { return [ 'dot_mail' => [ - 'default' => [ - 'message_options' => [ - 'from' => '', - 'from_name' => '', - 'reply_to' => '', - 'reply_to_name' => '', - 'to' => [], - 'cc' => [], - 'bcc' => [], - 'subject' => '', - 'body' => [ - 'content' => '', - 'charset' => 'utf-8', - ], - 'attachments' => [ - 'files' => [], - 'dir' => [ - 'iterate' => false, - 'path' => $this->fileSystem->url() . '/data/mail/attachments', - 'recursive' => false, + 'services' => [ + 'default' => [ + 'message_options' => [ + 'from' => '', + 'from_name' => '', + 'reply_to' => '', + 'reply_to_name' => '', + 'to' => [], + 'cc' => [], + 'bcc' => [], + 'subject' => '', + 'body' => [ + 'content' => '', + 'charset' => 'utf-8', + ], + 'attachments' => [ + 'files' => [], + 'dir' => [ + 'iterate' => false, + 'path' => $this->fileSystem->url() . '/data/mail/attachments', + 'recursive' => false, + ], ], ], - ], - /** - * the mail transport to use - * can be any class implementing Symfony\Component\Mailer\Transport\TransportInterface - * - * for standard mail transports, you can use these aliases - * - sendmail => Symfony\Component\Mailer\Transport\SendmailTransport - * - esmtp => Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport - * - * defaults to sendmail - **/ - 'transport' => 'esmtp', - //options that will be used only if Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport - // adapter is used - 'smtp_options' => [ - 'host' => 'testHost', - 'port' => 587, - 'connection_config' => [ - //the smtp authentication identity - 'username' => 'test', - //the smtp authentication credential - 'password' => 'testPassword', - 'tls' => null, + /** + * the mail transport to use + * can be any class implementing Symfony\Component\Mailer\Transport\TransportInterface + * + * for standard mail transports, you can use these aliases + * - sendmail => Symfony\Component\Mailer\Transport\SendmailTransport + * - esmtp => Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport + * + * defaults to sendmail + **/ + 'transport' => 'esmtp', + //options that will be used only if Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport + // adapter is used + 'smtp_options' => [ + 'host' => 'testHost', + 'port' => 587, + 'connection_config' => [ + //the smtp authentication identity + 'username' => 'test', + //the smtp authentication credential + 'password' => 'testPassword', + 'tls' => null, + ], ], ], + 'test' => 'string test', ], - 'test' => 'string test', // option to log the SENT emails 'log' => [ 'sent' => $this->fileSystem->url() . '/log/mail/sent.log', diff --git a/test/ConfigProviderTest.php b/test/ConfigProviderTest.php index cb4a0b8..8d7d4ce 100644 --- a/test/ConfigProviderTest.php +++ b/test/ConfigProviderTest.php @@ -10,6 +10,7 @@ use Dot\Mail\Factory\MailServiceAbstractFactory; use Dot\Mail\Service\LogService; use Dot\Mail\Service\LogServiceInterface; +use Dot\Mail\Service\MailServiceInterface; use PHPUnit\Framework\TestCase; class ConfigProviderTest extends TestCase @@ -42,6 +43,10 @@ public function testDependenciesHasAliases(): void LogService::class, $this->config['dependencies']['aliases'][LogServiceInterface::class] ); + $this->assertSame( + 'dot-mail.service.default', + $this->config['dependencies']['aliases'][MailServiceInterface::class] + ); } public function testDependenciesHasAbstractFactories(): void diff --git a/test/Factory/AbstractMailFactoryTest.php b/test/Factory/AbstractMailFactoryTest.php index 3792dc3..dc958d2 100644 --- a/test/Factory/AbstractMailFactoryTest.php +++ b/test/Factory/AbstractMailFactoryTest.php @@ -4,6 +4,7 @@ namespace DotTest\Mail\Factory; +use Dot\Mail\Exception\InvalidArgumentException; use Dot\Mail\Factory\AbstractMailFactory; use Dot\Mail\Options\MailOptions; use DotTest\Mail\CommonTrait; @@ -116,4 +117,58 @@ public function testLogKeyIsNotACreatableService(): void $this->assertTrue($this->subject->canCreate($container, 'dot-mail.testPart.default')); $this->assertFalse($this->subject->canCreate($container, 'dot-mail.testPart.log')); } + + /** + * @throws Exception + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testServicesCanOnlyComeFromServicesKey(): void + { + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturn([ + 'dot_mail' => [ + 'log' => ['sent' => '/tmp/sent.log'], + 'services' => ['log' => [], 'other' => []], + ], + ]); + + $this->assertTrue($this->subject->canCreate($container, 'dot-mail.testPart.other')); + $this->assertTrue($this->subject->canCreate($container, 'dot-mail.testPart.log')); + $this->assertFalse($this->subject->canCreate($container, 'dot-mail.testPart.missing')); + } + + /** + * @throws Exception + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testOldConfigurationLayoutIsRejected(): void + { + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturn([ + 'dot_mail' => [ + 'default' => ['transport' => 'sendmail'], + 'log' => ['sent' => '/tmp/sent.log'], + ], + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Mail service "default" must be configured under "dot_mail.services"'); + $this->subject->canCreate($container, 'dot-mail.testPart.default'); + } + + /** + * @throws Exception + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + */ + public function testConfigurationWithOnlyLogCreatesNoService(): void + { + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturn(['dot_mail' => ['log' => ['sent' => '/tmp/sent.log']]]); + + $this->assertFalse($this->subject->canCreate($container, 'dot-mail.testPart.log')); + $this->assertFalse($this->subject->canCreate($container, 'dot-mail.testPart.default')); + } } diff --git a/test/Factory/MailServiceAbstractFactoryTest.php b/test/Factory/MailServiceAbstractFactoryTest.php index c72bc9a..bf62743 100644 --- a/test/Factory/MailServiceAbstractFactoryTest.php +++ b/test/Factory/MailServiceAbstractFactoryTest.php @@ -26,6 +26,7 @@ use Psr\Container\NotFoundExceptionInterface; use Symfony\Component\Mailer\Transport\SendmailTransport; use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; +use Symfony\Component\Mailer\Transport\Smtp\Stream\SocketStream; use function restore_error_handler; use function set_error_handler; @@ -124,10 +125,6 @@ public function testGenerateServiceWithProvidedAdapter(): void 'tls' => null, ]); - $this->smtpOptions->expects($this->once()) - ->method('getHost') - ->willReturn('localhost'); - $this->mailOptions->expects($this->any()) ->method('getSmtpOptions') ->willReturn($this->smtpOptions); @@ -192,10 +189,6 @@ public function testGenerateServiceWithoutProvidedAdapter(): void 'tls' => false, ]); - $this->smtpOptions->expects($this->once()) - ->method('getHost') - ->willReturn('localhost'); - $this->mailOptions->expects($this->any()) ->method('getSmtpOptions') ->willReturn($this->smtpOptions); @@ -350,7 +343,7 @@ public function testEsmtpTransportWithoutConnectionConfigKeys(): void * @throws Exception * @throws NotFoundExceptionInterface */ - public function testEsmtpCredentialsAreUrlEncoded(): void + public function testEsmtpCredentialsWithSpecialCharacters(): void { $options = new MailOptions([ 'transport' => 'esmtp', @@ -373,4 +366,123 @@ public function testEsmtpCredentialsAreUrlEncoded(): void $this->assertSame('user@example.com', $transport->getUsername()); $this->assertSame('p:ss/w@rd', $transport->getPassword()); } + + /** + * @throws ContainerExceptionInterface + * @throws Exception + * @throws NotFoundExceptionInterface + */ + public function testEsmtpTransportConfiguration(): void + { + $options = new MailOptions([ + 'transport' => 'esmtp', + 'smtp_options' => [ + 'host' => 'mail.example.com', + 'port' => 2525, + 'name' => 'client.example.com', + 'connection_time_limit' => 7, + 'connection_config' => ['username' => 'user', 'password' => 'secret'], + ], + ]); + + $transport = ($this->subject)($this->containerFor($options), 'dot-mail.service.default')->getTransport(); + + $this->assertInstanceOf(EsmtpTransport::class, $transport); + $this->assertSame('client.example.com', $transport->getLocalDomain()); + $stream = $transport->getStream(); + $this->assertInstanceOf(SocketStream::class, $stream); + $this->assertSame(7.0, $stream->getTimeout()); + $this->assertTrue($transport->isAutoTls()); + } + + /** + * @throws ContainerExceptionInterface + * @throws Exception + * @throws NotFoundExceptionInterface + */ + public function testEsmtpTlsSettings(): void + { + $cases = [ + 'automatic on 587' => [587, null, true, false], + 'implicit on 465' => [465, null, true, true], + 'disabled' => [587, false, false, false], + ]; + + foreach ($cases as $label => [$port, $tls, $autoTls, $implicitTls]) { + $options = new MailOptions([ + 'transport' => 'esmtp', + 'smtp_options' => [ + 'host' => 'mail.example.com', + 'port' => $port, + 'connection_config' => ['tls' => $tls], + ], + ]); + + $transport = ($this->subject)($this->containerFor($options), 'dot-mail.service.default')->getTransport(); + + $this->assertInstanceOf(EsmtpTransport::class, $transport); + $this->assertSame($autoTls, $transport->isAutoTls(), $label); + $this->assertInstanceOf(SocketStream::class, $transport->getStream()); + $this->assertSame($implicitTls, $transport->getStream()->isTLS(), $label); + } + } + + /** + * @throws ContainerExceptionInterface + * @throws Exception + * @throws NotFoundExceptionInterface + */ + public function testProvidedTransportObjectIsUsedAsGiven(): void + { + $provided = new EsmtpTransport('provided.example.com', 25, false); + $options = new MailOptions([ + 'transport' => $provided, + 'smtp_options' => ['host' => 'ignored.example.com', 'port' => 587], + ]); + + $transport = ($this->subject)($this->containerFor($options), 'dot-mail.service.default')->getTransport(); + + $this->assertSame($provided, $transport); + } + + /** + * @throws ContainerExceptionInterface + * @throws Exception + * @throws NotFoundExceptionInterface + */ + public function testServiceKeepsConfiguredDefaultsAfterReset(): void + { + $options = new MailOptions([ + 'transport' => 'sendmail', + 'message_options' => [ + 'from' => 'from@example.com', + 'subject' => 'Default subject', + 'body' => ['content' => '

Default

'], + 'attachments' => ['files' => ['/some/default/file.txt']], + ], + ]); + + $mailService = ($this->subject)($this->containerFor($options), 'dot-mail.service.default'); + $mailService->getMessage()->addTo('someone@example.com'); + $mailService->addAttachment('/another/file.txt'); + + $mailService->reset(); + + $this->assertSame('Default subject', $mailService->getMessage()->getSubject()); + $this->assertSame('

Default

', $mailService->getMessage()->getHtmlBody()); + $this->assertSame('from@example.com', $mailService->getMessage()->getFrom()[0]->getAddress()); + $this->assertSame([], $mailService->getMessage()->getTo()); + $this->assertSame(['/some/default/file.txt'], $mailService->getAttachments()); + } + + private function containerFor(MailOptions $options): ContainerInterface + { + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->willReturnMap([ + ['dot-mail.options.default', $options], + [LogServiceInterface::class, $this->createMock(LogService::class)], + ]); + + return $container; + } } diff --git a/test/Result/MailResultTest.php b/test/Result/MailResultTest.php index 94c90bf..e5e44b0 100644 --- a/test/Result/MailResultTest.php +++ b/test/Result/MailResultTest.php @@ -7,6 +7,7 @@ use Dot\Mail\Result\MailResult; use Exception; use PHPUnit\Framework\TestCase; +use TypeError; class MailResultTest extends TestCase { @@ -27,4 +28,14 @@ public function testGettersAndSetters(): void $this->assertSame($exception->getMessage(), $mailResult->getException()->getMessage()); $this->assertTrue($mailResult->hasException()); } + + public function testResultCanHoldAnyThrowable(): void + { + $error = new TypeError('Type error test'); + + $mailResult = new MailResult(false, $error->getMessage(), $error); + + $this->assertTrue($mailResult->hasException()); + $this->assertSame($error, $mailResult->getException()); + } } diff --git a/test/Service/MailServiceTest.php b/test/Service/MailServiceTest.php index ad5027f..22f4ae6 100644 --- a/test/Service/MailServiceTest.php +++ b/test/Service/MailServiceTest.php @@ -5,6 +5,7 @@ namespace DotTest\Mail\Service; use Dot\Mail\Email; +use Dot\Mail\Event\AbstractMailEventListener; use Dot\Mail\Event\MailEvent; use Dot\Mail\Exception\MailException; use Dot\Mail\Exception\RuntimeException; @@ -25,6 +26,12 @@ use Symfony\Component\Mime\Part\DataPart; use Symfony\Component\Mime\Part\Multipart\MixedPart; use Symfony\Component\Mime\Part\TextPart; +use Throwable; +use TypeError; + +use function file_put_contents; +use function mkdir; +use function sort; class MailServiceTest extends TestCase { @@ -209,23 +216,18 @@ public function testAttachFilesSingleAttachmentIsFlat(): void $this->assertInstanceOf(DataPart::class, $parts[1]); } - public function testAttachFilesSkipsNonExistentFiles(): void + public function testAttachFilesThrowsOnMissingFile(): void { $this->message->html('

Test

'); - $this->mailService->addAttachment('/nonexistent/file.pdf'); $this->mailService->addAttachment( $this->fileSystem->url() . '/data/mail/attachments/testPdfAttachment.pdf' ); + $this->mailService->addAttachment('/nonexistent/file.pdf'); + $this->expectException(MailException::class); + $this->expectExceptionMessage('Attachment "/nonexistent/file.pdf" does not exist or is not readable'); $this->mailService->attachFiles(); - - $body = $this->message->getBody(); - $this->assertInstanceOf(MixedPart::class, $body); - - $parts = $body->getParts(); - // only 1 valid attachment + the html body - $this->assertCount(2, $parts); } public function testAttachFilesPreservesCustomFilename(): void @@ -299,11 +301,10 @@ public function testAttachFilesNoNestedMixedParts(): void /** * Found an attachment leak where DataParts * embedded into Message::$body by attachFiles() survived a failed send - * and were wrapped into the next email. The fix must live in dot-mail: - * after send() (success OR failure) the underlying Message::$body must - * be reset so the next send rebuilds the body from scratch. + * and were wrapped into the next email. After send() (success OR failure) + * the service must hand out a clean message built from the configured defaults. */ - public function testMessageBodyIsResetAfterFailedSendToPreventAttachmentLeak(): void + public function testMessageIsResetAfterFailedSendToPreventAttachmentLeak(): void { $this->mailService->setSubject('First'); $this->message->html('First body'); @@ -322,11 +323,12 @@ public function testMessageBodyIsResetAfterFailedSendToPreventAttachmentLeak(): // expected } + $this->assertNotSame($this->message, $this->mailService->getMessage()); $this->assertNull( - $this->readPrivateMessageBody($this->message), - 'After a failed send the Symfony Message::$body must be reset; ' - . 'otherwise attachFiles() will wrap the leaked MixedPart into the next email.' + $this->readPrivateMessageBody($this->mailService->getMessage()), + 'After a failed send the message must be rebuilt from the defaults, without the attached body.' ); + $this->assertSame([], $this->mailService->getAttachments()); } /** @@ -362,10 +364,9 @@ public function testAttachmentsDoNotLeakIntoSubsequentSendAfterFailure(): void // expected } - // Reconfigure: brand new email with NO attachments. - $this->mailService->setAttachments([]); + // Configure a brand new email; the attachment of the first one must be gone. $this->mailService->setSubject('Second'); - $this->message->html('Second email - must not carry first email\'s attachment'); + $this->mailService->getMessage()->html('Second email - must not carry first email\'s attachment'); $this->mailService->send(); @@ -381,13 +382,14 @@ public function testAttachmentsDoNotLeakIntoSubsequentSendAfterFailure(): void } /** - * Confirms the success path also resets the body, so a future refactor + * Confirms the success path also resets the message, so a future refactor * does not silently regress the success case. */ - public function testMessageBodyIsResetAfterSuccessfulSend(): void + public function testMessageIsResetAfterSuccessfulSend(): void { $this->mailService->setSubject('Hello'); $this->message->html('Body'); + $this->message->addTo('first@example.com'); $this->mailService->addAttachment( $this->fileSystem->url() . '/data/mail/attachments/testPdfAttachment.pdf' ); @@ -396,17 +398,200 @@ public function testMessageBodyIsResetAfterSuccessfulSend(): void $this->mailService->send(); - $this->assertNull( - $this->readPrivateMessageBody($this->message), - 'Message body should be null after a successful send so the next email starts clean.' + $message = $this->mailService->getMessage(); + $this->assertNotSame($this->message, $message); + $this->assertNull($this->readPrivateMessageBody($message)); + $this->assertNull($message->getSubject()); + $this->assertSame([], $message->getTo()); + $this->assertSame([], $this->mailService->getAttachments()); + } + + public function testResetRestoresConfiguredDefaults(): void + { + $defaults = new Email(); + $defaults->subject('Default subject'); + $defaults->addFrom('from@example.com'); + + $mailService = new MailService( + $this->createMock(LogServiceInterface::class), + $defaults, + $this->transportInterface, + $this->createMock(MailOptions::class), + ['/default/attachment.txt'] + ); + + $mailService->setSubject('Changed'); + $mailService->getMessage()->addTo('someone@example.com'); + $mailService->addAttachment('/extra/attachment.txt'); + + $mailService->reset(); + + $this->assertSame('Default subject', $mailService->getMessage()->getSubject()); + $this->assertSame('from@example.com', $mailService->getMessage()->getFrom()[0]->getAddress()); + $this->assertSame([], $mailService->getMessage()->getTo()); + $this->assertSame(['/default/attachment.txt'], $mailService->getAttachments()); + } + + public function testListenersSeeTheFullMessageBeforeTheReset(): void + { + $listener = new class extends AbstractMailEventListener { + public array $seen = []; + + public function onPostSend(MailEvent $e): void + { + $this->seen[] = $e->getMailService()->getMessage()->getSubject(); + } + }; + $this->mailService->attachListener($listener); + $this->mailService->setSubject('Visible to listeners'); + + $this->mailService->send(); + + $this->assertSame(['Visible to listeners'], $listener->seen); + $this->assertNull($this->mailService->getMessage()->getSubject()); + } + + public function testSendFailureKeepsThePreviousException(): void + { + $exception = new RuntimeException('Transport failure', 42); + $this->transportInterface->method('send')->willThrowException($exception); + + try { + $this->mailService->send(); + $this->fail('Expected MailException was not thrown'); + } catch (MailException $e) { + $this->assertSame($exception, $e->getPrevious()); + $this->assertSame(42, $e->getCode()); + $this->assertSame('Transport failure', $e->getMessage()); + } + } + + public function testErrorsAreReportedAsSendErrors(): void + { + $listener = new class extends AbstractMailEventListener { + public array $events = []; + + public function onSendError(MailEvent $e): void + { + $this->events[] = $e->getResult()?->getException()::class; + } + }; + $this->mailService->attachListener($listener); + $this->transportInterface->method('send')->willThrowException(new TypeError('Type problem')); + + try { + $this->mailService->send(); + $this->fail('Expected MailException was not thrown'); + } catch (MailException $e) { + $this->assertInstanceOf(TypeError::class, $e->getPrevious()); + } + + $this->assertSame([TypeError::class], $listener->events); + } + + public function testFailingPostSendListenerIsNotReportedAsSendError(): void + { + $listener = new class extends AbstractMailEventListener { + public int $errors = 0; + + public function onPostSend(MailEvent $e): void + { + throw new RuntimeException('Listener failure'); + } + + public function onSendError(MailEvent $e): void + { + $this->errors++; + } + }; + $this->mailService->attachListener($listener); + $this->mailService->setSubject('Delivered'); + + $caught = null; + try { + $this->mailService->send(); + } catch (Throwable $e) { + $caught = $e; + } + + $this->assertInstanceOf(RuntimeException::class, $caught); + $this->assertSame('Listener failure', $caught->getMessage()); + $this->assertSame(0, $listener->errors); + $this->assertNull($this->mailService->getMessage()->getSubject(), 'The message must be reset anyway'); + } + + public function testDirectoryAttachmentsAreCollectedAtSendTime(): void + { + $dir = $this->fileSystem->url() . '/data/mail/attachments'; + file_put_contents($dir . '/.gitkeep', ''); + mkdir($dir . '/nested'); + file_put_contents($dir . '/nested/nested.txt', 'nested'); + + $options = new MailOptions([ + 'message_options' => [ + 'attachments' => ['dir' => ['iterate' => true, 'path' => $dir, 'recursive' => false]], + ], + ]); + + $message = new Email(); + $mailService = new MailService( + $this->createMock(LogServiceInterface::class), + $message, + $this->transportInterface, + $options + ); + $message->html('

Test

'); + + // added after the service was created + file_put_contents($dir . '/late.txt', 'late'); + + $mailService->attachFiles(); + + $body = $message->getBody(); + $this->assertInstanceOf(MixedPart::class, $body); + $names = []; + foreach ($body->getParts() as $part) { + if ($part instanceof DataPart) { + $names[] = $part->getFilename(); + } + } + sort($names); + + $this->assertSame(['late.txt', 'testPdfAttachment.pdf', 'testXlsAttachment.xls'], $names); + } + + public function testRecursiveDirectoryAttachments(): void + { + $dir = $this->fileSystem->url() . '/data/mail/attachments'; + mkdir($dir . '/nested'); + file_put_contents($dir . '/nested/nested.txt', 'nested'); + + $options = new MailOptions([ + 'message_options' => [ + 'attachments' => ['dir' => ['iterate' => true, 'path' => $dir, 'recursive' => true]], + ], + ]); + + $message = new Email(); + $mailService = new MailService( + $this->createMock(LogServiceInterface::class), + $message, + $this->transportInterface, + $options ); + $message->html('

Test

'); + + $mailService->attachFiles(); + + $body = $message->getBody(); + $this->assertInstanceOf(MixedPart::class, $body); + $this->assertCount(4, $body->getParts()); } private function readPrivateMessageBody(Message $message): ?AbstractPart { $reflection = new ReflectionClass(Message::class); $property = $reflection->getProperty('body'); - $property->setAccessible(true); return $property->getValue($message); } @@ -444,19 +629,35 @@ public function testSetBodyDefaultsToUtf8(): void public function testSendKeepsConfiguredEncoding(): void { + $encoding = null; + $this->transportInterface->method('send')->willReturnCallback( + function (Email $email) use (&$encoding) { + $encoding = $email->getEncoding(); + return null; + } + ); + $this->mailService->setBody('

Body

', 'iso-8859-1'); $this->mailService->send(); - $this->assertSame('iso-8859-1', $this->message->getEncoding()); + $this->assertSame('iso-8859-1', $encoding); } public function testSendSetsDefaultEncodingWhenNoneIsSet(): void { + $encoding = 'not called'; + $this->transportInterface->method('send')->willReturnCallback( + function (Email $email) use (&$encoding) { + $encoding = $email->getEncoding(); + return null; + } + ); + $this->assertNull($this->message->getEncoding()); $this->mailService->send(); - $this->assertSame('utf-8', $this->message->getEncoding()); + $this->assertSame('utf-8', $encoding); } /** From 1a5598379c55f096c3c8afe7834d880b9028619f Mon Sep 17 00:00:00 2001 From: bidi Date: Thu, 29 Oct 2026 18:00:33 +0200 Subject: [PATCH 3/5] updated changelog Signed-off-by: bidi --- CHANGELOG.md | 1036 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 911 insertions(+), 125 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 382f245..c0fd414 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,418 +1,1204 @@ +# Changelog + +## 6.0.0 - Unreleased + +### Changed + +- **BC:** mail services are configured under `dot_mail.services`; `dot_mail.log` stays separate. A configuration in the old layout (`dot_mail.default`) now fails with an `InvalidArgumentException`. Move the service blocks as shown in `config/mail.global.php.dist`. +- **BC:** `send()` restores the configured defaults after every attempt, successful or not: recipients, subject, body and attachments must be set again for every message. `getMessage()` returns a new object after each send. +- **BC:** a missing or unreadable attachment now throws a `MailException` instead of being skipped. +- **BC:** the attachment directory (`attachments.dir`) is scanned at send time instead of once at start-up, and files starting with a dot are skipped. Attachments are read with `DataPart::fromPath()`, so no file handles are left open. +- **BC:** `send()` handles any `Throwable`, and the `MailException` it throws carries the original exception and code. `ResultInterface`, `MailResult` and `createMailResultFromException()` use `Throwable` instead of `Exception`. +- **BC:** an exception thrown by a post-send listener propagates unchanged and is no longer reported as a send error, because the message was already delivered. +- **BC:** the `esmtp` transport is built directly from `smtp_options` instead of through a DSN string. Credentials with special characters work, empty credentials are not sent, and `smtp_options.name` and `connection_time_limit` now take effect. `SmtpOptions::getName()` returns `?string` and defaults to `null`. +- **BC:** a transport object or container service provided in `transport` is used as given and no longer replaced. +- **BC:** `sendmail` is the default transport when the `transport` key is omitted, matching `config/mail.global.php.dist` and the documentation. +- **BC:** `MailEvent::getResult()` returns `?ResultInterface`; the result is `null` in the pre-send event. +- **BC:** a message that never received a body fails validation instead of being sent empty; an empty `body.content` in the configuration is no longer applied. +- **BC:** the charset passed to `setBody()` and `body.charset` are used; `send()` only sets `utf-8` when no encoding is set. +- **BC:** the minimum supported PHP version is 8.3 (`~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0`). +- A failing log write no longer turns a delivered email into an error; `LogService::sent()` returns `false` when the log directory cannot be created, and writes use `LOCK_EX`. +- Option getters fall back to defaults, so a minimal configuration no longer fails with uninitialized properties. + +### Added + +- `MailServiceInterface::reset()`, which restores the configured message and attachments. The `MailService` constructor accepts the default attachments as an optional fifth argument. +- `MailServiceInterface` is aliased to `dot-mail.service.default`, so it can be injected by interface. +- PHP 8.6 in the version constraint and in the CI matrix. +- `laminas/laminas-stdlib` as a direct dependency. + +### Deprecated + +- Nothing + +### Removed + +- Support for PHP 8.2. +- Unused attachment handling code from `Dot\Mail\Email`. + +### Fixed + +- `Email::addTo()` and the other address methods accept arrays of `Address` objects. +- Transport aliases are matched case-insensitively (`ESMTP`, `SendMail`). +- Missing `username`, `password` or `tls` keys in `smtp_options.connection_config` no longer raise warnings, and a username with special characters is encoded correctly. +- `canCreate()` no longer reads an undefined array key for names with too few parts. +- Detaching a listener that was never attached no longer removes a wrong entry. +- Error messages for an invalid transport, an invalid event listener and an invalid port are accurate. + +## 5.4.3 - 2026-05-11 + +### Changed + +- Nothing + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Attachments of a failed send no longer leak into the next email; the message body is reset after every send attempt ([#115](https://github.com/dotkernel/dot-mail/pull/115)) + +## 5.4.2 - 2026-03-03 + +### Changed + +- Nothing + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- `MailService`: fixed the email structure when sending multiple attachments ([#113](https://github.com/dotkernel/dot-mail/pull/113)) + +## 5.4.1 - 2025-11-12 + +### Changed + +- Updated OSS lifecycle metadata ([#111](https://github.com/dotkernel/dot-mail/pull/111)) +- Updated readme and documentation ([#112](https://github.com/dotkernel/dot-mail/pull/112)) + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 5.4.0 - 2025-11-03 + +### Changed + +- Nothing + +### Added + +- Support for PHP 8.4 and 8.5 ([#104](https://github.com/dotkernel/dot-mail/pull/104)) + +### Deprecated + +- Nothing + +### Removed + +- Support for PHP 8.1 ([#104](https://github.com/dotkernel/dot-mail/pull/104)) + +### Fixed + +- Linking between documentation versions ([#105](https://github.com/dotkernel/dot-mail/pull/105)) + +## 5.1.6 - 2025-11-03 + +### Changed + +- Nothing + +### Added + +- Support for PHP 8.4 and 8.5 ([#103](https://github.com/dotkernel/dot-mail/pull/103)) + +### Deprecated + +- Nothing + +### Removed + +- Support for PHP 8.1 ([#103](https://github.com/dotkernel/dot-mail/pull/103)) + +### Fixed + +- Nothing + +## 5.0.4 - 2025-11-03 + +### Changed + +- Documentation: version `5.0` is the default ([#75](https://github.com/dotkernel/dot-mail/pull/75)) +- Updated the v5 documentation ([#78](https://github.com/dotkernel/dot-mail/pull/78)) +- Bumped Psalm to 6.x ([#84](https://github.com/dotkernel/dot-mail/pull/84)) + +### Added + +- Support for `laminas/laminas-servicemanager` 4.x ([#82](https://github.com/dotkernel/dot-mail/pull/82)) +- Upgrade guide from v4 to v5 ([#81](https://github.com/dotkernel/dot-mail/pull/81)) +- Support for PHP 8.4 and 8.5 ([#102](https://github.com/dotkernel/dot-mail/pull/102)) + +### Deprecated + +- Nothing + +### Removed + +- Unused code inherited from version 4.0 ([#73](https://github.com/dotkernel/dot-mail/pull/73)) +- Support for PHP 8.1 ([#102](https://github.com/dotkernel/dot-mail/pull/102)) + +### Fixed + +- Version links in the documentation footer ([#80](https://github.com/dotkernel/dot-mail/pull/80)) +- Attachments not working ([#87](https://github.com/dotkernel/dot-mail/pull/87)) + +## 5.3.0 - 2025-03-04 + +### Changed + +- Replaced Psalm with PHPStan ([#90](https://github.com/dotkernel/dot-mail/pull/90)) + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Unused code ([#97](https://github.com/dotkernel/dot-mail/pull/97)) + +### Fixed + +- Error caused by special characters ([#94](https://github.com/dotkernel/dot-mail/pull/94)) + +## 5.1.5 - 2025-02-28 + +### Changed + +- Nothing + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Error caused by special characters in the password ([#95](https://github.com/dotkernel/dot-mail/pull/95)) + +## 5.1.4 - 2025-02-17 + +### Changed + +- Nothing + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Attachments not working ([#88](https://github.com/dotkernel/dot-mail/pull/88)) + +## 5.2.2 - 2025-02-14 + +### Changed + +- Nothing + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Attachments not working ([#87](https://github.com/dotkernel/dot-mail/pull/87)) + +## 5.2.1 - 2025-01-28 + +### Changed + +- Bumped Psalm to 6.x ([#84](https://github.com/dotkernel/dot-mail/pull/84)) + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 5.2.0 - 2025-01-13 + +### Changed + +- Nothing + +### Added + +- Support for `laminas/laminas-servicemanager` 4.x ([#82](https://github.com/dotkernel/dot-mail/pull/82)) + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 5.1.3 - 2025-01-06 + +### Changed + +- Nothing + +### Added + +- Upgrade guide from v4 to v5 ([#81](https://github.com/dotkernel/dot-mail/pull/81)) + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 5.1.2 - 2024-12-19 + +### Changed + +- Nothing + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Version links in the documentation footer ([#80](https://github.com/dotkernel/dot-mail/pull/80)) + +## 5.1.1 - 2024-12-19 + +### Changed + +- Documentation: version `5.0` is the default ([#75](https://github.com/dotkernel/dot-mail/pull/75)) +- Updated the v5 documentation ([#78](https://github.com/dotkernel/dot-mail/pull/78)) + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 5.1.0 - 2024-12-06 + +### Changed + +- Nothing + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Unused code inherited from version 4.0 ([#73](https://github.com/dotkernel/dot-mail/pull/73)) + +### Fixed + +- Nothing + +## 5.0.3 - 2024-12-03 + +### Changed + +- Added `declare(strict_types=1)` to the configuration file ([#69](https://github.com/dotkernel/dot-mail/pull/69)) + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 5.0.2 - 2024-11-19 + +### Changed + +- Cleaned the configuration file ([#67](https://github.com/dotkernel/dot-mail/pull/67)) + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- The "from" name is displayed correctly ([#67](https://github.com/dotkernel/dot-mail/pull/67)) + +## 5.0.1 - 2024-11-18 + +### Changed + +- Nothing + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- SMTP transport: missing `port` array key ([#66](https://github.com/dotkernel/dot-mail/pull/66)) + +## 5.0.0 - 2024-11-15 + +### Changed + +- Replaced `laminas-mail` with `symfony/mailer` ([#65](https://github.com/dotkernel/dot-mail/pull/65)) +- Updated readme ([#61](https://github.com/dotkernel/dot-mail/pull/61)) + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- `mime` and `imap` related functionality (see the upgrade guide from v4 to v5) + +### Fixed + +- Nothing + +## 4.3.0 - 2024-10-28 + +### Changed + +- Final release of the wrapper over `laminas-mail` +- Updated readme ([#61](https://github.com/dotkernel/dot-mail/pull/61)) +- Locked PHP at version 8.3 ([#64](https://github.com/dotkernel/dot-mail/pull/64)) + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 4.2.2 - 2024-06-28 + +### Changed + +- Updated the documentation overview ([#59](https://github.com/dotkernel/dot-mail/pull/59)) + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 4.2.1 - 2024-06-18 + +### Changed + +- Updated the documentation overview ([#58](https://github.com/dotkernel/dot-mail/pull/58)) + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 4.2.0 - 2024-05-03 + +### Changed + +- Updated workflows ([#55](https://github.com/dotkernel/dot-mail/pull/55)) + +### Added + +- v4 documentation ([#55](https://github.com/dotkernel/dot-mail/pull/55)) + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 4.1.1 - 2023-11-29 + +### Changed + +- Bumped PHP to 8.3 ([#53](https://github.com/dotkernel/dot-mail/pull/53)) + +### Added + +- Codecov badge ([#51](https://github.com/dotkernel/dot-mail/pull/51)) + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 4.1.0 - 2023-08-08 + +### Changed + +- Psalm and phpcs fixes ([#47](https://github.com/dotkernel/dot-mail/pull/47)) + +### Added + +- PHPUnit and tests ([#47](https://github.com/dotkernel/dot-mail/pull/47)) + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 4.0.4 - 2022-09-28 + +### Changed + +- Updated error reporting + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + +## 3.4.6 - 2022-09-28 + +### Changed + +- Updated error reporting + +### Added + +- Nothing + +### Deprecated + +- Nothing + +### Removed + +- Nothing + +### Fixed + +- Nothing + ## 4.0.3 - 2022-08-31 ### Changed -* Improved email storage handling -* Updated readme + +- Improved email storage handling +- Updated readme ### Added -* Nothing + +- Nothing ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- Nothing ### Fixed -* Nothing + +- Nothing ## 4.0.2 - 2022-08-23 ### Changed -* Updated mail.global.php.dist + +- Updated mail.global.php.dist ### Added -* Nothing + +- Nothing ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- Nothing ### Fixed -* Nothing + +- Nothing ## 4.0.1 - 2022-08-12 ### Changed -* Updated readme -* Updated mail.global.php.dist + +- Updated readme +- Updated mail.global.php.dist ### Added -* Nothing + +- Nothing ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- Nothing ### Fixed -* Nothing + +- Nothing ## 4.0.0 - 2022-08-11 ### Changed -* Updated dependecies versions, including PHP to ~8.0.0 || ~8.1.0 -* Restricted laminas-mail version to >=2.17 + +- Updated dependecies versions, including PHP to ~8.0.0 || ~8.1.0 +- Restricted laminas-mail version to >=2.17 ### Added -* Saving outgoing emails into folders + +- Saving outgoing emails into folders ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- Nothing ### Fixed -* Nothing + +- Nothing ## 3.4.5 - 2022-08-30 ### Changed -* Updated readme + +- Updated readme ### Added -* Nothing + +- Nothing ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- Nothing ### Fixed -* Nothing + +- Nothing ## 3.4.4 - 2022-08-26 ### Changed -* Nothing + +- Nothing ### Added -* Nothing + +- Nothing ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- Nothing ### Fixed -* Updated check for storage + +- Updated check for storage ## 3.4.3 - 2022-08-25 ### Changed -* Improved email storage handling + +- Improved email storage handling ### Added -* Nothing + +- Nothing ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- Nothing ### Fixed -* Nothing + +- Nothing ## 3.4.2 - 2022-08-23 ### Changed -* Updated mail.global.php.dist + +- Updated mail.global.php.dist ### Added -* Nothing + +- Nothing ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- Nothing ### Fixed -* Nothing + +- Nothing ## 3.4.1 - 2022-08-12 ### Changed -* Nothing + +- Nothing ### Added -* Nothing + +- Nothing ### Deprecated -* Nothing + +- Nothing ### Removed -* laminas/laminas-dependency-plugin + +- laminas/laminas-dependency-plugin ### Fixed -* Nothing + +- Nothing ## 3.4.0 - 2022-08-10 ### Changed -* Nothing + +- Nothing ### Added -* Saving outgoing emails into folders + +- Saving outgoing emails into folders ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- Nothing ### Fixed -* Nothing + +- Nothing ## 3.3.1 - 2022-08-10 ### Changed -* Updated dependecies versions -* Restricted laminas-mail version to <=2.16 + +- Updated dependecies versions +- Restricted laminas-mail version to <=2.16 ### Added -* Nothing + +- Nothing ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- Nothing ### Fixed -* Nothing + +- Nothing ## 3.3.0 - 2022-06-20 ### Changed -* Updated PHP version to allow 8.0 and 8.1 + +- Updated PHP version to allow 8.0 and 8.1 ### Added -* PHP 8.0 and 8.1 compatiblity + +- PHP 8.0 and 8.1 compatiblity ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- Nothing ### Fixed -* Nothing + +- Nothing ## 2.1 - 2021-11-04 ### Changed -* Nothing + +- Nothing ### Added -* Logging sent emails + +- Logging sent emails ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- Nothing ### Fixed -* Nothing + +- Nothing ## 3.2.1 - 2021-09-24 ### Changed -* Nothing + +- Nothing ### Added -* Nothing + +- Nothing ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- Nothing ### Fixed -* log/mail directory privileges + +- log/mail directory privileges ## 3.2.0 - 2021-09-23 ### Changed -* Nothing + +- Nothing ### Added -* Logging sent emails + +- Logging sent emails ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- Nothing ### Fixed -* Nothing + +- Nothing ## 3.1.1 - 2021-06-08 ### Changed -* Nothing + +- Nothing ### Added -* Nothing + +- Nothing ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- Nothing ### Fixed -* fix issue #9: UTF-8 characters in message body + +- fix issue #9: UTF-8 characters in message body ## 3.1.0 - 2020-11-03 ### Changed -* Composer 2 compatibility. -* Update composer.json to require version 2 of laminas/laminas-dependency-plugin to be Composer 2 compatible. + +- Composer 2 compatibility. +- Update composer.json to require version 2 of laminas/laminas-dependency-plugin to be Composer 2 compatible. ### Added -* bump min version of laminas/laminas-mail + +- bump min version of laminas/laminas-mail ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- Nothing ### Fixed -* bump min version of laminas/laminas-dependency-plugin -* bump min version of dotkernel/dot-event + +- bump min version of laminas/laminas-dependency-plugin +- bump min version of dotkernel/dot-event ## 2.0.0 - 2020-01-30 ### Changed -* Laminas and Mezzio migration. + +- Laminas and Mezzio migration. ### Added -* Nothing + +- Nothing ### Deprecated -* Nothing + +- Nothing ### Removed -* Support + +- Support ### Fixed -* Nothing +- Nothing ## 1.0.0 - 2018-10-05 ### Changed -* Nothing + +- Nothing ### Added -* Nothing + +- Nothing ### Deprecated -* Nothing + +- Nothing ### Removed -* TemplateRendererInterface requirement -* Templating in mail support + +- TemplateRendererInterface requirement +- Templating in mail support ### Fixed -* Nothing +- Nothing ## 0.2.0 - 2018-05-14 ### Changed -* updated `dot-twigrenderer` dependency + +- updated `dot-twigrenderer` dependency ### Added -* Nothing + +- Nothing ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- Nothing ### Fixed -* Nothing +- Nothing ## 0.1.3 - 2017-04-21 ### Changed -* send mail does not rethrow exceptions anymore + +- send mail does not rethrow exceptions anymore ### Added -* Nothing + +- Nothing ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- Nothing ### Fixed -* Nothing +- Nothing ## 0.1.2 - 2017-03-16 ### Changed -* updated composer.json + +- updated composer.json ### Added -* Nothing + +- Nothing ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- Nothing ### Fixed -* Nothing +- Nothing ## 0.1.1 - 2017-03-11 Updated php file headers doc blocks ### Added -* Nothing + +- Nothing ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- Nothing ### Fixed -* Nothing +- Nothing ## 0.1.0 - 2017-03-09 Initial tagged release ### Added -* Everything + +- Everything ### Deprecated -* Nothing + +- Nothing ### Removed -* Nothing + +- Nothing ### Fixed -* Nothing + +- Nothing From 402ffd657be71c6206083e8c133e6ab1b90dab82 Mon Sep 17 00:00:00 2001 From: bidi Date: Thu, 29 Oct 2026 18:56:29 +0200 Subject: [PATCH 4/5] updated readme, security Signed-off-by: bidi --- README.md | 23 ++++++++++++----------- SECURITY.md | 1 + 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index f3913d7..9c12fd3 100644 --- a/README.md +++ b/README.md @@ -10,26 +10,27 @@ Documentation is available at: https://docs.dotkernel.org/dot-mail/. ## Version History -| Branch | Release | PSR-11 | Mailer | OSS Lifecycle | PHP Version | -|--------|------------|----------|------------------|-------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------| -| 5.2 | `>=5.2.0` | 1 \|\| 2 | `symfony/mailer` | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-mail%2Fblob%2F5.2%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-mail/5.4.0) | -| 5.1 | `<5.2.0` | 1 | `symfony/mailer` | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-mail%2Fblob%2F5.1%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-mail/5.1.6) | -| 5.0 | `<5.1.0` | 1 \|\| 2 | `symfony/mailer` | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-mail%2Fblob%2F5.0%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-mail/5.0.4) | -| 4.0 | `<=4.3.0` | 1 | `laminas-mail` | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-mail%2Fblob%2F4.0%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-mail/4.3.0) | +| Branch | Release | PSR-11 | Mailer | OSS Lifecycle | PHP Version | +|--------|------------------|----------|------------------|-------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------| +| 6.0 | `>=6.0.0` | 1 \|\| 2 | `symfony/mailer` | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-mail%2Fblob%2F6.0%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-mail/6.0.0) | +| 5.2 | `>=5.2.0 <6.0.0` | 1 \|\| 2 | `symfony/mailer` | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-mail%2Fblob%2F5.2%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-mail/5.4.0) | +| 5.1 | `<5.2.0` | 1 | `symfony/mailer` | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-mail%2Fblob%2F5.1%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-mail/5.1.6) | +| 5.0 | `<5.1.0` | 1 \|\| 2 | `symfony/mailer` | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-mail%2Fblob%2F5.0%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-mail/5.0.4) | +| 4.0 | `<=4.3.0` | 1 | `laminas-mail` | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-mail%2Fblob%2F4.0%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-mail/4.3.0) | ## Badges ![OSS Lifecycle](https://img.shields.io/osslifecycle/dotkernel/dot-mail) -![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-mail/5.4.0) +![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-mail/6.0.0) [![GitHub issues](https://img.shields.io/github/issues/dotkernel/dot-mail)](https://github.com/dotkernel/dot-mail/issues) [![GitHub forks](https://img.shields.io/github/forks/dotkernel/dot-mail)](https://github.com/dotkernel/dot-mail/network) [![GitHub stars](https://img.shields.io/github/stars/dotkernel/dot-mail)](https://github.com/dotkernel/dot-mail/stargazers) -[![GitHub license](https://img.shields.io/github/license/dotkernel/dot-mail)](https://github.com/dotkernel/dot-mail/blob/5.2/LICENSE.md) +[![GitHub license](https://img.shields.io/github/license/dotkernel/dot-mail)](https://github.com/dotkernel/dot-mail/blob/6.0/LICENSE.md) -[![Build Static](https://github.com/dotkernel/dot-mail/actions/workflows/continuous-integration.yml/badge.svg?branch=5.2)](https://github.com/dotkernel/dot-mail/actions/workflows/continuous-integration.yml) -[![codecov](https://codecov.io/gh/dotkernel/dot-mail/branch/5.2/graph/badge.svg?token=G51NEHYKD3)](https://codecov.io/gh/dotkernel/dot-mail) -[![PHPStan](https://github.com/dotkernel/dot-mail/actions/workflows/static-analysis.yml/badge.svg?branch=5.2)](https://github.com/dotkernel/dot-mail/actions/workflows/static-analysis.yml) +[![Build Static](https://github.com/dotkernel/dot-mail/actions/workflows/continuous-integration.yml/badge.svg?branch=6.0)](https://github.com/dotkernel/dot-mail/actions/workflows/continuous-integration.yml) +[![codecov](https://codecov.io/gh/dotkernel/dot-mail/branch/6.0/graph/badge.svg?token=G51NEHYKD3)](https://codecov.io/gh/dotkernel/dot-mail) +[![PHPStan](https://github.com/dotkernel/dot-mail/actions/workflows/static-analysis.yml/badge.svg?branch=6.0)](https://github.com/dotkernel/dot-mail/actions/workflows/static-analysis.yml) ## Installation diff --git a/SECURITY.md b/SECURITY.md index d7fa46f..088dc6a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,6 +4,7 @@ | Version | Supported | PHP Version | |---------|-------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------| +| 6.0 | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-mail%2Fblob%2F6.0%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-mail/6.0.0) | | 5.2 | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-mail%2Fblob%2F5.2%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-mail/5.2.0) | | 5.1 | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-mail%2Fblob%2F5.1%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-mail/5.1.0) | | 5.0 | ![OSS Lifecycle](https://img.shields.io/osslifecycle?file_url=https%3A%2F%2Fgithub.com%2Fdotkernel%2Fdot-mail%2Fblob%2F5.0%2FOSSMETADATA) | ![PHP from Packagist (specify version)](https://img.shields.io/packagist/php-v/dotkernel/dot-mail/5.0.0) | From e2275ee5236b695eb1194451246b423e42257b1e Mon Sep 17 00:00:00 2001 From: bidi Date: Fri, 30 Oct 2026 12:41:22 +0200 Subject: [PATCH 5/5] updated laminas-ci - ignore php reqs Signed-off-by: bidi --- .laminas-ci.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.laminas-ci.json b/.laminas-ci.json index 29216fa..e827682 100644 --- a/.laminas-ci.json +++ b/.laminas-ci.json @@ -1,3 +1,6 @@ { + "ignore_php_platform_requirements": { + "8.6": true + }, "backwardCompatibilityCheck": true }