Initial commit: CloudOps infrastructure platform

This commit is contained in:
root
2026-04-09 19:58:57 +02:00
commit 1166a52f26
7762 changed files with 839452 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
<?php
return [
'parameters' => [
'messenger_dsn_email' => 'sync://', // sync means no queue
'messenger_dsn_hit' => 'sync://', // sync means no queue
'messenger_dsn_failed' => null, // failed transport is optional
'messenger_retry_strategy_max_retries' => 3, // Maximum number of retries for a failed send
'messenger_retry_strategy_delay' => 1000, // Delay in milliseconds between retries
'messenger_retry_strategy_multiplier' => 2.0, // Delay multiplier between retries e.g. 1 second delay, 2 seconds, 4 seconds
'messenger_retry_strategy_max_delay' => 0, // maximum delay in milliseconds between retries
],
];

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use Symfony\Component\Messenger\Transport\TransportFactory;
return function (ContainerConfigurator $configurator): void {
$services = $configurator->services()
->defaults()
->autowire()
->autoconfigure();
$services
->load('Mautic\\MessengerBundle\\', '../')
->exclude('../{Config,Tests,Message}');
$services->alias(TransportFactory::class, 'messenger.transport_factory');
};

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Mautic\MessengerBundle\Controller;
use Mautic\CoreBundle\Controller\AjaxController as CommonAjaxController;
use Mautic\MessengerBundle\Service\TestMessageFactory;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
class AjaxController extends CommonAjaxController
{
public function sendTestMessageAction(
Request $request,
MessageBusInterface $bus,
TestMessageFactory $messageFactory,
): Response {
try {
$message = $messageFactory->crateMessageByDsnKey((string) $request->request->get('key'));
} catch (\InvalidArgumentException) {
return $this->notFound();
}
$data = [
'success' => 1,
'message' => $this->translator->trans('mautic.core.success'),
];
try {
$bus->dispatch($message);
} catch (\Throwable $e) {
$data['success'] = 0;
$data['message'] = $this->translator->trans('mautic.messenger.config.dsn.test_message_failed', ['%message%' => $e->getMessage()]);
}
return $this->sendJsonResponse($data);
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Mautic\MessengerBundle\DependencyInjection\EnvProcessor;
use Symfony\Component\DependencyInjection\EnvVarProcessorInterface;
class MessengerNullableEnvVarProcessor implements EnvVarProcessorInterface
{
public function getEnv(string $prefix, string $name, \Closure $getEnv): string
{
return $getEnv($name) ?: 'null://';
}
public static function getProvidedTypes(): array
{
return [
'messenger-nullable' => 'string',
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Mautic\MessengerBundle\DependencyInjection;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Loader\PhpFileLoader;
class MauticMessengerExtension extends Extension
{
/**
* @param mixed[] $configs
*/
public function load(array $configs, ContainerBuilder $container): void
{
$loader = new PhpFileLoader($container, new FileLocator(__DIR__.'/../Config'));
$loader->load('services.php');
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Mautic\MessengerBundle\EventListener;
use Mautic\ConfigBundle\ConfigEvents;
use Mautic\ConfigBundle\Event\ConfigBuilderEvent;
use Mautic\MessengerBundle\Form\Type\ConfigType;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class ConfigSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
ConfigEvents::CONFIG_ON_GENERATE => ['onConfigGenerate', 0],
];
}
public function onConfigGenerate(ConfigBuilderEvent $event): void
{
$event->addForm([
'bundle' => 'MessengerBundle',
'formAlias' => 'messengerconfig',
'formType' => ConfigType::class,
'formTheme' => '@MauticMessenger/FormTheme/Config/_config_messengerconfig_widget.html.twig',
'parameters' => $event->getParametersFromConfig('MauticMessengerBundle'),
]);
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Mautic\MessengerBundle\Exceptions;
use Symfony\Component\Messenger\Exception\UnrecoverableExceptionInterface;
class InvalidPayloadException extends MauticMessengerException implements UnrecoverableExceptionInterface
{
/**
* @param array<mixed> $payload
*/
public function __construct(string $message = '', array $payload = [], ?\Throwable $previous = null)
{
$message .= json_encode($payload);
parent::__construct($message, 400, $previous);
}
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace Mautic\MessengerBundle\Exceptions;
class MauticMessengerException extends \Exception
{
}

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace Mautic\MessengerBundle\Form\Type;
use Mautic\ConfigBundle\Form\Type\DsnType;
use Mautic\MessengerBundle\Validator\Dsn;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
class ConfigType extends AbstractType
{
public function __construct(
private TranslatorInterface $translator,
) {
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$testButton = [
'action' => 'messenger:sendTestMessage',
'label' => $this->translator->trans('mautic.messenger.config.dsn.send_test_message'),
];
$builder->add(
'messenger_dsn_email',
DsnType::class,
[
'constraints' => [new Dsn()],
'test_button' => $testButton,
]
);
$builder->add(
'messenger_dsn_hit',
DsnType::class,
[
'constraints' => [new Dsn()],
'test_button' => $testButton,
]
);
$builder->add(
'messenger_dsn_failed',
DsnType::class,
[
'constraints' => [new Dsn()],
'required' => false,
'test_button' => $testButton,
]
);
$builder->add(
'messenger_retry_strategy_max_retries',
NumberType::class,
[
'label' => 'mautic.messenger.config.retry_strategy.max_retries',
'label_attr' => ['class' => 'control-label'],
'required' => false,
'attr' => [
'class' => 'form-control',
],
]
);
$builder->add(
'messenger_retry_strategy_delay',
NumberType::class,
[
'label' => 'mautic.messenger.config.retry_strategy.delay',
'label_attr' => ['class' => 'control-label'],
'required' => false,
'attr' => [
'class' => 'form-control',
],
]
);
$builder->add(
'messenger_retry_strategy_multiplier',
NumberType::class,
[
'scale' => 2,
'label' => 'mautic.messenger.config.retry_strategy.multiplier',
'label_attr' => ['class' => 'control-label'],
'required' => false,
'attr' => [
'class' => 'form-control',
],
]
);
$builder->add(
'messenger_retry_strategy_max_delay',
NumberType::class,
[
'label' => 'mautic.messenger.config.retry_strategy.max_delay',
'label_attr' => ['class' => 'control-label'],
'required' => false,
'attr' => [
'class' => 'form-control',
],
]
);
}
public function getBlockPrefix(): string
{
return 'messengerconfig';
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Mautic\MessengerBundle;
use Symfony\Component\HttpKernel\Bundle\Bundle;
class MauticMessengerBundle extends Bundle
{
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Mautic\MessengerBundle\Message;
use Mautic\MessengerBundle\Message\Traits\MessageRequestTrait;
use Symfony\Component\HttpFoundation\Request;
class EmailHitNotification
{
use MessageRequestTrait;
public function __construct(
private string $statId,
private Request $request,
?\DateTimeInterface $eventTime = null,
) {
$this->setEventTime($eventTime ?? new \DateTimeImmutable());
}
public function getStatId(): string
{
return $this->statId;
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace Mautic\MessengerBundle\Message;
use Mautic\MessengerBundle\Message\Traits\MessageRequestTrait;
use Symfony\Component\HttpFoundation\Request;
final class PageHitNotification
{
use MessageRequestTrait;
public function __construct(
private int $hitId,
private Request $request,
private bool $isNew,
private bool $isRedirect,
private ?int $pageId = null,
private ?int $leadId = null,
?\DateTimeInterface $eventTime = null,
) {
$this->setEventTime($eventTime ?? new \DateTimeImmutable());
}
public function getHitId(): int
{
return $this->hitId;
}
public function getPageId(): ?int
{
return $this->pageId;
}
public function getLeadId(): ?int
{
return $this->leadId;
}
public function isNew(): bool
{
return $this->isNew;
}
public function isRedirect(): bool
{
return $this->isRedirect;
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Mautic\MessengerBundle\Message;
class TestEmail
{
public function __construct(
public int $userId,
) {
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Mautic\MessengerBundle\Message;
class TestFailed
{
public function __construct(
public int $userId,
) {
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Mautic\MessengerBundle\Message;
class TestHit
{
public function __construct(
public int $userId,
) {
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Mautic\MessengerBundle\Message\Traits;
use Symfony\Component\HttpFoundation\Request;
trait MessageRequestTrait
{
private ?\DateTimeInterface $eventTime = null;
private Request $request;
public function getEventTime(): ?\DateTimeInterface
{
return $this->eventTime;
}
public function setEventTime(?\DateTimeInterface $eventTime = null): self
{
$this->eventTime = $eventTime;
return $this;
}
public function getRequest(): Request
{
return $this->request;
}
public function __serialize(): array
{
$data = get_object_vars($this);
$data['request'] = array_filter([
'attributes' => $this->request->attributes->all(),
'request' => $this->request->request->all(),
'query' => $this->request->query->all(),
'cookies' => $this->request->cookies->all(),
'files' => $this->request->files->all(),
'server' => $this->request->server->all(),
]);
return $data;
}
/**
* @param mixed[] $data
*/
public function __unserialize(array $data): void
{
$requestData = $data['request'];
$data['request'] = new Request(
$requestData['query'] ?? [],
$requestData['request'] ?? [],
$requestData['attributes'] ?? [],
$requestData['cookies'] ?? [],
$requestData['files'] ?? [],
$requestData['server'] ?? []
);
foreach ($data as $key => $item) {
$this->$key = $item;
}
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Mautic\MessengerBundle\MessageHandler;
use Doctrine\DBAL\Exception\RetryableException;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\EmailBundle\Model\EmailModel;
use Mautic\MessengerBundle\Message\EmailHitNotification;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\Exception\RecoverableMessageHandlingException;
use Symfony\Component\Messenger\Handler\Acknowledger;
#[AsMessageHandler]
class EmailHitNotificationHandler
{
private bool $isSyncTransport;
public function __construct(
private EmailModel $emailModel,
CoreParametersHelper $parametersHelper,
) {
$this->isSyncTransport = str_starts_with($parametersHelper->get('messenger_dsn_hit'), 'sync://');
}
public function __invoke(EmailHitNotification $message, ?Acknowledger $ack = null): void
{
try {
$this->emailModel->hitEmail(
$message->getStatId(),
$message->getRequest(),
false,
$this->isSyncTransport,
$message->getEventTime(),
true
);
} catch (RetryableException $e) {
throw new RecoverableMessageHandlingException($e->getMessage());
}
}
}

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace Mautic\MessengerBundle\MessageHandler;
use Mautic\LeadBundle\Entity\LeadRepository;
use Mautic\MessengerBundle\Exceptions\InvalidPayloadException;
use Mautic\MessengerBundle\Message\PageHitNotification;
use Mautic\PageBundle\Entity\Hit;
use Mautic\PageBundle\Entity\HitRepository;
use Mautic\PageBundle\Entity\PageRepository;
use Mautic\PageBundle\Entity\RedirectRepository;
use Mautic\PageBundle\Model\PageModel;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\Handler\Acknowledger;
#[AsMessageHandler]
class PageHitNotificationHandler
{
public function __construct(
private PageRepository $pageRepository,
private HitRepository $hitRepository,
private LeadRepository $leadRepository,
private LoggerInterface $logger,
private RedirectRepository $redirectRepository,
private PageModel $pageModel,
) {
}
/**
* @throws InvalidPayloadException
*/
public function __invoke(PageHitNotification $message, ?Acknowledger $ack = null): void
{
$parsed = $this->parseMessage($message);
$this->pageModel->processPageHit(...$parsed);
$this->logger->info('processed page hit #'.$message->getHitId());
}
/**
* @return array<string, mixed>
*
* @throws InvalidPayloadException
*/
private function parseMessage(PageHitNotification $message): array
{
$hit = $message->getHitId() > 0 ? $this->hitRepository->find($message->getHitId()) : null;
$pageObject = null;
if (null !== $message->getPageId()) {
try {
$pageObject = $message->isRedirect()
? $this->redirectRepository->find($message->getPageId())
: $this->pageRepository->find($message->getPageId());
} catch (\Exception $exception) {
$this->logger->error(
sprintf('Invalid page/redirect, exception. #%s', $message->getPageId()),
['message' => $message]
);
throw $exception;
}
if (null === $pageObject) {
$this->logger->error(
sprintf('Invalid page/redirect, id not found. #%s', $message->getPageId())
);
throw new InvalidPayloadException('Missing required information', ['message' => $message]);
}
}
if (!$hit instanceof Hit && $message->getHitId() > 0) {
$this->logger->warning('Invalid hit id #'.$message->getHitId(), ['message' => $message]);
throw new InvalidPayloadException('Invalid hit id #'.$message->getHitId(), (array) $message);
}
// Lead IS mandatory field
if (null === $lead = $this->leadRepository->find($message->getLeadId())) {
$this->logger->error('Invalid lead id #'.$message->getLeadId(), ['message' => $message]);
throw new InvalidPayloadException('Invalid lead id', (array) $message);
}
return [
'hit' => $hit,
'page' => $pageObject,
'request' => $message->getRequest(),
'lead' => $lead,
'trackingNewlyGenerated' => $message->isNew(),
'activeRequest' => false,
'hitDate' => $message->getEventTime(),
];
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Mautic\MessengerBundle\MessageHandler;
use Mautic\CoreBundle\Helper\FilePathResolver;
use Mautic\ReportBundle\Model\ExportHandler;
use Symfony\Component\Mailer\Messenger\SendEmailMessage;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Mime\Email;
#[AsMessageHandler(priority: -1000)]
class RemoveReportAttachmentHandler
{
public function __construct(private ExportHandler $exportHandler, private FilePathResolver $filePathResolver)
{
}
public function __invoke(SendEmailMessage $message): void
{
$email = $message->getMessage();
if (!$email instanceof Email) {
return;
}
$attachments = $email->getAttachments();
foreach ($attachments as $attachment) {
$headers = $attachment->getPreparedHeaders();
if (!$headers->has('Content-Disposition')) {
continue;
}
$filename = $headers->getHeaderParameter('Content-Disposition', 'filename');
if (null === $filename) {
continue;
}
$attachmentPath = $this->exportHandler->getPath(pathinfo($filename, \PATHINFO_FILENAME));
$this->filePathResolver->delete($attachmentPath);
// str_replace as in \Mautic\ReportBundle\Scheduler\Model\FileHandler::zipIt
$this->filePathResolver->delete(str_replace('.csv', '.zip', $attachmentPath));
}
}
}

View File

@@ -0,0 +1,88 @@
# Messenger Bundle
The bundle makes use of [Symfony's messenger component](https://symfony.com/doc/5.4/messenger.html) to dispatch and handle messages.
## Transports
It creates `synchronous` transport only, it is necessary for processing in case external messenger is not used.
https://symfony.com/doc/current/messenger.html#transports-async-queued-messages
The only thing you need to do is to map the routing key to the transport you wish to use.
https://symfony.com/doc/current/messenger.html#routing-messages-to-a-transport
By default, the transport is set to **synchronous**, meaning no AMQP/Doctrine or whatsoever is used and the request is handled directly and the message is marked as synchronous process if it implements **RequestStatusInterface**.
[Currently defined routes](MauticMessengerRoutes.php) are EMAIL, FAILED, SYNC and HIT although in default configuration only the SYNC is used.
> https://symfony.com/doc/5.4/messenger.html#routing-messages-to-a-transport
Here [a sample configuration](#sample-configuration)
## Notifications
Currently, 2 messages are defined.
* [EmailHitNotification](app/bundles/MessengerBundle/Message/EmailHitNotification.php)
* [PageHitNotification](app/bundles/MessengerBundle/Message/PageHitNotification.php)
## Configuring transports
> there is new serializer available, that uses JSON and has smaller payload than native php **'messenger.transport.jms_serializer'** that is recommended to use. You can place the following configuration sample to any config file where `$container` is available. For example `config/config_local.php`
```php
$container->loadFromExtension('framework', [
'messenger' => [
'routing' => [
\Mautic\MessengerBundle\Message\PageHitNotification::class => \Mautic\MessengerBundle\MauticMessengerTransports::HIT,
\Mautic\MessengerBundle\Message\EmailHitNotification::class => \Mautic\MessengerBundle\MauticMessengerTransports::HIT,
],
'failure_transport' => 'failed', // Define other than default if you wish
'transports' => [
'failed' => [
'dsn' => 'doctrine://default?queue_name=failed',
],
\Mautic\MessengerBundle\MauticMessengerTransports::SYNC => 'sync://',
\Mautic\MessengerBundle\MauticMessengerTransports::HIT => [
'dsn' => '%env(MAUTIC_MESSENGER_TRANSPORT_DSN)%',
'serializer' => 'messenger.transport.jms_serializer',
'options' => [
'heartbeat' => 1,
'persistent' => true,
'vhost' => '/',
'exchange' => [
'name' => 'mautic',
'type' => 'direct',
'default_publish_routing_key' => 'hit',
],
'queues' => [
'hits' => [
'binding_keys' => ['hit'],
'arguments' => [
'x-expires' => 60 * 60 * 24 * 21 * 1000, // queue ttl without consumer using it
],
],
],
],
'serializer' => 'messenger.transport.native_php_serializer',
'retry_strategy' => [
'max_retries' => 3,
'delay' => 500,
'multiplier' => 3,
'max_delay' => 0,
],
],
],
],
]);
```
## Usage
In order to run consumer, simply run:
```shell
sudo -uwww-data bin/console messenger:consume hit
```
> Where *hit* stands for your transport's name. In the example above; it is the value of `\Mautic\MessengerBundle\MauticMessengerTransports::HIT`

View File

@@ -0,0 +1,66 @@
{% block _config_messengerconfig_widget %}
<div class="alert alert-info mt-md" role="alert">
{% trans with {'%link%': 'https://symfony.com/doc/5.4/messenger.html#transport-configuration' } %}mautic.messenger.config.dsn_help_general{% endtrans %}
</div>
<h4 class="fw-sb mt-48 mb-xs">{{ 'mautic.messenger.config.dsn_email'|trans }}</h4>
<div class="text-muted small pb-md">{{ 'mautic.core.config.header.messenger.email.description'|trans }}</div>
<div class="row">
<div class="panel panel-default mb-md">
<div class="panel-body">
{{ form_row(form.messenger_dsn_email) }}
</div>
</div>
</div>
<h4 class="fw-sb mt-48 mb-xs">{{ 'mautic.messenger.config.dsn_hit'|trans }}</h4>
<div class="text-muted small pb-md">{{ 'mautic.core.config.header.messenger.hit.description'|trans }}</div>
<div class="row">
<div class="panel panel-default mb-md">
<div class="panel-body">
{{ form_row(form.messenger_dsn_hit) }}
</div>
</div>
</div>
<div class="alert alert-info" role="alert">
{% trans with {'%link%': 'https://symfony.com/doc/5.4/messenger.html#retries-failures' } %}mautic.messenger.config.dsn_help_retry_strategy{% endtrans %}
</div>
<h4 class="fw-sb mt-48 mb-xs">{{ 'mautic.messenger.config.retry_strategy'|trans }}</h4>
<div class="text-muted small pb-md">{{ 'mautic.core.config.header.messenger.retry.description'|trans }}</div>
<div class="row">
<div class="panel panel-default mb-md">
<div class="panel-body">
<div class="row">
<div class="col-xs-12 col-lg-3">
{{ form_row(form.messenger_retry_strategy_max_retries) }}
</div>
<div class="col-xs-12 col-lg-3">
{{ form_row(form.messenger_retry_strategy_delay) }}
</div>
<div class="col-xs-12 col-lg-3">
{{ form_row(form.messenger_retry_strategy_multiplier) }}
</div>
<div class="col-xs-12 col-lg-3">
{{ form_row(form.messenger_retry_strategy_max_delay) }}
</div>
</div>
</div>
</div>
</div>
<div class="alert alert-info" role="alert">
{% trans with {'%link%': 'https://symfony.com/doc/5.4/messenger.html#saving-retrying-failed-messages' } %}mautic.messenger.config.dsn_help_failed{% endtrans %}
</div>
<h4 class="fw-sb mt-48 mb-xs">{{ 'mautic.messenger.config.dsn_failed'|trans }}</h4>
<div class="text-muted small pb-md">{{ 'mautic.core.config.header.messenger.failed.description'|trans }}</div>
<div class="row">
<div class="panel panel-default mb-md">
<div class="panel-body">
{{ form_row(form.messenger_dsn_failed) }}
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Mautic\MessengerBundle\Retry;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Retry\MultiplierRetryStrategy;
use Symfony\Component\Messenger\Retry\RetryStrategyInterface;
class RetryStrategy implements RetryStrategyInterface
{
private RetryStrategyInterface $retryStrategy;
public function __construct(
private CoreParametersHelper $parametersHelper,
) {
}
public function isRetryable(Envelope $message, ?\Throwable $throwable = null): bool
{
return $this->getRetryStrategy()->isRetryable($message);
}
public function getWaitingTime(Envelope $message, ?\Throwable $throwable = null): int
{
return $this->getRetryStrategy()->getWaitingTime($message);
}
private function getRetryStrategy(): RetryStrategyInterface
{
if (!isset($this->retryStrategy)) {
$this->retryStrategy = new MultiplierRetryStrategy(
(int) $this->parametersHelper->get('messenger_retry_strategy_max_retries'),
(int) $this->parametersHelper->get('messenger_retry_strategy_delay'),
(float) $this->parametersHelper->get('messenger_retry_strategy_multiplier'),
(int) $this->parametersHelper->get('messenger_retry_strategy_max_delay'),
);
}
return $this->retryStrategy;
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Mautic\MessengerBundle\Service;
use Mautic\CoreBundle\Helper\UserHelper;
use Mautic\MessengerBundle\Message\TestEmail;
use Mautic\MessengerBundle\Message\TestFailed;
use Mautic\MessengerBundle\Message\TestHit;
class TestMessageFactory
{
public function __construct(
private UserHelper $userHelper,
) {
}
public function crateMessageByDsnKey(string $key): object
{
return match ($key) {
'messenger_dsn_email' => new TestEmail($this->userHelper->getUser()->getId()),
'messenger_dsn_hit' => new TestHit($this->userHelper->getUser()->getId()),
'messenger_dsn_failed' => new TestFailed($this->userHelper->getUser()->getId()),
default => throw new \InvalidArgumentException(sprintf('Unsupported key: "%s"', $key)),
};
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace Mautic\MessengerBundle\Tests\Message;
use Mautic\MessengerBundle\Message\EmailHitNotification;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
class EmailHitNotificationTest extends TestCase
{
public function testConstruct(): void
{
$request = new Request();
$request->query->set('testMe', 'Hit me once');
$message = new EmailHitNotification('statid', $request);
$this->assertArrayHasKey('testMe', $message->getRequest()->query->all());
$this->assertEquals($request, $message->getRequest());
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace Mautic\MessengerBundle\Tests\Message;
use Mautic\MessengerBundle\Message\PageHitNotification;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
class PageHitNotificationTest extends TestCase
{
public function testConstruct(): void
{
$request = new Request();
$request->query->set('testMe', 'Hit me once');
$message = new PageHitNotification(78, $request, false, false, 3, 1);
$this->assertArrayHasKey('testMe', $message->getRequest()->query->all());
$this->assertEquals($request, $message->getRequest());
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace Mautic\MessengerBundle\Tests\MessageHandler;
use Doctrine\DBAL\Exception\RetryableException;
use Mautic\CoreBundle\Helper\CoreParametersHelper;
use Mautic\EmailBundle\Model\EmailModel;
use Mautic\MessengerBundle\Message\EmailHitNotification;
use Mautic\MessengerBundle\MessageHandler\EmailHitNotificationHandler;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Messenger\Exception\RecoverableMessageHandlingException;
class EmailHitNotificationHandlerTest extends TestCase
{
public function testInvoke(): void
{
$hitId = sha1((string) random_int(0, 1_000_000));
$request = new Request();
$request->query->set('testMe', 'I am here');
/** @var MockObject|EmailModel $emailModelMock */
$emailModelMock = $this->createMock(EmailModel::class);
$emailModelMock
->expects($this->exactly(1))
->method('hitEmail')
->with($hitId, $request);
/** @var MockObject&CoreParametersHelper $parametersHelper */
$parametersHelper = $this->createMock(CoreParametersHelper::class);
$parametersHelper->method('get')
->willReturn('sync://');
$message = new EmailHitNotification($hitId, $request);
$handler = new EmailHitNotificationHandler($emailModelMock, $parametersHelper);
$handler->__invoke($message);
}
public function testInvokeThrowsRecoverableExceptionOnDBLock(): void
{
$hitId = sha1((string) random_int(0, 1_000_000));
$request = new Request();
$request->query->set('testMe', 'I am here');
/** @var MockObject|EmailModel $emailModelMock */
$emailModelMock = $this->createMock(EmailModel::class);
$emailModelMock
->expects($this->exactly(1))
->method('hitEmail')
->willThrowException($this->createMock(RetryableException::class));
/** @var MockObject&CoreParametersHelper $parametersHelper */
$parametersHelper = $this->createMock(CoreParametersHelper::class);
$parametersHelper->method('get')
->willReturn('sync://');
$message = new EmailHitNotification($hitId, $request);
$handler = new EmailHitNotificationHandler($emailModelMock, $parametersHelper);
$this->expectException(RecoverableMessageHandlingException::class);
$handler->__invoke($message);
}
public function testInvokeLogsUnrecoverableException(): void
{
$hitId = sha1((string) random_int(0, 1_000_000));
$request = new Request();
$request->query->set('testMe', 'I am here');
/** @var MockObject|EmailModel $emailModelMock */
$emailModelMock = $this->createMock(EmailModel::class);
$emailModelMock
->expects($this->exactly(1))
->method('hitEmail')
->willThrowException(new \InvalidArgumentException('got my argument?'));
/** @var MockObject&CoreParametersHelper $parametersHelper */
$parametersHelper = $this->createMock(CoreParametersHelper::class);
$parametersHelper->method('get')
->willReturn('sync://');
$message = new EmailHitNotification($hitId, $request);
$handler = new EmailHitNotificationHandler($emailModelMock, $parametersHelper);
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('got my argument?');
$handler->__invoke($message);
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace Mautic\MessengerBundle\Tests\MessageHandler;
use Mautic\LeadBundle\Entity\Lead;
use Mautic\LeadBundle\Entity\LeadRepository;
use Mautic\MessengerBundle\Message\PageHitNotification;
use Mautic\MessengerBundle\MessageHandler\PageHitNotificationHandler;
use Mautic\PageBundle\Entity\Hit;
use Mautic\PageBundle\Entity\HitRepository;
use Mautic\PageBundle\Entity\Page;
use Mautic\PageBundle\Entity\PageRepository;
use Mautic\PageBundle\Entity\Redirect;
use Mautic\PageBundle\Entity\RedirectRepository;
use Mautic\PageBundle\Model\PageModel;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
class PageHitNotificationHandlerTest extends TestCase
{
public function testInvoke(): void
{
[$hitId, $pageId, $leadId, $redirectId] = [random_int(1, 1000), random_int(1, 1000), random_int(1, 1000), random_int(1, 1000)];
$redirectObject = new Redirect();
$redirectObject->setRedirectId((string) $redirectId);
[$hitObject, $pageObject, $leadObject] = [
(new Hit())->setCode(7),
(new Page())->setAlias('james_bond'),
(new Lead())->setId($leadId),
];
$hitRepoMock = $this->createMock(HitRepository::class);
$hitRepoMock
->expects($this->once())
->method('find')
->with($hitId)
->willReturn($hitObject);
$pageRepoMock = $this->createMock(PageRepository::class);
$pageRepoMock->expects($this->once())
->method('find')
->with($pageId)
->willReturn($pageObject);
$redirectRepoMock = $this->createMock(RedirectRepository::class);
$redirectRepoMock
->expects($this->never())
->method('find')
->with($redirectId)
->willReturn($redirectObject);
$leadRepoMock = $this->createMock(LeadRepository::class);
$leadRepoMock
->expects($this->once())
->method('find')
->with($leadId)
->willReturn($leadObject);
$request = new Request();
$request->query->set('testMe', 'I am here');
/** @var MockObject|PageModel $pageModelMock */
$pageModelMock = $this->createMock(PageModel::class);
$pageModelMock
->expects($this->exactly(1))
->method('processPageHit')
->with($hitObject, $pageObject, $request, $leadObject, false, false);
$message = new PageHitNotification($hitId, $request, false, false, $pageId, $leadId);
/** @var MockObject|LoggerInterface $loggerMock */
$loggerMock = $this->createMock(LoggerInterface::class);
$handler = new PageHitNotificationHandler(
$pageRepoMock, $hitRepoMock, $leadRepoMock, $loggerMock, $redirectRepoMock, $pageModelMock
);
$handler->__invoke($message);
}
}

View File

@@ -0,0 +1,19 @@
mautic.config.tab.messengerconfig="Queue Settings"
mautic.messenger.config.retry_strategy="Retry strategy"
mautic.messenger.config.retry_strategy.max_retries="Max retries"
mautic.messenger.config.retry_strategy.delay="Delay"
mautic.messenger.config.retry_strategy.multiplier="Multiplier"
mautic.messenger.config.retry_strategy.max_delay="Max delay"
mautic.messenger.config.dsn_help_general="Queuing is not enabled by default (scheme is set to 'sync'). If you want to start using a queue, please follow the documentation at <a href="%link%">%link%</a>."
mautic.messenger.config.dsn_help_retry_strategy="When the processing of a message fails, the message is sent back to the queue for another try. You can adjust this behaviour in the following section. See the documentation on <a href="%link%">%link%</a> for more details."
mautic.messenger.config.dsn_help_failed="If a message fails all its retries, it's discarded by default. To avoid this happening, you can optionally configure a queue for failures. For more details see the documentation on <a href="%link%">%link%</a>."
mautic.messenger.config.dsn_email="Queue for email (SMS and push messages)"
mautic.messenger.config.dsn_hit="Queue for hits (page and email)"
mautic.messenger.config.dsn_failed="Queue for failures"
mautic.messenger.config.dsn.send_test_message="Send test message"
mautic.messenger.config.dsn.test_message_failed="The test message could not be sent due to the following error: '"%message%"'."
mautic.messenger.config.dsn.test_message_processed="The test message for DSN '"%type%"' was successfully processed."
mautic.core.config.header.messenger.email.description="Configure message queuing settings for email, SMS, and push notifications."
mautic.core.config.header.messenger.hit.description="Manage queuing settings for page views and email tracking data."
mautic.core.config.header.messenger.retry.description="Define how failed messages are handled and retried in the queue system."
mautic.core.config.header.messenger.failed.description="Set up storage for failed messages that exceed retry attempts."

View File

@@ -0,0 +1,2 @@
mautic.messenger.dsn.invalid_dsn="Invalid DSN. Please make sure you entered all the required fields. Usually the fields 'scheme' and 'host' are required."
mautic.messenger.dsn.unsupported_scheme="Unsupported scheme. Please make sure the entered scheme matches one of the supported schemes. You might need to install a package supporting the scheme first. For more details see https://symfony.com/doc/5.4/messenger.html#transport-configuration"

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Mautic\MessengerBundle\Transport;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Transport\TransportInterface;
class NullTransport implements TransportInterface
{
public function get(): iterable
{
return [];
}
public function ack(Envelope $envelope): void
{
}
public function reject(Envelope $envelope): void
{
}
public function send(Envelope $envelope): Envelope
{
return $envelope;
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Mautic\MessengerBundle\Transport;
use Symfony\Component\Messenger\Transport\Serialization\SerializerInterface;
use Symfony\Component\Messenger\Transport\TransportFactoryInterface;
use Symfony\Component\Messenger\Transport\TransportInterface;
/**
* Needed for the E2E tests.
*/
class NullTransportFactory implements TransportFactoryInterface
{
/**
* @param mixed[] $options
*/
public function createTransport(string $dsn, array $options, SerializerInterface $serializer): TransportInterface
{
return new NullTransport();
}
/**
* @param mixed[] $options
*/
public function supports(string $dsn, array $options): bool
{
return str_starts_with($dsn, 'null://');
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Mautic\MessengerBundle\Validator;
use Symfony\Component\Validator\Constraint;
class Dsn extends Constraint
{
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Mautic\MessengerBundle\Validator;
use Mautic\CoreBundle\Helper\Dsn\Dsn as CoreDsn;
use Mautic\MessengerBundle\Validator\Dsn as DsnConstraint;
use Symfony\Component\Messenger\Transport\TransportFactory;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
class DsnValidator extends ConstraintValidator
{
public function __construct(
private TransportFactory $transportFactory,
) {
}
public function validate(mixed $value, Constraint $constraint): void
{
if (!is_string($value)) {
throw new UnexpectedTypeException($value, 'string');
}
if (!$constraint instanceof DsnConstraint) {
throw new UnexpectedTypeException($constraint, DsnConstraint::class);
}
if (!$value) {
return;
}
try {
$dsn = CoreDsn::fromString($value);
} catch (\InvalidArgumentException) {
$this->context->addViolation('mautic.messenger.dsn.invalid_dsn');
return;
}
if (!$this->transportFactory->supports($value, $dsn->getOptions())) {
$this->context->addViolation('mautic.messenger.dsn.unsupported_scheme');
}
}
}