DEV Community

Vasquez MyGuy
Vasquez MyGuy

Posted on

I Automated 100 Cold Emails a Day With Python — Here's the Complete System

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

Why no AI here? Two reasons:

  1. Speed. Personalizing 100 emails takes 0.3 seconds, not 5 minutes.
  2. 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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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.
  2. Do not use the same template for more than 200 emails. Rotate between 3-5 templates minimum.
  3. Do not send on weekends. Reply rate drops 60% and spam filters are more aggressive.
  4. Do not use URL shorteners in body text. Major red flag for spam filters.
  5. 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
Enter fullscreen mode Exit fullscreen mode

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)