feat: MailJet newsletter subscription via MyE&P API endpoint

This commit is contained in:
Björn Fromme
2026-04-30 14:25:57 +02:00
parent 9d0ffda491
commit 98e7451118
9 changed files with 441 additions and 102 deletions
@@ -27,15 +27,54 @@ namespace EP\EpProducts\Controller;
* This copyright notice MUST APPEAR in all copies of the script!
***************************************************************/
use EP\EpProducts\Domain\Model\Dto\NewsletterSubscriptionRequest;
use EP\EpProducts\MyEP\ApiClient;
use EP\EpProducts\MyEP\ApiException;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
class NewsletterController extends ActionController
{
private ApiClient $apiClient;
public function __construct(ApiClient $apiClient)
{
$this->apiClient = $apiClient;
}
public function indexAction()
{
$this->view->assign('result', GeneralUtility::_GET('result'));
}
public function subscriptionFormAction(?NewsletterSubscriptionRequest $subscriptionRequest = null)
{
if (null === $subscriptionRequest) {
$subscriptionRequest = new NewsletterSubscriptionRequest();
}
try {
$lists = $this->apiClient->getNewsletterLists();
} catch (ApiException $e) {
$lists = [];
}
$this->view->assign('subscriptionRequest', $subscriptionRequest);
$this->view->assign('lists', $lists);
}
public function subscriptionFormSubmitAction(NewsletterSubscriptionRequest $subscriptionRequest)
{
try {
$result = $this->apiClient->subscribeToNewsletters($subscriptionRequest);
$result['success'] = true;
} catch (ApiException $e) {
$result = [
'success' => false,
'message' => $e->getMessage(),
];
}
$this->view->assign('result', $result);
}
}
@@ -0,0 +1,113 @@
<?php
namespace EP\EpProducts\Domain\Model\Dto;
use JsonSerializable;
class NewsletterSubscriptionRequest implements JsonSerializable
{
/**
* @TYPO3\CMS\Extbase\Annotation\Validate("NotEmpty")
*/
private ?string $firstName = null;
/**
* @TYPO3\CMS\Extbase\Annotation\Validate("NotEmpty")
*/
private ?string $lastName = null;
/**
* @TYPO3\CMS\Extbase\Annotation\Validate("NotEmpty")
* @TYPO3\CMS\Extbase\Annotation\Validate("EmailAddress")
*/
private ?string $email = null;
/**
* @var list<int>
* @TYPO3\CMS\Extbase\Annotation\Validate("NotEmpty")
*/
private array $listIds = [];
/**
* @TYPO3\CMS\Extbase\Annotation\Validate("Boolean", options={"is": true})
*/
private bool $consent = false;
public function getFirstName(): ?string
{
return $this->firstName;
}
public function setFirstName(?string $firstName): self
{
$this->firstName = $firstName;
return $this;
}
public function getLastName(): ?string
{
return $this->lastName;
}
public function setLastName(?string $lastName): self
{
$this->lastName = $lastName;
return $this;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(?string $email): self
{
$this->email = $email;
return $this;
}
/**
* @return list<int>
*/
public function getListIds(): array
{
return $this->listIds;
}
/**
* @param list<int|string> $listIds
*/
public function setListIds(array $listIds): self
{
$this->listIds = $listIds;
return $this;
}
public function isConsent(): bool
{
return $this->consent;
}
public function setConsent(bool $consent): self
{
$this->consent = $consent;
return $this;
}
public function jsonSerialize(): array
{
return [
'firstName' => $this->firstName,
'lastName' => $this->lastName,
'email' => $this->email,
'listIds' => array_map(function($value) {
return (int) $value;
}, $this->listIds),
];
}
}
@@ -2,16 +2,20 @@
namespace EP\EpProducts\MyEP;
use EP\EpProducts\Domain\Model\Dto\NewsletterSubscriptionRequest;
use GuzzleHttp\Exception\GuzzleException;
use League\OAuth2\Client\Provider\AbstractProvider;
use League\OAuth2\Client\Provider\GenericProvider;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use League\OAuth2\Client\Token\AccessToken;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Symfony\Component\HttpClient\Exception\ClientException;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Utility\GeneralUtility;
@@ -26,30 +30,18 @@ class ApiClient implements LoggerAwareInterface
*/
public function getLastUpdateAt(): ?\DateTimeImmutable
{
$httpClient = $this->getHttpClient();
$data = $this->requestJson('GET', 'last-update');
try {
$response = $httpClient->request('GET', 'last-update');
} catch (TransportExceptionInterface $e) {
$this->logger->error($e->getMessage());
throw new ApiException($e->getMessage());
if (!isset($data['timestamp']) || null === $data['timestamp'] || '' === $data['timestamp']) {
return null;
}
try {
if (200 !== $response->getStatusCode()) {
throw new ApiException($response->getStatusCode());
}
$data = $response->toArray(false);
if (null === $data['timestamp'] ?? null) {
return null;
}
return new \DateTimeImmutable($data['timestamp']);
} catch (\Throwable $e) {
$this->logger->error($e->getMessage());
throw new ApiException($e->getMessage());
$message = sprintf('Invalid timestamp returned by MyEP API for GET last-update: %s', $e->getMessage());
$this->logError($message, ['exception' => $e]);
throw new ApiException($message, 0, $e);
}
}
@@ -58,25 +50,7 @@ class ApiClient implements LoggerAwareInterface
*/
public function getPickups(): array
{
$httpClient = $this->getHttpClient();
try {
$response = $httpClient->request('GET', 'pickups');
} catch (TransportExceptionInterface $e) {
$this->logger->error($e->getMessage());
throw new ApiException($e->getMessage());
}
try {
if (200 !== $response->getStatusCode()) {
throw new ApiException($response->getStatusCode());
}
return $response->toArray(false);
} catch (\Throwable $e) {
$this->logger->error($e->getMessage());
throw new ApiException($e->getMessage());
}
return $this->requestJson('GET', 'pickups');
}
/**
@@ -84,25 +58,7 @@ class ApiClient implements LoggerAwareInterface
*/
public function getTravels(): array
{
$httpClient = $this->getHttpClient();
try {
$response = $httpClient->request('GET', 'travels');
} catch (TransportExceptionInterface $e) {
$this->logger->error($e->getMessage());
throw new ApiException($e->getMessage());
}
try {
if (200 !== $response->getStatusCode()) {
throw new ApiException($response->getStatusCode());
}
return $response->toArray(false);
} catch (\Throwable $e) {
$this->logger->error($e->getMessage());
throw new ApiException($e->getMessage());
}
return $this->requestJson('GET', 'travels');
}
/**
@@ -110,18 +66,33 @@ class ApiClient implements LoggerAwareInterface
*/
public function registerAddress(array $addressData): void
{
$httpClient = $this->getHttpClient();
$response = $this->request('POST', 'contactform', [
'json' => $addressData,
]);
try {
$httpClient->request('POST', 'contactform', [
'json' => $addressData,
]);
} catch (ClientException $e) {
$this->logger->warning($e->getMessage());
} catch (TransportExceptionInterface $e) {
$this->logger->error($e->getMessage());
throw new ApiException($e->getMessage());
}
$this->assertStatusCode($response, [Response::HTTP_OK, Response::HTTP_CREATED, Response::HTTP_ACCEPTED, Response::HTTP_NO_CONTENT], 'POST', 'contactform');
}
/**
* @throws ApiException
*/
public function getNewsletterLists(): array
{
return $this->requestJson('GET', 'newsletters');
}
/**
* @throws ApiException
*/
public function subscribeToNewsletters(NewsletterSubscriptionRequest $subscriptionRequest): array
{
$response = $this->request('POST', 'newsletter-subscriptions', [
'json' => $subscriptionRequest,
]);
$this->assertStatusCode($response, [Response::HTTP_OK, Response::HTTP_ACCEPTED], 'POST', 'newsletter-subscriptions');
return $this->decodeResponse($response, 'POST', 'newsletter-subscriptions');
}
/**
@@ -129,23 +100,7 @@ class ApiClient implements LoggerAwareInterface
*/
public function getTravel(string $productCode): array
{
$httpClient = $this->getHttpClient();
try {
$response = $httpClient->request('GET', 'travels/' . $productCode);
} catch (TransportExceptionInterface $e) {
throw new ApiException($e->getMessage());
}
try {
if (200 !== $response->getStatusCode()) {
throw new ApiException($response->getStatusCode());
}
return $response->toArray(false);
} catch (\Throwable $e) {
throw new ApiException($e->getMessage());
}
return $this->requestJson('GET', 'travels/' . $productCode);
}
/**
@@ -153,22 +108,75 @@ class ApiClient implements LoggerAwareInterface
*/
public function getPickupsPlanning(string $travelCode): array
{
$httpClient = $this->getHttpClient();
return $this->requestJson('GET', 'pickups-planning/' . $travelCode);
}
/**
* @throws ApiException
*/
private function requestJson(string $method, string $uri, array $options = [], array $expectedStatusCodes = [Response::HTTP_OK]): array
{
$response = $this->request($method, $uri, $options);
$this->assertStatusCode($response, $expectedStatusCodes, $method, $uri);
return $this->decodeResponse($response, $method, $uri);
}
/**
* @throws ApiException
*/
private function request(string $method, string $uri, array $options = []): ResponseInterface
{
try {
$response = $httpClient->request('GET', 'pickups-planning/' . $travelCode);
return $this->getHttpClient()->request($method, $uri, $options);
} catch (TransportExceptionInterface $e) {
throw new ApiException($e->getMessage());
$message = sprintf('MyEP API transport error for %s %s: %s', $method, $uri, $e->getMessage());
$this->logError($message, ['exception' => $e]);
throw new ApiException($message, 0, $e);
}
}
/**
* @param int[] $expectedStatusCodes
*
* @throws ApiException
*/
private function assertStatusCode(ResponseInterface $response, array $expectedStatusCodes, string $method, string $uri): void
{
try {
$statusCode = $response->getStatusCode();
} catch (TransportExceptionInterface $e) {
$message = sprintf('MyEP API transport error while reading status for %s %s: %s', $method, $uri, $e->getMessage());
$this->logError($message, ['exception' => $e]);
throw new ApiException($message, 0, $e);
}
try {
if (200 !== $response->getStatusCode()) {
throw new ApiException($response->getStatusCode());
}
if (true === in_array($statusCode, $expectedStatusCodes, true)) {
return;
}
$message = sprintf('MyEP API returned unexpected status %d for %s %s', $statusCode, $method, $uri);
$this->logWarning($message);
throw new ApiException($message, $statusCode);
}
/**
* @throws ApiException
*/
private function decodeResponse(ResponseInterface $response, string $method, string $uri): array
{
try {
return $response->toArray(false);
} catch (\Throwable $e) {
throw new ApiException($e->getMessage());
} catch (DecodingExceptionInterface $e) {
$message = sprintf('MyEP API returned invalid JSON for %s %s: %s', $method, $uri, $e->getMessage());
$this->logError($message, ['exception' => $e]);
throw new ApiException($message, 0, $e);
} catch (TransportExceptionInterface $e) {
$message = sprintf('MyEP API transport error while reading body for %s %s: %s', $method, $uri, $e->getMessage());
$this->logError($message, ['exception' => $e]);
throw new ApiException($message, 0, $e);
}
}
@@ -177,18 +185,41 @@ class ApiClient implements LoggerAwareInterface
$config = GeneralUtility::makeInstance(ExtensionConfiguration::class)
->get('ep_products');
if (null === static::$accessToken || true === static::$accessToken->hasExpired()) {
static::$accessToken = $this
->getProvider($config)
->getAccessToken('client_credentials')
;
}
static::$accessToken = $this->getAccessToken($config);
return HttpClient::createForBaseUri($config['myEpApiBaseUrl'], [
'auth_bearer' => static::$accessToken->getToken(),
]);
}
/**
* @throws ApiException
*/
private function getAccessToken(array $config): AccessToken
{
if (null !== static::$accessToken && false === static::$accessToken->hasExpired()) {
return static::$accessToken;
}
try {
static::$accessToken = $this->getProvider($config)->getAccessToken('client_credentials');
} catch (IdentityProviderException $e) {
$message = sprintf('MyEP OAuth token request failed: %s', $e->getMessage());
$this->logError($message, ['exception' => $e]);
throw new ApiException($message, 0, $e);
} catch (\UnexpectedValueException $e) {
$message = sprintf('MyEP OAuth token response was invalid: %s', $e->getMessage());
$this->logError($message, ['exception' => $e]);
throw new ApiException($message, 0, $e);
} catch (GuzzleException $e) {
$message = sprintf('MyEP OAuth token transport failed: %s', $e->getMessage());
$this->logError($message, ['exception' => $e]);
throw new ApiException($message, 0, $e);
}
return static::$accessToken;
}
private function getProvider(array $config): AbstractProvider
{
return new GenericProvider([
@@ -201,4 +232,18 @@ class ApiClient implements LoggerAwareInterface
'scopes' => 'api',
]);
}
private function logError(string $message, array $context = []): void
{
if (null !== $this->logger) {
$this->logger->error($message, $context);
}
}
private function logWarning(string $message, array $context = []): void
{
if (null !== $this->logger) {
$this->logger->warning($message, $context);
}
}
}
@@ -131,6 +131,12 @@ call_user_func(function () {
'Newsletter'
);
\TYPO3\CMS\Extbase\Utility\ExtensionUtility::registerPlugin(
'EP.EpProducts',
'newsletter_subscription',
'Newsletter-/RA-Registrierung'
);
\TYPO3\CMS\Extbase\Utility\ExtensionUtility::registerPlugin(
'EP.EpProducts',
'maps',
@@ -57,6 +57,7 @@ plugin.tx_epproducts {
logoImage = {$plugin.tx_eptheme.settings.logoImage}
themekey = {$plugin.tx_eptheme.settings.themekey}
globalConceptCode = {$plugin.tx_eptheme.settings.globalConceptCode}
dataProtectionPageUid = {$plugin.tx_eptheme.settings.dataProtectionPageUid}
datePickerPresets {
1 {
label = Silvester
@@ -213,6 +213,17 @@ $boot = function () {
]
);
\TYPO3\CMS\Extbase\Utility\ExtensionUtility::configurePlugin(
'EP.ep_products',
'newsletter_subscription',
[
'Newsletter' => 'subscriptionForm,subscriptionFormSubmit',
],
[
'Newsletter' => 'subscriptionForm,subscriptionFormSubmit',
]
);
\TYPO3\CMS\Extbase\Utility\ExtensionUtility::configurePlugin(
'EP.ep_products',
'maps',
@@ -85,6 +85,9 @@
<trans-unit id="tx_eptheme.message.contactForm.firstName.1221560718">
<target>Bitte angeben</target>
</trans-unit>
<trans-unit id="tx_eptheme.message.contactForm.lastName.1221560718">
<target>Bitte angeben</target>
</trans-unit>
<trans-unit id="tx_eptheme.message.contactForm.email.1221560718">
<target>Bitte angeben</target>
</trans-unit>
@@ -92,7 +95,14 @@
<target>Bitte angeben</target>
</trans-unit>
<trans-unit id="tx_eptheme.message.contactForm.email.1221559976">
<target>Ungültige E-Mail Adresse</target>
<target>Bitte gib eine gültige E-Mail Adresse an</target>
</trans-unit>
<trans-unit id="tx_eptheme.message.newsletter.listIds.1347992400">
<target>Bitte wähle mindestens einen Newsletter</target>
</trans-unit>
<trans-unit id="tx_eptheme.message.newsletter.consent.1361959228">
<target>Bitte stimme den Datenschutzbestimmungen zu</target>
</trans-unit>
<trans-unit id="label.whatsapp_instructions">
@@ -0,0 +1,91 @@
<html xmlns="http://www.w3.org/1999/xhtml" lang="en"
xmlns:f="http://typo3.org/ns/fluid/ViewHelpers">
<f:layout name="Default"/>
<f:section name="Main">
<a id="newsletter-form" class="anchor"></a>
<div class="container">
<f:form method="post" action="subscriptionFormSubmit" name="subscriptionRequest" section="newsletter-form" object="{subscriptionRequest}">
<div class="grid lg:grid-cols-2 lg:gap-x-8 items-start">
<div>
<h3>
Meine Daten
</h3>
<f:render section="Field" arguments="{property: 'firstName', label: 'Vorname'}"/>
<f:render section="Field" arguments="{property: 'lastName', label: 'Nachname'}"/>
<f:render section="Field" arguments="{property: 'email', label: 'E-Mail'}"/>
<div class="mb-4">
<f:form.validationResults for="subscriptionRequest.consent">
<label class="inline-flex items-center">
<f:form.checkbox property="consent" value="1" class="form-checkbox{f:if(condition: validationResults.errors.0, then: ' form-checkbox--has-error')}"/>
<span class="block ml-2">Ich akzeptiere die <f:link.typolink parameter="{settings.dataProtectionPageUid}" class="underline" target="_blank">Datenschutzbestimmungen</f:link.typolink>*</span>
</label>
<f:if condition="{validationResults.flattenedErrors}">
<div class="text-red-500 mt-2" role="alert">
<f:for each="{validationResults.errors}" as="error">
{f:translate(key: 'tx_eptheme.message.newsletter.consent.{error.code}', extensionName: 'ep_theme')}
<br />
</f:for>
</div>
</f:if>
</f:form.validationResults>
</div>
<div class="hidden lg:block">
<button type="submit" class="button bg-button">
Anmelden
</button>
</div>
</div>
<div>
<div>
<h3>
Ich möchte folgende Newsletter erhalten
</h3>
<f:form.validationResults for="subscriptionRequest.listIds">
<f:for each="{lists}" as="list">
<label for="list-{list.id}" class="flex items-center">
<f:form.checkbox property="listIds" multiple="1" value="{list.id}" class="form-checkbox"/>
<span class="block ml-2">{list.label}</span>
</label>
</f:for>
<f:if condition="{validationResults.flattenedErrors}">
<div class="text-red-500 mt-2" role="alert">
<f:for each="{validationResults.errors}" as="error">
{f:translate(key: 'tx_eptheme.message.newsletter.listIds.{error.code}', extensionName: 'ep_theme')}
<br />
</f:for>
</div>
</f:if>
</f:form.validationResults>
</div>
</div>
<div class="lg:hidden mt-4">
<button type="submit" class="button bg-button">
Anmelden
</button>
</div>
</div>
</f:form>
</div>
</f:section>
<f:section name="Field">
<f:form.validationResults for="subscriptionRequest.{property}">
<f:variable name="hasErrors" value="{validationResults.errors.0}"/>
<label class="block mb-4">
<span class="{f:if(condition: hasErrors, then: 'text-red-500', else: 'text-zinc-700')}">{label}*</span>
<f:form.textfield property="{property}" class="form-field {f:if(condition: hasErrors, then: 'form-field--has-error')}"/>
<f:if condition="{validationResults.flattenedErrors}">
<div class="text-red-500 mt-2" role="alert">
<f:for each="{validationResults.errors}" as="error">
{f:translate(key: 'tx_eptheme.message.contactForm.{property}.{error.code}', extensionName: 'ep_theme')}
<br />
</f:for>
</div>
</f:if>
</label>
</f:form.validationResults>
</f:section>
</html>
@@ -0,0 +1,23 @@
<html xmlns="http://www.w3.org/1999/xhtml" lang="en"
xmlns:f="http://typo3.org/ns/fluid/ViewHelpers">
<f:layout name="Default"/>
<f:section name="Main">
<div class="container">
<div class="bg-ep-primary-light text-white p-4">
<h2>
Vielen Dank für dein Interesse
</h2>
<p>
Deine Anmeldung muss noch bestätigt werden. Bitte nutze den Link aus der Bestätigungs-E-Mail, die in
Kürze bei dir eintreffen sollte.
</p>
<p class="text-sm">
Sieh am besten auch im Spam-Ordner nach.
</p>
</div>
</div>
</f:section>
</html>