diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 02d7fc80e..60623a4a8 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -19664,7 +19664,7 @@ "description": "User email", "example": "testuser@email.com", "format": "email", - "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$", + "pattern": "^[\\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}])?)*$", "type": "string" }, "password": { @@ -19708,7 +19708,7 @@ "userEmail": { "description": "User email", "format": "email", - "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$", + "pattern": "^[\\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}])?)*$", "type": "string" }, "userId": { @@ -20861,7 +20861,7 @@ "email": { "description": "User email", "format": "email", - "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$", + "pattern": "^[\\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}])?)*$", "type": "string" }, "id": { @@ -23429,7 +23429,7 @@ "description": "User email", "example": "testuser@email.com", "format": "email", - "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$", + "pattern": "^[\\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}])?)*$", "type": "string" }, "name": { @@ -27488,7 +27488,7 @@ "email": { "description": "User email", "format": "email", - "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$", + "pattern": "^[\\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}])?)*$", "type": "string" }, "isAdmin": { @@ -27570,7 +27570,7 @@ "email": { "description": "User email", "format": "email", - "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$", + "pattern": "^[\\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}])?)*$", "type": "string" }, "id": { @@ -27676,7 +27676,7 @@ "email": { "description": "User email", "format": "email", - "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$", + "pattern": "^[\\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}])?)*$", "type": "string" }, "isAdmin": { @@ -27875,7 +27875,7 @@ "email": { "description": "User email", "format": "email", - "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$", + "pattern": "^[\\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}])?)*$", "type": "string" }, "id": { @@ -27930,7 +27930,7 @@ "email": { "description": "User email", "format": "email", - "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$", + "pattern": "^[\\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}])?)*$", "type": "string" }, "name": { diff --git a/server/src/validation.spec.ts b/server/src/validation.spec.ts index 434ac89ce..7305c4d5c 100644 --- a/server/src/validation.spec.ts +++ b/server/src/validation.spec.ts @@ -1,4 +1,4 @@ -import { IsNotSiblingOf } from 'src/validation'; +import { IsNotSiblingOf, toEmail } from 'src/validation'; import { describe, expect, it } from 'vitest'; import z from 'zod'; @@ -42,4 +42,32 @@ describe('Validation', () => { expect(result.success).toBe(true); }); }); + + describe('toEmail', () => { + it.each([ + 'test@immich.cloud', + 'test@immich', + 'first.last+tag@immich.cloud', + // unicode local parts and internationalized domain names + 'tëst@immich.cloud', + 'leoñ@immich.cloud', + 'test@яндекс.рф', + 'test@xn--d1acpjx3f.xn--p1ai', + '用户@例子.广告', + 'test@ท่องเที่ยว.ไทย', + ])('should accept %s', (email) => { + expect(toEmail.safeParse(email).success).toBe(true); + }); + + it.each(['immich', 'test@@immich.cloud', 'test user@immich.cloud', 'test@immich..cloud', 'test@-immich.cloud'])( + 'should reject %s', + (email) => { + expect(toEmail.safeParse(email).success).toBe(false); + }, + ); + + it('should convert the email to lower case', () => { + expect(toEmail.parse('tÉst@Immich.Cloud')).toBe('tést@immich.cloud'); + }); + }); }); diff --git a/server/src/validation.ts b/server/src/validation.ts index f8de2a68f..467a87b67 100644 --- a/server/src/validation.ts +++ b/server/src/validation.ts @@ -119,14 +119,22 @@ const FilenameParamSchema = z.object({ export class FilenameParamDto extends createZodDto(FilenameParamSchema) {} +/** + * The HTML5 email regex, but with unicode support, so that international email addresses + * (unicode local parts and internationalized domain names) are accepted. + * @see {@link z.regexes.html5Email} + */ +const unicodeEmail = + /^[\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; + /** * Unified email validation - * Converts email strings to lowercase and validates against HTML5 email regex + * Converts email strings to lowercase and validates against a unicode aware email regex * @docs https://zod.dev/api?id=email */ export const toEmail = z .email({ - pattern: z.regexes.html5Email, + pattern: unicodeEmail, error: (iss) => `Invalid input: expected email, received ${typeof iss.input}`, }) .transform((val) => val.toLowerCase());