├── var └── .gitkeep ├── tests ├── data │ ├── .gitkeep │ └── templates │ │ ├── _includes │ │ └── example-include.twig │ │ ├── mjml-example │ │ ├── mjml-example.text.twig │ │ ├── mjml-example.mjml.twig │ │ └── mjml-example.meta.yml │ │ ├── new-user-welcome │ │ ├── new-user-welcome.text.twig │ │ └── new-user-welcome.meta.yml │ │ ├── simplest-test-message │ │ ├── simplest-test-message.text.twig │ │ ├── simplest-test-message.html.twig │ │ └── simplest-test-message.meta.yml │ │ ├── message-with-static-attachments │ │ ├── static-attachment.txt │ │ ├── message-with-static-attachments.text.twig │ │ ├── message-with-static-attachments.mjml.twig │ │ ├── message-with-static-attachments.schema.json │ │ └── message-with-static-attachments.meta.yml │ │ ├── message-with-attachments │ │ ├── message-with-attachments.text.twig │ │ ├── message-with-attachments.mjml.twig │ │ ├── message-with-attachments.meta.yml │ │ └── message-with-attachments.schema.json │ │ ├── template-with-include │ │ ├── template-with-include.text.twig │ │ ├── template-with-include.html.twig │ │ └── template-with-include.meta.yml │ │ └── without-text-version │ │ ├── without-text-version.html.twig │ │ └── without-text-version.meta.yml ├── integration │ └── pipeprint │ │ ├── test.sh │ │ └── docker-compose.yml ├── Unit │ └── Outstack │ │ └── Enveloper │ │ └── Resolution │ │ ├── AbstractResolutionUnitTest.php │ │ ├── AttachmentResolverTest.php │ │ ├── RecipientResolverTest.php │ │ ├── AttachmentListResolverTest.php │ │ └── RecipientListResolverTest.php └── Functional │ ├── AbstractApiTestCase.php │ ├── MessageHistoryFunctionalTest.php │ ├── MessagePreviewingFunctionalTest.php │ ├── ErrorHandlingFunctionalTest.php │ └── AttachmentHandlingFunctionalTest.php ├── docs ├── api │ ├── build │ │ ├── .gitignore │ │ ├── model │ │ │ └── .gitignore │ │ ├── resources │ │ │ ├── .gitignore │ │ │ └── errors │ │ │ │ └── .gitignore │ │ └── endpoints │ │ │ └── outbox │ │ │ ├── .gitignore │ │ │ └── preview │ │ │ └── .gitignore │ ├── package.json │ ├── index.html │ ├── build.js │ ├── yarn.lock │ └── openapi.yaml ├── examples │ └── hello-world │ │ ├── hello-world.text.twig │ │ ├── hello-world.html.twig │ │ ├── hello-world.meta.yml │ │ └── hello-world.schema.json ├── nginx-vhost.conf ├── _sidebar.md ├── 05-schema-validation-of-parameters.md ├── index.html ├── 03-configuring-the-database.md ├── 02-configuring-templates.md ├── 04-advanced-templating.md └── 01-getting-started.md ├── .dockerignore ├── schemata ├── model │ ├── template-parameters.schema.json │ ├── template-identifier.schema.json │ ├── message-request.schema.json │ └── participant.schema.json ├── endpoints │ └── outbox │ │ ├── post.requestBody.schema.json │ │ ├── preview │ │ ├── post.requestBody.schema.json │ │ └── post.responseBody.schema.json │ │ ├── getSentMessageById.responseBody.schema.json │ │ ├── get.responseBody.schema.json │ │ └── deliveryAttempts │ │ └── get.responseBody.schema.json └── resources │ ├── errors │ ├── base-error.schema.json │ ├── server-error.schema.json │ ├── bad-request.schema.json │ ├── not-acceptable.schema.json │ ├── syntax-error.schema.json │ └── failed-json-schema-validation.schema.json │ ├── delivery-attempt.schema.json │ ├── email-request.schema.json │ └── resolved-message.schema.json ├── Procfile ├── app ├── AppCache.php ├── config │ ├── config_prod.yml │ ├── routing.yml │ ├── config_dev.yml │ ├── routing_dev.yml │ ├── doctrine │ │ └── orm │ │ │ ├── Email.EmailRequest.orm.yml │ │ │ ├── Email.Email.orm.yml │ │ │ └── Delivery.AttemptedDelivery.orm.yml │ ├── security.yml │ ├── config_test.yml │ ├── config.yml │ └── services.yml ├── .htaccess ├── autoload.php └── AppKernel.php ├── infrastructure ├── php-fpm │ ├── php-fpm.conf │ └── www.conf ├── scripts │ └── install-composer.sh └── nginx │ ├── vhost.conf │ └── nginx.conf ├── src ├── AppBundle │ ├── AppBundle.php │ ├── Messenger │ │ ├── SpoolTransportFactory.php │ │ ├── SpoolTransport.php │ │ └── SpoolTransportEventSubscriber.php │ └── Controller │ │ ├── IndexController.php │ │ ├── ErrorController.php │ │ └── DeliveryAttemptController.php └── Outstack │ ├── Enveloper │ ├── Domain │ │ ├── Resolution │ │ │ ├── Templates │ │ │ │ ├── TemplateLoader.php │ │ │ │ ├── TemplateLanguage.php │ │ │ │ ├── Pipeline │ │ │ │ │ ├── TemplatePipeline.php │ │ │ │ │ └── Exceptions │ │ │ │ │ │ └── PipelineFailed.php │ │ │ │ ├── TemplateNotFound.php │ │ │ │ ├── AttachmentListTemplate.php │ │ │ │ ├── ParticipantListTemplate.php │ │ │ │ ├── ParticipantTemplate.php │ │ │ │ ├── AttachmentTemplate.php │ │ │ │ └── Template.php │ │ │ ├── ParametersFailedSchemaValidation.php │ │ │ ├── AttachmentResolver.php │ │ │ ├── ParticipantResolver.php │ │ │ ├── ParticipantListResolver.php │ │ │ ├── AttachmentListResolver.php │ │ │ └── MessageResolver.php │ │ ├── Delivery │ │ │ ├── DeliveryQueue.php │ │ │ ├── DeliveryMethod.php │ │ │ └── AttemptedDelivery.php │ │ ├── History │ │ │ ├── Exceptions │ │ │ │ ├── EmailRequestNotFound.php │ │ │ │ └── DeliveryAttemptNotFound.php │ │ │ └── EmailDeliveryLog.php │ │ └── Email │ │ │ ├── Participants │ │ │ ├── EmailAddressNotValid.php │ │ │ ├── EmailAddress.php │ │ │ ├── ParticipantList.php │ │ │ └── Participant.php │ │ │ ├── Attachments │ │ │ ├── Attachment.php │ │ │ └── AttachmentList.php │ │ │ ├── EmailRequest.php │ │ │ └── Email.php │ ├── Infrastructure │ │ ├── Delivery │ │ │ ├── DeliveryMethod │ │ │ │ └── SwiftMailer │ │ │ │ │ ├── SwiftMailerInterface.php │ │ │ │ │ ├── SwiftMailerImplementation.php │ │ │ │ │ ├── SwiftMailerRecordingDecorator.php │ │ │ │ │ ├── SwiftMailerFactory.php │ │ │ │ │ └── SwiftMailerDeliveryMethod.php │ │ │ └── DeliveryQueue │ │ │ │ └── SymfonyMessenger │ │ │ │ ├── SymfonyMessengerDeliveryQueue.php │ │ │ │ └── SymfonyMessengerDeliveryQueueHandler.php │ │ ├── Resolution │ │ │ ├── TemplateLoader │ │ │ │ └── Filesystem │ │ │ │ │ ├── Exceptions │ │ │ │ │ └── InvalidConfigurationException.php │ │ │ │ │ ├── ConfigurationParser │ │ │ │ │ └── TemplateConfiguration.php │ │ │ │ │ └── FilesystemLoader.php │ │ │ ├── TemplateLanguage │ │ │ │ └── Twig │ │ │ │ │ ├── TwigEnveloperExtension.php │ │ │ │ │ └── TwigTemplateLanguage.php │ │ │ └── TemplatePipeline │ │ │ │ ├── Twig │ │ │ │ └── TwigTemplatePipeline.php │ │ │ │ ├── TemplatePipelineFactory.php │ │ │ │ └── Pipeprint │ │ │ │ └── PipeprintPipeline.php │ │ └── History │ │ │ └── EmailDeliveryLog │ │ │ └── DoctrineOrm │ │ │ ├── ParticipantListType.php │ │ │ ├── ParticipantType.php │ │ │ └── DoctrineOrmEmailDeliveryLog.php │ └── Application │ │ ├── PreviewEmail.php │ │ ├── QueueEmailRequest.php │ │ └── AttemptDelivery.php │ └── Components │ ├── ApiProvider │ └── ApiProblemDetails │ │ ├── ApiProblemFactory.php │ │ └── ApiProblemBuilder.php │ ├── ApiConsumer │ └── ApiClient.php │ ├── SymfonySwiftMailerAssertionLibrary │ └── SwiftMailerAssertionTrait.php │ ├── Framework │ └── AppKernel.php │ ├── HttpInterop │ └── Psr7 │ │ └── ServerEnvironmentRequestFactory.php │ └── SymfonyKernelHttpClient │ └── SymfonyKernelHttpClient.php ├── .gitignore ├── LICENSE.md ├── docker-compose.travis.yml ├── .travis.yml ├── docker-compose.tests.yml ├── test_travis.sh ├── test.sh ├── Dockerfile.docs ├── phpunit.xml.dist ├── docker-compose.dev.yml ├── web └── app.php ├── bin └── console ├── config └── bundles.php ├── docker-compose.yml ├── composer.json ├── Dockerfile └── README.md /var/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/data/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/api/build/.gitignore: -------------------------------------------------------------------------------- 1 | *.openapi 2 | -------------------------------------------------------------------------------- /docs/api/build/model/.gitignore: -------------------------------------------------------------------------------- 1 | *.openapi 2 | -------------------------------------------------------------------------------- /docs/api/build/resources/.gitignore: -------------------------------------------------------------------------------- 1 | *.openapi 2 | -------------------------------------------------------------------------------- /docs/api/build/endpoints/outbox/.gitignore: -------------------------------------------------------------------------------- 1 | *.openapi 2 | -------------------------------------------------------------------------------- /docs/api/build/resources/errors/.gitignore: -------------------------------------------------------------------------------- 1 | *.openapi 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | var/* 2 | vendor/* 3 | docs/api/node_modules/* -------------------------------------------------------------------------------- /docs/api/build/endpoints/outbox/preview/.gitignore: -------------------------------------------------------------------------------- 1 | *.openapi 2 | -------------------------------------------------------------------------------- /docs/examples/hello-world/hello-world.text.twig: -------------------------------------------------------------------------------- 1 | Hello, {{ name }} -------------------------------------------------------------------------------- /tests/data/templates/_includes/example-include.twig: -------------------------------------------------------------------------------- 1 | Included file 2 | -------------------------------------------------------------------------------- /tests/data/templates/mjml-example/mjml-example.text.twig: -------------------------------------------------------------------------------- 1 | Hello, {{ name }} -------------------------------------------------------------------------------- /schemata/model/template-parameters.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object" 3 | } -------------------------------------------------------------------------------- /tests/data/templates/new-user-welcome/new-user-welcome.text.twig: -------------------------------------------------------------------------------- 1 | Hey, welcome {{ user.email }} -------------------------------------------------------------------------------- /tests/data/templates/simplest-test-message/simplest-test-message.text.twig: -------------------------------------------------------------------------------- 1 | Hello, {{ name }} -------------------------------------------------------------------------------- /tests/integration/pipeprint/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | docker-compose up -d 5 | -------------------------------------------------------------------------------- /tests/data/templates/message-with-static-attachments/static-attachment.txt: -------------------------------------------------------------------------------- 1 | static attachment content -------------------------------------------------------------------------------- /tests/data/templates/message-with-attachments/message-with-attachments.text.twig: -------------------------------------------------------------------------------- 1 | Message with attachments -------------------------------------------------------------------------------- /schemata/endpoints/outbox/post.requestBody.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$ref": "../../model/message-request.schema.json" 3 | } -------------------------------------------------------------------------------- /tests/data/templates/message-with-static-attachments/message-with-static-attachments.text.twig: -------------------------------------------------------------------------------- 1 | Message with attachments -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | nginx: PYTHONUNBUFFERED=true nginx -g 'pid /tmp/nginx.pid; daemon off;' 2 | php: /usr/local/sbin/php-fpm -F 3 | -------------------------------------------------------------------------------- /schemata/endpoints/outbox/preview/post.requestBody.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$ref": "../../../model/message-request.schema.json" 3 | } -------------------------------------------------------------------------------- /tests/data/templates/template-with-include/template-with-include.text.twig: -------------------------------------------------------------------------------- 1 | {% include '_includes/example-include.twig' %} 2 | -------------------------------------------------------------------------------- /docs/examples/hello-world/hello-world.html.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Hello, {{ name }}

4 | 5 | -------------------------------------------------------------------------------- /schemata/endpoints/outbox/getSentMessageById.responseBody.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$ref": "../../resources/sent-message.schema.json" 3 | } 4 | -------------------------------------------------------------------------------- /app/AppCache.php: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Hello, {{ name }}

4 | 5 | -------------------------------------------------------------------------------- /app/config/config_prod.yml: -------------------------------------------------------------------------------- 1 | imports: 2 | - { resource: config.yml } 3 | 4 | monolog: 5 | handlers: 6 | console: 7 | type: console 8 | -------------------------------------------------------------------------------- /tests/data/templates/simplest-test-message/simplest-test-message.html.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Hello, {{ name }}

4 | 5 | -------------------------------------------------------------------------------- /infrastructure/php-fpm/php-fpm.conf: -------------------------------------------------------------------------------- 1 | [global] 2 | error_log = /proc/self/fd/2 3 | emergency_restart_threshold=0 4 | 5 | 6 | 7 | include=etc/php-fpm.d/*.conf 8 | 9 | -------------------------------------------------------------------------------- /app/.htaccess: -------------------------------------------------------------------------------- 1 | 2 | Require all denied 3 | 4 | 5 | Order deny,allow 6 | Deny from all 7 | 8 | -------------------------------------------------------------------------------- /src/AppBundle/AppBundle.php: -------------------------------------------------------------------------------- 1 | 2 | 3 |

{% include '_includes/example-include.twig' %}

4 | 5 | -------------------------------------------------------------------------------- /docs/nginx-vhost.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80 default_server; 3 | 4 | root /usr/share/nginx/html; 5 | 6 | location / { 7 | try_files $uri $uri/index.html index.html; 8 | index index.html; 9 | } 10 | } -------------------------------------------------------------------------------- /tests/data/templates/mjml-example/mjml-example.mjml.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Hello, {{ name }} 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /tests/data/templates/without-text-version/without-text-version.meta.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | subject: "Hello, {{ name }}" 4 | recipients: 5 | to: 6 | - "{{ email }}" 7 | content: 8 | html: "without-text-version.html.twig" 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /var 2 | /vendor 3 | /phpunit.phar 4 | /supervisord.log 5 | /composer.phar 6 | /.DS_Store 7 | /data/ 8 | /tests/data/enveloper_test.sqlite 9 | /docker-compose.override.yml 10 | docs/api/node_modules 11 | .idea 12 | -------------------------------------------------------------------------------- /docs/examples/hello-world/hello-world.meta.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | subject: "Hello, {{ name }}" 4 | recipients: 5 | to: 6 | - "{{ email }}" 7 | content: 8 | html: "hello-world.html.twig" 9 | text: "hello-world.text.twig" 10 | -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Domain/Resolution/Templates/TemplateLoader.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Message with attachments 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /schemata/model/template-identifier.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "string", 3 | "minLength": 1, 4 | "description": "Template identifier. Corresponds to template files e.g. `my-template/my-template.meta.yml`", 5 | "example": "order-dispatch-notification" 6 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Currently there is [No License](https://choosealicense.com/no-license/) associated with this project. 2 | 3 | This is a temporary measure while I choose the appropriate open-source license. If you have any concerns about this, feel free to contact me. -------------------------------------------------------------------------------- /app/config/routing.yml: -------------------------------------------------------------------------------- 1 | app: 2 | resource: '@AppBundle/Controller/' 3 | type: annotation 4 | 5 | error_404: 6 | path: /{req} 7 | defaults: { _controller: 'AppBundle\Controller\ErrorController::pageNotFoundAction' } 8 | requirements: 9 | req: ".+" -------------------------------------------------------------------------------- /infrastructure/php-fpm/www.conf: -------------------------------------------------------------------------------- 1 | [www] 2 | user = enveloper 3 | group = enveloper 4 | listen = 127.0.0.1:9000 5 | 6 | catch_workers_output = yes 7 | 8 | pm = ondemand 9 | pm.max_children = 5 10 | pm.process_idle_timeout = 10s 11 | pm.max_requests = 200 12 | -------------------------------------------------------------------------------- /tests/data/templates/message-with-static-attachments/message-with-static-attachments.mjml.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Message with attachments 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Domain/Resolution/Templates/TemplateLanguage.php: -------------------------------------------------------------------------------- 1 | new \Twig_SimpleFilter('base64_decode', 'base64_decode') 11 | ]; 12 | } 13 | 14 | } -------------------------------------------------------------------------------- /schemata/endpoints/outbox/preview/post.responseBody.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "properties": { 4 | "html": { 5 | "oneOf": [ 6 | { "type": "string", "example": "Hello, world!" }, 7 | { "type": "null" } 8 | ] 9 | }, 10 | "text": { 11 | "oneOf": [ 12 | { "type": "string", "example": "Hello, world!" }, 13 | { "type": "null" } 14 | ] 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /tests/data/templates/message-with-attachments/message-with-attachments.meta.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | subject: "Message with attachments" 4 | recipients: 5 | to: 6 | - "{{ email }}" 7 | content: 8 | html: "message-with-attachments.mjml.twig" 9 | text: "message-with-attachments.text.twig" 10 | attachments: 11 | - { contents: "{% autoescape false %}{{ item.contents|base64_decode }}{% endautoescape %}", filename: "{{ item.filename }}", iterateOver: "attachments" } -------------------------------------------------------------------------------- /app/config/doctrine/orm/Email.EmailRequest.orm.yml: -------------------------------------------------------------------------------- 1 | Outstack\Enveloper\Domain\Email\EmailRequest: 2 | type: entity 3 | table: email_request 4 | id: 5 | id: 6 | type: guid 7 | generator: { strategy: UUID } 8 | fields: 9 | template: 10 | type: string 11 | length: 200 12 | parameters: 13 | type: json_array 14 | requestedAt: 15 | type: datetime_immutable -------------------------------------------------------------------------------- /tests/data/templates/new-user-welcome/new-user-welcome.meta.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | subject: "Welcome, {{ user.handle }}" 4 | from: "noreply@example.com" 5 | recipients: 6 | to: 7 | - "{{ user.email }}" 8 | cc: 9 | - name: "{{ item.name }}" 10 | email: "{{ item.email }}" 11 | iterateOver: "administrators" # Could use expression language, any real need? 12 | content: 13 | html: "new-user-welcome.mjml.twig" 14 | text: "new-user-welcome.text.twig" 15 | -------------------------------------------------------------------------------- /test_travis.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -ev 3 | 4 | # Cleanup 5 | mkdir -p var/logs 6 | echo "" > var/logs/test.log 7 | 8 | COMPOSE="docker-compose -f ./docker-compose.yml -f ./docker-compose.travis.yml" 9 | 10 | $COMPOSE run enveloper sh -c '\ 11 | infrastructure/scripts/install-composer.sh && \ 12 | ./vendor/bin/simple-phpunit --filter=Unit && \ 13 | ./bin/console --env=test cache:warmup && \ 14 | ./vendor/bin/simple-phpunit --filter=Functional' 15 | 16 | -------------------------------------------------------------------------------- /schemata/resources/errors/base-error.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "allOf": [ 3 | { 4 | "type": "object", 5 | "properties": { 6 | "title": { 7 | "type": "string" 8 | }, 9 | "status": { 10 | "type": "integer", 11 | "minimum": 200, 12 | "maximum": 599 13 | }, 14 | "detail": { 15 | "type": "string" 16 | } 17 | }, 18 | "additionalProperties": false 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Domain/History/Exceptions/EmailRequestNotFound.php: -------------------------------------------------------------------------------- 1 | id = $id; 12 | parent::__construct("Email request with id `$id` not found"); 13 | } 14 | 15 | public function getId(): string 16 | { 17 | return $this->id; 18 | } 19 | } -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Cleanup 5 | mkdir -p var/logs 6 | echo "" > var/logs/test.log 7 | 8 | COMPOSE="docker-compose -f ./docker-compose.yml -f ./docker-compose.tests.yml" 9 | 10 | $COMPOSE run --rm enveloper sh -c '\ 11 | infrastructure/scripts/install-composer.sh && \ 12 | ./composer.phar install && \ 13 | ./vendor/bin/simple-phpunit --filter=Unit && \ 14 | ./bin/console --env=test cache:warmup && \ 15 | ./vendor/bin/simple-phpunit --filter=Functional' 16 | 17 | -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Domain/Email/Participants/EmailAddressNotValid.php: -------------------------------------------------------------------------------- 1 | address = $address; 12 | parent::__construct("The email address $address failed validation"); 13 | } 14 | 15 | public function getAddress(): string 16 | { 17 | return $this->address; 18 | } 19 | } -------------------------------------------------------------------------------- /schemata/resources/errors/server-error.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "allOf": [ 3 | { "$ref": "./base-error.schema.json" }, 4 | { 5 | "type": "object", 6 | "properties": { 7 | "title": { 8 | "type": "string", 9 | "example": "Server Error" 10 | }, 11 | "detail": { 12 | "type": "string", 13 | "example": "An unexpected error has occurred" 14 | }, 15 | "status": { 16 | "minimum": 500, 17 | "maximum": 599 18 | } 19 | } 20 | } 21 | ] 22 | } -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Domain/Resolution/ParametersFailedSchemaValidation.php: -------------------------------------------------------------------------------- 1 | errors = $validationErrors; 13 | } 14 | 15 | public function getErrors(): array 16 | { 17 | return $this->errors; 18 | } 19 | } -------------------------------------------------------------------------------- /infrastructure/scripts/install-composer.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | EXPECTED_SIGNATURE=$(wget -q -O - https://composer.github.io/installer.sig) 4 | php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" 5 | ACTUAL_SIGNATURE=$(php -r "echo hash_file('SHA384', 'composer-setup.php');") 6 | 7 | if [ "$EXPECTED_SIGNATURE" != "$ACTUAL_SIGNATURE" ] 8 | then 9 | >&2 echo 'ERROR: Invalid installer signature' 10 | rm composer-setup.php 11 | exit 1 12 | fi 13 | 14 | php composer-setup.php --quiet 15 | RESULT=$? 16 | rm composer-setup.php 17 | exit $RESULT -------------------------------------------------------------------------------- /schemata/resources/delivery-attempt.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-06/schema#", 3 | "type": "object", 4 | "properties": { 5 | "@id": { 6 | "type": "string", 7 | "format": "uri", 8 | "description": "URL of delivery attempt" 9 | }, 10 | "resolved": { 11 | "type": "object", 12 | "$ref": "./resolved-message.schema.json" 13 | }, 14 | "attemptedAt": { 15 | "type": "string", 16 | "format": "date-time" 17 | } 18 | }, 19 | "required": ["@id", "resolved", "attemptedAt"] 20 | } 21 | -------------------------------------------------------------------------------- /app/config/doctrine/orm/Email.Email.orm.yml: -------------------------------------------------------------------------------- 1 | Outstack\Enveloper\Domain\Email\Email: 2 | type: embeddable 3 | fields: 4 | subject: 5 | type: string 6 | length: 1000 7 | text: 8 | type: text 9 | nullable: true 10 | html: 11 | type: text 12 | nullable: true 13 | sender: 14 | type: participant 15 | to: 16 | type: participant_list 17 | cc: 18 | type: participant_list 19 | bcc: 20 | type: participant_list 21 | -------------------------------------------------------------------------------- /Dockerfile.docs: -------------------------------------------------------------------------------- 1 | FROM node:10-alpine as build 2 | COPY schemata/ /app/schemata 3 | COPY docs/api/yarn.lock /app/docs/api/yarn.lock 4 | COPY docs/api/package.json /app/docs/api/package.json 5 | WORKDIR /app/docs/api 6 | RUN yarn install 7 | COPY docs /app/docs 8 | RUN node build.js 9 | 10 | FROM nginx:alpine 11 | COPY docs /usr/share/nginx/html 12 | COPY --from=build /app/docs/api/build /usr/share/nginx/html/api/build 13 | COPY README.md /usr/share/nginx/html/README.md 14 | RUN sed -i 's|](\./docs/|](\./|g' /usr/share/nginx/html/README.md 15 | COPY docs/nginx-vhost.conf /etc/nginx/conf.d/default.conf 16 | 17 | -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Infrastructure/Delivery/DeliveryMethod/SwiftMailer/SwiftMailerImplementation.php: -------------------------------------------------------------------------------- 1 | mailer = $mailer; 15 | } 16 | 17 | public function send(\Swift_Message $message) 18 | { 19 | return $this->mailer->send($message); 20 | } 21 | } -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | tests 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /tests/Unit/Outstack/Enveloper/Resolution/AbstractResolutionUnitTest.php: -------------------------------------------------------------------------------- 1 | language = new TwigTemplateLanguage(); 19 | } 20 | } -------------------------------------------------------------------------------- /tests/data/templates/message-with-attachments/message-with-attachments.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-06/schema#", 3 | "properties": { 4 | "email": { 5 | "type": "string", 6 | "format": "email" 7 | }, 8 | "attachments": { 9 | "type": "array", 10 | "items": { 11 | "type": "object", 12 | "properties": { 13 | "contents": { 14 | "type": "string", 15 | "minLength": 1 16 | }, 17 | "filename": { 18 | "type": "string", 19 | "minLength": 1 20 | } 21 | } 22 | } 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /schemata/resources/email-request.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-06/schema#", 3 | "type": "object", 4 | "properties": { 5 | "@id": { 6 | "type": "string", 7 | "format": "uri", 8 | "description": "URL of message resource" 9 | }, 10 | "template": { 11 | "$ref": "../model/template-identifier.schema.json" 12 | }, 13 | "parameters": { 14 | "$ref": "../model/template-parameters.schema.json" 15 | }, 16 | "requestedAt": { 17 | "type": "string", 18 | "format": "date-time" 19 | } 20 | }, 21 | "required": ["@id", "template", "parameters", "requestedAt"] 22 | } 23 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: '3.1' 2 | services: 3 | enveloper: 4 | volumes: 5 | - .:/app 6 | environment: 7 | - SYMFONY_ENV=dev 8 | - VIRTUAL_HOST=enveloper.dev 9 | - XDEBUG_CONFIG="idekey=PHPSTORM remote_host=192.168.65.2" 10 | - PHP_IDE_CONFIG="serverName=enveloper.test" 11 | docs: 12 | build: 13 | context: . 14 | dockerfile: Dockerfile.docs 15 | environment: 16 | - VIRTUAL_HOST=enveloper-docs.dev 17 | volumes: 18 | - ./docs/api/openapi.yaml:/usr/share/nginx/html/openapi.yaml 19 | ports: 20 | - 8081:80 -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Domain/Email/Participants/EmailAddress.php: -------------------------------------------------------------------------------- 1 | address = $validatedAddress; 20 | } 21 | 22 | public function __toString() 23 | { 24 | return $this->address; 25 | } 26 | } -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Domain/Email/Attachments/Attachment.php: -------------------------------------------------------------------------------- 1 | data = $data; 19 | $this->filename = $filename; 20 | } 21 | 22 | public function getData(): string 23 | { 24 | return $this->data; 25 | } 26 | 27 | public function getFilename(): string 28 | { 29 | return $this->filename; 30 | } 31 | } -------------------------------------------------------------------------------- /app/config/doctrine/orm/Delivery.AttemptedDelivery.orm.yml: -------------------------------------------------------------------------------- 1 | Outstack\Enveloper\Domain\Delivery\AttemptedDelivery: 2 | type: entity 3 | table: delivery_attempt 4 | id: 5 | id: 6 | type: guid 7 | generator: { strategy: UUID } 8 | fields: 9 | attemptDate: 10 | type: datetime_immutable 11 | attemptNumber: 12 | type: integer 13 | embedded: 14 | resolvedMessage: 15 | class: Outstack\Enveloper\Domain\Email\Email 16 | 17 | manyToOne: 18 | emailRequest: 19 | targetEntity: Outstack\Enveloper\Domain\Email\EmailRequest 20 | joinColumn: 21 | onDelete: 'CASCADE' -------------------------------------------------------------------------------- /schemata/resources/errors/bad-request.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "properties": { 4 | "title": { 5 | "type": "string", 6 | "description": "Brief title for the error", 7 | "example": "Bad Request" 8 | }, 9 | "detail": { 10 | "type": "string" 11 | }, 12 | "status": { 13 | "minimum": 400, 14 | "maximum": 499 15 | } 16 | }, 17 | "discriminator": { 18 | "propertyName": "title", 19 | "mapping": { 20 | "Parameters failed JSON schema validation": { 21 | "$ref": "./failed-json-schema-validation.schema.json" 22 | }, 23 | "Syntax Error": { 24 | "$ref": "./syntax-error.schema.json" 25 | } 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Domain/History/Exceptions/DeliveryAttemptNotFound.php: -------------------------------------------------------------------------------- 1 | id = $id; 16 | $this->index = $index; 17 | parent::__construct("Delivery attempt $index for email request with id `$id` not found"); 18 | } 19 | 20 | public function getIndex(): int 21 | { 22 | return $this->index; 23 | } 24 | 25 | public function getId(): string 26 | { 27 | return $this->id; 28 | } 29 | } -------------------------------------------------------------------------------- /docs/api/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Enveloper API Reference 5 | 6 | 7 | 8 | 9 | 10 | 13 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/AppBundle/Messenger/SpoolTransportFactory.php: -------------------------------------------------------------------------------- 1 | bus = $bus; 19 | } 20 | 21 | public function append(EmailRequest $emailRequest) 22 | { 23 | $this->bus->dispatch($emailRequest); 24 | } 25 | } -------------------------------------------------------------------------------- /src/AppBundle/Controller/IndexController.php: -------------------------------------------------------------------------------- 1 | json( 17 | [ 18 | 'name' => 'Outstack Enveloper API', 19 | '_links' => [ 20 | 'self' => $request->getUri(), 21 | 'docs' => 'https://github.com/outstack/enveloper/tree/docs' 22 | ] 23 | ] 24 | ); 25 | } 26 | } -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Domain/Resolution/Templates/AttachmentListTemplate.php: -------------------------------------------------------------------------------- 1 | addTemplate($template); 16 | } 17 | } 18 | 19 | private function addTemplate(AttachmentTemplate $template) 20 | { 21 | $this->templates[] = $template; 22 | } 23 | 24 | /** 25 | * @return AttachmentTemplate[] 26 | */ 27 | public function getAttachmentTemplates(): array 28 | { 29 | return $this->templates; 30 | } 31 | } -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Domain/Resolution/Templates/ParticipantListTemplate.php: -------------------------------------------------------------------------------- 1 | addTemplate($template); 16 | } 17 | } 18 | 19 | private function addTemplate(ParticipantTemplate $template) 20 | { 21 | $this->templates[] = $template; 22 | } 23 | 24 | /** 25 | * @return ParticipantTemplate[] 26 | */ 27 | public function getParticipantTemplates(): array 28 | { 29 | return $this->templates; 30 | } 31 | } -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Domain/Email/Participants/ParticipantList.php: -------------------------------------------------------------------------------- 1 | addRecipient($recipient); 16 | } 17 | } 18 | 19 | private function addRecipient(Participant $recipient) 20 | { 21 | $this->recipients[] = $recipient; 22 | } 23 | 24 | /** 25 | * @return \ArrayIterator|Participant[] 26 | */ 27 | public function getIterator(): \ArrayIterator 28 | { 29 | return new \ArrayIterator($this->recipients); 30 | } 31 | } -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Domain/Email/Attachments/AttachmentList.php: -------------------------------------------------------------------------------- 1 | addAttachment($attachment); 16 | } 17 | } 18 | 19 | private function addAttachment(Attachment $attachment) 20 | { 21 | $this->attachments[] = $attachment; 22 | } 23 | 24 | /** 25 | * @return \ArrayIterator|Attachment[] 26 | */ 27 | public function getIterator(): \ArrayIterator 28 | { 29 | return new \ArrayIterator($this->attachments); 30 | } 31 | } -------------------------------------------------------------------------------- /web/app.php: -------------------------------------------------------------------------------- 1 | handle($request); 25 | $response->send(); 26 | 27 | $kernel->terminate($request, $response); -------------------------------------------------------------------------------- /src/Outstack/Components/ApiProvider/ApiProblemDetails/ApiProblemFactory.php: -------------------------------------------------------------------------------- 1 | factory = $factory; 17 | } 18 | 19 | public function createProblem(int $status, string $title, ?string $type = null) 20 | { 21 | $builder = new ApiProblemBuilder($this->factory); 22 | $builder = $builder 23 | ->setStatus($status) 24 | ->setTitle($title) 25 | ; 26 | if ($type) { 27 | $builder = $builder->setType($type); 28 | } 29 | 30 | return $builder; 31 | } 32 | } -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Infrastructure/Delivery/DeliveryQueue/SymfonyMessenger/SymfonyMessengerDeliveryQueueHandler.php: -------------------------------------------------------------------------------- 1 | attemptDelivery = $attemptDelivery; 19 | } 20 | 21 | public function __invoke(EmailRequest $emailRequest) 22 | { 23 | \call_user_func($this->attemptDelivery, $emailRequest); 24 | } 25 | } -------------------------------------------------------------------------------- /infrastructure/nginx/vhost.conf: -------------------------------------------------------------------------------- 1 | server { 2 | root /app/web; 3 | listen 8080 default_server; 4 | 5 | client_max_body_size 10M; 6 | client_body_buffer_size 10M; 7 | 8 | location / { 9 | # try to serve file directly, fallback to app.php 10 | try_files $uri /app.php$is_args$args; 11 | } 12 | # PROD 13 | location ~ ^/app\.php(/|$) { 14 | fastcgi_pass 127.0.0.1:9000; 15 | fastcgi_split_path_info ^(.+\.php)(/.*)$; 16 | include fastcgi_params; 17 | fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; 18 | fastcgi_param DOCUMENT_ROOT $realpath_root; 19 | internal; 20 | } 21 | 22 | # return 404 for all other php files not matching the front controller 23 | # this prevents access to other php files you don't want to be accessible. 24 | location ~ \.php$ { 25 | return 404; 26 | } 27 | } -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Infrastructure/Resolution/TemplateLanguage/Twig/TwigTemplateLanguage.php: -------------------------------------------------------------------------------- 1 | twig = $twig; 22 | } 23 | 24 | public function render(string $templateContents, object $parameters): string 25 | { 26 | return $this->twig->createTemplate($templateContents)->render((array) $parameters); 27 | } 28 | } -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Application/PreviewEmail.php: -------------------------------------------------------------------------------- 1 | messageResolver = $messageResolver; 23 | $this->templateLoader = $templateLoader; 24 | } 25 | 26 | public function __invoke(string $template, object $parameters) 27 | { 28 | return $this->messageResolver->resolve($this->templateLoader->find($template), $parameters); 29 | } 30 | } -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Domain/History/EmailDeliveryLog.php: -------------------------------------------------------------------------------- 1 | twig = $twig; 22 | } 23 | 24 | public function render(string $templateName, string $templateContents, object $parameters): string 25 | { 26 | return $this->twig->createTemplate($templateContents)->render((array) $parameters); 27 | } 28 | 29 | } -------------------------------------------------------------------------------- /app/config/config_test.yml: -------------------------------------------------------------------------------- 1 | imports: 2 | - { resource: config_dev.yml } 3 | 4 | parameters: 5 | template_directory: "%kernel.root_dir%/../tests/data/templates" 6 | default_sender_email: "test@example.com" 7 | default_sender_name: "Test Default Sender" 8 | mailer_record_messages: true 9 | mailer_deliver_messages: false 10 | env(ENVELOPER_SMTP_HOST): mailhog 11 | env(ENVELOPER_SMTP_USER): ~ 12 | env(ENVELOPER_SMTP_PASSWORD): ~ 13 | env(ENVELOPER_SMTP_PORT): 1025 14 | 15 | monolog: 16 | handlers: 17 | main: 18 | type: stream 19 | path: '%kernel.logs_dir%/%kernel.environment%.log' 20 | level: debug 21 | channels: ['!event'] 22 | 23 | framework: 24 | test: ~ 25 | session: 26 | storage_id: session.storage.mock_file 27 | profiler: 28 | collect: false 29 | 30 | web_profiler: 31 | toolbar: false 32 | intercept_redirects: false 33 | -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Domain/Resolution/Templates/Pipeline/Exceptions/PipelineFailed.php: -------------------------------------------------------------------------------- 1 | error = $error; 19 | $this->errorData = $errorData; 20 | 21 | $msg = "An unhandled template pipeline error occurred"; 22 | if ($error) { 23 | $msg .= ": $error"; 24 | } 25 | return parent::__construct($msg); 26 | } 27 | 28 | public function getError(): string 29 | { 30 | return $this->error; 31 | } 32 | 33 | public function getErrorData(): ?array 34 | { 35 | return $this->errorData; 36 | } 37 | } -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | getParameterOption(['--env', '-e'], getenv('SYMFONY_ENV') ?: 'dev'); 15 | $debug = getenv('SYMFONY_DEBUG') !== '0' && !$input->hasParameterOption(['--no-debug', '']) && $env !== 'prod'; 16 | if ($debug) { 17 | Debug::enable(); 18 | } 19 | $kernel = new AppKernel($env, $debug); 20 | $application = new Application($kernel); 21 | $application->run($input); -------------------------------------------------------------------------------- /docs/05-schema-validation-of-parameters.md: -------------------------------------------------------------------------------- 1 | # Validating parameters with JSON schema 2 | 3 | As templates become more and more complex, the required parameters needed to render an email can become large and complex too. 4 | It's easy to omit a parameter, or forget what parameters are needed to be submitted for any given template. A mistake here, can result in 5 | your template language throwing an error, and a 500 response. 6 | 7 | In order to minimise this, you can optionally define a JSON schema which runtime parameters will be validated against. 8 | 9 | 10 | Simply define your JSON schema next to your template's meta file, e.g. `hello-world/hello-world.schema.json`: 11 | 12 | ```json 13 | { 14 | "$schema": "http://json-schema.org/draft-06/schema#", 15 | "properties": { 16 | "email": { 17 | "type": "string", 18 | "format": "email" 19 | }, 20 | "name": { 21 | "type": "string" 22 | } 23 | }, 24 | "required": ["email"] 25 | } 26 | ``` 27 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Document 6 | 7 | 8 | 9 | 10 | Enveloper - Transactional email microservice 11 | 12 | 13 |
14 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Domain/Resolution/Templates/ParticipantTemplate.php: -------------------------------------------------------------------------------- 1 | name = $name; 23 | $this->emailAddress = $emailAddress; 24 | $this->iterateOver = $iterateOver; 25 | } 26 | 27 | public function getEmailAddress(): string 28 | { 29 | return $this->emailAddress; 30 | } 31 | 32 | public function getName(): ?string 33 | { 34 | return $this->name; 35 | } 36 | 37 | public function getIterateOver() 38 | { 39 | return $this->iterateOver; 40 | } 41 | } -------------------------------------------------------------------------------- /app/AppKernel.php: -------------------------------------------------------------------------------- 1 | getProjectDir().'/config/bundles.php'; 11 | foreach ($contents as $class => $envs) { 12 | if ($envs[$this->environment] ?? $envs['all'] ?? false) { 13 | yield new $class(); 14 | } 15 | } 16 | } 17 | 18 | public function getRootDir() 19 | { 20 | return __DIR__; 21 | } 22 | 23 | public function getCacheDir() 24 | { 25 | return dirname(__DIR__).'/var/cache/'.$this->getEnvironment(); 26 | } 27 | 28 | public function getLogDir() 29 | { 30 | return dirname(__DIR__).'/var/logs'; 31 | } 32 | 33 | public function registerContainerConfiguration(LoaderInterface $loader) 34 | { 35 | $loader->load($this->getRootDir().'/config/config_'.$this->getEnvironment().'.yml'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Domain/Resolution/AttachmentResolver.php: -------------------------------------------------------------------------------- 1 | language = $language; 20 | } 21 | 22 | public function resolve(AttachmentTemplate $template, object $parameters) 23 | { 24 | return new Attachment( 25 | $template->isStatic() 26 | ? $template->getContents() 27 | : $this->language->render($template->getContents(), $parameters), 28 | $this->language->render( 29 | $template->getFilename(), 30 | $parameters 31 | ) 32 | ); 33 | } 34 | } -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Infrastructure/Resolution/TemplatePipeline/TemplatePipelineFactory.php: -------------------------------------------------------------------------------- 1 | twig = $twig; 24 | $this->filesystem = $filesystem; 25 | } 26 | 27 | public function create(?string $pipeprintUrl) 28 | { 29 | if ($pipeprintUrl) { 30 | return new PipeprintPipeline($this->filesystem, $pipeprintUrl); 31 | } 32 | 33 | return new TwigTemplatePipeline($this->twig); 34 | } 35 | } -------------------------------------------------------------------------------- /config/bundles.php: -------------------------------------------------------------------------------- 1 | ['all' => true], 5 | Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], 6 | Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], 7 | Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], 8 | Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle::class => ['all' => true], 9 | Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], 10 | AppBundle\AppBundle::class => ['all' => true], 11 | 12 | Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true, 'test' => true], 13 | Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true] 14 | ]; 15 | 16 | // Allow extra bundles to be added via environment variables 17 | foreach (array_map('trim', explode(',', getenv('ENVELOPER_EXTENSIONS'))) as $extensionClass) { 18 | if (class_exists($extensionClass)) { 19 | $bundles[$extensionClass] = ['all' => true]; 20 | } 21 | } 22 | 23 | return $bundles; -------------------------------------------------------------------------------- /src/AppBundle/Controller/ErrorController.php: -------------------------------------------------------------------------------- 1 | problemFactory 22 | ->createProblem(500, 'Server Error') 23 | ->setDetail('An unexpected error occurred') 24 | ->buildJsonResponse(); 25 | 26 | } 27 | 28 | public function pageNotFoundAction() 29 | { 30 | return $this->problemFactory 31 | ->createProblem(404, 'Not Found') 32 | ->setDetail('No matching action was found to handle the request') 33 | ->buildJsonResponse(); 34 | } 35 | } -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Domain/Email/EmailRequest.php: -------------------------------------------------------------------------------- 1 | template = $template; 25 | $this->parameters = $parameters; 26 | $this->requestedAt = $requestedAt; 27 | } 28 | 29 | public function getRequestedAt(): \DateTimeImmutable 30 | { 31 | return $this->requestedAt; 32 | } 33 | 34 | public function getId(): string 35 | { 36 | return $this->id; 37 | } 38 | 39 | public function getTemplate(): string 40 | { 41 | return $this->template; 42 | } 43 | 44 | public function getParameters(): object 45 | { 46 | return (object) $this->parameters; 47 | } 48 | } -------------------------------------------------------------------------------- /schemata/resources/errors/not-acceptable.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "allOf": [ 3 | { "$ref": "./base-error.schema.json" }, 4 | { 5 | "type": "object", 6 | "properties": { 7 | "title": { 8 | "type": "string", 9 | "example": "Not Acceptable" 10 | }, 11 | "detail": { 12 | "type": "string", 13 | "example": "No version of this email matching your Accept header could be found" 14 | }, 15 | "status": { 16 | "enum": [406] 17 | }, 18 | "availableContentTypes": { 19 | "type": "array", 20 | "items": { 21 | "type": "string", 22 | "enum": [ 23 | "text/plain", 24 | "text/html", 25 | "application/json" 26 | ] 27 | } 28 | } 29 | } 30 | } 31 | ], 32 | "example": { 33 | "title": "Syntax Error", 34 | "status": 400, 35 | "detail": "Request failed JSON schema validation", 36 | "errors": [ 37 | { 38 | "error": "The data must be a(n) object.", 39 | "path": "\/type" 40 | } 41 | ] 42 | } 43 | } -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Infrastructure/Delivery/DeliveryMethod/SwiftMailer/SwiftMailerRecordingDecorator.php: -------------------------------------------------------------------------------- 1 | mailer = $mailer; 21 | $this->deliverMessages = $deliverMessages; 22 | } 23 | 24 | public function getMessageCount() 25 | { 26 | return count($this->sentMessages); 27 | } 28 | 29 | public function getMessages(): array 30 | { 31 | return $this->sentMessages; 32 | } 33 | 34 | public function send(\Swift_Message $message) 35 | { 36 | if ($this->deliverMessages) { 37 | $this->mailer->send($message); 38 | } 39 | $this->sentMessages[] = $message; 40 | } 41 | } -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Domain/Email/Participants/Participant.php: -------------------------------------------------------------------------------- 1 | name = $name; 19 | $this->emailAddress = $emailAddress; 20 | } 21 | 22 | public function isNamed() 23 | { 24 | return $this->name !== null; 25 | } 26 | 27 | public function getName(): ?string 28 | { 29 | if ($this->name === null) { 30 | return null; 31 | } 32 | return $this->name; 33 | } 34 | 35 | public function getEmailAddress(): EmailAddress 36 | { 37 | return $this->emailAddress; 38 | } 39 | 40 | public function __toString() 41 | { 42 | return "{$this->name} <{$this->emailAddress}>"; 43 | } 44 | } -------------------------------------------------------------------------------- /tests/integration/pipeprint/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.1' 2 | services: 3 | enveloper: 4 | image: outstack/enveloper:test 5 | ports: 6 | - 80:80 7 | environment: 8 | - SYMFONY_ENV=prod 9 | - ENVELOPER_SMTP_HOST=mailhog 10 | - ENVELOPER_SMTP_PORT=1025 11 | - ENVELOPER_SMTP_USER=test 12 | - ENVELOPER_SMTP_PASSWORD=test 13 | - ENVELOPER_DEFAULT_SENDER_NAME=Default Sender 14 | - ENVELOPER_DEFAULT_SENDER_EMAIL=noreply@example.com 15 | - ENVELOPER_DB_DSN=sqlite:////app/data/enveloper.sqlite 16 | - ENVELOPER_PIPEPRINT_URL=http://pipeprint 17 | links: 18 | - mailhog 19 | - pipeprint 20 | pipeprint: 21 | image: outstack/pipeprint 22 | environment: 23 | - 'PIPEPRINT_ENGINE_CONFIG={"twig": "http://twig", "mjml": "http://mjml"}' 24 | links: 25 | - twig 26 | - mjml 27 | 28 | twig: 29 | image: outstack/pipeprint-engine-twig 30 | mjml: 31 | image: outstack/pipeprint-engine-mjml 32 | 33 | mailhog: 34 | image: mailhog/mailhog 35 | ports: 36 | - 8025:8025 -------------------------------------------------------------------------------- /docs/03-configuring-the-database.md: -------------------------------------------------------------------------------- 1 | # Configuring the database 2 | 3 | Emails are recorded by default into an SQLite database at `enveloper.sqlite` in the mounted data directory. 4 | 5 | ## Overriding the DSN 6 | This can be overridden by the `ENVELOPER_DB_DSN` environment variable using the [Doctrine DBAL format](http://docs.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url). 7 | 8 | ## Supported DB platforms 9 | 10 | The list of databases supported by doctrine is [here](http://docs.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/platforms.html). 11 | By default, not all PHP DB extensions are available, so you may need to install the relevant one by building a custom Dockerfile. 12 | 13 | MySQL and SQLite are available by default. 14 | 15 | ## DB lifecycle commands 16 | 17 | As the database uses the Symfony Doctrine ORM Bundle, you can use these commands to manage the DB. 18 | 19 | Examples include: 20 | 21 | To create the database if it does not exist: 22 | 23 | docker exec -it enveloper /app/bin/console doctrine:database:create 24 | 25 | To create the schema: 26 | 27 | docker exec -it enveloper /app/bin/console doctrine:schema:create -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Infrastructure/Delivery/DeliveryMethod/SwiftMailer/SwiftMailerFactory.php: -------------------------------------------------------------------------------- 1 | options = $options; 15 | } 16 | 17 | public function create(): SwiftMailerInterface 18 | { 19 | $transport = new Swift_SmtpTransport( 20 | $this->options['host'], 21 | $this->options['port'] 22 | ); 23 | $transport 24 | ->setUsername($this->options['username']) 25 | ->setPassword($this->options['password']) 26 | ; 27 | if ($this->options['encryption']) { 28 | $transport->setEncryption($this->options['encryption']); 29 | } 30 | 31 | $mailer = new SwiftMailerImplementation(new Swift_Mailer($transport)); 32 | if ($this->options['record']) { 33 | $mailer = new SwiftMailerRecordingDecorator($mailer, $this->options['deliver_messages']); 34 | } 35 | 36 | return $mailer; 37 | } 38 | } -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Domain/Resolution/Templates/AttachmentTemplate.php: -------------------------------------------------------------------------------- 1 | contents = $contents; 27 | $this->filename = $filename; 28 | $this->iterateOver = $iterateOver; 29 | $this->static = $static; 30 | } 31 | 32 | public function isStatic(): bool 33 | { 34 | return $this->static; 35 | } 36 | 37 | public function getContents(): string 38 | { 39 | return $this->contents; 40 | } 41 | 42 | public function getFilename(): string 43 | { 44 | return $this->filename; 45 | } 46 | 47 | public function getIterateOver(): ?string 48 | { 49 | return $this->iterateOver; 50 | } 51 | } -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.1' 2 | services: 3 | enveloper: 4 | build: . 5 | ports: 6 | - 8080:8080 7 | environment: 8 | - SYMFONY_ENV=prod 9 | - ENVELOPER_SMTP_HOST=mailhog 10 | - ENVELOPER_SMTP_PORT=1025 11 | - ENVELOPER_SMTP_USER=test 12 | - ENVELOPER_SMTP_PASSWORD=test 13 | - ENVELOPER_DEFAULT_SENDER_NAME=Default Sender 14 | - ENVELOPER_DEFAULT_SENDER_EMAIL=noreply@example.com 15 | - ENVELOPER_DB_DSN=sqlite:////app/data/enveloper.sqlite 16 | - ENVELOPER_PIPEPRINT_URL=http://pipeprint 17 | links: 18 | - mailhog 19 | - pipeprint 20 | pipeprint: 21 | image: outstack/pipeprint 22 | environment: 23 | - 'PIPEPRINT_ENGINE_CONFIG={"twig": "http://twig", "mjml": "http://mjml"}' 24 | links: 25 | - twig 26 | - mjml 27 | 28 | twig: 29 | image: outstack/pipeprint-engine-twig 30 | restart: on-failure 31 | mjml: 32 | image: outstack/pipeprint-engine-mjml 33 | restart: on-failure 34 | 35 | mailhog: 36 | image: mailhog/mailhog 37 | ports: 38 | - 8025:8025 -------------------------------------------------------------------------------- /schemata/resources/errors/syntax-error.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "allOf": [ 3 | { "$ref": "./base-error.schema.json" }, 4 | { 5 | "type": "object", 6 | "properties": { 7 | "title": { 8 | "type": "string", 9 | "example": "Syntax Error" 10 | }, 11 | "detail": { 12 | "type": "string", 13 | "example": "Request failed JSON schema validation" 14 | }, 15 | "status": { 16 | "enum": [400] 17 | }, 18 | "errors": { 19 | "type": "array", 20 | "items": { 21 | "type": "object", 22 | "properties": { 23 | "error": { 24 | "type": "string", 25 | "example": "The data must be a(n) object." 26 | }, 27 | "path": { 28 | "type": "string", 29 | "example": "\/type" 30 | } 31 | } 32 | } 33 | } 34 | } 35 | } 36 | ], 37 | "example": { 38 | "title": "Syntax Error", 39 | "status": 400, 40 | "detail": "Request failed JSON schema validation", 41 | "errors": [ 42 | { 43 | "error": "The data must be a(n) object.", 44 | "path": "\/type" 45 | } 46 | ] 47 | } 48 | } -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Domain/Resolution/ParticipantResolver.php: -------------------------------------------------------------------------------- 1 | language = $language; 20 | } 21 | 22 | public function resolveRecipient(ParticipantTemplate $participantTemplate, object $parameters) 23 | { 24 | $participantName = $participantTemplate->getName(); 25 | 26 | $resolvedName = $participantName 27 | ? $this->language->render($participantName, $parameters) 28 | : null; 29 | 30 | $resolvedEmail = new EmailAddress( 31 | $this->language->render( 32 | $participantTemplate->getEmailAddress(), 33 | $parameters 34 | ) 35 | ); 36 | 37 | return new Participant($resolvedName, $resolvedEmail); 38 | } 39 | } -------------------------------------------------------------------------------- /docs/02-configuring-templates.md: -------------------------------------------------------------------------------- 1 | # Configuring templates 2 | 3 | So you've got the hello world working, and you want to try a real template. 4 | 5 | Each template has its own folder in the template directory, inside that directory Enveloper will look for a meta file ending in `.meta.yml` with the same name as the folder. 6 | 7 | Imagine you're writing the welcome email for your onboarding process, you create a template named `new-user-welcome`. 8 | 9 | ```yml 10 | 11 | --- 12 | # new-user-welcome/new-user-welcome.meta.yml 13 | 14 | 15 | subject: "Welcome, {{ user.handle }}" 16 | from: "noreply@example.com" 17 | recipients: 18 | to: 19 | - "{{ user.email }}" 20 | cc: 21 | - name: "{{ item.name }}" 22 | email: "{{ item.email }}" 23 | iterateOver: "administrators" 24 | content: 25 | html: "new-user-welcome.html.twig" 26 | text: "new-user-welcome.text.twig" 27 | 28 | 29 | ``` 30 | 31 | This defines a template named `new-user-welcome`, with a few templated properties. The simplest of these is `subject` as it's just a string. Some are more complex. 32 | 33 | Each of these templated strings uses placeholders between `{{` and `}}`, allowing them to be dynamic based on what you send. 34 | 35 | The templating languages that powers this is [Twig](https://twig.sensiolabs.org/), similar to Jinja in Python. 36 | 37 | -------------------------------------------------------------------------------- /src/AppBundle/Messenger/SpoolTransport.php: -------------------------------------------------------------------------------- 1 | envelopes[] = $envelope; 23 | return $envelope; 24 | } 25 | 26 | public function get(): iterable 27 | { 28 | return $this->envelopes; 29 | } 30 | 31 | public function ack(Envelope $envelope): void 32 | { 33 | $this->removeEnvelopeFromQueue($envelope); 34 | } 35 | 36 | public function reject(Envelope $envelope): void 37 | { 38 | $this->removeEnvelopeFromQueue($envelope); 39 | } 40 | 41 | private function removeEnvelopeFromQueue(Envelope $envelope): void 42 | { 43 | foreach ($this->envelopes as $key => $queued) { 44 | if ($queued->getMessage() === $envelope->getMessage()) { 45 | unset($this->envelopes[$key]); 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tests/Unit/Outstack/Enveloper/Resolution/AttachmentResolverTest.php: -------------------------------------------------------------------------------- 1 | sut = new AttachmentResolver($this->language); 20 | } 21 | 22 | public function test_simple_txt_resolved() 23 | { 24 | $this->assertEquals( 25 | new Attachment( 26 | 'part 1 - part 2', 27 | '2part.txt' 28 | ), 29 | $this->sut->resolve( 30 | new AttachmentTemplate( 31 | false, 32 | '{{ string1 }} - {{ string2 }}', 33 | '{{ string3 }}.txt', 34 | null 35 | ), 36 | (object) [ 37 | 'string1' => 'part 1', 38 | 'string2' => 'part 2', 39 | 'string3' => '2part' 40 | ] 41 | ) 42 | ); 43 | } 44 | } -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "autoload": { 3 | "psr-4": { 4 | "": "src/" 5 | }, 6 | "classmap": [ "app/AppKernel.php", "app/AppCache.php" ] 7 | }, 8 | "autoload-dev": { 9 | "psr-4": { 10 | "Outstack\\Enveloper\\Tests\\": "tests/" 11 | } 12 | }, 13 | "require": { 14 | "symfony/symfony": "^4.2", 15 | "symfony/monolog-bundle": "^3.3", 16 | "symfony/polyfill-apcu": "^1.0", 17 | "symfony/options-resolver": "^4.1", 18 | "symfony/config": "^4.1", 19 | "symfony/http-foundation": "^4.1", 20 | "symfony/psr-http-message-bridge": "^1.1", 21 | "symfony/http-kernel": "^4.1", 22 | "symfony/framework-bundle": "^4.1", 23 | "swiftmailer/swiftmailer": "^6.0", 24 | "twig/twig": "^2.13", 25 | "league/flysystem": "^1.0", 26 | "league/flysystem-memory": "^1.0", 27 | "zendframework/zend-diactoros": "^1.4", 28 | "psr/http-message": "^1.0", 29 | "php-http/client-common": "^1.8", 30 | "doctrine/orm": "^2.6", 31 | "doctrine/doctrine-bundle": "^1.9.1", 32 | "league/json-guard": "^1.0", 33 | "league/json-reference": "^1.0", 34 | "symfony/messenger": "^4.1", 35 | "sensio/framework-extra-bundle": "^5.2", 36 | "symfony/serializer-pack": "^1.0" 37 | }, 38 | "require-dev": { 39 | "symfony/phpunit-bridge": "^3.4" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Outstack/Components/ApiConsumer/ApiClient.php: -------------------------------------------------------------------------------- 1 | requestFactory = $requestFactory; 38 | $this->streamFactory = $streamFactory; 39 | $this->uriFactory = $uriFactory; 40 | $this->httpClient = $httpClient; 41 | } 42 | 43 | public function request(string $method, string $uri, ?string $body, array $headers = []) 44 | { 45 | $uri = $this->uriFactory->createUri($uri); 46 | $stream = $this->streamFactory->createStream($body); 47 | $request = $this->requestFactory->createRequest($method, $uri, $headers, $stream); 48 | 49 | return $this->httpClient->sendRequest($request); 50 | } 51 | } -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Domain/Resolution/ParticipantListResolver.php: -------------------------------------------------------------------------------- 1 | recipientResolver = $recipientResolver; 18 | } 19 | 20 | 21 | public function resolveParticipantList(ParticipantListTemplate $template, object $parameters) 22 | { 23 | $resolvedParticipants = []; 24 | foreach ($template->getParticipantTemplates() as $participantTemplate) { 25 | 26 | $iterateOver = $participantTemplate->getIterateOver(); 27 | 28 | if (is_null($iterateOver)) { 29 | $resolvedParticipants[] = $this->recipientResolver->resolveRecipient($participantTemplate, (object) $parameters); 30 | continue; 31 | } 32 | 33 | foreach ($parameters->$iterateOver as $item) { 34 | $resolvedParticipants[] = $this->recipientResolver->resolveRecipient($participantTemplate, (object) ['item' => $item]); 35 | } 36 | } 37 | 38 | return new ParticipantList($resolvedParticipants); 39 | } 40 | 41 | } -------------------------------------------------------------------------------- /schemata/resources/resolved-message.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-06/schema#", 3 | "type": "object", 4 | "properties": { 5 | "subject": { 6 | "type": "string", 7 | "example": "📦 Your order has shipped!" 8 | }, 9 | "content": { 10 | "type": "object", 11 | "properties": { 12 | "@id": { 13 | "type": "string", 14 | "format": "uri", 15 | "description": "The URL where rendered content can be accessed" 16 | }, 17 | "availableContentTypes": { 18 | "type": "array", 19 | "items": { 20 | "type": "string", 21 | "enum": ["text/plain", "text/html", "application/json"] 22 | } 23 | } 24 | } 25 | }, 26 | "sender": { 27 | "$ref": "../model/participant.schema.json" 28 | }, 29 | "recipients": { 30 | "type": "object", 31 | "properties": { 32 | "to": { 33 | "type": "array", 34 | "items": { 35 | "$ref": "../model/participant.schema.json" 36 | } 37 | }, 38 | "cc": { 39 | "type": "array", 40 | "items": { 41 | "$ref": "../model/participant.schema.json" 42 | } 43 | }, 44 | "bcc": { 45 | "type": "array", 46 | "items": { 47 | "$ref": "../model/participant.schema.json" 48 | } 49 | } 50 | } 51 | } 52 | 53 | }, 54 | "required": ["subject", "content", "sender", "recipients"] 55 | } 56 | -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Domain/Delivery/AttemptedDelivery.php: -------------------------------------------------------------------------------- 1 | emailRequest = $emailRequest; 32 | $this->resolvedMessage = $resolvedMessage; 33 | $this->attemptDate = $attemptDate; 34 | $this->attemptNumber = $attemptNumber; 35 | } 36 | 37 | public function getAttemptNumber(): int 38 | { 39 | return $this->attemptNumber; 40 | } 41 | 42 | public function getId(): string 43 | { 44 | return $this->id; 45 | } 46 | 47 | public function getEmailRequest(): EmailRequest 48 | { 49 | return $this->emailRequest; 50 | } 51 | 52 | public function getResolvedMessage(): Email 53 | { 54 | return $this->resolvedMessage; 55 | } 56 | 57 | 58 | public function getAttemptDate(): \DateTimeImmutable 59 | { 60 | return $this->attemptDate; 61 | } 62 | } -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Application/QueueEmailRequest.php: -------------------------------------------------------------------------------- 1 | emailRequestLog = $emailRequestLog; 33 | $this->deliveryQueue = $deliveryQueue; 34 | $this->messageResolver = $messageResolver; 35 | $this->templateLoader = $templateLoader; 36 | } 37 | 38 | public function __invoke(EmailRequest $emailRequest) 39 | { 40 | $template = $this->templateLoader->find($emailRequest->getTemplate()); 41 | 42 | $this->messageResolver->validate($template, $emailRequest->getParameters()); 43 | $this->emailRequestLog->recordInitialRequest($emailRequest); 44 | $this->deliveryQueue->append($emailRequest); 45 | } 46 | } -------------------------------------------------------------------------------- /docs/api/build.js: -------------------------------------------------------------------------------- 1 | const process = require('process'); 2 | const fs = require('fs'); 3 | const glob = require('glob'); 4 | const path = require('path'); 5 | const toOpenApi = require('json-schema-to-openapi-schema'); 6 | const $RefParser = require('json-schema-ref-parser'); 7 | 8 | const sourceSchemaDir = path.resolve(__dirname, './../../schemata/'); 9 | const buildSchemaDir = path.resolve(__dirname, './build'); 10 | const jsonSchemaSuffix = '.schema.json'; 11 | const openApiSchemaSuffix = '.schema.openapi'; 12 | 13 | glob(sourceSchemaDir + '/**/*' + jsonSchemaSuffix, function(err, files) { 14 | 15 | files.forEach(function(file) { 16 | $RefParser.dereference(file, function(err, schema) { 17 | if (err) { 18 | console.error(err); 19 | process.exit(1); 20 | } 21 | else { 22 | // `schema` is just a normal JavaScript object that contains your entire JSON Schema, 23 | // including referenced files, combined into a single object 24 | 25 | // console.log(JSON.stringify(toOpenApi(schema))); 26 | var outputFile = file 27 | .replace(sourceSchemaDir, buildSchemaDir) 28 | .replace(jsonSchemaSuffix, openApiSchemaSuffix) 29 | ; 30 | 31 | console.log(outputFile); 32 | 33 | fs.writeFile(outputFile, JSON.stringify(toOpenApi(schema)), function(err) { 34 | if (err) { 35 | console.error(err); 36 | process.exit(2); 37 | } 38 | }); 39 | } 40 | }); 41 | 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/Outstack/Components/SymfonySwiftMailerAssertionLibrary/SwiftMailerAssertionTrait.php: -------------------------------------------------------------------------------- 1 | assertEquals( 17 | $expectedMessageCount, 18 | $this->mailerSpy->getMessageCount() 19 | ); 20 | } 21 | 22 | protected function assertMessageSent(callable $matcher) 23 | { 24 | $this->assertGreaterThan(0, count($this->mailerSpy->getMessages())); 25 | foreach ($this->mailerSpy->getMessages() as $message) { 26 | if ($matcher($message)) { 27 | return; 28 | } 29 | } 30 | 31 | throw new \LogicException("No matching message found"); 32 | } 33 | 34 | private function doesToIncludeEmailAddress(\Swift_Message $message, string $email): bool 35 | { 36 | return array_key_exists($email, $message->getTo()); 37 | } 38 | private function messageWasFromContact(\Swift_Message $message, string $expectedEmail, ?string $name): bool 39 | { 40 | $sender = $message->getFrom(); 41 | return 42 | ( is_null($name) && $expectedEmail === $sender) || 43 | (!is_null($name) && is_array($sender) && array_key_exists($expectedEmail, $sender) && $sender[$expectedEmail] == $name); 44 | } 45 | } -------------------------------------------------------------------------------- /schemata/resources/errors/failed-json-schema-validation.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "allOf": [ 3 | { "$ref": "./base-error.schema.json" }, 4 | { 5 | "type": "object", 6 | "properties": { 7 | "title": { 8 | "type": "string", 9 | "example": "Parameters failed JSON schema validation" 10 | }, 11 | "detail": { 12 | "type": "string", 13 | "example": "A template was found but the parameters submitted to it do not validate against the configured JSON schema" 14 | }, 15 | "status": { 16 | "enum": [400] 17 | }, 18 | "errors": { 19 | "type": "array", 20 | "items": { 21 | "type": "object", 22 | "properties": { 23 | "error": { 24 | "type": "string", 25 | "example": "The object must contain the properties [\"name\"]." 26 | }, 27 | "path": { 28 | "type": "string", 29 | "example": "\/required" 30 | } 31 | } 32 | } 33 | } 34 | } 35 | } 36 | ], 37 | "example": { 38 | "title": "Parameters failed JSON schema validation", 39 | "status": 400, 40 | "detail": "A template was found but the parameters submitted to it do not validate against the configured JSON schema", 41 | "errors": [ 42 | { 43 | "error": "The object must contain the properties [\"name\"].", 44 | "path": "\/required" 45 | }, 46 | { 47 | "error": "The object must not contain additional properties ([\"namde\"]).", 48 | "path": "\/additionalProperties" 49 | } 50 | ] 51 | } 52 | } -------------------------------------------------------------------------------- /tests/Unit/Outstack/Enveloper/Resolution/RecipientResolverTest.php: -------------------------------------------------------------------------------- 1 | sut = new ParticipantResolver($this->language); 21 | } 22 | 23 | public function test_recipient_without_name_resolved() 24 | { 25 | $this->assertEquals( 26 | new Participant(null, new EmailAddress('admin1@example.com')), 27 | 28 | $this->sut->resolveRecipient( 29 | new ParticipantTemplate(null, 'admin{{ number }}@{{ domain}}'), 30 | (object) [ 31 | 'number' => 1, 32 | 'domain' => 'example.com' 33 | ] 34 | ) 35 | ); 36 | } 37 | public function test_recipient_with_name_resolved() 38 | { 39 | $this->assertEquals( 40 | new Participant('Admin Number 1', new EmailAddress('admin1@example.com')), 41 | 42 | $this->sut->resolveRecipient( 43 | new ParticipantTemplate('Admin Number {{ number }}', 'admin1@example.com'), 44 | (object) [ 45 | 'number' => 1 46 | ] 47 | ) 48 | ); 49 | } 50 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:7.4-fpm-alpine3.12 as deps 2 | COPY --from=composer:1.6 /usr/bin/composer /usr/bin/composer 3 | WORKDIR /app 4 | COPY app/AppKernel.php /app/app/ 5 | COPY app/AppCache.php /app/app/ 6 | COPY composer.json /app/ 7 | COPY composer.lock /app/ 8 | RUN composer install --optimize-autoloader --no-interaction --ignore-platform-reqs --no-scripts 9 | 10 | FROM php:7.4-fpm-alpine3.12 11 | MAINTAINER Adam Quaile 12 | RUN apk update --no-cache \ 13 | && apk add openssl \ 14 | && apk add ca-certificates \ 15 | && apk add zlib-dev \ 16 | && apk add bash \ 17 | && apk add nginx=1.18.0-r0 \ 18 | && apk add zip \ 19 | && apk add libzip-dev=1.6.1-r1 \ 20 | && apk add unzip \ 21 | && docker-php-source extract \ 22 | && docker-php-ext-install zip \ 23 | && docker-php-ext-install bcmath \ 24 | && docker-php-source delete \ 25 | && wget https://raw.githubusercontent.com/chrismytton/shoreman/380e745d1c2cd7bc163a1485ee57b20c76395198/shoreman.sh && chmod +x shoreman.sh && mv shoreman.sh /usr/local/bin/shoreman 26 | 27 | WORKDIR /app 28 | COPY --from=deps /app/vendor /app/vendor 29 | COPY . /app 30 | RUN cp /app/infrastructure/php-fpm/php-fpm.conf /usr/local/etc/php-fpm.conf && \ 31 | cp /app/infrastructure/php-fpm/www.conf /usr/local/etc/php-fpm.d/www.conf && \ 32 | cp /app/infrastructure/nginx/nginx.conf /etc/nginx/nginx.conf && \ 33 | cp /app/infrastructure/nginx/vhost.conf /etc/nginx/conf.d/default.conf 34 | 35 | RUN ln -sf /dev/stdout /var/log/nginx/access.log && ln -sf /dev/stderr /var/log/nginx/error.log 36 | 37 | ENV SYMFONY_ENV prod 38 | EXPOSE 8080 39 | RUN addgroup enveloper && adduser -D -G enveloper enveloper && \ 40 | chown -R enveloper:enveloper \ 41 | /app \ 42 | /var/lib/nginx/ \ 43 | /etc/nginx 44 | USER enveloper 45 | CMD ["/usr/local/bin/shoreman"] 46 | -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Domain/Resolution/AttachmentListResolver.php: -------------------------------------------------------------------------------- 1 | attachmentResolver = $attachmentResolver; 19 | } 20 | 21 | public function resolveAttachmentList(AttachmentListTemplate $attachmentListTemplate, object $parameters): AttachmentList 22 | { 23 | $resolved = []; 24 | foreach ($attachmentListTemplate->getAttachmentTemplates() as $template) { 25 | foreach ($this->resolveTemplate($template, $parameters) as $attachment) { 26 | $resolved[] = $attachment; 27 | } 28 | } 29 | 30 | return new AttachmentList($resolved); 31 | } 32 | 33 | private function resolveTemplate(AttachmentTemplate $template, object $parameters) 34 | { 35 | if ($template->getIterateOver()) { 36 | foreach ($this->resolveIteratively($template, $parameters) as $template) { 37 | yield $template; 38 | } 39 | 40 | return; 41 | } 42 | 43 | yield $this->attachmentResolver->resolve($template, $parameters); 44 | } 45 | 46 | private function resolveIteratively(AttachmentTemplate $template, object $parameters) 47 | { 48 | foreach ($parameters->{$template->getIterateOver()} as $item) { 49 | yield $this->attachmentResolver->resolve($template, (object) ['item' => $item]); 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Infrastructure/History/EmailDeliveryLog/DoctrineOrm/ParticipantListType.php: -------------------------------------------------------------------------------- 1 | getIterator()))); 20 | } 21 | 22 | public function convertToPHPValue($value, AbstractPlatform $platform) 23 | { 24 | if ($value === null) { 25 | return null; 26 | } 27 | 28 | return new ParticipantList(array_map([ParticipantType::class, 'toObject'], json_decode($value, true))); 29 | } 30 | 31 | 32 | /** 33 | * Gets the SQL declaration snippet for a field of this type. 34 | * 35 | * @param array $fieldDeclaration The field declaration. 36 | * @param \Doctrine\DBAL\Platforms\AbstractPlatform $platform The currently used database platform. 37 | * 38 | * @return string 39 | */ 40 | public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform) 41 | { 42 | return $platform->getClobTypeDeclarationSQL($fieldDeclaration); 43 | } 44 | 45 | /** 46 | * Gets the name of this type. 47 | * 48 | * @return string 49 | * 50 | * @todo Needed? 51 | */ 52 | public function getName() 53 | { 54 | return 'participant_list'; 55 | } 56 | } -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Application/AttemptDelivery.php: -------------------------------------------------------------------------------- 1 | messageResolver = $messageResolver; 39 | $this->templateLoader = $templateLoader; 40 | $this->deliveryMethod = $deliveryMethod; 41 | $this->emailRequestLog = $emailRequestLog; 42 | } 43 | public function __invoke(EmailRequest $emailRequest) 44 | { 45 | $resolvedMessage = $this->messageResolver->resolve( 46 | $this->templateLoader->find($emailRequest->getTemplate()), 47 | $emailRequest->getParameters() 48 | ); 49 | 50 | $attemptedDelivery = new AttemptedDelivery( 51 | $emailRequest, 52 | $this->emailRequestLog->countDeliveryAttempts($emailRequest), 53 | $resolvedMessage, 54 | new \DateTimeImmutable('now') 55 | ); 56 | 57 | $this->deliveryMethod->attemptDelivery($emailRequest, $resolvedMessage); 58 | $this->emailRequestLog->recordAttemptedDelivery($attemptedDelivery); 59 | 60 | } 61 | } -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Infrastructure/History/EmailDeliveryLog/DoctrineOrm/ParticipantType.php: -------------------------------------------------------------------------------- 1 | $value->getName(), 'email' => (string)$value->getEmailAddress()]; 24 | } 25 | 26 | public function convertToDatabaseValue($value, AbstractPlatform $platform) 27 | { 28 | return json_encode(self::fromObject($value)); 29 | } 30 | 31 | public function convertToPHPValue($value, AbstractPlatform $platform) 32 | { 33 | if ($value === null) { 34 | return null; 35 | } 36 | 37 | $data = json_decode($value, true); 38 | return self::toObject($data); 39 | } 40 | 41 | 42 | /** 43 | * Gets the SQL declaration snippet for a field of this type. 44 | * 45 | * @param array $fieldDeclaration The field declaration. 46 | * @param \Doctrine\DBAL\Platforms\AbstractPlatform $platform The currently used database platform. 47 | * 48 | * @return string 49 | */ 50 | public function getSQLDeclaration(array $fieldDeclaration, AbstractPlatform $platform) 51 | { 52 | return $platform->getClobTypeDeclarationSQL($fieldDeclaration); 53 | } 54 | 55 | /** 56 | * Gets the name of this type. 57 | * 58 | * @return string 59 | * 60 | * @todo Needed? 61 | */ 62 | public function getName() 63 | { 64 | return 'participant'; 65 | } 66 | } -------------------------------------------------------------------------------- /src/Outstack/Components/Framework/AppKernel.php: -------------------------------------------------------------------------------- 1 | import('config/routing.yml'); 37 | * $routes->add('/admin', 'AppBundle:Admin:dashboard', 'admin_dashboard'); 38 | * 39 | * @param RouteCollectionBuilder $routes 40 | */ 41 | protected function configureRoutes(RouteCollectionBuilder $routes) 42 | { 43 | // TODO: Implement configureRoutes() method. 44 | } 45 | 46 | /** 47 | * Configures the container. 48 | * 49 | * You can register extensions: 50 | * 51 | * $c->loadFromExtension('framework', array( 52 | * 'secret' => '%secret%' 53 | * )); 54 | * 55 | * Or services: 56 | * 57 | * $c->register('halloween', 'FooBundle\HalloweenProvider'); 58 | * 59 | * Or parameters: 60 | * 61 | * $c->setParameter('halloween', 'lot of fun'); 62 | * 63 | * @param ContainerBuilder $c 64 | * @param LoaderInterface $loader 65 | */ 66 | protected function configureContainer(ContainerBuilder $c, LoaderInterface $loader) 67 | { 68 | // TODO: Implement configureContainer() method. 69 | } 70 | } -------------------------------------------------------------------------------- /src/Outstack/Components/HttpInterop/Psr7/ServerEnvironmentRequestFactory.php: -------------------------------------------------------------------------------- 1 | server = $server; 35 | } 36 | 37 | public function createServerRequest(RequestInterface $request): ServerRequestInterface 38 | { 39 | $server = []; 40 | $get = []; 41 | $post = []; 42 | $cookies = []; 43 | $files = []; 44 | 45 | foreach ($request->getHeaders() as $key => $value) { 46 | $server[strtoupper($key)] = $value; 47 | } 48 | 49 | $server['HTTP_HOST'] = $request->getUri()->getHost(); 50 | $server['REQUEST_METHOD'] = $request->getMethod(); 51 | $server['QUERY_STRING'] = $request->getUri()->getQuery(); 52 | $server['REQUEST_URI'] = $request->getUri()->getPath(); 53 | 54 | parse_str($request->getUri()->getQuery(), $queryParams); 55 | foreach ($queryParams as $key => $value) { 56 | $get[$key] = $value; 57 | } 58 | 59 | return ServerRequestFactory::fromGlobals( 60 | array_merge($this->server, $server), 61 | $get, 62 | $post, 63 | $cookies, 64 | $files 65 | )->withBody($request->getBody()); 66 | } 67 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OutStack Enveloper 2 | 3 | [![Build Status](https://travis-ci.org/outstack/enveloper.svg?branch=master)](https://travis-ci.org/outstack/enveloper) 4 | [![Codacy Badge](https://api.codacy.com/project/badge/Grade/93720c538eac41c78502805bfa6c04d2)](https://www.codacy.com/app/outstack/enveloper?utm_source=github.com&utm_medium=referral&utm_content=outstack/enveloper&utm_campaign=badger) 5 | 6 | **We've not yet reached version 1. You're still encouraged to use it, but if you find something amiss let us know. If you're not sure about something, just ask! To keep updated when we release new things, follow us on [Twitter](https://twitter.com/_outstack)** 7 | 8 | ## What is it? 9 | 10 | Enveloper is a small service intended to be run in your infrastructure to speed up developing and testing transactional emails in your application. 11 | 12 | Enveloper GIF Demo 13 | 14 | Define your templates using template files and YAML, then send messages using simple API requests. 15 | 16 | See [Getting Started](./docs/01-getting-started.md) or browse the rest of this documentation. 17 | 18 | ## Main features 19 | 20 | - Simple setup with docker. 21 | - Configurable to send to any SMTP server, e.g. Mailgun, Mandrill, Amazon SES or your private email server 22 | - Attachment support 23 | - Simple API to send and see sent messages 24 | - Support for Twig, MJML out of the box, [easily extensible for other languages](./docs/04-advanced-templating.md). 25 | - Records sent messages into [Relational DB supported by Doctrine ORM](http://docs.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/platforms.html). 26 | 27 | ## Issues / Roadmap 28 | Enveloper is currently in a workable and useful state, with minimal features. Things on the roadmap can be found at https://github.com/outstack/enveloper/issues . Those marked [High Priority](https://github.com/outstack/enveloper/issues?q=is%3Aopen+is%3Aissue+label%3A%22High+Priority%22) are likely to be worked on first. 29 | 30 | Create an issue if there's something you'd like to see added, and feel free to tackle any already in the list. Any help is welcomed. 31 | 32 | ## Running the tests / developing Enveloper 33 | 34 | There is a helper script in the root of the project, `test.sh` which will run all the tests. Inspect this file to see how to run a subset of the tests. 35 | 36 | Prerequisites: 37 | 38 | - First you must download and install composer into the project root as `composer.phar`. Give it executable permissions. 39 | -------------------------------------------------------------------------------- /docs/04-advanced-templating.md: -------------------------------------------------------------------------------- 1 | # Advanced templating 2 | 3 | > **While Pipeprint integration is a new experimental feature, and for now Twig is still supported natively, it's likely 4 | that this will become the recommended and only option.** 5 | 6 | Developing robust emails that work well and have good support in a large amount of email clients is hard. 7 | 8 | A modern tool should provide enough tools to speed up this process, while also adapting to any existing workflows and 9 | in-house templates you may already have. 10 | 11 | When you want to move past simple Twig templates, you should consider using our integration with [Pipeprint](https://github.com/outstack/pipeprint). 12 | 13 | ## Configuration 14 | 15 | To setup a simple Twig and MJML workflow, we need to add a few docker containers to our setup. Using docker composer add these 16 | services: 17 | 18 | pipeprint: 19 | image: outstack/pipeprint 20 | environment: 21 | - 'PIPEPRINT_ENGINE_CONFIG={"twig": "http://twig", "mjml": "http://mjml"}' 22 | links: 23 | - twig 24 | - mjml 25 | twig: 26 | image: outstack/pipeprint-engine-twig 27 | restart: on-failure 28 | mjml: 29 | image: outstack/pipeprint-engine-mjml 30 | restart: on-failure 31 | 32 | and add the environment variable `ENVELOPER_PIPEPRINT_URL=http://pipeprint` to enable the integration. 33 | 34 | Setup your template - note the filenames, these are important. 35 | 36 | ``` 37 | # mjml-example.mjml.twig 38 | 39 | 40 | 41 | Hello, {{ name }} 42 | 43 | 44 | 45 | ``` 46 | 47 | ``` 48 | # mjml-example.meta.yml 49 | content: 50 | html: "mjml-example.mjml.twig" 51 | text: "mjml-example.text.twig" 52 | ``` 53 | 54 | ``` 55 | #mjml-example.text.twig 56 | Hello, {{ name }} 57 | ``` 58 | 59 | ## Using your favourite languages 60 | 61 | Enveloper resolves your templates based on their filename extensions. In `mjml-example.mjml.twig`, it passes your template 62 | through two pipeline stages. Twig first, then mjml. 63 | 64 | These correspond to engines as defined in the `PIPEPRINT_ENGINE_CONFIG` environment variable. 65 | 66 | In our config above, we have only these two engines available. Follow along with [Pipeprint](https://github.com/outstack/pipeprint) 67 | progress to know when new languages are available or to learn how to create your own. 68 | 69 | For backwards compatibility and readability, `.txt` and `.html` are ignored in your filenames. -------------------------------------------------------------------------------- /src/Outstack/Components/ApiProvider/ApiProblemDetails/ApiProblemBuilder.php: -------------------------------------------------------------------------------- 1 | responseFactory = $responseFactory; 25 | } 26 | 27 | public function setStatus(int $status) 28 | { 29 | $builder = clone $this; 30 | $builder->status = $status; 31 | return $builder; 32 | } 33 | 34 | public function setTitle(string $title) 35 | { 36 | $builder = clone $this; 37 | $builder->title = $title; 38 | return $builder; 39 | } 40 | 41 | public function setDetail(string $detail) 42 | { 43 | $builder = clone $this; 44 | $builder->detail = $detail; 45 | return $builder; 46 | } 47 | 48 | public function setType(string $type) 49 | { 50 | $builder = clone $this; 51 | $builder->type = $type; 52 | return $builder; 53 | } 54 | 55 | public function buildJsonResponse(): ResponseInterface 56 | { 57 | $problemData = [ 58 | 'title' => $this->title, 59 | 'status' => $this->status 60 | ]; 61 | if (!is_null($this->type)) { 62 | $problemData['type'] = $this->type; 63 | } 64 | if (!is_null($this->instance)) { 65 | $problemData['instance'] = $this->instance; 66 | } 67 | if (!is_null($this->detail)) { 68 | $problemData['detail'] = $this->detail; 69 | } 70 | 71 | foreach ($this->fields as $field => $data) { 72 | $problemData[$field] = $data; 73 | } 74 | 75 | return $this 76 | ->responseFactory 77 | ->createResponse( 78 | $this->status, 79 | null, 80 | [ 81 | 'Content-type' => 'application/problem+json' 82 | ], 83 | json_encode($problemData) 84 | ); 85 | } 86 | 87 | public function addField(string $field, ?array $data) 88 | { 89 | $builder = clone $this; 90 | $builder->fields[$field] = $data; 91 | return $builder; 92 | } 93 | } -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Infrastructure/Delivery/DeliveryMethod/SwiftMailer/SwiftMailerDeliveryMethod.php: -------------------------------------------------------------------------------- 1 | mailer = $mailer; 21 | } 22 | 23 | public function attemptDelivery(EmailRequest $emailRequest, Email $email) 24 | { 25 | $this->mailer->send( 26 | $this->convertToSwiftMessage($email) 27 | ); 28 | } 29 | 30 | private function convertToSwiftMessage(Email $message) 31 | { 32 | $swiftTo = $this->convertToSwiftRecipientArray($message->getTo()); 33 | $swiftCc = $this->convertToSwiftRecipientArray($message->getCc()); 34 | $swiftBcc = $this->convertToSwiftRecipientArray($message->getBcc()); 35 | $swiftFrom = $this->convertToSwiftRecipientArray(new ParticipantList([$message->getSender()])); 36 | 37 | $swiftMessage = (new \Swift_Message()) 38 | ->setSubject($message->getSubject()) 39 | ->setFrom($swiftFrom) 40 | ->setTo($swiftTo) 41 | ->setCc($swiftCc) 42 | ->setBcc($swiftBcc); 43 | 44 | foreach ($message->getAttachments() as $attachment) { 45 | $swiftMessage->attach( 46 | new \Swift_Attachment($attachment->getData(), $attachment->getFilename()) 47 | ); 48 | } 49 | $body = $swiftMessage->setBody($message->getHtml(), 'text/html'); 50 | if ($message->getText()) { 51 | $body->addPart($message->getText(), 'text/plain'); 52 | } 53 | 54 | return $swiftMessage; 55 | 56 | } 57 | 58 | private function convertToSwiftRecipientArray(ParticipantList $recipientList) 59 | { 60 | $swiftArray = []; 61 | foreach ($recipientList->getIterator() as $recipient) { 62 | 63 | if ($recipient->isNamed()) { 64 | $swiftArray[(string) $recipient->getEmailAddress()] = $recipient->getName(); 65 | continue; 66 | } 67 | $swiftArray[] = (string) $recipient->getEmailAddress(); 68 | 69 | } 70 | 71 | return $swiftArray; 72 | 73 | } 74 | } -------------------------------------------------------------------------------- /src/AppBundle/Controller/DeliveryAttemptController.php: -------------------------------------------------------------------------------- 1 | [] 20 | ]; 21 | 22 | $attempts = $this->emailDeliveryLog->findDeliveryAttempts($this->emailDeliveryLog->find($id)); 23 | 24 | foreach ($attempts as $deliveryAttempt) { 25 | $data->items[] = $this->serialiseAttemptedDelivery($deliveryAttempt); 26 | } 27 | return new JsonResponse($data, 200); 28 | } 29 | 30 | /** 31 | * @Route("/outbox/{id}/delivery_attempts/{index}", name="app.delivery_attempts.byIndex", requirements={"id"="[a-zA-Z0-9]{8}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{12}"}, methods={"GET"}) 32 | */ 33 | public function findDeliveryAttempt(string $id, int $index) 34 | { 35 | try { 36 | $attempt = $this->emailDeliveryLog->findDeliveryAttempt($id, $index); 37 | } catch (DeliveryAttemptNotFound $exception) { 38 | return $this->problemFactory 39 | ->createProblem(404, 'Not Found') 40 | ->setDetail("Delivery attempt $index for email $id was not found") 41 | ->buildJsonResponse(); 42 | 43 | } 44 | 45 | return new JsonResponse($this->serialiseAttemptedDelivery($attempt), 200); 46 | } 47 | 48 | /** 49 | * @Route("/outbox/{id}/delivery_attempts/{index}/content", name="app.delivery_attempts.view.content", requirements={"index"="\d+", "id"="[a-zA-Z0-9]{8}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{4}-[a-zA-Z0-9]{12}"}, methods={"GET"}) 50 | */ 51 | public function viewContentAction(Request $request, string $id, int $index) 52 | { 53 | try { 54 | return $this->serialiseMessageContentsNegotiatingType( 55 | $request, 56 | $this->emailDeliveryLog->findDeliveryAttempt($id, $index)->getResolvedMessage() 57 | ); 58 | } catch (DeliveryAttemptNotFound $exception) { 59 | return $this->problemFactory 60 | ->createProblem(404, 'Not Found') 61 | ->setDetail("Delivery attempt $index for email $id was not found") 62 | ->buildJsonResponse(); 63 | } 64 | } 65 | 66 | } -------------------------------------------------------------------------------- /docs/01-getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting started 2 | 3 | ## What you'll need 4 | 5 | The easiest way to run Enveloper is through docker. You'll download and run a small container serving as an API. 6 | 7 | You'll need SMTP details for sending the emails. If you don't have these you can get some for a domain you own through a service like [Mailgun](https://www.mailgun.com/). 8 | 9 | You'll also need to share a folder where Enveloper will look for your templates. 10 | 11 | ## Running the server 12 | 13 | Let's assume you've created a folder called `enveloper-data`. Run this to start the server 14 | 15 | docker run \ 16 | -d \ 17 | --name enveloper \ 18 | -v $(pwd)/enveloper-data:/app/data \ 19 | -e ENVELOPER_SMTP_HOST=smtp.mailgun.org \ 20 | -e ENVELOPER_SMTP_USER=postmaster@example.com \ 21 | -e ENVELOPER_SMTP_PASSWORD=password \ 22 | -e ENVELOPER_SMTP_PORT=1025 \ 23 | -e ENVELOPER_DEFAULT_SENDER_EMAIL=noreply@example.com \ 24 | -e ENVELOPER_DEFAULT_SENDER_NAME=Your\ App \ 25 | -e ENVELOPER_DB_DSN=sqlite:////app/data/enveloper.sqlite \ 26 | -e ENVELOPER_DEFAULT_TIMEZONE=Europe/London \ 27 | -p 8080:8080 \ 28 | outstack/enveloper 29 | 30 | If you haven't already done so, you will need to create the database and schema: 31 | 32 | docker exec -it enveloper /app/bin/console doctrine:database:create 33 | docker exec -it enveloper /app/bin/console doctrine:schema:create 34 | 35 | ## Sending your first email 36 | 37 | If you're following this guide for the first time, `enveloper-data` will be empty and you'll have no templates to use. 38 | You can copy one from these docs at `docs/examples/hello-world` into `enveloper-data/templates`. 39 | You should now have `enveloper-data/templates/hello-world/hello-world.meta.yml` 40 | 41 | Test you can access the API: 42 | 43 | curl http://localhost:8080/ 44 | 45 | 46 | Send your first email using curl, like this: 47 | 48 | curl http://localhost:8080/outbox \ 49 | -X POST \ 50 | -d '{"template":"hello-world","parameters":{"name":"Bob","email":"youremailaddresshere@example.com"}}' 51 | 52 | Now you can inspect your sent emails. This is useful in writing in your test-suite, for example: 53 | 54 | curl -X GET http://localhost:8080/outbox 55 | 56 | You can also preview the content of a rendered message without sending it, for example: 57 | 58 | curl http://localhost:8080/outbox/preview \ 59 | -X POST \ 60 | -d '{"template":"hello-world","parameters":{"name":"Bob","email":"youremailaddresshere@example.com"}}' \ 61 | -H 'Accept: text/html' 62 | 63 | or 64 | 65 | curl http://localhost:8080/outbox/preview \ 66 | -X POST \ 67 | -d '{"template":"hello-world","parameters":{"name":"Bob","email":"youremailaddresshere@example.com"}}' \ 68 | -H 'Accept: text/plain' 69 | -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Infrastructure/History/EmailDeliveryLog/DoctrineOrm/DoctrineOrmEmailDeliveryLog.php: -------------------------------------------------------------------------------- 1 | manager = $manager; 25 | } 26 | 27 | public function recordInitialRequest(EmailRequest $emailRequest) 28 | { 29 | $this->manager->persist($emailRequest); 30 | $this->manager->flush(); 31 | } 32 | 33 | /** 34 | * @return \Generator|EmailRequest[] 35 | */ 36 | public function listAll() 37 | { 38 | return $this->manager->getRepository(EmailRequest::class)->findAll(); 39 | } 40 | 41 | public function deleteAll(): void 42 | { 43 | $this->manager->createQuery("DELETE FROM " . EmailRequest::class)->execute(); 44 | } 45 | 46 | public function find(string $id): EmailRequest 47 | { 48 | $request = $this->manager->getRepository(EmailRequest::class)->find($id); 49 | if ($request === null) { 50 | throw new EmailRequestNotFound($id); 51 | } 52 | return $request; 53 | } 54 | 55 | public function recordAttemptedDelivery(AttemptedDelivery $attemptedDelivery) 56 | { 57 | $this->manager->persist($attemptedDelivery); 58 | $this->manager->flush(); 59 | } 60 | 61 | public function findDeliveryAttempts(EmailRequest $emailRequest) 62 | { 63 | return $this->manager->getRepository(AttemptedDelivery::class)->findBy(['emailRequest' => $emailRequest]); 64 | } 65 | 66 | public function countDeliveryAttempts($emailRequest): int 67 | { 68 | return \count($this->findDeliveryAttempts($emailRequest)); 69 | } 70 | 71 | /** 72 | * @throws DeliveryAttemptNotFound 73 | */ 74 | public function findDeliveryAttempt(string $id, int $index): AttemptedDelivery 75 | { 76 | $attempt = $this->manager 77 | ->getRepository(AttemptedDelivery::class) 78 | ->findOneBy(['emailRequest' => $id, 'attemptNumber' => $index]); 79 | 80 | if ($attempt === null) { 81 | throw new DeliveryAttemptNotFound($id, $index); 82 | } 83 | 84 | return $attempt; 85 | } 86 | } -------------------------------------------------------------------------------- /tests/Unit/Outstack/Enveloper/Resolution/AttachmentListResolverTest.php: -------------------------------------------------------------------------------- 1 | sut = new AttachmentListResolver( 23 | new AttachmentResolver($this->language) 24 | ); 25 | } 26 | 27 | public function test_resolves_template_with_iterated_value() 28 | { 29 | $this->assertEquals( 30 | new AttachmentList( 31 | [ 32 | new Attachment('attachment 1', 'a1.txt'), 33 | new Attachment('attachment 2', 'a2.txt'), 34 | ] 35 | ), 36 | $this->sut->resolveAttachmentList( 37 | new AttachmentListTemplate( 38 | [ 39 | new AttachmentTemplate(false, '{{ item.data }}', '{{ item.filename }}', 'attachments') 40 | ] 41 | ), 42 | (object) [ 43 | 'attachments' => [ 44 | ['data' => 'attachment 1', 'filename' => 'a1.txt'], 45 | ['data' => 'attachment 2', 'filename' => 'a2.txt'] 46 | ] 47 | ] 48 | ) 49 | ); 50 | } 51 | 52 | public function test_resolves_multiple_attachments() 53 | { 54 | $this->assertEquals( 55 | new AttachmentList( 56 | [ 57 | new Attachment('attachment 1', 'a1.txt'), 58 | new Attachment('attachment 2', 'a2.txt'), 59 | ] 60 | ), 61 | $this->sut->resolveAttachmentList( 62 | new AttachmentListTemplate( 63 | [ 64 | new AttachmentTemplate(false, '{{ attachments[0].data }}', '{{ attachments[0].filename }}'), 65 | new AttachmentTemplate(false, '{{ attachments[1].data }}', '{{ attachments[1].filename }}') 66 | ] 67 | ), 68 | (object) [ 69 | 'attachments' => [ 70 | ['data' => 'attachment 1', 'filename' => 'a1.txt'], 71 | ['data' => 'attachment 2', 'filename' => 'a2.txt'] 72 | ] 73 | ] 74 | ) 75 | ); 76 | } 77 | 78 | } -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Domain/Email/Email.php: -------------------------------------------------------------------------------- 1 | id = $id; 62 | $this->subject = $subject; 63 | $this->sender = $sender; 64 | $this->text = $text; 65 | $this->to = $to; 66 | $this->cc = $cc; 67 | $this->bcc = $bcc; 68 | $this->html = $html; 69 | $this->attachments = $attachments; 70 | } 71 | 72 | /** 73 | * @return ParticipantList|Participant[] 74 | */ 75 | public function getTo(): ParticipantList 76 | { 77 | return $this->to; 78 | } 79 | 80 | /** 81 | * @return ParticipantList|Participant[] 82 | */ 83 | public function getCc(): ParticipantList 84 | { 85 | return $this->cc; 86 | } 87 | 88 | /** 89 | * @return ParticipantList|Participant[] 90 | */ 91 | public function getBcc(): ParticipantList 92 | { 93 | return $this->bcc; 94 | } 95 | 96 | public function getId(): string 97 | { 98 | return $this->id; 99 | } 100 | 101 | public function getSubject(): string 102 | { 103 | return $this->subject; 104 | } 105 | 106 | public function getSender(): Participant 107 | { 108 | return $this->sender; 109 | } 110 | 111 | public function getText(): ?string 112 | { 113 | return $this->text; 114 | } 115 | 116 | public function getHtml(): string 117 | { 118 | return $this->html; 119 | } 120 | 121 | public function getAttachments(): AttachmentList 122 | { 123 | return $this->attachments; 124 | } 125 | } -------------------------------------------------------------------------------- /infrastructure/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | 2 | # Set number of worker processes automatically based on number of CPU cores. 3 | worker_processes auto; 4 | 5 | # Enables the use of JIT for regular expressions to speed-up their processing. 6 | pcre_jit on; 7 | 8 | # Configures default error logger. 9 | error_log /var/log/nginx/error.log warn; 10 | 11 | # Includes files with directives to load dynamic modules. 12 | include /etc/nginx/modules/*.conf; 13 | 14 | 15 | events { 16 | # The maximum number of simultaneous connections that can be opened by 17 | # a worker process. 18 | worker_connections 1024; 19 | } 20 | 21 | http { 22 | # Includes mapping of file name extensions to MIME types of responses 23 | # and defines the default type. 24 | include /etc/nginx/mime.types; 25 | default_type application/octet-stream; 26 | 27 | # Name servers used to resolve names of upstream servers into addresses. 28 | # It's also needed when using tcpsocket and udpsocket in Lua modules. 29 | #resolver 208.67.222.222 208.67.220.220; 30 | 31 | # Don't tell nginx version to clients. 32 | server_tokens off; 33 | 34 | # Specifies the maximum accepted body size of a client request, as 35 | # indicated by the request header Content-Length. If the stated content 36 | # length is greater than this size, then the client receives the HTTP 37 | # error code 413. Set to 0 to disable. 38 | client_max_body_size 1m; 39 | 40 | # Timeout for keep-alive connections. Server will close connections after 41 | # this time. 42 | keepalive_timeout 65; 43 | 44 | # Sendfile copies data between one FD and other from within the kernel, 45 | # which is more efficient than read() + write(). 46 | sendfile on; 47 | 48 | # Don't buffer data-sends (disable Nagle algorithm). 49 | # Good for sending frequent small bursts of data in real time. 50 | tcp_nodelay on; 51 | 52 | # Causes nginx to attempt to send its HTTP response head in one packet, 53 | # instead of using partial frames. 54 | #tcp_nopush on; 55 | 56 | 57 | # Path of the file with Diffie-Hellman parameters for EDH ciphers. 58 | #ssl_dhparam /etc/ssl/nginx/dh2048.pem; 59 | 60 | # Specifies that our cipher suits should be preferred over client ciphers. 61 | ssl_prefer_server_ciphers on; 62 | 63 | # Enables a shared SSL cache with size that can hold around 8000 sessions. 64 | ssl_session_cache shared:SSL:2m; 65 | 66 | 67 | # Enable gzipping of responses. 68 | #gzip on; 69 | 70 | # Set the Vary HTTP header as defined in the RFC 2616. 71 | gzip_vary on; 72 | 73 | # Enable checking the existence of precompressed files. 74 | #gzip_static on; 75 | 76 | 77 | # Specifies the main log format. 78 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 79 | '$status $body_bytes_sent "$http_referer" ' 80 | '"$http_user_agent" "$http_x_forwarded_for"'; 81 | 82 | # Sets the path, format, and configuration for a buffered log write. 83 | access_log /var/log/nginx/access.log main; 84 | 85 | 86 | # Includes virtual hosts configs. 87 | include /etc/nginx/conf.d/*.conf; 88 | } -------------------------------------------------------------------------------- /tests/Unit/Outstack/Enveloper/Resolution/RecipientListResolverTest.php: -------------------------------------------------------------------------------- 1 | sut = new ParticipantListResolver(new ParticipantResolver($this->language)); 24 | } 25 | 26 | public function test_resolves_single_recipient() 27 | { 28 | $this->assertEquals( 29 | new ParticipantList( 30 | [ 31 | new Participant(null, new EmailAddress('admin1@example.com')), 32 | ] 33 | ), 34 | 35 | $this->sut->resolveParticipantList( 36 | 37 | new ParticipantListTemplate( 38 | [ 39 | new ParticipantTemplate(null, 'admin{{ number }}@{{ domain}}') 40 | ] 41 | ), 42 | (object) [ 43 | 'number' => 1, 44 | 'domain' => 'example.com' 45 | ] 46 | ) 47 | ); 48 | } 49 | public function test_resolves_mixed_single_and_iterated_recipient() 50 | { 51 | $this->assertEquals( 52 | new ParticipantList( 53 | [ 54 | new Participant(null, new EmailAddress('admin1@example.com')), 55 | new Participant('Admin 2', new EmailAddress('admin2@example.com')), 56 | new Participant('Admin 3', new EmailAddress('admin3@example.com')), 57 | ] 58 | ), 59 | 60 | $this->sut->resolveParticipantList( 61 | 62 | new ParticipantListTemplate( 63 | [ 64 | new ParticipantTemplate(null, 'admin{{ number }}@{{ domain}}'), 65 | new ParticipantTemplate('{{ item.name }}', '{{ item.email }}', 'administrators') 66 | ] 67 | ), 68 | (object) [ 69 | 'number' => 1, 70 | 'domain' => 'example.com', 71 | 'administrators' => [ 72 | ['name' => 'Admin 2', 'email' => 'admin2@example.com'], 73 | ['name' => 'Admin 3', 'email' => 'admin3@example.com'], 74 | ] 75 | ] 76 | ) 77 | ); 78 | } 79 | } -------------------------------------------------------------------------------- /src/Outstack/Components/SymfonyKernelHttpClient/SymfonyKernelHttpClient.php: -------------------------------------------------------------------------------- 1 | kernel = $kernel; 41 | $this->httpFoundationFactory = $httpFoundationFactory; 42 | $this->httpMessageFactory = $httpMessageFactory; 43 | $this->serverRequestFactory = $serverRequestFactory; 44 | } 45 | 46 | /** 47 | * Sends a PSR-7 request. 48 | * 49 | * @param RequestInterface $psrRequest 50 | * 51 | * @return ResponseInterface 52 | * 53 | * @throws \Http\Client\Exception If an error happens during processing the request. 54 | * @throws \Exception If processing the request is impossible (eg. bad configuration). 55 | */ 56 | public function sendRequest(RequestInterface $psrRequest) 57 | { 58 | $serverRequest = $this->serverRequestFactory->createServerRequest($psrRequest); 59 | 60 | $foundationRequest = $this->httpFoundationFactory->createRequest( 61 | $serverRequest 62 | ); 63 | $foundationResponse = $this->kernel->handle($foundationRequest); 64 | 65 | if ($this->kernel instanceof Kernel) { 66 | $this->kernel->terminate($foundationRequest, $foundationResponse); 67 | $this->kernel->shutdown(); 68 | } 69 | 70 | $psrResponse = $this->httpMessageFactory->createResponse($foundationResponse); 71 | 72 | if (!$foundationResponse->isSuccessful()) { 73 | throw new HttpException( 74 | "Kernel returned non-successful status code {$foundationResponse->getStatusCode()}", 75 | $psrRequest, 76 | $psrResponse 77 | ); 78 | } 79 | return $psrResponse; 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /src/AppBundle/Messenger/SpoolTransportEventSubscriber.php: -------------------------------------------------------------------------------- 1 | receiverLocator = $receiverLocator; 32 | $this->bus = $bus; 33 | $this->enveloperQueueDsn = $enveloperQueueDsn; 34 | } 35 | 36 | /** 37 | * Returns an array of event names this subscriber wants to listen to. 38 | * 39 | * The array keys are event names and the value can be: 40 | * 41 | * * The method name to call (priority defaults to 0) 42 | * * An array composed of the method name to call and the priority 43 | * * An array of arrays composed of the method names to call and respective 44 | * priorities, or 0 if unset 45 | * 46 | * For instance: 47 | * 48 | * * array('eventName' => 'methodName') 49 | * * array('eventName' => array('methodName', $priority)) 50 | * * array('eventName' => array(array('methodName1', $priority), array('methodName2'))) 51 | * 52 | * @return array The event names to listen to 53 | */ 54 | public static function getSubscribedEvents() 55 | { 56 | return [ 57 | KernelEvents::TERMINATE => 'onKernelTerminate' 58 | ]; 59 | } 60 | 61 | public function onKernelTerminate(PostResponseEvent $event) 62 | { 63 | if ($this->enveloperQueueDsn === 'spool://memory') { 64 | 65 | $stopWhenFinishedEventDispatcher = new EventDispatcher(); 66 | 67 | 68 | $worker = new Worker( 69 | [ 70 | 'email_queue' => $this->receiverLocator->get('email_queue') 71 | ], 72 | $this->bus, 73 | $stopWhenFinishedEventDispatcher 74 | ); 75 | 76 | $stopWhenFinishedEventDispatcher->addListener( 77 | WorkerRunningEvent::class, 78 | static function (WorkerRunningEvent $event) use ($worker) { 79 | if ($event->isWorkerIdle()) { 80 | $worker->stop(); 81 | } 82 | } 83 | ); 84 | $worker->run(); 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Infrastructure/Resolution/TemplatePipeline/Pipeprint/PipeprintPipeline.php: -------------------------------------------------------------------------------- 1 | pipeprintUrl = $pipeprintUrl; 25 | $this->templateFilesystem = $templateFilesystem; 26 | } 27 | 28 | public function render(string $templateName, string $templateContents, object $parameters): string 29 | { 30 | $pipeline = []; 31 | $parts = explode('.', $templateName); 32 | foreach (array_reverse(array_slice($parts, 1)) as $part) { 33 | if (in_array($part, $this->extensionsWithoutSemantics)) { 34 | continue; 35 | } 36 | $pipeline[] = ['engine' => $part]; 37 | } 38 | 39 | $pipeline[0]['template'] = "template/$templateName"; 40 | 41 | $files = [ 42 | "template/$templateName" => $templateContents 43 | ]; 44 | foreach ($this->templateFilesystem->listContents('./_includes/') as ['path' => $include]) { 45 | $files[$include] = $this->templateFilesystem->read($include); 46 | } 47 | 48 | $pipeprintRequest = json_encode( 49 | [ 50 | 'files' => $files, 51 | 'pipeline' => $pipeline, 52 | 'parameters' => $parameters 53 | ] 54 | ); 55 | 56 | $curl = curl_init(); 57 | 58 | curl_setopt_array($curl, array( 59 | CURLOPT_URL => "{$this->pipeprintUrl}/render/pipeline", 60 | CURLOPT_RETURNTRANSFER => true, 61 | CURLOPT_ENCODING => "", 62 | CURLOPT_MAXREDIRS => 5, 63 | CURLOPT_TIMEOUT => 5, 64 | CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, 65 | CURLOPT_CUSTOMREQUEST => "POST", 66 | CURLOPT_POSTFIELDS => $pipeprintRequest, 67 | CURLOPT_HTTPHEADER => array( 68 | "content-type: application/json" 69 | ), 70 | )); 71 | 72 | $response = curl_exec($curl); 73 | $err = curl_error($curl); 74 | $statusCode = curl_getinfo($curl, CURLINFO_HTTP_CODE); 75 | $contentType = curl_getinfo($curl, CURLINFO_CONTENT_TYPE); 76 | 77 | curl_close($curl); 78 | 79 | if ($err || $statusCode < 200 || $statusCode > 299) { 80 | $errorData = null; 81 | if ($contentType == 'application/problem+json') { 82 | $errorData = json_decode($response, true); 83 | } 84 | throw new PipelineFailed($err, $errorData); 85 | } 86 | return $response; 87 | } 88 | } -------------------------------------------------------------------------------- /tests/Functional/AbstractApiTestCase.php: -------------------------------------------------------------------------------- 1 | dereference("file://{$projectDir}/schemata/$schema"); 33 | 34 | $validator = new \League\JsonGuard\Validator($document, $schema); 35 | 36 | $this->assertFalse( 37 | $validator->fails(), 38 | implode( 39 | "\n", 40 | array_map( 41 | function(ValidationError $error) { 42 | return $error->getDataPath() . ": " . $error->getMessage() . json_encode($error->getData()); 43 | }, 44 | $validator->errors() 45 | ) 46 | ) 47 | ); 48 | } 49 | 50 | 51 | public function setUp() 52 | { 53 | parent::setUp(); 54 | 55 | self::$kernel = static::createKernel(['environment' => 'test']); 56 | self::$kernel->boot(); 57 | 58 | $this->client = new SymfonyKernelHttpClient( 59 | self::$kernel, 60 | new HttpFoundationFactory(), 61 | new DiactorosFactory(), 62 | new ServerEnvironmentRequestFactory([]) 63 | ); 64 | 65 | $dbFile = self::$kernel->getRootDir() . '/../tests/data/enveloper_test.sqlite'; 66 | if (file_exists($dbFile)) { 67 | unlink($dbFile); 68 | } 69 | 70 | $this->executeConsoleCommand("doctrine:database:create -v"); 71 | $this->executeConsoleCommand("doctrine:schema:create -v"); 72 | 73 | 74 | } 75 | 76 | protected function executeConsoleCommand($cmd) 77 | { 78 | $application = new Application(self::$kernel); 79 | $application->setAutoExit(false); 80 | $application->setCatchExceptions(false); 81 | $application->run(new StringInput("$cmd --env=test --no-interaction"), new NullOutput()); 82 | } 83 | 84 | protected function convertToStream(string $str) 85 | { 86 | $stream = fopen("php://temp", 'r+'); 87 | fputs($stream, $str); 88 | rewind($stream); 89 | return $stream; 90 | } 91 | 92 | } -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Domain/Resolution/Templates/Template.php: -------------------------------------------------------------------------------- 1 | subject = $subject; 67 | $this->recipientsTo = $recipientsTo; 68 | $this->recipientsCc = $recipientsCc; 69 | $this->recipientsBcc = $recipientsBcc; 70 | $this->text = $text; 71 | $this->textTemplateName = $textTemplateName; 72 | $this->html = $html; 73 | $this->htmlTemplateName = $htmlTemplateName; 74 | $this->sender = $sender; 75 | $this->attachments = $attachments; 76 | $this->schema = $schema; 77 | } 78 | 79 | public function getSchema(): ?object 80 | { 81 | return $this->schema; 82 | } 83 | 84 | /** 85 | * @return null|string 86 | */ 87 | public function getTextTemplateName() 88 | { 89 | return $this->textTemplateName; 90 | } 91 | 92 | /** 93 | * @return string 94 | */ 95 | public function getHtmlTemplateName(): string 96 | { 97 | return $this->htmlTemplateName; 98 | } 99 | 100 | public function getSubject(): string 101 | { 102 | return $this->subject; 103 | } 104 | 105 | public function getSender(): ?ParticipantTemplate 106 | { 107 | return $this->sender; 108 | } 109 | 110 | public function getRecipientsTo(): ParticipantListTemplate 111 | { 112 | return $this->recipientsTo; 113 | } 114 | 115 | public function getRecipientsCc(): ParticipantListTemplate 116 | { 117 | return $this->recipientsCc; 118 | } 119 | 120 | public function getRecipientsBcc(): ParticipantListTemplate 121 | { 122 | return $this->recipientsBcc; 123 | } 124 | 125 | public function getText(): ?string 126 | { 127 | return $this->text; 128 | } 129 | 130 | public function getHtml(): string 131 | { 132 | return $this->html; 133 | } 134 | 135 | public function getAttachments(): AttachmentListTemplate 136 | { 137 | return $this->attachments; 138 | } 139 | } -------------------------------------------------------------------------------- /app/config/config.yml: -------------------------------------------------------------------------------- 1 | imports: 2 | - { resource: security.yml } 3 | - { resource: services.yml } 4 | 5 | # Put parameters here that don't need to change on each machine where the app is deployed 6 | # http://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration 7 | parameters: 8 | env(SMTP_ENCRYPTION): ~ 9 | locale: en 10 | template_directory: "/app/data/templates" 11 | 12 | secret: ThisTokenIsNotSoSecretChangeIt 13 | 14 | default_sender_email: "%env(ENVELOPER_DEFAULT_SENDER_EMAIL)%" 15 | default_sender_name: "%env(ENVELOPER_DEFAULT_SENDER_NAME)%" 16 | mailer_host: '%env(ENVELOPER_SMTP_HOST)%' 17 | mailer_username: '%env(ENVELOPER_SMTP_USER)%' 18 | mailer_port: '%env(ENVELOPER_SMTP_PORT)%' 19 | mailer_password: '%env(ENVELOPER_SMTP_PASSWORD)%' 20 | mailer_encryption: '%env(SMTP_ENCRYPTION)%' 21 | mailer_record_messages: false 22 | mailer_deliver_messages: true 23 | env(ENVELOPER_DB_DSN): "sqlite:////app/data/enveloper.sqlite" 24 | pipeprint_url: '%env(ENVELOPER_PIPEPRINT_URL)%' 25 | env(ENVELOPER_PIPEPRINT_URL): ~ 26 | env(ENVELOPER_QUEUE_DSN): 'spool://memory' 27 | env(ENVELOPER_DEFAULT_TIMEZONE): 'UTC' 28 | 29 | 30 | monolog: 31 | handlers: 32 | main: 33 | type: stream 34 | path: "php://stdout" 35 | level: debug 36 | channels: ['!event'] 37 | console: 38 | type: console 39 | channels: ['!event', '!doctrine'] 40 | 41 | framework: 42 | #esi: ~ 43 | #translator: { fallbacks: ['%locale%'] } 44 | secret: '%secret%' 45 | router: 46 | resource: '%kernel.root_dir%/config/routing.yml' 47 | strict_requirements: ~ 48 | form: ~ 49 | csrf_protection: ~ 50 | validation: { enable_annotations: true } 51 | #serializer: { enable_annotations: true } 52 | templating: 53 | engines: ['twig'] 54 | default_locale: '%locale%' 55 | trusted_hosts: ~ 56 | session: 57 | # http://symfony.com/doc/current/reference/configuration/framework.html#handler-id 58 | handler_id: session.handler.native_file 59 | save_path: "%kernel.root_dir%/../var/sessions/%kernel.environment%" 60 | fragments: ~ 61 | http_method_override: true 62 | assets: ~ 63 | php_errors: 64 | log: true 65 | messenger: 66 | transports: 67 | email_queue: "%env(ENVELOPER_QUEUE_DSN)%" 68 | routing: 69 | Outstack\Enveloper\Domain\Email\EmailRequest: email_queue 70 | 71 | sensio_framework_extra: 72 | router: 73 | annotations: false 74 | 75 | doctrine: 76 | dbal: 77 | url: "%env(ENVELOPER_DB_DSN)%" 78 | types: 79 | participant: 'Outstack\Enveloper\Infrastructure\History\EmailDeliveryLog\DoctrineOrm\ParticipantType' 80 | participant_list: 'Outstack\Enveloper\Infrastructure\History\EmailDeliveryLog\DoctrineOrm\ParticipantListType' 81 | 82 | orm: 83 | auto_generate_proxy_classes: '%kernel.debug%' 84 | naming_strategy: doctrine.orm.naming_strategy.underscore 85 | auto_mapping: true 86 | mappings: 87 | Outstack\Enveloper\Domain: 88 | type: yml 89 | dir: '%kernel.root_dir%/config/doctrine/orm' 90 | alias: ~ 91 | prefix: Outstack\Enveloper\Domain 92 | 93 | # Twig Configuration 94 | twig: 95 | debug: '%kernel.debug%' 96 | strict_variables: '%kernel.debug%' 97 | exception_controller: AppBundle:Error:showException 98 | date: 99 | timezone: "%env(ENVELOPER_DEFAULT_TIMEZONE)%" 100 | -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Domain/Resolution/MessageResolver.php: -------------------------------------------------------------------------------- 1 | language = $language; 53 | $this->pipeline = $pipeline; 54 | $this->recipientListResolver = $recipientListResolver; 55 | $this->recipientResolver = $recipientResolver; 56 | $this->defaultSenderEmail = $defaultSenderEmail; 57 | $this->defaultSenderName = $defaultSenderName; 58 | $this->attachmentListResolver = $attachmentListResolver; 59 | } 60 | 61 | public function resolve(Template $template, object $parameters): Email 62 | { 63 | $this->validate($template, $parameters); 64 | 65 | if (is_null($template->getSender())) { 66 | $from = new Participant($this->defaultSenderName, new EmailAddress($this->defaultSenderEmail)); 67 | } else { 68 | $from = $this->recipientResolver->resolveRecipient($template->getSender(), $parameters); 69 | } 70 | 71 | return new Email( 72 | uniqid('', false), 73 | $this->language->render($template->getSubject(), $parameters), 74 | $from, 75 | $this->recipientListResolver->resolveParticipantList($template->getRecipientsTo(), $parameters), 76 | $this->recipientListResolver->resolveParticipantList($template->getRecipientsCc(), $parameters), 77 | $this->recipientListResolver->resolveParticipantList($template->getRecipientsBcc(), $parameters), 78 | $template->getTextTemplateName() ? $this->pipeline->render($template->getTextTemplateName(), $template->getText(), $parameters) : null, 79 | $this->pipeline->render($template->getHtmlTemplateName(), $template->getHtml(), $parameters), 80 | $this->attachmentListResolver->resolveAttachmentList($template->getAttachments(), $parameters) 81 | ); 82 | } 83 | 84 | public function validate(Template $template, object $parameters): void 85 | { 86 | if (!is_null($template->getSchema())) { 87 | $dereferencer = \League\JsonReference\Dereferencer::draft6(); 88 | $schema = $dereferencer->dereference($template->getSchema()); 89 | 90 | $validator = new \League\JsonGuard\Validator($parameters, $schema); 91 | if ($validator->fails()) { 92 | throw new ParametersFailedSchemaValidation($validator->errors()); 93 | } 94 | 95 | } 96 | } 97 | } -------------------------------------------------------------------------------- /tests/Functional/MessageHistoryFunctionalTest.php: -------------------------------------------------------------------------------- 1 | client->sendRequest( 17 | new Request( 18 | "{$this->baseUri}/outbox", 19 | 'POST', 20 | $this->convertToStream(json_encode([ 21 | 'template' => 'simplest-test-message', 22 | 'parameters' => [ 23 | 'name' => 'Bob', 24 | 'email' => 'bob@example.com' 25 | ] 26 | ])) 27 | ) 28 | ); 29 | 30 | $response = $this->client->sendRequest( 31 | new Request( 32 | "{$this->baseUri}/outbox", 33 | 'GET', 34 | $this->convertToStream('') 35 | ) 36 | ); 37 | 38 | $this->assertSame(200, $response->getStatusCode()); 39 | 40 | $actual = json_decode((string)$response->getBody()); 41 | 42 | $this->assertJsonDocumentMatchesSchema($actual, 'endpoints/outbox/get.responseBody.schema.json'); 43 | $this->assertCount(1, $actual->items); 44 | $latestEmailRequest = $actual->items[0]; 45 | $this->assertSame('simplest-test-message', $latestEmailRequest->template); 46 | $this->assertEquals( 47 | (object) [ 48 | 'name' => 'Bob', 49 | 'email' => 'bob@example.com' 50 | ], 51 | $latestEmailRequest->parameters 52 | ); 53 | 54 | $deliveryAttempts = json_decode((string) $this->client->sendRequest( 55 | new Request( 56 | "{$latestEmailRequest->deliveryAttempts}", 57 | 'GET', 58 | $this->convertToStream('') 59 | ) 60 | )->getBody()); 61 | 62 | $this->assertJsonDocumentMatchesSchema($deliveryAttempts, 'endpoints/outbox/deliveryAttempts/get.responseBody.schema.json'); 63 | $this->assertCount(1, $deliveryAttempts->items); 64 | 65 | $this->client->sendRequest( 66 | new Request( 67 | $deliveryAttempts->items[0]->{'@id'}, 68 | 'GET', 69 | $this->convertToStream('') 70 | ) 71 | ); 72 | 73 | $resolved = $deliveryAttempts->items[0]->resolved; 74 | 75 | $this->assertSame('Hello, Bob', $resolved->subject); 76 | 77 | $this->assertEquals( 78 | ' 79 | 80 |

Hello, Bob

81 | 82 | ', 83 | $this->client->sendRequest( 84 | new Request( 85 | $resolved->content->{'@id'}, 86 | 'GET', 87 | $this->convertToStream(''), 88 | ['HTTP_ACCEPT' => 'text/html'] 89 | ) 90 | )->getBody()->__toString() 91 | ); 92 | 93 | $this->client->sendRequest( 94 | new Request( 95 | "{$this->baseUri}/outbox", 96 | 'DELETE', 97 | $this->convertToStream('') 98 | ) 99 | ); 100 | 101 | $response = $this->client->sendRequest( 102 | new Request( 103 | "{$this->baseUri}/outbox", 104 | 'GET', 105 | $this->convertToStream('') 106 | ) 107 | ); 108 | 109 | $this->assertSame(200, $response->getStatusCode()); 110 | $this->assertEquals( 111 | (object) ['items' => []], 112 | json_decode((string) $response->getBody()) 113 | ); 114 | 115 | } 116 | } -------------------------------------------------------------------------------- /app/config/services.yml: -------------------------------------------------------------------------------- 1 | # Learn more about services, parameters and containers at 2 | # http://symfony.com/doc/current/service_container.html 3 | parameters: 4 | #parameter_name: value 5 | 6 | services: 7 | 8 | # default configuration for services in *this* file 9 | _defaults: 10 | # automatically injects dependencies in your services 11 | autowire: true 12 | # automatically registers your services as commands, event subscribers, etc. 13 | autoconfigure: true 14 | # this means you cannot fetch services directly from the container via $container->get() 15 | # if you need to do this, you can override this setting on individual services 16 | public: false 17 | bind: 18 | $projectDir: '%kernel.project_dir%' 19 | $defaultSenderEmail: '%default_sender_email%' 20 | $defaultSenderName: '%default_sender_name%' 21 | 22 | # makes classes in src/AppBundle available to be used as services 23 | # this creates a service per class whose id is the fully-qualified class name 24 | AppBundle\: 25 | resource: '../../src/AppBundle/*' 26 | # you can exclude directories or files 27 | # but if a service is unused, it's removed anyway 28 | exclude: '../../src/AppBundle/{Entity,Repository,Tests}' 29 | 30 | Outstack\: 31 | resource: '../../src/Outstack/*' 32 | 33 | # controllers are imported separately to make sure they're public 34 | # and have a tag that allows actions to type-hint services 35 | AppBundle\Controller\: 36 | resource: '../../src/AppBundle/Controller' 37 | public: true 38 | tags: ['controller.service_arguments'] 39 | 40 | Http\Message\ResponseFactory: '@Http\Message\MessageFactory\DiactorosMessageFactory' 41 | Http\Message\MessageFactory\DiactorosMessageFactory: ~ 42 | 43 | Outstack\Enveloper\Domain\Resolution\Templates\Pipeline\TemplatePipeline: 44 | factory: 'Outstack\Enveloper\Infrastructure\Resolution\TemplatePipeline\TemplatePipelineFactory:create' 45 | class: Outstack\Enveloper\Domain\Resolution\Templates\Pipeline\TemplatePipeline 46 | arguments: 47 | - '%pipeprint_url%' 48 | 49 | Outstack\Enveloper\Domain\Resolution\Templates\TemplateLoader: '@Outstack\Enveloper\Infrastructure\Resolution\TemplateLoader\Filesystem\FilesystemLoader' 50 | 51 | Outstack\Enveloper\Infrastructure\Resolution\TemplateLoader\Filesystem\FilesystemLoader: 52 | arguments: 53 | - '@enveloper.templates.template_loader.filesystem.flysystem' 54 | 55 | League\Flysystem\Filesystem: '@enveloper.templates.template_loader.filesystem.flysystem' 56 | enveloper.templates.template_loader.filesystem.flysystem: 57 | public: false 58 | class: League\Flysystem\Filesystem 59 | arguments: 60 | - '@enveloper.templates.template_loader.filesystem.flysystem.adapter' 61 | 62 | enveloper.templates.template_loader.filesystem.flysystem.adapter: 63 | public: false 64 | class: League\Flysystem\Adapter\Local 65 | arguments: 66 | - '%template_directory%' 67 | 68 | Outstack\Enveloper\Infrastructure\Delivery\DeliveryMethod\SwiftMailer\SwiftMailerFactory: 69 | arguments: 70 | $options: 71 | transport: 'smtp' 72 | record: '%mailer_record_messages%' 73 | host: '%mailer_host%' 74 | username: '%mailer_username%' 75 | port: '%mailer_port%' 76 | password: '%mailer_password%' 77 | encryption: '%mailer_encryption%' 78 | deliver_messages: '%mailer_deliver_messages%' 79 | 80 | Outstack\Enveloper\Infrastructure\Delivery\DeliveryMethod\SwiftMailer\SwiftMailerInterface: 81 | public: true 82 | factory: 'Outstack\Enveloper\Infrastructure\Delivery\DeliveryMethod\SwiftMailer\SwiftMailerFactory:create' 83 | 84 | Outstack\Enveloper\Outbox: ~ 85 | 86 | AppBundle\Messenger\SpoolTransportEventSubscriber: 87 | autowire: true 88 | arguments: 89 | $receiverLocator: '@messenger.receiver_locator' 90 | $enveloperQueueDsn: '%env(ENVELOPER_QUEUE_DSN)%' -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Infrastructure/Resolution/TemplateLoader/Filesystem/ConfigurationParser/TemplateConfiguration.php: -------------------------------------------------------------------------------- 1 | getRootNode() 19 | ->children() 20 | ->scalarNode('subject')->end() 21 | ->arrayNode('from') 22 | ->beforeNormalization() 23 | ->ifString() 24 | ->then(function($v) { return ['email' => $v]; }) 25 | ->end() 26 | ->children() 27 | ->scalarNode('name')->defaultNull()->end() 28 | ->scalarNode('email')->isRequired()->end() 29 | ->end() 30 | ->end() 31 | ->arrayNode('recipients') 32 | ->children() 33 | ->arrayNode('to') 34 | ->requiresAtLeastOneElement() 35 | ->prototype('array') 36 | ->beforeNormalization() 37 | ->ifString() 38 | ->then(function($v) { return ['email' => $v]; }) 39 | ->end() 40 | ->children() 41 | ->scalarNode('name')->defaultNull()->end() 42 | ->scalarNode('email')->isRequired()->end() 43 | ->scalarNode('iterateOver')->defaultNull()->end() 44 | ->end() 45 | ->end() 46 | ->end() 47 | ->arrayNode('cc') 48 | ->requiresAtLeastOneElement() 49 | ->prototype('array') 50 | ->beforeNormalization() 51 | ->ifString() 52 | ->then(function($v) { return ['email' => $v]; }) 53 | ->end() 54 | ->children() 55 | ->scalarNode('name')->defaultNull()->end() 56 | ->scalarNode('email')->isRequired()->end() 57 | ->scalarNode('iterateOver')->defaultNull()->end() 58 | ->end() 59 | ->end() 60 | ->end() 61 | ->arrayNode('bcc') 62 | ->requiresAtLeastOneElement() 63 | ->prototype('array') 64 | ->beforeNormalization() 65 | ->ifString() 66 | ->then(function($v) { return ['email' => $v]; }) 67 | ->end() 68 | ->children() 69 | ->scalarNode('name')->defaultNull()->end() 70 | ->scalarNode('email')->isRequired()->end() 71 | ->scalarNode('iterateOver')->defaultNull()->end() 72 | ->end() 73 | ->end() 74 | ->end() 75 | ->end() 76 | ->end() 77 | ->arrayNode('content') 78 | ->children() 79 | ->scalarNode('text') 80 | ->defaultNull() 81 | ->end() 82 | ->scalarNode('html')->end() 83 | ->end() 84 | ->end() 85 | ->arrayNode('attachments') 86 | ->prototype('array') 87 | ->children() 88 | ->scalarNode('source')->end() 89 | ->scalarNode('contents')->end() 90 | ->scalarNode('filename')->isRequired()->end() 91 | ->scalarNode('iterateOver')->defaultNull()->end() 92 | ->end() 93 | ->end() 94 | ->end() 95 | ->end() 96 | ; 97 | 98 | return $treeBuilder; 99 | 100 | } 101 | } -------------------------------------------------------------------------------- /docs/api/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@cloudflare/json-schema-walker@^0.1.1": 6 | version "0.1.1" 7 | resolved "https://registry.yarnpkg.com/@cloudflare/json-schema-walker/-/json-schema-walker-0.1.1.tgz#d1cc94065327b0b3800158db40cb78124a3476de" 8 | 9 | argparse@^1.0.7: 10 | version "1.0.10" 11 | resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" 12 | dependencies: 13 | sprintf-js "~1.0.2" 14 | 15 | balanced-match@^1.0.0: 16 | version "1.0.0" 17 | resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" 18 | 19 | brace-expansion@^1.1.7: 20 | version "1.1.11" 21 | resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" 22 | dependencies: 23 | balanced-match "^1.0.0" 24 | concat-map "0.0.1" 25 | 26 | call-me-maybe@^1.0.1: 27 | version "1.0.1" 28 | resolved "https://registry.yarnpkg.com/call-me-maybe/-/call-me-maybe-1.0.1.tgz#26d208ea89e37b5cbde60250a15f031c16a4d66b" 29 | 30 | concat-map@0.0.1: 31 | version "0.0.1" 32 | resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" 33 | 34 | debug@^3.1.0: 35 | version "3.1.0" 36 | resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261" 37 | dependencies: 38 | ms "2.0.0" 39 | 40 | esprima@^4.0.0: 41 | version "4.0.1" 42 | resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" 43 | 44 | format-util@^1.0.3: 45 | version "1.0.3" 46 | resolved "https://registry.yarnpkg.com/format-util/-/format-util-1.0.3.tgz#032dca4a116262a12c43f4c3ec8566416c5b2d95" 47 | 48 | fs.realpath@^1.0.0: 49 | version "1.0.0" 50 | resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" 51 | 52 | glob@^7.1.2: 53 | version "7.1.2" 54 | resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz#c19c9df9a028702d678612384a6552404c636d15" 55 | dependencies: 56 | fs.realpath "^1.0.0" 57 | inflight "^1.0.4" 58 | inherits "2" 59 | minimatch "^3.0.4" 60 | once "^1.3.0" 61 | path-is-absolute "^1.0.0" 62 | 63 | inflight@^1.0.4: 64 | version "1.0.6" 65 | resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" 66 | dependencies: 67 | once "^1.3.0" 68 | wrappy "1" 69 | 70 | inherits@2: 71 | version "2.0.3" 72 | resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" 73 | 74 | js-yaml@^3.12.0: 75 | version "3.13.1" 76 | resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847" 77 | dependencies: 78 | argparse "^1.0.7" 79 | esprima "^4.0.0" 80 | 81 | json-schema-ref-parser@^5.1.1: 82 | version "5.1.1" 83 | resolved "https://registry.yarnpkg.com/json-schema-ref-parser/-/json-schema-ref-parser-5.1.1.tgz#adfc84bbcd3ec41536ec9df5364734884afcf1c1" 84 | dependencies: 85 | call-me-maybe "^1.0.1" 86 | debug "^3.1.0" 87 | js-yaml "^3.12.0" 88 | ono "^4.0.5" 89 | 90 | json-schema-to-openapi-schema@^0.2.0: 91 | version "0.2.0" 92 | resolved "https://registry.yarnpkg.com/json-schema-to-openapi-schema/-/json-schema-to-openapi-schema-0.2.0.tgz#974b73d6850a7cec86a49b468428d806c1c6d9cc" 93 | dependencies: 94 | "@cloudflare/json-schema-walker" "^0.1.1" 95 | 96 | minimatch@^3.0.4: 97 | version "3.0.4" 98 | resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" 99 | dependencies: 100 | brace-expansion "^1.1.7" 101 | 102 | ms@2.0.0: 103 | version "2.0.0" 104 | resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" 105 | 106 | once@^1.3.0: 107 | version "1.4.0" 108 | resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" 109 | dependencies: 110 | wrappy "1" 111 | 112 | ono@^4.0.5: 113 | version "4.0.5" 114 | resolved "https://registry.yarnpkg.com/ono/-/ono-4.0.5.tgz#bc62740493a5c1c08b2c21e60cbb0e5c56ab7de2" 115 | dependencies: 116 | format-util "^1.0.3" 117 | 118 | path-is-absolute@^1.0.0: 119 | version "1.0.1" 120 | resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" 121 | 122 | sprintf-js@~1.0.2: 123 | version "1.0.3" 124 | resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" 125 | 126 | wrappy@1: 127 | version "1.0.2" 128 | resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" 129 | -------------------------------------------------------------------------------- /tests/Functional/MessagePreviewingFunctionalTest.php: -------------------------------------------------------------------------------- 1 | mailerSpy = self::$kernel->getContainer()->get(SwiftMailerInterface::class); 21 | } 22 | 23 | public function test_previewing_html_version() 24 | { 25 | $request = new Request( 26 | '/outbox/preview', 27 | 'POST', 28 | $this->convertToStream(json_encode([ 29 | 'template' => 'simplest-test-message', 30 | 'parameters' => [ 31 | 'name' => 'Bob', 32 | 'email' => 'bob@example.com' 33 | ] 34 | ])), 35 | [ 36 | 'HTTP_ACCEPT' => 'text/html' 37 | ] 38 | ); 39 | $response = $this->client->sendRequest($request); 40 | 41 | $expected = << 43 | 44 |

Hello, Bob

45 | 46 | 47 | HTML; 48 | 49 | $this->assertEquals(200, $response->getStatusCode()); 50 | $this->assertEquals($expected, $response->getBody()->__toString()); 51 | 52 | $this->assertCountSentMessages(0); 53 | } 54 | 55 | public function test_previewing_text_version() 56 | { 57 | $request = new Request( 58 | '/outbox/preview', 59 | 'POST', 60 | $this->convertToStream(json_encode([ 61 | 'template' => 'simplest-test-message', 62 | 'parameters' => [ 63 | 'name' => 'Bob', 64 | 'email' => 'bob@example.com' 65 | ] 66 | ])), 67 | [ 68 | 'HTTP_ACCEPT' => 'text/plain' 69 | ] 70 | ); 71 | $response = $this->client->sendRequest($request); 72 | 73 | $expected = "Hello, Bob"; 74 | 75 | $this->assertEquals(200, $response->getStatusCode()); 76 | $this->assertEquals($expected, $response->getBody()->__toString()); 77 | 78 | $this->assertCountSentMessages(0); 79 | } 80 | 81 | public function test_previewing_without_specifying_type() 82 | { 83 | $request = new Request( 84 | '/outbox/preview', 85 | 'POST', 86 | $this->convertToStream(json_encode([ 87 | 'template' => 'simplest-test-message', 88 | 'parameters' => [ 89 | 'name' => 'Bob', 90 | 'email' => 'bob@example.com' 91 | ] 92 | ])) 93 | ); 94 | $response = $this->client->sendRequest($request); 95 | 96 | $html = << 98 | 99 |

Hello, Bob

100 | 101 | 102 | HTML; 103 | $expected = ['text' => "Hello, Bob", 'html' => $html]; 104 | 105 | $this->assertEquals(200, $response->getStatusCode()); 106 | $this->assertEquals($expected, json_decode($response->getBody()->__toString(), true)); 107 | 108 | $this->assertCountSentMessages(0); 109 | } 110 | 111 | public function test_asking_for_unavailable_type_fails() 112 | { 113 | $request = new Request( 114 | '/outbox/preview', 115 | 'POST', 116 | $this->convertToStream(json_encode([ 117 | 'template' => 'without-text-version', 118 | 'parameters' => [ 119 | 'name' => 'Bob', 120 | 'email' => 'bob@example.com' 121 | ] 122 | ])), 123 | [ 124 | 'HTTP_ACCEPT' => 'text/plain' 125 | ] 126 | ); 127 | try { 128 | $this->client->sendRequest($request); 129 | } catch (HttpException $exception) { 130 | $this->assertEquals(406, $exception->getResponse()->getStatusCode()); 131 | $this->assertEquals( 132 | [ 133 | 'title' => 'Not Acceptable', 134 | 'status' => 406, 135 | 'detail' => 'No version of this email matching your Accept header could be found', 136 | 'availableContentTypes' => [ 137 | 'application/json', 138 | 'text/html' 139 | ] 140 | ], 141 | json_decode($exception->getResponse()->getBody(), true)); 142 | return; 143 | } 144 | 145 | throw new \LogicException("Expected HTTP exception but did not find one"); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /src/Outstack/Enveloper/Infrastructure/Resolution/TemplateLoader/Filesystem/FilesystemLoader.php: -------------------------------------------------------------------------------- 1 | filesystem = $filesystem; 30 | } 31 | 32 | public function find(string $name): Template 33 | { 34 | $configPath = "$name/$name.meta.yml"; 35 | $schemaPath = "$name/$name.schema.json"; 36 | 37 | try { 38 | $config = Yaml::parse( 39 | $this->filesystem->read($configPath) 40 | ); 41 | } catch (FileNotFoundException $e) { 42 | throw new TemplateNotFound($name); 43 | } catch (ParseException $e) { 44 | throw new InvalidConfigurationException("Failed parsing YAML at $configPath: {$e->getMessage()}", 0, $e); 45 | } 46 | 47 | $config = $this->normaliseConfig($config); 48 | 49 | $schema = null; 50 | if ($this->filesystem->has($schemaPath)) { 51 | $schema = json_decode($this->filesystem->read($schemaPath)); 52 | } 53 | 54 | $textTemplate = null; 55 | if (!is_null($config['content']['text'])) { 56 | $textTemplate = $this->filesystem->read("$name/{$config['content']['text']}"); 57 | } 58 | 59 | $htmlTemplate = $this->filesystem->read("$name/{$config['content']['html']}"); 60 | return new Template( 61 | $schema, 62 | $config['subject'], 63 | array_key_exists('from', $config) ? $this->parseRecipientTemplate($config['from']) : null, 64 | $this->parseRecipientListTemplate($config['recipients']['to']), 65 | $this->parseRecipientListTemplate($config['recipients']['cc']), 66 | $this->parseRecipientListTemplate($config['recipients']['bcc']), 67 | $config['content']['text'], 68 | $textTemplate, 69 | $config['content']['html'], 70 | $htmlTemplate, 71 | $this->parseAttachmentListTemplate($config['attachments'], $name) 72 | ); 73 | } 74 | 75 | private function parseAttachmentListTemplate(array $attachments, string $templateName) 76 | { 77 | return new AttachmentListTemplate( 78 | array_map( 79 | function($attachment) use ($templateName) { 80 | return $this->parseAttachmentTemplate($attachment, $templateName); 81 | }, 82 | $attachments) 83 | ); 84 | } 85 | 86 | private function parseAttachmentTemplate(array $template, string $templateName) 87 | { 88 | $static = false; 89 | if (!array_key_exists('content', $template) && array_key_exists('source', $template)) { 90 | $static = true; 91 | $template['contents'] = $this->filesystem->read("$templateName/{$template['source']}"); 92 | } 93 | return new AttachmentTemplate($static, $template['contents'], $template['filename'], $template['iterateOver'] ?? null); 94 | } 95 | 96 | private function parseRecipientListTemplate(array $recipients): ParticipantListTemplate 97 | { 98 | $templates = []; 99 | foreach ($recipients as $recipient) { 100 | $templates[] = $this->parseRecipientTemplate($recipient); 101 | } 102 | 103 | return new ParticipantListTemplate($templates); 104 | } 105 | 106 | private function parseRecipientTemplate(array $recipient): ParticipantTemplate 107 | { 108 | return new ParticipantTemplate($recipient['name'], $recipient['email'], $recipient['iterateOver'] ?? null); 109 | } 110 | 111 | private function normaliseConfig(array $config): array 112 | { 113 | $processor = new Processor(); 114 | $configNode = new TemplateConfiguration(); 115 | 116 | return $processor->processConfiguration($configNode, ['template' => $config]); 117 | } 118 | } -------------------------------------------------------------------------------- /docs/api/openapi.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | description: | 4 | 5 | # Introduction 6 | Enveloper is a small service intended to be run in your infrastucture to speed up developing and testing 7 | transactional emails in your application. 8 | 9 | This document describes the API, its endpoints and their structure. For information on getting started, 10 | take a look at the [project on github](https://github.com/outstack/enveloper) or the 11 | [getting started guide](https://github.com/outstack/enveloper/blob/master/docs/01-getting-started.md). 12 | 13 | # Authentication 14 | There is currently no authentication mechanism for Enveloper. You should either avoid exposing it publicly over 15 | the internet or place it behind an API gateway such as Tyk, Kong or Amazon API Gateway. 16 | 17 | version: 1.0.0 18 | title: Enveloper API 19 | contact: 20 | email: adamquaile@gmail.com 21 | url: https://enveloper.io 22 | license: 23 | name: No Licence 24 | url: 'https://github.com/outstack/enveloper/blob/master/LICENSE.md' 25 | externalDocs: 26 | description: View the Enveloper Github repository 27 | url: 'https://github.com/outstack/enveloper' 28 | tags: 29 | - name: Messages 30 | description: | 31 | A message is the result of a template and a set of parameters combined. 32 | paths: 33 | /outbox/preview: 34 | post: 35 | tags: 36 | - Messages 37 | summary: Preview message 38 | description: | 39 | Useful for testing how certain parameters will affect the layout of an email, or how changes to an email 40 | template will appear visually. 41 | requestBody: 42 | content: 43 | application/json: 44 | schema: 45 | $ref: './build/endpoints/outbox/preview/post.requestBody.schema.openapi' 46 | responses: 47 | '200': 48 | description: 'Preview successfully rendered' 49 | content: 50 | text/plain: 51 | schema: 52 | type: string 53 | example: | 54 | Your order has shipped 55 | ---------------------- 56 | 57 | Thanks for your order - it's on its way! 58 | text/html: 59 | schema: 60 | type: string 61 | example: | 62 |

Your order has shipped

63 |

Thanks for your order - it's on its way!

64 | application/json: 65 | schema: 66 | $ref: './build/endpoints/outbox/preview/post.responseBody.schema.openapi' 67 | '400': 68 | description: 'Bad Request' 69 | content: 70 | application/problem+json: 71 | schema: 72 | type: object 73 | discriminator: 74 | propertyName: title 75 | mapping: 76 | 'Syntax Error': '#/Components/schemas/syntax-error' 77 | 'Parameters failed JSON schema validation': '#/Components/schemas/failed-json-schema-validation' 78 | properties: 79 | title: 80 | description: Brief title for the error 81 | type: string 82 | '406': 83 | description: Not acceptable 84 | content: 85 | application/problem+json: 86 | schema: 87 | $ref: './build/resources/errors/not-acceptable.schema.openapi' 88 | '500': 89 | description: Server Error 90 | content: 91 | application/problem+json: 92 | schema: 93 | $ref: './build/resources/errors/server-error.schema.openapi' 94 | /outbox: 95 | post: 96 | tags: 97 | - Messages 98 | summary: Send message 99 | requestBody: 100 | content: 101 | application/json: 102 | schema: 103 | $ref: './build/endpoints/outbox/post.requestBody.schema.openapi' 104 | responses: 105 | '204': 106 | description: 'Message Sent' 107 | '400': 108 | description: Parameters failed JSON schema validation 109 | content: 110 | application/problem+json: 111 | schema: 112 | $ref: './build/resources/errors/failed-json-schema-validation.schema.openapi' 113 | '500': 114 | description: Server Error 115 | content: 116 | application/problem+json: 117 | schema: 118 | $ref: './build/resources/errors/server-error.schema.openapi' 119 | get: 120 | summary: List sent messages 121 | tags: 122 | - Messages 123 | responses: 124 | '200': 125 | description: 'List loaded successfully' 126 | content: 127 | application/json: 128 | schema: 129 | $ref: './build/endpoints/outbox/get.responseBody.schema.openapi' 130 | /outbox/{id}: 131 | get: 132 | operationId: getSentMessageById 133 | summary: Get message details 134 | tags: 135 | - Messages 136 | responses: 137 | '200': 138 | description: 'Message Found' 139 | content: 140 | application/json: 141 | schema: 142 | $ref: './build/endpoints/outbox/getSentMessageById.responseBody.schema.openapi' 143 | '404': 144 | description: 'No such message' 145 | servers: 146 | - url: 'https://enveloper.example.com' 147 | Components: 148 | schemas: 149 | syntax-error: { $ref: './build/resources/errors/syntax-error.schema.openapi' } 150 | failed-json-schema-validation: { $ref: './build/resources/errors/failed-json-schema-validation.schema.openapi' } 151 | -------------------------------------------------------------------------------- /tests/Functional/ErrorHandlingFunctionalTest.php: -------------------------------------------------------------------------------- 1 | client->sendRequest($request); 19 | } catch (HttpException $e) { 20 | 21 | $response = $e->getResponse(); 22 | $body = (string) $response->getBody(); 23 | 24 | $this->assertEquals(404, $response->getStatusCode()); 25 | $this->assertJson($body); 26 | $this->assertEquals([ 27 | 'title' => 'Not Found', 28 | 'detail' => 'No matching action was found to handle the request', 29 | 'status' => 404 30 | ], json_decode($body, true)); 31 | $this->assertEquals('application/problem+json', $response->getHeaderLine('Content-type')); 32 | 33 | return; 34 | } 35 | 36 | throw new \LogicException("Expected HttpException, none caught"); 37 | } 38 | 39 | public function test_server_error_is_nicely_formatted() 40 | { 41 | $request = new Request( 42 | '/errors/500', 43 | 'GET' 44 | ); 45 | 46 | try { 47 | $this->client->sendRequest($request); 48 | } catch (HttpException $e) { 49 | 50 | $response = $e->getResponse(); 51 | $body = (string) $response->getBody(); 52 | 53 | $this->assertEquals(500, $response->getStatusCode()); 54 | $this->assertJson($body); 55 | $this->assertEquals([ 56 | 'title' => 'Server Error', 57 | 'detail' => 'An unexpected error occurred', 58 | 'status' => 500 59 | ], json_decode($body, true)); 60 | $this->assertEquals('application/problem+json', $response->getHeaderLine('Content-type')); 61 | 62 | return; 63 | } 64 | 65 | throw new \LogicException("Expected HttpException, none caught"); 66 | } 67 | 68 | public function test_syntax_error_is_nicely_formatted() 69 | { 70 | $request = new Request( 71 | '/outbox', 72 | 'POST', 73 | $this->convertToStream(json_encode([ 74 | 'template-typo' => 'message-with-attachments', 75 | 'parameters' => [ 76 | 'email' => 'bob@example.com', 77 | 'attachments' => [ 78 | ['contents' => 'This is a note', 'filename' => 'note.txt'] 79 | ] 80 | ] 81 | ])) 82 | ); 83 | 84 | try { 85 | $this->client->sendRequest($request); 86 | } catch (HttpException $e) { 87 | 88 | $response = $e->getResponse(); 89 | $body = (string) $response->getBody(); 90 | 91 | $this->assertEquals(400, $response->getStatusCode()); 92 | $this->assertJson($body); 93 | $this->assertEquals([ 94 | 'title' => 'Syntax Error', 95 | 'detail' => 'Request failed JSON schema validation', 96 | 'status' => 400, 97 | 'errors' => [ 98 | [ 99 | 'error' => 'The object must contain the properties ["template"].', 100 | 'path' => '/required' 101 | ] 102 | ] 103 | ], json_decode($body, true)); 104 | $this->assertEquals('application/problem+json', $response->getHeaderLine('Content-type')); 105 | 106 | return; 107 | } 108 | 109 | throw new \LogicException("Expected HttpException, none caught"); 110 | 111 | } 112 | 113 | public function test_parameters_sent_to_template_are_validated_by_schema() 114 | { 115 | $request = new Request( 116 | '/outbox', 117 | 'POST', 118 | $this->convertToStream(json_encode([ 119 | 'template' => 'message-with-attachments', 120 | 'parameters' => [ 121 | 'email' => 'bob@example.com', 122 | 'attachments' => [ 123 | ['contents' => 'This is a note', 'filename' => ''] 124 | ] 125 | ] 126 | ])) 127 | ); 128 | 129 | try { 130 | $this->client->sendRequest($request); 131 | } catch (HttpException $e) { 132 | 133 | $response = $e->getResponse(); 134 | $body = (string) $response->getBody(); 135 | 136 | $this->assertEquals(400, $response->getStatusCode()); 137 | $this->assertJson($body); 138 | $this->assertEquals([ 139 | 'title' => 'Parameters failed JSON schema validation', 140 | 'detail' => 'A template was found but the parameters submitted to it do not validate against the configured JSON schema', 141 | 'status' => 400, 142 | 'errors' => [ 143 | [ 144 | 'error' => 'The string must be at least 1 characters long.', 145 | 'path' => '/properties/attachments/items/0/properties/filename/minLength' 146 | ] 147 | ] 148 | ], json_decode($body, true)); 149 | $this->assertEquals('application/problem+json', $response->getHeaderLine('Content-type')); 150 | 151 | return; 152 | } 153 | 154 | throw new \LogicException("Expected HttpException, none caught"); 155 | 156 | } 157 | } -------------------------------------------------------------------------------- /tests/Functional/AttachmentHandlingFunctionalTest.php: -------------------------------------------------------------------------------- 1 | mailerSpy = self::$kernel->getContainer()->get(SwiftMailerInterface::class); 20 | } 21 | 22 | public function test_attachments_sent() 23 | { 24 | $request = new Request( 25 | '/outbox', 26 | 'POST', 27 | $this->convertToStream(json_encode([ 28 | 'template' => 'message-with-attachments', 29 | 'parameters' => [ 30 | 'email' => 'bob@example.com', 31 | 'attachments' => [ 32 | ['contents' => base64_encode('This is a note'), 'filename' => 'note.txt'] 33 | ] 34 | ] 35 | ])) 36 | ); 37 | 38 | $response = $this->client->sendRequest($request); 39 | $this->assertEquals(204, $response->getStatusCode()); 40 | 41 | $this->assertCountSentMessages(1); 42 | $this->assertMessageSent( 43 | function(\Swift_Message $message) { 44 | $expectedContents = base64_encode('This is a note'); 45 | $expected = 46 | 'Content-Type: application/octet-stream; name=note.txt' . "\r\n" . 47 | 'Content-Transfer-Encoding: base64' . "\r\n" . 48 | 'Content-Disposition: attachment; filename=note.txt' . "\r\n" . "\r\n" . 49 | $expectedContents . "\r\n" . "\r\n" . 50 | '--' 51 | ; 52 | 53 | foreach (explode($message->getBoundary(), (string) $message) as $part) { 54 | if (trim($part) == trim($expected)) { 55 | return true; 56 | } 57 | } 58 | 59 | throw new \LogicException("No matching attachment found"); 60 | } 61 | ); 62 | } 63 | 64 | public function test_large_attachment_sent() 65 | { 66 | $largeAttachment = random_bytes(1048576 * 7); 67 | $request = new Request( 68 | '/outbox', 69 | 'POST', 70 | $this->convertToStream(json_encode([ 71 | 'template' => 'message-with-attachments', 72 | 'parameters' => [ 73 | 'email' => 'bob@example.com', 74 | 'attachments' => [ 75 | ['contents' => base64_encode($largeAttachment), 'filename' => 'random.txt'] 76 | ] 77 | ] 78 | ])) 79 | ); 80 | 81 | $response = $this->client->sendRequest($request); 82 | 83 | $this->assertEquals(204, $response->getStatusCode()); 84 | 85 | $this->assertCountSentMessages(1); 86 | $this->assertMessageSent( 87 | function(\Swift_Message $message) use ($largeAttachment) { 88 | $expectedContents = implode("\r\n", str_split(base64_encode($largeAttachment), 76)); 89 | $expected = 90 | 'Content-Type: application/octet-stream; name=random.txt' . "\r\n" . 91 | 'Content-Transfer-Encoding: base64' . "\r\n" . 92 | 'Content-Disposition: attachment; filename=random.txt' . "\r\n" . "\r\n" . 93 | $expectedContents . "\r\n" . "\r\n" . 94 | '--' 95 | ; 96 | 97 | foreach (explode($message->getBoundary(), (string) $message) as $part) { 98 | if (trim($part) == trim($expected)) { 99 | return true; 100 | } 101 | } 102 | 103 | throw new \LogicException("No matching attachment found"); 104 | } 105 | ); 106 | } 107 | 108 | public function test_static_attachment_sent() 109 | { 110 | $expectedAttachment = 'static attachment content'; 111 | $request = new Request( 112 | '/outbox', 113 | 'POST', 114 | $this->convertToStream(json_encode([ 115 | 'template' => 'message-with-static-attachments', 116 | 'parameters' => [ 117 | 'email' => 'bob@example.com' 118 | ] 119 | ])) 120 | ); 121 | 122 | $response = $this->client->sendRequest($request); 123 | 124 | $this->assertEquals(204, $response->getStatusCode()); 125 | 126 | $this->assertCountSentMessages(1); 127 | $this->assertMessageSent( 128 | function(\Swift_Message $message) use ($expectedAttachment) { 129 | $expectedContents = implode("\r\n", str_split(base64_encode($expectedAttachment), 76)); 130 | $expected = 131 | 'Content-Type: application/octet-stream; name=attachment.txt' . "\r\n" . 132 | 'Content-Transfer-Encoding: base64' . "\r\n" . 133 | 'Content-Disposition: attachment; filename=attachment.txt' . "\r\n" . "\r\n" . 134 | $expectedContents . "\r\n" . "\r\n" . 135 | '--' 136 | ; 137 | 138 | foreach (explode($message->getBoundary(), (string) $message) as $part) { 139 | if (trim($part) == trim($expected)) { 140 | return true; 141 | } 142 | } 143 | 144 | throw new \LogicException("No matching attachment found"); 145 | } 146 | ); 147 | } 148 | } --------------------------------------------------------------------------------