From 7e2347b3178278db338f7752cd91b75282ffcd8d Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Wed, 12 Aug 2026 11:10:07 +0200 Subject: [PATCH 01/21] Accept internationalized and uppercase email addresses in auth --- .../kitchen-sink/e2e-tests/tests/auth.spec.ts | 55 ++++++++++ examples/kitchen-sink/e2e-tests/tests/auth.ts | 2 +- .../kitchen-sink/e2e-tests/tests/helpers.ts | 8 ++ examples/kitchen-sink/package-lock.json | 28 ++--- waspc/ChangeLog.md | 1 + waspc/data/Generator/libs/auth/src/email.ts | 46 ++++++++ waspc/data/Generator/libs/auth/src/index.ts | 4 +- .../Generator/libs/auth/tests/email.test.ts | 102 ++++++++++++++++++ .../forms/internal/common/LoginSignupForm.tsx | 9 +- .../internal/email/ForgotPasswordForm.tsx | 7 +- .../wasp/auth/forms/internal/emailField.ts | 21 ++++ .../templates/sdk/wasp/auth/validation.ts | 10 +- .../snapshot-file-list.manifest | 5 + .../wasp-app/.wasp/out/.waspchecksums | 15 ++- .../forms/internal/common/LoginSignupForm.tsx | 7 +- .../internal/email/ForgotPasswordForm.tsx | 7 +- .../wasp/auth/forms/internal/emailField.ts | 21 ++++ .../.wasp/out/sdk/wasp/auth/validation.ts | 10 +- .../wasp-app/.wasp/out/.waspchecksums | 2 +- .../wasp-app/.wasp/out/.waspchecksums | 2 +- .../wasp-app/.wasp/out/.waspchecksums | 2 +- .../Generator/SdkGenerator/Auth/AuthFormsG.hs | 3 +- 22 files changed, 303 insertions(+), 64 deletions(-) create mode 100644 waspc/data/Generator/libs/auth/src/email.ts create mode 100644 waspc/data/Generator/libs/auth/tests/email.test.ts create mode 100644 waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/emailField.ts create mode 100644 waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/emailField.ts diff --git a/examples/kitchen-sink/e2e-tests/tests/auth.spec.ts b/examples/kitchen-sink/e2e-tests/tests/auth.spec.ts index afcd8eae2f..2608ea96b5 100644 --- a/examples/kitchen-sink/e2e-tests/tests/auth.spec.ts +++ b/examples/kitchen-sink/e2e-tests/tests/auth.spec.ts @@ -3,6 +3,7 @@ import { WASP_SERVER_URL } from "../playwright.config"; import { performEmailVerification, performLogin, performSignup } from "./auth"; import { generateRandomEmail, + generateRandomInternationalizedEmail, isRunningInDeployedMode, isRunningInDevMode, } from "./helpers"; @@ -96,4 +97,58 @@ test.describe("auth", () => { await expect(page).toHaveURL("/"); }); }); + + test.describe("internationalized email address", () => { + test.skip( + isRunningInDeployedMode(), + "Skipped in deployed mode (no Mailcrab)", + ); + test.describe.configure({ mode: "serial" }); + + const email = generateRandomInternationalizedEmail(); + const password = "12345678"; + + test("can sign up", async ({ page }) => { + await performSignup(page, { + email, + password, + address: "Some at least 10 letter address", + }); + + await expect(page.locator("body")).toContainText( + `You've signed up successfully! Check your email for the confirmation link.`, + ); + }); + + test("can verify email", async ({ page }) => { + if (isRunningInDevMode()) { + // Skip this test in dev mode, as email confirmation is not required. + test.skip(); + } + + await performEmailVerification(page, email); + }); + + test("can log in", async ({ page }) => { + await performLogin(page, { email, password }); + + await expect(page).toHaveURL("/"); + }); + }); + + test.describe("invalid email address", () => { + test("signing up with a malformed address results in an error message", async ({ + page, + }) => { + await performSignup(page, { + email: "not-an-email", + password: "12345678", + address: "Some at least 10 letter address", + }); + + await expect(page.locator("body")).toContainText( + "Email must be a valid email", + ); + }); + }); }); diff --git a/examples/kitchen-sink/e2e-tests/tests/auth.ts b/examples/kitchen-sink/e2e-tests/tests/auth.ts index 1c5af74064..cad6e89052 100644 --- a/examples/kitchen-sink/e2e-tests/tests/auth.ts +++ b/examples/kitchen-sink/e2e-tests/tests/auth.ts @@ -107,7 +107,7 @@ async function navigateToLoginPage(page: Page) { } async function submitLoginForm(page: Page, credentials: BaseEmailCredentials) { - await page.locator("input[type='email']").fill(credentials.email); + await page.locator("input[name='email']").fill(credentials.email); await page.locator("input[type='password']").fill(credentials.password); await page.getByRole("button", { name: "Log in" }).click(); } diff --git a/examples/kitchen-sink/e2e-tests/tests/helpers.ts b/examples/kitchen-sink/e2e-tests/tests/helpers.ts index 55f4b2bacc..ff9e769d52 100644 --- a/examples/kitchen-sink/e2e-tests/tests/helpers.ts +++ b/examples/kitchen-sink/e2e-tests/tests/helpers.ts @@ -12,3 +12,11 @@ export function isRunningInDeployedMode() { export function generateRandomEmail(): string { return `${randomUUID()}@test.com`; } + +/** + * An address with non-ASCII characters on both sides of the `@`, the kind + * RFC 6531 allows and the HTML5 `input[type=email]` grammar does not. + */ +export function generateRandomInternationalizedEmail(): string { + return `jürgen-${randomUUID()}@münchen.test`; +} diff --git a/examples/kitchen-sink/package-lock.json b/examples/kitchen-sink/package-lock.json index d1192870b7..8540478d0b 100644 --- a/examples/kitchen-sink/package-lock.json +++ b/examples/kitchen-sink/package-lock.json @@ -95,7 +95,7 @@ "dotenv": "^16.6.1", "express": "~5.1.0", "helmet": "^6.0.0", - "morgan": "~1.10.0", + "morgan": "~1.11.0", "socket.io": "^4.6.1", "superjson": "^2.2.1" }, @@ -3992,7 +3992,7 @@ "node_modules/@wasp.sh/lib-auth": { "version": "0.26.0", "resolved": "file:.wasp/out/libs/auth/wasp.sh-lib-auth-0.26.0.tgz", - "integrity": "sha512-zLl/sYSAjchwkfRKGsia/UhL37O96yoj+DO61P33EzLUzr047pWQYxzoJTOH6c2ugVbG4jbNZYp43QpONqN7qw==", + "integrity": "sha512-3KHDgvGEVY9FrEx+6SHt3HN/SooBnq4y1ECZQviEKgEI980oL0Fqkz+XX/qTvUpxdw5AgPFs0MhhgjaLIuKXlQ==", "license": "MIT", "dependencies": { "@node-rs/argon2": "^2.0.2", @@ -7007,19 +7007,23 @@ "license": "MIT" }, "node_modules/morgan": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", - "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.11.0.tgz", + "integrity": "sha512-zSkVu3t18r39pw4ixfBKvfZi3y2UOqr7d4WYwcj3m8nXpEQK4rPO6GLzs/CExoRgmX3y9EjmmcXqv6jq0SK46g==", "license": "MIT", "dependencies": { "basic-auth": "~2.0.1", "debug": "2.6.9", "depd": "~2.0.0", - "on-finished": "~2.3.0", + "on-finished": "~2.4.1", "on-headers": "~1.1.0" }, "engines": { "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/morgan/node_modules/debug": { @@ -7037,18 +7041,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, - "node_modules/morgan/node_modules/on-finished": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", - "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", diff --git a/waspc/ChangeLog.md b/waspc/ChangeLog.md index 04cd16b0d8..5a572955cd 100644 --- a/waspc/ChangeLog.md +++ b/waspc/ChangeLog.md @@ -25,6 +25,7 @@ - The `OAuthData` type your auth hooks receive now properly includes the `slack` provider. ([#4655](https://github.com/wasp-lang/wasp/pull/4655)) - Password reset now rejects an invalid or expired token before it looks at the new password, so someone without a valid reset link can no longer probe your app's password rules. ([#4657](https://github.com/wasp-lang/wasp/pull/4657)) - `onBeforeSignup` now runs before `userSignupFields` on every signup method: email, username and password, and OAuth. ([#4659](https://github.com/wasp-lang/wasp/pull/4659)) +- Email validation now accepts internationalized addresses such as `jürgen@münchen.de`, accepts addresses typed in uppercase, and no longer accepts a string that merely contains an address somewhere inside it. ([#1392](https://github.com/wasp-lang/wasp/issues/1392)) ## 0.25.0 diff --git a/waspc/data/Generator/libs/auth/src/email.ts b/waspc/data/Generator/libs/auth/src/email.ts new file mode 100644 index 0000000000..f8c7cee9e0 --- /dev/null +++ b/waspc/data/Generator/libs/auth/src/email.ts @@ -0,0 +1,46 @@ +/** + * The syntax we accept is the HTML5 `input[type=email]` grammar (WHATWG HTML, + * "valid e-mail address"), widened to also accept Unicode letters, marks and + * digits so that internationalized addresses (RFC 6531) are not rejected. + * + * We match the HTML5 grammar because that is what browsers already enforce on + * `input[type=email]`, so the client and the server agree on what an address + * looks like. We widen it because the HTML5 grammar is deliberately ASCII-only + * (see whatwg/html#4562), which locks out anyone whose address contains, say, + * an umlaut. + * + * Syntax is all we check. Whether an address can actually receive mail is + * settled by sending it a verification email, not by a regex. + */ +const validEmailRegex = + /^[\p{L}\p{M}\p{N}.!#$%&'*+/=?^_`{|}~-]+@[\p{L}\p{N}](?:[\p{L}\p{M}\p{N}-]{0,61}[\p{L}\p{M}\p{N}])?(?:\.[\p{L}\p{N}](?:[\p{L}\p{M}\p{N}-]{0,61}[\p{L}\p{M}\p{N}])?)*$/u; + +/** + * Upper bounds from RFC 5321 (4.5.3.1. Size Limits and Minimums), counted in + * octets because that is how the RFC counts them. + */ +const maxLocalPartOctets = 64; +const maxAddressOctets = 254; + +/** + * Checks that `input` looks like an email address. + */ +export function isValidEmail(input: unknown): boolean { + if (typeof input !== "string") { + return false; + } + + return ( + validEmailRegex.test(input) && + countOctets(input) <= maxAddressOctets && + countOctets(getLocalPart(input)) <= maxLocalPartOctets + ); +} + +function getLocalPart(email: string): string { + return email.slice(0, email.lastIndexOf("@")); +} + +function countOctets(text: string): number { + return new TextEncoder().encode(text).length; +} diff --git a/waspc/data/Generator/libs/auth/src/index.ts b/waspc/data/Generator/libs/auth/src/index.ts index abacf38d27..bbd6912019 100644 --- a/waspc/data/Generator/libs/auth/src/index.ts +++ b/waspc/data/Generator/libs/auth/src/index.ts @@ -1,3 +1 @@ -/** - * NOTE: this is a placeholder file for future code exported for both runtimes. - */ +export { isValidEmail } from "./email"; diff --git a/waspc/data/Generator/libs/auth/tests/email.test.ts b/waspc/data/Generator/libs/auth/tests/email.test.ts new file mode 100644 index 0000000000..debfe82f68 --- /dev/null +++ b/waspc/data/Generator/libs/auth/tests/email.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { isValidEmail } from "../src/email"; + +describe("isValidEmail", () => { + it.each([ + "user@example.com", + "user.name+tag@example.co.uk", + "user_name@example.com", + "!#$%&'*+-/=?^_`{|}~@example.com", + "user@sub.domain.example.com", + "user@a.io", + // `input[type=email]` accepts a dotless domain, so we do too. + "user@localhost", + ])("accepts the ASCII address %j", (email) => { + expect(isValidEmail(email)).toBe(true); + }); + + it.each([ + "", + "plainaddress", + "@example.com", + "user@", + "user@@example.com", + "user@-example.com", + "user@example-.com", + "user@exam ple.com", + "user name@example.com", + "user@example..com", + ])("rejects the malformed address %j", (email) => { + expect(isValidEmail(email)).toBe(false); + }); + + it.each([null, undefined, 42, {}, ["user@example.com"]])( + "rejects the non-string input %j", + (input) => { + expect(isValidEmail(input)).toBe(false); + }, + ); + + describe("internationalized addresses (RFC 6531)", () => { + it.each([ + "jürgen@example.com", + "user@münchen.de", + "jürgen@münchen.de", + "用户@例子.广告", + "θσερ@εχαμπλε.ψομ", + "अजय@डाटा.भारत", + "квіточка@пошта.укр", + ])("accepts %j", (email) => { + expect(isValidEmail(email)).toBe(true); + }); + }); + + describe("case", () => { + // The email signup endpoint validates the raw request body and only + // lowercases the address afterwards, so the validator has to accept + // whatever casing the user typed. + it.each(["JOHN@EXAMPLE.COM", "John@Example.com", "jOhN@eXaMpLe.CoM"])( + "accepts %j", + (email) => { + expect(isValidEmail(email)).toBe(true); + }, + ); + }); + + describe("anchoring", () => { + it.each([ + "user@example.com ", + "I am not an email, ask user@example.com", + "\nuser@example.com", + "user@example.com\n", + " user@example.com ", + ])("rejects %j, which merely contains an address", (input) => { + expect(isValidEmail(input)).toBe(false); + }); + }); + + describe("size limits (RFC 5321)", () => { + it("accepts a 64 octet local part", () => { + expect(isValidEmail(`${"a".repeat(64)}@example.com`)).toBe(true); + }); + + it("rejects a 65 octet local part", () => { + expect(isValidEmail(`${"a".repeat(65)}@example.com`)).toBe(false); + }); + + it("counts octets rather than characters in the local part", () => { + // "ä" is two octets in UTF-8, so 33 of them exceed the 64 octet limit + // while staying well under 64 characters. + expect(isValidEmail(`${"ä".repeat(32)}@example.com`)).toBe(true); + expect(isValidEmail(`${"ä".repeat(33)}@example.com`)).toBe(false); + }); + + it("rejects an address longer than 254 octets", () => { + const domain = `${"a".repeat(61)}.${"b".repeat(61)}.${"c".repeat(61)}.com`; + const localPart = "d".repeat(254 - domain.length - 1); + + expect(isValidEmail(`${localPart}@${domain}`)).toBe(true); + expect(isValidEmail(`${localPart}x@${domain}`)).toBe(false); + }); + }); +}); diff --git a/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/common/LoginSignupForm.tsx b/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/common/LoginSignupForm.tsx index d5d05aa89b..21b4551bd5 100644 --- a/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/common/LoginSignupForm.tsx +++ b/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/common/LoginSignupForm.tsx @@ -6,6 +6,9 @@ import { config } from '../../../../client/index.js' import { clsx } from '../util' import { useAuthContext } from '@wasp.sh/lib-auth/browser' +{=# enabledProviders.isEmailAuthEnabled =} +import { emailFieldRules, emailInputProps } from '../emailField' +{=/ enabledProviders.isEmailAuthEnabled =} import { Form, FormInput, @@ -189,10 +192,8 @@ export const LoginSignupForm = ({ E-mail {errors.email && {errors.email.message}} diff --git a/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/email/ForgotPasswordForm.tsx b/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/email/ForgotPasswordForm.tsx index f367af40cf..967d86f6bd 100644 --- a/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/email/ForgotPasswordForm.tsx +++ b/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/email/ForgotPasswordForm.tsx @@ -3,6 +3,7 @@ import { useAuthContext } from '@wasp.sh/lib-auth/browser' import { requestPasswordReset } from '../../../email/actions/passwordReset.js' import { Form, FormItemGroup, FormLabel, FormInput, SubmitButton, FormError } from '../Form' +import { emailFieldRules, emailInputProps } from '../emailField' // PRIVATE API @@ -34,10 +35,8 @@ export const ForgotPasswordForm = () => { E-mail {errors.email && {errors.email.message}} diff --git a/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/emailField.ts b/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/emailField.ts new file mode 100644 index 0000000000..15f4cddfd5 --- /dev/null +++ b/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/emailField.ts @@ -0,0 +1,21 @@ +import { isValidEmail } from '@wasp.sh/lib-auth' + +/** + * Deliberately not `type="email"`: browsers validate that against the HTML5 + * grammar, which is ASCII-only and would reject internationalized addresses + * that the server accepts. `inputMode` keeps the email keyboard on mobile. + */ +export const emailInputProps = { + type: 'text', + inputMode: 'email', + autoComplete: 'email', +} as const + +/** + * react-hook-form rules that mirror the server side `ensureValidEmail`. + */ +export const emailFieldRules = { + required: 'Email is required', + validate: (email: string) => + isValidEmail(email) || 'Email must be a valid email', +} diff --git a/waspc/data/Generator/templates/sdk/wasp/auth/validation.ts b/waspc/data/Generator/templates/sdk/wasp/auth/validation.ts index bbab0c26e1..671f54afb5 100644 --- a/waspc/data/Generator/templates/sdk/wasp/auth/validation.ts +++ b/waspc/data/Generator/templates/sdk/wasp/auth/validation.ts @@ -1,3 +1,4 @@ +import { isValidEmail } from '@wasp.sh/lib-auth'; import { HttpError } from '../server/index.js'; export const PASSWORD_FIELD = 'password'; @@ -57,15 +58,6 @@ function validate(args: object, validators: { validates: string, message: string // NOTE(miho): it would be good to replace our custom validations with e.g. Zod -const validEmailRegex = /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/ -function isValidEmail(input: unknown): boolean { - if (typeof input !== 'string') { - return false - } - - return input.match(validEmailRegex) !== null -} - function isMinLength(input: unknown, minLength: number): boolean { if (typeof input !== 'string') { return false diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/snapshot-file-list.manifest b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/snapshot-file-list.manifest index c6aa2a9e10..644d5f1f15 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/snapshot-file-list.manifest +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/snapshot-file-list.manifest @@ -48,6 +48,7 @@ wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/email/ForgotPasswordForm.tsx wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/email/ResetPasswordForm.tsx wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/email/VerifyEmailForm.tsx wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/email/useEmail.ts +wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/emailField.ts wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/social/SocialButton.module.css wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/social/SocialButton.tsx wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/social/SocialIcons.module.css @@ -222,6 +223,10 @@ wasp-app/.wasp/out/sdk/wasp/dist/auth/forms/internal/email/useEmail.d.ts wasp-app/.wasp/out/sdk/wasp/dist/auth/forms/internal/email/useEmail.d.ts.map wasp-app/.wasp/out/sdk/wasp/dist/auth/forms/internal/email/useEmail.js wasp-app/.wasp/out/sdk/wasp/dist/auth/forms/internal/email/useEmail.js.map +wasp-app/.wasp/out/sdk/wasp/dist/auth/forms/internal/emailField.d.ts +wasp-app/.wasp/out/sdk/wasp/dist/auth/forms/internal/emailField.d.ts.map +wasp-app/.wasp/out/sdk/wasp/dist/auth/forms/internal/emailField.js +wasp-app/.wasp/out/sdk/wasp/dist/auth/forms/internal/emailField.js.map wasp-app/.wasp/out/sdk/wasp/dist/auth/forms/internal/social/SocialButton.d.ts wasp-app/.wasp/out/sdk/wasp/dist/auth/forms/internal/social/SocialButton.d.ts.map wasp-app/.wasp/out/sdk/wasp/dist/auth/forms/internal/social/SocialButton.jsx diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums index 6426fbde9d..097f7a60fb 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums @@ -32,7 +32,7 @@ "file", "libs/auth/wasp.sh-lib-auth-0.26.0.tgz" ], - "35480e2a814f4c24e9bb22f8e6c0a43fbca98c1cf0612108bcf0f4c1302262f0" + "5a7c23f5c4b0b707d158f7e528950a370a0e01b816da95c6dd0e1d811cb9253d" ], [ [ @@ -186,14 +186,14 @@ "file", "sdk/wasp/auth/forms/internal/common/LoginSignupForm.tsx" ], - "dcc63ceab024657653b082cd772b7ec50f53e47583a8f20974c605dfdb200a91" + "2accd8d7e5f95f9374f86f0bfd30c3576e7e55c7a544248fb528451a8dea45fa" ], [ [ "file", "sdk/wasp/auth/forms/internal/email/ForgotPasswordForm.tsx" ], - "0cffa908694b6eb863d094c1e0027fd92876fe58e244a2ee092ef2aa38f07c12" + "e5a766bcd839a37385201480dd30a93a36c0e9850aed4dc15b5dbb5fbf7079a8" ], [ [ @@ -216,6 +216,13 @@ ], "7751b34516bc55579c5f556ff4092a255a09b4ecb94ce3ab9cc08ffe324a4bf0" ], + [ + [ + "file", + "sdk/wasp/auth/forms/internal/emailField.ts" + ], + "e52bd01554676a46057593fecd36d77b21867c076a01478b61d9c643adab7197" + ], [ [ "file", @@ -368,7 +375,7 @@ "file", "sdk/wasp/auth/validation.ts" ], - "5a408544fade201c599d9c0d66a1a847ed4bfdd8f44a7f957fab74a97ecaa43c" + "df41047a91314c9e3a1e47dc5a9a44d57135575e370ca91b1e3ff04d69a807ee" ], [ [ diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/common/LoginSignupForm.tsx b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/common/LoginSignupForm.tsx index 01a822f481..7968433d91 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/common/LoginSignupForm.tsx +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/common/LoginSignupForm.tsx @@ -5,6 +5,7 @@ import { config } from '../../../../client/index.js' import { clsx } from '../util' import { useAuthContext } from '@wasp.sh/lib-auth/browser' +import { emailFieldRules, emailInputProps } from '../emailField' import { Form, FormInput, @@ -110,10 +111,8 @@ export const LoginSignupForm = ({ E-mail {errors.email && {errors.email.message}} diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/email/ForgotPasswordForm.tsx b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/email/ForgotPasswordForm.tsx index f367af40cf..967d86f6bd 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/email/ForgotPasswordForm.tsx +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/email/ForgotPasswordForm.tsx @@ -3,6 +3,7 @@ import { useAuthContext } from '@wasp.sh/lib-auth/browser' import { requestPasswordReset } from '../../../email/actions/passwordReset.js' import { Form, FormItemGroup, FormLabel, FormInput, SubmitButton, FormError } from '../Form' +import { emailFieldRules, emailInputProps } from '../emailField' // PRIVATE API @@ -34,10 +35,8 @@ export const ForgotPasswordForm = () => { E-mail {errors.email && {errors.email.message}} diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/emailField.ts b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/emailField.ts new file mode 100644 index 0000000000..15f4cddfd5 --- /dev/null +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/emailField.ts @@ -0,0 +1,21 @@ +import { isValidEmail } from '@wasp.sh/lib-auth' + +/** + * Deliberately not `type="email"`: browsers validate that against the HTML5 + * grammar, which is ASCII-only and would reject internationalized addresses + * that the server accepts. `inputMode` keeps the email keyboard on mobile. + */ +export const emailInputProps = { + type: 'text', + inputMode: 'email', + autoComplete: 'email', +} as const + +/** + * react-hook-form rules that mirror the server side `ensureValidEmail`. + */ +export const emailFieldRules = { + required: 'Email is required', + validate: (email: string) => + isValidEmail(email) || 'Email must be a valid email', +} diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/validation.ts b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/validation.ts index bbab0c26e1..671f54afb5 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/validation.ts +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/validation.ts @@ -1,3 +1,4 @@ +import { isValidEmail } from '@wasp.sh/lib-auth'; import { HttpError } from '../server/index.js'; export const PASSWORD_FIELD = 'password'; @@ -57,15 +58,6 @@ function validate(args: object, validators: { validates: string, message: string // NOTE(miho): it would be good to replace our custom validations with e.g. Zod -const validEmailRegex = /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/ -function isValidEmail(input: unknown): boolean { - if (typeof input !== 'string') { - return false - } - - return input.match(validEmailRegex) !== null -} - function isMinLength(input: unknown, minLength: number): boolean { if (typeof input !== 'string') { return false diff --git a/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/.waspchecksums b/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/.waspchecksums index a508c7660a..da07dc6793 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/.waspchecksums +++ b/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/.waspchecksums @@ -25,7 +25,7 @@ "file", "libs/auth/wasp.sh-lib-auth-0.26.0.tgz" ], - "35480e2a814f4c24e9bb22f8e6c0a43fbca98c1cf0612108bcf0f4c1302262f0" + "5a7c23f5c4b0b707d158f7e528950a370a0e01b816da95c6dd0e1d811cb9253d" ], [ [ diff --git a/waspc/e2e-tests/test-outputs/snapshots/wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums b/waspc/e2e-tests/test-outputs/snapshots/wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums index 1435278ac7..465d616559 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums +++ b/waspc/e2e-tests/test-outputs/snapshots/wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums @@ -25,7 +25,7 @@ "file", "libs/auth/wasp.sh-lib-auth-0.26.0.tgz" ], - "35480e2a814f4c24e9bb22f8e6c0a43fbca98c1cf0612108bcf0f4c1302262f0" + "5a7c23f5c4b0b707d158f7e528950a370a0e01b816da95c6dd0e1d811cb9253d" ], [ [ diff --git a/waspc/e2e-tests/test-outputs/snapshots/wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums b/waspc/e2e-tests/test-outputs/snapshots/wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums index da1f788c61..0554f9d717 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums +++ b/waspc/e2e-tests/test-outputs/snapshots/wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums @@ -25,7 +25,7 @@ "file", "libs/auth/wasp.sh-lib-auth-0.26.0.tgz" ], - "35480e2a814f4c24e9bb22f8e6c0a43fbca98c1cf0612108bcf0f4c1302262f0" + "5a7c23f5c4b0b707d158f7e528950a370a0e01b816da95c6dd0e1d811cb9253d" ], [ [ diff --git a/waspc/src/Wasp/Generator/SdkGenerator/Auth/AuthFormsG.hs b/waspc/src/Wasp/Generator/SdkGenerator/Auth/AuthFormsG.hs index 42bce85f0a..486e69c846 100644 --- a/waspc/src/Wasp/Generator/SdkGenerator/Auth/AuthFormsG.hs +++ b/waspc/src/Wasp/Generator/SdkGenerator/Auth/AuthFormsG.hs @@ -96,7 +96,8 @@ genInternalAuthComponents auth = genEmailComponents = genConditionally isEmailAuthEnabled $ sequence - [ genFileCopyInAuthFormsInternal [relfile|email/VerifyEmailForm.tsx|], + [ genFileCopyInAuthFormsInternal [relfile|emailField.ts|], + genFileCopyInAuthFormsInternal [relfile|email/VerifyEmailForm.tsx|], genFileCopyInAuthFormsInternal [relfile|email/useEmail.ts|], genFileCopyInAuthFormsInternal [relfile|email/ForgotPasswordForm.tsx|], genFileCopyInAuthFormsInternal [relfile|email/ResetPasswordForm.tsx|] From 961c733b7bcab24d149a16af25a0dacb0eeb57c4 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Wed, 12 Aug 2026 11:27:11 +0200 Subject: [PATCH 02/21] Cover uppercase local part and domain separately in email tests --- .../Generator/libs/auth/tests/email.test.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/waspc/data/Generator/libs/auth/tests/email.test.ts b/waspc/data/Generator/libs/auth/tests/email.test.ts index debfe82f68..1d6504dd31 100644 --- a/waspc/data/Generator/libs/auth/tests/email.test.ts +++ b/waspc/data/Generator/libs/auth/tests/email.test.ts @@ -55,12 +55,19 @@ describe("isValidEmail", () => { // The email signup endpoint validates the raw request body and only // lowercases the address afterwards, so the validator has to accept // whatever casing the user typed. - it.each(["JOHN@EXAMPLE.COM", "John@Example.com", "jOhN@eXaMpLe.CoM"])( - "accepts %j", - (email) => { - expect(isValidEmail(email)).toBe(true); - }, - ); + it.each([ + "JOHN@EXAMPLE.COM", + "John@Example.com", + "jOhN@eXaMpLe.CoM", + // Uppercase confined to the domain. + "john@Example.com", + "john@example.COM", + // Uppercase confined to the local part, running right up to the "@". + "JOHN@example.com", + "john.DOE@example.com", + ])("accepts %j", (email) => { + expect(isValidEmail(email)).toBe(true); + }); }); describe("anchoring", () => { From e50e9fb62a5381cc575e34c8c82a125346b00d91 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Wed, 12 Aug 2026 12:44:16 +0200 Subject: [PATCH 03/21] Refresh kitchen-sink golden snapshot for new auth e2e tests --- .../wasp-app/e2e-tests/tests/auth.spec.ts | 55 +++++++++++++++++++ .../wasp-app/e2e-tests/tests/auth.ts | 2 +- .../wasp-app/e2e-tests/tests/helpers.ts | 8 +++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/auth.spec.ts b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/auth.spec.ts index afcd8eae2f..2608ea96b5 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/auth.spec.ts +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/auth.spec.ts @@ -3,6 +3,7 @@ import { WASP_SERVER_URL } from "../playwright.config"; import { performEmailVerification, performLogin, performSignup } from "./auth"; import { generateRandomEmail, + generateRandomInternationalizedEmail, isRunningInDeployedMode, isRunningInDevMode, } from "./helpers"; @@ -96,4 +97,58 @@ test.describe("auth", () => { await expect(page).toHaveURL("/"); }); }); + + test.describe("internationalized email address", () => { + test.skip( + isRunningInDeployedMode(), + "Skipped in deployed mode (no Mailcrab)", + ); + test.describe.configure({ mode: "serial" }); + + const email = generateRandomInternationalizedEmail(); + const password = "12345678"; + + test("can sign up", async ({ page }) => { + await performSignup(page, { + email, + password, + address: "Some at least 10 letter address", + }); + + await expect(page.locator("body")).toContainText( + `You've signed up successfully! Check your email for the confirmation link.`, + ); + }); + + test("can verify email", async ({ page }) => { + if (isRunningInDevMode()) { + // Skip this test in dev mode, as email confirmation is not required. + test.skip(); + } + + await performEmailVerification(page, email); + }); + + test("can log in", async ({ page }) => { + await performLogin(page, { email, password }); + + await expect(page).toHaveURL("/"); + }); + }); + + test.describe("invalid email address", () => { + test("signing up with a malformed address results in an error message", async ({ + page, + }) => { + await performSignup(page, { + email: "not-an-email", + password: "12345678", + address: "Some at least 10 letter address", + }); + + await expect(page.locator("body")).toContainText( + "Email must be a valid email", + ); + }); + }); }); diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/auth.ts b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/auth.ts index 1c5af74064..cad6e89052 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/auth.ts +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/auth.ts @@ -107,7 +107,7 @@ async function navigateToLoginPage(page: Page) { } async function submitLoginForm(page: Page, credentials: BaseEmailCredentials) { - await page.locator("input[type='email']").fill(credentials.email); + await page.locator("input[name='email']").fill(credentials.email); await page.locator("input[type='password']").fill(credentials.password); await page.getByRole("button", { name: "Log in" }).click(); } diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/helpers.ts b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/helpers.ts index 55f4b2bacc..ff9e769d52 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/helpers.ts +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/helpers.ts @@ -12,3 +12,11 @@ export function isRunningInDeployedMode() { export function generateRandomEmail(): string { return `${randomUUID()}@test.com`; } + +/** + * An address with non-ASCII characters on both sides of the `@`, the kind + * RFC 6531 allows and the HTML5 `input[type=email]` grammar does not. + */ +export function generateRandomInternationalizedEmail(): string { + return `jürgen-${randomUUID()}@münchen.test`; +} From 434614b999b95a5ffff096a98c44141daf939c72 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Wed, 12 Aug 2026 14:43:02 +0200 Subject: [PATCH 04/21] Regenerate wasp-build golden for rolldown 1.2.3 output formatting --- .../wasp-app/.wasp/out/web-app/build/assets/200.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/web-app/build/assets/200.js b/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/web-app/build/assets/200.js index 461302e9d8..17d259556e 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/web-app/build/assets/200.js +++ b/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/web-app/build/assets/200.js @@ -243,8 +243,10 @@ ky.extend({ } }); if (typeof window !== "undefined") window.addEventListener("storage", (event) => { - if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_SESSION_ID_NAME)) if (!!event.newValue) apiEventsEmitter.emit("sessionId.set"); - else apiEventsEmitter.emit("sessionId.clear"); + if (event.key === storage.getPrefixedKey(WASP_APP_AUTH_SESSION_ID_NAME)) { + if (!!event.newValue) apiEventsEmitter.emit("sessionId.set"); + else apiEventsEmitter.emit("sessionId.clear"); + } }); function getSessionIdFromAuthorizationHeader(header) { if (header && header.startsWith("Bearer ")) return header.substring(7); From b14206995b1be7b4862579a3c995b2e724e69a35 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Wed, 12 Aug 2026 15:50:26 +0200 Subject: [PATCH 05/21] Trim email input, send verification to the stored address, narrow the RFC 6531 claim --- .../kitchen-sink/e2e-tests/tests/auth.spec.ts | 63 +++++++++++++++++++ examples/kitchen-sink/package-lock.json | 2 +- .../features/auth/pages/CustomSignupPage.tsx | 2 +- .../features/auth/pages/ManualSignupPage.tsx | 2 +- waspc/ChangeLog.md | 1 + waspc/data/Generator/libs/auth/src/email.ts | 8 ++- .../Generator/libs/auth/tests/email.test.ts | 15 ++++- .../wasp/auth/forms/internal/emailField.ts | 3 + .../server/src/auth/providers/email/signup.ts | 10 ++- .../wasp-app/.wasp/out/.waspchecksums | 6 +- .../wasp/auth/forms/internal/emailField.ts | 3 + .../server/src/auth/providers/email/signup.ts | 10 ++- .../wasp-app/e2e-tests/tests/auth.spec.ts | 63 +++++++++++++++++++ .../features/auth/pages/CustomSignupPage.tsx | 2 +- .../features/auth/pages/ManualSignupPage.tsx | 2 +- .../wasp-app/.wasp/out/.waspchecksums | 2 +- .../wasp-app/.wasp/out/.waspchecksums | 2 +- .../wasp-app/.wasp/out/.waspchecksums | 2 +- waspc/starters-e2e-tests/tests/auth.ts | 2 +- web/docs/auth/email/create-your-own-ui.md | 18 ++++-- web/docs/auth/overview.md | 2 + 21 files changed, 194 insertions(+), 26 deletions(-) diff --git a/examples/kitchen-sink/e2e-tests/tests/auth.spec.ts b/examples/kitchen-sink/e2e-tests/tests/auth.spec.ts index 2608ea96b5..f7d79a5404 100644 --- a/examples/kitchen-sink/e2e-tests/tests/auth.spec.ts +++ b/examples/kitchen-sink/e2e-tests/tests/auth.spec.ts @@ -136,6 +136,69 @@ test.describe("auth", () => { }); }); + test.describe("uppercase email address", () => { + test.skip( + isRunningInDeployedMode(), + "Skipped in deployed mode (no Mailcrab)", + ); + test.describe.configure({ mode: "serial" }); + + // Emails are stored lowercased, and auth emails go to the stored address, + // so signing up in uppercase must deliver to the lowercased address. + const email = generateRandomEmail().toUpperCase(); + const password = "12345678"; + + test("can sign up", async ({ page }) => { + await performSignup(page, { + email, + password, + address: "Some at least 10 letter address", + }); + + await expect(page.locator("body")).toContainText( + `You've signed up successfully! Check your email for the confirmation link.`, + ); + }); + + test("receives the verification email at the lowercased address", async ({ + page, + }) => { + if (isRunningInDevMode()) { + // Skip this test in dev mode, as email confirmation is not required. + test.skip(); + } + + await performEmailVerification(page, email.toLowerCase()); + }); + + test("can log in", async ({ page }) => { + await performLogin(page, { email, password }); + + await expect(page).toHaveURL("/"); + }); + }); + + test.describe("email address with surrounding whitespace", () => { + test.skip( + isRunningInDeployedMode(), + "Skipped in deployed mode (no Mailcrab)", + ); + + test("the form trims the address before submitting it", async ({ + page, + }) => { + await performSignup(page, { + email: ` ${generateRandomEmail()} `, + password: "12345678", + address: "Some at least 10 letter address", + }); + + await expect(page.locator("body")).toContainText( + `You've signed up successfully! Check your email for the confirmation link.`, + ); + }); + }); + test.describe("invalid email address", () => { test("signing up with a malformed address results in an error message", async ({ page, diff --git a/examples/kitchen-sink/package-lock.json b/examples/kitchen-sink/package-lock.json index 8540478d0b..fba633cb20 100644 --- a/examples/kitchen-sink/package-lock.json +++ b/examples/kitchen-sink/package-lock.json @@ -3992,7 +3992,7 @@ "node_modules/@wasp.sh/lib-auth": { "version": "0.26.0", "resolved": "file:.wasp/out/libs/auth/wasp.sh-lib-auth-0.26.0.tgz", - "integrity": "sha512-3KHDgvGEVY9FrEx+6SHt3HN/SooBnq4y1ECZQviEKgEI980oL0Fqkz+XX/qTvUpxdw5AgPFs0MhhgjaLIuKXlQ==", + "integrity": "sha512-5Uv3Ousfv72qV94NhOOx3Vqt2rB2N9j8QQs/1bEVQwExtbkAP41ky8J9pA3ZS5gNjFJB4E+w50MQZ5gUqWyaRg==", "license": "MIT", "dependencies": { "@node-rs/argon2": "^2.0.2", diff --git a/examples/kitchen-sink/src/features/auth/pages/CustomSignupPage.tsx b/examples/kitchen-sink/src/features/auth/pages/CustomSignupPage.tsx index ec02fd8c87..0fbe7eea48 100644 --- a/examples/kitchen-sink/src/features/auth/pages/CustomSignupPage.tsx +++ b/examples/kitchen-sink/src/features/auth/pages/CustomSignupPage.tsx @@ -68,7 +68,7 @@ export const CustomSignupPage = () => { )} E-mail - + {errors.email?.message} diff --git a/examples/kitchen-sink/src/features/auth/pages/ManualSignupPage.tsx b/examples/kitchen-sink/src/features/auth/pages/ManualSignupPage.tsx index e87f17e21f..55511b9aea 100644 --- a/examples/kitchen-sink/src/features/auth/pages/ManualSignupPage.tsx +++ b/examples/kitchen-sink/src/features/auth/pages/ManualSignupPage.tsx @@ -68,7 +68,7 @@ export const ManualSignupPage = () => { )} E-mail - + {errors.email?.message} diff --git a/waspc/ChangeLog.md b/waspc/ChangeLog.md index 5a572955cd..483a9c84c3 100644 --- a/waspc/ChangeLog.md +++ b/waspc/ChangeLog.md @@ -26,6 +26,7 @@ - Password reset now rejects an invalid or expired token before it looks at the new password, so someone without a valid reset link can no longer probe your app's password rules. ([#4657](https://github.com/wasp-lang/wasp/pull/4657)) - `onBeforeSignup` now runs before `userSignupFields` on every signup method: email, username and password, and OAuth. ([#4659](https://github.com/wasp-lang/wasp/pull/4659)) - Email validation now accepts internationalized addresses such as `jürgen@münchen.de`, accepts addresses typed in uppercase, and no longer accepts a string that merely contains an address somewhere inside it. ([#1392](https://github.com/wasp-lang/wasp/issues/1392)) +- The email verification email now goes to the address Wasp stores (emails are stored lowercased) instead of the address exactly as typed, so it matches where the password reset email is sent. ([#1392](https://github.com/wasp-lang/wasp/issues/1392)) ## 0.25.0 diff --git a/waspc/data/Generator/libs/auth/src/email.ts b/waspc/data/Generator/libs/auth/src/email.ts index f8c7cee9e0..ba76ff8447 100644 --- a/waspc/data/Generator/libs/auth/src/email.ts +++ b/waspc/data/Generator/libs/auth/src/email.ts @@ -1,7 +1,7 @@ /** * The syntax we accept is the HTML5 `input[type=email]` grammar (WHATWG HTML, * "valid e-mail address"), widened to also accept Unicode letters, marks and - * digits so that internationalized addresses (RFC 6531) are not rejected. + * digits so that internationalized addresses are not rejected. * * We match the HTML5 grammar because that is what browsers already enforce on * `input[type=email]`, so the client and the server agree on what an address @@ -9,6 +9,12 @@ * (see whatwg/html#4562), which locks out anyone whose address contains, say, * an umlaut. * + * Letters, marks and digits are a deliberate subset of what RFC 6531 allows: + * the RFC extends the local part with any non-ASCII character, which would also + * let in zero-width joiners, bidirectional overrides and other characters that + * make two different addresses look identical. Those buy nobody a mailbox, so + * we leave them out. + * * Syntax is all we check. Whether an address can actually receive mail is * settled by sending it a verification email, not by a regex. */ diff --git a/waspc/data/Generator/libs/auth/tests/email.test.ts b/waspc/data/Generator/libs/auth/tests/email.test.ts index 1d6504dd31..487ca6e2a1 100644 --- a/waspc/data/Generator/libs/auth/tests/email.test.ts +++ b/waspc/data/Generator/libs/auth/tests/email.test.ts @@ -37,7 +37,7 @@ describe("isValidEmail", () => { }, ); - describe("internationalized addresses (RFC 6531)", () => { + describe("internationalized addresses", () => { it.each([ "jürgen@example.com", "user@münchen.de", @@ -49,6 +49,19 @@ describe("isValidEmail", () => { ])("accepts %j", (email) => { expect(isValidEmail(email)).toBe(true); }); + + // We accept Unicode letters, marks and digits, which is narrower than the + // "any non-ASCII character" RFC 6531 allows. See the note in `email.ts`. + it.each([ + // Emoji. + "😀@example.com", + // Zero width joiner. + "us‍er@example.com", + // Right-to-left override. + "us‮er@example.com", + ])("rejects the non-letter, non-digit character in %j", (email) => { + expect(isValidEmail(email)).toBe(false); + }); }); describe("case", () => { diff --git a/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/emailField.ts b/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/emailField.ts index 15f4cddfd5..91db166b3f 100644 --- a/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/emailField.ts +++ b/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/emailField.ts @@ -16,6 +16,9 @@ export const emailInputProps = { */ export const emailFieldRules = { required: 'Email is required', + // `type="email"` used to strip surrounding whitespace for us, `type="text"` + // doesn't, so a pasted address would fail validation without this. + setValueAs: (email: string) => email.trim(), validate: (email: string) => isValidEmail(email) || 'Email must be a valid email', } diff --git a/waspc/data/Generator/templates/server/src/auth/providers/email/signup.ts b/waspc/data/Generator/templates/server/src/auth/providers/email/signup.ts index aa7ac2d2dd..0d55b7f63e 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/email/signup.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/email/signup.ts @@ -146,14 +146,18 @@ export function getSignupRoute({ return } + // We send to the address we stored, not to the one the user typed, so that + // the address that proves ownership is the same one password reset later + // sends the reset link to. + const email = providerId.providerUserId const verificationLink = await createEmailVerificationLink( - fields.email, + email, clientRoute, ) try { - await sendEmailVerificationEmail(fields.email, { + await sendEmailVerificationEmail(email, { from: fromField, - to: fields.email, + to: email, ...getVerificationEmailContent({ verificationLink }), }) } catch (e: unknown) { diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums index 097f7a60fb..31010b3655 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums @@ -32,7 +32,7 @@ "file", "libs/auth/wasp.sh-lib-auth-0.26.0.tgz" ], - "5a7c23f5c4b0b707d158f7e528950a370a0e01b816da95c6dd0e1d811cb9253d" + "679a0122d48fb625ea03735e811605c3e14074537591cf86e57f3075f38c3fac" ], [ [ @@ -221,7 +221,7 @@ "file", "sdk/wasp/auth/forms/internal/emailField.ts" ], - "e52bd01554676a46057593fecd36d77b21867c076a01478b61d9c643adab7197" + "3d5ef44ba8341f0fdb01298f7b219d9f6569386ce519efce5553beb11785fce0" ], [ [ @@ -1607,7 +1607,7 @@ "file", "server/src/auth/providers/email/signup.ts" ], - "36980d460729b3ae58c2bbee24144cf21909ea77e642f4ad65c75b107455fad8" + "81394fa994b351ffcc19c94ce8135a787d8bc18c8e4c1cfe89d0e45e7df540aa" ], [ [ diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/emailField.ts b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/emailField.ts index 15f4cddfd5..91db166b3f 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/emailField.ts +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/emailField.ts @@ -16,6 +16,9 @@ export const emailInputProps = { */ export const emailFieldRules = { required: 'Email is required', + // `type="email"` used to strip surrounding whitespace for us, `type="text"` + // doesn't, so a pasted address would fail validation without this. + setValueAs: (email: string) => email.trim(), validate: (email: string) => isValidEmail(email) || 'Email must be a valid email', } diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/server/src/auth/providers/email/signup.ts b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/server/src/auth/providers/email/signup.ts index aa7ac2d2dd..0d55b7f63e 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/server/src/auth/providers/email/signup.ts +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/server/src/auth/providers/email/signup.ts @@ -146,14 +146,18 @@ export function getSignupRoute({ return } + // We send to the address we stored, not to the one the user typed, so that + // the address that proves ownership is the same one password reset later + // sends the reset link to. + const email = providerId.providerUserId const verificationLink = await createEmailVerificationLink( - fields.email, + email, clientRoute, ) try { - await sendEmailVerificationEmail(fields.email, { + await sendEmailVerificationEmail(email, { from: fromField, - to: fields.email, + to: email, ...getVerificationEmailContent({ verificationLink }), }) } catch (e: unknown) { diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/auth.spec.ts b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/auth.spec.ts index 2608ea96b5..f7d79a5404 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/auth.spec.ts +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/auth.spec.ts @@ -136,6 +136,69 @@ test.describe("auth", () => { }); }); + test.describe("uppercase email address", () => { + test.skip( + isRunningInDeployedMode(), + "Skipped in deployed mode (no Mailcrab)", + ); + test.describe.configure({ mode: "serial" }); + + // Emails are stored lowercased, and auth emails go to the stored address, + // so signing up in uppercase must deliver to the lowercased address. + const email = generateRandomEmail().toUpperCase(); + const password = "12345678"; + + test("can sign up", async ({ page }) => { + await performSignup(page, { + email, + password, + address: "Some at least 10 letter address", + }); + + await expect(page.locator("body")).toContainText( + `You've signed up successfully! Check your email for the confirmation link.`, + ); + }); + + test("receives the verification email at the lowercased address", async ({ + page, + }) => { + if (isRunningInDevMode()) { + // Skip this test in dev mode, as email confirmation is not required. + test.skip(); + } + + await performEmailVerification(page, email.toLowerCase()); + }); + + test("can log in", async ({ page }) => { + await performLogin(page, { email, password }); + + await expect(page).toHaveURL("/"); + }); + }); + + test.describe("email address with surrounding whitespace", () => { + test.skip( + isRunningInDeployedMode(), + "Skipped in deployed mode (no Mailcrab)", + ); + + test("the form trims the address before submitting it", async ({ + page, + }) => { + await performSignup(page, { + email: ` ${generateRandomEmail()} `, + password: "12345678", + address: "Some at least 10 letter address", + }); + + await expect(page.locator("body")).toContainText( + `You've signed up successfully! Check your email for the confirmation link.`, + ); + }); + }); + test.describe("invalid email address", () => { test("signing up with a malformed address results in an error message", async ({ page, diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/src/features/auth/pages/CustomSignupPage.tsx b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/src/features/auth/pages/CustomSignupPage.tsx index ec02fd8c87..0fbe7eea48 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/src/features/auth/pages/CustomSignupPage.tsx +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/src/features/auth/pages/CustomSignupPage.tsx @@ -68,7 +68,7 @@ export const CustomSignupPage = () => { )} E-mail - + {errors.email?.message} diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/src/features/auth/pages/ManualSignupPage.tsx b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/src/features/auth/pages/ManualSignupPage.tsx index e87f17e21f..55511b9aea 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/src/features/auth/pages/ManualSignupPage.tsx +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/src/features/auth/pages/ManualSignupPage.tsx @@ -68,7 +68,7 @@ export const ManualSignupPage = () => { )} E-mail - + {errors.email?.message} diff --git a/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/.waspchecksums b/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/.waspchecksums index da07dc6793..293f2df33c 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/.waspchecksums +++ b/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/.waspchecksums @@ -25,7 +25,7 @@ "file", "libs/auth/wasp.sh-lib-auth-0.26.0.tgz" ], - "5a7c23f5c4b0b707d158f7e528950a370a0e01b816da95c6dd0e1d811cb9253d" + "679a0122d48fb625ea03735e811605c3e14074537591cf86e57f3075f38c3fac" ], [ [ diff --git a/waspc/e2e-tests/test-outputs/snapshots/wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums b/waspc/e2e-tests/test-outputs/snapshots/wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums index 465d616559..4acc6591bc 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums +++ b/waspc/e2e-tests/test-outputs/snapshots/wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums @@ -25,7 +25,7 @@ "file", "libs/auth/wasp.sh-lib-auth-0.26.0.tgz" ], - "5a7c23f5c4b0b707d158f7e528950a370a0e01b816da95c6dd0e1d811cb9253d" + "679a0122d48fb625ea03735e811605c3e14074537591cf86e57f3075f38c3fac" ], [ [ diff --git a/waspc/e2e-tests/test-outputs/snapshots/wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums b/waspc/e2e-tests/test-outputs/snapshots/wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums index 0554f9d717..142f31fc8d 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums +++ b/waspc/e2e-tests/test-outputs/snapshots/wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums @@ -25,7 +25,7 @@ "file", "libs/auth/wasp.sh-lib-auth-0.26.0.tgz" ], - "5a7c23f5c4b0b707d158f7e528950a370a0e01b816da95c6dd0e1d811cb9253d" + "679a0122d48fb625ea03735e811605c3e14074537591cf86e57f3075f38c3fac" ], [ [ diff --git a/waspc/starters-e2e-tests/tests/auth.ts b/waspc/starters-e2e-tests/tests/auth.ts index 5a8cd6e1cb..5d76c30524 100644 --- a/waspc/starters-e2e-tests/tests/auth.ts +++ b/waspc/starters-e2e-tests/tests/auth.ts @@ -93,7 +93,7 @@ export async function performLogin(page: Page, credentials: Credentials) { } async function submitLoginForm(page: Page, credentials: Credentials) { - await page.locator("input[type='email']").fill(credentials.email); + await page.locator("input[name='email']").fill(credentials.email); await page.locator("input[type='password']").fill(credentials.password); await page.getByRole("button", { name: "Log in" }).click(); } diff --git a/web/docs/auth/email/create-your-own-ui.md b/web/docs/auth/email/create-your-own-ui.md index 07524ea407..77bf027df1 100644 --- a/web/docs/auth/email/create-your-own-ui.md +++ b/web/docs/auth/email/create-your-own-ui.md @@ -53,7 +53,8 @@ Below you can find a starting point for making your own UI in the client code. T {error &&

Error: {error.message}

} setEmail(e.target.value)} placeholder="Email" @@ -102,7 +103,8 @@ Below you can find a starting point for making your own UI in the client code. T {error &&

Error: {error.message}

} setEmail(e.target.value)} placeholder="Email" @@ -179,7 +181,8 @@ Below you can find a starting point for making your own UI in the client code. T {error &&

Error: {error.message}

} setEmail(e.target.value)} placeholder="Email" @@ -267,7 +270,8 @@ Below you can find a starting point for making your own UI in the client code. T {error &&

Error: {error.message}

} setEmail(e.target.value)} placeholder="Email" @@ -316,7 +320,8 @@ Below you can find a starting point for making your own UI in the client code. T {error &&

Error: {error.message}

} setEmail(e.target.value)} placeholder="Email" @@ -393,7 +398,8 @@ Below you can find a starting point for making your own UI in the client code. T {error &&

Error: {error.message}

} setEmail(e.target.value)} placeholder="Email" diff --git a/web/docs/auth/overview.md b/web/docs/auth/overview.md index fdcf913a45..f1929c8d93 100644 --- a/web/docs/auth/overview.md +++ b/web/docs/auth/overview.md @@ -352,6 +352,8 @@ If you use [Email](./email.md) authentication, the default validations are: Note that `email`s are stored in a **case-insensitive** manner. +An address counts as valid if it matches the syntax browsers enforce on `input[type=email]`, widened to accept internationalized addresses such as `jürgen@münchen.de`. Because browsers reject those addresses themselves, use `type="text"` with `inputMode="email"` if you build your own form, and trim the value before you send it. + ## Customizing the Signup Process Sometimes you want to include **extra fields** in your signup process, like first name and last name and save them in the `User` entity. From 6b3be5c9d8e2eeb9e895a5a1d0918009b38af590 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Wed, 12 Aug 2026 16:59:10 +0200 Subject: [PATCH 06/21] Update markdown snapshots for the email docs changes --- web/markdown-snapshots/docs/auth/overview.md | 2 ++ web/markdown-snapshots/llms-full.txt | 11 ++++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/web/markdown-snapshots/docs/auth/overview.md b/web/markdown-snapshots/docs/auth/overview.md index 82bb434d83..a1b9616629 100644 --- a/web/markdown-snapshots/docs/auth/overview.md +++ b/web/markdown-snapshots/docs/auth/overview.md @@ -360,6 +360,8 @@ If you use [Email](https://wasp.sh/docs/auth/email) authentication, the default Note that `email`s are stored in a **case-insensitive** manner. +An address counts as valid if it matches the syntax browsers enforce on `input[type=email]`, widened to accept internationalized addresses such as `jürgen@münchen.de`. Because browsers reject those addresses themselves, use `type="text"` with `inputMode="email"` if you build your own form, and trim the value before you send it. + ## Customizing the Signup Process Sometimes you want to include **extra fields** in your signup process, like first name and last name and save them in the `User` entity. diff --git a/web/markdown-snapshots/llms-full.txt b/web/markdown-snapshots/llms-full.txt index ddbd0dc0e4..747ae7abb5 100644 --- a/web/markdown-snapshots/llms-full.txt +++ b/web/markdown-snapshots/llms-full.txt @@ -3923,6 +3923,8 @@ If you use [Email](https://wasp.sh/docs/auth/email) authentication, the default Note that `email`s are stored in a **case-insensitive** manner. +An address counts as valid if it matches the syntax browsers enforce on `input[type=email]`, widened to accept internationalized addresses such as `jürgen@münchen.de`. Because browsers reject those addresses themselves, use `type="text"` with `inputMode="email"` if you build your own form, and trim the value before you send it. + ### Customizing the Signup Process Sometimes you want to include **extra fields** in your signup process, like first name and last name and save them in the `User` entity. @@ -5445,7 +5447,8 @@ export function LoginPage() { {error &&

Error: {error.message}

} setEmail(e.target.value)} placeholder="Email" @@ -5494,7 +5497,8 @@ export function SignupPage() { {error &&

Error: {error.message}

} setEmail(e.target.value)} placeholder="Email" @@ -5571,7 +5575,8 @@ export function RequestPasswordResetPage() { {error &&

Error: {error.message}

} setEmail(e.target.value)} placeholder="Email" From 98c86c041c035af11315748105dff1c4a24e7120 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Thu, 13 Aug 2026 09:28:24 +0200 Subject: [PATCH 07/21] fixes --- waspc/data/Generator/libs/auth/src/email.ts | 56 +++++++++---------- .../wasp/auth/forms/internal/emailField.ts | 13 ++--- web/docs/auth/overview.md | 5 +- 3 files changed, 35 insertions(+), 39 deletions(-) diff --git a/waspc/data/Generator/libs/auth/src/email.ts b/waspc/data/Generator/libs/auth/src/email.ts index ba76ff8447..2700098c81 100644 --- a/waspc/data/Generator/libs/auth/src/email.ts +++ b/waspc/data/Generator/libs/auth/src/email.ts @@ -1,49 +1,47 @@ +// TODO: If we ever need a more quality email validator, its worth to +// look at https://github.com/JoshData/python-email-validator for inspiration. /** - * The syntax we accept is the HTML5 `input[type=email]` grammar (WHATWG HTML, - * "valid e-mail address"), widened to also accept Unicode letters, marks and - * digits so that internationalized addresses are not rejected. + * The syntax we accept is the HTML5 `input[type=email]` grammar, widened to + * also accept Unicode letters, marks and digits so that internationalized + * addresses are not rejected. * - * We match the HTML5 grammar because that is what browsers already enforce on - * `input[type=email]`, so the client and the server agree on what an address - * looks like. We widen it because the HTML5 grammar is deliberately ASCII-only - * (see whatwg/html#4562), which locks out anyone whose address contains, say, - * an umlaut. + * We start from the HTML5 grammar rather than RFC 5322 because it is a good + * compromise. It is a willful violation of the RFC that drops the corners + * nobody uses (quoted local parts, comments, IP-literal domains) and keeps + * what remains simple enough to read as a single regex. We widen it because + * it is deliberately ASCII-only, which locks out anyone whose address + * contains unicode. + * @see https://github.com/whatwg/html/issues/4562 * - * Letters, marks and digits are a deliberate subset of what RFC 6531 allows: - * the RFC extends the local part with any non-ASCII character, which would also - * let in zero-width joiners, bidirectional overrides and other characters that - * make two different addresses look identical. Those buy nobody a mailbox, so - * we leave them out. - * - * Syntax is all we check. Whether an address can actually receive mail is - * settled by sending it a verification email, not by a regex. + * Letters, marks and digits are stricter than RFC 6531, which allows any + * non-ASCII character in the local part. We leave out the invisible and + * text-reordering ones, like zero-width joiners and bidirectional overrides, + * because they can make two different addresses look identical on screen. A + * combining mark cannot start the local part for the same reason: with no + * character to attach to, it lands on whatever text precedes the address. */ -const validEmailRegex = - /^[\p{L}\p{M}\p{N}.!#$%&'*+/=?^_`{|}~-]+@[\p{L}\p{N}](?:[\p{L}\p{M}\p{N}-]{0,61}[\p{L}\p{M}\p{N}])?(?:\.[\p{L}\p{N}](?:[\p{L}\p{M}\p{N}-]{0,61}[\p{L}\p{M}\p{N}])?)*$/u; +const HTML5_UNICODE_EMAIL_REGEX = + /^(?!\p{M})[\p{L}\p{M}\p{N}.!#$%&'*+/=?^_`{|}~-]+@[\p{L}\p{N}](?:[\p{L}\p{M}\p{N}-]{0,61}[\p{L}\p{M}\p{N}])?(?:\.[\p{L}\p{N}](?:[\p{L}\p{M}\p{N}-]{0,61}[\p{L}\p{M}\p{N}])?)*$/u; /** - * Upper bounds from RFC 5321 (4.5.3.1. Size Limits and Minimums), counted in - * octets because that is how the RFC counts them. + * Upper bounds from RFC 5321 (4.5.3.1. Size Limits and Minimums). */ -const maxLocalPartOctets = 64; -const maxAddressOctets = 254; +const MAX_LOCAL_PART_OCTESTS = 64; +const MAX_ADDRESS_OCTETS = 254; -/** - * Checks that `input` looks like an email address. - */ export function isValidEmail(input: unknown): boolean { if (typeof input !== "string") { return false; } return ( - validEmailRegex.test(input) && - countOctets(input) <= maxAddressOctets && - countOctets(getLocalPart(input)) <= maxLocalPartOctets + HTML5_UNICODE_EMAIL_REGEX.test(input) && + countOctets(input) <= MAX_ADDRESS_OCTETS && + countOctets(getEmailLocalPart(input)) <= MAX_LOCAL_PART_OCTESTS ); } -function getLocalPart(email: string): string { +function getEmailLocalPart(email: string): string { return email.slice(0, email.lastIndexOf("@")); } diff --git a/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/emailField.ts b/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/emailField.ts index 91db166b3f..744df8c166 100644 --- a/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/emailField.ts +++ b/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/emailField.ts @@ -1,9 +1,10 @@ import { isValidEmail } from '@wasp.sh/lib-auth' /** - * Deliberately not `type="email"`: browsers validate that against the HTML5 - * grammar, which is ASCII-only and would reject internationalized addresses - * that the server accepts. `inputMode` keeps the email keyboard on mobile. + * Deliberately avoids setting `type="email"`. + * Browsers validate input against the HTML5 grammar, which is ASCII-only + * and would reject internationalized addresses that the server accepts. + * `inputMode` keeps the email keyboard on mobile. */ export const emailInputProps = { type: 'text', @@ -11,13 +12,9 @@ export const emailInputProps = { autoComplete: 'email', } as const -/** - * react-hook-form rules that mirror the server side `ensureValidEmail`. - */ export const emailFieldRules = { required: 'Email is required', - // `type="email"` used to strip surrounding whitespace for us, `type="text"` - // doesn't, so a pasted address would fail validation without this. + // `type="email"` used to strip surrounding whitespace for us, `type="text"` doesn't. setValueAs: (email: string) => email.trim(), validate: (email: string) => isValidEmail(email) || 'Email must be a valid email', diff --git a/web/docs/auth/overview.md b/web/docs/auth/overview.md index f1929c8d93..467c16094a 100644 --- a/web/docs/auth/overview.md +++ b/web/docs/auth/overview.md @@ -347,12 +347,13 @@ Note that `username`s are stored in a **case-insensitive** manner. If you use [Email](./email.md) authentication, the default validations are: -- The `email` must not be empty and a valid email address +- The `email` must not be empty and a valid email address (HTML5 format widened to support unicode) - The `password` must not be empty, have at least 8 characters, and contain a number Note that `email`s are stored in a **case-insensitive** manner. -An address counts as valid if it matches the syntax browsers enforce on `input[type=email]`, widened to accept internationalized addresses such as `jürgen@münchen.de`. Because browsers reject those addresses themselves, use `type="text"` with `inputMode="email"` if you build your own form, and trim the value before you send it. +Because Wasp supports unicode email addresses, browser would reject their syntax with `input[type=email]`. +If you are building your own form, please use `type="text"` with `inputMode="email"` instead. ## Customizing the Signup Process From 76ad23592e6f3b07c7c4ff685610f2442fbea478 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Thu, 13 Aug 2026 09:46:40 +0200 Subject: [PATCH 08/21] Scope the email e2e tests to internationalized addresses --- .../kitchen-sink/e2e-tests/tests/auth.spec.ts | 68 ++++--------------- waspc/data/Generator/libs/auth/src/email.ts | 4 +- .../Generator/libs/auth/tests/email.test.ts | 8 +++ .../wasp-app/.wasp/out/.waspchecksums | 4 +- .../wasp/auth/forms/internal/emailField.ts | 13 ++-- .../wasp-app/e2e-tests/tests/auth.spec.ts | 68 ++++--------------- .../wasp-app/.wasp/out/.waspchecksums | 2 +- .../wasp-app/.wasp/out/.waspchecksums | 2 +- .../wasp-app/.wasp/out/.waspchecksums | 2 +- 9 files changed, 44 insertions(+), 127 deletions(-) diff --git a/examples/kitchen-sink/e2e-tests/tests/auth.spec.ts b/examples/kitchen-sink/e2e-tests/tests/auth.spec.ts index f7d79a5404..6ac1374958 100644 --- a/examples/kitchen-sink/e2e-tests/tests/auth.spec.ts +++ b/examples/kitchen-sink/e2e-tests/tests/auth.spec.ts @@ -46,7 +46,10 @@ test.describe("auth", () => { test("can sign up", async ({ page }) => { await performSignup(page, { - email, + // Padded to check that the form trims the address. The rest of this + // flow uses the unpadded address, so verifying and logging in only + // work if the trimmed address is the one that got stored. + email: ` ${email} `, password, address: "Some at least 10 letter address", }); @@ -136,75 +139,28 @@ test.describe("auth", () => { }); }); - test.describe("uppercase email address", () => { - test.skip( - isRunningInDeployedMode(), - "Skipped in deployed mode (no Mailcrab)", - ); - test.describe.configure({ mode: "serial" }); - - // Emails are stored lowercased, and auth emails go to the stored address, - // so signing up in uppercase must deliver to the lowercased address. - const email = generateRandomEmail().toUpperCase(); - const password = "12345678"; - - test("can sign up", async ({ page }) => { - await performSignup(page, { - email, - password, - address: "Some at least 10 letter address", - }); - - await expect(page.locator("body")).toContainText( - `You've signed up successfully! Check your email for the confirmation link.`, - ); - }); - - test("receives the verification email at the lowercased address", async ({ - page, - }) => { - if (isRunningInDevMode()) { - // Skip this test in dev mode, as email confirmation is not required. - test.skip(); - } - - await performEmailVerification(page, email.toLowerCase()); - }); - - test("can log in", async ({ page }) => { - await performLogin(page, { email, password }); - - await expect(page).toHaveURL("/"); - }); - }); - - test.describe("email address with surrounding whitespace", () => { - test.skip( - isRunningInDeployedMode(), - "Skipped in deployed mode (no Mailcrab)", - ); - - test("the form trims the address before submitting it", async ({ + test.describe("invalid email address", () => { + test("signing up with a malformed address results in an error message", async ({ page, }) => { await performSignup(page, { - email: ` ${generateRandomEmail()} `, + email: "not-an-email", password: "12345678", address: "Some at least 10 letter address", }); await expect(page.locator("body")).toContainText( - `You've signed up successfully! Check your email for the confirmation link.`, + "Email must be a valid email", ); }); - }); - test.describe("invalid email address", () => { - test("signing up with a malformed address results in an error message", async ({ + // We accept a narrower set of Unicode than RFC 6531 does, so an address + // can be internationalized and still be rejected. + test("signing up with a leading combining mark results in an error message", async ({ page, }) => { await performSignup(page, { - email: "not-an-email", + email: `\u0301${generateRandomInternationalizedEmail()}`, password: "12345678", address: "Some at least 10 letter address", }); diff --git a/waspc/data/Generator/libs/auth/src/email.ts b/waspc/data/Generator/libs/auth/src/email.ts index 2700098c81..dd5a804716 100644 --- a/waspc/data/Generator/libs/auth/src/email.ts +++ b/waspc/data/Generator/libs/auth/src/email.ts @@ -26,7 +26,7 @@ const HTML5_UNICODE_EMAIL_REGEX = /** * Upper bounds from RFC 5321 (4.5.3.1. Size Limits and Minimums). */ -const MAX_LOCAL_PART_OCTESTS = 64; +const MAX_LOCAL_PART_OCTETS = 64; const MAX_ADDRESS_OCTETS = 254; export function isValidEmail(input: unknown): boolean { @@ -37,7 +37,7 @@ export function isValidEmail(input: unknown): boolean { return ( HTML5_UNICODE_EMAIL_REGEX.test(input) && countOctets(input) <= MAX_ADDRESS_OCTETS && - countOctets(getEmailLocalPart(input)) <= MAX_LOCAL_PART_OCTESTS + countOctets(getEmailLocalPart(input)) <= MAX_LOCAL_PART_OCTETS ); } diff --git a/waspc/data/Generator/libs/auth/tests/email.test.ts b/waspc/data/Generator/libs/auth/tests/email.test.ts index 487ca6e2a1..6c8e4947d1 100644 --- a/waspc/data/Generator/libs/auth/tests/email.test.ts +++ b/waspc/data/Generator/libs/auth/tests/email.test.ts @@ -46,6 +46,8 @@ describe("isValidEmail", () => { "θσερ@εχαμπλε.ψομ", "अजय@डाटा.भारत", "квіточка@пошта.укр", + // Decomposed "ö", a combining mark following its base character. + "o\u0308ffentlich@example.com", ])("accepts %j", (email) => { expect(isValidEmail(email)).toBe(true); }); @@ -62,6 +64,12 @@ describe("isValidEmail", () => { ])("rejects the non-letter, non-digit character in %j", (email) => { expect(isValidEmail(email)).toBe(false); }); + + it("rejects a local part starting with a combining mark", () => { + // The mark has no character of its own to attach to, so it lands on + // whatever text precedes the address when it is rendered. + expect(isValidEmail("\u0301user@example.com")).toBe(false); + }); }); describe("case", () => { diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums index 31010b3655..7e847b93cd 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums @@ -32,7 +32,7 @@ "file", "libs/auth/wasp.sh-lib-auth-0.26.0.tgz" ], - "679a0122d48fb625ea03735e811605c3e14074537591cf86e57f3075f38c3fac" + "84b1ee14aba5b258e0f1faff2bf31382e7510a2bb80c769a17f59e559b13eb19" ], [ [ @@ -221,7 +221,7 @@ "file", "sdk/wasp/auth/forms/internal/emailField.ts" ], - "3d5ef44ba8341f0fdb01298f7b219d9f6569386ce519efce5553beb11785fce0" + "647510440df0b9d5d994fb68e61452d5311c335129938496b1c0ec0b5615e045" ], [ [ diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/emailField.ts b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/emailField.ts index 91db166b3f..744df8c166 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/emailField.ts +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/emailField.ts @@ -1,9 +1,10 @@ import { isValidEmail } from '@wasp.sh/lib-auth' /** - * Deliberately not `type="email"`: browsers validate that against the HTML5 - * grammar, which is ASCII-only and would reject internationalized addresses - * that the server accepts. `inputMode` keeps the email keyboard on mobile. + * Deliberately avoids setting `type="email"`. + * Browsers validate input against the HTML5 grammar, which is ASCII-only + * and would reject internationalized addresses that the server accepts. + * `inputMode` keeps the email keyboard on mobile. */ export const emailInputProps = { type: 'text', @@ -11,13 +12,9 @@ export const emailInputProps = { autoComplete: 'email', } as const -/** - * react-hook-form rules that mirror the server side `ensureValidEmail`. - */ export const emailFieldRules = { required: 'Email is required', - // `type="email"` used to strip surrounding whitespace for us, `type="text"` - // doesn't, so a pasted address would fail validation without this. + // `type="email"` used to strip surrounding whitespace for us, `type="text"` doesn't. setValueAs: (email: string) => email.trim(), validate: (email: string) => isValidEmail(email) || 'Email must be a valid email', diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/auth.spec.ts b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/auth.spec.ts index f7d79a5404..6ac1374958 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/auth.spec.ts +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/auth.spec.ts @@ -46,7 +46,10 @@ test.describe("auth", () => { test("can sign up", async ({ page }) => { await performSignup(page, { - email, + // Padded to check that the form trims the address. The rest of this + // flow uses the unpadded address, so verifying and logging in only + // work if the trimmed address is the one that got stored. + email: ` ${email} `, password, address: "Some at least 10 letter address", }); @@ -136,75 +139,28 @@ test.describe("auth", () => { }); }); - test.describe("uppercase email address", () => { - test.skip( - isRunningInDeployedMode(), - "Skipped in deployed mode (no Mailcrab)", - ); - test.describe.configure({ mode: "serial" }); - - // Emails are stored lowercased, and auth emails go to the stored address, - // so signing up in uppercase must deliver to the lowercased address. - const email = generateRandomEmail().toUpperCase(); - const password = "12345678"; - - test("can sign up", async ({ page }) => { - await performSignup(page, { - email, - password, - address: "Some at least 10 letter address", - }); - - await expect(page.locator("body")).toContainText( - `You've signed up successfully! Check your email for the confirmation link.`, - ); - }); - - test("receives the verification email at the lowercased address", async ({ - page, - }) => { - if (isRunningInDevMode()) { - // Skip this test in dev mode, as email confirmation is not required. - test.skip(); - } - - await performEmailVerification(page, email.toLowerCase()); - }); - - test("can log in", async ({ page }) => { - await performLogin(page, { email, password }); - - await expect(page).toHaveURL("/"); - }); - }); - - test.describe("email address with surrounding whitespace", () => { - test.skip( - isRunningInDeployedMode(), - "Skipped in deployed mode (no Mailcrab)", - ); - - test("the form trims the address before submitting it", async ({ + test.describe("invalid email address", () => { + test("signing up with a malformed address results in an error message", async ({ page, }) => { await performSignup(page, { - email: ` ${generateRandomEmail()} `, + email: "not-an-email", password: "12345678", address: "Some at least 10 letter address", }); await expect(page.locator("body")).toContainText( - `You've signed up successfully! Check your email for the confirmation link.`, + "Email must be a valid email", ); }); - }); - test.describe("invalid email address", () => { - test("signing up with a malformed address results in an error message", async ({ + // We accept a narrower set of Unicode than RFC 6531 does, so an address + // can be internationalized and still be rejected. + test("signing up with a leading combining mark results in an error message", async ({ page, }) => { await performSignup(page, { - email: "not-an-email", + email: `\u0301${generateRandomInternationalizedEmail()}`, password: "12345678", address: "Some at least 10 letter address", }); diff --git a/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/.waspchecksums b/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/.waspchecksums index 293f2df33c..8bc07b8521 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/.waspchecksums +++ b/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/.waspchecksums @@ -25,7 +25,7 @@ "file", "libs/auth/wasp.sh-lib-auth-0.26.0.tgz" ], - "679a0122d48fb625ea03735e811605c3e14074537591cf86e57f3075f38c3fac" + "84b1ee14aba5b258e0f1faff2bf31382e7510a2bb80c769a17f59e559b13eb19" ], [ [ diff --git a/waspc/e2e-tests/test-outputs/snapshots/wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums b/waspc/e2e-tests/test-outputs/snapshots/wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums index 4acc6591bc..f29d5bd427 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums +++ b/waspc/e2e-tests/test-outputs/snapshots/wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums @@ -25,7 +25,7 @@ "file", "libs/auth/wasp.sh-lib-auth-0.26.0.tgz" ], - "679a0122d48fb625ea03735e811605c3e14074537591cf86e57f3075f38c3fac" + "84b1ee14aba5b258e0f1faff2bf31382e7510a2bb80c769a17f59e559b13eb19" ], [ [ diff --git a/waspc/e2e-tests/test-outputs/snapshots/wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums b/waspc/e2e-tests/test-outputs/snapshots/wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums index 142f31fc8d..26f43d66b4 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums +++ b/waspc/e2e-tests/test-outputs/snapshots/wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums @@ -25,7 +25,7 @@ "file", "libs/auth/wasp.sh-lib-auth-0.26.0.tgz" ], - "679a0122d48fb625ea03735e811605c3e14074537591cf86e57f3075f38c3fac" + "84b1ee14aba5b258e0f1faff2bf31382e7510a2bb80c769a17f59e559b13eb19" ], [ [ From 7ee7fd148148e68cfa9bb47150c29fbcf0899d31 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Thu, 13 Aug 2026 09:53:25 +0200 Subject: [PATCH 09/21] Update markdown snapshots for the reworded email docs --- web/markdown-snapshots/docs/auth/overview.md | 4 ++-- web/markdown-snapshots/llms-full.txt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/web/markdown-snapshots/docs/auth/overview.md b/web/markdown-snapshots/docs/auth/overview.md index a1b9616629..654b3a38a0 100644 --- a/web/markdown-snapshots/docs/auth/overview.md +++ b/web/markdown-snapshots/docs/auth/overview.md @@ -355,12 +355,12 @@ Note that `username`s are stored in a **case-insensitive** manner. If you use [Email](https://wasp.sh/docs/auth/email) authentication, the default validations are: -- The `email` must not be empty and a valid email address +- The `email` must not be empty and a valid email address (HTML5 format widened to support unicode) - The `password` must not be empty, have at least 8 characters, and contain a number Note that `email`s are stored in a **case-insensitive** manner. -An address counts as valid if it matches the syntax browsers enforce on `input[type=email]`, widened to accept internationalized addresses such as `jürgen@münchen.de`. Because browsers reject those addresses themselves, use `type="text"` with `inputMode="email"` if you build your own form, and trim the value before you send it. +Because Wasp supports unicode email addresses, browser would reject their syntax with `input[type=email]`. If you are building your own form, please use `type="text"` with `inputMode="email"` instead. ## Customizing the Signup Process diff --git a/web/markdown-snapshots/llms-full.txt b/web/markdown-snapshots/llms-full.txt index 747ae7abb5..a5459e5efd 100644 --- a/web/markdown-snapshots/llms-full.txt +++ b/web/markdown-snapshots/llms-full.txt @@ -3918,12 +3918,12 @@ Note that `username`s are stored in a **case-insensitive** manner. If you use [Email](https://wasp.sh/docs/auth/email) authentication, the default validations are: -- The `email` must not be empty and a valid email address +- The `email` must not be empty and a valid email address (HTML5 format widened to support unicode) - The `password` must not be empty, have at least 8 characters, and contain a number Note that `email`s are stored in a **case-insensitive** manner. -An address counts as valid if it matches the syntax browsers enforce on `input[type=email]`, widened to accept internationalized addresses such as `jürgen@münchen.de`. Because browsers reject those addresses themselves, use `type="text"` with `inputMode="email"` if you build your own form, and trim the value before you send it. +Because Wasp supports unicode email addresses, browser would reject their syntax with `input[type=email]`. If you are building your own form, please use `type="text"` with `inputMode="email"` instead. ### Customizing the Signup Process From 2e478ec5cc04881221d6e10420066052c226f479 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Thu, 13 Aug 2026 09:57:46 +0200 Subject: [PATCH 10/21] Re-add the trimming advice to the email auth docs --- web/docs/auth/overview.md | 5 +++-- web/markdown-snapshots/docs/auth/overview.md | 4 ++-- web/markdown-snapshots/llms-full.txt | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/web/docs/auth/overview.md b/web/docs/auth/overview.md index 467c16094a..b230883ac6 100644 --- a/web/docs/auth/overview.md +++ b/web/docs/auth/overview.md @@ -347,13 +347,14 @@ Note that `username`s are stored in a **case-insensitive** manner. If you use [Email](./email.md) authentication, the default validations are: -- The `email` must not be empty and a valid email address (HTML5 format widened to support unicode) +- The `email` must not be empty and a valid email address (HTML5 format widened to support Unicode) - The `password` must not be empty, have at least 8 characters, and contain a number Note that `email`s are stored in a **case-insensitive** manner. -Because Wasp supports unicode email addresses, browser would reject their syntax with `input[type=email]`. +Because Wasp supports Unicode email addresses, browsers would reject their syntax with `input[type=email]`. If you are building your own form, please use `type="text"` with `inputMode="email"` instead. +Keep in mind that `type="text"` doesn't strip surrounding whitespace the way `type="email"` does, so trim the address before you send it. ## Customizing the Signup Process diff --git a/web/markdown-snapshots/docs/auth/overview.md b/web/markdown-snapshots/docs/auth/overview.md index 654b3a38a0..c23cf330df 100644 --- a/web/markdown-snapshots/docs/auth/overview.md +++ b/web/markdown-snapshots/docs/auth/overview.md @@ -355,12 +355,12 @@ Note that `username`s are stored in a **case-insensitive** manner. If you use [Email](https://wasp.sh/docs/auth/email) authentication, the default validations are: -- The `email` must not be empty and a valid email address (HTML5 format widened to support unicode) +- The `email` must not be empty and a valid email address (HTML5 format widened to support Unicode) - The `password` must not be empty, have at least 8 characters, and contain a number Note that `email`s are stored in a **case-insensitive** manner. -Because Wasp supports unicode email addresses, browser would reject their syntax with `input[type=email]`. If you are building your own form, please use `type="text"` with `inputMode="email"` instead. +Because Wasp supports Unicode email addresses, browsers would reject their syntax with `input[type=email]`. If you are building your own form, please use `type="text"` with `inputMode="email"` instead. Keep in mind that `type="text"` doesn't strip surrounding whitespace the way `type="email"` does, so trim the address before you send it. ## Customizing the Signup Process diff --git a/web/markdown-snapshots/llms-full.txt b/web/markdown-snapshots/llms-full.txt index a5459e5efd..183dd8bbe4 100644 --- a/web/markdown-snapshots/llms-full.txt +++ b/web/markdown-snapshots/llms-full.txt @@ -3918,12 +3918,12 @@ Note that `username`s are stored in a **case-insensitive** manner. If you use [Email](https://wasp.sh/docs/auth/email) authentication, the default validations are: -- The `email` must not be empty and a valid email address (HTML5 format widened to support unicode) +- The `email` must not be empty and a valid email address (HTML5 format widened to support Unicode) - The `password` must not be empty, have at least 8 characters, and contain a number Note that `email`s are stored in a **case-insensitive** manner. -Because Wasp supports unicode email addresses, browser would reject their syntax with `input[type=email]`. If you are building your own form, please use `type="text"` with `inputMode="email"` instead. +Because Wasp supports Unicode email addresses, browsers would reject their syntax with `input[type=email]`. If you are building your own form, please use `type="text"` with `inputMode="email"` instead. Keep in mind that `type="text"` doesn't strip surrounding whitespace the way `type="email"` does, so trim the address before you send it. ### Customizing the Signup Process From 838624dcbf1da7163e1cc4f9204f1fb5753aab1e Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Thu, 13 Aug 2026 10:00:23 +0200 Subject: [PATCH 11/21] Trim the email in the custom auth UI docs examples --- web/docs/auth/email/create-your-own-ui.md | 18 ++++++++++++------ web/markdown-snapshots/llms-full.txt | 12 +++++++++--- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/web/docs/auth/email/create-your-own-ui.md b/web/docs/auth/email/create-your-own-ui.md index 77bf027df1..015946fb88 100644 --- a/web/docs/auth/email/create-your-own-ui.md +++ b/web/docs/auth/email/create-your-own-ui.md @@ -12,6 +12,12 @@ Read more about the default email and password validation rules in the [auth ove Even though Wasp offers premade [Auth UI](../ui.md) for your authentication flows, there are times when you might want more customization, so we also give you the option to create your own UI and call Wasp's auth actions from your own code, similar to how Auth UI does it under the hood. +:::note Handling the email input +The examples below treat the email input the way Auth UI does. They use `type="text"` with `inputMode="email"` instead of `type="email"`, because browsers validate `type="email"` against an ASCII-only grammar that rejects the internationalized addresses Wasp accepts. They also trim the address before submitting it, which `type="email"` used to take care of on its own. + +Wasp validates the address on the server either way, so a malformed one comes back as an error you can show to the user. +::: + ## Example code Below you can find a starting point for making your own UI in the client code. This example has all the necessary components to handle login, signup, email verification, and the password reset flow. You can customize any of its look and behaviour, just make sure to call the functions imported from `wasp/client/auth`. @@ -41,7 +47,7 @@ Below you can find a starting point for making your own UI in the client code. T event.preventDefault() setError(null) try { - await login({ email, password }) + await login({ email: email.trim(), password }) navigate('/') } catch (error) { setError(error) @@ -81,7 +87,7 @@ Below you can find a starting point for making your own UI in the client code. T event.preventDefault() setError(null) try { - await signup({ email, password }) + await signup({ email: email.trim(), password }) setNeedsConfirmation(true) } catch (error) { console.error('Error during signup:', error) @@ -159,7 +165,7 @@ Below you can find a starting point for making your own UI in the client code. T event.preventDefault() setError(null) try { - await requestPasswordReset({ email }) + await requestPasswordReset({ email: email.trim() }) setNeedsConfirmation(true) } catch (error) { console.error('Error during requesting reset:', error) @@ -258,7 +264,7 @@ Below you can find a starting point for making your own UI in the client code. T event.preventDefault() setError(null) try { - await login({ email, password }) + await login({ email: email.trim(), password }) navigate('/') } catch (error: unknown) { setError(error as Error) @@ -298,7 +304,7 @@ Below you can find a starting point for making your own UI in the client code. T event.preventDefault() setError(null) try { - await signup({ email, password }) + await signup({ email: email.trim(), password }) setNeedsConfirmation(true) } catch (error: unknown) { console.error('Error during signup:', error) @@ -376,7 +382,7 @@ Below you can find a starting point for making your own UI in the client code. T event.preventDefault() setError(null) try { - await requestPasswordReset({ email }) + await requestPasswordReset({ email: email.trim() }) setNeedsConfirmation(true) } catch (error: unknown) { console.error('Error during requesting reset:', error) diff --git a/web/markdown-snapshots/llms-full.txt b/web/markdown-snapshots/llms-full.txt index 183dd8bbe4..471022d2bd 100644 --- a/web/markdown-snapshots/llms-full.txt +++ b/web/markdown-snapshots/llms-full.txt @@ -5408,6 +5408,12 @@ Read more about the default email and password validation rules in the [auth ove Even though Wasp offers premade [Auth UI](https://wasp.sh/docs/auth/ui) for your authentication flows, there are times when you might want more customization, so we also give you the option to create your own UI and call Wasp's auth actions from your own code, similar to how Auth UI does it under the hood. +:::note[Handling the email input] +The examples below treat the email input the way Auth UI does. They use `type="text"` with `inputMode="email"` instead of `type="email"`, because browsers validate `type="email"` against an ASCII-only grammar that rejects the internationalized addresses Wasp accepts. They also trim the address before submitting it, which `type="email"` used to take care of on its own. + +Wasp validates the address on the server either way, so a malformed one comes back as an error you can show to the user. +::: + ### Example code Below you can find a starting point for making your own UI in the client code. This example has all the necessary components to handle login, signup, email verification, and the password reset flow. You can customize any of its look and behaviour, just make sure to call the functions imported from `wasp/client/auth`. @@ -5435,7 +5441,7 @@ export function LoginPage() { event.preventDefault() setError(null) try { - await login({ email, password }) + await login({ email: email.trim(), password }) navigate('/') } catch (error: unknown) { setError(error as Error) @@ -5475,7 +5481,7 @@ export function SignupPage() { event.preventDefault() setError(null) try { - await signup({ email, password }) + await signup({ email: email.trim(), password }) setNeedsConfirmation(true) } catch (error: unknown) { console.error('Error during signup:', error) @@ -5553,7 +5559,7 @@ export function RequestPasswordResetPage() { event.preventDefault() setError(null) try { - await requestPasswordReset({ email }) + await requestPasswordReset({ email: email.trim() }) setNeedsConfirmation(true) } catch (error: unknown) { console.error('Error during requesting reset:', error) From 69b41f7476d8c38a8adfd5d772bcb5a71247d4d0 Mon Sep 17 00:00:00 2001 From: Franjo Mindek <84568328+FranjoMindek@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:05:08 +0200 Subject: [PATCH 12/21] Update web/docs/auth/email/create-your-own-ui.md Co-authored-by: Carlos Precioso --- web/docs/auth/email/create-your-own-ui.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/docs/auth/email/create-your-own-ui.md b/web/docs/auth/email/create-your-own-ui.md index 015946fb88..6b1055dc34 100644 --- a/web/docs/auth/email/create-your-own-ui.md +++ b/web/docs/auth/email/create-your-own-ui.md @@ -15,7 +15,7 @@ Even though Wasp offers premade [Auth UI](../ui.md) for your authentication flow :::note Handling the email input The examples below treat the email input the way Auth UI does. They use `type="text"` with `inputMode="email"` instead of `type="email"`, because browsers validate `type="email"` against an ASCII-only grammar that rejects the internationalized addresses Wasp accepts. They also trim the address before submitting it, which `type="email"` used to take care of on its own. -Wasp validates the address on the server either way, so a malformed one comes back as an error you can show to the user. +Wasp validates the address on the server either way, so an invalid one comes back as an error you can show to the user. ::: ## Example code From 34f969a1b19288d3e64e0a342235f6714b2eae39 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Wed, 26 Aug 2026 10:32:56 +0200 Subject: [PATCH 13/21] cleanup --- .../kitchen-sink/e2e-tests/tests/auth.spec.ts | 6 +- .../kitchen-sink/e2e-tests/tests/helpers.ts | 3 +- waspc/data/Generator/libs/auth/src/email.ts | 57 +++++++++---------- .../wasp/auth/forms/internal/emailField.ts | 4 +- .../templates/sdk/wasp/auth/validation.ts | 2 - .../server/src/auth/providers/email/signup.ts | 26 ++++----- web/docs/auth/email/create-your-own-ui.md | 6 +- 7 files changed, 48 insertions(+), 56 deletions(-) diff --git a/examples/kitchen-sink/e2e-tests/tests/auth.spec.ts b/examples/kitchen-sink/e2e-tests/tests/auth.spec.ts index 6ac1374958..1ed0650806 100644 --- a/examples/kitchen-sink/e2e-tests/tests/auth.spec.ts +++ b/examples/kitchen-sink/e2e-tests/tests/auth.spec.ts @@ -46,9 +46,7 @@ test.describe("auth", () => { test("can sign up", async ({ page }) => { await performSignup(page, { - // Padded to check that the form trims the address. The rest of this - // flow uses the unpadded address, so verifying and logging in only - // work if the trimmed address is the one that got stored. + // Padded to check that the form trims the address. email: ` ${email} `, password, address: "Some at least 10 letter address", @@ -154,8 +152,6 @@ test.describe("auth", () => { ); }); - // We accept a narrower set of Unicode than RFC 6531 does, so an address - // can be internationalized and still be rejected. test("signing up with a leading combining mark results in an error message", async ({ page, }) => { diff --git a/examples/kitchen-sink/e2e-tests/tests/helpers.ts b/examples/kitchen-sink/e2e-tests/tests/helpers.ts index ff9e769d52..4812c08b4a 100644 --- a/examples/kitchen-sink/e2e-tests/tests/helpers.ts +++ b/examples/kitchen-sink/e2e-tests/tests/helpers.ts @@ -14,8 +14,7 @@ export function generateRandomEmail(): string { } /** - * An address with non-ASCII characters on both sides of the `@`, the kind - * RFC 6531 allows and the HTML5 `input[type=email]` grammar does not. + * Generates an address with non-ASCII characters on both sides of the `@`. */ export function generateRandomInternationalizedEmail(): string { return `jürgen-${randomUUID()}@münchen.test`; diff --git a/waspc/data/Generator/libs/auth/src/email.ts b/waspc/data/Generator/libs/auth/src/email.ts index dd5a804716..f576bd5a58 100644 --- a/waspc/data/Generator/libs/auth/src/email.ts +++ b/waspc/data/Generator/libs/auth/src/email.ts @@ -1,50 +1,49 @@ // TODO: If we ever need a more quality email validator, its worth to // look at https://github.com/JoshData/python-email-validator for inspiration. + /** - * The syntax we accept is the HTML5 `input[type=email]` grammar, widened to - * also accept Unicode letters, marks and digits so that internationalized - * addresses are not rejected. + * The syntax we accept is the HTML5 `input[type=email]` grammar, + * widened to also accept Unicode characters. + * + * We start from the HTML5 grammar because it is a good compromise between + * completeness and complexity. We widen it because it is deliberately + * ASCII-only, which locks out anyone whose address contains unicode. * - * We start from the HTML5 grammar rather than RFC 5322 because it is a good - * compromise. It is a willful violation of the RFC that drops the corners - * nobody uses (quoted local parts, comments, IP-literal domains) and keeps - * what remains simple enough to read as a single regex. We widen it because - * it is deliberately ASCII-only, which locks out anyone whose address - * contains unicode. - * @see https://github.com/whatwg/html/issues/4562 + * We also do some extra safefty checks by filtering out invisible, + * text-reordering, zero-width and bidirectional override (LTR or RTL) + * unicode characters. Also a combining mark cannot start an email address, + * because it would attach to whatever text that precedes the address. * - * Letters, marks and digits are stricter than RFC 6531, which allows any - * non-ASCII character in the local part. We leave out the invisible and - * text-reordering ones, like zero-width joiners and bidirectional overrides, - * because they can make two different addresses look identical on screen. A - * combining mark cannot start the local part for the same reason: with no - * character to attach to, it lands on whatever text precedes the address. + * @see {@link https://github.com/whatwg/html/issues/4562 WHATWG international email addresses issue} */ -const HTML5_UNICODE_EMAIL_REGEX = +const HTML5_EMAIL_WITH_UNICODE_REGEX = /^(?!\p{M})[\p{L}\p{M}\p{N}.!#$%&'*+/=?^_`{|}~-]+@[\p{L}\p{N}](?:[\p{L}\p{M}\p{N}-]{0,61}[\p{L}\p{M}\p{N}])?(?:\.[\p{L}\p{N}](?:[\p{L}\p{M}\p{N}-]{0,61}[\p{L}\p{M}\p{N}])?)*$/u; -/** - * Upper bounds from RFC 5321 (4.5.3.1. Size Limits and Minimums). - */ -const MAX_LOCAL_PART_OCTETS = 64; -const MAX_ADDRESS_OCTETS = 254; - -export function isValidEmail(input: unknown): boolean { +export function isValidEmail(input: string): boolean { if (typeof input !== "string") { return false; } return ( - HTML5_UNICODE_EMAIL_REGEX.test(input) && - countOctets(input) <= MAX_ADDRESS_OCTETS && - countOctets(getEmailLocalPart(input)) <= MAX_LOCAL_PART_OCTETS + HTML5_EMAIL_WITH_UNICODE_REGEX.test(input) && isEmailOfValidLength(input) ); } -function getEmailLocalPart(email: string): string { - return email.slice(0, email.lastIndexOf("@")); +// Upper bounds from RFC 5321. +const MAX_EMAIL_ADDRESS_LOCAL_PART_OCTETS = 64; +const MAX_EMAIL_ADDRESS_OCTETS = 254; + +function isEmailOfValidLength(email: string) { + return ( + countOctets(email) <= MAX_EMAIL_ADDRESS_OCTETS && + countOctets(getEmailLocalPart(email)) <= MAX_EMAIL_ADDRESS_LOCAL_PART_OCTETS + ); } function countOctets(text: string): number { return new TextEncoder().encode(text).length; } + +function getEmailLocalPart(email: string): string { + return email.slice(0, email.lastIndexOf("@")); +} diff --git a/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/emailField.ts b/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/emailField.ts index 744df8c166..12f2c0aa8b 100644 --- a/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/emailField.ts +++ b/waspc/data/Generator/templates/sdk/wasp/auth/forms/internal/emailField.ts @@ -2,9 +2,11 @@ import { isValidEmail } from '@wasp.sh/lib-auth' /** * Deliberately avoids setting `type="email"`. - * Browsers validate input against the HTML5 grammar, which is ASCII-only + * Browsers validate email input against the HTML5 grammar, which is ASCII-only * and would reject internationalized addresses that the server accepts. * `inputMode` keeps the email keyboard on mobile. + * + * @see {@link https://github.com/whatwg/html/issues/4562 WHATWG international email addresses issue} */ export const emailInputProps = { type: 'text', diff --git a/waspc/data/Generator/templates/sdk/wasp/auth/validation.ts b/waspc/data/Generator/templates/sdk/wasp/auth/validation.ts index 671f54afb5..5521f1f440 100644 --- a/waspc/data/Generator/templates/sdk/wasp/auth/validation.ts +++ b/waspc/data/Generator/templates/sdk/wasp/auth/validation.ts @@ -56,8 +56,6 @@ function validate(args: object, validators: { validates: string, message: string } } -// NOTE(miho): it would be good to replace our custom validations with e.g. Zod - function isMinLength(input: unknown, minLength: number): boolean { if (typeof input !== 'string') { return false diff --git a/waspc/data/Generator/templates/server/src/auth/providers/email/signup.ts b/waspc/data/Generator/templates/server/src/auth/providers/email/signup.ts index 0d55b7f63e..e049b73de8 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/email/signup.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/email/signup.ts @@ -1,16 +1,5 @@ import { Request, Response } from 'express' import type { UserSignupFields } from 'wasp/auth/providers/types' -import { - createProviderId, - createUser, - deleteUserByAuthId, - doFakeWork, - findAuthIdentity, - getProviderDataWithPassword, - rethrowPossibleAuthError, - sanitizeAndSerializeProviderData, - validateAndGetUserFields, -} from 'wasp/server/auth/utils' import { ensurePasswordIsPresent, ensureValidEmail, @@ -23,6 +12,17 @@ import { isEmailResendAllowed, sendEmailVerificationEmail, } from 'wasp/server/auth/email/utils' +import { + createProviderId, + createUser, + deleteUserByAuthId, + doFakeWork, + findAuthIdentity, + getProviderDataWithPassword, + rethrowPossibleAuthError, + sanitizeAndSerializeProviderData, + validateAndGetUserFields, +} from 'wasp/server/auth/utils' import { EmailFromField } from 'wasp/server/email/core/types' import { onAfterSignupHook, onBeforeSignupHook } from '../../hooks.js' @@ -146,8 +146,8 @@ export function getSignupRoute({ return } - // We send to the address we stored, not to the one the user typed, so that - // the address that proves ownership is the same one password reset later + // We send the verification link to the normalized address that we stored. + // This way the address that proves ownership is the same one password reset later // sends the reset link to. const email = providerId.providerUserId const verificationLink = await createEmailVerificationLink( diff --git a/web/docs/auth/email/create-your-own-ui.md b/web/docs/auth/email/create-your-own-ui.md index 6b1055dc34..6b44600265 100644 --- a/web/docs/auth/email/create-your-own-ui.md +++ b/web/docs/auth/email/create-your-own-ui.md @@ -12,10 +12,8 @@ Read more about the default email and password validation rules in the [auth ove Even though Wasp offers premade [Auth UI](../ui.md) for your authentication flows, there are times when you might want more customization, so we also give you the option to create your own UI and call Wasp's auth actions from your own code, similar to how Auth UI does it under the hood. -:::note Handling the email input -The examples below treat the email input the way Auth UI does. They use `type="text"` with `inputMode="email"` instead of `type="email"`, because browsers validate `type="email"` against an ASCII-only grammar that rejects the internationalized addresses Wasp accepts. They also trim the address before submitting it, which `type="email"` used to take care of on its own. - -Wasp validates the address on the server either way, so an invalid one comes back as an error you can show to the user. +:::note Handling the internationalized email input +We use `type="text"` with `inputMode=email` because it allows unicode/international characters, while `type=email` only allows ASCII / english letters. ::: ## Example code From 0688f92fd24dc2e11eca954b6339b0e3011bccb9 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Wed, 26 Aug 2026 10:35:30 +0200 Subject: [PATCH 14/21] update --- web/markdown-snapshots/llms-full.txt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/web/markdown-snapshots/llms-full.txt b/web/markdown-snapshots/llms-full.txt index 471022d2bd..dba5e9253c 100644 --- a/web/markdown-snapshots/llms-full.txt +++ b/web/markdown-snapshots/llms-full.txt @@ -5408,10 +5408,8 @@ Read more about the default email and password validation rules in the [auth ove Even though Wasp offers premade [Auth UI](https://wasp.sh/docs/auth/ui) for your authentication flows, there are times when you might want more customization, so we also give you the option to create your own UI and call Wasp's auth actions from your own code, similar to how Auth UI does it under the hood. -:::note[Handling the email input] -The examples below treat the email input the way Auth UI does. They use `type="text"` with `inputMode="email"` instead of `type="email"`, because browsers validate `type="email"` against an ASCII-only grammar that rejects the internationalized addresses Wasp accepts. They also trim the address before submitting it, which `type="email"` used to take care of on its own. - -Wasp validates the address on the server either way, so a malformed one comes back as an error you can show to the user. +:::note[Handling the internationalized email input] +We use `type="text"` with `inputMode=email` because it allows unicode/international characters, while `type=email` only allows ASCII / english letters. ::: ### Example code From 8fc0d8eb701845d0c6d547392dde4e2d1ea33b5b Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Wed, 26 Aug 2026 10:46:40 +0200 Subject: [PATCH 15/21] update --- waspc/data/Generator/libs/auth/src/email.ts | 2 +- .../wasp-app/.wasp/out/.waspchecksums | 8 +++--- .../wasp/auth/forms/internal/emailField.ts | 4 ++- .../.wasp/out/sdk/wasp/auth/validation.ts | 2 -- .../server/src/auth/providers/email/signup.ts | 26 +++++++++---------- .../wasp-app/e2e-tests/tests/auth.spec.ts | 6 +---- .../wasp-app/e2e-tests/tests/helpers.ts | 3 +-- .../wasp-app/.wasp/out/.waspchecksums | 2 +- .../wasp-app/.wasp/out/.waspchecksums | 2 +- .../wasp-app/.wasp/out/.waspchecksums | 2 +- 10 files changed, 26 insertions(+), 31 deletions(-) diff --git a/waspc/data/Generator/libs/auth/src/email.ts b/waspc/data/Generator/libs/auth/src/email.ts index f576bd5a58..1f7c871f54 100644 --- a/waspc/data/Generator/libs/auth/src/email.ts +++ b/waspc/data/Generator/libs/auth/src/email.ts @@ -19,7 +19,7 @@ const HTML5_EMAIL_WITH_UNICODE_REGEX = /^(?!\p{M})[\p{L}\p{M}\p{N}.!#$%&'*+/=?^_`{|}~-]+@[\p{L}\p{N}](?:[\p{L}\p{M}\p{N}-]{0,61}[\p{L}\p{M}\p{N}])?(?:\.[\p{L}\p{N}](?:[\p{L}\p{M}\p{N}-]{0,61}[\p{L}\p{M}\p{N}])?)*$/u; -export function isValidEmail(input: string): boolean { +export function isValidEmail(input: unknown): boolean { if (typeof input !== "string") { return false; } diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums index 3c31be2754..3d8e39b825 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums @@ -32,7 +32,7 @@ "file", "libs/auth/wasp.sh-lib-auth-0.26.0.tgz" ], - "84b1ee14aba5b258e0f1faff2bf31382e7510a2bb80c769a17f59e559b13eb19" + "e1c43eab55b4c373756919826153c05f6300f3049598d3f51d40768330d5cfdf" ], [ [ @@ -221,7 +221,7 @@ "file", "sdk/wasp/auth/forms/internal/emailField.ts" ], - "647510440df0b9d5d994fb68e61452d5311c335129938496b1c0ec0b5615e045" + "f7e6f5601563191fc6d34140f8b273eef6bf26e101ff8ca0621225bf8319d9c8" ], [ [ @@ -375,7 +375,7 @@ "file", "sdk/wasp/auth/validation.ts" ], - "df41047a91314c9e3a1e47dc5a9a44d57135575e370ca91b1e3ff04d69a807ee" + "7c1a41e0d3456da0cd98b52ab127e197895036a063c9c567898637c63083b840" ], [ [ @@ -1607,7 +1607,7 @@ "file", "server/src/auth/providers/email/signup.ts" ], - "81394fa994b351ffcc19c94ce8135a787d8bc18c8e4c1cfe89d0e45e7df540aa" + "398fbf1958fc0d272b80c977996fd598adc6266d08ac322faf4ce93d9ac306d7" ], [ [ diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/emailField.ts b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/emailField.ts index 744df8c166..12f2c0aa8b 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/emailField.ts +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/forms/internal/emailField.ts @@ -2,9 +2,11 @@ import { isValidEmail } from '@wasp.sh/lib-auth' /** * Deliberately avoids setting `type="email"`. - * Browsers validate input against the HTML5 grammar, which is ASCII-only + * Browsers validate email input against the HTML5 grammar, which is ASCII-only * and would reject internationalized addresses that the server accepts. * `inputMode` keeps the email keyboard on mobile. + * + * @see {@link https://github.com/whatwg/html/issues/4562 WHATWG international email addresses issue} */ export const emailInputProps = { type: 'text', diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/validation.ts b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/validation.ts index 671f54afb5..5521f1f440 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/validation.ts +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/sdk/wasp/auth/validation.ts @@ -56,8 +56,6 @@ function validate(args: object, validators: { validates: string, message: string } } -// NOTE(miho): it would be good to replace our custom validations with e.g. Zod - function isMinLength(input: unknown, minLength: number): boolean { if (typeof input !== 'string') { return false diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/server/src/auth/providers/email/signup.ts b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/server/src/auth/providers/email/signup.ts index 0d55b7f63e..e049b73de8 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/server/src/auth/providers/email/signup.ts +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/server/src/auth/providers/email/signup.ts @@ -1,16 +1,5 @@ import { Request, Response } from 'express' import type { UserSignupFields } from 'wasp/auth/providers/types' -import { - createProviderId, - createUser, - deleteUserByAuthId, - doFakeWork, - findAuthIdentity, - getProviderDataWithPassword, - rethrowPossibleAuthError, - sanitizeAndSerializeProviderData, - validateAndGetUserFields, -} from 'wasp/server/auth/utils' import { ensurePasswordIsPresent, ensureValidEmail, @@ -23,6 +12,17 @@ import { isEmailResendAllowed, sendEmailVerificationEmail, } from 'wasp/server/auth/email/utils' +import { + createProviderId, + createUser, + deleteUserByAuthId, + doFakeWork, + findAuthIdentity, + getProviderDataWithPassword, + rethrowPossibleAuthError, + sanitizeAndSerializeProviderData, + validateAndGetUserFields, +} from 'wasp/server/auth/utils' import { EmailFromField } from 'wasp/server/email/core/types' import { onAfterSignupHook, onBeforeSignupHook } from '../../hooks.js' @@ -146,8 +146,8 @@ export function getSignupRoute({ return } - // We send to the address we stored, not to the one the user typed, so that - // the address that proves ownership is the same one password reset later + // We send the verification link to the normalized address that we stored. + // This way the address that proves ownership is the same one password reset later // sends the reset link to. const email = providerId.providerUserId const verificationLink = await createEmailVerificationLink( diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/auth.spec.ts b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/auth.spec.ts index 6ac1374958..1ed0650806 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/auth.spec.ts +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/auth.spec.ts @@ -46,9 +46,7 @@ test.describe("auth", () => { test("can sign up", async ({ page }) => { await performSignup(page, { - // Padded to check that the form trims the address. The rest of this - // flow uses the unpadded address, so verifying and logging in only - // work if the trimmed address is the one that got stored. + // Padded to check that the form trims the address. email: ` ${email} `, password, address: "Some at least 10 letter address", @@ -154,8 +152,6 @@ test.describe("auth", () => { ); }); - // We accept a narrower set of Unicode than RFC 6531 does, so an address - // can be internationalized and still be rejected. test("signing up with a leading combining mark results in an error message", async ({ page, }) => { diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/helpers.ts b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/helpers.ts index ff9e769d52..4812c08b4a 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/helpers.ts +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/e2e-tests/tests/helpers.ts @@ -14,8 +14,7 @@ export function generateRandomEmail(): string { } /** - * An address with non-ASCII characters on both sides of the `@`, the kind - * RFC 6531 allows and the HTML5 `input[type=email]` grammar does not. + * Generates an address with non-ASCII characters on both sides of the `@`. */ export function generateRandomInternationalizedEmail(): string { return `jürgen-${randomUUID()}@münchen.test`; diff --git a/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/.waspchecksums b/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/.waspchecksums index d626fb1db5..93288a101f 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/.waspchecksums +++ b/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/.waspchecksums @@ -25,7 +25,7 @@ "file", "libs/auth/wasp.sh-lib-auth-0.26.0.tgz" ], - "84b1ee14aba5b258e0f1faff2bf31382e7510a2bb80c769a17f59e559b13eb19" + "e1c43eab55b4c373756919826153c05f6300f3049598d3f51d40768330d5cfdf" ], [ [ diff --git a/waspc/e2e-tests/test-outputs/snapshots/wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums b/waspc/e2e-tests/test-outputs/snapshots/wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums index 2eb7d7bcf5..54c3e1311b 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums +++ b/waspc/e2e-tests/test-outputs/snapshots/wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums @@ -25,7 +25,7 @@ "file", "libs/auth/wasp.sh-lib-auth-0.26.0.tgz" ], - "84b1ee14aba5b258e0f1faff2bf31382e7510a2bb80c769a17f59e559b13eb19" + "e1c43eab55b4c373756919826153c05f6300f3049598d3f51d40768330d5cfdf" ], [ [ diff --git a/waspc/e2e-tests/test-outputs/snapshots/wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums b/waspc/e2e-tests/test-outputs/snapshots/wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums index ff5a34d40a..26811c9ad7 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums +++ b/waspc/e2e-tests/test-outputs/snapshots/wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums @@ -25,7 +25,7 @@ "file", "libs/auth/wasp.sh-lib-auth-0.26.0.tgz" ], - "84b1ee14aba5b258e0f1faff2bf31382e7510a2bb80c769a17f59e559b13eb19" + "e1c43eab55b4c373756919826153c05f6300f3049598d3f51d40768330d5cfdf" ], [ [ From 3a6802ad4c11c4298f534159da1b1272c5bca27a Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Wed, 26 Aug 2026 20:37:47 +0200 Subject: [PATCH 16/21] update --- waspc/data/Generator/libs/auth/src/email.ts | 16 ++++++++-------- .../.wasp/out/web-app/build/assets/200.js | 3 +-- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/waspc/data/Generator/libs/auth/src/email.ts b/waspc/data/Generator/libs/auth/src/email.ts index 1f7c871f54..3c2725ec9b 100644 --- a/waspc/data/Generator/libs/auth/src/email.ts +++ b/waspc/data/Generator/libs/auth/src/email.ts @@ -3,16 +3,16 @@ /** * The syntax we accept is the HTML5 `input[type=email]` grammar, - * widened to also accept Unicode characters. + * widened to also accept most unicode characters (HTML5 is ASCII only). * - * We start from the HTML5 grammar because it is a good compromise between - * completeness and complexity. We widen it because it is deliberately - * ASCII-only, which locks out anyone whose address contains unicode. + * We start from the HTML5 grammar because it is a good compromise + * between completeness and complexity. * - * We also do some extra safefty checks by filtering out invisible, - * text-reordering, zero-width and bidirectional override (LTR or RTL) - * unicode characters. Also a combining mark cannot start an email address, - * because it would attach to whatever text that precedes the address. + * We also do some additional safety checks: + * - We disallow invisible, text-reordering, zero-width and bidirectional + * override (LTR or RTL) unicode characters. + * - We disallow a combining mark at the start of an email address, + * because it would attach to whatever text that precedes the address. * * @see {@link https://github.com/whatwg/html/issues/4562 WHATWG international email addresses issue} */ diff --git a/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/web-app/build/assets/200.js b/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/web-app/build/assets/200.js index 157b23ee84..a8309ccf5c 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/web-app/build/assets/200.js +++ b/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/web-app/build/assets/200.js @@ -255,13 +255,12 @@ function getSessionIdFromAuthorizationHeader(header) { //#endregion //#region .wasp/out/sdk/wasp/dist/client/operations/queryClient.js var defaultQueryClientConfig = {}; -var queryClientConfig; var resolveQueryClientInitialized; var queryClientInitialized = new Promise((resolve) => { resolveQueryClientInitialized = resolve; }); function initializeQueryClient() { - const queryClient = new QueryClient(queryClientConfig ?? defaultQueryClientConfig); + const queryClient = new QueryClient(defaultQueryClientConfig); resolveQueryClientInitialized(queryClient); } //#endregion From a577b3576721057c1b2adda365093fc056401536 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Wed, 26 Aug 2026 20:40:07 +0200 Subject: [PATCH 17/21] snaps --- .../kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums | 2 +- .../wasp-build-golden/wasp-app/.wasp/out/.waspchecksums | 2 +- .../wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums | 2 +- .../wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums index 3d8e39b825..44eb4e9426 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums @@ -32,7 +32,7 @@ "file", "libs/auth/wasp.sh-lib-auth-0.26.0.tgz" ], - "e1c43eab55b4c373756919826153c05f6300f3049598d3f51d40768330d5cfdf" + "a26e6ec072b615b1a9d96c8ae5853b59ef12c864a4fa45bb40edb69429e10ae9" ], [ [ diff --git a/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/.waspchecksums b/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/.waspchecksums index 93288a101f..a9b4afe9c2 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/.waspchecksums +++ b/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/.waspchecksums @@ -25,7 +25,7 @@ "file", "libs/auth/wasp.sh-lib-auth-0.26.0.tgz" ], - "e1c43eab55b4c373756919826153c05f6300f3049598d3f51d40768330d5cfdf" + "a26e6ec072b615b1a9d96c8ae5853b59ef12c864a4fa45bb40edb69429e10ae9" ], [ [ diff --git a/waspc/e2e-tests/test-outputs/snapshots/wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums b/waspc/e2e-tests/test-outputs/snapshots/wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums index 54c3e1311b..24c13e3364 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums +++ b/waspc/e2e-tests/test-outputs/snapshots/wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums @@ -25,7 +25,7 @@ "file", "libs/auth/wasp.sh-lib-auth-0.26.0.tgz" ], - "e1c43eab55b4c373756919826153c05f6300f3049598d3f51d40768330d5cfdf" + "a26e6ec072b615b1a9d96c8ae5853b59ef12c864a4fa45bb40edb69429e10ae9" ], [ [ diff --git a/waspc/e2e-tests/test-outputs/snapshots/wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums b/waspc/e2e-tests/test-outputs/snapshots/wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums index 26811c9ad7..7d69906b6f 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums +++ b/waspc/e2e-tests/test-outputs/snapshots/wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums @@ -25,7 +25,7 @@ "file", "libs/auth/wasp.sh-lib-auth-0.26.0.tgz" ], - "e1c43eab55b4c373756919826153c05f6300f3049598d3f51d40768330d5cfdf" + "a26e6ec072b615b1a9d96c8ae5853b59ef12c864a4fa45bb40edb69429e10ae9" ], [ [ From 5f45b727f4c44137256e246f53f7e839c6554aec Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Wed, 26 Aug 2026 21:05:30 +0200 Subject: [PATCH 18/21] update --- waspc/data/Generator/libs/auth/src/email.ts | 28 +++++++---- .../Generator/libs/auth/tests/email.test.ts | 48 +++++++++++++++---- 2 files changed, 59 insertions(+), 17 deletions(-) diff --git a/waspc/data/Generator/libs/auth/src/email.ts b/waspc/data/Generator/libs/auth/src/email.ts index 3c2725ec9b..e28d58e3a0 100644 --- a/waspc/data/Generator/libs/auth/src/email.ts +++ b/waspc/data/Generator/libs/auth/src/email.ts @@ -8,24 +8,33 @@ * We start from the HTML5 grammar because it is a good compromise * between completeness and complexity. * - * We also do some additional safety checks: - * - We disallow invisible, text-reordering, zero-width and bidirectional - * override (LTR or RTL) unicode characters. - * - We disallow a combining mark at the start of an email address, - * because it would attach to whatever text that precedes the address. - * * @see {@link https://github.com/whatwg/html/issues/4562 WHATWG international email addresses issue} */ const HTML5_EMAIL_WITH_UNICODE_REGEX = - /^(?!\p{M})[\p{L}\p{M}\p{N}.!#$%&'*+/=?^_`{|}~-]+@[\p{L}\p{N}](?:[\p{L}\p{M}\p{N}-]{0,61}[\p{L}\p{M}\p{N}])?(?:\.[\p{L}\p{N}](?:[\p{L}\p{M}\p{N}-]{0,61}[\p{L}\p{M}\p{N}])?)*$/u; + /^[\p{L}\p{M}\p{Nd}.!#$%&'*+/=?^_`{|}~-]+@[\p{L}\p{Nd}](?:[\p{L}\p{M}\p{Nd}-]{0,61}[\p{L}\p{M}\p{Nd}])?(?:\.[\p{L}\p{Nd}](?:[\p{L}\p{M}\p{Nd}-]{0,61}[\p{L}\p{M}\p{Nd}])?)*$/u; + +/** + * Characters that render as nothing, so that two addresses spelled + * differently look identical on screen. + */ +const INVISIBLE_CHARACTER_REGEX = /\p{Default_Ignorable_Code_Point}/u; + +/** + * A combining mark at the start has no character of its own to attach to, + * so it lands on whatever text precedes the address when it is rendered. + */ +const LEADING_COMBINING_MARK_REGEX = /^\p{M}/u; -export function isValidEmail(input: unknown): boolean { +export function isValidEmail(input: unknown): input is string { if (typeof input !== "string") { return false; } return ( - HTML5_EMAIL_WITH_UNICODE_REGEX.test(input) && isEmailOfValidLength(input) + HTML5_EMAIL_WITH_UNICODE_REGEX.test(input) && + !INVISIBLE_CHARACTER_REGEX.test(input) && + !LEADING_COMBINING_MARK_REGEX.test(input) && + isEmailOfValidLength(input) ); } @@ -40,6 +49,7 @@ function isEmailOfValidLength(email: string) { ); } +// Not punycoded for simplicity. function countOctets(text: string): number { return new TextEncoder().encode(text).length; } diff --git a/waspc/data/Generator/libs/auth/tests/email.test.ts b/waspc/data/Generator/libs/auth/tests/email.test.ts index 6c8e4947d1..fa94e4e5ba 100644 --- a/waspc/data/Generator/libs/auth/tests/email.test.ts +++ b/waspc/data/Generator/libs/auth/tests/email.test.ts @@ -48,30 +48,62 @@ describe("isValidEmail", () => { "квіточка@пошта.укр", // Decomposed "ö", a combining mark following its base character. "o\u0308ffentlich@example.com", + // Arabic-Indic digits. + "١٢@example.com", ])("accepts %j", (email) => { expect(isValidEmail(email)).toBe(true); }); - // We accept Unicode letters, marks and digits, which is narrower than the - // "any non-ASCII character" RFC 6531 allows. See the note in `email.ts`. + // RFC 6531 allows any non-ASCII character in the local part. + // We do not go that far. it.each([ // Emoji. "😀@example.com", - // Zero width joiner. - "us‍er@example.com", - // Right-to-left override. - "us‮er@example.com", - ])("rejects the non-letter, non-digit character in %j", (email) => { + // Numerals that are not decimal digits. + "user@examp⑪le.com", + "user@Ⅷ.com", + ])("rejects the unsupported character in %j", (email) => { expect(isValidEmail(email)).toBe(false); }); - it("rejects a local part starting with a combining mark", () => { + it("rejects an address starting with a combining mark", () => { // The mark has no character of its own to attach to, so it lands on // whatever text precedes the address when it is rendered. expect(isValidEmail("\u0301user@example.com")).toBe(false); }); }); + // These render as nothing, so they let two addresses that are spelled + // differently look identical. + describe("invisible characters", () => { + it.each([ + // Zero width joiner. + "us\u200Der@example.com", + // Zero width non-joiner. + "us\u200Cer@example.com", + // Right-to-left override. + "us\u202Eer@example.com", + // Soft hyphen. + "us\u00ADer@example.com", + // Variation selector 1. + "us\uFE00er@example.com", + // Variation selector 16. + "us\uFE0Fer@example.com", + // Variation selector supplement. + "us\u{E0100}er@example.com", + // Mongolian free variation selector. + "us\u180Ber@example.com", + // Hangul filler, the classic invisible username character. + "\u3164@example.com", + // Halfwidth Hangul filler. + "\uFFA0@example.com", + // Hangul choseong filler, in the domain this time. + "user@examp\u115Fle.com", + ])("rejects %j", (email) => { + expect(isValidEmail(email)).toBe(false); + }); + }); + describe("case", () => { // The email signup endpoint validates the raw request body and only // lowercases the address afterwards, so the validator has to accept From 2f36a2f8c1f298fc22f73fe2346f34ac52cc55bd Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Wed, 26 Aug 2026 21:07:28 +0200 Subject: [PATCH 19/21] e2e --- .../kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums | 2 +- .../wasp-build-golden/wasp-app/.wasp/out/.waspchecksums | 2 +- .../wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums | 2 +- .../wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums index 44eb4e9426..a12c3e4c96 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums @@ -32,7 +32,7 @@ "file", "libs/auth/wasp.sh-lib-auth-0.26.0.tgz" ], - "a26e6ec072b615b1a9d96c8ae5853b59ef12c864a4fa45bb40edb69429e10ae9" + "2099670752ad430dd888512e2da1b340fe1f18cde9d8489f7c9cfe8e21cdbdde" ], [ [ diff --git a/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/.waspchecksums b/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/.waspchecksums index a9b4afe9c2..8b4814f985 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/.waspchecksums +++ b/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/.waspchecksums @@ -25,7 +25,7 @@ "file", "libs/auth/wasp.sh-lib-auth-0.26.0.tgz" ], - "a26e6ec072b615b1a9d96c8ae5853b59ef12c864a4fa45bb40edb69429e10ae9" + "2099670752ad430dd888512e2da1b340fe1f18cde9d8489f7c9cfe8e21cdbdde" ], [ [ diff --git a/waspc/e2e-tests/test-outputs/snapshots/wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums b/waspc/e2e-tests/test-outputs/snapshots/wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums index 24c13e3364..f558761496 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums +++ b/waspc/e2e-tests/test-outputs/snapshots/wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums @@ -25,7 +25,7 @@ "file", "libs/auth/wasp.sh-lib-auth-0.26.0.tgz" ], - "a26e6ec072b615b1a9d96c8ae5853b59ef12c864a4fa45bb40edb69429e10ae9" + "2099670752ad430dd888512e2da1b340fe1f18cde9d8489f7c9cfe8e21cdbdde" ], [ [ diff --git a/waspc/e2e-tests/test-outputs/snapshots/wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums b/waspc/e2e-tests/test-outputs/snapshots/wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums index 7d69906b6f..9c224b2d27 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums +++ b/waspc/e2e-tests/test-outputs/snapshots/wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums @@ -25,7 +25,7 @@ "file", "libs/auth/wasp.sh-lib-auth-0.26.0.tgz" ], - "a26e6ec072b615b1a9d96c8ae5853b59ef12c864a4fa45bb40edb69429e10ae9" + "2099670752ad430dd888512e2da1b340fe1f18cde9d8489f7c9cfe8e21cdbdde" ], [ [ From 116c1c37287c5878ffffc3400dc0209d7e2426cf Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Wed, 26 Aug 2026 21:34:19 +0200 Subject: [PATCH 20/21] e2e --- waspc/data/Generator/libs/auth/src/email.ts | 4 ++++ .../kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums | 2 +- .../wasp-build-golden/wasp-app/.wasp/out/.waspchecksums | 2 +- .../wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums | 2 +- .../wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums | 2 +- 5 files changed, 8 insertions(+), 4 deletions(-) diff --git a/waspc/data/Generator/libs/auth/src/email.ts b/waspc/data/Generator/libs/auth/src/email.ts index e28d58e3a0..9b428f4762 100644 --- a/waspc/data/Generator/libs/auth/src/email.ts +++ b/waspc/data/Generator/libs/auth/src/email.ts @@ -5,6 +5,10 @@ * The syntax we accept is the HTML5 `input[type=email]` grammar, * widened to also accept most unicode characters (HTML5 is ASCII only). * + * We widen it to unicode letters, marks and decimal digits, leaving out + * the possibly dangerous format characters. What still gets through is + * handled separately later. + * * We start from the HTML5 grammar because it is a good compromise * between completeness and complexity. * diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums index a12c3e4c96..cc8ac1d74d 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums @@ -32,7 +32,7 @@ "file", "libs/auth/wasp.sh-lib-auth-0.26.0.tgz" ], - "2099670752ad430dd888512e2da1b340fe1f18cde9d8489f7c9cfe8e21cdbdde" + "c804f643ca6618ec7567a41e1c4d84eb30b7828a48a8af181e3672a62b01f793" ], [ [ diff --git a/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/.waspchecksums b/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/.waspchecksums index 8b4814f985..371b7dea10 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/.waspchecksums +++ b/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/.waspchecksums @@ -25,7 +25,7 @@ "file", "libs/auth/wasp.sh-lib-auth-0.26.0.tgz" ], - "2099670752ad430dd888512e2da1b340fe1f18cde9d8489f7c9cfe8e21cdbdde" + "c804f643ca6618ec7567a41e1c4d84eb30b7828a48a8af181e3672a62b01f793" ], [ [ diff --git a/waspc/e2e-tests/test-outputs/snapshots/wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums b/waspc/e2e-tests/test-outputs/snapshots/wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums index f558761496..8359e6cf61 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums +++ b/waspc/e2e-tests/test-outputs/snapshots/wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums @@ -25,7 +25,7 @@ "file", "libs/auth/wasp.sh-lib-auth-0.26.0.tgz" ], - "2099670752ad430dd888512e2da1b340fe1f18cde9d8489f7c9cfe8e21cdbdde" + "c804f643ca6618ec7567a41e1c4d84eb30b7828a48a8af181e3672a62b01f793" ], [ [ diff --git a/waspc/e2e-tests/test-outputs/snapshots/wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums b/waspc/e2e-tests/test-outputs/snapshots/wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums index 9c224b2d27..b4f44cf01f 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums +++ b/waspc/e2e-tests/test-outputs/snapshots/wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums @@ -25,7 +25,7 @@ "file", "libs/auth/wasp.sh-lib-auth-0.26.0.tgz" ], - "2099670752ad430dd888512e2da1b340fe1f18cde9d8489f7c9cfe8e21cdbdde" + "c804f643ca6618ec7567a41e1c4d84eb30b7828a48a8af181e3672a62b01f793" ], [ [ From a57d428f7c9d7ee19ea632199732a39473fe8c27 Mon Sep 17 00:00:00 2001 From: Franjo Mindek Date: Wed, 26 Aug 2026 22:26:44 +0200 Subject: [PATCH 21/21] fix --- waspc/data/Generator/libs/auth/src/email.ts | 2 +- .../kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums | 2 +- .../wasp-build-golden/wasp-app/.wasp/out/.waspchecksums | 2 +- .../wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums | 2 +- .../wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/waspc/data/Generator/libs/auth/src/email.ts b/waspc/data/Generator/libs/auth/src/email.ts index 9b428f4762..2cb2d3d2ae 100644 --- a/waspc/data/Generator/libs/auth/src/email.ts +++ b/waspc/data/Generator/libs/auth/src/email.ts @@ -29,7 +29,7 @@ const INVISIBLE_CHARACTER_REGEX = /\p{Default_Ignorable_Code_Point}/u; */ const LEADING_COMBINING_MARK_REGEX = /^\p{M}/u; -export function isValidEmail(input: unknown): input is string { +export function isValidEmail(input: unknown): boolean { if (typeof input !== "string") { return false; } diff --git a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums index cc8ac1d74d..a035fb68cf 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums +++ b/waspc/e2e-tests/test-outputs/snapshots/kitchen-sink-golden/wasp-app/.wasp/out/.waspchecksums @@ -32,7 +32,7 @@ "file", "libs/auth/wasp.sh-lib-auth-0.26.0.tgz" ], - "c804f643ca6618ec7567a41e1c4d84eb30b7828a48a8af181e3672a62b01f793" + "b8b523100ff2dfeb74939afdd5c45883734ed8a7582f821b1c57ae05ff69c4d6" ], [ [ diff --git a/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/.waspchecksums b/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/.waspchecksums index 371b7dea10..4ed412b3d8 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/.waspchecksums +++ b/waspc/e2e-tests/test-outputs/snapshots/wasp-build-golden/wasp-app/.wasp/out/.waspchecksums @@ -25,7 +25,7 @@ "file", "libs/auth/wasp.sh-lib-auth-0.26.0.tgz" ], - "c804f643ca6618ec7567a41e1c4d84eb30b7828a48a8af181e3672a62b01f793" + "b8b523100ff2dfeb74939afdd5c45883734ed8a7582f821b1c57ae05ff69c4d6" ], [ [ diff --git a/waspc/e2e-tests/test-outputs/snapshots/wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums b/waspc/e2e-tests/test-outputs/snapshots/wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums index 8359e6cf61..435eade551 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums +++ b/waspc/e2e-tests/test-outputs/snapshots/wasp-compile-golden/wasp-app/.wasp/out/.waspchecksums @@ -25,7 +25,7 @@ "file", "libs/auth/wasp.sh-lib-auth-0.26.0.tgz" ], - "c804f643ca6618ec7567a41e1c4d84eb30b7828a48a8af181e3672a62b01f793" + "b8b523100ff2dfeb74939afdd5c45883734ed8a7582f821b1c57ae05ff69c4d6" ], [ [ diff --git a/waspc/e2e-tests/test-outputs/snapshots/wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums b/waspc/e2e-tests/test-outputs/snapshots/wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums index b4f44cf01f..f2300488de 100644 --- a/waspc/e2e-tests/test-outputs/snapshots/wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums +++ b/waspc/e2e-tests/test-outputs/snapshots/wasp-migrate-golden/wasp-app/.wasp/out/.waspchecksums @@ -25,7 +25,7 @@ "file", "libs/auth/wasp.sh-lib-auth-0.26.0.tgz" ], - "c804f643ca6618ec7567a41e1c4d84eb30b7828a48a8af181e3672a62b01f793" + "b8b523100ff2dfeb74939afdd5c45883734ed8a7582f821b1c57ae05ff69c4d6" ], [ [