From 92f47da08bcb6d70d46f178e71bcbb69b819a270 Mon Sep 17 00:00:00 2001 From: Steve Boyd Date: Wed, 18 Aug 2021 12:16:45 +1200 Subject: [PATCH] API Update SwiftMailer from v5 to v6 (#10048) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update SwiftMailer from v5 to v6 - Fixes #9834 - Update default Swift_Transport to use Swift_SendmailTransport - Update version restraint for Swiftmailer - Address new parameter type for Swift_Message::setDate() - Update class references in docblocks Co-authored-by: DanaeĢˆ Miller-Clendon --- composer.json | 5 +- docs/en/02_Developer_Guides/10_Email/index.md | 66 +++- src/Control/Email/Email.php | 15 +- src/Control/Email/SwiftPlugin.php | 4 +- tests/php/Control/Email/EmailTest.php | 6 +- .../swiftmailer/Swift/MailTransport.php | 75 +++++ .../Swift/Transport/MailInvoker.php | 40 +++ .../Swift/Transport/MailTransport.php | 313 ++++++++++++++++++ .../Swift/Transport/SimpleMailInvoker.php | 47 +++ 9 files changed, 556 insertions(+), 15 deletions(-) create mode 100644 thirdparty/swiftmailer/Swift/MailTransport.php create mode 100644 thirdparty/swiftmailer/Swift/Transport/MailInvoker.php create mode 100644 thirdparty/swiftmailer/Swift/Transport/MailTransport.php create mode 100644 thirdparty/swiftmailer/Swift/Transport/SimpleMailInvoker.php diff --git a/composer.json b/composer.json index 520853c72..9ac73f3bd 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,7 @@ "silverstripe/assets": "^1@dev", "silverstripe/vendor-plugin": "^1.4", "sminnee/callbacklist": "^0.1", - "swiftmailer/swiftmailer": "~5.4", + "swiftmailer/swiftmailer": "^6.2", "symfony/cache": "^3.3 || ^4", "symfony/config": "^3.2 || ^4", "symfony/translation": "^2.8 || ^3 || ^4", @@ -90,6 +90,9 @@ }, "files": [ "src/includes/constants.php" + ], + "classmap": [ + "thirdparty/swiftmailer" ] }, "include-path": [ diff --git a/docs/en/02_Developer_Guides/10_Email/index.md b/docs/en/02_Developer_Guides/10_Email/index.md index 495aca0a7..72f4274d9 100644 --- a/docs/en/02_Developer_Guides/10_Email/index.md +++ b/docs/en/02_Developer_Guides/10_Email/index.md @@ -6,23 +6,36 @@ icon: envelope-open # Email -Creating and sending email in Silverstripe CMS is done through the [Email](api:SilverStripe\Control\Email\Email) and [Mailer](api:SilverStripe\Control\Email\Mailer) classes. This document -covers how to create an `Email` instance, customise it with a HTML template, then send it through a custom `Mailer`. +Creating and sending email in Silverstripe CMS is done through the [Email](api:SilverStripe\Control\Email\Email) and [Mailer](api:SilverStripe\Control\Email\Mailer) classes. This document covers how to create an `Email` instance, customise it with a HTML template, then send it through a custom `Mailer`. ## Configuration -Silverstripe CMS provides an API over the top of the [SwiftMailer](http://swiftmailer.org/) PHP library which comes with an -extensive list of "transports" for sending mail via different services. +Silverstripe CMS provides an API over the top of the [SwiftMailer](http://swiftmailer.org/) PHP library which comes with an extensive list of "transports" for sending mail via different services. -Out of the box, Silverstripe CMS will use the built-in PHP `mail()` command via the `Swift_MailTransport` class. If you'd -like to use a more robust transport to send mail you can swap out the transport used by the `Mailer` via config: +For legacy reasons, Silverstripe CMS 4 defaults to using the built-in PHP `mail()` command via a deprecated class `Swift_MailTransport`. However, using this layer is less secure and is strongly discouraged. + +It's highly recommended you upgrade to a more robust transport for additional security. The Sendmail transport is the most common one. The `sendmail` binary is widely available across most Linux/Unix servers. + +You can use any of the Transport classes provided natively by SwiftMailer. There are also countless PHP libraries offering custom Transports to integrate with third party mailing service: +- read the [SwiftMailer Transport Types documentation](https://swiftmailer.symfony.com/docs/sending.html#transport-types) for a full list of native Transport +- search [Packagist for SwiftMailer Transport](https://packagist.org/?query=SwiftMailer+Transport) to discover additional third party integrations + +To swap out the transport used by the `Mailer`, create a file `app/_config/email.yml` + +To use a `sendmail` binary: ```yml +--- +Name: myemailconfig +After: + - '#emailconfig' +--- SilverStripe\Core\Injector\Injector: - Swift_Transport: Swift_SendmailTransport + Swift_Transport: + class: Swift_SendmailTransport ``` -For example, to use SMTP, create a file `app/_config/email.yml`: +To use SMTP: ```yml --- @@ -45,6 +58,42 @@ SilverStripe\Core\Injector\Injector: Note the usage of backticks to designate environment variables for the credentials - ensure you set these in your `.env` file or in your webserver configuration. +### Mailer Configuration for dev environments + +You may wish to use a different mailer configuration in your development environment. This can be used to suppress outgoing messages or to capture them for debugging purposes in a service like [MailCatcher](https://mailcatcher.me/). + +You can suppress all emails by using the [`Swift_Transport_NullTransport`](https://github.com/swiftmailer/swiftmailer/blob/master/lib/classes/Swift/Transport/NullTransport.php). + +```yml +--- +Name: mydevemailconfig +After: + - '#emailconfig' +Only: + environment: dev +--- +SilverStripe\Core\Injector\Injector: + Swift_Transport: + class: Swift_Transport_NullTransport +``` + +If you're using MailCatcher, or a similar tool, you can tell `Swift_SendmailTransport` to use a different binary. + +```yml +--- +Name: mydevemailconfig +After: + - '#emailconfig' +Only: + environment: dev +--- +SilverStripe\Core\Injector\Injector: + Swift_Transport: + class: Swift_SendmailTransport + constructor: + 0: '/usr/bin/env catchmail -t' +``` + ## Usage ### Sending plain text only @@ -224,6 +273,7 @@ SilverStripe\Core\Injector\Injector: For further information on SwiftMailer, consult their docs: http://swiftmailer.org/docs/introduction.html + ## API Documentation * [Email](api:SilverStripe\Control\Email\Email) diff --git a/src/Control/Email/Email.php b/src/Control/Email/Email.php index b3756f739..85bfb081c 100644 --- a/src/Control/Email/Email.php +++ b/src/Control/Email/Email.php @@ -2,6 +2,9 @@ namespace SilverStripe\Control\Email; +use DateTime; +use Egulias\EmailValidator\EmailValidator; +use Egulias\EmailValidator\Validation\RFCValidation; use SilverStripe\Control\Director; use SilverStripe\Control\HTTP; use SilverStripe\Core\Convert; @@ -99,7 +102,8 @@ class Email extends ViewableData */ public static function is_valid_address($address) { - return \Swift_Validate::email($address); + $validator = new EmailValidator(); + return $validator->isValid($address, new RFCValidation()); } /** @@ -269,7 +273,9 @@ class Email extends ViewableData */ public function setSwiftMessage($swiftMessage) { - $swiftMessage->setDate(DBDatetime::now()->getTimestamp()); + $dateTime = new DateTime(); + $dateTime->setTimestamp(DBDatetime::now()->getTimestamp()); + $swiftMessage->setDate($dateTime); if (!$swiftMessage->getFrom() && ($defaultFrom = $this->config()->get('admin_email'))) { $swiftMessage->setFrom($defaultFrom); } @@ -451,6 +457,9 @@ class Email extends ViewableData return $this; } + /** + * @return mixed + */ public function getReplyTo() { return $this->getSwiftMessage()->getReplyTo(); @@ -610,7 +619,7 @@ class Email extends ViewableData } $this->invalidateBody(); - + return $this; } diff --git a/src/Control/Email/SwiftPlugin.php b/src/Control/Email/SwiftPlugin.php index a0274731f..cccc47ce9 100644 --- a/src/Control/Email/SwiftPlugin.php +++ b/src/Control/Email/SwiftPlugin.php @@ -40,7 +40,7 @@ class SwiftPlugin implements \Swift_Events_SendListener } /** - * @param \Swift_Mime_Message $message + * @param \Swift_Message $message * @param array|string $to */ protected function setTo($message, $to) @@ -62,7 +62,7 @@ class SwiftPlugin implements \Swift_Events_SendListener } /** - * @param \Swift_Mime_Message $message + * @param \Swift_Message $message * @param array|string $from */ protected function setFrom($message, $from) diff --git a/tests/php/Control/Email/EmailTest.php b/tests/php/Control/Email/EmailTest.php index c8b654256..8857cfed6 100644 --- a/tests/php/Control/Email/EmailTest.php +++ b/tests/php/Control/Email/EmailTest.php @@ -2,6 +2,7 @@ namespace SilverStripe\Control\Tests\Email; +use DateTime; use PHPUnit_Framework_MockObject_MockObject; use SilverStripe\Control\Email\Email; use SilverStripe\Control\Email\Mailer; @@ -269,9 +270,12 @@ class EmailTest extends SapphireTest $email = new Email(); $swiftMessage = new Swift_Message(); $email->setSwiftMessage($swiftMessage); + $dateTime = new DateTime(); + $dateTime->setTimestamp(DBDatetime::now()->getTimestamp()); + $email->getSwiftMessage()->setDate($dateTime); $this->assertCount(1, $email->getFrom()); $this->assertContains('admin@example.com', array_keys($swiftMessage->getFrom())); - $this->assertEquals(strtotime('2017-01-01 07:00:00'), $swiftMessage->getDate()); + $this->assertEquals(strtotime('2017-01-01 07:00:00'), $swiftMessage->getDate()->getTimestamp()); $this->assertEquals($swiftMessage, $email->getSwiftMessage()); // check from field is retained diff --git a/thirdparty/swiftmailer/Swift/MailTransport.php b/thirdparty/swiftmailer/Swift/MailTransport.php new file mode 100644 index 000000000..9cc506000 --- /dev/null +++ b/thirdparty/swiftmailer/Swift/MailTransport.php @@ -0,0 +1,75 @@ +getDependencies() + ); + + $this->setExtraParams($extraParams); + } + + /** + * Create a new MailTransport instance. + * + * @param string $extraParams To be passed to mail() + * + * @return self + */ + public static function newInstance($extraParams = '-f%s') + { + return new self($extraParams); + } + + /** + * Add in deps for MailTransport which was removed as part of SwiftMailer v6 + * @see transport_deps.php + * + * @return array + */ + private function getDependencies(): array + { + $deps = Swift_DependencyContainer::getInstance()->createDependenciesFor('transport.mail'); + if (empty($deps)) { + Swift_DependencyContainer::getInstance() + ->register('transport.mail') + ->asNewInstanceOf('Swift_Transport_MailTransport') + ->withDependencies(['transport.mailinvoker', 'transport.eventdispatcher']) + ->register('transport.mailinvoker') + ->asSharedInstanceOf('Swift_Transport_SimpleMailInvoker'); + $deps = Swift_DependencyContainer::getInstance()->createDependenciesFor('transport.mail'); + } + return $deps; + } +} diff --git a/thirdparty/swiftmailer/Swift/Transport/MailInvoker.php b/thirdparty/swiftmailer/Swift/Transport/MailInvoker.php new file mode 100644 index 000000000..2a23e24d2 --- /dev/null +++ b/thirdparty/swiftmailer/Swift/Transport/MailInvoker.php @@ -0,0 +1,40 @@ +_invoker = $invoker; + $this->_eventDispatcher = $eventDispatcher; + } + + /** + * Not used. + */ + public function isStarted() + { + return false; + } + + /** + * Not used. + */ + public function start() + { + } + + /** + * Not used. + */ + public function stop() + { + } + + /** + * Set the additional parameters used on the mail() function. + * + * This string is formatted for sprintf() where %s is the sender address. + * + * @param string $params + * + * @return $this + */ + public function setExtraParams($params) + { + $this->_extraParams = $params; + + return $this; + } + + /** + * Get the additional parameters used on the mail() function. + * + * This string is formatted for sprintf() where %s is the sender address. + * + * @return string + */ + public function getExtraParams() + { + return $this->_extraParams; + } + + /** + * Send the given Message. + * + * Recipient/sender data will be retrieved from the Message API. + * The return value is the number of recipients who were accepted for delivery. + * + * @param Swift_Mime_Message $message + * @param string[] $failedRecipients An array of failures by-reference + * + * @return int + */ + public function send(Swift_Mime_SimpleMessage $message, &$failedRecipients = null) + { + $failedRecipients = (array) $failedRecipients; + + if ($evt = $this->_eventDispatcher->createSendEvent($this, $message)) { + $this->_eventDispatcher->dispatchEvent($evt, 'beforeSendPerformed'); + if ($evt->bubbleCancelled()) { + return 0; + } + } + + $count = ( + count((array) $message->getTo()) + + count((array) $message->getCc()) + + count((array) $message->getBcc()) + ); + + $toHeader = $message->getHeaders()->get('To'); + $subjectHeader = $message->getHeaders()->get('Subject'); + + if (0 === $count) { + $this->_throwException(new Swift_TransportException('Cannot send message without a recipient')); + } + $to = $toHeader ? $toHeader->getFieldBody() : ''; + $subject = $subjectHeader ? $subjectHeader->getFieldBody() : ''; + + $reversePath = $this->_getReversePath($message); + + // Remove headers that would otherwise be duplicated + $message->getHeaders()->remove('To'); + $message->getHeaders()->remove('Subject'); + + $messageStr = $message->toString(); + + if ($toHeader) { + $message->getHeaders()->set($toHeader); + } + $message->getHeaders()->set($subjectHeader); + + // Separate headers from body + if (false !== $endHeaders = strpos($messageStr, "\r\n\r\n")) { + $headers = substr($messageStr, 0, $endHeaders) . "\r\n"; //Keep last EOL + $body = substr($messageStr, $endHeaders + 4); + } else { + $headers = $messageStr . "\r\n"; + $body = ''; + } + + unset($messageStr); + + if ("\r\n" != PHP_EOL) { + // Non-windows (not using SMTP) + $headers = str_replace("\r\n", PHP_EOL, $headers); + $subject = str_replace("\r\n", PHP_EOL, $subject); + $body = str_replace("\r\n", PHP_EOL, $body); + $to = str_replace("\r\n", PHP_EOL, $to); + } else { + // Windows, using SMTP + $headers = str_replace("\r\n.", "\r\n..", $headers); + $subject = str_replace("\r\n.", "\r\n..", $subject); + $body = str_replace("\r\n.", "\r\n..", $body); + $to = str_replace("\r\n.", "\r\n..", $to); + } + + if ($this->_invoker->mail($to, $subject, $body, $headers, $this->_formatExtraParams($this->_extraParams, $reversePath))) { + if ($evt) { + $evt->setResult(Swift_Events_SendEvent::RESULT_SUCCESS); + $evt->setFailedRecipients($failedRecipients); + $this->_eventDispatcher->dispatchEvent($evt, 'sendPerformed'); + } + } else { + $failedRecipients = array_merge( + $failedRecipients, + array_keys((array) $message->getTo()), + array_keys((array) $message->getCc()), + array_keys((array) $message->getBcc()) + ); + + if ($evt) { + $evt->setResult(Swift_Events_SendEvent::RESULT_FAILED); + $evt->setFailedRecipients($failedRecipients); + $this->_eventDispatcher->dispatchEvent($evt, 'sendPerformed'); + } + + $message->generateId(); + + $count = 0; + } + + return $count; + } + + /** + * Register a plugin. + * + * @param Swift_Events_EventListener $plugin + */ + public function registerPlugin(Swift_Events_EventListener $plugin) + { + $this->_eventDispatcher->bindEventListener($plugin); + } + + /** Throw a TransportException, first sending it to any listeners */ + protected function _throwException(Swift_TransportException $e) + { + if ($evt = $this->_eventDispatcher->createTransportExceptionEvent($this, $e)) { + $this->_eventDispatcher->dispatchEvent($evt, 'exceptionThrown'); + if (!$evt->bubbleCancelled()) { + throw $e; + } + } else { + throw $e; + } + } + + /** Determine the best-use reverse path for this message */ + private function _getReversePath(Swift_Message $message) + { + $return = $message->getReturnPath(); + // casting to array to fixed incorrect PHPDOC in Swift_Mime_SimpleMessage which specifies @string + $sender = (array) $message->getSender(); + $from = $message->getFrom(); + $path = null; + if (!empty($return)) { + $path = $return; + } elseif (!empty($sender)) { + $keys = array_keys($sender); + $path = array_shift($keys); + } elseif (!empty($from)) { + $keys = array_keys($from); + $path = array_shift($keys); + } + + return $path; + } + + /** + * Fix CVE-2016-10074 by disallowing potentially unsafe shell characters. + * + * Note that escapeshellarg and escapeshellcmd are inadequate for our purposes, especially on Windows. + * + * @param string $string The string to be validated + * + * @return bool + */ + private function _isShellSafe($string) + { + // Future-proof + if (escapeshellcmd($string) !== $string || !in_array(escapeshellarg($string), ["'$string'", "\"$string\""])) { + return false; + } + + $length = strlen($string); + for ($i = 0; $i < $length; ++$i) { + $c = $string[$i]; + // All other characters have a special meaning in at least one common shell, including = and +. + // Full stop (.) has a special meaning in cmd.exe, but its impact should be negligible here. + // Note that this does permit non-Latin alphanumeric characters based on the current locale. + if (!ctype_alnum($c) && strpos('@_-.', $c) === false) { + return false; + } + } + + return true; + } + + /** + * Return php mail extra params to use for invoker->mail. + * + * @param $extraParams + * @param $reversePath + * + * @return string|null + */ + private function _formatExtraParams($extraParams, $reversePath) + { + if (false !== strpos($extraParams, '-f%s')) { + if (empty($reversePath) || false === $this->_isShellSafe($reversePath)) { + $extraParams = str_replace('-f%s', '', $extraParams); + } else { + $extraParams = sprintf($extraParams, $reversePath); + } + } + + return !empty($extraParams) ? $extraParams : null; + } + + /** + * {@inheritdoc} + */ + public function ping() + { + } +} diff --git a/thirdparty/swiftmailer/Swift/Transport/SimpleMailInvoker.php b/thirdparty/swiftmailer/Swift/Transport/SimpleMailInvoker.php new file mode 100644 index 000000000..cd7ef6c7b --- /dev/null +++ b/thirdparty/swiftmailer/Swift/Transport/SimpleMailInvoker.php @@ -0,0 +1,47 @@ +