diff --git a/_config/email.yml b/_config/email.yml
index 8842d39d29e..827560ab990 100644
--- a/_config/email.yml
+++ b/_config/email.yml
@@ -1,12 +1,55 @@
---
-Name: emailconfig
+Name: mailer
---
SilverStripe\Core\Injector\Injector:
- Swift_Transport: Swift_MailTransport
- Swift_Mailer:
- constructor:
- - '%$Swift_Transport'
- SilverStripe\Control\Email\Mailer:
- class: SilverStripe\Control\Email\SwiftMailer
- properties:
- SwiftMailer: '%$Swift_Mailer'
+ Symfony\Component\Mailer\MailerInterface:
+ class: Symfony\Component\Mailer\Mailer
+ constructor:
+ 0: '%$Symfony\Component\Mailer\Transport\TransportInterface'
+ 1: '%$Symfony\Component\Messenger\MessageBusInterface.mailer'
+ 2: '%$Symfony\Component\EventDispatcher\EventDispatcherInterface.mailer'
+ Symfony\Component\Messenger\MessageBusInterface.mailer:
+ class: Symfony\Component\Messenger\MessageBus
+ constructor:
+ 0:
+ - '%$Symfony\Component\Messenger\Middleware\MiddlewareInterface.mailer'
+ Symfony\Component\Messenger\Middleware\MiddlewareInterface.mailer:
+ class: Symfony\Component\Messenger\Middleware\SendMessageMiddleware
+ constructor:
+ 0: '%$Symfony\Component\Messenger\Transport\Sender\SendersLocatorInterface.mailer'
+ Symfony\Component\Messenger\Transport\Sender\SendersLocatorInterface.mailer:
+ class: Symfony\Component\Messenger\Transport\Sender\SendersLocator
+ constructor:
+ 0:
+ '*':
+ - 'Symfony\Component\Messenger\Transport\Sender\SenderInterface.mailer'
+ 1: '%$Symfony\Component\DependencyInjection\ContainerInterface.mailer'
+ Symfony\Component\Messenger\Transport\Sender\SenderInterface.mailer:
+ class: SilverStripe\Control\Email\EmailSender
+ Symfony\Component\DependencyInjection\ContainerInterface.mailer:
+ class: Symfony\Component\DependencyInjection\Container
+ calls:
+ - ['set', ['Symfony\Component\Messenger\Transport\Sender\SenderInterface.mailer', '%$Symfony\Component\Messenger\Transport\Sender\SenderInterface.mailer']]
+ Symfony\Component\EventDispatcher\EventDispatcherInterface.mailer:
+ class: Symfony\Component\EventDispatcher\EventDispatcher
+---
+Name: mailer-dsn-default-config
+---
+SilverStripe\Core\Injector\Injector:
+ Symfony\Component\Mailer\Transport\TransportInterface:
+ factory: Symfony\Component\Mailer\Transport
+ factory_method: fromDsn
+ constructor:
+ # TODO: confirm if this should be sendmail://default or native://default
+ # https://symfony.com/doc/current/mailer.html#using-built-in-transports
+ dsn: 'sendmail://default'
+---
+Name: mailer-dsn-env
+After: mailer-dsn-default-config
+Only:
+ envvarset: MAILER_DSN
+---
+SilverStripe\Core\Injector\Injector:
+ Symfony\Component\Mailer\Transport\TransportInterface:
+ constructor:
+ dsn: '`MAILER_DSN`'
diff --git a/composer.json b/composer.json
index 1cd80f8cd1d..4cc5dddaa55 100644
--- a/composer.json
+++ b/composer.json
@@ -40,7 +40,11 @@
"swiftmailer/swiftmailer": "^6.3.0",
"symfony/cache": "^6.1",
"symfony/config": "^6.1",
+ "symfony/dependency-injection": "^6.1",
"symfony/filesystem": "^6.1",
+ "symfony/mailer": "^6.1",
+ "symfony/messenger": "^6.1",
+ "symfony/mime": "^6.1",
"symfony/translation": "^6.1",
"symfony/yaml": "^6.1",
"ext-ctype": "*",
diff --git a/src/Control/Email/Email.php b/src/Control/Email/Email.php
index b2fcf07fa28..40c32c6ac38 100644
--- a/src/Control/Email/Email.php
+++ b/src/Control/Email/Email.php
@@ -2,175 +2,133 @@
namespace SilverStripe\Control\Email;
-use DateTime;
-use RuntimeException;
+use Symfony\Component\Mime\Email as SymfonyEmail;
+
use Egulias\EmailValidator\EmailValidator;
use Egulias\EmailValidator\Validation\RFCValidation;
+use RuntimeException;
use SilverStripe\Control\Director;
-use SilverStripe\Control\HTTP;
-use SilverStripe\Core\Convert;
+use SilverStripe\Core\Config\Configurable;
use SilverStripe\Core\Environment;
+use SilverStripe\Core\Extensible;
+use SilverStripe\Core\Injector\Injectable;
use SilverStripe\Core\Injector\Injector;
-use SilverStripe\ORM\FieldType\DBDatetime;
use SilverStripe\ORM\FieldType\DBField;
-use SilverStripe\ORM\FieldType\DBHTMLText;
use SilverStripe\View\Requirements;
use SilverStripe\View\SSViewer;
use SilverStripe\View\ThemeResourceLoader;
use SilverStripe\View\ViewableData;
-use Swift_Message;
-use Swift_Mime_SimpleMessage;
-use Swift_MimePart;
-
-/**
- * Class to support sending emails.
- */
-class Email extends ViewableData
+use Symfony\Component\Mailer\MailerInterface;
+use Symfony\Component\Mime\Address;
+use Symfony\Component\Mime\Part\AbstractPart;
+
+class Email extends SymfonyEmail
{
- /**
- * @var array
- * @config
- */
- private static $send_all_emails_to = [];
+ use Configurable;
+ use Extensible;
+ use Injectable;
- /**
- * @var array
- * @config
- */
- private static $cc_all_emails_to = [];
+ private static string|array $send_all_emails_to = [];
- /**
- * @var array
- * @config
- */
- private static $bcc_all_emails_to = [];
+ private static string|array $cc_all_emails_to = [];
- /**
- * @var array
- * @config
- */
- private static $send_all_emails_from = [];
+ private static string|array $bcc_all_emails_to = [];
+
+ private static string|array $send_all_emails_from = [];
/**
+ * The default administrator email address or array of [email => name]
* This will be set in the config on a site-by-site basis
* @see https://docs.silverstripe.org/en/4/developer_guides/email/#administrator-emails
- *
- * @config
- * @var string|array The default administrator email address or array of [email => name]
- */
- private static $admin_email = null;
-
- /**
- * @var Swift_Message
- */
- private $swiftMessage;
-
- /**
- * @var string The name of the HTML template to render the email with (without *.ss extension)
*/
- private $HTMLTemplate = null;
+ private static string|array $admin_email = '';
/**
- * @var string The name of the plain text template to render the plain part of the email with
+ * The name of the HTML template to render the email with (without *.ss extension)
*/
- private $plainTemplate = null;
+ private string $HTMLTemplate = '';
/**
- * @var Swift_MimePart
+ * The name of the plain text template to render the plain part of the email with
*/
- private $plainPart;
+ private string $plainTemplate = '';
/**
- * @var array|ViewableData Additional data available in a template.
+ * Additional data available in a template.
* Used in the same way than {@link ViewableData->customize()}.
*/
- private $data = [];
+ private array|ViewableData $data = [];
- /**
- * @var array
- */
- private $failedRecipients = [];
+ private array|ViewableData $dataAtLastRender = [];
+
+ private array $failedRecipients = [];
/**
* Checks for RFC822-valid email format.
*
- * @param string $address
- * @return boolean
- *
* @copyright Cal Henderson HTML body for $subject HTML body for Test send HTML test body body body body my content my content my content my content my content your content your content your content their content their content Test Test TestEmail template {$class}:
\n" . '' . $this->getSwiftMessage()->toString() . '
';
+ return "Email template {$class}:
\n" . '' . $this->getHtmlBody() . '
';
}
- /**
- * @return string
- */
- public function getHTMLTemplate()
+ public function getHTMLTemplate(): string
{
if ($this->HTMLTemplate) {
return $this->HTMLTemplate;
@@ -753,215 +382,153 @@ public function getHTMLTemplate()
/**
* Set the template to render the email with
- *
- * @param string $template
- * @return $this
*/
- public function setHTMLTemplate($template)
+ public function setHTMLTemplate(string $template): static
{
if (substr($template ?? '', -3) == '.ss') {
$template = substr($template ?? '', 0, -3);
}
$this->HTMLTemplate = $template;
-
return $this;
}
/**
* Get the template to render the plain part with
- *
- * @return string
*/
- public function getPlainTemplate()
+ public function getPlainTemplate(): string
{
return $this->plainTemplate;
}
/**
* Set the template to render the plain part with
- *
- * @param string $template
- * @return $this
*/
- public function setPlainTemplate($template)
+ public function setPlainTemplate(string $template): static
{
if (substr($template ?? '', -3) == '.ss') {
$template = substr($template ?? '', 0, -3);
}
$this->plainTemplate = $template;
-
return $this;
}
- /**
- * @param array $recipients
- * @return $this
- */
- public function setFailedRecipients($recipients)
+ public function setFailedRecipients(array $recipients): static
{
$this->failedRecipients = $recipients;
-
return $this;
}
- /**
- * @return array
- */
- public function getFailedRecipients()
+ public function getFailedRecipients(): array
{
return $this->failedRecipients;
}
- /**
- * Used by {@link SSViewer} templates to detect if we're rendering an email template rather than a page template
- *
- * @return bool
- */
- public function IsEmail()
- {
- return true;
- }
-
/**
* Send the message to the recipients
*
- * @return bool true if successful or array of failed recipients
+ * # NOTE: API change, was returning bool, now returns void
*/
- public function send()
+ public function send(): void
{
- if (!$this->getBody()) {
- $this->render();
- }
- if (!$this->hasPlainPart()) {
- $this->generatePlainPartFromBody();
- }
- return Injector::inst()->get(Mailer::class)->send($this);
+ $this->render();
+ Injector::inst()->get(MailerInterface::class)->send($this);
}
/**
- * @return array|bool
+ * Send the message to the recipients as plain-only
+ *
+ * # NOTE: API change, was returning bool, now returns void
*/
- public function sendPlain()
+ public function sendPlain(): void
{
- if (!$this->hasPlainPart()) {
- $this->render(true);
- }
- return Injector::inst()->get(Mailer::class)->send($this);
+ $html = $this->getHtmlBody();
+ $this->html(null);
+ $this->render(true);
+ Injector::inst()->get(MailerInterface::class)->send($this);
+ $this->html($html);
}
/**
- * Render the email
- * @param bool $plainOnly Only render the message as plain text
- * @return $this
+ * Call html() and/or text() passing a rendered email template
+ * If either body html or text were previously explicitly set, those values will not be overwritten
*/
- public function render($plainOnly = false)
+ public function render(bool $plainOnly = false): static
{
- if ($existingPlainPart = $this->findPlainPart()) {
- $this->getSwiftMessage()->detach($existingPlainPart);
- }
- unset($existingPlainPart);
-
- // Respect explicitly set body
- $htmlPart = $plainOnly ? null : $this->getBody();
- $plainPart = $plainOnly ? $this->getBody() : null;
+ // Respect explicitly set body html or text
+ $htmlBody = $plainOnly ? null : $this->getHtmlBody();
+ $plainBody = $this->getTextBody();
// Ensure we can at least render something
$htmlTemplate = $this->getHTMLTemplate();
$plainTemplate = $this->getPlainTemplate();
- if (!$htmlTemplate && !$plainTemplate && !$plainPart && !$htmlPart) {
+ if (!$htmlTemplate && !$plainTemplate && !$plainBody && !$htmlBody) {
return $this;
}
// Do not interfere with emails styles
Requirements::clear();
- // Render plain part
- if ($plainTemplate && !$plainPart) {
- $plainPart = $this->renderWith($plainTemplate, $this->getData())->Plain();
+ $htmlRender = null;
+ $plainRender = null;
+
+ $dataChangedSincedLastRender = $this->getDataHasChangedSinceLastRender();
+
+ if ($htmlBody && !$dataChangedSincedLastRender) {
+ $htmlRender = $htmlBody;
}
- // Render HTML part, either if sending html email, or a plain part is lacking
- if (!$htmlPart && $htmlTemplate && (!$plainOnly || empty($plainPart))) {
- $htmlPart = $this->renderWith($htmlTemplate, $this->getData());
+ if ($plainBody && !$dataChangedSincedLastRender) {
+ $plainRender = $plainBody;
}
- // Plain part fails over to generated from html
- if (!$plainPart && $htmlPart) {
- /** @var DBHTMLText $htmlPartObject */
- $htmlPartObject = DBField::create_field('HTMLFragment', $htmlPart);
- $plainPart = $htmlPartObject->Plain();
+ // Render plain
+ if (!$plainRender && $plainTemplate) {
+ $plainRender = ViewableData::create()->renderWith($plainTemplate, $this->getData())->Plain();
}
- // Rendering is finished
- Requirements::restore();
+ // Render HTML, either if sending html email, or a plain part is lacking
+ if (!$htmlRender && $htmlTemplate && (!$plainOnly || empty($plainRender))) {
+ $htmlRender = ViewableData::create()->renderWith($htmlTemplate, $this->getData())->RAW();
+ }
- // Fail if no email to send
- if (!$plainPart && !$htmlPart) {
- return $this;
+ // Plain render fails over to generated from html
+ if (!$plainRender && $htmlRender) {
+ $plainRender = DBField::create_field('HTMLFragment', $htmlRender)->Plain();
}
- // Build HTML / Plain components
- if ($htmlPart && !$plainOnly) {
- $this->setBody($htmlPart);
- $this->getSwiftMessage()->setContentType('text/html');
- $this->getSwiftMessage()->setCharset('utf-8');
- if ($plainPart) {
- $this->getSwiftMessage()->addPart($plainPart, 'text/plain', 'utf-8');
- }
- } else {
- if ($plainPart) {
- $this->setBody($plainPart);
- }
- $this->getSwiftMessage()->setContentType('text/plain');
- $this->getSwiftMessage()->setCharset('utf-8');
+ // Handle edge case where data changed since last render and there's no template
+ if (!$htmlRender && $htmlBody) {
+ $htmlRender = $htmlBody;
}
- return $this;
- }
+ if (!$plainRender && $plainBody) {
+ $plainRender = $plainBody;
+ }
- /**
- * @return Swift_MimePart|false
- */
- public function findPlainPart()
- {
- foreach ($this->getSwiftMessage()->getChildren() as $child) {
- if ($child instanceof Swift_MimePart && $child->getContentType() == 'text/plain') {
- return $child;
- }
+ // Rendering is finished
+ Requirements::restore();
+
+ if ($plainRender) {
+ $this->text($plainRender);
+ }
+ if ($htmlRender) {
+ $this->html($htmlRender);
}
- return false;
+
+ $this->dataAtLastRender = $this->data;
+
+ return $this;
}
- /**
- * @return bool
- */
- public function hasPlainPart()
+ private function getDataHasChangedSinceLastRender(): bool
{
- if ($this->getSwiftMessage()->getContentType() === 'text/plain') {
+ if (is_array($this->data) !== is_array($this->dataAtLastRender)) {
return true;
}
- return (bool) $this->findPlainPart();
- }
-
- /**
- * Automatically adds a plain part to the email generated from the current Body
- *
- * @return $this
- */
- public function generatePlainPartFromBody()
- {
- $plainPart = $this->findPlainPart();
- if ($plainPart) {
- $this->getSwiftMessage()->detach($plainPart);
+ if (is_array($this->data)) {
+ return $this->data !== $this->dataAtLastRender;
}
- unset($plainPart);
-
- $this->getSwiftMessage()->addPart(
- Convert::xml2raw($this->getBody()),
- 'text/plain',
- 'utf-8'
- );
-
- return $this;
+ // ViewableData
+ return serialize($this->data) !== serialize($this->dataAtLastRender);
}
}
diff --git a/src/Control/Email/EmailSender.php b/src/Control/Email/EmailSender.php
new file mode 100644
index 00000000000..d1a088f0550
--- /dev/null
+++ b/src/Control/Email/EmailSender.php
@@ -0,0 +1,25 @@
+getMessage();
+ /** @var RawMessage $rawMessage */
+ $rawMessage = $sendEmailMessage->getMessage();
+ /** @var TransportInterface $transport */
+ $transport = Injector::inst()->get(TransportInterface::class);
+ $transport->send($rawMessage);
+ return $envelope;
+ }
+}
diff --git a/src/Control/Email/Mailer.php b/src/Control/Email/Mailer.php
deleted file mode 100644
index b661c91b8db..00000000000
--- a/src/Control/Email/Mailer.php
+++ /dev/null
@@ -1,13 +0,0 @@
-bootEmail()
+ *
+ * See https://symfony.com/doc/current/mailer.html#mailer-events for further info
+ */
+class MailerSubscriber implements EventSubscriberInterface
+{
+ use Injectable;
+ use Extensible;
+
+ public static function getSubscribedEvents()
+ {
+ return [
+ MessageEvent::class => 'onMessage',
+ ];
+ }
+
+ public function onMessage(MessageEvent $event): void
+ {
+ $email = $event->getMessage();
+ if (!($email instanceof Email)) {
+ throw new InvalidArgumentException('Message is not a ' . Email::class);
+ }
+ $this->applyConfig($email);
+ $this->updateUrls($email);
+ $this->extend('updateOnMessage', $email, $event);
+ }
+
+ private function applyConfig(Email $email): void
+ {
+ $sendAllTo = Email::getSendAllEmailsTo();
+ if (!empty($sendAllTo)) {
+ $this->setTo($email, $sendAllTo);
+ }
+
+ $ccAllTo = Email::getCCAllEmailsTo();
+ if (!empty($ccAllTo)) {
+ $email->addCc(...$ccAllTo);
+ }
+
+ $bccAllTo = Email::getBCCAllEmailsTo();
+ if (!empty($bccAllTo)) {
+ $email->addBcc(...$bccAllTo);
+ }
+
+ $sendAllFrom = Email::getSendAllEmailsFrom();
+ if (!empty($sendAllFrom)) {
+ $this->setFrom($email, $sendAllFrom);
+ }
+ }
+
+ private function setTo(Email $email, array $sendAllTo): void
+ {
+ $headers = $email->getHeaders();
+ $xTo = $this->convertAddressesToString($email->getTo());
+ $xCc = $this->convertAddressesToString($email->getCc());
+ $xBcc = $this->convertAddressesToString($email->getBcc());
+
+ // set default recipient and remove all other recipients
+ $email->to(...$sendAllTo);
+ $email->cc(...[]);
+ $email->bcc(...[]);
+
+ // store the old data as X-Original-* Headers for debugging
+ $headers->addMailboxHeader('X-Original-To', $xTo);
+ $headers->addMailboxHeader('X-Original-Cc', $xCc);
+ $headers->addMailboxHeader('X-Original-Bcc', $xBcc);
+ }
+
+ private function setFrom(Email $email, array $sendAllFrom): void
+ {
+ $headers = $email->getHeaders();
+ $xFrom = $this->convertAddressesToString($email->getFrom());
+ $headers->addMailboxHeader('X-Original-From', $xFrom);
+ $email->from(...$sendAllFrom);
+ }
+
+ private function convertAddressesToString(array $addresses): string
+ {
+ return implode(',', array_map(fn(Address $address) => $address->getAddress(), $addresses));
+ }
+
+ private function updateUrls(Email $email): void
+ {
+ if ($email->getHtmlBody()) {
+ $email->html(HTTP::absoluteURLs($email->getHtmlBody()));
+ }
+ if ($email->getTextBody()) {
+ $email->text(HTTP::absoluteURLs($email->getTextBody()));
+ }
+ }
+}
diff --git a/src/Control/Email/SwiftMailer.php b/src/Control/Email/SwiftMailer.php
deleted file mode 100644
index 1bed67c6f09..00000000000
--- a/src/Control/Email/SwiftMailer.php
+++ /dev/null
@@ -1,80 +0,0 @@
-getSwiftMessage();
- $failedRecipients = [];
- $result = $this->sendSwift($swiftMessage, $failedRecipients);
- $message->setFailedRecipients($failedRecipients);
-
- return $result != 0;
- }
-
- /**
- * @param Swift_Message $message
- * @param array $failedRecipients
- * @return int
- */
- protected function sendSwift($message, &$failedRecipients = null)
- {
- return $this->getSwiftMailer()->send($message, $failedRecipients);
- }
-
- /**
- * @return Swift_Mailer
- */
- public function getSwiftMailer()
- {
- return $this->swift;
- }
-
- /**
- * @param Swift_Mailer $swift
- * @return $this
- */
- public function setSwiftMailer($swift)
- {
- // register any required plugins
- foreach ($this->config()->get('swift_plugins') as $plugin) {
- $swift->registerPlugin(Injector::inst()->create($plugin));
- }
- $this->swift = $swift;
-
- return $this;
- }
-}
diff --git a/src/Control/Email/SwiftPlugin.php b/src/Control/Email/SwiftPlugin.php
deleted file mode 100644
index cccc47ce91f..00000000000
--- a/src/Control/Email/SwiftPlugin.php
+++ /dev/null
@@ -1,80 +0,0 @@
-getMessage();
-
- $sendAllTo = Email::getSendAllEmailsTo();
- if (!empty($sendAllTo)) {
- $this->setTo($message, $sendAllTo);
- }
-
- $ccAllTo = Email::getCCAllEmailsTo();
- if (!empty($ccAllTo)) {
- foreach ($ccAllTo as $address => $name) {
- $message->addCc($address, $name);
- }
- }
-
- $bccAllTo = Email::getBCCAllEmailsTo();
- if (!empty($bccAllTo)) {
- foreach ($bccAllTo as $address => $name) {
- $message->addBcc($address, $name);
- }
- }
-
- $sendAllFrom = Email::getSendAllEmailsFrom();
- if (!empty($sendAllFrom)) {
- $this->setFrom($message, $sendAllFrom);
- }
- }
-
- /**
- * @param \Swift_Message $message
- * @param array|string $to
- */
- protected function setTo($message, $to)
- {
- $headers = $message->getHeaders();
- $origTo = $message->getTo();
- $cc = $message->getCc();
- $bcc = $message->getBcc();
-
- // set default recipient and remove all other recipients
- $message->setTo($to);
- $headers->removeAll('Cc');
- $headers->removeAll('Bcc');
-
- // store the old data as X-Original-* Headers for debugging
- $headers->addMailboxHeader('X-Original-To', $origTo);
- $headers->addMailboxHeader('X-Original-Cc', $cc);
- $headers->addMailboxHeader('X-Original-Bcc', $bcc);
- }
-
- /**
- * @param \Swift_Message $message
- * @param array|string $from
- */
- protected function setFrom($message, $from)
- {
- $headers = $message->getHeaders();
- $origFrom = $message->getFrom();
- $headers->addMailboxHeader('X-Original-From', $origFrom);
- $message->setFrom($from);
- }
-
- public function sendPerformed(\Swift_Events_SendEvent $evt)
- {
- // noop
- }
-}
diff --git a/src/Core/BaseKernel.php b/src/Core/BaseKernel.php
index 958a91615bb..451dc1f4a4c 100644
--- a/src/Core/BaseKernel.php
+++ b/src/Core/BaseKernel.php
@@ -28,6 +28,8 @@
use SilverStripe\View\ThemeManifest;
use SilverStripe\View\ThemeResourceLoader;
use Exception;
+use SilverStripe\Control\Email\MailerSubscriber;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
/**
* Simple Kernel container
@@ -243,6 +245,17 @@ protected function bootErrorHandling()
}
}
+ /**
+ * Register a subscriber for the symfony/mailer MailerEvent
+ * https://symfony.com/doc/current/components/event_dispatcher.html#using-event-subscribers
+ */
+ protected function bootEmail()
+ {
+ $subscriber = MailerSubscriber::create();
+ $dipatcher = Injector::inst()->get(EventDispatcherInterface::class . '.mailer');
+ $dipatcher->addSubscriber($subscriber);
+ }
+
/**
* Get the environment type
*
diff --git a/src/Core/CoreKernel.php b/src/Core/CoreKernel.php
index c16d75999cd..53044e9e2e1 100644
--- a/src/Core/CoreKernel.php
+++ b/src/Core/CoreKernel.php
@@ -35,6 +35,7 @@ public function boot($flush = false)
$this->bootErrorHandling();
$this->bootDatabaseEnvVars();
$this->bootConfigs();
+ $this->bootEmail();
$this->bootDatabaseGlobals();
$this->validateDatabase();
diff --git a/src/Core/DatabaselessKernel.php b/src/Core/DatabaselessKernel.php
index d5c981ba462..3fc85d6b051 100644
--- a/src/Core/DatabaselessKernel.php
+++ b/src/Core/DatabaselessKernel.php
@@ -49,6 +49,7 @@ public function boot($flush = false)
$this->bootManifests($flush);
$this->bootErrorHandling();
$this->bootConfigs();
+ $this->bootEmail();
$this->setBooted(true);
}
diff --git a/src/Dev/SapphireTest.php b/src/Dev/SapphireTest.php
index 965111b1284..4b5f0f4fb9a 100644
--- a/src/Dev/SapphireTest.php
+++ b/src/Dev/SapphireTest.php
@@ -15,7 +15,6 @@
use SilverStripe\Control\Cookie;
use SilverStripe\Control\Director;
use SilverStripe\Control\Email\Email;
-use SilverStripe\Control\Email\Mailer;
use SilverStripe\Control\HTTPApplication;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Core\Config\Config;
@@ -39,6 +38,10 @@
use SilverStripe\Security\Permission;
use SilverStripe\Security\Security;
use SilverStripe\View\SSViewer;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Symfony\Component\Mailer\MailerInterface;
+use Symfony\Component\Mailer\Transport\NullTransport;
+use Symfony\Component\Messenger\MessageBusInterface;
/**
* Test case class for the Silverstripe framework.
@@ -318,19 +321,18 @@ protected function setUp(): void
}
// Set up the test mailer
- if (class_exists(TestMailer::class)) {
- Injector::inst()->registerService(new TestMailer(), Mailer::class);
- }
-
- if (class_exists(Email::class)) {
- Email::config()->remove('send_all_emails_to');
- Email::config()->remove('send_all_emails_from');
- Email::config()->remove('cc_all_emails_to');
- Email::config()->remove('bcc_all_emails_to');
- }
+ $testMailer = new TestMailer(
+ new NullTransport(),
+ Injector::inst()->get(MessageBusInterface::class . '.mailer'),
+ Injector::inst()->get(EventDispatcherInterface::class . '.mailer'),
+ );
+ Injector::inst()->registerService($testMailer, MailerInterface::class);
+ Email::config()->remove('send_all_emails_to');
+ Email::config()->remove('send_all_emails_from');
+ Email::config()->remove('cc_all_emails_to');
+ Email::config()->remove('bcc_all_emails_to');
}
-
/**
* Helper method to determine if the current test should enable a test database
*
@@ -611,8 +613,8 @@ protected function tearDown(): void
*/
public function clearEmails()
{
- /** @var Mailer $mailer */
- $mailer = Injector::inst()->get(Mailer::class);
+ /** @var MailerInterface $mailer */
+ $mailer = Injector::inst()->get(MailerInterface::class);
if ($mailer instanceof TestMailer) {
$mailer->clearEmails();
return true;
@@ -632,8 +634,8 @@ public function clearEmails()
*/
public static function findEmail($to, $from = null, $subject = null, $content = null)
{
- /** @var Mailer $mailer */
- $mailer = Injector::inst()->get(Mailer::class);
+ /** @var MailerInterface $mailer */
+ $mailer = Injector::inst()->get(MailerInterface::class);
if ($mailer instanceof TestMailer) {
return $mailer->findEmail($to, $from, $subject, $content);
}
diff --git a/src/Dev/TestMailer.php b/src/Dev/TestMailer.php
index 44627e62c45..fe82e5dc127 100644
--- a/src/Dev/TestMailer.php
+++ b/src/Dev/TestMailer.php
@@ -2,98 +2,80 @@
namespace SilverStripe\Dev;
-use SilverStripe\Control\Email\Mailer;
-use Swift_Attachment;
+use InvalidArgumentException;
+use SilverStripe\Control\Email\Email;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+use Symfony\Component\Mailer\Envelope;
+use Symfony\Component\Mailer\Event\MessageEvent;
+use Symfony\Component\Mailer\MailerInterface;
+use Symfony\Component\Mailer\Transport\NullTransport;
+use Symfony\Component\Mime\RawMessage;
+use Symfony\Component\Mailer\Transport\TransportInterface;
+use Symfony\Component\Messenger\MessageBusInterface;
+use Symfony\Component\Mime\Address;
+use Symfony\Component\Mime\Part\DataPart;
-class TestMailer implements Mailer
+class TestMailer implements MailerInterface
{
- /**
- * @var array
- */
- protected $emailsSent = [];
-
- public function send($email)
- {
- // Detect body type
- $htmlContent = null;
- $plainContent = null;
- if ($email->getSwiftMessage()->getContentType() === 'text/plain') {
- $type = 'plain';
- $plainContent = $email->getBody();
- } else {
- $type = 'html';
- $htmlContent = $email->getBody();
- $plainPart = $email->findPlainPart();
- if ($plainPart) {
- $plainContent = $plainPart->getBody();
- }
- }
-
- // Get attachments
- $attachedFiles = [];
- foreach ($email->getSwiftMessage()->getChildren() as $child) {
- if ($child instanceof Swift_Attachment) {
- $attachedFiles[] = [
- 'contents' => $child->getBody(),
- 'filename' => $child->getFilename(),
- 'mimetype' => $child->getContentType(),
- ];
- }
- }
-
- // Serialise email
- $serialised = [
- 'Type' => $type,
- 'To' => implode(';', array_keys($email->getTo() ?: [])),
- 'From' => implode(';', array_keys($email->getFrom() ?: [])),
- 'Subject' => $email->getSubject(),
- 'Content' => $email->getBody(),
- 'AttachedFiles' => $attachedFiles,
- 'Headers' => $email->getSwiftMessage()->getHeaders(),
- ];
- if ($plainContent) {
- $serialised['PlainContent'] = $plainContent;
- }
- if ($htmlContent) {
- $serialised['HtmlContent'] = $htmlContent;
- }
-
- $this->saveEmail($serialised);
+ private array $emailsSent = [];
- return true;
- }
+ private TransportInterface $transport;
+ private ?MessageBusInterface $bus;
+ private ?EventDispatcherInterface $dispatcher;
/**
- * Save a single email to the log
+ * This constructor has params that match Symfony\Component\Mailer\Mailer, rather
+ * than Symfony\Component\Mailer\MailerInterface, so that it can be Injected in an
+ * indentical fashion.
*
- * @param array $data A map of information about the email
+ * Note - $transport and $bus are never actually used because Email are never supposed to
+ * be send. Dispatcher is however used to support SilverStripe\Control\Email\MailerSubscriber
*/
- protected function saveEmail($data)
- {
- $this->emailsSent[] = $data;
+ public function __construct(
+ TransportInterface $transport,
+ MessageBusInterface $bus = null,
+ EventDispatcherInterface $dispatcher = null
+ ) {
+ $this->transport = $transport;
+ $this->bus = $bus;
+ $this->dispatcher = $dispatcher;
}
- /**
- * Clear the log of emails sent
- */
- public function clearEmails()
+ public function send(RawMessage $message, Envelope $envelope = null): void
{
- $this->emailsSent = [];
+ if (!is_a($message, Email::class)) {
+ throw new InvalidArgumentException('$message must be a ' . Email::class);
+ }
+ /** @var Email $email */
+ $email = $message;
+ $this->dispatchEvent($email, $envelope);
+ $this->emailsSent[] = [
+ 'Type' => $email->getHtmlBody() ? 'html' : 'plain',
+ 'To' => $this->convertAddressesToString($email->getTo()),
+ 'From' => $this->convertAddressesToString($email->getFrom()),
+ 'Subject' => $email->getSubject(),
+ 'Content' => $email->getHtmlBody() ?: $email->getTextBody(),
+ 'Headers' => $email->getHeaders(),
+ 'PlainContent' => $email->getTextBody(),
+ 'HtmlContent' => $email->getHtmlBody(),
+ 'AttachedFiles' => array_map(fn(DataPart $attachment) => [
+ 'contents' => $attachment->getBody(),
+ 'filename' => $attachment->getFilename(),
+ 'mimetype' => $attachment->getContentType()
+ ], $email->getAttachments()),
+ ];
}
/**
* Search for an email that was sent.
* All of the parameters can either be a string, or, if they start with "/", a PREG-compatible regular expression.
- *
- * @param string $to
- * @param string $from
- * @param string $subject
- * @param string $content
- * @return array|null Contains keys: 'Type', 'To', 'From', 'Subject', 'Content', 'PlainContent', 'AttachedFiles',
- * 'HtmlContent'
*/
- public function findEmail($to, $from = null, $subject = null, $content = null)
- {
+ public function findEmail(
+ string $to,
+ ?string $from = null,
+ ?string $subject = null,
+ ?string $content = null
+ ): ?array {
$compare = [
'To' => $to,
'From' => $from,
@@ -131,9 +113,31 @@ public function findEmail($to, $from = null, $subject = null, $content = null)
}
/**
- * @param string $value
+ * Clear the log of emails sent
*/
- private function normaliseSpaces(string $value)
+ public function clearEmails(): void
+ {
+ $this->emailsSent = [];
+ }
+
+ private function convertAddressesToString(array $addresses): string
+ {
+ return implode(',', array_map(fn(Address $address) => $address->getAddress(), $addresses));
+ }
+
+ /**
+ * Note: unlike Symfony\Component\Mailer\Mailer, this does not clone the $message
+ * This is because the emails to, from, cc, and bcc may be updated by MailerSubscriber->onMessage()
+ * and we need to update the $this->emailsSent array with these updated values
+ */
+ private function dispatchEvent(RawMessage $message, ?Envelope $envelope): void
+ {
+ $clonedEnvelope = null !== $envelope ? clone $envelope : Envelope::create($message);
+ $event = new MessageEvent($message, $clonedEnvelope, (string) $this->transport, true);
+ $this->dispatcher->dispatch($event);
+ }
+
+ private function normaliseSpaces(string $value): string
{
return str_replace([', ', '; '], [',', ';'], $value ?? '');
}
diff --git a/src/Security/Member.php b/src/Security/Member.php
index 40b3ee5774b..dd47ddbe154 100644
--- a/src/Security/Member.php
+++ b/src/Security/Member.php
@@ -9,7 +9,6 @@
use SilverStripe\Control\Controller;
use SilverStripe\Control\Director;
use SilverStripe\Control\Email\Email;
-use SilverStripe\Control\Email\Mailer;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Core\Config\Config;
use SilverStripe\Core\Convert;
@@ -36,6 +35,7 @@
use SilverStripe\ORM\UnsavedRelationList;
use SilverStripe\ORM\ValidationException;
use SilverStripe\ORM\ValidationResult;
+use Symfony\Component\Mailer\MailerInterface;
/**
* The member class which represents the users of the system
@@ -907,7 +907,7 @@ public function onBeforeWrite()
// We don't send emails out on dev/tests sites to prevent accidentally spamming users.
// However, if TestMailer is in use this isn't a risk.
// @todo some developers use external tools, so emailing might be a good idea anyway
- if ((Director::isLive() || Injector::inst()->get(Mailer::class) instanceof TestMailer)
+ if ((Director::isLive() || Injector::inst()->get(MailerInterface::class) instanceof TestMailer)
&& $this->isChanged('Password')
&& $this->record['Password']
&& $this->Email
diff --git a/templates/SilverStripe/Control/Email/Email.ss b/templates/SilverStripe/Control/Email/Email.ss
index d69591afeef..045fb2c19de 100644
--- a/templates/SilverStripe/Control/Email/Email.ss
+++ b/templates/SilverStripe/Control/Email/Email.ss
@@ -6,7 +6,7 @@
Email Sub-class
', $email->getBody());
+ $sentMail = $this->getMailer()->findEmail('to@example.com');
+ $this->assertStringContainsString('Email Sub-class
', $sentMail['Content']);
}
- public function testConsturctor()
+ public function testConstructor(): void
{
$email = new Email(
'from@example.com',
'to@example.com',
'subject',
- 'body',
+ 'Title
');
- $this->assertEquals('Title
', $email->getBody());
- }
-
- public function testHTMLTemplate()
+ public function testHTMLTemplate(): void
{
- // Include dev theme
- SSViewer::set_themes([
- 'silverstripe/framework:/tests/php/Control/Email/EmailTest',
- '$default',
- ]);
-
// Find template on disk
$emailTemplate = ModuleResourceLoader::singleton()->resolveResource(
'silverstripe/framework:templates/SilverStripe/Control/Email/Email.ss'
@@ -522,7 +352,7 @@ public function testHTMLTemplate()
$this->assertEquals('MyTemplate', $email->getHTMLTemplate());
}
- public function testPlainTemplate()
+ public function testPlainTemplate(): void
{
$email = new Email();
$this->assertEmpty($email->getPlainTemplate());
@@ -530,145 +360,85 @@ public function testPlainTemplate()
$this->assertEquals('MyTemplate', $email->getPlainTemplate());
}
- public function testGetFailedRecipients()
- {
- $mailer = new SwiftMailer();
- /** @var Swift_NullTransport|MockObject $transport */
- $transport = $this->getMockBuilder(Swift_NullTransport::class)->getMock();
- $transport->expects($this->once())
- ->method('send')
- ->willThrowException(new Swift_RfcComplianceException('Bad email'));
- $mailer->setSwiftMailer(new Swift_Mailer($transport));
- $email = new Email();
- $email->setTo('to@example.com');
- $email->setFrom('from@example.com');
- $mailer->send($email);
- $this->assertCount(1, $email->getFailedRecipients());
- }
-
- public function testIsEmail()
+ public function testIsEmail(): void
{
$this->assertTrue((new Email)->IsEmail());
}
- public function testRenderAgain()
+ public function testRenderAgain(): void
{
$email = new Email();
$email->setData([
- 'EmailContent' => 'my content',
+ 'EmailContent' => 'Test
');
- $this->assertEmpty($email->getSwiftMessage()->getChildren());
- $email->generatePlainPartFromBody();
- $children = $email->getSwiftMessage()->getChildren();
- $this->assertCount(1, $children);
- $plainPart = reset($children);
- $this->assertStringContainsString('Test', $plainPart->getBody());
- $this->assertStringNotContainsString('Test
', $plainPart->getBody());
+ $this->assertSame('test content', $email->getTextBody());
}
- public function testMultipleEmailSends()
+ public function testMultipleEmailSends(): void
{
- $email = new Email();
+ $email = new Email(to: 'to@example.com');
$email->setData([
- 'EmailContent' => 'Test',
+ 'EmailContent' => 'Email Sub-class
- $EmailContent
+ $EmailContent.RAW
diff --git a/tests/php/Control/Email/EmailTest/templates/SilverStripe/Control/Tests/Email/EmailTest/HtmlTemplate.ss b/tests/php/Control/Email/EmailTest/templates/SilverStripe/Control/Tests/Email/EmailTest/HtmlTemplate.ss new file mode 100644 index 00000000000..ddc4160f7f6 --- /dev/null +++ b/tests/php/Control/Email/EmailTest/templates/SilverStripe/Control/Tests/Email/EmailTest/HtmlTemplate.ss @@ -0,0 +1,14 @@ + + +
+ <% base_tag %> + +
+