diff --git a/composer.json b/composer.json index 1bdaedca..db7c1ff3 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ } }, "require": { - "php": ">=8.0.0", + "php": ">=8.1.0", "ext-curl": "*", "ext-openssl": "*", "phpmailer/phpmailer": "6.9.1", diff --git a/composer.lock b/composer.lock index e341ecad..b7856622 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "dacd89729811e12e6007426189589c80", + "content-hash": "fb98182a4b49a3d30c785a6e888722b3", "packages": [ { "name": "giggsey/libphonenumber-for-php-lite", @@ -254,16 +254,16 @@ "packages-dev": [ { "name": "laravel/pint", - "version": "v1.27.0", + "version": "v1.29.0", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "c67b4195b75491e4dfc6b00b1c78b68d86f54c90" + "reference": "bdec963f53172c5e36330f3a400604c69bf02d39" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/c67b4195b75491e4dfc6b00b1c78b68d86f54c90", - "reference": "c67b4195b75491e4dfc6b00b1c78b68d86f54c90", + "url": "https://api.github.com/repos/laravel/pint/zipball/bdec963f53172c5e36330f3a400604c69bf02d39", + "reference": "bdec963f53172c5e36330f3a400604c69bf02d39", "shasum": "" }, "require": { @@ -274,13 +274,14 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.92.4", - "illuminate/view": "^12.44.0", - "larastan/larastan": "^3.8.1", - "laravel-zero/framework": "^12.0.4", + "friendsofphp/php-cs-fixer": "^3.94.2", + "illuminate/view": "^12.54.1", + "larastan/larastan": "^3.9.3", + "laravel-zero/framework": "^12.0.5", "mockery/mockery": "^1.6.12", - "nunomaduro/termwind": "^2.3.3", - "pestphp/pest": "^3.8.4" + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest": "^3.8.6", + "shipfastlabs/agent-detector": "^1.1.0" }, "bin": [ "builds/pint" @@ -317,7 +318,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2026-01-05T16:49:17+00:00" + "time": "2026-03-12T15:51:39+00:00" }, { "name": "myclabs/deep-copy", @@ -557,11 +558,11 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.32", + "version": "1.12.33", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/2770dcdf5078d0b0d53f94317e06affe88419aa8", - "reference": "2770dcdf5078d0b0d53f94317e06affe88419aa8", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/37982d6fc7cbb746dda7773530cda557cdf119e1", + "reference": "37982d6fc7cbb746dda7773530cda557cdf119e1", "shasum": "" }, "require": { @@ -606,7 +607,7 @@ "type": "github" } ], - "time": "2025-09-30T10:16:31+00:00" + "time": "2026-02-28T20:30:03+00:00" }, { "name": "phpunit/php-code-coverage", @@ -957,16 +958,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.51", + "version": "11.5.55", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "ad14159f92910b0f0e3928c13e9b2077529de091" + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/ad14159f92910b0f0e3928c13e9b2077529de091", - "reference": "ad14159f92910b0f0e3928c13e9b2077529de091", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00", + "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00", "shasum": "" }, "require": { @@ -1039,7 +1040,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.51" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55" }, "funding": [ { @@ -1063,7 +1064,7 @@ "type": "tidelift" } ], - "time": "2026-02-05T07:59:30+00:00" + "time": "2026-02-18T12:37:06+00:00" }, { "name": "sebastian/cli-parser", @@ -2160,7 +2161,7 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=8.0.0", + "php": ">=8.1.0", "ext-curl": "*", "ext-openssl": "*" }, @@ -2168,5 +2169,5 @@ "platform-overrides": { "php": "8.3" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/src/Utopia/Messaging/Adapter/Email/Mailgun.php b/src/Utopia/Messaging/Adapter/Email/Mailgun.php index a955b44c..63933f2e 100644 --- a/src/Utopia/Messaging/Adapter/Email/Mailgun.php +++ b/src/Utopia/Messaging/Adapter/Email/Mailgun.php @@ -51,24 +51,32 @@ protected function process(EmailMessage $message): array $domain = $this->isEU ? $euDomain : $usDomain; + $recipients = $message->getTo(); + $toEmails = \array_map(fn ($to) => $to['email'], $recipients); + $body = [ - 'to' => \implode(',', $message->getTo()), - 'from' => "{$message->getFromName()}<{$message->getFromEmail()}>", + 'to' => \implode(',', \array_map( + fn ($to) => !empty($to['name']) + ? "{$to['name']} <{$to['email']}>" + : $to['email'], + $recipients + )), + 'from' => "{$message->getFromName()} <{$message->getFromEmail()}>", 'subject' => $message->getSubject(), 'text' => $message->isHtml() ? null : $message->getContent(), 'html' => $message->isHtml() ? $message->getContent() : null, - 'h:Reply-To: '."{$message->getReplyToName()}<{$message->getReplyToEmail()}>", + 'h:Reply-To: '."{$message->getReplyToName()} <{$message->getReplyToEmail()}>", ]; - if (\count($message->getTo()) > 1) { - $body['recipient-variables'] = json_encode(array_fill_keys($message->getTo(), [])); + if (\count($recipients) > 1) { + $body['recipient-variables'] = json_encode(array_fill_keys($toEmails, [])); } if (!\is_null($message->getCC())) { foreach ($message->getCC() as $cc) { if (!empty($cc['email'])) { $ccString = !empty($cc['name']) - ? "{$cc['name']}<{$cc['email']}>" + ? "{$cc['name']} <{$cc['email']}>" : $cc['email']; $body['cc'] = !empty($body['cc']) @@ -82,7 +90,7 @@ protected function process(EmailMessage $message): array foreach ($message->getBCC() as $bcc) { if (!empty($bcc['email'])) { $bccString = !empty($bcc['name']) - ? "{$bcc['name']}<{$bcc['email']}>" + ? "{$bcc['name']} <{$bcc['email']}>" : $bcc['email']; $body['bcc'] = !empty($body['bcc']) @@ -140,16 +148,16 @@ protected function process(EmailMessage $message): array if ($statusCode >= 200 && $statusCode < 300) { $response->setDeliveredTo(\count($message->getTo())); foreach ($message->getTo() as $to) { - $response->addResult($to); + $response->addResult($to['email']); } } elseif ($statusCode >= 400 && $statusCode < 500) { foreach ($message->getTo() as $to) { if (\is_string($result['response'])) { - $response->addResult($to, $result['response']); + $response->addResult($to['email'], $result['response']); } elseif (isset($result['response']['message'])) { - $response->addResult($to, $result['response']['message']); + $response->addResult($to['email'], $result['response']['message']); } else { - $response->addResult($to, 'Unknown error'); + $response->addResult($to['email'], 'Unknown error'); } } } diff --git a/src/Utopia/Messaging/Adapter/Email/Mock.php b/src/Utopia/Messaging/Adapter/Email/Mock.php index 54ff4e95..f91b5e8e 100644 --- a/src/Utopia/Messaging/Adapter/Email/Mock.php +++ b/src/Utopia/Messaging/Adapter/Email/Mock.php @@ -47,7 +47,7 @@ protected function process(EmailMessage $message): array $mail->isHTML($message->isHtml()); foreach ($message->getTo() as $to) { - $mail->addAddress($to); + $mail->addAddress($to['email'], $to['name'] ?? ''); } if (!empty($message->getCC())) { @@ -64,12 +64,12 @@ protected function process(EmailMessage $message): array if (!$mail->send()) { foreach ($message->getTo() as $to) { - $response->addResult($to, $mail->ErrorInfo); + $response->addResult($to['email'], $mail->ErrorInfo); } } else { $response->setDeliveredTo(\count($message->getTo())); foreach ($message->getTo() as $to) { - $response->addResult($to); + $response->addResult($to['email']); } } diff --git a/src/Utopia/Messaging/Adapter/Email/Resend.php b/src/Utopia/Messaging/Adapter/Email/Resend.php index 7ad0ff69..ac15f67b 100644 --- a/src/Utopia/Messaging/Adapter/Email/Resend.php +++ b/src/Utopia/Messaging/Adapter/Email/Resend.php @@ -77,11 +77,15 @@ protected function process(EmailMessage $message): array $emails = []; foreach ($message->getTo() as $to) { + $toFormatted = !empty($to['name']) + ? "{$to['name']} <{$to['email']}>" + : $to['email']; + $email = [ 'from' => $message->getFromName() ? "{$message->getFromName()} <{$message->getFromEmail()}>" : $message->getFromEmail(), - 'to' => [$to], + 'to' => [$toFormatted], 'subject' => $message->getSubject(), ]; @@ -98,17 +102,13 @@ protected function process(EmailMessage $message): array } if (! \is_null($message->getCC()) && ! empty($message->getCC())) { - $ccList = []; - foreach ($message->getCC() as $cc) { - if (! empty($cc['email'])) { - $ccList[] = ! empty($cc['name']) - ? "{$cc['name']} <{$cc['email']}>" - : $cc['email']; - } - } - if (! empty($ccList)) { - $email['cc'] = $ccList; - } + $ccList = \array_map( + fn ($cc) => ! empty($cc['name']) + ? "{$cc['name']} <{$cc['email']}>" + : $cc['email'], + $message->getCC() + ); + $email['cc'] = $ccList; } if (! empty($attachments)) { @@ -116,17 +116,13 @@ protected function process(EmailMessage $message): array } if (! \is_null($message->getBCC()) && ! empty($message->getBCC())) { - $bccList = []; - foreach ($message->getBCC() as $bcc) { - if (! empty($bcc['email'])) { - $bccList[] = ! empty($bcc['name']) - ? "{$bcc['name']} <{$bcc['email']}>" - : $bcc['email']; - } - } - if (! empty($bccList)) { - $email['bcc'] = $bccList; - } + $bccList = \array_map( + fn ($bcc) => ! empty($bcc['name']) + ? "{$bcc['name']} <{$bcc['email']}>" + : $bcc['email'], + $message->getBCC() + ); + $email['bcc'] = $bccList; } $emails[] = $email; @@ -157,9 +153,9 @@ protected function process(EmailMessage $message): array foreach ($message->getTo() as $index => $to) { if (isset($failedIndices[$index])) { - $response->addResult($to, $failedIndices[$index]); + $response->addResult($to['email'], $failedIndices[$index]); } else { - $response->addResult($to); + $response->addResult($to['email']); } } @@ -168,7 +164,7 @@ protected function process(EmailMessage $message): array } else { $response->setDeliveredTo(\count($message->getTo())); foreach ($message->getTo() as $to) { - $response->addResult($to); + $response->addResult($to['email']); } } } elseif ($statusCode >= 400 && $statusCode < 500) { @@ -183,7 +179,7 @@ protected function process(EmailMessage $message): array } foreach ($message->getTo() as $to) { - $response->addResult($to, $errorMessage); + $response->addResult($to['email'], $errorMessage); } } elseif ($statusCode >= 500) { $errorMessage = 'Server error'; @@ -195,7 +191,7 @@ protected function process(EmailMessage $message): array } foreach ($message->getTo() as $to) { - $response->addResult($to, $errorMessage); + $response->addResult($to['email'], $errorMessage); } } diff --git a/src/Utopia/Messaging/Adapter/Email/SMTP.php b/src/Utopia/Messaging/Adapter/Email/SMTP.php index 28067fc8..7179f505 100644 --- a/src/Utopia/Messaging/Adapter/Email/SMTP.php +++ b/src/Utopia/Messaging/Adapter/Email/SMTP.php @@ -97,7 +97,7 @@ protected function process(EmailMessage $message): array $mail->AltBody = \trim($mail->AltBody); foreach ($message->getTo() as $to) { - $mail->addAddress($to); + $mail->addAddress($to['email'], $to['name'] ?? ''); } if (!empty($message->getCC())) { @@ -158,10 +158,10 @@ protected function process(EmailMessage $message): array ? 'Unknown error' : $mail->ErrorInfo; - $response->addResult($to, $sent ? '' : $error); + $response->addResult($to['email'], $sent ? '' : $error); } - foreach ($message->getCC() as $cc) { + foreach ($message->getCC() ?? [] as $cc) { $error = empty($mail->ErrorInfo) ? 'Unknown error' : $mail->ErrorInfo; @@ -169,7 +169,7 @@ protected function process(EmailMessage $message): array $response->addResult($cc['email'], $sent ? '' : $error); } - foreach ($message->getBCC() as $bcc) { + foreach ($message->getBCC() ?? [] as $bcc) { $error = empty($mail->ErrorInfo) ? 'Unknown error' : $mail->ErrorInfo; diff --git a/src/Utopia/Messaging/Adapter/Email/Sendgrid.php b/src/Utopia/Messaging/Adapter/Email/Sendgrid.php index 8ea0cb01..7b5620df 100644 --- a/src/Utopia/Messaging/Adapter/Email/Sendgrid.php +++ b/src/Utopia/Messaging/Adapter/Email/Sendgrid.php @@ -45,7 +45,9 @@ protected function process(EmailMessage $message): array { $personalizations = \array_map( fn ($to) => [ - 'to' => [['email' => $to]], + 'to' => [!empty($to['name']) + ? ['email' => $to['email'], 'name' => $to['name']] + : ['email' => $to['email']]], 'subject' => $message->getSubject(), ], $message->getTo() @@ -138,16 +140,16 @@ protected function process(EmailMessage $message): array if ($statusCode === 202) { $response->setDeliveredTo(\count($message->getTo())); foreach ($message->getTo() as $to) { - $response->addResult($to); + $response->addResult($to['email']); } } else { foreach ($message->getTo() as $to) { if (\is_string($result['response'])) { - $response->addResult($to, $result['response']); + $response->addResult($to['email'], $result['response']); } elseif (!\is_null($result['response']['errors'][0]['message'] ?? null)) { - $response->addResult($to, $result['response']['errors'][0]['message']); + $response->addResult($to['email'], $result['response']['errors'][0]['message']); } else { - $response->addResult($to, 'Unknown error'); + $response->addResult($to['email'], 'Unknown error'); } } } diff --git a/src/Utopia/Messaging/Messages/Email.php b/src/Utopia/Messaging/Messages/Email.php index 49c45736..877394a4 100644 --- a/src/Utopia/Messaging/Messages/Email.php +++ b/src/Utopia/Messaging/Messages/Email.php @@ -8,33 +8,50 @@ class Email implements Message { /** - * @param array $to The recipients of the email. + * @var array> + */ + private array $to; + + /** + * @var array>|null + */ + private ?array $cc; + + /** + * @var array>|null + */ + private ?array $bcc; + + /** + * @param array> $to The recipients of the email. Each entry can be an email string or an associative array with 'email' and optional 'name' keys. * @param string $subject The subject of the email. * @param string $content The content of the email. * @param string $fromName The name of the sender. * @param string $fromEmail The email address of the sender. - * @param array>|null $cc . The CC recipients of the email. Each recipient should be an array containing a "name" and an "email" key. - * @param array>|null $bcc . The BCC recipients of the email. Each recipient should be an array containing a "name" and an "email" key. * @param string|null $replyToName The name of the reply to. * @param string|null $replyToEmail The email address of the reply to. + * @param array>|null $cc The CC recipients of the email. Same format as $to. + * @param array>|null $bcc The BCC recipients of the email. Same format as $to. * @param array|null $attachments The attachments of the email. * @param bool $html Whether the message is HTML or not. - * - * @throws \InvalidArgumentException */ public function __construct( - private array $to, + array $to, private string $subject, private string $content, private string $fromName, private string $fromEmail, private ?string $replyToName = null, private ?string $replyToEmail = null, - private ?array $cc = null, - private ?array $bcc = null, + ?array $cc = null, + ?array $bcc = null, private ?array $attachments = null, private bool $html = false, ) { + $this->to = \array_map(self::normalizeRecipient(...), $to); + $this->cc = !\is_null($cc) ? \array_map(self::normalizeRecipient(...), $cc) : null; + $this->bcc = !\is_null($bcc) ? \array_map(self::normalizeRecipient(...), $bcc) : null; + if (\is_null($this->replyToName)) { $this->replyToName = $this->fromName; } @@ -42,26 +59,33 @@ public function __construct( if (\is_null($this->replyToEmail)) { $this->replyToEmail = $this->fromEmail; } + } - if (!\is_null($this->cc)) { - foreach ($this->cc as $recipient) { - if (!isset($recipient['email'])) { - throw new \InvalidArgumentException('Each CC recipient must have at least an email'); - } + /** + * Normalize a recipient entry to an associative array with 'email' and optional 'name' keys. + * + * @param string|array $value + * @return array + */ + private static function normalizeRecipient(string|array $value): array + { + if (\is_string($value)) { + if ($value === '') { + throw new \InvalidArgumentException('Recipient email must not be empty.'); } + + return ['email' => $value]; } - if (!\is_null($this->bcc)) { - foreach ($this->bcc as $recipient) { - if (!isset($recipient['email'])) { - throw new \InvalidArgumentException('Each BCC recipient must have at least an email'); - } - } + if (!isset($value['email']) || $value['email'] === '') { + throw new \InvalidArgumentException('Each recipient must have a non-empty "email" key.'); } + + return $value; } /** - * @return array + * @return array> */ public function getTo(): array { @@ -99,7 +123,7 @@ public function getReplyToEmail(): string } /** - * @return array>|null + * @return array>|null */ public function getCC(): ?array { @@ -107,7 +131,7 @@ public function getCC(): ?array } /** - * @return array>|null + * @return array>|null */ public function getBCC(): ?array { @@ -115,7 +139,7 @@ public function getBCC(): ?array } /** - * @return array|null + * @return array|null */ public function getAttachments(): ?array { diff --git a/tests/Messaging/Adapter/Email/EmailTest.php b/tests/Messaging/Adapter/Email/EmailTest.php index 0782fed5..7997aac9 100644 --- a/tests/Messaging/Adapter/Email/EmailTest.php +++ b/tests/Messaging/Adapter/Email/EmailTest.php @@ -43,4 +43,139 @@ public function testSendEmail(): void $this->assertEquals($cc[0]['email'], $lastEmail['cc'][0]['address']); $this->assertEquals($bcc[0]['email'], $lastEmail['envelope']['to'][2]['address']); } + + public function testSendEmailWithNamedToRecipient(): void + { + $sender = new Mock(); + + $message = new Email( + to: [['email' => 'tester@localhost.test', 'name' => 'Test User']], + subject: 'Named To Test', + content: 'Test Content', + fromName: 'Test Sender', + fromEmail: 'sender@localhost.test', + ); + + $response = $sender->send($message); + + $lastEmail = $this->getLastEmail(); + + $this->assertResponse($response); + $this->assertEquals('tester@localhost.test', $lastEmail['to'][0]['address']); + $this->assertEquals('Test User', $lastEmail['to'][0]['name']); + } + + public function testSendEmailWithMixedToFormats(): void + { + $sender = new Mock(); + + $message = new Email( + to: [ + 'plain@localhost.test', + ['email' => 'named@localhost.test', 'name' => 'Named User'], + ], + subject: 'Mixed To Test', + content: 'Test Content', + fromName: 'Test Sender', + fromEmail: 'sender@localhost.test', + ); + + $response = $sender->send($message); + + $this->assertEquals(2, $response['deliveredTo']); + $this->assertEquals('success', $response['results'][0]['status']); + $this->assertEquals('success', $response['results'][1]['status']); + + // Verify both recipients are normalized to array format + $to = $message->getTo(); + $this->assertEquals('plain@localhost.test', $to[0]['email']); + $this->assertArrayNotHasKey('name', $to[0]); + $this->assertEquals('named@localhost.test', $to[1]['email']); + $this->assertEquals('Named User', $to[1]['name']); + } + + public function testCcAcceptsPlainStrings(): void + { + $message = new Email( + to: ['tester@localhost.test'], + subject: 'CC String Test', + content: 'Test Content', + fromName: 'Test Sender', + fromEmail: 'sender@localhost.test', + cc: ['cc@localhost.test'], + ); + + $cc = $message->getCC(); + $this->assertNotNull($cc); + $this->assertEquals('cc@localhost.test', $cc[0]['email']); + } + + public function testBccAcceptsPlainStrings(): void + { + $message = new Email( + to: ['tester@localhost.test'], + subject: 'BCC String Test', + content: 'Test Content', + fromName: 'Test Sender', + fromEmail: 'sender@localhost.test', + bcc: ['bcc@localhost.test'], + ); + + $bcc = $message->getBCC(); + $this->assertNotNull($bcc); + $this->assertEquals('bcc@localhost.test', $bcc[0]['email']); + } + + public function testRejectsEmptyEmailString(): void + { + $this->expectException(\InvalidArgumentException::class); + + new Email( + to: [''], + subject: 'Test', + content: 'Test', + fromName: 'Test', + fromEmail: 'sender@localhost.test', + ); + } + + public function testRejectsEmptyEmailInArray(): void + { + $this->expectException(\InvalidArgumentException::class); + + new Email( + to: [['email' => '', 'name' => 'Ghost']], + subject: 'Test', + content: 'Test', + fromName: 'Test', + fromEmail: 'sender@localhost.test', + ); + } + + public function testRejectsMissingEmailKey(): void + { + $this->expectException(\InvalidArgumentException::class); + + new Email( + to: [['name' => 'No Email']], + subject: 'Test', + content: 'Test', + fromName: 'Test', + fromEmail: 'sender@localhost.test', + ); + } + + public function testRejectsEmptyEmailInCc(): void + { + $this->expectException(\InvalidArgumentException::class); + + new Email( + to: ['valid@localhost.test'], + subject: 'Test', + content: 'Test', + fromName: 'Test', + fromEmail: 'sender@localhost.test', + cc: [''], + ); + } }