feat: integrate with MailJet API for newsletter registration
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Repository\NewsletterOptInConfirmationRepository;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:cleanup:newsletter-opt-in-requests',
|
||||
description: 'Removes expired pending newsletter double opt-in requests',
|
||||
)]
|
||||
class CleanupNewsletterOptInRequestsCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NewsletterOptInConfirmationRepository $confirmationRepository,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$deletedCount = $this->confirmationRepository->deleteExpiredPending();
|
||||
|
||||
if (0 === $deletedCount) {
|
||||
$io->success('No expired pending newsletter opt-in requests found.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->logger->info('Deleted expired pending newsletter opt-in requests', [
|
||||
'count' => $deletedCount,
|
||||
]);
|
||||
|
||||
$io->success(sprintf('Deleted %d expired pending newsletter opt-in request(s).', $deletedCount));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,14 @@ use App\BusProNet\Exception\ApiClientException;
|
||||
use App\BusProNet\Model\Notification;
|
||||
use App\BusProNet\Model\PersonalData;
|
||||
use App\Entity\User;
|
||||
use App\Exception\NewsletterProviderException;
|
||||
use App\Form\PersonalDataType;
|
||||
use App\Repository\NewsletterOptInConfirmationRepository;
|
||||
use App\Security\Crypt;
|
||||
use App\Service\BookingEditDataLoaderService;
|
||||
use App\Service\ProfileCompletenessChecker;
|
||||
use App\Service\Newsletter\MailjetNewsletterService;
|
||||
use App\Service\Newsletter\NewsletterDoubleOptInService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -46,6 +50,9 @@ class PersonalDataController extends AbstractController
|
||||
private readonly BookingEditDataLoaderService $dataLoader,
|
||||
private readonly ProfileCompletenessChecker $completenessChecker,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly MailjetNewsletterService $newsletterService,
|
||||
private readonly NewsletterDoubleOptInService $doubleOptInService,
|
||||
private readonly NewsletterOptInConfirmationRepository $newsletterConfirmationRepository,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
@@ -130,9 +137,27 @@ class PersonalDataController extends AbstractController
|
||||
return $this->redirectToRoute('app_personal_data');
|
||||
}
|
||||
|
||||
$newsletterSubscribed = false;
|
||||
$newsletterPendingConfirmation = false;
|
||||
try {
|
||||
$newsletterSubscribed = $this->newsletterService->isSubscribed($email);
|
||||
} catch (NewsletterProviderException $e) {
|
||||
$this->logger->warning('Unable to read newsletter subscription status', [
|
||||
'email' => $email,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
$this->addFlash('error', 'Der Newsletter-Status konnte gerade nicht geladen werden.');
|
||||
}
|
||||
|
||||
if (!$newsletterSubscribed) {
|
||||
$newsletterPendingConfirmation = null !== $this->newsletterConfirmationRepository->findPendingByEmail($email);
|
||||
}
|
||||
|
||||
return $this->render('account/personal_data.html.twig', [
|
||||
'personalData' => $personalData,
|
||||
'personalDataForm' => $personalDataForm->createView(),
|
||||
'newsletterSubscribed' => $newsletterSubscribed,
|
||||
'newsletterPendingConfirmation' => $newsletterPendingConfirmation,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -149,43 +174,43 @@ class PersonalDataController extends AbstractController
|
||||
*/
|
||||
#[Route('/personal-data/newsletter', name: 'app_personal_data_newsletter', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_USER')]
|
||||
public function newsletter(): Response
|
||||
public function newsletter(Request $request): Response
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
$email = $user->getEmail();
|
||||
$password = $this->crypt->decrypt($user->getPassword());
|
||||
|
||||
$shouldSubscribe = $request->request->getBoolean('subscribed');
|
||||
|
||||
try {
|
||||
$personalData = $this
|
||||
->apiClient
|
||||
->getPersonalData($email, $password);
|
||||
} catch (ApiClientException $e) {
|
||||
$this->addFlash('error', 'Deine persönlichen Daten konnten nicht abgerufen werden');
|
||||
if ($shouldSubscribe) {
|
||||
if ($this->newsletterService->isSubscribed($email)) {
|
||||
$this->addFlash('info', 'Du bist bereits zum Newsletter angemeldet.');
|
||||
} else {
|
||||
$hasPendingConfirmation = null !== $this->newsletterConfirmationRepository->findPendingByEmail($email);
|
||||
$this->doubleOptInService->requestConfirmation($email);
|
||||
if ($hasPendingConfirmation) {
|
||||
$this->addFlash('success', 'Wir haben dir eine neue Bestätigungs-E-Mail gesendet.');
|
||||
} else {
|
||||
$this->addFlash('success', 'Bitte bestätige deine Newsletter-Anmeldung über den Link in der E-Mail.');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$this->newsletterService->unsubscribe($email);
|
||||
$this->addFlash('success', 'Du wurdest vom Newsletter abgemeldet.');
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('app_personal_data');
|
||||
}
|
||||
|
||||
if ($personalData instanceof Notification) {
|
||||
$this->logger->error('Unable to fetch personal data', [
|
||||
'code' => $personalData->code,
|
||||
'error' => $personalData->message,
|
||||
]);
|
||||
$this->addFlash('error', 'Deine persönlichen Daten konnten nicht abgerufen werden');
|
||||
|
||||
return $this->redirectToRoute('app_personal_data');
|
||||
}
|
||||
|
||||
$personalData->communication->newsletter = !$personalData->communication->newsletter;
|
||||
|
||||
try {
|
||||
$this->apiClient->updateNewsletterRegistration($email, $password, $personalData);
|
||||
$this->addFlash('success', 'Deine Anmeldung zum Newsletter wurde aktualisiert');
|
||||
$this->logger->info('Updated newsletter registration', [
|
||||
$this->logger->info('Updated newsletter registration intent', [
|
||||
'email' => $user->getEmail(),
|
||||
'subscribed' => $shouldSubscribe,
|
||||
]);
|
||||
} catch (NewsletterProviderException|\InvalidArgumentException $e) {
|
||||
$this->addFlash('error', 'Die Newsletter-Aktion konnte gerade nicht verarbeitet werden. Bitte versuche es erneut.');
|
||||
$this->logger->warning('Newsletter action failed', [
|
||||
'email' => $email,
|
||||
'subscribed' => $shouldSubscribe,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
} catch (ApiClientException $e) {
|
||||
$this->addFlash('error', $e->getMessage());
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('app_personal_data');
|
||||
|
||||
@@ -9,12 +9,16 @@ use App\BusProNet\Exception\TimeoutException;
|
||||
use App\BusProNet\Model\Notification;
|
||||
use App\Controller\Booking\Traits\BookingCreateTrait;
|
||||
use App\Controller\Booking\Traits\BookingExceptionHandlerTrait;
|
||||
use App\Exception\NewsletterProviderException;
|
||||
use App\Form\BookingCreateStep4Type;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Htmx\HxTrait;
|
||||
use App\Entity\User;
|
||||
use App\Service\BookingPriceCalculatorService;
|
||||
use App\Service\BookingService;
|
||||
use App\Service\BookingSummaryDataService;
|
||||
use App\Service\Newsletter\MailjetNewsletterService;
|
||||
use App\Service\Newsletter\NewsletterDoubleOptInService;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
@@ -38,6 +42,8 @@ class Step4Controller extends AbstractController
|
||||
private readonly BookingPriceCalculatorService $priceCalculator,
|
||||
private readonly ApiClient $apiClient,
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly MailjetNewsletterService $newsletterService,
|
||||
private readonly NewsletterDoubleOptInService $doubleOptInService,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
@@ -59,7 +65,23 @@ class Step4Controller extends AbstractController
|
||||
return $redirect;
|
||||
}
|
||||
|
||||
$form = $this->createForm(BookingCreateStep4Type::class, $bookingCreateDto);
|
||||
$newsletterTargetEmail = $this->resolveNewsletterTargetEmail($bookingCreateDto);
|
||||
$newsletterOptInVisible = false;
|
||||
if (null !== $newsletterTargetEmail) {
|
||||
try {
|
||||
$newsletterOptInVisible = false === $this->newsletterService->isSubscribed($newsletterTargetEmail);
|
||||
} catch (NewsletterProviderException $e) {
|
||||
$this->logger->warning('Could not resolve newsletter subscription state in booking step 4', [
|
||||
'email' => $newsletterTargetEmail,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$form = $this->createForm(BookingCreateStep4Type::class, $bookingCreateDto, [
|
||||
'show_newsletter_opt_in' => $newsletterOptInVisible,
|
||||
'newsletter_target_email' => $newsletterTargetEmail,
|
||||
]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if (true === $form->isSubmitted() && true === $form->isValid()) {
|
||||
@@ -71,7 +93,7 @@ class Step4Controller extends AbstractController
|
||||
return $this->handleApiError(
|
||||
'Booking creation failed - API notification',
|
||||
['message' => $bookingResponse->message],
|
||||
$bookingResponse->message ?? 'Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.',
|
||||
$bookingResponse->message ?? 'Ein Fehler ist aufgetreten. Bitte versuchen es erneut.',
|
||||
$bookingCreateDto,
|
||||
$form
|
||||
);
|
||||
@@ -92,6 +114,21 @@ class Step4Controller extends AbstractController
|
||||
);
|
||||
}
|
||||
|
||||
$newsletterOptInSelected = $newsletterOptInVisible
|
||||
&& $form->has('newsletterOptIn')
|
||||
&& true === $form->get('newsletterOptIn')->getData();
|
||||
|
||||
if (true === $newsletterOptInSelected && null !== $newsletterTargetEmail) {
|
||||
try {
|
||||
$this->doubleOptInService->requestConfirmation($newsletterTargetEmail);
|
||||
} catch (NewsletterProviderException|\InvalidArgumentException $e) {
|
||||
$this->logger->warning('Newsletter confirmation request failed after booking', [
|
||||
'email' => $newsletterTargetEmail,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Success: Store booking data in flash for conversion tracking
|
||||
$summaryData = $this->summaryDataService->getSummaryData($bookingCreateDto);
|
||||
$this->addFlash('booking_number', $bookingResponse->bookingNumber);
|
||||
@@ -114,7 +151,7 @@ class Step4Controller extends AbstractController
|
||||
'exception' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
],
|
||||
'Die Anfrage hat zu lange gedauert. Bitte versuchen Sie es erneut.',
|
||||
'Die Anfrage hat zu lange gedauert. Bitte versuche es erneut.',
|
||||
$bookingCreateDto,
|
||||
$form
|
||||
);
|
||||
@@ -132,13 +169,18 @@ class Step4Controller extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
return $this->renderStepForm($bookingCreateDto, $form);
|
||||
return $this->renderStepForm($bookingCreateDto, $form, $newsletterOptInVisible, $newsletterTargetEmail);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the step 4 form with standard template variables.
|
||||
*/
|
||||
private function renderStepForm(BookingDto $bookingCreateDto, FormInterface $form): Response
|
||||
private function renderStepForm(
|
||||
BookingDto $bookingCreateDto,
|
||||
FormInterface $form,
|
||||
bool $newsletterOptInVisible,
|
||||
?string $newsletterTargetEmail,
|
||||
): Response
|
||||
{
|
||||
// Get complete summary data (pricing, rooms, CMS data)
|
||||
$summaryData = $this->summaryDataService->getSummaryData($bookingCreateDto);
|
||||
@@ -148,9 +190,33 @@ class Step4Controller extends AbstractController
|
||||
'form' => $form->createView(),
|
||||
'summaryData' => $summaryData,
|
||||
'participantPrices' => $this->priceCalculator->calculateAllParticipantIndividualPrices($bookingCreateDto),
|
||||
'newsletterOptInVisible' => $newsletterOptInVisible,
|
||||
'newsletterTargetEmail' => $newsletterTargetEmail,
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveNewsletterTargetEmail(BookingDto $bookingDto): ?string
|
||||
{
|
||||
$currentUser = $this->getUser();
|
||||
$email = null;
|
||||
|
||||
if ($currentUser instanceof User) {
|
||||
$email = $currentUser->getEmail();
|
||||
}
|
||||
|
||||
if ((null === $email || '' === trim((string) $email)) && isset($bookingDto->participants[0])) {
|
||||
$email = $bookingDto->participants[0]->email;
|
||||
}
|
||||
|
||||
if (null === $email) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalizedEmail = mb_strtolower(trim($email));
|
||||
|
||||
return false !== filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL) ? $normalizedEmail : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears travel data and availability cache after successful booking.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Newsletter;
|
||||
|
||||
use App\Exception\NewsletterProviderException;
|
||||
use App\Service\Newsletter\NewsletterConfirmationResult;
|
||||
use App\Service\Newsletter\NewsletterDoubleOptInService;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
class ConfirmController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NewsletterDoubleOptInService $doubleOptInService,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Route('/newsletter/confirm/{token}', name: 'app_newsletter_confirm', methods: ['GET'])]
|
||||
#[IsGranted('PUBLIC_ACCESS')]
|
||||
public function __invoke(string $token): Response
|
||||
{
|
||||
try {
|
||||
$result = $this->doubleOptInService->confirmToken($token);
|
||||
} catch (NewsletterProviderException $exception) {
|
||||
$this->logger->error('Newsletter confirmation failed on provider sync', [
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
$this->addFlash('error', 'Deine Newsletter-Bestätigung konnte gerade nicht abgeschlossen werden. Bitte versuche es später erneut.');
|
||||
|
||||
return $this->redirectToRoute($this->resolveTargetRoute());
|
||||
}
|
||||
|
||||
$this->addFlash(...$this->resolveFlash($result));
|
||||
|
||||
return $this->redirectToRoute($this->resolveTargetRoute());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{string, string}
|
||||
*/
|
||||
private function resolveFlash(NewsletterConfirmationResult $result): array
|
||||
{
|
||||
return match ($result->status) {
|
||||
NewsletterConfirmationResult::STATUS_CONFIRMED => ['success', 'Deine Newsletter-Anmeldung wurde erfolgreich bestätigt.'],
|
||||
NewsletterConfirmationResult::STATUS_ALREADY_USED => ['info', 'Diese Newsletter-Bestätigung wurde bereits verwendet.'],
|
||||
NewsletterConfirmationResult::STATUS_EXPIRED => ['warning', 'Der Bestätigungslink ist abgelaufen. Bitte fordere eine neue Bestätigungs-E-Mail an.'],
|
||||
default => ['error', 'Der Bestätigungslink ist ungültig. Bitte fordere eine neue Bestätigungs-E-Mail an.'],
|
||||
};
|
||||
}
|
||||
|
||||
private function resolveTargetRoute(): string
|
||||
{
|
||||
if ($this->isGranted('ROLE_USER')) {
|
||||
return 'app_personal_data';
|
||||
}
|
||||
|
||||
return 'app_login';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Email;
|
||||
|
||||
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||
|
||||
interface EmailAttachmentInterface
|
||||
{
|
||||
public function attachTo(TemplatedEmail $email): void;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Email;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
|
||||
use Symfony\Component\Mailer\MailerInterface;
|
||||
use Symfony\Component\Mime\Address;
|
||||
use Symfony\Component\Mime\BodyRendererInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class Mailer
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MailerInterface $mailer,
|
||||
private readonly BodyRendererInterface $bodyRenderer,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly array $defaults,
|
||||
) {
|
||||
}
|
||||
|
||||
public function createAndSendEmail(array $context, array $options): void
|
||||
{
|
||||
$config = $this->resolveConfig($options);
|
||||
$email = $this->create($context, $config);
|
||||
|
||||
$recipients = (array) $config['to'];
|
||||
|
||||
foreach ($recipients as $recipient) {
|
||||
$email->to($recipient);
|
||||
$this->send($email);
|
||||
}
|
||||
}
|
||||
|
||||
public function create(array $context, array $config): TemplatedEmail
|
||||
{
|
||||
$email = (new TemplatedEmail())
|
||||
->from($config['from'])
|
||||
->subject($config['subject'])
|
||||
->htmlTemplate($config['template'])
|
||||
->context($context)
|
||||
;
|
||||
|
||||
foreach ($config['attachments'] as $attachment) {
|
||||
/* @var EmailAttachmentInterface $attachment */
|
||||
$attachment->attachTo($email);
|
||||
}
|
||||
|
||||
$this->bodyRenderer->render($email);
|
||||
|
||||
return $email;
|
||||
}
|
||||
|
||||
public function send(TemplatedEmail $email): void
|
||||
{
|
||||
$recipients = array_map(fn (Address $address) => $address->toString(), $email->getTo());
|
||||
|
||||
try {
|
||||
$this->mailer->send($email);
|
||||
$this->logger->info('Send email', [
|
||||
'to' => $recipients,
|
||||
'subject' => $email->getSubject(),
|
||||
]);
|
||||
} catch (TransportExceptionInterface $e) {
|
||||
$this->logger->error('Email could not be sent', [
|
||||
'to' => $recipients,
|
||||
'subject' => $email->getSubject(),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveConfig(array $options): array
|
||||
{
|
||||
$resolver = new OptionsResolver();
|
||||
|
||||
$resolver
|
||||
->setDefaults([
|
||||
'from' => $this->defaults['from'],
|
||||
'to' => $this->defaults['to'],
|
||||
'subject_parameters' => [],
|
||||
'attachments' => [],
|
||||
])
|
||||
->setRequired([
|
||||
'template',
|
||||
'subject',
|
||||
])
|
||||
->setAllowedTypes('to', ['string', 'array'])
|
||||
->setAllowedTypes('template', 'string')
|
||||
->setAllowedTypes('subject', 'string')
|
||||
->setAllowedTypes('attachments', 'array')
|
||||
;
|
||||
|
||||
return $resolver->resolve($options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\NewsletterOptInConfirmationRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: NewsletterOptInConfirmationRepository::class)]
|
||||
class NewsletterOptInConfirmation
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255)]
|
||||
private string $email;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 64, unique: true)]
|
||||
private string $tokenHash;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private \DateTimeImmutable $expiresAt;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
|
||||
private ?\DateTimeImmutable $confirmedAt = null;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
private \DateTimeImmutable $createdAt;
|
||||
|
||||
public function __construct(
|
||||
string $email,
|
||||
string $tokenHash,
|
||||
\DateTimeImmutable $expiresAt,
|
||||
) {
|
||||
$this->email = mb_strtolower(trim($email));
|
||||
$this->tokenHash = $tokenHash;
|
||||
$this->expiresAt = $expiresAt;
|
||||
$this->createdAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getEmail(): string
|
||||
{
|
||||
return $this->email;
|
||||
}
|
||||
|
||||
public function getTokenHash(): string
|
||||
{
|
||||
return $this->tokenHash;
|
||||
}
|
||||
|
||||
public function getExpiresAt(): \DateTimeImmutable
|
||||
{
|
||||
return $this->expiresAt;
|
||||
}
|
||||
|
||||
public function getConfirmedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->confirmedAt;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): \DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function isConfirmed(): bool
|
||||
{
|
||||
return null !== $this->confirmedAt;
|
||||
}
|
||||
|
||||
public function isExpired(?\DateTimeImmutable $now = null): bool
|
||||
{
|
||||
$reference = $now ?? new \DateTimeImmutable();
|
||||
|
||||
return $this->expiresAt <= $reference;
|
||||
}
|
||||
|
||||
public function markConfirmed(?\DateTimeImmutable $now = null): void
|
||||
{
|
||||
$this->confirmedAt = $now ?? new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function refreshRequest(string $tokenHash, \DateTimeImmutable $expiresAt): void
|
||||
{
|
||||
$this->tokenHash = $tokenHash;
|
||||
$this->expiresAt = $expiresAt;
|
||||
$this->confirmedAt = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exception;
|
||||
|
||||
class NewsletterProviderException extends \RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -25,6 +25,24 @@ class BookingCreateStep4Type extends AbstractType
|
||||
htmlspecialchars($this->termsAndConditionsUrl, ENT_QUOTES, 'UTF-8')
|
||||
);
|
||||
|
||||
$newsletterTargetEmail = $options['newsletter_target_email'];
|
||||
$newsletterLabel = 'Ich möchte den Newsletter erhalten und bestätige meine Anmeldung per E-Mail.';
|
||||
if (is_string($newsletterTargetEmail) && '' !== trim($newsletterTargetEmail)) {
|
||||
$newsletterLabel = sprintf(
|
||||
'Ich möchte den Newsletter für %s erhalten und bestätige meine Anmeldung per E-Mail.',
|
||||
htmlspecialchars($newsletterTargetEmail, ENT_QUOTES, 'UTF-8')
|
||||
);
|
||||
}
|
||||
|
||||
if (true === $options['show_newsletter_opt_in']) {
|
||||
$builder->add('newsletterOptIn', CheckboxType::class, [
|
||||
'label' => $newsletterLabel,
|
||||
'label_html' => true,
|
||||
'mapped' => false,
|
||||
'required' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
$builder
|
||||
->add('confirmationAccepted', CheckboxType::class, [
|
||||
'label' => 'Ich bestätige, dass alle Angaben korrekt sind und möchte verbindlich buchen.',
|
||||
@@ -52,6 +70,11 @@ class BookingCreateStep4Type extends AbstractType
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => BookingDto::class,
|
||||
'show_newsletter_opt_in' => false,
|
||||
'newsletter_target_email' => null,
|
||||
]);
|
||||
|
||||
$resolver->setAllowedTypes('show_newsletter_opt_in', 'bool');
|
||||
$resolver->setAllowedTypes('newsletter_target_email', ['null', 'string']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\NewsletterOptInConfirmation;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<NewsletterOptInConfirmation>
|
||||
*/
|
||||
class NewsletterOptInConfirmationRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, NewsletterOptInConfirmation::class);
|
||||
}
|
||||
|
||||
public function findByTokenHash(string $tokenHash): ?NewsletterOptInConfirmation
|
||||
{
|
||||
return $this->findOneBy(['tokenHash' => $tokenHash]);
|
||||
}
|
||||
|
||||
public function findPendingByEmail(string $email): ?NewsletterOptInConfirmation
|
||||
{
|
||||
return $this->createQueryBuilder('c')
|
||||
->where('c.email = :email')
|
||||
->andWhere('c.confirmedAt IS NULL')
|
||||
->andWhere('c.expiresAt > :now')
|
||||
->setParameter('email', mb_strtolower(trim($email)))
|
||||
->setParameter('now', new \DateTimeImmutable())
|
||||
->orderBy('c.createdAt', 'DESC')
|
||||
->setMaxResults(1)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
|
||||
public function deleteExpiredPendingByEmail(string $email): int
|
||||
{
|
||||
return (int) $this->createQueryBuilder('c')
|
||||
->delete()
|
||||
->where('c.email = :email')
|
||||
->andWhere('c.confirmedAt IS NULL')
|
||||
->andWhere('c.expiresAt <= :now')
|
||||
->setParameter('email', mb_strtolower(trim($email)))
|
||||
->setParameter('now', new \DateTimeImmutable())
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
|
||||
public function deleteExpiredPending(): int
|
||||
{
|
||||
$threshold = new \DateTimeImmutable();
|
||||
|
||||
return (int) $this->createQueryBuilder('c')
|
||||
->delete()
|
||||
->where('c.confirmedAt IS NULL')
|
||||
->andWhere('c.expiresAt <= :threshold')
|
||||
->setParameter('threshold', $threshold)
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Newsletter;
|
||||
|
||||
use App\Exception\NewsletterProviderException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class MailjetNewsletterService
|
||||
{
|
||||
private const DEFAULT_BASE_URL = 'https://api.mailjet.com/v3/REST';
|
||||
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $httpClient,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly ?string $mailjetApiKey = null,
|
||||
private readonly ?string $mailjetApiSecret = null,
|
||||
private readonly ?string $mailjetApiBaseUrl = null,
|
||||
private readonly ?string $mailjetNewsletterListId = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function isSubscribed(string $email): bool
|
||||
{
|
||||
$this->assertConfigured();
|
||||
|
||||
$normalizedEmail = $this->normalizeEmail($email);
|
||||
$contactId = $this->resolveContactId($normalizedEmail);
|
||||
if (null === $contactId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$response = $this->request('GET', 'Listrecipient', [
|
||||
'query' => [
|
||||
'Contact' => $contactId,
|
||||
'ContactsList' => $this->mailjetNewsletterListId,
|
||||
],
|
||||
]);
|
||||
|
||||
$entries = $response['Data'] ?? [];
|
||||
if (!is_array($entries)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if (!is_array($entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entryContactId = isset($entry['ContactID']) ? (int) $entry['ContactID'] : null;
|
||||
if ($entryContactId !== $contactId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$isActive = true === ($entry['IsActive'] ?? false);
|
||||
$isUnsubscribed = true === ($entry['IsUnsubscribed'] ?? false);
|
||||
|
||||
return $isActive && !$isUnsubscribed;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function ensureSubscribed(string $email): void
|
||||
{
|
||||
$this->assertConfigured();
|
||||
|
||||
$normalizedEmail = $this->normalizeEmail($email);
|
||||
$resource = sprintf('Contactslist/%s/managecontact', $this->mailjetNewsletterListId);
|
||||
|
||||
try {
|
||||
$this->request('POST', $resource, [
|
||||
'json' => [
|
||||
'Email' => $normalizedEmail,
|
||||
'Action' => 'addforce',
|
||||
],
|
||||
]);
|
||||
} catch (NewsletterProviderException $exception) {
|
||||
$this->logger->error('Mailjet subscribe failed', [
|
||||
'email' => $normalizedEmail,
|
||||
'list_id' => $this->mailjetNewsletterListId,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
public function unsubscribe(string $email): void
|
||||
{
|
||||
$this->assertConfigured();
|
||||
|
||||
$normalizedEmail = $this->normalizeEmail($email);
|
||||
$resource = sprintf('Contactslist/%s/managecontact', $this->mailjetNewsletterListId);
|
||||
|
||||
try {
|
||||
$this->request('POST', $resource, [
|
||||
'json' => [
|
||||
'Email' => $normalizedEmail,
|
||||
'Action' => 'unsub',
|
||||
],
|
||||
]);
|
||||
} catch (NewsletterProviderException $exception) {
|
||||
$this->logger->error('Mailjet unsubscribe failed', [
|
||||
'email' => $normalizedEmail,
|
||||
'list_id' => $this->mailjetNewsletterListId,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveContactId(string $email): ?int
|
||||
{
|
||||
$response = $this->request('GET', 'Contact', [
|
||||
'query' => [
|
||||
'Email' => $email,
|
||||
'Limit' => 1,
|
||||
],
|
||||
'allow_404' => true,
|
||||
]);
|
||||
|
||||
$entry = $response['Data'][0] ?? null;
|
||||
if (!is_array($entry) || !isset($entry['ID'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) $entry['ID'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $options
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function request(string $method, string $resource, array $options = []): array
|
||||
{
|
||||
$allow404 = true === ($options['allow_404'] ?? false);
|
||||
unset($options['allow_404']);
|
||||
|
||||
try {
|
||||
$response = $this->httpClient->request(
|
||||
$method,
|
||||
sprintf('%s/%s', $this->getBaseUrl(), $resource),
|
||||
array_merge($options, [
|
||||
'auth_basic' => sprintf('%s:%s', (string) $this->mailjetApiKey, (string) $this->mailjetApiSecret),
|
||||
])
|
||||
);
|
||||
|
||||
$statusCode = $response->getStatusCode();
|
||||
if (404 === $statusCode && $allow404) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$payload = $response->toArray(false);
|
||||
if ($statusCode >= 400) {
|
||||
$payloadSummary = is_array($payload)
|
||||
? json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
|
||||
: null;
|
||||
|
||||
throw new NewsletterProviderException(sprintf(
|
||||
'Mailjet request failed with status %d for resource %s%s',
|
||||
$statusCode,
|
||||
$resource,
|
||||
null !== $payloadSummary ? sprintf(' (%s)', $payloadSummary) : ''
|
||||
));
|
||||
}
|
||||
|
||||
return is_array($payload) ? $payload : [];
|
||||
} catch (\Throwable $exception) {
|
||||
if ($allow404 && str_contains($exception->getMessage(), '404')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if ($exception instanceof NewsletterProviderException) {
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
throw new NewsletterProviderException(
|
||||
sprintf('Mailjet request error for resource %s', $resource),
|
||||
previous: $exception
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function getBaseUrl(): string
|
||||
{
|
||||
$baseUrl = null !== $this->mailjetApiBaseUrl && '' !== trim($this->mailjetApiBaseUrl)
|
||||
? trim($this->mailjetApiBaseUrl)
|
||||
: self::DEFAULT_BASE_URL;
|
||||
|
||||
return rtrim($baseUrl, '/');
|
||||
}
|
||||
|
||||
private function assertConfigured(): void
|
||||
{
|
||||
if (empty($this->mailjetApiKey) || empty($this->mailjetApiSecret) || empty($this->mailjetNewsletterListId)) {
|
||||
throw new NewsletterProviderException('Mailjet newsletter service is not fully configured.');
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeEmail(string $email): string
|
||||
{
|
||||
return mb_strtolower(trim($email));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Newsletter;
|
||||
|
||||
class NewsletterConfirmationResult
|
||||
{
|
||||
public const STATUS_CONFIRMED = 'confirmed';
|
||||
public const STATUS_INVALID = 'invalid';
|
||||
public const STATUS_EXPIRED = 'expired';
|
||||
public const STATUS_ALREADY_USED = 'already_used';
|
||||
|
||||
public function __construct(
|
||||
public readonly string $status,
|
||||
public readonly ?string $email = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function isSuccess(): bool
|
||||
{
|
||||
return self::STATUS_CONFIRMED === $this->status;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Newsletter;
|
||||
|
||||
use App\Email\Mailer;
|
||||
use App\Exception\NewsletterProviderException;
|
||||
use App\Entity\NewsletterOptInConfirmation;
|
||||
use App\Repository\NewsletterOptInConfirmationRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class NewsletterDoubleOptInService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NewsletterOptInConfirmationRepository $confirmationRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly MailjetNewsletterService $newsletterService,
|
||||
private readonly Mailer $mailer,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly int $newsletterConfirmationTtlHours,
|
||||
) {
|
||||
}
|
||||
|
||||
public function requestConfirmation(string $email): void
|
||||
{
|
||||
$normalizedEmail = $this->normalizeEmail($email);
|
||||
|
||||
if (false === filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new \InvalidArgumentException('Invalid email for newsletter confirmation request.');
|
||||
}
|
||||
|
||||
$token = $this->generateToken();
|
||||
$tokenHash = $this->hashToken($token);
|
||||
$expiresAt = new \DateTimeImmutable(sprintf('+%d hours', $this->newsletterConfirmationTtlHours));
|
||||
$this->confirmationRepository->deleteExpiredPendingByEmail($normalizedEmail);
|
||||
$pendingConfirmation = $this->confirmationRepository->findPendingByEmail($normalizedEmail);
|
||||
|
||||
$wasExisting = null !== $pendingConfirmation;
|
||||
$previousTokenHash = null;
|
||||
$previousExpiresAt = null;
|
||||
|
||||
if (null !== $pendingConfirmation) {
|
||||
$previousTokenHash = $pendingConfirmation->getTokenHash();
|
||||
$previousExpiresAt = $pendingConfirmation->getExpiresAt();
|
||||
$pendingConfirmation->refreshRequest($tokenHash, $expiresAt);
|
||||
} else {
|
||||
$pendingConfirmation = new NewsletterOptInConfirmation(
|
||||
email: $normalizedEmail,
|
||||
tokenHash: $tokenHash,
|
||||
expiresAt: $expiresAt,
|
||||
);
|
||||
|
||||
$this->entityManager->persist($pendingConfirmation);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
try {
|
||||
$context = [
|
||||
'token' => $token,
|
||||
];
|
||||
$options = [
|
||||
'to' => $normalizedEmail,
|
||||
'subject' => 'Deine Anmeldung zum E&P Newsletter',
|
||||
'template' => 'email/newsletter_opt_in.html.twig',
|
||||
];
|
||||
$this->mailer->createAndSendEmail($context, $options);
|
||||
} catch (\Throwable $exception) {
|
||||
if (true === $wasExisting && null !== $previousTokenHash && null !== $previousExpiresAt) {
|
||||
$pendingConfirmation->refreshRequest($previousTokenHash, $previousExpiresAt);
|
||||
} else {
|
||||
$this->entityManager->remove($pendingConfirmation);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
throw new NewsletterProviderException('Could not send newsletter confirmation email.', previous: $exception);
|
||||
}
|
||||
|
||||
$this->logger->info('Newsletter confirmation requested', [
|
||||
'email' => $normalizedEmail,
|
||||
'expires_at' => $expiresAt->format(DATE_ATOM),
|
||||
]);
|
||||
}
|
||||
|
||||
public function confirmToken(string $token): NewsletterConfirmationResult
|
||||
{
|
||||
$normalizedToken = trim($token);
|
||||
if ('' === $normalizedToken) {
|
||||
return new NewsletterConfirmationResult(
|
||||
NewsletterConfirmationResult::STATUS_INVALID,
|
||||
);
|
||||
}
|
||||
|
||||
$tokenHash = $this->hashToken($normalizedToken);
|
||||
$confirmation = $this->confirmationRepository->findByTokenHash($tokenHash);
|
||||
if (null === $confirmation) {
|
||||
return new NewsletterConfirmationResult(
|
||||
NewsletterConfirmationResult::STATUS_INVALID,
|
||||
);
|
||||
}
|
||||
|
||||
if ($confirmation->isConfirmed()) {
|
||||
return new NewsletterConfirmationResult(
|
||||
NewsletterConfirmationResult::STATUS_ALREADY_USED,
|
||||
$confirmation->getEmail(),
|
||||
);
|
||||
}
|
||||
|
||||
if ($confirmation->isExpired()) {
|
||||
$this->entityManager->remove($confirmation);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return new NewsletterConfirmationResult(
|
||||
NewsletterConfirmationResult::STATUS_EXPIRED,
|
||||
$confirmation->getEmail(),
|
||||
);
|
||||
}
|
||||
|
||||
$this->newsletterService->ensureSubscribed($confirmation->getEmail());
|
||||
|
||||
$confirmation->markConfirmed();
|
||||
$this->entityManager->flush();
|
||||
|
||||
$this->logger->info('Newsletter double opt-in confirmed', [
|
||||
'email' => $confirmation->getEmail(),
|
||||
]);
|
||||
|
||||
return new NewsletterConfirmationResult(
|
||||
NewsletterConfirmationResult::STATUS_CONFIRMED,
|
||||
$confirmation->getEmail(),
|
||||
);
|
||||
}
|
||||
|
||||
private function generateToken(): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
private function hashToken(string $token): string
|
||||
{
|
||||
return hash('sha256', $token);
|
||||
}
|
||||
|
||||
private function normalizeEmail(string $email): string
|
||||
{
|
||||
return mb_strtolower(trim($email));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user