Skip to content
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
36 changes: 36 additions & 0 deletions backend/src/Api/Console/Controller/SendController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions backend/src/Api/Console/Input/RetrySendInput.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php

namespace App\Api\Console\Input;

class RetrySendInput
{
public ?int $send_after = null;
}
24 changes: 24 additions & 0 deletions backend/src/Service/Send/SendService.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use App\Entity\Project;
use App\Entity\Queue;
use App\Entity\Send;
use App\Entity\SendRecipient;
use App\Entity\Type\SendRecipientStatus;
use App\Entity\Type\SendRecipientType;
use App\Repository\SendRepository;
Expand Down Expand Up @@ -171,6 +172,29 @@ public function createSend(
}


/**
* @return int Number of re-queued recipients
*/
public function retrySend(Send $send, ?\DateTimeImmutable $sendAfter): int
{
$failedRecipients = $send->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}
*/
Expand Down
103 changes: 103 additions & 0 deletions backend/tests/Api/Console/Send/RetrySendTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php

namespace App\Tests\Api\Console\Send;

use App\Api\Console\Authorization\Scope;
use App\Api\Console\Controller\SendController;
use App\Entity\Type\SendRecipientStatus;
use App\Service\Send\SendService;
use App\Tests\Case\WebTestCase;
use App\Tests\Factory\DomainFactory;
use App\Tests\Factory\ProjectFactory;
use App\Tests\Factory\QueueFactory;
use App\Tests\Factory\SendFactory;
use App\Tests\Factory\SendRecipientFactory;
use PHPUnit\Framework\Attributes\CoversClass;

#[CoversClass(SendController::class)]
#[CoversClass(SendService::class)]
class RetrySendTest extends WebTestCase
{

public function test_retry_failed_recipients(): void
{
$project = ProjectFactory::createOne();
$domain = DomainFactory::createOne();
$queue = QueueFactory::createOne();

$send = SendFactory::createOne([
'project' => $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());
}

}
26 changes: 26 additions & 0 deletions frontend/src/routes/(marketing)/docs/content/ConsoleApi.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,9 @@ Authorization: Bearer <your_api_key>
<li>
<a href="#get-send-uuid">GET /sends/uuid/:uuid</a> - Get a send by UUID
</li>
<li>
<a href="#retry-send">POST /sends/:id/retry</a> - Retry a failed send
</li>
</ul>

<p>Objects:</p>
Expand Down Expand Up @@ -254,6 +257,29 @@ type Response = {
language="ts"
/>

<h4 id="retry-send">Retry Send</h4>

<p>
<code>POST /sends/:id/retry</code> (scope: <strong>sends.send</strong>)
</p>

<p>
Re-queues failed recipients of a send for retry. The send must not already be queued. Only
recipients with a <code>failed</code> status will be retried.
</p>

<CodeBlock
code={`
type Request = {
send_after?: number // Optional unix timestamp. Defaults to now.
}
type Response = {
retried_recipients: number // Count of re-queued recipients
}
`}
language="ts"
/>

<h3 id="domains">Domains</h3>

<p>Endpoints:</p>
Expand Down
18 changes: 14 additions & 4 deletions frontend/src/routes/console/[id]/sends/[uuid]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;
})
Expand Down Expand Up @@ -69,7 +79,7 @@
</div>

{#if activeTab === 'overview'}
<Overview {send} />
<Overview {send} {refreshSend} />
{/if}

{#if activeTab === 'preview'}
Expand Down
Loading
Loading