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
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,48 @@ $messaging = new FCM('YOUR_SERVICE_ACCOUNT_JSON');
$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`.

## Multiple Adapters (Failover)

You can use multiple adapters with automatic failover. If one adapter throws an exception, the next one will be tried.

```php
<?php

use \Utopia\Messaging\Messenger;
use \Utopia\Messaging\Messages\SMS;
use \Utopia\Messaging\Adapter\SMS\Twilio;
use \Utopia\Messaging\Adapter\SMS\Vonage;

$message = new SMS(
to: ['+12025550139'],
content: 'Hello World'
);

$messenger = new Messenger([
new Twilio('YOUR_ACCOUNT_SID', 'YOUR_AUTH_TOKEN'),
new Vonage('YOUR_API_KEY', 'YOUR_API_SECRET'),
]);

$messenger->send($message);
```

The `Messenger` class accepts multiple adapters and tries them in order. It stops at the first successful response and only throws an exception if all adapters fail.

## Adapters

> Want to implement any of the missing adapters or have an idea for another? We would love to hear from you! Please check out our [contribution guide](./CONTRIBUTING.md) and [new adapter guide](./docs/add-new-adapter.md) for more information.
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'
);

$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 on lines +198 to +209

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 parseIntOption skips range validation when the value is already a PHP int. parse_url returns $parts['port'] as a native integer, so a URL like smtp://host:0 or smtp://host:99999 bypasses the ctype_digit check entirely and passes an invalid port directly to the SMTP constructor. The same zero/overflow gap applies to query-string ports since ctype_digit("0") and ctype_digit("99999") both return true. Adding a positive-integer guard closes this for all call sites (port, timeout, timelimit).

Suggested change
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;
}
private static function parseIntOption(mixed $value, string $option): int
{
if (\is_int($value)) {
if ($value < 0) {
throw new \InvalidArgumentException('Invalid "'.$option.'" option. Expected non-negative integer value.');
}
return $value;
}
if (! \is_string($value) || $value === '' || ! \ctype_digit($value)) {
throw new \InvalidArgumentException('Invalid "'.$option.'" option. Expected integer value.');
}
return (int) $value;
}

}
131 changes: 131 additions & 0 deletions src/Utopia/Messaging/Messenger.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<?php

namespace Utopia\Messaging;

class Messenger
{
private array $adapters;

public function __construct(Adapter|array $adapters)
{
if ($adapters instanceof Adapter) {
$adapters = [$adapters];
}

if (empty($adapters)) {
throw new \InvalidArgumentException('At least one adapter must be provided.');
}

foreach ($adapters as $index => $adapter) {
if (! $adapter instanceof Adapter) {
throw new \InvalidArgumentException(
'All elements must be instances of Adapter, but element '
.$index
.' is '
.\get_debug_type($adapter)
.'.'
);
}
}

$this->validateAdapters($adapters);

$this->adapters = $adapters;
Comment on lines +31 to +33

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Normalize adapter arrays

Messenger accepts any array of adapters, but later reads $adapters[0]. An associative or sparse array like ['primary' => $adapter] or [1 => $adapter] passes the validation loop and then validateAdapters() tries to call methods on a missing index. Normalizing the array before storing or validating it lets named or sparse adapter lists work consistently.

}

public function send(Message $message): array
{
$errors = [];
$messageType = $this->adapters[0]->getMessageType();

if (! \is_a($message, $messageType)) {
throw new \Exception(
'Invalid message type. Expected "'
.$messageType
.'", got "'
.\get_class($message)
.'".'
);
}

foreach ($this->adapters as $index => $adapter) {
try {
return $adapter->send($message);
} catch (\Exception $e) {
$errors[] = $adapter->getName()
.' (adapter '
.($index + 1)
.'): '
.$e->getMessage();
}
Comment on lines +52 to +60

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 The catch block only catches \Exception, so PHP \Error subclasses (\TypeError, \ValueError, \Error, etc.) thrown by an adapter will propagate uncaught and bypass the failover loop entirely. Catching \Throwable instead ensures all adapter-level failures are handled and the next adapter is tried.

Suggested change
try {
return $adapter->send($message);
} catch (\Exception $e) {
$errors[] = $adapter->getName()
.' (adapter '
.($index + 1)
.'): '
.$e->getMessage();
}
try {
return $adapter->send($message);
} catch (\Throwable $e) {
$errors[] = $adapter->getName()
.' (adapter '
.($index + 1)
.'): '
.$e->getMessage();
}

}

$adapterCount = \count($this->adapters);
$adapterLabel = $adapterCount === 1 ? 'adapter' : 'adapters';

throw new \Exception(
'All '
.$adapterCount
.' '
.$adapterLabel
." failed:\n"
.\implode("\n", $errors)
);
}

public function getMessageType(): string
{
return $this->adapters[0]->getMessageType();
}

public function getType(): string
{
return $this->adapters[0]->getType();
}

public function getMaxMessagesPerRequest(): int
{
return array_reduce(
$this->adapters,
fn ($min, $adapter) => min($min, $adapter->getMaxMessagesPerRequest()),
PHP_INT_MAX
);
}

private function validateAdapters(array $adapters): void
{
$firstAdapter = $adapters[0];
$expectedType = $firstAdapter->getType();
$expectedMessageType = $firstAdapter->getMessageType();

foreach (\array_slice($adapters, 1, preserve_keys: true) as $index => $adapter) {
if ($adapter->getType() !== $expectedType) {
throw new \InvalidArgumentException(
'All adapters must be of the same type. Expected "'
.$expectedType
.'", but adapter '
.($index + 1)
.' ('
.$adapter->getName()
.') has type "'
.$adapter->getType()
.'".'
);
}

if ($adapter->getMessageType() !== $expectedMessageType) {
throw new \InvalidArgumentException(
'All adapters must support the same message type. Expected "'
.$expectedMessageType
.'", but adapter '
.($index + 1)
.' ('
.$adapter->getName()
.') supports "'
.$adapter->getMessageType()
.'".'
);
}
}
}
}
Loading