From d1e2bf3e7d9046b43ad3d6efe0223cc43c59efde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Tue, 17 Mar 2026 08:59:16 +0100 Subject: [PATCH] feat: integrate with MailJet API for newsletter registration --- .env | 10 + assets/images/logo.png | Bin 0 -> 7437 bytes config/packages/messenger.yaml | 4 +- config/packages/twig.yaml | 2 + config/packages/zenstruck_schedule.yaml | 4 + config/services.yaml | 16 + docs/technical-documentation.md | 13 + mailjet.http | 191 ++++++++++++ migrations/Version20260318113000.php | 26 ++ .../CleanupNewsletterOptInRequestsCommand.php | 48 +++ .../Account/PersonalDataController.php | 81 +++-- .../Booking/Create/Step4Controller.php | 76 ++++- .../Newsletter/ConfirmController.php | 66 +++++ src/Email/EmailAttachmentInterface.php | 12 + src/Email/Mailer.php | 99 +++++++ src/Entity/NewsletterOptInConfirmation.php | 97 ++++++ src/Exception/NewsletterProviderException.php | 9 + src/Form/BookingCreateStep4Type.php | 23 ++ .../NewsletterOptInConfirmationRepository.php | 65 ++++ .../Newsletter/MailjetNewsletterService.php | 209 +++++++++++++ .../NewsletterConfirmationResult.php | 24 ++ .../NewsletterDoubleOptInService.php | 151 ++++++++++ templates/account/personal_data.html.twig | 51 +++- templates/booking/create/step_4.html.twig | 5 + templates/email/layout.html.twig | 278 ++++++++++++++++++ templates/email/newsletter_opt_in.html.twig | 24 ++ 26 files changed, 1537 insertions(+), 47 deletions(-) create mode 100644 assets/images/logo.png create mode 100644 mailjet.http create mode 100644 migrations/Version20260318113000.php create mode 100644 src/Command/CleanupNewsletterOptInRequestsCommand.php create mode 100644 src/Controller/Newsletter/ConfirmController.php create mode 100644 src/Email/EmailAttachmentInterface.php create mode 100644 src/Email/Mailer.php create mode 100644 src/Entity/NewsletterOptInConfirmation.php create mode 100644 src/Exception/NewsletterProviderException.php create mode 100644 src/Repository/NewsletterOptInConfirmationRepository.php create mode 100644 src/Service/Newsletter/MailjetNewsletterService.php create mode 100644 src/Service/Newsletter/NewsletterConfirmationResult.php create mode 100644 src/Service/Newsletter/NewsletterDoubleOptInService.php create mode 100644 templates/email/layout.html.twig create mode 100644 templates/email/newsletter_opt_in.html.twig diff --git a/.env b/.env index 7842279..8c257a5 100644 --- a/.env +++ b/.env @@ -40,6 +40,12 @@ MESSENGER_TRANSPORT_DSN=doctrine://default?auto_setup=0 MAILER_DSN=null://null ###< symfony/mailer ### +MAILJET_API_KEY= +MAILJET_API_SECRET= +MAILJET_API_BASE_URL=https://api.mailjet.com/v3/REST +MAILJET_NEWSLETTER_LIST_ID= +NEWSLETTER_CONFIRMATION_TTL_HOURS=1 + APP_BPN_USER= APP_BPN_PASSWORD= APP_BPN_IP= @@ -61,6 +67,10 @@ APP_CUSTOMER_SERVICE_EMAIL="kundenservice@ep-reisen.de" APP_TRAVEL_PREFER_REMOTE=false APP_TRAVEL_ENABLE_FALLBACK=true +# Emails +APP_DEFAULT_EMAIL_FROM=info@ep-reisen.de +APP_DEFAULT_EMAIL_TO=info@ep-reisen.de + # Booking Configuration # Status for new bookings: 'F' (Fixed/Final), 'O' (Option - requires agency confirmation) # Use 'O' during beta phase, switch to 'F' for production diff --git a/assets/images/logo.png b/assets/images/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..1fc85107960fc6ec619d9a63b28b272c5216e677 GIT binary patch literal 7437 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 literal 0 HcmV?d00001 diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index 270f3c7..444ba96 100644 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -13,7 +13,7 @@ framework: max_retries: 3 multiplier: 2 failed: 'doctrine://default?queue_name=failed' - # sync: 'sync://' + sync: 'sync://' default_bus: messenger.bus.default @@ -21,7 +21,7 @@ framework: messenger.bus.default: [] routing: - Symfony\Component\Mailer\Messenger\SendEmailMessage: async + Symfony\Component\Mailer\Messenger\SendEmailMessage: sync Symfony\Component\Notifier\Message\ChatMessage: async Symfony\Component\Notifier\Message\SmsMessage: async diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml index 4a7a4c9..f908ee3 100644 --- a/config/packages/twig.yaml +++ b/config/packages/twig.yaml @@ -1,6 +1,8 @@ twig: file_name_pattern: '*.twig' form_themes: ['forms.html.twig'] + paths: + '%kernel.project_dir%/assets/images': images globals: htmx_change_trigger: 'change delay:100ms' website_base_url: '%env(APP_WEBSITE_BASE_URL)%' diff --git a/config/packages/zenstruck_schedule.yaml b/config/packages/zenstruck_schedule.yaml index b75efff..309d78f 100644 --- a/config/packages/zenstruck_schedule.yaml +++ b/config/packages/zenstruck_schedule.yaml @@ -24,6 +24,10 @@ zenstruck_schedule: frequency: "30 1 * * *" description: "Removes expired drafts" + - task: app:cleanup:newsletter-opt-in-requests + frequency: "45 1 * * *" + description: "Removes expired pending newsletter double opt-in requests" + when@staging: zenstruck_schedule: mailer: diff --git a/config/services.yaml b/config/services.yaml index ebdd626..a746fbe 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -9,6 +9,9 @@ parameters: path_to_keys: '%kernel.project_dir%/config/secret' bpn_debug: '%env(APP_BPN_DEBUG)%' default_booking_status: '%env(DEFAULT_BOOKING_STATUS)%' + newsletter_confirmation_ttl_hours: '%env(int:NEWSLETTER_CONFIRMATION_TTL_HOURS)%' + default_email_from: '%env(APP_DEFAULT_EMAIL_FROM)%' + default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%' # Body dimensions choices for BodyDimensionsType body_dimensions.height_choices: @@ -63,6 +66,10 @@ services: $termsAndConditionsUrl: '%terms_and_conditions_url%' $logger: '@monolog.logger.core' $environment: '%kernel.environment%' + $mailjetApiKey: '%env(default::MAILJET_API_KEY)%' + $mailjetApiSecret: '%env(default::MAILJET_API_SECRET)%' + $mailjetApiBaseUrl: '%env(default::MAILJET_API_BASE_URL)%' + $mailjetNewsletterListId: '%env(default::MAILJET_NEWSLETTER_LIST_ID)%' # makes classes in src/ available to be used as services # this creates a service per class whose id is the fully-qualified class name @@ -166,7 +173,16 @@ services: tags: - { name: monolog.processor } + App\Service\Newsletter\NewsletterDoubleOptInService: + arguments: + $newsletterConfirmationTtlHours: '%newsletter_confirmation_ttl_hours%' + App\Service\DomainConfigProvider: arguments: $domainConfig: '%domain_config%' + App\Email\Mailer: + arguments: + $defaults: + from: '%default_email_from%' + to: '%default_email_to%' diff --git a/docs/technical-documentation.md b/docs/technical-documentation.md index e25e796..608263c 100644 --- a/docs/technical-documentation.md +++ b/docs/technical-documentation.md @@ -1153,6 +1153,19 @@ php bin/console app:cleanup:xml-dumps Removes XML debug dumps older than 3 days. +#### app:cleanup:newsletter-opt-in-requests + +```bash +php bin/console app:cleanup:newsletter-opt-in-requests +``` + +Removes expired pending newsletter double opt-in requests. + +- Request TTL is controlled by `NEWSLETTER_CONFIRMATION_TTL_HOURS` (default: 1 hour). +- Expired pending requests are removed immediately when they are encountered (new request / confirmation attempt). +- Scheduled cleanup removes all currently expired pending requests (`expires_at <= now`) as a safety net. +- Confirmed requests are retained for audit/legal traceability. + ### 10.3 Setup Commands #### app:crypto:generate-keys diff --git a/mailjet.http b/mailjet.http new file mode 100644 index 0000000..7e61103 --- /dev/null +++ b/mailjet.http @@ -0,0 +1,191 @@ +### +# Mailjet API test requests for IntelliJ HTTP Client +# +# 1) Fill in variables below (or move them to http-client.private.env.json). +# 2) Set `mailjet_api_key` and `mailjet_api_secret` in your env file. +# IntelliJ HTTP Client will inject them into the Authorization header below. +# +# Required variables: +# - mailjet_base_url (example: https://api.mailjet.com/v3/REST) +# - mailjet_api_key +# - mailjet_api_secret +# - mailjet_contact_email +# - mailjet_list_id +# +# @no-cookie-jar + +### Mailjet health check (authenticated) +# @no-cookie-jar +GET {{mailjet_base_url}}/apikey +Accept: application/json +Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}} + + +### Find contact by email +# @no-cookie-jar +GET {{mailjet_base_url}}/contact?Email={{mailjet_contact_email}} +Accept: application/json +Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}} + +> {% + if (response.body && response.body.Data && response.body.Data.length > 0) { + client.global.set("contact_id", String(response.body.Data[0].ID)); + } +%} + + +### Create contact (run if not found) +# @no-cookie-jar +POST {{mailjet_base_url}}/contact +Accept: application/json +Content-Type: application/json +Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}} + +{ + "Email": "{{mailjet_contact_email}}", + "IsExcludedFromCampaigns": false +} + +> {% + if (response.body && response.body.Data && response.body.Data.length > 0) { + client.global.set("contact_id", String(response.body.Data[0].ID)); + } +%} + + +### Get contact by ID (after find/create) +# @no-cookie-jar +GET {{mailjet_base_url}}/contact/{{contact_id}} +Accept: application/json +Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}} + + +### Mark contact as subscribed (opt-in) +# @no-cookie-jar +PUT {{mailjet_base_url}}/contact/{{contact_id}} +Accept: application/json +Content-Type: application/json +Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}} + +{ + "IsExcludedFromCampaigns": false +} + + +### Mark contact as unsubscribed (opt-out) +# @no-cookie-jar +PUT {{mailjet_base_url}}/contact/{{contact_id}} +Accept: application/json +Content-Type: application/json +Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}} + +{ + "IsExcludedFromCampaigns": true +} + + +### Get newsletter list by ID +# @no-cookie-jar +GET {{mailjet_base_url}}/contactslist/{{mailjet_list_id}} +Accept: application/json +Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}} + + +### List all available contact lists +# @no-cookie-jar +GET {{mailjet_base_url}}/contactslist?Limit=500 +Accept: application/json +Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}} + + +### Check list recipient status (before actions) +# @no-cookie-jar +GET {{mailjet_base_url}}/listrecipient?ContactsList={{mailjet_list_id}}&Contact={{contact_id}} +Accept: application/json +Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}} + +> {% + if (response.body && response.body.Data && response.body.Data.length > 0) { + client.global.set("listrecipient_id", String(response.body.Data[0].ID)); + } +%} + + +### Subscribe to list (addnoforce) +# @no-cookie-jar +POST {{mailjet_base_url}}/contactslist/{{mailjet_list_id}}/managecontact +Accept: application/json +Content-Type: application/json +Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}} + +{ + "Action": "addnoforce", + "Email": "{{mailjet_contact_email}}" +} + + +### Verify list recipient after addnoforce +# @no-cookie-jar +GET {{mailjet_base_url}}/listrecipient?ContactsList={{mailjet_list_id}}&Contact={{contact_id}} +Accept: application/json +Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}} + + +### Unsubscribe from list (unsub) +# @no-cookie-jar +POST {{mailjet_base_url}}/contactslist/{{mailjet_list_id}}/managecontact +Accept: application/json +Content-Type: application/json +Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}} + +{ + "Action": "unsub", + "Email": "{{mailjet_contact_email}}" +} + + +### Verify list recipient after unsub +# @no-cookie-jar +GET {{mailjet_base_url}}/listrecipient?ContactsList={{mailjet_list_id}}&Contact={{contact_id}} +Accept: application/json +Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}} + + +### Re-subscribe attempt with addnoforce (expected to keep unsubscribed state) +# @no-cookie-jar +POST {{mailjet_base_url}}/contactslist/{{mailjet_list_id}}/managecontact +Accept: application/json +Content-Type: application/json +Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}} + +{ + "Action": "addnoforce", + "Email": "{{mailjet_contact_email}}" +} + + +### Verify list recipient after addnoforce re-subscribe attempt +# @no-cookie-jar +GET {{mailjet_base_url}}/listrecipient?ContactsList={{mailjet_list_id}}&Contact={{contact_id}} +Accept: application/json +Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}} + + +### Re-subscribe with addforce (expected to re-subscribe) +# @no-cookie-jar +POST {{mailjet_base_url}}/contactslist/{{mailjet_list_id}}/managecontact +Accept: application/json +Content-Type: application/json +Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}} + +{ + "Action": "addforce", + "Email": "{{mailjet_contact_email}}" +} + + +### Verify list recipient after addforce +# @no-cookie-jar +GET {{mailjet_base_url}}/listrecipient?ContactsList={{mailjet_list_id}}&Contact={{contact_id}} +Accept: application/json +Authorization: Basic {{mailjet_api_key}} {{mailjet_api_secret}} diff --git a/migrations/Version20260318113000.php b/migrations/Version20260318113000.php new file mode 100644 index 0000000..ba7a540 --- /dev/null +++ b/migrations/Version20260318113000.php @@ -0,0 +1,26 @@ +addSql("CREATE TABLE newsletter_opt_in_confirmation (id INT AUTO_INCREMENT NOT NULL, email VARCHAR(255) NOT NULL, token_hash VARCHAR(64) NOT NULL, expires_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', confirmed_at DATETIME DEFAULT NULL COMMENT '(DC2Type:datetime_immutable)', created_at DATETIME NOT NULL COMMENT '(DC2Type:datetime_immutable)', UNIQUE INDEX UNIQ_NEWSLETTER_OPT_IN_TOKEN_HASH (token_hash), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB"); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE newsletter_opt_in_confirmation'); + } +} diff --git a/src/Command/CleanupNewsletterOptInRequestsCommand.php b/src/Command/CleanupNewsletterOptInRequestsCommand.php new file mode 100644 index 0000000..811b673 --- /dev/null +++ b/src/Command/CleanupNewsletterOptInRequestsCommand.php @@ -0,0 +1,48 @@ +confirmationRepository->deleteExpiredPending(); + + if (0 === $deletedCount) { + $io->success('No expired pending newsletter opt-in requests found.'); + + return Command::SUCCESS; + } + + $this->logger->info('Deleted expired pending newsletter opt-in requests', [ + 'count' => $deletedCount, + ]); + + $io->success(sprintf('Deleted %d expired pending newsletter opt-in request(s).', $deletedCount)); + + return Command::SUCCESS; + } +} diff --git a/src/Controller/Account/PersonalDataController.php b/src/Controller/Account/PersonalDataController.php index df49b1a..71134dc 100644 --- a/src/Controller/Account/PersonalDataController.php +++ b/src/Controller/Account/PersonalDataController.php @@ -9,10 +9,14 @@ use App\BusProNet\Exception\ApiClientException; use App\BusProNet\Model\Notification; use App\BusProNet\Model\PersonalData; use App\Entity\User; +use App\Exception\NewsletterProviderException; use App\Form\PersonalDataType; +use App\Repository\NewsletterOptInConfirmationRepository; use App\Security\Crypt; use App\Service\BookingEditDataLoaderService; use App\Service\ProfileCompletenessChecker; +use App\Service\Newsletter\MailjetNewsletterService; +use App\Service\Newsletter\NewsletterDoubleOptInService; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -46,6 +50,9 @@ class PersonalDataController extends AbstractController private readonly BookingEditDataLoaderService $dataLoader, private readonly ProfileCompletenessChecker $completenessChecker, private readonly EntityManagerInterface $entityManager, + private readonly MailjetNewsletterService $newsletterService, + private readonly NewsletterDoubleOptInService $doubleOptInService, + private readonly NewsletterOptInConfirmationRepository $newsletterConfirmationRepository, private readonly LoggerInterface $logger, ) { } @@ -130,9 +137,27 @@ class PersonalDataController extends AbstractController return $this->redirectToRoute('app_personal_data'); } + $newsletterSubscribed = false; + $newsletterPendingConfirmation = false; + try { + $newsletterSubscribed = $this->newsletterService->isSubscribed($email); + } catch (NewsletterProviderException $e) { + $this->logger->warning('Unable to read newsletter subscription status', [ + 'email' => $email, + 'error' => $e->getMessage(), + ]); + $this->addFlash('error', 'Der Newsletter-Status konnte gerade nicht geladen werden.'); + } + + if (!$newsletterSubscribed) { + $newsletterPendingConfirmation = null !== $this->newsletterConfirmationRepository->findPendingByEmail($email); + } + return $this->render('account/personal_data.html.twig', [ 'personalData' => $personalData, 'personalDataForm' => $personalDataForm->createView(), + 'newsletterSubscribed' => $newsletterSubscribed, + 'newsletterPendingConfirmation' => $newsletterPendingConfirmation, ]); } @@ -149,43 +174,43 @@ class PersonalDataController extends AbstractController */ #[Route('/personal-data/newsletter', name: 'app_personal_data_newsletter', methods: ['POST'])] #[IsGranted('ROLE_USER')] - public function newsletter(): Response + public function newsletter(Request $request): Response { /** @var User $user */ $user = $this->getUser(); $email = $user->getEmail(); - $password = $this->crypt->decrypt($user->getPassword()); + + $shouldSubscribe = $request->request->getBoolean('subscribed'); try { - $personalData = $this - ->apiClient - ->getPersonalData($email, $password); - } catch (ApiClientException $e) { - $this->addFlash('error', 'Deine persönlichen Daten konnten nicht abgerufen werden'); + if ($shouldSubscribe) { + if ($this->newsletterService->isSubscribed($email)) { + $this->addFlash('info', 'Du bist bereits zum Newsletter angemeldet.'); + } else { + $hasPendingConfirmation = null !== $this->newsletterConfirmationRepository->findPendingByEmail($email); + $this->doubleOptInService->requestConfirmation($email); + if ($hasPendingConfirmation) { + $this->addFlash('success', 'Wir haben dir eine neue Bestätigungs-E-Mail gesendet.'); + } else { + $this->addFlash('success', 'Bitte bestätige deine Newsletter-Anmeldung über den Link in der E-Mail.'); + } + } + } else { + $this->newsletterService->unsubscribe($email); + $this->addFlash('success', 'Du wurdest vom Newsletter abgemeldet.'); + } - return $this->redirectToRoute('app_personal_data'); - } - - if ($personalData instanceof Notification) { - $this->logger->error('Unable to fetch personal data', [ - 'code' => $personalData->code, - 'error' => $personalData->message, - ]); - $this->addFlash('error', 'Deine persönlichen Daten konnten nicht abgerufen werden'); - - return $this->redirectToRoute('app_personal_data'); - } - - $personalData->communication->newsletter = !$personalData->communication->newsletter; - - try { - $this->apiClient->updateNewsletterRegistration($email, $password, $personalData); - $this->addFlash('success', 'Deine Anmeldung zum Newsletter wurde aktualisiert'); - $this->logger->info('Updated newsletter registration', [ + $this->logger->info('Updated newsletter registration intent', [ 'email' => $user->getEmail(), + 'subscribed' => $shouldSubscribe, + ]); + } catch (NewsletterProviderException|\InvalidArgumentException $e) { + $this->addFlash('error', 'Die Newsletter-Aktion konnte gerade nicht verarbeitet werden. Bitte versuche es erneut.'); + $this->logger->warning('Newsletter action failed', [ + 'email' => $email, + 'subscribed' => $shouldSubscribe, + 'error' => $e->getMessage(), ]); - } catch (ApiClientException $e) { - $this->addFlash('error', $e->getMessage()); } return $this->redirectToRoute('app_personal_data'); diff --git a/src/Controller/Booking/Create/Step4Controller.php b/src/Controller/Booking/Create/Step4Controller.php index 767d765..99144c7 100644 --- a/src/Controller/Booking/Create/Step4Controller.php +++ b/src/Controller/Booking/Create/Step4Controller.php @@ -9,12 +9,16 @@ use App\BusProNet\Exception\TimeoutException; use App\BusProNet\Model\Notification; use App\Controller\Booking\Traits\BookingCreateTrait; use App\Controller\Booking\Traits\BookingExceptionHandlerTrait; +use App\Exception\NewsletterProviderException; use App\Form\BookingCreateStep4Type; use App\Form\Model\BookingDto; use App\Htmx\HxTrait; +use App\Entity\User; use App\Service\BookingPriceCalculatorService; use App\Service\BookingService; use App\Service\BookingSummaryDataService; +use App\Service\Newsletter\MailjetNewsletterService; +use App\Service\Newsletter\NewsletterDoubleOptInService; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\Form\FormInterface; @@ -38,6 +42,8 @@ class Step4Controller extends AbstractController private readonly BookingPriceCalculatorService $priceCalculator, private readonly ApiClient $apiClient, private readonly CacheInterface $cache, + private readonly MailjetNewsletterService $newsletterService, + private readonly NewsletterDoubleOptInService $doubleOptInService, private readonly LoggerInterface $logger, ) { } @@ -59,7 +65,23 @@ class Step4Controller extends AbstractController return $redirect; } - $form = $this->createForm(BookingCreateStep4Type::class, $bookingCreateDto); + $newsletterTargetEmail = $this->resolveNewsletterTargetEmail($bookingCreateDto); + $newsletterOptInVisible = false; + if (null !== $newsletterTargetEmail) { + try { + $newsletterOptInVisible = false === $this->newsletterService->isSubscribed($newsletterTargetEmail); + } catch (NewsletterProviderException $e) { + $this->logger->warning('Could not resolve newsletter subscription state in booking step 4', [ + 'email' => $newsletterTargetEmail, + 'error' => $e->getMessage(), + ]); + } + } + + $form = $this->createForm(BookingCreateStep4Type::class, $bookingCreateDto, [ + 'show_newsletter_opt_in' => $newsletterOptInVisible, + 'newsletter_target_email' => $newsletterTargetEmail, + ]); $form->handleRequest($request); if (true === $form->isSubmitted() && true === $form->isValid()) { @@ -71,7 +93,7 @@ class Step4Controller extends AbstractController return $this->handleApiError( 'Booking creation failed - API notification', ['message' => $bookingResponse->message], - $bookingResponse->message ?? 'Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.', + $bookingResponse->message ?? 'Ein Fehler ist aufgetreten. Bitte versuchen es erneut.', $bookingCreateDto, $form ); @@ -92,6 +114,21 @@ class Step4Controller extends AbstractController ); } + $newsletterOptInSelected = $newsletterOptInVisible + && $form->has('newsletterOptIn') + && true === $form->get('newsletterOptIn')->getData(); + + if (true === $newsletterOptInSelected && null !== $newsletterTargetEmail) { + try { + $this->doubleOptInService->requestConfirmation($newsletterTargetEmail); + } catch (NewsletterProviderException|\InvalidArgumentException $e) { + $this->logger->warning('Newsletter confirmation request failed after booking', [ + 'email' => $newsletterTargetEmail, + 'error' => $e->getMessage(), + ]); + } + } + // Success: Store booking data in flash for conversion tracking $summaryData = $this->summaryDataService->getSummaryData($bookingCreateDto); $this->addFlash('booking_number', $bookingResponse->bookingNumber); @@ -114,7 +151,7 @@ class Step4Controller extends AbstractController 'exception' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ], - 'Die Anfrage hat zu lange gedauert. Bitte versuchen Sie es erneut.', + 'Die Anfrage hat zu lange gedauert. Bitte versuche es erneut.', $bookingCreateDto, $form ); @@ -132,13 +169,18 @@ class Step4Controller extends AbstractController } } - return $this->renderStepForm($bookingCreateDto, $form); + return $this->renderStepForm($bookingCreateDto, $form, $newsletterOptInVisible, $newsletterTargetEmail); } /** * Renders the step 4 form with standard template variables. */ - private function renderStepForm(BookingDto $bookingCreateDto, FormInterface $form): Response + private function renderStepForm( + BookingDto $bookingCreateDto, + FormInterface $form, + bool $newsletterOptInVisible, + ?string $newsletterTargetEmail, + ): Response { // Get complete summary data (pricing, rooms, CMS data) $summaryData = $this->summaryDataService->getSummaryData($bookingCreateDto); @@ -148,9 +190,33 @@ class Step4Controller extends AbstractController 'form' => $form->createView(), 'summaryData' => $summaryData, 'participantPrices' => $this->priceCalculator->calculateAllParticipantIndividualPrices($bookingCreateDto), + 'newsletterOptInVisible' => $newsletterOptInVisible, + 'newsletterTargetEmail' => $newsletterTargetEmail, ]); } + private function resolveNewsletterTargetEmail(BookingDto $bookingDto): ?string + { + $currentUser = $this->getUser(); + $email = null; + + if ($currentUser instanceof User) { + $email = $currentUser->getEmail(); + } + + if ((null === $email || '' === trim((string) $email)) && isset($bookingDto->participants[0])) { + $email = $bookingDto->participants[0]->email; + } + + if (null === $email) { + return null; + } + + $normalizedEmail = mb_strtolower(trim($email)); + + return false !== filter_var($normalizedEmail, FILTER_VALIDATE_EMAIL) ? $normalizedEmail : null; + } + /** * Clears travel data and availability cache after successful booking. */ diff --git a/src/Controller/Newsletter/ConfirmController.php b/src/Controller/Newsletter/ConfirmController.php new file mode 100644 index 0000000..3df85a9 --- /dev/null +++ b/src/Controller/Newsletter/ConfirmController.php @@ -0,0 +1,66 @@ +doubleOptInService->confirmToken($token); + } catch (NewsletterProviderException $exception) { + $this->logger->error('Newsletter confirmation failed on provider sync', [ + 'error' => $exception->getMessage(), + ]); + + $this->addFlash('error', 'Deine Newsletter-Bestätigung konnte gerade nicht abgeschlossen werden. Bitte versuche es später erneut.'); + + return $this->redirectToRoute($this->resolveTargetRoute()); + } + + $this->addFlash(...$this->resolveFlash($result)); + + return $this->redirectToRoute($this->resolveTargetRoute()); + } + + /** + * @return array{string, string} + */ + private function resolveFlash(NewsletterConfirmationResult $result): array + { + return match ($result->status) { + NewsletterConfirmationResult::STATUS_CONFIRMED => ['success', 'Deine Newsletter-Anmeldung wurde erfolgreich bestätigt.'], + NewsletterConfirmationResult::STATUS_ALREADY_USED => ['info', 'Diese Newsletter-Bestätigung wurde bereits verwendet.'], + NewsletterConfirmationResult::STATUS_EXPIRED => ['warning', 'Der Bestätigungslink ist abgelaufen. Bitte fordere eine neue Bestätigungs-E-Mail an.'], + default => ['error', 'Der Bestätigungslink ist ungültig. Bitte fordere eine neue Bestätigungs-E-Mail an.'], + }; + } + + private function resolveTargetRoute(): string + { + if ($this->isGranted('ROLE_USER')) { + return 'app_personal_data'; + } + + return 'app_login'; + } +} diff --git a/src/Email/EmailAttachmentInterface.php b/src/Email/EmailAttachmentInterface.php new file mode 100644 index 0000000..9a1fb75 --- /dev/null +++ b/src/Email/EmailAttachmentInterface.php @@ -0,0 +1,12 @@ +resolveConfig($options); + $email = $this->create($context, $config); + + $recipients = (array) $config['to']; + + foreach ($recipients as $recipient) { + $email->to($recipient); + $this->send($email); + } + } + + public function create(array $context, array $config): TemplatedEmail + { + $email = (new TemplatedEmail()) + ->from($config['from']) + ->subject($config['subject']) + ->htmlTemplate($config['template']) + ->context($context) + ; + + foreach ($config['attachments'] as $attachment) { + /* @var EmailAttachmentInterface $attachment */ + $attachment->attachTo($email); + } + + $this->bodyRenderer->render($email); + + return $email; + } + + public function send(TemplatedEmail $email): void + { + $recipients = array_map(fn (Address $address) => $address->toString(), $email->getTo()); + + try { + $this->mailer->send($email); + $this->logger->info('Send email', [ + 'to' => $recipients, + 'subject' => $email->getSubject(), + ]); + } catch (TransportExceptionInterface $e) { + $this->logger->error('Email could not be sent', [ + 'to' => $recipients, + 'subject' => $email->getSubject(), + 'error' => $e->getMessage(), + ]); + } + } + + private function resolveConfig(array $options): array + { + $resolver = new OptionsResolver(); + + $resolver + ->setDefaults([ + 'from' => $this->defaults['from'], + 'to' => $this->defaults['to'], + 'subject_parameters' => [], + 'attachments' => [], + ]) + ->setRequired([ + 'template', + 'subject', + ]) + ->setAllowedTypes('to', ['string', 'array']) + ->setAllowedTypes('template', 'string') + ->setAllowedTypes('subject', 'string') + ->setAllowedTypes('attachments', 'array') + ; + + return $resolver->resolve($options); + } +} diff --git a/src/Entity/NewsletterOptInConfirmation.php b/src/Entity/NewsletterOptInConfirmation.php new file mode 100644 index 0000000..5798309 --- /dev/null +++ b/src/Entity/NewsletterOptInConfirmation.php @@ -0,0 +1,97 @@ +email = mb_strtolower(trim($email)); + $this->tokenHash = $tokenHash; + $this->expiresAt = $expiresAt; + $this->createdAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getEmail(): string + { + return $this->email; + } + + public function getTokenHash(): string + { + return $this->tokenHash; + } + + public function getExpiresAt(): \DateTimeImmutable + { + return $this->expiresAt; + } + + public function getConfirmedAt(): ?\DateTimeImmutable + { + return $this->confirmedAt; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } + + public function isConfirmed(): bool + { + return null !== $this->confirmedAt; + } + + public function isExpired(?\DateTimeImmutable $now = null): bool + { + $reference = $now ?? new \DateTimeImmutable(); + + return $this->expiresAt <= $reference; + } + + public function markConfirmed(?\DateTimeImmutable $now = null): void + { + $this->confirmedAt = $now ?? new \DateTimeImmutable(); + } + + public function refreshRequest(string $tokenHash, \DateTimeImmutable $expiresAt): void + { + $this->tokenHash = $tokenHash; + $this->expiresAt = $expiresAt; + $this->confirmedAt = null; + } +} diff --git a/src/Exception/NewsletterProviderException.php b/src/Exception/NewsletterProviderException.php new file mode 100644 index 0000000..596359f --- /dev/null +++ b/src/Exception/NewsletterProviderException.php @@ -0,0 +1,9 @@ +termsAndConditionsUrl, ENT_QUOTES, 'UTF-8') ); + $newsletterTargetEmail = $options['newsletter_target_email']; + $newsletterLabel = 'Ich möchte den Newsletter erhalten und bestätige meine Anmeldung per E-Mail.'; + if (is_string($newsletterTargetEmail) && '' !== trim($newsletterTargetEmail)) { + $newsletterLabel = sprintf( + 'Ich möchte den Newsletter für %s erhalten und bestätige meine Anmeldung per E-Mail.', + htmlspecialchars($newsletterTargetEmail, ENT_QUOTES, 'UTF-8') + ); + } + + if (true === $options['show_newsletter_opt_in']) { + $builder->add('newsletterOptIn', CheckboxType::class, [ + 'label' => $newsletterLabel, + 'label_html' => true, + 'mapped' => false, + 'required' => false, + ]); + } + $builder ->add('confirmationAccepted', CheckboxType::class, [ 'label' => 'Ich bestätige, dass alle Angaben korrekt sind und möchte verbindlich buchen.', @@ -52,6 +70,11 @@ class BookingCreateStep4Type extends AbstractType { $resolver->setDefaults([ 'data_class' => BookingDto::class, + 'show_newsletter_opt_in' => false, + 'newsletter_target_email' => null, ]); + + $resolver->setAllowedTypes('show_newsletter_opt_in', 'bool'); + $resolver->setAllowedTypes('newsletter_target_email', ['null', 'string']); } } diff --git a/src/Repository/NewsletterOptInConfirmationRepository.php b/src/Repository/NewsletterOptInConfirmationRepository.php new file mode 100644 index 0000000..7f57b51 --- /dev/null +++ b/src/Repository/NewsletterOptInConfirmationRepository.php @@ -0,0 +1,65 @@ + + */ +class NewsletterOptInConfirmationRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, NewsletterOptInConfirmation::class); + } + + public function findByTokenHash(string $tokenHash): ?NewsletterOptInConfirmation + { + return $this->findOneBy(['tokenHash' => $tokenHash]); + } + + public function findPendingByEmail(string $email): ?NewsletterOptInConfirmation + { + return $this->createQueryBuilder('c') + ->where('c.email = :email') + ->andWhere('c.confirmedAt IS NULL') + ->andWhere('c.expiresAt > :now') + ->setParameter('email', mb_strtolower(trim($email))) + ->setParameter('now', new \DateTimeImmutable()) + ->orderBy('c.createdAt', 'DESC') + ->setMaxResults(1) + ->getQuery() + ->getOneOrNullResult(); + } + + public function deleteExpiredPendingByEmail(string $email): int + { + return (int) $this->createQueryBuilder('c') + ->delete() + ->where('c.email = :email') + ->andWhere('c.confirmedAt IS NULL') + ->andWhere('c.expiresAt <= :now') + ->setParameter('email', mb_strtolower(trim($email))) + ->setParameter('now', new \DateTimeImmutable()) + ->getQuery() + ->execute(); + } + + public function deleteExpiredPending(): int + { + $threshold = new \DateTimeImmutable(); + + return (int) $this->createQueryBuilder('c') + ->delete() + ->where('c.confirmedAt IS NULL') + ->andWhere('c.expiresAt <= :threshold') + ->setParameter('threshold', $threshold) + ->getQuery() + ->execute(); + } +} diff --git a/src/Service/Newsletter/MailjetNewsletterService.php b/src/Service/Newsletter/MailjetNewsletterService.php new file mode 100644 index 0000000..6daa5d0 --- /dev/null +++ b/src/Service/Newsletter/MailjetNewsletterService.php @@ -0,0 +1,209 @@ +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 $options + * + * @return array + */ + 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)); + } +} diff --git a/src/Service/Newsletter/NewsletterConfirmationResult.php b/src/Service/Newsletter/NewsletterConfirmationResult.php new file mode 100644 index 0000000..2bea9ec --- /dev/null +++ b/src/Service/Newsletter/NewsletterConfirmationResult.php @@ -0,0 +1,24 @@ +status; + } +} diff --git a/src/Service/Newsletter/NewsletterDoubleOptInService.php b/src/Service/Newsletter/NewsletterDoubleOptInService.php new file mode 100644 index 0000000..ff2beac --- /dev/null +++ b/src/Service/Newsletter/NewsletterDoubleOptInService.php @@ -0,0 +1,151 @@ +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)); + } +} diff --git a/templates/account/personal_data.html.twig b/templates/account/personal_data.html.twig index 9529420..192330f 100644 --- a/templates/account/personal_data.html.twig +++ b/templates/account/personal_data.html.twig @@ -24,18 +24,45 @@
-

- Du bist aktuell {% if not personalData.communication.newsletter %}nicht {% endif%} zum Newsletter - angemeldet. -

- + {% include '_partials/_alert.html.twig' with { + 'level': 'info', + 'messages': ['Du bist aktuell ' ~ (not newsletterSubscribed ? 'nicht ' : '') ~ 'zum Newsletter angemeldet.'] + } %} + {% if newsletterSubscribed %} + + {% elseif newsletterPendingConfirmation %} + {% include '_partials/_alert.html.twig' with { + 'level': 'info', + 'messages': ['Deine Anmeldung muss noch bestätigt werden. Bitte nutze den Link aus der Bestätigungs-E-Mail, die du in Kürze erhältst.'] + } %} + + {% else %} + + {% endif %}
diff --git a/templates/booking/create/step_4.html.twig b/templates/booking/create/step_4.html.twig index de6b054..70be8f8 100644 --- a/templates/booking/create/step_4.html.twig +++ b/templates/booking/create/step_4.html.twig @@ -546,6 +546,11 @@ Bestätigung
+ {% if form.newsletterOptIn is defined and newsletterOptInVisible and newsletterTargetEmail %} + {{ form_row(form.newsletterOptIn, { + 'label_attr': {'class': 'text-base font-semibold'} + }) }} + {% endif %} {{ form_row(form.confirmationAccepted, { 'label_attr': {'class': 'text-base font-semibold'} }) }} diff --git a/templates/email/layout.html.twig b/templates/email/layout.html.twig new file mode 100644 index 0000000..191319e --- /dev/null +++ b/templates/email/layout.html.twig @@ -0,0 +1,278 @@ + + + + + MyE&P + + + + + + + + + + + + + + + + + + + +
MyE&P
+
+ +
+ + + + + + +
+ + +
+ + + + + + +
+ + + + + + +
+ + + + + + +
+ Logo E&P +
+
+
+
+ +
+
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
{% block body %}

Lorem ipsum dolor sit amet

+

Lorem ipsum dolor sit amet, consectetur adipisicing elit. Architecto culpa delectus dolores earum eius fugiat in nesciunt quas quidem vitae?

+

Lorem ipsum dolor

+

Lorem ipsum dolor sit amet, consectetur adipisicing elit. Architecto culpa delectus dolores earum eius fugiat in nesciunt quas quidem vitae?

+

+ Button +

+

Lorem ipsum dolor sit amet, consectetur adipisicing elit. Doloribus, eveniet!

{% endblock %} +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
E&P Reisen und Events GmbH
Aachener Str. 326-328
50933 Köln
+
+
+ +
+ + + + + + +
+ +
+
+ +
+
+ +
+ + + diff --git a/templates/email/newsletter_opt_in.html.twig b/templates/email/newsletter_opt_in.html.twig new file mode 100644 index 0000000..563e735 --- /dev/null +++ b/templates/email/newsletter_opt_in.html.twig @@ -0,0 +1,24 @@ +{% extends 'email/layout.html.twig' %} + +{% block body %} +

+ Hallo! +

+

+ Vielen Dank für dein Interesse an unserem Newsletter. Bitte bestätige deine E-Mail-Adresse über den folgenden + Link: +

+

+ + E-Mail-Adresse bestätigen + +

+

+ Wenn du dich nicht zum Newsletter anmelden möchtest, kannst du diese E-Mail einfach ignorieren und löschen. +

+

+ + Zu MyE&P + +

+{% endblock %}