mirror of
https://github.com/getcargohq/cargo-skills.git
synced 2026-09-14 19:17:15 +08:00
2792b990ed
* feat(gtm): executable QA layer — fixture-tested contact-accuracy scripts
Four deterministic TypeScript scripts under cargo-gtm/scripts/ (Node >= 22.18
native type-stripping, zero deps in file mode) replace in-context row checking:
- validate-emails.ts free syntax/risk/duplicate cull before paid verifyEmail
- select-current-role.ts current role from an experiences array (job changers)
- validate-linkedin-names.ts name<->profile match (same-name decoys); P>=0.95/R>=0.85 gate
- contact-accuracy-audit.ts final per-row SEND/VERIFY/REVIEW/REMOVE stamp
Each reads --input CSV/JSON or a finished run directly (--workflow-uuid via
@cargo-ai/api, reusing the CLI's stored login) and self-tests with --fixtures
(152 synthetic cases total). New qa-script-fixtures CI job runs every suite on
push/PR. Doctrine in references/contact-accuracy.md ("run the script, don't
re-derive"); wired into the router (new section 8, spine step 8) and the
prospecting / outreach-activation verify steps. cargo-gtm -> 1.2.0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(gtm): provider playbooks 6 → 20 + read-first gate (#54)
Fourteen new per-provider playbooks grounded in live `connection integration
get` schemas: sourcing/company-data (linkedin, proxycurl, apolloio, oceanio,
datagma), email/contact (hunter, prospeo, icypeas, findyMail, leadMagic,
contactOut, zeroBounce), research (firecrawl, serper). Each carries the action
table with credit costs, real config-shape examples, input quirks, cost traps,
and its recipe-spine position.
Router §11 opens with a hard read-first stop before any paid call to a covered
provider; the linter now errors when a playbook on disk is missing from the
SKILL.md catalog.
Grounding also surfaced and fixed a recipe bug: linkedin-url-lookup.md
documented findProfileUrl inputs that don't exist — the live schema is
fullName (required) + companyName. Stale cost-table rows (prospeo.verifyEmail,
serper pricing) flagged in the CHANGELOG for the next regeneration.
cargo-gtm -> 1.3.0.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(gtm): QA-chain review feedback — six wiring bugs in scripts and recipes
- contact-accuracy-audit.ts: detect a bare "status" header (raw waterfall
verifyEmail rows) as the verification column, lowest priority so specific
headers still win; two new fixture cases.
- lib/common.ts: accept raw execute-batch output ({"results": [...]} /
{"records": [...]}) in JSON inputs — no jq unwrap needed.
- validate-emails.ts + contact-accuracy-audit.ts: new --json flag emits rows
as a JSON array for jq chaining; duplicates now get recommendation "skip"
so they never enter the paid verify batch (savings count includes them).
- prospecting.md step 6/6.5: the paid verify batch is built from the culled
output (recommendation != "skip"), an explicit merge step joins statuses
back onto ALL rows before the audit, and only audit_action == "SEND" rows
proceed to write-back.
- outreach-activation.md step 4: same cull -> verify survivors -> merge ->
audit-all -> SEND-only flow; steps 5-6 now read the audited SEND set
(deliverable.json), with the step-5/6 jq paths fixed for array input.
- contact-accuracy.md: documents the never-pre-filter-to-valid rule, the
execute-batch input support, and --json.
All four fixture suites pass; the full recipe chain was exercised end-to-end
on simulated batch outputs (disposable + duplicate culled before payment,
catch-all single-source -> VERIFY, only SEND rows in the handoff set).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(gtm): case-insensitive status merge; strict identity coverage; dedupe verdict
- Recipes: the verification-status merge now joins on the lowercased email —
verifyEmail results may re-case the address, and a missed join left
emailStatus empty, silently degrading deliverable rows to VERIFY.
- contact-accuracy-audit.ts: identityColumnsPresent now requires BOTH
name_match and role_confidence columns — one upstream script alone no
longer masks partial identity coverage (partial-signals flag stays on).
- contact-accuracy-audit.ts: new duplicate-row REMOVE rule reading the
is_duplicate column — a later occurrence of an address never reaches the
sequencer even when all other signals are green (found while re-testing:
the case-insensitive join let the dupe inherit "valid" and SEND).
23 fixture cases pass; chain re-run with re-cased verify results yields
exactly one SEND.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(gtm): merge verifyEmail via email_status, not status
waterfall.verifyEmail batch rows expose the verdict on email_status per the
connector output schema. The recipe merge jq read .status, so merged rows got
an empty emailStatus and contact-accuracy-audit stamped VERIFY instead of SEND.
Co-authored-by: Auree Aubert <aureeaubert@users.noreply.github.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Auree Aubert <aureeaubert@users.noreply.github.com>
402 lines
12 KiB
TypeScript
402 lines
12 KiB
TypeScript
// 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();
|