Stop overnight and weekend warmup mail by aligning defaults with the live quiet-hours policy.
Hearth
A small, consent-only email warmup service for levkine.ca. It sends
real mail from @levkine.ca (and between those mailboxes), and gets
replies back from your own personal inboxes (auto) plus a fixed allow-list of
people who've agreed to help (manual). That engagement is what improves
sender reputation with Gmail/Outlook/etc. Personal Gmail/Outlook accounts
never originate new warmup threads — they only receive and reply.
What this is (and isn't)
- Is: a private warmup tool, same idea as commercial products like Mailwarm/Lemwarm — send/reply traffic only between mailboxes you control or people who opted in, at a low, gradually-ramping volume.
- Isn't: a way to email arbitrary/unknown third parties, or to fake
engagement to deceive spam filters at scale. There is no code path in this
repo that can send to, or read mail from, an address that isn't explicitly
listed in
hearth.yml.
Guardrails
- Hard allow-list (
hearth.yml) — the only source of recipients.Config.is_known_email()does an exact (case-insensitive) match; nothing is ever derived, scraped, or guessed.mailer.send()re-validates this itself, independent of any caller. - Two participant tiers:
full_duplex— your own mailboxes. Hearth holds IMAP+SMTP credentials (via aHEARTH_PASSWORD_<KEY>env var, sourced from your secrets manager — never hardcoded), auto-replies to incoming pool mail, and un-spams anything a provider misfiled. New threads only originate from addresses whose domain is inHEARTH_WARMUP_SENDER_DOMAINS(defaultlevkine.ca). A personal Gmail kept asfull_duplexcan receive + auto-reply back to levkine, but will not start threads.send_only— a consenting friend/coworker. Hearth only ever sends to their address from a warmup-domain mailbox. It never holds credentials for them — they reply or mark-not-spam manually.
- Volume caps + ramp curve — starts at
HEARTH_DAILY_CAP_STARTmessages/day total, steps up byHEARTH_RAMP_STEP_PER_DAY/day to a hardHEARTH_DAILY_CAP_MAX, only duringHEARTH_BUSINESS_HOURS_START–_END(optionally weekdays only), spread with jitter rather than bursted. Auto-replies honor the same window: a reply that comes due overnight stays queued and goes out once the window opens. - Kill switch + dry run —
HEARTH_ENABLED=falsestops everything immediately;--dry-runlogs every intended action without sending or modifying any mailbox. - No bulk/marketing signatures — short, plain-text, varied content; no tracking pixels, no unsubscribe footers, no link-heavy bodies.
Quick start
cp hearth.yml.example hearth.yml # edit: your mailboxes + consenting participants
cp env.example .env # edit: HEARTH_PASSWORD_<KEY> per full_duplex mailbox
docker compose up -d --build
docker compose logs -f
Dry run locally without Docker:
python3 -m venv .venv && .venv/bin/pip install -r requirements-dev.txt
HEARTH_CONFIG=./hearth.yml HEARTH_DB_PATH=./data/hearth.db \
HEARTH_PASSWORD_MAILBOX_A=... .venv/bin/python -m hearth.main --once --dry-run
Run the test suite (includes the allow-list guardrail tests):
.venv/bin/python -m pytest -q
Adding / removing a participant
- Your own mailbox (
full_duplex): add an entry tohearth.ymlwithtier: full_duplex, IMAP/SMTP host+port,username, and apassword_envname; set that env var (or vault key, see theansiblerepo'sdocs/guides/hearth-deploy.md) to an app password. - A consenting friend (
send_only): send them docs/intro-email.md first (what to expect + reply ideas), and only add them tohearth.ymlonce they've actually said yes. Addtier: send_onlywith just theiremail(and optionaldisplay_name). No credentials, ever. - Removing someone: delete their block from
hearth.ymland restart — Hearth re-reads the allow-list on every start, and the code has no cache of "recipients I've seen before" outside of what's in that file. - Testing with a subset first: add a top-level
active: [key1, key2]list tohearth.ymlto restrict which already-defined participants are actually live, without deleting the others' config. Omitactiveentirely to make everyone defined in the file active (the default). This is how you'd dial in timing/content with just your own two mailboxes before turning on the full family pool — seedocs/intro-email.mdfor what to tell people once you do.
Architecture
| Module | Responsibility |
|---|---|
hearth/config.py |
Loads + validates hearth.yml; the only source of recipients |
hearth/settings.py |
Env-based runtime settings (caps, ramp, kill switch, dry-run) |
hearth/store.py |
SQLite: daily counters, ramp start date, message/thread history, reply queue |
hearth/composer.py |
Message content (template bank, optional Ollama/Open WebUI variety) |
hearth/mailer.py |
SMTP send, with its own allow-list re-check |
hearth/watcher.py |
IMAP poll for full_duplex mailboxes: detect pool mail, queue delayed replies (sent only in business hours), un-spam |
hearth/scheduler.py |
Ramp-curve + business-hours + cap logic for starting new threads |
hearth/main.py |
CLI entrypoint (--loop, --once, --dry-run, --healthcheck) |
Deployment
See the ansible homelab repo's docs/guides/hearth-deploy.md for how this
fits into the rest of the levkine.ca mail setup (Vault secrets convention,
target host, monitoring notes). deploy-hearth.sh in this repo handles the
actual rsync + docker compose up once a target host is chosen.