├── .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 | [](https://packagist.org/packages/synergitech/laravel-postal)
3 | 
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 |