feat: integrate with MailJet API for newsletter registration

This commit is contained in:
Björn Fromme
2026-03-19 09:42:00 +01:00
parent 66c55f0722
commit d1e2bf3e7d
26 changed files with 1537 additions and 47 deletions
@@ -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));
}
}