feat: mailing to filtered teamer list

addresses #869dv9bur
This commit is contained in:
Björn Fromme
2026-08-11 16:25:53 +02:00
parent 48cd2d6b67
commit 6511f821ac
18 changed files with 1223 additions and 7 deletions
+186
View File
@@ -0,0 +1,186 @@
<?php
namespace App\Service\Teamer;
use App\Email\Mailer;
use App\Entity\User;
use App\Model\TeamerFilterDto;
use App\Model\TeamerMailingDto;
use App\Model\TeamerMailingRecipient;
use App\Model\TeamerMailingRecipientsDto;
use App\Repository\TeamerRepository;
use Psr\Log\LoggerInterface;
class TeamerMailingService
{
public const PLACEHOLDER_FIRST_NAME = '{{vorname}}';
public const PLACEHOLDER_LAST_NAME = '{{nachname}}';
public const PLACEHOLDER_FULL_NAME = '{{name}}';
public const PLACEHOLDERS = [
self::PLACEHOLDER_FIRST_NAME => 'Vorname',
self::PLACEHOLDER_LAST_NAME => 'Nachname',
self::PLACEHOLDER_FULL_NAME => 'Vor- und Nachname',
];
private const TEMPLATE = 'email/teamer_mailing.html.twig';
private const PREVIEW_SUBJECT_PREFIX = '[Vorschau] ';
public function __construct(
private readonly TeamerRepository $teamerRepository,
private readonly Mailer $mailer,
private readonly LoggerInterface $logger,
) {
}
/**
* Split everyone the filter matches into those who will be written to and those who
* are skipped. Deleted and disabled teamers only show up here at all when the admin
* asked for them in the filter.
*/
public function resolveRecipients(TeamerFilterDto $filterDto): TeamerMailingRecipientsDto
{
$recipients = new TeamerMailingRecipientsDto();
foreach ($this->teamerRepository->getMailingRecipients($filterDto) as $recipient) {
if (true === $recipient->isDeleted()) {
$recipients->addSkippedDeleted();
continue;
}
if (true === $recipient->isDisabled()) {
$recipients->addSkippedDisabled();
continue;
}
if (null === $recipient->getEmail()) {
$recipients->addSkippedNoEmail();
continue;
}
$recipients->addEligible($recipient);
}
return $recipients;
}
/**
* Send the mailing to everyone eligible and return how many mails went out.
*/
public function send(TeamerMailingDto $mailingDto, TeamerMailingRecipientsDto $recipients): int
{
foreach ($recipients->getEligible() as $recipient) {
// resolveRecipients() already guaranteed the address, this only narrows the type
if (null === $email = $recipient->getEmail()) {
continue;
}
$values = $this->placeholderValuesFor($recipient);
$this->sendTo(
$email,
$this->render($mailingDto->getSubject(), $values),
$this->render($mailingDto->getMessage(), $values)
);
}
$this->logger->info('Send teamer mailing', [
'subject' => $mailingDto->getSubject(),
'recipients' => $recipients->getEligibleCount(),
'skipped' => $recipients->getSkippedCount(),
]);
return $recipients->getEligibleCount();
}
/**
* Send the very same mail the recipients would get to the composing admin, with their
* own name filled in for the placeholders.
*/
public function sendPreview(TeamerMailingDto $mailingDto, User $admin): void
{
$values = $this->placeholderValuesForUser($admin);
$this->sendTo(
$admin->getUserIdentifier(),
self::PREVIEW_SUBJECT_PREFIX.$this->render($mailingDto->getSubject(), $values),
$this->render($mailingDto->getMessage(), $values)
);
$this->logger->info('Send teamer mailing preview', [
'to' => $admin->getEmail(),
'subject' => $mailingDto->getSubject(),
]);
}
/**
* Replace the known placeholders. Anything that merely looks like a placeholder is
* left alone - a typo should not silently blank out part of the mail.
*/
public function render(?string $text, array $values): string
{
return strtr((string) $text, $values);
}
public function placeholderValuesFor(TeamerMailingRecipient $recipient): array
{
return $this->buildValues(
$recipient->getFirstName(),
$recipient->getLastName(),
$recipient->getFullName()
);
}
/**
* The preview uses the admin's own teamer record when they have one, so it is exactly
* what a recipient would see. Admins without one fall back to their user account, and
* to the local part of their address when even that has no name on it - an empty
* "Hallo ," in the preview would only be confusing, it says nothing about the real mail.
*/
public function placeholderValuesForUser(User $user): array
{
if (null !== $teamer = $user->getTeamer()) {
return $this->buildValues(
(string) $teamer->getFirstName(),
(string) $teamer->getLastName(),
$teamer->getFullName()
);
}
$firstName = (string) $user->getFirstName();
$lastName = (string) $user->getLastName();
$fullName = trim($user->getFullName());
if ('' === $firstName && '' === $lastName) {
$fallback = strstr((string) $user->getEmail(), '@', true) ?: (string) $user->getEmail();
$firstName = $fallback;
$fullName = $fallback;
}
return $this->buildValues($firstName, $lastName, $fullName);
}
private function buildValues(string $firstName, string $lastName, string $fullName): array
{
return [
self::PLACEHOLDER_FIRST_NAME => $firstName,
self::PLACEHOLDER_LAST_NAME => $lastName,
self::PLACEHOLDER_FULL_NAME => $fullName,
];
}
private function sendTo(string $email, string $subject, string $message): void
{
$this->mailer->createAndSendEmail([
'message' => $message,
], [
'to' => $email,
'subject' => $subject,
'template' => self::TEMPLATE,
]);
}
}