├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ └── tests.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── composer.json ├── config └── postal.php ├── migrations ├── 2019_07_12_132900_postal_create_email_table.php └── 2019_07_12_132901_postal_create_email_webhook_table.php ├── phpstan-baseline.neon ├── phpstan.neon ├── phpunit.xml ├── src ├── Controllers │ └── WebhookController.php ├── Models │ ├── Email.php │ └── Email │ │ └── Webhook.php ├── Notifications │ └── Emailable.php ├── PostalNotificationChannel.php ├── PostalServiceProvider.php └── PostalTransport.php └── tests ├── Controllers └── WebhookControllerTest.php ├── ExampleMailable.php ├── ExampleMailableWithAttachments.php ├── ExampleNotifiable.php ├── ExampleNotifiableWithRouteMethod.php ├── ExampleNotification.php ├── FeatureTest.php ├── PostalNotificationChannelTest.php ├── PostalServiceProviderTest.php ├── PostalTransportTest.php ├── TestCase.php ├── bootstrap.php └── fixtures ├── key ├── key.pub ├── test-attachment └── test-image /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = space 3 | indent_size = 4 4 | root = true 5 | 6 | [*.yml] 7 | indent_style = space 8 | indent_size = 2 9 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: composer 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | ignore: 9 | - dependency-name: phpunit/phpunit 10 | versions: 11 | - ">= 8.a, < 9" 12 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | laravel: [11, 12] 12 | php: [8.2, 8.3, 8.4] 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: test against Laravel ${{ matrix.laravel }} on PHP ${{ matrix.php }} 18 | run: docker build . --build-arg PHP_VERSION=${{ matrix.php }} --build-arg LARAVEL=${{ matrix.laravel }} 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | composer.lock 3 | coverage-html 4 | coverage.xml 5 | .phpunit.result.cache 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG PHP_VERSION=7.3 2 | FROM php:$PHP_VERSION-cli-alpine 3 | 4 | RUN apk add git zip unzip autoconf make g++ 5 | 6 | RUN curl -sS https://getcomposer.org/installer | php \ 7 | && mv composer.phar /usr/local/bin/composer 8 | 9 | WORKDIR /package 10 | 11 | RUN adduser -D -g '' dev 12 | 13 | RUN chown dev -R /package 14 | 15 | USER dev 16 | 17 | COPY --chown=dev composer.json ./ 18 | 19 | ARG LARAVEL=7 20 | RUN composer require laravel/framework ^$LARAVEL.0 21 | 22 | COPY --chown=dev . . 23 | 24 | RUN composer test 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Synergi Tech Ltd 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Postal 2 | [![Latest Stable Version](https://img.shields.io/packagist/v/synergitech/laravel-postal.svg?style=flat-square)](https://packagist.org/packages/synergitech/laravel-postal) 3 | ![Tests](https://github.com/SynergiTech/laravel-postal/workflows/Tests/badge.svg) 4 | 5 | This library integrates [Postal](https://github.com/postalhq/postal) with the standard Laravel mail framework. 6 | 7 | ## Install 8 | 9 | First, install the package using Composer: 10 | 11 | ``` 12 | composer require synergitech/laravel-postal 13 | ``` 14 | Next, run the package migrations: 15 | ``` 16 | php artisan migrate 17 | ``` 18 | 19 | Next, add your credentials to your `.env` and set your mail driver to `postal`: 20 | 21 | ``` 22 | MAIL_MAILER=postal 23 | 24 | POSTAL_DOMAIN=https://your.postal.server 25 | POSTAL_KEY=yourapicredential 26 | ``` 27 | 28 | Finally, add postal as a mailer to your `config/mail.php` file 29 | 30 | ``` 31 | 'mailers' => [ 32 | 'postal' => [ 33 | 'transport' => 'postal', 34 | ], 35 | ], 36 | ``` 37 | 38 | If you want to alter the configuration further, you can reference the `config/postal.php` file for the keys to place in your environment. Alternatively, you can publish the config file in the usual way if you wish to make specific changes: 39 | ``` 40 | php artisan vendor:publish --provider="SynergiTech\Postal\PostalServiceProvider" 41 | ``` 42 | 43 | Also make sure you have filled out who the email comes from and that the domain you use is authorised by the API credential. 44 | 45 | ``` 46 | MAIL_FROM_ADDRESS=noreply@your.company 47 | MAIL_FROM_NAME="Your Company" 48 | ``` 49 | 50 | ## Usage 51 | 52 | As this is a driver for the main Laravel Mail framework, sending emails is the same as usual - just follow the Laravel Mail documentation - however we recommend you make use of the `PostalNotificationChannel` class to enable full email tracking within your software. 53 | 54 | ## Upgrading 55 | ### Upgrading to V4 56 | Version 4 only supports Laravel 9 and newer owing to significant changes in how Laravel processes email. 57 | 58 | We also now throw `TransportException` when an API error occurs, instead of a `BadMethodCallException`. 59 | 60 | ### Upgrading to V3 61 | If you are updating to Laravel 7 as well, you will need to update your environment variable names. 62 | 63 | ### Upgrading from V1 to V2 64 | **Please note** version 2 is backwards compatible with version 1 as long as you are not using a listener. Version 2 is also configured differently and includes many more features (including support for webhooks) so if you're upgrading from version 1, please take time to re-read this information. 65 | 66 | ### Upgrading between V2 and V2.1 67 | There are no backwards incompatible changes between these two versions unless you have customized the default table names. Prior to v2.1, we published our migration files into your application. Beginning in v2.1, we now present these to Laravel in our service provider. 68 | 69 | Our migrations will be run again when upgrading between these two versions. The migrations will not recreate the table or otherwise error when it detects the presence of the default tables. However, if they have been renamed, they will be created again. Simply create a new migration to drop the tables. 70 | 71 | ### Logging messages sent against notifiable models 72 | 73 | Create an `email` notification as you would normally but have `'SynergiTech\Postal\PostalNotificationChannel'` or `PostalNotificationChannel::class` returned in the `via()` method. 74 | 75 | In order to associate the messages with the notifiable model, you will need to return the model object in a method called `logEmailAgainstModel` in your notification class. If you do not include this method, the messages will still be logged (if you have that enabled in the config) but the link back to your notifiable model will not be created. 76 | 77 | Here is a complete example of what you need to do to ensure your notifiable model is linked to the emails that get sent. 78 | 79 | ```php 80 | use App\Notifications\EnquiryNotification; 81 | use Illuminate\Support\Facades\Notification; 82 | use SynergiTech\Postal\PostalNotificationChannel; 83 | 84 | // Using the Notifiable trait 85 | $user->notify(new EnquiryNotification($enquiry)); 86 | 87 | // On demand notifications 88 | Notification::route(PostalNotificationChannel::class, 'john.smith@example.com') 89 | ->notify(new EnquiryNotification($enquiry)); 90 | ``` 91 | 92 | ```php 93 | namespace App\Notifications; 94 | 95 | use Illuminate\Notifications\Notification; 96 | use SynergiTech\Postal\PostalNotificationChannel; 97 | 98 | class EnquiryNotification extends Notification 99 | { 100 | private $enquiry; 101 | 102 | public function __construct($enquiry) 103 | { 104 | $this->enquiry = $enquiry; 105 | } 106 | 107 | public function via($notifiable) 108 | { 109 | return [PostalNotificationChannel::class]; 110 | } 111 | 112 | public function logEmailAgainstModel() 113 | { 114 | return $this->enquiry; 115 | } 116 | 117 | public function toMail($notifiable) 118 | { 119 | // message constructed here 120 | } 121 | } 122 | ``` 123 | 124 | You can still send messages through Postal as a driver if you just leave `'mail'` in the `via()` method but the channel from this package is responsible for creating the link so if you do not use `PostalNotificationChannel` as mentioned above, there will not be a link between the messages and your notifiable model. 125 | 126 | **Please note** that Postals PHP client can throw exceptions if it fails to submit the message to the server (i.e. a permission problem occurred or an email address wasn't valid) so if you have a process which relies on sending an email, it would be advisable to send the notification before proceeding (i.e. saving the updated object to the database). 127 | 128 | ### Send all email to one address (i.e. for development) 129 | 130 | Our [similar package for FuelPHP](https://github.com/SynergiTech/fuelphp-postal) allows you to send all messages to a specific email address defined in your environment. Laravel already has a mechanism for this and you can use it by updating the `config/mail.php` file as follows: 131 | 132 | ```php 133 | $config = [ 134 | // existing config array 135 | ]; 136 | 137 | if (getenv('EMAIL')) { 138 | $config['to'] = [ 139 | 'address' => getenv('EMAIL'), 140 | 'name' => 'Your Name' 141 | ]; 142 | } 143 | 144 | return $config; 145 | ``` 146 | 147 | ## Webhooks 148 | 149 | This package also provides the ability for you to record webhooks from Postal. This functionality is enabled by default. 150 | 151 | ### Verifying the webhook signature 152 | 153 | Each webhook payload should include a couple of unique values for some level of accuracy in your webhooks but if you want to verify the signature, you must provide the signing key from your Postal and enable this feature. 154 | 155 | You can access the signing public key by running `postal default-dkim-record` on your Postal server and copying the value of the `p` parameter (excluding the semicolon) to your environment under the key `POSTAL_WEBHOOK_PUBLIC_KEY`. 156 | 157 | ## Listeners 158 | 159 | As with default Laravel, you can make use of the `Illuminate\Mail\Events\MessageSent` listener. In version 1, you received the whole response from Postal however in version 2 you will only receive a `Postal-Message-ID` and this is contained in the message header. This will allow you to access the emails created as this will be the value of the `postal_email_id` column. 160 | 161 | The change allows your code to meet static analysis requirements. 162 | 163 | ```php 164 | namespace App\Listeners; 165 | 166 | use Illuminate\Mail\Events\MessageSent; 167 | 168 | class MessageSentListener 169 | { 170 | /** 171 | * Handle the event. 172 | * 173 | * @param MessageSent $event 174 | * @return void 175 | */ 176 | public function handle(MessageSent $event) 177 | { 178 | $headers = $event->message->getHeaders(); 179 | $postalmessageid = $headers->get('postal-message-id')?->getBodyAsString(); 180 | 181 | if ($postalmessageid) { 182 | // do something here 183 | } 184 | } 185 | } 186 | ``` 187 | 188 | ## Running tests 189 | To run the full suite of unit tests: 190 | ``` 191 | vendor/bin/phpunit -c phpunit.xml 192 | ``` 193 | You will need xdebug installed to generate code coverage. 194 | 195 | ### Docker 196 | A sample Dockerfile is provided to setup an environment to run the tests without configuring your local machine. The Dockerfile can test multiple combinations of versions for PHP and Laravel via arguments. 197 | 198 | ``` 199 | docker build . --build-arg PHP_VERSION=8.1 --build-arg LARAVEL=9 200 | ``` 201 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "synergitech/laravel-postal", 3 | "description": "This library integrates Postal with the standard Laravel mail framework.", 4 | "keywords": [ 5 | "Laravel", 6 | "Postal", 7 | "Email" 8 | ], 9 | "homepage": "https://github.com/synergitech/laravel-postal", 10 | "license": "MIT", 11 | "require": { 12 | "php": "^8.0", 13 | "laravel/framework": "^9.0.1|^10.0|^11.0|^12.0", 14 | "postal/postal": "^2.0.1" 15 | }, 16 | "require-dev": { 17 | "larastan/larastan": "^3.0", 18 | "orchestra/testbench": "^8.0|^9.0|^10.0", 19 | "phpstan/extension-installer": "^1.4", 20 | "phpunit/phpunit": "^10.0|^11.0" 21 | }, 22 | "autoload": { 23 | "psr-4": { 24 | "SynergiTech\\Postal\\": "src", 25 | "SynergiTech\\Postal\\Tests\\": "tests" 26 | } 27 | }, 28 | "extra": { 29 | "laravel": { 30 | "providers": [ 31 | "SynergiTech\\Postal\\PostalServiceProvider" 32 | ] 33 | } 34 | }, 35 | "scripts": { 36 | "test": [ 37 | "phpunit", 38 | "phpstan --memory-limit=1G" 39 | ] 40 | }, 41 | "config": { 42 | "allow-plugins": { 43 | "phpstan/extension-installer": true 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /config/postal.php: -------------------------------------------------------------------------------- 1 | env('POSTAL_DOMAIN'), 12 | 13 | // this is an API credential in the same mail server 14 | // as the domain you wish to send from 15 | 'key' => env('POSTAL_KEY'), 16 | 17 | 'models' => [ 18 | 'email' => env('POSTAL_MODELS_EMAIL', Email::class), 19 | 'webhook' => env('POSTAL_MODELS_WEBHOOK', Webhook::class), 20 | ], 21 | 22 | // enable features within this package 23 | // - note that webhookreceiving requires emaillogging to actually do anything 24 | 'enable' => [ 25 | 'emaillogging' => env('POSTAL_ENABLE_EMAILLOG', true), 26 | 'webhookreceiving' => env('POSTAL_ENABLE_WEBHOOKRECEIVE', true), 27 | ], 28 | 29 | 'webhook' => [ 30 | // route to receive webhooks, configure to avoid collisions with the rest of your app 31 | 'route' => env('POSTAL_WEBHOOK_ROUTE', '/postal/webhook'), 32 | // attempt to verify the X-Postal-Signature header 33 | 'verify' => env('POSTAL_WEBHOOK_VERIFY', true), 34 | // the public key, sourced from your servers DKIM record "p" value WITHOUT THE TRAILING SEMICOLON 35 | 'public_key' => env('POSTAL_WEBHOOK_PUBLIC_KEY', ''), 36 | ], 37 | ]; 38 | -------------------------------------------------------------------------------- /migrations/2019_07_12_132900_postal_create_email_table.php: -------------------------------------------------------------------------------- 1 | getTable(); 18 | 19 | if (Schema::hasTable($table)) { 20 | return; 21 | } 22 | 23 | Schema::create($table, function (Blueprint $table) { 24 | $table->bigIncrements('id'); 25 | 26 | $table->string('to_name')->nullable(); 27 | $table->string('to_email'); 28 | 29 | $table->string('from_name')->nullable(); 30 | $table->string('from_email'); 31 | 32 | $table->string('subject')->nullable(); 33 | $table->longText('body')->nullable(); 34 | 35 | $table->string('postal_email_id'); 36 | $table->integer('postal_id'); 37 | $table->string('postal_token'); 38 | 39 | // must be nullable as morph is optional and 40 | // if selected, added later 41 | $table->nullableMorphs('emailable'); 42 | 43 | $table->timestamp('created_at')->nullable(); 44 | 45 | // index for searching groups of emails 46 | $table->index('postal_email_id'); 47 | 48 | // index for webhook searching 49 | $table->index(['postal_id', 'postal_token'], 'postal_id_token'); 50 | }); 51 | } 52 | 53 | /** 54 | * Reverse the migrations. 55 | * 56 | * @return void 57 | */ 58 | public function down() 59 | { 60 | $model = config('postal.models.email'); 61 | $table = (new $model())->getTable(); 62 | 63 | Schema::dropIfExists($table); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /migrations/2019_07_12_132901_postal_create_email_webhook_table.php: -------------------------------------------------------------------------------- 1 | getTable(); 18 | 19 | if (Schema::hasTable($table)) { 20 | return; 21 | } 22 | 23 | Schema::create($table, function (Blueprint $table) { 24 | $emailModel = config('postal.models.email'); 25 | $emailTable = (new $emailModel())->getTable(); 26 | 27 | $table->bigIncrements('id'); 28 | 29 | $table->unsignedBigInteger('email_id'); 30 | $table->foreign('email_id')->references('id')->on($emailTable)->onDelete('cascade'); 31 | 32 | $table->string('action')->nullable(); 33 | 34 | $table->longText('payload')->nullable(); 35 | 36 | $table->timestamp('created_at')->nullable(); 37 | }); 38 | } 39 | 40 | /** 41 | * Reverse the migrations. 42 | * 43 | * @return void 44 | */ 45 | public function down() 46 | { 47 | $model = config('postal.models.webhook'); 48 | $table = (new $model())->getTable(); 49 | 50 | Schema::dropIfExists($table); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /phpstan-baseline.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | ignoreErrors: 3 | - 4 | message: "#^Access to an undefined property object\\:\\:\\$action\\.$#" 5 | count: 1 6 | path: src/Controllers/WebhookController.php 7 | 8 | - 9 | message: "#^Access to an undefined property object\\:\\:\\$email_id\\.$#" 10 | count: 1 11 | path: src/Controllers/WebhookController.php 12 | 13 | - 14 | message: "#^Access to an undefined property object\\:\\:\\$id\\.$#" 15 | count: 1 16 | path: src/Controllers/WebhookController.php 17 | 18 | - 19 | message: "#^Access to an undefined property object\\:\\:\\$payload\\.$#" 20 | count: 1 21 | path: src/Controllers/WebhookController.php 22 | 23 | - 24 | message: "#^Call to an undefined method object\\:\\:save\\(\\)\\.$#" 25 | count: 1 26 | path: src/Controllers/WebhookController.php 27 | 28 | - 29 | message: "#^Call to an undefined method object\\:\\:where\\(\\)\\.$#" 30 | count: 1 31 | path: src/Controllers/WebhookController.php 32 | 33 | - 34 | message: "#^Call to an undefined method Illuminate\\\\Contracts\\\\Mail\\\\Factory\\:\\:send\\(\\)\\.$#" 35 | count: 1 36 | path: src/PostalNotificationChannel.php 37 | 38 | - 39 | message: "#^Access to an undefined property object\\:\\:\\$body\\.$#" 40 | count: 2 41 | path: src/PostalTransport.php 42 | 43 | - 44 | message: "#^Access to an undefined property object\\:\\:\\$from_email\\.$#" 45 | count: 1 46 | path: src/PostalTransport.php 47 | 48 | - 49 | message: "#^Access to an undefined property object\\:\\:\\$from_name\\.$#" 50 | count: 1 51 | path: src/PostalTransport.php 52 | 53 | - 54 | message: "#^Access to an undefined property object\\:\\:\\$postal_email_id\\.$#" 55 | count: 1 56 | path: src/PostalTransport.php 57 | 58 | - 59 | message: "#^Access to an undefined property object\\:\\:\\$postal_id\\.$#" 60 | count: 1 61 | path: src/PostalTransport.php 62 | 63 | - 64 | message: "#^Access to an undefined property object\\:\\:\\$postal_token\\.$#" 65 | count: 1 66 | path: src/PostalTransport.php 67 | 68 | - 69 | message: "#^Access to an undefined property object\\:\\:\\$subject\\.$#" 70 | count: 1 71 | path: src/PostalTransport.php 72 | 73 | - 74 | message: "#^Access to an undefined property object\\:\\:\\$to_email\\.$#" 75 | count: 1 76 | path: src/PostalTransport.php 77 | 78 | - 79 | message: "#^Access to an undefined property object\\:\\:\\$to_name\\.$#" 80 | count: 1 81 | path: src/PostalTransport.php 82 | 83 | - 84 | message: "#^Call to an undefined method object\\:\\:getTable\\(\\)\\.$#" 85 | count: 1 86 | path: src/PostalTransport.php 87 | 88 | - 89 | message: "#^Call to an undefined method object\\:\\:save\\(\\)\\.$#" 90 | count: 1 91 | path: src/PostalTransport.php 92 | 93 | - 94 | message: "#^Parameter \\#1 \\$content of method Postal\\\\Send\\\\Message\\:\\:htmlBody\\(\\) expects string, resource\\|string given\\.$#" 95 | count: 1 96 | path: src/PostalTransport.php 97 | 98 | - 99 | message: "#^Parameter \\#1 \\$content of method Postal\\\\Send\\\\Message\\:\\:plainBody\\(\\) expects string, resource\\|string given\\.$#" 100 | count: 1 101 | path: src/PostalTransport.php 102 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - ./phpstan-baseline.neon 3 | 4 | parameters: 5 | paths: 6 | - src 7 | 8 | level: 9 9 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | ./tests/ 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 29 | 30 | 31 | ./src 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/Controllers/WebhookController.php: -------------------------------------------------------------------------------- 1 | input('payload') === null) { 16 | // todo remove link header 17 | return response('No payload', 400); 18 | } 19 | 20 | if ( 21 | config('postal.webhook.verify') === true 22 | && is_string(config('postal.webhook.public_key')) 23 | && strlen(config('postal.webhook.public_key')) > 0 24 | ) { 25 | $rsa_key_pem = "-----BEGIN PUBLIC KEY-----\r\n" . 26 | chunk_split(config('postal.webhook.public_key'), 64) . 27 | "-----END PUBLIC KEY-----\r\n"; 28 | $rsa_key = openssl_pkey_get_public($rsa_key_pem) ?: ''; 29 | 30 | $signature = ''; 31 | $encodedSignature = $request->header('x-postal-signature'); 32 | if (is_string($encodedSignature)) { 33 | $signature = base64_decode($encodedSignature); 34 | } 35 | 36 | /** @var string $body */ 37 | $body = $request->getContent(); 38 | 39 | $result = openssl_verify($body, $signature, $rsa_key, OPENSSL_ALGO_SHA1); 40 | 41 | if ($result !== 1) { 42 | return response('Unable to match signature header', 400); 43 | } 44 | } 45 | 46 | $emailmodel = config('postal.models.email'); 47 | $webhookmodel = config('postal.models.webhook'); 48 | 49 | $emailmodel = new $emailmodel; 50 | $webhookmodel = new $webhookmodel; 51 | 52 | if ($request->input('payload.message') !== null) { 53 | $postal_id = $request->input('payload.message.id'); 54 | $postal_token = $request->input('payload.message.token'); 55 | } elseif ($request->input('payload.original_message') !== null) { 56 | $postal_id = $request->input('payload.original_message.id'); 57 | $postal_token = $request->input('payload.original_message.token'); 58 | } 59 | 60 | if (isset($postal_id) && isset($postal_token)) { 61 | $email = $emailmodel 62 | ->where('postal_id', $postal_id) 63 | ->where('postal_token', $postal_token) 64 | ->first(); 65 | 66 | // we aren't concerned about not matching an email, don't visibly error 67 | if (is_object($email)) { 68 | $webhookmodel->email_id = $email->id; 69 | $webhookmodel->action = $request->input('event'); 70 | $webhookmodel->payload = json_encode($request->input('payload')); 71 | $webhookmodel->save(); 72 | } 73 | } 74 | 75 | // todo remove link header 76 | return response('', 200); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Models/Email.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | public function webhooks(): HasMany 16 | { 17 | return $this->hasMany(Email\Webhook::class); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Models/Email/Webhook.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | public function email(): BelongsTo 19 | { 20 | return $this->belongsTo(Email::class); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Notifications/Emailable.php: -------------------------------------------------------------------------------- 1 | morphMany(config('postal.models.email'), 'emailable')->orderBy('created_at', 'desc'); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/PostalNotificationChannel.php: -------------------------------------------------------------------------------- 1 | toMail($notifiable); 23 | 24 | // remove the checks 25 | 26 | return $this->mailer->send( 27 | $this->buildView($message), 28 | array_merge($message->data(), $this->additionalMessageData($notification)), 29 | $this->messageBuilder($notifiable, $notification, $message) 30 | ); 31 | // fin 32 | } 33 | 34 | /** 35 | * Get the recipients of the given message. 36 | * 37 | * @param mixed $notifiable 38 | * @param \Illuminate\Notifications\Notification $notification 39 | * @param \Illuminate\Notifications\Messages\MailMessage $message 40 | * @return mixed 41 | */ 42 | protected function getRecipients($notifiable, $notification, $message) 43 | { 44 | $recipients = []; 45 | 46 | // check if routeNotificationForPostal is defined, or default to the mail driver 47 | foreach ([self::class, 'postal', 'mail'] as $driver) { 48 | /** @phpstan-ignore-next-line */ 49 | $driverRecipients = $notifiable->routeNotificationFor($driver, $notification); 50 | 51 | if ($driverRecipients === null) { 52 | continue; 53 | } 54 | 55 | $recipients = $driverRecipients; 56 | if (is_string($recipients)) { 57 | $recipients = [$recipients]; 58 | } 59 | 60 | break; 61 | } 62 | 63 | /** @var array $recipients */ 64 | return collect($recipients)->mapWithKeys(function ($recipient, $email) { 65 | return is_numeric($email) 66 | /** @phpstan-ignore-next-line */ 67 | ? [$email => (is_string($recipient) ? $recipient : $recipient->email)] 68 | : [$email => $recipient]; 69 | })->all(); 70 | } 71 | 72 | /** 73 | * Build the mail message. 74 | * 75 | * @param \Illuminate\Mail\Message $mailMessage 76 | * @param mixed $notifiable 77 | * @param \Illuminate\Notifications\Notification $notification 78 | * @param \Illuminate\Notifications\Messages\MailMessage $message 79 | * @return void 80 | */ 81 | protected function buildMessage($mailMessage, $notifiable, $notification, $message) 82 | { 83 | parent::buildMessage($mailMessage, $notifiable, $notification, $message); 84 | 85 | // the model that the notification is based on should be returned 86 | // by a logEmailAgainstModel method in your notification 87 | if (method_exists($notification, 'logEmailAgainstModel')) { 88 | $model = $notification->logEmailAgainstModel(); 89 | if ($model instanceof Model) { 90 | // todo use callbacks instead? 91 | $headers = $mailMessage->getSymfonyMessage()->getHeaders(); 92 | $headers->addTextHeader('notifiable_class', get_class($model)); 93 | 94 | /** @var string $modelKey */ 95 | $modelKey = $model->getKey(); 96 | $headers->addTextHeader('notifiable_id', $modelKey); 97 | } 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/PostalServiceProvider.php: -------------------------------------------------------------------------------- 1 | publishes([ 20 | $configPath => config_path('postal.php'), 21 | ], 'config'); 22 | 23 | $this->loadMigrationsFrom($basePath . 'migrations'); 24 | 25 | // include the config file from the package if it isn't published 26 | $this->mergeConfigFrom($configPath, 'postal'); 27 | 28 | $webhookRoute = config('postal.webhook.route'); 29 | if (config('postal.enable.webhookreceiving') === true and is_string($webhookRoute)) { 30 | Route::post($webhookRoute, [WebhookController::class, 'process']); 31 | } 32 | 33 | Mail::extend('postal', function (array $config = []) { 34 | $config = config('postal'); 35 | 36 | if (! is_array($config)) { 37 | throw new \RuntimeException('missing Postal configuration'); 38 | } 39 | if (! is_string($config['domain'])) { 40 | throw new \RuntimeException('missing Postal domain configuration'); 41 | } 42 | if (! is_string($config['key'])) { 43 | throw new \RuntimeException('missing Postal key configuration'); 44 | } 45 | 46 | return new PostalTransport(new Client($config['domain'], $config['key'])); 47 | }); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/PostalTransport.php: -------------------------------------------------------------------------------- 1 | getOriginalMessage(); 33 | $symfonyMessage = MessageConverter::toEmail($originalMessage); 34 | 35 | $postalmessage = $this->symfonyToPostal($symfonyMessage); 36 | 37 | try { 38 | $response = $this->client->send->message($postalmessage); 39 | } catch (ApiException $error) { 40 | throw new TransportException($error->getMessage(), $error->getCode(), $error); 41 | } 42 | 43 | $headers = $symfonyMessage->getHeaders(); 44 | 45 | // send known header back for laravel to match emails coming out of Postal 46 | // - doesn't seem we can replace Message-ID 47 | $headers->addTextHeader('Postal-Message-ID', $response->message_id); 48 | 49 | if (config('postal.enable.emaillogging') !== true) { 50 | return; 51 | } 52 | 53 | $this->recordEmailsFromResponse($symfonyMessage, $response); 54 | 55 | $emailable_type = $headers->get('notifiable_class')?->getBody(); 56 | $emailable_id = $headers->get('notifiable_id')?->getBody(); 57 | 58 | // headers only set if using PostalNotificationChannel 59 | if ($emailable_type != '' && $emailable_id != '') { 60 | $emailmodel = config('postal.models.email'); 61 | \DB::table((new $emailmodel)->getTable()) 62 | ->where('postal_email_id', $response->message_id) 63 | ->update([ 64 | 'emailable_type' => $emailable_type, 65 | 'emailable_id' => $emailable_id, 66 | ]); 67 | } 68 | } 69 | 70 | /** 71 | * Convert symfony message into a Postal sendmessage 72 | */ 73 | private function symfonyToPostal(Email $symfonyMessage): SendMessage 74 | { 75 | // SendMessage cannot be reset so must be instantiated for each use 76 | $postalMessage = $this->getNewSendMessage(); 77 | 78 | $recipients = []; 79 | foreach (['to', 'cc', 'bcc'] as $type) { 80 | foreach ((array) $symfonyMessage->{'get' . ucwords($type)}() as $symfonyAddress) { 81 | // dedup recipients 82 | if (! in_array($symfonyAddress->getAddress(), $recipients)) { 83 | $recipients[] = $symfonyAddress->getAddress(); 84 | $postalMessage->{$type}($this->stringifyAddress($symfonyAddress)); 85 | } 86 | } 87 | } 88 | 89 | foreach ($symfonyMessage->getFrom() as $symfonyAddress) { 90 | $postalMessage->from($this->stringifyAddress($symfonyAddress)); 91 | } 92 | 93 | foreach ($symfonyMessage->getReplyTo() as $symfonyAddress) { 94 | $postalMessage->replyTo($this->stringifyAddress($symfonyAddress)); 95 | } 96 | 97 | if ($symfonyMessage->getSubject()) { 98 | $postalMessage->subject($symfonyMessage->getSubject()); 99 | } 100 | 101 | if ($symfonyMessage->getTextBody()) { 102 | $postalMessage->plainBody($symfonyMessage->getTextBody()); 103 | } 104 | if ($symfonyMessage->getHtmlBody()) { 105 | $postalMessage->htmlBody($symfonyMessage->getHtmlBody()); 106 | } 107 | 108 | foreach ($symfonyMessage->getAttachments() as $index => $symfonyPart) { 109 | $filename = $symfonyPart 110 | ->getPreparedHeaders() 111 | ->getHeaderParameter('content-disposition', 'filename'); 112 | 113 | $postalMessage->attach( 114 | $filename ?? "attached_file_$index", 115 | $symfonyPart->getMediaType() . '/' . $symfonyPart->getMediaSubtype(), 116 | $symfonyPart->getBody() 117 | ); 118 | } 119 | 120 | return $postalMessage; 121 | } 122 | 123 | /** 124 | * Preserve emails within database for later accounting with webhooks 125 | */ 126 | private function recordEmailsFromResponse(Email $symfonyMessage, SendResult $response): void 127 | { 128 | $recipients = []; 129 | 130 | // postals native libraries lowercase the email address but we still have the cased versions 131 | // in the swift message so rearrange what we have to get the best data out 132 | foreach (['to', 'cc', 'bcc'] as $type) { 133 | foreach ((array) $symfonyMessage->{'get' . ucwords($type)}() as $symfonyAddress) { 134 | $recipients[strtolower($symfonyAddress->getAddress())] = [ 135 | 'email' => $symfonyAddress->getAddress(), 136 | 'name' => $symfonyAddress->getName(), 137 | ]; 138 | } 139 | } 140 | 141 | $senderAddress = $symfonyMessage->getFrom(); 142 | $senderAddress = reset($senderAddress); 143 | 144 | $emailModel = config('postal.models.email'); 145 | 146 | foreach ($response->recipients() as $address => $message) { 147 | $email = new $emailModel; 148 | 149 | $email->to_email = $recipients[$address]['email']; 150 | $email->to_name = $recipients[$address]['name']; 151 | 152 | $email->from_email = $senderAddress ? $senderAddress->getAddress() : ''; 153 | $email->from_name = $senderAddress ? $senderAddress->getName() : ''; 154 | 155 | $email->subject = $symfonyMessage->getSubject(); 156 | 157 | if ($symfonyMessage->getTextBody()) { 158 | $email->body = $symfonyMessage->getTextBody(); 159 | } elseif ($symfonyMessage->getHtmlBody()) { 160 | $email->body = $symfonyMessage->getHtmlBody(); 161 | } 162 | 163 | $email->postal_email_id = $response->message_id; 164 | $email->postal_id = $message->id; 165 | $email->postal_token = $message->token; 166 | 167 | $email->save(); 168 | } 169 | } 170 | 171 | private function stringifyAddress(Address $address): string 172 | { 173 | if ($address->getName() != null) { 174 | return $address->getName() . ' <' . $address->getAddress() . '>'; 175 | } 176 | 177 | return $address->getAddress(); 178 | } 179 | 180 | private function getNewSendMessage(): SendMessage 181 | { 182 | return new SendMessage(); 183 | } 184 | 185 | /** 186 | * Get the string representation of the transport. 187 | */ 188 | public function __toString(): string 189 | { 190 | return 'postal'; 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /tests/Controllers/WebhookControllerTest.php: -------------------------------------------------------------------------------- 1 | postJson('/postal/webhook'); 12 | 13 | $response->assertStatus(400); 14 | $response->assertSee('No payload'); 15 | } 16 | 17 | public function testWebhookVerificationFailure() 18 | { 19 | $input = [ 20 | 'payload' => [ 21 | 'message' => [], 22 | ] 23 | ]; 24 | $response = $this 25 | ->withHeaders(['x-postal-signature' => 'invalid']) 26 | ->postJson('/postal/webhook', $input); 27 | 28 | $response->assertStatus(400); 29 | $response->assertSee('Unable to match signature header'); 30 | } 31 | 32 | public function testWebhookReceivedForNonexistentEmail() 33 | { 34 | $input = [ 35 | 'payload' => [ 36 | 'message' => [ 37 | 'id' => 'z', 38 | 'token' => 'z', 39 | ], 40 | ], 41 | ]; 42 | 43 | $body = json_encode($input); 44 | $signed = openssl_sign($body, $sig, $this->getKeyPair()['private'], OPENSSL_ALGO_SHA1); 45 | 46 | $this->assertTrue($signed); 47 | 48 | $response = $this 49 | ->withHeaders(['x-postal-signature' => base64_encode($sig)]) 50 | ->postJson('/postal/webhook', $input); 51 | 52 | $response->assertOk(); 53 | } 54 | 55 | public function testWebhookReceivedSuccessfully() 56 | { 57 | $input = [ 58 | 'payload' => [ 59 | 'message' => [ 60 | 'id' => 123, 61 | 'token' => 'a', 62 | ], 63 | ], 64 | 'event' => 'unit.test', 65 | ]; 66 | 67 | $this->sendSuccessfulWebhook($input); 68 | } 69 | 70 | public function testBounceWebhookReceivedSuccessfully() 71 | { 72 | $input = [ 73 | 'payload' => [ 74 | 'original_message' => [ 75 | 'id' => 123, 76 | 'token' => 'a', 77 | ], 78 | ], 79 | 'event' => 'unit.test', 80 | ]; 81 | 82 | $this->sendSuccessfulWebhook($input); 83 | } 84 | 85 | private function sendSuccessfulWebhook($input) 86 | { 87 | $emailModel = config('postal.models.email'); 88 | $email = new $emailModel(); 89 | $email->to_email = 'example@example.org'; 90 | $email->from_email = 'example@example.org'; 91 | $email->postal_id = 123; 92 | $email->postal_email_id = 'a'; 93 | $email->postal_token = 'a'; 94 | $email->save(); 95 | 96 | $body = json_encode($input); 97 | $signed = openssl_sign($body, $sig, $this->getKeyPair()['private'], OPENSSL_ALGO_SHA1); 98 | 99 | $this->assertTrue($signed); 100 | 101 | $response = $this 102 | ->withHeaders(['x-postal-signature' => base64_encode($sig)]) 103 | ->postJson('/postal/webhook', $input); 104 | 105 | $response->assertOk(); 106 | 107 | $webhookModel = config('postal.models.webhook'); 108 | $webhook = $webhookModel::where('email_id', $email->id)->first(); 109 | 110 | // detect whether the query was successful, i.e. the hardcoded IDs correspond 111 | $this->assertInstanceOf($webhookModel, $webhook); 112 | 113 | $this->assertSame('unit.test', $webhook->action); 114 | 115 | $this->assertEquals([$webhook], $email->webhooks()->get()->all()); 116 | $this->assertTrue($webhook->email()->first()->is($email)); 117 | $webhook->delete(); 118 | $email->delete(); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /tests/ExampleMailable.php: -------------------------------------------------------------------------------- 1 | html(' 34 | 35 | 36 | Example Mailable 37 | 38 | 39 | ') 40 | ->subject('Example Notification') 41 | ->replyTo('noreply@example.com'); 42 | 43 | if ($this->fromEmail !== null) { 44 | $mailable->from($this->fromEmail); 45 | } 46 | 47 | return $mailable; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tests/ExampleMailableWithAttachments.php: -------------------------------------------------------------------------------- 1 | html(' 34 | 35 | 36 | Example Mailable 37 | 38 | 39 | ') 40 | ->subject('Example Notification') 41 | ->replyTo('noreply@example.com') 42 | ->attach('tests/fixtures/test-attachment'); 43 | 44 | if ($this->fromEmail !== null) { 45 | $mailable->from($this->fromEmail); 46 | } 47 | 48 | return $mailable; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/ExampleNotifiable.php: -------------------------------------------------------------------------------- 1 | id = 1234; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tests/ExampleNotifiableWithRouteMethod.php: -------------------------------------------------------------------------------- 1 | id = 1234; 18 | } 19 | 20 | public function routeNotificationForPostal($notification) 21 | { 22 | return $this->email; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/ExampleNotification.php: -------------------------------------------------------------------------------- 1 | model = $model; 16 | } 17 | 18 | public function via($notifiable) 19 | { 20 | return [PostalNotificationChannel::class]; 21 | } 22 | 23 | public function toMail($notifiable) 24 | { 25 | $message = (new MailMessage()) 26 | ->subject('Example Notification'); 27 | 28 | return $message; 29 | } 30 | 31 | public function logEmailAgainstModel() 32 | { 33 | return $this->model; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/FeatureTest.php: -------------------------------------------------------------------------------- 1 | createMock(Client::class); 20 | $serviceMock = $this->createMock(SendService::class); 21 | 22 | $result = new Result([ 23 | 'message_id' => 'feature-test', 24 | 'messages' => ['feature-test@example.com' => [ 25 | 'id' => 123, 26 | 'token' => 'feature-test', 27 | ]], 28 | ]); 29 | 30 | $serviceMock->method('message') 31 | ->willReturn($result); 32 | 33 | $clientMock->send = $serviceMock; 34 | 35 | return new PostalTransport($clientMock); 36 | }); 37 | 38 | $notifiable = new ExampleNotifiable(); 39 | 40 | Notification::route(PostalNotificationChannel::class, 'feature-test@example.com') 41 | ->notify(new ExampleNotification($notifiable)); 42 | 43 | $emailModel = config('postal.models.email'); 44 | $email = $emailModel::where('to_email', 'feature-test@example.com')->first(); 45 | $this->assertNotNull($email); 46 | 47 | $this->assertSame(ExampleNotifiable::class, $email->emailable_type); 48 | $this->assertEquals($notifiable->id, $email->emailable_id); 49 | 50 | $morph = $notifiable->emails()->get(); 51 | $this->assertCount(1, $morph); 52 | $this->assertSame('feature-test@example.com', $morph[0]->to_email); 53 | } 54 | 55 | public function testSendingNotificationOnDemandWithAlias() 56 | { 57 | Mail::extend('postal', function (array $config = []) { 58 | $clientMock = $this->createMock(Client::class); 59 | $serviceMock = $this->createMock(SendService::class); 60 | 61 | $result = new Result([ 62 | 'message_id' => 'feature-test', 63 | 'messages' => ['feature-test@example.com' => [ 64 | 'id' => 123, 65 | 'token' => 'feature-test', 66 | ]], 67 | ]); 68 | 69 | $serviceMock->method('message') 70 | ->willReturn($result); 71 | 72 | $clientMock->send = $serviceMock; 73 | 74 | return new PostalTransport($clientMock); 75 | }); 76 | 77 | $notifiable = new ExampleNotifiable(); 78 | 79 | Notification::route('postal', 'feature-test@example.com') 80 | ->notify(new ExampleNotification($notifiable)); 81 | 82 | $emailModel = config('postal.models.email'); 83 | $email = $emailModel::where('to_email', 'feature-test@example.com')->first(); 84 | $this->assertNotNull($email); 85 | 86 | $this->assertSame(ExampleNotifiable::class, $email->emailable_type); 87 | $this->assertEquals($notifiable->id, $email->emailable_id); 88 | 89 | $morph = $notifiable->emails()->get(); 90 | $this->assertCount(1, $morph); 91 | $this->assertSame('feature-test@example.com', $morph[0]->to_email); 92 | } 93 | 94 | public function testSendingNotificationWithNotifiableTrait() 95 | { 96 | Mail::extend('postal', function (array $config = []) { 97 | $clientMock = $this->createMock(Client::class); 98 | $serviceMock = $this->createMock(SendService::class); 99 | 100 | $result = new Result([ 101 | 'message_id' => 'feature-test', 102 | 'messages' => ['feature-test@example.com' => [ 103 | 'id' => 123, 104 | 'token' => 'feature-test', 105 | ]], 106 | ]); 107 | 108 | $serviceMock->method('message') 109 | ->willReturn($result); 110 | 111 | $clientMock->send = $serviceMock; 112 | 113 | return new PostalTransport($clientMock); 114 | }); 115 | 116 | $notifiable = new ExampleNotifiableWithRouteMethod(); 117 | $notifiable->notify(new ExampleNotification($notifiable)); 118 | 119 | $emailModel = config('postal.models.email'); 120 | $email = $emailModel::where('to_email', 'feature-test@example.com')->first(); 121 | $this->assertNotNull($email); 122 | 123 | $this->assertSame(ExampleNotifiableWithRouteMethod::class, $email->emailable_type); 124 | $this->assertEquals($notifiable->id, $email->emailable_id); 125 | 126 | $morph = $notifiable->emails()->get(); 127 | $this->assertCount(1, $morph); 128 | $this->assertSame('feature-test@example.com', $morph[0]->to_email); 129 | } 130 | 131 | public function testSendingNotificationWithNotifiableTraitWithoutRouteMethod() 132 | { 133 | $transportMock = \Mockery::mock(PostalTransport::class); 134 | $transportMock->shouldReceive('send') 135 | ->once() 136 | ->withArgs(function ($email, DelayedEnvelope $envelope) { 137 | $recipients = $envelope->getRecipients(); 138 | $this->assertCount(1, $recipients); 139 | 140 | $this->assertSame('feature-test@example.com', $recipients[0]->getAddress()); 141 | return true; 142 | }); 143 | 144 | Mail::extend('postal', function (array $config = []) use ($transportMock) { 145 | return $transportMock; 146 | }); 147 | 148 | $notifiable = new ExampleNotifiable(); 149 | $notifiable->notify(new ExampleNotification($notifiable)); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /tests/PostalNotificationChannelTest.php: -------------------------------------------------------------------------------- 1 | createMock(\Illuminate\Mail\MailManager::class); 13 | $markdownMock = $this->createMock(\Illuminate\Mail\Markdown::class); 14 | $notifyMock = $this->createMock(ExampleNotification::class); 15 | 16 | $notifyMock 17 | ->expects($this->once()) 18 | ->method('toMail') 19 | ->with('test') 20 | ->willReturn((new MailMessage()) 21 | ->subject('Example Notification')); 22 | 23 | $nc = new PostalNotificationChannel($mailerMock, $markdownMock); 24 | 25 | $nc->send('test', $notifyMock); 26 | } 27 | 28 | public function testGetRecipients() 29 | { 30 | $mailerMock = $this->createMock(\Illuminate\Mail\MailManager::class); 31 | $markdownMock = $this->createMock(\Illuminate\Mail\Markdown::class); 32 | $notificationMock = $this->createMock(ExampleNotification::class); 33 | $notifiableMock = $this->createMock(\Illuminate\Notifications\AnonymousNotifiable::class); 34 | 35 | $obj = new \stdClass(); 36 | $obj->email = 'getRecipientsTest@example.com'; 37 | $mockResponses = [ 38 | 'getRecipientsTest@example.com', 39 | ['getRecipientsTest@example.com'], 40 | [$obj], 41 | ]; 42 | 43 | $notifiableMock->expects($this->exactly(count($mockResponses))) 44 | ->method('routeNotificationFor') 45 | ->with( 46 | PostalNotificationChannel::class, 47 | $notificationMock 48 | ) 49 | ->will($this->onConsecutiveCalls(...$mockResponses)); 50 | 51 | $nc = new PostalNotificationChannel($mailerMock, $markdownMock); 52 | 53 | $callProtectedFunction = function () use ($nc, $notifiableMock, $notificationMock) { 54 | $class = new \ReflectionClass($nc); 55 | $method = $class->getMethod('getRecipients'); 56 | $method->setAccessible(true); 57 | return $method->invokeArgs($nc, [$notifiableMock, $notificationMock, null]); 58 | }; 59 | 60 | $this->assertCount(3, $mockResponses); 61 | foreach ($mockResponses as $response) { 62 | $this->assertSame(['getRecipientsTest@example.com'], $callProtectedFunction()); 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /tests/PostalServiceProviderTest.php: -------------------------------------------------------------------------------- 1 | ['domain' => 'example.com', 'key' => 'hunter2'], 15 | ]); 16 | 17 | $driver = app('mail.manager') 18 | ->createSymfonyTransport(config('mail.mailers.postal')); 19 | 20 | $this->assertInstanceOf(PostalTransport::class, $driver); 21 | 22 | $this->assertSame('postal', (string) $driver); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/PostalTransportTest.php: -------------------------------------------------------------------------------- 1 | expectException(TransportException::class); 21 | 22 | Mail::extend('postal', function (array $config = []) { 23 | $clientMock = $this->createMock(Client::class); 24 | $serviceMock = $this->createMock(SendService::class); 25 | 26 | $serviceMock->method('message') 27 | ->will($this->throwException(new ApiException())); 28 | 29 | $clientMock->send = $serviceMock; 30 | 31 | return new PostalTransport($clientMock); 32 | }); 33 | 34 | Mail::to('testing@example.com')->send(new ExampleMailable()); 35 | } 36 | 37 | public function testSendSuccess(): void 38 | { 39 | Mail::extend('postal', function (array $config = []) { 40 | $clientMock = $this->createMock(Client::class); 41 | $serviceMock = $this->createMock(SendService::class); 42 | 43 | $result = new Result([ 44 | 'message_id' => 'test', 45 | 'messages' => ['testsendsuccess@example.com' => [ 46 | 'id' => 123, 47 | 'token' => 'first', 48 | ]], 49 | ]); 50 | 51 | $serviceMock->method('message') 52 | ->willReturn($result); 53 | 54 | $clientMock->send = $serviceMock; 55 | 56 | return new PostalTransport($clientMock); 57 | }); 58 | 59 | Mail::to('testsendsuccess@example.com')->send(new ExampleMailable( 60 | fromEmail: 'john@doe.com' 61 | )); 62 | 63 | $emailModel = config('postal.models.email'); 64 | $this->assertNotNull( 65 | $emailModel::where('to_email', 'testsendsuccess@example.com') 66 | ->where('from_email', 'john@doe.com') 67 | ->first() 68 | ); 69 | } 70 | 71 | public function testPostalCaseSensitivity(): void 72 | { 73 | Mail::extend('postal', function (array $config = []) { 74 | $clientMock = $this->createMock(Client::class); 75 | $serviceMock = $this->createMock(SendService::class); 76 | 77 | $result = new Result([ 78 | 'message_id' => 'caseSensitivityTest', 79 | 'messages' => ['caseSensitivityTest@example.com' => [ 80 | 'id' => 123, 81 | 'token' => 'caseSensitivityTest', 82 | ]], 83 | ]); 84 | 85 | $serviceMock->method('message') 86 | ->willReturn($result); 87 | 88 | $clientMock->send = $serviceMock; 89 | 90 | return new PostalTransport($clientMock); 91 | }); 92 | 93 | Mail::to('caseSensitivityTest@example.com')->send(new ExampleMailable( 94 | fromEmail: 'FancyName@example.com' 95 | )); 96 | 97 | $emailModel = config('postal.models.email'); 98 | $email = $emailModel::where('to_email', 'caseSensitivityTest@example.com')->first(); 99 | $this->assertNotNull($email); 100 | $this->assertSame('caseSensitivityTest@example.com', $email->to_email); 101 | $this->assertSame('FancyName@example.com', $email->from_email); 102 | } 103 | 104 | public function testAttachments(): void 105 | { 106 | Mail::extend('postal', function (array $config = []) { 107 | $clientMock = $this->createMock(Client::class); 108 | $serviceMock = $this->mock(SendService::class); 109 | 110 | $result = new Result([ 111 | 'message_id' => 'first', 112 | 'messages' => ['testsendsuccess@example.com' => [ 113 | 'id' => 123, 114 | 'token' => 'first', 115 | ]], 116 | ]); 117 | 118 | $serviceMock->shouldReceive('message') 119 | ->withArgs(function (Message $message) { 120 | $this->assertCount(1, $message->attachments); 121 | 122 | return $message->attachments[0]['name'] == 'test-attachment'; 123 | }) 124 | ->andReturn($result); 125 | 126 | $clientMock->send = $serviceMock; 127 | 128 | return new PostalTransport($clientMock); 129 | }); 130 | 131 | Mail::to('testsendsuccess@example.com')->send(new ExampleMailableWithAttachments()); 132 | 133 | $emailModel = config('postal.models.email'); 134 | $this->assertNotNull( 135 | $emailModel::where('to_email', 'testsendsuccess@example.com') 136 | ->first() 137 | ); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | trim(file_get_contents($fixtures.'key.pub')), 20 | 'private' => file_get_contents($fixtures.'key'), 21 | ]; 22 | } 23 | 24 | protected function resolveApplicationConfiguration($app) 25 | { 26 | parent::resolveApplicationConfiguration($app); 27 | 28 | $keyPair = $this->getKeyPair(); 29 | 30 | $app['config']->set('postal.webhook.public_key', $keyPair['public']); 31 | $app['config']->set('postal.webhook.verify', true); 32 | $app['config']->set('postal.webhook.route', '/postal/webhook'); 33 | $app['config']->set('mail.mailers.postal', ['transport' => 'postal']); 34 | } 35 | 36 | public function getEnvironmentSetUp($app) 37 | { 38 | $app['config']->set('database.default', 'testing'); 39 | } 40 | 41 | public function setUp() : void 42 | { 43 | parent::setUp(); 44 | 45 | $this->artisan('migrate')->run(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 |