From c16f368293683fc4cee42f45e8b3abe700afd584 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?=
Date: Wed, 4 Nov 2026 15:32:50 +0100
Subject: [PATCH] feat: create Mailjet lists for travel mailings
---
config/packages/zenstruck_schedule.yaml | 4 +
migrations/Version20261104142531.php | 26 +++
.../CleanupTravelMailingListsCommand.php | 67 ++++++++
.../TravelMailing/CreateListController.php | 94 +++++++++++
.../Admin/TravelMailing/IndexController.php | 8 +
src/Entity/TravelMailingList.php | 66 ++++++++
src/Mailjet/ApiClient.php | 148 +++++++++++++++++-
.../TravelMailingListRepository.php | 38 +++++
src/Service/TravelDataProvider.php | 12 ++
src/Service/TravelIndex.php | 20 +++
src/Service/TravelMailingListManager.php | 142 +++++++++++++++++
.../admin/travel_mailing/index.html.twig | 25 +++
tests/Mailjet/ApiClientTest.php | 84 ++++++++++
tests/Service/TravelIndexTest.php | 26 +++
.../Service/TravelMailingListManagerTest.php | 92 +++++++++++
15 files changed, 851 insertions(+), 1 deletion(-)
create mode 100644 migrations/Version20261104142531.php
create mode 100644 src/Command/CleanupTravelMailingListsCommand.php
create mode 100644 src/Controller/Admin/TravelMailing/CreateListController.php
create mode 100644 src/Entity/TravelMailingList.php
create mode 100644 src/Repository/TravelMailingListRepository.php
create mode 100644 src/Service/TravelMailingListManager.php
create mode 100644 tests/Service/TravelMailingListManagerTest.php
diff --git a/config/packages/zenstruck_schedule.yaml b/config/packages/zenstruck_schedule.yaml
index 06c4840d..6699378a 100644
--- a/config/packages/zenstruck_schedule.yaml
+++ b/config/packages/zenstruck_schedule.yaml
@@ -29,6 +29,10 @@ zenstruck_schedule:
frequency: "45 1 * * *"
description: "Removes expired pending newsletter double opt-in requests"
+ - task: app:cleanup:travel-mailing-lists
+ frequency: "50 1 * * *"
+ description: "Deletes Mailjet travel mailing lists and their orphaned contacts 30 days after the travel started"
+
- task: league:oauth2-server:clear-expired-tokens
frequency: "0 * * * *"
description: "Removes expired oauth2 tokens hourly"
diff --git a/migrations/Version20261104142531.php b/migrations/Version20261104142531.php
new file mode 100644
index 00000000..16a27f4c
--- /dev/null
+++ b/migrations/Version20261104142531.php
@@ -0,0 +1,26 @@
+addSql('CREATE TABLE travel_mailing_list (id INT AUTO_INCREMENT NOT NULL, travel_code VARCHAR(32) NOT NULL, mailjet_list_id INT NOT NULL, delete_after DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', UNIQUE INDEX UNIQ_68DC6E28BC4DD335 (travel_code), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
+ }
+
+ public function down(Schema $schema): void
+ {
+ $this->addSql('DROP TABLE travel_mailing_list');
+ }
+}
diff --git a/src/Command/CleanupTravelMailingListsCommand.php b/src/Command/CleanupTravelMailingListsCommand.php
new file mode 100644
index 00000000..4adc377c
--- /dev/null
+++ b/src/Command/CleanupTravelMailingListsCommand.php
@@ -0,0 +1,67 @@
+travelMailingListRepository->findDueForDeletion(new \DateTimeImmutable());
+ if ([] === $mailingLists) {
+ $io->success('No travel mailing lists due for deletion.');
+
+ return Command::SUCCESS;
+ }
+
+ $failed = 0;
+ foreach ($mailingLists as $mailingList) {
+ try {
+ $deletedContacts = $this->travelMailingListManager->delete($mailingList);
+ $io->writeln(sprintf('Deleted %s (list %d) and %d orphaned contact(s).', $mailingList->getTravelCode(), $mailingList->getMailjetListId(), $deletedContacts));
+ } catch (NewsletterProviderException $exception) {
+ // The row stays, so the next run picks the list up again.
+ ++$failed;
+ $this->logger->error('Delete travel mailing list failed', [
+ 'travel_code' => $mailingList->getTravelCode(),
+ 'mailjet_list_id' => $mailingList->getMailjetListId(),
+ 'error' => $exception->getMessage(),
+ ]);
+ $io->error(sprintf('%s (list %d): %s', $mailingList->getTravelCode(), $mailingList->getMailjetListId(), $exception->getMessage()));
+ }
+ }
+
+ if ($failed > 0) {
+ return Command::FAILURE;
+ }
+
+ $io->success(sprintf('Deleted %d travel mailing list(s).', count($mailingLists)));
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/Controller/Admin/TravelMailing/CreateListController.php b/src/Controller/Admin/TravelMailing/CreateListController.php
new file mode 100644
index 00000000..21611d8f
--- /dev/null
+++ b/src/Controller/Admin/TravelMailing/CreateListController.php
@@ -0,0 +1,94 @@
+isCsrfTokenValid(self::CSRF_TOKEN_ID, $request->request->getString('_token'))) {
+ throw $this->createAccessDeniedException('Invalid CSRF token.');
+ }
+
+ $travelCode = (new DateCodeUtility())->sanitize($request->request->getString('travelCode'));
+ $returnUrl = $this->generateUrl('app_admin_travelmailing', ['travelCode' => $travelCode]);
+
+ if (null !== $this->travelMailingListRepository->findOneByTravelCode($travelCode)) {
+ $this->addFlash('error', sprintf('Für %s gibt es bereits eine Mailjet-Liste.', $travelCode));
+
+ return $this->redirect($returnUrl);
+ }
+
+ $travelStartsAt = $this->travelDataProvider->mapDateCodeToStartDate($travelCode);
+ if (null === $travelStartsAt) {
+ $this->addFlash('error', sprintf('Für %s ist kein Reisebeginn bekannt, die Liste könnte nicht automatisch gelöscht werden.', $travelCode));
+
+ return $this->redirect($returnUrl);
+ }
+
+ // Fetched again rather than posted from the page, so the list holds what BusPro reports now.
+ try {
+ $recipients = $this->travelsClient->getMailingRecipients($travelCode)->data;
+ } catch (BpnConnectException) {
+ $this->addFlash('error', 'Die Teilnehmerliste konnte nicht von BusPro abgerufen werden. Bitte später erneut versuchen.');
+
+ return $this->redirect($returnUrl);
+ }
+
+ if ([] === $recipients) {
+ $this->addFlash('error', sprintf('Keine Empfänger für %s gefunden.', $travelCode));
+
+ return $this->redirect($returnUrl);
+ }
+
+ try {
+ $mailingList = $this->travelMailingListManager->create($travelCode, $recipients, $travelStartsAt);
+ } catch (NewsletterProviderException $exception) {
+ $this->adminLogger->error('Create travel mailing list failed', [
+ 'travel_code' => $travelCode,
+ 'error' => $exception->getMessage(),
+ ]);
+ $this->addFlash('error', 'Die Mailjet-Liste konnte nicht angelegt werden: '.$exception->getMessage());
+
+ return $this->redirect($returnUrl);
+ }
+
+ $this->adminLogger->info('Create travel mailing list', [
+ 'travel_code' => $travelCode,
+ 'mailjet_list_id' => $mailingList->getMailjetListId(),
+ 'recipients' => count($recipients),
+ ]);
+ $this->addFlash('success', sprintf('Die Mailjet-Liste %s wurde mit %d Empfängern angelegt.', $travelCode, count($recipients)));
+
+ return $this->redirect($returnUrl);
+ }
+}
diff --git a/src/Controller/Admin/TravelMailing/IndexController.php b/src/Controller/Admin/TravelMailing/IndexController.php
index 34894c80..eec53f3a 100644
--- a/src/Controller/Admin/TravelMailing/IndexController.php
+++ b/src/Controller/Admin/TravelMailing/IndexController.php
@@ -9,6 +9,7 @@ use App\BpnConnect\TravelsClient;
use App\BusProNet\Utility\DateCodeUtility;
use App\Form\Admin\TravelMailingLookupType;
use App\Form\Model\TravelMailingLookupDto;
+use App\Repository\TravelMailingListRepository;
use App\Service\TravelDataProvider;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
@@ -22,6 +23,7 @@ class IndexController extends AbstractController
public function __construct(
private readonly TravelsClient $travelsClient,
private readonly TravelDataProvider $travelDataProvider,
+ private readonly TravelMailingListRepository $travelMailingListRepository,
) {
}
@@ -36,6 +38,8 @@ class IndexController extends AbstractController
$result = null;
$travelLabel = null;
+ $travelStartsAt = null;
+ $mailingList = null;
$failed = false;
if ($form->isSubmitted() && $form->isValid()) {
// The local index matches codes exactly, so "lpjmp-281126" has to become "LPJMP281126"
@@ -45,6 +49,8 @@ class IndexController extends AbstractController
try {
$result = $this->travelsClient->getMailingRecipients($travelCode);
$travelLabel = $this->travelDataProvider->mapDateCodeToLabel($travelCode);
+ $travelStartsAt = $this->travelDataProvider->mapDateCodeToStartDate($travelCode);
+ $mailingList = $this->travelMailingListRepository->findOneByTravelCode($travelCode);
} catch (BpnConnectException) {
// The client has already logged the cause; the page only needs to say it failed.
$failed = true;
@@ -55,6 +61,8 @@ class IndexController extends AbstractController
'form' => $form,
'result' => $result,
'travel_label' => $travelLabel,
+ 'travel_starts_at' => $travelStartsAt,
+ 'mailing_list' => $mailingList,
'failed' => $failed,
]);
}
diff --git a/src/Entity/TravelMailingList.php b/src/Entity/TravelMailingList.php
new file mode 100644
index 00000000..b8afcb8d
--- /dev/null
+++ b/src/Entity/TravelMailingList.php
@@ -0,0 +1,66 @@
+travelCode = $travelCode;
+ $this->mailjetListId = $mailjetListId;
+ $this->deleteAfter = $deleteAfter;
+ $this->createdAt = new \DateTimeImmutable();
+ }
+
+ public function getId(): ?int
+ {
+ return $this->id;
+ }
+
+ public function getTravelCode(): string
+ {
+ return $this->travelCode;
+ }
+
+ public function getMailjetListId(): int
+ {
+ return $this->mailjetListId;
+ }
+
+ public function getDeleteAfter(): \DateTimeImmutable
+ {
+ return $this->deleteAfter;
+ }
+
+ public function getCreatedAt(): \DateTimeImmutable
+ {
+ return $this->createdAt;
+ }
+}
diff --git a/src/Mailjet/ApiClient.php b/src/Mailjet/ApiClient.php
index 6f8e6182..9013e405 100644
--- a/src/Mailjet/ApiClient.php
+++ b/src/Mailjet/ApiClient.php
@@ -12,6 +12,7 @@ class ApiClient
{
private const string DEFAULT_BASE_URL = 'https://api.mailjet.com/v3/REST';
private const int NAME_MAX_LENGTH = 255;
+ private const int PAGE_LIMIT = 1000;
/**
* @param array{firstName?: string, lastName?: string} $contactMetadataFields
@@ -141,6 +142,139 @@ class ApiClient
]);
}
+ public function createContactsList(string $name): int
+ {
+ $this->assertConfigured();
+
+ $response = $this->request('POST', 'contactslist', [
+ 'json' => [
+ 'Name' => $name,
+ ],
+ ]);
+
+ $listId = $response['Data'][0]['ID'] ?? null;
+ if (null === $listId) {
+ throw new NewsletterProviderException(sprintf('Mailjet did not return an id for the new contact list %s', $name));
+ }
+
+ return (int) $listId;
+ }
+
+ /**
+ * Adds the contacts as subscribed, including any who unsubscribed from this list before.
+ * Mailjet processes the request as an asynchronous job.
+ *
+ * @param list $contacts
+ *
+ * @return int|null the job id
+ */
+ public function addContactsToList(int $listId, array $contacts): ?int
+ {
+ $this->assertConfigured();
+
+ $payload = [];
+ foreach ($contacts as $contact) {
+ $entry = ['Email' => $this->normalizeEmail($contact['email'])];
+
+ $firstName = $this->normalizeName($contact['firstName']);
+ if (null !== $firstName && true === isset($this->contactMetadataFields['firstName'])) {
+ $entry['Properties'] = [$this->contactMetadataFields['firstName'] => $firstName];
+ }
+
+ $payload[] = $entry;
+ }
+
+ $response = $this->request('POST', sprintf('contactslist/%d/managemanycontacts', $listId), [
+ 'json' => [
+ 'Action' => 'addforce',
+ 'Contacts' => $payload,
+ ],
+ ]);
+
+ $jobId = $response['Data'][0]['JobID'] ?? null;
+
+ return null !== $jobId ? (int) $jobId : null;
+ }
+
+ /**
+ * @return list
+ */
+ public function getListContactIds(int $listId): array
+ {
+ $this->assertConfigured();
+
+ $contactIds = [];
+ $offset = 0;
+ do {
+ $response = $this->request('GET', 'listrecipient', [
+ 'query' => [
+ 'ContactsList' => $listId,
+ 'Limit' => self::PAGE_LIMIT,
+ 'Offset' => $offset,
+ ],
+ ]);
+
+ $entries = true === is_array($response['Data'] ?? null) ? $response['Data'] : [];
+ foreach ($entries as $entry) {
+ if (true === is_array($entry) && true === isset($entry['ContactID'])) {
+ $contactIds[] = (int) $entry['ContactID'];
+ }
+ }
+
+ $offset += self::PAGE_LIMIT;
+ } while (self::PAGE_LIMIT === count($entries));
+
+ return array_values(array_unique($contactIds));
+ }
+
+ /**
+ * Lists the contact is on, subscribed or not. Mailjet includes lists deleted within the last 60 days.
+ *
+ * @return list
+ */
+ public function getContactListIds(int $contactId): array
+ {
+ $this->assertConfigured();
+
+ $response = $this->request('GET', sprintf('contact/%d/getcontactslists', $contactId), [
+ 'allow_404' => true,
+ ]);
+
+ $listIds = [];
+ foreach ($response['Data'] ?? [] as $entry) {
+ if (true === is_array($entry) && true === isset($entry['ListID'])) {
+ $listIds[] = (int) $entry['ListID'];
+ }
+ }
+
+ return $listIds;
+ }
+
+ public function deleteContactsList(int $listId): void
+ {
+ $this->assertConfigured();
+
+ $this->request('DELETE', sprintf('contactslist/%d', $listId), [
+ 'allow_404' => true,
+ 'allow_empty' => true,
+ ]);
+ }
+
+ /**
+ * GDPR deletion: Mailjet anonymises the contact immediately and purges it after 30 days.
+ * This cannot be undone.
+ */
+ public function deleteContact(int $contactId): void
+ {
+ $this->assertConfigured();
+
+ $this->request('DELETE', sprintf('contacts/%d', $contactId), [
+ 'allow_404' => true,
+ 'allow_empty' => true,
+ 'api_version' => 'v4',
+ ]);
+ }
+
private function findOrCreateContactId(string $email): int
{
$contactId = $this->resolveContactId($email);
@@ -246,15 +380,17 @@ class ApiClient
{
$allow404 = true === ($options['allow_404'] ?? false);
$allowEmpty = true === ($options['allow_empty'] ?? false);
+ $baseUrl = 'v4' === ($options['api_version'] ?? null) ? $this->getV4BaseUrl() : $this->getBaseUrl();
unset($options['allow_404']);
unset($options['allow_empty']);
+ unset($options['api_version']);
$resourcePath = trim($resource, '/');
try {
$response = $this->httpClient->request(
$method,
- sprintf('%s/%s', $this->getBaseUrl(), $resourcePath),
+ sprintf('%s/%s', $baseUrl, $resourcePath),
array_merge($options, [
'auth_basic' => sprintf('%s:%s', $this->apiKey, $this->apiSecret),
])
@@ -310,6 +446,16 @@ class ApiClient
return rtrim($baseUrl, '/');
}
+ /**
+ * The configured base URL points at v3 (`…/v3/REST`); the v4 API sits beside it on the same host.
+ */
+ private function getV4BaseUrl(): string
+ {
+ $baseUrl = preg_replace('~/v3/REST$~i', '', $this->getBaseUrl());
+
+ return sprintf('%s/v4', $baseUrl);
+ }
+
private function assertConfigured(): void
{
if (true === empty($this->apiKey) || true === empty($this->apiSecret)) {
diff --git a/src/Repository/TravelMailingListRepository.php b/src/Repository/TravelMailingListRepository.php
new file mode 100644
index 00000000..69f839bd
--- /dev/null
+++ b/src/Repository/TravelMailingListRepository.php
@@ -0,0 +1,38 @@
+
+ */
+class TravelMailingListRepository extends ServiceEntityRepository
+{
+ public function __construct(ManagerRegistry $registry)
+ {
+ parent::__construct($registry, TravelMailingList::class);
+ }
+
+ public function findOneByTravelCode(string $travelCode): ?TravelMailingList
+ {
+ return $this->findOneBy(['travelCode' => $travelCode]);
+ }
+
+ /**
+ * @return list
+ */
+ public function findDueForDeletion(\DateTimeImmutable $now): array
+ {
+ return $this->createQueryBuilder('l')
+ ->where('l.deleteAfter <= :now')
+ ->setParameter('now', $now)
+ ->orderBy('l.deleteAfter', 'ASC')
+ ->getQuery()
+ ->getResult();
+ }
+}
diff --git a/src/Service/TravelDataProvider.php b/src/Service/TravelDataProvider.php
index 4e80237c..8e4fd684 100644
--- a/src/Service/TravelDataProvider.php
+++ b/src/Service/TravelDataProvider.php
@@ -432,6 +432,18 @@ class TravelDataProvider
return $this->travelLookupService->mapDateCodeToLabel($dateCode);
}
+ /**
+ * Map date code to the travel's start date.
+ *
+ * @param string $dateCode The date code to map
+ *
+ * @return \DateTimeImmutable|null The start date or null if not found
+ */
+ public function mapDateCodeToStartDate(string $dateCode): ?\DateTimeImmutable
+ {
+ return $this->travelLookupService->mapDateCodeToStartDate($dateCode);
+ }
+
/**
* Map hotel code to hotel ID.
*
diff --git a/src/Service/TravelIndex.php b/src/Service/TravelIndex.php
index 2cf4f018..3958856f 100644
--- a/src/Service/TravelIndex.php
+++ b/src/Service/TravelIndex.php
@@ -127,6 +127,26 @@ class TravelIndex
return $this->generateFilesMap()[$dateId]['label'] ?? null;
}
+ /**
+ * Resolve a date code to the travel's start date.
+ *
+ * @param string $dateCode The date code to resolve
+ *
+ * @return \DateTimeImmutable|null The start date, or null if the code is not found or the travel has none
+ */
+ public function mapDateCodeToStartDate(string $dateCode): ?\DateTimeImmutable
+ {
+ $dateId = $this->mapDateCodeToId($dateCode);
+
+ if (null === $dateId) {
+ return null;
+ }
+
+ $dateFrom = $this->generateFilesMap()[$dateId]['dateFrom'] ?? null;
+
+ return $dateFrom instanceof \DateTimeImmutable ? $dateFrom : null;
+ }
+
/**
* Convert a hotel code string to its corresponding hotel ID.
*
diff --git a/src/Service/TravelMailingListManager.php b/src/Service/TravelMailingListManager.php
new file mode 100644
index 00000000..8849f5c4
--- /dev/null
+++ b/src/Service/TravelMailingListManager.php
@@ -0,0 +1,142 @@
+mailjet->createContactsList($travelCode);
+
+ // Tracked before the contacts go in, so a list Mailjet already holds is never left without a row
+ // that the cleanup would find.
+ $mailingList = new TravelMailingList($travelCode, $listId, $travelStartsAt->modify(self::DELETE_AFTER));
+ $this->entityManager->persist($mailingList);
+ $this->entityManager->flush();
+
+ try {
+ $jobId = $this->mailjet->addContactsToList($listId, $this->buildContacts($recipients));
+ } catch (NewsletterProviderException $exception) {
+ $this->discardEmptyList($mailingList);
+
+ throw $exception;
+ }
+
+ $this->logger->info('Created Mailjet travel mailing list', [
+ 'travel_code' => $travelCode,
+ 'mailjet_list_id' => $listId,
+ 'mailjet_job_id' => $jobId,
+ 'recipients' => count($recipients),
+ ]);
+
+ return $mailingList;
+ }
+
+ /**
+ * Deletes the orphaned contacts first and the list last, so a run that fails halfway can be
+ * repeated: the list still names whoever is left.
+ *
+ * @return int the number of contacts deleted
+ *
+ * @throws NewsletterProviderException
+ */
+ public function delete(TravelMailingList $mailingList): int
+ {
+ $listId = $mailingList->getMailjetListId();
+
+ $deletedContacts = 0;
+ foreach ($this->mailjet->getListContactIds($listId) as $contactId) {
+ // Anyone who is, or recently was, on another list is kept: their newsletter consent or
+ // another travel's mailing still needs the contact.
+ if ([$listId] !== array_values(array_unique($this->mailjet->getContactListIds($contactId)))) {
+ continue;
+ }
+
+ $this->mailjet->deleteContact($contactId);
+ ++$deletedContacts;
+ }
+
+ $this->mailjet->deleteContactsList($listId);
+
+ $this->entityManager->remove($mailingList);
+ $this->entityManager->flush();
+
+ $this->logger->info('Deleted Mailjet travel mailing list', [
+ 'travel_code' => $mailingList->getTravelCode(),
+ 'mailjet_list_id' => $listId,
+ 'deleted_contacts' => $deletedContacts,
+ ]);
+
+ return $deletedContacts;
+ }
+
+ /**
+ * @param TravelMailingRecipient[] $recipients
+ *
+ * @return list
+ */
+ private function buildContacts(array $recipients): array
+ {
+ // Several participants can share one address; the first name that comes with it wins.
+ $contacts = [];
+ foreach ($recipients as $recipient) {
+ $email = mb_strtolower(trim($recipient->email));
+ if ('' === $email || true === isset($contacts[$email])) {
+ continue;
+ }
+
+ $contacts[$email] = [
+ 'email' => $email,
+ 'firstName' => '' !== trim($recipient->firstName) ? $recipient->firstName : null,
+ ];
+ }
+
+ return array_values($contacts);
+ }
+
+ private function discardEmptyList(TravelMailingList $mailingList): void
+ {
+ try {
+ $this->mailjet->deleteContactsList($mailingList->getMailjetListId());
+ } catch (NewsletterProviderException $exception) {
+ // The row stays, so the scheduled cleanup removes the list later.
+ $this->logger->error('Could not delete Mailjet travel mailing list after a failed import', [
+ 'travel_code' => $mailingList->getTravelCode(),
+ 'mailjet_list_id' => $mailingList->getMailjetListId(),
+ 'error' => $exception->getMessage(),
+ ]);
+
+ return;
+ }
+
+ $this->entityManager->remove($mailingList);
+ $this->entityManager->flush();
+ }
+}
diff --git a/templates/admin/travel_mailing/index.html.twig b/templates/admin/travel_mailing/index.html.twig
index 5dda24fd..d9fc4b72 100644
--- a/templates/admin/travel_mailing/index.html.twig
+++ b/templates/admin/travel_mailing/index.html.twig
@@ -37,6 +37,31 @@
{{ result.data|length }} Empfänger für {{ result.travelCode }}
+
+ {% if mailing_list is not null %}
+
+ Mailjet-Liste {{ mailing_list.travelCode }} (ID {{ mailing_list.mailjetListId }})
+ am {{ mailing_list.createdAt|date('d.m.Y, H:i') }} angelegt,
+ wird ab {{ mailing_list.deleteAfter|date('d.m.Y') }} automatisch gelöscht.
+
+ {% elseif travel_starts_at is null %}
+
+ Kein Reisebeginn bekannt, daher kann keine Mailjet-Liste angelegt werden.
+
+ {% else %}
+
+ {% endif %}
+
diff --git a/tests/Mailjet/ApiClientTest.php b/tests/Mailjet/ApiClientTest.php
index 1ea9385c..fd565118 100644
--- a/tests/Mailjet/ApiClientTest.php
+++ b/tests/Mailjet/ApiClientTest.php
@@ -194,4 +194,88 @@ class ApiClientTest extends TestCase
'Action' => 'addforce',
], json_decode((string) $requests[0][2]['body'], true, 512, JSON_THROW_ON_ERROR));
}
+
+ public function testAddContactsToListForcesSubscriptionAndSendsOnlyTheFirstName(): void
+ {
+ $requests = [];
+ $client = new MockHttpClient(function (string $method, string $url, array $options) use (&$requests): MockResponse {
+ $requests[] = [$method, $url, $options];
+
+ return new MockResponse(json_encode(['Data' => [['JobID' => 555]]], JSON_THROW_ON_ERROR), ['http_code' => 201]);
+ });
+
+ $mailjet = new ApiClient($client, new NullLogger(), 'key', 'secret', 'https://mailjet.test', null, [
+ 'firstName' => 'vorname',
+ 'lastName' => 'nachname',
+ ]);
+
+ $jobId = $mailjet->addContactsToList(42, [
+ ['email' => ' Mia@Example.COM ', 'firstName' => ' Mia '],
+ ['email' => 'anon@example.com', 'firstName' => null],
+ ]);
+
+ self::assertSame(555, $jobId);
+ self::assertSame('POST', $requests[0][0]);
+ self::assertSame('https://mailjet.test/contactslist/42/managemanycontacts', $requests[0][1]);
+ self::assertSame([
+ 'Action' => 'addforce',
+ 'Contacts' => [
+ ['Email' => 'mia@example.com', 'Properties' => ['vorname' => 'Mia']],
+ ['Email' => 'anon@example.com'],
+ ],
+ ], json_decode((string) $requests[0][2]['body'], true, 512, JSON_THROW_ON_ERROR));
+ }
+
+ public function testCreateContactsListReturnsTheNewListId(): void
+ {
+ $requests = [];
+ $client = new MockHttpClient(function (string $method, string $url, array $options) use (&$requests): MockResponse {
+ $requests[] = [$method, $url, $options];
+
+ return new MockResponse(json_encode(['Data' => [['ID' => 42, 'Name' => 'LPJMP281126']]], JSON_THROW_ON_ERROR), ['http_code' => 201]);
+ });
+
+ $mailjet = new ApiClient($client, new NullLogger(), 'key', 'secret', 'https://mailjet.test');
+
+ self::assertSame(42, $mailjet->createContactsList('LPJMP281126'));
+ self::assertSame('https://mailjet.test/contactslist', $requests[0][1]);
+ self::assertSame(['Name' => 'LPJMP281126'], json_decode((string) $requests[0][2]['body'], true, 512, JSON_THROW_ON_ERROR));
+ }
+
+ public function testGetListContactIdsPagesThroughAllRecipients(): void
+ {
+ $offsets = [];
+ $client = new MockHttpClient(function (string $method, string $url, array $options) use (&$offsets): MockResponse {
+ $offset = (int) $options['query']['Offset'];
+ $offsets[] = $offset;
+ $data = 0 === $offset
+ ? array_map(static fn (int $id): array => ['ContactID' => $id], range(1, 1000))
+ : [['ContactID' => 1001]];
+
+ return new MockResponse(json_encode(['Data' => $data], JSON_THROW_ON_ERROR));
+ });
+
+ $mailjet = new ApiClient($client, new NullLogger(), 'key', 'secret', 'https://mailjet.test');
+
+ $contactIds = $mailjet->getListContactIds(42);
+
+ self::assertSame([0, 1000], $offsets);
+ self::assertCount(1001, $contactIds);
+ }
+
+ public function testDeleteContactUsesTheV4Api(): void
+ {
+ $requests = [];
+ $client = new MockHttpClient(function (string $method, string $url, array $options) use (&$requests): MockResponse {
+ $requests[] = [$method, $url];
+
+ return new MockResponse('', ['http_code' => 200]);
+ });
+
+ $mailjet = new ApiClient($client, new NullLogger(), 'key', 'secret', 'https://api.mailjet.com/v3/REST');
+
+ $mailjet->deleteContact(99);
+
+ self::assertSame([['DELETE', 'https://api.mailjet.com/v4/contacts/99']], $requests);
+ }
}
diff --git a/tests/Service/TravelIndexTest.php b/tests/Service/TravelIndexTest.php
index 7967f643..7f18dd25 100644
--- a/tests/Service/TravelIndexTest.php
+++ b/tests/Service/TravelIndexTest.php
@@ -186,6 +186,32 @@ class TravelIndexTest extends TestCase
$this->assertNull($result);
}
+ // -------------------------------------------------------------------------
+ // mapDateCodeToStartDate
+ // -------------------------------------------------------------------------
+
+ public function testMapDateCodeToStartDateReturnsTheTravelStartDate(): void
+ {
+ $this->travelLoader->method('generateFilesMap')->willReturn([
+ 42 => ['id' => 42, 'code' => 'WI25', 'dateFrom' => new \DateTimeImmutable('2026-12-28'), 'hotels' => []],
+ ]);
+ $this->travelSnapshotService->method('generateMapping')->willReturn([]);
+
+ $result = $this->service()->mapDateCodeToStartDate('WI25');
+
+ $this->assertEquals(new \DateTimeImmutable('2026-12-28'), $result);
+ }
+
+ public function testMapDateCodeToStartDateReturnsNullWhenNotFound(): void
+ {
+ $this->travelLoader->method('generateFilesMap')->willReturn([]);
+ $this->travelSnapshotService->method('generateMapping')->willReturn([]);
+
+ $result = $this->service()->mapDateCodeToStartDate('UNKNOWN');
+
+ $this->assertNull($result);
+ }
+
// -------------------------------------------------------------------------
// mapHotelCodeToId
// -------------------------------------------------------------------------
diff --git a/tests/Service/TravelMailingListManagerTest.php b/tests/Service/TravelMailingListManagerTest.php
new file mode 100644
index 00000000..a8daed0d
--- /dev/null
+++ b/tests/Service/TravelMailingListManagerTest.php
@@ -0,0 +1,92 @@
+createMock(ApiClient::class);
+ $mailjet->expects(self::once())->method('createContactsList')->with('LPJMP281126')->willReturn(42);
+ $mailjet->expects(self::once())->method('addContactsToList')->with(42, [
+ ['email' => 'mia@example.com', 'firstName' => 'Mia'],
+ ['email' => 'tom@example.com', 'firstName' => null],
+ ])->willReturn(555);
+
+ $entityManager = $this->createMock(EntityManagerInterface::class);
+ $entityManager->expects(self::once())->method('persist')->with(self::isInstanceOf(TravelMailingList::class));
+ $entityManager->expects(self::never())->method('remove');
+
+ $mailingList = (new TravelMailingListManager($mailjet, $entityManager, new NullLogger()))->create('LPJMP281126', [
+ new TravelMailingRecipient('Mia@Example.com', 'Mia', 'Muster'),
+ new TravelMailingRecipient('mia@example.com', 'Lea', 'Muster'),
+ new TravelMailingRecipient('tom@example.com', '', 'Test'),
+ ], new \DateTimeImmutable('2026-11-28'));
+
+ self::assertSame(42, $mailingList->getMailjetListId());
+ self::assertEquals(new \DateTimeImmutable('2026-12-28'), $mailingList->getDeleteAfter());
+ }
+
+ public function testCreateDiscardsTheListWhenTheContactsCannotBeAdded(): void
+ {
+ $mailjet = $this->createMock(ApiClient::class);
+ $mailjet->method('createContactsList')->willReturn(42);
+ $mailjet->method('addContactsToList')->willThrowException(new NewsletterProviderException('boom'));
+ $mailjet->expects(self::once())->method('deleteContactsList')->with(42);
+
+ $entityManager = $this->createMock(EntityManagerInterface::class);
+ $entityManager->expects(self::once())->method('remove')->with(self::isInstanceOf(TravelMailingList::class));
+
+ $this->expectException(NewsletterProviderException::class);
+
+ (new TravelMailingListManager($mailjet, $entityManager, new NullLogger()))->create('LPJMP281126', [
+ new TravelMailingRecipient('mia@example.com', 'Mia', 'Muster'),
+ ], new \DateTimeImmutable('2026-11-28'));
+ }
+
+ public function testDeleteRemovesOnlyContactsOnNoOtherList(): void
+ {
+ $mailjet = $this->createMock(ApiClient::class);
+ $mailjet->method('getListContactIds')->with(42)->willReturn([1, 2]);
+ $mailjet->method('getContactListIds')->willReturnMap([
+ [1, [42]],
+ [2, [42, 10321569]],
+ ]);
+ $mailjet->expects(self::once())->method('deleteContact')->with(1);
+ $mailjet->expects(self::once())->method('deleteContactsList')->with(42);
+
+ $entityManager = $this->createMock(EntityManagerInterface::class);
+ $entityManager->expects(self::once())->method('remove');
+
+ $deleted = (new TravelMailingListManager($mailjet, $entityManager, new NullLogger()))
+ ->delete(new TravelMailingList('LPJMP281126', 42, new \DateTimeImmutable('2026-12-28')));
+
+ self::assertSame(1, $deleted);
+ }
+
+ public function testDeleteKeepsTheRowWhenMailjetFails(): void
+ {
+ $mailjet = $this->createMock(ApiClient::class);
+ $mailjet->method('getListContactIds')->willReturn([]);
+ $mailjet->expects(self::once())->method('deleteContactsList')->willThrowException(new NewsletterProviderException('boom'));
+
+ $entityManager = $this->createMock(EntityManagerInterface::class);
+ $entityManager->expects(self::never())->method('remove');
+
+ $this->expectException(NewsletterProviderException::class);
+
+ (new TravelMailingListManager($mailjet, $entityManager, new NullLogger()))
+ ->delete(new TravelMailingList('LPJMP281126', 42, new \DateTimeImmutable('2026-12-28')));
+ }
+}