We cancelled SendGrid on a Tuesday. The cancellation took 30 seconds. The replacement took an afternoon. The monthly bill went from $89 to $2.
This isn't a story about hating SaaS. SendGrid is a solid product. We outgrew it in reverse: we were paying for 50,000 sends a month and using maybe 400. The feature set we actually used was SMTP relay, an HTML template, and a rough sense of whether emails were being opened. None of those require a $89/month contract. Python's standard library handles two of three, and a 1x1 tracking pixel handles the third.
The whole thing is 25 lines. Here's what we built and how it works.
What We Actually Needed
Before replacing any tool, it's worth writing down what you're actually using. Our real requirements were four things.
SMTP relay. We needed to send email from our domain without getting flagged as spam. We were already paying for Namecheap Private Email at $2/month per mailbox. That package includes an SMTP relay. We'd been paying for it and routing around it to use SendGrid. Stopping SendGrid meant routing through the relay we already owned.
A simple template system. Our emails are plain by design. Subject line, two short paragraphs, a link, a sign-off. We don't use drag-and-drop builders. We don't need them. A Jinja2 template renders this in a function call. Jinja2 is installed on every machine that runs any modern Python project. It's not a new dependency.
Reply detection. We needed to know when someone replied to an outreach email so we could route it to the right follow-up. SendGrid doesn't actually solve this. You need a separate inbox polling system regardless. We were already paying $19/month for Mailparser to pull reply signals from our inbox. Eight lines of IMAP polling replaced it.
Rate limiting. Our provider caps sends at 100 per hour. We needed to respect that. time.sleep() is not a sophisticated solution. It is, however, a correct one when your volume is 200-500 sends per week and your window is the whole week.
That's the full list. No deliverability dashboard, no click-map heatmaps, no suppression list UI. If you need those things, keep SendGrid. At our volume, we didn't.
The 25-Line SMTP Wrapper
Here's the complete function we use in production:
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
def send_email(to_addr, subject, body_html, body_text=None):
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = "hello@yourdomain.com"
msg["To"] = to_addr
if body_text:
msg.attach(MIMEText(body_text, "plain"))
msg.attach(MIMEText(body_html, "html"))
with smtplib.SMTP_SSL("mail.privateemail.com", 465) as s:
s.login("hello@yourdomain.com", SMTP_PASSWORD)
s.sendmail("hello@yourdomain.com", to_addr, msg.as_string())
That's it. No API key management, no SDK import, no request object. The email.mime module handles message construction. smtplib handles the connection. Your provider handles delivery.
Four things in this code are worth understanding before you copy it into production.
The SMTP_SSL vs STARTTLS choice (and why the wrong one silently fails)
There are two ways to establish a secure SMTP connection. SMTP_SSL opens an encrypted connection immediately on port 465. STARTTLS opens a plain connection on port 587, then upgrades it to encrypted. The wrong one doesn't throw an obvious error. It either hangs, times out, or connects and then rejects your login credentials with a cryptic response code.
Namecheap Private Email uses port 465 with SMTP_SSL. If you switch to port 587 and try STARTTLS, you'll get a connection that looks like it works but drops on s.login(). The fix is the code above: SMTP_SSL on 465. Check your provider's documentation for which port and protocol they expect. Most budget email hosts publish this in a one-page setup guide.
Namecheap Private Email rate limit: 100 sends/hour, not 1,000
SendGrid's documentation talks about sends per day. Namecheap Private Email talks about sends per hour. The limit is 100. That's not per account, it's per mailbox. If you're sending 400 emails a week, that's manageable: run your send loop with a 40-second sleep between sends and you'll clear the hour limit with room to spare.
If you try to blast 400 emails in one sitting without rate limiting, your mailbox gets temporarily throttled and you'll see 421 or 450 responses from the SMTP server. You won't lose the emails if you handle the exception, but you will need to retry. Build the rate limit in from the start. It's one line: time.sleep(36) between sends (100 per hour means one send per 36 seconds at maximum throughput).
Reply detection: 8 lines of IMAP polling that replaced $19/month Mailparser
If you're sending outreach, you need to know when someone replies. Here's the IMAP poller we run every 15 minutes:
import imaplib
import email
def check_replies(host, user, password, folder="INBOX"):
with imaplib.IMAP4_SSL(host) as m:
m.login(user, password)
m.select(folder)
_, data = m.search(None, "UNSEEN")
for num in data[0].split():
_, msg_data = m.fetch(num, "(RFC822)")
msg = email.message_from_bytes(msg_data[0][1])
yield msg["From"], msg["Subject"], msg.get_payload(decode=True)
This yields the sender, subject, and body of every unread message. Pipe it into your CRM write function. Mark messages as read after processing. The whole reply-detection pipeline is 20 lines including the CRM write. Mailparser cost $19/month to do exactly this via a web interface. The web interface was solving a complexity problem that didn't exist.
For writing outreach copy that actually gets replies rather than spam flags, see our post on outreach copy that doesn't end up in spam. A clean send infrastructure means nothing if the content is triggering filters.
The SPF record you need before your first send
Before you send a single email from your domain via a third-party SMTP relay, you need an SPF record authorizing that relay. Without it, receiving servers see an email claiming to be from your domain but sent by an IP address they don't recognize. That's a spam signal.
For Namecheap Private Email, the SPF record looks like this:
v=spf1 include:spf.privateemail.com ~all
Add it as a TXT record on your domain. DNS propagation takes up to 48 hours. Do this first, before you write any code. Also set up DKIM. Namecheap Private Email provides DKIM keys in their email settings panel. Copy the values, add them as TXT records, wait for propagation. Without SPF and DKIM, your deliverability will be bad regardless of how clean your code is.
What SendGrid Has That This Doesn't
This section exists because the honest answer matters. We're not claiming Python stdlib is a full SendGrid replacement for every use case. It isn't.
SendGrid handles bounce processing automatically. When an email bounces, SendGrid suppresses that address from future sends so you don't keep hitting a dead mailbox. With our SMTP setup, we get bounce notifications as SMTP error responses (550, 551) or NDR emails. We log them manually and add them to a CSV we check before each send. At 200-500 sends per week, that's a 5-minute task. At 10,000 sends per day, it's a full-time job. Use SendGrid.
SendGrid has a global unsubscribe list with legal compliance tooling. Our unsubscribe mechanism is a reply-to address. Anyone who replies "unsubscribe" gets added to a manually maintained list. This works at our scale. It would be a compliance liability at 50,000 sends.
Click tracking is something SendGrid wraps transparently. Every link in your email gets rewritten through their tracker. We use a 1x1 pixel for open tracking and don't track clicks at all. If click data is load-bearing for your business decisions, that's a genuine reason to keep SendGrid.
The summary: at 200-500 sends per week, these are non-issues. If you're sending 10k per day, use SendGrid. If you're paying $89/month to send 400 emails, do the math.
The Economics, Stated Plainly
SendGrid Essentials: $19.95/month for 50,000 sends. We were on a higher tier at $89/month because of some feature we'd enabled and forgotten about. Namecheap Private Email: $2.08/month for the mailbox we were already paying for. Python's smtplib: $0. Jinja2: $0.
Monthly savings: $87. Annual savings: $1,044. Setup time: one afternoon. The math isn't close.
If your volume grows past 1,000 sends per day, revisit. At that point the manual bounce management and unsubscribe tooling becomes real work and you'll want an ESP's infrastructure. But "revisit when you need to" is a better default than "pay for capacity you'll never use."