From 5ee4a4aefe304708fd1a74f1459685e0209811c7 Mon Sep 17 00:00:00 2001 From: Mouad BANI Date: Mon, 12 Oct 2026 16:13:56 +0000 Subject: [PATCH 1/2] feat: resolve contributor emails to akrites members by verified domain (CM-1826) Signed-off-by: Mouad BANI --- .../resolveEmailDomainAffiliations.test.ts | 153 ++++++++++++++++++ .../resolveEmailDomainAffiliations.ts | 113 +++++++++++++ .../src/member-contributors/types.ts | 8 + 3 files changed, 274 insertions(+) create mode 100644 services/apps/packages_worker/src/member-contributors/__tests__/resolveEmailDomainAffiliations.test.ts create mode 100644 services/apps/packages_worker/src/member-contributors/resolveEmailDomainAffiliations.ts diff --git a/services/apps/packages_worker/src/member-contributors/__tests__/resolveEmailDomainAffiliations.test.ts b/services/apps/packages_worker/src/member-contributors/__tests__/resolveEmailDomainAffiliations.test.ts new file mode 100644 index 0000000000..b33d36cd76 --- /dev/null +++ b/services/apps/packages_worker/src/member-contributors/__tests__/resolveEmailDomainAffiliations.test.ts @@ -0,0 +1,153 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { disposableEmailDomains } from '@crowd/common/src/constants' +import * as organizationIdentities from '@crowd/data-access-layer/src/organizations/identities' +import type { OrganizationVerifiedPrimaryDomains } from '@crowd/data-access-layer/src/organizations/identities' +import { QueryExecutor } from '@crowd/data-access-layer/src/queryExecutor' + +import { AkritesMember } from '../registry' +import { resolveEmailDomainAffiliations } from '../resolveEmailDomainAffiliations' + +const log = vi.hoisted(() => ({ warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() })) + +vi.mock('@crowd/logging', () => ({ + getServiceChildLogger: () => log, +})) +vi.mock('@crowd/data-access-layer/src/organizations/identities', () => ({ + fetchManyOrganizationVerifiedPrimaryDomains: vi.fn(), +})) + +const fetchDomains = vi.mocked(organizationIdentities.fetchManyOrganizationVerifiedPrimaryDomains) + +const qx = {} as QueryExecutor + +const registry: AkritesMember[] = [ + { name: 'Cisco', cdpOrganizationIds: ['cisco-1', 'cisco-2'] }, + { name: 'Google', cdpOrganizationIds: ['google-1'] }, +] + +function orgDomains(orgId: string, domains: string[]): OrganizationVerifiedPrimaryDomains { + return { orgId, displayName: orgId, domains } +} + +beforeEach(() => { + vi.clearAllMocks() + fetchDomains.mockResolvedValue([ + orgDomains('cisco-1', ['cisco.com']), + orgDomains('google-1', ['google.com']), + ]) +}) + +describe('resolveEmailDomainAffiliations', () => { + it('returns empty and issues no query for empty input', async () => { + const out = await resolveEmailDomainAffiliations(qx, [], registry) + expect(out.size).toBe(0) + expect(fetchDomains).not.toHaveBeenCalled() + }) + + it('resolves an email at a member primary domain', async () => { + const out = await resolveEmailDomainAffiliations(qx, ['jane@cisco.com'], registry) + expect(out.get('email:jane@cisco.com')).toEqual({ + akritesMember: 'Cisco', + cdpOrganizationId: 'cisco-1', + matchedDomain: 'cisco.com', + confidence: 'email_domain', + confidenceScore: 0.7, + }) + }) + + it('resolves an email at a subdomain of a member primary domain', async () => { + const out = await resolveEmailDomainAffiliations(qx, ['dev@eng.google.com'], registry) + expect(out.get('email:dev@eng.google.com')).toMatchObject({ + akritesMember: 'Google', + matchedDomain: 'google.com', + }) + }) + + it('never walks past the registrable domain', async () => { + fetchDomains.mockResolvedValue([orgDomains('cisco-1', ['co.uk'])]) + const out = await resolveEmailDomainAffiliations(qx, ['jane@foo.co.uk'], registry) + expect(out.size).toBe(0) + }) + + it('never resolves a public email provider listed under a member', async () => { + fetchDomains.mockResolvedValue([orgDomains('google-1', ['google.com', 'gmail.com'])]) + const out = await resolveEmailDomainAffiliations( + qx, + ['jane@gmail.com', 'jane@mail.gmail.com'], + registry, + ) + expect(out.size).toBe(0) + }) + + it('never resolves a disposable domain listed under a member', async () => { + expect(disposableEmailDomains.has('gmial.com')).toBe(true) + fetchDomains.mockResolvedValue([orgDomains('google-1', ['google.com', 'gmial.com'])]) + const out = await resolveEmailDomainAffiliations(qx, ['jane@gmial.com'], registry) + expect(out.size).toBe(0) + }) + + it('omits an email at an unknown domain', async () => { + const out = await resolveEmailDomainAffiliations(qx, ['jane@acme.io'], registry) + expect(out.size).toBe(0) + }) + + it('omits invalid emails without throwing', async () => { + const out = await resolveEmailDomainAffiliations( + qx, + ['cisco.com', 'jane@', '@cisco.com', 'jane@@cisco', ''], + registry, + ) + expect(out.size).toBe(0) + }) + + it('excludes a domain verified under different members and warns once', async () => { + const threeMembers = [...registry, { name: 'IBM', cdpOrganizationIds: ['ibm-1'] }] + fetchDomains.mockResolvedValue([ + orgDomains('cisco-1', ['cisco.com', 'meetup.com']), + orgDomains('google-1', ['google.com', 'meetup.com']), + orgDomains('ibm-1', ['meetup.com']), + ]) + const out = await resolveEmailDomainAffiliations( + qx, + ['a@meetup.com', 'b@meetup.com', 'c@cisco.com'], + threeMembers, + ) + expect(out.has('email:a@meetup.com')).toBe(false) + expect(out.has('email:b@meetup.com')).toBe(false) + expect(out.get('email:c@cisco.com')?.akritesMember).toBe('Cisco') + expect(log.warn).toHaveBeenCalledTimes(1) + expect(log.warn).toHaveBeenCalledWith( + { conflictingDomains: ['meetup.com'] }, + expect.any(String), + ) + }) + + it('picks the first registry org id when one member has the domain under two org ids', async () => { + const rows = [orgDomains('cisco-2', ['cisco.com']), orgDomains('cisco-1', ['cisco.com'])] + + fetchDomains.mockResolvedValue(rows) + const first = await resolveEmailDomainAffiliations(qx, ['jane@cisco.com'], registry) + fetchDomains.mockResolvedValue([...rows].reverse()) + const second = await resolveEmailDomainAffiliations(qx, ['jane@cisco.com'], registry) + + expect(first.get('email:jane@cisco.com')?.cdpOrganizationId).toBe('cisco-1') + expect(second.get('email:jane@cisco.com')).toEqual(first.get('email:jane@cisco.com')) + expect(log.warn).not.toHaveBeenCalled() + }) + + it('fetches the domain table once with every registry org id', async () => { + await resolveEmailDomainAffiliations(qx, ['a@cisco.com', 'b@google.com', 'c@acme.io'], registry) + expect(fetchDomains).toHaveBeenCalledTimes(1) + expect(fetchDomains).toHaveBeenCalledWith(qx, ['cisco-1', 'cisco-2', 'google-1']) + }) + + it('treats emails case-insensitively and dedupes them', async () => { + const out = await resolveEmailDomainAffiliations( + qx, + ['Jane@Cisco.com ', ' jane@cisco.COM'], + registry, + ) + expect([...out.keys()]).toEqual(['email:jane@cisco.com']) + }) +}) diff --git a/services/apps/packages_worker/src/member-contributors/resolveEmailDomainAffiliations.ts b/services/apps/packages_worker/src/member-contributors/resolveEmailDomainAffiliations.ts new file mode 100644 index 0000000000..2cb2b62c77 --- /dev/null +++ b/services/apps/packages_worker/src/member-contributors/resolveEmailDomainAffiliations.ts @@ -0,0 +1,113 @@ +import { isDomainExcluded, isValidEmail, registrableDomain } from '@crowd/common' +import { disposableEmailDomains } from '@crowd/common/src/constants' +import { + OrganizationVerifiedPrimaryDomains, + fetchManyOrganizationVerifiedPrimaryDomains, +} from '@crowd/data-access-layer/src/organizations/identities' +import { QueryExecutor } from '@crowd/data-access-layer/src/queryExecutor' +import { getServiceChildLogger } from '@crowd/logging' + +import { AKRITES_MEMBERS, AkritesMember } from './registry' +import { ResolvedEmailDomainAffiliation, contributorIdentityKey } from './types' + +const log = getServiceChildLogger('resolveEmailDomainAffiliations') + +interface DomainOwner { + akritesMember: string + cdpOrganizationId: string +} + +function isIneligibleDomain(domain: string): boolean { + return isDomainExcluded(domain) || disposableEmailDomains.has(domain) +} + +function buildDomainOwners( + rows: OrganizationVerifiedPrimaryDomains[], + members: AkritesMember[], +): Map { + const domainsByOrgId = new Map(rows.map((row) => [row.orgId, row.domains])) + const owners = new Map() + const conflictingDomains = new Set() + + for (const member of members) { + for (const cdpOrganizationId of member.cdpOrganizationIds) { + for (const domain of domainsByOrgId.get(cdpOrganizationId) ?? []) { + if (isIneligibleDomain(domain) || conflictingDomains.has(domain)) continue + const owner = owners.get(domain) + if (!owner) { + owners.set(domain, { akritesMember: member.name, cdpOrganizationId }) + } else if (owner.akritesMember !== member.name) { + owners.delete(domain) + conflictingDomains.add(domain) + } + } + } + } + + if (conflictingDomains.size > 0) { + log.warn( + { conflictingDomains: [...conflictingDomains] }, + 'Excluding email domains verified under more than one Akrites member', + ) + } + return owners +} + +function normalizeEmails(emails: string[]): string[] { + const normalized = new Set(emails.map((email) => email.trim().toLowerCase())) + return [...normalized].filter((email) => isValidEmail(email)) +} + +function candidateDomains(domain: string): string[] { + const registrable = registrableDomain(domain) + if (!registrable || !domain.endsWith(registrable)) return [domain] + + const labels = domain.split('.') + const depth = registrable.split('.').length + const candidates: string[] = [] + for (let start = 0; labels.length - start >= depth; start++) { + candidates.push(labels.slice(start).join('.')) + } + return candidates +} + +function matchEmail( + email: string, + owners: Map, +): ResolvedEmailDomainAffiliation | undefined { + const domain = email.slice(email.lastIndexOf('@') + 1) + if (isIneligibleDomain(domain)) return undefined + + for (const candidate of candidateDomains(domain)) { + const owner = owners.get(candidate) + if (owner) { + return { + ...owner, + matchedDomain: candidate, + confidence: 'email_domain', + confidenceScore: 0.7, + } + } + } + return undefined +} + +export async function resolveEmailDomainAffiliations( + cdpQx: QueryExecutor, + emails: string[], + members: AkritesMember[] = AKRITES_MEMBERS, +): Promise> { + const resolved = new Map() + if (emails.length === 0) return resolved + + const organizationIds = members.flatMap((member) => member.cdpOrganizationIds) + const rows = await fetchManyOrganizationVerifiedPrimaryDomains(cdpQx, organizationIds) + const owners = buildDomainOwners(rows, members) + + for (const email of normalizeEmails(emails)) { + const affiliation = matchEmail(email, owners) + if (affiliation) + resolved.set(contributorIdentityKey({ type: 'email', value: email }), affiliation) + } + return resolved +} diff --git a/services/apps/packages_worker/src/member-contributors/types.ts b/services/apps/packages_worker/src/member-contributors/types.ts index c076b80117..3eac1931f1 100644 --- a/services/apps/packages_worker/src/member-contributors/types.ts +++ b/services/apps/packages_worker/src/member-contributors/types.ts @@ -16,6 +16,14 @@ export interface ResolvedCdpAffiliation { confidenceScore: 1 } +export interface ResolvedEmailDomainAffiliation { + akritesMember: string + cdpOrganizationId: string + matchedDomain: string + confidence: 'email_domain' + confidenceScore: 0.7 +} + export function contributorIdentityKey(identity: ContributorIdentity): string { return `${identity.type}:${identity.value.toLowerCase()}` } From 2d0ef9f2ee2e1ba655eb8cf794995c73aeec7875 Mon Sep 17 00:00:00 2001 From: Mouad BANI Date: Mon, 12 Oct 2026 16:23:47 +0000 Subject: [PATCH 2/2] fix: exclude subdomains of public and disposable email domains (CM-1826) Signed-off-by: Mouad BANI --- .../__tests__/resolveEmailDomainAffiliations.test.ts | 6 ++++++ .../resolveEmailDomainAffiliations.ts | 10 ++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/services/apps/packages_worker/src/member-contributors/__tests__/resolveEmailDomainAffiliations.test.ts b/services/apps/packages_worker/src/member-contributors/__tests__/resolveEmailDomainAffiliations.test.ts index b33d36cd76..7ae75e65a3 100644 --- a/services/apps/packages_worker/src/member-contributors/__tests__/resolveEmailDomainAffiliations.test.ts +++ b/services/apps/packages_worker/src/member-contributors/__tests__/resolveEmailDomainAffiliations.test.ts @@ -80,6 +80,12 @@ describe('resolveEmailDomainAffiliations', () => { expect(out.size).toBe(0) }) + it('never resolves a subdomain of a public email provider listed under a member', async () => { + fetchDomains.mockResolvedValue([orgDomains('google-1', ['mail.gmail.com'])]) + const out = await resolveEmailDomainAffiliations(qx, ['jane@mail.gmail.com'], registry) + expect(out.size).toBe(0) + }) + it('never resolves a disposable domain listed under a member', async () => { expect(disposableEmailDomains.has('gmial.com')).toBe(true) fetchDomains.mockResolvedValue([orgDomains('google-1', ['google.com', 'gmial.com'])]) diff --git a/services/apps/packages_worker/src/member-contributors/resolveEmailDomainAffiliations.ts b/services/apps/packages_worker/src/member-contributors/resolveEmailDomainAffiliations.ts index 2cb2b62c77..13e050ee0b 100644 --- a/services/apps/packages_worker/src/member-contributors/resolveEmailDomainAffiliations.ts +++ b/services/apps/packages_worker/src/member-contributors/resolveEmailDomainAffiliations.ts @@ -17,10 +17,6 @@ interface DomainOwner { cdpOrganizationId: string } -function isIneligibleDomain(domain: string): boolean { - return isDomainExcluded(domain) || disposableEmailDomains.has(domain) -} - function buildDomainOwners( rows: OrganizationVerifiedPrimaryDomains[], members: AkritesMember[], @@ -71,6 +67,12 @@ function candidateDomains(domain: string): string[] { return candidates } +function isIneligibleDomain(domain: string): boolean { + return candidateDomains(domain).some( + (candidate) => isDomainExcluded(candidate) || disposableEmailDomains.has(candidate), + ) +} + function matchEmail( email: string, owners: Map,