feat: add configurable wildcard domain matching to watchlist (#27409)
* feat: add wildcard domain matching to watchlist When a domain is blocked in the watchlist (e.g., cal.com), all subdomains are now also blocked (e.g., app.cal.com, sub.app.cal.com). Changes: - Add getParentDomains() utility to extract all parent domains - Update GlobalBlockingService.isBlocked() and areBlocked() for wildcard matching - Update OrganizationBlockingService.isBlocked() and areBlocked() for wildcard matching - Add comprehensive tests for wildcard domain matching - More specific subdomain matches take precedence over parent domain matches - Email matches still take precedence over domain matches Co-Authored-By: alex@cal.com <me@alexvanandel.com> * feat: make wildcard domain matching configurable - *.cal.com blocks all subdomains (app.cal.com, sub.app.cal.com, etc.) - cal.com only blocks exact matches (not subdomains) - Updated getWildcardPatternsForDomain to generate wildcard patterns - Added domainMatchesWatchlistEntry for configurable matching logic - Updated GlobalBlockingService and OrganizationBlockingService - Updated tests to verify new behavior Co-Authored-By: alex@cal.com <me@alexvanandel.com> * refactor: simplify wildcard pattern generation to strip only first part - Only strips the first part before the first dot - Handles multi-part TLDs correctly (e.g., .co.uk) - Returns empty array for two-part domains (e.g., cal.com -> no pattern) - app.cal.com -> *.cal.com - bloody-hell.cal.co.uk -> *.cal.co.uk Co-Authored-By: alex@cal.com <me@alexvanandel.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Devin AI
parent
81a656930a
commit
c662a76277
@@ -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", () => {
|
||||
|
||||
@@ -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<BlockingResult> {
|
||||
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<email (lowercase), BlockingResult> 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<BulkBlockingResult> {
|
||||
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<string, string>();
|
||||
|
||||
// Single DB query for all emails and domains
|
||||
// Collect all unique domains and wildcard patterns for the query
|
||||
const allDomainsAndPatterns = new Set<string>();
|
||||
|
||||
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<string, (typeof blockingEntries)[0]>();
|
||||
const domainEntries = new Map<string, (typeof blockingEntries)[0]>();
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<BlockingResult> {
|
||||
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<email (lowercase), BlockingResult> 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<BulkBlockingResult> {
|
||||
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<string, string>();
|
||||
const allDomainsAndPatterns = new Set<string>();
|
||||
|
||||
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<string, (typeof blockingEntries)[0]>();
|
||||
const domainEntries = new Map<string, (typeof blockingEntries)[0]>();
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user