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)); } }