AI-Powered Cold Email Outreach Automation: A Claude + Gmail API Pipeline for Creator Outreach with Human Review
Sending personalized cold outreach to hundreds of YouTube creators every week is a grind. Researching each channel, writing a subject line that doesn’t sound copy-pasted, and tracking who’s already been contacted eats up hours that a solo operator or small team simply doesn’t have. This post walks through a working AI-powered cold email outreach automation pipeline built with Claude and the Gmail API — one that generates personalized drafts automatically but never sends a single email without a human checking it first.
The system takes a daily CSV or XLSX export of creator prospects, runs each one through a Claude-based agent to draft a personalized email, and drops it into Gmail as a draft. A separate, manual “Send” step is the only thing that actually dispatches mail. That separation isn’t an afterthought — it’s the core design decision the entire pipeline is built around.
Why Fully Autonomous Cold Outreach Is a Bad Idea (And What to Build Instead)
It’s tempting to build an outreach bot that goes end-to-end: scrape prospects, generate copy, hit send. But cold email is exactly the kind of task where a single bad AI output — a wrong name, an off-tone joke, a hallucinated detail about someone’s channel — becomes a public, unrecoverable mistake. You can’t un-send an email, and a bad one can burn a relationship or get you flagged as spam.
This pipeline solves that by splitting the work into two workflows: Prepare and Send. Prepare does all the heavy lifting — scanning, cleaning, deduping, and AI drafting — but its output is a Gmail Draft, not a sent email. Nothing leaves the outbox until a human opens Gmail (or a compiled Markdown preview) and approves it. Only then does the separate Send workflow fire.
This is a deliberate safety net, not a limitation. The AI is trusted to do the creative, time-consuming part — research synthesis and copywriting — while the irreversible action (sending) stays gated behind human judgment. It also means the system degrades gracefully: if Claude produces a mediocre draft, the cost is a slightly awkward Gmail draft sitting in a folder, not an email that already reached someone’s inbox.
How the Two-Stage Prepare/Send Pipeline Works
The Prepare workflow (creator_outreach_prepare.js, run on the NORA workflow engine) chains together a series of small, single-purpose Python scripts rather than one monolithic script. Each stage does one job and hands off to the next through pointer files instead of passing data directly in memory:
scan_incoming_csv.pyfinds the newest CSV/XLSX in the drop folder and writes its path tocurrent_csv.txtnormalize_incoming.pyconverts XLSX to CSV if neededfilter_and_dedupe.pycleans the dataprepare_email_queue.pybreaks it into per-prospect JSON filesai_personalize_agent.pydrafts each emailcreate_gmail_drafts.pypushes drafts into Gmail
Why pointer files instead of piping data between scripts? It makes every stage independently runnable and testable. You can re-run filter_and_dedupe.py on its own, inspect its output, and debug it in isolation — you don’t need to replay the entire pipeline to see what one step did. It’s a small architectural choice, but it’s the difference between a pipeline you can actually maintain and one you have to run as a black box.
Each script also follows a strict contract: exit 0 always, and print a machine-readable keyword to stdout — SCAN_FOUND/SCAN_EMPTY, FILTER_HAS_ROWS/FILTER_EMPTY, and so on. Instead of relying on process exit codes (which many workflow engines handle inconsistently across platforms), NORA branches on parsed stdout text. It’s a pragmatic pattern for anyone orchestrating Python scripts from a workflow engine: make the output self-describing rather than trusting exit codes alone.
Building the Prepare Workflow: Scan, Normalize, Filter, and Dedupe Before Calling Claude
Before any AI call happens, the pipeline spends real effort making sure it doesn’t need to call Claude unnecessarily. This matters because every Claude call costs tokens and money, and there’s no reason to personalize an email for someone you’ve already contacted or someone whose email address is unusable.
normalize_incoming.py handles the messy reality of spreadsheet exports. XLSX files may have multiple sheets, so the script reads them with openpyxl in read_only + data_only mode and merges them into a single CSV by accumulating a deduped, order-preserving union of column headers. CSVs are read with utf-8-sig encoding specifically to strip BOM markers that non-US Excel exports often prepend — without this, accented names and emoji in channel descriptions can silently corrupt.
filter_and_dedupe.py is where the real cost-saving logic lives:
EMAIL_RE = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$')
def valid_single_email(raw_field):
parts = re.split(r'[,;]', raw_field.strip())
parts = [p.strip() for p in parts if p.strip()]
if len(parts) != 1:
return None # multiple addresses = too ambiguous, drop it
return parts[0] if EMAIL_RE.match(parts[0]) else None
Notice that rows with multiple comma/semicolon-separated emails are dropped entirely, not resolved by picking the first one. Guessing which of two emails is the “right” contact is exactly the kind of ambiguity that shouldn’t be automated for cold outreach — a wrong guess means emailing the wrong person or a shared inbox that ignores it. Dropping the row is the safer default.
The dedupe step then cross-references every remaining row against contacted.csv, the permanent history ledger, before anything reaches the AI stage. Filtering out already-contacted prospects before the Claude call — not after — is what actually saves API spend.
Personalizing Emails with Claude and Creating Safe Gmail Drafts
Once the queue is clean, prepare_email_queue.py writes one JSON file per surviving prospect, using a filesystem-safe slug plus a zero-padded index so filenames stay unique and ordered:
def make_filename(row_index, channel_title):
slug = re.sub(r'[^A-Za-z0-9_-]+', '_', channel_title).strip('_')
return f"{row_index:04d}_{slug}.json"
ai_personalize_agent.py reads each queued JSON and calls Claude (Sonnet) to generate a subject line and body tailored to that specific channel — using details like channel title, description, and subscriber count already present in the prospect data. The result is written to drafts/ready/, again as JSON, keeping every stage file-based and inspectable.
create_gmail_drafts.py then turns each ready draft into an actual Gmail draft using the Gmail API’s OAuth desktop flow, with a refresh token persisted to gmail_token.json and scopes limited to compose/send/modify — nothing broader than the pipeline actually needs. Messages are built manually with email.mime.text.MIMEText and base64url-encoded into the API’s raw format, rather than relying on a higher-level mail library — a choice that keeps the dependency surface small and the message construction fully transparent.
Crucially, the script calls drafts.create, not messages.send. This is what makes the human-review step meaningful: a person can open the draft in Gmail, edit a sentence, fix a name, or delete it entirely, and none of that gets overwritten later. For quick review outside Gmail itself, preview_drafts_md.py compiles every ready draft into one Markdown file — handy for skimming forty drafts at once instead of clicking through Gmail one by one.
Sending Approved Drafts and Logging Contact History to Prevent Re-Outreach
The Send workflow is intentionally separate and manual. send_approved_drafts.py reads the pending_send.json manifest (written earlier with draft IDs), calls drafts.send for each one, and — because sending went through the drafts API — any edits made in the Gmail UI are preserved in what actually goes out.
After sending, each recipient is appended to contacted.csv, an append-only ledger with a fixed schema: email, channel_id, channel_title, channel_url, subject, sent_at, gmail_message_id. This file does double duty — it’s both the permanent outreach history and the exact dataset that filter_and_dedupe.py checks against on the next run, which is what actually prevents accidentally re-emailing someone next week.
The script also archives the pending_send.json manifest with a timestamp after sending, so there’s a durable audit trail of exactly which batch went out and when — useful if you ever need to answer “did we already email this person, and what did we say?”
Getting Started
If you’re building something similar, the pattern worth borrowing isn’t the specific tools — it’s the shape of the pipeline: cheap, deterministic filtering before any AI call; file-based handoffs between stages for debuggability; and an irreversible action (sending) gated behind a separate, human-triggered step. You’ll need an Anthropic API key, a Gmail OAuth desktop client credential, and a workflow engine like NORA to schedule the Prepare run and gate the Send run behind manual approval. Start by getting the scan-normalize-filter chain working and inspectable on its own before wiring in Claude — the dedupe logic is where most of the real value (and cost savings) actually lives.