Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,21 @@ $messaging = new Resend('YOUR_API_KEY');
$messaging->send($message);
```

You can also create email adapters from a DSN:

```php
<?php

use Utopia\Messaging\Adapter\Email;

$smtp = Email::fromDsn('smtp://user:pass@smtp.example.com:587?secure=tls&autotls=1');
$resend = Email::fromDsn('resend://YOUR_API_KEY@default');
$sendgrid = Email::fromDsn('sendgrid://YOUR_API_KEY@default');
$mailgun = Email::fromDsn('mailgun://YOUR_API_KEY@example.com?eu=0');
```

Supported email DSN schemes are `smtp`, `smtps`, `resend`, `sendgrid`, and `mailgun`.

## SMS

```php
Expand Down
177 changes: 177 additions & 0 deletions src/Utopia/Messaging/Adapter/Email.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
abstract class Email extends Adapter
{
protected const TYPE = 'email';

protected const MESSAGE_TYPE = EmailMessage::class;

protected const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024; // 25MB
Expand All @@ -22,6 +23,37 @@ public function getMessageType(): string
return static::MESSAGE_TYPE;
}

/**
* Create an email adapter from a DSN string.
*
* Supported schemes: smtp, smtps, resend, sendgrid, mailgun.
*
* @throws \InvalidArgumentException
*/
public static function fromDsn(string $dsn): self
{
$parts = \parse_url($dsn);

if ($parts === false || empty($parts['scheme'])) {
throw new \InvalidArgumentException('Invalid email DSN.');
}

$scheme = \strtolower($parts['scheme']);
$query = [];

if (isset($parts['query'])) {
\parse_str($parts['query'], $query);
}

return match ($scheme) {
'smtp', 'smtps' => 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.
*
Expand All @@ -30,4 +62,149 @@ public function getMessageType(): string
* @throws \Exception
*/
abstract protected function process(EmailMessage $message): array;

/**
* @param array<string, int|string> $parts
* @param array<string, mixed> $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'
);
Comment on lines +78 to +81
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Query-string port silently overrides URL port

The precedence $query['port'] > $parts['port'] > scheme-default means smtp://host:587?port=25 resolves to port 25. Most DSN parsers treat the URL authority port as authoritative. This is a subtle gotcha worth a brief inline comment, or consider flipping the precedence to URL port > query port > scheme default.


$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<string, int|string> $parts
* @param class-string<self> $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<string, int|string> $parts
* @param array<string, mixed> $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;
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
131 changes: 131 additions & 0 deletions tests/Messaging/Adapter/Email/DsnTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<?php

namespace Utopia\Tests\Adapter\Email;

use PHPUnit\Framework\TestCase;
use Utopia\Messaging\Adapter\Email as EmailAdapter;
use Utopia\Messaging\Adapter\Email\Mailgun;
use Utopia\Messaging\Adapter\Email\Resend;
use Utopia\Messaging\Adapter\Email\Sendgrid;
use Utopia\Messaging\Adapter\Email\SMTP;

class DsnTest extends TestCase
{
public function test_creates_smtp_adapter_from_dsn(): void
{
$adapter = EmailAdapter::fromDsn(
'smtp://user:pass@mail.example.com:587?secure=tls&autotls=1&xmailer=Appwrite&timeout=60&keepalive=1&timelimit=15'
);

$this->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.');

Comment thread
greptile-apps[bot] marked this conversation as resolved.
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);
}
}
Loading