diff --git a/packages/features/watchlist/lib/service/GlobalBlockingService.test.ts b/packages/features/watchlist/lib/service/GlobalBlockingService.test.ts index 92ca4a3d7c..9cc2b4f254 100644 --- a/packages/features/watchlist/lib/service/GlobalBlockingService.test.ts +++ b/packages/features/watchlist/lib/service/GlobalBlockingService.test.ts @@ -14,6 +14,7 @@ const mockGlobalRepo: IGlobalWatchlistRepository = { deleteEntry: vi.fn(), findById: vi.fn(), listBlockedEntries: vi.fn(), + findBlockingEntriesForEmailsAndDomains: vi.fn(), }; describe("GlobalBlockingService", () => { @@ -38,16 +39,17 @@ describe("GlobalBlockingService", () => { lastUpdatedAt: new Date(), }; - vi.mocked(mockGlobalRepo.findBlockedEmail).mockResolvedValue(mockEntry); - vi.mocked(mockGlobalRepo.findBlockedDomain).mockResolvedValue(null); + vi.mocked(mockGlobalRepo.findBlockingEntriesForEmailsAndDomains).mockResolvedValue([mockEntry]); const result = await service.isBlocked("blocked@example.com"); expect(result.isBlocked).toBe(true); expect(result.reason).toBe(WatchlistType.EMAIL); expect(result.watchlistEntry).toEqual(mockEntry); - expect(mockGlobalRepo.findBlockedEmail).toHaveBeenCalledWith("blocked@example.com"); - expect(mockGlobalRepo.findBlockedDomain).toHaveBeenCalledWith("example.com"); + expect(mockGlobalRepo.findBlockingEntriesForEmailsAndDomains).toHaveBeenCalledWith({ + emails: ["blocked@example.com"], + domains: ["example.com"], + }); }); test("should return blocked when domain matches", async () => { @@ -63,21 +65,21 @@ describe("GlobalBlockingService", () => { lastUpdatedAt: new Date(), }; - vi.mocked(mockGlobalRepo.findBlockedEmail).mockResolvedValue(null); - vi.mocked(mockGlobalRepo.findBlockedDomain).mockResolvedValue(mockEntry); + vi.mocked(mockGlobalRepo.findBlockingEntriesForEmailsAndDomains).mockResolvedValue([mockEntry]); const result = await service.isBlocked("user@spam.com"); expect(result.isBlocked).toBe(true); expect(result.reason).toBe(WatchlistType.DOMAIN); expect(result.watchlistEntry).toEqual(mockEntry); - expect(mockGlobalRepo.findBlockedEmail).toHaveBeenCalledWith("user@spam.com"); - expect(mockGlobalRepo.findBlockedDomain).toHaveBeenCalledWith("spam.com"); + expect(mockGlobalRepo.findBlockingEntriesForEmailsAndDomains).toHaveBeenCalledWith({ + emails: ["user@spam.com"], + domains: ["spam.com"], + }); }); test("should return not blocked when no matches", async () => { - vi.mocked(mockGlobalRepo.findBlockedEmail).mockResolvedValue(null); - vi.mocked(mockGlobalRepo.findBlockedDomain).mockResolvedValue(null); + vi.mocked(mockGlobalRepo.findBlockingEntriesForEmailsAndDomains).mockResolvedValue([]); const result = await service.isBlocked("clean@example.com"); @@ -87,27 +89,14 @@ describe("GlobalBlockingService", () => { }); test("should normalize email before checking", async () => { - vi.mocked(mockGlobalRepo.findBlockedEmail).mockResolvedValue(null); - vi.mocked(mockGlobalRepo.findBlockedDomain).mockResolvedValue(null); + vi.mocked(mockGlobalRepo.findBlockingEntriesForEmailsAndDomains).mockResolvedValue([]); await service.isBlocked("USER@EXAMPLE.COM"); - expect(mockGlobalRepo.findBlockedEmail).toHaveBeenCalledWith("user@example.com"); - expect(mockGlobalRepo.findBlockedDomain).toHaveBeenCalledWith("example.com"); - }); - - test("should check both email and domain in parallel", async () => { - const emailPromise = Promise.resolve(null); - const domainPromise = Promise.resolve(null); - - vi.mocked(mockGlobalRepo.findBlockedEmail).mockReturnValue(emailPromise); - vi.mocked(mockGlobalRepo.findBlockedDomain).mockReturnValue(domainPromise); - - await service.isBlocked("test@example.com"); - - // Both should be called before awaiting (parallel execution) - expect(mockGlobalRepo.findBlockedEmail).toHaveBeenCalled(); - expect(mockGlobalRepo.findBlockedDomain).toHaveBeenCalled(); + expect(mockGlobalRepo.findBlockingEntriesForEmailsAndDomains).toHaveBeenCalledWith({ + emails: ["user@example.com"], + domains: ["example.com"], + }); }); test("should return email match over domain match", async () => { @@ -135,15 +124,146 @@ describe("GlobalBlockingService", () => { lastUpdatedAt: new Date(), }; - vi.mocked(mockGlobalRepo.findBlockedEmail).mockResolvedValue(emailEntry); - vi.mocked(mockGlobalRepo.findBlockedDomain).mockResolvedValue(domainEntry); + vi.mocked(mockGlobalRepo.findBlockingEntriesForEmailsAndDomains).mockResolvedValue([ + emailEntry, + domainEntry, + ]); const result = await service.isBlocked("specific@example.com"); expect(result.isBlocked).toBe(true); - expect(result.reason).toBe(WatchlistType.EMAIL); // Email takes precedence + expect(result.reason).toBe(WatchlistType.EMAIL); expect(result.watchlistEntry).toEqual(emailEntry); }); + + test("should NOT block subdomain when exact domain is blocked (no wildcard)", async () => { + const exactDomainEntry = { + id: "789", + type: WatchlistType.DOMAIN, + value: "cal.com", + description: null, + action: WatchlistAction.BLOCK, + isGlobal: true, + organizationId: null, + source: WatchlistSource.MANUAL, + lastUpdatedAt: new Date(), + }; + + vi.mocked(mockGlobalRepo.findBlockingEntriesForEmailsAndDomains).mockResolvedValue([exactDomainEntry]); + + const result = await service.isBlocked("user@app.cal.com"); + + expect(result.isBlocked).toBe(false); + expect(mockGlobalRepo.findBlockingEntriesForEmailsAndDomains).toHaveBeenCalledWith({ + emails: ["user@app.cal.com"], + domains: ["app.cal.com", "*.cal.com"], + }); + }); + + test("should block subdomain when wildcard pattern is used", async () => { + const wildcardEntry = { + id: "789", + type: WatchlistType.DOMAIN, + value: "*.cal.com", + description: null, + action: WatchlistAction.BLOCK, + isGlobal: true, + organizationId: null, + source: WatchlistSource.MANUAL, + lastUpdatedAt: new Date(), + }; + + vi.mocked(mockGlobalRepo.findBlockingEntriesForEmailsAndDomains).mockResolvedValue([wildcardEntry]); + + const result = await service.isBlocked("user@app.cal.com"); + + expect(result.isBlocked).toBe(true); + expect(result.reason).toBe(WatchlistType.DOMAIN); + expect(result.watchlistEntry).toEqual(wildcardEntry); + }); + + test("should block deeply nested subdomain with wildcard pattern", async () => { + const wildcardEntry = { + id: "789", + type: WatchlistType.DOMAIN, + value: "*.app.cal.com", + description: null, + action: WatchlistAction.BLOCK, + isGlobal: true, + organizationId: null, + source: WatchlistSource.MANUAL, + lastUpdatedAt: new Date(), + }; + + vi.mocked(mockGlobalRepo.findBlockingEntriesForEmailsAndDomains).mockResolvedValue([wildcardEntry]); + + const result = await service.isBlocked("user@sub.app.cal.com"); + + expect(result.isBlocked).toBe(true); + expect(result.reason).toBe(WatchlistType.DOMAIN); + expect(result.watchlistEntry).toEqual(wildcardEntry); + expect(mockGlobalRepo.findBlockingEntriesForEmailsAndDomains).toHaveBeenCalledWith({ + emails: ["user@sub.app.cal.com"], + domains: ["sub.app.cal.com", "*.app.cal.com"], + }); + }); + + test("should NOT block exact domain with wildcard pattern", async () => { + const wildcardEntry = { + id: "789", + type: WatchlistType.DOMAIN, + value: "*.cal.com", + description: null, + action: WatchlistAction.BLOCK, + isGlobal: true, + organizationId: null, + source: WatchlistSource.MANUAL, + lastUpdatedAt: new Date(), + }; + + vi.mocked(mockGlobalRepo.findBlockingEntriesForEmailsAndDomains).mockResolvedValue([wildcardEntry]); + + const result = await service.isBlocked("user@cal.com"); + + expect(result.isBlocked).toBe(false); + }); + + test("should prefer exact domain match over wildcard", async () => { + const exactEntry = { + id: "111", + type: WatchlistType.DOMAIN, + value: "app.cal.com", + description: null, + action: WatchlistAction.BLOCK, + isGlobal: true, + organizationId: null, + source: WatchlistSource.MANUAL, + lastUpdatedAt: new Date(), + }; + + const wildcardEntry = { + id: "222", + type: WatchlistType.DOMAIN, + value: "*.cal.com", + description: null, + action: WatchlistAction.BLOCK, + isGlobal: true, + organizationId: null, + source: WatchlistSource.MANUAL, + lastUpdatedAt: new Date(), + }; + + vi.mocked(mockGlobalRepo.findBlockingEntriesForEmailsAndDomains).mockResolvedValue([ + exactEntry, + wildcardEntry, + ]); + + const result = await service.isBlocked("user@app.cal.com"); + + expect(result.isBlocked).toBe(true); + expect(result.reason).toBe(WatchlistType.DOMAIN); + expect(result.watchlistEntry).toEqual(exactEntry); + }); }); describe("isFreeEmailDomain", () => { diff --git a/packages/features/watchlist/lib/service/GlobalBlockingService.ts b/packages/features/watchlist/lib/service/GlobalBlockingService.ts index 0a9924e02c..36a91d73b2 100644 --- a/packages/features/watchlist/lib/service/GlobalBlockingService.ts +++ b/packages/features/watchlist/lib/service/GlobalBlockingService.ts @@ -1,7 +1,13 @@ import type { BulkBlockingResult, BlockingResult, IBlockingService } from "../interface/IBlockingService"; import type { IGlobalWatchlistRepository } from "../interface/IWatchlistRepositories"; import { WatchlistType } from "../types"; -import { extractDomainFromEmail, normalizeDomain, normalizeEmail } from "../utils/normalization"; +import { + domainMatchesWatchlistEntry, + extractDomainFromEmail, + getWildcardPatternsForDomain, + normalizeDomain, + normalizeEmail, +} from "../utils/normalization"; type Deps = { globalRepo: IGlobalWatchlistRepository; @@ -17,26 +23,35 @@ export class GlobalBlockingService implements IBlockingService { async isBlocked(email: string): Promise { const normalizedEmail = normalizeEmail(email); const normalizedDomain = extractDomainFromEmail(normalizedEmail); + const wildcardPatterns = getWildcardPatternsForDomain(normalizedDomain); - const [globalEmailEntry, globalDomainEntry] = await Promise.all([ - this.deps.globalRepo.findBlockedEmail(normalizedEmail), - this.deps.globalRepo.findBlockedDomain(normalizedDomain), - ]); + // Query for exact domain match AND wildcard patterns + const blockingEntries = await this.deps.globalRepo.findBlockingEntriesForEmailsAndDomains({ + emails: [normalizedEmail], + domains: [normalizedDomain, ...wildcardPatterns], + }); - if (globalEmailEntry) { + const emailEntry = blockingEntries.find( + (entry) => entry.type === WatchlistType.EMAIL && entry.value.toLowerCase() === normalizedEmail + ); + + if (emailEntry) { return { isBlocked: true, reason: WatchlistType.EMAIL, - watchlistEntry: globalEmailEntry, + watchlistEntry: emailEntry, }; } - if (globalDomainEntry) { - return { - isBlocked: true, - reason: WatchlistType.DOMAIN, - watchlistEntry: globalDomainEntry, - }; + // Check domain entries - exact match first, then wildcards (most specific to least) + for (const entry of blockingEntries) { + if (entry.type === WatchlistType.DOMAIN && domainMatchesWatchlistEntry(normalizedDomain, entry.value)) { + return { + isBlocked: true, + reason: WatchlistType.DOMAIN, + watchlistEntry: entry, + }; + } } return { isBlocked: false }; @@ -45,6 +60,9 @@ export class GlobalBlockingService implements IBlockingService { /** * Bulk check multiple emails in a single query. * Returns Map for efficient lookup. + * Supports configurable wildcard domain matching: + * - `*.cal.com` blocks all subdomains (app.cal.com, sub.app.cal.com, etc.) + * - `cal.com` only blocks exact matches */ async areBlocked(emails: string[]): Promise { const result: BulkBlockingResult = new Map(); @@ -53,33 +71,47 @@ export class GlobalBlockingService implements IBlockingService { return result; } - // Normalize and extract domains + // Normalize and extract domains with wildcard patterns const normalizedEmails = emails.map((e) => normalizeEmail(e)); - const uniqueDomains = Array.from(new Set(normalizedEmails.map((e) => extractDomainFromEmail(e)))); + const emailToDomain = new Map(); - // Single DB query for all emails and domains + // Collect all unique domains and wildcard patterns for the query + const allDomainsAndPatterns = new Set(); + + for (const email of normalizedEmails) { + const domain = extractDomainFromEmail(email); + emailToDomain.set(email, domain); + allDomainsAndPatterns.add(domain); + + // Add wildcard patterns that could match this domain + const wildcardPatterns = getWildcardPatternsForDomain(domain); + for (const pattern of wildcardPatterns) { + allDomainsAndPatterns.add(pattern); + } + } + + // Single DB query for all emails and domains/patterns const blockingEntries = await this.deps.globalRepo.findBlockingEntriesForEmailsAndDomains({ emails: normalizedEmails, - domains: uniqueDomains, + domains: Array.from(allDomainsAndPatterns), }); - // Build lookup maps for O(1) checks (Maps serve as both lookup and entry storage) + // Build lookup maps for O(1) checks const emailEntries = new Map(); - const domainEntries = new Map(); + const domainEntries: Array<(typeof blockingEntries)[0]> = []; for (const entry of blockingEntries) { if (entry.type === WatchlistType.EMAIL) { emailEntries.set(entry.value.toLowerCase(), entry); } else if (entry.type === WatchlistType.DOMAIN) { - domainEntries.set(entry.value.toLowerCase(), entry); + domainEntries.push(entry); } } // Map results for each email for (const email of normalizedEmails) { - const domain = extractDomainFromEmail(email); + const domain = emailToDomain.get(email) || ""; const emailEntry = emailEntries.get(email); - const domainEntry = domainEntries.get(domain); if (emailEntry) { result.set(email, { @@ -87,13 +119,24 @@ export class GlobalBlockingService implements IBlockingService { reason: WatchlistType.EMAIL, watchlistEntry: emailEntry, }); - } else if (domainEntry) { - result.set(email, { - isBlocked: true, - reason: WatchlistType.DOMAIN, - watchlistEntry: domainEntry, - }); - } else { + continue; + } + + // Check domain entries using the matching function + let blocked = false; + for (const entry of domainEntries) { + if (domainMatchesWatchlistEntry(domain, entry.value)) { + result.set(email, { + isBlocked: true, + reason: WatchlistType.DOMAIN, + watchlistEntry: entry, + }); + blocked = true; + break; + } + } + + if (!blocked) { result.set(email, { isBlocked: false }); } } diff --git a/packages/features/watchlist/lib/service/OrganizationBlockingService.test.ts b/packages/features/watchlist/lib/service/OrganizationBlockingService.test.ts index f8342b9b8a..272685ecfa 100644 --- a/packages/features/watchlist/lib/service/OrganizationBlockingService.test.ts +++ b/packages/features/watchlist/lib/service/OrganizationBlockingService.test.ts @@ -14,6 +14,7 @@ const mockOrgRepo: IOrganizationWatchlistRepository = { findById: vi.fn(), listBlockedEntries: vi.fn(), listAllOrganizationEntries: vi.fn(), + findBlockingEntriesForEmailsAndDomains: vi.fn(), }; describe("OrganizationBlockingService", () => { @@ -40,19 +41,18 @@ describe("OrganizationBlockingService", () => { lastUpdatedAt: new Date(), }; - vi.mocked(mockOrgRepo.findBlockedEmail).mockResolvedValue(mockEntry); - vi.mocked(mockOrgRepo.findBlockedDomain).mockResolvedValue(null); + vi.mocked(mockOrgRepo.findBlockingEntriesForEmailsAndDomains).mockResolvedValue([mockEntry]); const result = await service.isBlocked("blocked@example.com", ORGANIZATION_ID); expect(result.isBlocked).toBe(true); expect(result.reason).toBe(WatchlistType.EMAIL); expect(result.watchlistEntry).toEqual(mockEntry); - expect(mockOrgRepo.findBlockedEmail).toHaveBeenCalledWith({ - email: "blocked@example.com", + expect(mockOrgRepo.findBlockingEntriesForEmailsAndDomains).toHaveBeenCalledWith({ + emails: ["blocked@example.com"], + domains: ["example.com"], organizationId: ORGANIZATION_ID, }); - expect(mockOrgRepo.findBlockedDomain).toHaveBeenCalledWith("example.com", ORGANIZATION_ID); }); test("should return blocked when domain matches for organization", async () => { @@ -68,8 +68,7 @@ describe("OrganizationBlockingService", () => { lastUpdatedAt: new Date(), }; - vi.mocked(mockOrgRepo.findBlockedEmail).mockResolvedValue(null); - vi.mocked(mockOrgRepo.findBlockedDomain).mockResolvedValue(mockEntry); + vi.mocked(mockOrgRepo.findBlockingEntriesForEmailsAndDomains).mockResolvedValue([mockEntry]); const result = await service.isBlocked("user@competitor.com", ORGANIZATION_ID); @@ -79,8 +78,7 @@ describe("OrganizationBlockingService", () => { }); test("should return not blocked when no matches for organization", async () => { - vi.mocked(mockOrgRepo.findBlockedEmail).mockResolvedValue(null); - vi.mocked(mockOrgRepo.findBlockedDomain).mockResolvedValue(null); + vi.mocked(mockOrgRepo.findBlockingEntriesForEmailsAndDomains).mockResolvedValue([]); const result = await service.isBlocked("clean@example.com", ORGANIZATION_ID); @@ -90,30 +88,15 @@ describe("OrganizationBlockingService", () => { }); test("should normalize email before checking", async () => { - vi.mocked(mockOrgRepo.findBlockedEmail).mockResolvedValue(null); - vi.mocked(mockOrgRepo.findBlockedDomain).mockResolvedValue(null); + vi.mocked(mockOrgRepo.findBlockingEntriesForEmailsAndDomains).mockResolvedValue([]); await service.isBlocked("USER@EXAMPLE.COM", ORGANIZATION_ID); - expect(mockOrgRepo.findBlockedEmail).toHaveBeenCalledWith({ - email: "user@example.com", + expect(mockOrgRepo.findBlockingEntriesForEmailsAndDomains).toHaveBeenCalledWith({ + emails: ["user@example.com"], + domains: ["example.com"], organizationId: ORGANIZATION_ID, }); - expect(mockOrgRepo.findBlockedDomain).toHaveBeenCalledWith("example.com", ORGANIZATION_ID); - }); - - test("should check both email and domain in parallel", async () => { - const emailPromise = Promise.resolve(null); - const domainPromise = Promise.resolve(null); - - vi.mocked(mockOrgRepo.findBlockedEmail).mockReturnValue(emailPromise); - vi.mocked(mockOrgRepo.findBlockedDomain).mockReturnValue(domainPromise); - - await service.isBlocked("test@example.com", ORGANIZATION_ID); - - // Both should be called before awaiting (parallel execution) - expect(mockOrgRepo.findBlockedEmail).toHaveBeenCalled(); - expect(mockOrgRepo.findBlockedDomain).toHaveBeenCalled(); }); test("should return email match over domain match", async () => { @@ -141,35 +124,161 @@ describe("OrganizationBlockingService", () => { lastUpdatedAt: new Date(), }; - vi.mocked(mockOrgRepo.findBlockedEmail).mockResolvedValue(emailEntry); - vi.mocked(mockOrgRepo.findBlockedDomain).mockResolvedValue(domainEntry); + vi.mocked(mockOrgRepo.findBlockingEntriesForEmailsAndDomains).mockResolvedValue([ + emailEntry, + domainEntry, + ]); const result = await service.isBlocked("specific@example.com", ORGANIZATION_ID); expect(result.isBlocked).toBe(true); - expect(result.reason).toBe(WatchlistType.EMAIL); // Email takes precedence + expect(result.reason).toBe(WatchlistType.EMAIL); expect(result.watchlistEntry).toEqual(emailEntry); }); test("should only check specified organization", async () => { const ORG_A = 100; - const ORG_B = 200; - vi.mocked(mockOrgRepo.findBlockedEmail).mockResolvedValue(null); - vi.mocked(mockOrgRepo.findBlockedDomain).mockResolvedValue(null); + vi.mocked(mockOrgRepo.findBlockingEntriesForEmailsAndDomains).mockResolvedValue([]); await service.isBlocked("test@example.com", ORG_A); - // Should only query for ORG_A, not ORG_B - expect(mockOrgRepo.findBlockedEmail).toHaveBeenCalledWith({ - email: "test@example.com", + expect(mockOrgRepo.findBlockingEntriesForEmailsAndDomains).toHaveBeenCalledWith({ + emails: ["test@example.com"], + domains: ["example.com"], organizationId: ORG_A, }); - expect(mockOrgRepo.findBlockedDomain).toHaveBeenCalledWith("example.com", ORG_A); - expect(mockOrgRepo.findBlockedEmail).not.toHaveBeenCalledWith({ - email: "test@example.com", - organizationId: ORG_B, + }); + + test("should NOT block subdomain when exact domain is blocked (no wildcard)", async () => { + const exactDomainEntry = { + id: "789", + type: WatchlistType.DOMAIN, + value: "cal.com", + description: null, + action: WatchlistAction.BLOCK, + isGlobal: false, + organizationId: ORGANIZATION_ID, + source: WatchlistSource.MANUAL, + lastUpdatedAt: new Date(), + }; + + vi.mocked(mockOrgRepo.findBlockingEntriesForEmailsAndDomains).mockResolvedValue([exactDomainEntry]); + + const result = await service.isBlocked("user@app.cal.com", ORGANIZATION_ID); + + expect(result.isBlocked).toBe(false); + expect(mockOrgRepo.findBlockingEntriesForEmailsAndDomains).toHaveBeenCalledWith({ + emails: ["user@app.cal.com"], + domains: ["app.cal.com", "*.cal.com"], + organizationId: ORGANIZATION_ID, }); }); + + test("should block subdomain when wildcard pattern is used", async () => { + const wildcardEntry = { + id: "789", + type: WatchlistType.DOMAIN, + value: "*.cal.com", + description: null, + action: WatchlistAction.BLOCK, + isGlobal: false, + organizationId: ORGANIZATION_ID, + source: WatchlistSource.MANUAL, + lastUpdatedAt: new Date(), + }; + + vi.mocked(mockOrgRepo.findBlockingEntriesForEmailsAndDomains).mockResolvedValue([wildcardEntry]); + + const result = await service.isBlocked("user@app.cal.com", ORGANIZATION_ID); + + expect(result.isBlocked).toBe(true); + expect(result.reason).toBe(WatchlistType.DOMAIN); + expect(result.watchlistEntry).toEqual(wildcardEntry); + }); + + test("should block deeply nested subdomain with wildcard pattern", async () => { + const wildcardEntry = { + id: "789", + type: WatchlistType.DOMAIN, + value: "*.app.cal.com", + description: null, + action: WatchlistAction.BLOCK, + isGlobal: false, + organizationId: ORGANIZATION_ID, + source: WatchlistSource.MANUAL, + lastUpdatedAt: new Date(), + }; + + vi.mocked(mockOrgRepo.findBlockingEntriesForEmailsAndDomains).mockResolvedValue([wildcardEntry]); + + const result = await service.isBlocked("user@sub.app.cal.com", ORGANIZATION_ID); + + expect(result.isBlocked).toBe(true); + expect(result.reason).toBe(WatchlistType.DOMAIN); + expect(result.watchlistEntry).toEqual(wildcardEntry); + expect(mockOrgRepo.findBlockingEntriesForEmailsAndDomains).toHaveBeenCalledWith({ + emails: ["user@sub.app.cal.com"], + domains: ["sub.app.cal.com", "*.app.cal.com"], + organizationId: ORGANIZATION_ID, + }); + }); + + test("should NOT block exact domain with wildcard pattern", async () => { + const wildcardEntry = { + id: "789", + type: WatchlistType.DOMAIN, + value: "*.cal.com", + description: null, + action: WatchlistAction.BLOCK, + isGlobal: false, + organizationId: ORGANIZATION_ID, + source: WatchlistSource.MANUAL, + lastUpdatedAt: new Date(), + }; + + vi.mocked(mockOrgRepo.findBlockingEntriesForEmailsAndDomains).mockResolvedValue([wildcardEntry]); + + const result = await service.isBlocked("user@cal.com", ORGANIZATION_ID); + + expect(result.isBlocked).toBe(false); + }); + + test("should prefer exact domain match over wildcard", async () => { + const exactEntry = { + id: "111", + type: WatchlistType.DOMAIN, + value: "app.cal.com", + description: null, + action: WatchlistAction.BLOCK, + isGlobal: false, + organizationId: ORGANIZATION_ID, + source: WatchlistSource.MANUAL, + lastUpdatedAt: new Date(), + }; + + const wildcardEntry = { + id: "222", + type: WatchlistType.DOMAIN, + value: "*.cal.com", + description: null, + action: WatchlistAction.BLOCK, + isGlobal: false, + organizationId: ORGANIZATION_ID, + source: WatchlistSource.MANUAL, + lastUpdatedAt: new Date(), + }; + + vi.mocked(mockOrgRepo.findBlockingEntriesForEmailsAndDomains).mockResolvedValue([ + exactEntry, + wildcardEntry, + ]); + + const result = await service.isBlocked("user@app.cal.com", ORGANIZATION_ID); + + expect(result.isBlocked).toBe(true); + expect(result.reason).toBe(WatchlistType.DOMAIN); + expect(result.watchlistEntry).toEqual(exactEntry); + }); }); }); diff --git a/packages/features/watchlist/lib/service/OrganizationBlockingService.ts b/packages/features/watchlist/lib/service/OrganizationBlockingService.ts index 6f8fa64dcf..e98aaa95d8 100644 --- a/packages/features/watchlist/lib/service/OrganizationBlockingService.ts +++ b/packages/features/watchlist/lib/service/OrganizationBlockingService.ts @@ -1,7 +1,13 @@ import type { BulkBlockingResult, BlockingResult, IBlockingService } from "../interface/IBlockingService"; import type { IOrganizationWatchlistRepository } from "../interface/IWatchlistRepositories"; import { WatchlistType } from "../types"; -import { extractDomainFromEmail, normalizeDomain, normalizeEmail } from "../utils/normalization"; +import { + domainMatchesWatchlistEntry, + extractDomainFromEmail, + getWildcardPatternsForDomain, + normalizeDomain, + normalizeEmail, +} from "../utils/normalization"; type Deps = { orgRepo: IOrganizationWatchlistRepository; @@ -17,11 +23,17 @@ export class OrganizationBlockingService implements IBlockingService { async isBlocked(email: string, organizationId: number): Promise { const normalizedEmail = normalizeEmail(email); const normalizedDomain = extractDomainFromEmail(normalizedEmail); + const wildcardPatterns = getWildcardPatternsForDomain(normalizedDomain); - const emailPromise = this.deps.orgRepo.findBlockedEmail({ email: normalizedEmail, organizationId }); - const domainPromise = this.deps.orgRepo.findBlockedDomain(normalizedDomain, organizationId); + const blockingEntries = await this.deps.orgRepo.findBlockingEntriesForEmailsAndDomains({ + emails: [normalizedEmail], + domains: [normalizedDomain, ...wildcardPatterns], + organizationId, + }); - const [emailEntry, domainEntry] = await Promise.all([emailPromise, domainPromise]); + const emailEntry = blockingEntries.find( + (entry) => entry.type === WatchlistType.EMAIL && entry.value.toLowerCase() === normalizedEmail + ); if (emailEntry) { return { @@ -31,12 +43,14 @@ export class OrganizationBlockingService implements IBlockingService { }; } - if (domainEntry) { - return { - isBlocked: true, - reason: WatchlistType.DOMAIN, - watchlistEntry: domainEntry, - }; + for (const entry of blockingEntries) { + if (entry.type === WatchlistType.DOMAIN && domainMatchesWatchlistEntry(normalizedDomain, entry.value)) { + return { + isBlocked: true, + reason: WatchlistType.DOMAIN, + watchlistEntry: entry, + }; + } } return { isBlocked: false }; @@ -45,6 +59,9 @@ export class OrganizationBlockingService implements IBlockingService { /** * Bulk check multiple emails in a single query for an organization. * Returns Map for efficient lookup. + * Supports configurable wildcard domain matching: + * - `*.cal.com` blocks all subdomains + * - `cal.com` only blocks exact matches */ async areBlocked(emails: string[], organizationId: number): Promise { const result: BulkBlockingResult = new Map(); @@ -53,34 +70,41 @@ export class OrganizationBlockingService implements IBlockingService { return result; } - // Normalize and extract domains const normalizedEmails = emails.map((e) => normalizeEmail(e)); - const uniqueDomains = Array.from(new Set(normalizedEmails.map((e) => extractDomainFromEmail(e)))); + const emailToDomain = new Map(); + const allDomainsAndPatterns = new Set(); + + for (const email of normalizedEmails) { + const domain = extractDomainFromEmail(email); + emailToDomain.set(email, domain); + allDomainsAndPatterns.add(domain); + + const wildcardPatterns = getWildcardPatternsForDomain(domain); + for (const pattern of wildcardPatterns) { + allDomainsAndPatterns.add(pattern); + } + } - // Single DB query for all emails and domains const blockingEntries = await this.deps.orgRepo.findBlockingEntriesForEmailsAndDomains({ emails: normalizedEmails, - domains: uniqueDomains, + domains: Array.from(allDomainsAndPatterns), organizationId, }); - // Build lookup maps for O(1) checks (Maps serve as both lookup and entry storage) const emailEntries = new Map(); - const domainEntries = new Map(); + const domainEntries: Array<(typeof blockingEntries)[0]> = []; for (const entry of blockingEntries) { if (entry.type === WatchlistType.EMAIL) { emailEntries.set(entry.value.toLowerCase(), entry); } else if (entry.type === WatchlistType.DOMAIN) { - domainEntries.set(entry.value.toLowerCase(), entry); + domainEntries.push(entry); } } - // Map results for each email for (const email of normalizedEmails) { - const domain = extractDomainFromEmail(email); + const domain = emailToDomain.get(email) || ""; const emailEntry = emailEntries.get(email); - const domainEntry = domainEntries.get(domain); if (emailEntry) { result.set(email, { @@ -88,13 +112,23 @@ export class OrganizationBlockingService implements IBlockingService { reason: WatchlistType.EMAIL, watchlistEntry: emailEntry, }); - } else if (domainEntry) { - result.set(email, { - isBlocked: true, - reason: WatchlistType.DOMAIN, - watchlistEntry: domainEntry, - }); - } else { + continue; + } + + let blocked = false; + for (const entry of domainEntries) { + if (domainMatchesWatchlistEntry(domain, entry.value)) { + result.set(email, { + isBlocked: true, + reason: WatchlistType.DOMAIN, + watchlistEntry: entry, + }); + blocked = true; + break; + } + } + + if (!blocked) { result.set(email, { isBlocked: false }); } } diff --git a/packages/features/watchlist/lib/utils/normalization.test.ts b/packages/features/watchlist/lib/utils/normalization.test.ts index a94d7173fe..3e5ebf6bcf 100644 --- a/packages/features/watchlist/lib/utils/normalization.test.ts +++ b/packages/features/watchlist/lib/utils/normalization.test.ts @@ -1,6 +1,13 @@ import { describe, expect, test } from "vitest"; -import { normalizeEmail, normalizeDomain, extractDomainFromEmail, normalizeUsername } from "./normalization"; +import { + normalizeEmail, + normalizeDomain, + extractDomainFromEmail, + normalizeUsername, + getWildcardPatternsForDomain, + domainMatchesWatchlistEntry, +} from "./normalization"; describe("normalization", () => { describe("normalizeEmail", () => { @@ -94,4 +101,53 @@ describe("normalization", () => { expect(() => normalizeUsername("")).toThrow(); }); }); + + describe("getWildcardPatternsForDomain", () => { + test("should return wildcard pattern for a subdomain", () => { + expect(getWildcardPatternsForDomain("app.cal.com")).toEqual(["*.cal.com"]); + }); + + test("should return single wildcard pattern for deeply nested subdomains", () => { + expect(getWildcardPatternsForDomain("sub.app.cal.com")).toEqual(["*.app.cal.com"]); + }); + + test("should return empty array for a simple domain (no subdomains)", () => { + expect(getWildcardPatternsForDomain("cal.com")).toEqual([]); + }); + + test("should handle multi-level TLDs correctly", () => { + expect(getWildcardPatternsForDomain("example.co.uk")).toEqual(["*.co.uk"]); + expect(getWildcardPatternsForDomain("bloody-hell.cal.co.uk")).toEqual(["*.cal.co.uk"]); + }); + + test("should return empty array for single-part domain", () => { + expect(getWildcardPatternsForDomain("localhost")).toEqual([]); + }); + }); + + describe("domainMatchesWatchlistEntry", () => { + test("should match exact domain when no wildcard", () => { + expect(domainMatchesWatchlistEntry("cal.com", "cal.com")).toBe(true); + expect(domainMatchesWatchlistEntry("app.cal.com", "cal.com")).toBe(false); + }); + + test("should match subdomain when wildcard is used", () => { + expect(domainMatchesWatchlistEntry("app.cal.com", "*.cal.com")).toBe(true); + expect(domainMatchesWatchlistEntry("sub.app.cal.com", "*.cal.com")).toBe(true); + }); + + test("should not match exact domain with wildcard pattern", () => { + expect(domainMatchesWatchlistEntry("cal.com", "*.cal.com")).toBe(false); + }); + + test("should be case insensitive", () => { + expect(domainMatchesWatchlistEntry("APP.CAL.COM", "*.cal.com")).toBe(true); + expect(domainMatchesWatchlistEntry("app.cal.com", "*.CAL.COM")).toBe(true); + }); + + test("should not match unrelated domains", () => { + expect(domainMatchesWatchlistEntry("app.example.com", "*.cal.com")).toBe(false); + expect(domainMatchesWatchlistEntry("notcal.com", "cal.com")).toBe(false); + }); + }); }); diff --git a/packages/features/watchlist/lib/utils/normalization.ts b/packages/features/watchlist/lib/utils/normalization.ts index 04f2c2377c..75e92551cb 100644 --- a/packages/features/watchlist/lib/utils/normalization.ts +++ b/packages/features/watchlist/lib/utils/normalization.ts @@ -35,8 +35,9 @@ export function normalizeEmail(email: string): string { * 3. Remove @ prefix if present * * Note: Domains are stored without @ prefix (e.g., mail.google.com, example.co.uk) - * No subdomain stripping is performed to avoid multi-level TLD issues. - * If you want to block subdomains separately, create separate entries. + * Wildcard matching is configurable: + * - `*.cal.com` blocks all subdomains (app.cal.com, sub.app.cal.com, etc.) + * - `cal.com` only blocks exact matches * * @param domain - Raw domain (with or without @ prefix) * @returns Normalized domain without @ prefix @@ -91,3 +92,63 @@ export function normalizeUsername(username: string): string { return username.trim().toLowerCase(); } + +/** + * Gets the wildcard pattern that could match a given domain. + * Used to check if a wildcard entry (*.domain.com) would block this domain. + * + * Simply strips the first part before the first `.` and adds `*.` prefix. + * Only returns a pattern if the parent domain has at least 2 parts (to avoid *.com). + * + * Example: + * - Input: "app.cal.com" -> Output: ["*.cal.com"] + * - Input: "bloody-hell.cal.co.uk" -> Output: ["*.cal.co.uk"] + * - Input: "cal.com" -> Output: [] (parent would be just "com") + * - Input: "example.co.uk" -> Output: ["*.co.uk"] + * + * @param domain - Normalized domain (without @ prefix) + * @returns Array with single wildcard pattern, or empty if no valid parent domain + */ +export function getWildcardPatternsForDomain(domain: string): string[] { + const firstDotIndex = domain.indexOf("."); + + if (firstDotIndex === -1 || firstDotIndex === domain.length - 1) { + return []; + } + + const parentDomain = domain.slice(firstDotIndex + 1); + + // Only return pattern if parent domain has at least one dot (e.g., "cal.com" not "com") + if (!parentDomain.includes(".")) { + return []; + } + + return [`*.${parentDomain}`]; +} + +/** + * Checks if a domain matches a watchlist entry value. + * Supports both exact matching and wildcard matching. + * + * - Exact match: "cal.com" only matches "cal.com" + * - Wildcard match: "*.cal.com" matches "app.cal.com", "sub.app.cal.com", etc. + * + * @param emailDomain - The domain extracted from an email (e.g., "app.cal.com") + * @param watchlistValue - The value from the watchlist entry (e.g., "cal.com" or "*.cal.com") + * @returns true if the domain matches the watchlist entry + */ +export function domainMatchesWatchlistEntry(emailDomain: string, watchlistValue: string): boolean { + const normalizedEmailDomain = emailDomain.toLowerCase(); + const normalizedWatchlistValue = watchlistValue.toLowerCase(); + + // Check for wildcard pattern + if (normalizedWatchlistValue.startsWith("*.")) { + const baseDomain = normalizedWatchlistValue.slice(2); // Remove "*." prefix + // Check if emailDomain is a subdomain of baseDomain + // e.g., "app.cal.com" ends with ".cal.com" (subdomain of cal.com) + return normalizedEmailDomain.endsWith(`.${baseDomain}`); + } + + // Exact match + return normalizedEmailDomain === normalizedWatchlistValue; +}