feat: integrate with MailJet API for newsletter registration
This commit is contained in:
@@ -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