Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
7e2347b
Accept internationalized and uppercase email addresses in auth
FranjoMindek Aug 12, 2026
961c733
Cover uppercase local part and domain separately in email tests
FranjoMindek Aug 12, 2026
e50e9fb
Refresh kitchen-sink golden snapshot for new auth e2e tests
FranjoMindek Aug 12, 2026
434614b
Regenerate wasp-build golden for rolldown 1.2.3 output formatting
FranjoMindek Aug 12, 2026
b142069
Trim email input, send verification to the stored address, narrow the…
FranjoMindek Aug 12, 2026
6b3be5c
Update markdown snapshots for the email docs changes
FranjoMindek Aug 12, 2026
98c86c0
fixes
FranjoMindek Aug 13, 2026
76ad235
Scope the email e2e tests to internationalized addresses
FranjoMindek Aug 13, 2026
7ee7fd1
Update markdown snapshots for the reworded email docs
FranjoMindek Aug 13, 2026
2e478ec
Re-add the trimming advice to the email auth docs
FranjoMindek Aug 13, 2026
838624d
Trim the email in the custom auth UI docs examples
FranjoMindek Aug 13, 2026
69b41f7
Update web/docs/auth/email/create-your-own-ui.md
FranjoMindek Aug 25, 2026
34f969a
cleanup
FranjoMindek Aug 26, 2026
0688f92
update
FranjoMindek Aug 26, 2026
c748460
Merge remote-tracking branch 'origin/main' into fix/email-validation-…
FranjoMindek Aug 26, 2026
8fc0d8e
update
FranjoMindek Aug 26, 2026
3a6802a
update
FranjoMindek Aug 26, 2026
a577b35
snaps
FranjoMindek Aug 26, 2026
5f45b72
update
FranjoMindek Aug 26, 2026
2f36a2f
e2e
FranjoMindek Aug 26, 2026
116c1c3
e2e
FranjoMindek Aug 26, 2026
a57d428
fix
FranjoMindek Aug 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions examples/kitchen-sink/e2e-tests/tests/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { WASP_SERVER_URL } from "../playwright.config";
import { performEmailVerification, performLogin, performSignup } from "./auth";
import {
generateRandomEmail,
generateRandomInternationalizedEmail,
isRunningInDeployedMode,
isRunningInDevMode,
} from "./helpers";
Expand Down Expand Up @@ -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",
);
});
});
});
2 changes: 1 addition & 1 deletion examples/kitchen-sink/e2e-tests/tests/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
8 changes: 8 additions & 0 deletions examples/kitchen-sink/e2e-tests/tests/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
}
28 changes: 10 additions & 18 deletions examples/kitchen-sink/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions waspc/ChangeLog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
46 changes: 46 additions & 0 deletions waspc/data/Generator/libs/auth/src/email.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This whole comment shoujld be on topo of isValidEmail f unction, shoudln't it? Why here?

* 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.
Comment thread
pullfrog[bot] marked this conversation as resolved.
Outdated
*
* 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.
*/
Comment on lines +4 to +16

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Waaaay to wordy IMO
I'd just say we use the same regex browsers use for consistency, with added unicode support, and link to the spec.
tbh i'm not even sure i'd try to properly validate it if we want to accept unicode, just go the zod way and allow anything with a @ and .:

https://github.com/colinhacks/zod/blob/3c9ca1d9bdc3c08939f2aa099158ee552c70ef80/packages/zod/src/v4/core/regexes.ts#L53

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in fact, can we just use the zod validator? there's even a not in the previous implementation about it

@FranjoMindek FranjoMindek Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to do html5 with unicode, and zod does not provide a good option there.

Zod options:

  • unicodeEmail is REALLY permissive. It accepts a lot of gibberish and size limits are just there so email isn't too big (can be invalid size). This is permissive enough (unicode passes) but doesn't really protect in any way.
  • rfc5322Email does accept some RFC stuff we don't (not really anything valuable), but rejects unicode we accept. E.g. cyrillic
  • html5Email has no unicode so misses the point
  • email same as above

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the description.

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;
Comment thread
pullfrog[bot] marked this conversation as resolved.
Outdated

/**
* 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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO this function should have input: string, and then the callsite is the one that should make sure that it's passing a string. it doesn't make sense to call this function with anything other than an email i think

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It actually does work with unknown values in ensureValidEmail.
So I just continued with it. Didn't want to change that code too.

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;
}
4 changes: 1 addition & 3 deletions waspc/data/Generator/libs/auth/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
/**
* NOTE: this is a placeholder file for future code exported for both runtimes.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finally 😄

*/
export { isValidEmail } from "./email";
109 changes: 109 additions & 0 deletions waspc/data/Generator/libs/auth/tests/email.test.ts

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i wish this was a tested external library so that we didn't have to test it ourselves

@FranjoMindek FranjoMindek Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not too happy with it too.

I've rejected zod, but didn't really look further because I didn't want to add additional dependencies to Wasp. This is now bordering simple and complex. But I think it's still fine.

@FranjoMindek FranjoMindek Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Asked Claude to compare our solution to existing packages to make sure:

image

Last one passes most of the only because it does not support unicode at all so it doesn't have to deal with those cases.
Overall 0 safety features in rest of them, just standard HTML5 with unicode.

I myself used https://github.com/JoshData/python-email-validator as inspiration. Took some of safety features from there.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IK have to agree that it sucks that we have to implement this. Good thing is I don't see this changing much so maybe it is fine.

Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
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.
Comment on lines +108 to +110

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It really doesn't sound like this is concern that should be stated here! That is anothe rpart of the system and here we shouldn't be concerend about it.

I would just say we accept uppercase and that is it. The rest of the system has to adapt to that, not this to it.

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", () => {
it.each([
"user@example.com <script>alert(1)</script>",
"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);
});
});
Comment on lines +126 to +136

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A bit weird names: anchoring, and also "rejects %j, which merely contains an address". Ancorhing is impelmenation detail of regexes, not sometihng we should test here.

I would instead name the test "email is substring ... and shouldn't be" or something liek that. I am not fully knowledagable about this describe/it mechanism here in whatevver test library we use so I don't know exactsly how to best name but you get the point.


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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -189,10 +192,8 @@ export const LoginSignupForm = ({
<FormItemGroup>
<FormLabel>E-mail</FormLabel>
<FormInput
{...register('email', {
required: 'Email is required',
})}
type="email"
{...register('email', emailFieldRules)}
{...emailInputProps}
disabled={isLoading}
/>
{errors.email && <FormError>{errors.email.message}</FormError>}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -34,10 +35,8 @@ export const ForgotPasswordForm = () => {
<FormItemGroup>
<FormLabel>E-mail</FormLabel>
<FormInput
{...register('email', {
required: 'Email is required',
})}
type="email"
{...register('email', emailFieldRules)}
{...emailInputProps}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels more bothersome than adding an email prop to FormInput that automatically sets the needed things (or even just detect it when type=email)

@FranjoMindek FranjoMindek Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but there is nothing simpler if we want to support unicode.
HTML5 simply does not support it, so we have to go around it.

disabled={isLoading}
/>
{errors.email && <FormError>{errors.email.message}</FormError>}
Expand Down
Original file line number Diff line number Diff line change
@@ -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',
}
Loading
Loading