From 32c3976273a5a1f165440daa3df4cc9f974f62ea Mon Sep 17 00:00:00 2001 From: Torsten Dittmann Date: Tue, 14 Apr 2026 21:57:49 +0400 Subject: [PATCH] feat: add email adapter DSN parsing --- README.md | 15 ++ src/Utopia/Messaging/Adapter/Email.php | 177 ++++++++++++++++++++++ tests/Messaging/Adapter/Email/DsnTest.php | 131 ++++++++++++++++ 3 files changed, 323 insertions(+) create mode 100644 tests/Messaging/Adapter/Email/DsnTest.php diff --git a/README.md b/README.md index aac6126d..f246ec77 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,21 @@ $messaging = new Resend('YOUR_API_KEY'); $messaging->send($message); ``` +You can also create email adapters from a DSN: + +```php + self::createSmtpAdapter($parts, $query, $scheme), + 'resend' => self::createApiKeyAdapter($parts, Email\Resend::class, 'Resend'), + 'sendgrid' => self::createApiKeyAdapter($parts, Email\Sendgrid::class, 'Sendgrid'), + 'mailgun' => self::createMailgunAdapter($parts, $query), + default => throw new \InvalidArgumentException('Unsupported email DSN scheme "'.$scheme.'".'), + }; + } + /** * Process an email message. * @@ -30,4 +62,149 @@ public function getMessageType(): string * @throws \Exception */ abstract protected function process(EmailMessage $message): array; + + /** + * @param array $parts + * @param array $query + */ + private static function createSmtpAdapter(array $parts, array $query, string $scheme): self + { + $host = self::decodeUrlComponent($parts['host'] ?? null); + + if ($host === null || $host === '') { + throw new \InvalidArgumentException('SMTP DSN must include a host.'); + } + + $port = self::parseIntOption( + value: $query['port'] ?? ($parts['port'] ?? ($scheme === 'smtps' ? 465 : 25)), + option: 'port' + ); + + $smtpSecure = self::parseSmtpSecureOption($query['secure'] ?? ($scheme === 'smtps' ? 'ssl' : '')); + $smtpAutoTLS = self::parseBoolOption($query['autotls'] ?? false, 'autotls'); + $timeout = self::parseIntOption($query['timeout'] ?? 30, 'timeout'); + $keepAlive = self::parseBoolOption($query['keepalive'] ?? false, 'keepalive'); + $timelimit = self::parseIntOption($query['timelimit'] ?? 30, 'timelimit'); + $xMailer = self::parseStringOption($query['xmailer'] ?? ''); + + return new Email\SMTP( + host: $host, + port: $port, + username: self::decodeUrlComponent($parts['user'] ?? null) ?? '', + password: self::decodeUrlComponent($parts['pass'] ?? null) ?? '', + smtpSecure: $smtpSecure, + smtpAutoTLS: $smtpAutoTLS, + xMailer: $xMailer, + timeout: $timeout, + keepAlive: $keepAlive, + timelimit: $timelimit, + ); + } + + /** + * @param array $parts + * @param class-string $adapterClass + */ + private static function createApiKeyAdapter(array $parts, string $adapterClass, string $adapterName): self + { + $apiKey = self::decodeUrlComponent($parts['user'] ?? null) + ?? self::decodeUrlComponent($parts['pass'] ?? null); + + if ($apiKey === null || $apiKey === '') { + throw new \InvalidArgumentException($adapterName.' DSN must include an API key.'); + } + + return new $adapterClass($apiKey); + } + + /** + * @param array $parts + * @param array $query + */ + private static function createMailgunAdapter(array $parts, array $query): self + { + $apiKey = self::decodeUrlComponent($parts['user'] ?? null) + ?? self::decodeUrlComponent($parts['pass'] ?? null); + + if ($apiKey === null || $apiKey === '') { + throw new \InvalidArgumentException('Mailgun DSN must include an API key.'); + } + + $domain = self::decodeUrlComponent($parts['host'] ?? null); + + if ($domain === null || $domain === '') { + throw new \InvalidArgumentException('Mailgun DSN must include a domain.'); + } + + return new Email\Mailgun( + apiKey: $apiKey, + domain: $domain, + isEU: self::parseBoolOption($query['eu'] ?? false, 'eu'), + ); + } + + private static function decodeUrlComponent(mixed $value): ?string + { + if (! \is_string($value) || $value === '') { + return null; + } + + return \rawurldecode($value); + } + + private static function parseStringOption(mixed $value): string + { + if (! \is_string($value)) { + throw new \InvalidArgumentException('Expected string query parameter value.'); + } + + return $value; + } + + private static function parseSmtpSecureOption(mixed $value): string + { + if (! \is_string($value)) { + throw new \InvalidArgumentException('Invalid SMTP "secure" option. Expected "", "ssl", or "tls".'); + } + + $value = \strtolower($value); + + if (! \in_array($value, ['', 'ssl', 'tls'], true)) { + throw new \InvalidArgumentException('Invalid SMTP "secure" option. Expected "", "ssl", or "tls".'); + } + + return $value; + } + + private static function parseBoolOption(mixed $value, string $option): bool + { + if (\is_bool($value)) { + return $value; + } + + if (! \is_string($value) && ! \is_int($value)) { + throw new \InvalidArgumentException('Invalid "'.$option.'" option. Expected boolean-like value.'); + } + + $normalized = \filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + + if ($normalized === null) { + throw new \InvalidArgumentException('Invalid "'.$option.'" option. Expected boolean-like value.'); + } + + return $normalized; + } + + private static function parseIntOption(mixed $value, string $option): int + { + if (\is_int($value)) { + return $value; + } + + if (! \is_string($value) || $value === '' || ! \ctype_digit($value)) { + throw new \InvalidArgumentException('Invalid "'.$option.'" option. Expected integer value.'); + } + + return (int) $value; + } } diff --git a/tests/Messaging/Adapter/Email/DsnTest.php b/tests/Messaging/Adapter/Email/DsnTest.php new file mode 100644 index 00000000..59660d39 --- /dev/null +++ b/tests/Messaging/Adapter/Email/DsnTest.php @@ -0,0 +1,131 @@ +assertInstanceOf(SMTP::class, $adapter); + $this->assertSame('mail.example.com', $this->readProperty($adapter, 'host')); + $this->assertSame(587, $this->readProperty($adapter, 'port')); + $this->assertSame('user', $this->readProperty($adapter, 'username')); + $this->assertSame('pass', $this->readProperty($adapter, 'password')); + $this->assertSame('tls', $this->readProperty($adapter, 'smtpSecure')); + $this->assertTrue($this->readProperty($adapter, 'smtpAutoTLS')); + $this->assertSame('Appwrite', $this->readProperty($adapter, 'xMailer')); + $this->assertSame(60, $this->readProperty($adapter, 'timeout')); + $this->assertTrue($this->readProperty($adapter, 'keepAlive')); + $this->assertSame(15, $this->readProperty($adapter, 'timelimit')); + } + + public function test_creates_smtps_adapter_with_implicit_ssl_defaults(): void + { + $adapter = EmailAdapter::fromDsn('smtps://user:pass@mail.example.com'); + + $this->assertInstanceOf(SMTP::class, $adapter); + $this->assertSame(465, $this->readProperty($adapter, 'port')); + $this->assertSame('ssl', $this->readProperty($adapter, 'smtpSecure')); + } + + public function test_creates_resend_adapter_from_dsn(): void + { + $adapter = EmailAdapter::fromDsn('resend://re_test_key@default'); + + $this->assertInstanceOf(Resend::class, $adapter); + $this->assertSame('re_test_key', $this->readProperty($adapter, 'apiKey')); + } + + public function test_creates_sendgrid_adapter_from_dsn(): void + { + $adapter = EmailAdapter::fromDsn('sendgrid://sg_test_key@default'); + + $this->assertInstanceOf(Sendgrid::class, $adapter); + $this->assertSame('sg_test_key', $this->readProperty($adapter, 'apiKey')); + } + + public function test_creates_mailgun_adapter_from_dsn(): void + { + $adapter = EmailAdapter::fromDsn('mailgun://mg_test_key@example.com?eu=1'); + + $this->assertInstanceOf(Mailgun::class, $adapter); + $this->assertSame('mg_test_key', $this->readProperty($adapter, 'apiKey')); + $this->assertSame('example.com', $this->readProperty($adapter, 'domain')); + $this->assertTrue($this->readProperty($adapter, 'isEU')); + } + + public function test_rejects_unsupported_scheme(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported email DSN scheme "ses".'); + + EmailAdapter::fromDsn('ses://key@default'); + } + + public function test_rejects_invalid_dsn(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid email DSN.'); + + EmailAdapter::fromDsn('not a dsn'); + } + + public function test_rejects_malformed_smtp_dsn(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid email DSN.'); + + EmailAdapter::fromDsn('smtp://'); + } + + public function test_rejects_missing_resend_api_key(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Resend DSN must include an API key.'); + + EmailAdapter::fromDsn('resend://@default'); + } + + public function test_rejects_invalid_boolean_query_value(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid "autotls" option. Expected boolean-like value.'); + + EmailAdapter::fromDsn('smtp://mail.example.com?autotls=maybe'); + } + + public function test_rejects_invalid_integer_query_value(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid "timeout" option. Expected integer value.'); + + EmailAdapter::fromDsn('smtp://mail.example.com?timeout=fast'); + } + + public function test_rejects_invalid_secure_query_value(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid SMTP "secure" option. Expected "", "ssl", or "tls".'); + + EmailAdapter::fromDsn('smtp://mail.example.com?secure=starttls'); + } + + private function readProperty(object $object, string $property): mixed + { + $reflection = new \ReflectionClass($object); + $property = $reflection->getProperty($property); + + return $property->getValue($object); + } +}