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

  1. 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.
  2. Two participant tiers:
    • full_duplex — your own mailboxes. Hearth holds IMAP+SMTP credentials (via a HEARTH_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 in HEARTH_WARMUP_SENDER_DOMAINS (default levkine.ca). A personal Gmail kept as full_duplex can 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.
  3. Volume caps + ramp curve — starts at HEARTH_DAILY_CAP_START messages/day total, steps up by HEARTH_RAMP_STEP_PER_DAY/day to a hard HEARTH_DAILY_CAP_MAX, only during HEARTH_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.
  4. Kill switch + dry runHEARTH_ENABLED=false stops everything immediately; --dry-run logs every intended action without sending or modifying any mailbox.
  5. 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 to hearth.yml with tier: full_duplex, IMAP/SMTP host+port, username, and a password_env name; set that env var (or vault key, see the ansible repo's docs/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 to hearth.yml once they've actually said yes. Add tier: send_only with just their email (and optional display_name). No credentials, ever.
  • Removing someone: delete their block from hearth.yml and 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 to hearth.yml to restrict which already-defined participants are actually live, without deleting the others' config. Omit active entirely 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 — see docs/intro-email.md for 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.

Description
Consent-only email warmup for levkine.ca — allow-listed send/reply, no cold outreach
Readme 107 KiB
Languages
Python 92%
Shell 7.4%
Dockerfile 0.6%