From ada55ff8f42f7e1176ac91b6fdabd4b52eac773d Mon Sep 17 00:00:00 2001 From: Sander van Hooft Date: Thu, 28 May 2026 23:14:01 +0200 Subject: [PATCH 1/2] feat: punycode email domains before sending (closes #26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Vatly API requires email domains in Punycode (ASCII) form per RFC 3492 and returns 422 `email_domain_not_ascii` for raw Unicode. The SDK was passing addresses through verbatim, forcing every caller to know about IDN. Adds `Vatly\API\Support\IdnEmail::toAscii()` (splits on the last `@`, runs the domain through `idn_to_ascii` with the UTS46 variant, fail-open on empty/failed conversion). Wires it into `BaseEndpoint::parseRequestBody` via a small recursive walker so every payload — current and future — gets normalized at a single point. Covers top-level `email`, nested `billingAddress.email`, and any other `email` key downstream packages (vatly-fluent-php, vatly-laravel) construct. Local-part is intentionally left untouched: there is no ASCII encoding for non-ASCII local-parts (Punycode is a DNS-label spec), and the API explicitly does not support EAI/SMTPUTF8 (openapi.yaml line 84). A non-ASCII local-part should surface as the server's validation error rather than be silently mangled. Declares `ext-intl` in composer.json. Co-Authored-By: Claude Opus 4.7 (1M context) --- composer.json | 1 + src/API/Endpoints/BaseEndpoint.php | 3 +- src/API/Support/IdnEmail.php | 65 ++++++++++++++++ tests/Endpoints/CustomersEndpointTest.php | 31 ++++++++ tests/Support/IdnEmailTest.php | 92 +++++++++++++++++++++++ 5 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 src/API/Support/IdnEmail.php create mode 100644 tests/Support/IdnEmailTest.php diff --git a/composer.json b/composer.json index 3c5ffa8..35b42b4 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,7 @@ "require": { "php": "^8.0", "ext-curl": "*", + "ext-intl": "*", "ext-json": "*", "ext-openssl": "*", "composer/ca-bundle": "^1.3" diff --git a/src/API/Endpoints/BaseEndpoint.php b/src/API/Endpoints/BaseEndpoint.php index fb1b534..5312de5 100644 --- a/src/API/Endpoints/BaseEndpoint.php +++ b/src/API/Endpoints/BaseEndpoint.php @@ -10,6 +10,7 @@ use Vatly\API\Resources\Links\LinksResourceFactory; use Vatly\API\Resources\Links\PaginationLinks; use Vatly\API\Resources\ResourceFactory; +use Vatly\API\Support\IdnEmail; use Vatly\API\VatlyApiClient; abstract class BaseEndpoint @@ -228,7 +229,7 @@ protected function parseRequestBody(array $body): ?string return null; } - return @json_encode($body); + return @json_encode(IdnEmail::normalizePayload($body)); } /** diff --git a/src/API/Support/IdnEmail.php b/src/API/Support/IdnEmail.php new file mode 100644 index 0000000..176b071 --- /dev/null +++ b/src/API/Support/IdnEmail.php @@ -0,0 +1,65 @@ + $payload + * @return array + */ + public static function normalizePayload(array $payload): array + { + foreach ($payload as $key => $value) { + if (is_array($value)) { + $payload[$key] = self::normalizePayload($value); + + continue; + } + + if ($key === 'email' && is_string($value)) { + $payload[$key] = self::toAscii($value); + } + } + + return $payload; + } +} diff --git a/tests/Endpoints/CustomersEndpointTest.php b/tests/Endpoints/CustomersEndpointTest.php index 41fe730..99e8b39 100644 --- a/tests/Endpoints/CustomersEndpointTest.php +++ b/tests/Endpoints/CustomersEndpointTest.php @@ -95,6 +95,37 @@ public function it_creates_customer_with_minimal_data(): void $this->assertNull($customer->metadata); } + /** @test */ + public function it_punycodes_the_email_domain_before_sending(): void + { + $responseBodyArray = [ + 'id' => 'customer_78b146a7de7d417e9d68d7e6ef193d18', + 'resource' => 'customer', + 'email' => 'user@xn--mnchen-3ya.de', + 'createdAt' => '2020-01-01T00:00:00+00:00', + 'testmode' => true, + 'links' => [ + 'self' => [ + 'href' => self::API_ENDPOINT_URL . '/customers/customer_78b146a7de7d417e9d68d7e6ef193d18', + 'type' => 'application/hal+json', + ], + ], + ]; + + $this->httpClient->setSendReturnObjectFromArray($responseBodyArray); + + $this->client->customers->create([ + 'email' => 'user@münchen.de', + ]); + + $this->assertWasSentOnly( + VatlyApiClient::HTTP_POST, + self::API_ENDPOINT_URL . '/customers', + [], + '{"email":"user@xn--mnchen-3ya.de"}' + ); + } + /** @test */ public function it_can_get_a_customer(): void { diff --git a/tests/Support/IdnEmailTest.php b/tests/Support/IdnEmailTest.php new file mode 100644 index 0000000..b1cbd6c --- /dev/null +++ b/tests/Support/IdnEmailTest.php @@ -0,0 +1,92 @@ +assertSame('user@example.com', IdnEmail::toAscii('user@example.com')); + } + + /** @test */ + public function it_converts_idn_domain_to_punycode(): void + { + $this->assertSame('user@xn--mnchen-3ya.de', IdnEmail::toAscii('user@münchen.de')); + $this->assertSame('billing@xn--mller-kva.de', IdnEmail::toAscii('billing@müller.de')); + } + + /** @test */ + public function it_returns_input_unchanged_when_at_sign_is_missing(): void + { + $this->assertSame('not-an-email', IdnEmail::toAscii('not-an-email')); + $this->assertSame('', IdnEmail::toAscii('')); + } + + /** @test */ + public function it_returns_input_unchanged_when_conversion_fails(): void + { + // Empty domain — idn_to_ascii returns false on this. Fail-open: return original. + $this->assertSame('user@', IdnEmail::toAscii('user@')); + } + + /** @test */ + public function it_splits_on_the_last_at_sign(): void + { + // Quoted local-parts may contain '@'; we should split on the rightmost one. + $this->assertSame('"foo@bar"@xn--mnchen-3ya.de', IdnEmail::toAscii('"foo@bar"@münchen.de')); + } + + /** @test */ + public function it_leaves_unicode_local_part_untouched(): void + { + // No standardized ASCII encoding for non-ASCII local-parts. Server rejects + // these per the API spec; the SDK should not silently transform them. + $this->assertSame('jösé@xn--mnchen-3ya.de', IdnEmail::toAscii('jösé@münchen.de')); + } + + /** @test */ + public function normalize_payload_walks_nested_structures(): void + { + $input = [ + 'email' => 'user@münchen.de', + 'billingAddress' => [ + 'email' => 'billing@müller.de', + 'city' => 'München', + ], + 'metadata' => [ + 'order_id' => '123', + ], + ]; + + $expected = [ + 'email' => 'user@xn--mnchen-3ya.de', + 'billingAddress' => [ + 'email' => 'billing@xn--mller-kva.de', + 'city' => 'München', + ], + 'metadata' => [ + 'order_id' => '123', + ], + ]; + + $this->assertSame($expected, IdnEmail::normalizePayload($input)); + } + + /** @test */ + public function normalize_payload_only_touches_string_email_values(): void + { + $input = [ + 'email' => null, + 'inner' => ['email' => 12345], + ]; + + $this->assertSame($input, IdnEmail::normalizePayload($input)); + } +} From 5bbb721cef251d30d9ed2a3e90ccabecaf09e174 Mon Sep 17 00:00:00 2001 From: Sander van Hooft Date: Thu, 28 May 2026 23:27:44 +0200 Subject: [PATCH 2/2] fix(idn-email): cover updateBilling/requestAddressUpdateLink, skip metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups from PR review: 1. `SubscriptionEndpoint::updateBilling` and `OrderEndpoint::requestAddressUpdateLink` were hand-rolling `json_encode($data)` instead of going through `parseRequestBody()`, so they bypassed the email normalization added in the previous commit. Both endpoints accept `billingAddress.email` per the OpenAPI spec, so an IDN domain still triggered the same 422 the patch was meant to prevent. Route them through `parseRequestBody()` like every other mutating call. 2. The recursive payload walker was rewriting any nested key named `email`, including inside `metadata`. The OpenAPI spec defines `metadata` as opaque application-defined key/value data, so the SDK must not mutate values nested there — a caller using `metadata.email` for their own tagging would get their data silently changed. Skip recursion into `metadata` entirely. New tests: - `IdnEmailTest::normalize_payload_leaves_metadata_untouched` - `SubscriptionEndpointTest::update_billing_punycodes_email_domains_in_billing_address` - `OrderEndpointTest::request_address_update_link_punycodes_email_domains_in_billing_address` `TestHelpersEndpoint::simulatePaymentFailure` also hand-rolls `json_encode`, but its payload shape doesn't carry email, so leaving it alone to keep the diff minimal. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/API/Endpoints/OrderEndpoint.php | 11 ++++----- src/API/Endpoints/SubscriptionEndpoint.php | 11 ++++----- src/API/Support/IdnEmail.php | 9 +++++++ tests/Endpoints/OrderEndpointTest.php | 23 ++++++++++++++++++ tests/Endpoints/SubscriptionEndpointTest.php | 23 ++++++++++++++++++ tests/Support/IdnEmailTest.php | 25 ++++++++++++++++++++ 6 files changed, 90 insertions(+), 12 deletions(-) diff --git a/src/API/Endpoints/OrderEndpoint.php b/src/API/Endpoints/OrderEndpoint.php index 58d108d..e5ee7e0 100644 --- a/src/API/Endpoints/OrderEndpoint.php +++ b/src/API/Endpoints/OrderEndpoint.php @@ -58,12 +58,11 @@ public function requestAddressUpdateLink(string $id, array $data = []): Link $resource = "{$this->getResourcePath()}/" . urlencode($id) . "/request-address-update-link"; - $body = null; - if (count($data) > 0) { - $body = json_encode($data); - } - - $result = $this->client->performHttpCall(self::REST_CREATE, $resource, $body); + $result = $this->client->performHttpCall( + self::REST_CREATE, + $resource, + $this->parseRequestBody($data), + ); return new Link($result->href, $result->type); } diff --git a/src/API/Endpoints/SubscriptionEndpoint.php b/src/API/Endpoints/SubscriptionEndpoint.php index 426ed23..0345215 100644 --- a/src/API/Endpoints/SubscriptionEndpoint.php +++ b/src/API/Endpoints/SubscriptionEndpoint.php @@ -91,12 +91,11 @@ public function updateBilling(string $subscriptionId, array $data = []): Link $resource = "{$this->getResourcePath()}/" . urlencode($subscriptionId) . "/update-billing"; - $body = null; - if (count($data) > 0) { - $body = json_encode($data); - } - - $result = $this->client->performHttpCall(self::REST_UPDATE, $resource, $body); + $result = $this->client->performHttpCall( + self::REST_UPDATE, + $resource, + $this->parseRequestBody($data), + ); return new Link($result->href, $result->type); } diff --git a/src/API/Support/IdnEmail.php b/src/API/Support/IdnEmail.php index 176b071..636cbb3 100644 --- a/src/API/Support/IdnEmail.php +++ b/src/API/Support/IdnEmail.php @@ -43,12 +43,21 @@ public static function toAscii(string $email): string * Walk a request payload and normalize any string value at a key named * 'email' to its Punycode (ASCII) form. Recurses into nested arrays. * + * The `metadata` key is treated as opaque: the OpenAPI spec defines it + * as arbitrary application-defined key/value data, so the SDK must not + * rewrite values nested inside it (e.g. a caller storing their own + * `metadata.email` key should get it through to the API untouched). + * * @param array $payload * @return array */ public static function normalizePayload(array $payload): array { foreach ($payload as $key => $value) { + if ($key === 'metadata') { + continue; + } + if (is_array($value)) { $payload[$key] = self::normalizePayload($value); diff --git a/tests/Endpoints/OrderEndpointTest.php b/tests/Endpoints/OrderEndpointTest.php index 12edc9b..5740fe1 100644 --- a/tests/Endpoints/OrderEndpointTest.php +++ b/tests/Endpoints/OrderEndpointTest.php @@ -287,4 +287,27 @@ public function can_request_address_update_link(): void ); $this->assertEquals(self::API_ENDPOINT_URL."/customer/invoices/$orderId?editable=true&signature=1234567890abcdef", $response->href); } + + /** @test */ + public function request_address_update_link_punycodes_email_domains_in_billing_address(): void + { + $orderId = 'order_dummy_id'; + $this->httpClient->setSendReturnObjectFromArray([ + 'href' => self::API_ENDPOINT_URL."/customer/invoices/$orderId?editable=true&signature=1234567890abcdef", + 'type' => 'text/html', + ]); + + $this->client->orders->requestAddressUpdateLink($orderId, [ + 'billingAddress' => [ + 'email' => 'billing@müller.de', + ], + ]); + + $this->assertWasSentOnly( + VatlyApiClient::HTTP_POST, + self::API_ENDPOINT_URL.'/orders/'.$orderId.'/request-address-update-link', + [], + '{"billingAddress":{"email":"billing@xn--mller-kva.de"}}' + ); + } } diff --git a/tests/Endpoints/SubscriptionEndpointTest.php b/tests/Endpoints/SubscriptionEndpointTest.php index 9f4f01e..cea55e0 100644 --- a/tests/Endpoints/SubscriptionEndpointTest.php +++ b/tests/Endpoints/SubscriptionEndpointTest.php @@ -246,6 +246,29 @@ public function can_update_billing_details() $this->assertEquals(self::WEBSITE_ENDPOINT_URL.'/checkout/checkout_dummy_id/update', $response->href); } + /** @test */ + public function update_billing_punycodes_email_domains_in_billing_address(): void + { + $this->httpClient->setSendReturnObjectFromArray([ + 'href' => self::WEBSITE_ENDPOINT_URL.'/checkout/checkout_dummy_id/update', + 'type' => 'text/html', + ]); + + $this->client->subscriptions->updateBilling('subscription_123', [ + 'billingAddress' => [ + 'email' => 'billing@müller.de', + 'city' => 'München', + ], + ]); + + $this->assertWasSentOnly( + VatlyApiClient::HTTP_PATCH, + self::API_ENDPOINT_URL.'/subscriptions/subscription_123/update-billing', + [], + '{"billingAddress":{"email":"billing@xn--mller-kva.de","city":"München"}}' + ); + } + /** @test */ public function can_update_subscription_quantity() { diff --git a/tests/Support/IdnEmailTest.php b/tests/Support/IdnEmailTest.php index b1cbd6c..7de505a 100644 --- a/tests/Support/IdnEmailTest.php +++ b/tests/Support/IdnEmailTest.php @@ -89,4 +89,29 @@ public function normalize_payload_only_touches_string_email_values(): void $this->assertSame($input, IdnEmail::normalizePayload($input)); } + + /** @test */ + public function normalize_payload_leaves_metadata_untouched(): void + { + // `metadata` is opaque application-defined data per the OpenAPI spec. + // The SDK must not rewrite values nested inside it, even when a caller + // happens to use a key named `email` for their own purposes. + $input = [ + 'email' => 'user@müller.de', + 'metadata' => [ + 'email' => 'tracker+user@müller.de', + 'nested' => ['email' => 'should-also-be-left-alone@münchen.de'], + ], + ]; + + $expected = [ + 'email' => 'user@xn--mller-kva.de', + 'metadata' => [ + 'email' => 'tracker+user@müller.de', + 'nested' => ['email' => 'should-also-be-left-alone@münchen.de'], + ], + ]; + + $this->assertSame($expected, IdnEmail::normalizePayload($input)); + } }