feat: email recipient whitelist

This commit is contained in:
fromme committed 2026-09-26 12:13:37 +02:00
1 parent 4cab57d136
commit 7cf39850e6
4 files changed
+308

No files matched your search

+8
View File
@@ -70,6 +70,14 @@ APP_BPN_CRM_ID_TEAMER=1070
APP_DEFAULT_EMAIL_FROM=[email protected]
APP_DEFAULT_EMAIL_TO=[email protected]
# Regular expressions of the only recipients a mail may actually reach. Everything else is
# stripped from the envelope and a mail left without a recipient is not sent at all, see
# App\EventListener\EmailRecipientWhitelistSubscriber. Empty means no filtering, which is what
# production runs with. Set this on staging: it runs the real transports against real teamer
# addresses. The patterns have to match the full address.
# Example: APP_MAIL_RECIPIENT_WHITELIST='.*@ep-reisen\.de,qa@example\.com'
APP_MAIL_RECIPIENT_WHITELIST=
SFTP_XML_EXPORT_HOST=
SFTP_XML_EXPORT_PORT=22
SFTP_XML_EXPORT_USER=
+13
View File
@@ -48,6 +48,12 @@ parameters:
default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%'
default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%'
# Per deployment and not per environment: staging and production run the same compiled
# environment and differ only in their .env.local. The csv processor turns an unset
# variable into an empty list, which switches the filtering off - so production is
# unaffected without anything having to be remembered at deploy time.
mail_recipient_whitelist: '%env(csv:APP_MAIL_RECIPIENT_WHITELIST)%'
teamer_inactive_period: '-2 years'
# The stand-in PDF served instead of real uploads while the upload_replacement feature is
@@ -362,6 +368,13 @@ services:
$logger: '@monolog.logger.api'
tags: [ 'controller.service_arguments' ]
App\EventListener\EmailRecipientWhitelistSubscriber:
arguments:
$allowedRecipients: '%mail_recipient_whitelist%'
# Not the audit channel this argument is bound to by default: a mail dropped by a
# staging guard is a technical event and has no place in the application log table.
$logger: '@logger'
App\Security\OAuth2\MyEpClient:
arguments:
$options:
@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace App\EventListener;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Mailer\Event\MessageEvent;
use Symfony\Component\Mime\Address;
/**
* Keeps mail away from real people on deployments that are not production.
*
* Staging runs the real transports against a copy of the production data, so every recipient
* in that database is a real address. With a whitelist configured, an envelope is reduced to
* the recipients it allows and a mail left without any recipient is not sent at all.
*
* An empty whitelist means "do nothing", never "drop everything": production runs with the
* environment variable unset and is therefore unaffected by this class. This is a deployment
* guard - no business rule decides who may be mailed here.
*
* Symfony's own framework.mailer.envelope handling cannot do this: it redirects everything to
* a catch-all address, and its allowed_recipients only adds the real recipient back on top.
*/
class EmailRecipientWhitelistSubscriber implements EventSubscriberInterface
{
/**
* @var string[]
*/
private readonly array $allowedRecipients;
/**
* @param string[] $allowedRecipients regular expressions matched against the full address
*
* @throws \InvalidArgumentException on a pattern that is not a valid regular expression;
* the service is lazy, so this surfaces on the first mail
*/
public function __construct(
array $allowedRecipients,
private readonly LoggerInterface $logger,
) {
// A blank entry - a stray space in a .env.local, a deploy template emitting an empty
// value - would otherwise make the whitelist non-empty while matching no address at
// all, which silently drops every mail. Dropped here, so "empty" stays "do nothing".
$patterns = array_values(array_filter(array_map(trim(...), $allowedRecipients), static fn (string $pattern) => '' !== $pattern));
foreach ($patterns as $pattern) {
// Checked once, here, rather than per address: an invalid pattern makes preg_match()
// return false for every address, which looks exactly like "nobody is whitelisted"
// and eats the mail. Blowing up on the first send is the louder failure.
if (false === @preg_match($this->toRegex($pattern), '')) {
throw new \InvalidArgumentException(\sprintf('Invalid recipient whitelist pattern "%s": it is not a valid regular expression.', $pattern));
}
}
$this->allowedRecipients = $patterns;
}
public static function getSubscribedEvents(): array
{
return [
// After MessageListener, which applies the configured default headers - a Bcc
// added there has to be filtered too - and before EnvelopeListener and
// MessageLoggerListener at -255, so a dropped mail is not recorded as sent.
MessageEvent::class => ['onMessage', -250],
];
}
public function onMessage(MessageEvent $event): void
{
if (!$this->allowedRecipients) {
return;
}
$envelope = $event->getEnvelope();
$recipients = $envelope->getRecipients();
$kept = [];
$stripped = [];
foreach ($recipients as $recipient) {
if ($this->isAllowed($recipient)) {
$kept[] = $recipient;
} else {
$stripped[] = $recipient->getAddress();
}
}
if (!$stripped) {
return;
}
if (!$kept) {
$this->logger->info('Mail dropped: no whitelisted recipient', [
'recipients' => $stripped,
'subject' => $this->subjectOf($event),
]);
$event->reject();
return;
}
$envelope->setRecipients($kept);
$this->logger->info('Mail recipients stripped: not whitelisted', [
'stripped' => $stripped,
'kept' => array_map(static fn (Address $address) => $address->getAddress(), $kept),
'subject' => $this->subjectOf($event),
]);
}
private function isAllowed(Address $recipient): bool
{
foreach ($this->allowedRecipients as $pattern) {
if (preg_match($this->toRegex($pattern), $recipient->getAddress())) {
return true;
}
}
return false;
}
/**
* Anchored the way Symfony\Component\Mailer\EventListener\EnvelopeListener anchors its own
* patterns: a pattern always has to match the whole address.
*/
private function toRegex(string $pattern): string
{
return '{\A'.$pattern.'\z}i';
}
private function subjectOf(MessageEvent $event): ?string
{
$message = $event->getMessage();
return method_exists($message, 'getSubject') ? $message->getSubject() : null;
}
}
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace App\Tests\EventListener;
use App\EventListener\EmailRecipientWhitelistSubscriber;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;
use Symfony\Component\Mailer\Envelope;
use Symfony\Component\Mailer\Event\MessageEvent;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
/**
* The guard that keeps staging from mailing real teamers: everything not whitelisted has to
* leave the envelope, and a mail with nothing left must not be sent at all.
*/
class EmailRecipientWhitelistSubscriberTest extends TestCase
{
public function testKeepsEnvelopeUntouchedWhenEveryRecipientIsWhitelisted(): void
{
$event = $this->event(['[email protected]', '[email protected]']);
$this->subscriber(['.*@ep-reisen\.de', 'qa@example\.com'])->onMessage($event);
self::assertFalse($event->isRejected());
self::assertSame(
['[email protected]', '[email protected]'],
$this->addresses($event)
);
}
public function testStripsTheRecipientsThatAreNotWhitelisted(): void
{
$event = $this->event(['[email protected]', '[email protected]', '[email protected]']);
$this->subscriber(['.*@ep-reisen\.de'])->onMessage($event);
self::assertFalse($event->isRejected());
self::assertSame(['[email protected]'], $this->addresses($event));
}
public function testRejectsTheMailWhenNoRecipientIsWhitelisted(): void
{
$event = $this->event(['[email protected]', '[email protected]']);
$this->subscriber(['.*@ep-reisen\.de'])->onMessage($event);
self::assertTrue($event->isRejected());
}
public function testDoesNothingWithoutAWhitelist(): void
{
$event = $this->event(['[email protected]']);
$this->subscriber([])->onMessage($event);
self::assertFalse($event->isRejected());
self::assertSame(['[email protected]'], $this->addresses($event));
}
public function testPatternsAreAnchoredToTheWholeAddress(): void
{
$event = $this->event(['[email protected]']);
$this->subscriber(['.*@ep-reisen\.de'])->onMessage($event);
self::assertTrue($event->isRejected());
}
public function testMatchingIsCaseInsensitive(): void
{
$event = $this->event(['[email protected]']);
$this->subscriber(['.*@ep-reisen\.de'])->onMessage($event);
self::assertFalse($event->isRejected());
self::assertSame(['[email protected]'], $this->addresses($event));
}
public function testBlankPatternsAreIgnoredInsteadOfDroppingEveryMail(): void
{
$event = $this->event(['[email protected]']);
$this->subscriber([' ', '', "\t"])->onMessage($event);
self::assertFalse($event->isRejected());
self::assertSame(['[email protected]'], $this->addresses($event));
}
public function testPatternsAreTrimmed(): void
{
$event = $this->event(['[email protected]', '[email protected]']);
$this->subscriber([' .*@ep-reisen\\.de '])->onMessage($event);
self::assertFalse($event->isRejected());
self::assertSame(['[email protected]'], $this->addresses($event));
}
public function testAnInvalidPatternFailsLoudlyOnConstruction(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Invalid recipient whitelist pattern ".*@ep-reisen\\.de("');
$this->subscriber(['.*@ep-reisen\\.de(']);
}
/**
* @param string[] $allowedRecipients
*/
private function subscriber(array $allowedRecipients): EmailRecipientWhitelistSubscriber
{
return new EmailRecipientWhitelistSubscriber($allowedRecipients, new NullLogger());
}
/**
* @param string[] $recipients
*/
private function event(array $recipients): MessageEvent
{
$email = (new Email())
->from('[email protected]')
->to(...$recipients)
->subject('Honorarvertrag fällig')
->text('...')
;
return new MessageEvent(
$email,
new Envelope(new Address('[email protected]'), Address::createArray($recipients)),
'main'
);
}
/**
* @return string[]
*/
private function addresses(MessageEvent $event): array
{
return array_map(
static fn (Address $address) => $address->getAddress(),
$event->getEnvelope()->getRecipients()
);
}
}