FREE GUIDE Plan 1c · Tested 2026-07-22

Set Up Automated Email
Outreach (The Right Way)

45 minutes · 10 steps · Built with Python + Zoho Mail + IMAP · Includes our mistakes so you avoid them

Want the full version? SendGrid integration, A/B testing, reply sentiment analysis, and CAN-SPAM compliance templates.

Get the Full Pack →

What You'll End Up With

READ THIS FIRST. We built this system and made every mistake in the book. 97% bounce rate. 0% reply rate. Domain got blocklisted by 7 servers. This guide includes hard-won lessons from our failures so you don't repeat them.

Prerequisites

The 5 Mistakes We Made (Don't Repeat These)

  1. Guessed email addresses. We fired [email protected] at 30 domains. 45% of those domains had no MX records or no info@ mailbox. Only email addresses found directly on the website.
  2. Cold-blasted from a new domain. 30 emails in 2 days from a domain registered days earlier. Spam filters flagged us instantly. Warm up: 5 emails/day, increase weekly.
  3. No DMARC enforcement. p=none tells receivers "don't enforce" = we looked suspicious. Use p=quarantine at minimum.
  4. Used a template that looks like spam. "I found issues on your website" is the #1 SEO scammer template. Lead with value, not a teaser.
  5. Used free Zoho tier for bulk sending. Got rate-limited and blocked within 24 hours. Use SendGrid or Mailgun for any volume > 5/day.

Step 1: Configure Email Authentication

Before sending a single email, set up these DNS records:

SPF (Sender Policy Framework)

# TXT record on yourdomain.com
v=spf1 include:_spf.zoho.com ~all

DKIM (DomainKeys Identified Mail)

Follow your email provider's guide. For Zoho: Settings → Mail → DKIM Authentication. Copy the TXT record to your DNS.

DMARC (Domain-based Message Authentication)

# TXT record on _dmarc.yourdomain.com
v=DMARC1; p=quarantine; rua=mailto:[email protected]; ruf=mailto:[email protected]
Start with p=none for 48 hours to collect reports without rejecting mail. Then move to p=quarantine. Move to p=reject after 2 weeks of clean reports.
Verify: Use MXToolbox DMARC Check to confirm all three records are live.

Step 2: MX Record Validation

Before sending to any email address, check that the domain can actually receive mail:

import dns.resolver

def has_mx_records(domain):
    """Check if a domain has MX records (can receive email)."""
    try:
        answers = dns.resolver.resolve(domain, 'MX')
        return len(answers) > 0
    except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
        return False
    except Exception:
        return False

# Before sending
if not has_mx_records("example.com"):
    print("SKIP: No MX records, domain can't receive email")
    continue

Install: pip install dnspython

This single check eliminates 24% of our bounces. If a domain has no MX records, info@ cannot exist. Don't waste a send.

Step 3: SMTP Verification (Optional but Recommended)

Check if the specific mailbox exists before sending:

import smtplib
import dns.resolver

def verify_email(email_addr):
    """Check if mailbox exists via SMTP RCPT TO."""
    domain = email_addr.split("@")[1]
    
    # Get MX records
    try:
        mx_records = dns.resolver.resolve(domain, 'MX')
        mx_host = str(mx_records[0].exchange)
    except Exception:
        return False
    
    # Connect and check
    try:
        server = smtplib.SMTP(mx_host, 25, timeout=10)
        server.ehlo("yourdomain.com")
        server.mail("[email protected]")
        code, msg = server.rcpt(email_addr)
        server.quit()
        return code == 250
    except Exception:
        return False
Some servers don't support this (they accept all addresses and bounce later). But it catches the obvious ones. Use it as a pre-filter, not a guarantee.

Step 4: The Sending Script

#!/usr/bin/env python3
"""Send personalized cold emails with bounce-safe practices."""
import smtplib
import csv
import time
import json
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart

SMTP_HOST = "smtp.zoho.com"
SMTP_PORT = 587
EMAIL_ADDR = "[email protected]"
EMAIL_PASS = "your_app_password"
DAILY_LIMIT = 5  # START LOW. Increase by 5 each week.

def send_email(to_addr, subject, body, from_name="Your Name"):
    msg = MIMEMultipart("alternative")
    msg["From"] = f"{from_name} <{EMAIL_ADDR}>"
    msg["To"] = to_addr
    msg["Subject"] = subject
    
    # Plain text version (always include)
    text_version = MIMEText(body, "plain", "utf-8")
    msg.attach(text_version)
    
    try:
        server = smtplib.SMTP(SMTP_HOST, SMTP_PORT)
        server.starttls()
        server.login(EMAIL_ADDR, EMAIL_PASS)
        server.sendmail(EMAIL_ADDR, [to_addr], msg.as_string())
        server.quit()
        return True
    except Exception as e:
        print(f"Send failed to {to_addr}: {e}")
        return False

# Main loop
with open("prospects.csv", "r") as f:
    reader = csv.DictReader(f)
    sent_today = 0
    
    for row in reader:
        if sent_today >= DAILY_LIMIT:
            print(f"Daily limit reached ({DAILY_LIMIT}). Stop.")
            break
        
        email = row["email"]
        name = row["name"]
        
        # Personalize heavily - NOT a template
        subject = f"Quick question about {row['website']}"
        body = f"""Hi {name},

I noticed {row['website']} has a few issues that might be costing you customers:

1. {row['issue_1']}
2. {row['issue_2']}

I put together a quick audit report showing exactly what's broken and how to fix it. Want me to send it over?

No strings attached - the report is free.

Best,
Your Name
yourdomain.com

---
Unsubscribe: Reply with 'unsubscribe' to stop receiving emails.
DAILY LIMIT IS 5. Not 50. Not 30. Five. For the first 2 weeks. Then 10. Then 20. Domain reputation takes weeks to build. One burst of 30 emails from a new domain = instant spam flag.

Step 5: IMAP Inbox Monitoring

#!/usr/bin/env python3
"""Check inbox for replies and bounces."""
import imaplib
import email
from email.header import decode_header

IMAP_HOST = "imap.zoho.com"
IMAP_PORT = 993
EMAIL_ADDR = "[email protected]"
EMAIL_PASS = "your_app_password"

def check_inbox():
    imap = imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT)
    imap.login(EMAIL_ADDR, EMAIL_PASS)
    imap.select("INBOX")
    
    # Search for bounces
    bounce_terms = [
        'FROM "mailer-daemon"',
        'FROM "postmaster"',
        'SUBJECT "undeliverable"',
        'SUBJECT "Delivery Status"',
    ]
    
    bounces = set()
    for term in bounce_terms:
        status, msgs = imap.search(None, term)
        if status == "OK" and msgs[0]:
            for mid in msgs[0].split():
                status, data = imap.fetch(mid, "(BODY.PEEK[HEADER.FIELDS (SUBJECT TO DATE)])")
                if status == "OK":
                    msg = email.message_from_bytes(data[0][1])
                    print(f"Bounce: {msg['Subject']} - {msg['Date']}")
                    bounces.add(mid)
    
    # Search for real replies
    status, msgs = imap.search(None, 'NOT FROM "[email protected]" NOT FROM "mailer-daemon" NOT FROM "postmaster"')
    replies = []
    if status == "OK" and msgs[0]:
        for mid in msgs[0].split():
            status, data = imap.fetch(mid, "(BODY.PEEK[HEADER.FIELDS (SUBJECT FROM DATE)])")
            if status == "OK":
                msg = email.message_from_bytes(data[0][1])
                from_addr = msg.get("From", "")
                if "mailer-daemon" not in from_addr.lower() and "postmaster" not in from_addr.lower():
                    replies.append({
                        "from": from_addr,
                        "subject": msg.get("Subject", ""),
                        "date": msg.get("Date", "")
                    })
    
    imap.logout()
    return bounces, replies

bounces, replies = check_inbox()
print(f"Bounces: {len(bounces)}")
print(f"Replies: {len(replies)}")
for r in replies:
    print(f"  REPLY: {r['from']} - {r['subject']}")

Step 6: Follow-Up Sequence

#!/usr/bin/env python3
"""Send follow-up nudges to non-responders."""
import csv
import os
from datetime import datetime, timedelta

def get_follow_ups():
    """Find prospects who need a follow-up."""
    with open("prospects.csv", "r") as f:
        reader = csv.DictReader(f)
        follow_3day = []
        follow_7day = []
        
        for row in reader:
            if row["status"] != "contacted":
                continue
            
            sent_date = datetime.fromisoformat(row["contacted_date"])
            days_since = (datetime.now() - sent_date).days
            
            if days_since == 3 and row.get("followup_3_sent") != "yes":
                follow_3day.append(row)
            elif days_since == 7 and row.get("followup_7_sent") != "yes":
                follow_7day.append(row)
    
    return follow_3day, follow_7day

# 3-day nudge
NUDGE_3 = """Hi {name},

Just checking if you saw my email about {website}. I don't want to be a pest - if this isn't relevant, just reply 'no thanks' and I'll stop.

Best,
Your Name"""

# 7-day final nudge
NUDGE_7 = """Hi {name},

Last time I'll reach out about {website}. The audit report is still ready if you want it - just reply 'yes' and I'll send it over.

Best,
Your Name"""
Key principle: Follow-ups should be shorter than the original. Each one gets progressively more brief. The 7-day nudge is the last one - don't keep emailing after that.

Step 7: Cron Jobs

# Inbox check - every hour
0 * * * * cd /opt/outreach && python3 check_inbox.py >> logs/inbox.log 2>&1

# Follow-ups - daily at 9 AM
0 9 * * * cd /opt/outreach && python3 follow_ups.py >> logs/followups.log 2>&1

# Daily send - 10 AM (respects daily limit)
0 10 * * * cd /opt/outreach && python3 send_daily.py >> logs/sent.log 2>&1

Step 8: The Right Outreach Template

BAD (what we used - 0% reply rate):
"I found issues on your website that might be costing you customers. I can fix them for $15/month."
GOOD (what we should have used):
"I noticed your menu PDF doesn't load on mobile - 60% of your visitors are on phones. I put together a 2-page audit showing the top 3 issues. Want me to send it over? Free, no catch."

The difference: value first, pitch never. Don't ask for money in the first email. Don't even mention your product. Just offer something useful for free.

Verification Checklist

CheckCommandExpected
SPF recordnslookup -type=TXT yourdomain.comContains v=spf1
DKIM recordCheck with providerVerified
DMARC recordnslookup -type=TXT _dmarc.yourdomain.comp=quarantine
SMTP worksSend test emailReceived in inbox
IMAP worksRun check_inbox.pyNo errors
Daily limitCheck sent count5 or fewer

Cost Breakdown

ItemCostNotes
Zoho Mail (free tier)$0Up to 5 users, 30/day limit
SendGrid (if you need volume)$0-15/mo100/day free, $15/mo for 10k/month
Domain$10/yrAlready have one
dnspythonFreeMX validation
Total (starter)$0/mo5 emails/day max

Key Lessons

This is the free version. The full Operator Playbook Pack includes SendGrid integration, A/B testing, reply sentiment analysis, and CAN-SPAM compliance templates.

Get the Full Pack →