Skip to content

Mailing

samuelgfeller edited this page Apr 15, 2024 · 11 revisions

Introduction

Mailing is the main way the application can communicate with users outside its own interface. It is used to send information or sensitive data such as password reset or email confirmation links after registration.

Choosing the library

There are many PHP libraries for sending emails. Some of the most popular ones are:

The PHPMailer library is probably the most widely used one and still well maintained with a large community.
It has been around for a very long time and may not be as optimized for performance as newer, more modern libraries such as Symfony Mailer.
Symfony Mailer replaced its predecessor SwiftMailer and is only a few years old. It is performant, modern has a large community and comes with lots of built-in mailer assertions which makes it a good choice.

Laminas mail feels a bit more lightweight, which is great, but it is not as popular as the other two.
It seems to require a bit more configuration and knowledge about transport and mailing.

When choosing a mailer, I wanted one that is intuitive and just works without asking too much.
This is why symfony/mailer is the choice for the slim-example-project.

Symfony Mailer

Configuration

Mailers can use different "transports" which are methods or protocols used to send emails.
The most common one is SMTP.

File: config/defaults.php

$settings['smtp'] = [
    // use type 'null' for the null adapter
    'type' => 'smtp',
    'host' => 'smtp.mailtrap.io',
    'port' => '587', // TLS: 587; SSL: 465
    'username' => 'my-username',
    'password' => 'my-secret-password',
];

The real host, secret username and password are stored in the environment-specific file config/env/env.php:

$settings['smtp']['host'] = 'smtp.host.com';
$settings['smtp']['username'] = 'username';
$settings['smtp']['password'] = 'password';

Setup

The mailer has to be instantiated with the configuration in the DI container.
The EventDispatcher is also added to the container and passed to the mailer. This allows asserting email content for testing.

File: config/container.php

use Psr\Container\ContainerInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\Mailer\EventListener\EnvelopeListener;
use Symfony\Component\Mailer\EventListener\MessageListener;
use Symfony\Component\Mailer\EventListener\MessageLoggerListener;

return [
    // ...
    
    MailerInterface::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 new Mailer(Transport::fromDsn($dsn, $eventDispatcher));
    },
    // Event dispatcher for mailer. Required to retrieve emails when testing.
    EventDispatcherInterface::class => function () {
        $eventDispatcher = new EventDispatcher();
        $eventDispatcher->addSubscriber(new MessageListener());
        $eventDispatcher->addSubscriber(new EnvelopeListener());
        $eventDispatcher->addSubscriber(new MessageLoggerListener());

        return $eventDispatcher;
    },
];

Creating and sending emails

To create a new email, an instance of the class Symfony\Component\Mime\Email has to be created. It is a data object that holds the email content and metadata as well as methods for setting and getting these properties.

The Email body can be plain text or HTML. The PHP template renderer can be used to render the HTML for the email body from a template.

To get the HTML from a template and to send the email, the App\Infrastructure\Service\Mailer helper service class can be used.

Example

use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
use App\Infrastructure\Service\Mailer;

// Create email object
$email = new Email();
// Set sender and reply-to address
$email->from(new Address('sender@email.com', 'Sender Name'))
->replyTo(new Address('reply-to@email.com', 'Reply To Name'));

// Set subject
$email->subject('Subject');

// Get body HTML from template password-reset.email.php
$body = $this->mailer->getContentFromTemplate(
    'authentication/email/password-reset.email.php',
    ['user' => $userData, 'queryParams' => $queryParamsWithToken]
);
// Set body
$email->html($body);

// Add recipients and priority
$email->to(new Address($userData->email, $userData->getFullName()))
->priority(Email::PRIORITY_HIGH);

// Send email
$this->mailer->send($email);

Mailer helper service

This mailer helper provides the send() function which can send an email using the Symfony mailer and logs the email request.
The logging is part of a security feature that limits the number of emails a user can send within a certain time frame.

The Mailer also contains a function to get the rendered HTML from a template.

File: src/Infrastructure/Service/Mailer.php

namespace App\Infrastructure\Service;

use App\Application\Data\UserNetworkSessionData;
use App\Domain\Security\Repository\EmailLoggerRepository;
use Slim\Views\PhpRenderer;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;

// Test sender score: https://www.mail-tester.com/
final readonly class Mailer
{
    private ?int $loggedInUserId;

    public function __construct(
        private MailerInterface $mailer,
        private PhpRenderer $phpRenderer,
        private EmailLoggerRepository $emailLoggerRepository,
        private UserNetworkSessionData $userNetworkSessionData
    ) {
        $this->loggedInUserId = $this->userNetworkSessionData->userId ?? null;
    }

    /**
     * Returns rendered HTML of given template path.
     * Using PHP-View template parser allows access to the attributes from PhpViewExtensionMiddleware
     * like uri and route.
     *
     * @param string $templatePath PHP-View path relative to template path defined in config
     * @param array $templateData ['varName' => 'data', 'otherVarName' => 'otherData', ]
     * @return string html email content
     */
    public function getContentFromTemplate(string $templatePath, array $templateData): string
    {
        // Prepare and fetch template
        $this->phpRenderer->setLayout(''); // Making sure there is no default layout
        foreach ($templateData as $key => $data) {
            $this->phpRenderer->addAttribute($key, $data);
        }

        return $this->phpRenderer->fetch($templatePath);
    }

    /**
    * Send and log email 
    *
    * @param Email $email
    * @return void
     */
    public function send(Email $email): void
    {
        $this->mailer->send($email);
        $cc = $email->getCc();
        $bcc = $email->getBcc();

        // Log email request
        $this->emailLoggerRepository->logEmailRequest(
            $email->getFrom()[0]->getAddress(),
            $email->getTo()[0]->getAddress(),
            $email->getSubject() ?? '',
            $this->loggedInUserId
        );
    }
}

Error handling

The MailerInterface's send() function throws a TransportExceptionInterface if the email could not be sent.
If this error should be caught, it can be done so by catching the TransportExceptionInterface in the Action class or service that sends the email.

try {
    // Send email
    $this->mailer->send($email);
} catch (TransportExceptionInterface $transportException) {
    // Handle error
}

Testing

Configuration

To prevent the mailer from sending emails during testing, the smtp adapter has to be changed to null in the testing environment config file.

File: config/env.test.php

// ... 

// Using the null adapter to prevent emails from actually being sent
$settings['smtp']['type'] = 'null';

Assertions

The Symfony Framework provides a set of mailer assertions, but they're not available outside the framework.

The samuelgfeller/test-traits MailerTestTrait provides the same assertions for the Symfony Mailer.

After adding the library as dev dependency, the MailerTestTrait can be used in the test class.

The full list of available assertions can be found in the vendor/samuelgfeller/test-traits/src/Trait/MailerTestTrait.php file.

File: tests/Integration/Authentication/PasswordForgottenEmailSubmitActionTest.php

namespace App\Test\Integration\Authentication;

use PHPUnit\Framework\TestCase;
use TestTraits\Trait\MailerTestTrait;

class PasswordForgottenEmailSubmitActionTest extends TestCase
{
    // ...
    use MailerTestTrait;    
    
    public function testPasswordForgottenEmailSubmit(): void
    {
        // ... Request being sent ...
        
        // Assert that email was sent
        $this->assertEmailCount(1);
        // Get email RawMessage
        $mailerMessage = $this->getMailerMessage();
        // Assert email content
        $this->assertEmailHtmlBodyContains(
            $mailerMessage,
            'If you recently requested to reset your password, click the link below to do so.'
        );
        // Assert email recipient
        $this->assertEmailHeaderSame(
            $mailerMessage, 'To', $userRow['first_name'] . ' ' . $userRow['surname'] . ' <' . $email . '>'
        );
        
        // ...
    }
Clone this wiki locally