What You'll End Up With
- A Python script that sends personalized cold emails via SMTP
- IMAP inbox monitoring that detects replies and bounces automatically
- Follow-up sequence scheduler (3-day nudge + 7-day final nudge)
- MX record validation (don't send to domains that can't receive email)
- Bounce tracking with categorized failure reasons
- A CSV pipeline that tracks every prospect's status
Prerequisites
- A domain with email setup (Zoho Mail free tier works, or Gmail/Outlook)
- DNS records: SPF, DKIM, and DMARC configured
- Python 3.12+ on any machine
- A list of prospects (name, website, email)
The 5 Mistakes We Made (Don't Repeat These)
- Guessed email addresses. We fired
[email protected]at 30 domains. 45% of those domains had no MX records or noinfo@mailbox. Only email addresses found directly on the website. - 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.
- No DMARC enforcement.
p=nonetells receivers "don't enforce" = we looked suspicious. Usep=quarantineat minimum. - 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.
- 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]
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.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
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
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.
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"""
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
"I found issues on your website that might be costing you customers. I can fix them for $15/month."
"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
| Check | Command | Expected |
|---|---|---|
| SPF record | nslookup -type=TXT yourdomain.com | Contains v=spf1 |
| DKIM record | Check with provider | Verified |
| DMARC record | nslookup -type=TXT _dmarc.yourdomain.com | p=quarantine |
| SMTP works | Send test email | Received in inbox |
| IMAP works | Run check_inbox.py | No errors |
| Daily limit | Check sent count | 5 or fewer |
Cost Breakdown
| Item | Cost | Notes |
|---|---|---|
| Zoho Mail (free tier) | $0 | Up to 5 users, 30/day limit |
| SendGrid (if you need volume) | $0-15/mo | 100/day free, $15/mo for 10k/month |
| Domain | $10/yr | Already have one |
| dnspython | Free | MX validation |
| Total (starter) | $0/mo | 5 emails/day max |
Key Lessons
- Warm up your domain. 5 emails/day for 2 weeks, then scale slowly.
- Only email addresses found on the actual website. No
info@guessing. - Validate MX records before sending. 24% of bounces are from domains with no MX.
- Lead with value, not a pitch. "I found issues" = spam. "Here's a free audit" = useful.
- Include unsubscribe in every email. CAN-SPAM compliance is non-negotiable.
- Track everything. Sent, bounced, replied, unsubscribed - all in the CSV.
- One spam complaint kills the channel. Don't risk it for one more send.