diff --git a/backend/src/Api/Console/Controller/SendController.php b/backend/src/Api/Console/Controller/SendController.php index d56ee348..075e3c4a 100644 --- a/backend/src/Api/Console/Controller/SendController.php +++ b/backend/src/Api/Console/Controller/SendController.php @@ -5,6 +5,7 @@ use App\Api\Console\Authorization\Scope; use App\Api\Console\Authorization\ScopeRequired; use App\Api\Console\Idempotency\IdempotencySupported; +use App\Api\Console\Input\RetrySendInput; use App\Api\Console\Input\SendEmail\SendEmailInput; use App\Api\Console\Input\SendEmail\UnableToDecodeAttachmentBase64Exception; use App\Api\Console\Object\SendObject; @@ -29,9 +30,12 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Requirement\Requirement; +use Symfony\Component\Clock\ClockAwareTrait; class SendController extends AbstractController { + use ClockAwareTrait; + public function __construct( private SendService $sendService, private SendAttemptService $sendAttemptService, @@ -191,6 +195,38 @@ public function getById(Send $send): JsonResponse ); } + #[Route("/sends/{id}/retry", methods: "POST")] + #[ScopeRequired(Scope::SENDS_SEND)] + public function retrySend( + Send $send, + #[MapRequestPayload] RetrySendInput $input + ): JsonResponse { + if ($send->getQueued()) { + throw new BadRequestException('This send is already queued'); + } + + $hasFailedRecipients = $send->getRecipients()->exists( + fn(int $key, \App\Entity\SendRecipient $r) => $r->getStatus() === SendRecipientStatus::FAILED + ); + if (!$hasFailedRecipients) { + throw new BadRequestException('No failed recipients to retry'); + } + + $sendAfter = null; + if ($input->send_after !== null) { + if ($input->send_after <= $this->now()->getTimestamp()) { + throw new BadRequestException('You cannot schedule a retry in the past'); + } + $sendAfter = $this->now()->setTimestamp($input->send_after); + } + + $retriedCount = $this->sendService->retrySend($send, $sendAfter); + + return new JsonResponse([ + 'retried_recipients' => $retriedCount, + ]); + } + #[Route("/sends/uuid/{uuid}", requirements: ['uuid' => Requirement::UUID], methods: "GET")] #[ScopeRequired(Scope::SENDS_READ)] public function getByUuid(Project $project, string $uuid): JsonResponse diff --git a/backend/src/Api/Console/Input/RetrySendInput.php b/backend/src/Api/Console/Input/RetrySendInput.php new file mode 100644 index 00000000..78c343c6 --- /dev/null +++ b/backend/src/Api/Console/Input/RetrySendInput.php @@ -0,0 +1,8 @@ +getRecipients()->filter( + fn(SendRecipient $r) => $r->getStatus() === SendRecipientStatus::FAILED + ); + + foreach ($failedRecipients as $recipient) { + $recipient->setStatus(SendRecipientStatus::QUEUED); + $recipient->setTryCount(0); + } + + $send->setQueued(true); + $send->setSendAfter($sendAfter ?? $this->now()); + $send->setUpdatedAt($this->now()); + + $this->em->flush(); + + return $failedRecipients->count(); + } + /** * @return array{sends_24h_count: int, recipients_24h_count: int, recipients_24h_accepted_count: int, recipients_24h_bounced_count: int, recipients_24h_complained_count: int, recipients_24h_failed_count: int, recipients_24h_suppressed_count: int} */ diff --git a/backend/tests/Api/Console/Send/RetrySendTest.php b/backend/tests/Api/Console/Send/RetrySendTest.php new file mode 100644 index 00000000..4255a027 --- /dev/null +++ b/backend/tests/Api/Console/Send/RetrySendTest.php @@ -0,0 +1,103 @@ + $project, + 'domain' => $domain, + 'queue' => $queue, + 'queued' => false, + ]); + + $recipient1 = SendRecipientFactory::createOne([ + 'send' => $send, + 'status' => SendRecipientStatus::FAILED, + 'try_count' => 7, + ]); + + $recipient2 = SendRecipientFactory::createOne([ + 'send' => $send, + 'status' => SendRecipientStatus::FAILED, + 'try_count' => 7, + ]); + + $response = $this->consoleApi( + $project, + 'POST', + '/sends/' . $send->getId() . '/retry', + scopes: [Scope::SENDS_SEND] + ); + + $this->assertSame(200, $response->getStatusCode()); + $json = $this->getJson(); + $this->assertSame(2, $json['retried_recipients']); + + $this->em->refresh($recipient1->_real()); + $this->em->refresh($recipient2->_real()); + $this->assertSame(SendRecipientStatus::QUEUED, $recipient1->getStatus()); + $this->assertSame(0, $recipient1->getTryCount()); + $this->assertSame(SendRecipientStatus::QUEUED, $recipient2->getStatus()); + $this->assertSame(0, $recipient2->getTryCount()); + + $this->em->refresh($send->_real()); + $this->assertTrue($send->getQueued()); + } + + public function test_retry_with_send_after(): void + { + $project = ProjectFactory::createOne(); + $domain = DomainFactory::createOne(); + $queue = QueueFactory::createOne(); + + $send = SendFactory::createOne([ + 'project' => $project, + 'domain' => $domain, + 'queue' => $queue, + 'queued' => false, + ]); + + SendRecipientFactory::createOne([ + 'send' => $send, + 'status' => SendRecipientStatus::FAILED, + ]); + + $sendAfter = time() + 3600; + + $response = $this->consoleApi( + $project, + 'POST', + '/sends/' . $send->getId() . '/retry', + data: ['send_after' => $sendAfter], + scopes: [Scope::SENDS_SEND] + ); + + $this->assertSame(200, $response->getStatusCode()); + + $this->em->refresh($send->_real()); + $this->assertSame($sendAfter, $send->getSendAfter()->getTimestamp()); + } + +} diff --git a/frontend/src/routes/(marketing)/docs/content/ConsoleApi.svelte b/frontend/src/routes/(marketing)/docs/content/ConsoleApi.svelte index bfc5108d..0a303151 100644 --- a/frontend/src/routes/(marketing)/docs/content/ConsoleApi.svelte +++ b/frontend/src/routes/(marketing)/docs/content/ConsoleApi.svelte @@ -156,6 +156,9 @@ Authorization: Bearer
  • GET /sends/uuid/:uuid - Get a send by UUID
  • +
  • + POST /sends/:id/retry - Retry a failed send +
  • Objects:

    @@ -254,6 +257,29 @@ type Response = { language="ts" /> +

    Retry Send

    + +

    + POST /sends/:id/retry (scope: sends.send) +

    + +

    + Re-queues failed recipients of a send for retry. The send must not already be queued. Only + recipients with a failed status will be retried. +

    + + +

    Domains

    Endpoints:

    diff --git a/frontend/src/routes/console/[id]/sends/[uuid]/+page.svelte b/frontend/src/routes/console/[id]/sends/[uuid]/+page.svelte index 8423353e..21bc391b 100644 --- a/frontend/src/routes/console/[id]/sends/[uuid]/+page.svelte +++ b/frontend/src/routes/console/[id]/sends/[uuid]/+page.svelte @@ -23,11 +23,21 @@ let error: string | null = $state(null); let activeTab: 'overview' | 'preview' | 'raw' = $state('overview'); - onMount(() => { + function fetchSend() { const emailUuid = page.params.uuid ?? ''; + return getEmailByUuid(emailUuid); + } + + async function refreshSend() { + try { + send = await fetchSend(); + } catch (err: any) { + toast.error(err.message || 'Failed to refresh email'); + } + } - // get the send to fetch raw data and attempts - getEmailByUuid(emailUuid) + onMount(() => { + fetchSend() .then((result) => { send = result; }) @@ -69,7 +79,7 @@ {#if activeTab === 'overview'} - + {/if} {#if activeTab === 'preview'} diff --git a/frontend/src/routes/console/[id]/sends/[uuid]/Overview.svelte b/frontend/src/routes/console/[id]/sends/[uuid]/Overview.svelte index 61ab2ec6..fb8c8e35 100644 --- a/frontend/src/routes/console/[id]/sends/[uuid]/Overview.svelte +++ b/frontend/src/routes/console/[id]/sends/[uuid]/Overview.svelte @@ -1,5 +1,5 @@
    @@ -32,6 +74,35 @@ {/if} + {#if hasFailedRecipients && !send.queued} +
    + + {#snippet icon()} + + {/snippet} + Some recipients failed to receive this email. You can retry sending to the failed recipients. +
    + + +
    +
    +
    + {/if} +
    @@ -79,6 +150,17 @@
    + +

    Choose when to retry sending to failed recipients.

    + +
    + diff --git a/frontend/src/routes/console/lib/actions/emailActions.ts b/frontend/src/routes/console/lib/actions/emailActions.ts index 3ed252e5..048dcde8 100644 --- a/frontend/src/routes/console/lib/actions/emailActions.ts +++ b/frontend/src/routes/console/lib/actions/emailActions.ts @@ -31,3 +31,10 @@ export function getEmailByUuid(uuid: string) { endpoint: `sends/uuid/${uuid}` }); } + +export function retrySend(sendId: number, sendAfter?: number) { + return consoleApi.post<{ retried_recipients: number }>({ + endpoint: `sends/${sendId}/retry`, + data: sendAfter !== undefined ? { send_after: sendAfter } : {} + }); +}