Live in production

A cold email platform
I built from scratch.

ShoutReach is a self-hosted outreach platform, sequences, reply detection, A/B testing, multi-inbox rotation, lead scraping, and AI copy review, all in one app running on a $5 VPS.

PythonFlaskSQLiteAPSchedulersmtplib / imaplibPlaywrightGCPnginxGitHub Actions
◆ HEXIV
ShoutReach
Dashboard
Campaigns
Contacts
Activity Log
Settings
Scraper
Database
? Help
Scheduler running
Dashboard
Overview across all campaigns
↺ Refresh
Total Contacts
3,847
Emails Sent
2,614
Replies
187
Reply Rate
7.2%
Sent Today
48
Bounced
31
Active Campaigns
View All →
CampaignStatusContactsSentReply RateDaily Limit
SaaS Founders Q2active1,2408938.4%50/day
Open →
E-commerce DTC Brandsactive9747616.8%40/day
Open →
Agency Outreach NYCactive6185127.0%30/day
Open →
Series A Startupspaused1,0154485.9%50/day
Open →

Demo

See it in action.


Background

Why I built this.

I joined Hexiv as the founding developer after a single phone call. The ask was simple: build an outreach system. I had no idea what outreach was.

The first version was a stack of third-party tools: expensive, rigid, and not ours. After losing about $1,000 on failed campaigns and months waiting on a work permit with savings draining, we had to get creative. I rebuilt the workflow manually and cut monthly expenses from $800 to around $100. It worked, but manual outreach at the volume we needed wasn't sustainable.

Then Python came up in a brainstorming session. I shrugged it off. Sounded like one of those things that works in theory. My co-founder pushed anyway. So I built the scraper. It worked better than expected, and more importantly, I could make it do exactly what we needed.

The only problem: my co-founder wasn't technical. A terminal script wasn't going to cut it. So I wrapped it in a UI. And as I built, I kept realizing I could add more: lead management, email sequences, campaign tracking, reply detection.

That's how ShoutReach came together. Not planned. Built out of necessity, one problem at a time.


The Problem

One app. Replaced a whole stack.

Running a cold outreach operation used to mean stitching together four or five separate tools, each with its own subscription, its own login, and its own point of failure. ShoutReach collapses all of it into one self-hosted app.

ToolWhat it didMonthly cost
Instantly (Premium)Email sequences, multi-inbox rotation~$97–358/mo
make.comAutomation glue between tools~$100/mo
PhantomBusterLead scraping from Google Maps, LinkedIn~$100/mo
AnyMailFinderEmail finding and validation~$100+/mo
ShoutReachAll of the above, self-hosted~$5/mo VPS
4 tools · 4 subscriptions · 4 points of failure · $800+/mo1 app · 1 server · $5/mo · you own everything

Comparison

How ShoutReach stacks up.

Pricing from public sources, 2025. ✓ = included  ✗ = not available  ~ = limited

FeatureShoutReachInstantlySmartleadApolloLemlist
Monthly cost~$5 VPS$37–$358$39–$174$49–$99$59–$99
Contacts limitUnlimitedCappedCappedCappedCapped
Custom SMTP / IMAP Any provider
Multi-inbox rotation
Campaign-wide variables
Timezone-aware sending Per campaign
OOO auto-reply filtering RFC 3834~~~~
MX email validation
Invalid email as sales lead(unique)
AI copy review BYOK, 3 providers~ Basic~ Basic
Live web scraping Google Maps~ B2B database~ Apollo DB~ B2B database
100% data ownership Your server Cloud Cloud Cloud Cloud

Tech Stack

What it's built with, and why.

🐍Python / Flask

Flask's minimal footprint meant I could keep the entire app as a single process, web server and background scheduler living together without orchestration overhead.

🗄️SQLite (WAL mode)

Single-user app, single server. WAL mode handles the concurrent reads from the scheduler thread without locking. No separate database process, no connection pooling, trivially backupable.

⏱️APScheduler

Runs as a background thread inside the Flask process. Handles the send queue and reply checks on a fixed interval, no Redis, no Celery, no separate worker to manage.

📨smtplib / imaplib

Standard library email clients. SMTP for sending with full header control (List-Unsubscribe, Message-ID), IMAP for polling replies and classifying bounces, no third-party email SDK needed.

🎭Playwright + Stealth

Google Maps scraper. playwright-stealth patches fingerprinting vectors that trigger CAPTCHAs. Runs locally only, headless servers get flagged; scraped CSVs are imported to the server manually.

🔍dnspython

MX record lookups on every imported email address. Validates that the domain can actually receive mail before a contact enters the sequence, and flags missing MX records as a sales signal.

🖥️Jinja2 + Vanilla JS

Server-rendered HTML shell, section-switched in the browser via vanilla JS. No frontend framework, the UI is simple enough that React would've been pure overhead.

☁️GCP Compute Engine

e2-medium in us-central1, enough CPU for Playwright runs, cheap at ~$30/mo on-demand or ~$10/mo committed. nginx in front, gunicorn behind, systemd keeping it alive.

⚙️GitHub Actions

Push to master → SSH into the server → pull latest → restart the systemd service. Simple, zero-cost CI/CD that keeps deploys from being a manual process.


Architecture

How it all fits together.

Browser
nginx (SSL termination, port 443)
gunicorn (1 worker, port 8000)
Flask Application
Web routes / API
Routes & API handlers
SQLite (WAL)
Background thread
APScheduler
smtplib sender
imaplib reply checker
1
Why SQLite instead of Postgres
This is a single-user app on a single server. Postgres would've added a separate process, a connection pool, and a more complex backup story, for no real benefit. SQLite in WAL mode handles concurrent reads from the scheduler thread and web handlers without locking, and the entire database is a single file I can copy to back up. The right tool for the actual scale.
2
Why exactly one gunicorn worker
The scheduler runs inside the Flask process as a background thread. If I spun up two workers, I'd have two schedulers running simultaneously, both scanning the send queue, both trying to send the same emails. One worker is intentional, not a limitation. The app doesn't need to handle concurrent web requests at a scale that would require more.
3
APScheduler over Celery + Redis
A job queue like Celery requires Redis as a broker and a separate worker process, that's two more things to run, monitor, and restart on the server. APScheduler as an in-process thread keeps the entire app as one deployable unit. For a send interval of 5 minutes and a daily cap of a few hundred emails, the simpler approach is the right one.

Engineering Challenges

The problems that actually took time to solve.

↩️
Reply detection that actually works

When someone replies, the sequence should stop. Sounds simple, but the naive approach of matching the incoming email's "From" address against your contact list breaks constantly. Replies come from aliases, mobile apps with different addresses, or auto-forwarders.

Match by In-Reply-To header, not email address. Every outbound email gets a unique Message-ID. When a reply comes in, it references that ID. That's the source of truth, immune to address variations.

🔗
Tamper-proof unsubscribe links

Unsubscribe links need to be unforgeable. A naive link like /unsubscribe?id=123 lets anyone unsubscribe anyone else by guessing IDs, and storing a token in the database means a DB lookup on every click.

Sign the contact ID with HMAC-SHA256 using a server secret. The link becomes /unsubscribe?id=123&sig=…. On click, recompute the signature and compare — no database lookup, no token table, and the link is mathematically unforgeable without the secret key.

🏖️
Filtering out-of-office auto-replies

If someone's on vacation, their email server sends back an auto-reply. That should not count as a real reply and should not stop the sequence. Keyword-matching the subject line misses too many cases and catches false positives.

Check for the Auto-Submitted: auto-replied header first — that's the RFC 3834 standard for automated messages. Fall back to subject-line keyword matching only as a secondary signal.

⚠️
Classifying email bounces

When an email bounces, the mail server sends back a DSN (Delivery Status Notification). You need to tell the difference between a permanent failure (bad address, stop trying) and a temporary one (mailbox full, maybe retry).

Parse the SMTP status codes out of the DSN body. A 5.x.x code is a hard bounce — the address is permanently invalid, contact is flagged and removed from the sequence. A 4.x.x code is a soft bounce, retried on the next send cycle.


Deployment

Production setup, end to end.

☁️Server
  • GCP e2-medium, us-central1, Debian 12
  • 2 vCPU, 4GB RAM + 2GB swap (Playwright needs headroom)
  • gunicorn with 1 worker, intentional, keeps scheduler single-instance
  • systemd service with auto-restart on crash
🌐Networking & SSL
  • nginx reverse proxy, SSL termination on 443, forwards to gunicorn on 8000
  • Let's Encrypt certificate via certbot
  • Auto-renews on a cron before expiry, zero manual cert management
  • HTTP → HTTPS redirect enforced at nginx level
Deploy pipeline: GitHub Actions on push to master
git push master
trigger
Actions runner
GitHub-hosted
SSH into GCP
via secret key
git pull origin master
on server
systemctl restart shoutreach
zero manual steps

Reflection

What I'd do differently.

01
Postgres if this were multi-tenant
SQLite was the right call for a single-user self-hosted app, but if ShoutReach needed to support multiple teams or users on shared infrastructure, I'd switch to Postgres. The WAL mode concurrency ceiling would become a real constraint, and connection pooling across workers would matter.
02
A dedicated job queue at higher volume
The scheduler is tied to the web process, restarting the app for a deploy also interrupts any in-flight send cycles. At higher send volumes, I'd move to a proper job queue (Redis + RQ or similar) with a standalone worker that survives web restarts independently.
03
Automated tests for the email pipeline
The sending, reply detection, and bounce classification logic is currently verified manually. I'd add integration tests against a local SMTP server (Mailpit is good for this) to catch regressions automatically, especially around the edge cases in reply matching and OOO detection that are easy to break silently.