feat: MailJet list handling with DOI, webhook receiver and API endpoint

addresses #869cut134
This commit is contained in:
Björn Fromme
2026-04-29 09:51:57 +02:00
parent df7885ca1c
commit e4ef2f7adc
46 changed files with 3389 additions and 322 deletions
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Command;
use App\Repository\NewsletterOptInConfirmationRepository;
use App\Repository\NewsletterOptInRequestRepository;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
@@ -19,7 +19,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
class CleanupNewsletterOptInRequestsCommand extends Command
{
public function __construct(
private readonly NewsletterOptInConfirmationRepository $confirmationRepository,
private readonly NewsletterOptInRequestRepository $optInRequestRepository,
private readonly LoggerInterface $logger,
) {
parent::__construct();
@@ -29,7 +29,7 @@ class CleanupNewsletterOptInRequestsCommand extends Command
{
$io = new SymfonyStyle($input, $output);
$deletedCount = $this->confirmationRepository->deleteExpiredPending();
$deletedCount = $this->optInRequestRepository->deleteExpiredPending();
if (0 === $deletedCount) {
$io->success('No expired pending newsletter opt-in requests found.');
@@ -10,12 +10,12 @@ use App\BusProNet\Model\Notification;
use App\BusProNet\Model\PersonalData;
use App\Entity\User;
use App\Exception\NewsletterProviderException;
use App\Htmx\HxTrait;
use App\Form\PersonalDataType;
use App\Repository\NewsletterOptInConfirmationRepository;
use App\Model\NewsletterSubscriptionRequestResult;
use App\Service\NewsletterManager;
use App\Security\Crypt;
use App\Service\BookingEditDataLoader;
use App\Service\MailjetApiClient;
use App\Service\NewsletterManager;
use App\Service\ProfileCompletenessChecker;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
@@ -30,31 +30,33 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
*
* Provides functionality for viewing and updating customer profile information
* through integration with the BusProNet API system. Handles personal data
* management and newsletter subscription preferences for authenticated users.
* management for authenticated users.
*/
class PersonalDataController extends AbstractController
{
use HxTrait;
public const SESSION_REDIRECT_KEY = '_profile_completion_redirect';
/**
* @param ApiClient $apiClient BusProNet API client for data operations
* @param Crypt $crypt Encryption service for password handling
* @param BookingEditDataLoader $dataLoader Data loader for cache invalidation
* @param ApiClient $apiClient BusProNet API client for data operations
* @param Crypt $crypt Encryption service for password handling
* @param BookingEditDataLoader $dataLoader Data loader for cache invalidation
* @param ProfileCompletenessChecker $completenessChecker Profile validation service
* @param EntityManagerInterface $entityManager Entity manager for persisting user changes
* @param LoggerInterface $logger Logger for audit trails and debugging
* @param EntityManagerInterface $entityManager Entity manager for persisting user changes
* @param NewsletterManager $newsletterManager Newsletter confirmation service
* @param LoggerInterface $logger Logger for audit trails and debugging
*/
public function __construct(
private readonly ApiClient $apiClient,
private readonly Crypt $crypt,
private readonly BookingEditDataLoader $dataLoader,
private readonly ApiClient $apiClient,
private readonly Crypt $crypt,
private readonly BookingEditDataLoader $dataLoader,
private readonly ProfileCompletenessChecker $completenessChecker,
private readonly EntityManagerInterface $entityManager,
private readonly MailjetApiClient $newsletterService,
private readonly NewsletterManager $doubleOptInService,
private readonly NewsletterOptInConfirmationRepository $newsletterConfirmationRepository,
private readonly LoggerInterface $logger,
) {
private readonly EntityManagerInterface $entityManager,
private readonly NewsletterManager $newsletterManager,
private readonly LoggerInterface $logger,
)
{
}
/**
@@ -78,25 +80,9 @@ class PersonalDataController extends AbstractController
$user = $this->getUser();
$email = $user->getEmail();
$password = $this->crypt->decrypt($user->getPassword());
try {
$personalData = $this
->apiClient
->getPersonalData($email, $password);
} catch (ApiClientException $e) {
$this->addFlash('error', 'Deine persönlichen Daten konnten nicht abgerufen werden');
$personalData = new PersonalData();
}
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');
$personalData = new PersonalData();
}
$personalData = $this->loadPersonalData($user);
$newsletterSubscribed = $this->newsletterManager->hasConfirmedOptIn($email);
$newsletterPendingConfirmation = $this->newsletterManager->hasPendingConfirmation($email);
$personalDataForm = $this->createForm(PersonalDataType::class, $personalData, [
'attr' => ['novalidate' => 'novalidate'],
@@ -137,22 +123,6 @@ 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(),
@@ -161,17 +131,6 @@ class PersonalDataController extends AbstractController
]);
}
/**
* Toggle newsletter subscription status for the authenticated user.
*
* Retrieves current personal data, toggles the newsletter subscription flag,
* and updates the preference via BusProNet API. Designed for HTMX AJAX
* requests to provide immediate feedback without full page reload.
*
* @return Response Redirect response to personal data page
*
* @throws ApiClientException When BusProNet API communication fails
*/
#[Route('/personal-data/newsletter', name: 'app_personal_data_newsletter', methods: ['POST'])]
#[IsGranted('ROLE_USER')]
public function newsletter(Request $request): Response
@@ -179,40 +138,80 @@ class PersonalDataController extends AbstractController
/** @var User $user */
$user = $this->getUser();
$email = $user->getEmail();
$shouldSubscribe = $request->request->getBoolean('subscribed');
$personalData = $this->loadPersonalData($user);
$hasPendingConfirmation = $this->newsletterManager->hasPendingConfirmation($email);
try {
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.');
}
}
if (true === $hasPendingConfirmation) {
$this->newsletterManager->requestConfirmation($email, $personalData->firstName, $personalData->name);
$this->addFlash('success', 'Wir haben dir eine neue Bestätigungs-E-Mail gesendet.');
} else {
$this->newsletterService->unsubscribe($email);
$this->addFlash('success', 'Du wurdest vom Newsletter abgemeldet.');
$result = $this->newsletterManager->requestDefaultListSubscription($email, $personalData->firstName, $personalData->name);
$this->addFlash(...$this->newsletterRequestFlash($result));
}
$this->logger->info('Updated newsletter registration intent', [
$this->logger->info('Requested newsletter confirmation', [
'email' => $user->getEmail(),
'subscribed' => $shouldSubscribe,
'pending_confirmation' => $hasPendingConfirmation,
]);
} 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(),
]);
}
return $this->redirectToRoute('app_personal_data');
return $this->htmxRedirect($request, $this->generateUrl('app_personal_data'));
}
/**
* @return array{string, string}
*/
private function newsletterRequestFlash(NewsletterSubscriptionRequestResult $result): array
{
$states = array_unique($result->listStates);
if (
NewsletterSubscriptionRequestResult::STATE_SUBSCRIBED === $result->state
&& [NewsletterSubscriptionRequestResult::LIST_STATE_ALREADY_REGISTERED] === array_values($states)
) {
return ['info', 'Du bist bereits zum Newsletter angemeldet.'];
}
return match ($result->state) {
NewsletterSubscriptionRequestResult::STATE_SUBSCRIBED => ['success', 'Deine Newsletter-Anmeldung wurde aktualisiert.'],
NewsletterSubscriptionRequestResult::STATE_PENDING_CONFIRMATION => ['info', 'Deine Anmeldung muss noch bestätigt werden. Bitte nutze den Link aus der Bestätigungs-E-Mail.'],
default => ['success', 'Bitte bestätige deine Newsletter-Anmeldung über den Link in der E-Mail.'],
};
}
private function loadPersonalData(User $user): PersonalData
{
$email = $user->getEmail();
$password = $this->crypt->decrypt($user->getPassword());
try {
$personalData = $this
->apiClient
->getPersonalData($email, $password);
} catch (ApiClientException $e) {
$this->addFlash('error', 'Deine persönlichen Daten konnten nicht abgerufen werden');
return new PersonalData();
}
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 new PersonalData();
}
return $personalData;
}
}
@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App\Controller\Api;
use App\Exception\NewsletterListNotAllowedException;
use App\Exception\NewsletterProviderException;
use App\Model\NewsletterSubscriptionRequest;
use App\Model\NewsletterSubscriptionRequestResult;
use App\Service\NewsletterManager;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api')]
#[IsGranted('ROLE_OAUTH2_API')]
class NewsletterSubscriptionController extends AbstractController
{
/**
* @param array<int, string> $mailjetLists
*/
public function __construct(
private readonly NewsletterManager $newsletterManager,
private readonly LoggerInterface $logger,
private readonly array $mailjetLists = [],
) {
}
#[Route('/newsletters', name: 'api_newsletters_all', methods: ['GET'])]
public function index():JsonResponse
{
return $this->json($this->newsletterManager->createListIdMapping(array_keys($this->mailjetLists)));
}
#[Route('/newsletter-subscriptions', name: 'api_newsletters_subscriptions', methods: ['POST'])]
public function subscribe(
#[MapRequestPayload(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
NewsletterSubscriptionRequest $request,
): JsonResponse {
try {
$result = $this
->newsletterManager
->requestApiSubscription(
(string) $request->email,
$request->listIds,
array_keys($this->mailjetLists),
$request->firstName,
$request->lastName,
)
;
} catch (NewsletterListNotAllowedException $exception) {
return $this->badRequest($exception->getMessage(), [
'listIds' => $exception->getListIds(),
'unknownListIds' => $exception->getUnknownListIds(),
]);
} catch (\InvalidArgumentException $exception) {
return $this->badRequest($exception->getMessage(), [
'listIds' => $request->listIds,
]);
} catch (NewsletterProviderException $exception) {
$this->logger->warning('Newsletter subscription request failed', [
'email' => $request->email,
'list_ids' => $request->listIds,
'error' => $exception->getMessage(),
]);
return new JsonResponse([
'success' => false,
'email' => $request->email,
'listIds' => $request->listIds,
'message' => 'Newsletter subscription request could not be processed.',
], Response::HTTP_SERVICE_UNAVAILABLE);
}
return new JsonResponse(
$this->responsePayload($result),
NewsletterSubscriptionRequestResult::STATE_CONFIRMATION_REQUESTED === $result->state
? Response::HTTP_ACCEPTED
: Response::HTTP_OK,
);
}
/**
* @param array<string, mixed> $extra
*/
private function badRequest(string $message, array $extra = []): JsonResponse
{
return new JsonResponse(array_merge([
'success' => false,
'message' => $message,
], $extra), Response::HTTP_BAD_REQUEST);
}
private function responsePayload(NewsletterSubscriptionRequestResult $result): array
{
return [
'success' => true,
'email' => $result->email,
'lists' => array_map(
fn (array $list): array => array_merge($list, [
'state' => $result->stateForList((int) $list['id']),
]),
$this->newsletterManager->createListIdMapping($result->listIds),
),
'state' => $result->state,
'confirmationRequested' => $result->confirmationRequested,
];
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_OAUTH2_API')]
class PickupController extends AbstractController
{
private const PLANNING_FILE = 'pickup_planning.json';
private const string PLANNING_FILE = 'pickup_planning.json';
public function __construct(
private readonly PickupLoader $xmlLoader,
@@ -14,11 +14,11 @@ use App\Exception\NewsletterProviderException;
use App\Exception\TravelNotFoundException;
use App\Form\BookingCreateStep4Type;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Htmx\HxTrait;
use App\Service\BookingConfigurator;
use App\Service\BookingCreateContextFactory;
use App\Service\BookingSessionManager;
use App\Service\MailjetApiClient;
use App\Service\NewsletterManager;
use Psr\Log\LoggerInterface;
use Symfony\Component\Form\FormInterface;
@@ -40,7 +40,6 @@ class Step4Controller extends AbstractBookingCreateController
private readonly BookingCreateContextFactory $createContextFactory,
private readonly ApiClient $apiClient,
private readonly CacheInterface $cache,
private readonly MailjetApiClient $newsletterService,
private readonly NewsletterManager $doubleOptInService,
private readonly LoggerInterface $logger,
) {
@@ -64,17 +63,7 @@ class Step4Controller extends AbstractBookingCreateController
}
$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(),
]);
}
}
$newsletterOptInVisible = null !== $newsletterTargetEmail;
$form = $this->createForm(BookingCreateStep4Type::class, $bookingCreateDto, [
'show_newsletter_opt_in' => $newsletterOptInVisible,
@@ -122,7 +111,12 @@ class Step4Controller extends AbstractBookingCreateController
if (true === $newsletterOptInSelected && null !== $newsletterTargetEmail) {
try {
$this->doubleOptInService->requestConfirmation($newsletterTargetEmail);
$targetParticipant = $this->resolveNewsletterTargetParticipant($bookingCreateDto);
$this->doubleOptInService->requestDefaultListSubscription(
$newsletterTargetEmail,
$targetParticipant?->firstName,
$targetParticipant?->lastName,
);
} catch (NewsletterProviderException|\InvalidArgumentException $e) {
$this->logger->warning('Newsletter confirmation request failed after booking', [
'email' => $newsletterTargetEmail,
@@ -216,6 +210,20 @@ class Step4Controller extends AbstractBookingCreateController
return false !== filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL) ? $normalizedEmail : null;
}
private function resolveNewsletterTargetParticipant(BookingDto $bookingDto): ?ParticipantDto
{
$participant = $bookingDto->participants[0] ?? null;
if (null === $participant) {
return null;
}
if (null === $participant->firstName && null === $participant->lastName) {
return null;
}
return $participant;
}
/**
* Clears travel data and availability cache after successful booking.
*/
@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
namespace App\Controller\Webhook;
use App\Message\MailjetNewsletterEventMessage;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
final class MailjetNewsletterWebhookController extends AbstractController
{
public function __construct(
private readonly MessageBusInterface $messageBus,
private readonly LoggerInterface $logger,
) {
}
#[Route('/webhooks/mailjet/newsletter', name: 'app_webhook_mailjet_newsletter', methods: ['POST'])]
public function __invoke(Request $request): JsonResponse
{
$payload = json_decode($request->getContent(), true);
if (JSON_ERROR_NONE !== json_last_error() || false === is_array($payload)) {
return new JsonResponse(['success' => false, 'message' => 'Invalid JSON payload.'], Response::HTTP_BAD_REQUEST);
}
$events = $this->normalizeEvents($payload);
$dispatched = 0;
foreach ($events as $eventPayload) {
$message = $this->createMessage($eventPayload);
if (null === $message) {
continue;
}
$this->messageBus->dispatch($message);
++$dispatched;
}
$this->logger->info('Accepted Mailjet newsletter webhook payload', [
'events' => count($events),
'dispatched' => $dispatched,
]);
return new JsonResponse(['success' => true, 'dispatched' => $dispatched]);
}
/**
* @param array<mixed> $payload
*
* @return list<array<string, mixed>>
*/
private function normalizeEvents(array $payload): array
{
if ([] === $payload) {
return [];
}
if (array_is_list($payload)) {
return array_values(array_filter($payload, static fn (mixed $entry): bool => is_array($entry)));
}
return [$payload];
}
/**
* @param array<string, mixed> $payload
*/
private function createMessage(array $payload): ?MailjetNewsletterEventMessage
{
$event = isset($payload['event']) ? strtolower(trim((string) $payload['event'])) : '';
if (MailjetNewsletterEventMessage::EVENT_UNSUBSCRIBE !== $event) {
return null;
}
$email = isset($payload['email']) ? mb_strtolower(trim((string) $payload['email'])) : '';
if (false === filter_var($email, FILTER_VALIDATE_EMAIL)) {
return null;
}
$mailjetListId = $this->resolveListId($payload);
if (null === $mailjetListId) {
return null;
}
return new MailjetNewsletterEventMessage(
email: $email,
mailjetListId: $mailjetListId,
event: $event,
eventAt: $this->resolveEventAt($payload),
payload: $payload,
);
}
/**
* @param array<string, mixed> $payload
*/
private function resolveListId(array $payload): ?int
{
$value = $payload['mj_list_id'] ?? $payload['list_id'] ?? null;
if (true === is_int($value)) {
return $value > 0 ? $value : null;
}
if (true === is_string($value) && true === ctype_digit(trim($value))) {
$listId = (int) trim($value);
return $listId > 0 ? $listId : null;
}
return null;
}
/**
* @param array<string, mixed> $payload
*/
private function resolveEventAt(array $payload): ?\DateTimeImmutable
{
$value = $payload['time'] ?? $payload['event_at'] ?? null;
if (true === is_int($value) || true === is_float($value) || true === (is_string($value) && ctype_digit(trim($value)))) {
return (new \DateTimeImmutable())->setTimestamp((int) $value);
}
if (true === is_string($value) && '' !== trim($value)) {
try {
return new \DateTimeImmutable($value);
} catch (\Throwable) {
return null;
}
}
return null;
}
}
+2
View File
@@ -81,6 +81,8 @@ class Mailer
'subject' => $email->getSubject(),
'error' => $e->getMessage(),
]);
throw $e;
}
}
+149
View File
@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\NewsletterConsentRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: NewsletterConsentRepository::class)]
#[ORM\UniqueConstraint(name: 'UNIQ_NEWSLETTER_CONSENT_EMAIL_LIST', columns: ['email', 'mailjet_list_id'])]
class NewsletterConsent
{
private const int NAME_MAX_LENGTH = 255;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 255)]
private string $email;
#[ORM\Column(type: 'integer')]
private int $mailjetListId;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $confirmedAt = null;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $revokedAt = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $firstName = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $lastName = null;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $updatedAt;
public function __construct(string $email, int $mailjetListId, ?string $firstName = null, ?string $lastName = null)
{
if ($mailjetListId <= 0) {
throw new \InvalidArgumentException('Mailjet list ID must be a positive integer.');
}
$this->email = mb_strtolower(trim($email));
$this->mailjetListId = $mailjetListId;
$this->setNames($firstName, $lastName);
$this->createdAt = new \DateTimeImmutable();
$this->updatedAt = $this->createdAt;
}
public function getId(): ?int
{
return $this->id;
}
public function getEmail(): string
{
return $this->email;
}
public function getMailjetListId(): int
{
return $this->mailjetListId;
}
public function getConfirmedAt(): ?\DateTimeImmutable
{
return $this->confirmedAt;
}
public function getRevokedAt(): ?\DateTimeImmutable
{
return $this->revokedAt;
}
public function getFirstName(): ?string
{
return $this->firstName;
}
public function getLastName(): ?string
{
return $this->lastName;
}
public function isConfirmed(): bool
{
return null !== $this->confirmedAt && null === $this->revokedAt;
}
public function isRevoked(): bool
{
return null !== $this->revokedAt;
}
public function markConfirmed(?\DateTimeImmutable $now = null, ?string $firstName = null, ?string $lastName = null): void
{
$timestamp = $now ?? new \DateTimeImmutable();
$this->confirmedAt = $timestamp;
$this->revokedAt = null;
$this->mergeNames($firstName, $lastName);
$this->updatedAt = $timestamp;
}
public function markRevoked(?\DateTimeImmutable $now = null): void
{
$timestamp = $now ?? new \DateTimeImmutable();
$this->revokedAt = $timestamp;
$this->updatedAt = $timestamp;
}
public function setNames(?string $firstName, ?string $lastName): void
{
$this->firstName = self::normalizeName($firstName);
$this->lastName = self::normalizeName($lastName);
}
public function mergeNames(?string $firstName, ?string $lastName): void
{
if (null !== $firstName) {
$this->firstName = self::normalizeName($firstName);
}
if (null !== $lastName) {
$this->lastName = self::normalizeName($lastName);
}
}
private static function normalizeName(?string $value): ?string
{
if (null === $value) {
return null;
}
$trimmed = trim($value);
if ('' === $trimmed) {
return null;
}
return mb_substr($trimmed, 0, self::NAME_MAX_LENGTH);
}
}
@@ -1,97 +0,0 @@
<?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;
}
}
+246
View File
@@ -0,0 +1,246 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\NewsletterOptInRequestRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: NewsletterOptInRequestRepository::class)]
class NewsletterOptInRequest
{
private const int NAME_MAX_LENGTH = 255;
#[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', nullable: true)]
private ?\DateTimeImmutable $revokedAt = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $firstName = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private ?string $lastName = null;
/**
* @var list<int>|null
*/
#[ORM\Column(type: 'json', nullable: true)]
private ?array $mailjetListIds = null;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
/**
* @param list<int|string> $mailjetListIds
*/
public function __construct(
string $email,
string $tokenHash,
\DateTimeImmutable $expiresAt,
array $mailjetListIds = [],
?string $firstName = null,
?string $lastName = null,
) {
$this->email = mb_strtolower(trim($email));
$this->tokenHash = $tokenHash;
$this->expiresAt = $expiresAt;
$this->setMailjetListIds($mailjetListIds);
$this->setNames($firstName, $lastName);
$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 getRevokedAt(): ?\DateTimeImmutable
{
return $this->revokedAt;
}
public function getFirstName(): ?string
{
return $this->firstName;
}
public function getLastName(): ?string
{
return $this->lastName;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
/**
* @return list<int>
*/
public function getMailjetListIds(): array
{
return $this->mailjetListIds ?? [];
}
public function isConfirmed(): bool
{
return null !== $this->confirmedAt;
}
public function isRevoked(): bool
{
return null !== $this->revokedAt;
}
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();
$this->revokedAt = null;
}
public function markRevoked(?\DateTimeImmutable $now = null): void
{
$this->revokedAt = $now ?? new \DateTimeImmutable();
}
public function clearRevokedAt(): void
{
$this->revokedAt = null;
}
/**
* @param list<int|string> $mailjetListIds
*/
public function refreshRequest(string $tokenHash, \DateTimeImmutable $expiresAt, array $mailjetListIds = [], ?string $firstName = null, ?string $lastName = null): void
{
$this->tokenHash = $tokenHash;
$this->expiresAt = $expiresAt;
$this->setMailjetListIds($mailjetListIds);
$this->mergeNames($firstName, $lastName);
$this->confirmedAt = null;
$this->revokedAt = null;
}
/**
* @param list<int|string> $mailjetListIds
*/
public function setMailjetListIds(array $mailjetListIds): void
{
$normalizedListIds = self::normalizeMailjetListIds($mailjetListIds);
$this->mailjetListIds = [] === $normalizedListIds ? null : $normalizedListIds;
}
/**
* @param list<int|string> $mailjetListIds
*/
public function mergeMailjetListIds(array $mailjetListIds): void
{
$this->setMailjetListIds(array_merge($this->getMailjetListIds(), $mailjetListIds));
}
public function setNames(?string $firstName, ?string $lastName): void
{
$this->firstName = self::normalizeName($firstName);
$this->lastName = self::normalizeName($lastName);
}
public function mergeNames(?string $firstName, ?string $lastName): void
{
if (null !== $firstName) {
$this->firstName = self::normalizeName($firstName);
}
if (null !== $lastName) {
$this->lastName = self::normalizeName($lastName);
}
}
private static function normalizeName(?string $value): ?string
{
if (null === $value) {
return null;
}
$trimmed = trim($value);
if ('' === $trimmed) {
return null;
}
return mb_substr($trimmed, 0, self::NAME_MAX_LENGTH);
}
/**
* @param list<mixed> $mailjetListIds
*
* @return list<int>
*/
private static function normalizeMailjetListIds(array $mailjetListIds): array
{
$normalizedListIds = [];
foreach ($mailjetListIds as $mailjetListId) {
if (true === is_int($mailjetListId)) {
$normalizedListId = $mailjetListId;
} elseif (true === is_string($mailjetListId) && true === ctype_digit(trim($mailjetListId))) {
$normalizedListId = (int) trim($mailjetListId);
} else {
throw new \InvalidArgumentException('Mailjet list IDs must be positive integers.');
}
if ($normalizedListId <= 0) {
throw new \InvalidArgumentException('Mailjet list IDs must be positive integers.');
}
$normalizedListIds[] = $normalizedListId;
}
$normalizedListIds = array_unique($normalizedListIds);
sort($normalizedListIds, SORT_NUMERIC);
return $normalizedListIds;
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Exception;
class NewsletterListNotAllowedException extends \InvalidArgumentException
{
/**
* @param list<int> $listIds
* @param list<int> $unknownListIds
*/
public function __construct(
private readonly array $listIds,
private readonly array $unknownListIds,
) {
parent::__construct('One or more Mailjet list IDs are not allowed.');
}
/**
* @return list<int>
*/
public function getListIds(): array
{
return $this->listIds;
}
/**
* @return list<int>
*/
public function getUnknownListIds(): array
{
return $this->unknownListIds;
}
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Message;
final class MailjetNewsletterEventMessage
{
public const string EVENT_UNSUBSCRIBE = 'unsub';
/**
* @param array<string, mixed> $payload
*/
public function __construct(
public readonly string $email,
public readonly int $mailjetListId,
public readonly string $event,
public readonly ?\DateTimeImmutable $eventAt = null,
public readonly array $payload = [],
) {
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\MessageHandler;
use App\Entity\NewsletterConsent;
use App\Message\MailjetNewsletterEventMessage;
use App\Repository\NewsletterConsentRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final class MailjetNewsletterEventHandler
{
public function __construct(
private readonly NewsletterConsentRepository $consentRepository,
private readonly EntityManagerInterface $entityManager,
) {
}
public function __invoke(MailjetNewsletterEventMessage $message): void
{
if (MailjetNewsletterEventMessage::EVENT_UNSUBSCRIBE !== $message->event) {
return;
}
$consent = $this->consentRepository->findOneByEmailAndListId($message->email, $message->mailjetListId);
if (null === $consent) {
$consent = new NewsletterConsent($message->email, $message->mailjetListId);
$this->entityManager->persist($consent);
}
$revokedAt = $message->eventAt ?? new \DateTimeImmutable();
if (null === $message->eventAt && null !== $consent->getRevokedAt()) {
return;
}
if (null !== $message->eventAt && null !== $consent->getConfirmedAt() && $consent->getConfirmedAt() > $message->eventAt) {
return;
}
if (null !== $consent->getRevokedAt() && $consent->getRevokedAt() >= $revokedAt) {
return;
}
$consent->markRevoked($revokedAt);
$this->entityManager->flush();
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Model;
use Symfony\Component\Validator\Constraints as Assert;
class NewsletterSubscriptionRequest
{
#[Assert\NotBlank]
#[Assert\Email(mode: 'strict')]
public ?string $email = null;
#[Assert\Length(max: 255)]
public ?string $firstName = null;
#[Assert\Length(max: 255)]
public ?string $lastName = null;
/**
* @var list<int>
*/
#[Assert\NotNull]
#[Assert\Type('array')]
#[Assert\Count(min: 1)]
#[Assert\All([
new Assert\Type('integer'),
new Assert\Positive(),
])]
public array $listIds = [];
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Model;
class NewsletterSubscriptionRequestResult
{
public const STATE_SUBSCRIBED = 'subscribed';
public const STATE_PENDING_CONFIRMATION = 'pending_confirmation';
public const STATE_CONFIRMATION_REQUESTED = 'confirmation_requested';
public const LIST_STATE_PENDING = 'pending';
public const LIST_STATE_ALREADY_REGISTERED = 'already_registered';
public const LIST_STATE_SUCCESS = 'success';
/**
* @param list<int> $listIds
* @param array<int, string> $listStates
*/
public function __construct(
public readonly string $email,
public readonly array $listIds,
public readonly string $state,
public readonly bool $confirmationRequested,
public readonly array $listStates = [],
) {
}
public function stateForList(int $listId): string
{
return $this->listStates[$listId] ?? match ($this->state) {
self::STATE_SUBSCRIBED => self::LIST_STATE_SUCCESS,
default => self::LIST_STATE_PENDING,
};
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\NewsletterConsent;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<NewsletterConsent>
*/
class NewsletterConsentRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, NewsletterConsent::class);
}
public function findActiveByEmail(string $email): ?NewsletterConsent
{
return $this->createQueryBuilder('c')
->where('c.email = :email')
->andWhere('c.confirmedAt IS NOT NULL')
->andWhere('c.revokedAt IS NULL')
->setParameter('email', mb_strtolower(trim($email)))
->orderBy('c.confirmedAt', 'DESC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
}
public function findOneByEmailAndListId(string $email, int $mailjetListId): ?NewsletterConsent
{
return $this->findOneBy([
'email' => mb_strtolower(trim($email)),
'mailjetListId' => $mailjetListId,
]);
}
}
@@ -4,26 +4,26 @@ declare(strict_types=1);
namespace App\Repository;
use App\Entity\NewsletterOptInConfirmation;
use App\Entity\NewsletterOptInRequest;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<NewsletterOptInConfirmation>
* @extends ServiceEntityRepository<NewsletterOptInRequest>
*/
class NewsletterOptInConfirmationRepository extends ServiceEntityRepository
class NewsletterOptInRequestRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, NewsletterOptInConfirmation::class);
parent::__construct($registry, NewsletterOptInRequest::class);
}
public function findByTokenHash(string $tokenHash): ?NewsletterOptInConfirmation
public function findByTokenHash(string $tokenHash): ?NewsletterOptInRequest
{
return $this->findOneBy(['tokenHash' => $tokenHash]);
}
public function findPendingByEmail(string $email): ?NewsletterOptInConfirmation
public function findPendingByEmail(string $email): ?NewsletterOptInRequest
{
return $this->createQueryBuilder('c')
->where('c.email = :email')
@@ -50,6 +50,17 @@ class NewsletterOptInConfirmationRepository extends ServiceEntityRepository
->execute();
}
public function deletePendingByEmail(string $email): int
{
return (int) $this->createQueryBuilder('c')
->delete()
->where('c.email = :email')
->andWhere('c.confirmedAt IS NULL')
->setParameter('email', mb_strtolower(trim($email)))
->getQuery()
->execute();
}
public function deleteExpiredPending(): int
{
$threshold = new \DateTimeImmutable();
+154 -39
View File
@@ -10,21 +10,52 @@ use Symfony\Contracts\HttpClient\HttpClientInterface;
class MailjetApiClient
{
private const DEFAULT_BASE_URL = 'https://api.mailjet.com/v3/REST';
private const string DEFAULT_BASE_URL = 'https://api.mailjet.com/v3/REST';
private const int NAME_MAX_LENGTH = 255;
/**
* @param array{firstName?: string, lastName?: string} $contactMetadataFields
*/
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,
private readonly ?string $apiKey = null,
private readonly ?string $apiSecret = null,
private readonly ?string $apiBaseUrl = null,
private readonly ?string $defaultListId = null,
private readonly array $contactMetadataFields = [],
) {
}
public function isSubscribed(string $email): bool
public function upsertContact(string $email, ?string $firstName = null, ?string $lastName = null): void
{
$this->assertConfigured();
$normalizedEmail = $this->normalizeEmail($email);
$normalizedFirstName = $this->normalizeName($firstName);
$normalizedLastName = $this->normalizeName($lastName);
if (null === $normalizedFirstName && null === $normalizedLastName) {
return;
}
$contactData = $this->buildContactDataPayload($normalizedFirstName, $normalizedLastName);
if ([] === $contactData) {
return;
}
$contactId = $this->findOrCreateContactId($normalizedEmail);
$this->request('POST', 'contactdata', [
'json' => [
'ContactID' => $contactId,
'Data' => $contactData,
],
]);
}
public function isSubscribed(string $email, ?int $listId = null): bool
{
$resolvedListId = $this->resolveListId($listId);
$normalizedEmail = $this->normalizeEmail($email);
$contactId = $this->resolveContactId($normalizedEmail);
@@ -35,17 +66,17 @@ class MailjetApiClient
$response = $this->request('GET', 'Listrecipient', [
'query' => [
'Contact' => $contactId,
'ContactsList' => $this->mailjetNewsletterListId,
'ContactsList' => $resolvedListId,
],
]);
$entries = $response['Data'] ?? [];
if (!is_array($entries)) {
if (false === is_array($entries)) {
return false;
}
foreach ($entries as $entry) {
if (!is_array($entry)) {
if (false === is_array($entry)) {
continue;
}
@@ -63,12 +94,12 @@ class MailjetApiClient
return false;
}
public function ensureSubscribed(string $email): void
public function ensureSubscribed(string $email, ?int $listId = null): void
{
$this->assertConfigured();
$resolvedListId = $this->resolveListId($listId);
$normalizedEmail = $this->normalizeEmail($email);
$resource = sprintf('Contactslist/%s/managecontact', $this->mailjetNewsletterListId);
$resource = sprintf('Contactslist/%s/managecontact', $resolvedListId);
try {
$this->request('POST', $resource, [
@@ -80,7 +111,7 @@ class MailjetApiClient
} catch (NewsletterProviderException $exception) {
$this->logger->error('Mailjet subscribe failed', [
'email' => $normalizedEmail,
'list_id' => $this->mailjetNewsletterListId,
'list_id' => $resolvedListId,
'error' => $exception->getMessage(),
]);
@@ -88,33 +119,50 @@ class MailjetApiClient
}
}
public function unsubscribe(string $email): void
private function findOrCreateContactId(string $email): int
{
$this->assertConfigured();
$normalizedEmail = $this->normalizeEmail($email);
$resource = sprintf('Contactslist/%s/managecontact', $this->mailjetNewsletterListId);
$contactId = $this->resolveContactId($email);
if (null !== $contactId) {
return $contactId;
}
try {
$this->request('POST', $resource, [
'json' => [
'Email' => $normalizedEmail,
'Action' => 'unsub',
],
]);
$contactId = $this->createContact($email);
if (null !== $contactId) {
return $contactId;
}
} catch (NewsletterProviderException $exception) {
$this->logger->error('Mailjet unsubscribe failed', [
'email' => $normalizedEmail,
'list_id' => $this->mailjetNewsletterListId,
'error' => $exception->getMessage(),
]);
$contactId = $this->resolveContactId($email);
if (null !== $contactId) {
return $contactId;
}
throw $exception;
}
$contactId = $this->resolveContactId($email);
if (null !== $contactId) {
return $contactId;
}
throw new NewsletterProviderException(sprintf('Mailjet contact could not be resolved for %s', $email));
}
private function createContact(string $email): ?int
{
$response = $this->request('POST', 'Contact', [
'json' => [
'Email' => $email,
],
]);
return $this->extractContactId($response);
}
private function resolveContactId(string $email): ?int
{
$this->assertConfigured();
$response = $this->request('GET', 'Contact', [
'query' => [
'Email' => $email,
@@ -123,12 +171,48 @@ class MailjetApiClient
'allow_404' => true,
]);
return $this->extractContactId($response);
}
/**
* @param array<string, mixed> $response
*/
private function extractContactId(array $response): ?int
{
$entry = $response['Data'][0] ?? null;
if (!is_array($entry) || !isset($entry['ID'])) {
return null;
if (true === is_array($entry) && true === isset($entry['ID'])) {
return (int) $entry['ID'];
}
return (int) $entry['ID'];
if (true === isset($response['Data']['ID'])) {
return (int) $response['Data']['ID'];
}
return null;
}
/**
* @return list<array{Name: string, Value: string}>
*/
private function buildContactDataPayload(?string $firstName, ?string $lastName): array
{
$data = [];
if (null !== $firstName && true === isset($this->contactMetadataFields['firstName'])) {
$data[] = [
'Name' => $this->contactMetadataFields['firstName'],
'Value' => $firstName,
];
}
if (null !== $lastName && true === isset($this->contactMetadataFields['lastName'])) {
$data[] = [
'Name' => $this->contactMetadataFields['lastName'],
'Value' => $lastName,
];
}
return $data;
}
/**
@@ -141,12 +225,14 @@ class MailjetApiClient
$allow404 = true === ($options['allow_404'] ?? false);
unset($options['allow_404']);
$resourcePath = trim($resource, '/');
try {
$response = $this->httpClient->request(
$method,
sprintf('%s/%s', $this->getBaseUrl(), $resource),
sprintf('%s/%s', $this->getBaseUrl(), $resourcePath),
array_merge($options, [
'auth_basic' => sprintf('%s:%s', (string) $this->mailjetApiKey, (string) $this->mailjetApiSecret),
'auth_basic' => sprintf('%s:%s', $this->apiKey, $this->apiSecret),
])
);
@@ -165,7 +251,7 @@ class MailjetApiClient
return $payload;
} catch (\Throwable $exception) {
if ($allow404 && str_contains($exception->getMessage(), '404')) {
if (true === $allow404 && true === str_contains($exception->getMessage(), '404')) {
return [];
}
@@ -179,8 +265,8 @@ class MailjetApiClient
private function getBaseUrl(): string
{
$baseUrl = null !== $this->mailjetApiBaseUrl && '' !== trim($this->mailjetApiBaseUrl)
? trim($this->mailjetApiBaseUrl)
$baseUrl = null !== $this->apiBaseUrl && '' !== trim($this->apiBaseUrl)
? trim($this->apiBaseUrl)
: self::DEFAULT_BASE_URL;
return rtrim($baseUrl, '/');
@@ -188,13 +274,42 @@ class MailjetApiClient
private function assertConfigured(): void
{
if (empty($this->mailjetApiKey) || empty($this->mailjetApiSecret) || empty($this->mailjetNewsletterListId)) {
if (true === empty($this->apiKey) || true === empty($this->apiSecret)) {
throw new NewsletterProviderException('Mailjet newsletter service is not fully configured.');
}
}
private function resolveListId(?int $listId): string
{
$this->assertConfigured();
$resolvedListId = null !== $listId
? (string) $listId
: (string) $this->defaultListId;
if ('' === trim($resolvedListId)) {
throw new NewsletterProviderException('Mailjet newsletter list is not configured.');
}
return trim($resolvedListId);
}
private function normalizeEmail(string $email): string
{
return mb_strtolower(trim($email));
}
private function normalizeName(?string $name): ?string
{
if (null === $name) {
return null;
}
$trimmed = trim($name);
if ('' === $trimmed) {
return null;
}
return mb_substr($trimmed, 0, self::NAME_MAX_LENGTH);
}
}
+396 -13
View File
@@ -5,26 +5,38 @@ declare(strict_types=1);
namespace App\Service;
use App\Email\Mailer;
use App\Entity\NewsletterOptInConfirmation;
use App\Entity\NewsletterConsent;
use App\Entity\NewsletterOptInRequest;
use App\Exception\NewsletterListNotAllowedException;
use App\Exception\NewsletterProviderException;
use App\Model\NewsletterConfirmationResult;
use App\Repository\NewsletterOptInConfirmationRepository;
use App\Model\NewsletterSubscriptionRequestResult;
use App\Repository\NewsletterOptInRequestRepository;
use App\Repository\NewsletterConsentRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
class NewsletterManager
{
private const int NAME_MAX_LENGTH = 255;
/**
* @param array<int, string> $mailjetLists
*/
public function __construct(
private readonly NewsletterOptInConfirmationRepository $confirmationRepository,
private readonly NewsletterOptInRequestRepository $optInRequestRepository,
private readonly NewsletterConsentRepository $consentRepository,
private readonly EntityManagerInterface $entityManager,
private readonly MailjetApiClient $newsletterService,
private readonly Mailer $mailer,
private readonly LoggerInterface $logger,
private readonly int $newsletterConfirmationTtlHours,
private readonly array $mailjetLists = [],
private readonly ?string $defaultMailjetListId = null,
) {
}
public function requestConfirmation(string $email): void
public function requestConfirmation(string $email, ?string $firstName = null, ?string $lastName = null): void
{
$normalizedEmail = $this->normalizeEmail($email);
@@ -32,25 +44,202 @@ class NewsletterManager
throw new \InvalidArgumentException('Invalid email for newsletter confirmation request.');
}
// Account/booking opt-ins target the default Mailjet list, so persist that intent immediately.
$this->createOrRefreshConfirmation(
$normalizedEmail,
mailjetListIds: [$this->defaultMailjetListId()],
firstName: $this->normalizeName($firstName),
lastName: $this->normalizeName($lastName),
);
}
public function requestDefaultListSubscription(string $email, ?string $firstName = null, ?string $lastName = null): NewsletterSubscriptionRequestResult
{
return $this->requestApiSubscription(
$email,
[$this->defaultMailjetListId()],
null,
$firstName,
$lastName,
);
}
public function hasConfirmedOptIn(string $email): bool
{
$normalizedEmail = $this->normalizeEmail($email);
return null !== $this->consentRepository->findActiveByEmail($normalizedEmail);
}
/**
* @param list<int|string> $mailjetListIds
* @param list<int|string>|null $knownMailjetListIds
*/
public function requestApiSubscription(string $email, array $mailjetListIds, ?array $knownMailjetListIds = null, ?string $firstName = null, ?string $lastName = null): NewsletterSubscriptionRequestResult
{
$normalizedEmail = $this->normalizeEmail($email);
$normalizedListIds = $this->normalizeMailjetListIds($mailjetListIds);
$knownNormalizedListIds = null === $knownMailjetListIds
? $normalizedListIds
: $this->normalizeMailjetListIds($knownMailjetListIds);
$normalizedFirstName = $this->normalizeName($firstName);
$normalizedLastName = $this->normalizeName($lastName);
if (false === filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException('Invalid email for newsletter subscription request.');
}
if (null !== $knownMailjetListIds) {
$unknownListIds = array_values(array_diff($normalizedListIds, $knownNormalizedListIds));
if ([] !== $unknownListIds) {
throw new NewsletterListNotAllowedException($normalizedListIds, $unknownListIds);
}
}
$subscribedListIds = $this->subscribedMailjetListIds($normalizedEmail, $normalizedListIds);
$missingListIds = array_values(array_diff($normalizedListIds, $subscribedListIds));
$hasConfirmedOptIn = null !== $this->consentRepository->findActiveByEmail($normalizedEmail);
if ([] === $missingListIds) {
$this->recordSubscription($normalizedEmail, $normalizedListIds, [], $normalizedFirstName, $normalizedLastName);
return new NewsletterSubscriptionRequestResult(
$normalizedEmail,
$normalizedListIds,
NewsletterSubscriptionRequestResult::STATE_SUBSCRIBED,
false,
$this->createListStates($normalizedListIds, NewsletterSubscriptionRequestResult::LIST_STATE_ALREADY_REGISTERED),
);
}
if (true === $hasConfirmedOptIn || [] !== $subscribedListIds || true === $this->isSubscribedToKnownList($normalizedEmail, $knownNormalizedListIds, $normalizedListIds)) {
$this->recordSubscription($normalizedEmail, $normalizedListIds, $missingListIds, $normalizedFirstName, $normalizedLastName);
return new NewsletterSubscriptionRequestResult(
$normalizedEmail,
$normalizedListIds,
NewsletterSubscriptionRequestResult::STATE_SUBSCRIBED,
false,
$this->createSubscribedListStates($normalizedListIds, $missingListIds),
);
}
$this->optInRequestRepository->deleteExpiredPendingByEmail($normalizedEmail);
$pendingConfirmation = $this->optInRequestRepository->findPendingByEmail($normalizedEmail);
if (null !== $pendingConfirmation) {
if ($pendingConfirmation->getMailjetListIds() !== $normalizedListIds || true === $this->pendingConfirmationNamesChanged($pendingConfirmation, $normalizedFirstName, $normalizedLastName)) {
// One pending DOI cycle per email: newest checkbox selection replaces the old intent.
$this->createOrRefreshConfirmation($normalizedEmail, false, true, $normalizedListIds, $normalizedFirstName, $normalizedLastName);
return new NewsletterSubscriptionRequestResult(
$normalizedEmail,
$normalizedListIds,
NewsletterSubscriptionRequestResult::STATE_CONFIRMATION_REQUESTED,
true,
$this->createListStates($normalizedListIds, NewsletterSubscriptionRequestResult::LIST_STATE_PENDING),
);
}
return new NewsletterSubscriptionRequestResult(
$normalizedEmail,
$normalizedListIds,
NewsletterSubscriptionRequestResult::STATE_PENDING_CONFIRMATION,
false,
$this->createListStates($normalizedListIds, NewsletterSubscriptionRequestResult::LIST_STATE_PENDING),
);
}
$this->createOrRefreshConfirmation($normalizedEmail, false, false, $normalizedListIds, $normalizedFirstName, $normalizedLastName);
return new NewsletterSubscriptionRequestResult(
$normalizedEmail,
$normalizedListIds,
NewsletterSubscriptionRequestResult::STATE_CONFIRMATION_REQUESTED,
true,
$this->createListStates($normalizedListIds, NewsletterSubscriptionRequestResult::LIST_STATE_PENDING),
);
}
public function hasPendingConfirmation(string $email): bool
{
$normalizedEmail = $this->normalizeEmail($email);
$this->optInRequestRepository->deleteExpiredPendingByEmail($normalizedEmail);
return null !== $this->optInRequestRepository->findPendingByEmail($normalizedEmail);
}
/**
* @param list<mixed> $mailjetListIds
*
* @return list<int>
*/
public function normalizeMailjetListIds(array $mailjetListIds): array
{
$normalizedListIds = [];
foreach ($mailjetListIds as $mailjetListId) {
if (true === is_int($mailjetListId)) {
$normalizedListId = $mailjetListId;
} elseif (true === is_string($mailjetListId) && true === ctype_digit(trim($mailjetListId))) {
$normalizedListId = (int) trim($mailjetListId);
} else {
throw new \InvalidArgumentException('Mailjet list IDs must be positive integers.');
}
if ($normalizedListId <= 0) {
throw new \InvalidArgumentException('Mailjet list IDs must be positive integers.');
}
$normalizedListIds[$normalizedListId] = $normalizedListId;
}
if ([] === $normalizedListIds) {
throw new \InvalidArgumentException('At least one Mailjet list ID is required.');
}
sort($normalizedListIds, SORT_NUMERIC);
return $normalizedListIds;
}
/**
* @param list<int|string> $mailjetListIds
*/
private function createOrRefreshConfirmation(string $normalizedEmail, bool $deleteExpiredPending = true, bool $findExistingPending = true, array $mailjetListIds = [], ?string $firstName = null, ?string $lastName = null): void
{
$token = $this->generateToken();
$tokenHash = $this->hashToken($token);
$expiresAt = new \DateTimeImmutable(sprintf('+%d hours', $this->newsletterConfirmationTtlHours));
$this->confirmationRepository->deleteExpiredPendingByEmail($normalizedEmail);
$pendingConfirmation = $this->confirmationRepository->findPendingByEmail($normalizedEmail);
if (true === $deleteExpiredPending) {
$this->optInRequestRepository->deleteExpiredPendingByEmail($normalizedEmail);
}
$pendingConfirmation = true === $findExistingPending
? $this->optInRequestRepository->findPendingByEmail($normalizedEmail)
: null;
$wasExisting = null !== $pendingConfirmation;
$previousTokenHash = null;
$previousExpiresAt = null;
$previousMailjetListIds = [];
$previousFirstName = null;
$previousLastName = null;
if (null !== $pendingConfirmation) {
$previousTokenHash = $pendingConfirmation->getTokenHash();
$previousExpiresAt = $pendingConfirmation->getExpiresAt();
$pendingConfirmation->refreshRequest($tokenHash, $expiresAt);
$previousMailjetListIds = $pendingConfirmation->getMailjetListIds();
$previousFirstName = $pendingConfirmation->getFirstName();
$previousLastName = $pendingConfirmation->getLastName();
$pendingConfirmation->refreshRequest($tokenHash, $expiresAt, $mailjetListIds, $firstName, $lastName);
} else {
$pendingConfirmation = new NewsletterOptInConfirmation(
$pendingConfirmation = new NewsletterOptInRequest(
email: $normalizedEmail,
tokenHash: $tokenHash,
expiresAt: $expiresAt,
mailjetListIds: $mailjetListIds,
firstName: $firstName,
lastName: $lastName,
);
$this->entityManager->persist($pendingConfirmation);
@@ -61,6 +250,7 @@ class NewsletterManager
try {
$context = [
'token' => $token,
'newsletterLists' => $this->createListIdMapping($mailjetListIds),
];
$options = [
'to' => $normalizedEmail,
@@ -70,7 +260,8 @@ class NewsletterManager
$this->mailer->createAndSendEmail($context, $options);
} catch (\Throwable $exception) {
if (true === $wasExisting) {
$pendingConfirmation->refreshRequest($previousTokenHash, $previousExpiresAt);
$pendingConfirmation->refreshRequest($previousTokenHash, $previousExpiresAt, $previousMailjetListIds, $previousFirstName, $previousLastName);
$pendingConfirmation->setNames($previousFirstName, $previousLastName);
} else {
$this->entityManager->remove($pendingConfirmation);
}
@@ -96,21 +287,21 @@ class NewsletterManager
}
$tokenHash = $this->hashToken($normalizedToken);
$confirmation = $this->confirmationRepository->findByTokenHash($tokenHash);
$confirmation = $this->optInRequestRepository->findByTokenHash($tokenHash);
if (null === $confirmation) {
return new NewsletterConfirmationResult(
NewsletterConfirmationResult::STATUS_INVALID,
);
}
if ($confirmation->isConfirmed()) {
if (true === $confirmation->isConfirmed()) {
return new NewsletterConfirmationResult(
NewsletterConfirmationResult::STATUS_ALREADY_USED,
$confirmation->getEmail(),
);
}
if ($confirmation->isExpired()) {
if (true === $confirmation->isExpired()) {
$this->entityManager->remove($confirmation);
$this->entityManager->flush();
@@ -120,9 +311,22 @@ class NewsletterManager
);
}
$this->newsletterService->ensureSubscribed($confirmation->getEmail());
$mailjetListIds = $confirmation->getMailjetListIds();
if ([] === $mailjetListIds) {
// Legacy/account confirmations did not choose explicit lists; treat them as default-list opt-ins.
$mailjetListIds = [$this->defaultMailjetListId()];
$confirmation->setMailjetListIds($mailjetListIds);
}
$this->syncMailjetContact($confirmation->getEmail(), $confirmation->getFirstName(), $confirmation->getLastName());
foreach ($mailjetListIds as $mailjetListId) {
$this->newsletterService->ensureSubscribed($confirmation->getEmail(), $mailjetListId);
}
$confirmation->markConfirmed();
$this->upsertConfirmedConsents($confirmation->getEmail(), $mailjetListIds, $confirmation->getFirstName(), $confirmation->getLastName(), false, false);
$this->entityManager->remove($confirmation);
$this->entityManager->flush();
$this->logger->info('Newsletter double opt-in confirmed', [
@@ -149,4 +353,183 @@ class NewsletterManager
{
return mb_strtolower(trim($email));
}
private function normalizeName(?string $name): ?string
{
if (null === $name) {
return null;
}
$trimmed = trim($name);
if ('' === $trimmed) {
return null;
}
return mb_substr($trimmed, 0, self::NAME_MAX_LENGTH);
}
private function syncMailjetContact(string $email, ?string $firstName, ?string $lastName): void
{
if (null === $firstName && null === $lastName) {
return;
}
try {
$this->newsletterService->upsertContact($email, $firstName, $lastName);
} catch (\Throwable $exception) {
$this->logger->warning('Mailjet contact sync failed', [
'email' => $email,
'error' => $exception->getMessage(),
]);
}
}
/**
* @param list<int> $mailjetListIds
*
* @return list<int>
*/
private function subscribedMailjetListIds(string $email, array $mailjetListIds): array
{
$subscribedListIds = [];
foreach ($mailjetListIds as $mailjetListId) {
if (true === $this->newsletterService->isSubscribed($email, $mailjetListId)) {
$subscribedListIds[] = $mailjetListId;
}
}
return $subscribedListIds;
}
/**
* @param list<int> $mailjetListIds
* @param list<int> $missingListIds
*/
private function recordSubscription(string $email, array $mailjetListIds, array $missingListIds, ?string $firstName, ?string $lastName): void
{
$this->syncMailjetContact($email, $firstName, $lastName);
foreach ($missingListIds as $mailjetListId) {
$this->newsletterService->ensureSubscribed($email, $mailjetListId);
}
$this->upsertConfirmedConsents($email, $mailjetListIds, $firstName, $lastName);
}
private function pendingConfirmationNamesChanged(NewsletterOptInRequest $pendingConfirmation, ?string $firstName, ?string $lastName): bool
{
if (null !== $firstName && $firstName !== $pendingConfirmation->getFirstName()) {
return true;
}
if (null !== $lastName && $lastName !== $pendingConfirmation->getLastName()) {
return true;
}
return false;
}
/**
* @param list<int> $mailjetListIds
*
* @return array<int, string>
*/
private function createListStates(array $mailjetListIds, string $state): array
{
return array_fill_keys($mailjetListIds, $state);
}
/**
* @param list<int> $mailjetListIds
* @param list<int> $subscribedNowListIds
*
* @return array<int, string>
*/
private function createSubscribedListStates(array $mailjetListIds, array $subscribedNowListIds): array
{
$subscribedNow = array_fill_keys($subscribedNowListIds, true);
$states = [];
foreach ($mailjetListIds as $mailjetListId) {
$states[$mailjetListId] = isset($subscribedNow[$mailjetListId])
? NewsletterSubscriptionRequestResult::LIST_STATE_SUCCESS
: NewsletterSubscriptionRequestResult::LIST_STATE_ALREADY_REGISTERED;
}
return $states;
}
/**
* @param list<int> $mailjetListIds
*/
private function upsertConfirmedConsents(string $email, array $mailjetListIds, ?string $firstName, ?string $lastName, bool $flush = true, bool $deletePending = true): void
{
foreach ($mailjetListIds as $mailjetListId) {
$consent = $this->consentRepository->findOneByEmailAndListId($email, $mailjetListId);
if (null === $consent) {
$consent = new NewsletterConsent($email, $mailjetListId, $firstName, $lastName);
$this->entityManager->persist($consent);
}
$consent->markConfirmed(firstName: $firstName, lastName: $lastName);
}
if (true === $flush) {
$this->entityManager->flush();
}
if (true === $deletePending) {
$this->optInRequestRepository->deletePendingByEmail($email);
}
}
/**
* @param list<int> $knownMailjetListIds
* @param list<int> $alreadyCheckedListIds
*/
private function isSubscribedToKnownList(string $normalizedEmail, array $knownMailjetListIds, array $alreadyCheckedListIds): bool
{
$alreadyCheckedListIdMap = array_fill_keys($alreadyCheckedListIds, true);
foreach ($knownMailjetListIds as $mailjetListId) {
if (true === isset($alreadyCheckedListIdMap[$mailjetListId])) {
continue;
}
if (true === $this->newsletterService->isSubscribed($normalizedEmail, $mailjetListId)) {
return true;
}
}
return false;
}
private function defaultMailjetListId(): int
{
if (null === $this->defaultMailjetListId || '' === trim($this->defaultMailjetListId)) {
throw new NewsletterProviderException('Mailjet newsletter list is not configured.');
}
return $this->normalizeMailjetListIds([$this->defaultMailjetListId])[0];
}
/**
* @param list<int> $mailjetListIds
*
* @return list<array{id: int, label: string}>
*/
public function createListIdMapping(array $mailjetListIds): array
{
$lists = [];
foreach ($mailjetListIds as $mailjetListId) {
$lists[] = [
'id' => $mailjetListId,
'label' => (string) ($this->mailjetLists[$mailjetListId] ?? $mailjetListId),
];
}
return $lists;
}
}