diff --git a/.gitattributes b/.gitattributes index ee50f57..6d32411 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,12 @@ -/tests export-ignore -/client/src export-ignore -/.gitattributes export-ignore +/tests export-ignore +/docs export-ignore +/client/src export-ignore +/.editorconfig export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.php-cs-fixer.dist.php export-ignore +/phpunit.xml.dist export-ignore +/.waratah export-ignore +/code-of-conduct.md export-ignore +/CONTRIBUTING.md export-ignore +/README.md export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..56dc775 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,13 @@ +name: CI + +on: + pull_request: null + +jobs: + Silverstripe: + name: 'Silverstripe (bundle)' + uses: nswdpc/ci-files/.github/workflows/silverstripe.yml@v-1 + PHPStan: + name: 'PHPStan (analyse)' + uses: nswdpc/ci-files/.github/workflows/phpstan.silverstripe.yml@v-1 + needs: Silverstripe diff --git a/.gitignore b/.gitignore index 8687317..9499621 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ /client/node_modules /vendor/ .DS_Store -.php_cs.cache +/.php-cs-fixer.cache +/public/ +/resources/ +/composer.lock +node_modules diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..f9b7107 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,21 @@ +in(__DIR__); + +$config = new PhpCsFixer\Config(); +return $config->setRules([ + '@PSR2' => true, + 'array_indentation' => true, + 'array_syntax' => ['syntax' => 'short'], + 'blank_line_after_namespace' => true, + 'blank_line_after_opening_tag' => true, + 'full_opening_tag' => true, + 'no_closing_tag' => true, + ]) + ->setIndent(" ") + ->setFinder($finder); diff --git a/.waratah b/.waratah index 1cf07b8..f93462e 100644 --- a/.waratah +++ b/.waratah @@ -1,46 +1,68 @@ -+--------------------------------------------------------------------------------+ -|oooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo| -|oooooooooooooooooooooooooooo+ooooooooo+.~oooooooooo+oooooooooooooooooooooooooooo| -|ooooooooooooooooooooooooooo. ~:+ooooo+.. .+ooooo+:. ~ooooooooooooooooooooooooooo| -|oooooooooooooooooooooooooo: .. .::oo: .....+oo:~. .. :oooooooooooooooooooooooooo| -|ooooooooooooooo+~~~:+ooooo. .... ~o: .. .. +o. .... .ooooo+::~~oooooooooooooooo| -|ooooooooooooooo: . .:+o+~. . .o+ ..... ...+o... ~:oo:~~ :ooooooooooooooo| -|ooooooooooooooo: ..... :oo+~ o+... . .... .o+ .~+oo~. .... :ooooooooooooooo| -|ooooooooooooooo~ ... ... ++::o++o~ ....... ...~o++o:~o: ....... :ooooooooooooooo| -|ooooooooooooooo~ ... .. ~o: .:o+. ..... .. ~oo:. :o..... .. :ooooooooooooooo| -|ooooo~.~~~~~~:o: ...... :o... .:o:. .. ... .+o:. .. ~o: ...... :o:~~~~~~.:ooooo| -|ooooo. . .o: . . .. +o ..... ~+o~ .. :o+~ .....o: .. . . :o. . ~ooooo| -|oooooo. . ....o+ ..... .++ .... :o:.. .+o: ... .. ++ ...... o+ .. .. ~oooooo| -|ooooooo~ . .+o........o: ... .... ~++..oo~ . ..... ++ . .. ..o:. . ~ooooooo| -|oooooooo:.::+++o~ . .. .o+ ..... .....o+o+. ..... . . +o...... :o+++::.:oooooooo| -|ooooooooo++:~..o+ . ...o+ .. ... . . .oo. .......... o+ . .. .o+.~~:++ooooooooo| -|oooooo+:~.. :o: ... +o.... ...... :o~ ... . . . .o: .... :o~ .~:+oooooo| -|ooo:~.. ...... +o. ... :o~ .. ..... .o+ .. ...... . ~o: ... ~o: .. ... ~:+ooo| -|o+ ..... . . ..+o. .. .o: .... . . :o~ ..... ..... +o .. ~o: . .... ... .+o| -|oo:~. ... .......+o~ .. :o~ ....... :o. ... .. ... :o: . ~o+ .......... .~:oo| -|oooo+:~.. . .. .:o:. :o~ ... . :o... ...... :o: ~:o: .... ..::+oooo| -|oooooooo++::~... .:+:.. :+:~ .~o: . . ~++~ .~:+:. .~~::++oooooooo| -|ooooooooooooooo++++::::ooo+::oo+::~...:o:~...~::+o+::+ooo:::+++ooooooooooooooooo| -|ooooooooooooooo:~~~::::::::::::::+++++oooo+++:+::::::::::::::~~.+ooooooooooooooo| -|ooooooooooooooo+~~... ....~:::++++o++ooooo+oo++++:::~~... .~~+ooooooooooooooo| -|oooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo| -|oooo:::::+ooooooooooo+::::+oooooo::~~~~:::+ooo+:::::oooooo+::::+oooooo+:::::oooo| -|ooo+ ~oooooooooo: ~ooo+. .~++ +ooooo~ +ooooo. :oooo| -|oooo :oooooooo: ~oo: ~~~~. ~+o: ~oooo+ ~oooo+ .ooooo| -|ooo+ .:oooooo: ~oo +oooooo:::oooo +ooo~ +ooo~ :ooooo| -|ooo+ ~+oooo: ~oo ~:++oooooooooo: ~oo+ ~oo+ .oooooo| -|oooo +: :ooo: ~oo: ..~:+oooooo +o~ ~: +o~ +oooooo| -|ooo+ +o+. :o: ~ooo+:. ~+ooo: :+ :o ~o .ooooooo| -|ooo+ +ooo: ~~ ~ooooooo+:::~ oooo . oo: ~ +ooooooo| -|oooo oooooo: ~ooo+:oooooooo+. :ooo: :ooo ~oooooooo| -|ooo+ ooooooo+~ ~oo: .~:++oo++ +oooo. .oooo: +oooooooo| -|ooo+ +oooooooo:. ~o: :ooooo: :ooooo ~ooooooooo| -|oooo .ooooooooooo: . ~ooo+:~. ~:oooooooo. .oooooo: +ooooooooo| -|oooooooooooooooooooooooooooooooooooo++++oooooooooooooooooooooooooooooooooooooooo| -|ooooo+:::ooo+:::+o:+ooo++o:::::o+:::+oo++ooo:o+:oooo:+o:::::o:+ooo:o+:+:::oooooo| -|ooo+.:+::+o~~:::~:: oo+ :+ ::::o ~:+~ o ~oo +: ~++~ :: :::+o :oo ++:..:+oooooo| -|ooo~.oo::~: oooo: o:.o :o+ :::+o.~:~~:o ::.: +:~:..+.:: :::+o ::.: :oo:.oooooooo| -|oooo~~::~.o:.:::.:oo~ ~oo+ ::::o.~+:.:o :o+~ +:.o+oo :: ::::o :o+~ +oo:.oooooooo| -|ooooo+:++oooo:++ooooo+oooo++:++o+oooo:o+oooo+oo+oooo+oo+++:+o+oooo+ooo++oooooooo| -|oooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo| -+--------------------------------------------------------------------------------+ ++------------------------------------------------------------------------------------------------------------------------+ +|oooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo| +|ooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo:+ooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo| +|ooooooooooooooooooooooooooooooooooooooooo++ooooooooooooooo~ ~oooooooooooooo+++ooooooooooooooooooooooooooooooooooooooooo| +|oooooooooooooooooooooooooooooooooooooooo+ .~+oooooooooo+. .. ~oooooooooo++~. .+oooooooooooooooooooooooooooooooooooooooo| +|oooooooooooooooooooooooooooooooooooooooo~ .. ~:+oooooo+. .... ~+oooooo+:. .. :oooooooooooooooooooooooooooooooooooooooo| +|ooooooooooooooooooooooooooooooooooooooo+ .. .. :+ooo+.... . . .oooo:~. ......+ooooooooooooooooooooooooooooooooooooooo| +|oooooooooooooooooooooooo~~:+++ooooooooo.......... ~oo+ ..... .. .+o+ .........:oooooooooo++~~:oooooooooooooooooooooooo| +|ooooooooooooooooooooooo: . .~:+ooooo+ .........+o+ .............oo+........ +oooo++:~ . . +ooooooooooooooooooooooo| +|ooooooooooooooooooooooo~ .... . .~++oo+~ ..... +o+... . .... .. ~oo+ .... .~+oo++~. ...... ~ooooooooooooooooooooooo| +|ooooooooooooooooooooooo........... ~+oo++. ..+oo~ ............ . ~oo+ . .~+ooo:~. ......... ~ooooooooooooooooooooooo| +|ooooooooooooooooooooooo.............. :ooooo+~ :oo~ ..... .......... +oo~.~+ooooo~ ..... . .... .ooooooooooooooooooooooo| +|oooooooooooooooooooooo+. . ... .. .. :oo~ ~+oo++o+ ........... .......+oo+oo+~.~oo~ . ...........ooooooooooooooooooooooo| +|ooooooooooooooooooooooo... . ....... +o+ .~+ooo. ................. ~ooo+~ ..+o: ........... ~ooooooooooooooooooooooo| +|oooooooo+++++++++++oooo......... ....oo: ... ~+oo:. .. .. .. .... .+oo+~ ... :o+ .. .... . ..~oo+o+++++++++++oooooooo| +|ooooooo: . .. ~oo............ ~oo....... ~+oo: ... ..... .. :oo+~ ...... .oo............ ~oo.... . +ooooooo| +|ooooooo+ .......... ~oo~ . ... .... :o+ ... .... :+o+~ ... .... ~+oo:. .. ......oo~ ... ...... ~oo. ...........+ooooooo| +|oooooooo~ ... .......oo: .. ..... ..+o+ ... ...... .+oo: .... .:oo+. .... ... +o: . .. ..... +o+........... +oooooooo| +|ooooooooo~ ..........+o+ ..... .... +o+ ...... ..... :oo+.... ~+oo~ ............ +o+ ....... ...+o+ ...... .. :ooooooooo| +|oooooooooo: ....... :o+ .. ........oo: ............. .+o+~ ~oo+~ .. ....... .. +o+....... ....oo~ ...... +oooooooooo| +|ooooooooooo+. .~:+oo~.... .... .oo+ . ... ... ... .+oo.:oo+. ....... ...... +o+ .. ...... ~oo+:~. . .+ooooooooooo| +|oooooooooooo+~.:++ooo+oo+ .. .. ...+o: .... ...... .... +oooo+ ..... ..... . . +o+.... ..... +oo+ooo++~.:ooooooooooooo| +|ooooooooooooooooo+:~. :oo...........oo+ .. ...... .... . +oo+.......... ....... +o+ ..... ...~oo~ .~:+ooooooooooooooooo| +|oooooooooooo++:. ....oo: .... ....+o+ .......... ...... +o+........ ....... ...+o+ ........ +o+ .. ~:++oooooooooooo| +|ooooooooo+:.. ...... :oo~ .... .. +o+. ..... ...... .. :oo~ ... ..... .. ......oo~ ... ... ~oo~....... . ~:+ooooooooo| +|ooooo++~.. ...... .... +o+ ....... ~oo~... .... ........+o+ ..... ............ :oo... ......oo+ .. ....... ..~+oooooo| +|ooo+~. ..... ...........oo+ ........oo+ ..... .... ... :oo. . ...... .... .....+o+ ...... .+o+ ...... ......... ..~+ooo| +|oo+ ..... .............. ~+o+ . . .. +oo............. . +o+. ................. ~oo~ ..... .+o+...... ....... ...... +oo| +|ooo:.. ............ .... .oo+. ......+o+ ... ..........+o+ .... ..... .. . .. +o+ .. .. ~+o+. ... ...... ....... ..+ooo| +|ooooo+~. ...... ........ .+oo~ ... ~oo: ... ....... +o+ ................ +oo~..... :oo+...... .......... .~:+ooooo| +|oooooooo++~.. ............ +oo+~ .. :oo+ ... .......+o+ .. .. . ...... .+oo. .. ~+o+~ ......... ..:++oooooooo| +|oooooooooooo++:~.. . .. .+oo+~ ~+oo: ......... :oo............. .:oo+. .~+oo+. .. .~::++oooooooooooo| +|oooooooooooooooooo+++::~~~.... .:+o++~~. ~+o+:.. ... +o+. .... . ~++o+~ ..:++o+: ....~~~::+++ooooooooooooooooooo| +|ooooooooooooooooooooooooooooooo+++++ooooo+++oooo++:~~....~oo+:.....~~:+oooo++++ooooo+++++ooooooooooooooooooooooooooooooo| +|ooooooooooooooooooooooo:~~~:::++++o++++++oo+++++++o+++o+oooooooo++o++oo++++oooo++++oo++++++::~~~+ooooooooooooooooooooooo| +|oooooooooooooooooooooo+. . .~~~:::++++++oooooo+++++::~~~... . .. . . :ooooooooooooooooooooooo| +|oooooooooooooooooooooooo+:~~~....~~.~~:++++ooooooooooooooooooooooooooooooooo+++::~~..........~:+oooooooooooooooooooooooo| +|oooooooooooooooooooooooooooooooo+ooooooooooooooooooooooooooooooooooooooooooooooooooooo+o+oo+oooooooooooooooooooooooooooo| +|oooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo| +|oooooo:~:~::::+ooooooooooooooooo+~::~:::oooooooo++:~.. ..~~:+oooooo~:::~:::oooooooooo:~:~::~+oooooooooo::::~:~+oooooo| +|ooooo+ :oooooooooooooooo~ oooooo+. .:+oo~ +oooooooo+ ooooooooo~ +oooooo| +|ooooo+ +oooooooooooooo~ oooo+~ .+o+ ~oooooooo. ~oooooooo ~ooooooo| +|ooooo+ .+oooooooooooo~ oooo. .::+::~. ~+ooo: +oooooo+ ooooooo+ +ooooooo| +|ooooo+ ~ooooooooooo~ ooo+ +ooooooooo++. :ooooo+ ~oooooo. :oooooo ~oooooooo| +|ooooo+ :ooooooooo~ +oo: +oooooooooooooooooooo: +oooo+ ooooo+ +oooooooo| +|ooooo+ . .+ooooooo~ ooo+ .:++oooooooooooooooo+ :oooo. ~oooo. ~ooooooooo| +|ooooo+ :+. ~+ooooo~ .oooo~ ..~:++oooooooooo: ooo+ +~ ooo+ oooooooooo| +|ooooo+ :oo+ :oooo~ +oooo: ~:oooooooo :oo o+ :oo. :oooooooooo| +|ooooo+ :oooo: +oo. ooooooo+~. ~oooooo: o+ +oo. o+ ooooooooooo| +|ooooo+ :ooooo+~ .+~ ooooooooooo++:~.. .oooooo :. ooo+ :~ :ooooooooooo| +|ooooo+ :ooooooo+. oooooooooooooooooo++~ +ooooo: :oooo. oooooooooooo| +|ooooo+ ~ooooooooo: +ooooo:+ooooooooooooo+ :oooooo. ooooo+ +oooooooooooo| +|ooooo+ :ooooooooooo~ oooo+ .~++ooooooooo+ :oooooo+ :oooooo. .ooooooooooooo| +|ooooo+ :oooooooooooo+. oo+. .~~::::~. .oooooooo. ooooooo+ +ooooooooooooo| +|ooooo+ :oooooooooooooo+ +o+~ ~ooooooooo+ :oooooooo. .oooooooooooooo| +|ooooo+ ~oooooooooooooooo: ooooo+:. .~+ooooooooooo. ooooooooo+ +oooooooooooooo| +|oooooo:+::+::+ooooooooooooooooo+::+:+:::oooooooo+++:~~.~.~.~::++oooooooooooooo+::+::+:+oooooooooo+:::+:++ooooooooooooooo| +|oooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo| +|ooooooooo+++ooooooooo+ooooooooooooooooo+o+o++oooooo++oooooo+ooooooooooooooooooooooooo++o+ooooooooooo+ooo+ooo+ooooooooooo| +|oooooo+~.~~..~+oo+~..~...+o: :ooooo. o+ .~.~~.+o~ .~...~+o+ .+oooo..o+ +oooo: ++ ..~~..:o+ .+oooo..o:.~. ...~ooooooooo| +|ooooo+ ~+oooo+oo+ .+ooo+~ :o. +ooo. +o+ :ooooooo~ +ooo+ o+ . ~ooo o+ ~oo~ oo .ooooooo+ ~ooo oooo+ ~oooooooooooo| +|ooooo ooo+~~:~o ooooooo. oo oo: +oo+ ~...~oo~ ::++~ ~o+ ++. +o .o+ :+ .. o~ +o ~~.~~+o+ ++ +o .oooo+ :oooooooooooo| +|ooooo: :ooo++ o~ :ooooo+ .oo+ ~+ :ooo+ :ooooooo~ ~:~ +oo+ :oo: ~ .o+ :oo.~oo~ +o .o++oooo+ :oo: ~ .oooo+ :oooooooooooo| +|oooooo+..~::~ .oo: .~:~. ~oooo+ ~oooo+ .:~::~+o~ +oo+. +o+ :ooo+. o+ ~oooooo. oo :~::~:o+ :ooo+. oooo+ ~oooooooooooo| +|oooooooo++:++oooooo++:++ooooooo++ooooo+++++++++o++ooooo++oo++ooooo++oo++oooooo++oo++++++++o+++ooooo++oooo+++oooooooooooo| +|oooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo| +|oooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo| ++------------------------------------------------------------------------------------------------------------------------+ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 99159bd..3eb08f6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,14 +1,19 @@ # Contributing + +For simplicity, our contribution process follows the relevant Silverstripe documents. + +## Guidelines + - Maintenance on this module is a shared effort of those who use it - To contribute improvements to the code, ensure you raise a pull request and discuss with the module maintainers -- Please follow the SilverStripe [code contribution guidelines](https://docs.silverstripe.org/en/contributing/code/) and [Module Standard](https://docs.silverstripe.org/en/developer_guides/extending/modules/#module-standard) +- Please follow the Silverstripe [code contribution guidelines](https://docs.silverstripe.org/en/contributing/code/) and [Module Standard](https://docs.silverstripe.org/en/developer_guides/extending/modules/#module-standard) - Supply documentation that follows the [GitHub Flavored Markdown](https://help.github.com/articles/markdown-basics/) conventions -- When having discussions about this module in issues or pull request please adhere to the [SilverStripe Community Code of Conduct](https://docs.silverstripe.org/en/contributing/code_of_conduct/) - +- When having discussions about this module in issues or pull request please adhere to the [Silverstripe Community Code of Conduct](https://docs.silverstripe.org/en/contributing/code_of_conduct/) ## Contributor license agreement -By supplying code to this module in patches, tickets and pull requests, you agree to assign copyright -of that code to MODULE_COPYRIGHT_HOLDER_HERE., on the condition that these code changes are released under the -same BSD license as the original module. We ask for this so that the ownership in the license is clear -and unambiguous. By releasing this code under a permissive license such as BSD, this copyright assignment -won't prevent you from using the code in any way you see fit. + +By supplying code to this module in patches, tickets and pull requests, you agree to assign copyright of that code to New South Wales Department of Premier & Cabinet, on the condition that these code changes are released under the same BSD license as the original module. + +We ask for this so that the ownership in the license is clear and unambiguous. + +By releasing this code under a permissive license such as BSD, this copyright assignment won't prevent you from using the code in any way you see fit. diff --git a/README.md b/README.md index 65cbaf2..1a6fd72 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ This module provides functionality to send emails via the Mailgun API and store See [composer.json](./composer.json) -+ silverstripe/framework ^4 ++ silverstripe/framework ^5 + Symbiote's [Queued Jobs](https://github.com/symbiote/silverstripe-queuedjobs) module + Mailgun PHP SDK ^3, kriswallsmith/buzz, nyholm/psr7 @@ -17,7 +17,7 @@ You need: * A Mailgun account * At least one non-sandbox Mailgun mailing domain ([verified is best](https://documentation.mailgun.com/en/latest/user_manual.html#verifying-your-domain)) in your choice of region * A Mailgun API key or a [Mailgun Domain Sending Key](https://www.mailgun.com/blog/mailgun-ip-pools-domain-keys) for the relevant mailing domain (the latter is recommended) -* MailgunEmail and MailgunMailer are configured in your project (see below) +* The correct configuration in your project (see below) ## Installing @@ -27,223 +27,23 @@ composer require nswdpc/silverstripe-mailgun-sync ## Configuration -### Mailgun account - -Configuration of your Mailgun domain and account is beyond the scope of this document but is straightforward. - -You should verify your domain to avoid message delivery issues. The best starting point is [Verifying a Domain](https://documentation.mailgun.com/en/latest/user_manual.html#verifying-your-domain). - -MXToolBox.com is a useful tool to check your mailing domain has valid DMARC records. - -### Module - -Add the following to your project's yaml config: -```yml ---- -Name: local-mailgunsync-config -After: - - '#mailgunsync' ---- -# API config -NSWDPC\Messaging\Mailgun\Connector\Base: - # your Mailgun mailing domain - api_domain: 'configured.mailgun.domain' - # your API key or Domain Sending Key - api_key: 'xxxx' - # the endpoint region, if you use EU set this value to 'API_ENDPOINT_EU' - # for the default region, leave empty - api_endpoint_region: '' - # this setting triggers o:testmode='yes' in messages - api_testmode: true|false - # You will probably want this as true, when false some clients will show 'Sent on behalf of' text - always_set_sender: true - # set this to override the From header, this is useful if your application sends out mail from anyone (see DMARC below) - always_from: 'someone@example.com' - # Whether to send via a job - see below - send_via_job: 'yes|no|when-attachments' - # When set, messages with no 'To' header are delivered here. - default_recipient: '' - # grab this from your Mailgun account control panel - webhook_signing_key: '' - # whether you want to store webhook requests - webhooks_enabled: true|false - # the current or new filter variable (see webhooks documentation in ./docs) - webhook_filter_variable: '' - # the previous one, to allow variable rotation - webhook_previous_filter_variable: '' ---- -# Configure the mailer -Name: local-mailer -After: - # Override core email configuration - - '#emailconfig' ---- -# Send messages via the MailgunMailer -SilverStripe\Core\Injector\Injector: - SilverStripe\Control\Email\Email: - class: 'NSWDPC\Messaging\Mailgun\MailgunEmail' - SilverStripe\Control\Email\Mailer: - class: 'NSWDPC\Messaging\Mailgun\MailgunMailer' -``` - -> Remember to flush configuration after a configuration change. - -See [detailed configuration, including project tags](./docs/en/005-detailed_configuration.md) - -## Sending - -### Mailer - -For a good example of this, look at the MailgunSyncTest class. Messages are sent using the default Silverstripe Email API: - -```php -use SilverStripe\Control\Email\Email; - -$email = Email::create(); -$email->setFrom($from); -$email->setTo($to); -$email->setSubject($subject); -``` -To add custom parameters used by Mailgun you call setCustomParameters(): -```php - -// variables -$variables = [ - 'test' => 'true', - 'foo' => 'bar', -]; - -//options -$options = [ - 'testmode' => 'yes', - 'tag' => ['tag1','tag2','tag4'], - 'tracking' => 'yes', - 'require-tls' => 'yes' -]; - -// headers -$headers = [ - 'X-Test-Header' => 'testing' -]; - -$recipient_variables = [ - 'someone@example.com' => ["unique_id" => "testing_123"] -]; - -$args = [ - 'options' => $options, - 'variables' => $variables, - 'headers' => $headers, - 'recipient-variables' => $recipient_variables -]; - -$email->setCustomParameters($args) -``` -Where `$args` is an array of [your custom parameters](https://documentation.mailgun.com/en/latest/api-sending.html#sending). Calling setCustomParameters() multiple times will overwrite previous parameters. - -Send the message: -```php -$response = $email->send(); -``` - -The response will either be a Mailgun message-id OR a `Symbiote\QueuedJobs\DataObjects\QueuedJobDescriptor` instance if you are sending via the queued job. - -### Via API connector - -You can send directly via the API connector, which handles client setup and the like based on configuration. -For a good example of this, look at the MailgunMailer class - -```php -use NSWDPC\Messaging\Mailgun\Connector\Message; - -//set parameters -$parameters = [ - 'to' => ..., - 'from' => ..., - 'o:tag' => ['tag1','tag2'] - // etc -]; -$connector = Message::create(); -$response = $connector->send($parameters); -``` -The response will either be a Mailgun message-id OR a `Symbiote\QueuedJobs\DataObjects\QueuedJobDescriptor` instance if you are sending via the queued job. - -### Direct to Mailgun PHP SDK - -If you like, you can send messages and interact with the Mailgun API via the Mailgun PHP SDK: - -```php -use Mailgun\Mailgun; - -$client = Mailgun::create($api_key); -// set things up then send -$response = $client->messages()->send($domain, $parameters); -``` - -The response will be a `Mailgun\Model\Message\SendResponse` instance if successful. - -See the [Mailgun PHP SDK documentation](https://github.com/mailgun/mailgun-php) for examples. - -## Queued Jobs - -The module uses the [Queued Jobs](https://github.com/symbiote/silverstripe-queuedjobs) module to deliver email at a later time. - -This way, a website request that involves delivering an email will not be held up by API issues. - -### SendJob - -This is a queued job that can be used to send emails depending on the ```send_via_job``` config value - -+ 'yes' - all the time -+ 'when-attachments' - only when attachments are present, or -+ 'no' - never (in which case messages will never send via a Queued Job) - -Messages are handed off to this queued job, which is configured to send after one minute. Once delivered, the message parameters are cleared to reduce space used by large messages. - -This job is marked as 'broken' immediately upon an API or other general error. Please read the Queued Jobs Health Check documentation to get assistance with Broken job reporting. - -### TruncateJob - -Use this job to clear out older MailgunEvent webhook records. If you don't use webhooks to store events, this job can remain unused. - -### RequeueJob - -Use this job to kick broken SendJob instances, which happen from time-to-time due to API or connectivity issues. - -This job will: -1. Take all job descriptor records for SendJob that are Broken -1. Reset their status, processing counts and worker value to default initial values -1. Set them to start after a minute -1. Save the record - -On the next queue run, these jobs will attempt to send again. - -## Manual Resubmission - -Messages can be resent from the Mailgun control panel. This depends on your Message Retention setting for the relevant mailing domain in Mailgun. - -## DMARC considerations - -When sending email it's wise to consider how you maintain the quality of your mailing domain (and IP(s)). - -If your mailing domain is "mg.example.com" and you send "From: someone@example.net" DMARC rules will most likely kick in at the recipient mail server and your message will be quarantined or rejected (unless example.net designates example.com as a permitted sender). Instead, use a From header of "someone@mg.example.com" or "someone@example.com" in your messages. - -Your Reply-To header can be any valid address. - -See [dmarc.org](https://dmarc.org) for more information on the importance of DMARC, SPF and DKIM - - -## Tests +See [Getting Started](./docs/en/001-index.md) -Unit tests: [./tests](./tests). Tests use the [TestMessage](./tests/TestMessage.php) connector. +### Breaking changes -### Sending emails using sandbox/testmode +## 5.0 release -For acceptance testing, you can use a combination of the Mailgun sandbox domain and API testmode. +This version refactored the module to support the `silverstripe/framework` change to using `symfony/mailer` and is not backwards compatible with previous versions. When updating your project, be aware of the following changes: -+ Sandbox domain: set the `api_domain` value in configuration to the sandbox domain provided by Mailgun. Remember to list approved recipients in the sandbox domain settings in the Mailgun control panel. -+ Test mode: set the `api_testmode` value to true. In testmode, Mailgun accepts but does not deliver messages. ++ Configuration is done via a symfony mailer DSN, either in project yml or environment variable ++ MailgunMailer was removed, almost all functionality was moved to the `MailgunSyncApiTransport` ++ Namespace updates to reflect psr-4 ++ The `api_domain`, `api_key` and `api_endpoint_region` configuration values were removed (see DSN) ++ Default recipient handling was removed ++ 'Always from' handling was removed, Email.send_all_emails_from is now the only way to do this ++ All client connectors that extend `Base` must now provide a `Dsn` or a string that a `Dsn` can be created from: -## Breaking changes in 1.0 release +## 1.0 release Version 1 removed unused features to reduce the complexity of this module. diff --git a/_config/config.yml b/_config/config.yml index 77ad111..cc37e58 100644 --- a/_config/config.yml +++ b/_config/config.yml @@ -1,28 +1,10 @@ --- -Name: mailgunsync +Name: 'mailgunsync' --- -# API config NSWDPC\Messaging\Mailgun\Connector\Base: - api_domain: '' - api_key: '' + # API settings api_testmode: false - api_endpoint_region: '' - webhooks_enabled: true - webhook_signing_key: '' - # the current or new filter variable - webhook_filter_variable: '' - # the previous one, to allow variable rotation - webhook_previous_filter_variable: '' - always_set_sender : true + # whether to always set the Sender header (true) or not (false) + always_set_sender: true # yes|no|when-attachments - send_via_job : 'when-attachments' - default_recipient : '' -# Mailer config -NSWDPC\Messaging\Mailgun\MailgunMailer: - always_from: '' ---- -Name: mailgunsyncqueue ---- -Symbiote\QueuedJobs\DataObjects\QueuedJobDescriptor: - db: - SavedJobData : 'NSWDPC\Messaging\Mailgun\ORM\FieldType\DBLongText' + send_via_job: 'when-attachments' diff --git a/_config/mailer.yml b/_config/mailer.yml new file mode 100644 index 0000000..b668e2c --- /dev/null +++ b/_config/mailer.yml @@ -0,0 +1,10 @@ +--- +Name: 'mailgunsync-mailer' +After: + '#mailer' +--- +SilverStripe\Core\Injector\Injector: + # override the core transport factory to ensure that the Mailgun Api transport + # factor is registered as a transport factory + SilverStripe\Control\Email\TransportFactory: + class: 'NSWDPC\Messaging\Mailgun\Transport\TransportFactory' diff --git a/_config/queuedjobs.yml b/_config/queuedjobs.yml new file mode 100644 index 0000000..de56a20 --- /dev/null +++ b/_config/queuedjobs.yml @@ -0,0 +1,6 @@ +--- +Name: 'mailgunsync-queue' +--- +Symbiote\QueuedJobs\DataObjects\QueuedJobDescriptor: + db: + SavedJobData : 'NSWDPC\Messaging\Mailgun\ORM\FieldType\DBLongText' diff --git a/_config/routes.yml b/_config/routes.yml index 94815d3..5289ad5 100644 --- a/_config/routes.yml +++ b/_config/routes.yml @@ -1,8 +1,8 @@ --- -Name: nswdpc_mailgunsync_routes +Name: 'mailgunsync-routes' After: - '#coreroutes' --- Silverstripe\Control\Director: rules: - '_wh//$Action/$ID/$OtherID': 'NSWDPC\Messaging\Mailgun\MailgunWebHook' + '_wh//$Action/$ID/$OtherID': 'NSWDPC\Messaging\Mailgun\Controllers\MailgunWebHook' diff --git a/_config/taggable.yml b/_config/taggable.yml new file mode 100644 index 0000000..07dd9a0 --- /dev/null +++ b/_config/taggable.yml @@ -0,0 +1,12 @@ +--- +Name: 'mailgunsync-taggable-notifications' +After: + - '#nswdpc-taggable-notifications' +--- +NSWDPC\Messaging\Taggable\ProjectTags: + tag: '' + tag_limit: 3 + tag_email_header_name: 'X-Mailgun-Tag' + tag_email_header_serialisation: 'multi' + tag_email_header_value_delimiter: '' +--- diff --git a/composer.json b/composer.json index 713cb40..838270d 100644 --- a/composer.json +++ b/composer.json @@ -26,24 +26,40 @@ "tests/" ], "NSWDPC\\Messaging\\Mailgun\\": [ - "src/Controllers/", - "src/Email/", - "src/Exceptions/", - "src/Jobs/", - "src/Models/", "src/" ] } }, + "repositories": [ + { + "type": "git", + "url": "https://github.com/nswdpc/ci-files.git" + } + ], "require": { - "symbiote/silverstripe-queuedjobs": "^4.9", + "symbiote/silverstripe-queuedjobs": "^5", "mailgun/mailgun-php": "^3", "kriswallsmith/buzz" : "^1.1", "nyholm/psr7" : "^1.3", - "silverstripe/framework" : "^4.10" + "silverstripe/framework" : "^5", + "nswdpc/silverstripe-taggable-notifications": "dev-ss5", + "symfony/http-client": "^6.4" }, "require-dev": { + "cambis/silverstripe-rector": "^0.5.1", "phpunit/phpunit": "^9.5", - "friendsofphp/php-cs-fixer": "^3" - } + "syntro/silverstripe-phpstan": "^5", + "nswdpc/ci-files": "dev-v-1" + }, + "config": { + "allow-plugins": { + "composer/installers": true, + "php-http/discovery": true, + "silverstripe/vendor-plugin": true, + "silverstripe/recipe-plugin": true, + "phpstan/extension-installer": true + } + }, + "prefer-stable": true, + "minimum-stability": "dev" } diff --git a/docs/en/001-index.md b/docs/en/001-index.md index f598251..c00bc59 100644 --- a/docs/en/001-index.md +++ b/docs/en/001-index.md @@ -1,72 +1,160 @@ -# Documentation +# Getting started -+ [More detailed configuration](./005-detailed_configuration.md) +See also: ++ [Sending email](./002-sending-email.md) ++ [Sending email (more)](./002.1-more-sending-email.md) ++ [Queued jobs](./003-sending-email.md) + [Webhooks](./100-webhooks.md) +## Configuration + +First, follow the setup details as defined in the Silverstripe Email documentation. +You must use a DSN in the format below (either in yaml as below) or as the MAILER_DSN environment variable value (recommended) + +In the DSN formatted as `scheme://user:password/host/?query_string` + ++ scheme: mailgunsync+api, this loads the correct Transport ++ user: the Mailgun sending domain ++ pass: the Mailgin sending API key ++ host: this is set to 'default' and is internally updated based on the region option ++ region: in the query string, set region=API_ENDPOINT_EU to send via the EU region (example below) + + +Add the following to your project's local yaml config e.g. in `app/_config/local.yml` and update options as required. Ignore this file in version control for your project (do not commit secrets to VCS). + +```yaml +--- +Name: local-mailer +After: + - '#mailer' +--- +SilverStripe\Core\Injector\Injector: + Symfony\Component\Mailer\Transport\TransportInterface: + constructor: + # , region not specified, and so will be set to API_ENDPOINT_DEFAULT internally + dsn: 'mailgunsync+api://sendingdomain:apikey@default' + # Specify a default region + # dsn: 'mailgunsync+api://sendingdomain:apikey@default?region=API_ENDPOINT_DEFAULT' + # Specify use of the EU region + # dsn: 'mailgunsync+api://sendingdomain:apikey@default?region=API_ENDPOINT_EU' +--- +``` + +You can override other options in the same config file + +```yaml +Name: local-mailgunsync +After: + - '#app-mailgunsync' +--- +# API config +NSWDPC\Messaging\Mailgun\Connector\Base: + # API settings + api_testmode: false +``` -## MailgunEmail - -MailgunEmail extends Email and provides added features for use with Mailgun via `setCustomParameters` - -These extra options, variables, headers and recipient variables are passed to the Mailgun API. - -```php -use SilverStripe\Control\Email\Email; - -$person = get_person(); - -// Set parameters (no need to prefix keys) -$parameters = [ - - // Set v: prefixed variables - 'variables' => [ - 'test' => 'true', - 'foo' => 'bar', - ], - - // Set o: prefixed options - 'options' => [ - 'deliverytime' => $person->getReminderTime(\DateTimeInterface::RFC2822), - 'dkim' => 'yes',// require DKIM for this specific message - 'tag' => ['tag1','tag2','tag4'], // send some tags for analytics - 'tracking' => 'yes', // turn tracking on just for this message - 'require-tls' => 'yes', // require a TLS connection when Mailgun connects to the remote mail server - 'skip-verification' => 'no' // do not skip TLS verification - ], - - // h: prefixed headers - 'headers' => [ - 'X-Test-Header' => 'testing' - ], - - // Specific recipient variables - 'recipient_variables' => [ - $person->Email => ["tagline" => "Reminder"] - ] -]; - -// Send the email -$email = Email::create(); -$email->setTo($person->Email) - ->setSubject('A reminder') - ->setFrom('someone.else@example.com') - ->setCustomParameters($parameters) - ->send(); +### Set up a project configuration + +Add the following to your project's yaml config e.g. in `app/_config/mailgun.yml` and update options. + +```yaml +--- +Name: app-mailgunsync +After: + - '#mailgunsync' +--- +# API config +NSWDPC\Messaging\Mailgun\Connector\Base: + # (bool) this setting triggers o:testmode='yes' in messages if true + api_testmode: false + # (bool) you will probably want this as true, when false some clients will show 'Sent on behalf of' text + always_set_sender: true + # (string) whether to send via a job - see below. options are 'yes', 'no', and 'when-attachments' + send_via_job: 'yes' + # (string) When set, messages with no 'To' header are delivered here. + default_recipient: '' +--- +# Configure the mailer +Name: app-emailconfig +After: + # override core email configuration + - '#emailconfig' + # replace TaggableEmail with MailgunEmail + - '#nswdpc-taggable-email' +--- +SilverStripe\Core\Injector\Injector: + SilverStripe\Control\Email\Email: + ## replace Email with MailgunEmail via Injector + class: 'NSWDPC\Messaging\Mailgun\MailgunEmail' ``` -## Tagging +> Remember to flush configuration after a configuration change. -To set tags on a message, include them in the `$parameters['options']['tag']` array. +## Options descriptions -[Mailgun places a limit of 3 tags per message](https://documentation.mailgun.com/en/latest/user_manual.html#tagging). +### api_testmode -## Future delivery +(bool) -To send in the future, use [scheduled delivery](https://documentation.mailgun.com/en/latest/user_manual.html#scheduling-delivery) with an RFC2822 formatted datetime. +When true, messages will send with the o:testmode parameter set to 'yes' -```php -//send in the future example -$options = [ - 'deliverytime' => 'Fri, 14 Oct 2032 06:30:00 +1100' -]; -``` +Any message sent with this enabled will be accepted but not delivered. + +### always_set_sender + +(bool) + +When true, sets the Sender header to match the From header unless the Sender header is already set. + +This can remove "on behalf of" and "sent by" messages showing in email clients. + +### send_via_job + +(string) + +The message will be sent via a Queued Job depending on this setting and the message in question: + ++ 'yes' = All messages ++ 'no' = Do not send via the queued job ++ 'when-attachments' = Only when attachments are present + +With a value of 'when-attachments' set, message delivery attempts without attachments will not use the queued job. + +## Configuring your Mailgun account + +Configuration of your Mailgun domain and account is beyond the scope of this document but is straightforward. + +You should verify your domain to avoid message delivery issues. The best starting point is [Verifying a Domain](https://documentation.mailgun.com/en/latest/user_manual.html#verifying-your-domain). + +MXToolBox.com is a useful tool to check your mailing domain has valid DMARC records. + +## Troubleshooting + +A few things can go wrong. If email is not being delivered: + ++ Check configuration ++ Enable Silverstripe logging per Silverstripe documentation, check logs for any errors or notices ++ Review Mailgin logs in their control panel - are messages being accepted? ++ Review and understand DMARC, SPF and DKIM for your domain, check DNS records + +## DMARC considerations + +When sending email it's wise to consider how you maintain the quality of your mailing domain (and IP(s)). + +If your mailing domain is "mg.example.com" and you send "From: someone@example.net" DMARC rules will most likely kick in at the recipient mail server and your message will be quarantined or rejected (unless example.net designates example.com as a permitted sender). Instead, use a From header of "someone@mg.example.com" or "someone@example.com" in your messages. + +Your Reply-To header can be any valid address. + +See [dmarc.org](https://dmarc.org) for more information on the importance of DMARC, SPF and DKIM + + +## Tests + +Unit tests: [./tests](./tests). Tests use the [TestMessage](./tests/TestMessage.php) connector. + +### Sending emails using sandbox/testmode + +For acceptance testing, you can use a combination of the Mailgun sandbox domain and API testmode. + ++ Sandbox domain: ensure the sending domain value in configuration is set to the sandbox domain provided by Mailgun. Remember to list approved recipients in the sandbox domain settings in the Mailgun control panel. ++ Test mode: set the `api_testmode` value to true. In testmode, Mailgun accepts but does not deliver messages. diff --git a/docs/en/002-sending-email.md b/docs/en/002-sending-email.md new file mode 100644 index 0000000..18e4992 --- /dev/null +++ b/docs/en/002-sending-email.md @@ -0,0 +1,71 @@ +# Sending email + +## via Silverstripe Email + +For a good example of this, look at the MailgunSyncTest class. Messages are sent using the default Silverstripe Email API: + +```php +setFrom($from); +$email->setTo($to); +$email->setSubject($subject); +$email->send(); +``` + +### via the API connector + +You can send directly via the API `Message` connector, which handles client setup and the like based on configuration. + +```php + ..., + 'from' => ..., + 'o:tag' => ['tag1','tag2'] + // etc +]; +// used the mailgunsync+api DSN in configuration +// note: this will only work if the TransportInterface dsn configuration is in place per Silverstripe documentation +$transport = Injector::inst()->create(TransportInterface::class); +$connector = Message::create($transport->getDsn()); +$response = $connector->send($parameter); + +// or as a string +$connector = Message::create('mailgunsync+api://mysendingdomain.example:some_sending_key@default?region=API_ENDPOINT_EU')); +$response = $connector->send($parameter); +``` + +The response will either be a Mailgun message-id (string) OR a `Symbiote\QueuedJobs\DataObjects\QueuedJobDescriptor` instance if you are sending via the queued job. + +## Direct to the Mailgun PHP SDK + +If you like, you can send messages and interact with the Mailgun API via the Mailgun PHP SDK: + +```php +messages()->send($domain, $parameters); +``` + +The response will be a `Mailgun\Model\Message\SendResponse` instance if successful. + +See the [Mailgun PHP SDK documentation](https://github.com/mailgun/mailgun-php) for examples. diff --git a/docs/en/002.1-more-sending-email.md b/docs/en/002.1-more-sending-email.md new file mode 100644 index 0000000..668f7c5 --- /dev/null +++ b/docs/en/002.1-more-sending-email.md @@ -0,0 +1,93 @@ +# Sending email (more detailed) + +MailgunEmail has support for custom parameters and tags. Custom parameters are useful for manaing email send on a per-email basis. Tags are useful for analytics/reporting. + +> Note: [Mailgun places a limit of 3 tags per message](https://documentation.mailgun.com/en/latest/user_manual.html#tagging) + +Here is an example of setting some custom options: +```php + [ + 'test' => 'true', + 'foo' => 'bar', + ], + + // Set o: prefixed options + 'options' => [ + 'deliverytime' => $person->getReminderTime(\DateTimeInterface::RFC2822), + 'dkim' => 'yes',// require DKIM for this specific message + 'tag' => ['tag1','tag2','tag4'], // send some tags for analytics + 'tracking' => 'yes', // turn tracking on just for this message + 'require-tls' => 'yes', // require a TLS connection when Mailgun connects to the remote mail server + 'skip-verification' => 'no' // do not skip TLS verification + ], + + // h: prefixed headers + 'headers' => [ + 'X-Test-Header' => 'testing' + ], + + // Specific recipient variables + 'recipient_variables' => [ + $person->Email => ["tagline" => "Reminder"] + ] +]; + +// Send the email +// Email will be an instance of MailgunEmail, if configuration is in place +$email = Email::create(); +$email->setTo($person->Email) + ->setSubject('A reminder') + ->setFrom('someone.else@example.com') + // apply custom parameters to this particular email + ->setCustomParameters($parameters) + ->send(); +``` + +## Tagging + +`MailgunEmail` uses our [`Taggable` trait](https://github.com/nswdpc/silverstripe-taggable-notifications) to quickly set tags on a message. + +```php +setTo('someone@example.com') + ->setSubject('Tagged message') + ->setFrom('someone.else@example.com') + // tag this email with 3 tags tag1, tag2 and tag4 - tag values are up to you + ->setNotificationTags(['tag1','tag2','tag4']); + ->send(); +``` + +Internally, this adds the tags to the options.tag parameter provided to the Mailgun API. + +> Tags set via this method or setCustomParameters will override other methods of tagging. + +## Future delivery + +> Queued sending in Silverstripe can also help with sending emails in the future. + +To send in the future, use [scheduled delivery](https://documentation.mailgun.com/en/latest/user_manual.html#scheduling-delivery) with an RFC2822 formatted datetime. + +```php + 'Fri, 14 Oct 2032 06:30:00 +1100' +]; +``` diff --git a/docs/en/003-queued-jobs.md b/docs/en/003-queued-jobs.md new file mode 100644 index 0000000..0e60ab3 --- /dev/null +++ b/docs/en/003-queued-jobs.md @@ -0,0 +1,36 @@ +# Queued Jobs + +The module uses the [Queued Jobs](https://github.com/symbiote/silverstripe-queuedjobs) module to deliver email at a later time. + +This way, a website request that involves delivering an email will not be held up by API issues. + +## SendJob + +This is a queued job that can be used to send emails depending on the ```send_via_job``` config value - ++ 'yes' - all the time ++ 'when-attachments' - only when attachments are present, or ++ 'no' - never (in which case messages will never send via a Queued Job) + +Messages are handed off to this queued job, which is configured to send after one minute. Once delivered, the message parameters are cleared to reduce space used by large messages. + +This job is marked as 'broken' immediately upon an API or other general error. Please read the Queued Jobs Health Check documentation to get assistance with Broken job reporting. + +## TruncateJob + +Use this job to clear out older MailgunEvent webhook records. If you don't use webhooks to store events, this job can remain unused. + +## RequeueJob + +Use this job to kick broken SendJob instances, which happen from time-to-time due to API or connectivity issues. + +This job will: +1. Take all job descriptor records for SendJob that are Broken +1. Reset their status, processing counts and worker value to default initial values +1. Set them to start after a minute +1. Save the record + +On the next queue run, these jobs will attempt to send again. + +## Manual Resubmission + +Messages can be resent from the Mailgun control panel. This depends on your Message Retention setting for the relevant mailing domain in Mailgun. diff --git a/docs/en/005-detailed_configuration.md b/docs/en/005-detailed_configuration.md deleted file mode 100644 index 4448edc..0000000 --- a/docs/en/005-detailed_configuration.md +++ /dev/null @@ -1,63 +0,0 @@ -## Detailed configuration - -More detailed configuration information is as follows - -```yml -NSWDPC\Messaging\Mailgun\Connector\Base: - api_domain: '' - ... -``` - -### api_domain - -This is your custom mailing domain. It's recommended that your verify this in DNS - -### api_key - -This is your Mailgun API key OR Domain sending key (the latter is recommended) - -### api_endpoint_region - -Leave empty ('') for the default region API endpoint provided by the Mailgun PHP SDK ('https://api.mailgun.net') - -To use the European Union (EU) endpoint for your Mailing domains in the Mailgun EU region, set this value to `API_ENDPOINT_EU` - -### api_testmode - -When true, messages will send with the o:testmode parameter set to 'yes' - -Any message sent with this enabled will be accepted but not delivered. - -### always_set_sender - -When true, sets the Sender header to match the From header unless the Sender header is already set. - -This can remove "on behalf of" and "sent by" messages showing in email clients. - -### send_via_job - -The message will be sent via a Queued Job depending on this setting and the message in question: - -+ 'yes' = All messages -+ 'no' = Do not send via the queued job -+ 'when-attachments' = Only when attachments are present - -With a value of 'when-attachments' set, message delivery attempts without attachments will not use the queued job. - -### default_recipient - -Mailgun requires a 'to' parameter. If your system sends messages with Bcc/Cc but no 'To' then you will need to specify a default_recipient (one that you control). - -### always_from - -> This setting will be replaced with `Email.send_all_emails_from` in the future. You can use that instead, now. - -```yml -NSWDPC\Messaging\Mailgun\MailgunMailer: - always_from: '' -``` - -If you wish to have all emails sent from a single address by default, regardless of the From header set then add the relevant value here. -This is off by default but can come in handy if your application is sending emails from random addresses, which will cause you to fall foul of DMARC rules. - -When in use the From header will be set as the Reply-To. diff --git a/docs/en/100-webhooks.md b/docs/en/100-webhooks.md index 280aa36..a41ca0b 100644 --- a/docs/en/100-webhooks.md +++ b/docs/en/100-webhooks.md @@ -6,33 +6,62 @@ In the event that you use the same mailing domain for multiple websites, you can ## Configuration -### webhooks_enabled +### YML +```yaml +NSWDPC\Messaging\Mailgun\Controllers\MailgunWebhook: + # allow incoming webhook requests + webhooks_enabled: true +``` + +### ENV + +MAILGUN_WEBHOOK_API_KEY + +(string) + +The API key used for webhooks, not a sending key. + +MAILGUN_WEBHOOK_DOMAIN + +(string) -Reject webhooks. Setting this will cause a 503 code to be returned to the Mailgun webhook HTTP request (meaning it will try again later until giving up) +The mailing domain to handle webhooks on -### webhook_signing_key +MAILGUN_WEBHOOK_REGION + +(string) + +Optional: the region. If using EU, the value should be 'API_ENDPOINT_EU'. + +MAILGUN_WEBHOOK_SIGNING_KEY + +(string) This is listed in your Mailgun account as your "HTTP webhook signing key", it's used to verify webhook requests. Treat this value like a private API key and password, if it is exposed then recycle it. -### webhook_filter_variable +MAILGUN_WEBHOOK_FILTER_VARIABLE + +(string) A value unique for the website or websites you wish to aggregate webhooks for. #### Example -You have 2 websites all using the same mailing domain, with 2 webhook endpoints pointing at these sites configured in Mailgun settings. +You have 2 websites both using the same mailing domain, with 2 webhook endpoints pointing at these sites configured in Mailgun settings. You can use this configuration value to filter out webhook submissions for the other site (provided the configuration value is different between the two sites). You can leave this empty and aggregate all webhook submissions on your mailing domain -### webhook_previous_filter_variable +MAILGUN_WEBHOOK_PREVIOUS_FILTER_VARIABLE + +(string) Webhooks submit over time. If you change your webhook_filter_variable in configuration some valid webhooks may not be accepted. -If this occurs, rotate your `webhook_filter_variable` into this configuration variable to catch these. +If this occurs, rotate your `webhook_filter_variable` into this configuration variable and your new filter variable into `webhook_filter_variable` to catch these (remember to flush cache) ## Example diff --git a/phpcs.xml.dist b/phpcs.xml.dist deleted file mode 100644 index fe58f6f..0000000 --- a/phpcs.xml.dist +++ /dev/null @@ -1,11 +0,0 @@ - - - CodeSniffer ruleset for SilverStripe coding conventions. - - - - - - - - diff --git a/phpunit.xml.dist b/phpunit.xml.dist index a5ec7a1..4168a92 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,5 +1,5 @@ - - + + tests/ diff --git a/src/Connector/Base.php b/src/Connector/Base.php index 951d663..99b30c6 100644 --- a/src/Connector/Base.php +++ b/src/Connector/Base.php @@ -1,15 +1,18 @@ dsn = $dsn; + } /** * Returns an RFC2822 datetime in the format accepted by Mailgun * @param string $relative a strtotime compatible format e.g 'now -4 weeks' */ - public static function DateTime($relative) + public static function DateTime(string $relative) { - if ($relative) { + if ($relative !== '') { return gmdate('r', strtotime($relative)); } else { return gmdate('r'); } } - public function getClient($api_key = null) + /** + * Get the Mailgun SDK client + * @param string $apiKey an optional alternate API key for use this this client instance + */ + public function getClient(string $apiKey = null) { - if (!$api_key) { - $api_key = $this->getApiKey(); + if ($apiKey === '' || is_null($apiKey)) { + $apiKey = $this->getApiKey(); } - $api_endpoint = $this->config()->get('api_endpoint_region'); - $this->api_endpoint_url = ''; - switch($api_endpoint) { - case 'API_ENDPOINT_EU': - $this->api_endpoint_url = self::API_ENDPOINT_EU; - $client = Mailgun::create($api_key, $this->api_endpoint_url); - break; - default: - $client = Mailgun::create($api_key); - break; + + if ($apiKey === '') { + throw new \RuntimeException("Cannot send if no API key is present"); } - return $client; + + return match ($this->getApiEndpointRegion()) { + 'API_ENDPOINT_EU' => Mailgun::create($apiKey, self::API_ENDPOINT_EU), + default => Mailgun::create($apiKey), + }; } - public function getApiEndpointRegion() { - return $this->api_endpoint_url; + /** + * Return the sending domain for this instance + */ + public function getApiDomain(): ?string + { + return $this->dsn->getUser(); } - public function getApiKey() + /** + * Get the configured API region string + */ + public function getApiEndpointRegion(): string { - $mailgun_api_key = $this->config()->get('api_key'); - return $mailgun_api_key; + $region = $this->dsn->getOption('region'); + if (!is_string($region)) { + $region = ''; + } + + return $region; } - public function getWebhookSigningKey() + /** + * Get the API key + */ + public function getApiKey(): ?string { - return $this->config()->get('webhook_signing_key'); + return $this->dsn->getPassword(); } - public function getWebhookFilterVariable() + /** + * Get the Mailgun webhook signing key from configuration + */ + public function getWebhookSigningKey(): string { - return $this->config()->get('webhook_filter_variable'); + return (string)Environment::getEnv('MAILGUN_WEBHOOK_SIGNING_KEY'); } - public function getWebhookPreviousFilterVariable() + /** + * Get the Mailgun webhook filter variable from config + */ + public function getWebhookFilterVariable(): string { - return $this->config()->get('webhook_previous_filter_variable'); + return (string)Environment::getEnv('MAILGUN_WEBHOOK_FILTER_VARIABLE'); } - public function getWebhooksEnabled() { - return $this->config()->get('webhooks_enabled'); + /** + * Get the previous Mailgun webhook filter variable from config + */ + public function getWebhookPreviousFilterVariable(): string + { + return (string)Environment::getEnv('MAILGUN_WEBHOOK_PREVIOUS_FILTER_VARIABLE'); } - public function getApiDomain() + /** + * Are webhooks enabled? + * Set to false in config to reject all webhook requests + */ + public function getWebhooksEnabled(): bool { - $mailgun_api_domain = $this->config()->get('api_domain'); - return $mailgun_api_domain; + return MailgunWebHook::config()->get('webhooks_enabled'); } - public function isSandbox() { - $api_domain = $this->getApiDomain(); - $result = preg_match("/^sandbox[a-z0-9]+\.mailgun\.org$/i", $api_domain); - return $result == 1; + /** + * Is the current sending domain a sandbox domain? + */ + public function isSandbox(): bool + { + $result = preg_match("/^sandbox[a-z0-9]+\.mailgun\.org$/i", (string) $this->getApiDomain()); + return $result === 1; } /** * Get send via job option value */ - final protected function sendViaJob() + final protected function sendViaJob(): string { return $this->config()->get('send_via_job'); } /** - * When true, the Sender header is always set to the From value. When false, use {@link NSWDPC\Messaging\Mailgun\MailgunMailer::setSender()} to set the Sender header as required + * When true, the Sender header is always set to the From value */ - final protected function alwaysSetSender() + final protected function alwaysSetSender(): bool { return $this->config()->get('always_set_sender'); } /** - * Prior to any send/sendMime action, check config and set testmode if config says so + * apply test mode based on configuration value */ - final protected function applyTestMode(&$parameters) + final protected function applyTestMode(array &$parameters): void { - $mailgun_testmode = $this->config()->get('api_testmode'); - if ($mailgun_testmode) { + if ($this->config()->get('api_testmode')) { $parameters['o:testmode'] = 'yes'; } } /** * When Bcc/Cc is provided with no 'To', mailgun rejects the request (400 Bad Request), this method applies the configured default_recipient + * @deprecated */ - final public function applyDefaultRecipient(&$parameters) + final public function applyDefaultRecipient(&$parameters): void { if (empty($parameters['to']) && (!empty($parameters['cc']) || !empty($parameters['bcc'])) diff --git a/src/Connector/Bounce.php b/src/Connector/Bounce.php index 6467c66..e918bfc 100644 --- a/src/Connector/Bounce.php +++ b/src/Connector/Bounce.php @@ -1,4 +1,5 @@ getApiKey(); $client = Mailgun::create($api_key); $domain = $this->getApiDomain(); - $response = $client->suppressions()->bounces()->delete($domain, $email_address); - return $response; + return $client->suppressions()->bounces()->delete($domain, $email_address); } /** * See: https://documentation.mailgun.com/en/latest/api-suppressions.html#add-a-single-bounce */ - public function add($email_address, $code = 550, $error = "", $created_at = "") + public function add(string $email_address, int $code = 550, string $error = "", string $created_at = ""): ?\Mailgun\Model\Suppression\Bounce\CreateResponse { $valid = Email::is_valid_address($email_address); if (!$valid) { @@ -45,20 +45,18 @@ public function add($email_address, $code = 550, $error = "", $created_at = "") $params = []; - if ($code) { + if ($code > 0) { $params['code'] = $code; } - if ($error) { + if ($error !== '') { $params['error'] = $error; } - if ($created_at) { + if ($created_at !== '') { $params['created_at'] = $created_at; } - $response = $client->suppressions()->bounces()->create($domain, $email_address, $params); - - return $response; + return $client->suppressions()->bounces()->create($domain, $email_address, $params); } } diff --git a/src/Connector/Event.php b/src/Connector/Event.php index 03c5b37..803eefc 100644 --- a/src/Connector/Event.php +++ b/src/Connector/Event.php @@ -1,10 +1,10 @@ isDeliveredMessage((string)$event->MessageId, (string)$event->Recipient); + } + + /** + * Given a message id and recipient, check if the message linked to the event is delivered + */ + public function isDeliveredMessage(string $msgId, string $recipient): bool + { + + if ($msgId === '') { + throw new \UnexpectedValueException("Empty message id when checking isDelivered"); + } + + // poll for delivered events, MG stores them for up to 30 days + $timeframe = 'now -30 days'; + $begin = Base::DateTime($timeframe); + $event_filter = MailgunEvent::DELIVERED; + $extra_params = [ + 'limit' => 25, + 'message-id' => $msgId, + 'recipient' => $recipient + ]; + + $events = $this->pollEvents($begin, $event_filter, $extra_params); + return $events !== []; + } + /** * @param string $begin an RFC 2822 formatted UTC datetime OR empty string for no begin datetime * @param string $event_filter see https://documentation.mailgun.com/en/latest/api-events.html#event-types can also be a filter expression e.g "failed OR rejected" * @param array $extra_params extra parameters for API request - * @return array */ - public function pollEvents($begin = null, $event_filter = "", $extra_params = array()) + public function pollEvents(?string $begin = null, string $event_filter = "", array $extra_params = []): array { $api_key = $this->getApiKey(); $client = Mailgun::create($api_key); @@ -31,16 +65,16 @@ public function pollEvents($begin = null, $event_filter = "", $extra_params = ar 'ascending' => 'yes', ]; - if ($begin) { + if ($begin !== '') { $params['begin'] = $begin; } - if ($event_filter) { + if ($event_filter !== '') { $params['event'] = $event_filter; } // Push anything extra into the API request - if (!empty($extra_params) && is_array($extra_params)) { + if ($extra_params !== []) { $params = array_merge($params, $extra_params); } @@ -59,6 +93,7 @@ public function pollEvents($begin = null, $event_filter = "", $extra_params = ar // recursively retrieve the events based on pagination $this->getNextPage($client, $response); } + return $this->results; } @@ -89,8 +124,9 @@ private function getNextPage($client, $response) $items = $response->getItems(); if (empty($items)) { // no more items - nothing to do - return; + return null; } + // add to results $this->results = array_merge($this->results, $items); return $this->getNextPage($client, $response); diff --git a/src/Connector/Message.php b/src/Connector/Message.php index e0204a8..c9bc09e 100644 --- a/src/Connector/Message.php +++ b/src/Connector/Message.php @@ -1,30 +1,27 @@ getClient(); if (empty($event->StorageURL)) { - throw new Exception("No StorageURL found on MailgunEvent #{$event->ID}"); + throw new \Exception("No StorageURL found on MailgunEvent #{$event->ID}"); } + // Get the mime encoded message, by passing the Accept header $message = $client->messages()->show($event->StorageURL, true); return $message; @@ -82,10 +80,9 @@ public function getMime(MailgunEvent $event) /** * Send a message with parameters * See: https://documentation.mailgun.com/en/latest/api-sending.html#sending - * @return SendResponse|QueuedJobDescriptor|null - * @param array $parameters an array of parameters for the Mailgun API + * @param array $parameters an array of message parameters for the Mailgun API */ - public function send($parameters) + public function send($parameters): QueuedJobDescriptor|SendResponse { // If configured and not already specified, set the Sender hader @@ -107,23 +104,23 @@ public function send($parameters) // if required, apply the default recipient // a default recipient can be applied if the message has no "To" parameter - $this->applyDefaultRecipient($parameters); + // @deprecated + // $this->applyDefaultRecipient($parameters); // apply the webhook_filter_variable, if webhooks are enabled - if($this->getWebhooksEnabled() && ($variable = $this->getWebhookFilterVariable())) { + if ($this->getWebhooksEnabled() && ($variable = $this->getWebhookFilterVariable())) { $parameters["v:wfv"] = $variable; } // Send a message defined by the parameters provided return $this->sendMessage($parameters); - } /** * Sends a message - * @param array $parameters */ - protected function sendMessage(array $parameters) { + protected function sendMessage(array $parameters): QueuedJobDescriptor|SendResponse + { /** * @var \Mailgun\Mailgun @@ -137,21 +134,12 @@ protected function sendMessage(array $parameters) { // send options $send_via_job = $this->sendViaJob(); $in = $this->getSendIn();// seconds - switch ($send_via_job) { - case 'yes': - return $this->queueAndSend($domain, $parameters, $in); - break; - case 'when-attachments': - if (!empty($parameters['attachment'])) { - return $this->queueAndSend($domain, $parameters, $in); - break; - } - // fallback to direct - // no break - case 'no': - default: - return $client->messages()->send($domain, $parameters); - break; + if ($send_via_job === 'yes') { + return $this->queueAndSend($domain, $parameters, $in); + } elseif ($send_via_job === 'when-attachments' && !empty($parameters['attachment'])) { + return $this->queueAndSend($domain, $parameters, $in); + } else { + return $client->messages()->send($domain, $parameters); } } @@ -162,8 +150,8 @@ protected function sendMessage(array $parameters) { public function encodeAttachments(&$parameters) { if (!empty($parameters['attachment']) && is_array($parameters['attachment'])) { - foreach ($parameters['attachment'] as $k=>$attachment) { - $parameters['attachment'][$k]['fileContent'] = base64_encode($attachment['fileContent']); + foreach ($parameters['attachment'] as $k => $attachment) { + $parameters['attachment'][$k]['fileContent'] = base64_encode((string) $attachment['fileContent']); } } } @@ -175,88 +163,59 @@ public function encodeAttachments(&$parameters) public function decodeAttachments(&$parameters) { if (!empty($parameters['attachment']) && is_array($parameters['attachment'])) { - foreach ($parameters['attachment'] as $k=>$attachment) { - $parameters['attachment'][$k]['fileContent'] = base64_decode($attachment['fileContent']); + foreach ($parameters['attachment'] as $k => $attachment) { + $parameters['attachment'][$k]['fileContent'] = base64_decode((string) $attachment['fileContent']); } } } /** * Returns a DateTime being when the queued job should be started after - * @param string $in See:http://php.net/manual/en/datetime.formats.relative.php + * @param mixed $in See:http://php.net/manual/en/datetime.formats.relative.php */ - private function getSendDateTime($in) : ?\DateTime + private function getSendDateTime(mixed $in): ?\DateTime { try { - $dt = $default = null; - if($in > 0) { + $dt = null; + $default = null; + if ((is_int($in) || is_float($in)) && $in > 0) { $dt = new \DateTime("now +{$in} seconds"); } - } catch (\Exception $e) { + } catch (\Exception) { } - return $dt ? $dt : $default; + + return $dt instanceof \DateTime ? $dt : $default; } /** * Send via the queued job * @param string $domain the Mailgun API domain e.g sandboxXXXXXX.mailgun.org * @param array $parameters Mailgun API parameters - * @param string $in - * @return QueuedJobDescriptor|false + * @param mixed $in See:http://php.net/manual/en/datetime.formats.relative.php */ - protected function queueAndSend($domain, $parameters, $in) + protected function queueAndSend(string $domain, array $parameters, mixed $in): ?QueuedJobDescriptor { $this->encodeAttachments($parameters); $startAfter = null; - if($start = $this->getSendDateTime($in)) { + if (($start = $this->getSendDateTime($in)) instanceof \DateTime) { $startAfter = $start->format('Y-m-d H:i:s'); } - $job = new SendJob($domain, $parameters); - if($job_id = QueuedJobService::singleton()->queueJob($job, $startAfter)) { - return QueuedJobDescriptor::get()->byId($job_id); - } - return false; - } - /** - * Lookup all events for the submission linked to this event - */ - public function isDelivered(MailgunEvent $event, $cleanup = true) - { - - // Query will be for this MessageId and a delivered status - if (empty($event->MessageId)) { - throw new Exception("Tried to query a message based on MailgunEvent #{$event->ID} with no linked MessageId"); + $job = new SendJob($parameters); + if ($job_id = QueuedJobService::singleton()->queueJob($job, $startAfter)) { + return QueuedJobDescriptor::get()->byId($job_id); + } else { + return null; } - - // poll for delivered events, MG stores them for up to 30 days - $connector = new EventConnector(); - $timeframe = 'now -30 days'; - $begin = Base::DateTime($timeframe); - - $event_filter = MailgunEvent::DELIVERED; - $resubmit = false;// no we don't want to resubmit - $extra_params = [ - 'limit' => 25, - 'message-id' => $event->MessageId, - 'recipient' => $event->Recipient,// match against the recipient of the event - ]; - - $events = $connector->pollEvents($begin, $event_filter, $extra_params); - - $is_delivered = !empty($events); - return $is_delivered; } /** * Trim < and > from message id - * @return string * @param string $message_id */ - public static function cleanMessageId($message_id) + public static function cleanMessageId($message_id): string { - $message_id = trim($message_id, "<>"); - return $message_id; + return trim($message_id, "<>"); } /** @@ -264,12 +223,17 @@ public static function cleanMessageId($message_id) * This is not the "o:deliverytime" option ("Messages can be scheduled for a maximum of 3 days in the future.") * To set "deliverytime" set it as an option to setOptions() */ - public function setSendIn(float $seconds) { + public function setSendIn(int $seconds): static + { $this->send_in_seconds = $seconds; return $this; } - public function getSendIn() { + /** + * Return send-in-seconds value + */ + public function getSendIn(): int + { return $this->send_in_seconds; } @@ -278,125 +242,167 @@ public function getSendIn() { * and value is a dictionary with variables * that can be referenced in the message body. */ - public function setRecipientVariables(array $recipient_variables) { + public function setRecipientVariables(array $recipient_variables): static + { $this->recipient_variables = $recipient_variables; return $this; } /** - * @returns string|null + * Returns all recipient variables */ - public function getRecipientVariables() { + public function getRecipientVariables(): array + { return $this->recipient_variables; } - public function setAmpHtml(string $html) { + /** + * Set AMP HTML (see https://amp.dev/documentation/guides-and-tutorials/learn/spec/amphtml) + */ + public function setAmpHtml(string $html): static + { $this->amp_html = $html; return $this; } - public function getAmpHtml() { + /** + * Get the AMP html + */ + public function getAmpHtml(): string + { return $this->amp_html; } - public function setTemplate($template, $version = "", $include_in_text = "") { - if($template) { + /** + * Set a Mailgun template to use + */ + public function setTemplate($template, $version = "", $include_in_text = ""): static + { + if ($template) { $this->template = [ 'template' => $template, 'version' => $version, 'text' => $include_in_text == "yes" ? "yes" : "", ]; } + return $this; } - public function getTemplate() { + /** + * Get the Mailgun template + */ + public function getTemplate(): array + { return $this->template; } /** * Keys are not prefixed with "o:" */ - public function setOptions(array $options) { + public function setOptions(array $options): static + { $this->options = $options; return $this; } - public function getOptions() { + /** + * Set a single option in the options, will overwrite the current option set + * or create if not yet set + */ + public function setOption(string $name, mixed $value): static + { + $this->options[$name] = $value; + return $this; + } + + /** + * Get all options + */ + public function getOptions(): array + { return $this->options; } /** * Keys are not prefixed with "h:" */ - public function setCustomHeaders(array $headers) { + public function setCustomHeaders(array $headers): static + { $this->headers = $headers; return $this; } - public function getCustomHeaders() { + /** + * Get all custom headers + */ + public function getCustomHeaders(): array + { return $this->headers; } /** * Keys are not prefixed with "v:" */ - public function setVariables(array $variables) { + public function setVariables(array $variables): static + { $this->variables = $variables; return $this; } - public function getVariables() { + /** + * Get all variables + */ + public function getVariables() + { return $this->variables; } /** * Based on options set in {@link NSWDPC\Messaging\Mailgun\MailgunEmail} set Mailgun options, params, headers and variables - * @param array $parameters */ - protected function addCustomParameters(&$parameters) + protected function addCustomParameters(array &$parameters) { // VARIABLES $variables = $this->getVariables(); - foreach($variables as $k=>$v) { + foreach ($variables as $k => $v) { $parameters["v:{$k}"] = $v; } // OPTIONS $options = $this->getOptions(); - foreach($options as $k=>$v) { + foreach ($options as $k => $v) { $parameters["o:{$k}"] = $v; } // TEMPLATE $template = $this->getTemplate(); - if(!empty($template['template'])) { + if (!empty($template['template'])) { $parameters["template"] = $template['template']; - if(!empty($template['version'])) { + if (!empty($template['version'])) { $parameters["t:version"] = $template['version']; } - if(isset($template['text']) && $template['text'] == "yes") { + + if (isset($template['text']) && $template['text'] == "yes") { $parameters["t:text"] = $template['text']; } } // AMP HTML handling - if($amp_html = $this->getAmpHtml()) { + if (($amp_html = $this->getAmpHtml()) !== '') { $parameters["amp-html"] = $amp_html; } // HEADERS $headers = $this->getCustomHeaders(); - foreach($headers as $k=>$v) { + foreach ($headers as $k => $v) { $parameters["h:{$k}"] = $v; } // RECIPIENT VARIABLES - if($recipient_variables = $this->getRecipientVariables()) { + if (($recipient_variables = $this->getRecipientVariables()) !== []) { $parameters["recipient-variables"] = json_encode($recipient_variables); } - } - } diff --git a/src/Connector/Webhook.php b/src/Connector/Webhook.php index 65dee7c..031ce8c 100644 --- a/src/Connector/Webhook.php +++ b/src/Connector/Webhook.php @@ -2,6 +2,7 @@ namespace NSWDPC\Messaging\Mailgun\Connector; +use NSWDPC\Messaging\Mailgun\Controllers\MailgunWebHook; use NSWDPC\Messaging\Mailgun\MailgunEvent; use NSWDPC\Messaging\Mailgun\Log; use Mailgun\Mailgun; @@ -10,42 +11,47 @@ /** * Webhook integration with Mailgun PHP SDK */ -class Webhook extends Base { - +class Webhook extends Base +{ /** - * verify signature - * @return bool returns true if signature is valid - * @param array $signature + * verify signature, which is an array of data in the main payload + * See https://documentation.mailgun.com/docs/mailgun/user-manual/tracking-messages/#securing-webhooks + * @param array $signature the signature part of the payload */ - public function verify_signature($signature) + public function verify_signature(array $signature): bool { - if($this->is_valid_signature($signature)) { - return hash_equals( $this->sign_token($signature), $signature['signature']); + if ($this->is_valid_signature($signature)) { + // check that the signed signature matches the signature provided + return hash_equals($this->sign_token($signature), $signature['signature']); } + return false; } /** - * Sign the token based on timestamp and signature in request - * @param array $signature + * Sign the token and timestamp from the signature data provided, with the configured signing key + * @param array $signature the signature part of the payload */ - public function sign_token($signature) { + public function sign_token(array $signature): string + { $webhook_signing_key = $this->getWebhookSigningKey(); - if(!$webhook_signing_key) { + if ($webhook_signing_key !== '') { + return hash_hmac('sha256', $signature['timestamp'] . $signature['token'], $webhook_signing_key); + } else { throw new \Exception("Please set a webhook signing key in configuration"); } - return hash_hmac( 'sha256', $signature['timestamp'] . $signature['token'], $webhook_signing_key ); + } /** - * Based on Mailgun docs, determine if the signature is correct - * @param array $signature + * Based on Mailgun docs, determine if the signature is correctly formatted + * @param array $signature the signature part of the payload */ - public function is_valid_signature($signature) { + public function is_valid_signature($signature): bool + { return isset($signature['timestamp']) && isset($signature['token']) - && strlen($signature['token']) == 50 + && strlen((string) $signature['token']) == 50 && isset($signature['signature']); } - } diff --git a/src/Controllers/MailgunModelAdmin.php b/src/Controllers/MailgunModelAdmin.php index 7fd9242..17a108b 100644 --- a/src/Controllers/MailgunModelAdmin.php +++ b/src/Controllers/MailgunModelAdmin.php @@ -1,9 +1,12 @@ Fields()->dataFieldByName($this->sanitiseClassName($this->modelClass)); - if($grid instanceof GridField) { + if ($grid instanceof GridField) { $config = $grid->getConfig(); $config->removeComponentsByType(GridFieldAddNewButton::class); $config->removeComponentsByType(GridFieldPrintButton::class); - if(! Permission::check( MailgunEvent::PERMISSIONS_DELETE, 'any', Member::currentUser()) ) { + if (! Permission::check(MailgunEvent::PERMISSIONS_DELETE, 'any', Security::getCurrentUser())) { $config->removeComponentsByType(GridFieldEditButton::class); $config->removeComponentsByType(GridFieldDeleteAction::class); } diff --git a/src/Controllers/MailgunWebHook.php b/src/Controllers/MailgunWebHook.php index df468b7..6818f3e 100644 --- a/src/Controllers/MailgunWebHook.php +++ b/src/Controllers/MailgunWebHook.php @@ -1,43 +1,64 @@ true ]; /** * Retrieve webook signing key from config + * This uses configuration from env */ - protected function getConnector() { - return Webhook::create(); + protected function getConnector(): Webhook + { + Injector::inst()->create(TransportInterface::class); + $key = (string)Environment::getEnv('MAILGUN_WEBHOOK_API_KEY'); + $domain = (string)Environment::getEnv('MAILGUN_WEBHOOK_DOMAIN'); + $region = (string)Environment::getEnv('MAILGUN_WEBHOOK_REGION'); + $options = ""; + if ($region !== '') { + $options = "?region={$region}"; + } + + $dsn = "mailgunsync+api://{$domain}:{$key}@default/{$options}"; + return Webhook::create($dsn); } /** * Return JSON encoded response body */ - protected function getResponseBody($success = true) { + protected function getResponseBody($success = true): string + { $data = [ 'success' => $success ]; @@ -47,9 +68,10 @@ protected function getResponseBody($success = true) { /** * We have done something wrong */ - protected function serverError($status_code = 503, $message = "") { - Log::log($message, \Psr\Log\LogLevel::NOTICE); - $response = HTTPResponse::create( $this->getResponseBody(false), $status_code); + protected function serverError($status_code = 503, $message = ""): HTTPResponse + { + Logger::log($message, \Psr\Log\LogLevel::NOTICE); + $response = HTTPResponse::create($this->getResponseBody(false), $status_code); $response->addHeader('Content-Type', 'application/json'); return $response; } @@ -57,8 +79,9 @@ protected function serverError($status_code = 503, $message = "") { /** * Client (being Mailgun user agent) has done something wrong */ - protected function clientError($status_code = 400, $message = "") { - Log::log($message, \Psr\Log\LogLevel::NOTICE); + protected function clientError($status_code = 400, $message = ""): HTTPResponse + { + Logger::log($message, \Psr\Log\LogLevel::NOTICE); $response = HTTPResponse::create($this->getResponseBody(false), $status_code); $response->addHeader('Content-Type', 'application/json'); return $response; @@ -67,7 +90,8 @@ protected function clientError($status_code = 400, $message = "") { /** * All is good */ - protected function returnOK($status_code = 200, $message = "OK") { + protected function returnOK($status_code = 200, $message = "OK"): HTTPResponse + { $response = HTTPResponse::create($this->getResponseBody(true), $status_code); $response->addHeader('Content-Type', 'application/json'); return $response; @@ -76,7 +100,8 @@ protected function returnOK($status_code = 200, $message = "OK") { /** * Ignore / requests */ - public function index($request) { + public function index($request): HTTPResponse + { return $this->clientError(404, "Not Found"); } @@ -85,76 +110,73 @@ public function index($request) { * @throws \Exception|WebhookServerException|WebhookClientException|WebhookNotAcceptableException * The exception thrown depends on the error found. A 406 error will stop Mailgun from retrying a particular request */ - public function submit(HTTPRequest $request = null) { - + public function submit(HTTPRequest $request = null): HTTPResponse + { try { - $connector = $this->getConnector(); // turned off in configuration - but allow retry if config error - if(!$connector->getWebhooksEnabled()) { + if (!$connector->getWebhooksEnabled()) { throw new WebhookServerException("Not enabled", 503); } // requests are always posts - Mailgun should only POST - if(!$request->isPOST()) { + if (!$request->isPOST()) { throw new WebhookClientException("Method not allowed", 405); } // requests are application/json $content_type = $request->getHeader('Content-Type'); - if($content_type != "application/json") { + if ($content_type != "application/json") { throw new WebhookClientException("Unexpected content-type: {$content_type}"); } // POST body - $payload = json_decode($request->getBody(), true); - if(!$payload) { + $payload = json_decode((string) $request->getBody(), true); + if (!$payload) { throw new WebhookClientException("No payload found"); } // No sig found - if(!isset($payload['signature'])) { + if (!isset($payload['signature'])) { throw new WebhookClientException("Missing payload data - signature"); } // No event data found - if(!isset($payload['event-data'])) { + if (!isset($payload['event-data'])) { // TODO - this is probably a client error throw new WebhookClientException("Missing payload data - event-data"); } // verify the variable, if set, is in the payload, ignore submission $variable = $connector->getWebhookFilterVariable();//from config - if($variable) { + if ($variable !== '') { $webhook_filter_ok = false; $previous_variable = $connector->getWebhookPreviousFilterVariable();//from config - if(!empty($payload['event-data']['user-variables']['wfv'])) { - if($payload['event-data']['user-variables']['wfv'] == $variable - || $payload['event-data']['user-variables']['wfv'] == $previous_variable) { - // the webhook submission equals the current or previous variable - $webhook_filter_ok = true; - } + if (!empty($payload['event-data']['user-variables']['wfv']) && ($payload['event-data']['user-variables']['wfv'] == $variable + || $payload['event-data']['user-variables']['wfv'] == $previous_variable)) { + // the webhook submission equals the current or previous variable + $webhook_filter_ok = true; } - if(!$webhook_filter_ok) { + + if (!$webhook_filter_ok) { // respond with a 400 not a 406 (possible configuration error: allow time to fix) throw new WebhookClientException("Webhook filter variable mismatch", 400); } } // Not a valid signature - this could happen if the signing key is recycled - if(!$connector->verify_signature($payload['signature'])) { + if (!$connector->verify_signature($payload['signature'])) { throw new WebhookNotAcceptableException("Signature verification failed"); } $event = \Mailgun\Model\Event\Event::create($payload['event-data']); $me = MailgunEvent::create(); - if(($mailgun_event = $me->storeEvent($event)) && $mailgun_event->exists()) { + if (($mailgun_event = $me->storeEvent($event)) && $mailgun_event->exists()) { return $this->returnOk(); } throw new WebhookServerException("Failed to save local record", 503); - } catch (WebhookServerException $e) { // we did something wrong, Mailgun will try again return $this->serverError($e->getCode(), $e->getMessage()); @@ -168,6 +190,5 @@ public function submit(HTTPRequest $request = null) { //general server error return $this->serverError(500, $e->getMessage()); } - } } diff --git a/src/Email/MailgunEmail.php b/src/Email/MailgunEmail.php index 1270081..d8743f6 100644 --- a/src/Email/MailgunEmail.php +++ b/src/Email/MailgunEmail.php @@ -1,20 +1,21 @@ connector = Injector::inst()->get( Message::class ); - return $this->connector; - } - - /** - * Get the custom parameters for this particular message - * Custom parameters are retrievable once to avoid replaying them across - * multiple messages + * Custom parameters for the mailer, if it is supported */ - public function getCustomParameters() : array { - $customParameters = $this->customParameters; - $this->clearCustomParameters(); - return $customParameters; - } - - /** - * Clear custom parameters - * @return self - */ - public function clearCustomParameters() { - $this->customParameters = []; - return $this; - } + use CustomParameters; /** - * Set custom parameters on the message connector - * @return self + * Set tags as options on the Mailgun API */ - public function setCustomParameters(array $args) { - $this->customParameters = $args; + public function setNotificationTags(array $tags): static + { + $this->setTaggableNotificationTags($tags); return $this; } diff --git a/src/Email/MailgunMailer.php b/src/Email/MailgunMailer.php deleted file mode 100644 index 1b6820a..0000000 --- a/src/Email/MailgunMailer.php +++ /dev/null @@ -1,379 +0,0 @@ -send(); - * See: https://docs.silverstripe.org/en/4/developer_guides/email/ for Email documentation. - */ -class MailgunMailer implements Mailer -{ - /** - * Allow configuration via API - */ - use Configurable; - - /** - * Injector - */ - use Injectable; - - // configured in project - private static $always_from = ""; - - // or set via Injector - public $alwaysFrom;// when set, override From address, applying From provided to Reply-To header, set original "From" as "Sender" header - - /** - * @var array An array of headers that Swift produces and Mailgun probably doesn't need - */ - private static $denylist_headers = [ - 'Content-Type', - 'MIME-Version', - 'Date', - 'Message-ID', - ]; - - public function getAlwaysFrom() { - $always_from = $this->config()->get('always_from'); - if(!$always_from && $this->alwaysFrom) { - $always_from = $this->alwaysFrom; - } - return $always_from; - } - - /** - * Retrieve and set custom parameters on the API connector - * @param MailgunEmail $email - * @param MessageConnector $connector instance for this send attempt - * @return MessageConnector - */ - protected function assignCustomParameters(MailgunEmail &$email, MessageConnector &$connector) : MessageConnector { - $customParameters = $email->getCustomParameters(); - $email->clearCustomParameters(); - $connector->setVariables( $customParameters['variables'] ?? [] ) - ->setOptions( $customParameters['options'] ?? [] ) - ->setCustomHeaders( $customParameters['headers'] ?? [] ) - ->setRecipientVariables( $customParameters['recipient-variables'] ?? [] ) - ->setSendIn($customParameters['send-in'] ?? 0) - ->setAmpHtml($customParameters['amp-html'] ?? '') - ->setTemplate($customParameters['template'] ?? []); - return $connector; - } - - /** - * @param Email $email - * @return mixed - */ - public function send($email) - { - try { - - // API client for this send attempt - $connector = MessageConnector::create(); - - // Prepare all parameters for sending - $parameters = $this->prepareParameters($email, $connector); - - // Send the payload - $response = $connector->send($parameters); - if($response instanceof SendResponse) { - // get a message.id from the response - $message_id = $this->saveResponse($response); - // return the message_id - return $message_id; - } else if($response instanceof QueuedJobDescriptor) { - // return job - return $response; - } else { - throw new \Exception("Tried to send, expected a SendResponse or a QueuedJobDescriptor but got type=" . gettype($response)); - } - } catch (\Exception $e) { - Log::log('Mailgun-Sync / Mailgun error: ' . $e->getMessage(), \Psr\Log\LogLevel::NOTICE); - } - return false; - } - - /** - * Process to, from, cc, bcc recipient headers that are in a email => displayName format - * Returns a flattened array of values being recipients understandable to the Mailgun API - * @return array - */ - public function processEmailDisplayName(array $data) { - $list = []; - foreach ($data as $email => $displayName) { - if (!empty($displayName)) { - $list[] = $displayName . " <" . $email . ">"; - } else { - $list[] = $email; - } - } - return $list; - } - - /** - * Given a Swift_Message, prepare parameters for the API send - * @param Email $email a SilverStripe Email instance - * @param MessageConnector $connector the connector to the Mailgun PHP SDK client - * @return array of parameters for the Mailgun API - */ - public function prepareParameters(Email $email, MessageConnector $connector) : array { - - /** - * @var Swift_Message - */ - $message = $email->getSwiftMessage(); - - if (!$message instanceof Swift_Message) { - throw new InvalidRequestException("There is no message associated with this request"); - } - - $recipients = $senders = []; - - // Handle 'From' headers from Swift_Message - $message_from = $message->getFrom(); - if (!empty($message_from)) { - $senders = $this->processEmailDisplayName($message_from); - } - - // Handle 'To' headers from Swift_Message - $message_to = $message->getTo(); - if (!empty($message_to)) { - $recipients = $this->processEmailDisplayName($message_to); - } - - // handle the message subject - $subject = $message->getSubject(); - - $to = implode(",", $recipients); - $from = implode(",", $senders); - - // Assign custom parameters to the connector - if($email instanceof MailgunEmail) { - $this->assignCustomParameters($email, $connector); - } - - // process attachments - $attachments = $this->prepareAttachments($message->getChildren()); - - // process headers - $headers = $message->getHeaders(); - if ($headers instanceof Swift_Mime_SimpleHeaderSet) { - $headers = $this->prepareHeaders( $headers ); - } else { - // ensure empty array - $headers = []; - } - - // parameters for the API - $parameters = []; - - // check if $always_from is set - if ($always_from = $this->getAlwaysFrom()) { - $parameters['h:Reply-To'] = $from;// set the from as a replyto - $from = $always_from;// replace 'from' - $headers['Sender'] = $from;// set in addCustomParameters below - } - - /** - * Message parts - */ - $plain = $email->findPlainPart(); - $plain_body = ''; - if($plain) { - $plain_body = $plain->getBody(); - } - - $parameters = array_merge($parameters, [ - 'from' => $from, - 'to' => $to, - 'subject' => $subject, - 'text' => $plain_body, - 'html' => $email->getBody() - ]); - - // HEADERS: these generic headers override anything passed in or added as a custom parameter - - // if Cc and Bcc have been provided - if (isset($headers['Cc'])) { - $parameters['cc'] = $headers['Cc']; - } - if (isset($headers['Bcc'])) { - $parameters['bcc'] = $headers['Bcc']; - } - - // Provide Mailgun the Attachments. Keys are 'fileContent' (the bytes) and filename (the file name) - // If the key filename is not provided, Mailgun will use the name of the file, which may not be what you want displayed - // TODO inline attchment disposition - if (!empty($attachments) && is_array($attachments)) { - $parameters['attachment'] = $attachments; - } - - // Assign default parameters - $this->assignDefaultParameters($parameters); - - // Finally: handle always from, which is our legacy handling - if ($always_from = $this->getAlwaysFrom()) { - $parameters['h:Reply-To'] = $from;// set the from as a replyto - $parameters['from'] = $always_from;// set from header - $parameters['h:Sender'] = $parameters['from'];// set Send header as new from - } - - return $parameters; - } - - /** - * Given {@link \SilverStripe\Control\Email\Email} configuration, apply relevant values - * @param array $parameters - */ - public function assignDefaultParameters(&$parameters) { - - // Override send all emails to - $sendAllEmailsTo = Email::getSendAllEmailsTo(); - if($sendAllEmailsTo) { - if(is_string($sendAllEmailsTo)) { - $parameters['to'] = $sendAllEmailsTo; - } else if(is_array($sendAllEmailsTo)) { - $sendAllEmailsTo = $this->processEmailDisplayName($sendAllEmailsTo); - $parameters['to'] = implode(",", $sendAllEmailsTo); - } else { - throw new \Exception("Email::getSendAllEmailsTo should be a string or array"); - } - } - - // Override from address, note always_from overrides this - $sendAllEmailsFrom = Email::getSendAllEmailsFrom(); - if($sendAllEmailsFrom) { - if(is_string($sendAllEmailsFrom)) { - $parameters['from'] = $sendAllEmailsFrom; - } else if(is_array($sendAllEmailsFrom)) { - $sendAllEmailsFrom = $this->processEmailDisplayName($sendAllEmailsFrom); - $parameters['from'] = implode(",",$sendAllEmailsFrom); - } else { - throw new \Exception("Email::getSendAllEmailsFrom should be a string or array"); - } - } - - // Add or set CC defaults - $ccAllEmailsTo = Email::getCCAllEmailsTo(); - if($ccAllEmailsTo) { - $cc = ''; - if(is_string($ccAllEmailsTo)) { - $cc = $ccAllEmailsTo; - } else if(is_array($ccAllEmailsTo)) { - $ccAllEmailsTo = $this->processEmailDisplayName($ccAllEmailsTo); - $cc = implode(",", $ccAllEmailsTo); - } else { - throw new \Exception("Email::getCCAllEmailsTo should be a string or array"); - } - - if($cc) { - if(isset($parameters['cc'])) { - $parameters['cc'] .= "," . $cc; - } else { - $parameters['cc'] = $cc; - } - } - } - - // Add or set BCC defaults - $bccAllEmailsTo = Email::getBCCAllEmailsTo(); - if($bccAllEmailsTo) { - $bcc = ''; - if(is_string($bccAllEmailsTo)) { - $bcc = $bccAllEmailsTo; - } else if(is_array($bccAllEmailsTo)) { - $bccAllEmailsTo = $this->processEmailDisplayName($bccAllEmailsTo); - $bcc = implode(",", $bccAllEmailsTo); - } else { - throw new \Exception("Email::getBCCAllEmailsTo should be a string or array"); - } - - if($bcc) { - if(isset($parameters['bcc'])) { - $parameters['bcc'] .= "," . $bcc; - } else { - $parameters['bcc'] = $bcc; - } - } - } - - } - - /** - * @return array - * Prepare headers for use in Mailgun - */ - protected function prepareHeaders(Swift_Mime_SimpleHeaderSet $header_set) - { - $list = $header_set->getAll(); - $headers = []; - foreach ($list as $header) { - // Swift_Mime_Headers_ParameterizedHeader - $headers[ $header->getFieldName() ] = $header->getFieldBody(); - } - $denylist = $this->config()->get('denylist_headers'); - if (is_array($denylist)) { - $denylist = array_merge( - $denylist, - [ 'From', 'To', 'Subject'] // avoid multiple headers and RFC5322 issues with a From: appearing twice, for instance - ); - foreach ($denylist as $header_name) { - unset($headers[ $header_name ]); - } - } - return $headers; - } - - /** - * @note refer to {@link Mailgun\Api\Message::prepareFile()} which is the preferred way of attaching messages from 3.0 onwards as {@link Mailgun\Connection\RestClient} is deprecated - * This overrides writing to temp files as Silverstripe {@link Email::attachFileFromString()} already provides the attachments in the following way: - * 'contents' => $data, - * 'filename' => $filename, - * 'mimetype' => $mimetype, - * @param array $attachments Each value is a {@link Swift_Attachment} - */ - protected function prepareAttachments(array $attachments) - { - $mailgun_attachments = []; - foreach ($attachments as $attachment) { - if (!$attachment instanceof Swift_Attachment) { - continue; - } - $mailgun_attachments[] = [ - 'fileContent' => $attachment->getBody(), - 'filename' => $attachment->getFilename(), - 'mimetype' => $attachment->getContentType() - ]; - } - return $mailgun_attachments; - } - - /* - object(Mailgun\Model\Message\SendResponse)[1740] - private 'id' => string '' (length=92) - private 'message' => string 'Queued. Thank you.' (length=18) - */ - final protected function saveResponse($message) - { - $message_id = $message->getId(); - $message_id = MessageConnector::cleanMessageId($message_id); - return $message_id; - } - -} diff --git a/src/Exceptions/InvalidRequestException.php b/src/Exceptions/InvalidRequestException.php index 29feb28..885ed7d 100644 --- a/src/Exceptions/InvalidRequestException.php +++ b/src/Exceptions/InvalidRequestException.php @@ -1,5 +1,6 @@ filter([ 'JobStatus' => QueuedJob::STATUS_BROKEN, 'Implementation' => SendJob::class ]); $count = $descriptors->count(); - $kick = $skip = 0; - if($count > 0) { + $kick = 0; + $skip = 0; + if ($count > 0) { $this->totalSteps = $count; - foreach($descriptors as $descriptor) { - + foreach ($descriptors as $descriptor) { $data = @unserialize($descriptor->SavedJobData); - if(empty($data->parameters)) { + if (empty($data->parameters)) { // parameters cleared so pointless re-queuing $skip++; continue; @@ -55,7 +54,7 @@ public function process() $descriptor->JobStatus = QueuedJob::STATUS_NEW; $descriptor->StepsProcessed = 0; $descriptor->LastProcessedCount = -1; - $descriptor->Worker = null;// clear otherwise job is considered locked + $descriptor->Worker = '';// clear otherwise job is considered locked $descriptor->write(); $kick++; @@ -64,7 +63,7 @@ public function process() $this->addMessage( _t( - __CLASS__ . '.JOB_STATUS', + self::class . '.JOB_STATUS', "Marked {kick}, ignored {skip} broken SendJob descriptors as new", [ 'kick' => $kick, @@ -73,11 +72,10 @@ public function process() ), "info" ); - } else { $this->addMessage( _t( - __CLASS__ . '.JOB_STATUS_NO_JOBS', + self::class . '.JOB_STATUS_NO_JOBS', "No jobs can be re-queued" ), "info" @@ -85,6 +83,5 @@ public function process() } $this->isComplete = true; - } } diff --git a/src/Jobs/SendJob.php b/src/Jobs/SendJob.php index 60db017..c066e7d 100644 --- a/src/Jobs/SendJob.php +++ b/src/Jobs/SendJob.php @@ -1,12 +1,18 @@ parameters; @@ -42,7 +45,7 @@ public function getTitle() $from = $parameters['from'] ?? 'from not set'; $testmode = $parameters['o:testmode'] ?? 'no'; return _t( - __CLASS__ . ".JOB_TITLE", + self::class . ".JOB_TITLE", "Email via Mailgun To: '{to}' From: '{from}' Subject: '{subject}' Test mode: '{testmode}'", [ 'to' => $to, @@ -61,9 +64,10 @@ public function getSignature() $params = []; // these simple message params $parts = ['to','from','cc','bcc','subject']; - foreach($parts as $part) { - $params[ $part ] = isset($this->parameters[ $part ]) ? $this->parameters[ $part ] : ''; + foreach ($parts as $part) { + $params[ $part ] = $this->parameters[ $part ] ?? ''; } + // at this time $params['sendtime'] = microtime(true); return md5($this->domain . ":" . serialize($params)); @@ -71,14 +75,11 @@ public function getSignature() /** * Create the job - * @param string $domain DEPRECATED * @param array $parameters for Mailgun API */ - public function __construct($domain = "", $parameters = []) + public function __construct($parameters = []) { - $this->connector = MessageConnector::create(); - $this->domain = $this->connector->getApiDomain(); - if(!empty($parameters)) { + if ($parameters !== []) { $this->parameters = $parameters; } } @@ -88,9 +89,7 @@ public function __construct($domain = "", $parameters = []) */ public function process() { - try { - if ($this->isComplete) { // the job has already been marked complete return; @@ -98,30 +97,41 @@ public function process() $this->currentStep++; - $client = $this->connector->getClient(); - $domain = $this->connector->getApiDomain(); + $transport = Injector::inst()->create(TransportInterface::class); + if (!($transport instanceof MailgunSyncApiTransport)) { + $type = get_debug_type($transport); + + // This job can only be processed with a MailgunSyncApiTransport + throw new \RuntimeException("SendJob::process() expected a MailgunSyncApiTransport to send the email, got a {$type}"); + } + + $connector = MessageConnector::create($transport->getDsn()); + + $client = $connector->getClient(); + $domain = $connector->getApiDomain(); if (!$domain) { $msg = _t( - __CLASS__ . ".MISSING_API_DOMAIN", + self::class . ".MISSING_API_DOMAIN", "Mailgun configuration is missing the Mailgun API domain value" ); throw new JobProcessingException($msg); } $parameters = $this->parameters; - if(empty($parameters)) { + if (empty($parameters)) { $msg = _t( - __CLASS__ . ".EMPTY_PARAMS", + self::class . ".EMPTY_PARAMS", "Mailgun SendJob was called with empty parameters" ); throw new JobProcessingException($msg); } // if required, apply the default recipient - $this->connector->applyDefaultRecipient($parameters); + // @deprecated + // $connector->applyDefaultRecipient($parameters); // decode all attachments - $this->connector->decodeAttachments($parameters); + $connector->decodeAttachments($parameters); // send directly via the API client $response = $client->messages()->send($domain, $parameters); $message_id = ""; @@ -138,16 +148,15 @@ public function process() throw new JobProcessingException( $this->addMessage( _t( - __CLASS__ . ".SEND_INVALID_RESPONSE_FROM_MAILGUN", + self::class . ".SEND_INVALID_RESPONSE_FROM_MAILGUN", "SendJob invalid response or no message.id returned" ) ) ); - } catch (JobProcessingException $e) { $this->addMessage( _t( - __CLASS__ . ".SEND_EXCEPTON", + self::class . ".SEND_EXCEPTON", "Mailgun send processing exception: {error}", [ "error" => $e->getMessage() @@ -158,7 +167,7 @@ public function process() } catch (\Exception $e) { $this->addMessage( _t( - __CLASS__ . ".GENERAL_EXCEPTON", + self::class . ".GENERAL_EXCEPTON", "Mailgun send general exception: {error}", [ "error" => $e->getMessage() @@ -171,14 +180,13 @@ public function process() /** * Mark the job as broken. This avoids repeated requests to the API * for the same send attempt and possibly cause quota issues. - * Semd attempts that arrive here need to be manually re-queued + * Send attempts that arrive here need to be manually re-queued */ throw new \Exception( _t( - __CLASS__ . ".MAILGUN_SEND_FAILED", - "Mailgun send failed. Check status.mailgun.com or connectivity?" + self::class . ".MAILGUN_SEND_FAILED", + "Mailgun send failed. Check log messages, status.mailgun.com or connectivity?" ) ); - } } diff --git a/src/Jobs/TruncateJob.php b/src/Jobs/TruncateJob.php index a0dc382..540616d 100644 --- a/src/Jobs/TruncateJob.php +++ b/src/Jobs/TruncateJob.php @@ -1,15 +1,15 @@ days > 0) { + if ($this->days > 0) { // allow for parts of days to the nearest hour $hours = round($this->days * 24); - $dt = new DateTime("now -{$hours}hour"); + $dt = new \DateTime("now -{$hours}hour"); } else { - $dt = new DateTime(); + $dt = new \DateTime(); } + $dt_formatted = $dt->format('Y-m-d H:i:s'); $this->addMessage("Removing events created before {$dt_formatted}", "info"); $events = MailgunEvent::get()->filter('Created:LessThan', $dt_formatted); $count = $events->count(); - if($count > 0) { + if ($count > 0) { $events->removeAll(); $this->addMessage("Removed {$count} events", "info"); } else { $this->addMessage("No events to remove", "info"); } + $this->currentStep = 1; $this->isComplete = true; } @@ -75,13 +77,14 @@ public function process() */ public function afterComplete() { - $next = new DateTime(); + $next = new \DateTime(); $next->modify('+' . $this->recreate_in . ' seconds'); + $job = new TruncateJob($this->days, $this->recreate_in); $service = singleton(QueuedJobService::class); $descriptor_id = $service->queueJob($job, $next->format('Y-m-d H:i:s')); if (!$descriptor_id) { - Log::log("Failed to queue new TruncateJob!", \Psr\Log\LogLevel::WARNING); + Logger::log("Failed to queue new TruncateJob!", \Psr\Log\LogLevel::WARNING); } } } diff --git a/src/Models/MailgunEvent.php b/src/Models/MailgunEvent.php index 7af9292..26ce0ef 100644 --- a/src/Models/MailgunEvent.php +++ b/src/Models/MailgunEvent.php @@ -1,25 +1,25 @@ '#', 'EventType' => 'Event', 'Severity' => 'Severity', @@ -113,10 +122,9 @@ class MailgunEvent extends DataObject implements PermissionProvider ]; /** - * Defines a default list of filters for the search context - * @var array + * @inheritdoc */ - private static $searchable_fields = [ + private static array $searchable_fields = [ 'Reason', 'Severity', 'EventType', @@ -126,9 +134,9 @@ class MailgunEvent extends DataObject implements PermissionProvider ]; /** - * @var array + * @inheritdoc */ - private static $indexes = [ + private static array $indexes = [ 'Created' => true, 'LastEdited' => true, 'EventType' => true, @@ -168,7 +176,7 @@ public function requireDefaultRecords() /** * Set permission groups */ - private function createGroupsAndPermissions() + private function createGroupsAndPermissions(): void { $manager_code = 'mailgun-managers'; $manager_group = Group::get()->filter('Code', $manager_code)->first(); @@ -176,15 +184,17 @@ private function createGroupsAndPermissions() $manager_group = Group::create(); $manager_group->Code = $manager_code; } + $manager_group->Title = "Mailgun Managers"; $manager_group_id = $manager_group->write(); if ($manager_group_id) { $permissions = $manager_group->Permissions()->filter('Code', [ self::PERMISSIONS_DELETE, self::PERMISSIONS_VIEW ]); $codes = $permissions->column('Code'); - if(!in_array( self::PERMISSIONS_DELETE, $codes)) { + if (!in_array(self::PERMISSIONS_DELETE, $codes)) { Permission::grant($manager_group_id, self::PERMISSIONS_DELETE); } - if(!in_array( self::PERMISSIONS_VIEW, $codes)) { + + if (!in_array(self::PERMISSIONS_VIEW, $codes)) { Permission::grant($manager_group_id, self::PERMISSIONS_VIEW); } } @@ -201,13 +211,13 @@ public function getTitle() /** * Returns the age of the event, in seconds */ - public function Age() + public function Age(): ?float { if ($this->Timestamp == 0) { - return false; + return null; } - $age = time() - $this->Timestamp; - return $age; + + return time() - $this->Timestamp; } /** @@ -224,8 +234,9 @@ public function canEdit($member = null) public function canDelete($member = null) { if (!$member) { - $member = Member::currentUser(); + $member = Security::getCurrentUser(); } + return Permission::check(self::PERMISSIONS_DELETE, 'any', $member); } @@ -235,8 +246,9 @@ public function canDelete($member = null) public function canView($member = null) { if (!$member) { - $member = Member::currentUser(); + $member = Security::getCurrentUser(); } + return Permission::check(self::PERMISSIONS_VIEW, 'any', $member); } @@ -270,7 +282,7 @@ public function getCmsFields() // show a list of related events sharing the same MessageId $siblings = $this->getSiblingEvents(); - if ($siblings && $siblings->count() > 0) { + if ($siblings->count() > 0) { $config = GridFieldConfig_RecordEditor::create(); $config->removeComponentsByType('GridFieldEditButton'); $gridfield = GridField::create('SiblingEventsGridField', 'Siblings', $siblings, $config); @@ -284,56 +296,52 @@ public function getCmsFields() /** * Events that are sibling to this event (sharing the smae MessageId) - * @return \SilverStripe\ORM\DataList */ - public function getSiblingEvents() + public function getSiblingEvents(): DataList { - $events = MailgunEvent::get()->filter('MessageId', $this->MessageId)->sort('Timestamp ASC'); - return $events; + return MailgunEvent::get()->filter('MessageId', $this->MessageId)->sort('Timestamp ASC'); } /** * UTC date/time based on Timestamp of this event - * @return string */ - public function UTCDateTime() + public function UTCDateTime(): string { return $this->RecordDateTime("UTC"); } /** * Local date/time based on Timestamp of this event - * @return string */ - public function LocalDateTime() + public function LocalDateTime(): string { return $this->RecordDateTime("Australia/Sydney"); } /** * Return RFC2822 formatted string of event timestamp - * @return string */ - private function RecordDateTime($timezone = "UTC") + private function RecordDateTime(string $timezone = "UTC"): string { if (!$this->Timestamp) { return ""; } - $dt = new DateTime(); + + $dt = new \DateTime(); $dt->setTimestamp($this->Timestamp); - $dt->setTimezone(new DateTimeZone($timezone)); - return $dt->format(DateTime::RFC2822); + $dt->setTimezone(new \DateTimeZone($timezone)); + return $dt->format(\DateTime::RFC2822); } /** * Combining all event types that are related to a user action */ - public static function UserActionStatus() + public static function UserActionStatus(): array { return [ self::OPENED, self::CLICKED, self::UNSUBSCRIBED, self::COMPLAINED ]; } - public function IsFailed() + public function IsFailed(): bool { return $this->EventType == self::FAILED; } @@ -341,130 +349,127 @@ public function IsFailed() /** * @deprecated use IsFailed() in order to match API event naming */ - public function IsFailure() + public function IsFailure(): bool { return $this->IsFailed(); } // Mailgun has not even attempted to deliver these - public function IsRejected() + public function IsRejected(): bool { return $this->EventType == self::REJECTED; } /** - * Helper method to determin if event is failed || rejected - * @return boolean + * Helper method to determine if event is failed || rejected */ - public function IsFailedOrRejected() + public function IsFailedOrRejected(): bool { return $this->IsFailed() || $this->IsRejected(); } /** - * @return boolean + * Helper method to determine if event was delivered */ - public function IsDelivered() + public function IsDelivered(): bool { return $this->EventType == self::DELIVERED; } /** - * @return boolean + * Helper method to determine if event was accepted */ - public function IsAccepted() + public function IsAccepted(): bool { return $this->EventType == self::ACCEPTED; } /** - * @return boolean + * Helper method to determine if event is via a user action (e.g complained) */ - public function IsUserEvent() + public function IsUserEvent(): bool { return in_array($this->EventType, self::UserActionStatus()); } /** * Helper method to create a UTC Date from a timestamp - * @return string */ - private static function CreateUTCDate($timestamp) + private function CreateUTCDate($timestamp): string { - return self::CreateUTCDateTime($timestamp, "Y-m-d"); + return $this->CreateUTCDateTime($timestamp, "Y-m-d"); } /** * Helper method to create a UTC DateTime from a timestamp - * @return string */ - private static function CreateUTCDateTime($timestamp, $format = "Y-m-d H:i:s") + private function CreateUTCDateTime($timestamp, string $format = "Y-m-d H:i:s"): string { - $dt = new DateTime(); + $dt = new \DateTime(); $dt->setTimestamp($timestamp); - $dt->setTimezone(new DateTimeZone('UTC')); - return $dt->format('Y-m-d H:i:s'); + $dt->setTimezone(new \DateTimeZone('UTC')); + return $dt->format($format); } /** * GetByMessageDetails - retrieve an event based on the message/timestamp/recipient/event type + * @deprecated + * @phpstan-ignore method.unused */ - private static function GetByMessageDetails($message_id, $timestamp, $recipient, $event_type) + private function GetByMessageDetails($message_id, $timestamp, $recipient, $event_type): false|object { if (!$message_id || !$timestamp || !$recipient || !$event_type) { return false; } + $event = MailgunEvent::get()->filter(['MessageId' => $message_id, 'Timestamp' => $timestamp, 'Recipient' => $recipient, 'EventType' => $event_type ])->first(); if (!empty($event->ID)) { return $event; } + return false; } /** * Return message header from the {@link Mailgun\Model\Event\Event} + * @deprecated * @return string * @param string $header the header to retrieve + * @phpstan-ignore method.unused */ private function getMessageHeader(MailgunEventModel $event, $header) { $message = $event->getMessage(); - $value = isset($message['headers'][$header]) ? $message['headers'][$header] : ''; - return $value; + return $message['headers'][$header] ?? ''; } /** * Based on a delivery status returned from Mailgun, grab relevant details for this record - * @param array $delivery_status */ - private function saveDeliveryStatus(array $delivery_status) + private function saveDeliveryStatus(array $delivery_status): bool { - $this->DeliveryStatusMessage = isset($delivery_status['message']) ? $delivery_status['message'] : ''; - $this->DeliveryStatusDescription = isset($delivery_status['description']) ? $delivery_status['description'] : ''; - $this->DeliveryStatusCode = isset($delivery_status['code']) ? $delivery_status['code'] : ''; - $this->DeliveryStatusAttempts = isset($delivery_status['attempt-no']) ? $delivery_status['attempt-no'] : ''; - $this->DeliveryStatusSession = isset($delivery_status['session-seconds']) ? $delivery_status['session-seconds'] : ''; - $this->DeliveryStatusMxHost = isset($delivery_status['mx-host']) ? $delivery_status['mx-host'] : ''; + $this->DeliveryStatusMessage = $delivery_status['message'] ?? ''; + $this->DeliveryStatusDescription = $delivery_status['description'] ?? ''; + $this->DeliveryStatusCode = $delivery_status['code'] ?? ''; + $this->DeliveryStatusAttempts = $delivery_status['attempt-no'] ?? ''; + $this->DeliveryStatusSession = $delivery_status['session-seconds'] ?? ''; + $this->DeliveryStatusMxHost = $delivery_status['mx-host'] ?? ''; return true; } /** * Given a Mailgun\Model\Event\Event, store if possible - * @param MailgunEventModel $event * @return MailgunEvent|boolean */ public function storeEvent(MailgunEventModel $event) { - $this->extend('onBeforeStoreMailgunEvent', $event); $mailgun_event_id = $event->getId(); $event_type = $event->getEvent(); - $variables = $event->getUserVariables(); $timestamp = $event->getTimestamp(); $status = $event->getDeliveryStatus(); $storage = $event->getStorage(); - $tags = $event->getTags(); $recipient = $event->getRecipient(); // get message id from headers @@ -478,19 +483,20 @@ public function storeEvent(MailgunEventModel $event) $mailgun_event->EventId = $mailgun_event_id;// webhooks do not provide a mailgun event id $mailgun_event->MessageId = $mailgun_message_id; $mailgun_event->Timestamp = $timestamp; - $mailgun_event->UTCEventDate = self::CreateUTCDate($timestamp); + $mailgun_event->UTCEventDate = $this->CreateUTCDate($timestamp); $mailgun_event->Severity = $event->getSeverity(); $mailgun_event->EventType = $event_type; $mailgun_event->Recipient = $recipient;// if the message is sent to Someone , the $recipient value will be someone@example.com $mailgun_event->Reason = $event->getReason();// doesn't appear to be set for 'rejected' events $mailgun_event->saveDeliveryStatus($status); - $mailgun_event->StorageURL = isset($storage['url']) ? $storage['url'] : ''; + $mailgun_event->StorageURL = $storage['url'] ?? ''; $mailgun_event->DecodedStorageKey = "";// no need to store this $mailgun_event_id = $mailgun_event->write(); if (!$mailgun_event_id) { // could not create record return false; } + $this->extend('onAfterStoreMailgunEvent', $event, $mailgun_event); return $mailgun_event; } @@ -499,14 +505,12 @@ public function storeEvent(MailgunEventModel $event) * Retrieve the number of failures for a particular recipient/message for this event's linked submission * Failures are determined to be 'failed' or 'rejected' events */ - public function GetRecipientFailures() + public function GetRecipientFailures(): int { - $events = MailgunEvent::get() + return MailgunEvent::get() ->filter('MessageId', $this->MessageId) // Failures for this specific message ->filter('Recipient', $this->Recipient) // Recipient is an email address ->filterAny('EventType', [ self::FAILED, self::REJECTED ]) ->count(); - return $events; } - } diff --git a/src/ORM/FieldType/DBLongText.php b/src/ORM/FieldType/DBLongText.php index d9be872..b2f8e31 100644 --- a/src/ORM/FieldType/DBLongText.php +++ b/src/ORM/FieldType/DBLongText.php @@ -1,4 +1,5 @@ ['onMailgunSendMessage', 5], + FailedMessageEvent::class => ['onMailgunFailedMessage', 0], + ]; + } + + /** + * Event handler for SentMessageEvent + * Log some information about the send + */ + public function onMailgunSendMessage(SentMessageEvent $event): void + { + try { + /** @var SentMessage $message */ + $message = $event->getMessage(); + $decoded = json_decode($message->getMessageId(), true, 512, JSON_THROW_ON_ERROR); + $msgId = $decoded['msgId'] ?? ''; + $queuedJobId = $decoded['queuedJobDescriptor'] ?? ''; + if ($msgId !== '') { + Logger::log("Mailgun accepted message {$msgId}", "INFO"); + } elseif ($queuedJobId !== '') { + Logger::log("Queued job #{$queuedJobId} was created for mailgun send attempt", "INFO"); + } else { + Logger::log("Mailgun sent", "INFO"); + } + } catch (\Exception) { + Logger::log("Sent mailgun message but failed to decoded SentMessage", "NOTICE"); + } + } + + /** + * Event handler for FailedMessageEvent + * Log some information + */ + public function onMailgunFailedMessage(FailedMessageEvent $event): void + { + /** @var \Throwable $error */ + $error = $event->getError(); + $errorMessage = $error->getMessage(); + Logger::log("Failed mailgun message: " . $errorMessage, "NOTICE"); + throw new SendException("Failed attempting to send mailgun message, check logs"); + } + +} diff --git a/src/Tasks/SendTestEmailTask.php b/src/Tasks/SendTestEmailTask.php new file mode 100644 index 0000000..8f6ccac --- /dev/null +++ b/src/Tasks/SendTestEmailTask.php @@ -0,0 +1,43 @@ +html('

HTML content

'); + $email->text('My plain text content'); + $email->send(); + + } catch (\Exception $exception) { + DB::alteration_message("Failed: {$exception->getMessage()}", "error"); + } + + } + +} diff --git a/src/Transport/ApiResponse.php b/src/Transport/ApiResponse.php new file mode 100644 index 0000000..4243d13 --- /dev/null +++ b/src/Transport/ApiResponse.php @@ -0,0 +1,224 @@ +msgId = ''; + $this->queuedJobDescriptor = null; + if ($response instanceof \Mailgun\Model\Message\SendResponse) { + // get a message.id from the response + return $this->setMsgId($this->saveResponse($response)); + } else { + // set job + return $this->setQueuedJobDescriptor($response); + } + } + + /** + * Store the msgId + */ + public function setMsgId(string $msgId): static + { + if (!is_null($this->queuedJobDescriptor)) { + throw new \RuntimeException("Cannot set a msgId response if the response already has a QueuedJobDescriptor"); + } + + $this->msgId = $msgId; + return $this; + } + + /** + * Get the msgId + */ + public function getMsgId(): string + { + return $this->msgId; + } + + /** + * Store the QueuedJobDescriptor response, if appropriate + */ + public function setQueuedJobDescriptor(QueuedJobDescriptor $queuedJobDescriptor): static + { + if ($this->msgId !== '') { + throw new \RuntimeException("Cannot set a QueuedJobDescriptor response if the response already has a msgId"); + } + + $this->queuedJobDescriptor = $queuedJobDescriptor; + return $this; + } + + /** + * Return the QueuedJobDescriptor response + */ + public function getQueuedJobDescriptor(): ?QueuedJobDescriptor + { + return $this->queuedJobDescriptor; + } + + /* + object(Mailgun\Model\Message\SendResponse)[1740] + private 'id' => string '' (length=92) + private 'message' => string 'Queued. Thank you.' (length=18) + */ + protected function saveResponse(\Mailgun\Model\Message\SendResponse $message): string + { + return MessageConnector::cleanMessageId($message->getId()); + } + + /** + * Gets the HTTP status code of the response. + */ + public function getStatusCode(): int + { + if ($this->msgId !== '') { + return 200;// OK + } elseif (!is_null($this->queuedJobDescriptor)) { + return 202;// Accepted + } else { + return 500;// Error condition + } + } + + /** + * Gets the HTTP headers of the response. + * + * @param bool $throw Whether an exception should be thrown on 3/4/5xx status codes + * + * @return string[][] The headers of the response keyed by header names in lowercase + * + * @throws TransportExceptionInterface When a network error occurs + * @throws RedirectionExceptionInterface On a 3xx when $throw is true and the "max_redirects" option has been reached + * @throws ClientExceptionInterface On a 4xx when $throw is true + * @throws ServerExceptionInterface On a 5xx when $throw is true + */ + public function getHeaders(bool $throw = true): array + { + return []; + } + + /** + * Gets the response body as a string. + * + * @param bool $throw Whether an exception should be thrown on 3/4/5xx status codes + * + * @throws TransportExceptionInterface When a network error occurs + * @throws RedirectionExceptionInterface On a 3xx when $throw is true and the "max_redirects" option has been reached + * @throws ClientExceptionInterface On a 4xx when $throw is true + * @throws ServerExceptionInterface On a 5xx when $throw is true + */ + public function getContent(bool $throw = true): string + { + return ""; + } + + /** + * Gets the response body decoded as array, typically from a JSON payload. + * + * @param bool $throw Whether an exception should be thrown on 3/4/5xx status codes + * + * @throws DecodingExceptionInterface When the body cannot be decoded to an array + * @throws TransportExceptionInterface When a network error occurs + * @throws RedirectionExceptionInterface On a 3xx when $throw is true and the "max_redirects" option has been reached + * @throws ClientExceptionInterface On a 4xx when $throw is true + * @throws ServerExceptionInterface On a 5xx when $throw is true + */ + public function toArray(bool $throw = true): array + { + return []; + } + + /** + * Closes the response stream and all related buffers. + * + * No further chunk will be yielded after this method has been called. + */ + public function cancel(): void + { + + } + + /** + * Returns info coming from the transport layer. + * + * This method SHOULD NOT throw any ExceptionInterface and SHOULD be non-blocking. + * The returned info is "live": it can be empty and can change from one call to + * another, as the request/response progresses. + * + * The following info MUST be returned: + * - canceled (bool) - true if the response was canceled using ResponseInterface::cancel(), false otherwise + * - error (string|null) - the error message when the transfer was aborted, null otherwise + * - http_code (int) - the last response code or 0 when it is not known yet + * - http_method (string) - the HTTP verb of the last request + * - redirect_count (int) - the number of redirects followed while executing the request + * - redirect_url (string|null) - the resolved location of redirect responses, null otherwise + * - response_headers (array) - an array modelled after the special $http_response_header variable + * - start_time (float) - the time when the request was sent or 0.0 when it's pending + * - url (string) - the last effective URL of the request + * - user_data (mixed) - the value of the "user_data" request option, null if not set + * + * When the "capture_peer_cert_chain" option is true, the "peer_certificate_chain" + * attribute SHOULD list the peer certificates as an array of OpenSSL X.509 resources. + * + * Other info SHOULD be named after curl_getinfo()'s associative return value. + * + * @return mixed An array of all available info, or one of them when $type is + * provided, or null when an unsupported type is requested + */ + public function getInfo(?string $type = null): mixed + { + if ($this->msgId !== '' || !is_null($this->queuedJobDescriptor)) { + // return info based on message send handling + $info = [ + 'canceled' => false, + 'error' => null, + 'http_code' => $this->getStatusCode(), + 'redirect_count' => 0, + 'redirect_url' => null, + 'response_headers' => [], + 'start_time' => microtime(true), + 'url' => '', + 'user_data' => null + ]; + } else { + $info = []; + } + + if (!is_null($type)) { + return $info[$type] ?? null; + } else { + return $info; + } + } + +} diff --git a/src/Transport/MailgunSyncApiTransport.php b/src/Transport/MailgunSyncApiTransport.php new file mode 100644 index 0000000..31c8c9b --- /dev/null +++ b/src/Transport/MailgunSyncApiTransport.php @@ -0,0 +1,338 @@ +getCustomParameters(); + $email->clearCustomParameters(); + $this->connector->setVariables($customParameters['variables'] ?? []) + ->setOptions($customParameters['options'] ?? []) + ->setCustomHeaders($customParameters['headers'] ?? []) + ->setRecipientVariables($customParameters['recipient-variables'] ?? []) + ->setSendIn($customParameters['send-in'] ?? 0) + ->setAmpHtml($customParameters['amp-html'] ?? '') + ->setTemplate($customParameters['template'] ?? []); + return $this; + } + + /** + * Taggable: retrieve tags set via setNotificationTags() + * Doing so will replace any tags assigned through setCustomParameters + */ + protected function assignNotificationTags(TaggableEmail $email): static + { + $tags = $email->getNotificationTags(); + if ($tags !== []) { + $this->connector->setOption('tag', $tags); + } + + return $this; + } + + /** + * Set the DSN for this API request, containing Mailgun API credentials and options + */ + public function setDsn(#[\SensitiveParameter] Dsn $dsn): static + { + $this->dsn = $dsn; + return $this; + } + + /** + * Get the DSN for this API request + */ + public function getDsn(): ?Dsn + { + return $this->dsn; + } + + /** + * Do send via the API + * @throws \RuntimeException|HttpTransportException + */ + protected function doSendApi(SentMessage $sentMessage, SymfonyEmail $email, Envelope $envelope): ResponseInterface + { + try { + + $apiResponse = new ApiResponse(); + + if (!($this->dsn instanceof Dsn)) { + throw new \RuntimeException("No DSN set for send attempt."); + } + + $this->connector = MessageConnector::create($this->dsn); + // Prepare all parameters for sending + $parameters = $this->prepareParameters($email); + $apiResponse->storeSendResponse($this->connector->send($parameters)); + + $queuedJobDescriptor = $apiResponse->getQueuedJobDescriptor(); + $sentMessage->setMessageId(json_encode([ + 'info' => $apiResponse->getInfo(), + 'queuedJobDescriptor' => $queuedJobDescriptor ? $queuedJobDescriptor->ID : null, + 'msgId' => $apiResponse->getMsgId() + ])); + + return $apiResponse; + } catch (\Exception $exception) { + throw new HttpTransportException($exception->getMessage(), $apiResponse, 0, $exception); + } + } + + /** + * Given an Email, prepare parameters for the API send + * @return array of parameters for the Mailgun API + */ + public function prepareParameters(SymfonyEmail $email): array + { + + $to = []; + $from = []; + $cc = []; + $bcc = []; + + // Handle 'From' headers from Email + $emailFrom = $email->getFrom(); + if ($emailFrom !== []) { + $from = $this->processEmailDisplayName($emailFrom); + } + + // Handle 'To' headers from Email + $emailTo = $email->getTo(); + if ($emailTo !== []) { + $to = $this->processEmailDisplayName($emailTo); + } + + // Handle 'Cc' headers from Email + $emailCc = $email->getCc(); + if ($emailCc !== []) { + $cc = $this->processEmailDisplayName($emailCc); + } + + // Handle 'Bcc' headers from Email + $emailBcc = $email->getBcc(); + if ($emailBcc !== []) { + $bcc = $this->processEmailDisplayName($emailBcc); + } + + // If the email supports custom parameters + if ($email instanceof EmailWithCustomParameters) { + $this->assignCustomParameters($email); + } + + // assign tags, if any + if ($email instanceof TaggableEmail) { + $this->assignNotificationTags($email); + } + + // parameters for the API + $parameters = [ + 'from' => implode(",", $from), + 'to' => implode(",", $to), + 'subject' => $email->getSubject(), + 'text' => $email->getTextBody(), + 'html' => $email->getHtmlBody() + ]; + + // if Cc and Bcc have been provided + if ($cc !== []) { + $parameters['cc'] = implode(",", $cc); + } + + if ($bcc !== []) { + $parameters['bcc'] = implode(",", $bcc); + } + + // Provide Mailgun the Attachments. Keys are 'fileContent' (the bytes) and filename (the file name) + // If the key filename is not provided, Mailgun will use the name of the file, which may not be what you want displayed + // TODO inline attachment disposition + $attachments = $this->prepareAttachments($email->getAttachments()); + if ($attachments !== []) { + $parameters['attachment'] = $attachments; + } + + // Default parameters override specific parameters set + $this->assignDefaultParameters($parameters); + + return $parameters; + } + + /** + * Process to, from, cc, bcc recipient headers that are in a email => displayName format + * Each value is an Address + * Returns a flattened array of values being recipients understandable to the Mailgun API + * @param Address[] $addresses array of Address values + */ + public function processEmailDisplayName(array $addresses): array + { + $list = []; + foreach ($addresses as $address) { + $list[] = $address->toString(); + } + + return $list; + } + + /** + * Given {@link \SilverStripe\Control\Email\Email} configuration, apply relevant values + */ + public function assignDefaultParameters(array &$parameters) + { + + // Override send all emails to + $sendAllEmailsTo = SilverStripeEmail::getSendAllEmailsTo(); + if ($sendAllEmailsTo !== []) { + $sendAllEmailsTo = $this->processEmailDisplayName($sendAllEmailsTo); + $parameters['to'] = implode(",", $sendAllEmailsTo); + } + + // Override from address, note always_from overrides this + $sendAllEmailsFrom = SilverStripeEmail::getSendAllEmailsFrom(); + if ($sendAllEmailsFrom !== []) { + $sendAllEmailsFrom = $this->processEmailDisplayName($sendAllEmailsFrom); + if ($sendAllEmailsFrom !== []) { + // the current from for the message + $from = $parameters['from']; + $parameters['h:Reply-To'] = $from;// set the original from as a reply-to + $parameters['from'] = implode(",", $sendAllEmailsFrom);// set from as configured value + $parameters['h:Sender'] = $parameters['from'];// set Sender header as new from + } + } + + // Add or set CC defaults + $ccAllEmailsTo = SilverStripeEmail::getCCAllEmailsTo(); + if ($ccAllEmailsTo !== []) { + $ccAllEmailsTo = $this->processEmailDisplayName($ccAllEmailsTo); + $cc = implode(",", $ccAllEmailsTo); + if ($cc !== '') { + if (isset($parameters['cc'])) { + $parameters['cc'] .= "," . $cc; + } else { + $parameters['cc'] = $cc; + } + } + } + + // Add or set BCC defaults + $bccAllEmailsTo = SilverStripeEmail::getBCCAllEmailsTo(); + if ($bccAllEmailsTo !== []) { + $bccAllEmailsTo = $this->processEmailDisplayName($bccAllEmailsTo); + $bcc = implode(",", $bccAllEmailsTo); + if ($bcc !== '') { + if (isset($parameters['bcc'])) { + $parameters['bcc'] .= "," . $bcc; + } else { + $parameters['bcc'] = $bcc; + } + } + } + } + + /** + * Prepare headers for use in Mailgun + * @todo this will remove 'From', 'To', 'Subject' headers, which is not what we want + */ + protected function prepareHeaders(Headers &$headers): void + { + $denylist = $this->config()->get('denylist_headers'); + if (is_array($denylist)) { + $denylist = array_merge( + $denylist, + [ 'From', 'To', 'Subject'] // avoid multiple headers and RFC5322 issues with a From: appearing twice, for instance + ); + foreach ($denylist as $deniedHeader) { + $headers->remove($deniedHeader); + } + } + } + + /** + * @note refer to {@link Mailgun\Api\Message::prepareFile()} which is the preferred way of attaching messages from 3.0 onwards as {@link Mailgun\Connection\RestClient} is deprecated + * @param DataPart[] $attachments Each value is a {@link DataPart} + */ + protected function prepareAttachments(array $attachments): array + { + $mailgunAttachments = []; + foreach ($attachments as $attachment) { + $mailgunAttachments[] = [ + 'fileContent' => $attachment->getBody(), + 'filename' => $attachment->getName(), + 'mimetype' => $attachment->getContentType() + ]; + } + + return $mailgunAttachments; + } + +} diff --git a/src/Transport/MailgunSyncTransportFactory.php b/src/Transport/MailgunSyncTransportFactory.php new file mode 100644 index 0000000..b1a55f9 --- /dev/null +++ b/src/Transport/MailgunSyncTransportFactory.php @@ -0,0 +1,48 @@ +getScheme(); + if ('mailgunsync+api' === $scheme) { + if ($this->dispatcher) { + $subscriber = new MailerSubscriber(); + $this->dispatcher->addSubscriber($subscriber); + } + + if (is_null($this->logger)) { + $this->logger = Injector::inst()->get(LoggerInterface::class); + } + + $transport = new MailgunSyncApiTransport($this->client, $this->dispatcher, $this->logger); + $transport->setDsn($dsn); + return $transport; + } + + throw new UnsupportedSchemeException($dsn, 'mailgunsync', $this->getSupportedSchemes()); + } + + protected function getSupportedSchemes(): array + { + return ['mailgunsync','mailgunsync+api']; + } +} diff --git a/src/Transport/TransportFactory.php b/src/Transport/TransportFactory.php new file mode 100644 index 0000000..f022653 --- /dev/null +++ b/src/Transport/TransportFactory.php @@ -0,0 +1,37 @@ +fromString($dsn); + } +} diff --git a/tests/MailgunSyncTest.php b/tests/MailgunSyncTest.php index f1f96d1..c578fe5 100644 --- a/tests/MailgunSyncTest.php +++ b/tests/MailgunSyncTest.php @@ -4,105 +4,149 @@ use NSWDPC\Messaging\Mailgun\Connector\Base; use NSWDPC\Messaging\Mailgun\Connector\Message as MessageConnector; -use NSWDPC\Messaging\Mailgun\SendJob; +use NSWDPC\Messaging\Mailgun\Jobs\SendJob; +use NSWDPC\Messaging\Mailgun\Transport\TransportFactory; +use NSWDPC\Messaging\Mailgun\Transport\MailgunSyncTransportFactory; +use NSWDPC\Messaging\Mailgun\Transport\MailgunSyncApiTransport; use Mailgun\Mailgun; use Mailgun\Model\Message\SendResponse; use SilverStripe\Dev\SapphireTest; use SilverStripe\Core\Config\Config; use SilverStripe\Core\Injector\Injector; -use SilverStripe\Control\Email\Mailer; use SilverStripe\Control\Email\Email; -use NSWDPC\Messaging\Mailgun\MailgunMailer; -use NSWDPC\Messaging\Mailgun\MailgunEmail; +use NSWDPC\Messaging\Mailgun\Email\MailgunMailer; +use NSWDPC\Messaging\Mailgun\Email\MailgunEmail; +use NSWDPC\Messaging\Taggable\ProjectTags; use SilverStripe\Assets\File; use SilverStripe\Assets\Folder; -use SilverStripe\Core\Config\Configurable; -use Exception; use Symbiote\QueuedJobs\DataObjects\QueuedJobDescriptor; +use Symfony\Component\Mailer\Mailer as SymfonyMailer; +use Symfony\Component\Mailer\MailerInterface; +use SilverStripe\Control\Email\TransportFactory as SilverStripeEmailTransportFactory; /** * Tests for mailgun-sync, see README.md for more * @author James */ - class MailgunSyncTest extends SapphireTest { + /** + * @inheritdoc + */ + protected $usesDatabase = false; - use Configurable; + protected string $test_api_key = 'the_api_key'; - protected $usesDatabase = false; + protected string $test_api_domain = 'testing.example.net'; + + protected string $to_address = "test@example.com"; + + protected string $to_name = "Test Tester"; - protected $test_api_key = 'the_api_key'; - protected $test_api_domain = 'testing.example.net'; - - // In your sandbox domains settings, set the To address to an address you can authorise - private static $to_address = "test@example.com";// an email address - private static $to_name = "Test Tester";// optional the recipient name - // Ditto if testing cc - private static $cc_address = ""; - // From header - private static $from_address = "from@example.com"; - private static $from_name = "From Tester";// option the from name (e.g for 'Joe ' formatting) - // Test body HTML - private static $test_body = "

Header provider strategic

" - . "

consulting support conversation advertisements policy promotional request.

" - . "

Option purpose programming

"; - - public function setUp() : void + protected string $from_address = "from@example.com"; + + protected string $from_name = "From Tester"; + + protected string $test_body = "

Header provider strategic

" + . "

consulting support conversation advertisements policy promotional request.

" + . "

Option purpose programming

"; + + protected function getTestDsn(string $regionValue = ''): string + { + return "mailgunsync+api://{$this->test_api_domain}:{$this->test_api_key}@default" + . ($regionValue !== '' ? "?region={$regionValue}" : ""); + } + + public function setUp(): void { parent::setUp(); - // Avoid using TestMailer for this test - Injector::inst()->registerService(new MailgunMailer(), Mailer::class); + + // Avoid using TestMailer for test + $transportFactory = Injector::inst()->create(SilverStripeEmailTransportFactory::class); + $this->assertInstanceOf(TransportFactory::class, $transportFactory); + $params = [ + 'dsn' => $this->getTestDsn() + ]; + $transport = $transportFactory->create(\Symfony\Component\Mailer\Transport\TransportInterface::class, $params); + Injector::inst()->registerService(new SymfonyMailer($transport), MailerInterface::class); + // use MailgunEmail Injector::inst()->registerService(MailgunEmail::create(), Email::class); // use TestMessage - Injector::inst()->registerService(TestMessage::create(), MessageConnector::class); + Injector::inst()->registerService(new TestMessage($this->getTestDsn()), MessageConnector::class); + + // use TransportFactory + Injector::inst()->registerService(new TransportFactory(), SilverStripeEmailTransportFactory::class); // modify some config values for tests - // never send via a job - Config::modify()->set(Base::class, 'api_domain', $this->test_api_domain); - Config::modify()->set(Base::class, 'api_key', $this->test_api_key); + // by default, do not send via a queued job Config::modify()->set(Base::class, 'send_via_job', 'no'); + // turn api test mode 'on' Config::modify()->set(Base::class, 'api_testmode', true); } + /** + * Test that the expected transport is returned based on mailgunsync+api:// DSN + */ + public function testTransportFactoryTransportReturn(): void + { + $transportFactory = Injector::inst()->create(SilverStripeEmailTransportFactory::class); + $this->assertInstanceOf(TransportFactory::class, $transportFactory); + $params = [ + 'dsn' => $this->getTestDsn() + ]; + $transport = $transportFactory->create(\Symfony\Component\Mailer\Transport\TransportInterface::class, $params); + $this->assertInstanceOf(MailgunSyncApiTransport::class, $transport); + } + + /** + * Test that sendmail transport is returned when DSN points to that + */ + public function testTransportFactorySendmailTransportReturn(): void + { + $transportFactory = Injector::inst()->create(SilverStripeEmailTransportFactory::class); + $this->assertInstanceOf(TransportFactory::class, $transportFactory); + $params = [ + 'dsn' => 'sendmail://default' + ]; + $transport = $transportFactory->create(\Symfony\Component\Mailer\Transport\TransportInterface::class, $params); + $this->assertInstanceOf(\Symfony\Component\Mailer\Transport\SendmailTransport::class, $transport); + } + /** * Test that the API domain configured is maintained */ - public function testApiDomain() { - $currentValue = Config::inst()->get(Base::class, 'api_domain'); - $value = "testing.example.org"; - Config::modify()->set(Base::class, 'api_domain', $value); - $connector = MessageConnector::create(); - $result = $connector->getApiDomain(); - $this->assertEquals($value, $result); - Config::modify()->set(Base::class, 'api_domain', $currentValue); + public function testApiDomain(): void + { + $connector = MessageConnector::create($this->getTestDsn()); + $this->assertEquals($this->test_api_domain, $connector->getApiDomain()); } /** * Test that the API endpoint configured is maintained */ - public function testApiEndpoint() { - + public function testApiEndpoint(): void + { $value = 'API_ENDPOINT_EU'; Config::modify()->set(Base::class, 'api_endpoint_region', $value); - $connector = MessageConnector::create(); - $domains = $connector->getClient(); + $connector = MessageConnector::create($this->getTestDsn($value)); + $connector->getClient(); // assert that the expected URL value is what was set on the client - $this->assertEquals(constant(Base::class . "::{$value}"), $connector->getApiEndpointRegion()); + $this->assertEquals($value, $connector->getApiEndpointRegion()); // switch to default region $value = ''; Config::modify()->set(Base::class, 'api_endpoint_region', $value); - $connector = MessageConnector::create(); - $domains = $connector->getClient(); + $connector = MessageConnector::create($this->getTestDsn()); + $connector->getClient(); // when no value is set, the default region URL is used $this->assertEquals('', $connector->getApiEndpointRegion()); } - protected function getCustomParameters($to_address, $send_in) : array { + protected function getCustomParameters($to_address, $send_in): array + { $variables = [ 'test' => 'true', 'foo' => 'bar', @@ -129,24 +173,25 @@ protected function getCustomParameters($to_address, $send_in) : array { 'headers' => $headers, 'recipient-variables' => $recipient_variables ]; - if($send_in > 0) { + if ($send_in > 0) { $customParameters['send-in'] = $send_in; } + return $customParameters; } /** * test mailer delivery only, no sync or event checking, just that we get the expected response + * @return mixed[] */ - public function testMailerDelivery($subject = "test_mailer_delivery", $send_in = 0) + public function testMailerDelivery(string $subject = "test_mailer_delivery", $send_in = 0): array { - - $to_address = self::config()->get('to_address'); - $to_name = self::config()->get('to_name'); + $to_address = $this->to_address; + $to_name = $this->to_name; $this->assertNotEmpty($to_address); - $from_address = self::config()->get('from_address'); - $from_name = self::config()->get('from_name'); + $from_address = $this->from_address; + $from_name = $this->from_name; $this->assertNotEmpty($from_address); $from = [ @@ -158,25 +203,28 @@ public function testMailerDelivery($subject = "test_mailer_delivery", $send_in = $email = Email::create(); + $this->assertInstanceOf(MailgunEmail::class, $email); + $email->setFrom($from); $email->setTo($to); $email->setCc(["cc@example.com" => "Cc Person"]); $email->setBcc(["bcc@example.com" => "Bcc Person"]); $email->setSubject($subject); - if ($cc = self::config()->get('cc_address')) { - $email->setCc($cc); - } - $htmlBody = self::config()->get('test_body'); - $email->setBody( $htmlBody ); + + $htmlBody = $this->test_body; + $email->setBody($htmlBody); $customParameters = $this->getCustomParameters($to_address, $send_in); + /** @var \NSWDPC\Messaging\Mailgun\Email\MailgunEmail $email */ $email->setCustomParameters($customParameters); // send the email, returns a message_id if delivered + $email->send(); - $response = $email->send(); - if(Config::inst()->get(Base::class, 'send_via_job') == 'no') { - $this->assertEquals($response, TestMessage::MSG_ID); + $response = TestMessage::getSendDataValue('response'); + if (Config::inst()->get(Base::class, 'send_via_job') == 'no') { + $this->assertInstanceOf(\Mailgun\Model\Message\SendResponse::class, $response); + $this->assertEquals(TestMessage::MSG_ID, MessageConnector::cleanMessageId($response->getId())); } else { // via job $this->assertInstanceOf(QueuedJobDescriptor::class, $response); @@ -185,44 +233,44 @@ public function testMailerDelivery($subject = "test_mailer_delivery", $send_in = $sendData = TestMessage::getSendData(); $this->assertEquals( - "{$from_name} <{$from_address}>", - $sendData['parameters']['from'] , + "\"{$from_name}\" <{$from_address}>", + $sendData['parameters']['from'], "From: mismatch" ); $this->assertEquals( - "{$to_name} <{$to_address}>", + "\"{$to_name}\" <{$to_address}>", $sendData['parameters']['to'], "To: mismatch" ); $this->assertEquals( - "Cc Person ", + '"Cc Person" ', $sendData['parameters']['cc'], "Cc: mismatch" ); $this->assertEquals( - "Bcc Person ", + '"Bcc Person" ', $sendData['parameters']['bcc'], "Bcc: mismatch" ); - foreach($customParameters['options'] as $k=>$v) { - $this->assertEquals( $sendData['parameters']["o:{$k}"], $v, "Option $k failed"); + foreach ($customParameters['options'] as $k => $v) { + $this->assertEquals($sendData['parameters']["o:{$k}"], $v, "Option {$k} failed"); } - foreach($customParameters['variables'] as $k=>$v) { - $this->assertEquals( $sendData['parameters']["v:{$k}"], $v , "Variable $k failed"); + foreach ($customParameters['variables'] as $k => $v) { + $this->assertEquals($sendData['parameters']["v:{$k}"], $v, "Variable {$k} failed"); } - foreach($customParameters['headers'] as $k=>$v) { - $this->assertEquals( $sendData['parameters']["h:{$k}"], $v , "Header $k failed"); + foreach ($customParameters['headers'] as $k => $v) { + $this->assertEquals($sendData['parameters']["h:{$k}"], $v, "Header {$k} failed"); } - $this->assertEquals( json_encode($customParameters['recipient-variables']), $sendData['parameters']['recipient-variables'] ); + $this->assertEquals(json_encode($customParameters['recipient-variables']), $sendData['parameters']['recipient-variables']); - $this->assertEquals($htmlBody, $sendData['parameters']['html'] ); + $this->assertEquals($htmlBody, $sendData['parameters']['html']); return $sendData; } @@ -230,7 +278,8 @@ public function testMailerDelivery($subject = "test_mailer_delivery", $send_in = /** * Test delivery via a Job */ - public function testJobMailerDelivery() { + public function testJobMailerDelivery(): void + { Config::modify()->set(Base::class, 'send_via_job', 'yes'); // send message $subject = "test_mailer_delivery_job"; @@ -244,7 +293,8 @@ public function testJobMailerDelivery() { /** * Test delivery via a Job */ - public function testJobMailerDeliveryInFuture() { + public function testJobMailerDeliveryInFuture(): void + { Config::modify()->set(Base::class, 'send_via_job', 'yes'); // send message $subject = "test_mailer_delivery_job_future"; @@ -258,68 +308,62 @@ public function testJobMailerDeliveryInFuture() { } - protected function checkJobData(QueuedJobDescriptor $job, $subject, $send_in) { - - + protected function checkJobData(QueuedJobDescriptor $job, $subject, $send_in) + { $this->assertEquals(SendJob::class, $job->Implementation); $data = @unserialize($job->SavedJobData ?? ''); + $this->assertObjectHasProperty('parameters', $data); - $this->assertEquals( - Config::inst()->get(Base::class, 'api_domain'), - $data->domain - ); - - $to = self::config()->get('to_name') . " <" . self::config()->get('to_address') . ">"; + $to = "\"{$this->to_name}\" <{$this->to_address}>"; $this->assertEquals($to, $data->parameters['to']); - $from = self::config()->get('from_name') . " <" . self::config()->get('from_address') . ">"; + $from = "\"{$this->from_name}\" <{$this->from_address}>"; $this->assertEquals($from, $data->parameters['from']); - $cc = "Cc Person "; + $cc = '"Cc Person" '; $this->assertEquals($cc, $data->parameters['cc']); - $bcc = "Bcc Person "; + $bcc = '"Bcc Person" '; $this->assertEquals($bcc, $data->parameters['bcc']); $this->assertEquals($subject, $data->parameters['subject']); - $this->assertEquals(self::config()->get('test_body'), $data->parameters['html']); + $this->assertEquals($this->test_body, $data->parameters['html']); - $customParameters = $this->getCustomParameters(self::config()->get('to_address'), $send_in); + $customParameters = $this->getCustomParameters($this->to_address, $send_in); - foreach($customParameters['options'] as $k=>$v) { - $this->assertEquals( $data->parameters["o:{$k}"], $v, "Option $k failed"); + foreach ($customParameters['options'] as $k => $v) { + $this->assertEquals($data->parameters["o:{$k}"], $v, "Option {$k} failed"); } - foreach($customParameters['variables'] as $k=>$v) { - $this->assertEquals( $data->parameters["v:{$k}"], $v , "Variable $k failed"); + foreach ($customParameters['variables'] as $k => $v) { + $this->assertEquals($data->parameters["v:{$k}"], $v, "Variable {$k} failed"); } - foreach($customParameters['headers'] as $k=>$v) { - $this->assertEquals( $data->parameters["h:{$k}"], $v , "Header $k failed"); + foreach ($customParameters['headers'] as $k => $v) { + $this->assertEquals($data->parameters["h:{$k}"], $v, "Header {$k} failed"); } - $this->assertEquals( json_encode($customParameters['recipient-variables']), $data->parameters['recipient-variables'] ); - + $this->assertEquals(json_encode($customParameters['recipient-variables']), $data->parameters['recipient-variables']); } /** * Test always from setting */ - public function testAlwaysFrom() { - + public function testAlwaysFrom(): void + { $alwaysFromEmail = 'alwaysfrom@example.com'; - Config::modify()->set(MailgunMailer::class, 'always_from', $alwaysFromEmail); + Config::modify()->set(Email::class, 'send_all_emails_from', $alwaysFromEmail); - $to_address = self::config()->get('to_address'); - $to_name = self::config()->get('to_name'); + $to_address = $this->to_address; + $to_name = $this->to_name; $this->assertNotEmpty($to_address); - $from_address = self::config()->get('from_address'); - $from_name = self::config()->get('from_name'); + $from_address = $this->from_address; + $from_name = $this->from_name; $this->assertNotEmpty($from_address); $from = [ @@ -336,12 +380,12 @@ public function testAlwaysFrom() { $email->setTo($to); $email->setSubject($subject); - $response = $email->send(); + $email->send(); - $this->assertEquals(TestMessage::MSG_ID, $response); + $response = TestMessage::getSendDataValue('response'); + $this->assertEquals(TestMessage::MSG_ID, MessageConnector::cleanMessageId($response->getId())); $sendData = TestMessage::getSendData(); - $this->assertEquals( $alwaysFromEmail, $sendData['parameters']['from'], @@ -352,23 +396,26 @@ public function testAlwaysFrom() { /** * test API delivery only */ - public function testAPIDelivery() + public function testAPIDelivery(): void { - Config::modify()->set(Base::class, 'send_via_job', 'no'); - $connector = MessageConnector::create(); - $to = $to_address = self::config()->get('to_address'); - $to_name = self::config()->get('to_name'); - if ($to_name) { + $connector = MessageConnector::create($this->getTestDsn()); + $to = $this->to_address; + $to_address = $this->to_address; + $to_name = $this->to_name; + if ($to_name !== '') { $to = $to_name . ' <' . $to_address . '>'; } + $this->assertNotEmpty($to_address); - $from = $from_address = self::config()->get('from_address'); - $from_name = self::config()->get('from_name'); - if ($from_name) { + $from = $this->from_address; + $from_address = $this->from_address; + $from_name = $this->from_name; + if ($from_name !== '') { $from = $from_name . ' <' . $from_address . '>'; } + $this->assertNotEmpty($from_address); $subject = "test_api_delivery"; @@ -383,13 +430,9 @@ public function testAPIDelivery() 'to' => $to, 'subject' => $subject, 'text' => '', - 'html' => self::config()->get('test_body') + 'html' => $this->test_body ]; - if ($cc = self::config()->get('cc_address')) { - $parameters['cc'] = $cc; - } - $response = $connector->send($parameters); $this->assertTrue($response && ($response instanceof SendResponse)); $message_id = $response->getId(); @@ -399,16 +442,89 @@ public function testAPIDelivery() $this->assertArrayHasKey('parameters', $sendData); - foreach(['o:testmode','o:tag','from','to','subject','text','html'] as $key) { + foreach (['o:testmode','o:tag','from','to','subject','text','html'] as $key) { $this->assertEquals($parameters[ $key ], $sendData['parameters'][ $key ]); } } /** - * Test sending with default values set + * Test that tags can be set via Taggable */ - public function testSendWithDefaultConfiguration() { + public function testTaggableEmail(): void + { + $limit = 3; + + Config::modify()->set(ProjectTags::class, 'tag', ''); + Config::modify()->set(ProjectTags::class, 'tag_limit', $limit); + + $to_address = $this->to_address; + $to_name = $this->to_name; + $this->assertNotEmpty($to_address); + + $from_address = $this->from_address; + $from_name = $this->from_name; + $this->assertNotEmpty($from_address); + + $from = [ + $from_address => $from_name, + ]; + $to = [ + $to_address => $to_name, + ]; + + $subject = "test_taggable_email"; + + $email = Email::create(); + $this->assertTrue($email instanceof MailgunEmail, "Email needs to be an instance of MailgunEmail"); + $email->setFrom($from); + $email->setTo($to); + $email->setSubject($subject); + + $email->setBody($this->test_body); + + $tags = ['tagheader1','tagheader2','tagheader3']; + $email->setNotificationTags($tags); + + $tags = $email->getNotificationTags(); + $this->assertEquals($limit, count($tags)); + + // Send message + $email->send(); + $response = TestMessage::getSendDataValue('response'); + + $this->assertEquals(TestMessage::MSG_ID, MessageConnector::cleanMessageId($response->getId())); + $sendData = TestMessage::getSendData(); + + $this->assertArrayHasKey('parameters', $sendData); + + $this->assertArrayHasKey('o:tag', $sendData['parameters']); + $this->assertEquals($tags, $sendData['parameters']['o:tag']); + + $tooManyTags = ['tagheader1','tagheader2','tagheader3', 'tagheader4']; + $expectedTags = ['tagheader1','tagheader2','tagheader3']; + $email->setNotificationTags($tooManyTags); + $this->assertEquals($expectedTags, $email->getNotificationTags()); + + // Send message again ... + $email->send(); + $response = TestMessage::getSendDataValue('response'); + + $this->assertEquals(TestMessage::MSG_ID, MessageConnector::cleanMessageId($response->getId())); + + $sendData = TestMessage::getSendData(); + + $this->assertArrayHasKey('parameters', $sendData); + + $this->assertArrayHasKey('o:tag', $sendData['parameters']); + $this->assertEquals($expectedTags, $sendData['parameters']['o:tag']); + } + + /** + * Test sending with default values set + */ + public function testSendWithDefaultConfiguration(): void + { $overrideTo = 'allemails@example.com'; $overrideFrom = 'allemailsfrom@example.com'; $overrideCc = 'ccallemailsto@example.com'; @@ -420,12 +536,12 @@ public function testSendWithDefaultConfiguration() { Config::modify()->set(Email::class, 'cc_all_emails_to', $overrideCc); Config::modify()->set(Email::class, 'bcc_all_emails_to', [ $overrideBcc => $overrideBccName ]); - $to_address = self::config()->get('to_address'); - $to_name = self::config()->get('to_name'); + $to_address = $this->to_address; + $to_name = $this->to_name; $this->assertNotEmpty($to_address); - $from_address = self::config()->get('from_address'); - $from_name = self::config()->get('from_name'); + $from_address = $this->from_address; + $from_name = $this->from_name; $this->assertNotEmpty($from_address); $from = [ @@ -442,13 +558,15 @@ public function testSendWithDefaultConfiguration() { $email->setBcc(['bcctest1@example.com' => 'bcctest 1', 'bcctest2@example.com' => 'bcctest 2']); $email->setSubject("Email with default configuration set"); - $response = $email->send(); + $email->send(); - $this->assertEquals(TestMessage::MSG_ID, $response); + $response = TestMessage::getSendDataValue('response'); + + $this->assertEquals(TestMessage::MSG_ID, MessageConnector::cleanMessageId($response->getId())); $sendData = TestMessage::getSendData(); - foreach(['domain','parameters','sentVia','client','in'] as $key) { + foreach (['domain','parameters','sentVia','client','in'] as $key) { $this->assertArrayHasKey($key, $sendData); } @@ -459,21 +577,21 @@ public function testSendWithDefaultConfiguration() { $this->assertEquals($overrideTo, $sendData['parameters']['to']); $this->assertEquals($overrideFrom, $sendData['parameters']['from']); - $this->assertContains( $overrideCc, explode(",", $sendData['parameters']['cc']) ); - $this->assertContains( "{$overrideBccName} <{$overrideBcc}>", explode(",", $sendData['parameters']['bcc']) ); - + $this->assertContains($overrideCc, explode(",", (string) $sendData['parameters']['cc'])); + $this->assertContains("\"{$overrideBccName}\" <{$overrideBcc}>", explode(",", (string) $sendData['parameters']['bcc'])); } /** * test a message with attachments */ - public function testAttachmentDelivery() { - $to_address = self::config()->get('to_address'); - $to_name = self::config()->get('to_name'); + public function testAttachmentDelivery(): void + { + $to_address = $this->to_address; + $to_name = $this->to_name; $this->assertNotEmpty($to_address); - $from_address = self::config()->get('from_address'); - $from_name = self::config()->get('from_name'); + $from_address = $this->from_address; + $from_name = $this->from_name; $this->assertNotEmpty($from_address); $from = [ @@ -490,49 +608,59 @@ public function testAttachmentDelivery() { $email->setFrom($from); $email->setTo($to); $email->setSubject($subject); - $htmlBody = self::config()->get('test_body'); - $email->setBody( $htmlBody ); + + $htmlBody = $this->test_body; + $email->setBody($htmlBody); $files = [ "test_attachment.pdf" => 'application/pdf', "test_attachment.txt" => 'text/plain' ]; $f = 1; - foreach($files as $file => $mimetype) { + foreach ($files as $file => $mimetype) { $email->addAttachment( - dirname(__FILE__) . "/attachments/{$file}", + __DIR__ . "/attachments/{$file}", $file, $mimetype ); $f++; } - $response = $email->send(); + $email->send(); $sendData = TestMessage::getSendData(); $this->assertArrayHasKey('parameters', $sendData); $this->assertArrayHasKey('attachment', $sendData['parameters']); - $attachments = $sendData['parameters']['attachment']; + + // Symfony attachments + $symfonyAttachments = $email->getAttachments(); + $this->assertEquals(count($files), count($symfonyAttachments)); + foreach ($symfonyAttachments as $symfonyAttachment) { + $this->assertInstanceOf(\Symfony\Component\Mime\Part\DataPart::class, $symfonyAttachment); + } + + // Mailgun formatted attachments + $mailgunAttachments = $sendData['parameters']['attachment']; $f = 1; - $this->assertEquals(count($files), count($attachments)); - foreach($attachments as $attachment) { - $this->assertArrayHasKey( 'filename', $attachment ); - $this->assertArrayHasKey( 'mimetype', $attachment ); - $this->assertArrayHasKey( 'fileContent', $attachment ); - foreach($files as $file => $mimetype) { - if($file == $attachment['filename']) { - $this->assertEquals($mimetype, $attachment['mimetype']); - $this->assertNotEmpty($attachment['fileContent']); + $this->assertEquals(count($files), count($mailgunAttachments)); + foreach ($mailgunAttachments as $mailgunAttachment) { + $this->assertArrayHasKey('filename', $mailgunAttachment); + $this->assertArrayHasKey('mimetype', $mailgunAttachment); + $this->assertArrayHasKey('fileContent', $mailgunAttachment); + foreach ($files as $file => $mimetype) { + if ($file == $mailgunAttachment['filename']) { + $this->assertEquals($mimetype, $mailgunAttachment['mimetype']); + $this->assertNotEmpty($mailgunAttachment['fileContent']); $this->assertEquals( - file_get_contents( dirname(__FILE__) . "/attachments/{$file}" ), - $attachment['fileContent'] + file_get_contents(__DIR__ . "/attachments/{$file}"), + $mailgunAttachment['fileContent'] ); } } + $f++; } } - } diff --git a/tests/README.md b/tests/README.md index 2ebd2d8..e53dfc7 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,36 +1,11 @@ # Test help -The tests here cover basic delivery of messages via the configured ```Mailer``` and directly to the Mailgun PHP SDK. +The tests here cover basic delivery of messages via `Email` and directly to the Mailgun PHP SDK. There are also webhook tests that use sample POSTed values retrieved from actual webhook requests. ## Running + `TestMessage` is registered as the connector service between the Mailer and the Mailgun PHP SDK ++ Emails are captured by TestMessage and return mock response + Submissions from tests set o:testmode='yes' in the request to avoid message leakage - -## Configuration - -+ Ensure the ```NSWDPC\Messaging\Mailgun\Connector\Base``` yml is correct for your setup. Use a project yml config file (`/mysite/_config` or `/app/_config` config.yml) -+ Add a copy of the below to your project configuration, with relevant changes - -```yml -NSWDPC\Messaging\Mailgun\MailgunSyncTest: - # a test recipient address - where test emails will be sent - to_address : 'recipient@example.com' - - # a test recipient name - to_name : 'Some Recipient' - - # a Cc address to use in tests (optional) - cc_address : 'cc@example.com' - - # a test sender address - from_address : 'sender@example.com' - - # a test sender name - from_name : 'Some Sender' - - # content for the message body - test_body : '' -``` diff --git a/tests/TestMessage.php b/tests/TestMessage.php index 7d57bf0..ce6213b 100644 --- a/tests/TestMessage.php +++ b/tests/TestMessage.php @@ -1,9 +1,11 @@ sendViaJob(); - switch ($send_via_job) { - case 'yes': - $this->sentVia = 'job'; - $response = $this->queueAndSend($domain, $parameters, $in); - break; - case 'when-attachments': - if (!empty($parameters['attachment'])) { - $this->sentVia = 'job-as-attachments'; - $response = $this->queueAndSend($domain, $parameters, $in); - break; - } - case 'no': - default: - $this->sentVia = 'direct-to-api'; - $response = SendResponse::create(['id' => self::MSG_ID, 'message' => self::MSG_MESSAGE]); - break; + if ($send_via_job === 'yes') { + $this->sentVia = 'job'; + $response = $this->queueAndSend($domain, $parameters, $in); + } elseif ($send_via_job === 'when-attachments') { + $this->sentVia = 'job-as-attachments'; + $response = $this->queueAndSend($domain, $parameters, $in); + } else { + $this->sentVia = 'direct-to-api'; + $response = SendResponse::create(['id' => self::MSG_ID, 'message' => self::MSG_MESSAGE]); } // Store message info @@ -79,6 +72,8 @@ protected function sendMessage(array $parameters) { 'sentVia' => $this->sentVia, 'client' => $this->getClient(), 'domain' => $this->getApiDomain(), + 'key' => $this->getApiKey(), + 'region' => $this->getApiEndpointRegion(), 'response' => $response ]); @@ -88,15 +83,24 @@ protected function sendMessage(array $parameters) { /** * Set data that would be used */ - public function setSendData(array $data) { + public function setSendData(array $data) + { self::$sendData = $data; } /** * Get data that would be used */ - public static function getSendData() : array { + public static function getSendData(): array + { return self::$sendData; } + /** + * Get a specific data value that would be used + */ + public static function getSendDataValue(string $key): mixed + { + return self::$sendData[$key] ?? null; + } } diff --git a/tests/WebhookTest.php b/tests/WebhookTest.php index 46a0e80..0e0a74f 100644 --- a/tests/WebhookTest.php +++ b/tests/WebhookTest.php @@ -4,74 +4,91 @@ use NSWDPC\Messaging\Mailgun\Connector\Base; use NSWDPC\Messaging\Mailgun\Connector\Webhook; -use NSWDPC\Messaging\Mailgun\MailgunEvent; +use NSWDPC\Messaging\Mailgun\Controllers\MailgunWebHook; +use NSWDPC\Messaging\Mailgun\Models\MailgunEvent; use SilverStripe\Dev\FunctionalTest; use SilverStripe\Core\Config\Config; +use SilverStripe\Core\Environment; /** - * Tests for RequestHandler and HTTPRequest. - * We've set up a simple URL handling model based on - * https://36ca20005a6091432f680c4bff2191a4.m.pipedream.net + * Tests for RequestHandler and HTTPRequest */ class WebhookTest extends FunctionalTest { + protected string $webhook_filter_variable = 'test-filter-var-curr'; - private $webhook_filter_variable = 'skjhgiehg943753-"'; - private $webhook_previous_filter_variable = 'snsd875bslw['; + protected string $webhook_previous_filter_variable = 'test-filter-var-prev'; + + protected string $webhook_signing_key = 'TEST_SHOULD_PASS'; + + protected string $test_api_key = 'webhook_api_key'; + + protected string $test_api_domain = 'webhook.example.net'; + + protected string $test_api_region = 'API_ENDPOINT_EU'; protected $usesDatabase = true; - public function setUp() : void { + public function setUp(): void + { parent::setUp(); - Config::modify()->set(Base::class, 'webhook_filter_variable', $this->webhook_filter_variable); - Config::modify()->set(Base::class, 'webhook_previous_filter_variable', $this->webhook_previous_filter_variable); - Config::modify()->set(Base::class, 'webhooks_enabled', true); + Environment::setEnv('MAILGUN_WEBHOOK_API_KEY', $this->test_api_key); + Environment::setEnv('MAILGUN_WEBHOOK_DOMAIN', $this->test_api_domain); + Environment::setEnv('MAILGUN_WEBHOOK_REGION', $this->test_api_region); + Environment::setEnv('MAILGUN_WEBHOOK_FILTER_VARIABLE', $this->webhook_filter_variable); + Environment::setEnv('MAILGUN_WEBHOOK_PREVIOUS_FILTER_VARIABLE', $this->webhook_previous_filter_variable); + Environment::setEnv('MAILGUN_WEBHOOK_SIGNING_KEY', $this->webhook_signing_key); + Config::modify()->set(MailgunWebHook::class, 'webhooks_enabled', true); + } + + protected function getTestDsn(): string + { + $domain = Environment::getEnv('MAILGUN_WEBHOOK_DOMAIN'); + $key = Environment::getEnv('MAILGUN_WEBHOOK_API_KEY'); + return "mailgunsync+api://{$domain}:{$key}@default"; } /** * Get test data from disk */ - protected function getWebhookRequestData($event_type) { - return file_get_contents( dirname(__FILE__) . "/webhooks/{$event_type}.json"); + protected function getWebhookRequestData($event_type): string|false + { + return file_get_contents(__DIR__ . "/webhooks/{$event_type}.json"); } /** * Our configured endpoint for submitting POST data */ - protected function getSubmissionUrl() { + protected function getSubmissionUrl(): string + { return '_wh/submit'; } /** * Webhook Mailgun API connector */ - protected function getConnector() { - return Webhook::create(); - } - - /** - * Set a signing key in Configuration - * @param string $signing_key - */ - protected function setSigningKey($signing_key) { - Config::modify()->set(Base::class, 'webhook_signing_key', $signing_key); + protected function getConnector() + { + return Webhook::create($this->getTestDsn()); } /** * Replace the signature on the request data with something to trigger success/error - * @param string $signing_key - * @param string $request_data + * @param string $request_data JSON encoded request data from a test payload * @return array */ - protected function setSignatureOnRequest($signing_key, $request_data) { + protected function setSignatureOnRequest(string $request_data) + { $decoded = json_decode($request_data, true); $connector = $this->getConnector(); + // sign the signature $signature = $connector->sign_token($decoded['signature']); $decoded['signature']['signature'] = $signature; return $decoded; } - protected function setWebhookFilterVariable($data, $value) { + protected function setWebhookFilterVariable(array $data, $value): array + { $data['event-data']['user-variables']['wfv'] = $value; return $data; } @@ -81,45 +98,45 @@ protected function setWebhookFilterVariable($data, $value) { * and one that should fail * @param string $type */ - protected function sendWebhookRequest($type) { + protected function sendWebhookRequest($type) + { - $signing_key = "TEST_SHOULD_PASS"; - $this->setSigningKey($signing_key); + $signingKey = Environment::getEnv('MAILGUN_WEBHOOK_SIGNING_KEY'); $url = $this->getSubmissionUrl(); $headers = [ 'Content-Type' => "application/json" ]; $session = null; - $data = $this->setSignatureOnRequest($signing_key, $this->getWebhookRequestData($type)); - $data = $this->setWebhookFilterVariable($data, $this->webhook_filter_variable); + $data = $this->setSignatureOnRequest($this->getWebhookRequestData($type)); + $data = $this->setWebhookFilterVariable($data, Environment::getEnv('MAILGUN_WEBHOOK_FILTER_VARIABLE')); + $cookies = null; - $body = json_encode($data, JSON_UNESCAPED_SLASHES|JSON_PRETTY_PRINT); + $body = json_encode($data, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); $response = $this->post($url, $data, $headers, $session, $body, $cookies); $this->assertEquals( 200, $response->getStatusCode(), - 'Expected success response with correct signing_key failed: ' . $response->getStatusCode() . "/" . $response->getStatusDescription() + 'Expected success response with correct signing_key ' . $signingKey . ' failed: ' . $response->getStatusCode() . "/" . $response->getStatusDescription() ); $event = \Mailgun\Model\Event\Event::create($data['event-data']); // test if the event was saved $record = MailgunEvent::get()->filter('EventId', $event->getId())->first(); - $this->assertTrue( $record && $record->exists() , "DB Mailgun event does not exist for event {$event->getId()}"); + $this->assertTrue($record && $record->exists(), "DB Mailgun event does not exist for event {$event->getId()}"); // change the webhook config variable to the previous var - $data = $this->setWebhookFilterVariable($data, $this->webhook_previous_filter_variable); + $data = $this->setWebhookFilterVariable($data, Environment::getEnv('MAILGUN_WEBHOOK_PREVIOUS_FILTER_VARIABLE')); $response = $this->post($url, $data, $headers, $session, json_encode($data, JSON_UNESCAPED_SLASHES), $cookies); $this->assertEquals( 200, $response->getStatusCode(), - 'Expected success response with correct signing_key failed: ' . $response->getStatusCode() . "/" . $response->getStatusDescription() + 'Expected success response with correct signing_key ' . $signingKey . ' failed: ' . $response->getStatusCode() . "/" . $response->getStatusDescription() ); - // change the webhook variable to something else completely $data = $this->setWebhookFilterVariable($data, 'not going to work'); $response = $this->post($url, $data, $headers, $session, json_encode($data, JSON_UNESCAPED_SLASHES), $cookies); @@ -130,48 +147,51 @@ protected function sendWebhookRequest($type) { ); // remove webhook variable and test - unset( $data['event-data']['user-variables']['wfv'] ); - Config::modify()->set(Base::class, 'webhook_filter_variable', ''); - Config::modify()->set(Base::class, 'webhook_previous_filter_variable', ''); - - // change the signing key in config, it should fail now - $signing_key = "YOU_SHALL_NOT_PASS"; - $this->setSigningKey($signing_key); + unset($data['event-data']['user-variables']['wfv']); + Environment::setEnv('MAILGUN_WEBHOOK_FILTER_VARIABLE', ''); + Environment::setEnv('MAILGUN_WEBHOOK_PREVIOUS_FILTER_VARIABLE', ''); + // change the signing key, it should fail now as the payload signatures are signed with the 'webhook_signing_key' value + Environment::setEnv('MAILGUN_WEBHOOK_SIGNING_KEY', "YOU_SHALL_NOT_PASS"); $response = $this->post($url, $data, $headers, $session, json_encode($data, JSON_UNESCAPED_SLASHES), $cookies); $this->assertEquals( 406, $response->getStatusCode(), 'Expected failed response code 406 with incorrect signing_key but got ' . $response->getStatusCode() . "/" . $response->getStatusDescription() ); - } - public function testWebookDelivered() { + public function testWebhookDelivered(): void + { $this->sendWebhookRequest("delivered"); } - public function testWebookClick() { + public function testWebhookClick(): void + { $this->sendWebhookRequest("clicked"); } - public function testWebookOpened() { + public function testWebhookOpened(): void + { $this->sendWebhookRequest("opened"); } - public function testWebookFailedPermanent() { + public function testWebhookFailedPermanent(): void + { $this->sendWebhookRequest("failed_permanent"); } - public function testWebookFailedTemporary() { + public function testWebhookFailedTemporary(): void + { $this->sendWebhookRequest("failed_temporary"); } - public function testWebookUnsubscribed() { + public function testWebhookUnsubscribed(): void + { $this->sendWebhookRequest("unsubscribed"); } - public function testWebookComplained() { + public function testWebhookComplained(): void + { $this->sendWebhookRequest("complained"); } - }