From c13a70e226e2b9347c0d1e756a6e96e609088974 Mon Sep 17 00:00:00 2001 From: fogelito Date: Thu, 23 Oct 2025 12:38:18 +0300 Subject: [PATCH 01/19] Add users attributes --- app/config/collections/common.php | 44 +++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/app/config/collections/common.php b/app/config/collections/common.php index 6de7eb224b..2b3d10e457 100644 --- a/app/config/collections/common.php +++ b/app/config/collections/common.php @@ -364,6 +364,50 @@ return [ 'array' => false, 'filters' => ['datetime'], ], + [ + '$id' => ID::custom('emailIsFree'), + 'type' => Database::VAR_BOOLEAN, + 'format' => '', + 'size' => 0, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('emailIsDisposable'), + 'type' => Database::VAR_BOOLEAN, + 'format' => '', + 'size' => 0, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('emailIsCorporate'), + 'type' => Database::VAR_BOOLEAN, + 'format' => '', + 'size' => 0, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('emailIsCanonical'), + 'type' => Database::VAR_BOOLEAN, + 'format' => '', + 'size' => 0, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], ], 'indexes' => [ [ From 037d60633bbfe803e2c099cfcfda8d27c8c3a0d8 Mon Sep 17 00:00:00 2001 From: fogelito Date: Mon, 27 Oct 2025 15:59:20 +0200 Subject: [PATCH 02/19] Add emailCanonical --- app/config/collections/common.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/config/collections/common.php b/app/config/collections/common.php index 2b3d10e457..d8e1c1699a 100644 --- a/app/config/collections/common.php +++ b/app/config/collections/common.php @@ -364,6 +364,17 @@ return [ 'array' => false, 'filters' => ['datetime'], ], + [ + '$id' => ID::custom('emailCanonical'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 320, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], [ '$id' => ID::custom('emailIsFree'), 'type' => Database::VAR_BOOLEAN, From e40e88c32233d42ae5ded50a43eb34e98e71389e Mon Sep 17 00:00:00 2001 From: fogelito Date: Thu, 30 Oct 2025 18:09:36 +0200 Subject: [PATCH 03/19] Update emails attributes --- app/controllers/api/account.php | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 5563fc6a59..68cf189395 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -57,6 +57,7 @@ use Utopia\Database\Validator\Query\Cursor; use Utopia\Database\Validator\Query\Limit; use Utopia\Database\Validator\Query\Offset; use Utopia\Database\Validator\UID; +use Utopia\Emails\Email as EmailCanonical; use Utopia\Locale\Locale; use Utopia\Storage\Validator\FileName; use Utopia\System\System; @@ -1674,7 +1675,15 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') } if (empty($user->getAttribute('email'))) { - $user->setAttribute('email', $oauth2->getUserEmail($accessToken)); + $emailCanonical = new EmailCanonical($oauth2->getUserEmail($accessToken)); + + $user + ->setAttribute('email', $oauth2->getUserEmail($accessToken)) + ->setAttribute('emailCanonical', $emailCanonical->getCanonical()) + ->setAttribute('emailIsCanonical', $emailCanonical->isCanonicalSupported()) + ->setAttribute('emailIsCorporate', $emailCanonical->isCorporate()) + ->setAttribute('emailIsDisposable', $emailCanonical->isDisposable()) + ->setAttribute('emailIsFree', $emailCanonical->isFree()); } if (empty($user->getAttribute('name'))) { @@ -2219,6 +2228,8 @@ App::post('/v1/account/tokens/email') $userId = $userId === 'unique()' ? ID::unique() : $userId; + $emailCanonical = new EmailCanonical($email); + $user->setAttributes([ '$id' => $userId, '$permissions' => [ @@ -2241,6 +2252,11 @@ App::post('/v1/account/tokens/email') 'memberships' => null, 'search' => implode(' ', [$userId, $email]), 'accessedAt' => DateTime::now(), + 'emailCanonical' => $emailCanonical->getCanonical(), + 'emailIsCanonical' => $emailCanonical->isCanonicalSupported(), + 'emailIsCorporate' => $emailCanonical->isCorporate(), + 'emailIsDisposable' => $emailCanonical->isDisposable(), + 'emailIsFree' => $emailCanonical->isFree(), ]); $user->removeAttribute('$sequence'); @@ -3050,9 +3066,17 @@ App::patch('/v1/account/email') throw new Exception(Exception::GENERAL_BAD_REQUEST); /** Return a generic bad request to prevent exposing existing accounts */ } + $emailCanonical = new EmailCanonical($email); + $user ->setAttribute('email', $email) ->setAttribute('emailVerification', false) // After this user needs to confirm mail again + ->setAttribute('emailVerification', false) + ->setAttribute('emailCanonical', $emailCanonical->getCanonical()) + ->setAttribute('emailIsCanonical', $emailCanonical->isCanonicalSupported()) + ->setAttribute('emailIsCorporate', $emailCanonical->isCorporate()) + ->setAttribute('emailIsDisposable', $emailCanonical->isDisposable()) + ->setAttribute('emailIsFree', $emailCanonical->isFree()) ; if (empty($passwordUpdate)) { From fea16309092535dd3ac5017136cffa206518312c Mon Sep 17 00:00:00 2001 From: fogelito Date: Thu, 30 Oct 2025 18:18:09 +0200 Subject: [PATCH 04/19] Remove redundant emailVerification --- app/controllers/api/account.php | 1 - 1 file changed, 1 deletion(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 68cf189395..cdd7280fb7 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -3071,7 +3071,6 @@ App::patch('/v1/account/email') $user ->setAttribute('email', $email) ->setAttribute('emailVerification', false) // After this user needs to confirm mail again - ->setAttribute('emailVerification', false) ->setAttribute('emailCanonical', $emailCanonical->getCanonical()) ->setAttribute('emailIsCanonical', $emailCanonical->isCanonicalSupported()) ->setAttribute('emailIsCorporate', $emailCanonical->isCorporate()) From ca8cb6f2601d3e520ef9023387d442364bf21f46 Mon Sep 17 00:00:00 2001 From: fogelito Date: Sun, 2 Nov 2025 09:13:55 +0200 Subject: [PATCH 05/19] Add utopia-php/emails --- app/controllers/api/account.php | 6 +- composer.json | 1 + composer.lock | 162 ++++++++++++++++++++++---------- 3 files changed, 116 insertions(+), 53 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index cdd7280fb7..6c31f28754 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -1675,10 +1675,12 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') } if (empty($user->getAttribute('email'))) { - $emailCanonical = new EmailCanonical($oauth2->getUserEmail($accessToken)); + $oauth2Email = $oauth2->getUserEmail($accessToken); + + $emailCanonical = new EmailCanonical($oauth2Email); $user - ->setAttribute('email', $oauth2->getUserEmail($accessToken)) + ->setAttribute('email', $oauth2Email) ->setAttribute('emailCanonical', $emailCanonical->getCanonical()) ->setAttribute('emailIsCanonical', $emailCanonical->isCanonicalSupported()) ->setAttribute('emailIsCorporate', $emailCanonical->isCorporate()) diff --git a/composer.json b/composer.json index 3e00015220..f008c8e089 100644 --- a/composer.json +++ b/composer.json @@ -60,6 +60,7 @@ "utopia-php/framework": "0.33.*", "utopia-php/fetch": "0.4.*", "utopia-php/image": "0.8.*", + "utopia-php/emails": "0.6.*", "utopia-php/locale": "0.8.*", "utopia-php/logger": "0.6.*", "utopia-php/messaging": "0.20.*", diff --git a/composer.lock b/composer.lock index 36c2c94265..394e2a9e3e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f826d3b283b10af98dfd565c4187a83a", + "content-hash": "c53027e4009de3901769fe29b0d1a68f", "packages": [ { "name": "adhocore/jwt", @@ -3840,16 +3840,16 @@ }, { "name": "utopia-php/database", - "version": "3.0.2", + "version": "3.1.2", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "b92554e2e7b3b00f0f0acb2b53c6a11e1349b81e" + "reference": "b6541a9cd9b21786a5020327f582838afdb159aa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/b92554e2e7b3b00f0f0acb2b53c6a11e1349b81e", - "reference": "b92554e2e7b3b00f0f0acb2b53c6a11e1349b81e", + "url": "https://api.github.com/repos/utopia-php/database/zipball/b6541a9cd9b21786a5020327f582838afdb159aa", + "reference": "b6541a9cd9b21786a5020327f582838afdb159aa", "shasum": "" }, "require": { @@ -3892,22 +3892,22 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/3.0.2" + "source": "https://github.com/utopia-php/database/tree/3.1.2" }, - "time": "2025-10-20T23:58:56+00:00" + "time": "2025-10-30T13:10:13+00:00" }, { "name": "utopia-php/detector", - "version": "0.2.0", + "version": "0.2.2", "source": { "type": "git", "url": "https://github.com/utopia-php/detector.git", - "reference": "795ed56169af833fd6a4ea58a6c747e05ccc7ba6" + "reference": "9a41be5f21efe2d865de79b08dff94fff85ce5e9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/detector/zipball/795ed56169af833fd6a4ea58a6c747e05ccc7ba6", - "reference": "795ed56169af833fd6a4ea58a6c747e05ccc7ba6", + "url": "https://api.github.com/repos/utopia-php/detector/zipball/9a41be5f21efe2d865de79b08dff94fff85ce5e9", + "reference": "9a41be5f21efe2d865de79b08dff94fff85ce5e9", "shasum": "" }, "require": { @@ -3937,9 +3937,9 @@ ], "support": { "issues": "https://github.com/utopia-php/detector/issues", - "source": "https://github.com/utopia-php/detector/tree/0.2.0" + "source": "https://github.com/utopia-php/detector/tree/0.2.2" }, - "time": "2025-10-21T13:57:30+00:00" + "time": "2025-10-31T12:43:31+00:00" }, { "name": "utopia-php/dns", @@ -4106,6 +4106,66 @@ }, "time": "2024-05-07T02:01:25+00:00" }, + { + "name": "utopia-php/emails", + "version": "0.6.2", + "source": { + "type": "git", + "url": "https://github.com/utopia-php/emails.git", + "reference": "9c4c40cf7c03c2e9e21364566f9b192d03ea93c9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/utopia-php/emails/zipball/9c4c40cf7c03c2e9e21364566f9b192d03ea93c9", + "reference": "9c4c40cf7c03c2e9e21364566f9b192d03ea93c9", + "shasum": "" + }, + "require": { + "php": ">=8.0", + "utopia-php/cli": "^0.15", + "utopia-php/domains": "^0.9", + "utopia-php/fetch": "^0.4", + "utopia-php/validators": "^0.0.2" + }, + "require-dev": { + "laravel/pint": "1.25.*", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Utopia\\Emails\\": "src/Emails" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eldad Fux", + "email": "eldad@appwrite.io" + } + ], + "description": "Utopia Emails library is simple and lite library for parsing and validating email addresses. This library is aiming to be as simple and easy to learn and use.", + "keywords": [ + "RFC5322", + "email", + "emails", + "framework", + "parsing", + "php", + "upf", + "utopia", + "validation" + ], + "support": { + "issues": "https://github.com/utopia-php/emails/issues", + "source": "https://github.com/utopia-php/emails/tree/0.6.2" + }, + "time": "2025-10-28T16:08:17+00:00" + }, { "name": "utopia-php/fetch", "version": "0.4.2", @@ -4395,16 +4455,16 @@ }, { "name": "utopia-php/migration", - "version": "1.3.2", + "version": "1.3.3", "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "f5c1d2cae764290766a4c2d1546c1d51de95b67f" + "reference": "731b3a963c58c30e0b2368695d57a7e8fcb7455c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/f5c1d2cae764290766a4c2d1546c1d51de95b67f", - "reference": "f5c1d2cae764290766a4c2d1546c1d51de95b67f", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/731b3a963c58c30e0b2368695d57a7e8fcb7455c", + "reference": "731b3a963c58c30e0b2368695d57a7e8fcb7455c", "shasum": "" }, "require": { @@ -4444,9 +4504,9 @@ ], "support": { "issues": "https://github.com/utopia-php/migration/issues", - "source": "https://github.com/utopia-php/migration/tree/1.3.2" + "source": "https://github.com/utopia-php/migration/tree/1.3.3" }, - "time": "2025-10-22T12:30:47+00:00" + "time": "2025-10-28T04:02:08+00:00" }, { "name": "utopia-php/mongo", @@ -5193,16 +5253,16 @@ }, { "name": "webmozart/assert", - "version": "1.12.0", + "version": "1.12.1", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "541057574806f942c94662b817a50f63f7345360" + "reference": "9be6926d8b485f55b9229203f962b51ed377ba68" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/541057574806f942c94662b817a50f63f7345360", - "reference": "541057574806f942c94662b817a50f63f7345360", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68", + "reference": "9be6926d8b485f55b9229203f962b51ed377ba68", "shasum": "" }, "require": { @@ -5245,9 +5305,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.12.0" + "source": "https://github.com/webmozarts/assert/tree/1.12.1" }, - "time": "2025-10-20T12:43:39+00:00" + "time": "2025-10-29T15:56:20+00:00" }, { "name": "webonyx/graphql-php", @@ -5318,16 +5378,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.4.11", + "version": "1.5.0", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "5970defc3c6e64817fe9847c0b33c87af71709c5" + "reference": "42df22195d6457e52e4c819678168470b114a816" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/5970defc3c6e64817fe9847c0b33c87af71709c5", - "reference": "5970defc3c6e64817fe9847c0b33c87af71709c5", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/42df22195d6457e52e4c819678168470b114a816", + "reference": "42df22195d6457e52e4c819678168470b114a816", "shasum": "" }, "require": { @@ -5363,9 +5423,9 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/1.4.11" + "source": "https://github.com/appwrite/sdk-generator/tree/1.5.0" }, - "time": "2025-10-24T10:03:09+00:00" + "time": "2025-10-31T10:10:25+00:00" }, { "name": "doctrine/annotations", @@ -6069,16 +6129,16 @@ }, { "name": "phpbench/phpbench", - "version": "1.4.1", + "version": "1.4.2", "source": { "type": "git", "url": "https://github.com/phpbench/phpbench.git", - "reference": "78cd98a9aa34e0f8f80ca01972a8b88d2c30194b" + "reference": "bb61ae6c54b3d58642be154eb09f4e73c3511018" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpbench/phpbench/zipball/78cd98a9aa34e0f8f80ca01972a8b88d2c30194b", - "reference": "78cd98a9aa34e0f8f80ca01972a8b88d2c30194b", + "url": "https://api.github.com/repos/phpbench/phpbench/zipball/bb61ae6c54b3d58642be154eb09f4e73c3511018", + "reference": "bb61ae6c54b3d58642be154eb09f4e73c3511018", "shasum": "" }, "require": { @@ -6105,7 +6165,7 @@ "ergebnis/composer-normalize": "^2.39", "friendsofphp/php-cs-fixer": "^3.0", "jangregor/phpstan-prophecy": "^1.0", - "phpspec/prophecy": "dev-master", + "phpspec/prophecy": "^1.22", "phpstan/extension-installer": "^1.1", "phpstan/phpstan": "^1.0", "phpstan/phpstan-phpunit": "^1.0", @@ -6155,7 +6215,7 @@ ], "support": { "issues": "https://github.com/phpbench/phpbench/issues", - "source": "https://github.com/phpbench/phpbench/tree/1.4.1" + "source": "https://github.com/phpbench/phpbench/tree/1.4.2" }, "funding": [ { @@ -6163,7 +6223,7 @@ "type": "github" } ], - "time": "2025-03-12T08:01:40+00:00" + "time": "2025-10-26T14:21:59+00:00" }, { "name": "phpstan/phpstan", @@ -7812,16 +7872,16 @@ }, { "name": "symfony/console", - "version": "v7.3.4", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "2b9c5fafbac0399a20a2e82429e2bd735dcfb7db" + "reference": "cdb80fa5869653c83cfe1a9084a673b6daf57ea7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/2b9c5fafbac0399a20a2e82429e2bd735dcfb7db", - "reference": "2b9c5fafbac0399a20a2e82429e2bd735dcfb7db", + "url": "https://api.github.com/repos/symfony/console/zipball/cdb80fa5869653c83cfe1a9084a673b6daf57ea7", + "reference": "cdb80fa5869653c83cfe1a9084a673b6daf57ea7", "shasum": "" }, "require": { @@ -7886,7 +7946,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.3.4" + "source": "https://github.com/symfony/console/tree/v7.3.5" }, "funding": [ { @@ -7906,7 +7966,7 @@ "type": "tidelift" } ], - "time": "2025-09-22T15:31:00+00:00" + "time": "2025-10-14T15:46:26+00:00" }, { "name": "symfony/filesystem", @@ -7980,16 +8040,16 @@ }, { "name": "symfony/finder", - "version": "v7.3.2", + "version": "v7.3.5", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe" + "reference": "9f696d2f1e340484b4683f7853b273abff94421f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/2a6614966ba1074fa93dae0bc804227422df4dfe", - "reference": "2a6614966ba1074fa93dae0bc804227422df4dfe", + "url": "https://api.github.com/repos/symfony/finder/zipball/9f696d2f1e340484b4683f7853b273abff94421f", + "reference": "9f696d2f1e340484b4683f7853b273abff94421f", "shasum": "" }, "require": { @@ -8024,7 +8084,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.3.2" + "source": "https://github.com/symfony/finder/tree/v7.3.5" }, "funding": [ { @@ -8044,7 +8104,7 @@ "type": "tidelift" } ], - "time": "2025-07-15T13:41:35+00:00" + "time": "2025-10-15T18:45:57+00:00" }, { "name": "symfony/options-resolver", @@ -8856,5 +8916,5 @@ "platform-overrides": { "php": "8.3" }, - "plugin-api-version": "2.3.0" + "plugin-api-version": "2.6.0" } From 371cab0658fd3136c21e49256402faa215090e23 Mon Sep 17 00:00:00 2001 From: fogelito Date: Sun, 2 Nov 2025 10:59:28 +0200 Subject: [PATCH 06/19] try catch EmailCanonical --- app/controllers/api/account.php | 48 ++++++++++++++++++++++----------- app/controllers/api/teams.php | 12 +++++++++ app/controllers/api/users.php | 12 +++++++++ 3 files changed, 57 insertions(+), 15 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 6c31f28754..e54dd3ef42 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -374,6 +374,13 @@ App::post('/v1/account') $passwordHistory = $project->getAttribute('auths', [])['passwordHistory'] ?? 0; $password = Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS); + + try { + $emailCanonical = new EmailCanonical($email); + } catch (Throwable) { + $emailCanonical = null; + } + try { $userId = $userId == 'unique()' ? ID::unique() : $userId; $user->setAttributes([ @@ -402,7 +409,13 @@ App::post('/v1/account') 'authenticators' => null, 'search' => implode(' ', [$userId, $email, $name]), 'accessedAt' => DateTime::now(), + 'emailCanonical' => $emailCanonical?->getCanonical(), + 'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(), + 'emailIsCorporate' => $emailCanonical?->isCorporate(), + 'emailIsDisposable' => $emailCanonical?->isDisposable(), // todo: fix throw + 'emailIsFree' => $emailCanonical?->isFree(), // todo: fix throw ]); + $user->removeAttribute('$sequence'); $user = Authorization::skip(fn () => $dbForProject->createDocument('users', $user)); try { @@ -1675,17 +1688,18 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') } if (empty($user->getAttribute('email'))) { - $oauth2Email = $oauth2->getUserEmail($accessToken); + $user->setAttribute('email', $oauth2->getUserEmail($accessToken)); - $emailCanonical = new EmailCanonical($oauth2Email); + try { + $emailCanonical = new EmailCanonical($user->getAttribute('email')); - $user - ->setAttribute('email', $oauth2Email) - ->setAttribute('emailCanonical', $emailCanonical->getCanonical()) - ->setAttribute('emailIsCanonical', $emailCanonical->isCanonicalSupported()) - ->setAttribute('emailIsCorporate', $emailCanonical->isCorporate()) - ->setAttribute('emailIsDisposable', $emailCanonical->isDisposable()) - ->setAttribute('emailIsFree', $emailCanonical->isFree()); + $user->setAttribute('emailCanonical', $emailCanonical->getCanonical()); + $user->setAttribute('emailIsCanonical', $emailCanonical->isCanonicalSupported()); + $user->setAttribute('emailIsCorporate', $emailCanonical->isCorporate()); + $user->setAttribute('emailIsDisposable', $emailCanonical->isDisposable()); + $user->setAttribute('emailIsFree', $emailCanonical->isFree()); + } catch (Throwable) { + } } if (empty($user->getAttribute('name'))) { @@ -2230,7 +2244,11 @@ App::post('/v1/account/tokens/email') $userId = $userId === 'unique()' ? ID::unique() : $userId; - $emailCanonical = new EmailCanonical($email); + try { + $emailCanonical = new EmailCanonical($email); + } catch (Throwable) { + $emailCanonical = null; + } $user->setAttributes([ '$id' => $userId, @@ -2254,11 +2272,11 @@ App::post('/v1/account/tokens/email') 'memberships' => null, 'search' => implode(' ', [$userId, $email]), 'accessedAt' => DateTime::now(), - 'emailCanonical' => $emailCanonical->getCanonical(), - 'emailIsCanonical' => $emailCanonical->isCanonicalSupported(), - 'emailIsCorporate' => $emailCanonical->isCorporate(), - 'emailIsDisposable' => $emailCanonical->isDisposable(), - 'emailIsFree' => $emailCanonical->isFree(), + 'emailCanonical' => $emailCanonical?->getCanonical(), + 'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(), + 'emailIsCorporate' => $emailCanonical?->isCorporate(), + 'emailIsDisposable' => $emailCanonical?->isDisposable(), // todo: fix throw + 'emailIsFree' => $emailCanonical?->isFree(), // todo: fix throw ]); $user->removeAttribute('$sequence'); diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index 7398e451b5..cb956390b7 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -48,6 +48,7 @@ use Utopia\Database\Validator\Query\Cursor; use Utopia\Database\Validator\Query\Limit; use Utopia\Database\Validator\Query\Offset; use Utopia\Database\Validator\UID; +use Utopia\Emails\Email as EmailCanonical; use Utopia\Locale\Locale; use Utopia\System\System; use Utopia\Validator\ArrayList; @@ -564,6 +565,12 @@ App::post('/v1/teams/:teamId/memberships') throw new Exception(Exception::USER_EMAIL_ALREADY_EXISTS); } + try { + $emailCanonical = new EmailCanonical($email); + } catch (Throwable) { + $emailCanonical = null; + } + try { $userId = ID::unique(); $invitee = Authorization::skip(fn () => $dbForProject->createDocument('users', new Document([ @@ -596,6 +603,11 @@ App::post('/v1/teams/:teamId/memberships') 'tokens' => null, 'memberships' => null, 'search' => implode(' ', [$userId, $email, $name]), + 'emailCanonical' => $emailCanonical?->getCanonical(), + 'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(), + 'emailIsCorporate' => $emailCanonical?->isCorporate(), + 'emailIsDisposable' => $emailCanonical?->isDisposable(), // todo: fix throw + 'emailIsFree' => $emailCanonical?->isFree(), // todo: fix throw ]))); } catch (Duplicate $th) { throw new Exception(Exception::USER_ALREADY_EXISTS); diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index 5498a33bf5..dccfec1101 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -49,6 +49,7 @@ use Utopia\Database\Validator\Query\Cursor; use Utopia\Database\Validator\Query\Limit; use Utopia\Database\Validator\Query\Offset; use Utopia\Database\Validator\UID; +use Utopia\Emails\Email as EmailCanonical; use Utopia\Locale\Locale; use Utopia\System\System; use Utopia\Validator\ArrayList; @@ -97,6 +98,12 @@ function createUser(string $hash, mixed $hashOptions, string $userId, ?string $e } } + try { + $emailCanonical = new EmailCanonical($email ?? ''); + } catch (Throwable) { + $emailCanonical = null; + } + $password = (!empty($password)) ? ($hash === 'plaintext' ? Auth::passwordHash($password, $hash, $hashOptionsObject) : $password) : null; $user = new Document([ '$id' => $userId, @@ -124,6 +131,11 @@ function createUser(string $hash, mixed $hashOptions, string $userId, ?string $e 'tokens' => null, 'memberships' => null, 'search' => implode(' ', [$userId, $email, $phone, $name]), + 'emailCanonical' => $emailCanonical?->getCanonical(), + 'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(), + 'emailIsCorporate' => $emailCanonical?->isCorporate(), + 'emailIsDisposable' => $emailCanonical?->isDisposable(), // todo: fix throw + 'emailIsFree' => $emailCanonical?->isFree(), // todo: fix throw ]); if ($hash === 'plaintext') { From 503145734b8ccf18f5e45b7ca1ac4e3430b8908f Mon Sep 17 00:00:00 2001 From: fogelito Date: Sun, 2 Nov 2025 11:06:56 +0200 Subject: [PATCH 07/19] Move user Document out of try block --- app/controllers/api/teams.php | 77 ++++++++++++++++++----------------- 1 file changed, 40 insertions(+), 37 deletions(-) diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index cb956390b7..7fbf4e2d38 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -571,44 +571,47 @@ App::post('/v1/teams/:teamId/memberships') $emailCanonical = null; } + $userId = ID::unique(); + + $userDocument = new Document([ + '$id' => $userId, + '$permissions' => [ + Permission::read(Role::any()), + Permission::read(Role::user($userId)), + Permission::update(Role::user($userId)), + Permission::delete(Role::user($userId)), + ], + 'email' => empty($email) ? null : $email, + 'phone' => empty($phone) ? null : $phone, + 'emailVerification' => false, + 'status' => true, + // TODO: Set password empty? + 'password' => Auth::passwordHash(Auth::passwordGenerator(), Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS), + 'hash' => Auth::DEFAULT_ALGO, + 'hashOptions' => Auth::DEFAULT_ALGO_OPTIONS, + /** + * Set the password update time to 0 for users created using + * team invite and OAuth to allow password updates without an + * old password + */ + 'passwordUpdate' => null, + 'registration' => DateTime::now(), + 'reset' => false, + 'name' => $name, + 'prefs' => new \stdClass(), + 'sessions' => null, + 'tokens' => null, + 'memberships' => null, + 'search' => implode(' ', [$userId, $email, $name]), + 'emailCanonical' => $emailCanonical?->getCanonical(), + 'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(), + 'emailIsCorporate' => $emailCanonical?->isCorporate(), + 'emailIsDisposable' => $emailCanonical?->isDisposable(), // todo: fix throw + 'emailIsFree' => $emailCanonical?->isFree(), // todo: fix throw + ]); + try { - $userId = ID::unique(); - $invitee = Authorization::skip(fn () => $dbForProject->createDocument('users', new Document([ - '$id' => $userId, - '$permissions' => [ - Permission::read(Role::any()), - Permission::read(Role::user($userId)), - Permission::update(Role::user($userId)), - Permission::delete(Role::user($userId)), - ], - 'email' => empty($email) ? null : $email, - 'phone' => empty($phone) ? null : $phone, - 'emailVerification' => false, - 'status' => true, - // TODO: Set password empty? - 'password' => Auth::passwordHash(Auth::passwordGenerator(), Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS), - 'hash' => Auth::DEFAULT_ALGO, - 'hashOptions' => Auth::DEFAULT_ALGO_OPTIONS, - /** - * Set the password update time to 0 for users created using - * team invite and OAuth to allow password updates without an - * old password - */ - 'passwordUpdate' => null, - 'registration' => DateTime::now(), - 'reset' => false, - 'name' => $name, - 'prefs' => new \stdClass(), - 'sessions' => null, - 'tokens' => null, - 'memberships' => null, - 'search' => implode(' ', [$userId, $email, $name]), - 'emailCanonical' => $emailCanonical?->getCanonical(), - 'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(), - 'emailIsCorporate' => $emailCanonical?->isCorporate(), - 'emailIsDisposable' => $emailCanonical?->isDisposable(), // todo: fix throw - 'emailIsFree' => $emailCanonical?->isFree(), // todo: fix throw - ]))); + $invitee = Authorization::skip(fn () => $dbForProject->createDocument('users', $userDocument)); } catch (Duplicate $th) { throw new Exception(Exception::USER_ALREADY_EXISTS); } From 6a41f8b2b9d587eb6ae381c88f90faed6be0d41f Mon Sep 17 00:00:00 2001 From: fogelito Date: Sun, 2 Nov 2025 11:16:12 +0200 Subject: [PATCH 08/19] Remove todos --- app/controllers/api/account.php | 8 ++++---- app/controllers/api/teams.php | 4 ++-- app/controllers/api/users.php | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index e54dd3ef42..00b4c619d7 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -412,8 +412,8 @@ App::post('/v1/account') 'emailCanonical' => $emailCanonical?->getCanonical(), 'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(), 'emailIsCorporate' => $emailCanonical?->isCorporate(), - 'emailIsDisposable' => $emailCanonical?->isDisposable(), // todo: fix throw - 'emailIsFree' => $emailCanonical?->isFree(), // todo: fix throw + 'emailIsDisposable' => $emailCanonical?->isDisposable(), + 'emailIsFree' => $emailCanonical?->isFree(), ]); $user->removeAttribute('$sequence'); @@ -2275,8 +2275,8 @@ App::post('/v1/account/tokens/email') 'emailCanonical' => $emailCanonical?->getCanonical(), 'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(), 'emailIsCorporate' => $emailCanonical?->isCorporate(), - 'emailIsDisposable' => $emailCanonical?->isDisposable(), // todo: fix throw - 'emailIsFree' => $emailCanonical?->isFree(), // todo: fix throw + 'emailIsDisposable' => $emailCanonical?->isDisposable(), + 'emailIsFree' => $emailCanonical?->isFree(), ]); $user->removeAttribute('$sequence'); diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index 7fbf4e2d38..3a9908ffe3 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -606,8 +606,8 @@ App::post('/v1/teams/:teamId/memberships') 'emailCanonical' => $emailCanonical?->getCanonical(), 'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(), 'emailIsCorporate' => $emailCanonical?->isCorporate(), - 'emailIsDisposable' => $emailCanonical?->isDisposable(), // todo: fix throw - 'emailIsFree' => $emailCanonical?->isFree(), // todo: fix throw + 'emailIsDisposable' => $emailCanonical?->isDisposable(), + 'emailIsFree' => $emailCanonical?->isFree(), ]); try { diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index dccfec1101..d88148b5a1 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -134,8 +134,8 @@ function createUser(string $hash, mixed $hashOptions, string $userId, ?string $e 'emailCanonical' => $emailCanonical?->getCanonical(), 'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(), 'emailIsCorporate' => $emailCanonical?->isCorporate(), - 'emailIsDisposable' => $emailCanonical?->isDisposable(), // todo: fix throw - 'emailIsFree' => $emailCanonical?->isFree(), // todo: fix throw + 'emailIsDisposable' => $emailCanonical?->isDisposable(), + 'emailIsFree' => $emailCanonical?->isFree(), ]); if ($hash === 'plaintext') { From fe9d49c3465b803769773a20cf4cad97cf0e748d Mon Sep 17 00:00:00 2001 From: fogelito Date: Tue, 4 Nov 2025 17:13:53 +0200 Subject: [PATCH 09/19] Response Model --- src/Appwrite/Utopia/Response/Model/User.php | 30 +++++++++++++++++++++ tests/e2e/Services/Account/AccountBase.php | 4 ++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/Appwrite/Utopia/Response/Model/User.php b/src/Appwrite/Utopia/Response/Model/User.php index 672b8885a0..c9ec5e36bc 100644 --- a/src/Appwrite/Utopia/Response/Model/User.php +++ b/src/Appwrite/Utopia/Response/Model/User.php @@ -139,6 +139,36 @@ class User extends Model 'default' => '', 'example' => self::TYPE_DATETIME_EXAMPLE, ]) + ->addRule('emailCanonical', [ + 'type' => self::TYPE_STRING, + 'description' => 'User email address.', + 'default' => null, + 'example' => 'john@appwrite.io', + ]) + ->addRule('emailIsCanonical', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'User email is canonical.', + 'default' => null, + 'example' => true, + ]) + ->addRule('emailIsCorporate', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'User email is corporate.', + 'default' => null, + 'example' => true, + ]) + ->addRule('emailIsDisposable', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'User email is disposable.', + 'default' => null, + 'example' => true, + ]) + ->addRule('emailIsFree', [ + 'type' => self::TYPE_BOOLEAN, + 'description' => 'User email is free.', + 'default' => null, + 'example' => true, + ]) ; } diff --git a/tests/e2e/Services/Account/AccountBase.php b/tests/e2e/Services/Account/AccountBase.php index b2f85637a8..d51632c6c9 100644 --- a/tests/e2e/Services/Account/AccountBase.php +++ b/tests/e2e/Services/Account/AccountBase.php @@ -29,7 +29,7 @@ trait AccountBase ]); $id = $response['body']['$id']; - + var_dump($response['body']); $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['$id']); @@ -41,6 +41,8 @@ trait AccountBase $this->assertNotEmpty($response['body']['accessedAt']); $this->assertArrayHasKey('targets', $response['body']); $this->assertEquals($email, $response['body']['targets'][0]['identifier']); + $this->assertEquals('shmuel', 'fogel'); + /** * Test for FAILURE From 1f0e3e0f51bd8f166e50ae2aa8386077ba67099a Mon Sep 17 00:00:00 2001 From: fogelito Date: Wed, 5 Nov 2025 13:21:52 +0200 Subject: [PATCH 10/19] lock file --- composer.lock | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/composer.lock b/composer.lock index 394e2a9e3e..c950ec5492 100644 --- a/composer.lock +++ b/composer.lock @@ -3840,16 +3840,16 @@ }, { "name": "utopia-php/database", - "version": "3.1.2", + "version": "3.1.3", "source": { "type": "git", "url": "https://github.com/utopia-php/database.git", - "reference": "b6541a9cd9b21786a5020327f582838afdb159aa" + "reference": "b38cc9887a8fefedcb9a962168dd6f28b7082fc1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/database/zipball/b6541a9cd9b21786a5020327f582838afdb159aa", - "reference": "b6541a9cd9b21786a5020327f582838afdb159aa", + "url": "https://api.github.com/repos/utopia-php/database/zipball/b38cc9887a8fefedcb9a962168dd6f28b7082fc1", + "reference": "b38cc9887a8fefedcb9a962168dd6f28b7082fc1", "shasum": "" }, "require": { @@ -3892,9 +3892,9 @@ ], "support": { "issues": "https://github.com/utopia-php/database/issues", - "source": "https://github.com/utopia-php/database/tree/3.1.2" + "source": "https://github.com/utopia-php/database/tree/3.1.3" }, - "time": "2025-10-30T13:10:13+00:00" + "time": "2025-11-04T11:41:54+00:00" }, { "name": "utopia-php/detector", @@ -5378,16 +5378,16 @@ "packages-dev": [ { "name": "appwrite/sdk-generator", - "version": "1.5.0", + "version": "1.5.1", "source": { "type": "git", "url": "https://github.com/appwrite/sdk-generator.git", - "reference": "42df22195d6457e52e4c819678168470b114a816" + "reference": "cd712674e34136f706e9170641ed6f4ce160e772" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/42df22195d6457e52e4c819678168470b114a816", - "reference": "42df22195d6457e52e4c819678168470b114a816", + "url": "https://api.github.com/repos/appwrite/sdk-generator/zipball/cd712674e34136f706e9170641ed6f4ce160e772", + "reference": "cd712674e34136f706e9170641ed6f4ce160e772", "shasum": "" }, "require": { @@ -5423,9 +5423,9 @@ "description": "Appwrite PHP library for generating API SDKs for multiple programming languages and platforms", "support": { "issues": "https://github.com/appwrite/sdk-generator/issues", - "source": "https://github.com/appwrite/sdk-generator/tree/1.5.0" + "source": "https://github.com/appwrite/sdk-generator/tree/1.5.1" }, - "time": "2025-10-31T10:10:25+00:00" + "time": "2025-11-04T09:55:47+00:00" }, { "name": "doctrine/annotations", From 5afae248aead81a96410d1b06ba0a73e2108ffd9 Mon Sep 17 00:00:00 2001 From: fogelito Date: Mon, 10 Nov 2025 15:00:18 +0200 Subject: [PATCH 11/19] Test Update --- composer.json | 1 - tests/e2e/Services/Account/AccountBase.php | 9 ++++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/composer.json b/composer.json index 9694c2dc51..62abce9ca2 100644 --- a/composer.json +++ b/composer.json @@ -60,7 +60,6 @@ "utopia-php/framework": "0.33.*", "utopia-php/fetch": "0.4.*", "utopia-php/image": "0.8.*", - "utopia-php/emails": "0.6.*", "utopia-php/locale": "0.8.*", "utopia-php/logger": "0.6.*", "utopia-php/messaging": "0.20.*", diff --git a/tests/e2e/Services/Account/AccountBase.php b/tests/e2e/Services/Account/AccountBase.php index d51632c6c9..9f35932700 100644 --- a/tests/e2e/Services/Account/AccountBase.php +++ b/tests/e2e/Services/Account/AccountBase.php @@ -29,7 +29,7 @@ trait AccountBase ]); $id = $response['body']['$id']; - var_dump($response['body']); + $this->assertEquals(201, $response['headers']['status-code']); $this->assertNotEmpty($response['body']); $this->assertNotEmpty($response['body']['$id']); @@ -41,8 +41,11 @@ trait AccountBase $this->assertNotEmpty($response['body']['accessedAt']); $this->assertArrayHasKey('targets', $response['body']); $this->assertEquals($email, $response['body']['targets'][0]['identifier']); - $this->assertEquals('shmuel', 'fogel'); - + $this->assertArrayNotHasKey('emailCanonical', $response['body']); + $this->assertArrayNotHasKey('emailIsFree', $response['body']); + $this->assertArrayNotHasKey('emailIsDisposable', $response['body']); + $this->assertArrayNotHasKey('emailIsCorporate', $response['body']); + $this->assertArrayNotHasKey('emailIsCanonical', $response['body']); /** * Test for FAILURE From 6096c42ff9fa9fd80b4a4dee355fe925ec1b1e82 Mon Sep 17 00:00:00 2001 From: fogelito Date: Mon, 10 Nov 2025 17:30:40 +0200 Subject: [PATCH 12/19] More updates --- app/controllers/api/account.php | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 737f5c7016..41d746e7dc 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -1612,6 +1612,12 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') $failureRedirect(Exception::GENERAL_BAD_REQUEST); /** Return a generic bad request to prevent exposing existing accounts */ } + try { + $emailCanonical = new EmailCanonical($email); + } catch (Throwable) { + $emailCanonical = null; + } + try { $userId = ID::unique(); $user->setAttributes([ @@ -1639,7 +1645,13 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') 'authenticators' => null, 'search' => implode(' ', [$userId, $email, $name]), 'accessedAt' => DateTime::now(), + 'emailCanonical' => $emailCanonical?->getCanonical(), + 'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(), + 'emailIsCorporate' => $emailCanonical?->isCorporate(), + 'emailIsDisposable' => $emailCanonical?->isDisposable(), + 'emailIsFree' => $emailCanonical?->isFree(), ]); + $user->removeAttribute('$sequence'); $userDoc = Authorization::skip(fn () => $dbForProject->createDocument('users', $user)); $dbForProject->createDocument('targets', new Document([ @@ -2015,6 +2027,12 @@ App::post('/v1/account/tokens/magic-url') $userId = $userId === 'unique()' ? ID::unique() : $userId; + try { + $emailCanonical = new EmailCanonical($email); + } catch (Throwable) { + $emailCanonical = null; + } + $user->setAttributes([ '$id' => $userId, '$permissions' => [ @@ -2039,6 +2057,11 @@ App::post('/v1/account/tokens/magic-url') 'authenticators' => null, 'search' => implode(' ', [$userId, $email]), 'accessedAt' => DateTime::now(), + 'emailCanonical' => $emailCanonical?->getCanonical(), + 'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(), + 'emailIsCorporate' => $emailCanonical?->isCorporate(), + 'emailIsDisposable' => $emailCanonical?->isDisposable(), + 'emailIsFree' => $emailCanonical?->isFree(), ]); $user->removeAttribute('$sequence'); @@ -2645,6 +2668,11 @@ App::post('/v1/account/tokens/phone') 'memberships' => null, 'search' => implode(' ', [$userId, $phone]), 'accessedAt' => DateTime::now(), + 'emailCanonical' => null, + 'emailIsCanonical' => null, + 'emailIsCorporate' => null, + 'emailIsDisposable' => null, + 'emailIsFree' => null, ]); $user->removeAttribute('$sequence'); From ce7f40dd34b4641e493d76c88f451e787952247c Mon Sep 17 00:00:00 2001 From: fogelito Date: Tue, 11 Nov 2025 08:16:09 +0200 Subject: [PATCH 13/19] Revert response model --- src/Appwrite/Utopia/Response/Model/User.php | 30 --------------------- 1 file changed, 30 deletions(-) diff --git a/src/Appwrite/Utopia/Response/Model/User.php b/src/Appwrite/Utopia/Response/Model/User.php index c9ec5e36bc..672b8885a0 100644 --- a/src/Appwrite/Utopia/Response/Model/User.php +++ b/src/Appwrite/Utopia/Response/Model/User.php @@ -139,36 +139,6 @@ class User extends Model 'default' => '', 'example' => self::TYPE_DATETIME_EXAMPLE, ]) - ->addRule('emailCanonical', [ - 'type' => self::TYPE_STRING, - 'description' => 'User email address.', - 'default' => null, - 'example' => 'john@appwrite.io', - ]) - ->addRule('emailIsCanonical', [ - 'type' => self::TYPE_BOOLEAN, - 'description' => 'User email is canonical.', - 'default' => null, - 'example' => true, - ]) - ->addRule('emailIsCorporate', [ - 'type' => self::TYPE_BOOLEAN, - 'description' => 'User email is corporate.', - 'default' => null, - 'example' => true, - ]) - ->addRule('emailIsDisposable', [ - 'type' => self::TYPE_BOOLEAN, - 'description' => 'User email is disposable.', - 'default' => null, - 'example' => true, - ]) - ->addRule('emailIsFree', [ - 'type' => self::TYPE_BOOLEAN, - 'description' => 'User email is free.', - 'default' => null, - 'example' => true, - ]) ; } From b28e53696766487a3c0b079801a3d3ef433671d8 Mon Sep 17 00:00:00 2001 From: fogelito Date: Tue, 11 Nov 2025 10:35:39 +0200 Subject: [PATCH 14/19] Add try catch --- app/controllers/api/account.php | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 41d746e7dc..11824ec066 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -1725,14 +1725,15 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') try { $emailCanonical = new EmailCanonical($user->getAttribute('email')); - - $user->setAttribute('emailCanonical', $emailCanonical->getCanonical()); - $user->setAttribute('emailIsCanonical', $emailCanonical->isCanonicalSupported()); - $user->setAttribute('emailIsCorporate', $emailCanonical->isCorporate()); - $user->setAttribute('emailIsDisposable', $emailCanonical->isDisposable()); - $user->setAttribute('emailIsFree', $emailCanonical->isFree()); } catch (Throwable) { + $emailCanonical = null; } + + $user->setAttribute('emailCanonical', $emailCanonical?->getCanonical()); + $user->setAttribute('emailIsCanonical', $emailCanonical?->isCanonicalSupported()); + $user->setAttribute('emailIsCorporate', $emailCanonical?->isCorporate()); + $user->setAttribute('emailIsDisposable', $emailCanonical?->isDisposable()); + $user->setAttribute('emailIsFree', $emailCanonical?->isFree()); } if (empty($user->getAttribute('name'))) { @@ -3136,16 +3137,20 @@ App::patch('/v1/account/email') throw new Exception(Exception::GENERAL_BAD_REQUEST); /** Return a generic bad request to prevent exposing existing accounts */ } - $emailCanonical = new EmailCanonical($email); + try { + $emailCanonical = new EmailCanonical($email); + } catch (Throwable) { + $emailCanonical = null; + } $user ->setAttribute('email', $email) ->setAttribute('emailVerification', false) // After this user needs to confirm mail again - ->setAttribute('emailCanonical', $emailCanonical->getCanonical()) - ->setAttribute('emailIsCanonical', $emailCanonical->isCanonicalSupported()) - ->setAttribute('emailIsCorporate', $emailCanonical->isCorporate()) - ->setAttribute('emailIsDisposable', $emailCanonical->isDisposable()) - ->setAttribute('emailIsFree', $emailCanonical->isFree()) + ->setAttribute('emailCanonical', $emailCanonical?->getCanonical()) + ->setAttribute('emailIsCanonical', $emailCanonical?->isCanonicalSupported()) + ->setAttribute('emailIsCorporate', $emailCanonical?->isCorporate()) + ->setAttribute('emailIsDisposable', $emailCanonical?->isDisposable()) + ->setAttribute('emailIsFree', $emailCanonical?->isFree()) ; if (empty($passwordUpdate)) { From cc97d78b5a0eeb12d625f399f08116f2e755e016 Mon Sep 17 00:00:00 2001 From: fogelito Date: Tue, 11 Nov 2025 11:35:18 +0200 Subject: [PATCH 15/19] Add try catch --- app/controllers/api/users.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index f32d394d48..0efb355d46 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -99,7 +99,7 @@ function createUser(string $hash, mixed $hashOptions, string $userId, ?string $e } try { - $emailCanonical = new EmailCanonical($email ?? ''); + $emailCanonical = new EmailCanonical($email); } catch (Throwable) { $emailCanonical = null; } From 27d0c511acea05096a0702dce9373a2b4af2ac7a Mon Sep 17 00:00:00 2001 From: fogelito Date: Tue, 11 Nov 2025 12:09:40 +0200 Subject: [PATCH 16/19] patch users email --- app/controllers/api/users.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index 0efb355d46..db08709054 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -1449,9 +1449,20 @@ App::patch('/v1/users/:userId/email') $oldEmail = $user->getAttribute('email'); + try { + $emailCanonical = new EmailCanonical($email); + } catch (Throwable) { + $emailCanonical = null; + } + $user ->setAttribute('email', $email) ->setAttribute('emailVerification', false) + ->setAttribute('emailCanonical', $emailCanonical?->getCanonical()) + ->setAttribute('emailIsCanonical', $emailCanonical?->isCanonicalSupported()) + ->setAttribute('emailIsCorporate', $emailCanonical?->isCorporate()) + ->setAttribute('emailIsDisposable', $emailCanonical?->isDisposable()) + ->setAttribute('emailIsFree', $emailCanonical?->isFree()) ; try { From da0a6b167fc376a0aa8bbd475d3d5be0e9872bbe Mon Sep 17 00:00:00 2001 From: fogelito Date: Tue, 11 Nov 2025 15:00:09 +0200 Subject: [PATCH 17/19] EmailValidator --- app/controllers/api/account.php | 86 ++++++++++++++++----------------- app/controllers/api/teams.php | 16 +++--- app/controllers/api/users.php | 30 ++++++------ 3 files changed, 66 insertions(+), 66 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 11824ec066..258479b10c 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -57,7 +57,7 @@ use Utopia\Database\Validator\Query\Cursor; use Utopia\Database\Validator\Query\Limit; use Utopia\Database\Validator\Query\Offset; use Utopia\Database\Validator\UID; -use Utopia\Emails\Email as EmailCanonical; +use Utopia\Emails\Email as EmailValidator; use Utopia\Locale\Locale; use Utopia\Storage\Validator\FileName; use Utopia\System\System; @@ -397,9 +397,9 @@ App::post('/v1/account') $password = Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS); try { - $emailCanonical = new EmailCanonical($email); + $emailValidator = new EmailValidator($email); } catch (Throwable) { - $emailCanonical = null; + $emailValidator = null; } try { @@ -430,11 +430,11 @@ App::post('/v1/account') 'authenticators' => null, 'search' => implode(' ', [$userId, $email, $name]), 'accessedAt' => DateTime::now(), - 'emailCanonical' => $emailCanonical?->getCanonical(), - 'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(), - 'emailIsCorporate' => $emailCanonical?->isCorporate(), - 'emailIsDisposable' => $emailCanonical?->isDisposable(), - 'emailIsFree' => $emailCanonical?->isFree(), + 'emailCanonical' => $emailValidator?->getCanonical(), + 'emailIsCanonical' => $emailValidator?->isCanonicalSupported(), + 'emailIsCorporate' => $emailValidator?->isCorporate(), + 'emailIsDisposable' => $emailValidator?->isDisposable(), + 'emailIsFree' => $emailValidator?->isFree(), ]); $user->removeAttribute('$sequence'); @@ -1613,9 +1613,9 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') } try { - $emailCanonical = new EmailCanonical($email); + $emailValidator = new EmailValidator($email); } catch (Throwable) { - $emailCanonical = null; + $emailValidator = null; } try { @@ -1645,11 +1645,11 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') 'authenticators' => null, 'search' => implode(' ', [$userId, $email, $name]), 'accessedAt' => DateTime::now(), - 'emailCanonical' => $emailCanonical?->getCanonical(), - 'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(), - 'emailIsCorporate' => $emailCanonical?->isCorporate(), - 'emailIsDisposable' => $emailCanonical?->isDisposable(), - 'emailIsFree' => $emailCanonical?->isFree(), + 'emailCanonical' => $emailValidator?->getCanonical(), + 'emailIsCanonical' => $emailValidator?->isCanonicalSupported(), + 'emailIsCorporate' => $emailValidator?->isCorporate(), + 'emailIsDisposable' => $emailValidator?->isDisposable(), + 'emailIsFree' => $emailValidator?->isFree(), ]); $user->removeAttribute('$sequence'); @@ -1724,16 +1724,16 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') $user->setAttribute('email', $oauth2->getUserEmail($accessToken)); try { - $emailCanonical = new EmailCanonical($user->getAttribute('email')); + $emailValidator = new EmailValidator($user->getAttribute('email')); } catch (Throwable) { - $emailCanonical = null; + $emailValidator = null; } - $user->setAttribute('emailCanonical', $emailCanonical?->getCanonical()); - $user->setAttribute('emailIsCanonical', $emailCanonical?->isCanonicalSupported()); - $user->setAttribute('emailIsCorporate', $emailCanonical?->isCorporate()); - $user->setAttribute('emailIsDisposable', $emailCanonical?->isDisposable()); - $user->setAttribute('emailIsFree', $emailCanonical?->isFree()); + $user->setAttribute('emailCanonical', $emailValidator?->getCanonical()); + $user->setAttribute('emailIsCanonical', $emailValidator?->isCanonicalSupported()); + $user->setAttribute('emailIsCorporate', $emailValidator?->isCorporate()); + $user->setAttribute('emailIsDisposable', $emailValidator?->isDisposable()); + $user->setAttribute('emailIsFree', $emailValidator?->isFree()); } if (empty($user->getAttribute('name'))) { @@ -2029,9 +2029,9 @@ App::post('/v1/account/tokens/magic-url') $userId = $userId === 'unique()' ? ID::unique() : $userId; try { - $emailCanonical = new EmailCanonical($email); + $emailValidator = new EmailValidator($email); } catch (Throwable) { - $emailCanonical = null; + $emailValidator = null; } $user->setAttributes([ @@ -2058,11 +2058,11 @@ App::post('/v1/account/tokens/magic-url') 'authenticators' => null, 'search' => implode(' ', [$userId, $email]), 'accessedAt' => DateTime::now(), - 'emailCanonical' => $emailCanonical?->getCanonical(), - 'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(), - 'emailIsCorporate' => $emailCanonical?->isCorporate(), - 'emailIsDisposable' => $emailCanonical?->isDisposable(), - 'emailIsFree' => $emailCanonical?->isFree(), + 'emailCanonical' => $emailValidator?->getCanonical(), + 'emailIsCanonical' => $emailValidator?->isCanonicalSupported(), + 'emailIsCorporate' => $emailValidator?->isCorporate(), + 'emailIsDisposable' => $emailValidator?->isDisposable(), + 'emailIsFree' => $emailValidator?->isFree(), ]); $user->removeAttribute('$sequence'); @@ -2290,9 +2290,9 @@ App::post('/v1/account/tokens/email') $userId = $userId === 'unique()' ? ID::unique() : $userId; try { - $emailCanonical = new EmailCanonical($email); + $emailValidator = new EmailValidator($email); } catch (Throwable) { - $emailCanonical = null; + $emailValidator = null; } $user->setAttributes([ @@ -2317,11 +2317,11 @@ App::post('/v1/account/tokens/email') 'memberships' => null, 'search' => implode(' ', [$userId, $email]), 'accessedAt' => DateTime::now(), - 'emailCanonical' => $emailCanonical?->getCanonical(), - 'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(), - 'emailIsCorporate' => $emailCanonical?->isCorporate(), - 'emailIsDisposable' => $emailCanonical?->isDisposable(), - 'emailIsFree' => $emailCanonical?->isFree(), + 'emailCanonical' => $emailValidator?->getCanonical(), + 'emailIsCanonical' => $emailValidator?->isCanonicalSupported(), + 'emailIsCorporate' => $emailValidator?->isCorporate(), + 'emailIsDisposable' => $emailValidator?->isDisposable(), + 'emailIsFree' => $emailValidator?->isFree(), ]); $user->removeAttribute('$sequence'); @@ -3138,19 +3138,19 @@ App::patch('/v1/account/email') } try { - $emailCanonical = new EmailCanonical($email); + $emailValidator = new EmailValidator($email); } catch (Throwable) { - $emailCanonical = null; + $emailValidator = null; } $user ->setAttribute('email', $email) ->setAttribute('emailVerification', false) // After this user needs to confirm mail again - ->setAttribute('emailCanonical', $emailCanonical?->getCanonical()) - ->setAttribute('emailIsCanonical', $emailCanonical?->isCanonicalSupported()) - ->setAttribute('emailIsCorporate', $emailCanonical?->isCorporate()) - ->setAttribute('emailIsDisposable', $emailCanonical?->isDisposable()) - ->setAttribute('emailIsFree', $emailCanonical?->isFree()) + ->setAttribute('emailCanonical', $emailValidator?->getCanonical()) + ->setAttribute('emailIsCanonical', $emailValidator?->isCanonicalSupported()) + ->setAttribute('emailIsCorporate', $emailValidator?->isCorporate()) + ->setAttribute('emailIsDisposable', $emailValidator?->isDisposable()) + ->setAttribute('emailIsFree', $emailValidator?->isFree()) ; if (empty($passwordUpdate)) { diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index cbf1be4052..85120abe29 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -48,7 +48,7 @@ use Utopia\Database\Validator\Query\Cursor; use Utopia\Database\Validator\Query\Limit; use Utopia\Database\Validator\Query\Offset; use Utopia\Database\Validator\UID; -use Utopia\Emails\Email as EmailCanonical; +use Utopia\Emails\Email as EmailValidator; use Utopia\Locale\Locale; use Utopia\System\System; use Utopia\Validator\ArrayList; @@ -568,9 +568,9 @@ App::post('/v1/teams/:teamId/memberships') } try { - $emailCanonical = new EmailCanonical($email); + $emailValidator = new EmailValidator($email); } catch (Throwable) { - $emailCanonical = null; + $emailValidator = null; } $userId = ID::unique(); @@ -605,11 +605,11 @@ App::post('/v1/teams/:teamId/memberships') 'tokens' => null, 'memberships' => null, 'search' => implode(' ', [$userId, $email, $name]), - 'emailCanonical' => $emailCanonical?->getCanonical(), - 'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(), - 'emailIsCorporate' => $emailCanonical?->isCorporate(), - 'emailIsDisposable' => $emailCanonical?->isDisposable(), - 'emailIsFree' => $emailCanonical?->isFree(), + 'emailCanonical' => $emailValidator?->getCanonical(), + 'emailIsCanonical' => $emailValidator?->isCanonicalSupported(), + 'emailIsCorporate' => $emailValidator?->isCorporate(), + 'emailIsDisposable' => $emailValidator?->isDisposable(), + 'emailIsFree' => $emailValidator?->isFree(), ]); try { diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index db08709054..75504e581b 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -49,7 +49,7 @@ use Utopia\Database\Validator\Query\Cursor; use Utopia\Database\Validator\Query\Limit; use Utopia\Database\Validator\Query\Offset; use Utopia\Database\Validator\UID; -use Utopia\Emails\Email as EmailCanonical; +use Utopia\Emails\Email as EmailValidator; use Utopia\Locale\Locale; use Utopia\System\System; use Utopia\Validator\ArrayList; @@ -99,9 +99,9 @@ function createUser(string $hash, mixed $hashOptions, string $userId, ?string $e } try { - $emailCanonical = new EmailCanonical($email); + $emailValidator = new EmailValidator($email); } catch (Throwable) { - $emailCanonical = null; + $emailValidator = null; } $password = (!empty($password)) ? ($hash === 'plaintext' ? Auth::passwordHash($password, $hash, $hashOptionsObject) : $password) : null; @@ -131,11 +131,11 @@ function createUser(string $hash, mixed $hashOptions, string $userId, ?string $e 'tokens' => null, 'memberships' => null, 'search' => implode(' ', [$userId, $email, $phone, $name]), - 'emailCanonical' => $emailCanonical?->getCanonical(), - 'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(), - 'emailIsCorporate' => $emailCanonical?->isCorporate(), - 'emailIsDisposable' => $emailCanonical?->isDisposable(), - 'emailIsFree' => $emailCanonical?->isFree(), + 'emailCanonical' => $emailValidator?->getCanonical(), + 'emailIsCanonical' => $emailValidator?->isCanonicalSupported(), + 'emailIsCorporate' => $emailValidator?->isCorporate(), + 'emailIsDisposable' => $emailValidator?->isDisposable(), + 'emailIsFree' => $emailValidator?->isFree(), ]); if ($hash === 'plaintext') { @@ -1450,19 +1450,19 @@ App::patch('/v1/users/:userId/email') $oldEmail = $user->getAttribute('email'); try { - $emailCanonical = new EmailCanonical($email); + $emailValidator = new EmailValidator($email); } catch (Throwable) { - $emailCanonical = null; + $emailValidator = null; } $user ->setAttribute('email', $email) ->setAttribute('emailVerification', false) - ->setAttribute('emailCanonical', $emailCanonical?->getCanonical()) - ->setAttribute('emailIsCanonical', $emailCanonical?->isCanonicalSupported()) - ->setAttribute('emailIsCorporate', $emailCanonical?->isCorporate()) - ->setAttribute('emailIsDisposable', $emailCanonical?->isDisposable()) - ->setAttribute('emailIsFree', $emailCanonical?->isFree()) + ->setAttribute('emailCanonical', $emailValidator?->getCanonical()) + ->setAttribute('emailIsCanonical', $emailValidator?->isCanonicalSupported()) + ->setAttribute('emailIsCorporate', $emailValidator?->isCorporate()) + ->setAttribute('emailIsDisposable', $emailValidator?->isDisposable()) + ->setAttribute('emailIsFree', $emailValidator?->isFree()) ; try { From 8b4a56cd9b829c9bb7c0e07fbe6736570f569126 Mon Sep 17 00:00:00 2001 From: fogelito Date: Tue, 11 Nov 2025 15:25:10 +0200 Subject: [PATCH 18/19] $emailCanonical --- app/controllers/api/account.php | 100 ++++++++++++++++---------------- app/controllers/api/teams.php | 20 +++---- app/controllers/api/users.php | 54 ++++++++--------- 3 files changed, 87 insertions(+), 87 deletions(-) diff --git a/app/controllers/api/account.php b/app/controllers/api/account.php index 258479b10c..b7959bb6a9 100644 --- a/app/controllers/api/account.php +++ b/app/controllers/api/account.php @@ -20,7 +20,7 @@ use Appwrite\Event\Messaging; use Appwrite\Event\StatsUsage; use Appwrite\Extend\Exception; use Appwrite\Hooks\Hooks; -use Appwrite\Network\Validator\Email; +use Appwrite\Network\Validator\Email as EmailValidator; use Appwrite\Network\Validator\Redirect; use Appwrite\OpenSSL\OpenSSL; use Appwrite\SDK\AuthType; @@ -57,7 +57,7 @@ use Utopia\Database\Validator\Query\Cursor; use Utopia\Database\Validator\Query\Limit; use Utopia\Database\Validator\Query\Offset; use Utopia\Database\Validator\UID; -use Utopia\Emails\Email as EmailValidator; +use Utopia\Emails\Email; use Utopia\Locale\Locale; use Utopia\Storage\Validator\FileName; use Utopia\System\System; @@ -338,7 +338,7 @@ App::post('/v1/account') )) ->label('abuse-limit', 10) ->param('userId', '', new CustomId(), 'User ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') - ->param('email', '', new Email(), 'User email.') + ->param('email', '', new EmailValidator(), 'User email.') ->param('password', '', fn ($project, $passwordsDictionary) => new PasswordDictionary($passwordsDictionary, $project->getAttribute('auths', [])['passwordDictionary'] ?? false), 'New user password. Must be between 8 and 256 chars.', false, ['project', 'passwordsDictionary']) ->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true) ->inject('request') @@ -397,9 +397,9 @@ App::post('/v1/account') $password = Auth::passwordHash($password, Auth::DEFAULT_ALGO, Auth::DEFAULT_ALGO_OPTIONS); try { - $emailValidator = new EmailValidator($email); + $emailCanonical = new Email($email); } catch (Throwable) { - $emailValidator = null; + $emailCanonical = null; } try { @@ -430,11 +430,11 @@ App::post('/v1/account') 'authenticators' => null, 'search' => implode(' ', [$userId, $email, $name]), 'accessedAt' => DateTime::now(), - 'emailCanonical' => $emailValidator?->getCanonical(), - 'emailIsCanonical' => $emailValidator?->isCanonicalSupported(), - 'emailIsCorporate' => $emailValidator?->isCorporate(), - 'emailIsDisposable' => $emailValidator?->isDisposable(), - 'emailIsFree' => $emailValidator?->isFree(), + 'emailCanonical' => $emailCanonical?->getCanonical(), + 'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(), + 'emailIsCorporate' => $emailCanonical?->isCorporate(), + 'emailIsDisposable' => $emailCanonical?->isDisposable(), + 'emailIsFree' => $emailCanonical?->isFree(), ]); $user->removeAttribute('$sequence'); @@ -917,7 +917,7 @@ App::post('/v1/account/sessions/email') )) ->label('abuse-limit', 10) ->label('abuse-key', 'url:{url},email:{param-email}') - ->param('email', '', new Email(), 'User email.') + ->param('email', '', new EmailValidator(), 'User email.') ->param('password', '', new Password(), 'User password. Must be at least 8 chars.') ->inject('request') ->inject('response') @@ -1613,9 +1613,9 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') } try { - $emailValidator = new EmailValidator($email); + $emailCanonical = new Email($email); } catch (Throwable) { - $emailValidator = null; + $emailCanonical = null; } try { @@ -1645,11 +1645,11 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') 'authenticators' => null, 'search' => implode(' ', [$userId, $email, $name]), 'accessedAt' => DateTime::now(), - 'emailCanonical' => $emailValidator?->getCanonical(), - 'emailIsCanonical' => $emailValidator?->isCanonicalSupported(), - 'emailIsCorporate' => $emailValidator?->isCorporate(), - 'emailIsDisposable' => $emailValidator?->isDisposable(), - 'emailIsFree' => $emailValidator?->isFree(), + 'emailCanonical' => $emailCanonical?->getCanonical(), + 'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(), + 'emailIsCorporate' => $emailCanonical?->isCorporate(), + 'emailIsDisposable' => $emailCanonical?->isDisposable(), + 'emailIsFree' => $emailCanonical?->isFree(), ]); $user->removeAttribute('$sequence'); @@ -1724,16 +1724,16 @@ App::get('/v1/account/sessions/oauth2/:provider/redirect') $user->setAttribute('email', $oauth2->getUserEmail($accessToken)); try { - $emailValidator = new EmailValidator($user->getAttribute('email')); + $emailCanonical = new Email($user->getAttribute('email')); } catch (Throwable) { - $emailValidator = null; + $emailCanonical = null; } - $user->setAttribute('emailCanonical', $emailValidator?->getCanonical()); - $user->setAttribute('emailIsCanonical', $emailValidator?->isCanonicalSupported()); - $user->setAttribute('emailIsCorporate', $emailValidator?->isCorporate()); - $user->setAttribute('emailIsDisposable', $emailValidator?->isDisposable()); - $user->setAttribute('emailIsFree', $emailValidator?->isFree()); + $user->setAttribute('emailCanonical', $emailCanonical?->getCanonical()); + $user->setAttribute('emailIsCanonical', $emailCanonical?->isCanonicalSupported()); + $user->setAttribute('emailIsCorporate', $emailCanonical?->isCorporate()); + $user->setAttribute('emailIsDisposable', $emailCanonical?->isDisposable()); + $user->setAttribute('emailIsFree', $emailCanonical?->isFree()); } if (empty($user->getAttribute('name'))) { @@ -1982,7 +1982,7 @@ App::post('/v1/account/tokens/magic-url') ->label('abuse-limit', 60) ->label('abuse-key', ['url:{url},email:{param-email}', 'url:{url},ip:{ip}']) ->param('userId', '', new CustomId(), 'Unique Id. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars. If the email address has never been used, a new account is created using the provided userId. Otherwise, if the email address is already attached to an account, the user ID is ignored.') - ->param('email', '', new Email(), 'User email.') + ->param('email', '', new EmailValidator(), 'User email.') ->param('url', '', fn ($platforms, $devKey) => $devKey->isEmpty() ? new Redirect($platforms) : new URL(), 'URL to redirect the user back to your app from the magic URL login. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', true, ['platforms', 'devKey']) ->param('phrase', false, new Boolean(), 'Toggle for security phrase. If enabled, email will be send with a randomly generated phrase and the phrase will also be included in the response. Confirming phrases match increases the security of your authentication flow.', true) ->inject('request') @@ -2029,9 +2029,9 @@ App::post('/v1/account/tokens/magic-url') $userId = $userId === 'unique()' ? ID::unique() : $userId; try { - $emailValidator = new EmailValidator($email); + $emailCanonical = new Email($email); } catch (Throwable) { - $emailValidator = null; + $emailCanonical = null; } $user->setAttributes([ @@ -2058,11 +2058,11 @@ App::post('/v1/account/tokens/magic-url') 'authenticators' => null, 'search' => implode(' ', [$userId, $email]), 'accessedAt' => DateTime::now(), - 'emailCanonical' => $emailValidator?->getCanonical(), - 'emailIsCanonical' => $emailValidator?->isCanonicalSupported(), - 'emailIsCorporate' => $emailValidator?->isCorporate(), - 'emailIsDisposable' => $emailValidator?->isDisposable(), - 'emailIsFree' => $emailValidator?->isFree(), + 'emailCanonical' => $emailCanonical?->getCanonical(), + 'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(), + 'emailIsCorporate' => $emailCanonical?->isCorporate(), + 'emailIsDisposable' => $emailCanonical?->isDisposable(), + 'emailIsFree' => $emailCanonical?->isFree(), ]); $user->removeAttribute('$sequence'); @@ -2246,7 +2246,7 @@ App::post('/v1/account/tokens/email') ->label('abuse-limit', 10) ->label('abuse-key', ['url:{url},email:{param-email}', 'url:{url},ip:{ip}']) ->param('userId', '', new CustomId(), 'User ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars. If the email address has never been used, a new account is created using the provided userId. Otherwise, if the email address is already attached to an account, the user ID is ignored.') - ->param('email', '', new Email(), 'User email.') + ->param('email', '', new EmailValidator(), 'User email.') ->param('phrase', false, new Boolean(), 'Toggle for security phrase. If enabled, email will be send with a randomly generated phrase and the phrase will also be included in the response. Confirming phrases match increases the security of your authentication flow.', true) ->inject('request') ->inject('response') @@ -2290,9 +2290,9 @@ App::post('/v1/account/tokens/email') $userId = $userId === 'unique()' ? ID::unique() : $userId; try { - $emailValidator = new EmailValidator($email); + $emailCanonical = new Email($email); } catch (Throwable) { - $emailValidator = null; + $emailCanonical = null; } $user->setAttributes([ @@ -2317,11 +2317,11 @@ App::post('/v1/account/tokens/email') 'memberships' => null, 'search' => implode(' ', [$userId, $email]), 'accessedAt' => DateTime::now(), - 'emailCanonical' => $emailValidator?->getCanonical(), - 'emailIsCanonical' => $emailValidator?->isCanonicalSupported(), - 'emailIsCorporate' => $emailValidator?->isCorporate(), - 'emailIsDisposable' => $emailValidator?->isDisposable(), - 'emailIsFree' => $emailValidator?->isFree(), + 'emailCanonical' => $emailCanonical?->getCanonical(), + 'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(), + 'emailIsCorporate' => $emailCanonical?->isCorporate(), + 'emailIsDisposable' => $emailCanonical?->isDisposable(), + 'emailIsFree' => $emailCanonical?->isFree(), ]); $user->removeAttribute('$sequence'); @@ -3102,7 +3102,7 @@ App::patch('/v1/account/email') ], contentType: ContentType::JSON )) - ->param('email', '', new Email(), 'User email.') + ->param('email', '', new EmailValidator(), 'User email.') ->param('password', '', new Password(), 'User password. Must be at least 8 chars.') ->inject('requestTimestamp') ->inject('response') @@ -3138,19 +3138,19 @@ App::patch('/v1/account/email') } try { - $emailValidator = new EmailValidator($email); + $emailCanonical = new Email($email); } catch (Throwable) { - $emailValidator = null; + $emailCanonical = null; } $user ->setAttribute('email', $email) ->setAttribute('emailVerification', false) // After this user needs to confirm mail again - ->setAttribute('emailCanonical', $emailValidator?->getCanonical()) - ->setAttribute('emailIsCanonical', $emailValidator?->isCanonicalSupported()) - ->setAttribute('emailIsCorporate', $emailValidator?->isCorporate()) - ->setAttribute('emailIsDisposable', $emailValidator?->isDisposable()) - ->setAttribute('emailIsFree', $emailValidator?->isFree()) + ->setAttribute('emailCanonical', $emailCanonical?->getCanonical()) + ->setAttribute('emailIsCanonical', $emailCanonical?->isCanonicalSupported()) + ->setAttribute('emailIsCorporate', $emailCanonical?->isCorporate()) + ->setAttribute('emailIsDisposable', $emailCanonical?->isDisposable()) + ->setAttribute('emailIsFree', $emailCanonical?->isFree()) ; if (empty($passwordUpdate)) { @@ -3387,7 +3387,7 @@ App::post('/v1/account/recovery') )) ->label('abuse-limit', 10) ->label('abuse-key', ['url:{url},email:{param-email}', 'url:{url},ip:{ip}']) - ->param('email', '', new Email(), 'User email.') + ->param('email', '', new EmailValidator(), 'User email.') ->param('url', '', fn ($platforms, $devKey) => $devKey->isEmpty() ? new Redirect($platforms) : new URL(), 'URL to redirect the user back to your app from the recovery email. Only URLs from hostnames in your project platform list are allowed. This requirement helps to prevent an [open redirect](https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html) attack against your project API.', false, ['platforms', 'devKey']) ->inject('request') ->inject('response') diff --git a/app/controllers/api/teams.php b/app/controllers/api/teams.php index 85120abe29..554ef6f4fe 100644 --- a/app/controllers/api/teams.php +++ b/app/controllers/api/teams.php @@ -10,7 +10,7 @@ use Appwrite\Event\Mail; use Appwrite\Event\Messaging; use Appwrite\Event\StatsUsage; use Appwrite\Extend\Exception; -use Appwrite\Network\Validator\Email; +use Appwrite\Network\Validator\Email as EmailValidator; use Appwrite\Network\Validator\Redirect; use Appwrite\Platform\Workers\Deletes; use Appwrite\SDK\AuthType; @@ -48,7 +48,7 @@ use Utopia\Database\Validator\Query\Cursor; use Utopia\Database\Validator\Query\Limit; use Utopia\Database\Validator\Query\Offset; use Utopia\Database\Validator\UID; -use Utopia\Emails\Email as EmailValidator; +use Utopia\Emails\Email; use Utopia\Locale\Locale; use Utopia\System\System; use Utopia\Validator\ArrayList; @@ -469,7 +469,7 @@ App::post('/v1/teams/:teamId/memberships') )) ->label('abuse-limit', 10) ->param('teamId', '', new UID(), 'Team ID.') - ->param('email', '', new Email(), 'Email of the new team member.', true) + ->param('email', '', new EmailValidator(), 'Email of the new team member.', true) ->param('userId', '', new UID(), 'ID of the user to be added to a team.', true) ->param('phone', '', new Phone(), 'Phone number. Format this number with a leading \'+\' and a country code, e.g., +16175551212.', true) ->param('roles', [], function (Document $project) { @@ -568,9 +568,9 @@ App::post('/v1/teams/:teamId/memberships') } try { - $emailValidator = new EmailValidator($email); + $emailCanonical = new Email($email); } catch (Throwable) { - $emailValidator = null; + $emailCanonical = null; } $userId = ID::unique(); @@ -605,11 +605,11 @@ App::post('/v1/teams/:teamId/memberships') 'tokens' => null, 'memberships' => null, 'search' => implode(' ', [$userId, $email, $name]), - 'emailCanonical' => $emailValidator?->getCanonical(), - 'emailIsCanonical' => $emailValidator?->isCanonicalSupported(), - 'emailIsCorporate' => $emailValidator?->isCorporate(), - 'emailIsDisposable' => $emailValidator?->isDisposable(), - 'emailIsFree' => $emailValidator?->isFree(), + 'emailCanonical' => $emailCanonical?->getCanonical(), + 'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(), + 'emailIsCorporate' => $emailCanonical?->isCorporate(), + 'emailIsDisposable' => $emailCanonical?->isDisposable(), + 'emailIsFree' => $emailCanonical?->isFree(), ]); try { diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index 75504e581b..956c9da042 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -16,7 +16,7 @@ use Appwrite\Event\Delete; use Appwrite\Event\Event; use Appwrite\Extend\Exception; use Appwrite\Hooks\Hooks; -use Appwrite\Network\Validator\Email; +use Appwrite\Network\Validator\Email as EmailValidator; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Deprecated; @@ -49,7 +49,7 @@ use Utopia\Database\Validator\Query\Cursor; use Utopia\Database\Validator\Query\Limit; use Utopia\Database\Validator\Query\Offset; use Utopia\Database\Validator\UID; -use Utopia\Emails\Email as EmailValidator; +use Utopia\Emails\Email; use Utopia\Locale\Locale; use Utopia\System\System; use Utopia\Validator\ArrayList; @@ -99,9 +99,9 @@ function createUser(string $hash, mixed $hashOptions, string $userId, ?string $e } try { - $emailValidator = new EmailValidator($email); + $emailCanonical = new Email($email); } catch (Throwable) { - $emailValidator = null; + $emailCanonical = null; } $password = (!empty($password)) ? ($hash === 'plaintext' ? Auth::passwordHash($password, $hash, $hashOptionsObject) : $password) : null; @@ -131,11 +131,11 @@ function createUser(string $hash, mixed $hashOptions, string $userId, ?string $e 'tokens' => null, 'memberships' => null, 'search' => implode(' ', [$userId, $email, $phone, $name]), - 'emailCanonical' => $emailValidator?->getCanonical(), - 'emailIsCanonical' => $emailValidator?->isCanonicalSupported(), - 'emailIsCorporate' => $emailValidator?->isCorporate(), - 'emailIsDisposable' => $emailValidator?->isDisposable(), - 'emailIsFree' => $emailValidator?->isFree(), + 'emailCanonical' => $emailCanonical?->getCanonical(), + 'emailIsCanonical' => $emailCanonical?->isCanonicalSupported(), + 'emailIsCorporate' => $emailCanonical?->isCorporate(), + 'emailIsDisposable' => $emailCanonical?->isDisposable(), + 'emailIsFree' => $emailCanonical?->isFree(), ]); if ($hash === 'plaintext') { @@ -220,7 +220,7 @@ App::post('/v1/users') ] )) ->param('userId', '', new CustomId(), 'User ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') - ->param('email', null, new Email(), 'User email.', true) + ->param('email', null, new EmailValidator(), 'User email.', true) ->param('phone', null, new Phone(), 'Phone number. Format this number with a leading \'+\' and a country code, e.g., +16175551212.', true) ->param('password', '', fn ($project, $passwordsDictionary) => new PasswordDictionary($passwordsDictionary, $project->getAttribute('auths', [])['passwordDictionary'] ?? false), 'Plain text user password. Must be at least 8 chars.', true, ['project', 'passwordsDictionary']) ->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true) @@ -255,7 +255,7 @@ App::post('/v1/users/bcrypt') ] )) ->param('userId', '', new CustomId(), 'User ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') - ->param('email', '', new Email(), 'User email.') + ->param('email', '', new EmailValidator(), 'User email.') ->param('password', '', new Password(), 'User password hashed using Bcrypt.') ->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true) ->inject('response') @@ -290,7 +290,7 @@ App::post('/v1/users/md5') ] )) ->param('userId', '', new CustomId(), 'User ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') - ->param('email', '', new Email(), 'User email.') + ->param('email', '', new EmailValidator(), 'User email.') ->param('password', '', new Password(), 'User password hashed using MD5.') ->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true) ->inject('response') @@ -325,7 +325,7 @@ App::post('/v1/users/argon2') ] )) ->param('userId', '', new CustomId(), 'User ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') - ->param('email', '', new Email(), 'User email.') + ->param('email', '', new EmailValidator(), 'User email.') ->param('password', '', new Password(), 'User password hashed using Argon2.') ->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true) ->inject('response') @@ -360,7 +360,7 @@ App::post('/v1/users/sha') ] )) ->param('userId', '', new CustomId(), 'User ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') - ->param('email', '', new Email(), 'User email.') + ->param('email', '', new EmailValidator(), 'User email.') ->param('password', '', new Password(), 'User password hashed using SHA.') ->param('passwordVersion', '', new WhiteList(['sha1', 'sha224', 'sha256', 'sha384', 'sha512/224', 'sha512/256', 'sha512', 'sha3-224', 'sha3-256', 'sha3-384', 'sha3-512']), "Optional SHA version used to hash password. Allowed values are: 'sha1', 'sha224', 'sha256', 'sha384', 'sha512/224', 'sha512/256', 'sha512', 'sha3-224', 'sha3-256', 'sha3-384', 'sha3-512'", true) ->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true) @@ -402,7 +402,7 @@ App::post('/v1/users/phpass') ] )) ->param('userId', '', new CustomId(), 'User ID. Choose a custom ID or pass the string `ID.unique()`to auto generate it. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') - ->param('email', '', new Email(), 'User email.') + ->param('email', '', new EmailValidator(), 'User email.') ->param('password', '', new Password(), 'User password hashed using PHPass.') ->param('name', '', new Text(128), 'User name. Max length: 128 chars.', true) ->inject('response') @@ -437,7 +437,7 @@ App::post('/v1/users/scrypt') ] )) ->param('userId', '', new CustomId(), 'User ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') - ->param('email', '', new Email(), 'User email.') + ->param('email', '', new EmailValidator(), 'User email.') ->param('password', '', new Password(), 'User password hashed using Scrypt.') ->param('passwordSalt', '', new Text(128), 'Optional salt used to hash password.') ->param('passwordCpu', 8, new Integer(), 'Optional CPU cost used to hash password.') @@ -485,7 +485,7 @@ App::post('/v1/users/scrypt-modified') ] )) ->param('userId', '', new CustomId(), 'User ID. Choose a custom ID or generate a random ID with `ID.unique()`. Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can\'t start with a special char. Max length is 36 chars.') - ->param('email', '', new Email(), 'User email.') + ->param('email', '', new EmailValidator(), 'User email.') ->param('password', '', new Password(), 'User password hashed using Scrypt Modified.') ->param('passwordSalt', '', new Text(128), 'Salt used to hash password.') ->param('passwordSaltSeparator', '', new Text(128), 'Salt separator used to hash password.') @@ -539,7 +539,7 @@ App::post('/v1/users/:userId/targets') switch ($providerType) { case 'email': - $validator = new Email(); + $validator = new EmailValidator(); if (!$validator->isValid($identifier)) { throw new Exception(Exception::GENERAL_INVALID_EMAIL); } @@ -1414,7 +1414,7 @@ App::patch('/v1/users/:userId/email') ] )) ->param('userId', '', new UID(), 'User ID.') - ->param('email', '', new Email(allowEmpty: true), 'User email.') + ->param('email', '', new EmailValidator(allowEmpty: true), 'User email.') ->inject('response') ->inject('dbForProject') ->inject('queueForEvents') @@ -1450,19 +1450,19 @@ App::patch('/v1/users/:userId/email') $oldEmail = $user->getAttribute('email'); try { - $emailValidator = new EmailValidator($email); + $emailCanonical = new EmailValidator($email); } catch (Throwable) { - $emailValidator = null; + $emailCanonical = null; } $user ->setAttribute('email', $email) ->setAttribute('emailVerification', false) - ->setAttribute('emailCanonical', $emailValidator?->getCanonical()) - ->setAttribute('emailIsCanonical', $emailValidator?->isCanonicalSupported()) - ->setAttribute('emailIsCorporate', $emailValidator?->isCorporate()) - ->setAttribute('emailIsDisposable', $emailValidator?->isDisposable()) - ->setAttribute('emailIsFree', $emailValidator?->isFree()) + ->setAttribute('emailCanonical', $emailCanonical?->getCanonical()) + ->setAttribute('emailIsCanonical', $emailCanonical?->isCanonicalSupported()) + ->setAttribute('emailIsCorporate', $emailCanonical?->isCorporate()) + ->setAttribute('emailIsDisposable', $emailCanonical?->isDisposable()) + ->setAttribute('emailIsFree', $emailCanonical?->isFree()) ; try { @@ -1723,7 +1723,7 @@ App::patch('/v1/users/:userId/targets/:targetId') switch ($providerType) { case 'email': - $validator = new Email(); + $validator = new EmailValidator(); if (!$validator->isValid($identifier)) { throw new Exception(Exception::GENERAL_INVALID_EMAIL); } From 97454c076887bb183a40277c89a067a44e4c089b Mon Sep 17 00:00:00 2001 From: fogelito Date: Tue, 11 Nov 2025 15:33:16 +0200 Subject: [PATCH 19/19] EmailValidator chnage to email --- app/controllers/api/users.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/api/users.php b/app/controllers/api/users.php index 956c9da042..a3ef4f852b 100644 --- a/app/controllers/api/users.php +++ b/app/controllers/api/users.php @@ -1450,7 +1450,7 @@ App::patch('/v1/users/:userId/email') $oldEmail = $user->getAttribute('email'); try { - $emailCanonical = new EmailValidator($email); + $emailCanonical = new Email($email); } catch (Throwable) { $emailCanonical = null; }