← back to Email Deliverability Agent

contact-audit.js

276 lines

#!/usr/bin/env node
'use strict';

/**
 * contact-audit.js — find sister sites currently losing customer leads.
 *
 * Walks every directory under PROJECTS_DIR that contains a site.config.json,
 * greps its server.js + public files for `mailto:` (and `tel:`) literals,
 * then cross-references each contact email's domain against the Purelymail
 * domain-health data. Any site whose contact address sits on a domain that
 * fails MX or has no routing rule is UNDELIVERABLE — leads emailed there
 * are silently lost.
 *
 * Writes output/lead-loss-report.json and prints a human summary.
 *
 *   node contact-audit.js
 *
 * Env:
 *   PROJECTS_DIR  override the project root (default: ~/Projects)
 */

const fs = require('fs');
const path = require('path');
const os = require('os');
const {
  loadDomainSnapshot,
  loadRoutingRules,
  domainHealth,
  ensureOutputDir,
} = require('./lib/data');

const PROJECTS_DIR =
  process.env.PROJECTS_DIR || path.join(os.homedir(), 'Projects');

// Files to scan inside each site for contact literals.
const SCAN_FILES = ['server.js', 'app.js', 'index.js'];
const SCAN_DIRS = ['public', 'views', 'src'];
const SCAN_EXT = new Set(['.html', '.js', '.ejs', '.liquid', '.css', '.json']);
const MAX_FILE_BYTES = 2 * 1024 * 1024;

const MAILTO_RE = /mailto:([^"'`\s)<>?]+)/gi;
const TEL_RE = /tel:([+0-9()\-.\s]{5,})/gi;

function listSiteProjects() {
  let entries;
  try {
    entries = fs.readdirSync(PROJECTS_DIR, { withFileTypes: true });
  } catch (err) {
    throw new Error(`Cannot read PROJECTS_DIR ${PROJECTS_DIR}: ${err.message}`);
  }
  const sites = [];
  for (const e of entries) {
    if (!e.isDirectory()) continue;
    const dir = path.join(PROJECTS_DIR, e.name);
    const cfgPath = path.join(dir, 'site.config.json');
    if (!fs.existsSync(cfgPath)) continue;
    let cfg = {};
    try {
      cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
    } catch (err) {
      console.warn(`  warn: bad site.config.json in ${e.name}: ${err.message}`);
    }
    sites.push({ name: e.name, dir, config: cfg });
  }
  return sites.sort((a, b) => a.name.localeCompare(b.name));
}

function collectScanTargets(siteDir) {
  const files = [];
  for (const f of SCAN_FILES) {
    const p = path.join(siteDir, f);
    if (fs.existsSync(p)) files.push(p);
  }
  for (const d of SCAN_DIRS) {
    const dp = path.join(siteDir, d);
    if (!fs.existsSync(dp)) continue;
    walk(dp, files);
  }
  return files;
}

function walk(dir, out, depth = 0) {
  if (depth > 4) return;
  let entries;
  try {
    entries = fs.readdirSync(dir, { withFileTypes: true });
  } catch {
    return;
  }
  for (const e of entries) {
    if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
    const p = path.join(dir, e.name);
    if (e.isDirectory()) {
      walk(p, out, depth + 1);
    } else if (SCAN_EXT.has(path.extname(e.name).toLowerCase())) {
      out.push(p);
    }
  }
}

function extractContacts(files) {
  const emails = new Set();
  const phones = new Set();
  for (const f of files) {
    let text;
    try {
      const stat = fs.statSync(f);
      if (stat.size > MAX_FILE_BYTES) continue;
      text = fs.readFileSync(f, 'utf8');
    } catch {
      continue;
    }
    let m;
    MAILTO_RE.lastIndex = 0;
    while ((m = MAILTO_RE.exec(text))) {
      // strip trailing punctuation / query separators just in case
      const addr = m[1].split('?')[0].trim().toLowerCase();
      if (addr.includes('@')) emails.add(addr);
    }
    TEL_RE.lastIndex = 0;
    while ((m = TEL_RE.exec(text))) {
      phones.add(m[1].trim());
    }
  }
  return { emails: [...emails], phones: [...phones] };
}

function domainOf(email) {
  const at = email.lastIndexOf('@');
  return at === -1 ? '' : email.slice(at + 1).toLowerCase();
}

function main() {
  const snapshot = loadDomainSnapshot();
  const routing = loadRoutingRules();
  const health = domainHealth(snapshot, routing.rules);
  const healthByDomain = new Map(health.map((h) => [h.domain.toLowerCase(), h]));

  const sites = listSiteProjects();
  console.log(`Scanning ${sites.length} sister-site projects under ${PROJECTS_DIR}\n`);

  const results = [];
  for (const site of sites) {
    const files = collectScanTargets(site.dir);
    const { emails, phones } = extractContacts(files);

    const contactEmails = emails.map((email) => {
      const dom = domainOf(email);
      const h = healthByDomain.get(dom) || null;
      let status = 'UNKNOWN_DOMAIN'; // domain not on this Purelymail account
      let reasons = [];
      if (h) {
        if (h.deliverable) {
          status = 'DELIVERABLE';
        } else {
          status = 'UNDELIVERABLE';
          if (!h.passesMx) reasons.push('domain fails MX (cannot receive email)');
          if (!h.hasRouting)
            reasons.push('domain has no Purelymail routing rule (mail goes nowhere)');
        }
      } else {
        reasons.push('domain not found on the Purelymail account snapshot');
      }
      return { email, domain: dom, status, reasons, health: h };
    });

    // A site loses leads if ANY of its mailto contacts is undeliverable.
    const undeliverableContacts = contactEmails.filter(
      (c) => c.status === 'UNDELIVERABLE'
    );
    const unknownContacts = contactEmails.filter(
      (c) => c.status === 'UNKNOWN_DOMAIN'
    );
    const deliverableContacts = contactEmails.filter(
      (c) => c.status === 'DELIVERABLE'
    );

    let siteStatus;
    if (contactEmails.length === 0) siteStatus = 'NO_CONTACT_FOUND';
    else if (undeliverableContacts.length) siteStatus = 'LOSING_LEADS';
    else if (deliverableContacts.length) siteStatus = 'OK';
    else siteStatus = 'UNVERIFIED'; // only unknown-domain contacts

    results.push({
      site: site.name,
      dir: site.dir,
      configDomain: site.config.domain || null,
      contactEmails,
      phones,
      siteStatus,
      undeliverableContacts,
      unknownContacts,
    });
  }

  const losing = results.filter((r) => r.siteStatus === 'LOSING_LEADS');
  const noContact = results.filter((r) => r.siteStatus === 'NO_CONTACT_FOUND');
  const unverified = results.filter((r) => r.siteStatus === 'UNVERIFIED');
  const ok = results.filter((r) => r.siteStatus === 'OK');

  const report = {
    generatedAt: new Date().toISOString(),
    snapshotCapturedAt: snapshot.capturedAt || null,
    projectsDir: PROJECTS_DIR,
    totals: {
      sitesScanned: results.length,
      losingLeads: losing.length,
      ok: ok.length,
      noContactFound: noContact.length,
      unverified: unverified.length,
    },
    // The headline list: site -> contact address -> why it's undeliverable.
    losingLeads: losing.map((r) => ({
      site: r.site,
      configDomain: r.configDomain,
      undeliverable: r.undeliverableContacts.map((c) => ({
        contact: c.email,
        domain: c.domain,
        why: c.reasons.join('; '),
      })),
    })),
    sites: results,
  };

  const outDir = ensureOutputDir();
  const outFile = path.join(outDir, 'lead-loss-report.json');
  fs.writeFileSync(outFile, JSON.stringify(report, null, 2));

  // ---- human summary ----
  const t = report.totals;
  console.log('=== Sister-Site Lead-Loss Audit ===');
  console.log(`sites scanned     : ${t.sitesScanned}`);
  console.log(`LOSING LEADS      : ${t.losingLeads}`);
  console.log(`OK (deliverable)  : ${t.ok}`);
  console.log(`no contact found  : ${t.noContactFound}`);
  console.log(`unverified        : ${t.unverified}  (contact domain not on Purelymail)`);

  if (losing.length) {
    console.log('\n--- SITES CURRENTLY LOSING LEADS ---');
    for (const r of report.losingLeads) {
      for (const c of r.undeliverable) {
        console.log(`  ${r.site}`);
        console.log(`     contact : ${c.contact}`);
        console.log(`     reason  : ${c.why}`);
      }
    }
  } else {
    console.log('\nNo sites losing leads via undeliverable contact domains.');
  }

  if (unverified.length) {
    console.log('\n--- UNVERIFIED (contact domain not on Purelymail snapshot) ---');
    for (const r of unverified) {
      const addrs = r.unknownContacts.map((c) => c.email).join(', ');
      console.log(`  ${r.site}: ${addrs}`);
    }
  }
  if (noContact.length) {
    console.log('\n--- NO mailto: contact found in source ---');
    noContact.forEach((r) => console.log(`  ${r.site}`));
  }

  console.log(`\nWrote ${outFile}\n`);
}

if (require.main === module) {
  try {
    main();
  } catch (err) {
    console.error('contact-audit.js failed:', err.message);
    process.exit(1);
  }
}

module.exports = { main };