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