TC
Troy’s Tech Corner
build tech2026-04-079 min read

SendGrid + Python: Building Cold Outreach That Doesn't Land in Spam

Troy Brown

Written by Troy Brown

Troy writes beginner-friendly guides, practical gear advice, and hands-on tech walkthroughs designed to help real people make smarter decisions and build with more confidence.

SendGrid + Python: Building Cold Outreach That Doesn't Land in Spam

Cold email has a bad reputation, and most of the time it deserves it. The average cold outreach pipeline I see from solo founders looks like this: scrape a list, dump it into a Google Sheet, send 500 identical emails through their personal Gmail, get throttled by hour two, watch the open rates crater, and conclude that "cold email doesn't work."

Cold email works fine. What doesn't work is treating it like a fire-and-forget script. If you want to send outreach at any kind of volume — even just 50–100 emails a day for a small lead-gen business — you need infrastructure. You need a sender reputation, proper authentication, smart throttling, personalization that doesn't look templated, and tracking that tells you what's actually landing.

This guide walks through building that infrastructure in Python using SendGrid. By the end, you'll have a working pipeline that sends personalized outreach, respects deliverability best practices, tracks opens and clicks, and gives you the data to iterate. I built a version of this for my own lead-gen pipeline, and it's the difference between cold email being a liability and cold email being a real channel.

Why SendGrid (And Not Just Gmail)

The first thing most people ask: why pay for a service when I can just send through my Gmail account?

Because Gmail will rate-limit you at around 500 emails per day on a personal account, and far less if you're sending to addresses you've never contacted before. More importantly, when you send cold outreach from your personal Gmail, you're spending the deliverability reputation of an inbox you actually need. One spam complaint can land your real email address in trouble for weeks.

SendGrid (and similar services like Postmark, Mailgun, Amazon SES) gives you a few things Gmail won't:

  • Dedicated sending infrastructure with its own IP reputation, separate from your personal inbox
  • Proper authentication — SPF, DKIM, and DMARC records you can configure on a subdomain
  • Webhooks for tracking opens, clicks, bounces, and spam complaints in real time
  • Suppression management so you never accidentally email someone who unsubscribed or bounced
  • Higher volume limits that scale with your plan

The free tier gives you 100 emails per day, which is enough to test the entire pipeline. The Essentials plan starts at around $20/month for 50,000 emails. For most solo lead-gen operations, that's more than enough.

A note: SendGrid is owned by Twilio now, and their cold-email policies are stricter than they used to be. You can absolutely use it for outreach, but you need to follow the rules — accurate sender identity, valid unsubscribe, no scraped lists from sketchy sources, and you have to actually be sending things people might want. If you're spamming, they'll shut you down. That's a feature, not a bug.

Setting Up SendGrid the Right Way

Before you write a single line of Python, get the foundation right. This is the part most people skip, and it's the reason their emails land in spam.

Use a subdomain for sending

Don't send from your main domain. Set up a subdomain like mail.yourdomain.com or outreach.yourdomain.com and configure SendGrid to send from there. This protects your main domain's reputation if something goes wrong, and it makes domain authentication cleaner.

Authenticate the domain

In SendGrid's settings, go to Sender Authentication and walk through Domain Authentication. It will give you a set of CNAME records to add to your DNS. These set up SPF and DKIM, which tell receiving mail servers that SendGrid is allowed to send on behalf of your domain. Without this, your emails are flagged as suspicious before they even hit a spam filter.

Add a DMARC record

DMARC tells receiving servers what to do with emails that fail SPF or DKIM checks. Start with a permissive policy and tighten it later:

v=DMARC1; p=none; rua=mailto:dmarc-reports@yourdomain.com

The p=none policy means "monitor but don't reject," which is the right starting point. Once you've confirmed everything is authenticating cleanly for a few weeks, you can move to p=quarantine and then p=reject.

Warm up the IP and domain

This is the part nobody wants to hear: you can't send 500 cold emails on day one and expect them to land. New sending infrastructure has no reputation, and inbox providers are suspicious of any domain that suddenly starts sending volume. Start small. Send 20 emails the first day, 30 the next, 50 the day after. Ramp up over two to three weeks. SendGrid has automated IP warmup tools on higher plans, but for solo volume, manual warmup is fine — just be patient.

The Python Pipeline

Here's the structure I use. It has four parts: a contact loader, a template engine, the SendGrid sender, and an event handler for tracking what happens after the send.

Install the basics

pip install sendgrid python-dotenv jinja2 sqlalchemy

sendgrid is the official Python client. python-dotenv keeps your API key out of your code. jinja2 handles templating. sqlalchemy gives you a clean way to track contacts and events in SQLite.

Project structure

outreach/
├── .env
├── config.py
├── db.py
├── templates/
│   └── intro.txt
│   └── intro.html
├── sender.py
├── webhook_handler.py
└── run.py

Keep it small. You don't need a framework for this.

The .env file

SENDGRID_API_KEY=SG.your_key_here
FROM_EMAIL=troy@mail.yourdomain.com
FROM_NAME=Troy
REPLY_TO=troy@yourdomain.com

Never commit this. Add it to .gitignore immediately.

The sender module

This is the core. Here's a stripped-down version of what it looks like:

import os
from sendgrid import SendGridAPIClient
from sendgrid.helpers.mail import Mail, Email, To, Content, ReplyTo
from jinja2 import Environment, FileSystemLoader
from dotenv import load_dotenv

load_dotenv()

class OutreachSender:
    def __init__(self):
        self.client = SendGridAPIClient(os.environ["SENDGRID_API_KEY"])
        self.from_email = os.environ["FROM_EMAIL"]
        self.from_name = os.environ["FROM_NAME"]
        self.reply_to = os.environ["REPLY_TO"]
        self.env = Environment(loader=FileSystemLoader("templates"))

    def render(self, template_name, context):
        return self.env.get_template(template_name).render(**context)

    def send(self, to_email, to_name, subject, template, context):
        text_body = self.render(f"{template}.txt", context)
        html_body = self.render(f"{template}.html", context)

        message = Mail(
            from_email=Email(self.from_email, self.from_name),
            to_emails=To(to_email, to_name),
            subject=subject,
            plain_text_content=Content("text/plain", text_body),
            html_content=Content("text/html", html_body),
        )
        message.reply_to = ReplyTo(self.reply_to)

        # Custom args let you tag emails for tracking
        message.custom_args = {
            "campaign": context.get("campaign", "default"),
            "contact_id": str(context.get("contact_id", "")),
        }

        response = self.client.send(message)
        return response.status_code

A few things worth noting:

  • Always send both plain text and HTML. Spam filters penalize HTML-only emails. Plain text fallback is non-negotiable.
  • Use a real reply-to address. If your from address is on a sending subdomain, set the reply-to to your actual inbox so replies come back to you.
  • Custom args are gold. They get attached to webhook events, so when SendGrid tells you an email was opened, you know exactly which campaign and contact it belonged to.

The template

Here's where personalization happens. Jinja2 lets you write templates that interpolate per-contact data without hand-concatenating strings.

templates/intro.txt:

Hi {{ first_name }},

I came across {{ business_name }} while looking at {{ industry }} businesses
in {{ city }}, and I noticed your current site doesn't have {{ missing_feature }}.

I run a small studio that builds high-converting landing pages for local
{{ industry }} businesses. I put together a free preview of what an upgraded
site could look like for you — no obligation, just thought you might be
interested.

You can see it here: {{ preview_url }}

If it's not for you, no worries. If you'd like to chat, just reply to this email.

Troy

The HTML version is the same content wrapped in minimal, clean HTML. Don't go overboard with images and heavy styling — plain-looking emails actually deliver better than designer-style HTML.

The runner

This is what you actually execute. It pulls contacts from your database, sends them in batches with delays, and logs the results.

import time
import random
from sender import OutreachSender
from db import get_pending_contacts, mark_sent

def run_campaign(campaign_name, daily_limit=50):
    sender = OutreachSender()
    contacts = get_pending_contacts(campaign_name, limit=daily_limit)

    for contact in contacts:
        try:
            context = {
                "first_name": contact.first_name,
                "business_name": contact.business_name,
                "industry": contact.industry,
                "city": contact.city,
                "missing_feature": contact.missing_feature,
                "preview_url": contact.preview_url,
                "campaign": campaign_name,
                "contact_id": contact.id,
            }

            subject = f"Quick idea for {contact.business_name}"

            status = sender.send(
                to_email=contact.email,
                to_name=contact.first_name,
                subject=subject,
                template="intro",
                context=context,
            )

            if status in (200, 202):
                mark_sent(contact.id)
                print(f"Sent to {contact.email}")

            # Randomized delay between 30-90 seconds
            time.sleep(random.uniform(30, 90))

        except Exception as e:
            print(f"Failed for {contact.email}: {e}")
            continue

if __name__ == "__main__":
    run_campaign("hvac_mcdonough", daily_limit=40)

The randomized delay matters. Sending one email every 30-90 seconds looks like a human. Sending 50 emails in 50 seconds looks like a bot, and inbox providers will start flagging you. You can send faster eventually, but during warmup, slow and natural is the rule.

Tracking What Actually Happens

The send is the easy part. Knowing what happens after is where the value is. SendGrid sends webhook events for every email — delivered, opened, clicked, bounced, spam-reported, unsubscribed. You need to capture those.

Set up the webhook endpoint

Configure a small Flask or FastAPI endpoint that SendGrid posts to whenever an event happens:

from flask import Flask, request, jsonify
from db import log_event

app = Flask(__name__)

@app.post("/sendgrid/webhook")
def handle_webhook():
    events = request.get_json()
    for event in events:
        log_event(
            email=event.get("email"),
            event_type=event.get("event"),
            timestamp=event.get("timestamp"),
            campaign=event.get("campaign"),
            contact_id=event.get("contact_id"),
            url=event.get("url"),  # for click events
        )
    return jsonify({"status": "ok"}), 200

In SendGrid's settings, point Event Webhook at https://yourdomain.com/sendgrid/webhook and enable the events you care about. For cold outreach, the important ones are: delivered, open, click, bounce, spam_report, unsubscribe.

Why this matters

Once you have event data flowing into your database, you can answer real questions:

  • Which subject lines have the highest open rate?
  • Which industries respond best?
  • What's my bounce rate by source list? (High bounces = bad list, kill it fast)
  • How many spam complaints am I getting per 1,000 sends? (Should be under 0.1%)
  • How long after a send does the average reply come in?

Without this data, you're flying blind. With it, you can iterate on the parts that matter and stop wasting sends on things that don't work.

Deliverability Rules That Actually Matter

A few hard-won lessons from running this in production:

Never buy a list. Bought lists are the fastest way to torch your sender reputation. Build your own from public sources — Google Maps, business directories, LinkedIn for B2B — and verify every address before sending.

Verify emails before sending. Use an email verification service (NeverBounce, ZeroBounce, BriteVerify) to check that addresses actually exist. A 5% bounce rate will get you flagged. Under 2% is the goal.

Always include an unsubscribe link. SendGrid handles this automatically if you enable it, but make sure it's there and that it works. Honoring unsubscribes isn't just legally required (CAN-SPAM, GDPR, CCPA) — it's the single biggest factor in long-term sender reputation.

Don't use spammy language. "FREE!!!", excessive caps, "limited time offer," and dollar signs in subject lines all trigger spam filters. Write like a human writing to a human.

Personalize beyond first name. The bar for personalization has gone up. "Hi {first_name}" doesn't cut it anymore. Reference something specific to the recipient — their business, their city, something on their current site. The Jinja2 templates above are designed for this; the data has to come from your scraping pipeline.

Send from a real person. "Troy at Signal Sites" gets opened. "Marketing Team" gets ignored. Use a real name, a real email, a real signature.

Cap volume per domain. If you're sending to 50 contacts at the same company, throttle it. Sending 50 emails to @bigcompany.com in an hour will get all 50 flagged.

What This Replaces

The reason I built this instead of using Lemlist or Instantly or any of the dozen hosted cold-email tools is control. Hosted tools are great until they're not. They charge per contact, they limit your customization, they shut down accounts when their algorithms get nervous, and you don't own your sending infrastructure. With SendGrid + Python, you own the whole stack. You can integrate it directly into your scraper, your CRM, your dashboard, and it costs maybe $20 a month at the volume most solo founders need.

It also forces you to actually understand deliverability instead of trusting a black box. The first time you see a real bounce rate or spam complaint chart in your own database, you start making better decisions about who you contact and how.

Where to Go From Here

Once the core pipeline is running, the natural next steps are:

  • Reply detection — parse incoming replies via the SendGrid Inbound Parse webhook so you can auto-pause sends when someone responds
  • Multi-touch sequences — schedule a follow-up two days after the initial send if there's no reply, then another four days later
  • A/B testing subject lines — split contacts into groups and track open rates per variant
  • Integration with your CRM — feed event data into a contact scoring system so hot leads bubble up automatically

But don't build any of that until the basics are solid. Send 500 emails through the simple version of this pipeline first. Watch the data. Fix what's broken. Then add complexity.

Cold email isn't dead, and it isn't magic. It's infrastructure. Build the infrastructure right and it becomes one of the cheapest, most controllable acquisition channels you have. Build it wrong and you'll spend six months wondering why nobody's responding. The difference is mostly in the boring details — authentication, warmup, list quality, throttling, and tracking. Get those right and the rest is just writing better emails.

Enjoyed this guide?

Get more beginner-friendly tech explanations and guides sent to your inbox.

No spam. Unsubscribe at any time. We respect your privacy.

Related Guides