feat: MailJet list handling with DOI, webhook receiver and API endpoint
addresses #869cut134
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user