diff --git a/README.md b/README.md index b4b3673..f107048 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 0000000..bc3db40 --- /dev/null +++ b/src/Utopia/Messaging/Adapter/Email/SES.php @@ -0,0 +1,704 @@ + + */ + 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); + + $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( + 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() + ); + + $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); + + 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' => [ + '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 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'], $error); + } + + 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. + * + * 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 + { + $hash = \hash('sha256', \implode("\0", [ + $message->getSubject(), + $message->getContent(), + $message->isHtml() ? '1' : '0', + ])); + + $hashLength = self::TEMPLATE_NAME_MAX_LENGTH - \strlen(self::TEMPLATE_NAME_PREFIX); + + return self::TEMPLATE_NAME_PREFIX.\substr($hash, 0, $hashLength); + } + + /** + * 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). + * + * 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 + { + if (empty($name)) { + return $email; + } + + if (\preg_match('/[,;:@<>()\[\]\\\\".]/', $name)) { + $name = '"'.\addcslashes($name, '"\\').'"'; + } + + 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 0000000..f0530e8 --- /dev/null +++ b/tests/Messaging/Adapter/Email/SESRoutingTest.php @@ -0,0 +1,609 @@ +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); + } + + 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); + } +} + +/** + * 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 0000000..0e6e10a --- /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 0000000..ace9801 --- /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); + } +}