katekruger

Campaign Preflight

Community katekruger
Updated

Read-only preflight checks for outbound campaigns—contacts, personalization, suppressions, schedules, sender readiness, and configuration. CLI + MCP.

Campaign Preflight

Campaign Preflight is a read-only linter for outbound campaigns. It catchesconfiguration, contact-data, personalization, suppression, schedule, and senderproblems before launch.

CISecurityPython 3.9+Dependencies: noneLicense: MIT

Every outbound team has shipped a campaign with a mistake in it. Someone whounsubscribed got emailed anyway. A sequence kept following up after the prospectreplied. A merge field never merged and two hundred people got "Hi{{first_name}}."

You find out after it sends.

Campaign Preflight runs 76 deterministic checks over a campaign's configuration,leads, copy, schedule, senders, and suppression exposure, and returns areadiness decision with evidence for every finding. It never writes to yourprovider and it cannot activate anything.

Try it in thirty seconds

pipx install campaign-preflight
campaign-preflight demo

No API key. No network. No configuration. No dependencies — the whole thingis standard library, Python 3.9+, so it also runs straight from a checkout:

PYTHONPATH=src python3 -m campaign_preflight.cli demo
CAMPAIGN PREFLIGHT
Campaign: Enterprise Q3 Outbound
Provider: demo
Readiness: NOT READY
Score: 0/100
Confidence: MEDIUM

BLOCKERS

[campaign.stop_on_reply]
Stop-on-reply is disabled: repliers will keep receiving follow-ups.
  Remediation: Enable stop-on-reply on the campaign.

[personalization.prompt_injection]
1 contact(s) have prompt-injection text in their personalization.
  Affected: s***********[email protected]
  Remediation: Remove the affected personalization and review the enrichment source it came from.

[suppression.contact_listed]
1 contact(s) appear on the active suppression list.
  Affected: m**********[email protected]
  Remediation: Remove these contacts from the campaign before activation.

WARNINGS

[contacts.missing_first_name]
2 of 20 contacts (10.0%) are missing a first name.
  Affected: i**[email protected], r******[email protected]
  Remediation: Backfill the missing first names, or use a fallback in your copy.

UNKNOWN

[senders.aggregate_capacity]
Sender capacity is unavailable: 1 of 3 senders report no daily limit.
  Affected: r***[email protected]

------------------------------------------------------------------------------
Summary:
8 blockers, 17 failures, 21 warnings, 1 unknown, 32 passed
20 leads and 3 sender(s) checked in 0.0s
Confidence is MEDIUM: 1 check(s) could not run.
Point-in-time snapshot. Campaign state may change after this check ran.

Note the last finding. One sender reports no daily limit, so total capacitycannot be summed. Most tools would add up the senders that do report one andcall it a number. This one says it does not know — and drops confidence fromHIGH to MEDIUM because of it.

That distinction is the whole idea.

"We checked and it's fine" ≠ "we couldn't check"

A checker that cannot tell those apart is worse than no checker, because itturns a permissions error into a green light.

Campaign Preflight makes the distinction structural. Every provider read returnsdata plus the reason it does or does not exist, and every rule declares thedata it needs. If that data is unavailable, the engine short-circuits the ruleto UNKNOWN before it can run. Rules cannot opt out.

Situation Result
Suppression list read, nobody matched PASS
No suppression list supplied UNKNOWN → run is INCOMPLETE
Suppression endpoint returned 403 UNKNOWN → run is INCOMPLETE
Zero leads in the campaign FAIL
Lead endpoint unreachable UNKNOWN

There are four verdicts, not two: READY, READY_WITH_WARNINGS, NOT_READY,and INCOMPLETE.

Why read-only matters

Campaign Preflight has no code path that writes. Not "we chose not to" — thereis nothing to call.

  • The Instantly provider routes every request through a transport that checks(method, path) against an explicit allowlist and raises before the requestleaves the process. The check sits below the client and below the provider,so a future code change that adds a PATCH fails loudly instead of quietlyediting your campaign.
  • Two guards run at import time: the allowlist cannot contain PUT, PATCH,DELETE, HEAD, or OPTIONS, and POST is permitted for exactly one path(/leads/list, which is Instantly's documented shape for a filtered read).
  • The MCP server refuses to start if any registered tool has a mutating verbin its name or does not declare itself read-only.
  • tests/contract/test_instantly_transport.py exercises the full method × pathmatrix plus every documented mutating endpoint. A failure there is a securityincident, not a test failure.

This is what makes it safe to hand an agent a live campaign. It gets theanalysis and none of the authority.

What it checks

76 rules across seven categories. Full catalogue: docs/rules.md.

Category Rules Examples
Campaign 10 Stop-on-reply disabled, daily volume above threshold, no sending window, dates that leave no sending days
Contacts 15 Malformed addresses, duplicates (exact and case-folded), role inboxes, placeholder values, control and bidi characters, spreadsheet formula injection
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
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
Copy 13 Empty subject on the first step, broken links, TODO markers, missing opt-out language, a follow-up identical to the first email
Schedule 9 Invalid timezone, weekend sending, zero active days, a window that ends before it starts, DST transitions inside the campaign
Senders 8 Mailboxes below your health threshold, error states, volume exceeding capacity — and honest UNKNOWNs when the provider won't say

Ask the tool about any of them:

campaign-preflight rules list --category suppression
campaign-preflight rules explain senders.aggregate_capacity

What it deliberately does not check

There is no spam-word rule. "Free" and "act now" are not evidence of anything,and shipping that list would train you to ignore the tool. Rules that arejudgement calls — copy length, link count, generation artifacts — are markedheuristic, labelled as such in every report, and are never blockers by default.

Quickstart

From files, no account needed

campaign-preflight check \
  --campaign examples/clean_campaign/campaign.yaml \
  --leads examples/clean_campaign/leads.csv \
  --suppressions examples/clean_campaign/suppressions.csv

Three worked examples ship with the repo, one per verdict:

Example Verdict Exit
examples/clean_campaign READY, 100/100 0
examples/risky_campaign NOT_READY, 13 blockers 2
examples/incomplete_campaign INCOMPLETE — nothing is wrong, it just can't be verified 3

Against a live Instantly campaign

export INSTANTLY_API_KEY="..."
campaign-preflight instantly --campaign-id 01a03960-aa51-777b-8a74-c93b2883a947

Reads the campaign, its leads, its sending accounts, and the workspace blocklist. Five requests, all on the allowlist. See docs/instantly.md.

The key comes from the environment. There is deliberately no --api-key flag: akey on the command line ends up in shell history, ps output, and CI logs.

In CI

campaign-preflight check \
  --campaign campaign.yaml \
  --leads leads.csv \
  --fail-on blocker \
  --format markdown --output preflight.md

Exit codes carry the verdict, so this drops straight into a pipeline. Seedocs/ci.md.

As an MCP server

claude mcp add campaign-preflight -- campaign-preflight-mcp

Check campaign abc123 before I activate it.

Six read-only tools. Nothing that could activate, edit, import, or send. Setupfor Claude Code and Claude Desktop: docs/mcp.md.

As a Cowork plugin

For people who would rather describe a campaign than write a YAML file:

uv run python scripts/build_plugin.py     # produces dist/campaign-preflight.plugin

Install that file in Cowork and ask in plain language — upload a lead list,paste one, or just describe the sequence and let it build the campaign file foryou. Same 76 checks, same engine, no install step for the recipient. Seeplugin/campaign-preflight/README.md.

Exit codes

Code Meaning
0 READY
1 READY_WITH_WARNINGS
2 NOT_READY
3 INCOMPLETE — a critical check could not run
4 Configuration or input error
5 Provider or authentication error
6 Unexpected internal error

--fail-on none|warning|high|blocker raises the bar at which a verdict becomesa nonzero exit. It never changes the verdict itself. INCOMPLETE is notsilenced by a severity threshold — a check that could not run is a differentproblem from a low-severity finding.

Scoring is published, not hidden

score = 100 - sum(weight[status][severity] for every FAIL and WARN)

readiness:
  NOT_READY            any BLOCKER FAIL, or any HIGH FAIL
  INCOMPLETE           else if any critical rule is UNKNOWN
  READY_WITH_WARNINGS  else if any FAIL or WARN
  READY                otherwise

Four things follow from that, and each has a test:

  1. A blocker always produces NOT_READY. The number cannot override it.
  2. UNKNOWN deducts nothing. A provider outage must not look like a badcampaign — it lowers confidence instead.
  3. NOT_APPLICABLE affects nothing.
  4. Every deduction is itemized. --verbose prints the arithmetic so you cancheck it by hand.

Weights and the critical-rule list are configurable:docs/configuration.md.

Architecture

flowchart LR
    CLI[CLI] --> Engine
    MCP[MCP server] --> Engine
    Engine -->|gather| Provider{Provider}
    Provider --> CSV[CSV / files]
    Provider --> Instantly[Instantly v2]
    Instantly --> Guard[ReadOnlyTransport]
    Guard -->|allowlist| API[(Instantly API)]
    Provider -->|data + why| Context[Frozen context]
    Context --> Rules[76 rules]
    Rules --> Score[Scoring]
    Score --> Out[Terminal / JSON / Markdown]
    style Guard fill:#4a1f1f,stroke:#c04040,color:#fff

The context is a frozen Pydantic model, so "a rule never mutates its input" isenforced by the type system rather than by review. Provider-specific behaviourlives entirely behind the provider interface.

Full design and threat model: docs/architecture.md.

Privacy

  • Redacted by default. Mailbox local parts are masked(m**********[email protected]); domains are kept, because a domainis what makes a suppression finding actionable.
  • Secrets are scrubbed unconditionally. --no-redact disables PII masking,never credential masking. A provider that echoes your API key back in an errorbody cannot get it into a report — there is a test for exactly that.
  • Nothing leaves your machine by default. The optional LLM claim evaluatoris off unless you configure it, and validate-config warns you when a configturns it on.
  • Report files are written 0600, to a temporary file and then renamed.
  • Samples are bounded. A 100,000-lead campaign cannot emit 100,000 lines.

Performance

Workload Time
Demo (20 leads) 0.02 s
10,000 leads 0.28 s
100,000 leads 3.0 s, ~300 MB peak

Rows are streamed, not slurped. Pagination, retries, sender concurrency, andoutput size are all bounded.

Limitations

Read docs/limitations.md before relying on a READY. In short:

  • It does not guarantee deliverability. It checks configuration and data,not inbox placement, and never invents a deliverability score.
  • It does not provide legal advice. Region, domain, and opt-out rules checkyour campaign against your configured outreach policy. They make nodetermination under GDPR, CAN-SPAM, CASL, or anything else.
  • It does not replace provider-native safeguards. Keep them on.
  • Some checks depend on provider scope. An API key without block-list accessproduces UNKNOWN, not PASS.
  • LLM-based claim assessment is optional and probabilistic, labelledMODEL_ASSESSED and never presented as fact.
  • Results are a point-in-time snapshot. The campaign can change a secondlater.
  • It never activates a campaign.

Roadmap

  • Additional providers behind the same read-only interface (Smartlead,HubSpot Sequences, Apollo)
  • Domain reputation and DNS record checks (SPF, DKIM, DMARC alignment)
  • A GitHub Action wrapping the CLI with PR annotations
  • Baseline comparison: diff two reports and show what changed since the last run
  • Per-segment thresholds, so one config can cover several motions

Contributing

Rules are small, pure, and independently testable — a new one is usually aclass, a docstring, and a handful of tests. SeeCONTRIBUTING.md.

git clone https://github.com/katekruger/campaign-preflight
cd campaign-preflight
uv sync --all-extras
uv run pytest

The package itself has no runtime dependencies; the dev group exists for thetest suite (pytest, hypothesis, jsonschema), the linters, and two libraries usedonly as test oracles — httpx for the optional Instantly provider and PyYAMLto differentially test the bundled YAML parser against.

Security

Report vulnerabilities privately: SECURITY.md.

License

MIT. See LICENSE.

MCP Server · Populars

MCP Server · New

    PSU3D0

    agent-spreadsheet

    MCP server for spreadsheet analysis and editing. Slim, token-efficient tool surface designed for LLM agents.

    Community PSU3D0
    pitiflautico

    NeoBrowser

    MCP server that drives real Chrome with your real logged-in sessions — genuine fingerprint (passes bot.sannysoft), human-like input, bot-wall aware. 43 tools, single static Rust binary.

    Community pitiflautico
    aeonfun

    Aeon MCP Server

    The most autonomous AI agent framework: runs unattended on GitHub Actions, self-healing skills, drives Claude Code, Grok, Codex & more. No approval loops. Configure once, forget forever.

    Community aeonfun
    nhadaututtheky

    NeuralMemory

    NeuralMemory stores experiences as interconnected neurons and recalls them through spreading activation, mimicking how the human brain works. Instead of searching a database, memories are retrieved through associative recall - activating related concepts until the relevant memory emerges.

    Community nhadaututtheky
    norrietaylor

    Distillery

    Team knowledge evaporates daily — pairing sessions, debugging context, architectural rationale lost to Slack. Distillery captures it at the point of creation, connects it into a living graph, and surfaces it conversationally. It monitors feeds, tracks what matters to your projects, and alerts you before you know to ask. A team brain that learns.

    Community norrietaylor