diff --git a/apps/web/modules/settings/organizations/privacy/components/create-blocklist-entry-modal.tsx b/apps/web/modules/settings/organizations/privacy/components/create-blocklist-entry-modal.tsx index f1e3323ad9..b2b5d036c2 100644 --- a/apps/web/modules/settings/organizations/privacy/components/create-blocklist-entry-modal.tsx +++ b/apps/web/modules/settings/organizations/privacy/components/create-blocklist-entry-modal.tsx @@ -2,7 +2,7 @@ import { Controller, useForm } from "react-hook-form"; -import { domainWithAtRegex, emailRegex } from "@calcom/lib/emailSchema"; +import { domainRegex, emailRegex } from "@calcom/lib/emailSchema"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { WatchlistType } from "@calcom/prisma/enums"; import { trpc } from "@calcom/trpc/react"; @@ -71,7 +71,7 @@ export function CreateBlocklistEntryModal({ isOpen, onClose }: CreateBlocklistEn return t("invalid_email_address"); } } else if (watchType === WatchlistType.DOMAIN) { - if (!domainWithAtRegex.test(value)) { + if (!domainRegex.test(value)) { return t("invalid_domain_format"); } } @@ -130,7 +130,7 @@ export function CreateBlocklistEntryModal({ isOpen, onClose }: CreateBlocklistEn render={({ field }) => ( )} /> diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index eaf846a4f7..c68527b661 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -3834,7 +3834,7 @@ "no_description_provided": "No description provided", "organization_blocklist": "Organization Blocklist", "manage_blocked_emails_and_domains": "Manage blocked emails and domains for your organization", - "invalid_domain_format": "Invalid domain format. Example: @example.com", + "invalid_domain_format": "Invalid domain format. Example: example.com", "invalid_email_address": "Invalid email address. Example: user@example.com", "reason_for_adding_to_blocklist": "Reason for adding to blocklist", "what_would_you_like_to_block": "What would you like to block?", diff --git a/packages/lib/emailSchema.ts b/packages/lib/emailSchema.ts index 64d45d97f2..d91d7b9ccc 100644 --- a/packages/lib/emailSchema.ts +++ b/packages/lib/emailSchema.ts @@ -1,16 +1,18 @@ import { z } from "zod"; /** @see https://github.com/colinhacks/zod/issues/3155#issuecomment-2060045794 */ + export const emailRegex = + /* eslint-disable-next-line no-useless-escape */ /^(?!\.)(?!.*\.\.)([A-Z0-9_+-\.']*)[A-Z0-9_+'-]@([A-Z0-9][A-Z0-9\-]*\.)+[A-Z]{2,}$/i; /** - * Domain regex for watchlist entries - requires @ prefix + * Domain regex for watchlist entries * Supports international domains with Unicode characters - * Examples: @example.com, @münchen.de, @example.co.uk + * Examples: example.com, münchen.de, example.co.uk */ -export const domainWithAtRegex = - /^@[a-zA-Z0-9\u00a1-\uffff]([a-zA-Z0-9\u00a1-\uffff-]*[a-zA-Z0-9\u00a1-\uffff])?(\.[a-zA-Z0-9\u00a1-\uffff]([a-zA-Z0-9\u00a1-\uffff-]*[a-zA-Z0-9\u00a1-\uffff])?)*$/; +export const domainRegex = + /^[a-zA-Z0-9\u00a1-\uffff]([a-zA-Z0-9\u00a1-\uffff-]*[a-zA-Z0-9\u00a1-\uffff])?(\.[a-zA-Z0-9\u00a1-\uffff]([a-zA-Z0-9\u00a1-\uffff-]*[a-zA-Z0-9\u00a1-\uffff])?)*$/; /** * RFC 5321 Section 4.5.3.1.3 specifies: diff --git a/packages/trpc/server/routers/viewer/organizations/createWatchlistEntry.handler.test.ts b/packages/trpc/server/routers/viewer/organizations/createWatchlistEntry.handler.test.ts index 0e43e7ff93..90ab355223 100644 --- a/packages/trpc/server/routers/viewer/organizations/createWatchlistEntry.handler.test.ts +++ b/packages/trpc/server/routers/viewer/organizations/createWatchlistEntry.handler.test.ts @@ -1,9 +1,12 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; import { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository"; import { WatchlistType, WatchlistAction, MembershipRole } from "@calcom/prisma/enums"; +import { TRPCError } from "@trpc/server"; + import { createWatchlistEntryHandler } from "./createWatchlistEntry.handler"; vi.mock("@calcom/features/pbac/services/permission-check.service"); @@ -35,7 +38,7 @@ describe("createWatchlistEntryHandler", () => { it("should throw FORBIDDEN when user is not part of an organization", async () => { await expect( createWatchlistEntryHandler({ - ctx: { user: { ...mockUser, organizationId: undefined, profile: null } }, + ctx: { user: { ...(mockUser as any), organizationId: undefined, profile: null } }, input: { type: WatchlistType.EMAIL, value: "spam@example.com", @@ -54,7 +57,7 @@ describe("createWatchlistEntryHandler", () => { await expect( createWatchlistEntryHandler({ - ctx: { user: mockUser }, + ctx: { user: mockUser as any }, input: { type: WatchlistType.EMAIL, value: "spam@example.com", @@ -73,7 +76,7 @@ describe("createWatchlistEntryHandler", () => { mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-1" }); await createWatchlistEntryHandler({ - ctx: { user: mockUser }, + ctx: { user: mockUser as any }, input: { type: WatchlistType.EMAIL, value: "test@example.com", @@ -97,7 +100,7 @@ describe("createWatchlistEntryHandler", () => { it("should throw BAD_REQUEST for invalid email format", async () => { await expect( createWatchlistEntryHandler({ - ctx: { user: mockUser }, + ctx: { user: mockUser as any }, input: { type: WatchlistType.EMAIL, value: "invalid-email", @@ -114,7 +117,7 @@ describe("createWatchlistEntryHandler", () => { it("should throw BAD_REQUEST for email missing @ symbol", async () => { await expect( createWatchlistEntryHandler({ - ctx: { user: mockUser }, + ctx: { user: mockUser as any }, input: { type: WatchlistType.EMAIL, value: "notanemail.com", @@ -126,43 +129,43 @@ describe("createWatchlistEntryHandler", () => { }); }); - it("should throw BAD_REQUEST for invalid domain format without @", async () => { - await expect( - createWatchlistEntryHandler({ - ctx: { user: mockUser }, - input: { - type: WatchlistType.DOMAIN, - value: "example.com", - }, - }) - ).rejects.toMatchObject({ - code: "BAD_REQUEST", - message: "Invalid domain format. Domain must start with @ (e.g., @example.com)", + it("should accept valid domain format without @", async () => { + mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-1" }); + + const result = await createWatchlistEntryHandler({ + ctx: { user: mockUser as any }, + input: { + type: WatchlistType.DOMAIN, + value: "example.com", + }, }); - expect(mockWatchlistRepo.createEntry).not.toHaveBeenCalled(); + expect(result.success).toBe(true); + expect(mockWatchlistRepo.createEntry).toHaveBeenCalled(); }); it("should throw BAD_REQUEST for domain with invalid format", async () => { await expect( createWatchlistEntryHandler({ - ctx: { user: mockUser }, + ctx: { user: mockUser as any }, input: { type: WatchlistType.DOMAIN, - value: "not-a-domain", + value: "invalid..domain", }, }) - ).rejects.toMatchObject({ - code: "BAD_REQUEST", - message: "Invalid domain format. Domain must start with @ (e.g., @example.com)", - }); + ).rejects.toStrictEqual( + new TRPCError({ + code: "BAD_REQUEST", + message: "Invalid domain format (e.g., example.com)", + }) + ); }); it("should accept valid email format", async () => { mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-1" }); const result = await createWatchlistEntryHandler({ - ctx: { user: mockUser }, + ctx: { user: mockUser as any }, input: { type: WatchlistType.EMAIL, value: "valid@example.com", @@ -173,19 +176,23 @@ describe("createWatchlistEntryHandler", () => { expect(mockWatchlistRepo.createEntry).toHaveBeenCalled(); }); - it("should accept valid domain format with @", async () => { + it("should accept valid domain format and normalize it", async () => { mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-1" }); const result = await createWatchlistEntryHandler({ - ctx: { user: mockUser }, + ctx: { user: mockUser as any }, input: { type: WatchlistType.DOMAIN, - value: "@example.com", + value: "example.com", }, }); expect(result.success).toBe(true); - expect(mockWatchlistRepo.createEntry).toHaveBeenCalled(); + expect(mockWatchlistRepo.createEntry).toHaveBeenCalledWith( + expect.objectContaining({ + value: "example.com", + }) + ); }); }); @@ -205,7 +212,7 @@ describe("createWatchlistEntryHandler", () => { mockWatchlistRepo.createEntry.mockResolvedValue(mockEntry); const result = await createWatchlistEntryHandler({ - ctx: { user: mockUser }, + ctx: { user: mockUser as any }, input: { type: WatchlistType.EMAIL, value: "spam@example.com", @@ -224,21 +231,21 @@ describe("createWatchlistEntryHandler", () => { }); }); - it("should create watchlist entry for DOMAIN type", async () => { + it("should create watchlist entry for DOMAIN type and normalize it", async () => { const mockEntry = { id: "watchlist-2", type: WatchlistType.DOMAIN, - value: "@spammer.com", + value: "spammer.com", organizationId: 100, action: WatchlistAction.BLOCK, }; mockWatchlistRepo.createEntry.mockResolvedValue(mockEntry); const result = await createWatchlistEntryHandler({ - ctx: { user: mockUser }, + ctx: { user: mockUser as any }, input: { type: WatchlistType.DOMAIN, - value: "@spammer.com", + value: "spammer.com", }, }); @@ -246,7 +253,7 @@ describe("createWatchlistEntryHandler", () => { expect(result.entry).toEqual(mockEntry); expect(mockWatchlistRepo.createEntry).toHaveBeenCalledWith({ type: WatchlistType.DOMAIN, - value: "@spammer.com", + value: "spammer.com", organizationId: 100, action: WatchlistAction.BLOCK, description: undefined, @@ -258,7 +265,7 @@ describe("createWatchlistEntryHandler", () => { mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-4" }); await createWatchlistEntryHandler({ - ctx: { user: mockUser }, + ctx: { user: mockUser as any }, input: { type: WatchlistType.EMAIL, value: "SPAM@EXAMPLE.COM", @@ -279,7 +286,7 @@ describe("createWatchlistEntryHandler", () => { mockWatchlistRepo.createEntry.mockResolvedValue({ id: "watchlist-5" }); await createWatchlistEntryHandler({ - ctx: { user: mockUser }, + ctx: { user: mockUser as any }, input: { type: WatchlistType.EMAIL, value: "test@example.com", diff --git a/packages/trpc/server/routers/viewer/organizations/createWatchlistEntry.handler.ts b/packages/trpc/server/routers/viewer/organizations/createWatchlistEntry.handler.ts index 6e098eba80..8b9acabc72 100644 --- a/packages/trpc/server/routers/viewer/organizations/createWatchlistEntry.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/createWatchlistEntry.handler.ts @@ -1,5 +1,5 @@ import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; -import { domainWithAtRegex, emailRegex } from "@calcom/lib/emailSchema"; +import { domainRegex, emailRegex } from "@calcom/lib/emailSchema"; import { WatchlistRepository } from "@calcom/lib/server/repository/watchlist.repository"; import { prisma } from "@calcom/prisma"; import { MembershipRole, WatchlistAction } from "@calcom/prisma/enums"; @@ -51,10 +51,10 @@ export const createWatchlistEntryHandler = async ({ ctx, input }: CreateWatchlis }); } - if (input.type === "DOMAIN" && !domainWithAtRegex.test(input.value)) { + if (input.type === "DOMAIN" && !domainRegex.test(input.value)) { throw new TRPCError({ code: "BAD_REQUEST", - message: "Invalid domain format. Domain must start with @ (e.g., @example.com)", + message: "Invalid domain format (e.g., example.com)", }); }