From 1bae574e496b7836718c82f4f51efb7100b4f886 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 3 Jun 2026 00:50:48 +1200 Subject: [PATCH 1/2] feat: add Amazon SES email adapter Add a production AWS SES adapter built for high-volume bulk sending (the 500K-recipient newsletter path), using the SES API v2 directly with no AWS SDK dependency. Primary path (no attachments) uses SendBulkEmail, which is template-based: a deterministic template name is derived from a hash of subject+body+isHtml, created on demand the first time SES reports it missing, and reused across every batch of the same send. Each recipient maps to one BulkEmailEntry and its BulkEmailEntryResults[].Status is mapped to a per-recipient result. Fallback path (attachments present) uses SendEmail with Content.Raw, one request per recipient, with the MIME assembled via PHPMailer (matching the SMTP adapter). SES templates cannot carry attachments, so this mirrors how the Resend adapter falls back to individual sends. Authentication is hand-rolled AWS Signature Version 4 (service "ses"), supporting temporary credentials via an optional session token. The signing core is covered by a deterministic unit test asserting it reproduces AWS's published aws-sig-v4-test-suite "get-vanilla" signature, plus request-routing unit tests (bulk endpoint, template lifecycle, result parsing, 50-recipient limit, Raw attachment fallback) and env-gated live tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 6 +- src/Utopia/Messaging/Adapter/Email/SES.php | 633 ++++++++++++++++++ .../Adapter/Email/SESRoutingTest.php | 459 +++++++++++++ .../Adapter/Email/SESSigningTest.php | 144 ++++ tests/Messaging/Adapter/Email/SESTest.php | 128 ++++ 5 files changed, 1369 insertions(+), 1 deletion(-) create mode 100644 src/Utopia/Messaging/Adapter/Email/SES.php create mode 100644 tests/Messaging/Adapter/Email/SESRoutingTest.php create mode 100644 tests/Messaging/Adapter/Email/SESSigningTest.php create mode 100644 tests/Messaging/Adapter/Email/SESTest.php diff --git a/README.md b/README.md index b4b36732..f107048c 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ use \Utopia\Messaging\Messages\Email; use \Utopia\Messaging\Adapter\Email\SendGrid; use \Utopia\Messaging\Adapter\Email\Mailgun; use \Utopia\Messaging\Adapter\Email\Resend; +use \Utopia\Messaging\Adapter\Email\SES; $message = new Email( to: ['team@appwrite.io'], @@ -39,6 +40,9 @@ $messaging->send($message); $messaging = new Resend('YOUR_API_KEY'); $messaging->send($message); + +$messaging = new SES('YOUR_ACCESS_KEY', 'YOUR_SECRET_KEY', 'YOUR_REGION'); +$messaging->send($message); ``` ## SMS @@ -94,7 +98,7 @@ $messaging->send($message); - [ ] [SendinBlue](https://www.sendinblue.com/) - [ ] [MailSlurp](https://www.mailslurp.com/) - [ ] [ElasticEmail](https://elasticemail.com/) -- [ ] [SES](https://aws.amazon.com/ses/) +- [x] [SES](https://aws.amazon.com/ses/) ### SMS - [x] [Twilio](https://www.twilio.com/) diff --git a/src/Utopia/Messaging/Adapter/Email/SES.php b/src/Utopia/Messaging/Adapter/Email/SES.php new file mode 100644 index 00000000..d2e8fc72 --- /dev/null +++ b/src/Utopia/Messaging/Adapter/Email/SES.php @@ -0,0 +1,633 @@ + + */ + private array $ensuredTemplates = []; + + /** + * @param string $accessKey AWS access key ID. + * @param string $secretKey AWS secret access key. + * @param string $region AWS region, e.g. 'us-east-1'. + * @param string|null $sessionToken Optional session token for temporary credentials. + */ + public function __construct( + private string $accessKey, + private string $secretKey, + private string $region, + private ?string $sessionToken = null, + ) { + } + + public function getName(): string + { + return static::NAME; + } + + public function getMaxMessagesPerRequest(): int + { + return self::MAX_DESTINATIONS; + } + + /** + * {@inheritdoc} + */ + protected function process(EmailMessage $message): array + { + $response = new Response($this->getType()); + + $hasAttachments = ! \is_null($message->getAttachments()) && ! empty($message->getAttachments()); + + if ($hasAttachments) { + return $this->sendRaw($message, $response); + } + + return $this->sendBulk($message, $response); + } + + /** + * Primary path: template-based bulk send via SES SendBulkEmail. + * + * @return array{deliveredTo: int, type: string, results: array>} + * + * @throws \Exception + */ + private function sendBulk(EmailMessage $message, Response $response): array + { + $templateName = $this->templateName($message); + + $entries = \array_map( + fn ($to) => [ + 'Destination' => [ + 'ToAddresses' => [$to['email']], + ], + 'ReplacementEmailContent' => [ + 'ReplacementTemplate' => [ + 'ReplacementTemplateData' => '{}', + ], + ], + ], + $message->getTo() + ); + + $body = [ + 'FromEmailAddress' => $this->formatAddress($message->getFromEmail(), $message->getFromName()), + 'DefaultContent' => [ + 'Template' => [ + 'TemplateName' => $templateName, + 'TemplateData' => '{}', + ], + ], + 'BulkEmailEntries' => $entries, + ]; + + if (! empty($message->getReplyToEmail())) { + $body['ReplyToAddresses'] = [ + $this->formatAddress($message->getReplyToEmail(), $message->getReplyToName()), + ]; + } + + $result = $this->dispatch('POST', '/v2/email/outbound-bulk-emails', $body); + + // If the template does not exist yet, create it once and retry. + if ($this->isTemplateMissing($result)) { + $this->ensureTemplate($message, $templateName); + $result = $this->dispatch('POST', '/v2/email/outbound-bulk-emails', $body); + } + + return $this->parseBulkResult($message, $result, $response); + } + + /** + * Fallback path: one SES SendEmail (Content.Raw) request per recipient. + * Used when the message carries attachments, which SES templates cannot + * represent. + * + * @return array{deliveredTo: int, type: string, results: array>} + * + * @throws \Exception + */ + private function sendRaw(EmailMessage $message, Response $response): array + { + $this->assertAttachmentSize($message); + + $deliveredTo = 0; + + foreach ($message->getTo() as $to) { + $mime = $this->buildMime($message, $to); + + $body = [ + 'FromEmailAddress' => $this->formatAddress($message->getFromEmail(), $message->getFromName()), + 'Destination' => [ + 'ToAddresses' => [$to['email']], + ], + 'Content' => [ + 'Raw' => [ + 'Data' => \base64_encode($mime), + ], + ], + ]; + + if (! empty($message->getReplyToEmail())) { + $body['ReplyToAddresses'] = [ + $this->formatAddress($message->getReplyToEmail(), $message->getReplyToName()), + ]; + } + + $result = $this->dispatch('POST', '/v2/email/outbound-emails', $body); + + $statusCode = $result['statusCode']; + + if ($statusCode >= 200 && $statusCode < 300) { + $response->addResult($to['email']); + $deliveredTo++; + } else { + $response->addResult($to['email'], $this->errorMessage($result)); + } + } + + $response->setDeliveredTo($deliveredTo); + + return $response->toArray(); + } + + /** + * Map a SendBulkEmail response to per-recipient results. + * + * On a whole-request failure (non-2xx) every recipient in the batch is + * 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|null, error: string|null} $result + * @return array{deliveredTo: int, type: string, results: array>} + */ + private function parseBulkResult(EmailMessage $message, array $result, Response $response): array + { + $recipients = $message->getTo(); + $statusCode = $result['statusCode']; + + if ($statusCode < 200 || $statusCode >= 300) { + $error = $this->errorMessage($result); + foreach ($recipients as $to) { + $response->addResult($to['email'], $error); + } + + return $response->toArray(); + } + + $entryResults = \is_array($result['response']) + ? ($result['response']['BulkEmailEntryResults'] ?? null) + : null; + + if (! \is_array($entryResults)) { + // 2xx without a parseable body: treat the whole batch as delivered. + $response->setDeliveredTo(\count($recipients)); + foreach ($recipients as $to) { + $response->addResult($to['email']); + } + + return $response->toArray(); + } + + $deliveredTo = 0; + + foreach ($recipients as $index => $to) { + $entry = $entryResults[$index] ?? null; + $status = \is_array($entry) ? ($entry['Status'] ?? null) : null; + + if ($status === self::STATUS_SUCCESS) { + $response->addResult($to['email']); + $deliveredTo++; + } else { + $error = (\is_array($entry) ? ($entry['Error'] ?? null) : null) + ?: ($status ?? 'Unknown error'); + $response->addResult($to['email'], $error); + } + } + + $response->setDeliveredTo($deliveredTo); + + return $response->toArray(); + } + + /** + * Ensure the content-hash template exists in the SES account, creating it + * from the message's subject/HTML/text if necessary. Idempotent per + * instance and tolerant of concurrent creation (AlreadyExistsException). + * + * @throws \Exception + */ + private function ensureTemplate(EmailMessage $message, string $templateName): void + { + if (isset($this->ensuredTemplates[$templateName])) { + return; + } + + $content = $message->isHtml() + ? ['Subject' => $message->getSubject(), 'Html' => $message->getContent()] + : ['Subject' => $message->getSubject(), 'Text' => $message->getContent()]; + + $result = $this->dispatch('POST', '/v2/email/templates', [ + 'TemplateName' => $templateName, + 'TemplateContent' => $content, + ]); + + $statusCode = $result['statusCode']; + $created = $statusCode >= 200 && $statusCode < 300; + $alreadyExists = $this->errorType($result) === 'AlreadyExistsException'; + + if (! $created && ! $alreadyExists) { + throw new \Exception('SES failed to create email template: '.$this->errorMessage($result)); + } + + $this->ensuredTemplates[$templateName] = true; + } + + /** + * Derive a deterministic, SES-valid template name from the message content + * so identical content reuses a single template across batches and sends. + */ + private function templateName(EmailMessage $message): string + { + $hash = \hash('sha256', \implode("\0", [ + $message->getSubject(), + $message->getContent(), + $message->isHtml() ? '1' : '0', + ])); + + return 'utopia-'.$hash; + } + + /** + * 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|null, 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) { + return true; + } + } + + $entryResults = \is_array($result['response'] ?? null) + ? ($result['response']['BulkEmailEntryResults'] ?? null) + : null; + + if (\is_array($entryResults)) { + foreach ($entryResults as $entry) { + $status = \is_array($entry) ? ($entry['Status'] ?? null) : null; + if ($status === 'TEMPLATE_NOT_FOUND' || $status === 'TEMPLATE_DOES_NOT_EXIST') { + return true; + } + } + } + + return false; + } + + /** + * Build a raw RFC 5322 MIME message (with attachments) for a single + * recipient using PHPMailer's pre-send assembly. + * + * @param array $to + * + * @throws \Exception + */ + private function buildMime(EmailMessage $message, array $to): string + { + $mail = new PHPMailer(true); + $mail->CharSet = 'UTF-8'; + $mail->Subject = $message->getSubject(); + $mail->Body = $message->getContent(); + $mail->setFrom($message->getFromEmail(), $message->getFromName()); + $mail->addReplyTo($message->getReplyToEmail(), $message->getReplyToName()); + $mail->isHTML($message->isHtml()); + + if ($message->isHtml()) { + $alt = \preg_replace('/]*>(.*?)<\/style>/is', '', $message->getContent()); + $mail->AltBody = \trim(\strip_tags($alt ?? '')); + } + + $mail->addAddress($to['email'], $to['name'] ?? ''); + + foreach ($message->getCC() ?? [] as $cc) { + $mail->addCC($cc['email'], $cc['name'] ?? ''); + } + + foreach ($message->getBCC() ?? [] as $bcc) { + $mail->addBCC($bcc['email'], $bcc['name'] ?? ''); + } + + foreach ($message->getAttachments() ?? [] as $attachment) { + $content = $attachment->getContent(); + if ($content === null) { + $data = \file_get_contents($attachment->getPath()); + if ($data === false) { + throw new \Exception('Failed to read attachment file: '.$attachment->getPath()); + } + $content = $data; + } + + $mail->addStringAttachment( + string: $content, + filename: $attachment->getName(), + encoding: PHPMailer::ENCODING_BASE64, + type: $attachment->getType(), + ); + } + + if (! $mail->preSend()) { + throw new \Exception('Failed to build MIME message: '.$mail->ErrorInfo); + } + + return $mail->getSentMIMEMessage(); + } + + /** + * Validate total attachment size against the adapter limit. + * + * @throws \Exception + */ + private function assertAttachmentSize(EmailMessage $message): void + { + $size = 0; + + foreach ($message->getAttachments() ?? [] as $attachment) { + if ($attachment->getContent() !== null) { + $size += \strlen($attachment->getContent()); + } else { + $fileSize = \filesize($attachment->getPath()); + if ($fileSize === false) { + throw new \Exception('Failed to read attachment file: '.$attachment->getPath()); + } + $size += $fileSize; + } + } + + if ($size > self::MAX_ATTACHMENT_BYTES) { + throw new \Exception('Total attachment size exceeds '.self::MAX_ATTACHMENT_BYTES.' bytes'); + } + } + + /** + * Format an email address with an optional display name (RFC 5322). + */ + private function formatAddress(string $email, ?string $name): string + { + if (empty($name)) { + return $email; + } + + return "{$name} <{$email}>"; + } + + /** + * Sign and dispatch a request to the SES API v2 endpoint for the + * configured region. + * + * @param array $body + * @return array{url: string, statusCode: int, response: array|string|null, error: string|null} + * + * @throws \Exception + */ + private function dispatch(string $method, string $path, array $body): array + { + $host = 'email.'.$this->region.'.amazonaws.com'; + $payload = \json_encode($body, JSON_THROW_ON_ERROR); + + $headers = $this->signature($method, $host, $path, $payload); + $headers[] = 'Content-Type: application/json'; + + return $this->request( + method: $method, + url: 'https://'.$host.$path, + headers: $headers, + body: $body, + ); + } + + /** + * Build the AWS Signature Version 4 request headers using the current + * timestamp. + * + * The signed headers are content-type, host and x-amz-date (plus + * x-amz-security-token when temporary credentials are used). The returned + * list contains the Host, X-Amz-Date, optional X-Amz-Security-Token and + * Authorization headers; the caller adds Content-Type. + * + * @return array + * + * @link https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html + */ + private function signature(string $method, string $host, string $path, string $payload): array + { + $amzDate = \gmdate('Ymd\THis\Z'); + + $signed = [ + 'content-type' => 'application/json', + 'host' => $host, + 'x-amz-date' => $amzDate, + ]; + + if (! empty($this->sessionToken)) { + $signed['x-amz-security-token'] = $this->sessionToken; + } + + $authorization = $this->sign($method, $path, $payload, $signed, $amzDate); + + $headers = [ + 'Host: '.$host, + 'X-Amz-Date: '.$amzDate, + 'Authorization: '.$authorization, + ]; + + if (! empty($this->sessionToken)) { + $headers[] = 'X-Amz-Security-Token: '.$this->sessionToken; + } + + return $headers; + } + + /** + * Compute the AWS Signature Version 4 Authorization header value. + * + * Pure function of its inputs (no clock, no network): canonical request → + * string to sign → signing key → signature. Exposed as protected so the + * signing can be verified against AWS's published test vectors. + * + * Header names in $signedHeaders must be lowercase; they are sorted and + * joined to form both the canonical headers block and the SignedHeaders + * list, per the SigV4 specification. + * + * @param array $signedHeaders Lowercase header name => value. + * + * @link https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html + */ + protected function sign(string $method, string $path, string $payload, array $signedHeaders, string $amzDate): string + { + \ksort($signedHeaders); + + $canonicalHeaders = ''; + foreach ($signedHeaders as $name => $value) { + $canonicalHeaders .= $name.':'.\trim($value)."\n"; + } + $signedHeaderList = \implode(';', \array_keys($signedHeaders)); + + $canonicalRequest = \implode("\n", [ + $method, + $path, + '', + $canonicalHeaders, + $signedHeaderList, + \hash('sha256', $payload), + ]); + + $dateStamp = \substr($amzDate, 0, 8); + $credentialScope = $dateStamp.'/'.$this->region.'/'.$this->service.'/aws4_request'; + + $stringToSign = \implode("\n", [ + self::ALGORITHM, + $amzDate, + $credentialScope, + \hash('sha256', $canonicalRequest), + ]); + + $signingKey = $this->signingKey($dateStamp); + $signature = \hash_hmac('sha256', $stringToSign, $signingKey); + + return self::ALGORITHM + .' Credential='.$this->accessKey.'/'.$credentialScope + .', SignedHeaders='.$signedHeaderList + .', Signature='.$signature; + } + + /** + * Derive the SigV4 signing key for the given date via the HMAC-SHA256 + * chain over date, region, service and the aws4_request terminator. + */ + private function signingKey(string $dateStamp): string + { + $kDate = \hash_hmac('sha256', $dateStamp, 'AWS4'.$this->secretKey, true); + $kRegion = \hash_hmac('sha256', $this->region, $kDate, true); + $kService = \hash_hmac('sha256', $this->service, $kRegion, true); + + return \hash_hmac('sha256', 'aws4_request', $kService, true); + } + + /** + * Extract a human-readable error message from a SES error response. + * + * @param array{url: string, statusCode: int, response: array|string|null, error: string|null} $result + */ + private function errorMessage(array $result): string + { + $body = $result['response']; + + if (\is_array($body)) { + if (isset($body['message']) && \is_string($body['message'])) { + return $body['message']; + } + if (isset($body['Message']) && \is_string($body['Message'])) { + return $body['Message']; + } + } + + if (\is_string($body) && $body !== '') { + return $body; + } + + if (! empty($result['error'])) { + return $result['error']; + } + + return 'Unknown error'; + } + + /** + * 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". + * + * @param array{url: string, statusCode: int, response: array|string|null, error: string|null} $result + */ + private function errorType(array $result): ?string + { + $body = $result['response']; + + if (\is_array($body)) { + $type = $body['__type'] ?? $body['code'] ?? null; + if (\is_string($type)) { + // __type can be "prefix#AlreadyExistsException"; keep the suffix. + $parts = \explode('#', $type); + + return \end($parts); + } + } + + return null; + } +} diff --git a/tests/Messaging/Adapter/Email/SESRoutingTest.php b/tests/Messaging/Adapter/Email/SESRoutingTest.php new file mode 100644 index 00000000..ab2e8b87 --- /dev/null +++ b/tests/Messaging/Adapter/Email/SESRoutingTest.php @@ -0,0 +1,459 @@ +assertSame(50, $stub->getMaxMessagesPerRequest()); + } + + public function testWithoutAttachmentsUsesBulkEndpoint(): void + { + $stub = new SESStub('key', 'secret', 'us-east-1'); + $stub->stubResponses[] = [ + 'statusCode' => 200, + 'response' => ['BulkEmailEntryResults' => [ + ['Status' => 'SUCCESS', 'MessageId' => 'a'], + ['Status' => 'SUCCESS', 'MessageId' => 'b'], + ]], + ]; + + $message = new Email( + to: [['email' => 'a@example.com'], ['email' => 'b@example.com']], + subject: 'Subject', + content: 'Body', + fromName: 'Sender', + fromEmail: 'from@example.com', + ); + + $response = $stub->send($message); + + $this->assertCount(1, $stub->capturedRequests); + $request = $stub->capturedRequests[0]; + + $this->assertSame('POST', $request['method']); + $this->assertStringEndsWith('/v2/email/outbound-bulk-emails', $request['url']); + $this->assertStringContainsString('email.us-east-1.amazonaws.com', $request['url']); + + // One BulkEmailEntry per recipient, each with a single ToAddresses entry. + $this->assertCount(2, $request['body']['BulkEmailEntries']); + $this->assertSame(['a@example.com'], $request['body']['BulkEmailEntries'][0]['Destination']['ToAddresses']); + $this->assertSame(['b@example.com'], $request['body']['BulkEmailEntries'][1]['Destination']['ToAddresses']); + + // The default content references a template by name. + $this->assertArrayHasKey('TemplateName', $request['body']['DefaultContent']['Template']); + $this->assertSame('Sender ', $request['body']['FromEmailAddress']); + + $this->assertSame(2, $response['deliveredTo']); + $this->assertSame('success', $response['results'][0]['status']); + $this->assertSame('success', $response['results'][1]['status']); + } + + public function testTemplateNameIsDeterministicForSameContent(): void + { + $stub = new SESStub('key', 'secret', 'us-east-1'); + $stub->stubResponses[] = ['statusCode' => 200, 'response' => ['BulkEmailEntryResults' => [['Status' => 'SUCCESS']]]]; + $stub->stubResponses[] = ['statusCode' => 200, 'response' => ['BulkEmailEntryResults' => [['Status' => 'SUCCESS']]]]; + + $build = fn () => new Email( + to: [['email' => 'a@example.com']], + subject: 'Same Subject', + content: 'Same Body', + fromName: 'Sender', + fromEmail: 'from@example.com', + ); + + $stub->send($build()); + $stub->send($build()); + + $first = $stub->capturedRequests[0]['body']['DefaultContent']['Template']['TemplateName']; + $second = $stub->capturedRequests[1]['body']['DefaultContent']['Template']['TemplateName']; + + $this->assertSame($first, $second); + $this->assertStringStartsWith('utopia-', $first); + } + + public function testTemplateNameDiffersForDifferentContent(): void + { + $stub = new SESStub('key', 'secret', 'us-east-1'); + $stub->stubResponses[] = ['statusCode' => 200, 'response' => ['BulkEmailEntryResults' => [['Status' => 'SUCCESS']]]]; + $stub->stubResponses[] = ['statusCode' => 200, 'response' => ['BulkEmailEntryResults' => [['Status' => 'SUCCESS']]]]; + + $stub->send(new Email( + to: [['email' => 'a@example.com']], + subject: 'Subject A', + content: 'Body', + fromName: 'Sender', + fromEmail: 'from@example.com', + )); + + $stub->send(new Email( + to: [['email' => 'a@example.com']], + subject: 'Subject B', + content: 'Body', + fromName: 'Sender', + fromEmail: 'from@example.com', + )); + + $first = $stub->capturedRequests[0]['body']['DefaultContent']['Template']['TemplateName']; + $second = $stub->capturedRequests[1]['body']['DefaultContent']['Template']['TemplateName']; + + $this->assertNotSame($first, $second); + } + + public function testTemplateNotFoundTriggersCreateAndRetry(): void + { + $stub = new SESStub('key', 'secret', 'us-east-1'); + + // 1) Bulk send: template missing (per-entry status). + $stub->stubResponses[] = [ + 'statusCode' => 200, + 'response' => ['BulkEmailEntryResults' => [['Status' => 'TEMPLATE_NOT_FOUND']]], + ]; + // 2) CreateEmailTemplate: created. + $stub->stubResponses[] = ['statusCode' => 200, 'response' => []]; + // 3) Bulk send retry: success. + $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', + html: true, + ); + + $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']); + + // The created template carries the message subject and HTML content. + $templateBody = $stub->capturedRequests[1]['body']; + $this->assertSame('Subject', $templateBody['TemplateContent']['Subject']); + $this->assertSame('

Body

', $templateBody['TemplateContent']['Html']); + $this->assertArrayNotHasKey('Text', $templateBody['TemplateContent']); + + $this->assertSame(1, $response['deliveredTo']); + $this->assertSame('success', $response['results'][0]['status']); + } + + public function testTextTemplateUsesTextContent(): void + { + $stub = new SESStub('key', 'secret', 'us-east-1'); + $stub->stubResponses[] = [ + 'statusCode' => 200, + 'response' => ['BulkEmailEntryResults' => [['Status' => 'TEMPLATE_NOT_FOUND']]], + ]; + $stub->stubResponses[] = ['statusCode' => 200, 'response' => []]; + $stub->stubResponses[] = [ + 'statusCode' => 200, + 'response' => ['BulkEmailEntryResults' => [['Status' => 'SUCCESS']]], + ]; + + $message = new Email( + to: [['email' => 'a@example.com']], + subject: 'Plain Subject', + content: 'Plain body', + fromName: 'Sender', + fromEmail: 'from@example.com', + ); + + $stub->send($message); + + $templateBody = $stub->capturedRequests[1]['body']; + $this->assertSame('Plain body', $templateBody['TemplateContent']['Text']); + $this->assertArrayNotHasKey('Html', $templateBody['TemplateContent']); + } + + public function testPartialFailureMapsPerRecipientResults(): void + { + $stub = new SESStub('key', 'secret', 'us-east-1'); + $stub->stubResponses[] = [ + 'statusCode' => 200, + 'response' => ['BulkEmailEntryResults' => [ + ['Status' => 'SUCCESS', 'MessageId' => 'ok'], + ['Status' => 'MESSAGE_REJECTED', 'Error' => 'Email address is not verified'], + ]], + ]; + + $message = new Email( + to: [['email' => 'good@example.com'], ['email' => 'bad@example.com']], + subject: 'Subject', + content: 'Body', + fromName: 'Sender', + fromEmail: 'from@example.com', + ); + + $response = $stub->send($message); + + $this->assertSame(1, $response['deliveredTo']); + $this->assertSame('success', $response['results'][0]['status']); + $this->assertSame('good@example.com', $response['results'][0]['recipient']); + $this->assertSame('failure', $response['results'][1]['status']); + $this->assertSame('bad@example.com', $response['results'][1]['recipient']); + $this->assertSame('Email address is not verified', $response['results'][1]['error']); + } + + public function testWholeRequestFailureMarksAllRecipientsFailed(): void + { + $stub = new SESStub('key', 'secret', 'us-east-1'); + $stub->stubResponses[] = [ + 'statusCode' => 400, + 'response' => ['message' => 'The sending domain is not verified'], + ]; + + $message = new Email( + to: [['email' => 'a@example.com'], ['email' => 'b@example.com']], + subject: 'Subject', + content: 'Body', + fromName: 'Sender', + fromEmail: 'from@example.com', + ); + + $response = $stub->send($message); + + $this->assertSame(0, $response['deliveredTo']); + foreach ($response['results'] as $result) { + $this->assertSame('failure', $result['status']); + $this->assertSame('The sending domain is not verified', $result['error']); + } + } + + public function testFiftyRecipientsProduceSingleBulkRequest(): void + { + $stub = new SESStub('key', 'secret', 'us-east-1'); + + $entryResults = []; + $recipients = []; + for ($i = 0; $i < 50; $i++) { + $recipients[] = ['email' => "user{$i}@example.com"]; + $entryResults[] = ['Status' => 'SUCCESS']; + } + + $stub->stubResponses[] = [ + 'statusCode' => 200, + 'response' => ['BulkEmailEntryResults' => $entryResults], + ]; + + $message = new Email( + to: $recipients, + subject: 'Subject', + content: 'Body', + fromName: 'Sender', + fromEmail: 'from@example.com', + ); + + $response = $stub->send($message); + + $this->assertCount(1, $stub->capturedRequests); + $this->assertCount(50, $stub->capturedRequests[0]['body']['BulkEmailEntries']); + $this->assertSame(50, $response['deliveredTo']); + } + + public function testExceedingFiftyRecipientsThrows(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('can only send 50 messages per request'); + + $stub = new SESStub('key', 'secret', 'us-east-1'); + + $recipients = []; + for ($i = 0; $i < 51; $i++) { + $recipients[] = ['email' => "user{$i}@example.com"]; + } + + $message = new Email( + to: $recipients, + subject: 'Subject', + content: 'Body', + fromName: 'Sender', + fromEmail: 'from@example.com', + ); + + $stub->send($message); + } + + public function testWithAttachmentsUsesSendEmailRawPerRecipient(): void + { + $stub = new SESStub('key', 'secret', 'us-east-1'); + $stub->stubResponses[] = ['statusCode' => 200, 'response' => ['MessageId' => 'one']]; + $stub->stubResponses[] = ['statusCode' => 200, 'response' => ['MessageId' => 'two']]; + + $message = new Email( + to: [['email' => 'a@example.com'], ['email' => 'b@example.com']], + subject: 'Subject', + content: 'Body', + fromName: 'Sender', + fromEmail: 'from@example.com', + attachments: [new Attachment( + name: 'note.txt', + path: '', + type: 'text/plain', + content: 'hello attachment', + )], + ); + + $response = $stub->send($message); + + $this->assertCount(2, $stub->capturedRequests); + + foreach ($stub->capturedRequests as $request) { + $this->assertStringEndsWith('/v2/email/outbound-emails', $request['url']); + $this->assertArrayHasKey('Raw', $request['body']['Content']); + + $mime = \base64_decode($request['body']['Content']['Raw']['Data']); + $this->assertStringContainsString('Subject: Subject', $mime); + $this->assertStringContainsString('note.txt', $mime); + // The attachment content is base64-encoded inside the MIME body. + $this->assertStringContainsString(\base64_encode('hello attachment'), $mime); + } + + $this->assertSame(2, $response['deliveredTo']); + } + + public function testAttachmentPartialFailureAggregatesResults(): void + { + $stub = new SESStub('key', 'secret', 'us-east-1'); + $stub->stubResponses[] = ['statusCode' => 200, 'response' => ['MessageId' => 'one']]; + $stub->stubResponses[] = ['statusCode' => 400, 'response' => ['message' => 'Invalid recipient']]; + + $message = new Email( + to: [['email' => 'a@example.com'], ['email' => 'b@example.com']], + subject: 'Subject', + content: 'Body', + fromName: 'Sender', + fromEmail: 'from@example.com', + attachments: [new Attachment( + name: 'note.txt', + path: '', + type: 'text/plain', + content: 'hello', + )], + ); + + $response = $stub->send($message); + + $this->assertSame(1, $response['deliveredTo']); + $this->assertSame('success', $response['results'][0]['status']); + $this->assertSame('failure', $response['results'][1]['status']); + $this->assertSame('Invalid recipient', $response['results'][1]['error']); + } + + public function testAttachmentExceedingMaxSizeThrows(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Total attachment size exceeds'); + + $stub = new SESStub('key', 'secret', 'us-east-1'); + + $message = new Email( + to: [['email' => 'a@example.com']], + subject: 'Subject', + content: 'Body', + fromName: 'Sender', + fromEmail: 'from@example.com', + attachments: [new Attachment( + name: 'large.bin', + path: '', + type: 'application/octet-stream', + content: \str_repeat('x', 25 * 1024 * 1024 + 1), + )], + ); + + $stub->send($message); + } + + public function testSessionTokenAddsSecurityTokenHeader(): void + { + $stub = new SESStub('key', 'secret', 'us-east-1', 'session-token-value'); + $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', + ); + + $stub->send($message); + + $headers = $stub->capturedRequests[0]['headers']; + $joined = \implode("\n", $headers); + + $this->assertStringContainsString('X-Amz-Security-Token: session-token-value', $joined); + // The signed headers list in the Authorization header must include it. + $this->assertStringContainsString('x-amz-security-token', $joined); + } +} + +/** + * Captures the requests the SES adapter would send and returns canned + * responses, so request building and routing can be asserted without network. + */ +class SESStub extends SES +{ + /** + * @var array, body: mixed}> + */ + public array $capturedRequests = []; + + /** + * @var array|string|null}> + */ + public array $stubResponses = []; + + /** + * @param array $headers + * @param array|null $body + * @return array{url: string, statusCode: int, response: array|string|null, error: string|null} + */ + protected function request( + string $method, + string $url, + array $headers = [], + ?array $body = null, + int $timeout = 30, + int $connectTimeout = 10 + ): array { + $this->capturedRequests[] = [ + 'method' => $method, + 'url' => $url, + 'headers' => $headers, + 'body' => $body, + ]; + + $stub = \array_shift($this->stubResponses) ?? ['statusCode' => 200, 'response' => []]; + + return [ + 'url' => $url, + 'statusCode' => $stub['statusCode'], + 'response' => $stub['response'], + 'error' => null, + ]; + } +} diff --git a/tests/Messaging/Adapter/Email/SESSigningTest.php b/tests/Messaging/Adapter/Email/SESSigningTest.php new file mode 100644 index 00000000..0e6e10a4 --- /dev/null +++ b/tests/Messaging/Adapter/Email/SESSigningTest.php @@ -0,0 +1,144 @@ +callSign( + method: 'GET', + path: '/', + payload: '', + signedHeaders: [ + 'host' => 'example.amazonaws.com', + 'x-amz-date' => '20150830T123600Z', + ], + amzDate: '20150830T123600Z', + ); + + $expected = 'AWS4-HMAC-SHA256 ' + .'Credential='.self::ACCESS_KEY.'/20150830/us-east-1/service/aws4_request, ' + .'SignedHeaders=host;x-amz-date, ' + .'Signature='.self::EXPECTED_SIGNATURE; + + $this->assertSame($expected, $authorization); + } + + public function testSignatureContainsExpectedHexSignature(): void + { + $signer = new SESSigningStub(self::ACCESS_KEY, self::SECRET_KEY, 'us-east-1'); + + $authorization = $signer->callSign( + method: 'GET', + path: '/', + payload: '', + signedHeaders: [ + 'host' => 'example.amazonaws.com', + 'x-amz-date' => '20150830T123600Z', + ], + amzDate: '20150830T123600Z', + ); + + $this->assertStringContainsString('Signature='.self::EXPECTED_SIGNATURE, $authorization); + } + + public function testHeadersAreSortedRegardlessOfInputOrder(): void + { + $signer = new SESSigningStub(self::ACCESS_KEY, self::SECRET_KEY, 'us-east-1'); + + // Supply headers out of order; SignedHeaders must still be sorted. + $authorization = $signer->callSign( + method: 'GET', + path: '/', + payload: '', + signedHeaders: [ + 'x-amz-date' => '20150830T123600Z', + 'host' => 'example.amazonaws.com', + ], + amzDate: '20150830T123600Z', + ); + + $this->assertStringContainsString('SignedHeaders=host;x-amz-date', $authorization); + $this->assertStringContainsString('Signature='.self::EXPECTED_SIGNATURE, $authorization); + } + + public function testDifferentPayloadProducesDifferentSignature(): void + { + $signer = new SESSigningStub(self::ACCESS_KEY, self::SECRET_KEY, 'us-east-1'); + + $empty = $signer->callSign( + method: 'GET', + path: '/', + payload: '', + signedHeaders: [ + 'host' => 'example.amazonaws.com', + 'x-amz-date' => '20150830T123600Z', + ], + amzDate: '20150830T123600Z', + ); + + $withBody = $signer->callSign( + method: 'GET', + path: '/', + payload: '{"hello":"world"}', + signedHeaders: [ + 'host' => 'example.amazonaws.com', + 'x-amz-date' => '20150830T123600Z', + ], + amzDate: '20150830T123600Z', + ); + + $this->assertNotSame($empty, $withBody); + } +} + +/** + * Exposes the protected sign() method and pins the SigV4 service name to the + * AWS test-vector value ('service') so the implementation can be checked + * against published vectors without hitting the network. + */ +class SESSigningStub extends SES +{ + public function __construct(string $accessKey, string $secretKey, string $region) + { + parent::__construct($accessKey, $secretKey, $region); + $this->service = 'service'; + } + + /** + * @param array $signedHeaders + */ + public function callSign(string $method, string $path, string $payload, array $signedHeaders, string $amzDate): string + { + return $this->sign($method, $path, $payload, $signedHeaders, $amzDate); + } +} diff --git a/tests/Messaging/Adapter/Email/SESTest.php b/tests/Messaging/Adapter/Email/SESTest.php new file mode 100644 index 00000000..ace98017 --- /dev/null +++ b/tests/Messaging/Adapter/Email/SESTest.php @@ -0,0 +1,128 @@ +testEmail = \getenv('SES_TEST_EMAIL') ?: ''; + $sessionToken = \getenv('SES_SESSION_TOKEN') ?: null; + + if ($accessKey === '' || $secretKey === '' || $region === '' || $this->testEmail === '') { + $this->markTestSkipped('SES credentials are not configured.'); + } + + $this->sender = new SES($accessKey, $secretKey, $region, $sessionToken); + } + + public function testSendEmail(): void + { + $message = new Email( + to: [$this->testEmail], + subject: 'Test Subject', + content: 'Test Content', + fromName: 'Test Sender', + fromEmail: $this->testEmail, + ); + + $response = $this->sender->send($message); + + $this->assertResponse($response); + } + + public function testSendEmailWithHtml(): void + { + $message = new Email( + to: [$this->testEmail], + subject: 'Test HTML Subject', + content: '

Test HTML Content

This is a test email.

', + fromName: 'Test Sender', + fromEmail: $this->testEmail, + html: true, + ); + + $response = $this->sender->send($message); + + $this->assertResponse($response); + } + + public function testSendEmailWithReplyTo(): void + { + $message = new Email( + to: [$this->testEmail], + subject: 'Test Reply-To Subject', + content: 'Test Content with Reply-To', + fromName: 'Test Sender', + fromEmail: $this->testEmail, + replyToName: 'Reply To Name', + replyToEmail: $this->testEmail, + ); + + $response = $this->sender->send($message); + + $this->assertResponse($response); + } + + public function testSendMultipleEmails(): void + { + $message = new Email( + to: [$this->testEmail, $this->testEmail], + subject: 'Test Batch Subject', + content: 'Test Batch Content', + fromName: 'Test Sender', + fromEmail: $this->testEmail, + ); + + $response = $this->sender->send($message); + + $this->assertSame(2, $response['deliveredTo'], \var_export($response, true)); + $this->assertSame('success', $response['results'][0]['status'], \var_export($response, true)); + $this->assertSame('success', $response['results'][1]['status'], \var_export($response, true)); + } + + public function testSendEmailWithStringAttachment(): void + { + $message = new Email( + to: [$this->testEmail], + subject: 'Test String Attachment', + content: 'Test Content with string attachment', + fromName: 'Test Sender', + fromEmail: $this->testEmail, + attachments: [new Attachment( + name: 'test.txt', + path: '', + type: 'text/plain', + content: 'Hello, this is a test attachment.', + )], + ); + + $response = $this->sender->send($message); + + $this->assertResponse($response); + } +} From 528432120681def6f7ec2a57e470f608b6ced825 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 3 Jun 2026 02:11:15 +1200 Subject: [PATCH 2/2] fix(SES): address review feedback on bulk send and limits Resolves the review comments on the SES adapter: - Bulk path now sets CcAddresses/BccAddresses on each BulkEmailEntry Destination; previously CC/BCC recipients on attachment-free sends were silently dropped. - formatAddress() wraps display names containing RFC 5322 specials in a quoted-string, so a name like "Acme, Inc." no longer produces a malformed address that SES rejects with a 400. - Template names are truncated to SES's 64-character limit; the prefix plus a full SHA-256 hex was 71 chars, which failed every CreateEmailTemplate. - A 2xx SendBulkEmail response without parseable per-recipient results is now reported as a failure for all recipients instead of false-positive successes. - MAX_ATTACHMENT_BYTES is overridden to SES's real 10MB message limit, and the assembled MIME size is validated so oversized sends (including base64/MIME overhead) fail fast with a clear exception. - Documents that content-hashed templates are never deleted and accumulate toward the per-account template quota. Adds routing-test coverage for CC/BCC forwarding, address quoting, the template-name length cap, the unparseable-success failure path, and the MIME size limit. Co-Authored-By: Claude Opus 4.8 --- src/Utopia/Messaging/Adapter/Email/SES.php | 97 +++++++++-- .../Adapter/Email/SESRoutingTest.php | 150 ++++++++++++++++++ 2 files changed, 234 insertions(+), 13 deletions(-) diff --git a/src/Utopia/Messaging/Adapter/Email/SES.php b/src/Utopia/Messaging/Adapter/Email/SES.php index d2e8fc72..bc3db406 100644 --- a/src/Utopia/Messaging/Adapter/Email/SES.php +++ b/src/Utopia/Messaging/Adapter/Email/SES.php @@ -22,6 +22,11 @@ * `Content.Raw` MIME payload, one request per recipient, because * SES templates cannot carry attachments. * + * Templates created by the bulk path are never deleted, so one persists per + * unique (subject, content, isHtml) triple. High-variety or multi-tenant + * senders should periodically purge stale `utopia-` templates to stay under + * the per-account template quota (default 20,000). + * * Authentication is AWS Signature Version 4, hand-rolled (no AWS SDK * dependency), supporting both long-lived credentials and temporary * credentials via an optional session token. @@ -50,12 +55,34 @@ class SES extends EmailAdapter */ protected const MAX_DESTINATIONS = 50; + /** + * SES caps a full MIME message (after base64 encoding of attachments) at + * 10MB, well below the 25MB adapter default. Enforcing the real limit lets + * oversized sends fail fast instead of being rejected by SES. + * + * @link https://docs.aws.amazon.com/ses/latest/dg/quotas.html + */ + protected const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024; // 10MB + /** * The SES BulkEmailEntryResult status that indicates the message was * accepted. Any other status is treated as a per-recipient failure. */ protected const STATUS_SUCCESS = 'SUCCESS'; + /** + * Prefix for the deterministic, content-hashed template names. + */ + protected const TEMPLATE_NAME_PREFIX = 'utopia-'; + + /** + * SES limits template names to 64 characters, so the content hash is + * truncated to fit alongside the prefix. + * + * @link https://docs.aws.amazon.com/ses/latest/APIReference-V2/API_CreateEmailTemplate.html + */ + protected const TEMPLATE_NAME_MAX_LENGTH = 64; + /** * Tracks template names this instance has already ensured exist, so the * same send does not re-issue CreateEmailTemplate for every batch. @@ -115,17 +142,35 @@ private function sendBulk(EmailMessage $message, Response $response): array { $templateName = $this->templateName($message); + $cc = \array_map( + fn ($recipient) => $this->formatAddress($recipient['email'], $recipient['name'] ?? null), + $message->getCC() ?? [] + ); + $bcc = \array_map( + fn ($recipient) => $this->formatAddress($recipient['email'], $recipient['name'] ?? null), + $message->getBCC() ?? [] + ); + $entries = \array_map( - fn ($to) => [ - 'Destination' => [ - 'ToAddresses' => [$to['email']], - ], - 'ReplacementEmailContent' => [ - 'ReplacementTemplate' => [ - 'ReplacementTemplateData' => '{}', + function ($to) use ($cc, $bcc) { + $destination = ['ToAddresses' => [$to['email']]]; + + if (! empty($cc)) { + $destination['CcAddresses'] = $cc; + } + if (! empty($bcc)) { + $destination['BccAddresses'] = $bcc; + } + + return [ + 'Destination' => $destination, + 'ReplacementEmailContent' => [ + 'ReplacementTemplate' => [ + 'ReplacementTemplateData' => '{}', + ], ], - ], - ], + ]; + }, $message->getTo() ); @@ -175,6 +220,10 @@ private function sendRaw(EmailMessage $message, Response $response): array foreach ($message->getTo() as $to) { $mime = $this->buildMime($message, $to); + if (\strlen($mime) > self::MAX_ATTACHMENT_BYTES) { + throw new \Exception('MIME message size exceeds SES limit of '.self::MAX_ATTACHMENT_BYTES.' bytes'); + } + $body = [ 'FromEmailAddress' => $this->formatAddress($message->getFromEmail(), $message->getFromName()), 'Destination' => [ @@ -239,10 +288,12 @@ private function parseBulkResult(EmailMessage $message, array $result, Response : null; if (! \is_array($entryResults)) { - // 2xx without a parseable body: treat the whole batch as delivered. - $response->setDeliveredTo(\count($recipients)); + // 2xx without parseable BulkEmailEntryResults: per-recipient + // delivery cannot be confirmed, so report failure rather than + // false-positive successes. + $error = 'SES returned a success status without per-recipient results'; foreach ($recipients as $to) { - $response->addResult($to['email']); + $response->addResult($to['email'], $error); } return $response->toArray(); @@ -305,6 +356,15 @@ private function ensureTemplate(EmailMessage $message, string $templateName): vo /** * Derive a deterministic, SES-valid template name from the message content * so identical content reuses a single template across batches and sends. + * + * The SHA-256 hash is truncated so the prefixed name stays within the SES + * 64-character template-name limit; the retained length still leaves ample + * entropy to keep distinct content on distinct templates. + * + * Note: templates created via {@see ensureTemplate()} are never deleted, so + * one persists per unique (subject, content, isHtml) triple. High-variety + * or multi-tenant senders should periodically purge stale `utopia-` + * templates to stay under the per-account template quota (default 20,000). */ private function templateName(EmailMessage $message): string { @@ -314,7 +374,9 @@ private function templateName(EmailMessage $message): string $message->isHtml() ? '1' : '0', ])); - return 'utopia-'.$hash; + $hashLength = self::TEMPLATE_NAME_MAX_LENGTH - \strlen(self::TEMPLATE_NAME_PREFIX); + + return self::TEMPLATE_NAME_PREFIX.\substr($hash, 0, $hashLength); } /** @@ -435,6 +497,11 @@ private function assertAttachmentSize(EmailMessage $message): void /** * Format an email address with an optional display name (RFC 5322). + * + * When the display name contains any RFC 5322 special character it is + * wrapped in a quoted-string (with embedded quotes and backslashes + * escaped). Without this, a name such as "Acme, Inc." produces a malformed + * address that SES rejects with a 400. */ private function formatAddress(string $email, ?string $name): string { @@ -442,6 +509,10 @@ private function formatAddress(string $email, ?string $name): string return $email; } + if (\preg_match('/[,;:@<>()\[\]\\\\".]/', $name)) { + $name = '"'.\addcslashes($name, '"\\').'"'; + } + return "{$name} <{$email}>"; } diff --git a/tests/Messaging/Adapter/Email/SESRoutingTest.php b/tests/Messaging/Adapter/Email/SESRoutingTest.php index ab2e8b87..f0530e8d 100644 --- a/tests/Messaging/Adapter/Email/SESRoutingTest.php +++ b/tests/Messaging/Adapter/Email/SESRoutingTest.php @@ -409,6 +409,156 @@ public function testSessionTokenAddsSecurityTokenHeader(): void // The signed headers list in the Authorization header must include it. $this->assertStringContainsString('x-amz-security-token', $joined); } + + public function testBulkEntriesIncludeCcAndBcc(): void + { + $stub = new SESStub('key', 'secret', 'us-east-1'); + $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', + cc: [['email' => 'cc@example.com', 'name' => 'CC Person']], + bcc: [['email' => 'bcc@example.com']], + ); + + $stub->send($message); + + $destination = $stub->capturedRequests[0]['body']['BulkEmailEntries'][0]['Destination']; + + $this->assertSame(['a@example.com'], $destination['ToAddresses']); + $this->assertSame(['CC Person '], $destination['CcAddresses']); + $this->assertSame(['bcc@example.com'], $destination['BccAddresses']); + } + + public function testBulkEntriesOmitCcAndBccWhenAbsent(): void + { + $stub = new SESStub('key', 'secret', 'us-east-1'); + $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', + ); + + $stub->send($message); + + $destination = $stub->capturedRequests[0]['body']['BulkEmailEntries'][0]['Destination']; + + $this->assertArrayNotHasKey('CcAddresses', $destination); + $this->assertArrayNotHasKey('BccAddresses', $destination); + } + + public function testDisplayNameWithSpecialCharactersIsQuoted(): void + { + $stub = new SESStub('key', 'secret', 'us-east-1'); + $stub->stubResponses[] = [ + 'statusCode' => 200, + 'response' => ['BulkEmailEntryResults' => [['Status' => 'SUCCESS']]], + ]; + + $message = new Email( + to: [['email' => 'a@example.com']], + subject: 'Subject', + content: 'Body', + fromName: 'Acme, Inc.', + fromEmail: 'from@example.com', + ); + + $stub->send($message); + + // A name containing RFC 5322 specials must be quoted or SES rejects it. + $this->assertSame( + '"Acme, Inc." ', + $stub->capturedRequests[0]['body']['FromEmailAddress'] + ); + } + + public function testTemplateNameRespectsSesLengthLimit(): void + { + $stub = new SESStub('key', 'secret', 'us-east-1'); + $stub->stubResponses[] = [ + 'statusCode' => 200, + 'response' => ['BulkEmailEntryResults' => [['Status' => 'SUCCESS']]], + ]; + + $message = new Email( + to: [['email' => 'a@example.com']], + subject: \str_repeat('long subject ', 64), + content: \str_repeat('long body ', 64), + fromName: 'Sender', + fromEmail: 'from@example.com', + ); + + $stub->send($message); + + $templateName = $stub->capturedRequests[0]['body']['DefaultContent']['Template']['TemplateName']; + + $this->assertLessThanOrEqual(64, \strlen($templateName)); + $this->assertStringStartsWith('utopia-', $templateName); + } + + public function testSuccessWithoutEntryResultsMarksAllRecipientsFailed(): void + { + $stub = new SESStub('key', 'secret', 'us-east-1'); + // A 2xx whose body carries no BulkEmailEntryResults must not be reported + // as a delivery, since per-recipient status cannot be confirmed. + $stub->stubResponses[] = ['statusCode' => 200, 'response' => []]; + + $message = new Email( + to: [['email' => 'a@example.com'], ['email' => 'b@example.com']], + subject: 'Subject', + content: 'Body', + fromName: 'Sender', + fromEmail: 'from@example.com', + ); + + $response = $stub->send($message); + + $this->assertSame(0, $response['deliveredTo']); + foreach ($response['results'] as $result) { + $this->assertSame('failure', $result['status']); + $this->assertNotSame('', $result['error']); + } + } + + public function testMimeExceedingSesLimitThrows(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('MIME message size exceeds SES limit'); + + $stub = new SESStub('key', 'secret', 'us-east-1'); + + // ~8MB of raw content clears the raw-attachment check (< 10MB) but its + // base64-encoded MIME exceeds the SES 10MB message limit. + $message = new Email( + to: [['email' => 'a@example.com']], + subject: 'Subject', + content: 'Body', + fromName: 'Sender', + fromEmail: 'from@example.com', + attachments: [new Attachment( + name: 'big.bin', + path: '', + type: 'application/octet-stream', + content: \str_repeat('x', 8 * 1024 * 1024), + )], + ); + + $stub->send($message); + } } /**