← 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,
};