diff --git a/resources/views/email_services/options/smtp.blade.php b/resources/views/email_services/options/smtp.blade.php index f064bb0a..2ab3727c 100644 --- a/resources/views/email_services/options/smtp.blade.php +++ b/resources/views/email_services/options/smtp.blade.php @@ -2,4 +2,4 @@ - \ No newline at end of file + diff --git a/src/Exceptions/MessageLimitReachedException.php b/src/Exceptions/MessageLimitReachedException.php new file mode 100644 index 00000000..83592e31 --- /dev/null +++ b/src/Exceptions/MessageLimitReachedException.php @@ -0,0 +1,10 @@ +campaigns = $campaigns; - $this->quotaService = $quotaService; } /** @@ -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([ diff --git a/src/Interfaces/QuotaServiceInterface.php b/src/Interfaces/QuotaServiceInterface.php index f347f538..d387ce35 100644 --- a/src/Interfaces/QuotaServiceInterface.php +++ b/src/Interfaces/QuotaServiceInterface.php @@ -6,5 +6,5 @@ interface QuotaServiceInterface { - public function exceedsQuota(EmailService $emailService, int $messageCount): bool; + public function hasReachedMessageLimit(EmailService $emailService): bool; } diff --git a/src/Listeners/MessageDispatchHandler.php b/src/Listeners/MessageDispatchHandler.php index e70233e4..915dfe34 100644 --- a/src/Listeners/MessageDispatchHandler.php +++ b/src/Listeners/MessageDispatchHandler.php @@ -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'; @@ -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(); + } } } diff --git a/src/Services/Messages/DispatchMessage.php b/src/Services/Messages/DispatchMessage.php index 584cdaf4..c60321b8 100644 --- a/src/Services/Messages/DispatchMessage.php +++ b/src/Services/Messages/DispatchMessage.php @@ -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; @@ -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; } /** @@ -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); diff --git a/src/Services/QuotaService.php b/src/Services/QuotaService.php index a5fe6361..90d6ae1e 100644 --- a/src/Services/QuotaService.php +++ b/src/Services/QuotaService.php @@ -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: @@ -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); @@ -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; } } diff --git a/src/Traits/MocksSesMailAdapter.php b/src/Traits/MocksSesMailAdapter.php new file mode 100644 index 00000000..b0e14bfc --- /dev/null +++ b/src/Traits/MocksSesMailAdapter.php @@ -0,0 +1,38 @@ + $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; + }); + } +} diff --git a/tests/Feature/Messages/MessagesControllerTest.php b/tests/Feature/Messages/MessagesControllerTest.php index 98bf0b78..998b48d5 100644 --- a/tests/Feature/Messages/MessagesControllerTest.php +++ b/tests/Feature/Messages/MessagesControllerTest.php @@ -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() { diff --git a/tests/Unit/Services/QuotaServiceTest.php b/tests/Unit/Services/QuotaServiceTest.php index 96b5b06e..09117f2d 100644 --- a/tests/Unit/Services/QuotaServiceTest.php +++ b/tests/Unit/Services/QuotaServiceTest.php @@ -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; @@ -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]); } }