Skip to content

feat: add email adapter DSN parsing#125

Open
deepshekhardas wants to merge 1 commit into
utopia-php:mainfrom
deepshekhardas:pr-117
Open

feat: add email adapter DSN parsing#125
deepshekhardas wants to merge 1 commit 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.

@greptile-apps

greptile-apps Bot commented Jun 7, 2026

Copy link
Copy Markdown

Greptile Summary

This PR introduces DSN-string parsing for email adapters (smtp, smtps, resend, sendgrid, mailgun) via Email::fromDsn() and adds a new Messenger class that wraps multiple adapters and fails over to the next adapter when one throws.

  • Email::fromDsn() parses the DSN with parse_url, dispatches to per-scheme factory methods, and includes helpers (parseIntOption, parseBoolOption, parseSmtpSecureOption) for typed query-parameter coercion — but integer values coming directly from parse_url (e.g. the URL-level port) skip all range validation, so out-of-range ports like 0 or 99999 are silently accepted.
  • Messenger iterates adapters in order and catches the first success; it catches only \\Exception, so PHP \\Error subclasses thrown by an adapter escape the loop entirely rather than triggering the next adapter.
  • The test for smtp:// (no host) asserts the wrong exception message and will fail at runtime.

Confidence Score: 3/5

The PR has multiple real defects across both changed classes that would produce incorrect runtime behaviour before any fixes land.

Three independent defects exist in the changed code: the Messenger failover loop silently swallows only Exception leaving TypeError and other Error subclasses uncaught; integer values returned directly by parse_url bypass range validation in parseIntOption; and a DSN test asserts the wrong exception message for smtp:// causing the test suite to fail.

src/Utopia/Messaging/Messenger.php (catch clause and array indexing) and src/Utopia/Messaging/Adapter/Email.php (integer range validation) need the most attention; tests/Messaging/Adapter/Email/DsnTest.php has a failing assertion that should be corrected alongside the implementation fix.

Important Files Changed

Filename Overview
src/Utopia/Messaging/Adapter/Email.php Adds fromDsn() static factory with helpers for SMTP, Resend, Sendgrid, Mailgun; parseIntOption returns native ints from parse_url without range-checking port/timeout/timelimit values (e.g. port 0 or 99999 passes through unchecked).
src/Utopia/Messaging/Messenger.php New failover class wrapping multiple adapters; catch (\Exception) misses PHP \Error subclasses that would bypass the failover loop entirely, and $adapters[0] throughout is unsafe for non-sequential or associative adapter arrays.
tests/Messaging/Adapter/Email/DsnTest.php New DSN test suite; test_rejects_malformed_smtp_dsn expects "Invalid email DSN." for smtp:// but the code actually throws "SMTP DSN must include a host." because parse_url('smtp://') succeeds with a valid scheme, so this test will fail.
tests/Messaging/MessengerTest.php Comprehensive Messenger tests covering failover, type validation, and edge cases; all tests match current implementation behaviour.
README.md Adds DSN usage examples and Messenger failover documentation; content is accurate and matches the implementation.

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

Comment thread tests/Messaging/Adapter/Email/DsnTest.php
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();
}

Comment on lines +198 to +209
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;
}

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

Comment on lines +31 to +33
$this->validateAdapters($adapters);

$this->adapters = $adapters;

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.

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.

1 participant