← Files Cargo CLIARCHIVED FILE

skills/cargo-gtm/scripts/validate-emails.ts

11.8 KB · Oct 5, 2026 · 18:31 UTC

↓ Download file

// validate-emails.ts — free deterministic email pre-filter for cargo-gtm.
//
// Paid verification (waterfall verifyEmail) costs credits per address. This
// script culls obvious junk first — malformed addresses, placeholders,
// disposable domains — and flags role accounts and consumer free-mail so the
// paid step only runs on addresses worth verifying.
//
// Usage:
//   node validate-emails.ts --input <rows.csv|rows.json> [--email-column email]
//     [--output <file.csv>]
//   node validate-emails.ts --workflow-uuid <uuid> [--batch-uuid <uuid>]
//     [--output-node-slug <slug>] [--workspace-uuid <uuid>]
//   node validate-emails.ts --fixtures
//
// Output: input rows + `valid`, `risk`, `reason`, `recommendation`,
// `is_duplicate` columns as CSV (stdout or --output). A one-line summary goes
// to stderr, including how many paid verifications were saved
// (invalid + disposable rows).
//
// Runtime contract: Node >= 22.18, run directly (`node validate-emails.ts`,
// native type-stripping). Erasable TypeScript only; zero npm dependencies.

import { writeFileSync } from "node:fs";
import { join } from "node:path";
import {
  parseArgs,
  readRows,
  toCsv,
  readJson,
  reportFixtureRun,
  fail,
  type Row,
} from "./lib/common.ts";

// ---------------------------------------------------------------------------
// Classification
// ---------------------------------------------------------------------------

export type Risk = "ok" | "free" | "role" | "disposable" | "invalid";

export type Classification = {
  valid: boolean;
  risk: Risk;
  reason: string;
};

// Throwaway inbox providers. Addresses here bounce or rot within minutes —
// never worth a paid verification credit.
const DISPOSABLE_DOMAINS = new Set([
  "mailinator.com",
  "guerrillamail.com",
  "10minutemail.com",
  "tempmail.com",
  "temp-mail.org",
  "yopmail.com",
  "sharklasers.com",
  "throwawaymail.com",
  "getnada.com",
  "maildrop.cc",
  "dispostable.com",
  "fakeinbox.com",
  "trashmail.com",
  "mytemp.email",
  "mohmal.com",
  "emailondeck.com",
  "spamgourmet.com",
  "mailnesia.com",
  "tempinbox.com",
  "mintemail.com",
  "mailcatch.com",
  "inboxkitten.com",
  "33mail.com",
  "burnermail.io",
  "anonaddy.me",
  "spam4.me",
  "grr.la",
  "pokemail.net",
  "tmpmail.org",
  "moakt.com",
]);

// Role accounts (exact local-part match) — shared inboxes, poor outreach
// targets; verify only after human review.
const ROLE_LOCALS = new Set([
  "info",
  "sales",
  "support",
  "admin",
  "contact",
  "hello",
  "team",
  "office",
  "hr",
  "jobs",
  "careers",
  "marketing",
  "billing",
  "finance",
  "legal",
  "help",
  "service",
  "enquiries",
  "inquiries",
  "press",
  "media",
  "webmaster",
  "postmaster",
  "abuse",
  "noc",
  "security",
  "no-reply",
  "noreply",
  "newsletter",
]);

// Consumer free-mail providers. Fine for SMB outreach, but flagged — for B2B a
// contact at the company domain is preferred.
const FREE_DOMAINS = new Set([
  "gmail.com",
  "googlemail.com",
  "yahoo.com",
  "yahoo.co.uk",
  "yahoo.fr",
  "yahoo.de",
  "yahoo.es",
  "yahoo.it",
  "yahoo.ca",
  "yahoo.co.jp",
  "yahoo.co.in",
  "yahoo.com.au",
  "yahoo.com.br",
  "hotmail.com",
  "hotmail.co.uk",
  "outlook.com",
  "live.com",
  "msn.com",
  "aol.com",
  "icloud.com",
  "me.com",
  "mac.com",
  "protonmail.com",
  "proton.me",
  "gmx.com",
  "gmx.de",
  "gmx.net",
  "mail.com",
  "zoho.com",
  "yandex.com",
  "yandex.ru",
  "web.de",
  "orange.fr",
  "wanadoo.fr",
  "free.fr",
  "t-online.de",
  "comcast.net",
  "verizon.net",
  "att.net",
  "qq.com",
  "163.com",
  "126.com",
]);

// Placeholder detection. Literal junk values first, then substring patterns
// CRMs commonly hold instead of a real address. Note the ordering consequence:
// "no-reply@…" is caught here as a placeholder (before the role check ever
// runs), while "noreply@…" (no hyphen) passes syntax and classifies as a role
// account. Domain placeholders are matched on the EXACT domains example.com /
// example.org / example.net — invented business domains under the reserved
// .example and .test TLDs (e.g. acme-widgets.example) stay valid.
const PLACEHOLDER_LITERALS = new Set(["n/a", "null", "none"]);
const PLACEHOLDER_SUBSTRINGS = ["test@test", "noemail", "no-reply@", "unknown@"];
const PLACEHOLDER_DOMAINS = new Set(["example.com", "example.org", "example.net"]);

const LOCAL_CHARSET = /^[a-z0-9._%+-]+$/;
const DOMAIN_LABEL = /^[a-z0-9-]+$/;
const TLD = /^[a-z]{2,}$/;

function invalid(reason: string): Classification {
  return { valid: false, risk: "invalid", reason };
}

/** Normalize (trim + lowercase) an email for comparison and dedupe. */
export function normalizeEmail(email: string): string {
  return email.trim().toLowerCase();
}

/**
 * Pure, deterministic email classification — no network, no credits.
 * `valid` means syntactically plausible (risk !== "invalid"); `risk` grades
 * how worthwhile a paid verification would be.
 */
export function classifyEmail(email: string): Classification {
  const normalized = normalizeEmail(email);
  if (normalized === "") return invalid("empty value");
  if (PLACEHOLDER_LITERALS.has(normalized)) {
    return invalid(`placeholder value "${normalized}"`);
  }
  for (const pattern of PLACEHOLDER_SUBSTRINGS) {
    if (normalized.includes(pattern)) {
      return invalid(`placeholder pattern "${pattern}"`);
    }
  }
  if (/[\s,]/.test(normalized)) return invalid("contains spaces or commas");
  if (normalized.length > 254) return invalid("longer than 254 characters");

  const atCount = normalized.split("@").length - 1;
  if (atCount === 0) return invalid("missing @");
  if (atCount > 1) return invalid("more than one @");
  const [local, domain] = normalized.split("@");

  if (local.length === 0) return invalid("empty local part");
  if (local.length > 64) return invalid("local part longer than 64 characters");
  if (!LOCAL_CHARSET.test(local)) {
    return invalid("local part has characters outside [a-z0-9._%+-]");
  }
  if (local.startsWith(".") || local.endsWith(".")) {
    return invalid("local part starts or ends with a dot");
  }
  if (local.includes("..")) return invalid("local part has consecutive dots");

  if (domain.length === 0) return invalid("empty domain");
  if (domain.length > 253) return invalid("domain longer than 253 characters");
  const labels = domain.split(".");
  if (labels.length < 2) return invalid("domain needs at least two labels");
  for (const label of labels) {
    if (label.length === 0) return invalid("domain has an empty label");
    if (label.length > 63) return invalid("domain label longer than 63 characters");
    if (!DOMAIN_LABEL.test(label)) {
      return invalid("domain label has characters outside [a-z0-9-]");
    }
    if (label.startsWith("-") || label.endsWith("-")) {
      return invalid("domain label starts or ends with a hyphen");
    }
  }
  if (!TLD.test(labels[labels.length - 1])) {
    return invalid("TLD must be at least 2 alphabetic characters");
  }
  if (PLACEHOLDER_DOMAINS.has(domain)) {
    return invalid(`placeholder domain "${domain}"`);
  }

  if (DISPOSABLE_DOMAINS.has(domain)) {
    return {
      valid: true,
      risk: "disposable",
      reason: `disposable provider "${domain}"`,
    };
  }
  if (ROLE_LOCALS.has(local)) {
    return { valid: true, risk: "role", reason: `role account "${local}@"` };
  }
  if (FREE_DOMAINS.has(domain)) {
    return {
      valid: true,
      risk: "free",
      reason: `consumer free-mail "${domain}" — company domain preferred for B2B`,
    };
  }
  return { valid: true, risk: "ok", reason: "syntax OK, business domain" };
}

const RECOMMENDATION: Record<Risk, string> = {
  invalid: "skip",
  disposable: "skip",
  role: "review",
  free: "verify",
  ok: "verify",
};

// ---------------------------------------------------------------------------
// Fixture mode
// ---------------------------------------------------------------------------

type FixtureCase = {
  email: string;
  expected: { valid: boolean; risk: Risk };
  note: string;
};

function runFixtures(): void {
  const path = join(import.meta.dirname, "fixtures_email_validation.json");
  const { cases } = readJson<{ cases: FixtureCase[] }>(path);
  const failures: string[] = [];
  for (const c of cases) {
    const got = classifyEmail(c.email);
    if (got.valid !== c.expected.valid || got.risk !== c.expected.risk) {
      failures.push(
        `${JSON.stringify(c.email)} (${c.note}): expected ` +
          `valid=${c.expected.valid} risk=${c.expected.risk}, got ` +
          `valid=${got.valid} risk=${got.risk} (${got.reason})`,
      );
    }
  }
  reportFixtureRun("validate-emails", { total: cases.length, failures });
}

// ---------------------------------------------------------------------------
// Row mode
// ---------------------------------------------------------------------------

const EMAIL_COLUMN_CANDIDATES = ["email", "workEmail", "emailAddress", "contactEmail"];

function resolveEmailColumn(rows: Row[], override: string | undefined): string {
  const columns = Object.keys(rows[0] ?? {});
  if (override) {
    if (!columns.includes(override)) {
      fail(
        `--email-column "${override}" not found — available columns: ` +
          columns.join(", "),
      );
    }
    return override;
  }
  for (const candidate of EMAIL_COLUMN_CANDIDATES) {
    const match = columns.find((c) => c.toLowerCase() === candidate.toLowerCase());
    if (match) return match;
  }
  return fail(
    `no email column found (tried ${EMAIL_COLUMN_CANDIDATES.join(", ")}) — ` +
      `pass --email-column; available columns: ${columns.join(", ")}`,
  );
}

async function main(): Promise<void> {
  const args = parseArgs(process.argv.slice(2), {
    value: [
      "input",
      "workflow-uuid",
      "batch-uuid",
      "output-node-slug",
      "workspace-uuid",
      "email-column",
      "output",
    ],
    boolean: ["fixtures", "json"],
  });

  if (args.flags.has("fixtures")) {
    runFixtures();
    return;
  }

  const rows = await readRows(args);
  if (rows.length === 0) fail("no input rows");
  const emailColumn = resolveEmailColumn(rows, args.values.get("email-column"));

  const counts: Record<Risk, number> = {
    ok: 0,
    free: 0,
    role: 0,
    disposable: 0,
    invalid: 0,
  };
  const seen = new Set<string>();
  let duplicates = 0;

  const output = rows.map((row) => {
    const email = row[emailColumn] ?? "";
    const result = classifyEmail(email);
    counts[result.risk]++;

    const normalized = normalizeEmail(email);
    const isDuplicate = normalized !== "" && seen.has(normalized);
    if (isDuplicate) duplicates++;
    if (normalized !== "") seen.add(normalized);

    // Prefixed column names: they survive merges with provider outputs and
    // are what contact-accuracy-audit.ts auto-detects downstream.
    return {
      ...row,
      email_syntax_valid: String(result.valid),
      email_risk: result.risk,
      email_risk_reason: result.reason,
      // Duplicates skip regardless of risk — verifying the same address twice
      // is pure credit waste; the first occurrence carries the verdict.
      recommendation: isDuplicate ? "skip" : RECOMMENDATION[result.risk],
      is_duplicate: String(isDuplicate),
    };
  });

  // --json renders rows as a JSON array — the shape jq chains want (e.g.
  // select(.recommendation != "skip") to build the paid-verify batch).
  const rendered = args.flags.has("json")
    ? JSON.stringify(output, null, 2) + "\n"
    : toCsv(output);
  const outputPath = args.values.get("output");
  if (outputPath) {
    writeFileSync(outputPath, rendered);
  } else {
    process.stdout.write(rendered);
  }

  const saved = counts.invalid + counts.disposable + duplicates;
  process.stderr.write(
    `validate-emails: ${rows.length} rows — ok=${counts.ok} free=${counts.free} ` +
      `role=${counts.role} disposable=${counts.disposable} invalid=${counts.invalid}; ` +
      `duplicates=${duplicates}; paid verifications saved=${saved}\n`,
  );
}

await main();

SHA-256: 9d6d9e027ee6168b3ffca4279296e380d9cc805a96eb3f5d87226f5146e02840