diff --git a/config/services/mappers.yml b/config/services/mappers.yml index b0df7b58..54879ad8 100644 --- a/config/services/mappers.yml +++ b/config/services/mappers.yml @@ -11,3 +11,7 @@ services: PhpList\Core\Domain\Subscription\Service\CsvToDtoImporter: autowire: true autoconfigure: true + + PhpList\Core\Domain\Messaging\Service\Mapper\DefaultTemplateMapper: + autowire: true + autoconfigure: true diff --git a/config/services/messenger.yml b/config/services/messenger.yml index 6ae953d4..38130f6e 100644 --- a/config/services/messenger.yml +++ b/config/services/messenger.yml @@ -11,6 +11,10 @@ services: resource: '../../src/Domain/Subscription/MessageHandler' tags: [ 'messenger.message_handler' ] - PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessorMessageHandler: + PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessor\CampaignProcessorMessageHandler: + autowire: true + autoconfigure: true + + PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessor\TestCampaignProcessorMessageHandler: autowire: true autoconfigure: true diff --git a/config/services/resolvers.yml b/config/services/resolvers.yml index 6dfab328..baa9d3b9 100644 --- a/config/services/resolvers.yml +++ b/config/services/resolvers.yml @@ -1,4 +1,16 @@ services: + _defaults: + autowire: true + autoconfigure: true + + _instanceof: + PhpList\Core\Domain\Configuration\Service\Placeholder\PlaceholderValueResolverInterface: + tags: ['phplist.placeholder_resolver'] + PhpList\Core\Domain\Configuration\Service\Placeholder\PatternValueResolverInterface: + tags: ['phplist.pattern_resolver'] + PhpList\Core\Domain\Configuration\Service\Placeholder\SupportingPlaceholderResolverInterface: + tags: ['phplist.supporting_placeholder_resolver'] + PhpList\Core\Domain\Subscription\Service\Resolver\AttributeValueResolver: arguments: $providers: @@ -14,26 +26,5 @@ services: arguments: - !tagged_iterator { tag: 'phplist.bounce_action_handler' } - PhpList\Core\Domain\Configuration\Service\Placeholder\UnsubscribeUrlValueResolver: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Configuration\Service\Placeholder\ConfirmationUrlValueResolver: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Configuration\Service\Placeholder\PreferencesUrlValueResolver: - autowire: true - autoconfigure: true - - PhpList\Core\Domain\Configuration\Service\Placeholder\SubscribeUrlValueResolver: - autowire: true - autoconfigure: true - - _instanceof: - PhpList\Core\Domain\Configuration\Service\Placeholder\PlaceholderValueResolverInterface: - tags: ['phplist.placeholder_resolver'] - PhpList\Core\Domain\Configuration\Service\Placeholder\PatternValueResolverInterface: - tags: [ 'phplist.pattern_resolver' ] - PhpList\Core\Domain\Configuration\Service\Placeholder\SupportingPlaceholderResolverInterface: - tags: [ 'phplist.supporting_placeholder_resolver' ] + PhpList\Core\Domain\Configuration\Service\Placeholder\: + resource: '../../src/Domain/Configuration/Service/Placeholder/*' diff --git a/public/.htaccess b/public/.htaccess index 4dc72516..0c1f8117 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -3,7 +3,7 @@ # mod_rewrite). Additionally, this reduces the matching process for the # start page (path "/") because otherwise Apache will apply the rewriting rules # to each configured DirectoryIndex file (e.g. index.php, index.html, index.pl). -DirectoryIndex app.php +DirectoryIndex index.php # By default, Apache does not evaluate symbolic links if you did not enable this # feature in your server configuration. Uncomment the following line if you @@ -46,7 +46,7 @@ DirectoryIndex app.php # - use Apache >= 2.3.9 and replace all L flags by END flags and remove the # following RewriteCond (best solution) RewriteCond %{ENV:REDIRECT_STATUS} ^$ - RewriteRule ^app\.php(?:/(.*)|$) %{ENV:BASE}/$1 [R=301,L] + RewriteRule ^index\.php(?:/(.*)|$) %{ENV:BASE}/$1 [R=301,L] # If the requested filename exists, simply serve it. # We only want to let Apache serve files and not directories. @@ -54,7 +54,7 @@ DirectoryIndex app.php RewriteRule ^ - [L] # Rewrite all other queries to the front controller. - RewriteRule ^ %{ENV:BASE}/app.php [L] + RewriteRule ^ %{ENV:BASE}/index.php [L] @@ -62,7 +62,7 @@ DirectoryIndex app.php # When mod_rewrite is not available, we instruct a temporary redirect of # the start page to the front controller explicitly so that the website # and the generated links can still be used. - RedirectMatch 302 ^/$ /app.php/ + RedirectMatch 302 ^/$ /index.php/ # RedirectTemp cannot be used instead diff --git a/public/index.php b/public/index.php new file mode 100644 index 00000000..8a06781f --- /dev/null +++ b/public/index.php @@ -0,0 +1,20 @@ +ensureDevelopmentOrTestingEnvironment(); +} + +$bootstrap + ->setEnvironment($environment) + ->configure() + ->dispatch(); diff --git a/src/Bounce/Service/Manager/BounceManager.php b/src/Bounce/Service/Manager/BounceManager.php index 230ad279..868aae10 100644 --- a/src/Bounce/Service/Manager/BounceManager.php +++ b/src/Bounce/Service/Manager/BounceManager.php @@ -18,24 +18,13 @@ class BounceManager { - private BounceRepository $bounceRepository; - private UserMessageBounceRepository $userMessageBounceRepo; - private EntityManagerInterface $entityManager; - private LoggerInterface $logger; - private TranslatorInterface $translator; - public function __construct( - BounceRepository $bounceRepository, - UserMessageBounceRepository $userMessageBounceRepo, - EntityManagerInterface $entityManager, - LoggerInterface $logger, - TranslatorInterface $translator, + private readonly BounceRepository $bounceRepository, + private readonly UserMessageBounceRepository $userMessageBounceRepo, + private readonly EntityManagerInterface $entityManager, + private readonly LoggerInterface $logger, + private readonly TranslatorInterface $translator, ) { - $this->bounceRepository = $bounceRepository; - $this->userMessageBounceRepo = $userMessageBounceRepo; - $this->entityManager = $entityManager; - $this->logger = $logger; - $this->translator = $translator; } public function create( @@ -93,7 +82,10 @@ public function linkUserMessageBounce( int $subscriberId, ?int $messageId = -1 ): UserMessageBounce { - $userMessageBounce = new UserMessageBounce($bounce->getId(), new DateTime($date->format('Y-m-d H:i:s'))); + $userMessageBounce = new UserMessageBounce( + bounceId: $bounce->getId(), + createdAt: new DateTime($date->format('Y-m-d H:i:s')) + ); $userMessageBounce->setUserId($subscriberId); $userMessageBounce->setMessageId($messageId); diff --git a/src/DependencyInjection/PhpListCoreExtension.php b/src/DependencyInjection/PhpListCoreExtension.php index e04ad940..a41381f0 100644 --- a/src/DependencyInjection/PhpListCoreExtension.php +++ b/src/DependencyInjection/PhpListCoreExtension.php @@ -11,12 +11,26 @@ class PhpListCoreExtension extends Extension { + private array $configFiles = [ + 'builders.yml', + 'commands.yml', + 'managers.yml', + 'mappers.yml', + 'messengers.yml', + 'processors.yml', + 'providers.yml', + 'repositories.yml', + 'resolvers.yml', + 'services.yml', + 'validators.yml', + ]; + public function load(array $configs, ContainerBuilder $container): void { $loader = new YamlFileLoader($container, new FileLocator(__DIR__ . '/../../config/services')); // Load core service definitions if present (keep optional to avoid breaking consumers) - foreach (['services.yml', 'builders.yml', 'managers.yml'] as $file) { + foreach ($this->configFiles as $file) { $path = __DIR__ . '/../../config/services/' . $file; if (is_file($path) && is_readable($path)) { $loader->load($file); diff --git a/src/Domain/Analytics/Service/LinkTrackService.php b/src/Domain/Analytics/Service/LinkTrackService.php index 60230dd3..d6d60f95 100644 --- a/src/Domain/Analytics/Service/LinkTrackService.php +++ b/src/Domain/Analytics/Service/LinkTrackService.php @@ -4,6 +4,7 @@ namespace PhpList\Core\Domain\Analytics\Service; +use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Core\ParameterProvider; use PhpList\Core\Domain\Analytics\Exception\MissingMessageIdException; use PhpList\Core\Domain\Analytics\Model\LinkTrack; @@ -12,13 +13,11 @@ class LinkTrackService { - private LinkTrackRepository $linkTrackRepository; - private ParameterProvider $paramProvider; - - public function __construct(LinkTrackRepository $linkTrackRepository, ParameterProvider $paramProvider) - { - $this->linkTrackRepository = $linkTrackRepository; - $this->paramProvider = $paramProvider; + public function __construct( + private readonly LinkTrackRepository $linkTrackRepository, + private readonly ParameterProvider $paramProvider, + private readonly EntityManagerInterface $entityManager, + ) { } public function getUrlById(int $id): ?string @@ -58,7 +57,6 @@ public function extractAndSaveLinks(MessagePrecacheDto $content, int $userId, ?i $links = array_unique($links); $savedLinks = []; - foreach ($links as $url) { $existingLinkTrack = $this->linkTrackRepository->findByUrlUserIdAndMessageId($url, $userId, $messageId); if ($existingLinkTrack !== null) { @@ -74,6 +72,8 @@ public function extractAndSaveLinks(MessagePrecacheDto $content, int $userId, ?i $savedLinks[] = $linkTrack; } + $this->entityManager->flush(); + return $savedLinks; } diff --git a/src/Domain/Common/RemotePageFetcher.php b/src/Domain/Common/RemotePageFetcher.php index 30ac316e..fdeb01db 100644 --- a/src/Domain/Common/RemotePageFetcher.php +++ b/src/Domain/Common/RemotePageFetcher.php @@ -60,13 +60,16 @@ public function __invoke(string $url, array $userData): string if (!empty($content)) { $content = $this->htmlUrlRewriter->addAbsoluteResources($content, $url); - $this->eventLogManager->log(page: 'unknown page', entry:'Fetching '.$url.' success'); + $this->eventLogManager->log(page: 'unknown page', entry: 'Fetching ' . $url . ' success'); $caches = $this->urlCacheRepository->getByUrl($url); foreach ($caches as $cache) { $this->entityManager->remove($cache); } - $urlCache = (new UrlCache())->setUrl($url)->setContent($content)->setLastModified($lastModified); + $urlCache = (new UrlCache()) + ->setUrl($url) + ->setContent($content) + ->setLastModified($lastModified); $this->urlCacheRepository->persist($urlCache); $this->cache->set($cacheKey, [ diff --git a/src/Domain/Configuration/Service/MessagePlaceholderProcessor.php b/src/Domain/Configuration/Service/MessagePlaceholderProcessor.php index ba216a1a..f0443f4a 100644 --- a/src/Domain/Configuration/Service/MessagePlaceholderProcessor.php +++ b/src/Domain/Configuration/Service/MessagePlaceholderProcessor.php @@ -65,10 +65,8 @@ public function process( name: 'ORGANIZATION_NAME', resolver: fn(PlaceholderContext $ctx) => $this->config->getValue(ConfigOption::OrganisationName) ?? '' ); - - foreach ($this->placeholderResolvers as $placeholderResolver) { - $resolver->register($placeholderResolver->name(), $placeholderResolver); - } + + $this->registerNestedResolvers($resolver); foreach ($this->patternResolvers as $patternResolver) { $resolver->registerPattern($patternResolver->pattern(), $patternResolver); @@ -136,4 +134,57 @@ private function registerAttributeResolvers( ); } } + + private function maskFooterPlaceholders(string $value, array &$placeholderMap): string + { + $placeholderMap = []; + $index = 0; + + return preg_replace_callback( + '/\[FOOTER(?:%%[^\]]+)?\]/i', + function (array $matches) use (&$placeholderMap, &$index): string { + $token = sprintf('__PHPLIST_FOOTER_TOKEN_%d__', $index++); + $placeholderMap[$token] = $matches[0]; + + return $token; + }, + $value + ) ?? $value; + } + + /** @param array $placeholderMap */ + private function restoreFooterPlaceholders(string $value, array $placeholderMap): string + { + if ($placeholderMap === []) { + return $value; + } + + return strtr($value, $placeholderMap); + } + + private function registerNestedResolvers(PlaceholderResolver $resolver): void + { + foreach ($this->placeholderResolvers as $placeholderResolver) { + if (strtoupper($placeholderResolver->name()) !== 'FOOTER') { + $resolver->register($placeholderResolver->name(), $placeholderResolver); + continue; + } + + $resolver->register( + $placeholderResolver->name(), + function (PlaceholderContext $ctx) use ($placeholderResolver, $resolver): string { + $footer = (string) $placeholderResolver($ctx); + if (!str_contains($footer, '[')) { + return $footer; + } + + $placeholderMap = []; + $maskedFooter = $this->maskFooterPlaceholders($footer, $placeholderMap); + $resolvedFooter = $resolver->resolve($maskedFooter, $ctx); + + return $this->restoreFooterPlaceholders($resolvedFooter, $placeholderMap); + } + ); + } + } } diff --git a/src/Domain/Configuration/Service/Placeholder/FooterValueResolver.php b/src/Domain/Configuration/Service/Placeholder/FooterValueResolver.php index 506581dd..96e66e77 100644 --- a/src/Domain/Configuration/Service/Placeholder/FooterValueResolver.php +++ b/src/Domain/Configuration/Service/Placeholder/FooterValueResolver.php @@ -7,12 +7,13 @@ use PhpList\Core\Domain\Configuration\Model\ConfigOption; use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; use PhpList\Core\Domain\Configuration\Model\Dto\PlaceholderContext; +use Symfony\Component\DependencyInjection\Attribute\Autowire; final class FooterValueResolver implements PlaceholderValueResolverInterface { public function __construct( private readonly ConfigProvider $config, - private readonly bool $forwardAlternativeContent, + #[Autowire('%messaging.forward_alternative_content%')] private readonly bool $forwardAlternativeContent, ) { } diff --git a/src/Domain/Configuration/Service/Placeholder/ListsValueResolver.php b/src/Domain/Configuration/Service/Placeholder/ListsValueResolver.php index 5a77deb4..7df7adcc 100644 --- a/src/Domain/Configuration/Service/Placeholder/ListsValueResolver.php +++ b/src/Domain/Configuration/Service/Placeholder/ListsValueResolver.php @@ -6,6 +6,7 @@ use PhpList\Core\Domain\Configuration\Model\Dto\PlaceholderContext; use PhpList\Core\Domain\Subscription\Repository\SubscriberListRepository; +use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Contracts\Translation\TranslatorInterface; final class ListsValueResolver implements PlaceholderValueResolverInterface @@ -13,7 +14,7 @@ final class ListsValueResolver implements PlaceholderValueResolverInterface public function __construct( private readonly SubscriberListRepository $subscriberListRepository, private readonly TranslatorInterface $translator, - private readonly bool $showPrivateLists = false, + #[Autowire('%app.preference_page_show_private_lists%')] private readonly bool $showPrivateLists = false, ) { } diff --git a/src/Domain/Configuration/Service/Placeholder/SignatureValueResolver.php b/src/Domain/Configuration/Service/Placeholder/SignatureValueResolver.php index ebd90daa..b00b9182 100644 --- a/src/Domain/Configuration/Service/Placeholder/SignatureValueResolver.php +++ b/src/Domain/Configuration/Service/Placeholder/SignatureValueResolver.php @@ -7,12 +7,13 @@ use PhpList\Core\Domain\Configuration\Model\ConfigOption; use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; use PhpList\Core\Domain\Configuration\Model\Dto\PlaceholderContext; +use Symfony\Component\DependencyInjection\Attribute\Autowire; final class SignatureValueResolver implements PlaceholderValueResolverInterface { public function __construct( private readonly ConfigProvider $config, - private readonly bool $emailTextCredits = false, + #[Autowire('%messaging.email_text_credits%')] private readonly bool $emailTextCredits = false, ) { } diff --git a/src/Domain/Identity/Model/AdminAttributeValue.php b/src/Domain/Identity/Model/AdminAttributeValue.php index 3f4e6c68..f3889085 100644 --- a/src/Domain/Identity/Model/AdminAttributeValue.php +++ b/src/Domain/Identity/Model/AdminAttributeValue.php @@ -15,12 +15,12 @@ class AdminAttributeValue implements DomainModel { #[ORM\Id] #[ORM\ManyToOne(targetEntity: AdminAttributeDefinition::class)] - #[ORM\JoinColumn(name: 'adminattributeid', referencedColumnName: 'id', nullable: false)] + #[ORM\JoinColumn(name: 'adminattributeid', referencedColumnName: 'id')] private AdminAttributeDefinition $attributeDefinition; #[ORM\Id] #[ORM\ManyToOne(targetEntity: Administrator::class)] - #[ORM\JoinColumn(name: 'adminid', referencedColumnName: 'id', nullable: false)] + #[ORM\JoinColumn(name: 'adminid', referencedColumnName: 'id')] private Administrator $administrator; #[ORM\Column(name: 'value', type: 'string', length: 255, nullable: true)] diff --git a/src/Domain/Identity/Service/PermissionChecker.php b/src/Domain/Identity/Service/PermissionChecker.php index 9986ff59..cce4d514 100644 --- a/src/Domain/Identity/Service/PermissionChecker.php +++ b/src/Domain/Identity/Service/PermissionChecker.php @@ -8,6 +8,7 @@ use PhpList\Core\Domain\Common\Model\Interfaces\OwnableInterface; use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Identity\Model\PrivilegeFlag; +use PhpList\Core\Domain\Messaging\Model\ListMessage; use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Subscription\Model\Subscriber; use PhpList\Core\Domain\Subscription\Model\SubscriberList; @@ -70,9 +71,7 @@ private function resolveRelatedEntity(DomainModel $resource, string $relatedClas } if ($resource instanceof Message && $relatedClass === SubscriberList::class) { - // todo: check which one is correct - // return $resource->getListMessages()->map(fn(ListMessage $lm) => $lm->getList())->toArray(); - return $resource->getListMessages()->map(fn($lm) => $lm->getSubscriberList())->toArray(); + return $resource->getListMessages()->map(fn(ListMessage $lm) => $lm->getList())->toArray(); } return []; diff --git a/src/Domain/Messaging/Command/ProcessQueueCommand.php b/src/Domain/Messaging/Command/ProcessQueueCommand.php index 245bc57a..080c24cb 100644 --- a/src/Domain/Messaging/Command/ProcessQueueCommand.php +++ b/src/Domain/Messaging/Command/ProcessQueueCommand.php @@ -8,7 +8,7 @@ use Doctrine\ORM\EntityManagerInterface; use PhpList\Core\Domain\Configuration\Model\ConfigOption; use PhpList\Core\Domain\Configuration\Service\Provider\ConfigProvider; -use PhpList\Core\Domain\Messaging\Message\CampaignProcessorMessage; +use PhpList\Core\Domain\Messaging\Message\CampaignProcessor\CampaignProcessorMessage; use PhpList\Core\Domain\Messaging\Model\Message\MessageStatus; use PhpList\Core\Domain\Messaging\Repository\MessageRepository; use PhpList\Core\Domain\Messaging\Service\MessageProcessingPreparator; diff --git a/src/Domain/Messaging/Message/CampaignProcessorMessage.php b/src/Domain/Messaging/Message/CampaignProcessor/CampaignProcessorMessage.php similarity index 88% rename from src/Domain/Messaging/Message/CampaignProcessorMessage.php rename to src/Domain/Messaging/Message/CampaignProcessor/CampaignProcessorMessage.php index 06220106..574149d8 100644 --- a/src/Domain/Messaging/Message/CampaignProcessorMessage.php +++ b/src/Domain/Messaging/Message/CampaignProcessor/CampaignProcessorMessage.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Message; +namespace PhpList\Core\Domain\Messaging\Message\CampaignProcessor; class CampaignProcessorMessage implements CampaignProcessorMessageInterface { diff --git a/src/Domain/Messaging/Message/CampaignProcessorMessageInterface.php b/src/Domain/Messaging/Message/CampaignProcessor/CampaignProcessorMessageInterface.php similarity index 70% rename from src/Domain/Messaging/Message/CampaignProcessorMessageInterface.php rename to src/Domain/Messaging/Message/CampaignProcessor/CampaignProcessorMessageInterface.php index 0d0bc978..dbf71b25 100644 --- a/src/Domain/Messaging/Message/CampaignProcessorMessageInterface.php +++ b/src/Domain/Messaging/Message/CampaignProcessor/CampaignProcessorMessageInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Message; +namespace PhpList\Core\Domain\Messaging\Message\CampaignProcessor; interface CampaignProcessorMessageInterface { diff --git a/src/Domain/Messaging/Message/SyncCampaignProcessorMessage.php b/src/Domain/Messaging/Message/CampaignProcessor/SyncCampaignProcessorMessage.php similarity index 88% rename from src/Domain/Messaging/Message/SyncCampaignProcessorMessage.php rename to src/Domain/Messaging/Message/CampaignProcessor/SyncCampaignProcessorMessage.php index a310129f..463dc96b 100644 --- a/src/Domain/Messaging/Message/SyncCampaignProcessorMessage.php +++ b/src/Domain/Messaging/Message/CampaignProcessor/SyncCampaignProcessorMessage.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\Message; +namespace PhpList\Core\Domain\Messaging\Message\CampaignProcessor; class SyncCampaignProcessorMessage implements CampaignProcessorMessageInterface { diff --git a/src/Domain/Messaging/Message/CampaignProcessor/TestCampaignProcessorMessage.php b/src/Domain/Messaging/Message/CampaignProcessor/TestCampaignProcessorMessage.php new file mode 100644 index 00000000..961e5406 --- /dev/null +++ b/src/Domain/Messaging/Message/CampaignProcessor/TestCampaignProcessorMessage.php @@ -0,0 +1,34 @@ +messageId = $messageId; + $this->listIds = $listIds ?? []; + $this->subscriberEmails = $subscriberEmails ?? []; + } + + public function getMessageId(): int + { + return $this->messageId; + } + + public function getListIds(): array + { + return $this->listIds; + } + + public function getSubscriberEmails(): array + { + return $this->subscriberEmails; + } +} diff --git a/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php b/src/Domain/Messaging/MessageHandler/CampaignProcessor/CampaignProcessorMessageHandler.php similarity index 96% rename from src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php rename to src/Domain/Messaging/MessageHandler/CampaignProcessor/CampaignProcessorMessageHandler.php index f576d1df..e1aa9219 100644 --- a/src/Domain/Messaging/MessageHandler/CampaignProcessorMessageHandler.php +++ b/src/Domain/Messaging/MessageHandler/CampaignProcessor/CampaignProcessorMessageHandler.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace PhpList\Core\Domain\Messaging\MessageHandler; +namespace PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessor; use DateTime; use DateTimeImmutable; @@ -13,8 +13,8 @@ use PhpList\Core\Domain\Messaging\Exception\AttachmentCopyException; use PhpList\Core\Domain\Messaging\Exception\MessageCacheMissingException; use PhpList\Core\Domain\Messaging\Exception\MessageSizeLimitExceededException; -use PhpList\Core\Domain\Messaging\Message\CampaignProcessorMessage; -use PhpList\Core\Domain\Messaging\Message\SyncCampaignProcessorMessage; +use PhpList\Core\Domain\Messaging\Message\CampaignProcessor\CampaignProcessorMessage; +use PhpList\Core\Domain\Messaging\Message\CampaignProcessor\SyncCampaignProcessorMessage; use PhpList\Core\Domain\Messaging\Model\Dto\MessagePrecacheDto; use PhpList\Core\Domain\Messaging\Model\Message; use PhpList\Core\Domain\Messaging\Model\Message\MessageStatus; @@ -109,7 +109,11 @@ public function __invoke(CampaignProcessorMessage|SyncCampaignProcessorMessage $ // $userSelection = $loadedMessageData['userselection']; $cacheKey = sprintf('messaging.message.base.%d.%d', $campaign->getId(), 0); - if (!$this->precacheService->precacheMessage($campaign, $loadedMessageData)) { + if (!$this->precacheService->precacheMessage( + campaign: $campaign, + loadedMessageData: $loadedMessageData, + isTest: false + )) { $this->updateMessageStatus($campaign, MessageStatus::Suspended); return; @@ -199,7 +203,7 @@ private function handleEmailSending( UserMessage $userMessage, MessagePrecacheDto $precachedContent, ): void { - // todo: check at which point link tracking should be applied (maybe after constructing ful text?) + // todo: check at which point link tracking should be applied (maybe after constructing full text?) $processed = $this->messagePreparator->processMessageLinks( campaignId: $campaign->getId(), cachedMessageDto: $precachedContent, diff --git a/src/Domain/Messaging/MessageHandler/CampaignProcessor/TestCampaignProcessorMessageHandler.php b/src/Domain/Messaging/MessageHandler/CampaignProcessor/TestCampaignProcessorMessageHandler.php new file mode 100644 index 00000000..8ae47ea7 --- /dev/null +++ b/src/Domain/Messaging/MessageHandler/CampaignProcessor/TestCampaignProcessorMessageHandler.php @@ -0,0 +1,167 @@ +messageRepository->findById($data->getMessageId()); + if (!$campaign) { + $this->logger->warning( + $this->translator->trans('Campaign not found or not in submitted status'), + ['campaign_id' => $data->getMessageId()] + ); + + return; + } + + $loadedMessageData = ($this->messageDataLoader)($campaign); + + $cacheKey = sprintf('messaging.message.base.%d.%d.%d', $campaign->getId(), 0, 1); + if (!$this->precacheService->precacheMessage( + campaign: $campaign, + loadedMessageData: $loadedMessageData, + isTest: true + )) { + return; + } + + $subscribers = $this->subscriberProvider->getSubscribersForMessageOrLists(data: $data, campaign: $campaign); + + $this->processSubscribersForCampaign(campaign: $campaign, subscribers: $subscribers, cacheKey: $cacheKey); + } + + private function handleEmailSending( + Message $campaign, + Subscriber $subscriber, + MessagePrecacheDto $precachedContent, + ): void { + // todo: check at which point link tracking should be applied (maybe after constructing full text?) + $processed = $this->messagePreparator->processMessageLinks( + campaignId: $campaign->getId(), + cachedMessageDto: $precachedContent, + subscriber: $subscriber + ); + + try { + $result = $this->campaignEmailBuilder->buildCampaignEmail( + messageId: $campaign->getId(), + data: $processed, + toEmail: $subscriber->getEmail(), + skipBlacklistCheck: false, + inBlast: true, + htmlPref: $subscriber->hasHtmlEmail(), + ); + if ($result === null) { + return; + } + $email = $result[0]; + $this->campaignEmailBuilder->applyCampaignHeaders(email: $email, subscriber: $subscriber); + + $this->mailer->send($email); + ($this->mailSizeChecker)($campaign, $email, $subscriber->hasHtmlEmail()); + } catch (MessageSizeLimitExceededException $e) { + // stop after the first message if size is exceeded + $this->logger->error($e->getMessage(), [ + 'campaign_id' => $campaign->getId(), + ]); + throw $e; + } catch (AttachmentCopyException $e) { + // stop after the first message if size is exceeded + $data = new MessagePrecacheDto(); + $data->subject = $this->translator->trans('phpList system error'); + $data->content = $this->translator->trans($e->getMessage()); + + $email = $this->systemEmailBuilder->buildCampaignEmail( + messageId: $campaign->getId(), + data: $data, + toEmail: $this->configProvider->getValue(ConfigOption::ReportAddress) ?? '', + ); + + $envelope = new Envelope( + sender: new Address($this->bounceEmail, 'PHPList'), + recipients: [new Address($email->getTo()[0]->getAddress())], + ); + $this->mailer->send(message: $email, envelope: $envelope); + + throw $e; + } catch (Throwable $e) { + $this->logger->error($e->getMessage(), [ + 'subscriber_id' => $subscriber->getId(), + 'campaign_id' => $campaign->getId(), + ]); + $this->logger->warning($this->translator->trans('Failed to send to: %email%', [ + '%email%' => $subscriber->getEmail(), + ])); + } + } + + private function processSubscribersForCampaign(Message $campaign, array $subscribers, string $cacheKey): void + { + foreach ($subscribers as $subscriber) { + if (!filter_var($subscriber->getEmail(), FILTER_VALIDATE_EMAIL)) { + continue; + } + + $messagePrecacheDto = $this->cache->get($cacheKey); + if ($messagePrecacheDto === null) { + throw new MessageCacheMissingException(); + } + + $this->handleEmailSending($campaign, $subscriber, $messagePrecacheDto); + } + } +} diff --git a/src/Domain/Messaging/Model/Dto/CreateTemplateDto.php b/src/Domain/Messaging/Model/Dto/CreateTemplateDto.php index b6aa0926..f54d53ab 100644 --- a/src/Domain/Messaging/Model/Dto/CreateTemplateDto.php +++ b/src/Domain/Messaging/Model/Dto/CreateTemplateDto.php @@ -14,6 +14,7 @@ public function __construct( public readonly bool $shouldCheckLinks = false, public readonly bool $shouldCheckImages = false, public readonly bool $shouldCheckExternalImages = false, + public readonly int $listOrder = 0, ) { } } diff --git a/src/Domain/Messaging/Model/Dto/Message/MessageContentDto.php b/src/Domain/Messaging/Model/Dto/Message/MessageContentDto.php index 0ac18672..c5fb5ce8 100644 --- a/src/Domain/Messaging/Model/Dto/Message/MessageContentDto.php +++ b/src/Domain/Messaging/Model/Dto/Message/MessageContentDto.php @@ -9,7 +9,6 @@ class MessageContentDto public function __construct( public readonly string $subject, public readonly string $text, - public readonly string $textMessage, public readonly string $footer, ) { } diff --git a/src/Domain/Messaging/Model/Dto/Message/MessageFormatDto.php b/src/Domain/Messaging/Model/Dto/Message/MessageFormatDto.php index 01bc0474..d6849d26 100644 --- a/src/Domain/Messaging/Model/Dto/Message/MessageFormatDto.php +++ b/src/Domain/Messaging/Model/Dto/Message/MessageFormatDto.php @@ -6,10 +6,7 @@ class MessageFormatDto { - public function __construct( - public readonly bool $htmlFormated, - public readonly string $sendFormat, - public readonly array $formatOptions, - ) { + public function __construct(public readonly string $sendFormat) + { } } diff --git a/src/Domain/Messaging/Model/Dto/UpdateTemplateDto.php b/src/Domain/Messaging/Model/Dto/UpdateTemplateDto.php index 72a70bf9..3e4480c6 100644 --- a/src/Domain/Messaging/Model/Dto/UpdateTemplateDto.php +++ b/src/Domain/Messaging/Model/Dto/UpdateTemplateDto.php @@ -14,6 +14,7 @@ public function __construct( public readonly bool $shouldCheckLinks = false, public readonly bool $shouldCheckImages = false, public readonly bool $shouldCheckExternalImages = false, + public readonly int $listOrder = 0, ) { } } diff --git a/src/Domain/Messaging/Model/Filter/MessageFilter.php b/src/Domain/Messaging/Model/Filter/MessageFilter.php index ef1211cf..ccb5b1ac 100644 --- a/src/Domain/Messaging/Model/Filter/MessageFilter.php +++ b/src/Domain/Messaging/Model/Filter/MessageFilter.php @@ -11,6 +11,7 @@ class MessageFilter extends PaginatedFilter implements FilterRequestInterface { private ?Administrator $owner = null; + private ?string $subject = null; public function getOwner(): ?Administrator { @@ -22,4 +23,18 @@ public function setOwner(?Administrator $admin): self $this->owner = $admin; return $this; } + + public function getSubject(): ?string + { + return $this->subject; + } + + public function setSubject(?string $subject): self + { + if ($subject !== null) { + $subject = trim($subject); + } + $this->subject = $subject; + return $this; + } } diff --git a/src/Domain/Messaging/Model/Message.php b/src/Domain/Messaging/Model/Message.php index c70ff01c..4d5f4e8f 100644 --- a/src/Domain/Messaging/Model/Message.php +++ b/src/Domain/Messaging/Model/Message.php @@ -61,6 +61,9 @@ class Message implements DomainModel, Identity, ModificationDate, OwnableInterfa #[ORM\JoinColumn(name: 'template', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] private ?Template $template = null; + /** + * @var Collection + */ #[ORM\OneToMany(targetEntity: ListMessage::class, mappedBy: 'message')] private Collection $listMessages; @@ -190,6 +193,9 @@ public function setOptions(MessageOptions $options): self return $this; } + /** + * @return Collection + */ public function getListMessages(): Collection { return $this->listMessages; diff --git a/src/Domain/Messaging/Model/Message/MessageFormat.php b/src/Domain/Messaging/Model/Message/MessageFormat.php index 1f857c37..419b63df 100644 --- a/src/Domain/Messaging/Model/Message/MessageFormat.php +++ b/src/Domain/Messaging/Model/Message/MessageFormat.php @@ -55,6 +55,11 @@ public function getSendFormat(): ?string return $this->sendFormat; } + public function isInvitation(): bool + { + return $this->sendFormat === 'invite'; + } + public function setSendFormat(?string $sendFormat): self { $this->sendFormat = $sendFormat; diff --git a/src/Domain/Messaging/Model/Message/MessageMetadata.php b/src/Domain/Messaging/Model/Message/MessageMetadata.php index 4d774143..c571bd1f 100644 --- a/src/Domain/Messaging/Model/Message/MessageMetadata.php +++ b/src/Domain/Messaging/Model/Message/MessageMetadata.php @@ -6,8 +6,8 @@ use DateTime; use Doctrine\ORM\Mapping as ORM; -use InvalidArgumentException; use PhpList\Core\Domain\Common\Model\Interfaces\EmbeddableInterface; +use Symfony\Component\Validator\Exception\ValidatorException; #[ORM\Embeddable] class MessageMetadata implements EmbeddableInterface @@ -57,7 +57,11 @@ public function getStatus(): ?MessageStatus public function setStatus(MessageStatus $status): self { if (!$this->getStatus()->canTransitionTo($status)) { - throw new InvalidArgumentException('Invalid status transition'); + throw new ValidatorException( + 'status: Invalid transition ' . $this->status . ' -> ' . $status->value + . PHP_EOL . + 'metadata.status: Invalid transition ' . $this->status . ' -> ' . $status->value + ); } $this->status = $status->value; diff --git a/src/Domain/Messaging/Model/Message/MessageStatus.php b/src/Domain/Messaging/Model/Message/MessageStatus.php index 90b7f987..789f07c2 100644 --- a/src/Domain/Messaging/Model/Message/MessageStatus.php +++ b/src/Domain/Messaging/Model/Message/MessageStatus.php @@ -22,12 +22,13 @@ enum MessageStatus: string public function allowedTransitions(): array { return match ($this) { - self::Draft, self::Suspended => [self::Submitted], - self::Submitted => [self::Prepared, self::InProcess], - self::Prepared => [self::InProcess], + self::Draft => [self::Prepared, self::Submitted], + self::Suspended => [self::Submitted, self::Requeued], + self::Submitted => [self::Prepared, self::InProcess, self::Suspended], + self::Prepared => [self::InProcess, self::Suspended], self::InProcess => [self::Sent, self::Suspended, self::Submitted], self::Requeued => [self::InProcess, self::Suspended], - self::Sent => [], + self::Sent => [self::Requeued], }; } diff --git a/src/Domain/Messaging/Model/Template.php b/src/Domain/Messaging/Model/Template.php index efff0de4..dc1b67a0 100644 --- a/src/Domain/Messaging/Model/Template.php +++ b/src/Domain/Messaging/Model/Template.php @@ -59,12 +59,19 @@ public function getTitle(): string public function getContent(): ?string { + if ($this->content === null) { + return null; + } + if (is_resource($this->content)) { rewind($this->content); - return stream_get_contents($this->content); + $value = stream_get_contents($this->content); + rewind($this->content); + + return $value === false ? null : $value; } - return $this->content; + return (string) $this->content; } public function getText(): ?string @@ -95,14 +102,30 @@ public function setTitle(string $title): self public function setContent(?string $content): self { - $this->content = $content !== null ? fopen('data://text/plain,' . $content, 'r') : null; + if ($content === null) { + $this->content = null; + return $this; + } + + $stream = fopen('php://temp', 'r+'); + fwrite($stream, $content); + rewind($stream); + + $this->content = $stream; + return $this; } - public function setText(?string $text): self { - $this->text = $text !== null ? fopen('data://text/plain,' . $text, 'r') : null; + if ($text === null) { + $text = '[CONTENT]'; + } + $stream = fopen('data://text/plain,' . $text, 'r'); + rewind($stream); + + $this->text = $stream; + return $this; } diff --git a/src/Domain/Messaging/Model/TemplateImage.php b/src/Domain/Messaging/Model/TemplateImage.php index 2cc5288f..c1c5c8c4 100644 --- a/src/Domain/Messaging/Model/TemplateImage.php +++ b/src/Domain/Messaging/Model/TemplateImage.php @@ -98,7 +98,7 @@ public function setFilename(?string $filename): self public function setData(?string $data): self { - $this->data = $data !== null ? fopen('data://text/plain,' . $data, 'r') : null; + $this->data = $data !== null ? fopen('data://text/plain,' . rawurlencode($data), 'r') : null; return $this; } diff --git a/src/Domain/Messaging/Model/UserMessage.php b/src/Domain/Messaging/Model/UserMessage.php index 3f3c9cdf..d5fe202c 100644 --- a/src/Domain/Messaging/Model/UserMessage.php +++ b/src/Domain/Messaging/Model/UserMessage.php @@ -22,12 +22,12 @@ class UserMessage implements DomainModel { #[ORM\Id] #[ORM\ManyToOne(targetEntity: Subscriber::class)] - #[ORM\JoinColumn(name: 'userid', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] + #[ORM\JoinColumn(name: 'userid', referencedColumnName: 'id', onDelete: 'CASCADE')] private Subscriber $user; #[ORM\Id] #[ORM\ManyToOne(targetEntity: Message::class)] - #[ORM\JoinColumn(name: 'messageid', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] + #[ORM\JoinColumn(name: 'messageid', referencedColumnName: 'id', onDelete: 'CASCADE')] private Message $message; #[ORM\Column(name: 'entered', type: 'datetime')] diff --git a/src/Domain/Messaging/Repository/BounceRepository.php b/src/Domain/Messaging/Repository/BounceRepository.php index 410f5da1..9034f634 100644 --- a/src/Domain/Messaging/Repository/BounceRepository.php +++ b/src/Domain/Messaging/Repository/BounceRepository.php @@ -8,6 +8,8 @@ use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait; use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface; use PhpList\Core\Domain\Messaging\Model\Bounce; +use PhpList\Core\Domain\Messaging\Model\Message; +use PhpList\Core\Domain\Messaging\Model\UserMessageBounce; class BounceRepository extends AbstractRepository implements PaginatableRepositoryInterface { @@ -18,4 +20,39 @@ public function findByStatus(string $status): array { return $this->findBy(['status' => $status]); } + + /** + * Returns bounce totals grouped by campaign, matching legacy msgbounces listing data. + * + * @param int|null $ownerId Limit results to campaigns owned by this admin when provided. + * @return array + */ + public function getCampaignBounceTotals(?int $ownerId = null): array + { + $queryBuilder = $this->getEntityManager() + ->createQueryBuilder() + ->select('m.id AS messageId', 'm.content.subject AS subject', 'COUNT(umb.bounceId) AS totalBounces') + ->from(Message::class, 'm') + ->innerJoin(UserMessageBounce::class, 'umb', 'ON', 'umb.messageId = m.id') + ->groupBy('m.id, m.content.subject') + ->orderBy('m.id', 'ASC'); + + if ($ownerId !== null) { + $queryBuilder + ->andWhere('IDENTITY(m.owner) = :ownerId') + ->setParameter('ownerId', $ownerId); + } + + /** @var array $rows */ + $rows = $queryBuilder->getQuery()->getArrayResult(); + + return array_map( + static fn (array $row): array => [ + 'messageId' => (int) $row['messageId'], + 'subject' => $row['subject'], + 'totalBounces' => (int) $row['totalBounces'], + ], + $rows + ); + } } diff --git a/src/Domain/Messaging/Repository/MessageRepository.php b/src/Domain/Messaging/Repository/MessageRepository.php index 10ee8629..d18ce68b 100644 --- a/src/Domain/Messaging/Repository/MessageRepository.php +++ b/src/Domain/Messaging/Repository/MessageRepository.php @@ -60,6 +60,11 @@ public function getFilteredAfterId(FilterRequestInterface $filter): PaginatedRes ->setParameter('ownerId', $filter->getOwner()->getId()); } + if ($filter instanceof MessageFilter && $filter->getSubject() !== null) { + $queryBuilder->andWhere('m.content.subject LIKE :subject') + ->setParameter('subject', '%' . $filter->getSubject() . '%'); + } + $countQb = clone $queryBuilder; $total = (int) $countQb ->select('COUNT(DISTINCT m.id)') diff --git a/src/Domain/Messaging/Repository/TemplateImageRepository.php b/src/Domain/Messaging/Repository/TemplateImageRepository.php index 60a48b39..16fce26a 100644 --- a/src/Domain/Messaging/Repository/TemplateImageRepository.php +++ b/src/Domain/Messaging/Repository/TemplateImageRepository.php @@ -7,6 +7,7 @@ use PhpList\Core\Domain\Common\Repository\AbstractRepository; use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait; use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface; +use PhpList\Core\Domain\Messaging\Model\Template; use PhpList\Core\Domain\Messaging\Model\TemplateImage; class TemplateImageRepository extends AbstractRepository implements PaginatableRepositoryInterface @@ -43,4 +44,15 @@ public function findByTemplateIdAndFilename(int $templateId, string $filename): ->getQuery() ->getOneOrNullResult(); } + + public function poweredByImageExists(Template $template): bool + { + return $this->createQueryBuilder('ti') + ->andWhere('ti.template = :templateId') + ->setParameter('templateId', $template->getId()) + ->andWhere('ti.filename = :filename') + ->setParameter('filename', 'powerphplist.png') + ->getQuery() + ->getOneOrNullResult() !== null; + } } diff --git a/src/Domain/Messaging/Repository/TemplateRepository.php b/src/Domain/Messaging/Repository/TemplateRepository.php index b84f2837..7cc40e9e 100644 --- a/src/Domain/Messaging/Repository/TemplateRepository.php +++ b/src/Domain/Messaging/Repository/TemplateRepository.php @@ -4,6 +4,8 @@ namespace PhpList\Core\Domain\Messaging\Repository; +use PhpList\Core\Domain\Common\Model\Filter\FilterRequestInterface; +use PhpList\Core\Domain\Common\Model\PaginatedResult; use PhpList\Core\Domain\Common\Repository\AbstractRepository; use PhpList\Core\Domain\Common\Repository\CursorPaginationTrait; use PhpList\Core\Domain\Common\Repository\Interfaces\PaginatableRepositoryInterface; @@ -17,4 +19,37 @@ public function findOneById(int $id): ?Template { return $this->findOneBy(['id' => $id]); } + + /** + * @return PaginatedResult