I built an outreach system where Claude drafts personalized cold emails using real enrichment data about each company — not template fill.
**The problem:** Every “AI email” tool I tried produces the same generic output. “Hi {first_name}, I noticed your company is growing…” That’s not personalization — it’s mail merge with extra steps.
**What this workflow does:**
1. Takes a lead ID from a webhook trigger
2. Fetches the full lead record from Supabase (company, contact, industry, location, website)
3. Fetches all previously sent emails to this lead (so Claude never repeats a subject line or angle)
4. Builds a Claude prompt with industry-specific pain points and opening hooks
5. Claude drafts the email with anti-spam rules enforced
6. A quality gate checks for banned words, fabricated statistics, and word count
7. Saves the draft to Supabase for human review before sending
**The key insight:** I maintain 5 specific pain points and 5 opening hooks for each of my 7 target industries. The prompt picks one based on the lead ID (deterministic but varied). For law firms, that might be “deadline tracking spread across calendars, spreadsheets, and people’s heads.” For medical practices, “insurance verification calls eating your front desk staff alive.”
This means two law firms in the same city get emails referencing different operational challenges — not the same template with the company name swapped.
**Anti-spam rules baked into the prompt:**
- Banned words: streamline, optimize, leverage, game-changer, transform, unlock
- No fabricated statistics or percentages
- No “I noticed your…” or “I was impressed by…” openers
- Subject lines under 50 characters, no ALL CAPS, no exclamation marks
- Body under 120 words
**Quality gate (Code node after Claude):**
```javascript
const wordCount = body.split(/\s+/).length;
const hasSpamWords = /streamline|optimize|leverage|game.changer/i.test(body);
const hasFakeStats = /\d{2,3}%/.test(body);
return {
quality: {
word_count: wordCount,
has_spam_words: hasSpamWords,
has_fake_stats: hasFakeStats,
passed: wordCount <= 150 && !hasSpamWords && !hasFakeStats
}
};
```
**Important n8n-specific lesson:** If you’re on n8n Cloud, use `$vars` for all credential references, NOT `$env`. `$env` silently returns undefined on Cloud — I spent days debugging why my enrichment data was empty. Everything looked correct, but `$env.SUPABASE_URL` was returning nothing.
**Architecture:**
```
Webhook → Fetch Lead (Supabase) ─┐
├→ Merge → Build Prompt → Claude → Quality Gate → Save Draft
Fetch Prior Emails ──────┘
```
The Merge node is critical — without it, Build Prompt fires before Fetch Prior Emails completes, and Claude doesn’t know what subjects to avoid.
I use this as part of a larger CRM I built for my consulting practice. The full system includes lead scraping from Google Maps, multi-source enrichment, this drafter, an approval queue UI, and automated follow-up sequences. Happy to share any of those individual workflows if there’s interest.
**Stack:** n8n Cloud, Claude Sonnet 4, Supabase, SerpAPI, Hunter.io, Resend
If anyone’s built something similar or has improvements, I’d love to hear about them — especially around deliverability and warm-up strategies.