Self-hosted email warmup network. Give it a set of domains; it generates realistic inboxes on each, sends natural-looking mail between them across domains, and auto-replies on human-like delays. Volume ramps over 28 days, then holds steady. The goal: build sender reputation on fresh domains and mailboxes before you send anything that matters.
No SaaS, no per-seat fees, no handing your reputation data to a third party. It's a small Python runtime backed by SQLite that you run on any always-on machine (or a cheap VPS / mini PC).
┌──────────────────────────────────────────────────────────────────┐
│ darkzwarmer │
│ │
│ config.yaml ─┐ │
│ .env ────────┤── main.py ── scheduler (APScheduler) │
│ │ │ │
│ ┌───────┴──────┼─────────────────────┐ │
│ ▼ ▼ ▼ │
│ sender.py replier.py resend_source.py │
│ (outbound (IMAP receive + (Resend webhook queue │
│ SMTP send) auto-reply) drain → same reply path) │
│ │ │ │ │
│ └──────── database.py (SQLite: inboxes, logs, ramp state) │
│ │
│ fleet.py → inbox generation content.py → message templates │
│ schedule_table.py → the 28-day volume ramp │
└──────────────────────────────────────────────────────────────────┘
Responsible use. This tool warms domains and mailboxes you own and control. It only ever emails its own network — it does not send to third parties. Use it to build legitimate sender reputation for opt-in mail that complies with CAN-SPAM, GDPR, and your provider's terms. Don't use it to facilitate spam. You are responsible for how you use it.
- A fleet of inboxes. For each domain you list, the tool generates N
inboxes named
firstname.lastname@domain(e.g.emma.johnson@…). - Cross-domain traffic only. An inbox on
domain-aonly ever emails inboxes on other domains. Same-domain pairs are never used — that mirrors how real mail flows and avoids self-referential patterns. - Spread sends. Each day, every inbox's quota of sends is scheduled at randomized times across a daytime window (default 07:00–19:00 local), with a 2–8 minute jitter between an inbox's consecutive sends.
- Human-delayed replies. When the network receives one of its own
messages, it waits a random 30 min – 4 h, then replies in-thread (proper
In-Reply-To/Referencesheaders). Only the network's own mail is answered; outsider mail is ignored. - The 28-day ramp. Per-inbox volume walks
5 → 35messages/day over 28 days, then holds at the steady-state value. Each inbox tracks its own day counter, so inboxes added later start at their own Day 1. - Restart-safe. All state lives in
warmup.db(SQLite). Crash or reboot mid-run and it picks up where it left off; in-flight replies are re-armed and a unique index prevents double-replies.
- One or more domains you own. Fresh domains are the whole point. Two domains is the practical minimum (cross-domain needs somewhere to send).
- An outbound SMTP provider to send through — Amazon SES, SMTP.com, Postmark, Mailgun, Brevo, SendGrid, etc. (provider-agnostic; see below).
- A way for each domain to receive mail, so the network can reply to itself. Two supported architectures — IMAP or Resend inbound — covered in detail below. You can mix both across domains.
- Python 3.11+ and an always-on machine to run it.
# 1. Install dependencies
python -m pip install -r requirements.txt
# 2. Configure
cp .env.example .env # then edit: SMTP_USERNAME / SMTP_PASSWORD
# edit config.yaml in place # your domains + smtp host
cp catchall_credentials.example.yaml catchall_credentials.yaml # IMAP domains only
# 3. One-time bootstrap — generates inboxes
python setup.py
# 4. Run the warmup (runs indefinitely)
python main.pyOn Windows PowerShell, swap cp for Copy-Item and load .env however you
prefer — main.py also auto-loads .env via python-dotenv.
darkzwarmer sends through whatever SMTP server you configure. Set the
transport in config.yaml:
smtp:
host: smtp.your-provider.example
port: 587
tls_mode: starttls # starttls | ssl | plainand the credentials in .env:
SMTP_USERNAME=your-smtp-username
SMTP_PASSWORD=your-smtp-password
Common providers:
| Provider | Host (example) | Port | tls_mode |
|---|---|---|---|
| Amazon SES | email-smtp.us-east-1.amazonaws.com |
587 | starttls |
| SMTP.com | send.smtp.com |
587 | starttls |
| Postmark | smtp.postmarkapp.com |
587 | starttls |
| Mailgun | smtp.mailgun.org |
587 | starttls |
| Brevo | smtp-relay.brevo.com |
587 | starttls |
Authenticate your sending domains at the provider regardless of how you receive. Add the SPF and DKIM records the provider gives you for each domain (and a DMARC record), so your warmup mail passes alignment. Unsigned warmup traffic teaches the wrong reputation.
Note on Amazon SES: SMTP credentials are not your AWS API keys — create them under SES → SMTP settings → Create SMTP credentials. A new SES account is in sandbox mode (can only send to verified addresses) until you request production access. Optionally, add an
aws:block toconfig.yamland installboto3to havesetup.pyprint SES domain-verification status.
The network must be able to receive its own mail to reply to it. Pick per domain:
| Option A — Mailbox host + IMAP | Option B — Resend inbound | |
|---|---|---|
| You need | A mailbox host with a catch-all | A domain + a Resend account + a tiny serverless webhook |
| Good when | You already host email, or want real mailboxes | You don't want to pay for / manage mailboxes |
| How replies arrive | Poll one catch-all mailbox over IMAP | Resend webhook → queue → local drain |
| Config | imap_host + imap_port per domain |
source: resend per domain |
| Extra setup | Catch-all forwarding | DNS + Resend webhook + Upstash + Vercel deploy |
The examples below use Namecheap for the domains (placeholder domain names
like example-a.com — substitute your own). Namecheap conveniently supports
both options: it sells the domains, offers Private Email hosting for
Option A, and lets you point DNS to Resend for Option B.
Use this when each domain has a real mailbox host. Works with Namecheap Private Email, Google Workspace, Zoho Mail, Fastmail, IONOS, Migadu — any host that exposes IMAP and supports a catch-all mailbox.
Each domain gets one catch-all mailbox (e.g. warmup@example-a.com) that
receives all mail addressed to any address on that domain. darkzwarmer logs
into that single mailbox per poll cycle and replies to the network's own
traffic. You don't create a real mailbox per generated inbox — the catch-all
collects everything.
- Buy the domain(s) at Namecheap.
- Add Private Email to each domain (Namecheap dashboard → the email product of your choice), or point the domain's MX records at whichever host you use. Apply the host's MX / SPF / DKIM records.
- Create one mailbox per domain named
warmup@yourdomain.tld. - Enable catch-all and route it to that mailbox:
- Namecheap Private Email: Mailbox → Settings → Catch-all → forward to
warmup@yourdomain.tld. - Google Workspace: Routing → Default routing → Add → catch-all → deliver
to
warmup@…. - Zoho: Mail Admin → Email Routing / Catch-all.
- Namecheap Private Email: Mailbox → Settings → Catch-all → forward to
- Find the IMAP host/port for your provider (e.g. Namecheap Private Email
mail.privateemail.com:993, Zohoimap.zoho.com:993, Workspaceimap.gmail.com:993). - Enable IMAP access on the mailbox. Many providers (Google especially) require an app password rather than the account password.
In config.yaml, list each IMAP domain with its host/port:
domains:
- name: example-a.com
imap_host: mail.privateemail.com
imap_port: 993
- name: example-b.com
imap_host: mail.privateemail.com
imap_port: 993In catchall_credentials.yaml (copied from the .example, gitignored),
put the catch-all password for each domain:
example-a.com:
imap_user: warmup@example-a.com # optional; defaults to warmup@<domain>
imap_password: "your-catchall-mailbox-password"
example-b.com:
imap_password: "your-catchall-mailbox-password"That's it — no entry is needed in .env for IMAP receiving.
Use this when you don't want to run mailboxes. The domain's DNS lives at Namecheap; Resend receives inbound mail and pushes a webhook; a tiny Vercel function drops the event into an Upstash Redis queue; darkzwarmer drains that queue on its normal poll cycle and replies through the same logic as the IMAP path.
Inbound mail ─▶ Resend (email.received) ─▶ Vercel webhook (webhook/)
│ LPUSH email_id
▼
Upstash Redis queue
▲
│ RPOP (poll cycle)
darkzwarmer ─┘ resend_source.py
└▶ fetch email via Resend API ▶ reply
Everything in webhook/ is the deployable serverless function. It's already
generic — no secrets in the repo; you supply them as environment variables.
-
Buy the domain(s) at Namecheap and keep DNS on Namecheap (BasicDNS / their nameservers).
-
Add the domain in Resend and follow Resend's verification. Resend shows you the exact records to add at Namecheap:
- Sending: SPF (
TXT) + DKIM (TXT/CNAME) records. - Receiving (inbound): an MX record pointing at Resend's inbound host (Resend gives you the precise value).
In Namecheap: Domain List → Manage → Advanced DNS → Add New Record for each one. (Tip: you can send from a subdomain like
mail.yourdomain.tldwhile receiving on the apex, or use the apex for both — follow what Resend shows you.) - Sending: SPF (
-
Create an Upstash Redis database (free tier is plenty). Copy its
UPSTASH_REDIS_REST_URLandUPSTASH_REDIS_REST_TOKEN. -
Deploy the webhook (from the
webhook/directory):cd webhook npm install vercel --prodSet these environment variables on the Vercel project (all environments):
Var Value RESEND_WEBHOOK_SECRETResend webhook signing secret ( whsec_…)UPSTASH_REDIS_REST_URLfrom Upstash UPSTASH_REDIS_REST_TOKENfrom Upstash -
Create the Resend webhook: point it at your deployed Vercel URL and subscribe to the
email.receivedevent. Resend signs requests (Svix); the function verifies the signature and enqueues only theemail_id. -
Give the local runtime its keys. In
.env:RESEND_API_KEY=re_... UPSTASH_REDIS_REST_URL=... UPSTASH_REDIS_REST_TOKEN=...
In config.yaml, mark these domains source: resend (no IMAP fields):
domains:
- name: example-d.com
source: resend
- name: example-e.com
source: resendOn each poll cycle, resend_source.py drains the queue, fetches each email's
headers from the Resend API, and runs it through the shared reply handler. If
the Resend/Upstash env vars are absent, the drain is simply a no-op — handy for
running IMAP-only without Resend configured.
| Key | Meaning |
|---|---|
domains[].name |
A domain to warm up |
domains[].imap_host / imap_port |
IMAP receiving (Option A) |
domains[].source: resend |
Resend receiving (Option B) |
smtp.host / port / tls_mode |
Outbound transport |
warmup.inboxes_per_domain |
Inboxes generated per domain at bootstrap |
warmup.send_window_start_hour / end_hour |
Daily local send window |
warmup.min/max_send_delay_seconds |
Jitter between an inbox's sends |
warmup.reply_min/max_delay_minutes |
Random reply delay band |
warmup.imap_poll_interval_minutes |
How often to poll for inbound |
warmup.steady_state_emails_per_day |
Per-inbox volume after day 28 |
warmup.max_sends_per_inbox_per_day |
Optional hard ceiling (null = off) |
wave_cohorts |
Optional automated fleet expansion (see below) |
| File | Purpose | Committed? |
|---|---|---|
config.yaml |
domains, SMTP, warmup tuning | yes (yours) |
.env |
SMTP_*, Resend/Upstash keys |
no (gitignored) |
catchall_credentials.yaml |
IMAP catch-all passwords | no (gitignored) |
warmup.db |
SQLite state | no (gitignored) |
python setup.py # one-time: generate inboxes (re-runnable; only adds new)
python main.py # start the runtime (Ctrl+C to stop)Two options ship in tools/:
# A) Task Scheduler — runs at logon, restarts on crash, disables sleep on AC
powershell -ExecutionPolicy Bypass -File tools\install.ps1
schtasks /Run /TN "Darkzwarmer Warmup" # start now
powershell -ExecutionPolicy Bypass -File tools\stop.ps1
powershell -ExecutionPolicy Bypass -File tools\uninstall.ps1
# B) Windows Service via NSSM — survives reboot with no one logged in
# (run from an ELEVATED PowerShell)
powershell -ExecutionPolicy Bypass -File tools\install_service_nssm.ps1Both auto-detect Python on PATH (override with $env:DARKZWARMER_PYTHON).
On Linux/macOS, run main.py under systemd, supervisord, pm2, or a
tmux/screen session — it just needs to stay up.
# Quick per-domain snapshot
python status.py
# Verify SMTP connectivity / auth
python tools/smtp_check.py # auth only
python tools/smtp_check.py --send you@you.com # auth + send a test message
# Poke at the DB directly
sqlite3 warmup.db
> SELECT inbox_email, current_day, emails_sent_today FROM warmup_state;
> SELECT COUNT(*), day_number FROM send_log GROUP BY day_number;Logs go to warmup.log (rotating). Tail it:
Get-Content warmup.log -Wait -Tail 50schedule_table.py holds the 28-day ramp (5 → 35 per inbox/day), then holds
steady. max_sends_per_inbox_per_day clamps the top of the ramp if you need to
keep total volume under a provider plan limit — rough monthly volume is:
messages/month ≈ inboxes × cap × 2 (reply doubling) × 30.4
wave_cohorts (optional) grows the fleet automatically: each cohort's domains
add step_per_domain inboxes every interval_days from an anchor_date up to
max_per_domain. New inboxes always enter at Day 1; existing inboxes are never
disturbed; it's idempotent and catch-up-safe across restarts. Delete the block
for a fixed fleet.
| Module | Responsibility |
|---|---|
main.py |
Entry point: load config + DB, start scheduler, park |
scheduler.py |
APScheduler jobs: daily roll, send planner, inbound poll, wave reconcile, orphan-reply recovery |
fleet.py |
Generate unique first.last@domain inboxes |
sender.py |
Outbound SMTP transport + single-send logic |
replier.py |
IMAP poll + the shared inbound→reply decision logic |
resend_source.py |
Drain the Resend/Upstash queue into that same logic |
content.py |
Message + reply templates with no-repeat tracking |
schedule_table.py |
The 28-day volume ramp |
database.py |
SQLite schema + helpers (inboxes, logs, state, dedup) |
webhook/ |
Vercel function: verify Resend webhook → enqueue to Upstash |
Tests live in tests/ (python -m pytest).
- IMAP login fails — most hosts need an app password, not the account
password. Check
imap_usermatches what the host expects (some want the full address). If blank, it defaults towarmup@<domain>. [IMAP-CONNECT-FAIL]every poll — wrong host/port, IMAP not enabled, or the catch-all isn't actually routing mail to the polled mailbox.- No replies on a Resend domain — confirm the MX record points at Resend,
the webhook is subscribed to
email.received,RESEND_WEBHOOK_SECRETmatches, and the local.envhas the Resend/Upstash keys (the drain no-ops without them). [SEND-FAIL]— usually the provider's rate/quota, or the sending domain isn't authenticated (SPF/DKIM) at the provider. Lowerinboxes_per_domain, setmax_sends_per_inbox_per_day, or raise your provider quota.- Nothing sends — did you run
python setup.py? Is it inside the 07:00–19:00 local window? Weekends are intentionally paused.
MIT. Provided "as is", without warranty. See the responsible-use note at the top — warm only domains and mailboxes you own, for mail that complies with applicable anti-spam law and your provider's terms.