feat: email recipient whitelist
This commit is contained in:
1 parent
4cab57d136
commit
7cf39850e6
4 files changed
+308
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user