Problem/Motivation

I tried to add a new TransportFactory and Transport class to handle a new DSN like foo://

This is possible with symfony mailer by following a few steps:

final class FooTransportFactory extends AbstractTransportFactory
{
    public function create(Dsn $dsn): TransportInterface
    {
        return new FooTransport();
    }

    protected function getSupportedSchemes(): array
    {
        return ['foo'];
    }
}
services:
  mailer.transport_factory.foo:
    class: App\FooTransportFactory
    parent: mailer.transport_factory.abstract
    tags:
      - {name: mailer.transport_factory}

For now, the Drupal "MailerTransport" plugin allows us to define a new DSN, it is usable in the configuration, but that's all.
Symfony Mailer crash saying "Unknow Dsn scheme"

Is there a way to do it?

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

Damien Laguerre created an issue. See original summary.

damien laguerre’s picture

Issue summary: View changes
adamps’s picture

Category: Feature request » Support request

I tried to add a new TransportFactory and Transport class to handle a new DSN like foo://

This function is entirely with the Symfony Mailer library so I suggest you ask there.

damien laguerre’s picture

This is currently working with symfony.

The problem exist only with Drupal symfony mailer.

damien laguerre’s picture

damien laguerre’s picture

StatusFileSize
new20.43 KB

This is a POC.

I translated the Symfony Mailer services for Drupal.
This way, the ability to define a new transport factory is functional, as with Symfony.

damien laguerre’s picture

StatusFileSize
new9.05 KB

Cancel and replace the previous patch... Sorry.

adamps’s picture

Title: Can't add TransportFactory and custom Transport implementing new DSN » Allow custom TransportFactory
Category: Support request » Feature request
Status: Active » Needs work

I don't really get this one yet😃.

1. - $transport_dsn = $email->getTransportDsn();
That can't be right surely? We need to use the provided DSN.

2 SymfonyMailerServiceProvider.php duplicates a lot of code with a long list of service providers. We don't want to maintain that code in this module.

damien laguerre’s picture

I applied the Symfony approach.
When you install symfony/mail on Symfony, Mailer services are declared on the project.

1. - $transport_dsn = $email->getTransportDsn() ;

In Symfony, the default transport is defined in the .env file, and passed to the TransportFactory.
For this to work with Drupal, we can't use the database storage at the service build stage.
So I defined it in the settings.php.

2 SymfonyMailerServiceProvider.php duplicates a lot of code with a long list of service providers. We don't want to maintain this code in this module.

It is not really a duplicate, it is normally generated by composing when symfony/mailer is installed on a Symfony project.

Using Symfony Mailer without declaring services, restricts the use of all Mailer possibilities.

adamps’s picture

I can't commit this unless it can be done without breaking things.

1. $email->getTransportDsn() ; must be used.

2.

it is normally generated by composing when symfony/mailer is installed on a Symfony project.

Yes but this is not a symfony project it's a Drupal module. It seems very strange that symfony mailer library expects some other code to register services for it. Who says this is the right thing to do? Why doesn't it do so itself?

There is a list of factories in Symfony\Component\Mailer\Transport::getDefaultFactories. We shouldn't duplicate that.

damien laguerre’s picture

There are some example of Symfony service define in the Drupal core.

Take a look at the file web/core/modules/serialization/serialization.services.yml
You will see some example of Symfony service declaration like:

services:
  serializer:
    class: Symfony\Component\Serializer\Serializer
    arguments: [{  }, {  }]

In Symfony, services are defined by the framework-bundle package.
But when we use a Symfony package, we have to define it.

The mailer config file from the framework-bundle package :
https://github.com/symfony/framework-bundle/blob/6.2/Resources/config/ma...
This file is not included in the Mailer Package itself.

adamps’s picture

Thanks for the explanation.

1. It's a good suggestion to make a service for Symfony\Component\Mailer\Mailer. However currently this class only accepts a transport at create time - there is no way to change this later. This module allows each email to have a different transport. Therefore this isn't going to work, and we need to stick to creating a instance of the Mailer object for each message sent.

You could raise a feature request for the library to add a way to specify a transport DSN per send request. This could be a new function or an extra parameter on the existing method.

The Symfony\Component\Mailer\Transport\Transports has the same problem - it requires an array of all possible transports at create time. This module cannot provide such a list, so we can't use this class as a service.

However Symfony\Component\Mailer\Transport doesn't have this problem. It can be created with a list of factories. So this class could work as a service, and we can call the fromDsnObject() function. However this is messy, it requires registration of the service in code to add the list of factories as an argument.

2. I stick to my belief that the details of all the transports don't belong in this module. Transports will get added, so it's chasing a moving target. Instead we need to call Transport::getDefaultFactories().

----

So putting everything together, I suggest the answer is like this.

1) Add code to the constructor of Mailer
- Copy the code currently in SymfonyMailerCompilerPass with comment "Retrieve all service transport factory tagged with mailer.transport_factory".
- Join this array with the result of calling Transport::getDefaultFactories().
- Create a Transport object using this combined array and store it in a protected class variable.

2) Instead of calling Transport::fromDsn() call $this->transport->fromDsnObject().

No other changes needed.

There are no new services because each of the library 3 classes that could be a service are unsuitable, as they require information at create time that isn't easily available. This could change if symfony library code changes.

damien laguerre’s picture

StatusFileSize
new6.29 KB

Thanks for the time you takes to analyze the problem and my code!

I have rewritten my patch with your suggestions.

The TransportFactoryManager class allows to register a new Factory in a service.yml with a tag.
I think it would be nice to keep the same registration system as describe in the Symfony Mailer documentation:

  mailer.transport_factory.custom:
    class: Drupal\custom_transport\Transport\CustomTransportFactory
    tags:
      - { name: mailer.transport_factory }
adamps’s picture

Thanks, that looks good.

I think it would be nice to keep the same registration system as describe in the Symfony Mailer documentation:

I agree.

I have some detailed comments. Please set the patch to needs review, check the tests pass and fix all coding standards.

1.

+  /**
+   * @var \Symfony\Component\Mailer\Transport
+   */
+  protected $transport;
+
+  /**
+   * @var \Drupal\symfony_mailer\TransportFactoryManager
+   */
+  protected $transportFactoryManager;
+

Comments need a description of the variable in addition to the @var.

2.

protected function getTransport() {

Do we actually need this function? Why not set $this->transport = in the constructor?

3. Let's put the call to Transport::getDefaultFactories() into SymfonyMailerCompilerPass. Instead of $transports = []; do $transports = Transport::getDefaultFactories(). Then the $factories constructor parameter contains all the factories rather than just some of them, and we only call getDefaultFactories() once.

4.

$container->register('symfony_mailer.transport_factory_manager', TransportFactoryManager::class);

Can we put this into symfony_mailer.services.yml as it's the more common place for people to look? Then we can have a comment to explain that an argument will be added.

5.

+class TransportFactoryManager {

Please implement an interface.

6.

+ private $factories;

We always use protected please. Also this needs a comment with a var statement.

7.

+   * @param TransportFactoryInterface[] $factories
+   */
+  public function __construct(iterable $factories)

Inconsistent types - I suggest TransportFactoryInterface[] is better, it is a stricter type. Also needs a description of the @param.

8.

+  /**
+   * Return all existing factories.
+   *
+   * @return \Generator
+   */

I prefer @return TransportFactoryInterface[], also the needs a description beneath the @return.

adamps’s picture

Sorry I made a mistake in #12.

Symfony\Component\Mailer\Transport doesn't have this problem. It can be created with a list of factories. So this class could work as a service, and we can call the fromDsnObject() function. However this is messy, it requires registration of the service in code to add the list of factories as an argument.

The "messy" code (SymfonyMailerCompilerPass) is necessary anyway to call $container->findTaggedServiceIds(). So I propose we should create a service for Transport instead of creating TransportFactoryManager. Apologies for the extra work.

damien laguerre’s picture

I made almost all the changes but I don't totally agree with everything.

4. Declaring the service in services.yml and completing it with a PassCompiler in another place is not ideal.

7. I had kept the same type as the package. I think it makes sense.

/**
* @author Fabien Potencier
*/
final class Transports implements TransportInterface
{
private $transports;
private $default;

/**
* @param TransportInterface[] $transports
*/
public function __construct(iterable $transports)

damien laguerre’s picture

Status: Needs work » Needs review
adamps’s picture

Great thanks. I have updated the MR with two changes:

  1. For performance, Mailer should create the Transport object once during the constructor, not once per mail sent.
  2. I discovered that in Drupal the service_collector tag corresponds to tagged_iterator in Symfony. Use this instead of creating a compiler pass.

Please can you review and check it works in your system?

damien laguerre’s picture

Great!

2. I discovered that in Drupal the service_collector tag corresponds to tagged_iterator in Symfony. Use this instead of creating a compiler pass.

I first tried this way, but it could not be used with all the Symfony services I defined the first time.
Now it's really the best solution.

I did some tests and everything works fine!

adamps’s picture

Status: Needs review » Fixed

Great thanks

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.