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
);
}
}
}