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/routes/api.php b/routes/api.php index 40c2696d..c7632e3a 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->get('elastic', 'ElasticWebhooksController@handle')->name('elastic'); }); Route::get('ping', static function () { diff --git a/src/Adapters/ElasticMailAdapter.php b/src/Adapters/ElasticMailAdapter.php new file mode 100644 index 00000000..f577e58f --- /dev/null +++ b/src/Adapters/ElasticMailAdapter.php @@ -0,0 +1,95 @@ +resolveClient()->Email->Send( + $subject, + $fromEmail, + //TODO Need to get fromName from the campaign + null, + $fromEmail, + null, + $fromEmail, + null, + null, + null, + [$toEmail], + [], + [], + [], + [], + [], + null, + null, + null, + $content, + null, + 'utf-8', + null, + null, + null, + null, + [], + [], + null, + [], + null, + null, + null, + [] + //TODO ElasticEmail API rejects request when tracking options are set. Maybe because my account is trial. +// $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/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/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/Http/Controllers/Api/Webhooks/ElasticWebhooksController.php b/src/Http/Controllers/Api/Webhooks/ElasticWebhooksController.php new file mode 100644 index 00000000..f568ee01 --- /dev/null +++ b/src/Http/Controllers/Api/Webhooks/ElasticWebhooksController.php @@ -0,0 +1,29 @@ +only('date', 'status', 'category', 'messageid', 'target'); + + Log::info('ElasticEmail webhook received'); + + if (count($payload)) { + event(new ElasticWebhookReceived($payload)); + + return response('OK'); + } + + return response('OK (not processed'); + } +} 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/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/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 => [ // ... ], 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; } diff --git a/tests/Feature/Webhooks/ElasticWebhooksTest.php b/tests/Feature/Webhooks/ElasticWebhooksTest.php new file mode 100644 index 00000000..b18dfb10 --- /dev/null +++ b/tests/Feature/Webhooks/ElasticWebhooksTest.php @@ -0,0 +1,146 @@ +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, $category = 'NotDelivered') + { + return [ + 'date' => now()->toIso8601String(), + 'messageid' => $message->message_id, + 'target' => $this->faker->url, + 'status' => $status, + 'category' => $category, + ]; + } + + /** + * @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); + } +}