Skip to content

feat: punycode email domains before sending (closes #26)#27

Merged
sandervanhooft merged 2 commits into
mainfrom
claude/dreamy-euclid-75032b
May 28, 2026
Merged

feat: punycode email domains before sending (closes #26)#27
sandervanhooft merged 2 commits into
mainfrom
claude/dreamy-euclid-75032b

Conversation

@sandervanhooft
Copy link
Copy Markdown
Member

@sandervanhooft sandervanhooft commented May 28, 2026

Summary

Closes #26.

The Vatly API requires email domains in Punycode (ASCII) form per RFC 3492 and returns 422 email_domain_not_ascii for raw Unicode (see openapi.yaml lines 66–84). The SDK was passing addresses through verbatim, forcing every caller to know about IDN. This PR makes the SDK normalize transparently.

What changed

src/API/Support/IdnEmail.php (new)

  • toAscii(string $email): string — splits on the last @, runs the domain through idn_to_ascii($domain, IDNA_NONTRANSITIONAL_TO_ASCII, INTL_IDNA_VARIANT_UTS46), fail-open if there's no @, empty domain, or idn_to_ascii returns false. On ASCII input it's a no-op; on user@münchen.de it returns user@xn--mnchen-3ya.de.
  • normalizePayload(array): array — recursively walks a payload and applies toAscii to any string value at a key named email.

src/API/Endpoints/BaseEndpoint.php

  • Calls IdnEmail::normalizePayload() once inside parseRequestBody(), right before json_encode. Single integration point covers every endpoint (current and future) and every nested address — no per-call-site changes, no risk of forgetting a new endpoint. Downstream packages (vatly-fluent-php, vatly-laravel) that hand arrays to this SDK pick it up automatically.

composer.json — adds ext-intl to require.

Why local-part is untouched

I had to push back on the original framing here. There is no ASCII encoding for non-ASCII local-parts — Punycode (RFC 3492) is defined for DNS labels only. The only standardized path for Unicode local-parts is SMTPUTF8 (RFC 6531), passthrough UTF-8 on the wire. And the API explicitly does not support EAI/SMTPUTF8 per openapi.yaml line 84:

Local-part Unicode (EAI / SMTPUTF8) is not supported.

So if a caller passes josé@example.com, the SDK passing it through unchanged is correct behavior — the server returns a meaningful validation error rather than the SDK silently mangling someone's address. This matches the original issue's framing ("local-part stays as UTF-8") and matches the spec.

Behavior

Input Sent on wire
user@example.com user@example.com (passthrough)
user@münchen.de user@xn--mnchen-3ya.de
billing@müller.de billing@xn--mller-kva.de
"foo@bar"@münchen.de "foo@bar"@xn--mnchen-3ya.de (last-@ split)
josé@münchen.de josé@xn--mnchen-3ya.de (domain only; server will 422 on local-part)
not-an-email not-an-email (no @, returned unchanged)
user@ user@ (empty domain, fail-open)

Tests

  • tests/Support/IdnEmailTest.php — 8 unit tests covering ASCII passthrough, IDN conversion, no-@ fallback, empty-domain fallback, last-@ split, Unicode local-part untouched, nested payload walking, non-string email values left alone.
  • tests/Endpoints/CustomersEndpointTest.php — 1 integration test asserting user@münchen.de goes out on the wire as user@xn--mnchen-3ya.de via the real endpoint stack.

Test plan

  • vendor/bin/phpunit — 105/105 pass (was 96, +9 new)
  • vendor/bin/phpstan analyse src tests --memory-limit=512M — clean
  • vendor/bin/php-cs-fixer fix --allow-risky=yes --dry-run — clean
  • CI green across the PHP 8.0–8.4 matrix (ext-intl must be available on each runner — it's part of the standard Docker PHP images and Ubuntu PHP packages, but worth confirming)

Acceptance criteria (from #26)

  • Helper converts IDN domains in emails to ASCII
  • All endpoint payloads carrying an email (or billingAddress.email) field route through it
  • Tests cover: ASCII passthrough, IDN domain conversion, no-@ fallback, failed conversion fallback
  • composer.json declares ext-intl

sandervanhooft and others added 2 commits May 28, 2026 23:14
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) <noreply@anthropic.com>
…tadata

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) <noreply@anthropic.com>
@sandervanhooft sandervanhooft merged commit 2f9e21c into main May 28, 2026
12 checks passed
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.

Convert IDN email domains to ASCII (punycode) before sending

1 participant