├── docs └── .gitkeep ├── .gitattributes ├── config ├── services │ ├── messenger_handlers.yml │ ├── factories.yml │ ├── services.yml │ ├── providers.yml │ ├── listeners.yml │ ├── builders.yml │ ├── controllers.yml │ ├── validators.yml │ ├── managers.yml │ └── normalizers.yml └── services.yml ├── src ├── Messaging │ ├── Request │ │ ├── Message │ │ │ ├── RequestDtoInterface.php │ │ │ ├── MessageMetadataRequest.php │ │ │ ├── MessageContentRequest.php │ │ │ ├── MessageOptionsRequest.php │ │ │ ├── MessageFormatRequest.php │ │ │ └── MessageScheduleRequest.php │ │ ├── UpdateMessageRequest.php │ │ ├── CreateMessageRequest.php │ │ ├── CreateBounceRegexRequest.php │ │ └── CreateTemplateRequest.php │ ├── Validator │ │ └── Constraint │ │ │ ├── ContainsPlaceholder.php │ │ │ ├── TemplateExists.php │ │ │ ├── ContainsPlaceholderValidator.php │ │ │ └── TemplateExistsValidator.php │ ├── Serializer │ │ ├── TemplateImageNormalizer.php │ │ ├── TemplateNormalizer.php │ │ ├── BounceRegexNormalizer.php │ │ ├── ListMessageNormalizer.php │ │ └── MessageNormalizer.php │ └── Service │ │ └── CampaignService.php ├── Common │ ├── Request │ │ ├── RequestInterface.php │ │ └── PaginationCursorRequest.php │ ├── Dto │ │ ├── CursorPaginationResult.php │ │ └── ValidationContext.php │ ├── Service │ │ ├── Factory │ │ │ └── PaginationCursorRequestFactory.php │ │ └── Provider │ │ │ └── PaginatedDataProvider.php │ ├── EventListener │ │ ├── ResponseListener.php │ │ └── ExceptionListener.php │ ├── Serializer │ │ └── CursorPaginationNormalizer.php │ ├── Controller │ │ └── BaseController.php │ ├── SwaggerSchemasResponse.php │ └── Validator │ │ └── RequestValidator.php ├── Identity │ ├── Request │ │ ├── ValidateTokenRequest.php │ │ ├── RequestPasswordResetRequest.php │ │ ├── ResetPasswordRequest.php │ │ ├── CreateSessionRequest.php │ │ ├── AdminAttributeDefinitionRequest.php │ │ ├── UpdateAdministratorRequest.php │ │ └── CreateAdministratorRequest.php │ ├── Validator │ │ └── Constraint │ │ │ ├── UniqueEmail.php │ │ │ ├── UniqueLoginName.php │ │ │ ├── UniqueEmailValidator.php │ │ │ └── UniqueLoginNameValidator.php │ ├── Serializer │ │ ├── AdministratorTokenNormalizer.php │ │ ├── AdminAttributeValueNormalizer.php │ │ ├── AdminAttributeDefinitionNormalizer.php │ │ └── AdministratorNormalizer.php │ └── Controller │ │ ├── SessionController.php │ │ └── PasswordResetController.php ├── Subscription │ ├── Request │ │ ├── SubscribePageDataRequest.php │ │ ├── AddToBlacklistRequest.php │ │ ├── SubscribePageRequest.php │ │ ├── SubscriptionRequest.php │ │ ├── CreateSubscriberListRequest.php │ │ ├── CreateSubscriberRequest.php │ │ ├── UpdateSubscriberRequest.php │ │ ├── SubscriberAttributeDefinitionRequest.php │ │ └── SubscribersExportRequest.php │ ├── Validator │ │ └── Constraint │ │ │ ├── UniqueEmail.php │ │ │ ├── EmailExists.php │ │ │ ├── ListExists.php │ │ │ ├── EmailExistsValidator.php │ │ │ ├── ListExistsValidator.php │ │ │ └── UniqueEmailValidator.php │ ├── Serializer │ │ ├── SubscribersExportRequestNormalizer.php │ │ ├── UserBlacklistNormalizer.php │ │ ├── SubscribePageNormalizer.php │ │ ├── SubscriberAttributeValueNormalizer.php │ │ ├── SubscriberHistoryNormalizer.php │ │ ├── SubscriptionNormalizer.php │ │ ├── SubscriberListNormalizer.php │ │ ├── SubscriberOnlyNormalizer.php │ │ ├── AttributeDefinitionNormalizer.php │ │ └── SubscriberNormalizer.php │ ├── Service │ │ └── SubscriberHistoryService.php │ └── Controller │ │ ├── SubscriberExportController.php │ │ ├── SubscriberImportController.php │ │ ├── ListMembersController.php │ │ └── SubscriptionController.php ├── ViewHandler │ └── SecuredViewHandler.php ├── DependencyInjection │ └── PhpListRestExtension.php ├── Statistics │ └── Serializer │ │ ├── TopDomainsNormalizer.php │ │ ├── TopLocalPartsNormalizer.php │ │ ├── ViewOpensStatisticsNormalizer.php │ │ └── CampaignStatisticsNormalizer.php └── PhpListRestBundle.php ├── .gitignore ├── .coderabbit.yaml ├── phpunit.xml.dist ├── CHANGELOG.md ├── README.md ├── CODE_OF_CONDUCT.md └── composer.json /docs/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Exclude tests and development stuff from archives 2 | /tests export-ignore 3 | /.github export-ignore 4 | -------------------------------------------------------------------------------- /config/services/messenger_handlers.yml: -------------------------------------------------------------------------------- 1 | services: 2 | PhpList\Core\Domain\Messaging\MessageHandler\CampaignProcessorMessageHandler: 3 | autowire: true 4 | autoconfigure: true 5 | public: false 6 | -------------------------------------------------------------------------------- /src/Messaging/Request/Message/RequestDtoInterface.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/Identity/Request/RequestPasswordResetRequest.php: -------------------------------------------------------------------------------- 1 | query->getInt('after_id'), 16 | $request->query->getInt('limit', 25) 17 | ); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Subscription/Request/SubscribePageRequest.php: -------------------------------------------------------------------------------- 1 | options[$key] = $value; 14 | 15 | return $this; 16 | } 17 | 18 | public function get(string $key, mixed $default = null): mixed 19 | { 20 | return $this->options[$key] ?? $default; 21 | } 22 | 23 | public function has(string $key): bool 24 | { 25 | return array_key_exists($key, $this->options); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Identity/Request/ResetPasswordRequest.php: -------------------------------------------------------------------------------- 1 | getResponse(); 15 | 16 | if ($response instanceof JsonResponse) { 17 | $response->headers->set('X-Content-Type-Options', 'nosniff'); 18 | $response->headers->set('Content-Security-Policy', "default-src 'none'"); 19 | $response->headers->set('X-Frame-Options', 'DENY'); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Subscription/Request/SubscriptionRequest.php: -------------------------------------------------------------------------------- 1 | entityClass = $entityClass; 19 | } 20 | 21 | public function validatedBy(): string 22 | { 23 | return UniqueEmailValidator::class; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Subscription/Validator/Constraint/UniqueEmail.php: -------------------------------------------------------------------------------- 1 | entityClass = $entityClass; 19 | } 20 | 21 | public function validatedBy(): string 22 | { 23 | return UniqueEmailValidator::class; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /config/services/builders.yml: -------------------------------------------------------------------------------- 1 | services: 2 | _defaults: 3 | autowire: true 4 | autoconfigure: true 5 | public: false 6 | 7 | PhpList\Core\Domain\Messaging\Service\Builder\MessageBuilder: 8 | autowire: true 9 | autoconfigure: true 10 | 11 | PhpList\Core\Domain\Messaging\Service\Builder\MessageFormatBuilder: 12 | autowire: true 13 | autoconfigure: true 14 | 15 | PhpList\Core\Domain\Messaging\Service\Builder\MessageScheduleBuilder: 16 | autowire: true 17 | autoconfigure: true 18 | 19 | PhpList\Core\Domain\Messaging\Service\Builder\MessageContentBuilder: 20 | autowire: true 21 | autoconfigure: true 22 | 23 | PhpList\Core\Domain\Messaging\Service\Builder\MessageOptionsBuilder: 24 | autowire: true 25 | autoconfigure: true 26 | -------------------------------------------------------------------------------- /src/Common/Request/PaginationCursorRequest.php: -------------------------------------------------------------------------------- 1 | afterId = $afterId; 17 | $this->limit = min(100, max(1, $limit)); 18 | } 19 | 20 | public static function fromRequest(Request $request): self 21 | { 22 | return new self( 23 | $request->query->get('after_id') ? (int)$request->query->get('after_id') : 0, 24 | $request->query->getInt('limit', 25) 25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Messaging/Request/UpdateMessageRequest.php: -------------------------------------------------------------------------------- 1 | content->getDto(), 16 | format: $this->format->getDto(), 17 | metadata: $this->metadata->getDto(), 18 | options: $this->options->getDto(), 19 | schedule: $this->schedule->getDto(), 20 | templateId: $this->templateId, 21 | ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Identity/Validator/Constraint/UniqueLoginName.php: -------------------------------------------------------------------------------- 1 | mode = $mode ?? $this->mode; 20 | $this->message = $message ?? $this->message; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Messaging/Validator/Constraint/TemplateExists.php: -------------------------------------------------------------------------------- 1 | mode = $mode ?? $this->mode; 20 | $this->message = $message ?? $this->message; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Subscription/Validator/Constraint/EmailExists.php: -------------------------------------------------------------------------------- 1 | mode = $mode ?? $this->mode; 20 | $this->message = $message ?? $this->message; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Subscription/Validator/Constraint/ListExists.php: -------------------------------------------------------------------------------- 1 | mode = $mode ?? $this->mode; 20 | $this->message = $message ?? $this->message; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Messaging/Request/Message/MessageMetadataRequest.php: -------------------------------------------------------------------------------- 1 | status), 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /config/services/controllers.yml: -------------------------------------------------------------------------------- 1 | services: 2 | _defaults: 3 | autowire: true 4 | autoconfigure: true 5 | public: false 6 | 7 | PhpList\RestBundle\Identity\Controller\: 8 | resource: '../src/Identity/Controller' 9 | tags: [ 'controller.service_arguments' ] 10 | autowire: true 11 | autoconfigure: true 12 | public: true 13 | 14 | PhpList\RestBundle\Messaging\Controller\: 15 | resource: '../src/Messaging/Controller' 16 | tags: [ 'controller.service_arguments' ] 17 | autowire: true 18 | autoconfigure: true 19 | public: true 20 | 21 | PhpList\RestBundle\Subscription\Controller\: 22 | resource: '../src/Subscription/Controller' 23 | tags: [ 'controller.service_arguments' ] 24 | autowire: true 25 | autoconfigure: true 26 | public: true 27 | 28 | PhpList\RestBundle\Statistics\Controller\: 29 | resource: '../src/Statistics/Controller' 30 | tags: [ 'controller.service_arguments' ] 31 | autowire: true 32 | autoconfigure: true 33 | public: true 34 | -------------------------------------------------------------------------------- /src/Messaging/Validator/Constraint/ContainsPlaceholderValidator.php: -------------------------------------------------------------------------------- 1 | placeholder)) { 24 | $this->context->buildViolation($constraint->message) 25 | ->setParameter('{{ placeholder }}', $constraint->placeholder) 26 | ->addViolation(); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Identity/Serializer/AdministratorTokenNormalizer.php: -------------------------------------------------------------------------------- 1 | $object->getId(), 23 | 'key' => $object->getKey(), 24 | 'expiry_date' => $object->getExpiry()->format('Y-m-d\TH:i:sP'), 25 | ]; 26 | } 27 | 28 | /** 29 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) 30 | */ 31 | public function supportsNormalization($data, string $format = null): bool 32 | { 33 | return $data instanceof AdministratorToken; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/ViewHandler/SecuredViewHandler.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | class SecuredViewHandler 17 | { 18 | /** 19 | * @param ViewHandler $handler 20 | * @param View $view 21 | * @param Request $request 22 | * @param string $format 23 | * 24 | * @return Response 25 | */ 26 | public function createResponse(ViewHandler $handler, View $view, Request $request, string $format): Response 27 | { 28 | $view->setHeaders( 29 | [ 30 | 'X-Content-Type-Options' => 'nosniff', 31 | 'Content-Security-Policy' => "default-src 'none'", 32 | 'X-Frame-Options' => 'DENY', 33 | ] 34 | ); 35 | 36 | return $handler->createResponse($view, $request, $format); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Subscription/Serializer/SubscribersExportRequestNormalizer.php: -------------------------------------------------------------------------------- 1 | $object->dateType, 23 | 'list_id' => $object->listId, 24 | 'date_from' => $object->dateFrom, 25 | 'date_to' => $object->dateTo, 26 | 'columns' => $object->columns, 27 | ]; 28 | } 29 | 30 | /** 31 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) 32 | */ 33 | public function supportsNormalization($data, string $format = null): bool 34 | { 35 | return $data instanceof SubscribersExportRequest; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/DependencyInjection/PhpListRestExtension.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | class PhpListRestExtension extends Extension 20 | { 21 | /** 22 | * Loads a specific configuration. 23 | * 24 | * @param array $configs configuration values 25 | * @param ContainerBuilder $container 26 | * 27 | * @return void 28 | * 29 | * @throws InvalidArgumentException|Exception if the provided tag is not defined in this extension 30 | */ 31 | public function load(array $configs, ContainerBuilder $container): void 32 | { 33 | // @phpstan-ignore-next-line 34 | $configs; 35 | $loader = new YamlFileLoader($container, new FileLocator(__DIR__ . '/../../config')); 36 | $loader->load('services.yml'); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Subscription/Serializer/UserBlacklistNormalizer.php: -------------------------------------------------------------------------------- 1 | blacklistManager->getBlacklistReason($object->getEmail()); 27 | 28 | return [ 29 | 'email' => $object->getEmail(), 30 | 'added' => $object->getAdded()?->format('Y-m-d\TH:i:sP'), 31 | 'reason' => $reason, 32 | ]; 33 | } 34 | 35 | /** 36 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) 37 | */ 38 | public function supportsNormalization($data, string $format = null): bool 39 | { 40 | return $data instanceof UserBlacklist; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # phpList 4 REST API change log 2 | 3 | All notable changes to this project will be documented in this file. 4 | This project adheres to [Semantic Versioning](https://semver.org/). 5 | 6 | 7 | ## x.y.z (next release) 8 | 9 | ### Added 10 | - Run the system test on Travis (#113) 11 | - Add security headers to the default response (#110) 12 | - Whitelist BadRequestHttpException so that messages are not sanitized (#108) 13 | 14 | ### Changed 15 | 16 | ### Deprecated 17 | 18 | ### Removed 19 | 20 | ### Fixed 21 | 22 | ## 5.0.0-alpha1 23 | 24 | ### Changed 25 | - php version 8.1 26 | 27 | ## 4.0.0-alpha2 28 | 29 | ### Added 30 | - REST API endpoint for deleting a session (log out) (#101) 31 | - REST API endpoint for deleting a list (#98) 32 | - REST API endpoint for getting list details (#89) 33 | - System tests for the test and dev environment (#81) 34 | - REST action for getting all subscribers for a list (#83) 35 | 36 | ### Changed 37 | - Move the PHPUnit configuration file (#99) 38 | - Use the renamed phplist/core package (#97) 39 | - Adopt more of the default Symfony project structure (#92, #93, #94, #95, #102, #106) 40 | 41 | ### Deprecated 42 | 43 | ### Removed 44 | 45 | ### Fixed 46 | - Prevent crashes from sensio/framework-extra-bundle updates (#105) 47 | - Make the exception codes 32-bit safe (#100) 48 | - Always truncate the DB tables after an integration test (#86) 49 | 50 | ### Security 51 | -------------------------------------------------------------------------------- /src/Common/Serializer/CursorPaginationNormalizer.php: -------------------------------------------------------------------------------- 1 | items; 19 | $limit = $object->limit; 20 | $total = $object->total; 21 | $hasNext = !empty($items) && isset($items[array_key_last($items)]['id']); 22 | 23 | return [ 24 | 'items' => $items, 25 | 'pagination' => [ 26 | 'total' => $total, 27 | 'limit' => $limit, 28 | 'has_more' => count($items) === $limit, 29 | 'next_cursor' => $hasNext ? $items[array_key_last($items)]['id'] : null, 30 | ], 31 | ]; 32 | } 33 | 34 | /** 35 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) 36 | */ 37 | public function supportsNormalization($data, string $format = null): bool 38 | { 39 | return $data instanceof CursorPaginationResult; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Common/Controller/BaseController.php: -------------------------------------------------------------------------------- 1 | authentication = $authentication; 25 | $this->validator = $validator; 26 | } 27 | 28 | protected function requireAuthentication(Request $request): Administrator 29 | { 30 | $administrator = $this->authentication->authenticateByApiKey($request); 31 | if ($administrator === null) { 32 | throw new AccessDeniedHttpException( 33 | 'No valid session key was provided as basic auth password.', 34 | null, 35 | 1512749701 36 | ); 37 | } 38 | 39 | return $administrator; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Messaging/Request/Message/MessageContentRequest.php: -------------------------------------------------------------------------------- 1 | subject, 40 | text: $this->text, 41 | textMessage: $this->textMessage, 42 | footer: $this->footer, 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Subscription/Request/CreateSubscriberListRequest.php: -------------------------------------------------------------------------------- 1 | name, 39 | isPublic: $this->public, 40 | listPosition: $this->listPosition, 41 | description: $this->description, 42 | ); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Messaging/Request/Message/MessageOptionsRequest.php: -------------------------------------------------------------------------------- 1 | fromField, 40 | toField: $this->toField, 41 | replyTo: $this->replyTo, 42 | userSelection: $this->userSelection, 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Messaging/Validator/Constraint/TemplateExistsValidator.php: -------------------------------------------------------------------------------- 1 | templateRepository = $templateRepository; 21 | } 22 | 23 | public function validate($value, Constraint $constraint): void 24 | { 25 | if (!$constraint instanceof TemplateExists) { 26 | throw new UnexpectedTypeException($constraint, TemplateExists::class); 27 | } 28 | 29 | if (null === $value || '' === $value) { 30 | return; 31 | } 32 | 33 | if (!is_int($value)) { 34 | throw new UnexpectedValueException($value, 'integer'); 35 | } 36 | 37 | $existingUser = $this->templateRepository->find($value); 38 | 39 | if (!$existingUser) { 40 | throw new ConflictHttpException('Template with that id does not exists.'); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Identity/Validator/Constraint/UniqueEmailValidator.php: -------------------------------------------------------------------------------- 1 | repository->findOneBy(['email' => $value]); 35 | 36 | $dto = $this->context->getObject(); 37 | $updatingId = $dto->administratorId ?? null; 38 | 39 | if ($existingUser && $existingUser->getId() !== $updatingId) { 40 | throw new ConflictHttpException('Email already exists.'); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Subscription/Validator/Constraint/EmailExistsValidator.php: -------------------------------------------------------------------------------- 1 | subscriberRepository = $subscriberRepository; 21 | } 22 | 23 | public function validate($value, Constraint $constraint): void 24 | { 25 | if (!$constraint instanceof EmailExists) { 26 | throw new UnexpectedTypeException($constraint, EmailExists::class); 27 | } 28 | 29 | if (null === $value || '' === $value) { 30 | return; 31 | } 32 | 33 | if (!is_string($value)) { 34 | throw new UnexpectedValueException($value, 'string'); 35 | } 36 | 37 | $existingUser = $this->subscriberRepository->findOneBy(['email' => $value]); 38 | 39 | if (!$existingUser) { 40 | throw new NotFoundHttpException('Subscriber with email does not exists.'); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Subscription/Validator/Constraint/ListExistsValidator.php: -------------------------------------------------------------------------------- 1 | subscriberListRepository = $subscriberListRepository; 21 | } 22 | 23 | public function validate($value, Constraint $constraint): void 24 | { 25 | if (!$constraint instanceof ListExists) { 26 | throw new UnexpectedTypeException($constraint, ListExists::class); 27 | } 28 | 29 | if (null === $value || '' === $value) { 30 | return; 31 | } 32 | 33 | if (!is_string($value)) { 34 | throw new UnexpectedValueException($value, 'string'); 35 | } 36 | 37 | $existingList = $this->subscriberListRepository->find($value); 38 | 39 | if (!$existingList) { 40 | throw new NotFoundHttpException('Subscriber list does not exists.'); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Subscription/Request/CreateSubscriberRequest.php: -------------------------------------------------------------------------------- 1 | email, 41 | requestConfirmation: $this->requestConfirmation, 42 | htmlEmail: $this->htmlEmail, 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /config/services/validators.yml: -------------------------------------------------------------------------------- 1 | services: 2 | PhpList\RestBundle\Common\Validator\RequestValidator: 3 | arguments: 4 | $serializer: '@Symfony\Component\Serializer\Normalizer\ObjectNormalizer' 5 | $validator: '@validator' 6 | 7 | PhpList\RestBundle\Identity\Validator\Constraint\UniqueEmailValidator: 8 | autowire: true 9 | autoconfigure: true 10 | tags: [ 'validator.constraint_validator' ] 11 | 12 | PhpList\RestBundle\Subscription\Validator\Constraint\UniqueEmailValidator: 13 | autowire: true 14 | autoconfigure: true 15 | tags: [ 'validator.constraint_validator' ] 16 | 17 | PhpList\RestBundle\Subscription\Validator\Constraint\EmailExistsValidator: 18 | autowire: true 19 | autoconfigure: true 20 | tags: [ 'validator.constraint_validator' ] 21 | 22 | PhpList\RestBundle\Messaging\Validator\Constraint\TemplateExistsValidator: 23 | autowire: true 24 | autoconfigure: true 25 | tags: [ 'validator.constraint_validator' ] 26 | 27 | PhpList\RestBundle\Messaging\Validator\Constraint\ContainsPlaceholderValidator: 28 | tags: ['validator.constraint_validator'] 29 | 30 | PhpList\RestBundle\Identity\Validator\Constraint\UniqueLoginNameValidator: 31 | autowire: true 32 | autoconfigure: true 33 | tags: [ 'validator.constraint_validator' ] 34 | 35 | PhpList\RestBundle\Subscription\Validator\Constraint\ListExistsValidator: 36 | autowire: true 37 | autoconfigure: true 38 | tags: [ 'validator.constraint_validator' ] 39 | 40 | PhpList\Core\Domain\Identity\Validator\AttributeTypeValidator: 41 | autowire: true 42 | autoconfigure: true 43 | 44 | -------------------------------------------------------------------------------- /src/Subscription/Validator/Constraint/UniqueEmailValidator.php: -------------------------------------------------------------------------------- 1 | entityManager 35 | ->getRepository($constraint->entityClass) 36 | ->findOneBy(['email' => $value]); 37 | 38 | $dto = $this->context->getObject(); 39 | $updatingId = $dto->subscriberId ?? null; 40 | 41 | if ($existingUser && $existingUser->getId() !== $updatingId) { 42 | throw new ConflictHttpException('Email already exists.'); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Messaging/Request/Message/MessageFormatRequest.php: -------------------------------------------------------------------------------- 1 | htmlFormated, 49 | sendFormat: $this->sendFormat, 50 | formatOptions: $this->formatOptions, 51 | ); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Identity/Validator/Constraint/UniqueLoginNameValidator.php: -------------------------------------------------------------------------------- 1 | administratorRepository = $administratorRepository; 21 | } 22 | 23 | public function validate($value, Constraint $constraint): void 24 | { 25 | if (!$constraint instanceof UniqueLoginName) { 26 | throw new UnexpectedTypeException($constraint, UniqueLoginName::class); 27 | } 28 | 29 | if (null === $value || '' === $value) { 30 | return; 31 | } 32 | 33 | if (!is_string($value)) { 34 | throw new UnexpectedValueException($value, 'string'); 35 | } 36 | 37 | $existingUser = $this->administratorRepository->findOneBy(['loginName' => $value]); 38 | 39 | $dto = $this->context->getObject(); 40 | $updatingId = $dto->administratorId ?? null; 41 | 42 | if ($existingUser && $existingUser->getId() !== $updatingId) { 43 | throw new ConflictHttpException('Login already exists.'); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Subscription/Serializer/SubscribePageNormalizer.php: -------------------------------------------------------------------------------- 1 | $object->getId(), 39 | 'title' => $object->getTitle(), 40 | 'active' => $object->isActive(), 41 | 'owner' => $this->adminNormalizer->normalize($object->getOwner()), 42 | ]; 43 | } 44 | 45 | /** 46 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) 47 | */ 48 | public function supportsNormalization($data, string $format = null): bool 49 | { 50 | return $data instanceof SubscribePage; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /config/services/managers.yml: -------------------------------------------------------------------------------- 1 | services: 2 | _defaults: 3 | autowire: true 4 | autoconfigure: true 5 | public: false 6 | 7 | PhpList\Core\Domain\Subscription\Service\Manager\SubscriberManager: 8 | autowire: true 9 | autoconfigure: true 10 | 11 | PhpList\Core\Domain\Identity\Service\SessionManager: 12 | autowire: true 13 | autoconfigure: true 14 | 15 | PhpList\Core\Domain\Subscription\Service\Manager\SubscriberListManager: 16 | autowire: true 17 | autoconfigure: true 18 | 19 | PhpList\Core\Domain\Subscription\Service\Manager\SubscriptionManager: 20 | autowire: true 21 | autoconfigure: true 22 | 23 | PhpList\Core\Domain\Messaging\Service\Manager\MessageManager: 24 | autowire: true 25 | autoconfigure: true 26 | 27 | PhpList\Core\Domain\Messaging\Service\Manager\TemplateManager: 28 | autowire: true 29 | autoconfigure: true 30 | 31 | PhpList\Core\Domain\Messaging\Service\Manager\TemplateImageManager: 32 | autowire: true 33 | autoconfigure: true 34 | 35 | PhpList\Core\Domain\Messaging\Service\Manager\BounceRegexManager: 36 | autowire: true 37 | autoconfigure: true 38 | 39 | PhpList\Core\Domain\Identity\Service\AdministratorManager: 40 | autowire: true 41 | autoconfigure: true 42 | 43 | PhpList\Core\Domain\Subscription\Service\SubscriberCsvExporter: 44 | autowire: true 45 | autoconfigure: true 46 | 47 | PhpList\Core\Domain\Analytics\Service\Manager\LinkTrackManager: 48 | autowire: true 49 | autoconfigure: true 50 | 51 | PhpList\Core\Domain\Analytics\Service\Manager\UserMessageViewManager: 52 | autowire: true 53 | autoconfigure: true 54 | 55 | PhpList\Core\Domain\Analytics\Service\AnalyticsService: 56 | autowire: true 57 | autoconfigure: true 58 | 59 | PhpList\Core\Domain\Identity\Service\PasswordManager: 60 | autowire: true 61 | autoconfigure: true 62 | -------------------------------------------------------------------------------- /src/Subscription/Serializer/SubscriberAttributeValueNormalizer.php: -------------------------------------------------------------------------------- 1 | $this->subscriberNormalizer->normalize($object->getSubscriber()), 38 | 'definition' => $this->definitionNormalizer->normalize($object->getAttributeDefinition()), 39 | 'value' => $object->getValue(), 40 | ]; 41 | } 42 | 43 | /** 44 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) 45 | */ 46 | public function supportsNormalization($data, string $format = null): bool 47 | { 48 | return $data instanceof SubscriberAttributeValue; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Identity/Serializer/AdminAttributeValueNormalizer.php: -------------------------------------------------------------------------------- 1 | $this->adminNormalizer->normalize($object->getAdministrator()), 39 | 'definition' => $this->definitionNormalizer->normalize($object->getAttributeDefinition()), 40 | 'value' => $object->getValue() ?? $object->getAttributeDefinition()->getDefaultValue(), 41 | ]; 42 | } 43 | 44 | /** 45 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) 46 | */ 47 | public function supportsNormalization($data, string $format = null): bool 48 | { 49 | return $data instanceof AdminAttributeValue; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Messaging/Request/CreateMessageRequest.php: -------------------------------------------------------------------------------- 1 | content->getDto(), 47 | format: $this->format->getDto(), 48 | metadata: $this->metadata->getDto(), 49 | options: $this->options->getDto(), 50 | schedule: $this->schedule->getDto(), 51 | templateId: $this->templateId, 52 | ); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Statistics/Serializer/TopDomainsNormalizer.php: -------------------------------------------------------------------------------- 1 | $domain['domain'] ?? '', 44 | 'subscribers' => $domain['subscribers'] ?? 0, 45 | ]; 46 | } 47 | 48 | return [ 49 | 'domains' => $domains, 50 | 'total' => $object['total'] ?? 0, 51 | ]; 52 | } 53 | 54 | /** 55 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) 56 | */ 57 | public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool 58 | { 59 | return is_array($data) && isset($context['top_domains']); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Identity/Serializer/AdminAttributeDefinitionNormalizer.php: -------------------------------------------------------------------------------- 1 | $object->getId(), 36 | 'name' => $object->getName(), 37 | 'type' => $object->getType(), 38 | 'list_order' => $object->getListOrder(), 39 | 'default_value' => $object->getDefaultValue(), 40 | 'required' => $object->isRequired(), 41 | ]; 42 | } 43 | 44 | /** 45 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) 46 | */ 47 | public function supportsNormalization($data, string $format = null): bool 48 | { 49 | return $data instanceof AdminAttributeDefinition; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Messaging/Request/CreateBounceRegexRequest.php: -------------------------------------------------------------------------------- 1 | $this->regex, 36 | 'action' => $this->action, 37 | 'listOrder' => $this->listOrder, 38 | 'admin' => $this->admin, 39 | 'comment' => $this->comment, 40 | 'status' => $this->status, 41 | ]; 42 | } 43 | 44 | #[Assert\Callback('validateRegexPattern')] 45 | public function validateRegexPattern(ExecutionContextInterface $context): void 46 | { 47 | if (!isset($this->regex)) { 48 | return; 49 | } 50 | set_error_handler(static function () { 51 | return true; 52 | }); 53 | // phpcs:ignore Generic.PHP.NoSilencedErrors 54 | $allGood = @preg_match($this->regex, ''); 55 | restore_error_handler(); 56 | 57 | if ($allGood === false) { 58 | $context->buildViolation('Invalid regular expression pattern.') 59 | ->atPath('regex') 60 | ->addViolation(); 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Messaging/Request/Message/MessageScheduleRequest.php: -------------------------------------------------------------------------------- 1 | embargo, 52 | repeatInterval: $this->repeatInterval, 53 | repeatUntil: $this->repeatUntil, 54 | requeueInterval: $this->requeueInterval, 55 | requeueUntil: $this->requeueUntil, 56 | ); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Subscription/Service/SubscriberHistoryService.php: -------------------------------------------------------------------------------- 1 | query->get('date_from'); 34 | $dateFromFormated = $dateFrom ? new DateTimeImmutable($dateFrom) : null; 35 | } catch (Exception $e) { 36 | throw new ValidatorException('Invalid date format. Use format: Y-m-d'); 37 | } 38 | 39 | $filter = new SubscriberHistoryFilter( 40 | subscriber: $subscriber, 41 | ip: $request->query->get('ip'), 42 | dateFrom: $dateFromFormated, 43 | summery: $request->query->get('summery'), 44 | ); 45 | 46 | return $this->paginatedDataProvider->getPaginatedList( 47 | request: $request, 48 | normalizer: $this->serializer, 49 | className: SubscriberHistory::class, 50 | filter: $filter 51 | ); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Subscription/Serializer/SubscriberHistoryNormalizer.php: -------------------------------------------------------------------------------- 1 | $object->getId(), 41 | 'ip' => $object->getIp(), 42 | 'created_at' => $object->getCreatedAt()->format('Y-m-d\TH:i:sP'), 43 | 'summary' => $object->getSummary(), 44 | 'detail' => $object->getDetail(), 45 | 'system_info' => $object->getSystemInfo(), 46 | ]; 47 | } 48 | 49 | /** 50 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) 51 | */ 52 | public function supportsNormalization($data, string $format = null): bool 53 | { 54 | return $data instanceof SubscriberHistory; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Statistics/Serializer/TopLocalPartsNormalizer.php: -------------------------------------------------------------------------------- 1 | $localPart['localPart'] ?? '', 45 | 'count' => $localPart['count'] ?? 0, 46 | 'percentage' => $localPart['percentage'] ?? 0.0, 47 | ]; 48 | } 49 | 50 | return [ 51 | 'local_parts' => $localParts, 52 | 'total' => $object['total'] ?? 0, 53 | ]; 54 | } 55 | 56 | /** 57 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) 58 | */ 59 | public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool 60 | { 61 | return is_array($data) && isset($context['top_local_parts']); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Common/Service/Provider/PaginatedDataProvider.php: -------------------------------------------------------------------------------- 1 | paginationFactory->fromRequest($request); 33 | 34 | $repository = $this->entityManager->getRepository($className); 35 | 36 | if (!$repository instanceof PaginatableRepositoryInterface) { 37 | throw new RuntimeException('Repository not found'); 38 | } 39 | 40 | $items = $repository->getFilteredAfterId( 41 | lastId: $pagination->afterId, 42 | limit: $pagination->limit, 43 | filter: $filter, 44 | ); 45 | $total = $repository->count(); 46 | 47 | $normalizedItems = array_map( 48 | fn($item) => $normalizer->normalize($item, 'json'), 49 | $items 50 | ); 51 | 52 | return $this->paginationNormalizer->normalize( 53 | new CursorPaginationResult($normalizedItems, $pagination->limit, $total) 54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Common/SwaggerSchemasResponse.php: -------------------------------------------------------------------------------- 1 | email, 53 | confirmed: $this->confirmed, 54 | blacklisted: $this->blacklisted, 55 | htmlEmail: $this->htmlEmail, 56 | disabled: $this->disabled, 57 | additionalData: $this->additionalData, 58 | ); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Subscription/Serializer/SubscriptionNormalizer.php: -------------------------------------------------------------------------------- 1 | subscriberNormalizer = $subscriberNormalizer; 35 | $this->subscriberListNormalizer = $subscriberListNormalizer; 36 | } 37 | 38 | /** 39 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) 40 | */ 41 | public function normalize($object, string $format = null, array $context = []): array 42 | { 43 | if (!$object instanceof Subscription) { 44 | return []; 45 | } 46 | 47 | return [ 48 | 'subscriber' => $this->subscriberNormalizer->normalize($object->getSubscriber()), 49 | 'subscriber_list' => $this->subscriberListNormalizer->normalize($object->getSubscriberList()), 50 | 'subscription_date' => $object->getCreatedAt()->format('Y-m-d\TH:i:sP'), 51 | ]; 52 | } 53 | 54 | /** 55 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) 56 | */ 57 | public function supportsNormalization($data, string $format = null): bool 58 | { 59 | return $data instanceof Subscription; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Identity/Serializer/AdministratorNormalizer.php: -------------------------------------------------------------------------------- 1 | $object->getId(), 44 | 'login_name' => $object->getLoginName(), 45 | 'email' => $object->getEmail(), 46 | 'super_user' => $object->isSuperUser(), 47 | 'privileges' => $object->getPrivileges()->all(), 48 | 'created_at' => $object->getCreatedAt()?->format(DateTimeInterface::ATOM), 49 | ]; 50 | } 51 | 52 | /** 53 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) 54 | */ 55 | public function supportsNormalization($data, string $format = null): bool 56 | { 57 | return $data instanceof Administrator; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Messaging/Serializer/TemplateImageNormalizer.php: -------------------------------------------------------------------------------- 1 | $object->getId(), 45 | 'template_id' => $object->getTemplate()?->getId(), 46 | 'mimetype' => $object->getMimeType(), 47 | 'filename' => $object->getFilename(), 48 | 'data' => base64_encode($object->getData() ?? ''), 49 | 'width' => $object->getWidth(), 50 | 'height' => $object->getHeight(), 51 | ]; 52 | } 53 | 54 | /** 55 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) 56 | */ 57 | public function supportsNormalization($data, string $format = null): bool 58 | { 59 | return $data instanceof TemplateImage; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Subscription/Serializer/SubscriberListNormalizer.php: -------------------------------------------------------------------------------- 1 | $object->getId(), 43 | 'name' => $object->getName(), 44 | 'created_at' => $object->getCreatedAt()->format('Y-m-d\TH:i:sP'), 45 | 'description' => $object->getDescription(), 46 | 'list_position' => $object->getListPosition(), 47 | 'subject_prefix' => $object->getSubjectPrefix(), 48 | 'public' => $object->isPublic(), 49 | 'category' => $object->getCategory(), 50 | ]; 51 | } 52 | 53 | /** 54 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) 55 | */ 56 | public function supportsNormalization($data, string $format = null): bool 57 | { 58 | return $data instanceof SubscriberList; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Messaging/Serializer/TemplateNormalizer.php: -------------------------------------------------------------------------------- 1 | $object->getId(), 47 | 'title' => $object->getTitle(), 48 | 'content' => $object->getContent(), 49 | 'text' => $object->getText(), 50 | 'order' => $object->getListOrder(), 51 | 'images' => $object->getImages()->toArray() ? array_map(function (TemplateImage $image) { 52 | return $this->templateImageNormalizer->normalize($image); 53 | }, $object->getImages()->toArray()) : null 54 | ]; 55 | } 56 | 57 | /** 58 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) 59 | */ 60 | public function supportsNormalization($data, string $format = null): bool 61 | { 62 | return $data instanceof Template; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Messaging/Serializer/BounceRegexNormalizer.php: -------------------------------------------------------------------------------- 1 | $object->getId(), 39 | 'regex' => $object->getRegex(), 40 | 'regex_hash' => $object->getRegexHash(), 41 | 'action' => $object->getAction(), 42 | 'list_order' => $object->getListOrder(), 43 | 'admin_id' => $object->getAdminId(), 44 | 'comment' => $object->getComment(), 45 | 'status' => $object->getStatus(), 46 | 'count' => $object->getCount(), 47 | ]; 48 | } 49 | 50 | /** 51 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) 52 | */ 53 | public function supportsNormalization($data, string $format = null): bool 54 | { 55 | return $data instanceof BounceRegex; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Messaging/Serializer/ListMessageNormalizer.php: -------------------------------------------------------------------------------- 1 | $object->getId(), 52 | 'message' => $this->messageNormalizer->normalize($object->getMessage()), 53 | 'subscriber_list' => $this->subscriberListNormalizer->normalize($object->getList()), 54 | 'created_at' => $object->getEntered()->format('Y-m-d\TH:i:sP'), 55 | 'updated_at' => $object->getUpdatedAt()->format('Y-m-d\TH:i:sP'), 56 | ]; 57 | } 58 | 59 | /** 60 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) 61 | */ 62 | public function supportsNormalization($data, string $format = null): bool 63 | { 64 | return $data instanceof ListMessage; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Subscription/Serializer/SubscriberOnlyNormalizer.php: -------------------------------------------------------------------------------- 1 | $object->getId(), 44 | 'email' => $object->getEmail(), 45 | 'created_at' => $object->getCreatedAt()->format('Y-m-d\TH:i:sP'), 46 | 'confirmed' => $object->isConfirmed(), 47 | 'blacklisted' => $object->isBlacklisted(), 48 | 'bounce_count' => $object->getBounceCount(), 49 | 'unique_id' => $object->getUniqueId(), 50 | 'html_email' => $object->hasHtmlEmail(), 51 | 'disabled' => $object->isDisabled(), 52 | ]; 53 | } 54 | 55 | /** 56 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) 57 | */ 58 | public function supportsNormalization($data, string $format = null): bool 59 | { 60 | return $data instanceof Subscriber; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Common/Validator/RequestValidator.php: -------------------------------------------------------------------------------- 1 | getContent(), true, 512, JSON_THROW_ON_ERROR); 27 | } catch (Throwable $e) { 28 | throw new BadRequestHttpException('Invalid JSON: ' . $e->getMessage()); 29 | } 30 | $routeParams = $request->attributes->get('_route_params') ?? []; 31 | 32 | if (isset($routeParams['listId'])) { 33 | $routeParams['listId'] = (int) $routeParams['listId']; 34 | } 35 | 36 | $data = array_merge($routeParams, $body ?? []); 37 | 38 | try { 39 | /** @var RequestInterface $dto */ 40 | $dto = $this->serializer->denormalize( 41 | $data, 42 | $dtoClass, 43 | null, 44 | ['allow_extra_attributes' => true] 45 | ); 46 | } catch (Throwable $e) { 47 | throw new BadRequestHttpException('Invalid request data: ' . $e->getMessage()); 48 | } 49 | 50 | return $this->validateDto($dto); 51 | } 52 | 53 | public function validateDto(RequestInterface $request): RequestInterface 54 | { 55 | $errors = $this->validator->validate($request); 56 | 57 | if (count($errors) > 0) { 58 | $lines = []; 59 | foreach ($errors as $violation) { 60 | $lines[] = sprintf( 61 | '%s: %s', 62 | $violation->getPropertyPath(), 63 | $violation->getMessage() 64 | ); 65 | } 66 | 67 | $message = implode("\n", $lines); 68 | 69 | throw new UnprocessableEntityHttpException($message); 70 | } 71 | 72 | return $request; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Statistics/Serializer/ViewOpensStatisticsNormalizer.php: -------------------------------------------------------------------------------- 1 | normalizeCampaign($item); 37 | } 38 | 39 | return [ 40 | 'items' => $items, 41 | 'pagination' => $this->normalizePagination($object, $context), 42 | ]; 43 | } 44 | 45 | private function normalizeCampaign(array $item): array 46 | { 47 | return [ 48 | 'campaign_id' => $item['campaignId'] ?? 0, 49 | 'subject' => $item['subject'] ?? '', 50 | 'sent' => $item['sent'] ?? 0, 51 | 'unique_views' => $item['uniqueViews'] ?? 0, 52 | 'rate' => $item['rate'] ?? 0.0, 53 | ]; 54 | } 55 | 56 | private function normalizePagination(array $object, array $context): array 57 | { 58 | return [ 59 | 'total' => $object['total'] ?? 0, 60 | 'limit' => $context['limit'] ?? AnalyticsController::BATCH_SIZE, 61 | 'has_more' => $object['hasMore'] ?? false, 62 | 'next_cursor' => $object['lastId'] ? $object['lastId'] + 1 : 0, 63 | ]; 64 | } 65 | 66 | /** 67 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) 68 | */ 69 | public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool 70 | { 71 | return is_array($data) && isset($data['items']) && isset($context['view_opens_statistics']); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Identity/Request/AdminAttributeDefinitionRequest.php: -------------------------------------------------------------------------------- 1 | name, 57 | type: $this->type, 58 | listOrder: $this->order, 59 | defaultValue: $this->defaultValue, 60 | required: $this->required, 61 | ); 62 | } 63 | 64 | public function validateType(ExecutionContextInterface $context): void 65 | { 66 | if ($this->type === null) { 67 | return; 68 | } 69 | 70 | $validator = new AttributeTypeValidator(new IdentityTranslator()); 71 | 72 | try { 73 | $validator->validate($this->type); 74 | } catch (ValidatorException $e) { 75 | $context->buildViolation($e->getMessage()) 76 | ->atPath('type') 77 | ->addViolation(); 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # phpList 4 REST API 2 | 3 | [![Build Status](https://github.com/phpList/rest-api/workflows/phpList%20REST%20API%20Build/badge.svg)](https://github.com/phpList/rest-api/actions) 4 | [![Latest Stable Version](https://poser.pugx.org/phplist/rest-api/v/stable.svg)](https://packagist.org/packages/phpList/rest-api) 5 | [![Total Downloads](https://poser.pugx.org/phplist/rest-api/downloads.svg)](https://packagist.org/packages/phpList/rest-api) 6 | [![Latest Unstable Version](https://poser.pugx.org/phplist/rest-api/v/unstable.svg)](https://packagist.org/packages/phpList/rest-api) 7 | [![License](https://poser.pugx.org/phplist/rest-api/license.svg)](https://packagist.org/packages/phpList/rest-api) 8 | 9 | 10 | ## About phpList 11 | 12 | phpList is an open source newsletter manager. 13 | 14 | 15 | ## About this package 16 | 17 | This module is the REST API for phpList 4, providing functions for superusers 18 | to manage lists, subscribers and subscriptions via REST calls. It uses 19 | functionality from the `phplist/core` module (the phpList 4 core). 20 | It does not contain any SQL queries, uses functionality from the new core for 21 | DB access. 22 | 23 | This module is optional, i.e., it is possible to run phpList 4 without the 24 | REST API. 25 | 26 | This new REST API can also be used to provide REST access to an existing 27 | phpList 3 installation. For this, the phpList 3 installation and the phpList 4 28 | installation with the REST API need to share the same database. For security 29 | reasons, the REST APIs from phpList 3 and phpList 4 should not be used for the 30 | same database in parallel, though. 31 | 32 | 33 | ## Installation 34 | 35 | Please install this package via Composer from within the 36 | [phpList base distribution](https://github.com/phpList/base-distribution), 37 | which also has more detailed installation instructions in the README. 38 | 39 | ## API Documentation 40 | 41 | Visit `https://phplist.github.io/restapi-docs/` endpoint to access the full interactive documentation for `phpList/rest-api`. 42 | 43 | Look at the **"API Documentation with Swagger"** section in the [contribution guide](.github/CONTRIBUTING.md) for more information on API documenation. 44 | 45 | ## Local demo with Postman 46 | 47 | You can try out the API using pre-prepared requests and the Postman GUI 48 | tool. Install Postman as a browser extension or stand-alone app, open the 49 | [phpList 4 REST API Demo collection](https://documenter.getpostman.com/view/3293511/phplist-4-rest-api-demo/RVftkC9t#4710e871-973d-46fa-94b7-727fdc292cd5) 50 | and click "Run in Postman". 51 | 52 | 53 | ## Contributing to this package 54 | 55 | Please read the [contribution guide](.github/CONTRIBUTING.md) on how to 56 | contribute and how to run the unit tests and style checks locally. 57 | 58 | ### Code of Conduct 59 | 60 | This project adheres to a [Contributor Code of Conduct](CODE_OF_CONDUCT.md). 61 | By participating in this project and its community, you are expected to uphold 62 | this code. 63 | -------------------------------------------------------------------------------- /src/Messaging/Request/CreateTemplateRequest.php: -------------------------------------------------------------------------------- 1 | [CONTENT]', 23 | nullable: true 24 | ), 25 | new OA\Property(property: 'text', type: 'string', example: '[CONTENT]'), 26 | new OA\Property( 27 | property: 'file', 28 | description: 'Optional file upload for HTML content', 29 | type: 'string', 30 | format: 'binary' 31 | ), 32 | new OA\Property( 33 | property: 'check_links', 34 | description: 'Check that all links have full URLs', 35 | type: 'boolean', 36 | example: true 37 | ), 38 | new OA\Property( 39 | property: 'check_images', 40 | description: 'Check that all images have full URLs', 41 | type: 'boolean', 42 | example: false 43 | ), 44 | new OA\Property( 45 | property: 'check_external_images', 46 | description: 'Check that all external images exist', 47 | type: 'boolean', 48 | example: true 49 | ), 50 | ], 51 | type: 'object' 52 | )] 53 | class CreateTemplateRequest implements RequestInterface 54 | { 55 | #[Assert\NotBlank(normalizer: 'trim')] 56 | #[Assert\NotNull] 57 | public string $title; 58 | 59 | #[ContainsPlaceholder] 60 | public ?string $content = null; 61 | 62 | #[ContainsPlaceholder] 63 | public ?string $text = null; 64 | 65 | public ?UploadedFile $file = null; 66 | public bool $checkLinks = false; 67 | public bool $checkImages = false; 68 | public bool $checkExternalImages = false; 69 | 70 | public function getDto(): CreateTemplateDto 71 | { 72 | return new CreateTemplateDto( 73 | title: $this->title, 74 | content: $this->content, 75 | text: $this->text, 76 | fileContent: $this->file instanceof UploadedFile ? file_get_contents($this->file->getPathname()) : null, 77 | shouldCheckLinks: $this->checkLinks, 78 | shouldCheckImages: $this->checkImages, 79 | shouldCheckExternalImages: $this->checkExternalImages, 80 | ); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Subscription/Serializer/AttributeDefinitionNormalizer.php: -------------------------------------------------------------------------------- 1 | getOptions(); 50 | if (!empty($options)) { 51 | $options = array_map(function ($option) { 52 | return [ 53 | 'id' => $option->id, 54 | 'name' => $option->name, 55 | 'list_order' => $option->listOrder, 56 | ]; 57 | }, $options); 58 | } 59 | 60 | return [ 61 | 'id' => $object->getId(), 62 | 'name' => $object->getName(), 63 | 'type' => $object->getType() ? $object->getType()->value : null, 64 | 'list_order' => $object->getListOrder(), 65 | 'default_value' => $object->getDefaultValue(), 66 | 'required' => $object->isRequired(), 67 | 'options' => $options, 68 | ]; 69 | } 70 | 71 | /** 72 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) 73 | */ 74 | public function supportsNormalization($data, string $format = null): bool 75 | { 76 | return $data instanceof SubscriberAttributeDefinition; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Common/EventListener/ExceptionListener.php: -------------------------------------------------------------------------------- 1 | getThrowable(); 23 | 24 | if ($exception instanceof AccessDeniedHttpException) { 25 | $response = new JsonResponse([ 26 | 'message' => $exception->getMessage(), 27 | ], 403); 28 | 29 | $event->setResponse($response); 30 | } elseif ($exception instanceof HttpExceptionInterface) { 31 | $response = new JsonResponse([ 32 | 'message' => $exception->getMessage(), 33 | ], $exception->getStatusCode()); 34 | 35 | $event->setResponse($response); 36 | } elseif ($exception instanceof SubscriptionCreationException) { 37 | $response = new JsonResponse([ 38 | 'message' => $exception->getMessage(), 39 | ], $exception->getStatusCode()); 40 | $event->setResponse($response); 41 | } elseif ($exception instanceof AdminAttributeCreationException) { 42 | $response = new JsonResponse([ 43 | 'message' => $exception->getMessage(), 44 | ], $exception->getStatusCode()); 45 | $event->setResponse($response); 46 | } elseif ($exception instanceof AttributeDefinitionCreationException) { 47 | $response = new JsonResponse([ 48 | 'message' => $exception->getMessage(), 49 | ], $exception->getStatusCode()); 50 | $event->setResponse($response); 51 | } elseif ($exception instanceof ValidatorException) { 52 | $response = new JsonResponse([ 53 | 'message' => $exception->getMessage(), 54 | ], 400); 55 | $event->setResponse($response); 56 | } elseif ($exception instanceof AccessDeniedException) { 57 | $response = new JsonResponse([ 58 | 'message' => $exception->getMessage(), 59 | ], 403); 60 | $event->setResponse($response); 61 | } elseif ($exception instanceof Exception) { 62 | $response = new JsonResponse([ 63 | 'message' => $exception->getMessage(), 64 | ], 500); 65 | 66 | $event->setResponse($response); 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Subscription/Controller/SubscriberExportController.php: -------------------------------------------------------------------------------- 1 | exportManager = $exportManager; 29 | } 30 | 31 | #[Route('/export', name: 'csv', methods: ['POST'])] 32 | #[OA\Post( 33 | path: '/api/v2/subscribers/export', 34 | description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . 35 | 'Export subscribers to CSV file.', 36 | summary: 'Export subscribers', 37 | requestBody: new OA\RequestBody( 38 | description: 'Filter parameters for subscribers to export. ', 39 | required: true, 40 | content: new OA\JsonContent(ref: '#/components/schemas/ExportSubscriberRequest') 41 | ), 42 | tags: ['subscribers'], 43 | parameters: [ 44 | new OA\Parameter( 45 | name: 'php-auth-pw', 46 | description: 'Session key obtained from login', 47 | in: 'header', 48 | required: true, 49 | schema: new OA\Schema(type: 'string') 50 | ), 51 | ], 52 | responses: [ 53 | new OA\Response( 54 | response: 200, 55 | description: 'Success', 56 | content: new OA\MediaType( 57 | mediaType: 'text/csv', 58 | schema: new OA\Schema(type: 'string', format: 'binary') 59 | ) 60 | ), 61 | new OA\Response( 62 | response: 403, 63 | description: 'Failure', 64 | content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') 65 | ) 66 | ] 67 | )] 68 | public function exportSubscribers(Request $request): Response 69 | { 70 | $this->requireAuthentication($request); 71 | 72 | /** @var SubscribersExportRequest $exportRequest */ 73 | $exportRequest = $this->validator->validate($request, SubscribersExportRequest::class); 74 | 75 | return $this->exportManager->exportToCsv($exportRequest->getDto()); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Subscription/Serializer/SubscriberNormalizer.php: -------------------------------------------------------------------------------- 1 | $object->getId(), 54 | 'email' => $object->getEmail(), 55 | 'created_at' => $object->getCreatedAt()->format('Y-m-d\TH:i:sP'), 56 | 'confirmed' => $object->isConfirmed(), 57 | 'blacklisted' => $object->isBlacklisted(), 58 | 'bounce_count' => $object->getBounceCount(), 59 | 'unique_id' => $object->getUniqueId(), 60 | 'html_email' => $object->hasHtmlEmail(), 61 | 'disabled' => $object->isDisabled(), 62 | 'subscribed_lists' => array_map(function (Subscription $subscription) { 63 | return $this->subscriberListNormalizer->normalize($subscription->getSubscriberList()); 64 | }, $object->getSubscriptions()->toArray()), 65 | ]; 66 | } 67 | 68 | /** 69 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) 70 | */ 71 | public function supportsNormalization($data, string $format = null): bool 72 | { 73 | return $data instanceof Subscriber; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/PhpListRestBundle.php: -------------------------------------------------------------------------------- 1 | 14 | * @author Tatevik Grigoryan 15 | */ 16 | #[OA\Info( 17 | version: '1.0.0', 18 | description: 'This is the OpenAPI documentation for phpList API.', 19 | title: 'phpList API Documentation', 20 | contact: new OA\Contact( 21 | email: 'support@phplist.com' 22 | ), 23 | license: new OA\License( 24 | name: 'AGPL-3.0-or-later', 25 | url: 'https://www.gnu.org/licenses/agpl.txt' 26 | ) 27 | )] 28 | #[OA\Server( 29 | url: 'https://www.phplist.com/api/v2', 30 | description: 'Production server' 31 | )] 32 | #[OA\Schema( 33 | schema: 'DetailedDomainStats', 34 | properties: [ 35 | new OA\Property( 36 | property: 'domains', 37 | type: 'array', 38 | items: new OA\Items( 39 | properties: [ 40 | new OA\Property(property: 'domain', type: 'string'), 41 | new OA\Property( 42 | property: 'confirmed', 43 | properties: [ 44 | new OA\Property(property: 'count', type: 'integer'), 45 | new OA\Property(property: 'percentage', type: 'number', format: 'float'), 46 | ], 47 | type: 'object' 48 | ), 49 | new OA\Property( 50 | property: 'unconfirmed', 51 | properties: [ 52 | new OA\Property(property: 'count', type: 'integer'), 53 | new OA\Property(property: 'percentage', type: 'number', format: 'float'), 54 | ], 55 | type: 'object' 56 | ), 57 | new OA\Property( 58 | property: 'blacklisted', 59 | properties: [ 60 | new OA\Property(property: 'count', type: 'integer'), 61 | new OA\Property(property: 'percentage', type: 'number', format: 'float'), 62 | ], 63 | type: 'object' 64 | ), 65 | new OA\Property( 66 | property: 'total', 67 | properties: [ 68 | new OA\Property(property: 'count', type: 'integer'), 69 | new OA\Property(property: 'percentage', type: 'number', format: 'float'), 70 | ], 71 | type: 'object' 72 | ), 73 | ], 74 | type: 'object' 75 | ) 76 | ), 77 | new OA\Property(property: 'total', type: 'integer'), 78 | ], 79 | type: 'object', 80 | nullable: true 81 | )] 82 | class PhpListRestBundle extends Bundle 83 | { 84 | } 85 | -------------------------------------------------------------------------------- /src/Statistics/Serializer/CampaignStatisticsNormalizer.php: -------------------------------------------------------------------------------- 1 | normalizeCampaign($item); 41 | } 42 | return [ 43 | 'items' => $items, 44 | 'pagination' => $this->normalizePagination($object, $context), 45 | ]; 46 | } 47 | 48 | private function normalizeCampaign(array $campaign): array 49 | { 50 | return [ 51 | 'campaign_id' => $campaign['campaignId'] ?? 0, 52 | 'subject' => $campaign['subject'] ?? '', 53 | 'sent' => $campaign['sent'] ?? 0, 54 | 'bounces' => $campaign['bounces'] ?? 0, 55 | 'forwards' => $campaign['forwards'] ?? 0, 56 | 'unique_views' => $campaign['uniqueViews'] ?? 0, 57 | 'total_clicks' => $campaign['totalClicks'] ?? 0, 58 | 'unique_clicks' => $campaign['uniqueClicks'] ?? 0, 59 | 'date_sent' => $campaign['dateSent'] ?? null, 60 | ]; 61 | } 62 | 63 | private function normalizePagination(array $object, array $context): array 64 | { 65 | return [ 66 | 'total' => $object['total'] ?? 0, 67 | 'limit' => $context['limit'] ?? AnalyticsController::BATCH_SIZE, 68 | 'has_more' => $object['hasMore'] ?? false, 69 | 'next_cursor' => $object['lastId'] ? $object['lastId'] + 1 : 0, 70 | ]; 71 | } 72 | 73 | /** 74 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) 75 | */ 76 | public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool 77 | { 78 | return is_array($data) && isset($data['campaign_statistics']); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, 8 | body size, disability, ethnicity, gender identity and expression, level of 9 | experience, nationality, personal appearance, race, religion, or sexual 10 | identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | 35 | ## Our Responsibilities 36 | 37 | Project maintainers are responsible for clarifying the standards of acceptable 38 | behavior and are expected to take appropriate and fair corrective action in 39 | response to any instances of unacceptable behavior. 40 | 41 | Project maintainers have the right and responsibility to remove, edit, or 42 | reject comments, commits, code, wiki edits, issues, and other contributions 43 | that are not aligned to this Code of Conduct, or to ban temporarily or 44 | permanently any contributor for other behaviors that they deem inappropriate, 45 | threatening, offensive, or harmful. 46 | 47 | ## Scope 48 | 49 | This Code of Conduct applies both within project spaces and in public spaces 50 | when an individual is representing the project or its community. Examples of 51 | representing a project or community include using an official project e-mail 52 | address, posting via an official social media account, or acting as an 53 | appointed representative at an online or offline event. Representation of a 54 | project may be further defined and clarified by project maintainers. 55 | 56 | ## Enforcement 57 | 58 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 59 | reported by contacting the project team at (abuse at phplist dot com). 60 | All complaints will be reviewed and investigated and will result in a response 61 | that is deemed necessary and appropriate to the circumstances. The project team 62 | is obligated to maintain confidentiality with regard to the reporter of an 63 | incident. Further details of specific enforcement policies may be posted 64 | separately. 65 | 66 | Project maintainers who do not follow or enforce the Code of Conduct in good 67 | faith may face temporary or permanent repercussions as determined by other 68 | members of the project's leadership. 69 | 70 | ## Attribution 71 | 72 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 73 | version 1.4, available at 74 | [http://contributor-covenant.org/version/1/4/][version]. 75 | 76 | [homepage]: http://contributor-covenant.org 77 | [version]: http://contributor-covenant.org/version/1/4/ 78 | -------------------------------------------------------------------------------- /src/Identity/Request/UpdateAdministratorRequest.php: -------------------------------------------------------------------------------- 1 | true, 'campaigns' => false, 'statistics' => true, 'settings' => false] 55 | ), 56 | ], 57 | type: 'object' 58 | )] 59 | class UpdateAdministratorRequest implements RequestInterface 60 | { 61 | #[Assert\Length(min: 3, max: 255)] 62 | #[UniqueLoginName] 63 | public ?string $loginName = null; 64 | 65 | #[Assert\Length(min: 6, max: 255)] 66 | public ?string $password = null; 67 | 68 | #[Assert\Email] 69 | #[UniqueEmail(Administrator::class)] 70 | public ?string $email = null; 71 | 72 | #[Assert\Type('bool')] 73 | public ?bool $superUser = null; 74 | 75 | /** 76 | * Array of privileges where keys are privilege names (from PrivilegeFlag enum) and values are booleans. 77 | * Example: ['subscribers' => true, 'campaigns' => false, 'statistics' => true, 'settings' => false] 78 | */ 79 | #[Assert\Type('array')] 80 | #[Assert\All([ 81 | 'constraints' => [ 82 | new Assert\Type(['type' => 'bool']), 83 | ], 84 | ])] 85 | public array $privileges = []; 86 | 87 | public function getDto(): UpdateAdministratorDto 88 | { 89 | return new UpdateAdministratorDto( 90 | loginName: $this->loginName, 91 | password: $this->password, 92 | email: $this->email, 93 | superAdmin: $this->superUser, 94 | privileges: $this->privileges 95 | ); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/Messaging/Service/CampaignService.php: -------------------------------------------------------------------------------- 1 | setOwner($administrator); 34 | 35 | return $this->paginatedProvider->getPaginatedList($request, $this->normalizer, Message::class, $filter); 36 | } 37 | 38 | public function getMessage(Message $message = null): array 39 | { 40 | if (!$message) { 41 | throw new NotFoundHttpException('Campaign not found.'); 42 | } 43 | 44 | return $this->normalizer->normalize($message); 45 | } 46 | 47 | public function createMessage(CreateMessageRequest $createMessageRequest, Administrator $administrator): array 48 | { 49 | if (!$administrator->getPrivileges()->has(PrivilegeFlag::Campaigns)) { 50 | throw new AccessDeniedHttpException('You are not allowed to create campaigns.'); 51 | } 52 | 53 | $data = $this->messageManager->createMessage($createMessageRequest->getDto(), $administrator); 54 | 55 | return $this->normalizer->normalize($data); 56 | } 57 | 58 | public function updateMessage( 59 | UpdateMessageRequest $updateMessageRequest, 60 | Administrator $administrator, 61 | Message $message = null 62 | ): array { 63 | if (!$administrator->getPrivileges()->has(PrivilegeFlag::Campaigns)) { 64 | throw new AccessDeniedHttpException('You are not allowed to update campaigns.'); 65 | } 66 | 67 | if (!$message) { 68 | throw new NotFoundHttpException('Campaign not found.'); 69 | } 70 | 71 | $data = $this->messageManager->updateMessage( 72 | $updateMessageRequest->getDto(), 73 | $message, 74 | $administrator 75 | ); 76 | 77 | return $this->normalizer->normalize($data); 78 | } 79 | 80 | public function deleteMessage(Administrator $administrator, Message $message = null): void 81 | { 82 | if (!$administrator->getPrivileges()->has(PrivilegeFlag::Campaigns)) { 83 | throw new AccessDeniedHttpException('You are not allowed to delete campaigns.'); 84 | } 85 | 86 | if (!$message) { 87 | throw new NotFoundHttpException('Campaign not found.'); 88 | } 89 | 90 | $this->messageManager->delete($message); 91 | $this->entityManager->flush(); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/Identity/Request/CreateAdministratorRequest.php: -------------------------------------------------------------------------------- 1 | true, 'campaigns' => false, 'statistics' => true, 'settings' => false] 56 | ), 57 | ], 58 | type: 'object' 59 | )] 60 | class CreateAdministratorRequest implements RequestInterface 61 | { 62 | #[Assert\NotBlank] 63 | #[Assert\Length(min: 3, max: 255)] 64 | #[UniqueLoginName] 65 | public string $loginName; 66 | 67 | #[Assert\NotBlank] 68 | #[Assert\Length(min: 6, max: 255)] 69 | public string $password; 70 | 71 | #[Assert\NotBlank] 72 | #[Assert\Email] 73 | #[UniqueEmail(Administrator::class)] 74 | public string $email; 75 | 76 | #[Assert\NotNull] 77 | #[Assert\Type('bool')] 78 | public bool $superUser = false; 79 | 80 | /** 81 | * Array of privileges where keys are privilege names (from PrivilegeFlag enum) and values are booleans. 82 | * Example: ['subscribers' => true, 'campaigns' => false, 'statistics' => true, 'settings' => false] 83 | */ 84 | #[Assert\Type('array')] 85 | #[Assert\All([ 86 | 'constraints' => [ 87 | new Assert\Type(['type' => 'bool']), 88 | ], 89 | ])] 90 | public array $privileges = []; 91 | 92 | public function getDto(): CreateAdministratorDto 93 | { 94 | return new CreateAdministratorDto( 95 | loginName: $this->loginName, 96 | password: $this->password, 97 | email: $this->email, 98 | isSuperUser: $this->superUser, 99 | privileges: $this->privileges 100 | ); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /config/services/normalizers.yml: -------------------------------------------------------------------------------- 1 | services: 2 | _defaults: 3 | autowire: true 4 | autoconfigure: true 5 | public: false 6 | 7 | Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter: ~ 8 | 9 | Symfony\Component\Serializer\Normalizer\ObjectNormalizer: 10 | arguments: 11 | $classMetadataFactory: '@?serializer.mapping.class_metadata_factory' 12 | $nameConverter: '@Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter' 13 | 14 | PhpList\RestBundle\Subscription\Serializer\SubscriberNormalizer: 15 | tags: [ 'serializer.normalizer' ] 16 | autowire: true 17 | 18 | PhpList\RestBundle\Subscription\Serializer\SubscriberOnlyNormalizer: 19 | tags: [ 'serializer.normalizer' ] 20 | autowire: true 21 | 22 | PhpList\RestBundle\Identity\Serializer\AdministratorTokenNormalizer: 23 | tags: [ 'serializer.normalizer' ] 24 | autowire: true 25 | 26 | PhpList\RestBundle\Subscription\Serializer\SubscriberListNormalizer: 27 | tags: [ 'serializer.normalizer' ] 28 | autowire: true 29 | 30 | PhpList\RestBundle\Subscription\Serializer\SubscriberHistoryNormalizer: 31 | tags: [ 'serializer.normalizer' ] 32 | autowire: true 33 | 34 | PhpList\RestBundle\Subscription\Serializer\SubscriptionNormalizer: 35 | tags: [ 'serializer.normalizer' ] 36 | autowire: true 37 | 38 | PhpList\RestBundle\Messaging\Serializer\MessageNormalizer: 39 | tags: [ 'serializer.normalizer' ] 40 | autowire: true 41 | 42 | PhpList\RestBundle\Messaging\Serializer\TemplateImageNormalizer: 43 | tags: [ 'serializer.normalizer' ] 44 | autowire: true 45 | 46 | PhpList\RestBundle\Messaging\Serializer\TemplateNormalizer: 47 | tags: [ 'serializer.normalizer' ] 48 | autowire: true 49 | 50 | PhpList\RestBundle\Messaging\Serializer\ListMessageNormalizer: 51 | tags: [ 'serializer.normalizer' ] 52 | autowire: true 53 | 54 | PhpList\RestBundle\Identity\Serializer\AdministratorNormalizer: 55 | tags: [ 'serializer.normalizer' ] 56 | autowire: true 57 | 58 | PhpList\RestBundle\Identity\Serializer\AdminAttributeDefinitionNormalizer: 59 | tags: [ 'serializer.normalizer' ] 60 | autowire: true 61 | 62 | PhpList\RestBundle\Identity\Serializer\AdminAttributeValueNormalizer: 63 | tags: [ 'serializer.normalizer' ] 64 | autowire: true 65 | 66 | PhpList\RestBundle\Subscription\Serializer\AttributeDefinitionNormalizer: 67 | tags: [ 'serializer.normalizer' ] 68 | autowire: true 69 | 70 | PhpList\RestBundle\Subscription\Serializer\SubscriberAttributeValueNormalizer: 71 | tags: [ 'serializer.normalizer' ] 72 | autowire: true 73 | 74 | PhpList\RestBundle\Common\Serializer\CursorPaginationNormalizer: 75 | autowire: true 76 | 77 | PhpList\RestBundle\Subscription\Serializer\SubscribersExportRequestNormalizer: 78 | tags: [ 'serializer.normalizer' ] 79 | autowire: true 80 | 81 | PhpList\RestBundle\Statistics\Serializer\CampaignStatisticsNormalizer: 82 | tags: [ 'serializer.normalizer' ] 83 | autowire: true 84 | 85 | PhpList\RestBundle\Statistics\Serializer\ViewOpensStatisticsNormalizer: 86 | tags: [ 'serializer.normalizer' ] 87 | autowire: true 88 | 89 | PhpList\RestBundle\Statistics\Serializer\TopDomainsNormalizer: 90 | tags: [ 'serializer.normalizer' ] 91 | autowire: true 92 | 93 | PhpList\RestBundle\Statistics\Serializer\TopLocalPartsNormalizer: 94 | tags: [ 'serializer.normalizer' ] 95 | autowire: true 96 | 97 | PhpList\RestBundle\Subscription\Serializer\UserBlacklistNormalizer: 98 | tags: [ 'serializer.normalizer' ] 99 | autowire: true 100 | 101 | PhpList\RestBundle\Subscription\Serializer\SubscribePageNormalizer: 102 | tags: [ 'serializer.normalizer' ] 103 | autowire: true 104 | 105 | PhpList\RestBundle\Messaging\Serializer\BounceRegexNormalizer: 106 | tags: [ 'serializer.normalizer' ] 107 | autowire: true 108 | -------------------------------------------------------------------------------- /src/Subscription/Request/SubscriberAttributeDefinitionRequest.php: -------------------------------------------------------------------------------- 1 | [ 78 | new Assert\Type(['type' => DynamicListAttrDto::class]), 79 | ], 80 | ])] 81 | public ?array $options = null; 82 | 83 | public function getDto(): AttributeDefinitionDto 84 | { 85 | $type = null; 86 | if ($this->type !== null) { 87 | $type = AttributeTypeEnum::tryFrom($this->type); 88 | } 89 | return new AttributeDefinitionDto( 90 | name: $this->name, 91 | type: $type, 92 | listOrder: $this->order, 93 | defaultValue: $this->defaultValue, 94 | required: $this->required, 95 | options: $this->options ?? [], 96 | ); 97 | } 98 | 99 | public function validateType(ExecutionContextInterface $context): void 100 | { 101 | if ($this->type === null) { 102 | return; 103 | } 104 | 105 | $validator = new AttributeTypeValidator(new IdentityTranslator()); 106 | 107 | try { 108 | $validator->validate($this->type); 109 | } catch (ValidatorException $e) { 110 | $context->buildViolation($e->getMessage()) 111 | ->atPath('type') 112 | ->addViolation(); 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/Subscription/Request/SubscribersExportRequest.php: -------------------------------------------------------------------------------- 1 | dateFrom ? new DateTimeImmutable($this->dateFrom) : null; 105 | $dateTo = $this->dateTo ? new DateTimeImmutable($this->dateTo) : null; 106 | 107 | return match ($this->dateType) { 108 | 'subscribed' => [$dateFrom, $dateTo, null, null, null, null], 109 | 'signup' => [null, null, $dateFrom, $dateTo, null, null], 110 | 'changed' => [null, null, null, null, $dateFrom, $dateTo], 111 | 'any', 'changelog' => [null, null, null, null, null, null], 112 | default => [null, null, null, null, null, null], 113 | }; 114 | } 115 | 116 | public function getDto(): SubscriberFilter 117 | { 118 | [$subscribedFrom, $subscribedTo, $signupFrom, $signupTo, $changedFrom, $changedTo] = $this->resolveDates(); 119 | 120 | return new SubscriberFilter( 121 | listId: $this->listId ?? null, 122 | subscribedDateFrom: $subscribedFrom, 123 | subscribedDateTo: $subscribedTo, 124 | createdDateFrom: $signupFrom, 125 | createdDateTo: $signupTo, 126 | updatedDateFrom: $changedFrom, 127 | updatedDateTo: $changedTo, 128 | columns: $this->columns 129 | ); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "phplist/rest-api", 3 | "description": "A REST API for phpList, the world's most popular open source newsletter manager", 4 | "type": "phplist-module", 5 | "keywords": [ 6 | "phplist", 7 | "email", 8 | "newsletter", 9 | "manager", 10 | "rest", 11 | "api" 12 | ], 13 | "homepage": "https://www.phplist.com/", 14 | "license": "AGPL-3.0-or-later", 15 | "authors": [ 16 | { 17 | "name": "Xheni Myrtaj", 18 | "email": "xheni@phplist.com", 19 | "role": "Former developer" 20 | }, 21 | { 22 | "name": "Oliver Klee", 23 | "email": "oliver@phplist.com", 24 | "role": "Former developer" 25 | }, 26 | { 27 | "name": "Tatevik Grigoryan", 28 | "email": "tatevik@phplist.com", 29 | "role": "Maintainer" 30 | } 31 | ], 32 | "repositories": [ 33 | { 34 | "type": "vcs", 35 | "url": "https://github.com/TatevikGr/rss-bundle.git" 36 | } 37 | ], 38 | "support": { 39 | "issues": "https://github.com/phpList/rest-api/issues", 40 | "forum": "https://discuss.phplist.org/", 41 | "source": "https://github.com/phpList/rest-api" 42 | }, 43 | "require": { 44 | "php": "^8.1", 45 | "phplist/core": "dev-main", 46 | "friendsofsymfony/rest-bundle": "*", 47 | "symfony/test-pack": "^1.0", 48 | "symfony/process": "^6.4", 49 | "zircote/swagger-php": "^4.11", 50 | "ext-dom": "*", 51 | "tatevikgr/rss-feed": "dev-main as 0.1.0" 52 | }, 53 | "require-dev": { 54 | "phpunit/phpunit": "^10.0", 55 | "guzzlehttp/guzzle": "^6.3.0", 56 | "squizlabs/php_codesniffer": "^3.2.0", 57 | "phpstan/phpstan": "^1.10", 58 | "nette/caching": "^3.0.0", 59 | "nikic/php-parser": "^4.19.1", 60 | "phpmd/phpmd": "^2.6.0", 61 | "doctrine/instantiator": "^2.0." 62 | }, 63 | "autoload": { 64 | "psr-4": { 65 | "PhpList\\RestBundle\\": "src/" 66 | } 67 | }, 68 | "autoload-dev": { 69 | "psr-4": { 70 | "PhpList\\RestBundle\\Tests\\": "tests/" 71 | } 72 | }, 73 | "scripts": { 74 | "list-modules": [ 75 | "PhpList\\Core\\Composer\\ScriptHandler::listModules" 76 | ], 77 | "create-directories": [ 78 | "PhpList\\Core\\Composer\\ScriptHandler::createBinaries", 79 | "PhpList\\Core\\Composer\\ScriptHandler::createPublicWebDirectory" 80 | ], 81 | "update-configuration": [ 82 | "PhpList\\Core\\Composer\\ScriptHandler::createGeneralConfiguration", 83 | "PhpList\\Core\\Composer\\ScriptHandler::createBundleConfiguration", 84 | "PhpList\\Core\\Composer\\ScriptHandler::createRoutesConfiguration", 85 | "PhpList\\Core\\Composer\\ScriptHandler::createParametersConfiguration", 86 | "PhpList\\Core\\Composer\\ScriptHandler::clearAllCaches" 87 | ], 88 | "post-install-cmd": [ 89 | "@create-directories", 90 | "@update-configuration" 91 | ], 92 | "post-update-cmd": [ 93 | "@create-directories", 94 | "@update-configuration" 95 | ], 96 | "openapi-generate": [ 97 | "vendor/bin/openapi -o docs/openapi.json --format json src" 98 | ] 99 | }, 100 | "extra": { 101 | "symfony-app-dir": "bin", 102 | "symfony-bin-dir": "bin", 103 | "symfony-var-dir": "var", 104 | "symfony-web-dir": "public", 105 | "symfony-tests-dir": "tests", 106 | "phplist/core": { 107 | "bundles": [ 108 | "PhpList\\RestBundle\\PhpListRestBundle" 109 | ], 110 | "routes": { 111 | "rest-api-identity": { 112 | "resource": "@PhpListRestBundle/Identity/Controller/", 113 | "type": "attribute", 114 | "prefix": "/api/v2" 115 | }, 116 | "rest-api-subscription": { 117 | "resource": "@PhpListRestBundle/Subscription/Controller/", 118 | "type": "attribute", 119 | "prefix": "/api/v2" 120 | }, 121 | "rest-api-messaging": { 122 | "resource": "@PhpListRestBundle/Messaging/Controller/", 123 | "type": "attribute", 124 | "prefix": "/api/v2" 125 | }, 126 | "rest-api-analitics": { 127 | "resource": "@PhpListRestBundle/Statistics/Controller/", 128 | "type": "attribute", 129 | "prefix": "/api/v2" 130 | } 131 | } 132 | } 133 | }, 134 | "config": { 135 | "allow-plugins": { 136 | "php-http/discovery": true 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/Subscription/Controller/SubscriberImportController.php: -------------------------------------------------------------------------------- 1 | importManager = $importManager; 34 | } 35 | 36 | #[Route('/import', name: 'csv', methods: ['POST'])] 37 | #[OA\Post( 38 | path: '/api/v2/subscribers/import', 39 | description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . 40 | 'Import subscribers from CSV file.', 41 | summary: 'Import subscribers', 42 | requestBody: new OA\RequestBody( 43 | required: true, 44 | content: new OA\MediaType( 45 | mediaType: 'multipart/form-data', 46 | schema: new OA\Schema( 47 | properties: [ 48 | new OA\Property( 49 | property: 'file', 50 | description: 'CSV file with subscribers data', 51 | type: 'string', 52 | format: 'binary' 53 | ), 54 | new OA\Property( 55 | property: 'list_id', 56 | description: 'List id to add imported subscribers to', 57 | type: 'string', 58 | default: null, 59 | pattern: '^\\d+$' 60 | ), 61 | new OA\Property( 62 | property: 'update_existing', 63 | description: 'Weather to update existing subscribers or not', 64 | type: 'string', 65 | default: '0', 66 | enum: ['0', '1'] 67 | ), 68 | new OA\Property( 69 | property: 'skip_invalid_emails', 70 | description: 'Weather to skip invalid email or add with invalid_ prefix', 71 | ) 72 | ], 73 | type: 'object' 74 | ) 75 | ) 76 | ), 77 | tags: ['subscribers'], 78 | parameters: [ 79 | new OA\Parameter( 80 | name: 'php-auth-pw', 81 | description: 'Session key obtained from login', 82 | in: 'header', 83 | required: true, 84 | schema: new OA\Schema(type: 'string') 85 | ) 86 | ], 87 | responses: [ 88 | new OA\Response( 89 | response: 200, 90 | description: 'Success', 91 | content: new OA\JsonContent( 92 | properties: [ 93 | new OA\Property(property: 'imported', type: 'integer'), 94 | new OA\Property(property: 'skipped', type: 'integer'), 95 | new OA\Property( 96 | property: 'errors', 97 | type: 'array', 98 | items: new OA\Items(type: 'string') 99 | ) 100 | ] 101 | ) 102 | ), 103 | new OA\Response( 104 | response: 400, 105 | description: 'Bad Request', 106 | content: new OA\JsonContent(ref: '#/components/schemas/BadRequestResponse') 107 | ), 108 | new OA\Response( 109 | response: 403, 110 | description: 'Unauthorized', 111 | content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') 112 | ) 113 | ] 114 | )] 115 | public function importSubscribers(Request $request): JsonResponse 116 | { 117 | $admin = $this->requireAuthentication($request); 118 | if (!$admin->getPrivileges()->has(PrivilegeFlag::Subscribers)) { 119 | throw $this->createAccessDeniedException('You are not allowed to create subscribers.'); 120 | } 121 | 122 | /** @var UploadedFile|null $file */ 123 | $file = $request->files->get('file'); 124 | 125 | if (!$file) { 126 | return $this->json(['message' => 'No file uploaded'], Response::HTTP_BAD_REQUEST); 127 | } 128 | 129 | if ($file->getClientMimeType() !== 'text/csv' && $file->getClientOriginalExtension() !== 'csv') { 130 | return $this->json(['message' => 'File must be a CSV'], Response::HTTP_BAD_REQUEST); 131 | } 132 | 133 | try { 134 | $stats = $this->importManager->importFromCsv( 135 | file: $file, 136 | options: new SubscriberImportOptions( 137 | updateExisting: $request->getPayload()->getBoolean('update_existing'), 138 | listIds: $request->getPayload()->get('list_id') ? [$request->getPayload()->get('list_id')] : [], 139 | skipInvalidEmail: $request->getPayload()->getBoolean('skip_invalid_emails', true) 140 | ), 141 | ); 142 | 143 | return $this->json([ 144 | 'imported' => $stats['created'], 145 | 'skipped' => $stats['skipped'], 146 | 'errors' => $stats['errors'] 147 | ]); 148 | } catch (CouldNotReadUploadedFileException $exception) { 149 | return $this->json([ 150 | 'message' => 'Could not read uploaded file. ' . $exception->getMessage() 151 | ], Response::HTTP_BAD_REQUEST); 152 | } catch (Exception $e) { 153 | return $this->json([ 154 | 'message' => $e->getMessage() 155 | ], Response::HTTP_BAD_REQUEST); 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/Messaging/Serializer/MessageNormalizer.php: -------------------------------------------------------------------------------- 1 | ', 75 | nullable: true 76 | ), 77 | new OA\Property(property: 'to_field', type: 'string', example: '', nullable: true), 78 | new OA\Property(property: 'reply_to', type: 'string', nullable: true), 79 | new OA\Property(property: 'user_selection', type: 'string', nullable: true), 80 | ], 81 | type: 'object' 82 | ), 83 | ], 84 | type: 'object' 85 | )] 86 | class MessageNormalizer implements NormalizerInterface 87 | { 88 | public function __construct(private readonly TemplateNormalizer $templateNormalizer) 89 | { 90 | } 91 | 92 | /** 93 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) 94 | */ 95 | public function normalize($object, string $format = null, array $context = []): array 96 | { 97 | if (!$object instanceof Message) { 98 | return []; 99 | } 100 | 101 | $template = $object->getTemplate(); 102 | return [ 103 | 'id' => $object->getId(), 104 | 'unique_id' => $object->getUuid(), 105 | 'template' => $template?->getId() ? $this->templateNormalizer->normalize($template) : null, 106 | 'message_content' => [ 107 | 'subject' => $object->getContent()->getSubject(), 108 | 'text' => $object->getContent()->getText(), 109 | 'text_message' => $object->getContent()->getTextMessage(), 110 | 'footer' => $object->getContent()->getFooter(), 111 | ], 112 | 'message_format' => [ 113 | 'html_formated' => $object->getFormat()->isHtmlFormatted(), 114 | 'send_format' => $object->getFormat()->getSendFormat(), 115 | 'format_options' => $object->getFormat()->getFormatOptions() 116 | ], 117 | 'message_metadata' => [ 118 | 'status' => $object->getMetadata()->getStatus()->value, 119 | 'processed' => $object->getMetadata()->isProcessed(), 120 | 'views' => $object->getMetadata()->getViews(), 121 | 'bounce_count' => $object->getMetadata()->getBounceCount(), 122 | 'entered' => $object->getMetadata()->getEntered()?->format('Y-m-d\TH:i:sP'), 123 | 'sent' => $object->getMetadata()->getSent()?->format('Y-m-d\TH:i:sP'), 124 | ], 125 | 'message_schedule' => [ 126 | 'repeat_interval' => $object->getSchedule()->getRepeatInterval(), 127 | 'repeat_until' => $object->getSchedule()->getRepeatUntil()?->format('Y-m-d\TH:i:sP'), 128 | 'requeue_interval' => $object->getSchedule()->getRequeueInterval(), 129 | 'requeue_until' => $object->getSchedule()->getRequeueUntil()?->format('Y-m-d\TH:i:sP'), 130 | 'embargo' => $object->getSchedule()->getEmbargo()?->format('Y-m-d\TH:i:sP'), 131 | ], 132 | 'message_options' => [ 133 | 'from_field' => $object->getOptions()->getFromField(), 134 | 'to_field' => $object->getOptions()->getToField(), 135 | 'reply_to' => $object->getOptions()->getReplyTo(), 136 | 'user_selection' => $object->getOptions()->getUserSelection(), 137 | ], 138 | ]; 139 | } 140 | 141 | /** 142 | * @SuppressWarnings(PHPMD.UnusedFormalParameter) 143 | */ 144 | public function supportsNormalization($data, string $format = null): bool 145 | { 146 | return $data instanceof Message; 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/Identity/Controller/SessionController.php: -------------------------------------------------------------------------------- 1 | 27 | * @author Tatevik Grigoryan 28 | */ 29 | #[Route('/sessions', name: 'session_')] 30 | class SessionController extends BaseController 31 | { 32 | private SessionManager $sessionManager; 33 | 34 | public function __construct( 35 | Authentication $authentication, 36 | RequestValidator $validator, 37 | SessionManager $sessionManager, 38 | private readonly EntityManagerInterface $entityManager, 39 | ) { 40 | parent::__construct($authentication, $validator); 41 | 42 | $this->sessionManager = $sessionManager; 43 | } 44 | 45 | #[Route('', name: 'create', methods: ['POST'])] 46 | #[OA\Post( 47 | path: '/api/v2/sessions', 48 | description: '✅ **Status: Stable** – This method is stable and safe for production use. ' . 49 | 'Given valid login data, this will generate a login token that will be valid for 1 hour.', 50 | summary: 'Log in or create new session.', 51 | requestBody: new OA\RequestBody( 52 | description: 'Pass session credentials', 53 | required: true, 54 | content: new OA\JsonContent( 55 | required: ['login_name', 'password'], 56 | properties: [ 57 | new OA\Property(property: 'login_name', type: 'string', format: 'string', example: 'admin'), 58 | new OA\Property(property: 'password', type: 'string', format: 'password', example: 'eetIc/Gropvoc1') 59 | ] 60 | ) 61 | ), 62 | tags: ['sessions'], 63 | responses: [ 64 | new OA\Response( 65 | response: 201, 66 | description: 'Success', 67 | content: new OA\JsonContent( 68 | properties: [ 69 | new OA\Property(property: 'id', type: 'integer', example: 1234), 70 | new OA\Property(property: 'key', type: 'string', example: '2cfe100561473c6cdd99c9e2f26fa974'), 71 | new OA\Property(property: 'expiry', type: 'string', example: '2017-07-20T18:22:48+00:00') 72 | ] 73 | ) 74 | ), 75 | new OA\Response( 76 | response: 400, 77 | description: 'Failure', 78 | content: new OA\JsonContent(ref: '#/components/schemas/BadRequestResponse') 79 | ), 80 | new OA\Response( 81 | response: 401, 82 | description: 'Failure', 83 | content: new OA\JsonContent( 84 | properties: [ 85 | new OA\Property(property: 'message', type: 'string', example: 'Not authorized.') 86 | ] 87 | ) 88 | ) 89 | ] 90 | )] 91 | public function createSession( 92 | Request $request, 93 | AdministratorTokenNormalizer $normalizer 94 | ): JsonResponse { 95 | /** @var CreateSessionRequest $createSessionRequest */ 96 | $createSessionRequest = $this->validator->validate($request, CreateSessionRequest::class); 97 | $token = $this->sessionManager->createSession( 98 | loginName:$createSessionRequest->loginName, 99 | password: $createSessionRequest->password 100 | ); 101 | $this->entityManager->flush(); 102 | 103 | $json = $normalizer->normalize($token, 'json'); 104 | 105 | return $this->json($json, Response::HTTP_CREATED); 106 | } 107 | 108 | /** 109 | * Deletes a session. 110 | * 111 | * This action may only be called for sessions that are owned by the authenticated administrator. 112 | * 113 | * @throws AccessDeniedHttpException 114 | */ 115 | #[Route('/{sessionId}', name: 'delete', requirements: ['sessionId' => '\d+'], methods: ['DELETE'])] 116 | #[OA\Delete( 117 | path: '/api/v2/sessions/{sessionId}', 118 | description: '✅ **Status: Stable** – This method is stable and safe for production use. ' . 119 | 'Delete the session passed as a parameter.', 120 | summary: 'Delete a session.', 121 | tags: ['sessions'], 122 | parameters: [ 123 | new OA\Parameter( 124 | name: 'php-auth-pw', 125 | description: 'Session key obtained from login', 126 | in: 'header', 127 | required: true, 128 | schema: new OA\Schema(type: 'string') 129 | ), 130 | new OA\Parameter( 131 | name: 'sessionId', 132 | description: 'Session id (not key as for authentication) obtained from login', 133 | in: 'path', 134 | required: true, 135 | schema: new OA\Schema(type: 'string') 136 | ), 137 | ], 138 | responses: [ 139 | new OA\Response( 140 | response: 200, 141 | description: 'Success' 142 | ), 143 | new OA\Response( 144 | response: 403, 145 | description: 'Failure', 146 | content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') 147 | ), 148 | new OA\Response( 149 | response: 404, 150 | description: 'Failure', 151 | content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') 152 | ) 153 | ] 154 | )] 155 | public function deleteSession( 156 | Request $request, 157 | #[MapEntity(mapping: ['sessionId' => 'id'])] ?AdministratorToken $token = null 158 | ): JsonResponse { 159 | $administrator = $this->requireAuthentication($request); 160 | 161 | if (!$token) { 162 | throw $this->createNotFoundException('Token not found.'); 163 | } 164 | if ($token->getAdministrator() !== $administrator) { 165 | throw new AccessDeniedHttpException('You do not have access to this session.', null, 1519831644); 166 | } 167 | 168 | $this->sessionManager->deleteSession($token); 169 | $this->entityManager->flush(); 170 | 171 | return $this->json(null, Response::HTTP_NO_CONTENT); 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/Identity/Controller/PasswordResetController.php: -------------------------------------------------------------------------------- 1 | passwordManager = $passwordManager; 38 | } 39 | 40 | #[Route('/request', name: 'request', methods: ['POST'])] 41 | #[OA\Post( 42 | path: '/api/v2/password-reset/request', 43 | description: 'Request a password reset token for an administrator account.', 44 | summary: 'Request a password reset.', 45 | requestBody: new OA\RequestBody( 46 | description: 'Administrator email', 47 | required: true, 48 | content: new OA\JsonContent( 49 | required: ['email'], 50 | properties: [ 51 | new OA\Property(property: 'email', type: 'string', format: 'email', example: 'admin@example.com'), 52 | ] 53 | ) 54 | ), 55 | tags: ['password-reset'], 56 | responses: [ 57 | new OA\Response( 58 | response: 204, 59 | description: 'Password reset token generated', 60 | ), 61 | new OA\Response( 62 | response: 400, 63 | description: 'Failure', 64 | content: new OA\JsonContent(ref: '#/components/schemas/BadRequestResponse') 65 | ), 66 | new OA\Response( 67 | response: 404, 68 | description: 'Failure', 69 | content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') 70 | ) 71 | ] 72 | )] 73 | public function requestPasswordReset(Request $request): JsonResponse 74 | { 75 | /** @var RequestPasswordResetRequest $resetRequest */ 76 | $resetRequest = $this->validator->validate($request, RequestPasswordResetRequest::class); 77 | 78 | $this->passwordManager->generatePasswordResetToken($resetRequest->email); 79 | $this->entityManager->flush(); 80 | 81 | return $this->json(null, Response::HTTP_NO_CONTENT); 82 | } 83 | 84 | #[Route('/validate', name: 'validate', methods: ['POST'])] 85 | #[OA\Post( 86 | path: '/api/v2/password-reset/validate', 87 | description: 'Validate a password reset token.', 88 | summary: 'Validate a password reset token.', 89 | requestBody: new OA\RequestBody( 90 | description: 'Password reset token', 91 | required: true, 92 | content: new OA\JsonContent( 93 | required: ['token'], 94 | properties: [ 95 | new OA\Property(property: 'token', type: 'string', example: 'a1b2c3d4e5f6'), 96 | ] 97 | ) 98 | ), 99 | tags: ['password-reset'], 100 | responses: [ 101 | new OA\Response( 102 | response: 200, 103 | description: 'Success', 104 | content: new OA\JsonContent( 105 | properties: [ 106 | new OA\Property(property: 'valid', type: 'boolean', example: true), 107 | ] 108 | ) 109 | ), 110 | new OA\Response( 111 | response: 400, 112 | description: 'Failure', 113 | content: new OA\JsonContent(ref: '#/components/schemas/BadRequestResponse') 114 | ) 115 | ] 116 | )] 117 | public function validateToken(Request $request): JsonResponse 118 | { 119 | /** @var ValidateTokenRequest $validateRequest */ 120 | $validateRequest = $this->validator->validate($request, ValidateTokenRequest::class); 121 | 122 | $administrator = $this->passwordManager->validatePasswordResetToken($validateRequest->token); 123 | $this->entityManager->flush(); 124 | 125 | return $this->json([ 'valid' => $administrator !== null]); 126 | } 127 | 128 | #[Route('/reset', name: 'reset', methods: ['POST'])] 129 | #[OA\Post( 130 | path: '/api/v2/password-reset/reset', 131 | description: 'Reset an administrator password using a token.', 132 | summary: 'Reset password with token.', 133 | requestBody: new OA\RequestBody( 134 | description: 'Password reset information', 135 | required: true, 136 | content: new OA\JsonContent( 137 | required: ['token', 'newPassword'], 138 | properties: [ 139 | new OA\Property(property: 'token', type: 'string', example: 'a1b2c3d4e5f6'), 140 | new OA\Property( 141 | property: 'newPassword', 142 | type: 'string', 143 | format: 'password', 144 | example: 'newSecurePassword123', 145 | ), 146 | ] 147 | ) 148 | ), 149 | tags: ['password-reset'], 150 | responses: [ 151 | new OA\Response( 152 | response: 200, 153 | description: 'Success', 154 | content: new OA\JsonContent( 155 | properties: [ 156 | new OA\Property(property: 'message', type: 'string', example: 'Password updated successfully'), 157 | ] 158 | ) 159 | ), 160 | new OA\Response( 161 | response: 400, 162 | description: 'Invalid or expired token', 163 | content: new OA\JsonContent(ref: '#/components/schemas/BadRequestResponse') 164 | ) 165 | ] 166 | )] 167 | public function resetPassword(Request $request): JsonResponse 168 | { 169 | /** @var ResetPasswordRequest $resetRequest */ 170 | $resetRequest = $this->validator->validate($request, ResetPasswordRequest::class); 171 | 172 | $success = $this->passwordManager->updatePasswordWithToken( 173 | $resetRequest->token, 174 | $resetRequest->newPassword 175 | ); 176 | $this->entityManager->flush(); 177 | 178 | if ($success) { 179 | return $this->json([ 'message' => 'Password updated successfully']); 180 | } 181 | 182 | return $this->json(['message' => 'Invalid or expired token'], Response::HTTP_BAD_REQUEST); 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/Subscription/Controller/ListMembersController.php: -------------------------------------------------------------------------------- 1 | subscriberNormalizer = $subscriberNormalizer; 36 | $this->paginatedProvider = $paginatedProvider; 37 | } 38 | 39 | #[Route('/{listId}/subscribers', name: 'get_list', requirements: ['listId' => '\d+'], methods: ['GET'])] 40 | #[OA\Get( 41 | path: '/api/v2/lists/{listId}/subscribers', 42 | description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . 43 | 'Returns a JSON list of all subscribers for a subscriber list.', 44 | summary: 'Gets a list of all subscribers of a subscriber list.', 45 | tags: ['subscriptions'], 46 | parameters: [ 47 | new OA\Parameter( 48 | name: 'php-auth-pw', 49 | description: 'Session key obtained from login', 50 | in: 'header', 51 | required: true, 52 | schema: new OA\Schema(type: 'string') 53 | ), 54 | new OA\Parameter( 55 | name: 'listId', 56 | description: 'List ID', 57 | in: 'path', 58 | required: true, 59 | schema: new OA\Schema(type: 'integer') 60 | ), 61 | new OA\Parameter( 62 | name: 'after_id', 63 | description: 'Last id (starting from 0)', 64 | in: 'query', 65 | required: false, 66 | schema: new OA\Schema(type: 'integer', default: 1, minimum: 1) 67 | ), 68 | new OA\Parameter( 69 | name: 'limit', 70 | description: 'Number of results per page', 71 | in: 'query', 72 | required: false, 73 | schema: new OA\Schema(type: 'integer', default: 25, maximum: 100, minimum: 1) 74 | ) 75 | ], 76 | responses: [ 77 | new OA\Response( 78 | response: 200, 79 | description: 'Success', 80 | content: new OA\JsonContent( 81 | properties: [ 82 | new OA\Property( 83 | property: 'items', 84 | type: 'array', 85 | items: new OA\Items(ref: '#/components/schemas/Subscriber') 86 | ), 87 | new OA\Property(property: 'pagination', ref: '#/components/schemas/CursorPagination') 88 | ], 89 | type: 'object' 90 | ) 91 | ), 92 | new OA\Response( 93 | response: 403, 94 | description: 'Failure', 95 | content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') 96 | ), 97 | new OA\Response( 98 | response: 404, 99 | description: 'Failure', 100 | content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') 101 | ) 102 | ] 103 | )] 104 | public function getListMembers( 105 | Request $request, 106 | #[MapEntity(mapping: ['listId' => 'id'])] ?SubscriberList $list = null, 107 | ): JsonResponse { 108 | $this->requireAuthentication($request); 109 | 110 | if (!$list) { 111 | throw $this->createNotFoundException('Subscriber list not found.'); 112 | } 113 | 114 | return $this->json( 115 | $this->paginatedProvider->getPaginatedList( 116 | request: $request, 117 | normalizer: $this->subscriberNormalizer, 118 | className: Subscriber::class, 119 | filter: new SubscriberFilter($list->getId()) 120 | ), 121 | Response::HTTP_OK 122 | ); 123 | } 124 | 125 | #[Route('/{listId}/subscribers/count', name: 'get_count', requirements: ['listId' => '\d+'], methods: ['GET'])] 126 | #[OA\Get( 127 | path: '/api/v2/lists/{listId}/count', 128 | description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . 129 | 'Returns a count of all subscribers in a given list.', 130 | summary: 'Gets the total number of subscribers of a list', 131 | tags: ['subscriptions'], 132 | parameters: [ 133 | new OA\Parameter( 134 | name: 'php-auth-pw', 135 | description: 'Session key obtained from login', 136 | in: 'header', 137 | required: true, 138 | schema: new OA\Schema(type: 'string') 139 | ), 140 | new OA\Parameter( 141 | name: 'listId', 142 | description: 'List ID', 143 | in: 'path', 144 | required: true, 145 | schema: new OA\Schema(type: 'string') 146 | ) 147 | ], 148 | responses: [ 149 | new OA\Response( 150 | response: 200, 151 | description: 'Success', 152 | content: new OA\JsonContent( 153 | properties: [ 154 | new OA\Property( 155 | property: 'subscribers_count', 156 | type: 'integer', 157 | example: 42 158 | ) 159 | ], 160 | type: 'object' 161 | ) 162 | ), 163 | new OA\Response( 164 | response: 403, 165 | description: 'Failure', 166 | content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') 167 | ), 168 | new OA\Response( 169 | response: 404, 170 | description: 'Failure', 171 | content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') 172 | ) 173 | ] 174 | )] 175 | public function getSubscribersCount( 176 | Request $request, 177 | #[MapEntity(mapping: ['listId' => 'id'])] ?SubscriberList $list = null, 178 | ): JsonResponse { 179 | $this->requireAuthentication($request); 180 | 181 | if (!$list) { 182 | throw $this->createNotFoundException('Subscriber list not found.'); 183 | } 184 | 185 | return $this->json(['subscribers_count' => count($list->getSubscribers())], Response::HTTP_OK); 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/Subscription/Controller/SubscriptionController.php: -------------------------------------------------------------------------------- 1 | 26 | */ 27 | #[Route('/lists', name: 'subscription_')] 28 | class SubscriptionController extends BaseController 29 | { 30 | private SubscriptionManager $subscriptionManager; 31 | private SubscriptionNormalizer $subscriptionNormalizer; 32 | private EntityManagerInterface $entityManager; 33 | 34 | public function __construct( 35 | Authentication $authentication, 36 | RequestValidator $validator, 37 | SubscriptionManager $subscriptionManager, 38 | SubscriptionNormalizer $subscriptionNormalizer, 39 | EntityManagerInterface $entityManager, 40 | ) { 41 | parent::__construct($authentication, $validator); 42 | $this->subscriptionManager = $subscriptionManager; 43 | $this->subscriptionNormalizer = $subscriptionNormalizer; 44 | $this->entityManager = $entityManager; 45 | } 46 | 47 | #[Route('/{listId}/subscribers', name: 'create', requirements: ['listId' => '\d+'], methods: ['POST'])] 48 | #[OA\Post( 49 | path: '/api/v2/lists/{listId}/subscribers', 50 | description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . 51 | 'Subscribe subscriber to a list.', 52 | summary: 'Create subscription', 53 | requestBody: new OA\RequestBody( 54 | description: 'Pass session credentials', 55 | required: true, 56 | content: new OA\JsonContent( 57 | required: ['emails'], 58 | properties: [ 59 | new OA\Property( 60 | property: 'emails', 61 | type: 'array', 62 | items: new OA\Items(type: 'string', format: 'email'), 63 | example: ['test1@example.com', 'test2@example.com'] 64 | ), 65 | ] 66 | ) 67 | ), 68 | tags: ['subscriptions'], 69 | parameters: [ 70 | new OA\Parameter( 71 | name: 'php-auth-pw', 72 | description: 'Session key obtained from login', 73 | in: 'header', 74 | required: true, 75 | schema: new OA\Schema(type: 'string') 76 | ), 77 | new OA\Parameter( 78 | name: 'listId', 79 | description: 'List ID', 80 | in: 'path', 81 | required: true, 82 | schema: new OA\Schema(type: 'string') 83 | ), 84 | ], 85 | responses: [ 86 | new OA\Response( 87 | response: 201, 88 | description: 'Success', 89 | content: new OA\JsonContent( 90 | type: 'array', 91 | items: new OA\Items(ref: '#/components/schemas/Subscription') 92 | ) 93 | ), 94 | new OA\Response( 95 | response: 400, 96 | description: 'Failure', 97 | content: new OA\JsonContent(ref: '#/components/schemas/BadRequestResponse') 98 | ), 99 | new OA\Response( 100 | response: 403, 101 | description: 'Failure', 102 | content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') 103 | ), 104 | new OA\Response( 105 | response: 404, 106 | description: 'Failure', 107 | content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') 108 | ), 109 | new OA\Response( 110 | response: 409, 111 | description: 'Failure', 112 | content: new OA\JsonContent(ref: '#/components/schemas/AlreadyExistsResponse') 113 | ), 114 | new OA\Response( 115 | response: 422, 116 | description: 'Failure', 117 | content: new OA\JsonContent(ref: '#/components/schemas/ValidationErrorResponse') 118 | ), 119 | ] 120 | )] 121 | public function createSubscription( 122 | Request $request, 123 | #[MapEntity(mapping: ['listId' => 'id'])] ?SubscriberList $list = null, 124 | ): JsonResponse { 125 | $this->requireAuthentication($request); 126 | 127 | if (!$list) { 128 | throw $this->createNotFoundException('Subscriber list not found.'); 129 | } 130 | 131 | /** @var SubscriptionRequest $subscriptionRequest */ 132 | $subscriptionRequest = $this->validator->validate($request, SubscriptionRequest::class); 133 | $subscriptions = $this->subscriptionManager->createSubscriptions($list, $subscriptionRequest->emails); 134 | $this->entityManager->flush(); 135 | $normalized = array_map(fn($item) => $this->subscriptionNormalizer->normalize($item), $subscriptions); 136 | 137 | return $this->json($normalized, Response::HTTP_CREATED); 138 | } 139 | 140 | #[Route('/{listId}/subscribers', name: 'delete', requirements: ['listId' => '\d+'], methods: ['DELETE'])] 141 | #[OA\Delete( 142 | path: '/api/v2/lists/{listId}/subscribers', 143 | description: '🚧 **Status: Beta** – This method is under development. Avoid using in production. ' . 144 | 'Delete subscription.', 145 | summary: 'Delete subscription', 146 | tags: ['subscriptions'], 147 | parameters: [ 148 | new OA\Parameter( 149 | name: 'php-auth-pw', 150 | description: 'Session key obtained from login', 151 | in: 'header', 152 | required: true, 153 | schema: new OA\Schema(type: 'string') 154 | ), 155 | new OA\Parameter( 156 | name: 'listId', 157 | description: 'List ID', 158 | in: 'path', 159 | required: true, 160 | schema: new OA\Schema(type: 'string') 161 | ), 162 | new OA\Parameter( 163 | name: 'emails', 164 | description: 'emails of subscribers to delete from list.', 165 | in: 'query', 166 | required: true, 167 | schema: new OA\Schema(type: 'string') 168 | ), 169 | ], 170 | responses: [ 171 | new OA\Response( 172 | response: 204, 173 | description: 'Success', 174 | ), 175 | new OA\Response( 176 | response: 403, 177 | description: 'Failure', 178 | content: new OA\JsonContent(ref: '#/components/schemas/UnauthorizedResponse') 179 | ), 180 | new OA\Response( 181 | response: 404, 182 | description: 'Failure', 183 | content: new OA\JsonContent(ref: '#/components/schemas/NotFoundErrorResponse') 184 | ) 185 | ] 186 | )] 187 | public function deleteSubscriptions( 188 | Request $request, 189 | #[MapEntity(mapping: ['listId' => 'id'])] ?SubscriberList $list = null, 190 | ): JsonResponse { 191 | $this->requireAuthentication($request); 192 | if (!$list) { 193 | throw $this->createNotFoundException('Subscriber list not found.'); 194 | } 195 | $subscriptionRequest = new SubscriptionRequest(); 196 | $subscriptionRequest->emails = $request->query->all('emails'); 197 | 198 | /** @var SubscriptionRequest $subscriptionRequest */ 199 | $subscriptionRequest = $this->validator->validateDto($subscriptionRequest); 200 | $this->subscriptionManager->deleteSubscriptions($list, $subscriptionRequest->emails); 201 | $this->entityManager->flush(); 202 | 203 | return $this->json(null, Response::HTTP_NO_CONTENT); 204 | } 205 | } 206 | --------------------------------------------------------------------------------