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! * 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\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController; use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
class NewsletterController extends ActionController class NewsletterController extends ActionController
{ {
private ApiClient $apiClient;
public function __construct(ApiClient $apiClient)
{
$this->apiClient = $apiClient;
}
public function indexAction() public function indexAction()
{ {
$this->view->assign('result', GeneralUtility::_GET('result')); $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; 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\AbstractProvider;
use League\OAuth2\Client\Provider\GenericProvider; use League\OAuth2\Client\Provider\GenericProvider;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use League\OAuth2\Client\Token\AccessToken; use League\OAuth2\Client\Token\AccessToken;
use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait; use Psr\Log\LoggerAwareTrait;
use Symfony\Component\HttpClient\Exception\ClientException;
use Symfony\Component\HttpClient\HttpClient; 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\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface; use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration; use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Core\Utility\GeneralUtility;
@@ -26,30 +30,18 @@ class ApiClient implements LoggerAwareInterface
*/ */
public function getLastUpdateAt(): ?\DateTimeImmutable public function getLastUpdateAt(): ?\DateTimeImmutable
{ {
$httpClient = $this->getHttpClient(); $data = $this->requestJson('GET', 'last-update');
try { if (!isset($data['timestamp']) || null === $data['timestamp'] || '' === $data['timestamp']) {
$response = $httpClient->request('GET', 'last-update'); return null;
} catch (TransportExceptionInterface $e) {
$this->logger->error($e->getMessage());
throw new ApiException($e->getMessage());
} }
try { 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']); return new \DateTimeImmutable($data['timestamp']);
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->logger->error($e->getMessage()); $message = sprintf('Invalid timestamp returned by MyEP API for GET last-update: %s', $e->getMessage());
throw new ApiException($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 public function getPickups(): array
{ {
$httpClient = $this->getHttpClient(); return $this->requestJson('GET', 'pickups');
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());
}
} }
/** /**
@@ -84,25 +58,7 @@ class ApiClient implements LoggerAwareInterface
*/ */
public function getTravels(): array public function getTravels(): array
{ {
$httpClient = $this->getHttpClient(); return $this->requestJson('GET', 'travels');
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());
}
} }
/** /**
@@ -110,18 +66,33 @@ class ApiClient implements LoggerAwareInterface
*/ */
public function registerAddress(array $addressData): void public function registerAddress(array $addressData): void
{ {
$httpClient = $this->getHttpClient(); $response = $this->request('POST', 'contactform', [
'json' => $addressData,
]);
try { $this->assertStatusCode($response, [Response::HTTP_OK, Response::HTTP_CREATED, Response::HTTP_ACCEPTED, Response::HTTP_NO_CONTENT], 'POST', 'contactform');
$httpClient->request('POST', 'contactform', [ }
'json' => $addressData,
]); /**
} catch (ClientException $e) { * @throws ApiException
$this->logger->warning($e->getMessage()); */
} catch (TransportExceptionInterface $e) { public function getNewsletterLists(): array
$this->logger->error($e->getMessage()); {
throw new ApiException($e->getMessage()); 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 public function getTravel(string $productCode): array
{ {
$httpClient = $this->getHttpClient(); return $this->requestJson('GET', 'travels/' . $productCode);
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());
}
} }
/** /**
@@ -153,22 +108,75 @@ class ApiClient implements LoggerAwareInterface
*/ */
public function getPickupsPlanning(string $travelCode): array 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 { try {
$response = $httpClient->request('GET', 'pickups-planning/' . $travelCode); return $this->getHttpClient()->request($method, $uri, $options);
} catch (TransportExceptionInterface $e) { } 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 (true === in_array($statusCode, $expectedStatusCodes, true)) {
if (200 !== $response->getStatusCode()) { return;
throw new ApiException($response->getStatusCode()); }
}
$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); return $response->toArray(false);
} catch (\Throwable $e) { } catch (DecodingExceptionInterface $e) {
throw new ApiException($e->getMessage()); $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) $config = GeneralUtility::makeInstance(ExtensionConfiguration::class)
->get('ep_products'); ->get('ep_products');
if (null === static::$accessToken || true === static::$accessToken->hasExpired()) { static::$accessToken = $this->getAccessToken($config);
static::$accessToken = $this
->getProvider($config)
->getAccessToken('client_credentials')
;
}
return HttpClient::createForBaseUri($config['myEpApiBaseUrl'], [ return HttpClient::createForBaseUri($config['myEpApiBaseUrl'], [
'auth_bearer' => static::$accessToken->getToken(), '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 private function getProvider(array $config): AbstractProvider
{ {
return new GenericProvider([ return new GenericProvider([
@@ -201,4 +232,18 @@ class ApiClient implements LoggerAwareInterface
'scopes' => 'api', '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' 'Newsletter'
); );
\TYPO3\CMS\Extbase\Utility\ExtensionUtility::registerPlugin(
'EP.EpProducts',
'newsletter_subscription',
'Newsletter-/RA-Registrierung'
);
\TYPO3\CMS\Extbase\Utility\ExtensionUtility::registerPlugin( \TYPO3\CMS\Extbase\Utility\ExtensionUtility::registerPlugin(
'EP.EpProducts', 'EP.EpProducts',
'maps', 'maps',
@@ -57,6 +57,7 @@ plugin.tx_epproducts {
logoImage = {$plugin.tx_eptheme.settings.logoImage} logoImage = {$plugin.tx_eptheme.settings.logoImage}
themekey = {$plugin.tx_eptheme.settings.themekey} themekey = {$plugin.tx_eptheme.settings.themekey}
globalConceptCode = {$plugin.tx_eptheme.settings.globalConceptCode} globalConceptCode = {$plugin.tx_eptheme.settings.globalConceptCode}
dataProtectionPageUid = {$plugin.tx_eptheme.settings.dataProtectionPageUid}
datePickerPresets { datePickerPresets {
1 { 1 {
label = Silvester 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( \TYPO3\CMS\Extbase\Utility\ExtensionUtility::configurePlugin(
'EP.ep_products', 'EP.ep_products',
'maps', 'maps',
@@ -85,6 +85,9 @@
<trans-unit id="tx_eptheme.message.contactForm.firstName.1221560718"> <trans-unit id="tx_eptheme.message.contactForm.firstName.1221560718">
<target>Bitte angeben</target> <target>Bitte angeben</target>
</trans-unit> </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"> <trans-unit id="tx_eptheme.message.contactForm.email.1221560718">
<target>Bitte angeben</target> <target>Bitte angeben</target>
</trans-unit> </trans-unit>
@@ -92,7 +95,14 @@
<target>Bitte angeben</target> <target>Bitte angeben</target>
</trans-unit> </trans-unit>
<trans-unit id="tx_eptheme.message.contactForm.email.1221559976"> <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>
<trans-unit id="label.whatsapp_instructions"> <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>