diff --git a/config/doctrine/Domain/User.orm.xml b/config/doctrine/Domain/User.orm.xml index 1f17117f..402a2b74 100644 --- a/config/doctrine/Domain/User.orm.xml +++ b/config/doctrine/Domain/User.orm.xml @@ -12,6 +12,7 @@ + diff --git a/docker/php/fpm/Dockerfile b/docker/php/fpm/Dockerfile index dbe2e5c5..53bd57bb 100644 --- a/docker/php/fpm/Dockerfile +++ b/docker/php/fpm/Dockerfile @@ -7,7 +7,7 @@ ENV APP_VERSION=${APP_VERSION} ENV PHP_VERSION=${PHP_VERSION} # Install packages for PHP and extensions -RUN PHP_EXTENSIONS="apcu bcmath ctype curl dom fileinfo fpm gmp mbstring opcache pcntl pdo_mysql pdo_pgsql phar redis simplexml sockets tokenizer xml xmlreader xmlwriter" \ +RUN PHP_EXTENSIONS="apcu bcmath ctype curl dom fileinfo fpm gmp intl mbstring opcache pcntl pdo_mysql pdo_pgsql phar redis simplexml sockets tokenizer xml xmlreader xmlwriter" \ && PHP_PACKAGE="php${PHP_VERSION/./}" \ && PHP_SUBPACKAGES="" \ && for EXTENSION in ${PHP_EXTENSIONS}; do \ @@ -16,7 +16,7 @@ RUN PHP_EXTENSIONS="apcu bcmath ctype curl dom fileinfo fpm gmp mbstring opcache && set -ex \ && apk update \ && apk upgrade \ - && apk add --no-cache curl fcgi ${PHP_PACKAGE} ${PHP_SUBPACKAGES} \ + && apk add --no-cache curl fcgi icu-data-full ${PHP_PACKAGE} ${PHP_SUBPACKAGES} \ && ln -s /etc/${PHP_PACKAGE} /etc/php \ && test -e /usr/sbin/php-fpm || ln -s /usr/sbin/php-fpm${PHP_VERSION/./} /usr/sbin/php-fpm \ && test -e /usr/bin/php || ln -s /usr/bin/${PHP_PACKAGE} /usr/bin/php \ @@ -49,9 +49,9 @@ COPY composer.lock composer.json ./ RUN composer install --optimize-autoloader --no-cache --no-dev --no-progress # Install own application sources -COPY templates templates/ COPY src src/ COPY public public/ +COPY locales locales/ COPY config config/ COPY bin bin/ diff --git a/docker/php/roadrunner/Dockerfile b/docker/php/roadrunner/Dockerfile index 7408bcb0..6c7b4f20 100644 --- a/docker/php/roadrunner/Dockerfile +++ b/docker/php/roadrunner/Dockerfile @@ -10,7 +10,7 @@ ENV APP_VERSION=${APP_VERSION} ENV PHP_VERSION=${PHP_VERSION} # Install packages for PHP and extensions -RUN PHP_EXTENSIONS="apcu bcmath ctype curl dom fileinfo gmp mbstring opcache pcntl pdo_mysql pdo_pgsql phar redis simplexml sockets tokenizer xml xmlreader xmlwriter" \ +RUN PHP_EXTENSIONS="apcu bcmath ctype curl dom fileinfo gmp intl mbstring opcache pcntl pdo_mysql pdo_pgsql phar redis simplexml sockets tokenizer xml xmlreader xmlwriter" \ && PHP_PACKAGE="php${PHP_VERSION/./}" \ && PHP_SUBPACKAGES="" \ && for EXTENSION in ${PHP_EXTENSIONS}; do \ @@ -19,7 +19,7 @@ RUN PHP_EXTENSIONS="apcu bcmath ctype curl dom fileinfo gmp mbstring opcache pcn && set -ex \ && apk update \ && apk upgrade \ - && apk add --no-cache curl ${PHP_PACKAGE} ${PHP_SUBPACKAGES} \ + && apk add --no-cache curl icu-data-full ${PHP_PACKAGE} ${PHP_SUBPACKAGES} \ && ln -s /etc/${PHP_PACKAGE} /etc/php \ && test -e /usr/bin/php || ln -s /usr/bin/${PHP_PACKAGE} /usr/bin/php \ && adduser -u 82 -D -S -G www-data www-data \ @@ -53,8 +53,8 @@ COPY composer.lock composer.json ./ RUN composer install --optimize-autoloader --no-cache --no-dev --no-progress # Install own application sources -COPY templates templates/ COPY src src/ +COPY locales locales/ COPY config config/ COPY bin bin/ diff --git a/locales/de.json b/locales/de.json new file mode 100644 index 00000000..578323d8 --- /dev/null +++ b/locales/de.json @@ -0,0 +1,27 @@ +{ + "mail": { + "inviteUser": { + "title": "Deine Einladung", + "content": { + "text": "Hey %s, du wurdest von %s zum Liga-Manager eingeladen.", + "action": "Registrieren" + }, + "hints": { + "validity": "Deine Einladung ist gültig bis: %s", + "disclosure": "Bitte leite deine Einladung nicht an eine andere Person weiter." + } + }, + "resetPassword": { + "title": "Passwort zurücksetzen", + "content": { + "text": "Hey %s, nutze den folgenden Link um ein neues Passwort zu vergeben.", + "action": "Neues Passwort setzen" + }, + "hints": { + "validity": "Der Link ist gültig bis: %s", + "disclosure": "Bitte leite diese E-Mail nicht an eine andere Person weiter.", + "flooding": "Wenn du diese E-Mail wiederholt bekommst ohne sie selbst angefordert zu haben, melde dich bitte beim Admin-Team." + } + } + } +} \ No newline at end of file diff --git a/locales/en.json b/locales/en.json new file mode 100644 index 00000000..1d100888 --- /dev/null +++ b/locales/en.json @@ -0,0 +1,27 @@ +{ + "mail": { + "inviteUser": { + "title": "Your invite", + "content": { + "text": "Hey %s, you have been invited from %s to join Liga-Manager.", + "action": "Register" + }, + "hints": { + "validity": "Your invite is valid until: %s", + "disclosure": "Please do not forward your invite to another person." + } + }, + "resetPassword": { + "title": "Password reset", + "content": { + "text": "Hey %s, use the following link for setting a new password.", + "action": "Set new password" + }, + "hints": { + "validity": "The link is valid until: %s", + "disclosure": "Please do not forward this mail to another person.", + "flooding": "If you receive this mail repeatedly, but you did not request it, please contact the admin team." + } + } + } +} \ No newline at end of file diff --git a/src/Application/Command/CreateUserCommand.php b/src/Application/Command/CreateUserCommand.php index bc812d12..55737299 100644 --- a/src/Application/Command/CreateUserCommand.php +++ b/src/Application/Command/CreateUserCommand.php @@ -25,6 +25,9 @@ class CreateUserCommand implements CommandInterface /** @var string[] */ private array $teamIds; + /** @var string|null */ + private ?string $locale; + /** * @param string|null $id * @param string $email @@ -33,6 +36,7 @@ class CreateUserCommand implements CommandInterface * @param string $lastName * @param string $role * @param string[] $teamIds + * @param string|null $locale */ public function __construct( ?string $id, @@ -41,7 +45,8 @@ public function __construct( string $firstName, string $lastName, string $role, - array $teamIds + array $teamIds, + ?string $locale ) { $this->setId($id); $this->email = $email; @@ -52,6 +57,7 @@ public function __construct( $this->teamIds = array_map(function (string $teamId) { return $teamId; }, $teamIds); + $this->locale = $locale; } /** @@ -101,4 +107,12 @@ public function getTeamIds(): array { return $this->teamIds; } + + /** + * @return string|null + */ + public function getLocale(): ?string + { + return $this->locale; + } } diff --git a/src/Application/Command/UpdateUserCommand.php b/src/Application/Command/UpdateUserCommand.php index 2bf5adfa..1f670e6e 100644 --- a/src/Application/Command/UpdateUserCommand.php +++ b/src/Application/Command/UpdateUserCommand.php @@ -23,6 +23,9 @@ class UpdateUserCommand implements CommandInterface /** @var string[]|null */ private ?array $teamIds = null; + /** @var string|null */ + private ?string $locale; + /** * @param string $userId * @param string|null $email @@ -30,20 +33,21 @@ class UpdateUserCommand implements CommandInterface * @param string|null $lastName * @param string|null $role * @param string[]|null $teamIds + * @param string|null $locale */ - public function __construct(string $userId, ?string $email, ?string $firstName, ?string $lastName, ?string $role, ?array $teamIds) + public function __construct(string $userId, ?string $email, ?string $firstName, ?string $lastName, ?string $role, ?array $teamIds, ?string $locale) { $this->userId = $userId; $this->email = $email; $this->firstName = $firstName; $this->lastName = $lastName; $this->role = $role; - if (null !== $teamIds) { $this->teamIds = array_map(function (string $teamId) { return $teamId; }, $teamIds); } + $this->locale = $locale; } /** @@ -93,4 +97,12 @@ public function getTeamIds(): ?array { return $this->teamIds; } + + /** + * @return string|null + */ + public function getLocale(): ?string + { + return $this->locale; + } } diff --git a/src/Application/Email/HtmlUtilsTrait.php b/src/Application/Email/HtmlUtilsTrait.php deleted file mode 100644 index db7fc44e..00000000 --- a/src/Application/Email/HtmlUtilsTrait.php +++ /dev/null @@ -1,26 +0,0 @@ -(.+)<\/title>/', $mailBody, $matches); - - if (!isset($matches[1])) { - throw new RuntimeException('Failed to extract title from mail body'); - } - - return html_entity_decode($matches[1]); - } -} \ No newline at end of file diff --git a/src/Application/Email/MailerInterface.php b/src/Application/Email/MailerInterface.php index 5b4ecfe8..8d82b035 100644 --- a/src/Application/Email/MailerInterface.php +++ b/src/Application/Email/MailerInterface.php @@ -8,13 +8,7 @@ interface MailerInterface /** * @param array $to * @param string $subject - * @param string $html - * @return object + * @param MessageBody $body */ - public function createMessage(array $to, string $subject, string $html): object; - - /** - * @param object $message - */ - public function send(object $message); + public function send(array $to, string $subject, MessageBody $body); } diff --git a/src/Application/Email/MessageBody.php b/src/Application/Email/MessageBody.php new file mode 100644 index 00000000..b3d4f5f7 --- /dev/null +++ b/src/Application/Email/MessageBody.php @@ -0,0 +1,21 @@ +getUser()->assertIsAdmin(); $this->userRepository->assertEmailDoesNotExist($command->getEmail()); - $user = new User($command->getId(), $command->getEmail(), $command->getPassword(), $command->getFirstName(), $command->getLastName()); - $user->setRole($command->getRole()); + + $user = new User( + $command->getId(), + $command->getEmail(), + $command->getPassword(), + $command->getFirstName(), + $command->getLastName(), + $command->getRole(), + $command->getLocale() + ); + foreach ($command->getTeamIds() as $teamId) { /** @var Team $team */ $team = $this->teamRepository->find($teamId); diff --git a/src/Application/Handler/SendInviteMailHandler.php b/src/Application/Handler/SendInviteMailHandler.php index a6e18e0f..75044722 100644 --- a/src/Application/Handler/SendInviteMailHandler.php +++ b/src/Application/Handler/SendInviteMailHandler.php @@ -4,36 +4,34 @@ use DateTimeImmutable; use HexagonalPlayground\Application\Command\SendInviteMailCommand; -use HexagonalPlayground\Application\Email\HtmlUtilsTrait; use HexagonalPlayground\Application\Email\MailerInterface; +use HexagonalPlayground\Application\Email\MessageBody; use HexagonalPlayground\Application\Security\AccessLinkGeneratorInterface; use HexagonalPlayground\Application\Security\AuthContext; use HexagonalPlayground\Application\Security\UserRepositoryInterface; -use HexagonalPlayground\Application\TemplateRendererInterface; +use HexagonalPlayground\Application\Translator; use HexagonalPlayground\Domain\Event\Event; use HexagonalPlayground\Domain\User; class SendInviteMailHandler implements AuthAwareHandler { - use HtmlUtilsTrait; - private UserRepositoryInterface $userRepository; - private TemplateRendererInterface $templateRenderer; private MailerInterface $mailer; private AccessLinkGeneratorInterface $accessLinkGenerator; + private Translator $translator; /** * @param UserRepositoryInterface $userRepository - * @param TemplateRendererInterface $templateRenderer * @param MailerInterface $mailer * @param AccessLinkGeneratorInterface $accessLinkGenerator + * @param Translator $translator */ - public function __construct(UserRepositoryInterface $userRepository, TemplateRendererInterface $templateRenderer, MailerInterface $mailer, AccessLinkGeneratorInterface $accessLinkGenerator) + public function __construct(UserRepositoryInterface $userRepository, MailerInterface $mailer, AccessLinkGeneratorInterface $accessLinkGenerator, Translator $translator) { $this->userRepository = $userRepository; - $this->templateRenderer = $templateRenderer; $this->mailer = $mailer; $this->accessLinkGenerator = $accessLinkGenerator; + $this->translator = $translator; } /** @@ -50,19 +48,25 @@ public function __invoke(SendInviteMailCommand $command, AuthContext $authContex $expiresAt = new DateTimeImmutable('now + 1 day'); $targetLink = $this->accessLinkGenerator->generateAccessLink($user, $expiresAt, $command->getTargetPath()); + $locale = $user->getLocale() ?? 'de'; - $recipient = [$user->getEmail() => $user->getFullName()]; - $mailBody = $this->templateRenderer->render('InviteUser.html.php', [ - 'sender' => $authContext->getUser()->getFirstName(), - 'receiver' => $user->getFirstName(), - 'targetLink' => $targetLink, - 'validUntil' => $expiresAt - ]); - - $subject = $this->extractTitle($mailBody); - $message = $this->mailer->createMessage($recipient, $subject, $mailBody); + $messageBody = new MessageBody( + $this->translator->get($locale, 'mail.inviteUser.title'), + $this->translator->get($locale, 'mail.inviteUser.content.text', [$user->getFirstName(), $authContext->getUser()->getFirstName()]), + [ + $this->translator->get($locale, 'mail.inviteUser.content.action') => $targetLink + ], + [ + $this->translator->get($locale, 'mail.inviteUser.hints.validity', [$this->translator->getLocalizedDateTime($locale, $expiresAt)]), + $this->translator->get($locale, 'mail.inviteUser.hints.disclosure') + ] + ); - $this->mailer->send($message); + $this->mailer->send( + [$user->getEmail() => $user->getFullName()], + $messageBody->title, + $messageBody + ); return []; } diff --git a/src/Application/Handler/SendPasswordResetMailHandler.php b/src/Application/Handler/SendPasswordResetMailHandler.php index dc5db4c3..74f9a3de 100644 --- a/src/Application/Handler/SendPasswordResetMailHandler.php +++ b/src/Application/Handler/SendPasswordResetMailHandler.php @@ -5,35 +5,33 @@ use DateTimeImmutable; use HexagonalPlayground\Application\Command\SendPasswordResetMailCommand; -use HexagonalPlayground\Application\Email\HtmlUtilsTrait; use HexagonalPlayground\Application\Email\MailerInterface; +use HexagonalPlayground\Application\Email\MessageBody; use HexagonalPlayground\Application\Security\AccessLinkGeneratorInterface; use HexagonalPlayground\Domain\Exception\NotFoundException; use HexagonalPlayground\Application\Security\UserRepositoryInterface; -use HexagonalPlayground\Application\TemplateRendererInterface; +use HexagonalPlayground\Application\Translator; use HexagonalPlayground\Domain\Event\Event; class SendPasswordResetMailHandler { - use HtmlUtilsTrait; - private UserRepositoryInterface $userRepository; - private TemplateRendererInterface $templateRenderer; private MailerInterface $mailer; private AccessLinkGeneratorInterface $accessLinkGenerator; + private Translator $translator; /** * @param UserRepositoryInterface $userRepository - * @param TemplateRendererInterface $templateRenderer * @param MailerInterface $mailer * @param AccessLinkGeneratorInterface $accessLinkGenerator + * @param Translator $translator */ - public function __construct(UserRepositoryInterface $userRepository, TemplateRendererInterface $templateRenderer, MailerInterface $mailer, AccessLinkGeneratorInterface $accessLinkGenerator) + public function __construct(UserRepositoryInterface $userRepository, MailerInterface $mailer, AccessLinkGeneratorInterface $accessLinkGenerator, Translator $translator) { $this->userRepository = $userRepository; - $this->templateRenderer = $templateRenderer; $this->mailer = $mailer; $this->accessLinkGenerator = $accessLinkGenerator; + $this->translator = $translator; } /** @@ -48,20 +46,27 @@ public function __invoke(SendPasswordResetMailCommand $command): array return []; // Simply do nothing, when user cannot be found to prevent user discovery attacks } - $expiresAt = new DateTimeImmutable('now + 1 day'); - $targetLink = $this->accessLinkGenerator->generateAccessLink($user, $expiresAt, $command->getTargetPath()); - - $recipient = [$user->getEmail() => $user->getFullName()]; - $subject = 'Reset your password'; - $mailBody = $this->templateRenderer->render('PasswordReset.html.php', [ - 'receiver' => $user->getFirstName(), - 'targetLink' => $targetLink, - 'validUntil' => $expiresAt - ]); - $subject = $this->extractTitle($mailBody); - $message = $this->mailer->createMessage($recipient, $subject, $mailBody); + $expiresAt = new DateTimeImmutable('now + 1 day'); + $targetLink = $this->accessLinkGenerator->generateAccessLink($user, $expiresAt, $command->getTargetPath()); + $locale = $user->getLocale() ?? 'de'; + $messageBody = new MessageBody( + $this->translator->get($locale, 'mail.resetPassword.title'), + $this->translator->get($locale, 'mail.resetPassword.content.text', [$user->getFirstName()]), + [ + $this->translator->get($locale, 'mail.resetPassword.content.action') => $targetLink + ], + [ + $this->translator->get($locale, 'mail.resetPassword.hints.validity', [$this->translator->getLocalizedDateTime($locale, $expiresAt)]), + $this->translator->get($locale, 'mail.resetPassword.hints.disclosure'), + $this->translator->get($locale, 'mail.resetPassword.hints.flooding') + ] + ); - $this->mailer->send($message); + $this->mailer->send( + [$user->getEmail() => $user->getFullName()], + $messageBody->title, + $messageBody + ); return []; } diff --git a/src/Application/Handler/UpdateUserHandler.php b/src/Application/Handler/UpdateUserHandler.php index 80afbcbd..fac771ad 100644 --- a/src/Application/Handler/UpdateUserHandler.php +++ b/src/Application/Handler/UpdateUserHandler.php @@ -74,6 +74,10 @@ public function __invoke(UpdateUserCommand $command, AuthContext $authContext): } } + if (null !== $command->getLocale()) { + $user->setLocale($command->getLocale()); + } + $this->userRepository->save($user); return []; diff --git a/src/Application/TemplateRendererInterface.php b/src/Application/TemplateRendererInterface.php deleted file mode 100644 index c839f0d2..00000000 --- a/src/Application/TemplateRendererInterface.php +++ /dev/null @@ -1,9 +0,0 @@ -translations = []; + $this->dateFormatters = []; + } + + public function get(string $locale, string $key, array $params = []): string + { + if (!isset($this->translations[$locale])) { + $filePath = join( + DIRECTORY_SEPARATOR, + [__DIR__, '..', '..', 'locales', "$locale.json"] + ); + + if (!file_exists($filePath)) { + throw new RuntimeException("Failed to find translations for locale $locale"); + } + + $this->translations[$locale] = $this->flattenArray(json_decode(file_get_contents($filePath), true)); + } + + $value = $this->translations[$locale][$key] ?? ''; + + if ($value !== '' && count($params) > 0) { + $value = sprintf($value, ...$params); + } + + return $value; + } + + public function getLocalizedDateTime(string $locale, DateTimeInterface $dateTime): string + { + if (!isset($this->dateFormatters[$locale])) { + $this->dateFormatters[$locale] = new IntlDateFormatter($locale, IntlDateFormatter::LONG, IntlDateFormatter::LONG); + } + + return $this->dateFormatters[$locale]->format($dateTime); + } + + private function flattenArray(array $array, string $parentKey = ''): array + { + $flattened = []; + + foreach ($array as $key => $value) { + $newKey = $parentKey ? $parentKey . '.' . $key : $key; + + if (is_array($value)) { + $flattened = array_merge($flattened, $this->flattenArray($value, $newKey)); + } else { + $flattened[$newKey] = $value; + } + } + + return $flattened; + } +} \ No newline at end of file diff --git a/src/Domain/User.php b/src/Domain/User.php index 2c066125..5addd9f7 100644 --- a/src/Domain/User.php +++ b/src/Domain/User.php @@ -40,6 +40,9 @@ class User extends Entity /** @var string */ private string $role; + /** @var string|null */ + private ?string $locale; + /** * @param string $id * @param string $email @@ -47,6 +50,7 @@ class User extends Entity * @param string $firstName * @param string $lastName * @param string $role + * @param string|null $locale */ public function __construct( string $id, @@ -54,7 +58,8 @@ public function __construct( ?string $password, string $firstName, string $lastName, - string $role = self::ROLE_TEAM_MANAGER + string $role = self::ROLE_TEAM_MANAGER, + ?string $locale = null ) { parent::__construct($id); $this->setEmail($email); @@ -62,6 +67,7 @@ public function __construct( $this->setFirstName($firstName); $this->setLastName($lastName); $this->setRole($role); + $this->setLocale($locale); $this->teams = new ArrayCollection(); } @@ -258,15 +264,14 @@ public function haveAccessTokensBeenInvalidatedSince(DateTimeImmutable $since): */ public function getPublicProperties(): array { - $data = [ + return [ 'id' => $this->id, 'email' => $this->email, 'role' => $this->role, 'first_name' => $this->firstName, - 'last_name' => $this->lastName + 'last_name' => $this->lastName, + 'locale' => $this->locale ]; - - return $data; } /** @@ -331,4 +336,31 @@ public static function getRoles(): array self::ROLE_TEAM_MANAGER ]; } + + /** + * Returns an array of valid locales + */ + public static function getLocales(): array + { + return ['de', 'en']; + } + + /** + * @return string|null + */ + public function getLocale(): ?string + { + return $this->locale; + } + + /** + * @param string|null $locale + */ + public function setLocale(?string $locale): void + { + if ($locale !== null) { + Assert::oneOf($locale, self::getLocales(), 'Unsupported locale. Valid: [%s], Got: %s', InvalidInputException::class); + } + $this->locale = $locale; + } } diff --git a/src/Infrastructure/API/GraphQL/UserLocaleType.php b/src/Infrastructure/API/GraphQL/UserLocaleType.php new file mode 100644 index 00000000..dbf9cc8f --- /dev/null +++ b/src/Infrastructure/API/GraphQL/UserLocaleType.php @@ -0,0 +1,24 @@ + [] + ]; + + foreach (User::getLocales() as $locale) { + $config['values'][$locale] = ['value' => $locale]; + } + + parent::__construct($config); + } +} diff --git a/src/Infrastructure/API/GraphQL/UserType.php b/src/Infrastructure/API/GraphQL/UserType.php index 39cd2af9..57b79ac3 100644 --- a/src/Infrastructure/API/GraphQL/UserType.php +++ b/src/Infrastructure/API/GraphQL/UserType.php @@ -45,6 +45,9 @@ public function __construct() ], 'last_name' => [ 'type' => Type::nonNull(Type::string()) + ], + 'locale' => [ + 'type' => UserLocaleType::getInstance() ] ]; } diff --git a/src/Infrastructure/CLI/CreateUserCommand.php b/src/Infrastructure/CLI/CreateUserCommand.php index 188eff7f..ad46a879 100644 --- a/src/Infrastructure/CLI/CreateUserCommand.php +++ b/src/Infrastructure/CLI/CreateUserCommand.php @@ -22,6 +22,7 @@ protected function configure(): void $this->addOption('first-name', null, InputOption::VALUE_REQUIRED); $this->addOption('last-name', null, InputOption::VALUE_REQUIRED); $this->addOption('role', null, InputOption::VALUE_REQUIRED); + $this->addOption('locale', null, InputOption::VALUE_REQUIRED); $this->addOption('default', null, InputOption::VALUE_NONE); } @@ -34,7 +35,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->getFirstName($input, $output), $this->getLastName($input, $output), $this->getRole($input, $output), - [] + [], + $this->getLocale($input, $output) ); $this->container->get(CommandBus::class)->execute($command, $this->getAuthContext()); @@ -120,4 +122,21 @@ private function getRole(InputInterface $input, OutputInterface $output): ?strin return null; } + + private function getLocale(InputInterface $input, OutputInterface $output): ?string + { + if ($input->getOption('default')) { + return 'en'; + } + + if ($input->getOption('locale')) { + return $input->getOption('locale'); + } + + if ($input->isInteractive()) { + return $this->getStyledIO($input, $output)->choice('Choose a locale', User::getLocales()); + } + + return null; + } } diff --git a/src/Infrastructure/CLI/LoadDemoDataCommand.php b/src/Infrastructure/CLI/LoadDemoDataCommand.php index bf890bd3..f649fdaa 100644 --- a/src/Infrastructure/CLI/LoadDemoDataCommand.php +++ b/src/Infrastructure/CLI/LoadDemoDataCommand.php @@ -145,7 +145,8 @@ private function createTeamManagers(array $teamIds): void 'user' . $i, 'admin' . $i, User::ROLE_TEAM_MANAGER, - [array_shift($teamIds)] + [array_shift($teamIds)], + 'en' ); $this->getCommandBus()->execute($command, $this->getAuthContext()); diff --git a/src/Infrastructure/CLI/SendMailCommand.php b/src/Infrastructure/CLI/SendMailCommand.php index 0b1c0bfc..e08c9ca0 100644 --- a/src/Infrastructure/CLI/SendMailCommand.php +++ b/src/Infrastructure/CLI/SendMailCommand.php @@ -3,6 +3,7 @@ namespace HexagonalPlayground\Infrastructure\CLI; use HexagonalPlayground\Application\Email\MailerInterface; +use HexagonalPlayground\Application\Email\MessageBody; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -12,10 +13,10 @@ class SendMailCommand extends Command protected function configure(): void { $this->setName('app:mail:send'); - $this->setDescription('Send a mail with HTML body'); + $this->setDescription('Send a mail using an arbitrary content'); $this->addArgument('recipient', InputArgument::REQUIRED, 'Recipients mail address'); $this->addArgument('subject', InputArgument::REQUIRED, 'Mail subject'); - $this->addArgument('html-file', InputArgument::REQUIRED, 'Path to an HTML file containing the message body'); + $this->addArgument('content', InputArgument::REQUIRED, 'Mail content text'); } protected function execute(InputInterface $input, OutputInterface $output): int @@ -23,14 +24,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int /** @var MailerInterface $mailer */ $mailer = $this->container->get(MailerInterface::class); - $message = $mailer->createMessage( + $mailer->send( [$input->getArgument('recipient') => ''], $input->getArgument('subject'), - file_get_contents($input->getArgument('html-file')) + new MessageBody( + $input->getArgument('subject'), + $input->getArgument('content') + ) ); - $mailer->send($message); - $this->getStyledIO($input, $output)->success('Mail has been to ' . $input->getArgument('recipient')); return 0; diff --git a/src/Infrastructure/Email/HtmlMailRenderer.php b/src/Infrastructure/Email/HtmlMailRenderer.php new file mode 100644 index 00000000..a09088a7 --- /dev/null +++ b/src/Infrastructure/Email/HtmlMailRenderer.php @@ -0,0 +1,170 @@ + [ + 'margin' => '0', + 'padding' => '0', + 'font-family' => 'Arial, sans-serif', + ], + 'table' => [ + 'width' => '100%', + 'border-collapse' => 'collapse', + ], + 'img' => [ + 'display' => 'block', + 'max-width' => '100%', + 'height' => 'auto', + ], + '.container' => [ + 'width' => '100%', + 'max-width' => '600px', + 'margin' => '0 auto', + 'padding' => '20px' + ], + '.header' => [ + 'text-align' => 'center', + 'padding' => '40px 0', + ], + '.content' => [ + 'background-color' => '#ffffff', + 'padding' => '20px', + 'text-align' => 'center', + ], + '.cta-button' => [ + 'background-color' => '#ffc700', + 'color' => '#000000', + 'padding' => '15px 25px', + 'text-decoration' => 'none', + 'font-weight' => 'bold', + 'border-radius' => '5px', + 'display' => 'inline-block', + 'margin-top' => '20px', + ], + '.footer' => [ + 'text-align' => 'center', + 'padding' => '20px', + 'font-size' => '12px', + 'color' => '#888888' + ] + ]; + + public function render(MessageBody $data): string + { + $document = new DOMDocument(); + + // + $document->insertBefore($document->createProcessingInstruction('DOCTYPE', 'html'), $document->firstChild); + + // + $html = $this->addElement($document, $document, 'html'); + + // + $head = $this->addElement($document, $html, 'head'); + $this->addElement($document, $head, 'meta', ['charset' => 'UTF-8']); + $this->addElement($document, $head, 'meta', ['name' => 'viewport', 'value' => 'width=device-width, initial-scale=1.0']); + $this->addElement($document, $head, 'title', [], $data->title); + + // + $body = $this->addElement($document, $html, 'body'); + $container = $this->addContainer($document, $body); + $this->addHeader($document, $container); + $this->addContent($document, $container, $data); + $this->addFooter($document, $container, $data); + + return $document->saveHTML(); + } + + private function addContainer(DOMDocument $document, DOMNode $body): DOMElement + { + $table = $this->addElement($document, $body, 'table', [ + 'role' => 'presentation', + 'width' => '100%', + 'cellspacing' => '0', + 'cellpadding' => '0', + 'border' => '0' + ]); + $tableRow = $this->addElement($document, $table, 'tr'); + $tableCol = $this->addElement($document, $tableRow, 'td', ['align' => 'center']); + + return $this->addElement($document, $tableCol, 'div', ['class' => 'container']); + } + + private function addHeader(DOMDocument $document, DOMNode $container): DOMElement + { + $header = $this->addElement($document, $container, 'div', ['class' => 'header']); + + $this->addElement($document, $header, 'img', [ + 'src' => 'https://www.wildeligabremen.de/wp-content/uploads/2023/05/cropped-Logo-mit-Schrift_30-Jahre-Kopie_2-e1683381765583.jpg', + 'alt' => 'Wilde Liga Bremen' + ]); + + return $header; + } + + private function addContent(DOMDocument $document, DOMNode $container, MessageBody $data): DOMElement + { + $content = $this->addElement($document, $container, 'div', ['class' => 'content']); + + $this->addElement($document, $content, 'h1', [], $data->title); + $this->addElement($document, $content, 'p', [], $data->content); + + foreach ($data->actions as $label => $link) { + $this->addElement($document, $content, 'a', ['class' => 'cta-button', 'href' => $link], $label); + } + + return $content; + } + + private function addFooter(DOMDocument $document, DOMNode $container, MessageBody $data): DOMElement + { + $footer = $this->addElement($document, $container, 'div', ['class' => 'footer']); + + foreach ($data->hints as $hint) { + $this->addElement($document, $footer, 'p', [], $hint); + } + + return $footer; + } + + private function addElement(DOMDocument $document, DOMNode $parent, string $tag, array $attributes = [], ?string $text = null): DOMElement + { + $element = $document->createElement($tag); + + $styles = []; + $styles = array_merge($styles, $this->styles[$element->tagName] ?? []); + + if (isset($attributes['class'])) { + $styles = array_merge($styles, $this->styles['.' . $attributes['class']] ?? []); + unset($attributes['class']); + } + + foreach ($attributes as $name => $value) { + $element->setAttribute($name, $value); + } + + if (count($styles) > 0) { + $values = []; + foreach ($styles as $name => $value) { + $values[] = "$name:$value"; + } + $element->setAttribute('style', implode(';', $values)); + } + + if ($text !== null) { + $element->appendChild($document->createTextNode($text)); + } + + $parent->appendChild($element); + + return $element; + } +} \ No newline at end of file diff --git a/src/Infrastructure/Email/MailServiceProvider.php b/src/Infrastructure/Email/MailServiceProvider.php index 06b927d0..4b021940 100644 --- a/src/Infrastructure/Email/MailServiceProvider.php +++ b/src/Infrastructure/Email/MailServiceProvider.php @@ -6,10 +6,7 @@ use DI; use HexagonalPlayground\Application\Email\MailerInterface; use HexagonalPlayground\Application\ServiceProviderInterface; -use HexagonalPlayground\Application\TemplateRendererInterface; -use HexagonalPlayground\Infrastructure\Filesystem\FilesystemService; use HexagonalPlayground\Infrastructure\HealthCheckInterface; -use HexagonalPlayground\Infrastructure\TemplateRenderer; use Psr\Container\ContainerInterface; use Symfony\Component\Mailer\Mailer; use Symfony\Component\Mailer\Transport\TransportInterface; @@ -21,13 +18,6 @@ public function getDefinitions(): array return [ MailerInterface::class => DI\get(SymfonyMailer::class), SymfonyMailer::class => DI\autowire(), - TemplateRendererInterface::class => DI\get(TemplateRenderer::class), - TemplateRenderer::class => DI\factory(function (ContainerInterface $container) { - return new TemplateRenderer( - $container->get(FilesystemService::class), - $container->get('app.home') - ); - }), Mailer::class => DI\factory(function (ContainerInterface $container) { return new Mailer($container->get(TransportInterface::class)); }), diff --git a/src/Infrastructure/Email/SymfonyMailer.php b/src/Infrastructure/Email/SymfonyMailer.php index 66d2c096..5a0a42b9 100644 --- a/src/Infrastructure/Email/SymfonyMailer.php +++ b/src/Infrastructure/Email/SymfonyMailer.php @@ -4,6 +4,7 @@ namespace HexagonalPlayground\Infrastructure\Email; use HexagonalPlayground\Application\Email\MailerInterface; +use HexagonalPlayground\Application\Email\MessageBody; use HexagonalPlayground\Infrastructure\Config; use Symfony\Component\Mailer\Mailer; use Symfony\Component\Mime\Address; @@ -13,14 +14,18 @@ class SymfonyMailer implements MailerInterface { private Mailer $mailer; private Config $config; + private HtmlMailRenderer $htmlRenderer; + private TextMailRenderer $textRenderer; - public function __construct(Mailer $mailer, Config $config) + public function __construct(Mailer $mailer, Config $config, HtmlMailRenderer $htmlRenderer, TextMailRenderer $textRenderer) { $this->mailer = $mailer; $this->config = $config; + $this->htmlRenderer = $htmlRenderer; + $this->textRenderer = $textRenderer; } - public function createMessage(array $to, string $subject, string $html): object + public function send(array $to, string $subject, MessageBody $body): void { $message = new Email(); @@ -34,13 +39,9 @@ public function createMessage(array $to, string $subject, string $html): object } $message->subject($subject); - $message->html($html); + $message->html($this->htmlRenderer->render($body)); + $message->text($this->textRenderer->render($body)); - return $message; - } - - public function send(object $message): void - { $this->mailer->send($message); } } diff --git a/src/Infrastructure/Email/TextMailRenderer.php b/src/Infrastructure/Email/TextMailRenderer.php new file mode 100644 index 00000000..fdd36fc5 --- /dev/null +++ b/src/Infrastructure/Email/TextMailRenderer.php @@ -0,0 +1,28 @@ +title; + $lines[] = ''; + $lines[] = $data->content; + $lines[] = ''; + + foreach ($data->actions as $label => $link) { + $lines[] = $label . ': ' . $link; + } + $lines[] = ''; + + foreach ($data->hints as $hint) { + $lines[] = '* ' . $hint; + } + + return implode("\r\n", $lines); + } +} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Read/UserRepository.php b/src/Infrastructure/Persistence/Read/UserRepository.php index 056aa40f..eb2fe7d5 100644 --- a/src/Infrastructure/Persistence/Read/UserRepository.php +++ b/src/Infrastructure/Persistence/Read/UserRepository.php @@ -21,7 +21,8 @@ protected function getFieldDefinitions(): array new DateTimeField('last_password_change', true), new StringField('role', false), new StringField('first_name', false), - new StringField('last_name', false) + new StringField('last_name', false), + new StringField('locale', true) ]; } } diff --git a/src/Infrastructure/TemplateRenderer.php b/src/Infrastructure/TemplateRenderer.php deleted file mode 100644 index 5d77243a..00000000 --- a/src/Infrastructure/TemplateRenderer.php +++ /dev/null @@ -1,41 +0,0 @@ -filesystemService = $filesystemService; - $this->templatePath = $filesystemService->joinPaths([$appHome, 'templates']); - if (!$this->filesystemService->isDirectory($this->templatePath)) { - throw new InvalidArgumentException('Template directory does not exist'); - } - } - - /** - * @param string $template - * @param array $data - * @return string - */ - public function render(string $template, array $data): string - { - $path = $this->filesystemService->joinPaths([$this->templatePath, $template]); - extract($data); - ob_start(); - require $path; - return ob_get_clean(); - } -} diff --git a/templates/InviteUser.html.php b/templates/InviteUser.html.php deleted file mode 100644 index 2b0f1982..00000000 --- a/templates/InviteUser.html.php +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - Deine Einladung - - - - - - - -
-
- -
- Wilde Liga Bremen -
- -
-

Deine Einladung

-

Hey , du wurdest von zum Liga-Manager eingeladen.

- Registrieren -
- - -
-
- - \ No newline at end of file diff --git a/templates/PasswordReset.html.php b/templates/PasswordReset.html.php deleted file mode 100644 index 16bce766..00000000 --- a/templates/PasswordReset.html.php +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - Passwort zurücksetzen - - - - - - - -
-
- -
- Wilde Liga Bremen -
- -
-

Passwort zurücksetzen

-

Hey , nutze den folgenden Link um ein neues Passwort zu vergeben.

- Neues Passwort setzen -
- - -
-
- - diff --git a/templates/assets/style.css b/templates/assets/style.css deleted file mode 100644 index 163ea0c6..00000000 --- a/templates/assets/style.css +++ /dev/null @@ -1,45 +0,0 @@ -body { - margin: 0; - padding: 0; - font-family: Arial, sans-serif; -} -table { - width: 100%; - border-collapse: collapse; -} -img { - display: block; - max-width: 100%; - height: auto; -} -.container { - width: 100%; - max-width: 600px; - margin: 0 auto; - padding: 20px; -} -.header { - text-align: center; - padding: 40px 0; -} -.content { - background-color: #ffffff; - padding: 20px; - text-align: center; -} -.cta-button { - background-color: #ffc700; - color: #000000; - padding: 15px 25px; - text-decoration: none; - font-weight: bold; - border-radius: 5px; - display: inline-block; - margin-top: 20px; -} -.footer { - text-align: center; - padding: 20px; - font-size: 12px; - color: #888888; -} \ No newline at end of file diff --git a/tests/CLI/CliTest.php b/tests/CLI/CliTest.php index a4d3565b..6e2eb84e 100644 --- a/tests/CLI/CliTest.php +++ b/tests/CLI/CliTest.php @@ -79,7 +79,7 @@ public function testMigratingDatabase(): void public function testCreatingUser(): void { $tester = $this->getCommandTester('app:user:create'); - $tester->setInputs(['mary.poppins@example.com', DataGenerator::generatePassword(), 'Mary', 'Poppins', 'admin']); + $tester->setInputs(['mary.poppins@example.com', DataGenerator::generatePassword(), 'Mary', 'Poppins', 'admin', 'en']); self::assertExecutionSuccess($tester->execute([])); $tester = $this->getCommandTester('app:user:create'); @@ -204,15 +204,12 @@ public function testDatabaseCanBeImported(string $xmlFile): void public function testSendingMail(): void { - $htmlFile = tempnam(sys_get_temp_dir(), 'message'); - file_put_contents($htmlFile, '

This is just a test

'); $tester = $this->getCommandTester('app:mail:send'); self::assertExecutionSuccess($tester->execute([ 'recipient' => 'test@example.com', 'subject' => 'Test', - 'html-file' => $htmlFile + 'content' => 'This is just a test' ])); - unlink($htmlFile); } public function testImportingLogo(): string diff --git a/tests/GraphQL/UserTest.php b/tests/GraphQL/UserTest.php index f4eb6296..8ca09c98 100644 --- a/tests/GraphQL/UserTest.php +++ b/tests/GraphQL/UserTest.php @@ -2,6 +2,7 @@ namespace HexagonalPlayground\Tests\GraphQL; +use DOMDocument; use HexagonalPlayground\Domain\User; use HexagonalPlayground\Tests\Framework\DataGenerator; use HexagonalPlayground\Tests\Framework\GraphQL\Exception; @@ -90,6 +91,7 @@ public function testPasswordResetSendsAnEmail() $recipient = current($recipients); self::assertIsObject($recipient); self::assertEquals($user['email'], $recipient->address); + self::assertHtmlMailBodyIsValid($mail->html); } public function testPasswordResetDoesNotErrorWithUnknownEmail(): void @@ -167,6 +169,7 @@ public function testSendingInviteEmail(array $user): array $recipient = current($recipients); self::assertIsObject($recipient); self::assertEquals($user['email'], $recipient->address); + self::assertHtmlMailBodyIsValid($mail->html); return $user; } @@ -205,4 +208,16 @@ public function testUserCanBeDeleted(array $user) $this->expectClientException(); $this->client->getAuthenticatedUser(); } + + /** + * @param string $html + */ + private static function assertHtmlMailBodyIsValid(string $html): void + { + $document = new DOMDocument(); + $document->loadHTML($html); + + self::assertCount(1, $document->getElementsByTagName('title')); + self::assertCount(1, $document->getElementsByTagName('img')); + } }