From 19e381ad016492b9e317eed0315f14c7465a01a6 Mon Sep 17 00:00:00 2001 From: rubenmuehlhans <85454114+rubenmuehlhans@users.noreply.github.com> Date: Thu, 17 Mar 2022 18:34:40 +0100 Subject: [PATCH] Add Postal Mail Service (#139) * add postal emailservice * Postal * Postal * Postal Webhooks * Postal Webhooks * Postal Webhooks * Postal Webhooks * Delete .DS_Store * Create 2021_10_22_202820_add_postal_email_service_type.php * Update 2021_10_22_202820_add_postal_email_service_type.php * Update PostalAdapter.php Co-authored-by: Ruben Muehlhans --- composer.json | 3 +- config/config.php | 0 ...2_202820_add_postal_email_service_type.php | 26 +++ .../email_services/options/postal.blade.php | 2 + src/Adapters/PostalAdapter.php | 46 +++++ src/Events/Webhooks/PostalWebhookReceived.php | 14 ++ src/Factories/MailAdapterFactory.php | 2 + .../Api/Webhooks/PostalWebhooksController.php | 27 +++ .../Webhooks/HandlePostalWebhook.php | 160 ++++++++++++++++++ src/Models/EmailServiceType.php | 2 + src/Providers/EventServiceProvider.php | 5 + src/Routes/ApiRoutes.php | 1 + src/SendportalBaseServiceProvider.php | 0 src/Services/QuotaService.php | 1 + 14 files changed, 288 insertions(+), 1 deletion(-) mode change 100755 => 100644 config/config.php create mode 100644 database/migrations/2021_10_22_202820_add_postal_email_service_type.php create mode 100644 resources/views/email_services/options/postal.blade.php create mode 100644 src/Adapters/PostalAdapter.php create mode 100644 src/Events/Webhooks/PostalWebhookReceived.php create mode 100644 src/Http/Controllers/Api/Webhooks/PostalWebhooksController.php create mode 100644 src/Listeners/Webhooks/HandlePostalWebhook.php mode change 100755 => 100644 src/SendportalBaseServiceProvider.php diff --git a/composer.json b/composer.json index c79bfb54..b121d2df 100755 --- a/composer.json +++ b/composer.json @@ -17,7 +17,8 @@ "nyholm/psr7": "^1.3", "rap2hpoutre/fast-excel": "^2.3", "sendgrid/sendgrid": "^7.9", - "wildbit/postmark-php": "^4.0" + "wildbit/postmark-php": "^4.0", + "postal/postal": "^1.0" }, "require-dev": { "orchestra/testbench": "^6.0", diff --git a/config/config.php b/config/config.php old mode 100755 new mode 100644 diff --git a/database/migrations/2021_10_22_202820_add_postal_email_service_type.php b/database/migrations/2021_10_22_202820_add_postal_email_service_type.php new file mode 100644 index 00000000..6afe5dd5 --- /dev/null +++ b/database/migrations/2021_10_22_202820_add_postal_email_service_type.php @@ -0,0 +1,26 @@ +insert( + [ + 'id' => EmailServiceType::POSTAL, + 'name' => 'Postal', + 'created_at' => now(), + 'updated_at' => now(), + ] + ); + } +} diff --git a/resources/views/email_services/options/postal.blade.php b/resources/views/email_services/options/postal.blade.php new file mode 100644 index 00000000..f59c9d9d --- /dev/null +++ b/resources/views/email_services/options/postal.blade.php @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/src/Adapters/PostalAdapter.php b/src/Adapters/PostalAdapter.php new file mode 100644 index 00000000..33389486 --- /dev/null +++ b/src/Adapters/PostalAdapter.php @@ -0,0 +1,46 @@ +config, 'postal_host'), Arr::get($this->config, 'key')); + + $message = new SendMessage($client); + $message->to($toEmail); + $message->from($fromName.' <'.$fromEmail.'>'); + $message->subject($subject); + $message->htmlBody($content); + $response = $message->send(); + + return $this->resolveMessageId($response); + } + + + + protected function resolveMessageId($response): string + { + foreach ($response->recipients() as $email => $message) { + return (string) $message->id(); + } + + throw new DomainException('Unable to resolve message ID'); + } +} diff --git a/src/Events/Webhooks/PostalWebhookReceived.php b/src/Events/Webhooks/PostalWebhookReceived.php new file mode 100644 index 00000000..128f15db --- /dev/null +++ b/src/Events/Webhooks/PostalWebhookReceived.php @@ -0,0 +1,14 @@ +payload = $payload; + } +} diff --git a/src/Factories/MailAdapterFactory.php b/src/Factories/MailAdapterFactory.php index 792f4b40..fa6b7c4d 100644 --- a/src/Factories/MailAdapterFactory.php +++ b/src/Factories/MailAdapterFactory.php @@ -11,6 +11,7 @@ use Sendportal\Base\Adapters\SendgridMailAdapter; use Sendportal\Base\Adapters\SesMailAdapter; use Sendportal\Base\Adapters\SmtpAdapter; +use Sendportal\Base\Adapters\PostalAdapter; use Sendportal\Base\Interfaces\MailAdapterInterface; use Sendportal\Base\Models\EmailService; use Sendportal\Base\Models\EmailServiceType; @@ -25,6 +26,7 @@ class MailAdapterFactory EmailServiceType::POSTMARK => PostmarkMailAdapter::class, EmailServiceType::MAILJET => MailjetAdapter::class, EmailServiceType::SMTP => SmtpAdapter::class, + EmailServiceType::POSTAL => PostalAdapter::class, ]; /** diff --git a/src/Http/Controllers/Api/Webhooks/PostalWebhooksController.php b/src/Http/Controllers/Api/Webhooks/PostalWebhooksController.php new file mode 100644 index 00000000..80fa31ad --- /dev/null +++ b/src/Http/Controllers/Api/Webhooks/PostalWebhooksController.php @@ -0,0 +1,27 @@ +getContent(), true); + + Log::info('Postal webhook received'); + + event(new PostalWebhookReceived($payload)); + + + return response('OK'); + } +} diff --git a/src/Listeners/Webhooks/HandlePostalWebhook.php b/src/Listeners/Webhooks/HandlePostalWebhook.php new file mode 100644 index 00000000..54e65efb --- /dev/null +++ b/src/Listeners/Webhooks/HandlePostalWebhook.php @@ -0,0 +1,160 @@ +emailWebhookService = $emailWebhookService; + } + + public function handle(PostalWebhookReceived $event): void + { + + $messageId = $this->extractMessageId($event->payload); + $eventName = $this->extractEventName($event->payload); + + Log::info('Processing Postal webhook.', ['type' => $eventName, 'message_id' => $messageId]); + + switch ($eventName) { + case 'MessageSent': + $this->handleDelivered($messageId, $event->payload); + break; + + case 'MessageLoaded': + $this->handleOpen($messageId, $event->payload); + break; + + case 'MessageLinkClicked': + $this->handleClick($messageId, $event->payload); + break; + + case 'MessageBounced': + $messageId = $this->extractMessageIdBounced($event->payload); + $this->handleBounce($messageId, $event->payload); + break; + + case 'MessageDeliveryFailed': + $this->handleFailed($messageId, $event->payload); + break; + + case 'MessageHeld': + $this->handleHeld($messageId, $event->payload); + break; + + default: + throw new RuntimeException("Unknown Postal webhook event type '{$eventName}'."); + } + } + + private function handleDelivered(string $messageId, array $content): void + { + $timestamp = $this->extractTimestamp($content); + + $this->emailWebhookService->handleDelivery($messageId, $timestamp); + } + + private function handleOpen(string $messageId, array $content): void + { + $ipAddress = Arr::get($content, 'ip'); + $timestamp = $this->extractTimestamp($content); + + $this->emailWebhookService->handleOpen($messageId, $timestamp, $ipAddress); + } + + private function handleClick(string $messageId, array $content): void + { + $url = Arr::get($content, 'payload.url'); + $timestamp = $this->extractTimestamp($content); + + $this->emailWebhookService->handleClick($messageId, $timestamp, $url); + } + + private function handleBounce(string $messageId, array $content): void + { + $timestamp = $this->extractTimestampBounced($content); + $description = Arr::get($content, 'payload.bounce.subject'); + + $this->emailWebhookService->handleFailure($messageId, 'Permanent', $description, $timestamp); + + $this->emailWebhookService->handlePermanentBounce($messageId, $timestamp); + } + + private function handleFailed(string $messageId, array $content): void + { + $severity = Arr::get($content, 'payload.status'); + $description = Arr::get($content, 'payload.output'); + $timestamp = $this->extractTimestampFailed($content); + + $this->emailWebhookService->handleFailure($messageId, $severity, $description, $timestamp); + + if ($severity === 'HardFail') { + $this->emailWebhookService->handlePermanentBounce($messageId, $timestamp); + } + } + + private function handleHeld(string $messageId, array $content): void + { + $severity = Arr::get($content, 'payload.status'); + $description = Arr::get($content, 'payload.details'); + $timestamp = $this->extractTimestampFailed($content); + + $this->emailWebhookService->handleFailure($messageId, $severity, $description, $timestamp); + + if ($severity === 'Held') { + $this->emailWebhookService->handlePermanentBounce($messageId, $timestamp); + } + } + + private function extractEventName(array $payload): string + { + return Arr::get($payload, 'event'); + } + + private function extractMessageIdBounced(array $payload): string + { + $messageId = Arr::get($payload, 'payload.original_message.id'); + + return trim((string) $messageId); + } + + private function extractMessageId(array $payload): string + { + $messageId = Arr::get($payload, 'payload.message.id'); + + return trim((string) $messageId); + } + + private function extractTimestampBounced($payload): Carbon + { + return Carbon::createFromTimestamp(Arr::get($payload, 'payload.bounce.timestamp')); + } + + private function extractTimestampFailed($payload): Carbon + { + return Carbon::createFromTimestamp(Arr::get($payload, 'payload.timestamp')); + } + + private function extractTimestamp($payload): Carbon + { + return Carbon::createFromTimestamp(Arr::get($payload, 'timestamp')); + } +} diff --git a/src/Models/EmailServiceType.php b/src/Models/EmailServiceType.php index 8660505a..71569dc5 100644 --- a/src/Models/EmailServiceType.php +++ b/src/Models/EmailServiceType.php @@ -14,6 +14,7 @@ class EmailServiceType extends BaseModel public const POSTMARK = 4; public const MAILJET = 5; public const SMTP = 6; + public const POSTAL = 7; /** @var array */ protected static $types = [ @@ -23,6 +24,7 @@ class EmailServiceType extends BaseModel self::POSTMARK => 'Postmark', self::MAILJET => 'Mailjet', self::SMTP => 'SMTP', + self::POSTAL => 'Postal', ]; /** diff --git a/src/Providers/EventServiceProvider.php b/src/Providers/EventServiceProvider.php index ef2caf28..1b6998fb 100644 --- a/src/Providers/EventServiceProvider.php +++ b/src/Providers/EventServiceProvider.php @@ -10,12 +10,14 @@ use Sendportal\Base\Events\Webhooks\PostmarkWebhookReceived; use Sendportal\Base\Events\Webhooks\SendgridWebhookReceived; use Sendportal\Base\Events\Webhooks\SesWebhookReceived; +use Sendportal\Base\Events\Webhooks\PostalWebhookReceived; use Sendportal\Base\Listeners\MessageDispatchHandler; use Sendportal\Base\Listeners\Webhooks\HandleMailgunWebhook; use Sendportal\Base\Listeners\Webhooks\HandleMailjetWebhook; use Sendportal\Base\Listeners\Webhooks\HandlePostmarkWebhook; use Sendportal\Base\Listeners\Webhooks\HandleSendgridWebhook; use Sendportal\Base\Listeners\Webhooks\HandleSesWebhook; +use Sendportal\Base\Listeners\Webhooks\HandlePostalWebhook; class EventServiceProvider extends ServiceProvider { @@ -43,6 +45,9 @@ class EventServiceProvider extends ServiceProvider MailjetWebhookReceived::class => [ HandleMailjetWebhook::class ], + PostalWebhookReceived::class => [ + HandlePostalWebhook::class + ], SubscriberAddedEvent::class => [ // ... ], diff --git a/src/Routes/ApiRoutes.php b/src/Routes/ApiRoutes.php index 5c05c748..8d1df876 100644 --- a/src/Routes/ApiRoutes.php +++ b/src/Routes/ApiRoutes.php @@ -45,6 +45,7 @@ public function sendportalPublicApiRoutes(): callable $webhookRouter->post('postmark', 'PostmarkWebhooksController@handle')->name('postmark'); $webhookRouter->post('sendgrid', 'SendgridWebhooksController@handle')->name('sendgrid'); $webhookRouter->post('mailjet', 'MailjetWebhooksController@handle')->name('mailjet'); + $webhookRouter->post('postal', 'PostalWebhooksController@handle')->name('postal'); }); $this->get('v1/ping', '\Sendportal\Base\Http\Controllers\Api\PingController@index'); diff --git a/src/SendportalBaseServiceProvider.php b/src/SendportalBaseServiceProvider.php old mode 100755 new mode 100644 diff --git a/src/Services/QuotaService.php b/src/Services/QuotaService.php index a5fe6361..33967c28 100644 --- a/src/Services/QuotaService.php +++ b/src/Services/QuotaService.php @@ -23,6 +23,7 @@ public function exceedsQuota(EmailService $emailService, int $messageCount): boo case EmailServiceType::POSTMARK: case EmailServiceType::MAILJET: case EmailServiceType::SMTP: + case EmailServiceType::POSTAL: return false; }