Display the number of failed messages and their details if they are picked from the queue but fail during the SMTP transaction (e.g., rejected, rate-limited)

Hi,

Mautic is new to us. We’re currently evaluating and setting up Mautic 6 within our company. We have a self-hosted MTA with full control, so it would be ideal if our employees could recognize failed messages caused by spam or viruses.

I’d like to share the modifications we’ve made. Any comments or feedback would be greatly appreciated. Since we’re currently only using segment emails, I’m not sure whether our changes affect other parts of the system.

  1. New messenger handler to send email:
diff --git a/app/bundles/MessengerBundle/MessageHandler/SendEmailMessageHandler.php b/app/bundles/MessengerBundle/MessageHandler/SendEmailMessageHandler.php
new file mode 100644
index 0000000000..1a57b32cb1
--- /dev/null
+++ b/app/bundles/MessengerBundle/MessageHandler/SendEmailMessageHandler.php
@@ -0,0 +1,132 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Mautic\MessengerBundle\MessageHandler;
+
+use Psr\Log\LoggerInterface;
+use Symfony\Component\Mailer\Exception\TransportException;
+use Symfony\Component\Mailer\Messenger\SendEmailMessage;
+use Symfony\Component\Mailer\SentMessage;
+use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
+use Symfony\Component\Mailer\Transport\Smtp\Stream\SocketStream;
+use Symfony\Component\Mailer\Transport\TransportInterface;
+use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
+
+use Mautic\CoreBundle\Helper\CoreParametersHelper;
+use Mautic\CoreBundle\Helper\DateTimeHelper;
+use Mautic\EmailBundle\Entity\StatRepository;
+use Mautic\EmailBundle\Model\EmailStatModel;
+
+class SendEmailMessageHandler implements MessageHandlerInterface
+{
+    private $transport;
+    private $logger;
+    private $coreParametersHelper;
+
+    public function __construct(
+        TransportInterface $transport,
+        private EmailStatModel $emailStatModel,
+        LoggerInterface $logger,
+        CoreParametersHelper $coreParametersHelper,
+    ) {
+        $this->transport = $transport;
+        $this->logger = $logger;
+        $this->coreParametersHelper = $coreParametersHelper;
+    }
+
+    public function __invoke(SendEmailMessage $message): ?SentMessage
+    {
+        try {
+            if ($this->transport instanceof EsmtpTransport) {
+                $stream = $this->transport->getStream();
+
+                if ($stream instanceof SocketStream) {
+                    $stream->setTimeout(30);
+                }
+            }
+
+            $ret = $this->transport->send($message->getMessage(), $message->getEnvelope());
+
+            $idHash = $message->getMessage()->getLeadIdHash();
+            // $this->logger->debug($idHash);
+            if (isset($idHash)) {
+                $stat = $this->getEmailStatus($idHash);
+                if ($stat) {
+                    if ($stat->isFailed()) {
+                        $stat->setIsFailed(false);
+                        $this->emailStatModel->saveEntity($stat);
+                    }
+                }
+            }
+
+            return $ret;
+
+        } catch (TransportException $exception) {
+            $context = [];
+            if ($this->transport instanceof EsmtpTransport) {
+                $context['debug'] = $this->transport->getStream()->getDebug();
+            } else {
+                $context['debug'] = $exception->getDebug();
+            }
+            $exceptionMessage = $exception->getMessage();
+            $this->logger->error($exceptionMessage, $context);
+
+            $idHash = $message->getMessage()->getLeadIdHash();
+            // $this->logger->debug($idHash);
+            if (isset($idHash)) {
+                $stat = $this->getEmailStatus($idHash);
+                if ($stat) {
+                    if (preg_match('/Expected response code "([0-9]+)" but got code "([0-9]+)", with message "([^"]+)"/is', $exceptionMessage, $matches)) {
+                        $errorCode = (int)$matches[2];
+                        $errorMessage = $matches[3];
+                        $errorType = 'failures';
+
+                        if ($errorCode >= 400 && $errorCode <= 499) {
+                            $errorType = 'temporary_failures';
+                        } elseif ($errorCode >= 500 && $errorCode <= 599) {
+                            $errorType = 'permanent_failures';
+                        }
+
+                        $dtHelper    = new DateTimeHelper();
+                        $openDetails = $stat->getOpenDetails();
+
+                        if (!isset($openDetails[$errorType])) {
+                            $openDetails[$errorType] = [];
+                        }
+
+                        $openDetails[$errorType][0] = [
+                            'datetime' => $dtHelper->toUtcString(),
+                            'reason'   => trim(preg_replace('/\s+/', ' ', $errorMessage)),
+                            'code'     => $errorCode,
+                            'type'     => ($errorType == 'temporary_failures') ? 'soft' : 'hard',
+                        ];
+
+                        $stat->setOpenDetails($openDetails);
+                    }
+
+                    $retryCount = $stat->getRetryCount();
+                    ++$retryCount;
+                    $stat->setRetryCount($retryCount);
+
+                    $maxRetries = (int)$this->coreParametersHelper->get('messenger_retry_strategy_max_retries');
+                    if ($retryCount >= $maxRetries && !$stat->isFailed()) $stat->setIsFailed(true);
+
+                    $this->emailStatModel->saveEntity($stat);
+                }
+            }
+
+            throw $exception;
+        }
+    }
+
+    public function getStatRepository(): StatRepository
+    {
+        return $this->emailStatModel->getRepository();
+    }
+
+    public function getEmailStatus($idHash)
+    {
+        return $this->getStatRepository()->getEmailStatus($idHash);
+    }
+}
  1. Register the new handler:
diff --git a/app/config/services.php b/app/config/services.php
index 0edc234b87..dbe3c5c5d2 100644
--- a/app/config/services.php
+++ b/app/config/services.php
@@ -4,6 +4,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator;
 
 use Mautic\CoreBundle\DependencyInjection\MauticCoreExtension;
 use Symfony\Component\DependencyInjection\ContainerBuilder;
+use Mautic\MessengerBundle\MessageHandler\SendEmailMessageHandler;
 
 // This is loaded by \Mautic\CoreBundle\DependencyInjection\MauticCoreExtension to auto-wire services
 // if the bundle do not cover it itself by their own *Extension and services.php which is prefered.
@@ -14,6 +15,10 @@ return function (ContainerConfigurator $configurator, ContainerBuilder $containe
         ->autoconfigure()
         ->public();
 
+    # Replace original Symfony service with ours
+    # Otherwise mail will be sent twice (by original and our handler)
+    $services->alias('mailer.messenger.message_handler', SendEmailMessageHandler::class);
+
     $bundles = array_merge($container->getParameter('mautic.bundles'), $container->getParameter('mautic.plugin.bundles'));
 
     // Autoconfigure services for bundles that do not have its own Config/services.php
  1. Display number of failed messages:
diff --git a/app/bundles/EmailBundle/Entity/Email.php b/app/bundles/EmailBundle/Entity/Email.php
index c8073813c2..07b86875c8 100644
--- a/app/bundles/EmailBundle/Entity/Email.php
+++ b/app/bundles/EmailBundle/Entity/Email.php
@@ -728,6 +728,17 @@ class Email extends FormEntity implements VariantEntityInterface, TranslationEnt
         return $this;
     }
 
+    /**
+     * @return mixed
+     */
+    public function getFailedCount()
+    {
+        $failedCount = $this->getStats()->filter(function ($stat) {
+            return $stat->getIsFailed() === true;
+        })->count();
+        return $failedCount;
+    }
+
     public function getIsClone(): bool
     {
         return $this->isCloned;
@@ -990,7 +1001,10 @@ class Email extends FormEntity implements VariantEntityInterface, TranslationEnt
      */
     public function getSentCount($includeVariants = false)
     {
-        return ($includeVariants) ? $this->getAccumulativeVariantCount('getSentCount') : $this->sentCount;
+        if ($includeVariants) { # I'm not confident about this change
+            return $this->getAccumulativeVariantCount('getSentCount');
+        }
+        return $this->sentCount - $this->getFailedCount();
     }
 
     /**
diff --git a/app/bundles/EmailBundle/Resources/views/Email/_list.html.twig b/app/bundles/EmailBundle/Resources/views/Email/_list.html.twig
index 808fad9ae6..451d21112c 100644
--- a/app/bundles/EmailBundle/Resources/views/Email/_list.html.twig
+++ b/app/bundles/EmailBundle/Resources/views/Email/_list.html.twig
@@ -169,6 +169,11 @@
                              data-toggle="tooltip"
                              title="{{ 'mautic.email.stat.tooltip'|trans }}">{{ 'mautic.email.stat.sentcount'|trans ({'%count%' : item.getSentCount(true)}) }}</a>
                         </span>
+                        <span class="mt-xs label label-red" id="failed-count-{{ item.getId() }}">
+                            <i class="ri-mail-unread-line"></i><a href="{{ path('mautic_contact_index', {'search' : 'mautic.lead.lead.searchcommand.email_failed'|trans ~ ':' ~ item.getId()}) }}"
+                             data-toggle="tooltip"
+                             title="{{ 'mautic.email.stat.tooltip'|trans }}">{{ 'mautic.email.stat.failedcount'|trans ({'%count%' : item.getFailedCount()}) }}</a>
+                        </span>
                         <span class="mt-xs label label-teal" id="read-count-{{ item.getId() }}">
                             <i class="ri-mail-open-line"></i><a href="{{ path('mautic_contact_index', {'search' : 'mautic.lead.lead.searchcommand.email_read'|trans ~ ':' ~ item.getId()}) }}"
                              data-toggle="tooltip"
diff --git a/app/bundles/EmailBundle/Translations/en_US/messages.ini b/app/bundles/EmailBundle/Translations/en_US/messages.ini
index c6160aa4c4..6b605c7e0a 100644
--- a/app/bundles/EmailBundle/Translations/en_US/messages.ini
+++ b/app/bundles/EmailBundle/Translations/en_US/messages.ini
@@ -333,6 +333,7 @@ mautic.email.stats.report.table="Emails Sent"
 mautic.email.stat.leadcount="%count% Pending"
 mautic.email.stat.readcount="%count% Read"
 mautic.email.stat.sentcount="%count% Sent"
+mautic.email.stat.failedcount="%count% Failed"
 mautic.email.stat.failed="Failed"
 mautic.email.stat.leadcount.tooltip="Number of contacts that have not received this email."
 mautic.email.stat.pending="Pending"
diff --git a/app/bundles/LeadBundle/Entity/LeadRepository.php b/app/bundles/LeadBundle/Entity/LeadRepository.php
index e57aedd7ef..cb31ce317a 100644
--- a/app/bundles/LeadBundle/Entity/LeadRepository.php
+++ b/app/bundles/LeadBundle/Entity/LeadRepository.php
@@ -967,6 +967,7 @@ class LeadRepository extends CommonRepository implements CustomFieldRepositoryIn
             'mautic.lead.lead.searchcommand.stage',
             'mautic.lead.lead.searchcommand.duplicate',
             'mautic.lead.lead.searchcommand.email_sent',
+            'mautic.lead.lead.searchcommand.email_failed',
             'mautic.lead.lead.searchcommand.email_read',
             'mautic.lead.lead.searchcommand.email_queued',
             'mautic.lead.lead.searchcommand.email_pending',
diff --git a/app/bundles/LeadBundle/EventListener/SearchSubscriber.php b/app/bundles/LeadBundle/EventListener/SearchSubscriber.php
index eb19fd335b..2dc22cf890 100644
--- a/app/bundles/LeadBundle/EventListener/SearchSubscriber.php
+++ b/app/bundles/LeadBundle/EventListener/SearchSubscriber.php
@@ -160,6 +160,10 @@ class SearchSubscriber implements EventSubscriberInterface
             case $this->translator->trans('mautic.lead.lead.searchcommand.email_read', [], null, 'en_US'):
                 $this->buildEmailReadQuery($event);
                 break;
+            case $this->translator->trans('mautic.lead.lead.searchcommand.email_failed'):
+            case $this->translator->trans('mautic.lead.lead.searchcommand.email_failed', [], null, 'en_US'):
+                $this->buildEmailFailedQuery($event);
+                break;
             case $this->translator->trans('mautic.lead.lead.searchcommand.email_sent'):
             case $this->translator->trans('mautic.lead.lead.searchcommand.email_sent', [], null, 'en_US'):
                 $this->buildEmailSentQuery($event);
@@ -394,6 +398,9 @@ class SearchSubscriber implements EventSubscriberInterface
 
         $config = [
             'column' => 'es.email_id',
+            'params' => [
+                'es.is_failed' => 0,
+            ],
         ];
 
         $this->buildJoinQuery($event, $tables, $config);
@@ -420,6 +427,27 @@ class SearchSubscriber implements EventSubscriberInterface
         $this->buildJoinQuery($event, $tables, $config);
     }
 
+    private function buildEmailFailedQuery(LeadBuildSearchEvent $event): void
+    {
+        $tables = [
+            [
+                'from_alias' => 'l',
+                'table'      => 'email_stats',
+                'alias'      => 'es',
+                'condition'  => 'l.id = es.lead_id',
+            ],
+        ];
+
+        $config = [
+            'column' => 'es.email_id',
+            'params' => [
+                'es.is_failed' => 1,
+            ],
+        ];
+
+        $this->buildJoinQuery($event, $tables, $config);
+    }
+
     private function buildSmsSentQuery(LeadBuildSearchEvent $event): void
     {
         $tables = [
diff --git a/app/bundles/LeadBundle/Translations/en_US/messages.ini b/app/bundles/LeadBundle/Translations/en_US/messages.ini
index 4cee144f4b..8d07ba16b2 100644
--- a/app/bundles/LeadBundle/Translations/en_US/messages.ini
+++ b/app/bundles/LeadBundle/Translations/en_US/messages.ini
@@ -143,6 +143,7 @@ mautic.lead.lead.searchcommand.tag.description="Filters contacts by a specific t
 mautic.lead.lead.searchcommand.stage.description="Filters contacts by a specific stage"
 mautic.lead.lead.searchcommand.duplicate.description="Finds contacts that are common among the specified lists"
 mautic.lead.lead.searchcommand.email_sent.description="Filters contacts who have been sent a specific email"
+mautic.lead.lead.searchcommand.email_failed.description="Filters contacts who have been sent a specific failed email"
 mautic.lead.lead.searchcommand.email_read.description="Filters contacts who have read a specific email"
 mautic.lead.lead.searchcommand.email_queued.description="Filters contacts who have a specific email queued to be sent"
 mautic.lead.lead.searchcommand.email_pending.description="Filters contacts who have a specific email pending"
@@ -562,6 +563,7 @@ mautic.lead.lead.searchcommand.stage="stage"
 mautic.lead.lead.searchcommand.tag="tag"
 mautic.lead.lead.searchcommand.duplicate="common"
 mautic.lead.lead.searchcommand.email_sent="email_sent"
+mautic.lead.lead.searchcommand.email_failed="email_failed"
 mautic.lead.lead.searchcommand.email_read="email_read"
 mautic.lead.lead.searchcommand.email_queued="email_queued"
 mautic.lead.lead.searchcommand.email_pending="email_pending"
  1. Display detailed reason:
diff --git a/app/bundles/EmailBundle/Resources/views/SubscribedEvents/Timeline/index.html.twig b/app/bundles/EmailBundle/Resources/views/SubscribedEvents/Timeline/index.html.twig
index d276bf655f..9fb325161b 100644
--- a/app/bundles/EmailBundle/Resources/views/SubscribedEvents/Timeline/index.html.twig
+++ b/app/bundles/EmailBundle/Resources/views/SubscribedEvents/Timeline/index.html.twig
@@ -7,7 +7,7 @@
 {% if item != false %}
     <p>
         {% if item.isFailed is not empty and item.isFailed %}
-            {% if item.openDetails.bouces is defined %}
+            {% if item.openDetails.bounces is defined %}
                 <span class="label label-warning" data-toggle="tooltip" title="{{ 'mautic.email.timeline.event.bounced'|trans }}">
                     {{ 'mautic.email.timeline.event.bounced'|trans }}
                 </span>
@@ -35,12 +35,22 @@
         {% if item.list_name is not empty %}
             {{ 'mautic.email.timeline.event.list'|trans({'%list%': item['list_name']}) }}
         {% endif %}
+
+        {% for type in ['temporary_failures', 'permanent_failures', 'failures'] %}
+            {% if item.openDetails[type] is defined %}
+                {% for detail in item.openDetails[type] %}
+                    {% if detail.reason is defined %}
+                        <hr/>{{ detail.reason }}
+                    {% endif %}
+                {% endfor %}
+            {% endif %}
+        {% endfor %}
     </p>
     <div class="small">
-      {% if item.openDetails is not empty %}
+      {% if not item.isFailed and item.openDetails is not empty %}
         <h6 class="mt-lg mb-sm"><strong>{{ 'mautic.email.timeline.open_details'|trans }}</strong></h6>
         {% for key, detail in item.openDetails %}
-            {% if 'bounces' != key %}
+            {% if key not in ['bounces', 'temporary_failures', 'permanent_failures', 'failures'] %}
               {% if showMore is not defined and loop.index > 5 %}
                   {% set showMore = true %}
                   <div style="display:none">