Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adjust SES message throttling #128

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion resources/views/email_services/options/smtp.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
<x-sendportal.text-field type="number" name="settings[port]" :label="__('SMTP Port')" :value="Arr::get($settings ?? [], 'port')" />
<x-sendportal.text-field name="settings[encryption]" :label="__('Encryption')" :value="Arr::get($settings ?? [], 'encryption')" />
<x-sendportal.text-field name="settings[username]" :label="__('Username')" :value="Arr::get($settings ?? [], 'username')" />
<x-sendportal.text-field type="password" name="settings[password]" :label="__('Password')" :value="Arr::get($settings ?? [], 'password')" />
<x-sendportal.text-field type="password" name="settings[password]" :label="__('Password')" :value="Arr::get($settings ?? [], 'password')" />
10 changes: 10 additions & 0 deletions src/Exceptions/MessageLimitReachedException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace Sendportal\Base\Exceptions;

use Exception;

class MessageLimitReachedException extends Exception
{
//
}
15 changes: 1 addition & 14 deletions src/Http/Controllers/Campaigns/CampaignDispatchController.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
use Sendportal\Base\Facades\Sendportal;
use Sendportal\Base\Http\Controllers\Controller;
use Sendportal\Base\Http\Requests\CampaignDispatchRequest;
use Sendportal\Base\Interfaces\QuotaServiceInterface;
use Sendportal\Base\Models\CampaignStatus;
use Sendportal\Base\Repositories\Campaigns\CampaignTenantRepositoryInterface;

Expand All @@ -19,17 +18,10 @@ class CampaignDispatchController extends Controller
/** @var CampaignTenantRepositoryInterface */
protected $campaigns;

/**
* @var QuotaServiceInterface
*/
protected $quotaService;

public function __construct(
CampaignTenantRepositoryInterface $campaigns,
QuotaServiceInterface $quotaService
CampaignTenantRepositoryInterface $campaigns
) {
$this->campaigns = $campaigns;
$this->quotaService = $quotaService;
}

/**
Expand All @@ -56,11 +48,6 @@ public function send(CampaignDispatchRequest $request, int $id): RedirectRespons

$campaign->tags()->sync($request->get('tags'));

if ($this->quotaService->exceedsQuota($campaign->email_service, $campaign->unsent_count)) {
return redirect()->route('sendportal.campaigns.edit', $id)
->withErrors(__('The number of subscribers for this campaign exceeds your SES quota'));
}

$scheduledAt = $request->get('schedule') === 'scheduled' ? Carbon::parse($request->get('scheduled_at')) : now();

$campaign->update([
Expand Down
2 changes: 1 addition & 1 deletion src/Interfaces/QuotaServiceInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@

interface QuotaServiceInterface
{
public function exceedsQuota(EmailService $emailService, int $messageCount): bool;
public function hasReachedMessageLimit(EmailService $emailService): bool;
}
10 changes: 9 additions & 1 deletion src/Listeners/MessageDispatchHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@

use Exception;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Sendportal\Base\Events\MessageDispatchEvent;
use Sendportal\Base\Exceptions\MessageLimitReachedException;
use Sendportal\Base\Services\Messages\DispatchMessage;

class MessageDispatchHandler implements ShouldQueue
{
use InteractsWithQueue;

/** @var string */
public $queue = 'sendportal-message-dispatch';

Expand All @@ -27,6 +31,10 @@ public function __construct(DispatchMessage $dispatchMessage)
*/
public function handle(MessageDispatchEvent $event): void
{
$this->dispatchMessage->handle($event->message);
try {
$this->dispatchMessage->handle($event->message);
} catch (MessageLimitReachedException $e) {
$this->release();
}
}
}
15 changes: 12 additions & 3 deletions src/Services/Messages/DispatchMessage.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

use Exception;
use Illuminate\Support\Facades\Log;
use Sendportal\Base\Exceptions\MessageLimitReachedException;
use Sendportal\Base\Interfaces\QuotaServiceInterface;
use Sendportal\Base\Models\Campaign;
use Sendportal\Base\Models\CampaignStatus;
use Sendportal\Base\Models\EmailService;
Expand All @@ -30,18 +32,23 @@ class DispatchMessage
/** @var MarkAsSent */
protected $markAsSent;

/** @var QuotaServiceInterface */
protected $quotaService;

public function __construct(
MergeContentService $mergeContentService,
MergeSubjectService $mergeSubjectService,
ResolveEmailService $resolveEmailService,
RelayMessage $relayMessage,
MarkAsSent $markAsSent
MarkAsSent $markAsSent,
QuotaServiceInterface $quotaService
) {
$this->mergeContentService = $mergeContentService;
$this->mergeSubjectService = $mergeSubjectService;
$this->resolveEmailService = $resolveEmailService;
$this->relayMessage = $relayMessage;
$this->markAsSent = $markAsSent;
$this->quotaService = $quotaService;
}

/**
Expand All @@ -58,11 +65,13 @@ public function handle(Message $message): ?string
$message = $this->mergeSubject($message);

$mergedContent = $this->getMergedContent($message);

$emailService = $this->getEmailService($message);

$trackingOptions = MessageTrackingOptions::fromMessage($message);

if ($this->quotaService->hasReachedMessageLimit($emailService)) {
throw new MessageLimitReachedException;
}

$messageId = $this->dispatch($message, $emailService, $trackingOptions, $mergedContent);

$this->markSent($message, $messageId);
Expand Down
10 changes: 4 additions & 6 deletions src/Services/QuotaService.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@

class QuotaService implements QuotaServiceInterface
{
public function exceedsQuota(EmailService $emailService, int $messageCount): bool
public function hasReachedMessageLimit(EmailService $emailService): bool
{
switch ($emailService->type_id) {
case EmailServiceType::SES:
return $this->exceedsSesQuota($emailService, $messageCount);
return $this->exceedsSesQuota($emailService);

case EmailServiceType::SENDGRID:
case EmailServiceType::MAILGUN:
Expand All @@ -34,7 +34,7 @@ protected function resolveMailAdapter(EmailService $emailService): BaseMailAdapt
return app(MailAdapterFactory::class)->adapter($emailService);
}

protected function exceedsSesQuota(EmailService $emailService, int $messageCount): bool
protected function exceedsSesQuota(EmailService $emailService): bool
{
$mailAdapter = $this->resolveMailAdapter($emailService);

Expand All @@ -60,8 +60,6 @@ protected function exceedsSesQuota(EmailService $emailService, int $messageCount

$sent = Arr::get($quota, 'SentLast24Hours');

$remaining = (int)floor($limit - $sent);

return $messageCount > $remaining;
return $sent >= $limit;
}
}
38 changes: 38 additions & 0 deletions src/Traits/MocksSesMailAdapter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

namespace Sendportal\Base\Traits;

use Aws\Sdk;
use Aws\Ses\SesClient;

trait MocksSesMailAdapter
{
/*
* We need to mock the SES mail adapter for tests that could cause QuotaService::hasReachedMessageLimit()
* to be called to prevent any attempt to query the API.
*/
protected function mockSesMailAdapter(int $quota = null, int $sent = 0): void
{
$sendQuota = [];

if ($quota) {
$sendQuota = [
'Max24HourSend' => $quota,
'SentLast24Hours' => $sent,
];
}

$sesClient = $this->getMockBuilder(SesClient::class)
->disableOriginalConstructor()
->getMock();

$sesClient->method('__call')->willReturn(collect($sendQuota));

$aws = $this->getMockBuilder(Sdk::class)->getMock();
$aws->method('createClient')->willReturn($sesClient);

$this->app->singleton('aws', function () use ($aws) {
return $aws;
});
}
}
9 changes: 9 additions & 0 deletions tests/Feature/Messages/MessagesControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,21 @@
use Sendportal\Base\Facades\Sendportal;
use Sendportal\Base\Models\Campaign;
use Sendportal\Base\Models\Message;
use Sendportal\Base\Traits\MocksSesMailAdapter;
use Tests\TestCase;

class MessagesControllerTest extends TestCase
{
use MocksSesMailAdapter;
use RefreshDatabase;

public function setUp(): void
{
parent::setUp();

$this->mockSesMailAdapter();
}

/** @test */
public function the_index_of_sent_messages_is_accessible_to_an_authenticated_user()
{
Expand Down
70 changes: 39 additions & 31 deletions tests/Unit/Services/QuotaServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,19 @@

namespace Tests\Unit\Services;

use Aws\Sdk;
use Aws\Ses\SesClient;
use Sendportal\Base\Exceptions\MessageLimitReachedException;
use Sendportal\Base\Interfaces\QuotaServiceInterface;
use Sendportal\Base\Models\Campaign;
use Sendportal\Base\Models\EmailService;
use Sendportal\Base\Models\EmailServiceType;
use Sendportal\Base\Models\Message;
use Sendportal\Base\Traits\MocksSesMailAdapter;
use Tests\TestCase;

class QuotaServiceTest extends TestCase
{
use MocksSesMailAdapter;

/** @var QuotaServiceInterface */
protected $quotaService;

Expand All @@ -24,75 +28,79 @@ public function setUp(): void
}

/** @test */
public function fewer_subscribers_than_quota_available()
public function ses_message_limit_has_not_been_reached()
{
// given
$emailService = EmailService::factory()->create(['type_id' => EmailServiceType::SES]);

$this->mockMailAdapter(2);
$this->mockSesMailAdapter(2);

// then
self::assertFalse($this->quotaService->exceedsQuota($emailService, 1));
self::assertFalse($this->quotaService->hasReachedMessageLimit($emailService));
}

/** @test */
public function more_subscribers_than_quota_available()
public function ses_message_limit_has_been_reached()
{
// given
$emailService = EmailService::factory()->create(['type_id' => EmailServiceType::SES]);

$this->mockMailAdapter(1);
$this->mockSesMailAdapter(1, 1);

// then
self::assertTrue($this->quotaService->exceedsQuota($emailService, 2));
self::assertTrue($this->quotaService->hasReachedMessageLimit($emailService));
}

/** @test */
public function send_quota_not_available()
public function ses_message_limit_quota_not_available()
{
// given
$emailService = EmailService::factory()->create(['type_id' => EmailServiceType::SES]);

$this->mockMailAdapter();
$this->mockSesMailAdapter();

// then
self::assertFalse($this->quotaService->exceedsQuota($emailService, 1));
self::assertFalse($this->quotaService->hasReachedMessageLimit($emailService));
}

/** @test */
public function unlimited_quota()
public function ses_message_limit_unlimited_quota()
{
// given
$emailService = EmailService::factory()->create(['type_id' => EmailServiceType::SES]);

$this->mockMailAdapter(-1);
$this->mockSesMailAdapter(-1);

// then
self::assertFalse($this->quotaService->exceedsQuota($emailService, 1));
self::assertFalse($this->quotaService->hasReachedMessageLimit($emailService));
}

protected function mockMailAdapter(int $quota = null): void
/** @test */
public function a_message_limit_reached_exception_is_thrown()
{
$sendQuota = [];
// given
$emailService = EmailService::factory()->create(['type_id' => EmailServiceType::SES]);

if ($quota) {
$sendQuota = [
'Max24HourSend' => $quota,
'SentLast24Hours' => 0,
];
}
$campaign = Campaign::factory()->create(
[
'email_service_id' => $emailService->id,
'workspace_id' => $emailService->workspace_id,
'content' => 'test',
]
);

$sesClient = $this->getMockBuilder(SesClient::class)
->disableOriginalConstructor()
->getMock();
$message = Message::factory()->create(
[
'source_id' => $campaign->id,
'workspace_id' => $emailService->workspace_id,
]
);

$sesClient->method('__call')->willReturn(collect($sendQuota));
$this->mockSesMailAdapter(1, 1);

$aws = $this->getMockBuilder(Sdk::class)->getMock();
$aws->method('createClient')->willReturn($sesClient);
$this->withoutExceptionHandling()
->expectException(MessageLimitReachedException::class);

$this->app->singleton('aws', function () use ($aws) {
return $aws;
});
$this->post(route('sendportal.messages.send'), ['id' => $message->id]);
}
}