Skip to content
Snippets Groups Projects
Verified Commit 2565825a authored by Dave Long's avatar Dave Long
Browse files

Issue #3165762 by znerol, Berdir, jungle, AdamPS, longwave, smustgrave,...

Issue #3165762 by znerol, Berdir, jungle, AdamPS, longwave, smustgrave, imclean, catch, dpi: Add symfony/mailer into core
parent ec92d1f8
No related branches found
No related tags found
20 merge requests!8376Drupal views: adding more granularity to the ‘use ajax’ functionality,!8300Issue #3443586 View area displays even when parent view has no results.,!7567Issue #3153723 by quietone, Hardik_Patel_12: Change the scaffolding...,!7565Issue #3153723 by quietone, Hardik_Patel_12: Change the scaffolding...,!7509Change label "Block description" to "Block type",!7344Issue #3292350 by O'Briat, KlemenDEV, hswong3i, smustgrave, quietone: Update...,!6922Issue #3412959 by quietone, smustgrave, longwave: Fix 12 'un' words,!6848Issue #3417553 by longwave: Remove withConsecutive() in CacheCollectorTest,!6720Revert "Issue #3358581 by pfrenssen, _tarik_, a.dmitriiev, smustgrave:...,!6560Update ClaroPreRender.php, confirming classes provided are in array format,!6528Issue #3414261 by catch: Add authenticated user umami performance tests,!6501Issue #3263668 by omkar-pd, Wim Leers, hooroomoo: Re-enable inline form errors...,!6354Draft: Issue #3380392 by phma: Updating language weight from the overview reverts label if translated,!6324Issue #3416723 by Ludo.R: Provide a "node type" views default argument,!6119Issue #3405704 by Spokje, longwave: symfony/psr-http-message-bridge major version bump,!5950Issue #3403653 by alexpott, longwave: Incorporate improvements to how contrib runs PHPStan to core,!5858Issue #3401971 by fjgarlin: Test-only job shouldn't require constant rebases...,!5716Draft: Issue #3401102 by Spokje, longwave, smustgrave: Nightwatch artifacts on GitLab not retained,!5674Transaction autocommit during shutdown relies on unreliable object destruction order,!5644Issue #3395563 by nireneko, marvil07, lauriii, borisson_, smustgrave, Wim...
Pipeline #34260 failed
Showing
with 372 additions and 0 deletions
......@@ -500,6 +500,9 @@ services:
logger.channel.security:
parent: logger.channel_base
arguments: ['security']
logger.channel.mail:
parent: logger.channel_base
arguments: ['mail']
logger.channel.menu:
parent: logger.channel_base
arguments: ['menu']
......
<?php
namespace Drupal\Core\Mail\Plugin\Mail;
use Drupal\Component\Render\MarkupInterface;
use Drupal\Core\Mail\MailFormatHelper;
use Drupal\Core\Mail\MailInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Utility\Error;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mailer\Transport;
use Symfony\Component\Mime\Email;
/**
* Defines an experimental mail backend, based on the Symfony mailer component.
*
* This mail plugin acts as a drop-in replacement for the current default PHP
* mail plugin. Mail delivery is based on the Symfony mailer component. Hence,
* all transports registered by default in the Symfony mailer transport factory
* are available via configurable DSN.
*
* By default, this plugin uses `sendmail://default` as the transport DSN. I.e.,
* it attempts to use `/usr/sbin/sendmail -bs` in order to submit a message to
* the MTA. Sites hosted on operating systems without a working MTA (e.g.,
* Windows) need to configure a suitable DSN.
*
* The DSN can be set via the `mailer_dsn` key of the `system.mailer` config.
*
* The following example shows how to switch the default mail plugin to the
* experimental Symfony mailer plugin with a custom DSN using config overrides
* in `settings.php`:
*
* @code
* $config['system.mail']['interface'] = [ 'default' => 'symfony_mailer' ];
* $config['system.mail']['mailer_dsn'] = 'smtp://user:pass@smtp.example.com:25';
* @endcode
*
* Note that special characters in the mailer_dsn need to be URL encoded.
*
* @see https://symfony.com/doc/current/mailer.html#using-built-in-transports
*
* @Mail(
* id = "symfony_mailer",
* label = @Translation("Symfony mailer (Experimental)"),
* )
*
* @internal
*/
class SymfonyMailer implements MailInterface, ContainerFactoryPluginInterface {
/**
* A list of headers that can contain multiple email addresses.
*
* @see \Symfony\Component\Mime\Header\Headers::HEADER_CLASS_MAP
*/
protected const MAILBOX_LIST_HEADERS = ['from', 'to', 'reply-to', 'cc', 'bcc'];
/**
* List of headers to skip copying from the message array.
*
* Symfony mailer sets Content-Type and Content-Transfer-Encoding according to
* the actual body content. Note that format=flowed is not supported by
* Symfony.
*
* @see \Symfony\Component\Mime\Part\TextPart
*/
protected const SKIP_HEADERS = ['content-type', 'content-transfer-encoding'];
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$container->get('logger.channel.mail')
);
}
/**
* Symfony mailer constructor.
*
* @param \Psr\Log\LoggerInterface $logger
* The logger service.
* @param \Symfony\Component\Mailer\MailerInterface $mailer
* The mailer service. Only specify an instance in unit tests, pass NULL in
* production.
*/
public function __construct(
protected LoggerInterface $logger,
protected ?MailerInterface $mailer = NULL) {
}
public function format(array $message) {
// Convert any HTML to plain-text.
foreach ($message['body'] as &$part) {
if ($part instanceof MarkupInterface) {
$part = MailFormatHelper::htmlToText($part);
}
else {
$part = MailFormatHelper::wrapMail($part);
}
}
// Join the body array into one string.
$message['body'] = implode("\n\n", $message['body']);
return $message;
}
public function mail(array $message) {
try {
$email = new Email();
$headers = $email->getHeaders();
foreach ($message['headers'] as $name => $value) {
if (!in_array(strtolower($name), self::SKIP_HEADERS, TRUE)) {
if (in_array(strtolower($name), self::MAILBOX_LIST_HEADERS, TRUE)) {
// Split values by comma, but ignore commas encapsulated in double
// quotes.
$value = str_getcsv($value, ',');
}
$headers->addHeader($name, $value);
}
}
$email
->to($message['to'])
->subject($message['subject'])
->text($message['body']);
$mailer = $this->getMailer();
$mailer->send($email);
return TRUE;
}
catch (\Exception $e) {
Error::logException($this->logger, $e);
return FALSE;
}
}
/**
* Returns a minimalistic Symfony mailer service.
*/
protected function getMailer(): MailerInterface {
if (!isset($this->mailer)) {
$dsn = \Drupal::config('system.mail')->get('mailer_dsn');
// Symfony Mailer and Transport classes both optionally depend on the
// event dispatcher. When provided, a MessageEvent is fired whenever an
// email is prepared before sending.
//
// The MessageEvent will likely play an important role in an upcoming mail
// API. However, emails handled by this plugin already were processed by
// hook_mail and hook_mail_alter. Firing the MessageEvent would leak those
// mails into the code path (i.e., event subscribers) of the new API.
// Therefore, this plugin deliberately refrains from injecting the event
// dispatcher.
$transport = Transport::fromDsn($dsn, logger: $this->logger);
$this->mailer = new Mailer($transport);
}
return $this->mailer;
}
}
......@@ -327,6 +327,7 @@ protected function initConfig(ContainerInterface $container) {
// some tests expect to be able to test mail system implementations.
$config->getEditable('system.mail')
->set('interface.default', 'test_mail_collector')
->set('mailer_dsn', 'null://null')
->save();
// By default, verbosely display all errors and disable all production
......
......@@ -65,6 +65,10 @@ public function useTestMailCollector() {
'value' => 'test_mail_collector',
'required' => TRUE,
];
$settings['config']['system.mail']['mailer_dsn'] = (object) [
'value' => 'null://null',
'required' => TRUE,
];
$this->writeSettings($settings);
}
......
interface:
default: 'php_mail'
mailer_dsn: "sendmail://default"
......@@ -302,6 +302,9 @@ system.mail:
sequence:
type: string
label: 'Interface'
mailer_dsn:
type: string
label: 'Symfony mailer transport DSN'
system.theme.global:
type: theme_settings
......
......@@ -15,6 +15,12 @@ process:
map:
DefaultMailSystem: php_mail
MailTestCase: test_mail_collector
'mailer_dsn':
plugin: static_map
source: 'mail_system/default-system'
map:
DefaultMailSystem: 'sendmail://default'
MailTestCase: 'null://null'
destination:
plugin: config
config_name: system.mail
......@@ -158,3 +158,11 @@ function system_post_update_set_blank_log_url_to_null() {
->save(TRUE);
}
}
/**
* Add new default mail transport dsn.
*/
function system_post_update_mailer_dsn_settings() {
$config = \Drupal::configFactory()->getEditable('system.mail');
$config->set('mailer_dsn', 'sendmail://default')->save();
}
<?php
namespace Drupal\Tests\system\Functional\Update;
use Drupal\FunctionalTests\Update\UpdatePathTestBase;
/**
* Tests creation of default mail transport dsn settings.
*
* @see system_post_update_mailer_dsn_settings()
*
* @group Update
*/
class MailDsnSettingsUpdateTest extends UpdatePathTestBase {
/**
* {@inheritdoc}
*/
protected function setDatabaseDumpFiles() {
$this->databaseDumpFiles = [
__DIR__ . '/../../../fixtures/update/drupal-9.4.0.bare.standard.php.gz',
];
}
/**
* Tests system_post_update_mailer_dsn_settings().
*/
public function testSystemPostUpdateMailerDsnSettings() {
$this->runUpdates();
// Confirm that config was created.
$config = $this->config('system.mail');
$this->assertEquals('sendmail://default', $config->get('mailer_dsn'));
}
}
......@@ -59,6 +59,7 @@ class MigrateSystemConfigurationTest extends MigrateDrupal7TestBase {
'interface' => [
'default' => 'php_mail',
],
'mailer_dsn' => 'sendmail://default',
],
'system.maintenance' => [
// langcode is not handled by the migration.
......
......@@ -197,6 +197,7 @@ protected function setUp(): void {
$this->container->get('config.factory')
->getEditable('system.mail')
->set('interface.default', 'test_mail_collector')
->set('mailer_dsn', 'null://null')
->save();
$this->installDefaultThemeFromClassProperty($this->container);
......
......@@ -430,6 +430,7 @@ protected function bootKernel() {
// While this should be enforced via settings.php prior to installation,
// some tests expect to be able to test mail system implementations.
$GLOBALS['config']['system.mail']['interface']['default'] = 'test_mail_collector';
$GLOBALS['config']['system.mail']['mailer_dsn'] = 'null://null';
// Manually configure the default file scheme so that modules that use file
// functions don't have to install system and its configuration.
......
......@@ -119,6 +119,7 @@ protected function setUpMailManager($interface = []) {
$this->configFactory = $this->getConfigFactoryStub([
'system.mail' => [
'interface' => $interface,
'mailer_dsn' => 'null://null',
],
'system.site' => [
'mail' => 'test@example.com',
......
......@@ -46,6 +46,7 @@ protected function setUp(): void {
$this->configFactory = $this->getConfigFactoryStub([
'system.mail' => [
'interface' => [],
'mailer_dsn' => 'null://null',
],
'system.site' => [
'mail' => 'test@example.com',
......
<?php
namespace Drupal\Tests\Core\Mail\Plugin\Mail;
use Drupal\Component\Render\FormattableMarkup;
use Drupal\Core\Mail\MailFormatHelper;
use Drupal\Core\Mail\Plugin\Mail\SymfonyMailer;
use Drupal\Tests\UnitTestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
/**
* @coversDefaultClass \Drupal\Core\Mail\Plugin\Mail\SymfonyMailer
* @group Mail
*/
class SymfonyMailerTest extends UnitTestCase {
/**
* Tests that mixed plain text and html body is converted correctly.
*
* @covers ::format
*/
public function testFormatResemblesHtml() {
// Populate global $base_path to avoid notices generated by
// MailFormatHelper::htmlToMailUrls()
global $base_path;
$original_base_path = $base_path;
$base_path = '/';
$variables = [
'@form-url' => 'https://www.example.com/contact',
'@sender-url' => 'https://www.example.com/user/123',
'@sender-name' => $this->randomString(),
];
$plain = "In HTML, ampersand must be written as &amp;.\nI saw your house and <wow> it is great. There is too much to say about that beautiful building, it will never fit on one line of text.\nIf a<b and b<c then a<c.";
$template = "@sender-name (@sender-url) sent a message using the contact form at @form-url.";
$markup = new FormattableMarkup($template, $variables);
$message = [
'body' => [
$plain,
$markup,
],
];
/** @var \Symfony\Component\Mailer\MailerInterface|\PHPUnit\Framework\MockObject\MockObject */
$mailer = $this->getMockBuilder(MailerInterface::class)->getMock();
/** @var \Psr\Log\LoggerInterface|\PHPUnit\Framework\MockObject\MockObject */
$logger = $this->getMockBuilder(LoggerInterface::class)->getMock();
$plugin = new SymfonyMailer($logger, $mailer);
$message = $plugin->format($message);
$expect = MailFormatHelper::wrapMail($plain . "\n\n" . strtr($template, $variables) . "\n");
$this->assertEquals($expect, $message['body']);
$base_path = $original_base_path;
}
/**
* Tests sending a mail using a From address with a comma in it.
*
* @covers ::mail
*/
public function testMail() {
// Setup a mail message.
$message = [
'id' => 'example_key',
'module' => 'example',
'key' => 'key',
'to' => 'to@example.org',
'from' => 'from@example.org',
'reply-to' => 'from@example.org',
'langcode' => 'en',
'params' => [],
'send' => TRUE,
'subject' => "test\r\nsubject",
'body' => '',
'headers' => [
'MIME-Version' => '1.0',
'Content-Type' => 'text/plain; charset=UTF-8; format=flowed; delsp=yes',
'Content-Transfer-Encoding' => '8Bit',
'X-Mailer' => 'Drupal',
'From' => '"Foo, Bar, and Baz" <from@example.org>',
'Reply-to' => 'from@example.org',
'Return-Path' => 'from@example.org',
],
];
// Verify we use line endings consistent with the PHP mail() function, which
// changed with PHP 8. See:
// - https://www.drupal.org/node/3270647
// - https://bugs.php.net/bug.php?id=81158
$line_end = "\r\n";
/** @var \Symfony\Component\Mailer\MailerInterface|\PHPUnit\Framework\MockObject\MockObject */
$mailer = $this->getMockBuilder(MailerInterface::class)->getMock();
$mailer->expects($this->once())->method('send')
->with(
$this->logicalAnd(
$this->callback(fn (Email $email) =>
$email->getHeaders()->get('mime-version')->getBodyAsString() === '1.0'
),
$this->callback(fn (Email $email) =>
$email->getHeaders()->has('content-type') === FALSE
),
$this->callback(fn (Email $email) =>
$email->getHeaders()->has('content-transfer-encoding') === FALSE
),
$this->callback(fn (Email $email) =>
$email->getHeaders()->get('x-mailer')->getBodyAsString() === 'Drupal'
),
$this->callback(fn (Email $email) =>
$email->getHeaders()->get('from')->getBodyAsString() === '"Foo, Bar, and Baz" <from@example.org>'
),
$this->callback(fn (Email $email) =>
$email->getHeaders()->get('reply-to')->getBodyAsString() === 'from@example.org'
),
$this->callback(fn (Email $email) =>
$email->getHeaders()->get('to')->getBodyAsString() === 'to@example.org'
),
$this->callback(fn (Email $email) =>
$email->getHeaders()->get('subject')->getBodyAsString() === "=?utf-8?Q?test?=$line_end =?utf-8?Q?subject?="
),
$this->callback(fn (Email $email) =>
$email->getTextBody() === ''
)
)
);
/** @var \Psr\Log\LoggerInterface|\PHPUnit\Framework\MockObject\MockObject */
$logger = $this->getMockBuilder(LoggerInterface::class)->getMock();
$plugin = new SymfonyMailer($logger, $mailer);
$this->assertTrue($plugin->mail($message));
}
}
  • znerol @znerol

    mentioned in merge request !5062

    ·

    mentioned in merge request !5062

    Toggle commit list
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment