diff --git a/composer.json b/composer.json index c4c880f8..3dc5a3db 100644 --- a/composer.json +++ b/composer.json @@ -42,13 +42,14 @@ }, "require": { "php": "^8.1", - "phplist/core": "dev-main", + "phplist/core": "dev-ref/campaign-processing", "friendsofsymfony/rest-bundle": "*", "symfony/test-pack": "^1.0", "symfony/process": "^6.4", "zircote/swagger-php": "^4.11", "ext-dom": "*", - "tatevikgr/rss-feed": "dev-main as 0.1.0" + "tatevikgr/rss-feed": "dev-main as 0.1.0", + "psr/simple-cache": "^3.0" }, "require-dev": { "phpunit/phpunit": "^10.0", diff --git a/config/services/normalizers.yml b/config/services/normalizers.yml index 2179f6ad..b6176579 100644 --- a/config/services/normalizers.yml +++ b/config/services/normalizers.yml @@ -4,6 +4,10 @@ services: autoconfigure: true public: false + _instanceof: + Symfony\Component\Serializer\Normalizer\NormalizerInterface: + tags: [ 'serializer.normalizer' ] + Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter: ~ Symfony\Component\Serializer\Normalizer\ObjectNormalizer: @@ -11,97 +15,5 @@ services: $classMetadataFactory: '@?serializer.mapping.class_metadata_factory' $nameConverter: '@Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter' - PhpList\RestBundle\Subscription\Serializer\SubscriberNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true - - PhpList\RestBundle\Subscription\Serializer\SubscriberOnlyNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true - - PhpList\RestBundle\Identity\Serializer\AdministratorTokenNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true - - PhpList\RestBundle\Subscription\Serializer\SubscriberListNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true - - PhpList\RestBundle\Subscription\Serializer\SubscriberHistoryNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true - - PhpList\RestBundle\Subscription\Serializer\SubscriptionNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true - - PhpList\RestBundle\Messaging\Serializer\MessageNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true - - PhpList\RestBundle\Messaging\Serializer\TemplateImageNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true - - PhpList\RestBundle\Messaging\Serializer\TemplateNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true - - PhpList\RestBundle\Messaging\Serializer\ListMessageNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true - - PhpList\RestBundle\Identity\Serializer\AdministratorNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true - - PhpList\RestBundle\Identity\Serializer\AdminAttributeDefinitionNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true - - PhpList\RestBundle\Identity\Serializer\AdminAttributeValueNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true - - PhpList\RestBundle\Subscription\Serializer\AttributeDefinitionNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true - - PhpList\RestBundle\Subscription\Serializer\SubscriberAttributeValueNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true - - PhpList\RestBundle\Common\Serializer\CursorPaginationNormalizer: - autowire: true - - PhpList\RestBundle\Subscription\Serializer\SubscribersExportRequestNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true - - PhpList\RestBundle\Statistics\Serializer\CampaignStatisticsNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true - - PhpList\RestBundle\Statistics\Serializer\ViewOpensStatisticsNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true - - PhpList\RestBundle\Statistics\Serializer\TopDomainsNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true - - PhpList\RestBundle\Statistics\Serializer\TopLocalPartsNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true - - PhpList\RestBundle\Subscription\Serializer\UserBlacklistNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true - - PhpList\RestBundle\Subscription\Serializer\SubscribePageNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true - - PhpList\RestBundle\Messaging\Serializer\BounceRegexNormalizer: - tags: [ 'serializer.normalizer' ] - autowire: true + PhpList\RestBundle\: + resource: '../../src/*/Serializer/*' diff --git a/config/services/services.yml b/config/services/services.yml index f087aa6e..17127575 100644 --- a/config/services/services.yml +++ b/config/services/services.yml @@ -1,8 +1,19 @@ services: - PhpList\RestBundle\Subscription\Service\SubscriberService: + PhpList\RestBundle\Subscription\Service\SubscriberHistoryService: autowire: true autoconfigure: true - PhpList\RestBundle\Subscription\Service\SubscriberHistoryService: + PhpList\Core\Domain\Messaging\Service\ForwardingGuard: + autowire: true + autoconfigure: true + public: false + + PhpList\Core\Domain\Messaging\Service\ForwardDeliveryService: + autowire: true + autoconfigure: true + public: false + + PhpList\Core\Domain\Messaging\Service\ForwardContentService: autowire: true autoconfigure: true + public: false diff --git a/src/Identity/Controller/AdminAttributeDefinitionController.php b/src/Identity/Controller/AdminAttributeDefinitionController.php index 4bf38643..41ba5a91 100644 --- a/src/Identity/Controller/AdminAttributeDefinitionController.php +++ b/src/Identity/Controller/AdminAttributeDefinitionController.php @@ -7,7 +7,7 @@ use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; use PhpList\Core\Domain\Identity\Model\AdminAttributeDefinition; -use PhpList\Core\Domain\Identity\Service\AdminAttributeDefinitionManager; +use PhpList\Core\Domain\Identity\Service\Manager\AdminAttributeDefinitionManager; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Common\Controller\BaseController; use PhpList\RestBundle\Common\Service\Provider\PaginatedDataProvider; diff --git a/src/Identity/Controller/AdminAttributeValueController.php b/src/Identity/Controller/AdminAttributeValueController.php index b8e1b5a4..5cbea428 100644 --- a/src/Identity/Controller/AdminAttributeValueController.php +++ b/src/Identity/Controller/AdminAttributeValueController.php @@ -10,7 +10,7 @@ use PhpList\Core\Domain\Identity\Model\Administrator; use PhpList\Core\Domain\Identity\Model\AdminAttributeDefinition; use PhpList\Core\Domain\Identity\Model\AdminAttributeValue; -use PhpList\Core\Domain\Identity\Service\AdminAttributeManager; +use PhpList\Core\Domain\Identity\Service\Manager\AdminAttributeManager; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Common\Controller\BaseController; use PhpList\RestBundle\Common\Service\Provider\PaginatedDataProvider; diff --git a/src/Identity/Controller/AdministratorController.php b/src/Identity/Controller/AdministratorController.php index c1f173f1..83138038 100644 --- a/src/Identity/Controller/AdministratorController.php +++ b/src/Identity/Controller/AdministratorController.php @@ -7,7 +7,7 @@ use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; use PhpList\Core\Domain\Identity\Model\Administrator; -use PhpList\Core\Domain\Identity\Service\AdministratorManager; +use PhpList\Core\Domain\Identity\Service\Manager\AdministratorManager; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Common\Controller\BaseController; use PhpList\RestBundle\Common\Service\Provider\PaginatedDataProvider; diff --git a/src/Identity/Controller/PasswordResetController.php b/src/Identity/Controller/PasswordResetController.php index a3527b07..b020814d 100644 --- a/src/Identity/Controller/PasswordResetController.php +++ b/src/Identity/Controller/PasswordResetController.php @@ -6,7 +6,7 @@ use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; -use PhpList\Core\Domain\Identity\Service\PasswordManager; +use PhpList\Core\Domain\Identity\Service\Manager\PasswordManager; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Common\Controller\BaseController; use PhpList\RestBundle\Common\Validator\RequestValidator; diff --git a/src/Identity/Controller/SessionController.php b/src/Identity/Controller/SessionController.php index 66b49ce9..1924072e 100644 --- a/src/Identity/Controller/SessionController.php +++ b/src/Identity/Controller/SessionController.php @@ -7,7 +7,7 @@ use Doctrine\ORM\EntityManagerInterface; use OpenApi\Attributes as OA; use PhpList\Core\Domain\Identity\Model\AdministratorToken; -use PhpList\Core\Domain\Identity\Service\SessionManager; +use PhpList\Core\Domain\Identity\Service\Manager\SessionManager; use PhpList\Core\Security\Authentication; use PhpList\RestBundle\Common\Controller\BaseController; use PhpList\RestBundle\Common\Validator\RequestValidator; diff --git a/src/Messaging/Controller/CampaignController.php b/src/Messaging/Controller/CampaignController.php index a047c9cd..dc2ac689 100644 --- a/src/Messaging/Controller/CampaignController.php +++ b/src/Messaging/Controller/CampaignController.php @@ -29,19 +29,14 @@ #[Route('/campaigns', name: 'campaign_')] class CampaignController extends BaseController { - private CampaignService $campaignService; - private MessageBusInterface $messageBus; - public function __construct( Authentication $authentication, RequestValidator $validator, - CampaignService $campaignService, - MessageBusInterface $messageBus, + private readonly CampaignService $campaignService, + private readonly MessageBusInterface $messageBus, private readonly EntityManagerInterface $entityManager, ) { parent::__construct($authentication, $validator); - $this->campaignService = $campaignService; - $this->messageBus = $messageBus; } #[Route('', name: 'get_list', methods: ['GET'])] diff --git a/src/Messaging/Controller/EmailForwardController.php b/src/Messaging/Controller/EmailForwardController.php new file mode 100644 index 00000000..3917e8ca --- /dev/null +++ b/src/Messaging/Controller/EmailForwardController.php @@ -0,0 +1,121 @@ + + */ +#[Route('/email-forward', name: 'email_forward_')] +class EmailForwardController extends BaseController +{ + public function __construct( + Authentication $authentication, + RequestValidator $validator, + private readonly EntityManagerInterface $entityManager, + private readonly MessageForwardService $messageForwardService, + private readonly ForwardingResultNormalizer $forwardingResultNormalizer, + ) { + parent::__construct($authentication, $validator); + } + + #[Route('/{messageId}', name: 'forward', requirements: ['messageId' => '\\d+'], methods: ['POST'])] + #[OA\Post( + path: '/api/v2/campaigns/{messageId}/forward', + description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . + 'Queues forwarding of a campaign/message to provided recipient emails.', + summary: 'Forward a message to recipients.', + requestBody: new OA\RequestBody( + description: 'Forwarding payload', + required: true, + content: new OA\JsonContent(ref: '#/components/schemas/ForwardMessageRequest') + ), + tags: ['campaigns'], + parameters: [ + new OA\Parameter( + name: 'php-auth-pw', + description: 'Session key obtained from login', + in: 'header', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'messageId', + description: 'message ID', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string') + ) + ], + responses: [ + new OA\Response( + response: 202, + description: 'Accepted', + content: new OA\JsonContent(ref: '#/components/schemas/ForwardResult') + ), + new OA\Response( + response: 403, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') + ), + new OA\Response( + response: 404, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') + ), + new OA\Response( + response: 422, + description: 'Failure', + content: new OA\JsonContent(ref: '#/components/schemas/ValidationErrorResponse') + ) + ] + )] + public function forwardMessage( + Request $request, + #[MapEntity(mapping: ['messageId' => 'id'])] ?Message $message = null + ): JsonResponse { + if ($message === null) { + throw $this->createNotFoundException('Campaign not found.'); + } + + /** @var ForwardMessageRequest $forwardRequest */ + $forwardRequest = $this->validator->validate($request, ForwardMessageRequest::class); + + $result = $this->messageForwardService->forward( + messageForwardDto: new MessageForwardDto( + emails: $forwardRequest->recipients, + uid: $forwardRequest->uid, + fromName: $forwardRequest->fromName, + fromEmail: $forwardRequest->fromEmail, + note: $forwardRequest->note, + ), + campaign: $message, + ); + + $this->entityManager->flush(); + + return $this->json( + $this->forwardingResultNormalizer->normalize($result), + Response::HTTP_ACCEPTED + ); + } +} diff --git a/src/Messaging/Request/ForwardMessageRequest.php b/src/Messaging/Request/ForwardMessageRequest.php new file mode 100644 index 00000000..e32f75e3 --- /dev/null +++ b/src/Messaging/Request/ForwardMessageRequest.php @@ -0,0 +1,92 @@ + [ + new Assert\Email([]), + new Assert\Length(max: 255), + ], + ])] + public array $recipients = []; + + #[Assert\Length(max: 255)] + public ?string $uid = null; + + #[MaxPersonalNoteSize] + public ?string $note = null; + + #[Assert\Length(max: 255)] + public ?string $fromName = null; + + #[Assert\Email] + #[Assert\Length(max: 255)] + public ?string $fromEmail = null; + + public function getDto(): array + { + return [ + 'recipients' => $this->recipients, + 'uid' => $this->uid, + 'note' => $this->note, + 'fromName' => $this->fromName, + 'fromEmail' => $this->fromEmail, + ]; + } +} diff --git a/src/Messaging/Serializer/ForwardingResultNormalizer.php b/src/Messaging/Serializer/ForwardingResultNormalizer.php new file mode 100644 index 00000000..61b85cf7 --- /dev/null +++ b/src/Messaging/Serializer/ForwardingResultNormalizer.php @@ -0,0 +1,71 @@ + $recipient->email, + 'status' => $recipient->status, + 'reason' => $recipient->reason, + ]; + }, $object->recipients); + + return [ + 'total_requested' => $object->totalRequested, + 'total_sent' => $object->totalSent, + 'total_failed' => $object->totalFailed, + 'total_already_sent' => $object->totalAlreadySent, + 'recipients' => $recipients, + ]; + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function supportsNormalization($data, string $format = null): bool + { + return $data instanceof ForwardingResult; + } +} diff --git a/src/Messaging/Serializer/MessageNormalizer.php b/src/Messaging/Serializer/MessageNormalizer.php index 5c1f3e60..7a2ead3b 100644 --- a/src/Messaging/Serializer/MessageNormalizer.php +++ b/src/Messaging/Serializer/MessageNormalizer.php @@ -33,12 +33,6 @@ properties: [ new OA\Property(property: 'html_formated', type: 'boolean'), new OA\Property(property: 'send_format', type: 'string', example: 'text', nullable: true), - new OA\Property( - property: 'format_options', - type: 'array', - items: new OA\Items(type: 'string'), - example: ['as_html', 'as_text'], - ), ], type: 'object' ), @@ -112,7 +106,6 @@ public function normalize($object, string $format = null, array $context = []): 'message_format' => [ 'html_formated' => $object->getFormat()->isHtmlFormatted(), 'send_format' => $object->getFormat()->getSendFormat(), - 'format_options' => $object->getFormat()->getFormatOptions() ], 'message_metadata' => [ 'status' => $object->getMetadata()->getStatus()->value, diff --git a/src/Messaging/Service/CampaignService.php b/src/Messaging/Service/CampaignService.php index 4bc9e3c6..90ec7d25 100644 --- a/src/Messaging/Service/CampaignService.php +++ b/src/Messaging/Service/CampaignService.php @@ -32,7 +32,12 @@ public function getMessages(Request $request, Administrator $administrator): arr { $filter = (new MessageFilter())->setOwner($administrator); - return $this->paginatedProvider->getPaginatedList($request, $this->normalizer, Message::class, $filter); + return $this->paginatedProvider->getPaginatedList( + request: $request, + normalizer: $this->normalizer, + className: Message::class, + filter: $filter + ); } public function getMessage(Message $message = null): array @@ -50,7 +55,10 @@ public function createMessage(CreateMessageRequest $createMessageRequest, Admini throw new AccessDeniedHttpException('You are not allowed to create campaigns.'); } - $data = $this->messageManager->createMessage($createMessageRequest->getDto(), $administrator); + $data = $this->messageManager->createMessage( + createMessageDto: $createMessageRequest->getDto(), + authUser: $administrator + ); return $this->normalizer->normalize($data); } @@ -69,9 +77,9 @@ public function updateMessage( } $data = $this->messageManager->updateMessage( - $updateMessageRequest->getDto(), - $message, - $administrator + updateMessageDto: $updateMessageRequest->getDto(), + message: $message, + authUser: $administrator ); return $this->normalizer->normalize($data); diff --git a/src/Messaging/Validator/Constraint/MaxForwardCount.php b/src/Messaging/Validator/Constraint/MaxForwardCount.php new file mode 100644 index 00000000..9c6e41db --- /dev/null +++ b/src/Messaging/Validator/Constraint/MaxForwardCount.php @@ -0,0 +1,13 @@ + $this->maxForward) { + $this->context->buildViolation($constraint->message) + ->setParameter('{{ limit }}', $this->maxForward) + ->addViolation(); + } + } +} diff --git a/src/Messaging/Validator/Constraint/MaxPersonalNoteSize.php b/src/Messaging/Validator/Constraint/MaxPersonalNoteSize.php new file mode 100644 index 00000000..1b0c519b --- /dev/null +++ b/src/Messaging/Validator/Constraint/MaxPersonalNoteSize.php @@ -0,0 +1,13 @@ +maxSize === null || $this->maxSize < 0) { + return; + } + + if (!is_string($value)) { + return; + } + + $sizeLimit = $this->maxSize * 2; + $length = function_exists('mb_strlen') ? mb_strlen($value) : strlen($value); + + if ($length > $sizeLimit) { + $this->context->buildViolation($constraint->message) + ->setParameter('{{ limit }}', (string) $this->maxSize) + ->addViolation(); + } + } +} diff --git a/tests/Integration/Common/AbstractTestController.php b/tests/Integration/Common/AbstractTestController.php index e431abbd..e8d96b85 100644 --- a/tests/Integration/Common/AbstractTestController.php +++ b/tests/Integration/Common/AbstractTestController.php @@ -60,6 +60,10 @@ protected function jsonRequest( ): Crawler { $serverWithContentType = $server; $serverWithContentType['CONTENT_TYPE'] = 'application/json'; + // Ensure the server knows the client expects JSON back as well + $serverWithContentType['HTTP_ACCEPT'] = 'application/json'; + // Mark as AJAX-style request to help some handlers choose JSON rendering + $serverWithContentType['HTTP_X_REQUESTED_WITH'] = 'XMLHttpRequest'; return self::getClient()->request( $method, diff --git a/tests/Integration/Messaging/Fixtures/MessageFixture.php b/tests/Integration/Messaging/Fixtures/MessageFixture.php index 42b33b51..f281546c 100644 --- a/tests/Integration/Messaging/Fixtures/MessageFixture.php +++ b/tests/Integration/Messaging/Fixtures/MessageFixture.php @@ -50,13 +50,8 @@ public function load(ObjectManager $manager): void $template = $templateRepository->find($row['template']); $format = new MessageFormat( - (bool)$row['htmlformatted'], - $row['sendformat'], - array_keys(array_filter([ - MessageFormat::FORMAT_TEXT => $row['astext'], - MessageFormat::FORMAT_HTML => $row['ashtml'], - MessageFormat::FORMAT_PDF => $row['aspdf'], - ])) + htmlFormatted: (bool)$row['htmlformatted'], + sendFormat: $row['sendformat'], ); $schedule = new MessageSchedule( diff --git a/tests/Unit/Messaging/Serializer/MessageNormalizerTest.php b/tests/Unit/Messaging/Serializer/MessageNormalizerTest.php index d90ef900..8d6856d9 100644 --- a/tests/Unit/Messaging/Serializer/MessageNormalizerTest.php +++ b/tests/Unit/Messaging/Serializer/MessageNormalizerTest.php @@ -19,7 +19,11 @@ class MessageNormalizerTest extends TestCase public function __construct(string $name) { parent::__construct($name); - $this->normalizer = new MessageNormalizer(new TemplateNormalizer(new TemplateImageNormalizer())); + $this->normalizer = new MessageNormalizer( + new TemplateNormalizer( + new TemplateImageNormalizer() + ) + ); } public function testSupportsNormalization(): void @@ -41,7 +45,6 @@ public function testNormalizeReturnsExpectedArray(): void $content = new Message\MessageContent('Subject', 'Text', 'TextMsg', 'Footer'); $format = new Message\MessageFormat(true, 'html'); - $format->setFormatOptions(['text', 'html']); $entered = new DateTime('2025-01-01T10:00:00+00:00'); $sent = new DateTime('2025-01-02T10:00:00+00:00'); @@ -61,7 +64,12 @@ public function testNormalizeReturnsExpectedArray(): void new DateTime('2025-01-01T00:00:00+00:00') ); - $options = new Message\MessageOptions('from@example.com', 'to@example.com', 'reply@example.com', 'group'); + $options = new Message\MessageOptions( + 'from@example.com', + 'to@example.com', + 'reply@example.com', + 'group' + ); $message = $this->createMock(Message::class); $message->method('getId')->willReturn(1); @@ -79,7 +87,6 @@ public function testNormalizeReturnsExpectedArray(): void $this->assertSame('uuid-123', $result['unique_id']); $this->assertSame('Test Template', $result['template']['title']); $this->assertSame('Subject', $result['message_content']['subject']); - $this->assertSame(['text', 'html'], $result['message_format']['format_options']); $this->assertSame(Message\MessageStatus::Draft->value, $result['message_metadata']['status']); $this->assertSame('from@example.com', $result['message_options']['from_field']); }