[FrameworkBundle][Mailer] Add a way to configure some email headers from semantic configuration

This commit is contained in:
Fabien Potencier 2020-05-06 09:10:29 +02:00
parent 5a74790bfd
commit 805e9e62c1
15 changed files with 301 additions and 36 deletions

View File

@ -1492,6 +1492,7 @@ class Configuration implements ConfigurationInterface
->thenInvalid('"dsn" and "transports" cannot be used together.')
->end()
->fixXmlConfig('transport')
->fixXmlConfig('header')
->children()
->scalarNode('message_bus')->defaultNull()->info('The message bus to use. Defaults to the default bus if the Messenger component is installed.')->end()
->scalarNode('dsn')->defaultNull()->end()
@ -1515,6 +1516,20 @@ class Configuration implements ConfigurationInterface
->end()
->end()
->end()
->arrayNode('headers')
->normalizeKeys(false)
->useAttributeAsKey('name')
->prototype('array')
->normalizeKeys(false)
->beforeNormalization()
->ifTrue(function ($v) { return !\is_array($v) || array_keys($v) !== ['value']; })
->then(function ($v) { return ['value' => $v]; })
->end()
->children()
->variableNode('value')->end()
->end()
->end()
->end()
->end()
->end()
->end()

View File

@ -89,6 +89,7 @@ use Symfony\Component\Messenger\MessageBus;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Transport\TransportFactoryInterface;
use Symfony\Component\Messenger\Transport\TransportInterface;
use Symfony\Component\Mime\Header\Headers;
use Symfony\Component\Mime\MimeTypeGuesserInterface;
use Symfony\Component\Mime\MimeTypes;
use Symfony\Component\Notifier\Bridge\Firebase\FirebaseTransportFactory;
@ -1986,12 +1987,24 @@ class FrameworkExtension extends Extension
}
}
$recipients = $config['envelope']['recipients'] ?? null;
$sender = $config['envelope']['sender'] ?? null;
$envelopeListener = $container->getDefinition('mailer.envelope_listener');
$envelopeListener->setArgument(0, $sender);
$envelopeListener->setArgument(1, $recipients);
$envelopeListener->setArgument(0, $config['envelope']['sender'] ?? null);
$envelopeListener->setArgument(1, $config['envelope']['recipients'] ?? null);
if ($config['headers']) {
$headers = new Definition(Headers::class);
foreach ($config['headers'] as $name => $data) {
$value = $data['value'];
if (\in_array(strtolower($name), ['from', 'to', 'cc', 'bcc', 'reply-to'])) {
$value = (array) $value;
}
$headers->addMethodCall('addHeader', [$name, $value]);
}
$messageListener = $container->getDefinition('mailer.message_listener');
$messageListener->setArgument(0, $headers);
} else {
$container->removeDefinition('mailer.message_listener');
}
}
private function registerNotifierConfiguration(array $config, ContainerBuilder $container, XmlFileLoader $loader)

View File

@ -39,6 +39,11 @@
<tag name="kernel.event_subscriber"/>
</service>
<service id="mailer.message_listener" class="Symfony\Component\Mailer\EventListener\MessageListener">
<argument /> <!-- headers -->
<tag name="kernel.event_subscriber"/>
</service>
<service id="mailer.logger_message_listener" class="Symfony\Component\Mailer\EventListener\MessageLoggerListener">
<tag name="kernel.event_subscriber"/>
</service>

View File

@ -560,14 +560,19 @@
<xsd:complexType name="mailer">
<xsd:sequence>
<xsd:element name="envelope" type="mailer_envelope" minOccurs="0" maxOccurs="1" />
<xsd:element name="header" type="header" minOccurs="0" maxOccurs="unbounded" />
</xsd:sequence>
<xsd:attribute name="dsn" type="xsd:string" />
<xsd:attribute name="message-bus" type="xsd:string" />
</xsd:complexType>
<xsd:complexType name="header" mixed="true">
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
<xsd:complexType name="mailer_envelope">
<xsd:sequence>
<xsd:element name="sender" type="xsd:string" />
<xsd:element name="sender" type="xsd:string" minOccurs="0" maxOccurs="1" />
<xsd:element name="recipients" type="xsd:string" minOccurs="0" maxOccurs="unbounded" />
</xsd:sequence>
</xsd:complexType>

View File

@ -493,6 +493,7 @@ class ConfigurationTest extends TestCase
'transports' => [],
'enabled' => !class_exists(FullStack::class) && class_exists(Mailer::class),
'message_bus' => null,
'headers' => [],
],
'notifier' => [
'enabled' => !class_exists(FullStack::class) && class_exists(Notifier::class),

View File

@ -7,5 +7,10 @@ $container->loadFromExtension('framework', [
'sender' => 'sender@example.org',
'recipients' => ['redirected@example.org', 'redirected1@example.org'],
],
'headers' => [
'from' => 'from@example.org',
'bcc' => ['bcc1@example.org', 'bcc2@example.org'],
'foo' => 'bar',
],
],
]);

View File

@ -13,6 +13,9 @@
<framework:recipients>redirected@example.org</framework:recipients>
<framework:recipients>redirected1@example.org</framework:recipients>
</framework:envelope>
<framework:header name="from">from@example.org</framework:header>
<framework:header name="bcc">bcc1@example.org</framework:header>
<framework:header name="foo">bar</framework:header>
</framework:mailer>
</framework:config>
</container>

View File

@ -6,3 +6,7 @@ framework:
recipients:
- redirected@example.org
- redirected1@example.org
headers:
from: from@example.org
bcc: [bcc1@example.org, bcc2@example.org]
foo: bar

View File

@ -1435,6 +1435,11 @@ abstract class FrameworkExtensionTest extends TestCase
$this->assertSame('sender@example.org', $l->getArgument(0));
$this->assertSame(['redirected@example.org', 'redirected1@example.org'], $l->getArgument(1));
$this->assertEquals(new Reference('messenger.default_bus', ContainerInterface::NULL_ON_INVALID_REFERENCE), $container->getDefinition('mailer.mailer')->getArgument(1));
$this->assertTrue($container->hasDefinition('mailer.message_listener'));
$l = $container->getDefinition('mailer.message_listener');
$h = $l->getArgument(0);
$this->assertCount(3, $h->getMethodCalls());
}
public function testMailerWithDisabledMessageBus(): void

View File

@ -45,7 +45,7 @@
"symfony/expression-language": "^4.4|^5.0",
"symfony/http-client": "^4.4|^5.0",
"symfony/lock": "^4.4|^5.0",
"symfony/mailer": "^4.4|^5.0",
"symfony/mailer": "^5.2",
"symfony/messenger": "^4.4|^5.0",
"symfony/mime": "^4.4|^5.0",
"symfony/process": "^4.4|^5.0",
@ -79,7 +79,7 @@
"symfony/http-client": "<4.4",
"symfony/form": "<4.4",
"symfony/lock": "<4.4",
"symfony/mailer": "<4.4",
"symfony/mailer": "<5.2",
"symfony/messenger": "<4.4",
"symfony/mime": "<4.4",
"symfony/property-info": "<4.4",

View File

@ -13,8 +13,11 @@ namespace Symfony\Component\Mailer\EventListener;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Mailer\Event\MessageEvent;
use Symfony\Component\Mailer\Exception\InvalidArgumentException;
use Symfony\Component\Mailer\Exception\RuntimeException;
use Symfony\Component\Mime\BodyRendererInterface;
use Symfony\Component\Mime\Header\Headers;
use Symfony\Component\Mime\Header\MailboxListHeader;
use Symfony\Component\Mime\Message;
/**
@ -24,13 +27,38 @@ use Symfony\Component\Mime\Message;
*/
class MessageListener implements EventSubscriberInterface
{
public const HEADER_SET_IF_EMPTY = 1;
public const HEADER_ADD = 2;
public const HEADER_REPLACE = 3;
public const DEFAULT_RULES = [
'from' => self::HEADER_SET_IF_EMPTY,
'return-path' => self::HEADER_SET_IF_EMPTY,
'reply-to' => self::HEADER_ADD,
'to' => self::HEADER_SET_IF_EMPTY,
'cc' => self::HEADER_ADD,
'bcc' => self::HEADER_ADD,
];
private $headers;
private $headerRules = [];
private $renderer;
public function __construct(Headers $headers = null, BodyRendererInterface $renderer = null)
public function __construct(Headers $headers = null, BodyRendererInterface $renderer = null, array $headerRules = self::DEFAULT_RULES)
{
$this->headers = $headers;
$this->renderer = $renderer;
foreach ($headerRules as $headerName => $rule) {
$this->addHeaderRule($headerName, $rule);
}
}
public function addHeaderRule(string $headerName, int $rule): void
{
if ($rule < 1 || $rule > 3) {
throw new InvalidArgumentException(sprintf('The "%d" rule is not supported.', $rule));
}
$this->headerRules[$headerName] = $rule;
}
public function onMessage(MessageEvent $event): void
@ -54,14 +82,38 @@ class MessageListener implements EventSubscriberInterface
foreach ($this->headers->all() as $name => $header) {
if (!$headers->has($name)) {
$headers->add($header);
} else {
if (Headers::isUniqueHeader($name)) {
continue;
}
$headers->add($header);
continue;
}
switch ($this->headerRules[$name] ?? self::HEADER_SET_IF_EMPTY) {
case self::HEADER_SET_IF_EMPTY:
break;
case self::HEADER_REPLACE:
$headers->remove($name);
$headers->add($header);
break;
case self::HEADER_ADD:
if (!Headers::isUniqueHeader($name)) {
$headers->add($header);
break;
}
$h = $headers->get($name);
if (!$h instanceof MailboxListHeader) {
throw new RuntimeException(sprintf('Unable to set header "%s".', $name));
}
Headers::checkHeaderClass($header);
foreach ($header->getAddresses() as $address) {
$h->addAddress($address);
}
}
}
$message->setHeaders($headers);
}
private function renderMessage(Message $message): void

View File

@ -0,0 +1,105 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Mailer\Tests;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Mailer\Envelope;
use Symfony\Component\Mailer\Event\MessageEvent;
use Symfony\Component\Mailer\EventListener\MessageListener;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Header\Headers;
use Symfony\Component\Mime\Header\MailboxListHeader;
use Symfony\Component\Mime\Header\UnstructuredHeader;
use Symfony\Component\Mime\Message;
class MessageListenerTest extends TestCase
{
/**
* @dataProvider provideHeaders
*/
public function testHeaders(Headers $initialHeaders, Headers $defaultHeaders, Headers $expectedHeaders, array $rules = MessageListener::DEFAULT_RULES)
{
$message = new Message($initialHeaders);
$listener = new MessageListener($defaultHeaders, null, $rules);
$event = new MessageEvent($message, new Envelope(new Address('sender@example.com'), [new Address('recipient@example.com')]), 'smtp');
$listener->onMessage($event);
$this->assertEquals($expectedHeaders, $event->getMessage()->getHeaders());
}
public function provideHeaders(): iterable
{
$initialHeaders = new Headers();
$defaultHeaders = (new Headers())
->add(new MailboxListHeader('from', [new Address('from-default@example.com')]))
;
yield 'No defaults, all headers copied over' => [$initialHeaders, $defaultHeaders, $defaultHeaders];
$initialHeaders = new Headers();
$defaultHeaders = (new Headers())
->add(new UnstructuredHeader('foo', 'bar'))
->add(new UnstructuredHeader('bar', 'foo'))
;
yield 'No defaults, default is to set if empty' => [$initialHeaders, $defaultHeaders, $defaultHeaders];
$initialHeaders = (new Headers())
->add(new UnstructuredHeader('foo', 'initial'))
;
$defaultHeaders = (new Headers())
->add(new UnstructuredHeader('foo', 'bar'))
->add(new UnstructuredHeader('bar', 'foo'))
;
$expectedHeaders = (new Headers())
->add(new UnstructuredHeader('foo', 'initial'))
->add(new UnstructuredHeader('bar', 'foo'))
;
yield 'Some defaults, default is to set if empty' => [$initialHeaders, $defaultHeaders, $expectedHeaders];
$initialHeaders = (new Headers())
->add(new UnstructuredHeader('foo', 'initial'))
;
$defaultHeaders = (new Headers())
->add(new UnstructuredHeader('foo', 'bar'))
->add(new UnstructuredHeader('bar', 'foo'))
;
$rules = [
'foo' => MessageListener::HEADER_REPLACE,
];
yield 'Some defaults, replace if set' => [$initialHeaders, $defaultHeaders, $defaultHeaders, $rules];
$initialHeaders = (new Headers())
->add(new UnstructuredHeader('foo', 'bar'))
;
$defaultHeaders = (new Headers())
->add(new UnstructuredHeader('foo', 'foo'))
;
$expectedHeaders = (new Headers())
->add(new UnstructuredHeader('foo', 'bar'))
->add(new UnstructuredHeader('foo', 'foo'))
;
$rules = [
'foo' => MessageListener::HEADER_ADD,
];
yield 'Some defaults, add if set (not unique header)' => [$initialHeaders, $defaultHeaders, $expectedHeaders, $rules];
$initialHeaders = (new Headers())
->add(new MailboxListHeader('bcc', [new Address('bcc-initial@example.com')]))
;
$defaultHeaders = (new Headers())
->add(new MailboxListHeader('bcc', [new Address('bcc-default@example.com'), new Address('bcc-default-1@example.com')]))
;
$expectedHeaders = (new Headers())
->add(new MailboxListHeader('bcc', [new Address('bcc-initial@example.com'), new Address('bcc-default@example.com'), new Address('bcc-default-1@example.com')]))
;
yield 'bcc, add another bcc (unique header)' => [$initialHeaders, $defaultHeaders, $expectedHeaders];
}
}

View File

@ -20,7 +20,7 @@
"egulias/email-validator": "^2.1.10",
"psr/log": "~1.0",
"symfony/event-dispatcher": "^4.4|^5.0",
"symfony/mime": "^4.4|^5.0",
"symfony/mime": "^5.2",
"symfony/polyfill-php80": "^1.15",
"symfony/service-contracts": "^1.1|^2"
},

View File

@ -21,10 +21,23 @@ use Symfony\Component\Mime\Exception\LogicException;
*/
final class Headers
{
private static $uniqueHeaders = [
private const UNIQUE_HEADERS = [
'date', 'from', 'sender', 'reply-to', 'to', 'cc', 'bcc',
'message-id', 'in-reply-to', 'references', 'subject',
];
private const HEADER_CLASS_MAP = [
'date' => DateHeader::class,
'from' => MailboxListHeader::class,
'sender' => MailboxHeader::class,
'reply-to' => MailboxListHeader::class,
'to' => MailboxListHeader::class,
'cc' => MailboxListHeader::class,
'bcc' => MailboxListHeader::class,
'message-id' => IdentificationHeader::class,
'in-reply-to' => IdentificationHeader::class,
'references' => IdentificationHeader::class,
'return-path' => PathHeader::class,
];
private $headers = [];
private $lineLength = 76;
@ -122,6 +135,22 @@ final class Headers
return $this->add(new ParameterizedHeader($name, $value, $params));
}
/**
* @return $this
*/
public function addHeader(string $name, $argument, array $more = []): self
{
$parts = explode('\\', self::HEADER_CLASS_MAP[$name] ?? UnstructuredHeader::class);
$method = 'add'.ucfirst(array_pop($parts));
if ('addUnstructuredHeader' === $method) {
$method = 'addTextHeader';
} elseif ('addIdentificationHeader' === $method) {
$method = 'addIdHeader';
}
return $this->$method($name, $argument, $more);
}
public function has(string $name): bool
{
return isset($this->headers[strtolower($name)]);
@ -132,28 +161,12 @@ final class Headers
*/
public function add(HeaderInterface $header): self
{
static $map = [
'date' => DateHeader::class,
'from' => MailboxListHeader::class,
'sender' => MailboxHeader::class,
'reply-to' => MailboxListHeader::class,
'to' => MailboxListHeader::class,
'cc' => MailboxListHeader::class,
'bcc' => MailboxListHeader::class,
'message-id' => IdentificationHeader::class,
'in-reply-to' => IdentificationHeader::class,
'references' => IdentificationHeader::class,
'return-path' => PathHeader::class,
];
self::checkHeaderClass($header);
$header->setMaxLineLength($this->lineLength);
$name = strtolower($header->getName());
if (isset($map[$name]) && !$header instanceof $map[$name]) {
throw new LogicException(sprintf('The "%s" header must be an instance of "%s" (got "%s").', $header->getName(), $map[$name], get_debug_type($header)));
}
if (\in_array($name, self::$uniqueHeaders, true) && isset($this->headers[$name]) && \count($this->headers[$name]) > 0) {
if (\in_array($name, self::UNIQUE_HEADERS, true) && isset($this->headers[$name]) && \count($this->headers[$name]) > 0) {
throw new LogicException(sprintf('Impossible to set header "%s" as it\'s already defined and must be unique.', $header->getName()));
}
@ -201,7 +214,19 @@ final class Headers
public static function isUniqueHeader(string $name): bool
{
return \in_array($name, self::$uniqueHeaders, true);
return \in_array($name, self::UNIQUE_HEADERS, true);
}
/**
* @throws LogicException if the header name and class are not compatible
*/
public static function checkHeaderClass(HeaderInterface $header): void
{
$name = strtolower($header->getName());
if (($c = self::HEADER_CLASS_MAP[$name] ?? null) && !$header instanceof $c) {
throw new LogicException(sprintf('The "%s" header must be an instance of "%s" (got "%s").', $header->getName(), $c, get_debug_type($header)));
}
}
public function toString(): string

View File

@ -13,9 +13,11 @@ namespace Symfony\Component\Mime\Tests\Header;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Header\DateHeader;
use Symfony\Component\Mime\Header\Headers;
use Symfony\Component\Mime\Header\IdentificationHeader;
use Symfony\Component\Mime\Header\MailboxListHeader;
use Symfony\Component\Mime\Header\PathHeader;
use Symfony\Component\Mime\Header\UnstructuredHeader;
class HeadersTest extends TestCase
@ -63,6 +65,31 @@ class HeadersTest extends TestCase
$this->assertNotNull($headers->get('Return-Path'));
}
public function testAddHeader()
{
$headers = new Headers();
$headers->addHeader('from', ['from@example.com']);
$headers->addHeader('return-path', 'return@example.com');
$headers->addHeader('foo', 'bar');
$headers->addHeader('date', $now = new \DateTimeImmutable());
$headers->addHeader('message-id', 'id@id');
$this->assertInstanceOf(MailboxListHeader::class, $headers->get('from'));
$this->assertEquals([new Address('from@example.com')], $headers->get('from')->getBody());
$this->assertInstanceOf(PathHeader::class, $headers->get('return-path'));
$this->assertEquals(new Address('return@example.com'), $headers->get('return-path')->getBody());
$this->assertInstanceOf(UnstructuredHeader::class, $headers->get('foo'));
$this->assertSame('bar', $headers->get('foo')->getBody());
$this->assertInstanceOf(DateHeader::class, $headers->get('date'));
$this->assertSame($now, $headers->get('date')->getBody());
$this->assertInstanceOf(IdentificationHeader::class, $headers->get('message-id'));
$this->assertSame(['id@id'], $headers->get('message-id')->getBody());
}
public function testHasReturnsFalseWhenNoHeaders()
{
$headers = new Headers();