odan / slim4-tutorial

Slim 4 Tutorial - Source Code
98 stars 25 forks source link

How can I test emails ? #36

Closed samuelgfeller closed 3 years ago

samuelgfeller commented 3 years ago

I'm using PHPMailer and the best (and only free / open source) way I found on how to test emails was this article from codeception written in 2013 which uses MailCatcher.

Although I like the functionality, I don't feel too well having this additional dependency on the testing environment which also needs ruby and gem installed.

I guess the best way I can imagine would be, if I could tell PHPMailer in the testing env configuration to write emails to a specific file, so I could assert its content later instead of really sending them out. The only thing I found does not work for me, I think because I'm using smtp and not sendmail right?

Curiously, I hardly found anything on this topic on the internet, and I'm unsure why. Probably I'm just searching wrongly I guess.

odan commented 3 years ago

With all due respect, but PHPMailer is a pretty outdated package. It was written at a time when TDD was not as popular as it is today. If you still want to use PHPMailer, then you may wrap it behind a custom MailerInterface with a public send method. Then you could replace that mailer with a NullMailer or so.

Better would be to use the Symfony Mailer component, because you can replace the Transport class with a NullTransport adapter for testing purposes.

The Laminas Mailer offers the same concept for testing.

https://odan.github.io/2020/04/11/slim4-sending-emails.html

samuelgfeller commented 3 years ago

Interesting, I thought that it was still modern as there are many recent commits.

Between Symfony mailer and Laminas mailer, is there one you recommend over the other or are they very similar and similarly well maintained?

P.S. The answer to this question would be a valuable addition in your blog article in my opinion as well as maybe a note on PHPMailer

odan commented 3 years ago

Symfony mailer and Laminas mailer are very good and well maintained, but the Symfony mailer has a bigger community. The Symfony mailer provides Twig integration and some more Transport / Auth possibilities, while the Laminas mailer feels more lightweight to me.

samuelgfeller commented 3 years ago

Alright, thank you.

samuelgfeller commented 3 years ago

I have now installed symfony mailer and added the null transport to env.testing.php. How do I test the emails from here?

Awesome would be to have these assertions. I grabbed the MailerAssertionsTrait.php from the Symfony repo which I added to my test traits. Important there though is the function getMessageMailerEvents() which returns the event with which RawMessage can be extracted and later asserted.
The function seems to take it from some kind of logger as it attempts to get 'mailer.message_logger_listener' from the Symfony container $container->get('mailer.message_logger_listener').
I don't have a Symfony container though and and if I try to take those strings there's an error saying that there is no entry in my container.

Do I have to make my own NullTransport which extends AbstractTransport and writes the content I want to assert in a logfile in the function doSend? Because the original NullTransport does not do anything in its doSend function. That would be pretty inpractical as I would have to manually write the content I want to assert in a way I can extract them easily for the assertion later.
Better would be if there is a way I can access the RawMessage object and use the Symfony assert functions with it.

odan commented 3 years ago

I never tested it so far, because for me, it doesn't matter how many emails has been sent, because I assume that the prod environment is properly configured for SMTP. So the NullTransport was always good enough for me.

For testing you could configure null as adapter, for example: null://user:pass@smtp.example.com:25

The NullTransport extends from AbstractTransport. So the EventDispatcherInterface should also work because the send method triggers the MessageEvent to the dispatcher.

Symfony specific container keys like mailer.message_logger_listener will not work. In your case, you have to add a DI container definition for Symfony\Component\Mailer\Transport\TransportInterface::class instead and use that interface within your MailerInterface::class DI container definition, e.g. $container->get(TransportInterface::class);

samuelgfeller commented 3 years ago

I never tested it so far, because for me, it doesn't matter how many emails has been sent, because I assume that the prod environment is properly configured for SMTP. So the NullTransport was always good enough for me.

I'm not specifically interested in the amount of emails being sent but more their content, subject and headers. All assertions in MailerAssertionsTrait work with a RawMessage object and the way to grab that object is to use the $this->getMailerMessage(0) function which takes it from the event.

you have to add a DI container definition for Symfony\Component\Mailer\Transport\TransportInterface::class instead and use that interface within your MailerInterface::class DI container definition, e.g. $container->get(TransportInterface::class);

I have to admit, I don't think that I fully understood that part. My container looks like this now

// SMTP transport
MailerInterface::class => function (ContainerInterface $container) {
    return new Mailer($container->get(TransportInterface::class));
},
// Mailer transport interface
TransportInterface::class  => function (ContainerInterface $container) {
    $settings = $container->get('settings')['smtp'];

    // smtp://user:pass@smtp.example.com:25
    $dsn =
        sprintf(
            '%s://%s:%s@%s:%s',
            $settings['type'],
            $settings['username'],
            $settings['password'],
            $settings['host'],
            $settings['port']
        );
    return Transport::fromDsn($dsn);
},

Is that correct?

Now when trying to access that RawMessage object it fails

/** @var ?RawMessage $email */
$email = $this->container->get(TransportInterface::class)->getEvents()->getMessages(null);
// Error : Call to undefined method Symfony\Component\Mailer\Transport\NullTransport::getEvents()

For context, here are the Symfony functions:

public static function getMailerMessages(string $transport = null): array
{
    return self::getMessageMailerEvents()->getMessages($transport);
}
public static function getMailerMessage(int $index = 0, string $transport = null): ?RawMessage
{
    return self::getMailerMessages($transport)[$index] ?? null;
}
private static function getMessageMailerEvents(): MessageEvents
{
    $container = static::getContainer();
    if ($container->has('mailer.message_logger_listener')) {
        return $container->get('mailer.message_logger_listener')->getEvents();
    }
    if ($container->has('mailer.logger_message_listener')) {
        return $container->get('mailer.logger_message_listener')->getEvents();
    }
    static::fail('A client must have Mailer enabled to make email assertions. Did you forget to require symfony/mailer?');
}
odan commented 3 years ago

The DI container definition looks quite correct. Then also add/change and this definition/s:

use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Mailer\EventListener\EnvelopeListener;
use Symfony\Component\Mailer\EventListener\MessageListener;
use Symfony\Component\Mailer\EventListener\MessageLoggerListener;
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mailer\Transport;
use Symfony\Component\Mailer\Transport\TransportInterface;
// ...

// Mailer
MailerInterface::class => function (ContainerInterface $container) {
    return new Mailer($container->get(TransportInterface::class));
},

// Mailer transport
TransportInterface::class => function (ContainerInterface $container) {
    $settings = $container->get('settings')['smtp'];

    // smtp://user:pass@smtp.example.com:25
    $dsn = sprintf(
        '%s://%s:%s@%s:%s',
        $settings['type'],
        $settings['username'],
        $settings['password'],
        $settings['host'],
        $settings['port']
    );

    $eventDispatcher = $container->get(EventDispatcherInterface::class);

    return Transport::fromDsn($dsn, $eventDispatcher);
},

EventDispatcherInterface::class => function () {
    $eventDispatcher = new EventDispatcher();
    $eventDispatcher->addSubscriber(new MessageListener());
    $eventDispatcher->addSubscriber(new EnvelopeListener());
    $eventDispatcher->addSubscriber(new MessageLoggerListener());

    return $eventDispatcher;
},

// Error : Call to undefined method Symfony\Component\Mailer\Transport\NullTransport::getEvents()

Makes sense because there is no getEvents method.

Now when trying to access that RawMessage object it fails

I would try to refactor the getMessageMailerEvents method like this to get the events:

use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Mailer\EventListener\MessageLoggerListener;
use Symfony\Component\Mailer\Event\MessageEvents;
use RuntimeException;
// ...

private function getMessageMailerEvents(): MessageEvents
{
    $dispatcher = $this->container->get(EventDispatcherInterface::class)

    /** @var EventSubscriberInterface[] $listeners */
    foreach ($dispatcher->getListeners() as $listeners) {
        foreach ($listeners as $listener) {
            $listenerInstance = $listener[0];

            if (!$listenerInstance instanceof MessageLoggerListener) {
                continue;
            }

            return $listenerInstance->getEvents();
        }
    }

    throw new RuntimeException('The Mailer event dispatcher must be enabled to make email assertions.');
}
samuelgfeller commented 3 years ago

The DI container definition looks quite correct.

Yay!

I would try to refactor the getMessageMailerEvents method like this to get the events:

This is amazing! A thousand thanks for this implementation.
I would definitely have struggled a lot especially with those event subscribers I don't think that I would have been able to find that out, even with hours of trying.
I admire your ability a lot and hope to get to this point once.