{"id":"6faeb333-3ca8-49d4-a992-8daa11e9eba3","slug":"campaign-preflight","kind":"mcp","name":"campaign-preflight","summary":"Read-only preflight checks for outbound campaigns—contacts, personalization, suppressions, schedules, sender readiness, and configuration. CLI + MCP.","owner":"katekruger","repoUrl":"https://github.com/katekruger/campaign-preflight","license":"MIT","licenseTier":"permissive","trust":"community","stars":0,"match":null,"fewStars":true,"format":"mcp","createdAt":"2026-08-26T00:17:16.000Z","addedAt":"2026-10-21T06:40:03.897Z","logoUrl":"https://avatars.githubusercontent.com/u/281727338?v=4","linkOnly":false,"collection":null,"skillPath":null,"categories":["productivity","memory-knowledge"],"tags":["campaign-automation","developer-tools","email-automation","gtm-engineering","instantly-ai","python","revenue-operations","sales-ops"],"runtime":"python","local":true,"cloudCapable":false,"cloudCapability":"unknown","cloudReadyOnPlatform":false,"needsApiKey":false,"reviewed":false,"syncedAt":"2026-10-23T03:01:12.757Z","sample":false,"url":"https://conduits.dev/tools/campaign-preflight","language":"Python","transports":["stdio"],"install":{"pypi":"campaign-preflight"},"requirements":{"env":[],"upstreamMayCharge":false},"tools":null,"readme":"# Campaign Preflight\n\n**Campaign Preflight is a read-only linter for outbound campaigns. It catches\nconfiguration, contact-data, personalization, suppression, schedule, and sender\nproblems before launch.**\n\n[![CI](https://github.com/katekruger/campaign-preflight/actions/workflows/ci.yml/badge.svg)](https://github.com/katekruger/campaign-preflight/actions/workflows/ci.yml)\n[![Security](https://github.com/katekruger/campaign-preflight/actions/workflows/security.yml/badge.svg)](https://github.com/katekruger/campaign-preflight/actions/workflows/security.yml)\n[![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-blue.svg)](https://www.python.org/downloads/)\n[![Dependencies: none](https://img.shields.io/badge/dependencies-none-brightgreen.svg)](pyproject.toml)\n[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)\n\n---\n\n## What it does\n\nEvery outbound team has shipped a campaign with a mistake in it. Someone who\nunsubscribed got emailed anyway. A sequence kept following up after the prospect\nreplied. A merge field never merged and two hundred people got \"Hi\n{{first_name}}.\"\n\nYou find out after it sends.\n\nCampaign Preflight runs 76 deterministic checks over a campaign's configuration,\nleads, copy, schedule, senders, and suppression exposure, and returns a\nreadiness decision with evidence for every finding. It never writes to your\nprovider and it cannot activate anything.\n\n**What it does not do**, up front rather than buried:\n\n- **It does not guarantee deliverability.** It checks configuration and data,\n  not inbox placement, and never invents a deliverability score.\n- **It does not give legal advice.** Region, domain, and opt-out checks compare\n  a campaign against *your own configured policy* — not GDPR, CAN-SPAM, or CASL.\n- **It does not verify mailboxes.** Address checks are syntax only. No DNS, no\n  SMTP.\n- **It does not replace your provider's safeguards.** Keep those on.\n- **Results are a point-in-time snapshot.** A campaign that passed at 09:00 can\n  be edited at 09:05.\n\nFuller detail in [docs/limitations.md](docs/limitations.md).\n\n---\n\n## \"We checked and it's fine\" ≠ \"we couldn't check\"\n\nA checker that cannot tell those apart is worse than no checker, because it\nturns a permissions error into a green light.\n\nCampaign Preflight makes the distinction structural. Every provider read returns\ndata *plus the reason it does or does not exist*, and every rule declares the\ndata it needs. If that data is unavailable, the engine short-circuits the rule\nto `UNKNOWN` before it can run. Rules cannot opt out.\n\n| Situation | Result |\n|---|---|\n| Suppression list read, nobody matched | `PASS` |\n| No suppression list supplied | `UNKNOWN` → run is `INCOMPLETE` |\n| Suppression endpoint returned 403 | `UNKNOWN` → run is `INCOMPLETE` |\n| Zero leads in the campaign | `FAIL` |\n| Lead endpoint unreachable | `UNKNOWN` |\n\nThere are four verdicts, not two: `READY`, `READY_WITH_WARNINGS`, `NOT_READY`,\nand `INCOMPLETE`.\n\n---\n\n## Requirements\n\n**Python 3.9 or newer.** That is the whole list.\n\nThe package has **no runtime dependencies** — it imports nothing outside the\nstandard library. `httpx` is an optional extra needed only for the live\nInstantly provider, behind a lazy import.\n\nThe 3.9 floor is deliberate, and deliberately lower than you might expect. It is\nthe oldest interpreter the plugin can encounter on a user's machine, and since\nthere are no dependencies, nothing forces it higher. CI runs 3.9 through 3.13\nplus a bare-interpreter job that installs nothing at all, on Linux, macOS, and\nWindows.\n\nThat combination is what lets the plugin run with no install step: it uses\nwhatever `python3` is already there.\n\n---\n\n## Install\n\n### As a Claude plugin (marketplace)\n\n```\n/plugin marketplace add katekruger/campaign-preflight\n/plugin install campaign-preflight\n```\n\nThe repository is its own marketplace: `.claude-plugin/marketplace.json` sits at\nthe root alongside the plugin manifest.\n\n### As a Claude plugin (local checkout)\n\n```bash\ngit clone https://github.com/katekruger/campaign-preflight\n```\n\n```\n/plugin marketplace add ./campaign-preflight\n/plugin install campaign-preflight\n```\n\n### As a CLI\n\nStraight from a checkout, with nothing installed at all:\n\n```bash\nPYTHONPATH=src python3 -m campaign_preflight.cli demo\n```\n\nNot yet published to PyPI — this is the only CLI install path today.\n\n### As an MCP server\n\n```bash\nclaude mcp add campaign-preflight -- campaign-preflight-mcp\n```\n\nSix read-only tools. Nothing that could activate, edit, import, or send. Setup\nfor Claude Code and Claude Desktop: [docs/mcp.md](docs/mcp.md).\n\n---\n\n## Quick start\n\n```bash\ncampaign-preflight demo\n```\n\nNo API key. No network. No configuration.\n\n```\nCAMPAIGN PREFLIGHT\nCampaign: Enterprise Q3 Outbound\nProvider: demo\nReadiness: NOT READY\nScore: 0/100\nConfidence: MEDIUM\n\nBLOCKERS\n\n[campaign.stop_on_reply]\nStop-on-reply is disabled: repliers will keep receiving follow-ups.\n  Remediation: Enable stop-on-reply on the campaign.\n\n[personalization.prompt_injection]\n1 contact(s) have prompt-injection text in their personalization.\n  Affected: s***********a@caldera.example.com\n  Remediation: Remove the affected personalization and review the enrichment source it came from.\n\n[suppression.contact_listed]\n1 contact(s) appear on the active suppression list.\n  Affected: m**********s@stonebridge.example.com\n  Remediation: Remove these contacts from the campaign before activation.\n\nWARNINGS\n\n[contacts.missing_first_name]\n2 of 20 contacts (10.0%) are missing a first name.\n  Affected: i**o@summitforge.example.com, r******s@clearwater.example.com\n  Remediation: Backfill the missing first names, or use a fallback in your copy.\n\nUNKNOWN\n\n[senders.aggregate_capacity]\nSender capacity is unavailable: 1 of 3 senders report no daily limit.\n  Affected: r***n@example.com\n\n------------------------------------------------------------------------------\nSummary:\n8 blockers, 17 failures, 21 warnings, 1 unknown, 32 passed\n20 leads and 3 sender(s) checked in 0.0s\nConfidence is MEDIUM: 1 check(s) could not run.\nPoint-in-time snapshot. Campaign state may change after this check ran.\n```\n\nNote the last finding. One sender reports no daily limit, so total capacity\ncannot be summed. Most tools would add up the senders that *do* report one and\ncall it a number. This one says it does not know — and drops confidence from\n`HIGH` to `MEDIUM` because of it.\n\nThat distinction is the whole idea.\n\n### Checking your own campaign\n\nOnce the plugin is installed, describe it in plain language:\n\n> Check this campaign before I send it.\n\n> Here's my lead list — anything wrong with it?  *(paste or upload)*\n\n> I'm sending a 3-email sequence to 200 people, 80 a day, weekdays 9-5 Eastern.\n> Is that okay?\n\nThere are three ways in, and none needs an account:\n\n| You have | What happens |\n|---|---|\n| **A file** (uploaded, or on disk) | Checked directly. |\n| **A pasted list or some copy** | Written to a scratch file, checked, then cleaned up. |\n| **Only a description** | The campaign file is built from what you say, shown to you, then checked. |\n\nAnything you do not know is left blank rather than guessed — a blank field comes\nback as \"couldn't check\", which is the honest answer.\n\n### From files, on the command line\n\n```bash\ncampaign-preflight check \\\n  --campaign examples/clean_campaign/campaign.yaml \\\n  --leads examples/clean_campaign/leads.csv \\\n  --suppressions examples/clean_campaign/suppressions.csv\n```\n\nThree worked examples ship with the repo, one per verdict:\n\n| Example | Verdict | Exit |\n|---|---|---|\n| [`examples/clean_campaign`](examples/clean_campaign) | `READY`, 100/100 | `0` |\n| [`examples/risky_campaign`](examples/risky_campaign) | `NOT_READY`, 13 blockers | `2` |\n| [`examples/incomplete_campaign`](examples/incomplete_campaign) | `INCOMPLETE` — nothing is wrong, it just can't be verified | `3` |\n\n### In CI\n\n```bash\ncampaign-preflight check --campaign campaign.yaml --leads leads.csv --fail-on blocker\n```\n\nExit codes carry the verdict, so this drops straight into a pipeline. See\n[docs/ci.md](docs/ci.md).\n\n---\n\n## What is inside\n\nThe repository root **is** the plugin. There is no second copy of the tree.\n\n```\n.claude-plugin/     plugin manifest and marketplace manifest\nskills/             the three skills, one directory each\nbin/                launchers the MCP server and CLI run through\nsrc/                the Python package: rules, engine, providers, reporters\ntests/              unit, integration, contract\ndocs/               rules catalogue, configuration, MCP, CI, limitations, architecture\nexamples/           three worked campaigns, one per verdict\nscripts/            generators and the plugin packager\n```\n\n---\n\n## Skills\n\n| Skill | Use it for |\n|---|---|\n| **`preflight-campaign`** | Checking a real campaign you supply — a file, a paste, or a description. |\n| **`preflight-demo`** | Watching the checker run against bundled sample data. |\n| **`preflight-rules`** | Which rules exist, what each one tests, and how to retune or disable them. |\n\nThe boundaries are deliberate: each description names its own situation and\npoints at its neighbour, so a near-miss lands somewhere recoverable.\n\n---\n\n## What it checks\n\n76 rules across seven categories. Full catalogue: [docs/rules.md](docs/rules.md).\n\n| Category | Rules | Examples |\n|---|---|---|\n| **Campaign** | 10 | Stop-on-reply disabled, daily volume above threshold, no sending window, dates that leave no sending days |\n| **Contacts** | 15 | Malformed addresses, duplicates (exact and case-folded), role inboxes, placeholder values, control and bidi characters, spreadsheet formula injection |\n| **Suppression** | 8 | Contacts and domains on your suppression list, existing customers, internal addresses, competitors, restricted regions — and whether the suppression check could run at all |\n| **Personalization** | 13 | Unrendered merge tokens, a greeting addressed to the wrong person, a company that isn't theirs, claims unsupported by their own evidence, stale research, **prompt-injection text scraped in from a target's page** |\n| **Copy** | 13 | Empty subject on the first step, broken links, `TODO` markers, missing opt-out language, a follow-up identical to the first email |\n| **Schedule** | 9 | Invalid timezone, weekend sending, zero active days, a window that ends before it starts, DST transitions inside the campaign |\n| **Senders** | 8 | Mailboxes below your health threshold, error states, volume exceeding capacity — and honest `UNKNOWN`s when the provider won't say |\n\nAsk the tool about any of them:\n\n```bash\ncampaign-preflight rules list --category suppression\ncampaign-preflight rules explain senders.aggregate_capacity\n```\n\n### What it deliberately does not check\n\nThere is no spam-word rule. \"Free\" and \"act now\" are not evidence of anything,\nand shipping that list would train you to ignore the tool. Rules that *are*\njudgement calls — copy length, link count, generation artifacts — are marked\n`heuristic`, labelled as such in every report, and are never blockers by default.\n\n---\n\n## Configuration\n\nCampaign Preflight runs with sensible defaults and no config file. Add one when\nyour thresholds differ, or to switch on the checks that depend on your own\ndomain and region lists.\n\n```yaml\nversion: 1\n\nsettings:\n  target_timezone: America/New_York\n  required_variables: [first_name, company_name]\n  internal_domains: [ourcompany.example.com]\n  customer_domains: [bigcustomer.example.com]\n  allow_weekend_sending: false\n\nrules:\n  campaign.daily_volume:\n    warning_above: 100\n    blocker_above: 250\n  senders.health_below_threshold:\n    minimum_score: 80\n  contacts.missing_job_title:\n    enabled: false\n```\n\n```bash\ncampaign-preflight validate-config preflight.yaml\ncampaign-preflight check --campaign c.yaml --leads l.csv --config preflight.yaml\n```\n\nValidation is strict on purpose: an unknown rule id or an unknown option is a\nhard error, not a warning. A typo that silently disables a safety check is worse\nthan no config at all.\n\nFull reference: [docs/configuration.md](docs/configuration.md).\n\n---\n\n## Why read-only matters\n\nCampaign Preflight has no code path that writes. Not \"we chose not to\" — there\nis nothing to call.\n\n- The Instantly provider routes every request through a transport that checks\n  `(method, path)` against an explicit allowlist and **raises before the request\n  leaves the process**. The check sits below the client and below the provider,\n  so a future code change that adds a `PATCH` fails loudly instead of quietly\n  editing your campaign.\n- Two guards run at import time: the allowlist cannot contain `PUT`, `PATCH`,\n  `DELETE`, `HEAD`, or `OPTIONS`, and `POST` is permitted for exactly one path\n  (`/leads/list`, which is Instantly's documented shape for a filtered read).\n- The MCP server **refuses to start** if any registered tool has a mutating verb\n  in its name or does not declare itself read-only.\n- `tests/contract/test_instantly_transport.py` exercises the full method × path\n  matrix plus every documented mutating endpoint. A failure there is a security\n  incident, not a test failure.\n\nThis is what makes it safe to hand an agent a live campaign. It gets the\nanalysis and none of the authority.\n\n### What it will never do\n\n- Activate, pause, resume, or schedule a campaign\n- Create, update, move, merge, or delete a lead\n- Add to or remove from a suppression list\n- Send, reply to, or forward an email\n- Modify anything in your sending platform\n\nThere is no code path to any of these, and two independent guards — the\ntransport allowlist and the MCP startup assertion — fail closed if one is ever\nadded.\n\n---\n\n## Exit codes\n\n| Code | Meaning |\n|---|---|\n| `0` | `READY` |\n| `1` | `READY_WITH_WARNINGS` |\n| `2` | `NOT_READY` |\n| `3` | `INCOMPLETE` — a critical check could not run |\n| `4` | Configuration or input error |\n| `5` | Provider or authentication error |\n| `6` | Unexpected internal error |\n\n`--fail-on none|warning|high|blocker` raises the bar at which a verdict becomes\na nonzero exit. It never changes the verdict itself. `INCOMPLETE` is not\nsilenced by a severity threshold — a check that could not run is a different\nproblem from a low-severity finding.\n\n---\n\n## Scoring is published, not hidden\n\n```\nscore = 100 - sum(weight[status][severity] for every FAIL and WARN)\n\nreadiness:\n  NOT_READY            any BLOCKER FAIL, or any HIGH FAIL\n  INCOMPLETE           else if any critical rule is UNKNOWN\n  READY_WITH_WARNINGS  else if any FAIL or WARN\n  READY                otherwise\n```\n\nFour things follow from that, and each has a test:\n\n1. **A blocker always produces `NOT_READY`.** The number cannot override it.\n2. **`UNKNOWN` deducts nothing.** A provider outage must not look like a bad\n   campaign — it lowers *confidence* instead.\n3. **`NOT_APPLICABLE` affects nothing.**\n4. **Every deduction is itemized.** `--verbose` prints the arithmetic so you can\n   check it by hand.\n\nWeights and the critical-rule list are configurable:\n[docs/configuration.md](docs/configuration.md).\n\n---\n\n## Architecture\n\n```mermaid\nflowchart LR\n    CLI[CLI] --> Engine\n    MCP[MCP server] --> Engine\n    Engine -->|gather| Provider{Provider}\n    Provider --> CSV[CSV / files]\n    Provider --> Instantly[Instantly v2]\n    Instantly --> Guard[ReadOnlyTransport]\n    Guard -->|allowlist| API[(Instantly API)]\n    Provider -->|data + why| Context[Frozen context]\n    Context --> Rules[76 rules]\n    Rules --> Score[Scoring]\n    Score --> Out[Terminal / JSON / Markdown]\n    style Guard fill:#4a1f1f,stroke:#c04040,color:#fff\n```\n\nThe context is a frozen dataclass, so \"a rule never mutates its input\" is\nenforced by the type system rather than by review. Provider-specific behaviour\nlives entirely behind the provider interface.\n\nFull design and threat model: [docs/architecture.md](docs/architecture.md).\n\n---\n\n## Privacy\n\n- **Redacted by default.** Mailbox local parts are masked\n  (`m**********s@stonebridge.example.com`); domains are kept, because a domain\n  is what makes a suppression finding actionable.\n- **Secrets are scrubbed unconditionally.** `--no-redact` disables PII masking,\n  never credential masking. A provider that echoes your API key back in an error\n  body cannot get it into a report — there is a test for exactly that.\n- **Nothing leaves your machine by default.** The optional LLM claim evaluator\n  is off unless you configure it, and `validate-config` warns you when a config\n  turns it on.\n- **Report files are written `0600`**, to a temporary file and then renamed.\n- **Samples are bounded.** A 100,000-lead campaign cannot emit 100,000 lines.\n\n---\n\n## Performance\n\n| Workload | Time |\n|---|---|\n| Demo (20 leads) | 0.02 s |\n| 10,000 leads | 0.28 s |\n| 100,000 leads | 3.0 s, ~300 MB peak |\n\nRows are streamed, not slurped. Pagination, retries, sender concurrency, and\noutput size are all bounded.\n\n---\n\n## Development\n\n```bash\ngit clone https://github.com/katekruger/campaign-preflight\ncd campaign-preflight\nuv sync --all-extras\nuv run pytest\n```\n\n```bash\nuv run ruff format .                                  # format\nuv run ruff check .                                   # lint\nuv run mypy                                           # typecheck, strict\nclaude plugin validate . --strict                     # manifests\nuv run python scripts/generate_rules_doc.py --check   # docs/rules.md is current\n./scripts/bump-version.sh --check                     # version fields agree\nuv run python scripts/build_plugin.py                 # dist/campaign-preflight.plugin\n```\n\nThe package itself has no runtime dependencies; the dev group exists for the\ntest suite, the linters, and two libraries used only as test oracles — `httpx`\nfor the optional Instantly provider and `PyYAML` to differentially test the\nbundled YAML parser against.\n\nConventions that look like mistakes until you know why are written down in\n[AGENTS.md](AGENTS.md).\n\n---\n\n## Roadmap\n\n- Additional providers behind the same read-only interface (Smartlead,\n  HubSpot Sequences, Apollo)\n- Domain reputation and DNS record checks (SPF, DKIM, DMARC alignment)\n- A GitHub Action wrapping the CLI with PR annotations\n- Baseline comparison: diff two reports and show what changed since the last run\n- Per-segment thresholds, so one config can cover several motions\n\n---\n\n## Contributing\n\nRules are small, pure, and independently testable — a new one is usually a\nclass, a docstring, and a handful of tests. See\n[CONTRIBUTING.md](CONTRIBUTING.md) and\n[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).\n\n## Security\n\nReport vulnerabilities privately: [SECURITY.md](SECURITY.md). A rule that\nreturned `PASS` when the data was missing counts as a security issue.\n\n\n## See also\n\nEvery project here shares one idea: a GTM system should refuse to act on data it cannot verify.\n\n[deliverability-guard](https://github.com/katekruger/deliverability-guard) — the runtime counterpart. This checks a campaign before it sends; that one watches reputation while it is sending and pauses before a domain burns.\n\n[instantly-mcp](https://github.com/katekruger/instantly-mcp) — the same risk-tiered safety model applied to writes rather than reads.\n\n[segment-mcp](https://github.com/katekruger/segment-mcp) — a read-first MCP\nserver for Twilio Segment. Same \"checked and it's fine ≠ we couldn't check\"\nphilosophy: `insufficient_data` is its own state, never coerced to a pass.\n\n## License\n\nMIT. See [LICENSE](LICENSE).\n"}