From cb8f5c551f6f6505918332bd25055b9a3f9b5b57 Mon Sep 17 00:00:00 2001 From: Maizer Gomes Date: Wed, 10 Jun 2020 22:45:24 +0200 Subject: [PATCH 1/8] Add ElastiEmail Service --- composer.json | 1 + ..._182641_add_elastic_email_service_type.php | 22 +++++ .../options/elasticemail.blade.php | 1 + src/Adapters/ElasticMailAdapter.php | 94 +++++++++++++++++++ src/Factories/MailAdapterFactory.php | 8 +- src/Models/EmailServiceType.php | 10 +- src/Services/QuotaService.php | 1 + 7 files changed, 130 insertions(+), 7 deletions(-) create mode 100644 database/migrations/2020_06_10_182641_add_elastic_email_service_type.php create mode 100644 resources/views/email_services/options/elasticemail.blade.php create mode 100644 src/Adapters/ElasticMailAdapter.php diff --git a/composer.json b/composer.json index a741216b..768de9f8 100755 --- a/composer.json +++ b/composer.json @@ -9,6 +9,7 @@ "php": "^7.2.5", "ext-json": "*", "aws/aws-sdk-php-laravel": "^3.4", + "elastic-email/web-api-client": "^1.0", "illuminate/support": "^7.0", "kriswallsmith/buzz": "^1.1", "laravel/ui": "^2.0", diff --git a/database/migrations/2020_06_10_182641_add_elastic_email_service_type.php b/database/migrations/2020_06_10_182641_add_elastic_email_service_type.php new file mode 100644 index 00000000..ce66c11c --- /dev/null +++ b/database/migrations/2020_06_10_182641_add_elastic_email_service_type.php @@ -0,0 +1,22 @@ + EmailServiceType::ELASTIC, + 'name' => 'ElasticEmail', + ]); + } +} diff --git a/resources/views/email_services/options/elasticemail.blade.php b/resources/views/email_services/options/elasticemail.blade.php new file mode 100644 index 00000000..57d204a7 --- /dev/null +++ b/resources/views/email_services/options/elasticemail.blade.php @@ -0,0 +1 @@ +{!! Form::textField('settings[key]', __('API Key'), \Arr::get($settings ?? [], 'key'), ['autocomplete' => 'off']) !!} \ No newline at end of file diff --git a/src/Adapters/ElasticMailAdapter.php b/src/Adapters/ElasticMailAdapter.php new file mode 100644 index 00000000..f937b45d --- /dev/null +++ b/src/Adapters/ElasticMailAdapter.php @@ -0,0 +1,94 @@ +resolveClient()->Email->Send( + $subject, + $fromEmail, + null, + $fromEmail, + null, + $fromEmail, + null, + null, + null, + [$toEmail], + [], + [], + [], + [], + [], + null, + null, + null, + $content, + null, + 'utf-8', + null, + null, + null, + null, + [], + [], + null, + [], + null, + null, + null, + [] + //TODO Figure out why keep getting Parameter trackOpens is in an incorrect format exception. +// $trackingOptions->isOpenTracking(), +// $trackingOptions->isClickTracking() + ); + + return $this->resolveMessageId($result); + } + + protected function resolveClient(): ElasticClient + { + if ($this->client) { + return $this->client; + } + + $configuration = new ApiConfiguration([ + 'apiUrl' => $this->url, + 'apiKey' => Arr::get($this->config, 'key'), + ]); + + $this->client = new ElasticClient($configuration); + + return $this->client; + } + + protected function resolveMessageId($result): string + { + return $result->messageid; + } +} diff --git a/src/Factories/MailAdapterFactory.php b/src/Factories/MailAdapterFactory.php index af96d2bf..fdeeb420 100644 --- a/src/Factories/MailAdapterFactory.php +++ b/src/Factories/MailAdapterFactory.php @@ -4,6 +4,7 @@ namespace Sendportal\Base\Factories; +use Sendportal\Base\Adapters\ElasticMailAdapter; use Sendportal\Base\Adapters\MailgunMailAdapter; use Sendportal\Base\Adapters\PostmarkMailAdapter; use Sendportal\Base\Adapters\SendgridMailAdapter; @@ -17,10 +18,11 @@ class MailAdapterFactory { /** @var array */ public static $adapterMap = [ - EmailServiceType::SES => SesMailAdapter::class, + EmailServiceType::SES => SesMailAdapter::class, EmailServiceType::SENDGRID => SendgridMailAdapter::class, - EmailServiceType::MAILGUN => MailgunMailAdapter::class, - EmailServiceType::POSTMARK => PostmarkMailAdapter::class + EmailServiceType::MAILGUN => MailgunMailAdapter::class, + EmailServiceType::POSTMARK => PostmarkMailAdapter::class, + EmailServiceType::ELASTIC => ElasticMailAdapter::class, ]; /** diff --git a/src/Models/EmailServiceType.php b/src/Models/EmailServiceType.php index 5205cc6c..7fb241e0 100644 --- a/src/Models/EmailServiceType.php +++ b/src/Models/EmailServiceType.php @@ -6,17 +6,19 @@ class EmailServiceType extends BaseModel { - public const SES = 1; + public const SES = 1; public const SENDGRID = 2; - public const MAILGUN = 3; + public const MAILGUN = 3; public const POSTMARK = 4; + public const ELASTIC = 5; /** @var array */ protected static $types = [ - self::SES => 'SES', + self::SES => 'SES', self::SENDGRID => 'Sendgrid', - self::MAILGUN => 'Mailgun', + self::MAILGUN => 'Mailgun', self::POSTMARK => 'Postmark', + self::ELASTIC => 'ElasticEmail', ]; /** diff --git a/src/Services/QuotaService.php b/src/Services/QuotaService.php index 4ad0e509..a14ab37e 100644 --- a/src/Services/QuotaService.php +++ b/src/Services/QuotaService.php @@ -22,6 +22,7 @@ public function exceedsQuota(Campaign $campaign): bool case EmailServiceType::SENDGRID: case EmailServiceType::MAILGUN: case EmailServiceType::POSTMARK: + case EmailServiceType::ELASTIC: return false; } From 7ac9d9af8894c2181040b76ef159c6bfe51369f2 Mon Sep 17 00:00:00 2001 From: Maizer Gomes Date: Thu, 11 Jun 2020 22:58:35 +0200 Subject: [PATCH 2/8] Add todo comments --- src/Adapters/ElasticMailAdapter.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Adapters/ElasticMailAdapter.php b/src/Adapters/ElasticMailAdapter.php index f937b45d..f577e58f 100644 --- a/src/Adapters/ElasticMailAdapter.php +++ b/src/Adapters/ElasticMailAdapter.php @@ -32,6 +32,7 @@ public function send(string $fromEmail, string $toEmail, string $subject, Messag $result = $this->resolveClient()->Email->Send( $subject, $fromEmail, + //TODO Need to get fromName from the campaign null, $fromEmail, null, @@ -63,7 +64,7 @@ public function send(string $fromEmail, string $toEmail, string $subject, Messag null, null, [] - //TODO Figure out why keep getting Parameter trackOpens is in an incorrect format exception. + //TODO ElasticEmail API rejects request when tracking options are set. Maybe because my account is trial. // $trackingOptions->isOpenTracking(), // $trackingOptions->isClickTracking() ); From 8831d957c946815c3fc9c9b2e184cba590085a45 Mon Sep 17 00:00:00 2001 From: Maizer Gomes Date: Thu, 11 Jun 2020 23:00:12 +0200 Subject: [PATCH 3/8] Wire up ElasticEmail adapter webhook event --- .../Webhooks/ElasticWebhookReceived.php | 21 +++ .../Webhooks/HandleElasticWebhook.php | 129 ++++++++++++++++++ src/Providers/EventServiceProvider.php | 5 + 3 files changed, 155 insertions(+) create mode 100644 src/Events/Webhooks/ElasticWebhookReceived.php create mode 100644 src/Listeners/Webhooks/HandleElasticWebhook.php diff --git a/src/Events/Webhooks/ElasticWebhookReceived.php b/src/Events/Webhooks/ElasticWebhookReceived.php new file mode 100644 index 00000000..1b0947ab --- /dev/null +++ b/src/Events/Webhooks/ElasticWebhookReceived.php @@ -0,0 +1,21 @@ +payload = $payload; + } +} diff --git a/src/Listeners/Webhooks/HandleElasticWebhook.php b/src/Listeners/Webhooks/HandleElasticWebhook.php new file mode 100644 index 00000000..c7d2067a --- /dev/null +++ b/src/Listeners/Webhooks/HandleElasticWebhook.php @@ -0,0 +1,129 @@ +emailWebhookService = $emailWebhookService; + } + + public function handle(ElasticWebhookReceived $event): void + { + // https://help.elasticemail.com/en/articles/2376855-how-to-manage-http-web-notifications-webhooks + $messageId = $this->extractMessageId($event->payload); + $eventName = $this->extractEventName($event->payload); + + Log::info('Processing ElasticEmail webhook.', ['type' => $eventName, 'message_id' => $messageId]); + + switch ($eventName) { + case 'Sent': + $this->handleSent($messageId, $event->payload); + break; + + case 'Opened': + $this->handleOpen($messageId, $event->payload); + break; + + case 'Clicked': + $this->handleClick($messageId, $event->payload); + break; + + case 'AbuseReport': + $this->handleAbuseReport($messageId, $event->payload); + break; + + case 'Error': + $this->handleError($messageId, $event->payload); + break; + + case 'Unsubscribed': + $this->handleUnsubscribe($messageId, $event->payload); + break; + + default: + throw new RuntimeException("Unknown ElasticEmail webhook event type '{$eventName}'."); + } + } + + private function extractMessageId(array $payload): string + { + return Arr::get($payload, 'messageid'); + } + + private function extractEventName(array $payload): string + { + return Arr::get($payload, 'status'); + } + + private function handleSent(string $messageId, array $content): void + { + $timestamp = $this->extractTimestamp($content); + + $this->emailWebhookService->handleDelivery($messageId, $timestamp); + } + + private function extractTimestamp($payload): Carbon + { + return Carbon::createFromDate(Arr::get($payload, 'date')); + } + + private function handleOpen(string $messageId, array $content): void + { + $timestamp = $this->extractTimestamp($content); + + $this->emailWebhookService->handleOpen($messageId, $timestamp, null); + } + + private function handleClick(string $messageId, array $content): void + { + $url = Arr::get($content, 'target'); + $timestamp = $this->extractTimestamp($content); + + $this->emailWebhookService->handleClick($messageId, $timestamp, $url); + } + + private function handleAbuseReport(string $messageId, array $content): void + { + $timestamp = $this->extractTimestamp($content); + + $this->emailWebhookService->handleComplaint($messageId, $timestamp); + } + + private function handleError(string $messageId, array $content): void + { + $timestamp = $this->extractTimestamp($content); + $description = Arr::get($content, 'category'); + + //TODO Create method to determine the severity of the failure: + // Ignore|Spam|BlackListed|NoMailbox|GreyListed|Throttled|Timeout|ConnectionProblem|SPFProblem|AccountProblem|DNSProblem|WhitelistingProblem|CodeError|ManualCancel|ConnectionTerminated|ContentFilter|NotDelivered|Unknown + + $this->emailWebhookService->handleFailure($messageId, 'Temporary', $description, $timestamp); + } + + private function handleUnsubscribe(string $messageId, array $content): void + { + $timestamp = $this->extractTimestamp($content); + + $this->emailWebhookService->handleComplaint($messageId, $timestamp); + } +} diff --git a/src/Providers/EventServiceProvider.php b/src/Providers/EventServiceProvider.php index 8aa817a0..c376f2a7 100644 --- a/src/Providers/EventServiceProvider.php +++ b/src/Providers/EventServiceProvider.php @@ -4,11 +4,13 @@ use Sendportal\Base\Events\MessageDispatchEvent; use Sendportal\Base\Events\SubscriberAddedEvent; +use Sendportal\Base\Events\Webhooks\ElasticWebhookReceived; use Sendportal\Base\Events\Webhooks\MailgunWebhookReceived; use Sendportal\Base\Events\Webhooks\PostmarkWebhookReceived; use Sendportal\Base\Events\Webhooks\SendgridWebhookReceived; use Sendportal\Base\Events\Webhooks\SesWebhookReceived; use Sendportal\Base\Listeners\MessageDispatchHandler; +use Sendportal\Base\Listeners\Webhooks\HandleElasticWebhook; use Sendportal\Base\Listeners\Webhooks\HandleSesWebhook; use Sendportal\Base\Listeners\Webhooks\HandleMailgunWebhook; use Sendportal\Base\Listeners\Webhooks\HandlePostmarkWebhook; @@ -43,6 +45,9 @@ class EventServiceProvider extends ServiceProvider SesWebhookReceived::class => [ HandleSesWebhook::class ], + ElasticWebhookReceived::class => [ + HandleElasticWebhook::class + ], SubscriberAddedEvent::class => [ // ... ], From b20dea5e307e7d2649404681e088bfc6618df2f3 Mon Sep 17 00:00:00 2001 From: Maizer Gomes Date: Thu, 11 Jun 2020 23:00:54 +0200 Subject: [PATCH 4/8] Set up ElasticEmail webhook controller --- routes/api.php | 1 + .../Webhooks/ElasticWebhooksController.php | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 src/Http/Controllers/Api/Webhooks/ElasticWebhooksController.php diff --git a/routes/api.php b/routes/api.php index 40c2696d..4278d510 100644 --- a/routes/api.php +++ b/routes/api.php @@ -52,6 +52,7 @@ $webhookRouter->post('mailgun', 'MailgunWebhooksController@handle')->name('mailgun'); $webhookRouter->post('postmark', 'PostmarkWebhooksController@handle')->name('postmark'); $webhookRouter->post('sendgrid', 'SendgridWebhooksController@handle')->name('sendgrid'); + $webhookRouter->post('elastic', 'ElasticWebhooksController@handle')->name('elastic'); }); Route::get('ping', static function () { diff --git a/src/Http/Controllers/Api/Webhooks/ElasticWebhooksController.php b/src/Http/Controllers/Api/Webhooks/ElasticWebhooksController.php new file mode 100644 index 00000000..4725379c --- /dev/null +++ b/src/Http/Controllers/Api/Webhooks/ElasticWebhooksController.php @@ -0,0 +1,29 @@ +only('to', 'date', 'subject', 'status', 'channel', 'account', 'category', 'messageid', 'transaction', 'target'); + + Log::info('ElasticEmail webhook received'); + + if (count($payload)) { + event(new ElasticWebhookReceived($payload)); + + return response('OK'); + } + + return response('OK (not processed'); + } +} From 5e12a26f57634a6b99007b399afc29760264007e Mon Sep 17 00:00:00 2001 From: Maizer Gomes Date: Fri, 12 Jun 2020 00:20:42 +0200 Subject: [PATCH 5/8] Change elastic route method to get --- routes/api.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes/api.php b/routes/api.php index 4278d510..c7632e3a 100644 --- a/routes/api.php +++ b/routes/api.php @@ -52,7 +52,7 @@ $webhookRouter->post('mailgun', 'MailgunWebhooksController@handle')->name('mailgun'); $webhookRouter->post('postmark', 'PostmarkWebhooksController@handle')->name('postmark'); $webhookRouter->post('sendgrid', 'SendgridWebhooksController@handle')->name('sendgrid'); - $webhookRouter->post('elastic', 'ElasticWebhooksController@handle')->name('elastic'); + $webhookRouter->get('elastic', 'ElasticWebhooksController@handle')->name('elastic'); }); Route::get('ping', static function () { From c361c96646f4dbc15efc1b51ec533e6f33026031 Mon Sep 17 00:00:00 2001 From: Maizer Gomes Date: Fri, 12 Jun 2020 00:21:38 +0200 Subject: [PATCH 6/8] Remove unnecessary parameters --- src/Http/Controllers/Api/Webhooks/ElasticWebhooksController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Http/Controllers/Api/Webhooks/ElasticWebhooksController.php b/src/Http/Controllers/Api/Webhooks/ElasticWebhooksController.php index 4725379c..f568ee01 100644 --- a/src/Http/Controllers/Api/Webhooks/ElasticWebhooksController.php +++ b/src/Http/Controllers/Api/Webhooks/ElasticWebhooksController.php @@ -14,7 +14,7 @@ class ElasticWebhooksController extends Controller public function handle(): Response { /** @var array $payload */ - $payload = request()->only('to', 'date', 'subject', 'status', 'channel', 'account', 'category', 'messageid', 'transaction', 'target'); + $payload = request()->only('date', 'status', 'category', 'messageid', 'target'); Log::info('ElasticEmail webhook received'); From dbd6f5b0d7a3b23437fecf6fe9e334facb4ca43b Mon Sep 17 00:00:00 2001 From: Maizer Gomes Date: Fri, 12 Jun 2020 00:22:51 +0200 Subject: [PATCH 7/8] Make sure all webhook test for elasticEmail are passing --- .../Feature/Webhooks/ElasticWebhooksTest.php | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 tests/Feature/Webhooks/ElasticWebhooksTest.php diff --git a/tests/Feature/Webhooks/ElasticWebhooksTest.php b/tests/Feature/Webhooks/ElasticWebhooksTest.php new file mode 100644 index 00000000..7920cc49 --- /dev/null +++ b/tests/Feature/Webhooks/ElasticWebhooksTest.php @@ -0,0 +1,145 @@ +createMessage(); + + $this->assertNull($message->delivered_at); + + $webhook = $this->resolveWebhook($message, 'Sent'); + + $this->get(route($this->route, $webhook)); + + $this->assertNotNull($message->refresh()->delivered_at); + } + + /** + * Create Message + */ + protected function createMessage(): Message + { + return factory(Message::class)->create([ + 'message_id' => Str::random(), + ]); + } + + protected function resolveWebhook(Message $message, $status) + { + return [ + 'date' => now()->toIso8601String(), + 'messageid' => $message->message_id, + 'target' => $this->faker->url, + 'status' => $status, + ]; + } + + /** + * @return void + */ + public function testOpen() + { + $message = $this->createMessage(); + + $this->assertEquals(0, $message->open_count); + $this->assertNull($message->opened_at); + + $webhook = $this->resolveWebhook($message, 'Opened'); + + $this->get(route($this->route, $webhook)); + + $this->assertEquals(1, $message->refresh()->open_count); + $this->assertNotNull($message->opened_at); + } + + /** + * @return void + */ + public function testClick() + { + $message = $this->createMessage(); + + $this->assertEquals(0, $message->click_count); + $this->assertNull($message->clicked_at); + + $webhook = $this->resolveWebhook($message, 'Clicked'); + + $this->get(route($this->route, $webhook)); + + $this->assertEquals(1, $message->refresh()->click_count); + $this->assertNotNull($message->clicked_at); + } + + /** + * @return void + */ + public function testAbuseReport() + { + $message = $this->createMessage(); + + $this->assertNull($message->unsubscribed_at); + + $webhook = $this->resolveWebhook($message, 'AbuseReport'); + + $this->get(route($this->route, $webhook)); + + $this->assertNotNull($message->refresh()->unsubscribed_at); + } + + /** + * @return void + */ + public function testError() + { + $message = $this->createMessage(); + + $webhook = $this->resolveWebhook($message, 'Error'); + + $this->get(route($this->route, $webhook)); + + $this->assertDatabaseHas( + 'message_failures', + [ + 'message_id' => $message->id, + 'severity' => 'Temporary', + ] + ); + } + + + /** + * @return void + */ + public function testUnsubscribe() + { + $message = $this->createMessage(); + + $this->assertNull($message->unsubscribed_at); + + $webhook = $this->resolveWebhook($message, 'Unsubscribed'); + + $this->get(route($this->route, $webhook)); + + $this->assertNotNull($message->refresh()->unsubscribed_at); + } +} From 8defe1d0b0657c27d442232a52b4be638cd0a0ec Mon Sep 17 00:00:00 2001 From: Maizer Gomes Date: Fri, 12 Jun 2020 00:32:02 +0200 Subject: [PATCH 8/8] Include category in the webhook resolver --- tests/Feature/Webhooks/ElasticWebhooksTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Feature/Webhooks/ElasticWebhooksTest.php b/tests/Feature/Webhooks/ElasticWebhooksTest.php index 7920cc49..b18dfb10 100644 --- a/tests/Feature/Webhooks/ElasticWebhooksTest.php +++ b/tests/Feature/Webhooks/ElasticWebhooksTest.php @@ -44,13 +44,14 @@ protected function createMessage(): Message ]); } - protected function resolveWebhook(Message $message, $status) + protected function resolveWebhook(Message $message, $status, $category = 'NotDelivered') { return [ 'date' => now()->toIso8601String(), 'messageid' => $message->message_id, 'target' => $this->faker->url, 'status' => $status, + 'category' => $category, ]; }