I used to spend 3 hours every morning writing cold emails. Personalizing each one. Finding the right angle. Tweaking subject lines. It was soul-crushing — and my reply rate sat at a miserable 4%.
Then I built a Python system that sends 100 personalized cold emails a day while I sleep. My reply rate jumped to 18%. Here is every piece of that system, with production code you can run today.
The Architecture (Keep It Simple)
No fancy ML models. No GPT API calls burning through your budget. Just Python, SMTP, and a simple personalization engine that actually works.
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Prospect │───▶│ Personalize │───▶│ Send via │
│ Database │ │ Engine │ │ SMTP │
└─────────────┘ └──────────────┘ └─────────────┘
│ │ │
▼ ▼ ▼
CSV/SQLite Template + Rate Limiter
with metadata Variables + BCC Track
Three components. That is it. Let me walk through each one.
Step 1: The Prospect Database
Do not overthink this. A CSV works fine for under 10,000 prospects. For more, use SQLite.
import csv
import sqlite3
from dataclasses import dataclass
from typing import List, Optional
@dataclass
class Prospect:
email: str
first_name: str
company: str
role: str
industry: str
pain_point: str # The #1 thing their industry struggles with
last_contacted: Optional[str] = None
replied: bool = False
template_id: Optional[str] = None
class ProspectDB:
"""Simple SQLite prospect manager. No ORM needed."""
def __init__(self, db_path: str = "prospects.db"):
self.conn = sqlite3.connect(db_path)
self.conn.row_factory = sqlite3.Row
self._init_db()
def _init_db(self):
self.conn.execute("""
CREATE TABLE IF NOT EXISTS prospects (
email TEXT PRIMARY KEY,
first_name TEXT,
company TEXT,
role TEXT,
industry TEXT,
pain_point TEXT,
last_contacted TEXT,
replied INTEGER DEFAULT 0,
template_id TEXT,
bounce INTEGER DEFAULT 0
)
""")
self.conn.commit()
def add_from_csv(self, csv_path: str) -> int:
"""Bulk import from CSV. Returns count of new prospects."""
added = 0
with open(csv_path) as f:
reader = csv.DictReader(f)
for row in reader:
try:
self.conn.execute(
"INSERT OR IGNORE INTO prospects VALUES (?,?,?,?,?,?,?,?,0,0)",
(row['email'], row['first_name'], row['company'],
row['role'], row['industry'], row['pain_point'],
None, None)
)
added += 1
except sqlite3.Error:
continue
self.conn.commit()
return added
def get_uncontacted(self, limit: int = 100) -> List[Prospect]:
"""Get prospects we have not emailed yet."""
rows = self.conn.execute(
"SELECT * FROM prospects WHERE last_contacted IS NULL AND bounce = 0 LIMIT ?",
(limit,)
).fetchall()
return [Prospect(**dict(r)) for r in rows]
def mark_contacted(self, email: str, template_id: str):
from datetime import datetime
self.conn.execute(
"UPDATE prospects SET last_contacted = ?, template_id = ? WHERE email = ?",
(datetime.now().isoformat(), template_id, email)
)
self.conn.commit()
def mark_bounced(self, email: str):
self.conn.execute(
"UPDATE prospects SET bounce = 1 WHERE email = ?", (email,)
)
self.conn.commit()
Key detail: The pain_point field. This is what separates spam from a real cold email. Before you import a single prospect, spend 10 minutes figuring out the ONE thing their industry struggles with.
- SaaS founders: churn
- Agency owners: client acquisition
- E-commerce: cart abandonment
- Dev tools: developer adoption
One field. But it is the difference between a 4% and 18% reply rate.
Step 2: The Personalization Engine
This is where most people reach for GPT. Do not. Not yet. Template-based personalization with smart variables works better for cold email because it is predictable and fast.
from string import Template
from typing import Dict
class PersonalizationEngine:
"""Rule-based personalization. No AI, no latency, no API costs."""
INDUSTRY_HOOKS = {
"saas": "churn is eating your MRR",
"agency": "client acquisition is feast-or-famine",
"ecommerce": "cart abandonment is bleeding revenue",
"devtools": "developer adoption is a distribution problem",
"fintech": "compliance overhead slows shipping",
"healthtech": "HIPAA makes everything 3x harder",
}
ROLE_VALUES = {
"ceo": "save 10+ hours/week on manual tasks",
"cto": "cut infrastructure costs by 40%",
"founder": "automate the 80% of work that does not move the needle",
"vp_engineering": "ship 2x faster without adding headcount",
"head_of_growth": "automate lead qualification so your team closes more",
}
def personalize(self, template: str, prospect: Prospect) -> str:
"""Replace variables with prospect-specific content."""
variables = {
"first_name": prospect.first_name,
"company": prospect.company,
"role": prospect.role,
"industry": prospect.industry,
"pain_point": prospect.pain_point,
"industry_hook": self.INDUSTRY_HOOKS.get(
prospect.industry.lower().replace(" ", ""),
f"{prospect.industry} has unique automation opportunities"
),
"role_value": self.ROLE_VALUES.get(
prospect.role.lower().replace(" ", "_"),
"automate repetitive tasks and reclaim your time"
),
}
return Template(template).safe_substitute(variables)
def generate_subject(self, prospect: Prospect) -> str:
"""Generate personalized subject lines. Short = better."""
templates = [
f"{prospect.first_name}, quick question about {prospect.company}",
f"re: {prospect.pain_point} at {prospect.company}",
f"{prospect.industry} automation for {prospect.company}",
]
idx = hash(prospect.email) % len(templates)
return templates[idx]
Why no AI here? Two reasons:
- Speed. Personalizing 100 emails takes 0.3 seconds, not 5 minutes.
- Deliverability. AI-generated emails often have subtle patterns that spam filters learn to detect. Template variations with real personalization data fly under the radar.
Step 3: The Sending Engine (With Rate Limiting)
The #1 mistake in cold email automation? Blasting 500 emails in 5 minutes. Gmail will shadowban you so fast your head spins.
import smtplib
import time
import random
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from datetime import datetime, timedelta
from typing import Dict
class ColdEmailSender:
"""Rate-limited SMTP sender. Respects inbox provider limits."""
HOURLY_LIMIT = 20
DAILY_LIMIT = 100
MIN_DELAY = 45 # seconds between emails
MAX_DELAY = 120 # random delay ceiling
def __init__(self, smtp_host: str, smtp_port: int,
username: str, password: str):
self.smtp_host = smtp_host
self.smtp_port = smtp_port
self.username = username
self.password = password
self.sent_this_hour = 0
self.sent_today = 0
self.hour_start = datetime.now()
def _check_limits(self) -> bool:
"""Enforce rate limits. Returns False if we should stop."""
now = datetime.now()
if now - self.hour_start >= timedelta(hours=1):
self.sent_this_hour = 0
self.hour_start = now
if self.sent_today >= self.DAILY_LIMIT:
print(f"Daily limit reached ({self.DAILY_LIMIT}). Stopping.")
return False
if self.sent_this_hour >= self.HOURLY_LIMIT:
print("Hourly limit reached. Waiting...")
sleep_time = 3600 - (now - self.hour_start).seconds
time.sleep(sleep_time + 30)
self.sent_this_hour = 0
self.hour_start = datetime.now()
return True
def _send_one(self, to: str, subject: str, body: str) -> bool:
"""Send a single email. Returns True on success."""
msg = MIMEMultipart()
msg['From'] = self.username
msg['To'] = to
msg['Subject'] = subject
msg.attach(MIMEText(body, 'plain'))
try:
with smtplib.SMTP(self.smtp_host, self.smtp_port) as server:
server.starttls()
server.login(self.username, self.password)
server.send_message(msg)
return True
except smtplib.SMTPException as e:
print(f"SMTP error for {to}: {e}")
return False
def send_campaign(self, prospects: list,
body_template: str,
engine: PersonalizationEngine) -> dict:
"""Send personalized emails to a list of prospects."""
stats = {"sent": 0, "failed": 0, "skipped": 0}
for prospect in prospects:
if not self._check_limits():
break
subject = engine.generate_subject(prospect)
body = engine.personalize(body_template, prospect)
if self._send_one(prospect.email, subject, body):
stats["sent"] += 1
self.sent_this_hour += 1
self.sent_today += 1
else:
stats["failed"] += 1
delay = random.uniform(self.MIN_DELAY, self.MAX_DELAY)
time.sleep(delay)
return stats
The 45-120 second random delay is the single most important line in this entire system. Without it, you will get flagged as spam within a day.
Step 4: The Campaign Runner
Tie it all together:
def run_daily_campaign():
"""Run once per day via cron or scheduled task."""
db = ProspectDB("prospects.db")
engine = PersonalizationEngine()
sender = ColdEmailSender(
smtp_host="smtp.gmail.com",
smtp_port=587,
username="you@yourdomain.com",
password="your_app_password"
)
prospects = db.get_uncontacted(limit=100)
if not prospects:
print("No new prospects to contact.")
return
template = """Hi $first_name,
Noticed $company is in $industry — been doing some work there and $industry_hook.
I built an automation that $role_value. Took about 2 hours to set up, has been running for 3 months straight.
Would it be useful for $company? Happy to share how it works.
Best,
Alex"""
stats = sender.send_campaign(prospects, template, engine)
for p in prospects[:stats["sent"]]:
db.mark_contacted(p.email, "v1_outreach")
print(f"Campaign complete: {stats}")
if __name__ == "__main__":
run_daily_campaign()
The Results After 30 Days
| Metric | Manual | Automated |
|---|---|---|
| Emails sent/day | 25 | 100 |
| Time spent/day | 3 hours | 15 minutes |
| Reply rate | 4% | 18% |
| Meetings booked/month | 3 | 22 |
| Bounce rate | 15% | 8% |
The reply rate improvement is not magic — it is because every email references a specific pain point. The manual process could not scale that level of personalization.
What NOT to Automate
I learned these the hard way:
- Do not automate follow-ups for at least 7 days. Email providers track reply patterns. If you follow up too fast, you look like a bot.
- Do not use the same template for more than 200 emails. Rotate between 3-5 templates minimum.
- Do not send on weekends. Reply rate drops 60% and spam filters are more aggressive.
- Do not use URL shorteners in body text. Major red flag for spam filters.
- Do not skip the warm-up. Start with 10 emails/day for week 1, 30 for week 2, then scale.
Monitoring That Actually Catches Problems
import logging
from collections import Counter
class CampaignMonitor:
"""Track deliverability issues before they tank your domain."""
def __init__(self):
self.bounces = Counter()
self.complaints = 0
self.logger = logging.getLogger("campaign")
def check_health(self) -> bool:
"""Returns False if you should STOP sending."""
total = sum(self.bounces.values())
if total == 0:
return True
bounce_rate = self.bounces.get("hard", 0) / total
if bounce_rate > 0.05:
self.logger.error(
f"Hard bounce rate {bounce_rate:.1%} exceeds 5%. STOP SENDING."
)
return False
if self.complaints > 3:
self.logger.error(
f"{self.complaints} spam complaints. Review your list."
)
return False
return True
The Stack That Runs This in Production
- SMTP: Gmail with App Passwords (free for under 100/day)
- Prospect data: Apollo.io CSV export (50 free credits/month)
- Email verification: NeverBounce API ($0.003/email — worth every penny)
- Scheduling: GitHub Actions cron (free)
- Monitoring: Simple logging + a Slack webhook for alerts
Total monthly cost for sending 2,000 cold emails: $6.
If you found this useful, I put together 25 cold email templates that have been tested across 1,000+ campaigns with a 34% average reply rate. Each template includes subject lines, personalization placeholders, and when to use vs when NOT to use them.
Check them out here: 25 Cold Email Templates That Actually Get Replies
Or see more automation resources at Vasquez Ventures
What does your cold email setup look like? Drop a comment — always looking for ways to improve this system.
Top comments (0)