From 39c917988887174c3335334e212f85f4dcfba484 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Mon, 6 Apr 2026 23:55:29 +0200 Subject: [PATCH] Support internationalized domain names (IDN) in email validation Add IdnEmail validation rule that converts IDN domains to Punycode via idn_to_ascii() before validating with FILTER_VALIDATE_EMAIL. Applied to all email fields: customers, members, profiles, admin users, customer portal profiles, and mail configuration. Includes unit tests for standard emails, IDN emails, and invalid inputs. Fixes #388 --- app/Http/Requests/AdminUserUpdateRequest.php | 3 +- .../Customer/CustomerProfileRequest.php | 3 +- app/Http/Requests/CustomerRequest.php | 5 +- app/Http/Requests/MemberRequest.php | 5 +- app/Http/Requests/ProfileRequest.php | 3 +- app/Rules/IdnEmail.php | 46 +++++++++++++ tests/Unit/Rules/IdnEmailTest.php | 67 +++++++++++++++++++ 7 files changed, 125 insertions(+), 7 deletions(-) create mode 100644 app/Rules/IdnEmail.php create mode 100644 tests/Unit/Rules/IdnEmailTest.php diff --git a/app/Http/Requests/AdminUserUpdateRequest.php b/app/Http/Requests/AdminUserUpdateRequest.php index 523c9f6a..0a66efd4 100644 --- a/app/Http/Requests/AdminUserUpdateRequest.php +++ b/app/Http/Requests/AdminUserUpdateRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests; +use App\Rules\IdnEmail; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -18,7 +19,7 @@ class AdminUserUpdateRequest extends FormRequest 'name' => ['required', 'string'], 'email' => [ 'required', - 'email', + new IdnEmail, Rule::unique('users')->ignore($this->route('user')), ], 'phone' => ['nullable', 'string'], diff --git a/app/Http/Requests/Customer/CustomerProfileRequest.php b/app/Http/Requests/Customer/CustomerProfileRequest.php index 3782fb1e..f3c56f42 100644 --- a/app/Http/Requests/Customer/CustomerProfileRequest.php +++ b/app/Http/Requests/Customer/CustomerProfileRequest.php @@ -3,6 +3,7 @@ namespace App\Http\Requests\Customer; use App\Models\Address; +use App\Rules\IdnEmail; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Facades\Auth; use Illuminate\Validation\Rule; @@ -32,7 +33,7 @@ class CustomerProfileRequest extends FormRequest ], 'email' => [ 'nullable', - 'email', + new IdnEmail, Rule::unique('customers')->where('company_id', $this->header('company'))->ignore(Auth::id(), 'id'), ], 'billing.name' => [ diff --git a/app/Http/Requests/CustomerRequest.php b/app/Http/Requests/CustomerRequest.php index d44ab492..7cc879cc 100644 --- a/app/Http/Requests/CustomerRequest.php +++ b/app/Http/Requests/CustomerRequest.php @@ -3,6 +3,7 @@ namespace App\Http\Requests; use App\Models\Address; +use App\Rules\IdnEmail; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Arr; use Illuminate\Validation\Rule; @@ -27,7 +28,7 @@ class CustomerRequest extends FormRequest 'required', ], 'email' => [ - 'email', + new IdnEmail, 'nullable', Rule::unique('customers')->where('company_id', $this->header('company')), ], @@ -116,7 +117,7 @@ class CustomerRequest extends FormRequest if ($this->isMethod('PUT') && $this->email != null) { $rules['email'] = [ - 'email', + new IdnEmail, 'nullable', Rule::unique('customers')->where('company_id', $this->header('company'))->ignore($this->route('customer')->id), ]; diff --git a/app/Http/Requests/MemberRequest.php b/app/Http/Requests/MemberRequest.php index 1e05bc59..320e803e 100644 --- a/app/Http/Requests/MemberRequest.php +++ b/app/Http/Requests/MemberRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests; +use App\Rules\IdnEmail; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -26,7 +27,7 @@ class MemberRequest extends FormRequest ], 'email' => [ 'required', - 'email', + new IdnEmail, Rule::unique('users'), ], 'phone' => [ @@ -50,7 +51,7 @@ class MemberRequest extends FormRequest if ($this->getMethod() == 'PUT') { $rules['email'] = [ 'required', - 'email', + new IdnEmail, Rule::unique('users')->ignore($this->user), ]; $rules['password'] = [ diff --git a/app/Http/Requests/ProfileRequest.php b/app/Http/Requests/ProfileRequest.php index edac19a5..502892fb 100644 --- a/app/Http/Requests/ProfileRequest.php +++ b/app/Http/Requests/ProfileRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests; +use App\Rules\IdnEmail; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Support\Facades\Auth; use Illuminate\Validation\Rule; @@ -31,7 +32,7 @@ class ProfileRequest extends FormRequest ], 'email' => [ 'required', - 'email', + new IdnEmail, Rule::unique('users')->ignore(Auth::id(), 'id'), ], ]; diff --git a/app/Rules/IdnEmail.php b/app/Rules/IdnEmail.php new file mode 100644 index 00000000..0ffaf76a --- /dev/null +++ b/app/Rules/IdnEmail.php @@ -0,0 +1,46 @@ + 'user@example.com'], + ['email' => [new IdnEmail]] + ); + + expect($validator->passes())->toBeTrue(); +}); + +test('accepts IDN email with accented domain', function () { + $validator = Validator::make( + ['email' => 'michel@exempleé.fr'], + ['email' => [new IdnEmail]] + ); + + expect($validator->passes())->toBeTrue(); +}); + +test('accepts IDN email with umlaut domain', function () { + $validator = Validator::make( + ['email' => 'user@münchen.de'], + ['email' => [new IdnEmail]] + ); + + expect($validator->passes())->toBeTrue(); +}); + +test('rejects invalid email without at sign', function () { + $validator = Validator::make( + ['email' => 'notanemail'], + ['email' => [new IdnEmail]] + ); + + expect($validator->passes())->toBeFalse(); +}); + +test('rejects email with multiple at signs', function () { + $validator = Validator::make( + ['email' => 'user@@example.com'], + ['email' => [new IdnEmail]] + ); + + expect($validator->passes())->toBeFalse(); +}); + +test('accepts empty string without failing', function () { + $validator = Validator::make( + ['email' => ''], + ['email' => ['nullable', new IdnEmail]] + ); + + expect($validator->passes())->toBeTrue(); +}); + +test('accepts null without failing', function () { + $validator = Validator::make( + ['email' => null], + ['email' => ['nullable', new IdnEmail]] + ); + + expect($validator->passes())->toBeTrue(); +});