Skip to content

Instantly share code, notes, and snippets.

@thibaut-decherit
Last active August 23, 2023 16:22
Show Gist options
  • Save thibaut-decherit/eddf6194a5a43239f7b50ae2afd4bb69 to your computer and use it in GitHub Desktop.
Save thibaut-decherit/eddf6194a5a43239f7b50ae2afd4bb69 to your computer and use it in GitHub Desktop.
Symfony - MailerService

Symfony - MailerService

Dependencies:

  • Mailer events (Symfony)

app/config/services.yml

parameters:
  app.website_name: '%env(WEBSITE_NAME)%'
  app.website_url: '%env(WEBSITE_URL)%'

services:
  App\Service\MailerService:
    arguments:
      $fromAddress: '%env(MAILER_FROM)%'
      $fromName: '%app.website_name%'
      $replyToAddress: '%env(MAILER_REPLY_TO)%'
      $websiteUrl: '%app.website_url%'
      $kernelEnvironment: '%kernel.environment%'
    lazy: true

AppBundle/Service/MailerService.php

<?php

namespace App\Service;

use App\Event\Mailer\AttachmentFailedEvent;
use App\Event\Mailer\EmailSendingFailedEvent;
use App\Helper\SanitizationHelper;
use App\Model\AbstractUser;
use Egulias\EmailValidator\EmailValidator;
use Egulias\EmailValidator\Validation\MessageIDValidation;
use Egulias\EmailValidator\Validation\RFCValidation;
use InvalidArgumentException;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use Twig\Environment as Twig;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;

class MailerService
{
    private string $fromAddress;

    private string $fromName;

    private string $replyToAddress;

    private string $kernelEnvironment;

    private Twig $twig;

    private MailerInterface $mailer;

    private TranslatorInterface $translator;

    private RouterInterface $router;

    private EmailValidator $emailValidator;

    private EventDispatcherInterface $eventDispatcher;

    public function __construct(
        string $fromAddress,
        string $fromName,
        string $replyToAddress,
        string $websiteUrl,
        string $kernelEnvironment,
        Twig $twig,
        MailerInterface $mailer,
        TranslatorInterface $translator,
        RouterInterface $router,
        EventDispatcherInterface $eventDispatcher
    )
    {
        $this->fromAddress = $fromAddress;
        $this->fromName = $fromName;
        $this->replyToAddress = $replyToAddress;
        $this->kernelEnvironment = $kernelEnvironment;
        $this->twig = $twig;
        $this->mailer = $mailer;
        $this->translator = $translator;
        $this->router = $router;
        $this->emailValidator = new EmailValidator();
        $this->eventDispatcher = $eventDispatcher;

        $urlComponents = parse_url($websiteUrl);

        if (!isset($urlComponents['scheme'])) {
            $urlComponents['scheme'] = $kernelEnvironment === 'prod' ? 'https' : 'http';
        }

        if (!isset($urlComponents['port'])) {
            $urlComponents['port'] = $urlComponents['scheme'] === 'https' ? 443 : 80;
        }

        /*
         * Ensures URLs generated by $this->router begin with the correct scheme and host.
         * If the email is sent through a command (e.g. cron job) you MUST use this router instead of the one used
         * internally by Twig to generate URLs included in the email template or the scheme and host will be wrong and
         * break the generated URLs.
         */
        $routerContext = $this->router->getContext();
        $routerContext
            ->setScheme($urlComponents['scheme'])
            ->setHost($urlComponents['host']);

        /**
         * Ensures URLs generated by $this->router have the correct port if email is sent by a command while in dev env
         * or if port is not the scheme's default one.
         */
        if (
            $kernelEnvironment === 'dev'
            || ($urlComponents['scheme'] === 'http' && $urlComponents['port'] !== 80)
            || ($urlComponents['scheme'] === 'https' && $urlComponents['port'] !== 443)
        ) {
            $routerContext
                ->setHttpPort($urlComponents['port'])
                ->setHttpsPort($urlComponents['port']);
        }
    }

    /**
     * Email sent when user requests account deletion.
     *
     * @param AbstractUser $user
     * @param int $accountDeletionTokenLifetimeInMinutes
     * @param string $locale
     * @throws LoaderError
     * @throws RuntimeError
     * @throws SyntaxError
     */
    public function accountDeletionRequest(
        AbstractUser $user,
        int $accountDeletionTokenLifetimeInMinutes,
        string $locale
    ): void
    {
        $emailBody = $this->twig->render(
            'email/' . $locale . '/user/account_deletion_request.html.twig', [
                'user' => $user,
                'account_deletion_token_lifetime_in_minutes' => $accountDeletionTokenLifetimeInMinutes
            ]
        );

        $this->sendEmail(
            $user->getEmail(),
            $this->translator->trans('mailer.subjects.account_deletion_request'),
            $emailBody
        );
    }

    /**
     * Email sent when user confirms account deletion.
     *
     * @param AbstractUser $user
     * @param string $locale
     * @throws LoaderError
     * @throws RuntimeError
     * @throws SyntaxError
     */
    public function accountDeletionSuccess(AbstractUser $user, string $locale): void
    {
        $emailBody = $this->twig->render(
            'email/' . $locale . '/user/account_deletion_success.html.twig', [
                'user' => $user,
            ]
        );

        $this->sendEmail(
            $user->getEmail(),
            $this->translator->trans('mailer.subjects.account_deletion_success'),
            $emailBody
        );
    }

    /**
     * Email sent when user requests email address change.
     *
     * @param AbstractUser $user
     * @param int $emailChangeTokenLifetimeInMinutes
     * @param string $locale
     * @throws LoaderError
     * @throws RuntimeError
     * @throws SyntaxError
     */
    public function emailChange(AbstractUser $user, int $emailChangeTokenLifetimeInMinutes, string $locale): void
    {
        $emailBody = $this->twig->render(
            "email/$locale/user/email_address_change.html.twig", [
                'user' => $user,
                'email_change_token_lifetime_in_minutes' => $emailChangeTokenLifetimeInMinutes
            ]
        );

        $this->sendEmail(
            $user->getEmailChangePending(),
            $this->translator->trans('mailer.subjects.email_address_change'),
            $emailBody
        );
    }

    /**
     * @param AbstractUser $user
     * @param string $locale
     * @throws LoaderError
     * @throws RuntimeError
     * @throws SyntaxError
     */
    public function loginAttemptOnNonActivatedAccount(AbstractUser $user, string $locale): void
    {
        $emailBody = $this->twig->render(
            'email/' . $locale . '/user/login_attempt_on_unactivated_account.html.twig', [
                'user' => $user
            ]
        );

        $this->sendEmail(
            $user->getEmail(),
            $this->translator->trans('mailer.subjects.login_attempt'),
            $emailBody
        );
    }

    /**
     * Email sent when user requests password reset.
     *
     * @param AbstractUser $user
     * @param int $passwordResetTokenLifetimeInMinutes
     * @param string $locale
     * @throws LoaderError
     * @throws RuntimeError
     * @throws SyntaxError
     */
    public function passwordResetRequest(AbstractUser $user, int $passwordResetTokenLifetimeInMinutes, string $locale): void
    {
        $emailBody = $this->twig->render(
            'email/' . $locale . '/user/password_reset_request.html.twig', [
                'user' => $user,
                'password_reset_token_lifetime_in_minutes' => $passwordResetTokenLifetimeInMinutes
            ]
        );

        $this->sendEmail(
            $user->getEmail(),
            $this->translator->trans('mailer.subjects.password_reset'),
            $emailBody
        );
    }

    /**
     * @param AbstractUser $user
     * @param string $locale
     * @throws LoaderError
     * @throws RuntimeError
     * @throws SyntaxError
     */
    public function registrationAttemptOnExistingVerifiedEmailAddress(AbstractUser $user, string $locale): void
    {
        $emailBody = $this->twig->render(
            "email/$locale/user/registration_attempt_on_existing_verified_email_address.html.twig", [
                'user' => $user
            ]
        );

        $this->sendEmail(
            $user->getEmail(),
            $this->translator->trans('mailer.subjects.registration_attempt'),
            $emailBody
        );
    }

    /**
     * @param AbstractUser $user
     * @param string $locale
     * @throws LoaderError
     * @throws RuntimeError
     * @throws SyntaxError
     */
    public function registrationAttemptOnExistingUnverifiedEmailAddress(AbstractUser $user, string $locale): void
    {
        $emailBody = $this->twig->render(
            "email/$locale/user/registration_attempt_on_existing_unverified_email_address.html.twig", [
                'user' => $user
            ]
        );

        $this->sendEmail(
            $user->getEmail(),
            $this->translator->trans('mailer.subjects.registration_attempt'),
            $emailBody
        );
    }

    /**
     * Email sent after user registration, it contains an activation link.
     *
     * @param AbstractUser $user
     * @param string $locale
     * @throws LoaderError
     * @throws RuntimeError
     * @throws SyntaxError
     */
    public function registrationSuccess(AbstractUser $user, string $locale): void
    {
        $emailBody = $this->twig->render(
            'email/' . $locale . '/user/registration_success.html.twig', [
                'user' => $user
            ]
        );

        $this->sendEmail(
            $user->getEmail(),
            $this->translator->trans('mailer.subjects.welcome'),
            $emailBody
        );
    }

    private function isEmailAddressValid(?string $emailAddress): bool
    {
        return $this->emailValidator->isValid(
            $emailAddress,
            class_exists(MessageIDValidation::class) ? new MessageIDValidation() : new RFCValidation()
        );
    }

    /**
     * @param string|null $toAddress
     * @param string $subject
     * @param string $body
     *
     * Each entry must either be:
     *  - the file path as a string, in that case the filename will be automatically extracted from that path
     *  - an associative array containing:
     *      - 'path_or_raw_content': (string, mandatory), the file path or raw content
     *      - 'raw_content': (boolean, optional), if specified and true 'path_or_raw_content' will be considered as the
     *      raw content of the file and 'name' is mandatory (because there is no file path to extract the filename from)
     *      - 'name': (string, optional if 'raw_content' is not specified or false, mandatory if 'raw_content' is
     *      specified and true), the filename with the extension if it has one. If that optional entry is not
     *      specified and 'raw_content' is false or unspecified the filename will be extracted from the
     *      'path_or_raw_content' entry.
     *      Warning: if 'raw_content' is true 'name' must be specified or the file will be skipped.
     * @param array $attachments
     *
     * @param string|null $fromAddress
     * @param string|null $fromName
     * @param string|null $replyToAddress
     */
    private function sendEmail(
        ?string $toAddress,
        string $subject,
        string $body,
        array $attachments = [],
        ?string $fromAddress = null,
        ?string $fromName = null,
        ?string $replyToAddress = null
    ): void
    {
        if (is_null($fromAddress)) {
            $fromAddress = $this->fromAddress;
        }

        if (is_null($fromName)) {
            $fromName = $this->fromName;
        }

        if (is_null($replyToAddress)) {
            $replyToAddress = $this->replyToAddress;
        }

        if (!$this->isEmailAddressValid($toAddress)) {
            $this->eventDispatcher->dispatch(
                new EmailSendingFailedEvent(
                    '$toAddress is invalid.',
                    $fromAddress,
                    $fromName,
                    $replyToAddress,
                    $toAddress,
                    $subject
                ),
                EmailSendingFailedEvent::NAME
            );

            return;
        }

        if (!$this->isEmailAddressValid($fromAddress)) {
            $this->eventDispatcher->dispatch(
                new EmailSendingFailedEvent(
                    '$fromAddress is invalid.',
                    $fromAddress,
                    $fromName,
                    $replyToAddress,
                    $toAddress,
                    $subject
                ),
                EmailSendingFailedEvent::NAME
            );

            return;
        }

        if ($replyToAddress !== '' && !$this->isEmailAddressValid($replyToAddress)) {
            $this->eventDispatcher->dispatch(
                new EmailSendingFailedEvent(
                    '$replyToAddress is invalid.',
                    $fromAddress,
                    $fromName,
                    $replyToAddress,
                    $toAddress,
                    $subject
                ),
                EmailSendingFailedEvent::NAME
            );

            return;
        }

        $email = new Email();

        $email
            ->subject($subject)
            ->from(new Address($fromAddress, $fromName))
            ->to($toAddress)
            ->html($body);

        if (!empty($replyToAddress)) {
            $email->replyTo($replyToAddress);
        }

        /*
         * continue statements are used instead of throwing an exception to prevent crashing the whole mailing process,
         * e.g. if multiple emails are sent in a loop.
         */
        foreach ($attachments as $attachment) {
            if (
                (!is_string($attachment) || $attachment === '')
                && (!is_array($attachment) || $attachment === [])
            ) {
                $this->eventDispatcher->dispatch(
                    new AttachmentFailedEvent(
                        'Attachment skipped, $attachment must be of type array or a string and not empty.',
                        $fromAddress,
                        $fromName,
                        $replyToAddress,
                        $toAddress,
                        $subject
                    ),
                    AttachmentFailedEvent::NAME
                );

                continue;
            }

            $extractFilenameFromFilePath = false;
            $isRawContent = false;
            $filePath = null;
            $filename = '';
            if (is_string($attachment)) {
                $filePath = $attachment;
                $extractFilenameFromFilePath = true;
            } elseif (is_array($attachment) && isset($attachment['path_or_raw_content'])) {
                if (!isset($attachment['raw_content']) || $attachment['raw_content'] !== true) {
                    $filePath = $attachment['path_or_raw_content'];
                } else {
                    $isRawContent = true;
                }

                if (array_key_exists('name', $attachment) && $attachment['name'] !== '') {
                    $filename = $attachment['name'];
                } else {
                    $extractFilenameFromFilePath = !$isRawContent;
                }
            } else {
                $this->eventDispatcher->dispatch(
                    new AttachmentFailedEvent(
                        'Attachment skipped, $attachment is an array but does not contain the mandatory \'path_or_raw_content\' key.',
                        $fromAddress,
                        $fromName,
                        $replyToAddress,
                        $toAddress,
                        $subject
                    ),
                    AttachmentFailedEvent::NAME
                );

                continue;
            }

            if ($extractFilenameFromFilePath) {
                $filename = SanitizationHelper::filename(
                    pathinfo($filePath, PATHINFO_FILENAME) . '.' . pathinfo($filePath, PATHINFO_EXTENSION)
                );
            }

            if ($filename === '') {
                $this->eventDispatcher->dispatch(
                    new AttachmentFailedEvent(
                        'Attachment skipped, something went wrong: $filename should not be an empty string at this point.',
                        $fromAddress,
                        $fromName,
                        $replyToAddress,
                        $toAddress,
                        $subject,
                        $filename
                    ),
                    AttachmentFailedEvent::NAME
                );

                continue;
            }

            if (isset($attachment['raw_content']) && $attachment['raw_content'] === true) {
                $email->attach($attachment['path_or_raw_content'], $filename);
            } else {
                if (!file_exists($filePath)) {
                    $this->eventDispatcher->dispatch(
                        new AttachmentFailedEvent(
                            'Attachment skipped, file not found on disk.',
                            $fromAddress,
                            $fromName,
                            $replyToAddress,
                            $toAddress,
                            $subject,
                            $filename,
                            $filePath
                        ),
                        AttachmentFailedEvent::NAME
                    );
                    continue;
                }

                $email->attachFromPath($filePath, $filename);
            }
        }

        try {
            $this->mailer->send($email);
        } catch (InvalidArgumentException|TransportExceptionInterface $exception) {
            $this->eventDispatcher->dispatch(
                new EmailSendingFailedEvent(
                    $exception->getMessage(),
                    $fromAddress,
                    $fromName,
                    $replyToAddress,
                    $toAddress,
                    $subject
                ),
                EmailSendingFailedEvent::NAME
            );
        }
    }
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment