I think now could be a good time for considering a complete refactoring of Mautic’s email sending.
Here’s what I am roposing on Github:
Right about now, some changes need to ocur in the Mautic delivery system because of deprecations and M5 requirements changing.
We could use this opportunity to improve Mautic’s email delivery, both technically and functionally, among other things, by discussing the requirements with the community and even more specifically with who sends the freaking emails, the marketers!
I understand would be harder and would take extra time, but it is not like we have a deadline for M5… or do we?
Please check the conversation on Github:
opened 01:56PM - 20 Mar 23 UTC
As the SwiftMailer library is dead and we must replace it with Symfony Mailer th… en this is a great opportunity to refactor the email sending in Mautic. This crucial part of Mautic has evolved in the years and each new feature added new `if` in the existing implementation. So the maintenance of this code is very costly and every change in the code can break some unrelated transport for example. The transport that support toketinzation (sending more than 1 email at once via API) use different code than SMTP-based transports. So debugging is complicated.
Symfony Mailer can send only one email per request and this won't change in the future. See https://github.com/symfony/symfony/issues/32991
There is WIP PR which removes SwiftMailer with Symfony Mailer: https://github.com/mautic/mautic/pull/11613
I suggest to improve this PR with more maintainable solution when we are forced to refactor it anyway.
What the system must support:
- [ ] Sending emails in batches by default. This is not considered in Symfony mailer at all. Each mailer sends one email at a time even if the API-based mailers can send 10k emails in 1 request. This is crucial to support for fast email sending that Mautic users are used to. Imagine you can send 1 email/second or 10k emails/second difference. These mailers will have to be created by our community.
- [ ] Ideally support the [pre-made Symfony mailers](https://symfony.com/doc/6.3/mailer.html#using-a-3rd-party-transport).
- [ ] At least prepare the possibility to have different mailer for marketing and another for transactional emails.
- [ ] Support bounce callbacks.
An iterable collection of recipients that can have 1 or several thousands or records.
```php
interface RecipientsInterface extends Iterable
{
public function add(Recipient $recipient): void;
}
```
DTO class that will hold the info we need to send a personalized email.
```php
class Recipient
{
public function getRecipietTokens(): array;
public function getEmailAddress(): Address;
}
```
Wrapper DTO that will be used to send a batch email. Can be easily extended.
```php
class BatchEmail
{
public function getRecipients(): RecipientsInterface;
public function getEmail(): Email;
public function getGlobalTokens(): array;
}
```
A factory that can check whether it supports a DSN and if so, create the transport with it.
```php
interface BatchTransportFactoryInterface
{
public function supports(Dsn $dsn): bool;
public function create(Dsn $dsn): BatchTransportInterface;
}
```
The batch transports will have to implement this interface to send batch (and/or single) emails
```php
interface BatchTransportInterface
{
public function send(BatchEmail $batchEmail): void;
}
```
Optional interface to handle bounces. It's not used anywhere in this example but will be implemented similarly like BatchTransportInterface for bounce requests.
```php
interface TransportBounceInterface
{
public function processCallbackRequest(RequestInterface $request): void;
}
```
Example of how we can use the classic Symfony transports that can use only 1 email per request.
```php
class BasicTransport implements BatchTransportInterface
{
private AnyExistingSymfonyTransport $transport
public function __construct(AnyExistingSymfonyTransport $transport)
{
$this->transport = $transport;
}
public function send(BatchEmail $batchEmail): void
{
foreach ($batchEmail->getRecipients() as $recipient) {
$batchEmail->getEmail()->to($recipient->getEmail();
$this->replaceTokens($recipient->getRecipietTokens() + $batchEmail->getGlobalTokens(), $batchEmail->getEmail()->getHtml());
$this->transport->send($email);
}
}
}
```
Example of how we can implement our own batch transports that can send thousands of emails per request.
```php
class SparkpostTransport implements BatchTransportInterface
{
private SparkpostClient $sparkPostClient
public function __construct(SparkpostClient $sparkPostClient)
{
$this->sparkPostClient = $sparkPostClient;
}
public function send(BatchEmail $batchEmail): void
{
$sparkPostMessage = $this->makeSparkPostMessage($batchEmail);
$sparkPostClient->transmissions->post($sparkPostMessage);
}
}
```
A collector that will be filled on cache build with transports that are ready to send emails. There are examples of 4 methods of how to get some specific transport. We may have 4 fields for primary, backup, marketing, transport DSNs in the configuration. If some is not configured then the primary will be used.
```php
class BatchTransportCollector
{
private CoreParametersHelper $coreParametersHelper;
private ContainerInterface $serviceLocator;
private array $transports;
public function __construct(CoreParametersHelper $coreParametersHelper, ContainerInterface $serviceLocator)
{
$this->coreParametersHelper = $coreParametersHelper;
$this->serviceLocator = $serviceLocator;
}
// There can be various transports for various use cases:
public function getPrimaryTransport(): BatchTransportInterface;
public function getBackupTransport(): BatchTransportInterface;
public function getMarketingTransport(): BatchTransportInterface;
public function getTransactionalTransport(): BatchTransportInterface;
private function init()
{
if (isset($this->transport)) {
return;
}
$dsns = $this->coreParametersHelper->get('dsns'); // an array of DSN strings
foreach ($this->serviceLocator->getProvidedServices() as $serviceId) {
$transportFactory = $this->serviceLocator->get($serviceId);
foreach ($dsns as $dsn) {
if ($transportFactory->supports($dsn)) {
$this->transports[] = $transportFactory->create($dsn);
}
}
}
}
}
```
Autowiring the batch transports. Any new class implementing the BatchTransportInterface will be automatically tagged in `app/bundles/CoreBundles/Config/services.php`:
```php
$services->instanceof(\Mautic\EmailBundle\Mailer\BatchTransportFactoryInterface::class)->tag('batch.transport.factory');
```
Then in a new BatchTransportCompilerPass.php we'll do something like
```php
class BatchTransportCompilerPass implements CompilerPassInterface
{
public function process(ContainerBuilder $containerBuilder)
{
if (!$containerBuilder->hasDefinition(BatchTransportCollector::class)) {
return;
}
$configuratorDef = $containerBuilder->findDefinition(BatchTransportCollector::class);
$locateableServices = [];
foreach ($containerBuilder->findTaggedServiceIds('batch.transport.factory') as $id => $tags) {
$locateableServices[$id] = new Reference($id);
}
$configuratorDef->addArgument(ServiceLocatorTagPass::register($containerBuilder, $locateableServices));
}
}
```
Example usage of how to send a batch/segment email:
```php
$symfonyEmail = (new Email())
->from($this->getFrom()) // Similar for cc, bcc. To will be empty here.
->subject($mauticEmail->getSubject())
->html($mauticEmail->getCustomHtml());
$recipients = new Recipients();
foreach ($contacts as $contact) {
$recipients->add(new Recipient($contact->getFields(true), $contact->getEmail()));
}
$bathEmail = new BatchEmail($symfonyEmail, $recipients, $this->getGlobalTokens());
$batchTransportCollector->getPrimaryTransport()->send($batchEmail);
```
<bountysource-plugin>
---
Want to back this issue? **[Post a bounty on it!](https://app.bountysource.com/issues/121828590-new-email-service-based-on-symfony-mailer?utm_campaign=plugin&utm_content=tracker%2F5355074&utm_medium=issues&utm_source=github)** We accept bounties via [Bountysource](https://app.bountysource.com/?utm_campaign=plugin&utm_content=tracker%2F5355074&utm_medium=issues&utm_source=github).
</bountysource-plugin>