← back to Rentv Adintel
lib/compliance/no-inferred-email.js
148 lines
'use strict';
/**
* No-inferred-email guard (spec §6.8, §6.9).
*
* Hard rules enforced in code:
* §6.8 NEVER infer or generate an email address from a naming pattern.
* §6.9 Store a phone/email ONLY when it is EXPLICITLY published as a
* business contact on a first-party page / press release / media kit /
* event page / government record / public professional page, OR is
* manually entered by an authorized user.
*
* Therefore a contact_point may only be stored when it carries a
* source_evidence_id AND explicitly_public === true. We only store
* explicitly-published addresses; looksLikePatternEmail() documents and detects
* the FORBIDDEN pattern-generated case so importers/UI can REFUSE it.
*/
/** Access methods / source flags that imply the value was INFERRED, not seen. */
const INFERENCE_METHODS = Object.freeze([
'PATTERN_GUESS',
'EMAIL_PERMUTATION',
'INFERRED',
'GENERATED',
'GUESSED',
'ENRICHMENT_INFERRED',
]);
/**
* assertContactEvidence(contact) — throws unless the contact is provably
* explicitly-public. Every contact_point must be traceable to evidence (§6.11).
*/
function assertContactEvidence(contact) {
if (!contact || typeof contact !== 'object') {
throw new Error('assertContactEvidence: contact must be an object');
}
const evidenceId = contact.source_evidence_id || contact.sourceEvidenceId;
if (!evidenceId) {
throw new Error(
'assertContactEvidence: refusing to store contact without source_evidence_id (§6.9/§6.11) — every stored email/phone must cite explicit public evidence'
);
}
const explicit = contact.explicitly_public === true || contact.explicitlyPublic === true;
if (!explicit) {
throw new Error(
'assertContactEvidence: refusing to store contact that is not explicitly_public (§6.9) — only explicitly-published business contacts may be stored'
);
}
return true;
}
/**
* looksLikePatternEmail(email, personName, domain) — heuristic that flags
* emails that appear PATTERN-GENERATED from a person's name (first.last@domain,
* flast@domain, firstl@domain, first@domain, etc.). Returns true when the
* local-part matches a common permutation of the person's name.
*
* This is a REFUSE signal: we do not store inferred addresses. It documents the
* forbidden case (§6.8) so the importer/UI can reject a permutation even if a
* caller mistakenly tries to save one.
*/
function looksLikePatternEmail(email, personName, domain) {
if (!email || typeof email !== 'string') return false;
const at = email.indexOf('@');
if (at <= 0) return false;
const local = email.slice(0, at).toLowerCase().replace(/[^a-z0-9._-]/g, '');
const emailDomain = email.slice(at + 1).toLowerCase();
// If a domain is supplied, a mismatch means this isn't a pattern off THIS org.
if (domain) {
const d = String(domain).toLowerCase().replace(/^www\./, '');
if (!emailDomain.endsWith(d)) return false;
}
if (!personName || typeof personName !== 'string') return false;
const parts = personName
.toLowerCase()
.normalize('NFKD')
.replace(/[^a-z\s]/g, ' ')
.split(/\s+/)
.filter(Boolean);
if (parts.length < 2) {
// Single-token name: flag if local-part equals the token exactly.
return parts.length === 1 && local.replace(/[._-]/g, '') === parts[0];
}
const first = parts[0];
const last = parts[parts.length - 1];
const fi = first[0];
const li = last[0];
const bare = local.replace(/[._-]/g, '');
const permutations = new Set([
`${first}.${last}`,
`${first}_${last}`,
`${first}-${last}`,
`${first}${last}`,
`${fi}${last}`, // flast
`${fi}.${last}`, // f.last
`${fi}_${last}`,
`${first}${li}`, // firstl
`${first}.${li}`,
`${last}.${first}`,
`${last}${first}`,
`${last}.${fi}`, // last.f
`${last}${fi}`, // lastf
first, // first@
last, // last@
`${fi}${li}`, // initials
]);
if (permutations.has(local)) return true;
// Also compare the punctuation-stripped local-part against stripped perms.
for (const p of permutations) {
if (p.replace(/[._-]/g, '') === bare) return true;
}
return false;
}
/**
* assertNoInference(source) — throws if the source's access/method/flags imply
* the value was inferred rather than observed. Accepts a string method or an
* object with { method, accessMethod, inferred }.
*/
function assertNoInference(source) {
let method;
let inferredFlag = false;
if (typeof source === 'string') {
method = source;
} else if (source && typeof source === 'object') {
method = source.method || source.accessMethod || source.discoveryMethod;
inferredFlag = source.inferred === true || source.generated === true;
}
const m = String(method || '').toUpperCase();
if (inferredFlag || INFERENCE_METHODS.includes(m)) {
throw new Error(
`assertNoInference: source method "${method}" implies an INFERRED/pattern-generated value — forbidden (§6.8). Only explicitly-published contacts may be stored.`
);
}
return true;
}
module.exports = {
INFERENCE_METHODS,
assertContactEvidence,
looksLikePatternEmail,
assertNoInference,
};