Skip to content

Mailing

Samuel Gfeller edited this page Dec 12, 2023 · 12 revisions

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 this project.

Symfony Mailer

Configuration

Mailers can use different "transports" which are methods or protocols used to send emails.
This project uses the most common one "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 object of the class Email has to be instantiated. It is a data object that holds the email's 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 Mailer helper service class is 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 is meant to offer functionalities from the Symfony Mailer such as the send() function but extending it with additional functionalities such as logging the email request and 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/
class Mailer
{
    private ?int $loggedInUserId;

    public function __construct(
        private readonly MailerInterface $mailer,
        private readonly PhpRenderer $phpRenderer,
        private readonly EmailLoggerRepository $emailLoggerRepository,
        private readonly 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.

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

Testing

The mailer assertions Symfony Mailer provides are made accessible by the selective/test-traits library.

The MailerTestTrait has to be included to the test class to be able to access the assertions.

File: tests/Integration/Authentication/PasswordForgottenEmailSubmitActionTest.php

namespace App\Test\Integration\Authentication;

use PHPUnit\Framework\TestCase;
use Selective\TestTrait\Traits\MailerTestTrait;

class PasswordForgottenEmailSubmitActionTest extends TestCase
{
    // ...
    use MailerTestTrait;    
    
    public function testPasswordForgottenEmailSubmit(): void
    {
        // ...
        
        // Assert 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