diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70f31fb0..fc16b0d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,38 +4,55 @@ on: push: pull_request: +permissions: + contents: read + jobs: tests: - name: PHP 8.5 / Symfony 7.4 + name: "PHPUnit (PHP 8.5, deps: ${{ matrix.dependency-mode }})" runs-on: ubuntu-latest timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + dependency-mode: ['stable', 'lowest'] + + env: + MAILER_DSN: 'null://null' + steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup PHP uses: shivammathur/setup-php@v2 with: php-version: '8.5' coverage: none - extensions: mbstring, xml, ctype, iconv, intl, dom, json, pdo, pdo_sqlite + extensions: ctype, dom, fileinfo, filter, gd, iconv, intl, json, mailparse, mbstring, pdo, pdo_sqlite, simplexml, tokenizer, xml, xmlwriter tools: composer:v2 - name: Cache Composer - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.composer/cache/files ~/.cache/composer/files - key: composer-${{ runner.os }}-${{ hashFiles('**/composer.json') }} - restore-keys: composer-${{ runner.os }}- + key: composer-${{ runner.os }}-php85-${{ matrix.dependency-mode }}-${{ hashFiles('composer.json') }} + restore-keys: | + composer-${{ runner.os }}-php85-${{ matrix.dependency-mode }}- - name: Validate composer.json run: composer validate --strict --no-check-publish - - name: Install dependencies + - name: Install dependencies (stable) + if: matrix.dependency-mode == 'stable' run: composer update --prefer-dist --no-interaction + - name: Install dependencies (lowest) + if: matrix.dependency-mode == 'lowest' + run: composer update --prefer-lowest --prefer-stable --prefer-dist --no-interaction + - name: Run test suite run: vendor/bin/phpunit -c phpunit.xml.dist diff --git a/Command/ClearAndLogFailedMailsCommand.php b/Command/ClearAndLogFailedMailsCommand.php deleted file mode 100644 index 6529b2f1..00000000 --- a/Command/ClearAndLogFailedMailsCommand.php +++ /dev/null @@ -1,137 +0,0 @@ -container; - $this->container = $container; - - return $previous; - } - - protected function getContainer(): ContainerInterface - { - if (null === $this->container) { - throw new \LogicException('Container has not been set.'); - } - - return $this->container; - } - - protected function configure(): void - { - $this->setName('emails:clear-and-log-failures') - ->setDescription('Clears and logs failed emails from the spool') - ->setDefinition(array(new InputArgument('date', - InputArgument::OPTIONAL, - 'Try to send and then delete all failed emails that are older than "date". The date must be something that strtotime() is able to parse: => e.g. "since yesterday", "until 2 days ago", "> now - 2 hours", ">= 2005-10-15" ' - ), - )) - ->setHelp(<<emails:clear-and-log-failures command tries to send failed emails and deletes them -from the spool directory after this last try. Any email-address that still failed, is logged. -EOF - ) - ; - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - $failedRecipients = array(); - - // check if the current environment is configured to spool emails - try { - /** @var $transport \Swift_Transport */ - $transport = $this->getContainer()->get('swiftmailer.transport.real'); - } catch (ServiceNotFoundException $ex) { - $output->writeln("\n\n\nCould not load transport. Is file-spooling configured in your config.yml for this environment?\n\n\n"); - - return Command::SUCCESS; - } - - try { - $mailers = $this->getContainer()->getParameter('swiftmailer.mailers'); - $mailerName = key($mailers); - $spoolPath = $this->getContainer()->getParameter("swiftmailer.spool.$mailerName.file.path"); - } catch (InvalidArgumentException $ex) { - $output->writeln("\n\n\nCould not find file spool path. Is file-spooling configured in your config.yml for this environment?\n\n\n"); - - return Command::SUCCESS; - } - - // start the mail transport - if (!$transport->isStarted()) { - $transport->start(); - } - - // find pending mails and try to send them again now - $finder = Finder::create()->in($spoolPath)->name('*.sending'); - - $date = $input->getArgument('date'); - - if ($date) { - $finder->date($date); - } - - if (0 == $finder->count()) { - $output->writeln("No failed-message-files found in '$spoolPath' for retry."); - - return Command::SUCCESS; - } - - foreach ($finder as $failedFile) { - // rename the file, so no other process tries to find it - $tmpFilename = $failedFile.'.finalretry'; - rename($failedFile, $tmpFilename); - - /** @var $message \Swift_Message */ - $message = unserialize(file_get_contents($tmpFilename)); - $output->writeln(sprintf( - "Retrying to send '%s' to '%s'", - $message->getSubject(), - implode(', ', array_keys($message->getTo())) - )); - - try { - $transport->send($message, $failedRecipients); - $output->writeln('Sent!'); - } catch (\Swift_TransportException $e) { - $output->writeln('Send failed - deleting spooled message'); - } - - // delete the file, either because it sent, or because it failed - unlink($tmpFilename); - } - - // write the failure to the log - if (sizeof($failedRecipients) > 0) { - /** @var $logger LoggerInterface */ - $logger = $this->getContainer()->get('logger'); - $logger->warning('Failed to send an email to : '.implode(', ', $failedRecipients).''); - } - - return Command::SUCCESS; - } -} diff --git a/Command/RemoveOldWebViewEmailsCommand.php b/Command/RemoveOldWebViewEmailsCommand.php index b63f8ddd..724c26bf 100644 --- a/Command/RemoveOldWebViewEmailsCommand.php +++ b/Command/RemoveOldWebViewEmailsCommand.php @@ -1,91 +1,75 @@ container; - $this->container = $container; - - return $previous; + public function __construct( + private readonly ManagerRegistry $managerRegistry, + private readonly int $retentionDays, + ) { + parent::__construct(); } - protected function getContainer(): ContainerInterface - { - if (null === $this->container) { - throw new \LogicException('Container has not been set.'); - } - - return $this->container; - } - - /** - * (non-PHPdoc). - * - * @see Symfony\Component\Console\Command.Command::configure() - */ protected function configure(): void { - $this->setName('emails:remove-old-web-view-emails') - ->setDescription('Remove all "SentEmail" from the database that are older than the configured time.') - ->setDefinition(array(new InputArgument('keep', InputArgument::OPTIONAL, 'Remove all SentEmails older than "keep" days => also see azine_email_web_view_retention_time '))) - ->setHelp(<<emails:remove-old-web-view-emails command deletes all SentEmail entities from the database -that are older than the number of days specified in the command-line parameter "keep" or configured in the -parameter "azine_email_web_view_retention" in your config.yml. -EOF - ) - ; + $this + ->addArgument( + 'keep', + InputArgument::OPTIONAL, + 'Remove stored emails older than this number of days.', + ) + ->setHelp(<<<'HELP' +The emails:remove-old-web-view-emails command deletes SentEmail entities older than +"keep" days. When the argument is omitted, azine_email.web_view_retention is used. +HELP + ); } - /** - * (non-PHPdoc). - * - * @see Symfony\Component\Console\Command.Command::execute() - */ protected function execute(InputInterface $input, OutputInterface $output): int { - // get the number of days from the command-line-input - $days = $input->getArgument('keep'); + $argument = $input->getArgument('keep'); + $days = is_numeric($argument) ? (int) $argument : $this->retentionDays; - // or if it is not given, from the config.yml - if (!is_numeric($days)) { - $days = $this->getContainer()->getParameter('azine_email_web_view_retention'); - $output->writeln("using the parameter from the configuration => '$days' days."); - } + if ($days < 1) { + $output->writeln('The web-view retention period must be at least one day.'); - if (null === $days) { - $output->writeln('either the commandline parameter "keep" or the "azine_email_web_view_retention" in your config.yml or the default-config has to be defined.'); + return Command::INVALID; + } - return Command::SUCCESS; + if (!is_numeric($argument)) { + $output->writeln(sprintf('Using the configured retention period: %d days.', $days)); } - // delete all SentEmails older than $date from the database - $date = new \DateTime("$days days ago"); - $sentEmails = $this->getContainer()->get('doctrine')->getManager()->createQueryBuilder() - ->delete('AzineEmailBundle:SentEmail', 's') + $cutoff = new \DateTimeImmutable(sprintf('-%d days', $days)); + $deleted = $this->managerRegistry + ->getManager() + ->createQueryBuilder() + ->delete(SentEmail::class, 's') ->where('s.sent < :sent') - ->setParameter('sent', $date); - $q = $sentEmails->getQuery(); - $result = $q->execute(); + ->setParameter('sent', $cutoff) + ->getQuery() + ->execute(); - $output->writeln($result.' SentEmails have been deleted that were older than '.$date->format('Y-m-d H:i:s')); + $output->writeln(sprintf( + '%d SentEmails older than %s were deleted.', + (int) $deleted, + $cutoff->format('Y-m-d H:i:s'), + )); return Command::SUCCESS; } diff --git a/Command/SendNewsLetterCommand.php b/Command/SendNewsLetterCommand.php index b10a68a1..c7712669 100644 --- a/Command/SendNewsLetterCommand.php +++ b/Command/SendNewsLetterCommand.php @@ -1,99 +1,69 @@ container; - $this->container = $container; - - return $previous; - } - - protected function getContainer(): ContainerInterface - { - if (null === $this->container) { - throw new \LogicException('Container has not been set.'); - } - - return $this->container; + public function __construct( + private readonly NotifierServiceInterface $notifierService, + private readonly LockFactory $lockFactory, + ) { + parent::__construct(); } - /** - * (non-PHPdoc). - * - * @see Symfony\Component\Console\Command.Command::configure() - */ protected function configure(): void { - $this->setName('emails:sendNewsletter') - ->setDescription('Send Newsletter via email to all subscribers.') - ->setHelp(<<emails:sendNewsletter command sends the newsletter email to all recipients who -indicate that they would like to recieve the newsletter (see Azine\EmailBundle\Entity\RecipientInterface.getNewsletter). - -Depending on you Swiftmailer-Configuration the email will be send directly or will be written to the spool. - -If you configured Swiftmailer to spool email, then you need to run the swiftmailer:spool:send -command to actually send the emails from the spool. - -EOF - ) - ; + $this->setHelp(<<<'HELP' +The emails:sendNewsletter command sends the newsletter to all recipients who opted in. +Delivery uses the configured Symfony Mailer transport. For asynchronous delivery, route +Symfony Mailer messages through Messenger and operate the Messenger worker separately. +HELP + ); } - /** - * (non-PHPdoc). - * - * @see Symfony\Component\Console\Command.Command::execute() - */ protected function execute(InputInterface $input, OutputInterface $output): int { - if (\Symfony\Component\HttpKernel\Kernel::VERSION_ID < 30400) { - $lock = new \Symfony\Component\Filesystem\LockHandler($this->getName()); - $unlockedCommand = $lock->lock(); - } else { - $store = new \Symfony\Component\Lock\Store\SemaphoreStore(); - $factory = new \Symfony\Component\Lock\LockFactory($store); - - $lock = $factory->createLock($this->getName()); - $unlockedCommand = $lock->acquire(); - } - - if (!$unlockedCommand) { + $lock = $this->lockFactory->createLock((string) $this->getName()); + if (!$lock->acquire()) { $output->writeln('The command is already running in another process.'); - return 0; + return Command::SUCCESS; } - $failedAddresses = array(); - $output->writeln(date(\DateTime::RFC2822).' : starting to send newsletter emails.'); - - $sentMails = $this->getContainer()->get('azine_email_notifier_service')->sendNewsletter($failedAddresses); - - $output->writeln(date(\DateTime::RFC2822).' : '.str_pad($sentMails, 4, ' ', STR_PAD_LEFT).' newsletter emails have been sent.'); - if (sizeof($failedAddresses) > 0) { - $output->writeln(date(\DateTime::RFC2822).' : '.'The following email-addresses failed:'); - foreach ($failedAddresses as $address) { - $output->writeln(' '.$address); + try { + $failedAddresses = []; + $output->writeln((new \DateTimeImmutable())->format(\DateTimeInterface::RFC2822).' : starting to send newsletter emails.'); + $sentMails = $this->notifierService->sendNewsletter($failedAddresses); + + $output->writeln(sprintf( + '%s : %4d newsletter emails have been sent.', + (new \DateTimeImmutable())->format(\DateTimeInterface::RFC2822), + $sentMails, + )); + + if ([] !== $failedAddresses) { + $output->writeln((new \DateTimeImmutable())->format(\DateTimeInterface::RFC2822).' : The following email addresses failed:'); + foreach ($failedAddresses as $address) { + $output->writeln(' '.$address); + } } - } - return Command::SUCCESS; + return Command::SUCCESS; + } finally { + $lock->release(); + } } } diff --git a/Command/SendNotificationsCommand.php b/Command/SendNotificationsCommand.php index a5e4804e..a14971d0 100644 --- a/Command/SendNotificationsCommand.php +++ b/Command/SendNotificationsCommand.php @@ -1,99 +1,68 @@ container; - $this->container = $container; - - return $previous; - } - - protected function getContainer(): ContainerInterface - { - if (null === $this->container) { - throw new \LogicException('Container has not been set.'); - } - - return $this->container; + public function __construct( + private readonly NotifierServiceInterface $notifierService, + private readonly LockFactory $lockFactory, + ) { + parent::__construct(); } - /** - * (non-PHPdoc). - * - * @see Symfony\Component\Console\Command.Command::configure() - */ protected function configure(): void { - $this->setName('emails:sendNotifications') - ->setDescription('Aggregate and send pending notifications via email.') - ->setHelp(<<emails:sendNotifications command sends emails for all pending notifications. - -Depending on you Swiftmailer-Configuration the email will be send directly or will be written to the spool. - -If you configured Swiftmailer to spool email, then you need to run the swiftmailer:spool:send -command to actually send the emails from the spool. - -EOF - ) - ; + $this->setHelp(<<<'HELP' +The emails:sendNotifications command aggregates and sends pending notification emails. +Delivery uses the configured Symfony Mailer transport. For asynchronous delivery, route +Symfony Mailer messages through Messenger and operate the Messenger worker separately. +HELP + ); } - /** - * (non-PHPdoc). - * - * @see Symfony\Component\Console\Command.Command::execute() - */ protected function execute(InputInterface $input, OutputInterface $output): int { - if (\Symfony\Component\HttpKernel\Kernel::VERSION_ID < 30400) { - $lock = new \Symfony\Component\Filesystem\LockHandler($this->getName()); - $unlockedCommand = $lock->lock(); - } else { - $store = new \Symfony\Component\Lock\Store\SemaphoreStore(); - $factory = new \Symfony\Component\Lock\LockFactory($store); - - $lock = $factory->createLock($this->getName()); - $unlockedCommand = $lock->acquire(); - } - - if (!$unlockedCommand) { + $lock = $this->lockFactory->createLock((string) $this->getName()); + if (!$lock->acquire()) { $output->writeln('The command is already running in another process.'); - return 0; + return Command::SUCCESS; } - $failedAddresses = array(); - $sentMails = $this->getContainer()->get('azine_email_notifier_service')->sendNotifications($failedAddresses); - - $output->writeln(date(\DateTime::RFC2822).' : '.str_pad($sentMails, 4, ' ', STR_PAD_LEFT).' emails have been processed.'); - if (sizeof($failedAddresses) > 0) { - $output->writeln(date(\DateTime::RFC2822).' : '.'The following email-addresses failed:'); - foreach ($failedAddresses as $address) { - $output->writeln(' '.$address); + try { + $failedAddresses = []; + $sentMails = $this->notifierService->sendNotifications($failedAddresses); + + $output->writeln(sprintf( + '%s : %4d emails have been processed.', + (new \DateTimeImmutable())->format(\DateTimeInterface::RFC2822), + $sentMails, + )); + + if ([] !== $failedAddresses) { + $output->writeln((new \DateTimeImmutable())->format(\DateTimeInterface::RFC2822).' : The following email addresses failed:'); + foreach ($failedAddresses as $address) { + $output->writeln(' '.$address); + } } - } - // (optional) release the lock (otherwise, PHP will do it for you automatically) - $lock->release(); - - return Command::SUCCESS; + return Command::SUCCESS; + } finally { + $lock->release(); + } } } diff --git a/Controller/AzineEmailTemplateController.php b/Controller/AzineEmailTemplateController.php index 021396d0..4ce40b83 100644 --- a/Controller/AzineEmailTemplateController.php +++ b/Controller/AzineEmailTemplateController.php @@ -1,10 +1,18 @@ get('customEmail', 'custom@email.com'); - $templates = $this->container->get('azine_email_web_view_service')->getTemplatesForWebPreView(); - $emails = $this->container->get('azine_email_web_view_service')->getTestMailAccounts(); - - return $this->container->get('templating') - ->renderResponse('AzineEmailBundle:Webview:index.html.twig', - array( - 'customEmail' => $customEmail, - 'templates' => $templates, - 'emails' => $emails, - )); + public function __construct( + private readonly WebViewServiceInterface $webViewService, + private readonly TemplateProviderInterface $templateProvider, + private readonly TemplateTwigMailerInterface $mailer, + private readonly SpamCheckService $spamCheckService, + private readonly Environment $twig, + private readonly AzineEmailTwigExtension $emailTwigExtension, + private readonly ManagerRegistry $managerRegistry, + private readonly TokenStorageInterface $tokenStorage, + private readonly TranslatorInterface $translator, + private readonly RouterInterface $router, + private readonly ?EmailOpenTrackingCodeBuilderInterface $emailOpenTrackingCodeBuilder, + private readonly array $noReply, + private readonly int $webViewRetentionDays, + ) { } - /** - * Show a web-preview-version of an email-template, filled with dummy-content. - * - * @param string $format - * - * @return Response - */ - public function webPreViewAction(Request $request, $template, $format = null) + public function indexAction(Request $request): Response { - if ('txt' !== $format) { - $format = 'html'; - } + return $this->renderTemplate('@AzineEmail/Webview/index.html.twig', [ + 'customEmail' => $request->query->getString('customEmail', 'custom@email.com'), + 'templates' => $this->webViewService->getTemplatesForWebPreView(), + 'emails' => $this->webViewService->getTestMailAccounts(), + ]); + } + public function webPreViewAction(Request $request, string $template, ?string $format = null): Response + { + $format = 'txt' === $format ? 'txt' : 'html'; $template = urldecode($template); - $locale = $request->getLocale(); - - // merge request vars with dummyVars, but make sure request vars remain as they are. - $emailVars = array_merge(array(), $request->query->all()); - $emailVars = $this->container->get('azine_email_web_view_service')->getDummyVarsFor($template, $locale, $emailVars); - $emailVars = array_merge($emailVars, $request->query->all()); - - // add the styles - $emailVars = $this->getTemplateProviderService()->addTemplateVariablesFor($template, $emailVars); - - // add the from-email for the footer-text - if (!array_key_exists('fromEmail', $emailVars)) { - $noReply = $this->container->getParameter('azine_email_no_reply'); - $emailVars['fromEmail'] = $noReply['email']; - $emailVars['fromName'] = $noReply['name']; - } - - // set the emailLocale for the templates - $emailVars['emailLocale'] = $locale; - - // replace absolute image-paths with relative ones. - $emailVars = $this->getTemplateProviderService()->makeImagePathsWebRelative($emailVars, $locale); - - // add code-snippets - $emailVars = $this->getTemplateProviderService()->addTemplateSnippetsWithImagesFor($template, $emailVars, $locale); - - // render & return email - $response = $this->renderResponse("$template.$format.twig", $emailVars); - - // add campaign tracking params - $campaignParams = $this->getTemplateProviderService()->getCampaignParamsFor($template, $emailVars); - $campaignParams['utm_medium'] = 'webPreview'; - if (sizeof($campaignParams) > 0) { - $content = $response->getContent(); - $content = $this->container->get('azine.email.bundle.twig.filters')->addCampaignParamsToAllUrls($content, $campaignParams); - - $emailOpenTrackingCodeBuilder = $this->container->get('azine_email_email_open_tracking_code_builder'); - if ($emailOpenTrackingCodeBuilder) { - // add an image at the end of the html tag with the tracking-params to track email-opens - $imgTrackingCode = $emailOpenTrackingCodeBuilder->getTrackingImgCode($template, $campaignParams, $emailVars, 'dummy', 'dummy@from.email.com', null, null); - if ($imgTrackingCode && strlen($imgTrackingCode) > 0) { - // replace the tracking url, so no request is made to the real tracking system. - $imgTrackingCode = str_replace('://', '://webview-dummy-domain.', $imgTrackingCode); - $htmlCloseTagPosition = strpos($content, ''); - $content = substr_replace($content, $imgTrackingCode, $htmlCloseTagPosition, 0); + $requestVariables = $request->query->all(); + + $emailVariables = $this->webViewService->getDummyVarsFor( + $template, + $locale, + $requestVariables, + ); + $emailVariables = array_merge($emailVariables, $requestVariables); + $emailVariables = $this->templateProvider->addTemplateVariablesFor($template, $emailVariables); + + $emailVariables['fromEmail'] ??= (string) ($this->noReply['email'] ?? ''); + $emailVariables['fromName'] ??= (string) ($this->noReply['name'] ?? ''); + $emailVariables['sendMailAccountAddress'] ??= $emailVariables['fromEmail']; + $emailVariables['sendMailAccountName'] ??= $emailVariables['fromName']; + $emailVariables['emailLocale'] = $locale; + + $emailVariables = $this->templateProvider->makeImagePathsWebRelative($emailVariables, $locale); + $emailVariables = $this->templateProvider->addTemplateSnippetsWithImagesFor( + $template, + $emailVariables, + $locale, + ); + + $content = $this->twig->render($this->templateFile($template, $format), $emailVariables); + $campaignParameters = $this->templateProvider->getCampaignParamsFor($template, $emailVariables); + if ([] !== $campaignParameters) { + $campaignParameters['utm_medium'] = 'webPreview'; + $content = $this->emailTwigExtension->addCampaignParamsToAllUrls($content, $campaignParameters); + + if ('html' === $format && null !== $this->emailOpenTrackingCodeBuilder) { + $trackingCode = $this->emailOpenTrackingCodeBuilder->getTrackingImgCode( + $template, + $campaignParameters, + $emailVariables, + 'dummy', + 'dummy@from.email.com', + null, + null, + ); + if (is_string($trackingCode) && '' !== $trackingCode) { + $trackingCode = str_replace('://', '://webview-dummy-domain.', $trackingCode); + $content = $this->appendBeforeClosingTag($content, $trackingCode, ''); } } - $response->setContent($content); } - // if the requested format is txt, remove the html-part - if ('txt' == $format) { - // set the correct content-type - $response->headers->set('Content-Type', 'text/plain'); - - // cut away the html-part - $content = $response->getContent(); + if ('txt' === $format) { $textEnd = stripos($content, ' 0) { - $response->setContent(substr($content, 0, $textEnd)); + if (false !== $textEnd) { + $content = substr($content, 0, $textEnd); } + + return new Response($content, Response::HTTP_OK, ['Content-Type' => 'text/plain; charset=UTF-8']); } - return $response; + return new Response($content); } - /** - * Show a web-version of an email that has been sent to recipients and has been stored in the database. - * - * @param string $token - * - * @return Response - */ - public function webViewAction(Request $request, $token) + public function webViewAction(Request $request, string $token): Response { - // find email recipients, template & params $sentEmail = $this->getSentEmailForToken($token); + if (!$sentEmail instanceof SentEmail) { + return $this->renderTemplate( + '@AzineEmail/Webview/mail.not.available.html.twig', + ['days' => $this->webViewRetentionDays], + Response::HTTP_NOT_FOUND, + ); + } - // check if the sent email is available - if (null !== $sentEmail) { - // check if the current user is allowed to see the email - if ($this->userIsAllowedToSeeThisMail($sentEmail)) { - $template = $sentEmail->getTemplate(); - $emailVars = $sentEmail->getVariables(); - - // re-attach all entities to the EntityManager. - $this->reAttachAllEntities($emailVars); - - // remove the web-view-token from the param-array - $templateProvider = $this->getTemplateProviderService(); - unset($emailVars[$templateProvider->getWebViewTokenId()]); - - // render & return email - $response = $this->renderResponse("$template.html.twig", $emailVars); - - $campaignParams = $templateProvider->getCampaignParamsFor($template, $emailVars); - - if (null != $campaignParams && sizeof($campaignParams) > 0) { - $response->setContent($this->container->get('azine.email.bundle.twig.filters')->addCampaignParamsToAllUrls($response->getContent(), $campaignParams)); - } + if (!$this->userIsAllowedToSeeThisMail($sentEmail)) { + throw new AccessDeniedException( + $this->translator->trans('web.pre.view.test.mail.access.denied'), + ); + } - return $response; + $template = (string) $sentEmail->getTemplate(); + $emailVariables = $sentEmail->getVariables(); + $this->reAttachAllEntities($emailVariables); + unset($emailVariables[$this->templateProvider->getWebViewTokenId()]); - // if the user is not allowed to see this mail - } - $msg = $this->container->get('translator')->trans('web.pre.view.test.mail.access.denied'); - throw new AccessDeniedException($msg); + $content = $this->twig->render($this->templateFile($template, 'html'), $emailVariables); + $campaignParameters = $this->templateProvider->getCampaignParamsFor($template, $emailVariables); + if ([] !== $campaignParameters) { + $content = $this->emailTwigExtension->addCampaignParamsToAllUrls($content, $campaignParameters); } - // the parameters-array is null => the email is not available in webView - $days = $this->container->getParameter('azine_email_web_view_retention'); - $response = $this->renderResponse('AzineEmailBundle:Webview:mail.not.available.html.twig', array('days' => $days)); - $response->setStatusCode(404); - - return $response; + return new Response($content); } - /** - * Check if the user is allowed to see the email. - * => the mail is public or the user is among the recipients or the user is an admin. - * - * @return bool - */ - private function userIsAllowedToSeeThisMail(SentEmail $mail) + public function serveImageAction(Request $request, string $folderKey, string $filename): BinaryFileResponse { - $recipients = $mail->getRecipients(); - - // it is a public email - if (null === $recipients) { - return true; - } - - // get the current user - $currentUser = null; - if (!$this->has('security.token_storage')) { - // @codeCoverageIgnoreStart - throw new \LogicException('The SecurityBundle is not registered in your application.'); - // @codeCoverageIgnoreEnd + $folder = $this->templateProvider->getFolderFrom($folderKey); + if (false === $folder) { + throw new FileNotFoundException($filename); } - $token = $this->container->get('security.token_storage')->getToken(); - // check if the token is not null and the user in the token an object - if ($token instanceof TokenInterface && is_object($token->getUser())) { - $currentUser = $token->getUser(); + $baseFolder = realpath((string) $folder); + $fullPath = realpath(rtrim((string) $folder, '/').'/'.urldecode($filename)); + if ( + false === $baseFolder + || false === $fullPath + || !str_starts_with($fullPath, rtrim($baseFolder, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR) + || !is_file($fullPath) + ) { + throw new FileNotFoundException($filename); } - // it is not a public email, and a user is logged in - if (null !== $currentUser) { - // the user is among the recipients - if (false !== array_search($currentUser->getEmail(), $recipients)) { - return true; - } - - // the user is admin - if ($currentUser->hasRole('ROLE_ADMIN')) { - return true; - } + $response = new BinaryFileResponse($fullPath); + $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE); + $mimeType = mime_content_type($fullPath); + if (is_string($mimeType)) { + $response->headers->set('Content-Type', $mimeType); } - // not public email, but - // - there is no user, or - // - the user is not among the recipients and - // - the user not an admin-user either - return false; + return $response; } - /** - * Replace all unmanaged Objects in the array (recursively) - * by managed Entities fetched via Doctrine EntityManager. - * - * It is assumed that managed objects can be identified - * by their id and implement the function getId() to get that id. - * - * @param array $vars passed by reference & manipulated but not returned - * - * @return null - */ - private function reAttachAllEntities(array &$vars) + public function sendTestEmailAction(Request $request, string $template, string $email): RedirectResponse { - /** @var EntityManager $em */ - $em = $this->container->get('doctrine')->getManager(); - foreach ($vars as $key => $next) { - if (is_object($next) && method_exists($next, 'getId')) { - $className = get_class($next); - $managedEntity = $em->find($className, $next->getId()); - $em->refresh($managedEntity); - if ($managedEntity) { - $vars[$key] = $managedEntity; - } - continue; - } elseif (is_array($next)) { - $this->reAttachAllEntities($next); - $vars[$key] = $next; - continue; - } - } - } + $locale = $request->getLocale(); + $template = urldecode($template); + $emailVariables = $this->webViewService->getDummyVarsFor($template, $locale); + $recipients = $this->parseAddresses($email); + $message = new Email(); + + $sent = $this->mailer->sendSingleEmail( + $recipients, + null, + (string) ($emailVariables['subject'] ?? 'Test email'), + $emailVariables, + $this->templateFile($template, 'txt'), + $locale, + (string) ($emailVariables['sendMailAccountAddress'] ?? $this->noReply['email'] ?? ''), + (string) ($emailVariables['sendMailAccountName'] ?? $this->noReply['name'] ?? '').' (Test)', + $message, + ); - /** - * Serve the image from the templates-folder. - * - * @param string $folderKey - * @param string $filename - * - * @return BinaryFileResponse - */ - public function serveImageAction(Request $request, $folderKey, $filename) - { - $folder = $this->getTemplateProviderService()->getFolderFrom($folderKey); - if (false !== $folder) { - $fullPath = $folder.urldecode($filename); - $response = new BinaryFileResponse($fullPath); - $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE); - $response->headers->set('Content-Type', 'image'); - - return $response; + $flashBag = $request->getSession()->getFlashBag(); + $spamReport = $this->getSpamIndexReportForSwiftMessage($message); + $spamInfo = $this->formatSpamReport($spamReport); + if (null !== $spamInfo) { + [$level, $messageText] = $spamInfo; + $flashBag->add($level, $messageText); } - throw new FileNotFoundException($filename); + $translationKey = $sent + ? 'web.pre.view.test.mail.sent.for.%template%.to.%email%' + : 'web.pre.view.test.mail.failed.for.%template%.to.%email%'; + $flashBag->add($sent ? 'info' : 'warn', $this->translator->trans($translationKey, [ + '%template%' => $template, + '%email%' => $email, + ])); + + return new RedirectResponse($this->router->generate('azine_email_template_index', [ + 'customEmail' => $email, + ])); } /** - * @return TemplateProviderInterface + * The historical method name is retained for application compatibility; + * the message is now a Symfony Mime RawMessage rather than Swift_Message. */ - protected function getTemplateProviderService() + public function getSpamIndexReportForSwiftMessage(RawMessage $message, string $report = 'long'): array { - return $this->container->get('azine_email_template_provider'); + return $this->spamCheckService->checkMessage($message, $report); } - /** - * @param string $view - * @param Response $response - * - * @return Response - */ - protected function renderResponse($view, array $parameters = array(), ?Response $response = null) + public function checkSpamScoreOfSentEmailAction(Request $request): JsonResponse { - return $this->container->get('templating')->renderResponse($view, $parameters, $response); + $messageSource = $request->request->getString( + 'emailSource', + $request->query->getString('emailSource'), + ); + $spamReport = $this->spamCheckService->checkRawMessage($messageSource); + $formatted = $this->formatSpamReport($spamReport); + + return new JsonResponse([ + 'result' => null === $formatted ? '' : $formatted[1], + ]); } - /** - * Get the sent email from the database. - * - * @param string $token the token identifying the sent email - * - * @return SentEmail - */ - protected function getSentEmailForToken($token) + private function getSentEmailForToken(string $token): ?SentEmail { - $sentEmail = $this->container->get('doctrine')->getRepository('AzineEmailBundle:SentEmail')->findOneByToken($token); + $sentEmail = $this->managerRegistry + ->getManager() + ->getRepository(SentEmail::class) + ->findOneBy(['token' => $token]); - return $sentEmail; + return $sentEmail instanceof SentEmail ? $sentEmail : null; } - /** - * Send a test-mail for the template to the given email-address. - * - * @param string $template templateId without ending => AzineEmailBundle::baseEmailLayout (without .txt.twig) - * @param string $email - * - * @return RedirectResponse - */ - public function sendTestEmailAction(Request $request, $template, $email) + private function userIsAllowedToSeeThisMail(SentEmail $mail): bool { - $locale = $request->getLocale(); + $recipients = $mail->getRecipients(); + if (null === $recipients) { + return true; + } - $template = urldecode($template); + $user = $this->tokenStorage->getToken()?->getUser(); + if (!is_object($user)) { + return false; + } - // get the email-vars for email-sending => absolute fs-paths to images - $emailVars = $this->container->get('azine_email_web_view_service')->getDummyVarsFor($template, $locale); + if (method_exists($user, 'getEmail') && in_array($user->getEmail(), $recipients, true)) { + return true; + } - // send the mail - $message = new \Swift_Message(); - $mailer = $this->container->get('azine_email_template_twig_swift_mailer'); - $emailArray = array(); - foreach (mailparse_rfc822_parse_addresses($email) as $next){ - $emailArray[$next['address']] = "Test-Mail-Recipient"; + if (method_exists($user, 'hasRole') && $user->hasRole('ROLE_ADMIN')) { + return true; } - $sent = $mailer->sendSingleEmail($emailArray, null, $emailVars['subject'], $emailVars, $template.'.txt.twig', $locale, $emailVars['sendMailAccountAddress'], $emailVars['sendMailAccountName'].' (Test)', $message); - $flashBag = $request->getSession()->getFlashBag(); + return method_exists($user, 'getRoles') && in_array('ROLE_ADMIN', $user->getRoles(), true); + } - $spamReport = $this->getSpamIndexReportForSwiftMessage($message); - if (is_array($spamReport)) { - if (200 == $spamReport['curlHttpCode'] && $spamReport['success']) { - $spamScore = $spamReport['score']; - $spamInfo = "SpamScore: $spamScore! \n".$spamReport['report']; - } else { - //@codeCoverageIgnoreStart - // this only happens if the spam-check-server has a problem / is not responding - $spamScore = 10; - $spamInfo = 'Getting the spam-info failed. - HttpCode: '.$spamReport['curlHttpCode'].' - SpamReportMsg: '.$spamReport['message']; - if (array_key_exists('curlError', $spamReport)) { - $spamInfo .= ' - cURL-Error: '.$spamReport['curlError']; - } - //@codeCoverageIgnoreEnd + private function reAttachAllEntities(array &$variables): void + { + /** @var EntityManagerInterface $entityManager */ + $entityManager = $this->managerRegistry->getManager(); + + foreach ($variables as $key => &$value) { + if (is_array($value)) { + $this->reAttachAllEntities($value); + continue; } - if ($spamScore <= 2) { - $flashBag->add('info', $spamInfo); - } elseif ($spamScore > 2 && $spamScore < 5) { - $flashBag->add('warn', $spamInfo); - } else { - $flashBag->add('error', $spamInfo); + if (!is_object($value) || !method_exists($value, 'getId')) { + continue; } - } - // inform about sent/failed emails - if ($sent) { - $msg = $this->container->get('translator')->trans('web.pre.view.test.mail.sent.for.%template%.to.%email%', array('%template%' => $template, '%email%' => $email)); - $flashBag->add('info', $msg); - - //@codeCoverageIgnoreStart - } else { - // this only happens if the mail-server has a problem - $msg = $this->container->get('translator')->trans('web.pre.view.test.mail.failed.for.%template%.to.%email%', array('%template%' => $template, '%email%' => $email)); - $flashBag->add('warn', $msg); - //@codeCoverageIgnoreStart - } + $identifier = $value->getId(); + if (null === $identifier) { + continue; + } - // show the index page again. - return new RedirectResponse($this->container->get('router')->generate('azine_email_template_index', array('customEmail' => $email))); + $managedEntity = $entityManager->find($value::class, $identifier); + if (null !== $managedEntity) { + $variables[$key] = $managedEntity; + } + } + unset($value); } /** - * Make an RESTful call to http://spamcheck.postmarkapp.com/filter to test the emails-spam-index. - * See http://spamcheck.postmarkapp.com/doc. - * - * @return array TestResult array('success', 'message', 'curlHttpCode', 'curlError', ['score', 'report']) + * @return array */ - public function getSpamIndexReportForSwiftMessage(\Swift_Message $message, $report = 'long') + private function parseAddresses(string $email): array { - return $this->getSpamIndexReport($message->toString(), $report); + $recipients = []; + foreach (mailparse_rfc822_parse_addresses($email) as $parsedAddress) { + $address = (string) ($parsedAddress['address'] ?? ''); + if ('' === $address || false === filter_var($address, FILTER_VALIDATE_EMAIL)) { + continue; + } + + $recipients[$address] = (string) ($parsedAddress['display'] ?? 'Test-Mail-Recipient'); + } + + if ([] === $recipients) { + throw new \InvalidArgumentException(sprintf('No valid email address was found in "%s".', $email)); + } + + return $recipients; } /** - * @param $msgString - * @param string $report - * - * @return mixed + * @return array{0: string, 1: string}|null */ - private function getSpamIndexReport($msgString, $report = 'long') + private function formatSpamReport(array $spamReport): ?array { - // check if cURL is loaded/available - if (!function_exists('curl_init')) { - // @codeCoverageIgnoreStart - return array('success' => false, - 'curlHttpCode' => '-', - 'curlError' => '-', - 'message' => 'No Spam-Check done. cURL module is not available.', - ); - // @codeCoverageIgnoreEnd + if (Response::HTTP_OK === ($spamReport['curlHttpCode'] ?? null) && true === ($spamReport['success'] ?? false)) { + $score = (float) ($spamReport['score'] ?? 10); + $info = sprintf("SpamScore: %s! \n%s", $score, (string) ($spamReport['report'] ?? '')); + + return [ + $score <= 2 ? 'info' : ($score < 5 ? 'warn' : 'error'), + $info, + ]; } - $ch = curl_init('http://spamcheck.postmarkapp.com/filter'); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_POST, true); - $data = array('email' => $msgString, 'options' => $report); - curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data)); - curl_setopt($ch, CURLOPT_HTTPHEADER, array('Content-Type: application/json', 'Accept: application/json')); - curl_setopt($ch, CURLOPT_TIMEOUT, 5); // max wait for 5sec for reply - - $result = json_decode(curl_exec($ch), true); - $error = curl_error($ch); - $result['curlHttpCode'] = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - if (strlen($error) > 0) { - $result['curlError'] = $error; + $info = sprintf( + "Getting the spam-info failed.\nHttpCode: %s\nSpamReportMsg: %s", + (string) ($spamReport['curlHttpCode'] ?? '-'), + (string) ($spamReport['message'] ?? '-'), + ); + if (isset($spamReport['curlError'])) { + $info .= "\ncURL-Error: ".(string) $spamReport['curlError']; } - if (!array_key_exists('message', $result)) { - $result['message'] = '-'; + return ['error', $info]; + } + + private function renderTemplate(string $template, array $parameters, int $status = Response::HTTP_OK): Response + { + return new Response($this->twig->render($template, $parameters), $status); + } + + private function templateFile(string $templateBase, string $format): string + { + return $this->normalizeTemplateBase($templateBase).'.'.$format.'.twig'; + } + + private function normalizeTemplateBase(string $template): string + { + if (str_starts_with($template, '@')) { + return $template; } - if (!array_key_exists('success', $result)) { - $result['message'] = "Something went wrong! Here's the content of the curl-reply:\n\n".nl2br(print_r($result, true)); - } elseif (!$result['success'] && false !== strpos($msgString, 'Content-Transfer-Encoding: base64')) { - $result['message'] = $result['message']."\n\nRemoving the base64-Encoded Mime-Parts might help."; + if (!str_contains($template, ':')) { + return $template; } - return $result; + $parts = explode(':', $template); + $bundle = preg_replace('/Bundle$/', '', array_shift($parts) ?? '') ?: ''; + $path = implode('/', array_values(array_filter($parts, static fn (string $part): bool => '' !== $part))); + + return '@'.$bundle.('/' === substr($bundle, -1) || '' === $path ? '' : '/').$path; } - /** - * Ajax action to check the spam-score for the pasted email-source. - */ - public function checkSpamScoreOfSentEmailAction(Request $request) + private function appendBeforeClosingTag(string $content, string $addition, string $closingTag): string { - $msgString = $request->request->get('emailSource'); - $spamReport = $this->getSpamIndexReport($msgString); - $spamInfo = ''; - if (is_array($spamReport)) { - if (array_key_exists('curlHttpCode', $spamReport) && 200 == $spamReport['curlHttpCode'] && $spamReport['success'] && array_key_exists('score', $spamReport)) { - $spamScore = $spamReport['score']; - $spamInfo = "SpamScore: $spamScore! \n".$spamReport['report']; - //@codeCoverageIgnoreStart - // this only happens if the spam-check-server has a problem / is not responding - } else { - if (array_key_exists('curlHttpCode', $spamReport) && array_key_exists('curlError', $spamReport) && array_key_exists('message', $spamReport)) { - $spamInfo = 'Getting the spam-info failed. - HttpCode: '.$spamReport['curlHttpCode'].' - cURL-Error: '.$spamReport['curlError'].' - SpamReportMsg: '.$spamReport['message']; - } elseif (null !== $spamReport && is_array($spamReport)) { - $spamInfo = 'Getting the spam-info failed. This was returned: ----Start---------------------------------------------- -'.implode(";\n", $spamReport).' ----End------------------------------------------------'; - } - //@codeCoverageIgnoreEnd - } - } + $position = stripos($content, $closingTag); - return new JsonResponse(array('result' => $spamInfo)); + return false === $position + ? $content.$addition + : substr_replace($content, $addition, $position, 0); } } diff --git a/DependencyInjection/AzineEmailExtension.php b/DependencyInjection/AzineEmailExtension.php index fd40c100..1b62c09f 100644 --- a/DependencyInjection/AzineEmailExtension.php +++ b/DependencyInjection/AzineEmailExtension.php @@ -1,98 +1,111 @@ processConfiguration($configuration, $configs); - + $config = $this->processConfiguration(new Configuration(), $configs); $prefix = self::PREFIX; + $container->setAlias($prefix.self::RECIPIENT_PROVIDER, $config[self::RECIPIENT_PROVIDER]); $container->setParameter($prefix.self::RECIPIENT_CLASS, $config[self::RECIPIENT_CLASS]); $container->setParameter($prefix.self::RECIPIENT_NEWSLETTER_FIELD, $config[self::RECIPIENT_NEWSLETTER_FIELD]); $container->setAlias($prefix.self::TEMPLATE_PROVIDER, $config[self::TEMPLATE_PROVIDER]); - $container->setAlias($prefix.self::TEMPLATE_TWIG_SWIFT_MAILER, $config[self::TEMPLATE_TWIG_SWIFT_MAILER]); - $container->setParameter($prefix.'no_reply', array('email' => $config[self::NO_REPLY][self::NO_REPLY_EMAIL_ADDRESS], - 'name' => $config[self::NO_REPLY][self::NO_REPLY_EMAIL_NAME], )); - $container->setParameter($prefix.self::TEMPLATE_IMAGE_DIR, realpath($config[self::TEMPLATE_IMAGE_DIR])); - $allowedFolders = array(); + + $mailerService = $config[self::TEMPLATE_TWIG_SWIFT_MAILER] ?: $config[self::TEMPLATE_TWIG_MAILER]; + $container->setAlias($prefix.self::TEMPLATE_TWIG_MAILER, $mailerService); + $container->setAlias($prefix.self::TEMPLATE_TWIG_SWIFT_MAILER, $prefix.self::TEMPLATE_TWIG_MAILER); + $container->setAlias($prefix.self::IMMEDIATE_MAILER_SERVICE, $config[self::IMMEDIATE_MAILER_SERVICE]); + + $container->setParameter($prefix.self::NO_REPLY, [ + self::NO_REPLY_EMAIL_ADDRESS => $config[self::NO_REPLY][self::NO_REPLY_EMAIL_ADDRESS], + self::NO_REPLY_EMAIL_NAME => $config[self::NO_REPLY][self::NO_REPLY_EMAIL_NAME], + ]); + + $container->setParameter( + $prefix.self::TEMPLATE_IMAGE_DIR, + realpath($config[self::TEMPLATE_IMAGE_DIR]) ?: $config[self::TEMPLATE_IMAGE_DIR], + ); + + $allowedFolders = []; foreach ($config[self::ALLOWED_IMAGES_FOLDERS] as $folder) { - $allowedFolders[] = realpath($folder); + $allowedFolders[] = realpath($folder) ?: $folder; } - $container->setParameter($prefix.self::ALLOWED_IMAGES_FOLDERS, $allowedFolders); + $container->setParameter($prefix.self::ALLOWED_IMAGES_FOLDERS, array_values(array_unique($allowedFolders))); $container->setAlias($prefix.self::NOTIFIER_SERVICE, $config[self::NOTIFIER_SERVICE]); $container->setParameter($prefix.self::NEWSLETTER.'_'.self::NEWSLETTER_INTERVAL, $config[self::NEWSLETTER][self::NEWSLETTER_INTERVAL]); $container->setParameter($prefix.self::NEWSLETTER.'_'.self::NEWSLETTER_SEND_TIME, $config[self::NEWSLETTER][self::NEWSLETTER_SEND_TIME]); - $container->setParameter($prefix.self::TEMPLATES.'_'.self::NEWSLETTER_TEMPLATE, $config[self::TEMPLATES][self::NEWSLETTER_TEMPLATE]); $container->setParameter($prefix.self::TEMPLATES.'_'.self::NOTIFICATIONS_TEMPLATE, $config[self::TEMPLATES][self::NOTIFICATIONS_TEMPLATE]); $container->setParameter($prefix.self::TEMPLATES.'_'.self::CONTENT_ITEM_TEMPLATE, $config[self::TEMPLATES][self::CONTENT_ITEM_TEMPLATE]); - $container->setParameter($prefix.self::TRACKING_PARAM_CAMPAIGN_CONTENT, $config[self::TRACKING_PARAM_CAMPAIGN_CONTENT]); - $container->setParameter($prefix.self::TRACKING_PARAM_CAMPAIGN_MEDIUM, $config[self::TRACKING_PARAM_CAMPAIGN_MEDIUM]); - $container->setParameter($prefix.self::TRACKING_PARAM_CAMPAIGN_NAME, $config[self::TRACKING_PARAM_CAMPAIGN_NAME]); - $container->setParameter($prefix.self::TRACKING_PARAM_CAMPAIGN_SOURCE, $config[self::TRACKING_PARAM_CAMPAIGN_SOURCE]); - $container->setParameter($prefix.self::TRACKING_PARAM_CAMPAIGN_TERM, $config[self::TRACKING_PARAM_CAMPAIGN_TERM]); - $container->setParameter($prefix.self::EMAIL_TRACKING_BASE_URL, $config[self::EMAIL_TRACKING_BASE_URL]); - $container->setParameter($prefix.self::DOMAINS_FOR_TRACKING, $config[self::DOMAINS_FOR_TRACKING]); - $container->setAlias($prefix.self::EMAIL_TRACKING_CODE_BUILDER, $config[self::EMAIL_TRACKING_CODE_BUILDER]); + foreach ([ + self::TRACKING_PARAM_CAMPAIGN_CONTENT, + self::TRACKING_PARAM_CAMPAIGN_MEDIUM, + self::TRACKING_PARAM_CAMPAIGN_NAME, + self::TRACKING_PARAM_CAMPAIGN_SOURCE, + self::TRACKING_PARAM_CAMPAIGN_TERM, + self::EMAIL_TRACKING_BASE_URL, + self::DOMAINS_FOR_TRACKING, + self::SPAM_CHECK_ENDPOINT, + ] as $key) { + $container->setParameter($prefix.$key, $config[$key]); + } + $container->setAlias($prefix.self::EMAIL_TRACKING_CODE_BUILDER, $config[self::EMAIL_TRACKING_CODE_BUILDER]); $container->setAlias($prefix.self::WEB_VIEW_SERVICE, $config[self::WEB_VIEW_SERVICE]); $container->setParameter($prefix.self::WEB_VIEW_RETENTION, $config[self::WEB_VIEW_RETENTION]); - // this parameter is only made available using versions of FriendsOfSymfonyUserBundle where - // https://github.com/FriendsOfSymfony/FOSUserBundle/pull/2612 has already been merged into. - if (!$container->hasParameter('fos_user.email_update_confirmation.template')) { - $container->setParameter('fos_user.email_update_confirmation.template', array('no-template')); + if (!$container->hasParameter('azine_email_update_confirmation.template')) { + $container->setParameter( + 'azine_email_update_confirmation.template', + '@AzineEmailUpdateConfirmation/Email/email_update_confirmation.txt.twig', + ); } - $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); - $loader->load('services.yml'); + (new YamlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')))->load('services.yml'); } } diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php index 8f8764d8..e01ef9d5 100644 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -1,19 +1,15 @@ children() - ->scalarNode(AzineEmailExtension::RECIPIENT_CLASS)->defaultValue('Acme\\SomeBundle\\Entity\\User')->info('the class of your implementation of the RecipientInterface')->end() - ->scalarNode(AzineEmailExtension::RECIPIENT_NEWSLETTER_FIELD)->defaultValue('newsletter')->info('the fieldname of the boolean field on the recipient class indicating, that a newsletter should be sent or not')->end() - ->scalarNode(AzineEmailExtension::NOTIFIER_SERVICE)->defaultValue('azine_email.example.notifier_service')->info('the service-id of your implementation of the nofitier service to be used')->end() - ->scalarNode(AzineEmailExtension::TEMPLATE_PROVIDER)->defaultValue('azine_email.example.template_provider')->info('the service-id of your implementation of the template provider service to be used')->end() - ->scalarNode(AzineEmailExtension::RECIPIENT_PROVIDER)->defaultValue('azine_email.default.recipient_provider')->info('the service-id of the implementation of the RecipientProviderInterface to be used')->end() - ->scalarNode(AzineEmailExtension::TEMPLATE_TWIG_SWIFT_MAILER)->defaultValue('azine_email.default.template_twig_swift_mailer')->info('the service-id of the mailer service to be used')->end() - ->arrayNode(AzineEmailExtension::NO_REPLY)->isRequired() + ->scalarNode(AzineEmailExtension::RECIPIENT_CLASS) + ->defaultValue('Acme\\SomeBundle\\Entity\\User') + ->end() + ->scalarNode(AzineEmailExtension::RECIPIENT_NEWSLETTER_FIELD) + ->defaultValue('newsletter') + ->end() + ->scalarNode(AzineEmailExtension::NOTIFIER_SERVICE) + ->defaultValue('azine_email.example.notifier_service') + ->end() + ->scalarNode(AzineEmailExtension::TEMPLATE_PROVIDER) + ->defaultValue('azine_email.example.template_provider') + ->end() + ->scalarNode(AzineEmailExtension::RECIPIENT_PROVIDER) + ->defaultValue('azine_email.default.recipient_provider') + ->end() + ->scalarNode(AzineEmailExtension::TEMPLATE_TWIG_MAILER) + ->defaultValue('azine_email.default.template_twig_mailer') + ->end() + ->scalarNode(AzineEmailExtension::TEMPLATE_TWIG_SWIFT_MAILER) + ->defaultNull() + ->info('Deprecated alias for template_twig_mailer.') + ->end() + ->scalarNode(AzineEmailExtension::IMMEDIATE_MAILER_SERVICE) + ->defaultValue('mailer') + ->info('Symfony Mailer service used for emails flagged for immediate delivery.') + ->end() + ->arrayNode(AzineEmailExtension::NO_REPLY) ->addDefaultsIfNotSet() ->children() - ->scalarNode(AzineEmailExtension::NO_REPLY_EMAIL_ADDRESS)->defaultValue('no-reply@example.com')->isRequired()->info('the no-reply email-address')->isRequired()->end() - ->scalarNode(AzineEmailExtension::NO_REPLY_EMAIL_NAME)->defaultValue('notification daemon')->isRequired()->info("the name to appear with the 'no-reply'-address.")->isRequired()->end() + ->scalarNode(AzineEmailExtension::NO_REPLY_EMAIL_ADDRESS) + ->defaultValue('no-reply@example.com') + ->end() + ->scalarNode(AzineEmailExtension::NO_REPLY_EMAIL_NAME) + ->defaultValue('notification daemon') ->end() ->end() - ->scalarNode(AzineEmailExtension::TEMPLATE_IMAGE_DIR)->defaultValue('%kernel.root_dir%/../vendor/azine/email-bundle/Azine/EmailBundle/Resources/htmlTemplateImages/')->info('absolute path to the image-folder containing the images used in your templates.')->end() - ->variableNode(AzineEmailExtension::ALLOWED_IMAGES_FOLDERS)->defaultValue(array())->info('list of folders from which images are allowed to be embeded into emails')->end() - ->arrayNode(AzineEmailExtension::NEWSLETTER)->info('newsletter configuration') + ->end() + ->scalarNode(AzineEmailExtension::TEMPLATE_IMAGE_DIR) + ->defaultValue('%kernel.project_dir%/vendor/azine/email-bundle/Resources/htmlTemplateImages/') + ->end() + ->arrayNode(AzineEmailExtension::ALLOWED_IMAGES_FOLDERS) + ->scalarPrototype()->end() + ->defaultValue([]) + ->end() + ->arrayNode(AzineEmailExtension::NEWSLETTER) ->addDefaultsIfNotSet() ->children() - ->scalarNode(AzineEmailExtension::NEWSLETTER_INTERVAL)->defaultValue('14')->info('number of days between newsletters')->end() - ->scalarNode(AzineEmailExtension::NEWSLETTER_SEND_TIME)->defaultValue('10:00')->info('time of the day, when newsletters should be sent, 24h-format => e.g. 23:59')->end() + ->integerNode(AzineEmailExtension::NEWSLETTER_INTERVAL) + ->min(1) + ->defaultValue(14) + ->end() + ->scalarNode(AzineEmailExtension::NEWSLETTER_SEND_TIME) + ->defaultValue('10:00') + ->validate() + ->ifTrue(static fn (mixed $value): bool => 1 !== preg_match('/^(?:[01]\\d|2[0-3]):[0-5]\\d$/', (string) $value)) + ->thenInvalid('Expected a 24-hour HH:MM time, got "%s".') + ->end() + ->end() ->end() ->end() - - ->arrayNode(AzineEmailExtension::TEMPLATES)->info('templates configuration') + ->arrayNode(AzineEmailExtension::TEMPLATES) ->addDefaultsIfNotSet() ->children() - ->scalarNode(AzineEmailExtension::NEWSLETTER_TEMPLATE)->defaultValue(AzineTemplateProvider::NEWSLETTER_TEMPLATE)->info('wrapper template id (without ending) for the newsletter')->end() - ->scalarNode(AzineEmailExtension::NOTIFICATIONS_TEMPLATE)->defaultValue(AzineTemplateProvider::NOTIFICATIONS_TEMPLATE)->info('wrapper template id (without ending) for notifications')->end() - ->scalarNode(AzineEmailExtension::CONTENT_ITEM_TEMPLATE)->defaultValue(AzineTemplateProvider::CONTENT_ITEM_MESSAGE_TEMPLATE)->info('template id (without ending) for notification content items')->end() + ->scalarNode(AzineEmailExtension::NEWSLETTER_TEMPLATE) + ->defaultValue(AzineTemplateProvider::NEWSLETTER_TEMPLATE) + ->end() + ->scalarNode(AzineEmailExtension::NOTIFICATIONS_TEMPLATE) + ->defaultValue(AzineTemplateProvider::NOTIFICATIONS_TEMPLATE) + ->end() + ->scalarNode(AzineEmailExtension::CONTENT_ITEM_TEMPLATE) + ->defaultValue(AzineTemplateProvider::CONTENT_ITEM_MESSAGE_TEMPLATE) + ->end() ->end() ->end() - - ->scalarNode(AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_NAME)->defaultValue('utm_campaign')->info('See https://ga-dev-tools.appspot.com/campaign-url-builder/ for more infos')->end() - ->scalarNode(AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_TERM)->defaultValue('utm_term')->info('See https://ga-dev-tools.appspot.com/campaign-url-builder/ for more infos')->end() - ->scalarNode(AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_CONTENT)->defaultValue('utm_content')->info('See https://ga-dev-tools.appspot.com/campaign-url-builder/ for more infos')->end() - ->scalarNode(AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_MEDIUM)->defaultValue('utm_medium')->info('See https://ga-dev-tools.appspot.com/campaign-url-builder/ for more infos')->end() - ->scalarNode(AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_SOURCE)->defaultValue('utm_source')->info('See https://ga-dev-tools.appspot.com/campaign-url-builder/ for more infos')->end() - ->scalarNode(AzineEmailExtension::EMAIL_TRACKING_BASE_URL)->defaultValue(null)->info('See the README.md file for more information')->end() - ->scalarNode(AzineEmailExtension::EMAIL_TRACKING_CODE_BUILDER)->defaultValue('azine.email.open.tracking.code.builder.ga.or.piwik')->info('Defaults to the AzineEmailOpenTrackingCodeBuilder. See the README.md file for more information')->end() - ->arrayNode(AzineEmailExtension::DOMAINS_FOR_TRACKING)->info("Defaults to 'all domains' => empty array.") - ->prototype('scalar')->end() + ->scalarNode(AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_NAME)->defaultValue('utm_campaign')->end() + ->scalarNode(AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_TERM)->defaultValue('utm_term')->end() + ->scalarNode(AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_CONTENT)->defaultValue('utm_content')->end() + ->scalarNode(AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_MEDIUM)->defaultValue('utm_medium')->end() + ->scalarNode(AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_SOURCE)->defaultValue('utm_source')->end() + ->scalarNode(AzineEmailExtension::EMAIL_TRACKING_BASE_URL)->defaultNull()->end() + ->scalarNode(AzineEmailExtension::EMAIL_TRACKING_CODE_BUILDER) + ->defaultValue('azine.email.open.tracking.code.builder.ga.or.piwik') ->end() - - ->scalarNode(AzineEmailExtension::WEB_VIEW_RETENTION)->defaultValue('90')->info('number of days that emails should be available in web-view')->end() - ->scalarNode(AzineEmailExtension::WEB_VIEW_SERVICE)->defaultValue('azine_email.example.web.view.service')->info('the service-id of your implementation of the web view service to be used')->end() - ; + ->arrayNode(AzineEmailExtension::DOMAINS_FOR_TRACKING) + ->scalarPrototype()->end() + ->defaultValue([]) + ->end() + ->integerNode(AzineEmailExtension::WEB_VIEW_RETENTION) + ->min(1) + ->defaultValue(90) + ->end() + ->scalarNode(AzineEmailExtension::WEB_VIEW_SERVICE) + ->defaultValue('azine_email.example.web_view_service') + ->end() + ->scalarNode(AzineEmailExtension::SPAM_CHECK_ENDPOINT) + ->cannotBeEmpty() + ->defaultValue('https://spamcheck.postmarkapp.com/filter') + ->validate() + ->ifTrue(static fn (mixed $value): bool => !str_starts_with((string) $value, 'https://')) + ->thenInvalid('The spam-check endpoint must use HTTPS.') + ->end() + ->end() + ->end(); return $treeBuilder; } diff --git a/Resources/config/routing.yml b/Resources/config/routing.yml index c1a6ff78..f44642a4 100644 --- a/Resources/config/routing.yml +++ b/Resources/config/routing.yml @@ -1,39 +1,35 @@ -################################################################## -## Web-View routes -################################################################## -# route for users to see emails azine_email_webview: - path: /email/webview/{token} - defaults: { _controller: "AzineEmailBundle:AzineEmailTemplate:webView" } + path: /email/webview/{token} + controller: Azine\EmailBundle\Controller\AzineEmailTemplateController::webViewAction + methods: [GET] -# route for images that were embeded in emails and now must be shown in web-view -azine_email_serve_template_image: - path: /email/image/{folderKey}/{filename} - defaults: { _controller: "AzineEmailBundle:AzineEmailTemplate:serveImage"} - -# index with all the email-templates you configured in you implementation of WebViewServiceInterface azine_email_template_index: - path: /admin/email/ - defaults: { _controller: "AzineEmailBundle:AzineEmailTemplate:index" } + path: /email/templates + controller: Azine\EmailBundle\Controller\AzineEmailTemplateController::indexAction + methods: [GET] -# preview of a template filled with dummy-data ... this should probably only be accessible by admins azine_email_web_preview: - path: /admin/email/webpreview/{template}/{format} - defaults: { _controller: "AzineEmailBundle:AzineEmailTemplate:webPreView", format : null } + path: /email/templates/webview/{template}/{format} + controller: Azine\EmailBundle\Controller\AzineEmailTemplateController::webPreViewAction + defaults: + format: html + requirements: + format: html|txt + methods: [GET] -# route to send test-mails filled with dummy-data ... this should probably only be accessible by admins -azine_email_send_test_email: - path: /admin/email/send-test-email-for/{template}/to/{email} - defaults: { _controller: "AzineEmailBundle:AzineEmailTemplate:sendTestEmail", email: null} +azine_email_spam_score: + path: /email/spam-score + controller: Azine\EmailBundle\Controller\AzineEmailTemplateController::checkSpamScoreOfSentEmailAction + methods: [POST] -azine_email_test_sent_email_spam_score: - path: /admin/email/test-sent-email-spam-score - defaults: { _controller: "AzineEmailBundle:AzineEmailTemplate:checkSpamScoreOfSentEmail" } +azine_email_send_test_mail: + path: /email/templates/send-test/{template}/{email} + controller: Azine\EmailBundle\Controller\AzineEmailTemplateController::sendTestEmailAction + methods: [GET, POST] -azine_admin_email_dashboard: - path: /admin/email/dashboard - defaults: { _controller: "AzineEmailBundle:AzineEmail:emailsDashboard",} - -azine_admin_email_details_by_token: - path: /admin/email/details/{token} - defaults: { _controller: "AzineEmailBundle:AzineEmail:emailDetailsByToken",} \ No newline at end of file +azine_email_serve_template_image: + path: /email/templates/image/{folderKey}/{filename} + controller: Azine\EmailBundle\Controller\AzineEmailTemplateController::serveImageAction + requirements: + filename: .+ + methods: [GET] diff --git a/Resources/config/services.yml b/Resources/config/services.yml index b4004d0b..f9419da1 100644 --- a/Resources/config/services.yml +++ b/Resources/config/services.yml @@ -1,124 +1,175 @@ -parameters: - services: -################################################################### -## Services you should override/extend in your bundle -################################################################### + _defaults: + autowire: false + autoconfigure: false + azine_email.example.template_provider: - class: Azine\EmailBundle\Services\AzineTemplateProvider + class: Azine\EmailBundle\Services\SymfonyMailerTemplateProvider arguments: - $router: "@router" - $translator: "@translator" - $parameters: - image_dir: "%azine_email_image_dir%" - allowed_images_folders: "%azine_email_allowed_images_folders%" - tracking_params_campaign_name: "%azine_email_tracking_params_campaign_name%" - tracking_params_campaign_term: "%azine_email_tracking_params_campaign_term%" - tracking_params_campaign_content: "%azine_email_tracking_params_campaign_content%" - tracking_params_campaign_medium: "%azine_email_tracking_params_campaign_medium%" - tracking_params_campaign_source: "%azine_email_tracking_params_campaign_source%" + $router: '@router' + $translator: '@translator' + $parameters: + image_dir: '%azine_email_image_dir%' + allowed_images_folders: '%azine_email_allowed_images_folders%' + tracking_params_campaign_name: '%azine_email_tracking_params_campaign_name%' + tracking_params_campaign_term: '%azine_email_tracking_params_campaign_term%' + tracking_params_campaign_content: '%azine_email_tracking_params_campaign_content%' + tracking_params_campaign_medium: '%azine_email_tracking_params_campaign_medium%' + tracking_params_campaign_source: '%azine_email_tracking_params_campaign_source%' + + Azine\EmailBundle\Services\TemplateProviderInterface: + alias: azine_email_template_provider azine_email.example.notifier_service: class: Azine\EmailBundle\Services\AzineNotifierService arguments: - $mailer: "@azine_email_template_twig_swift_mailer" - $twig: "@twig" - $router: "@router" - $managerRegistry: "@doctrine" - $templateProvider: "@azine_email_template_provider" - $recipientProvider: "@azine_email_recipient_provider" - $translatorService: "@translator" - $parameters: - newsletter_interval : "%azine_email_newsletter_interval%" - newsletter_send_time: "%azine_email_newsletter_send_time%" - templates_newsletter: "%azine_email_templates_newsletter%" - templates_notifications: "%azine_email_templates_notifications%" - templates_content_item: "%azine_email_templates_content_item%" + $mailer: '@azine_email_template_twig_mailer' + $twig: '@twig' + $router: '@router' + $managerRegistry: '@doctrine' + $templateProvider: '@azine_email_template_provider' + $recipientProvider: '@azine_email_recipient_provider' + $translatorService: '@translator' + $parameters: + newsletter_interval: '%azine_email_newsletter_interval%' + newsletter_send_time: '%azine_email_newsletter_send_time%' + templates_newsletter: '%azine_email_templates_newsletter%' + templates_notifications: '%azine_email_templates_notifications%' + templates_content_item: '%azine_email_templates_content_item%' + + Azine\EmailBundle\Services\NotifierServiceInterface: + alias: azine_email_notifier_service azine_email.example.web_view_service: class: Azine\EmailBundle\Services\AzineWebViewService arguments: - $router: "@router" + $router: '@router' + Azine\EmailBundle\Services\WebViewServiceInterface: + alias: azine_email_web_view_service -################################################################### -## Services you can use as default if you work with the FOSUserBundle -################################################################### - azine_email.default.template_twig_swift_mailer: - class: Azine\EmailBundle\Services\AzineTwigSwiftMailer + azine_email.default.template_twig_mailer: + class: Azine\EmailBundle\Services\AzineTwigMailer + public: true arguments: - $mailer: "@mailer" - $router: "@router" - $twig: "@twig" - $translator: "@translator" - $templateProvider: "@azine_email_template_provider" - $managerRegistry: "@doctrine" - $emailOpenTrackingCodeBuilder: "@azine_email_email_open_tracking_code_builder" - $emailTwigExtension: "@azine.email.bundle.twig.filters" - $parameters: - template: - confirmation : "%fos_user.registration.confirmation.template%" - resetting: "%fos_user.resetting.email.template%" - email_updating: "%azine_email_update_confirmation.template%" - from_email: - confirmation: "%fos_user.registration.confirmation.from_email%" - resetting: "%fos_user.resetting.email.from_email%" - no_reply: "%azine_email_no_reply%" - $immediateMailer: "@swiftmailer.mailer.immediateMailer" + $mailer: '@mailer' + $router: '@router' + $twig: '@twig' + $translator: '@translator' + $templateProvider: '@azine_email_template_provider' + $managerRegistry: '@doctrine' + $emailOpenTrackingCodeBuilder: '@azine_email_email_open_tracking_code_builder' + $emailTwigExtension: '@azine.email.bundle.twig.filters' + $parameters: + template: + confirmation: '%fos_user.registration.confirmation.template%' + resetting: '%fos_user.resetting.email.template%' + email_updating: '%azine_email_update_confirmation.template%' + from_email: + confirmation: '%fos_user.registration.confirmation.from_email%' + resetting: '%fos_user.resetting.email.from_email%' + no_reply: '%azine_email_no_reply%' + $immediateMailer: '@azine_email_immediate_mailer_service' + + azine_email.default.template_twig_swift_mailer: + alias: azine_email.default.template_twig_mailer + public: true + + Azine\EmailBundle\Services\TemplateTwigMailerInterface: + alias: azine_email_template_twig_mailer + + Azine\EmailBundle\Services\TemplateTwigSwiftMailerInterface: + alias: azine_email_template_twig_mailer + + FOS\UserBundle\Mailer\MailerInterface: + alias: azine_email_template_twig_mailer azine_email.default.recipient_provider: class: Azine\EmailBundle\Services\AzineRecipientProvider arguments: - $managerRegistry: "@doctrine" - $userClass: "%azine_email_recipient_class%" - $newsletterField: "%azine_email_recipient_newsletter_field%" + $managerRegistry: '@doctrine' + $userClass: '%azine_email_recipient_class%' + $newsletterField: '%azine_email_recipient_newsletter_field%' -################################################################### -## Email open tracking code builder -################################################################### azine.email.open.tracking.code.builder.ga.or.piwik: class: Azine\EmailBundle\Services\AzineEmailOpenTrackingCodeBuilder arguments: - $trackingUrlTemplate: "%azine_email_email_open_tracking_url%" - $parameters: - tracking_params_campaign_name: "%azine_email_tracking_params_campaign_name%" - tracking_params_campaign_term: "%azine_email_tracking_params_campaign_term%" - tracking_params_campaign_content: "%azine_email_tracking_params_campaign_content%" - tracking_params_campaign_medium: "%azine_email_tracking_params_campaign_medium%" - tracking_params_campaign_source: "%azine_email_tracking_params_campaign_source%" - -################################################################### -## Twig-Filter -################################################################### + $trackingUrlTemplate: '%azine_email_email_open_tracking_url%' + $parameters: + tracking_params_campaign_name: '%azine_email_tracking_params_campaign_name%' + tracking_params_campaign_term: '%azine_email_tracking_params_campaign_term%' + tracking_params_campaign_content: '%azine_email_tracking_params_campaign_content%' + tracking_params_campaign_medium: '%azine_email_tracking_params_campaign_medium%' + tracking_params_campaign_source: '%azine_email_tracking_params_campaign_source%' + + Azine\EmailBundle\Services\EmailOpenTrackingCodeBuilderInterface: + alias: azine_email_email_open_tracking_code_builder + azine.email.bundle.twig.filters: class: Azine\EmailBundle\Services\AzineEmailTwigExtension public: false arguments: - $templateProvider: "@azine_email_template_provider" - $translator: "@translator" - $domainsToTrack: "%azine_email_domains_for_tracking%" + $templateProvider: '@azine_email_template_provider' + $translator: '@translator' + $domainsToTrack: '%azine_email_domains_for_tracking%' tags: - { name: twig.extension } -################################################################### -## Commands -################################################################### + azine_email.spam_check_service: + class: Azine\EmailBundle\Services\SpamCheckService + arguments: + $httpClient: '@http_client' + $endpoint: '%azine_email_spam_check_endpoint%' + + Azine\EmailBundle\Controller\AzineEmailTemplateController: + public: true + arguments: + $webViewService: '@azine_email_web_view_service' + $templateProvider: '@azine_email_template_provider' + $mailer: '@azine_email_template_twig_mailer' + $spamCheckService: '@azine_email.spam_check_service' + $twig: '@twig' + $emailTwigExtension: '@azine.email.bundle.twig.filters' + $managerRegistry: '@doctrine' + $tokenStorage: '@security.token_storage' + $translator: '@translator' + $router: '@router' + $emailOpenTrackingCodeBuilder: '@azine_email_email_open_tracking_code_builder' + $noReply: '%azine_email_no_reply%' + $webViewRetentionDays: '%azine_email_web_view_retention%' + tags: + - { name: controller.service_arguments } + + azine_email.command_lock_store: + class: Symfony\Component\Lock\Store\FlockStore + arguments: + - '%kernel.cache_dir%/azine-email-locks' + + azine_email.command_lock_factory: + class: Symfony\Component\Lock\LockFactory + arguments: + - '@azine_email.command_lock_store' + azine.send_notifications_command: class: Azine\EmailBundle\Command\SendNotificationsCommand + arguments: + $notifierService: '@azine_email_notifier_service' + $lockFactory: '@azine_email.command_lock_factory' tags: - { name: console.command } azine.send_news_letter_command: class: Azine\EmailBundle\Command\SendNewsLetterCommand + arguments: + $notifierService: '@azine_email_notifier_service' + $lockFactory: '@azine_email.command_lock_factory' tags: - { name: console.command } azine.remove_old_web_view_emails_command: class: Azine\EmailBundle\Command\RemoveOldWebViewEmailsCommand + arguments: + $managerRegistry: '@doctrine' + $retentionDays: '%azine_email_web_view_retention%' tags: - { name: console.command } - - azine.clear_and_log_failed_mails_command: - class: Azine\EmailBundle\Command\ClearAndLogFailedMailsCommand - tags: - - { name: console.command } \ No newline at end of file diff --git a/Services/AzineEmailTwigExtension.php b/Services/AzineEmailTwigExtension.php index 4e6d5a00..7c4c5c37 100644 --- a/Services/AzineEmailTwigExtension.php +++ b/Services/AzineEmailTwigExtension.php @@ -36,11 +36,11 @@ public function __construct(TemplateProviderInterface $templateProvider, Transla */ public function getFilters() { - $filters[] = new \Twig_SimpleFilter('textWrap', array($this, 'textWrap')); - $filters[] = new \Twig_SimpleFilter('urlEncodeText', array($this, 'urlEncodeText'), array('is_safe' => array('html'))); - $filters[] = new \Twig_SimpleFilter('addCampaignParamsForTemplate', array($this, 'addCampaignParamsForTemplate'), array('is_safe' => array('html'))); - $filters[] = new \Twig_SimpleFilter('stripAndConvertTags', array($this, 'stripAndConvertTags'), array('is_safe' => array('html'))); - $filters[] = new \Twig_SimpleFilter('printVars', array($this, 'printVars')); + $filters[] = new \Twig\TwigFilter('textWrap', array($this, 'textWrap')); + $filters[] = new \Twig\TwigFilter('urlEncodeText', array($this, 'urlEncodeText'), array('is_safe' => array('html'))); + $filters[] = new \Twig\TwigFilter('addCampaignParamsForTemplate', array($this, 'addCampaignParamsForTemplate'), array('is_safe' => array('html'))); + $filters[] = new \Twig\TwigFilter('stripAndConvertTags', array($this, 'stripAndConvertTags'), array('is_safe' => array('html'))); + $filters[] = new \Twig\TwigFilter('printVars', array($this, 'printVars')); return $filters; } diff --git a/Services/AzineNotifierService.php b/Services/AzineNotifierService.php index be43143e..54efd752 100644 --- a/Services/AzineNotifierService.php +++ b/Services/AzineNotifierService.php @@ -1,5 +1,7 @@ mailer = $mailer; + $this->twig = $twig; + $this->router = $router; + $this->managerRegistry = $managerRegistry; + $this->templateProvider = $templateProvider; + $this->recipientProvider = $recipientProvider; + $this->translatorService = $translatorService; + $this->configParameter = $parameters; + } + protected function getVarsForNotificationsEmail() { - $params = array(); - - return $params; + return []; } - /** - * Override this function to fill in any recipient-specific parameters that are required to - * render the notifications-template or one of the notification-item-templates that - * are rendered into the notifications-template. - * - * @return array - */ protected function getRecipientVarsForNotificationsEmail(RecipientInterface $recipient) { - $recipientParams = array(); - $recipientParams['recipient'] = $recipient; - $recipientParams['mode'] = $recipient->getNotificationMode(); - - return $recipientParams; + return [ + 'recipient' => $recipient, + 'mode' => $recipient->getNotificationMode(), + ]; } - /** - * Get the subject for the notifications-email to send. Override this function to implement your custom subject-lines. - * - * @param array of array $contentItems - * - * @return string - */ public function getRecipientSpecificNotificationsSubject($contentItems, RecipientInterface $recipient) { - $count = sizeof($contentItems); - - if (1 == $count) { - // get the content-item out of the boxed associative array => array(array('templateId' => contentItem)) - $onlyItem = current(current($contentItems)); - // get the title out of the notification in the contentItem - return $onlyItem['notification']->getTitle(); + $count = count($contentItems); + if (1 === $count) { + $boxedItem = current($contentItems); + $onlyItem = is_array($boxedItem) ? current($boxedItem) : null; + if (is_array($onlyItem) && isset($onlyItem['notification'])) { + return $onlyItem['notification']->getTitle(); + } } - return $this->translatorService->transChoice('_az.email.notifications.subject.%count%', $count, array('%count%' => $count)); + return $this->translatorService->trans( + '_az.email.notifications.subject.%count%', + ['%count%' => $count], + ); } - /** - * Override this function to fill in any non-recipient-specific parameters that are required - * to render the newsletter-template and are not provided by the TemplateProvider. e.g. the total number of recipients of this newsletter. - * - * @return array - */ protected function getGeneralVarsForNewsletter() { - $vars = array(); - $vars['recipientCount'] = sizeof($this->recipientProvider->getNewsletterRecipientIDs()); - - return $vars; + return ['recipientCount' => count($this->recipientProvider->getNewsletterRecipientIDs())]; } - /** - * Override this function to fill in any non-recipient-specific content items that are the same - * for all recipients of the newsletter. - * - * E.g. a list of featured events or news-articles. - * - * @return array of templatesIds (without ending) as key and params to render the template as value. => array('AzineEmailBundle:contentItem:message' => array('notification => $someNotification, 'goToUrl' => 'http://example.com', ...)); - */ protected function getNonRecipientSpecificNewsletterContentItems() { - // @codeCoverageIgnoreStart - $contentItems = array(); - - //$contentItems[] = array('AcmeBundle:foo:barSameForAllRecipientsTemplate' => $templateParams); - return $contentItems; - // @codeCoverageIgnoreEnd + return []; } - /** - * Override this function to add more parameters that are required to render the newsletter template. - * - * @return array - */ public function getRecipientSpecificNewsletterParams(RecipientInterface $recipient) { - return array('recipient' => $recipient); + return ['recipient' => $recipient]; } - /** - * Override this function to fill in any recipient-specific content items that are different - * depending on the recipient of the newsletter. - * - * E.g. a list of the recipients latest activites. - * - * @return array of arrays with templatesIds (without ending) as key and params to render the template as value. - * => array( - * array('AzineEmailBundle:contentItem:message' => array('notification => $someNotification1, 'goToUrl' => 'http://example.com/1', ...)) - * array('AzineEmailBundle:contentItem:message' => array('notification => $someNotification2, 'goToUrl' => 'http://example.com/2', ...)) - * ); - */ protected function getRecipientSpecificNewsletterContentItems(RecipientInterface $recipient) { - // @codeCoverageIgnoreStart - $contentItems = array(); - - //$contentItems[] = array('AcmeBundle:foo:barDifferentForEachRecipientTemplate' => $recipientSpecificTemplateParams); - //$contentItems[] = array(AzineTemplateProvider::CONTENT_ITEM_MESSAGE_TEMPLATE => array('notification' => array('title' => 'SampleMessage', 'created' => new \DateTime('1 hour ago'), 'content' => 'Sample Text. Lorem Ipsum.'))); - return $contentItems; - // @codeCoverageIgnoreEnd + return []; } - /** - * Override this function to use a custom subject line for each newsletter-recipient. - * - * @param $generalContentItems array of content items. => e.g. array of array('templateID' => array('notification => $someNotification, 'goToUrl' => 'http://example.com', ...)) - * @param $recipientContentItems array of content items. => e.g. array of array('templateID' => array('notification => $someNotification, 'goToUrl' => 'http://example.com', ...)) - * @param $params array the array with all general template-params, including the item with the key 'subject' containing the default-subject - * @param $recipient RecipientInterface - * @param $locale string The language-code for translation of the subject - * - * @return the subject line - */ - public function getRecipientSpecificNewsletterSubject(array $generalContentItems, array $recipientContentItems, array $params, RecipientInterface $recipient, $locale) - { + public function getRecipientSpecificNewsletterSubject( + array $generalContentItems, + array $recipientContentItems, + array $params, + RecipientInterface $recipient, + $locale, + ) { return $params['subject']; } - /** - * By overriding this function you can rearrange the content items to you liking. By default no ordering is done, so the order is as follows:. - * - * - all user-specific content items as returned by AzineNotifierService::getRecipientSpecificNewsletterContentItems - * - all non-user-specific content items as returned by AzineNotifierService::getNonRecipientSpecificNewsletterContentItems - * - * @return array - */ public function orderContentItems(array $contentItems) { return $contentItems; } - /** - * Over ride this constructor if you need to inject more dependencies to get all the data together that you need for your newsletter/notifications. - */ - public function __construct(TemplateTwigSwiftMailerInterface $mailer, \Twig_Environment $twig, UrlGeneratorInterface $router, - ManagerRegistry $managerRegistry, TemplateProviderInterface $templateProvider, RecipientProviderInterface $recipientProvider, - TranslatorInterface $translatorService, array $parameters) - { - $this->mailer = $mailer; - $this->twig = $twig; - $this->router = $router; - $this->managerRegistry = $managerRegistry; - $this->templateProvider = $templateProvider; - $this->recipientProvider = $recipientProvider; - $this->translatorService = $translatorService; - $this->configParameter = $parameters; - } - - ////////////////////////////////////////////////////////////////////////// - /* You probably don't need to change or override any of the stuff below */ - ////////////////////////////////////////////////////////////////////////// - - const CONTENT_ITEMS = 'contentItems'; - /** - * @var TemplateTwigSwiftMailerInterface - */ - protected $mailer; - - /** - * @var \Twig_Environment - */ - protected $twig; - - /** - * @var UrlGeneratorInterface - */ - protected $router; - - /** - * @var TemplateProviderInterface - */ - protected $templateProvider; - - /** - * @var RecipientProviderInterface - */ - protected $recipientProvider; - - /** - * @var ManagerRegistry - */ - protected $managerRegistry; - - /** - * Array of configuration-parameters from the config.yml. - * - * @var array - */ - protected $configParameter; - - /** - * The translator. - * - * @var TranslatorInterface - */ - protected $translatorService; - - /** - * Get the number of seconds in a "one-hour-interval". - * - * @return int of seconds to consider as an hour - */ protected function getHourInterval() { - // about an hour ago (57min) - // this is because if the last run started 60min. ago, then the notifications - // for any recipient have been send after that and would be skipped until the next run. - // if your cron-job runs every minute, this is not needed. - return 60 * 60 - 3 * 60; + return 60 * 60 - 3 * 60; } - /** - * Get the number of seconds in a "one-day-interval". - * - * @return int of seconds to consider as a day - */ protected function getDayInterval() { - // about a day ago (23h57min) - // this is because if the last run started 24h. ago, then the notifications - // for any recipient have been send after that and would be skipped until the next run. - // if your cron-job runs every minute, this is not needed. return 60 * 60 * 24 - 3 * 60; } - /** - * (non-PHPdoc). - * - * @see Azine\EmailBundle\Services.NotifierServiceInterface::sendNotifications() - */ public function sendNotifications(array &$failedAddresses) { - // get all recipientIds with pending notifications in the database, that are due to be sent $recipientIds = $this->getNotificationRecipientIds(); - - // get vars that are the same for all recipients of this notification-mail-batch $params = $this->getVarsForNotificationsEmail(); - - $notificationsTemplate = $this->configParameter[AzineEmailExtension::TEMPLATES.'_'.AzineEmailExtension::NOTIFICATIONS_TEMPLATE]; + $notificationsTemplate = $this->configParameter[ + AzineEmailExtension::TEMPLATES.'_'.AzineEmailExtension::NOTIFICATIONS_TEMPLATE + ]; $sentCount = 0; foreach ($recipientIds as $recipientId) { - // send the mail for this recipient - $failedAddress = $this->sendNotificationsFor($recipientId, $notificationsTemplate, $params); - if (null !== $failedAddress && strlen($failedAddress) > 0) { + $failedAddress = $this->sendNotificationsFor( + $recipientId, + $notificationsTemplate, + $params, + ); + + if (is_string($failedAddress) && '' !== $failedAddress) { $failedAddresses[] = $failedAddress; } else { ++$sentCount; @@ -285,260 +153,195 @@ public function sendNotifications(array &$failedAddresses) return $sentCount; } - /** - * Send the notifications-email for one recipient. - * - * @param int $recipientId - * @param string $wrapperTemplateName - * @param array $params array of parameters for this recipient - * - * @return string|null or the failed email addressess - */ public function sendNotificationsFor($recipientId, $wrapperTemplateName, $params) { - // get the recipient $recipient = $this->recipientProvider->getRecipient($recipientId); - - // get all Notification-Items for the recipient from the database $notifications = $this->getNotificationsFor($recipient); - if (0 == sizeof($notifications)) { + if ([] === $notifications) { return null; } - // get the recipient specific parameters for the twig-templates - $recipientParams = $this->getRecipientVarsForNotificationsEmail($recipient); - $params = array_merge($recipientParams, $params); + $params = array_merge( + $this->getRecipientVarsForNotificationsEmail($recipient), + $params, + ); - // prepare the arrays with template and template-variables for each notification - $contentItems = array(); + $contentItems = []; foreach ($notifications as $notification) { - // decode the $params from the json in the notification-entity - $itemVars = $notification->getVariables(); - $itemVars = array_merge($params, $itemVars); - $itemVars['notification'] = $notification; - $itemVars['recipient'] = $recipient; - - $itemTemplateName = $notification->getTemplate(); - - $contentItems[] = array($itemTemplateName => $itemVars); + $itemVariables = array_merge($params, $notification->getVariables()); + $itemVariables['notification'] = $notification; + $itemVariables['recipient'] = $recipient; + $contentItems[] = [$notification->getTemplate() => $itemVariables]; } - // add the notifications to the params array so they will be rendered later $params[self::CONTENT_ITEMS] = $contentItems; $params['recipient'] = $recipient; $params['_locale'] = $recipient->getPreferredLocale(); - $subject = $this->getRecipientSpecificNotificationsSubject($contentItems, $recipient); - // send the email with the right wrapper-template - $sent = $this->mailer->sendSingleEmail($recipient->getEmail(), $recipient->getDisplayName(), $subject, $params, $wrapperTemplateName.'.txt.twig', $recipient->getPreferredLocale()); + $sent = $this->mailer->sendSingleEmail( + $recipient->getEmail(), + $recipient->getDisplayName(), + $subject, + $params, + $wrapperTemplateName.'.txt.twig', + $recipient->getPreferredLocale(), + ); - if ($sent) { - // save the updated notifications - $this->setNotificationsAsSent($notifications); - - return null; + if (!$sent) { + return $recipient->getEmail(); } - return $recipient->getEmail(); + $this->setNotificationsAsSent($notifications); + + return null; } - /** - * (non-PHPdoc). - * - * @see Azine\EmailBundle\Services.NotifierServiceInterface::sendNewsletter() - */ public function sendNewsletter(array &$failedAddresses) { - // params array for all recipients - $params = array(); - - // set a default subject - $params['subject'] = $this->translatorService->trans('_az.email.newsletter.subject'); - - // get the the non-recipient-specific contentItems of the newsletter - $params[self::CONTENT_ITEMS] = $this->getNonRecipientSpecificNewsletterContentItems(); - - // get recipientIds for the newsletter + $params = [ + 'subject' => $this->translatorService->trans('_az.email.newsletter.subject'), + self::CONTENT_ITEMS => $this->getNonRecipientSpecificNewsletterContentItems(), + ]; $recipientIds = $this->recipientProvider->getNewsletterRecipientIDs(); - - $newsletterTemplate = $this->configParameter[AzineEmailExtension::TEMPLATES.'_'.AzineEmailExtension::NEWSLETTER_TEMPLATE]; + $newsletterTemplate = $this->configParameter[ + AzineEmailExtension::TEMPLATES.'_'.AzineEmailExtension::NEWSLETTER_TEMPLATE + ]; foreach ($recipientIds as $recipientId) { - $failedAddress = $this->sendNewsletterFor($recipientId, $params, $newsletterTemplate); - - if (null !== $failedAddress && strlen($failedAddress) > 0) { + $failedAddress = $this->sendNewsletterFor( + $recipientId, + $params, + $newsletterTemplate, + ); + if (is_string($failedAddress) && '' !== $failedAddress) { $failedAddresses[] = $failedAddress; } } - return sizeof($recipientIds) - sizeof($failedAddresses); + return count($recipientIds) - count($failedAddresses); } - /** - * Send the newsletter for one recipient. - * - * @param int $recipientId - * @param array $params params and contentItems that are the same for all recipients - * @param string $wrapperTemplate - * - * @return string|null or the failed email addressess - */ public function sendNewsletterFor($recipientId, array $params, $wrapperTemplate) { $recipient = $this->recipientProvider->getRecipient($recipientId); - - // create new array for each recipient. - $recipientParams = array_merge($params, $this->getRecipientSpecificNewsletterParams($recipient)); - - // get the recipient-specific contentItems of the newsletter + $recipientParams = array_merge( + $params, + $this->getGeneralVarsForNewsletter(), + $this->getRecipientSpecificNewsletterParams($recipient), + ); $recipientContentItems = $this->getRecipientSpecificNewsletterContentItems($recipient); - - // merge the recipient-specific and the general content items. recipient-specific first/at the top! - $recipientParams[self::CONTENT_ITEMS] = $this->orderContentItems(array_merge($recipientContentItems, $params[self::CONTENT_ITEMS])); + $recipientParams[self::CONTENT_ITEMS] = $this->orderContentItems(array_merge( + $recipientContentItems, + $params[self::CONTENT_ITEMS], + )); $recipientParams['_locale'] = $recipient->getPreferredLocale(); - if (0 == sizeof($recipientParams[self::CONTENT_ITEMS])) { + if ([] === $recipientParams[self::CONTENT_ITEMS]) { return $recipient->getEmail(); } - $subject = $this->getRecipientSpecificNewsletterSubject($params[self::CONTENT_ITEMS], $recipientContentItems, $params, $recipient, $recipient->getPreferredLocale()); - - // render and send the email with the right wrapper-template - $sent = $this->mailer->sendSingleEmail($recipient->getEmail(), $recipient->getDisplayName(), $subject, $recipientParams, $wrapperTemplate.'.txt.twig', $recipient->getPreferredLocale()); - - if ($sent) { - // save that this recipient has recieved the newsletter - return null; - } - - return $recipient->getEmail(); + $subject = $this->getRecipientSpecificNewsletterSubject( + $params[self::CONTENT_ITEMS], + $recipientContentItems, + $params, + $recipient, + $recipient->getPreferredLocale(), + ); + + $sent = $this->mailer->sendSingleEmail( + $recipient->getEmail(), + $recipient->getDisplayName(), + $subject, + $recipientParams, + $wrapperTemplate.'.txt.twig', + $recipient->getPreferredLocale(), + ); + + return $sent ? null : $recipient->getEmail(); } - /** - * Get the Notifications that have not yet been sent yet. - * Ordered by "template" and "title". - * - * @return array of Notification - */ protected function getNotificationsFor(RecipientInterface $recipient) { - // get the notification mode $notificationMode = $recipient->getNotificationMode(); - - // get the date/time of the last notification - $lastNotificationDate = $this->getNotificationRepository()->getLastNotificationDate($recipient->getId()); - - $sendNotifications = false; + $lastNotificationDate = $this->getNotificationRepository()->getLastNotificationDate( + $recipient->getId(), + ); $timeDelta = time() - $lastNotificationDate->getTimestamp(); - if (RecipientInterface::NOTIFICATION_MODE_IMMEDIATELY == $notificationMode) { - $sendNotifications = true; - } elseif (RecipientInterface::NOTIFICATION_MODE_HOURLY == $notificationMode) { - $sendNotifications = ($timeDelta > $this->getHourInterval()); - } elseif (RecipientInterface::NOTIFICATION_MODE_DAYLY == $notificationMode) { - $sendNotifications = ($timeDelta > $this->getDayInterval()); - } elseif (RecipientInterface::NOTIFICATION_MODE_NEVER == $notificationMode) { + if (RecipientInterface::NOTIFICATION_MODE_NEVER === $notificationMode) { $this->markAllNotificationsAsSentFarInThePast($recipient); - return array(); + return []; } - // regularly sent notifications now - if ($sendNotifications) { - $notifications = $this->getNotificationRepository()->getNotificationsToSend($recipient->getId()); - - // if notifications exist, that should be sent immediately, then send those now disregarding the users mailing-preferences. - } else { - $notifications = $this->getNotificationRepository()->getNotificationsToSendImmediately($recipient->getId()); - } + $sendNotifications = match ($notificationMode) { + RecipientInterface::NOTIFICATION_MODE_IMMEDIATELY => true, + RecipientInterface::NOTIFICATION_MODE_HOURLY => $timeDelta > $this->getHourInterval(), + RecipientInterface::NOTIFICATION_MODE_DAYLY => $timeDelta > $this->getDayInterval(), + default => false, + }; - return $notifications; + return $sendNotifications + ? $this->getNotificationRepository()->getNotificationsToSend($recipient->getId()) + : $this->getNotificationRepository()->getNotificationsToSendImmediately($recipient->getId()); } - /** - * Get all IDs for Recipients of pending notifications. - * - * @return array of IDs - */ protected function getNotificationRecipientIds() { return $this->getNotificationRepository()->getNotificationRecipientIds(); } - /** - * Update (set sent = now) and save the notifications. - */ protected function setNotificationsAsSent(array $notifications) { + $entityManager = $this->managerRegistry->getManager(); foreach ($notifications as $notification) { $notification->setSent(new \DateTime()); - $this->managerRegistry->getManager()->persist($notification); + $entityManager->persist($notification); } - $this->managerRegistry->getManager()->flush(); + $entityManager->flush(); } - /** - * Mark all Notifications as sent long ago, as the recipient never want's to get any notifications. - */ protected function markAllNotificationsAsSentFarInThePast(RecipientInterface $recipient) { - $this->getNotificationRepository()->markAllNotificationsAsSentFarInThePast($recipient->getId()); + $this->getNotificationRepository()->markAllNotificationsAsSentFarInThePast( + $recipient->getId(), + ); } - /** - * Get the interval in days between newsletter mailings. - */ protected function getNewsletterInterval() { - return $this->configParameter[AzineEmailExtension::NEWSLETTER.'_'.AzineEmailExtension::NEWSLETTER_INTERVAL]; + return $this->configParameter[ + AzineEmailExtension::NEWSLETTER.'_'.AzineEmailExtension::NEWSLETTER_INTERVAL + ]; } - /** - * Get the time of the day when the newsletter should be sent. - * - * @return string Time of the day in the format HH:mm - */ protected function getNewsletterSendTime() { - return $this->configParameter[AzineEmailExtension::NEWSLETTER.'_'.AzineEmailExtension::NEWSLETTER_SEND_TIME]; + return $this->configParameter[ + AzineEmailExtension::NEWSLETTER.'_'.AzineEmailExtension::NEWSLETTER_SEND_TIME + ]; } - /** - * Get the DateTime at which the last newsletter mailing probably has taken place, if a newsletter is sent today. - * (Calculated: send-time-today - interval in days). - * - * @return \DateTime - */ protected function getDateTimeOfLastNewsletter() { return new \DateTime($this->getNewsletterInterval().' days ago '.$this->getNewsletterSendTime()); } - /** - * Get the DateTime at which the next newsletter mailing will take place, if a newsletter is sent today. - * (Calculated: send-time-today + interval in days). - */ protected function getDateTimeOfNextNewsletter() { - return new \DateTime('+'.$this->getNewsletterInterval().' days '.$this->getNewsletterSendTime()); + return new \DateTime('+'.$this->getNewsletterInterval().' days '.$this->getNewsletterSendTime()); } - /** - * Convenience-function to add and save a Notification-entity. - * - * @param int $recipientId the ID of the recipient of this notification => see RecipientProvider.getRecipient($id) - * @param string $title the title of the notification. depending on the recipients settings, multiple notifications are sent in one email. - * @param string $content the content of the notification - * @param string $template the twig-template to render the notification with - * @param array $templateVars the parameters used in the twig-template, 'notification' => Notification and 'recipient' => RecipientInterface will be added to this array when rendering the twig-template - * @param int $importance important messages are at the top of the notification-emails, un-important at the bottom - * @param bool $sendImmediately whether or not to ignore the recipients mailing-preference and send the notification a.s.a.p. - * - * @return Notification - */ - public function addNotification($recipientId, $title, $content, $template, $templateVars, $importance, $sendImmediately) - { + public function addNotification( + $recipientId, + $title, + $content, + $template, + $templateVars, + $importance, + $sendImmediately, + ) { $notification = new Notification(); $notification->setRecipientId($recipientId); $notification->setTitle($title); @@ -547,41 +350,49 @@ public function addNotification($recipientId, $title, $content, $template, $temp $notification->setImportance($importance); $notification->setSendImmediately($sendImmediately); $notification->setVariables($templateVars); - $this->managerRegistry->getManager()->persist($notification); - $this->managerRegistry->getManager()->flush($notification); + + $entityManager = $this->managerRegistry->getManager(); + $entityManager->persist($notification); + $entityManager->flush(); return $notification; } - /** - * Convenience-function to add and save a Notification-entity for a message => see AzineTemplateProvider::CONTENT_ITEM_MESSAGE_TYPE. - * - * The following default are used: - * $importance = NORMAL - * $sendImmediately = fale - * $template = template for type AzineTemplateProvider::CONTENT_ITEM_MESSAGE_TYPE - * $templateVars = only those from the template-provider - * - * @param int $recipientId - * @param string $title - * @param string $content nl2br will be applied in the html-version of the email - * @param string $goToUrl if this is supplied, a link "Go to message" will be added - */ public function addNotificationMessage($recipientId, $title, $content, $goToUrl = null) { - $contentItemTemplate = $this->configParameter[AzineEmailExtension::TEMPLATES.'_'.AzineEmailExtension::CONTENT_ITEM_TEMPLATE]; - $templateVars = array(); - if (null !== $goToUrl && strlen($goToUrl) > 0) { - $templateVars['goToUrl'] = $goToUrl; + $contentItemTemplate = $this->configParameter[ + AzineEmailExtension::TEMPLATES.'_'.AzineEmailExtension::CONTENT_ITEM_TEMPLATE + ]; + $templateVariables = []; + if (is_string($goToUrl) && '' !== $goToUrl) { + $templateVariables['goToUrl'] = $goToUrl; } - $this->addNotification($recipientId, $title, $content, $contentItemTemplate, $this->templateProvider->addTemplateVariablesFor($contentItemTemplate, $templateVars), Notification::IMPORTANCE_NORMAL, false); + + return $this->addNotification( + $recipientId, + $title, + $content, + $contentItemTemplate, + $this->templateProvider->addTemplateVariablesFor( + $contentItemTemplate, + $templateVariables, + ), + Notification::IMPORTANCE_NORMAL, + false, + ); } - /** - * @return NotificationRepository - */ protected function getNotificationRepository() { - return $this->managerRegistry->getRepository('AzineEmailBundle:Notification'); + $repository = $this->managerRegistry->getRepository(Notification::class); + if (!$repository instanceof NotificationRepository) { + throw new \LogicException(sprintf( + 'Expected repository "%s", got "%s".', + NotificationRepository::class, + get_debug_type($repository), + )); + } + + return $repository; } } diff --git a/Services/AzineRecipientProvider.php b/Services/AzineRecipientProvider.php index dd590915..064c2bf8 100644 --- a/Services/AzineRecipientProvider.php +++ b/Services/AzineRecipientProvider.php @@ -1,63 +1,50 @@ managerRegistry = $managerRegistry; - $this->userClass = $userClass; - $this->newsletterField = $newsletterField; + public function __construct( + private readonly ManagerRegistry $managerRegistry, + private readonly string $userClass, + private readonly string $newsletterField, + ) { } - /** - * (non-PHPdoc). - * - * @see Azine\EmailBundle\Services.RecipientProviderInterface::getRecipient() - */ - public function getRecipient($id) + public function getRecipient(int|string $id): RecipientInterface { - return $this->managerRegistry->getManager()->getRepository($this->userClass)->find($id); + $recipient = $this->managerRegistry->getManager()->getRepository($this->userClass)->find($id); + if (!$recipient instanceof RecipientInterface) { + throw new \RuntimeException(sprintf( + 'No recipient of class "%s" was found for id "%s".', + $this->userClass, + $id, + )); + } + + return $recipient; } - /** - * (non-PHPdoc). - * - * @see Azine\EmailBundle\Services.RecipientProviderInterface::getNewsletterRecipientIDs() - */ - public function getNewsletterRecipientIDs() + public function getNewsletterRecipientIDs(): array { - $qb = $this->managerRegistry->getManager()->createQueryBuilder() - ->select('n.id') - ->from($this->userClass, 'n') - ->where('n.'.$this->newsletterField.' = true') - ->andWhere('n.enabled = 1') // exclude inactive users - ; - $results = $qb->getQuery()->execute(); - $ids = array(); - foreach ($results as $next) { - $ids[] = $next['id']; - } - - return $ids; + $rows = $this->managerRegistry + ->getManager() + ->createQueryBuilder() + ->select('recipient.id') + ->from($this->userClass, 'recipient') + ->where(sprintf('recipient.%s = true', $this->newsletterField)) + ->andWhere('recipient.enabled = true') + ->getQuery() + ->getArrayResult(); + + return array_values(array_map( + static fn (array $row): int|string => $row['id'], + $rows, + )); } } diff --git a/Services/AzineTwigMailer.php b/Services/AzineTwigMailer.php new file mode 100644 index 00000000..7c2a8d3a --- /dev/null +++ b/Services/AzineTwigMailer.php @@ -0,0 +1,529 @@ + */ + private array $templateCache = []; + + public function __construct( + private readonly MailerInterface $mailer, + private readonly RouterInterface $router, + private readonly Environment $twig, + private readonly TranslatorInterface $translator, + private readonly TemplateProviderInterface $templateProvider, + private readonly ManagerRegistry $managerRegistry, + private readonly ?EmailOpenTrackingCodeBuilderInterface $emailOpenTrackingCodeBuilder, + private readonly AzineEmailTwigExtension $emailTwigExtension, + private readonly array $parameters, + private readonly ?MailerInterface $immediateMailer = null, + ) { + } + + public function sendEmail( + array &$failedRecipients, + string $subject, + ?string $from, + ?string $fromName, + string|array $to, + ?string $toName, + string|array|null $cc, + ?string $ccName, + string|array|null $bcc, + ?string $bccName, + string|array|null $replyTo, + ?string $replyToName, + array $params, + string $template, + array $attachments = [], + ?string $emailLocale = null, + ?Email &$message = null, + ): int { + $message ??= new Email(); + $failedRecipients = []; + + [$defaultFromEmail, $defaultFromName] = $this->getNoReplyAddress(); + $from ??= $defaultFromEmail; + $fromName ??= $defaultFromName; + + $params['sendMailAccountName'] ??= $defaultFromName; + $params['sendMailAccountAddress'] ??= $defaultFromEmail; + + $templateBaseId = $this->getTemplateBaseId($template); + $saveWebView = $this->templateProvider->saveWebViewFor($templateBaseId); + $webViewParams = $saveWebView ? $params : []; + + if ($saveWebView) { + $params[$this->templateProvider->getWebViewTokenId()] = SentEmail::getNewToken(); + } + + $params = $this->templateProvider->addTemplateVariablesFor($templateBaseId, $params); + $embeddedItems = $this->prepareEmbeddedItems($params); + + $previousLocale = $this->translator->getLocale(); + $emailLocale = $emailLocale ?: $previousLocale; + $routerContext = $this->router->getContext(); + $previousRouteLocale = $routerContext->getParameter('_locale'); + + if (method_exists($this->translator, 'setLocale')) { + $this->translator->setLocale($emailLocale); + } + $routerContext->setParameter('_locale', $emailLocale); + + try { + $params = $this->templateProvider->addTemplateSnippetsWithImagesFor( + $templateBaseId, + $params, + $emailLocale, + ); + $params['emailLocale'] = $emailLocale; + + $twigTemplate = $this->loadTemplate($template); + $textBody = $twigTemplate->renderBlock('body_text', $params); + $htmlBody = $twigTemplate->renderBlock('body_html', $params); + + $campaignParams = $this->templateProvider->getCampaignParamsFor($templateBaseId, $params); + if ([] !== $campaignParams) { + $htmlBody = $this->emailTwigExtension->addCampaignParamsToAllUrls($htmlBody, $campaignParams); + } + + $messageId = $this->createMessageId(); + $message->getHeaders()->addIdHeader('Message-ID', $messageId); + + if (null !== $this->emailOpenTrackingCodeBuilder) { + $trackingCode = $this->emailOpenTrackingCodeBuilder->getTrackingImgCode( + $templateBaseId, + $campaignParams, + $params, + $messageId, + $to, + $cc, + $bcc, + ); + if (is_string($trackingCode) && '' !== $trackingCode) { + $htmlBody = $this->appendBeforeBodyClose($htmlBody, $trackingCode); + } + } + + $message + ->subject($subject) + ->from(new Address($from, $fromName ?? '')) + ->to(...$this->normalizeAddresses($to, $toName)) + ->text($textBody) + ->html($htmlBody); + + $ccAddresses = $this->normalizeAddresses($cc, $ccName); + if ([] !== $ccAddresses) { + $message->cc(...$ccAddresses); + } + + $bccAddresses = $this->normalizeAddresses($bcc, $bccName); + if ([] !== $bccAddresses) { + $message->bcc(...$bccAddresses); + } + + $replyToAddresses = $this->normalizeAddresses($replyTo ?: $from, $replyToName ?: $fromName); + if ([] !== $replyToAddresses) { + $message->replyTo(...$replyToAddresses); + } + + $this->attachReferencedEmbeddedItems($message, $embeddedItems, $htmlBody); + $this->attachFiles($message, $attachments); + $this->templateProvider->addCustomHeaders($templateBaseId, $message, $params); + + try { + $this->getMailer($params)->send($message); + } catch (TransportExceptionInterface) { + $failedRecipients = array_map( + static fn (Address $address): string => $address->getAddress(), + $message->getTo(), + ); + + return 0; + } + + if ($saveWebView) { + $this->storeWebView( + $templateBaseId, + $webViewParams, + $params, + $emailLocale, + $message, + $failedRecipients, + ); + } + + return 1; + } finally { + if (method_exists($this->translator, 'setLocale')) { + $this->translator->setLocale($previousLocale); + } + $routerContext->setParameter('_locale', $previousRouteLocale); + } + } + + public function sendSingleEmail( + string|array $to, + ?string $toName, + string $subject, + array $params, + string $template, + ?string $emailLocale, + ?string $from = null, + ?string $fromName = null, + ?Email &$message = null, + ): bool { + $failedRecipients = []; + $sent = $this->sendEmail( + $failedRecipients, + $subject, + $from, + $fromName, + $to, + $toName, + null, + null, + null, + null, + null, + null, + $params, + $template, + [], + $emailLocale, + $message, + ); + + return 1 === $sent && [] === $failedRecipients; + } + + public function sendConfirmationEmailMessage(UserInterface $user): void + { + $template = (string) $this->parameters['template']['confirmation']; + $url = $this->router->generate( + 'fos_user_registration_confirm', + ['token' => $user->getConfirmationToken()], + UrlGeneratorInterface::ABSOLUTE_URL, + ); + + $this->sendAccountMessage( + $template, + ['user' => $user, 'confirmationUrl' => $url], + $this->parameters['from_email']['confirmation'], + (string) $user->getEmail(), + ); + } + + public function sendResettingEmailMessage(UserInterface $user): void + { + $template = (string) $this->parameters['template']['resetting']; + $url = $this->router->generate( + 'fos_user_resetting_reset', + ['token' => $user->getConfirmationToken()], + UrlGeneratorInterface::ABSOLUTE_URL, + ); + + $this->sendAccountMessage( + $template, + ['user' => $user, 'confirmationUrl' => $url], + $this->parameters['from_email']['resetting'], + (string) $user->getEmail(), + ); + } + + /** + * Backwards-compatible entry point used by the email-update confirmation bundle. + */ + public function sendUpdateEmailConfirmation( + UserInterface $user, + string $confirmationUrl, + string $toEmail, + ): void { + $template = (string) $this->parameters['template']['email_updating']; + $this->sendAccountMessage( + $template, + ['user' => $user, 'confirmationUrl' => $confirmationUrl], + $this->parameters['from_email']['confirmation'], + $toEmail, + ); + } + + /** + * @param array{address?: string, sender_name?: string}|string $fromEmail + */ + private function sendAccountMessage(string $template, array $context, array|string $fromEmail, string $toEmail): void + { + $twigTemplate = $this->loadTemplate($template); + $subject = trim($twigTemplate->renderBlock('subject', $context)); + [$fromAddress, $fromName] = $this->normalizeFromConfiguration($fromEmail); + $message = null; + + if (!$this->sendSingleEmail( + $toEmail, + null, + $subject, + $context, + $template, + $this->translator->getLocale(), + $fromAddress, + $fromName, + $message, + )) { + throw new \RuntimeException(sprintf('Unable to send account email to "%s".', $toEmail)); + } + } + + private function loadTemplate(string $template): TemplateWrapper + { + return $this->templateCache[$template] ??= $this->twig->load($template); + } + + private function getTemplateBaseId(string $template): string + { + return preg_replace('/\.(?:txt|html)\.twig$/', '', $template) ?? $template; + } + + /** + * @return array{0: string, 1: string} + */ + private function getNoReplyAddress(): array + { + $config = $this->parameters[AzineEmailExtension::NO_REPLY] ?? $this->parameters['no_reply'] ?? []; + + return [ + (string) ($config[AzineEmailExtension::NO_REPLY_EMAIL_ADDRESS] ?? $config['email'] ?? 'no-reply@example.com'), + (string) ($config[AzineEmailExtension::NO_REPLY_EMAIL_NAME] ?? $config['name'] ?? 'Notification service'), + ]; + } + + /** + * @param array{address?: string, sender_name?: string}|string $fromEmail + * + * @return array{0: string, 1: string} + */ + private function normalizeFromConfiguration(array|string $fromEmail): array + { + if (is_string($fromEmail)) { + return [$fromEmail, '']; + } + + return [ + (string) ($fromEmail['address'] ?? array_key_first($fromEmail) ?? ''), + (string) ($fromEmail['sender_name'] ?? (is_string(reset($fromEmail)) ? reset($fromEmail) : '')), + ]; + } + + /** + * @return list
+ */ + private function normalizeAddresses(string|array|null $addresses, ?string $name = null): array + { + if (null === $addresses || '' === $addresses || [] === $addresses) { + return []; + } + + if (is_string($addresses)) { + return [new Address($addresses, $name ?? '')]; + } + + $normalized = []; + foreach ($addresses as $email => $displayName) { + if (is_int($email)) { + $normalized[] = new Address((string) $displayName); + } else { + $normalized[] = new Address((string) $email, (string) $displayName); + } + } + + return $normalized; + } + + private function createMessageId(): string + { + $host = preg_replace('/[^a-z0-9.-]/i', '', gethostname() ?: 'localhost') ?: 'localhost'; + + return bin2hex(random_bytes(16)).'@'.$host; + } + + private function appendBeforeBodyClose(string $html, string $addition): string + { + $position = stripos($html, ''); + + return false === $position + ? $html.$addition + : substr_replace($html, $addition, $position, 0); + } + + /** + * Replaces allowed image file paths and generated GD images with stable cid: references. + * + * @return array + */ + private function prepareEmbeddedItems(array &$params): array + { + $embeddedItems = []; + $this->walkEmbeddedItems($params, $embeddedItems); + + return $embeddedItems; + } + + /** + * @param array $embeddedItems + */ + private function walkEmbeddedItems(array &$params, array &$embeddedItems): void + { + foreach ($params as $key => &$value) { + if (is_array($value)) { + $this->walkEmbeddedItems($value, $embeddedItems); + continue; + } + + if (is_string($value) && is_file($value) && $this->templateProvider->isFileAllowed($value)) { + $path = realpath($value); + if (false === $path) { + continue; + } + + $cid = 'azine-'.sha1($path); + $embeddedItems[$cid] = ['cid' => $cid, 'path' => $path]; + $value = 'cid:'.$cid; + continue; + } + + $isGdImage = class_exists(\GdImage::class) && $value instanceof \GdImage; + $isLegacyGdResource = is_resource($value) && str_starts_with(strtolower(get_resource_type($value)), 'gd'); + if (!$isGdImage && !$isLegacyGdResource) { + continue; + } + + ob_start(); + imagepng($value); + $data = (string) ob_get_clean(); + $cid = 'azine-generated-'.sha1($data); + $embeddedItems[$cid] = [ + 'cid' => $cid, + 'data' => $data, + 'contentType' => 'image/png', + ]; + $value = 'cid:'.$cid; + } + unset($value); + } + + /** + * @param array $embeddedItems + */ + private function attachReferencedEmbeddedItems(Email $message, array $embeddedItems, string $htmlBody): void + { + foreach ($embeddedItems as $item) { + if (!str_contains($htmlBody, 'cid:'.$item['cid'])) { + continue; + } + + if (isset($item['path'])) { + $message->embedFromPath($item['path'], $item['cid']); + continue; + } + + if (isset($item['data'])) { + $message->embed($item['data'], $item['cid'], $item['contentType'] ?? null); + } + } + } + + private function attachFiles(Email $message, array $attachments): void + { + foreach ($attachments as $fileName => $file) { + if (is_string($file)) { + if (!is_file($file)) { + throw new FileException('File not found: '.$file); + } + + $message->attachFromPath( + $file, + strlen((string) $fileName) >= 5 ? (string) $fileName : null, + ); + continue; + } + + $message->attach((string) $file, (string) $fileName); + } + } + + private function getMailer(array $params): MailerInterface + { + if ( + null !== $this->immediateMailer + && !empty($params[AzineTemplateProvider::SEND_IMMEDIATELY_FLAG]) + ) { + return $this->immediateMailer; + } + + return $this->mailer; + } + + private function storeWebView( + string $templateBaseId, + array $webViewParams, + array $renderedParams, + string $emailLocale, + Email $message, + array $failedRecipients, + ): void { + $tokenId = $this->templateProvider->getWebViewTokenId(); + if (!array_key_exists($tokenId, $renderedParams)) { + return; + } + + $webViewParams = $this->templateProvider->addTemplateVariablesFor($templateBaseId, $webViewParams); + $webViewParams = $this->templateProvider->makeImagePathsWebRelative($webViewParams, $emailLocale); + $webViewParams = $this->templateProvider->addTemplateSnippetsWithImagesFor( + $templateBaseId, + $webViewParams, + $emailLocale, + true, + ); + + $recipients = array_map( + static fn (Address $address): string => $address->getAddress(), + $message->getTo(), + ); + + $sentEmail = new SentEmail(); + $sentEmail->setToken((string) $renderedParams[$tokenId]); + $sentEmail->setTemplate($templateBaseId); + $sentEmail->setSent(new \DateTime()); + $sentEmail->setVariables($webViewParams); + $sentEmail->setRecipients(array_values(array_diff($recipients, $failedRecipients))); + + $entityManager = $this->managerRegistry->getManager(); + $entityManager->persist($sentEmail); + $entityManager->flush(); + $entityManager->clear(); + } +} diff --git a/Services/AzineTwigSwiftMailer.php b/Services/AzineTwigSwiftMailer.php index d2617628..7d903749 100644 --- a/Services/AzineTwigSwiftMailer.php +++ b/Services/AzineTwigSwiftMailer.php @@ -1,533 +1,12 @@ immediateMailer = $immediateMailer; - $this->translator = $translator; - $this->templateProvider = $templateProvider; - $this->managerRegistry = $managerRegistry; - $this->noReplyEmail = $parameters[AzineEmailExtension::NO_REPLY][AzineEmailExtension::NO_REPLY_EMAIL_ADDRESS]; - $this->noReplyName = $parameters[AzineEmailExtension::NO_REPLY][AzineEmailExtension::NO_REPLY_EMAIL_NAME]; - $this->emailOpenTrackingCodeBuilder = $emailOpenTrackingCodeBuilder; - $this->routerContext = $router->getContext(); - $this->encodedItemIdPattern = '/^cid:.*@/'; - $this->emailTwigExtension = $emailTwigExtension; - } - - /** - * (non-PHPdoc). - * - * @see Azine\EmailBundle\Services.TemplateTwigSwiftMailerInterface::sendEmail() - * - * @param array $failedRecipients - * @param string $subject - * @param string $from - * @param string $fromName - * @param array|string $to - * @param string $toName - * @param array|string $cc - * @param string $ccName - * @param array|string $bcc - * @param string $bccName - * @param $replyTo - * @param $replyToName - * @param $template - * @param array $attachments - * @param null $emailLocale - * @param \Swift_Message $message - * - * @return int - */ - public function sendEmail(&$failedRecipients, $subject, $from, $fromName, $to, $toName, $cc, $ccName, $bcc, $bccName, $replyTo, $replyToName, array $params, $template, $attachments = array(), $emailLocale = null, \Swift_Message &$message = null) - { - // create the message - if (null === $message) { - $message = new \Swift_Message(); - } - - $message->setSubject($subject); - - // set the from-Name & -Email to the default ones if not given - if (null === $from) { - $from = $this->noReplyEmail; - if (null === $fromName) { - $fromName = $this->noReplyName; - } - } - - // add the from-email for the footer-text - if (!array_key_exists('fromEmail', $params)) { - $params['sendMailAccountName'] = $this->noReplyName; - $params['sendMailAccountAddress'] = $this->noReplyEmail; - } - - // get the baseTemplate. => templateId without the ending. - $templateBaseId = substr($template, 0, strrpos($template, '.', -6)); - - // check if this email should be stored for web-view - if ($this->templateProvider->saveWebViewFor($templateBaseId)) { - // keep a copy of the vars for the web-view - $webViewParams = $params; - - // add the web-view token - $params[$this->templateProvider->getWebViewTokenId()] = SentEmail::getNewToken(); - } else { - $webViewParams = array(); - } - - // recursively add all template-variables for the wrapper-templates and contentItems - $params = $this->templateProvider->addTemplateVariablesFor($templateBaseId, $params); - - // recursively attach all messages in the array - $this->embedImages($message, $params); - - // change the locale for the email-recipients - if (null !== $emailLocale && strlen($emailLocale) > 0) { - $currentUserLocale = $this->translator->getLocale(); - - // change the router-context locale - $this->routerContext->setParameter('_locale', $emailLocale); - - // change the translator locale - $this->translator->setLocale($emailLocale); - } else { - $emailLocale = $this->translator->getLocale(); - } - - // recursively add snippets for the wrapper-templates and contentItems - $params = $this->templateProvider->addTemplateSnippetsWithImagesFor($templateBaseId, $params, $emailLocale); - - // add the emailLocale (used for web-view) - $params['emailLocale'] = $emailLocale; - - // render the email parts - $twigTemplate = $this->loadTemplate($template); - $textBody = $twigTemplate->renderBlock('body_text', $params); - $message->addPart($textBody, 'text/plain'); - - $htmlBody = $twigTemplate->renderBlock('body_html', $params); - - $campaignParams = $this->templateProvider->getCampaignParamsFor($templateBaseId, $params); - - if (sizeof($campaignParams) > 0) { - $htmlBody = $this->emailTwigExtension->addCampaignParamsToAllUrls($htmlBody, $campaignParams); - } - - // if email-tracking is enabled - if ($this->emailOpenTrackingCodeBuilder) { - // add an image at the end of the html tag with the tracking-params to track email-opens - $imgTrackingCode = $this->emailOpenTrackingCodeBuilder->getTrackingImgCode($templateBaseId, $campaignParams, $params, $message->getId(), $to, $cc, $bcc); - if ($imgTrackingCode && strlen($imgTrackingCode) > 0) { - $htmlCloseTagPosition = strpos($htmlBody, ''); - $htmlBody = substr_replace($htmlBody, $imgTrackingCode, $htmlCloseTagPosition, 0); - } - } - - $message->setBody($htmlBody, 'text/html'); - - // remove unused/unreferenced embeded items from the message - $message = $this->removeUnreferecedEmbededItemsFromMessage($message, $params, $htmlBody); - - // change the locale back to the users locale - if (isset($currentUserLocale) && null !== $currentUserLocale) { - $this->routerContext->setParameter('_locale', $currentUserLocale); - $this->translator->setLocale($currentUserLocale); - } - - // add attachments - foreach ($attachments as $fileName => $file) { - // add attachment from existing file - if (is_string($file)) { - // check that the file really exists! - if (file_exists($file)) { - $attachment = \Swift_Attachment::fromPath($file); - if (strlen($fileName) >= 5) { - $attachment->setFilename($fileName); - } - } else { - throw new FileException('File not found: '.$file); - } - - // add attachment from generated data - } else { - $attachment = new \Swift_Attachment($file, $fileName); - } - - $message->attach($attachment); - } - - // set the addresses - if ($from) { - $message->setFrom($from, $fromName); - } - if ($replyTo) { - $message->setReplyTo($replyTo, $replyToName); - } elseif ($from) { - $message->setReplyTo($from, $fromName); - } - if ($to) { - $message->setTo($to, $toName); - } - if ($cc) { - $message->setCc($cc, $ccName); - } - if ($bcc) { - $message->setBcc($bcc, $bccName); - } - - // add custom headers - $this->templateProvider->addCustomHeaders($templateBaseId, $message, $params); - - // send the message - $mailer = $this->getMailer($params); - $messagesSent = $mailer->send($message, $failedRecipients); - - // if the message was successfully sent, - // and it should be made available in web-view - if ($messagesSent && array_key_exists($this->templateProvider->getWebViewTokenId(), $params)) { - // store the email - $sentEmail = new SentEmail(); - $sentEmail->setToken($params[$this->templateProvider->getWebViewTokenId()]); - $sentEmail->setTemplate($templateBaseId); - $sentEmail->setSent(new \DateTime()); - - // recursively add all template-variables for the wrapper-templates and contentItems - $webViewParams = $this->templateProvider->addTemplateVariablesFor($template, $webViewParams); - - // replace absolute image-paths with relative ones. - $webViewParams = $this->templateProvider->makeImagePathsWebRelative($webViewParams, $emailLocale); - - // recursively add snippets for the wrapper-templates and contentItems - $webViewParams = $this->templateProvider->addTemplateSnippetsWithImagesFor($template, $webViewParams, $emailLocale, true); - - $sentEmail->setVariables($webViewParams); - - // save only successfull recipients - if (!is_array($to)) { - $to = array($to); - } - $successfulRecipients = array_diff($to, $failedRecipients); - $sentEmail->setRecipients($successfulRecipients); - - // write to db - $em = $this->managerRegistry->getManager(); - $em->persist($sentEmail); - $em->flush($sentEmail); - $em->clear(); - gc_collect_cycles(); - } - - return $messagesSent; - } - - /** - * Remove all Embeded Attachments that are not referenced in the html-body from the message - * to avoid using unneccary bandwidth. - * - * @param array $params the parameters used to render the html - * @param string $htmlBody - * - * @return \Swift_Message - */ - private function removeUnreferecedEmbededItemsFromMessage(\Swift_Message $message, $params, $htmlBody) - { - foreach ($params as $key => $value) { - // remove unreferenced attachments from contentItems too. - if ('contentItems' === $key) { - foreach ($value as $contentItemParams) { - $message = $this->removeUnreferecedEmbededItemsFromMessage($message, $contentItemParams, $htmlBody); - } - } else { - // check if the embeded items are referenced in the templates - $isEmbededItem = is_string($value) && 1 == preg_match($this->encodedItemIdPattern, $value); - - if ($isEmbededItem && false === stripos($htmlBody, $value)) { - // remove unreferenced items - $children = array(); - - foreach ($message->getChildren() as $attachment) { - if ('cid:'.$attachment->getId() != $value) { - $children[] = $attachment; - } - } - - $message->setChildren($children); - } - } - } - - return $message; - } - - /** - * Get the template from the cache if it was loaded already. - * - * @param string $template - * - * @return \Twig\Template - */ - private function loadTemplate($template) - { - if (!array_key_exists($template, $this->templateCache)) { - $this->templateCache[$template] = $this->twig->loadTemplate($template); - } - - return $this->templateCache[$template]; - } - - /** - * Recursively embed all images in the array into the message. - * - * @param \Swift_Message $message - * @param array $params - * - * @return array $params - */ - private function embedImages(&$message, &$params) - { - // loop through the array - foreach ($params as $key => $value) { - // if the current value is an array - if (is_array($value)) { - // search for more images deeper in the arrays - $value = $this->embedImages($message, $value); - $params[$key] = $value; - - // if the current value is an existing file from the image-folder, embed it - } elseif (is_string($value)) { - if (is_file($value)) { - // check if the file is from an allowed folder - if (false !== $this->templateProvider->isFileAllowed($value)) { - $encodedImage = $this->cachedEmbedImage($value); - if (null !== $encodedImage) { - $id = $message->embed($encodedImage); - $params[$key] = $id; - } - } - - // the $filePath isn't a regular file - } else { - // add a null-value to the cache for this path, so we don't try again. - $this->imageCache[$value] = null; - } - - //if the current value is a generated image - } elseif (is_resource($value) && 0 == stripos(get_resource_type($value), 'gd')) { - // get the image-data as string - ob_start(); - imagepng($value); - $imageData = ob_get_clean(); - - // encode the image - $encodedImage = new \Swift_Image($imageData, 'generatedImage'.md5($imageData)); - $id = $message->embed($encodedImage); - $params[$key] = $id; - } - // don't do anything - } - - // remove duplicate-attachments - $message->setChildren(array_unique($message->getChildren())); - - return $params; - } - - /** - * Get the Swift_Image for the file. - * - * @param string $filePath - * - * @return \Swift_Image|null - */ - private function cachedEmbedImage($filePath) - { - $filePath = realpath($filePath); - if (!array_key_exists($filePath, $this->imageCache)) { - if (is_file($filePath)) { - $image = \Swift_Image::fromPath($filePath); - $id = $image->getId(); - - // $id and $value must not be the same => this happens if the file cannot be found/read - if ($id == $filePath) { - // @codeCoverageIgnoreStart - // add a null-value to the cache for this path, so we don't try again. - $this->imageCache[$filePath] = null; - } else { - // @codeCoverageIgnoreEnd - // add the image to the cache - $this->imageCache[$filePath] = $image; - } - } - } - - return $this->imageCache[$filePath]; - } - - /** - * (non-PHPdoc). - * - * @see Azine\EmailBundle\Services.TemplateTwigSwiftMailerInterface::sendSingleEmail() - * - * @param string $to - * @param string $toName - * @param string $subject - * @param string $template - * @param string $emailLocale - * @param null $from - * @param null $fromName - * @param \Swift_Message $message - * - * @return bool - */ - public function sendSingleEmail($to, $toName, $subject, array $params, $template, $emailLocale, $from = null, $fromName = null, \Swift_Message &$message = null) - { - $failedRecipients = array(); - $this->sendEmail($failedRecipients, $subject, $from, $fromName, $to, $toName, null, null, null, null, null, null, $params, $template, array(), $emailLocale, $message); - - return 0 == sizeof($failedRecipients); - } - - /** - * Override the fosuserbundles original sendMessage, to embed template variables etc. into html-emails. - * - * @param string $templateName - * @param array $context - * @param string $fromEmail - * @param string $toEmail - * - * @return bool true if the mail was sent successfully, else false - */ - protected function sendMessage($templateName, $context, $fromEmail, $toEmail) - { - // get the subject from the template - // => make sure the subject block exists in your fos-templates (FOSUserBundle:Registration:email.txt.twig & FOSUserBundle:Resetting:email.txt.twig) - $twigTemplate = $this->loadTemplate($templateName); - $subject = $twigTemplate->renderBlock('subject', $context); - - return $this->sendSingleEmail($toEmail, null, $subject, $context, $templateName, $this->translator->getLocale(), $fromEmail); - } - - /** - * Return the Swift_Mailer to be used for sending mails immediately (e.g. instead of spooling them) if it is configured. - * - * @param $params - * - * @return \Swift_Mailer - */ - private function getMailer($params) - { - // if the second mailer for immediate mail-delivery has been configured - if (null !== $this->immediateMailer) { - // check if this template has been configured to be sent immediately - if (array_key_exists(AzineTemplateProvider::SEND_IMMEDIATELY_FLAG, $params) && $params[AzineTemplateProvider::SEND_IMMEDIATELY_FLAG]) { - return $this->immediateMailer; - } - } - - return $this->mailer; - } - - /** - * Send confirmation link to specified new user email. - * - * @param $confirmationUrl - * @param $toEmail - * - * @return bool - */ - public function sendUpdateEmailConfirmation(UserInterface $user, $confirmationUrl, $toEmail) - { - $template = $this->parameters['template']['email_updating']; - $fromEmail = $this->parameters['from_email']['confirmation']; - $context = array( - 'user' => $user, - 'confirmationUrl' => $confirmationUrl, - ); - - $this->sendMessage($template, $context, $fromEmail, $toEmail); - } } diff --git a/Services/NotifierServiceInterface.php b/Services/NotifierServiceInterface.php index ff5a124b..a386fc04 100644 --- a/Services/NotifierServiceInterface.php +++ b/Services/NotifierServiceInterface.php @@ -1,27 +1,12 @@ */ + public function getNewsletterRecipientIDs(): array; } diff --git a/Services/SpamCheckService.php b/Services/SpamCheckService.php new file mode 100644 index 00000000..2f1d94bb --- /dev/null +++ b/Services/SpamCheckService.php @@ -0,0 +1,74 @@ +checkRawMessage($message->toString(), $report); + } + + /** + * @return array{success: bool, message: string, curlHttpCode: int|string, curlError?: string, score?: float|int, report?: string, rules?: array} + */ + public function checkRawMessage(string $messageSource, string $report = 'long'): array + { + if (!in_array($report, ['short', 'long'], true)) { + throw new \InvalidArgumentException('The spam report type must be either "short" or "long".'); + } + + try { + $response = $this->httpClient->request('POST', $this->endpoint, [ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'email' => $messageSource, + 'options' => $report, + ], + 'timeout' => 5.0, + ]); + + $statusCode = $response->getStatusCode(); + $decoded = json_decode($response->getContent(false), true); + if (!is_array($decoded)) { + return [ + 'success' => false, + 'curlHttpCode' => $statusCode, + 'message' => 'The spam-check service returned an invalid JSON response.', + ]; + } + + $decoded['curlHttpCode'] = $statusCode; + $decoded['success'] = true === ($decoded['success'] ?? false); + $decoded['message'] ??= '-'; + + if (!$decoded['success'] && str_contains($messageSource, 'Content-Transfer-Encoding: base64')) { + $decoded['message'] .= "\n\nRemoving base64-encoded MIME parts may help."; + } + + return $decoded; + } catch (TransportExceptionInterface $exception) { + return [ + 'success' => false, + 'curlHttpCode' => '-', + 'curlError' => $exception->getMessage(), + 'message' => 'The spam-check service could not be reached.', + ]; + } + } +} diff --git a/Services/SymfonyMailerTemplateProvider.php b/Services/SymfonyMailerTemplateProvider.php new file mode 100644 index 00000000..46e838ef --- /dev/null +++ b/Services/SymfonyMailerTemplateProvider.php @@ -0,0 +1,52 @@ +addCustomHeadersToEmail((string) $template, $message, $params); + + return; + } + + parent::addCustomHeaders($template, $message, $params); + } + + public function addCustomHeadersToEmail(string $template, Email $message, array $params): void + { + $headers = $message->getHeaders(); + + if (array_key_exists($this->getWebViewTokenId(), $params)) { + $headers->addTextHeader( + 'x-azine-webview-token', + (string) $params[$this->getWebViewTokenId()], + ); + } + + if (array_key_exists(AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_NAME, $params)) { + $headers->addTextHeader( + 'x-utm_campaign', + (string) $params[AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_NAME], + ); + } + + if (array_key_exists(AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_SOURCE, $params)) { + $headers->addTextHeader( + 'x-utm_source', + (string) $params[AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_SOURCE], + ); + } + } +} diff --git a/Services/SymfonyMailerTemplateProviderInterface.php b/Services/SymfonyMailerTemplateProviderInterface.php new file mode 100644 index 00000000..53d2ed0d --- /dev/null +++ b/Services/SymfonyMailerTemplateProviderInterface.php @@ -0,0 +1,12 @@ + "AcmeFooBundle:bar:default") - * @param array $contentVariables array with variables required to render the content in the email - * - * @return array of merged template- and content-vars. Variables in the supplied array will NOT be replaced by newly added ones. - */ public function addTemplateVariablesFor($template, array $contentVariables); - /** - * Add template blocks that refer to images encoded in the email to the supplied array. - * This function will be called AFTER the images have been embeded, so you can define vars that include embede images => e.g. see variable "cellSeparator" in class AzineTemplateProvider. - * - * @param string $template the twig template for the email to render (template id in standard-notation, without the ending ( .txt.twig) => "AcmeFooBundle:bar:default") - * @param string $emailLocale the locale to be used for translations for this single email - * @param bool $forWebView - * - * @return array of merged template-vars. Variables in the supplied array WILL BE replaced by newly added ones, if the use the same key. - */ public function addTemplateSnippetsWithImagesFor($template, array $vars, $emailLocale, $forWebView = false); - /** - * Just before sending the message, extra custom headers can be added to the message. - * - * @param string $template - * - * @return array of \Swift_Mime_Header - */ - public function addCustomHeaders($template, \Swift_Message $message, array $params); - - /** - * Get the absolute filesystem path to the folder where the template-images are stored. - * - * @return string - */ public function getTemplateImageDir(); - /** - * Recursively replace all absolute image paths in the $emailVars array with relative web urls. - * - * @see \Azine\EmailBundle\Services\AzineTemplateProvider::makeImagePathsWebRelative for a reference implementation - * - * @param $locale - * - * @return mixed emailVars-array with relative paths for images - */ public function makeImagePathsWebRelative(array $emailVars, $locale); - /** - * Check if an image that should be embeded into an email is stored in an "allowed_images_folder" see config.yml. - * - * @param string the filesystem path to the file - * @param string $filePath - */ public function isFileAllowed($filePath); - /** - * Get the filesystem-folder for the given key. - * - * @param $key - * - * @return string|bool the filesystem-folder or false - */ public function getFolderFrom($key); - /** - * Define for which emails you want to make the web-view available and for which not. - * - * @param string $template the template id in standard-notation, without the ending ( .txt.twig) => "AcmeFooBundle:bar:default" - * - * @return bool - */ public function saveWebViewFor($template); - /** - * Get the id of the webViewToken. You only have to implement this method if your TemplateProvider - * doesn't extend the AzineTemplateProvider or if you wan't to change the ID from "azineEmailWebViewToken" - * to something else. - * - * This ID is used in the AzineEmailBundle::baseEmailLayout.html.twig to show a link to the web-view. - * - * @return string - */ public function getWebViewTokenId(); - /** - * Get the url-query-parameters for campaign identification. - * If you work with GoogleAnalytics take a look at this page: https://support.google.com/analytics/answer/1033867. - * - * @param string $templateId the template id in standard-notation, without the ending ( .txt.twig) => "AcmeFooBundle:bar:default" - * @param array $params campaing parameters already loaded - * - * @return array of (string => string) - */ public function getCampaignParamsFor($templateId, array $params = null); } diff --git a/Services/TemplateTwigMailerInterface.php b/Services/TemplateTwigMailerInterface.php new file mode 100644 index 00000000..a1257d30 --- /dev/null +++ b/Services/TemplateTwigMailerInterface.php @@ -0,0 +1,9 @@ + email-addresses - * @param string $toName will be ignored it $to is an array - * @param string $subject - * @param string $template - * @param string $emailLocale - * @param string $from defaults to azine's mailer - * @param string $fromName defaults to azine's mailer - * @param \Swift_Message $message instance of \Swift_Message that can be accessed by reference after sending the email - * @param string $to - * - * @return bool true if the mail was sent successfully, else false - */ - public function sendSingleEmail($to, $toName, $subject, array $params, $template, $emailLocale, $from = null, $fromName = null, \Swift_Message &$message = null); + public function sendSingleEmail( + string|array $to, + ?string $toName, + string $subject, + array $params, + string $template, + ?string $emailLocale, + ?string $from = null, + ?string $fromName = null, + ?Email &$message = null, + ): bool; } diff --git a/Services/WebViewServiceInterface.php b/Services/WebViewServiceInterface.php index 0ae6e7f2..cdb26a93 100644 --- a/Services/WebViewServiceInterface.php +++ b/Services/WebViewServiceInterface.php @@ -1,35 +1,23 @@ > */ public function getTemplatesForWebPreView(); /** - * Get a list of email-addresses that you would like to be able to send test-mails with dummy-data from the AzineEmailBundle:WebView:indexAction. - * - * @return array of associative arrays of strings + * @return array> */ public function getTestMailAccounts(); /** - * Get the dummy-content for the email to be rendered in the webPreView or sent to the test-account. - * - * @param string $template : the template id in standard-notation, without the ending ( .txt.twig) => "AcmeFooBundle:bar:default" - * @param string $locale : the locale for the templateVars - * - * @return array with all the content-variables needed to render the email. (the template variables from the TemplateProvider will be added later). + * @return array */ - public function getDummyVarsFor($template, $locale); + public function getDummyVarsFor($template, $locale, $variables = []); } diff --git a/Tests/AzineQueryMock.php b/Tests/AzineQueryMock.php index 91abe5f6..d247fa5b 100644 --- a/Tests/AzineQueryMock.php +++ b/Tests/AzineQueryMock.php @@ -1,37 +1,38 @@ result = $result; - } - - protected function doExecute() + public function __construct(private readonly mixed $result) { - return; } - protected function _doExecute() - { - return; + public function execute( + ArrayCollection|array|null $parameters = null, + string|int|null $hydrationMode = null, + ): mixed { + return $this->result; } - public function execute($parameters = null, $hydrationMode = null) + protected function _doExecute(): Result|int { - return $this->result; + return is_int($this->result) ? $this->result : 0; } - public function getSQL() + public function getSQL(): string { return 'dummy sql'; } diff --git a/Tests/Command/AzineNotifierServiceMock.php b/Tests/Command/AzineNotifierServiceMock.php deleted file mode 100644 index d28c04ee..00000000 --- a/Tests/Command/AzineNotifierServiceMock.php +++ /dev/null @@ -1,45 +0,0 @@ -fail = $fail; - } - - public function sendNotifications(array &$failedAddresses) - { - if ($this->fail) { - $failedAddresses[] = self::FAILED_ADDRESS; - - return self::EMAIL_COUNT - 1; - } - - return self::EMAIL_COUNT; - } - - public function sendNewsletter(array &$failedAddresses) - { - if ($this->fail) { - $failedAddresses[] = self::FAILED_ADDRESS; - - return self::EMAIL_COUNT - 1; - } - - return self::EMAIL_COUNT; - } -} diff --git a/Tests/Command/ClearAndLogFailedMailsCommandTest.php b/Tests/Command/ClearAndLogFailedMailsCommandTest.php deleted file mode 100644 index f263cf9f..00000000 --- a/Tests/Command/ClearAndLogFailedMailsCommandTest.php +++ /dev/null @@ -1,218 +0,0 @@ -getCommand(); - - $display = $command->getHelp(); - $this->assertStringContainsString('Any email-address that still failed, is logged.', $display); - } - - public function testSendingFailedMails() - { - $command = $this->getCommand(); - $failedRecipients = array('failed@email.com'); - $count = 2; - - $this->createFakeFailedMessageFiles($count); - $command->setContainer($this->getMockSetup($failedRecipients, false, false, $this->exactly($count))); - - $display = $this->executeCommandAndGetDisplay($command, array('')); - $this->assertStringContainsString("Retrying to send 'subject blabbla' to 'test-recipient@example.com'", $display); - $this->assertStringContainsString('Sent!', $display); - } - - public function testSendingFailedMailsWithDate() - { - $command = $this->getCommand(); - $failedRecipients = array('failed@email.com'); - $count = 4; - $this->createFakeFailedMessageFiles($count); - - $command->setContainer($this->getMockSetup($failedRecipients, false, false, $this->exactly($count))); - - $display = $this->executeCommandAndGetDisplay($command, array('date' => ' > now -1 minute')); - $this->assertStringContainsString("Retrying to send 'subject blabbla' to 'test-recipient@example.com'", $display); - $this->assertStringContainsString('Sent!', $display); - } - - public function testSendingFailedMailsNoMailsFound() - { - $command = $this->getCommand(); - $failedRecipients = array(); - $command->setContainer($this->getMockSetup($failedRecipients, false, false, $this->never())); - - $display = $this->executeCommandAndGetDisplay($command, array('')); - - $this->assertStringContainsString('No failed-message-files found', $display); - } - - public function testSendingFailedMailsWithoutTransport() - { - $command = $this->getCommand(); - $failedRecipients = array('failed@email.com'); - $command->setContainer($this->getMockSetup($failedRecipients, false, true)); - - $display = $this->executeCommandAndGetDisplay($command, array('')); - - $this->assertStringContainsString('Could not load transport. Is file-spooling configured in your config.yml for this environment?', $display); - } - - public function testSendingFailedMailsWithoutSpooling() - { - $command = $this->getCommand(); - $failedRecipients = array('failed@email.com'); - $command->setContainer($this->getMockSetup($failedRecipients, true)); - - $display = $this->executeCommandAndGetDisplay($command, array('')); - - $this->assertStringContainsString('Could not find file spool path. Is file-spooling configured in your config.yml for this environment?', $display); - } - - /** - * @param string[] $failedRecipients - * @param bool $noSpoolPath - * @param bool $noTransport - * @param null $msgCount - * - * @internal param string $message - * - * @return ContainerInterface - */ - private function getMockSetup($failedRecipients, $noSpoolPath = false, $noTransport = false, $msgCount = null) - { - if (null == $msgCount) { - $msgCount = $this->once(); - } - - $containerMock = $this->getMockBuilder("Symfony\Component\DependencyInjection\ContainerInterface")->disableOriginalConstructor()->getMock(); - - if ($noTransport) { - $containerMock->expects($this->once())->method('get')->will($this->throwException(new ServiceNotFoundException('swiftmailer.transport.real'))); - - return $containerMock; - } - - $transportMock = $this->getMockBuilder("\Swift_SmtpTransport")->getMock(); - - if ($noSpoolPath) { - $containerMock->expects($this->once())->method('get')->will($this->returnValue($transportMock)); - $containerMock->expects($this->once())->method('getParameter')->will($this->throwException(new InvalidArgumentException())); - - return $containerMock; - } - - $loggerMock = $this->getMockBuilder("Psr\Log\LoggerInterface")->disableOriginalConstructor()->getMock(); - if (sizeof($failedRecipients) > 0) { - $loggerMock->expects($this->once())->method('warning')->with('Failed to send an email to : '.implode(', ', $failedRecipients).''); - $getServiceCallCount = $this->exactly(2); - } else { - $loggerMock->expects($this->never())->method('warning'); - $getServiceCallCount = $this->once(); - } - - $transportMock->expects($this->once())->method('isStarted')->will($this->returnValue(false)); - $transportMock->expects($this->once())->method('start'); - $this->failedRecipients = $failedRecipients; - $transportMock->expects($msgCount)->method('send')->will($this->returnCallback(array($this, 'send_failures_callback'))); - - $containerMock->expects($getServiceCallCount)->method('get')->will($this->returnValueMap(array( - array('swiftmailer.transport.real', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $transportMock), - array('logger', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $loggerMock), - ))); - $containerMock->expects($this->exactly(2))->method('getParameter')->will($this->returnValueMap(array( - array('swiftmailer.mailers', array('default_mailer' => 'a dummy value for a mailer')), - array('swiftmailer.spool.default_mailer.file.path', __DIR__.'/mock.spool.path'), - ))); - - return $containerMock; - } - - private $failedRecipients = array(); - - public function send_failures_callback($message, &$failedRecipients = null) - { - if (sizeof($this->failedRecipients) > 0) { - $failedRecipients[] = array_pop($this->failedRecipients); - } - } - - private function createFakeFailedMessageFiles($count = 1) - { - $targetDir = __DIR__.'/mock.spool.path/'; - - $i = 0; - while ($i < $count) { - $random = md5(date('now')).$count.rand(0, 10000000); - $filename = $targetDir."$random.sending"; - $msg = new \Swift_Message(); - $msg->setTo('test-recipient@example.com'); - $msg->setBody("random file $random bla bla."); - $msg->setSubject('subject blabbla'); - $msg->setSender('test@example.com'); - $ser = serialize($msg); - $filehandle = fopen($filename, 'w'); - fwrite($filehandle, $ser); - fclose($filehandle); - ++$i; - } - - // make sure the right number of files has been created. - $fileCount = 0; - $targetDirHandle = opendir($targetDir); - while (false !== ($file = readdir($targetDirHandle))) { - ++$fileCount; - } - $this->assertSame($count + 3, $fileCount, "Exactly $count + 2 files (*.sending, '.', '..' and '.keepMe') expected in this directory( $targetDir )."); - } - - /** - * @return ClearAndLogFailedMailsCommand - */ - private function getCommand() - { - $application = new Application(); - $application->add(new ClearAndLogFailedMailsCommand()); - - return $application->find('emails:clear-and-log-failures'); - } - - /** - * @param ClearAndLogFailedMailsCommand $command - * @param array $input - * - * @return string - */ - private function executeCommandAndGetDisplay($command, $input) - { - $tester = new CommandTester($command); - $tester->execute($input); - $display = $tester->getDisplay(); - - return $display; - } - - public function tearDown(): void - { - parent::tearDown(); - $finder = Finder::create()->in(__DIR__.'/mock.spool.path/')->name('*'); - foreach ($finder as $next) { - unlink($next); - } - } -} diff --git a/Tests/Command/RemoveOldWebViewEmailsCommandTest.php b/Tests/Command/RemoveOldWebViewEmailsCommandTest.php index 38a543ba..0d6497f4 100644 --- a/Tests/Command/RemoveOldWebViewEmailsCommandTest.php +++ b/Tests/Command/RemoveOldWebViewEmailsCommandTest.php @@ -1,106 +1,108 @@ add(new RemoveOldWebViewEmailsCommand()); + $command = $this->createUnexecutedCommand(90); - $command = $application->find('emails:remove-old-web-view-emails'); - $this->assertStringContainsString('command deletes all SentEmail entities from the database', $command->getHelp()); - $this->assertStringContainsString('Remove all "SentEmail" from the database that are older than the configured time.', $command->getDescription()); + self::assertStringContainsString('deletes SentEmail entities', $command->getHelp()); + self::assertStringContainsString('Remove stored email web views', $command->getDescription()); } - /** - * @expectedException \Exception - */ - public function testDeleteSentEmailsFromWebViewNoConfig() + public function testDeletesUsingConfiguredRetention(): void { - $application = new Application(); - $application->add(new RemoveOldWebViewEmailsCommand()); - - $command = $application->find('emails:remove-old-web-view-emails'); - $days = null; - $command->setContainer($this->getMockBuilder("Symfony\Component\DependencyInjection\ContainerInterface")->disableOriginalConstructor()->getMock()); + $tester = new CommandTester($this->createCommand(66, 9)); - $tester = new CommandTester($command); - $tester->execute(array('')); - $display = $tester->getDisplay(); - $this->assertStringContainsString('either the commandline parameter "keep" or the "azine_email_web_view_retention" in your config.yml or the default-config has to be defined.', $display); + self::assertSame(Command::SUCCESS, $tester->execute([])); + self::assertStringContainsString('Using the configured retention period: 66 days.', $tester->getDisplay()); + self::assertStringContainsString('9 SentEmails older than', $tester->getDisplay()); } - public function testDeleteSentEmailsFromWebView() + public function testCommandArgumentOverridesConfiguredRetention(): void { - $application = new Application(); - $application->add(new RemoveOldWebViewEmailsCommand()); - - $command = $application->find('emails:remove-old-web-view-emails'); - $days = 66; - $deletedWebMails = 9; - $command->setContainer($this->getMockSetup($days, $deletedWebMails)); - - $tester = new CommandTester($command); - $tester->execute(array('')); - $display = $tester->getDisplay(); - $this->assertStringContainsString("using the parameter from the configuration => '$days' days.", $display); - $this->assertStringContainsString("$deletedWebMails SentEmails have been deleted that were older than", $display); + $tester = new CommandTester($this->createCommand(66, 900)); + + self::assertSame(Command::SUCCESS, $tester->execute(['keep' => 121])); + self::assertStringContainsString('900 SentEmails older than', $tester->getDisplay()); + self::assertStringNotContainsString('configured retention period', $tester->getDisplay()); } - public function testDeleteSentEmailsFromWebViewWithDayParam() + public function testRejectsInvalidRetention(): void { - $application = new Application(); - $application->add(new RemoveOldWebViewEmailsCommand()); - - $command = $application->find('emails:remove-old-web-view-emails'); - $days = null; - $deletedWebMails = 900; - $command->setContainer($this->getMockSetup($days, $deletedWebMails, true)); - - $tester = new CommandTester($command); - $tester->execute(array('keep' => 121)); - $display = $tester->getDisplay(); - $this->assertStringContainsString("$deletedWebMails SentEmails have been deleted that were older than", $display); - $this->assertTrue(false === strpos($display, 'using the parameter from the configuration'), "display is:\n\n$display"); + $registry = $this->createMock(ManagerRegistry::class); + $registry->expects(self::never())->method('getManager'); + $tester = new CommandTester($this->register(new RemoveOldWebViewEmailsCommand($registry, 0))); + + self::assertSame(Command::INVALID, $tester->execute([])); + self::assertStringContainsString('must be at least one day', $tester->getDisplay()); } - /** - * @param int|null $days - * @param int $deletedWebMails - * - * @return \PHPUnit_Framework_MockObject_MockObject - */ - private function getMockSetup($days, $deletedWebMails, $useKeep = false) + private function createUnexecutedCommand(int $retentionDays): RemoveOldWebViewEmailsCommand { - $containerMock = $this->getMockBuilder("Symfony\Component\DependencyInjection\ContainerInterface")->disableOriginalConstructor()->getMock(); - - $queryBuilderMock = $this->getMockBuilder("Doctrine\ORM\QueryBuilder")->disableOriginalConstructor()->getMock(); - $queryBuilderMock->expects($this->once())->method('delete')->will($this->returnSelf()); - $queryBuilderMock->expects($this->once())->method('where')->will($this->returnSelf()); - $queryBuilderMock->expects($this->once())->method('setParameter')->will($this->returnSelf()); - $queryBuilderMock->expects($this->once())->method('getQuery')->will($this->returnValue(new AzineQueryMock($deletedWebMails))); + return $this->register(new RemoveOldWebViewEmailsCommand( + $this->createMock(ManagerRegistry::class), + $retentionDays, + )); + } - $entityManagerMock = $this->getMockBuilder("Doctrine\ORM\EntityManager")->disableOriginalConstructor()->getMock(); - $entityManagerMock->expects($this->once())->method('createQueryBuilder')->will($this->returnValue($queryBuilderMock)); + private function createCommand(int $retentionDays, int $deletedWebMails): RemoveOldWebViewEmailsCommand + { + $query = $this->getMockBuilder(Query::class) + ->disableOriginalConstructor() + ->onlyMethods(['execute']) + ->getMock(); + $query->expects(self::once())->method('execute')->willReturn($deletedWebMails); + + $queryBuilder = $this->getMockBuilder(QueryBuilder::class) + ->disableOriginalConstructor() + ->onlyMethods(['delete', 'where', 'setParameter', 'getQuery']) + ->getMock(); + $queryBuilder + ->expects(self::once()) + ->method('delete') + ->with(SentEmail::class, 's') + ->willReturnSelf(); + $queryBuilder->expects(self::once())->method('where')->with('s.sent < :sent')->willReturnSelf(); + $queryBuilder + ->expects(self::once()) + ->method('setParameter') + ->with('sent', self::isInstanceOf(\DateTimeImmutable::class)) + ->willReturnSelf(); + $queryBuilder->expects(self::once())->method('getQuery')->willReturn($query); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::once())->method('createQueryBuilder')->willReturn($queryBuilder); + + $registry = $this->createMock(ManagerRegistry::class); + $registry->expects(self::once())->method('getManager')->willReturn($entityManager); + + return $this->register(new RemoveOldWebViewEmailsCommand($registry, $retentionDays)); + } - $doctrineMock = $this->getMockBuilder("\Doctrine\Persistence\ManagerRegistry")->disableOriginalConstructor()->getMock(); - $doctrineMock->expects($this->once())->method('getManager')->will($this->returnValue($entityManagerMock)); + private function register(RemoveOldWebViewEmailsCommand $command): RemoveOldWebViewEmailsCommand + { + $application = new Application(); + $application->add($command); - if (!$useKeep) { - $containerMock->expects($this->once())->method('getParameter')->with('azine_email_web_view_retention')->will($this->returnValue($days)); - } - $containerMock->expects($this->once())->method('get')->with('doctrine')->will($this->returnValue($doctrineMock)); + /** @var RemoveOldWebViewEmailsCommand $registered */ + $registered = $application->find('emails:remove-old-web-view-emails'); - return $containerMock; + return $registered; } } diff --git a/Tests/Command/SendNewsLetterCommandTest.php b/Tests/Command/SendNewsLetterCommandTest.php index 946b1176..87872a57 100644 --- a/Tests/Command/SendNewsLetterCommandTest.php +++ b/Tests/Command/SendNewsLetterCommandTest.php @@ -1,85 +1,103 @@ getCommand(); - $display = $command->getHelp(); - $this->assertStringContainsString('Depending on you Swiftmailer-Configuration the email will be send directly or will be written to the spool.', $display); + $notifier = $this->createMock(NotifierServiceInterface::class); + $notifier->expects(self::never())->method('sendNewsletter'); + $command = $this->register(new SendNewsLetterCommand( + $notifier, + $this->getMockBuilder(LockFactory::class)->disableOriginalConstructor()->getMock(), + )); + + self::assertStringContainsString('Symfony Mailer transport', $command->getHelp()); + self::assertStringContainsString('Messenger', $command->getHelp()); } - public function testSend() + public function testSendsNewsletter(): void { - $command = $this->getCommand(); - $tester = new CommandTester($command); - $tester->execute(array('')); - $display = $tester->getDisplay(); - $this->assertStringContainsString(AzineNotifierServiceMock::EMAIL_COUNT.' newsletter emails have been sent.', $display); + $tester = new CommandTester($this->createCommand()); + + self::assertSame(Command::SUCCESS, $tester->execute([])); + self::assertStringContainsString('10 newsletter emails have been sent.', $tester->getDisplay()); + } + + public function testReportsFailedRecipients(): void + { + $tester = new CommandTester($this->createCommand(true)); + + self::assertSame(Command::SUCCESS, $tester->execute([])); + self::assertStringContainsString('9 newsletter emails have been sent.', $tester->getDisplay()); + self::assertStringContainsString('a.failed@address.com', $tester->getDisplay()); } - public function testSendFail() + public function testDoesNotRunWhenLockIsUnavailable(): void { - $command = $this->getCommand(true); + $notifier = $this->createMock(NotifierServiceInterface::class); + $notifier->expects(self::never())->method('sendNewsletter'); + $command = $this->register(new SendNewsLetterCommand($notifier, $this->createLockFactory(false))); $tester = new CommandTester($command); - $tester->execute(array('')); - $display = $tester->getDisplay(); - $this->assertStringContainsString((AzineNotifierServiceMock::EMAIL_COUNT - 1).' newsletter emails have been sent.', $display); - $this->assertStringContainsString(AzineNotifierServiceMock::FAILED_ADDRESS, $display); + + self::assertSame(Command::SUCCESS, $tester->execute([])); + self::assertStringContainsString('already running', $tester->getDisplay()); } - /** - * @return SendNewsLetterCommand - */ - private function getCommand($fail = false) + private function createCommand(bool $fail = false): SendNewsLetterCommand { - $application = new Application(); - $application->add(new SendNewsLetterCommand()); - $command = $application->find('emails:sendNewsletter'); - $command->setContainer($this->getMockSetup($fail)); + $notifier = $this->createMock(NotifierServiceInterface::class); + $notifier + ->expects(self::once()) + ->method('sendNewsletter') + ->willReturnCallback(static function (array &$failedAddresses) use ($fail): int { + if ($fail) { + $failedAddresses[] = 'a.failed@address.com'; + + return 9; + } + + return 10; + }); - return $command; + return $this->register(new SendNewsLetterCommand($notifier, $this->createLockFactory(true))); } - private function getMockSetup($fail = false) + private function createLockFactory(bool $acquired): LockFactory { - $containerMock = $this->getMockBuilder("Symfony\Component\DependencyInjection\ContainerInterface")->disableOriginalConstructor()->getMock(); - $notifierServiceMock = new AzineNotifierServiceMock($fail); - $containerMock->expects($this->any())->method('get')->with('azine_email_notifier_service')->will($this->returnValue($notifierServiceMock)); + $lock = $this->createMock(SharedLockInterface::class); + $lock->expects(self::once())->method('acquire')->willReturn($acquired); + $lock->expects($acquired ? self::once() : self::never())->method('release'); - return $containerMock; + $factory = $this->getMockBuilder(LockFactory::class) + ->disableOriginalConstructor() + ->onlyMethods(['createLock']) + ->getMock(); + $factory->expects(self::once())->method('createLock')->willReturn($lock); + + return $factory; } - public function testLockingFunctionality() + private function register(SendNewsLetterCommand $command): SendNewsLetterCommand { - if (!class_exists('AppKernel')) { - $this->markTestSkipped('This test does only works if a full application is installed (including AppKernel class'); - } - $commandName = $this->getCommand()->getName(); - $reflector = new \ReflectionClass(\AppKernel::class); - $appDirectory = dirname($reflector->getFileName()); - - // start commands in a separate processes - $process1 = new Process("php $appDirectory/../bin/console $commandName --env=test"); - $process2 = new Process("php $appDirectory/../bin/console $commandName --env=test"); - $process1->start(); - $process2->start(); - - // wait until both processes have terminated - while (!$process1->isTerminated() || !$process2->isTerminated()) { - usleep(10); - } - - $this->assertStringContainsString('The command is already running in another process.', $process2->getOutput().$process1->getOutput()); + $application = new Application(); + $application->add($command); + + /** @var SendNewsLetterCommand $registered */ + $registered = $application->find('emails:sendNewsletter'); + + return $registered; } } diff --git a/Tests/Command/SendNotificationsCommandTest.php b/Tests/Command/SendNotificationsCommandTest.php index cc0769df..5ce4b069 100644 --- a/Tests/Command/SendNotificationsCommandTest.php +++ b/Tests/Command/SendNotificationsCommandTest.php @@ -1,85 +1,104 @@ getCommand(); - $display = $command->getHelp(); - $this->assertStringContainsString('Depending on you Swiftmailer-Configuration the email will be send directly or will be written to the spool.', $display); + $notifier = $this->createMock(NotifierServiceInterface::class); + $notifier->expects(self::never())->method('sendNotifications'); + $command = $this->register(new SendNotificationsCommand( + $notifier, + $this->getMockBuilder(LockFactory::class)->disableOriginalConstructor()->getMock(), + )); + + self::assertStringContainsString('Symfony Mailer transport', $command->getHelp()); + self::assertStringContainsString('Messenger', $command->getHelp()); } - public function testSend() + public function testSendsNotifications(): void { - $command = $this->getCommand(); - $tester = new CommandTester($command); - $tester->execute(array('')); - $display = $tester->getDisplay(); - $this->assertStringContainsString(AzineNotifierServiceMock::EMAIL_COUNT.' emails have been processed.', $display); + $tester = new CommandTester($this->createCommand()); + + self::assertSame(Command::SUCCESS, $tester->execute([])); + self::assertStringContainsString('10 emails have been processed.', $tester->getDisplay()); } - public function testSendFail() + public function testReportsFailedRecipients(): void { - $command = $this->getCommand(true); - $tester = new CommandTester($command); - $tester->execute(array('')); - $display = $tester->getDisplay(); - $this->assertStringContainsString((AzineNotifierServiceMock::EMAIL_COUNT - 1).' emails have been processed.', $display); - $this->assertStringContainsString(AzineNotifierServiceMock::FAILED_ADDRESS, $display); + $tester = new CommandTester($this->createCommand(true)); + + self::assertSame(Command::SUCCESS, $tester->execute([])); + self::assertStringContainsString('9 emails have been processed.', $tester->getDisplay()); + self::assertStringContainsString('a.failed@address.com', $tester->getDisplay()); } - /** - * @return SendNotificationsCommand - */ - private function getCommand($fail = false) + public function testDoesNotRunWhenLockIsUnavailable(): void { - $application = new Application(); - $application->add(new SendNotificationsCommand()); - $command = $application->find('emails:sendNotifications'); - $command->setContainer($this->getMockSetup($fail)); + $notifier = $this->createMock(NotifierServiceInterface::class); + $notifier->expects(self::never())->method('sendNotifications'); + $tester = new CommandTester($this->register( + new SendNotificationsCommand($notifier, $this->createLockFactory(false)), + )); - return $command; + self::assertSame(Command::SUCCESS, $tester->execute([])); + self::assertStringContainsString('already running', $tester->getDisplay()); } - private function getMockSetup($fail = false) + private function createCommand(bool $fail = false): SendNotificationsCommand { - $containerMock = $this->getMockBuilder("Symfony\Component\DependencyInjection\ContainerInterface")->disableOriginalConstructor()->getMock(); - $notifierServiceMock = new AzineNotifierServiceMock($fail); - $containerMock->expects($this->any())->method('get')->with('azine_email_notifier_service')->will($this->returnValue($notifierServiceMock)); + $notifier = $this->createMock(NotifierServiceInterface::class); + $notifier + ->expects(self::once()) + ->method('sendNotifications') + ->willReturnCallback(static function (array &$failedAddresses) use ($fail): int { + if ($fail) { + $failedAddresses[] = 'a.failed@address.com'; + + return 9; + } - return $containerMock; + return 10; + }); + + return $this->register(new SendNotificationsCommand($notifier, $this->createLockFactory(true))); } - public function testLockingFunctionality() + private function createLockFactory(bool $acquired): LockFactory { - if (!class_exists('AppKernel')) { - $this->markTestSkipped('This test does only works if a full application is installed (including AppKernel class'); - } - $commandName = $this->getCommand()->getName(); - $reflector = new \ReflectionClass(\AppKernel::class); - $appDirectory = dirname($reflector->getFileName()); - - // start commands in a separate processes - $process1 = new Process("php $appDirectory/../bin/console $commandName --env=test"); - $process2 = new Process("php $appDirectory/../bin/console $commandName --env=test"); - $process1->start(); - $process2->start(); - - // wait until both processes have terminated - while (!$process1->isTerminated() || !$process2->isTerminated()) { - usleep(10); - } - - $this->assertStringContainsString('The command is already running in another process.', $process2->getOutput().$process1->getOutput()); + $lock = $this->createMock(SharedLockInterface::class); + $lock->expects(self::once())->method('acquire')->willReturn($acquired); + $lock->expects($acquired ? self::once() : self::never())->method('release'); + + $factory = $this->getMockBuilder(LockFactory::class) + ->disableOriginalConstructor() + ->onlyMethods(['createLock']) + ->getMock(); + $factory->expects(self::once())->method('createLock')->willReturn($lock); + + return $factory; + } + + private function register(SendNotificationsCommand $command): SendNotificationsCommand + { + $application = new Application(); + $application->add($command); + + /** @var SendNotificationsCommand $registered */ + $registered = $application->find('emails:sendNotifications'); + + return $registered; } } diff --git a/Tests/Controller/AzineEmailTemplateControllerTest.php b/Tests/Controller/AzineEmailTemplateControllerTest.php index 4bc3fed1..a3322968 100644 --- a/Tests/Controller/AzineEmailTemplateControllerTest.php +++ b/Tests/Controller/AzineEmailTemplateControllerTest.php @@ -1,498 +1,363 @@ createMock(WebViewServiceInterface::class); + $webViewService->method('getTemplatesForWebPreView')->willReturn([['description' => 'Newsletter']]); + $webViewService->method('getTestMailAccounts')->willReturn([['accountEmail' => 'test@example.com']]); - public function renderResponseCallback($template, $params) - { - if ('AzineEmailBundle:Webview:index.html.twig' == $template) { - return new Response('indexPage-html :'.print_r($params, true)); - } elseif ('AzineEmailBundle:Webview:mail.not.available.html.twig' == $template) { - return new Response('mail.not.available.html.twig :'.print_r($params, true)); - } elseif ($template == AzineTemplateProvider::NEWSLETTER_TEMPLATE.'.html.twig') { - return new Response("newsletter-html bla with param:".print_r($params, true)); - } elseif ($template == AzineTemplateProvider::NEWSLETTER_TEMPLATE.'.txt.twig') { - return new Response("newsletter-text bla\n\n some url with param http://testurl.com/with/?param=1 in plain-text:".print_r($params, true)); - } elseif ('A' == $template) { - throw new \Exception("unexpected template $template"); - } - } + $controller = $this->createController( + webViewService: $webViewService, + twigTemplates: [ + '@AzineEmail/Webview/index.html.twig' => '{{ customEmail }}|{{ templates[0].description }}|{{ emails[0].accountEmail }}', + ], + ); - public function testIndexAction() - { - $requestMock = $this->getMockBuilder("Symfony\Component\HttpFoundation\Request")->disableOriginalConstructor()->setMethods(array('get'))->getMock(); - $requestMock->expects($this->once())->method('get')->will($this->returnValue('a-custom@email.com')); - $webViewServiceMock = $this->getMockBuilder("Azine\EmailBundle\Services\AzineWebViewService")->disableOriginalConstructor()->getMock(); - $webViewServiceMock->expects($this->once())->method('getTemplatesForWebPreView')->will($this->returnValue(array( - array('url' => 'azine_email_web_preview/newsletter', - 'description' => 'Newsletter Template', - 'formats' => array('html', 'txt'), - 'templateId' => AzineTemplateProvider::NEWSLETTER_TEMPLATE, - ), - array('url' => 'azine_email_web_preview/notifications', - 'description' => 'Notifications Template', - 'formats' => array('html', 'txt'), - 'templateId' => AzineTemplateProvider::NOTIFICATIONS_TEMPLATE, - ), - ))); - $webViewServiceMock->expects($this->once())->method('getTestMailAccounts')->will($this->returnValue(array( - array('accountDescription' => 'Gmail', 'accountEmail' => 'some-account@gmail.com'), - array('accountDescription' => 'GMX', 'accountEmail' => 'some-account@gmx.com'), - ))); - $twigMock = $this->getMockBuilder("Symfony\Bundle\TwigBundle\TwigEngine")->disableOriginalConstructor()->getMock(); - $twigMock->expects($this->once())->method('renderResponse')->will($this->returnCallback(array($this, 'renderResponseCallback'))); - $containerMock = $this->getMockBuilder("Symfony\Component\DependencyInjection\ContainerInterface")->disableOriginalConstructor()->getMock(); - $containerMock->expects($this->exactly(3))->method('get')->will($this->returnValueMap(array( - array('request', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $requestMock), - array('azine_email_web_view_service', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $webViewServiceMock), - array('templating', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $twigMock), - ))); - $controller = new AzineEmailTemplateController(); - $controller->setContainer($containerMock); - $controller->indexAction($requestMock); - } + $response = $controller->indexAction(new Request(['customEmail' => 'custom@example.com'])); - public function testWebPreViewAction() - { - $requestMock = $this->getMockBuilder("Symfony\Component\HttpFoundation\Request")->disableOriginalConstructor()->setMethods(array('getLocale'))->getMock(); - $requestMock->expects($this->exactly(3))->method('getLocale')->will($this->returnValue('en')); - $requestMock->query = new InputBag(); - $webViewServiceMock = $this->getMockBuilder("Azine\EmailBundle\Services\AzineWebViewService")->disableOriginalConstructor()->getMock(); - $webViewServiceMock->expects($this->exactly(3))->method('getDummyVarsFor')->will($this->returnValue(array())); - $twigMock = $this->getMockBuilder("Symfony\Bundle\TwigBundle\TwigEngine")->disableOriginalConstructor()->getMock(); - $twigMock->expects($this->exactly(3))->method('renderResponse')->will($this->returnCallback(array($this, 'renderResponseCallback'))); - $emailVars = array(); - $templateProviderMock = $this->getMockBuilder("Azine\EmailBundle\Services\AzineTemplateProvider")->disableOriginalConstructor()->getMock(); - $templateProviderMock->expects($this->exactly(3))->method('addTemplateVariablesFor')->will($this->returnValue($emailVars)); - $templateProviderMock->expects($this->exactly(3))->method('makeImagePathsWebRelative')->will($this->returnValue($emailVars)); - $templateProviderMock->expects($this->exactly(3))->method('addTemplateSnippetsWithImagesFor')->will($this->returnValue($emailVars)); - $templateProviderMock->expects($this->exactly(3))->method('getCampaignParamsFor')->will($this->returnValue(array('utm_campaign' => 'name', 'utm_medium' => 'medium'))); - $trackingCodeBuilderMock = $this->getMockBuilder("Azine\EmailBundle\Services\AzineEmailOpenTrackingCodeBuilder")->setConstructorArgs(array('http://www.google-analytics.com/?', array( - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_NAME => 'utm_campaign', - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_TERM => 'utm_term', - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_SOURCE => 'utm_source', - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_MEDIUM => 'utm_medium', - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_CONTENT => 'utm_content', - )))->getMock(); - $trackingCodeBuilderMock->expects($this->exactly(3))->method('getTrackingImgCode')->will($this->returnValue('http://www.google-analytics.com/?')); - $azineEmailTwigExtension = $this->getMockBuilder("Azine\EmailBundle\Services\AzineEmailTwigExtension")->disableOriginalConstructor()->getMock(); - $azineEmailTwigExtension->expects($this->exactly(3))->method('addCampaignParamsToAllUrls')->will($this->returnArgument(0)); - $containerMock = $this->getMockBuilder("Symfony\Component\DependencyInjection\ContainerInterface")->disableOriginalConstructor()->getMock(); - $containerMock->expects($this->exactly(24))->method('get')->will($this->returnValueMap(array( - array('request', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $requestMock), - array('azine_email_web_view_service', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $webViewServiceMock), - array('templating', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $twigMock), - array('azine_email_template_provider', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $templateProviderMock), - array('azine_email_email_open_tracking_code_builder', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $trackingCodeBuilderMock), - array('azine.email.bundle.twig.filters', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $azineEmailTwigExtension), - ))); - $containerMock->expects($this->exactly(3))->method('getParameter')->with('azine_email_no_reply')->will($this->returnValue(array('email' => 'no-reply-email-mock@email.com', 'name' => 'no-reply-name'))); - $controller = new AzineEmailTemplateController(); - $controller->setContainer($containerMock); - $controller->webPreViewAction($requestMock, AzineTemplateProvider::NEWSLETTER_TEMPLATE); - $controller->webPreViewAction($requestMock, AzineTemplateProvider::NEWSLETTER_TEMPLATE, 'html'); - $response = $controller->webPreViewAction($requestMock, AzineTemplateProvider::NEWSLETTER_TEMPLATE, 'txt'); - $this->assertSame('text/plain', $response->headers->get('Content-Type')); - $this->assertStringNotContainsString('getContent()); + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertSame( + 'custom@example.com|Newsletter|test@example.com', + $response->getContent(), + ); } - public function testWebViewAction_User_access_allowed() + public function testHtmlPreviewSupportsLegacyBundleTemplateNotation(): void { - $token = 'fdasdfasfafsadf'; - $twigMock = $this->getMockBuilder("Symfony\Bundle\TwigBundle\TwigEngine")->disableOriginalConstructor()->getMock(); - $twigMock->expects($this->once())->method('renderResponse')->will($this->returnCallback(array($this, 'renderResponseCallback'))); - $userMail = 'a-user@email.com'; - $userMock = $this->getMockBuilder('stdClass')->addMethods(array('getEmail', 'hasRole'))->getMock(); - $userMock->expects($this->once())->method('getEmail')->will($this->returnValue($userMail)); - $sentEmail = new SentEmail(); - $sentEmail->setRecipients(array($userMail)); - $sentEmail->setSent(new \DateTime('2 weeks ago')); - $sentEmail->setTemplate(AzineTemplateProvider::NEWSLETTER_TEMPLATE); - $sentEmail->setVariables(array()); - $sentEmail->setToken($token); - $repositoryMock = $this->getMockBuilder("Azine\EmailBundle\Entity\Repositories\SentEmailRepository")->disableOriginalConstructor()->setMethods(array('findOneByToken'))->getMock(); - $repositoryMock->expects($this->once())->method('findOneByToken')->will($this->returnValue($sentEmail)); - $doctrineManagerMock = $this->getMockBuilder("Doctrine\ORM\EntityManagerMock")->disableOriginalConstructor()->getMock(); - $doctrineManagerRegistryMock = $this->getMockBuilder("Doctrine\Persistence\ManagerRegistry")->disableOriginalConstructor()->getMock(); - $doctrineManagerRegistryMock->expects($this->once())->method('getRepository')->with('AzineEmailBundle:SentEmail')->will($this->returnValue($repositoryMock)); - $doctrineManagerRegistryMock->expects($this->once())->method('getManager')->will($this->returnValue($this->returnValue($doctrineManagerMock))); - $securityTokenMock = $this->getMockBuilder('stdClass')->addMethods(array('getUser'))->getMock(); - $securityTokenMock->expects($this->exactly(2))->method('getUser')->will($this->returnValue($userMock)); - $tokenStorageMock = $this->getMockBuilder("Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface")->disableOriginalConstructor()->getMock(); - $tokenStorageMock->expects($this->once())->method('getToken')->will($this->returnValue($securityTokenMock)); - $templateProviderMock = $this->getMockBuilder("Azine\EmailBundle\Services\AzineTemplateProvider")->disableOriginalConstructor()->getMock(); - $templateProviderMock->expects($this->once())->method('getWebViewTokenId')->will($this->returnValue('tokenId')); - $containerMock = $this->getMockBuilder("Symfony\Component\DependencyInjection\ContainerInterface")->disableOriginalConstructor()->getMock(); - $containerMock->expects($this->exactly(5))->method('get')->will($this->returnValueMap(array( - array('azine_email_template_provider', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $templateProviderMock), - array('templating', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $twigMock), - array('doctrine', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $doctrineManagerRegistryMock), - array('security.token_storage', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $tokenStorageMock), - ))); - $containerMock->expects($this->once())->method('has')->with('security.token_storage')->will($this->returnValue(true)); - $requestMock = $this->getMockBuilder("Symfony\Component\HttpFoundation\Request")->disableOriginalConstructor()->getMock(); - $controller = new AzineEmailTemplateController(); - $controller->setContainer($containerMock); - $controller->webViewAction($requestMock, $token); - } + $webViewService = $this->createMock(WebViewServiceInterface::class); + $webViewService + ->expects(self::once()) + ->method('getDummyVarsFor') + ->with('AzineEmailBundle::preview', 'de', ['name' => 'Request value']) + ->willReturn(['name' => 'Dummy value']); - public function testWebViewAction_Anonymous_access_allowed() - { - $token = 'fdasdfasfafsadf'; - $twigMock = $this->getMockBuilder("Symfony\Bundle\TwigBundle\TwigEngine")->disableOriginalConstructor()->getMock(); - $twigMock->expects($this->once())->method('renderResponse')->will($this->returnCallback(array($this, 'renderResponseCallback'))); - $sentEmail = new SentEmail(); - $sentEmail->setSent(new \DateTime('2 weeks ago')); - $sentEmail->setTemplate(AzineTemplateProvider::NEWSLETTER_TEMPLATE); - $sentEmail->setVariables(array()); - $sentEmail->setToken($token); - $repositoryMock = $this->getMockBuilder("Azine\EmailBundle\Entity\Repositories\SentEmailRepository")->disableOriginalConstructor()->setMethods(array('findOneByToken'))->getMock(); - $repositoryMock->expects($this->once())->method('findOneByToken')->will($this->returnValue($sentEmail)); - $doctrineManagerMock = $this->getMockBuilder("Doctrine\ORM\EntityManagerMock")->disableOriginalConstructor()->getMock(); - $doctrineManagerRegistryMock = $this->getMockBuilder("Doctrine\Persistence\ManagerRegistry")->disableOriginalConstructor()->getMock(); - $doctrineManagerRegistryMock->expects($this->once())->method('getRepository')->with('AzineEmailBundle:SentEmail')->will($this->returnValue($repositoryMock)); - $doctrineManagerRegistryMock->expects($this->once())->method('getManager')->will($this->returnValue($this->returnValue($doctrineManagerMock))); - $securityTokenMock = $this->getMockBuilder('stdClass')->addMethods(array('getUser'))->getMock(); - $securityTokenMock->expects($this->never())->method('getUser'); - $tokenStorageMock = $this->getMockBuilder("Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface")->disableOriginalConstructor()->getMock(); - $tokenStorageMock->expects($this->never())->method('getToken'); - $templateProviderMock = $this->getMockBuilder("Azine\EmailBundle\Services\AzineTemplateProvider")->disableOriginalConstructor()->getMock(); - $templateProviderMock->expects($this->once())->method('getWebViewTokenId')->will($this->returnValue('tokenId')); - $containerMock = $this->getMockBuilder("Symfony\Component\DependencyInjection\ContainerInterface")->disableOriginalConstructor()->getMock(); - $containerMock->expects($this->exactly(4))->method('get')->will($this->returnValueMap(array( - array('azine_email_template_provider', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $templateProviderMock), - array('templating', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $twigMock), - array('doctrine', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $doctrineManagerRegistryMock), - array('security.token_storage', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $tokenStorageMock), - ))); - $containerMock->expects($this->never())->method('has'); - $requestMock = $this->getMockBuilder("Symfony\Component\HttpFoundation\Request")->disableOriginalConstructor()->getMock(); - $controller = new AzineEmailTemplateController(); - $controller->setContainer($containerMock); - $controller->webViewAction($requestMock, $token); + $templateProvider = $this->createTemplateProviderMock(); + $templateProvider->method('addTemplateVariablesFor')->willReturnCallback( + static fn (string $template, array $variables): array => $variables, + ); + $templateProvider->method('makeImagePathsWebRelative')->willReturnArgument(0); + $templateProvider->method('addTemplateSnippetsWithImagesFor')->willReturnArgument(1); + $templateProvider->method('getCampaignParamsFor')->willReturn([]); + + $controller = $this->createController( + webViewService: $webViewService, + templateProvider: $templateProvider, + twigTemplates: [ + '@AzineEmail/preview.html.twig' => '{{ name }}|{{ fromEmail }}|{{ emailLocale }}', + ], + ); + $request = new Request(['name' => 'Request value']); + $request->setLocale('de'); + + $response = $controller->webPreViewAction($request, 'AzineEmailBundle::preview', 'html'); + + self::assertSame( + 'Request value|no-reply@azine.me|de', + $response->getContent(), + ); } - /** - * @expectedException \Symfony\Component\Security\Core\Exception\AccessDeniedException - */ - public function testWebViewAction_User_access_denied() + public function testTextPreviewUsesPlainTextContentType(): void { - $token = 'fdasdfasfafsadf'; - $userMail = 'an-other-user@email.com'; - $userMock = $this->getMockBuilder('stdClass')->addMethods(array('getEmail', 'hasRole'))->getMock(); - $userMock->expects($this->once())->method('getEmail')->will($this->returnValue($userMail)); - $sentEmail = new SentEmail(); - $sentEmail->setRecipients(array('someuser@email.com')); - $sentEmail->setSent(new \DateTime('2 weeks ago')); - $sentEmail->setTemplate(AzineTemplateProvider::NEWSLETTER_TEMPLATE); - $sentEmail->setVariables(array()); - $sentEmail->setToken($token); - $repositoryMock = $this->getMockBuilder("Azine\EmailBundle\Entity\Repositories\SentEmailRepository")->disableOriginalConstructor()->setMethods(array('findOneByToken'))->getMock(); - $repositoryMock->expects($this->once())->method('findOneByToken')->will($this->returnValue($sentEmail)); - $doctrineManagerMock = $this->getMockBuilder("Doctrine\ORM\EntityManagerMock")->disableOriginalConstructor()->getMock(); - $doctrineManagerRegistryMock = $this->getMockBuilder("Doctrine\Persistence\ManagerRegistry")->disableOriginalConstructor()->getMock(); - $doctrineManagerRegistryMock->expects($this->once())->method('getRepository')->with('AzineEmailBundle:SentEmail')->will($this->returnValue($repositoryMock)); - $securityTokenMock = $this->getMockBuilder('stdClass')->addMethods(array('getUser'))->getMock(); - $securityTokenMock->expects($this->exactly(2))->method('getUser')->will($this->returnValue($userMock)); - $tokenStorageMock = $this->getMockBuilder("Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface")->disableOriginalConstructor()->getMock(); - $tokenStorageMock->expects($this->once())->method('getToken')->will($this->returnValue($securityTokenMock)); - $translatorMock = $this->getMockBuilder("Symfony\Bundle\FrameworkBundle\Translation\Translator")->disableOriginalConstructor()->setMethods(array('trans'))->getMock(); - $translatorMock->expects($this->once())->method('trans')->will($this->returnValue('translation')); - $containerMock = $this->getMockBuilder("Symfony\Component\DependencyInjection\ContainerInterface")->disableOriginalConstructor()->getMock(); - $containerMock->expects($this->exactly(3))->method('get')->will($this->returnValueMap(array( - array('doctrine', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $doctrineManagerRegistryMock), - array('security.token_storage', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $tokenStorageMock), - array('translator', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $translatorMock), - ))); - $containerMock->expects($this->once())->method('has')->with('security.token_storage')->will($this->returnValue(true)); - $requestMock = $this->getMockBuilder("Symfony\Component\HttpFoundation\Request")->disableOriginalConstructor()->getMock(); - $controller = new AzineEmailTemplateController(); - $controller->setContainer($containerMock); - $controller->webViewAction($requestMock, $token); + $webViewService = $this->createMock(WebViewServiceInterface::class); + $webViewService->method('getDummyVarsFor')->willReturn(['name' => 'Dominik']); + + $templateProvider = $this->createTemplateProviderMock(); + $templateProvider->method('addTemplateVariablesFor')->willReturnArgument(1); + $templateProvider->method('makeImagePathsWebRelative')->willReturnArgument(0); + $templateProvider->method('addTemplateSnippetsWithImagesFor')->willReturnArgument(1); + $templateProvider->method('getCampaignParamsFor')->willReturn([]); + + $controller = $this->createController( + webViewService: $webViewService, + templateProvider: $templateProvider, + twigTemplates: ['@App/Email/message.txt.twig' => 'Hello {{ name }}'], + ); + + $response = $controller->webPreViewAction(new Request(), '@App/Email/message', 'txt'); + + self::assertSame('Hello Dominik', $response->getContent()); + self::assertStringStartsWith('text/plain', (string) $response->headers->get('Content-Type')); } - /** - * @expectedException \Symfony\Component\Security\Core\Exception\AccessDeniedException - */ - public function testWebViewAction_Anonymous_Access_denied() + public function testPublicStoredEmailCanBeViewed(): void { - $token = 'fdasdfasfafsadf'; - $sentEmail = new SentEmail(); - $sentEmail->setRecipients(array('someuser@email.com')); - $sentEmail->setSent(new \DateTime('2 weeks ago')); - $sentEmail->setTemplate(AzineTemplateProvider::NEWSLETTER_TEMPLATE); - $sentEmail->setVariables(array()); - $sentEmail->setToken($token); - $repositoryMock = $this->getMockBuilder("Azine\EmailBundle\Entity\Repositories\SentEmailRepository")->disableOriginalConstructor()->setMethods(array('findOneByToken'))->getMock(); - $repositoryMock->expects($this->once())->method('findOneByToken')->will($this->returnValue($sentEmail)); - $doctrineManagerMock = $this->getMockBuilder("Doctrine\ORM\EntityManagerMock")->disableOriginalConstructor()->getMock(); - $doctrineManagerRegistryMock = $this->getMockBuilder("Doctrine\Persistence\ManagerRegistry")->disableOriginalConstructor()->getMock(); - $doctrineManagerRegistryMock->expects($this->once())->method('getRepository')->with('AzineEmailBundle:SentEmail')->will($this->returnValue($repositoryMock)); - $securityTokenMock = $this->getMockBuilder('stdClass')->addMethods(array('getUser'))->getMock(); - $securityTokenMock->expects($this->once())->method('getUser')->will($this->returnValue(null)); - $tokenStorageMock = $this->getMockBuilder("Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface")->disableOriginalConstructor()->getMock(); - $tokenStorageMock->expects($this->once())->method('getToken')->will($this->returnValue($securityTokenMock)); - $translatorMock = $this->getMockBuilder("Symfony\Bundle\FrameworkBundle\Translation\Translator")->disableOriginalConstructor()->setMethods(array('trans'))->getMock(); - $translatorMock->expects($this->once())->method('trans')->will($this->returnValue('translation')); - $containerMock = $this->getMockBuilder("Symfony\Component\DependencyInjection\ContainerInterface")->disableOriginalConstructor()->getMock(); - $containerMock->expects($this->exactly(3))->method('get')->will($this->returnValueMap(array( - array('doctrine', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $doctrineManagerRegistryMock), - array('security.token_storage', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $tokenStorageMock), - array('translator', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $translatorMock), - ))); - $containerMock->expects($this->once())->method('has')->with('security.token_storage')->will($this->returnValue(true)); - $requestMock = $this->getMockBuilder("Symfony\Component\HttpFoundation\Request")->disableOriginalConstructor()->getMock(); - $controller = new AzineEmailTemplateController(); - $controller->setContainer($containerMock); - $controller->webViewAction($requestMock, $token); + $sentEmail = (new SentEmail()) + ->setToken('public-token') + ->setRecipients(null) + ->setTemplate('@App/Email/stored') + ->setVariables(['name' => 'Stored']); + + $templateProvider = $this->createTemplateProviderMock(); + $templateProvider->method('getWebViewTokenId')->willReturn('webViewToken'); + $templateProvider->method('getCampaignParamsFor')->willReturn([]); + + $controller = $this->createController( + templateProvider: $templateProvider, + sentEmail: $sentEmail, + twigTemplates: ['@App/Email/stored.html.twig' => 'Stored email: {{ name }}'], + ); + + $response = $controller->webViewAction(new Request(), 'public-token'); + + self::assertSame('Stored email: Stored', $response->getContent()); } - public function testWebViewAction_Admin_with_CampaignParams() + public function testPrivateStoredEmailRejectsUnrelatedUser(): void { - $token = 'fdasdfasfafsadf'; - $twigMock = $this->getMockBuilder("Symfony\Bundle\TwigBundle\TwigEngine")->disableOriginalConstructor()->getMock(); - $twigMock->expects($this->once())->method('renderResponse')->will($this->returnCallback(array($this, 'renderResponseCallback'))); - $userMock = $this->getMockBuilder('stdClass')->addMethods(array('getEmail', 'hasRole'))->getMock(); - $userMock->expects($this->once())->method('getEmail')->will($this->returnValue('admin@email.com')); - $userMock->expects($this->once())->method('hasRole')->with('ROLE_ADMIN')->will($this->returnValue(true)); - $sentEmail = new SentEmail(); - $sentEmail->setRecipients(array('a-user@email.com')); - $sentEmail->setSent(new \DateTime('2 weeks ago')); - $sentEmail->setTemplate(AzineTemplateProvider::NEWSLETTER_TEMPLATE); - $sentEmail->setVariables(array()); - $sentEmail->setToken($token); - $repositoryMock = $this->getMockBuilder("Azine\EmailBundle\Entity\Repositories\SentEmailRepository")->disableOriginalConstructor()->setMethods(array('findOneByToken'))->getMock(); - $repositoryMock->expects($this->once())->method('findOneByToken')->will($this->returnValue($sentEmail)); - $doctrineManagerMock = $this->getMockBuilder("Doctrine\ORM\EntityManagerMock")->disableOriginalConstructor()->getMock(); - $doctrineManagerRegistryMock = $this->getMockBuilder("Doctrine\Persistence\ManagerRegistry")->disableOriginalConstructor()->getMock(); - $doctrineManagerRegistryMock->expects($this->once())->method('getRepository')->with('AzineEmailBundle:SentEmail')->will($this->returnValue($repositoryMock)); - $doctrineManagerRegistryMock->expects($this->once())->method('getManager')->will($this->returnValue($this->returnValue($doctrineManagerMock))); - $securityTokenMock = $this->getMockBuilder('stdClass')->addMethods(array('getUser'))->getMock(); - $securityTokenMock->expects($this->exactly(2))->method('getUser')->will($this->returnValue($userMock)); - $tokenStorageMock = $this->getMockBuilder("Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface")->disableOriginalConstructor()->getMock(); - $tokenStorageMock->expects($this->once())->method('getToken')->will($this->returnValue($securityTokenMock)); - $translatorMock = $this->getMockBuilder("Symfony\Bundle\FrameworkBundle\Translation\Translator")->disableOriginalConstructor()->getMock(); - $translatorMock->expects($this->any())->method('trans')->will($this->returnArgument(0)); - $templateProviderMock = $this->getMockBuilder("Azine\EmailBundle\Services\AzineTemplateProvider")->disableOriginalConstructor()->getMock(); - $templateProviderMock->expects($this->once())->method('getWebViewTokenId')->will($this->returnValue('tokenId')); - $templateProviderMock->expects($this->once())->method('getCampaignParamsFor')->will($this->returnValue(array('campaign' => 'newsletter', 'keyword' => '2013-11-19'))); - $emailTwigExtension = new AzineEmailTwigExtension($templateProviderMock, $translatorMock, array('testurl.com')); - $containerMock = $this->getMockBuilder("Symfony\Component\DependencyInjection\ContainerInterface")->disableOriginalConstructor()->getMock(); - $containerMock->expects($this->exactly(6))->method('get')->will($this->returnValueMap(array( - array('azine_email_template_provider', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $templateProviderMock), - array('templating', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $twigMock), - array('doctrine', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $doctrineManagerRegistryMock), - array('security.token_storage', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $tokenStorageMock), - array('azine.email.bundle.twig.filters', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $emailTwigExtension), - ))); - $containerMock->expects($this->once())->method('has')->with('security.token_storage')->will($this->returnValue(true)); - $requestMock = $this->getMockBuilder("Symfony\Component\HttpFoundation\Request")->disableOriginalConstructor()->getMock(); - $controller = new AzineEmailTemplateController(); - $controller->setContainer($containerMock); - $response = $controller->webViewAction($requestMock, $token); - $this->assertStringContainsString('http://testurl.com/?campaign=newsletter&keyword=2013-11-19', $response->getContent()); - $this->assertStringContainsString('http://testurl.com/with/?param=1&campaign=newsletter&keyword=2013-11-19', $response->getContent()); + $sentEmail = (new SentEmail()) + ->setToken('private-token') + ->setRecipients(['recipient@example.com']) + ->setTemplate('@App/Email/stored') + ->setVariables([]); + + $user = new class implements SecurityUserInterface { + public function getEmail(): string + { + return 'other@example.com'; + } + + public function getUserIdentifier(): string + { + return $this->getEmail(); + } + + public function getRoles(): array + { + return ['ROLE_USER']; + } + + public function eraseCredentials(): void + { + } + }; + $token = $this->createMock(TokenInterface::class); + $token->method('getUser')->willReturn($user); + $tokenStorage = $this->createMock(TokenStorageInterface::class); + $tokenStorage->method('getToken')->willReturn($token); + + $this->expectException(AccessDeniedException::class); + + $this->createController( + sentEmail: $sentEmail, + tokenStorage: $tokenStorage, + twigTemplates: ['@App/Email/stored.html.twig' => 'private'], + )->webViewAction(new Request(), 'private-token'); } - public function testWebViewAction_MailNotFound() + public function testUnavailableStoredEmailReturns404(): void { - $token = 'fdasdfasfafsadf-not-found'; - $twigMock = $this->getMockBuilder("Symfony\Bundle\TwigBundle\TwigEngine")->disableOriginalConstructor()->getMock(); - $twigMock->expects($this->once())->method('renderResponse')->will($this->returnCallback(array($this, 'renderResponseCallback'))); - $repositoryMock = $this->getMockBuilder("Azine\EmailBundle\Entity\Repositories\SentEmailRepository")->disableOriginalConstructor()->setMethods(array('findOneByToken'))->getMock(); - $repositoryMock->expects($this->once())->method('findOneByToken')->will($this->returnValue(null)); - $doctrineManagerRegistryMock = $this->getMockBuilder("Doctrine\Persistence\ManagerRegistry")->disableOriginalConstructor()->getMock(); - $doctrineManagerRegistryMock->expects($this->once())->method('getRepository')->with('AzineEmailBundle:SentEmail')->will($this->returnValue($repositoryMock)); - $containerMock = $this->getMockBuilder("Symfony\Component\DependencyInjection\ContainerInterface")->disableOriginalConstructor()->getMock(); - $containerMock->expects($this->once())->method('getParameter')->with('azine_email_web_view_retention')->will($this->returnValue(123)); - $containerMock->expects($this->exactly(2))->method('get')->will($this->returnValueMap(array( - array('templating', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $twigMock), - array('doctrine', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $doctrineManagerRegistryMock), - ))); - $requestMock = $this->getMockBuilder("Symfony\Component\HttpFoundation\Request")->disableOriginalConstructor()->getMock(); - $controller = new AzineEmailTemplateController(); - $controller->setContainer($containerMock); - $controller->webViewAction($requestMock, $token); + $controller = $this->createController(twigTemplates: [ + '@AzineEmail/Webview/mail.not.available.html.twig' => 'Unavailable after {{ days }} days', + ]); + + $response = $controller->webViewAction(new Request(), 'missing'); + + self::assertSame(Response::HTTP_NOT_FOUND, $response->getStatusCode()); + self::assertSame('Unavailable after 90 days', $response->getContent()); } - public function testServeImageAction() + public function testServeImageReturnsInlineFileAndRejectsTraversal(): void { - $folderKey = 'asdfadfasfasfd'; - $filename = 'testImage.png'; - $templateProviderMock = $this->getMockBuilder("Azine\EmailBundle\Services\AzineTemplateProvider")->disableOriginalConstructor()->getMock(); - $templateProviderMock->expects($this->exactly(1))->method('getFolderFrom')->with($folderKey)->will($this->returnValue(__DIR__.'/')); - $containerMock = $this->getMockBuilder("Symfony\Component\DependencyInjection\ContainerInterface")->disableOriginalConstructor()->getMock(); - $containerMock->expects($this->exactly(1))->method('get')->will($this->returnValueMap(array( - array('azine_email_template_provider', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $templateProviderMock), - ))); - $requestMock = $this->getMockBuilder("Symfony\Component\HttpFoundation\Request")->disableOriginalConstructor()->getMock(); - $controller = new AzineEmailTemplateController(); - $controller->setContainer($containerMock); - $response = $controller->serveImageAction($requestMock, $folderKey, $filename); - $this->assertSame('image', $response->headers->get('Content-Type')); - - if (Kernel::VERSION_ID < 40102) { - $this->assertSame('inline; filename="'.$filename.'"', $response->headers->get('Content-Disposition')); - } else { - $this->assertSame('inline; filename='.$filename, $response->headers->get('Content-Disposition')); + $folder = sys_get_temp_dir().'/azine-email-test-'.bin2hex(random_bytes(4)); + mkdir($folder); + file_put_contents($folder.'/logo.png', 'image-data'); + + try { + $templateProvider = $this->createTemplateProviderMock(); + $templateProvider->method('getFolderFrom')->with('templates')->willReturn($folder.'/'); + $controller = $this->createController(templateProvider: $templateProvider); + + $response = $controller->serveImageAction(new Request(), 'templates', 'logo.png'); + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertStringContainsString('inline', (string) $response->headers->get('Content-Disposition')); + + $this->expectException(\Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException::class); + $controller->serveImageAction(new Request(), 'templates', '../secret.txt'); + } finally { + @unlink($folder.'/logo.png'); + @rmdir($folder); } } - /** - * @expectedException \Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException - */ - public function testServeImageAction_404() + public function testSendTestEmailAddsSpamAndDeliveryFlashes(): void { - $folderKey = 'asdfadfasfasfd'; - $filename = 'testImage.not.found.png'; - $templateProviderMock = $this->getMockBuilder("Azine\EmailBundle\Services\AzineTemplateProvider")->disableOriginalConstructor()->getMock(); - $templateProviderMock->expects($this->exactly(1))->method('getFolderFrom')->with($folderKey)->will($this->returnValue(false)); - $containerMock = $this->getMockBuilder("Symfony\Component\DependencyInjection\ContainerInterface")->disableOriginalConstructor()->getMock(); - $containerMock->expects($this->exactly(1))->method('get')->will($this->returnValueMap(array( - array('azine_email_template_provider', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $templateProviderMock), - ))); - $requestMock = $this->getMockBuilder("Symfony\Component\HttpFoundation\Request")->disableOriginalConstructor()->getMock(); - $controller = new AzineEmailTemplateController(); - $controller->setContainer($containerMock); - $controller->serveImageAction($requestMock, $folderKey, $filename); + $webViewService = $this->createMock(WebViewServiceInterface::class); + $webViewService->method('getDummyVarsFor')->willReturn([ + 'subject' => 'Test subject', + 'sendMailAccountAddress' => 'sender@example.com', + 'sendMailAccountName' => 'Sender', + ]); + + $mailer = $this->createMock(TemplateTwigMailerInterface::class); + $mailer + ->expects(self::once()) + ->method('sendSingleEmail') + ->with( + self::callback(static fn (array $recipients): bool => array_key_exists('recipient@example.com', $recipients)), + null, + 'Test subject', + self::isType('array'), + '@App/Email/test.txt.twig', + 'en', + 'sender@example.com', + 'Sender (Test)', + self::isInstanceOf(Email::class), + ) + ->willReturn(true); + + $spamCheck = $this->createMock(SpamCheckService::class); + $spamCheck->method('checkMessage')->willReturn([ + 'success' => true, + 'curlHttpCode' => 200, + 'score' => 1.2, + 'report' => 'Looks good', + 'message' => '-', + ]); + + $translator = $this->createMock(TranslatorInterface::class); + $translator->method('trans')->willReturn('Mail sent'); + $router = $this->createMock(RouterInterface::class); + $router->method('generate')->willReturn('/email/templates?customEmail=recipient@example.com'); + + $controller = $this->createController( + webViewService: $webViewService, + mailer: $mailer, + spamCheckService: $spamCheck, + translator: $translator, + router: $router, + ); + $request = new Request(); + $request->setLocale('en'); + $request->setSession(new Session(new MockArraySessionStorage())); + + $response = $controller->sendTestEmailAction( + $request, + '@App/Email/test', + 'recipient@example.com', + ); + + self::assertSame('/email/templates?customEmail=recipient@example.com', $response->getTargetUrl()); + $flashes = $request->getSession()->getFlashBag()->all(); + self::assertSame(2, array_sum(array_map('count', $flashes))); } - public function testSendTestEmailAction() + public function testSpamScoreAjaxReturnsFormattedReport(): void { - if (null !== static::$kernel) { - static::$kernel->shutdown(); - } - try { - static::$kernel = static::createKernel(array()); - } catch (\RuntimeException $ex) { - $this->markTestSkipped('There does not seem to be a full application available (e.g. running tests on travis.org). So this test is skipped.'); + $spamCheck = $this->createMock(SpamCheckService::class); + $spamCheck + ->expects(self::once()) + ->method('checkRawMessage') + ->with('raw message') + ->willReturn([ + 'success' => true, + 'curlHttpCode' => 200, + 'score' => 3.1, + 'report' => 'Some warnings', + 'message' => '-', + ]); - return; - } - static::$kernel->boot(); - $container = static::$kernel->getContainer(); - $spoolDir = $container->getParameter('swiftmailer.spool.defaultMailer.file.path'); - // delete all spooled mails from other tests - array_map('unlink', glob($spoolDir.'/*.messag*')); - array_map('unlink', glob($spoolDir.'/.*.messag*')); - $context = new RequestContext('/app.php'); - $context->setParameter('_locale', 'en'); - $router = $container->get('router'); - $router->setContext($context); - $to = md5(time().'to').'@email.non-existent.to.mail.domain.com'; - $uri = $router->generate('azine_email_send_test_email', array('template' => AzineTemplateProvider::NEWSLETTER_TEMPLATE, 'email' => $to)); - $container->set('request', Request::create($uri, 'GET')); - // "login" a user - $token = new UsernamePasswordToken('username', 'password', 'main'); - $recipientProvider = $container->get('azine_email_recipient_provider'); - $users = $recipientProvider->getNewsletterRecipientIDs(); - $token->setUser($recipientProvider->getRecipient($users[0])); - $container->get('security.token_storage')->setToken($token); - $container->get('request')->setSession(new Session(new MockFileSessionStorage())); - // instantiate the controller and try to send the email - $controller = new AzineEmailTemplateController(); - $controller->setContainer($container); - $response = $controller->sendTestEmailAction($container->get('request'), AzineTemplateProvider::NEWSLETTER_TEMPLATE, $to); - $this->assertSame(302, $response->getStatusCode(), 'Status-Code 302 expected.'); - $uri = $router->generate('azine_email_template_index'); - $this->assertStringContainsString("Redirecting to $uri", $response->getContent(), 'Redirect expected.'); - $findInFile = new FindInFileUtil(); - $findInFile->excludeMode = false; - $findInFile->formats = array('.message'); - $this->assertSame(1, sizeof($findInFile->find($spoolDir, 'This is just the default content-block.'))); - $this->assertSame(1, sizeof($findInFile->find($spoolDir, 'Add some html content here'))); + $request = new Request([], ['emailSource' => 'raw message']); + $response = $this->createController(spamCheckService: $spamCheck) + ->checkSpamScoreOfSentEmailAction($request); + + self::assertStringContainsString('SpamScore: 3.1', (string) $response->getContent()); } - public function testGetSpamIndexReportForSwiftMessage() - { - $swiftMessage = new \Swift_Message(); - $swiftMessage->setFrom('from@email.com'); - $swiftMessage->setTo('to@email.com'); - $swiftMessage->setSubject('a subject.'); - $swiftMessage->addPart('Hello dude, -================================================================================ -Add some content here -This is just the default content-block. -Best regards, -the azine team -________________________________________________________________________________ -azine ist ein Service von Azine IT Services AG -© 2013 by Azine IT Services AG -Füge "no-reply@some.host.com" zu deinem Adressbuch hinzu, um den Empfang von azine Mails sicherzustellen. -- Help / FAQs : https://some.host.com/app_dev.php/de/help -- AGB : https://some.host.com/app_dev.php/de/terms -- Über azine: https://some.host.com/app_dev.php/de/about -- Kontakt : https://some.host.com/app_dev.php/de/contact - ', 'text/plain'); - $swiftMessage->setBody("azine –
   
 \"azine\" -  
IT-Rekrutierung von morgen... weil du die beste Besetzung verdienst.
 
 Hallo dude,

- Add some content here -

- This is just the default content-block. -

- Freundliche Grüsse und bis bald, -
dein azine Team

 
 \"azine\" 
 

azine ist ein Service angeboten von Azine IT Services AG. -

- Füge \"no-reply@some.host.com\" zu deinem Adressbuch hinzu, um den Empfang von azine Mails sicherzustellen. -

- © 2013 by Azine IT Services AG -

Hilfe / FAQs | - AGB | - Über azine | - Kontakt

 
-", 'text/html'); - $controller = new AzineEmailTemplateController(); - $report = $controller->getSpamIndexReportForSwiftMessage($swiftMessage); - if (array_key_exists('curlError', $report)) { - $this->markTestIncomplete("It seems postmarks spam-check-service is unresponsive.\n\n".print_r($report, true)); + private function createController( + ?WebViewServiceInterface $webViewService = null, + ?TemplateProviderInterface $templateProvider = null, + ?TemplateTwigMailerInterface $mailer = null, + ?SpamCheckService $spamCheckService = null, + ?TokenStorageInterface $tokenStorage = null, + ?TranslatorInterface $translator = null, + ?RouterInterface $router = null, + ?SentEmail $sentEmail = null, + array $twigTemplates = [], + ): AzineEmailTemplateController { + $webViewService ??= $this->createMock(WebViewServiceInterface::class); + $templateProvider ??= $this->createTemplateProviderMock(); + $mailer ??= $this->createMock(TemplateTwigMailerInterface::class); + $spamCheckService ??= $this->createMock(SpamCheckService::class); + $tokenStorage ??= $this->createMock(TokenStorageInterface::class); + if (null === $translator) { + $translator = $this->createMock(TranslatorInterface::class); + $translator->method('trans')->willReturnArgument(0); } - $this->assertArrayHasKey('success', $report, "success was expected in report.\n\n".print_r($report, true)); - $this->assertArrayNotHasKey('curlError', $report, "curlError was not expected in report.\n\n".print_r($report, true)); - $this->assertArrayHasKey('message', $report, "message was expected in report.\n\n".print_r($report, true)); + $router ??= $this->createMock(RouterInterface::class); + + $repository = $this->getMockBuilder(EntityRepository::class) + ->disableOriginalConstructor() + ->onlyMethods(['findOneBy']) + ->getMock(); + $repository->method('findOneBy')->willReturn($sentEmail); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->method('getRepository')->willReturn($repository); + + $registry = $this->createMock(ManagerRegistry::class); + $registry->method('getManager')->willReturn($entityManager); + + $twig = new Environment(new ArrayLoader($twigTemplates)); + $twigExtension = $this->createMock(AzineEmailTwigExtension::class); + $twigExtension + ->method('addCampaignParamsToAllUrls') + ->willReturnArgument(0); + + return new AzineEmailTemplateController( + $webViewService, + $templateProvider, + $mailer, + $spamCheckService, + $twig, + $twigExtension, + $registry, + $tokenStorage, + $translator, + $router, + null, + ['email' => 'no-reply@azine.me', 'name' => 'Azine Mailer'], + 90, + ); } - public function testCheckSpamScoreOfSentEmailAction() + private function createTemplateProviderMock(): TemplateProviderInterface { - $requestMock = $this->getMockBuilder("Symfony\Component\HttpFoundation\Request")->disableOriginalConstructor()->setMethods(array('get'))->getMock(); - $containerMock = $this->getMockBuilder("Symfony\Component\DependencyInjection\ContainerInterface")->disableOriginalConstructor()->getMock(); - $containerMock->expects($this->once())->method('get')->will($this->returnValueMap(array( - array('request', ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE, $requestMock), ))); - $controller = new AzineEmailTemplateController(); - $controller->setContainer($containerMock); - $jsonResponse = $controller->checkSpamScoreOfSentEmailAction($requestMock); - $json = $jsonResponse->getContent(); - if (false !== strpos($json, 'Getting the spam-info failed')) { - $this->markTestIncomplete("It seems postmarks spam-check-service is unresponsive.\n\n$json"); - } - $this->assertStringNotContainsString('Getting the spam-info failed.', $jsonResponse->getContent(), "Spamcheck returned:\n".$jsonResponse->getContent()); - $this->assertStringContainsString('SpamScore', $jsonResponse->getContent()); + $provider = $this->createMock(TemplateProviderInterface::class); + $provider->method('getWebViewTokenId')->willReturn('webViewToken'); + $provider->method('getCampaignParamsFor')->willReturn([]); + + return $provider; } } diff --git a/Tests/DependencyInjection/AzineEmailExtensionTest.php b/Tests/DependencyInjection/AzineEmailExtensionTest.php index 1b537ac3..ab1c631c 100644 --- a/Tests/DependencyInjection/AzineEmailExtensionTest.php +++ b/Tests/DependencyInjection/AzineEmailExtensionTest.php @@ -1,231 +1,153 @@ getMinimalConfig(); - $loader->load(array($config), new ContainerBuilder()); - $this->assertTrue(true, 'Without this stupid assertion, PHPUnit classifies this test as risky'); - } - - /** - * This should not throw an exception. - */ - public function testFullConfig() - { - $loader = new AzineEmailExtension(); - $config = $this->getFullConfig(); - $loader->load(array($config), new ContainerBuilder()); - $this->assertTrue(true, 'Without this stupid assertion, PHPUnit classifies this test as risky'); - } - - /** - * This should throw an exception. - * - * @expectedException \Symfony\Component\Config\Definition\Exception\InvalidConfigurationException - */ - public function testConfigWithMissingRecipientClass() - { - $loader = new AzineEmailExtension(); - $config = $this->getFullConfig(); - unset($config['recipient_class']); - $loader->load(array($config), new ContainerBuilder()); - } - - /** - * This should throw an exception. - * - * @expectedException \Symfony\Component\Config\Definition\Exception\InvalidConfigurationException - */ - public function testConfigWithMissingTemplateProvider() - { - $loader = new AzineEmailExtension(); - $config = $this->getFullConfig(); - unset($config['template_provider']); - $loader->load(array($config), new ContainerBuilder()); - } - - /** - * This should throw an exception. - * - * @expectedException \Symfony\Component\Config\Definition\Exception\InvalidConfigurationException - */ - public function testConfigWithMissingEmailAddress() - { - $loader = new AzineEmailExtension(); - $config = $this->getFullConfig(); - unset($config['no_reply']['email']); - $loader->load(array($config), new ContainerBuilder()); - } - - /** - * This should throw an exception. - * - * @expectedException \Symfony\Component\Config\Definition\Exception\InvalidConfigurationException - */ - public function testConfigWithMissingEmailName() - { - $loader = new AzineEmailExtension(); - $config = $this->getFullConfig(); - unset($config['no_reply']['name']); - $loader->load(array($config), new ContainerBuilder()); - } - - /** - * This should throw an exception. - * - * @expectedException \Symfony\Component\Config\Definition\Exception\InvalidConfigurationException - */ - public function testConfigWithMissingEmail() - { - $loader = new AzineEmailExtension(); - $config = $this->getFullConfig(); - unset($config['no_reply']); - $loader->load(array($config), new ContainerBuilder()); - } - - public function testCustomConfiguration() - { - $this->configuration = new ContainerBuilder(); - $loader = new AzineEmailExtension(); - $config = $this->getFullConfig(); - $config['recipient_class'] = 'TestRecipientClass'; - $config['recipient_newsletter_field'] = 'some_field'; - $config['template_provider'] = 'TestTemplateProvider'; - $config['notifier_service'] = 'TestNotifierService'; - $config['recipient_provider'] = 'TestRecipientService'; - $config['template_twig_swift_mailer'] = 'TestTwigSwiftMailer'; - $config['no_reply']['email'] = 'test@email.com'; - $config['no_reply']['name'] = 'test name'; - $config['image_dir'] = '/tmp'; - - $loader->load(array($config), $this->configuration); - - $this->assertParameter('TestRecipientClass', 'azine_email_recipient_class'); - $this->assertParameter('some_field', 'azine_email_recipient_newsletter_field'); - - $mailArray = $this->configuration->getParameter('azine_email_no_reply'); - $this->assertSame('test name', $mailArray['name'], 'The no-reply-name is not correct.'); - $this->assertSame('test@email.com', $mailArray['email'], 'The no-reply-email is not correct.'); - - $this->assertParameter('/tmp', 'azine_email_image_dir'); - - $this->assertAlias('testtemplateprovider', 'azine_email_template_provider'); - $this->assertAlias('testnotifierservice', 'azine_email_notifier_service'); - $this->assertAlias('testrecipientservice', 'azine_email_recipient_provider'); - $this->assertAlias('testtwigswiftmailer', 'azine_email_template_twig_swift_mailer'); - } - - protected function createEmptyConfiguration() - { - $this->configuration = new ContainerBuilder(); - $loader = new AzineEmailExtension(); - $config = $this->getEmptyConfig(); - $loader->load(array($config), $this->configuration); - $this->assertTrue($this->configuration instanceof ContainerBuilder); - } - - protected function createFullConfiguration() - { - $this->configuration = new ContainerBuilder(); - $loader = new AzineEmailExtension(); - $config = $this->getFullConfig(); - $loader->load(array($config), $this->configuration); - $this->assertTrue($this->configuration instanceof ContainerBuilder); - } - - /** - * Get the minimal config. - * - * @return array - */ - protected function getMinimalConfig() - { - $yaml = <<assertSame(strtolower($value), strtolower((string) $this->configuration->getAlias($key)), sprintf('%s alias is correct', $key)); - } - - /** - * @param string $value - * @param string $key - */ - private function assertParameter($value, $key) - { - $this->assertSame($value, $this->configuration->getParameter($key), sprintf('%s parameter is correct', $key)); - } - - /** - * @param string $id - */ - private function assertHasDefinition($id) - { - $this->assertTrue(($this->configuration->hasDefinition($id) ?: $this->configuration->hasAlias($id))); - } - - /** - * @param string $id - */ - private function assertNotHasDefinition($id) - { - $this->assertFalse(($this->configuration->hasDefinition($id) ?: $this->configuration->hasAlias($id))); - } - - protected function tearDown(): void - { - unset($this->configuration); + public function testDefaultConfigurationLoadsUsableServiceAliases(): void + { + $container = $this->load([]); + + self::assertSame( + 'Acme\\SomeBundle\\Entity\\User', + $container->getParameter('azine_email_recipient_class'), + ); + self::assertSame( + 'azine_email.example.template_provider', + (string) $container->getAlias('azine_email_template_provider'), + ); + self::assertSame( + 'azine_email.default.template_twig_mailer', + (string) $container->getAlias('azine_email_template_twig_mailer'), + ); + self::assertSame( + 'azine_email_template_twig_mailer', + (string) $container->getAlias('azine_email_template_twig_swift_mailer'), + ); + self::assertTrue($container->hasDefinition('azine_email.default.template_twig_mailer')); + self::assertTrue($container->hasDefinition('azine_email.command_lock_factory')); + } + + public function testMinimalConfigurationOverridesRequiredApplicationValues(): void + { + $container = $this->load([ + 'recipient_class' => 'Azine\\PlatformBundle\\Entity\\User', + 'template_provider' => 'azine_platform.emailtemplateprovider', + 'no_reply' => [ + 'email' => 'no-reply@azine.me', + 'name' => 'azine.me notification daemon', + ], + ]); + + self::assertSame( + 'Azine\\PlatformBundle\\Entity\\User', + $container->getParameter('azine_email_recipient_class'), + ); + self::assertSame( + 'azine_platform.emailtemplateprovider', + (string) $container->getAlias('azine_email_template_provider'), + ); + self::assertSame([ + 'email' => 'no-reply@azine.me', + 'name' => 'azine.me notification daemon', + ], $container->getParameter('azine_email_no_reply')); + } + + public function testCanonicalMailerOptionAndImmediateMailerAreWired(): void + { + $container = $this->load([ + 'template_twig_mailer' => 'app.custom_mailer', + 'immediate_mailer_service' => 'app.immediate_mailer', + ]); + + self::assertSame( + 'app.custom_mailer', + (string) $container->getAlias('azine_email_template_twig_mailer'), + ); + self::assertSame( + 'azine_email_template_twig_mailer', + (string) $container->getAlias('azine_email_template_twig_swift_mailer'), + ); + self::assertSame( + 'app.immediate_mailer', + (string) $container->getAlias('azine_email_immediate_mailer_service'), + ); + } + + public function testDeprecatedSwiftmailerOptionStillFeedsCanonicalAlias(): void + { + $container = $this->load([ + 'template_twig_mailer' => 'azine_email.default.template_twig_mailer', + 'template_twig_swift_mailer' => 'app.legacy_named_mailer', + ]); + + self::assertSame( + 'app.legacy_named_mailer', + (string) $container->getAlias('azine_email_template_twig_mailer'), + ); + self::assertSame( + 'azine_email_template_twig_mailer', + (string) $container->getAlias('azine_email_template_twig_swift_mailer'), + ); + } + + public function testFullCustomConfigurationIsRetained(): void + { + $container = $this->load([ + 'recipient_class' => 'TestRecipientClass', + 'recipient_newsletter_field' => 'some_field', + 'template_provider' => 'TestTemplateProvider', + 'notifier_service' => 'TestNotifierService', + 'recipient_provider' => 'TestRecipientService', + 'template_twig_mailer' => 'TestTwigMailer', + 'immediate_mailer_service' => 'TestImmediateMailer', + 'no_reply' => [ + 'email' => 'test@email.com', + 'name' => 'test name', + ], + 'image_dir' => '/tmp', + 'allowed_images_folders' => ['/tmp'], + 'newsletter' => [ + 'interval' => 7, + 'send_time' => '09:30', + ], + 'web_view_retention' => 45, + ]); + + self::assertSame('TestRecipientClass', $container->getParameter('azine_email_recipient_class')); + self::assertSame('some_field', $container->getParameter('azine_email_recipient_newsletter_field')); + self::assertSame('/tmp', $container->getParameter('azine_email_image_dir')); + self::assertSame(['/tmp'], $container->getParameter('azine_email_allowed_images_folders')); + self::assertSame(7, $container->getParameter('azine_email_newsletter_interval')); + self::assertSame('09:30', $container->getParameter('azine_email_newsletter_send_time')); + self::assertSame(45, $container->getParameter('azine_email_web_view_retention')); + self::assertSame('testtemplateprovider', strtolower((string) $container->getAlias('azine_email_template_provider'))); + self::assertSame('testnotifierservice', strtolower((string) $container->getAlias('azine_email_notifier_service'))); + self::assertSame('testrecipientservice', strtolower((string) $container->getAlias('azine_email_recipient_provider'))); + self::assertSame('testtwigmailer', strtolower((string) $container->getAlias('azine_email_template_twig_mailer'))); + self::assertSame('testimmediatemailer', strtolower((string) $container->getAlias('azine_email_immediate_mailer_service'))); + } + + public function testRejectsInvalidNewsletterTime(): void + { + $this->expectException(\Symfony\Component\Config\Definition\Exception\InvalidConfigurationException::class); + + $this->load([ + 'newsletter' => ['send_time' => '25:99'], + ]); + } + + private function load(array $config): ContainerBuilder + { + $container = new ContainerBuilder(); + (new AzineEmailExtension())->load([$config], $container); + + return $container; } } diff --git a/Tests/Entity/SentEmailTest.php b/Tests/Entity/SentEmailTest.php index 63ce0eb4..95f17695 100644 --- a/Tests/Entity/SentEmailTest.php +++ b/Tests/Entity/SentEmailTest.php @@ -11,7 +11,7 @@ public function testGetNewToken() $tockens = array(); while (sizeof($tockens) < 100) { $newToken = SentEmail::getNewToken(); - $this->assertStringNotContainsString($newToken, $tockens); + $this->assertNotContains($newToken, $tockens); $tockens[] = $newToken; } } diff --git a/Tests/Functional/EmailImagesInEmailAndWebViewTest.php b/Tests/Functional/EmailImagesInEmailAndWebViewTest.php index 818b5415..6e9cbd6b 100644 --- a/Tests/Functional/EmailImagesInEmailAndWebViewTest.php +++ b/Tests/Functional/EmailImagesInEmailAndWebViewTest.php @@ -1,303 +1,183 @@ uniqueId = md5(microtime().'_'.random_int(0, 1000)); + $imagePath = tempnam(sys_get_temp_dir(), 'azine-email-image-'); + self::assertIsString($imagePath); + file_put_contents($imagePath, file_get_contents(__DIR__.'/../../Resources/htmlTemplateImages/logo.png')); - // make sure there is an application - $this->checkApplication(); - - $this->appContainer = $this->getKernel()->getContainer(); - - // empty the spool directory - $this->cleanMailSpoolDirectory(); - - // create the test-recipient/user for this test - $this->testRecipient = $this->getTestRecipient(); - - // copy sample-image to all allowed image folders - $allowedImageFolders = $this->appContainer->getParameter('azine_email_allowed_images_folders'); - $allowedImageFolders[] = $this->appContainer->getParameter('azine_email_image_dir'); - $allowedImageFolders = array_unique($allowedImageFolders); - $testImage = $this->uniqueId.'-test_image.jpg'; - foreach ($allowedImageFolders as $nextAllowedImageFolder) { - $targetFile = realpath($nextAllowedImageFolder).'/'.$testImage; - copy(__DIR__.'/../../Resources/htmlTemplateImages/logo.png', $targetFile); - $this->testImages[] = $targetFile; + try { + $sentMessage = null; + $transport = $this->createMock(MailerInterface::class); + $transport + ->expects(self::once()) + ->method('send') + ->willReturnCallback(static function (Email $message) use (&$sentMessage): void { + $sentMessage = $message; + }); + + $storedWebView = null; + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager + ->expects(self::once()) + ->method('persist') + ->with(self::callback(static function (SentEmail $email) use (&$storedWebView): bool { + $storedWebView = $email; + + return true; + })); + $entityManager->expects(self::once())->method('flush'); + $entityManager->expects(self::once())->method('clear'); + + $registry = $this->createMock(ManagerRegistry::class); + $registry->method('getManager')->willReturn($entityManager); + + $router = $this->createMock(RouterInterface::class); + $router->method('getContext')->willReturn(new RequestContext()); + + $translator = $this->createMock(TranslatorInterface::class); + $translator->method('getLocale')->willReturn('en'); + + $provider = new ImageWebViewTemplateProvider($imagePath); + $twig = new Environment(new ArrayLoader([ + 'test.txt.twig' => <<<'TWIG' +{% block body_text %}Image email for {{ name }}{% endblock %} +{% block body_html %}logo{{ name }}{% endblock %} +TWIG, + ])); + + $mailer = new AzineTwigMailer( + $transport, + $router, + $twig, + $translator, + $provider, + $registry, + null, + new AzineEmailTwigExtension($provider, $translator), + [ + AzineEmailExtension::NO_REPLY => [ + AzineEmailExtension::NO_REPLY_EMAIL_ADDRESS => 'no-reply@azine.me', + AzineEmailExtension::NO_REPLY_EMAIL_NAME => 'Azine Mailer', + ], + 'template' => [ + 'confirmation' => 'test.txt.twig', + 'resetting' => 'test.txt.twig', + 'email_updating' => 'test.txt.twig', + ], + 'from_email' => [ + 'confirmation' => ['address' => 'no-reply@azine.me', 'sender_name' => 'Azine Mailer'], + 'resetting' => ['address' => 'no-reply@azine.me', 'sender_name' => 'Azine Mailer'], + ], + ], + ); + + $message = null; + self::assertTrue($mailer->sendSingleEmail( + 'recipient@example.com', + 'Recipient', + 'Embedded image test', + ['name' => 'Dominik', 'image' => $imagePath], + 'test.txt.twig', + 'en', + message: $message, + )); + + self::assertSame($sentMessage, $message); + self::assertInstanceOf(Email::class, $sentMessage); + self::assertStringContainsString('src="cid:azine-', (string) $sentMessage->getHtmlBody()); + self::assertCount(1, $sentMessage->getAttachments()); + + self::assertInstanceOf(SentEmail::class, $storedWebView); + self::assertSame(['recipient@example.com'], $storedWebView->getRecipients()); + self::assertSame('/email/web-images/logo.png', $storedWebView->getVariables()['image']); + self::assertNotEmpty($storedWebView->getToken()); + } finally { + @unlink($imagePath); } } +} - public function testImagesEmbededAndReferencedInEmailAndImagesReferencedInWebView() +final class ImageWebViewTemplateProvider implements TemplateProviderInterface +{ + public function __construct(private readonly string $imagePath) { - $uniqueSubject = 'email-subject-for-test-case-'.$this->uniqueId; - $notification = new Notification(); - $notification->setCreatedValue(); - $notification->setContent('content for'.$uniqueSubject); - $notification->setTitle('title for '.$uniqueSubject); - $notification->setTemplate(AzineTemplateProvider::CONTENT_ITEM_MESSAGE_TEMPLATE); - $notification->setRecipientId($this->testRecipient->getId()); - $notification->setSendImmediately(false); - $notification->setImportance(Notification::IMPORTANCE_NORMAL); - $em = $this->getEntityManager(); - $em->persist($notification); - $em->flush($notification); - $em->refresh($notification); - - $contentItems = array(array(AzineTemplateProvider::CONTENT_ITEM_MESSAGE_TEMPLATE => array('notification' => $notification))); - - $this->sendEmail($contentItems, $uniqueSubject); - $this->verifyEmbedding(); - $this->verifyWebView(); } - public function testImagesFromAllConfiguredAllowedFolders() + public function addTemplateVariablesFor($template, array $contentVariables) { - // create template/content-item to show all images - $contentItems = array(); - foreach ($this->testImages as $testImage) { - $contentItems[] = array('AzineEmailBundle:contentItem:image-test-message' => array('title' => "message for $testImage", 'test_image' => $testImage, 'original_location' => "file: $testImage")); - } - - $this->sendEmail($contentItems, 'email-subject-for-test-case-'.$this->uniqueId); - $this->verifyEmbedding(); - $this->verifyWebView(); + return $contentVariables; } - public function tearDown(): void + public function addTemplateSnippetsWithImagesFor($template, array $vars, $emailLocale, $forWebView = false) { - // revert the test-User password & salt - $this->testRecipient->setPassword($this->originalUserPassword); - $this->testRecipient->setSalt($this->originalUserSalt); - $this->getEntityManager()->flush(); - - // remove sample-image from all folders - foreach ($this->testImages as $testImage) { - unlink($testImage); - } + return $vars; } - private function sendEmail(array $contentItems, string $subjectLine) + public function addCustomHeaders($template, $message, array $params): void { - $notifierService = $this->appContainer->get('azine_email_notifier_service'); - - $params = array('subject' => $subjectLine); - $params = array_merge($params, $notifierService->getRecipientSpecificNewsletterParams($this->testRecipient)); - $params[AzineNotifierService::CONTENT_ITEMS] = $contentItems; - - $newsletterTemplate = 'AzineEmailBundle::newsletterEmailLayout'; - $notifierService->sendNewsletterFor($this->testRecipient, $params, $newsletterTemplate); } - private function verifyWebView() + public function getTemplateImageDir() { - // find the webViewToken for the sent email & check the images in the webView - /** @var SentEmail $sentEmail */ - $sentEmails = $this->getEntityManager()->getRepository(SentEmail::class)->createQueryBuilder('e') - ->where('e.recipients like :recipientEmail and e.variables like :uniqueId') - ->setParameter('recipientEmail', '%'.$this->testRecipient->getEmail().'%') - ->setParameter('uniqueId', '%'.$this->uniqueId.'%') - ->getQuery() - ->execute(); - $this->assertSame(1, sizeof($sentEmails)); - $sentEmail = $sentEmails[0]; - - /** @var RouterInterface $router */ - $router = $this->appContainer->get('router'); - $router->getContext()->setParameter('_locale', $this->testRecipient->getPreferredLocale()); - $router->getContext()->setPathInfo('/'); - $webViewUrl = TestHelper::makeAbsolutPath($router->generate('azine_email_webview', array('token' => $sentEmail->getToken()), RouterInterface::ABSOLUTE_PATH),$this->testRecipient->getPreferredLocale()); - - $client = static::createClient(); - $client->followRedirects(); - - // browse to webView - $this->loginTestUserIfRequired($client, $webViewUrl); - $crawler = $client->getCrawler(); - $this->assertSame(200, $client->getResponse()->getStatusCode()); - - // check that the page loaded correctly and the referenced image urls load as well - $urls = array(); - $crawler->filter('img')->each(function (Crawler $nextImage) use (&$urls) { - $urls[] = substr($nextImage->image()->getUri(), strpos($nextImage->image()->getUri(), '/en/')); - }); - - foreach ($urls as $nextUrl) { - $nextUrl = TestHelper::makeAbsolutPath($nextUrl, $this->testRecipient->getPreferredLocale()); - if(strpos($nextUrl, '/bundle/') == 0){ - $this->assertFileExists($this->getKernel()->getProjectDir()."/web".$nextUrl); - continue; - } - $imageCrawler = $client->request('GET', $nextUrl); - $statusCode = $client->getResponse()->getStatusCode(); - ($client->getRequest()->getUri()); - $this->assertSame(200, $statusCode, 'Image failed to load correctly'); - } + return dirname($this->imagePath).DIRECTORY_SEPARATOR; } - private function verifyEmbedding() + public function makeImagePathsWebRelative(array $emailVars, $locale) { - // find the source of the sent email & check images - $messageFiles = $this->findTextInSpooledTestEmail($this->uniqueId); - $this->assertSame(1, sizeof($messageFiles)); - $messageContent = file_get_contents($messageFiles[0]); - - /** @var \Swift_Message $sentSwiftMessage */ - $sentSwiftMessage = unserialize($messageContent); - $matches = array(); - preg_match_all('/cid:(.*?generated)/', $sentSwiftMessage->getBody(), $matches); - $children = $sentSwiftMessage->getChildren(); - foreach ($matches[1] as $match) { - $found = false; - foreach ($children as $child) { - if ($child instanceof \Swift_Image && $child->getId() == $match) { - $found = true; - break; - } + array_walk_recursive($emailVars, function (&$value): void { + if ($value === $this->imagePath) { + $value = '/email/web-images/logo.png'; } - $this->assertTrue($found, 'image not found as embedded.'); - } - } - - private function loginTestUserIfRequired(Client $client, $url) - { - $crawler = $client->request('GET', $url); - // login if required - if (false !== stripos($crawler->filter('title')->text(), 'login')) { - $form = $crawler->filter('form')->form(array('_username' => $this->testRecipient->getUsername(), '_password' => $this->uniqueId)); - $crawler = $client->submit($form); - $crawler = $client->request('GET', $url); - } - } - - /** - * @return EntityManager - */ - private function getEntityManager() - { - return $this->getKernel()->getContainer()->get('doctrine')->getManager(); - } - - /** - * Check if the current setup is a full application. - * If not, mark the test as skipped else continue. - */ - private function checkApplication() - { - try { - $this->getKernel(); - } catch (\RuntimeException $ex) { - $this->markTestSkipped('There does not seem to be a full application available (e.g. running tests on travis.org). So this test is skipped.'); + }); - return; - } + return $emailVars; } - /** - * Delete all files in the spool directory. - */ - private function cleanMailSpoolDirectory() + public function isFileAllowed($filePath) { - $files = glob($this->getSpoolDirectory().'/*'); - foreach ($files as $file) { - if (is_file($file)) { - unlink($file); - } - } + return realpath((string) $filePath) === realpath($this->imagePath); } - /** - * @return \AppKernel - */ - private function getKernel() + public function getFolderFrom($key) { - if (null == static::$kernel) { - static::$kernel = static::createKernel(); - static::$kernel->boot(); - } - - return static::$kernel; + return false; } - /** - * Search for a spooled email with the given string in its content. - * - * @param string $searchString - * - * @return array of files with the searchString - */ - private function findTextInSpooledTestEmail($searchString) + public function saveWebViewFor($template) { - $findInFile = new FindInFileUtil(); - $findInFile->excludeMode = false; - $findInFile->formats = array('.message'); - $result = $findInFile->find($this->getSpoolDirectory(), $searchString); - - return $result; + return true; } - /** - * Get the configured spool directory. - * - * @return string - */ - private function getSpoolDirectory() + public function getWebViewTokenId() { - return $this->getKernel()->getContainer()->getParameter('swiftmailer.spool.defaultMailer.file.path'); + return 'azineEmailWebViewToken'; } - /** - * @return User - * - * @throws \Exception - */ - private function getTestRecipient() + public function getCampaignParamsFor($templateId, array $params = null) { - /** @var UserManager $userManager */ - $userManager = $this->appContainer->get('fos_user.user_manager'); - /** @var User $testUser */ - $testUser = $userManager->findUsers()[0]; - $this->originalUserPassword = $testUser->getPassword(); - $this->originalUserSalt = $testUser->getSalt(); - $testUser->setPlainPassword($this->uniqueId); - $userManager->updateUser($testUser, true); - - return $testUser; + return []; } } diff --git a/Tests/Services/AzineEmailTwigExtensionTest.php b/Tests/Services/AzineEmailTwigExtensionTest.php index 4cfe7c11..8c15b172 100644 --- a/Tests/Services/AzineEmailTwigExtensionTest.php +++ b/Tests/Services/AzineEmailTwigExtensionTest.php @@ -16,8 +16,8 @@ public function testFilters() $this->assertSame(5, sizeof($filters), 'Unexpected number of Twig filters'); foreach ($filters as $filter) { - /* @var $filter \Twig_SimpleFilter */ - $this->assertTrue($filter instanceof \Twig_SimpleFilter, 'Twig_SimpleFilter expected as filter'); + /* @var $filter \Twig\TwigFilter */ + $this->assertTrue($filter instanceof \Twig\TwigFilter, 'Twig_SimpleFilter expected as filter'); $filterNames[] = $filter->getName(); } $this->assertContains('textWrap', $filterNames, 'The filter textWrap should exist.'); diff --git a/Tests/Services/AzineNotifierServiceTest.php b/Tests/Services/AzineNotifierServiceTest.php index e360b9b1..4aa06c5f 100644 --- a/Tests/Services/AzineNotifierServiceTest.php +++ b/Tests/Services/AzineNotifierServiceTest.php @@ -1,257 +1,369 @@ getMockBuilder("Azine\EmailBundle\Services\TemplateTwigSwiftMailerInterface")->disableOriginalConstructor()->getMock(); - $mocks['twig'] = $this->getMockBuilder("\Twig_Environment")->disableOriginalConstructor()->getMock(); - $mocks['router'] = $this->getMockBuilder("Symfony\Component\Routing\Generator\UrlGeneratorInterface")->disableOriginalConstructor()->getMock(); - $mocks['entityManager'] = $this->getMockBuilder("Doctrine\ORM\EntityManager")->disableOriginalConstructor()->getMock(); - $mocks['notificationRepository'] = $this->getMockBuilder("Azine\EmailBundle\Entity\Repositories\NotificationRepository")->disableOriginalConstructor()->getMock(); - $mocks['managerRegistry'] = $this->getMockBuilder("Doctrine\Persistence\ManagerRegistry")->disableOriginalConstructor()->getMock(); - $mocks['managerRegistry']->expects($this->any())->method('getManager')->will($this->returnValue($mocks['entityManager'])); - $mocks['managerRegistry']->expects($this->any())->method('getRepository')->will($this->returnValue($mocks['notificationRepository'])); - $mocks['templateProvider'] = $this->getMockBuilder("Azine\EmailBundle\Services\TemplateProviderInterface")->disableOriginalConstructor()->getMock(); - $mocks['recipientProvider'] = $this->getMockBuilder("Azine\EmailBundle\Services\RecipientProviderInterface")->disableOriginalConstructor()->getMock(); - $mocks['translator'] = $this->getMockBuilder("Symfony\Bundle\FrameworkBundle\Translation\Translator")->disableOriginalConstructor()->getMock(); - $mocks['parameters'] = array( - AzineEmailExtension::NEWSLETTER.'_'.AzineEmailExtension::NEWSLETTER_INTERVAL => '7', - AzineEmailExtension::NEWSLETTER.'_'.AzineEmailExtension::NEWSLETTER_SEND_TIME => '09:00', - AzineEmailExtension::TEMPLATES.'_'.AzineEmailExtension::NEWSLETTER_TEMPLATE => AzineTemplateProvider::NEWSLETTER_TEMPLATE, - AzineEmailExtension::TEMPLATES.'_'.AzineEmailExtension::NOTIFICATIONS_TEMPLATE => AzineTemplateProvider::NOTIFICATIONS_TEMPLATE, - AzineEmailExtension::TEMPLATES.'_'.AzineEmailExtension::CONTENT_ITEM_TEMPLATE => AzineTemplateProvider::CONTENT_ITEM_MESSAGE_TEMPLATE, - ); - - return $mocks; + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager + ->expects(self::once()) + ->method('persist') + ->with(self::isInstanceOf(Notification::class)); + $entityManager->expects(self::once())->method('flush'); + + $notifier = $this->createNotifier(entityManager: $entityManager); + $notification = $notifier->addNotification( + 12, + 'A title', + 'Some content', + '@App/Email/item', + ['foo' => 'bar'], + 1, + true, + ); + + self::assertSame('12', (string) $notification->getRecipientId()); + self::assertSame('A title', $notification->getTitle()); + self::assertSame('Some content', $notification->getContent()); + self::assertSame('@App/Email/item', $notification->getTemplate()); + self::assertSame(['foo' => 'bar'], $notification->getVariables()); + self::assertTrue($notification->getSendImmediately()); } - public function testAddNotification() + public function testAddNotificationMessageUsesConfiguredContentTemplate(): void { - $mocks = $this->getMockSetup(); - $mocks['entityManager']->expects($this->once())->method('persist'); - $notifier = new AzineNotifierService($mocks['mailer'], $mocks['twig'], $mocks['router'], $mocks['managerRegistry'], $mocks['templateProvider'], $mocks['recipientProvider'], $mocks['translator'], $mocks['parameters']); - - $n = $notifier->addNotification('12', 'title', 'content', 'template', array('templateVars'), 1, false); - - $this->assertSame('12', $n->getRecipientId()); - $this->assertSame('title', $n->getTitle()); - $this->assertSame('template', $n->getTemplate()); - $this->assertSame(array('templateVars'), $n->getVariables()); + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager + ->expects(self::once()) + ->method('persist') + ->with(self::callback(static function (Notification $notification): bool { + return '@App/Email/message' === $notification->getTemplate() + && ['goToUrl' => 'https://azine.me/messages', 'base' => 'value'] === $notification->getVariables(); + })); + $entityManager->expects(self::once())->method('flush'); + + $templateProvider = $this->createMock(TemplateProviderInterface::class); + $templateProvider + ->expects(self::once()) + ->method('addTemplateVariablesFor') + ->with('@App/Email/message', ['goToUrl' => 'https://azine.me/messages']) + ->willReturn(['goToUrl' => 'https://azine.me/messages', 'base' => 'value']); + + $this->createNotifier( + entityManager: $entityManager, + templateProvider: $templateProvider, + )->addNotificationMessage( + 12, + 'Message title', + 'Message body', + 'https://azine.me/messages', + ); } - public function testAddNotificationMessage() + public function testNewsletterSendsPerRecipientAndCollectsFailures(): void { - $goToUrl = 'http://azine.email/this/is/a/url'; - $templateVars = array('logo_png' => '/some/directory/logo.png', 'mainColor' => 'green'); - $mocks = $this->getMockSetup(); - $mocks['entityManager']->expects($this->once())->method('persist'); - $mocks['templateProvider']->expects($this->once())->method('addTemplateVariablesFor')->with(AzineTemplateProvider::CONTENT_ITEM_MESSAGE_TEMPLATE, array('goToUrl' => $goToUrl))->will($this->returnValue(array_merge(array('goToUrl' => $goToUrl), $templateVars))); - $notifier = new AzineNotifierService($mocks['mailer'], $mocks['twig'], $mocks['router'], $mocks['managerRegistry'], $mocks['templateProvider'], $mocks['recipientProvider'], $mocks['translator'], $mocks['parameters']); - - $notifier->addNotificationMessage('12', 'some title', "some content with \nline breaks.", $goToUrl); - - $mocks = $this->getMockSetup(); - $mocks['entityManager']->expects($this->once())->method('persist'); - $mocks['templateProvider']->expects($this->once())->method('addTemplateVariablesFor')->with(AzineTemplateProvider::CONTENT_ITEM_MESSAGE_TEMPLATE, array())->will($this->returnValue($templateVars)); - $notifier = new AzineNotifierService($mocks['mailer'], $mocks['twig'], $mocks['router'], $mocks['managerRegistry'], $mocks['templateProvider'], $mocks['recipientProvider'], $mocks['translator'], $mocks['parameters']); - - $notifier->addNotificationMessage('12', 'some title', "some content with \nline breaks."); + $first = $this->createRecipient(11, 'first@example.com'); + $second = $this->createRecipient(12, 'failed@example.com'); + + $recipientProvider = $this->createMock(RecipientProviderInterface::class); + $recipientProvider->method('getNewsletterRecipientIDs')->willReturn([11, 12]); + $recipientProvider + ->method('getRecipient') + ->willReturnMap([[11, $first], [12, $second]]); + + $mailer = $this->createMock(TemplateTwigMailerInterface::class); + $mailer + ->expects(self::exactly(2)) + ->method('sendSingleEmail') + ->willReturnCallback(static fn (string $email): bool => 'failed@example.com' !== $email); + + $failedAddresses = []; + $sent = $this->createNotifier( + mailer: $mailer, + recipientProvider: $recipientProvider, + withNewsletterContent: true, + )->sendNewsletter($failedAddresses); + + self::assertSame(1, $sent); + self::assertSame(['failed@example.com'], $failedAddresses); } - public function testSendNewsletter() + public function testNewsletterWithoutContentIsReportedAsFailedAndNotSent(): void { - $failedAddresses = array(); - $recipientIds = array(11, 12, 13, 14); - $mocks = $this->getMockSetup(); - $this->mockRecipients($mocks['recipientProvider'], $recipientIds); - $mocks['recipientProvider']->expects($this->once())->method('getNewsletterRecipientIDs')->will($this->returnValue($recipientIds)); - $mocks['mailer']->expects($this->exactly(sizeof($recipientIds)))->method('sendSingleEmail')->will($this->returnCallback(array($this, 'sendSingleEmailCallBack'))); - - $mocks['mailer']->expects($this->exactly(sizeof($recipientIds)))->method('sendSingleEmail'); - - $notifier = new ExampleNotifierService($mocks['mailer'], $mocks['twig'], $mocks['router'], $mocks['managerRegistry'], $mocks['templateProvider'], $mocks['recipientProvider'], $mocks['translator'], $mocks['parameters']); - $sentMails = $notifier->sendNewsletter($failedAddresses); - $this->assertSame(1, sizeof($failedAddresses)); - $this->assertSame(sizeof($recipientIds) - 1, $sentMails); + $recipient = $this->createRecipient(11, 'recipient@example.com'); + $recipientProvider = $this->createMock(RecipientProviderInterface::class); + $recipientProvider->method('getNewsletterRecipientIDs')->willReturn([11]); + $recipientProvider->method('getRecipient')->willReturn($recipient); + + $mailer = $this->createMock(TemplateTwigMailerInterface::class); + $mailer->expects(self::never())->method('sendSingleEmail'); + + $failedAddresses = []; + $sent = $this->createNotifier( + mailer: $mailer, + recipientProvider: $recipientProvider, + )->sendNewsletter($failedAddresses); + + self::assertSame(0, $sent); + self::assertSame(['recipient@example.com'], $failedAddresses); } - public function sendSingleEmailCallBack($email, $displayName, $params, $wrapperTemplate, $locale) + public function testNotificationDeliveryMarksItemsAsSent(): void { - if ('11mail@email.com' == $email) { - return false; - } - - return true; + $recipient = $this->createRecipient( + 11, + 'recipient@example.com', + RecipientInterface::NOTIFICATION_MODE_IMMEDIATELY, + ); + $notification = (new Notification()) + ->setTitle('A title') + ->setContent('A body') + ->setTemplate('@App/Email/item') + ->setVariables(['foo' => 'bar']); + + $repository = $this->createNotificationRepository(); + $repository->method('getNotificationRecipientIds')->willReturn([11]); + $repository->method('getLastNotificationDate')->with(11)->willReturn(new \DateTime('@0')); + $repository->method('getNotificationsToSend')->with(11)->willReturn([$notification]); + + $recipientProvider = $this->createMock(RecipientProviderInterface::class); + $recipientProvider->method('getRecipient')->with(11)->willReturn($recipient); + + $mailer = $this->createMock(TemplateTwigMailerInterface::class); + $mailer + ->expects(self::once()) + ->method('sendSingleEmail') + ->with( + 'recipient@example.com', + 'Recipient 11', + 'A title', + self::callback(static fn (array $params): bool => isset($params[AzineNotifierService::CONTENT_ITEMS])), + '@App/Email/notifications.txt.twig', + 'en', + ) + ->willReturn(true); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::once())->method('persist')->with($notification); + $entityManager->expects(self::once())->method('flush'); + + $failedAddresses = []; + $sent = $this->createNotifier( + mailer: $mailer, + recipientProvider: $recipientProvider, + notificationRepository: $repository, + entityManager: $entityManager, + )->sendNotifications($failedAddresses); + + self::assertSame(1, $sent); + self::assertSame([], $failedAddresses); + self::assertInstanceOf(\DateTime::class, $notification->getSent()); } - public function testSendNewsletter_NoContent() + public function testNeverModeMarksPendingNotificationsAsHandledWithoutSending(): void { - $failedAddresses = array(); - $recipientIds = array(11, 12, 13, 14); - $mocks = $this->getMockSetup(); - $this->mockRecipients($mocks['recipientProvider'], $recipientIds); - $mocks['recipientProvider']->expects($this->once())->method('getNewsletterRecipientIDs')->will($this->returnValue($recipientIds)); - - $mocks['mailer']->expects($this->never())->method('sendSingleEmail'); - - $notifier = new AzineNotifierService($mocks['mailer'], $mocks['twig'], $mocks['router'], $mocks['managerRegistry'], $mocks['templateProvider'], $mocks['recipientProvider'], $mocks['translator'], $mocks['parameters']); - $sentMails = $notifier->sendNewsletter($failedAddresses); - $this->assertSame(4, sizeof($failedAddresses), 'Email-addresses failed unexpectedly:'.print_r($failedAddresses, true)); - $this->assertSame(0, $sentMails, 'Not the expected number of sent emails.'); + $recipient = $this->createRecipient( + 11, + 'recipient@example.com', + RecipientInterface::NOTIFICATION_MODE_NEVER, + ); + $repository = $this->createNotificationRepository(); + $repository->method('getNotificationRecipientIds')->willReturn([11]); + $repository->method('getLastNotificationDate')->willReturn(new \DateTime('@0')); + $repository + ->expects(self::once()) + ->method('markAllNotificationsAsSentFarInThePast') + ->with(11); + + $recipientProvider = $this->createMock(RecipientProviderInterface::class); + $recipientProvider->method('getRecipient')->willReturn($recipient); + + $mailer = $this->createMock(TemplateTwigMailerInterface::class); + $mailer->expects(self::never())->method('sendSingleEmail'); + + $failedAddresses = []; + $sent = $this->createNotifier( + mailer: $mailer, + recipientProvider: $recipientProvider, + notificationRepository: $repository, + )->sendNotifications($failedAddresses); + + self::assertSame(1, $sent); + self::assertSame([], $failedAddresses); } - public function testSendNotificationsAzineNotifierService() + public function testPluralNotificationSubjectUsesModernTranslatorApi(): void { - $failedAddresses = array(); - $recipientIds = array(11, 12, 13, 14); - $mocks = $this->getMockSetup(); - $this->mockRecipients($mocks['recipientProvider'], $recipientIds); - $mocks['mailer']->expects($this->exactly(sizeof($recipientIds)))->method('sendSingleEmail')->will($this->returnCallback(array($this, 'sendSingleEmailCallBack'))); - - $notification = new Notification(); - $notification->setContent('bla bla'); - $notification->setCreated(new \DateTime()); - $notification->setImportance(0); - $notification->setTemplate(AzineTemplateProvider::CONTENT_ITEM_MESSAGE_TEMPLATE); - $notification->setVariables(array('blabla' => 'blablaValue')); - $notification->setTitle('a title'); - - $mocks['notificationRepository']->expects($this->once())->method('getNotificationRecipientIds')->will($this->returnValue($recipientIds)); - $mocks['notificationRepository']->expects($this->exactly(4))->method('getNotificationsToSend')->will($this->returnValue(array($notification))); - $mocks['notificationRepository']->expects($this->never())->method('getNotificationsToSendImmediately'); - $mocks['notificationRepository']->expects($this->never())->method('markAllNotificationsAsSentFarInThePast'); - $mocks['notificationRepository']->expects($this->exactly(4))->method('getLastNotificationDate')->will($this->returnValue(new \DateTime('@0'))); - - $notifier = new AzineNotifierService($mocks['mailer'], $mocks['twig'], $mocks['router'], $mocks['managerRegistry'], $mocks['templateProvider'], $mocks['recipientProvider'], $mocks['translator'], $mocks['parameters']); - $sentMails = $notifier->sendNotifications($failedAddresses); - $this->assertSame(1, sizeof($failedAddresses), 'One failed address was expected.'); - $sentMailCount = count($recipientIds) - count($failedAddresses); - $this->assertSame($sentMailCount, $sentMails, "Not the right number of emails has been sent successfully. Expected $sentMailCount"); + $translator = $this->createMock(TranslatorInterface::class); + $translator + ->expects(self::once()) + ->method('trans') + ->with('_az.email.notifications.subject.%count%', ['%count%' => 2]) + ->willReturn('2 notifications'); + + $subject = $this->createNotifier(translator: $translator) + ->getRecipientSpecificNotificationsSubject( + [['item' => []], ['item' => []]], + $this->createRecipient(11, 'recipient@example.com'), + ); + + self::assertSame('2 notifications', $subject); } - public function testSendNotificationsAzineNotifierService_NoNotifications() + public function testNewsletterScheduleRetainsConfiguredIntervalAndTime(): void { - $failedAddresses = array(); - $recipientIds = array(11, 12, 13, 14); - $mocks = $this->getMockSetup(); - $this->mockRecipients($mocks['recipientProvider'], $recipientIds); - $mocks['mailer']->expects($this->never())->method('sendSingleEmail')->will($this->returnCallback(array($this, 'sendSingleEmailCallBack'))); - - $mocks['notificationRepository']->expects($this->once())->method('getNotificationRecipientIds')->will($this->returnValue($recipientIds)); - $mocks['notificationRepository']->expects($this->exactly(4))->method('getNotificationsToSend')->will($this->returnValue(array())); - $mocks['notificationRepository']->expects($this->never())->method('getNotificationsToSendImmediately'); - $mocks['notificationRepository']->expects($this->never())->method('markAllNotificationsAsSentFarInThePast'); - $mocks['notificationRepository']->expects($this->exactly(4))->method('getLastNotificationDate')->will($this->returnValue(new \DateTime('@0'))); - - $notifier = new AzineNotifierService($mocks['mailer'], $mocks['twig'], $mocks['router'], $mocks['managerRegistry'], $mocks['templateProvider'], $mocks['recipientProvider'], $mocks['translator'], $mocks['parameters']); - $sentMails = $notifier->sendNotifications($failedAddresses); - $this->assertSame(0, sizeof($failedAddresses), 'Email-addresses failed unexpectedly:'.print_r($failedAddresses, true)); - $this->assertSame(4, $sentMails, 'Not the expected number of sent emails.'); - } + $notifier = $this->createNotifier(); - public function testSendNotificationsExampleNotifier() - { - $failedAddresses = array(); - $recipientIds = array(11, 12, 13, 14); - $mocks = $this->getMockSetup(); - $this->mockRecipients($mocks['recipientProvider'], $recipientIds); - $mocks['mailer']->expects($this->exactly(sizeof($recipientIds)))->method('sendSingleEmail')->will($this->returnCallback(array($this, 'sendSingleEmailCallBack'))); - - $notification = new Notification(); - $notification->setContent('bla bla'); - $notification->setCreated(new \DateTime()); - $notification->setImportance(0); - $notification->setTemplate(AzineTemplateProvider::CONTENT_ITEM_MESSAGE_TEMPLATE); - $notification->setVariables(array('blabla' => 'blablaValue')); - $notification->setTitle('a title'); - - $mocks['notificationRepository']->expects($this->once())->method('getNotificationRecipientIds')->will($this->returnValue($recipientIds)); - $mocks['notificationRepository']->expects($this->exactly(4))->method('getNotificationsToSend')->will($this->returnValue(array($notification))); - $mocks['notificationRepository']->expects($this->never())->method('getNotificationsToSendImmediately'); - $mocks['notificationRepository']->expects($this->never())->method('markAllNotificationsAsSentFarInThePast'); - $mocks['notificationRepository']->expects($this->exactly(4))->method('getLastNotificationDate')->will($this->returnValue(new \DateTime('@0'))); - - $mocks['mailer']->expects($this->exactly(sizeof($recipientIds)))->method('sendSingleEmail'); - - $notifier = new ExampleNotifierService($mocks['mailer'], $mocks['twig'], $mocks['router'], $mocks['managerRegistry'], $mocks['templateProvider'], $mocks['recipientProvider'], $mocks['translator'], $mocks['parameters']); - $sentMails = $notifier->sendNotifications($failedAddresses); - $this->assertSame(1, sizeof($failedAddresses)); - $this->assertSame(sizeof($recipientIds) - 1, $sentMails); + self::assertSame('09:00', $notifier->newsletterSendTime()); + self::assertSame(7, $notifier->newsletterInterval()); + self::assertSame(9, (int) $notifier->lastNewsletterDate()->format('H')); + self::assertSame(9, (int) $notifier->nextNewsletterDate()->format('H')); } - private function mockRecipients(\PHPUnit_Framework_MockObject_MockObject $mock, array $ids) - { - $notificationType = 0; - $notificationTypes = array(RecipientInterface::NOTIFICATION_MODE_IMMEDIATELY, RecipientInterface::NOTIFICATION_MODE_HOURLY, RecipientInterface::NOTIFICATION_MODE_DAYLY); - $valueMap = array(); - foreach ($ids as $id) { - $recipientMock = $this->getMockBuilder("Azine\EmailBundle\Entity\RecipientInterface")->disableOriginalConstructor()->getMock(); - $recipientMock->expects($this->any())->method('getEmail')->will($this->returnValue($id.'mail@email.com')); - $recipientMock->expects($this->any())->method('getDisplayName')->will($this->returnValue("DisplayName of $id")); - $recipientMock->expects($this->any())->method('getPreferredLocale')->will($this->returnValue('en')); - $recipientMock->expects($this->any())->method('getNotificationMode')->will($this->returnValue($notificationTypes[$notificationType % sizeof($notificationTypes)])); - ++$notificationType; - $valueMap[] = array($id, $recipientMock); + private function createNotifier( + ?TemplateTwigMailerInterface $mailer = null, + ?RecipientProviderInterface $recipientProvider = null, + ?TemplateProviderInterface $templateProvider = null, + ?NotificationRepository $notificationRepository = null, + ?EntityManagerInterface $entityManager = null, + ?TranslatorInterface $translator = null, + bool $withNewsletterContent = false, + ): TestNotifierService { + $mailer ??= $this->createMock(TemplateTwigMailerInterface::class); + $recipientProvider ??= $this->createMock(RecipientProviderInterface::class); + $templateProvider ??= $this->createMock(TemplateProviderInterface::class); + $notificationRepository ??= $this->createNotificationRepository(); + $entityManager ??= $this->createMock(EntityManagerInterface::class); + if (null === $translator) { + $translator = $this->createMock(TranslatorInterface::class); + $translator->method('trans')->willReturnArgument(0); } - $mock->expects($this->exactly(sizeof($ids)))->method('getRecipient')->will($this->returnValueMap($valueMap)); + $registry = $this->createMock(ManagerRegistry::class); + $registry->method('getManager')->willReturn($entityManager); + $registry + ->method('getRepository') + ->with(Notification::class) + ->willReturn($notificationRepository); + + return new TestNotifierService( + $mailer, + $this->createMock(Environment::class), + $this->createMock(UrlGeneratorInterface::class), + $registry, + $templateProvider, + $recipientProvider, + $translator, + [ + AzineEmailExtension::NEWSLETTER.'_'.AzineEmailExtension::NEWSLETTER_INTERVAL => 7, + AzineEmailExtension::NEWSLETTER.'_'.AzineEmailExtension::NEWSLETTER_SEND_TIME => '09:00', + AzineEmailExtension::TEMPLATES.'_'.AzineEmailExtension::NEWSLETTER_TEMPLATE => '@App/Email/newsletter', + AzineEmailExtension::TEMPLATES.'_'.AzineEmailExtension::NOTIFICATIONS_TEMPLATE => '@App/Email/notifications', + AzineEmailExtension::TEMPLATES.'_'.AzineEmailExtension::CONTENT_ITEM_TEMPLATE => '@App/Email/message', + ], + $withNewsletterContent, + ); } - public function testProtectedMethods() + private function createNotificationRepository(): NotificationRepository { - // create service-instance - $mocks = $this->getMockSetup(); - - $recipientIds = array(11, 12, 13, 14); - $mocks['recipientProvider']->expects($this->once())->method('getNewsletterRecipientIDs')->will($this->returnValue($recipientIds)); - - $notifier = new AzineNotifierService($mocks['mailer'], $mocks['twig'], $mocks['router'], $mocks['managerRegistry'], $mocks['templateProvider'], $mocks['recipientProvider'], $mocks['translator'], $mocks['parameters']); - - // access the protected method and execute it - $returnValue = self::getMethod('getDateTimeOfLastNewsletter')->invokeArgs($notifier, array()); - $this->assertInstanceOf('DateTime', $returnValue); - $lastDate = new \DateTime('7 days ago'); - $lastDate->setTime(9, 0); - $this->assertSame($lastDate->getTimestamp(), $returnValue->getTimestamp()); + return $this->getMockBuilder(NotificationRepository::class) + ->disableOriginalConstructor() + ->onlyMethods([ + 'getNotificationRecipientIds', + 'getLastNotificationDate', + 'getNotificationsToSend', + 'getNotificationsToSendImmediately', + 'markAllNotificationsAsSentFarInThePast', + ]) + ->getMock(); + } - $returnValue = self::getMethod('getDateTimeOfNextNewsletter')->invokeArgs($notifier, array()); - $this->assertInstanceOf('DateTime', $returnValue); - $nextDate = new \DateTime('7 days'); - $nextDate->setTime(9, 0); - $this->assertSame($nextDate->getTimestamp(), $returnValue->getTimestamp()); + private function createRecipient( + int $id, + string $email, + int $notificationMode = RecipientInterface::NOTIFICATION_MODE_IMMEDIATELY, + ): RecipientInterface { + $recipient = $this->createMock(RecipientInterface::class); + $recipient->method('getId')->willReturn($id); + $recipient->method('getEmail')->willReturn($email); + $recipient->method('getDisplayName')->willReturn('Recipient '.$id); + $recipient->method('getPreferredLocale')->willReturn('en'); + $recipient->method('getNotificationMode')->willReturn($notificationMode); + $recipient->method('getNewsletter')->willReturn(true); + + return $recipient; + } +} - $returnValue = self::getMethod('getHourInterval')->invokeArgs($notifier, array()); - $this->assertSame((60 * 60 - 3 * 60), $returnValue); +final class TestNotifierService extends AzineNotifierService +{ + public function __construct( + TemplateTwigMailerInterface $mailer, + Environment $twig, + UrlGeneratorInterface $router, + ManagerRegistry $managerRegistry, + TemplateProviderInterface $templateProvider, + RecipientProviderInterface $recipientProvider, + TranslatorInterface $translatorService, + array $parameters, + private readonly bool $withNewsletterContent, + ) { + parent::__construct( + $mailer, + $twig, + $router, + $managerRegistry, + $templateProvider, + $recipientProvider, + $translatorService, + $parameters, + ); + } - $returnValue = self::getMethod('getGeneralVarsForNewsletter')->invokeArgs($notifier, array()); - $this->assertSame(sizeof($recipientIds), $returnValue['recipientCount']); + protected function getNonRecipientSpecificNewsletterContentItems() + { + return $this->withNewsletterContent + ? [['@App/Email/item' => ['title' => 'General item']]] + : []; + } - $recipientMock = $this->getMockBuilder("Azine\EmailBundle\Entity\RecipientInterface")->disableOriginalConstructor()->getMock(); - $recipientMock->expects($this->any())->method('getId')->will($this->returnValue(11)); + public function newsletterSendTime(): string + { + return (string) $this->getNewsletterSendTime(); + } - $returnValue = self::getMethod('markAllNotificationsAsSentFarInThePast')->invokeArgs($notifier, array($recipientMock)); + public function newsletterInterval(): int + { + return (int) $this->getNewsletterInterval(); } - /** - * @param string $name - */ - private static function getMethod($name) + public function lastNewsletterDate(): \DateTime { - $class = new \ReflectionClass("Azine\EmailBundle\Services\AzineNotifierService"); - $method = $class->getMethod($name); - $method->setAccessible(true); + return $this->getDateTimeOfLastNewsletter(); + } - return $method; + public function nextNewsletterDate(): \DateTime + { + return $this->getDateTimeOfNextNewsletter(); } } diff --git a/Tests/Services/AzineRecipientProviderTest.php b/Tests/Services/AzineRecipientProviderTest.php index 23728884..c9086072 100644 --- a/Tests/Services/AzineRecipientProviderTest.php +++ b/Tests/Services/AzineRecipientProviderTest.php @@ -1,56 +1,104 @@ createMock(RecipientInterface::class); + $recipient->method('getId')->willReturn(11); + + $repository = $this->getMockBuilder(EntityRepository::class) + ->disableOriginalConstructor() + ->onlyMethods(['find']) + ->getMock(); + $repository->expects(self::once())->method('find')->with(11)->willReturn($recipient); + + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::once())->method('getRepository')->with('a-user-class')->willReturn($repository); + + $registry = $this->createMock(ManagerRegistry::class); + $registry->method('getManager')->willReturn($entityManager); - $recipientMock = $this->getMockBuilder("Azine\EmailBundle\Entity\RecipientInterface")->disableOriginalConstructor()->getMock(); - $recipientMock->expects($this->once())->method('getId')->will($this->returnValue($id)); + $provider = new AzineRecipientProvider($registry, 'a-user-class', 'newsletterField'); - $repositoryMock = $this->getMockBuilder("Doctrine\ORM\EntityRepository")->disableOriginalConstructor()->getMock(); - $repositoryMock->expects($this->once())->method('find')->with($id, LockMode::NONE, null)->will($this->returnValue($recipientMock)); + self::assertSame($recipient, $provider->getRecipient(11)); + } + + public function testMissingRecipientThrowsUsefulException(): void + { + $repository = $this->getMockBuilder(EntityRepository::class) + ->disableOriginalConstructor() + ->onlyMethods(['find']) + ->getMock(); + $repository->method('find')->willReturn(null); - $entityManagerMock = $this->getMockBuilder("Doctrine\ORM\EntityManager")->disableOriginalConstructor()->getMock(); - $entityManagerMock->expects($this->once())->method('getRepository')->will($this->returnValue($repositoryMock)); + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->method('getRepository')->willReturn($repository); - $managerRegistryMock = $this->getMockBuilder("Doctrine\Persistence\ManagerRegistry")->disableOriginalConstructor()->getMock(); - $managerRegistryMock->expects($this->any())->method('getManager')->will($this->returnValue($entityManagerMock)); + $registry = $this->createMock(ManagerRegistry::class); + $registry->method('getManager')->willReturn($entityManager); - $recipientProvider = new AzineRecipientProvider($managerRegistryMock, 'a-user-class', 'newsletterField'); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('No recipient'); - $recipient = $recipientProvider->getRecipient($id); - $this->assertSame($id, $recipient->getId()); + (new AzineRecipientProvider($registry, 'a-user-class', 'newsletterField'))->getRecipient(11); } - public function testGetNewsletterRecipientIDs() + public function testGetNewsletterRecipientIds(): void { - $queryResult = array(array('id' => 11), array('id' => 12), array('id' => 13), array('id' => 14)); - $recipientsArray = array(11, 12, 13, 14); + $query = $this->getMockBuilder(Query::class) + ->disableOriginalConstructor() + ->onlyMethods(['getArrayResult']) + ->getMock(); + $query + ->expects(self::once()) + ->method('getArrayResult') + ->willReturn([ + ['id' => 11], + ['id' => 12], + ['id' => 13], + ['id' => 14], + ]); - $queryBuilderMock = $this->getMockBuilder("Doctrine\ORM\QueryBuilder")->disableOriginalConstructor()->getMock(); - $queryBuilderMock->expects($this->once())->method('select')->will($this->returnSelf()); - $queryBuilderMock->expects($this->once())->method('from')->will($this->returnSelf()); - $queryBuilderMock->expects($this->once())->method('where')->will($this->returnSelf()); - $queryBuilderMock->expects($this->once())->method('andWhere')->will($this->returnSelf()); + $queryBuilder = $this->getMockBuilder(QueryBuilder::class) + ->disableOriginalConstructor() + ->onlyMethods(['select', 'from', 'where', 'andWhere', 'getQuery']) + ->getMock(); + $queryBuilder->expects(self::once())->method('select')->with('recipient.id')->willReturnSelf(); + $queryBuilder->expects(self::once())->method('from')->with('a-user-class', 'recipient')->willReturnSelf(); + $queryBuilder + ->expects(self::once()) + ->method('where') + ->with('recipient.newsletterField = true') + ->willReturnSelf(); + $queryBuilder + ->expects(self::once()) + ->method('andWhere') + ->with('recipient.enabled = true') + ->willReturnSelf(); + $queryBuilder->expects(self::once())->method('getQuery')->willReturn($query); - $queryBuilderMock->expects($this->once())->method('getQuery')->will($this->returnValue(new AzineQueryMock($queryResult))); + $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager->expects(self::once())->method('createQueryBuilder')->willReturn($queryBuilder); - $entityManagerMock = $this->getMockBuilder("Doctrine\ORM\EntityManager")->disableOriginalConstructor()->getMock(); - $entityManagerMock->expects($this->once())->method('createQueryBuilder')->will($this->returnValue($queryBuilderMock)); + $registry = $this->createMock(ManagerRegistry::class); + $registry->method('getManager')->willReturn($entityManager); - $managerRegistryMock = $this->getMockBuilder("Doctrine\Persistence\ManagerRegistry")->disableOriginalConstructor()->getMock(); - $managerRegistryMock->expects($this->any())->method('getManager')->will($this->returnValue($entityManagerMock)); + $provider = new AzineRecipientProvider($registry, 'a-user-class', 'newsletterField'); - $recipientProvider = new AzineRecipientProvider($managerRegistryMock, 'a-user-class', 'newsletterField'); - $recipients = $recipientProvider->getNewsletterRecipientIDs(); - $this->assertSame($recipientsArray, $recipients); + self::assertSame([11, 12, 13, 14], $provider->getNewsletterRecipientIDs()); } } diff --git a/Tests/Services/AzineTemplateProviderTest.php b/Tests/Services/AzineTemplateProviderTest.php index 9c62443e..b3ce2439 100644 --- a/Tests/Services/AzineTemplateProviderTest.php +++ b/Tests/Services/AzineTemplateProviderTest.php @@ -1,235 +1,225 @@ getMockBuilder("Symfony\Bundle\FrameworkBundle\Translation\Translator")->disableOriginalConstructor()->setMethods(array('trans'))->getMock(); - - $translatorMock->expects($this->any())->method('trans')->will($this->returnValueMap(array( - array('html.email.go.to.top.link.label', array(), 'messages', 'de', 'de übersetzung'), - array('html.email.go.to.top.link.label', array(), 'messages', 'en', 'en translation'), - ))); - - $routerMock = $this->getMockBuilder("Symfony\Component\Routing\Generator\UrlGeneratorInterface")->disableOriginalConstructor()->getMock(); - $routerMock->expects($this->any())->method('generate')->withAnyParameters()->will($this->returnCallback(array($this, 'createRelativeUrl'))); - - $params = array(AzineEmailExtension::TEMPLATE_IMAGE_DIR => realpath(__DIR__.'/../../Resources/htmlTemplateImages/'), - AzineEmailExtension::ALLOWED_IMAGES_FOLDERS => array(realpath(__DIR__.'/../../Resources/htmlTemplateImages/')), - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_NAME => 'utm_campaign', - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_TERM => 'utm_term', - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_SOURCE => 'utm_source', - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_MEDIUM => 'utm_medium', - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_CONTENT => 'utm_content', - ); - - return array('router' => $routerMock, 'translator' => $translatorMock, 'params' => $params); - } - - public function createRelativeUrl($routeName, $params) + public function testAddTemplateVariablesFor(): void { - if ('azine_email_serve_template_image' == $routeName) { - return '/template/images/'.$params['filename']; - } - echo $routeName; - - return '/some/relative/url/to/images/folder'; + $provider = $this->createProvider(); + $contentVariables = ['testVar' => 'testValue']; + + $resetVariables = $provider->addTemplateVariablesFor( + AzineTemplateProvider::FOS_USER_PWD_RESETTING_TEMPLATE, + $contentVariables, + ); + self::assertSame('testValue', $resetVariables['testVar']); + self::assertTrue($resetVariables[AzineTemplateProvider::SEND_IMMEDIATELY_FLAG]); + + $registrationVariables = $provider->addTemplateVariablesFor( + AzineTemplateProvider::FOS_USER_REGISTRATION_TEMPLATE, + $contentVariables, + ); + self::assertSame('testValue', $registrationVariables['testVar']); + self::assertTrue($registrationVariables[AzineTemplateProvider::SEND_IMMEDIATELY_FLAG]); + + $contentVariables[AzineTemplateProvider::CONTENT_ITEMS] = [[ + AzineTemplateProvider::CONTENT_ITEM_MESSAGE_TEMPLATE => ['otherTestVar' => 'otherTestValue'], + ]]; + $filledVariables = $provider->addTemplateVariablesFor( + AzineTemplateProvider::BASE_TEMPLATE, + $contentVariables, + ); + + self::assertSame('testValue', $filledVariables['testVar']); + self::assertSame( + 'otherTestValue', + $filledVariables[AzineTemplateProvider::CONTENT_ITEMS][0] + [AzineTemplateProvider::CONTENT_ITEM_MESSAGE_TEMPLATE]['otherTestVar'], + ); + self::assertFileExists($filledVariables['logo_png']); } - public function testAddTemplateVariablesFor() + public function testAddsLocaleSpecificSnippetsRecursively(): void { - $mocks = $this->getMockSetup(); - $templateProvider = new AzineTemplateProvider($mocks['router'], $mocks['translator'], $mocks['params']); - - // test without contentItems - $contentVars = array('testVar' => 'testValue'); - $filledVars = $templateProvider->addTemplateVariablesFor(AzineTemplateProvider::FOS_USER_PWD_RESETTING_TEMPLATE, $contentVars); - $this->assertSame('testValue', $filledVars['testVar']); - $this->assertGreaterThan(sizeof($contentVars), sizeof($filledVars)); - - $filledVars = $templateProvider->addTemplateVariablesFor(AzineTemplateProvider::FOS_USER_REGISTRATION_TEMPLATE, $contentVars); - $this->assertSame('testValue', $filledVars['testVar']); - $this->assertGreaterThan(sizeof($contentVars), sizeof($filledVars)); - - // test with contentItems - $contentVars[AzineTemplateProvider::CONTENT_ITEMS] = array(array(AzineTemplateProvider::CONTENT_ITEM_MESSAGE_TEMPLATE => array('otherTestVar' => 'otherTestValue'))); - $filledVars = $templateProvider->addTemplateVariablesFor(AzineTemplateProvider::BASE_TEMPLATE, $contentVars); - $this->assertSame('testValue', $filledVars['testVar']); - $this->assertGreaterThan(sizeof($contentVars), sizeof($filledVars)); - $this->assertTrue(is_array($filledVars[AzineTemplateProvider::CONTENT_ITEMS])); - $this->assertTrue(is_array($filledVars[AzineTemplateProvider::CONTENT_ITEMS][0][AzineTemplateProvider::CONTENT_ITEM_MESSAGE_TEMPLATE])); - $this->assertSame('otherTestValue', $filledVars[AzineTemplateProvider::CONTENT_ITEMS][0][AzineTemplateProvider::CONTENT_ITEM_MESSAGE_TEMPLATE]['otherTestVar']); + $provider = $this->createProvider(); + $contentVariables = [ + 'testVar' => 'testValue', + AzineTemplateProvider::CONTENT_ITEMS => [[ + AzineTemplateProvider::CONTENT_ITEM_MESSAGE_TEMPLATE => ['otherTestVar' => 'otherTestValue'], + ]], + ]; + $contentVariables = $provider->addTemplateVariablesFor( + AzineTemplateProvider::BASE_TEMPLATE, + $contentVariables, + ); + + $english = $provider->addTemplateSnippetsWithImagesFor( + AzineTemplateProvider::BASE_TEMPLATE, + $contentVariables, + 'en', + ); + $german = $provider->addTemplateSnippetsWithImagesFor( + AzineTemplateProvider::BASE_TEMPLATE, + $contentVariables, + 'de', + ); + + self::assertSame('testValue', $english['testVar']); + self::assertStringContainsString('en translation', $english['linkToTop']); + self::assertStringContainsString('de übersetzung', $german['linkToTop']); + self::assertArrayHasKey( + 'linkToTop', + $english[AzineTemplateProvider::CONTENT_ITEMS][0] + [AzineTemplateProvider::CONTENT_ITEM_MESSAGE_TEMPLATE], + ); } - public function testAddSnippetsWithImagesFor() + public function testSnippetGenerationRejectsMissingBaseVariables(): void { - $mocks = $this->getMockSetup(); - $templateProvider = new AzineTemplateProvider($mocks['router'], $mocks['translator'], $mocks['params']); - - $contentVars = array('testVar' => 'testValue'); - $contentVars[AzineTemplateProvider::CONTENT_ITEMS] = array(array(AzineTemplateProvider::CONTENT_ITEM_MESSAGE_TEMPLATE => array('otherTestVar' => 'otherTestValue'))); - $contentVars = $templateProvider->addTemplateVariablesFor(AzineTemplateProvider::BASE_TEMPLATE, $contentVars); - - $filledVars = $templateProvider->addTemplateSnippetsWithImagesFor(AzineTemplateProvider::BASE_TEMPLATE, $contentVars, 'en'); - $this->assertSame('testValue', $filledVars['testVar']); - $this->assertTrue(array_key_exists('linkToTop', $filledVars)); - - $contentVars2 = array('testVar' => 'testValue'); - $contentVars2[AzineTemplateProvider::CONTENT_ITEMS] = array(array(AzineTemplateProvider::CONTENT_ITEM_MESSAGE_TEMPLATE => array('otherTestVar' => 'otherTestValue'))); - $contentVars2 = $templateProvider->addTemplateVariablesFor(AzineTemplateProvider::NEWSLETTER_TEMPLATE, $contentVars2); - - $filledVars2 = $templateProvider->addTemplateSnippetsWithImagesFor(AzineTemplateProvider::NEWSLETTER_TEMPLATE, $contentVars2, 'en'); - $this->assertSame($filledVars['linkToTop'], $filledVars2['linkToTop']); - - $contentVars3 = array('testVar' => 'testValue'); - $contentVars3[AzineTemplateProvider::CONTENT_ITEMS] = array(array(AzineTemplateProvider::CONTENT_ITEM_MESSAGE_TEMPLATE => array('otherTestVar' => 'otherTestValue'))); - $contentVars3 = $templateProvider->addTemplateVariablesFor(AzineTemplateProvider::NOTIFICATIONS_TEMPLATE, $contentVars3); - - $filledVars3 = $templateProvider->addTemplateSnippetsWithImagesFor(AzineTemplateProvider::NOTIFICATIONS_TEMPLATE, $contentVars3, 'de'); - $this->assertTrue(array_key_exists('linkToTop', $filledVars3)); - $this->assertNotSame($filledVars['linkToTop'], $filledVars3['linkToTop']); - - $filledVars4 = $templateProvider->addTemplateSnippetsWithImagesFor(AzineTemplateProvider::BASE_TEMPLATE, $contentVars, 'de', true); - $this->assertTrue(array_key_exists('linkToTop', $filledVars4)); - $this->assertSame($filledVars3['linkToTop'], $filledVars4['linkToTop']); + $this->expectException(\Exception::class); + $this->expectExceptionMessage('required images'); + + $this->createProvider()->addTemplateSnippetsWithImagesFor( + AzineTemplateProvider::BASE_TEMPLATE, + ['testVar' => 'testValue'], + 'en', + ); } - /** - * \Exception("some required images are not yet added to the template-vars array."). - * - * @expectedException \Exception - */ - public function testAddSnippetsWithImagesForEmptyVars() + public function testSnippetGenerationRequiresLocale(): void { - $mocks = $this->getMockSetup(); - $templateProvider = new AzineTemplateProvider($mocks['router'], $mocks['translator'], $mocks['params']); - - $contentVars = array('testVar' => 'testValue'); - $contentVars[AzineTemplateProvider::CONTENT_ITEMS] = array(array(AzineTemplateProvider::CONTENT_ITEM_MESSAGE_TEMPLATE => array('otherTestVar' => 'otherTestValue'))); - $filledVars = $templateProvider->addTemplateSnippetsWithImagesFor(AzineTemplateProvider::BASE_TEMPLATE, $contentVars, 'en'); - } + $provider = $this->createProvider(); + $variables = $provider->addTemplateVariablesFor(AzineTemplateProvider::BASE_TEMPLATE, []); - /** - * \Exception("Only use the translator here when you already know in which language the user should get the email."). - * - * @expectedException \Exception - */ - public function testAddSnippetsWithImagesForNoLocale() - { - $mocks = $this->getMockSetup(); - $templateProvider = new AzineTemplateProvider($mocks['router'], $mocks['translator'], $mocks['params']); + $this->expectException(\Exception::class); + $this->expectExceptionMessage('know in which language'); - $contentVars = array('testVar' => 'testValue'); - $contentVars[AzineTemplateProvider::CONTENT_ITEMS] = array(array(AzineTemplateProvider::CONTENT_ITEM_MESSAGE_TEMPLATE => array('otherTestVar' => 'otherTestValue'))); - $contentVars = $templateProvider->addTemplateVariablesFor(AzineTemplateProvider::BASE_TEMPLATE, $contentVars); - $filledVars = $templateProvider->addTemplateSnippetsWithImagesFor(AzineTemplateProvider::BASE_TEMPLATE, $contentVars, null); + $provider->addTemplateSnippetsWithImagesFor( + AzineTemplateProvider::BASE_TEMPLATE, + $variables, + null, + ); } - public function testGetCampaignParamsFor() + public function testCampaignParametersPreserveExistingBehavior(): void { - $mocks = $this->getMockSetup(); - $templateProvider = new AzineTemplateProvider($mocks['router'], $mocks['translator'], $mocks['params']); - - $campaignParams1 = $templateProvider->getCampaignParamsFor(AzineTemplateProvider::NEWSLETTER_TEMPLATE); - $this->assertSame(3, sizeof($campaignParams1)); - $this->assertSame('newsletter', $campaignParams1['utm_source']); - - $campaignParams2 = $templateProvider->getCampaignParamsFor(AzineTemplateProvider::NOTIFICATIONS_TEMPLATE); - $this->assertSame(3, sizeof($campaignParams2)); - $this->assertSame('mailnotify', $campaignParams2['utm_source']); - - $campaignParams3 = $templateProvider->getCampaignParamsFor(AzineTemplateProvider::CONTENT_ITEM_MESSAGE_TEMPLATE); - $this->assertTrue(is_array($campaignParams3)); - $this->assertSame(3, sizeof($campaignParams3)); + $provider = $this->createProvider(); + + self::assertSame( + 'newsletter', + $provider->getCampaignParamsFor(AzineTemplateProvider::NEWSLETTER_TEMPLATE)['utm_source'], + ); + self::assertSame( + 'mailnotify', + $provider->getCampaignParamsFor(AzineTemplateProvider::NOTIFICATIONS_TEMPLATE)['utm_source'], + ); + self::assertSame( + 'message', + $provider->getCampaignParamsFor(AzineTemplateProvider::CONTENT_ITEM_MESSAGE_TEMPLATE)['utm_content'], + ); + self::assertSame( + [], + $provider->getCampaignParamsFor(AzineTemplateProvider::FOS_USER_PWD_RESETTING_TEMPLATE), + ); } - public function testIsFileAllowed() + public function testAllowedImageFoldersAndWebPaths(): void { - $mocks = $this->getMockSetup(); - $templateProvider = new AzineTemplateProvider($mocks['router'], $mocks['translator'], $mocks['params']); - - $allowed1 = $mocks['params'][AzineEmailExtension::TEMPLATE_IMAGE_DIR].'/logo.png'; - $key = $templateProvider->isFileAllowed($allowed1); - $this->assertTrue(is_string($key), "$allowed1 is not allowed, but it should!"); - - $allowed2 = $mocks['params'][AzineEmailExtension::ALLOWED_IMAGES_FOLDERS][0].'/logo.png'; - $this->assertTrue(is_string($templateProvider->isFileAllowed($allowed2)), "$allowed2 is not allowed, but it should!"); - - $notAllowed = __FILE__; - $this->assertFalse(is_string($templateProvider->isFileAllowed($notAllowed)), "$notAllowed is allowed, but it should not!"); - - $this->assertTrue(is_dir($templateProvider->getFolderFrom($key))); - $this->assertFalse(is_dir($templateProvider->getFolderFrom('noKey'))); + $provider = $this->createProvider(); + $allowedImage = $provider->getTemplateImageDir().'logo.png'; + $folderKey = $provider->isFileAllowed($allowedImage); + + self::assertIsString($folderKey); + self::assertSame($provider->getTemplateImageDir(), $provider->getFolderFrom($folderKey)); + self::assertFalse($provider->isFileAllowed(__FILE__)); + self::assertFalse($provider->getFolderFrom('unknown')); + + $relative = $provider->makeImagePathsWebRelative(['logo' => $allowedImage], 'en'); + self::assertStringStartsWith('/template/images/', $relative['logo']); + self::assertStringContainsString('_locale=en', $relative['logo']); } - public function testMakeImagePathsWebRelative() + public function testWebViewPolicyAndTokenStayStable(): void { - $mocks = $this->getMockSetup(); - $templateProvider = new AzineTemplateProvider($mocks['router'], $mocks['translator'], $mocks['params']); - $locale = 'en'; - - $contentVars = array('testVar' => 'testValue'); - $contentVars[AzineTemplateProvider::CONTENT_ITEMS] = array(array(AzineTemplateProvider::CONTENT_ITEM_MESSAGE_TEMPLATE => array('otherTestVar' => 'otherTestValue'))); - $contentVars = $templateProvider->addTemplateVariablesFor(AzineTemplateProvider::BASE_TEMPLATE, $contentVars); - $contentVars = $templateProvider->addTemplateSnippetsWithImagesFor(AzineTemplateProvider::BASE_TEMPLATE, $contentVars, $locale); - - $relativeVars = $templateProvider->makeImagePathsWebRelative($contentVars, $locale); - $this->assertTrue(is_file(realpath($contentVars['logo_png']))); - $this->assertNotSame($relativeVars['logo_png'], $contentVars['logo_png']); - - $contentItemImage = $contentVars[AzineTemplateProvider::CONTENT_ITEMS][0][AzineTemplateProvider::CONTENT_ITEM_MESSAGE_TEMPLATE]['logo_png']; - $contentItemImage2 = $relativeVars[AzineTemplateProvider::CONTENT_ITEMS][0][AzineTemplateProvider::CONTENT_ITEM_MESSAGE_TEMPLATE]['logo_png']; - $this->assertTrue(is_file(realpath($contentItemImage))); - $this->assertNotSame($contentItemImage, $contentItemImage2); - } + $provider = $this->createProvider(); - public function testGetWebViewTokenId() - { - $mocks = $this->getMockSetup(); - $templateProvider = new AzineTemplateProvider($mocks['router'], $mocks['translator'], $mocks['params']); - $this->assertSame(AzineTemplateProvider::EMAIL_WEB_VIEW_TOKEN, $templateProvider->getWebViewTokenId()); + self::assertSame(AzineTemplateProvider::EMAIL_WEB_VIEW_TOKEN, $provider->getWebViewTokenId()); + self::assertTrue($provider->saveWebViewFor(AzineTemplateProvider::NEWSLETTER_TEMPLATE)); + self::assertFalse($provider->saveWebViewFor(AzineTemplateProvider::NOTIFICATIONS_TEMPLATE)); + self::assertFalse($provider->saveWebViewFor(AzineTemplateProvider::FOS_USER_REGISTRATION_TEMPLATE)); } - public function testSaveWebViewFor() + public function testSymfonyMimeCustomHeaders(): void { - $mocks = $this->getMockSetup(); - $templateProvider = new AzineTemplateProvider($mocks['router'], $mocks['translator'], $mocks['params']); - - $this->assertFalse($templateProvider->saveWebViewFor(AzineTemplateProvider::FOS_USER_PWD_RESETTING_TEMPLATE)); - $this->assertFalse($templateProvider->saveWebViewFor(AzineTemplateProvider::FOS_USER_REGISTRATION_TEMPLATE)); - $this->assertFalse($templateProvider->saveWebViewFor(AzineTemplateProvider::NOTIFICATIONS_TEMPLATE)); - $this->assertTrue($templateProvider->saveWebViewFor(AzineTemplateProvider::NEWSLETTER_TEMPLATE)); - $this->assertFalse($templateProvider->saveWebViewFor('some other string')); + $provider = $this->createProvider(true); + $email = new Email(); + + $provider->addCustomHeadersToEmail('testTemplate', $email, [ + AzineTemplateProvider::EMAIL_WEB_VIEW_TOKEN => 'testToken', + AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_NAME => 'testCampaignValue', + AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_SOURCE => 'testSourceValue', + ]); + + $headers = $email->getHeaders(); + self::assertSame('testToken', $headers->get('x-azine-webview-token')?->getBodyAsString()); + self::assertSame('testCampaignValue', $headers->get('x-utm_campaign')?->getBodyAsString()); + self::assertSame('testSourceValue', $headers->get('x-utm_source')?->getBodyAsString()); } - public function testAddCustomHeaders() + private function createProvider(bool $symfonyMailerProvider = false): AzineTemplateProvider { - $message = new \Swift_Message(); - - $mocks = $this->getMockSetup(); - $templateProvider = new AzineTemplateProvider($mocks['router'], $mocks['translator'], $mocks['params']); - - $tokenValue = 'testToken'; - $campaignValue = 'testCampaignValue'; - $sourceValue = 'testSourceValue'; - - $params = array(AzineTemplateProvider::EMAIL_WEB_VIEW_TOKEN => $tokenValue, - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_NAME => $campaignValue, - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_SOURCE => $sourceValue, ); - - $templateProvider->addCustomHeaders('testTemplate', $message, $params); - - $headerSet = $message->getHeaders(); - $this->assertTrue($headerSet->has('x-azine-webview-token')); - $this->assertSame($headerSet->get('x-azine-webview-token')->getValue(), $tokenValue); - $this->assertTrue($headerSet->has('x-utm_campaign')); - $this->assertSame($headerSet->get('x-utm_campaign')->getValue(), $campaignValue); - $this->assertTrue($headerSet->has('x-utm_source')); - $this->assertSame($headerSet->get('x-utm_source')->getValue(), $sourceValue); + $translator = $this->createMock(TranslatorInterface::class); + $translator + ->method('trans') + ->willReturnCallback(static function ( + string $id, + array $parameters = [], + ?string $domain = null, + ?string $locale = null, + ): string { + return 'de' === $locale ? 'de übersetzung' : 'en translation'; + }); + + $router = $this->createMock(UrlGeneratorInterface::class); + $router + ->method('generate') + ->willReturnCallback(static function (string $routeName, array $parameters = []): string { + if ('azine_email_serve_template_image' === $routeName) { + return sprintf( + '/template/images/%s?_locale=%s', + $parameters['filename'], + $parameters['_locale'], + ); + } + + return '/some/relative/url'; + }); + + $parameters = [ + AzineEmailExtension::TEMPLATE_IMAGE_DIR => realpath(__DIR__.'/../../Resources/htmlTemplateImages/'), + AzineEmailExtension::ALLOWED_IMAGES_FOLDERS => [ + realpath(__DIR__.'/../../Resources/htmlTemplateImages/'), + ], + AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_NAME => 'utm_campaign', + AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_TERM => 'utm_term', + AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_SOURCE => 'utm_source', + AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_MEDIUM => 'utm_medium', + AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_CONTENT => 'utm_content', + ]; + + return $symfonyMailerProvider + ? new SymfonyMailerTemplateProvider($router, $translator, $parameters) + : new AzineTemplateProvider($router, $translator, $parameters); } } diff --git a/Tests/Services/AzineTwigSwiftMailerTest.php b/Tests/Services/AzineTwigSwiftMailerTest.php index 02851364..2413c286 100644 --- a/Tests/Services/AzineTwigSwiftMailerTest.php +++ b/Tests/Services/AzineTwigSwiftMailerTest.php @@ -1,416 +1,227 @@ getMockBuilder("\Swift_Mailer")->disableOriginalConstructor()->getMock(); - $mocks['mailer']->expects($this->once())->method('send')->will($this->returnCallback($sendCallback)); - $mocks['router'] = $this->getMockBuilder("Symfony\Component\Routing\Generator\UrlGeneratorInterface")->disableOriginalConstructor()->getMock(); - $mocks['twig'] = $this->getMockBuilder("\Twig_Environment")->disableOriginalConstructor()->getMock(); - $mocks['baseTemplateMock'] = $this->getMockBuilder("\Twig\Template")->disableOriginalConstructor()->setMethods(array('renderBlock'))->getMockForAbstractClass(); - $mocks['twig']->expects($this->once())->method('loadTemplate')->will($this->returnValue($mocks['baseTemplateMock'])); - - $mocks['translator'] = $this->getMockBuilder("Symfony\Bundle\FrameworkBundle\Translation\Translator")->disableOriginalConstructor()->getMock(); - $mocks['translator']->expects($this->any())->method('trans')->will($this->returnValue('azine.translation.mock')); - - $imagesDir = realpath(__DIR__.'/../../Resources/htmlTemplateImages/'); - $mocks['templateProvider'] = new AzineTemplateProvider($mocks['router'], $mocks['translator'], array(AzineEmailExtension::ALLOWED_IMAGES_FOLDERS => array($imagesDir), - AzineEmailExtension::TEMPLATE_IMAGE_DIR => $imagesDir, - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_NAME => 'utm_campaign', - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_TERM => 'utm_term', - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_SOURCE => 'utm_source', - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_MEDIUM => 'utm_medium', - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_CONTENT => 'utm_content', - )); - $this->getMockBuilder("Azine\EmailBundle\Services\AzineTemplateProvider")->disableOriginalConstructor()->getMock(); - - $mocks['entityManager'] = $this->getMockBuilder("Doctrine\ORM\EntityManager")->disableOriginalConstructor()->getMock(); - $mocks['managerRegistry'] = $this->getMockBuilder("Doctrine\Persistence\ManagerRegistry")->disableOriginalConstructor()->getMock(); - $mocks['managerRegistry']->expects($this->any())->method('getManager')->will($this->returnValue($mocks['entityManager'])); - - $mocks['parameters'] = array(AzineEmailExtension::NO_REPLY => array( - AzineEmailExtension::NO_REPLY_EMAIL_ADDRESS => 'no-reply@address.com', - AzineEmailExtension::NO_REPLY_EMAIL_NAME => 'no-reply-name', ), - AzineTemplateProvider::CONTENT_ITEMS => array( - 0 => array(AzineTemplateProvider::CONTENT_ITEM_MESSAGE_TEMPLATE => array('notification' => array('title' => 'some title', 'created' => new \DateTime('2 hours ago'), 'content' => 'some content'))), - 1 => array(AzineTemplateProvider::CONTENT_ITEM_MESSAGE_TEMPLATE => array('notification' => array('title' => 'some other title', 'created' => new \DateTime('1 hours ago'), 'content' => 'some other content'))), - ), - 'logo_png' => $imagesDir.'/logo.png', - 'noFile_png' => $imagesDir.'/../../../unallowedFolder/logo.png', - 'not_allowed_png' => $imagesDir.'/inexistentFile.png', - ); - $requestContext = $this->getMockBuilder("Symfony\Component\Routing\RequestContext")->disableOriginalConstructor()->getMock(); - $mocks['router']->expects($this->once())->method('getContext')->will($this->returnValue($requestContext)); - - $mocks['trackingCodeImgBuilder'] = $this->getMockBuilder("Azine\EmailBundle\Services\AzineEmailOpenTrackingCodeBuilder")->setConstructorArgs(array('https://www.google-analytics.com/?tid=blabla', array(AzineEmailExtension::ALLOWED_IMAGES_FOLDERS => array($imagesDir), - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_NAME => 'utm_campaign', - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_TERM => 'utm_term', - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_SOURCE => 'utm_source', - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_MEDIUM => 'utm_medium', - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_CONTENT => 'utm_content', - )))->getMock(); - $mocks['trackingCodeImgBuilder']->expects($this->any())->method('getTrackingImgCode')->will($this->returnValue("")); - - $mocks['emailTwigExtension'] = new AzineEmailTwigExtension($mocks['templateProvider'], $mocks['translator'], array('testurl.com')); - - return $mocks; - } - - public function returnOne($message, &$failedRecipients = null) + public function testSendsMultipartEmailWithLegacyServiceApi(): void { - return 1; - } - - public function returnOneValidateCampaignUrls($message, &$failedRecipients = null) - { - $body = $message->getBody(); - - // has a email-tracking-image at the end - $this->assertStringContainsString("assertStringContainsString('&utm_medium=email', $body, 'Email links are expected to have tracking parameters attached.'); - - return 1; - } - - public function returnZeroWithFailedAddress($message, &$failedRecipients = null) - { - $failedRecipients[] = $message->getTo(); - - return 0; - } - - /** - * @return \FOS\UserBundle\Model\UserInterface - */ - private function getUserMock() - { - $user = $this->getMockBuilder("FOS\UserBundle\Model\UserInterface")->disableOriginalConstructor()->getMock(); - $user->expects($this->once())->method('getEmail')->will($this->returnValue('user@email.com')); - $user->expects($this->any())->method('getConfirmationToken')->will($this->returnValue('aptrqi3o4pte:::token:::zfpguhask5jx0a9xukp')); - - return $user; - } - - public function renderBlockCallback($name, $context = array(), $blocks = array()) - { - if ('subject' == $name) { - return 'a subject'; - } elseif ('body_html' == $name) { - $generatedImage = ''; - if (array_key_exists('embededUsedGeneratedImage', $context)) { - $generatedImage = "generatedImage"; - } - - return "

a html body

$generatedImagelogo

with a paragraph and links.

"; - } elseif ('body_text' == $name) { - return "a text body \n \n with new lines."; - } - throw new \Exception("un-known block : '$name'"); - } - - public function generateCallback($name, $parameters = array(), $referenceType = self::ABSOLUTE_PATH) - { - if ('fos_user_registration_confirm' == $name) { - return 'http://azine.bundle.com/confirmation/url/'.$parameters['token']; - } elseif ('fos_user_resetting_reset' == $name) { - return 'http://azine.bundle.com/resetting/url/'.$parameters['token']; - } elseif ('azine_email_serve_template_image' == $name) { - return 'http://azine.bundle.com/image/url/logo.png'; - } - throw new \Exception("un-expected route for url-generation : '$name'"); - } - - public function testSendSingleEmail() - { - $mocks = $this->getMockSetup(array($this, 'returnOneValidateCampaignUrls')); - $mocks['baseTemplateMock']->expects($this->exactly(2))->method('renderBlock')->will($this->returnCallback(array($this, 'renderBlockCallback'))); - $mocks['translator']->expects($this->once())->method('getLocale')->will($this->returnValue('en')); - $mocks['router']->expects($this->exactly(12))->method('generate')->will($this->returnCallback(array($this, 'generateCallback'))); - - $azineMailer = new AzineTwigSwiftMailer($mocks['mailer'], $mocks['router'], $mocks['twig'], $mocks['translator'], $mocks['templateProvider'], $mocks['managerRegistry'], $mocks['trackingCodeImgBuilder'], $mocks['emailTwigExtension'], $mocks['parameters']); - - $to = 'to@mail.com'; - $toName = 'ToName'; - $params = array('aKey' => 'aValue', 'contentItems' => array(array(AzineTemplateProvider::CONTENT_ITEM_MESSAGE_TEMPLATE => array('someOtherKey' => 'someOtherValue')))); - $template = AzineTemplateProvider::NEWSLETTER_TEMPLATE.'.txt.twig'; - $emailLocale = 'en'; - $subject = 'custom subject'; - $azineMailer->sendSingleEmail($to, $toName, $subject, $params, $template, $emailLocale); + $mailer = $this->createMock(MailerInterface::class); + $mailer + ->expects(self::once()) + ->method('send') + ->with(self::callback(static function (Email $email): bool { + return 'A subject' === $email->getSubject() + && 'to@example.com' === $email->getTo()[0]->getAddress() + && 'no-reply@example.com' === $email->getFrom()[0]->getAddress() + && 'Hello Dominik' === trim((string) $email->getTextBody()) + && str_contains((string) $email->getHtmlBody(), 'Dominik') + && 'present' === $email->getHeaders()->get('X-Azine-Test')?->getBodyAsString(); + })); + + $service = $this->createService($mailer); + $message = null; + + self::assertTrue($service->sendSingleEmail( + 'to@example.com', + 'Recipient', + 'A subject', + ['name' => 'Dominik'], + 'email.txt.twig', + 'en', + message: $message, + )); + self::assertInstanceOf(Email::class, $message); } - public function testSendSingleEmailFails() + public function testImmediateFlagUsesImmediateMailer(): void { - $mocks = array(); - $mocks['mailer'] = $this->getMockBuilder("\Swift_Mailer")->disableOriginalConstructor()->getMock(); - $mocks['mailer']->expects($this->once())->method('send')->will($this->returnCallback(array($this, 'returnZeroWithFailedAddress'))); - $mocks['router'] = $this->getMockBuilder("Symfony\Component\Routing\Generator\UrlGeneratorInterface")->disableOriginalConstructor()->getMock(); - $mocks['twig'] = $this->getMockBuilder("\Twig_Environment")->disableOriginalConstructor()->getMock(); - $mocks['baseTemplateMock'] = $this->getMockBuilder("\Twig\Template")->disableOriginalConstructor()->setMethods(array('renderBlock'))->getMockForAbstractClass(); - $mocks['twig']->expects($this->once())->method('loadTemplate')->will($this->returnValue($mocks['baseTemplateMock'])); - - $mocks['translator'] = $this->getMockBuilder("Symfony\Bundle\FrameworkBundle\Translation\Translator")->disableOriginalConstructor()->getMock(); - $mocks['translator']->expects($this->any())->method('trans')->will($this->returnValue('azine.translation.mock')); - - $imagesDir = realpath(__DIR__.'/../../Resources/htmlTemplateImages/'); - $mocks['templateProvider'] = new AzineTemplateProvider($mocks['router'], $mocks['translator'], array(AzineEmailExtension::ALLOWED_IMAGES_FOLDERS => array($imagesDir), - AzineEmailExtension::TEMPLATE_IMAGE_DIR => $imagesDir, - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_NAME => 'utm_campaign', - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_TERM => 'utm_term', - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_SOURCE => 'utm_source', - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_MEDIUM => 'utm_medium', - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_CONTENT => 'utm_content', + $defaultMailer = $this->createMock(MailerInterface::class); + $defaultMailer->expects(self::never())->method('send'); + + $immediateMailer = $this->createMock(MailerInterface::class); + $immediateMailer->expects(self::once())->method('send'); + + $service = $this->createService($defaultMailer, $immediateMailer, true); + $message = null; + + self::assertTrue($service->sendSingleEmail( + 'to@example.com', + null, + 'Immediate subject', + ['name' => 'Dominik'], + 'email.txt.twig', + 'en', + message: $message, )); - $this->getMockBuilder("Azine\EmailBundle\Services\AzineTemplateProvider")->disableOriginalConstructor()->getMock(); - - $mocks['entityManager'] = $this->getMockBuilder("Doctrine\ORM\EntityManager")->disableOriginalConstructor()->getMock(); - $mocks['managerRegistry'] = $this->getMockBuilder("Doctrine\Persistence\ManagerRegistry")->disableOriginalConstructor()->getMock(); - $mocks['managerRegistry']->expects($this->any())->method('getManager')->will($this->returnValue($mocks['entityManager'])); - - $mocks['parameters'] = array(AzineEmailExtension::NO_REPLY => array( - AzineEmailExtension::NO_REPLY_EMAIL_ADDRESS => 'no-reply@address.com', - AzineEmailExtension::NO_REPLY_EMAIL_NAME => 'no-reply-name', ), - AzineTemplateProvider::CONTENT_ITEMS => array( - 0 => array(AzineTemplateProvider::CONTENT_ITEM_MESSAGE_TEMPLATE => array('notification' => array('title' => 'some title', 'created' => new \DateTime('2 hours ago'), 'content' => 'some content'))), - 1 => array(AzineTemplateProvider::CONTENT_ITEM_MESSAGE_TEMPLATE => array('notification' => array('title' => 'some other title', 'created' => new \DateTime('1 hours ago'), 'content' => 'some other content'))), - ), - 'logo_png' => $imagesDir.'/logo.png', - 'noFile_png' => $imagesDir.'/../../../unallowedFolder/logo.png', - 'not_allowed_png' => $imagesDir.'/inexistentFile.png', - ); - $requestContext = $this->getMockBuilder("Symfony\Component\Routing\RequestContext")->disableOriginalConstructor()->getMock(); - $mocks['router']->expects($this->once())->method('getContext')->will($this->returnValue($requestContext)); - - $mocks['trackingCodeImgBuilder'] = $this->getMockBuilder("Azine\EmailBundle\Services\AzineEmailOpenTrackingCodeBuilder")->setConstructorArgs(array('https://www.google-analytics.com/?tid=blabla', array(AzineEmailExtension::ALLOWED_IMAGES_FOLDERS => array($imagesDir), - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_NAME => 'utm_campaign', - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_TERM => 'utm_term', - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_SOURCE => 'utm_source', - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_MEDIUM => 'utm_medium', - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_CONTENT => 'utm_content', - )))->getMock(); - - $mocks['baseTemplateMock']->expects($this->exactly(2))->method('renderBlock')->will($this->returnCallback(array($this, 'renderBlockCallback'))); - $mocks['translator']->expects($this->once())->method('getLocale')->will($this->returnValue('en')); - - $mocks['emailTwigExtension'] = $this->getMockBuilder("Azine\EmailBundle\Services\AzineEmailTwigExtension")->disableOriginalConstructor()->getMock(); - $mocks['emailTwigExtension']->expects($this->exactly(1))->method('addCampaignParamsToAllUrls')->will($this->returnArgument(0)); - - $azineMailer = new AzineTwigSwiftMailer($mocks['mailer'], $mocks['router'], $mocks['twig'], $mocks['translator'], $mocks['templateProvider'], $mocks['managerRegistry'], $mocks['trackingCodeImgBuilder'], $mocks['emailTwigExtension'], $mocks['parameters']); - - $to = 'to@mail.com'; - $toName = 'ToName'; - $params = array('aKey' => 'aValue', 'contentItems' => array(array(AzineTemplateProvider::CONTENT_ITEM_MESSAGE_TEMPLATE => array('someOtherKey' => 'someOtherValue')))); - $template = AzineTemplateProvider::NEWSLETTER_TEMPLATE.'.txt.twig'; - $emailLocale = 'en'; - $subject = 'custom subject'; - $result = $azineMailer->sendSingleEmail($to, $toName, $subject, $params, $template, $emailLocale); - $this->assertFalse($result, 'expected send to fail'); } - public function testSendEmailWithEmailLocaleAndAttachments() + public function testTransportFailureReturnsFalseAndExposesFailedRecipient(): void { - $mocks = $this->getMockSetup(array($this, 'returnOne')); - $mocks['baseTemplateMock']->expects($this->exactly(2))->method('renderBlock')->will($this->returnCallback(array($this, 'renderBlockCallback'))); - $mocks['translator']->expects($this->once())->method('getLocale')->will($this->returnValue('en')); - $mocks['router']->expects($this->exactly(6))->method('generate')->will($this->returnCallback(array($this, 'generateCallback'))); - - $azineMailer = new AzineTwigSwiftMailer($mocks['mailer'], $mocks['router'], $mocks['twig'], $mocks['translator'], $mocks['templateProvider'], $mocks['managerRegistry'], $mocks['trackingCodeImgBuilder'], $mocks['emailTwigExtension'], $mocks['parameters']); - - $failedRecipients = array(); - $from = 'from@email.com'; - $fromName = 'FromName'; - $to = 'to@mail.com'; - $toName = 'ToName'; - $cc = 'cc@mail.com'; - $ccName = 'CcName'; - $bcc = 'bcc@email.com'; - $bccName = 'BccName'; - $replyTo = 'replyTo@email.com'; - $replyToName = 'ReplyToName'; - $subject = 'some dummy test subject'; - $params = array(); - $generatedImage = imagecreate(100, 100); - $background_color = imagecolorallocate($generatedImage, 0, 0, 0); - $text_color = imagecolorallocate($generatedImage, 233, 14, 91); - imagestring($generatedImage, 1, 5, 5, 'A Simple Text String', $text_color); - $template = AzineTemplateProvider::NEWSLETTER_TEMPLATE.'.txt.twig'; - - // embed a regular file, a generated file and an invalid file - $params['embededUnusedFile'] = __FILE__; - $params['embededUnusedGeneratedFile'] = $generatedImage; - $params['embededUsedGeneratedImage'] = $generatedImage; - $params['embededUnusedInexistentFile'] = __FILE__.'not.existent.jpg'; - - // attach a regular file and a generated file - $attachments = array('regularFile' => __FILE__, 'generatedFile' => $generatedImage, 'fileWithVeryShortName.replacement.txt' => __DIR__.'/a.b'); - $emailLocale = 'en'; - - $azineMailer->sendEmail($failedRecipients, $subject, $from, $fromName, $to, $toName, $cc, $ccName, $bcc, $bccName, $replyTo, $replyToName, $params, $template, $attachments, $emailLocale); + $mailer = $this->createMock(MailerInterface::class); + $mailer + ->method('send') + ->willThrowException(new TransportException('Transport unavailable.')); + + $service = $this->createService($mailer); + $failedRecipients = []; + $message = null; + + self::assertSame(0, $service->sendEmail( + $failedRecipients, + 'A subject', + null, + null, + 'to@example.com', + null, + null, + null, + null, + null, + null, + null, + ['name' => 'Dominik'], + 'email.txt.twig', + emailLocale: 'en', + message: $message, + )); + self::assertSame(['to@example.com'], $failedRecipients); } - /** - * @expectedException \Symfony\Component\HttpFoundation\File\Exception\FileException - */ - public function testSendEmailWithEmailLocaleAndInexistentAttachment() - { - $mocks['mailer'] = $this->getMockBuilder("\Swift_Mailer")->disableOriginalConstructor()->getMock(); - $mocks['mailer']->expects($this->never())->method('send'); - - $mocks['router'] = $this->getMockBuilder("Symfony\Component\Routing\Generator\UrlGeneratorInterface")->disableOriginalConstructor()->getMock(); - $mocks['twig'] = $this->getMockBuilder("\Twig_Environment")->disableOriginalConstructor()->getMock(); - $mocks['baseTemplateMock'] = $this->getMockBuilder("\Twig\Template")->disableOriginalConstructor()->setMethods(array('renderBlock'))->getMockForAbstractClass(); - $mocks['twig']->expects($this->once())->method('loadTemplate')->will($this->returnValue($mocks['baseTemplateMock'])); - - $mocks['translator'] = $this->getMockBuilder("Symfony\Bundle\FrameworkBundle\Translation\Translator")->disableOriginalConstructor()->getMock(); - $mocks['translator']->expects($this->any())->method('trans')->will($this->returnValue('azine.translation.mock')); - - $imagesDir = realpath(__DIR__.'/../../Resources/htmlTemplateImages/'); - $mocks['templateProvider'] = new AzineTemplateProvider($mocks['router'], $mocks['translator'], array(AzineEmailExtension::ALLOWED_IMAGES_FOLDERS => array($imagesDir), - AzineEmailExtension::TEMPLATE_IMAGE_DIR => $imagesDir, - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_NAME => 'utm_campaign', - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_TERM => 'utm_term', - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_SOURCE => 'utm_source', - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_MEDIUM => 'utm_medium', - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_CONTENT => 'utm_content', - )); - $this->getMockBuilder("Azine\EmailBundle\Services\AzineTemplateProvider")->disableOriginalConstructor()->getMock(); - - $mocks['entityManager'] = $this->getMockBuilder("Doctrine\ORM\EntityManager")->disableOriginalConstructor()->getMock(); - $mocks['managerRegistry'] = $this->getMockBuilder("Doctrine\Persistence\ManagerRegistry")->disableOriginalConstructor()->getMock(); - $mocks['managerRegistry']->expects($this->any())->method('getManager')->will($this->returnValue($mocks['entityManager'])); - - $mocks['parameters'] = array(AzineEmailExtension::NO_REPLY => array( - AzineEmailExtension::NO_REPLY_EMAIL_ADDRESS => 'no-reply@address.com', - AzineEmailExtension::NO_REPLY_EMAIL_NAME => 'no-reply-name', ), - AzineTemplateProvider::CONTENT_ITEMS => array( - 0 => array(AzineTemplateProvider::CONTENT_ITEM_MESSAGE_TEMPLATE => array('notification' => array('title' => 'some title', 'created' => new \DateTime('2 hours ago'), 'content' => 'some content'))), - 1 => array(AzineTemplateProvider::CONTENT_ITEM_MESSAGE_TEMPLATE => array('notification' => array('title' => 'some other title', 'created' => new \DateTime('1 hours ago'), 'content' => 'some other content'))), - ), - 'logo_png' => $imagesDir.'/logo.png', - 'noFile_png' => $imagesDir.'/../../../unallowedFolder/logo.png', - 'not_allowed_png' => $imagesDir.'/inexistentFile.png', - ); - $requestContext = $this->getMockBuilder("Symfony\Component\Routing\RequestContext")->disableOriginalConstructor()->getMock(); - $mocks['router']->expects($this->once())->method('getContext')->will($this->returnValue($requestContext)); - $mocks['baseTemplateMock']->expects($this->exactly(2))->method('renderBlock')->will($this->returnCallback(array($this, 'renderBlockCallback'))); - $mocks['translator']->expects($this->once())->method('getLocale')->will($this->returnValue('en')); - $mocks['router']->expects($this->never())->method('generate'); - - $mocks['trackingCodeImgBuilder'] = $this->getMockBuilder("Azine\EmailBundle\Services\AzineEmailOpenTrackingCodeBuilder")->setConstructorArgs(array('http://www.google-analytics.com/?', array(AzineEmailExtension::ALLOWED_IMAGES_FOLDERS => array($imagesDir), - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_NAME => 'utm_campaign', - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_TERM => 'utm_term', - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_SOURCE => 'utm_source', - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_MEDIUM => 'utm_medium', - AzineEmailExtension::TRACKING_PARAM_CAMPAIGN_CONTENT => 'utm_content', - )))->getMock(); - - $mocks['emailTwigExtension'] = $this->getMockBuilder("Azine\EmailBundle\Services\AzineEmailTwigExtension")->disableOriginalConstructor()->getMock(); - $mocks['emailTwigExtension']->expects($this->exactly(1))->method('addCampaignParamsToAllUrls')->will($this->returnArgument(0)); - - $azineMailer = new AzineTwigSwiftMailer($mocks['mailer'], $mocks['router'], $mocks['twig'], $mocks['translator'], $mocks['templateProvider'], $mocks['managerRegistry'], $mocks['trackingCodeImgBuilder'], $mocks['emailTwigExtension'], $mocks['parameters']); - - $failedRecipients = array(); - $from = 'from@email.com'; - $fromName = 'FromName'; - $to = 'to@mail.com'; - $toName = 'ToName'; - $cc = 'cc@mail.com'; - $ccName = 'CcName'; - $bcc = 'bcc@email.com'; - $bccName = 'BccName'; - $replyTo = 'replyTo@email.com'; - $replyToName = 'ReplyToName'; - $subject = 'some dummy test subject'; - $params = array(); - $template = AzineTemplateProvider::NEWSLETTER_TEMPLATE.'.txt.twig'; - - // embed an inexistent file - $params['embededUnusedInexistentFile'] = __FILE__.'not.existent.jpg'; - - // attach an inexistent file - $attachments = array(__FILE__.'not.existent.jpg'); - $emailLocale = 'en'; - - $azineMailer->sendEmail($failedRecipients, $subject, $from, $fromName, $to, $toName, $cc, $ccName, $bcc, $bccName, $replyTo, $replyToName, $params, $template, $attachments, $emailLocale); + private function createService( + MailerInterface $mailer, + ?MailerInterface $immediateMailer = null, + bool $sendImmediately = false, + ): AzineTwigMailer { + $provider = $this->createTemplateProvider($sendImmediately); + $translator = $this->createMock(TranslatorInterface::class); + $translator->method('getLocale')->willReturn('en'); + + $router = $this->createMock(RouterInterface::class); + $router->method('getContext')->willReturn(new RequestContext()); + + $twig = new Environment(new ArrayLoader([ + 'email.txt.twig' => <<<'TWIG' +{% block subject %}Template subject{% endblock %} +{% block body_text %}Hello {{ name }}{% endblock %} +{% block body_html %}{{ name }}{% endblock %} +TWIG, + ])); + + return new AzineTwigMailer( + $mailer, + $router, + $twig, + $translator, + $provider, + $this->createMock(ManagerRegistry::class), + null, + new AzineEmailTwigExtension($provider, $translator), + [ + AzineEmailExtension::NO_REPLY => [ + AzineEmailExtension::NO_REPLY_EMAIL_ADDRESS => 'no-reply@example.com', + AzineEmailExtension::NO_REPLY_EMAIL_NAME => 'Azine Mailer', + ], + 'template' => [ + 'confirmation' => 'email.txt.twig', + 'resetting' => 'email.txt.twig', + 'email_updating' => 'email.txt.twig', + ], + 'from_email' => [ + 'confirmation' => ['address' => 'no-reply@example.com', 'sender_name' => 'Azine Mailer'], + 'resetting' => ['address' => 'no-reply@example.com', 'sender_name' => 'Azine Mailer'], + ], + ], + $immediateMailer, + ); } - public function testSendEmailWithOutEmailLocaleAndNoAttachment() + private function createTemplateProvider(bool $sendImmediately): TemplateProviderInterface { - $mocks = $this->getMockSetup(array($this, 'returnOne')); - $mocks['baseTemplateMock']->expects($this->exactly(2))->method('renderBlock')->will($this->returnCallback(array($this, 'renderBlockCallback'))); - $mocks['translator']->expects($this->once())->method('getLocale')->will($this->returnValue('en')); - $mocks['router']->expects($this->exactly(0))->method('generate')->will($this->returnCallback(array($this, 'generateCallback'))); - - $azineMailer = new AzineTwigSwiftMailer($mocks['mailer'], $mocks['router'], $mocks['twig'], $mocks['translator'], $mocks['templateProvider'], $mocks['managerRegistry'], $mocks['trackingCodeImgBuilder'], $mocks['emailTwigExtension'], $mocks['parameters']); + return new class($sendImmediately) implements TemplateProviderInterface, SymfonyMailerTemplateProviderInterface { + public function __construct(private readonly bool $sendImmediately) + { + } - $failedRecipients = array(); - $from = 'from@email.com'; - $fromName = 'FromName'; - $to = 'to@mail.com'; - $toName = 'ToName'; - $cc = 'cc@mail.com'; - $ccName = 'CcName'; - $bcc = 'bcc@email.com'; - $bccName = 'BccName'; - $replyTo = 'replyTo@email.com'; - $replyToName = 'ReplyToName'; - $subject = 'some dummy test subject'; - $params = array(); - $template = AzineTemplateProvider::BASE_TEMPLATE.'.txt.twig'; - $attachments = array(); - $emailLocale = null; + public function addTemplateVariablesFor($template, array $contentVariables) + { + if ($this->sendImmediately) { + $contentVariables[AzineTemplateProvider::SEND_IMMEDIATELY_FLAG] = true; + } - $sentCount = $azineMailer->sendEmail($failedRecipients, $subject, $from, $fromName, $to, $toName, $cc, $ccName, $bcc, $bccName, $replyTo, $replyToName, $params, $template, $attachments, $emailLocale); + return $contentVariables; + } - $this->assertSame(1, $sentCount, 'One email should have been sent.'); - } + public function addTemplateSnippetsWithImagesFor($template, array $vars, $emailLocale, $forWebView = false) + { + return $vars; + } - public function testSendConfirmationEmailMessage() - { - $azineMailer = $this->prepareForSendTest(); - $azineMailer->sendConfirmationEmailMessage($this->getUserMock()); - } + public function addCustomHeaders($template, $message, array $params): void + { + if ($message instanceof Email) { + $this->addCustomHeadersToEmail((string) $template, $message, $params); + } + } - public function testSendResettingEmailMessage() - { - $azineMailer = $this->prepareForSendTest(); - $azineMailer->sendResettingEmailMessage($this->getUserMock()); - } + public function addCustomHeadersToEmail(string $template, Email $message, array $params): void + { + $message->getHeaders()->addTextHeader('X-Azine-Test', 'present'); + } - /** - * @param $templateBaseId - * - * @return AzineTwigSwiftMailer - */ - private function prepareForSendTest() - { - $mocks = $this->getMockSetup(array($this, 'returnOne')); + public function getTemplateImageDir() + { + return __DIR__; + } - // as the subject from FOS-templates is embeded in the twig-template, the render-block is called 3 instead of only 2 times - $mocks['baseTemplateMock']->expects($this->exactly(3))->method('renderBlock')->will($this->returnCallback(array($this, 'renderBlockCallback'))); + public function makeImagePathsWebRelative(array $emailVars, $locale) + { + return $emailVars; + } - $mocks['parameters']['template'] = array(); - $mocks['parameters']['template']['confirmation'] = AzineTemplateProvider::FOS_USER_REGISTRATION_TEMPLATE.'.txt.twig'; - $mocks['parameters']['template']['resetting'] = AzineTemplateProvider::FOS_USER_PWD_RESETTING_TEMPLATE.'.txt.twig'; - $mocks['parameters']['from_email'] = array(); - $mocks['parameters']['from_email']['confirmation'] = 'from@email.com'; - $mocks['parameters']['from_email']['resetting'] = 'from@email.com'; + public function isFileAllowed($filePath) + { + return false; + } - $mocks['router']->expects($this->once())->method('generate')->will($this->returnCallback(array($this, 'generateCallback'))); + public function getFolderFrom($key) + { + return false; + } - $mocks['translator']->expects($this->exactly(2))->method('getLocale')->will($this->returnValue('en')); + public function saveWebViewFor($template) + { + return false; + } - $azineMailer = new AzineTwigSwiftMailer($mocks['mailer'], $mocks['router'], $mocks['twig'], $mocks['translator'], $mocks['templateProvider'], $mocks['managerRegistry'], $mocks['trackingCodeImgBuilder'], $mocks['emailTwigExtension'], $mocks['parameters']); + public function getWebViewTokenId() + { + return 'azineEmailWebViewToken'; + } - return $azineMailer; + public function getCampaignParamsFor($templateId, array $params = null) + { + return []; + } + }; } } diff --git a/Tests/Services/AzineWebViewServiceTest.php b/Tests/Services/AzineWebViewServiceTest.php index 406f3dc9..bc13babf 100644 --- a/Tests/Services/AzineWebViewServiceTest.php +++ b/Tests/Services/AzineWebViewServiceTest.php @@ -1,88 +1,65 @@ getMockBuilder("Symfony\Component\Routing\Generator\UrlGeneratorInterface")->disableOriginalConstructor()->getMock(); - } - - public function testGetTemplatesForWebView() - { - $routerMock = $this->getMockRouter(); - $webViewService = new AzineWebViewService($routerMock); - - $this->assertTrue(is_array($webViewService->getTemplatesForWebPreView())); - } - - public function testGetTestMailAccounts() - { - $routerMock = $this->getMockRouter(); - $webViewService = new AzineWebViewService($routerMock); - - $this->assertTrue(is_array($webViewService->getTestMailAccounts())); - } - - public function testGetDummyVarsFor() - { - $routerMock = $this->getMockRouter(); - $webViewService = new AzineWebViewService($routerMock); - - $this->assertTrue(is_array($webViewService->getDummyVarsFor('some template', 'de'))); - } - - public function testAddTestMailAccount() + public function testDefaultCollectionsAreArrays(): void { - $routerMock = $this->getMockRouter(); - $webViewService = new AzineWebViewService($routerMock); - - $description = 'Some description'; - $emailAddress = 'sfsdf@mail.com'; - $args = array(array(), $description, $emailAddress); - $returnValue = self::getMethod('addTestMailAccount')->invokeArgs($webViewService, $args); + $service = new AzineWebViewService($this->createMock(UrlGeneratorInterface::class)); - $this->assertSame(array('accountDescription' => $description, 'accountEmail' => $emailAddress), $returnValue[0]); + self::assertIsArray($service->getTemplatesForWebPreView()); + self::assertIsArray($service->getTestMailAccounts()); + self::assertIsArray($service->getDummyVarsFor('some template', 'de')); } - public function testAddTemplate() + public function testAddTestMailAccount(): void { - $templateId = 'someId'; - $description = 'some new template'; - $formats = array('txt', 'html', 'xml'); - $someUrl = '/some/url/to/the/preview'; - - $routerMock = $this->getMockRouter(); - $routerMock->expects($this->once())->method('generate')->with('azine_email_web_preview', array('template' => $templateId))->will($this->returnValue($someUrl)); + $service = new AzineWebViewService($this->createMock(UrlGeneratorInterface::class)); + $method = new \ReflectionMethod($service, 'addTestMailAccount'); - $webViewService = new AzineWebViewService($routerMock); + $accounts = $method->invoke($service, [], 'Some description', 'account@example.com'); - $args = array(array(), $description, $templateId, $formats); - $templates = self::getMethod('addTemplate')->invokeArgs($webViewService, $args); - - $this->assertSame(1, sizeof($templates)); - $this->assertSame(array('url' => $someUrl, - 'description' => $description, - 'formats' => $formats, - 'templateId' => $templateId, - ), $templates[0]); + self::assertSame([ + [ + 'accountDescription' => 'Some description', + 'accountEmail' => 'account@example.com', + ], + ], $accounts); } - /** - * @param string $name - */ - private static function getMethod($name) + public function testAddTemplate(): void { - $class = new \ReflectionClass("Azine\EmailBundle\Services\AzineWebViewService"); - $method = $class->getMethod($name); - $method->setAccessible(true); - - return $method; + $router = $this->createMock(UrlGeneratorInterface::class); + $router + ->expects(self::once()) + ->method('generate') + ->with('azine_email_web_preview', ['template' => 'someId']) + ->willReturn('/some/url/to/the/preview'); + + $service = new AzineWebViewService($router); + $method = new \ReflectionMethod($service, 'addTemplate'); + $templates = $method->invoke( + $service, + [], + 'some new template', + 'someId', + ['txt', 'html', 'xml'], + ); + + self::assertSame([ + [ + 'url' => '/some/url/to/the/preview', + 'description' => 'some new template', + 'formats' => ['txt', 'html', 'xml'], + 'templateId' => 'someId', + ], + ], $templates); } } diff --git a/Tests/Services/SpamCheckServiceTest.php b/Tests/Services/SpamCheckServiceTest.php new file mode 100644 index 00000000..f48cfe36 --- /dev/null +++ b/Tests/Services/SpamCheckServiceTest.php @@ -0,0 +1,73 @@ + true, + 'score' => 1.3, + 'report' => 'Looks good', + 'rules' => [], + ], JSON_THROW_ON_ERROR), ['http_code' => 200]); + }); + + $report = (new SpamCheckService($client))->checkRawMessage('raw message'); + + self::assertTrue($report['success']); + self::assertSame(200, $report['curlHttpCode']); + self::assertSame(1.3, $report['score']); + self::assertSame('Looks good', $report['report']); + self::assertSame('-', $report['message']); + } + + public function testInvalidJsonProducesUsefulFailure(): void + { + $client = new MockHttpClient(new MockResponse('not-json', ['http_code' => 502])); + + $report = (new SpamCheckService($client))->checkRawMessage('raw message'); + + self::assertFalse($report['success']); + self::assertSame(502, $report['curlHttpCode']); + self::assertSame('The spam-check service returned an invalid JSON response.', $report['message']); + } + + public function testTransportFailureRetainsLegacyResultKeys(): void + { + $client = $this->createMock(HttpClientInterface::class); + $client + ->method('request') + ->willThrowException(new TransportException('Connection failed')); + + $report = (new SpamCheckService($client))->checkRawMessage('raw message'); + + self::assertFalse($report['success']); + self::assertSame('-', $report['curlHttpCode']); + self::assertSame('Connection failed', $report['curlError']); + self::assertSame('The spam-check service could not be reached.', $report['message']); + } + + public function testRejectsUnknownReportType(): void + { + $this->expectException(\InvalidArgumentException::class); + + (new SpamCheckService(new MockHttpClient()))->checkRawMessage('raw message', 'unknown'); + } +} diff --git a/UPGRADE.md b/UPGRADE.md index 9712e210..5bbf22ae 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,138 +1,123 @@ Azine Email Bundle Upgrade Instructions -================== +======================================== -## From 3.0 to 0dev-master -While cleaning up some code in the bundle to conform with the latest coding guidelines and best practices, the following BC-Breaks occured. - -- If you have implemented your own version of AzineEmailTemplateController (extending it), ou must add the parameter to your implementation as well in the following functions. - - \Azine\PlatformBundle\Controller\AzineEmailTemplateController::webPreViewAction - - \Azine\PlatformBundle\Controller\AzineEmailTemplateController::webViewAction - - \Azine\PlatformBundle\Controller\AzineEmailTemplateController::serveImageAction +## Upgrade to PHP 8.5 / Symfony 7.4 -Before: -``` - public function webPreViewAction($template, $format = null){ ... -``` -After: update the arguments in your implementation of AzineEmailTemplateController -``` - public function webPreViewAction(Request $request, $template, $format = null){ ... -``` +This release is a new major-version upgrade. It keeps the existing Azine email feature set—multipart Twig templates, newsletters, notifications, account emails, inline images, attachments, tracking, test sends, spam scoring and persisted web views—while replacing unsupported framework integrations. -Before: -``` - public function webViewAction ($token){ ... -``` -After: update the arguments in your implementation of AzineEmailTemplateController -``` - public function webViewAction (Request $request, $token){ ... -``` +### Runtime requirements -Before: -``` - public function serveImageAction($folderKey, $filename){ ... -``` -After: update the arguments in your implementation of AzineEmailTemplateController -``` - public function serveImageAction(Request $request, $folderKey, $filename){ ... -``` +- PHP `^8.5`; +- Symfony `^7.4`; +- Doctrine ORM `^3.6`; +- Twig `^3.14`; +- FOSUserBundle `^4.1` for registration and password-reset email integration; +- PHP extensions `ctype`, `fileinfo`, `filter`, `json` and `mailparse`; +- `gd` remains optional and is only needed when generated GD images are embedded. + +### Swiftmailer to Symfony Mailer + +Swiftmailer and `symfony/swiftmailer-bundle` are no longer used. Configure Symfony Mailer through `MAILER_DSN` and the normal Symfony FrameworkBundle mailer configuration. + +The canonical application service is now: -## From 2.1 to 3.0 -While cleaning up some code in the bundle (removing potential errors and fixing a memory leak) a few BC breaks were introduced. They should be rather straight forward to fix though. - -Reasons for the BC-Breaks: -- As it is a bad idea, to inject the `EntityManager` into a service, as the `EntityManager` could get closed before the usage. It is better to inject the `ManagerRegistry` and get the `EntityManager` from there. -- The usage of the `Logger` in the AzineTwigSwiftMailer and the AzineNotifierService caused a memory leak. - -### Required changes -If you have sub-classed any of the following classes from this bundle, you will have to update your services.yml and your implementation as well. - - `Azine\EmailBundle\Services\AzineNotifierService` - - `Azine\EmailBundle\Services\AzineTwigSwiftMailer` - - `Azine\EmailBundle\Services\AzineRecipientProvider` - - `Azine\EmailBundle\Services\AzineWebViewService` - -#### update services.yml -Before: +```yaml +azine_email: + template_twig_mailer: azine_email.default.template_twig_mailer + immediate_mailer_service: mailer ``` -arguments: - entityManager: "@doctrine.orm.entity_manager" + +The following legacy names remain as deprecated compatibility aliases for one migration cycle: + +- `template_twig_swift_mailer`; +- `azine_email.default.template_twig_swift_mailer`; +- `TemplateTwigSwiftMailerInterface`; +- `AzineTwigSwiftMailer`. + +New application code should use `TemplateTwigMailerInterface`, `AzineTwigMailer` and Symfony Mime's `Email` class. + +### Asynchronous delivery and retries + +The old serialized Swiftmailer file-spool command was removed. For asynchronous delivery or transport retries, route Symfony Mailer messages through Symfony Messenger and operate the Messenger worker using the application's normal process supervision. + +The newsletter and notification commands continue to exist and use a filesystem lock to prevent overlapping executions: + +```text +emails:sendNewsletter +emails:sendNotifications +emails:remove-old-web-view-emails ``` -After: rename the EntityManager(Registry) and remove the logger from the arguments list + +### Custom template providers + +Existing providers extending `AzineTemplateProvider` remain source-compatible. To add headers to Symfony Mime messages, implement `SymfonyMailerTemplateProviderInterface` or extend `SymfonyMailerTemplateProvider`: + +```php +public function addCustomHeadersToEmail(string $template, Email $message, array $params): void +{ + $message->getHeaders()->addTextHeader('X-Example', 'value'); +} ``` + +### Custom notifier and web-view services + +Application-specific subclasses of `AzineNotifierService`, `AzineTemplateProvider` and `AzineWebViewService` keep their historical extension-hook signatures. Their service definitions must inject current interfaces: + +```yaml arguments: - managerRegistry: "@doctrine" + $mailer: '@azine_email_template_twig_mailer' + $managerRegistry: '@doctrine' + $twig: '@twig' ``` -#### update class fields and constructor functions -Before: -``` - /** - * @var EntityManager - */ - protected $em; -... - public function __construct(..., EntityManager $entityManager, ...) { - $this->em = $entityManager; -``` +Doctrine repositories must be requested using entity class names rather than bundle notation, and ORM 3 code must call `flush()` without an entity argument. -After: rename the EntityManager(Registry) and remove the logger from the constructor -``` - /** - * @var ManagerRegistry - */ - protected $managerRegistry; -... - public function __construct(..., ManagerRegistry $managerRegistry, ...) { - $this->managerRegistry = $managerRegistry; -``` +### Email templates -#### update access to the EntityManager -Before: -``` - $this->em->persist($notification); -``` +Modern Twig namespace notation is preferred: -After: -``` - $this->managerRegistry->getManager()->persist($notification); +```text +@AzineEmail/Email/newsletter.txt.twig +@App/Email/notifications.html.twig ``` +The preview controller temporarily translates legacy `Bundle:Folder:template` notation so stored email records and existing application configuration can be migrated without losing web-view access. +### Spam scoring -## From 1.x to 2.0 -To support the full tracking functionality of google analytics the tracking parameter names have been changed. +The test-email spam-score feature now uses Symfony HttpClient and Postmark's HTTPS endpoint. Override only when necessary: -### Required changes - -- tracking parameter names in your `services.yml` -``` - - campaign_param_name: "%azine_email_campaign_param_name%" - - campaign_keyword_param_name: "%azine_email_campaign_keyword_param_name%" - + tracking_params_campaign_name: "%azine_email_tracking_params_campaign_name%" - + tracking_params_campaign_term: "%azine_email_tracking_params_campaign_term%" - + tracking_params_campaign_content: "%azine_email_tracking_params_campaign_content%" - + tracking_params_campaign_medium: "%azine_email_tracking_params_campaign_medium%" - + tracking_params_campaign_source: "%azine_email_tracking_params_campaign_source%" +```yaml +azine_email: + spam_check_endpoint: 'https://spamcheck.postmarkapp.com/filter' ``` -- update your implementation of `TemplateProviderInterface::getCampaignParamsFor($templateId, array $params = null)` to use the new parameter names. +Non-HTTPS endpoints are rejected. -- if you configured special tracking paramter names in your `app/config/config.yml`, then update these as well. (see above) +### FOSUser and email-address confirmation +The mailer implements FOSUserBundle 4.1's `MailerInterface`. Registration and password-reset templates continue to use Twig blocks for `subject`, `body_text` and `body_html`. -### Optional changes +When `azine/emailupdateconfirmation-bundle` is installed, configure its mailer to `azine_email.default.template_twig_mailer` to retain the same branded account-email rendering. -- if you use piwik to do the tracking, then install https://plugins.piwik.org/AdvancedCampaignReporting to get the best out of it. +### Removed dependencies and duplicate code -## Upgrade to PHP 8.5 / Symfony 7.4 +- Swiftmailer and the custom SwiftmailerBundle; +- the obsolete Swiftmailer spool cleanup command; +- container-aware console commands; +- raw cURL spam-check code; +- duplicate Twig/Swiftmailer transport implementations. + +### Deployment checks + +Before deploying an application using this release: -### Dependency changes -- Minimum PHP is now `8.5`. -- Symfony components are now constrained to `^7.4`. -- PHPUnit was upgraded to `^11.5` and `phpunit.xml.dist` now uses the PHPUnit 11 schema. -- Legacy hard dependency on `friendsofsymfony/user-bundle` was removed (it is now optional in `suggest`). -- `twig/extensions` was replaced by `twig/extra-bundle`. +1. configure `MAILER_DSN` and send representative registration, reset, notification and newsletter emails; +2. run the notification/newsletter commands and, when enabled, the Messenger worker; +3. verify HTML/text preview, inline images, attachments, spam scoring and stored web views; +4. run Doctrine schema validation and application migrations against a production-data copy; +5. run `composer audit --locked` on the final application lock file. -### CI changes -- Travis CI configuration was removed. -- GitHub Actions now runs composer validation and the PHPUnit suite on every push and pull request. +## Historical notes +Older 1.x–4.x releases used Swiftmailer, legacy Twig class names and Symfony bundle-notation repositories. Those APIs are retained only where explicitly documented above as short-lived source-compatibility aliases; they should not be used in new code. diff --git a/compat.php b/compat.php new file mode 100644 index 00000000..c6bf191b --- /dev/null +++ b/compat.php @@ -0,0 +1,25 @@ + 'Twig_Environment', + Template::class => 'Twig_Template', + AbstractExtension::class => 'Twig_Extension', + TwigFilter::class => 'Twig_SimpleFilter', + TwigFunction::class => 'Twig_SimpleFunction', + TwigTest::class => 'Twig_SimpleTest', +]; + +foreach ($aliases as $modernClass => $legacyClass) { + if (!class_exists($legacyClass, false) && class_exists($modernClass)) { + class_alias($modernClass, $legacyClass); + } +} diff --git a/composer.json b/composer.json index 4539c2c0..db7de416 100644 --- a/composer.json +++ b/composer.json @@ -1,15 +1,13 @@ { "name": "azine/email-bundle", "type": "symfony-bundle", - "description": "Symfony bundle to send HTML/text emails (notifications, newsletters, web-view archives).", + "description": "Symfony bundle for multipart notifications, newsletters, email web views and account emails.", "keywords": [ "email", "newsletter", "notification", - "updates", - "email web view", - "mailgun", - "email open tracking" + "symfony", + "web view" ], "homepage": "https://github.com/azine/email-bundle", "license": "MIT", @@ -19,43 +17,64 @@ "email": "github@azine-it.ch" } ], - "config": { - "process-timeout": 590, - "sort-packages": true, - "allow-plugins": { - "php-http/discovery": true - } - }, "require": { "php": "^8.5", - "doctrine/orm": "^2.20 || ^3.0", + "ext-ctype": "*", + "ext-fileinfo": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-mailparse": "*", + "doctrine/orm": "^3.6", + "doctrine/persistence": "^3.3 || ^4.0", + "friendsofsymfony/user-bundle": "^4.1", "knplabs/knp-paginator-bundle": "^6.0", - "monolog/monolog": "^2.9 || ^3.0", "ramsey/uuid": "^4.7", - "swiftmailer/swiftmailer": "^6.3", "symfony/console": "^7.4", "symfony/filesystem": "^7.4", "symfony/finder": "^7.4", "symfony/framework-bundle": "^7.4", + "symfony/http-client": "^7.4", + "symfony/http-client-contracts": "^3.6", + "symfony/http-foundation": "^7.4", "symfony/lock": "^7.4", + "symfony/mailer": "^7.4", + "symfony/mime": "^7.4", + "symfony/routing": "^7.4", + "symfony/translation": "^7.4", "symfony/twig-bundle": "^7.4", "symfony/yaml": "^7.4", - "twig/extra-bundle": "^3.0" + "twig/extra-bundle": "^3.14", + "twig/twig": "^3.14" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.59", - "phpunit/phpunit": "^9.6", - "symfony/phpunit-bridge": "^7.4", - "symfony/translation": "^7.4" + "doctrine/doctrine-bundle": "^2.15", + "friendsofphp/php-cs-fixer": "^3.90", + "phpunit/phpunit": "^12.0", + "symfony/phpunit-bridge": "^7.4" }, "suggest": { - "friendsofsymfony/user-bundle": "Optional: integrate bundle helpers with FOSUser templates and user model.", - "azine/emailupdateconfirmation-bundle": "Optional legacy integration; current releases are tied to Symfony <=4 and are not compatible with Symfony 7.4." + "azine/emailupdateconfirmation-bundle": "Adds confirmation of user email-address changes.", + "ext-gd": "Allows generated GD images to be embedded into outgoing email." }, "autoload": { "psr-4": { "Azine\\EmailBundle\\": "" + }, + "files": [ + "compat.php" + ] + }, + "autoload-dev": { + "psr-4": { + "Azine\\EmailBundle\\Tests\\": "Tests/" } }, - "minimum-stability": "stable" -} \ No newline at end of file + "scripts": { + "test": "phpunit -c phpunit.xml.dist" + }, + "config": { + "sort-packages": true + }, + "minimum-stability": "stable", + "prefer-stable": true +}