$mailjetLists */ public function __construct( 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, ?string $firstName = null, ?string $lastName = null): void { $normalizedEmail = $this->normalizeEmail($email); if (false === filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL)) { 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 $mailjetListIds * @param list|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); } } $hasConfirmedOptIn = null !== $this->consentRepository->findActiveByEmail($normalizedEmail); // We trust our local consent state for DOI decisions. If Mailjet is ahead of the database, // we accept a redundant confirmation cycle and reconcile the contact on confirmation. if (true === $hasConfirmedOptIn) { $confirmedListIds = $this->confirmedMailjetListIds($normalizedEmail, $normalizedListIds); $missingListIds = array_values(array_diff($normalizedListIds, $confirmedListIds)); 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), ); } $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 $mailjetListIds * * @return list */ 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 $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)); 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(); $previousMailjetListIds = $pendingConfirmation->getMailjetListIds(); $previousFirstName = $pendingConfirmation->getFirstName(); $previousLastName = $pendingConfirmation->getLastName(); $pendingConfirmation->refreshRequest($tokenHash, $expiresAt, $mailjetListIds, $firstName, $lastName); } else { $pendingConfirmation = new NewsletterOptInRequest( email: $normalizedEmail, tokenHash: $tokenHash, expiresAt: $expiresAt, mailjetListIds: $mailjetListIds, firstName: $firstName, lastName: $lastName, ); $this->entityManager->persist($pendingConfirmation); } $this->entityManager->flush(); try { $context = [ 'token' => $token, 'newsletterLists' => $this->createListIdMapping($mailjetListIds), ]; $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) { $pendingConfirmation->refreshRequest($previousTokenHash, $previousExpiresAt, $previousMailjetListIds, $previousFirstName, $previousLastName); $pendingConfirmation->setNames($previousFirstName, $previousLastName); } 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->optInRequestRepository->findByTokenHash($tokenHash); if (null === $confirmation) { return new NewsletterConfirmationResult( NewsletterConfirmationResult::STATUS_INVALID, ); } if (true === $confirmation->isConfirmed()) { return new NewsletterConfirmationResult( NewsletterConfirmationResult::STATUS_ALREADY_USED, $confirmation->getEmail(), ); } if (true === $confirmation->isExpired()) { $this->entityManager->remove($confirmation); $this->entityManager->flush(); return new NewsletterConfirmationResult( NewsletterConfirmationResult::STATUS_EXPIRED, $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', [ '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)); } 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 $mailjetListIds * * @return list */ private function confirmedMailjetListIds(string $email, array $mailjetListIds): array { $confirmedListIds = []; foreach ($mailjetListIds as $mailjetListId) { $consent = $this->consentRepository->findOneByEmailAndListId($email, $mailjetListId); if (null !== $consent && true === $consent->isConfirmed()) { $confirmedListIds[] = $mailjetListId; } } return $confirmedListIds; } /** * @param list $mailjetListIds * @param list $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 $mailjetListIds * * @return array */ private function createListStates(array $mailjetListIds, string $state): array { return array_fill_keys($mailjetListIds, $state); } /** * @param list $mailjetListIds * @param list $subscribedNowListIds * * @return array */ 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 $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); } } 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 $mailjetListIds * * @return list */ public function createListIdMapping(array $mailjetListIds): array { $lists = []; foreach ($mailjetListIds as $mailjetListId) { $lists[] = [ 'id' => $mailjetListId, 'label' => (string) ($this->mailjetLists[$mailjetListId] ?? $mailjetListId), ]; } return $lists; } }