Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ jobs:
MAILGUN_DOMAIN: ${{ secrets.MAILGUN_DOMAIN }}
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
SENDGRID_API_KEY: ${{ secrets.SENDGRID_API_KEY }}
SES_ACCESS_KEY: ${{ secrets.SES_ACCESS_KEY }}
SES_SECRET_KEY: ${{ secrets.SES_SECRET_KEY }}
SES_SESSION_TOKEN: ${{ secrets.SES_SESSION_TOKEN }}
FCM_SERVICE_ACCOUNT_JSON: ${{ secrets.FCM_SERVICE_ACCOUNT_JSON }}
FCM_TO: ${{ secrets.FCM_TO }}
TWILIO_ACCOUNT_SID: ${{ secrets.TWILIO_ACCOUNT_SID }}
Expand Down Expand Up @@ -53,6 +56,8 @@ jobs:
INFORU_SENDER_ID: ${{ secrets.INFORU_SENDER_ID }}

RESEND_TEST_EMAIL: ${{ vars.RESEND_TEST_EMAIL }}
SES_REGION: ${{ vars.SES_REGION }}
SES_TEST_EMAIL: ${{ vars.SES_TEST_EMAIL }}
run: |
docker compose up -d --build
sleep 5
Expand Down
5 changes: 5 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ services:
- RESEND_API_KEY
- RESEND_TEST_EMAIL
- SENDGRID_API_KEY
- SES_ACCESS_KEY
- SES_SECRET_KEY
- SES_REGION
- SES_TEST_EMAIL
- SES_SESSION_TOKEN
- FCM_SERVICE_ACCOUNT_JSON
- FCM_TO
- TWILIO_ACCOUNT_SID
Expand Down
19 changes: 19 additions & 0 deletions src/Utopia/Messaging/Adapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ public function send(Message $message): array
* url: string,
* statusCode: int,
* response: array<string, mixed>|string|null,
* headers: array<string, string>,
* error: string|null
* }
*
Expand Down Expand Up @@ -103,6 +104,8 @@ protected function request(
}
}

$responseHeaders = [];

\curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_URL => $url,
Expand All @@ -111,6 +114,14 @@ protected function request(
CURLOPT_USERAGENT => "Appwrite {$this->getName()} Message Sender",
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CONNECTTIMEOUT => $connectTimeout,
CURLOPT_HEADERFUNCTION => function ($ch, string $header) use (&$responseHeaders): int {
$parts = \explode(':', $header, 2);
if (\count($parts) === 2) {
$responseHeaders[\strtolower(\trim($parts[0]))] = \trim($parts[1]);
Comment thread
abnegate marked this conversation as resolved.
}

return \strlen($header);
},
]);

$response = \curl_exec($ch);
Expand All @@ -125,6 +136,7 @@ protected function request(
'url' => $url,
'statusCode' => \curl_getinfo($ch, CURLINFO_RESPONSE_CODE),
'response' => $response,
'headers' => $responseHeaders,
'error' => \curl_error($ch),
];
}
Expand All @@ -140,6 +152,7 @@ protected function request(
* url: string,
* statusCode: int,
* response: array<string, mixed>|null,
* headers: array<string, string>,
* error: string|null
* }>
*
Expand Down Expand Up @@ -244,6 +257,12 @@ protected function requestMulti(
'url' => \curl_getinfo($ch, CURLINFO_EFFECTIVE_URL),
'statusCode' => \curl_getinfo($ch, CURLINFO_RESPONSE_CODE),
'response' => $response,
// Kept in sync with request()'s shape. Response headers are not
// captured here: this path copies a configured handle with
// curl_copy_handle(), and copying a handle that carries a
// CURLOPT_HEADERFUNCTION closure segfaults. Wire up per-handle
// capture (without copy_handle) if a multi-path adapter needs it.
'headers' => [],
'error' => \curl_error($ch),
];

Expand Down
37 changes: 26 additions & 11 deletions src/Utopia/Messaging/Adapter/Email/SES.php
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ private function sendRaw(EmailMessage $message, Response $response): array
* marked failed with the SES error. On success each recipient is mapped
* from its corresponding BulkEmailEntryResults entry.
*
* @param array{url: string, statusCode: int, response: array<string, mixed>|string|null, error: string|null} $result
* @param array{url: string, statusCode: int, response: array<string, mixed>|string|null, headers: array<string, string>, error: string|null} $result
* @return array{deliveredTo: int, type: string, results: array<array<string, mixed>>}
*/
private function parseBulkResult(EmailMessage $message, array $result, Response $response): array
Expand Down Expand Up @@ -383,14 +383,20 @@ private function templateName(EmailMessage $message): string
* Whether a SendBulkEmail result indicates the referenced template is
* missing, via either the top-level error or per-entry statuses.
*
* @param array{url: string, statusCode: int, response: array<string, mixed>|string|null, error: string|null} $result
* @param array{url: string, statusCode: int, response: array<string, mixed>|string|null, headers: array<string, string>, error: string|null} $result
*/
private function isTemplateMissing(array $result): bool
{
$errorType = $this->errorType($result);
if ($errorType === 'NotFoundException' || $errorType === 'BadRequestException') {
$message = $this->errorMessage($result);
if (\stripos($message, 'template') !== false) {
// BadRequestException is generic, so confirm the message is about a
// missing template rather than another template error (e.g. invalid
// template content).
$message = \strtolower($this->errorMessage($result));
if (
\str_contains($message, 'template')
&& (\str_contains($message, 'does not exist') || \str_contains($message, 'not found'))
) {
return true;
}
}
Expand Down Expand Up @@ -521,7 +527,7 @@ private function formatAddress(string $email, ?string $name): string
* configured region.
*
* @param array<string, mixed> $body
* @return array{url: string, statusCode: int, response: array<string, mixed>|string|null, error: string|null}
* @return array{url: string, statusCode: int, response: array<string, mixed>|string|null, headers: array<string, string>, error: string|null}
*
* @throws \Exception
*/
Expand Down Expand Up @@ -652,7 +658,7 @@ private function signingKey(string $dateStamp): string
/**
* Extract a human-readable error message from a SES error response.
*
* @param array{url: string, statusCode: int, response: array<string, mixed>|string|null, error: string|null} $result
* @param array{url: string, statusCode: int, response: array<string, mixed>|string|null, headers: array<string, string>, error: string|null} $result
*/
private function errorMessage(array $result): string
{
Expand All @@ -679,16 +685,25 @@ private function errorMessage(array $result): string
}

/**
* Extract the SES error type. SES signals the type either via the
* x-amzn-ErrorType header (not available here) or a `__type` body field,
* e.g. "AlreadyExistsException" or "NotFoundException".
* Extract the SES error type, e.g. "AlreadyExistsException" or
* "NotFoundException".
*
* @param array{url: string, statusCode: int, response: array<string, mixed>|string|null, error: string|null} $result
* SES API v2 uses the AWS REST-JSON protocol, which reports the exception
* type in the x-amzn-ErrorType response header, not the body. The header
* value can carry a trailing ":<location>" which is stripped. Older AWS
* JSON-protocol responses instead carry it in a `__type` (optionally
* "prefix#Type") or `code` body field, which is used as a fallback.
*
* @param array{url: string, statusCode: int, response: array<string, mixed>|string|null, headers: array<string, string>, error: string|null} $result
*/
private function errorType(array $result): ?string
{
$body = $result['response'];
$header = $result['headers']['x-amzn-errortype'] ?? null;
if (\is_string($header) && $header !== '') {
return \trim(\explode(':', $header)[0]);
}

$body = $result['response'];
if (\is_array($body)) {
$type = $body['__type'] ?? $body['code'] ?? null;
if (\is_string($type)) {
Expand Down
3 changes: 2 additions & 1 deletion tests/Messaging/Adapter/Email/ResendRoutingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ class ResendStub extends Resend
/**
* @param array<string> $headers
* @param array<string, mixed>|null $body
* @return array{url: string, statusCode: int, response: array<string, mixed>|string|null, error: string|null}
* @return array{url: string, statusCode: int, response: array<string, mixed>|string|null, headers: array<string, string>, error: string|null}
*/
protected function request(
string $method,
Expand All @@ -158,6 +158,7 @@ protected function request(
'url' => $url,
'statusCode' => $stub['statusCode'],
'response' => $stub['response'],
'headers' => [],
'error' => null,
];
}
Expand Down
82 changes: 80 additions & 2 deletions tests/Messaging/Adapter/Email/SESRoutingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,83 @@ public function testTemplateNotFoundTriggersCreateAndRetry(): void
$this->assertSame('success', $response['results'][0]['status']);
}

public function testTopLevelMissingTemplateTriggersCreateAndRetry(): void
{
$stub = new SESStub('key', 'secret', 'us-east-1');

// The real SES v2 response for a missing DefaultContent template is a
// top-level 400 whose exception type lives only in the x-amzn-ErrorType
// response header, with a plain {"message": ...} body. The auto-create
// must trigger off that, not off a per-entry BulkEmailEntryResults
// status (which SES does not return for a missing default template).
$stub->stubResponses[] = [
'statusCode' => 400,
'headers' => ['x-amzn-errortype' => 'BadRequestException'],
'response' => ['message' => 'Template utopia-abc123 does not exist.'],
];
$stub->stubResponses[] = ['statusCode' => 200, 'response' => []];
$stub->stubResponses[] = [
'statusCode' => 200,
'response' => ['BulkEmailEntryResults' => [['Status' => 'SUCCESS', 'MessageId' => 'x']]],
];

$message = new Email(
to: [['email' => 'a@example.com']],
subject: 'Subject',
content: 'Body',
fromName: 'Sender',
fromEmail: 'from@example.com',
);

$response = $stub->send($message);

$this->assertCount(3, $stub->capturedRequests);
$this->assertStringEndsWith('/v2/email/outbound-bulk-emails', $stub->capturedRequests[0]['url']);
$this->assertStringEndsWith('/v2/email/templates', $stub->capturedRequests[1]['url']);
$this->assertStringEndsWith('/v2/email/outbound-bulk-emails', $stub->capturedRequests[2]['url']);

$this->assertSame(1, $response['deliveredTo']);
$this->assertSame('success', $response['results'][0]['status']);
}

public function testCreateTemplateToleratesAlreadyExists(): void
{
$stub = new SESStub('key', 'secret', 'us-east-1');

// Missing on the first send (top-level 400) ...
$stub->stubResponses[] = [
'statusCode' => 400,
'headers' => ['x-amzn-errortype' => 'BadRequestException'],
'response' => ['message' => 'Template utopia-abc123 does not exist.'],
];
// ... but a concurrent sender created it first, so CreateEmailTemplate
// returns AlreadyExistsException (again, type in the header). That must
// be tolerated rather than surfaced as a failure.
$stub->stubResponses[] = [
'statusCode' => 400,
'headers' => ['x-amzn-errortype' => 'AlreadyExistsException'],
'response' => ['message' => 'Template utopia-abc123 already exists.'],
];
$stub->stubResponses[] = [
'statusCode' => 200,
'response' => ['BulkEmailEntryResults' => [['Status' => 'SUCCESS']]],
];

$message = new Email(
to: [['email' => 'a@example.com']],
subject: 'Subject',
content: 'Body',
fromName: 'Sender',
fromEmail: 'from@example.com',
);

$response = $stub->send($message);

$this->assertCount(3, $stub->capturedRequests);
$this->assertSame(1, $response['deliveredTo']);
$this->assertSame('success', $response['results'][0]['status']);
}

public function testTextTemplateUsesTextContent(): void
{
$stub = new SESStub('key', 'secret', 'us-east-1');
Expand Down Expand Up @@ -573,14 +650,14 @@ class SESStub extends SES
public array $capturedRequests = [];

/**
* @var array<array{statusCode: int, response: array<string, mixed>|string|null}>
* @var array<array{statusCode: int, response: array<string, mixed>|string|null, headers?: array<string, string>}>
*/
public array $stubResponses = [];

/**
* @param array<string> $headers
* @param array<string, mixed>|null $body
* @return array{url: string, statusCode: int, response: array<string, mixed>|string|null, error: string|null}
* @return array{url: string, statusCode: int, response: array<string, mixed>|string|null, headers: array<string, string>, error: string|null}
*/
protected function request(
string $method,
Expand All @@ -603,6 +680,7 @@ protected function request(
'url' => $url,
'statusCode' => $stub['statusCode'],
'response' => $stub['response'],
'headers' => $stub['headers'] ?? [],
'error' => null,
];
}
Expand Down
Loading