feat: integrate with MailJet API for newsletter registration

This commit is contained in:
Björn Fromme
2026-03-19 09:42:00 +01:00
parent 66c55f0722
commit d1e2bf3e7d
26 changed files with 1537 additions and 47 deletions
+10
View File
@@ -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="[email protected]"
APP_TRAVEL_PREFER_REMOTE=false
APP_TRAVEL_ENABLE_FALLBACK=true
# Emails
APP_DEFAULT_EMAIL_FROM=[email protected]
APP_DEFAULT_EMAIL_TO=[email protected]
# Booking Configuration
# Status for new bookings: 'F' (Fixed/Final), 'O' (Option - requires agency confirmation)
# Use 'O' during beta phase, switch to 'F' for production
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

+2 -2
View File
@@ -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
+2
View File
@@ -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)%'
+4
View File
@@ -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:
+16
View File
@@ -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%'
+13
View File
@@ -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
+191
View File
@@ -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}}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260318113000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create newsletter_opt_in_confirmation table for double opt-in confirmations';
}
public function up(Schema $schema): void
{
$this->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');
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Repository\NewsletterOptInConfirmationRepository;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'app:cleanup:newsletter-opt-in-requests',
description: 'Removes expired pending newsletter double opt-in requests',
)]
class CleanupNewsletterOptInRequestsCommand extends Command
{
public function __construct(
private readonly NewsletterOptInConfirmationRepository $confirmationRepository,
private readonly LoggerInterface $logger,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$deletedCount = $this->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;
}
}
@@ -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');
@@ -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.
*/
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Controller\Newsletter;
use App\Exception\NewsletterProviderException;
use App\Service\Newsletter\NewsletterConfirmationResult;
use App\Service\Newsletter\NewsletterDoubleOptInService;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class ConfirmController extends AbstractController
{
public function __construct(
private readonly NewsletterDoubleOptInService $doubleOptInService,
private readonly LoggerInterface $logger,
) {
}
#[Route('/newsletter/confirm/{token}', name: 'app_newsletter_confirm', methods: ['GET'])]
#[IsGranted('PUBLIC_ACCESS')]
public function __invoke(string $token): Response
{
try {
$result = $this->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';
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Email;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
interface EmailAttachmentInterface
{
public function attachTo(TemplatedEmail $email): void;
}
+99
View File
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Email;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\BodyRendererInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class Mailer
{
public function __construct(
private readonly MailerInterface $mailer,
private readonly BodyRendererInterface $bodyRenderer,
private readonly LoggerInterface $logger,
private readonly array $defaults,
) {
}
public function createAndSendEmail(array $context, array $options): void
{
$config = $this->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);
}
}
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\NewsletterOptInConfirmationRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: NewsletterOptInConfirmationRepository::class)]
class NewsletterOptInConfirmation
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 255)]
private string $email;
#[ORM\Column(type: 'string', length: 64, unique: true)]
private string $tokenHash;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $expiresAt;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $confirmedAt = null;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
public function __construct(
string $email,
string $tokenHash,
\DateTimeImmutable $expiresAt,
) {
$this->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;
}
}
@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Exception;
class NewsletterProviderException extends \RuntimeException
{
}
+23
View File
@@ -25,6 +25,24 @@ class BookingCreateStep4Type extends AbstractType
htmlspecialchars($this->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']);
}
}
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\NewsletterOptInConfirmation;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<NewsletterOptInConfirmation>
*/
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();
}
}
@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace App\Service\Newsletter;
use App\Exception\NewsletterProviderException;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class MailjetNewsletterService
{
private const DEFAULT_BASE_URL = 'https://api.mailjet.com/v3/REST';
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly LoggerInterface $logger,
private readonly ?string $mailjetApiKey = null,
private readonly ?string $mailjetApiSecret = null,
private readonly ?string $mailjetApiBaseUrl = null,
private readonly ?string $mailjetNewsletterListId = null,
) {
}
public function isSubscribed(string $email): bool
{
$this->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<string, mixed> $options
*
* @return array<string, mixed>
*/
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));
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Service\Newsletter;
class NewsletterConfirmationResult
{
public const STATUS_CONFIRMED = 'confirmed';
public const STATUS_INVALID = 'invalid';
public const STATUS_EXPIRED = 'expired';
public const STATUS_ALREADY_USED = 'already_used';
public function __construct(
public readonly string $status,
public readonly ?string $email = null,
) {
}
public function isSuccess(): bool
{
return self::STATUS_CONFIRMED === $this->status;
}
}
@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace App\Service\Newsletter;
use App\Email\Mailer;
use App\Exception\NewsletterProviderException;
use App\Entity\NewsletterOptInConfirmation;
use App\Repository\NewsletterOptInConfirmationRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
class NewsletterDoubleOptInService
{
public function __construct(
private readonly NewsletterOptInConfirmationRepository $confirmationRepository,
private readonly EntityManagerInterface $entityManager,
private readonly MailjetNewsletterService $newsletterService,
private readonly Mailer $mailer,
private readonly LoggerInterface $logger,
private readonly int $newsletterConfirmationTtlHours,
) {
}
public function requestConfirmation(string $email): void
{
$normalizedEmail = $this->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));
}
}
+39 -12
View File
@@ -24,18 +24,45 @@
</h2>
</div>
<div id="newsletter">
<p class="pb-4 text-white">
Du bist aktuell {% if not personalData.communication.newsletter %}<strong>nicht</strong> {% endif%} zum Newsletter
angemeldet.
</p>
<button type="button"
class="relative button button--primary"
hx-post="{{ path('app_personal_data_newsletter') }}"
hx-target="#newsletter"
hx-select="#newsletter"
hx-swap="outerHTML">
{% if personalData.communication.newsletter %}jetzt abmelden{% else %}jetzt anmelden{% endif %}
</button>
{% include '_partials/_alert.html.twig' with {
'level': 'info',
'messages': ['Du bist aktuell ' ~ (not newsletterSubscribed ? '<strong>nicht</strong> ' : '') ~ 'zum Newsletter angemeldet.']
} %}
{% if newsletterSubscribed %}
<button type="button"
class="relative button button--primary"
hx-post="{{ path('app_personal_data_newsletter') }}"
hx-vals='{{ {subscribed: 0}|json_encode }}'
hx-target="#newsletter"
hx-select="#newsletter"
hx-swap="outerHTML">
Jetzt abmelden
</button>
{% 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.']
} %}
<button type="button"
class="relative button button--primary"
hx-post="{{ path('app_personal_data_newsletter') }}"
hx-vals='{{ {subscribed: 1}|json_encode }}'
hx-target="#newsletter"
hx-select="#newsletter"
hx-swap="outerHTML">
Bestätigungs E-Mail erneut senden
</button>
{% else %}
<button type="button"
class="relative button button--primary"
hx-post="{{ path('app_personal_data_newsletter') }}"
hx-vals='{{ {subscribed: 1}|json_encode }}'
hx-target="#newsletter"
hx-select="#newsletter"
hx-swap="outerHTML">
Jetzt anmelden
</button>
{% endif %}
</div>
</div>
<div class="pt-8">
@@ -546,6 +546,11 @@
Bestätigung
</div>
<div class="p-4">
{% 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'}
}) }}
+278
View File
@@ -0,0 +1,278 @@
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title>MyE&amp;P</title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css2?family=Lato:wght@400;700&display=swap" rel="stylesheet" type="text/css">
<style type="text/css">
@import url(https://fonts.googleapis.com/css2?family=Lato:wght@400;700&display=swap);
</style>
<!--<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
.mj-column-per-50 {
width: 50% !important;
max-width: 50%;
}
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
.moz-text-html .mj-column-per-50 {
width: 50% !important;
max-width: 50%;
}
</style>
<style type="text/css">
@media only screen and (max-width:479px) {
table.mj-full-width-mobile {
width: 100% !important;
}
td.mj-full-width-mobile {
width: auto !important;
}
}
</style>
<style type="text/css">
h1 {
font-weight: bold;
font-size: 24px;
line-height: 28px;
margin-bottom: 0;
padding-bottom: 8px;
}
h2 {
font-weight: normal;
font-size: 20px;
line-height: 24px;
margin-bottom: 0;
padding-bottom: 0;
}
p {
font-size: 16px;
padding-bottom: 16px;
}
p.small {
font-size: 14px;
color: #999999;
}
a {
text-decoration: underline;
color: #5F7983;
}
a.footerlink {
text-decoration: none;
color: inherit;
}
a.button {
text-decoration: none;
color: white;
display: inline-block;
background-color: #3d8ccb;
padding: 8px 16px;
border-radius: 4px;
}
strong {
font-weight: bold;
}
</style>
</head>
<body style="word-spacing:normal;background-color:#666666;">
<div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">MyE&amp;P</div>
<div style="background-color:#666666;">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0;font-size:0;mso-line-height-rule:exactly;"><v:image style="border:0;mso-position-horizontal:center;position:absolute;top:0;width:600px;z-index:-3;" xmlns:v="urn:schemas-microsoft-com:vml" /><![endif]-->
<div style="margin:0 auto;max-width:600px;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr style="vertical-align:top;">
<td style="width:0.01%;padding-bottom:NaN%;mso-padding-bottom-alt:0;" />
<td style="background:#FFFFFF;background-position:center center;background-repeat:no-repeat;padding:0px;padding-top:10px;padding-bottom:10px;vertical-align:top;">
<!--[if mso | IE]><table border="0" cellpadding="0" cellspacing="0" style="width:600px;" width="600" ><tr><td style=""><![endif]-->
<div class="mj-hero-content" style="margin:0px auto;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;margin:0px;">
<tbody>
<tr>
<td style="">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;margin:0px;">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:160px;">
<img alt="Logo E&amp;P" src="{{ email.image('@images/logo.png') }}" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" width="160" height="auto" />
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
<td style="width:0.01%;padding-bottom:NaN%;mso-padding-bottom-alt:0;" />
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-top:1px solid #3d8ccb;direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Lato, Verdana, Arial, 'Helvetica neue', sans-serif;font-size:16px;line-height:24px;text-align:left;color:#666666;">{% block body %} <h1> Lorem ipsum dolor sit amet </h1>
<p> Lorem ipsum dolor sit amet, consectetur adipisicing elit. Architecto culpa delectus dolores earum eius fugiat in nesciunt quas quidem vitae? </p>
<h2> Lorem ipsum dolor </h2>
<p> Lorem ipsum dolor sit amet, consectetur adipisicing elit. Architecto culpa delectus dolores <strong>earum eius fugiat in nesciunt quas quidem vitae</strong>? </p>
<p>
<a href="#" class="button"> Button </a>
</p>
<p class="small"> Lorem ipsum dolor sit amet, consectetur adipisicing elit. Doloribus, eveniet! </p> {% endblock %}
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#3d8ccb" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#3d8ccb;background-color:#3d8ccb;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#3d8ccb;background-color:#3d8ccb;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:300px;" ><![endif]-->
<div class="mj-column-per-50 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Lato, Verdana, Arial, 'Helvetica neue', sans-serif;font-size:14px;line-height:18px;text-align:left;color:#FFFFFF;">E&amp;P Reisen und Events GmbH <br /> Aachener Str. 326-328 <br /> 50933 Köln</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td><td class="" style="vertical-align:top;width:300px;" ><![endif]-->
<div class="mj-column-per-50 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Lato, Verdana, Arial, 'Helvetica neue', sans-serif;font-size:14px;line-height:1;text-align:left;color:#FFFFFF;"><a href="https://www.ep-reisen.de/unternehmen/impressum-skireiseveranstalter/" title="E&P Impressum" class="footerlink" target="_blank">Impressum</a>
<span>|</span>
<a href="https://www.ep-reisen.de/unternehmen/impressum-skireiseveranstalter/datenschutz-winterreisen/" title="E&P Datenschutz" class="footerlink" target="_blank">Datenschutz</a>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>
@@ -0,0 +1,24 @@
{% extends 'email/layout.html.twig' %}
{% block body %}
<h1>
Hallo!
</h1>
<p>
Vielen Dank für dein Interesse an unserem Newsletter. Bitte bestätige deine E-Mail-Adresse über den folgenden
Link:
</p>
<p>
<a href="{{ url('app_newsletter_confirm', { 'token': token }) }}" class="button">
E-Mail-Adresse bestätigen
</a>
</p>
<p>
Wenn du dich nicht zum Newsletter anmelden möchtest, kannst du diese E-Mail einfach ignorieren und löschen.
</p>
<p>
<a href="{{ url('app_login') }}" class="button">
Zu MyE&P
</a>
</p>
{% endblock %}