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 ~$30/mo VPS.

PythonFlaskSQLitesmtplib / 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 →
Sample data, shown for demo purposes

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~$30/mo VPS
4 tools · 4 subscriptions · 4 points of failure · $800+/mo1 app · 1 server · ~$30/mo · you own everything

Comparison

How ShoutReach stacks up.

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

FeatureShoutReachInstantlySmartleadApolloLemlist
Monthly cost~$30/mo$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.

⏱️Hand-rolled scheduler

A threading.Thread inside the Flask process running an event-driven loop, _wake_event.wait(timeout=60), instead of a fixed sleep. No Redis, no Celery, no separate worker to manage. It's also why a "Run Now" button wakes the loop instantly instead of waiting out the interval, and why stop() joins cleanly on shutdown.

📨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 (worker-only)

Google Maps CAPTCHAs need a human to see and solve them, so the browser never launches on the server. The scraper runs only in a worker process on my own machine; requirements-worker.txt is split out from the server's requirements.txt and the Flask app doesn't import the scraper at all.

🔍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, runs the Flask app and scheduler only now, the scraper's browser lives on my own machine. ~$30/mo. 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
Scheduler thread
smtplib sender
imaplib reply checker
Scrape jobs
Job queue endpoint
Worker on my machine (Chrome)
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
A hand-rolled scheduler 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. A plain threading.Thread running an event-driven loop keeps the send queue and reply checks in-process, one deployable unit, no dependencies beyond the standard library. For a send interval measured in minutes and a daily cap of a few hundred emails, the simpler approach is the right one.
4
A job queue instead of a server-side scraper
The first version tried to run the scraper on the server, and it failed immediately: no X display to open a browser window, and playwright install chromium had never been run. Chasing that as a deploy problem missed the real issue: Google Maps serves CAPTCHAs that a human has to see and solve, no amount of stealth patching gets around that, it's an architectural constraint, not a detection problem. So the app became a job queue instead. Press Start in the cloud UI and the job gets queued; a worker process on my own machine claims it, opens Chrome locally where I can solve any CAPTCHA myself, and streams progress back over an endpoint that does triple duty, heartbeat, log upload, and control channel in the same round trip. The response side of that same call carries stop/resume flags back to the worker, which is what lets a Resume button on the cloud page unblock a CAPTCHA in a browser sitting on my desk. Completed leads get pushed back over a separate API-key-authenticated endpoint: the worker is a script with no session cookie, and browsers can't attach custom headers cross-origin, so those routes aren't reachable via CSRF.

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, so the contact is flagged and removed from the sequence. A 4.x.x code is a soft bounce, retried on the next send cycle.

🧬
Deduplicating leads without breaking sequences

Google Maps returns the same business multiple times with slightly different URLs: http://x.ca, https://www.x.ca/, http://x.ca/?utm_source=gmb. Comparing them as raw strings created a duplicate contact row per variant instead of catching the repeat.

Contacts dedupe on a canonical domain instead. When a site exposes multiple addresses, all are kept but only the best-ranked is marked sendable: personal beats role addresses (info@, office@) beats billing/careers/no-reply. Suppression lives in a dedicated duplicate_of column, not contact status, because the send query filters on status='active' and follow-up steps re-enter through that same query days later; suppressing via status would have silently cancelled steps 2 and 3 for anyone already mid-sequence. For the same reason, a contact mid-sequence always wins its domain regardless of ranking. A contact also can't be enrolled in two campaigns at once, so nobody gets two different pitches in the same window.

🗺️
Contact pages breaking on UTM-tagged URLs

Google Maps hands out website URLs with UTM tracking params already attached. Contact-page paths were being concatenated directly onto those, so …/?utm_campaign=gmb + /contact produced a broken URL that never resolved, and only the homepage ever loaded.

Strip the query params, then resolve every later URL against where the request actually landed, so www→apex and http→https redirects don't break path joining either. That was the biggest single bug in a broader pass, retries, following the site's own contact links instead of guessing at /contact, and reading mailto, JSON-LD and Cloudflare-obfuscated addresses. Together they took email extraction recall from 52% to 72% of businesses on a real dataset, with "site blocked" failures dropping from 7 to 0.


Testing & Security

Two audits, seven findings, all closed.

Five test suites, 103 checks: schema migrations, email extraction (offline), duplicate handling, the worker protocol end-to-end, and regression tests for the closed security findings. The SMTP send path itself is still verified manually.

Two external code audits so far. Seven findings closed in this rework, each pinned by a regression test so it can't quietly reopen. Findings are marked resolved in place, with the commit that closed them and how they were verified; retired audits move into a resolved/ folder in the repo rather than being deleted.

Three of the seven:

01
Mail credentials sent over unverified TLS
smtplib and imaplib default to CERT_NONE, so nothing was checking that the certificate presented by the mail server actually belonged to it. SMTP credentials were exposed to anyone sitting on the network path. Closed and pinned with a regression test.
02
One-click unsubscribe was advertised but not implemented
The List-Unsubscribe-Post header told Gmail it could unsubscribe with a POST request, but the route only accepted GET, so Gmail's one-click unsubscribe got a 405 back. Closed and pinned with a regression test.
03
Fresh installs crashed on a bad migration
A schema migration dropped a column that later code still expected. Harmless on a database that had already migrated forward, fatal on a fresh install running every migration from scratch. Closed and pinned with a regression test.

Deployment

Production setup, end to end.

☁️Server
  • GCP e2-medium, us-central1, Debian 12
  • 2 vCPU, 4GB RAM + 2GB swap
  • 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 SMTP send path
Schema migrations, email extraction, duplicate handling, and the worker protocol are covered by five test suites now, around 103 checks. The SMTP send path itself is still verified manually. I'd add integration tests against a local SMTP server (Mailpit is good for this) to close that last gap.