Skip to content

feat: add email adapter DSN parsing#125

Open
deepshekhardas wants to merge 3 commits into
utopia-php:mainfrom
deepshekhardas:pr-117
Open

feat: add email adapter DSN parsing#125
deepshekhardas wants to merge 3 commits into
utopia-php:mainfrom
deepshekhardas:pr-117

Conversation

@deepshekhardas

Copy link
Copy Markdown

Add DSN (Data Source Name) parsing support for email adapters, enabling connection configuration via DSN strings.

This commit introduces the Messenger class, which enables automatic failover
across multiple messaging adapters. If one adapter throws an exception, the
next adapter in the sequence is tried until one succeeds or all fail.

Features:
- Accepts a single Adapter or an array of Adapters
- Tries adapters sequentially on exception
- Validates adapter compatibility (same type and message type)
- Returns the first successful response
- Throws aggregated exception with details if all adapters fail
- Supports SMS, Email, Push, and any other adapter types

Example usage:
    $messenger = new Messenger([
        new Twilio('sid', 'token'),
        new Vonage('key', 'secret'),
    ]);
    $result = $messenger->send($message);

Changes:
- Add src/Utopia/Messaging/Messenger.php
- Add tests/Messaging/MessengerTest.php with comprehensive test coverage
- Update README.md with usage example

Closes: feature request for multiple adapter support
Validate Messenger adapter arrays at runtime so invalid elements fail with a clear InvalidArgumentException instead of a PHP error. This keeps the new Adapter|Adapter[] constructor ergonomic without weakening input validation.

Also replace Messenger sprintf-based error construction with direct string concatenation, pluralize the single-adapter failure message correctly, and add test coverage for single-adapter construction and invalid array elements.
@greptile-apps

greptile-apps Bot commented Jun 7, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds DSN-string factory support (Email::fromDsn()) for SMTP, Resend, Sendgrid, and Mailgun email adapters, and introduces a new Messenger class that wraps multiple adapters for ordered failover.

  • Email::fromDsn() parses the scheme, credentials, host, and query options through focused private helpers; constructors of existing adapters are all matched correctly.
  • Messenger validates adapter-type compatibility at construction time and retries each adapter in sequence on exception, throwing a composite error message if all fail.
  • One DSN test has a mismatched expected exception message that will cause the test suite to fail.

Confidence Score: 3/5

The new feature code is well-structured, but one test in DsnTest.php asserts the wrong exception message and will fail on the current implementation.

The test_rejects_malformed_smtp_dsn test expects 'Invalid email DSN.' for smtp://, but the actual code path throws 'SMTP DSN must include a host.' - the test will fail as written. The Messenger catch-clause gap is a secondary concern with no current failing path.

tests/Messaging/Adapter/Email/DsnTest.php - the malformed-SMTP test case expects the wrong exception message and will fail

Important Files Changed

Filename Overview
src/Utopia/Messaging/Adapter/Email.php Adds fromDsn() factory with SMTP, Resend, Sendgrid, and Mailgun support. URL parsing, credential extraction, and option validation are all well-structured. Constructor signatures align with existing adapters.
src/Utopia/Messaging/Messenger.php New failover-capable Messenger class with solid adapter-type validation. Catch clause covers only \Exception, not \Error subclasses, which could bypass the failover chain on adapter-level PHP errors.
tests/Messaging/Adapter/Email/DsnTest.php test_rejects_malformed_smtp_dsn expects 'Invalid email DSN.' but smtp:// will produce 'SMTP DSN must include a host.' - this test will fail.
tests/Messaging/MessengerTest.php Comprehensive test coverage for Messenger: failover, all-fail, mixed types, single adapter, and returned-failure-vs-exception distinction. All assertions match implementation.
README.md Adds DSN usage examples and a Messenger failover section. Examples match the implementation.

Reviews (1): Last reviewed commit: "feat: add email adapter DSN parsing" | Re-trigger Greptile

Comment on lines +84 to +90
public function test_rejects_malformed_smtp_dsn(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Invalid email DSN.');

EmailAdapter::fromDsn('smtp://');
}

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 The expected exception message doesn't match what the code actually throws for smtp://. parse_url('smtp://') returns ['scheme' => 'smtp'], so the scheme check passes and createSmtpAdapter is reached, which then throws 'SMTP DSN must include a host.' — not 'Invalid email DSN.'. This test will fail as-is.

Suggested change
public function test_rejects_malformed_smtp_dsn(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Invalid email DSN.');
EmailAdapter::fromDsn('smtp://');
}
public function test_rejects_malformed_smtp_dsn(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('SMTP DSN must include a host.');
EmailAdapter::fromDsn('smtp://');
}

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

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();
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants