From 7d93df04dda3ac9de12ee017d89fee2d0de455d1 Mon Sep 17 00:00:00 2001 From: Nam Nguyen <114103526+Euclid0192@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:28:22 -0400 Subject: [PATCH] fix: add utf8 email validation support (#6239) Co-authored-by: Thomas --- .../create/_components/create-user-stepper.tsx | 3 ++- .../authorization/ldap-authorization.ts | 6 +++--- packages/old-import/src/user-schema.ts | 4 +++- packages/validation/src/email.ts | 16 ++++++++++++++++ packages/validation/src/form/i18n.spec.ts | 16 ++++++++++++++++ packages/validation/src/user.ts | 11 +++-------- 6 files changed, 43 insertions(+), 13 deletions(-) create mode 100644 packages/validation/src/email.ts diff --git a/apps/nextjs/src/app/[locale]/manage/users/create/_components/create-user-stepper.tsx b/apps/nextjs/src/app/[locale]/manage/users/create/_components/create-user-stepper.tsx index 3997bda60..6406259f5 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/create/_components/create-user-stepper.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/create/_components/create-user-stepper.tsx @@ -17,6 +17,7 @@ import { showErrorNotification } from "@homarr/notifications"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; import { UserAvatar } from "@homarr/ui"; import { createCustomErrorParams } from "@homarr/validation/form/i18n"; +import { optionalEmailSchema } from "@homarr/validation/email"; import { userPasswordSchema } from "@homarr/validation/user"; import { GroupSelectModal } from "~/components/access/group-select-modal"; @@ -60,7 +61,7 @@ export const UserCreateStepperComponent = ({ initialGroups }: UserCreateStepperC const generalForm = useZodForm( z.object({ username: z.string().min(1), - email: z.string().email().or(z.string().length(0).optional()), + email: optionalEmailSchema, }), { initialValues: { diff --git a/packages/auth/providers/credentials/authorization/ldap-authorization.ts b/packages/auth/providers/credentials/authorization/ldap-authorization.ts index 592fde682..31fa7593d 100644 --- a/packages/auth/providers/credentials/authorization/ldap-authorization.ts +++ b/packages/auth/providers/credentials/authorization/ldap-authorization.ts @@ -1,5 +1,5 @@ import { CredentialsSignin } from "@auth/core/errors"; -import { z } from "zod/v4"; +import type { z } from "zod/v4"; import { createId } from "@homarr/common"; import { createLogger } from "@homarr/core/infrastructure/logs"; @@ -7,6 +7,7 @@ import type { Database, InferInsertModel } from "@homarr/db"; import { and, eq } from "@homarr/db"; import { users } from "@homarr/db/schema"; import type { ldapSignInSchema } from "@homarr/validation/user"; +import { utf8EmailSchema } from "@homarr/validation/email"; import { env } from "../../../env"; import { LdapClient } from "../ldap-client"; @@ -52,8 +53,7 @@ export const authorizeWithLdapCredentialsAsync = async ( throw new CredentialsSignin(`User not found in LDAP username="${credentials.name}"`); } - // Validate email - const mailResult = await z.string().email().safeParseAsync(ldapUser[env.AUTH_LDAP_USER_MAIL_ATTRIBUTE]); + const mailResult = await utf8EmailSchema.safeParseAsync(ldapUser[env.AUTH_LDAP_USER_MAIL_ATTRIBUTE]); if (!mailResult.success) { logger.error("User found in LDAP but with invalid or non-existing Email", { diff --git a/packages/old-import/src/user-schema.ts b/packages/old-import/src/user-schema.ts index 84e544ea3..1bbe91211 100644 --- a/packages/old-import/src/user-schema.ts +++ b/packages/old-import/src/user-schema.ts @@ -1,5 +1,7 @@ import { z } from "zod/v4"; +import { requiredNullableEmailSchema } from "@homarr/validation/email"; + const regexEncryptedSchema = z.string().regex(/^[a-f0-9]+\.[a-f0-9]+$/g); const encryptedSchema = z.custom<`${string}.${string}`>((value) => regexEncryptedSchema.safeParse(value).success); @@ -7,7 +9,7 @@ const encryptedSchema = z.custom<`${string}.${string}`>((value) => regexEncrypte export const oldmarrImportUserSchema = z.object({ id: z.string(), name: z.string(), - email: z.string().email().nullable(), + email: requiredNullableEmailSchema, emailVerified: z.date().nullable(), image: z.string().nullable(), isAdmin: z.boolean(), diff --git a/packages/validation/src/email.ts b/packages/validation/src/email.ts new file mode 100644 index 000000000..fd076a3a8 --- /dev/null +++ b/packages/validation/src/email.ts @@ -0,0 +1,16 @@ +import { z } from "zod/v4"; + +export const utf8EmailSchema = z.email({ pattern: z.regexes.unicodeEmail }); + +export const optionalEmailSchema = utf8EmailSchema.or(z.string().length(0)).optional(); + +export const nullableEmailSchema = utf8EmailSchema + .or(z.literal("")) + .transform((value) => (value === "" ? null : value)) + .optional() + .nullable(); + +export const requiredNullableEmailSchema = utf8EmailSchema + .or(z.literal("")) + .transform((value) => (value === "" ? null : value)) + .nullable(); diff --git a/packages/validation/src/form/i18n.spec.ts b/packages/validation/src/form/i18n.spec.ts index 3ff2cba94..9232ecf91 100644 --- a/packages/validation/src/form/i18n.spec.ts +++ b/packages/validation/src/form/i18n.spec.ts @@ -4,6 +4,7 @@ import { z } from "zod/v4"; import type { TranslationFunction } from "@homarr/translation"; import { createCustomErrorParams, zodErrorMap } from "./i18n"; +import { utf8EmailSchema } from "../email"; const expectError = (error: z.core.$ZodIssue, key: string) => { expect(error.message).toContain(key); @@ -111,4 +112,19 @@ describe("i18n", () => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion expectError(result.error.issues[0]!, "boardAlreadyExists"); }); + + test("should validate UTF8SMTP email with umlauts", () => { + const result = utf8EmailSchema.safeParse("jörg@example.com"); + expect(result.success).toBe(true); + }); + + test("should validate UTF8SMTP email with unicode domain", () => { + const result = utf8EmailSchema.safeParse("user@münchen.de"); + expect(result.success).toBe(true); + }); + + test("should validate standard ASCII emails", () => { + const result = utf8EmailSchema.safeParse("user@example.com"); + expect(result.success).toBe(true); + }); }); diff --git a/packages/validation/src/user.ts b/packages/validation/src/user.ts index 824567fa6..b63e18b68 100644 --- a/packages/validation/src/user.ts +++ b/packages/validation/src/user.ts @@ -6,6 +6,7 @@ import type { TranslationObject } from "@homarr/translation"; import { zodEnumFromArray } from "./enums"; import { createCustomErrorParams } from "./form/i18n"; +import { nullableEmailSchema, optionalEmailSchema } from "./email"; // We always want the lowercase version of the username to compare it in a case-insensitive way export const usernameSchema = z.string().trim().toLowerCase().min(3).max(255); @@ -42,7 +43,7 @@ export const userBaseCreateSchema = z.object({ username: usernameSchema, password: userPasswordSchema, confirmPassword: z.string(), - email: z.string().email().or(z.string().length(0)).optional(), + email: optionalEmailSchema, }); export const userCreateSchema = addConfirmPasswordRefinement(userBaseCreateSchema).and( @@ -85,13 +86,7 @@ export const userRegistrationApiSchema = userRegistrationSchema.and( export const userEditProfileSchema = z.object({ id: z.string(), name: usernameSchema, - email: z - .string() - .email() - .or(z.literal("")) - .transform((value) => (value === "" ? null : value)) - .optional() - .nullable(), + email: nullableEmailSchema, }); const baseChangePasswordSchema = z.object({